Drilldown
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:
- A property naming the parent. TIGER counties carry
state_abbrandstate_fips, Eurostat NUTS carriescntr_code, Natural Earth admin-1 carriesadm0_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 whosenamehappens to coincidentally equal a state abbreviation can't outrank thestate_abbrfield that actually matches all fifty-eight. - A key prefix, tried when no property matches. County FIPS
06037sits under state FIPS06; NUTSDE12sits underDE1. This is the fallback, and it's what carries a NUTS drilldown below level 1, wherecntr_codestops 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:
- 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.
drillUpmirrors it, starting from wherever inside the child level the reader had moved to. - 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
drilldownhandler counting features still sees exactly one level. - 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
- Selection & Linked Maps for why a drag that ends on a drilldown-enabled feature does not accidentally drill into it.
- Zoom, Pan & Camera for
frameFeature, the camera call drilldown uses internally to line up the two levels. - Legends, Labels & Tooltips for how the a11y description and data table update after a level change.