diff --git a/src/app.css b/src/app.css index eb9e73331..84244a56b 100644 --- a/src/app.css +++ b/src/app.css @@ -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; } } } diff --git a/src/lib/map/mapHash.ts b/src/lib/map/mapHash.ts index 489ddef49..771ff5b28 100644 --- a/src/lib/map/mapHash.ts +++ b/src/lib/map/mapHash.ts @@ -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; diff --git a/src/lib/merchantDrawerHash.test.ts b/src/lib/merchantDrawerHash.test.ts index 012b0c93c..c5f9474d8 100644 --- a/src/lib/merchantDrawerHash.test.ts +++ b/src/lib/merchantDrawerHash.test.ts @@ -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(() => { @@ -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, + }); + }); +}); diff --git a/src/lib/merchantDrawerHash.ts b/src/lib/merchantDrawerHash.ts index 7eca912f7..36181e122 100644 --- a/src/lib/merchantDrawerHash.ts +++ b/src/lib/merchantDrawerHash.ts @@ -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}`; +} diff --git a/src/routes/map/+page.svelte b/src/routes/map/+page.svelte index d1991cad5..643c8ce19 100644 --- a/src/routes/map/+page.svelte +++ b/src/routes/map/+page.svelte @@ -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"; @@ -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 { @@ -170,9 +174,93 @@ let latestHullClusterId: number | null = null; let deepLinkPanUnsub: (() => void) | null = null; let deepLinkPanTimer: ReturnType | 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 = + '' + + '' + + ''; + 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 @@ -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) => { @@ -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 @@ -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 @@ -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 @@ -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); @@ -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(); diff --git a/src/routes/merchant/[id]/components/MerchantHero.svelte b/src/routes/merchant/[id]/components/MerchantHero.svelte index 2f7ec1319..b02bcae20 100644 --- a/src/routes/merchant/[id]/components/MerchantHero.svelte +++ b/src/routes/merchant/[id]/components/MerchantHero.svelte @@ -2,6 +2,7 @@ import Icon from "$components/Icon.svelte"; import SaveButton from "$components/SaveButton.svelte"; import { _ } from "$lib/i18n"; +import { buildMerchantMapHref } from "$lib/merchantDrawerHash"; import MerchantStaticMap from "./MerchantStaticMap.svelte"; @@ -44,7 +45,7 @@ export let deleted = false; {/if} {$_('info.viewOnMainMap')}