@independo/leaflet-independo-maps
Version:
Leaflet plugin for displaying points of interest as pictograms.
488 lines (478 loc) • 24.8 kB
JavaScript
(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