// 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("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(null); const [mapWidth, setMapWidth] = useState(1200); // CMS const { data, loading, error } = useSingleton( "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(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 ( e.type !== "wheel" && e.type !== "dblclick"} onMoveEnd={() => { setCenter(initialCenter); setZoom(zoom); }} > {({ geographies }: { geographies: any[] }) => geographies.map((geo: any) => { const name = geo.properties.name as string; const isAlways = highlighted.has(name); const isActive = hovered === name; return ( 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" }, }} /> ); }) } {/* markers (флаг или кружок) */} {COUNTRIES.map((c) => ( setHovered(c.name)} onMouseLeave={() => setHovered((prev) => (prev === c.name ? null : prev))} > ))} {/* labels/cards */} {COUNTRIES.map((c) => { const pos = LAYOUT[c.id as CmsCountry["code"]]; const items = c.items; const twoCols = items.length > 4; return ( setHovered(c.name)} onMouseLeave={() => setHovered((prev) => (prev === c.name ? null : prev))} >
{c.flagUrl ? ( ): (
)}
{c.label ?? c.name}
{items.length <= 1 ? ( items[0] ? (
{items[0]}
) : null ) : (
    {items.map((t, i) => (
  • {t}
  • ))}
)}
); })} + ); } /* =================== 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); } `;