Built with ApexMaps, ApexCharts.js, ApexGrid
Click a region on a map and a chart and a data grid both narrow to it. The answer is not to wire the three components to each other: it is to give them one plain object holding what is selected, one function that derives everything from it, and a re-entrancy guard, because setting a selection programmatically makes a library emit the same event a user click does.
npm install apexmaps apexcharts apex-grid
Which event does each component emit?
Three libraries, three vocabularies. This is the whole reason a shared store beats direct wiring, and it is worth seeing side by side before any code:
| Component | Publishes | Payload |
|---|---|---|
| ApexMaps | map.on('selectionChange', fn) | { ids }, an array of joined feature ids |
| ApexCharts | chart.events.dataPointSelection | (event, ctx, config), where config.dataPointIndex is a position, not a label |
| ApexGrid | properties on the custom element | you assign grid.data; there is no event to listen for here |
They do not even agree on shape: one gives you identity, one gives you an index, and one gives you nothing because you are the one pushing rows into it. Wiring each to the other two is six adapters for three components, and it makes every component's state depend on the others' event order.
The contract: one store, not a mesh of adapters
Nothing talks to anything else. There is one object, one derive step, and each component does exactly two things: publish into the store, and re-read what the derive step produced.
const filter = { state: null, industry: null }
let applying = false
function matches(row) {
if (filter.state && row.state !== filter.state) return false
if (filter.industry && row.industry !== filter.industry) return false
return true
}
const subscribers = []
const subscribe = (fn) => subscribers.push(fn)
function apply(origin) {
// The guard. See the next section: without it the first click never settles.
if (applying) return
applying = true
try {
const rows = ACCOUNTS.filter(matches)
for (const fn of subscribers) fn(rows, origin)
} finally {
applying = false
}
}
Adding a fourth component adds one publisher and one subscriber. It does not add
six adapters, and it cannot introduce an ordering bug in the three that already
work. origin is passed through to every subscriber, which the next two
sections both depend on.
Why the first click ping-pongs without a guard
This is the failure that costs people an afternoon, and it is not specific to these libraries.
Selecting a region programmatically makes a map emit selectionChange, the same
event a human click emits. So the moment you both listen for selection and push
selection back, you get a loop:
user clicks -> selectionChange -> apply() -> map.setSelection()
-> selectionChange -> apply() -> map.setSelection() -> ...
Nothing in the API tells you this, because from the library's point of view both
calls are legitimate. The applying flag breaks it by making apply a no-op
while it is already running, and each publisher checks the same flag before it
writes into the store:
map.on('selectionChange', ({ ids }) => {
if (applying) return
// `multiple: false` means one id or none, so this collapses to a single value.
filter.state = ids.length === 1 ? ids[0] : null
apply('map')
})
Every cross-filtered dashboard needs this guard whatever its libraries are. If you take one thing from this page, take that.
Don't echo a selection back to the component it came from
The guard stops the infinite loop. It does not stop a subtler problem: pushing a
selection back into the component the user is currently interacting with fights
the user. That is what origin is for.
subscribe((rows, origin) => {
// The map already shows this selection: the user just made it there.
if (origin === 'map') return
if (filter.state) map.setSelection([filter.state])
else map.clearSelection()
})
Now clicking a bar in the chart highlights the matching region on the map, and clicking the map does not re-highlight what the map already has selected.
One configuration choice matters here:
interaction: { selection: { enabled: true, multiple: false } }
multiple defaults to true, which toggles and accumulates. A single-region
filter wants a click to replace the selection, so it has to be set. Leave the
default and every click adds another region, which is a different feature that
happens to look like a bug.
Selection is free in every plan, so the map half of this recipe needs no licence key and renders with no watermark.
Making a chart both a filter and a view
A component that is also a filter source has to aggregate over rows filtered by everything except itself:
subscribe((rows) => {
// Note: ACCOUNTS filtered by state only, NOT the `rows` argument.
const scoped = ACCOUNTS.filter((r) => !filter.state || r.state === filter.state)
industryChart.updateSeries([
{ name: 'Revenue', data: byIndustry(scoped).map((d) => d.revenue) },
])
})
Use rows and the bar chart collapses to the single bar you just clicked, which
takes away the control the reader used to get there. It looks broken and is
technically correct, which is the worst combination to debug.
The grid has no such problem, because it is a pure view:
subscribe((rows) => {
grid.data = rows.slice().sort((a, b) => b.revenue - a.revenue).slice(0, 200)
})
What breaks first
In the order you are likely to hit them.
| Symptom | Cause |
|---|---|
| The first click hangs or the selection flickers | No applying guard. The section above. |
| Clicking accumulates regions instead of replacing | selection.multiple left at its default of true. |
| The bar chart collapses to one bar | A filter source aggregating over its own filtered rows. |
| Everything is empty and every total is a dash | Two filters intersected to nothing. This is a correct result and it reads as a crash, so say so in the UI rather than showing a blank table. |
| Nothing renders on load, then everything appears | map.render() returns a promise because it fetches geometry. The charts and grid are synchronous. Do the first apply() after the map resolves, and once before, or first paint is empty. |
| The map is grey where your data has values | A join failure, not a sync problem. joinBy takes [geometryProperty, dataKey], and the map pillar covers the diagnosis. |
The load order point in full, since it is easy to get subtly wrong:
map.render().then(() => apply('init'))
apply('init')
Both calls are deliberate. The second paints the charts, grid and totals immediately; the first repaints once the geometry has arrived so the map picks up the same state. The guard makes the overlap harmless.
When a shared store is the wrong shape
| Your situation | Reach for |
|---|---|
| Several components, one selection, one page | This. A plain object and a subscriber list, roughly 20 lines. |
| Two components and one direction only | Skip the store. Wire the event straight across, as in drilling from a chart into a grid. |
| You already run Redux, Zustand, Vuex or a signals library | Use it. filter becomes your store's state and apply becomes a selector plus a subscription. The re-entrancy guard is still yours to add, because it is a property of the libraries and not of your state manager. |
| The selection must survive a reload or be shareable | Put it in the URL query string and make that the source of truth, with the store hydrating from it. |
| Filtering happens server-side over millions of rows | The store holds the query, not the rows. Every subscriber becomes a fetch, so add request cancellation before you add components. |
Which plan covers this?
Cross-filtering itself is application code, so what matters is the libraries. Map selection, chart events, and grid data binding are all in the free Community tier, which covers organizations under $2M USD in annual revenue; at or above that a Commercial licence applies. Nothing in the family is open source, and source published on GitHub is not an open licence. The pricing page has the full matrix.
See all three components wired together, with the full sourceSee the pieces running
Reference documentation
Frequently Asked Questions
How do I detect which region was clicked on an ApexMaps map?
Listen for the selectionChange event: map.on('selectionChange', ({ ids }) => ...). The payload carries the joined feature ids, so unlike a chart's dataPointSelection you get identity rather than a positional index. Selection has to be turned on first with interaction.selection.enabled.
Why does my cross-filtered dashboard loop on the first click?
Because setting a selection programmatically makes the library emit the same event a user click emits, so listening for selection while also pushing it back gives you selectionChange, apply, setSelection, selectionChange, and round again. Guard it with a flag that makes the apply step a no-op while it is already running, and check that flag in every publisher.
Should each component listen to the others directly?
No. Three components wired to each other is six adapters, and every component then depends on the others' event order. Keep one plain object holding what is selected plus one function that derives state from it. A fourth component then costs one publisher and one subscriber rather than six more adapters.
Why does clicking a map region add to the selection instead of replacing it?
Because interaction.selection.multiple defaults to true, which toggles and accumulates. A single-region filter wants a click to replace what is selected, so set multiple to false explicitly.
Why is my dashboard empty on first paint?
ApexMaps render() returns a promise because it fetches geometry, while the charts and grid are synchronous. Call your first derive step once immediately and again when the map resolves; the re-entrancy guard makes the overlap harmless.
Related
See it running, with the full source
This recipe is the map seam of the sales analytics showcase, which cross-filters a map, two charts and a grid on one screen.