Enable moving own features on MapLibre and switch to raster tiles.
CI / test (push) Successful in 4s

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
This commit is contained in:
2026-03-02 22:28:44 +00:00
parent 59c9a719e0
commit 5716d4adf6
5 changed files with 136 additions and 2 deletions
+17
View File
@@ -443,6 +443,23 @@ func (s *Service) DeleteFeature(ownerKey, featureID string) error {
return s.store.DeleteFeature(featureID) 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 { type CreateAssetInput struct {
FeatureID string FeatureID string
Checksum string Checksum string
+22
View File
@@ -308,6 +308,28 @@ func TestCollectionOwnershipIsolation(t *testing.T) {
if resp.StatusCode != http.StatusForbidden { if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode) 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) { func TestAssetLifecycleAndVisibility(t *testing.T) {
+23
View File
@@ -42,6 +42,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature) mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures) mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
mux.HandleFunc("GET /v1/features/public", a.listPublicFeatures) 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("DELETE /v1/features/{id}", a.deleteFeature)
mux.HandleFunc("POST /v1/assets", a.createAsset) mux.HandleFunc("POST /v1/assets", a.createAsset)
mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset) 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) 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) { func (a *API) createAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r) user, err := a.authUser(r)
if err != nil { if err != nil {
+1 -1
View File
@@ -99,7 +99,7 @@
<div class="panel"> <div class="panel">
<h1>MapLibre GL + Three.js Demo</h1> <h1>MapLibre GL + Three.js Demo</h1>
<div class="muted"> <div class="muted">
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.
</div> </div>
<h2>Connection</h2> <h2>Connection</h2>
+73 -1
View File
@@ -36,6 +36,8 @@ let threeScene;
let threeRenderer; let threeRenderer;
let threeCamera; let threeCamera;
const featureMeshes = new Map(); const featureMeshes = new Map();
const ownFeatureMarkers = new Map();
const ownFeatureCoords = new Map();
function setStatus(message) { function setStatus(message) {
statusEl.textContent = message; statusEl.textContent = message;
@@ -71,6 +73,27 @@ function setClientBase(baseUrl) {
setStatus(`API base updated: ${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) { function buildMapShareLink(feature, asset) {
const coords = feature?.geometry?.coordinates; const coords = feature?.geometry?.coordinates;
if (!Array.isArray(coords) || coords.length < 2) { if (!Array.isArray(coords) || coords.length < 2) {
@@ -156,6 +179,14 @@ function clearFeatureMeshes() {
featureMeshes.clear(); featureMeshes.clear();
} }
function clearOwnFeatureMarkers() {
for (const marker of ownFeatureMarkers.values()) {
marker.remove();
}
ownFeatureMarkers.clear();
ownFeatureCoords.clear();
}
function addObjectMesh(featureId, lng, lat, isPublic, kind) { function addObjectMesh(featureId, lng, lat, isPublic, kind) {
const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0); const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0);
const meters = merc.meterInMercatorCoordinateUnits(); const meters = merc.meterInMercatorCoordinateUnits();
@@ -173,6 +204,40 @@ function addObjectMesh(featureId, lng, lat, isPublic, kind) {
featureMeshes.set(featureId, 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() { async function ensureKeys() {
keys = await client.ensureKeysInStorage(); keys = await client.ensureKeysInStorage();
publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`; publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`;
@@ -259,6 +324,7 @@ async function refreshFeatures() {
const publicData = await publicResp.json(); const publicData = await publicResp.json();
const byID = new Map((publicData.features || []).map((feature) => [feature.id, feature])); const byID = new Map((publicData.features || []).map((feature) => [feature.id, feature]));
const ownFeatureIDs = new Set();
if (accessToken) { if (accessToken) {
const { collections } = await client.listCollections(); const { collections } = await client.listCollections();
if (!collectionId && collections.length > 0) { if (!collectionId && collections.length > 0) {
@@ -273,21 +339,27 @@ async function refreshFeatures() {
); );
for (const feature of ownFeatureSets.flat()) { for (const feature of ownFeatureSets.flat()) {
byID.set(feature.id, feature); byID.set(feature.id, feature);
ownFeatureIDs.add(feature.id);
} }
} }
const features = Array.from(byID.values()); const features = Array.from(byID.values());
clearFeatureMeshes(); clearFeatureMeshes();
clearOwnFeatureMarkers();
for (const feature of features) { for (const feature of features) {
const coords = feature.geometry?.coordinates; const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) continue; if (!coords || coords.length < 2) continue;
const lng = coords[0]; const lng = coords[0];
const lat = coords[1]; const lat = coords[1];
ownFeatureCoords.set(feature.id, coords);
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : []; const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
const first = assets[0]; const first = assets[0];
if (first) { if (first) {
addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind); addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind);
} }
if (ownFeatureIDs.has(feature.id) && accessToken) {
addOwnFeatureMarker(feature.id, lng, lat);
}
} }
renderAssets(features); renderAssets(features);
} }
@@ -385,7 +457,7 @@ document.getElementById("uploadAsset").onclick = async () => {
map = new maplibregl.Map({ map = new maplibregl.Map({
container: "map", container: "map",
style: "./osm-liberty-gl-style/style.json", style: buildRasterStyle(),
center: [-16.2518, 28.4636], center: [-16.2518, 28.4636],
zoom: 12, zoom: 12,
pitch: 55, pitch: 55,