Time travel on the canvas
Review reads the local SQLite journal, reconstructs the state of tracked layers at a user-chosen date T, and projects the result onto the QGIS canvas as transient overlay layers — without ever writing back. A slim canvas-anchored date bar (Google Earth style) lets the user scrub through time. It is the read-only twin of Restore: same data, opposite intent.
Vision
Restore answers the question « put the data back ». Review answers a different one: « what did the data look like at date T ». The same audit events feed both, but Review never mutates a source layer. It reconstructs the state of tracked layers at a chosen date and builds in-memory overlay layers showing that state.
The mode is designed as a continuous companion to QGIS editing: turn it on, drag the date bar, see the past. Turn it off and every trace disappears. No styles edited on source layers. No features changed. No journal rows written.
Review only reads from the journal and adds memory layers prefixed __rl_lens_. The mode never calls changeGeometry, changeAttributeValue, addFeatures or deleteFeatures on a tracked layer.
The cache holds events for a time range; the renderer keeps only those whose envelope intersects the current canvas extent. Pan or zoom and the visible subset recomputes in 20–80 ms per layer.
The session subscribes to the edit tracker. Each new commit is debounced (600 ms) and incrementally added to the cache and the visible overlays.
~2 KB per cached event, ~10 MB at 5 000 events, ~100 MB at 50k. The render worker is cancelable; a new viewport supersedes the previous one without waiting.
What Review is (and is not)
Review is a runtime mode, not a workflow. It does not produce artefacts, it does not modify history, and it does not persist anything beyond the session.
| Review is | Review is not |
|---|---|
| A read-only overlay showing data state at date T | An editing tool |
| A time-travel reconstruction of tracked layers | A diff report or attribute table |
| A companion to Restore (same data, opposite verb) | A replacement for Restore |
| Driven by a canvas date bar (scrub through time) | An async export job |
| Cleared on toggle off | A persistent layer style |
__rl_lens_ followed by an 8-character UUID and a role suffix (geom_past, arrows, attr_markers). The prefix is shared with the legacy Time Lens dock so a single teardown (workflow_service._LENS_LAYER_PREFIX) cleans both. Nothing else is touched.The four layers
Review lives across UI, orchestration, domain, and worker threads. Each layer has a single role; dependencies flow downward only.
| Layer | Owns | Forbidden |
|---|---|---|
| L1 UI | Dock, toggle, status pill, map tools, dialog wiring | Direct cache mutation, SQL reads |
| L2 Session | Cache lifecycle, overlay creation/teardown, debounce | Feature classification, geometry math |
| L3 Domain | Contracts, planner, envelope intersection | QGIS imports, Qt signals |
| L4 Workers | Off-UI fetch and feature build | QGIS layer mutation, project access |
lens_contracts → lens_planner → lens_renderer mirrors the Restore trio restore_contracts → restore_planner → restore_executor. Same separation, opposite verb.Session start
From toggle ON to first visible overlay. The first refresh is the slowest because the entire time range is loaded into RAM in a single QThread shot; everything afterwards reuses that cache.
| Step | Module | Cost | Failure mode |
|---|---|---|---|
| 1 Toggle | recover_dialog | O(1) | Status pill insertion forced via QApplication.processEvents() for instant feedback. |
| 2 Selection | core/lens_contracts | O(layers) | Empty selection → early exit with WARNING log. |
| 3 Fetch | widgets/review_worker | O(events) on read connection | Cap MAX_EVENTS_PER_RESTORE applies; truncation flagged in fetch_stats. |
| 4 Overlay create | core/review_session | O(layers) × 3 | Geometry family detected from first event; mixed types skipped with warning. |
| 5 First render | widgets/review_render_worker | O(events in viewport) | Worker cancelable on rapid extent change. |
| 6 Display | recover_dialog | O(features) | addFeatures on memory provider ≈ 5 ms per layer. |
Viewport refresh
Pan or zoom in QGIS → canvas.extentsChanged → debounce 600 ms → render worker. The cache is never re-read from SQLite; only the in-RAM dict is filtered by envelope intersection.
QgsGeometry is a Qt object and cannot safely cross threads. The worker emits (wkb_bytes, attributes_list); the UI thread builds QgsGeometry.fromWkb() and calls addFeatures() in one batch.Live edits
While Review is on, the user can keep editing tracked layers. Every commit triggers the same capture pipeline as Restore (edit_tracker → write_queue); Review listens to the tracker's committed_features signal and updates the canvas without polling SQLite.
commit_capture (~50 ms) + debounce (600 ms) + render (~80 ms). The 600 ms debounce is the dominant term; it absorbs back-to-back commits during multi-feature edit sessions.Session stop
Toggle off, status pill close button, dialog close, project switch — four entry points, one teardown path.
| Trigger | Effect |
|---|---|
| Toggle off | Cancel render worker · cancel cache worker · session.stop() · remove all __rl_lens_* overlays · clear cache · hide status pill |
| Status pill × | Same as toggle off · toggle widget reverts |
| Dialog close | Same as toggle off · purge_lens_overlays("dialog_close") as belt-and-braces guard |
| Project switch | Same as dialog close · reset of journal connection drops the cache implicitly |
QgsProject.instance() only for the duration of the session.Snapshot mode (Mode A)
Instead of showing what changed (diff), Snapshot mode shows how things were at a user-chosen date T. A slim date bar anchored to the bottom of the QGIS canvas (Google Earth style) lets the user drag a handle or type a date. The reconstructed state is displayed as one overlay layer per source.
The temporal_snapshot_engine replays the cached events (already bounded by time range) up to T. O(N) over cache, ~1–5 ms per 5000 events. No SQLite read per date change.
The CanvasDateBar debounces date_changed at 800 ms so rapid drags do not queue up redundant rebuilds.
Unlike diff mode (3 layers per source), Snapshot produces exactly one memory layer per tracked layer, containing the entities that existed at T.
The TemporalTimelineWidget paints operation-colored markers (green INSERT, orange UPDATE, red DELETE) with magnetic snap so the user naturally finds meaningful dates.
Algorithm (forward replay until T)
| Last event ≤ T | Result |
|---|---|
| INSERT or UPDATE | Entity exists at T → included in overlay with reconstructed geometry + attributes |
| DELETE | Entity was removed before T → excluded (counted as n_absent) |
| No events ≤ T | Entity not yet created → excluded (counted as n_unknown) |
Pipeline
__rl_snap_ prefix and the layer group « Review Snapshot ». The diff-mode overlays (__rl_lens_) are unaffected. Both can coexist but are toggled separately.The three overlays
For every tracked source layer, Review creates exactly three memory layers. They share the source CRS but live in their own provider so styles and selections do not leak.
| Suffix | Geometry | Carries | What it tells the user |
|---|---|---|---|
geom_past | Same family as source (Point / Line / Polygon) | OLD geometry of every event in scope | Where the entity was before the change. |
arrows | LineString | centroid(OLD) → centroid(NEW) for UPDATEs with geometry change | Movement vector. Length and direction tell the eye. |
attr_markers | Point | centroid for UPDATEs that changed attributes only | « something changed but not the shape » — opens the diff panel on click. |
entity_fp, event_id, created_at, op, user, _rl_age, _rl_class (entity classification). For mode Updates stacked (P1), two more: _rl_opacity and _rl_stack_order. The dock's diff panel reads event_id to fetch the full attribute delta from SQLite on demand.Visualization modes
Review ships one visualization mode today. One future mode is planned.
The state of every tracked layer reconstructed at one cutoff date T. One geometry per entity, overlaid via a canvas-anchored date bar (Google Earth style). Forward-replayed from cached events in RAM; zero SQL per date change.
Time-driven playback through the timeline. Replays Snapshot frames at user-controlled speed. Reuses the same overlays, only the active subset changes per tick.
Entity classification
For every entity, the planner inspects its OLD/NEW geometries against the selection bbox at first and last event. The result is one of seven classes carried in the _rl_class attribute and used by the dock symbology.
| Class | Meaning |
|---|---|
CREATED_IN_ZONE | First event is INSERT and falls inside the bbox. |
DELETED_FROM_ZONE | Last event is DELETE; previous geometries were inside. |
MOVED_INTO_ZONE | Old geometry outside, new geometry inside. |
MOVED_OUT_OF_ZONE | Old geometry inside, new geometry outside. |
MOVED_WITHIN_ZONE | Both inside, geometry differs. |
ATTR_ONLY_IN_ZONE | Geometry stable, attributes changed. |
UPDATED_IN_ZONE | Generic UPDATE inside the bbox; fallback bucket. |
Module catalog — Contracts
Pure data, zero QGIS, zero Qt. The vocabulary every other Review module agrees on.
| File | Lines | Owns |
|---|---|---|
core/lens_contracts.py | 186 | Enums (LensOpFilter, LensVisualizationMode, EntityClassification) and NamedTuples (LensSelection, EntityState, EntityTimeline, LensFetchStats, LensRenderPlan, LensRenderResult, LensRefreshOutcome). |
core/wkb_envelope.py | 250 | WKB envelope parser using only the standard library. parse_envelope(wkb) returns a 4-tuple; envelope_intersects(a, b) answers in O(1) without GEOS. |
Module catalog — Domain (planner, renderer)
Pure logic. Same import discipline as the contracts. Tested without a running QGIS instance.
| File | Lines | Role |
|---|---|---|
core/lens_planner.py | 236 | Groups events by entity (fingerprint preferred, identity_json fallback for shapefile FID recycling), orders timelines, builds EntityStates with old/new geom and attr delta, classifies the entity. Output: LensRenderPlan. |
core/lens_renderer.py | 549 | Synchronous renderer that consumes a plan and creates the three QGIS memory layers in one call. Used by the standalone Time Lens dock; reused by review_session for overlay creation primitives (_make_overlay_layer) and by the render worker as the attribute schema reference. |
core/temporal_snapshot_engine.py | 290 | Snapshot mode core. Forward-replays cached events until cutoff date T. Per entity: last event ≤ T → INSERT/UPDATE = exists, DELETE = absent. Returns SnapshotResult mapping {datasource_fp: {entity_fp: SnapshotFeature}}. Pure Python, O(N) over cache, ~1–5 ms per 5000 events. |
core/snapshot_overlay_session.py | 650 | Manages one memory layer per source layer showing the reconstructed state at date T. Lifecycle: start() → update(snapshot_result) → stop(). Prefix __rl_snap_, group « Review Snapshot ». |
Module catalog — Session & workers
Where domain meets QGIS. The session lives on the UI thread; the workers run off it.
| File | Lines | Role |
|---|---|---|
core/review_session.py | 505 | Owns the in-RAM event cache, the three overlay layer IDs per source, the active flag, and the destination CRS. Methods: start(), refresh_viewport(bbox), refresh_one_layer(fp, bbox), on_new_events(events), stop(). |
widgets/review_worker.py | 117 | One-shot QThread for the initial cache load. Emits progress(layer_name, n_events, idx), finished_ok(trace_id, cache), finished_err(trace_id, msg). |
widgets/review_render_worker.py | 512 | Per-viewport feature builder. Cancelable. Emits layer_ready(fp, past[], arrows[], attr_markers[]) with raw (wkb_bytes, attrs) tuples and finally all_done({n_entities, n_features, elapsed_ms}). |
widgets/snapshot_rebuild_worker.py | 319 | Snapshot mode worker. Per-date SQL query (debounced 800 ms by CanvasDateBar). CTE isolates MAX(created_at) per entity_fp, then inner join fetches full row. O(N entities) rows. Emits result_ready(trace_id, SnapshotResult) and markers_ready. |
Module catalog — UI surface
| File | Lines | Role |
|---|---|---|
widgets/temporal_lens_dock.py | 994 | QDockWidget hosting the Review panel: presets, custom date range, op filter, mode selector, entity list, attribute diff panel, status messages, error banner. Fully i18n (FR/EN, 56 entries). |
widgets/temporal_lens_map_tool.py | 108 | Rectangle map tool used to draw the Review selection bbox. |
widgets/temporal_lens_polygon_map_tool.py | 111 | Polygon map tool variant for irregular zones. |
widgets/review_status_widget.py | 246 | Status bar pill: phase dot (idle / fetch / render / alert), entity counter, OFF button. Inserted in the QGIS main status bar only when a session is active. |
widgets/review_segmented_switch.py | 114 | Segmented switch « Present / Review » for the dialog header. |
widgets/canvas_date_bar.py | 410 | Snapshot mode UI. Slim QWidget anchored to canvas bottom (Google Earth style). Hosts QDateEdit + TemporalTimelineWidget + stats label. Debounced date_changed signal (800 ms). Purges stale bars across plugin reloads. |
widgets/temporal_timeline_widget.py | 343 | Custom-painted timeline replacing QSlider. DPI-aware, operation-colored markers (INSERT green, UPDATE orange, DELETE red), adaptive ruler (hours / days / months / years), magnetic snap within 10 px, tooltip preview on hover. |
Threading model
Four threads cooperate: UI, cache worker, render worker (diff mode), and snapshot rebuild worker (snapshot mode). No shared mutable state crosses the boundary except the immutable event cache snapshot passed by reference to the render worker.
| Thread | Owns | Lifetime |
|---|---|---|
| UI | Cache mutation, overlay creation, addFeatures, signal handlers | Plugin lifetime |
| Cache worker | Initial bulk SELECT into the cache dict | One shot per session start |
| Render worker | Envelope filter + planner + repair + reproject + WKB serialization | One per viewport refresh, cancelable |
Status bar pill
The QGIS status bar carries two RecoverLand widgets with distinct roles. The Review pill is hidden when the mode is off; it appears only when a session runs.
| Widget | Visible when | Shows |
|---|---|---|
StatusBarIndicator (existing) | Always | Tracking on/off, journal health, event count. |
ReviewStatusWidget (new) | Review session active | Phase dot (idle / fetch / render / alert), Review · n entity counter, OFF button (×). |
insertPermanentWidget(0, …) so it sits left of the QGIS coordinate display. QApplication.processEvents() is called right after insertion to force an immediate repaint before recover_and_load() blocks the UI thread.Performance budget
| Operation | Target | Mechanism |
|---|---|---|
| Toggle ON → pill visible | < 50 ms | Synchronous insert + processEvents() |
| Cache load (10k events, 5 layers) | < 1.5 s | Single bounded SELECT in QThread |
| Viewport refresh (one layer in extent) | 20–80 ms | Pure-Python envelope filter on cached dict |
UI thread addFeatures | ≈ 5 ms / layer | Memory provider, batched in one call |
| Live edit → visible | < 800 ms | 600 ms debounce + 80 ms render + 5 ms add |
| RAM at 50k cached events | ≈ 100 MB | ~2 KB per AuditEvent in cache |
| Snapshot rebuild (5k entities) | 1–5 ms | Pure-Python forward replay on cached events, zero SQL |
| Snapshot date change → overlay | < 900 ms | 800 ms debounce + 5 ms replay + addFeatures |
MAX_EVENTS_PER_RESTORE events in the time range, the fetch returns a truncated list and fetch_stats.n_events_truncated is non-zero. The dock paints a red header banner; older events are simply not displayed.Integration with Restore & Rewind
The three modes (Restore, Rewind, Review) consume the same audit events and share four core modules. Restore and Rewind write back; Review only reads. The relationship is « same data, opposite verb ».
| Module | Used by Restore | Used by Review |
|---|---|---|
core/event_stream_repository | fetch_events_after_cutoff | fetch_events_in_zone |
core/geometry_utils | WKB ⇆ QgsGeometry, comparison | + repair_geometry_for_render · reproject_geometry_for_render |
core/identity | Match snapshot to live feature | Group events by entity |
core/journal_manager | Read/write SQLite connection | Read-only SQLite connection |
lens_* or review_* modules; reusable primitives are added to the existing core modules.Known debt
The mode is healthy on most axes. One debt item remains. Naming it here makes it easier to attack later.
The synchronous core/lens_renderer.execute_lens_render() and the async widgets/review_render_worker (from the former diff modes) remain in the codebase. They are no longer the primary path (Snapshot uses SnapshotRebuildWorker + SnapshotOverlaySession) but are not yet removed.
Contracts pure, session bounded, workers cancelable, status pill scoped to active sessions. No leak detected in lifecycle scenarios.