Contour Lines on Geographic Map — Highcharts

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 Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// contour-map-geographic: Contour Lines on Geographic Map
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 90/100 | Created: 2026-09-01

//# anyplot-orientation: landscape
const t = window.ANYPLOT_TOKENS;

// --- Data: synthetic alpine elevation field over a lon/lat survey grid -----
// --- (deterministic sum of peak Gaussians — no RNG needed) -----------------
const LON_MIN = 6.3;
const LON_MAX = 8.7;
const LAT_MIN = 45.15;
const LAT_MAX = 46.5;
const NX = 36;
const NY = 20;

const PEAKS = [
  { lon: 7.05, lat: 45.55, height: 3100, sigma: 0.32 },
  { lon: 7.95, lat: 46.05, height: 2550, sigma: 0.28 },
  { lon: 8.35, lat: 45.35, height: 2050, sigma: 0.26 },
  { lon: 6.7, lat: 46.05, height: 1750, sigma: 0.24 },
];

function elevationAt(lon, lat) {
  let z = 420; // valley floor, meters
  for (const peak of PEAKS) {
    const dLon = lon - peak.lon;
    const dLat = lat - peak.lat;
    const d2 = dLon * dLon + dLat * dLat;
    z += peak.height * Math.exp(-d2 / (2 * peak.sigma * peak.sigma));
  }
  return z;
}

const lons = Array.from(
  { length: NX },
  (_, i) => LON_MIN + (i * (LON_MAX - LON_MIN)) / (NX - 1),
);
const lats = Array.from(
  { length: NY },
  (_, j) => LAT_MIN + (j * (LAT_MAX - LAT_MIN)) / (NY - 1),
);
const grid = lats.map((lat) => lons.map((lon) => elevationAt(lon, lat)));

const flatValues = grid.flat();
const gridMin = Math.min(...flatValues);
const gridMax = Math.max(...flatValues);
const halfDLon = (lons[1] - lons[0]) / 2;
const halfDLat = (lats[1] - lats[0]) / 2;

// --- Locate the single highest grid point for a focal-point callout --------
let peakRow = 0;
let peakCol = 0;
grid.forEach((row, j) => {
  row.forEach((value, i) => {
    if (value > grid[peakRow][peakCol]) {
      peakRow = j;
      peakCol = i;
    }
  });
});
const peakLon = lons[peakCol];
const peakLat = lats[peakRow];

// --- Contour levels: round-number intervals spanning the field -------------
const LEVEL_STEP = 300; // meters
const firstLevel = Math.ceil((gridMin + LEVEL_STEP) / LEVEL_STEP) * LEVEL_STEP;
const levels = [];
for (let lvl = firstLevel; lvl < gridMax; lvl += LEVEL_STEP) levels.push(lvl);

// --- Imprint sequential color scale (single-polarity: low -> high) ---------
// Parses both "#RRGGBB" and "rgb(r, g, b)" so mixHex() results can be re-mixed
// (e.g. blending an already-mixed isoline color further toward t.ink).
function parseColor(color) {
  if (color.startsWith("#")) {
    return {
      r: parseInt(color.slice(1, 3), 16),
      g: parseInt(color.slice(3, 5), 16),
      b: parseInt(color.slice(5, 7), 16),
    };
  }
  const [r, g, b] = color.match(/[\d.]+/g).map(Number);
  return { r, g, b };
}

function mixHex(colorLow, colorHigh, frac) {
  const f = Math.max(0, Math.min(1, frac));
  const a = parseColor(colorLow);
  const b = parseColor(colorHigh);
  const r = Math.round(a.r + (b.r - a.r) * f);
  const g = Math.round(a.g + (b.g - a.g) * f);
  const bl = Math.round(a.b + (b.b - a.b) * f);
  return `rgb(${r}, ${g}, ${bl})`;
}

