{"spec_id":"map-projections","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// map-projections: World Map with Different Projections\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-08-26\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n// Subtle top-to-bottom gradient instead of a flat fill — a small texture cue\n// that still reads as \"neutral land color\" per the spec, using a Highcharts\n// gradient color object (a renderer-native feature, not a plain SVG attr).\nconst LAND_FILL = {\n  linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },\n  stops:\n    t.theme === \"dark\"\n      ? [\n          [0, \"rgba(240,239,232,0.22)\"],\n          [1, \"rgba(240,239,232,0.10)\"],\n        ]\n      : [\n          [0, \"rgba(26,26,23,0.16)\"],\n          [1, \"rgba(26,26,23,0.08)\"],\n        ],\n};\n\n// --- Projections (computed manually) ---------------------------------------\n// Only the core Highcharts bundle is loaded (no highmaps / modules — see\n// prompts/library/highcharts.md), so there is no Maps coordinate system to\n// draw into. Instead the projection math runs here, in plain JS, and the\n// result is drawn with the core SVGRenderer (`chart.renderer`), the same\n// public API Highcharts itself uses for custom shapes and annotations.\n\n// Mercator: conformal, unbounded area distortion toward the poles.\n// Clamped to +/-85 deg — the same clip web-Mercator tiles use, which also\n// happens to make the projected extent square.\nfunction mercatorProject(lonDeg, latDeg) {\n  const lon = (lonDeg * Math.PI) / 180;\n  const lat = (Math.max(-85, Math.min(85, latDeg)) * Math.PI) / 180;\n  return [lon, Math.log(Math.tan(Math.PI / 4 + lat / 2))];\n}\n\n// Mollweide: pseudocylindrical equal-area projection onto an ellipse. The\n// auxiliary angle theta has no closed form — solved with fixed-iteration\n// Newton-Raphson (10 steps is stable for all latitudes, no seeded RNG needed).\nfunction mollweideTheta(latRad) {\n  if (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++) {\n    theta -= (2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(latRad)) / (2 + 2 * Math.cos(2 * theta));\n  }\n  return theta;\n}\nfunction mollweideProject(lonDeg, latDeg) {\n  const lon = (lonDeg * Math.PI) / 180;\n  const lat = (latDeg * Math.PI) / 180;\n  const theta = mollweideTheta(lat);\n  return [((2 * Math.SQRT2) / Math.PI) * lon * Math.cos(theta), Math.SQRT2 * Math.sin(theta)];\n}\n\n// Orthographic: azimuthal perspective as seen from infinity — shape and\n// scale are only true at the view center, with distortion growing toward the\n// limb, and only one hemisphere is visible at all (the far side is behind\n// the globe). Centered on the Africa/Europe/Atlantic quadrant so the visible\n// hemisphere still carries several recognizable landmasses.\nconst ORTHO_LON0 = (10 * Math.PI) / 180;\nconst ORTHO_LAT0 = (15 * Math.PI) / 180;\nfunction orthoVisible(lonDeg, latDeg) {\n  const lon = (lonDeg * Math.PI) / 180;\n  const lat = (latDeg * Math.PI) / 180;\n  return Math.sin(ORTHO_LAT0) * Math.sin(lat) + Math.cos(ORTHO_LAT0) * Math.cos(lat) * Math.cos(lon - ORTHO_LON0) >= 0;\n}\nfunction orthoProject(lonDeg, latDeg) {\n  const lon = (lonDeg * Math.PI) / 180;\n  const lat = (latDeg * Math.PI) / 180;\n  return [\n    Math.cos(lat) * Math.sin(lon - ORTHO_LON0),\n    Math.cos(ORTHO_LAT0) * Math.sin(lat) - Math.sin(ORTHO_LAT0) * Math.cos(lat) * Math.cos(lon - ORTHO_LON0),\n  ];\n}\n\n// --- Data: simplified continent silhouettes (in-memory, deterministic) -----\n// Coarse landmass outlines for illustration, not survey-grade GIS boundaries.\nconst CONTINENTS = [\n  { ring: [[-165, 68], [-140, 70], [-125, 49], [-124, 40], [-117, 32], [-105, 20], [-97, 16], [-90, 14],\n           [-81, 25], [-75, 35], [-70, 41], [-65, 45], [-60, 50], [-65, 60], [-80, 62], [-95, 68],\n           [-110, 70], [-130, 70], [-150, 70], [-165, 68]] }, // North America\n  { ring: [[-80, 10], [-77, 5], [-70, -5], [-70, -18], [-68, -30], [-70, -40], [-73, -50], [-68, -55],\n           [-65, -52], [-58, -38], [-48, -25], [-35, -8], [-50, 0], [-60, 5], [-70, 10], [-80, 10]] }, // South America\n  { ring: [[-17, 15], [-16, 20], [-10, 30], [0, 35], [10, 37], [20, 32], [32, 31], [35, 28], [43, 12],\n           [51, 12], [45, 0], [40, -10], [35, -22], [32, -28], [25, -34], [18, -34], [15, -25], [12, -18],\n           [9, 5], [-5, 5], [-17, 15]] }, // Africa\n  { ring: [[-9, 36], [-9, 44], [0, 49], [10, 54], [20, 60], [30, 68], [40, 70], [60, 72], [80, 75],\n           [100, 77], [120, 73], [140, 65], [150, 60], [145, 45], [140, 35], [130, 30], [122, 25],\n           [110, 20], [100, 10], [95, 5], [80, 8], [70, 20], [60, 25], [50, 25], [45, 15], [36, 20],\n           [30, 32], [26, 35], [20, 40], [10, 38], [0, 38], [-9, 36]] }, // Eurasia\n  { ring: [[113, -22], [115, -33], [118, -35], [130, -32], [137, -35], [145, -38], [150, -37], [153, -28],\n           [150, -22], [145, -16], [137, -12], [130, -12], [122, -18], [113, -22]] }, // Australia\n  { ring: [[-45, 60], [-55, 65], [-65, 70], [-60, 76], [-45, 82], [-30, 80], [-25, 72], [-30, 65],\n           [-40, 61], [-45, 60]] }, // Greenland\n];\n\n// Graticule — 30 deg meridians clipped at +/-85, 30 deg parallels, sampled\n// densely so curved meridians (Mollweide, Orthographic) render smoothly.\nfunction sampleMeridian(lon, latFrom, latTo, step) {\n  const pts = [];\n  for (let lat = latFrom; lat < latTo; lat += step) pts.push([lon, lat]);\n  pts.push([lon, latTo]);\n  return pts;\n}\nfunction sampleParallel(lat, lonFrom, lonTo, step) {\n  const pts = [];\n  for (let lon = lonFrom; lon < lonTo; lon += step) pts.push([lon, lat]);\n  pts.push([lonTo, lat]);\n  return pts;\n}\nconst GRATICULE_LINES = [];\n[-180, -150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150, 180].forEach((lon) =>\n  GRATICULE_LINES.push(sampleMeridian(lon, -85, 85, 5))\n);\n[-60, -30, 0, 30, 60].forEach((lat) => GRATICULE_LINES.push(sampleParallel(lat, -180, 180, 10)));\n\n// Splits a lon/lat polyline into the runs that pass a pane's visibility test\n// (Orthographic only — Mercator/Mollweide show the whole globe, so every\n// point is visible there). The far side of the globe projects into the same\n// disk as the near side, so a shape crossing the horizon must break into\n// separate paths instead of connecting straight across it.\nfunction visibleRuns(pointsLonLat, closed, isVisible) {\n  const n = pointsLonLat.length;\n  const vis = pointsLonLat.map((p) => isVisible(p[0], p[1]));\n  const runs = [];\n  let current = [];\n  for (let i = 0; i < n; i++) {\n    if (vis[i]) current.push(pointsLonLat[i]);\n    else if (current.length) {\n      runs.push(current);\n      current = [];\n    }\n  }\n  if (current.length) runs.push(current);\n  if (closed && runs.length > 1 && vis[0] && vis[n - 1]) {\n    const first = runs.shift();\n    runs[runs.length - 1] = runs[runs.length - 1].concat(first);\n  }\n  return runs;\n}\n\n// Tissot indicatrices — small spherical circles (angular radius 6 deg) at\n// graticule intersections, drawn through each projection. A true circle on\n// the globe comes out looking different in each projection: that visual\n// difference *is* the projection's distortion.\nfunction tissotCircle(lon0, lat0, radiusDeg, steps) {\n  const pts = [];\n  const cosLat0 = Math.cos((lat0 * Math.PI) / 180);\n  for (let i = 0; i <= steps; i++) {\n    const bearing = (i / steps) * 2 * Math.PI;\n    const lat = lat0 + radiusDeg * Math.cos(bearing);\n    const lon = lon0 + (radiusDeg * Math.sin(bearing)) / cosLat0;\n    pts.push([lon, lat]);\n  }\n  return pts;\n}\nconst TISSOT_CENTERS = [];\n[-60, -30, 0, 30, 60].forEach((lat) =>\n  [-150, -90, -30, 30, 90, 150].forEach((lon) => TISSOT_CENTERS.push([lon, lat]))\n);\n// Orthographic only shows one hemisphere — pre-filter to centers that are\n// actually visible from the chosen viewpoint, so we don't draw circles that\n// would be entirely behind the globe.\nconst ORTHO_TISSOT_CENTERS = TISSOT_CENTERS.filter(([lon, lat]) => orthoVisible(lon, lat));\n\nfunction buildEllipse(rx, ry, steps) {\n  const pts = [];\n  for (let i = 0; i <= steps; i++) {\n    const angle = (i / steps) * 2 * Math.PI;\n    pts.push([rx * Math.cos(angle), ry * Math.sin(angle)]);\n  }\n  return pts;\n}\n\n// --- Pane layout (CSS px, mount is 1600x900) --------------------------------\n// Two rows so the full canvas height is used: Mercator + Mollweide share the\n// top row (conformal vs. equal-area contrast), Orthographic sits alone below\n// (perspective/limb-distortion contrast, and only shows a single hemisphere).\nconst UNIT = 290;\nconst ROW_GAP = 50;\nconst ROW1_TOP = 140;\nconst ROW2_TOP = 500;\nconst ROW1_WIDTH = UNIT + ROW_GAP + 2 * UNIT; // Mercator + gap + Mollweide (2x wide)\nconst ROW1_LEFT = (1600 - ROW1_WIDTH) / 2;\nconst LAYOUT = {\n  mercator: { left: ROW1_LEFT, top: ROW1_TOP, width: UNIT, height: UNIT },\n  mollweide: { left: ROW1_LEFT + UNIT + ROW_GAP, top: ROW1_TOP, width: 2 * UNIT, height: UNIT },\n  orthographic: { left: (1600 - UNIT) / 2, top: ROW2_TOP, width: UNIT, height: UNIT },\n};\nconst PAD = 0.08;\nconst MERCATOR_Y_MAX = mercatorProject(0, 85)[1];\nconst MOLLWEIDE_X_MAX = 2 * Math.SQRT2;\nconst MOLLWEIDE_Y_MAX = Math.SQRT2;\n\nconst PANES = [\n  {\n    key: \"mercator\",\n    name: \"Mercator (conformal)\",\n    axisIdx: 0,\n    ...LAYOUT.mercator,\n    project: mercatorProject,\n    xMax: Math.PI,\n    yMax: MERCATOR_Y_MAX,\n    tissotCenters: TISSOT_CENTERS,\n    boundaryXY: [\n      [-Math.PI, MERCATOR_Y_MAX],\n      [Math.PI, MERCATOR_Y_MAX],\n      [Math.PI, -MERCATOR_Y_MAX],\n      [-Math.PI, -MERCATOR_Y_MAX],\n    ],\n  },\n  {\n    key: \"mollweide\",\n    name: \"Mollweide (equal-area)\",\n    axisIdx: 1,\n    ...LAYOUT.mollweide,\n    project: mollweideProject,\n    xMax: MOLLWEIDE_X_MAX,\n    yMax: MOLLWEIDE_Y_MAX,\n    tissotCenters: TISSOT_CENTERS,\n    boundaryXY: buildEllipse(MOLLWEIDE_X_MAX, MOLLWEIDE_Y_MAX, 72),\n  },\n  {\n    key: \"orthographic\",\n    name: \"Orthographic (true at center)\",\n    axisIdx: 2,\n    ...LAYOUT.orthographic,\n    project: orthoProject,\n    visible: orthoVisible,\n    xMax: 1,\n    yMax: 1,\n    tissotCenters: ORTHO_TISSOT_CENTERS,\n    boundaryXY: buildEllipse(1, 1, 72),\n  },\n];\n\n// --- Drawing (core SVGRenderer, run once the chart + axes are ready) -------\nfunction drawPane(chart, pane) {\n  const xAxis = chart.xAxis[pane.axisIdx];\n  const yAxis = chart.yAxis[pane.axisIdx];\n  const renderer = chart.renderer;\n  const group = renderer.g(\"pane-\" + pane.key).add();\n  group.clip(renderer.clipRect(pane.left, pane.top, pane.width, pane.height));\n\n  function pathFromXY(pointsXY, close) {\n    const path = [];\n    pointsXY.forEach((p, i) => {\n      path.push(i === 0 ? \"M\" : \"L\", xAxis.toPixels(p[0], false), yAxis.toPixels(p[1], false));\n    });\n    if (close) path.push(\"Z\");\n    return path;\n  }\n  // Draws a lon/lat shape, splitting it into visible runs first when the\n  // pane only shows one hemisphere (Orthographic).\n  function drawShape(pointsLonLat, close, attrs) {\n    const runs = pane.visible ? visibleRuns(pointsLonLat, close, pane.visible) : [pointsLonLat];\n    runs.forEach((run) => {\n      if (run.length < 2) return;\n      const xy = run.map((p) => pane.project(p[0], p[1]));\n      renderer.path(pathFromXY(xy, close)).attr(attrs).add(group);\n    });\n  }\n\n  renderer\n    .path(pathFromXY(pane.boundaryXY, true))\n    .attr({ fill: \"none\", stroke: t.inkSoft, \"stroke-width\": 1.5, opacity: 0.6 })\n    .add(group);\n\n  GRATICULE_LINES.forEach((line) => {\n    drawShape(line, false, { fill: \"none\", stroke: t.grid, \"stroke-width\": 1 });\n  });\n\n  CONTINENTS.forEach((continent) => {\n    drawShape(continent.ring, true, { fill: LAND_FILL, stroke: t.inkSoft, \"stroke-width\": 1, opacity: 0.9 });\n  });\n\n  pane.tissotCenters.forEach(([lon0, lat0]) => {\n    drawShape(tissotCircle(lon0, lat0, 6, 24), true, {\n      fill: t.palette[0],\n      \"fill-opacity\": 0.32,\n      stroke: t.palette[0],\n      \"stroke-width\": 1.2,\n    });\n  });\n\n  renderer\n    .text(pane.name, pane.left + pane.width / 2, pane.top - 14)\n    .attr({ align: \"center\" })\n    .css({ color: t.inkSoft, fontSize: \"15px\", fontWeight: \"600\" })\n    .add();\n}\n\nfunction drawLegend(chart) {\n  const renderer = chart.renderer;\n  const y = 835;\n  let x = 460;\n  [\n    { shape: \"square\", swatch: LAND_FILL, stroke: t.inkSoft, label: \"Landmass (simplified coastline)\" },\n    // Drawn with the circle it actually represents (renderer.circle, the\n    // same symbol primitive Highcharts uses for point markers) instead of a\n    // generic square swatch.\n    { shape: \"circle\", swatch: t.palette[0], stroke: t.palette[0], label: \"Tissot indicatrix — angular distortion\" },\n  ].forEach((item) => {\n    if (item.shape === \"circle\") {\n      renderer.circle(x + 9, y + 9, 9).attr({ fill: item.swatch, stroke: item.stroke, \"stroke-width\": 1 }).add();\n    } else {\n      renderer.rect(x, y, 18, 18, 2).attr({ fill: item.swatch, stroke: item.stroke, \"stroke-width\": 1 }).add();\n    }\n    const label = renderer\n      .text(item.label, x + 26, y + 14)\n      .css({ color: t.inkSoft, fontSize: \"14px\" })\n      .add();\n    x += 26 + label.getBBox().width + 44;\n  });\n}\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    spacing: [10, 10, 10, 10],\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load: function () {\n        PANES.forEach((pane) => drawPane(this, pane));\n        drawLegend(this);\n        window.__anyplotReady = true;\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"map-projections · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Tissot indicatrices show how each projection distorts shape and area · coastlines simplified for illustration\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: PANES.map((pane) => ({\n    min: -pane.xMax * (1 + PAD),\n    max: pane.xMax * (1 + PAD),\n    left: pane.left + \"px\",\n    top: pane.top + \"px\",\n    width: pane.width + \"px\",\n    height: pane.height + \"px\",\n    gridLineWidth: 0,\n    lineWidth: 0,\n    tickLength: 0,\n    labels: { enabled: false },\n    title: { text: null },\n    startOnTick: false,\n    endOnTick: false,\n  })),\n  yAxis: PANES.map((pane) => ({\n    min: -pane.yMax * (1 + PAD),\n    max: pane.yMax * (1 + PAD),\n    left: pane.left + \"px\",\n    top: pane.top + \"px\",\n    width: pane.width + \"px\",\n    height: pane.height + \"px\",\n    gridLineWidth: 0,\n    lineWidth: 0,\n    tickLength: 0,\n    labels: { enabled: false },\n    title: { text: null },\n    startOnTick: false,\n    endOnTick: false,\n  })),\n  legend: { enabled: false },\n  tooltip: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  series: [{ xAxis: 0, yAxis: 0, data: [] }],\n});\n"}