Files
backend/web/maplibre-demo.js
Andriy Oblivantsev 5716d4adf6
CI / test (push) Successful in 4s
Enable moving own features on MapLibre and switch to raster tiles.
Add feature geometry PATCH API support and update MapLibre demo to use OSM raster tiles, load all public/owned features, and let logged-in users drag their own feature markers to persist new positions.

Made-with: Cursor
2026-03-02 22:28:44 +00:00

491 lines
15 KiB
JavaScript

import * as THREE from "https://unpkg.com/three@0.168.0/build/three.module.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 ownFeatureMarkers = new Map();
const ownFeatureCoords = new Map();
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 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 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";
addObjectMesh(`shared-${assetId}`, lng, lat, isPublic, kind);
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("Shared object loaded on map.");
}
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 disposeMesh(mesh) {
if (!mesh) return;
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (Array.isArray(mesh.material)) {
for (const mat of mesh.material) mat.dispose();
} else {
mesh.material.dispose();
}
}
}
function clearFeatureMeshes() {
for (const mesh of featureMeshes.values()) {
threeScene.remove(mesh);
disposeMesh(mesh);
}
featureMeshes.clear();
}
function clearOwnFeatureMarkers() {
for (const marker of ownFeatureMarkers.values()) {
marker.remove();
}
ownFeatureMarkers.clear();
ownFeatureCoords.clear();
}
function addObjectMesh(featureId, lng, lat, isPublic, kind) {
const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0);
const meters = merc.meterInMercatorCoordinateUnits();
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);
mesh.position.set(merc.x, merc.y, merc.z);
mesh.scale.setScalar(meters * (is3D ? 18 : 24));
if (!is3D) {
mesh.rotation.x = Math.PI / 2;
}
threeScene.add(mesh);
featureMeshes.set(featureId, mesh);
}
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() {
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 first = assets[0];
if (first) {
addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind);
}
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();
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);
}