diff --git a/.github/workflows/ci-testing.yml b/.github/workflows/ci-testing.yml deleted file mode 100644 index ee8a5b8ff..000000000 --- a/.github/workflows/ci-testing.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Continuous Testing - -on: [pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: latest - - run: sudo apt-get install xvfb - - run: npm install - - run: npx playwright install --with-deps - - run: npm install -g grunt-cli - - run: grunt default - - run: xvfb-run --auto-servernum -- npx playwright test --grep-invert="popupTabNavigation\.test\.js|layerContextMenuKeyboard\.test\.js" --workers=1 --retries=3 -# - run: xvfb-run --auto-servernum -- npx playwright test --grep="popupTabNavigation\.test\.js|layerContextMenuKeyboard\.test\.js" --workers=1 --retries=3 -# - run: xvfb-run --auto-servernum -- npm run jest - env: - CI: true diff --git a/.gitignore b/.gitignore index 393215552..a8cd4bdd2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ /test-results/ .idea/ *.iml -test.html \ No newline at end of file +test.html +**/.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8d1c0399e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Test Commands +- Build project: `grunt default` +- Format and lint: `grunt format` +- Run tests: `npx playwright test` +- Run single test: `npx playwright test test/e2e/path/to/test.test.js` +- Start test server: `node test/server.js` +- Test with specific browser: `npx playwright test --project=chromium` + +## Code Style Guidelines +- JavaScript: ES6+, esversion 11 +- Formatting: Prettier with singleQuote: true, trailingComma: "none" +- Testing: Playwright for E2E tests, Jest for unit tests +- Style the code like existing files, following established patterns +- Use jshint for linting +- Components use custom HTML elements pattern +- Prefer absolute paths over relative paths +- MapML is a custom extension of HTML for maps +- Prefer async/await in test files +- Error handling should follow existing patterns in similar code +- Include meaningful test descriptions \ No newline at end of file diff --git a/example-tiles-and-features.mapml.xml b/example-tiles-and-features.mapml.xml new file mode 100644 index 000000000..9906fde11 --- /dev/null +++ b/example-tiles-and-features.mapml.xml @@ -0,0 +1,128 @@ + + + sfdem, streams, roads, restricted, archsites, bugsites + + + + + + .bbox {display:none} .capitals-r1-s1{r:48.0; stroke-opacity:1.0; + stroke-dashoffset:0; well-known-name:circle; stroke-width:2.0; opacity:1.0; + fill:#FFFFFF; fill-opacity:1.0; stroke:#000000; stroke-linecap:butt} + + + + + + + + + + + + + + + + + + -11538847.8 5531019.6 -11538860.6 5531045.3 -11538871.2 + 5531077.4 -11538896 5531123.7 -11538899.6 5531138 -11538917.3 5531170.1 + -11539009.6 5531252.1 -11539119.7 5531316.3 -11539254.6 5531433.9 + -11539300.8 5531483.8 -11539332.7 5531548 -11539329.1 5531637.1 -11539311.4 + 5531683.4 -11539279.4 5531701.2 -11539130.2 5531722.6 -11539073.4 5531765.3 + -11539041.4 5531843.7 -11538995.2 5531847.2 -11538959.7 5531829.4 + -11538874.4 5531832.9 -11538847.8 5531846.3 + + + + + + + -11543278.6 5529563.7 -11543275 5529602.9 -11543257.3 5529642 + -11543236 5529656.3 -11543157.8 5529770.3 -11543125.9 5529852.3 -11543111.7 + 5529941.4 -11543108.2 5530012.6 -11543093.9 5530119.5 -11543062 5530240.7 + -11543026.5 5530294.1 -11543005.2 5530301.3 -11542969.7 5530297.7 + -11542937.7 5530272.8 -11542905.7 5530226.4 -11542891.5 5530176.6 + -11542838.3 5530098.2 -11542763.7 5530101.7 -11542593.2 5530190.8 + -11542458.3 5530201.5 -11542397.9 5530233.6 -11542316.2 5530247.8 + -11542270.1 5530369 -11542263 5530415.3 -11542220.3 5530483 -11542170.6 + 5530500.8 -11542081.8 5530486.6 -11542039.2 5530461.6 -11541978.8 5530401 + -11541964.7 5530369 -11541946.9 5530351.2 -11541922 5530301.3 -11541875.9 + 5530247.8 -11541858.1 5530240.7 -11541801.3 5530247.8 -11541765.8 5530287 + -11541662.8 5530426 -11541606 5530486.5 -11541531.4 5530497.2 -11541439 + 5530458 -11541353.8 5530404.5 -11541197.6 5530265.5 -11541140.8 5530197.8 + -11541101.7 5530180 -11541041.4 5530183.6 -11540956.1 5530229.9 -11540856.7 + 5530222.7 -11540743.1 5530190.6 -11540672 5530204.9 -11540604.5 5530304.6 + -11540579.7 5530318.9 -11540448.3 5530329.6 -11540224.5 5530415 -11540157 + 5530429.2 -11540061.1 5530429.2 -11539951.1 5530350.8 -11539890.7 5530322.3 + -11539787.8 5530233.2 -11539752.3 5530204.6 -11539667 5530204.6 -11539599.5 + 5530222.4 -11539535.6 5530265.1 -11539496.5 5530318.6 -11539471.7 5530375.6 + -11539425.4 5530518.1 -11539393.4 5530635.7 -11539372.1 5530678.5 + -11539350.8 5530710.5 -11539283.3 5530739 -11539166.1 5530739 -11539080.9 + 5530706.9 -11539041.8 5530660.5 -11539020.6 5530599.9 -11538985.1 5530557.1 + -11538931.8 5530550 -11538896.3 5530560.7 -11538847.8 5530609.3 + + + + + + + -11543097.4 5528690.3 -11543097.4 5528740.6 -11543118.7 + 5528765.5 -11543186.2 5528797.6 -11543232.4 5528897.4 -11543235.9 5528918.7 + -11543250.1 5528936.6 -11543260.8 5528965.1 -11543267.9 5529072 -11543243 + 5529153.9 -11543161.4 5529275 -11543129.4 5529307.1 -11543129.4 5529381.9 + -11543211.1 5529471 -11543260.8 5529513.8 -11543278.6 5529549.4 -11543278.6 + 5529563.7 + + + + + + + -1.154197545486741E7 5528690.2558668 -1.15419842E7 5528702.6 + -1.15420779E7 5528948.8 -1.15421423E7 5529066.4 -1.15423138E7 5529238.3 + -1.15423223E7 5529233.2 -1.15424015E7 5529256.6 -1.15424412E7 5529352.7 + -1.15424537E7 5529429.8 + + + + + + + -1.15432167E7 5529271.1 -1.1543219E7 5529347.4 -1.15432537E7 + 5529412.6 -1.15433427E7 5529536.3 -1.15433579E7 5529585.2 -1.1543356E7 + 5529639.1 -1.1543219E7 5529868.4 -1.15432153E7 5529937.8 -1.15432021E7 + 5530059.4 -1.15431755E7 5530144.2 -1.15431196E7 5530299.9 -1.15431364E7 + 5530354 -1.15431942E7 5530403 -1.15433533E7 5530481.5 -1.15434197E7 + 5530475.7 -1.15435152E7 5530461.4 -1.15435587E7 5530450.4 -1.15435759E7 + 5530410.2 -1.1543599E7 5530374.3 -1.15436169E7 5530375.2 -1.15436371E7 + 5530437.8 -1.15436496E7 5530524.4 -1.15436859E7 5530638.5 -1.15436064E7 + 5530707.6 -1.15436037E7 5530757.3 -1.1543651E7 5530900.6 -1.154373E7 + 5530962.5 -1.15437836E7 5530985 -1.15438474E7 5531032.3 + + + + + + + -1.15438474E7 5531032.3 -1.15438747E7 5531008.4 + -1.154394996991142E7 5530977.68753614 + + + + + \ No newline at end of file diff --git a/index.html b/index.html index 18f6bf4a3..0cfc4abee 100644 --- a/index.html +++ b/index.html @@ -1,173 +1,43 @@ - - - + index-map.html - - + + - - - - - - - - - - - - - - - - - All cuisines - African - Asian - Cajun - Indian - Italian - Mexican - - - - - - - - - - - - - - - - - - - - All cuisines - African - Asian - Cajun - Indian - Italian - Mexican - - - - - - - + + + + + + + + diff --git a/newXMLDocument.xml b/newXMLDocument.xml new file mode 100644 index 000000000..e4a0571e4 --- /dev/null +++ b/newXMLDocument.xml @@ -0,0 +1,24 @@ + + + Spearfish + + + + + + + + + + + + \ No newline at end of file diff --git a/src/map-feature.js b/src/map-feature.js index 5ae172fd4..785b64c6d 100644 --- a/src/map-feature.js +++ b/src/map-feature.js @@ -1,5 +1,7 @@ import { bounds, point } from 'leaflet'; +import { featureLayer } from './mapml/layers/FeatureLayer.js'; +import { featureRenderer } from './mapml/features/featureRenderer.js'; import { Util } from './mapml/utils/Util.js'; import proj4 from 'proj4'; @@ -180,6 +182,9 @@ export class HTMLFeatureElement extends HTMLElement { this._parentEl.parentElement?.hasAttribute('data-moving') ) return; + if (this._parentEl.nodeName === 'MAP-LINK') { + this._createOrGetFeatureLayer(); + } // use observer to monitor the changes in mapFeature's subtree // (i.e. map-properties, map-featurecaption, map-coordinates) this._observer = new MutationObserver((mutationList) => { @@ -266,7 +271,79 @@ export class HTMLFeatureElement extends HTMLElement { layerToAddTo.addLayer(this._geometry); this._setUpEvents(); } + isFirst() { + // Get the previous element sibling + const prevSibling = this.previousElementSibling; + + // If there's no previous sibling, return true + if (!prevSibling) { + return true; + } + // Compare the node names (tag names) - return true if they're different + return this.nodeName !== prevSibling.nodeName; + } + getPrevious() { + // Check if this is the first element of a sequence + if (this.isFirst()) { + return null; // No previous element available + } + + // Since we know it's not the first, we can safely return the previous element sibling + return this.previousElementSibling; + } + _createOrGetFeatureLayer() { + if (this.isFirst() && this._parentEl._templatedLayer) { + const parentElement = this._parentEl; + + let map = parentElement.getMapEl()._map; + + // Create a new FeatureLayer + this._featureLayer = featureLayer(null, { + // pass the vector layer a renderer of its own, otherwise leaflet + // puts everything into the overlayPane + renderer: featureRenderer(), + // pass the vector layer the container for the parent into which + // it will append its own container for rendering into + pane: parentElement._templatedLayer.getContainer(), + // the bounds will be static, fixed, constant for the lifetime of the layer + layerBounds: parentElement.getBounds(), + zoomBounds: this._getZoomBounds(), + projection: map.options.projection, + mapEl: parentElement.getMapEl(), + onEachFeature: function (properties, geometry) { + if (properties) { + const popupOptions = { + autoClose: false, + autoPan: true, + maxHeight: map.getSize().y * 0.5 - 50, + maxWidth: map.getSize().x * 0.7, + minWidth: 165 + }; + var c = document.createElement('div'); + c.classList.add('mapml-popup-content'); + c.insertAdjacentHTML('afterbegin', properties.innerHTML); + geometry.bindPopup(c, popupOptions); + } + } + }); + this.addFeature(this._featureLayer); + + // add featureLayer to TemplatedFeaturesOrTilesLayerGroup of the parentElement + if ( + parentElement._templatedLayer && + parentElement._templatedLayer.addLayer + ) { + parentElement._templatedLayer.addLayer(this._featureLayer); + } + } else { + // get the previous feature's layer + this._featureLayer = this.getPrevious()?._featureLayer; + if (this._featureLayer) { + this.addFeature(this._featureLayer); + } + } + } _setUpEvents() { ['click', 'focus', 'blur', 'keyup', 'keydown'].forEach((name) => { // when is clicked / focused / blurred diff --git a/src/map-link.js b/src/map-link.js index 600b2f423..8edddaf3f 100644 --- a/src/map-link.js +++ b/src/map-link.js @@ -10,8 +10,8 @@ import { import { Util } from './mapml/utils/Util.js'; import { templatedImageLayer } from './mapml/layers/TemplatedImageLayer.js'; import { templatedTileLayer } from './mapml/layers/TemplatedTileLayer.js'; -import { templatedFeaturesLayer } from './mapml/layers/TemplatedFeaturesLayer.js'; import { templatedPMTilesLayer } from './mapml/layers/TemplatedPMTilesLayer.js'; +import { templatedFeaturesOrTilesLayerGroup } from './mapml/layers/TemplatedFeaturesOrTilesLayerGroup.js'; /* global M */ export class HTMLLinkElement extends HTMLElement { @@ -436,7 +436,8 @@ export class HTMLLinkElement extends HTMLElement { // be loaded as part of a templated layer processing i.e. on moveend // and the generated that implements this should be located // in the parent ._templatedLayer.container root node if - // the _templatedLayer is an instance of TemplatedTileLayer or TemplatedFeaturesLayer + // the _templatedLayer is an instance of TemplatedTileLayer or + // TemplatedFeaturesOrTilesLayerGroup // // if the parent node (or the host of the shadow root parent node) is map-layer, the link should be created in the _layer // container @@ -551,13 +552,19 @@ export class HTMLLinkElement extends HTMLElement { if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); } - this._templatedLayer = templatedFeaturesLayer(this._templateVars, { - zoomBounds: this.getZoomBounds(), - extentBounds: this.getBounds(), - zIndex: this.zIndex, - pane: this.parentExtent._extentLayer.getContainer(), - linkEl: this - }).addTo(this.parentExtent._extentLayer); + // Use the FeaturesTilesLayerGroup to handle both map-feature and map-tile elements + this._templatedLayer = templatedFeaturesOrTilesLayerGroup( + this._templateVars, + { + zoomBounds: this.getZoomBounds(), + extentBounds: this.getBounds(), + zIndex: this.zIndex, + pane: this.parentExtent._extentLayer.getContainer(), + linkEl: this, + projection: this.mapEl._map.options.projection, + renderer: this.mapEl._map.options.renderer + } + ).addTo(this.parentExtent._extentLayer); } else if (this.rel === 'query') { if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); diff --git a/src/map-tile.js b/src/map-tile.js new file mode 100644 index 000000000..5286100bd --- /dev/null +++ b/src/map-tile.js @@ -0,0 +1,231 @@ +import { bounds as Lbounds, point as Lpoint } from 'leaflet'; + +import { Util } from './mapml/utils/Util.js'; +import { mapTileLayer } from './mapml/layers/MapTileLayer.js'; + +/* global M */ + +export class HTMLTileElement extends HTMLElement { + static get observedAttributes() { + return ['row', 'col', 'zoom', 'src']; + } + /* jshint ignore:start */ + #hasConnected; + /* jshint ignore:end */ + get row() { + return +(this.hasAttribute('row') ? this.getAttribute('row') : 0); + } + set row(val) { + var parsedVal = parseInt(val, 10); + if (!isNaN(parsedVal)) { + this.setAttribute('row', parsedVal); + } + } + get col() { + return +(this.hasAttribute('col') ? this.getAttribute('col') : 0); + } + set col(val) { + var parsedVal = parseInt(val, 10); + if (!isNaN(parsedVal)) { + this.setAttribute('col', parsedVal); + } + } + get zoom() { + return +(this.hasAttribute('zoom') ? this.getAttribute('zoom') : 0); + } + set zoom(val) { + var parsedVal = parseInt(val, 10); + if (!isNaN(parsedVal) && parsedVal >= 0 && parsedVal <= 25) { + this.setAttribute('zoom', parsedVal); + } + } + get src() { + return this.hasAttribute('src') ? this.getAttribute('src') : ''; + } + set src(val) { + if (val) { + this.setAttribute('src', val); + } + } + get extent() { + if (!this._extent) { + this._calculateExtent(); + } + return this._extent; + } + constructor() { + // Always call super first in constructor + super(); + } + connectedCallback() { + // initialization is done in connectedCallback, attribute initialization + // calls (which happen first) are effectively ignored, so we should be able + // to rely on them being all correctly set by this time e.g. zoom, row, col + // all now have a value that together identify this tiled bit of space + /* jshint ignore:start */ + this.#hasConnected = true; + /* jshint ignore:end */ + + // Get parent element to determine how to handle the tile + // Need to handle shadow DOM correctly like map-feature does + this._parentElement = + this.parentNode.nodeName.toUpperCase() === 'MAP-LAYER' || + this.parentNode.nodeName.toUpperCase() === 'LAYER-' || + this.parentNode.nodeName.toUpperCase() === 'MAP-LINK' + ? this.parentNode + : this.parentNode.host; + + this._createOrGetTileLayer(); + + // Calculate the extent + //this._calculateExtent(); + } + + disconnectedCallback() { + // If this is a map-tile connected to a tile layer, remove it from the layer + if (this._tileLayer) { + this._tileLayer.removeMapTile(this); + + // If this was the last tile in the layer, clean up the layer + if (this._tileLayer._mapTiles && this._tileLayer._mapTiles.length === 0) { + // Clean up happens in the map-link that created the layer + // The map-link handles this through FeaturesTilesLayerGroup + } + } + } + isFirst() { + // Get the previous element sibling + const prevSibling = this.previousElementSibling; + + // If there's no previous sibling, return true + if (!prevSibling) { + return true; + } + + // Compare the node names (tag names) - return true if they're different + return this.nodeName !== prevSibling.nodeName; + } + getPrevious() { + // Check if this is the first element of a sequence + if (this.isFirst()) { + return null; // No previous element available + } + + // Since we know it's not the first, we can safely return the previous element sibling + return this.previousElementSibling; + } + zoomTo() { + let extent = this.extent; + let map = this.getMapEl()._map, + xmin = extent.topLeft.pcrs.horizontal, + xmax = extent.bottomRight.pcrs.horizontal, + ymin = extent.bottomRight.pcrs.vertical, + ymax = extent.topLeft.pcrs.vertical, + bounds = Lbounds(Lpoint(xmin, ymin), Lpoint(xmax, ymax)), + center = map.options.crs.unproject(bounds.getCenter(true)), + maxZoom = extent.zoom.maxZoom, + minZoom = extent.zoom.minZoom; + map.setView(center, Util.getMaxZoom(bounds, map, minZoom, maxZoom), { + animate: false + }); + } + getMapEl() { + return Util.getClosest(this, 'mapml-viewer,map[is=web-map]'); + } + getLayerEl() { + return Util.getClosest(this, 'map-layer,layer-'); + } + attributeChangedCallback(name, oldValue, newValue) { + if (this.#hasConnected /* jshint ignore:line */) { + switch (name) { + case 'src': + case 'row': + case 'col': + case 'zoom': + if (oldValue !== newValue) { + // If we've already calculated an extent, recalculate it + if (this._extent) { + this._calculateExtent(); + } + + // If this tile is connected to a tile layer, update it + if (this._tileLayer) { + // Remove and re-add to update the tile's position + this._tileLayer.removeMapTile(this); + this._tileLayer.addMapTile(this); + } + } + break; + } + } + } + _createOrGetTileLayer() { + if (this.isFirst()) { + const parentElement = this._parentElement; + + // Create a new MapTileLayer + this._tileLayer = mapTileLayer({ + projection: this.getMapEl()._map.options.projection, + opacity: 1, + pane: parentElement._templatedLayer.getContainer() + }); + this._tileLayer.addMapTile(this); + + // add MapTileLayer to TemplatedFeaturesOrTilesLayerGroup of the parentElement + if ( + parentElement._templatedLayer && + parentElement._templatedLayer.addLayer + ) { + parentElement._templatedLayer.addLayer(this._tileLayer); + } + } else { + // get the previous tile's layer + this._tileLayer = this.getPrevious()?._tileLayer; + if (this._tileLayer) { + this._tileLayer.addMapTile(this); + } + } + } + _calculateExtent() { + const mapEl = this.getMapEl(); + + if (!mapEl || !mapEl._map) { + // Can't calculate extent without a map + return; + } + + const map = mapEl._map; + const projection = map.options.projection; + const tileSize = M[projection].options.crs.tile.bounds.max.x; + + // Convert tile coordinates to pixel bounds + const pixelX = this.col * tileSize; + const pixelY = this.row * tileSize; + const pixelBounds = Lbounds( + Lpoint(pixelX, pixelY), + Lpoint(pixelX + tileSize, pixelY + tileSize) + ); + + // Convert pixel bounds to PCRS bounds + const pcrsBounds = Util.pixelToPCRSBounds( + pixelBounds, + this.zoom, + projection + ); + + // Format the extent similar to feature extents + this._extent = Util._convertAndFormatPCRS( + pcrsBounds, + map.options.crs, + projection + ); + + // Add zoom information + this._extent.zoom = { + minZoom: this.zoom, + maxZoom: this.zoom, + minNativeZoom: this.zoom, + maxNativeZoom: this.zoom + }; + } +} diff --git a/src/mapml-viewer.js b/src/mapml-viewer.js index b22cbfe51..826c8fa60 100644 --- a/src/mapml-viewer.js +++ b/src/mapml-viewer.js @@ -14,6 +14,7 @@ import { HTMLLayerElement } from './map-layer.js'; import { LayerDashElement } from './layer-.js'; import { HTMLMapCaptionElement } from './map-caption.js'; import { HTMLFeatureElement } from './map-feature.js'; +import { HTMLTileElement } from './map-tile.js'; import { HTMLExtentElement } from './map-extent.js'; import { HTMLInputElement } from './map-input.js'; import { HTMLSelectElement } from './map-select.js'; @@ -1491,6 +1492,7 @@ window.customElements.define('map-layer', HTMLLayerElement); window.customElements.define('layer-', LayerDashElement); window.customElements.define('map-caption', HTMLMapCaptionElement); window.customElements.define('map-feature', HTMLFeatureElement); +window.customElements.define('map-tile', HTMLTileElement); window.customElements.define('map-extent', HTMLExtentElement); window.customElements.define('map-input', HTMLInputElement); window.customElements.define('map-select', HTMLSelectElement); @@ -1501,6 +1503,7 @@ export { HTMLLayerElement, HTMLMapCaptionElement, HTMLFeatureElement, + HTMLTileElement, HTMLExtentElement, HTMLInputElement, HTMLSelectElement, diff --git a/src/mapml/index.js b/src/mapml/index.js index 92d55728a..2548596c4 100644 --- a/src/mapml/index.js +++ b/src/mapml/index.js @@ -44,6 +44,7 @@ import { HTMLMapmlViewerElement } from '../mapml-viewer.js'; import { HTMLLayerElement } from '../mapml-viewer.js'; import { HTMLMapCaptionElement } from '../mapml-viewer.js'; import { HTMLFeatureElement } from '../mapml-viewer.js'; +import { HTMLTileElement } from '../mapml-viewer.js'; import { HTMLExtentElement } from '../mapml-viewer.js'; import { HTMLInputElement } from '../mapml-viewer.js'; import { HTMLSelectElement } from '../mapml-viewer.js'; @@ -57,6 +58,7 @@ window.MapML = { HTMLLayerElement, HTMLMapCaptionElement, HTMLFeatureElement, + HTMLTileElement, HTMLExtentElement, HTMLInputElement, HTMLSelectElement, diff --git a/src/mapml/layers/MapFeatureLayerGroup.js b/src/mapml/layers/MapFeatureLayerGroup.js new file mode 100644 index 000000000..9628daddf --- /dev/null +++ b/src/mapml/layers/MapFeatureLayerGroup.js @@ -0,0 +1,556 @@ +import { + FeatureGroup, + DomUtil, + bounds, + SVG, + Util as LeafletUtil, + Browser +} from 'leaflet'; +import { Util } from '../utils/Util.js'; +import { path } from '../features/path.js'; +import { geometry } from '../features/geometry.js'; + +export var MapFeatureLayerGroup = FeatureGroup.extend({ + /* + * This is the feature equivalent of MapTileLayer. The intended use is to + * represent an adjacent sequence of elements found in a templated + * response, but MAYBE we'll be able to use it to represent such a sequence + * in a static document response, too, tbd. + * + * This layer will be inserted into the LayerGroup hosted by the + * immediately after creation, so that its index within the _layers array of + * that LayerGroup will be equal to its z-index within the LayerGroup's container + * + * LayerGroup._layers[0] + * LayerGroup._layers[0] + * LayerGroup._layers[1] <- each set of adjacent features + * LayerGroup._layers[1] <- is a single MapFeatureLayerGroup + * LayerGroup._layers[2] + * LayerGroup._layers[2] + * LayerGroup._layers[3] + * LayerGroup._layers[3] + * and so on + * + */ + initialize: function (mapml, options) { + /* + mapml: + 1. for query: an array of map-feature elements that it fetches + 2. for static templated feature: null + 3. for non-templated feature: map-layer (with no src) or mapml file (with src) + */ + FeatureGroup.prototype.initialize.call(this, null, options); + // this.options.static is false ONLY for tiled vector features + // this._staticFeature is ONLY true when not used by TemplatedFeaturesLayer + // this.options.query true when created by QueryHandler.js + + if (!this.options.tiles) { + // not a tiled vector layer + this._container = null; + if (this.options.query) { + this._container = DomUtil.create( + 'div', + 'leaflet-layer', + this.options.pane + ); + DomUtil.addClass( + this._container, + 'leaflet-pane mapml-vector-container' + ); + } else if (this.options._leafletLayer) { + this._container = DomUtil.create( + 'div', + 'leaflet-layer', + this.options.pane + ); + DomUtil.addClass( + this._container, + 'leaflet-pane mapml-vector-container' + ); + } else { + // if the current featureLayer is a sublayer of templatedFeatureLayer, + // append directly to the templated feature container (passed in as options.pane) + this._container = this.options.pane; + DomUtil.addClass( + this._container, + 'leaflet-pane mapml-vector-container' + ); + } + this.options.renderer.options.pane = this._container; + } + if (this.options.query) { + this._queryFeatures = mapml.features ? mapml.features : mapml; + } else if (!mapml) { + // use this.options._leafletLayer to distinguish the featureLayer constructed for initialization and for templated features / tiles + if (this.options._leafletLayer) { + // this._staticFeature should be set to true to make sure the _getEvents works properly + this._features = {}; + this._staticFeature = true; + } + } + }, + + isVisible: function () { + let map = this.options.mapEl._map; + // if query, isVisible is unconditionally true + if (this.options.query) return true; + // if the featureLayer is for static features, i.e. it is the mapmlvector layer, + // if it is empty, isVisible = false + // this._staticFeature: flag to determine if the featureLayer is used by static features only + // this._features: check if the current static featureLayer is empty + // (Object.keys(this._features).length === 0 => this._features is an empty object) + else if (this._staticFeature && Object.keys(this._features).length === 0) { + return false; + } else { + let mapZoom = map.getZoom(), + zoomBounds = this.zoomBounds || this.options.zoomBounds, + layerBounds = this.layerBounds || this.options.layerBounds, + withinZoom = zoomBounds + ? mapZoom <= zoomBounds.maxZoom && mapZoom >= zoomBounds.minZoom + : false; + return ( + withinZoom && + this._layers && + layerBounds && + layerBounds.overlaps( + Util.pixelToPCRSBounds( + map.getPixelBounds(), + mapZoom, + map.options.projection + ) + ) + ); + } + }, + + onAdd: function (map) { + this._map = map; + FeatureGroup.prototype.onAdd.call(this, map); + if (this._staticFeature) { + this._validateRendering(); + } + if (this._queryFeatures) { + map.on('featurepagination', this.showPaginationFeature, this); + } + }, + addLayer: function (layerToAdd) { + FeatureGroup.prototype.addLayer.call(this, layerToAdd); + if (!this.options.layerBounds) { + this.layerBounds = this.layerBounds + ? this.layerBounds.extend(layerToAdd.layerBounds) + : bounds(layerToAdd.layerBounds.min, layerToAdd.layerBounds.max); + + if (this.zoomBounds) { + if (layerToAdd.zoomBounds.minZoom < this.zoomBounds.minZoom) + this.zoomBounds.minZoom = layerToAdd.zoomBounds.minZoom; + if (layerToAdd.zoomBounds.maxZoom > this.zoomBounds.maxZoom) + this.zoomBounds.maxZoom = layerToAdd.zoomBounds.maxZoom; + if (layerToAdd.zoomBounds.minNativeZoom < this.zoomBounds.minNativeZoom) + this.zoomBounds.minNativeZoom = layerToAdd.zoomBounds.minNativeZoom; + if (layerToAdd.zoomBounds.maxNativeZoom > this.zoomBounds.maxNativeZoom) + this.zoomBounds.maxNativeZoom = layerToAdd.zoomBounds.maxNativeZoom; + } else { + this.zoomBounds = layerToAdd.zoomBounds; + } + } + if (this._staticFeature) { + // TODO: validate the use the feature.zoom which is new (was in createGeometry) + let featureZoom = layerToAdd.options.mapmlFeature.zoom; + if (featureZoom in this._features) { + this._features[featureZoom].push(layerToAdd); + } else { + this._features[featureZoom] = [layerToAdd]; + } + // hide/display features based on the their zoom limits + this._validateRendering(); + } + return this; + }, + addRendering: function (featureToAdd) { + FeatureGroup.prototype.addLayer.call(this, featureToAdd); + }, + onRemove: function (map) { + if (this._queryFeatures) { + map.off('featurepagination', this.showPaginationFeature, this); + delete this._queryFeatures; + DomUtil.remove(this._container); + } + FeatureGroup.prototype.onRemove.call(this, map); + this._map.featureIndex.cleanIndex(); + }, + + removeLayer: function (featureToRemove) { + FeatureGroup.prototype.removeLayer.call(this, featureToRemove); + if (!this.options.layerBounds) { + delete this.layerBounds; + // this ensures that the .extent gets recalculated if needed + delete this.options._leafletLayer.bounds; + delete this.zoomBounds; + // this ensures that the .extent gets recalculated if needed + delete this.options._leafletLayer.zoomBounds; + delete this._layers[featureToRemove._leaflet_id]; + this._removeFromFeaturesList(featureToRemove); + // iterate through all remaining layers + let layerBounds, zoomBounds; + let layerIds = Object.keys(this._layers); + // re-calculate the layerBounds and zoomBounds for the whole layer when + // a feature is permanently removed from the overall layer + // bug alert: it's necessary to create a new bounds object to initialize + // this.layerBounds, to avoid changing the layerBounds of the first geometry + // added to this layer + for (let id of layerIds) { + let layer = this._layers[id]; + if (layerBounds) { + layerBounds.extend(layer.layerBounds); + } else { + layerBounds = bounds(layer.layerBounds.min, layer.layerBounds.max); + } + if (zoomBounds) { + if (layer.zoomBounds.minZoom < zoomBounds.minZoom) + zoomBounds.minZoom = layer.zoomBounds.minZoom; + if (layer.zoomBounds.maxZoom > zoomBounds.maxZoom) + zoomBounds.maxZoom = layer.zoomBounds.maxZoom; + if (layer.zoomBounds.minNativeZoom < zoomBounds.minNativeZoom) + zoomBounds.minNativeZoom = layer.zoomBounds.minNativeZoom; + if (layer.zoomBounds.maxNativeZoom > zoomBounds.maxNativeZoom) + zoomBounds.maxNativeZoom = layer.zoomBounds.maxNativeZoom; + } else { + zoomBounds = {}; + zoomBounds.minZoom = layer.zoomBounds.minZoom; + zoomBounds.maxZoom = layer.zoomBounds.maxZoom; + zoomBounds.minNativeZoom = layer.zoomBounds.minNativeZoom; + zoomBounds.maxNativeZoom = layer.zoomBounds.maxNativeZoom; + } + } + // If the last feature is removed, we should remove the .layerBounds and + // .zoomBounds properties, so that the FeatureLayer may be ignored + if (layerBounds) { + this.layerBounds = layerBounds; + } else { + delete this.layerBounds; + } + if (zoomBounds) { + this.zoomBounds = zoomBounds; + } else { + delete this.zoomBounds; + delete this.options.zoomBounds; + } + } + return this; + }, + /** + * Remove the geomtry rendering (an svg g/ M.Geomtry) from the L.FeatureGroup + * _layers array, so that it's not visible on the map, but still contributes + * to the bounds and zoom limits of the FeatureLayer. + * + * @param {type} featureToRemove + * @returns {undefined} + */ + removeRendering: function (featureToRemove) { + FeatureGroup.prototype.removeLayer.call(this, featureToRemove); + }, + _removeFromFeaturesList: function (feature) { + for (let zoom in this._features) + for (let i = 0; i < this._features[zoom].length; ++i) { + let feature = this._features[zoom][i]; + if (feature._leaflet_id === feature._leaflet_id) { + this._features[zoom].splice(i, 1); + break; + } + } + }, + getEvents: function () { + if (this._staticFeature) { + return { + moveend: this._handleMoveEnd, + zoomend: this._handleZoomEnd + }; + } + return {}; + }, + + // for query + showPaginationFeature: function (e) { + if (this.options.query && this._queryFeatures[e.i]) { + let feature = this._queryFeatures[e.i]; + feature._linkEl.shadowRoot.replaceChildren(); + this.clearLayers(); + // append all map-meta from mapml document + if (feature.meta) { + for (let i = 0; i < feature.meta.length; i++) { + feature._linkEl.shadowRoot.appendChild(feature.meta[i]); + } + } + feature._linkEl.shadowRoot.appendChild(feature); + feature.addFeature(this); + e.popup._navigationBar.querySelector('p').innerText = + e.i + 1 + '/' + this.options._leafletLayer._totalFeatureCount; + e.popup._content + .querySelector('iframe') + .setAttribute('sandbox', 'allow-same-origin allow-forms'); + e.popup._content.querySelector('iframe').srcdoc = + feature.querySelector('map-properties').innerHTML; + // "zoom to here" link need to be re-set for every pagination + this._map.fire('attachZoomLink', { i: e.i, currFeature: feature }); + this._map.once( + 'popupclose', + function (e) { + this.shadowRoot.innerHTML = ''; + }, + feature._linkEl + ); + } + }, + + _handleMoveEnd: function () { + this._removeCSS(); + }, + + _handleZoomEnd: function (e) { + // handle zoom end gets called twice for every zoom, this condition makes it go through once only. + if (this.zoomBounds) { + this._validateRendering(); + } + }, + /* + * _validateRendering prunes the features currently in the _features hashmap (created + * by us). _features categorizes features by zoom, and is used to remove or add + * features from the map based on the map-feature min/max getters. It also + * maintains the _map.featureIndex property, which is used to control the tab + * order for interactive (static) features currently rendered on the map. + * @private + * */ + _validateRendering: function () { + // since features are removed and re-added by zoom level, need to clean the feature index before re-adding + if (this._map) this._map.featureIndex.cleanIndex(); + let map = this._map || this.options._leafletLayer._map; + // it's important that we not try to validate rendering if the FeatureLayer + // isn't actually being rendered (i.e. on the map. the _map property can't + // be used because once it's assigned (by onAdd, above) it's never unassigned. + if (!map.hasLayer(this)) return; + if (this._features) { + for (let zoom in this._features) { + for (let k = 0; k < this._features[zoom].length; k++) { + let geometry = this._features[zoom][k], + renderable = geometry._checkRender( + map.getZoom(), + this.zoomBounds.minZoom, + this.zoomBounds.maxZoom + ); + if (!renderable) { + // insert a placeholder in the dom rendering for the geometry + // so that it retains its layering order when it is next rendered + let placeholder = document.createElement('span'); + placeholder.id = geometry._leaflet_id; + // geometry.defaultOptions.group is the rendered svg g element in sd + geometry.defaultOptions.group.insertAdjacentElement( + 'beforebegin', + placeholder + ); + // removing the rendering without removing the feature from the feature list + this.removeRendering(geometry); + } else if ( + // checking for _map so we do not enter this code block during the connectedCallBack of the map-feature + !map.hasLayer(geometry) && + !geometry._map + ) { + this.addRendering(geometry); + // update the layerbounds + let placeholder = + geometry.defaultOptions.group.parentNode.querySelector( + `span[id="${geometry._leaflet_id}"]` + ); + placeholder.replaceWith(geometry.defaultOptions.group); + } + } + } + } + }, + + _setZoomTransform: function (center, clampZoom) { + var scale = this._map.getZoomScale(this._map.getZoom(), clampZoom), + translate = center + .multiplyBy(scale) + .subtract(this._map._getNewPixelOrigin(center, this._map.getZoom())) + .round(); + + if (Browser.any3d) { + DomUtil.setTransform(this._layers[clampZoom], translate, scale); + } else { + DomUtil.setPosition(this._layers[clampZoom], translate); + } + }, + + /** + * Render a as a Leaflet layer that can be added to a map or + * LayerGroup as required. Kind of a "factory" method. + * + * Uses this.options, so if you need to, you can construct a FeatureLayer + * with options set as required + * + * @param feature - a element + * @param {String} fallbackCS - "gcrs" | "pcrs" + * @param {String} tileZoom - the zoom of the map at which the coordinates will exist + * + * @returns Geometry, which is an L.FeatureGroup + * @public + */ + createGeometry: function (feature, fallbackCS, tileZoom) { + // was let options = this.options, but that was causing unwanted side-effects + // because we were adding .layerBounds and .zoomBounds to it before passing + // to _createGeometry, which meant that FeatureLayer was sprouting + // options.layerBounds and .zoomBounds when it should not have those props + let options = Object.assign({}, this.options); + + if (options.filter && !options.filter(feature)) { + return; + } + + if (feature.classList.length) { + options.className = feature.classList.value; + } + // tileZoom is only used when the map-feature is discarded i.e. for rendering + // vector tiles' feature geometries in bulk (in this case only the geomtry + // is rendered on a tile-shaped FeatureLayer + let zoom = feature.zoom ?? tileZoom, + title = feature.querySelector('map-featurecaption'); + title = title + ? title.innerHTML + : this.options.mapEl.locale.dfFeatureCaption; + + if (feature.querySelector('map-properties')) { + options.properties = document.createElement('div'); + options.properties.classList.add('mapml-popup-content'); + options.properties.insertAdjacentHTML( + 'afterbegin', + feature.querySelector('map-properties').innerHTML + ); + } + let cs = + feature.getElementsByTagName('map-geometry')[0]?.getAttribute('cs') ?? + fallbackCS; + // options.layerBounds and options.zoomBounds are set by TemplatedTileLayer._createFeatures + // each geometry needs bounds so that it can be a good community member of this._layers + if (this._staticFeature || this.options.query) { + options.layerBounds = Util.extentToBounds(feature.extent, 'PCRS'); + options.zoomBounds = feature.extent.zoom; + } + let geom = this._geometryToLayer(feature, options, cs, +zoom, title); + if (geom && Object.keys(geom._layers).length !== 0) { + // if the layer is being used as a query handler output, it will have + // a color option set. Otherwise, copy classes from the feature + if (!geom.options.color && feature.hasAttribute('class')) { + geom.options.className = feature.getAttribute('class'); + } + geom.defaultOptions = geom.options; + this.resetStyle(geom); + + if (options.onEachFeature) { + geom.bindTooltip(title, { interactive: true, sticky: true }); + } + if (feature.tagName.toUpperCase() === 'MAP-FEATURE') { + feature._groupEl = geom.options.group; + } + return geom; + } + }, + + resetStyle: function (layer) { + var style = this.options.style; + if (style) { + // reset any custom styles + LeafletUtil.extend(layer.options, layer.defaultOptions); + this._setLayerStyle(layer, style); + } + }, + + setStyle: function (style) { + this.eachLayer(function (layer) { + this._setLayerStyle(layer, style); + }, this); + }, + + _setLayerStyle: function (layer, style) { + if (typeof style === 'function') { + style = style(layer.feature); + } + if (layer.setStyle) { + layer.setStyle(style); + } + }, + _removeCSS: function () { + let toDelete = this._container.querySelectorAll( + 'link[rel=stylesheet],style' + ); + for (let i = 0; i < toDelete.length; i++) { + this._container.removeChild(toDelete[i]); + } + }, + _geometryToLayer: function (feature, vectorOptions, cs, zoom, title) { + let geom = feature.getElementsByTagName('map-geometry')[0], + group = [], + groupOptions = {}, + svgGroup = SVG.create('g'), + copyOptions = Object.assign({}, vectorOptions); + svgGroup._featureEl = feature; // rendered has a reference to map-feature + if (geom) { + for (let geo of geom.querySelectorAll( + 'map-polygon, map-linestring, map-multilinestring, map-point, map-multipoint' + )) { + group.push( + path( + geo, + Object.assign(copyOptions, { + nativeCS: cs, + nativeZoom: zoom, + projection: this.options.projection, + featureID: feature.id, + group: svgGroup, + wrappers: this._getGeometryParents(geo.parentElement), + featureLayer: this, + _leafletLayer: this.options._leafletLayer + }) + ) + ); + } + let groupOptions = { + group: svgGroup, + mapmlFeature: feature, + featureID: feature.id, + accessibleTitle: title, + onEachFeature: vectorOptions.onEachFeature, + properties: vectorOptions.properties, + _leafletLayer: this.options._leafletLayer, + layerBounds: vectorOptions.layerBounds, + zoomBounds: vectorOptions.zoomBounds + }, + collections = + geom.querySelector('map-multipolygon') || + geom.querySelector('map-geometrycollection'); + if (collections) + groupOptions.wrappers = this._getGeometryParents( + collections.parentElement + ); + return geometry(group, groupOptions); + } + }, + + _getGeometryParents: function (subType, elems = []) { + if (subType && subType.tagName.toUpperCase() !== 'MAP-GEOMETRY') { + if ( + subType.tagName.toUpperCase() === 'MAP-MULTIPOLYGON' || + subType.tagName.toUpperCase() === 'MAP-GEOMETRYCOLLECTION' + ) + return this._getGeometryParents(subType.parentElement, elems); + return this._getGeometryParents( + subType.parentElement, + elems.concat([subType]) + ); + } else { + return elems; + } + } +}); +export var mapFeatureLayerGroup = function (mapml, options) { + return new MapFeatureLayerGroup(mapml, options); +}; diff --git a/src/mapml/layers/MapMLLayer.js b/src/mapml/layers/MapMLLayer.js index 06ea80c52..ee9be2e71 100644 --- a/src/mapml/layers/MapMLLayer.js +++ b/src/mapml/layers/MapMLLayer.js @@ -317,7 +317,7 @@ export var MapMLLayer = LayerGroup.extend({ (zoom && zoom.getAttribute('value')) || '0' ); - var newTiles = mapml.getElementsByTagName('map-tile'); + var newTiles = mapml.querySelectorAll('map-tile'); for (var nt = 0; nt < newTiles.length; nt++) { tiles.appendChild(document.importNode(newTiles[nt], true)); } diff --git a/src/mapml/layers/MapTileLayer.js b/src/mapml/layers/MapTileLayer.js new file mode 100644 index 000000000..0b7b986d6 --- /dev/null +++ b/src/mapml/layers/MapTileLayer.js @@ -0,0 +1,354 @@ +import { GridLayer, DomUtil, point, bounds } from 'leaflet'; +import { Util } from '../utils/Util.js'; + +/** + * Leaflet layer for handling map-tile elements + * Extends GridLayer to create tiles based on map-tile elements + * + * Similar in intent to MapFeatureLayerGroup, which is a receiver for + * map-feature elements' leaflet layer object + * + * This layer will be inserted into the LayerGroup hosted by the + * immediately after creation, so that its index within the _layers array of + * that LayerGroup will be equal to its z-index within the LayerGroup's container + * + * LayerGroup._layers[0] <- each set of adjacent tiles + * LayerGroup._layers[0] <- is a single MapTileLayer + * LayerGroup._layers[1] + * LayerGroup._layers[1] + * LayerGroup._layers[2] + * LayerGroup._layers[2] + * LayerGroup._layers[3] + * LayerGroup._layers[3] + * and so on + */ +export var MapTileLayer = GridLayer.extend({ + initialize: function (options) { + GridLayer.prototype.initialize.call(this, options); + this._mapTiles = options.mapTiles || []; + this._tileMap = {}; + this._pendingTiles = {}; + this._buildTileMap(); + this._container = DomUtil.create('div', 'leaflet-layer'); + DomUtil.addClass(this._container, 'mapml-static-tile-container'); + // Store bounds for visibility checks + // this.layerBounds = this._computeLayerBounds(); + // this.zoomBounds = this._computeZoomBounds(); + }, + + onAdd: function (map) { + this.options.pane.appendChild(this._container); + // Call the parent method + GridLayer.prototype.onAdd.call(this, map); + }, + onRemove: function (map) { + // Clean up pending tiles + this._pendingTiles = {}; + DomUtil.remove(this._container); + }, + + /** + * Adds a map-tile element to the layer + * @param {HTMLTileElement} mapTile - The map-tile element to add + */ + addMapTile: function (mapTile) { + if (!this._mapTiles.includes(mapTile)) { + this._mapTiles.push(mapTile); + this._addToTileMap(mapTile); + // this._updateBounds(); + // this.redraw(); + } + }, + + /** + * Removes a map-tile element from the layer + * @param {HTMLTileElement} mapTile - The map-tile element to remove + */ + removeMapTile: function (mapTile) { + const index = this._mapTiles.indexOf(mapTile); + if (index !== -1) { + this._mapTiles.splice(index, 1); + this._removeFromTileMap(mapTile); + // this._updateBounds(); + // this.redraw(); + } + }, + + /** + * Checks if the layer is currently visible on the map + * @returns {boolean} - True if the layer is visible, false otherwise + */ + isVisible: function () { + if (!this._map) return false; + + const mapZoom = this._map.getZoom(); + // Clamp zoom to layer's zoom bounds + const zoomLevel = Math.max( + this.zoomBounds.minNativeZoom, + Math.min(mapZoom, this.zoomBounds.maxNativeZoom) + ); + + return ( + mapZoom >= this.zoomBounds.minZoom && + mapZoom <= this.zoomBounds.maxZoom && + this.layerBounds.overlaps( + Util.pixelToPCRSBounds( + this._map.getPixelBounds(), + mapZoom, + this._map.options.projection + ) + ) + ); + }, + + /** + * Overrides GridLayer createTile to use map-tile elements + * @param {Object} coords - Tile coordinates + * @param {Function} done - Callback to be called when the tile is ready, with error and tile element params + * @returns {HTMLElement} - The created tile element + */ + createTile: function (coords, done) { + const tileKey = this._tileCoordsToKey(coords); + const tileSize = this.getTileSize(); + + // Create container element + const tileElement = document.createElement('div'); + tileElement.setAttribute('col', coords.x); + tileElement.setAttribute('row', coords.y); + tileElement.setAttribute('zoom', coords.z); + DomUtil.addClass(tileElement, 'leaflet-tile'); + + // Set size + tileElement.style.width = tileSize.x + 'px'; + tileElement.style.height = tileSize.y + 'px'; + + // Find matching tile in our map + const matchingTile = this._tileMap[tileKey]; + + if (matchingTile) { + // Create an image element with the src from the matching map-tile + const img = document.createElement('img'); + img.src = matchingTile.src; + img.width = tileSize.x; + img.height = tileSize.y; + img.alt = ''; + img.setAttribute('role', 'presentation'); + // bidirectional link map-tile element and rendered div + tileElement._mapTile = matchingTile; + matchingTile._tileDiv = tileElement; + + tileElement.appendChild(img); + + // Add the loaded class manually to ensure tile is visible + DomUtil.addClass(tileElement, 'leaflet-tile-loaded'); + + // Call the done callback to signal that the tile is ready + done(null, tileElement); + } else { + // The tile might be added later, register a pending tile + if (!this._pendingTiles) { + this._pendingTiles = {}; + } + + // Store the tile element and done callback for later update + this._pendingTiles[tileKey] = { + element: tileElement, + done: done + }; + + // Don't call done yet - we'll call it when the map-tile is added + } + + return tileElement; + }, + + /** + * Builds the tile map from the current map-tile elements + * @private + */ + _buildTileMap: function () { + this._tileMap = {}; + for (const mapTile of this._mapTiles) { + this._addToTileMap(mapTile); + } + }, + + /** + * Adds a map-tile element to the tile map + * @param {HTMLTileElement} mapTile - The map-tile element to add + * @private + */ + _addToTileMap: function (mapTile) { + const tileKey = `${mapTile.col}:${mapTile.row}:${mapTile.zoom}`; + this._tileMap[tileKey] = mapTile; + + // Check if this tile was requested but not available at that time + if (this._pendingTiles && this._pendingTiles[tileKey]) { + const pendingTile = this._pendingTiles[tileKey]; + const tileElement = pendingTile.element; + const doneCallback = pendingTile.done; + + // Create and append the image to the tile + const tileSize = this.getTileSize(); + const img = document.createElement('img'); + img.src = mapTile.src; + img.width = tileSize.x; + img.height = tileSize.y; + img.alt = ''; + img.setAttribute('role', 'presentation'); + + // bidirectional link map-tile element and rendered div + tileElement._mapTile = mapTile; + mapTile._tileDiv = tileElement; + + tileElement.appendChild(img); + + // Add the loaded class manually to ensure tile is visible + DomUtil.addClass(tileElement, 'leaflet-tile-loaded'); + + // Call the done callback to signal that the tile is now ready + if (doneCallback) { + doneCallback(null, tileElement); + } + + // Remove from pending tiles + delete this._pendingTiles[tileKey]; + } + }, + /** + * Removes a map-tile element from the tile map + * @param {HTMLTileElement} mapTile - The map-tile element to remove + * @private + */ + _removeFromTileMap: function (mapTile) { + const tileKey = `${mapTile.col}:${mapTile.row}:${mapTile.zoom}`; + delete this._tileMap[tileKey]; + + // Also remove from pending tiles if it exists there + if (this._pendingTiles && this._pendingTiles[tileKey]) { + delete this._pendingTiles[tileKey]; + } + }, + + /** + * Updates layer bounds when tiles are added or removed + * @private + */ + _updateBounds: function () { + this.layerBounds = this._computeLayerBounds(); + this.zoomBounds = this._computeZoomBounds(); + }, + + /** + * Computes the layer bounds from all map-tile elements + * @returns {L.Bounds} - The computed layer bounds + * @private + */ + _computeLayerBounds: function () { + if (this._mapTiles.length === 0) { + return bounds([0, 0], [0, 0]); + } + + const tilesByZoom = {}; + const projection = this.options.projection; + const tileSize = M[projection].options.crs.tile.bounds.max.x; + + // Group tiles by zoom + for (const mapTile of this._mapTiles) { + const zoom = mapTile.zoom; + + if (!tilesByZoom[zoom]) { + tilesByZoom[zoom] = []; + } + + tilesByZoom[zoom].push({ + x: mapTile.col, + y: mapTile.row, + z: zoom + }); + } + + // Calculate bounds for each zoom level + const layerBoundsByZoom = {}; + for (const zoom in tilesByZoom) { + const tiles = tilesByZoom[zoom]; + let pixelBounds = null; + + for (const tile of tiles) { + const pixelX = tile.x * tileSize; + const pixelY = tile.y * tileSize; + + if (!pixelBounds) { + pixelBounds = bounds( + point(pixelX, pixelY), + point(pixelX + tileSize, pixelY + tileSize) + ); + } else { + pixelBounds.extend(point(pixelX, pixelY)); + pixelBounds.extend(point(pixelX + tileSize, pixelY + tileSize)); + } + } + + if (pixelBounds) { + layerBoundsByZoom[zoom] = Util.pixelToPCRSBounds( + pixelBounds, + parseInt(zoom, 10), + projection + ); + } + } + + // Combine all zoom level bounds + let combinedBounds = null; + for (const zoom in layerBoundsByZoom) { + if (!combinedBounds) { + combinedBounds = layerBoundsByZoom[zoom].clone(); + } else { + combinedBounds.extend(layerBoundsByZoom[zoom].min); + combinedBounds.extend(layerBoundsByZoom[zoom].max); + } + } + + return combinedBounds || bounds([0, 0], [0, 0]); + }, + + /** + * Computes zoom bounds from all map-tile elements + * @returns {Object} - The computed zoom bounds + * @private + */ + _computeZoomBounds: function () { + const result = { + minZoom: Infinity, + maxZoom: -Infinity, + minNativeZoom: Infinity, + maxNativeZoom: -Infinity + }; + + if (this._mapTiles.length === 0) { + return { + minZoom: 0, + maxZoom: 22, + minNativeZoom: 0, + maxNativeZoom: 22 + }; + } + + // Find min/max zoom from map-tile elements + for (const mapTile of this._mapTiles) { + const zoom = mapTile.zoom; + result.minNativeZoom = Math.min(result.minNativeZoom, zoom); + result.maxNativeZoom = Math.max(result.maxNativeZoom, zoom); + } + + // Set min/max zoom based on native zoom + result.minZoom = result.minNativeZoom; + result.maxZoom = result.maxNativeZoom; + + return result; + } +}); + +export var mapTileLayer = function (options) { + return new MapTileLayer(options); +}; diff --git a/src/mapml/layers/TemplatedFeaturesLayer.js b/src/mapml/layers/TemplatedFeaturesLayer.js deleted file mode 100644 index fe224f369..000000000 --- a/src/mapml/layers/TemplatedFeaturesLayer.js +++ /dev/null @@ -1,376 +0,0 @@ -import { - Layer, - DomUtil, - extend, - setOptions, - Util as LeafletUtil -} from 'leaflet'; - -import { Util } from '../utils/Util.js'; -import { featureLayer } from '../layers/FeatureLayer.js'; -import { featureRenderer } from '../features/featureRenderer.js'; -import { renderStyles } from '../elementSupport/layers/renderStyles.js'; - -export var TemplatedFeaturesLayer = Layer.extend({ - // this and M.ImageLayer could be merged or inherit from a common parent - initialize: function (template, options) { - this._template = template; - this._container = DomUtil.create('div', 'leaflet-layer'); - DomUtil.addClass(this._container, 'mapml-features-container'); - this.zoomBounds = options.zoomBounds; - this.extentBounds = options.extentBounds; - // get rid of duplicate info, it can be confusing - delete options.zoomBounds; - delete options.extentBounds; - this._linkEl = options.linkEl; - setOptions( - this, - extend(options, this._setUpFeaturesTemplateVars(template)) - ); - }, - - isVisible: function () { - let map = this._linkEl.getMapEl()._map; - let mapZoom = map.getZoom(); - let mapBounds = Util.pixelToPCRSBounds( - map.getPixelBounds(), - mapZoom, - map.options.projection - ); - return ( - mapZoom <= this.zoomBounds.maxZoom && - mapZoom >= this.zoomBounds.minZoom && - this.extentBounds.overlaps(mapBounds) - ); - }, - - getEvents: function () { - var events = { - moveend: this._onMoveEnd - }; - return events; - }, - onAdd: function (map) { - this._map = map; - // this causes the layer (this._features) to actually render... - this.options.pane.appendChild(this._container); - var opacity = this.options.opacity || 1, - container = this._container; - if (!this._features) { - this._features = featureLayer(null, { - // pass the vector layer a renderer of its own, otherwise leaflet - // puts everything into the overlayPane - renderer: featureRenderer(), - // pass the vector layer the container for the parent into which - // it will append its own container for rendering into - pane: container, - // the bounds will be static, fixed, constant for the lifetime of the layer - layerBounds: this.extentBounds, - zoomBounds: this.zoomBounds, - opacity: opacity, - projection: map.options.projection, - mapEl: this._linkEl.getMapEl(), - onEachFeature: function (properties, geometry) { - const popupOptions = { - autoClose: false, - autoPan: true, - maxHeight: map.getSize().y * 0.5 - 50, - maxWidth: map.getSize().x * 0.7, - minWidth: 108 - }; - // need to parse as HTML to preserve semantics and styles - var c = document.createElement('div'); - c.classList.add('mapml-popup-content'); - c.insertAdjacentHTML('afterbegin', properties.innerHTML); - geometry.bindPopup(c, popupOptions); - } - }); - extend(this._features.options, { _leafletLayer: this._features }); - this._features._layerEl = this._linkEl.getLayerEl(); - } else { - this._features.eachLayer((layer) => layer.addTo(map)); - } - this._onMoveEnd(); - }, - onRemove: function () { - if (this._features) this._features.eachLayer((layer) => layer.remove()); - DomUtil.remove(this._container); - }, - renderStyles, - - redraw: function () { - this._onMoveEnd(); - }, - - _removeCSS: function () { - let toDelete = this._container.querySelectorAll( - 'link[rel=stylesheet],style' - ); - for (let i = 0; i < toDelete.length; i++) { - let parent = toDelete[i].parentNode; - parent.removeChild(toDelete[i]); - } - }, - - _onMoveEnd: function () { - let history = this._map.options.mapEl._history; - let current = history[history.length - 1]; - let previous = history[history.length - 2] ?? current; - let step = this._template.step; - let mapZoom = this._map.getZoom(); - let steppedZoom = mapZoom; - //If zooming out from one step interval into a lower one or panning, set the stepped zoom - if ( - (step !== '1' && - (mapZoom + 1) % step === 0 && - current.zoom === previous.zoom - 1) || - current.zoom === previous.zoom || - Math.floor(mapZoom / step) * step !== - Math.floor(previous.zoom / step) * step - ) { - steppedZoom = Math.floor(mapZoom / step) * step; - } - //No request needed if in a step interval (unless panning) - else if (mapZoom % this._template.step !== 0) return; - - let scaleBounds = this._map.getPixelBounds( - this._map.getCenter(), - steppedZoom - ); - - // should set this.isVisible properly BEFORE return, otherwise will cause map-layer.validateDisabled not work properly - let url = this._getfeaturesUrl(steppedZoom, scaleBounds); - // No request needed if the current template url is the same as the url to request - if (url === this._url) return; - - // do cleaning up for new request - this._features.clearLayers(); - // shadow may has not yet attached to for the first-time rendering - if (this._linkEl.shadowRoot) { - this._linkEl.shadowRoot.innerHTML = ''; - } - this._removeCSS(); - //Leave the layers cleared if the layer is not visible - if (!this.isVisible() && steppedZoom === mapZoom) { - this._url = ''; - return; - } - - // TODO add preference with a bit less weight than that for text/mapml; 0.8 for application/geo+json; 0.6 - var mapml, - headers = new Headers({ - Accept: 'text/mapml;q=0.9,application/geo+json;q=0.8' - }), - parser = new DOMParser(), - featureLayer = this._features, - linkEl = this._linkEl, - map = this._map, - context = this, - MAX_PAGES = 10, - // TODO: Fetching logic should migrate to map-link - _pullFeatureFeed = function (url, limit) { - return fetch(url, { redirect: 'follow', headers: headers }) - .then(function (response) { - return response.text(); - }) - .then(function (text) { - //TODO wrap this puppy in a try/catch/finally to parse application/geo+json if necessary - mapml = parser.parseFromString(text, 'application/xml'); - var base = new URL( - mapml.querySelector('map-base') - ? mapml.querySelector('map-base').getAttribute('href') - : url - ).href; - url = mapml.querySelector('map-link[rel=next]') - ? mapml.querySelector('map-link[rel=next]').getAttribute('href') - : null; - url = url ? new URL(url, base).href : null; - let frag = document.createDocumentFragment(); - let elements = mapml.querySelectorAll('map-head > *, map-body > *'); - for (let i = 0; i < elements.length; i++) { - frag.appendChild(elements[i]); - } - linkEl.shadowRoot.appendChild(frag); - let features = linkEl.shadowRoot.querySelectorAll('map-feature'); - let featuresReady = []; - for (let i = 0; i < features.length; i++) { - featuresReady.push(features[i].whenReady()); - } - Promise.allSettled(featuresReady).then(() => { - for (let i = 0; i < features.length; i++) { - features[i].addFeature(featureLayer); - } - }); - if (url && --limit) { - return _pullFeatureFeed(url, limit); - } - }); - }; - this._url = url; - _pullFeatureFeed(url, MAX_PAGES) - .then(function () { - map.addLayer(featureLayer); - //Fires event for feature index overlay - map.fire('templatedfeatureslayeradd'); - TemplatedFeaturesLayer.prototype._updateTabIndex(context); - }) - .catch(function (error) { - console.log(error); - }); - }, - setZIndex: function (zIndex) { - this.options.zIndex = zIndex; - this._updateZIndex(); - return this; - }, - _updateTabIndex: function (context) { - let c = context || this; - for (let layerNum in c._features._layers) { - let layer = c._features._layers[layerNum]; - if (layer._path) { - if (layer._path.getAttribute('d') !== 'M0 0') { - layer._path.setAttribute('tabindex', 0); - } else { - layer._path.removeAttribute('tabindex'); - } - if (layer._path.childElementCount === 0) { - let title = document.createElement('title'); - title.innerText = mapEl.locale.dfFeatureCaption; - layer._path.appendChild(title); - } - } - } - }, - _updateZIndex: function () { - if ( - this._container && - this.options.zIndex !== undefined && - this.options.zIndex !== null - ) { - this._container.style.zIndex = this.options.zIndex; - } - }, - _getfeaturesUrl: function (zoom, bounds) { - if (zoom === undefined) zoom = this._map.getZoom(); - if (bounds === undefined) bounds = this._map.getPixelBounds(); - var obj = {}; - if (this.options.feature.zoom) { - obj[this.options.feature.zoom] = zoom; - } - if (this.options.feature.width) { - obj[this.options.feature.width] = this._map.getSize().x; - } - if (this.options.feature.height) { - obj[this.options.feature.height] = this._map.getSize().y; - } - if (this.options.feature.bottom) { - obj[this.options.feature.bottom] = this._TCRSToPCRS(bounds.max, zoom).y; - } - if (this.options.feature.left) { - obj[this.options.feature.left] = this._TCRSToPCRS(bounds.min, zoom).x; - } - if (this.options.feature.top) { - obj[this.options.feature.top] = this._TCRSToPCRS(bounds.min, zoom).y; - } - if (this.options.feature.right) { - obj[this.options.feature.right] = this._TCRSToPCRS(bounds.max, zoom).x; - } - // hidden and other variables that may be associated - for (var v in this.options.feature) { - if ( - ['width', 'height', 'left', 'right', 'top', 'bottom', 'zoom'].indexOf( - v - ) < 0 - ) { - obj[v] = this.options.feature[v]; - } - } - return LeafletUtil.template(this._template.template, obj); - }, - _TCRSToPCRS: function (coords, zoom) { - // TCRS pixel point to Projected CRS point (in meters, presumably) - var map = this._map, - crs = map.options.crs, - loc = crs.transformation.untransform(coords, crs.scale(zoom)); - return loc; - }, - _setUpFeaturesTemplateVars: function (template) { - // process the inputs and create an object named "extent" - // with member properties as follows: - // {width: {name: 'widthvarname'}, // value supplied by map if necessary - // height: {name: 'heightvarname'}, // value supplied by map if necessary - // left: {name: 'leftvarname', axis: 'leftaxisname'}, // axis name drives (coordinate system of) the value supplied by the map - // right: {name: 'rightvarname', axis: 'rightaxisname'}, // axis name (coordinate system of) drives the value supplied by the map - // top: {name: 'topvarname', axis: 'topaxisname'}, // axis name drives (coordinate system of) the value supplied by the map - // bottom: {name: 'bottomvarname', axis: 'bottomaxisname'} // axis name drives (coordinate system of) the value supplied by the map - // zoom: {name: 'zoomvarname'} - // hidden: [{name: name, value: value}]} - - var featuresVarNames = { feature: {} }, - inputs = template.values; - featuresVarNames.feature.hidden = []; - for (var i = 0; i < inputs.length; i++) { - // this can be removed when the spec removes the deprecated inputs... - var type = inputs[i].getAttribute('type'), - units = inputs[i].getAttribute('units'), - axis = inputs[i].getAttribute('axis'), - name = inputs[i].getAttribute('name'), - position = inputs[i].getAttribute('position'), - value = inputs[i].getAttribute('value'), - select = inputs[i].tagName.toLowerCase() === 'map-select'; - if (type === 'width') { - featuresVarNames.feature.width = name; - } else if (type === 'height') { - featuresVarNames.feature.height = name; - } else if (type === 'zoom') { - featuresVarNames.feature.zoom = name; - } else if ( - type === 'location' && - (units === 'pcrs' || units === 'gcrs') - ) { - //