Table of Contents

Class MarkerClusterer

Namespace
LansMap.Core.Markers
public sealed class MarkerClusterer
Inheritance
object
MarkerClusterer

Properties

LastBuildMembers

The slot ids of every candidate this instance's most recent Build call considered, grouped contiguously by cluster: a given cluster's members are LastBuildMembers[c.MemberStart .. c.MemberStart + c.MemberCount). Valid until the next Build call on this same instance.

public IReadOnlyList<int> LastBuildMembers { get; }

Property Value

IReadOnlyList<int>

Methods

Build(MarkerStore, double, int, double, List<MarkerCluster>)

public void Build(MarkerStore store, double zoom, int tileSizePx, double clusterRadiusPx, List<MarkerCluster> results)

Parameters

store MarkerStore
zoom double
tileSizePx int
clusterRadiusPx double
results List<MarkerCluster>

Build(MarkerStore, double, int, double, List<MarkerCluster>, in MercatorBounds)

Viewport-bounded variant: only markers whose padded position can overlap bounds enter the candidate pool.

public void Build(MarkerStore store, double zoom, int tileSizePx, double clusterRadiusPx, List<MarkerCluster> results, in MercatorBounds bounds)

Parameters

store MarkerStore
zoom double
tileSizePx int
clusterRadiusPx double
results List<MarkerCluster>
bounds MercatorBounds

Remarks

Unlike the fixed-grid algorithm this replaced, agreement with the unbounded overload is no longer exact for every candidate inside bounds. The old grid decided a marker's cluster purely from that marker's own coordinates, so one radius of padding was provably enough: a cell touching the bounds always kept every one of its members. Greedy distance clustering decides a candidate's cluster from the current mean of whichever cluster is nearest when that candidate is visited, and a cluster's mean can have drifted through a chain of earlier merges - in principle arbitrarily far from any single member, including ones outside the padded region this overload never sees. In practice, real marker distributions do not chain that far relative to one radius of padding, and MarkerClusterController.RebuildClusters pads by a further 3x3 viewports on top of this overload's own one-radius pad before ever calling it, so the gap has not been observed to reach a screen in practice - but it is not proven closed the way the old grid's was, and a test asserting byte-identical clusters at the padding boundary is asserting a property this algorithm does not have. [tried, not proven: MarkerClustererSpatialIndexTests exercises this overload against the index-backed one under the same bounds and stays exact, because both sides see an identical candidate set - the gap above is specifically between this overload and the UNBOUNDED one, which sees strictly more.]

Build(MarkerStore, double, int, double, List<MarkerCluster>, in MercatorBounds, MarkerSpatialIndex, List<int>)

Same contract as the bounds-only overload, but candidate markers come from index instead of a linear store scan

  • only cells the padded bounds touch are visited. index must already hold every live marker in store; this method only reads it (see MarkerPicker's matching overload for the same caller-keeps-it-synced contract). candidateScratch is caller-owned and reused (Query clears it), so this overload allocates nothing. index null falls back to the bounds-only overload above. Collects the identical candidate set the bounds-only overload would for the same bounds, so - unlike the bounds-only-vs-unbounded gap documented there - this overload agrees with it exactly.
public void Build(MarkerStore store, double zoom, int tileSizePx, double clusterRadiusPx, List<MarkerCluster> results, in MercatorBounds bounds, MarkerSpatialIndex index, List<int> candidateScratch)

Parameters

store MarkerStore
zoom double
tileSizePx int
clusterRadiusPx double
results List<MarkerCluster>
bounds MercatorBounds
index MarkerSpatialIndex
candidateScratch List<int>

Remarks

Unlike TryPick(MarkerStore, MapViewTransform, ScreenPoint, double, MarkerSpatialIndex, List<int>, out int, out double), this method's brute-force bounds test applies to EVERY marker, altitude included - there is no ground-plane exemption here, since a cluster's screen position already comes from its stored ground mean, not a projection. Query(in MercatorBounds, List<int>) returns altitude markers unconditionally regardless of bounds, so the altitude candidates it hands back still need the same ContainsPoint filter the ground candidates get for free from the query rect itself, or this overload would keep out-of-view raised markers the brute-force path would have dropped.

BuildFromSeeds(List<ClusterSeed>, double, int, double, List<MarkerCluster>)

Clusters an arbitrary list of seeds, letting a caller cluster something other than a plain marker store - most usefully, one clustering level's own output, so a whole zoom hierarchy can be folded level by level instead of re-reading every marker at each level.

public void BuildFromSeeds(List<ClusterSeed> seeds, double zoom, int tileSizePx, double clusterRadiusPx, List<MarkerCluster> results)

Parameters

seeds List<ClusterSeed>

Inputs to cluster, visited in the order given. Caller-owned and only read. The order decides the result, so a caller that needs two builds of the same data to agree must supply the same order both times (see the remarks).

zoom double

Map zoom; quantized with QuantizeZoom(double).

tileSizePx int

Tile edge length in screen px.

clusterRadiusPx double

Merge radius in screen px at the quantized zoom.

results List<MarkerCluster>

Caller-owned output list; cleared first.

Remarks

A seed whose Count is not at least 1 stands for no markers and is skipped: it joins no cluster, founds none, and contributes no membership entry. Skipping rather than admitting it keeps every cluster's marker total exact and its centroid finite, which admitting a zero count would not - a cluster whose seeds summed to zero markers has no mean position to report.

Each cluster's Count is the total markers its seeds stand for, so counts are conserved through any depth of folding, while its MemberStart / MemberCount range slices LastBuildMembers by seed, holding one SourceId per seed that joined. Those two numbers are equal only when every seed stands for one marker, which is what the Build overloads feed in.

Clustering is sequential: a seed joins the nearest cluster already placed within the merge radius, or founds a new one, so which cluster a seed lands in depends on what exists when it is visited. This method does not sort - it deliberately preserves the caller's order, since a folding caller's natural order (the previous level's cluster order) is already deterministic and re-sorting it would discard that. A caller building its own seed list from unordered data owes it a stable order, and must keep that order in an ordered list rather than recovering it from a hash container's iteration order.

QuantizeZoom(double)

public static double QuantizeZoom(double zoom)

Parameters

zoom double

Returns

double