Skip to Content

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:

  1. Provides a registration API via Vue’s provide/inject mechanism
  2. Creates and manages hooks for each registered component
  3. Maintains the filter panel UI based on registration metadata
  4. Invokes hooks when map state changes (year, zoom level, county selection)
  5. 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, } } ): HookObject

Parameters:

**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 value
  • params — 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:

  1. Filters to only those filters where visible is true (matching current zoom level)
  2. Sorts alphabetically by display label
  3. Automatically updates when zoomState changes

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:

  1. Invokes the appropriate callback (onChecked or onUnchecked)
  2. Updates the filter’s status
  3. 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