{"spec_id":"contour-map-geographic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// contour-map-geographic: Contour Lines on Geographic Map\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 93/100 | Updated: 2026-09-02\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Synthetic elevation model of a coastal mountain range (Cascade-like terrain):\n// a wavy coastline, an inland rise, and three peaks. Grid: 100 x 100 points\n// over a 4.2° x 4.2° lon/lat box (fine enough that marching-squares isolines\n// read as smooth curves rather than faceted polygons).\nconst LON_MIN = -124.6, LON_MAX = -120.4;\nconst LAT_MIN = 43.6, LAT_MAX = 47.8;\nconst NX = 100, NY = 100;\nconst TICK_INTERVAL = Math.max(1, Math.floor(NX / 7));\n\nconst lonVals = Array.from({ length: NX }, (_, i) => LON_MIN + (i * (LON_MAX - LON_MIN)) / (NX - 1));\nconst latVals = Array.from({ length: NY }, (_, j) => LAT_MIN + (j * (LAT_MAX - LAT_MIN)) / (NY - 1));\n\nfunction coastLon(lat) {\n  const p = (lat - LAT_MIN) / (LAT_MAX - LAT_MIN);\n  return -122.7 + 0.4 * Math.sin(p * 6.6) - 0.15 * Math.cos(p * 3.1);\n}\n\nconst PEAKS = [\n  { lon: -121.9, lat: 47.0, h: 2000, s: 0.34 }, // northern range crest\n  { lon: -121.5, lat: 45.4, h: 1550, s: 0.32 }, // central summit\n  { lon: -122.0, lat: 44.1, h: 1300, s: 0.3 },  // southern summit\n];\n\nfunction elevationAt(lon, lat) {\n  const coast = coastLon(lat);\n  if (lon < coast) return 0; // ocean\n  let z = (lon - coast) * 900; // inland rise toward the crest\n  for (const peak of PEAKS) {\n    const d2 = (lon - peak.lon) ** 2 + (lat - peak.lat) ** 2;\n    z += peak.h * Math.exp(-d2 / (2 * peak.s * peak.s));\n  }\n  return Math.min(3400, z);\n}\n\n// Flat grid of elevations (index space) + land-only heatmap tiles.\nconst gridZ = new Array(NX * NY);\nconst gridData = [];\nlet maxVal = 0;\nfor (let j = 0; j < NY; j++) {\n  for (let i = 0; i < NX; i++) {\n    const z = elevationAt(lonVals[i], latVals[j]);\n    gridZ[j * NX + i] = z;\n    if (z > 0) {\n      gridData.push([i, j, z]);\n      if (z > maxVal) maxVal = z;\n    }\n  }\n}\n\nconst lonLabels = lonVals.map((v) => `${Math.abs(v).toFixed(1)}°W`);\nconst latLabels = latVals.map((v) => `${v.toFixed(1)}°N`);\n\n// --- Coastline overlay (index-space polyline) -------------------------------\nfunction lonToIndex(lon) {\n  return ((lon - LON_MIN) / (LON_MAX - LON_MIN)) * (NX - 1);\n}\nconst coastSegments = [];\nfor (let j = 0; j < NY - 1; j++) {\n  const x1 = lonToIndex(coastLon(latVals[j]));\n  const x2 = lonToIndex(coastLon(latVals[j + 1]));\n  coastSegments.push([[x1, j], [x2, j + 1]]);\n}\n\n// Single ocean polygon that shares its right edge with the land heatmap\n// tiles' left edge on every row: heatmap tiles are grid-snapped (they exist\n// from the first whole index where elevationAt > 0, spanning index±0.5), so\n// the fill boundary must be grid-snapped too, not the continuous fractional\n// coastline — otherwise up to half a cell of page background bleeds through\n// between the two fills at every staircase step.\nfunction firstLandIndex(j) {\n  const ci = lonToIndex(coastLon(latVals[j]));\n  return Math.ceil(ci - 1e-9);\n}\nconst oceanPolygon = [[-0.5, -0.5]];\nfor (let j = 0; j < NY; j++) oceanPolygon.push([firstLandIndex(j) - 0.5, j]);\noceanPolygon.push([-0.5, NY - 0.5]);\n\n// --- Marching squares: isoline segments for one elevation threshold ---------\nfunction contourSegments(threshold) {\n  const segs = [];\n  for (let j = 0; j < NY - 1; j++) {\n    for (let i = 0; i < NX - 1; i++) {\n      const vbl = gridZ[j * NX + i];\n      const vbr = gridZ[j * NX + (i + 1)];\n      const vtr = gridZ[(j + 1) * NX + (i + 1)];\n      const vtl = gridZ[(j + 1) * NX + i];\n\n      const abl = vbl >= threshold;\n      const abr = vbr >= threshold;\n      const atr = vtr >= threshold;\n      const atl = vtl >= threshold;\n      if (abl === abr && abr === atr && atr === atl) continue;\n\n      const lerp = (a, b, va, vb) => a + ((b - a) * (threshold - va)) / (vb - va);\n      const pts = [];\n      if (abl !== abr) pts.push([lerp(i, i + 1, vbl, vbr), j]);\n      if (abr !== atr) pts.push([i + 1, lerp(j, j + 1, vbr, vtr)]);\n      if (atr !== atl) pts.push([lerp(i, i + 1, vtl, vtr), j + 1]);\n      if (atl !== abl) pts.push([i, lerp(j, j + 1, vbl, vtl)]);\n\n      if (pts.length === 2) {\n        segs.push([pts[0], pts[1]]);\n      } else if (pts.length === 4) {\n        const center = (vbl + vbr + vtr + vtl) / 4;\n        const code5 = abl && atr && !abr && !atl;\n        const swapped = code5 ? center < threshold : center >= threshold;\n        if (swapped) {\n          segs.push([pts[0], pts[3]]);\n          segs.push([pts[1], pts[2]]);\n        } else {\n          segs.push([pts[0], pts[1]]);\n          segs.push([pts[2], pts[3]]);\n        }\n      }\n    }\n  }\n  return segs;\n}\n\n// --- Chain raw marching-squares segments into connected paths --------------\n// Adjacent cells produce segments that share an exact interpolated endpoint\n// (same grid-edge crossing), so a simple point-key walk reconnects them into\n// closed loops (rings around a peak) or open chains (cut off by the map\n// edge). Chaining first is what lets us smooth the *path*, not each tiny\n// facet independently, and label once per loop instead of once per segment.\nfunction chainSegments(segs) {\n  const key = (p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`;\n  const pointMap = new Map();\n  segs.forEach((seg, idx) => {\n    [0, 1].forEach((end) => {\n      const k = key(seg[end]);\n      if (!pointMap.has(k)) pointMap.set(k, []);\n      pointMap.get(k).push({ idx, end });\n    });\n  });\n\n  const used = new Array(segs.length).fill(false);\n  const chains = [];\n  const takeNeighbor = (pointKey, currentIdx) => {\n    const candidates = pointMap.get(pointKey) || [];\n    for (const c of candidates) {\n      if (c.idx !== currentIdx && !used[c.idx]) return c;\n    }\n    return null;\n  };\n\n  for (let start = 0; start < segs.length; start++) {\n    if (used[start]) continue;\n    used[start] = true;\n    const chain = [segs[start][0], segs[start][1]];\n\n    let next = takeNeighbor(key(chain[chain.length - 1]), start);\n    while (next) {\n      const seg = segs[next.idx];\n      chain.push(seg[next.end === 0 ? 1 : 0]);\n      used[next.idx] = true;\n      next = takeNeighbor(key(chain[chain.length - 1]), next.idx);\n    }\n    let prev = takeNeighbor(key(chain[0]), -1);\n    while (prev) {\n      const seg = segs[prev.idx];\n      chain.unshift(seg[prev.end === 0 ? 1 : 0]);\n      used[prev.idx] = true;\n      prev = takeNeighbor(key(chain[0]), prev.idx);\n    }\n    chains.push(chain);\n  }\n  return chains;\n}\n\n// Chaikin corner-cutting: replaces each edge with two points 1/4 and 3/4\n// along it, rounding the polygonal marching-squares output into a smooth\n// curve without changing the underlying topology. `points` must be the\n// distinct ring vertices with NO repeated closing point — chainSegments\n// represents a closed loop as [...ring, ring[0]] (first === last, so the\n// shape renders closed), and feeding that duplicate straight into the\n// modulo-wrapped closed-loop math below creates one degenerate zero-length\n// edge exactly at the seam, which survives every iteration as an unsmoothed\n// sharp corner (the notch/spike artifacts on the peak rings). Callers must\n// strip the duplicate before calling and re-append it after.\nfunction chaikinSmooth(points, iterations, closed) {\n  let pts = points;\n  for (let it = 0; it < iterations; it++) {\n    const next = [];\n    const n = pts.length;\n    const edgeCount = closed ? n : n - 1;\n    if (!closed) next.push(pts[0]);\n    for (let i = 0; i < edgeCount; i++) {\n      const p0 = pts[i];\n      const p1 = pts[(i + 1) % n];\n      next.push([p0[0] * 0.75 + p1[0] * 0.25, p0[1] * 0.75 + p1[1] * 0.25]);\n      next.push([p0[0] * 0.25 + p1[0] * 0.75, p0[1] * 0.25 + p1[1] * 0.75]);\n    }\n    if (!closed) next.push(pts[pts.length - 1]);\n    pts = next;\n  }\n  return pts;\n}\n\n// Elevation isolines every 400 m; every third line (1200 m) is a bold, labeled\n// \"index contour\" — the cartographic convention for topographic maps.\nconst CONTOUR_INTERVAL = 400;\nconst INDEX_EVERY = 1200;\nconst levels = [];\nfor (let lvl = CONTOUR_INTERVAL; lvl <= maxVal; lvl += CONTOUR_INTERVAL) levels.push(lvl);\n\n// One entry per smoothed contour loop/chain: { points, labels, bold }. A\n// single elevation level often forms one long connected boundary that\n// snakes past several peaks (the inland-rise term keeps the ridge between\n// peaks above the threshold too) rather than one separate ring per peak —\n// so index (bold) chains get a label every ~55 points of *raw* (pre-smooth)\n// path, spacing labels out along the line instead of stamping just one per\n// chain. Basing the count on the raw chain — not the Chaikin-smoothed one —\n// keeps label density independent of the smoothing-iteration count.\nconst CHAIKIN_ITERATIONS = 4;\nconst RAW_LABEL_SPACING = 55;\nconst contourPaths = [];\nlevels.forEach((threshold) => {\n  const bold = threshold % INDEX_EVERY === 0;\n  const rawChains = chainSegments(contourSegments(threshold));\n  rawChains.forEach((chain) => {\n    if (chain.length < 2) return;\n    const closed =\n      chain.length > 2 &&\n      Math.abs(chain[0][0] - chain[chain.length - 1][0]) < 1e-4 &&\n      Math.abs(chain[0][1] - chain[chain.length - 1][1]) < 1e-4;\n    // Drop the duplicate closing vertex before smoothing (see chaikinSmooth\n    // comment above), then re-append it so the rendered path still closes.\n    const ringPoints = closed ? chain.slice(0, -1) : chain;\n    const smoothed = chaikinSmooth(ringPoints, CHAIKIN_ITERATIONS, closed);\n    if (closed) smoothed.push(smoothed[0]);\n\n    const labels = [];\n    if (bold) {\n      const count = Math.max(1, Math.round(chain.length / RAW_LABEL_SPACING));\n      for (let k = 0; k < count; k++) {\n        const at = Math.min(smoothed.length - 1, Math.floor(((k + 0.5) * smoothed.length) / count));\n        labels.push({ text: `${threshold} m`, at });\n      }\n    }\n    contourPaths.push({ points: smoothed, labels, bold });\n  });\n});\n\n// api.coord() on a *category* axis runs every value through ECharts'\n// OrdinalScale.parse, which does `Math.round()` on numeric input before\n// mapping to pixels — silently snapping every fractional marching-squares /\n// Chaikin coordinate to the nearest whole grid line. That's what was making\n// the \"smoothed\" contours render as raw staircases. Fix: read the pixel\n// position of only the two exact-integer axis ends (never rounded, since\n// they're already integers) once per renderItem call, then interpolate\n// fractional coordinates ourselves — bypassing the axis's rounding entirely.\nfunction projectPoint(api, pt) {\n  const origin = api.coord([0, 0]);\n  const xEnd = api.coord([NX - 1, 0]);\n  const yEnd = api.coord([0, NY - 1]);\n  const pxPerX = (xEnd[0] - origin[0]) / (NX - 1);\n  const pxPerY = (yEnd[1] - origin[1]) / (NY - 1);\n  return [origin[0] + pt[0] * pxPerX, origin[1] + pt[1] * pxPerY];\n}\n\n// --- Init --------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById('container'));\n\nconst TITLE = 'Cascade Range Elevation · contour-map-geographic · javascript · echarts · anyplot.ai';\n\nchart.setOption({\n  animation: false,\n  backgroundColor: 'transparent',\n  title: {\n    text: TITLE,\n    left: 'center',\n    top: 24,\n    textStyle: { color: t.ink, fontSize: Math.round(24 * Math.min(1, 67 / TITLE.length)), fontWeight: 'bold' },\n  },\n  grid: { left: 110, right: 165, top: 100, bottom: 100 },\n  xAxis: {\n    type: 'category',\n    data: lonLabels,\n    name: 'Longitude',\n    nameLocation: 'middle',\n    nameGap: 50,\n    nameTextStyle: { color: t.inkSoft, fontSize: 16 },\n    axisLabel: { color: t.inkSoft, fontSize: 14, interval: TICK_INTERVAL },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false },\n  },\n  yAxis: {\n    type: 'category',\n    data: latLabels,\n    name: 'Latitude',\n    nameLocation: 'middle',\n    nameGap: 60,\n    nameTextStyle: { color: t.inkSoft, fontSize: 16 },\n    axisLabel: { color: t.inkSoft, fontSize: 14, interval: TICK_INTERVAL },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false },\n  },\n  visualMap: {\n    min: 0,\n    max: maxVal,\n    seriesIndex: [1],\n    calculable: false,\n    orient: 'vertical',\n    right: 25,\n    top: 'center',\n    itemHeight: 260,\n    itemWidth: 22,\n    inRange: { color: t.seq },\n    textStyle: { color: t.inkSoft, fontSize: 13 },\n    text: [`${Math.round(maxVal)} m`, '0 m (coast)'],\n    formatter: (v) => `${Math.round(v)} m`,\n  },\n  series: [\n    {\n      // Subtle basemap tint for the ocean — the \"water → blue\" semantic\n      // exception from the Imprint palette, at low opacity so it reads as a\n      // faint fill rather than competing with the imprint_seq land gradient.\n      type: 'custom',\n      coordinateSystem: 'cartesian2d',\n      renderItem(params, api) {\n        const coords = oceanPolygon.map((p) => projectPoint(api, p));\n        return {\n          type: 'polygon',\n          shape: { points: coords },\n          style: { fill: t.palette[2], opacity: 0.22 },\n        };\n      },\n      data: [[0, 0]],\n      encode: { x: 0, y: 1 },\n      z: 1,\n      silent: true,\n    },\n    {\n      // Filled elevation surface — land tiles only.\n      type: 'heatmap',\n      data: gridData,\n      emphasis: { disabled: true },\n      z: 2,\n    },\n    {\n      // Coastline overlay marking land/ocean boundary.\n      type: 'custom',\n      coordinateSystem: 'cartesian2d',\n      renderItem(params, api) {\n        const seg = coastSegments[params.dataIndex];\n        const p1 = projectPoint(api, seg[0]);\n        const p2 = projectPoint(api, seg[1]);\n        return {\n          type: 'line',\n          shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },\n          style: { stroke: t.inkSoft, lineWidth: 2.5, opacity: 0.85 },\n        };\n      },\n      data: coastSegments.map((_, idx) => [idx, 0]),\n      encode: { x: 0, y: 1 },\n      z: 5,\n      silent: true,\n    },\n    {\n      // Elevation isolines via marching squares, chained into paths and\n      // Chaikin-smoothed — bold + labeled (once per loop) every 1200 m.\n      type: 'custom',\n      coordinateSystem: 'cartesian2d',\n      renderItem(params, api) {\n        const path = contourPaths[params.dataIndex];\n        const coords = path.points.map((p) => projectPoint(api, p));\n        const lineEl = {\n          type: 'polyline',\n          shape: { points: coords },\n          style: { stroke: t.ink, lineWidth: path.bold ? 2.4 : 1.2, opacity: path.bold ? 0.8 : 0.4, fill: 'none' },\n        };\n\n        if (!path.labels.length) return lineEl;\n\n        const labelEls = path.labels.flatMap((lbl) => {\n          const [mx, my] = coords[lbl.at];\n          return [\n            {\n              type: 'rect',\n              shape: { x: mx - 30, y: my - 18, width: 60, height: 20, r: 3 },\n              style: { fill: t.pageBg, opacity: 0.82 },\n            },\n            {\n              type: 'text',\n              x: mx,\n              y: my - 8,\n              style: {\n                text: lbl.text,\n                fill: t.ink,\n                fontSize: 13,\n                fontFamily: 'sans-serif',\n                textAlign: 'center',\n                opacity: 0.95,\n              },\n            },\n          ];\n        });\n\n        return { type: 'group', children: [lineEl, ...labelEls] };\n      },\n      data: contourPaths.map((_, idx) => [idx, 0]),\n      encode: { x: 0, y: 1 },\n      z: 10,\n      silent: true,\n    },\n  ],\n});\n"}