Contour Lines on Geographic Map — Apache ECharts

A contour map overlays isolines (lines of equal value) onto a geographic basemap, visualizing continuous spatial data such as elevation, temperature, or atmospheric pressure across real-world coordinates. Unlike basic contour plots that use abstract x/y coordinates, this plot anchors contours to latitude/longitude positions with geographic context like coastlines, borders, or terrain. It combines the precision of isoline visualization with spatial awareness, making it ideal for meteorological, topographic, and environmental applications.

Contour Lines on Geographic Map rendered with Apache ECharts

Renders

JavaScript source (Apache ECharts)

// anyplot.ai
// contour-map-geographic: Contour Lines on Geographic Map
// Library: echarts 6.1.0 | JavaScript 22.23.2
// Quality: 93/100 | Updated: 2026-09-02
//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------
// Synthetic elevation model of a coastal mountain range (Cascade-like terrain):
// a wavy coastline, an inland rise, and three peaks. Grid: 100 x 100 points
// over a 4.2° x 4.2° lon/lat box (fine enough that marching-squares isolines
// read as smooth curves rather than faceted polygons).
const LON_MIN = -124.6, LON_MAX = -120.4;
const LAT_MIN = 43.6, LAT_MAX = 47.8;
const NX = 100, NY = 100;
const TICK_INTERVAL = Math.max(1, Math.floor(NX / 7));

const lonVals = Array.from({ length: NX }, (_, i) => LON_MIN + (i * (LON_MAX - LON_MIN)) / (NX - 1));
const latVals = Array.from({ length: NY }, (_, j) => LAT_MIN + (j * (LAT_MAX - LAT_MIN)) / (NY - 1));

function coastLon(lat) {
  const p = (lat - LAT_MIN) / (LAT_MAX - LAT_MIN);
  return -122.7 + 0.4 * Math.sin(p * 6.6) - 0.15 * Math.cos(p * 3.1);
}

const PEAKS = [
  { lon: -121.9, lat: 47.0, h: 2000, s: 0.34 }, // northern range crest
  { lon: -121.5, lat: 45.4, h: 1550, s: 0.32 }, // central summit
  { lon: -122.0, lat: 44.1, h: 1300, s: 0.3 },  // southern summit
];

function elevationAt(lon, lat) {
  const coast = coastLon(lat);
  if (lon < coast) return 0; // ocean
  let z = (lon - coast) * 900; // inland rise toward the crest
  for (const peak of PEAKS) {
    const d2 = (lon - peak.lon) ** 2 + (lat - peak.lat) ** 2;
    z += peak.h * Math.exp(-d2 / (2 * peak.s * peak.s));
  }
  return Math.min(3400, z);
}

// Flat grid of elevations (index space) + land-only heatmap tiles.
const gridZ = new Array(NX * NY);
const gridData = [];
let maxVal = 0;
for (let j = 0; j < NY; j++) {
  for (let i = 0; i < NX; i++) {
    const z = elevationAt(lonVals[i], latVals[j]);
    gridZ[j * NX + i] = z;
    if (z > 0) {
      gridData.push([i, j, z]);
      if (z > maxVal) maxVal = z;
    }
  }
}

const lonLabels = lonVals.map((v) => `${Math.abs(v).toFixed(1)}°W`);
const latLabels = latVals.map((v) => `${v.toFixed(1)}°N`);

// --- Coastline overlay (index-space polyline) -------------------------------
function lonToIndex(lon) {
  return ((lon - LON_MIN) / (LON_MAX - LON_MIN)) * (NX - 1);
}
const coastSegments = [];
for (let j = 0; j < NY - 1; j++) {
  const x1 = lonToIndex(coastLon(latVals[j]));
  const x2 = lonToIndex(coastLon(latVals[j + 1]));
  coastSegments.push([[x1, j], [x2, j + 1]]);
}

// Single ocean polygon that shares its right edge with the land heatmap
// tiles' left edge on every row: heatmap tiles are grid-snapped (they exist
// from the first whole index where elevationAt > 0, spanning index±0.5), so
// the fill boundary must be grid-snapped too, not the continuous fractional
// coastline — otherwise up to half a cell of page background bleeds through
// between the two fills at every staircase step.
function firstLandIndex(j) {
  const ci = lonToIndex(coastLon(latVals[j]));
  return Math.ceil(ci - 1e-9);
}
const oceanPolygon = [[-0.5, -0.5]];
for (let j = 0; j < NY; j++) oceanPolygon.push([firstLandIndex(j) - 0.5, j]);
oceanPolygon.push([-0.5, NY - 0.5]);

