UNPKG

@independo/leaflet-independo-maps

Version:

Leaflet plugin for displaying points of interest as pictograms.

488 lines (478 loc) 24.8 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('leaflet')) : typeof define === 'function' && define.amd ? define(['exports', 'leaflet'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.LeafletIndependoMaps = {}, global.L)); })(this, (function (exports, L) { 'use strict'; /** * A custom Leaflet layer that displays a pictogram marker at a specified geographical location. * * This marker supports accessibility features, customizable interactions, and flexible styling. * It is designed to be used with the Leaflet library and integrates easily into any Leaflet map. */ class PictogramMarker extends L.Layer { getLatLng() { return this._latlng; } /** * Constructs a new instance of the `PictogramMarker` class. * * @param latlng - The geographical coordinates where the marker should be placed. * @param pictogram - The pictogram object containing the display data (e.g., URL, label, description). * @param pointOfInterest - (Optional) The associated point of interest for this marker. * @param options - (Optional) Configuration options for the marker's behavior and interactivity. * * @example * ```typescript * const latlng = L.latLng(48.20849, 16.37208); * const pictogram = { * id: '1', * url: 'https://example.com/pictogram.png', * displayText: 'Restaurant', * description: 'A fine dining restaurant serving local cuisine.' * }; * * const options = { * addDescription: true, * bringToFrontOnClick: true, * onClick: (pictogram) => { * console.log('Pictogram clicked:', pictogram); * } * }; * * const marker = new PictogramMarker(latlng, pictogram, undefined, options); * marker.addTo(map); * ``` */ constructor(latlng, pictogram, pointOfInterest, options) { var _a, _b, _c, _d; super(); this.map = null; this._latlng = L.latLng(latlng); this._pictogram = pictogram; this._pointOfInterest = pointOfInterest; this.addDescription = (_a = options === null || options === void 0 ? void 0 : options.addDescription) !== null && _a !== void 0 ? _a : false; this.bringToFrontOnClick = (_b = options === null || options === void 0 ? void 0 : options.bringToFrontOnClick) !== null && _b !== void 0 ? _b : true; this.bringToFrontOnHover = (_c = options === null || options === void 0 ? void 0 : options.bringToFrontOnHover) !== null && _c !== void 0 ? _c : true; this.bringToFrontOnFocus = (_d = options === null || options === void 0 ? void 0 : options.bringToFrontOnFocus) !== null && _d !== void 0 ? _d : true; this.onClick = options === null || options === void 0 ? void 0 : options.onClick; } onAdd(map) { this.map = map; this.container = L.DomUtil.create("div", "pictogram-marker-container"); this.container.style.position = "absolute"; this.container.style.transform = "translate(-50%, -100%)"; this.box = L.DomUtil.create("div", "pictogram-marker-box", this.container); const imgWrapper = L.DomUtil.create("div", "pictogram-marker-img-wrapper", this.box); const img = document.createElement("img"); img.src = this._pictogram.url; // Accessible description is in the parent container img.alt = ""; img.setAttribute("aria-hidden", "true"); img.setAttribute("role", "presentation"); imgWrapper.appendChild(img); const label = L.DomUtil.create("div", "pictogram-marker-label", this.box); label.textContent = this._pictogram.displayText; if (this.addDescription && this._pictogram.description) { const description = L.DomUtil.create("div", "pictogram-marker-description", this.box); description.textContent = this._pictogram.description; const id = `pmd-${Math.random().toString(36).substring(7)}`; description.id = id; this.box.setAttribute("aria-describedby", id); description.setAttribute("aria-hidden", "true"); } L.DomUtil.create("div", "pictogram-marker-pointer", this.container); // Add event listeners if (this.onClick) { this.box.addEventListener("click", () => { var _a; return (_a = this.onClick) === null || _a === void 0 ? void 0 : _a.call(this, this._pictogram, this._pointOfInterest); }); this.box.setAttribute("role", "button"); this.box.tabIndex = 0; } // Add the container to the map's overlay pane map.getPanes().overlayPane.appendChild(this.container); // Update the position of the container this.updatePosition(); map.on("zoomend moveend", this.updatePosition, this); this.setupInteractions(); return this; } onRemove(map) { if (this.container) { map.getPanes().overlayPane.removeChild(this.container); } map.off("zoomend moveend", this.updatePosition, this); return this; } updatePosition() { if (!this.map || !this.container) return; const position = this.map.latLngToLayerPoint(this._latlng); this.container.style.left = `${position.x}px`; this.container.style.top = `${position.y}px`; } setupInteractions() { if (!this.container) return; if (!this.box) return; const container = this.container; const box = this.box; if (this.bringToFrontOnClick) { container.addEventListener("click", () => this.toggleInFront(container)); container.addEventListener("keydown", (e) => { if (e.key === "Enter") { this.toggleInFront(container); } }); } if (this.bringToFrontOnHover) { container.addEventListener("mouseenter", () => this.toggleInFront(container)); container.addEventListener("mouseleave", () => this.toggleInFront(container)); } if (this.bringToFrontOnFocus) { box.addEventListener("focus", () => this.toggleInFront(container)); box.addEventListener("blur", () => this.toggleInFront(container)); } if (this.onClick) { container.addEventListener("click", () => { var _a; return (_a = this.onClick) === null || _a === void 0 ? void 0 : _a.call(this, this._pictogram, this._pointOfInterest); }); container.addEventListener("keypress", (e) => { var _a; if (e.key === "Enter") { (_a = this.onClick) === null || _a === void 0 ? void 0 : _a.call(this, this._pictogram, this._pointOfInterest); } }); } } toggleInFront(container) { if (container.style.zIndex === "1000") { container.style.zIndex = "auto"; } else { container.style.zIndex = "1000"; } } } function pictogramMarker(latlng, pictogram, pointOfInterest, options) { return new PictogramMarker(latlng, pictogram, pointOfInterest, options); } /** * @description Capitalizes the first letter of each word in a string * @param str The string to capitalize */ function nameify(str) { return str .split("_") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } /** * Implementation of the PointOfInterestService interface using the Overpass API. */ class OverpassPOIService { constructor(options) { var _a, _b; this.apiUrl = (options === null || options === void 0 ? void 0 : options.apiUrl) || "https://overpass-api.de/api/interpreter"; this.defaultTypes = (options === null || options === void 0 ? void 0 : options.defaultTypes) || ["shop", "leisure"]; this.osmTypes = (options === null || options === void 0 ? void 0 : options.osmTypes) || ["node"]; this.defaultLimit = (options === null || options === void 0 ? void 0 : options.defaultLimit) || 25; this.maxRetries = (options === null || options === void 0 ? void 0 : options.maxRetries) || 3; this.retryDelay = (options === null || options === void 0 ? void 0 : options.retryDelay) || 1000; this.timeout = (options === null || options === void 0 ? void 0 : options.timeout) || 25; this.deriveNames = (_a = options === null || options === void 0 ? void 0 : options.deriveNames) !== null && _a !== void 0 ? _a : true; this.filterOutNoName = (_b = options === null || options === void 0 ? void 0 : options.filterOutNoName) !== null && _b !== void 0 ? _b : true; } /** @inheritDoc */ async getPointsOfInterest(bounds, options) { let { types = undefined, limit = this.defaultLimit } = options || {}; if ((types === null || types === void 0 ? void 0 : types.length) === 0) { return []; } if (types === undefined) { types = this.defaultTypes; } const bbox = `${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}`; const queries = types.map((type) => `${this.osmTypes.map((osmType) => `${osmType}["${type}"](${bbox});`).join("")}`); const query = ` [out:json][timeout:${this.timeout}]; (${queries.join("")}); out center ${limit}; `; return this.fetchWithRetry(query, this.maxRetries); } async fetchWithRetry(query, retries) { try { const response = await fetch(`${this.apiUrl}?data=${encodeURIComponent(query)}`); if (!response.ok) { if ([409, 504].includes(response.status) && retries > 0) { console.warn(`Rate limit or server error, retrying in ${this.retryDelay}ms...`); await this.delay(this.retryDelay); return this.fetchWithRetry(query, retries - 1); } throw new Error(`Failed to fetch data from Overpass API: ${response.statusText}`); } const data = await response.json(); return this.processResults(data.elements); } catch (error) { console.error("Error fetching POI data from Overpass API:", error); return []; } } processResults(elements) { return elements .filter((element) => { var _a; return element.lat || ((_a = element.center) === null || _a === void 0 ? void 0 : _a.lat); }) .filter((element) => { var _a; return element.lon || ((_a = element.center) === null || _a === void 0 ? void 0 : _a.lon); }) .map((element) => { var _a, _b, _c, _d, _e; return ({ id: element.id.toString(), name: ((_a = element.tags) === null || _a === void 0 ? void 0 : _a.name) || "Unknown", type: ((_b = element.tags) === null || _b === void 0 ? void 0 : _b.amenity) || ((_c = element.tags) === null || _c === void 0 ? void 0 : _c.shop) || "Unknown", latitude: element.lat || ((_d = element.center) === null || _d === void 0 ? void 0 : _d.lat), longitude: element.lon || ((_e = element.center) === null || _e === void 0 ? void 0 : _e.lon), address: this.buildAddress(element.tags), metadata: element || {}, }); }) .map((poi) => { if (!this.deriveNames) return poi; if (poi.name === "Unknown" && poi.type !== "Unknown") { poi.name = nameify(poi.type); } return poi; }) .filter((poi) => !this.filterOutNoName || poi.name !== "Unknown"); } buildAddress(tags) { var _a, _b, _c, _d; const street = (_a = tags === null || tags === void 0 ? void 0 : tags['addr:street']) !== null && _a !== void 0 ? _a : ""; const houseNumber = (_b = tags === null || tags === void 0 ? void 0 : tags['addr:housenumber']) !== null && _b !== void 0 ? _b : ""; const city = (_c = tags === null || tags === void 0 ? void 0 : tags['addr:city']) !== null && _c !== void 0 ? _c : ""; const postcode = (_d = tags === null || tags === void 0 ? void 0 : tags['addr:postcode']) !== null && _d !== void 0 ? _d : ""; const str = `${street} ${houseNumber}, ${postcode} ${city}`.trim(); return str === "," ? undefined : str; } delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } class GlobalSymbolsPictogramService { constructor(options) { var _a; this.apiUrl = (options === null || options === void 0 ? void 0 : options.apiUrl) || "https://globalsymbols.com/api/v1/labels/search"; this.symbolSet = (options === null || options === void 0 ? void 0 : options.symbolSet) || "arasaac"; this.includeTypeInDisplayText = (options === null || options === void 0 ? void 0 : options.includeTypeInDisplayText) || false; this.includeTypeInAriaLabel = (_a = options === null || options === void 0 ? void 0 : options.includeTypeInAriaLabel) !== null && _a !== void 0 ? _a : true; this.cacheStrategy = (options === null || options === void 0 ? void 0 : options.cacheStrategy) || "local-storage"; this.cacheExpiration = (options === null || options === void 0 ? void 0 : options.cacheExpiration) || 604800000; this.cachePrefix = (options === null || options === void 0 ? void 0 : options.cachePrefix) || "global-symbols-pictogram-service"; this.memoryCache = new Map(); if (this.cacheStrategy === "local-storage") { this.cleanupLocalStorage(); } } /** @inheritDoc */ async getPictogram(poi) { const searchTerm = poi.type; const cacheKey = this.getCacheKey(searchTerm); const cachedResponse = this.getFromCache(cacheKey); if (cachedResponse) { return this.constructPictogram(poi, cachedResponse); } const queryParams = new URLSearchParams({ query: searchTerm, language: "eng", language_iso_format: "639-3", limit: "1", symbolSet: this.symbolSet, }); try { const response = await fetch(`${this.apiUrl}?${queryParams.toString()}`); if (!response.ok) { throw new Error(`Failed to fetch pictogram: ${response.statusText}`); } const data = await response.json(); this.saveToCache(cacheKey, data); return this.constructPictogram(poi, data); } catch (error) { console.error(`Error fetching pictogram for POI: ${searchTerm}`, error); throw error; } } constructPictogram(poi, data) { if (data.length > 0) { return { id: data[0].id.toString(), url: data[0].picto.image_url, displayText: this.includeTypeInDisplayText ? `${nameify(poi.type)}: ${poi.name}` : poi.name, ariaLabel: this.includeTypeInAriaLabel ? `${nameify(poi.type)}: ${poi.name}` : poi.name, description: data[0].description, metadata: data[0], }; } else { return undefined; } } getCacheKey(searchTerm) { return `${this.cachePrefix}:${this.symbolSet}:${searchTerm}`; } getFromCache(key) { if (this.cacheStrategy === "in-memory") { const cached = this.memoryCache.get(key); if (cached && Date.now() - cached.timestamp < this.cacheExpiration) { return cached.data; } this.memoryCache.delete(key); } else if (this.cacheStrategy === "local-storage") { const cachedString = localStorage.getItem(key); if (cachedString) { const cached = JSON.parse(cachedString); if (Date.now() - cached.timestamp < this.cacheExpiration) { return cached.data; } localStorage.removeItem(key); // Remove expired item immediately } } return undefined; } saveToCache(key, data) { const cachedResponse = { timestamp: Date.now(), data }; if (this.cacheStrategy === "in-memory") { this.memoryCache.set(key, cachedResponse); } else if (this.cacheStrategy === "local-storage") { localStorage.setItem(key, JSON.stringify(cachedResponse)); } } /** * Cleans up expired items in `localStorage`. * * This method iterates over all keys in `localStorage` and removes any expired cache entries. */ cleanupLocalStorage() { const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key) continue; if (!key.startsWith(this.cachePrefix)) continue; const cachedString = localStorage.getItem(key); if (cachedString) { const cached = JSON.parse(cachedString); if (Date.now() - cached.timestamp >= this.cacheExpiration) { keysToRemove.push(key); } } } // Remove all expired keys after iteration to avoid modifying the storage while iterating keysToRemove.forEach((key) => localStorage.removeItem(key)); } } class GridSortingService { constructor(options) { this.lr = (options === null || options === void 0 ? void 0 : options.lr) || "lr"; this.tb = (options === null || options === void 0 ? void 0 : options.tb) || "tb"; this.rowThreshold = (options === null || options === void 0 ? void 0 : options.rowThreshold) || 64; } /** * Sorts the markers into a 2D grid based on the provided layout direction. * * @param markers An array of {@link PictogramMarker} instances to order. * @param map The map is provided for context, e.g. to determine the current map bounds, mapping the coordinates * to layer points, etc. * @returns A promise resolving to the sorted markers. */ async sortMarkers(markers, map) { const pixelData = markers.map(marker => { const latLng = marker.getLatLng(); const point = map.latLngToLayerPoint(latLng); return { marker, point }; }); // Group markers into rows based on their y-coordinate const rows = []; pixelData.forEach(data => { let row = rows.find(r => Math.abs(r.y - data.point.y) < this.rowThreshold); if (!row) { row = { y: data.point.y, markers: [] }; rows.push(row); } row.markers.push(data); }); // Sort rows based on y-axis (top-to-bottom or bottom-to-top) rows.sort((a, b) => (this.tb === "tb" ? a.y - b.y : b.y - a.y)); // Sort each row based on x-axis (left-to-right or right-to-left) rows.forEach(row => { row.markers.sort((a, b) => (this.lr === "lr" ? a.point.x - b.point.x : b.point.x - a.point.x)); }); // Flatten the 2D grid into a single array return rows .map(row => row.markers.map(data => data.marker)) .reduce((acc, val) => acc.concat(val), []); } } function debounceAsync(func, delay) { let timeout; return (...args) => { if (timeout !== undefined) { clearTimeout(timeout); } timeout = window.setTimeout(() => { func(...args).catch((error) => console.error('Debounced function error:', error)); }, delay); }; } class IndependoMaps { constructor(map, options) { this.debounceInterval = (options === null || options === void 0 ? void 0 : options.debounceInterval) || 300; this.defaultPictogram = options === null || options === void 0 ? void 0 : options.defaultPictogram; this.map = map; this.poiLayerGroup = new L.LayerGroup(); this.poiService = (options === null || options === void 0 ? void 0 : options.poiService) || new OverpassPOIService(options === null || options === void 0 ? void 0 : options.overpassServiceOptions); this.pictogramService = (options === null || options === void 0 ? void 0 : options.pictogramService) || new GlobalSymbolsPictogramService(options === null || options === void 0 ? void 0 : options.globalSymbolsServiceOptions); this.markerSortingService = (options === null || options === void 0 ? void 0 : options.markerSortingService) || new GridSortingService(options === null || options === void 0 ? void 0 : options.gridSortServiceOptions); this.pictogramMarkerOptions = options === null || options === void 0 ? void 0 : options.pictogramMarkerOptions; // Add the POI layer group to the map this.map.addLayer(this.poiLayerGroup); const debouncedUpdateMap = debounceAsync(this.updateMap.bind(this), this.debounceInterval); this.map.on('moveend zoomend', debouncedUpdateMap); // Perform an initial update this.updateMap(); } /** * Updates the map by fetching POIs and adding corresponding markers. */ async updateMap() { const bounds = this.map.getBounds(); const pointsOfInterest = await this.poiService.getPointsOfInterest(bounds); const markerPromises = pointsOfInterest.map(async (poi) => { const latlng = L.latLng(poi.latitude, poi.longitude); const pictogram = await this.pictogramService.getPictogram(poi); if (!pictogram) return this.defaultPictogram; return pictogramMarker(latlng, pictogram, poi, this.pictogramMarkerOptions); }); let layers = (await Promise.all(markerPromises)) .filter((layer) => layer !== undefined); layers = await this.markerSortingService.sortMarkers(layers, this.map); this.poiLayerGroup.clearLayers(); layers.forEach((layer) => this.poiLayerGroup.addLayer(layer)); } } /** * Initializes the Independo Maps plugin on a {@link L.Map} and returns an instance of the plugin. * * @param map The {@link L.Map} to initialize the plugin on. * @param options Optional {@link IndependoMapsOptions} to configure the plugin. * @returns An instance of {@link IndependoMaps}. */ function initIndependoMaps(map, options) { return new IndependoMaps(map, options); } exports.PictogramMarker = PictogramMarker; exports.initIndependoMaps = initIndependoMaps; })); //# sourceMappingURL=leaflet-independo-maps.js.map