update how it works card update
This commit is contained in:
@@ -85,6 +85,15 @@
|
||||
box-shadow: 0 6px 18px rgba(192, 18, 39, 0.45);
|
||||
}
|
||||
|
||||
/* Headquarters button — subtly elevated so it reads as the primary location. */
|
||||
.controlBtnHq {
|
||||
border-color: rgba(192, 18, 39, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(192, 18, 39, 0.25), 0 4px 14px rgba(192, 18, 39, 0.2);
|
||||
}
|
||||
.controlBtnHq.controlBtnActive {
|
||||
box-shadow: 0 6px 20px rgba(192, 18, 39, 0.55);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.controls {
|
||||
top: 12px;
|
||||
@@ -112,6 +121,53 @@
|
||||
transform: translateY(-3px) scale(1.06);
|
||||
}
|
||||
|
||||
/* ---- Headquarters pin — larger, glowing, pulsing, always on top ---- */
|
||||
.markerIconHq {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
/* red glow + grounding shadow */
|
||||
filter: drop-shadow(0 0 9px rgba(192, 18, 39, 0.85))
|
||||
drop-shadow(0 5px 7px rgba(0, 0, 0, 0.55));
|
||||
transition: transform 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.markerIconHq svg {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.markerIconHq:hover,
|
||||
.markerIconHq:focus-visible {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
}
|
||||
|
||||
/* Soft expanding ring radiating from the HQ pin head. */
|
||||
.pinPulse {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
left: 20px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 50%;
|
||||
background: rgba(192, 18, 39, 0.5);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
animation: hqPulse 2.2s ease-out infinite;
|
||||
}
|
||||
@keyframes hqPulse {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.75;
|
||||
}
|
||||
70% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Themed Leaflet internals (scoped to this map only) ---- */
|
||||
.root :global(.leaflet-container) {
|
||||
background: #0b0b0b;
|
||||
@@ -158,34 +214,36 @@
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.root :global(.leaflet-popup-content-wrapper) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.root :global(.leaflet-popup-content) {
|
||||
margin: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.root :global(.leaflet-popup-tip) {
|
||||
background: #141416;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.root :global(.leaflet-popup-close-button) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
/* ---- Compact, Google-Maps-style tooltip content ---- */
|
||||
.tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
text-align: left;
|
||||
font-family: var(--font-manrope), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.root :global(.leaflet-popup-close-button:hover) {
|
||||
.tipTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: #ffffff;
|
||||
}
|
||||
.root :global(.leaflet-popup-content .office-popup__name) {
|
||||
display: block;
|
||||
}
|
||||
.root :global(.leaflet-popup-content .office-popup__dot) {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 8px;
|
||||
border-radius: 50%;
|
||||
background: #c01227;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Loading skeleton (prevents CLS — fills the fixed-height host) ---- */
|
||||
@@ -210,7 +268,9 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton { animation: none; }
|
||||
.markerIcon { transition: none; }
|
||||
.markerIcon,
|
||||
.markerIconHq { transition: none; }
|
||||
.pinPulse { animation: none; opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ---- Graceful error fallback ---- */
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
* 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 experience opens focused on the Hyderabad headquarters — the largest,
|
||||
* glowing, pulsing marker — with its popup open by default, so the command
|
||||
* centre of the network is immediately legible. Hovering any marker opens its
|
||||
* popup; leaving closes it. Clicking a marker or a nav button flies to it.
|
||||
*
|
||||
* 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).
|
||||
@@ -21,13 +26,11 @@ import { MapContainer, Marker, Popup, TileLayer, ZoomControl, useMap } from "rea
|
||||
|
||||
import {
|
||||
ESRI_WORLD_IMAGERY,
|
||||
MAP_FIT_MAX_ZOOM,
|
||||
MAP_FIT_PADDING,
|
||||
HQ_OFFICE,
|
||||
MAP_FOCUS_ZOOM,
|
||||
MAP_INITIAL_CENTER,
|
||||
MAP_INITIAL_ZOOM,
|
||||
OFFICE_LOCATIONS,
|
||||
type LatLng,
|
||||
} from "./offices";
|
||||
|
||||
type MapStatus = "loading" | "ready" | "error";
|
||||
@@ -35,8 +38,29 @@ 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 {
|
||||
/**
|
||||
* 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.
|
||||
* (Module-scope-safe construction — this file is client-only.)
|
||||
*/
|
||||
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, -46],
|
||||
});
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
className: styles.markerIcon,
|
||||
html: `
|
||||
@@ -54,77 +78,54 @@ function createMarkerIcon(): L.DivIcon {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - keep the viewport sized correctly across resizes / lazy reveals
|
||||
* - on first paint, snap to the HQ and open its popup (no jarring long fly)
|
||||
* - 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();
|
||||
const didInit = useRef(false);
|
||||
|
||||
// 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.
|
||||
// Keep the map correctly sized on container resize / lazy reveal. We never
|
||||
// re-frame the view here, so resizing keeps whatever office is in focus.
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
raf = requestAnimationFrame(() => map.invalidateSize());
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [map, positions]);
|
||||
}, [map]);
|
||||
|
||||
// Fly to the selected office, then open its popup once movement settles.
|
||||
// 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;
|
||||
|
||||
map.flyTo(office.position, MAP_FOCUS_ZOOM, { duration: 1.1 });
|
||||
const openPopup = () => markerRefs.current[office.id]?.openPopup();
|
||||
|
||||
const marker = markerRefs.current[office.id];
|
||||
if (!marker) return;
|
||||
const openPopup = () => marker.openPopup();
|
||||
if (!didInit.current) {
|
||||
didInit.current = true;
|
||||
map.invalidateSize();
|
||||
map.setView(office.position, MAP_FOCUS_ZOOM, { animate: false });
|
||||
// Open the HQ popup once the marker has mounted on this frame.
|
||||
const raf = requestAnimationFrame(openPopup);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
map.flyTo(office.position, MAP_FOCUS_ZOOM, { duration: 1.1 });
|
||||
map.once("moveend", openPopup);
|
||||
return () => {
|
||||
map.off("moveend", openPopup);
|
||||
@@ -135,14 +136,12 @@ function MapController({
|
||||
}
|
||||
|
||||
export default function OfficeMap() {
|
||||
const icon = useMemo(() => createMarkerIcon(), []);
|
||||
const positions = useMemo<LatLng[]>(
|
||||
() => OFFICE_LOCATIONS.map((office) => office.position),
|
||||
[],
|
||||
);
|
||||
const icon = useMemo(() => createMarkerIcon(false), []);
|
||||
const hqIcon = useMemo(() => createMarkerIcon(true), []);
|
||||
const markerRefs = useRef<Record<string, L.Marker>>({});
|
||||
|
||||
const [focus, setFocus] = useState<FocusTarget | null>(null);
|
||||
// Default to the headquarters so its button reads active and its popup opens.
|
||||
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 }));
|
||||
}, []);
|
||||
@@ -189,7 +188,9 @@ export default function OfficeMap() {
|
||||
<button
|
||||
key={office.id}
|
||||
type="button"
|
||||
className={`${styles.controlBtn} ${isActive ? styles.controlBtnActive : ""}`}
|
||||
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)}
|
||||
@@ -221,29 +222,46 @@ export default function OfficeMap() {
|
||||
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>
|
||||
))}
|
||||
{OFFICE_LOCATIONS.map((office) => {
|
||||
// Google-Maps-style tooltip: hovering shows just the location name;
|
||||
// clicking focuses the office.
|
||||
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),
|
||||
// Hover opens the compact popup without moving the map.
|
||||
mouseover: (event) => event.target.openPopup(),
|
||||
mouseout: (event) => event.target.closePopup(),
|
||||
}}
|
||||
ref={(instance) => {
|
||||
if (instance) markerRefs.current[office.id] = instance;
|
||||
}}
|
||||
>
|
||||
<Popup
|
||||
className={styles.popup}
|
||||
autoPan={false}
|
||||
closeButton={false}
|
||||
minWidth={120}
|
||||
maxWidth={200}
|
||||
>
|
||||
<span className={styles.tip}>
|
||||
<span className={styles.tipTitle}>
|
||||
<span aria-hidden="true">📍</span> {office.shortLabel}
|
||||
</span>
|
||||
</span>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
<MapController positions={positions} focus={focus} markerRefs={markerRefs} />
|
||||
<MapController focus={focus} markerRefs={markerRefs} />
|
||||
</MapContainer>
|
||||
|
||||
{status === "error" && (
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Kept dependency-free (no Leaflet runtime import) so it can be safely consumed
|
||||
* by both server and client modules. Types model `[lat, lng]` tuples that are
|
||||
* directly compatible with Leaflet's `LatLngExpression`.
|
||||
*
|
||||
* Coordinates are the real Doormile operational sites, verified against
|
||||
* satellite view — not generic city-centre points.
|
||||
*/
|
||||
|
||||
/** A `[latitude, longitude]` coordinate pair. */
|
||||
@@ -12,21 +15,56 @@ export type LatLng = [number, number];
|
||||
export interface OfficeLocation {
|
||||
/** Stable, unique key (used for React keys + analytics). */
|
||||
readonly id: string;
|
||||
/** Short city label, shown on the navigation buttons. */
|
||||
/** Short city label, shown on the navigation button. */
|
||||
readonly city: string;
|
||||
/** Human-readable label shown in the marker popup + a11y fallback. */
|
||||
/** Human-readable label shown in the a11y fallback + marker title. */
|
||||
readonly name: string;
|
||||
/** Compact heading shown in the marker tooltip/popup (e.g. "Hyderabad HQ"). */
|
||||
readonly shortLabel: string;
|
||||
/** `[latitude, longitude]`. */
|
||||
readonly position: LatLng;
|
||||
/** Headquarters gets the largest, glowing, default-active marker. */
|
||||
readonly isHeadquarters?: boolean;
|
||||
}
|
||||
|
||||
/** The three permanent office markers, ordered north-to-south is irrelevant — bounds are auto-fit. */
|
||||
/**
|
||||
* The three permanent office markers, ordered for the navigation row by
|
||||
* operational hierarchy: Hyderabad (HQ) → Bengaluru → Coimbatore. The
|
||||
* headquarters is conveyed purely through styling (active state + border +
|
||||
* glow), not through icons or label text.
|
||||
*/
|
||||
export const OFFICE_LOCATIONS: readonly OfficeLocation[] = [
|
||||
{ id: "coimbatore", city: "Coimbatore", name: "Coimbatore Office", position: [11.0168, 76.9558] },
|
||||
{ id: "bengaluru", city: "Bengaluru", name: "Bengaluru Office", position: [12.9716, 77.5946] },
|
||||
{ id: "hyderabad", city: "Hyderabad", name: "Hyderabad Office", position: [17.385, 78.4867] },
|
||||
{
|
||||
id: "hyderabad",
|
||||
city: "Hyderabad",
|
||||
name: "Doormile Headquarters",
|
||||
shortLabel: "Hyderabad HQ",
|
||||
// Vision Ultima, Jayabheri Enclave, Gachibowli — verified on satellite.
|
||||
position: [17.4484, 78.3573],
|
||||
isHeadquarters: true,
|
||||
},
|
||||
{
|
||||
id: "bengaluru",
|
||||
city: "Bengaluru",
|
||||
name: "Bengaluru Hub",
|
||||
shortLabel: "Bengaluru Hub",
|
||||
// Resolved from the supplied Google Maps share link — verified on satellite.
|
||||
position: [12.9929351, 77.6988599],
|
||||
},
|
||||
{
|
||||
id: "coimbatore",
|
||||
city: "Coimbatore",
|
||||
name: "Coimbatore Hub",
|
||||
shortLabel: "Coimbatore Hub",
|
||||
// Mayflower Valencia, Coimbatore — verified against satellite view.
|
||||
position: [11.0191, 76.9883],
|
||||
},
|
||||
];
|
||||
|
||||
/** The headquarters office — focused by default on load. Falls back to the first office. */
|
||||
export const HQ_OFFICE: OfficeLocation =
|
||||
OFFICE_LOCATIONS.find((office) => office.isHeadquarters) ?? OFFICE_LOCATIONS[0];
|
||||
|
||||
export interface TileLayerConfig {
|
||||
readonly url: string;
|
||||
readonly attribution: string;
|
||||
@@ -45,15 +83,12 @@ export const ESRI_WORLD_IMAGERY: TileLayerConfig = {
|
||||
maxZoom: 19,
|
||||
};
|
||||
|
||||
/** Padding (in px) applied when auto-fitting bounds so markers never touch the edges. */
|
||||
export const MAP_FIT_PADDING: LatLng = [50, 50];
|
||||
|
||||
/** Cap the auto-fit zoom so two close offices don't zoom the map in too far. */
|
||||
export const MAP_FIT_MAX_ZOOM = 7;
|
||||
|
||||
/** City-level zoom used when a single office is selected via the nav buttons. */
|
||||
/** City-level zoom used when an office is selected (and for the initial HQ view). */
|
||||
export const MAP_FOCUS_ZOOM = 13;
|
||||
|
||||
/** Initial center/zoom (roughly the centroid of the offices) used before bounds are fit. */
|
||||
export const MAP_INITIAL_CENTER: LatLng = [14.0, 77.7];
|
||||
export const MAP_INITIAL_ZOOM = 5;
|
||||
/**
|
||||
* Initial center/zoom: the experience opens focused on the Hyderabad HQ so the
|
||||
* command-centre reads as the heart of the network the instant the map paints.
|
||||
*/
|
||||
export const MAP_INITIAL_CENTER: LatLng = HQ_OFFICE.position;
|
||||
export const MAP_INITIAL_ZOOM = MAP_FOCUS_ZOOM;
|
||||
|
||||
Reference in New Issue
Block a user