Split ElevationChart + PlannerMap into deep modules; add visual regression testing

ElevationChart (1013 lines) split into:
- elevation-chart-draw.ts — pure drawElevationChart(ctx, w, h, params) function
- use-elevation-data.ts — useElevationData(routeData) hook
- ElevationChart.tsx — ~300 lines, interaction + render only

PlannerMap (869 lines) split into:
- MapHelpers.tsx — 7 Leaflet sub-components (MapExposer, RouteFitter, MapClickHandler,
  CursorTracker, NoGoAreaButton, OverlaySync, PoiRefresher)
- use-waypoint-manager.ts — all waypoint CRUD + route data sync
- use-gpx-drop.ts — GPX drag-and-drop hook
- PlannerMap.tsx — ~200 lines, orchestration only

Add Vitest browser visual regression tests for drawElevationChart via
@vitest/browser + Playwright (toMatchScreenshot). Tests cover plain, grade,
elevation, surface color modes plus hover and drag-select states.

Add update-visual-snapshots.yml workflow: triggered by workflow_dispatch or
the `update-snapshots` PR label; commits snapshots back to the branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-10 16:41:23 +02:00
parent 6c5fb6df0e
commit 34529a9432
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
13 changed files with 1764 additions and 1322 deletions

View file

@ -0,0 +1,103 @@
name: Update visual snapshots
# How to use this workflow
# ========================
#
# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are
# committed to the repo. They are generated by Vitest browser mode running
# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright.
#
# When to update snapshots:
# - You intentionally changed the look of the elevation chart (new color mode,
# layout change, etc.) and the old snapshots are now wrong.
# - You added a new `*.browser.test.tsx` test and need the initial snapshots.
#
# Two ways to trigger this workflow:
#
# 1. Manual dispatch (workflow_dispatch):
# Go to Actions → "Update visual snapshots" → "Run workflow".
# Choose the branch you want updated. The workflow will commit the new
# snapshots back to that branch.
#
# 2. PR label (update-snapshots):
# Add the `update-snapshots` label to any PR. The workflow will update
# snapshots on the PR's head branch. Remove the label after to avoid
# re-triggering on every subsequent push.
#
# After the workflow commits updated snapshots, pull the branch locally:
# git pull origin <your-branch>
#
# Running locally:
# pnpm --filter @trails-cool/planner test:visual # run tests
# pnpm --filter @trails-cool/planner test:visual:update # update snapshots
#
# Platform note:
# Snapshots are generated on ubuntu-latest to keep CI and local results
# consistent. Snapshots generated on macOS or Windows will have subtle
# font-rendering differences and will fail on CI. Always use this workflow
# (or a Linux machine / Docker) to produce the canonical snapshots.
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to update snapshots on"
required: false
default: ""
pull_request:
types: [labeled]
jobs:
update-snapshots:
# Only run for manual dispatch, or when the label is "update-snapshots"
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.label.name == 'update-snapshots')
runs-on: ubuntu-latest
permissions:
contents: write # needed to push snapshot commits back
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }}
# Use a token with push rights so the commit-back step can push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: pnpm exec playwright install chromium --with-deps
- name: Update visual snapshots
run: pnpm --filter @trails-cool/planner test:visual:update
- name: Commit updated snapshots
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update visual snapshots [skip ci]"
file_pattern: "apps/planner/app/**/__screenshots__/**"
commit_user_name: "github-actions[bot]"
commit_user_email: "github-actions[bot]@users.noreply.github.com"
- name: Upload snapshots as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-snapshots
path: apps/planner/app/**/__screenshots__/
if-no-files-found: ignore

View file

