{"spec_id":"scatter-map-geographic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// scatter-map-geographic: Scatter Map with Geographic Points\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Synthetic earthquake epicenters traced along the Alpide seismic belt\n// (Iberia -> Anatolia -> Himalaya -> Sunda arc). Only the core Highcharts\n// bundle is loaded (no highmaps module), so geographic context comes from a\n// simplified landmass outline drawn by hand with SVGRenderer (see\n// \"Geographic context\" below) rather than a rendered map projection.\nfunction lcg(seed) {\n  let s = seed >>> 0;\n  return () => {\n    s = (1103515245 * s + 12345) >>> 0;\n    return s / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst ANCHORS = [\n  { lon: -9, lat: 38, depth: 20 }, // Iberia\n  { lon: 14, lat: 41, depth: 15 }, // Italy\n  { lon: 29, lat: 39, depth: 25 }, // Anatolia\n  { lon: 48, lat: 34, depth: 35 }, // Zagros\n  { lon: 71, lat: 33, depth: 70 }, // Hindu Kush\n  { lon: 85, lat: 28, depth: 20 }, // Himalaya\n  { lon: 96, lat: 21, depth: 90 }, // Myanmar arc\n  { lon: 106, lat: 2, depth: 160 }, // Sumatra\n  { lon: 120, lat: -6, depth: 210 }, // Java-Banda\n  { lon: 135, lat: -3, depth: 280 }, // Banda deep\n];\n\nconst MIN_MAG = 4.0;\nconst MAX_MAG = 7.8;\nconst MIN_DEPTH = 5;\nconst MAX_DEPTH = 300;\nconst POINT_COUNT = 170;\n\nconst epicenters = [];\nfor (let i = 0; i < POINT_COUNT; i++) {\n  const along = (rand() * 0.9 + i / POINT_COUNT * 0.1 * (POINT_COUNT - 1) / POINT_COUNT) % 1;\n  const span = along * (ANCHORS.length - 1);\n  const idx = Math.min(Math.floor(span), ANCHORS.length - 2);\n  const localT = span - idx;\n  const a = ANCHORS[idx];\n  const b = ANCHORS[idx + 1];\n\n  const lonJitter = (rand() - 0.5) * 8;\n  const latJitter = (rand() - 0.5) * 6;\n  const depthJitter = (rand() - 0.5) * 60;\n\n  const lon = a.lon + (b.lon - a.lon) * localT + lonJitter;\n  const lat = a.lat + (b.lat - a.lat) * localT + latJitter;\n  const depthBase = a.depth + (b.depth - a.depth) * localT;\n  const depth = Math.min(MAX_DEPTH, Math.max(MIN_DEPTH, depthBase + depthJitter));\n\n  // Gutenberg-Richter-like skew: many small quakes, few large ones.\n  const magnitude = Math.min(MAX_MAG, MIN_MAG + -Math.log(1 - rand() * 0.98) * 0.55);\n\n  epicenters.push({ lon, lat, magnitude, depth });\n}\n\n// --- Helpers: size + color encoding -----------------------------------------\nfunction sizeForMagnitude(magnitude) {\n  const frac = (magnitude - MIN_MAG) / (MAX_MAG - MIN_MAG);\n  return 4 + frac * 16;\n}\n\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction lerpColor(hexA, hexB, frac) {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  const rgb = a.map((c, i) => Math.round(c + (b[i] - c) * frac));\n  return `#${rgb.map((v) => v.toString(16).padStart(2, \"0\")).join(\"\")}`;\n}\n\nfunction colorForDepth(depth, alpha) {\n  const frac = (depth - MIN_DEPTH) / (MAX_DEPTH - MIN_DEPTH);\n  const hex = lerpColor(t.seq[0], t.seq[1], Math.min(1, Math.max(0, frac)));\n  const [r, g, b] = hexToRgb(hex);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// The Java-Banda segment of the belt (idx 8-9 in ANCHORS) packs the most\n// points into the smallest lon/lat span, so circles blur into a blob there\n// even with alpha blending. Shrink + lighten markers inside that box only.\nconst DENSE_ZONE = { lonMin: 100, lonMax: 132, latMin: -12, latMax: 4 };\nfunction inDenseZone(lon, lat) {\n  return lon >= DENSE_ZONE.lonMin && lon <= DENSE_ZONE.lonMax && lat >= DENSE_ZONE.latMin && lat <= DENSE_ZONE.latMax;\n}\n\n// --- Chart -------------------------------------------------------------------\nconst epicenterSeries = {\n  type: \"scatter\",\n  name: \"Epicenters\",\n  showInLegend: false,\n  data: epicenters.map((e) => {\n    const dense = inDenseZone(e.lon, e.lat);\n    return {\n      x: e.lon,\n      y: e.lat,\n      custom: { magnitude: e.magnitude, depth: e.depth },\n      marker: {\n        radius: sizeForMagnitude(e.magnitude) * (dense ? 0.7 : 1),\n        fillColor: colorForDepth(e.depth, dense ? 0.55 : 0.78),\n        lineColor: t.pageBg,\n        lineWidth: 1,\n      },\n    };\n  }),\n};\n\n// Dummy legend-only series — a standard Highcharts technique for a manual\n// legend (no interactive module is required; the swatches use the exact same\n// colorForDepth / sizeForMagnitude formulas the real points use).\n// Representative depths pushed toward each bin's extreme (rather than its\n// midpoint) so the three swatches span a wider slice of the imprint_seq\n// gradient and stay visually distinguishable at a glance.\nconst depthLegend = [\n  { label: \"Depth 5–80 km\", depth: 10 },\n  { label: \"Depth 80–150 km\", depth: 145 },\n  { label: \"Depth 150–300 km\", depth: 295 },\n].map((bin) => ({\n  type: \"scatter\",\n  name: bin.label,\n  showInLegend: true,\n  enableMouseTracking: false,\n  data: [],\n  marker: { radius: 9, fillColor: colorForDepth(bin.depth, 1), lineColor: t.pageBg, lineWidth: 1 },\n}));\n\nconst magnitudeLegend = [\n  { label: \"Magnitude 4.5\", magnitude: 4.5 },\n  { label: \"Magnitude 6.0\", magnitude: 6.0 },\n  { label: \"Magnitude 7.5\", magnitude: 7.5 },\n].map((bin) => ({\n  type: \"scatter\",\n  name: bin.label,\n  showInLegend: true,\n  enableMouseTracking: false,\n  data: [],\n  marker: { radius: sizeForMagnitude(bin.magnitude), fillColor: t.palette[0], lineColor: t.pageBg, lineWidth: 1 },\n}));\n\n// --- Geographic context (SVGRenderer, since highmaps is off-limits) ---------\n// Simplified landmass silhouettes (lon/lat polygons, deliberately coarse —\n// this is visual orientation, not a surveyed coastline) for the regions the\n// belt crosses: S. Europe/N. Africa, Middle East, India, mainland SE Asia,\n// Sumatra, Java. Drawn on chart.events.render via renderer.path() + toPixels()\n// so it re-projects with the axes — a Highcharts-native technique with no\n// direct equivalent in a generic <canvas>-based chart library.\nconst LANDMASSES = [\n  [[-20, 50], [-20, 36], [-9, 36], [-9, 30], [10, 30], [10, 36], [20, 36], [20, 32], [36, 32], [36, 42], [20, 44], [0, 44], [-10, 44], [-20, 50]],\n  [[36, 32], [36, 12], [44, 12], [50, 18], [56, 25], [56, 32], [70, 38], [75, 38], [75, 30], [68, 24], [60, 25], [50, 30], [44, 30], [36, 32]],\n  [[68, 24], [72, 20], [73, 8], [80, 8], [80, 20], [88, 22], [92, 26], [97, 27], [97, 20], [92, 22], [85, 26], [80, 26], [73, 24], [68, 24]],\n  [[92, 26], [97, 27], [105, 23], [108, 16], [105, 10], [100, 7], [97, 15], [94, 16], [92, 20], [92, 26]],\n  [[95, 6], [99, 4], [105, -3], [103, -6], [99, 0], [95, 3], [95, 6]],\n  [[105, -6], [112, -8], [115, -9], [114, -7], [108, -6], [105, -6]],\n];\n\nfunction drawGeoContext(chart) {\n  if (chart.customGeo) {\n    chart.customGeo.forEach((el) => el.destroy());\n  }\n  chart.customGeo = [];\n\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n  if (!chart.customClip) {\n    chart.customClip = chart.renderer.clipRect(chart.plotLeft, chart.plotTop, chart.plotWidth, chart.plotHeight);\n  } else {\n    chart.customClip.attr({ x: chart.plotLeft, y: chart.plotTop, width: chart.plotWidth, height: chart.plotHeight });\n  }\n\n  const toPath = (points) =>\n    points.map((pt, i) => (i === 0 ? [\"M\", xAxis.toPixels(pt[0]), yAxis.toPixels(pt[1])] : [\"L\", xAxis.toPixels(pt[0]), yAxis.toPixels(pt[1])])).concat([[\"Z\"]]);\n\n  LANDMASSES.forEach((poly) => {\n    chart.customGeo.push(\n      chart.renderer\n        .path(toPath(poly))\n        .attr({ fill: t.grid, \"fill-opacity\": 0.4, stroke: t.inkSoft, \"stroke-width\": 1, \"stroke-opacity\": 0.35, zIndex: 1 })\n        .clip(chart.customClip)\n        .add()\n    );\n  });\n\n  // Soft translucent ribbon tracing the seismic-belt corridor itself.\n  const beltPath = ANCHORS.map((a, i) => (i === 0 ? [\"M\", xAxis.toPixels(a.lon), yAxis.toPixels(a.lat)] : [\"L\", xAxis.toPixels(a.lon), yAxis.toPixels(a.lat)]));\n  chart.customGeo.push(\n    chart.renderer\n      .path(beltPath)\n      .attr({ stroke: t.palette[0], \"stroke-width\": 26, \"stroke-opacity\": 0.08, \"stroke-linecap\": \"round\", \"stroke-linejoin\": \"round\", fill: \"none\", zIndex: 2 })\n      .clip(chart.customClip)\n      .add()\n  );\n\n  // Callout labeling the densest cluster (Java-Banda) for orientation,\n  // anchored above the cluster in open space so it clears both the markers\n  // and the trailing Banda-deep point further along the belt.\n  const calloutX = xAxis.toPixels(112) - 10;\n  const calloutY = yAxis.toPixels(8);\n  chart.customGeo.push(\n    chart.renderer\n      .text(\"Java–Banda cluster\", calloutX, calloutY)\n      .css({ color: t.inkSoft, fontSize: \"12px\", fontStyle: \"italic\" })\n      .attr({ zIndex: 5 })\n      .add()\n  );\n}\n\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    zooming: { type: \"xy\" }, // drag-select zoom + pan for exploring point clusters (core feature)\n    events: { render: function () { drawGeoContext(this); } },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"scatter-map-geographic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Alpide seismic belt · synthetic epicenters, magnitude ≥ 4.0\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    min: -20,\n    max: 145,\n    tickInterval: 20,\n    title: { text: \"Longitude (°E)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" }, format: \"{value}°\" },\n  },\n  yAxis: {\n    min: -15,\n    max: 50,\n    tickInterval: 10,\n    startOnTick: false,\n    endOnTick: false,\n    title: { text: \"Latitude (°N)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineColor: t.grid,\n    gridLineWidth: 1,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" }, format: \"{value}°\" },\n  },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n    symbolHeight: 12,\n    symbolWidth: 12,\n    symbolRadius: 6,\n  },\n  tooltip: {\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    style: { color: t.ink, fontSize: \"14px\" },\n    pointFormatter: function () {\n      return (\n        `Lon ${this.x.toFixed(1)}°, Lat ${this.y.toFixed(1)}°<br/>` +\n        `Magnitude ${this.custom.magnitude.toFixed(1)}<br/>` +\n        `Depth ${Math.round(this.custom.depth)} km`\n      );\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: { marker: { symbol: \"circle\" } },\n  },\n  series: [epicenterSeries, ...depthLegend, ...magnitudeLegend],\n});\n"}