Skip to content

Commit fe748be

Browse files
beetlebugorgclaude
andcommitted
feat(web): precise DOM tap pads for INFORM01 info callouts
The cursor-pick can't "own" an info callout: the SY(INFORM01) box floats offset from the feature, but the symbol's hit quad is centred on the feature, so the fuzzy queryRenderedFeatures makes the whole symbol area "close enough" and symbol declutter / z-order drops some boxes entirely ("some work, some don't"). Overlay a transparent, exactly-positioned DOM pad on each visible box instead — a real clickable element (like the AIS-target Markers), placed via the feature's baked icon `scale` + the live size-scale so it tracks the rendered box. Tap → showInfoForFeature opens that object's additional information; tapping the feature itself still picks the feature. Sparse by design (only info-bearing features), so DOM markers are affordable here — unlike the general pick, which must stay on GPU queryRenderedFeatures. Follows the "Information callouts" toggle for free (symbol unrendered → no pads). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93b4165 commit fe748be

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

web/src/chartplotter.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { ConnectionsController } from "./plugins/connections.mjs"; // NMEA0183 d
2727
import { VesselStateStore } from "./data/vessel-state-store.mjs"; // live NMEA0183 vessel state (own-ship/AIS/HUD feed)
2828
import { OwnShip } from "./plugins/own-ship.mjs"; // own-ship marker + course predictor + follow camera
2929
import { AISOverlay } from "./plugins/ais-overlay.mjs"; // AIS targets (other vessels) from the live feed
30+
import { InfoCallouts } from "./plugins/info-callouts.mjs"; // precise DOM tap pads on INFORM01 info-callout boxes
3031
import "./plugins/target-info.mjs"; // defines <target-info> (own-ship / AIS tap-info picker)
3132
import { PALETTE_DAY_ICON, PALETTE_DUSK_ICON, PALETTE_NIGHT_ICON } from "./lib/openbridge-icons.mjs"; // OpenBridge scheme glyphs
3233
import { DISTRICTS, NOAA_ENC_URL } from "./plugins/chart-library.mjs"; // NOAA CG-district packs + ENC page (shared)
@@ -661,6 +662,15 @@ export class ChartPlotter extends HTMLElement {
661662
this._ownShip = new OwnShip({ map, plotter: this._plotter, vessel: this._vessel, host: this.shadowRoot, onSelect: showInfo, units: () => this._mariner });
662663
// AIS targets (other vessels) from the live feed.
663664
this._ais = new AISOverlay({ map, assets: this._assets, widget: this._widget, onSelect: showInfo, units: () => this._mariner });
665+
// Precise DOM tap pads on the INFORM01 "additional information" callout boxes
666+
// (the box floats offset from the feature, so the fuzzy symbol pick can't own
667+
// it). Sparse by nature — only info-bearing features — so DOM markers are fine.
668+
this._infoCallouts = new InfoCallouts({
669+
map,
670+
getSizeScale: () => (this._plotter && this._plotter._featureSizeScale ? this._plotter._featureSizeScale() : 1),
671+
atlasPpu: (this._plotter && this._plotter._atlasPpu) || 0.08,
672+
onSelect: (f) => this.showInfoForFeature(f),
673+
});
664674
}
665675

666676
// Persist the view so a refresh resumes where you were; refresh the coverage
@@ -1298,6 +1308,20 @@ export class ChartPlotter extends HTMLElement {
12981308
el.show(uniq, ev ? { x: ev.clientX, y: ev.clientY } : null);
12991309
}
13001310

