Files
backend/web/maplibre-demo.js
Andriy Oblivantsev 59c9a719e0
CI / test (push) Successful in 3s
Add public GeoJSON features API and load public 3D objects on maps.
Expose GET /v1/features/public (optional kind filter) and update Leaflet/MapLibre demos to render all public 3D assets globally, while still merging owner collections after login.

Made-with: Cursor
2026-03-02 22:21:21 +00:00

419 lines
13 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();
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 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 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 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]));
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);
}
}
const features = Array.from(byID.values());
clearFeatureMeshes();
for (const feature of features) {
const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) continue;
const lng = coords[0];
const lat = coords[1];
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);
}
}
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: "./osm-liberty-gl-style/style.json",
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);
}