doxa-maps/library/composables/useSelectionCamera.js
Doxa Maps 15820f1432 Dashboard kit, landing 3: the camera and the CSV exporter
Both are zero-domain in the sense the spec meant: they import only `vue` and read
rows through accessors. Bodies are what research-dashboard shipped apart from the
two parameters the spec named and one policy object, and the old paths re-export
for one cycle.

★ THE FIRST STEP FOUND TWO GATES THAT READ THESE FILES BY PATH, which is why it is
now the first step. Landing 1 nearly disarmed check-facet-cascade the same way.
  internal/check-csv-export-shape.mjs does not import useCsvExport; it READS the
  source and extracts esc() out of it. Left at the plugin path it would have read
  the one-line shim, found no esc(), and failed with "could not extract esc()" — a
  gate accusing a working exporter because its subject moved.
  check-filter-multivalue.mjs — MINE — lists the same file in its PANELS array.
  Left at the plugin path it would have read the shim, found no predicate to
  object to, and PASSED VACUOUSLY. That is worse: a gate going quiet about the
  thing it guards. Both move in this commit.

THE COORDINATE IS A PARAMETER; WHAT MAKES A COORDINATE VALID IS NOT. `coords`
replaces only the READ of row.longitude / row.latitude, which is pray-tools'
spelling and not a universal one. Every guard stays shared: Number() on API
strings, 0 accepted per axis, the exact 0,0 null-island sentinel rejected, the
out-of-range check. A kit that let each caller re-implement that validation would
hand the next dashboard the same four bugs to rediscover.

★ AND THE OPTION WAS DEAD WHEN I FIRST WROTE IT. `coords` was accepted in the
signature and never passed to selectionBounds, so a caller could set it,
type-check clean, build clean, and watch it do nothing. This tree has paid for
that shape before — a defineProps on a component the custom element never binds —
and it is worse than a missing feature because it READS as supported. Nobody files
a bug against a knob they assume they misused. check:kit-options-reachable now
asserts, across all four shared composables, that every option accepted is used.

THE CAMERA'S EIGHT CONSTANTS ARE ONE DECISION, so CAMERA_POLICY exports them
together. The failure that prevents is a second dashboard re-picking them one at a
time and arriving at a camera that feels different from Big Picture's for no reason
anybody wrote down. It is read-only and deliberately not yet overridable: making
each a per-caller option means threading eight of them through every use, and no
consumer has asked for a different camera. Exporting the decision so it can be
cited is the half with a use today; saying so is better than shipping eight
options nobody passes.

THE EXPORTER GAINS exportLabel and sources. "Research Dashboard" was this
dashboard's name, and the provenance line said nothing about where the data came
from — which is what a provenance line is for. Both default to today's output, to
the byte.

★ AND THE NEW GATE HAD THE COMMENT HOLE TWICE IN ONE SITTING. First it searched
the raw body, and the comment beside the fixed call site says "`coords` IS PASSED
HERE" — so the mutant that removed the argument left it green. Then, stripping
comments only per-part, it split the signature's comment PROSE on commas and
reported that useCsvExport accepts `and`. Comments are text; every text search has
to say so, in both directions. That is the fourth variant of one mistake this
week, and all four were caught by running the mutant rather than by reading.

Library index regenerated in the same commit, 103 items. Source only.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. The
internal/ hunk is one line in check-csv-export-shape.mjs.
2026-09-27 01:16:52 +00:00

272 lines
14 KiB
JavaScript