1311+
// Open the pick report for ONE feature — the info-callout pads (InfoCallouts) call
1312+
// this when their box is tapped, so a callout surfaces exactly its own object's
1313+
// additional information rather than a cursor-pick of whatever's under the box.
1314+
showInfoForFeature(f) {
1315+
if (!f) return;
1316+
const el = this._ensurePickEl();
1317+
if (!el) return;
1318+
f._hiGeom = f.geometry;
1319+
el.setCatalogue(this._s57cat);
1320+
el.setAux(this._aux);
1321+
el.setUnits(this._mariner);
1322+
el.show([f], null);
1323+
}
1324+
13011325
// Create the cursor-pick panel on first use and bridge it to the map highlight.
13021326
// Returns null if <pick-report> hasn't been defined (module failed to load).
13031327
_ensurePickEl() {

web/src/plugins/info-callouts.mjs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// InfoCallouts gives the S-52 §10.6.1.1 "additional information available" markers
2+
// (SY(INFORM01), the box-on-a-leader) a PRECISE, layering-proof tap target.
3+
//
4+
// The marker is a baked map symbol whose icon hit-quad is centred on the FEATURE,
5+
// so MapLibre's fuzzy queryRenderedFeatures makes the whole symbol area tappable
6+
// ("close enough") and symbol declutter/z-order makes some boxes un-pickable. This
7+
// overlay instead drops a transparent, exactly-sized DOM pad on each visible box
8+
// (a real clickable element, like the AIS-target Markers) — tapping it opens that
9+
// feature's info, and tapping the feature itself is left to pick the feature.
10+
//
11+
// It is purely an INTERACTION layer: the baked INFORM01 sprite stays the visual
12+
// box-on-leader; the pad is invisible and sits on top. It follows the mariner
13+
// toggle for free — when "Information callouts" is off the symbol isn't rendered,
14+
// so queryRenderedFeatures returns none and no pads are placed.
15+
16+
// The INFORM01.svg "i" box, relative to the sprite pivot (the feature), in mm: the
17+
// box centre and (square) size. Used to place + size the pad over the rendered box.
18+
const BOX_CENTRE_MM = [12.4, -12.6]; // +x right, -y up (SVG y is down)
19+
const BOX_SIZE_MM = 5.0;
20+
const MIN_PAD_PX = 22; // touch-friendly floor, regardless of zoom-independent symbol size
21+
22+
export class InfoCallouts {
23+
constructor({ map, getSizeScale, atlasPpu = 0.08, onSelect } = {}) {
24+
this._map = map;
25+
this._getSizeScale = getSizeScale || (() => 1);
26+
this._atlasPpu = atlasPpu || 0.08;
27+
this._onSelect = onSelect; // (feature) => open its info
28+
this._markers = new Map(); // key -> { marker, el }
29+
this._timer = 0;
30+
this._refresh = this._refresh.bind(this);
31+
this._schedule = this._schedule.bind(this);
32+
map.on("moveend", this._schedule);
33+
map.on("idle", this._schedule);
34+
this._schedule();
35+
}
36+
37+
_schedule() {
38+
clearTimeout(this._timer);
39+
this._timer = setTimeout(this._refresh, 120); // debounce the pan/zoom churn
40+
}
41+
42+
// px the rendered icon occupies per sprite-mm: the atlas is 8 px/mm, scaled by the
43+
// feature's baked icon scale (scale/atlasPpu) and the physical-size multiplier. So
44+
// the pad tracks the ACTUAL rendered box even while the symbol size is calibrated.
45+
_pxPerMM(scale) {
46+
return 8 * ((scale || 0.0378) / this._atlasPpu) * this._getSizeScale();
47+
}
48+
49+
_pointSymbolLayers() {
50+
try {
51+
return this._map.getStyle().layers
52+
.filter((l) => l.type === "symbol" && /point_symbols/.test(l.id))
53+
.map((l) => l.id);
54+
} catch {
55+
return [];
56+
}
57+
}
58+
59+
_refresh() {
60+
const m = this._map;
61+
const layers = this._pointSymbolLayers();
62+
let feats = [];
63+
try {
64+
feats = (layers.length ? m.queryRenderedFeatures({ layers }) : m.queryRenderedFeatures())
65+
.filter((f) => f.properties && f.properties.symbol_name === "INFORM01" && f.geometry && f.geometry.type === "Point");
66+
} catch {
67+
return;
68+
}
69+
const seen = new Set();
70+
for (const f of feats) {
71+
const p = f.properties;
72+
const key = (p.cell || "") + "|" + (p.class || "") + "|" + f.geometry.coordinates.join(",");
73+
if (seen.has(key)) continue;
74+
seen.add(key);
75+
const pxmm = this._pxPerMM(+p.scale);
76+
const off = [BOX_CENTRE_MM[0] * pxmm, BOX_CENTRE_MM[1] * pxmm];
77+
const size = Math.max(MIN_PAD_PX, BOX_SIZE_MM * pxmm);
78+
let rec = this._markers.get(key);
79+
if (!rec) {
80+
const el = document.createElement("div");
81+
el.className = "info-callout-pad";
82+
el.title = "Additional information — tap to view";
83+
// Invisible by default (the baked S-52 box stays the visual); a faint ring on
84+
// hover/touch shows it's the live tap target. pointer-events auto so it owns
85+
// the click; box-sizing so the ring doesn't grow it.
86+
el.style.cssText =
87+
"box-sizing:border-box;border-radius:4px;cursor:pointer;pointer-events:auto;" +
88+
"background:transparent;border:2px solid transparent;transition:border-color .1s;";
89+
el.addEventListener("pointerenter", () => { el.style.borderColor = "var(--info-callout-hi,#cc3aa8)"; });
90+
el.addEventListener("pointerleave", () => { el.style.borderColor = "transparent"; });
91+
// Stop the click reaching the map so it doesn't also fire the cursor-pick.
92+
const open = (e) => { e.stopPropagation(); e.preventDefault(); if (this._onSelect) this._onSelect(rec ? rec.feat : f); };
93+
el.addEventListener("click", open);
94+
el.addEventListener("pointerdown", (e) => e.stopPropagation());
95+
const marker = new window.maplibregl.Marker({ element: el, anchor: "center", offset: off }).setLngLat(f.geometry.coordinates).addTo(m);
96+
rec = { marker, el, feat: f };
97+
this._markers.set(key, rec);
98+
} else {
99+
rec.feat = f;
100+
rec.marker.setOffset(off);
101+
rec.marker.setLngLat(f.geometry.coordinates);
102+
}
103+
rec.el.style.width = rec.el.style.height = `${Math.round(size)}px`;
104+
}
105+
for (const [k, rec] of this._markers) {
106+
if (!seen.has(k)) {
107+
rec.marker.remove();
108+
this._markers.delete(k);
109+
}
110+
}
111+
}
112+
113+
destroy() {
114+
clearTimeout(this._timer);
115+
this._map.off("moveend", this._schedule);
116+
this._map.off("idle", this._schedule);
117+
for (const rec of this._markers.values()) rec.marker.remove();
118+
this._markers.clear();
119+
}
120+
}

0 commit comments

Comments
 (0)