diff --git a/apps/admin/src/components/icons.tsx b/apps/admin/src/components/icons.tsx index 5a772ce..6de6a2d 100644 --- a/apps/admin/src/components/icons.tsx +++ b/apps/admin/src/components/icons.tsx @@ -1,4 +1,4 @@ -export interface IconProps { +interface IconProps { size?: number fill?: string stroke?: string @@ -50,7 +50,6 @@ export const Icons = { d="M12 22s-7-7.5-7-13a7 7 0 1 1 14 0c0 5.5-7 13-7 13z M12 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z" /> ), - Map: (p) => , Layers: (p) => ( ), @@ -85,15 +84,6 @@ export const Icons = { BarChart: (p) => ( ), - Settings: (p) => ( - - ), Inbox: (p) => ( , Plus: (p) => , - Bell: (p) => ( - - ), Check: (p) => , X: (p) => , ChevronRight: (p) => , ChevronLeft: (p) => , ChevronDown: (p) => , - ChevronUp: (p) => , ArrowUp: (p) => , ArrowDown: (p) => , ArrowRight: (p) => , @@ -149,7 +135,6 @@ export const Icons = { ), Lock: (p) => , - Bookmark: (p) => , FileText: (p) => ( ), - Phone: (p) => ( - - ), - Sidebar: (p) => , Trash: (p) => ( ( ), - Filter: (p) => , MoreH: (p) => , CornerArr: (p) => , Globe: (p) => ( diff --git a/apps/admin/src/components/map/boundary-map.tsx b/apps/admin/src/components/map/boundary-map.tsx index 24abd7d..9035e06 100644 --- a/apps/admin/src/components/map/boundary-map.tsx +++ b/apps/admin/src/components/map/boundary-map.tsx @@ -4,6 +4,7 @@ import * as React from "react" import L from "leaflet" import { withCartoKey } from "@/lib/carto" +import { MAP_SETTLE_MS } from "@/lib/timing" // Derived from Leaflet's own signature, so no direct @types/geojson dependency is needed. type LeafletGeoJson = Parameters[0] @@ -17,6 +18,12 @@ const TILE = { subdomains: "abcd", } +const TILE_MAX_ZOOM = 19 +// maxZoom caps how far the fit zooms in, so a tiny place still shows its surroundings. +const FIT_OPTIONS: L.FitBoundsOptions = { padding: [12, 12], maxZoom: 13 } +const SHAPE_WEIGHT = 2 +const SHAPE_FILL_OPACITY = 0.18 + const LAYER_COLOR: Record = { place: "#5B8C6E", county: "#3F7CAC", @@ -25,7 +32,7 @@ const LAYER_COLOR: Record = { tribal: "#B0593F", } -export interface BoundaryMapProps { +interface BoundaryMapProps { geometry: { type: string; coordinates: unknown } /** [west, south, east, north] */ bbox: [number, number, number, number] @@ -49,17 +56,17 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp L.tileLayer(TILE.url, { attribution: TILE.attribution, subdomains: TILE.subdomains, - maxZoom: 19, + maxZoom: TILE_MAX_ZOOM, detectRetina: true, }).addTo(map) - const ro = new ResizeObserver(() => map.invalidateSize()) - ro.observe(elRef.current) - const t0 = setTimeout(() => map.invalidateSize(), 60) + const resizeObserver = new ResizeObserver(() => map.invalidateSize()) + resizeObserver.observe(elRef.current) + const settleTimer = setTimeout(() => map.invalidateSize(), MAP_SETTLE_MS) return () => { - ro.disconnect() - clearTimeout(t0) + resizeObserver.disconnect() + clearTimeout(settleTimer) map.remove() mapRef.current = null shapeRef.current = null @@ -76,7 +83,7 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp const color = LAYER_COLOR[layer] ?? LAYER_COLOR.place // L.geoJSON accepts a bare GeoJSON geometry object. const shape = L.geoJSON(geometry as unknown as LeafletGeoJson, { - style: { color, weight: 2, fillColor: color, fillOpacity: 0.18 }, + style: { color, weight: SHAPE_WEIGHT, fillColor: color, fillOpacity: SHAPE_FILL_OPACITY }, }).addTo(map) shapeRef.current = shape @@ -87,7 +94,7 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp [south, west], [north, east], ], - { padding: [12, 12], maxZoom: 13 }, + FIT_OPTIONS, ) }, [geometry, bbox, layer]) diff --git a/apps/admin/src/components/map/leaflet-map.tsx b/apps/admin/src/components/map/leaflet-map.tsx index 94349c4..f75afed 100644 --- a/apps/admin/src/components/map/leaflet-map.tsx +++ b/apps/admin/src/components/map/leaflet-map.tsx @@ -6,6 +6,7 @@ import L from "leaflet" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { withCartoKey } from "@/lib/carto" import { CATEGORY_GLYPHS } from "@/lib/category" +import { MAP_SETTLE_MS } from "@/lib/timing" // Leaflet rather than community-web's MapLibre seam: a deliberate choice for this internal tool. // Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false. @@ -43,7 +44,25 @@ const MAP_TILES = { export type MapTint = keyof typeof MAP_TILES -export const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 } +const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 } +const BASEMAP_MAX_ZOOM = 20 + +function addBasemap(map: L.Map, tint: MapTint): L.TileLayer { + const tiles = MAP_TILES[tint] ?? MAP_TILES.voyager + return L.tileLayer(tiles.url, { + attribution: tiles.attribution, + subdomains: tiles.subdomains, + maxZoom: BASEMAP_MAX_ZOOM, + detectRetina: true, + }).addTo(map) +} + +const PIN_VIEWBOX_WIDTH = 64 +const PIN_VIEWBOX_HEIGHT = 76 +// Centers the 24-unit glyph (the icon set's grid) in the teardrop's head. +const PIN_GLYPH_OFFSET = "translate(20 16)" +const PIN_WIDTH = 31 +const PIN_WIDTH_ACTIVE = 40 // Report pins are gray once routed and red while they still need a routing contact; events take the // fill of their kind. @@ -77,21 +96,21 @@ function pinIcon( // constant own entry of GLYPHS may reach it (an inherited key like "constructor" would not). const glyph = Object.hasOwn(GLYPHS, glyphKey) ? GLYPHS[glyphKey]! : CATEGORY_GLYPHS.other const fill = PIN_FILL[state] ?? PIN_FILL.routed - const w = active ? 40 : 31 - const h = w * (76 / 64) + const width = active ? PIN_WIDTH_ACTIVE : PIN_WIDTH + const height = width * (PIN_VIEWBOX_HEIGHT / PIN_VIEWBOX_WIDTH) const html = - `
` + - `` + + `
` + + `` + `` + - `` + + `` + `` + `
` return L.divIcon({ html, className: "pi-pin2-wrap", - iconSize: [w, h], - iconAnchor: [w / 2, h], - popupAnchor: [0, -h], + iconSize: [width, height], + iconAnchor: [width / 2, height], + popupAnchor: [0, -height], }) } @@ -109,7 +128,13 @@ function tooltipNode(text: string): HTMLElement { return el } -const TOOLTIP_OPTIONS: L.TooltipOptions = { direction: "top", offset: [0, -30], className: "pi-map-tip" } +// Lifts the tooltip clear of the pin head, since the marker is anchored at the pin's tip. +const PIN_TOOLTIP_OFFSET: L.PointExpression = [0, -30] +const TOOLTIP_OPTIONS: L.TooltipOptions = { + direction: "top", + offset: PIN_TOOLTIP_OFFSET, + className: "pi-map-tip", +} // A keyboard marker is a role=button tab stop, and its divIcon has no text of its own to name it. function labelMarker(marker: L.Marker, text: string | null): void { @@ -125,6 +150,93 @@ function syncTooltip(marker: L.Marker, text: string | null): void { else marker.bindTooltip(tooltipNode(text), TOOLTIP_OPTIONS) } +// What each marker last rendered, so a sync skips the DivIcon rebuild and DOM teardown, the tooltip +// rebind and setLatLng when nothing visible changed. Without it one activeId change re-icons every +// marker instead of just the two whose active state flipped. +interface RenderedMarker { + key: string + text: string | null + lat: number + lng: number +} + +interface MarkerSync { + activeId: string | null + interactive: boolean + markers: Record + rendered: Record + latestPins: Record + onTap: (pin: MapPin) => void +} + +function createMarker( + map: L.Map, + pin: MapPin, + icon: L.DivIcon, + text: string | null, + sync: MarkerSync, +): L.Marker { + const marker = L.marker([pin.lat, pin.lng], { icon, riseOnHover: true, keyboard: sync.interactive }) + marker.addTo(map) + syncTooltip(marker, text) + if (sync.interactive) labelMarker(marker, text) + const tap = () => { + const latest = sync.latestPins[pin.id] + if (latest) sync.onTap(latest) + } + marker.on("click", tap) + marker.on("keydown", (e: L.LeafletKeyboardEvent) => { + if (!isKeyboardActivationKey(e.originalEvent.key)) return + e.originalEvent.preventDefault() + tap() + }) + return marker +} + +function updateMarker( + marker: L.Marker, + prev: RenderedMarker | undefined, + next: RenderedMarker, + icon: () => L.DivIcon, + interactive: boolean, +): void { + if (!prev || prev.key !== next.key) marker.setIcon(icon()) + if (!prev || prev.text !== next.text) { + syncTooltip(marker, next.text) + if (interactive) labelMarker(marker, next.text) + } + if (!prev || prev.lat !== next.lat || prev.lng !== next.lng) marker.setLatLng([next.lat, next.lng]) +} + +function syncMarkers(map: L.Map, pins: MapPin[], sync: MarkerSync): void { + const { markers, rendered, latestPins } = sync + const nextIds = new Set(pins.map((p) => p.id)) + for (const id of Object.keys(markers)) { + if (nextIds.has(id)) continue + markers[id]?.remove() + delete markers[id] + delete rendered[id] + delete latestPins[id] + } + + for (const p of pins) { + latestPins[p.id] = p + const active = String(p.id) === String(sync.activeId) + const next: RenderedMarker = { + // Everything pinIcon() depends on: an equal key means an identical DivIcon. + key: `${p.category}|${p.draft}|${p.kind}|${active}`, + text: tooltipText(p), + lat: p.lat, + lng: p.lng, + } + const icon = () => pinIcon(p.category, { active, draft: p.draft, kind: p.kind }) + const existing = markers[p.id] + if (existing) updateMarker(existing, rendered[p.id], next, icon, sync.interactive) + else markers[p.id] = createMarker(map, p, icon(), next.text, sync) + rendered[p.id] = next + } +} + export interface LeafletMapProps { pins?: MapPin[] center?: [number, number] @@ -148,12 +260,7 @@ export function LeafletMap({ const mapRef = React.useRef(null) const tileRef = React.useRef(null) const markersRef = React.useRef>({}) - // What each marker last rendered, so reconcile skips the DivIcon rebuild and DOM teardown, the tooltip - // rebind and setLatLng when nothing visible changed. Without it one activeId change re-icons every - // marker instead of just the two whose active state flipped. - const renderRef = React.useRef< - Record - >({}) + const renderRef = React.useRef>({}) // The latest pin per id: marker handlers are bound once, and a refetch replaces the pin objects. const pinsRef = React.useRef>({}) // Keep the latest onPinTap without re-running the create effect. @@ -177,25 +284,19 @@ export function LeafletMap({ }) mapRef.current = map - const t = MAP_TILES[tint] ?? MAP_TILES.voyager - tileRef.current = L.tileLayer(t.url, { - attribution: t.attribution, - subdomains: t.subdomains, - maxZoom: 20, - detectRetina: true, - }).addTo(map) + tileRef.current = addBasemap(map, tint) if (interactive) { L.control.zoom({ position: "topright" }).addTo(map) } - const ro = new ResizeObserver(() => map.invalidateSize()) - ro.observe(elRef.current) - const t0 = setTimeout(() => map.invalidateSize(), 60) + const resizeObserver = new ResizeObserver(() => map.invalidateSize()) + resizeObserver.observe(elRef.current) + const settleTimer = setTimeout(() => map.invalidateSize(), MAP_SETTLE_MS) return () => { - ro.disconnect() - clearTimeout(t0) + resizeObserver.disconnect() + clearTimeout(settleTimer) map.remove() mapRef.current = null markersRef.current = {} @@ -215,69 +316,19 @@ export function LeafletMap({ const map = mapRef.current if (!map) return if (tileRef.current) tileRef.current.remove() - const t = MAP_TILES[tint] ?? MAP_TILES.voyager - tileRef.current = L.tileLayer(t.url, { - attribution: t.attribution, - subdomains: t.subdomains, - maxZoom: 20, - detectRetina: true, - }).addTo(map) + tileRef.current = addBasemap(map, tint) }, [tint]) React.useEffect(() => { const map = mapRef.current if (!map) return - const next: Record = {} - pins.forEach((p) => { - next[p.id] = p - }) - - Object.keys(markersRef.current).forEach((id) => { - if (!next[id]) { - markersRef.current[id]?.remove() - delete markersRef.current[id] - delete renderRef.current[id] - delete pinsRef.current[id] - } - }) - - pins.forEach((p) => { - pinsRef.current[p.id] = p - const existing = markersRef.current[p.id] - const active = String(p.id) === String(activeId) - // Everything pinIcon() depends on: an equal key means an identical DivIcon. - const key = `${p.category}|${p.draft}|${p.kind}|${active}` - const text = tooltipText(p) - if (existing) { - const prev = renderRef.current[p.id] - if (!prev || prev.key !== key) { - existing.setIcon(pinIcon(p.category, { active, draft: p.draft, kind: p.kind })) - } - if (!prev || prev.text !== text) { - syncTooltip(existing, text) - if (interactive) labelMarker(existing, text) - } - if (!prev || prev.lat !== p.lat || prev.lng !== p.lng) { - existing.setLatLng([p.lat, p.lng]) - } - } else { - const icon = pinIcon(p.category, { active, draft: p.draft, kind: p.kind }) - const m = L.marker([p.lat, p.lng], { icon, riseOnHover: true, keyboard: interactive }).addTo(map) - syncTooltip(m, text) - if (interactive) labelMarker(m, text) - const tap = () => { - const latest = pinsRef.current[p.id] - if (latest) onPinTapRef.current?.(latest) - } - m.on("click", tap) - m.on("keydown", (e: L.LeafletKeyboardEvent) => { - if (!isKeyboardActivationKey(e.originalEvent.key)) return - e.originalEvent.preventDefault() - tap() - }) - markersRef.current[p.id] = m - } - renderRef.current[p.id] = { key, text, lat: p.lat, lng: p.lng } + syncMarkers(map, pins, { + activeId, + interactive, + markers: markersRef.current, + rendered: renderRef.current, + latestPins: pinsRef.current, + onTap: (pin) => onPinTapRef.current?.(pin), }) }, [pins, activeId, interactive]) diff --git a/apps/admin/src/components/map/live-map.tsx b/apps/admin/src/components/map/live-map.tsx index 990809a..fa34cb2 100644 --- a/apps/admin/src/components/map/live-map.tsx +++ b/apps/admin/src/components/map/live-map.tsx @@ -9,12 +9,7 @@ import { useHomeMap } from "@/hooks/use-admin-home" import { useNav } from "@/store/ui-store" import { categoryLabel } from "@/lib/category" import { EVENT_KIND_PIN_KIND, eventKindLabel } from "@/lib/event-kind" -import { - BUCKET_VIEW, - reportBucketOf, - reportNeedsAttention, - type ReportBucket, -} from "@/lib/report-status" +import { reportNeedsAttention, reportStatusView } from "@/lib/report-status" import type { MapPin, MapTint } from "@/components/map/leaflet-map" const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) => m.LeafletMap), { @@ -28,18 +23,19 @@ function pinTitle(p: HomeMapPin): string { return p.category ? `${categoryLabel(p.category)} report` : "Report" } -export function toMapPin(p: HomeMapPin): MapPin & { +interface LivePin extends MapPin { + tip: string refType: HomeMapPin["refType"] refId: string eventKind: NonNullable | null status: HomeMapPin["status"] flagged: boolean - title: string -} { +} + +function toMapPin(p: HomeMapPin): LivePin { const isEvent = p.refType === "event" const needs = !isEvent && reportNeedsAttention(p.status, p.flagged) const eventKind = isEvent ? (p.eventKind ?? "cleanup") : null - const title = pinTitle(p) return { id: `${p.refType}-${p.id}`, refType: p.refType, @@ -50,42 +46,107 @@ export function toMapPin(p: HomeMapPin): MapPin & { category: isEvent ? "event" : p.category, draft: needs, kind: eventKind ? EVENT_KIND_PIN_KIND[eventKind] : null, - tip: title, + tip: pinTitle(p), place: p.place, status: p.status, flagged: p.flagged, - title, } } -type ActivePin = ReturnType +function statusTone(pin: LivePin): { color: string; label: string } { + if (pin.eventKind === "other_volunteer") { + return { color: "var(--moss-700)", label: `${eventKindLabel(pin.eventKind)} event` } + } + if (pin.eventKind) return { color: "var(--sun-700)", label: `${eventKindLabel(pin.eventKind)} event` } + if (pin.flagged) return { color: "var(--bloom-700)", label: "Flagged" } + const view = reportStatusView(pin.status) + return { color: view.tone, label: view.label } +} -const BUCKET_TONE: Record = { - submitted: "var(--ink-2)", - in_progress: "var(--lilac-600)", - completed: "var(--moss-700)", - removed: "var(--bloom-700)", +function pinStateClass(pin: LivePin): string { + if (pin.refType === "event") return "is-event" + return pin.draft ? "is-needs" : "is-routed" } -export function statusTone(m: ActivePin): { color: string; label: string } { - if (m.eventKind === "other_volunteer") { - return { color: "var(--moss-700)", label: `${eventKindLabel(m.eventKind)} event` } - } - if (m.eventKind) return { color: "var(--sun-700)", label: `${eventKindLabel(m.eventKind)} event` } - if (m.flagged) return { color: "var(--bloom-700)", label: "Flagged" } - const bucket = reportBucketOf(m.status) - return { color: BUCKET_TONE[bucket], label: BUCKET_VIEW[bucket].label } +function ActivePinCard({ + pin, + onOpen, + onClose, +}: { + pin: LivePin + onOpen: () => void + onClose: () => void +}) { + const tone = statusTone(pin) + return ( +
+
+ +
+
+
{pin.tip}
+
+ {pin.place} + · + {tone.label} +
+
+ + +
+ ) +} + +function MapUnavailableCard({ onRetry }: { onRetry: () => void }) { + return ( +
+
+ +
+
+
Map unavailable
+
+ Could not load the live feed. +
+
+ +
+ ) +} + +function MapLegend() { + return ( +
+ + + Needs attention + + + + Handled + + + + Events + +
+ ) } export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) { const nav = useNav() - const q = useHomeMap() + const mapQuery = useHomeMap() const [activeId, setActiveId] = React.useState(null) - const pins = React.useMemo(() => q.data?.pins ?? [], [q.data]) + const pins = React.useMemo(() => mapQuery.data?.pins ?? [], [mapQuery.data]) const markers = React.useMemo(() => pins.map(toMapPin), [pins]) const active = markers.find((m) => m.id === activeId) ?? null - const tone = active ? statusTone(active) : null const reportCount = pins.filter((p) => p.refType === "report").length const eventCount = pins.filter((p) => p.refType === "event").length const needsAttention = pins.filter( @@ -117,68 +178,15 @@ export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) { activeId={active ? active.id : null} onPinTap={(p) => setActiveId(p.id)} /> -
- - - Needs attention - - - - Handled - - - - Events - -
+ - {q.isError && !pins.length && ( -
-
- -
-
-
Map unavailable
-
- Could not load the live feed. -
-
- -
+ {mapQuery.isError && !pins.length && ( + mapQuery.refetch()} /> )}
- {active && tone && ( -
-
- -
-
-
{active.title}
-
- {active.place} - · - {tone.label} -
-
- - -
+ {active && ( + setActiveId(null)} /> )}
diff --git a/apps/admin/src/components/providers.tsx b/apps/admin/src/components/providers.tsx index 218bd78..9a59c5e 100644 --- a/apps/admin/src/components/providers.tsx +++ b/apps/admin/src/components/providers.tsx @@ -5,6 +5,7 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query" import { makeQueryClient } from "@/lib/query" import { AuthHydrator } from "@/components/auth/auth-hydrator" +import { BrandBug } from "@/components/shared/brand-bug" import { ErrorBoundary } from "@/components/shell/error-boundary" import { OperatorLogin } from "@/features/auth/operator-login" import { useOperatorSession } from "@/hooks/use-admin-auth" @@ -47,9 +48,7 @@ function BootScreen({ label }: { label: string }) { return (
) } - -export function Skeleton({ - width = "100%", - height = 16, - radius = 8, - className = "", -}: { - width?: number | string - height?: number | string - radius?: number - className?: string -}) { - return ( -