Drilldown

Premium feature

Drilldown is a Premium feature

Available on the Premium and OEM plans. Drilldown works without a key for evaluation, with a watermark on the map; call ApexMaps.setLicense(key) to remove it.

Click a state, get its counties. One option on the series:

series: [
  {
    name: 'Adoption',
    joinBy: { data: 'key' },
    data: rows,
    drilldown: { map: 'us/counties' },
  },
]

Scoped to the clicked feature

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 would leave the reader to find California again themselves; a drilldown swaps in only California's counties.

Which child features belong to which parent isn't something you configure by default, because published hierarchical geometry already answers it in one of two ways, checked in this order:

  1. A property naming the parent. TIGER counties carry state_abbr and state_fips, Eurostat NUTS carries cntr_code, Natural Earth admin-1 carries adm0_a3. ApexMaps detects the field by scoring every candidate property by how many children it matches against the parent's key and taking the winner, so one county whose name happens to coincidentally equal a state abbreviation can't outrank the state_abbr field that actually matches all fifty-eight.
  2. A key prefix, tried when no property matches. County FIPS 06037 sits under state FIPS 06; NUTS DE12 sits under DE1. This is the fallback, and it's what carries a NUTS drilldown below level 1, where cntr_code stops distinguishing levels from each other.
drilldown: {
  map: 'us/counties',
  parentField: 'state_abbr',   // skip detection and name the property yourself
}

scope: 'all' opts out of scoping entirely and draws the whole child map regardless of which feature was clicked:

drilldown: { map: 'us/counties', scope: 'all' }

Declining rather than drawing nothing

When neither a matching property nor a matching key prefix is found, the drilldown is declined instead of performed. Landing on an empty map costs the reader the level they were just reading and gives nothing back in return, so ApexMaps refuses the swap and explains why in the dev-mode diagnostics rather than drawing a blank map silently:

no child feature belongs to "06": no property holds that value and no key starts with it.
Pass drilldown.parentField, or scope: 'all' to draw the whole child map.

The function form of drilldown.map can decline too, per feature, by returning null or undefined:

drilldown: {
  map: (context) => (context.depth === 1 ? 'eu/nuts2' : 'eu/nuts3'),
  animate: 'zoom',   // frames the parent before the swap; 'none' cuts instead
}

context is a DrilldownContext: the clicked feature's key, name, joined datum, properties, the depth being entered, and the map (from) currently on screen.

The breadcrumb, and getting back out

A trail renders above the map by default (drilldown.breadcrumb, real buttons, keyboard reachable), because a drilldown with no visible way out is a trap:

drilldown: { map: 'us/counties', breadcrumb: { rootLabel: 'United States' } }

Getting back out is deliberately over-provided: the breadcrumb, the Escape key, or the imperative methods below. None of them refetch, since each level's geometry is still held once it's been loaded, so climbing back up is synchronous.

map.drillUp()             // one level up
map.drillUp(Infinity)     // back to the top
map.drillTo('CA')         // drill in, exactly as a click on 'CA' would
map.drillDepth            // levels below the top currently displayed

Keyboard access

The way in and the way back out both work from the keyboard. Enter (or Space) on a focused feature drills exactly where a click on it would. Escape climbs one level, and takes priority over an in-progress selection box if one happens to be open; at the top level, with nowhere left to climb, Escape falls through to the accessibility layer's own meaning for it.

Data for the deeper level

Rows for every level can live in one array, since the join takes whatever matches the level currently on screen and the join diagnostic reports mismatches against the other level's rows rather than treating them as a broken join. Or fetch per level from the drilldown event, which fires once the child level is already on screen:

map.on('drilldown', ({ key, name, depth, featureCount }) => {
  fetchCounties(key).then((rows) => map.updateSeries([{ ...series, data: rows }]))
})
map.on('drillup', ({ to, depth }) => {
  if (depth === 0) map.updateSeries([{ ...series, data: allStates }])
})

The transition

animate: 'zoom' (the default) plays in three parts, in the order the reader sees them, so a drilldown reads as the parent shape dividing into its children rather than as a cut to an already-divided child:

  1. The child level is handed the exact screen box the parent feature occupied and eases to its own fit from there. Both levels project the same geography, so the handoff is exact: the counties land on California's box to the pixel. drillUp mirrors it, starting from wherever inside the child level the reader had moved to.
  2. The outgoing level is copied above the incoming one and faded out, which covers the surroundings disappearing. The copy gives up its classes and keys, so for the length of the transition a hit test, an export, or a drilldown handler counting features still sees exactly one level.
  3. The incoming marks are seeded with the parent's own fill and no boundaries, then released in bands ordered outward from the middle, so the level starts as a flat copy of the shape it came from and divides into its children.

All of it rides the CSS transitions the map already declares, so there is no per-frame cost, and it honors chart.animations.speed, the motion budget, and prefers-reduced-motion without knowing they exist. animate: 'none' cuts instead, which is also what a reader who has asked for reduced motion gets. Feature selection does not survive a level change, because those selected keys belong to the level you just left.

See also