Files
TREK/client/src/components/Tours/activity/useActivityCompareMap.ts
T
Maurice 6a0c54cb2f feat(tours): compare a tour's plan with the track it was actually done with (ACT-02)
Each actual track in a tour's detail now stands beside the plan instead of
on its own: a table names the plan and the actual track as columns, each with
the line it is drawn as on the map, and sets distance, ascent, descent and the
time with breaks side by side. The difference is shown, signed and with an
arrow, only where both values are known and mean the same thing; a planned
total that leaves a stay out reads "at least" and is not subtracted from. A
value one side does not carry is named as missing ("Not in the plan", "Not in
the track", "No time stamps"), never estimated. Below the table, how far the
track left the planned line: its farthest point and the recorded stretches
more than 50 m away, or that there is no line to compare.

On the desktop plan map, "Compare on map" draws the actual track dotted in
the colour of done next to the plan, which stays picked in its own track
colour (and is put back should it be hidden on the map). The recording leaves
the map as a tour of its own meanwhile, so it is never drawn twice or in a
plan's colour. A frosted legend over the map names both lines and ends the
comparison; so do closing the detail, comparing another track, or the track
being unlinked. Comparing only reads: neither the plan nor the track changes.

The track is laid into the day's route lines the map already draws, so both
renderers show it without a layer of their own. distancesToLine in the shared
route corridor measures many points against one line laid flat once; a long
recording is measured in at most 1500 samples.
2026-10-20 15:23:18 +02:00

93 lines
4.2 KiB
TypeScript

import { useMemo } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Place } from '../../../types';
import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry';
import { useMapPaint } from '../../Map/mapPaint';
import { resolveTrackColor } from '../../Map/trackColors';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/** The actual track's colour resolved for map paint; the map cannot read a CSS variable. */
const ACTUAL_PAINT = { line: '--success', casing: '--surface-card' } as const;
/** What the legend over the map names: the two lines, each as it is drawn. */
export interface ActivityCompareLegend {
planName: string;
planColor: string;
trackName: string;
}
interface CompareMapInput<P extends Place> {
/** False where the map shows something else (the road trip, the trip overview): nothing is compared there. */
enabled: boolean;
/** Every place of the trip, to find the plan and the recording in even when the map leaves them out. */
places: Place[];
/** The places the map draws. */
mapPlaces: P[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** The place whose detail is open: the comparison shows only beside its plan. */
selectedPlaceId: number | null;
}
/** The map's input as it is drawn: unchanged, or with the comparison laid in. */
interface CompareMapLines<P extends Place> {
places: (P | Place)[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** One entry per line of `route`; left out, the route keeps its own colour throughout. */
routeColors: LineColor[] | undefined;
legend: ActivityCompareLegend | null;
}
/**
* The plan map's places and lines while the comparison is on, and the
* legend's names. Pure, for the hook below and its tests.
*
* The plan is the tour's own track, picked on the map and drawn in its track
* colour; it is put back on the map should the person have hidden it there.
* The recording leaves the map as a tour of its own and comes back as one more
* line of the route, dotted in the colour of done, so it is drawn once and
* never in the colour of a plan. Off, everything stays exactly as it came.
*/
export function compareMapLines<P extends Place>(
input: CompareMapInput<P>,
comparison: { tourPlaceId: number; trackPlaceId: number } | null,
paint: { line: string; casing: string }
): CompareMapLines<P> {
const unchanged: CompareMapLines<P> = {
places: input.mapPlaces,
route: input.route,
routeWalking: input.routeWalking,
routeColors: undefined,
legend: null,
};
if (!input.enabled || !comparison || comparison.tourPlaceId !== input.selectedPlaceId) return unchanged;
const plan = input.places.find((place) => place.id === comparison.tourPlaceId);
const track = input.places.find((place) => place.id === comparison.trackPlaceId);
const line = parseRenderableRouteGeometry(track?.route_geometry);
if (!plan || !track || !line || line.length < 2) return unchanged;
const route = input.route ?? [];
const drawn = input.mapPlaces.filter((place) => place.id !== track.id);
return {
places: drawn.some((place) => place.id === plan.id) ? drawn : [...drawn, plan],
route: [...route, line.map(([lat, lng]): [number, number] => [lat, lng])],
// Aligned by index: the route's own flags may run short of its lines.
routeWalking: [...route.map((_, index) => input.routeWalking?.[index] === true), true],
routeColors: [...route.map((): LineColor => undefined), paint],
legend: { planName: plan.name, planColor: resolveTrackColor(plan), trackName: track.name },
};
}
/** ACT-02 on the plan map: `compareMapLines` with the comparison of the store and the theme's colours. */
export function useActivityCompareMap<P extends Place>(input: CompareMapInput<P>) {
const comparison = useTourActivityCompareStore((state) => state.comparison);
const paint = useMapPaint(ACTUAL_PAINT);
const { enabled, places, mapPlaces, route, routeWalking, selectedPlaceId } = input;
return useMemo(
() => compareMapLines({ enabled, places, mapPlaces, route, routeWalking, selectedPlaceId }, comparison, paint),
[comparison, enabled, mapPlaces, paint, places, route, routeWalking, selectedPlaceId]
);
}