Skip to content

Choropleth Map ​

Geography

The library's first geo chart: shade a world or region map by value. Continuous data (export value, an index, a share) drives a threshold colour scale; categorical data (a "latest available year" bucket, a status) drives an explicit label → colour map. Geography is always a prop - this package bundles no topology data, so you import your own world/region GeoJSON and pass it straight through.

Example
canvas · responsive
Go deeper: Insights guide·DevTools guide

Categorical mode - colorsMapping wins over colorScale (the sdg-trade Data Availability use case: a handful of fixed label → colour buckets, not a numeric gradient):

Example
canvas · responsive
Go deeper: Insights guide·DevTools guide

Demo geography only - and borders are political

The world atlas on this page is a simplified public-domain GeoJSON file bundled with the docs examples for illustration. It can be wrong: boundaries, names, and shapes are NOT authoritative, and some of them are disputed. Which borders and names are "correct" differs by audience, jurisdiction and organisation, and a map that gets them wrong is a political problem, not a cosmetic one. Nothing rendered here is an endorsement of any boundary. Before production use, review the borders and naming of the geography file you pass against your organization's cartographic policy: the library renders the file as-is and applies no corrections.

The chart above is the same engine in every framework - only the integration code below differs.

Bring your own geography ​

geography accepts either a full GeoJSON FeatureCollection or a pre-normalized GeoFeatureItem[] ({ id, geometry, name? }). Nothing about the shapes is bundled in @michi-vz/core - import a world map once in your app and reuse it across every choropleth:

ts
import worldJson from "./world.json"; // your own GeoJSON FeatureCollection
import { ChoroplethMapChart } from "@michi-vz/react";

<ChoroplethMapChart
  geography={worldJson}
  dataSet={[
    { id: "USA", label: "United States", value: 2450 },
    { id: "DEU", label: "Germany", value: 1870 },
    // ...
  ]}
  colorScale={{ domain: [500, 1000, 2000], range: ["#eaf3fb", "#5b9bd5", "#123a63"] }}
/>

A FeatureCollection's per-feature id is read from the GeoJSON Feature.id (falling back to properties.id); name from properties.name. Popular sources: world-atlas (TopoJSON - convert with topojson-client's feature()), Natural Earth, or a region-specific file your own team maintains. The demo above, and this package's own examples, use a real 176-country world atlas (ISO-A3 ids) so the shapes on screen are actual coastlines - @michi-vz/core itself still bundles no topology data; the GeoJSON lives only in @michi-vz/examples, one directory up from your own app's copy.

Named colour schemes ​

colorScale.range takes resolved colours. For a named light-to-dark ramp, @michi-vz/core exports sequentialScheme(name, count): the ColorBrewer single-hue sequential schemes, with exactly the colour lists d3-scale-chromatic ships as schemePurples[k] and its siblings, and no d3 dependency.

Example
canvas · responsive
Go deeper: Insights guide·DevTools guide
ts
import { sequentialScheme } from "@michi-vz/core";

const props = {
  geography: worldJson,
  dataSet,
  // a threshold scale takes one more colour than thresholds: 4 thresholds, 5 colours
  colorScale: { domain: [200, 500, 1000, 2000], range: sequentialScheme("purples", 5) },
};
  • Six names: "blues", "greens", "greys", "oranges", "purples" and "reds", also exported as SEQUENTIAL_SCHEME_NAMES (type SequentialSchemeName). An unknown name throws an error that lists them.
  • count runs from 3 to 9 colours, light to dark: a smaller count returns 3 colours, a larger one 9, and a fraction rounds.
  • Every call returns a fresh array, so you can .reverse() it for a dark-to-light ramp without affecting other charts.
  • The framework wrappers install @michi-vz/core for you; with a strict package manager such as pnpm, add it to your own dependencies before importing from it.
  • SymbolMapChart's colorScale takes the same { domain, range } shape, so the helper works there too.
  • Colours from ColorBrewer 2.0 by Cynthia A. Brewer, Pennsylvania State University, under the Apache License 2.0.

Play through the years ​

Give every region a date and flip on timeline: the map becomes a year-by-year story with its own play button and scrubber, shading one period at a time. Off by default - nothing changes until a chart opts in.

Press the play button under the chart: it steps through the years, one snapshot at a time. Drag the scrubber to jump to any year.

tsx
const ref = useRef<ChoroplethMapChartHandle>(null);

<ChoroplethMapChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / seekIndex(i) / stepForward()
vue
<ChoroplethMapChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" />
svelte
<div use:choroplethMapChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>
ts
applyChoroplethMapChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });
html
<michi-vz-choropleth-map-chart id="c"></michi-vz-choropleth-map-chart>
<script>
  const el = document.getElementById("c");
  el.timeline = { speedMs: 1000, loop: true };
  // el.getTimeline() -> play() / pause() / seek(year) / seekIndex(i)
</script>
  • speedMs sets the pace, loop wraps around, autoplay: true starts on mount, showControl: false hides the built-in bar.
  • Values glide between periods by default (interpolate); tune the motion with tweenMs and easing, or set interpolate: false for hard cuts. Reduced motion always gets the hard cut.
  • The headless controller is always available: chart.timeline() exposes play() / pause() / toggle() / seek(period) / seekIndex(i) / stepForward() / stepBack(), plus onStep and formatPeriod in the config for custom UI.
  • seek(period) looks for the period first, comparing as text, so seek(2021) and seek("2021") both land on 2021 whether your dates are numbers or strings. A number counts as a position (0 = first) only when no period matches, and a string that matches none does nothing. seekIndex(i) always goes by position, as the built-in scrubber does.
  • Regions without a date stay visible in every period.

