Enable moving own features on MapLibre and switch to raster tiles.
CI / test (push) Successful in 4s
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:
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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: "© 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user