Files
backend/web/maplibre-demo.js
Andriy Oblivantsev b1b11b47f7
CI / test (push) Successful in 4s
Render image assets as textured planes in MapLibre demo.
This loads image files via backend asset links (with auth when available), applies them as Three.js textures on plane meshes, and falls back to primitive placeholders if texture loading fails.

Made-with: Cursor
2026-03-02 22:56:51 +00:00

724 lines
22 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 textureLoader = new THREE.TextureLoader();
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: "&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";
}
function isImageAsset(asset) {
if (!asset) return false;
const kind = String(asset.kind || "").toLowerCase();
const ext = normalizeExt(asset.ext) || extFromLink(asset.link);
return kind === "image" && ["jpg", "jpeg", "png", "webp"].includes(ext);
}
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();
}
async function loadTextureFromAssetLink(assetLink) {
const assetURL = client.resolveRelativeLink(assetLink);
const headers = accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined;
const response = await fetch(assetURL, { headers });
if (!response.ok) {
throw new Error(`Failed to load image asset: HTTP ${response.status}`);
}
const blob = await response.blob();
const objectURL = URL.createObjectURL(blob);
try {
const texture = await new Promise((resolve, reject) => {
textureLoader.load(objectURL, resolve, undefined, reject);
});
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
} finally {
URL.revokeObjectURL(objectURL);
}
}
async function addImagePlaneMesh(featureId, lng, lat, asset) {
const texture = await loadTextureFromAssetLink(asset.link);
const geometry = new THREE.PlaneGeometry(1.8, 1.8);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
positionObjectOnMap(mesh, lng, lat, 20);
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) {
if (isImageAsset(asset) && asset.link) {
try {
await addImagePlaneMesh(featureId, lng, lat, asset);
return true;
} catch (error) {
console.warn(`Failed to load image texture for feature ${featureId}:`, error);
}
}
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);
}