CI / test (push) Successful in 4s
This ensures map features remain visible in the MapLibre demo even when properties.assets is empty. Made-with: Cursor
672 lines
21 KiB
JavaScript
672 lines
21 KiB
JavaScript
import * as THREE from "./vendor/three/three.module.js";
|
|
import { GLTFLoader } from "./vendor/three/GLTFLoader.js";
|
|
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
|
|
|
|
class BrowserStorage {
|
|
getItem(key) {
|
|
return localStorage.getItem(key);
|
|
}
|
|
setItem(key, value) {
|
|
localStorage.setItem(key, value);
|
|
}
|
|
removeItem(key) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
|
|
const statusEl = document.getElementById("status");
|
|
const apiBaseEl = document.getElementById("apiBase");
|
|
const publicKeyPreviewEl = document.getElementById("publicKeyPreview");
|
|
const collectionInfoEl = document.getElementById("collectionInfo");
|
|
const collectionNameEl = document.getElementById("collectionName");
|
|
const assetFileEl = document.getElementById("assetFile");
|
|
const assetNameEl = document.getElementById("assetName");
|
|
const assetDescEl = document.getElementById("assetDesc");
|
|
const assetsListEl = document.getElementById("assetsList");
|
|
|
|
let client = new GeoApiClient(apiBaseEl.value.trim(), new BrowserStorage());
|
|
let keys = null;
|
|
let accessToken = "";
|
|
let collectionId = "";
|
|
let selectedLngLat = null;
|
|
let pendingMarker = null;
|
|
|
|
let map;
|
|
let threeLayer;
|
|
let threeScene;
|
|
let threeRenderer;
|
|
let threeCamera;
|
|
const featureMeshes = new Map();
|
|
const modelTemplateCache = new Map();
|
|
const gltfLoader = new GLTFLoader();
|
|
const ownFeatureMarkers = new Map();
|
|
const ownFeatureCoords = new Map();
|
|
let renderCycle = 0;
|
|
|
|
function setStatus(message) {
|
|
statusEl.textContent = message;
|
|
}
|
|
|
|
function currentApiBase() {
|
|
return apiBaseEl.value.trim().replace(/\/+$/g, "");
|
|
}
|
|
|
|
function shouldUseHostedDefault(savedBase) {
|
|
const host = window.location.hostname.toLowerCase();
|
|
if (host === "localhost" || host === "127.0.0.1") return false;
|
|
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(savedBase || "");
|
|
}
|
|
|
|
function extFromFilename(name) {
|
|
const idx = name.lastIndexOf(".");
|
|
if (idx <= 0) return "";
|
|
return name.slice(idx + 1).toLowerCase();
|
|
}
|
|
|
|
function normalizeExt(value) {
|
|
const text = String(value || "").trim().toLowerCase();
|
|
if (!text) return "";
|
|
return text.includes(".") ? extFromFilename(text) : text;
|
|
}
|
|
|
|
function kindFromExt(ext) {
|
|
return ext === "gltf" || ext === "glb" ? "3d" : "image";
|
|
}
|
|
|
|
async function sha256Hex(file) {
|
|
const buffer = await file.arrayBuffer();
|
|
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
|
return Array.from(new Uint8Array(digest))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
function setClientBase(baseUrl) {
|
|
const normalized = baseUrl.trim().replace(/\/+$/g, "");
|
|
client = new GeoApiClient(normalized, new BrowserStorage());
|
|
if (accessToken) client.setAccessToken(accessToken);
|
|
localStorage.setItem("geo_api_base", normalized);
|
|
setStatus(`API base updated: ${normalized}`);
|
|
}
|
|
|
|
function buildRasterStyle() {
|
|
return {
|
|
version: 8,
|
|
sources: {
|
|
"osm-raster": {
|
|
type: "raster",
|
|
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
|
|
tileSize: 256,
|
|
attribution: "© OpenStreetMap contributors",
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: "osm-raster-layer",
|
|
type: "raster",
|
|
source: "osm-raster",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildMapShareLink(feature, asset) {
|
|
const coords = feature?.geometry?.coordinates;
|
|
if (!Array.isArray(coords) || coords.length < 2) {
|
|
return client.resolveRelativeLink(asset.link);
|
|
}
|
|
const url = new URL(window.location.origin + window.location.pathname);
|
|
url.searchParams.set("shared", "1");
|
|
url.searchParams.set("lng", String(coords[0]));
|
|
url.searchParams.set("lat", String(coords[1]));
|
|
url.searchParams.set("kind", asset.kind || "3d");
|
|
url.searchParams.set("public", asset.isPublic ? "1" : "0");
|
|
url.searchParams.set("assetId", asset.id);
|
|
url.searchParams.set("assetLink", client.resolveRelativeLink(asset.link));
|
|
return url.toString();
|
|
}
|
|
|
|
function extFromLink(link) {
|
|
if (!link) return "";
|
|
try {
|
|
const url = new URL(link, `${currentApiBase()}/`);
|
|
return extFromFilename(url.pathname);
|
|
} catch {
|
|
return extFromFilename(link.split("?")[0]);
|
|
}
|
|
}
|
|
|
|
function is3DAsset(asset) {
|
|
if (!asset) return false;
|
|
const kind = String(asset.kind || "").toLowerCase();
|
|
const ext = normalizeExt(asset.ext) || extFromLink(asset.link);
|
|
return kind === "3d" && ext === "glb";
|
|
}
|
|
|
|
async function renderSharedAssetFromQuery() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("shared") !== "1") return;
|
|
const lng = Number(params.get("lng"));
|
|
const lat = Number(params.get("lat"));
|
|
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return;
|
|
const kind = params.get("kind") === "image" ? "image" : "3d";
|
|
const isPublic = params.get("public") !== "0";
|
|
const assetId = params.get("assetId") || "shared-asset";
|
|
const assetLink = params.get("assetLink") || "";
|
|
|
|
const loadedModel = await addObjectMeshFromAsset(
|
|
`shared-${assetId}`,
|
|
lng,
|
|
lat,
|
|
{
|
|
id: assetId,
|
|
kind,
|
|
ext: extFromLink(assetLink) || (kind === "3d" ? "glb" : "png"),
|
|
isPublic,
|
|
link: assetLink,
|
|
},
|
|
renderCycle
|
|
);
|
|
if (pendingMarker) pendingMarker.remove();
|
|
pendingMarker = new maplibregl.Marker({ color: "#f59e0b" }).setLngLat({ lng, lat }).addTo(map);
|
|
map.easeTo({ center: [lng, lat], zoom: Math.max(map.getZoom(), 14), pitch: 55, bearing: -15 });
|
|
setStatus(loadedModel ? "Shared 3D model loaded on map." : "Shared object loaded with fallback shape.");
|
|
}
|
|
|
|
function createThreeLayer() {
|
|
function syncRendererSize(m) {
|
|
if (!threeRenderer) return;
|
|
const canvas = m.getCanvas();
|
|
if (!canvas) return;
|
|
const width = canvas.clientWidth || canvas.width || 0;
|
|
const height = canvas.clientHeight || canvas.height || 0;
|
|
if (width <= 0 || height <= 0) return;
|
|
threeRenderer.setPixelRatio(window.devicePixelRatio || 1);
|
|
threeRenderer.setSize(width, height, false);
|
|
}
|
|
|
|
return {
|
|
id: "threejs-custom-layer",
|
|
type: "custom",
|
|
renderingMode: "3d",
|
|
onAdd(m, gl) {
|
|
threeCamera = new THREE.Camera();
|
|
threeScene = new THREE.Scene();
|
|
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
|
|
const directional = new THREE.DirectionalLight(0xffffff, 0.7);
|
|
directional.position.set(0, -70, 100).normalize();
|
|
threeScene.add(ambient);
|
|
threeScene.add(directional);
|
|
|
|
threeRenderer = new THREE.WebGLRenderer({
|
|
canvas: m.getCanvas(),
|
|
context: gl,
|
|
antialias: true,
|
|
});
|
|
threeRenderer.autoClear = false;
|
|
syncRendererSize(m);
|
|
m.on("resize", () => syncRendererSize(m));
|
|
},
|
|
render(gl, matrix) {
|
|
const canvas = map.getCanvas();
|
|
if (!canvas || canvas.width <= 0 || canvas.height <= 0 || gl.drawingBufferWidth <= 0 || gl.drawingBufferHeight <= 0) {
|
|
return;
|
|
}
|
|
const m = new THREE.Matrix4().fromArray(matrix);
|
|
threeCamera.projectionMatrix = m;
|
|
syncRendererSize(map);
|
|
threeRenderer.resetState();
|
|
threeRenderer.render(threeScene, threeCamera);
|
|
map.triggerRepaint();
|
|
},
|
|
};
|
|
}
|
|
|
|
function disposeMaterial(material) {
|
|
if (!material) return;
|
|
for (const value of Object.values(material)) {
|
|
if (value && value.isTexture) value.dispose();
|
|
}
|
|
material.dispose();
|
|
}
|
|
|
|
function disposeObject3D(object) {
|
|
if (!object) return;
|
|
object.traverse((node) => {
|
|
if (!node.isMesh) return;
|
|
if (node.geometry) node.geometry.dispose();
|
|
if (node.material) {
|
|
if (Array.isArray(node.material)) {
|
|
for (const material of node.material) disposeMaterial(material);
|
|
} else {
|
|
disposeMaterial(node.material);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearFeatureMeshes() {
|
|
for (const object of featureMeshes.values()) {
|
|
threeScene.remove(object);
|
|
disposeObject3D(object);
|
|
}
|
|
featureMeshes.clear();
|
|
}
|
|
|
|
function clearOwnFeatureMarkers() {
|
|
for (const marker of ownFeatureMarkers.values()) {
|
|
marker.remove();
|
|
}
|
|
ownFeatureMarkers.clear();
|
|
ownFeatureCoords.clear();
|
|
}
|
|
|
|
function positionObjectOnMap(object, lng, lat, scaleMeters) {
|
|
const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0);
|
|
const meters = merc.meterInMercatorCoordinateUnits();
|
|
object.position.set(merc.x, merc.y, merc.z);
|
|
object.scale.setScalar(meters * scaleMeters);
|
|
}
|
|
|
|
function addFallbackMesh(featureId, lng, lat, isPublic, kind) {
|
|
const is3D = kind === "3d";
|
|
const geometry = is3D ? new THREE.BoxGeometry(1.2, 1.2, 2.2) : new THREE.PlaneGeometry(1.8, 1.8);
|
|
const color = isPublic ? 0x44dd88 : 0xdd5566;
|
|
const material = new THREE.MeshStandardMaterial({ color, transparent: true, opacity: 0.92 });
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
positionObjectOnMap(mesh, lng, lat, is3D ? 18 : 24);
|
|
if (!is3D) {
|
|
mesh.rotation.x = Math.PI / 2;
|
|
}
|
|
threeScene.add(mesh);
|
|
featureMeshes.set(featureId, mesh);
|
|
map.triggerRepaint();
|
|
}
|
|
|
|
function prepareModelRoot(modelRoot) {
|
|
const box = new THREE.Box3().setFromObject(modelRoot);
|
|
if (box.isEmpty()) return;
|
|
const center = new THREE.Vector3();
|
|
const size = new THREE.Vector3();
|
|
box.getCenter(center);
|
|
box.getSize(size);
|
|
modelRoot.position.x -= center.x;
|
|
modelRoot.position.y -= center.y;
|
|
modelRoot.position.z -= box.min.z;
|
|
modelRoot.userData.baseSize = Math.max(size.x, size.y, size.z) || 1;
|
|
}
|
|
|
|
function cloneModelTemplate(modelRoot) {
|
|
const clone = modelRoot.clone(true);
|
|
clone.traverse((node) => {
|
|
if (!node.isMesh) return;
|
|
node.castShadow = true;
|
|
node.receiveShadow = true;
|
|
});
|
|
return clone;
|
|
}
|
|
|
|
async function loadModelTemplate(modelURL) {
|
|
const cached = modelTemplateCache.get(modelURL);
|
|
if (cached) return cached;
|
|
|
|
const pending = new Promise((resolve, reject) => {
|
|
gltfLoader.setRequestHeader(accessToken ? { Authorization: `Bearer ${accessToken}` } : {});
|
|
gltfLoader.load(
|
|
modelURL,
|
|
(gltf) => {
|
|
const root = gltf.scene || gltf.scenes?.[0];
|
|
if (!root) {
|
|
reject(new Error("GLTF file has no scene graph."));
|
|
return;
|
|
}
|
|
prepareModelRoot(root);
|
|
resolve(root);
|
|
},
|
|
undefined,
|
|
reject
|
|
);
|
|
});
|
|
|
|
modelTemplateCache.set(modelURL, pending);
|
|
try {
|
|
return await pending;
|
|
} catch (error) {
|
|
modelTemplateCache.delete(modelURL);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function addObjectMeshFromAsset(featureId, lng, lat, asset, cycleID) {
|
|
if (!asset) {
|
|
addFallbackMesh(featureId, lng, lat, true, "3d");
|
|
return false;
|
|
}
|
|
|
|
if (!is3DAsset(asset) || !asset.link) {
|
|
const ext = normalizeExt(asset.ext) || extFromLink(asset.link);
|
|
if (String(asset.kind || "").toLowerCase() === "3d" && ext === "gltf") {
|
|
console.warn(
|
|
`Feature ${featureId} uses .gltf. Map rendering supports self-contained .glb via /download; using fallback shape.`
|
|
);
|
|
}
|
|
addFallbackMesh(featureId, lng, lat, Boolean(asset.isPublic), asset.kind || "3d");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const modelURL = client.resolveRelativeLink(asset.link);
|
|
const template = await loadModelTemplate(modelURL);
|
|
if (cycleID !== renderCycle) return false;
|
|
|
|
const modelRoot = cloneModelTemplate(template);
|
|
const baseSize = Number(template.userData.baseSize) || 1;
|
|
positionObjectOnMap(modelRoot, lng, lat, 12 / baseSize);
|
|
threeScene.add(modelRoot);
|
|
featureMeshes.set(featureId, modelRoot);
|
|
map.triggerRepaint();
|
|
return true;
|
|
} catch (error) {
|
|
console.warn(`Failed to load model for feature ${featureId}:`, error);
|
|
addFallbackMesh(featureId, lng, lat, Boolean(asset.isPublic), asset.kind || "3d");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function moveOwnFeature(featureID, lng, lat) {
|
|
const existing = ownFeatureCoords.get(featureID);
|
|
const alt = Array.isArray(existing) && existing.length >= 3 ? existing[2] : 0;
|
|
const res = await fetch(`${currentApiBase()}/v1/features/${featureID}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
body: JSON.stringify({
|
|
geometry: { type: "Point", coordinates: [lng, lat, alt] },
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const maybeJson = await res.json().catch(() => ({}));
|
|
throw new Error(maybeJson.error || `Failed to move feature (${res.status})`);
|
|
}
|
|
}
|
|
|
|
function addOwnFeatureMarker(featureID, lng, lat) {
|
|
const marker = new maplibregl.Marker({ color: "#2563eb", draggable: true }).setLngLat({ lng, lat }).addTo(map);
|
|
marker.on("dragend", async () => {
|
|
const p = marker.getLngLat();
|
|
try {
|
|
await moveOwnFeature(featureID, p.lng, p.lat);
|
|
await refreshFeatures();
|
|
setStatus(`Feature ${featureID} moved.`);
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
});
|
|
ownFeatureMarkers.set(featureID, marker);
|
|
}
|
|
|
|
async function ensureKeys() {
|
|
keys = await client.ensureKeysInStorage();
|
|
publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`;
|
|
}
|
|
|
|
async function register() {
|
|
if (!keys) await ensureKeys();
|
|
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
|
}
|
|
|
|
async function login() {
|
|
if (!keys) await ensureKeys();
|
|
accessToken = await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
|
client.setAccessToken(accessToken);
|
|
await refreshFeatures();
|
|
}
|
|
|
|
async function ensureCollection() {
|
|
if (collectionId) return collectionId;
|
|
const created = await client.createCollection(collectionNameEl.value.trim() || "MapLibre 3D objects demo");
|
|
collectionId = created.id;
|
|
collectionInfoEl.textContent = `${created.name} (${created.id})`;
|
|
return collectionId;
|
|
}
|
|
|
|
function renderAssets(features) {
|
|
assetsListEl.innerHTML = "";
|
|
for (const feature of features) {
|
|
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
|
|
for (const asset of assets) {
|
|
const absoluteLink = client.resolveRelativeLink(asset.link);
|
|
const card = document.createElement("div");
|
|
card.className = "asset-card";
|
|
card.innerHTML = `
|
|
<div><strong>${asset.kind}</strong> • ${asset.ext}</div>
|
|
<div class="muted">Feature: ${feature.id}</div>
|
|
<div class="muted">Visibility: ${asset.isPublic ? "public" : "private"}</div>
|
|
<div class="muted">Link: ${asset.link}</div>
|
|
`;
|
|
const actions = document.createElement("div");
|
|
actions.className = "asset-actions";
|
|
|
|
const openBtn = document.createElement("button");
|
|
openBtn.textContent = "Open";
|
|
openBtn.onclick = () => window.open(absoluteLink, "_blank", "noopener,noreferrer");
|
|
actions.appendChild(openBtn);
|
|
|
|
const toggleBtn = document.createElement("button");
|
|
toggleBtn.textContent = asset.isPublic ? "Set Private" : "Set Public";
|
|
toggleBtn.onclick = async () => {
|
|
try {
|
|
await client.setAssetVisibility(asset.id, !asset.isPublic);
|
|
await refreshFeatures();
|
|
setStatus(`Updated visibility for ${asset.id}`);
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
};
|
|
actions.appendChild(toggleBtn);
|
|
|
|
const copyBtn = document.createElement("button");
|
|
copyBtn.textContent = "Copy Share Link";
|
|
copyBtn.onclick = async () => {
|
|
const shareLink = buildMapShareLink(feature, asset);
|
|
await navigator.clipboard.writeText(shareLink);
|
|
setStatus("Map share link copied to clipboard.");
|
|
};
|
|
actions.appendChild(copyBtn);
|
|
|
|
card.appendChild(actions);
|
|
assetsListEl.appendChild(card);
|
|
}
|
|
}
|
|
if (!assetsListEl.children.length) {
|
|
assetsListEl.innerHTML = `<div class="muted">No assets linked yet.</div>`;
|
|
}
|
|
}
|
|
|
|
async function refreshFeatures() {
|
|
renderCycle += 1;
|
|
const cycleID = renderCycle;
|
|
const publicResp = await fetch(`${currentApiBase()}/v1/features/public?kind=3d`);
|
|
if (!publicResp.ok) {
|
|
throw new Error(`Failed to load public features: HTTP ${publicResp.status}`);
|
|
}
|
|
const publicData = await publicResp.json();
|
|
const byID = new Map((publicData.features || []).map((feature) => [feature.id, feature]));
|
|
|
|
const ownFeatureIDs = new Set();
|
|
if (accessToken) {
|
|
const { collections } = await client.listCollections();
|
|
if (!collectionId && collections.length > 0) {
|
|
collectionId = collections[0].id;
|
|
collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`;
|
|
}
|
|
const ownFeatureSets = await Promise.all(
|
|
collections.map(async (collection) => {
|
|
const { features } = await client.listFeatures(collection.id);
|
|
return features;
|
|
})
|
|
);
|
|
for (const feature of ownFeatureSets.flat()) {
|
|
byID.set(feature.id, feature);
|
|
ownFeatureIDs.add(feature.id);
|
|
}
|
|
}
|
|
|
|
const features = Array.from(byID.values());
|
|
clearFeatureMeshes();
|
|
clearOwnFeatureMarkers();
|
|
for (const feature of features) {
|
|
const coords = feature.geometry?.coordinates;
|
|
if (!coords || coords.length < 2) continue;
|
|
const lng = coords[0];
|
|
const lat = coords[1];
|
|
ownFeatureCoords.set(feature.id, coords);
|
|
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
|
|
const preferred3D = assets.find((asset) => is3DAsset(asset));
|
|
const first = preferred3D || assets[0];
|
|
if (first) {
|
|
await addObjectMeshFromAsset(feature.id, lng, lat, first, cycleID);
|
|
} else {
|
|
addFallbackMesh(feature.id, lng, lat, true, "3d");
|
|
}
|
|
if (ownFeatureIDs.has(feature.id) && accessToken) {
|
|
addOwnFeatureMarker(feature.id, lng, lat);
|
|
}
|
|
}
|
|
renderAssets(features);
|
|
}
|
|
|
|
async function createFeatureAndUpload() {
|
|
if (!selectedLngLat) {
|
|
throw new Error("Click the map to choose object location first.");
|
|
}
|
|
const file = assetFileEl.files?.[0];
|
|
if (!file) {
|
|
throw new Error("Select a 3D/image file first.");
|
|
}
|
|
const ext = extFromFilename(file.name);
|
|
if (!ext) {
|
|
throw new Error("File extension is required.");
|
|
}
|
|
if (ext === "gltf") {
|
|
setStatus("Note: .gltf may fail on map if it references external files. Prefer .glb for reliable 3D rendering.");
|
|
}
|
|
|
|
await ensureCollection();
|
|
const featureName = assetNameEl.value.trim() || file.name;
|
|
const feature = await client.createPointFeature(
|
|
collectionId,
|
|
selectedLngLat.lng,
|
|
selectedLngLat.lat,
|
|
{ name: featureName, placement: "maplibre-demo" }
|
|
);
|
|
|
|
const checksum = await sha256Hex(file);
|
|
const kind = kindFromExt(ext);
|
|
const created = await client.createOrLinkAsset({
|
|
featureId: feature.id,
|
|
checksum,
|
|
ext,
|
|
kind,
|
|
mimeType: file.type || "application/octet-stream",
|
|
sizeBytes: file.size,
|
|
name: featureName,
|
|
description: assetDescEl.value.trim(),
|
|
isPublic: true,
|
|
});
|
|
|
|
try {
|
|
await client.uploadAssetBinary(created.asset.id, file, file.type || "application/octet-stream");
|
|
} catch (error) {
|
|
throw new Error(`Upload failed: ${error.message}`);
|
|
}
|
|
|
|
await refreshFeatures();
|
|
assetNameEl.value = "";
|
|
assetDescEl.value = "";
|
|
assetFileEl.value = "";
|
|
setStatus("3D/image object stored and rendered on map.");
|
|
}
|
|
|
|
document.getElementById("applyApi").onclick = () => setClientBase(apiBaseEl.value);
|
|
document.getElementById("ensureKeys").onclick = async () => {
|
|
try {
|
|
await ensureKeys();
|
|
setStatus("Keys are ready.");
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
};
|
|
document.getElementById("register").onclick = async () => {
|
|
try {
|
|
await register();
|
|
setStatus("Registered.");
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
};
|
|
document.getElementById("login").onclick = async () => {
|
|
try {
|
|
await login();
|
|
setStatus("Logged in. Loaded all collections on map.");
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
};
|
|
document.getElementById("createCollection").onclick = async () => {
|
|
try {
|
|
await ensureCollection();
|
|
setStatus("Collection is ready.");
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
};
|
|
document.getElementById("uploadAsset").onclick = async () => {
|
|
try {
|
|
if (!accessToken) throw new Error("Login first.");
|
|
await createFeatureAndUpload();
|
|
} catch (error) {
|
|
setStatus(error.message);
|
|
}
|
|
};
|
|
|
|
map = new maplibregl.Map({
|
|
container: "map",
|
|
style: buildRasterStyle(),
|
|
center: [-16.2518, 28.4636],
|
|
zoom: 12,
|
|
pitch: 55,
|
|
bearing: -15,
|
|
hash: false,
|
|
antialias: true,
|
|
});
|
|
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
|
|
|
map.on("load", () => {
|
|
threeLayer = createThreeLayer();
|
|
map.addLayer(threeLayer);
|
|
renderSharedAssetFromQuery().catch((error) => setStatus(error.message));
|
|
refreshFeatures().catch((error) => setStatus(error.message));
|
|
});
|
|
|
|
map.on("click", (event) => {
|
|
selectedLngLat = event.lngLat;
|
|
if (pendingMarker) pendingMarker.remove();
|
|
pendingMarker = new maplibregl.Marker({ color: "#22d3ee" })
|
|
.setLngLat(event.lngLat)
|
|
.addTo(map);
|
|
setStatus(`Selected location: ${event.lngLat.lat.toFixed(5)}, ${event.lngLat.lng.toFixed(5)}`);
|
|
});
|
|
|
|
const savedBase = localStorage.getItem("geo_api_base");
|
|
if (savedBase && !shouldUseHostedDefault(savedBase)) {
|
|
apiBaseEl.value = savedBase;
|
|
setClientBase(savedBase);
|
|
} else {
|
|
const defaultBase = window.location.origin;
|
|
apiBaseEl.value = defaultBase;
|
|
setClientBase(defaultBase);
|
|
}
|