Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions docs/asset-inspector.md
Original file line number Diff line number Diff line change
@@ -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()`.
9 changes: 9 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions sdk/mapcontrol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 2 additions & 2 deletions sdk/mapcontrol/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions sdk/mapcontrol/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions sdk/tests/test_metadata_sdk.py
Original file line number Diff line number Diff line change
@@ -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"})
43 changes: 39 additions & 4 deletions server/mapcontrol_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -439,6 +443,9 @@ def _thumbnail_url(entry) -> str:
if ui == "default" else ""
)
ui_body = f'<script src="{root_path}/static/esip-contract.js"></script>'
if inspect_enabled:
ui_head += f'<link rel="stylesheet" href="{root_path}/static/esip-inspector.css">'
ui_body += f'<script src="{root_path}/static/esip-inspector.js"></script>'
if ui == "default":
ui_body += f'<script src="{root_path}/static/esip-embed.js"></script>'

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}}

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
}},

Expand Down Expand Up @@ -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_')) {{
Expand All @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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:';
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading