Files
daily_console_web/src/components/TrailMap.tsx
abhishek 34bf7989f7 dispatch map, plan vs actual, and a fleet page
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
2026-09-09 17:02:21 +05:30

200 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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, '');
}