apexmaps
v0.3.0
Published
Interactive geographic data visualization and storytelling for the ApexCharts ecosystem
Maintainers
Readme
ApexMaps
Interactive geographic data visualization and storytelling for the ApexCharts ecosystem.
Status: phase 1, pre-alpha (0.3.0). Written in TypeScript. The engine, five series (choropleth, bubble, marker, arc, line), projections, joins, scales, pattern and image fills, legend, tooltip, labels, annotations, camera, geometry registry, drilldown, clustering, selection and accessibility layer are working and tested. The story engine and tiles are not built yet.
import ApexMaps from 'apexmaps'
const map = new ApexMaps(document.querySelector('#map'), {
geo: { map: 'world/countries@110m' },
series: [
{
name: 'Unemployment rate',
joinBy: ['iso_a3', 'code'],
data: [{ code: 'FRA', value: 7.3 }, { code: 'DEU', value: 5.7 }],
},
],
})
await map.render()That is the whole example. No geometry to find, host or parse, and no projection, palette, classification, legend, label or tooltip configuration: the defaults are meant to be publishable.
What works today
| Area | Detail |
|---|---|
| Series | choropleth, bubble (proportional symbols), marker (seven shapes, categorical colour, clustering †), arc (great-circle connections, travelling flow beads) †, line (routes through given vertices) †, plus an automatic basemap whenever no feature series is present |
| Projections | 13 core projections with aliases (equalEarth default, webMercator, epsg:3857, albersUsa, orthographic, conics, azimuthals), spec objects with rotate / parallels / clipAngle, and ApexMaps.registerProjection() † for the rest of d3-geo-projection |
| Geometry | 26 built-in packs: world countries and land, US states and counties, EU NUTS 0-3, and admin-1 for 15 more countries. Lazy, one request per pack, provenance and attribution attached |
| Data | GeoJSON, TopoJSON, bare geometry, feature arrays; automatic winding repair; join-key auto-detection |
| Joins | Explicit joinBy, mismatch diagnostics with suggestions, FIPS leading-zero repair, opt-in fuzzyJoin |
| Scales | quantile, equal interval, Jenks, threshold, linear, log, sqrt, ordinal; OkLab-sampled ramps; 17 palettes; automatic diverging selection; square-root size scales with nested-circle legends |
| Fills | Flat colour, or eight pattern tiles with automatic ink contrast and patterned legend swatches †, or an image per region clipped to its outline † |
| Interaction | Anchored wheel zoom, inertial pan, pinch, double-click zoom, versor globe dragging on orthographic, hover states, click and box selection with dimming, cross-map linked selection †, legend class muting, drilldown with automatic parent detection and a breadcrumb † |
| Camera | flyTo (Van Wijk zoom-and-pan path), easeTo, jumpTo, fitBounds, frameFeature, resetView, interruptible and retargeting; on azimuthal projections a move to a place turns the sphere (quaternion slerp) instead of panning |
| Components | Classed, gradient and nested-circle legends with a hover marker that tracks the pointer along the bar, HTML tooltips with edge flipping, collision-avoiding labels with halos, editorial annotations † |
| Accessibility | ARIA roles, auto-generated description, roving-tabindex keyboard navigation, live-region announcements, optional data table, prefers-reduced-motion |
| Platform | TypeScript source with a discriminated Series union, ESM / UMD / IIFE builds, emitted declarations, SSR-safe import, 75 kB gzipped core |
| Frameworks | react-apexmaps, vue-apexmaps and ngx-apexmaps, typed against this package's own options |
† Licensed feature. It works without a key so you can evaluate it, with a watermark on the map. See Licensing for the whole list and the reasoning.
React
npm install apexmaps react-apexmapsimport ApexMaps from 'react-apexmaps'
<ApexMaps
options={{ geo: { map: 'world/countries@110m' } }}
series={[{ name: 'Unemployment rate', joinBy: ['iso_a3', 'code'], data }]}
onFeatureClick={({ key }) => setSelected(key)}
height={480}
/>Props are compared deeply, so passing a fresh options object on every parent render (which is what
React does) is not a redraw, and a series-only change still tweens rather than rebuilding the DOM. See
wrappers/react for the props, the events and the sizing rules.
Vue 3
npm install apexmaps vue-apexmaps<ApexMaps :options="options" :series="series" :height="480" @feature-click="onClick" />Built for the Vue change model rather than adapted from the React one: mutating options in place on
reactive state is seen, and no reactive proxy ever reaches the map. See wrappers/vue.
Angular
npm install apexmaps ngx-apexmaps<apx-map [options]="options" [series]="series()" [height]="480" (featureClick)="onClick($event)" />A standalone component with signal inputs, zoneless-ready, and peers of @angular/core and
apexmaps alone. The map runs outside the Angular zone (pointer traffic never triggers change
detection) and outputs re-enter it, so handlers that set state repaint in zoned and zoneless apps
alike. See wrappers/angular. A Svelte wrapper will live in the same tree and is
not built yet.
Try the examples
npm install
npm run examples # builds, then serves on http://localhost:8084/examples/
npm run check:examples # loads every demo and fails on one that errors or draws nothingThat is all: the geometry packs are committed under geo/, so nothing has to be downloaded or
generated first. npm run data:build only exists to regenerate them from source.
One page per feature, so each demo loads on its own, can be linked to directly, and cannot be broken by an unrelated one:
| Demo | Shows | |---|---| | choropleth | What two option keys and no styling produce | | scales | Quantile, Jenks, equal interval and threshold on one skewed dataset | | palettes | All 17 ramps, and the automatic diverging choice | | normalize | Counts versus rates, side by side | | projections | Eight projections, spec objects, rotation | | basemap | Geometry with no data, and label collision | | patterns | A tile per region, with the colour still leading | | image-fill | A picture per region, clipped to its own outline | | bubbles | Square-root versus linear sizing, second colour encoding | | markers | Seven shapes, categorical colour, clustering | | arcs | Great circles, antimeridian cutting, curvature, travelling flow | | registry | Three packs with no configuration, plus the catalogue | | joins | A failing join explained, then repaired | | drilldown | States into counties, and back out | | selection | Box selection brushing a linked pair of maps | | camera | flyTo, easeTo, fitBounds, interruption, flying across a globe | | a11y | Keyboard navigation, generated description, data table | | theming | Dark mode, CSS custom properties, responsive rules | | extending | registerMap with a floor plan, registerPalette, a loader function | | bench | Frame times in your own browser, up to 3,231 features |
npm run check:examples opens all of them in headless Chromium and fails on a console error, a demo
that never becomes ready, a map that drew zero marks, or a page missing from the index. The blank-map
case is the one worth automating: a demo that throws is obvious, while a demo that renders nothing
because a pack id moved looks fine in a diff. Playwright is not a dependency here (a browser download
does not belong in a clone that only runs unit tests), so the check resolves it from wherever it
already exists and reports itself as skipped rather than passing when it finds none.
The geometry registry
Finding, converting and hosting boundaries is the tax on every map project, and it is paid before any chart is drawn. So the geometry is part of the product:
geo: { map: 'world/countries@110m' } // canonical id
geo: { map: 'world/countries' } // detail-free: the lightest one
geo: { map: 'us' } // states
geo: { map: 'jp/prefectures' } // the country's own word for its tier
geo: { map: 'eu/nuts2@20m' } // Eurostat regions26 packs cover world countries and coastline, US states and all 3,231 counties, NUTS levels 0 to 3, and admin-1 for China, India, Japan, Germany, the UK, France, Italy, Canada, Brazil, Russia, Mexico, Australia, South Korea, Spain and Indonesia. Nothing is fetched until a pack is used, and one pack is one request no matter how many aliases or maps on the page ask for it.
Four things travel with each pack, and each of them removes a bug the caller would otherwise hit.
A recommended join key. Data is keyed the way data is actually keyed, so us/states joins on
"CA" and admin-1 packs join on ISO 3166-2 codes like "DE-BY". Generic detection cannot get this
right: an admin-1 feature also carries adm0_a3, and choosing it would give all 47 Japanese
prefectures the key JPN. TIGER geometry carries only FIPS codes, so the pipeline adds the USPS
abbreviations.
Repaired identifiers. Natural Earth publishes ISO_A3 as -99 for France and Norway, among
others, because it splits their overseas parts into separate features. Anyone joining on iso_a3
loses two countries silently. The pipeline repairs them once, at build time, and records how.
A recommended projection and view, for the packs where the generic default is not merely
suboptimal but wrong. us gets albersUsa, because Alaska's Aleutians cross the antimeridian and
otherwise the United States spans the whole width of the world. NUTS packs get EPSG:3035 and a
mainland view, because the geometry reaches from French Guiana to Réunion and fitting all of it
leaves Europe a few pixels across. An explicit geo.projection or geo.view always wins.
Provenance. Source, licence, vintage, boundary policy and detail level, available through
ApexMaps.mapMeta(id) and ApexMaps.catalogue(). Required attribution renders automatically, so a
NUTS map credits EuroGeographics whether or not anyone remembered to.
The dataset ships as a separate package, apexmaps-geo, versioned independently of
the library. Not for bundle reasons (packs are fetched at runtime and never enter your bundle) but
because boundaries change on their own schedule: NUTS is revised every three years, US counties
re-district on the census, and countries rename themselves. A boundary correction should not require
a library upgrade, and a library patch should not republish 6.7 MB of unchanged JSON.
By default packs are fetched from jsDelivr, so nothing needs installing. Install the dataset when you want the files locally, for offline work, air-gapped deployments, or build-time resolution:
npm install apexmaps-geoApexMaps.setGeoSource((file) => import(`apexmaps-geo/${file}`).then((m) => m.default)) // no network
ApexMaps.setGeoSource('https://cdn.example.com/apexmaps-geo/') // self-hostedStatus:
apexmaps-geois prepared but awaiting its first publish, so the default source 404s until then. Pass geometry directly or callsetGeoSource()in the meantime.
In this repository the packs are committed under geo/, which is both what the examples read and what
gets published. npm run data:build regenerates them from Natural Earth, US Census TIGER and Eurostat
GISCO.
Performance
Measured in headless Chromium with the frame rate unclamped, so the intervals reflect work rather
than the display's refresh rate. npm run examples then open bench.html to
run it on your own machine.
| Pack | Features | Parse | Render | Reproject | JS per frame | Frame p50 | Frame p95 |
|---|---|---|---|---|---|---|---|
| world/countries@110m | 177 | 0.4 ms | 29 ms | 14 ms | 0.1 ms | 0.5 ms | 0.9 ms |
| eu/nuts3@20m | 1,514 | 1.5 ms | 42 ms | 33 ms | 0.1 ms | 1.2 ms | 1.6 ms |
| us/counties@10m | 3,231 | 3.0 ms | 331 ms | 87 ms | 0.1 ms | 2.4 ms | 3.1 ms |
| us/counties@10m + labels | 3,231 | 2.5 ms | 312 ms | 83 ms | 0.0 ms | 2.5 ms | 3.3 ms |
The budget is a p95 pan and zoom frame under 16 ms at 3,000 features. It comes in at 3.1 ms with 3,231 features, and 0.1 ms of that is library code.
That last column is the design. Features live in world space under a single group, so panning and
zooming write one transform on one element: the per-frame cost in our code is constant in the
feature count, and what scales is the browser rasterising paths it already has. Only marks whose
pixel size carries meaning (bubble radii, label positions) are rewritten per frame, and that cost is
O(marks), not O(features).
Two things worth knowing from the numbers. The 331 ms initial render for counties is mostly
projection maths: albersUsa is a composite of three sub-projections with a point-in-region test per
coordinate, and it costs roughly three times a simple projection, which is the price of insetting
Alaska and Hawaii. Labels are culled by projected area before any per-label work, so 3,142 candidates
produce 69 placed labels without measuring 3,142 of them.
CI enforces the invariant rather than the milliseconds (test/perf.test.ts): a camera frame writes exactly one attribute regardless of feature count, and feature path data is byte-identical before and after a pan. A wall-clock assertion would flake on a loaded runner, get skipped, and then nothing would be enforced, and reprojecting 3,000 features per frame would still pass a generous millisecond budget on a fast laptop.
The join diagnostic
Around nine in ten real-world map failures are join failures, and every library renders them as silent grey. In development ApexMaps prints what actually happened:
join: 3/6 data rows matched 3/177 features (geometry key "name", data key "name")
3 data row(s) did not match geometry:
"Ivory Coast" -> did you mean "Côte d'Ivoire"?
"United States" -> did you mean "United States of America"?
"Democratic Republic of the Congo" -> did you mean "Dem. Rep. Congo"?
174 feature(s) had no data (rendered as no-data): Fiji, Tanzania, W. Sahara, ...Available programmatically as map.diagnoseJoin(). Matching stays exact by default, because silently
guessing turns an obvious failure into a plausible wrong answer. Pass fuzzyJoin: true to apply the
suggestions, and the report lists every substitution it made.
One row can legitimately colour several shapes, and the report says when it does. Natural Earth gives
Australia, the Indian Ocean Territories and Ashmore and Cartier Islands the same iso_a3, and Lord
Howe Island carries AU-NSW alongside New South Wales, so a key maps to a list of features rather
than to one. Keeping only the last would leave mainland Australia grey while colouring an uninhabited
island.
Pattern and image fills
Licensed:
fill.patternandfill.image. Licensed features work without a key so you can evaluate them, with a watermark on the map. See Licensing.
A flat fill is one colour per feature. fill adds a second channel over the top of it, and there are
three situations where it is not decoration:
- Print and photocopy. A five-step ramp collapses to about three once it has been through a photocopier or a mono laser printer. A tile per class survives both.
- Colour-vision deficiency. Two classes of a diverging ramp can read as one. A different tile keeps them apart without abandoning the ramp.
- Kinds rather than amounts. When the classes are categories, a sequential ramp implies an order that is not in the data. Texture carries the distinction and the colour stops doing work it should not be doing.
series: [{ data, fill: { pattern: { type: 'dots' } } }]The tile background is the colour the scale already chose, so the pattern is added to the encoding rather than replacing it, and the ink defaults to whatever stays legible against that colour: white on a mid-tone or dark class, a darkened tint on a pale one. A whole ramp therefore reads without being configured class by class.
Eight tiles: dots, squares, checks, lines, grid, diagonal, crosshatch, and custom
with your own path. Each takes size, color, background, strokeWidth, angle and
opacity.
size is the spacing between marks, not the size of one, and the marks are deliberately small
against it: 10px spacing by default, with a dot covering about a twelfth of its tile and a bar a
fifth of its width. That ratio is the whole design. Tighten it and the ink starts averaging with the
fill into a shade that is on no scale, neighbouring classes stop being separable, and the map reads
as clogged rather than as drawn. The colour is still doing the work; the tile is a mark on it.
For the qualitative case, the pattern is a function of the feature:
const TILES = ['dots', 'diagonal', 'grid', 'crosshatch', 'lines']
fill: {
pattern: ({ classIndex, color, value, datum, key }) => ({ type: TILES[classIndex], size: 7 }),
}Legend swatches show the tile, not a flat colour, off the same builder the map uses: a patterned map with plain swatches tells the reader the texture means nothing.
The tile holds its size on screen as the reader zooms, on the same reasoning as the borders'
non-scaling-stroke: texture that grows with the camera stops reading as a fill and starts reading
as geometry.
No-data areas are never textured, and nor is a class muted from the legend. An absence has to keep reading as an absence rather than as one more category.
A picture per region
The same mechanism carries imagery. Each feature's own outline clips an image fitted to its bounding box, which is how a map of flags, satellite crops or portraits is built:
series: [{
data,
fill: {
image: {
src: ({ key }) => `/flags/${key.toLowerCase()}.svg`,
fit: 'cover', // 'contain' fits the whole image inside the box; 'fill' stretches
},
},
}]src runs per feature and may return null to decline, which leaves that feature on its flat
colour. background (the feature's colour by default) shows through a contain fit and while the
file is still loading, so a slow image degrades to the choropleth rather than to a hole. Unlike a
texture tile, an image scales with the region: it is pinned to the ground it describes.
Under the hood both are one <pattern> in <defs> per distinct appearance, referenced by fill.
Five classes across three thousand features is five defs. Image fills are inherently per-feature,
because the tile is positioned on that feature's box, so this is the wrong tool for three thousand
counties.
Two things to know before you ship image fills:
- PNG export and CORS. Exporting to PNG rasterises through a canvas, and a browser refuses to
read back a canvas that an image from another origin has touched. Same-origin files, or a host
sending
Access-Control-Allow-Origin, export cleanly. SVG export is unaffected either way. fit: 'fill'with an SVG source may not stretch. A referenced SVG brings its own aspect handling, and per spec it wins over the element referencing it.'cover'is handled: the crop is computed from the source's measured aspect ratio rather than asked for with an attribute, precisely so an SVG flag is not quietly letterboxed inside the region with the fill colour around it.'contain'needs nothing. Only'fill'still depends on the file, which needspreserveAspectRatio="none"of its own to distort. Raster sources are unaffected throughout.
examples/patterns.html and examples/image-fill.html are the two demos.
Bubbles and arcs
Licensed: the
arcandlineroute series. Bubbles are free. Licensed features work without a key so you can evaluate them, with a watermark on the map. See Licensing.
Both series exist to say something a choropleth cannot.
Bubbles are for absolute magnitudes. A choropleth of totals mostly redraws the map of where big areas are; a circle's size is independent of the polygon under it. Three defaults are chosen for you because the alternative is usually wrong: square-root radius scaling so area is proportional to value, largest-first paint order so small circles stay clickable, and a radius range derived from the plot size. The size legend nests three reference circles at round values, which is the only bubble legend a reader can actually decode.
Arcs are for connections: routes, cables, trade, migration. They are geodesics by default,
because the shortest path between two places is a great circle and a straight line on a projected map
is neither the route nor the right length. Emitting each arc as a lon/lat LineString lets d3-geo cut
it at the antimeridian, so a trans-Pacific route leaves the right edge and re-enters on the left
instead of streaking backwards. curvature is available for the decorative fanned look and says so in
a dev-mode warning, since a bulged arc is no longer the true path.
series: [
{ type: 'bubble', name: 'Population', data: cities },
{ type: 'arc', name: 'Routes', data: routes, endpoints: { show: true }, flow: true },
]flow sends beads along each route, from from towards to. It exists because an arc says two
places are related without saying which way anything moves, and on a hub map every route leaves the
same airport, so the reader cannot infer it either. flow: true is the whole option: the bead takes
the route's colour, its size from the route's width, and its phase from a hash of the route's key, so
many departures out of one hub read as traffic rather than as a single pulse. { style: 'dash' }
marches a dashed highlight instead. speed and spacing are screen pixels in the view the map opened
at: the beads are anchored to the ground, so zooming in spreads them with the geography rather than
fitting more of them onto the same route. Each of the three is bounded, because each degenerates
differently at the far end of the camera: size at three times what it opened at, pace at twice, spacing
at six, past which the flow looks the same however far the reader keeps going. { scale: 'screen' }
holds all three fixed for a dashboard where the beads are furniture rather than geography. Under
prefers-reduced-motion, with
chart.animations.enabled: false, or past 600 routes on one series, the beads stay where they are and
stop travelling: a dotted route still reads as a route.
Rendering follows from what each mark encodes: arcs live in world space and scale with the camera, while bubbles live in screen space and hold their radius, because that radius carries a value. Thin arcs get an invisible wider hit path so a 1px flight line is still hoverable, and a flow's beads are one more companion path, in a group of their own above the routes so that the only content that repaints every frame is separated from the geometry that never moves.
Markers and clustering
Licensed: clustering. Markers themselves are free. Licensed features work without a key so you can evaluate them, with a watermark on the map. See Licensing.
A marker says "something is here", so its size is fixed. The moment size varies, a reader starts decoding it as a quantity, and that is the bubble series, which scales by area and ships a legend that can be decoded.
series: [
{
type: 'marker',
name: 'Sites',
data: sites, // { name, lon, lat, kind }
shape: 'pin', // circle, square, diamond, triangle, star, cross, pin
colorBy: 'kind', // ordinal scale plus a legend, automatically
cluster: { radius: 55, maxZoom: 6 },
},
]Seven shapes, each generated as a path, so they scale without a sprite sheet, an icon font or an
image load that can fail CORS. pin is the only one anchored at its point rather than its centre,
because a pin floating above the place it marks is a pin pointing at nothing. Every mark gets an
invisible hit circle, so a 6px star does not demand pixel-perfect aim.
Clustering is an option on the marker series, not a series of its own. The data is identical either way: clustering is a decision about how to draw points that would otherwise pile up, in the same way classification is a decision about how to colour values. A separate series type would fork position resolution, hit testing, colouring and the legend, and would force you to swap series types at a zoom threshold.
Three things the clustering does that a quick implementation usually does not:
- Merges by distance, not by grid cell. Bucketing points into cells is simpler, but it separates two points a few pixels apart that happen to straddle a boundary while merging two at opposite corners of one cell. Readers notice, because the map contradicts what they can see. A grid is used only as a neighbour index; the merge test is real distance.
- Clusters in world space at quantized zoom levels. Panning never reclusters, so counts never shimmer or renumber under the cursor, and a smooth pinch recomputes a handful of times instead of sixty times a second.
- Places each cluster at its members' centre of mass, sized by the square root of the count, so a cluster of 100 does not read as a hundred times a cluster of 1.
Clicking a cluster flies to the bounds of its members, and emits clusterClick first if you would
rather do something else. Members that share a position would give a zero-size box and ask the camera
for infinite zoom, so that case steps in by a fixed amount instead.
Drilldown
Licensed: drilldown, and the breadcrumb with it. Licensed features work without a key so you can evaluate them, with a watermark on the map. See Licensing.
Click a state, get its counties. One option:
series: [
{
name: 'Adoption',
joinBy: { data: 'key' },
data: rows,
drilldown: { map: 'us/counties' },
},
]The child level is restricted to the feature that was clicked, which is the part that makes it a drilldown rather than a zoom: showing all 3,231 US counties after clicking California leaves the reader to find California again. Which counties belong to California is not configuration, because published hierarchical geometry already says so, in one of two ways:
- A property naming the parent. TIGER counties carry
state_abbrandstate_fips, Eurostat NUTS carriescntr_code, Natural Earth admin-1 carriesadm0_a3. The field is detected by matching against the parent's key, scoring candidates by how many children they match so a coincidence in one row cannot pick the hierarchy. - A key prefix. County FIPS
06037sits under state FIPS06, NUTSDE12underDE1. This is the fallback, and it is what carries NUTS below level 1 wherecntr_codestops distinguishing levels.
Set parentField to name the property yourself, or scope: 'all' to draw the whole child map. When
neither route matches anything, the drilldown is declined rather than performed, with the reason
in the dev-mode diagnostics: landing on an empty map costs the reader the level they were reading and
gives nothing back.
Going deeper is a function of the clicked feature, so levels can differ, and returning null
declines:
drilldown: {
map: (context) => (context.depth === 1 ? 'eu/nuts2' : 'eu/nuts3'),
animate: 'zoom', // zoom, hand over, dissolve, divide. 'none' cuts
breadcrumb: { rootLabel: 'Europe' },
}Data for the deeper level can arrive two ways. Rows for every level can live in one array, since the join takes whatever matches the level on screen, and the diagnostic says so instead of reporting the other level's rows as a broken join. Or fetch per level from the event, which fires once the child is already on screen:
map.on('drilldown', ({ key, name, depth, featureCount }) => {
fetchCounties(key).then((rows) => map.updateSeries([{ ...series, data: rows }]))
})
map.on('drillup', ({ depth }) => {})Getting back out is deliberately over-provided, because a drilldown with no visible exit is a trap:
the breadcrumb above the map (real buttons, keyboard reachable), Escape, or drillUp() /
drillUp(Infinity). None of them refetch: each level's geometry is still held, so climbing back is
synchronous. drillTo(key) drills programmatically, and drillDepth says where you are. The way in
works from the keyboard too: Enter on a focused feature drills exactly where a click would.
Two details worth knowing. The level change is continuous rather than a cut, in four steps: the camera
frames the clicked feature; the child level is handed the exact box on screen that feature
occupied instead of its own fit; the level being left dissolves out over it; and the child develops
out of the parent's own colour, each county's fill and boundary arriving together in a ripple from the
middle outwards. Because the child covers the same geography, nothing about the map moves across the
swap, and because it starts as a flat copy of the parent, it is never simply there: what the reader
sees is California dividing into its counties while its neighbours fade. Climbing back runs the camera
half in reverse, from wherever inside the child level the reader had moved to. Everything above is one
transition per mark on --apexmaps-anim, so it follows chart.animations.speed, degrades with the
motion budget, and turns into a plain swap under prefers-reduced-motion. And the selection does not
survive a level change, because those keys belong to the level you left.
Selection, and linked maps
Licensed: linking maps with
link: { group }. Selection itself is free. Licensed features work without a key so you can evaluate them, with a watermark on the map. See Licensing.
Click a feature to select it. Shift-drag a box to select everything inside it, Alt to add to what is already selected, Escape to abandon the box, and a box over nothing clears the selection, which is the only obvious way for a reader to undo one.
interaction: {
selection: {
enabled: true,
multiple: true,
rectangle: true, // default. A plain drag still pans
modifier: 'shift', // 'alt' | 'meta' | 'ctrl' | 'none' ('none' needs pan off)
},
},
states: { muted: { opacity: 0.25 } }, // 1 turns dimming offA box tests each candidate's anchor, not its bounding box. Bounding-box intersection reads plausibly and behaves badly: Alaska's bbox spans the Pacific, so any box touching the Aleutians would select it, and a box over the Great Lakes would select half a dozen states it does not visibly cover. Points are tested at their own position, and the automatic basemap is left out, since a country drawn only so the bubbles have a coastline carries no data and could not filter anything.
While anything is selected, everything else dims to states.muted.opacity. That is what makes a
selection legible on a dense map: an outline on 3 of 3,000 counties is nearly invisible, while 2,997
dimmed ones read instantly.
Linked maps share a selection:
// on every map that should brush together
link: { group: 'sales-dashboard', filter: 'bidirectional' } // or 'emit' / 'receive'Brushing one map applies the selection to the others in the group and dims them the same way. Keys
have to mean the same thing across the group, which they do whenever the maps are of the same
geography; when a received selection matches nothing, dev mode says so rather than leaving one map
mysteriously blank. A map that receives a selection never rebroadcasts it, so a bidirectional pair
cannot ring. link.group is a licensed feature, and evaluating it watermarks the map.
One detail that is invisible when right and obvious when wrong: the click that ends a drag is not a
click on whatever it landed on. The browser fires click after any drag that starts and ends on the
same element, so without suppressing it, panning the map would select the country under the release,
and dragging a box across a feature with a drilldown would drill into it.
Spinning the globe
On orthographic a drag turns the sphere. It has to: panning a globe slides a picture of the earth
around inside its box and leaves the far hemisphere permanently unreachable, which reads as a broken
map rather than a choice. Wheel zoom, pinch and double-click still belong to the camera, so the two
gestures compose: zoom into the Mediterranean, then spin to the Pacific.
interaction: {
rotate: {
enabled: 'auto', // default: globes spin, flat maps pan. true forces it on any
// projection that can rotate and invert; false gives the drag back
inertia: true, // defaults to pan.inertia
},
},map.rotateTo([-25, -18]) // absolute [lambda, phi, gamma], as a drag would leave it
map.rotation // where it is now
map.on('rotate', ({ rotate }) => {}) // while turning
map.on('rotateEnd', ({ rotate }) => {}) // once the glide settles
await map.resetView() // the camera *and* the rotation the map opened atThe rotation is versor-based (Bostock and Davies): the point under the cursor stays under the cursor for the whole drag, at any latitude, however far the globe has already turned. Accumulating Euler angles instead is a dozen lines shorter and immediately recognisable as the cheap version, because near the poles a horizontal drag whips the sphere around. Dragging past the edge of the disc keeps turning rather than freezing, and longitude wraps, so the spin runs through 360 degrees and out the other side.
Rotation is a projection change, not a camera transform, so unlike a pan it reprojects every coordinate on the map. The work is coalesced to one pass per animation frame, and it is the reason this is opt-out on flat maps: on a Mercator a drag should move the map, and it still does.
Flying to a place on a globe
The camera moves know this too. On an azimuthal projection (orthographic, stereographic,
gnomonic, both azimuthal*) a center target is a rotation rather than a pan, because the camera
is a screen-space transform and no amount of translating it reaches the far side of a sphere:
// Facing Brazil. India is behind the planet, and this turns the globe to it.
await map.camera.flyTo({ center: [79, 22] })
await map.camera.easeTo({ center: [79, 22], duration: 600 })
map.camera.jumpTo({ center: [79, 22] })
await map.frameFeature('Australia') // turns first, then frames what it findsThe turn interpolates as a quaternion slerp, so it takes the short way round (a move across the
antimeridian goes 20 degrees, not 340), holds a steady angular pace, and does not swing out sideways
crossing a pole the way interpolating [lambda, phi] component-wise does. flyTo derives its
duration from the angle covered, the rotation analogue of the Van Wijk path length, and when a move
both turns and zooms the longer of the two sets the pace so they land together. easeTo keeps its
fixed duration, rotation included, because a caller who asked for 400 ms asked for 400 ms.
Everything laid out flat is untouched: on Equal Earth, Mercator, the conics and Albers USA a
center is the same pan it has always been, to the pixel. viewport.supportsRecentre() is the
switch, and the conics are deliberately on the flat side of it: they are defined by their standard
parallels rather than by a centre, so turning one would re-skew the whole map under the reader.
Opinionated defaults, and why
- Equal Earth, not Web Mercator. Most developers never choose a projection, so the default has to be the defensible one. Mercator exaggerates high-latitude area by an order of magnitude, which is exactly wrong when area encodes a value.
- Quantile classification with visible breaks. Every class is populated and the actual break values appear in the legend, because classification silently changes a choropleth's conclusion.
- Ramps sampled in OkLab. Interpolating through sRGB turns ramp midpoints muddy grey, and readers infer magnitude from perceived lightness.
- No-data is its own colour. An unmatched feature never falls into the lowest class, which would understate it.
- Winding is repaired on ingest. A counterclockwise ring makes
d3-georender the whole sphere minus your polygon: solid fill, identical centroids for every feature, nothing thrown. - Accessibility is on by default and free in every tier.
API sketch
const map = new ApexMaps(element, {
chart: { height: 520, animations: { enabled: true } },
geo: {
map: 'world/countries@110m', // registry id, URL, GeoJSON or TopoJSON
projection: 'equalEarth', // name, or { name, rotate, parallels, ... }
view: { fit: 'data', padding: 24 },
graticule: { show: true, step: 20 },
sphere: { show: true },
},
series: [
{
type: 'choropleth',
name: 'Unemployment rate',
joinBy: ['iso_a3', 'code'], // [geometryProperty, dataKey]
data: [/* ... */],
normalizeBy: 'population', // legend retitles itself
scale: { type: 'quantile', classes: 5, palette: 'blues' },
stroke: { color: '#ffffff', width: 0.5 },
drilldown: { map: 'us/counties' }, // child level, scoped to the clicked feature
},
{
type: 'bubble',
name: 'Metro population',
// Positions come from the data, or from geometry centroids via joinBy.
data: [{ name: 'Tokyo', lon: 139.7, lat: 35.7, value: 37_400_000 }],
size: { scale: 'sqrt', range: [3, 28] }, // sqrt is the default, and why
colorScale: { palette: 'reds' }, // optional second encoding
},
{
type: 'marker',
name: 'Sites',
data: [{ name: 'Depot 4', lon: -0.12, lat: 51.5, kind: 'depot' }],
shape: 'pin', // 7 shapes, all generated paths
colorBy: 'kind', // ordinal scale plus a legend
cluster: { radius: 60 }, // merges by distance, dissolves on zoom
},
{
type: 'arc',
name: 'Weekly flights',
// Endpoints may be [lon, lat] pairs or geometry keys.
data: [{ from: [103.99, 1.36], to: 'GBR', value: 900 }],
geodesic: true, // default: the real great circle
endpoints: { show: true },
},
],
dataLabels: { enabled: true, collision: 'hide' },
interaction: {
// `+` / `-` / reset buttons over the plot, on by default: a gesture is not a
// keyboard path. `controls: false` removes them, `{ position, reset }` tunes them.
zoom: { enabled: true, wheel: true, controls: { position: 'top-right' } },
pan: { enabled: true, inertia: true },
rotate: { enabled: 'auto' }, // a drag spins a globe instead of panning it
selection: { multiple: true, rectangle: true, modifier: 'shift' },
},
link: { group: 'dashboard' }, // brush this map, brush the others (licensed)
legend: {
position: 'bottom', // 'top' | 'left' | 'right' too; a side is a column,
width: 180, // this wide, taken out of the plot's width
interactive: true,
style: 'gradient', // one bar; classed scales draw as hard bands
marker: true, // arrow on the bar tracks the hovered feature
},
tooltip: { formatter: ({ name, value }) => `${name}: ${value}` },
a11y: { enabled: true, description: 'auto', dataTable: false },
})
await map.render()
map.camera.flyTo({ center: [2.35, 48.85], zoom: 8 }) // a pan here, a rotation on a globe
map.zoomIn(); map.zoomOut() // one step, what the buttons and the +/- keys do
await map.resetView() // back to the opening fit, and a globe's opening spin
map.rotateTo([-25, -18]) // turns the sphere, on a projection that can be turned
await map.frameFeature('FRA', { padding: 40 })
await map.drillTo('FRA') // as a click would; drillUp() / drillUp(Infinity) climb back
map.setSelection(['FRA', 'DEU'])
map.updateSeries([{ name: 'Unemployment rate', joinBy: ['iso_a3', 'code'], data: next }])
await map.updateOptions({ geo: { projection: 'mercator' } })
map.on('featureClick', ({ key, value, datum }) => {})
const spec = map.toSpec() // JSON-serialisableStatics
ApexMaps.setLicense(key) // shared across the ApexCharts family
ApexMaps.registerMap(id, geometry, meta) // meta carries source, licence, vintage
ApexMaps.registerProjection(name, factory)
ApexMaps.registerPalette(name, { kind, stops })
ApexMaps.listMaps()
ApexMaps.listProjections()
ApexMaps.listPalettes()
ApexMaps.palette('blues') // { kind, stops, colorblindSafe }
ApexMaps.mapMeta('us/counties@10m') // source, licence, vintage, key, view
ApexMaps.catalogue() // every built-in pack, with provenanceLicensing
Dual licensed on the same terms as the rest of the family: a free Community License for individuals, non-profits, educators and organizations under $2M USD annual revenue, and a paid Commercial or OEM license above that. One key works across every Apex product, so an ApexCharts or ApexGrid customer does not buy a second one for maps. See LICENSE.
What is licensed is a set of features, not map count, map size, or geometry downloads. There is no metering of map loads, no seat counting in the library, and no network call to check anything.
The line is that a map that answers a question is free, and a map that becomes an application is licensed. A rule rather than a list, because a list invites an argument per feature and a rule survives the next one.
| Free, always | Licensed |
|---|---|
| choropleth, bubble and marker series, and the automatic basemap | Point clustering (cluster) |
| Every one of the 13 built-in projections, with spec objects | Projections you register yourself (registerProjection) |
| The geometry registry, all 26 packs, provenance and attribution | Drilldown and the breadcrumb (drilldown) |
| Tooltips, legends, labels, data labels, states and themes | Editorial annotations (annotations) |
| Zoom, pan, pinch, hover, click and box selection, the camera API | arc and line route series |
| Joins, including fuzzyJoin, and the join diagnostics | Linked selection across maps (link: { group }) |
| Scales, palettes, size legends, responsive rules | Story mode (chart: { context: 'story' }) |
| Flat fills, in every scale and palette | Pattern fills (fill: { pattern }) and image fills (fill: { image }) |
| PNG and SVG export | Presentation mode, map-to-chart morphing, time playback, the WebGL tier (not built yet) |
| The accessibility layer | |
One of those free entries is deliberate rather than accidental. fuzzyJoin is free because
cleaning up someone's data is not a premium experience, it is the cost of using real data.
One licensed entry deserves the same directness, because it sits next to a commitment made below.
Pattern fills help a reader with a colour-vision deficiency, and accessibility is never gated here.
The line drawn is that the accessibility layer (ARIA, keyboard navigation, the generated
description, the data table, reduced motion) is free permanently, and so is the free tier's answer to
colour vision: the okabeIto palette, which is colourblind-safe by construction, and the automatic
diverging-palette selection. Patterns are the cartographic and print form of the same idea, and
they are priced with the rest of the authoring surface. If that reads as the wrong call for your
audience, say so: it is a pricing decision, not a technical one, and it lives in
src/core/premium.ts where it can be argued with.
Without a valid key the licensed features still work, in full, with a watermark on the map. That is deliberate: evaluate the thing before paying for it, in your own app, with your own data. A valid key removes the watermark. A map that uses none of them renders clean, with or without a key.
ApexMaps.setLicense('APEX-xxxxxxxx') // before rendering; applies to every map on the pageOne key covers every Apex product, but each product needs its own call: ApexMaps.setLicense()
and ApexCharts.setLicense() set different copies of the licence manager, because each library
bundles its own. A page with a chart and a map calls both, with the same key.
The watermark tracks the current options rather than the map's history. It is recomputed on every
render and every updateOptions, so turning a licensed feature off removes the watermark, and a late
setLicense() clears it without a reload. Get a license at
apexcharts.com/pricing.
Two commitments that do not change with tier:
- Accessibility is never gated. Free in every tier, permanently. Gating it would block the public-sector buyer we most want to serve, and it is the wrong thing to charge for.
- No mandatory network calls. The default build fetches nothing and phones home to nothing.
Geometry is fetched only when you name a pack, from wherever you point
setGeoSource().
Geometry and licences
The software licence does not cover the geographic data, which is published by third parties under its own terms. LICENSE lists each source and its obligations.
Every pack, and any geometry you register yourself, carries its provenance (source, licence, vintage, detail level, boundary policy), and required attribution renders automatically. The three sources used are permissively licensed: Natural Earth 5.1.1 and US Census TIGER/Line are public domain, Eurostat GISCO NUTS is CC BY 4.0 with EuroGeographics terms and is credited on screen. GADM is deliberately not used: it is non-commercial only, which is a licence trap that has caught other products.
Boundary policy is recorded rather than decided: the packs carry Natural Earth's and Eurostat's own
views, and mapMeta(id).boundaries says so.
Development
npm test # vitest, 536 tests, including the real geometry packs and perf invariants
npm run test:coverage
npm run lint
npm run typecheck
npm run format # prettier, config matches apexcharts-js
npm run build # rollup bundles + tsc declarations (cleans dist/ first)
npm run check:size # fail if a bundle crosses the 150 kB gzipped budget
npm run check:license # drive the BUILT bundle through licence enforcement (gates publishing)
npm run check:geo # verify the geo/ dataset and the library agree (gates publishing)
npm run check:geo-source # verify the default CDN geometry source actually serves geometry
npm run examples # build, then serve examples/ on :8084
npm run check:examples # load every demo in Chromium, fail on an error or an empty map
npm run data:build # regenerate geo/ from source (needs network, ~45 MB of downloads)
npm run build:wrappers # build every package under wrappers/ (needs npm run build first)
npm run typecheck:wrappers # check wrapper props against this package's emitted declarations
npm run check:wrappers # packaging: 'use client' survives, peers external, wrappers subpath importedThe framework wrappers are npm workspaces, so one npm install covers them and npm test runs their
tests alongside the core's. They resolve apexmaps through its published exports, which is why
build:wrappers and typecheck:wrappers want npm run build first: they check the type surface a
consumer actually gets, not the source.
The Angular suite goes one step further and tests the built ngx-apexmaps package itself, because
signal inputs and outputs are initializer APIs that only exist after the Angular compiler has run:
plain type-stripping never registers them. So npm test on a fresh clone wants
npm run build && npm run build:wrappers first; without them the Angular tests fail with exactly
that instruction rather than a resolution error.
All of it runs on every push and pull request (.github/workflows/ci.yml), including the bundle
budget and the demo smoke check, so a claim in this file is a job in that one.
A feature is not finished until it has a demo page that check:examples loads: the unit tests prove
the behaviour, the demo proves someone can use it, and the check stops the demo rotting. Playwright is
resolved from wherever it already exists rather than installed here, and the check skips loudly when it
finds none.
data:build is the only script that touches the network. It caches its downloads in .geo-cache/,
and it fails if the packs it produced and the ids declared in src/core/GeoCatalogue.ts disagree,
because a catalogue entry with no file is a runtime 404 and a file with no entry is invisible.
Dependencies
d3-geo (ISC) for projections, great-circle interpolation and spherical maths, topojson-client
(ISC), apex-commons for licensing. d3-geo is wrapped rather than reimplemented: spherical
clipping, antimeridian cutting and adaptive resampling are correctness traps that took it years to get
right, and the winding convention alone cost this project three attempts.
Type-checking runs as npm run typecheck (tsc --noEmit); bundling strips types via Babel, so there
is one source of truth for diagnostics and a fast build.
