Files
doormile_react/src/components/map/OfficeMap.tsx

304 lines
10 KiB
TypeScript

"use client";
/**
* OfficeMap
* ---------------------------------------------------------------------------
* Client-only Leaflet satellite map (Esri World Imagery) rendering the three
* Doormile office markers, plus a row of "jump to office" buttons that fly the
* map to a selected office's coordinates and open its popup.
*
* The map centers with a North latitude offset to keep markers lower in the viewport,
* avoiding collisions with the navigation tabs. Popups are compact and styled like
* clean business cards.
*/
import "leaflet/dist/leaflet.css";
import styles from "./OfficeMap.module.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import L from "leaflet";
import { MapContainer, Marker, Popup, TileLayer, ZoomControl, useMap } from "react-leaflet";
import {
LatLng,
ESRI_WORLD_IMAGERY,
HQ_OFFICE,
MAP_FOCUS_ZOOM,
MAP_INITIAL_CENTER,
MAP_INITIAL_ZOOM,
OFFICE_LOCATIONS,
} from "./offices";
type MapStatus = "loading" | "ready" | "error";
/** A request to focus a specific office. `nonce` lets the same office be re-selected. */
type FocusTarget = { id: string; nonce: number };
/**
* Build a branded SVG pin. The headquarters pin is larger, carries a red glow
* and a soft pulse ring, and sits above every other marker.
*/
function createMarkerIcon(isHeadquarters: boolean): L.DivIcon {
if (isHeadquarters) {
return L.divIcon({
className: styles.markerIconHq,
html: `
<span class="${styles.pinPulse}" aria-hidden="true"></span>
<svg width="40" height="52" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M15 0C6.72 0 0 6.72 0 15c0 10.5 13.06 23.86 13.62 24.42a1.95 1.95 0 0 0 2.76 0C16.94 38.86 30 25.5 30 15 30 6.72 23.28 0 15 0Z" fill="#C01227"/>
<path d="M15 1.5C7.54 1.5 1.5 7.54 1.5 15c0 9.6 12.3 22.4 12.83 22.94a.95.95 0 0 0 1.34 0C16.2 37.4 28.5 24.6 28.5 15 28.5 7.54 22.46 1.5 15 1.5Z" fill="none" stroke="#ffffff" stroke-width="1.2" stroke-opacity="0.9"/>
<circle cx="15" cy="15" r="5.4" fill="#ffffff"/>
</svg>
`,
iconSize: [40, 52],
iconAnchor: [20, 52],
popupAnchor: [0, -52], // Anchored to top tip of pin
});
}
return L.divIcon({
className: styles.markerIcon,
html: `
<svg width="30" height="40" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M15 0C6.72 0 0 6.72 0 15c0 10.5 13.06 23.86 13.62 24.42a1.95 1.95 0 0 0 2.76 0C16.94 38.86 30 25.5 30 15 30 6.72 23.28 0 15 0Z" fill="#C01227"/>
<path d="M15 1.5C7.54 1.5 1.5 7.54 1.5 15c0 9.6 12.3 22.4 12.83 22.94a.95.95 0 0 0 1.34 0C16.2 37.4 28.5 24.6 28.5 15 28.5 7.54 22.46 1.5 15 1.5Z" fill="none" stroke="#ffffff" stroke-width="1.2" stroke-opacity="0.85"/>
<circle cx="15" cy="15" r="5.4" fill="#ffffff"/>
</svg>
`,
iconSize: [30, 40],
iconAnchor: [15, 40],
popupAnchor: [0, -40], // Anchored to top tip of pin
});
}
/**
* Imperative map effects that need the Leaflet instance:
* - keep the viewport sized correctly across resizes / lazy reveals
* - snap to the offset HQ on first paint, fly thereafter and open popups.
*/
function MapController({
focus,
markerRefs,
}: {
focus: FocusTarget | null;
markerRefs: React.RefObject<Record<string, L.Marker>>;
}) {
const map = useMap();
const didInit = useRef(false);
// Keep the map correctly sized on container resize / lazy reveal.
useEffect(() => {
const container = map.getContainer();
let raf = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => map.invalidateSize());
});
observer.observe(container);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
};
}, [map]);
// React to the focused office: snap on first paint, fly thereafter.
useEffect(() => {
if (!focus) return;
const office = OFFICE_LOCATIONS.find((item) => item.id === focus.id);
if (!office) return;
const openPopup = () => markerRefs.current[office.id]?.openPopup();
// Offset the center slightly North so the marker sits lower in the viewport,
// preventing the popup card from colliding with the top controls.
const offsetLatitude = 0.022;
const centeredPosition: LatLng = [office.position[0] + offsetLatitude, office.position[1]];
if (!didInit.current) {
didInit.current = true;
map.invalidateSize();
map.setView(centeredPosition, MAP_FOCUS_ZOOM, { animate: false });
const raf = requestAnimationFrame(openPopup);
return () => cancelAnimationFrame(raf);
}
map.flyTo(centeredPosition, MAP_FOCUS_ZOOM, { duration: 1.1 });
map.once("moveend", openPopup);
return () => {
map.off("moveend", openPopup);
};
}, [map, focus, markerRefs]);
return null;
}
export default function OfficeMap() {
const icon = useMemo(() => createMarkerIcon(false), []);
const hqIcon = useMemo(() => createMarkerIcon(true), []);
const markerRefs = useRef<Record<string, L.Marker>>({});
// Default to the headquarters.
const [focus, setFocus] = useState<FocusTarget | null>({ id: HQ_OFFICE.id, nonce: 0 });
const focusOffice = useCallback((id: string) => {
setFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }));
}, []);
const [hoveredOfficeId, setHoveredOfficeId] = useState<string | null>(null);
// Restore the focused office popup when mouse leaves other markers.
useEffect(() => {
if (hoveredOfficeId === null && focus?.id) {
markerRefs.current[focus.id]?.openPopup();
}
}, [hoveredOfficeId, focus]);
const [status, setStatus] = useState<MapStatus>("loading");
const loadedRef = useRef(false);
const errorCountRef = useRef(0);
const handleTileLoad = useCallback(() => {
loadedRef.current = true;
setStatus("ready");
}, []);
const handleTileError = useCallback(() => {
errorCountRef.current += 1;
if (!loadedRef.current && errorCountRef.current >= 6) setStatus("error");
}, []);
useEffect(() => {
const timeout = window.setTimeout(() => {
if (!loadedRef.current) setStatus("error");
}, 12_000);
return () => window.clearTimeout(timeout);
}, []);
return (
<div className={styles.root} role="region" aria-label="Map of Doormile office locations">
{/* Semantic, always-available fallback for assistive tech + no-JS/SEO. */}
<ul className={styles.srOnly}>
{OFFICE_LOCATIONS.map((office) => (
<li key={office.id}>
{office.name} latitude {office.position[0]}, longitude {office.position[1]}
</li>
))}
</ul>
{/* Jump-to-office navigation buttons. */}
<div className={styles.controls} role="group" aria-label="Jump to an office location">
{OFFICE_LOCATIONS.map((office) => {
const isActive = focus?.id === office.id;
return (
<button
key={office.id}
type="button"
className={`${styles.controlBtn} ${isActive ? styles.controlBtnActive : ""} ${
office.isHeadquarters ? styles.controlBtnHq : ""
}`}
aria-pressed={isActive}
aria-label={`Show ${office.name} on the map`}
onClick={() => focusOffice(office.id)}
>
{office.city}
</button>
);
})}
</div>
<MapContainer
className={styles.map}
center={MAP_INITIAL_CENTER}
zoom={MAP_INITIAL_ZOOM}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
worldCopyJump
>
{/* Keep zoom controls clear of the top-row buttons. */}
<ZoomControl position="bottomleft" />
<TileLayer
url={ESRI_WORLD_IMAGERY.url}
attribution={ESRI_WORLD_IMAGERY.attribution}
maxZoom={ESRI_WORLD_IMAGERY.maxZoom}
updateWhenIdle
keepBuffer={2}
eventHandlers={{ load: handleTileLoad, tileerror: handleTileError }}
/>
{OFFICE_LOCATIONS.map((office) => {
const isFocused = focus?.id === office.id;
return (
<Marker
key={office.id}
position={office.position}
icon={office.isHeadquarters ? hqIcon : icon}
zIndexOffset={office.isHeadquarters ? 1000 : 0}
keyboard
title={office.name}
alt={office.name}
eventHandlers={{
click: () => focusOffice(office.id),
mouseover: (event) => {
setHoveredOfficeId(office.id);
event.target.openPopup();
},
mouseout: (event) => {
setHoveredOfficeId(null);
if (focus?.id !== office.id) {
event.target.closePopup();
}
},
}}
ref={(instance) => {
if (instance) markerRefs.current[office.id] = instance;
}}
>
<Popup
className={styles.popup}
autoPan={isFocused}
autoPanPadding={[25, 25]}
closeButton={false}
minWidth={240}
maxWidth={290}
>
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardIcon} aria-hidden="true">📍</span>
<h4 className={styles.cardTitle}>{office.city} Office</h4>
</div>
<div className={styles.cardBody}>
{office.address.map((line, idx) => (
<p key={idx} className={styles.addressLine}>
{line}
</p>
))}
</div>
</div>
</Popup>
</Marker>
);
})}
<MapController focus={focus} markerRefs={markerRefs} />
</MapContainer>
{status === "error" && (
<div className={styles.errorOverlay} role="alert">
<p className={styles.errorTitle}>Map could not be loaded</p>
<p className={styles.errorText}>
Please check your connection. Our offices are located in:
</p>
<ul className={styles.errorList}>
{OFFICE_LOCATIONS.map((office) => (
<li key={office.id}>{office.name}</li>
))}
</ul>
</div>
)}
</div>
);
}