diff --git a/docs/asset-inspector.md b/docs/asset-inspector.md index 6a120ce..94b300a 100644 --- a/docs/asset-inspector.md +++ b/docs/asset-inspector.md @@ -1,8 +1,8 @@ # 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. +The application supplies the content; MC2 owns hover previews, pinned callouts, +geographic connectors, and dismissal. No station or analysis-specific asset type is needed. Opt an asset in through `metadata.extra.inspector`: @@ -34,7 +34,9 @@ free of credentials, whitespace, control characters, and backslashes. Content mu 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 +Titles and descriptions remain plain-text metadata. The compact callout shows +one asset title and the attachments; it does not repeat attachment headings or +display the description. 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. @@ -80,36 +82,53 @@ The inspector is enabled with `ui=default`. Embedders using `ui=controls` or 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. +Hovering over an enabled asset briefly shows a compact, read-only preview. +Moving away hides that preview. Clicking pins the callout and enables interaction +with its attachments. Several callouts can be pinned for comparison; repeated +clicks on the same asset bring its existing callout forward. Within a multi-feature +asset, a new click reanchors that asset's existing callout. + +Drag a pinned callout by its header to reposition it. Pinned callouts stay in +place on the screen while panning or zooming. Thin connectors track their original +map locations: exact point geometry for points and the inspected location for +other geometries. Resizing the map keeps the cards within its bounds. + +Background clicks and clicks outside an embedded map hide only the hover preview. +Close dismisses its callout; Escape dismisses the preview or the active pinned +callout. Drawing/deleting mode suppresses inspection. Hidden or deleted assets +lose their callouts. Pinning, dragging, and dismissal 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. +The `asset_hover` event also includes `inspector_handled` so custom hover UIs can +yield to the native preview. -HTML attachments execute only in an `allow-scripts` iframe sandbox, without +Producers should supply compact, responsive attachments with their own chart +controls and layout appropriate to a small frame. MC2 does not modify attachment +URLs or strip content from third-party charts. 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: +An embedder can hide the transient preview when a click moves outside its map iframe: ```javascript mapIframe.contentWindow.postMessage({ source: 'esip-embedder', type: 'close_inspector', map_id: mapId, + reason: 'outside', }, new URL(mapIframe.src).origin); ``` +Use `reason: 'escape'` to dismiss the active callout on an Escape keypress in the +parent application. Omitting the reason has the same behavior as `outside`. 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()`. +can call `window.ESIPMap.inspector?.close()` to close all previews and pins. diff --git a/server/mapcontrol_server/static/esip-contract.js b/server/mapcontrol_server/static/esip-contract.js index 942fbfb..9a3beda 100644 --- a/server/mapcontrol_server/static/esip-contract.js +++ b/server/mapcontrol_server/static/esip-contract.js @@ -106,8 +106,14 @@ hoverId = aid; map.getCanvas().style.cursor = "pointer"; var reg = registry[aid] || {}; + var inspector = window.ESIPMap && window.ESIPMap.inspector; + var handled = !!(inspector && inspector.claimHover && inspector.claimHover({ + asset_id: aid, feature: feats[0], point: point, + lngLat: [lngLat.lng, lngLat.lat], + })); emit("asset_hover", { asset_id: aid, + inspector_handled: handled, name: reg.name || null, asset_type: reg.asset_type || null, point: { x: point.x, y: point.y }, diff --git a/server/mapcontrol_server/static/esip-inspector.css b/server/mapcontrol_server/static/esip-inspector.css index 6fd5600..e4067a5 100644 --- a/server/mapcontrol_server/static/esip-inspector.css +++ b/server/mapcontrol_server/static/esip-inspector.css @@ -1,33 +1,25 @@ -/* 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; } -} +/* Independent of the optional layer outfit; callouts belong to this viewer. */ +.esip-inspector-layer { position: absolute; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; font: 12px/1.35 Inter, system-ui, sans-serif; } +.esip-inspector-connectors { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none; } +.esip-inspector-connectors line { stroke: var(--eo-text-muted, #64748b); stroke-width: 1; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 1px #fff); } +.esip-inspector-anchor { fill: var(--eo-accent, #5fa830); stroke: #fff; stroke-width: 1.5; vector-effect: non-scaling-stroke; } +.esip-inspector-card { position: absolute; width: min(320px, calc(var(--inspector-map-width, 100vw) - 16px)); max-height: calc(var(--inspector-map-height, 100vh) - 16px); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--eo-border, #dce2e9); border-radius: 7px; background: var(--eo-surface, #fff); color: var(--eo-text, #0f172a); box-shadow: 0 3px 14px #0003; box-sizing: border-box; pointer-events: auto; } +.esip-inspector-card.is-preview { pointer-events: none; box-shadow: 0 2px 10px #0002; } +.esip-inspector-header { display: flex; align-items: center; gap: 5px; height: 28px; padding: 0 6px; flex: 0 0 auto; user-select: none; touch-action: none; } +.is-pinned .esip-inspector-header { cursor: grab; } +.is-dragging .esip-inspector-header { cursor: grabbing; } +.esip-inspector-header h2 { margin: 0; font-size: 11px; font-weight: 600; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.esip-inspector-grip { color: var(--eo-text-muted, #64748b); font-size: 15px; } +.is-preview .esip-inspector-grip, .is-preview .esip-inspector-close, .is-pinned .esip-inspector-hint { display: none; } +.esip-inspector-hint { font-size: 10px; color: var(--eo-text-muted, #64748b); } +.esip-inspector-close { appearance: none; border: 0; border-radius: 4px; background: transparent; color: var(--eo-text-muted, #64748b); font: 18px/20px system-ui, sans-serif; width: 28px; height: 28px; margin-right: -5px; cursor: pointer; padding: 0; } +.esip-inspector-close:hover { background: var(--eo-surface-hover, #f1f5f9); color: var(--eo-text, #0f172a); } +.esip-inspector-header:focus-visible, .esip-inspector-close:focus-visible, .esip-inspector-link:focus-visible { outline: 2px solid var(--eo-accent, #5fa830); outline-offset: -2px; } +.esip-inspector-body { min-height: 0; overflow: auto; overscroll-behavior: contain; } +.esip-inspector-status { color: var(--eo-text-muted, #475569); margin: 12px; } +.esip-inspector-attachment + .esip-inspector-attachment { border-top: 1px solid var(--eo-border, #e2e8f0); } +.esip-inspector-frame { display: block; width: 100%; height: min(210px, calc(var(--inspector-map-height, 100vh) - 48px)); min-height: 80px; border: 0; background: #fff; } +.is-preview .esip-inspector-frame { pointer-events: none; } +.is-pinned .esip-inspector-frame { pointer-events: auto; } +.esip-inspector-image { display: block; width: 100%; height: 210px; object-fit: contain; } +.esip-inspector-link { display: inline-block; margin: 10px 12px; color: var(--eo-accent, #396d1b); text-decoration: underline; } diff --git a/server/mapcontrol_server/static/esip-inspector.js b/server/mapcontrol_server/static/esip-inspector.js index d598a74..910aafe 100644 --- a/server/mapcontrol_server/static/esip-inspector.js +++ b/server/mapcontrol_server/static/esip-inspector.js @@ -1,4 +1,4 @@ -/* Viewer-local inspection of metadata.extra.inspector. No map mutations. */ +/* Viewer-local chart previews and pinned callouts. No map mutations. */ (function () { "use strict"; @@ -26,70 +26,118 @@ function start() { var internals = window.__esipInternals; var api = window.ESIPMap; - if (!internals || !api || !window.maplibregl) return; + if (!internals || !api) return; var map = internals.map; - var selected = null; - var popup = null; - var expanded = false; - var signature = ""; - var frames = []; - var fetches = []; - var previousFocus = null; + var container = map.getContainer(); + var layer = node("div", "esip-inspector-layer"); + var svgNS = "http://www.w3.org/2000/svg"; + var connectors = document.createElementNS(svgNS, "svg"); + connectors.classList.add("esip-inspector-connectors"); + connectors.setAttribute("aria-hidden", "true"); + layer.appendChild(connectors); + container.appendChild(layer); + var pinned = new Map(); + var preview = null; + var pendingHover = null; + var hoverTimer = null; + var active = null; + var order = 0; + var drag = null; + var listeners = []; - 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 listen(name, handler) { + window.addEventListener(name, handler); + listeners.push([name, handler]); } + 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) { + function drawing() { return internals.isDrawing && internals.isDrawing(); } + function records() { return Array.from(pinned.values()).concat(preview ? [preview] : []); } + function anchorFor(detail) { + var geometry = detail.feature && detail.feature.geometry; + var anchor = geometry && geometry.type === "Point" ? geometry.coordinates.slice(0, 2) : (detail.lngLat || []).slice(0, 2); + return anchor.length === 2 && anchor.every(Number.isFinite) ? anchor : null; + } + function disposeContent(record) { + record.fetches.forEach(function (controller) { controller.abort(); }); + record.fetches = []; + record.frames = []; + } + function cancelHover() { + clearTimeout(hoverTimer); + hoverTimer = null; + pendingHover = null; + } + function removeCard(record) { + if (!record) return; + disposeContent(record); + if (drag && drag.record === record) drag = null; + record.el.remove(); + if (record.line) record.line.remove(); + if (record.anchorDot) record.anchorDot.remove(); + if (preview === record) preview = null; + pinned.delete(record.asset_id); + if (active === record) active = Array.from(pinned.values()).sort(function (a, b) { return b.order - a.order; })[0] || null; + if (!preview && !pinned.size && internals.deselectAsset) internals.deselectAsset(); + } + function hidePreview() { cancelHover(); removeCard(preview); } + function closeAll() { cancelHover(); records().forEach(removeCard); } + function dismissTop() { + if (preview || pendingHover) { hidePreview(); return; } + removeCard(active); + } + function raise(record) { + record.order = ++order; + record.el.style.zIndex = record.order; + if (record.pinned) active = record; + } + function project(anchor) { + return map.project ? map.project(anchor) : { x: container.clientWidth / 2, y: container.clientHeight / 2 }; + } + function clamp(value, min, max) { return Math.max(min, Math.min(value, Math.max(min, max))); } + function layout(record) { + var width = container.clientWidth; + var height = container.clientHeight; + record.el.style.setProperty("--inspector-map-width", width + "px"); + record.el.style.setProperty("--inspector-map-height", height + "px"); + var size = { width: record.el.offsetWidth, height: record.el.offsetHeight }; + var point = project(record.anchor); + if (!record.pinned || !record.position) { + // Glimpses follow their marker; pinned cards keep a screen position. + var left = point.x - size.width / 2; + var top = point.y - size.height - 14; + if (top < 8) top = point.y + 14; + record.position = { x: left, y: top }; + } + record.position.x = clamp(record.position.x, 8, width - size.width - 8); + record.position.y = clamp(record.position.y, 8, height - size.height - 8); + record.el.style.left = record.position.x + "px"; + record.el.style.top = record.position.y + "px"; + if (record.line) { + // Connect to the nearest card edge, rather than crossing the chart. + var x = clamp(point.x, record.position.x, record.position.x + size.width); + var y = clamp(point.y, record.position.y, record.position.y + size.height); + var inside = point.x >= record.position.x && point.x <= record.position.x + size.width && point.y >= record.position.y && point.y <= record.position.y + size.height; + record.line.style.visibility = inside ? "hidden" : "visible"; + record.line.setAttribute("x1", point.x); + record.line.setAttribute("y1", point.y); + record.line.setAttribute("x2", x); + record.line.setAttribute("y2", y); + record.anchorDot.setAttribute("cx", point.x); + record.anchorDot.setAttribute("cy", point.y); + } + } + function layoutAll() { records().forEach(layout); } + function htmlFrame(record, 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. + frame.tabIndex = record.pinned ? 0 : -1; var token = Math.random().toString(36).slice(2); - frames.push({ frame: frame, token: token }); + record.frames.push({ frame: frame, token: token }); var controller = new AbortController(); - fetches.push(controller); + record.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(); @@ -98,7 +146,6 @@ var base = document.createElement("base"); base.href = url; var bridge = """" @pytest.fixture(scope="module") @@ -64,72 +97,198 @@ def browser(): @pytest.fixture def page(browser): page = browser.new_page(viewport={"width": 900, "height": 650}) - page.route("http://fixture.test/**", lambda route: route.fulfill( - content_type="text/html", body="" - )) - page.set_content('
') + page.route("http://fixture.test/**", lambda route: route.fulfill(content_type="text/html", body=CHART_HTML)) + page.set_content('
') page.evaluate(MAP_SETUP_JS) + page.add_style_tag(path=str(STATIC / "esip-inspector.css")) page.add_script_tag(path=str(STATIC / "esip-contract.js")) page.add_script_tag(path=str(STATIC / "esip-inspector.js")) yield page page.close() -def test_claim_anchor_lifecycle_and_live_metadata(page): +def card(page, asset_id="a"): + return page.locator(f'.esip-inspector-card[data-asset-id="{asset_id}"]') + + +def chart_ready(page, asset_id="a"): + page.evaluate("""id => __esipInternals.handlers.update_metadata({ asset_id: id, metadata: { + title:'Repeated artifact title', description:'An interpretation that should not clutter the chart.', + extra: { inspector: { version:1, status:'ready', attachments:[{type:'html',url:'/figure.html',title:'Repeated artifact title'}] } } + } })""", asset_id) + + +def test_hover_preview_is_delayed_and_closes_on_leave(page): + page.evaluate("hoverAsset('a')") + page.wait_for_timeout(60) + assert card(page).count() == 0 + card(page).wait_for(state="visible") + assert "is-preview" in card(page).get_attribute("class") + assert "preparing" in card(page).locator(".esip-inspector-status").inner_text().lower() + assert page.evaluate("document.activeElement === document.body") + page.evaluate("leaveMap()") + card(page).wait_for(state="detached") + # A brief pass over a station must not produce a late orphaned preview. + page.evaluate("hoverAsset('a'); leaveMap()") + page.wait_for_timeout(350) + assert card(page).count() == 0 + + +def test_hover_promotes_without_reloading_chart_and_supports_multiple_pins(page): + chart_ready(page) + page.evaluate("hoverAsset('a')") + preview = card(page) + preview.wait_for(state="visible") + frame = preview.locator("iframe") + frame.wait_for() + frame.evaluate("frame => frame.dataset.chartState = 'preserved'") + assert preview.evaluate("el => el.inert") is True + assert frame.evaluate("frame => getComputedStyle(frame).pointerEvents") == "none" page.evaluate("clickAsset('a')") - assert page.locator(".esip-inspector-popup").count() == 1 - assert page.evaluate("popupAnchor") == [1, 2] + assert "is-pinned" in preview.get_attribute("class") + assert preview.evaluate("el => el.inert") is False + assert frame.get_attribute("data-chart-state") == "preserved" + assert frame.evaluate("frame => getComputedStyle(frame).pointerEvents") != "none" assert page.evaluate("clickEvents.at(-1).inspector_handled") is True - assert page.evaluate("clickEvents.at(-1).feature_id") == "feature-1" - assert "prepared" in page.locator(".esip-inspector-status").inner_text() - page.evaluate("""__esipInternals.handlers.update_metadata({ asset_id: 'a', metadata: { - description: 'Plain text description', extra: { inspector: { version: 1, status: 'ready', attachments: [ - { type: 'link', url: '/results/figure.html', title: 'View result' }, - { type: 'html', url: 'javascript:alert(1)' }, { type: 'image', url: 'data:image/svg+xml,bad' } - ] } } - } })""") - assert page.locator(".esip-inspector-popup").count() == 1 - assert page.locator(".esip-inspector-description b").count() == 0 - assert not page.locator(".esip-inspector-description").is_visible() - page.locator(".esip-inspector-details summary").click() - assert page.locator(".esip-inspector-description").is_visible() - assert page.locator(".esip-inspector-link").count() == 1 - assert page.locator(".esip-inspector-link").get_attribute("href") == "http://fixture.test/results/figure.html" - assert page.locator("iframe, img").count() == 0 - page.get_by_role("button", name="Expand", exact=True).click() - assert page.locator(".esip-inspector-popup.is-expanded").count() == 1 - page.evaluate("__esipInternals.handlers.set_visibility({asset_id:'a',visible:false})") - assert page.locator(".esip-inspector-popup").count() == 0 - - -def test_dismiss_switch_delete_draw_and_background(page): - page.evaluate("clickAsset('a'); clickAsset('b')") - assert page.locator(".esip-inspector-popup").count() == 0 + assert page.evaluate("clickEvents.at(-1).feature_id") == "feature-a" + page.evaluate("hoverAsset('c')") + card(page, "c").wait_for(state="visible") + page.evaluate("clickAsset('c'); leaveMap(); clickAsset(null); clickAsset('b')") + assert page.locator(".esip-inspector-card.is-pinned").count() == 2 assert page.evaluate("clickEvents.at(-1).inspector_handled") is False - page.evaluate("clickAsset('a')") + assert preview.locator("h2").count() == 1 + assert preview.locator("h3,details,summary,a").count() == 0 + assert "Repeated artifact title" not in preview.inner_text() + card(page, "c").locator(".esip-inspector-close").click() + assert card(page, "c").count() == 0 + assert card(page).count() == 1 + + +def test_escape_dismisses_preview_then_topmost_pin(page): + page.evaluate("clickAsset('a'); clickAsset('c')") page.keyboard.press("Escape") - assert page.locator(".esip-inspector-popup").count() == 0 - page.evaluate("clickAsset('a'); clickAsset(null)") - assert page.locator(".esip-inspector-popup").count() == 0 - page.evaluate("drawing=true; clickAsset('a')") - assert page.locator(".esip-inspector-popup").count() == 0 - page.evaluate("drawing=false; clickAsset('a'); __esipInternals.handlers.delete_asset({asset_id:'a'})") - assert page.locator(".esip-inspector-popup").count() == 0 - - -def test_html_opaque_sandbox_and_escape_inside_chart(page): - page.evaluate("""__esipInternals.handlers.update_metadata({ asset_id:'a', metadata: { extra: { inspector: { version:1, attachments:[{type:'html',url:'/figure.html'}] } } } }); clickAsset('a')""") - frame = page.locator(".esip-inspector-frame") + assert card(page, "c").count() == 0 + assert card(page).count() == 1 + page.evaluate("hoverAsset('c')") + card(page, "c").wait_for(state="visible") + page.keyboard.press("Escape") + assert card(page, "c").count() == 0 + assert card(page).count() == 1 + page.keyboard.press("Escape") + assert page.locator(".esip-inspector-card").count() == 0 + + +def test_live_updates_hide_and_delete_only_affected_pin(page): + page.evaluate("clickAsset('a'); clickAsset('c')") + second = card(page, "c") + second.evaluate("el => el.dataset.untouched = 'yes'") + before = card(page).bounding_box() + chart_ready(page) + assert card(page).locator("iframe").count() == 1 + assert card(page).bounding_box()["x"] == pytest.approx(before["x"]) + assert card(page).bounding_box()["y"] == pytest.approx(before["y"]) + assert second.get_attribute("data-untouched") == "yes" + page.evaluate("__esipInternals.handlers.set_visibility({asset_id:'c',visible:false})") + assert second.count() == 0 + assert card(page).count() == 1 + page.evaluate("__esipInternals.handlers.delete_asset({asset_id:'a'})") + assert card(page).count() == 0 + assert page.locator(".esip-inspector-connectors line").count() == 0 + assert page.locator(".esip-inspector-anchor").count() == 0 + + +def test_dragged_pins_stay_screen_fixed_while_connector_tracks_map_and_resize(page): + page.evaluate("clickAsset('a')") + selected = card(page) + selected.wait_for(state="visible") + header = selected.locator(".esip-inspector-header").bounding_box() + initial = selected.bounding_box() + page.mouse.move(header["x"] + 35, header["y"] + header["height"] / 2) + page.mouse.down() + page.mouse.move(header["x"] + 180, header["y"] + 75, steps=6) + page.mouse.up() + dragged = selected.bounding_box() + assert dragged["x"] > initial["x"] + 70 + assert dragged["y"] > initial["y"] + 20 + assert page.evaluate("mapInputs.pointerdown") == 0 + line = page.locator('.esip-inspector-connectors line[data-asset-id="a"]') + dot = page.locator('.esip-inspector-anchor[data-asset-id="a"]') + assert float(line.get_attribute("x1")) == pytest.approx(100) + assert float(line.get_attribute("y1")) == pytest.approx(200) + assert float(dot.get_attribute("cx")) == pytest.approx(100) + assert float(dot.get_attribute("cy")) == pytest.approx(200) + page.evaluate("moveMap(35, 20, 1.25)") + page.wait_for_function("Number(document.querySelector('.esip-inspector-connectors line[data-asset-id=\"a\"]').getAttribute('x1')) === 160") + assert selected.bounding_box()["x"] == pytest.approx(dragged["x"]) + assert selected.bounding_box()["y"] == pytest.approx(dragged["y"]) + assert float(line.get_attribute("x1")) == pytest.approx(160) + assert float(line.get_attribute("y1")) == pytest.approx(270) + assert float(dot.get_attribute("cx")) == pytest.approx(160) + assert float(dot.get_attribute("cy")) == pytest.approx(270) + page.evaluate("resizeMap(390, 320)") + page.wait_for_function("""() => { + const bounds=document.querySelector('.esip-inspector-card[data-asset-id="a"]').getBoundingClientRect(); + return bounds.right <= 390 && bounds.bottom <= 320; + }""") + resized = selected.bounding_box() + assert resized["x"] >= 0 and resized["y"] >= 0 + assert resized["x"] + resized["width"] <= 390 + assert resized["y"] + resized["height"] <= 320 + + +def test_pinned_chart_receives_pointer_and_wheel_without_dragging_map(page): + chart_ready(page) + page.evaluate("clickAsset('a')") + inner = page.frame_locator('.esip-inspector-card[data-asset-id="a"] iframe') + inner.get_by_role("button", name="Inside chart").click() + assert inner.get_by_role("button", name="Chart changed").count() == 1 + plot = inner.locator("#plot") + plot.hover() + page.mouse.down() + page.mouse.move(200, 250, steps=3) + page.mouse.up() + plot.hover() + page.mouse.wheel(0, 100) + assert plot.get_attribute("data-dragged") == "yes" + # Dispatch can be async relative to Playwright's mouse.wheel completion. + inner.locator('#plot[data-zoomed="yes"]').wait_for(state="visible") + assert page.evaluate("mapInputs") == {"pointerdown": 0, "wheel": 0} + + +def test_html_sandbox_and_source_checked_escape_bridge(page): + chart_ready(page) + page.evaluate("clickAsset('a'); clickAsset('c')") + frame = card(page).locator("iframe") assert frame.get_attribute("sandbox") == "allow-scripts" page.wait_for_function("document.querySelector('.esip-inspector-frame').srcdoc.includes('esip-inspector-artifact')") - page.frame_locator(".esip-inspector-frame").get_by_role("button", name="Inside chart").click() + page.evaluate("window.postMessage({source:'esip-inspector-artifact',token:'forged'}, '*')") + assert card(page).count() == 1 + content_frame = frame.element_handle().content_frame() + assert content_frame.evaluate("() => { try { parent.document.body; return false; } catch (_) { return true; } }") + content_frame.get_by_role("button", name="Inside chart").click() page.keyboard.press("Escape") - page.wait_for_function("!document.querySelector('.esip-inspector-popup')") + card(page).wait_for(state="detached") + assert card(page, "c").count() == 1 + + +def test_unsafe_urls_filtered_and_link_only_attachments_remain_available(page): + page.evaluate("""__esipInternals.handlers.update_metadata({asset_id:'a',metadata:{extra:{inspector:{version:1,status:'ready',attachments:[ + {type:'html',url:'javascript:alert(1)'}, {type:'image',url:'data:image/svg+xml,bad'}, + {type:'link',url:'/results/figure.html',title:'View result'} + ]}}}}); clickAsset('a')""") + assert card(page).locator("iframe,img").count() == 0 + link = card(page).get_by_role("link", name="View result") + assert link.get_attribute("href") == "http://fixture.test/results/figure.html" + assert link.get_attribute("rel") == "noopener noreferrer" + + +def test_drawing_mode_suppresses_previews_and_pins(page): + page.evaluate("drawing=true; hoverAsset('a'); clickAsset('a')") + page.wait_for_timeout(350) + assert page.locator(".esip-inspector-card").count() == 0 def test_auto_session_preserves_embed_options_and_hash(page): - # Execute the actual bootstrap URL-rewrite block independently of tiles - # and WebSocket timing, so this regression needs no live tile provider. source = (STATIC.parent / "main.py").read_text() block = source.split("window.USER_SESSION_AUTO = data.user_session_id;", 1)[1] block = block.split("// Rebuild WS URL with the new session", 1)[0] @@ -148,21 +307,6 @@ def test_auto_session_preserves_embed_options_and_hash(page): } - -def test_mobile_sheet_overrides_maplibre_top_position(page): - page.set_viewport_size({"width": 390, "height": 750}) - # MapLibre's baseline top:0 must be explicitly released for a bottom sheet. - page.add_style_tag(content=".esip-inspector-popup { position:absolute; top:0; left:0; }") - page.add_style_tag(path=str(STATIC / "esip-inspector.css")) - page.evaluate("document.querySelector('#map').style.cssText='position:relative;width:390px;height:750px'; document.body.style.margin='0'; clickAsset('a')") - popup = page.locator(".esip-inspector-popup") - bounds = popup.bounding_box() - assert bounds is not None - assert bounds["y"] + bounds["height"] == pytest.approx(742, abs=1) - page.get_by_role("button", name="Expand", exact=True).click() - assert popup.bounding_box()["y"] == pytest.approx(8, abs=1) - - def test_versioned_contract_recovers_inspector_with_warm_browser_cache(browser): """Reproduce an upgrade in one browser context, without disabling its cache. @@ -231,13 +375,13 @@ def log_message(self, *_args): page.evaluate("clickAsset('a')") assert page.evaluate("clickEvents.at(-1).asset_id") == "a" assert page.evaluate("clickEvents.at(-1).inspector_handled") is None - assert page.locator(".esip-inspector-popup").count() == 0 + assert page.locator(".esip-inspector-card").count() == 0 assert requests["/static/esip-contract.js"] == 1 # Still the old browser cache entry. page.goto(base_url + "/after-versioned") page.evaluate("clickAsset('a')") assert page.evaluate("clickEvents.at(-1).inspector_handled") is True - assert page.locator(".esip-inspector-popup").count() == 1 + assert page.locator(".esip-inspector-card").count() == 1 assert page.get_by_role("heading", name="Station A", exact=True).count() == 1 assert requests[_ui_asset_url("", "esip-contract.js")] == 1 assert requests["/static/esip-contract.js"] == 1