Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/lib/map/boostedClustering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";

import { BOOSTED_CLUSTERING_MAX_ZOOM } from "$lib/constants";
import type { Place } from "$lib/types";

import {
routePlacesByBoostAndZoom,
shouldClusterBoostedAtZoom,
} from "./boostedClustering";

const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
const PAST = "2000-01-01T00:00:00.000Z";

const makePlace = (id: number, overrides: Partial<Place> = {}): Place => ({
id,
lat: 0,
lon: 0,
icon: "question_mark",
...overrides,
});

const boosted = (id: number) => makePlace(id, { boosted_until: FAR_FUTURE });
const regular = (id: number) => makePlace(id);

describe("shouldClusterBoostedAtZoom", () => {
it("clusters boosted markers at/below the threshold", () => {
expect(shouldClusterBoostedAtZoom(1)).toBe(true);
expect(shouldClusterBoostedAtZoom(BOOSTED_CLUSTERING_MAX_ZOOM)).toBe(true);
});

it("keeps boosted markers standalone above the threshold", () => {
expect(shouldClusterBoostedAtZoom(BOOSTED_CLUSTERING_MAX_ZOOM + 1)).toBe(
false,
);
expect(shouldClusterBoostedAtZoom(15)).toBe(false);
});

it("keys on the integer zoom level, not the fractional value", () => {
// MapLibre zoom is fractional; clustering transitions on integer levels.
// The whole z5 band stays clustered; declustering happens at exactly z6.
expect(shouldClusterBoostedAtZoom(5.01)).toBe(true);
expect(shouldClusterBoostedAtZoom(5.99)).toBe(true);
expect(shouldClusterBoostedAtZoom(6)).toBe(false);
expect(shouldClusterBoostedAtZoom(6.01)).toBe(false);
});
});

describe("routePlacesByBoostAndZoom", () => {
const places = [regular(1), boosted(2), regular(3), boosted(4)];

it("folds boosted into the clustered source at/below the threshold", () => {
const { clustered, standalone } = routePlacesByBoostAndZoom(
places,
BOOSTED_CLUSTERING_MAX_ZOOM,
);
expect(clustered.map((p) => p.id).sort()).toEqual([1, 2, 3, 4]);
expect(standalone).toEqual([]);
});

it("keeps boosted clustered at fractional zoom within the z5 band", () => {
const { clustered, standalone } = routePlacesByBoostAndZoom(places, 5.5);
expect(clustered.map((p) => p.id).sort()).toEqual([1, 2, 3, 4]);
expect(standalone).toEqual([]);
});

it("routes boosted to the standalone source above the threshold", () => {
const { clustered, standalone } = routePlacesByBoostAndZoom(
places,
BOOSTED_CLUSTERING_MAX_ZOOM + 1,
);
expect(clustered.map((p) => p.id).sort()).toEqual([1, 3]);
expect(standalone.map((p) => p.id).sort()).toEqual([2, 4]);
});

it("treats expired boosts as regular places", () => {
const expired = makePlace(5, { boosted_until: PAST });
const { clustered, standalone } = routePlacesByBoostAndZoom([expired], 12);
expect(clustered.map((p) => p.id)).toEqual([5]);
expect(standalone).toEqual([]);
});

it("drops deleted places from both sources at every zoom", () => {
const deleted = makePlace(6, {
boosted_until: FAR_FUTURE,
deleted_at: PAST,
});
for (const zoom of [BOOSTED_CLUSTERING_MAX_ZOOM, 12]) {
const { clustered, standalone } = routePlacesByBoostAndZoom(
[deleted],
zoom,
);
expect(clustered).toEqual([]);
expect(standalone).toEqual([]);
}
});
});
46 changes: 46 additions & 0 deletions src/lib/map/boostedClustering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { BOOSTED_CLUSTERING_MAX_ZOOM } from "$lib/constants";
import type { Place } from "$lib/types";
import { isBoosted } from "$lib/utils";

