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