Phase 35 — Tactical Map & Spatial Visualization¶
Summary¶
Built a 2D tactical map with post-hoc replay for completed simulation runs. The pipeline captures terrain and unit position data during run execution, stores it in the database, serves it via two new API endpoints, and renders it on an HTML5 Canvas with playback controls, overlays, and chart synchronization.
Backend: 4 modified API files + 1 new test file (13 tests). Two new nullable DB columns (terrain_json, frames_json), terrain/frame capture in RunManager._run_sync(), and GET /terrain + GET /frames endpoints.
Frontend: 15 new source files + 10 new test files (58 tests) + 3 modified files. Canvas-based TacticalMap component with terrain rendering, unit markers (domain-specific shapes), engagement arcs, movement trails, playback controls, map legend, unit detail sidebar, and viewport zoom/pan. Integrated as a new tab in RunDetailPage and as a standalone fullscreen route.
What Was Built¶
35a: API Data Pipeline¶
api/database.py—terrain_json TEXTandframes_json TEXTnullable columns withALTER TABLEmigrationapi/run_manager.py—_capture_terrain()extracts heightmap/classification/objectives;_capture_frame()captures unit positions at dynamic intervals (max(1, max_ticks // 500))api/schemas.py— 5 new pydantic models:MapUnitFrame,ReplayFrame,FramesResponse,ObjectiveInfo,TerrainResponseapi/routers/runs.py—GET /runs/{id}/terrainandGET /runs/{id}/frameswithstart_tick/end_tickfiltering
35b: Canvas Map Renderer + Types¶
types/map.ts— 7 TypeScript interfaces mirroring API modelsapi/map.ts—fetchRunTerrain()andfetchRunFrames()fetch wrappershooks/useMap.ts— TanStack Query hooks withstaleTime: Infinitylib/terrain.ts— 15 land cover colors/names,worldToScreen()/screenToWorld()transforms with Y-flip,getVisibleCellRange()for viewport cullinglib/unitRendering.ts—drawUnit()with domain shapes (rect/triangle/diamond/circle), status modifiers,hitTestUnit()for click detectioncomponents/map/useViewportControls.ts— zoom-at-cursor, pan,fitToExtent()components/map/TacticalMap.tsx— Main orchestrator: ResizeObserver, offscreen terrain canvas, layered render (terrain → objectives → trails → engagements → units)
35c: Overlays, Legend & Selection¶
components/map/MapControls.tsx— Toggle bar (labels, destroyed, engagements, trails), Fit button, world coordinate displaycomponents/map/MapLegend.tsx— Terrain colors, side colors, domain shape iconscomponents/map/UnitDetailSidebar.tsx— Selected unit panel with full metadatalib/engagementProcessing.ts—buildEngagementArcs()matches events to nearest frame
35d: Playback Controls & Integration¶
hooks/usePlayback.ts— rAF-based animation loop, play/pause/step/seek, 4 speed options (1x/2x/5x/10x)components/map/PlaybackControls.tsx— Transport buttons, timeline scrubber, speed selector, tick infopages/runs/tabs/MapTab.tsx— Integrated tab with empty state for pre-Phase-35 runspages/map/FullscreenMapPage.tsx— Standalone/map/:runIdroute- Chart sync — MapTab writes
?tick=Nto URL params; ChartsTab reads it and draws vertical reference line on ForceStrengthChart vialayoutOverrides
Design Decisions¶
- Lightweight frames, not full snapshots — ~80 bytes/unit (compact keys
d,s,h,t) vs 100KB+ for fullctx.get_state(). Targets ~500 frames per run regardless of length. - Single Canvas, no extra deps — Pure HTML5 Canvas with offscreen terrain cache. No Pixi.js/Konva/Leaflet. Keeps bundle small.
- Y-axis flip — Canvas Y↓ vs ENU northing↑. All rendering through
worldToScreen()with consistent flip. - Nullable columns for backward compat — Pre-Phase-35 runs show "Map data not available" empty state. No migration pain.
getattr()safe access — Terrain capture usesgetattr(ctx, "heightmap", None)pattern to handle scenarios where terrain objects may not be present.- Chart sync via URL params —
?tick=Nshared between MapTab and ChartsTab. No global store needed; URL is the source of truth. - Dynamic frame interval —
max(1, max_ticks // 500)targets ~500 frames regardless of run length. Not configurable yet.
Deviations from Plan¶
None significant. All planned items delivered. Deferred items (detection circles, FOW toggle, elevation shading, engagement fade animation) were pre-planned deferrals from the Phase 35 design doc.
Issues & Fixes¶
- TypeScript strict null checks —
extent[0]returnsnumber | undefinedwith strict mode. Fixed with non-null assertions (extent[0]!) after length guard. getVisibleCellRangeinverted bounds —screenToWorld(0,0)gives min-X/max-Y, not max-X/max-Y. Fixed by usingMath.min/maxon both corners rather than assuming positions.- UnitDetailSidebar duplicate text — Unit type appears in both header and Type row. Test fixed to use
getAllByTextinstead ofgetByText. - Canvas mock in vitest —
HTMLCanvasElement.prototype.getContextmust be re-mocked aftervi.restoreAllMocks()inbeforeEach.
Known Limitations¶
- Frame data uses compact keys in storage but API expands to full names — slight redundancy
terrain_jsonandframes_jsonstored as TEXT blobs — no indexing, large runs may produce 2MB+ framesuseViewportControlshook has no dedicated test file (exercised indirectly via TacticalMap)- Only
ForceStrengthChartshows the tick sync marker line — other charts don't - Frame capture interval is not configurable
- No keyboard shortcuts for playback (deferred to Phase 36)
Lessons Learned¶
- Off-screen canvas for terrain caching is essential — re-rendering terrain cells every frame would be too slow. Only re-render when transform changes.
- Canvas mock strategy in vitest: Mock
getContextto return a stub with all needed method names. Use property setters for style properties (set fillStyle(_v: string) {}). getVisibleCellRangemath: Never assume screen corners map to specific world corners — useMath.min/maxon both to get correct world bounds regardless of transform.- URL params as state:
?tick=Nfor cross-tab sync is simpler than any state management solution and survives page refreshes. @staticmethodfor capture helpers: Both_capture_terrainand_capture_framehave noselfdependency, making them testable without instantiatingRunManager.
Postmortem¶
- Scope: On target — all planned items delivered, deferred items were pre-planned
- Quality: High — 71 new tests (13 Python + 58 vitest), clean TypeScript, no dead code, no TODOs
- Integration: Fully wired — backend→DB→API→frontend→canvas pipeline, chart sync
- Deficits: 6 new items (all LOW, 1 deferred to Phase 36)
- Action items: Documentation lockstep update (CLAUDE.md, README.md, MEMORY.md, devlog/index.md, development-phases-block3.md, docs/index.md, docs/reference/api.md, mkdocs.yml)