Introduction
This small TypeScript library, SF Map SVG, draws San Francisco as plain SVG. It knows the city’s precise coastline, the supervisorial districts of 2002, 2012 and 2022, three independent neighborhood definitions, park boundaries, highways, key roads and BART stations. The renderer has no runtime dependencies and never loads map tiles. You supply the geography explicitly, through bundled imports or your own data loading.
It offers two ways to work, built on the same geography:
renderMapreturns an SVG string and a projection. It needs no DOM, so it runs in Node, at the edge, or in a build step.createMapreturns a controller for an interactive map in the browser, with selection, a camera, typed events and optional motion.
Geography is always passed in explicitly. The root entry point bundles no data, so a page that needs only a few layers ships only those layers.
Install
pnpm add @kahwee/sf-map-svgServer rendering needs Node 24 or newer. Browser maps need a DOM and a bundler that can import JSON, such as Vite, Rollup, esbuild or webpack.
A first static map
Import renderMap and a geography preset, then choose the layers you want. Every layer is independent and optional.
import { renderMap } from '@kahwee/sf-map-svg/static';
import { staticMapData } from '@kahwee/sf-map-svg/data/static';
const { svg, project, viewBox } = renderMap(staticMapData, {
year: 2022,
neighborhoodLines: true,
landmarks: true,
bartStations: true,
title: 'San Francisco',
});
// Place your own points with the same projection.
const [x, y] = project([-122.3937, 37.7955]); // Ferry BuildingThe result is a single self-contained <svg> with a title and description. Insert it into HTML, save it as a file, or embed it in an <img>. User-supplied text and attributes are escaped. Each map gets unique ids; pass idPrefix for stable ids across builds.
Choosing geography
Pick the smallest dataset that has what you need. Each is a frozen, typed import.
| Import | Contains | Use with |
|---|---|---|
/data/static | Coast, all district years, SFAR neighborhoods, parks, highways, key roads, BART | renderMap |
/data/full | Everything above, plus all three neighborhood collections and district feature maps | createMap |
/guide/data | A lightweight, simplified guide dataset | Both |
/data/coast, /data/districts, … | One dataset each, to compose your own | Both |
Canonical geometry keeps its full source precision. If you render at build time, only the SVG ships to the browser, whatever the dataset’s size. See Performance.
Layers
Layers draw in a fixed, predictable order. Static maps turn them on with options; interactive maps switch them at runtime with map.configure({ layers }).
districtFills, districtLines, districtLabels.neighborhoodLines; interactively also neighborhoodLabels.landmarks: park property boundaries.highways, keyRoads, roadLabels.bartStations: the eight stations in the city.Set labels: false to hide every visible label while keeping accessible titles. In interactive maps, district layers default on in 'districts' mode and neighborhood layers in 'neighborhoods' mode; explicit layers values always win.
Divisions
Supervisorial districts
Three dated snapshots are included: 2002, 2012 and 2022. Each keeps its own published geometry under a shared coastline mask. Choose one with year, or change it on an interactive map with map.setDistrictYear(year).
Neighborhoods
There is no single neighborhood map of San Francisco. The library ships three definitions and keeps them separate, each with its own names, aliases and sources:
| Source key | Areas | Definition |
|---|---|---|
'realtor' | 92 | San Francisco Association of Realtors market areas, August 2010. The default. |
'sf-find' | 117 | General locations from the Mayor’s Office of Neighborhood Services, 2006. Approximate by design. |
'analysis' | 41 | Reporting areas grouped from census tracts; they can combine several named places. |
const map = createMap(fullMapData, { mode: 'neighborhoods', source: 'realtor' });
map.setSource('sf-find');
// Look a place up in one definition, without a map.
import { getNeighborhood } from '@kahwee/sf-map-svg/data';
getNeighborhood('Inner Mission', { source: 'realtor' });Compare them side by side in the layers studio, or pick one point and see how each names it in One spot, three San Franciscos.
Interactive maps
createMap returns a controller. Mount map.element, then drive the map through the controller.
import { createMap } from '@kahwee/sf-map-svg';
import { fullMapData } from '@kahwee/sf-map-svg/data/full';
const map = createMap(fullMapData, {
mode: 'districts', // 'basemap' | 'neighborhoods' | 'districts'
year: 2022,
layers: { bartStations: true },
appearance: { theme: 'districts' },
});
const host = document.querySelector('#map');
if (!host) throw new Error('Missing #map container');
host.append(map.element);
const stop = map.on('districtchange', ({ id, year }) => console.log(id, year));
map.selectDistrict(5, { fit: true });
map.camera.zoom(1.5, { animate: true });
// Call when your application removes this view.
function disposeMap() {
stop();
map.destroy();
map.element.remove();
}Update layers, controls, features, appearance, mode, source, year and labels with map.configure(). Live appearance retains camera, selection and focus. Try it in the map design playground. The camera works in a fixed 800 × 800 projected space, so viewports serialize cleanly into URLs.
Choropleths
Color districts by any value with districtStyle, a function from a district row to { fill, stroke, opacity }. It runs once per district when the map is built or restyled, not on every hover.
const yesShare = (row) => row.yes / (row.yes + row.no);
const style = (district) => ({
fill: color(yesShare(measure.districts[district.id - 1])),
});
// Static
renderMap(staticMapData, { year: 2022, districtStyle: style });
// Interactive: restyle when the data changes.
// With features.layerTransitions, the fills crossfade.
map.setDistrictStyle(style);The measures, propositions and supervisorial votes examples are complete choropleth pages.
Animation
Every animation is opt-in, and every one yields to the reader’s reduced-motion setting.
Static maps that draw themselves
Pass animation: true to renderMap. The SVG carries its own scoped CSS: land fades in, the coast and lines draw, districts grow in order, then labels and points appear. It plays when the SVG is inserted into a page or loaded as an image, with no JavaScript. Re-insert the markup to replay it.
const { svg } = renderMap(staticMapData, {
neighborhoodLines: true,
highways: true,
bartStations: true,
animation: { duration: 3000, delay: 200 },
});Interactive motion
| Feature | What moves |
|---|---|
motion | The camera eases between views, selections and fits. |
markerEntrance | New markers enter with a stagger; retained markers keep their state. |
selectedMarkerRing | A ring marks the selected marker. |
layerTransitions | Layers fade as they switch; neighborhood definitions crossfade on setSource, and district fills on setDistrictStyle. |
districtMorph | District outlines travel between map years on setDistrictYear, then settle on the exact geometry. |
const map = createMap(fullMapData, {
features: {
motion: { duration: 600 },
layerTransitions: true,
districtMorph: { duration: 1400 },
},
});
map.setDistrictYear(2012); // morphs
map.setDistrictYear(2022, { animate: false }); // instantMarkers and overlays
Markers are points with stable ids; overlays are lines or polygons you supply as GeoJSON geometry. Both work in static and interactive maps.
map.setMarkers([
{ id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955 },
{ id: 'dolores', label: 'Dolores Park', lng: -122.4269, lat: 37.7596, color: '#b4432a' },
]);
map.selectMarker('ferry', { fit: true });
map.on('markerchange', ({ id }) => console.log(id));setMarkers reconciles by id: markers that stay keep their DOM nodes, focus and animations. Turn on clustering for dense data; a full, accessible chooser remains available.
Accessibility
- Static maps have a
<title>and a description of the layers they show. - Interactive districts, neighborhoods and markers are reachable by keyboard and announce their state.
- A visually hidden status region reports selections and mode changes.
- Touch gestures are opt-in, so a map never traps page scrolling on phones.
- All motion stops under
prefers-reduced-motion.
Performance
The fastest map is one rendered before the page loads. Call renderMap in a build step or on the server and ship only the SVG. This site does exactly that: its example maps and plates are rendered at build time, and its interactive maps load a copy of fullMapData simplified for display.
- Import individual datasets rather than presets when a page needs only a few layers.
- Use
/guide/datafor a lightweight interactive guide. - Load interactive maps when they scroll into view with a dynamic
import().
Documentation for coding assistants
Start with the developer guide in Markdown for defaults, configuration updates, coordinates and cleanup. The llms.txt index points to task-specific docs; download the complete text documentation for guides and exact TypeScript contracts in one file.
These files follow the package selected for this site. A local preview can include unreleased APIs; check its changelog before copying them into an npm integration.
Guides, transit and sources
/guide provides ready-made neighborhood guide layouts, including a server-rendered shell you can enhance later. /transit provides the schematic BART journey animation. Every geographic source, license and download date is recorded in SOURCES.md.