WFDASH-WEBAPP/src/components/leaflet-markers.ts

142 lines
No EOL
7.4 KiB
TypeScript

/**
* Shared Leaflet marker factories.
*
* Used by `SupplierMap` and `CollectionMap`. Kept in a sibling file
* (rather than co-located in `geo.ts`) so importing this pulls in the
* Leaflet runtime — only do that from a `"use client"` page.
*/
import L from "leaflet";
/**
* Small circular pin. Used for both supplier coordinates and assigned
* stops. `label` renders inside the circle (empty string for blank).
*/
export function pinMarker(color: string, label: string) {
return L.divIcon({
className: "",
html: `<div style="display:grid;place-items:center;width:22px;height:22px;border-radius:999px;background:${color};color:white;border:2px solid white;box-shadow:0 6px 14px rgba(15,23,42,.24);font-size:9px;font-weight:800;">${label}</div>`,
iconSize: [22, 22],
iconAnchor: [11, 11],
});
}
/**
* Status-aware pin — same coordinate, four distinct shapes/colours so
* the operator can read the bucket state at a glance when the All filter
* is active.
*
* assigned → 22px filled circle in the driver's palette colour, white border.
* unassigned → 28px rotated-square "diamond", hollow centre with a dashed
* white inset ring, amber fill, amber outer shadow. The
* diamond is rotated via CSS transform so Leaflet's iconAnchor
* stays centered.
* collected → 24px filled emerald circle, white border (closed-state —
* the supplier handed goods over successfully).
* failed → 24px filled emerald circle, white border, plus a 3px red
* ring on the outside so the sub-state stays visible without
* overriding the green "collected" semantic.
*/
export function pinMarkerKind(
kind: "assigned" | "unassigned" | "collected" | "failed",
color: string,
label: string,
count?: number
) {
if (kind === "unassigned") {
const size = 28;
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${size}px;height:${size}px;transform:rotate(45deg);"><div style="position:absolute;inset:0;background:${color};border:2.5px solid white;border-radius:5px;box-shadow:0 8px 18px rgba(217,119,6,.42);"></div><div style="position:absolute;inset:4px;border:1.5px dashed rgba(255,255,255,.85);border-radius:2px;"></div><div style="position:absolute;inset:0;display:grid;place-items:center;transform:rotate(-45deg);color:#ffffff;font-size:10px;font-weight:900;letter-spacing:.3px;text-shadow:0 1px 2px rgba(0,0,0,.45);">${label}</div></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
if (kind === "collected" || kind === "failed") {
const size = 24;
const outerRing = kind === "failed"
? `<div style="position:absolute;inset:-3px;border:3px solid #DC2626;border-radius:999px;"></div>`
: "";
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${size}px;height:${size}px;">${outerRing}<div style="position:absolute;inset:0;background:${color};color:white;border:2.5px solid white;border-radius:999px;box-shadow:0 6px 14px rgba(15,23,42,.28);"></div><div style="position:absolute;inset:0;display:grid;place-items:center;color:#ffffff;font-size:9px;font-weight:800;text-shadow:0 1px 2px rgba(0,0,0,.45);">${label}</div></div>`,
iconSize: [size + (kind === "failed" ? 6 : 0), size + (kind === "failed" ? 6 : 0)],
iconAnchor: [(size + (kind === "failed" ? 6 : 0)) / 2, (size + (kind === "failed" ? 6 : 0)) / 2],
});
}
// assigned — preserve the original shape for back-compat
return pinMarker(color, label);
}
/**
* Square pin with the brand-yellow ring. Used exclusively for drivers
* to distinguish them from supplier/stop pins at a glance. The yellow
* ring is the PixelForge brand accent and reads on both light and dark
* tile layers.
*/
export function driverMarker(color: string, initials: string) {
return L.divIcon({
className: "",
html: `<div style="display:grid;place-items:center;width:28px;height:28px;border-radius:6px;background:${color};color:#0a0e1a;border:2.5px solid #FFE600;box-shadow:0 6px 14px rgba(15,23,42,.34);font-size:10px;font-weight:800;letter-spacing:0.5px;">${initials}</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
}
/**
* Round cluster bubble with a count. Scales up for larger clusters
* so 25+ doesn't render at the same size as 2.
*/
export function clusterBubble(color: string, count: number) {
const size = count >= 25 ? 52 : count >= 10 ? 46 : 38;
return L.divIcon({
className: "",
html: `<div style="display:grid;place-items:center;width:${size}px;height:${size}px;border-radius:999px;background:${color};color:white;border:3px solid white;box-shadow:0 12px 28px rgba(15,23,42,.32);font-size:14px;font-weight:900;">${count}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
/**
* Blue circle with the driver letter (A-Z, then 1-9) inside. Used by
* the new collection-map "one pin per driver per supplier" model. The
* count renders as a small round superscript in the top-right corner.
*
* Default colour is `DRIVER_PALETTE[1]` (#1B76BB) — the existing deep
* blue from src/lib/geo.ts — so the helper stays consistent with the
* rest of the app's driver signal even though the page no longer uses
* the palette for differentiating drivers (the letter does that now).
*/
export function driverLetterPin(
letter: string,
count: number,
color: string = "#1B76BB"
) {
const size = 28;
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${size}px;height:${size}px;"><div style="position:absolute;inset:0;display:grid;place-items:center;background:${color};color:white;border:2.5px solid white;border-radius:999px;box-shadow:0 6px 14px rgba(15,23,42,.28);font-size:13px;font-weight:800;letter-spacing:0.3px;text-shadow:0 1px 2px rgba(0,0,0,.45);">${letter}</div><div style="position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;padding:0 4px;display:grid;place-items:center;background:white;color:#1F2937;border-radius:999px;border:1.5px solid ${color};font-size:10px;font-weight:800;box-shadow:0 2px 6px rgba(15,23,42,.22);">${count}</div></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
/**
* Amber circle with ✓ or ✕ inside, for unassigned buckets on the
* collection map. Same size as the driver letter pin so they read as
* sibling shapes. The count renders as a white-bordered superscript
* in the top-right, mirroring the driver pin.
*/
export function unassignedPoolPin(
glyph: "ready" | "unready",
count: number
) {
const size = 28;
const amber = "#F59E0B";
const symbol = glyph === "ready" ? "✓" : "✕";
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${size}px;height:${size}px;"><div style="position:absolute;inset:0;display:grid;place-items:center;background:${amber};color:white;border:2.5px solid white;border-radius:999px;box-shadow:0 6px 14px rgba(217,119,6,.32);font-size:14px;font-weight:900;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.45);">${symbol}</div><div style="position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;padding:0 4px;display:grid;place-items:center;background:white;color:#1F2937;border-radius:999px;border:1.5px solid ${amber};font-size:10px;font-weight:800;box-shadow:0 2px 6px rgba(15,23,42,.22);">${count}</div></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}