Skip to content

Releases: apexcharts/apexmaps

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 01 Sep 13:11

💎 Version 0.4.0

The headline is representation: layout: 'hex' redraws a region set as one equal cell per region, so a reader stops needing to know that Rhode Island exists to find it, and land area stops deciding how loud a value is. Seven layouts ship (US states, Japan, Europe, Brazil, Germany, Canada, Australia), each keyed the way its own boundary pack is keyed, so the same data and the same joinBy serve both pictures. Toggling between them morphs, region by region, which is what makes the cartogram legible: the reader watches Texas become a hexagon and knows which hexagon Texas is.

Alongside it, type: 'hexbin' is a sixth series for when there are more points than pixels: a lattice over the projection, each cell coloured by what landed in it, refining as the reader zooms rather than magnifying.

No breaking changes. Nothing is renamed or removed, and no default changes for an existing configuration.

This release requires apexmaps-geo@1.1.0. The seven layout files are new in that dataset, and layout: 'hex' fetches its cell table like any other pack. The default CDN source is pinned to apexmaps-geo@1, so a caller on the default picks it up with no change; a caller who self-hosts has seven files to copy.

✨ New

Hex tile layouts (premium)

A choropleth answers "how much, where" by painting real boundaries, and real boundaries carry an argument nobody chose: area. Ninety percent of a US map's ink is spent on states holding a tenth of its people, so a value in Wyoming shouts and the same value in New Jersey is a speck a reader has to hunt for. A hex tile map (honeycomb, tilegram) gives every region one cell of the same size, and asks the reader to accept a distorted map in exchange for a legible one.

const map = new ApexMaps(el, {
  geo: { map: 'us', layout: 'hex' },
  series: [{ type: 'choropleth', data, joinBy: 'abbr' }],
})

Three routes to the same feature, because the option is only right for one of them:

geo: { map: 'us', layout: 'hex' }        // toggleable, and what most callers want
geo: { map: 'us/states@hex' }            // named directly; 'au/hex' and friends alias too
ApexMaps.registerLayout(id, table, meta) // your own cell table

Seven ship, each scored against the boundary pack it claims to represent by npm run check:layout:

layout cells borders kept west-east north-south
au/admin1@hex 8 10 of 10 (100%) 0.952 0.976
jp/admin1@hex 47 74 of 86 (86%) 0.985 0.977
us/states@hex 51 89 of 107 (83%) 0.990 0.951
br/admin1@hex 27 41 of 50 (82%) 0.955 0.942
eu/nuts0@hex 37 45 of 58 (78%) 0.922 0.963
ca/admin1@hex 13 12 of 16 (75%) 0.967 0.923
de/admin1@hex 16 21 of 29 (72%) 0.841 0.968

"Borders kept" is the share of real shared boundaries whose cells touch; the last two columns are rank correlations of cell position against true centroid, so a layout cannot quietly put Florida north of Maine. Every layout has zero order errors. Japan scores highest of the large ones for a structural reason worth knowing: Hokkaidō, Shikoku, Kyūshū and Okinawa share no boundary with Honshū at all, so each island is a separate component that can be solved exactly, and Kyūshū keeps all nine of its internal borders.

Five compromises are recorded rather than hidden. check:layout fails on a flattening unless the layout file names it in accept.order with a reason, so DC sitting level with North Carolina is a decision in a file rather than a threshold quietly loosened until the tests passed. Nagano borders eight prefectures and a hexagon has six sides: some of these are arithmetic, not effort.

Keying is the boundary pack's, not a new one. us/states@hex is keyed on abbr and eu/nuts0@hex on nuts_id, exactly as their boundary packs are, so one dataset and one joinBy serve both representations and switching is an option change rather than a data migration. A layout also resolves independently: asking for the honeycomb never downloads the boundaries.

The morph, geography ↔ hex

A cartogram has one hard problem: a cell is unlabelled geography until the reader learns which is which. Turning layout on or off through updateOptions walks each region between its outline and its cell instead of swapping them.

