Recipe

Sync a map selection to a chart and a grid

Three libraries, three event vocabularies, one selection. Wiring them to each other is where cross-filtered dashboards break, and the fix is smaller than the bug.

Selection & Linked MapsOpen in new tab

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:

ComponentPublishesPayload
ApexMapsmap.on('selectionChange', fn){ ids }, an array of joined feature ids
ApexChartschart.events.dataPointSelection(event, ctx, config), where config.dataPointIndex is a position, not a label
ApexGridproperties on the custom elementyou 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.

SymptomCause
The first click hangs or the selection flickersNo applying guard. The section above.
Clicking accumulates regions instead of replacingselection.multiple left at its default of true.
The bar chart collapses to one barA filter source aggregating over its own filtered rows.
Everything is empty and every total is a dashTwo 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 appearsmap.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 valuesA 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 situationReach for
Several components, one selection, one pageThis. A plain object and a subscriber list, roughly 20 lines.
Two components and one direction onlySkip 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 libraryUse 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 shareablePut 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 rowsThe 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 source

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

Get started