Skip to content

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

  1. Beacon renders type: surface layers as L.imageOverlay with base64 PNG
  2. Layer toggle works — surface on/off independently of markers
  3. Surface PNG generated by Prism, not Beacon
  4. Evidence block includes surface_hash when spatial result is present
  5. All existing demos continue to work (no regressions)
  6. Pack config supports all 5 layer types
  7. 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