From 5716d4adf6565756e31d1847d29117ef4359dd5b Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 2 Mar 2026 22:28:44 +0000 Subject: [PATCH] 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 --- internal/app/service.go | 17 +++++++++ internal/http/api_test.go | 22 ++++++++++++ internal/http/handlers.go | 23 ++++++++++++ web/maplibre-demo.html | 2 +- web/maplibre-demo.js | 74 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 136 insertions(+), 2 deletions(-) diff --git a/internal/app/service.go b/internal/app/service.go index b31c5a0..17fbce2 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -443,6 +443,23 @@ func (s *Service) DeleteFeature(ownerKey, featureID string) error { return s.store.DeleteFeature(featureID) } +func (s *Service) UpdateFeatureGeometry(ownerKey, featureID string, geometry store.Point) (store.Feature, error) { + feature, err := s.store.GetFeature(featureID) + if err != nil { + return store.Feature{}, ErrFeatureMiss + } + if feature.OwnerKey != ownerKey { + return store.Feature{}, ErrForbidden + } + if err := validatePoint(geometry); err != nil { + return store.Feature{}, err + } + feature.Geometry = geometry + feature.UpdatedAt = time.Now().UTC() + s.store.SaveFeature(feature) + return feature, nil +} + type CreateAssetInput struct { FeatureID string Checksum string diff --git a/internal/http/api_test.go b/internal/http/api_test.go index 7bda8f5..2a6b390 100644 --- a/internal/http/api_test.go +++ b/internal/http/api_test.go @@ -308,6 +308,28 @@ func TestCollectionOwnershipIsolation(t *testing.T) { if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403, got %d", resp.StatusCode) } + + featureID := createFeatureData["id"].(string) + + patchOwnResp, patchOwnData := patchJSON(t, client, server.URL+"/v1/features/"+featureID, map[string]interface{}{ + "geometry": map[string]interface{}{ + "type": "Point", + "coordinates": []float64{-16.6299, 28.4639, 11}, + }, + }, user1Token) + if patchOwnResp.StatusCode != http.StatusOK { + t.Fatalf("owner patch feature status=%d body=%v", patchOwnResp.StatusCode, patchOwnData) + } + + patchOtherResp, patchOtherData := patchJSON(t, client, server.URL+"/v1/features/"+featureID, map[string]interface{}{ + "geometry": map[string]interface{}{ + "type": "Point", + "coordinates": []float64{-16.6301, 28.4641, 12}, + }, + }, user2Token) + if patchOtherResp.StatusCode != http.StatusForbidden { + t.Fatalf("non-owner patch feature status=%d body=%v", patchOtherResp.StatusCode, patchOtherData) + } } func TestAssetLifecycleAndVisibility(t *testing.T) { diff --git a/internal/http/handlers.go b/internal/http/handlers.go index c9d93bb..1b6408d 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -42,6 +42,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature) mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures) mux.HandleFunc("GET /v1/features/public", a.listPublicFeatures) + mux.HandleFunc("PATCH /v1/features/{id}", a.patchFeature) mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature) mux.HandleFunc("POST /v1/assets", a.createAsset) mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset) @@ -393,6 +394,28 @@ func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (a *API) patchFeature(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + featureID := r.PathValue("id") + var req struct { + Geometry store.Point `json:"geometry"` + } + if err := readJSON(r, &req); err != nil { + writeErr(w, app.ErrBadRequest) + return + } + feature, err := a.service.UpdateFeatureGeometry(user, featureID, req.Geometry) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, feature) +} + func (a *API) createAsset(w http.ResponseWriter, r *http.Request) { user, err := a.authUser(r) if err != nil { diff --git a/web/maplibre-demo.html b/web/maplibre-demo.html index 8bef86b..328482b 100644 --- a/web/maplibre-demo.html +++ b/web/maplibre-demo.html @@ -99,7 +99,7 @@

MapLibre GL + Three.js Demo

- Vector tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share. + Raster tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share.

Connection

diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js index e37b60c..518311d 100644 --- a/web/maplibre-demo.js +++ b/web/maplibre-demo.js @@ -36,6 +36,8 @@ let threeScene; let threeRenderer; let threeCamera; const featureMeshes = new Map(); +const ownFeatureMarkers = new Map(); +const ownFeatureCoords = new Map(); function setStatus(message) { statusEl.textContent = message; @@ -71,6 +73,27 @@ function setClientBase(baseUrl) { 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) { @@ -156,6 +179,14 @@ function clearFeatureMeshes() { 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(); @@ -173,6 +204,40 @@ function addObjectMesh(featureId, lng, lat, isPublic, kind) { 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)}...`; @@ -259,6 +324,7 @@ async function refreshFeatures() { 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) { @@ -273,21 +339,27 @@ async function refreshFeatures() { ); 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); } @@ -385,7 +457,7 @@ document.getElementById("uploadAsset").onclick = async () => { map = new maplibregl.Map({ container: "map", - style: "./osm-liberty-gl-style/style.json", + style: buildRasterStyle(), center: [-16.2518, 28.4636], zoom: 12, pitch: 55,