diff --git a/package-lock.json b/package-lock.json index 9dcae99f4..3da544812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,8 @@ "leaflet": "^1.9.4", "leaflet.locatecontrol": "^0.81.1", "mapml-extension": "git+https://github.com/Maps4HTML/mapml-extension", + "media-query-parser": "^3.0.2", + "media-query-solver": "^0.1.3", "path": "^0.12.7", "playwright": "^1.39.0", "proj4": "^2.6.2", @@ -2006,6 +2008,32 @@ "node": ">=8" } }, + "node_modules/media-query-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-3.0.2.tgz", + "integrity": "sha512-3WLXSFQZVuo1d9xpt72Ul0khXy72qgtz1KqDBO6KbmyvMsBsQRcePhYYR2U5QBSOz/u5pb5zj2s2FmgJe8jaTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.5.0" + } + }, + "node_modules/media-query-solver": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/media-query-solver/-/media-query-solver-0.1.3.tgz", + "integrity": "sha512-OkL4tpvexcAOG09rNdbAO1MebJzRa2s0RK7hTO9gVAL1jefbm6rLG9jcRYbwO1M3N8UGhHymMtOXK2ciDy3r8w==", + "dev": true, + "license": "MIT", + "bin": { + "media-query-solver": "dist/cjs/cli.js" + }, + "engines": { + "node": ">=16.0.0 || ^14.13.1" + }, + "peerDependencies": { + "media-query-parser": "^3.0.2" + } + }, "node_modules/media-typer": { "version": "0.3.0", "dev": true, diff --git a/package.json b/package.json index 6c10babc8..0431be43e 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,9 @@ "grunt-prettier": "^2.2.0", "leaflet": "^1.9.4", "leaflet.locatecontrol": "^0.81.1", + "mapml-extension": "git+https://github.com/Maps4HTML/mapml-extension", + "media-query-parser": "^3.0.2", + "media-query-solver": "^0.1.3", "path": "^0.12.7", "playwright": "^1.39.0", "proj4": "^2.6.2", diff --git a/src/mapml-viewer.js b/src/mapml-viewer.js index 5aab491dc..b69b012c5 100644 --- a/src/mapml-viewer.js +++ b/src/mapml-viewer.js @@ -10,6 +10,9 @@ import Proj from 'proj4leaflet/src/proj4leaflet.js'; import { Util } from './mapml/utils/Util.js'; import { DOMTokenList } from './mapml/utils/DOMTokenList.js'; +import { parseMediaQueryList } from 'media-query-parser'; +import { solveMediaQueryList } from 'media-query-solver'; + import { HTMLLayerElement } from './map-layer.js'; import { LayerDashElement } from './layer-.js'; import { HTMLMapCaptionElement } from './map-caption.js'; @@ -986,7 +989,298 @@ export class HTMLMapmlViewerElement extends HTMLElement { } }); } + getTestQuery() { + // Retrieve the map extent + const extent = this.extent; + + // Extract the PCRS values for the bounding box + const topLeftEasting = Math.trunc(extent.topLeft.pcrs.horizontal); + const topLeftNorthing = Math.trunc(extent.topLeft.pcrs.vertical); + const bottomRightEasting = Math.trunc(extent.bottomRight.pcrs.horizontal); + const bottomRightNorthing = Math.trunc(extent.bottomRight.pcrs.vertical); + + // Format the media query string to detect overlap: + // (xminm < xmaxq) and (xmaxm > xminq) and (yminm < ymaxq) and (ymaxm > yminq) + const query = `(map-projection: OSMTILE) and (map-zoom < 14) and (map-top-left-easting < ${bottomRightEasting}) and (map-bottom-right-easting > ${topLeftEasting}) and (map-bottom-right-northing < ${topLeftNorthing}) and (map-top-left-northing > ${bottomRightNorthing})`; + + console.log(query); + let matcher = this.matchMedia(query); + const logResults = (e) => { + if (e.target.matches) { + layer.checked = true; + layer.removeAttribute('hidden'); + } else { + layer.checked = false; + layer.hidden = true; + } + console.log('The query matches the map extent: ' + e.target.matches); + }; + matcher.addEventListener('change', logResults); + + // create a layer to visually represent the query as the map moves + let f = ` +${query} +${topLeftEasting} ${topLeftNorthing} + ${bottomRightEasting} ${topLeftNorthing} ${bottomRightEasting} ${bottomRightNorthing} ${topLeftEasting} ${bottomRightNorthing} + ${topLeftEasting} ${topLeftNorthing}`; + + const parser = new DOMParser(); + const layer = parser + .parseFromString(f, 'text/html') + .querySelector('map-layer'); + this.appendChild(layer); + return { matcher, logResults }; + } + matchMedia(query) { + // useful features for maps: prefers-color-scheme, prefers-lang, projection, zoom, extent + const parsedQuery = parseMediaQueryList(query); + + // less obviously useful: aspect-ratio, orientation, (device) resolution, overflow-block, overflow-inline + + const map = this; + const features = { + 'prefers-lang': { + type: 'discrete', + get values() { + return [navigator.language.substring(0, 2)]; + } + }, + 'map-projection': { + type: 'discrete', + get values() { + return [map.projection.toLowerCase()]; + } + }, + 'map-zoom': { + type: 'range', + valueType: 'integer', + canBeNegative: false, + canBeZero: true, + get extraValues() { + return { + min: 0, + max: map.zoom + }; + } + }, + 'map-top-left-easting': { + type: 'range', + valueType: 'integer', + canBeNegative: true, + canBeZero: true, + get values() { + return [Math.trunc(map.extent.topLeft.pcrs.horizontal)]; + } + }, + 'map-top-left-northing': { + type: 'range', + valueType: 'integer', + canBeNegative: true, + canBeZero: true, + get values() { + return [Math.trunc(map.extent.topLeft.pcrs.vertical)]; + } + }, + 'map-bottom-right-easting': { + type: 'range', + valueType: 'integer', + canBeNegative: true, + canBeZero: true, + get values() { + return [Math.trunc(map.extent.bottomRight.pcrs.horizontal)]; + } + }, + 'map-bottom-right-northing': { + type: 'range', + valueType: 'integer', + canBeNegative: true, + canBeZero: true, + get values() { + return [Math.trunc(map.extent.bottomRight.pcrs.vertical)]; + } + }, + 'prefers-color-scheme': { + type: 'discrete', + get values() { + return [ + window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + ]; + } + }, + 'prefers-map-content': { + type: 'discrete', + get values() { + return M.options.contentPreference; + } + } + }; + + const solveUnknownFeature = (featureNode) => { + let feature = featureNode.feature; + let queryValue = featureNode.value.value; + if (feature === 'prefers-lang') { + return features['prefers-lang'].values.includes(queryValue).toString(); + } else if ( + feature === 'map-zoom' || + feature === 'map-top-left-easting' || + feature === 'map-top-left-northing' || + feature === 'map-bottom-right-easting' || + feature === 'map-bottom-right-northing' + ) { + return solveRangeFeature(featureNode); + } else if (feature === 'map-projection') { + return features['map-projection'].values + .some((p) => p === queryValue) + .toString(); + } else if (feature === 'prefers-color-scheme') { + return features['prefers-color-scheme'].values + .some((s) => s === queryValue) + .toString(); + } else if (feature === 'prefers-map-content') { + return features[feature].values + .some((pref) => pref === queryValue) + .toString(); + } + return 'false'; + }; + let matches = + solveMediaQueryList(parsedQuery, { + features, + solveUnknownFeature + }) === 'true' + ? true + : false; + + function solveRangeFeature(featureNode) { + const { context, feature, value, op } = featureNode; + + if (!feature.startsWith('map-')) { + return 'unknown'; + } + + const currentValue = getMapFeatureValue(feature); + + if (currentValue === undefined) { + return 'unknown'; + } + + if (context === 'value') { + // Plain case: : + // Example: (map-zoom: 15) + return currentValue === value.value ? 'true' : 'false'; + } + + if (context === 'range') { + // Range case: + // Example: (0 <= map-zoom < 15) + switch (op) { + case '<': + return currentValue < value.value ? 'true' : 'false'; + case '<=': + return currentValue <= value.value ? 'true' : 'false'; + case '>': + return currentValue > value.value ? 'true' : 'false'; + case '>=': + return currentValue >= value.value ? 'true' : 'false'; + case '=': + return currentValue === value.value ? 'true' : 'false'; + default: + return 'unknown'; + } + } + + return 'unknown'; // If the context is neither "value" nor "range" + } + + function getMapFeatureValue(feature) { + switch (feature) { + case 'map-zoom': + return map.zoom; + case 'map-top-left-easting': + return Math.trunc(map.extent.topLeft.pcrs.horizontal); + case 'map-top-left-northing': + return Math.trunc(map.extent.topLeft.pcrs.vertical); + case 'map-bottom-right-easting': + return Math.trunc(map.extent.bottomRight.pcrs.horizontal); + case 'map-bottom-right-northing': + return Math.trunc(map.extent.bottomRight.pcrs.vertical); + default: + return undefined; // Unsupported or unknown feature + } + } + + // Make mediaQueryList an EventTarget for dispatching events + const mediaQueryList = Object.assign(new EventTarget(), { + matches, + media: query, + listeners: [], + // this is a client facing api + addEventListener(event, listener) { + if (event === 'change') { + this.listeners.push(listener); + + // Start observing properties only if there is at least one listener + if (this.listeners.length !== 0) { + observeProperties(); + } + EventTarget.prototype.addEventListener.call(this, event, listener); + } + }, + + // this is a client facing api + removeEventListener(event, listener) { + if (event === 'change') { + this.listeners = this.listeners.filter((l) => l !== listener); + + // Stop observing if there are no more listeners + if (this.listeners.length === 0) { + stopObserving(); + } + EventTarget.prototype.removeEventListener.call(this, event, listener); + } + } + }); + + const observeProperties = () => { + const notifyIfChanged = () => { + const newMatches = + solveMediaQueryList(parsedQuery, { + features, + solveUnknownFeature + }) === 'true' + ? true + : false; + if (newMatches !== mediaQueryList.matches) { + mediaQueryList.matches = newMatches; + + // Dispatch a "change" event to notify listeners of the update + mediaQueryList.dispatchEvent(new Event('change')); + } + }; + notifyIfChanged.bind(this); + // Subscribe to internal events for changes in projection, zoom, and extent + this.addEventListener('map-projectionchange', notifyIfChanged); + this.addEventListener('map-moveend', notifyIfChanged); + const colorSchemeQuery = window.matchMedia( + '(prefers-color-scheme: dark)' + ); + colorSchemeQuery.addEventListener('change', notifyIfChanged); + + // Stop observing function + stopObserving = () => { + this.removeEventListener('map-projectionchange', notifyIfChanged); + this.removeEventListener('map-moveend', notifyIfChanged); + colorSchemeQuery.removeEventListener('change', notifyIfChanged); + }; + }; + + let stopObserving; // Declare here so it can be assigned within observeProperties + + return mediaQueryList; + } locate(options) { //options: https://leafletjs.com/reference.html#locate-options if (this._geolocationButton) {