fix contect page
This commit is contained in:
264
src/components/map/OfficeMap.tsx
Normal file
264
src/components/map/OfficeMap.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"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.
|
||||
*
|
||||
* Loaded via a `ssr:false` dynamic import so Leaflet (which touches `window`)
|
||||
* never runs on the server and cannot cause hydration mismatches. Layout/spacing
|
||||
* is owned by the host container (see ContactMap).
|
||||
*/
|
||||
|
||||
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 {
|
||||
ESRI_WORLD_IMAGERY,
|
||||
MAP_FIT_MAX_ZOOM,
|
||||
MAP_FIT_PADDING,
|
||||
MAP_FOCUS_ZOOM,
|
||||
MAP_INITIAL_CENTER,
|
||||
MAP_INITIAL_ZOOM,
|
||||
OFFICE_LOCATIONS,
|
||||
type LatLng,
|
||||
} 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 the branded SVG pin once (module scope is fine — this file is client-only). */
|
||||
function createMarkerIcon(): L.DivIcon {
|
||||
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, -36],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative map effects that need the Leaflet instance:
|
||||
* - fit the viewport to every marker (with edge padding)
|
||||
* - keep that framing correct across resizes / lazy reveals
|
||||
* - fly to a single office when one is selected via the buttons
|
||||
*/
|
||||
function MapController({
|
||||
positions,
|
||||
focus,
|
||||
markerRefs,
|
||||
}: {
|
||||
positions: LatLng[];
|
||||
focus: FocusTarget | null;
|
||||
markerRefs: React.RefObject<Record<string, L.Marker>>;
|
||||
}) {
|
||||
const map = useMap();
|
||||
|
||||
// Latest focus, read inside the (stable) resize handler without resubscribing.
|
||||
const focusRef = useRef(focus);
|
||||
useEffect(() => {
|
||||
focusRef.current = focus;
|
||||
}, [focus]);
|
||||
|
||||
const fitAll = useCallback(() => {
|
||||
if (positions.length === 0) return;
|
||||
map.invalidateSize();
|
||||
map.fitBounds(L.latLngBounds(positions), {
|
||||
padding: MAP_FIT_PADDING,
|
||||
maxZoom: MAP_FIT_MAX_ZOOM,
|
||||
});
|
||||
}, [map, positions]);
|
||||
|
||||
// Initial fit, after the container has its final size.
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(fitAll);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [fitAll]);
|
||||
|
||||
// Re-measure on container resize; only re-frame all markers when nothing is
|
||||
// focused, so resizing while zoomed into one office doesn't jump the view away.
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
let raf = 0;
|
||||
const observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
map.invalidateSize();
|
||||
if (!focusRef.current) {
|
||||
map.fitBounds(L.latLngBounds(positions), {
|
||||
padding: MAP_FIT_PADDING,
|
||||
maxZoom: MAP_FIT_MAX_ZOOM,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [map, positions]);
|
||||
|
||||
// Fly to the selected office, then open its popup once movement settles.
|
||||
useEffect(() => {
|
||||
if (!focus) return;
|
||||
const office = OFFICE_LOCATIONS.find((item) => item.id === focus.id);
|
||||
if (!office) return;
|
||||
|
||||
map.flyTo(office.position, MAP_FOCUS_ZOOM, { duration: 1.1 });
|
||||
|
||||
const marker = markerRefs.current[office.id];
|
||||
if (!marker) return;
|
||||
const openPopup = () => marker.openPopup();
|
||||
map.once("moveend", openPopup);
|
||||
return () => {
|
||||
map.off("moveend", openPopup);
|
||||
};
|
||||
}, [map, focus, markerRefs]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OfficeMap() {
|
||||
const icon = useMemo(() => createMarkerIcon(), []);
|
||||
const positions = useMemo<LatLng[]>(
|
||||
() => OFFICE_LOCATIONS.map((office) => office.position),
|
||||
[],
|
||||
);
|
||||
const markerRefs = useRef<Record<string, L.Marker>>({});
|
||||
|
||||
const [focus, setFocus] = useState<FocusTarget | null>(null);
|
||||
const focusOffice = useCallback((id: string) => {
|
||||
setFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }));
|
||||
}, []);
|
||||
|
||||
const [status, setStatus] = useState<MapStatus>("loading");
|
||||
const loadedRef = useRef(false);
|
||||
const errorCountRef = useRef(0);
|
||||
|
||||
const handleTileLoad = useCallback(() => {
|
||||
loadedRef.current = true;
|
||||
setStatus("ready");
|
||||
}, []);
|
||||
|
||||
// Only surface an error if tiles never render (network/CORS/down). Once any
|
||||
// tile load succeeds the map is considered healthy and stays that way.
|
||||
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 : ""}`}
|
||||
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) => (
|
||||
<Marker
|
||||
key={office.id}
|
||||
position={office.position}
|
||||
icon={icon}
|
||||
keyboard
|
||||
title={office.name}
|
||||
alt={office.name}
|
||||
eventHandlers={{ click: () => focusOffice(office.id) }}
|
||||
ref={(instance) => {
|
||||
if (instance) markerRefs.current[office.id] = instance;
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<span className="office-popup__name">
|
||||
<span className="office-popup__dot" aria-hidden="true" />
|
||||
{office.name}
|
||||
</span>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
<MapController positions={positions} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user