LendingTelecom/landing/src/components/CountriesMap/index.tsx

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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); }
`;