From c67a7addd3eed9876b770e6403eda45145d2f301 Mon Sep 17 00:00:00 2001 From: Ben Betz Date: Sun, 12 Jul 2026 23:37:35 -0700 Subject: [PATCH] feat: implement anisotropic zoom functionality for map - Added AxisStretchFrame component to manage anisotropic zoom behavior. - Introduced AxisZoomControls for per-axis zoom adjustments. - Updated WaypointMarkers and ProfilePanel to accommodate anisotropic scaling. - Enhanced CSS styles for various components to ensure crisp rendering during zoom. - Implemented utility functions for axis zoom calculations and scaling factors. - Added tests for axis zoom functionality to ensure correctness. --- CLAUDE.md | 4 +- src/components/map/AircraftOverlay.tsx | 18 +- src/components/map/AppMap.tsx | 96 +++--- .../map/AxisStretchFrame.module.css | 21 ++ src/components/map/AxisStretchFrame.tsx | 280 ++++++++++++++++++ .../map/AxisZoomControls.module.css | 84 ++++++ src/components/map/AxisZoomControls.tsx | 93 ++++++ src/components/map/DataBlock.module.css | 20 ++ src/components/map/RangeRingsLayer.module.css | 7 + src/components/map/WaypointMarkers.module.css | 12 + src/components/map/WaypointMarkers.tsx | 27 +- src/components/profile/ProfilePanel.tsx | 15 +- src/config/constants.ts | 12 + src/index.css | 22 ++ src/store/useMapStore.ts | 22 ++ src/utils/__tests__/axisZoom.test.ts | 151 ++++++++++ src/utils/axisZoom.ts | 110 +++++++ 17 files changed, 943 insertions(+), 51 deletions(-) create mode 100644 src/components/map/AxisStretchFrame.module.css create mode 100644 src/components/map/AxisStretchFrame.tsx create mode 100644 src/components/map/AxisZoomControls.module.css create mode 100644 src/components/map/AxisZoomControls.tsx create mode 100644 src/utils/__tests__/axisZoom.test.ts create mode 100644 src/utils/axisZoom.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1fc323d..ab5cb40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,8 @@ CIFP file facts (verified against live FAA data, June 2026): **Collapsible pane + mobile bottom sheet.** `usePaneStore` is a plain (non-domain) Zustand store: `collapsed` (desktop/tablet rail-collapse, persisted) and `sheetOpen` (phone bottom-sheet expanded vs. peeking, session-only). `deriveMode(widthPx)` is a pure function (no DOM read) mapping viewport width to `'push'` (desktop — the sidebar reflows `.mapArea` on collapse, and `AppMap` exposes `resize()` fired on the rail's `transitionend` since mapbox-gl doesn't notice a reflowed container on its own) or `'overlay'` (phones ≤640px, `PANE_OVERLAY_BREAKPOINT_PX` — the sidebar becomes a fixed-position bottom sheet with a 44px handle bar, and the map canvas never resizes). `Cmd/Ctrl+B` toggles the desktop collapse from anywhere outside a text input. +**Anisotropic (per-axis) zoom.** The map can be zoomed further along one screen axis than the other (e.g. zoom in horizontally to separate parallel-runway traffic while staying zoomed out vertically to see the whole approach). Mapbox has one zoom, so `useMapStore.axisRatio` (zoomY − zoomX, session-only, clamped ±`AXIS_ZOOM_MAX_RATIO`) is realized by `AxisStretchFrame`: the mapbox zoom is always the LESS zoomed axis and the frame is laid out smaller along the more-zoomed axis, then CSS-scaled up to fill the viewport (origin top-left, so layout↔visual conversion is a pure divide — pure math in `src/utils/axisZoom.ts`, unit-tested). Because the stretch is independent of the mapbox zoom, every standard zoom mechanism (wheel, pinch, double-click, NavigationControl) changes both axes together and preserves the ratio; the `AxisZoomControls` cluster (bottom-right stack) does the per-axis ± and 1:1 reset. The transform breaks mapbox's own pointer math, so while stretched the frame disables mapbox's drag/scroll/box/rotate/pitch handlers and substitutes pointer/wheel handlers that convert visual→layout deltas, re-dispatches point-consuming mouse events (click/dblclick/mousemove/contextmenu) with corrected coordinates in the capture phase, and forces bearing/pitch to 0; at 1:1 the feature is fully inert (no transform, no listeners, all native handlers). DOM content inside the stretched frame is counter-scaled to stay crisp — waypoint markers/segment labels via CSS keyed on `[data-axis-stretch]` + `--axis-inv-sx/sy` vars, the rotated course/barb/hold labels inline (their angle is re-derived with `stretchRotationDeg` to stay parallel to the stretched leg), the DataBlock popup and mapbox ctrl corners per-anchor; `AircraftOverlay` (already outside the frame) instead multiplies `map.project()` output by the stretch scales and re-derives icon headings with `stretchTrackDeg`, and ProfilePanel's obstacle-avoidance projections do the same. GL-rendered content (basemap labels, procedure lines) stretches with the map by design. + **All-US airport index, built and validated offline.** The CIFP worker already parses every US airport with published procedures, but the curated 89-airport `airports.json` gates search reachability. `scripts/buildAirportIndex.ts` (`npm run build-airport-index`, flags `--force` to bypass the download cache, `--cifp ` to use a local CIFP zip, `--help`) joins the CIFP's enumerated airports against OurAirports metadata to emit `public/data/airport-index.json` (one compact row per airport with ≥1 published approach, ~2–3k rows — search corpus for `useAirportSearch`) and `public/data/airports/{key}.json` shards (metadata + runway geometry, fetched on selection like a map tile). **Sandbox caveat:** building the index requires unrestricted egress to `aeronav.faa.gov`, which some sandboxes block — until it's run somewhere unrestricted, the generated files don't exist and the app transparently falls back to the bundled 89-airport set. `scripts/validateStaticData.ts` (`npm run validate-static-data`, `--live` to also sample live upstream APIs with a seeded stratified selection) checks schema/coordinate sanity and CIFP↔index cross-checks; its posture is advisory — failures below a 2% threshold per category exit 0 and are appended to `TODO-data-issues.md` (one entry per distinct failure class, each meant to become its own follow-up fix) rather than failing the run. `scripts/lib/` holds the shared join/validation helpers (`csv.ts`, `cache.ts`, `runways.ts`, `airportIndex.ts`, `validate.ts`) used by both build scripts. **Dual procedure visibility model.** `src/store/useProcedureStore.ts` keeps `userToggles` (explicit user action) and `autoVisible` (detection engine) separate. `isVisible(id) = userToggles[id] ?? autoVisible[id] ?? false`. "Revert to auto" clears `userToggles[id]`. The store also tracks `lastDetectedAt`, `detectedHexes` (confirmed aircraft per procedure — powers hover highlighting and the profile panel; array identity is stable when contents don't change), `aircraftAssignments` (hex → the one approach that aircraft is assigned to), and `autoShownIds`. @@ -179,7 +181,7 @@ render budgets (`MAX_RENDERED_PROCEDURE_LINES`, `MAX_ONSCREEN_WAYPOINT_SYMBOLS`) ADS-B poll clustering (`POLL_CLUSTER_MAX_RADIUS_NM`), detection-machine gates and hysteresis (`DETECT_*`, including the bbox prefilter pad `DETECT_BBOX_PAD_NM`), glideslope math, route-cache TTLs/backoff (`ROUTE_*`), auto-hide delay, -extended-centerline length, map styles, and AIRAC/NASR cycle constants. The +extended-centerline length, map styles, per-axis zoom step/limit (`AXIS_ZOOM_STEP`, `AXIS_ZOOM_MAX_RATIO`), and AIRAC/NASR cycle constants. The path-prediction engine adds its own groups: `TRACKLOG_*` (ring-buffer capacity, gap-break), `PREDICT_*` (step/horizon, turn-rate clamps and hold/decay timing, profile-capture tolerance), `HOLD_ENTRY_*` (trigger bearing/ETA/pass-distance diff --git a/src/components/map/AircraftOverlay.tsx b/src/components/map/AircraftOverlay.tsx index 9e12838..421ed5a 100644 --- a/src/components/map/AircraftOverlay.tsx +++ b/src/components/map/AircraftOverlay.tsx @@ -4,6 +4,8 @@ import { useAircraftStore } from '../../store/useAircraftStore' import { useSelectionStore, selectedHexOf } from '../../store/useSelectionStore' import { useSettingsStore } from '../../store/useSettingsStore' import { usePathStore } from '../../store/usePathStore' +import { useMapStore } from '../../store/useMapStore' +import { stretchScales, stretchTrackDeg } from '../../utils/axisZoom' import type { AircraftAlert } from '../../types/path' import { formatAltitude, formatSpeed, formatHeading } from '../../utils/formatters' import { altitudeColor } from '../../utils/colorScheme' @@ -90,6 +92,10 @@ export function AircraftOverlay({ mapRef }: Props) { // from getState per frame for the z-order/visibility side. const showTerrainAlerts = useSettingsStore((s) => s.showTerrainAlerts) const showTrafficAlerts = useSettingsStore((s) => s.showTrafficAlerts) + // Anisotropic zoom: icons are drawn crisp (unstretched), so their rotation + // is adjusted to match the visually stretched trajectory below. + const axisRatio = useMapStore((s) => s.axisRatio) + const { sx: stretchSx, sy: stretchSy } = stretchScales(axisRatio) // Snapshot the airborne aircraft set; only changes on a poll. const aircraft = useMemo( @@ -117,6 +123,10 @@ export function AircraftOverlay({ mapRef }: Props) { const { alerts, forcedVisibleHexes } = usePathStore.getState() const minFt = positionToMinFt(altFilterMin) const maxFt = positionToMaxFt(altFilterMax) + // Anisotropic zoom: this overlay sits OUTSIDE the stretched map frame + // (so aircraft stay crisp), which means map.project()'s layout-space + // pixels must be scaled to visual pixels here. (1, 1) at normal zoom. + const { sx, sy } = stretchScales(useMapStore.getState().axisRatio) for (const [hex, node] of nodes.current) { const ac = store.aircraftMap.get(hex) @@ -143,6 +153,8 @@ export function AircraftOverlay({ mapRef }: Props) { node.style.display = '' const p = map.project([ac.interpLon, ac.interpLat]) + p.x *= sx + p.y *= sy node.style.transform = `translate(${p.x}px, ${p.y}px)` node.style.color = altitudeColor(ac.altBaro) @@ -167,6 +179,8 @@ export function AircraftOverlay({ mapRef }: Props) { const other = otherHex ? store.aircraftMap.get(otherHex) : undefined if (other) { const op = map.project([other.interpLon, other.interpLat]) + op.x *= sx + op.y *= sy let dx = p.x - op.x let dy = p.y - op.y const dist = Math.hypot(dx, dy) @@ -303,7 +317,9 @@ export function AircraftOverlay({ mapRef }: Props) { >
diff --git a/src/components/map/AppMap.tsx b/src/components/map/AppMap.tsx index fff38e8..9e3a9b0 100644 --- a/src/components/map/AppMap.tsx +++ b/src/components/map/AppMap.tsx @@ -24,6 +24,8 @@ import { TrackLogLayer } from './TrackLogLayer' import { HoldEntryLayer } from './HoldEntryLayer' import { PredictionLayer } from './PredictionLayer' import { PathControls } from './PathControls' +import { AxisStretchFrame } from './AxisStretchFrame' +import { AxisZoomControls } from './AxisZoomControls' import { RenderBudgetHint } from './RenderBudgetHint' import { ActiveProceduresOverlay } from '../layout/ActiveProceduresOverlay' import { AltitudeFilter } from './AltitudeFilter' @@ -139,66 +141,73 @@ export function AppMap() { const handleMouseLeave = useCallback(() => setHoverCursor(false), []) return ( -
- - +
+ {/* AxisStretchFrame implements the anisotropic (per-axis) zoom: at 1:1 + it's a pass-through; otherwise it CSS-stretches the map frame along + the more-zoomed axis and takes over pointer handling. The DOM + overlays below (AircraftOverlay, control stack, ProfilePanel) stay + OUTSIDE the frame so they render unstretched. */} + + + - {/* Terrain and safe-altitude overlays are always mounted (visibility - toggled via layout.visibility) rather than conditionally rendered, - so their runtime GL layers keep a stable position in the render - order across toggles — see the z-order rationale comment atop - TerrainLayer.tsx. */} - + {/* Terrain and safe-altitude overlays are always mounted (visibility + toggled via layout.visibility) rather than conditionally rendered, + so their runtime GL layers keep a stable position in the render + order across toggles — see the z-order rationale comment atop + TerrainLayer.tsx. */} + - + - + - + - + - + - + - {showCenterlines && } + {showCenterlines && } - {visibleProcedures.map((p) => ( - - ))} + {visibleProcedures.map((p) => ( + + ))} - + - + - + - + - + - + - + - - + + + {/* DOM overlay so aircraft render above the waypoint markers and stay crisp */} @@ -220,6 +229,7 @@ export function AppMap() { pointerEvents: 'none', }} > + diff --git a/src/components/map/AxisStretchFrame.module.css b/src/components/map/AxisStretchFrame.module.css new file mode 100644 index 0000000..5340792 --- /dev/null +++ b/src/components/map/AxisStretchFrame.module.css @@ -0,0 +1,21 @@ +/* ── Anisotropic-zoom frame ──────────────────────────────────────────────── */ +/* Outer fills the map area and clips the (sub)pixel overhang of the scaled */ +/* inner frame; inner is laid out smaller along the more-zoomed axis and */ +/* scaled back up (origin top left so layout↔visual is a pure divide). */ + +.outer { + position: absolute; + inset: 0; + overflow: hidden; + /* Our own pointer pan/pinch handlers run while stretched. */ + touch-action: none; +} + +.inner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform-origin: top left; +} diff --git a/src/components/map/AxisStretchFrame.tsx b/src/components/map/AxisStretchFrame.tsx new file mode 100644 index 0000000..1ee7e75 --- /dev/null +++ b/src/components/map/AxisStretchFrame.tsx @@ -0,0 +1,280 @@ +import { useEffect, useRef } from 'react' +import type { MapRef } from 'react-map-gl' +import { useMapStore } from '../../store/useMapStore' +import { stretchScales } from '../../utils/axisZoom' +import styles from './AxisStretchFrame.module.css' + +interface Props { + mapRef: React.RefObject + children: React.ReactNode +} + +/** Marks re-dispatched (coordinate-corrected) events so the capture-phase + * interceptor doesn't process its own clones. */ +const CORRECTED = '__axisStretchCorrected' + +/** Mouse event types mapbox consumes point-positionally (feature clicks, + * interactiveLayerIds hover, double-click zoom). While stretched, these are + * stopped in the capture phase and re-dispatched with layout-space client + * coordinates so mapbox's own screen→lngLat math stays correct. */ +const CORRECT_TYPES = ['click', 'dblclick', 'mousemove', 'contextmenu'] as const + +/** Wheel-to-zoom rates, matching mapbox-gl's ScrollZoomHandler feel: + * trackpad pinches arrive as ctrl+wheel and are much finer-grained. */ +const WHEEL_ZOOM_RATE = 1 / 450 +const PINCH_WHEEL_ZOOM_RATE = 1 / 100 +/** px equivalent per wheel "line" for deltaMode DOM_DELTA_LINE devices. */ +const WHEEL_LINE_PX = 20 +/** Max zoom change from one wheel event, so free-spinning wheels don't warp. */ +const WHEEL_MAX_DELTA = 2 +/** Pointer movement (px) before a press becomes a drag rather than a click. */ +const DRAG_SLOP_PX = 3 +/** Window after a drag ends in which a stray synthetic click is swallowed. */ +const POST_DRAG_CLICK_SUPPRESS_MS = 150 + +const isInCanvas = (t: EventTarget | null): boolean => + t instanceof Element && !!t.closest('.mapboxgl-canvas-container') + +/** + * Anisotropic-zoom frame around the mapbox Map. + * + * When `useMapStore.axisRatio` ≠ 0 the map's container is laid out SMALLER + * than the viewport along the more-zoomed axis and CSS-scaled back up to fill + * it (transform-origin top left, so layout↔visual conversion is a pure + * divide/multiply). The mapbox zoom stays the less-zoomed axis, so ordinary + * zoom mechanics scale both axes together and preserve the ratio. + * + * The transform breaks mapbox's own pointer math (it reads client coordinates + * against an unstretched container), so while stretched: + * - mapbox's drag/scroll/box/rotate/pitch handlers are disabled and replaced + * by pointer/wheel handlers here that convert visual→layout deltas + * (double-click zoom and keyboard stay enabled — they're coordinate-safe + * given the corrected events below), + * - point-consuming mouse events headed for the canvas are intercepted in + * the capture phase and re-dispatched with layout-space coordinates, so + * feature clicks / hover / dblclick-zoom anchor correctly, + * - bearing/pitch are forced to 0 — the stretch is screen-axis aligned and + * only coherent on a north-up planar view. + * + * At 1:1 nothing is transformed, no listener is attached, and every native + * mapbox handler is left in its default state — the feature is fully inert. + */ +export function AxisStretchFrame({ mapRef, children }: Props) { + const axisRatio = useMapStore((s) => s.axisRatio) + const outerRef = useRef(null) + const { sx, sy } = stretchScales(axisRatio) + const stretched = axisRatio !== 0 + + // The container's layout size changes with the ratio; tell mapbox promptly + // (its own ResizeObserver would catch it a frame later). resize() keeps the + // geographic center at the container center, which the top-left-origin + // scale maps back to the visual viewport center — so the view stays put. + useEffect(() => { + mapRef.current?.getMap()?.resize() + }, [axisRatio, mapRef]) + + // Interaction-handler swap + north-up enforcement while stretched. + useEffect(() => { + const map = mapRef.current?.getMap() + if (!map || !stretched) return + + if (map.getBearing() !== 0 || map.getPitch() !== 0) { + map.easeTo({ bearing: 0, pitch: 0, duration: 200 }) + } + map.dragPan.disable() + map.scrollZoom.disable() + map.boxZoom.disable() + map.dragRotate.disable() + map.touchZoomRotate.disable() + map.touchPitch.disable() + + return () => { + map.dragPan.enable() + map.scrollZoom.enable() + map.boxZoom.enable() + map.dragRotate.enable() + map.touchZoomRotate.enable() + map.touchPitch.enable() + } + }, [stretched, mapRef]) + + // Gesture handling + event coordinate correction while stretched. + useEffect(() => { + const outer = outerRef.current + const map = mapRef.current?.getMap() + if (!outer || !map || !stretched) return + + // visual (client) → layout (mapbox container) point, in container px. + const layoutPoint = (clientX: number, clientY: number) => { + const r = outer.getBoundingClientRect() + return { x: (clientX - r.left) / sx, y: (clientY - r.top) / sy, rect: r } + } + + let suppressClickUntil = 0 + + // ── capture-phase coordinate correction for mapbox's point consumers ── + const correct = (e: MouseEvent) => { + if ((e as unknown as Record)[CORRECTED]) return + if (!isInCanvas(e.target)) return + e.stopImmediatePropagation() + if (e.type === 'click' && performance.now() < suppressClickUntil) return + const { x, y, rect } = layoutPoint(e.clientX, e.clientY) + const clone = new MouseEvent(e.type, { + bubbles: true, + cancelable: true, + view: window, + detail: e.detail, + screenX: e.screenX, + screenY: e.screenY, + clientX: rect.left + x, + clientY: rect.top + y, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + button: e.button, + buttons: e.buttons, + relatedTarget: e.relatedTarget, + }) + ;(clone as unknown as Record)[CORRECTED] = true + e.target?.dispatchEvent(clone) + } + + // ── wheel: proportional zoom (ratio untouched) about the cursor ── + const onWheel = (e: WheelEvent) => { + if (e.target instanceof Element && e.target.closest('.mapboxgl-ctrl')) return + e.preventDefault() + e.stopImmediatePropagation() + const px = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * WHEEL_LINE_PX : e.deltaY + const rate = e.ctrlKey ? PINCH_WHEEL_ZOOM_RATE : WHEEL_ZOOM_RATE + const dz = Math.max(-WHEEL_MAX_DELTA, Math.min(WHEEL_MAX_DELTA, -px * rate)) + if (dz === 0) return + const { x, y } = layoutPoint(e.clientX, e.clientY) + map.easeTo({ zoom: map.getZoom() + dz, around: map.unproject([x, y]), duration: 0 }) + } + + // ── pointer pan / pinch (replaces the disabled mapbox handlers) ── + const pointers = new Map() + let dragging = false + let downPt: { x: number; y: number } | null = null + let pinch: { dist: number; zoom: number } | null = null + let lastMid: { x: number; y: number } | null = null + + const midpoint = () => { + const [a, b] = [...pointers.values()] + return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) } + } + + const onPointerDown = (e: PointerEvent) => { + if (!isInCanvas(e.target)) return + if (e.pointerType === 'mouse' && e.button !== 0) return + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + if (pointers.size === 1) { + downPt = { x: e.clientX, y: e.clientY } + dragging = false + } else if (pointers.size === 2) { + dragging = false + for (const id of pointers.keys()) { + try { + outer.setPointerCapture(id) + } catch { + /* pointer already gone */ + } + } + const m = midpoint() + pinch = { dist: m.dist, zoom: map.getZoom() } + lastMid = { x: m.x, y: m.y } + } + } + + const onPointerMove = (e: PointerEvent) => { + const prev = pointers.get(e.pointerId) + if (!prev) return + const cur = { x: e.clientX, y: e.clientY } + pointers.set(e.pointerId, cur) + + if (pointers.size === 1) { + if (!dragging && downPt && Math.hypot(cur.x - downPt.x, cur.y - downPt.y) > DRAG_SLOP_PX) { + dragging = true + try { + outer.setPointerCapture(e.pointerId) + } catch { + /* ignore */ + } + } + if (dragging) { + map.panBy([-(cur.x - prev.x) / sx, -(cur.y - prev.y) / sy], { duration: 0 }) + } + } else if (pointers.size === 2 && pinch && lastMid) { + const m = midpoint() + map.panBy([-(m.x - lastMid.x) / sx, -(m.y - lastMid.y) / sy], { duration: 0 }) + lastMid = { x: m.x, y: m.y } + if (pinch.dist > 0 && m.dist > 0) { + const { x, y } = layoutPoint(m.x, m.y) + map.easeTo({ + zoom: pinch.zoom + Math.log2(m.dist / pinch.dist), + around: map.unproject([x, y]), + duration: 0, + }) + } + } + } + + const onPointerEnd = (e: PointerEvent) => { + if (!pointers.delete(e.pointerId)) return + if (pointers.size < 2) { + pinch = null + lastMid = null + } + if (pointers.size === 1) { + // pinch → single-finger pan handoff: keep dragging from here. + const [rest] = [...pointers.values()] + downPt = rest + dragging = true + } + if (pointers.size === 0) { + if (dragging) suppressClickUntil = performance.now() + POST_DRAG_CLICK_SUPPRESS_MS + dragging = false + downPt = null + } + } + + for (const t of CORRECT_TYPES) outer.addEventListener(t, correct, true) + outer.addEventListener('wheel', onWheel, { capture: true, passive: false }) + outer.addEventListener('pointerdown', onPointerDown) + outer.addEventListener('pointermove', onPointerMove) + outer.addEventListener('pointerup', onPointerEnd) + outer.addEventListener('pointercancel', onPointerEnd) + + return () => { + for (const t of CORRECT_TYPES) outer.removeEventListener(t, correct, true) + outer.removeEventListener('wheel', onWheel, true) + outer.removeEventListener('pointerdown', onPointerDown) + outer.removeEventListener('pointermove', onPointerMove) + outer.removeEventListener('pointerup', onPointerEnd) + outer.removeEventListener('pointercancel', onPointerEnd) + } + }, [stretched, sx, sy, mapRef]) + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/components/map/AxisZoomControls.module.css b/src/components/map/AxisZoomControls.module.css new file mode 100644 index 0000000..3f7a40c --- /dev/null +++ b/src/components/map/AxisZoomControls.module.css @@ -0,0 +1,84 @@ +/* ── Per-axis zoom control cluster ───────────────────────────────────────── */ +/* Shares PathControls/TrafficFilter's white NavigationControl-style box so */ +/* it slots into the same bottom-right stack (positioned by AppMap). */ + +.container { + background: #fff; + border-radius: 4px; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: all; + user-select: none; +} + +.axisRow { + display: flex; + align-items: center; + gap: 4px; +} + +.axisLabel { + width: 14px; + text-align: center; + font-family: 'Roboto Mono', monospace; + font-size: 12px; + font-weight: 700; + color: #1a1a1a; + flex-shrink: 0; +} + +.btn { + flex: 1; + min-width: 26px; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #f3f4f6; + padding: 3px 0; + font-family: 'Roboto Mono', monospace; + font-size: 12px; + font-weight: 700; + line-height: 1; + color: #1a1a1a; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; +} + +.btn:hover:not(:disabled) { + border-color: #9ca3af; + background: #e5e7eb; +} + +.btn:disabled { + opacity: 0.4; + cursor: default; +} + +.reset { + width: 100%; + border: 1px solid #1e293b; + border-radius: 3px; + background: #1e293b; + padding: 3px 6px; + font-family: 'Roboto Mono', monospace; + font-size: 10px; + font-weight: 600; + color: #ffffff; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s, opacity 0.1s; +} + +.reset:hover:not(:disabled) { + background: #334155; +} + +/* At 1:1 the reset is informational only — render it like an idle toggle. */ +.resetIdle { + background: #f3f4f6; + border-color: #d1d5db; + color: #1a1a1a; + opacity: 0.5; + cursor: default; +} diff --git a/src/components/map/AxisZoomControls.tsx b/src/components/map/AxisZoomControls.tsx new file mode 100644 index 0000000..10755a1 --- /dev/null +++ b/src/components/map/AxisZoomControls.tsx @@ -0,0 +1,93 @@ +import { useMapStore } from '../../store/useMapStore' +import { AXIS_ZOOM_STEP, AXIS_ZOOM_MAX_RATIO } from '../../config/constants' +import { formatStretchFactor } from '../../utils/axisZoom' +import styles from './AxisZoomControls.module.css' + +/** + * Per-axis zoom controls, styled to slot into the bottom-right stack with + * PathControls/TrafficFilter. Each +/- zooms ONE screen axis, leaving the + * other axis's scale untouched (e.g. zoom in horizontally to separate + * parallel-runway traffic while keeping the full approach in view + * vertically). The 1:1 button clears the stretch; while stretched it shows + * which axis is zoomed in further and by what factor. Ordinary zoom + * mechanics (wheel, pinch, double-click, the NavigationControl buttons) + * always zoom both axes together, preserving the ratio. + */ +export function AxisZoomControls() { + const axisRatio = useMapStore((s) => s.axisRatio) + const adjustAxisZoom = useMapStore((s) => s.adjustAxisZoom) + const resetAxisZoom = useMapStore((s) => s.resetAxisZoom) + + const atMax = axisRatio >= AXIS_ZOOM_MAX_RATIO + const atMin = axisRatio <= -AXIS_ZOOM_MAX_RATIO + + return ( +
+
+ + + +
+ +
+ + + +
+ + +
+ ) +} diff --git a/src/components/map/DataBlock.module.css b/src/components/map/DataBlock.module.css index 4e35c19..d423d9f 100644 --- a/src/components/map/DataBlock.module.css +++ b/src/components/map/DataBlock.module.css @@ -215,3 +215,23 @@ opacity: 0.15; } } + +/* ── Anisotropic zoom (AxisStretchFrame) ─────────────────────────────────── */ +/* The popup lives inside the stretched map frame; counter-scale its content + (with the origin pinned to the anchored corner, so the block stays attached + to its quadrant offset point) so the data block stays crisp. */ +:global([data-axis-stretch]) .popup :global(.mapboxgl-popup-content) { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); +} +:global([data-axis-stretch]) .popup:global(.mapboxgl-popup-anchor-bottom-left) :global(.mapboxgl-popup-content) { + transform-origin: bottom left; +} +:global([data-axis-stretch]) .popup:global(.mapboxgl-popup-anchor-top-left) :global(.mapboxgl-popup-content) { + transform-origin: top left; +} +:global([data-axis-stretch]) .popup:global(.mapboxgl-popup-anchor-top-right) :global(.mapboxgl-popup-content) { + transform-origin: top right; +} +:global([data-axis-stretch]) .popup:global(.mapboxgl-popup-anchor-bottom-right) :global(.mapboxgl-popup-content) { + transform-origin: bottom right; +} diff --git a/src/components/map/RangeRingsLayer.module.css b/src/components/map/RangeRingsLayer.module.css index 98c5778..edb717b 100644 --- a/src/components/map/RangeRingsLayer.module.css +++ b/src/components/map/RangeRingsLayer.module.css @@ -10,3 +10,10 @@ white-space: nowrap; pointer-events: none; } + +/* Anisotropic zoom (AxisStretchFrame): keep the chip text crisp while the + map frame is stretched — it's center-anchored, so the counter-scale + happens about its ring point. */ +:global([data-axis-stretch]) .chip { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); +} diff --git a/src/components/map/WaypointMarkers.module.css b/src/components/map/WaypointMarkers.module.css index 8df071d..544e028 100644 --- a/src/components/map/WaypointMarkers.module.css +++ b/src/components/map/WaypointMarkers.module.css @@ -214,3 +214,15 @@ text-shadow: 0 0 2px #0b0f14, 0 0 2px #0b0f14, 0 0 3px #0b0f14; } + +/* ── Anisotropic zoom (AxisStretchFrame) ─────────────────────────────────── */ +/* While the map frame is stretched, counter-scale marker content so glyphs + and text stay crisp/undistorted; the 0×0 .container (and center-anchored + .segDist) keep their anchor because the scale happens about it. The vars + are set by AxisStretchFrame; the rules only match while stretched. The + rotated course/barb/hold labels are handled inline in WaypointMarkers.tsx + instead (their rotation angle also needs re-deriving under the stretch). */ +:global([data-axis-stretch]) .container, +:global([data-axis-stretch]) .segDist { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); +} diff --git a/src/components/map/WaypointMarkers.tsx b/src/components/map/WaypointMarkers.tsx index cb014b7..bc048eb 100644 --- a/src/components/map/WaypointMarkers.tsx +++ b/src/components/map/WaypointMarkers.tsx @@ -17,6 +17,8 @@ import { } from '../../geo/procedureShapes' import { magneticToTrue } from '../../utils/arincRecords' import { isOverWaypointBudget } from '../../utils/renderBudget' +import { useMapStore } from '../../store/useMapStore' +import { stretchScales, stretchRotationDeg } from '../../utils/axisZoom' import styles from './WaypointMarkers.module.css' const norm360 = (d: number): number => ((d % 360) + 360) % 360 @@ -559,6 +561,16 @@ export function WaypointMarkers({ procedures }: Props) { } }, [mapRef, symbols, procedures]) + // Anisotropic zoom (AxisStretchFrame): the un-rotated markers are + // counter-scaled in CSS (see the module stylesheet), but the rotated + // course/barb/hold labels carry inline transforms, so the counter-scale is + // composed here — and their angle is re-derived so the crisp label stays + // parallel to the visually stretched leg it annotates. + const axisRatio = useMapStore((s) => s.axisRatio) + const { sx: strSx, sy: strSy } = stretchScales(axisRatio) + const labelScale = axisRatio === 0 ? '' : `scale(${1 / strSx}, ${1 / strSy}) ` + const labelRot = (deg: number) => stretchRotationDeg(deg, strSx, strSy) + return ( <> {placements.map((pl) => { @@ -648,7 +660,10 @@ export function WaypointMarkers({ procedures }: Props) {
{cl.text}
@@ -661,7 +676,10 @@ export function WaypointMarkers({ procedures }: Props) {
{bl.text}
@@ -676,7 +694,10 @@ export function WaypointMarkers({ procedures }: Props) {
{hl.text} {hl.alt && } diff --git a/src/components/profile/ProfilePanel.tsx b/src/components/profile/ProfilePanel.tsx index b8b4fb5..0d85d1b 100644 --- a/src/components/profile/ProfilePanel.tsx +++ b/src/components/profile/ProfilePanel.tsx @@ -11,6 +11,8 @@ import { buildProfileTrail } from '../../geo/profileTrail' import type { ProfileTrailPoint } from '../../geo/profileTrail' import { pickPanelAnchor, type Rect } from '../../geo/panelPlacement' import { getTrack } from '../../services/trackLog' +import { useMapStore } from '../../store/useMapStore' +import { stretchScales } from '../../utils/axisZoom' import { PROFILE_PANEL_MIN_W, PROFILE_PANEL_MIN_H, @@ -119,19 +121,26 @@ export function ProfilePanel({ mapRef }: Props) { { x: containerW - PROFILE_MARGIN_PX - width, y: containerH - PROFILE_MARGIN_PX - height, w: width, h: height }, ] + // map.project() returns map-container (layout) pixels; under anisotropic + // zoom the container is CSS-stretched (AxisStretchFrame), so scale to the + // visual pixels this panel is positioned in. (1, 1) at normal zoom. + const { sx, sy } = stretchScales(useMapStore.getState().axisRatio) const obstaclePts: Array<{ x: number; y: number }> = [] for (const p of procedures) { if (!computeVisibility(userToggles, autoVisible, p.id)) continue for (const sym of p.symbols) { - obstaclePts.push(map.project([sym.lon, sym.lat])) + const pt = map.project([sym.lon, sym.lat]) + obstaclePts.push({ x: pt.x * sx, y: pt.y * sy }) } } - // Measure the other absolutely-positioned overlays sharing this container + // Measure the other absolutely-positioned overlays sharing the map root // (they mark themselves with data-map-overlay) so placement avoids their // real on-screen footprint rather than numbers copied from their CSS. + // Resolved via the data-map-root wrapper (AppMap) rather than the + // container's direct parent, which is the AxisStretchFrame. const reservedRects: Rect[] = [] - const host = map.getContainer().parentElement + const host = map.getContainer().closest('[data-map-root]') if (host) { for (const el of Array.from(host.querySelectorAll('[data-map-overlay]'))) { const r = el.getBoundingClientRect() diff --git a/src/config/constants.ts b/src/config/constants.ts index bd0df5e..5adb2ba 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -364,3 +364,15 @@ export const RING_ZOOM_BUCKETS: { minZoom: number; radiiNm: [number, number, num export const PREDICTION_LINE_COLOR = '#ffffff' export const ALERT_AMBER = '#fbbf24' // distinct from AIRCRAFT_COLOR #f59e0b (src/utils/colorScheme.ts) export const WARNING_RED = '#ef4444' + +// ── Anisotropic (per-axis) zoom ───────────────────────────────────────────── +// The map can be zoomed further along one screen axis than the other (e.g. +// zoom in horizontally to separate parallel-runway traffic while staying +// zoomed out vertically to see the whole approach). Implemented as a CSS +// scale of the map frame along the more-zoomed axis — see +// src/components/map/AxisStretchFrame.tsx and src/utils/axisZoom.ts. +// Zoom-level step applied per +/- click of the per-axis controls. +export const AXIS_ZOOM_STEP = 0.5 +// Max |zoomY - zoomX| in zoom levels (3 → up to 8× stretch). Bounds both the +// visual distortion and the CSS upscale blur. +export const AXIS_ZOOM_MAX_RATIO = 3 diff --git a/src/index.css b/src/index.css index b61e72c..125a3b8 100644 --- a/src/index.css +++ b/src/index.css @@ -58,3 +58,25 @@ button { .mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip { border-top-color: transparent !important; } + +/* ── Anisotropic zoom (AxisStretchFrame) ─────────────────────────────────── */ +/* The mapbox control corners (NavigationControl, logo) render inside the + stretched map frame; counter-scale them about their pinned corner so the + buttons stay crisp and correctly sized. --axis-inv-* vars are set by + AxisStretchFrame; these rules only match while the map is stretched. */ +[data-axis-stretch] .mapboxgl-ctrl-bottom-right { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); + transform-origin: bottom right; +} +[data-axis-stretch] .mapboxgl-ctrl-bottom-left { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); + transform-origin: bottom left; +} +[data-axis-stretch] .mapboxgl-ctrl-top-right { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); + transform-origin: top right; +} +[data-axis-stretch] .mapboxgl-ctrl-top-left { + transform: scale(var(--axis-inv-sx, 1), var(--axis-inv-sy, 1)); + transform-origin: top left; +} diff --git a/src/store/useMapStore.ts b/src/store/useMapStore.ts index a165e27..9ac393f 100644 --- a/src/store/useMapStore.ts +++ b/src/store/useMapStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { MAP_STYLES, DEFAULT_MAP_CENTER } from '../config/constants' +import { applyAxisZoomDelta, type ZoomAxis } from '../utils/axisZoom' export type MapTheme = 'light' | 'dark' export type MapStyleKey = 'light' | 'dark' | 'satellite' @@ -29,6 +30,14 @@ interface MapStore { * AirportLabelsLayer can highlight the matching ident label. */ hoveredAirportKey: string | null + /** + * Anisotropic zoom: zoomY - zoomX in zoom levels (0 = normal 1:1). + * `viewport.zoom` is always the LESS zoomed axis; the other axis is + * produced by CSS-stretching the map frame (AxisStretchFrame). Session-only + * by design — restoring a stretched map on reload would be disorienting. + * See src/utils/axisZoom.ts for the math. + */ + axisRatio: number setViewport: (v: Partial) => void setTheme: (t: MapTheme) => void @@ -36,6 +45,10 @@ interface MapStore { getMapStyle: () => string requestResize: () => void setHoveredAirportKey: (k: string | null) => void + /** Zoom one axis in/out by `delta` levels, leaving the other axis as-is. */ + adjustAxisZoom: (axis: ZoomAxis, delta: number) => void + /** Back to 1:1 — the less-zoomed axis's scale is kept. */ + resetAxisZoom: () => void } export const useMapStore = create((set, get) => ({ @@ -45,6 +58,7 @@ export const useMapStore = create((set, get) => ({ satelliteOn: false, resizeToken: 0, hoveredAirportKey: null, + axisRatio: 0, setViewport: (v) => set((s) => ({ viewport: { ...s.viewport, ...v } })), @@ -68,4 +82,12 @@ export const useMapStore = create((set, get) => ({ requestResize: () => set((s) => ({ resizeToken: s.resizeToken + 1 })), setHoveredAirportKey: (k) => set({ hoveredAirportKey: k }), + + adjustAxisZoom: (axis, delta) => + set((s) => { + const next = applyAxisZoomDelta({ zoom: s.viewport.zoom, axisRatio: s.axisRatio }, axis, delta) + return { axisRatio: next.axisRatio, viewport: { ...s.viewport, zoom: next.zoom } } + }), + + resetAxisZoom: () => set({ axisRatio: 0 }), })) diff --git a/src/utils/__tests__/axisZoom.test.ts b/src/utils/__tests__/axisZoom.test.ts new file mode 100644 index 0000000..31237e0 --- /dev/null +++ b/src/utils/__tests__/axisZoom.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' +import { + axisZooms, + applyAxisZoomDelta, + stretchScales, + stretchTrackDeg, + stretchRotationDeg, + formatStretchFactor, +} from '../axisZoom' +import { AXIS_ZOOM_MAX_RATIO } from '../../config/constants' + +describe('axisZooms', () => { + it('is identity at ratio 0', () => { + expect(axisZooms({ zoom: 11, axisRatio: 0 })).toEqual({ zoomX: 11, zoomY: 11 }) + }) + + it('puts the extra zoom on Y for positive ratios', () => { + expect(axisZooms({ zoom: 11, axisRatio: 1.5 })).toEqual({ zoomX: 11, zoomY: 12.5 }) + }) + + it('puts the extra zoom on X for negative ratios', () => { + expect(axisZooms({ zoom: 11, axisRatio: -2 })).toEqual({ zoomX: 13, zoomY: 11 }) + }) +}) + +describe('applyAxisZoomDelta', () => { + it('vertical zoom-in from 1:1 raises the ratio, base unchanged', () => { + expect(applyAxisZoomDelta({ zoom: 11, axisRatio: 0 }, 'v', 0.5)).toEqual({ + zoom: 11, + axisRatio: 0.5, + }) + }) + + it('horizontal zoom-in from 1:1 lowers the ratio, base unchanged', () => { + expect(applyAxisZoomDelta({ zoom: 11, axisRatio: 0 }, 'h', 0.5)).toEqual({ + zoom: 11, + axisRatio: -0.5, + }) + }) + + it('vertical zoom-out from 1:1 lowers the base (Y becomes the min axis)', () => { + expect(applyAxisZoomDelta({ zoom: 11, axisRatio: 0 }, 'v', -0.5)).toEqual({ + zoom: 10.5, + axisRatio: -0.5, + }) + }) + + it('leaves the untouched axis zoom invariant across any single-axis change', () => { + const before = { zoom: 11, axisRatio: 1 } + const { zoomY: yBefore } = axisZooms(before) + const after = applyAxisZoomDelta(before, 'h', 2) // crosses ratio zero + const { zoomX: xAfter, zoomY: yAfter } = axisZooms(after) + expect(yAfter).toBeCloseTo(yBefore) + expect(xAfter).toBeCloseTo(axisZooms(before).zoomX + 2) + expect(after.axisRatio).toBeCloseTo(-1) + expect(after.zoom).toBeCloseTo(12) // min(13, 12) + }) + + it('clamps the ratio at +max and is a no-op past it', () => { + const at = { zoom: 11, axisRatio: AXIS_ZOOM_MAX_RATIO } + expect(applyAxisZoomDelta(at, 'v', 0.5)).toEqual(at) + }) + + it('clamps the ratio at -max and is a no-op past it', () => { + const at = { zoom: 11, axisRatio: -AXIS_ZOOM_MAX_RATIO } + expect(applyAxisZoomDelta(at, 'h', 0.5)).toEqual(at) + }) + + it('a partially clamped delta still applies the allowed portion', () => { + const near = { zoom: 11, axisRatio: AXIS_ZOOM_MAX_RATIO - 0.25 } + const out = applyAxisZoomDelta(near, 'v', 0.5) + expect(out.axisRatio).toBeCloseTo(AXIS_ZOOM_MAX_RATIO) + expect(out.zoom).toBe(11) + }) +}) + +describe('stretchScales', () => { + it('is 1:1 at ratio 0', () => { + expect(stretchScales(0)).toEqual({ sx: 1, sy: 1 }) + }) + + it('stretches Y for positive ratios, X stays 1', () => { + expect(stretchScales(1)).toEqual({ sx: 1, sy: 2 }) + expect(stretchScales(3)).toEqual({ sx: 1, sy: 8 }) + }) + + it('stretches X for negative ratios, Y stays 1', () => { + expect(stretchScales(-1)).toEqual({ sx: 2, sy: 1 }) + }) + + it('never returns a factor below 1 (frame never renders oversized)', () => { + for (const r of [-3, -0.5, 0, 0.5, 3]) { + const { sx, sy } = stretchScales(r) + expect(sx).toBeGreaterThanOrEqual(1) + expect(sy).toBeGreaterThanOrEqual(1) + } + }) +}) + +describe('stretchTrackDeg', () => { + it('is identity at 1:1', () => { + expect(stretchTrackDeg(123, 1, 1)).toBe(123) + }) + + it('leaves the cardinal directions fixed', () => { + for (const d of [0, 90, 180, 270]) { + expect(stretchTrackDeg(d, 1, 2)).toBeCloseTo(d) + expect(stretchTrackDeg(d, 2, 1)).toBeCloseTo(d) + } + }) + + it('a NE track looks more northerly under vertical stretch', () => { + // atan2(sin45, 2·cos45) = 26.57° + expect(stretchTrackDeg(45, 1, 2)).toBeCloseTo(26.57, 1) + }) + + it('a SE track mirrors correctly (stays in the SE quadrant)', () => { + expect(stretchTrackDeg(135, 1, 2)).toBeCloseTo(180 - 26.57, 1) + }) + + it('a NE track looks more easterly under horizontal stretch', () => { + expect(stretchTrackDeg(45, 2, 1)).toBeCloseTo(90 - 26.57, 1) + }) +}) + +describe('stretchRotationDeg', () => { + it('is identity at 1:1', () => { + expect(stretchRotationDeg(30, 1, 1)).toBe(30) + }) + + it('keeps horizontal and vertical text axes fixed', () => { + expect(stretchRotationDeg(0, 1, 2)).toBeCloseTo(0) + expect(stretchRotationDeg(90, 2, 1)).toBeCloseTo(90) + }) + + it('steepens a 45° rotation under vertical stretch', () => { + // Screen y is down: a +45° (downhill-right) line doubles its rise. + expect(stretchRotationDeg(45, 1, 2)).toBeCloseTo(63.43, 1) + }) +}) + +describe('formatStretchFactor', () => { + it('formats whole and fractional factors', () => { + expect(formatStretchFactor(0)).toBe('1×') + expect(formatStretchFactor(1)).toBe('2×') + expect(formatStretchFactor(-1)).toBe('2×') + expect(formatStretchFactor(0.5)).toBe('1.4×') + expect(formatStretchFactor(1.5)).toBe('2.8×') + expect(formatStretchFactor(3)).toBe('8×') + }) +}) diff --git a/src/utils/axisZoom.ts b/src/utils/axisZoom.ts new file mode 100644 index 0000000..fff541d --- /dev/null +++ b/src/utils/axisZoom.ts @@ -0,0 +1,110 @@ +import { AXIS_ZOOM_MAX_RATIO } from '../config/constants' + +/** + * Anisotropic (per-axis) zoom math. + * + * Mapbox has a single zoom level, so per-axis zoom is modeled as: + * - the mapbox zoom is always the LESS zoomed of the two axes + * (`zoom = min(zoomX, zoomY)`), and + * - `axisRatio = zoomY - zoomX` (zoom levels; positive = vertical axis is + * zoomed in further, negative = horizontal further, 0 = normal 1:1). + * + * The more-zoomed axis is produced by CSS-scaling the map frame UP along that + * axis by 2^|axisRatio| (AxisStretchFrame). Keying the mapbox zoom to the + * minimum means the frame is always scaled up (never down), so the map never + * renders more pixels than the viewport needs. + * + * Because the stretch is a fixed CSS transform independent of the mapbox + * zoom, every ordinary zoom mechanism (wheel, pinch, double-click, the + * NavigationControl buttons) changes both axes together and preserves the + * ratio — exactly the required proportional behavior. + */ + +export type ZoomAxis = 'h' | 'v' + +export interface AxisZoomView { + /** Mapbox base zoom — always min(zoomX, zoomY). */ + zoom: number + /** zoomY - zoomX in zoom levels, clamped to ±AXIS_ZOOM_MAX_RATIO. */ + axisRatio: number +} + +/** Effective per-axis zoom levels for a (base zoom, ratio) pair. */ +export function axisZooms(view: AxisZoomView): { zoomX: number; zoomY: number } { + return view.axisRatio >= 0 + ? { zoomX: view.zoom, zoomY: view.zoom + view.axisRatio } + : { zoomX: view.zoom - view.axisRatio, zoomY: view.zoom } +} + +/** + * Apply a zoom delta to ONE axis, leaving the other axis's effective zoom + * unchanged. Returns the new base zoom + ratio. The ratio is clamped to + * ±AXIS_ZOOM_MAX_RATIO by refusing the part of the delta that would exceed + * it (so a click at the limit is a no-op, not a proportional zoom). + */ +export function applyAxisZoomDelta( + view: AxisZoomView, + axis: ZoomAxis, + delta: number, +): AxisZoomView { + let { zoomX, zoomY } = axisZooms(view) + if (axis === 'h') zoomX += delta + else zoomY += delta + + const ratio = zoomY - zoomX + if (ratio > AXIS_ZOOM_MAX_RATIO) { + if (axis === 'v') zoomY = zoomX + AXIS_ZOOM_MAX_RATIO + else zoomX = zoomY - AXIS_ZOOM_MAX_RATIO + } else if (ratio < -AXIS_ZOOM_MAX_RATIO) { + if (axis === 'h') zoomX = zoomY + AXIS_ZOOM_MAX_RATIO + else zoomY = zoomX - AXIS_ZOOM_MAX_RATIO + } + + return { zoom: Math.min(zoomX, zoomY), axisRatio: zoomY - zoomX } +} + +/** + * CSS scale factors for the map frame. The stretched axis is the MORE zoomed + * one, so both factors are always ≥ 1 (the frame is laid out smaller than the + * viewport along that axis and scaled up to fill it). + */ +export function stretchScales(axisRatio: number): { sx: number; sy: number } { + return { + sx: axisRatio < 0 ? 2 ** -axisRatio : 1, + sy: axisRatio > 0 ? 2 ** axisRatio : 1, + } +} + +const norm360 = (d: number): number => ((d % 360) + 360) % 360 + +/** + * Visual screen direction of a ground track under an axis stretch: a track of + * `deg` (0 = up/north, clockwise) drawn on a map stretched by (sx, sy) points + * along atan2(sx·sin, sy·cos). Used to keep crisp (counter-scaled or + * overlay-drawn) aircraft icons aligned with their visually stretched + * trajectory. Identity when sx = sy = 1. + */ +export function stretchTrackDeg(deg: number, sx: number, sy: number): number { + if (sx === 1 && sy === 1) return deg + const r = (deg * Math.PI) / 180 + return norm360((Math.atan2(sx * Math.sin(r), sy * Math.cos(r)) * 180) / Math.PI) +} + +/** + * Same correction for a CSS rotate() angle (0 = horizontal text baseline, + * positive = clockwise, screen y down): a line at `deg` renders at + * atan2(sy·sin, sx·cos) once stretched. Used by counter-scaled rotated labels + * so they stay parallel to the (stretched) leg they annotate. + */ +export function stretchRotationDeg(deg: number, sx: number, sy: number): number { + if (sx === 1 && sy === 1) return deg + const r = (deg * Math.PI) / 180 + return (Math.atan2(sy * Math.sin(r), sx * Math.cos(r)) * 180) / Math.PI +} + +/** "2×", "2.8×" — display factor for the current ratio's magnitude. */ +export function formatStretchFactor(axisRatio: number): string { + const f = 2 ** Math.abs(axisRatio) + const rounded = Math.round(f * 10) / 10 + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}×` +}