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.
Categorical mode - colorsMapping wins over colorScale (the sdg-trade Data Availability use case: a handful of fixed label → colour buckets, not a numeric gradient):
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:
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.
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 asSEQUENTIAL_SCHEME_NAMES(typeSequentialSchemeName). An unknown name throws an error that lists them. countruns 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/corefor you; with a strict package manager such as pnpm, add it to your own dependencies before importing from it. SymbolMapChart'scolorScaletakes 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.
const ref = useRef<ChoroplethMapChartHandle>(null);
<ChoroplethMapChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / seekIndex(i) / stepForward()<ChoroplethMapChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" /><div use:choroplethMapChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>applyChoroplethMapChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });<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>speedMssets the pace,loopwraps around,autoplay: truestarts on mount,showControl: falsehides the built-in bar.- Values glide between periods by default (
interpolate); tune the motion withtweenMsandeasing, or setinterpolate: falsefor hard cuts. Reduced motion always gets the hard cut. - The headless controller is always available:
chart.timeline()exposesplay() / pause() / toggle() / seek(period) / seekIndex(i) / stepForward() / stepBack(), plusonStepandformatPeriodin the config for custom UI. seek(period)looks for the period first, comparing as text, soseek(2021)andseek("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
datestay visible in every period.
Usage
import { ChoroplethMapChart } from "@michi-vz/react";
export default () => <ChoroplethMapChart {...props} />; // props = the chart options<script setup>
import { ChoroplethMapChart } from "@michi-vz/vue";
</script>
<template>
<ChoroplethMapChart :options="props" />
</template><script>
import { choroplethMapChart } from "@michi-vz/svelte";
</script>
<div use:choroplethMapChart={props}></div>// 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);<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>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;onChartDataProcessedis only emitted when the context changes.
- Every feature in
geographyis rendered. Legacy consumers that special-cased certain features as invisible (e.g. sdg-trade's old chart skipped Antarctica, feature idATA) should filter those features out of thegeographyprop before passing it in — the chart does not special-case any feature id.