@ -3,74 +3,17 @@ import { useTranslation } from "react-i18next";
import type { DayStage } from "@trails-cool/gpx";
import type { YjsState } from "~/lib/use-yjs";
import {
elevationColor, maxspeedColor,
SURFACE_COLORS, DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS, DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS, DEFAULT_SMOOTHNESS_COLOR,
TRACKTYPE_COLORS, DEFAULT_TRACKTYPE_COLOR,
CYCLEWAY_COLORS, DEFAULT_CYCLEWAY_COLOR,
BIKEROUTE_COLORS, DEFAULT_BIKEROUTE_COLOR,
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS,
DEFAULT_SMOOTHNESS_COLOR,
CYCLEWAY_COLORS,
DEFAULT_CYCLEWAY_COLOR,
} from "@trails-cool/map-core";
import type { ColorMode } from "~/components/ColoredRoute";
function gradeColor(grade: number): string {
const absGrade = Math.abs(grade);
if (absGrade < 3) return "#22c55e"; // green: flat/gentle
if (absGrade < 6) return "#eab308"; // yellow: moderate
if (absGrade < 10) return "#f97316"; // orange: steep
if (absGrade < 15) return "#ef4444"; // red: very steep
return "#991b1b"; // dark red: extreme
}
interface ElevationPoint {
distance: number;
elevation: number;
lat: number;
lon: number;
}
function extractElevation(geojsonStr: string): ElevationPoint[] {
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length === 0) return [];
const points: ElevationPoint[] = [];
let totalDist = 0;
for (let i = 0; i < coords.length; i++) {
if (i > 0) {
const prev = coords[i - 1]!;
const curr = coords[i]!;
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
}
if (coords[i]![2] !== undefined) {
points.push({
distance: totalDist,
elevation: coords[i]![2]!,
lat: coords[i]![1]!,
lon: coords[i]![0]!,
});
}
}
return points;
} catch {
return [];
}
}
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw";
import { useElevationData } from "~/lib/use-elevation-data";
interface ElevationChartProps {
yjs: YjsState;
@ -83,19 +26,9 @@ interface ElevationChartProps {
export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) {
const { t } = useTranslation("planner");
const [points, setPoints] = useState<ElevationPoint[]>([]);
const { points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes } = useElevationData(yjs.routeData);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
const [surfaces, setSurfaces] = useState<string[]>([]);
const [highways, setHighways] = useState<string[]>([]);
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
const [tracktypes, setTracktypes] = useState<string[]>([]);
const [cycleways, setCycleways] = useState<string[]>([]);
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
pointsRef.current = points;
const isExternalHover = useRef(false);
const dragStartX = useRef<number | null>(null);
const dragStartClientX = useRef<number | null>(null);
@ -124,68 +57,9 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
}
isExternalHover.current = true;
setHoverIdx(closest);
// Do NOT call onHover here to avoid feedback loop
}, [highlightDistance, points]);
useEffect(() => {
const update = () => {
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
setPoints(extractElevation(geojson));
} else {
setPoints([]);
}
const mode = yjs.routeData.get("colorMode") as ColorMode | undefined;
setColorMode(mode ?? "plain");
const surfacesJson = yjs.routeData.get("surfaces") as string | undefined;
if (surfacesJson) {
try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); }
} else {
setSurfaces([]);
}
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
if (highwaysJson) {
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
} else {
setHighways([]);
}
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
if (maxspeedsJson) {
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
} else {
setMaxspeeds([]);
}
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
if (smoothnessesJson) {
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
} else {
setSmoothnesses([]);
}
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
if (tracktypesJson) {
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
} else {
setTracktypes([]);
}
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
if (cyclewaysJson) {
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
} else {
setCycleways([]);
}
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
if (bikeroutesJson) {
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
} else {
setBikeroutes([]);
}
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
const drawChart = useCallback(
const draw = useCallback(
(highlightIdx: number | null) => {
const canvas = canvasRef.current;
if (!canvas || points.length < 2) return;
@ -199,384 +73,29 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const chartW = w - PADDING.left - PADDING.right;
const chartH = h - PADDING.top - PADDING.bottom;
const maxDist = points[points.length - 1]!.distance;
const elevations = points.map((p) => p.elevation);
const minEle = Math.min(...elevations);
const maxEle = Math.max(...elevations);
const eleRange = maxEle - minEle || 1;
const toX = (d: number) => PADDING.left + (d / maxDist) * chartW;
const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH;
ctx.clearRect(0, 0, w, h);
if (colorMode === "grade") {
// Grade-colored segments: color by steepness
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const dDist = p1.distance - p0.distance;
const grade = dDist > 0 ? ((p1.elevation - p0.elevation) / dDist) * 100 : 0;
const color = gradeColor(grade);
// Fill segment
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color.replace(")", ", 0.25)").replace("rgb", "rgba").replace("#", "");
// hex to rgba fill
ctx.fillStyle = color + "40";
ctx.fill();
// Line segment
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "elevation") {
// Elevation-colored fill and line segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const t = (p0.elevation - minEle) / eleRange;
const color = elevationColor(t);
// Fill segment
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)");
ctx.fill();
// Line segment
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "surface" && surfaces.length >= points.length) {
// Surface-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const surface = surfaces[i] ?? "unknown";
const color = SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR;
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "highway" && highways.length >= points.length) {
// Highway-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const highway = highways[i] ?? "unknown";
const color = HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR;
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
// Maxspeed-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const speed = maxspeeds[i] ?? "unknown";
const color = maxspeedColor(speed);
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
// Smoothness-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const smoothness = smoothnesses[i] ?? "unknown";
const color = SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_COLOR;
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
// Track type-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const tracktype = tracktypes[i] ?? "unknown";
const color = TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_COLOR;
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
// Cycleway-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const cycleway = cycleways[i] ?? "unknown";
const color = CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_COLOR;
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
// Bike route-colored segments
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const bikeroute = bikeroutes[i] ?? "none";
const color = BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_COLOR;
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else {
// Plain fill and line
ctx.beginPath();
ctx.moveTo(PADDING.left, PADDING.top + chartH);
for (const p of points) {
ctx.lineTo(toX(p.distance), toY(p.elevation));
}
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
ctx.fill();
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
else ctx.lineTo(toX(p.distance), toY(p.elevation));
}
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Axis labels
ctx.fillStyle = "#6b7280";
ctx.font = "10px sans-serif";
ctx.textAlign = "right";
ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10);
ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH);
ctx.textAlign = "center";
ctx.fillText("0 km", PADDING.left, h - 4);
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
// Day dividers
if (days && days.length > 1) {
for (let d = 0; d < days.length - 1; d++) {
// Find the point closest to the day boundary distance
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
const bx = toX(boundaryDist);
// Dashed vertical line
ctx.beginPath();
ctx.setLineDash([4, 3]);
ctx.moveTo(bx, PADDING.top);
ctx.lineTo(bx, PADDING.top + chartH);
ctx.strokeStyle = "#9A9484";
ctx.lineWidth = 1;
ctx.stroke();
ctx.setLineDash([]);
// Day label at top
ctx.fillStyle = "#9A9484";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
}
}
// Hover crosshair + info
if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) {
const p = points[highlightIdx]!;
const hx = toX(p.distance);
const hy = toY(p.elevation);
// Vertical line
ctx.beginPath();
ctx.moveTo(hx, PADDING.top);
ctx.lineTo(hx, PADDING.top + chartH);
ctx.strokeStyle = "rgba(239, 68, 68, 0.5)";
ctx.lineWidth = 1;
ctx.stroke();
// Dot
ctx.beginPath();
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.stroke();
// Info label
ctx.fillStyle = "#1f2937";
ctx.font = "bold 10px sans-serif";
ctx.textAlign = "left";
let label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`;
if (colorMode === "grade" && highlightIdx > 0) {
const prev = points[highlightIdx - 1]!;
const dDist = p.distance - prev.distance;
const grade = dDist > 0 ? ((p.elevation - prev.elevation) / dDist) * 100 : 0;
label += ` · ${grade > 0 ? "+" : ""}${grade.toFixed(1)}%`;
}
if (colorMode === "surface" && surfaces[highlightIdx]) {
label += ` · ${surfaces[highlightIdx]}`;
}
if (colorMode === "highway" && highways[highlightIdx]) {
label += ` · ${highways[highlightIdx]}`;
}
if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) {
const s = maxspeeds[highlightIdx]!;
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
}
if (colorMode === "smoothness" && smoothnesses[highlightIdx]) {
label += ` · ${smoothnesses[highlightIdx]}`;
}
if (colorMode === "tracktype" && tracktypes[highlightIdx]) {
label += ` · ${tracktypes[highlightIdx]}`;
}
if (colorMode === "cycleway" && cycleways[highlightIdx]) {
label += ` · ${cycleways[highlightIdx]}`;
}
if (colorMode === "bikeroute" && bikeroutes[highlightIdx]) {
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
label += ` · ${names[bikeroutes[highlightIdx]!] ?? bikeroutes[highlightIdx]}`;
}
const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8;
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
ctx.fillText(label, labelX, PADDING.top + 10);
}
// Drag selection overlay
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null) {
const x1 = Math.max(PADDING.left, Math.min(dragStartX.current, PADDING.left + chartW));
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX.current, PADDING.left + chartW));
const selLeft = Math.min(x1, x2);
const selRight = Math.max(x1, x2);
ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH);
ctx.strokeStyle = "rgba(59, 130, 246, 0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH);
}
drawElevationChart(ctx, rect.width, rect.height, {
points,
colorMode,
surfaces,
highways,
maxspeeds,
smoothnesses,
tracktypes,
cycleways,
bikeroutes,
hoverIdx: highlightIdx,
isDragging: isDragging.current,
dragStartX: dragStartX.current,
dragCurrentX: dragCurrentX.current,
days,
});
},
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t],
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days],
);
useEffect(() => {
drawChart(hoverIdx);
}, [points, hoverIdx, colorMode, drawChart]);
draw(hoverIdx);
}, [points, hoverIdx, colorMode, draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
@ -588,13 +107,12 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (mouseX - PADDING.left) / chartW;
// Handle drag selection
if (dragStartClientX.current != null) {
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx > 5) {
isDragging.current = true;
dragCurrentX.current = mouseX;
drawChart(hoverIdx);
draw(hoverIdx);
return;
}
}
@ -608,7 +126,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const maxDist = points[points.length - 1]!.distance;
const targetDist = ratio * maxDist;
// Find closest point
let closest = 0;
let minDiff = Infinity;
for (let i = 0; i < points.length; i++) {
@ -624,7 +141,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const p = points[closest]!;
onHover?.([p.lat, p.lon]);
},
[points, onHover, hoverIdx, drawChart],
[points, onHover, hoverIdx, draw],
);
const handleMouseLeave = useCallback(() => {
@ -636,18 +153,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
dragCurrentX.current = null;
}, [onHover]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
dragStartX.current = e.clientX - rect.left;
dragStartClientX.current = e.clientX;
isDragging.current = false;
dragCurrentX.current = null;
},
[],
);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
dragStartX.current = e.clientX - rect.left;
dragStartClientX.current = e.clientX;
isDragging.current = false;
dragCurrentX.current = null;
}, []);
const handleMouseUp = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
@ -665,14 +179,12 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const maxDist = points[points.length - 1]!.distance;
if (isDragging.current && dragStartX.current != null) {
// Drag-select: compute bounding box of selected range
const endX = e.clientX - rect.left;
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
const endRatio = Math.max(0, Math.min(1, (endX - PADDING.left) / chartW));
const startDist = Math.min(startRatio, endRatio) * maxDist;
const endDist = Math.max(startRatio, endRatio) * maxDist;
// Find all points in the selected distance range
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
let found = false;
for (const p of points) {
@ -684,12 +196,10 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
found = true;
}
}
if (found && onDragSelect) {
onDragSelect([[minLat, minLon], [maxLat, maxLon]]);
}
} else if (dragStartClientX.current != null) {
// Click (not drag): pan map to clicked point
const dx = Math.abs(e.clientX - dragStartClientX.current);
if (dx <= 5 && onClickPosition) {
const mouseX = e.clientX - rect.left;
@ -705,8 +215,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
closest = i;
}
}
const p = points[closest]!;
onClickPosition([p.lat, p.lon]);
onClickPosition([points[closest]!.lat, points[closest]!.lon]);
}
}
}
@ -715,12 +224,11 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
drawChart(hoverIdx);
draw(hoverIdx);
},
[points, onClickPosition, onDragSelect, hoverIdx, drawChart],
[points, onClickPosition, onDragSelect, hoverIdx, draw],
);
// Touch handlers for mobile
const handleTouchStart = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
@ -729,13 +237,11 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const rect = canvas.getBoundingClientRect();
if (e.touches.length === 1) {
// Single touch: start scrub + potential drag
const x = e.touches[0]!.clientX - rect.left;
dragStartX.current = x;
dragStartClientX.current = e.touches[0]!.clientX;
isDragging.current = false;
dragCurrentX.current = null;
// Immediately show highlight at touch position
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (x - PADDING.left) / chartW;
if (ratio >= 0 && ratio <= 1 && points.length >= 2) {
@ -753,16 +259,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
onHover?.([p.lat, p.lon]);
}
} else if (e.touches.length === 2) {
// Two fingers: range select
const x1 = e.touches[0]!.clientX - rect.left;
const x2 = e.touches[1]!.clientX - rect.left;
dragStartX.current = Math.min(x1, x2);
dragCurrentX.current = Math.max(x1, x2);
isDragging.current = true;
drawChart(hoverIdx);
draw(hoverIdx);
}
},
[points, onHover, hoverIdx, drawChart],
[points, onHover, hoverIdx, draw],
);
const handleTouchMove = useCallback(
@ -773,7 +278,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
const rect = canvas.getBoundingClientRect();
if (e.touches.length === 1 && !isDragging.current) {
// Single touch scrub: update highlight
const x = e.touches[0]!.clientX - rect.left;
const chartW = rect.width - PADDING.left - PADDING.right;
const ratio = (x - PADDING.left) / chartW;
@ -792,22 +296,20 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
onHover?.([p.lat, p.lon]);
}
} else if (e.touches.length === 2) {
// Two finger range select: update selection
const x1 = e.touches[0]!.clientX - rect.left;
const x2 = e.touches[1]!.clientX - rect.left;
dragStartX.current = Math.min(x1, x2);
dragCurrentX.current = Math.max(x1, x2);
isDragging.current = true;
drawChart(hoverIdx);
draw(hoverIdx);
}
},
[points, onHover, hoverIdx, drawChart],
[points, onHover, hoverIdx, draw],
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null && points.length >= 2) {
// Two finger range complete: zoom map
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
@ -834,10 +336,8 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
}
}
} else if (e.changedTouches.length === 1 && onClickPosition && dragStartClientX.current != null) {
// Single tap (no significant movement): pan map
const dx = Math.abs(e.changedTouches[0]!.clientX - dragStartClientX.current);
if (dx <= 10) {
// Treat as tap → pan
const canvas = canvasRef.current;
if (canvas && points.length >= 2) {
const rect = canvas.getBoundingClientRect();
@ -859,16 +359,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
}
}
// Clear highlight on touch end
setHoverIdx(null);
onHover?.(null);
dragStartX.current = null;
dragStartClientX.current = null;
isDragging.current = false;
dragCurrentX.current = null;
drawChart(null);
draw(null);
},
[points, onHover, onClickPosition, onDragSelect, drawChart],
[points, onHover, onClickPosition, onDragSelect, draw],
);
const setMode = useCallback((mode: string) => {

View file

@ -0,0 +1,275 @@
import { useEffect, useState, useRef } from "react";
import { useMap, useMapEvents, Marker } from "react-leaflet";
import L from "leaflet";
import type { YjsState } from "~/lib/use-yjs";
import { overlayLayers } from "@trails-cool/map";
import { usePois } from "~/lib/use-pois";
import { Z_CURSOR } from "@trails-cool/map-core";
// Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations.
export function MapExposer() {
const map = useMap();
useEffect(() => {
if (typeof window !== "undefined") {
(window as unknown as Record<string, unknown>).__leafletMap = map;
}
}, [map]);
return null;
}
// Fits the map to the route bounds once on first load.
export function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) {
const map = useMap();
const hasFitted = useRef(false);
useEffect(() => {
if (hasFitted.current || !coordinates || coordinates.length < 2) return;
const bounds = L.latLngBounds(
coordinates.map((c) => [c[1]!, c[0]!] as [number, number]),
);
if (!bounds.isValid()) return;
const raf = requestAnimationFrame(() => {
map.invalidateSize();
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
hasFitted.current = true;
});
return () => cancelAnimationFrame(raf);
}, [coordinates, map]);
return null;
}
// Routes map click events to a callback, with a suppressRef for one-shot suppression (e.g. after route insert).
export function MapClickHandler({
onAdd,
suppressRef,
}: {
onAdd: (lat: number, lng: number) => void;
suppressRef: React.RefObject<boolean>;
}) {
useMapEvents({
click(e) {
if (suppressRef.current) {
suppressRef.current = false;
return;
}
onAdd(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
// Broadcasts the local user's cursor position via Yjs awareness and renders remote cursors.
export function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
const map = useMap();
const [cursors, setCursors] = useState<
Map<number, { lat: number; lng: number; color: string; name: string }>
>(new Map());
useEffect(() => {
const localId = awareness.clientID;
const handleMouseMove = (e: L.LeafletMouseEvent) => {
awareness.setLocalStateField("mapCursor", { lat: e.latlng.lat, lng: e.latlng.lng });
};
const handleMouseOut = () => {
awareness.setLocalStateField("mapCursor", null);
};
map.on("mousemove", handleMouseMove);
map.on("mouseout", handleMouseOut);
const updateCursors = () => {
const states = awareness.getStates();
const newCursors = new Map<number, { lat: number; lng: number; color: string; name: string }>();
states.forEach((state, clientId) => {
if (clientId !== localId && state.mapCursor && state.user) {
newCursors.set(clientId, {
lat: state.mapCursor.lat,
lng: state.mapCursor.lng,
color: state.user.color,
name: state.user.name,
});
}
});
setCursors(newCursors);
};
awareness.on("change", updateCursors);
return () => {
map.off("mousemove", handleMouseMove);
map.off("mouseout", handleMouseOut);
awareness.off("change", updateCursors);
};
}, [map, awareness]);
return (
<>
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
<Marker
key={clientId}
position={[cursor.lat, cursor.lng]}
zIndexOffset={Z_CURSOR}
icon={L.divIcon({
className: "",
html: `<div style="position:relative;z-index:400;pointer-events:none">
<svg width="16" height="20" viewBox="0 0 16 20" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))">
<path d="M0 0L16 12L8 12L4 20Z" fill="${cursor.color}" />
</svg>
<span style="
position:absolute;left:16px;top:2px;
background:${cursor.color};color:white;
padding:1px 6px;border-radius:6px;
font-size:11px;font-weight:500;white-space:nowrap;
box-shadow:0 1px 3px rgba(0,0,0,0.2);
line-height:1.4;
">${cursor.name}</span>
</div>`,
iconSize: [0, 0],
})}
/>
))}
</>
);
}
// Leaflet control button for toggling no-go area drawing mode.
export function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
}, []);
return (
<div ref={ref} className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
<div className="leaflet-control leaflet-bar">
<a
href="#"
role="button"
title={active ? "Cancel no-go area" : "Draw no-go area"}
onClick={(e) => { e.preventDefault(); onClick(); }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
fontSize: 16,
background: active ? "#fecaca" : "white",
color: active ? "#dc2626" : "#333",
}}
>
</a>
</div>
</div>
);
}
// Keeps Yjs routeData and the Leaflet LayersControl in sync (both directions).
export function OverlaySync({
yjs,
onOverlayChange,
onBaseLayerChange,
}: {
yjs: YjsState;
onOverlayChange: (ids: string[]) => void;
onBaseLayerChange: (name: string) => void;
}) {
const map = useMap();
const suppressRef = useRef(false);
useEffect(() => {
const handleAdd = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
const layer = overlayLayers.find((l) => l.name === e.name);
if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined;
const current: string[] = raw ? JSON.parse(raw) : [];
if (!current.includes(layer.id)) {
const updated = [...current, layer.id];
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated);
}
};
const handleRemove = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
const layer = overlayLayers.find((l) => l.name === e.name);
if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined;
const current: string[] = raw ? JSON.parse(raw) : [];
const updated = current.filter((id) => id !== layer.id);
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated);
};
const handleBaseChange = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
yjs.routeData.set("baseLayer", e.name);
};
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn);
map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
return () => {
map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn);
map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn);
map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
};
}, [map, yjs, onOverlayChange]);
useEffect(() => {
const handleChange = () => {
const raw = yjs.routeData.get("overlays") as string | undefined;
if (raw) {
try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ }
}
const base = yjs.routeData.get("baseLayer") as string | undefined;
if (base) onBaseLayerChange(base);
};
yjs.routeData.observe(handleChange);
handleChange();
return () => yjs.routeData.unobserve(handleChange);
}, [yjs, onOverlayChange, onBaseLayerChange]);
return null;
}
// Triggers POI refreshes on map move and category changes.
export function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
const map = useMap();
const refreshRef = useRef(poiState.refresh);
refreshRef.current = poiState.refresh;
useEffect(() => {
const refresh = () => {
const bounds = map.getBounds();
const zoom = map.getZoom();
refreshRef.current(
{ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() },
zoom,
);
};
map.on("moveend", refresh);
return () => { map.off("moveend", refresh); };
}, [map]);
const prevCategories = useRef(poiState.enabledCategories);
useEffect(() => {
if (prevCategories.current === poiState.enabledCategories) return;
prevCategories.current = poiState.enabledCategories;
const bounds = map.getBounds();
const zoom = map.getZoom();
poiState.refresh(
{ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() },
zoom,
);
}, [map, poiState.enabledCategories, poiState.refresh]);
return null;
}

View file

@ -1,42 +1,33 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet";
import { useState, useCallback, useRef } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline } from "react-leaflet";
import L from "leaflet";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import type { DayStage } from "@trails-cool/gpx";
import type { YjsState } from "~/lib/use-yjs";
import { baseLayers, overlayLayers } from "@trails-cool/map";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { isOvernight } from "~/lib/overnight";
import { setOvernight } from "~/lib/overnight";
import { usePois } from "~/lib/use-pois";
import { useProfileDefaults } from "~/lib/use-profile-defaults";
import { useYjsPoiSync } from "~/lib/use-yjs-poi-sync";
import { snapToPoi } from "~/lib/poi-snap";
import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core";
import { Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core";
import { NoGoAreaLayer } from "./NoGoAreaLayer";
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
import { ColoredRoute } from "./ColoredRoute";
import { RouteInteraction } from "./RouteInteraction";
import { PoiPanel, PoiMarkers } from "./PoiPanel";
import { WaypointContextMenu } from "./WaypointContextMenu";
import {
MapExposer,
RouteFitter,
MapClickHandler,
CursorTracker,
NoGoAreaButton,
OverlaySync,
PoiRefresher,
} from "./MapHelpers";
import { useWaypointManager } from "~/lib/use-waypoint-manager";
import { useGpxDrop } from "~/lib/use-gpx-drop";
import "leaflet/dist/leaflet.css";
/** Distance from a point to a line segment in degrees (approximate) */
function pointToSegmentDist(
pLat: number, pLon: number,
aLat: number, aLon: number,
bLat: number, bLon: number,
): number {
const dx = bLon - aLon;
const dy = bLat - aLat;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2);
const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq));
const projLon = aLon + t * dx;
const projLat = aLat + t * dy;
return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2);
}
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
const bg = overnight ? "#8B6D3A" : "#2563eb";
const scale = highlighted ? "scale(1.17)" : "scale(1)";
@ -70,26 +61,10 @@ function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon {
});
}
interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
return waypoints.toArray().map((yMap) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
interface PlannerMapProps {
yjs: YjsState;
sessionId: string;
onRouteRequest?: (waypoints: WaypointData[]) => void;
onRouteRequest?: (waypoints: { lat: number; lon: number; name?: string; overnight: boolean }[]) => void;
onImportError?: (message: string) => void;
highlightPosition?: [number, number] | null;
highlightedWaypoint?: number | null;
@ -97,625 +72,45 @@ interface PlannerMapProps {
days?: DayStage[];
}
function MapExposer() {
const map = useMap();
useEffect(() => {
if (typeof window !== "undefined") {
(window as unknown as Record<string, unknown>).__leafletMap = map;
}
}, [map]);
return null;
}
function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) {
const map = useMap();
const hasFitted = useRef(false);
useEffect(() => {
if (hasFitted.current || !coordinates || coordinates.length < 2) return;
// Coordinates are in [lon, lat, elevation] GeoJSON format
const bounds = L.latLngBounds(
coordinates.map((c) => [c[1]!, c[0]!] as [number, number]),
);
if (!bounds.isValid()) return;
// Delay fitBounds so the layout has settled (elevation chart may resize the map)
const raf = requestAnimationFrame(() => {
map.invalidateSize();
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
hasFitted.current = true;
});
return () => cancelAnimationFrame(raf);
}, [coordinates, map]);
return null;
}
function MapClickHandler({ onAdd, suppressRef }: { onAdd: (lat: number, lng: number) => void; suppressRef: React.RefObject<boolean> }) {
useMapEvents({
click(e) {
if (suppressRef.current) {
suppressRef.current = false;
return;
}
onAdd(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
const map = useMap();
const [cursors, setCursors] = useState<Map<number, { lat: number; lng: number; color: string; name: string }>>(new Map());
useEffect(() => {
const localId = awareness.clientID;
const handleMouseMove = (e: L.LeafletMouseEvent) => {
awareness.setLocalStateField("mapCursor", {
lat: e.latlng.lat,
lng: e.latlng.lng,
});
};
const handleMouseOut = () => {
awareness.setLocalStateField("mapCursor", null);
};
map.on("mousemove", handleMouseMove);
map.on("mouseout", handleMouseOut);
const updateCursors = () => {
const states = awareness.getStates();
const newCursors = new Map<number, { lat: number; lng: number; color: string; name: string }>();
states.forEach((state, clientId) => {
if (clientId !== localId && state.mapCursor && state.user) {
newCursors.set(clientId, {
lat: state.mapCursor.lat,
lng: state.mapCursor.lng,
color: state.user.color,
name: state.user.name,
});
}
});
setCursors(newCursors);
};
awareness.on("change", updateCursors);
return () => {
map.off("mousemove", handleMouseMove);
map.off("mouseout", handleMouseOut);
awareness.off("change", updateCursors);
};
}, [map, awareness]);
return (
<>
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
<Marker
key={clientId}
position={[cursor.lat, cursor.lng]}
zIndexOffset={Z_CURSOR}
icon={L.divIcon({
className: "",
html: `<div style="position:relative;z-index:400;pointer-events:none">
<svg width="16" height="20" viewBox="0 0 16 20" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))">
<path d="M0 0L16 12L8 12L4 20Z" fill="${cursor.color}" />
</svg>
<span style="
position:absolute;left:16px;top:2px;
background:${cursor.color};color:white;
padding:1px 6px;border-radius:6px;
font-size:11px;font-weight:500;white-space:nowrap;
box-shadow:0 1px 3px rgba(0,0,0,0.2);
line-height:1.4;
">${cursor.name}</span>
</div>`,
iconSize: [0, 0],
})}
/>
))}
</>
);
}
function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
const ref = useRef<HTMLDivElement>(null);
// Prevent clicks from reaching the Leaflet map (same as built-in controls)
useEffect(() => {
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
}, []);
return (
<div ref={ref} className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
<div className="leaflet-control leaflet-bar">
<a
href="#"
role="button"
title={active ? "Cancel no-go area" : "Draw no-go area"}
onClick={(e) => { e.preventDefault(); onClick(); }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
fontSize: 16,
background: active ? "#fecaca" : "white",
color: active ? "#dc2626" : "#333",
}}
>
</a>
</div>
</div>
);
}
function OverlaySync({ yjs, onOverlayChange, onBaseLayerChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void; onBaseLayerChange: (name: string) => void }) {
const map = useMap();
const suppressRef = useRef(false);
// Map events → Yjs
useEffect(() => {
const handleAdd = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
const name = e.name;
const layer = overlayLayers.find((l) => l.name === name);
if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined;
const current: string[] = raw ? JSON.parse(raw) : [];
if (!current.includes(layer.id)) {
const updated = [...current, layer.id];
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated);
}
};
const handleRemove = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
const name = e.name;
const layer = overlayLayers.find((l) => l.name === name);
if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined;
const current: string[] = raw ? JSON.parse(raw) : [];
const updated = current.filter((id) => id !== layer.id);
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated);
};
// Base layer change → Yjs
const handleBaseChange = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
yjs.routeData.set("baseLayer", e.name);
};
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn);
map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
return () => {
map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn);
map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn);
map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
};
}, [map, yjs, onOverlayChange]);
// Yjs → Map: load initial state from Yjs
useEffect(() => {
const handleChange = () => {
const raw = yjs.routeData.get("overlays") as string | undefined;
if (raw) {
try {
onOverlayChange(JSON.parse(raw));
} catch { /* ignore */ }
}
const base = yjs.routeData.get("baseLayer") as string | undefined;
if (base) onBaseLayerChange(base);
};
yjs.routeData.observe(handleChange);
handleChange();
return () => yjs.routeData.unobserve(handleChange);
}, [yjs, onOverlayChange, onBaseLayerChange]);
return null;
}
function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
const map = useMap();
const refreshRef = useRef(poiState.refresh);
refreshRef.current = poiState.refresh;
useEffect(() => {
const refresh = () => {
const bounds = map.getBounds();
const zoom = map.getZoom();
refreshRef.current({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
}, zoom);
};
map.on("moveend", refresh);
// Don't call refresh() immediately — let moveend trigger it
return () => { map.off("moveend", refresh); };
}, [map]);
// Trigger refresh when categories change (but not on mount)
const prevCategories = useRef(poiState.enabledCategories);
useEffect(() => {
if (prevCategories.current === poiState.enabledCategories) return;
prevCategories.current = poiState.enabledCategories;
const bounds = map.getBounds();
const zoom = map.getZoom();
poiState.refresh({
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
}, zoom);
}, [map, poiState.enabledCategories, poiState.refresh]);
return null;
}
export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const poiState = usePois(sessionId);
useProfileDefaults(yjs, poiState);
useYjsPoiSync(yjs, poiState);
const [enabledOverlays, setEnabledOverlays] = useState<string[]>([]);
const [selectedBaseLayer, setSelectedBaseLayer] = useState<string>(baseLayers[0]!.name);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; index: number } | null>(null);
const [draggingOver, setDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
const [surfaces, setSurfaces] = useState<string[]>([]);
const [highways, setHighways] = useState<string[]>([]);
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
const [tracktypes, setTracktypes] = useState<string[]>([]);
const [cycleways, setCycleways] = useState<string[]>([]);
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
const [noGoDrawing, setNoGoDrawing] = useState(false);
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
const suppressMapClickRef = useRef(false);
const routeInteractionSuspendedRef = useRef(false);
const waypointDraggingRef = useRef(false);
// Sync waypoints from Yjs
useEffect(() => {
const update = () => {
const wps = getWaypointsFromYjs(yjs.waypoints);
setWaypoints(wps);
if (wps.length >= 2 && onRouteRequest) {
onRouteRequest(wps);
}
};
const {
waypoints,
routeCoordinates,
segmentBoundaries,
surfaces,
highways,
maxspeeds,
smoothnesses,
tracktypes,
cycleways,
bikeroutes,
colorMode,
addWaypoint,
handleRouteInsert,
moveWaypoint,
deleteWaypoint,
handleRoutePolylineHover,
} = useWaypointManager(yjs, poiState, suppressMapClickRef, onRouteRequest);
yjs.waypoints.observeDeep(update);
update();
return () => {
yjs.waypoints.unobserveDeep(update);
};
}, [yjs.waypoints, onRouteRequest]);
// Sync route data from Yjs (enriched: coordinates + segment boundaries)
useEffect(() => {
const update = () => {
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined;
const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined;
if (coordsJson) {
try {
setRouteCoordinates(JSON.parse(coordsJson));
} catch {
setRouteCoordinates(null);
}
} else {
// Fallback: parse from geojson for backwards compat
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
try {
const parsed = JSON.parse(geojson);
const coords = parsed.features?.[0]?.geometry?.coordinates;
if (coords) {
setRouteCoordinates(coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]));
}
} catch { setRouteCoordinates(null); }
} else {
setRouteCoordinates(null);
}
}
if (boundsJson) {
try { setSegmentBoundaries(JSON.parse(boundsJson)); } catch { setSegmentBoundaries([]); }
} else {
setSegmentBoundaries([]);
}
const surfacesJson = yjs.routeData.get("surfaces") as string | undefined;
if (surfacesJson) {
try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); }
} else {
setSurfaces([]);
}
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
if (highwaysJson) {
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
} else {
setHighways([]);
}
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
if (maxspeedsJson) {
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
} else {
setMaxspeeds([]);
}
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
if (smoothnessesJson) {
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
} else {
setSmoothnesses([]);
}
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
if (tracktypesJson) {
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
} else {
setTracktypes([]);
}
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
if (cyclewaysJson) {
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
} else {
setCycleways([]);
}
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
if (bikeroutesJson) {
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
} else {
setBikeroutes([]);
}
if (modeVal) setColorMode(modeVal);
};
yjs.routeData.observe(update);
update();
return () => {
yjs.routeData.unobserve(update);
};
}, [yjs.routeData]);
const addWaypoint = useCallback(
(lat: number, lng: number, name?: string) => {
const snap = snapToPoi(lat, lng, poiState.pois);
const finalLat = snap.lat;
const finalLon = snap.snapped ? snap.lon : lng;
// Find the best insertion index: if the point is near the route,
// insert between the closest segment's waypoints instead of appending
let insertIndex = yjs.waypoints.length; // default: append
if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) {
let bestDist = Infinity;
let bestSegment = -1;
// For each segment, find the closest point on the route
for (let seg = 0; seg < segmentBoundaries.length; seg++) {
const start = segmentBoundaries[seg]!;
const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length;
for (let i = start; i < end - 1; i++) {
const c1 = routeCoordinates[i]!;
const c2 = routeCoordinates[i + 1]!;
const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!);
if (dist < bestDist) {
bestDist = dist;
bestSegment = seg;
}
}
}
// If within ~1km of the route, insert after the segment's waypoint
if (bestDist < 0.01 && bestSegment >= 0) {
insertIndex = bestSegment + 1;
}
}
yjs.doc.transact(() => {
const yMap = new Y.Map();
yMap.set("lat", finalLat);
yMap.set("lon", finalLon);
if (snap.name) yMap.set("name", snap.name);
else if (name) yMap.set("name", name);
if (snap.osmId) yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
yjs.waypoints.insert(insertIndex, [yMap]);
}, "local");
},
[yjs.doc, yjs.waypoints, poiState.pois, routeCoordinates, segmentBoundaries],
);
const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => {
const snap = snapToPoi(lat, lon, poiState.pois);
yjs.doc.transact(() => {
const yMap = new Y.Map();
yMap.set("lat", snap.lat);
yMap.set("lon", snap.snapped ? snap.lon : lon);
if (snap.name) yMap.set("name", snap.name);
if (snap.osmId) yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
}, "local");
},
[yjs.doc, yjs.waypoints, poiState.pois],
);
const handleRouteInsert = useCallback(
(pointIndex: number, lat: number, lon: number) => {
suppressMapClickRef.current = true;
const segIdx = findSegmentForPoint(pointIndex, segmentBoundaries);
insertWaypointAtSegment(segIdx, lat, lon);
},
[segmentBoundaries, insertWaypointAtSegment],
);
const moveWaypoint = useCallback(
(index: number, lat: number, lng: number) => {
const snap = snapToPoi(lat, lng, poiState.pois);
const yMap = yjs.waypoints.get(index);
if (yMap) {
yjs.doc.transact(() => {
yMap.set("lat", snap.lat);
yMap.set("lon", snap.snapped ? snap.lon : lng);
if (snap.snapped && snap.name) {
yMap.set("name", snap.name);
} else {
yMap.delete("name");
}
if (snap.osmId) {
yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
} else {
yMap.delete("osmId");
yMap.delete("poiTags");
}
}, "local");
}
},
[yjs.waypoints, yjs.doc, poiState.pois],
);
const deleteWaypoint = useCallback(
(index: number) => {
yjs.doc.transact(() => {
yjs.waypoints.delete(index, 1);
}, "local");
},
[yjs.waypoints],
);
const handleRoutePolylineHover = useCallback(
(e: L.LeafletMouseEvent) => {
if (!routeCoordinates || routeCoordinates.length < 2 || !onRouteHover) return;
const { lat, lng } = e.latlng;
// Find the closest coordinate index
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < routeCoordinates.length; i++) {
const c = routeCoordinates[i]!;
const dLat = c[1]! - lat;
const dLon = c[0]! - lng;
const dist = dLat * dLat + dLon * dLon;
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
// Compute cumulative distance to this point using haversine
let totalDist = 0;
for (let i = 1; i <= bestIdx; i++) {
const prev = routeCoordinates[i - 1]!;
const curr = routeCoordinates[i]!;
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(curr[1]! - prev[1]!);
const dLon = toRad(curr[0]! - prev[0]!);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2;
totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
onRouteHover(totalDist);
},
[routeCoordinates, onRouteHover],
);
const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError);
const handleRoutePolylineOut = useCallback(() => {
onRouteHover?.(null);
}, [onRouteHover]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current++;
if (dragCounterRef.current === 1) setDraggingOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setDraggingOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setDraggingOver(false);
const file = e.dataTransfer.files[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith(".gpx")) {
onImportError?.(t("importGpxError"));
return;
}
try {
const text = await file.text();
const gpxData = await parseGpxAsync(text);
const newWaypoints = extractWaypoints(gpxData);
if (newWaypoints.length < 2) return;
if (!window.confirm(t("replaceRouteConfirm"))) return;
yjs.doc.transact(() => {
// Replace waypoints
yjs.waypoints.delete(0, yjs.waypoints.length);
for (const wp of newWaypoints) {
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
if (wp.isDayBreak) yMap.set("overnight", true);
yjs.waypoints.push([yMap]);
}
// Replace no-go areas
yjs.noGoAreas.delete(0, yjs.noGoAreas.length);
for (const area of gpxData.noGoAreas) {
const yMap = new Y.Map();
yMap.set("points", area.points);
yjs.noGoAreas.push([yMap]);
}
// Replace notes if GPX has a description
if (gpxData.description) {
yjs.notes.delete(0, yjs.notes.length);
yjs.notes.insert(0, gpxData.description);
}
}, "local");
} catch {
onImportError?.(t("importGpxError"));
}
}, [yjs, t, onImportError]);
return (
<div
className="relative h-full w-full"
@ -731,128 +126,123 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
</div>
</div>
)}
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
<LayersControl position="topright">
{baseLayers.map((layer) => (
<LayersControl.BaseLayer key={layer.name} checked={layer.name === selectedBaseLayer} name={layer.name}>
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
</LayersControl.BaseLayer>
))}
{overlayLayers.map((layer) => (
<LayersControl.Overlay key={layer.id} checked={enabledOverlays.includes(layer.id)} name={layer.name}>
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} opacity={layer.opacity ?? 0.7} />
</LayersControl.Overlay>
))}
</LayersControl>
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
<LayersControl position="topright">
{baseLayers.map((layer) => (
<LayersControl.BaseLayer key={layer.name} checked={layer.name === selectedBaseLayer} name={layer.name}>
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
</LayersControl.BaseLayer>
))}
{overlayLayers.map((layer) => (
<LayersControl.Overlay key={layer.id} checked={enabledOverlays.includes(layer.id)} name={layer.name}>
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} opacity={layer.opacity ?? 0.7} />
</LayersControl.Overlay>
))}
</LayersControl>
<MapExposer />
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} onBaseLayerChange={setSelectedBaseLayer} />
<RouteFitter coordinates={routeCoordinates} />
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
<CursorTracker awareness={yjs.awareness} />
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
<PoiRefresher poiState={poiState} />
<PoiMarkers poiState={poiState} onAddWaypoint={addWaypoint} />
<PoiPanel poiState={poiState} />
<MapExposer />
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} onBaseLayerChange={setSelectedBaseLayer} />
<RouteFitter coordinates={routeCoordinates} />
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
<CursorTracker awareness={yjs.awareness} />
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
<PoiRefresher poiState={poiState} />
<PoiMarkers poiState={poiState} onAddWaypoint={addWaypoint} />
<PoiPanel poiState={poiState} />
{waypoints.map((wp, i) => (
<Marker
key={i}
position={[wp.lat, wp.lon]}
draggable
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
eventHandlers={{
mouseover: () => {
routeInteractionSuspendedRef.current = true;
},
mouseout: () => {
if (!waypointDraggingRef.current) {
routeInteractionSuspendedRef.current = false;
}
},
dragstart: () => {
waypointDraggingRef.current = true;
yjs.undoManager.stopCapturing();
routeInteractionSuspendedRef.current = true;
},
dragend: (e) => {
waypointDraggingRef.current = false;
routeInteractionSuspendedRef.current = false;
const { lat, lng } = e.target.getLatLng();
moveWaypoint(i, lat, lng);
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
const orig = e.originalEvent as MouseEvent;
setContextMenu({ x: orig.clientX, y: orig.clientY, index: i });
},
}}
/>
))}
{/* Day boundary labels on map */}
{days && days.length > 1 && days.map((day) => {
const wp = waypoints[day.endWaypointIndex];
if (!wp || day.dayNumber === days.length) return null;
return (
{waypoints.map((wp, i) => (
<Marker
key={`day-${day.dayNumber}`}
key={i}
position={[wp.lat, wp.lon]}
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
interactive={false}
draggable
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
eventHandlers={{
mouseover: () => { routeInteractionSuspendedRef.current = true; },
mouseout: () => {
if (!waypointDraggingRef.current) routeInteractionSuspendedRef.current = false;
},
dragstart: () => {
waypointDraggingRef.current = true;
yjs.undoManager.stopCapturing();
routeInteractionSuspendedRef.current = true;
},
dragend: (e) => {
waypointDraggingRef.current = false;
routeInteractionSuspendedRef.current = false;
const { lat, lng } = e.target.getLatLng();
moveWaypoint(i, lat, lng);
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
const orig = e.originalEvent as MouseEvent;
setContextMenu({ x: orig.clientX, y: orig.clientY, index: i });
},
}}
/>
);
})}
))}
{routeCoordinates && routeCoordinates.length >= 2 && (
<>
<ColoredRoute
coordinates={routeCoordinates}
colorMode={colorMode}
surfaces={surfaces}
highways={highways}
maxspeeds={maxspeeds}
smoothnesses={smoothnesses}
tracktypes={tracktypes}
cycleways={cycleways}
bikeroutes={bikeroutes}
/>
<RouteInteraction
coordinates={routeCoordinates}
segmentBoundaries={segmentBoundaries}
onInsertWaypoint={handleRouteInsert}
suspendedRef={routeInteractionSuspendedRef}
disabled={noGoDrawing}
/>
{onRouteHover && (
<Polyline
positions={routeCoordinates.map((c) => [c[1]!, c[0]!] as [number, number])}
pathOptions={{ weight: 20, opacity: 0, interactive: true }}
eventHandlers={{
mouseover: handleRoutePolylineHover,
mousemove: handleRoutePolylineHover,
mouseout: handleRoutePolylineOut,
}}
{days && days.length > 1 && days.map((day) => {
const wp = waypoints[day.endWaypointIndex];
if (!wp || day.dayNumber === days.length) return null;
return (
<Marker
key={`day-${day.dayNumber}`}
position={[wp.lat, wp.lon]}
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
interactive={false}
/>
)}
</>
)}
);
})}
{highlightPosition && (
<Marker
position={highlightPosition}
zIndexOffset={Z_HIGHLIGHT}
interactive={false}
icon={L.divIcon({
className: "",
html: '<div style="width:12px;height:12px;border-radius:50%;background:#ef4444;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);transform:translate(-6px,-6px)"></div>',
iconSize: [0, 0],
})}
/>
)}
</MapContainer>
{routeCoordinates && routeCoordinates.length >= 2 && (
<>
<ColoredRoute
coordinates={routeCoordinates}
colorMode={colorMode}
surfaces={surfaces}
highways={highways}
maxspeeds={maxspeeds}
smoothnesses={smoothnesses}
tracktypes={tracktypes}
cycleways={cycleways}
bikeroutes={bikeroutes}
/>
<RouteInteraction
coordinates={routeCoordinates}
segmentBoundaries={segmentBoundaries}
onInsertWaypoint={handleRouteInsert}
suspendedRef={routeInteractionSuspendedRef}
disabled={noGoDrawing}
/>
{onRouteHover && (
<Polyline
positions={routeCoordinates.map((c) => [c[1]!, c[0]!] as [number, number])}
pathOptions={{ weight: 20, opacity: 0, interactive: true }}
eventHandlers={{
mouseover: (e) => handleRoutePolylineHover(e, onRouteHover),
mousemove: (e) => handleRoutePolylineHover(e, onRouteHover),
mouseout: handleRoutePolylineOut,
}}
/>
)}
</>
)}
{highlightPosition && (
<Marker
position={highlightPosition}
zIndexOffset={Z_HIGHLIGHT}
interactive={false}
icon={L.divIcon({
className: "",
html: '<div style="width:12px;height:12px;border-radius:50%;background:#ef4444;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);transform:translate(-6px,-6px)"></div>',
iconSize: [0, 0],
})}
/>
)}
</MapContainer>
{contextMenu && (
<WaypointContextMenu
position={{ x: contextMenu.x, y: contextMenu.y }}

View file

@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { page } from "@vitest/browser/context";
import { drawElevationChart } from "./elevation-chart-draw";
import type { DrawChartParams } from "./elevation-chart-draw";
// 10-point synthetic route: gentle climb from 100m to 200m over 10km
const BASE_POINTS = Array.from({ length: 10 }, (_, i) => ({
distance: i * 1000,
elevation: 100 + i * 10,
lat: 48 + i * 0.01,
lon: 11 + i * 0.01,
}));
const BASE_PARAMS: DrawChartParams = {
points: BASE_POINTS,
colorMode: "plain",
surfaces: [],
highways: [],
maxspeeds: [],
smoothnesses: [],
tracktypes: [],
cycleways: [],
bikeroutes: [],
hoverIdx: null,
isDragging: false,
dragStartX: null,
dragCurrentX: null,
};
function ChartFixture({ params }: { params: DrawChartParams }) {
return (
<canvas
data-testid="chart"
width={600}
height={100}
style={{ display: "block" }}
ref={(canvas) => {
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
drawElevationChart(ctx, 600, 100, params);
}}
/>
);
}
describe("drawElevationChart visual regression", () => {
it("renders plain color mode", async () => {
render(<ChartFixture params={BASE_PARAMS} />);
await expect(page.getByTestId("chart")).toMatchScreenshot("plain.png");
});
it("renders grade color mode", async () => {
render(<ChartFixture params={{ ...BASE_PARAMS, colorMode: "grade" }} />);
await expect(page.getByTestId("chart")).toMatchScreenshot("grade.png");
});
it("renders elevation color mode", async () => {
render(<ChartFixture params={{ ...BASE_PARAMS, colorMode: "elevation" }} />);
await expect(page.getByTestId("chart")).toMatchScreenshot("elevation.png");
});
it("renders surface color mode", async () => {
const surfaces = BASE_POINTS.map((_, i) =>
i < 5 ? "asphalt" : "gravel",
);
render(<ChartFixture params={{ ...BASE_PARAMS, colorMode: "surface", surfaces }} />);
await expect(page.getByTestId("chart")).toMatchScreenshot("surface.png");
});
it("renders hover crosshair at index 5", async () => {
render(<ChartFixture params={{ ...BASE_PARAMS, hoverIdx: 5 }} />);
await expect(page.getByTestId("chart")).toMatchScreenshot("hover.png");
});
it("renders drag selection overlay", async () => {
render(
<ChartFixture
params={{
...BASE_PARAMS,
isDragging: true,
dragStartX: 100,
dragCurrentX: 300,
}}
/>,
);
await expect(page.getByTestId("chart")).toMatchScreenshot("drag-select.png");
});
});

View file

@ -0,0 +1,289 @@
import {
elevationColor,
maxspeedColor,
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
SMOOTHNESS_COLORS,
DEFAULT_SMOOTHNESS_COLOR,
TRACKTYPE_COLORS,
DEFAULT_TRACKTYPE_COLOR,
CYCLEWAY_COLORS,
DEFAULT_CYCLEWAY_COLOR,
BIKEROUTE_COLORS,
DEFAULT_BIKEROUTE_COLOR,
} from "@trails-cool/map-core";
import type { ColorMode } from "~/components/ColoredRoute";
import type { DayStage } from "@trails-cool/gpx";
export interface ElevationPoint {
distance: number;
elevation: number;
lat: number;
lon: number;
}
function gradeColor(grade: number): string {
const absGrade = Math.abs(grade);
if (absGrade < 3) return "#22c55e";
if (absGrade < 6) return "#eab308";
if (absGrade < 10) return "#f97316";
if (absGrade < 15) return "#ef4444";
return "#991b1b";
}
export const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
export interface DrawChartParams {
points: ElevationPoint[];
colorMode: ColorMode;
surfaces: string[];
highways: string[];
maxspeeds: string[];
smoothnesses: string[];
tracktypes: string[];
cycleways: string[];
bikeroutes: string[];
hoverIdx: number | null;
isDragging: boolean;
dragStartX: number | null;
dragCurrentX: number | null;
days?: DayStage[];
}
function drawSegments(
ctx: CanvasRenderingContext2D,
points: ElevationPoint[],
chartH: number,
getColor: (i: number) => string,
toX: (d: number) => number,
toY: (e: number) => number,
) {
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const color = getColor(i);
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color + "40";
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
}
export function drawElevationChart(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
params: DrawChartParams,
): void {
const {
points,
colorMode,
surfaces,
highways,
maxspeeds,
smoothnesses,
tracktypes,
cycleways,
bikeroutes,
hoverIdx,
isDragging,
dragStartX,
dragCurrentX,
days,
} = params;
if (points.length < 2) return;
const w = width;
const h = height;
const chartW = w - PADDING.left - PADDING.right;
const chartH = h - PADDING.top - PADDING.bottom;
const maxDist = points[points.length - 1]!.distance;
const elevations = points.map((p) => p.elevation);
const minEle = Math.min(...elevations);
const maxEle = Math.max(...elevations);
const eleRange = maxEle - minEle || 1;
const toX = (d: number) => PADDING.left + (d / maxDist) * chartW;
const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH;
ctx.clearRect(0, 0, w, h);
if (colorMode === "grade") {
drawSegments(ctx, points, chartH, (i) => {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const dDist = p1.distance - p0.distance;
const grade = dDist > 0 ? ((p1.elevation - p0.elevation) / dDist) * 100 : 0;
return gradeColor(grade);
}, toX, toY);
} else if (colorMode === "elevation") {
// elevationColor returns rgb(...), not hex, so we can't use hex alpha suffix
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]!;
const p1 = points[i + 1]!;
const t = (p0.elevation - minEle) / eleRange;
const color = elevationColor(t);
ctx.beginPath();
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)");
ctx.fill();
ctx.beginPath();
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
} else if (colorMode === "surface" && surfaces.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => SURFACE_COLORS[surfaces[i] ?? "unknown"] ?? DEFAULT_SURFACE_COLOR, toX, toY);
} else if (colorMode === "highway" && highways.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => HIGHWAY_COLORS[highways[i] ?? "unknown"] ?? DEFAULT_HIGHWAY_COLOR, toX, toY);
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => maxspeedColor(maxspeeds[i] ?? "unknown"), toX, toY);
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => SMOOTHNESS_COLORS[smoothnesses[i] ?? "unknown"] ?? DEFAULT_SMOOTHNESS_COLOR, toX, toY);
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => TRACKTYPE_COLORS[tracktypes[i] ?? "unknown"] ?? DEFAULT_TRACKTYPE_COLOR, toX, toY);
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => CYCLEWAY_COLORS[cycleways[i] ?? "unknown"] ?? DEFAULT_CYCLEWAY_COLOR, toX, toY);
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
drawSegments(ctx, points, chartH, (i) => BIKEROUTE_COLORS[bikeroutes[i] ?? "none"] ?? DEFAULT_BIKEROUTE_COLOR, toX, toY);
} else {
// Plain fill and line
ctx.beginPath();
ctx.moveTo(PADDING.left, PADDING.top + chartH);
for (const p of points) {
ctx.lineTo(toX(p.distance), toY(p.elevation));
}
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
ctx.closePath();
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
ctx.fill();
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
else ctx.lineTo(toX(p.distance), toY(p.elevation));
}
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Axis labels
ctx.fillStyle = "#6b7280";
ctx.font = "10px sans-serif";
ctx.textAlign = "right";
ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10);
ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH);
ctx.textAlign = "center";
ctx.fillText("0 km", PADDING.left, h - 4);
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
// Day dividers
if (days && days.length > 1) {
for (let d = 0; d < days.length - 1; d++) {
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
const bx = toX(boundaryDist);
ctx.beginPath();
ctx.setLineDash([4, 3]);
ctx.moveTo(bx, PADDING.top);
ctx.lineTo(bx, PADDING.top + chartH);
ctx.strokeStyle = "#9A9484";
ctx.lineWidth = 1;
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#9A9484";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
}
}
// Hover crosshair + info
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < points.length) {
const p = points[hoverIdx]!;
const hx = toX(p.distance);
const hy = toY(p.elevation);
ctx.beginPath();
ctx.moveTo(hx, PADDING.top);
ctx.lineTo(hx, PADDING.top + chartH);
ctx.strokeStyle = "rgba(239, 68, 68, 0.5)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = "#1f2937";
ctx.font = "bold 10px sans-serif";
ctx.textAlign = "left";
let label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`;
if (colorMode === "grade" && hoverIdx > 0) {
const prev = points[hoverIdx - 1]!;
const dDist = p.distance - prev.distance;
const grade = dDist > 0 ? ((p.elevation - prev.elevation) / dDist) * 100 : 0;
label += ` · ${grade > 0 ? "+" : ""}${grade.toFixed(1)}%`;
}
if (colorMode === "surface" && surfaces[hoverIdx]) label += ` · ${surfaces[hoverIdx]}`;
if (colorMode === "highway" && highways[hoverIdx]) label += ` · ${highways[hoverIdx]}`;
if (colorMode === "maxspeed" && maxspeeds[hoverIdx]) {
const s = maxspeeds[hoverIdx]!;
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
}
if (colorMode === "smoothness" && smoothnesses[hoverIdx]) label += ` · ${smoothnesses[hoverIdx]}`;
if (colorMode === "tracktype" && tracktypes[hoverIdx]) label += ` · ${tracktypes[hoverIdx]}`;
if (colorMode === "cycleway" && cycleways[hoverIdx]) label += ` · ${cycleways[hoverIdx]}`;
if (colorMode === "bikeroute" && bikeroutes[hoverIdx]) {
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
label += ` · ${names[bikeroutes[hoverIdx]!] ?? bikeroutes[hoverIdx]}`;
}
const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8;
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
ctx.fillText(label, labelX, PADDING.top + 10);
}
// Drag selection overlay
if (isDragging && dragStartX != null && dragCurrentX != null) {
const x1 = Math.max(PADDING.left, Math.min(dragStartX, PADDING.left + chartW));
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX, PADDING.left + chartW));
const selLeft = Math.min(x1, x2);
const selRight = Math.max(x1, x2);
ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH);
ctx.strokeStyle = "rgba(59, 130, 246, 0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH);
}
}

