Skip to content

Progressive rendering during parallel quadrant fetching#133

Open
dougborg wants to merge 4 commits intoFoggedLens:mainfrom
dougborg:feat/progressive-quadrant-rendering
Open

Progressive rendering during parallel quadrant fetching#133
dougborg wants to merge 4 commits intoFoggedLens:mainfrom
dougborg:feat/progressive-quadrant-rendering

Conversation

@dougborg
Copy link
Collaborator

@dougborg dougborg commented Feb 26, 2026

Summary

  • Each completed Overpass quadrant now renders on the map immediately instead of waiting for all quadrants to finish
  • NodeProviderWithCache listens to NodeDataManager directly, replacing the manual post-fetch notifyListeners() call
  • 2 new tests verify per-quadrant notification behavior

Problem

On main, _fetchSplitAreas() fetches quadrants sequentially in a for loop. The only intentional UI notification happens after getNodesFor() returns — meaning the code is designed so the user sees nothing until every single quadrant (up to 64 at max split depth) has completed. The whole pipeline is:

  1. NodeProviderWithCache.fetchAndUpdate() calls await _nodeDataManager.getNodesFor(...)
  2. getNodesFor() calls fetchWithSplitting(), which calls _fetchSplitAreas()
  3. _fetchSplitAreas() loops through quadrants one-by-one with await
  4. Only after all quadrants return does getNodesFor() call notifyListeners()
  5. fetchAndUpdate() then calls notifyListeners() again on the provider

Accidental progressive rendering on main

There is, however, an accidental progressive rendering path that already exists on main. Users may have noticed partial data appearing during split fetches — but only while actively interacting with the map:

  • onPositionChanged in map_view.dart (line 408) calls setState(() {}) on every frame during a pan/zoom gesture
  • Each setState triggers a widget rebuild, which calls MapDataManager.getNodesForRendering()NodeProviderWithCache.getCachedNodesForBounds() → reads directly from the shared NodeSpatialCache singleton
  • Meanwhile, the sequential _fetchSplitAreas loop writes each completed quadrant's data into that same shared singleton cache via _cache.markAreaAsFetched()
  • So during user interaction, the continuous setState rebuilds accidentally pick up partially-written quadrant data from the cache

This means progressive rendering on main is gesture-dependent: if the map is completely still (no touches), no setState fires, and nothing re-reads the cache until all quadrants finish. The user only sees partial results if they happen to be panning/zooming during the fetch.

Why parallelization alone doesn't fix rendering

PR #114 parallelized the fetching (replacing the for loop with Future.wait()), but Future.wait() still waits for all futures to resolve before returning. The single notifyListeners() at the end of getNodesFor() still fires only once, after every quadrant has finished. The user still sees the same all-or-nothing behavior when the map is still — just faster overall. The accidental gesture-dependent path still works the same way.

Fix

Two small changes make progressive rendering intentional and reliable, regardless of whether the user is interacting with the map:

NodeDataManager.fetchWithSplitting() — call notifyListeners() right after _cache.markAreaAsFetched() when a quadrant returns non-empty data. Since each quadrant runs as its own async task inside Future.wait(), this fires as soon as that quadrant's HTTP response arrives — not when all four finish.

NodeProviderWithCache — register as a listener on NodeDataManager so those mid-flight notifications propagate to the map widget. This also removes the now-redundant manual notifyListeners() after await getNodesFor() returns, which was causing a double rebuild.

Why this is safe

  • Flutter coalesces rapid setState calls within the same frame, so even at max split depth (64 quadrants), rapid-fire notifications don't cause 64 separate frames
  • During user interaction, the onPositionChanged setState and our listener-triggered setState coalesce within the same frame — no extra rebuilds
  • The nodes.isNotEmpty guard skips empty quadrant results
  • getCachedNodesForBounds() is a cheap in-memory read from the shared NodeSpatialCache singleton
  • Both NodeDataManager and NodeProviderWithCache are singletons sharing the same cache, so data written by one is immediately visible to the other
  • The trailing notifyListeners() in getNodesFor() (line 278) is kept — it's still needed for the non-split happy path and empty-result cases

