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 = "