import * as THREE from "https://unpkg.com/three@0.168.0/build/three.module.js"; import { GLTFLoader } from "https://unpkg.com/three@0.168.0/examples/jsm/loaders/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 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" || ext === "gltf"); } 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() { 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; }, render(gl, matrix) { const m = new THREE.Matrix4().fromArray(matrix); threeCamera.projectionMatrix = m; threeRenderer.resetState(); threeRenderer.render(threeScene, threeCamera); map.triggerRepaint(); gl.disable(gl.DEPTH_TEST); }, }; } 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) { 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 = `
${asset.kind} • ${asset.ext}
Feature: ${feature.id}
Visibility: ${asset.isPublic ? "public" : "private"}
Link: ${asset.link}
`; 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 = `
No assets linked yet.
`; } } 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); } 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."); } 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) { apiBaseEl.value = savedBase; setClientBase(savedBase); }