Test plan

  • flutter test test/services/node_data_manager_test.dart — 26 tests pass (2 new progressive rendering tests)
  • flutter test — 92 tests pass, no regressions
  • flutter analyze — no issues
  • Smoke test: dense area (London/NYC) at zoom 10-11 → cameras appear progressively as quadrants complete, even without touching the map

🤖 Generated with Claude Code

Doug Borg and others added 3 commits February 25, 2026 18:19
When Overpass queries hit the 50k node limit, _fetchSplitAreas() splits
the area into 4 quadrants. Previously these were fetched sequentially —
at max split depth (3) that's up to 64 serial HTTP requests, which is
the primary cause of the slow "splitting" network status users report.

This change:

- Parallelizes quadrant fetches with Future.wait() instead of a for-loop
- Adds a dynamic concurrency limiter (_AsyncSemaphore) sized from the
  Overpass /api/status slot count, so we never exceed the server's
  per-IP rate limit
- Replaces the "sleep 30s and give up on 429" behavior with smart retry:
  polls /api/status until a slot is available (modeled after OSMnx and
  OSMPythonTools), then retries up to 2 times
- Resizes the semaphore on retry with the latest observed slot count,
  adapting to changing server conditions

Refactors for testability:
- HTTP client injection in OverpassService (following RoutingService pattern)
- DI constructors + forTesting() factories on NodeDataManager and
  NodeSpatialCache
- splitBounds made static + @VisibleForTesting for direct unit testing

18 new tests covering: splitBounds geometry, getSlotCount/waitForSlot
parsing, fetchWithSplitting (happy path, split, max depth, rate limit
retry + cap), partial/total quadrant failure, recursive splitting, and
semaphore init deduplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the user pans/zooms mid-fetch, queued sub-requests now bail out
instead of blocking the semaphore. Each getNodesFor() call increments
_fetchGeneration; fetchWithSplitting checks _isStale(generation) at
6 cooperative checkpoints (before semaphore, inside lambda, before
splitting, before/after waitForSlot, top of _fetchSplitAreas).

Null generation (offline download, existing tests) is never stale,
so there is zero regression risk for non-production-path callers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each completed quadrant now triggers notifyListeners() immediately so the
map renders nodes as they arrive, rather than waiting for all quadrants
to finish. NodeProviderWithCache listens to NodeDataManager directly,
replacing the manual notifyListeners() after getNodesFor() returns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 26, 2026 16:55
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enables progressive map updates while fetching Overpass quadrants in parallel by propagating mid-fetch NodeDataManager cache updates through NodeProviderWithCache, and adds unit tests for the per-quadrant notification behavior.

Changes:

  • Emit NodeDataManager.notifyListeners() as each non-empty quadrant result is cached, enabling progressive rendering.
  • Have NodeProviderWithCache subscribe to NodeDataManager updates (and remove the manual post-fetch provider notification).
  • Add tests (and fake_async) to verify per-quadrant notification behavior and Overpass status polling behavior.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/services/node_data_manager.dart Adds per-quadrant notifyListeners() after caching and threads stale-generation + semaphore logic through split fetches.
lib/widgets/node_provider_with_cache.dart Subscribes provider to NodeDataManager updates; removes redundant provider-side notify after fetch.
lib/services/overpass_service.dart Adds /api/status slot-count parsing and polling wait helper for rate limiting.
lib/services/node_spatial_cache.dart Adds a forTesting constructor to allow non-singleton cache instances in tests.
lib/services/map_data_provider.dart Documents that offline downloads pass null generation (not stale-cancelled) and may occupy semaphore slots.
test/services/node_data_manager_test.dart Adds extensive tests including progressive rendering notification counts.
pubspec.yaml Adds fake_async as a direct dev dependency.
pubspec.lock Updates dependency metadata for fake_async to direct dev.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dougborg
Copy link
Collaborator Author

This one could merge in place of #114 - it has all those commits too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants