Files
TREK/client/src/components/Map/assistantViewport.ts
T
Maurice 3c3a228737 fix(assistant): lint, types and tests for the CI round
- agent loop and decisions without await in loops: steps as recursion,
  calls and applied changes in order through one promise chain, the
  independent reads and claims in parallel; part mapping in its own helpers
- place card, header and weather strip without refs or components created
  in render; long hooks and components split; state reset during render
- trip notes only in a turn's tool session, never an undo's; map view
  reported only while the assistant can run
- place strip scrolls through the strip's own smooth scroll, no physical side
- tests follow the raised limits and the new card fields; toasts read through
  a mocked useToast instead of a window global; baselines lowered where the
  counts fell
2026-10-21 22:29:58 +02:00

100 lines
3.5 KiB
TypeScript

import type { AssistantMapView } from '@trek/shared';
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
/**
* The part of the planner's map on screen, told to the assistant after every
* move, so a question about "here" needs no place named. Both renderers report
* through this module; the assistant sends it with the next question only for
* the trip it was taken on.
*/
interface Edges {
south: number;
west: number;
north: number;
east: number;
}
const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value));
const round = (value: number) => Math.round(value * 1e4) / 1e4;
/** The edges as the contract takes them: inside the globe (a wrapped map runs past ±180) and to about ten metres. */
export function mapViewOf(edges: Edges): AssistantMapView {
return {
south: round(clamp(edges.south, 90)),
west: round(clamp(edges.west, 180)),
north: round(clamp(edges.north, 90)),
east: round(clamp(edges.east, 180)),
};
}
function report(tripId: number | null, edges: Edges | null): void {
const set = useAssistantStore.getState().setMapView;
// A map without a size yet (hidden, or still laying out) has no edges to speak of.
const usable = edges && Object.values(edges).every(Number.isFinite) && edges.north > edges.south;
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
}
/** The trip whose view is reported: only while the assistant can run, so a map without it listens to nothing. */
function useReportedTrip(): number | null {
const available = useAssistantStore((s) => s.status?.available === true);
const tripId = useTripStore((s) => s.trip?.id ?? null);
return available ? tripId : null;
}
/** Leaflet: read from inside the map container, after each move and once on mount. */
export function useReportViewLeaflet(): void {
const map = useMap();
const tripId = useReportedTrip();
useEffect(() => {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
};
read();
map.on('moveend', read);
return () => {
map.off('moveend', read);
report(null, null);
};
}, [map, tripId]);
}
/** What the GL map offers for it; mapbox-gl and maplibre-gl alike. */
interface ViewMap {
getBounds: () => {
getSouth: () => number;
getWest: () => number;
getNorth: () => number;
getEast: () => number;
} | null;
on: (type: 'moveend', listener: () => void) => unknown;
off: (type: 'moveend', listener: () => void) => unknown;
}
/** GL: the same, once the map is ready. */
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
const tripId = useReportedTrip();
useEffect(() => {
const map = mapRef.current as Partial<ViewMap> | null;
if (tripId === null || !mapReady || !map?.on || !map.off || !map.getBounds) return;
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
};
read();
on('moveend', read);
return () => {
off('moveend', read);
report(null, null);
};
}, [mapRef, mapReady, tripId]);
}