- {#if !mapLoaded}
+ {#if webglUnsupported}
+
+ {:else if !mapLoaded}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 39d362c7f..a4bbd91cb 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -11,15 +11,11 @@ type EventName =
| "search_input_focus"
| "category_filter"
| "boost_layer_toggle"
- | "nearby_button_click"
| "worldwide_mode_click"
| "nearby_mode_click"
| "home_button_click"
| "show_all_on_map_click"
| "merchant_list_item_click"
- | "zoom_in_click"
- | "zoom_out_click"
- | "fullscreen_click"
| "locate_click"
| "add_location_click"
| "community_map_click"
diff --git a/src/lib/i18n/locales/bg.json b/src/lib/i18n/locales/bg.json
index b813ae761..26982a995 100644
--- a/src/lib/i18n/locales/bg.json
+++ b/src/lib/i18n/locales/bg.json
@@ -49,19 +49,16 @@
"submitAnother": "Изпрати друго {type}"
},
"mapControls": {
- "fullScreen": "Цял екран",
"goToHome": "Към началната страница",
"addLocation": "Добави място",
"communityMap": "Карта на общностите",
"merchantMap": "Карта на търговците",
"dataRefreshAvailable": "Налични нови данни",
- "support": "Подкрепи",
- "supportWithSats": "Подкрепи с сатошита",
"zoomIn": "Увеличете",
"zoomOut": "Намалете",
+ "basemapTitle": "Базова карта",
"locate": "Покажи къде съм",
"boostAlt": "Подсили",
- "fullScreenAlt": "Цял екран",
"locateAlt": "Покажи къде съм",
"goToHomeAlt": "Към началната страница",
"addLocationAlt": "Добави място",
@@ -298,10 +295,8 @@
"loadFailed": "Неуспешно зареждане на търговците наоколо",
"locationSearch": "Не може да се търсят места, моля опитайте отново или се свържете с BTC Map.",
"mapView": "Не може да се зададе изглед на картата с предоставените координати, моля опитайте отново или се свържете с BTC Map.",
- "mapViewCachedCoords": "Не може да се зададе изглед на картата с кешираните координати, моля опитайте отново или се свържете с BTC Map.",
"merchantDetailsLoadError": "Грешка при зареждане на детайлите за търговеца. Моля, опитайте отново.",
"boostLoadError": "Неуспешно зареждане на информация за подсилване. Моля, опитайте отново.",
- "fullscreenError": "Грешка при опит за активиране на режим на цял екран: {message} ({name})",
"noLocationSelected": "Моля, изберете място...",
"noPaymentMethod": "Моля, изберете поне един метод на плащане...",
"notFound": "Търговецът не е намерен",
@@ -662,11 +657,8 @@
"loadingTiles": "Зареждане на плочките...",
"finalizing": "Финализиране...",
"initializing": "Инициализация...",
- "initializingMarkers": "Инициализация на маркерите...",
"loading": "Зареждане...",
"loadingMap": "Зареждане на картата...",
- "loadingPlaces": "Зареждане на местата...",
- "loadingPlacesInView": "Зареждане на местата в изгледа...",
"merging": "Обединяване на актуализациите...",
"preparing": "Подготовка...",
"processing": "Обработка на данните..."
diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json
index 14bec4c52..d4ed5fa6e 100644
--- a/src/lib/i18n/locales/de.json
+++ b/src/lib/i18n/locales/de.json
@@ -75,19 +75,16 @@
"noMatches": "Keine Ergebnisse in dieser Kategorie"
},
"mapControls": {
- "fullScreen": "Vollbild",
"goToHome": "Zur Startseite gehen",
"addLocation": "Ort hinzufügen",
"communityMap": "Communitykarte",
"merchantMap": "Händlerkarte",
"dataRefreshAvailable": "Datenaktualisierung verfügbar",
- "support": "Unterstützung",
- "supportWithSats": "Mit Sats unterstützen",
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
+ "basemapTitle": "Hintergrundkarte",
"locate": "Zeige mir meinen Standort",
"boostAlt": "Boost",
- "fullScreenAlt": "Vollbild",
"locateAlt": "Zeige mir meinen Standort",
"goToHomeAlt": "Zur Startseite gehen",
"addLocationAlt": "Ort hinzufügen",
@@ -212,10 +209,7 @@
"status": {
"loading": "Laden...",
"loadingMap": "Karte laden...",
- "loadingPlaces": "Orte laden...",
"initializing": "Initialisieren...",
- "initializingMarkers": "Marker initialisieren...",
- "loadingPlacesInView": "Orte im Bereich laden...",
"downloading": "Ortsdaten herunterladen...",
"processing": "Ortsdaten verarbeiten...",
"complete": "Fertig!",
@@ -275,10 +269,8 @@
"formSubmission": "Formularübermittlung fehlgeschlagen, bitte versuche es erneut oder kontaktiere BTC Map.",
"locationSearch": "Ortssuche fehlgeschlagen, bitte versuche es erneut oder kontaktiere BTC Map.",
"mapView": "Kartenansicht konnte nicht auf die angegebenen Koordinaten gesetzt werden, bitte versuche es erneut oder kontaktiere BTC Map.",
- "mapViewCachedCoords": "Kartenansicht konnte nicht auf zwischengespeicherte Koordinaten gesetzt werden, bitte versuche es erneut oder kontaktiere BTC Map.",
"merchantDetailsLoadError": "Fehler beim Laden der Händlerdetails. Bitte versuche es erneut.",
- "boostLoadError": "Boost-Informationen konnten nicht geladen werden. Bitte versuche es erneut.",
- "fullscreenError": "Fehler beim Versuch, den Vollbildmodus zu aktivieren: {message} ({name})"
+ "boostLoadError": "Boost-Informationen konnten nicht geladen werden. Bitte versuche es erneut."
},
"success": {
"locationSelected": "Ort ausgewählt!",
diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json
index 92fc0f7c5..a34917c17 100644
--- a/src/lib/i18n/locales/en.json
+++ b/src/lib/i18n/locales/en.json
@@ -150,7 +150,6 @@
"noMatches": "No results in this category"
},
"mapControls": {
- "fullScreen": "Full screen",
"goToHome": "Go to home page",
"addLocation": "Add location",
"communityMap": "Community map",
@@ -158,13 +157,11 @@
"account": "Account",
"login": "Log in",
"dataRefreshAvailable": "Data refresh available",
- "support": "Support",
- "supportWithSats": "Support with sats",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
+ "basemapTitle": "Basemap",
"locate": "Show me where I am",
"boostAlt": "Boost",
- "fullScreenAlt": "Full screen",
"locateAlt": "Show me where I am",
"goToHomeAlt": "Go to home page",
"addLocationAlt": "Add location",
@@ -299,10 +296,7 @@
"status": {
"loading": "Loading...",
"loadingMap": "Loading map...",
- "loadingPlaces": "Loading places...",
"initializing": "Initializing...",
- "initializingMarkers": "Initializing markers...",
- "loadingPlacesInView": "Loading places in view...",
"downloading": "Downloading places data...",
"processing": "Processing places data...",
"complete": "Complete!",
@@ -363,10 +357,8 @@
"formSubmission": "Form submission failed, please try again or contact BTC Map.",
"locationSearch": "Could not search for locations, please try again or contact BTC Map.",
"mapView": "Could not set map view to provided coordinates, please try again or contact BTC Map.",
- "mapViewCachedCoords": "Could not set map view to cached coords, please try again or contact BTC Map.",
"merchantDetailsLoadError": "Error loading merchant details. Please try again.",
- "boostLoadError": "Failed to load boost information. Please try again.",
- "fullscreenError": "Error attempting to enable fullscreen mode: {message} ({name})"
+ "boostLoadError": "Failed to load boost information. Please try again."
},
"success": {
"locationSelected": "Location selected!",
diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json
index c2d105224..d06580dc9 100644
--- a/src/lib/i18n/locales/es.json
+++ b/src/lib/i18n/locales/es.json
@@ -75,19 +75,16 @@
"noMatches": "Sin resultados en esta categoría"
},
"mapControls": {
- "fullScreen": "Pantalla completa",
"goToHome": "Ir a la página de inicio",
"addLocation": "Agregar ubicación",
"communityMap": "Mapa de comunidades",
"merchantMap": "Mapa de comercios",
"dataRefreshAvailable": "Actualización de datos disponible",
- "support": "Apoyar",
- "supportWithSats": "Apoyar con sats",
"zoomIn": "Acercar",
"zoomOut": "Alejar",
+ "basemapTitle": "Mapa base",
"locate": "Mostrar mi ubicación",
"boostAlt": "Impulsar",
- "fullScreenAlt": "Pantalla completa",
"locateAlt": "Mostrar mi ubicación",
"goToHomeAlt": "Ir a la página de inicio",
"addLocationAlt": "Agregar ubicación",
@@ -212,10 +209,7 @@
"status": {
"loading": "Cargando...",
"loadingMap": "Cargando mapa...",
- "loadingPlaces": "Cargando lugares...",
"initializing": "Inicializando...",
- "initializingMarkers": "Inicializando marcadores...",
- "loadingPlacesInView": "Cargando lugares en la vista...",
"downloading": "Descargando datos de lugares...",
"processing": "Procesando datos de lugares...",
"complete": "¡Completo!",
@@ -275,10 +269,8 @@
"formSubmission": "Error al enviar el formulario, por favor inténtalo de nuevo o contacta a BTC Map.",
"locationSearch": "No se pudo buscar ubicaciones, por favor inténtalo de nuevo o contacta a BTC Map.",
"mapView": "No se pudo establecer la vista del mapa en las coordenadas proporcionadas, por favor inténtalo de nuevo o contacta a BTC Map.",
- "mapViewCachedCoords": "No se pudo establecer la vista del mapa en las coordenadas en caché, por favor inténtalo de nuevo o contacta a BTC Map.",
"merchantDetailsLoadError": "Error al cargar los detalles del comercio. Por favor, inténtalo de nuevo.",
- "boostLoadError": "Error al cargar la información del impulso. Por favor, inténtalo de nuevo.",
- "fullscreenError": "Error al intentar activar el modo de pantalla completa: {message} ({name})"
+ "boostLoadError": "Error al cargar la información del impulso. Por favor, inténtalo de nuevo."
},
"success": {
"locationSelected": "¡Ubicación seleccionada!",
diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json
index 8554c483d..2b26cf4c6 100644
--- a/src/lib/i18n/locales/fr.json
+++ b/src/lib/i18n/locales/fr.json
@@ -75,19 +75,16 @@
"noMatches": "Aucun résultat dans cette catégorie"
},
"mapControls": {
- "fullScreen": "Plein écran",
"goToHome": "Aller à la page d'accueil",
"addLocation": "Ajouter un lieu",
"communityMap": "Carte des communautés",
"merchantMap": "Carte des commerces",
"dataRefreshAvailable": "Mise à jour des données disponible",
- "support": "Soutenir",
- "supportWithSats": "Soutenir avec des sats",
"zoomIn": "Zoomer",
"zoomOut": "Dézoomer",
+ "basemapTitle": "Fond de carte",
"locate": "Afficher ma position",
"boostAlt": "Boost",
- "fullScreenAlt": "Plein écran",
"locateAlt": "Afficher ma position",
"goToHomeAlt": "Aller à la page d'accueil",
"addLocationAlt": "Ajouter un lieu",
@@ -212,10 +209,7 @@
"status": {
"loading": "Chargement...",
"loadingMap": "Chargement de la carte...",
- "loadingPlaces": "Chargement des lieux...",
"initializing": "Initialisation...",
- "initializingMarkers": "Initialisation des marqueurs...",
- "loadingPlacesInView": "Chargement des lieux visibles...",
"downloading": "Téléchargement des données...",
"processing": "Traitement des données...",
"complete": "Terminé !",
@@ -275,10 +269,8 @@
"formSubmission": "Échec de l'envoi du formulaire, veuillez réessayer ou contacter BTC Map.",
"locationSearch": "Impossible de rechercher des lieux, veuillez réessayer ou contacter BTC Map.",
"mapView": "Impossible d'afficher la carte aux coordonnées indiquées, veuillez réessayer ou contacter BTC Map.",
- "mapViewCachedCoords": "Impossible d'afficher la carte aux coordonnées en cache, veuillez réessayer ou contacter BTC Map.",
"merchantDetailsLoadError": "Erreur lors du chargement des détails du commerce. Veuillez réessayer.",
- "boostLoadError": "Échec du chargement des informations de boost. Veuillez réessayer.",
- "fullscreenError": "Erreur lors du passage en plein écran : {message} ({name})"
+ "boostLoadError": "Échec du chargement des informations de boost. Veuillez réessayer."
},
"success": {
"locationSelected": "Lieu sélectionné !",
diff --git a/src/lib/i18n/locales/nl.json b/src/lib/i18n/locales/nl.json
index 8ef1de12e..722c00a9b 100644
--- a/src/lib/i18n/locales/nl.json
+++ b/src/lib/i18n/locales/nl.json
@@ -150,7 +150,6 @@
"noMatches": "Geen resultaten in deze categorie"
},
"mapControls": {
- "fullScreen": "Schermvullend",
"goToHome": "Ga naar de startpagina",
"addLocation": "Locatie toevoegen",
"communityMap": "Communitykaart",
@@ -158,13 +157,11 @@
"account": "Account",
"login": "Aanmelden",
"dataRefreshAvailable": "Nieuwe gegevens beschikbaar",
- "support": "Support",
- "supportWithSats": "Ondersteun met sats",
"zoomIn": "Inzoomen",
"zoomOut": "Uitzoomen",
+ "basemapTitle": "Achtergrondkaart",
"locate": "Laat me zien waar ik ben",
"boostAlt": "Boost",
- "fullScreenAlt": "Schermvullend",
"locateAlt": "Laat me zien waar ik ben",
"goToHomeAlt": "Ga naar de startpagina",
"addLocationAlt": "Locatie toevoegen",
@@ -299,10 +296,7 @@
"status": {
"loading": "Laden...",
"loadingMap": "Kaart laden...",
- "loadingPlaces": "Locaties laden...",
"initializing": "Initialiseren...",
- "initializingMarkers": "Markeringen initialiseren...",
- "loadingPlacesInView": "Zichtbare locaties worden geladen...",
"downloading": "Locatiegegevens downloaden...",
"processing": "Locatiegegevens verwerken...",
"complete": "Compleet!",
@@ -363,10 +357,8 @@
"formSubmission": "Het verzenden van het formulier is mislukt. Probeer het nog eens of neem contact op met BTC Map.",
"locationSearch": "Het zoeken naar locaties is mislukt. Probeer het opnieuw of neem contact op met BTC Map.",
"mapView": "Kan de kaartweergave niet instellen op de opgegeven coördinaten, probeer het opnieuw of neem contact op met BTC Map.",
- "mapViewCachedCoords": "Kan de kaartweergave niet instellen op gecachte coördinaten, probeer het opnieuw of neem contact op met BTC Map.",
"merchantDetailsLoadError": "Fout bij het laden van de ondernemergegevens. Probeer het opnieuw.",
- "boostLoadError": "Laden van boostinformatie mislukt. Probeer het opnieuw.",
- "fullscreenError": "Fout bij het activeren van de schermvullende weergave: {message} ({name})"
+ "boostLoadError": "Laden van boostinformatie mislukt. Probeer het opnieuw."
},
"success": {
"locationSelected": "Locatie geselecteerd!",
diff --git a/src/lib/i18n/locales/pt-BR.json b/src/lib/i18n/locales/pt-BR.json
index 60a35ec3e..d6550e125 100644
--- a/src/lib/i18n/locales/pt-BR.json
+++ b/src/lib/i18n/locales/pt-BR.json
@@ -75,19 +75,16 @@
"noMatches": "Nenhum resultado nesta categoria"
},
"mapControls": {
- "fullScreen": "Tela cheia",
"goToHome": "Ir para a página inicial",
"addLocation": "Adicionar local",
"communityMap": "Mapa da comunidade",
"merchantMap": "Mapa de comerciantes",
"dataRefreshAvailable": "Atualização de dados disponível",
- "support": "Apoiar",
- "supportWithSats": "Apoiar com sats",
"zoomIn": "Aumentar zoom",
"zoomOut": "Diminuir zoom",
+ "basemapTitle": "Mapa base",
"locate": "Mostrar onde estou",
"boostAlt": "Impulsionar",
- "fullScreenAlt": "Tela cheia",
"locateAlt": "Mostrar onde estou",
"goToHomeAlt": "Ir para a página inicial",
"addLocationAlt": "Adicionar local",
@@ -208,10 +205,7 @@
"status": {
"loading": "Carregando...",
"loadingMap": "Carregando mapa...",
- "loadingPlaces": "Carregando locais...",
"initializing": "Inicializando...",
- "initializingMarkers": "Inicializando marcadores...",
- "loadingPlacesInView": "Carregando locais na visualização...",
"downloading": "Baixando dados de locais...",
"processing": "Processando dados de locais...",
"complete": "Completo!",
@@ -270,10 +264,8 @@
"noPaymentMethod": "Por favor, selecione pelo menos um método de pagamento...",
"formSubmission": "Falha no envio do formulário. Tente novamente ou entre em contato com o BTC Map.",
"mapView": "Não foi possível definir a visualização do mapa para as coordenadas fornecidas. Tente novamente ou entre em contato com o BTC Map.",
- "mapViewCachedCoords": "Não foi possível definir a visualização do mapa para as coordenadas em cache. Tente novamente ou entre em contato com o BTC Map.",
"merchantDetailsLoadError": "Erro ao carregar detalhes do comerciante. Tente novamente.",
- "boostLoadError": "Falha ao carregar informações de boost. Tente novamente.",
- "fullscreenError": "Erro ao tentar ativar o modo tela cheia: {message} ({name})"
+ "boostLoadError": "Falha ao carregar informações de boost. Tente novamente."
},
"aria": {
"logoAlt": "Logo do BTC Map",
diff --git a/src/lib/i18n/locales/ru.json b/src/lib/i18n/locales/ru.json
index a0d61c2ac..b804d9468 100644
--- a/src/lib/i18n/locales/ru.json
+++ b/src/lib/i18n/locales/ru.json
@@ -75,19 +75,16 @@
"noMatches": "Нет результатов в этой категории"
},
"mapControls": {
- "fullScreen": "Полный экран",
"goToHome": "На главную страницу",
"addLocation": "Добавить место",
"communityMap": "Карта сообщества",
"merchantMap": "Карта торговцев",
"dataRefreshAvailable": "Доступно обновление данных",
- "support": "Поддержать",
- "supportWithSats": "Поддержать сатоши",
"zoomIn": "Приблизить",
"zoomOut": "Отдалить",
+ "basemapTitle": "Базовая карта",
"locate": "Показать моё местоположение",
"boostAlt": "Буст",
- "fullScreenAlt": "Полный экран",
"locateAlt": "Показать моё местоположение",
"goToHomeAlt": "На главную страницу",
"addLocationAlt": "Добавить место",
@@ -212,10 +209,7 @@
"status": {
"loading": "Загрузка...",
"loadingMap": "Загрузка карты...",
- "loadingPlaces": "Загрузка мест...",
"initializing": "Инициализация...",
- "initializingMarkers": "Инициализация меток...",
- "loadingPlacesInView": "Загрузка мест в поле зрения...",
"downloading": "Загрузка данных мест...",
"processing": "Обработка данных мест...",
"complete": "Готово!",
@@ -275,10 +269,8 @@
"formSubmission": "Ошибка отправки формы, пожалуйста, попробуйте снова или свяжитесь с BTC Map.",
"locationSearch": "Не удалось выполнить поиск местоположений, пожалуйста, попробуйте снова или свяжитесь с BTC Map.",
"mapView": "Не удалось установить вид карты на указанные координаты, пожалуйста, попробуйте снова или свяжитесь с BTC Map.",
- "mapViewCachedCoords": "Не удалось установить вид карты на кэшированные координаты, пожалуйста, попробуйте снова или свяжитесь с BTC Map.",
"merchantDetailsLoadError": "Ошибка загрузки деталей торговца. Пожалуйста, попробуйте снова.",
- "boostLoadError": "Не удалось загрузить информацию о бусте. Пожалуйста, попробуйте снова.",
- "fullscreenError": "Ошибка при попытке включить полноэкранный режим: {message} ({name})"
+ "boostLoadError": "Не удалось загрузить информацию о бусте. Пожалуйста, попробуйте снова."
},
"success": {
"locationSelected": "Местоположение выбрано!",
diff --git a/src/lib/map/basemaps.ts b/src/lib/map/basemaps.ts
new file mode 100644
index 000000000..306875cec
--- /dev/null
+++ b/src/lib/map/basemaps.ts
@@ -0,0 +1,28 @@
+// Raster basemap catalog for /map. All three sources/layers are added
+// to the style at init; switching is just a layer-visibility toggle, no
+// setStyle — which avoids the tile-compile cascade that broke the first
+// basemap-switcher attempt.
+
+export type BasemapId = "osm" | "carto-light" | "carto-dark";
+
+export const BASEMAPS: { id: BasemapId; label: string }[] = [
+ { id: "osm", label: "OpenStreetMap" },
+ { id: "carto-light", label: "Carto Light" },
+ { id: "carto-dark", label: "Carto Dark" },
+];
+
+export const BASEMAP_STORAGE_KEY = "btcmap-next-basemap";
+
+export const isBasemapId = (v: string): v is BasemapId =>
+ v === "osm" || v === "carto-light" || v === "carto-dark";
+
+export const getStoredBasemap = (): BasemapId | null => {
+ if (typeof window === "undefined") return null;
+ try {
+ const v = localStorage.getItem(BASEMAP_STORAGE_KEY);
+ if (v && isBasemapId(v)) return v;
+ } catch {
+ // localStorage unavailable
+ }
+ return null;
+};
diff --git a/src/lib/map/batch-processor.ts b/src/lib/map/batch-processor.ts
deleted file mode 100644
index 564f58436..000000000
--- a/src/lib/map/batch-processor.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import type { FeatureGroup, Marker, MarkerClusterGroup } from "leaflet";
-
-import { attachMarkerLabelIfVisible } from "$lib/map/labels";
-import type { LoadedMarkers } from "$lib/map/markers";
-import { highlightMarker } from "$lib/map/markers";
-import { generateIcon, generateMarker } from "$lib/map/setup";
-import type { Leaflet, Place } from "$lib/types";
-import type { ProcessedPlace } from "$lib/workers/map-worker";
-
-type ProcessBatchOptions = {
- batch: ProcessedPlace[];
- leaflet: Leaflet;
- currentZoom: number;
- placeDetailsCache: Map
;
- placesById: Map;
- savedPlaceIds?: Set;
- loadedMarkers: LoadedMarkers;
- boostedLayerMarkerIds: Set;
- shouldClusterBoostedMarkers: () => boolean;
- markers: MarkerClusterGroup;
- boostedLayer: FeatureGroup;
- selectedMarkerId: number | null;
- onMarkerClick: (id: number) => void;
-};
-
-export const processBatchOnMainThread = ({
- batch,
- leaflet,
- currentZoom,
- placeDetailsCache,
- placesById,
- savedPlaceIds,
- loadedMarkers,
- boostedLayerMarkerIds,
- shouldClusterBoostedMarkers,
- markers,
- boostedLayer,
- selectedMarkerId,
- onMarkerClick,
-}: ProcessBatchOptions): void => {
- const regularMarkersToAdd: Marker[] = [];
- const boostedMarkersToAdd: Marker[] = [];
-
- batch.forEach((element: ProcessedPlace) => {
- const { iconData } = element;
- const placeId = element.id.toString();
-
- if (loadedMarkers[placeId]) return;
-
- const isSaved = savedPlaceIds?.has(element.id) ?? false;
- const divIcon = generateIcon(
- leaflet,
- iconData.iconTmp,
- iconData.boosted,
- iconData.commentsCount,
- isSaved,
- );
-
- const marker = generateMarker({
- lat: element.lat,
- long: element.lon,
- icon: divIcon,
- placeId: element.id,
- leaflet,
- verify: true,
- onMarkerClick: (id) => onMarkerClick(Number(id)),
- });
-
- attachMarkerLabelIfVisible({
- marker,
- placeId: element.id,
- currentZoom,
- placeDetailsCache,
- placesById,
- boosted: Boolean(iconData.boosted),
- leaflet,
- fallbackPlace: placesById.get(element.id),
- });
-
- if (iconData.boosted && !shouldClusterBoostedMarkers()) {
- boostedMarkersToAdd.push(marker);
- boostedLayerMarkerIds.add(placeId);
- } else {
- regularMarkersToAdd.push(marker);
- }
- loadedMarkers[placeId] = marker;
- });
-
- if (regularMarkersToAdd.length > 0 && markers) {
- markers.addLayers(regularMarkersToAdd);
- }
-
- if (boostedMarkersToAdd.length > 0 && boostedLayer) {
- boostedMarkersToAdd.forEach((m) => boostedLayer.addLayer(m));
- }
-
- if (
- (regularMarkersToAdd.length > 0 || boostedMarkersToAdd.length > 0) &&
- selectedMarkerId
- ) {
- highlightMarker(loadedMarkers, selectedMarkerId);
- }
-};
diff --git a/src/lib/map/imports.ts b/src/lib/map/imports.ts
deleted file mode 100644
index c6cb2c9b0..000000000
--- a/src/lib/map/imports.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// Dynamic imports for browser-only map dependencies
-// These packages access window/document and must be loaded client-side only
-export async function loadMapDependencies() {
- // Leaflet must load first - it sets window.L
- const [leaflet, DomEvent] = await Promise.all([
- import("leaflet"),
- import("leaflet/src/dom/DomEvent"),
- ]);
-
- // Plugins depend on window.L - load after leaflet
- // Most imports are for side effects only (setting up window.L plugins)
- const [
- _maplibreGL,
- _maplibreLeaflet,
- locateControlModule,
- _markerCluster,
- _subgroup,
- ] = await Promise.all([
- import("maplibre-gl"),
- import("@maplibre/maplibre-gl-leaflet"),
- import("leaflet.locatecontrol"),
- import("leaflet.markercluster"),
- import("leaflet.featuregroup.subgroup"),
- ]);
-
- const { LocateControl } = locateControlModule;
-
- return { leaflet, DomEvent, LocateControl };
-}
diff --git a/src/lib/map/labels.ts b/src/lib/map/labels.ts
deleted file mode 100644
index 6aebbce55..000000000
--- a/src/lib/map/labels.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import type { Marker, TooltipOptions } from "leaflet";
-
-import { LABEL_VISIBLE_ZOOM } from "$lib/constants";
-import type { LoadedMarkers } from "$lib/map/markers";
-import type { Leaflet, Place } from "$lib/types";
-import { escapeHtml } from "$lib/utils";
-
-/**
- * Marker label management for the map
- * Handles tooltip creation, updates, and visibility based on zoom level
- */
-
-/**
- * Shared tooltip configuration to avoid duplication across functions
- */
-export function getMarkerLabelTooltipOptions(
- leaflet: Leaflet,
- boosted: boolean = false,
-): TooltipOptions {
- return {
- permanent: true,
- direction: "right",
- className: boosted ? "marker-label marker-label-boosted" : "marker-label",
- // Position label to the right of marker (17px) and above center (-25px)
- // to avoid overlapping with the marker icon tip
- offset: leaflet.point(17, -25),
- };
-}
-
-/**
- * Check if a place is currently boosted
- */
-export function isPlaceBoosted(place?: Place | null): boolean {
- return place?.boosted_until
- ? Date.parse(place.boosted_until) > Date.now()
- : false;
-}
-
-/**
- * Centralized handler for binding tooltips to markers
- * Only updates tooltip if content or styling has changed
- */
-export function bindMarkerLabelTooltip(
- marker: Marker,
- labelText: string,
- boosted: boolean,
- leaflet: Leaflet,
-): void {
- const tooltip = marker.getTooltip();
- if (tooltip) {
- const options = getMarkerLabelTooltipOptions(leaflet, boosted);
- const currentClass = tooltip.options.className || "";
- const needsClassUpdate = currentClass !== options.className;
- const needsContentUpdate = tooltip.getContent() !== labelText;
-
- // No changes needed
- if (!needsClassUpdate && !needsContentUpdate) {
- return;
- }
-
- // If class needs to change, recreate the tooltip using Leaflet's public API
- // (Leaflet doesn't provide a way to update tooltip classes after creation)
- if (needsClassUpdate) {
- marker.unbindTooltip();
- marker.bindTooltip(labelText, options);
- return;
- }
-
- // Only content needs updating - setContent() automatically updates the DOM
- if (needsContentUpdate) {
- tooltip.setContent(labelText);
- }
- return;
- }
- marker.bindTooltip(labelText, getMarkerLabelTooltipOptions(leaflet, boosted));
-}
-
-/**
- * Get label text for a place from multiple sources (with fallback chain)
- */
-export function getLabelText(
- placeId: number,
- placeDetailsCache: Map,
- placesById: Map,
- fallbackPlace?: Place,
-): string | null {
- const sources: Array = [
- placeDetailsCache.get(placeId),
- fallbackPlace,
- placesById.get(placeId),
- ];
-
- for (const source of sources) {
- if (!source) continue;
- // Handle empty string as intentional "no name" to prevent fallback
- if (source.name === "") return null;
- // Escape HTML to prevent XSS (Leaflet tooltips treat strings as HTML)
- if (source.name) return escapeHtml(source.name);
- if (source["osm:amenity"]) return escapeHtml(source["osm:amenity"]);
- }
-
- return null;
-}
-
-type AttachMarkerLabelOptions = {
- marker: Marker;
- placeId: number;
- currentZoom: number;
- placeDetailsCache: Map;
- placesById: Map;
- boosted: boolean;
- leaflet: Leaflet;
- fallbackPlace?: Place;
- signalUpdate?: () => void;
-};
-
-// Attach label tooltip to marker if zoom level allows visibility
-export function attachMarkerLabelIfVisible({
- marker,
- placeId,
- currentZoom,
- placeDetailsCache,
- placesById,
- boosted,
- leaflet,
- fallbackPlace,
- signalUpdate,
-}: AttachMarkerLabelOptions): boolean {
- if (currentZoom < LABEL_VISIBLE_ZOOM) return false;
-
- const labelText = getLabelText(
- placeId,
- placeDetailsCache,
- placesById,
- fallbackPlace,
- );
- if (labelText) {
- bindMarkerLabelTooltip(marker, labelText, boosted, leaflet);
- if (signalUpdate) {
- signalUpdate();
- }
- return true;
- }
- return false;
-}
-
-/**
- * Update all marker labels based on zoom level and available data
- */
-export function updateMarkerLabels(
- loadedMarkers: LoadedMarkers,
- currentZoom: number,
- placeDetailsCache: Map,
- placesById: Map,
- boostedLayerMarkerIds: Set,
- leaflet: Leaflet,
-): void {
- if (currentZoom < LABEL_VISIBLE_ZOOM) {
- // Remove all tooltips when zoomed out
- Object.values(loadedMarkers).forEach((marker) => {
- if (marker.getTooltip()) {
- marker.unbindTooltip();
- }
- });
- return;
- }
-
- // Update or create tooltips for all visible markers
- Object.entries(loadedMarkers).forEach(([placeId, marker]) => {
- const placeIdNum = Number(placeId);
- const sourcePlace = placesById.get(placeIdNum);
- const boosted =
- isPlaceBoosted(sourcePlace) || boostedLayerMarkerIds.has(placeId);
-
- const attached = attachMarkerLabelIfVisible({
- marker,
- placeId: placeIdNum,
- currentZoom,
- placeDetailsCache,
- placesById,
- boosted,
- leaflet,
- fallbackPlace: sourcePlace,
- });
-
- // Clean up stale tooltips if label text is no longer available
- if (!attached && marker.getTooltip()) {
- marker.unbindTooltip();
- }
- });
-}
-
-/**
- * State tracker for label updates
- * Manages change detection to trigger label updates efficiently
- */
-export class LabelUpdateTracker {
- private lastLabelZoomState: boolean;
- private lastCacheRevision: number;
- private lastEnrichingState: boolean;
- private labelVersion: number = 0;
- private lastLabelVersion: number = 0;
-
- constructor(
- initialZoom: number,
- initialCacheSize: number,
- initialEnrichingState: boolean,
- ) {
- this.lastLabelZoomState = initialZoom >= LABEL_VISIBLE_ZOOM;
- this.lastCacheRevision = initialCacheSize;
- this.lastEnrichingState = initialEnrichingState;
- }
-
- /**
- * Signal that a label was manually updated (e.g., marker was just added)
- */
- public incrementVersion(): void {
- this.labelVersion += 1;
- }
-
- /**
- * Determines if marker labels need updating based on state changes
- * Returns true if update is needed
- */
- public shouldUpdate(
- labelsVisible: boolean,
- currentCacheSize: number,
- isEnriching: boolean,
- updateCallback: () => void,
- ): boolean {
- const zoomStateChanged = labelsVisible !== this.lastLabelZoomState;
- const cacheChanged = currentCacheSize !== this.lastCacheRevision;
- const enrichmentCompleted = this.lastEnrichingState && !isEnriching;
- const versionChanged = this.labelVersion !== this.lastLabelVersion;
-
- const shouldUpdate =
- zoomStateChanged || cacheChanged || enrichmentCompleted || versionChanged;
-
- if (shouldUpdate) {
- updateCallback();
- this.lastLabelZoomState = labelsVisible;
- this.lastCacheRevision = currentCacheSize;
- this.lastLabelVersion = this.labelVersion;
- }
-
- this.lastEnrichingState = isEnriching;
- return shouldUpdate;
- }
-}
diff --git a/src/lib/map/mapHash.ts b/src/lib/map/mapHash.ts
new file mode 100644
index 000000000..489ddef49
--- /dev/null
+++ b/src/lib/map/mapHash.ts
@@ -0,0 +1,64 @@
+// URL hash 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.
+
+export type HashCoords = {
+ zoom: number;
+ lat: number;
+ lng: number;
+ bearing: number;
+ pitch: number;
+};
+
+export const parseHashCoords = (): HashCoords | null => {
+ if (typeof window === "undefined") return null;
+ const hash = window.location.hash.substring(1);
+ const ampIndex = hash.indexOf("&");
+ const coordsPart = ampIndex !== -1 ? hash.substring(0, ampIndex) : hash;
+ if (!coordsPart.includes("/")) return null;
+ const parts = coordsPart.split("/");
+ if (parts.length < 3) return null;
+ const zoom = Number.parseFloat(parts[0]);
+ const lat = Number.parseFloat(parts[1]);
+ const lng = Number.parseFloat(parts[2]);
+ if (Number.isNaN(zoom) || Number.isNaN(lat) || Number.isNaN(lng)) return null;
+ const bearing = parts[3] ? Number.parseFloat(parts[3]) : 0;
+ const pitch = parts[4] ? Number.parseFloat(parts[4]) : 0;
+ return {
+ zoom,
+ lat,
+ lng,
+ bearing: Number.isNaN(bearing) ? 0 : bearing,
+ pitch: Number.isNaN(pitch) ? 0 : pitch,
+ };
+};
+
+export const writeHashCoords = (c: HashCoords) => {
+ if (typeof window === "undefined") return;
+ const currentHash = window.location.hash.substring(1);
+ const ampIndex = currentHash.indexOf("&");
+ let existingParams = "";
+ if (ampIndex !== -1 && currentHash.substring(0, ampIndex).includes("/")) {
+ existingParams = currentHash.substring(ampIndex);
+ } else if (!currentHash.includes("/") && currentHash.length > 0) {
+ existingParams = `&${currentHash}`;
+ }
+ // Cascading zeros: bearing is written when EITHER bearing or pitch is
+ // non-zero (so pitch always has a slot before it); pitch only when
+ // non-zero. `15/40/-74` and `15/40/-74/0` therefore decode identically
+ // — fine because the writer never produces the second form.
+ let coordsPart = `${c.zoom.toFixed(2)}/${c.lat.toFixed(5)}/${c.lng.toFixed(5)}`;
+ if (c.bearing !== 0 || c.pitch !== 0) {
+ coordsPart += `/${c.bearing.toFixed(1)}`;
+ }
+ if (c.pitch !== 0) {
+ coordsPart += `/${c.pitch.toFixed(1)}`;
+ }
+ const newHash = `#${coordsPart}${existingParams}`;
+ const search = window.location.search || "";
+ const url = window.location.pathname + search + newHash;
+ history.replaceState(history.state, "", url);
+};
diff --git a/src/lib/map/maplibreSprites.ts b/src/lib/map/maplibreSprites.ts
new file mode 100644
index 000000000..631c01960
--- /dev/null
+++ b/src/lib/map/maplibreSprites.ts
@@ -0,0 +1,216 @@
+import type { Map as MapLibreMap } from "maplibre-gl";
+
+import { resolveMaterialIcon } from "$lib/materialIcons";
+import type { Place } from "$lib/types";
+import { isBoosted } from "$lib/utils";
+
+export const resolveIconifyName = (icon: string): string => {
+ // "question_mark" is the API's placeholder for an untagged place; the
+ // Bitcoin glyph is a friendlier stand-in. Icon.svelte applies the same
+ // substitution upstream of its own resolution.
+ const key = icon === "question_mark" ? "currency_bitcoin" : icon;
+ return resolveMaterialIcon(key);
+};
+
+export const PIN_PATH =
+ "M0 16.0333C0 6.08 8.05161 0.131836 15.8361 0.131836C23.6205 0.131836 31.6721 6.08 31.6721 16.0333C31.6721 26.461 16.9494 41.3035 16.3229 41.9301C16.1941 42.0595 16.0185 42.1318 15.8361 42.1318C15.6536 42.1318 15.478 42.0595 15.3493 41.9301C14.7227 41.3035 0 26.461 0 16.0333Z";
+
+export const PIN_FILL_REGULAR = "#0E95AF";
+export const PIN_FILL_BOOSTED = "#F7931A";
+
+export const spriteName = (icon: string, boosted: boolean): string =>
+ `pin-${boosted ? "b" : "r"}-${icon}`;
+
+// Render scale for composite pin sprites. The outer SVG is rasterized at
+// SCALE× its declared px dimensions (viewBox stays the same), then
+// registered with `pixelRatio: SCALE` so MapLibre displays at native size
+// but draws from the higher-density bitmap — same idea as a @2x asset.
+// Without this, the Material Icon inside the pin looks blurry on retina
+// displays. The Iconify fetch URL keeps width=20 height=20 because the
+// inner SVG is positioned in the outer's USER UNITS, so it scales with
+// the outer's rasterization resolution.
+//
+// 3× targets phone-class DPRs (most modern phones report 3, some 4).
+// Higher = sharper on those screens but a larger sprite cache; 3 is a
+// reasonable ceiling — beyond that the perceptual gain stops mattering
+// against the memory cost.
+export const PIN_RENDER_SCALE = 3;
+
+export const fetchIconifyByName = async (
+ iconifyName: string,
+): Promise => {
+ const path = iconifyName.replace(":", "/");
+ const url = `https://api.iconify.design/${path}.svg?color=white&width=20&height=20`;
+ const res = await fetch(url);
+ if (!res.ok) return null;
+ return await res.text();
+};
+
+// Cascading fallback for icon names whose resolved Iconify name 404s.
+// The known-missing names are in the materialExceptions table now, so
+// this is the safety net for any future tag value that resolves to a
+// nonexistent `ic:outline-*`: try material-symbols next, then fall back
+// to the Bitcoin glyph so every pin has at least a recognizable shape.
+export const fetchIconInnerSvg = async (icon: string): Promise => {
+ const primary = resolveIconifyName(icon);
+ const primarySvg = await fetchIconifyByName(primary);
+ if (primarySvg) return primarySvg;
+ if (primary.startsWith("ic:outline-")) {
+ const stem = primary.slice("ic:outline-".length);
+ const fallback = await fetchIconifyByName(`material-symbols:${stem}`);
+ if (fallback) return fallback;
+ }
+ const bitcoin = await fetchIconifyByName("material-symbols:currency-bitcoin");
+ if (bitcoin) return bitcoin;
+ throw new Error(`No icon found for ${icon}`);
+};
+
+export const buildCompositeSvg = (
+ innerSvg: string,
+ boosted: boolean,
+): string => {
+ const fill = boosted ? PIN_FILL_BOOSTED : PIN_FILL_REGULAR;
+ // innerSvg is a complete document; nesting an SVG inside an
+ // outer SVG is valid and rasterizes correctly through
.
+ // width/height are SCALE× the viewBox dims; nested vector content (pin
+ // path, inner SVG glyph) re-rasterizes at the higher resolution → crisp
+ // on retina. addImage in ensureSprite registers with pixelRatio: SCALE
+ // so MapLibre still displays at the logical 32×43 size.
+ const w = 32 * PIN_RENDER_SCALE;
+ const h = 43 * PIN_RENDER_SCALE;
+ return ``;
+};
+
+export const loadSvgImage = (svg: string): Promise =>
+ new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.onload = () => resolve(img);
+ img.onerror = (err) => reject(err);
+ img.src = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+ });
+
+// Per-map sprite-promise cache. Keyed by MapLibreMap so multiple maps in the
+// same page session (e.g. /map + an AreaMap embed) don't cross-pollute
+// — each map has its own image registry, so a cached "completed" promise from
+// one map shouldn't short-circuit registration on another.
+const spritePromisesByMap = new WeakMap<
+ MapLibreMap,
+ Map>
+>();
+
+const getSpritePromises = (m: MapLibreMap): Map> => {
+ let cache = spritePromisesByMap.get(m);
+ if (!cache) {
+ cache = new Map();
+ spritePromisesByMap.set(m, cache);
+ }
+ return cache;
+};
+
+// Track which sprite names we've registered with the *real* composite bitmap,
+// distinct from the 1×1 stubs the styleimagemissing handler may have inserted.
+// Without this, ensureSprite's hasImage() short-circuit returns true for a
+// stub and the real sprite never replaces it — pin renders transparent.
+const realSpritesByMap = new WeakMap>();
+
+// Maps that have a style.load listener attached to evict tracking on setStyle.
+const styleResetInstalledFor = new WeakSet();
+
+const ensureStyleResetListener = (m: MapLibreMap): void => {
+ if (styleResetInstalledFor.has(m)) return;
+ styleResetInstalledFor.add(m);
+ m.on("style.load", () => {
+ realSpritesByMap.get(m)?.clear();
+ });
+};
+
+const hasRealSprite = (m: MapLibreMap, name: string): boolean =>
+ realSpritesByMap.get(m)?.has(name) ?? false;
+
+const markRealSprite = (m: MapLibreMap, name: string): void => {
+ let set = realSpritesByMap.get(m);
+ if (!set) {
+ set = new Set();
+ realSpritesByMap.set(m, set);
+ }
+ set.add(name);
+};
+
+export const ensureSprite = (
+ m: MapLibreMap,
+ icon: string,
+ boosted: boolean,
+): Promise => {
+ ensureStyleResetListener(m);
+ const name = spriteName(icon, boosted);
+ if (hasRealSprite(m, name)) return Promise.resolve();
+ const cache = getSpritePromises(m);
+ const existing = cache.get(name);
+ if (existing) return existing;
+ const promise = (async () => {
+ const inner = await fetchIconInnerSvg(icon);
+ const composite = buildCompositeSvg(inner, boosted);
+ const img = await loadSvgImage(composite);
+ if (hasRealSprite(m, name)) return;
+ // Remove any stub the placeholder handler installed before we got here
+ // — addImage throws on a duplicate name, and updateImage drops the
+ // pixelRatio (so the pin would render at 1× instead of @2×).
+ if (m.hasImage(name)) m.removeImage(name);
+ m.addImage(name, img, { pixelRatio: PIN_RENDER_SCALE });
+ markRealSprite(m, name);
+ m.triggerRepaint();
+ })();
+ cache.set(name, promise);
+ // Cache only dedupes in-flight requests; once resolved, drop the entry so a
+ // subsequent setStyle() (which may evict the image) can trigger regeneration
+ // instead of short-circuiting on a stale resolved promise. Errors clear too,
+ // so a transient Iconify outage doesn't permanently poison the cache.
+ promise.then(
+ () => cache.delete(name),
+ () => cache.delete(name),
+ );
+ return promise;
+};
+
+export const ensureSpritesForPlaces = (m: MapLibreMap, list: Place[]): void => {
+ const seen = new Set();
+ for (const p of list) {
+ if (p.deleted_at) continue;
+ const icon = p.icon ?? "question_mark";
+ const boosted = Boolean(isBoosted(p));
+ const key = spriteName(icon, boosted);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ // ensureSprite owns its own in-flight cache, so we don't need to
+ // hold the returned promise here — but we DO need to attach a
+ // .catch so a transient Iconify outage doesn't silently fail.
+ // Without this log, "pin renders as a transparent stub" was
+ // undebuggable because the rejection was swallowed by the cache
+ // cleanup handlers inside ensureSprite.
+ ensureSprite(m, icon, boosted).catch((err) => {
+ console.warn(`ensureSprite failed for ${key}:`, err);
+ });
+ }
+};
+
+// 1×1 transparent placeholder so styleimagemissing doesn't spam warnings
+// before composite sprites resolve. Each missing icon name registers the
+// same blank bitmap; once the real sprite is added via addImage(), it
+// replaces this stub.
+export const transparentPixel = (): {
+ width: number;
+ height: number;
+ data: Uint8Array;
+} => ({
+ width: 1,
+ height: 1,
+ data: new Uint8Array([0, 0, 0, 0]),
+});
+
+export const installPlaceholderHandler = (m: MapLibreMap): void => {
+ m.on("styleimagemissing", (e) => {
+ if (m.hasImage(e.id)) return;
+ m.addImage(e.id, transparentPixel());
+ });
+};
diff --git a/src/lib/map/marker-creation.ts b/src/lib/map/marker-creation.ts
deleted file mode 100644
index c1cee549a..000000000
--- a/src/lib/map/marker-creation.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { Marker } from "leaflet";
-
-import { attachMarkerLabelIfVisible } from "$lib/map/labels";
-import { generateIcon, generateMarker } from "$lib/map/setup";
-import type { Leaflet, Place } from "$lib/types";
-
-type CreateMarkerOptions = {
- place: Place;
- leaflet: Leaflet;
- currentZoom: number;
- placeDetailsCache: Map;
- placesById: Map;
- savedPlaceIds?: Set;
- onMarkerClick: (id: number) => void;
- onLabelUpdate?: () => void;
-};
-
-export const createMarkerWithLabel = ({
- place,
- leaflet,
- currentZoom,
- placeDetailsCache,
- placesById,
- savedPlaceIds,
- onMarkerClick,
- onLabelUpdate,
-}: CreateMarkerOptions): { marker: Marker; boosted: boolean } => {
- const commentsCount = place.comments || 0;
- const icon = place.icon;
- const boosted = place.boosted_until
- ? Date.parse(place.boosted_until) > Date.now()
- : false;
- const isSaved = savedPlaceIds?.has(place.id) ?? false;
-
- const divIcon = generateIcon(leaflet, icon, boosted, commentsCount, isSaved);
-
- const marker = generateMarker({
- lat: place.lat,
- long: place.lon,
- icon: divIcon,
- placeId: place.id,
- leaflet,
- verify: true,
- onMarkerClick: (id) => onMarkerClick(Number(id)),
- });
-
- attachMarkerLabelIfVisible({
- marker,
- placeId: place.id,
- currentZoom,
- placeDetailsCache,
- placesById,
- boosted,
- leaflet,
- fallbackPlace: place,
- signalUpdate: onLabelUpdate,
- });
-
- return { marker, boosted };
-};
diff --git a/src/lib/map/markers.ts b/src/lib/map/markers.ts
deleted file mode 100644
index 1108c5642..000000000
--- a/src/lib/map/markers.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { FeatureGroup, LatLngBounds, Marker } from "leaflet";
-
-export type LoadedMarkers = Record;
-
-// Clear selection styling from a marker
-export const clearMarkerSelection = (
- loadedMarkers: LoadedMarkers,
- markerId: number,
-): void => {
- const marker = loadedMarkers[markerId.toString()];
- if (!marker) return;
-
- const markerIcon = marker.getElement();
- if (markerIcon) {
- markerIcon.classList.remove("selected-marker", "selected-marker-boosted");
- }
-};
-
-// Add selection styling to a marker
-export const highlightMarker = (
- loadedMarkers: LoadedMarkers,
- markerId: number,
-): void => {
- const marker = loadedMarkers[markerId.toString()];
- if (!marker) return;
-
- const markerIcon = marker.getElement();
- if (markerIcon) {
- const isBoosted = markerIcon.classList.contains("boosted-icon");
- markerIcon.classList.add(
- isBoosted ? "selected-marker-boosted" : "selected-marker",
- );
- }
-};
-
-export type CleanupMarkersOptions = {
- loadedMarkers: LoadedMarkers;
- upToDateLayer: FeatureGroup.SubGroup;
- boostedLayer: FeatureGroup;
- boostedLayerMarkerIds: Set;
- bounds: LatLngBounds;
-};
-
-// Remove markers that are no longer in viewport
-export const cleanupOutOfBoundsMarkers = ({
- loadedMarkers,
- upToDateLayer,
- boostedLayer,
- boostedLayerMarkerIds,
- bounds,
-}: CleanupMarkersOptions): string[] => {
- const markersToRemove: string[] = [];
-
- Object.entries(loadedMarkers).forEach(([placeId, marker]) => {
- const markerLatLng = marker.getLatLng();
- if (!bounds.contains(markerLatLng)) {
- if (boostedLayerMarkerIds.has(placeId)) {
- boostedLayer.removeLayer(marker);
- boostedLayerMarkerIds.delete(placeId);
- } else {
- upToDateLayer.removeLayer(marker);
- }
- markersToRemove.push(placeId);
- }
- });
-
- markersToRemove.forEach((placeId) => {
- delete loadedMarkers[placeId];
- });
-
- if (markersToRemove.length > 0) {
- console.info(`Cleaned up ${markersToRemove.length} out-of-bounds markers`);
- }
-
- return markersToRemove;
-};
diff --git a/src/lib/map/queryViewport.test.ts b/src/lib/map/queryViewport.test.ts
new file mode 100644
index 000000000..b3385ed10
--- /dev/null
+++ b/src/lib/map/queryViewport.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+
+import { parseLatLongQuery } from "./queryViewport";
+
+const sp = (qs: string): URLSearchParams => new URLSearchParams(qs);
+
+describe("parseLatLongQuery", () => {
+ it("returns null when neither lat nor long is present", () => {
+ expect(parseLatLongQuery(sp(""))).toBeNull();
+ expect(parseLatLongQuery(sp("foo=bar"))).toBeNull();
+ });
+
+ it("returns null when only one of lat/long is present", () => {
+ expect(parseLatLongQuery(sp("lat=40"))).toBeNull();
+ expect(parseLatLongQuery(sp("long=-74"))).toBeNull();
+ });
+
+ it('returns a "point" for a single lat/long pair', () => {
+ expect(parseLatLongQuery(sp("lat=40&long=-74"))).toEqual({
+ kind: "point",
+ lat: 40,
+ lng: -74,
+ });
+ });
+
+ it('returns "bounds" with canonicalized SW/NE for two pairs', () => {
+ // Pass them out-of-order; the helper should sort to SW/NE
+ // regardless of which corner the embed listed first.
+ const got = parseLatLongQuery(sp("lat=41&long=-73&lat=40&long=-74"));
+ expect(got).toEqual({
+ kind: "bounds",
+ sw: [-74, 40],
+ ne: [-73, 41],
+ });
+ });
+
+ it("uses the first two pairs when three or more are supplied", () => {
+ const got = parseLatLongQuery(
+ sp("lat=10&long=10&lat=20&long=20&lat=99&long=99"),
+ );
+ expect(got).toEqual({
+ kind: "bounds",
+ sw: [10, 10],
+ ne: [20, 20],
+ });
+ });
+
+ it("falls back to a single point when counts are asymmetric", () => {
+ // Legacy /map treated mismatched counts (1 lat + 2 longs, etc.) as
+ // a single point using the first of each — preserve that.
+ expect(parseLatLongQuery(sp("lat=40&long=-74&long=-73"))).toEqual({
+ kind: "point",
+ lat: 40,
+ lng: -74,
+ });
+ expect(parseLatLongQuery(sp("lat=40&lat=41&long=-74"))).toEqual({
+ kind: "point",
+ lat: 40,
+ lng: -74,
+ });
+ });
+
+ it("returns null when any coordinate is non-numeric", () => {
+ expect(parseLatLongQuery(sp("lat=abc&long=-74"))).toBeNull();
+ expect(parseLatLongQuery(sp("lat=40&long=xyz"))).toBeNull();
+ });
+
+ it("returns null when any coordinate is out of range", () => {
+ expect(parseLatLongQuery(sp("lat=91&long=0"))).toBeNull();
+ expect(parseLatLongQuery(sp("lat=-91&long=0"))).toBeNull();
+ expect(parseLatLongQuery(sp("lat=0&long=181"))).toBeNull();
+ expect(parseLatLongQuery(sp("lat=0&long=-181"))).toBeNull();
+ });
+
+ it("returns null when either pair in a bounds query is invalid", () => {
+ expect(
+ parseLatLongQuery(sp("lat=40&long=-74&lat=200&long=-73")),
+ ).toBeNull();
+ });
+
+ it("returns null when any coordinate is empty", () => {
+ expect(parseLatLongQuery(sp("lat=&long=-74"))).toBeNull();
+ expect(parseLatLongQuery(sp("lat=40&long="))).toBeNull();
+ });
+});
diff --git a/src/lib/map/queryViewport.ts b/src/lib/map/queryViewport.ts
new file mode 100644
index 000000000..6aa273b44
--- /dev/null
+++ b/src/lib/map/queryViewport.ts
@@ -0,0 +1,47 @@
+// Legacy /map supported `?lat=X&long=Y` for the initial viewport, including
+// a bounds form with two pairs (`?lat=A&long=B&lat=C&long=D`). Embeds rely
+// on this contract, so the MapLibre rewrite has to honor it even though
+// the in-page UI uses the hash format.
+
+export type LatLongQuery =
+ | { kind: "point"; lat: number; lng: number }
+ | { kind: "bounds"; sw: [number, number]; ne: [number, number] };
+
+const isFiniteCoord = (lat: number, lng: number): boolean =>
+ Number.isFinite(lat) &&
+ Number.isFinite(lng) &&
+ lat >= -90 &&
+ lat <= 90 &&
+ lng >= -180 &&
+ lng <= 180;
+
+const parseNumber = (raw: string | undefined): number => {
+ if (raw === undefined || raw.trim() === "") return Number.NaN;
+ return Number(raw);
+};
+
+export const parseLatLongQuery = (
+ searchParams: URLSearchParams,
+): LatLongQuery | null => {
+ const lats = searchParams.getAll("lat");
+ const longs = searchParams.getAll("long");
+ if (lats.length === 0 || longs.length === 0) return null;
+
+ if (lats.length >= 2 && longs.length >= 2) {
+ const lat1 = parseNumber(lats[0]);
+ const lng1 = parseNumber(longs[0]);
+ const lat2 = parseNumber(lats[1]);
+ const lng2 = parseNumber(longs[1]);
+ if (!isFiniteCoord(lat1, lng1) || !isFiniteCoord(lat2, lng2)) return null;
+ return {
+ kind: "bounds",
+ sw: [Math.min(lng1, lng2), Math.min(lat1, lat2)],
+ ne: [Math.max(lng1, lng2), Math.max(lat1, lat2)],
+ };
+ }
+
+ const lat = parseNumber(lats[0]);
+ const lng = parseNumber(longs[0]);
+ if (!isFiniteCoord(lat, lng)) return null;
+ return { kind: "point", lat, lng };
+};
diff --git a/src/lib/map/setup.ts b/src/lib/map/setup.ts
deleted file mode 100644
index cb82c8f67..000000000
--- a/src/lib/map/setup.ts
+++ /dev/null
@@ -1,798 +0,0 @@
-import type { DivIcon, LatLng, Map as LeafletMap } from "leaflet";
-import { get } from "svelte/store";
-
-import Icon from "$components/Icon.svelte";
-import { trackEvent } from "$lib/analytics";
-import { API_BASE } from "$lib/api-base";
-import { buildFieldsParam, PLACE_FIELD_SETS } from "$lib/api-fields";
-import api from "$lib/axios";
-import { _ } from "$lib/i18n";
-import en from "$lib/i18n/locales/en.json";
-import { session } from "$lib/session";
-import { selectedMerchant } from "$lib/store";
-import { theme } from "$lib/theme";
-import type { BaseMaps, DomEventType, Leaflet, Theme } from "$lib/types";
-import { userLocation } from "$lib/userLocationStore";
-import { errToast, humanizeIconName } from "$lib/utils";
-
-import { replaceState } from "$app/navigation";
-
-export const updateMapHash = (zoom: number, center: LatLng): void => {
- // Preserve any existing merchant/view parameters
- // Hash formats:
- // #zoom/lat/lon → coords only, no params
- // #zoom/lat/lon&merchant=123 → coords + params (separated by &)
- // #merchant=123 → params only (no coords yet)
- const currentHash = window.location.hash.substring(1);
- const ampIndex = currentHash.indexOf("&");
-
- let existingParams = "";
- if (ampIndex !== -1 && currentHash.substring(0, ampIndex).includes("/")) {
- // Has coords before & — params are after it (e.g. 15/10.2/-67.5&merchant=123)
- existingParams = currentHash.substring(ampIndex);
- } else if (!currentHash.includes("/")) {
- // No coords at all — entire hash is params (e.g. merchant=123 or merchant=123&view=boost)
- existingParams = currentHash ? `&${currentHash}` : "";
- }
-
- const newHash = `#${zoom}/${center.lat.toFixed(5)}/${center.lng.toFixed(5)}${existingParams}`;
- // Use SvelteKit's replaceState to preserve pathname, search params (e.g. language=bg), and hash
- const search = window.location.search || "";
- const url = window.location.pathname + search + newHash;
- replaceState(url, {});
-};
-
-export const layers = (leaflet: Leaflet, map: LeafletMap) => {
- const currentTheme = theme.current;
-
- const osm = leaflet.tileLayer(
- "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
- {
- noWrap: true,
- maxZoom: 21,
- maxNativeZoom: 19,
- },
- );
-
- const openFreeMapLiberty = window.L.maplibreGL({
- style: "https://tiles.openfreemap.org/styles/liberty",
- });
-
- const openFreeMapDark = window.L.maplibreGL({
- style: "https://static.btcmap.org/map-styles/dark.json",
- });
-
- const cartoPositron = window.L.maplibreGL({
- style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
- });
-
- const cartoDarkMatter = window.L.maplibreGL({
- style: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
- });
-
- let activeLayer;
- if (currentTheme === "dark") {
- cartoDarkMatter.addTo(map);
- activeLayer = cartoDarkMatter;
- } else {
- openFreeMapLiberty.addTo(map);
- activeLayer = openFreeMapLiberty;
- }
-
- const baseMaps = {
- "OpenFreeMap Liberty": openFreeMapLiberty,
- "OpenFreeMap Dark": openFreeMapDark,
- "Carto Positron": cartoPositron,
- "Carto Dark Matter": cartoDarkMatter,
- OpenStreetMap: osm,
- };
-
- return { baseMaps, activeLayer };
-};
-
-export const attribution = (L: Leaflet, map: LeafletMap) => {
- // Use Leaflet's default attribution control
- L.control.attribution({ position: "bottomleft", prefix: false }).addTo(map);
-};
-
-// Swap the OpenFreeMap Liberty/Dark layers when the theme changes.
-// Callers invoke this from a reactive block once the map is initialized.
-export const applyThemeToBaseMaps = (
- currentTheme: Theme | undefined,
- baseMaps: BaseMaps,
- map: LeafletMap,
-) => {
- if (currentTheme === "dark") {
- baseMaps["OpenFreeMap Liberty"].remove();
- baseMaps["OpenFreeMap Dark"].addTo(map);
- } else {
- baseMaps["OpenFreeMap Dark"].remove();
- baseMaps["OpenFreeMap Liberty"].addTo(map);
- }
-};
-
-export type MapControlsTranslations = {
- fullScreen?: string;
- goToHome?: string;
- addLocation?: string;
- communityMap?: string;
- merchantMap?: string;
- account?: string;
- login?: string;
- dataRefreshAvailable?: string;
- support?: string;
- supportWithSats?: string;
- zoomIn?: string;
- zoomOut?: string;
- locate?: string;
-};
-
-// Fallbacks when callers omit translations (e.g. communities map, add-location). Sourced from en.json.
-const defaultMapControls: Required = {
- fullScreen: en.mapControls.fullScreen,
- goToHome: en.mapControls.goToHome,
- addLocation: en.mapControls.addLocation,
- communityMap: en.mapControls.communityMap,
- merchantMap: en.mapControls.merchantMap,
- account: en.mapControls.account,
- login: en.mapControls.login,
- dataRefreshAvailable: en.mapControls.dataRefreshAvailable,
- support: en.mapControls.support,
- supportWithSats: en.mapControls.supportWithSats,
- zoomIn: en.mapControls.zoomIn,
- zoomOut: en.mapControls.zoomOut,
- locate: en.mapControls.locate,
-};
-
-// Updates map control labels in the DOM when locale changes. Call from a locale subscription.
-export const applyMapControlTranslations = (t: (key: string) => string) => {
- const labels = {
- support: t("mapControls.support"),
- supportWithSats: t("mapControls.supportWithSats"),
- zoomIn: t("mapControls.zoomIn"),
- zoomOut: t("mapControls.zoomOut"),
- fullScreen: t("mapControls.fullScreen"),
- fullScreenAlt: t("mapControls.fullScreenAlt"),
- locate: t("mapControls.locate"),
- locateAlt: t("mapControls.locateAlt"),
- goToHome: t("mapControls.goToHome"),
- goToHomeAlt: t("mapControls.goToHomeAlt"),
- addLocation: t("mapControls.addLocation"),
- addLocationAlt: t("mapControls.addLocationAlt"),
- communityMap: t("mapControls.communityMap"),
- communityMapAlt: t("mapControls.communityMapAlt"),
- merchantMap: t("mapControls.merchantMap"),
- merchantMapAlt: t("mapControls.merchantMapAlt"),
- account: t("mapControls.account"),
- accountAlt: t("mapControls.accountAlt"),
- login: t("mapControls.login"),
- loginAlt: t("mapControls.loginAlt"),
- dataRefreshAvailable: t("mapControls.dataRefreshAvailable"),
- dataRefreshAlt: t("mapControls.dataRefreshAlt"),
- boostLocations: t("boost.locations"),
- boostAlt: t("mapControls.boostAlt"),
- };
-
- const supportLink = document.querySelector(
- ".leaflet-control-attribution a[href='/supporters']",
- ) as HTMLAnchorElement | null;
- if (supportLink) {
- supportLink.title = labels.supportWithSats;
- supportLink.textContent = labels.support;
- }
-
- const zoomIn = document.querySelector(".leaflet-control-zoom-in");
- if (zoomIn) {
- zoomIn.setAttribute("title", labels.zoomIn);
- zoomIn.setAttribute("aria-label", labels.zoomIn);
- }
- const zoomOut = document.querySelector(".leaflet-control-zoom-out");
- if (zoomOut) {
- zoomOut.setAttribute("title", labels.zoomOut);
- zoomOut.setAttribute("aria-label", labels.zoomOut);
- }
-
- const fullscreen = document.querySelector(".leaflet-control-full-screen");
- if (fullscreen) {
- fullscreen.setAttribute("title", labels.fullScreen);
- fullscreen.setAttribute("aria-label", labels.fullScreen);
- const fullscreenImg = fullscreen.querySelector("img");
- if (fullscreenImg) fullscreenImg.setAttribute("alt", labels.fullScreenAlt);
- }
-
- const locateBtn = document.querySelector(
- ".leaflet-bar-part.leaflet-bar-part-single",
- );
- if (locateBtn) {
- locateBtn.setAttribute("title", labels.locate);
- locateBtn.setAttribute("aria-label", labels.locate);
- const locateImg = locateBtn.querySelector("img");
- if (locateImg) locateImg.setAttribute("alt", labels.locateAlt);
- }
-
- const homeBtn = document.querySelector(".leaflet-control-home");
- if (homeBtn) {
- homeBtn.setAttribute("title", labels.goToHome);
- homeBtn.setAttribute("aria-label", labels.goToHome);
- const homeImg = homeBtn.querySelector("img");
- if (homeImg) homeImg.setAttribute("alt", labels.goToHomeAlt);
- }
- const addLocBtn = document.querySelector(".leaflet-control-add-location");
- if (addLocBtn) {
- addLocBtn.setAttribute("title", labels.addLocation);
- addLocBtn.setAttribute("aria-label", labels.addLocation);
- const addLocImg = addLocBtn.querySelector("img");
- if (addLocImg) addLocImg.setAttribute("alt", labels.addLocationAlt);
- }
- const communityBtn = document.querySelector(".leaflet-control-community-map");
- if (communityBtn) {
- communityBtn.setAttribute("title", labels.communityMap);
- communityBtn.setAttribute("aria-label", labels.communityMap);
- const communityImg = communityBtn.querySelector("img");
- if (communityImg) communityImg.setAttribute("alt", labels.communityMapAlt);
- }
- const merchantBtn = document.querySelector(".leaflet-control-merchant-map");
- if (merchantBtn) {
- merchantBtn.setAttribute("title", labels.merchantMap);
- merchantBtn.setAttribute("aria-label", labels.merchantMap);
- const merchantImg = merchantBtn.querySelector("img");
- if (merchantImg) merchantImg.setAttribute("alt", labels.merchantMapAlt);
- }
-
- const dataRefreshBtn = document.querySelector(
- ".leaflet-control-data-refresh",
- );
- if (dataRefreshBtn) {
- dataRefreshBtn.setAttribute("title", labels.dataRefreshAvailable);
- dataRefreshBtn.setAttribute("aria-label", labels.dataRefreshAvailable);
- const refreshImg = dataRefreshBtn.querySelector("img");
- if (refreshImg) refreshImg.setAttribute("alt", labels.dataRefreshAlt);
- }
-
- const boostBtn = document.querySelector(".leaflet-control-boost-layer");
- if (boostBtn) {
- boostBtn.setAttribute("title", labels.boostLocations);
- boostBtn.setAttribute("aria-label", labels.boostLocations);
- const boostImg = boostBtn.querySelector("img");
- if (boostImg) boostImg.setAttribute("alt", labels.boostAlt);
- }
-};
-
-export const support = (t?: MapControlsTranslations) => {
- const labels = { ...defaultMapControls, ...t };
- const supportAttribution: HTMLDivElement | null = document.querySelector(
- ".leaflet-bottom.leaflet-right > .leaflet-control-attribution",
- );
-
- if (!supportAttribution) return;
-
- supportAttribution.textContent = "";
- const link = document.createElement("a");
- link.href = "/supporters";
- link.title = labels.supportWithSats;
- link.textContent = labels.support;
- supportAttribution.append(link, document.createTextNode(" BTC Map"));
-};
-
-export const scaleBars = (L: Leaflet, map: LeafletMap) => {
- // Use Leaflet's default scale control
- L.control.scale({ position: "bottomleft" }).addTo(map);
-};
-
-export const changeDefaultIcons = (
- _layers: boolean,
- L: Leaflet,
- mapElement: HTMLDivElement,
- DomEvent: DomEventType,
- t?: MapControlsTranslations,
-) => {
- const labels = { ...defaultMapControls, ...t };
-
- // Add analytics tracking to zoom controls (keep Leaflet's default +/- text)
- const zoomIn: HTMLAnchorElement | null = document.querySelector(
- ".leaflet-control-zoom-in",
- );
- if (zoomIn) {
- zoomIn.title = labels.zoomIn;
- zoomIn.setAttribute("aria-label", labels.zoomIn);
- zoomIn.addEventListener("click", () => {
- trackEvent("zoom_in_click");
- });
- }
-
- const zoomOut: HTMLAnchorElement | null = document.querySelector(
- ".leaflet-control-zoom-out",
- );
- if (zoomOut) {
- zoomOut.title = labels.zoomOut;
- zoomOut.setAttribute("aria-label", labels.zoomOut);
- zoomOut.addEventListener("click", () => {
- trackEvent("zoom_out_click");
- });
- }
-
- // Add fullscreen button (custom control, not native to Leaflet)
- const leafletBar: HTMLDivElement | null =
- document.querySelector(".leaflet-bar");
- const fullscreenButton = L.DomUtil.create("a");
- fullscreenButton.classList.add("leaflet-control-full-screen");
- fullscreenButton.title = labels.fullScreen;
- fullscreenButton.role = "button";
- fullscreenButton.ariaLabel = labels.fullScreen;
- fullscreenButton.ariaDisabled = "false";
- const fullscreenImg = L.DomUtil.create(
- "img",
- "inline",
- fullscreenButton,
- ) as HTMLImageElement;
- fullscreenImg.src = "/icons/expand.svg";
- fullscreenImg.alt = get(_)("mapControls.fullScreenAlt");
- fullscreenImg.style.width = "16px";
- fullscreenImg.style.height = "16px";
- fullscreenButton.onclick = function toggleFullscreen() {
- trackEvent("fullscreen_click");
- if (!document.fullscreenElement) {
- mapElement.requestFullscreen().catch((err) => {
- errToast(
- get(_)("errors.fullscreenError", {
- values: { message: err.message, name: err.name },
- }),
- );
- });
- } else {
- document.exitFullscreen();
- }
- };
-
- leafletBar?.append(fullscreenButton);
-
- if (DomEvent) {
- DomEvent.disableClickPropagation(fullscreenButton);
- }
-};
-
-export const geolocate = (
- _L: Leaflet,
- map: LeafletMap,
- LocateControl: typeof import("leaflet.locatecontrol").LocateControl,
- t?: MapControlsTranslations,
-) => {
- const labels = { ...defaultMapControls, ...t };
- const locateTitle = labels.locate;
-
- new LocateControl({
- position: "topright",
- strings: { title: locateTitle },
- }).addTo(map);
-
- // Sync location to userLocationStore so the nearby panel can show distances
- // without requiring the user to click the separate "Enable precise distances" button
- map.on("locationfound", (e) => {
- userLocation.setLocation(e.latlng.lat, e.latlng.lng);
- });
-
- const locateButton: HTMLAnchorElement | null = document.querySelector(
- ".leaflet-bar-part.leaflet-bar-part-single",
- );
- if (locateButton) {
- // Replace default arrow icon with custom crosshairs icon
- locateButton.textContent = "";
- const locateImg = document.createElement("img") as HTMLImageElement;
- locateImg.src = "/icons/locate.svg";
- locateImg.alt = get(_)("mapControls.locateAlt");
- locateImg.style.width = "16px";
- locateImg.style.height = "16px";
- locateButton.appendChild(locateImg);
- locateButton.title = locateTitle;
- locateButton.setAttribute("aria-label", locateTitle);
-
- locateButton.addEventListener("click", () => {
- trackEvent("locate_click");
- });
- }
-};
-
-export const homeMarkerButtons = (
- L: Leaflet,
- map: LeafletMap,
- DomEvent: DomEventType,
- mainMap?: boolean,
- t?: MapControlsTranslations,
-) => {
- const labels = { ...defaultMapControls, ...t };
- const addControlDiv = L.DomUtil.create("div");
-
- // Account button stays in sync with session (SaveAuthPrompt can update it without a reload)
- // and locale (language switcher) — kept in closure so onRemove can unsubscribe.
- let accountSessionUnsubscribe: (() => void) | null = null;
- let accountLocaleUnsubscribe: (() => void) | null = null;
-
- const customControls = L.Control.extend({
- options: {
- position: "topright",
- },
- onAdd: () => {
- addControlDiv.classList.add(
- "leaflet-control-site-links",
- "leaflet-bar",
- "leaflet-control",
- );
-
- // Home button
- const addHomeButton = L.DomUtil.create("a");
- addHomeButton.classList.add("leaflet-control-home");
- addHomeButton.href = "/";
- addHomeButton.title = labels.goToHome;
- addHomeButton.role = "button";
- addHomeButton.ariaLabel = labels.goToHome;
- const homeImg = L.DomUtil.create(
- "img",
- "",
- addHomeButton,
- ) as HTMLImageElement;
- homeImg.src = "/icons/home.svg";
- homeImg.alt = get(_)("mapControls.goToHomeAlt");
- homeImg.style.width = "16px";
- homeImg.style.height = "16px";
- addHomeButton.onclick = () => {
- trackEvent("home_button_click");
- };
- addControlDiv.append(addHomeButton);
-
- if (mainMap) {
- // Add location button
- const addLocationButton = L.DomUtil.create("a");
- addLocationButton.classList.add("leaflet-control-add-location");
- addLocationButton.href = "/add-location";
- addLocationButton.title = labels.addLocation;
- addLocationButton.role = "button";
- addLocationButton.ariaLabel = labels.addLocation;
- const addLocImg = L.DomUtil.create(
- "img",
- "",
- addLocationButton,
- ) as HTMLImageElement;
- addLocImg.src = "/icons/marker.svg";
- addLocImg.alt = get(_)("mapControls.addLocationAlt");
- addLocImg.style.width = "16px";
- addLocImg.style.height = "16px";
- addLocationButton.onclick = () => {
- trackEvent("add_location_click");
- };
- addControlDiv.append(addLocationButton);
-
- // Community map button
- const communityMapButton = L.DomUtil.create("a");
- communityMapButton.classList.add("leaflet-control-community-map");
- communityMapButton.href = "/communities/map";
- communityMapButton.title = labels.communityMap;
- communityMapButton.role = "button";
- communityMapButton.ariaLabel = labels.communityMap;
- const communityImg = L.DomUtil.create(
- "img",
- "",
- communityMapButton,
- ) as HTMLImageElement;
- communityImg.src = "/icons/group.svg";
- communityImg.alt = get(_)("mapControls.communityMapAlt");
- communityImg.style.width = "16px";
- communityImg.style.height = "16px";
- communityMapButton.onclick = () => {
- trackEvent("community_map_click");
- };
- addControlDiv.append(communityMapButton);
- } else {
- // Merchant map button (for community map page)
- const merchantMapButton = L.DomUtil.create("a");
- merchantMapButton.classList.add("leaflet-control-merchant-map");
- merchantMapButton.href = "/map";
- merchantMapButton.title = labels.merchantMap;
- merchantMapButton.role = "button";
- merchantMapButton.ariaLabel = labels.merchantMap;
- const merchantImg = L.DomUtil.create(
- "img",
- "",
- merchantMapButton,
- ) as HTMLImageElement;
- merchantImg.src = "/icons/shopping.svg";
- merchantImg.alt = get(_)("mapControls.merchantMapAlt");
- merchantImg.style.width = "16px";
- merchantImg.style.height = "16px";
- addControlDiv.append(merchantMapButton);
- }
-
- // Account / Log in button — href + labels are driven by a session/locale subscription
- // so the button stays correct after in-place auth flows (SaveAuthPrompt, logout, etc.).
- const accountButton = L.DomUtil.create("a");
- accountButton.classList.add("leaflet-control-account");
- accountButton.role = "button";
- const accountImg = L.DomUtil.create(
- "img",
- "",
- accountButton,
- ) as HTMLImageElement;
- accountImg.src = "/icons/account.svg";
- accountImg.style.width = "16px";
- accountImg.style.height = "16px";
- accountButton.onclick = () => {
- trackEvent("account_button_click", { logged_in: !!get(session) });
- };
- addControlDiv.append(accountButton);
-
- const updateAccount = () => {
- const loggedIn = !!get(session);
- const t = get(_);
- const title = loggedIn
- ? t("mapControls.account")
- : t("mapControls.login");
- accountButton.href = loggedIn ? "/user/activity" : "/login";
- accountButton.title = title;
- accountButton.ariaLabel = title;
- accountImg.alt = loggedIn
- ? t("mapControls.accountAlt")
- : t("mapControls.loginAlt");
- };
-
- // Both subscribe() calls fire synchronously with the current value, so
- // this also handles initial render — no separate init path needed.
- accountSessionUnsubscribe = session.subscribe(updateAccount);
- accountLocaleUnsubscribe = _.subscribe(updateAccount);
-
- return addControlDiv;
- },
- onRemove: () => {
- accountSessionUnsubscribe?.();
- accountLocaleUnsubscribe?.();
- accountSessionUnsubscribe = null;
- accountLocaleUnsubscribe = null;
- },
- });
-
- map.addControl(new customControls());
- DomEvent.disableClickPropagation(addControlDiv);
-};
-
-export const dataRefresh = (
- L: Leaflet,
- map: LeafletMap,
- DomEvent: DomEventType,
- t?: MapControlsTranslations,
-) => {
- const labels = { ...defaultMapControls, ...t };
- const dataRefreshButton = L.DomUtil.create("a");
-
- const customDataRefreshButton = L.Control.extend({
- options: {
- position: "topright",
- },
- onAdd: () => {
- const dataRefreshDiv = L.DomUtil.create("div");
- dataRefreshDiv.classList.add(
- "leaflet-bar",
- "leaflet-control",
- "data-refresh-div",
- );
- dataRefreshDiv.style.display = "none";
-
- dataRefreshButton.classList.add("leaflet-control-data-refresh");
- dataRefreshButton.title = labels.dataRefreshAvailable;
- dataRefreshButton.role = "button";
- dataRefreshButton.ariaLabel = labels.dataRefreshAvailable;
- dataRefreshButton.ariaDisabled = "false";
- const refreshImg = L.DomUtil.create(
- "img",
- "",
- dataRefreshButton,
- ) as HTMLImageElement;
- refreshImg.src = "/icons/refresh.svg";
- refreshImg.alt = get(_)("mapControls.dataRefreshAlt");
- refreshImg.style.width = "16px";
- refreshImg.style.height = "16px";
- dataRefreshButton.onclick = () => {
- trackEvent("data_refresh_click");
- location.reload();
- };
-
- dataRefreshDiv.append(dataRefreshButton);
-
- return dataRefreshDiv;
- },
- });
-
- map.addControl(new customDataRefreshButton());
- DomEvent.disableClickPropagation(dataRefreshButton);
-};
-
-export const generateLocationIcon = (L: Leaflet) => {
- return L.divIcon({
- className: "div-icon",
- iconSize: [32, 43],
- iconAnchor: [16, 43],
- popupAnchor: [0, -43],
- });
-};
-
-// DivIcon augmented with the Svelte component instances it owns, so the
-// marker that uses the icon can $destroy() them when removed from the map.
-// Without this, every cluster re-render leaks the icon components.
-type DivIconWithInstances = DivIcon & { _iconInstances?: Icon[] };
-
-// Marker augmented with a disposer for the icon's Svelte components.
-// Disposal is explicit — see attachIconCleanup for why we don't hook
-// 'remove'.
-type MarkerWithDisposer = import("leaflet").Marker & {
- _disposeIconInstances?: () => void;
-};
-
-// Wire up Icon-component cleanup for a marker built from a generateIcon()
-// divIcon. Two cleanup paths:
-// 1. setIcon is wrapped so swaps (boost/comment/saved-status updates)
-// destroy the previous icon's components and start tracking the new
-// one's. Covers the most common in-place state change.
-// 2. An explicit disposeMarker(marker) call destroys the currently-
-// tracked instances. Callers must invoke this when they are
-// *permanently* done with the marker (e.g. before clearLayers, in
-// the parent component's onDestroy). DON'T hook on('remove') for
-// this — Leaflet fires 'remove' during temporary layer transitions
-// (removeLayer + addLayer), and destroying components mid-transition
-// leaves the re-added marker with a broken icon.
-export const attachIconCleanup = (
- marker: import("leaflet").Marker,
- initialIcon: DivIcon,
-): void => {
- const readInstances = (i: DivIcon): Icon[] =>
- (i as DivIconWithInstances)._iconInstances ?? [];
- let trackedInstances = readInstances(initialIcon);
-
- const origSetIcon = marker.setIcon.bind(marker);
- marker.setIcon = (nextIcon: DivIcon) => {
- for (const instance of trackedInstances) instance.$destroy();
- trackedInstances = readInstances(nextIcon);
- return origSetIcon(nextIcon);
- };
-
- (marker as MarkerWithDisposer)._disposeIconInstances = () => {
- for (const instance of trackedInstances) instance.$destroy();
- trackedInstances = [];
- };
-};
-
-// Destroy the Svelte Icon components owned by a marker's icon. Safe to
-// call on markers without attached cleanup (no-op). Idempotent within a
-// single marker (a second call after the first finds an empty list).
-export const disposeMarker = (marker: import("leaflet").Marker): void => {
- (marker as MarkerWithDisposer)._disposeIconInstances?.();
-};
-
-export const generateIcon = (
- L: Leaflet,
- icon: string,
- boosted: boolean,
- commentsCount: number,
- isSaved = false,
-): DivIconWithInstances => {
- const className = boosted ? "animate-wiggle" : "";
- const iconTmp = icon !== "question_mark" ? icon : "currency_bitcoin";
-
- const iconContainer = document.createElement("div");
- iconContainer.className =
- "icon-container relative flex items-center justify-center";
-
- const instances: Icon[] = [];
-
- const iconElement = document.createElement("div");
- instances.push(
- new Icon({
- target: iconElement,
- props: {
- w: "20",
- h: "20",
- class: `${className} mt-[5.75px] text-white`,
- icon: iconTmp,
- type: "material",
- },
- }),
- );
- iconContainer.appendChild(iconElement);
-
- if (commentsCount > 0) {
- const commentsCountSpan = document.createElement("span");
- commentsCountSpan.textContent = `${commentsCount}`;
- commentsCountSpan.className =
- "absolute top-1 right-1 transform translate-x-1/2 -translate-y-1/2 " +
- "bg-green-600 text-white text-[10px] font-bold " +
- "rounded-full w-4 h-4 flex items-center justify-center";
- iconContainer.appendChild(commentsCountSpan);
- }
-
- if (isSaved) {
- const savedBadge = document.createElement("span");
- savedBadge.className =
- "saved-badge absolute top-1 left-1 transform -translate-x-1/2 -translate-y-1/2 " +
- "bg-white text-link ring-1 ring-link " +
- "rounded-full w-4 h-4 flex items-center justify-center " +
- "pointer-events-none";
- const savedIcon = document.createElement("div");
- // Wrapper's text-link colors the glyph via currentColor — no class needed here.
- instances.push(
- new Icon({
- target: savedIcon,
- props: {
- w: "10",
- h: "10",
- icon: "bookmark_filled",
- type: "material",
- },
- }),
- );
- savedBadge.appendChild(savedIcon);
- iconContainer.appendChild(savedBadge);
- }
-
- // Accessible label for screen readers
- const accessibleLabel = document.createElement("span");
- accessibleLabel.className = "sr-only";
- accessibleLabel.textContent = isSaved
- ? `${humanizeIconName(icon)} (${get(_)("merchant.savedStatus")})`
- : humanizeIconName(icon);
- iconContainer.appendChild(accessibleLabel);
-
- const divIcon = L.divIcon({
- className: boosted ? "boosted-icon" : "div-icon",
- iconSize: [32, 43],
- iconAnchor: [16, 43],
- popupAnchor: [0, -43],
- html: iconContainer,
- }) as DivIconWithInstances;
- divIcon._iconInstances = instances;
- return divIcon;
-};
-
-export const generateMarker = ({
- lat,
- long,
- icon,
- placeId,
- // element,
- // payment,
- leaflet: L,
- onMarkerClick,
- // verifiedDate,
- // verify,
- // boosted
- // issues
-}: {
- lat: number;
- long: number;
- icon: DivIcon;
- placeId: number | string;
- leaflet: Leaflet;
- onMarkerClick?: (placeId: number | string) => void;
- // verifiedDate: number;
- verify: boolean;
- boosted?: boolean;
- // issues?: Issue[];
-}) => {
- const marker = L.marker([lat, long], { icon });
- attachIconCleanup(marker, icon);
-
- marker.on("click", async () => {
- if (onMarkerClick) {
- onMarkerClick(placeId);
- } else {
- // Fallback to old store-based behavior
- try {
- const response = await api.get(
- `${API_BASE}/v4/places/${placeId}?fields=${buildFieldsParam(PLACE_FIELD_SETS.COMPLETE_PLACE)}`,
- );
- const placeDetails = response.data;
- selectedMerchant.set(placeDetails);
- } catch (error) {
- console.error("Error fetching place details:", error);
- errToast(get(_)("errors.merchantDetailsLoadError"));
- }
- }
- });
-
- return marker;
-};
diff --git a/src/lib/map/viewport.test.ts b/src/lib/map/viewport.test.ts
index b0709a78b..c0b3cac26 100644
--- a/src/lib/map/viewport.test.ts
+++ b/src/lib/map/viewport.test.ts
@@ -1,13 +1,18 @@
+import type { LngLatBounds } from "maplibre-gl";
import { describe, expect, it } from "vitest";
-import type { Place } from "$lib/types";
+import { calculateRadiusKmFromLngLatBounds, getZoomBehavior } from "./viewport";
-import {
- calculateRadiusKm,
- getBufferedBounds,
- getVisiblePlaces,
- getZoomBehavior,
-} from "./viewport";
+// Minimal stub matching the two methods calculateRadiusKmFromLngLatBounds
+// reads off LngLatBounds. Cheaper than instantiating MapLibre's real class.
+const stubBounds = (
+ center: { lat: number; lng: number },
+ ne: { lat: number; lng: number },
+) =>
+ ({
+ getCenter: () => center,
+ getNorthEast: () => ne,
+ }) as unknown as LngLatBounds;
describe("getZoomBehavior", () => {
it('returns "none" for zoom levels below 11', () => {
@@ -31,225 +36,33 @@ describe("getZoomBehavior", () => {
});
});
-describe("calculateRadiusKm", () => {
- // Mock LatLngBounds object
- const createMockBounds = (
- centerLat: number,
- centerLng: number,
- northEastLat: number,
- northEastLng: number,
- ) => ({
- getCenter: () => ({ lat: centerLat, lng: centerLng }),
- getNorthEast: () => ({ lat: northEastLat, lng: northEastLng }),
- });
-
- it("calculates radius for a small area", () => {
- // Small area around a point (approx 1km span)
- const bounds = createMockBounds(51.5, -0.1, 51.505, -0.095);
- const radius = calculateRadiusKm(
- bounds as Parameters[0],
+describe("calculateRadiusKmFromLngLatBounds", () => {
+ it("returns a positive radius for a standard mid-latitude box", () => {
+ // NYC area: center 40°N -74°W, NE corner 41°N -73°W. The corner is
+ // roughly 140km from center; we assert in a wide band instead of
+ // pinning exact math.
+ const r = calculateRadiusKmFromLngLatBounds(
+ stubBounds({ lat: 40, lng: -74 }, { lat: 41, lng: -73 }),
);
-
- // Should be a small radius (less than 1km)
- expect(radius).toBeGreaterThan(0);
- expect(radius).toBeLessThan(2);
+ expect(r).toBeGreaterThan(100);
+ expect(r).toBeLessThan(160);
});
- it("calculates radius for a larger area", () => {
- // Larger area (approx 10km span)
- const bounds = createMockBounds(51.5, -0.1, 51.55, -0.05);
- const radius = calculateRadiusKm(
- bounds as Parameters[0],
+ it("returns near-zero for a degenerate bounds (center == ne)", () => {
+ const r = calculateRadiusKmFromLngLatBounds(
+ stubBounds({ lat: 0, lng: 0 }, { lat: 0, lng: 0 }),
);
-
- // Should be a larger radius
- expect(radius).toBeGreaterThan(3);
- expect(radius).toBeLessThan(10);
+ expect(r).toBe(0);
});
- it("includes 10% buffer in the calculation", () => {
- // The function multiplies by 1.1 for a buffer
- const bounds = createMockBounds(0, 0, 1, 1);
- const radius = calculateRadiusKm(
- bounds as Parameters[0],
+ it("handles antimeridian-crossing bounds without inflating radius", () => {
+ // Fiji-ish: center 179°E, NE corner -179°E. Naive `ne.lng - center.lng`
+ // is -358° → enormous radius. Normalized to +2°, the corner is ~220km
+ // away at the equator.
+ const r = calculateRadiusKmFromLngLatBounds(
+ stubBounds({ lat: 0, lng: 179 }, { lat: 1, lng: -179 }),
);
-
- // Without buffer would be ~157km, with 10% buffer should be ~173km
- expect(radius).toBeGreaterThan(170);
- expect(radius).toBeLessThan(180);
- });
-
- it("handles equator coordinates", () => {
- const bounds = createMockBounds(0, 0, 0.01, 0.01);
- const radius = calculateRadiusKm(
- bounds as Parameters[0],
- );
-
- // Should return a positive radius
- expect(radius).toBeGreaterThan(0);
- });
-
- it("handles high latitude coordinates", () => {
- // Near the poles (high latitude)
- const bounds = createMockBounds(70, 10, 70.01, 10.01);
- const radius = calculateRadiusKm(
- bounds as Parameters[0],
- );
-
- // Should still return a positive radius
- expect(radius).toBeGreaterThan(0);
- });
-});
-
-describe("getBufferedBounds", () => {
- // Mock leaflet with latLngBounds factory
- const createMockLeaflet = () => ({
- latLngBounds: (coords: [[number, number], [number, number]]) => ({
- _southWest: { lat: coords[0][0], lng: coords[0][1] },
- _northEast: { lat: coords[1][0], lng: coords[1][1] },
- getSouth: () => coords[0][0],
- getWest: () => coords[0][1],
- getNorth: () => coords[1][0],
- getEast: () => coords[1][1],
- contains: (point: [number, number]) => {
- const [lat, lng] = point;
- return (
- lat >= coords[0][0] &&
- lat <= coords[1][0] &&
- lng >= coords[0][1] &&
- lng <= coords[1][1]
- );
- },
- }),
- });
-
- const createMockBounds = (
- south: number,
- west: number,
- north: number,
- east: number,
- ) => ({
- getSouth: () => south,
- getWest: () => west,
- getNorth: () => north,
- getEast: () => east,
- });
-
- it("expands bounds by buffer percentage", () => {
- const leaflet = createMockLeaflet();
- const bounds = createMockBounds(10, 20, 12, 22); // 2x2 degree box
-
- const buffered = getBufferedBounds(
- leaflet as unknown as Parameters[0],
- bounds as unknown as Parameters[1],
- 0.2, // 20% buffer
- );
-
- // Original span is 2 degrees, 20% buffer = 0.4 degrees on each side
- expect(buffered.getSouth()).toBe(9.6); // 10 - 0.4
- expect(buffered.getNorth()).toBe(12.4); // 12 + 0.4
- expect(buffered.getWest()).toBe(19.6); // 20 - 0.4
- expect(buffered.getEast()).toBe(22.4); // 22 + 0.4
- });
-
- it("handles zero buffer", () => {
- const leaflet = createMockLeaflet();
- const bounds = createMockBounds(10, 20, 12, 22);
-
- const buffered = getBufferedBounds(
- leaflet as unknown as Parameters[0],
- bounds as unknown as Parameters[1],
- 0,
- );
-
- expect(buffered.getSouth()).toBe(10);
- expect(buffered.getNorth()).toBe(12);
- });
-});
-
-describe("getVisiblePlaces", () => {
- const createMockLeaflet = () => ({
- latLngBounds: (coords: [[number, number], [number, number]]) => ({
- contains: (point: [number, number]) => {
- const [lat, lng] = point;
- return (
- lat >= coords[0][0] &&
- lat <= coords[1][0] &&
- lng >= coords[0][1] &&
- lng <= coords[1][1]
- );
- },
- }),
- });
-
- const createMockBounds = (
- south: number,
- west: number,
- north: number,
- east: number,
- ) => ({
- getSouth: () => south,
- getWest: () => west,
- getNorth: () => north,
- getEast: () => east,
- });
-
- const createMockPlace = (id: number, lat: number, lon: number): Place =>
- ({
- id,
- lat,
- lon,
- }) as Place;
-
- it("filters places within bounds", () => {
- const leaflet = createMockLeaflet();
- const bounds = createMockBounds(10, 20, 12, 22);
- const places = [
- createMockPlace(1, 11, 21), // inside
- createMockPlace(2, 15, 25), // outside
- createMockPlace(3, 10.5, 20.5), // inside
- ];
-
- const visible = getVisiblePlaces(
- leaflet as unknown as Parameters[0],
- places,
- bounds as unknown as Parameters[2],
- 0, // no buffer for simplicity
- );
-
- expect(visible).toHaveLength(2);
- expect(visible.map((p) => p.id)).toEqual([1, 3]);
- });
-
- it("returns empty array for empty places", () => {
- const leaflet = createMockLeaflet();
- const bounds = createMockBounds(10, 20, 12, 22);
-
- const visible = getVisiblePlaces(
- leaflet as unknown as Parameters[0],
- [],
- bounds as unknown as Parameters[2],
- 0,
- );
-
- expect(visible).toHaveLength(0);
- });
-
- it("applies buffer to include places slightly outside viewport", () => {
- const leaflet = createMockLeaflet();
- const bounds = createMockBounds(10, 20, 12, 22); // 2x2 degree box
- const places = [
- createMockPlace(1, 11, 21), // inside original
- createMockPlace(2, 9.8, 21), // outside original, inside with 20% buffer (0.4 deg)
- ];
-
- const visible = getVisiblePlaces(
- leaflet as unknown as Parameters[0],
- places,
- bounds as unknown as Parameters[2],
- 0.2, // 20% buffer
- );
-
- expect(visible).toHaveLength(2);
+ expect(r).toBeGreaterThan(150);
+ expect(r).toBeLessThan(300);
});
});
diff --git a/src/lib/map/viewport.ts b/src/lib/map/viewport.ts
index cd08f4f4e..c89ed6027 100644
--- a/src/lib/map/viewport.ts
+++ b/src/lib/map/viewport.ts
@@ -1,7 +1,6 @@
-import type { LatLngBounds } from "leaflet";
+import type { LngLatBounds } from "maplibre-gl";
import { MERCHANT_LIST_LOW_ZOOM, MERCHANT_LIST_MIN_ZOOM } from "$lib/constants";
-import type { Leaflet, Place } from "$lib/types";
export type ZoomBehavior = "none" | "api-with-limit" | "local-markers";
@@ -13,52 +12,27 @@ export function getZoomBehavior(zoom: number): ZoomBehavior {
return "none"; // Below zoom 11
}
-// Calculate radius from map center to corner (Haversine formula)
-export const calculateRadiusKm = (bounds: LatLngBounds): number => {
+// MapLibre-shaped haversine for the enrichment fetch radius. No 10%
+// buffer: the enrichment fetch wants a tight radius matching the
+// visible viewport.
+//
+// `ne.lng - center.lng` is normalized to the shortest signed angular
+// distance — across the antimeridian (e.g. center near 179°, NE near
+// -179°) the raw difference would be ~-358° and `dLon` would represent
+// a circumnavigation, blowing the radius up to ~half the planet.
+export const calculateRadiusKmFromLngLatBounds = (
+ bounds: LngLatBounds,
+): number => {
const center = bounds.getCenter();
- const corner = bounds.getNorthEast();
-
+ const ne = bounds.getNorthEast();
const R = 6371; // Earth radius in km
- const dLat = ((corner.lat - center.lat) * Math.PI) / 180;
- const dLon = ((corner.lng - center.lng) * Math.PI) / 180;
+ const dLat = ((ne.lat - center.lat) * Math.PI) / 180;
+ const dLngDeg = ((ne.lng - center.lng + 540) % 360) - 180;
+ const dLon = (dLngDeg * Math.PI) / 180;
const a =
- Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.sin(dLat / 2) ** 2 +
Math.cos((center.lat * Math.PI) / 180) *
- Math.cos((corner.lat * Math.PI) / 180) *
- Math.sin(dLon / 2) *
- Math.sin(dLon / 2);
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
- return R * c * 1.1; // Add 10% buffer
-};
-
-// Get expanded bounds with buffer for preloading
-export const getBufferedBounds = (
- leaflet: Leaflet,
- bounds: LatLngBounds,
- bufferPercent: number,
-): LatLngBounds => {
- const latDiff = bounds.getNorth() - bounds.getSouth();
- const lngDiff = bounds.getEast() - bounds.getWest();
- const latBuffer = latDiff * bufferPercent;
- const lngBuffer = lngDiff * bufferPercent;
-
- return leaflet.latLngBounds([
- [bounds.getSouth() - latBuffer, bounds.getWest() - lngBuffer],
- [bounds.getNorth() + latBuffer, bounds.getEast() + lngBuffer],
- ]);
-};
-
-// Get places visible in current viewport with buffer
-export const getVisiblePlaces = (
- leaflet: Leaflet,
- places: Place[],
- bounds: LatLngBounds,
- bufferPercent: number,
-): Place[] => {
- if (!bounds) return [];
-
- const bufferedBounds = getBufferedBounds(leaflet, bounds, bufferPercent);
- return places.filter((place) =>
- bufferedBounds.contains([place.lat, place.lon]),
- );
+ Math.cos((ne.lat * Math.PI) / 180) *
+ Math.sin(dLon / 2) ** 2;
+ return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
diff --git a/src/lib/map/viewportCache.ts b/src/lib/map/viewportCache.ts
new file mode 100644
index 000000000..fee08a901
--- /dev/null
+++ b/src/lib/map/viewportCache.ts
@@ -0,0 +1,48 @@
+import localforage from "localforage";
+
+// Last-viewport persistence for /map. Legacy /map stored a Leaflet
+// LatLngBounds object under "coords"; this rewrite stores
+// {lat, lng, zoom} under a fresh, versioned key so a user landing on a
+// stale prod-Leaflet build and then this branch (or vice-versa)
+// doesn't accidentally read the other format. The center/zoom shape is
+// simpler and matches what the hash format already uses.
+
+export type CachedView = { lat: number; lng: number; zoom: number };
+
+const STORAGE_KEY = "coords-next-v1";
+
+const isValidView = (v: unknown): v is CachedView => {
+ if (!v || typeof v !== "object") return false;
+ const view = v as Record;
+ return (
+ typeof view.lat === "number" &&
+ Number.isFinite(view.lat) &&
+ view.lat >= -90 &&
+ view.lat <= 90 &&
+ typeof view.lng === "number" &&
+ Number.isFinite(view.lng) &&
+ view.lng >= -180 &&
+ view.lng <= 180 &&
+ typeof view.zoom === "number" &&
+ Number.isFinite(view.zoom)
+ );
+};
+
+export const loadCachedView = async (): Promise => {
+ try {
+ const raw = await localforage.getItem(STORAGE_KEY);
+ return isValidView(raw) ? raw : null;
+ } catch {
+ return null;
+ }
+};
+
+export const saveCachedView = async (view: CachedView): Promise => {
+ try {
+ await localforage.setItem(STORAGE_KEY, view);
+ } catch (err) {
+ // IndexedDB unavailable / quota exceeded — non-fatal, the user
+ // just loses last-viewport restore on next visit.
+ console.error("Error caching map viewport:", err);
+ }
+};
diff --git a/src/lib/map/webgl.ts b/src/lib/map/webgl.ts
new file mode 100644
index 000000000..9d2fbe33c
--- /dev/null
+++ b/src/lib/map/webgl.ts
@@ -0,0 +1,20 @@
+// MapLibre GL JS requires WebGL. Older Android WebViews, restricted
+// enterprise browsers, privacy-hardened Firefox configurations, and
+// devices with hardware acceleration disabled can all fail to provide
+// a context — in which case MapLibre throws and the map container
+// renders blank. Callers should check this before instantiating a Map
+// and fall back to a static message instead.
+
+export const hasWebGL = (): boolean => {
+ if (typeof document === "undefined") return false;
+ try {
+ const canvas = document.createElement("canvas");
+ const ctx =
+ canvas.getContext("webgl2") ||
+ canvas.getContext("webgl") ||
+ canvas.getContext("experimental-webgl");
+ return ctx !== null;
+ } catch {
+ return false;
+ }
+};
diff --git a/src/lib/materialIcons.ts b/src/lib/materialIcons.ts
new file mode 100644
index 000000000..bfe9939f9
--- /dev/null
+++ b/src/lib/materialIcons.ts
@@ -0,0 +1,34 @@
+// Shared Material Icon name resolution. Used by the Icon component (which
+// renders icons via @iconify/svelte) and by the MapLibre sprite pipeline
+// (which fetches the SVG from the Iconify API to bake into pin sprites).
+// Single source of truth — previously duplicated in Icon.svelte and
+// maplibreSprites.ts.
+
+// Maps a place/UI icon name to a fully-qualified Iconify name when the
+// default `ic:outline-` form doesn't exist or isn't the one we want.
+export const materialExceptions: Record = {
+ camping: "material-symbols:camping-rounded",
+ gate: "material-symbols:gate",
+ cooking: "material-symbols:cooking",
+ dentistry: "material-symbols:dentistry",
+ sauna: "material-symbols:sauna",
+ info_outline: "material-symbols:info-outline",
+ skull: "material-symbols:skull",
+ currency_bitcoin: "material-symbols:currency-bitcoin",
+ close_round: "ic:round-close",
+ my_location: "material-symbols:my-location-rounded",
+ bookmark_filled: "ic:baseline-bookmark-added",
+ account_circle_filled: "ic:baseline-account-circle",
+ // Missing from ic:outline — fall back to material-symbols
+ potted_plant: "material-symbols:potted-plant-outline",
+ footprint: "material-symbols:footprint-outline",
+ water_pump: "material-symbols:water-pump-outline",
+ adult_content: "material-symbols:explicit-outline",
+ raven: "material-symbols:raven-outline",
+ surgical: "material-symbols:surgical-outline",
+};
+
+// Resolves a Material icon name to its Iconify name: an explicit override
+// from the table, else the default `ic:outline-` form.
+export const resolveMaterialIcon = (icon: string): string =>
+ materialExceptions[icon] ?? `ic:outline-${icon.replace(/_/g, "-")}`;
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 880db3c0c..54cf3ad00 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,12 +1,4 @@
import type { GeoJSON, MultiPolygon, Polygon } from "geojson";
-import type leaflet from "leaflet";
-import type {
- DomEvent,
- FeatureGroup,
- LayerGroup,
- MaplibreGL,
- TileLayer,
-} from "leaflet";
import type { MobileNavIconName } from "$lib/icons/types";
@@ -282,22 +274,6 @@ export type Tagger = {
// FRONTEND
-// leaflet
-
-export type Leaflet = typeof leaflet;
-
-export type DomEventType = typeof DomEvent;
-
-export type MapGroups = { [key: string]: LayerGroup | FeatureGroup.SubGroup };
-
-export type BaseMaps = {
- "OpenFreeMap Liberty": MaplibreGL;
- "OpenFreeMap Dark": MaplibreGL;
- "Carto Positron": MaplibreGL;
- "Carto Dark Matter": MaplibreGL;
- OpenStreetMap: TileLayer;
-};
-
// map
export type Boost = { id: number; name: string; boost: string } | undefined;
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index d86470bf8..41083ce30 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,5 +1,7 @@
@@ -454,7 +464,9 @@ $: $theme !== undefined && mapLoaded === true && toggleTheme();
bind:this={mapElement}
class="z-10 h-[300px] !cursor-crosshair rounded-2xl border-2 border-input !bg-teal md:h-[400px] dark:!bg-dark"
/>
- {#if !mapLoaded}
+ {#if webglUnsupported}
+
+ {:else if !mapLoaded}
{/if}
diff --git a/src/routes/communities/map/+page.svelte b/src/routes/communities/map/+page.svelte
index b91b7bc53..678924b34 100644
--- a/src/routes/communities/map/+page.svelte
+++ b/src/routes/communities/map/+page.svelte
@@ -1,68 +1,289 @@
@@ -292,10 +532,19 @@ onDestroy(async () => {
+
{$_('communityMap.pageTitle')}
-
+
+
+
+ {#if webglUnsupported}
+
+ {/if}
diff --git a/src/routes/communities/map/communities-map.css b/src/routes/communities/map/communities-map.css
new file mode 100644
index 000000000..2f14095b5
--- /dev/null
+++ b/src/routes/communities/map/communities-map.css
@@ -0,0 +1,101 @@
+/*
+ * Polish for MapLibre popups on /communities/map. The default popup
+ * styling is cramped, the close X sits awkwardly inside the content
+ * padding, and dark mode isn't handled at all — the popup stays white
+ * against a dark basemap. Scoped to this route via the
+ * communities-map-page class on the wrapper so /map's drawer isn't
+ * touched.
+ *
+ * Lives outside the component's
diff --git a/src/routes/map/components/MapControls.svelte b/src/routes/map/components/MapControls.svelte
deleted file mode 100644
index 2d46fe4b9..000000000
--- a/src/routes/map/components/MapControls.svelte
+++ /dev/null
@@ -1,105 +0,0 @@
-
diff --git a/src/routes/map/components/MapSearchBar.svelte b/src/routes/map/components/MapSearchBar.svelte
index f00514f39..315ac9bde 100644
--- a/src/routes/map/components/MapSearchBar.svelte
+++ b/src/routes/map/components/MapSearchBar.svelte
@@ -124,7 +124,7 @@ function handleClear() {
class="rounded-full px-4 py-2.5 text-sm font-medium shadow-sm transition-colors md:px-3 md:py-1.5 md:text-xs
{mode === 'search'
? 'bg-link text-white'
- : 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-dark dark:text-white/70 dark:hover:bg-white/10'}"
+ : 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-dark dark:text-white/70 dark:hover:bg-gray-700'}"
>
{$_('search.worldwide')}
@@ -136,7 +136,7 @@ function handleClear() {
class="rounded-full px-4 py-2.5 text-sm font-medium shadow-sm transition-colors md:px-3 md:py-1.5 md:text-xs
{mode === 'nearby'
? 'bg-link text-white'
- : 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-dark dark:text-white/70 dark:hover:bg-white/10'}"
+ : 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-dark dark:text-white/70 dark:hover:bg-gray-700'}"
>
{$_('search.nearby')}{#if isLoadingCount}
... void) | null = null;
+ #docClickHandler: ((e: MouseEvent) => void) | null = null;
+
+ constructor(options: Options) {
+ this.#options = options;
+ this.#current = options.initial;
+ }
+
+ getDefaultPosition(): ControlPosition {
+ return "top-right";
+ }
+
+ onAdd(map: MapLibreMap): HTMLElement {
+ this.#map = map;
+
+ const container = document.createElement("div");
+ container.className =
+ "maplibregl-ctrl maplibregl-ctrl-group maplibre-next-basemaps";
+
+ // Toggle button — anchor styled like the other action controls.
+ const button = document.createElement("a");
+ button.className = "maplibregl-ctrl-icon maplibregl-ctrl-link";
+ button.href = "#";
+ button.tabIndex = 0;
+ button.setAttribute("role", "button");
+ button.setAttribute("aria-haspopup", "true");
+ button.setAttribute("aria-expanded", "false");
+
+ // Inline SVG: stack-of-layers icon (Material `layers-outline`).
+ // Inline rather than fetched so the button renders the moment the
+ // control is added — no flash, no extra request.
+ button.innerHTML = `
`;
+
+ button.addEventListener("click", (e) => {
+ e.preventDefault();
+ this.#toggle();
+ });
+ container.appendChild(button);
+
+ // Popup with one labelled radio per basemap. Hidden until toggled.
+ const popup = document.createElement("div");
+ popup.className = "maplibre-next-basemaps-popup";
+ popup.setAttribute("role", "radiogroup");
+ popup.hidden = true;
+ container.appendChild(popup);
+
+ this.#container = container;
+ this.#button = button;
+ this.#popup = popup;
+
+ this.#renderPopup();
+
+ // Re-render labels when the locale changes.
+ this.#unsubLocale = _.subscribe(() => this.#renderPopup());
+
+ // Click anywhere outside the control closes the popup.
+ this.#docClickHandler = (e) => {
+ if (!this.#container?.contains(e.target as Node)) this.#close();
+ };
+ document.addEventListener("click", this.#docClickHandler);
+
+ return container;
+ }
+
+ onRemove(): void {
+ this.#unsubLocale?.();
+ this.#unsubLocale = null;
+ if (this.#docClickHandler) {
+ document.removeEventListener("click", this.#docClickHandler);
+ this.#docClickHandler = null;
+ }
+ this.#container?.parentNode?.removeChild(this.#container);
+ this.#container = undefined;
+ this.#button = undefined;
+ this.#popup = undefined;
+ this.#map = undefined;
+ }
+
+ #renderPopup(): void {
+ if (!this.#popup) return;
+ this.#popup.innerHTML = "";
+ const groupName = `maplibre-next-basemap-${Math.random().toString(36).slice(2, 8)}`;
+ for (const bm of this.#options.basemaps) {
+ const label = document.createElement("label");
+ label.className = "maplibre-next-basemaps-option";
+
+ const radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = groupName;
+ radio.value = bm.id;
+ radio.checked = bm.id === this.#current;
+ radio.addEventListener("change", () => {
+ if (radio.checked) this.#select(bm.id);
+ });
+
+ const text = document.createElement("span");
+ text.textContent = bm.label;
+
+ label.appendChild(radio);
+ label.appendChild(text);
+ this.#popup.appendChild(label);
+ }
+
+ const title = get(_)("mapControls.basemapTitle", {
+ default: "Basemap",
+ });
+ this.#button?.setAttribute("title", title);
+ this.#button?.setAttribute("aria-label", title);
+ }
+
+ #select(id: BasemapId): void {
+ if (!this.#map) return;
+ if (id === this.#current) {
+ this.#close();
+ return;
+ }
+ // Toggle visibility on the pre-declared raster layers.
+ for (const bm of this.#options.basemaps) {
+ this.#map.setLayoutProperty(
+ bm.id,
+ "visibility",
+ bm.id === id ? "visible" : "none",
+ );
+ }
+ this.#current = id;
+ try {
+ if (isBasemapId(id)) localStorage.setItem(BASEMAP_STORAGE_KEY, id);
+ } catch {
+ // localStorage unavailable; skip persistence
+ }
+ trackEvent("layer_change", { layer: id });
+ this.#close();
+ }
+
+ #toggle(): void {
+ if (!this.#popup) return;
+ if (this.#popup.hidden) this.#open();
+ else this.#close();
+ }
+
+ #open(): void {
+ if (!this.#popup || !this.#button) return;
+ this.#popup.hidden = false;
+ this.#button.setAttribute("aria-expanded", "true");
+ }
+
+ #close(): void {
+ if (!this.#popup || !this.#button) return;
+ this.#popup.hidden = true;
+ this.#button.setAttribute("aria-expanded", "false");
+ }
+}
diff --git a/src/routes/map/controls/BoostToggleControl.ts b/src/routes/map/controls/BoostToggleControl.ts
new file mode 100644
index 000000000..c8309376b
--- /dev/null
+++ b/src/routes/map/controls/BoostToggleControl.ts
@@ -0,0 +1,82 @@
+import type {
+ ControlPosition,
+ IControl,
+ Map as MapLibreMap,
+} from "maplibre-gl";
+import { get } from "svelte/store";
+
+import { trackEvent } from "$lib/analytics";
+import { _, locale } from "$lib/i18n";
+
+import "./controls.css";
+
+// Mirrors /map's BoostControl (MapControls.svelte:20-81): a single
+// ctrl-group with one anchor that toggles the `?boosts=true` URL param.
+// Click triggers a full page reload via `location.search = …`, matching
+// /map's behavior — no live URL listener needed. Tooltip + alt re-render
+// on locale change so the language toggle is reflected without a reload.
+export class BoostToggleControl implements IControl {
+ #container: HTMLDivElement | undefined;
+ #unsubLocale: (() => void) | null = null;
+
+ getDefaultPosition(): ControlPosition {
+ return "top-right";
+ }
+
+ onAdd(_map: MapLibreMap): HTMLElement {
+ const container = document.createElement("div");
+ container.className = "maplibregl-ctrl maplibregl-ctrl-group";
+
+ const boostsActive =
+ typeof window !== "undefined" &&
+ new URLSearchParams(window.location.search).has("boosts");
+
+ const a = document.createElement("a");
+ a.className = "maplibregl-ctrl-icon maplibregl-ctrl-link";
+ a.href = "#";
+ a.tabIndex = 0;
+ a.setAttribute("role", "button");
+ a.setAttribute("aria-disabled", "false");
+
+ const img = document.createElement("img");
+ img.src = boostsActive ? "/icons/boost-solid.svg" : "/icons/boost.svg";
+ img.width = 16;
+ img.height = 16;
+ a.appendChild(img);
+
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ trackEvent("boost_layer_toggle");
+ const currentUrl = new URL(window.location.href);
+ if (currentUrl.searchParams.has("boosts")) {
+ currentUrl.searchParams.delete("boosts");
+ } else {
+ currentUrl.searchParams.set("boosts", "true");
+ }
+ window.location.search = currentUrl.search;
+ });
+
+ container.appendChild(a);
+ this.#container = container;
+
+ const applyLabels = () => {
+ const t = get(_);
+ const label = t("boost.locations");
+ a.title = label;
+ a.setAttribute("aria-label", label);
+ img.alt = t("mapControls.boostAlt");
+ };
+ // subscribe fires synchronously with the current locale so this also
+ // handles initial render.
+ this.#unsubLocale = locale.subscribe(applyLabels);
+
+ return container;
+ }
+
+ onRemove(): void {
+ this.#unsubLocale?.();
+ this.#unsubLocale = null;
+ this.#container?.parentNode?.removeChild(this.#container);
+ this.#container = undefined;
+ }
+}
diff --git a/src/routes/map/controls/DataRefreshControl.ts b/src/routes/map/controls/DataRefreshControl.ts
new file mode 100644
index 000000000..9e5352c00
--- /dev/null
+++ b/src/routes/map/controls/DataRefreshControl.ts
@@ -0,0 +1,85 @@
+import type {
+ ControlPosition,
+ IControl,
+ Map as MapLibreMap,
+} from "maplibre-gl";
+import { get } from "svelte/store";
+
+import { trackEvent } from "$lib/analytics";
+import { _, locale } from "$lib/i18n";
+import { mapUpdates, placesSyncCount } from "$lib/store";
+
+import "./controls.css";
+
+// Mirrors /map's `dataRefresh` (src/lib/map/setup.ts:555): a button that
+// stays hidden until a fresh sync arrives, then reveals itself and
+// reloads the page on click. The reveal condition matches /map's
+// reactive trigger in `+page.svelte:337`:
+// $mapUpdates && $placesSyncCount > 1
+// Subscribing to both stores in onAdd keeps the visibility in sync;
+// onRemove tears the subscriptions down.
+export class DataRefreshControl implements IControl {
+ #container: HTMLDivElement | undefined;
+ #unsubs: Array<() => void> = [];
+
+ getDefaultPosition(): ControlPosition {
+ return "top-right";
+ }
+
+ onAdd(_map: MapLibreMap): HTMLElement {
+ const container = document.createElement("div");
+ container.className = "maplibregl-ctrl maplibregl-ctrl-group";
+ container.style.display = "none";
+
+ const a = document.createElement("a");
+ a.className = "maplibregl-ctrl-icon maplibregl-ctrl-link";
+ a.href = "#";
+ a.setAttribute("role", "button");
+ a.setAttribute("aria-disabled", "false");
+
+ const img = document.createElement("img");
+ img.src = "/icons/refresh.svg";
+ img.width = 16;
+ img.height = 16;
+ a.appendChild(img);
+
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ trackEvent("data_refresh_click");
+ window.location.reload();
+ });
+
+ container.appendChild(a);
+
+ const evaluateVisibility = () => {
+ const visible = get(mapUpdates) && get(placesSyncCount) > 1;
+ container.style.display = visible ? "block" : "none";
+ };
+
+ // Re-render tooltip/aria/alt on locale change.
+ const applyLabels = () => {
+ const t = get(_);
+ const label = t("mapControls.dataRefreshAvailable");
+ a.title = label;
+ a.setAttribute("aria-label", label);
+ img.alt = t("mapControls.dataRefreshAlt");
+ };
+
+ // All subscribe() calls fire synchronously with the current value, so
+ // initial visibility + labels are set immediately — no separate init
+ // path needed.
+ this.#unsubs.push(mapUpdates.subscribe(evaluateVisibility));
+ this.#unsubs.push(placesSyncCount.subscribe(evaluateVisibility));
+ this.#unsubs.push(locale.subscribe(applyLabels));
+
+ this.#container = container;
+ return container;
+ }
+
+ onRemove(): void {
+ for (const u of this.#unsubs) u();
+ this.#unsubs = [];
+ this.#container?.parentNode?.removeChild(this.#container);
+ this.#container = undefined;
+ }
+}
diff --git a/src/routes/map/controls/NavButtonsControl.ts b/src/routes/map/controls/NavButtonsControl.ts
new file mode 100644
index 000000000..75bc83ba0
--- /dev/null
+++ b/src/routes/map/controls/NavButtonsControl.ts
@@ -0,0 +1,175 @@
+import type {
+ ControlPosition,
+ IControl,
+ Map as MapLibreMap,
+} from "maplibre-gl";
+import { get } from "svelte/store";
+
+import { trackEvent } from "$lib/analytics";
+import { _, locale } from "$lib/i18n";
+import { session } from "$lib/session";
+
+import "./controls.css";
+
+// Mirrors /map's `homeMarkerButtons` (src/lib/map/setup.ts) as a
+// MapLibre custom IControl. Two variants:
+// - "main": home → add-location → community map → account (used by /map)
+// - "communities": home → merchant map → account (used by /communities/map)
+// All button tooltips/aria-labels re-translate on locale change so the
+// language toggle reflects across the entire UI without a page reload.
+export type NavButtonsVariant = "main" | "communities";
+
+type ButtonRefs = {
+ a: HTMLAnchorElement;
+ img: HTMLImageElement;
+ titleKey: string;
+ altKey: string;
+};
+
+export class NavButtonsControl implements IControl {
+ #variant: NavButtonsVariant;
+ #container: HTMLDivElement | undefined;
+ #unsubs: Array<() => void> = [];
+ #staticButtons: ButtonRefs[] = [];
+
+ constructor(variant: NavButtonsVariant = "main") {
+ this.#variant = variant;
+ }
+
+ getDefaultPosition(): ControlPosition {
+ return "top-right";
+ }
+
+ onAdd(_map: MapLibreMap): HTMLElement {
+ const container = document.createElement("div");
+ container.className = "maplibregl-ctrl maplibregl-ctrl-group";
+
+ // Home — always present in both variants
+ container.appendChild(
+ this.#createStaticButton({
+ href: "/",
+ titleKey: "mapControls.goToHome",
+ iconSrc: "/icons/home.svg",
+ altKey: "mapControls.goToHomeAlt",
+ onClick: () => trackEvent("home_button_click"),
+ }),
+ );
+
+ if (this.#variant === "main") {
+ container.appendChild(
+ this.#createStaticButton({
+ href: "/add-location",
+ titleKey: "mapControls.addLocation",
+ iconSrc: "/icons/marker.svg",
+ altKey: "mapControls.addLocationAlt",
+ onClick: () => trackEvent("add_location_click"),
+ }),
+ );
+ container.appendChild(
+ this.#createStaticButton({
+ href: "/communities/map",
+ titleKey: "mapControls.communityMap",
+ iconSrc: "/icons/group.svg",
+ altKey: "mapControls.communityMapAlt",
+ onClick: () => trackEvent("community_map_click"),
+ }),
+ );
+ } else {
+ container.appendChild(
+ this.#createStaticButton({
+ href: "/map",
+ titleKey: "mapControls.merchantMap",
+ iconSrc: "/icons/shopping.svg",
+ altKey: "mapControls.merchantMapAlt",
+ onClick: () => {},
+ }),
+ );
+ }
+
+ // Account / Log in — href + labels driven by session subscription so
+ // the button stays correct after in-place auth flows (no reload).
+ const accountBtn = document.createElement("a");
+ accountBtn.className = "maplibregl-ctrl-icon maplibregl-ctrl-link";
+ accountBtn.setAttribute("role", "button");
+ const accountImg = document.createElement("img");
+ accountImg.src = "/icons/account.svg";
+ accountImg.width = 16;
+ accountImg.height = 16;
+ accountBtn.appendChild(accountImg);
+ accountBtn.addEventListener("click", () => {
+ trackEvent("account_button_click", { logged_in: !!get(session) });
+ });
+ container.appendChild(accountBtn);
+
+ const updateAccount = () => {
+ const loggedIn = !!get(session);
+ const tt = get(_);
+ const title = loggedIn
+ ? tt("mapControls.account")
+ : tt("mapControls.login");
+ accountBtn.href = loggedIn ? "/user/activity" : "/login";
+ accountBtn.title = title;
+ accountBtn.setAttribute("aria-label", title);
+ accountImg.alt = loggedIn
+ ? tt("mapControls.accountAlt")
+ : tt("mapControls.loginAlt");
+ };
+
+ const updateStaticButtons = () => {
+ const tt = get(_);
+ for (const b of this.#staticButtons) {
+ const title = tt(b.titleKey);
+ b.a.title = title;
+ b.a.setAttribute("aria-label", title);
+ b.img.alt = tt(b.altKey);
+ }
+ };
+
+ // subscribe() fires synchronously with the current value, so the
+ // initial render flows through these too — no separate init path.
+ this.#unsubs.push(session.subscribe(updateAccount));
+ this.#unsubs.push(
+ locale.subscribe(() => {
+ updateAccount();
+ updateStaticButtons();
+ }),
+ );
+
+ this.#container = container;
+ return container;
+ }
+
+ onRemove(): void {
+ for (const u of this.#unsubs) u();
+ this.#unsubs = [];
+ this.#staticButtons = [];
+ this.#container?.parentNode?.removeChild(this.#container);
+ this.#container = undefined;
+ }
+
+ #createStaticButton(opts: {
+ href: string;
+ titleKey: string;
+ iconSrc: string;
+ altKey: string;
+ onClick: () => void;
+ }): HTMLAnchorElement {
+ const a = document.createElement("a");
+ a.className = "maplibregl-ctrl-icon maplibregl-ctrl-link";
+ a.href = opts.href;
+ a.setAttribute("role", "button");
+ const img = document.createElement("img");
+ img.src = opts.iconSrc;
+ img.width = 16;
+ img.height = 16;
+ a.appendChild(img);
+ a.addEventListener("click", opts.onClick);
+ this.#staticButtons.push({
+ a,
+ img,
+ titleKey: opts.titleKey,
+ altKey: opts.altKey,
+ });
+ return a;
+ }
+}
diff --git a/src/routes/map/controls/controls.css b/src/routes/map/controls/controls.css
new file mode 100644
index 000000000..5f56a1eba
--- /dev/null
+++ b/src/routes/map/controls/controls.css
@@ -0,0 +1,114 @@
+/*
+ * Shared styles for the custom MapLibre IControls in this directory
+ * (BasemapsControl, NavButtonsControl, BoostToggleControl,
+ * DataRefreshControl). Imported by each control's TS module so any page
+ * that mounts the controls — currently /map and /communities/map — gets
+ * the popup positioning, dark-mode treatment, and anchor button styles.
+ *
+ * Lives outside any component's