| // --------------------------------------------------------------------------- | |
| // 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)); | |
| } | |