{"spec_id":"map-projections","library":"d3","language":"javascript","code":"// anyplot.ai\n// map-projections: World Map with Different Projections\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst THEME = window.ANYPLOT_THEME;\nconst BRAND = t.palette[0];\nconst MUTED = THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Simplified world landmasses (hand-simplified coastlines, lon/lat rings) ---\n// d3-geo always clips against the antimeridian, which depends on the right-hand\n// winding rule — normalize each hand-authored ring via its signed geoArea rather\n// than trust hand-derived orientation (a flipped ring fills \"everything but the\n// shape\", ~4π−area, instead of the shape itself).\nconst poly = (ring) => {\n  const f = { type: \"Feature\", geometry: { type: \"Polygon\", coordinates: [ring] } };\n  return d3.geoArea(f) > 2 * Math.PI\n    ? { type: \"Feature\", geometry: { type: \"Polygon\", coordinates: [ring.slice().reverse()] } }\n    : f;\n};\n\nconst NORTH_AMERICA = [\n  [-165, 68], [-165, 60], [-150, 58], [-135, 58], [-125, 49], [-124, 40],\n  [-117, 32], [-105, 20], [-97, 16], [-92, 14], [-84, 9], [-77, 8],\n  [-80, 25], [-81, 30], [-75, 35], [-70, 41], [-67, 45], [-60, 47],\n  [-55, 51], [-65, 58], [-75, 62], [-85, 67], [-95, 70], [-110, 72],\n  [-125, 70], [-140, 70], [-155, 71], [-165, 68],\n];\n\nconst SOUTH_AMERICA = [\n  [-77, 8], [-72, -2], [-80, -5], [-81, -15], [-75, -20], [-70, -30],\n  [-71, -40], [-73, -50], [-68, -55], [-65, -53], [-58, -52], [-57, -38],\n  [-48, -25], [-40, -15], [-35, -8], [-40, 0], [-50, 5], [-60, 9],\n  [-70, 10], [-77, 8],\n];\n\nconst GREENLAND = [\n  [-45, 60], [-55, 65], [-55, 70], [-45, 75], [-30, 75], [-20, 70],\n  [-25, 65], [-40, 60], [-45, 60],\n];\n\nconst EURASIA = [\n  [-9, 43], [-5, 48], [-1, 51], [5, 51], [8, 54], [10, 57], [5, 58],\n  [6, 62], [10, 66], [20, 70], [30, 70], [40, 68], [60, 68], [70, 72],\n  [90, 73], [110, 73], [130, 72], [140, 70], [160, 68], [170, 65],\n  [178, 65], [178, 60], [160, 55], [155, 50], [142, 45], [135, 42],\n  [122, 30], [115, 22], [108, 15], [103, 5], [98, 10], [92, 20],\n  [80, 15], [77, 8], [72, 20], [68, 24], [60, 25], [56, 26], [50, 30],\n  [45, 15], [43, 12], [40, 15], [35, 29], [33, 31], [28, 36], [22, 38],\n  [15, 40], [5, 43], [-2, 37], [-9, 37], [-9, 43],\n];\n\nconst AFRICA = [\n  [-17, 15], [-16, 12], [-10, 10], [-5, 5], [5, 4], [8, 2], [9, -2],\n  [12, -6], [13, -13], [12, -18], [15, -22], [18, -27], [20, -30],\n  [25, -34], [30, -30], [33, -25], [35, -20], [40, -15], [42, -10],\n  [44, -2], [42, 4], [45, 10], [43, 12], [38, 15], [35, 20], [32, 25],\n  [30, 31], [25, 32], [10, 37], [-6, 35], [-10, 33], [-15, 25],\n  [-17, 20], [-17, 15],\n];\n\nconst MADAGASCAR = [[43, -12], [47, -16], [47, -22], [45, -25], [44, -20], [43, -16], [43, -12]];\n\nconst AUSTRALIA = [\n  [113, -22], [114, -28], [115, -32], [118, -35], [122, -34], [126, -32],\n  [129, -31], [132, -32], [136, -35], [140, -38], [143, -39], [146, -38],\n  [150, -37], [150, -33], [153, -28], [153, -25], [150, -22], [147, -19],\n  [145, -16], [142, -11], [137, -12], [132, -12], [129, -15], [126, -14],\n  [123, -17], [121, -18], [117, -20], [113, -22],\n];\n\nconst NEW_ZEALAND = [[172, -41], [174, -42], [178, -40], [177, -37], [174, -36], [172, -38], [172, -41]];\n\nconst JAPAN = [\n  [130, 31], [132, 33], [135, 34], [138, 35], [140, 36], [141, 38],\n  [141, 40], [140, 43], [142, 45], [144, 41], [141, 39], [140, 37],\n  [136, 35], [133, 34], [131, 33], [130, 31],\n];\n\nconst BRITISH_ISLES = [[-8, 51], [-6, 52], [-5, 55], [-6, 58], [-3, 58], [-1, 55], [0, 52], [1, 51], [-1, 50], [-5, 50], [-8, 51]];\n\nconst ANTARCTICA = [\n  [-180, -63], [-150, -65], [-120, -66], [-90, -68], [-60, -70], [-30, -72],\n  [0, -74], [30, -72], [60, -70], [90, -68], [120, -66], [150, -65],\n  [180, -63], [180, -90], [-180, -90], [-180, -63],\n];\n\n// Mercator has no pole-aware clipping (unlike .clipAngle() on Orthographic), so a\n// ring enclosing the south-pole singularity renders inverted once .clipExtent()\n// clips it to a finite rectangle. Clamp Antarctica's southern edge to the same\n// latitude as mercatorBounds (-82) for the Mercator panel so the ring never spans\n// the pole there.\nconst ANTARCTICA_MERCATOR = [\n  [-180, -63], [-150, -65], [-120, -66], [-90, -68], [-60, -70], [-30, -72],\n  [0, -74], [30, -72], [60, -70], [90, -68], [120, -66], [150, -65],\n  [180, -63], [180, -82], [-180, -82], [-180, -63],\n];\n\nconst baseLandRings = [\n  NORTH_AMERICA, SOUTH_AMERICA, GREENLAND, EURASIA, AFRICA, MADAGASCAR,\n  AUSTRALIA, NEW_ZEALAND, JAPAN, BRITISH_ISLES,\n];\n\nconst landFeatures = [...baseLandRings, ANTARCTICA].map(poly);\nconst landFeaturesMercator = [...baseLandRings, ANTARCTICA_MERCATOR].map(poly);\n\n// --- Graticule + Tissot indicatrices (equal angular-radius circles, reveal distortion) ---\nconst graticule = d3.geoGraticule().step([30, 30]);\n\nconst tissotLons = [-135, -90, -45, 0, 45, 90, 135];\nconst tissotLats = [-60, -30, 0, 30, 60];\nconst tissotCenters = tissotLons.flatMap((lon) => tissotLats.map((lat) => [lon, lat]));\nconst tissotFeatures = tissotCenters.map(([lon, lat]) => d3.geoCircle().center([lon, lat]).radius(6)());\n\nconst mercatorBounds = { type: \"Polygon\", coordinates: [[[-180, -82], [180, -82], [180, 82], [-180, 82], [-180, -82]]] };\n\n// --- Panel layout: three projections side by side ---\nconst panels = [\n  {\n    key: \"mercator\", name: \"Mercator\",\n    note: \"Conformal — true shapes, area inflated near poles\",\n    make: () => d3.geoMercator(),\n  },\n  {\n    key: \"orthographic\", name: \"Orthographic\",\n    note: \"True-globe view — one hemisphere, edges compressed\",\n    make: () => d3.geoOrthographic().clipAngle(90).rotate([-15, -20, 0]),\n  },\n  {\n    key: \"equalearth\", name: \"Equal Earth\",\n    note: \"Equal-area — true sizes, shapes stretch at poles\",\n    make: () => d3.geoEqualEarth(),\n  },\n];\n\nconst marginTop = 96;\nconst captionH = 62;\nconst bottomPad = 18;\nconst outerX = 46;\nconst gap = 36;\nconst panelAreaTop = marginTop;\nconst panelAreaBottom = height - captionH - bottomPad;\nconst panelW = (width - 2 * outerX - 2 * gap) / 3;\n\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\npanels.forEach((p, i) => {\n  const x0 = outerX + i * (panelW + gap);\n  const x1 = x0 + panelW;\n  const box = [[x0 + 16, panelAreaTop + 16], [x1 - 16, panelAreaBottom - 16]];\n\n  const projection = p.make();\n  let path;\n  if (p.key === \"mercator\") {\n    projection.fitExtent(box, mercatorBounds);\n    projection.clipExtent([[x0, panelAreaTop], [x1, panelAreaBottom]]);\n    path = d3.geoPath(projection);\n  } else {\n    projection.fitExtent(box, { type: \"Sphere\" });\n    path = d3.geoPath(projection);\n  }\n\n  const g = svg.append(\"g\").attr(\"class\", `panel-${p.key}`);\n\n  // Panel background / outline\n  if (p.key === \"mercator\") {\n    g.append(\"rect\")\n      .attr(\"x\", x0).attr(\"y\", panelAreaTop)\n      .attr(\"width\", panelW).attr(\"height\", panelAreaBottom - panelAreaTop)\n      .attr(\"fill\", t.elevatedBg).attr(\"stroke\", t.inkSoft)\n      .attr(\"stroke-width\", 1.2).attr(\"opacity\", 0.6);\n  } else {\n    g.append(\"path\").datum({ type: \"Sphere\" }).attr(\"d\", path)\n      .attr(\"fill\", t.elevatedBg).attr(\"stroke\", t.inkSoft)\n      .attr(\"stroke-width\", 1.2).attr(\"opacity\", 0.6);\n  }\n\n  // Graticule (30° lat/lon grid)\n  g.append(\"path\").datum(graticule()).attr(\"d\", path)\n    .attr(\"fill\", \"none\").attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\n  // Landmasses (neutral fill — not a data-driven color)\n  const landData = p.key === \"mercator\" ? landFeaturesMercator : landFeatures;\n  g.selectAll(\"path.land\").data(landData).join(\"path\")\n    .attr(\"class\", \"land\").attr(\"d\", path)\n    .attr(\"fill\", MUTED).attr(\"fill-opacity\", 0.55)\n    .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 0.5);\n\n  // Tissot indicatrices — equal angular radius on the globe, distortion is visible\n  // as they become unequal ellipses under each projection\n  g.selectAll(\"path.tissot\").data(tissotFeatures).join(\"path\")\n    .attr(\"class\", \"tissot\").attr(\"d\", path)\n    .attr(\"fill\", BRAND).attr(\"fill-opacity\", 0.3)\n    .attr(\"stroke\", BRAND).attr(\"stroke-width\", 1).attr(\"stroke-opacity\", 0.85);\n\n  // Panel caption\n  const cx = (x0 + x1) / 2;\n  g.append(\"text\")\n    .attr(\"x\", cx).attr(\"y\", panelAreaBottom + 26).attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink).style(\"font-size\", \"17px\").style(\"font-weight\", \"700\")\n    .text(p.name);\n  g.append(\"text\")\n    .attr(\"x\", cx).attr(\"y\", panelAreaBottom + 46).attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\").style(\"font-style\", \"italic\")\n    .text(p.note);\n});\n\n// Title (scaled for length) + subtitle\nconst titleStr = \"World Map Projections Compared · map-projections · javascript · d3 · anyplot.ai\";\nconst titleSize = Math.max(14, Math.round(22 * Math.min(1, 67 / titleStr.length)));\n\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 40).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", `${titleSize}px`).style(\"font-weight\", \"600\")\n  .text(titleStr);\n\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 66).attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(\"Same-angular-radius circles (Tissot indicatrices) reveal how each projection distorts shape and area\");\n"}