Files
backend/web/maplibre-demo.js
Andriy Oblivantsev 6f873100ef
CI / test (push) Successful in 4s
Load real GLB models in the MapLibre demo instead of placeholder geometry.
This restores true 3D rendering from backend asset links and keeps runtime var data out of git/agent workflows.

Made-with: Cursor
2026-03-02 22:39:20 +00:00

634 lines
19 KiB
JavaScript

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: "&copy; 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 = `
<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);
}
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);
}