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
70 changes: 51 additions & 19 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -140,34 +140,66 @@
animation: wiggle 2s ease-in-out infinite;
}

/* Selected marker highlight */
.selected-marker {
filter: drop-shadow(0 0 4px #00b7d2) drop-shadow(0 0 8px #00b7d2) !important;
animation: pulse-glow 2s ease-in-out infinite;
/* Selected-merchant locator pulse — the DOM overlay marker dropped on the
selected pin's geo point (design C). --bm-pulse-color is set per
selection (in +page.svelte) to PIN_FILL_REGULAR / PIN_FILL_BOOSTED so it
matches the pin; the #0e95af fallback below just mirrors PIN_FILL_REGULAR
as a last resort and is never used in practice. */
.bm-selected-pulse {
position: relative;
width: 0;
height: 0;
}

.bm-pulse-ring,
.bm-pulse-dot {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%);
border-radius: 50%;
}

.selected-marker-boosted {
filter: drop-shadow(0 0 4px #f7931a) drop-shadow(0 0 8px #f7931a) !important;
animation: pulse-glow-boosted 2s ease-in-out infinite;
.bm-pulse-ring {
border: 3px solid var(--bm-pulse-color, #0e95af);
opacity: 0;
animation: bm-pulse 1.8s ease-out infinite;
}

@keyframes pulse-glow {
0%,
100% {
filter: drop-shadow(0 0 4px #00b7d2) drop-shadow(0 0 8px #00b7d2);
.bm-pulse-ring--delay {
animation-delay: 0.9s;
}

.bm-pulse-dot {
width: 8px;
height: 8px;
background: var(--bm-pulse-color, #0e95af);
box-shadow: 0 0 0 2px #fff;
}

@keyframes bm-pulse {
0% {
width: 18px;
height: 18px;
opacity: 0.55;
}
50% {
filter: drop-shadow(0 0 6px #00b7d2) drop-shadow(0 0 12px #00b7d2);
100% {
width: 70px;
height: 70px;
opacity: 0;
}
}

@keyframes pulse-glow-boosted {
0%,
100% {
filter: drop-shadow(0 0 4px #f7931a) drop-shadow(0 0 8px #f7931a);
@media (prefers-reduced-motion: reduce) {
.bm-pulse-ring {
animation: none;
opacity: 0.4;
width: 44px;
height: 44px;
}
50% {
filter: drop-shadow(0 0 6px #f7931a) drop-shadow(0 0 12px #f7931a);

.bm-pulse-ring--delay {
display: none;
}
}
}
Expand Down
16 changes: 11 additions & 5 deletions src/lib/map/mapHash.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
// URL hash viewport sync for /map.
// URL viewport sync for /map.
//
// Format: `#zoom/lat/lng&merchant=123&view=…`. We extend it
// with optional `/bearing/pitch` segments after lng, written only when
// non-zero. Drawer params after `&` are preserved untouched so a shared
// URL combining a viewport + an open drawer round-trips.
// The hash holds ONLY the viewport: `#zoom/lat/lng`, optionally extended with
// `/bearing/pitch` segments (written only when non-zero). The merchant drawer
// params (`merchant`, `view`) live in the QUERY STRING instead — see
// $lib/merchantDrawerHash — so the server-side OG-image loader can read them
// (the hash fragment is never sent to the server). Build the canonical
// merchant deep link with buildMerchantMapHref().
//
// Any legacy `&`-suffixed hash params (the pre-#1019 `#z/lat/lng&merchant=…`
// scheme) are still preserved untouched below for backward compatibility, but
// nothing in the app reads merchant/view from the hash anymore.

export type HashCoords = {
zoom: number;
Expand Down
45 changes: 44 additions & 1 deletion src/lib/merchantDrawerHash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ vi.mock("$app/environment", () => ({
browser: true,
}));

import { parseMerchantHash, updateMerchantHash } from "./merchantDrawerHash";
import { parseHashCoords } from "./map/mapHash";
import {
buildMerchantMapHref,
parseMerchantHash,
updateMerchantHash,
} from "./merchantDrawerHash";

describe("parseMerchantHash", () => {
beforeEach(() => {
Expand Down Expand Up @@ -245,3 +250,41 @@ describe("updateMerchantHash", () => {
});
});
});

describe("buildMerchantMapHref deep-link round-trip", () => {
const setLocationFromHref = (href: string) => {
const url = new URL(href, "http://localhost");
delete (window as unknown as { location: unknown }).location;
(
window as unknown as { location: { search: string; hash: string } }
).location = { search: url.search, hash: url.hash };
};

it("produces a link parseMerchantHash and parseHashCoords can both read", () => {
setLocationFromHref(buildMerchantMapHref(1128, 32.6489863, -16.9101835));
expect(parseMerchantHash().merchantId).toBe(1128);
expect(parseHashCoords()).toMatchObject({
zoom: 18,
lat: 32.6489863,
lng: -16.9101835,
});
});

it("keeps the merchant in the query string, not the hash", () => {
expect(buildMerchantMapHref(1128, 1, 2)).toBe("/map?merchant=1128#18/1/2");
});

it("regression: the pre-#1019 hash format leaves the merchant unreadable", () => {
// `#z/lat/lng&merchant=…` leaves the query string empty, so
// parseMerchantHash returns null — the exact reason the old
// "View on main map" link selected nothing.
setLocationFromHref("/map#18/32.6489863/-16.9101835&merchant=1128");
expect(parseMerchantHash().merchantId).toBeNull();
// The viewport still parses, which is why the map opened at the right
// place but with no marker selected or drawer open.
expect(parseHashCoords()).toMatchObject({
lat: 32.6489863,
lng: -16.9101835,
});
});
});
19 changes: 19 additions & 0 deletions src/lib/merchantDrawerHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,22 @@ export function updateMerchantHash(
history.pushState(null, "", url.toString());
window.dispatchEvent(new Event(MERCHANT_URL_CHANGE_EVENT));
}

// Canonical /map deep link to a single merchant. The merchant id goes in the
// query string (read by parseMerchantHash above and by the server-side
// OG-image loader in map/+page.server.ts), while the viewport goes in the hash
// (read by parseHashCoords in $lib/map/mapHash). Both readers must agree with
// this format — the round-trip is covered in merchantDrawerHash.test.ts. Do
// NOT move the merchant into the hash: the fragment is never sent to the
// server, and parseMerchantHash only reads window.location.search.
export function buildMerchantMapHref(
merchantId: number | string,
lat: number,
lng: number,
zoom = 18,
): string {
// Encode the id so a non-numeric/crafted id can never split into extra
// query params or break the URL. Place ids are numeric today (so this
// encodes to itself), making it purely defensive.
return `/map?merchant=${encodeURIComponent(String(merchantId))}#${zoom}/${lat}/${lng}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
escapedcat marked this conversation as resolved.
Comment thread
escapedcat marked this conversation as resolved.
132 changes: 125 additions & 7 deletions src/routes/map/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import convex from "@turf/convex";
import { featureCollection, point } from "@turf/helpers";
import type { Feature, FeatureCollection, Point, Polygon } from "geojson";
import type {
FilterSpecification,
GeoJSONSource,
LngLatBounds,
MapGeoJSONFeature,
MapLayerMouseEvent,
Map as MapLibreMap,
Marker,
} from "maplibre-gl";
import { onDestroy, onMount } from "svelte";
import { get } from "svelte/store";
Expand Down Expand Up @@ -49,6 +51,8 @@ import {
ensureSpritesForPlaces,
installPlaceholderHandler,
loadSvgImage,
PIN_FILL_BOOSTED,
PIN_FILL_REGULAR,
} from "$lib/map/maplibreSprites";
import { parseLatLongQuery } from "$lib/map/queryViewport";
import {
Expand Down Expand Up @@ -170,9 +174,93 @@ let latestHullClusterId: number | null = null;
let deepLinkPanUnsub: (() => void) | null = null;
let deepLinkPanTimer: ReturnType<typeof setTimeout> | null = null;

// Zoom level that reveals a single selected merchant: just past
// CLUSTERING_DISABLED_ZOOM (17) so it declusters into its own pin (with the
// selection pulse on top) rather than being absorbed into a cluster. The
// DEFAULT_MAP_ZOOM (15) the deep-link pan used to land on still clusters.
const REVEAL_ZOOM = 17.5;

const panToPlace = (lat: number, lon: number) => {
if (!map) return;
map.easeTo({ center: [lon, lat], zoom: DEFAULT_MAP_ZOOM, duration: 300 });
map.easeTo({ center: [lon, lat], zoom: REVEAL_ZOOM, duration: 300 });
};

// Selected-merchant highlight (design "C — centered pulsing locator"): the GL
// pin scales up, and a single DOM overlay marker — a pulsing ring + a center
// dot in the pin's own hue — sits on the selected pin's geo point. The pulse
// is pure CSS (composited, honors prefers-reduced-motion), so the GL pin layer
// stays untouched and fast. `maplibre` is imported dynamically in onMount, so
// we stash the namespace here to construct the Marker reactively.
let maplibreNs: typeof import("maplibre-gl") | null = null;
let pulseMarker: Marker | null = null;
let pulsePinId: number | null = null; // merchant the pulse overlay is on

const buildPulseElement = (): HTMLDivElement => {
const el = document.createElement("div");
el.className = "bm-selected-pulse";
el.style.pointerEvents = "none";
el.innerHTML =
'<span class="bm-pulse-ring"></span>' +
'<span class="bm-pulse-ring bm-pulse-ring--delay"></span>' +
'<span class="bm-pulse-dot"></span>';
return el;
};

// 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
// 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 filter: FilterSpecification = [
"all",
["!", ["has", "point_count"]],
["==", ["get", "id"], pulsePinId],
];
visible = map.querySourceFeatures("places", { filter }).length > 0;
}
pulseMarker.getElement().style.display = visible ? "" : "none";
};

// Show / move / hide the locator pulse for the selected merchant. Needs the
// place's coordinates, so it no-ops until the place is in $placesById — a
// deep-linked merchant gets its pulse once places sync in (the reactive below
// re-runs on $places).
const syncSelectionPulse = (selectedId: number | null) => {
if (!map || !maplibreNs) return;
if (selectedId === null) {
pulseMarker?.remove();
pulsePinId = null;
return;
}
const place = get(placesById).get(selectedId);
if (!place) return; // not loaded yet
const isNewSelection = pulsePinId !== selectedId;
if (!pulseMarker) {
pulseMarker = new maplibreNs.Marker({
element: buildPulseElement(),
anchor: "center",
});
}
// Keep colour + position in sync with the place even when the selection is
// unchanged: boosting from the drawer recolours the pin (teal → orange) and
// fires the $places reactive, and the pulse must follow. setProperty,
// setLngLat and addTo are idempotent on an already-added marker, so this
// stays cheap on every $places tick. Colours come from the same source of
// truth as the GL pin sprite so the two can't desync.
const color = isBoosted(place) ? PIN_FILL_BOOSTED : PIN_FILL_REGULAR;
pulseMarker.getElement().style.setProperty("--bm-pulse-color", color);
pulseMarker.setLngLat([place.lon, place.lat]).addTo(map);
pulsePinId = selectedId;
// Reconcile cluster-based visibility only on a real selection change;
// moveend/idle handle it thereafter (avoids a querySourceFeatures per
// $places tick).
if (isNewSelection) updatePulseVisibility();
};

// Reactive zoom level for the panel — drives the "zoom in" prompt and the
Expand Down Expand Up @@ -327,7 +415,19 @@ const debouncedUpdateMerchantList = debounce(

const panToNearbyMerchant = (place: Place) => {
if (!map) return;
map.easeTo({ center: [place.lon, place.lat], duration: 300 });
// Below the clustering threshold the picked merchant may be absorbed into a
// cluster, leaving the selection pulse floating with no pin under it. Zoom
// past the threshold to reveal its individual pin (same intent as
// zoomToSearchResult); once we're already zoomed in, just pan.
if (map.getZoom() < CLUSTERING_DISABLED_ZOOM) {
map.easeTo({
center: [place.lon, place.lat],
zoom: REVEAL_ZOOM,
duration: 400,
});
} else {
map.easeTo({ center: [place.lon, place.lat], duration: 300 });
}
};

const zoomToSearchResult = (place: Place) => {
Expand Down Expand Up @@ -687,6 +787,14 @@ $: if (map && styleLoaded && $lastUpdatedPlaceId) {
lastUpdatedPlaceId.set(undefined);
}

// Position the locator pulse. Depends on $places too so a deep-linked merchant
// gets its pulse once the place data syncs in; syncSelectionPulse no-ops when
// the target is unchanged or not yet loaded.
$: if (map && styleLoaded) {
void $places;
syncSelectionPulse($merchantDrawer.merchantId);
}

// The custom sources + layers we add on top of whatever basemap is
// active. applyBasemap() carries these across a setStyle() so the pins,
// clusters, and labels survive a basemap/theme swap untouched (MapLibre's
Expand Down Expand Up @@ -769,6 +877,8 @@ onMount(async () => {
// User may have navigated away while the dynamic import was in
// flight; bail before instantiating against an unmounted container.
if (destroyed) return;
// Stash the namespace so the selection-pulse helpers can build a Marker.
maplibreNs = maplibre;

// Five basemaps (legacy parity): four vector styles + the OSM raster
// style. A stored picker choice wins; otherwise the first-visit default
Expand Down Expand Up @@ -1520,6 +1630,9 @@ onMount(async () => {
}
tilesLoading = false;
mapTilesLoaded = true;
// Clustering is settled on idle — reconcile the pulse with whether the
// selected pin is currently rendered individually or inside a cluster.
updatePulseVisibility();
});

// Persist viewport in the URL hash. Preserves any merchant=… params
Expand Down Expand Up @@ -1566,11 +1679,14 @@ onMount(async () => {
window.addEventListener(MERCHANT_URL_CHANGE_EVENT, handleHashChange);
window.addEventListener("popstate", handleHashChange);

// Deep link: URL had a merchant= param but the hash carried no
// viewport coords. Pan the camera to the merchant once it's in the
// places store. If places are still loading, subscribe and wait —
// 10s safety unsubscribe so we never leak.
if (!hashCoords) {
// Deep link: the URL selected a merchant. Reveal it as an individual
// pin — pan/zoom to it once it's in the places store — when the URL
// carried no viewport, OR carried one whose zoom is below the clustering
// threshold (where the pin would be absorbed into a cluster, leaving the
// selection pulse floating with nothing under it). A hash zoom at/above
// the threshold is honoured as-is. If places are still loading,
// subscribe and wait — 10s safety unsubscribe so we never leak.
if (!hashCoords || hashCoords.zoom < CLUSTERING_DISABLED_ZOOM) {
const { merchantId, isOpen } = parseMerchantHash();
if (isOpen && merchantId) {
const place = get(placesById).get(merchantId);
Expand Down Expand Up @@ -1634,6 +1750,8 @@ onDestroy(() => {
if (deepLinkPanTimer) clearTimeout(deepLinkPanTimer);
deepLinkPanUnsub?.();
deepLinkPanUnsub = null;
pulseMarker?.remove();
pulseMarker = null;
if (tilesLoadingTimer) clearTimeout(tilesLoadingTimer);
if (tilesLoadingFallback) clearTimeout(tilesLoadingFallback);
spiderfier?.unspiderfyAll();
Expand Down
Loading
Loading