Add map overnight markers, day labels, and context menu

- Overnight waypoints render amber-brown (#8B6D3A) with moon icon
- Day boundary labels (white pill "Day N · X km") at overnight waypoints
- Right-click middle waypoints toggles overnight (first/last still delete)
- Wire days prop through SessionView → PlannerMap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 00:03:56 +02:00
parent a21bf16e73
commit dd8d72d7b3
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
3 changed files with 50 additions and 10 deletions

View file

@ -3,25 +3,43 @@ import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEve
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 } from "@trails-cool/map";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { isOvernight } from "~/lib/overnight";
import { setOvernight } from "~/lib/overnight";
import { NoGoAreaLayer } from "./NoGoAreaLayer";
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
import { RouteInteraction } from "./RouteInteraction";
import "leaflet/dist/leaflet.css";
function waypointIcon(index: number): L.DivIcon {
function waypointIcon(index: number, overnight?: boolean): L.DivIcon {
const bg = overnight ? "#8B6D3A" : "#2563eb";
return L.divIcon({
className: "",
html: `<div style="
width:24px;height:24px;border-radius:50%;
background:#2563eb;color:white;
background:${bg};color:white;
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-12px,-12px);
">${index + 1}</div>`,
">${overnight ? "" : index + 1}</div>`,
iconSize: [0, 0],
});
}
function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="
background:white;color:#1f2937;
padding:2px 8px;border-radius:12px;
font-size:11px;font-weight:600;white-space:nowrap;
box-shadow:0 1px 4px rgba(0,0,0,0.15);
transform:translate(-50%,-30px);
">Day ${dayNumber} · ${distanceKm} km</div>`,
iconSize: [0, 0],
});
}
@ -30,6 +48,7 @@ interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
@ -37,6 +56,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
@ -45,6 +65,7 @@ interface PlannerMapProps {
onRouteRequest?: (waypoints: WaypointData[]) => void;
onImportError?: (message: string) => void;
highlightPosition?: [number, number] | null;
days?: DayStage[];
}
function MapExposer() {
@ -206,7 +227,7 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
);
}
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) {
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError, days }: PlannerMapProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [draggingOver, setDraggingOver] = useState(false);
@ -442,7 +463,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
key={i}
position={[wp.lat, wp.lon]}
draggable
icon={waypointIcon(i)}
icon={waypointIcon(i, wp.overnight)}
eventHandlers={{
mouseover: () => {
routeInteractionSuspendedRef.current = true;
@ -465,12 +486,31 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
deleteWaypoint(i);
// Middle waypoints: toggle overnight. First/last: delete.
if (i > 0 && i < waypoints.length - 1) {
setOvernight(yjs, i, !wp.overnight);
} else {
deleteWaypoint(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 (
<Marker
key={`day-${day.dayNumber}`}
position={[wp.lat, wp.lon]}
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
interactive={false}
/>
);
})}
{routeCoordinates && routeCoordinates.length >= 2 && (
<>
<ColoredRoute