Last Updated: 4/8/2026
The BaseMap Hook System
The BaseMap.vue component provides a registration and hook system that allows data components to integrate with the map’s lifecycle. This article provides a technical deep-dive into how the registration API works, how hooks are created and invoked, and how the filter system manages component visibility.
Architecture Overview
BaseMap.vue acts as a central orchestrator that:
- Provides a registration API via Vue’s
provide/injectmechanism - Creates and manages hooks for each registered component
- Maintains the filter panel UI based on registration metadata
- Invokes hooks when map state changes (year, zoom level, county selection)
- Respects filter status when invoking hooks (inactive filters don’t receive callbacks)
The Registration API
Providing the Register Function
BaseMap.vue provides the registration function using Vue’s provide/inject system:
import { provide } from 'vue';
import { registerKey } from '../utility/RegisterKey';
provide(registerKey, registerComponent);The registerKey is a Symbol that ensures type-safe injection. Data components inject this key to access the registration function:
import { inject } from 'vue';
import { registerKey } from '@/utility/RegisterKey';
const hooks = inject(registerKey)(label, options);The registerComponent Function
The registerComponent function signature:
function registerComponent(
label: string,
options: {
filter?: {
legibleLabel: string,
defaultStatus: boolean,
visibleStates: Set<string>,
groups: string[],
onChecked: () => void,
onUnchecked: () => void,
}
}
): HookObjectParameters:
**label** (string, required) — Unique identifier for the component. Must be unique across all registered components. BaseMap throws an error if a label is already registered.
**options** (object, optional) — Configuration object that may contain a filter property.
**options.filter** (object, optional) — If provided, registers a UI filter for this component. All filter properties are required if the filter object is present.
Returns: A HookObject containing three hook subscription functions: onYearChange, onZoomChange, and onCountyTransition.
Registration Validation
BaseMap validates all registration attempts:
// Check for duplicate labels
if (registeredLabels.has(label)) {
throw new Error(`BaseMap: Component with label '${label}' already exists.`);
}
registeredLabels.add(label);
// Validate filter options if provided
if (Object.hasOwn(options, 'filter')) {
if (!Object.hasOwn(options.filter, 'legibleLabel')) {
throw new Error(`BaseMap: 'legibleLabel' for filter does not exist.`);
}
// ... additional validation for each required property
}This validation ensures that components provide complete configuration and prevents subtle bugs from missing properties.
Hook Creation
The createHooks Function
When a component registers, BaseMap calls createHooks(label) to create a set of hook subscription functions:
function createHooks(label) {
let hooks = {};
Object.keys(HookType).forEach((key) => {
// Initialize hook bucket for this hook type and label
if (!Object.hasOwn(hookBuckets, key)) {
hookBuckets[key] = {};
}
if (!Object.hasOwn(hookBuckets[key], label)) {
hookBuckets[key][label] = [];
}
// Create the subscription function
hooks[key] = (callbackFn) => {
hookBuckets[key][label].push(callbackFn);
};
});
return hooks;
}This function creates a subscription function for each hook type defined in HookType.js. Each subscription function pushes callbacks into a “bucket” specific to that hook type and component label.
Hook Buckets Data Structure
The hookBuckets object organizes callbacks by hook type and component label:
hookBuckets = {
onYearChange: {
'railroads': [callback1, callback2],
'cities': [callback1],
'schools': [callback1]
},
onZoomChange: {
'railroads': [callback1],
'cities': [callback1, callback2],
'schools': [callback1]
},
onCountyTransition: {
'schools': [callback1],
'tracts': [callback1]
}
}This structure allows BaseMap to:
- Invoke all callbacks for a specific hook type
- Skip callbacks for components whose filters are unchecked
- Support multiple callbacks per component per hook type (though most components register only one)
Hook Types
Three hook types are defined in HookType.js:
onYearChange
Triggered when: The timeline slider moves or the play button advances the year.
Callback signature: (newValue, oldValue, params) => void
newValue— The new year (number)oldValue— The previous year (number)params— Empty object (reserved for future use)
Implementation:
watch(props.inputValue, (newValue, oldValue) => {
invokeHook(HookType.onYearChange, newValue, oldValue, {});
});Common uses:
- Fetch data for the new year
- Update visibility based on temporal properties (e.g., “built in 1950”)
- Update labels or tooltips with year-specific information
onZoomChange
Triggered when: The map transitions between State View and County View.
Callback signature: (newValue, oldValue, params) => void
newValue— The new zoom level (‘state’ or ‘county’)oldValue— The previous zoom level (‘state’ or ‘county’)params— Empty object (reserved for future use)
Implementation:
function changeZoomLevel(zoomLevel, viewBox) {
svgTag
.transition()
.duration(750)
.attr('viewBox', viewBox)
.on('end', () => {
invokeHook(HookType.onZoomChange, zoomLevel, zoomState.value, {});
zoomState.value = zoomLevel;
});
}Important: The hook is invoked AFTER the SVG transition completes, ensuring the viewport has finished animating before components update.
Common uses:
- Adjust symbol sizes (larger in County View, smaller in State View)
- Change stroke widths for lines
- Show/hide labels based on zoom level
- Enable/disable hover interactions
onCountyTransition
Triggered when: A county is clicked, including when switching between counties without returning to State View.
Callback signature: (newValue, oldValue, params) => void
newValue— Boolean toggle value (flips each transition)oldValue— Previous boolean toggle valueparams— Empty object (reserved for future use)
Note: The newValue and oldValue are simply boolean toggles that flip each time. Components typically ignore these values and instead access the current bounding box from props.properties.bbox.
Implementation:
function onTransition(type, boxString, bbox) {
if (boxString === svgTag.attr('viewBox')) {
// Clicked same county - return to state view
properties.bbox = { x: 0, y: 0, width: 1600, height: 800 };
changeZoomLevel('state', defaultViewBox);
} else {
// Clicked different county - zoom to it
properties.bbox = bbox;
changeZoomLevel('county', boxString);
invokeHook(
HookType.onCountyTransition,
!countyTransition.value,
countyTransition.value,
{}
);
countyTransition.value = !countyTransition.value;
}
}Common uses:
- Filter features by bounding box (show only features within the visible county)
- Update culled selections for performance
- Reset hover states when switching counties
Hook Invocation
The invokeHook Function
BaseMap invokes hooks using the invokeHook function:
function invokeHook(hookName, newValue, oldValue, params = {}) {
Object.entries(hookBuckets[hookName]).forEach(([key, fnList]) => {
// Skip if the component has a filter and it's unchecked
if (!(Object.hasOwn(filters, key) && !filters[key].status)) {
fnList.forEach((fn) => fn(newValue, oldValue, params));
}
});
}Key behavior: If a component has registered a filter and that filter is currently unchecked (status === false), its callbacks are NOT invoked. This prevents inactive components from wasting CPU cycles updating visualizations that aren’t visible.
Exception: Components that don’t register a filter (like BorderData.vue) always receive hook callbacks, regardless of any filter state.
The Filter System
Filter Registration
When a component registers with a filter option, BaseMap creates a filter entry:
filters[label] = {
displayLabel: options.filter.legibleLabel,
status: options.filter.defaultStatus,
statusRef: ref(options.filter.defaultStatus),
visible: computed(() => options.filter.visibleStates.has(zoomState.value)),
onChecked: options.filter.onChecked,
onUnchecked: options.filter.onUnchecked,
};Properties:
**displayLabel** — Human-readable label shown in the UI
**status** — Current checked state (boolean)
**statusRef** — Reactive reference to status (for Vue reactivity)
**visible** — Computed property that returns true if the current zoom level is in visibleStates
**onChecked** / **onUnchecked** — Callbacks invoked when the user toggles the checkbox
Visible Filters Computed Property
BaseMap computes which filters to display based on the current zoom level:
const visibleFilters = computed(() =>
Object.values(filters)
.filter((item) => item.visible.value)
.sort((a, b) => a.displayLabel.localeCompare(b.displayLabel))
);This computed property:
- Filters to only those filters where
visibleis true (matching current zoom level) - Sorts alphabetically by display label
- Automatically updates when
zoomStatechanges
The template uses v-for to render checkboxes for each visible filter:
<TransitionGroup class="test" name="filters" tag="ul">
<li class="filter" v-for="item in visibleFilters" :key="item.displayLabel">
<input
type="checkbox"
:checked="item.status"
@click="onFilterClicked($event, item)"
/>
{{ item.displayLabel }}
</li>
</TransitionGroup>Filter Click Handling
When a user clicks a filter checkbox:
function onFilterClicked(event, item) {
if (event.target.checked) {
item.onChecked();
} else {
item.onUnchecked();
}
item.status = event.target.checked;
item.statusRef.value = item.status;
}This function:
- Invokes the appropriate callback (
onCheckedoronUnchecked) - Updates the filter’s status
- Updates the reactive status reference
The component’s onChecked/onUnchecked callbacks typically fade the visualization in or out.
Group System
GroupType Enum
Filters can belong to logical groups defined in GroupType.js:
export const GroupType = Object.freeze({
INFRASTRUCTURE: 'infrastructure',
OTHER: 'other',
});Bucket Groups
BaseMap maintains a mapping of group names to component labels:
let bucketGroups = {
[GroupType.INFRASTRUCTURE]: [],
[GroupType.OTHER]: [],
};During registration, components are added to their specified groups:
options.filter.groups.forEach((value) => {
if (!Object.hasOwn(bucketGroups, value)) {
throw new Error(`BaseMap: Group with name '${value}' doesn't exist.`);
}
bucketGroups[value].push(label);
});Current usage: Groups are registered but not yet used for UI features. Future enhancements might include “toggle all infrastructure” buttons or group-based styling.
Example: Complete Hook Subscription
Here’s how a typical component subscribes to all three hooks:
const hooks = inject(registerKey)('example', {
filter: {
legibleLabel: 'Example Layer',
defaultStatus: true,
visibleStates: new Set([MapZoomLevel.STATE, MapZoomLevel.COUNTY]),
groups: [GroupType.OTHER],
onChecked: () => fadeIn(selection),
onUnchecked: () => fadeOut(selection),
},
});
// Subscribe to year changes
hooks.onYearChange((newYear, oldYear) => {
console.log(`Year changed from ${oldYear} to ${newYear}`);
fetchAndRenderData(newYear);
});
// Subscribe to zoom changes
hooks.onZoomChange((newZoom, oldZoom) => {
console.log(`Zoom changed from ${oldZoom} to ${newZoom}`);
adjustSymbolSizes(newZoom);
});
// Subscribe to county transitions
hooks.onCountyTransition(() => {
console.log('County changed, updating visible features');
filterByBoundingBox(props.properties.bbox);
});Performance Considerations
Hook skipping: Unchecked filters don’t receive hook callbacks, preventing unnecessary work.
Transition timing: onZoomChange fires after the SVG transition completes, avoiding visual conflicts between the viewport animation and component updates.
Bucket structure: The hook bucket structure allows O(1) lookup of callbacks by hook type and component label.
Computed visibility: The visibleFilters computed property efficiently updates the filter panel without manual DOM manipulation.
What’s Next
- Adding A Data Component – Step-by-step guide using the registration API
- System Architecture – Understand how BaseMap fits into the overall application
- Data Layers Reference – See how the nine existing data layers use the hook system