// The legacy Leaflet /map clustered boosted (orange) markers together with
// regular ones at world/continent zoom and only pulled them out into a
// standalone, non-clustered layer once zoomed past BOOSTED_CLUSTERING_MAX_ZOOM
// — otherwise the zoomed-out view is littered with overlapping orange pins.
// MapLibre's `cluster` flag is a static per-source property, so we reproduce
// that zoom gate by routing boosted places into the clustered source at/below
// the threshold and into the non-clustered source above it.
//
// MapLibre zoom is fractional, but clustering (including the regular `places`
// source's clusterMaxZoom gate) transitions on integer zoom levels. Floor the
// zoom so boosted markers stay clustered across the whole integer-5 band
// (display zoom [5, 6)) and only break out at integer zoom 6 — matching the
// legacy "zoom 1-5 clustered, 6+ standalone" intent — rather than declustering
// early at 5.x while regular places are still clustered at the z5 level.
export const shouldClusterBoostedAtZoom = (zoom: number): boolean =>
Math.floor(zoom) <= BOOSTED_CLUSTERING_MAX_ZOOM;

export type BoostedPlaceRouting = {
// → the cluster:true "places" source
clustered: Place[];
// → the cluster:false "places-boosted" source
standalone: Place[];
};

// Split places into the two MapLibre sources, dropping deleted ones. Above the
// zoom threshold boosted places ride the standalone source so a paid boost
// always stands out above cluster discs; at/below it they fold into the
// clustered source so the zoomed-out view stays uncluttered.
export const routePlacesByBoostAndZoom = (
places: Place[],
zoom: number,
): BoostedPlaceRouting => {
const clusterBoosted = shouldClusterBoostedAtZoom(zoom);
const clustered: Place[] = [];
const standalone: Place[] = [];
for (const place of places) {
if (place.deleted_at) continue;
if (isBoosted(place) && !clusterBoosted) standalone.push(place);
else clustered.push(place);
}
return { clustered, standalone };
};
85 changes: 50 additions & 35 deletions src/routes/map/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import {
SUPPORT_ATTR,
styleForBasemap,
} from "$lib/map/basemaps";
import {
routePlacesByBoostAndZoom,
shouldClusterBoostedAtZoom,
} from "$lib/map/boostedClustering";
import {
type HashCoords,
parseHashCoords,
Expand Down Expand Up @@ -129,6 +133,13 @@ let lastAppliedLabelTheme: "light" | "dark" | undefined;
// search may have the same result count as the previous one.
let lastSearchModeSig = "";

// Last place list fed to the sources, kept so the boosted-clustering boundary
// re-sync (on zoom crossing BOOSTED_CLUSTERING_MAX_ZOOM) can rebuild without a
// $places change. `boostedAreClustered` mirrors the routing decision of the
// most recent sync so the moveend handler only re-syncs when it actually flips.
let lastSyncedList: Place[] = [];
let boostedAreClustered = false;

// Place-label colors. MapLibre paint expressions can't read CSS custom
// properties, so the values are inlined here.
const LABEL_PALETTE = {
Expand Down Expand Up @@ -208,15 +219,19 @@ const buildPulseElement = (): HTMLDivElement => {

// Hide the pulse when the selected merchant has been rolled into a cluster at
// the current zoom (there's no individual pin to sit on, so the pulse would
// float orphaned); show it again once the pin renders individually. The
// regular `places` source clusters; the boosted source never does. We query
// float orphaned); show it again once the pin renders individually. Regular
// places live in the clustered `places` source; boosted places do too, but
// only at/below BOOSTED_CLUSTERING_MAX_ZOOM — above it they ride the
// non-clustered boosted source and are always individually visible. We query
// the live source state rather than a zoom threshold — whether a point
// clusters depends on its neighbours, not just the zoom level.
const updatePulseVisibility = () => {
if (!map || !pulseMarker || pulsePinId === null) return;
const place = get(placesById).get(pulsePinId);
let visible = true;
if (place && !isBoosted(place)) {
const inClusteredSource =
!!place && (!isBoosted(place) || shouldClusterBoostedAtZoom(map.getZoom()));
if (inClusteredSource) {
const filter: FilterSpecification = [
"all",
["!", ["has", "point_count"]],
Expand Down Expand Up @@ -555,31 +570,15 @@ const EMPTY_COLLECTION: PlaceFeatureCollection = {
features: [],
};

// Boosted places render via a separate non-clustered source so they stay
// individually visible above cluster discs at every zoom — the legacy /map
// did this via a dedicated boostedLayer + BOOSTED_CLUSTERING_MAX_ZOOM
// constant; here we just route boosted features to a sibling source that
// has cluster:false.
const partitionPlacesForFeatures = (
list: Place[],
): {
regular: Place[];
boosted: Place[];
} => {
const regular: Place[] = [];
const boosted: Place[] = [];
for (const p of list) {
if (p.deleted_at) continue;
if (isBoosted(p)) boosted.push(p);
else regular.push(p);
}
return { regular, boosted };
};
// Boosted places ride a separate non-clustered source so they stay
// individually visible above cluster discs — but only above
// BOOSTED_CLUSTERING_MAX_ZOOM. At/below it they fold into the clustered
// `places` source (matching the legacy /map's dedicated boostedLayer +
// BOOSTED_CLUSTERING_MAX_ZOOM behavior) so the zoomed-out world view isn't
// littered with overlapping orange pins. Routing lives in
// $lib/map/boostedClustering; see syncPlacesToSource for the wiring.

const buildFeatureCollectionFor = (
list: Place[],
includeBoosted: boolean,
): PlaceFeatureCollection => {
const buildFeatureCollectionFor = (list: Place[]): PlaceFeatureCollection => {
const saved = get(savedPlaceIds);
// Snapshot the enriched cache once per build — names arrive lazily as
// the viewport-bound /v4/places/search fetch resolves.
Expand All @@ -605,7 +604,9 @@ const buildFeatureCollectionFor = (
geometry: { type: "Point", coordinates: [p.lon, p.lat] },
properties: {
id: p.id,
boosted: includeBoosted,
// Per-place so a boosted marker folded into the clustered source at
// low zoom still renders its orange pin when it sits unclustered.
boosted: !!isBoosted(p),
icon: p.icon ?? "question_mark",
comments: p.comments ?? 0,
saved: saved.has(p.id),
Expand Down Expand Up @@ -700,9 +701,14 @@ const syncPlacesToSource = (list: Place[]) => {
| GeoJSONSource
| undefined;
if (!source || !boostedSource) return;
const { regular, boosted } = partitionPlacesForFeatures(list);
source.setData(buildFeatureCollectionFor(regular, false));
boostedSource.setData(buildFeatureCollectionFor(boosted, true));
lastSyncedList = list;
boostedAreClustered = shouldClusterBoostedAtZoom(map.getZoom());
const { clustered, standalone } = routePlacesByBoostAndZoom(
list,
map.getZoom(),
);
source.setData(buildFeatureCollectionFor(clustered));
boostedSource.setData(buildFeatureCollectionFor(standalone));
ensureSpritesForPlaces(map, list);
if (list.length > 0) elementsLoaded = true;
// E2E test hook: Playwright can't probe WebGL canvas pins like it
Expand Down Expand Up @@ -1083,10 +1089,13 @@ onMount(async () => {
clusterMaxZoom: CLUSTERING_DISABLED_ZOOM - 1,
});

// Separate non-clustered source for boosted places. Routing boosted
// features here keeps them visually prominent above cluster discs at
// every zoom — they never get absorbed into a cluster icon. Matches
// the legacy /map's dedicated boostedLayer behavior.
// Separate non-clustered source for boosted places. Above
// BOOSTED_CLUSTERING_MAX_ZOOM, syncPlacesToSource routes boosted
// features here so they stay visually prominent above cluster discs and
// never get absorbed into a cluster icon. At/below that zoom they're
// routed into the clustered `places` source instead (see
// $lib/map/boostedClustering) — the MapLibre analogue of the legacy
// /map's dedicated boostedLayer + BOOSTED_CLUSTERING_MAX_ZOOM swap.
map.addSource("places-boosted", {
type: "geojson",
data: EMPTY_COLLECTION,
Expand Down Expand Up @@ -1605,6 +1614,12 @@ onMount(async () => {
const c = map.getCenter();
currentLat = c.lat;
currentLon = c.lng;
// Re-route boosted places when zoom crosses BOOSTED_CLUSTERING_MAX_ZOOM
// — the MapLibre analogue of the legacy boostedLayer swap. Only re-syncs
// on an actual flip, so steady-state pans stay cheap.
if (shouldClusterBoostedAtZoom(currentZoom) !== boostedAreClustered) {
syncPlacesToSource(lastSyncedList);
}
debouncedUpdateMerchantList();
});

Expand Down
Loading