{"spec_id":"map-projections","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// map-projections: World Map with Different Projections\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-08-26\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Orthographic projection, centered on Africa/Europe so the horizon\n// naturally clips the far side of the globe. --------------------------------\nconst LON0 = 20; // degrees E, view center\nconst LAT0 = 5; // degrees N, view center\nconst LAMBDA0 = (LON0 * Math.PI) / 180;\nconst PHI0 = (LAT0 * Math.PI) / 180;\n\nfunction project(lonDeg, latDeg) {\n  const lon = (lonDeg * Math.PI) / 180;\n  const lat = (latDeg * Math.PI) / 180;\n  const cosC = Math.sin(PHI0) * Math.sin(lat) + Math.cos(PHI0) * Math.cos(lat) * Math.cos(lon - LAMBDA0);\n  if (cosC <= 0.02) return null; // past the horizon — far side of the globe\n  return {\n    x: Math.cos(lat) * Math.sin(lon - LAMBDA0),\n    y: Math.cos(PHI0) * Math.sin(lat) - Math.sin(PHI0) * Math.cos(lat) * Math.cos(lon - LAMBDA0),\n    cosC,\n  };\n}\n\n// Densify a lon/lat polyline before projecting so the horizon clip reads as a\n// smooth curve instead of jumping straight between the last two raw vertices.\nfunction densify(points, steps) {\n  const out = [];\n  for (let i = 0; i < points.length - 1; i++) {\n    const [lon0, lat0] = points[i];\n    const [lon1, lat1] = points[i + 1];\n    for (let s = 0; s < steps; s++) {\n      const f = s / steps;\n      out.push([lon0 + (lon1 - lon0) * f, lat0 + (lat1 - lat0) * f]);\n    }\n  }\n  out.push(points[points.length - 1]);\n  return out;\n}\n\nfunction projectLine(lonLatPairs) {\n  return lonLatPairs.map(([lon, lat]) => {\n    const p = project(lon, lat);\n    return p ? { x: p.x, y: p.y } : { x: null, y: null };\n  });\n}\n\nfunction hexToRgba(hex, alpha) {\n  const n = parseInt(hex.slice(1), 16);\n  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;\n}\n\n// --- Graticule: meridians every 30°, parallels every 30° --------------------\nconst graticuleLines = [];\nfor (let lon = -180; lon < 180; lon += 30) {\n  const pts = [];\n  for (let lat = -88; lat <= 88; lat += 2) pts.push([lon, lat]);\n  graticuleLines.push(projectLine(pts));\n}\nfor (const lat of [-60, -30, 30, 60]) {\n  const pts = [];\n  for (let lon = -180; lon <= 180; lon += 2) pts.push([lon, lat]);\n  graticuleLines.push(projectLine(pts));\n}\nconst equatorPts = [];\nfor (let lon = -180; lon <= 180; lon += 2) equatorPts.push([lon, 0]);\nconst equator = projectLine(equatorPts);\n\n// --- Globe outline: the horizon circle (edge of the visible hemisphere) ----\nconst globeOutline = [];\nfor (let a = 0; a <= 360; a += 2) {\n  const rad = (a * Math.PI) / 180;\n  globeOutline.push({ x: Math.cos(rad), y: Math.sin(rad) });\n}\n\n// --- Simplified continent coastlines (illustrative, not navigational) ------\nconst AFRICA = [\n  [-17, 21], [-16, 15], [-11, 7], [-3, 5], [8, 4], [9, 2], [11, -4], [12, -6], [13, -18],\n  [15, -23], [18, -29], [20, -34], [26, -33], [33, -27], [35, -19], [40, -15], [41, -3],\n  [51, 10], [45, 11], [43, 13], [37, 15], [35, 28], [25, 32], [10, 37], [-1, 35], [-6, 35],\n  [-9, 31], [-17, 21],\n];\nconst EUROPE = [\n  [-9, 37], [-9, 43], [-1, 43], [3, 43], [8, 44], [12, 42], [16, 40], [20, 39], [24, 38],\n  [28, 40], [31, 44], [35, 46], [39, 47], [41, 52], [38, 56], [30, 54], [20, 52], [10, 51],\n  [3, 48], [-1, 47], [-4, 46], [-9, 43],\n];\nconst SOUTH_AMERICA = [\n  [-77, 8], [-70, 10], [-60, 8], [-50, 0], [-35, -8], [-40, -20], [-48, -25], [-57, -35],\n  [-62, -40], [-65, -50], [-68, -55], [-72, -52], [-73, -42], [-71, -30], [-70, -18],\n  [-81, -5], [-80, 2], [-77, 8],\n];\n// Middle East + Indian subcontinent — the largest landmass still missing from\n// this exact viewing hemisphere (centered 20E/5N puts the Arabian Peninsula\n// and India well inside the ~90-degree horizon).\nconst MIDEAST_INDIA = [\n  [35, 33], [37, 21], [43, 13], [52, 13], [59, 23], [56, 27], [61, 25], [66, 24],\n  [69, 22], [72, 19], [74, 12], [77, 8], [80, 13], [84, 17], [87, 21], [92, 22],\n  [88, 27], [80, 30], [72, 33], [65, 35], [60, 33], [48, 34], [38, 34], [35, 33],\n];\n\n// Only continent-level silhouettes are modeled (no country borders) since\n// Chart.js ships no geo/GeoJSON plugin — see prompts/library/chartjs.md.\nconst continents = [AFRICA, EUROPE, SOUTH_AMERICA, MIDEAST_INDIA].map((ring) => projectLine(densify(ring, 8)));\n\n// --- Tissot indicatrices: equal small circles on the globe rendered as\n// ellipses in the plane, showing exactly how orthographic distorts shape and\n// area as points move away from the sub-view point (h = cos c radially,\n// k = 1 tangentially — Snyder's orthographic scale factors). -----------------\nconst TISSOT_LATS = [-60, -30, 0, 30, 60];\nconst TISSOT_LONS = [-150, -90, -30, 30, 90, 150];\nconst TISSOT_ANGULAR_RADIUS = 0.09; // exaggerated for visibility, standard Tissot practice\nconst tissotPoints = [];\nfor (const lat of TISSOT_LATS) {\n  for (const lon of TISSOT_LONS) {\n    const p = project(lon, lat);\n    if (p && p.cosC > 0.2) tissotPoints.push(p);\n  }\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Custom plugin: fill land polygons with a neutral tone before any dataset\n// (graticule/coastline/globe) draws on top, so land reads distinctly from\n// ocean instead of relying on the coastline outline alone. -------------------\nconst landPlugin = {\n  id: \"landFill\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.fillStyle = hexToRgba(t.inkSoft, 0.12);\n    for (const ring of continents) {\n      const pts = ring.filter((p) => p.x !== null && p.y !== null);\n      if (pts.length < 3) continue;\n      ctx.beginPath();\n      pts.forEach((p, i) => {\n        const px = scales.x.getPixelForValue(p.x);\n        const py = scales.y.getPixelForValue(p.y);\n        if (i === 0) ctx.moveTo(px, py);\n        else ctx.lineTo(px, py);\n      });\n      ctx.closePath();\n      ctx.fill();\n    }\n    ctx.restore();\n  },\n};\n\n// --- Custom plugin: draw the Tissot indicatrix ellipses on top of the map --\nconst tissotPlugin = {\n  id: \"tissotIndicatrix\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const scale = Math.abs(scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0));\n    const ox = scales.x.getPixelForValue(0);\n    const oy = scales.y.getPixelForValue(0);\n    ctx.save();\n    ctx.fillStyle = hexToRgba(t.palette[0], 0.22);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    for (const p of tissotPoints) {\n      const px = scales.x.getPixelForValue(p.x);\n      const py = scales.y.getPixelForValue(p.y);\n      const rotation = Math.atan2(py - oy, px - ox);\n      const radial = Math.max(TISSOT_ANGULAR_RADIUS * p.cosC * scale, 1);\n      const tangential = TISSOT_ANGULAR_RADIUS * scale;\n      ctx.beginPath();\n      ctx.ellipse(px, py, radial, tangential, rotation, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.stroke();\n    }\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      ...graticuleLines.map((data) => ({\n        type: \"line\",\n        data,\n        showLine: true,\n        pointRadius: 0,\n        borderColor: t.grid,\n        borderWidth: 1.25,\n        tension: 0,\n        spanGaps: false,\n      })),\n      {\n        type: \"line\",\n        data: equator,\n        showLine: true,\n        pointRadius: 0,\n        borderColor: t.ink,\n        borderWidth: 2,\n        borderDash: [9, 5],\n        tension: 0,\n        spanGaps: false,\n      },\n      ...continents.map((data) => ({\n        type: \"line\",\n        data,\n        showLine: true,\n        pointRadius: 0,\n        borderColor: t.inkSoft,\n        borderWidth: 3,\n        tension: 0,\n        spanGaps: false,\n      })),\n      {\n        type: \"line\",\n        data: globeOutline,\n        showLine: true,\n        pointRadius: 0,\n        borderColor: t.ink,\n        borderWidth: 3,\n        tension: 0,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 24 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"Orthographic Projection · map-projections · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 19 },\n        padding: { top: 4, bottom: 16 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { type: \"linear\", display: false, min: -1.2, max: 1.2 },\n      y: { type: \"linear\", display: false, min: -1.155, max: 1.155 },\n    },\n  },\n  plugins: [landPlugin, tissotPlugin],\n});\n"}