Skip to content

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

  1. Now: Build new /layers/* endpoints
  2. Later: Apps migrate from /districts/* to /layers/federal-congressional/*
  3. 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

  1. ID format: Use source IDs (080) or generate GEOIDs (26080)?
  2. State senate: Import now or wait for data?
  3. Other states: Priority order for Iowa, Kentucky?

Ready to implement when approved.