// --- Marching squares: isoline segments for one elevation threshold ---------
function contourSegments(threshold) {
  const segs = [];
  for (let j = 0; j < NY - 1; j++) {
    for (let i = 0; i < NX - 1; i++) {
      const vbl = gridZ[j * NX + i];
      const vbr = gridZ[j * NX + (i + 1)];
      const vtr = gridZ[(j + 1) * NX + (i + 1)];
      const vtl = gridZ[(j + 1) * NX + i];

      const abl = vbl >= threshold;
      const abr = vbr >= threshold;
      const atr = vtr >= threshold;
      const atl = vtl >= threshold;
      if (abl === abr && abr === atr && atr === atl) continue;

      const lerp = (a, b, va, vb) => a + ((b - a) * (threshold - va)) / (vb - va);
      const pts = [];
      if (abl !== abr) pts.push([lerp(i, i + 1, vbl, vbr), j]);
      if (abr !== atr) pts.push([i + 1, lerp(j, j + 1, vbr, vtr)]);
      if (atr !== atl) pts.push([lerp(i, i + 1, vtl, vtr), j + 1]);
      if (atl !== abl) pts.push([i, lerp(j, j + 1, vbl, vtl)]);

      if (pts.length === 2) {
        segs.push([pts[0], pts[1]]);
      } else if (pts.length === 4) {
        const center = (vbl + vbr + vtr + vtl) / 4;
        const code5 = abl && atr && !abr && !atl;
        const swapped = code5 ? center < threshold : center >= threshold;
        if (swapped) {
          segs.push([pts[0], pts[3]]);
          segs.push([pts[1], pts[2]]);
        } else {
          segs.push([pts[0], pts[1]]);
          segs.push([pts[2], pts[3]]);
        }
      }
    }
  }
  return segs;
}

