Symbol Map
Geography
Chart #20: a force-de-overlapped bubble map. Migration target for legacy sdg-trade MapSymbolForce - a dot-only bubble map where each item's coordinates project onto the plane, then a one-shot force simulation nudges overlapping circles apart just enough to be readable, without moving them far from their true position. Unlike Choropleth Map, geography is optional here: omit it for the legacy chart's own dot-only look (the default), or supply it to draw a muted backdrop landmass behind the symbols.
A concentric second ring (valueSecond) reads as a sub-metric of the outer circle - e.g. "of which" a specific partner or channel:
Precise positions or force de-overlap
positionMode chooses what a symbol's position means:
"force"(default, legacy parity): the one-shot simulation pulls colliding circles apart. Readable when many symbols crowd one area, but every symbol can drift from its true coordinates, and on small plots the drift can be large."precise": every symbol stays at its exact projectedlng/lat; overlapping circles are allowed.
A symbol drawn over the wrong country is a cartographic-accuracy problem and, for some audiences, a politically sensitive one. Prefer "precise" whenever a geography backdrop is visible: a landmass invites reading positions literally.
Switch to force and watch the bubbles drift off their true coordinates to resolve collisions. With a visible landmass, precise is usually the honest choice.
Demo geography only
The world atlas on this page is a simplified public-domain GeoJSON file bundled with the docs examples for illustration. Boundaries, names, and shapes are NOT authoritative. Review the borders and naming of the geography file you pass against your organization's cartographic policy before production use: 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 coordinates
Unlike the legacy chart (which bundled a ~200-row static country coordinate CSV), @michi-vz/core ships no coordinate table. Each dataSet item supplies its own lng/lat:
import { SymbolMapChart } from "@michi-vz/react";
<SymbolMapChart
dataSet={[
{ id: "usa", label: "United States", lng: -95.7, lat: 38.9, value: 100 },
{ id: "deu", label: "Germany", lng: 13.4, lat: 52.5, value: 60, valueSecond: 30 },
// ...
]}
/>Because the extent used to fit the dot-only layout to the plot is derived from your own dataset's coordinates (not a bundled, always-full-world table), the "world" the chart draws only spans as far as the points you pass in - see Behaviour notes below.
Play through the years
Give every marker a date and flip on timeline: the map becomes a year-by-year story with its own play button and scrubber, placing one period's symbols 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<SymbolMapChartHandle>(null);
<SymbolMapChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / stepForward()<SymbolMapChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" /><div use:symbolMapChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>applySymbolMapChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });<michi-vz-symbol-map-chart id="c"></michi-vz-symbol-map-chart>
<script>
const el = document.getElementById("c");
el.timeline = { speedMs: 1000, loop: true };
// el.getTimeline() -> play() / pause() / seek(year)
</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) / stepForward() / stepBack(), plusonStepandformatPeriodin the config for custom UI. - Symbols without a
datestay visible in every period.
Usage
import { SymbolMapChart } from "@michi-vz/react";
export default () => <SymbolMapChart {...props} />; // props = the chart options<script setup>
import { SymbolMapChart } from "@michi-vz/vue";
</script>
<template>
<SymbolMapChart :options="props" />
</template><script>
import { symbolMapChart } from "@michi-vz/svelte";
</script>
<div use:symbolMapChart={props}></div>// main.ts - register the elements once
import "@michi-vz/angular";
import { applySymbolMapChartProps } from "@michi-vz/angular";
// component (uses CUSTOM_ELEMENTS_SCHEMA)
// template: <michi-vz-symbol-map-chart #c></michi-vz-symbol-map-chart>
applySymbolMapChartProps(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-symbol-map-chart id="c"></michi-vz-symbol-map-chart>
<script>
Object.assign(document.getElementById("c"), props); // dataSet, title, …
</script>import { mountSymbolMapChart } from "@michi-vz/core";
const chart = mountSymbolMapChart(el, props);
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();API
Props are typed as SymbolMapChartProps 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
The one-shot force de-overlap
Every item's lng/lat projects to a "true" pixel position; forceX/forceY pin the simulation to that exact position as its target, forceManyBody() adds mild separation, and forceCollide (radius + 2px padding, 3 iterations) is what actually pushes overlapping circles apart. The simulation settles synchronously to the SAME alpha threshold the legacy chart used (0.0011) - deterministic: identical inputs always settle to the identical layout, so two mounts of the same data produce pixel-identical positions. Two items with the exact same coordinates start stacked and separate cleanly once the simulation settles. Set positionMode: "precise" to skip the simulation entirely: every symbol then stays at its exact projected position and overlaps are allowed (see the toggle demo above).
Dot-only vs. backdrop projection
With no geography, the chosen projection (default "geoMercator", matching the legacy chart) is used untuned - a bare projection factory with none of Choropleth Map's translate/scale/rotate/center tuning - and the projected point extent is then rescaled to fill the plot, mirroring the legacy chart's own math. With geography supplied, the SAME tuned dispatch Choropleth Map uses takes over instead, so the backdrop landmass and the symbol coordinates share one consistent geographic framing (no extent rescale).
radiusVisibleMin - filtered on the raw value
radiusVisibleMin hides items whose raw value is at or below the threshold (and, when valueSecond is set, whose valueSecond is also below it) before the force layout runs - ported from the legacy chart's own filter, which compared the raw value, never the scaled radius. The radius/opacity scale's domain is still built from every item with valid coordinates regardless of this filter, so a symbol's radius stays comparable across renders even as the threshold changes.
The concentric second ring
valueSecond draws a second circle, same colour as the outer one, at opacity - 0.3 (clamped to non-negative), drawn ON TOP of the primary circle - ported from the legacy ForceNode's exact layering. When the second circle is smaller than the outer one it reads as a dimmer core inside a lighter ring (e.g. "of which"); when larger, it fully covers the outer circle.
Optional backdrop geography
geography (a GeoJSON FeatureCollection or a pre-normalized GeoFeatureItem[], same contract as Choropleth Map's geography) is a new capability - the legacy chart never drew landmass. Omit it for the dot-only legacy look; supply it for a muted, non-interactive backdrop (geographyColor, default #eef1f5) behind the symbols.
Rendering: SVG, canvas, and a delegated WebGPU
renderer="svg" (default) draws one <circle class="symbol"> per visible item (plus an optional <circle class="symbol-second">). renderer="canvas" paints the same marks to a 2D canvas, resolving fill colour through the same consumer-CSS probe every single-mark chart uses. renderer="webgpu" delegates to the canvas-2D renderer, same rationale as Choropleth Map: the optional backdrop is arbitrary (possibly concave, multi-ring) GeoJSON, so correct GPU tessellation is disproportionate scope here.
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.
