Last Updated: 4/8/2026
Adding a New Data Component
This guide walks through the process of creating a new data visualization layer for the “Where Did They Go?” map. Data components are Vue 3 components that register with BaseMap.vue, subscribe to state change hooks, fetch their data, and render visualizations to the shared SVG canvas.
Prerequisites
Before creating a new data component, you should understand:
- Vue 3 Composition API (
<script setup>syntax) - D3.js for SVG rendering and geographic projections
- The BaseMap hook system (see “The BaseMap Hook System” article)
- GeoJSON or JSON data formats
Step 1: Create the Component File
Create a new Vue component in client/src/components/. Follow the naming convention [DataType]Data.vue (e.g., AirportData.vue, LibraryData.vue).
<!--
components/AirportData.vue
Renders and updates data relating to airports in Kansas.
=== Hooks ===
OnYearChange: Fetches airport data corresponding to the new year
OnZoomChange: Adjusts symbol size based on zoom level
Filter: Fades in when checked, fades out when unchecked.
-->
<script setup>
// External imports
import { defineProps, onMounted, useTemplateRef, watch, inject } from 'vue';
import * as d3 from 'd3';
// Utility imports
import { fetchGeojson } from '@/utility/fetchers';
import { fadeIn, fadeOut } from '@/d3/transitions/fadeSelection';
import { registerKey } from '@/utility/RegisterKey';
// Enum imports
import { MapZoomLevel } from '@/enums/MapZoomLevel';
import { GroupType } from '@/enums/GroupType';
// Define props and template refs
const props = defineProps(['properties']);
const gRef = useTemplateRef('g');
// Component-specific constants
const label = 'airports';
const pathGen = d3.geoPath(props.properties.projection);
// Component state
let selection = null;
let gTag = null;
let paths = {
geojson: `${props.properties.path}/geojson`,
json: `${props.properties.path}/json`,
};
// Registration happens in Step 2...
</script>
<template>
<g class="airports" ref="g"></g>
</template>
<style scoped>
:global(.airport) {
fill: orange;
stroke: black;
stroke-width: 0.2;
pointer-events: visible;
}
</style>Step 2: Register with BaseMap
Inject the registerKey and call registerComponent() to register your component and receive hooks. The registration should happen during component setup (before onMounted).
// Register this component with BaseMap
const hooks = inject(registerKey)(label, {
filter: {
legibleLabel: 'Airports',
defaultStatus: false,
visibleStates: new Set([MapZoomLevel.STATE, MapZoomLevel.COUNTY]),
groups: [GroupType.INFRASTRUCTURE],
onChecked: onChecked,
onUnchecked: onUnchecked,
},
});Filter Configuration Options
**legibleLabel** (string, required) — Human-readable label displayed in the filter panel UI.
**defaultStatus** (boolean, required) — Whether the filter starts checked (true) or unchecked (false).
**visibleStates** (Set , required) — Set of zoom levels where this filter should appear in the panel. Use MapZoomLevel.STATE and/or MapZoomLevel.COUNTY.
**groups** (array of strings, required) — Logical grouping for the filter. Use GroupType.INFRASTRUCTURE or GroupType.OTHER. Can be an empty array if no grouping is needed.
**onChecked** (function, required) — Callback invoked when the user checks the filter checkbox. Typically fades in your visualization.
**onUnchecked** (function, required) — Callback invoked when the user unchecks the filter checkbox. Typically fades out your visualization.
Step 3: Subscribe to Hooks
Use the returned hooks object to subscribe to map state changes. Each hook accepts a callback function with the signature (newValue, oldValue, params) => void.
// Subscribe to year changes
hooks.onYearChange((newValue, oldValue, params) => {
// Fetch new data for the selected year
const { result } = fetchGeojson(`${paths.geojson}/airports_${newValue}.geojson`);
renderToSVG(result);
});
// Subscribe to zoom level changes
hooks.onZoomChange((newValue, oldValue, params) => {
if (!selection) return;
switch (newValue) {
case MapZoomLevel.STATE:
// Smaller symbols at state level
selection.transition().duration(500).attr('r', 1.5);
break;
case MapZoomLevel.COUNTY:
// Larger symbols at county level
selection.transition().duration(500).attr('r', 3);
break;
}
});
// Subscribe to county transitions (optional)
hooks.onCountyTransition((newValue, oldValue, params) => {
// Update visibility based on bounding box if needed
updateVisibleByBBox();
});Available Hooks
**onYearChange** — Triggered when the timeline slider moves. Use this to fetch data for the new year or update visibility based on temporal properties (e.g., “built in 1950”).
**onZoomChange** — Triggered when transitioning between State View and County View. Use this to adjust symbol sizes, stroke widths, or label visibility.
**onCountyTransition** — Triggered every time a county is selected, including when switching between counties without returning to State View. Use this to filter features by bounding box.
Step 4: Fetch Data
Use the fetchGeojson() or fetchJson() utility functions to load data from the server. These functions return a reactive result object with data, loading, and error refs.
onMounted(() => {
gTag = d3.select(gRef.value);
// Fetch initial data
const { result } = fetchGeojson(`${paths.geojson}/airports_1860.geojson`);
renderToSVG(result);
});Handling Async Data
The result object is reactive. If data is still loading when you first access it, watch for the loading ref to change:
function renderToSVG(result) {
const d = result.data.value;
const l = result.loading.value;
const e = result.error.value;
if (l) {
// Data still loading - watch for completion
const unwatch = watch(
() => result.loading.value,
() => {
renderToSVG(result);
unwatch();
}
);
return;
}
if (e) {
// Handle error
console.error('Failed to load airport data:', e);
return;
}
// Data loaded successfully - render it
renderFeatures(d);
}Step 5: Render to SVG
Use D3.js to bind your data to SVG elements and render them to the <g> element referenced by gRef.
function renderFeatures(data) {
selection = gTag
.selectAll('.airport')
.data(data.features, d => d.properties.id)
.join(
// Enter: create new elements
(enter) => enter
.append('circle')
.attr('cx', d => {
const coords = props.properties.projection(d.geometry.coordinates);
return coords[0];
})
.attr('cy', d => {
const coords = props.properties.projection(d.geometry.coordinates);
return coords[1];
})
.attr('r', 1.5)
.attr('opacity', '0%')
.classed('airport', true)
.call(enter => fadeIn(enter)),
// Update: modify existing elements
(update) => update,
// Exit: remove old elements
(exit) => fadeOut(exit).remove()
);
}Using the Projection
Access the D3 geo projection from props.properties.projection. This converts [longitude, latitude] coordinates to [x, y] SVG coordinates:
const coords = props.properties.projection(feature.geometry.coordinates);
const x = coords[0];
const y = coords[1];For path-based features (LineStrings, Polygons), use d3.geoPath():
const pathGen = d3.geoPath(props.properties.projection);
selection.attr('d', pathGen);Step 6: Implement Filter Callbacks
Define the onChecked and onUnchecked functions referenced in your filter configuration:
function onChecked() {
if (selection) {
fadeIn(selection);
}
}
function onUnchecked() {
if (selection) {
fadeOut(selection);
}
}These functions control what happens when the user toggles your filter in the UI. Most components simply fade in/out, but you can implement more complex behavior if needed.
Step 7: Register in BaseMap Template
Add your component to the BaseMap.vue template so it renders as part of the map:
<!-- In client/src/components/BaseMap.vue -->
<template>
<div class="container">
<svg ref="svg" width="1200" height="800" viewBox="0 0 1600 800">
<BorderData :properties="properties" @transition="onTransition" />
<RiverData :properties="properties" />
<LakeData :properties="properties" />
<RailroadData :properties="properties" />
<TractData :properties="properties" />
<CityData :properties="properties" />
<SchoolData :properties="properties" @school-hover="hoveredSchool = $event" />
<HealthcareData :properties="properties" />
<InterstateData :properties="properties" />
<!-- Add your new component here -->
<AirportData :properties="properties" />
</svg>
<!-- ... rest of template ... -->
</div>
</template>Rendering order matters: Components listed later in the template render on top of earlier components. Place your component in the appropriate z-order position.
Complete Example: Minimal Component
Here’s a complete minimal data component that demonstrates all the key concepts:
<script setup>
import { defineProps, onMounted, useTemplateRef, inject } from 'vue';
import * as d3 from 'd3';
import { fetchGeojson } from '@/utility/fetchers';
import { fadeIn, fadeOut } from '@/d3/transitions/fadeSelection';
import { registerKey } from '@/utility/RegisterKey';
import { MapZoomLevel } from '@/enums/MapZoomLevel';
import { GroupType } from '@/enums/GroupType';
const props = defineProps(['properties']);
const gRef = useTemplateRef('g');
const label = 'example';
let selection = null;
let gTag = null;
// Register with BaseMap
const hooks = inject(registerKey)(label, {
filter: {
legibleLabel: 'Example Layer',
defaultStatus: true,
visibleStates: new Set([MapZoomLevel.STATE, MapZoomLevel.COUNTY]),
groups: [GroupType.OTHER],
onChecked: () => fadeIn(selection),
onUnchecked: () => fadeOut(selection),
},
});
// Subscribe to hooks
hooks.onYearChange((newYear) => {
console.log('Year changed to:', newYear);
});
// Fetch and render data
onMounted(() => {
gTag = d3.select(gRef.value);
const { result } = fetchGeojson(`${props.properties.path}/geojson/example.geojson`);
result.data.value && renderData(result.data.value);
});
function renderData(data) {
const pathGen = d3.geoPath(props.properties.projection);
selection = gTag
.selectAll('.example-feature')
.data(data.features)
.join('path')
.attr('d', pathGen)
.classed('example-feature', true);
}
</script>
<template>
<g class="example" ref="g"></g>
</template>
<style scoped>
:global(.example-feature) {
fill: none;
stroke: purple;
stroke-width: 1;
}
</style>Testing Your Component
- Start the development server (should auto-start in Dev Container)
- Open
http://localhost:5173in your browser - Verify your filter appears in the filter panel at the appropriate zoom level
- Toggle the filter on/off to verify fade in/out behavior
- Move the timeline slider to verify year change behavior
- Zoom into a county to verify zoom change behavior
What’s Next
- Basemap Hook System – Deep-dive into how the registration API and hooks work internally
- System Architecture – Understand how data components fit into the overall application architecture
- Fetching Data – Details on the fetchGeojson/fetchJson composables and retry logic