Skip to content

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.

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

A concentric second ring (valueSecond) reads as a sub-metric of the outer circle - e.g. "of which" a specific partner or channel:

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

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 projected lng/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 - 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.

Hexagon tiles on a honeycomb ​

Sometimes every place should weigh the same and the value belongs in colour: a tile map. Set shape: "hexagon" and positionMode: "honeycomb", and every symbol becomes an equal-size hexagon snapped to a hex lattice - tiles tessellate, never overlap, and a country's tile stays as close to its centroid as the lattice allows. colorScale (a scaleThreshold domain/range, the same contract as the Choropleth Map) carries the value; marks paint at full opacity because the colour is the encoding.

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

The demo-geography warning above applies here too: the tiles sit on an illustrative atlas, and every tile's position, and which country it stands for, is only as correct as the geography and coordinates you supply.

  • honeycomb - { radius, gap, orientation, placement, reserve }: tile circumradius in px (default 11), gap between tile edges (default 2), and "flat" (default) or "pointy" tops. Orientation drives both the hexagon outline and the lattice, so the tiles fit. placement and reserve are described below.
  • Collisions - by default (honeycomb.placement: "first") - are resolved in dataSet order: a later tile whose cell is taken walks outward ring by ring to the first free cell, deterministically. If no cell is free within six rings the tile overlaps and an onDataWarning of type layout-overflow names it.
  • honeycomb.placement: "geographic" keeps neighbours in their real order. The tile nearest a cell's centre keeps the cell, a displaced tile takes the free cell nearest its true position, and a repair pass swaps or nudges neighbouring tiles that would read east-for-west or north-for-south. The result does not depend on dataSet order. It does more work than "first" (a repair pass after the claim), about 25 ms for 200 tiles. Neighbouring pairs still in the wrong order are counted in the context (stats.outOfOrderCount, measured for every placement so "first" can be compared; under "geographic" it is what is left after the repair) and, with "geographic" only, named by an onDataWarning of type layout-out-of-order; stats.displacedCount counts tiles away from their nearest cell.
  • honeycomb.reserve: "all" lets tiles without a value claim a cell too, so the layout stays the same when the values change (a map that switches between products or years). Tiles removed by disabledItems or radiusVisibleMin still leave the layout. The default "valued" keeps no-data tiles out of the way.
  • fit: "width" lays the map out once at width x height and scales the drawing to the container (never above width), so tiles stay put when the window resizes. The default "none" draws at exact pixel size.
  • No value? Omit value. The item is painted noDataColor (default #d2d7dd, pass "transparent" to hide it), is excluded from the colour scale, and, with the default honeycomb.reserve: "valued", never claims a cell - so "no data" tiles cannot push real data around (honeycomb.reserve: "all" lets them keep a cell too, see above).
  • offset on an item nudges it in px after projection and before the snap - the escape hatch for a small country hidden under a large neighbour.
  • markers draws decorative pins above the tiles at a lng/lat (a home location): the built-in map pin, or your own path in its pathSize box, scaled to size px and anchored tip-down. Give a marker tile (a tile's id or label) to put the pin on that tile wherever the layout placed it; lng/lat remain the fallback. The context's markers keep the declared lng/lat; for a pin on a tile, read the tile's x/y from symbols. Pins are not laid out, not hit-tested and have no tooltip; style them via .symbol-map-marker. The context lists every placed symbol's x/y so you can draw your own overlays and legend.

shape: "hexagon" also works on its own with "force" or "precise" placement, and colorScale works with circles: the pieces compose.

What stays yours

The library draws tiles, pins and colours. Legend text, tooltip copy, number formatting, which country is "home" and which palette it gets are the consumer's - pass them through colorScale, markers and tooltipFormatter.

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:

ts
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.

Place symbols from the geography ​

With a geography backdrop you can leave lng/lat out: an item is then placed at the centroid of the feature whose id matches its geoId (default: the item's own id). Rows keyed by ISO code join a world file keyed the same way with no coordinate table at all; an item that matches no feature is dropped with an invalid-geometry warning that names the id it looked for.

ts
<SymbolMapChart
  geography={world}
  dataSet={[
    { id: "DEU", label: "Germany", value: 60, valueSecond: 30 },
    { id: "row-9", geoId: "FRA", label: "France", value: 40 },
  ]}
  labelFormatter={(d) => `${d.label} ${Math.round((d.valueSecond! / d.value!) * 100)}%`}
  onSymbolClick={(d) => go(d.id)}
/>

labelFormatter sets the text drawn inside a symbol that is large enough (default: the label), so a share or a value can sit next to the name; the text is still fitted to the circle. onSymbolClick(item, event) fires when a symbol is clicked, in svg and on the canvas hit-test alike; the click still pins the tooltip.

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.

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

<SymbolMapChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / seekIndex(i) / stepForward()
vue
<SymbolMapChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" />
svelte
<div use:symbolMapChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>
ts
applySymbolMapChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });
html
<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) / 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.
  • Symbols without a date stay visible in every period.

Usage ​

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

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

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

<div use:symbolMapChart={props}></div>
ts
// 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);
html
<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>
ts
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; onChartDataProcessed is only emitted when the context changes.