View file

@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import * as Y from "yjs";
import type { ColorMode } from "~/components/ColoredRoute";
import type { ElevationPoint } from "~/lib/elevation-chart-draw";
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function extractElevation(geojsonStr: string): ElevationPoint[] {
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length === 0) return [];
const points: ElevationPoint[] = [];
let totalDist = 0;
for (let i = 0; i < coords.length; i++) {
if (i > 0) {
const prev = coords[i - 1]!;
const curr = coords[i]!;
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
}
if (coords[i]![2] !== undefined) {
points.push({
distance: totalDist,
elevation: coords[i]![2]!,
lat: coords[i]![1]!,
lon: coords[i]![0]!,
});
}
}
return points;
} catch {
return [];
}
}
function parseJsonArray(json: string | undefined): string[] {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
export interface ElevationData {
points: ElevationPoint[];
colorMode: ColorMode;
surfaces: string[];
highways: string[];
maxspeeds: string[];
smoothnesses: string[];
tracktypes: string[];
cycleways: string[];
bikeroutes: string[];
}
export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
const [data, setData] = useState<ElevationData>({
points: [],
colorMode: "plain",
surfaces: [],
highways: [],
maxspeeds: [],
smoothnesses: [],
tracktypes: [],
cycleways: [],
bikeroutes: [],
});
useEffect(() => {
const update = () => {
const geojson = routeData.get("geojson") as string | undefined;
setData({
points: geojson ? extractElevation(geojson) : [],
colorMode: (routeData.get("colorMode") as ColorMode | undefined) ?? "plain",
surfaces: parseJsonArray(routeData.get("surfaces") as string | undefined),
highways: parseJsonArray(routeData.get("highways") as string | undefined),
maxspeeds: parseJsonArray(routeData.get("maxspeeds") as string | undefined),
smoothnesses: parseJsonArray(routeData.get("smoothnesses") as string | undefined),
tracktypes: parseJsonArray(routeData.get("tracktypes") as string | undefined),
cycleways: parseJsonArray(routeData.get("cycleways") as string | undefined),
bikeroutes: parseJsonArray(routeData.get("bikeroutes") as string | undefined),
});
};
routeData.observe(update);
update();
return () => routeData.unobserve(update);
}, [routeData]);
return data;
}