await map.updateOptions({ geo: { layout: 'hex' } }) // morphs
await map.updateOptions({ geo: { layout: null } })  // morphs back

Automatic, with no option to enable. Three things decide whether it reads as a morph or a glitch, all of them correspondence rather than timing: each outline is resampled at equal arc length (a hexagon has six vertices, Michigan's main ring has hundreds), rotated to the cyclic offset that best matches its target so states do not spin, and checked for winding, because the two representations do not share a projection and opposite winding turns a shape inside out halfway across.

It is skipped when the region set itself changes, since morphing Texas into Bavaria is not information. Measured at 8.3 ms median frame and 9.1 ms p90, with no measurable setup cost: 74.9 ms with the morph against 76.2 ms without, because that time was always the geometry swap.

Hexbin (premium)

Ten thousand markers on a country map is not a map of ten thousand things. Past the first overlap the ink stops tracking the number, so the reader gets the shape of the data and no way to rank one part of it against another. A hexbin lays a lattice over the projection and colours each cell by what landed in it, because a cell of fixed area can carry a number.

series: [
  {
    type: 'hexbin',
    data: points,                   // { lon, lat } or { lng, lat } or { coordinates }
    radius: 12,                     // screen pixels, centre to vertex
    aggregate: 'count',             // or sum, mean, min, max
    scale: { palette: 'viridis', classes: 6 },
  },
]

count is the default and needs no value field. "Where are these things" is the question that brings anyone here, and demanding a numeric column to answer it would be asking for a column the caller does not have.

The radius is in screen pixels, so cells stay the size you chose and the lattice refines as the reader zooms instead of magnifying: the resolution follows whoever is looking. Cells are rebuilt at quantized zoom levels, which is the policy point clustering already uses and is shared with it, so a pan never re-bins and a smooth zoom crosses a level a handful of times rather than sixty times a second.

The colour domain follows the bins. Smaller cells hold fewer points, so class breaks move down when the lattice refines and the legend moves with them: the same colour means a different number at a different zoom and the legend says so rather than quietly lying. scale.domain pins them, which is what you want when two maps must be read against each other and what you do not want as a default, since fixed classes leave every cell in the palest one two levels in.

Also orientation ('pointy' or 'flat'), minCount to stop a one-point cell shouting as loudly as a hundred, gap to read the lattice as cells rather than a sheet, and valueField. There is no joinBy: a hexbin bins positions, so a datum with no position is dropped with a counted warning rather than joined to something.

Hexagons twice, and why they are unrelated

Two features now draw hexagons and they have nothing to do with each other, which is worth saying once because the word does double duty:

layout: 'hex' type: 'hexbin'
a cell is one region, placed by hand one patch of the projection
needs a region set with a layout points with coordinates
cell count fixed: however many regions whatever the data and radius give
boundaries replaced by it ignored by it, so it sits on a basemap
reading it which region, at equal weight how much landed where

🐛 Fixes

A layout was scored against its islands. Eurostat files metropolitan France and its five overseas departments as one MultiPolygon, so a centroid taken from the whole geometry put "France" 9° west of Paris in the Bay of Biscay; Natural Earth does the same to Tokyo via the Ogasawara islands, inverting it against Kanagawa. check:layout now decomposes MultiPolygons and measures the largest polygon, across rings as well as features. The four layouts that already existed score identically, which is how the fix was checked.

Ties were how a bad swap hid. Two cells in the same row are neither before nor after each other, so the checker skipped the comparison and the tuner exploited it, placing Rio Grande do Sul beside Santa Catarina rather than below it and scoring clean. Ties now count as order violations. That immediately flagged two real flattenings in layouts already shipped; both were investigated, confirmed forced by geometry, and recorded as accepted with reasons rather than the threshold being loosened.

A layout's fixed flag leaked into the level below it.

The examples check counted no bins. Its mark selector predates hexbin, so a hexbin-only page would have reported drawing nothing and passed as healthy.

⚠️ Licensing

Two new licensed features, both rendering fully without a key for evaluation, with a watermark:

  • gridLayout covers all three routes to a hex layout, registerLayout included: the gate is on the representation, not on whose table it is. It is priced with the geometry rather than the renderer, because the generator is a hundred lines and what is being sold is the curated cell table, a per-country judgement about which adjacencies matter that has no canonical answer and has to be verified against the boundary pack it claims to represent.
  • hexbin is priced with clustering: both take raw points and hand back an aggregate the reader cannot recov...
Read more

💎 Version 0.3.0

Choose a tag to compare

@github-actions github-actions released this 31 Jul 19:08

The headline is texture: fill.pattern lays one of eight tiles over the colour a scale already chose, and fill.image clips a picture to each region's own outline. A five-step ramp survives a photocopy, two classes of a diverging ramp stop reading as one, and a set of categories stops pretending to be a sequence. Alongside it, flow: true sends beads travelling along an arc or a line so a connection says which way something moves; an orthographic globe now spins under the pointer instead of sliding around inside its box; a drilldown divides the parent shape into its children rather than cutting to an already-divided child; and on-screen + / - controls give zoom its first keyboard and mouse path.

Three defects a caller could see are fixed: geodesic: false was silently drawing the great circle, legend swatches were inert on every series except the choropleth, and forced dark mode left pale text over a light page.

One breaking change, and the reason this is a minor rather than a patch: the canvas tier is gone. chart.renderer and chart.rendererThreshold no longer exist. Nothing else about a 0.2.0 configuration changes.

✨ New

Pattern fills (premium)

A flat fill is one colour per feature, and there are three places that is not enough. Print and photocopy collapse a five-step ramp to about three. Two classes of a diverging ramp can read as one to a reader with a colour-vision deficiency. And when the classes are kinds rather than amounts, a sequential ramp asserts an order that is not in the data. fill.pattern adds a second channel over the colour the scale already resolved, rather than replacing it.

series: [
  {
    type: 'choropleth',
    data,
    scale: { type: 'quantile', classes: 5 },
    fill: { pattern: { type: 'dots', size: 10 } },
  },
]

Eight tiles via type: dots, squares, checks (filled), lines, grid, diagonal, crosshatch (stroked at strokeWidth), and custom with your own path drawn in a size by size box. Each takes size, color, background, strokeWidth, angle and opacity.

A decision per feature, which is the form the qualitative case wants. The callback is handed the colour the scale resolved, so a tile can tint itself from the data instead of being told a colour twice:

fill: {
  pattern: ({ classIndex, color, value, datum, key, name, properties }) => ({
    type: TILES[classIndex],
    angle: ANGLES[classIndex],
  }),
}

What it does without being configured:

  • The ink picks itself. color defaults to white on a background dark enough to carry it, and to a darkened tint of the background otherwise, so a whole sequential ramp stays legible without a per-class review.
  • The tile background defaults to the class colour, so texture is added to the encoding rather than substituted for it.
  • size is the spacing between marks, not the size of one, and the marks are deliberately small against it: 10px spacing, a dot covering about a twelfth of its tile, a bar a fifth of its width. That ratio is the design. Tighten it and the ink averages with the fill into a shade that is on no scale, so a reader matching a region to a swatch matches a colour the legend never shows, and neighbouring classes converge on the same mud, which is the one job the texture was added to do.
  • The texture holds its size on screen. A pattern resolves in the user space of the shape referencing it, and shapes ride the camera, so an untouched tile grows with the zoom. Tiles are rescaled by the inverse per camera frame, on the same reasoning that gives borders non-scaling-stroke. An image fill is deliberately left alone, because there the scaling is the point.
  • Legend swatches draw the class's real tile, off the same builder as the map, scaled to show two repeats. A patterned map with plain swatches tells the reader the texture means nothing.
  • No-data and legend-muted features are never textured. An absence has to keep reading as an absence, and a pattern over it reads as one more category.
  • Paints are deduplicated by appearance (five classes across three thousand features is five <pattern> elements), pruned per series when a redraw stops asking for them, and their ids namespaced per registry, so two maps on a page cannot collide.

Image fills (premium)

fill.image clips a picture to each region's own outline. The function form of src is the useful one: it is how each region gets its own image, a flag, a crest, a satellite tile, a portrait.

series: [
  {
    type: 'choropleth',
    data,
    fill: {
      image: {
        src: ({ key, name, value, datum }) => `/scenes/${key.toLowerCase()}.jpg`,
        fit: 'cover', // 'contain' fits it all in; 'fill' stretches
        background: '#eef1f6', // shows under a 'contain' fit, and while loading
      },
    },
  },
]

Returning null from src leaves that feature on its flat colour.

fit: 'cover' computes its crop from the source's measured aspect ratio rather than asking for it with preserveAspectRatio="xMidYMid slice". That attribute is a request the referencing element does not win: a referenced SVG brings its own aspect handling and per spec it takes precedence, so the most likely source for a per-region image, an SVG flag or crest, came out letterboxed inside the region with the fill colour around it. Verified in Chromium that the attribute alone letterboxes, that object-fit: cover does not help, and that only geometry the library computes works. A probe image supplies the ratio, cached per source, with the attribute as the answer for the frame before it lands and for sources that cannot be measured.

Both fills, and what a paint breaks

SVG paints texture by reference, so the fill on the element stops being a colour, and everything that reasoned about that colour stopped being able to. Hover darkened the string url(#id), a no-op because darken returns unparseable input unchanged, so a painted map silently stopped responding to the pointer, and a drilldown developed the child level out of it. The flat colour is now written alongside on data-fill, and hover on a painted feature dims with brightness(), because the def behind it is shared by its whole class and rewriting it would highlight every feature in that class.

Gated as two features rather than one, because they are separable purchases: texture is a legibility and print concern, imagery is a different product with its own support surface. That sits next to a commitment this repo now writes down rather than leaving for a customer to find: the accessibility layer stays free permanently, and so does the free tier's colour-vision answer, okabeIto and the automatic diverging selection. Patterns are the cartographic and print form of the same idea, and are priced with the authoring surface.

+2.3 kB gzipped. Demos: examples/patterns.html and examples/image-fill.html.

Flow: beads travelling along a route

An arc says two places are related. It does not say which way anything moves, and on a hub map every route leaves the same airport, so the reader cannot infer it either. flow: true on an arc or line series sends beads along each route from from towards to.

series: [
  {
    type: 'arc',
    name: 'Great-circle distance',
    data: ROUTES,
    geodesic: true,
    width: { range: [0.6, 3.4] },
    flow: true, // beads, from -> to
  },
]
flow: { style: 'dash', speed: 60, spacing: 30 }
flow: { color: '#e8f4ff', size: 5, stagger: false }
flow: { scale: 'screen' }   // fixed size and spacing at any zoom

Full option set: style ('dots' | 'dash'), scale ('zoom' | 'screen'), speed (px/sec, default 90), spacing (px between beads, default 56), size (bead diameter, defaults to the route's own width so the heavy corridors carry the fat beads), color, opacity, and stagger (default true, so a corridor of parallel routes reads as traffic rather than one synchronised pulse).

The mechanism is one dashed companion path per route, animated on its dash offset, and the reason it is that rather than a dot moved per frame is arithmetic: a repeating pattern only has to advance one period to loop, so the duration is spacing / speed and the route's own length never enters into it. Nothing is measured, which matters because measuring means getTotalLength, and that exists in a browser but not in jsdom or on a server. The phase is then a pure function of time, so panning cannot disturb it, and the per-frame cost is zero.

The beads are anchored to the ground, not the screen, and each of the three properties that follow the camera is bounded, because each degenerates in its own way at the far end of it:

Property Bound Why
Size 3x Past that a bead is a blob on a route that kept its own width.
Pace 2x Past that it reads as agitation rather than traffic.
Spacing 6x Past that the beads thin out until none is in view, since the viewport does not grow with the zoom.

Holding the spacing constant instead is what the first cut did, and it is wrong: the bead count then climbs with the zoom, and a route that read as five beads at the opening view is a dotted line at 3x. scale: 'screen' is that behaviour, kept for a dashboard where the beads are furniture rather than geography.

The default spacing is 56px, measured rather than picked. At a normal figure width a hub map's routes run 130 to 760 screen pixels, so the 22 this started with put 6 to 35 beads on them, and that reads as a dotted line: the eye sees a texture and stops seeing anything move.

Beads go in a group of their own above the routes, so every bead clears every route and the only content that repaints each frame is a separate paint chunk from the geometry that never moves. They never take the pointer, so the route underneath stays as hoverable as it was. prefers-reduced-motion, `chart.anim...

Read more

💎 Version 0.2.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 14:54

This release is about licensing, and it is a correction. The tier shipped in 0.1.0 named six features, two of which did not exist, one of which was never gated, and none of which a developer meets on a first real map: everything they actually reach for was free. The line is now a rule rather than a list, because a list invites an argument per feature and a rule survives the next one. A map that answers a question is free. A map that becomes an application is licensed.

Two enforcement defects are fixed, and they were hiding each other. Free maps could be watermarked by a premium map elsewhere on the page. And chart.context: 'story', the primary paid differentiator, sat in the premium list with no call site, so it was free while the licence tests passed on a watermark arriving from the other defect.

Nothing about rendering changes, no option is renamed or removed, and no map's output moves by a pixel. Also in this release: release: X.Y.Z on main now publishes all four packages with provenance, and a new check drives the built bundle through the sequence a customer produces.

🔑 What is licensed

Licensed features still work, in full, with a watermark on the map. That is deliberate: evaluate the thing in your own app with your own data before paying for it. 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 page

One key covers every Apex product, but each product needs its own call, because each bundles its own licence manager. An ApexCharts customer calling ApexCharts.setLicense() has not licensed their maps.

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' })
PNG and SVG export Presentation mode, map-to-chart morphing, time playback, the WebGL tier (not built yet)
The canvas renderer and the whole performance path
The accessibility layer

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.

Four free entries are there by decision rather than by omission, and this is where the rule needed saying out loud:

  • The canvas renderer, because it is a rendering strategy and not a feature. Gating it would mean "your map is slow unless you pay".
  • fuzzyJoin, on the same reasoning: cleaning up someone's data is not a premium experience, it is the cost of using real data.
  • The spatial index, which is internal hit testing that no caller opts into.
  • Accessibility, which is never gated. A watermark over a screen-reader affordance is indefensible, and in some markets it disqualifies the product from procurement.

🐛 Fixes

  • The free tier was watermarked. _evaluateLicense called Watermark.remove() for a map with no premium feature in use, and that call tracks the container. Reconciliation inside apex-commons is licence-driven (paint everything tracked while the licence is invalid) while this policy is usage-driven, so the correction meant for a premium map on a forged key painted every plain map too: one story map on a page took three ordinary choropleths down with it. A container with nothing premium in use is now untracked, which is what apex-commons documents for a caller driving the watermark itself. Premium containers stay tracked, because signature verification is asynchronous and the provisional verdict has to be correctable.

  • chart.context: 'story' was not gated at all. It sat in PREMIUM_FEATURES with no call site, so _premiumUsed stayed empty and the licence decision had no effect on it. The licence test believed otherwise: its own comment said that without a premium feature in play the tests would pass while checking nothing, which is exactly what was happening, and the watermark it asserted was arriving from the defect above. _checkPremium now requires it, so the only enforcement of the primary paid differentiator exists. Verified by mutation, since green tests prove nothing here: restoring the single-branch _evaluateLicense fails both new tests, and removing the story gate fails the original forged-key test, the one that used to pass for the wrong reason.

  • The watermark described the map's history rather than the map. _premiumUsed only ever grew, so a map stayed watermarked after the option that earned it was removed, until a reload. Config churn is the normal state and not an edge case: responsive rules rebuild the resolved config per breakpoint, and the framework wrappers hand over a fresh tree every render. Premium usage is now recomputed on every change, so turning a feature off removes the mark. A second set, _premiumInvoked, is sticky and starts empty, because a feature used imperatively (a morph, a story step, a playback run) leaves nothing behind to recompute from and would otherwise clear its own watermark when the animation ended. Restoring the accumulating set fails all seven of the new turn-it-off tests.

  • The library's reported version had drifted from package.json. render.test.ts now pins the two together.

  • npm pack --json changed shape under npm 12, from an array of results to an object keyed by package name, so JSON.parse(out)[0] was undefined and the wrapper check reported tarball contents as unknown. CI installs npm@latest, which had become 12.0.2, while the release laptop had 10.9.2: the same check passed locally and failed in the release, after the core had already published. Both shapes are handled and verified against both versions. The check also discarded stderr and reported only "the tarball contents are unknown", which is why a two-line shape change cost a release step; it now names the cause, and an unrecognised shape says so with the first 200 characters of what npm actually returned.

✨ Also new

check:license drives the built bundle

The test suite imports source through the vitest alias, and nothing else in this repo ever loads dist. apex-commons is inlined, so what enforces in a customer's app is a copy that went through rollup, babel and terser, and a build change can break enforcement with every test green. apexcharts-js shipped exactly that in 6.6.0.

scripts/check-license-enforcement.mjs drives the sequence a customer produces (set the key, render, await the asynchronous verdict, inspect the DOM) over forged, unsigned-legacy, expired and absent keys, asserts the free tier stays clean, and asserts that turning the feature off clears the mark. It runs in CI on every push, before publishing, and in the core's prepublishOnly, so a publish from a laptop cannot skip it.

It earned its place on the first run twice over: against the pre-fix bundle it reported every defect above, and it caught a regression introduced while writing it, since exporting the premium list from the entry module breaks the IIFE build (output.exports: 'default' requires a default-only entry). The list now lives in src/core/premium.ts, which is a better home for a pricing decision anyway.

A gate cannot be declared and forgotten

test/premium.test.ts drives each feature through the option that turns it on and asserts the watermark, then drives the free tier and asserts none. It fails on any declared feature with no live gate, which is the original story defect reproduced as a test, and it fails on an undeclared name added to the list. Nine mutants, one per gate plus one making built-in projections premium, each killed by exactly the test that should catch it. The four names ahead of their features (morph, presentation, timePlayback, webgl) are the documented exception that test knows about.

Telling a caller's projection from a built-in

Both arrive through the same registerProjection, so distinguishing them needs a snapshot of the registry taken after the last built-in registration and before any consumer can run. isCustomProjection answers about the name a caller asked for, not the factory behind it, so re-registering 'mercator' over the built-in is still the built-in and stays free.

One commit publishes all four packages

release: X.Y.Z on main publishes apexmaps and any wrapper carrying that same version, then creates the vX.Y.Z tag and the GitHub release:

release: X.Y.Z                   apexmaps, plus any wrapper at that same version
release(react-apexmaps): X.Y.Z   that wrapper alone
release(vue-apexmaps): X.Y.Z
release(ngx-apexmaps): X.Y.Z

release: is matched literally, so a scoped subject never matches the core and vice versa, and fix(release): ... matches neither.

  • npm trusted publishing over OIDC, so there is no NPM_TOKEN in this repository and provenance is attached automatically. All four packages need a trusted publisher pointing at this repository and the filename publish.yml, which npm verifies, so renaming that file breaks publishing until the four settings agree.
  • A core release also publishes any wrapper at that version. The wrappers pin the core with a caret, and under 0.x a caret does not cross a minor, so a core minor forces all three wrapp...
Read more