/**
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
*
* Subscribes to the selection bus and drives the map camera ONCE per committed selection
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps
* `bus.selectionVersion`; this composable watches that version. That is exactly the
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
* no table code.
*
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the Driver is photosensitive) ──────────────
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
*
* 1. ONE animation per committed selection change — never per frame, never per row.
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
* so selecting 2,107 rows emits ONE version bump, not 2,107.
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
* many times in a few hundred ms; only the final state moves the camera. Each new bump
* CANCELS the pending move, so intermediate states are never animated.
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
* easing. Same destination, zero traversal, no optical flow across the viewport.
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
* callback, i.e. at most once per settled selection, never inside a computed/watcher
* that the map redraw depends on.
*
* ── COST ──────────────────────────────────────────────────────────────────────────────
* One O(n) pass over the selected rows per settled change (n = selection size), using the
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
* happens once, after the debounce, off the render path.
*/
import { watch } from 'vue'
// ── Camera policy constants ───────────────────────────────────────────────────────────
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
// cascade, short enough that a single checkbox click still feels immediate.
const CAMERA_DEBOUNCE_MS = 150
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
// and the filter camera feel like the same gesture.
const FLY_DURATION_MS = 1200
// Viewport padding so selected pins never sit flush against the panel edge.
const FIT_PADDING_PX = 70
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
const FIT_MAX_ZOOM = 5.5
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
const SINGLE_ROW_ZOOM = 6
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
// sit within metres of each other), which is a zero-area bbox by another route. Below this
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
const DEGENERATE_SPAN_DEG = 0.01
// Past this longitude span a selection effectively wraps the globe; framing it literally
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
const WIDE_SPAN_DEG = 110
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
/**
* ★ THE CAMERA'S POLICY IN ONE NAMED PLACE (dashboard-kit landing 3).
*
* The eight constants above are not magic numbers; together they are a decision about how this
* camera behaves, and the reason for each is in the comment beside it. Collecting them here is what
* the kit spec asked for, and the failure it prevents is specific: a second dashboard re-picking
* them one at a time, arriving at a camera that feels different from Big Picture's for no reason
* anybody wrote down. A consumer that cites CAMERA_POLICY.FLY_DURATION_MS can be asked why it
* differs; a consumer that retypes 1200 has made an invisible decision.
*
* ★ READ-ONLY, AND DELIBERATELY NOT YET OVERRIDABLE. Making each value a per-caller option means
* threading eight of them through every use, and no consumer has asked for a different camera.
* Exporting the decision so it can be READ and CITED is the half that has a use today; the override
* belongs with the first consumer that needs one, who will also be able to say which value and why.
* Saying that here is better than shipping eight options nobody passes.
*/
export const CAMERA_POLICY = Object.freeze({
CAMERA_DEBOUNCE_MS, FLY_DURATION_MS, FIT_PADDING_PX, FIT_MAX_ZOOM,
SINGLE_ROW_ZOOM, DEGENERATE_SPAN_DEG, WIDE_SPAN_DEG, WORLD_ZOOM,
})
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
* preference can be toggled mid-session, and a stale `false` would animate at them. */
function prefersReducedMotion() {
try {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
} catch (_) { return false }
}
/**
* Numeric coordinate pair for a row, or null when unusable.
*
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
*
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
* sentinel that a missing geocode degrades into. Flying there would show the camera an
* empty patch of the Gulf of Guinea.
*/
// ★ HOW a row carries its coordinate is a parameter; WHAT makes a coordinate usable is not.
// `row.longitude` / `row.latitude` is pray-tools' spelling, not a universal one — the next
// dashboard's rows put the same idea under different keys. So only the READ is configurable, and
// every guard below it (Number() on API strings, 0 accepted per-axis, the exact 0,0 null-island
// sentinel rejected, the out-of-range check) applies to every consumer. A kit that let each caller
// re-implement the validation would hand the next dashboard the same four bugs to rediscover.
const DEFAULT_COORD_READ = (row) => [row.longitude, row.latitude]
function coordsOf(row, readPair = DEFAULT_COORD_READ) {
if (!row) return null
const pair = readPair(row) || []
const lng = Number(pair[0])
const lat = Number(pair[1])
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
return [lng, lat]
}
/**
* Antimeridian-aware longitude extent.
*
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
*
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
* and wraps correctly.
*/
function lngExtent(longitudes) {
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
for (const lng of longitudes) {
if (lng < aMin) aMin = lng
if (lng > aMax) aMax = lng
const shifted = lng < 0 ? lng + 360 : lng
if (shifted < bMin) bMin = shifted
if (shifted > bMax) bMax = shifted
}
if (!Number.isFinite(aMin)) return null
const spanA = aMax - aMin
const spanB = bMax - bMin
if (spanB < spanA) {
const minRaw = bMin > 180 ? bMin - 360 : bMin
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
}
return { minLng: aMin, maxLng: aMax, span: spanA }
}
/**
* Bounding box of the selected rows.
*
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
* row array per slug, so a 2,107-row selection stays one linear pass.
*
* @returns {null | { kind:'point', center:[lng,lat] }
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
* null when NOTHING is selected or no selected row has usable coordinates —
* the caller must then leave the camera alone.
*/
export function selectionBounds(selectedSlugs, rowStore, readPair = DEFAULT_COORD_READ) {
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
const lngs = []
let minLat = Infinity, maxLat = -Infinity
let first = null
let count = 0
for (const slug of selectedSlugs) {
const c = coordsOf(rowStore.get(slug), readPair)
if (!c) continue // missing/null/invalid coords — skip the row
if (count === 0) first = c
lngs.push(c[0])
if (c[1] < minLat) minLat = c[1]
if (c[1] > maxLat) maxLat = c[1]
count++
}
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
if (count === 0) return null
if (count === 1) return { kind: 'point', center: first }
const ext = lngExtent(lngs)
if (!ext) return { kind: 'point', center: first }
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
}
return {
kind: 'bounds',
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
span: ext.span,
}
}
/**
* Wire selection -> camera. Call once from the map panel's setup.
*
* @param {Object} o
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
* @param {Function} o.getMap - () => the live map instance (null before ready)
* @param {Function} o.getRowStore - () => Map<slug, row>
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
* @returns {Function} teardown — clears the pending timer and stops the watcher
*/
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true,
// `coords` is the one data-shape assumption this file makes. Default = the fields
// research-dashboard has always used, so a caller that passes nothing is byte-for-byte unchanged.
coords = DEFAULT_COORD_READ }) {
if (!bus || !bus.selectionVersion) return () => {}
let timer = null
function moveCamera() {
timer = null
const map = getMap && getMap()
if (!map || !isEnabled()) return
// ★ `coords` IS PASSED HERE, and leaving it out was a real bug I nearly shipped: the option was
// accepted in the signature and never reached selectionBounds, so a caller could set it, type-
// check clean, build clean, and watch it do nothing. That is the presence-vs-reachability trap
// this tree has paid for before (a defineProps on a component the custom element never binds).
// An option that cannot be observed to work is worse than no option: it reads as supported.
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore(), coords)
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
if (!target) return
// Reduced motion: same destination, zero traversal.
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
try {
if (target.kind === 'point') {
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
return
}
if (target.span > WIDE_SPAN_DEG) {
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
// camera swing the wrong way around the antimeridian — the visible "snap".
mapFly.flyTo(
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
(target.bounds[0][1] + target.bounds[1][1]) / 2],
{ zoom: WORLD_ZOOM, duration, essential: true },
)
return
}
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
})
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
}
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
// this callback ONCE — and the debounce collapses even that into a single camera move.
const stop = watch(() => bus.selectionVersion.value, () => {
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
})
return function teardown() {
if (timer) { clearTimeout(timer); timer = null }
try { stop() } catch (_) {}
}
}