View file

@ -0,0 +1,77 @@
import { useState, useRef, useCallback } from "react";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import type { YjsState } from "~/lib/use-yjs";
export function useGpxDrop(yjs: YjsState, onImportError?: (message: string) => void) {
const { t } = useTranslation("planner");
const [draggingOver, setDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current++;
if (dragCounterRef.current === 1) setDraggingOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setDraggingOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setDraggingOver(false);
const file = e.dataTransfer.files[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith(".gpx")) {
onImportError?.(t("importGpxError"));
return;
}
try {
const text = await file.text();
const gpxData = await parseGpxAsync(text);
const newWaypoints = extractWaypoints(gpxData);
if (newWaypoints.length < 2) return;
if (!window.confirm(t("replaceRouteConfirm"))) return;
yjs.doc.transact(() => {
yjs.waypoints.delete(0, yjs.waypoints.length);
for (const wp of newWaypoints) {
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
if (wp.isDayBreak) yMap.set("overnight", true);
yjs.waypoints.push([yMap]);
}
yjs.noGoAreas.delete(0, yjs.noGoAreas.length);
for (const area of gpxData.noGoAreas) {
const yMap = new Y.Map();
yMap.set("points", area.points);
yjs.noGoAreas.push([yMap]);
}
if (gpxData.description) {
yjs.notes.delete(0, yjs.notes.length);
yjs.notes.insert(0, gpxData.description);
}
}, "local");
} catch {
onImportError?.(t("importGpxError"));
}
}, [yjs, t, onImportError]);
return { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop };
}

