304 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|