pages/PrintMap/PrintMap.jsxjavascript
import { useEffect, useMemo, useRef, useState } from "react";
import { geoEqualEarth, geoOrthographic, geoPath } from "d3-geo";
import { UIMCButton } from "@ui-marcom/react/button";
import { useConfig } from "../../services/config/config";
import { TAG_META } from "../../services/config/globeDefaults";
import { resolveAssetPath } from "../../utils/resolveAssetPath";
import { loadCountryPolygons } from "../../components/InteractiveGlobe/countryPolygons";
import { resolveCountryColors } from "../../components/InteractiveGlobe/countryColors";
import "./PrintMap.scss";
// selectable print canvases - the SVG itself is resolution-independent (it's
// vector), this just fixes the aspect ratio d3-geo's fitSize projects the
// world into (and the coordinate space of the whole document). Portrait
// formats letterbox rather than distort: fitSize preserves the map's own
// aspect ratio and centers it, leaving room above/below for a title or the
// legend on a printed poster.
const FORMAT_PRESETS = [
{ id: "world-2-1", label: "World map (2:1)", width: 2000, height: 1000 },
{ id: "a3-landscape", label: "A3 landscape", width: 1800, height: 1273 },
{ id: "a3-portrait", label: "A3 portrait", width: 1273, height: 1800 },
{ id: "square", label: "Square (1:1)", width: 1600, height: 1600 },
];
// "flat" always shows the whole world (fitSize fits every country; only
// rotateLambda - which meridian lands in the horizontal center - changes).
// 70°E is the midpoint between mainland Europe's western edge (~-10°E,
// Portugal) and mainland Asia's eastern edge (~150°E, Japan/Kamchatka), so
// "Flat map — Europe/Asia" keeps both roughly balanced either side of
// center instead of Asia running off the right edge, as it does at the
// default 0° (Prime Meridian) centering.
//
// "globe" instead renders an orthographic (true sphere) projection centered
// on {lat, lng} with the far hemisphere clipped away - this is the closest
// vector approximation of "what the 3D globe currently shows", requested in
// place of the flat map. It is NOT a reconstruction of the 3D WebGL
// camera/perspective (see the print-map decision doc) - no camera FOV,
// lighting or altitude, just a geographically-accurate sphere view centered
// on the same point.
const VIEW_PRESETS = [
{ id: "flat-atlantic", label: "Flat map — Atlantic", mode: "flat", rotateLambda: 0 },
{ id: "flat-eurasia", label: "Flat map — Europe/Asia", mode: "flat", rotateLambda: 70 },
{ id: "globe-europe-asia", label: "Globe — Europe/Asia", mode: "globe", lat: 35, lng: 55 },
// -40°E puts the mid-Atlantic in the center: at 35°N this keeps every
// Europe and North America marker (see value-partnership-points.json)
// within the 90° visible hemisphere, with Brazil/Chile as a bonus - only
// the single South Africa marker falls just outside it.
{ id: "globe-atlantic", label: "Globe — Atlantic", mode: "globe", lat: 35, lng: -40 },
];
const toRadians = (deg) => (deg * Math.PI) / 180;
// great-circle test: is (lng, lat) within the visible (near) hemisphere for
// a globe view centered on (centerLng, centerLat)? projection([lng, lat])
// still returns a (wrong-side) point for far-hemisphere coordinates even
// with clipAngle set on the projection - clipAngle only affects geoPath's
// own polygon clipping, not raw point projection - so markers need this
// separate check before being placed.
const isFrontHemisphere = (lng, lat, centerLng, centerLat) => {
const λ1 = toRadians(centerLng);
const φ1 = toRadians(centerLat);
const λ2 = toRadians(lng);
const φ2 = toRadians(lat);
const cosDistance = Math.sin(φ1) * Math.sin(φ2) + Math.cos(φ1) * Math.cos(φ2) * Math.cos(λ2 - λ1);
return cosDistance > 0;
};
// kept at the same 92:71 aspect ratio as the source marker SVGs (see
// InteractiveGlobe.jsx's markerHeight), sized for print rather than
// InteractiveGlobe's own globeMarkerWidth config value, which is tuned for
// the 3D globe's on-screen scale
const MARKER_WIDTH = 34;
const MARKER_HEIGHT = Math.round((MARKER_WIDTH * 71) / 92);
// ported verbatim from marker_orange.svg/marker_petrol.svg (public/assets/
// images/value-partnership/) - defined inline as <symbol>s (see the <defs>
// block below) instead of referencing those files via <image href>, so the
// downloaded SVG is fully self-contained: openable in Illustrator/Inkscape
// without the two external icon files alongside it, and its pins stay real
// vector paths rather than embedded raster/nested documents.
const MARKER_PIN_PATH =
"M45.9437 0C59.6452 0 70.7541 11.1072 70.7543 24.8086C70.7543 30.7393 68.6715 36.183 65.1996 40.4512L45.9447 70.3516L26.6888 40.4512C23.2169 36.183 21.1342 30.7393 21.1342 24.8086C21.1343 11.1073 32.2425 0.000231928 45.9437 0Z";
const MARKER_FILLS = { outcome: "#EC6602", impact: "#009999" };
// human-readable labels for value-partnership-globe-countries.json's
// highlight/normal/hide states (see countryColors.js) - there's no existing
// label source for these (unlike TAG_META for points), so they're authored
// here for the legend only
const COUNTRY_STATE_LABELS = { highlight: "Highlighted", normal: "Standard", hide: "Hidden" };
/**
* Dev-only export tool: renders the same country polygons and location
* points the 3D globe (InteractiveGlobe.jsx) uses, but as a flat, unclipped
* d3-geo projection instead of a 3D perspective - see the print-map decision
* doc referenced from the project chat. Not part of the visitor-facing
* kiosk; reachable directly at #/print-map (see HashRouter.jsx), outside the
* Stage/ContentProvider/Screensaver chain, so it fetches its own content
* instead of going through ContentProvider's route-based file resolution.
*/
export const PrintMapPage = () => {
const { globeOceanColor, globeLandStrokeColor, globeFallbackCountryColor } = useConfig();
const svgRef = useRef(null);
const [content, setContent] = useState(null);
const [error, setError] = useState(null);
const [formatId, setFormatId] = useState(FORMAT_PRESETS[0].id);
const [viewId, setViewId] = useState(VIEW_PRESETS[0].id);
const format = FORMAT_PRESETS.find((f) => f.id === formatId) ?? FORMAT_PRESETS[0];
const view = VIEW_PRESETS.find((v) => v.id === viewId) ?? VIEW_PRESETS[0];
useEffect(() => {
let cancelled = false;
Promise.all([
fetch(resolveAssetPath("/data/value-partnership-points.json")).then((r) => r.json()),
fetch(resolveAssetPath("/data/value-partnership-globe-countries.json")).then((r) => r.json()),
])
.then(([pointsData, countryDisplayData]) => {
if (cancelled) return;
setContent({
points: pointsData.valuePartnership?.points ?? [],
countryDisplay: countryDisplayData.valuePartnership?.countryDisplay,
});
})
.catch((fetchError) => {
if (!cancelled) setError(fetchError);
});
return () => {
cancelled = true;
};
}, []);
const countryFeatures = useMemo(() => loadCountryPolygons(), []);
const { defaultCountryColor, countryColorByCcn3 } = useMemo(
() => resolveCountryColors(content?.countryDisplay, globeFallbackCountryColor),
[content?.countryDisplay, globeFallbackCountryColor]
);
const { pathGenerator, projectPoint, isPointVisible, sphereOutline } = useMemo(() => {
const projection =
view.mode === "globe"
? geoOrthographic()
.clipAngle(90)
.rotate([-view.lng, -view.lat])
.fitSize([format.width, format.height], { type: "Sphere" })
: geoEqualEarth().rotate([-view.rotateLambda, 0]).fitSize([format.width, format.height], {
type: "FeatureCollection",
features: countryFeatures,
});
const path = geoPath(projection);
return {
pathGenerator: path,
projectPoint: (lng, lat) => projection([lng, lat]),
isPointVisible:
view.mode === "globe"
? (lng, lat) => isFrontHemisphere(lng, lat, view.lng, view.lat)
: () => true,
// geoPath's special "Sphere" GeoJSON type draws the full projection
// outline - a rectangle for the flat map, the clipped globe circle
// for the orthographic view
sphereOutline: path({ type: "Sphere" }),
};
}, [countryFeatures, format.width, format.height, view]);
const countryStates = Object.entries(content?.countryDisplay?.colors ?? {});
const handleDownload = () => {
if (!svgRef.current) return;
const svgString = new XMLSerializer().serializeToString(svgRef.current);
const blob = new Blob([`<?xml version="1.0" encoding="UTF-8"?>\n${svgString}`], {
type: "image/svg+xml",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `value-partnership-print-map-${format.id}-${view.id}.svg`;
link.click();
URL.revokeObjectURL(url);
};
if (error) {
return (
<div className="print-map print-map--message">Failed to load content: {error.message}</div>
);
}
if (!content) {
return <div className="print-map print-map--message">Loading…</div>;
}
return (
<div className="print-map">
<div className="print-map__toolbar">
<label className="print-map__format">
Format
<select value={formatId} onChange={(e) => setFormatId(e.target.value)}>
{FORMAT_PRESETS.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
</label>
<label className="print-map__format">
View
<select value={viewId} onChange={(e) => setViewId(e.target.value)}>
{VIEW_PRESETS.map((v) => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
</select>
</label>
<UIMCButton variant="primary-cta" onClick={handleDownload}>
<ui-marcom-icon slot="prefix" icon="download"></ui-marcom-icon>
Download SVG
</UIMCButton>
</div>
<div className="print-map__canvas">
<svg
ref={svgRef}
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${format.width} ${format.height}`}
width={format.width}
height={format.height}>
<path d={sphereOutline} fill={globeOceanColor} />
<g>
{countryFeatures.map((feature) => (
<path
key={feature.properties.ccn3}
d={pathGenerator(feature)}
fill={countryColorByCcn3.get(feature.properties.ccn3) ?? defaultCountryColor}
stroke={globeLandStrokeColor}
strokeWidth={0.5}
/>
))}
</g>
<g>
{content.points.map((point) => {
if (!isPointVisible(point.lon, point.lat)) return null;
const projected = projectPoint(point.lon, point.lat);
if (!projected) return null;
const [x, y] = projected;
const fill = MARKER_FILLS[point.type] ?? MARKER_FILLS.outcome;
// inlined as a plain <g transform> + <path>/<circle> rather than
// a <symbol>/<use> pair: Illustrator's SVG import drops <use>
// elements that reference a <symbol> with its own viewBox
// (fails to resolve the nested viewport), and Figma resolves
// that same nested transform inconsistently, offsetting and
// mis-scaling every marker. Baking the translate/scale directly
// as an SVG transform attribute (not a CSS transform, which
// neither tool honors as reliably) keeps the file portable.
return (
<g
key={point.id}
transform={`translate(${x - MARKER_WIDTH / 2}, ${y - MARKER_HEIGHT}) scale(${MARKER_WIDTH / 92}, ${MARKER_HEIGHT / 71})`}>
<path d={MARKER_PIN_PATH} fill="white" />
<circle cx="45.9446" cy="24.8094" r="17.7459" fill={fill} />
</g>
);
})}
</g>
</svg>
</div>
<div className="print-map__legend">
<div className="print-map__legend-group">
<h2>Countries</h2>
{countryStates.map(([state, color]) => (
<div key={state} className="print-map__legend-row">
<span className="print-map__legend-swatch" style={{ background: color }} />
{COUNTRY_STATE_LABELS[state] ?? state}
</div>
))}
</div>
<div className="print-map__legend-group">
<h2>Locations</h2>
{Object.entries(MARKER_FILLS).map(([type, fill]) => (
<div key={type} className="print-map__legend-row">
<svg className="print-map__legend-marker" viewBox="0 0 92 71">
<path d={MARKER_PIN_PATH} fill="white" />
<circle cx="45.9446" cy="24.8094" r="17.7459" fill={fill} />
</svg>
{TAG_META[type]?.label ?? type}
</div>
))}
</div>
</div>
</div>
);
};