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