diff --git a/.github/workflows/update-visual-snapshots.yml b/.github/workflows/update-visual-snapshots.yml new file mode 100644 index 0000000..0d45db9 --- /dev/null +++ b/.github/workflows/update-visual-snapshots.yml @@ -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 +# +# 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 diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 5d5e006..392f8cb 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -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([]); + const { points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes } = useElevationData(yjs.routeData); const [hoverIdx, setHoverIdx] = useState(null); - const [colorMode, setColorMode] = useState("plain"); - const [surfaces, setSurfaces] = useState([]); - const [highways, setHighways] = useState([]); - const [maxspeeds, setMaxspeeds] = useState([]); - const [smoothnesses, setSmoothnesses] = useState([]); - const [tracktypes, setTracktypes] = useState([]); - const [cycleways, setCycleways] = useState([]); - const [bikeroutes, setBikeroutes] = useState([]); const canvasRef = useRef(null); - const pointsRef = useRef([]); - pointsRef.current = points; const isExternalHover = useRef(false); const dragStartX = useRef(null); const dragStartClientX = useRef(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 = { 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) => { @@ -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) => { - 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) => { + 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) => { @@ -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) => { 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) => { 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) => { diff --git a/apps/planner/app/components/MapHelpers.tsx b/apps/planner/app/components/MapHelpers.tsx new file mode 100644 index 0000000..73753d4 --- /dev/null +++ b/apps/planner/app/components/MapHelpers.tsx @@ -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).__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; +}) { + 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 + >(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(); + 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]) => ( + + + + + ${cursor.name} + `, + 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(null); + + useEffect(() => { + if (ref.current) L.DomEvent.disableClickPropagation(ref.current); + }, []); + + return ( + + ); +} + +// 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 }) { + 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; +} diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index bb64215..25eceda 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -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>): 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).__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 }) { - 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>(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(); - 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]) => ( - - - - - ${cursor.name} - `, - iconSize: [0, 0], - })} - /> - ))} - - ); -} - -function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) { - const ref = useRef(null); - - // Prevent clicks from reaching the Leaflet map (same as built-in controls) - useEffect(() => { - if (ref.current) L.DomEvent.disableClickPropagation(ref.current); - }, []); - - return ( - - ); -} - -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 }) { - 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([]); const poiState = usePois(sessionId); useProfileDefaults(yjs, poiState); useYjsPoiSync(yjs, poiState); const [enabledOverlays, setEnabledOverlays] = useState([]); const [selectedBaseLayer, setSelectedBaseLayer] = useState(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([]); - const [surfaces, setSurfaces] = useState([]); - const [highways, setHighways] = useState([]); - const [maxspeeds, setMaxspeeds] = useState([]); - const [smoothnesses, setSmoothnesses] = useState([]); - const [tracktypes, setTracktypes] = useState([]); - const [cycleways, setCycleways] = useState([]); - const [bikeroutes, setBikeroutes] = useState([]); - const [colorMode, setColorMode] = useState("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 (
)} - - - {baseLayers.map((layer) => ( - - - - ))} - {overlayLayers.map((layer) => ( - - - - ))} - + + + {baseLayers.map((layer) => ( + + + + ))} + {overlayLayers.map((layer) => ( + + + + ))} + - - - - {} : addWaypoint} suppressRef={suppressMapClickRef} /> - - - - - - + + + + {} : addWaypoint} suppressRef={suppressMapClickRef} /> + + + + + + - {waypoints.map((wp, i) => ( - { - 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) => ( { 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 && ( - <> - - - {onRouteHover && ( - [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 ( + - )} - - )} + ); + })} - {highlightPosition && ( - ', - iconSize: [0, 0], - })} - /> - )} - + {routeCoordinates && routeCoordinates.length >= 2 && ( + <> + + + {onRouteHover && ( + [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 && ( + ', + iconSize: [0, 0], + })} + /> + )} + {contextMenu && ( ({ + 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 ( + { + 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(); + await expect(page.getByTestId("chart")).toMatchScreenshot("plain.png"); + }); + + it("renders grade color mode", async () => { + render(); + await expect(page.getByTestId("chart")).toMatchScreenshot("grade.png"); + }); + + it("renders elevation color mode", async () => { + render(); + 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(); + await expect(page.getByTestId("chart")).toMatchScreenshot("surface.png"); + }); + + it("renders hover crosshair at index 5", async () => { + render(); + await expect(page.getByTestId("chart")).toMatchScreenshot("hover.png"); + }); + + it("renders drag selection overlay", async () => { + render( + , + ); + await expect(page.getByTestId("chart")).toMatchScreenshot("drag-select.png"); + }); +}); diff --git a/apps/planner/app/lib/elevation-chart-draw.ts b/apps/planner/app/lib/elevation-chart-draw.ts new file mode 100644 index 0000000..e8c8867 --- /dev/null +++ b/apps/planner/app/lib/elevation-chart-draw.ts @@ -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 = { 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); + } +} diff --git a/apps/planner/app/lib/use-elevation-data.ts b/apps/planner/app/lib/use-elevation-data.ts new file mode 100644 index 0000000..9ad3e01 --- /dev/null +++ b/apps/planner/app/lib/use-elevation-data.ts @@ -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): ElevationData { + const [data, setData] = useState({ + 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; +} diff --git a/apps/planner/app/lib/use-gpx-drop.ts b/apps/planner/app/lib/use-gpx-drop.ts new file mode 100644 index 0000000..c0bcd8b --- /dev/null +++ b/apps/planner/app/lib/use-gpx-drop.ts @@ -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 }; +} diff --git a/apps/planner/app/lib/use-waypoint-manager.ts b/apps/planner/app/lib/use-waypoint-manager.ts new file mode 100644 index 0000000..085f4bb --- /dev/null +++ b/apps/planner/app/lib/use-waypoint-manager.ts @@ -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>): 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(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, + suppressMapClickRef: React.RefObject, + onRouteRequest?: (waypoints: WaypointData[]) => void, +) { + const [state, setState] = useState({ + 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(boundsJson), + surfaces: parseJsonArray(yjs.routeData.get("surfaces") as string | undefined), + highways: parseJsonArray(yjs.routeData.get("highways") as string | undefined), + maxspeeds: parseJsonArray(yjs.routeData.get("maxspeeds") as string | undefined), + smoothnesses: parseJsonArray(yjs.routeData.get("smoothnesses") as string | undefined), + tracktypes: parseJsonArray(yjs.routeData.get("tracktypes") as string | undefined), + cycleways: parseJsonArray(yjs.routeData.get("cycleways") as string | undefined), + bikeroutes: parseJsonArray(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, + }; +} diff --git a/apps/planner/package.json b/apps/planner/package.json index e889d3b..a2093e2 100644 --- a/apps/planner/package.json +++ b/apps/planner/package.json @@ -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:", diff --git a/apps/planner/vitest.browser.config.ts b/apps/planner/vitest.browser.config.ts new file mode 100644 index 0000000..06b8978 --- /dev/null +++ b/apps/planner/vitest.browser.config.ts @@ -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" }], + }, + }, +}); diff --git a/apps/planner/vitest.config.ts b/apps/planner/vitest.config.ts index cf58c77..3158f62 100644 --- a/apps/planner/vitest.config.ts +++ b/apps/planner/vitest.config.ts @@ -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}"], + }, }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c34b487..c3059ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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