View file

@ -0,0 +1,286 @@
import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import type { ColorMode } from "~/components/ColoredRoute";
import { usePois } from "~/lib/use-pois";
import { snapToPoi } from "~/lib/poi-snap";
import { isOvernight } from "~/lib/overnight";
import { findSegmentForPoint } from "~/components/ColoredRoute";
export interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
return waypoints.toArray().map((yMap) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
function pointToSegmentDist(
pLat: number, pLon: number,
aLat: number, aLon: number,
bLat: number, bLon: number,
): number {
const dx = bLon - aLon;
const dy = bLat - aLat;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2);
const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq));
const projLon = aLon + t * dx;
const projLat = aLat + t * dy;
return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2);
}
function parseJsonArray<T>(json: string | undefined): T[] {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
export interface RouteState {
waypoints: WaypointData[];
routeCoordinates: [number, number, number][] | null;
segmentBoundaries: number[];
surfaces: string[];
highways: string[];
maxspeeds: string[];
smoothnesses: string[];
tracktypes: string[];
cycleways: string[];
bikeroutes: string[];
colorMode: ColorMode;
}
export function useWaypointManager(
yjs: YjsState,
poiState: ReturnType<typeof usePois>,
suppressMapClickRef: React.RefObject<boolean>,
onRouteRequest?: (waypoints: WaypointData[]) => void,
) {
const [state, setState] = useState<RouteState>({
waypoints: [],
routeCoordinates: null,
segmentBoundaries: [],
surfaces: [],
highways: [],
maxspeeds: [],
smoothnesses: [],
tracktypes: [],
cycleways: [],
bikeroutes: [],
colorMode: "plain",
});
// Sync waypoints from Yjs
useEffect(() => {
const update = () => {
const wps = getWaypointsFromYjs(yjs.waypoints);
setState((prev) => ({ ...prev, waypoints: wps }));
if (wps.length >= 2 && onRouteRequest) {
onRouteRequest(wps);
}
};
yjs.waypoints.observeDeep(update);
update();
return () => yjs.waypoints.unobserveDeep(update);
}, [yjs.waypoints, onRouteRequest]);
// Sync route data from Yjs
useEffect(() => {
const update = () => {
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined;
const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined;
let routeCoordinates: [number, number, number][] | null = null;
if (coordsJson) {
try { routeCoordinates = JSON.parse(coordsJson); } catch { /* ignore */ }
} else {
// Fallback: parse from geojson for backwards compat
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
try {
const parsed = JSON.parse(geojson);
const coords = parsed.features?.[0]?.geometry?.coordinates;
if (coords) {
routeCoordinates = coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]);
}
} catch { /* ignore */ }
}
}
setState((prev) => ({
...prev,
routeCoordinates,
segmentBoundaries: parseJsonArray<number>(boundsJson),
surfaces: parseJsonArray<string>(yjs.routeData.get("surfaces") as string | undefined),
highways: parseJsonArray<string>(yjs.routeData.get("highways") as string | undefined),
maxspeeds: parseJsonArray<string>(yjs.routeData.get("maxspeeds") as string | undefined),
smoothnesses: parseJsonArray<string>(yjs.routeData.get("smoothnesses") as string | undefined),
tracktypes: parseJsonArray<string>(yjs.routeData.get("tracktypes") as string | undefined),
cycleways: parseJsonArray<string>(yjs.routeData.get("cycleways") as string | undefined),
bikeroutes: parseJsonArray<string>(yjs.routeData.get("bikeroutes") as string | undefined),
colorMode: modeVal ?? "plain",
}));
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
const addWaypoint = useCallback(
(lat: number, lng: number, name?: string) => {
const { routeCoordinates, segmentBoundaries } = state;
const snap = snapToPoi(lat, lng, poiState.pois);
const finalLat = snap.lat;
const finalLon = snap.snapped ? snap.lon : lng;
let insertIndex = yjs.waypoints.length;
if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) {
let bestDist = Infinity;
let bestSegment = -1;
for (let seg = 0; seg < segmentBoundaries.length; seg++) {
const start = segmentBoundaries[seg]!;
const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length;
for (let i = start; i < end - 1; i++) {
const c1 = routeCoordinates[i]!;
const c2 = routeCoordinates[i + 1]!;
const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!);
if (dist < bestDist) {
bestDist = dist;
bestSegment = seg;
}
}
}
if (bestDist < 0.01 && bestSegment >= 0) {
insertIndex = bestSegment + 1;
}
}
yjs.doc.transact(() => {
const yMap = new Y.Map();
yMap.set("lat", finalLat);
yMap.set("lon", finalLon);
if (snap.name) yMap.set("name", snap.name);
else if (name) yMap.set("name", name);
if (snap.osmId) yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
yjs.waypoints.insert(insertIndex, [yMap]);
}, "local");
},
[yjs.doc, yjs.waypoints, poiState.pois, state],
);
const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => {
const snap = snapToPoi(lat, lon, poiState.pois);
yjs.doc.transact(() => {
const yMap = new Y.Map();
yMap.set("lat", snap.lat);
yMap.set("lon", snap.snapped ? snap.lon : lon);
if (snap.name) yMap.set("name", snap.name);
if (snap.osmId) yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
}, "local");
},
[yjs.doc, yjs.waypoints, poiState.pois],
);
const handleRouteInsert = useCallback(
(pointIndex: number, lat: number, lon: number) => {
suppressMapClickRef.current = true;
const segIdx = findSegmentForPoint(pointIndex, state.segmentBoundaries);
insertWaypointAtSegment(segIdx, lat, lon);
},
[state.segmentBoundaries, insertWaypointAtSegment, suppressMapClickRef],
);
const moveWaypoint = useCallback(
(index: number, lat: number, lng: number) => {
const snap = snapToPoi(lat, lng, poiState.pois);
const yMap = yjs.waypoints.get(index);
if (yMap) {
yjs.doc.transact(() => {
yMap.set("lat", snap.lat);
yMap.set("lon", snap.snapped ? snap.lon : lng);
if (snap.snapped && snap.name) {
yMap.set("name", snap.name);
} else {
yMap.delete("name");
}
if (snap.osmId) {
yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
} else {
yMap.delete("osmId");
yMap.delete("poiTags");
}
}, "local");
}
},
[yjs.waypoints, yjs.doc, poiState.pois],
);
const deleteWaypoint = useCallback(
(index: number) => {
yjs.doc.transact(() => {
yjs.waypoints.delete(index, 1);
}, "local");
},
[yjs.waypoints],
);
const handleRoutePolylineHover = useCallback(
(e: { latlng: { lat: number; lng: number } }, onRouteHover: (d: number | null) => void) => {
const { routeCoordinates } = state;
if (!routeCoordinates || routeCoordinates.length < 2) return;
const { lat, lng } = e.latlng;
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < routeCoordinates.length; i++) {
const c = routeCoordinates[i]!;
const dLat = c[1]! - lat;
const dLon = c[0]! - lng;
const dist = dLat * dLat + dLon * dLon;
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
let totalDist = 0;
for (let i = 1; i <= bestIdx; i++) {
const prev = routeCoordinates[i - 1]!;
const curr = routeCoordinates[i]!;
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(curr[1]! - prev[1]!);
const dLon = toRad(curr[0]! - prev[0]!);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2;
totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
onRouteHover(totalDist);
},
[state],
);
return {
...state,
addWaypoint,
insertWaypointAtSegment,
handleRouteInsert,
moveWaypoint,
deleteWaypoint,
handleRoutePolylineHover,
};
}

