Unified Rendering Architecture for Spatial Lens Results
Status & scope
- Status: Accepted — cross-repo design decision (Prism / Cortex / Beacon), frozen; implementation tracked per repo.
- Applies to: Prism (axonis-lens, surface rasterization), Cortex (lens result block), Beacon (
GeoTemporalViewerComponent). - Date: 2026-03-27.
Problem
Every spatial demo hit the same rendering performance wall at different feature counts. Each solved it ad-hoc:
| Demo | Features | What Happened | Ad-hoc Fix |
|---|---|---|---|
| Caucasus 6km | 3,440 rectangles | Vertical stripes, slow pan | Switched to PNG overlay |
| Caucasus 1km | 119,296 cells | Would crash browser | PNG from day 1 |
| Houston soils | 3,861 polygons | Sluggish map interaction | Simplified to 298 |
| Houston gauges | 100 markers | Fine | Kept as markers |
| Mobility surface | 4,000 cells | OK but borderline | Heatmap layer |
Need: A single rule that Prism, Cortex, and Beacon all follow.
The Rule
< 500 features → Vector (L.marker, L.polygon, L.polyline)
Interactive: click, hover, popup
> 500 features → Raster (L.imageOverlay with base64 PNG)
Fast: one image, any grid resolution
Clickable markers ON TOP of the raster for key points
Always: → Both available via layer toggle
Surface for speed, features for drill-down
Five Layer Types
Every map layer in a Beacon experience pack declares its rendering type:
| Type | Leaflet Renderer | Data Format | Use Case |
|---|---|---|---|
point |
L.circleMarker |
{lat, lng, value, color}[] |
Gauges, sensors, entities (<500) |
line |
L.polyline |
GeoJSON LineString | Routes, rivers, roads, tracks |
polygon |
L.polygon |
GeoJSON Polygon | FEMA zones, catchments (<500) |
surface |
L.imageOverlay |
Base64 PNG + bbox | Cost surfaces, risk heatmaps, terrain — any count |
heatmap |
L.heatLayer |
{lat, lng, intensity}[] |
Density visualization |
surface is the new type. Beacon does not have it today. Adding it is one component change.
Pack Config Example
layout: geo_temporal
layers:
- name: flood_risk_surface
type: surface
data_source: query
weight: 0.5
- name: usgs_gauges
type: point
data_source: query
weight: 0.3
- name: evacuation_route
type: line
data_source: query
weight: 0.2
Prism Output Contract
Every spatial lens result returns both formats:
@dataclass(frozen=True)
class SpatialLensResult:
# Standard lens result fields
composite_score: float
layer_contributions: dict
evidence_block: EvidenceBlock
# Spatial rendering (SPEC-08)
surface_png: str | None # Base64 RGBA PNG
surface_bbox: tuple | None # (min_lat, min_lon, max_lat, max_lon)
features: list | None # GeoJSON FeatureCollection (for drill-down)
Why both? PNG renders instantly at any resolution. GeoJSON enables click/hover on individual features. Beacon renders PNG by default, loads GeoJSON on demand (e.g., when user zooms in or clicks "Show Details").
Where Rasterization Happens
Prism (Python, PIL). Not Beacon, not Cortex.
| Layer | Who Computes | Who Renders |
|---|---|---|
| Cost surface PNG | Prism (_render_surface_png()) |
Beacon (L.imageOverlay) |
| Gauge markers | Cortex (ES query → positions) | Beacon (L.circleMarker) |
| Route polyline | Prism (A* on surface) | Beacon (L.polyline) |
| Soil polygons | Prism (simplify GeoJSON) | Beacon (L.polygon) — only if <500 |
Rationale: Beacon stays thin (no canvas computation, no numpy). Prism already has PIL, numpy, and the cost grid in memory. Rasterization is a 10-line function.
Evidence Model for Surfaces
The surface_png is visual evidence. It gets its own hash in the evidence block:
evidence_block = EvidenceBlock(
query_hash=hash(spec + inputs), # reproducible computation
result_hash=hash(composite_score), # numeric result
surface_hash=hash(surface_png_bytes), # visual evidence (new)
)
This enables visual pinning — an analyst can pin the surface to an investigation, and the frozen hash guarantees the image hasn't been modified.
Tradeoff Table
| Decision | Choice | Rationale |
|---|---|---|
| Vector vs raster threshold | 500 features | Empirical: Leaflet degrades above 500 path objects; 100K crashes Safari |
| PNG format | RGBA base64 inline | No extra HTTP request; alpha for transparency over basemap |
| Resolution | Match grid cell size | 1km cells → 1px per cell; Leaflet scales smoothly on zoom |
| Interactivity on surfaces | Markers on top | Keep <500 key points as clickable markers over the static surface |
| Dual return | PNG + GeoJSON | PNG for speed; GeoJSON for drill-down; Beacon toggles |
| Rasterization location | Prism (Python) | Beacon stays thin; Prism has the data in memory |
| PNG compression | Default PIL PNG | ~50KB for 120K cells; could add JPEG for larger surfaces |
Integration Path
| Step | Component | What | Effort |
|---|---|---|---|
| 1 | Prism | Generalize _render_surface_png() to all spatial lens types |
1 hour |
| 2 | Cortex | Add surface_png, surface_bbox, surface_hash to lens result block |
30 min |
| 3 | Beacon | Add surface layer type to GeoTemporalViewerComponent |
2 hours |
| 4 | Packs | Add type: surface to map layer definitions in experience YAML |
30 min |
Total: half day. All existing demos already use this pattern. Just needs to be formalized in Beacon.
Acceptance Criteria
- Beacon renders
type: surfacelayers asL.imageOverlaywith base64 PNG - Layer toggle works — surface on/off independently of markers
- Surface PNG generated by Prism, not Beacon
- Evidence block includes
surface_hashwhen spatial result is present - All existing demos continue to work (no regressions)
- Pack config supports all 5 layer types
- Houston demo renders cost surface + gauge markers + evacuation routes on same map
This spec documents the rendering architecture decision made during the March 2026 demo sprint. It codifies the pattern that emerged across Caucasus, Houston, and mobility surface demos.
Depends on: component.prism.evidence-model, component.prism.scoring-engine
Realizes: product.lens