// --- Marching squares: trace one level's isolines as lon/lat segments ------
function marchingSquares(level) {
  const segments = [];
  const interp = (v0, v1, p0, p1) => {
    const frac = (level - v0) / (v1 - v0);
    return [p0[0] + frac * (p1[0] - p0[0]), p0[1] + frac * (p1[1] - p0[1])];
  };

  for (let j = 0; j < NY - 1; j++) {
    for (let i = 0; i < NX - 1; i++) {
      const x0 = lons[i];
      const x1 = lons[i + 1];
      const y0 = lats[j];
      const y1 = lats[j + 1];
      const bl = grid[j][i];
      const br = grid[j][i + 1];
      const tr = grid[j + 1][i + 1];
      const tl = grid[j + 1][i];
      const bit =
        (tl >= level ? 8 : 0) |
        (tr >= level ? 4 : 0) |
        (br >= level ? 2 : 0) |
        (bl >= level ? 1 : 0);
      if (bit === 0 || bit === 15) continue;

      const top = () => interp(tl, tr, [x0, y1], [x1, y1]);
      const right = () => interp(tr, br, [x1, y1], [x1, y0]);
      const bottom = () => interp(bl, br, [x0, y0], [x1, y0]);
      const left = () => interp(bl, tl, [x0, y0], [x0, y1]);

      // Standard marching-squares case table (TL=8, TR=4, BR=2, BL=1).
      // Cases 5 and 10 are the ambiguous saddle configurations; this table
      // resolves them with a fixed diagonal, which is a common convention.
      const CASES = {
        1: [[left(), bottom()]],
        2: [[bottom(), right()]],
        3: [[left(), right()]],
        4: [[right(), top()]],
        5: [
          [left(), top()],
          [bottom(), right()],
        ],
        6: [[bottom(), top()]],
        7: [[left(), top()]],
        8: [[top(), left()]],
        9: [[top(), bottom()]],
        10: [
          [top(), right()],
          [left(), bottom()],
        ],
        11: [[top(), right()]],
        12: [[right(), left()]],
        13: [[right(), bottom()]],
        14: [[bottom(), left()]],
      };
      segments.push(...CASES[bit]);
    }
  }
  return segments;
}