View file

@ -9,7 +9,9 @@
"start": "node server.ts",
"typecheck": "react-router typegen && tsc",
"lint": "eslint .",
"test": "vitest run"
"test": "vitest run",
"test:visual": "vitest run --config vitest.browser.config.ts",
"test:visual:update": "vitest run --config vitest.browser.config.ts --update-snapshots"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
@ -22,9 +24,9 @@
"@sentry/node": "catalog:",
"@sentry/react": "catalog:",
"@trails-cool/db": "workspace:*",
"@trails-cool/jobs": "workspace:*",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/jobs": "workspace:*",
"@trails-cool/map": "workspace:*",
"@trails-cool/map-core": "workspace:*",
"@trails-cool/sentry-config": "workspace:*",
@ -52,6 +54,7 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.5",
"leaflet.markercluster": "^1.5.3",
"pino-pretty": "^13.1.3",
"tailwindcss": "catalog:",

View file

@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
// Separate config for Vitest browser visual regression tests.
// Run with: pnpm --filter @trails-cool/planner test:visual
// Update snapshots: pnpm --filter @trails-cool/planner test:visual:update
//
// Snapshots live next to each test file in a __screenshots__/ directory.
// On CI, snapshots are updated via the "Update visual snapshots" workflow
// (.github/workflows/update-visual-snapshots.yml), triggered manually or
// by adding the `update-snapshots` label to a PR.
export default defineConfig({
esbuild: {
jsx: "automatic",
},
resolve: {
alias: {
"~": resolve(import.meta.dirname, "app"),
},
},
test: {
name: "browser",
include: ["app/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
// Provider is resolved from whichever @vitest/browser peer is installed.
// playwright is the default when @playwright/test is available.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: "playwright" as any,
instances: [{ browser: "chromium" }],
},
},
});

