Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 3be547a

Browse files
Add OSM cache and make smaller queries to OSM when possible
1 parent ed71478 commit 3be547a

6 files changed

Lines changed: 282 additions & 10 deletions

File tree

Alidade.Osm/AlidadeOsmModule.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class AlidadeOsmModule : Module
1414
protected override void Load(ContainerBuilder builder)
1515
{
1616
builder.RegisterType<EditBufferStateService>().AsSelf().SingleInstance();
17+
builder.RegisterType<OsmCacheService>().As<IOsmCacheService>().SingleInstance();
1718

1819
builder.RegisterType<PresetService>().AsSelf().InstancePerLifetimeScope();
1920
builder.RegisterType<NsiService>().AsSelf().InstancePerLifetimeScope();

Alidade.Osm/Models/CacheBounds.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace Alidade.Osm.Models;
2+
3+
/// <summary>
4+
/// An axis-aligned geographic bounding box used by <see cref="IOsmCacheService"/>.
5+
/// Equivalent to <c>Alidade.Map.Models.MapBounds</c> without the zoom level, since the
6+
/// cache layer operates purely on geographic extent.
7+
/// </summary>
8+
/// <param name="West">Western longitude bound in decimal degrees.</param>
9+
/// <param name="South">Southern latitude bound in decimal degrees.</param>
10+
/// <param name="East">Eastern longitude bound in decimal degrees.</param>
11+
/// <param name="North">Northern latitude bound in decimal degrees.</param>
12+
public record CacheBounds(double West, double South, double East, double North);

Alidade.Osm/Models/OsmCacheData.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Alidade.Osm.Models;
2+
3+
/// <summary>
4+
/// The three typed OSM element collections stored in and returned by
5+
/// <see cref="IOsmCacheService"/>. Contains only data fetched
6+
/// from the OSM API this session — never locally-edited elements.
7+
/// </summary>
8+
/// <param name="Nodes">OSM nodes included in the cached area.</param>
9+
/// <param name="Ways">OSM ways included in the cached area.</param>
10+
/// <param name="Relations">OSM relations included in the cached area.</param>
11+
public record OsmCacheData(IReadOnlyList<OsmNode> Nodes, IReadOnlyList<OsmWay> Ways, IReadOnlyList<OsmRelation> Relations);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace Alidade.Osm.Services;
2+
3+
/// <summary>
4+
/// Session-scoped cache for OSM data fetched from the API.
5+
///
6+
/// Tracks the geographic area that has already been fetched so that
7+
/// viewport changes only trigger API calls for the uncached portions.
8+
/// </summary>
9+
public interface IOsmCacheService
10+
{
11+
/// <summary>
12+
/// Returns the sub-bboxes of <paramref name="request"/> whose data is not yet in
13+
/// the cache, decomposed into axis-aligned rectangles. Returns an empty list when
14+
/// the entire requested area is already cached.
15+
/// </summary>
16+
/// <param name="request">The geographic bounding box to test against the cache.</param>
17+
/// <returns>
18+
/// A list of bboxes whose union covers the uncached portion of <paramref name="request"/>,
19+
/// or an empty list if the full area is already cached.
20+
/// </returns>
21+
public List<CacheBounds> GetGeometryMissBboxes(CacheBounds request);
22+
23+
/// <summary>
24+
/// Unions <paramref name="bounds"/> into the cached area geometry and merges the
25+
/// supplied OSM elements into the cache store.
26+
/// </summary>
27+
/// <param name="bounds">The geographic bounding box that was just fetched.</param>
28+
/// <param name="data">The OSM elements returned for that bbox.</param>
29+
public void AddToCache(CacheBounds bounds, OsmCacheData data);
30+
31+
/// <summary>
32+
/// Returns all cached OSM elements that fall within <paramref name="bounds"/>.
33+
/// Ways whose nodes extend outside <paramref name="bounds"/> are included along
34+
/// with their out-of-bbox member nodes, matching the OSM API's full-way behaviour.
35+
/// </summary>
36+
/// <param name="bounds">The geographic bounding box to retrieve data for.</param>
37+
/// <returns>The cached OSM elements within <paramref name="bounds"/>.</returns>
38+
/// <exception cref="InvalidOperationException">
39+
/// Thrown when any part of <paramref name="bounds"/> lies outside the cached area.
40+
/// This is intentional during development to surface missing-cache bugs early.
41+
/// </exception>
42+
public OsmCacheData GetGeometryFromBbox(CacheBounds bounds);
43+
44+
/// <summary>
45+
/// Resets the cache to its initial empty state, discarding all cached geometry and elements.
46+
/// </summary>
47+
public void Clear();
48+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using NetTopologySuite.Geometries;
2+
3+
namespace Alidade.Osm.Services;
4+
5+
/// <inheritdoc />
6+
internal sealed class OsmCacheService : IOsmCacheService
7+
{
8+
// TODO: Replace the O(n) linear scans in GetGeometryFromBbox with three
9+
// NTS Quadtree<long> spatial indexes (one per element type) if profiling
10+
// shows the scan becoming a bottleneck during long panning sessions.
11+
// Quadtree supports incremental inserts; STRtree is bulk-load only.
12+
private Geometry? _cachedArea;
13+
private readonly Dictionary<long, OsmNode> _nodes = [];
14+
private readonly Dictionary<long, OsmWay> _ways = [];
15+
private readonly Dictionary<long, OsmRelation> _relations = [];
16+
17+
private static readonly GeometryFactory _geomFactory = new(new PrecisionModel(), 4326);
18+
19+
/// <inheritdoc />
20+
public List<CacheBounds> GetGeometryMissBboxes(CacheBounds request)
21+
{
22+
if (_cachedArea is null)
23+
{
24+
return [request];
25+
}
26+
27+
Geometry requestGeom = BoundsToGeometry(request);
28+
Geometry diff = requestGeom.Difference(_cachedArea);
29+
30+
if (diff.IsEmpty)
31+
{
32+
return [];
33+
}
34+
35+
return DecomposeToBboxes(diff);
36+
}
37+
38+
/// <inheritdoc />
39+
public void AddToCache(CacheBounds bounds, OsmCacheData data)
40+
{
41+
Geometry newGeom = BoundsToGeometry(bounds);
42+
_cachedArea = _cachedArea is null
43+
? newGeom
44+
: _cachedArea.Union(newGeom);
45+
46+
foreach (OsmNode n in data.Nodes)
47+
{
48+
_nodes[n.Id] = n;
49+
}
50+
51+
foreach (OsmWay w in data.Ways)
52+
{
53+
_ways[w.Id] = w;
54+
}
55+
56+
foreach (OsmRelation r in data.Relations)
57+
{
58+
_relations[r.Id] = r;
59+
}
60+
}
61+
62+
/// <inheritdoc />
63+
public OsmCacheData GetGeometryFromBbox(CacheBounds bounds)
64+
{
65+
Geometry requestGeom = BoundsToGeometry(bounds);
66+
67+
if (_cachedArea is null || !_cachedArea.Covers(requestGeom))
68+
{
69+
throw new InvalidOperationException(
70+
$"Requested bbox ({bounds}) includes area outside the OSM cache.");
71+
}
72+
73+
List<OsmNode> nodesInBbox = [.. _nodes.Values
74+
.Where(n => n.Lat <= bounds.North
75+
&& n.Lon <= bounds.East
76+
&& n.Lat >= bounds.South
77+
&& n.Lon >= bounds.West)];
78+
79+
HashSet<long> nodeIdsInBbox = nodesInBbox.Select(n => n.Id).ToHashSet();
80+
81+
List<OsmWay> ways = [.. _ways.Values.Where(w => w.NodeIds.Any(id => nodeIdsInBbox.Contains(id)))];
82+
83+
// Include out-of-bbox nodes referenced by included ways so that rendering
84+
// doesn't break for ways that cross the viewport boundary — the OSM API
85+
// always returns full way geometry including nodes outside the requested bbox.
86+
HashSet<long> allNodeIds = [..nodeIdsInBbox];
87+
List<OsmNode> allNodes = [..nodesInBbox];
88+
89+
foreach (OsmWay way in ways)
90+
{
91+
foreach (long nodeId in way.NodeIds)
92+
{
93+
if (allNodeIds.Add(nodeId) && _nodes.TryGetValue(nodeId, out OsmNode? extraNode))
94+
{
95+
allNodes.Add(extraNode);
96+
}
97+
}
98+
}
99+
100+
HashSet<long> wayIds = [.. ways.Select(w => w.Id)];
101+
102+
List<OsmRelation> relations = [.. _relations.Values
103+
.Where(r => r.Members.Any(m =>
104+
(m.Type == OsmElementTypes.Node && allNodeIds.Contains(m.Ref))
105+
|| (m.Type == OsmElementTypes.Way && wayIds.Contains(m.Ref)))
106+
)];
107+
108+
return new OsmCacheData(allNodes, ways, relations);
109+
}
110+
111+
/// <inheritdoc />
112+
public void Clear()
113+
{
114+
_cachedArea = null;
115+
_nodes.Clear();
116+
_ways.Clear();
117+
_relations.Clear();
118+
}
119+
120+
private static Geometry BoundsToGeometry(CacheBounds b)
121+
=> _geomFactory.ToGeometry(new Envelope(b.West, b.East, b.South, b.North));
122+
123+
private static CacheBounds EnvelopeToCacheBounds(Envelope env)
124+
=> new(env.MinX, env.MinY, env.MaxX, env.MaxY);
125+
126+
/// <summary>
127+
/// Decomposes an orthogonal difference polygon (or multipolygon) into a minimal
128+
/// set of axis-aligned bboxes using horizontal strip decomposition. Each consecutive
129+
/// pair of unique latitude values in the geometry defines a horizontal band; the
130+
/// intersection of the diff with that band yields one or more bboxes for that strip.
131+
/// An L-shaped diff (typical single-pan case) produces exactly two bboxes.
132+
/// </summary>
133+
private static List<CacheBounds> DecomposeToBboxes(Geometry diff)
134+
{
135+
double[] ys = [.. diff.Coordinates
136+
.Select(c => c.Y)
137+
.Distinct()
138+
.OrderBy(y => y)];
139+
140+
List<CacheBounds> result = [];
141+
Envelope diffEnv = diff.EnvelopeInternal;
142+
143+
for (int i = 0; i < ys.Length - 1; i++)
144+
{
145+
double yLow = ys[i];
146+
double yHigh = ys[i + 1];
147+
148+
// Horizontal slab spanning the band, extended past diff's X extent so the
149+
// intersection clips cleanly against the diff's actual X boundary.
150+
Envelope slabEnv = new(diffEnv.MinX - 1.0, diffEnv.MaxX + 1.0, yLow, yHigh);
151+
Geometry slab = _geomFactory.ToGeometry(slabEnv);
152+
Geometry band = diff.Intersection(slab);
153+
154+
if (band.IsEmpty)
155+
{
156+
continue;
157+
}
158+
159+
if (band is GeometryCollection gc)
160+
{
161+
for (int j = 0; j < gc.NumGeometries; j++)
162+
{
163+
Geometry part = gc.GetGeometryN(j);
164+
if (!part.IsEmpty)
165+
{
166+
result.Add(EnvelopeToCacheBounds(part.EnvelopeInternal));
167+
}
168+
}
169+
}
170+
else
171+
{
172+
result.Add(EnvelopeToCacheBounds(band.EnvelopeInternal));
173+
}
174+
}
175+
176+
return result;
177+
}
178+
}

Alidade/Services/EditBufferService.cs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
using System.Text.Json;
2-
using Alidade.Handlers.EditBuffer;
32
using Alidade.Map.Handlers;
4-
using Alidade.Osm.Handlers.Editing;
53
using Alidade.Osm.Models.Editing;
64
using NetTopologySuite.Features;
75

@@ -24,6 +22,7 @@ public class EditBufferService : IDisposable
2422
private readonly EditBufferStateService _editState;
2523
private readonly MapStateService _mapState;
2624
private readonly SelectionStateService _selectionState;
25+
private readonly IOsmCacheService _osmCache;
2726
private readonly ValidationService? _validation;
2827
private readonly ILogger<EditBufferService> _log;
2928

@@ -50,6 +49,7 @@ public EditBufferService(
5049
EditBufferStateService editState,
5150
MapStateService mapState,
5251
SelectionStateService selectionState,
52+
IOsmCacheService osmCache,
5353
ILogger<EditBufferService> log,
5454
ValidationService? validation = null)
5555
{
@@ -60,6 +60,7 @@ public EditBufferService(
6060
_editState = editState;
6161
_mapState = mapState;
6262
_selectionState = selectionState;
63+
_osmCache = osmCache;
6364
_validation = validation;
6465
_log = log;
6566

@@ -201,6 +202,7 @@ public void Clear()
201202
_fetchCts.Cancel();
202203
_fetchCts.Dispose();
203204
_fetchCts = new CancellationTokenSource();
205+
_osmCache.Clear();
204206
_editState.SetState(new EditBufferState());
205207

206208
MapBounds? bounds = _mapState.State.CurrentBounds;
@@ -421,20 +423,40 @@ internal async Task RunFetchBboxAsync(MapBounds bounds, IMediator mediator, Canc
421423

422424
try
423425
{
424-
FetchBboxResult? fetched = await mediator.Send(
425-
new FetchBbox.Query(bounds.West, bounds.South, bounds.East, bounds.North), ct);
426+
CacheBounds cacheBounds = new(bounds.West, bounds.South, bounds.East, bounds.North);
427+
List<CacheBounds> missBboxes = _osmCache.GetGeometryMissBboxes(cacheBounds);
428+
bool allSucceeded = true;
426429

427-
if (fetched is not null)
430+
foreach (CacheBounds miss in missBboxes)
428431
{
429-
MergeFetchedData(
430-
(IReadOnlyList<OsmNode>)fetched.Nodes,
431-
(IReadOnlyList<OsmWay>)fetched.Ways,
432-
(IReadOnlyList<OsmRelation>)fetched.Relations);
432+
FetchBboxResult? fetched = await mediator.Send(
433+
new FetchBbox.Query(miss.West, miss.South, miss.East, miss.North), ct);
434+
435+
if (fetched is not null)
436+
{
437+
_osmCache.AddToCache(miss, new OsmCacheData(
438+
(IReadOnlyList<OsmNode>)fetched.Nodes,
439+
(IReadOnlyList<OsmWay>)fetched.Ways,
440+
(IReadOnlyList<OsmRelation>)fetched.Relations));
441+
}
442+
else
443+
{
444+
allSucceeded = false;
445+
}
446+
}
447+
448+
// Merge from cache when the full viewport is available.
449+
// allSucceeded remains true when missBboxes is empty (full cache hit),
450+
// covering the case where the user navigates back to a previously visited area.
451+
if (allSucceeded)
452+
{
453+
OsmCacheData viewportData = _osmCache.GetGeometryFromBbox(cacheBounds);
454+
MergeFetchedData(viewportData.Nodes, viewportData.Ways, viewportData.Relations);
433455
}
434456
}
435457
catch (OperationCanceledException)
436458
{
437-
// Fetch cancelled by Clear() during an endpoint switch
459+
// Fetch cancelled by Clear() during an endpoint switch.
438460
}
439461
}
440462

0 commit comments

Comments
 (0)