Usage ​

tsx
import { ChoroplethMapChart } from "@michi-vz/react";

export default () => <ChoroplethMapChart {...props} />; // props = the chart options
vue
<script setup>
import { ChoroplethMapChart } from "@michi-vz/vue";
</script>

<template>
  <ChoroplethMapChart :options="props" />
</template>
svelte
<script>
  import { choroplethMapChart } from "@michi-vz/svelte";
</script>

<div use:choroplethMapChart={props}></div>
ts
// main.ts - register the elements once
import "@michi-vz/angular";
import { applyChoroplethMapChartProps } from "@michi-vz/angular";

// component (uses CUSTOM_ELEMENTS_SCHEMA)
// template: <michi-vz-choropleth-map-chart #c></michi-vz-choropleth-map-chart>
applyChoroplethMapChartProps(this.c.nativeElement, props);
html
<script type="module" src="https://cdn.jsdelivr.net/npm/@michi-vz/wc/dist/michi-vz-wc.bundle.js"></script>

<michi-vz-choropleth-map-chart id="c"></michi-vz-choropleth-map-chart>
<script>
  Object.assign(document.getElementById("c"), props); // geography, dataSet, title, …
</script>
ts
import { mountChoroplethMapChart } from "@michi-vz/core";

const chart = mountChoroplethMapChart(el, props);
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();

API ​

Props are typed as ChoroplethMapChartProps in @michi-vz/core. Shared across all charts: width, height, margin, colors / colorsMapping, renderer ("svg", "canvas", or experimental "webgpu"), highlightItems, disabledItems, and the on* callbacks. onChartDataProcessed / getContext() return the renderer-agnostic ChartContext.

Behaviour notes ​

Joining dataSet to geography - joinBy ​

Each dataSet row ({ id, label, value?, color? }) is matched against a geography feature by joinBy: "id" (default) matches ChoroplethDataItem.id against GeoFeatureItem.id / GeoJSON Feature.id - the clean, stable join (e.g. ISO-A3 codes). "name" matches label against GeoFeatureItem.name / properties.name instead, for data keyed by country name rather than code. A feature with no matching row (or a row in disabledItems) renders noDataColor and its tooltip receives the fallback { id, name } shape instead of the full row.

Colour resolution precedence ​

colorsMapping[label] (categorical, explicit) wins over colorScale (continuous - a resolved hex range + numeric domain, built into a d3 scaleThreshold; values outside the domain clamp to the first/last range colour) wins over the row's own color wins over the auto-assigned colors palette. Core stays free of d3-scale-chromatic - pass already-resolved hex colours to colorScale.range, from core's own sequentialScheme or from anywhere else in your app.

Projections - all 13, one default ​

projection dispatches to any of geoEqualEarth, geoMercator, geoTransverseMercator, geoAlbers, geoAlbersUsa, geoAzimuthalEqualArea, geoAzimuthalEquidistant, geoOrthographic, geoConicConformal, geoConicEqualArea, geoConicEquidistant, geoRobinson (the default), and geoGilbert (both from d3-geo-projection). projectionConfig (scale, rotate, center, parallels) fine-tunes the chosen projection; omitted fields fall back to the legacy sdg-trade MapChoropleth chart's own defaults (rotate: [-18, 0], center: [0, 10], a width-derived base scale) rather than projection.fitSize - this matches that chart's visual result exactly rather than re-fitting to a different framing. geoAlbersUsa is a fixed composite projection: it ignores rotate / center / parallels.

Rendering: SVG, canvas, and a delegated WebGPU ​

renderer="svg" (default) draws one <path class="region"> per feature. renderer="canvas" draws via geoPath(projection, ctx) - d3-geo renders natively and efficiently straight to a 2D canvas context, so nothing re-parses the SVG path string or reimplements projection math. renderer="webgpu" delegates to the same canvas-2D renderer rather than tessellating arbitrary polygons on the GPU: real-world regions are frequently concave, multi-ring, and hole-containing (an enclave, an island group), and correct GPU triangulation of arbitrary simple polygons needs real ear-clipping - disproportionate scope next to d3-geo's already-fast native 2D path rendering for a typically dozens-to-low-hundreds-of-paths chart. It still capability-gates on navigator.gpu and reports the effective renderer via getContext().renderer, same as every other chart.

Hover / tooltip ​

tooltipFormatter(d) receives the full ChoroplethDataItem for a matched region, or { id, name } for an unmatched one. In canvas/webgpu mode, hovering is resolved by re-projecting each feature's raw geometry and running a point-in-polygon test (not a bounding-box approximation), so irregular borders hit-test correctly. Hovering dims every other region (the house's standard highlightItems behaviour) rather than a persistent CSS :hover border-darken - the legacy chart's own hover effect.

Loading / no-data ​

isLoading and isNodata drive the overlay (React: isLoadingComponent / isNodataComponent), identical to every other chart in the house.

Consumer colour authorities: the context carries legendData ({ label, color, dataLabelSafe }) so a CSS-injection colour system can key per-label rules; onChartDataProcessed is only emitted when the context changes.

  • Every feature in geography is rendered. Legacy consumers that special-cased certain features as invisible (e.g. sdg-trade's old chart skipped Antarctica, feature id ATA) should filter those features out of the geography prop before passing it in — the chart does not special-case any feature id.