405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
// src/components/CountriesMap/index.tsx
|
||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
ComposableMap,
|
||
Geographies,
|
||
Geography,
|
||
Marker,
|
||
Annotation,
|
||
ZoomableGroup,
|
||
} from "react-simple-maps";
|
||
import styled from "styled-components";
|
||
import {
|
||
s_Text16_700 as Text16_700,
|
||
} from "../Typography";
|
||
import { useSingleton } from "cms/factory";
|
||
|
||
/* =================== constants =================== */
|
||
const MAP_HEIGHT = 520;
|
||
const GEO_URL = "/data/countries-110m.json";
|
||
const CARD_W = 290;
|
||
const CARD_H = 160;
|
||
|
||
const MIN_ZOOM = 0.8;
|
||
const MAX_ZOOM = 8;
|
||
const INITIAL_ZOOM = 4;
|
||
const ZOOM_STEP = 1.25;
|
||
|
||
const clamp = (n: number, min: number, max: number) =>
|
||
Math.min(max, Math.max(min, n));
|
||
|
||
|
||
/* =================== CMS types/selection =================== */
|
||
type CmsItem = { id: string; text: string; order: number };
|
||
type CmsCountry = {
|
||
id: string;
|
||
code: "usa" | "kaz" | "uzb" | "kgz" | "tjk" | "tur" | "cyp" | "arm" | "uae";
|
||
name: string;
|
||
isEnabled: boolean;
|
||
order: number;
|
||
flag?: { url?: string | null } | null;
|
||
items: CmsItem[];
|
||
};
|
||
type CmsSection = {
|
||
id: string;
|
||
title?: string | null;
|
||
countries: CmsCountry[];
|
||
};
|
||
|
||
const SELECTION = `
|
||
id
|
||
title
|
||
countries(orderBy: { order: asc }) {
|
||
id code name isEnabled order
|
||
flag { url }
|
||
items(orderBy: { order: asc }) { id text order }
|
||
}
|
||
`;
|
||
|
||
/* =================== layout map (coords/offsets) =================== */
|
||
/** координаты и смещения — «жёсткие», не редактируются в CMS */
|
||
const LAYOUT: Record<
|
||
CmsCountry["code"],
|
||
{ coord: [number, number]; dx: number; dy: number; label?: string }
|
||
> = {
|
||
usa: { coord: [-97, 39], dx: 850, dy: -210, label: "United States of America" },
|
||
kaz: { coord: [67, 48], dx: -80, dy: -130 },
|
||
uzb: { coord: [64.6, 41.3], dx: 250, dy: -140 },
|
||
kgz: { coord: [74.7, 41.2], dx: 150, dy: 0 },
|
||
tjk: { coord: [71, 38.9], dx: 50, dy: 80 },
|
||
tur: { coord: [35, 39], dx: -100, dy: -100 },
|
||
cyp: { coord: [33, 35], dx: -200, dy: -30 },
|
||
arm: { coord: [45, 40], dx: -120, dy: 100 },
|
||
uae: { coord: [54.4, 24.3], dx: -10, dy: 45 },
|
||
};
|
||
|
||
/* =================== fallback (твои текущие данные) =================== */
|
||
type CountryCard = {
|
||
id: string; // code
|
||
name: string;
|
||
coord: [number, number];
|
||
dx: number;
|
||
dy: number;
|
||
items: string[];
|
||
flagUrl?: string | null; // + флаг из CMS (если есть)
|
||
label?: string;
|
||
};
|
||
|
||
const FALLBACK: CountryCard[] = [
|
||
{ id: "usa", name: "United States of America", coord: [-97, 39], dx: 60, dy: -10, items: ["NYSE institutional broker"] },
|
||
{ id: "kaz", name: "Kazakhstan", coord: [67, 48], dx: 40, dy: -20, items: ["Freedom Bank","Freedom Broker","Freedom Life","Freedom Insurance","Freedom Pay","Freedom Telecom","Freedom Travel"] },
|
||
{ id: "uzb", name: "Uzbekistan", coord: [64.6, 41.3], dx: 50, dy: 0, items: ["Freedom Broker Uzbekistan","Freedom Pay Uzbekistan","Freedom Telecom Uzbekistan"] },
|
||
{ id: "kgz", name: "Kyrgyzstan", coord: [74.7, 41.2], dx: 40, dy: 0, items: ["Freedom Broker Kyrgyzstan","Freedom Telecom Kyrgyzstan"] },
|
||
{ id: "tjk", name: "Tajikistan", coord: [71, 38.9], dx: 40, dy: 20, items: ["Freedom Broker Tajikistan","Freedom Telecom Tajikistan"] },
|
||
{ id: "tur", name: "Turkey", coord: [35, 39], dx: -10, dy: 30, items: ["Freedom Broker"] },
|
||
{ id: "cyp", name: "Cyprus", coord: [33, 35], dx: -10, dy: 30, items: ["Freedom Finance Europe"] },
|
||
{ id: "arm", name: "Armenia", coord: [45, 40], dx: -10, dy: 30, items: ["Brokerage"] },
|
||
{ id: "uae", name: "United Arab Emirates", coord: [54.4, 24.3], dx: -10, dy: 30, items: ["Freedom Telecom International","Freedom Broker"] },
|
||
];
|
||
|
||
/* =================== map CMS → view =================== */
|
||
function mapCountries(section?: CmsSection | null): CountryCard[] {
|
||
const list = section?.countries ?? [];
|
||
if (!list.length) return [];
|
||
|
||
return list
|
||
.filter((c) => c.isEnabled && LAYOUT[c.code])
|
||
.map((c) => {
|
||
const lay = LAYOUT[c.code];
|
||
return {
|
||
id: c.code,
|
||
name: c.name,
|
||
coord: lay.coord,
|
||
dx: lay.dx,
|
||
dy: lay.dy,
|
||
label: lay.label,
|
||
items: (c.items ?? []).map((i) => i.text),
|
||
flagUrl: c.flag?.url ?? null,
|
||
};
|
||
});
|
||
}
|
||
|
||
function updateScaleKeepCenter(newScale: number) {
|
||
const g = document.querySelector<SVGGElement>("g.rsm-zoomable-group");
|
||
const svg = g?.closest("svg") as SVGSVGElement | null;
|
||
if (!g || !svg) return;
|
||
|
||
const transform = g.getAttribute("transform") || "";
|
||
const matchT = transform.match(/translate\((-?\d+\.?\d*)[, ]+(-?\d+\.?\d*)\)/);
|
||
const matchS = transform.match(/scale\((-?\d+\.?\d*)\)/);
|
||
|
||
let tx = matchT ? parseFloat(matchT[1]) : 0;
|
||
let ty = matchT ? parseFloat(matchT[2]) : 0;
|
||
const currentScale = matchS ? parseFloat(matchS[1]) : 1;
|
||
|
||
// центр SVG
|
||
const cx = svg.clientWidth / 2;
|
||
const cy = svg.clientHeight / 2;
|
||
|
||
// пересчёт translate
|
||
const ratio = newScale / currentScale;
|
||
tx = cx - (cx - tx) * ratio;
|
||
ty = cy - (cy - ty) * ratio;
|
||
|
||
g.setAttribute("transform", `translate(${tx} ${ty}) scale(${newScale})`);
|
||
}
|
||
|
||
|
||
|
||
/* =================== component =================== */
|
||
export default function CountriesMap() {
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
const [mapWidth, setMapWidth] = useState(1200);
|
||
|
||
// CMS
|
||
const { data, loading, error } = useSingleton<CmsSection>(
|
||
"CountriesMapSection",
|
||
SELECTION
|
||
);
|
||
|
||
const cmsCountries = useMemo(() => mapCountries(data), [data]);
|
||
const COUNTRIES: CountryCard[] = cmsCountries.length ? cmsCountries : FALLBACK;
|
||
|
||
// ширина контейнера
|
||
useEffect(() => {
|
||
if (!wrapRef.current) return;
|
||
const observer = new ResizeObserver((entries) => {
|
||
const entry = entries[0];
|
||
if (entry) setMapWidth(entry.contentRect.width);
|
||
});
|
||
observer.observe(wrapRef.current);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
const highlighted = useMemo(() => new Set(COUNTRIES.map((d) => d.name)), [COUNTRIES]);
|
||
const [hovered, setHovered] = useState<string | null>(null);
|
||
|
||
const mobile = typeof window !== "undefined" ? window.innerWidth < 700 : false;
|
||
const initialCenter: [number, number] = mobile ? [65, 38] : [52, 38];
|
||
|
||
const [center, setCenter] = useState<[number, number]>(initialCenter);
|
||
const [zoom, setZoom] = useState(INITIAL_ZOOM);
|
||
const [zoomScale, setZoomScale] = useState(INITIAL_ZOOM);
|
||
|
||
useEffect(() => {
|
||
updateScaleKeepCenter(zoomScale);
|
||
},[zoomScale]);
|
||
|
||
const zoomIn = () => setZoomScale(z => clamp(z * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
|
||
const zoomOut = () => setZoomScale(z => clamp(z / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
|
||
const resetView = () => {
|
||
setCenter(initialCenter);
|
||
setZoom(INITIAL_ZOOM);
|
||
};
|
||
|
||
|
||
return (
|
||
<Wrap ref={wrapRef}>
|
||
<ComposableMap
|
||
key={mapWidth}
|
||
projection="geoMercator"
|
||
projectionConfig={{ scale: 110 }}
|
||
width={mapWidth}
|
||
height={MAP_HEIGHT}
|
||
style={{ width: "100%", height: `${MAP_HEIGHT}px` }}
|
||
preserveAspectRatio="none"
|
||
>
|
||
<ZoomableGroup
|
||
center={center}
|
||
zoom={zoom}
|
||
minZoom={0.8}
|
||
maxZoom={8}
|
||
translateExtent={[[-1000, -500], [1000, 900]]}
|
||
filterZoomEvent={(e) => e.type !== "wheel" && e.type !== "dblclick"}
|
||
onMoveEnd={() => {
|
||
setCenter(initialCenter);
|
||
setZoom(zoom);
|
||
}}
|
||
>
|
||
<Geographies geography={GEO_URL}>
|
||
{({ geographies }: { geographies: any[] }) =>
|
||
geographies.map((geo: any) => {
|
||
const name = geo.properties.name as string;
|
||
const isAlways = highlighted.has(name);
|
||
const isActive = hovered === name;
|
||
return (
|
||
<Geography
|
||
key={geo.rsmKey as string}
|
||
geography={geo}
|
||
onMouseEnter={() => setHovered(name)}
|
||
onMouseLeave={() => setHovered((prev) => (prev === name ? null : prev))}
|
||
style={{
|
||
default: {
|
||
fill: isActive ? "#0E7C44" : isAlways ? "#2EA86B" : "#CFE1E7",
|
||
stroke: "#FFFFFF",
|
||
strokeWidth: 0.6,
|
||
outline: "none",
|
||
transition: "fill .2s ease",
|
||
},
|
||
hover: { fill: "#0E7C44", outline: "none" },
|
||
pressed: { fill: "#0E7C44", outline: "none" },
|
||
}}
|
||
/>
|
||
);
|
||
})
|
||
}
|
||
</Geographies>
|
||
|
||
{/* markers (флаг или кружок) */}
|
||
{COUNTRIES.map((c) => (
|
||
<Marker
|
||
key={`m-${c.id}`}
|
||
coordinates={c.coord}
|
||
onMouseEnter={() => setHovered(c.name)}
|
||
onMouseLeave={() => setHovered((prev) => (prev === c.name ? null : prev))}
|
||
>
|
||
<g transform={`scale(${1 / zoom})`}>
|
||
<circle r={5} fill="#E11D48" stroke="#fff" strokeWidth={2} />
|
||
</g>
|
||
</Marker>
|
||
))}
|
||
|
||
{/* labels/cards */}
|
||
{COUNTRIES.map((c) => {
|
||
const pos = LAYOUT[c.id as CmsCountry["code"]];
|
||
const items = c.items;
|
||
const twoCols = items.length > 4;
|
||
|
||
return (
|
||
<Annotation
|
||
key={`a-${c.id}`}
|
||
subject={c.coord}
|
||
dx={pos.dx / zoom}
|
||
dy={pos.dy / zoom}
|
||
connectorProps={{ stroke: "#94A3B8", strokeWidth: 0.5 }}
|
||
>
|
||
<g transform={`scale(${1 / (zoom + (mobile ? 0 : 0))})`} data-card={c.name}>
|
||
<foreignObject
|
||
x={-140}
|
||
y={-8}
|
||
width={CARD_W}
|
||
height={CARD_H}
|
||
style={{ pointerEvents: "auto" }}
|
||
onMouseEnter={() => setHovered(c.name)}
|
||
onMouseLeave={() => setHovered((prev) => (prev === c.name ? null : prev))}
|
||
>
|
||
<div
|
||
style={{
|
||
background: "#FFFFFF",
|
||
borderRadius: 16,
|
||
padding: "6px 12px",
|
||
boxShadow: "0px 8px 14.13px 0px #2764698F",
|
||
border: "1px solid #14935F",
|
||
margin: "0px 10px",
|
||
display: "flex",
|
||
flexDirection: "row",
|
||
}}
|
||
>
|
||
{c.flagUrl ? (
|
||
<img
|
||
src={c.flagUrl??undefined}
|
||
style={{width:16.67,height:16.67}}
|
||
/>
|
||
): (
|
||
<div
|
||
style={{
|
||
width: 16.67,
|
||
height: 16.67,
|
||
background: "#10B981",
|
||
borderRadius: 999,
|
||
}}
|
||
/>
|
||
)}
|
||
<div style={{ paddingLeft: 8 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
marginBottom: 0,
|
||
}}
|
||
>
|
||
<Text16_700 style={{ fontFamily: "inherit" }}>
|
||
{c.label ?? c.name}
|
||
</Text16_700>
|
||
</div>
|
||
|
||
{items.length <= 1 ? (
|
||
items[0] ? (
|
||
<div style={{ fontSize: 12, lineHeight: "18px" }}>{items[0]}</div>
|
||
) : null
|
||
) : (
|
||
<ul
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
lineHeight: "16px",
|
||
display: twoCols ? "grid" : "block",
|
||
gridTemplateColumns: twoCols ? "1fr 1fr" : undefined,
|
||
columnGap: twoCols ? 16 : undefined,
|
||
listStyleType: 'disc'
|
||
}}
|
||
>
|
||
{items.map((t, i) => (
|
||
<li key={i}>
|
||
<div style={{ whiteSpace: "nowrap" }}>{t}</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</foreignObject>
|
||
</g>
|
||
</Annotation>
|
||
);
|
||
})}
|
||
</ZoomableGroup>
|
||
</ComposableMap>
|
||
<ZoomControls>
|
||
|
||
<ZoomBtn onClick={zoomIn} aria-label="Zoom in">+</ZoomBtn>
|
||
<ZoomBtn onClick={zoomOut} aria-label="Zoom out">–</ZoomBtn>
|
||
<ZoomBtn onClick={resetView} aria-label="Reset">⟳</ZoomBtn>
|
||
</ZoomControls>
|
||
</Wrap>
|
||
);
|
||
}
|
||
|
||
/* =================== styles =================== */
|
||
const Wrap = styled.div`
|
||
position: relative;
|
||
background: #e9f0f3;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
`;
|
||
|
||
const ZoomControls = styled.div`
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 12px;
|
||
display: flex;
|
||
gap: 8px;
|
||
z-index: 2;
|
||
pointer-events: auto;
|
||
flex-direction: column;
|
||
`;
|
||
|
||
const ZoomBtn = styled.button`
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
border: 1px solid #14935f;
|
||
box-shadow: 0px 8px 14.13px 0px #2764698f;
|
||
font-weight: 700;
|
||
line-height: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
&:active { transform: translateY(1px); }
|
||
`;
|
||
|