From 993b9f63ad86b917c1a099d3891cb4265dff26ef Mon Sep 17 00:00:00 2001 From: Otto Wagner <48657113+isConic@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:34:10 -0500 Subject: [PATCH] feat: add metadata-driven asset inspector --- docs/asset-inspector.md | 115 ++++++++ sdk/README.md | 9 + sdk/mapcontrol/__init__.py | 4 +- sdk/mapcontrol/models.py | 4 +- sdk/mapcontrol/session.py | 11 + sdk/tests/test_metadata_sdk.py | 40 +++ server/mapcontrol_server/main.py | 43 ++- server/mapcontrol_server/models.py | 52 +++- server/mapcontrol_server/routers/assets.py | 9 + .../services/asset_service.py | 57 +++- .../services/event_service.py | 60 ++++- .../mapcontrol_server/static/esip-contract.js | 22 +- server/mapcontrol_server/static/esip-embed.js | 2 +- .../static/esip-inspector.css | 33 +++ .../static/esip-inspector.js | 236 +++++++++++++++++ server/tests/test_inspector_browser.py | 156 +++++++++++ server/tests/test_metadata.py | 246 ++++++++++++++++++ 17 files changed, 1068 insertions(+), 31 deletions(-) create mode 100644 docs/asset-inspector.md create mode 100644 sdk/tests/test_metadata_sdk.py create mode 100644 server/mapcontrol_server/static/esip-inspector.css create mode 100644 server/mapcontrol_server/static/esip-inspector.js create mode 100644 server/tests/test_inspector_browser.py create mode 100644 server/tests/test_metadata.py diff --git a/docs/asset-inspector.md b/docs/asset-inspector.md new file mode 100644 index 0000000..6a120ce --- /dev/null +++ b/docs/asset-inspector.md @@ -0,0 +1,115 @@ +# Asset inspector + +MC2's inspector displays content associated with an ordinary geographic asset. +The application supplies the content; MC2 owns selection, geographic anchoring, +presentation, and dismissal. No station or analysis-specific asset type is needed. + +Opt an asset in through `metadata.extra.inspector`: + +```json +{ + "title": "Observation site", + "description": "Measurements collected at this location.", + "extra": { + "inspector": { + "version": 1, + "status": "ready", + "attachments": [ + { + "type": "html", + "title": "Observation history", + "url": "https://artifacts.example.org/history.html" + } + ] + } + } +} +``` + +`version` defaults to `1`; unknown versions are rejected. Optional `status` is one of `loading`, `ready`, +`empty`, or `error`. `attachments` defaults to an empty list. Each attachment +has a `type` (`html`, `image`, or `link`), a `url`, and an optional `title`. +URLs must use HTTP(S) or be relative to the MC2 map page, and must be +free of credentials, whitespace, control characters, and backslashes. Content must be +accessible to the viewer's browser. Applications remain responsible for artifact +hosting, access, link lifetimes, and refreshing expired references. + +Titles and descriptions are plain text. HTML content is a URL reference and +is rendered in a sandboxed iframe; supplied markup is never inserted into MC2's +DOM. There is no additional `inspectable` flag. Setting the inspector to `null` +disables the inspector for that asset. + +## Updating content after asset creation + +```python +session.update_metadata(asset_id, { + "description": "The updated interpretation.", + "extra": { + "inspector": { + "version": 1, + "status": "ready", + "attachments": [{"type": "html", "url": chart_url}], + }, + }, +}) +``` + +`MapSession.update_metadata(asset_id, Metadata | dict)` sends an `update_metadata` +event. The event persists metadata and broadcasts the complete merged value as +`data: {asset_id, metadata}`. An already-open inspector refreshes without changing +the selected asset. Reconnecting viewers receive the same metadata in their +session snapshot. + +Both this event and `PATCH /api/maps/{map_id}/assets/{asset_id}` merge supplied +`title` and `description` fields and shallow-merge keys inside `extra`. Other +metadata is preserved. Each supplied `extra` namespace, including `inspector`, +is replaced as a whole; send the complete new inspector descriptor when changing +its state or attachments. Use a dict with an explicit `null` to clear a text +field or set `extra.inspector` to `null` to disable inspection. An empty `extra` +object does not clear existing namespaces. + +The REST PATCH endpoint broadcasts `asset_updated` with the complete asset model +in `data`, including the merged metadata. Invalid inspector descriptors or unsafe +URL schemes are rejected. `update_metadata` uses the event API's `error` response +field for missing assets and invalid data; the SDK raises `ServerError` for these. + +## Viewer behavior and embedding + +The inspector is enabled with `ui=default`. Embedders using `ui=controls` or +`ui=none` opt in with `inspect=1`; `inspect=0` disables it in every UI mode. +For example, `/map/{map_id}?ui=controls&inspect=1` keeps the native navigation and +drawing controls while allowing inspection without the built-in layer panel. + +Clicking an enabled asset opens one MapLibre popup. Points anchor at their exact +geometry; other geometries anchor at the clicked location. The popup follows +map movements, and Expand gives a larger view within the map. Small map frames +use a bottom sheet. Clicking another enabled asset replaces the content; +background clicks, ordinary asset clicks, Close, and Escape dismiss it. +Drawing/deleting mode suppresses inspection. Hidden or deleted selections close. +Selection and expansion are local to the viewer and never sent as map events. + +The ordinary `asset_click` event includes `inspector_handled: true` when MC2 +opened the inspector, and `false` otherwise. An embedding app should suppress its +own selection card/layer drawer when this is true. The event also includes the +rendered `feature_id` when available; generated feature IDs are local to the +current asset geometry and should not be used as durable artifact identities. + +HTML attachments execute only in an `allow-scripts` iframe sandbox, without +same-origin access to MC2. For CORS-readable documents, MC2 adds a sandboxed +Escape-key bridge; documents that cannot be fetched through CORS load directly +in the same sandbox. In that fallback, Close remains available outside the +frame and Escape works when focus is on MC2. Attachment links open separately. + +An embedder can close inspection when focus/click moves outside its map iframe: + +```javascript +mapIframe.contentWindow.postMessage({ + source: 'esip-embedder', + type: 'close_inspector', + map_id: mapId, +}, new URL(mapIframe.src).origin); +``` + +MC2 accepts that message only from its parent window with a matching map ID and, +when supplied by the browser, a matching referrer origin. Same-document consumers +can call `window.ESIPMap.inspector?.close()`. diff --git a/sdk/README.md b/sdk/README.md index ce55f4c..1e552ef 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -38,6 +38,15 @@ shot = session.take_screenshot() # PNG of the current view | `MapSession` | A live map: assets, camera, GeoTIFFs, screenshots, themes, terrain | | `Asset`, `Viewport`, `Style`, `ScreenshotResult` | Typed models for the API | +## Asset inspectors + +Attach referenced charts, images, or links to any asset through +`metadata.extra.inspector`. Use `session.update_metadata(asset_id, metadata)` +to update content after creation; connected viewers refresh and restored maps +retain the metadata. Updates preserve omitted text fields and unrelated `extra` +keys. See [the inspector contract](../docs/asset-inspector.md) for the schema, +rendering behavior, and merge semantics. + ## Running the server The server ships as a container: diff --git a/sdk/mapcontrol/__init__.py b/sdk/mapcontrol/__init__.py index 0f3fe7e..3568ec7 100644 --- a/sdk/mapcontrol/__init__.py +++ b/sdk/mapcontrol/__init__.py @@ -2,7 +2,7 @@ from .client import MapControl from .session import MapSession -from .models import Asset, Viewport, Style, ScreenshotResult +from .models import Asset, Viewport, Style, Metadata, ScreenshotResult __version__ = "0.3.0" -__all__ = ["MapControl", "MapSession", "Asset", "Viewport", "Style", "ScreenshotResult"] +__all__ = ["MapControl", "MapSession", "Asset", "Viewport", "Style", "Metadata", "ScreenshotResult"] diff --git a/sdk/mapcontrol/models.py b/sdk/mapcontrol/models.py index dd5f7a1..04deb0c 100644 --- a/sdk/mapcontrol/models.py +++ b/sdk/mapcontrol/models.py @@ -103,9 +103,9 @@ class Metadata: def to_dict(self) -> dict: d = {} - if self.title: + if self.title is not None: d["title"] = self.title - if self.description: + if self.description is not None: d["description"] = self.description if self.extra: d["extra"] = self.extra diff --git a/sdk/mapcontrol/session.py b/sdk/mapcontrol/session.py index 9e675e3..d48ba84 100644 --- a/sdk/mapcontrol/session.py +++ b/sdk/mapcontrol/session.py @@ -422,6 +422,17 @@ def update_style(self, asset_id: str, style: Style | dict) -> EventResult: style_dict = style.to_dict() if isinstance(style, Style) else style return self._send_event("update_style", {"asset_id": asset_id, "style": style_dict}) + def update_metadata(self, asset_id: str, metadata: Metadata | dict) -> EventResult: + """Merge metadata and notify live viewers without recreating the asset. + + Supplied title/description replace those fields. ``extra`` is merged + by key; each namespace (including ``inspector``) is replaced in full. + Pass a dict with explicit null title/description to clear text, or + ``{"extra": {"inspector": None}}`` to remove the inspector. + """ + metadata_dict = metadata.to_dict() if isinstance(metadata, Metadata) else metadata + return self._send_event("update_metadata", {"asset_id": asset_id, "metadata": metadata_dict}) + # ─── Tile Layers ───────────────────────────────────────────────────────── def add_tile_layer( diff --git a/sdk/tests/test_metadata_sdk.py b/sdk/tests/test_metadata_sdk.py new file mode 100644 index 0000000..2066dbc --- /dev/null +++ b/sdk/tests/test_metadata_sdk.py @@ -0,0 +1,40 @@ +"""The SDK sends partial metadata updates and reports server failures.""" + +import json + +import httpx +import pytest + +from mapcontrol import Metadata +from mapcontrol.exceptions import ServerError +from mapcontrol.session import MapSession + + +@pytest.mark.parametrize("metadata,expected", [ + (Metadata(description=""), {"description": ""}), + (Metadata(extra={"inspector": {"version": 1, "status": "loading"}}), + {"extra": {"inspector": {"version": 1, "status": "loading"}}}), + ({"title": None, "extra": {"inspector": None}}, {"title": None, "extra": {"inspector": None}}), +]) +def test_metadata_update_serializes_only_supplied_fields(metadata, expected): + def handle(request): + assert request.url.path == "/api/maps/map-123/events" + assert json.loads(request.content) == { + "type": "update_metadata", "data": {"asset_id": "asset-123", "metadata": expected}, + "user_session_id": "viewer-123", + } + return httpx.Response(201, json={ + "event_id": "event-123", "type": "update_metadata", "asset_id": "asset-123", "created_at": "now", + }) + + with httpx.Client(base_url="http://test", transport=httpx.MockTransport(handle)) as client: + session = MapSession(client, "http://test", "map-123", "", "viewer-123", "") + assert session.update_metadata("asset-123", metadata).asset_id == "asset-123" + + +def test_metadata_update_surfaces_server_error(): + transport = httpx.MockTransport(lambda request: httpx.Response(201, json={"error": "Asset not found"})) + with httpx.Client(base_url="http://test", transport=transport) as client: + session = MapSession(client, "http://test", "map-123", "", "viewer-123", "") + with pytest.raises(ServerError, match="Asset not found"): + session.update_metadata("missing", {"description": "new"}) diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index 330f00e..0a450b3 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -419,6 +419,10 @@ def _thumbnail_url(entry) -> str: ui = "none" + # Inspection is part of the default UI, independently opt-in for embeds. + inspect_param = request.query_params.get("inspect") + inspect_enabled = inspect_param == "1" or (ui == "default" and inspect_param != "0") + # Reverse-proxy mount prefix (ADR-0001 dual-deployability). When ESIP runs # behind a single-origin proxy under a sub-path (MAPCONTROL_ROOT_PATH → # uvicorn --root-path), Starlette puts that prefix on request.scope. @@ -439,6 +443,9 @@ def _thumbnail_url(entry) -> str: if ui == "default" else "" ) ui_body = f'' + if inspect_enabled: + ui_head += f'' + ui_body += f'' if ui == "default": ui_body += f'' @@ -773,6 +780,19 @@ def _thumbnail_url(entry) -> str: // ─── Asset Registry ─── // Tracks layer IDs and GeoJSON bounds for each asset const assetRegistry = {{}}; // asset_id -> {{ layerIds, bounds, srcId }} + // Persisted presentation data, available synchronously on clicks even + // while an image overlay is still loading. Snapshot + live events own it. + const assetInfo = {{}}; // asset_id -> asset metadata/name/visibility + function rememberAsset(data) {{ + if (!data || !data.asset_id) return; + assetInfo[data.asset_id] = {{ ...(assetInfo[data.asset_id] || {{}}), ...data }}; + const reg = assetRegistry[data.asset_id]; + if (reg) {{ + for (const key of ['metadata', 'name', 'visible']) {{ + if (key in data) reg[key] = data[key]; + }} + }} + }} let currentBasemap = DEFAULTS.basemap; let currentTerrain = DEFAULTS.terrain || '2d'; // '2d' or '3d' @@ -1778,7 +1798,7 @@ class BasemapPickerControl {{ if (labelId) layerIds.push(labelId); // geojson kept so update_style can rebuild the mask layer later. - assetRegistry[assetId] = {{ layerIds, bounds: geojsonBounds(geojson), srcId, name: name || null, asset_type: assetType || 'vector', geomTypes: Array.from(geomTypes), geojson, zIndex }}; + assetRegistry[assetId] = {{ layerIds, bounds: geojsonBounds(geojson), srcId, name: name || null, asset_type: assetType || 'vector', geomTypes: Array.from(geomTypes), geojson, zIndex, visible: visible !== false, metadata: assetInfo[assetId]?.metadata }}; applyZPosition(assetId); // Optional mask (style.mask): darken everything outside the polygon @@ -1900,7 +1920,7 @@ class BasemapPickerControl {{ layout: {{ visibility: visible !== false ? 'visible' : 'none' }}, }}, beforeId); const b = new maplibregl.LngLatBounds([bounds[0], bounds[1]], [bounds[2], bounds[3]]); - assetRegistry[assetId] = {{ layerIds: [layerId], bounds: b, srcId, name: name || null, asset_type: assetType || 'geotiff', zIndex }}; + assetRegistry[assetId] = {{ layerIds: [layerId], bounds: b, srcId, name: name || null, asset_type: assetType || 'geotiff', zIndex, visible: visible !== false, metadata: assetInfo[assetId]?.metadata }}; raiseDrawLayers(); }} @@ -2123,6 +2143,12 @@ class BasemapPickerControl {{ add_arc(data) {{ addArc(data.asset_id, data.geojson, data.style, true, data.name, data.from, data.to, data.curvature, data.z_index); }}, // ─── Asset management ─── + update_metadata(data) {{ rememberAsset(data); }}, + asset_updated(data) {{ + rememberAsset(data); + if ('visible' in data) handlers.set_visibility(data); + if (data.style) handlers.update_style(data); + }}, delete_asset(data) {{ if (selection.assetId === data.asset_id) deselectAsset(); const reg = assetRegistry[data.asset_id]; @@ -2427,7 +2453,7 @@ class BasemapPickerControl {{ layout: {{ visibility: vis }}, }}, beforeId); - assetRegistry[assetId] = {{ layerIds: [layerId], bounds: null, srcId, tileUrl: url, name: data.name || null, asset_type: 'tile', zIndex: data.z_index }}; + assetRegistry[assetId] = {{ layerIds: [layerId], bounds: null, srcId, tileUrl: url, name: data.name || null, asset_type: 'tile', zIndex: data.z_index, visible: data.visible !== false, metadata: assetInfo[assetId]?.metadata }}; console.log('Added tile layer:', assetId, url, 'opacity:', opacity); }}, @@ -2638,6 +2664,9 @@ class BasemapPickerControl {{ for (const id of Object.keys(deckArcs)) delete deckArcs[id]; renderDeckArcs(); + // Replace the presentation cache with persisted snapshot values. + for (const id of Object.keys(assetInfo)) delete assetInfo[id]; + for (const asset of (snapshot.assets || [])) rememberAsset(asset); // Restore all assets for (const asset of (snapshot.assets || [])) {{ if (asset.asset_type && asset.asset_type.startsWith('geotiff_')) {{ @@ -2663,6 +2692,7 @@ class BasemapPickerControl {{ addGeoJSON(asset.asset_id, asset.geojson, asset.style, asset.visible, asset.name, asset.asset_type, asset.z_index); }} }} + window.dispatchEvent(new CustomEvent('esip:assetschanged', {{ detail: {{ type: 'snapshot' }} }})); // Restore basemap — ONLY when it actually differs, and always // through the kind-aware set_basemap handler. Snapshots arrive on // every WS (re)connect, so this path must be a no-op when nothing @@ -2818,6 +2848,7 @@ class BasemapPickerControl {{ }} function updateDrawToolbar() {{ + window.dispatchEvent(new CustomEvent('esip:interactionmode')); document.querySelectorAll('.draw-toolbar button[data-draw-mode]').forEach(btn => {{ btn.setAttribute('aria-pressed', btn.getAttribute('data-draw-mode') === currentDrawMode ? 'true' : 'false'); @@ -3251,7 +3282,8 @@ class DrawToolbarControl {{ const data = await resp.json(); // Update the global and URL window.USER_SESSION_AUTO = data.user_session_id; - const newUrl = window.location.pathname + '?user_session=' + data.user_session_id; + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('user_session', data.user_session_id); window.history.replaceState(null, '', newUrl); // Rebuild WS URL with the new session const wsProto2 = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -3320,6 +3352,9 @@ class DrawToolbarControl {{ window.__esipInternals = {{ map: map, registry: assetRegistry, + assetInfo: assetInfo, + rememberAsset: rememberAsset, + deselectAsset: deselectAsset, basemaps: BASEMAPS, handlers: handlers, mapId: MAP_ID, diff --git a/server/mapcontrol_server/models.py b/server/mapcontrol_server/models.py index 96c6a28..38598e5 100644 --- a/server/mapcontrol_server/models.py +++ b/server/mapcontrol_server/models.py @@ -3,8 +3,9 @@ from __future__ import annotations from datetime import datetime -from typing import Any -from pydantic import BaseModel, Field, model_validator +from typing import Any, Literal +from urllib.parse import urlsplit +from pydantic import BaseModel, Field, field_validator, model_validator # ─── Style ─────────────────────────────────────────────────────────────────── @@ -121,11 +122,58 @@ def _expand_status(self) -> "AssetStyle": # ─── Asset Metadata ────────────────────────────────────────────────────────── +class InspectorAttachment(BaseModel): + """A reference to content rendered by the generic map inspector.""" + + type: Literal["html", "image", "link"] + url: str + title: str | None = None + + @field_validator("url") + @classmethod + def _safe_url(cls, value: str) -> str: + # Keep the original string intact (signed URLs may be sensitive to + # normalization). Relative references resolve against the map page. + if not value or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError("Attachment URL must be a non-empty HTTP(S) or relative URL") + try: + parsed = urlsplit(value) + # Accessing these properties also checks malformed IPv6/ports. + hostname, _port = parsed.hostname, parsed.port + except ValueError: + raise ValueError("Attachment URL is malformed") from None + if parsed.scheme.lower() not in ("", "http", "https"): + raise ValueError("Attachment URL must use HTTP(S) or be relative") + if parsed.scheme and not hostname: + raise ValueError("HTTP(S) attachment URLs must include a host") + if parsed.username or parsed.password or "\\" in value: + raise ValueError("Attachment URLs must not contain credentials or backslashes") + return value + + +class AssetInspector(BaseModel): + """Versioned, domain-independent convention for metadata.extra.inspector.""" + + version: Literal[1] = 1 + status: Literal["loading", "ready", "empty", "error"] | None = None + attachments: list[InspectorAttachment] = Field(default_factory=list) + + class AssetMetadata(BaseModel): title: str | None = None description: str | None = None extra: dict[str, Any] = Field(default_factory=dict) + @field_validator("extra") + @classmethod + def _validate_inspector(cls, value: dict[str, Any]) -> dict[str, Any]: + if value.get("inspector") is not None: + inspector = AssetInspector.model_validate(value["inspector"]) + # Stored metadata and live messages share the same complete v1 + # descriptor even when callers omit version or attachments. + return {**value, "inspector": inspector.model_dump(exclude_none=True)} + return value + # ─── Map ───────────────────────────────────────────────────────────────────── diff --git a/server/mapcontrol_server/routers/assets.py b/server/mapcontrol_server/routers/assets.py index b2cfcd3..50eb6fb 100644 --- a/server/mapcontrol_server/routers/assets.py +++ b/server/mapcontrol_server/routers/assets.py @@ -6,6 +6,7 @@ from ..models import AssetResponse, AssetUpdate from ..services import asset_service, session_service +from ..websocket import manager router = APIRouter(prefix="/api/maps/{map_id}/assets", tags=["assets"]) @@ -41,6 +42,10 @@ async def update_asset(map_id: str, asset_id: str, update: AssetUpdate): asset = await asset_service.update_asset(map_id, asset_id, update) if asset is None: raise HTTPException(status_code=404, detail="Asset not found") + await manager.broadcast_to_map(map_id, { + "type": "asset_updated", + "data": asset.model_dump(), + }) return asset @@ -50,3 +55,7 @@ async def delete_asset(map_id: str, asset_id: str): deleted = await asset_service.delete_asset(map_id, asset_id) if not deleted: raise HTTPException(status_code=404, detail="Asset not found") + await manager.broadcast_to_map(map_id, { + "type": "delete_asset", + "data": {"asset_id": asset_id}, + }) diff --git a/server/mapcontrol_server/services/asset_service.py b/server/mapcontrol_server/services/asset_service.py index 11c88e6..49ab73f 100644 --- a/server/mapcontrol_server/services/asset_service.py +++ b/server/mapcontrol_server/services/asset_service.py @@ -276,7 +276,6 @@ async def update_asset( ) -> AssetResponse | None: """Partially update an asset.""" db = await get_db() - now = datetime.now(timezone.utc).isoformat() # Build SET clause dynamically updates = [] @@ -288,9 +287,6 @@ async def update_asset( if update.style is not None: updates.append("style = ?") params.append(update.style.model_dump_json()) - if update.metadata is not None: - updates.append("metadata = ?") - params.append(update.metadata.model_dump_json()) if update.visible is not None: updates.append("visible = ?") params.append(int(update.visible)) @@ -298,18 +294,51 @@ async def update_asset( updates.append("animated = ?") params.append(int(update.animated)) - if not updates: + if not updates and update.metadata is None: return await get_asset(map_id, asset_id) - updates.append("updated_at = ?") - params.append(now) - params.extend([asset_id, map_id]) - - set_clause = ", ".join(updates) - await db.execute( - f"UPDATE assets SET {set_clause} WHERE id = ? AND map_id = ?", params - ) - await db.commit() + while True: + set_parts = list(updates) + values = list(params) + metadata_before = None + if update.metadata is not None: + # Read only metadata, not potentially multi-MB geometry. Each + # extra key is a namespace replaced as a whole. Guard the write + # with the exact stored JSON so concurrent metadata edits retry + # against the newest value instead of losing unrelated fields. + cursor = await db.execute( + "SELECT metadata FROM assets WHERE id = ? AND map_id = ?", + (asset_id, map_id), + ) + row = await cursor.fetchone() + if row is None: + return None + metadata_before = row["metadata"] + metadata = json.loads(metadata_before) if metadata_before else {} + patch = update.metadata.model_dump(exclude_unset=True) + if "extra" in patch: + patch["extra"] = {**metadata.get("extra", {}), **patch["extra"]} + metadata.update(patch) + set_parts.append("metadata = ?") + values.append(AssetMetadata.model_validate(metadata).model_dump_json()) + + set_parts.append("updated_at = ?") + values.append(datetime.now(timezone.utc).isoformat()) + values.extend([asset_id, map_id]) + where = "id = ? AND map_id = ?" + if update.metadata is not None: + # IS compares both SQL NULL and text, including legacy rows whose + # metadata has not been written yet. No global/per-map lock needed. + where += " AND metadata IS ?" + values.append(metadata_before) + cursor = await db.execute( + f"UPDATE assets SET {', '.join(set_parts)} WHERE {where}", values + ) + await db.commit() + if update.metadata is None or cursor.rowcount: + break + # The row changed (or was deleted) between SELECT and UPDATE. Re-read + # it and merge again; deletion returns None at the top of the loop. return await get_asset(map_id, asset_id) diff --git a/server/mapcontrol_server/services/event_service.py b/server/mapcontrol_server/services/event_service.py index 6142224..1ce8043 100644 --- a/server/mapcontrol_server/services/event_service.py +++ b/server/mapcontrol_server/services/event_service.py @@ -6,12 +6,14 @@ import math import uuid from datetime import datetime, timezone +from pydantic import ValidationError from ..database import get_db from ..config import load_config from ..models import ( AssetMetadata, AssetStyle, + AssetUpdate, MapEvent, MapEventResponse, EventListItem, @@ -111,6 +113,45 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: event_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() + # Validate every metadata descriptor before any asset/event write. In a + # batch, one invalid attachment must not leave a half-created collection. + # Never include validation input values in errors: URLs can be signed. + metadata_events = { + "add_point", "add_polygon", "add_path", "add_arc", + "add_polygon_url", "add_path_url", "update_metadata", + } + batch_events = {"add_points", "add_polygons", "add_paths"} + if event.type == "update_metadata" and ( + not isinstance(event.data.get("asset_id"), str) + or not event.data.get("asset_id") + or not isinstance(event.data.get("metadata"), dict) + ): + return MapEventResponse( + event_id=event_id, type=event.type, created_at=now, + error="update_metadata requires 'asset_id' and a 'metadata' object", + ) + try: + if event.type in metadata_events and "metadata" in event.data: + event.data["metadata"] = AssetMetadata.model_validate( + event.data["metadata"] + ).model_dump(exclude_unset=True) + elif event.type in batch_events: + for item in event.data.get("items", []): + if isinstance(item, dict) and "metadata" in item: + item["metadata"] = AssetMetadata.model_validate( + item["metadata"] + ).model_dump(exclude_unset=True) + except ValidationError as exc: + errors = exc.errors(include_url=False, include_context=False, include_input=False) + detail = "; ".join( + f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" + for error in errors + ) + return MapEventResponse( + event_id=event_id, type=event.type, created_at=now, + error=f"Invalid metadata: {detail}", + ) + # Persist the event await db.execute( "INSERT INTO events (id, map_id, event_type, event_data, created_at) VALUES (?, ?, ?, ?, ?)", @@ -274,7 +315,6 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: target_id = event.data.get("asset_id") visible = event.data.get("visible", True) if target_id: - from ..models import AssetUpdate await asset_service.update_asset( map_id, target_id, AssetUpdate(visible=visible) ) @@ -283,7 +323,6 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: target_id = event.data.get("asset_id") style_data = event.data.get("style", {}) if target_id: - from ..models import AssetUpdate style = AssetStyle(**style_data) await asset_service.update_asset( map_id, target_id, AssetUpdate(style=style) @@ -297,6 +336,22 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: # colors. event.data["style"] = style.model_dump(exclude_none=True) + elif event.type == "update_metadata": + target_id = event.data["asset_id"] + metadata = AssetMetadata.model_validate(event.data["metadata"]) + asset = await asset_service.update_asset( + map_id, target_id, AssetUpdate(metadata=metadata) + ) + if asset is None: + return MapEventResponse( + event_id=event_id, type=event.type, created_at=now, + error="Asset not found", + ) + asset_id = asset.asset_id + # Broadcast the canonical merged value, so open viewers and clients + # restoring from storage see exactly the same metadata. + event.data["metadata"] = asset.metadata.model_dump() + elif event.type == "set_theme": # Map-level UI theme (light | dark | auto). Persist so new viewers / # session restores get it, then broadcast so connected viewers flip @@ -392,6 +447,7 @@ async def process_event(map_id: str, event: MapEvent) -> MapEventResponse: "geojson": item.get("geojson", ""), **({"style": item["style"]} if "style" in item else {}), **({"name": item["name"]} if "name" in item else {}), + **({"metadata": item["metadata"]} if "metadata" in item else {}), }, } await manager.broadcast_to_map(map_id, item_broadcast) diff --git a/server/mapcontrol_server/static/esip-contract.js b/server/mapcontrol_server/static/esip-contract.js index d999a3b..942fbfb 100644 --- a/server/mapcontrol_server/static/esip-contract.js +++ b/server/mapcontrol_server/static/esip-contract.js @@ -130,13 +130,21 @@ // Additive: this does not interfere with the map's own delete-mode click // handler; it only emits an event the embedder may act on. map.on("click", function (e) { + if (typeof internals.isDrawing === "function" && internals.isDrawing()) return; var ids = assetLayerIds(); var feats = ids.length ? map.queryRenderedFeatures(e.point, { layers: ids }) : []; if (feats.length) { var aid = assetForLayer(feats[0].layer.id); if (aid) { + var inspector = window.ESIPMap && window.ESIPMap.inspector; + var handled = !!(inspector && inspector.claimClick({ + asset_id: aid, feature: feats[0], point: e.point, + lngLat: [e.lngLat.lng, e.lngLat.lat], + })); emit("asset_click", { asset_id: aid, + feature_id: feats[0].id == null ? null : feats[0].id, + inspector_handled: handled, point: { x: e.point.x, y: e.point.y }, lngLat: [e.lngLat.lng, e.lngLat.lat], }); @@ -155,6 +163,11 @@ var orig = handlers[name]; if (typeof orig !== "function") return; handlers[name] = function (data) { + if (data && data.asset_id && internals.rememberAsset) { + if (name === "delete_asset" || name === "remove_tile_layer") { + delete internals.assetInfo[data.asset_id]; + } else internals.rememberAsset(data); + } var r = orig.apply(this, arguments); try { after(data); } catch (e) { /* keep map resilient */ } return r; @@ -165,17 +178,17 @@ "add_arc", "add_geotiff_rgb", "add_geotiff_singleband", "add_tile_layer", "add_drawn_polygon", "delete_asset", "remove_tile_layer", - "update_style", "set_opacity", "move_layer", + "update_style", "set_opacity", "move_layer", "update_metadata", "asset_updated", ]; CHANGE_EVENTS.forEach(function (name) { - decorate(name, function () { - window.dispatchEvent(new CustomEvent("esip:assetschanged")); + decorate(name, function (data) { + emit("assetschanged", { type: name, asset_id: data && data.asset_id }); }); }); decorate("set_visibility", function (data) { var reg = registry[data.asset_id]; if (reg) reg.visible = data.visible !== false; - window.dispatchEvent(new CustomEvent("esip:assetschanged")); + emit("assetschanged", { type: "set_visibility", asset_id: data.asset_id }); }); // ─── REST command surface (dogfoods the public events API) ──────────── @@ -197,6 +210,7 @@ getBasemaps: function () { return basemaps || {}; }, getCurrentBasemap: internals.getCurrentBasemap, getRegistry: function () { return registry; }, + getAssetInfo: function (id) { return internals.assetInfo[id] || null; }, actions: { setVisibility: function (assetId, visible) { return postEvent("set_visibility", { asset_id: assetId, visible: visible }); }, deleteAsset: function (assetId) { return postEvent("delete_asset", { asset_id: assetId }); }, diff --git a/server/mapcontrol_server/static/esip-embed.js b/server/mapcontrol_server/static/esip-embed.js index ab66524..559f046 100644 --- a/server/mapcontrol_server/static/esip-embed.js +++ b/server/mapcontrol_server/static/esip-embed.js @@ -512,7 +512,7 @@ window.addEventListener("esip:assetschanged", function () { refreshAssets(); }); window.addEventListener("esip:asset_click", function (e) { var d = e.detail || {}; - if (!d.asset_id) return; + if (!d.asset_id || d.inspector_handled) return; // Click = focus in the panel (no modal): open panel, switch to Layers, // expand + scroll to the row. togglePanel(true); diff --git a/server/mapcontrol_server/static/esip-inspector.css b/server/mapcontrol_server/static/esip-inspector.css new file mode 100644 index 0000000..6fd5600 --- /dev/null +++ b/server/mapcontrol_server/static/esip-inspector.css @@ -0,0 +1,33 @@ +/* Independent of the optional layer-manager outfit: usable in controls/none. */ +.esip-inspector-popup { z-index: 40; font: 13px/1.45 Inter, system-ui, sans-serif; } +.esip-inspector-popup .maplibregl-popup-content { padding: 0; border-radius: 12px; background: var(--eo-surface, #fff); color: var(--eo-text, #0f172a); box-shadow: 0 8px 32px #0004; overflow: hidden; } +.esip-inspector-popup .maplibregl-popup-tip { border-top-color: var(--eo-surface, #fff); border-bottom-color: var(--eo-surface, #fff); } +.esip-inspector-card { width: min(380px, calc(var(--inspector-map-width, 100vw) - 24px)); max-width: 100%; display: flex; flex-direction: column; max-height: min(560px, calc(var(--inspector-map-height, 100vh) - 40px), var(--inspector-available-height, 560px)); } +.esip-inspector-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--eo-border, #e2e8f0); flex: 0 0 auto; } +.esip-inspector-header h2 { font-size: 14px; margin: 0; flex: 1; min-width: 0; overflow-wrap: anywhere; } +.esip-inspector-button { appearance: none; cursor: pointer; font: inherit; color: inherit; background: var(--eo-surface-hover, #f1f5f9); border: 1px solid var(--eo-border, #cbd5e1); border-radius: 6px; padding: 5px 8px; } +.esip-inspector-button:focus-visible, .esip-inspector-link:focus-visible { outline: 2px solid var(--eo-accent, #5fa830); outline-offset: 2px; } +.esip-inspector-close { font-size: 20px; line-height: 20px; min-width: 30px; } +.esip-inspector-body { overflow: auto; overscroll-behavior: contain; padding: 12px; min-height: 0; } +.esip-inspector-description { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0 0 12px; } +.esip-inspector-details { margin-top: 12px; border-top: 1px solid var(--eo-border, #e2e8f0); padding-top: 8px; } +.esip-inspector-details summary { cursor: pointer; font-weight: 600; } +.esip-inspector-details[open] summary { margin-bottom: 8px; } +.esip-inspector-status { color: var(--eo-text-muted, #475569); margin: 0 0 12px; } +.esip-inspector-attachment + .esip-inspector-attachment { margin-top: 16px; } +.esip-inspector-attachment h3 { font-size: 12px; margin: 0 0 6px; } +.esip-inspector-frame { display: block; width: 100%; height: 320px; background: white; border: 0; border-radius: 5px; } +.esip-inspector-image { display: block; width: 100%; max-height: 320px; object-fit: contain; border-radius: 5px; } +.esip-inspector-link { display: inline-block; margin-top: 6px; color: var(--eo-accent, #396d1b); text-decoration: underline; } +.esip-inspector-popup.is-expanded { transform: none !important; top: 12px; left: 12px; right: 12px; bottom: 12px; width: auto; max-width: none !important; } +.esip-inspector-popup.is-expanded .maplibregl-popup-tip { display: none; } +.esip-inspector-popup.is-expanded .maplibregl-popup-content { width: 100%; height: 100%; } +.esip-inspector-popup.is-expanded .esip-inspector-card { width: 100%; height: 100%; max-height: none; } +.esip-inspector-popup.is-expanded .esip-inspector-body { flex: 1; } +.esip-inspector-popup.is-expanded .esip-inspector-frame { height: max(320px, calc(var(--inspector-map-height, 100vh) - 190px)); } +@media (max-width: 520px) { + .esip-inspector-popup { transform: none !important; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-width: none !important; } + .esip-inspector-popup .maplibregl-popup-tip { display: none; } + .esip-inspector-card { width: 100%; max-height: calc(var(--inspector-map-height, 100vh) * .72); } + .esip-inspector-popup.is-expanded { top: 8px; } +} diff --git a/server/mapcontrol_server/static/esip-inspector.js b/server/mapcontrol_server/static/esip-inspector.js new file mode 100644 index 0000000..d598a74 --- /dev/null +++ b/server/mapcontrol_server/static/esip-inspector.js @@ -0,0 +1,236 @@ +/* Viewer-local inspection of metadata.extra.inspector. No map mutations. */ +(function () { + "use strict"; + + function safeUrl(value) { + if (typeof value !== "string" || !value.trim()) return null; + try { + var url = new URL(value, document.baseURI); + return /^(https?:)$/.test(url.protocol) ? url.href : null; + } catch (_) { return null; } + } + function descriptor(asset) { + var value = asset && asset.metadata && asset.metadata.extra && asset.metadata.extra.inspector; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (value.version != null && value.version !== 1) return null; + if (value.attachments != null && !Array.isArray(value.attachments)) return null; + return Object.assign({ version: 1 }, value, { attachments: value.attachments || [] }); + } + function node(tag, className, text) { + var el = document.createElement(tag); + if (className) el.className = className; + if (text != null) el.textContent = text; + return el; + } + + function start() { + var internals = window.__esipInternals; + var api = window.ESIPMap; + if (!internals || !api || !window.maplibregl) return; + var map = internals.map; + var selected = null; + var popup = null; + var expanded = false; + var signature = ""; + var frames = []; + var fetches = []; + var previousFocus = null; + + function disposeContent() { + fetches.forEach(function (controller) { controller.abort(); }); + fetches = []; + frames = []; + } + function close(keepMapSelection) { + disposeContent(); + var old = popup; + popup = null; + selected = null; + signature = ""; + expanded = false; + if (old) old.remove(); + if (!keepMapSelection && internals.deselectAsset) internals.deselectAsset(); + if (previousFocus && previousFocus.isConnected && previousFocus.focus) { + previousFocus.focus({ preventScroll: true }); + } + previousFocus = null; + } + function assetFor(id) { return api.getAssetInfo(id); } + function syncSize() { + if (!popup) return; + var el = popup.getElement(); + el.classList.toggle("is-expanded", expanded); + el.style.setProperty("--inspector-map-height", map.getContainer().clientHeight + "px"); + el.style.setProperty("--inspector-map-width", map.getContainer().clientWidth + "px"); + var height = map.getContainer().clientHeight; + var point = selected && map.project ? map.project(selected.anchor) : { y: height / 2 }; + // A large card cannot fit either above or below a central marker. + // Constrain its scrollable body to the larger side before MapLibre + // chooses the anchor, leaving room for its tip, offset, and edge inset. + var available = Math.max(64, Math.min(height - 24, Math.max(point.y, height - point.y) - 36)); + el.style.setProperty("--inspector-available-height", available + "px"); + popup.setMaxWidth(Math.max(100, Math.min(380, map.getContainer().clientWidth - 24)) + "px"); + if (selected) popup.setLngLat(selected.anchor); + } + function link(url, title) { + var anchor = node("a", "esip-inspector-link", title || "Open attachment"); + anchor.href = url; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + return anchor; + } + function htmlFrame(url, title) { + var frame = node("iframe", "esip-inspector-frame"); + frame.title = title || "Interactive visualization"; + frame.setAttribute("sandbox", "allow-scripts"); + frame.referrerPolicy = "no-referrer"; + // Direct loading remains a fallback when the artifact host disallows + // CORS. When readable, add an Escape bridge inside the opaque sandbox. + var token = Math.random().toString(36).slice(2); + frames.push({ frame: frame, token: token }); + var controller = new AbortController(); + fetches.push(controller); + fetch(url, { signal: controller.signal, credentials: "include" }).then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }).then(function (html) { + if (controller.signal.aborted || !frame.isConnected) return; + var base = document.createElement("base"); + base.href = url; + var bridge = "