// --- Stitch disjoint segments into continuous polylines ---------------------
function stitchPolylines(segments) {
  const key = (p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`;
  const adjacency = new Map();
  segments.forEach((segment, segIdx) => {
    [0, 1].forEach((endIdx) => {
      const k = key(segment[endIdx]);
      if (!adjacency.has(k)) adjacency.set(k, []);
      adjacency.get(k).push({ segIdx, endIdx });
    });
  });

  const used = new Array(segments.length).fill(false);
  const polylines = [];
  for (let i = 0; i < segments.length; i++) {
    if (used[i]) continue;
    used[i] = true;
    const line = [segments[i][0], segments[i][1]];

    let extended = true;
    while (extended) {
      extended = false;
      const candidates = adjacency.get(key(line[line.length - 1])) || [];
      for (const cand of candidates) {
        if (used[cand.segIdx]) continue;
        const seg = segments[cand.segIdx];
        line.push(seg[cand.endIdx === 0 ? 1 : 0]);
        used[cand.segIdx] = true;
        extended = true;
        break;
      }
    }
    extended = true;
    while (extended) {
      extended = false;
      const candidates = adjacency.get(key(line[0])) || [];
      for (const cand of candidates) {
        if (used[cand.segIdx]) continue;
        const seg = segments[cand.segIdx];
        line.unshift(seg[cand.endIdx === 0 ? 1 : 0]);
        used[cand.segIdx] = true;
        extended = true;
        break;
      }
    }
    polylines.push(line);
  }
  return polylines;
}

// --- Build one real Highcharts series per traced polyline -------------------
// Each isoline is genuine point data (hover works), not a decorative overlay.
// Lines are blended toward t.ink (more so on dark theme, where both ends of
// the seq gradient sit close in lightness to the near-black page) so isolines
// stay legible against the raster without changing the raster's own colors.
const ISOLINE_INK_BLEND = t.theme === "dark" ? 0.34 : 0.12;
const topLevel = levels[levels.length - 1];
const contourSeries = [];
levels.forEach((level, levelIdx) => {
  const polylines = stitchPolylines(marchingSquares(level)).filter(
    (line) => line.length >= 4,
  );
  if (polylines.length === 0) return;

  const isFocalLevel = level === topLevel;
  const baseColor = mixHex(t.seq[0], t.seq[1], (level - gridMin) / (gridMax - gridMin));
  const lineColor = mixHex(baseColor, t.ink, ISOLINE_INK_BLEND);
  const longest = polylines.reduce((a, b) => (b.length > a.length ? b : a));

  polylines.forEach((line) => {
    const midIdx = Math.floor(line.length / 2);
    const showLabel = line === longest && (levelIdx % 2 === 0 || isFocalLevel);
    contourSeries.push({
      name: `${level} m`,
      type: "spline",
      color: lineColor,
      lineWidth: isFocalLevel ? 3.2 : 2.2,
      marker: { enabled: false },
      showInLegend: false,
      zIndex: isFocalLevel ? 5 : 4,
      tooltip: { headerFormat: "", pointFormat: `Elevation isoline: <b>${level} m</b>` },
      data: line.map((p, idx) => {
        const point = { x: p[0], y: p[1] };
        if (showLabel && idx === midIdx) {
          point.dataLabels = {
            enabled: true,
            format: `${level} m`,
            style: { color: t.ink, fontSize: "12px", fontWeight: "600", textOutline: "none" },
            backgroundColor: t.elevatedBg,
            borderColor: t.inkSoft,
            borderWidth: 1,
            borderRadius: 3,
            padding: 3,
          };
        }
        return point;
      }),
    });
  });
});

// --- Survey area boundary (geographic context, inset within the grid) ------
const boundaryPoints = [
  [6.55, 45.3],
  [6.85, 45.2],
  [7.35, 45.22],
  [7.85, 45.28],
  [8.3, 45.35],
  [8.5, 45.65],
  [8.45, 46.0],
  [8.15, 46.3],
  [7.7, 46.4],
  [7.15, 46.38],
  [6.7, 46.15],
  [6.5, 45.75],
  [6.55, 45.3],
];
const boundarySeries = {
  name: "Survey area boundary",
  type: "line",
  color: t.inkSoft,
  dashStyle: "Dash",
  lineWidth: 2,
  marker: { enabled: false },
  enableMouseTracking: false,
  showInLegend: true,
  zIndex: 2,
  data: boundaryPoints.map((p) => ({ x: p[0], y: p[1] })),
};

// --- Ridge-crest reference line (second geographic context feature, -------
// --- a stylized topographic divide tracing the massif chain) ---------------
const ridgelinePoints = [
  [6.35, 46.42],
  [6.72, 46.08],
  [7.05, 45.58],
  [7.4, 45.62],
  [7.95, 46.02],
  [8.35, 45.42],
  [8.62, 45.28],
];
const ridgelineSeries = {
  name: "Ridge crest (reference)",
  type: "line",
  color: t.inkSoft,
  dashStyle: "Dot",
  lineWidth: 1.6,
  marker: { enabled: false },
  enableMouseTracking: false,
  showInLegend: true,
  zIndex: 3,
  data: ridgelinePoints.map((p) => ({ x: p[0], y: p[1] })),
};

// --- Highest-peak focal callout (sharpens the terrain read beyond an -------
// --- evenly-weighted four-peak overview) ------------------------------------
const peakSeries = {
  name: "Highest peak",
  type: "scatter",
  color: t.ink,
  marker: {
    enabled: true,
    symbol: "triangle",
    radius: 7,
    fillColor: t.amber,
    lineColor: t.ink,
    lineWidth: 1.5,
  },
  enableMouseTracking: false,
  showInLegend: true,
  zIndex: 6,
  dataLabels: {
    enabled: true,
    format: `Highest peak · ${Math.round(gridMax)} m`,
    y: -16,
    style: { color: t.ink, fontSize: "13px", fontWeight: "700", textOutline: "none" },
    backgroundColor: t.elevatedBg,
    borderColor: t.inkSoft,
    borderWidth: 1,
    borderRadius: 3,
    padding: 4,
  },
  data: [{ x: peakLon, y: peakLat }],
};

// --- Filled elevation raster (core Highcharts has no heatmap/colorAxis -----
// --- module — each grid cell is drawn as a real, data-colored rect) --------
function drawElevationRaster(chart) {
  const xAxis = chart.xAxis[0];
  const yAxis = chart.yAxis[0];
  grid.forEach((row, j) => {
    row.forEach((value, i) => {
      const frac = (value - gridMin) / (gridMax - gridMin);
      const x0 = xAxis.toPixels(lons[i] - halfDLon, false);
      const x1 = xAxis.toPixels(lons[i] + halfDLon, false);
      const y0 = yAxis.toPixels(lats[j] - halfDLat, false);
      const y1 = yAxis.toPixels(lats[j] + halfDLat, false);
      chart.renderer
        .rect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x1 - x0), Math.abs(y0 - y1))
        .attr({ fill: mixHex(t.seq[0], t.seq[1], frac), opacity: 0.85, zIndex: 0 })
        .add();
    });
  });
}

// --- Elevation color-scale legend (core Highcharts has no colorbar --------
// --- widget — drawn manually, same swatch idiom as a standard legend) ------
function drawElevationLegend(chart) {
  const barW = 22;
  const barH = 260;
  const barX = chart.plotLeft + chart.plotWidth + 46;
  const barY = chart.plotTop + 34;

  chart.renderer
    .rect(barX - 16, barY - 32, 150, barH + 66, 6)
    .attr({ fill: t.elevatedBg, stroke: t.inkSoft, "stroke-width": 1, zIndex: 6, opacity: 0.94 })
    .add();

  chart.renderer
    .text("Elevation", barX + 38, barY - 10)
    .attr({ align: "center", zIndex: 7 })
    .css({ color: t.ink, fontSize: "14px", fontWeight: "600" })
    .add();

  chart.renderer
    .rect(barX, barY, barW, barH)
    .attr({
      fill: {
        linearGradient: { x1: 0, y1: 1, x2: 0, y2: 0 },
        stops: [
          [0, t.seq[0]],
          [1, t.seq[1]],
        ],
      },
      stroke: t.inkSoft,
      "stroke-width": 1,
      zIndex: 7,
    })
    .add();

  [gridMax, (gridMax + gridMin) / 2, gridMin].forEach((value, idx) => {
    const y = barY + (barH * idx) / 2;
    chart.renderer
      .text(`${Math.round(value)} m`, barX + barW + 10, y + 4)
      .attr({ zIndex: 7 })
      .css({ color: t.inkSoft, fontSize: "12px" })
      .add();
  });
}

// --- Chart -------------------------------------------------------------------
Highcharts.chart("container", {
  chart: {
    type: "scatter",
    backgroundColor: "transparent",
    marginRight: 190,
    animation: false,
    style: { fontFamily: "inherit" },
    events: {
      load: function () {
        drawElevationRaster(this);
        drawElevationLegend(this);
      },
    },
  },
  credits: { enabled: false },
  title: {
    text: "contour-map-geographic · javascript · highcharts · anyplot.ai",
    style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
  },
  subtitle: {
    text: "Synthetic alpine survey grid — elevation isolines every 300 m across four peak massifs",
    style: { color: t.inkSoft, fontSize: "14px" },
  },
  xAxis: {
    min: LON_MIN - halfDLon,
    max: LON_MAX + halfDLon,
    startOnTick: false,
    endOnTick: false,
    gridLineWidth: 0,
    lineColor: t.inkSoft,
    tickColor: t.inkSoft,
    title: { text: "Longitude (°E)", style: { color: t.inkSoft, fontSize: "16px" } },
    labels: { style: { color: t.inkSoft, fontSize: "14px" }, format: "{value:.1f}°" },
  },
  yAxis: {
    min: LAT_MIN - halfDLat,
    max: LAT_MAX + halfDLat,
    startOnTick: false,
    endOnTick: false,
    gridLineWidth: 0,
    lineColor: t.inkSoft,
    tickColor: t.inkSoft,
    title: { text: "Latitude (°N)", style: { color: t.inkSoft, fontSize: "16px" } },
    labels: { style: { color: t.inkSoft, fontSize: "14px" }, format: "{value:.1f}°" },
  },
  legend: {
    itemStyle: { color: t.inkSoft, fontSize: "14px" },
    itemHoverStyle: { color: t.ink },
  },
  plotOptions: {
    series: { animation: false, states: { hover: { enabled: false } } },
  },
  series: [boundarySeries, ridgelineSeries, peakSeries, ...contourSeries],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/contour-map-geographic/highcharts/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": "highcharts",
  "page": "https://anyplot.ai/contour-map-geographic/javascript/highcharts",
  "hub": "https://anyplot.ai/contour-map-geographic",
  "code_json": "https://api.anyplot.ai/specs/contour-map-geographic/highcharts/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/highcharts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/highcharts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/highcharts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/contour-map-geographic/javascript/highcharts/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations