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