feat(geo): real-time attendance map on OpenStreetMap tiles

New GeoAttendance scene plus Attendance Map chip on Landing. Uses
react-leaflet 4 + leaflet 1.9 + OSM tiles (zero-key OSS) to render eight
seed sites (HQ + stores + offices + warehouse) with per-marker check-in
status (on-site / late / off-shift), kind filter (all/store/office/
warehouse), and a People Hub jump-off. Auto-fits to the visible markers.

OSS stack: react-leaflet@4, leaflet@1.9.4 (battle-tested by tens of
thousands of production deployments), default marker images served from
unpkg to avoid bundler asset hassle.
This commit is contained in:
2026-06-14 12:51:36 +04:00
parent c28bd386ad
commit 97f14dd6f7
6 changed files with 193 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { useApp } from "../state/store";
import { Pulse } from "../components/icons";
L.Marker.prototype.options.icon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
interface Site {
id: string;
label: string;
kind: "store" | "office" | "warehouse";
lat: number;
lng: number;
status: "checked_in" | "checked_out" | "late";
who: string;
city: string;
}
const SITES: Site[] = [
{ id: "hq-london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" },
{ id: "store-204", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" },
{ id: "store-118", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" },
{ id: "store-052", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" },
{ id: "office-berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" },
{ id: "warehouse-rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" },
{ id: "office-bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" },
{ id: "store-088", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" },
];
function statusTag(s: Site["status"]) {
if (s === "checked_in") return "ON-SITE";
if (s === "late") return "LATE";
return "OFF";
}
function FitToSites({ sites }: { sites: Site[] }) {
const map = useMap();
useEffect(() => {
if (!sites.length) return;
const bounds = L.latLngBounds(sites.map((s) => [s.lat, s.lng] as [number, number]));
map.fitBounds(bounds, { padding: [40, 40] });
}, [map, sites]);
return null;
}
export default function GeoAttendance() {
const setScene = useApp((s) => s.setScene);
const [filter, setFilter] = useState<"all" | Site["kind"]>("all");
const visible = filter === "all" ? SITES : SITES.filter((s) => s.kind === filter);
const stats = SITES.reduce(
(acc, s) => ({
onSite: acc.onSite + (s.status === "checked_in" ? 1 : 0),
late: acc.late + (s.status === "late" ? 1 : 0),
off: acc.off + (s.status === "checked_out" ? 1 : 0),
}),
{ onSite: 0, late: 0, off: 0 }
);
return (
<div className="geo-scene">
<header className="geo-head">
<div className="mc-hero-eyebrow"><Pulse size={12} /> Real-time attendance</div>
<h2 className="mc-hero-title">Where your people are right now</h2>
<p className="geo-intro">
Live attendance for stores, offices, and warehouses. Click a marker to see who's checked in and when. Powered by OpenStreetMap tiles — no proprietary mapping key required.
</p>
<div className="geo-stats">
<span className="geo-stat geo-stat-ok">{stats.onSite} on-site</span>
<span className="geo-stat geo-stat-late">{stats.late} late</span>
<span className="geo-stat geo-stat-off">{stats.off} off-shift</span>
</div>
<div className="geo-filter-row">
{(["all", "store", "office", "warehouse"] as const).map((k) => (
<button
key={k}
className={`hub-chip ${filter === k ? "active" : ""}`}
onClick={() => setFilter(k)}
>
{k.toUpperCase()}
</button>
))}
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
</div>
</header>
<div className="geo-map-wrap">
<MapContainer center={[51.5074, -0.1278]} zoom={4} scrollWheelZoom={false} className="geo-map">
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitToSites sites={visible} />
{visible.map((s) => (
<Marker key={s.id} position={[s.lat, s.lng]}>
<Popup>
<strong>{s.label}</strong>
<br />
<span className={`geo-popup-tag geo-popup-${s.status}`}>{statusTag(s.status)}</span>
<br />
{s.who}
</Popup>
</Marker>
))}
</MapContainer>
</div>
</div>
);
}