View file

@ -12,5 +12,9 @@ export default mergeConfig(
"~": resolve(import.meta.dirname, "app"),
},
},
test: {
// Exclude browser visual regression tests — they run via vitest.browser.config.ts
exclude: ["**/*.browser.test.{ts,tsx}"],
},
}),
);

323
pnpm-lock.yaml generated
View file

@ -151,13 +151,13 @@ importers:
version: 8.2.1
jsdom:
specifier: ^29.1.1
version: 29.1.1
version: 29.1.1(canvas@3.2.3)
leaflet:
specifier: ^1.9.4
version: 1.9.4
linkedom:
specifier: ^0.18.12
version: 0.18.12
version: 0.18.12(canvas@3.2.3)
playwright:
specifier: ^1.59.1
version: 1.59.1
@ -196,7 +196,7 @@ importers:
version: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)
vitest:
specifier: ^4.1.5
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
apps/journal:
dependencies:
@ -441,7 +441,7 @@ importers:
version: 19.2.14
jest-expo:
specifier: ^55.0.17
version: 55.0.17(@babel/core@7.29.0)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.2))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
version: 55.0.17(@babel/core@7.29.0)(canvas@3.2.3)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.2))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-test-renderer:
specifier: ^19.2.6
version: 19.2.6(react@19.2.6)
@ -566,6 +566,9 @@ importers:
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitest/browser':
specifier: ^4.1.5
version: 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)))
leaflet.markercluster:
specifier: ^1.5.3
version: 1.5.3(leaflet@1.9.4)
@ -624,7 +627,7 @@ importers:
version: link:../types
linkedom:
specifier: ^0.18.12
version: 0.18.12
version: 0.18.12(canvas@3.2.3)
devDependencies:
'@types/node':
specifier: 'catalog:'
@ -1207,6 +1210,9 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@ -2543,6 +2549,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/core@1.28.4':
resolution: {integrity: sha512-wmtUYHYqA3zIAKDKvYWRNWAQsWOIBwxV08e+bWzVy0wQQzpaS/LzzRupXWRMRrLOk+1x3JKFxbqA3n0QGvpqsQ==}
@ -3927,6 +3936,11 @@ packages:
peerDependencies:
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitest/browser@4.1.5':
resolution: {integrity: sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==}
peerDependencies:
vitest: 4.1.5
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@ -4211,6 +4225,9 @@ packages:
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@1.20.5:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@ -4251,6 +4268,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@ -4285,6 +4305,10 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas@3.2.3:
resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
engines: {node: ^18.12.0 || >= 20.9.0}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@ -4320,6 +4344,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chrome-launcher@0.15.2:
resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==}
engines: {node: '>=12.13.0'}
@ -4549,6 +4576,10 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@ -4557,6 +4588,10 @@ packages:
babel-plugin-macros:
optional: true
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -4926,6 +4961,10 @@ packages:
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
engines: {node: '>= 0.8.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@ -5291,6 +5330,9 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -5350,6 +5392,9 @@ packages:
resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==}
engines: {node: '>=6'}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -5536,6 +5581,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@ -6239,6 +6287,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@ -6257,6 +6309,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@ -6269,6 +6324,10 @@ packages:
resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==}
engines: {node: '>= 0.8.0'}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@ -6287,6 +6346,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@ -6302,6 +6364,13 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-abi@3.92.0:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@ -6579,6 +6648,10 @@ packages:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
@ -6622,6 +6695,12 @@ packages:
rxjs:
optional: true
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -6733,6 +6812,10 @@ packages:
rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-devtools-core@6.1.5:
resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
@ -6893,6 +6976,10 @@ packages:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -7117,12 +7204,22 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-plist@1.3.1:
resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@ -7239,6 +7336,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@5.2.0:
resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
engines: {node: '>=6'}
@ -7263,6 +7363,10 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@ -7314,6 +7418,13 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
@ -7379,6 +7490,10 @@ packages:
toqr@0.1.1:
resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
@ -7419,6 +7534,9 @@ packages:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
turbo@2.9.12:
resolution: {integrity: sha512-lCPgus1NuTiBdaITWqzSH/Ff6HVL8HHGBtOXHg1dHRfcshN79XkygSdh0M6g8b0td91ILLG5MTkLOkp5UvyPJw==}
hasBin: true
@ -7551,6 +7669,9 @@ packages:
peerDependencies:
react: ^19.2.5
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@ -8552,6 +8673,8 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
'@blazediff/core@1.9.1': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@ -10058,6 +10181,8 @@ snapshots:
dependencies:
playwright: 1.59.1
'@polka/url@1.0.0-next.29': {}
'@posthog/core@1.28.4':
dependencies:
'@posthog/types': 1.372.10
@ -11616,6 +11741,23 @@ snapshots:
dependencies:
vite: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)
'@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)))':
dependencies:
'@blazediff/core': 1.9.1
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
'@vitest/utils': 4.1.5
magic-string: 0.30.21
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@ -11949,6 +12091,13 @@ snapshots:
bintrees@1.0.2: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
optional: true
body-parser@1.20.5:
dependencies:
bytes: 3.1.2
@ -12007,6 +12156,12 @@ snapshots:
buffer-from@1.1.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
optional: true
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@ -12034,6 +12189,12 @@ snapshots:
caniuse-lite@1.0.30001792: {}
canvas@3.2.3:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
optional: true
chai@6.2.2: {}
chalk@2.4.2:
@ -12064,6 +12225,9 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@1.1.4:
optional: true
chrome-launcher@0.15.2:
dependencies:
'@types/node': 25.6.2
@ -12296,8 +12460,16 @@ snapshots:
decode-uri-component@0.2.2: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
optional: true
dedent@1.7.2: {}
deep-extend@0.6.0:
optional: true
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
@ -12634,6 +12806,9 @@ snapshots:
exit@0.1.2: {}
expand-template@2.0.3:
optional: true
expect-type@1.3.0: {}
expect@29.7.0:
@ -13095,6 +13270,9 @@ snapshots:
fresh@0.5.2: {}
fs-constants@1.0.0:
optional: true
fs.realpath@1.0.0: {}
fsevents@2.3.2:
@ -13143,6 +13321,9 @@ snapshots:
getenv@2.0.0: {}
github-from-package@0.0.0:
optional: true
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -13337,6 +13518,9 @@ snapshots:
inherits@2.0.4: {}
ini@1.3.8:
optional: true
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@ -13533,7 +13717,7 @@ snapshots:
jest-util: 29.7.0
pretty-format: 29.7.0
jest-environment-jsdom@29.7.0:
jest-environment-jsdom@29.7.0(canvas@3.2.3):
dependencies:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
@ -13542,7 +13726,9 @@ snapshots:
'@types/node': 25.6.2
jest-mock: 29.7.0
jest-util: 29.7.0
jsdom: 20.0.3
jsdom: 20.0.3(canvas@3.2.3)
optionalDependencies:
canvas: 3.2.3
transitivePeerDependencies:
- bufferutil
- supports-color
@ -13557,7 +13743,7 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
jest-expo@55.0.17(@babel/core@7.29.0)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.2))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
jest-expo@55.0.17(@babel/core@7.29.0)(canvas@3.2.3)(expo@55.0.23)(jest@29.7.0(@types/node@25.6.2))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@expo/config': 55.0.16(typescript@5.9.3)
'@expo/json-file': 10.0.14
@ -13565,7 +13751,7 @@ snapshots:
'@jest/globals': 29.7.0
babel-jest: 29.7.0(@babel/core@7.29.0)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
jest-environment-jsdom: 29.7.0
jest-environment-jsdom: 29.7.0(canvas@3.2.3)
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.6.2))
@ -13829,7 +14015,7 @@ snapshots:
jsc-safe-url@0.2.4: {}
jsdom@20.0.3:
jsdom@20.0.3(canvas@3.2.3):
dependencies:
abab: 2.0.6
acorn: 8.16.0
@ -13857,12 +14043,14 @@ snapshots:
whatwg-url: 11.0.0
ws: 8.20.0
xml-name-validator: 4.0.0
optionalDependencies:
canvas: 3.2.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
jsdom@29.1.1:
jsdom@29.1.1(canvas@3.2.3):
dependencies:
'@asamuzakjp/css-color': 5.1.11
'@asamuzakjp/dom-selector': 7.1.1
@ -13885,6 +14073,8 @@ snapshots:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 3.2.3
transitivePeerDependencies:
- '@noble/hashes'
@ -13987,13 +14177,15 @@ snapshots:
lines-and-columns@1.2.4: {}
linkedom@0.18.12:
linkedom@0.18.12(canvas@3.2.3):
dependencies:
css-select: 5.2.2
cssom: 0.5.0
html-escaper: 3.0.3
htmlparser2: 10.1.0
uhyphen: 0.2.0
optionalDependencies:
canvas: 3.2.3
locate-path@5.0.0:
dependencies:
@ -14437,6 +14629,9 @@ snapshots:
mimic-function@5.0.1: {}
mimic-response@3.1.0:
optional: true
min-indent@1.0.1: {}
minimatch@10.2.5:
@ -14451,6 +14646,9 @@ snapshots:
minipass@7.1.3: {}
mkdirp-classic@0.5.3:
optional: true
mkdirp@1.0.4: {}
module-details-from-path@1.0.4: {}
@ -14465,6 +14663,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
mrmime@2.0.1: {}
ms@2.0.0: {}
ms@2.1.3: {}
@ -14475,6 +14675,9 @@ snapshots:
nanoid@3.3.12: {}
napi-build-utils@2.0.0:
optional: true
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@ -14483,6 +14686,14 @@ snapshots:
negotiator@1.0.0: {}
node-abi@3.92.0:
dependencies:
semver: 7.8.0
optional: true
node-addon-api@7.1.1:
optional: true
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@ -14772,6 +14983,8 @@ snapshots:
pngjs@3.4.0: {}
pngjs@7.0.0: {}
point-in-polygon-hao@1.2.4:
dependencies:
robust-predicates: 3.0.3
@ -14809,6 +15022,22 @@ snapshots:
dependencies:
'@posthog/core': 1.28.4
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.92.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
optional: true
prelude-ls@1.2.1: {}
prettier@3.8.3: {}
@ -14919,6 +15148,14 @@ snapshots:
dependencies:
quickselect: 2.0.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
optional: true
react-devtools-core@6.1.5:
dependencies:
shell-quote: 1.8.3
@ -15118,6 +15355,13 @@ snapshots:
react@19.2.6: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
readdirp@4.1.2: {}
real-require@0.2.0: {}
@ -15372,6 +15616,16 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1:
optional: true
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
optional: true
simple-plist@1.3.1:
dependencies:
bplist-creator: 0.1.0
@ -15382,6 +15636,12 @@ snapshots:
dependencies:
is-arrayish: 0.3.4
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
slash@3.0.0: {}
@ -15481,6 +15741,11 @@ snapshots:
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
optional: true
strip-ansi@5.2.0:
dependencies:
ansi-regex: 4.1.1
@ -15501,6 +15766,9 @@ snapshots:
dependencies:
min-indent: 1.0.1
strip-json-comments@2.0.1:
optional: true
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
@ -15540,6 +15808,23 @@ snapshots:
tapable@2.3.3: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
optional: true
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
optional: true
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
@ -15599,6 +15884,8 @@ snapshots:
toqr@0.1.1: {}
totalist@3.0.1: {}
tough-cookie@4.1.4:
dependencies:
psl: 1.15.0
@ -15639,6 +15926,11 @@ snapshots:
dependencies:
tslib: 1.14.1
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
turbo@2.9.12:
optionalDependencies:
'@turbo/darwin-64': 2.9.12
@ -15749,6 +16041,9 @@ snapshots:
dependencies:
react: 19.2.6
util-deprecate@1.0.2:
optional: true
utils-merge@1.0.1: {}
uuid@7.0.3: {}
@ -15830,7 +16125,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.4
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(jsdom@29.1.1(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))
@ -15855,7 +16150,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.6.2
jsdom: 29.1.1
jsdom: 29.1.1(canvas@3.2.3)
transitivePeerDependencies:
- msw