/* Shared Europe map component. Renders projected country paths from world-atlas topojson via d3-geo. Each design styles fills, strokes, labels independently via props. */ function useEuTopo() { const [topo, setTopo] = React.useState(window.EU_TOPO || null); React.useEffect(() => { if (window.EU_TOPO) { setTopo(window.EU_TOPO); return; } const h = () => setTopo(window.EU_TOPO); window.addEventListener('eu-topo-ready', h); return () => window.removeEventListener('eu-topo-ready', h); }, []); return topo; } // Memoised projection + path builder for a given size & feature collection. function useEuropePaths(width, height, padding, extent) { const topo = useEuTopo(); return React.useMemo(() => { if (!topo) return null; const all = topojson.feature(topo, topo.objects.countries).features; // Crop to a Europe-focused bbox by centroid (keeps countries we want to draw) const inBox = all.filter(f => { const c = d3.geoCentroid(f); if (!c || isNaN(c[0])) return false; return c[0] > -25 && c[0] < 42 && c[1] > 33 && c[1] < 71; }); // Fit projection to a fixed bbox over Europe. d3-geo treats GeoJSON // polygons with the "left-hand rule" (exterior ring clockwise on the // sphere) — we provide the winding accordingly so geoBounds returns the // small Europe extent, not the entire sphere. const bbox = { type: 'Polygon', coordinates: [[ [-12, 34], [-12, 71], [40, 71], [40, 34], [-12, 34], ]], }; const fitExtent = extent || [ [padding, padding], [width - padding, height - padding], ]; const proj = d3.geoMercator().fitExtent(fitExtent, bbox); const path = d3.geoPath(proj); const features = inBox.map(f => { const iso = window.ISO_NUM_TO_3[String(f.id).padStart(3, '0')]; const country = iso ? window.EU_COUNTRIES.find(c => c.iso === iso) : null; const d = path(f); const c = proj(d3.geoCentroid(f)); return { f, iso, country, d, cx: c && c[0], cy: c && c[1], isEU: !!country }; }); return { features, proj, path }; }, [topo, width, height, padding, extent && extent.join(',')]); } function EuropeMap({ width, height, padding = 20, extent, background = 'transparent', oceanColor = null, otherFill = '#e5e7eb', otherStroke = '#cbd5e1', euFill, // (country, max) => color euStroke = '#ffffff', euStrokeWidth = 0.6, selectedIso, onSelect, hoveredIso, onHover, showLabels = false, labelColor = '#1f2937', labelMinSpend = 30, showValues = false, valueColor = '#ffffff', showDots = false, dotForCountry, // (country) => {r, fill, stroke} graticule = false, interactive = false, // enable wheel-zoom + drag-pan children, }) { const build = useEuropePaths(width, height, padding, extent); const max = React.useMemo( () => Math.max(...window.EU_COUNTRIES.map(c => c.spend)), [] ); // Pan/zoom state — applied as a transform on the inner group const [tx, setTx] = React.useState(0); const [ty, setTy] = React.useState(0); const [zoom, setZoom] = React.useState(1); const [isDragging, setIsDragging] = React.useState(false); const svgRef = React.useRef(null); const dragRef = React.useRef(null); // { startX, startY, startTx, startTy, moved } // Clamp pan so the map can't drift off the canvas. Allowed slack grows with // zoom (at 1× we lock to ~10% slack, at 6× we let the user travel further). const clampPan = (nx, ny, z = zoom) => { const slack = Math.max(0.1, z - 0.5); const maxX = width * slack; const maxY = height * slack; return [ Math.max(-maxX, Math.min(maxX, nx)), Math.max(-maxY, Math.min(maxY, ny)), ]; }; // Wheel zoom — bind at window level and gate by hit-testing the SVG rect. // We bind to window (not the SVG) because empty SVG areas can't be reliably // hit-tested for wheel events across browsers; window listener always fires. React.useEffect(() => { if (!interactive) return; const onWheel = (e) => { const svg = svgRef.current; if (!svg) return; const rect = svg.getBoundingClientRect(); // Only act when the cursor is over the SVG bounds. if ( e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom ) return; // Skip when cursor is over a UI panel sitting above the map. const overPanel = (e.target instanceof Element) && e.target.closest( '.ps-panel, .ps-stats-widget, .ps-brand, .ps-legend, .ps-deal-modal' ); if (overPanel) return; e.preventDefault(); const cssX = e.clientX - rect.left; const cssY = e.clientY - rect.top; const userX = cssX * (width / rect.width); const userY = cssY * (height / rect.height); const factor = Math.exp(-e.deltaY * 0.0015); setZoom((z) => { const next = Math.max(0.7, Math.min(6, z * factor)); const realFactor = next / z; const slack = Math.max(0.1, next - 0.5); setTx((curTx) => { const cand = userX - (userX - curTx) * realFactor; return Math.max(-width * slack, Math.min(width * slack, cand)); }); setTy((curTy) => { const cand = userY - (userY - curTy) * realFactor; return Math.max(-height * slack, Math.min(height * slack, cand)); }); return next; }); }; window.addEventListener('wheel', onWheel, { passive: false }); return () => window.removeEventListener('wheel', onWheel); }, [interactive, width, height]); // Pointer drag handlers — only capture/treat as drag AFTER a 4px move. // Until then, clicks on countries (and hover events) fire normally. const onPointerDown = (e) => { if (!interactive) return; dragRef.current = { startX: e.clientX, startY: e.clientY, startTx: tx, startTy: ty, moved: false, captured: false, pointerId: e.pointerId, target: e.currentTarget, }; }; const onPointerMove = (e) => { const d = dragRef.current; if (!d) return; const dx = e.clientX - d.startX; const dy = e.clientY - d.startY; if (!d.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { d.moved = true; setIsDragging(true); // Capture pointer only now — too late to affect the click we won't // be firing, but keeps the drag smooth even if the cursor strays // outside the SVG bounds. try { d.target.setPointerCapture(d.pointerId); d.captured = true; } catch (_) {} } if (d.moved) { const rect = svgRef.current.getBoundingClientRect(); const scaleX = width / rect.width; const scaleY = height / rect.height; const [cx, cy] = clampPan(d.startTx + dx * scaleX, d.startTy + dy * scaleY); setTx(cx); setTy(cy); } }; const onPointerUp = (e) => { const d = dragRef.current; if (d && d.captured) { try { d.target.releasePointerCapture(d.pointerId); } catch (_) {} } dragRef.current = null; setIsDragging(false); }; const wasDragged = () => dragRef.current && dragRef.current.moved; if (!build) { return (
Loading map…
); } const transform = `translate(${tx} ${ty}) scale(${zoom})`; return ( {oceanColor && } {/* Transparent hit-rect so wheel & drag fire on empty (no-country) areas */} {!oceanColor && interactive && ( )} {graticule && ( )} {/* Non-EU countries (context) */} {build.features.filter(x => !x.isEU).map((x, i) => ( ))} {/* EU countries */} {build.features.filter(x => x.isEU).map((x, i) => { const isSel = selectedIso === x.iso; const isHov = hoveredIso === x.iso; const fill = typeof euFill === 'function' ? euFill(x.country, max) : euFill; return ( onHover(x.iso) : undefined} onMouseLeave={onHover ? () => onHover(null) : undefined} onClick={onSelect ? (e) => { if (!wasDragged()) onSelect(x.iso); } : undefined} /> ); })} {/* Labels */} {showLabels && ( {build.features.filter(x => x.isEU && x.country.spend >= labelMinSpend).map(x => ( {x.country.name} {showValues && ( €{x.country.spend.toFixed(1)}B )} ))} )} {/* Optional dots overlay (for buyer scatter etc) */} {showDots && ( {build.features.filter(x => x.isEU).map(x => { const dot = dotForCountry && dotForCountry(x.country); if (!dot) return null; return ( ); })} )} {/* Children rendered on top — for arrows / overlays. Children receive { ...build, zoom } so they can counter-scale text to stay crisp. */} {typeof children === 'function' ? children({ ...build, zoom }) : children} ); } // Helper to get the projected pixel location of a country centroid. function getEuCentroid(build, iso) { const f = build.features.find(x => x.iso === iso); return f ? [f.cx, f.cy] : null; } Object.assign(window, { EuropeMap, getEuCentroid });