loopable / web /src /customer-grid /mapProjection.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
dcdb685 verified
Raw
History Blame Contribute Delete
26.1 kB
// ---------------------------------------------------------------------------
// customer-grid / mapProjection.ts
// Wave-8 I2/I6 β€” the map's geometry, split from MapView so it can be tested
// under node without React.
//
// WHY THIS EXISTS AS ITS OWN LAYER. The wave-7 map derived its projection from
// the DATA's bounding box (MapView.tsx:96-118, an equirectangular fit). That is
// fine for a static scatter and wrong for everything wave 8 asks of it:
//
// - the map re-projected on every filter change, so the whole picture jumped
// whenever a condition was edited;
// - box-select needs a stable screen<->point mapping DURING a drag, which a
// projection memoised over `points` is not;
// - a data-fit projection distorts real geography β€” state outlines drawn
// through it visibly skew once you zoom into one metro.
//
// So the two concerns are separated:
// PROJECTION fixed Web Mercator, world -> a fixed square. Never changes.
// VIEW {k, tx, ty} β€” zoom and pan, an affine transform ON TOP.
// The old data-fit becomes the INITIAL VIEW value rather than the projection,
// which is what makes zoom/pan and hit-testing tractable at all.
// ---------------------------------------------------------------------------
/** Base resolution of the projected world square, in SVG user units. */
export const WORLD = 4096;
/** Mercator blows up at the poles; every web map clamps. */
const MAX_LAT = 85.05112878;
export interface Pt {
x: number;
y: number;
}
/** A pan/zoom transform: screen = view.t + view.k * projected. */
export interface View {
k: number;
tx: number;
ty: number;
}
/** lon/lat -> the fixed projected plane (0..WORLD on both axes). */
export function project(lon: number, lat: number): Pt {
const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
const rad = (clamped * Math.PI) / 180;
const x = (lon + 180) / 360;
const y = 0.5 - Math.log(Math.tan(Math.PI / 4 + rad / 2)) / (2 * Math.PI);
return { x: x * WORLD, y: y * WORLD };
}
/** The fixed projected plane -> lon/lat. The exact inverse of `project`. */
export function unproject(p: Pt): { lon: number; lat: number } {
const t = 0.5 - p.y / WORLD;
const rad = 2 * Math.atan(Math.exp(2 * Math.PI * t)) - Math.PI / 2;
return { lon: (p.x / WORLD) * 360 - 180, lat: (rad * 180) / Math.PI };
}
/** Screen -> the fixed projected plane, through a view. The inverse of `toScreen`. */
export function fromScreen(s: Pt, view: View): Pt {
return { x: (s.x - view.tx) / view.k, y: (s.y - view.ty) / view.k };
}
// -------------------------------------------------------- the Google hand-off
//
// The honest answer to "I need to see the actual street". A licence-free offline
// vector basemap stops at roughly metro scale (see BASEMAP_DETAIL_K), and the
// alternative β€” bundling a tile renderer β€” costs 270 KB gz, an API key or a
// hosted planet file, and sends every customer's coordinates to a third party on
// every pan. A LINK costs none of that: nothing ships, nothing is fetched, and
// the coordinates travel only if the user deliberately clicks.
//
// ⚠ Every one of these must be rendered with rel="noopener noreferrer" β€” that
// strips the Referer, so the destination never learns which tenant or which
// deployment the click came from, and it denies the opened tab window.opener.
/**
* Google Maps' zoom level for our zoom `k`.
*
* Google measures a world 256 * 2^z px wide; ours is WORLD units wide, painted
* at `k` and then ~1.08 CSS px per unit. Equating the two:
* 256 * 2^z = WORLD * k * 1.08 -> z = log2(WORLD * 1.08 * k / 256)
* Clamped to Google's own 0..21. A fitted US book (k~1.36) hands over z5, the
* country; the detail cap (k=32) hands over z9, a metro β€” i.e. the hand-off
* starts exactly where our basemap runs out, which is the point of it.
*/
export function googleZoomForK(k: number): number {
if (!Number.isFinite(k) || k <= 0) return 4;
const z = Math.log2((WORLD * 1.08 * k) / 256);
return Math.max(0, Math.min(21, Math.round(z)));
}
/** True only for a coordinate Google can actually be sent. */
export function isPlottable(lat: number | null, lon: number | null): boolean {
return (
lat != null && lon != null &&
Number.isFinite(lat) && Number.isFinite(lon) &&
Math.abs(lat) <= 90 && Math.abs(lon) <= 180
);
}
/**
* A dropped pin at exactly the coordinate WE plotted.
*
* ⚠ Deliberately by lat/lon and never by customer name or address: a name search
* can resolve somewhere else entirely, and then the app's map and the link
* disagree about where a customer is. The pin is the geocode; the link is the
* same geocode. `api=1` is Google's documented, stable URL contract.
*/
export function googleMapsUrl(lat: number, lon: number, zoom?: number): string {
const at = `${lat.toFixed(6)},${lon.toFixed(6)}`;
return zoom == null
? `https://www.google.com/maps/search/?api=1&query=${at}`
: `https://www.google.com/maps/@${at},${Math.round(zoom)}z`;
}
/** Directions to a customer β€” the version a rep on the road actually wants. */
export function googleDirectionsUrl(lat: number, lon: number): string {
return `https://www.google.com/maps/dir/?api=1&destination=${lat.toFixed(6)},${lon.toFixed(6)}`;
}
/** Projected point -> screen, through a view. */
export function toScreen(p: Pt, view: View): Pt {
return { x: view.tx + p.x * view.k, y: view.ty + p.y * view.k };
}
/**
* The view that frames `pts` inside a w x h viewport with `pad` px of margin.
* `minSpan` stops a single point (or one city) from zooming to street level:
* one customer should still look like a PLACE, not a full-bleed dot β€” the
* wave-7 behaviour, kept.
*/
export function fitView(
pts: Pt[],
w: number,
h: number,
pad = 40,
minSpan = WORLD / 90
): View | null {
if (pts.length === 0) return null;
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
let spanX = Math.max(maxX - minX, minSpan);
let spanY = Math.max(maxY - minY, minSpan);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
spanX *= 1.16; // breathing room so edge pins are not on the frame
spanY *= 1.16;
const k = Math.min((w - pad * 2) / spanX, (h - pad * 2) / spanY);
return { k, tx: w / 2 - cx * k, ty: h / 2 - cy * k };
}
/** Zoom by `factor` while holding the point under (mx, my) still β€” the gesture
* every map has and the reason zoom cannot be a plain scale on the group. */
export function zoomAt(view: View, factor: number, mx: number, my: number, kMin: number, kMax: number): View {
const k = Math.max(kMin, Math.min(kMax, view.k * factor));
if (k === view.k) return view;
return {
k,
tx: mx - ((mx - view.tx) * k) / view.k,
ty: my - ((my - view.ty) * k) / view.k,
};
}
// ---------------------------------------------------------------- stroke width
//
// ⚠ THE MAP HAS EXACTLY ONE STROKE-WIDTH MECHANISM, AND THIS IS IT.
//
// Everything painted inside `<g transform="... scale(k)">` is scaled by k, so a
// line meant to read 1.1 px on screen must be handed 1.1/k. That is `hairline`.
// SVG offers a SECOND way to the same end β€” the CSS `vector-effect:
// non-scaling-stroke`, which makes the renderer ignore the transform when it
// strokes. Either works. Using BOTH cancels the zoom twice, and the line then
// gets THINNER the further you zoom IN.
//
// Wave 9 found exactly that, and it is the "blurry when zoomed" bug the owner
// reported. `.cg-map-land` carried the CSS property AND `hair(1.1)`. Measured
// against the real constants (WORLD 4096, viewBox 1000x620, ~1.10 CSS px per
// viewBox unit, fitted k ~1.359): the coastline painted 0.89 CSS px at the
// fitted view, 0.445 at 2x, 0.089 at 10x and 0.015 at the zoom cap β€” below
// ~0.5 px a stroke is an anti-aliased smear and below ~0.2 px a ghost. The
// graticule, the lakes and the pins were all correct, because they use
// `hairline` alone. The bug hid precisely because two idioms coexisted on
// different elements of the same picture.
//
// So the rule is singular now, and it is GATED rather than merely commented:
// `paintedStroke` must be flat across the whole zoom range, and
// `scalingConflicts` re-reads the real stylesheet so the CSS half cannot come
// back either. If a future element genuinely wants `non-scaling-stroke`, that
// is a deliberate change to this rule β€” change the comment and the gate, not
// just the stylesheet.
/** Stroke width to hand an element drawn INSIDE the zoomed group. */
export function hairline(basePx: number, k: number): number {
return basePx / k;
}
/**
* A dash pattern for a line drawn INSIDE the zoomed group.
*
* ⚠ Exactly the same trap as stroke width, and it caught me: `stroke-dasharray`
* in CSS is in USER units, so inside `scale(k)` a "5 4" dash becomes 5k on and
* 4k off. At a regional fit that is a 70 px dash and a 55 px gap β€” the route
* line renders as a few disconnected strokes floating between the stops, which
* reads as a broken polyline rather than a scaled dash. Every length handed to
* the transformed group goes through `hairline`, dashes included.
*/
export function dashPattern(onPx: number, offPx: number, k: number): string {
return `${hairline(onPx, k)} ${hairline(offPx, k)}`;
}
/**
* What the renderer actually paints, in screen units, for a `hairline` width at
* zoom k β€” i.e. the attribute multiplied by the group's scale. Not circular: it
* models the SVG pipeline, which is the thing the invariant is about. It must
* return `basePx` at EVERY k, and the gate sweeps the range to prove it.
*/
export function paintedStroke(basePx: number, k: number): number {
return hairline(basePx, k) * k;
}
/**
* The CSS half of the same invariant: any `.cg-map*` rule that declares
* `vector-effect: non-scaling-stroke` is double-compensating against
* `hairline`. Returns the offending selectors (empty = clean) so the gate can
* name them. Comments are stripped first so a commented-out example cannot trip
* it.
*/
export function scalingConflicts(css: string): string[] {
const bad: string[] = [];
for (const chunk of css.replace(/\/\*[\s\S]*?\*\//g, "").split("}")) {
const brace = chunk.indexOf("{");
if (brace < 0) continue;
const selector = chunk.slice(0, brace);
if (!selector.includes(".cg-map")) continue;
if (/vector-effect\s*:[^;]*non-scaling/i.test(chunk.slice(brace + 1)))
bad.push(selector.trim().replace(/\s+/g, " "));
}
return bad;
}
/**
* How far in the VENDORED basemap is still worth showing, as an absolute zoom.
*
* DERIVED, not chosen by feel. The geometry in mapGeometry.ts is Natural Earth
* 50m simplified at 0.02 degrees, giving a ~13 km median vertex spacing. With
* the viewBox painting ~1.08 CSS px per unit at latitude 39, one screen pixel is
* ~7041/k metres, so a 13 km segment measures ~1.85*k pixels. At k = 32 that is
* a ~60 px straight run and ~10 px of simplification error β€” coarse but still
* unmistakably a shape. Past it the coastline degenerates into long straight
* lines and the user is zooming into an empty polygon, which is the opposite of
* the sharpness this was asked for.
*
* ⚠ This is an HONESTY limit and it is the reason the map stops where it does:
* street-level detail is not available from any licence-free offline vector set.
* It needs a tile provider β€” a runtime network dependency, an API key and an
* attribution obligation β€” which is the owner's call, not a silent addition.
*/
export const BASEMAP_DETAIL_K = 32;
/**
* The camera's zoom range for a given fitted zoom.
*
* `kMin` β€” zoom OUT to four times the data's own extent for context, but never
* past the point where the whole projected world already fits: beyond that
* there is nothing further to reveal, only empty margin. (The wave-8 rule was a
* flat `fit.k * 0.6`, which locked you in at barely half a step out.)
*
* `kMax` β€” how far IN. This is a HONESTY limit as much as a UX one: zooming
* past the resolution of the basemap actually vendored just shows a bigger
* empty polygon, so `detailK` caps it. Pass `Infinity` for no cap.
*/
export function zoomLimits(
fitK: number,
viewH: number,
detailK = Infinity
): { kMin: number; kMax: number } {
const worldFit = viewH / WORLD;
const kMin = Math.min(fitK, Math.max(worldFit, fitK * 0.25));
return { kMin, kMax: Math.max(fitK, Math.min(fitK * 60, detailK)) };
}
/**
* Graticule opacity at zoom k. The 10-degree grid earns its place on a
* zoomed-OUT world view, where it is the only thing giving scale. Once wave 9
* vendored real state borders it became noise the moment you zoom into the
* country: two competing line systems over the same picture. So it fades out
* before the borders take over rather than fighting them.
*/
export function graticuleOpacity(k: number): number {
return Math.max(0, Math.min(1, (1.5 - k) / 0.9));
}
/**
* Where to put a hover card of `w` x `h` for a pin at (sx, sy), in screen space.
*
* Prefers ABOVE the pin, flips below when there is no room, and clamps inside
* the viewport on both axes β€” a card that runs off the frame is a card whose
* numbers cannot be read, and the pins nearest the edge are exactly the ones a
* territory question is usually about.
*/
export function cardBox(
sx: number, sy: number, w: number, h: number,
viewW: number, viewH: number, gap = 14, pad = 6
): Pt {
const above = sy - h - gap;
// ⚠ Both axes clamp UNCONDITIONALLY. Clamping only the "flipped below" branch
// looks right and is not: a pin panned off the BOTTOM of the frame still has
// acres of room "above" it, passes the room check, and places the card far
// below the viewport. Caught by the every-corner leg, never by a screenshot.
return {
x: Math.max(pad, Math.min(viewW - w - pad, sx - w / 2)),
y: Math.max(pad, Math.min(viewH - h - pad, above >= pad ? above : sy + gap)),
};
}
// ---------------------------------------------------------- route planning
//
// I18-R. Sequencing a visit order is a TRAVELLING SALESMAN problem, and it is
// pure arithmetic: no data, no service, no dependency, no cost. The half that
// costs money is turning an order into ROAD distances, and this deliberately
// does not attempt that β€” see `routeNote` and the mailbox's tier analysis
// (Google's route matrix bills per element: a 30-stop run is ~$4.50, a full
// 1,550-customer matrix ~$12,000).
/** A stop, in the coordinates the host geocoded β€” never projected units. */
export interface GeoStop {
lat: number;
lon: number;
}
/**
* ⚠ THE SEAM. Everything below takes distance as a FUNCTION and knows nothing
* else about it. Swapping in real road distances later (a self-hosted OSRM
* matrix, precomputed and cached) is then a one-line change at the call site
* rather than a rewrite of the sequencer.
*/
export type StopDistance = (a: GeoStop, b: GeoStop) => number;
const EARTH_R_KM = 6371.0088;
/**
* Great-circle distance in km.
*
* ⚠ MUST be computed on lon/lat, NOT as euclidean distance in projected WORLD
* units. Mercator stretches by 1/cos(latitude): across this book's range
* (lat 25-49) that is a 0.91 -> 0.66 swing, ~38%, which systematically ranks
* north-south pairs against east-west ones. The resulting route looks entirely
* plausible and is wrong, which is the worst kind of wrong.
*/
export const haversineKm: StopDistance = (a, b) => {
const rad = Math.PI / 180;
const dLat = (b.lat - a.lat) * rad;
const dLon = (b.lon - a.lon) * rad;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(s)));
};
/** Total length of a tour. `roundTrip` adds the closing edge back to the start. */
export function tourLength(
order: number[], stops: GeoStop[], dist: StopDistance, roundTrip = false
): number {
if (order.length < 2) return 0;
let km = 0;
for (let i = 1; i < order.length; i++) km += dist(stops[order[i - 1]], stops[order[i]]);
if (roundTrip) km += dist(stops[order[order.length - 1]], stops[order[0]]);
return km;
}
/** Greedy construction: from `start`, repeatedly hop to the nearest unvisited stop. */
export function nearestNeighbourOrder(
stops: GeoStop[], dist: StopDistance, start = 0
): number[] {
const n = stops.length;
if (n === 0) return [];
const from = Math.max(0, Math.min(n - 1, Math.round(start) || 0));
const seen = new Array<boolean>(n).fill(false);
const order = [from];
seen[from] = true;
for (let k = 1; k < n; k++) {
const last = order[order.length - 1];
let best = -1;
let bestD = Infinity;
for (let i = 0; i < n; i++) {
if (seen[i]) continue;
const d = dist(stops[last], stops[i]);
if (d < bestD) { bestD = d; best = i; }
}
if (best < 0) break;
seen[best] = true;
order.push(best);
}
return order;
}
/**
* 2-opt: repeatedly reverse a segment when doing so shortens the tour.
*
* Index 0 is PINNED β€” it is the origin the user chose, and silently re-rooting
* their route would be a worse bug than a slightly longer one. Only strictly
* improving moves are accepted, which is what makes "never returns a tour
* longer than the one it was given" a guarantee the gate can assert rather than
* a hope.
*/
export function twoOptOrder(
order: number[], stops: GeoStop[], dist: StopDistance,
roundTrip = false, maxPasses = 24
): number[] {
const n = order.length;
const cur = order.slice();
if (n < 4) return cur;
const D = (a: number, b: number) => dist(stops[a], stops[b]);
for (let pass = 0; pass < maxPasses; pass++) {
let improved = false;
for (let i = 1; i < n - 1; i++) {
for (let j = i + 1; j < n; j++) {
const a = cur[i - 1], b = cur[i], c = cur[j];
let delta: number;
if (j === n - 1 && !roundTrip) {
// Reversing the tail of an OPEN path only re-hangs the entry edge:
// there is no closing edge to pay for.
delta = D(a, c) - D(a, b);
} else {
const d = cur[(j + 1) % n];
delta = D(a, c) + D(b, d) - D(a, b) - D(c, d);
}
if (delta < -1e-9) {
for (let lo = i, hi = j; lo < hi; lo++, hi--) {
const t = cur[lo]; cur[lo] = cur[hi]; cur[hi] = t;
}
improved = true;
}
}
}
if (!improved) break;
}
return cur;
}
/** Construct then improve. Returns the visit order and its length. */
export function planRoute(
stops: GeoStop[], dist: StopDistance,
opts: { start?: number; roundTrip?: boolean } = {}
): { order: number[]; km: number } {
const roundTrip = !!opts.roundTrip;
if (stops.length === 0) return { order: [], km: 0 };
const nn = nearestNeighbourOrder(stops, dist, opts.start ?? 0);
const order = twoOptOrder(nn, stops, dist, roundTrip);
return { order, km: tourLength(order, stops, dist, roundTrip) };
}
// --------------------------------------------------- handing the route over
//
// MEASURED 2026-07-29, do not re-derive: Google Maps URLs need NO API key and
// cost NOTHING, but they carry at most 9 waypoints on desktop and 3 on mobile
// browsers, inside a 2,048-character URL.
export const ROUTE_WAYPOINTS_DESKTOP = 9;
export const ROUTE_WAYPOINTS_MOBILE = 3;
export const MAX_MAPS_URL = 2048;
/** How many STOPS can ride the free URL: the waypoints plus the two endpoints
* (a round trip returns to its origin, so the origin is not also a waypoint). */
export function routeStopCap(coarsePointer: boolean, roundTrip = false): number {
const w = coarsePointer ? ROUTE_WAYPOINTS_MOBILE : ROUTE_WAYPOINTS_DESKTOP;
return roundTrip ? w + 1 : w + 2;
}
/**
* Build the free Google directions URL for an ORDERED list of stops.
*
* Returns `used` alongside the url so the caller can say "first 11 of 23" on
* screen. It never silently drops a stop; truncation is a fact the UI states
* ([[no-unverifiable-aggregates]]). Shrinks further if the character budget
* binds, which it can with a long tail of 6-dp coordinates.
*/
export function googleRouteUrl(
stops: GeoStop[],
opts: { roundTrip?: boolean; coarsePointer?: boolean } = {}
): { url: string; used: number } | null {
if (stops.length < 2) return null;
const roundTrip = !!opts.roundTrip;
const at = (s: GeoStop) => `${s.lat.toFixed(6)},${s.lon.toFixed(6)}`;
let used = Math.min(stops.length, routeStopCap(!!opts.coarsePointer, roundTrip));
for (;;) {
const chosen = stops.slice(0, used);
const origin = chosen[0];
const dest = roundTrip ? origin : chosen[chosen.length - 1];
const mids = roundTrip ? chosen.slice(1) : chosen.slice(1, -1);
const url =
`https://www.google.com/maps/dir/?api=1&origin=${at(origin)}` +
`&destination=${at(dest)}` +
(mids.length ? `&waypoints=${mids.map(at).join("|")}` : "") +
`&travelmode=driving`;
if (url.length <= MAX_MAPS_URL || used <= 2) return { url, used };
used -= 1;
}
}
/** An svg's own bounding box in client px β€” the `getBoundingClientRect()` half
* of the conversion below, taken as plain data so the maths stays testable
* without a DOM. */
export interface FrameRect {
left: number;
top: number;
width: number;
height: number;
}
/**
* Client px -> the svg's own user-space coords. Every pointer gesture on the
* map β€” marquee, rubber band, cursor-anchored wheel zoom β€” starts here.
*
* ⚠ It is NOT `(client / frame) * viewBox`. MapView paints with
* `preserveAspectRatio="xMidYMid meet"`, so the viewBox is scaled UNIFORMLY by
* the tighter of the two axes and then CENTRED, leaving a letterbox band on the
* other axis. Wave 8 (`f6f45a6`) bolted a stretch-to-fill conversion onto that
* `meet` svg: the binding axis came out right and the other carried BOTH a
* wrong scale and a missing offset. On a 1400x600 frame the full 0..1000
* x-range collapsed into ~154..846 β€” so a marquee at either edge caught
* NOTHING, the rubber band lagged the cursor by ~150 px, and cursor-anchored
* zoom drifted, all from this one function. `zoomAt` and the hit test were
* always correct; they were being handed the wrong point.
*
* β›” The result is deliberately NOT clamped to the viewBox. A drag that begins
* in the letterbox band is a real gesture β€” everything from the painted edge
* inward must still be caught β€” and clamping re-breaks exactly the edge
* marquee this exists to fix.
*/
export function clientToUser(
clientX: number,
clientY: number,
rect: FrameRect,
viewW: number,
viewH: number
): Pt {
const s = Math.min(rect.width / viewW, rect.height / viewH);
if (!(s > 0) || !Number.isFinite(s)) return { x: 0, y: 0 };
const offX = (rect.width - viewW * s) / 2;
const offY = (rect.height - viewH * s) / 2;
return { x: (clientX - rect.left - offX) / s, y: (clientY - rect.top - offY) / s };
}
/**
* Is `p` inside the closed polygon `poly`? Even-odd ray casting (the crossing
* number), in the same user-space units `toScreen` returns.
*
* β›” It is NOT a bounding-box test, and that difference IS the feature. A lasso
* drawn as a C or a horseshoe must EXCLUDE whatever sits in its mouth β€”
* otherwise it is the rectangle marquee wearing a lasso's name, which is the
* one thing a person drawing a loop by hand would never expect. The gate
* asserts exactly that case, over a shape whose bounding box gives a different
* answer: a convex test polygon would make the control inert.
*
* The ray is cast along +x from `p`, and each edge that straddles `p.y` and
* crosses to the LEFT of nothing / RIGHT of `p.x` flips the parity. A point
* exactly on a vertex or an edge may fall either way: this selects pins under a
* hand-drawn path, where a half-pixel tie carries no meaning and an epsilon to
* break it would be a number nobody could justify.
*/
export function pointInPolygon(p: Pt, poly: Pt[]): boolean {
if (poly.length < 3) return false;
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i];
const b = poly[j];
const straddles = a.y > p.y !== b.y > p.y;
if (straddles && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
}
return inside;
}
/**
* The axis-aligned bounds of a freehand path, in `normRect`'s own {x0,y0,x1,y1}
* shape so one mis-click rule can measure either gesture. An empty path is a
* zero box rather than an Infinity one β€” the caller's "did this move at all"
* test must answer NO, not NaN.
*/
export function pathBounds(pts: Pt[]) {
if (pts.length === 0) return { x0: 0, y0: 0, x1: 0, y1: 0 };
let x0 = pts[0].x;
let y0 = pts[0].y;
let x1 = pts[0].x;
let y1 = pts[0].y;
for (const p of pts) {
if (p.x < x0) x0 = p.x;
if (p.x > x1) x1 = p.x;
if (p.y < y0) y0 = p.y;
if (p.y > y1) y1 = p.y;
}
return { x0, y0, x1, y1 };
}
/** Screen-space rect (any two corners) -> normalized {x0,y0,x1,y1}. */
export function normRect(ax: number, ay: number, bx: number, by: number) {
return {
x0: Math.min(ax, bx),
y0: Math.min(ay, by),
x1: Math.max(ax, bx),
y1: Math.max(ay, by),
};
}
/**
* Bubble radius for a value under a sqrt scale (I5). AREA is proportional to
* the value, which is the only honest way to size a circle β€” radius-proportional
* bubbles overstate large values by the square, the classic bubble-chart lie.
* `null`/non-finite gets `rNull`: a value-less row is drawn small, never hidden
* and never faked (rule 8b).
*/
export function bubbleRadius(
v: number | null,
min: number,
max: number,
rMin: number,
rMax: number,
rNull: number
): number {
if (v == null || !Number.isFinite(v)) return rNull;
if (!(max > min)) return (rMin + rMax) / 2;
const t = Math.max(0, Math.min(1, (v - min) / (max - min)));
return Math.sqrt(rMin * rMin + t * (rMax * rMax - rMin * rMin));
}