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:
@@ -19,8 +19,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.40.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -28,6 +30,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Generated
+48
@@ -20,12 +20,18 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^12.40.0
|
||||
version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
react:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.7
|
||||
react-dom:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.7(react@19.2.7)
|
||||
react-leaflet:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
reactflow:
|
||||
specifier: ^11.11.4
|
||||
version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -42,6 +48,9 @@ importers:
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@types/leaflet':
|
||||
specifier: ^1.9.21
|
||||
version: 1.9.21
|
||||
'@types/node':
|
||||
specifier: ^24.12.3
|
||||
version: 24.13.2
|
||||
@@ -492,6 +501,13 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@react-leaflet/core@2.1.0':
|
||||
resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==}
|
||||
peerDependencies:
|
||||
leaflet: ^1.9.0
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
|
||||
'@reactflow/background@11.3.14':
|
||||
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
|
||||
peerDependencies:
|
||||
@@ -772,6 +788,9 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/leaflet@1.9.21':
|
||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
|
||||
@@ -1297,6 +1316,9 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
leaflet@1.9.4:
|
||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1499,6 +1521,13 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-leaflet@4.2.1:
|
||||
resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==}
|
||||
peerDependencies:
|
||||
leaflet: ^1.9.0
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2270,6 +2299,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
leaflet: 1.9.4
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@reactflow/background@11.3.14(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@reactflow/core': 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -2572,6 +2607,10 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/leaflet@1.9.21':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/node@24.13.2':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
@@ -3118,6 +3157,8 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -3276,6 +3317,13 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
leaflet: 1.9.4
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
@@ -11,6 +11,7 @@ import SsoCallback from "./scenes/SsoCallback";
|
||||
import Chat from "./scenes/Chat";
|
||||
import Agent from "./scenes/Agent";
|
||||
import Hub from "./scenes/Hub";
|
||||
import GeoAttendance from "./scenes/GeoAttendance";
|
||||
import CommandBar from "./components/CommandBar";
|
||||
import Toaster from "./components/Toaster";
|
||||
import Console from "./components/Console";
|
||||
@@ -163,6 +164,7 @@ export default function App() {
|
||||
{scene === "hub-procurement" && <Hub hub="procurement" />}
|
||||
{scene === "hub-hr" && <Hub hub="hr" />}
|
||||
{scene === "hub-it" && <Hub hub="it" />}
|
||||
{scene === "geo-attendance" && <GeoAttendance />}
|
||||
{scene === "settings" && <Settings />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1959,3 +1959,24 @@ select.studio-input { background: var(--bp-paper); }
|
||||
.hero-hub-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.hub-chip { padding: 8px 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); color: var(--bp-navy); font-family: var(--bp-mono); font-size: 11px; letter-spacing: 0.04em; cursor: pointer; transition: background 0.1s; }
|
||||
.hub-chip:hover { background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper)); }
|
||||
|
||||
.geo-scene { padding: 24px; max-width: 1400px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
|
||||
.geo-head { margin-bottom: 20px; }
|
||||
.geo-intro { font-size: 13px; color: var(--bp-muted); max-width: 720px; line-height: 1.5; margin-top: 6px; }
|
||||
.geo-stats { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.geo-stat { font-family: var(--bp-mono); font-size: 11px; padding: 4px 10px; border: 1px solid var(--bp-navy); }
|
||||
.geo-stat-ok { background: color-mix(in srgb, #4a8a3f 25%, var(--bp-paper)); }
|
||||
.geo-stat-late { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); }
|
||||
.geo-stat-off { color: var(--bp-muted); }
|
||||
.geo-filter-row { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.hub-chip.active { background: var(--bp-amber); color: var(--bp-paper); }
|
||||
.geo-map-wrap { border: 1px solid var(--bp-navy); height: 560px; overflow: hidden; }
|
||||
.geo-map { width: 100%; height: 100%; }
|
||||
.geo-popup-tag { font-family: var(--bp-mono); font-size: 10px; padding: 2px 6px; border: 1px solid currentColor; display: inline-block; margin: 2px 0; }
|
||||
.geo-popup-checked_in { color: #2e7a25; }
|
||||
.geo-popup-late { color: #c08020; }
|
||||
.geo-popup-checked_out { color: #777; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.geo-map-wrap { height: 380px; }
|
||||
}
|
||||
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export default function Landing() {
|
||||
<button className="hub-chip" onClick={() => setScene("hub-procurement")}>Procurement Hub</button>
|
||||
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
|
||||
<button className="hub-chip" onClick={() => setScene("hub-it")}>IT Hub</button>
|
||||
<button className="hub-chip" onClick={() => setScene("geo-attendance")}>Attendance Map</button>
|
||||
<button className="hub-chip" onClick={() => setScene("agent")}>Talk to Pi</button>
|
||||
<button className="hub-chip" onClick={() => setScene("chat")}>Team Chat</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user