Multi-Layer Polygon Architecture¶
Overview¶
Extend cbdistricts to support multiple geographic boundary layers, each stored in an independent DuckDB database. New endpoints added alongside existing ones for backwards compatibility.
Current State¶
/api/v1/districts/* # Congressional districts (441)
/api/v1/states/* # State metadata
/api/v1/demographics/* # Census data
Single database: data/output/cbdistricts.duckdb
Proposed Architecture¶
Database Structure¶
data/
├── output/
│ └── cbdistricts.duckdb # KEEP: existing congressional data
│
└── layers/
├── registry.json # Layer metadata registry
│
├── federal/
│ └── congressional_119.duckdb # Copy of cbdistricts.duckdb
│
├── michigan/
│ ├── state_house.duckdb # 110 districts
│ └── state_senate.duckdb # Future
│
├── iowa/
│ └── state_house.duckdb # Future
│
└── kentucky/
└── state_house.duckdb # Future
Layer Registry (registry.json)¶
{
"version": "1.0",
"layers": {
"federal-congressional": {
"name": "U.S. Congressional Districts",
"description": "119th Congress (2025-2027)",
"database": "federal/congressional_119.duckdb",
"feature_count": 441,
"geometry_type": "MultiPolygon",
"id_field": "geoid",
"properties": ["name", "state_fips", "district", "party", "representative"],
"source": "Census TIGER/Line 2024",
"updated": "2025-12-15"
},
"michigan-state-house": {
"name": "Michigan State House Districts",
"description": "v17a redistricting",
"database": "michigan/state_house.duckdb",
"feature_count": 110,
"geometry_type": "Polygon",
"id_field": "name",
"properties": ["name", "legislator", "party", "url", "peninsula"],
"source": "Michigan GIS Open Data",
"updated": "2025-12-17"
}
}
}
New API Endpoints¶
All new endpoints under /api/v1/layers/ - existing endpoints unchanged.
| Method | Endpoint | Description |
|---|---|---|
| GET | /layers |
List all available layers |
| GET | /layers/{layer_id} |
Layer metadata |
| GET | /layers/{layer_id}/features |
List features (with pagination) |
| GET | /layers/{layer_id}/features/{feature_id} |
Single feature detail |
| GET | /layers/{layer_id}/geojson |
Full GeoJSON FeatureCollection |
Example Requests¶
# List all layers
GET /api/v1/layers
{
"layers": [
{"id": "federal-congressional", "name": "U.S. Congressional Districts", "count": 441},
{"id": "michigan-state-house", "name": "Michigan State House Districts", "count": 110}
]
}
# Get layer metadata
GET /api/v1/layers/michigan-state-house
{
"id": "michigan-state-house",
"name": "Michigan State House Districts",
"feature_count": 110,
"properties": ["name", "legislator", "party", "url", "peninsula"],
"bounds": [-90.4, 41.7, -82.4, 48.2],
...
}
# Get all features as GeoJSON (for map rendering)
GET /api/v1/layers/michigan-state-house/geojson?include_geometry=true
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"id": "080",
"name": "District 80",
"legislator": "Mary Whiteford",
"party": "Republican",
"peninsula": "lower"
},
"geometry": { ... }
},
...
]
}
# Get single feature
GET /api/v1/layers/michigan-state-house/features/080
{
"id": "080",
"name": "District 80",
"legislator": "Mary Whiteford",
"party": "Republican",
"url": "https://gophouse.org/representatives/southwest/whiteford/",
"peninsula": "lower",
"sq_miles": 733.917,
"geometry": { ... }
}
Implementation Plan¶
Phase 1: Infrastructure (This PR)¶
Files to create:
| File | Description |
|---|---|
data/layers/registry.json |
Layer metadata registry |
api/services/layer_service.py |
Layer database management |
api/models/layer.py |
Pydantic models for layers |
api/routes/layers.py |
New /layers/* endpoints |
Files to modify:
| File | Change |
|---|---|
api/main.py |
Register layers router |
config/settings.py |
Add LAYERS_DIR path |
Phase 2: Michigan State House Import¶
Script to create:
| File | Description |
|---|---|
scripts/import_layer.py |
Generic layer import tool |
Usage:
python scripts/import_layer.py \
--source state-data/michigan/Michigan_State_House_Districts_(v17a).geojson \
--layer-id michigan-state-house \
--name "Michigan State House Districts" \
--id-field NAME \
--property-map "NAME:name,LEGISLATOR:legislator,PARTY:party,URL:url,PENINSULA:peninsula"
Phase 3: Frontend Integration¶
Map client can load multiple layers:
// Load layer registry
const layers = await fetch('/api/v1/layers').then(r => r.json());
// Add layer selector to UI
layers.forEach(layer => {
addLayerToggle(layer.id, layer.name);
});
// Load selected layer
async function loadLayer(layerId) {
const geojson = await fetch(`/api/v1/layers/${layerId}/geojson?include_geometry=true`);
map.addSource(layerId, { type: 'geojson', data: geojson });
map.addLayer({
id: layerId,
source: layerId,
type: 'fill',
paint: {
'fill-color': ['match', ['get', 'party'],
'Republican', '#E91D0E',
'Democrat', '#232066',
'R', '#E91D0E',
'D', '#232066',
'#808080'
]
}
});
}
DuckDB Schema (Per Layer)¶
Each layer database has the same core structure:
-- Features table (required)
CREATE TABLE features (
id VARCHAR PRIMARY KEY, -- Feature identifier
name VARCHAR, -- Display name
geometry GEOMETRY, -- Spatial geometry
properties JSON -- All other attributes
);
-- Spatial index
CREATE INDEX idx_features_geom ON features USING RTREE (geometry);
-- Metadata table (required)
CREATE TABLE _metadata (
key VARCHAR PRIMARY KEY,
value JSON
);
-- Example metadata
INSERT INTO _metadata VALUES
('layer_id', '"michigan-state-house"'),
('feature_count', '110'),
('bounds', '[-90.4, 41.7, -82.4, 48.2]'),
('created_at', '"2025-12-17T00:00:00Z"');
Party Normalization¶
Map source party values to standard format:
| Source | Normalized |
|---|---|
R |
Republican |
D |
Democrat |
Republican |
Republican |
Democrat |
Democrat |
Democratic |
Democrat |
Applied during import so all layers have consistent party values.
Caching Strategy¶
Same Redis caching pattern as existing endpoints:
# Cache key pattern
layers:{layer_id}:metadata # Layer info (7 days)
layers:{layer_id}:geojson # Full GeoJSON (7 days)
layers:{layer_id}:features:{id} # Single feature (7 days)
Migration Path¶
- Now: Build new
/layers/*endpoints - Later: Apps migrate from
/districts/*to/layers/federal-congressional/* - Eventually: Deprecate old endpoints (optional, can keep forever)
File Summary¶
api/
├── routes/
│ └── layers.py # NEW: /layers/* endpoints
├── services/
│ └── layer_service.py # NEW: Layer DB management
├── models/
│ └── layer.py # NEW: Layer Pydantic models
│
data/
└── layers/
├── registry.json # NEW: Layer registry
├── federal/
│ └── congressional_119.duckdb
└── michigan/
└── state_house.duckdb
│
scripts/
└── import_layer.py # NEW: Layer import tool
Estimated Effort¶
| Phase | Tasks | Estimate |
|---|---|---|
| Phase 1 | Infrastructure + endpoints | 3-4 hours |
| Phase 2 | Michigan import | 1-2 hours |
| Phase 3 | Frontend layer switcher | 2-3 hours |
Questions to Resolve¶
- ID format: Use source IDs (
080) or generate GEOIDs (26080)? - State senate: Import now or wait for data?
- Other states: Priority order for Iowa, Kentucky?
Ready to implement when approved.