Three of the features the old console had and ours did not, built on what the data can actually support rather than on what the column names imply. Two findings changed the shape of the work: `riderlogs` is not a GPS trail. Every ping a rider sends carries the SAME coordinate — one rider's 2,404 pings on 14 August all read 11.052998, 76.929958, and the same holds on every day and region checked. Distance, speed and "time moving" cannot come from it. The Fleet page therefore reports presence only: who was online and for how long, inferred from the gaps between check-ins, because `login`, `logout` and `workhours` are empty on all 320,132 August rows. It says out loud that it cannot tell a rider parked all day from one who crossed the city. The delivery ladder is not the order its columns are in. `starttime` is later than `arrivaltime` on 316 of 316 rows, which looks corrupt and is not: `starttime` is per-DROP, stamped when the rider sets off for that address having finished the last one. Read as assign -> arrive -> pickup -> start -> deliver, every duration is positive. On tenant 916 that shows the bottleneck is not the riding: a median 69.5 minutes passes between handing an order to a rider and that rider reaching the shop, against 0.6 minutes at the counter. - Map tab on dispatch, fed by `deliveries.riderslat/lon` — the only rider positions that move (353 distinct across 461 rows). Tenant-scoped, so a shop sees its own rounds. The line joins stops in worked order and says it is not a route. - Plan vs actual tab: promised against delivered, and a step breakdown of where the hours go. `actualkms` is excluded — it equals the planned `kms` to the decimal on every delivered row, so it is a copy, not a measurement. - Fleet page in the platform console: a presence gantt and a map of where each rider is registered. - `ridername` holds a delivery status on more rows than it holds a name for two riders in five, so names are resolved by excluding the status vocabulary first. - leaflet, wrapped directly rather than via react-leaflet, lazy-loaded so only the pages with a map pay for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
||
import L from 'leaflet';
|
||
import 'leaflet/dist/leaflet.css';
|
||
import './trailMap.css';
|
||
|
||
/**
|
||
* A map, for things that have a position.
|
||
*
|
||
* ── Why leaflet directly and not react-leaflet ──────────────────────────────
|
||
*
|
||
* A leaflet map is an imperative object that owns a DOM node and must be torn
|
||
* down by hand — `remove()`, or the tile layer keeps fetching and the container
|
||
* keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in
|
||
* components and adds a second package that has to track React's major version
|
||
* forever. The wrapping is about forty lines; it is written here instead.
|
||
*
|
||
* ── Why the pins are divIcons ───────────────────────────────────────────────
|
||
*
|
||
* Leaflet's default marker is a PNG resolved relative to the stylesheet, which
|
||
* every bundler rewrites and breaks — the classic "markers are invisible"
|
||
* bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is
|
||
* markup, so it ships with the bundle, takes the console's brand colour and
|
||
* needs no image at all.
|
||
*/
|
||
|
||
export interface MapTrail {
|
||
id: string | number;
|
||
label: string;
|
||
points: readonly { lat: number; lng: number }[];
|
||
colour: string;
|
||
}
|
||
|
||
export interface MapPin {
|
||
id: string | number;
|
||
lat: number;
|
||
lng: number;
|
||
label: string;
|
||
/** Shown under the label in the popup. Plain text, one line per entry. */
|
||
lines?: string[];
|
||
colour?: string;
|
||
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */
|
||
isFaded?: boolean;
|
||
}
|
||
|
||
/** Tamil Nadu, so an empty map still shows the right part of the world. */
|
||
const FALLBACK: L.LatLngExpression = [11.0168, 76.9558];
|
||
|
||
export function TrailMap({
|
||
trails = [],
|
||
pins = [],
|
||
height = 380,
|
||
emptyNote = 'Nothing to place on the map yet.',
|
||
}: {
|
||
trails?: readonly MapTrail[];
|
||
pins?: readonly MapPin[];
|
||
height?: number;
|
||
emptyNote?: string;
|
||
}) {
|
||
const host = useRef<HTMLDivElement | null>(null);
|
||
const map = useRef<L.Map | null>(null);
|
||
/* Everything drawn, kept together so a redraw clears exactly what it drew.
|
||
Clearing the map wholesale would take the tile layer with it. */
|
||
const drawn = useRef<L.LayerGroup | null>(null);
|
||
|
||
const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0;
|
||
|
||
useEffect(() => {
|
||
if (!host.current || map.current) return;
|
||
const instance = L.map(host.current, {
|
||
center: FALLBACK,
|
||
zoom: 12,
|
||
// The console scrolls; a wheel over the map should scroll the page, not
|
||
// zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people
|
||
// expect from a map embedded in a document.
|
||
scrollWheelZoom: false,
|
||
attributionControl: true,
|
||
});
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
attribution: '© OpenStreetMap contributors',
|
||
}).addTo(instance);
|
||
drawn.current = L.layerGroup().addTo(instance);
|
||
map.current = instance;
|
||
|
||
return () => {
|
||
instance.remove();
|
||
map.current = null;
|
||
drawn.current = null;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const instance = map.current;
|
||
const layer = drawn.current;
|
||
if (!instance || !layer) return;
|
||
|
||
layer.clearLayers();
|
||
const bounds = L.latLngBounds([]);
|
||
|
||
for (const trail of trails) {
|
||
if (trail.points.length < 2) continue;
|
||
const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]);
|
||
L.polyline(line, {
|
||
color: trail.colour,
|
||
weight: 3,
|
||
opacity: 0.85,
|
||
// Rounded joins, or a dense GPS trail draws spikes at every turn.
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
})
|
||
.bindTooltip(trail.label, { sticky: true })
|
||
.addTo(layer);
|
||
line.forEach((point) => bounds.extend(point));
|
||
}
|
||
|
||
for (const pin of pins) {
|
||
const colour = pin.colour ?? 'var(--color-brand)';
|
||
L.marker([pin.lat, pin.lng], {
|
||
title: pin.label,
|
||
icon: L.divIcon({
|
||
className: 'trail-pin-wrap',
|
||
html: `<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}" style="--pin:${escapeAttr(colour)}"></span>`,
|
||
iconSize: [16, 16],
|
||
iconAnchor: [8, 8],
|
||
}),
|
||
})
|
||
.bindPopup(
|
||
`<strong>${escapeHtml(pin.label)}</strong>` +
|
||
(pin.lines ?? []).map((line) => `<br>${escapeHtml(line)}`).join(''),
|
||
)
|
||
.addTo(layer);
|
||
bounds.extend([pin.lat, pin.lng]);
|
||
}
|
||
|
||
if (bounds.isValid()) {
|
||
// `maxZoom` matters: a single pin, or a rider who never left one street,
|
||
// otherwise zooms to building level and the map shows one grey rectangle
|
||
// with no landmarks to orient by.
|
||
instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 });
|
||
}
|
||
}, [trails, pins]);
|
||
|
||
/* Leaflet measures its container once, at construction. Inside a drawer or a
|
||
tab the container is often zero-height at that moment, and the map renders
|
||
as a grey strip with one tile in the corner until something resizes the
|
||
window. Re-measuring whenever the box changes size fixes it for good,
|
||
including when the drawer animates open. */
|
||
useEffect(() => {
|
||
const node = host.current;
|
||
const instance = map.current;
|
||
if (!node || !instance || typeof ResizeObserver === 'undefined') return;
|
||
const observer = new ResizeObserver(() => instance.invalidateSize());
|
||
observer.observe(node);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
return (
|
||
<div className="trail-map" style={{ height }}>
|
||
<div ref={host} className="trail-map-canvas" />
|
||
{isEmpty ? <div className="trail-map-empty">{emptyNote}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Distinct, legible line colours.
|
||
*
|
||
* Hand-picked rather than generated from a hue wheel: evenly spaced hues put
|
||
* two yellows next to each other on an OSM tile and both vanish. These are all
|
||
* dark enough to read over map detail and different enough to tell apart at the
|
||
* width of a polyline.
|
||
*/
|
||
export const TRAIL_COLOURS = [
|
||
'#662582',
|
||
'#0f8a5f',
|
||
'#c2410c',
|
||
'#1d4ed8',
|
||
'#b91c1c',
|
||
'#0e7490',
|
||
'#7c2d12',
|
||
'#4d7c0f',
|
||
] as const;
|
||
|
||
export function trailColour(index: number): string {
|
||
return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string;
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
/** Popup and icon HTML is a string, so a rider named `<b>` must not become markup. */
|
||
function escapeAttr(value: string): string {
|
||
return value.replace(/["'<>]/g, '');
|
||
}
|