{"spec_id":"map-projections","library":"muix","language":"javascript","code":"// anyplot.ai\n// map-projections: World Map with Different Projections\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 85/100 | Created: 2026-08-26\n//# anyplot-orientation: landscape\n// anyplot.ai\n// map-projections: World Map with Different Projections\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-08-26\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME === \"dark\" ? \"dark\" : \"light\";\n// The community @mui/x-charts token set exposes ink/inkSoft but not a\n// separate \"muted\" anchor — derive it locally (same hex the style guide\n// assigns to the tertiary-text / muted-anchor role) for land fill + captions.\nconst INK_MUTED = THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\nconst TITLE = \"map-projections · javascript · muix · anyplot.ai\";\n\n// --- Simplified world outline (in-memory, deterministic; ~200 hand-traced\n// vertices, not survey-accurate). @mui/x-charts has no polygon/basemap\n// primitive, so continents are plain [lon, lat] point lists that we project\n// ourselves and hand to a MUI X ChartContainer purely for its linear pixel\n// scale + theming — the same \"custom SVG overlay over a chart coordinate\n// system\" technique used for the connection-lines and geographic-hexbin maps,\n// applied through three different non-linear projection functions instead of\n// one flat equirectangular one. --------------------------------------------\nconst CONTINENTS = [\n  {\n    name: \"North America\",\n    points: [\n      [-165, 68], [-165, 60], [-145, 60], [-140, 55], [-130, 54], [-125, 49],\n      [-124, 40], [-118, 34], [-108, 31], [-105, 20], [-97, 16], [-92, 15],\n      [-88, 14], [-84, 10], [-80, 8], [-77, 8], [-82, 22], [-90, 21],\n      [-97, 26], [-97, 29], [-90, 29], [-81, 25], [-80, 32], [-75, 35],\n      [-70, 41], [-67, 45], [-60, 47], [-55, 50], [-65, 58], [-75, 62],\n      [-85, 67], [-95, 69], [-110, 72], [-125, 71], [-140, 70], [-155, 71],\n      [-165, 68],\n    ],\n  },\n  {\n    name: \"Greenland\",\n    points: [\n      [-45, 60], [-52, 61], [-55, 65], [-53, 70], [-45, 75], [-35, 78],\n      [-25, 80], [-20, 77], [-25, 70], [-30, 65], [-38, 61], [-45, 60],\n    ],\n  },\n  {\n    name: \"South America\",\n    points: [\n      [-77, 8], [-72, 1], [-79, -3], [-80, -6], [-81, -15], [-75, -18],\n      [-70, -22], [-68, -30], [-70, -40], [-73, -45], [-72, -52], [-68, -55],\n      [-65, -53], [-62, -45], [-58, -38], [-57, -33], [-48, -26], [-40, -15],\n      [-35, -8], [-38, -4], [-45, 2], [-51, 4], [-60, 9], [-67, 10], [-77, 8],\n    ],\n  },\n  {\n    name: \"Africa\",\n    points: [\n      [-17, 15], [-16, 21], [-10, 30], [-5, 35], [10, 37], [20, 32],\n      [25, 31], [32, 31], [35, 30], [35, 20], [43, 12], [51, 12], [43, 4],\n      [41, -2], [40, -10], [35, -18], [35, -24], [32, -28], [27, -33],\n      [20, -34], [16, -29], [12, -18], [13, -8], [9, 4], [3, 6], [-4, 5],\n      [-9, 5], [-11, 7], [-17, 15],\n    ],\n  },\n  {\n    name: \"Europe\",\n    points: [\n      [-9, 43], [-9, 38], [-5, 36], [3, 36], [9, 44], [8, 44], [10, 45],\n      [13, 45], [13, 42], [18, 40], [20, 40], [24, 35], [26, 40], [28, 41],\n      [30, 45], [38, 45], [40, 44], [45, 42], [48, 46], [45, 50], [40, 55],\n      [35, 60], [30, 65], [25, 70], [20, 68], [14, 66], [8, 58], [10, 54],\n      [8, 54], [5, 51], [3, 51], [-2, 50], [-5, 48], [-1, 46], [-2, 44],\n      [-9, 43],\n    ],\n  },\n  {\n    name: \"Asia\",\n    points: [\n      [30, 45], [38, 45], [45, 42], [48, 46], [55, 50], [60, 55], [65, 60],\n      [70, 65], [80, 72], [100, 75], [120, 73], [140, 70], [160, 68],\n      [170, 65], [180, 66], [178, 60], [165, 55], [158, 53], [145, 45],\n      [140, 42], [130, 35], [122, 31], [120, 23], [110, 20], [108, 10],\n      [104, 1], [103, 1], [100, 5], [98, 8], [95, 15], [90, 22], [88, 22],\n      [80, 10], [77, 8], [73, 20], [68, 24], [61, 25], [57, 26], [52, 29],\n      [48, 30], [42, 29], [35, 30], [35, 32], [36, 36], [37, 37], [42, 37],\n      [45, 39], [42, 40], [35, 37], [30, 37], [27, 37], [28, 41], [30, 45],\n    ],\n  },\n  {\n    name: \"Australia\",\n    points: [\n      [113, -22], [114, -28], [115, -33], [118, -35], [128, -32], [137, -33],\n      [140, -38], [145, -38], [150, -37], [153, -28], [153, -22], [145, -15],\n      [142, -11], [137, -12], [131, -12], [126, -14], [122, -18], [113, -22],\n    ],\n  },\n  {\n    name: \"Antarctica\",\n    points: [\n      [-180, -63], [-150, -66], [-120, -70], [-90, -72], [-60, -70],\n      [-30, -68], [0, -66], [30, -67], [60, -70], [90, -73], [120, -70],\n      [150, -66], [180, -63], [180, -90], [-180, -90], [-180, -63],\n    ],\n  },\n];\n\n// A few well-known, non-disputed border lines (not a full country layer --\n// @mui/x-charts has no geo primitive and hand-tracing all ~200 country\n// borders accurately/neutrally is out of scope) to partially answer the\n// spec's \"country borders\" ask without risking disputed-boundary inaccuracy.\nconst COUNTRY_BORDERS = [\n  {\n    // US-Canada: Pacific coast along the 49th parallel, then the Great\n    // Lakes / St. Lawrence corridor to the Atlantic.\n    points: [\n      [-123, 49], [-110, 49], [-95, 49], [-95, 49.4], [-94.8, 48.8], [-89.5, 48],\n      [-84.5, 46.5], [-83.5, 46], [-82.5, 45.3], [-79.2, 43.3], [-76.5, 44.2],\n      [-75.3, 45], [-71.5, 45], [-70.3, 45.9], [-67.8, 47.1], [-67, 45.1],\n    ],\n  },\n  {\n    // US-Mexico: Pacific coast to the Gulf of Mexico, roughly the Rio Grande.\n    points: [\n      [-117, 32.5], [-114.8, 32.5], [-111, 31.3], [-108.2, 31.3], [-106.5, 31.8],\n      [-104.5, 29.5], [-102.3, 29.9], [-99.5, 27.5], [-97.5, 26],\n    ],\n  },\n];\n\nconst MERIDIANS = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];\nconst PARALLELS = [-60, -30, 0, 30, 60];\nconst TISSOT_LATS = [-60, -30, 0, 30, 60];\nconst TISSOT_LONS = [-150, -90, -30, 30, 90, 150];\nconst TISSOT_RHO_DEG = 6; // angular radius of each reference circle on the sphere\n\n// --- Spherical helpers -------------------------------------------------------\nfunction toRad(deg) {\n  return (deg * Math.PI) / 180;\n}\n\n// Destination point at bearing/angular-distance from a lat/lon origin\n// (great-circle \"direct\" formula) — used to trace Tissot indicatrix circles.\nfunction destPoint(lonDeg, latDeg, bearingRad, rhoRad) {\n  const lat1 = toRad(latDeg);\n  const lon1 = toRad(lonDeg);\n  const lat2 = Math.asin(\n    Math.sin(lat1) * Math.cos(rhoRad) + Math.cos(lat1) * Math.sin(rhoRad) * Math.cos(bearingRad),\n  );\n  const lon2 =\n    lon1 +\n    Math.atan2(\n      Math.sin(bearingRad) * Math.sin(rhoRad) * Math.cos(lat1),\n      Math.cos(rhoRad) - Math.sin(lat1) * Math.sin(lat2),\n    );\n  const lonDeg2 = ((((lon2 * 180) / Math.PI) + 540) % 360) - 180;\n  return [lonDeg2, (lat2 * 180) / Math.PI];\n}\n\nfunction tissotCircle(lonDeg, latDeg, rhoDeg, n) {\n  const rhoRad = toRad(rhoDeg);\n  const pts = [];\n  for (let i = 0; i <= n; i += 1) {\n    pts.push(destPoint(lonDeg, latDeg, (i / n) * 2 * Math.PI, rhoRad));\n  }\n  return pts;\n}\n\n// --- Projections: [lon, lat] in degrees -> [X, Y] in projection units, or\n// null when the point falls on the far side of the globe (orthographic). ---\nconst MERC_LAT_CLIP = 80;\n\nfunction projMercator([lon, lat]) {\n  const clat = Math.max(-MERC_LAT_CLIP, Math.min(MERC_LAT_CLIP, lat));\n  return [toRad(lon), Math.log(Math.tan(Math.PI / 4 + toRad(clat) / 2))];\n}\n\nfunction mollweideTheta(latRad) {\n  if (Math.abs(Math.abs(latRad) - Math.PI / 2) < 1e-9) return Math.sign(latRad) * (Math.PI / 2);\n  let theta = latRad;\n  for (let i = 0; i < 10; i += 1) {\n    const denom = 2 + 2 * Math.cos(2 * theta);\n    if (Math.abs(denom) < 1e-9) break;\n    theta -= (2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(latRad)) / denom;\n  }\n  return theta;\n}\n\nfunction projMollweide([lon, lat]) {\n  const theta = mollweideTheta(toRad(lat));\n  const x = ((2 * Math.SQRT2) / Math.PI) * toRad(lon) * Math.cos(theta);\n  const y = Math.SQRT2 * Math.sin(theta);\n  return [x, y];\n}\n\nconst ORTHO_LON0 = -20;\nconst ORTHO_LAT0 = 15;\n\nfunction projOrthographic([lon, lat]) {\n  const latR = toRad(lat);\n  const lonR = toRad(lon);\n  const lat0R = toRad(ORTHO_LAT0);\n  const lon0R = toRad(ORTHO_LON0);\n  const cosc = Math.sin(lat0R) * Math.sin(latR) + Math.cos(lat0R) * Math.cos(latR) * Math.cos(lonR - lon0R);\n  if (cosc < 0.001) return null;\n  const x = Math.cos(latR) * Math.sin(lonR - lon0R);\n  const y = Math.cos(lat0R) * Math.sin(latR) - Math.sin(lat0R) * Math.cos(latR) * Math.cos(lonR - lon0R);\n  return [x, y];\n}\n\nconst PROJECTIONS = [\n  {\n    key: \"mercator\",\n    name: \"Mercator\",\n    subtitle: \"Conformal — area inflates sharply toward the poles\",\n    project: projMercator,\n  },\n  {\n    key: \"mollweide\",\n    name: \"Mollweide\",\n    subtitle: \"Equal-area — shape stretches near the outer edges\",\n    project: projMollweide,\n  },\n  {\n    key: \"orthographic\",\n    name: \"Orthographic\",\n    subtitle: \"True perspective from space — only one hemisphere shown\",\n    project: projOrthographic,\n  },\n];\n\n// Split a [lon, lat] polyline into runs of consecutively-projectable points,\n// breaking wherever the projection hides a point (orthographic far side).\nfunction toRuns(points, projFn) {\n  const runs = [];\n  let current = [];\n  points.forEach((p) => {\n    const proj = projFn(p);\n    if (proj) {\n      current.push(proj);\n    } else if (current.length > 1) {\n      runs.push(current);\n      current = [];\n    } else {\n      current = [];\n    }\n  });\n  if (current.length > 1) runs.push(current);\n  return runs;\n}\n\nfunction boundaryFor(config) {\n  if (config.key === \"orthographic\") {\n    const pts = [];\n    for (let i = 0; i <= 72; i += 1) {\n      const a = (i / 72) * 2 * Math.PI;\n      pts.push([Math.cos(a), Math.sin(a)]);\n    }\n    return pts;\n  }\n  if (config.key === \"mollweide\") {\n    const pts = [];\n    for (let lat = 90; lat >= -90; lat -= 5) pts.push(projMollweide([180, lat]));\n    for (let lat = -90; lat <= 90; lat += 5) pts.push(projMollweide([-180, lat]));\n    return pts;\n  }\n  const [x0, yTop] = projMercator([-180, MERC_LAT_CLIP]);\n  const [x1, yBottom] = projMercator([180, -MERC_LAT_CLIP]);\n  return [\n    [x0, yTop],\n    [x1, yTop],\n    [x1, yBottom],\n    [x0, yBottom],\n  ];\n}\n\nfunction buildProjectionData(config) {\n  const projFn = config.project;\n  const boundary = boundaryFor(config);\n\n  const continents = CONTINENTS.map((c) => {\n    const runs = toRuns(c.points, projFn);\n    const closed = runs.length === 1 && runs[0].length === c.points.length;\n    return { runs, closed };\n  });\n\n  const graticule = [];\n  MERIDIANS.forEach((lon) => {\n    const pts = [];\n    for (let lat = -80; lat <= 80; lat += 10) pts.push([lon, lat]);\n    graticule.push(toRuns(pts, projFn));\n  });\n  PARALLELS.forEach((lat) => {\n    const pts = [];\n    for (let lon = -180; lon <= 180; lon += 10) pts.push([lon, lat]);\n    graticule.push(toRuns(pts, projFn));\n  });\n\n  const tissots = [];\n  TISSOT_LATS.forEach((lat) => {\n    TISSOT_LONS.forEach((lon) => {\n      if (!projFn([lon, lat])) return; // center on the hidden side — skip\n      const circlePts = tissotCircle(lon, lat, TISSOT_RHO_DEG, 24);\n      const runs = toRuns(circlePts, projFn);\n      if (runs.length === 1 && runs[0].length === circlePts.length) tissots.push(runs[0]);\n    });\n  });\n\n  const borders = COUNTRY_BORDERS.map((b) => toRuns(b.points, projFn));\n\n  return { boundary, continents, graticule, tissots, borders };\n}\n\nfunction bboxOf(points) {\n  const xs = points.map((p) => p[0]);\n  const ys = points.map((p) => p[1]);\n  return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };\n}\n\n// Natural width:height ratio of a projection's own outline (e.g. Mercator's\n// clipped rectangle, Mollweide's wide ellipse, Orthographic's circle) --\n// used to size each panel to its projection instead of forcing every panel\n// into one shared box aspect.\nfunction boundaryAspect(config) {\n  const { minX, maxX, minY, maxY } = bboxOf(boundaryFor(config));\n  return (maxX - minX) / (maxY - minY);\n}\n\n// Fit a data range into a pixel box, expanding the shorter axis so a true\n// circle (orthographic) is never rendered as an ellipse by unequal scaling.\nfunction fitDomain(minX, maxX, minY, maxY, boxW, boxH, padFrac) {\n  const midX = (minX + maxX) / 2;\n  const midY = (minY + maxY) / 2;\n  const boxAspect = boxW / boxH;\n  const dataAspect = (maxX - minX) / (maxY - minY);\n  let halfW;\n  let halfH;\n  if (dataAspect > boxAspect) {\n    halfW = (maxX - minX) / 2 / (1 - 2 * padFrac);\n    halfH = halfW / boxAspect;\n  } else {\n    halfH = (maxY - minY) / 2 / (1 - 2 * padFrac);\n    halfW = halfH * boxAspect;\n  }\n  return { min: [midX - halfW, midY - halfH], max: [midX + halfW, midY + halfH] };\n}\n\nfunction pathFromRuns(runs, closed) {\n  return runs\n    .filter((run) => run.length > 1)\n    .map((run) => {\n      const d = run.map((p, i) => `${i === 0 ? \"M\" : \"L\"} ${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(\" \");\n      return closed ? `${d} Z` : d;\n    })\n    .join(\" \");\n}\n\n// --- Overlay: reads the ChartContainer's own linear scale, so every path\n// lands in the right pixel spot for this panel's projection. ----------------\nfunction GeoOverlay({ data }) {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const toPx = (p) => [xScale(p[0]), yScale(p[1])];\n  const boundaryPx = data.boundary.map(toPx);\n\n  return (\n    <g>\n      <path\n        d={pathFromRuns([boundaryPx], true)}\n        fill={t.elevatedBg}\n        stroke={t.inkSoft}\n        strokeOpacity={0.55}\n        strokeWidth={1.5}\n      />\n      {data.graticule.map((runs, i) => (\n        <path\n          // eslint-disable-next-line react/no-array-index-key\n          key={`grid-${i}`}\n          d={pathFromRuns(runs.map((run) => run.map(toPx)), false)}\n          fill=\"none\"\n          stroke={t.grid}\n          strokeWidth={1}\n        />\n      ))}\n      {data.continents.map((c) => (\n        <path\n          key={c.name}\n          d={pathFromRuns(\n            c.runs.map((run) => run.map(toPx)),\n            c.closed,\n          )}\n          fill={c.closed ? INK_MUTED : \"none\"}\n          fillOpacity={0.32}\n          stroke={INK_MUTED}\n          strokeWidth={1.1}\n        />\n      ))}\n      {data.borders.map((runs, i) => (\n        <path\n          // eslint-disable-next-line react/no-array-index-key\n          key={`border-${i}`}\n          d={pathFromRuns(runs.map((run) => run.map(toPx)), false)}\n          fill=\"none\"\n          stroke={t.ink}\n          strokeOpacity={0.6}\n          strokeWidth={1}\n          strokeDasharray=\"4 3\"\n        />\n      ))}\n      {data.tissots.map((run, i) => (\n        <path\n          // eslint-disable-next-line react/no-array-index-key\n          key={`tissot-${i}`}\n          d={pathFromRuns([run.map(toPx)], true)}\n          fill={t.palette[0]}\n          fillOpacity={0.22}\n          stroke={t.palette[0]}\n          strokeWidth={1.4}\n        />\n      ))}\n    </g>\n  );\n}\n\nfunction ProjectionPanel({ config, width, mapAreaH, headerH }) {\n  const data = buildProjectionData(config);\n  const { minX, maxX, minY, maxY } = bboxOf(data.boundary);\n  const domain = fitDomain(minX, maxX, minY, maxY, width, mapAreaH, 0.06);\n\n  return (\n    <div style={{ width, display: \"flex\", flexDirection: \"column\", alignItems: \"center\" }}>\n      <div style={{ height: headerH, textAlign: \"center\" }}>\n        <div style={{ fontSize: 17, fontWeight: 600, color: t.ink }}>{config.name}</div>\n        <div style={{ fontSize: 14, color: t.inkSoft, marginTop: 2 }}>{config.subtitle}</div>\n      </div>\n      <ChartContainer\n        width={width}\n        height={mapAreaH}\n        margin={{ top: 0, right: 0, bottom: 0, left: 0 }}\n        series={[]}\n        skipAnimation\n        disableAxisListener\n        xAxis={[{ scaleType: \"linear\", min: domain.min[0], max: domain.max[0] }]}\n        yAxis={[{ scaleType: \"linear\", min: domain.min[1], max: domain.max[1] }]}\n      >\n        <GeoOverlay data={data} />\n      </ChartContainer>\n    </div>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  const { width: W, height: H } = window.ANYPLOT_SIZE;\n  const TITLE_H = 56;\n  const CAPTION_H = 42;\n  const OUTER_PAD = 24;\n  const GAP = 22;\n  const PANEL_HEADER_H = 54;\n  const MIN_PANEL_W = 460; // keeps the longest one-line subtitle from wrapping\n\n  // A single 3-across row starves every panel of height: world maps are wide\n  // (~1.3-2:1), so at a third of the canvas width none of them come close to\n  // needing the full leftover row height, leaving 40%+ of the canvas as a\n  // dead band below the maps (VQ-05). Two rows (2 + 1) roughly double the\n  // width each panel gets, which is exactly what wide-format maps need to\n  // grow into the available height instead of leaving it blank.\n  const ROWS = [PROJECTIONS.slice(0, 2), PROJECTIONS.slice(2)];\n  const panelsRowH = H - TITLE_H - CAPTION_H;\n  const rowGap = GAP;\n  const mapAreaH = (panelsRowH - (ROWS.length - 1) * rowGap - ROWS.length * PANEL_HEADER_H) / ROWS.length;\n\n  // Size each panel to its own projection's natural aspect (rather than one\n  // shared box) so the wide Mollweide ellipse and the near-square\n  // Orthographic globe both fill their frame without internal letterboxing.\n  const panelWidth = (config) => Math.max(MIN_PANEL_W, mapAreaH * boundaryAspect(config));\n  const availableW = W - 2 * OUTER_PAD;\n  const maxRowW = Math.max(...ROWS.map((row) => row.reduce((sum, c) => sum + panelWidth(c), 0) + (row.length - 1) * GAP));\n  const widthScale = maxRowW > availableW ? availableW / maxRowW : 1;\n\n  return (\n    <div style={{ width: W, height: H, display: \"flex\", flexDirection: \"column\" }}>\n      <div style={{ height: TITLE_H, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n        <span style={{ fontSize: 22, fontWeight: 500, color: t.ink }}>{TITLE}</span>\n      </div>\n      <div style={{ height: panelsRowH, display: \"flex\", flexDirection: \"column\", gap: rowGap }}>\n        {ROWS.map((row) => (\n          <div\n            key={row.map((c) => c.key).join(\"-\")}\n            style={{\n              height: PANEL_HEADER_H + mapAreaH,\n              display: \"flex\",\n              flexDirection: \"row\",\n              justifyContent: \"center\",\n              gap: GAP,\n            }}\n          >\n            {row.map((config) => (\n              <ProjectionPanel\n                key={config.key}\n                config={config}\n                width={panelWidth(config) * widthScale}\n                mapAreaH={mapAreaH}\n                headerH={PANEL_HEADER_H}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n      <div style={{ height: CAPTION_H, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n        <span style={{ fontSize: 14, color: INK_MUTED }}>\n          Green circles are equal-size reference regions (Tissot indicatrices, ~670 km radius) — their changing\n          shape and area reveal each projection&apos;s distortion.\n        </span>\n      </div>\n    </div>\n  );\n}\n"}