// --- Chain raw marching-squares segments into connected paths --------------
// Adjacent cells produce segments that share an exact interpolated endpoint
// (same grid-edge crossing), so a simple point-key walk reconnects them into
// closed loops (rings around a peak) or open chains (cut off by the map
// edge). Chaining first is what lets us smooth the *path*, not each tiny
// facet independently, and label once per loop instead of once per segment.
function chainSegments(segs) {
  const key = (p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`;
  const pointMap = new Map();
  segs.forEach((seg, idx) => {
    [0, 1].forEach((end) => {
      const k = key(seg[end]);
      if (!pointMap.has(k)) pointMap.set(k, []);
      pointMap.get(k).push({ idx, end });
    });
  });

  const used = new Array(segs.length).fill(false);
  const chains = [];
  const takeNeighbor = (pointKey, currentIdx) => {
    const candidates = pointMap.get(pointKey) || [];
    for (const c of candidates) {
      if (c.idx !== currentIdx && !used[c.idx]) return c;
    }
    return null;
  };

  for (let start = 0; start < segs.length; start++) {
    if (used[start]) continue;
    used[start] = true;
    const chain = [segs[start][0], segs[start][1]];

    let next = takeNeighbor(key(chain[chain.length - 1]), start);
    while (next) {
      const seg = segs[next.idx];
      chain.push(seg[next.end === 0 ? 1 : 0]);
      used[next.idx] = true;
      next = takeNeighbor(key(chain[chain.length - 1]), next.idx);
    }
    let prev = takeNeighbor(key(chain[0]), -1);
    while (prev) {
      const seg = segs[prev.idx];
      chain.unshift(seg[prev.end === 0 ? 1 : 0]);
      used[prev.idx] = true;
      prev = takeNeighbor(key(chain[0]), prev.idx);
    }
    chains.push(chain);
  }
  return chains;
}

// Chaikin corner-cutting: replaces each edge with two points 1/4 and 3/4
// along it, rounding the polygonal marching-squares output into a smooth
// curve without changing the underlying topology. `points` must be the
// distinct ring vertices with NO repeated closing point — chainSegments
// represents a closed loop as [...ring, ring[0]] (first === last, so the
// shape renders closed), and feeding that duplicate straight into the
// modulo-wrapped closed-loop math below creates one degenerate zero-length
// edge exactly at the seam, which survives every iteration as an unsmoothed
// sharp corner (the notch/spike artifacts on the peak rings). Callers must
// strip the duplicate before calling and re-append it after.
function chaikinSmooth(points, iterations, closed) {
  let pts = points;
  for (let it = 0; it < iterations; it++) {
    const next = [];
    const n = pts.length;
    const edgeCount = closed ? n : n - 1;
    if (!closed) next.push(pts[0]);
    for (let i = 0; i < edgeCount; i++) {
      const p0 = pts[i];
      const p1 = pts[(i + 1) % n];
      next.push([p0[0] * 0.75 + p1[0] * 0.25, p0[1] * 0.75 + p1[1] * 0.25]);
      next.push([p0[0] * 0.25 + p1[0] * 0.75, p0[1] * 0.25 + p1[1] * 0.75]);
    }
    if (!closed) next.push(pts[pts.length - 1]);
    pts = next;
  }
  return pts;
}

// Elevation isolines every 400 m; every third line (1200 m) is a bold, labeled
// "index contour" — the cartographic convention for topographic maps.
const CONTOUR_INTERVAL = 400;
const INDEX_EVERY = 1200;
const levels = [];
for (let lvl = CONTOUR_INTERVAL; lvl <= maxVal; lvl += CONTOUR_INTERVAL) levels.push(lvl);

// One entry per smoothed contour loop/chain: { points, labels, bold }. A
// single elevation level often forms one long connected boundary that
// snakes past several peaks (the inland-rise term keeps the ridge between
// peaks above the threshold too) rather than one separate ring per peak —
// so index (bold) chains get a label every ~55 points of *raw* (pre-smooth)
// path, spacing labels out along the line instead of stamping just one per
// chain. Basing the count on the raw chain — not the Chaikin-smoothed one —
// keeps label density independent of the smoothing-iteration count.
const CHAIKIN_ITERATIONS = 4;
const RAW_LABEL_SPACING = 55;
const contourPaths = [];
levels.forEach((threshold) => {
  const bold = threshold % INDEX_EVERY === 0;
  const rawChains = chainSegments(contourSegments(threshold));
  rawChains.forEach((chain) => {
    if (chain.length < 2) return;
    const closed =
      chain.length > 2 &&
      Math.abs(chain[0][0] - chain[chain.length - 1][0]) < 1e-4 &&
      Math.abs(chain[0][1] - chain[chain.length - 1][1]) < 1e-4;
    // Drop the duplicate closing vertex before smoothing (see chaikinSmooth
    // comment above), then re-append it so the rendered path still closes.
    const ringPoints = closed ? chain.slice(0, -1) : chain;
    const smoothed = chaikinSmooth(ringPoints, CHAIKIN_ITERATIONS, closed);
    if (closed) smoothed.push(smoothed[0]);

    const labels = [];
    if (bold) {
      const count = Math.max(1, Math.round(chain.length / RAW_LABEL_SPACING));
      for (let k = 0; k < count; k++) {
        const at = Math.min(smoothed.length - 1, Math.floor(((k + 0.5) * smoothed.length) / count));
        labels.push({ text: `${threshold} m`, at });
      }
    }
    contourPaths.push({ points: smoothed, labels, bold });
  });
});

// api.coord() on a *category* axis runs every value through ECharts'
// OrdinalScale.parse, which does `Math.round()` on numeric input before
// mapping to pixels — silently snapping every fractional marching-squares /
// Chaikin coordinate to the nearest whole grid line. That's what was making
// the "smoothed" contours render as raw staircases. Fix: read the pixel
// position of only the two exact-integer axis ends (never rounded, since
// they're already integers) once per renderItem call, then interpolate
// fractional coordinates ourselves — bypassing the axis's rounding entirely.
function projectPoint(api, pt) {
  const origin = api.coord([0, 0]);
  const xEnd = api.coord([NX - 1, 0]);
  const yEnd = api.coord([0, NY - 1]);
  const pxPerX = (xEnd[0] - origin[0]) / (NX - 1);
  const pxPerY = (yEnd[1] - origin[1]) / (NY - 1);
  return [origin[0] + pt[0] * pxPerX, origin[1] + pt[1] * pxPerY];
}

// --- Init --------------------------------------------------------------------
const chart = echarts.init(document.getElementById('container'));

const TITLE = 'Cascade Range Elevation · contour-map-geographic · javascript · echarts · anyplot.ai';

chart.setOption({
  animation: false,
  backgroundColor: 'transparent',
  title: {
    text: TITLE,
    left: 'center',
    top: 24,
    textStyle: { color: t.ink, fontSize: Math.round(24 * Math.min(1, 67 / TITLE.length)), fontWeight: 'bold' },
  },
  grid: { left: 110, right: 165, top: 100, bottom: 100 },
  xAxis: {
    type: 'category',
    data: lonLabels,
    name: 'Longitude',
    nameLocation: 'middle',
    nameGap: 50,
    nameTextStyle: { color: t.inkSoft, fontSize: 16 },
    axisLabel: { color: t.inkSoft, fontSize: 14, interval: TICK_INTERVAL },
    axisLine: { lineStyle: { color: t.inkSoft } },
    axisTick: { show: false },
    splitLine: { show: false },
  },
  yAxis: {
    type: 'category',
    data: latLabels,
    name: 'Latitude',
    nameLocation: 'middle',
    nameGap: 60,
    nameTextStyle: { color: t.inkSoft, fontSize: 16 },
    axisLabel: { color: t.inkSoft, fontSize: 14, interval: TICK_INTERVAL },
    axisLine: { lineStyle: { color: t.inkSoft } },
    axisTick: { show: false },
    splitLine: { show: false },
  },
  visualMap: {
    min: 0,
    max: maxVal,
    seriesIndex: [1],
    calculable: false,
    orient: 'vertical',
    right: 25,
    top: 'center',
    itemHeight: 260,
    itemWidth: 22,
    inRange: { color: t.seq },
    textStyle: { color: t.inkSoft, fontSize: 13 },
    text: [`${Math.round(maxVal)} m`, '0 m (coast)'],
    formatter: (v) => `${Math.round(v)} m`,
  },
  series: [
    {
      // Subtle basemap tint for the ocean — the "water → blue" semantic
      // exception from the Imprint palette, at low opacity so it reads as a
      // faint fill rather than competing with the imprint_seq land gradient.
      type: 'custom',
      coordinateSystem: 'cartesian2d',
      renderItem(params, api) {
        const coords = oceanPolygon.map((p) => projectPoint(api, p));
        return {
          type: 'polygon',
          shape: { points: coords },
          style: { fill: t.palette[2], opacity: 0.22 },
        };
      },
      data: [[0, 0]],
      encode: { x: 0, y: 1 },
      z: 1,
      silent: true,
    },
    {
      // Filled elevation surface — land tiles only.
      type: 'heatmap',
      data: gridData,
      emphasis: { disabled: true },
      z: 2,
    },
    {
      // Coastline overlay marking land/ocean boundary.
      type: 'custom',
      coordinateSystem: 'cartesian2d',
      renderItem(params, api) {
        const seg = coastSegments[params.dataIndex];
        const p1 = projectPoint(api, seg[0]);
        const p2 = projectPoint(api, seg[1]);
        return {
          type: 'line',
          shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },
          style: { stroke: t.inkSoft, lineWidth: 2.5, opacity: 0.85 },
        };
      },
      data: coastSegments.map((_, idx) => [idx, 0]),
      encode: { x: 0, y: 1 },
      z: 5,
      silent: true,
    },
    {
      // Elevation isolines via marching squares, chained into paths and
      // Chaikin-smoothed — bold + labeled (once per loop) every 1200 m.
      type: 'custom',
      coordinateSystem: 'cartesian2d',
      renderItem(params, api) {
        const path = contourPaths[params.dataIndex];
        const coords = path.points.map((p) => projectPoint(api, p));
        const lineEl = {
          type: 'polyline',
          shape: { points: coords },
          style: { stroke: t.ink, lineWidth: path.bold ? 2.4 : 1.2, opacity: path.bold ? 0.8 : 0.4, fill: 'none' },
        };

        if (!path.labels.length) return lineEl;

        const labelEls = path.labels.flatMap((lbl) => {
          const [mx, my] = coords[lbl.at];
          return [
            {
              type: 'rect',
              shape: { x: mx - 30, y: my - 18, width: 60, height: 20, r: 3 },
              style: { fill: t.pageBg, opacity: 0.82 },
            },
            {
              type: 'text',
              x: mx,
              y: my - 8,
              style: {
                text: lbl.text,
                fill: t.ink,
                fontSize: 13,
                fontFamily: 'sans-serif',
                textAlign: 'center',
                opacity: 0.95,
              },
            },
          ];
        });

        return { type: 'group', children: [lineEl, ...labelEls] };
      },
      data: contourPaths.map((_, idx) => [idx, 0]),
      encode: { x: 0, y: 1 },
      z: 10,
      silent: true,
    },
  ],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/contour-map-geographic/echarts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "contour-map-geographic",
  "language": "javascript",
  "library": "echarts",
  "page": "https://anyplot.ai/contour-map-geographic/javascript/echarts",
  "hub": "https://anyplot.ai/contour-map-geographic",
  "code_json": "https://api.anyplot.ai/specs/contour-map-geographic/echarts/code",
  "spec_json": "https://api.anyplot.ai/specs/contour-map-geographic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/echarts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/echarts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/echarts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/echarts/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Contour Lines on Geographic Map on anyplot.ai.

Other implementations