From 42d35e74c48618e37be9d22354ef74436aa53823 Mon Sep 17 00:00:00 2001 From: Quentin BOUILLAGUET Date: Fri, 31 Jul 2026 13:55:06 +0200 Subject: [PATCH 1/4] example: add OGC service browser --- examples/config.json | 11 + .../images/thumbnails/source_ogc_client.svg | 36 +++ examples/index.html | 3 +- examples/jsm/OGCClientHelper.js | 301 ++++++++++++++++++ examples/source_ogc_client.html | 104 ++++++ examples/source_ogc_client.js | 284 +++++++++++++++++ package-lock.json | 131 ++++++++ package.json | 1 + 8 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 examples/images/thumbnails/source_ogc_client.svg create mode 100644 examples/jsm/OGCClientHelper.js create mode 100644 examples/source_ogc_client.html create mode 100644 examples/source_ogc_client.js diff --git a/examples/config.json b/examples/config.json index 1fc51bf25f..95bb23c1c5 100644 --- a/examples/config.json +++ b/examples/config.json @@ -195,6 +195,17 @@ } ] }, + { + "id": "ogc", + "label": "OGC services", + "items": [ + { + "id": "source_ogc_client", + "label": "OGC services browser", + "description": "Connect to an OGC service with @camptocamp/ogc-client and add layers interactively to a globe." + } + ] + }, { "id": "specific_source_options", "label": "Specific source options", diff --git a/examples/images/thumbnails/source_ogc_client.svg b/examples/images/thumbnails/source_ogc_client.svg new file mode 100644 index 0000000000..cb788521fc --- /dev/null +++ b/examples/images/thumbnails/source_ogc_client.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/index.html b/examples/index.html index 81480f46c6..8cd9dadb79 100644 --- a/examples/index.html +++ b/examples/index.html @@ -628,7 +628,8 @@

${category.label}

${category.items.map(item => `
  • - ${item.label} + ${item.label}

    ${item.label}

    diff --git a/examples/jsm/OGCClientHelper.js b/examples/jsm/OGCClientHelper.js new file mode 100644 index 0000000000..d4b3e77a49 --- /dev/null +++ b/examples/jsm/OGCClientHelper.js @@ -0,0 +1,301 @@ +// @ts-check +import { WmtsEndpoint, WmsEndpoint, WfsEndpoint } from '@camptocamp/ogc-client'; +import { Extent, WFSSource, WMSSource, WMTSSource } from 'itowns'; + +/** @typedef {WmtsEndpoint | WmsEndpoint | WfsEndpoint} Endpoint */ +/** @typedef {import('@camptocamp/ogc-client').BoundingBox} BoundingBox */ +/** @typedef {import('@camptocamp/ogc-client').WfsFeatureTypeSummary} WfsFeatureType */ +/** @typedef {import('@camptocamp/ogc-client').WmsLayerFull} WmsLayer */ +/** @typedef {import('@camptocamp/ogc-client').WmtsLayer} WmtsLayer */ +/** @typedef {WmtsLayer['matrixSets'][number]['limits']} MatrixSetLimits */ +/** @typedef {WmtsLayer | WmsLayer | WfsFeatureType} LayerDescriptor */ +/** + * @typedef {object} LayerSource + * @property {WMTSSource | WMSSource | WFSSource} source + * @property {'color' | 'elevation'} layerType + */ + +const SUPPORTED_CRS = ['EPSG:3857', 'EPSG:4326']; +const RASTER_FORMATS = ['image/png', 'image/jpeg']; +const VECTOR_FORMATS = ['application/json', 'application/geojson']; + +function isCrsSupported(/** @type {string} */ crs) { + return SUPPORTED_CRS.includes(crs); +} + +function findCompatibleMatrixSet(/** @type {WmtsLayer} */ layer) { + return layer.matrixSets.find(matrixSet => isCrsSupported(matrixSet.crs)); +} + +function tileMatrixSetLimits(/** @type {MatrixSetLimits} */ limits) { + return Object.fromEntries( + limits.map(({ tileMatrix, ...bounds }) => [tileMatrix, bounds]), + ); +} + +function bboxToExtent(/** @type {BoundingBox} */ bbox, /** @type {string} */ crs) { + const [west, south, east, north] = bbox; + return new Extent(crs).setFromExtent({ west, south, east, north }); +} + +function getWMTSLayerCrs(/** @type {WmtsLayer} */ layer) { + return findCompatibleMatrixSet(layer)?.crs; +} + +function getWMSLayerCrs(/** @type {WmsLayer} */ layer) { + return layer.availableCrs.find(crs => isCrsSupported(crs)); +} + +function getWFSLayerCrs(/** @type {WfsFeatureType} */ featureType) { + return isCrsSupported(featureType.defaultCrs) ? featureType.defaultCrs + : featureType.otherCrs.find(crs => isCrsSupported(crs)); +} + + +function selectRasterFormat(/** @type {string[] | undefined} */ formats) { + if (!formats) { return; } + return RASTER_FORMATS.find(f => formats.includes(f)); +} + +function selectVectorFormat(/** @type {string[] | undefined} */ formats) { + if (!formats) { return; } + return VECTOR_FORMATS.find(f => formats.includes(f)); +} + +/** + * Returns a globe-compatible CRS from a layer descriptor. + * + * @param {LayerDescriptor} layer - A WMS, WFS or WMTS layer descriptor. + * @returns {string | undefined} A compatible CRS, or `undefined` if none is + * available. + */ +export function getLayerCrs(layer) { + if ('matrixSets' in layer) { // WMTS layer + return getWMTSLayerCrs(layer); + } + if ('availableCrs' in layer) { // WMS layer + return getWMSLayerCrs(layer); + } + if ('defaultCrs' in layer) { // WFS layer + return getWFSLayerCrs(layer); + } + return; +} + +/** + * Zoom levels covered by a WMTS layer, or `undefined` for any other layer. + * + * @param {LayerDescriptor} layer - A WMS, WFS or WMTS layer descriptor. + * @returns {{ min: number, max: number } | undefined} + */ +export function getZoom(layer) { + if (!('matrixSets' in layer)) { + return; + } + + const limits = findCompatibleMatrixSet(layer)?.limits; + if (!limits?.length) { + return; + } + + // Note: This is fragile but we are severely limited by the lack of support + // for tilegrids. See WMTSEndpoint#getOpenLayersTileGrid. + const zooms = limits.map(limit => Number(limit.tileMatrix)); + return { min: Math.min(...zooms), max: Math.max(...zooms) }; +} + +/** + * Fetch and parse the capabilities of an OGC service from an URL and the type + * of service. Supported services include WMS, WFS and WMTS. + * + * @param {string} url + * @param {'wmts' | 'wms' | 'wfs'} type + * @returns {Promise} + */ +export function endpointFromUrl(url, type) { + switch (type) { + case 'wmts': + return new WmtsEndpoint(url).isReady(); + case 'wms': + return new WmsEndpoint(url).isReady(); + case 'wfs': + return new WfsEndpoint(url).isReady(); + default: + throw new Error(`Unsupported OGC service type: ${type}`); + } +} + +/** + * List renderable layers advertised by an OGC service. + * + * @param {Endpoint} endpoint - The OGC endpoint to list layers from. + * @returns {LayerDescriptor[]} - A list of layer descriptors. + */ +export function listLayers(endpoint) { + if (endpoint instanceof WmtsEndpoint) { + return endpoint.getLayers(); + } + + if (endpoint instanceof WmsEndpoint) { + return endpoint.getFlattenedLayers() + // Layers without a name are group headers, they cannot be rendered + .filter(layer => layer.name) + .map(layer => endpoint.getLayerByName(layer.name)); + } + + if (endpoint instanceof WfsEndpoint) { + return endpoint.getFeatureTypes() + .map(f => endpoint.getFeatureTypeSummary(f.name)); + } + + throw new Error('Unsupported OGC endpoint'); +} + +/** + * @param {WmtsEndpoint} endpoint + * @param {string} name + * @returns {LayerSource} + */ +function wmtsSource(endpoint, name) { + const layer = endpoint.getLayerByName(name); + if (!layer) { + throw new Error(`WMTS layer "${name}" not found in capabilities`); + } + + const url = endpoint.getServiceInfo()?.getTileUrls?.kvp ?? + layer.resourceLinks?.find(link => link.encoding === 'KVP')?.url; + if (!url) { + throw new Error(`No KVP GetTile URL found for WMTS layer "${name}"`); + } + + const matrixSet = findCompatibleMatrixSet(layer); + if (!matrixSet) { + throw new Error(`No globe matrixSet for WMTS layer "${name}"`); + } + + const format = selectRasterFormat(layer.resourceLinks?.map(link => link.format)); + if (!format) { + throw new Error(`Image format not supported for WMTS layer "${name}"! ${layer.resourceLinks?.map(link => link.format).join(', ')}`); + } + + return { + source: new WMTSSource({ + url, + name: layer.name, + crs: matrixSet.crs, + tileMatrixSet: matrixSet.identifier, + format, + tileMatrixSetLimits: matrixSet.limits?.length + ? tileMatrixSetLimits(matrixSet.limits) + : undefined, + }), + layerType: 'color', + }; +} + +/** + * @param {WmsEndpoint} endpoint + * @param {string} name + * @returns {LayerSource} + */ +function wmsSource(endpoint, name) { + const layer = endpoint.getLayerByName(name); + if (!layer) { + throw new Error(`WMS layer "${name}" not found in capabilities`); + } + + const url = endpoint.getOperationUrl('GetMap') ?? + endpoint.getCapabilitiesUrl(); + + const crs = getWMSLayerCrs(layer); + if (!crs) { + throw new Error(`No globe-compatible CRS for WMS layer "${name}"`); + } + + const format = selectRasterFormat(endpoint.getServiceInfo()?.outputFormats); + if (!format) { + throw new Error(`No image output format for WMS layer "${name}"`); + } + + const bbox = layer.boundingBoxes?.[crs]; + if (!bbox) { + throw new Error(`No bounding box for WMS layer "${name}"`); + } + + return { + source: new WMSSource({ + url, + name: layer.name, + crs, + format, + extent: bboxToExtent(bbox, crs), + version: endpoint.getVersion(), + // Note: keep those parameters until we have saner defaults + transparent: true, + }), + layerType: 'color', + }; +} + +/** + * @param {WfsEndpoint} endpoint + * @param {string} name + * @returns {LayerSource} + */ +function wfsSource(endpoint, name) { + const featureType = endpoint.getFeatureTypeSummary(name); + if (!featureType) { + throw new Error(`WFS feature type "${name}" not found in capabilities`); + } + + const url = endpoint.getOperationUrl('GetFeature') ?? + endpoint.getCapabilitiesUrl(); + + const crs = getWFSLayerCrs(featureType); + if (!crs) { + throw new Error(`No globe CRS for WFS feature type "${name}"`); + } + + const format = selectVectorFormat(featureType.outputFormats); + if (!format) { + throw new Error(`Feature type "${name}" does not advertise JSON output format`); + } + + // bounding box is always lat/lon + const extent = featureType.boundingBox ? + bboxToExtent(featureType.boundingBox, 'EPSG:4326') : + undefined; + + return { + source: new WFSSource({ + url, + typeName: featureType.name, + crs, + format, + version: endpoint.getVersion(), + extent, + }), + layerType: 'color', + }; +} + +/** + * Build the iTowns source feeding a layer of a service, together with the kind + * of layer it should be attached to. + * + * @param {Endpoint} endpoint + * @param {string} layerName + * @returns {LayerSource} + */ +export function sourceFromEndpoint(endpoint, layerName) { + if (endpoint instanceof WmtsEndpoint) { + return wmtsSource(endpoint, layerName); + } + if (endpoint instanceof WmsEndpoint) { + return wmsSource(endpoint, layerName); + } + if (endpoint instanceof WfsEndpoint) { + return wfsSource(endpoint, layerName); + } + throw new Error('Unsupported OGC endpoint'); +} diff --git a/examples/source_ogc_client.html b/examples/source_ogc_client.html new file mode 100644 index 0000000000..128c6109be --- /dev/null +++ b/examples/source_ogc_client.html @@ -0,0 +1,104 @@ + + + Itowns - OGC Client layer browser + + + + + + + + + + + +
    + +
    +
    + Connect to an OGC service + + + + +
    + + + + +
    + + + + diff --git a/examples/source_ogc_client.js b/examples/source_ogc_client.js new file mode 100644 index 0000000000..338be69e8f --- /dev/null +++ b/examples/source_ogc_client.js @@ -0,0 +1,284 @@ +// @ts-check +import * as itowns from 'itowns'; +import { + endpointFromUrl, + listLayers, + sourceFromEndpoint, + getLayerCrs, + getZoom, +} from './jsm/OGCClientHelper.js'; + +/** @typedef {import('./jsm/OGCClientHelper.js').Endpoint} Endpoint */ +/** @typedef {import('./jsm/OGCClientHelper.js').LayerDescriptor} LayerDescriptor */ + +/** + * @template {keyof HTMLElementTagNameMap} T + * @param {T} tag + * @param {Partial} [props] + * @param {...(Node | string)} children + * @returns {HTMLElementTagNameMap[T]} + */ +function el(tag, props = {}, ...children) { + const element = Object.assign(document.createElement(tag), props); + element.append(...children); + return element; +} + + +// ---- OGC client wrapper ---- + +/** + * @param {itowns.GlobeView} view + * @param {object} props + * @param {Endpoint} props.endpoint + * @param {string} props.name + * @returns {Promise} + */ +async function addLayer(view, { endpoint, name }) { + const layerId = `${name}_${crypto.randomUUID()}`; + const { source } = sourceFromEndpoint(endpoint, name); + + /** @type {itowns.ColorLayer} */ + let layer; + if (source.isWFSSource) { + layer = new itowns.ColorLayer(layerId, { + source, + name, + // Keep those parameters until we have saner defaults + style: { + fill: { color: 'orange', opacity: 0.5 }, + stroke: { color: 'white', width: 1.5 }, + }, + }); + } else { + layer = new itowns.ColorLayer(layerId, { source, name }); + } + + await view.addLayer(layer); + return layer; +} + +// ---- UI components ---- + +/** + * @param {object} props + * @param {LayerDescriptor} props.layer + * @returns {HTMLLIElement} + */ +function ogcLayerItem({ layer }) { + const crs = getLayerCrs(layer); + const zoom = getZoom(layer); + + const title = 'title' in layer ? layer.title : undefined; + let subtitle = 'unsupported CRS'; + if (crs) { + subtitle = zoom ? `${crs}, zoom ${zoom.min}-${zoom.max}` : crs; + } + + const checkbox = el('input', { type: 'checkbox', value: layer.name, disabled: !crs }); + const label = el('label', { title }, + checkbox, + el('span', {}, + layer.name ?? '', + el('small', { textContent: subtitle }), + ), + ); + + return el('li', {}, label); +} + +/** + * @param {object} props + * @param {itowns.ColorLayer} props.layer + * @param {(direction: -1 | 1) => void} props.onMove + * @param {() => void} props.onRemove + * @param {(opacity: number) => void} props.onOpacity + * @returns {HTMLLIElement} + */ +function itownsLayerItem({ layer, onMove, onRemove, onOpacity }) { + const upBtn = el('button', { type: 'button', textContent: '\u25B2', title: 'Move up' }); + const downBtn = el('button', { type: 'button', textContent: '\u25BC', title: 'Move down' }); + const removeBtn = el('button', { type: 'button', textContent: '\u2715', title: 'Remove' }); + + const label = layer.name; + const children = [ + upBtn, + downBtn, + el('span', { textContent: label }), + ]; + + if ('isColorLayer' in layer) { + const slider = el('input', { + type: 'range', min: '0', max: '1', step: '0.05', value: '1', title: 'opacity', + }); + slider.addEventListener('input', () => onOpacity(slider.valueAsNumber)); + children.push(slider); + } + + children.push(removeBtn); + const item = el('li', {}, ...children); + + /** + * @param {-1 | 1} direction + */ + function move(/** @type {-1 | 1} */ direction) { + const sibling = direction < 0 ? + item.previousElementSibling : item.nextElementSibling; + if (!sibling) { + return; + } + + if (direction < 0) { + sibling.before(item); + } else { + sibling.after(item); + } + onMove(direction); + } + + upBtn.addEventListener('click', () => move(-1)); + downBtn.addEventListener('click', () => move(1)); + removeBtn.addEventListener('click', () => { + item.remove(); + onRemove(); + }); + + return item; +} + + +// ---- State ---- + +const viewerDiv = /** @type {HTMLDivElement} */ (document.getElementById('viewerDiv')); +const state = { + /** @type {Endpoint | null} */ + endpoint: null, + /** @type {itowns.GlobeView} */ + view: new itowns.GlobeView(viewerDiv, { + coord: new itowns.Coordinates('EPSG:4326', 2.351323, 48.856712), + }), +}; + + +// ---- DOM events ---- + +const toolbox = /** @type {HTMLFormElement} */ (document.getElementById('toolbox')); +const ogcURL = /** @type {HTMLInputElement} */ (document.getElementById('ogc-url')); +const ogcSelect = /** @type {HTMLSelectElement} */ (document.getElementById('ogc-type')); +const ogcStatus = /** @type {HTMLOutputElement} */ (document.getElementById('ogc-status')); +const layerPicker = /** @type {HTMLFieldSetElement} */ (document.getElementById('layer-picker')); +const layerPickerCount = /** @type {HTMLOutputElement} */ (document.getElementById('layer-count')); +const layerPickerSearch = /** @type {HTMLInputElement} */ (document.getElementById('layer-search')); +const pickerEl = /** @type {HTMLUListElement} */ (document.getElementById('layer-list')); +const layerAddButton = /** @type {HTMLButtonElement} */ (document.getElementById('layer-add')); +const activeLayers = /** @type {HTMLFieldSetElement} */ (document.getElementById('active')); +const activeLayersList = /** @type {HTMLOListElement} */ (document.getElementById('active-list')); +const activeLayersCount = /** @type {HTMLOutputElement} */ (document.getElementById('active-count')); + +function getCheckedNames() { + return Array.from( + /** @type {NodeListOf} */ (pickerEl.querySelectorAll('input:checked')), + input => input.value, + ); +} + +function updateActiveList() { + const items = activeLayersList.children; + + activeLayers.hidden = items.length === 0; + activeLayersCount.value = items.length.toString(); + + for (let i = 0; i < items.length; i++) { + const buttons = items[i].getElementsByTagName('button'); + buttons[0].disabled = i === 0; + buttons[1].disabled = i === items.length - 1; + } +} + +toolbox.addEventListener('submit', async (event) => { + event.preventDefault(); + + const type = /** @type {'wmts' | 'wms' | 'wfs'} */ (ogcSelect.value); + + ogcStatus.textContent = 'Connecting...'; + layerPicker.hidden = true; + pickerEl.replaceChildren(); + layerPickerSearch.value = ''; + + try { + const endpoint = await endpointFromUrl(ogcURL.value.trim(), type); + state.endpoint = endpoint; + ogcStatus.textContent = endpoint.getServiceInfo()?.title; + + const layers = listLayers(endpoint); + const supported = layers.filter(layer => getLayerCrs(layer) !== undefined); + pickerEl.replaceChildren(...layers.map( + layer => ogcLayerItem({ layer }), + )); + layerPickerCount.value = `${supported.length}/${layers.length}`; + layerPicker.hidden = false; + } catch (err) { + ogcStatus.textContent = `Error: ${err instanceof Error ? err.message : err}`; + } +}); + +layerPickerSearch.addEventListener('input', () => { + const query = layerPickerSearch.value.toLowerCase().trim(); + for (const item of pickerEl.children) { + if (!(item instanceof HTMLElement)) { + continue; + } + const label = item.firstElementChild; + if (!(label instanceof HTMLLabelElement)) { + continue; + } + const content = `${label.textContent} ${label.title}`.toLowerCase(); + item.hidden = query !== '' && !content.includes(query); + } +}); + +layerAddButton.addEventListener('click', async () => { + const { endpoint, view } = state; + if (!endpoint) { + return; + } + + for (const name of getCheckedNames()) { + try { + const layer = await addLayer(view, { endpoint, name }); + activeLayersList.append(itownsLayerItem({ + layer, + onMove(direction) { + if (layer instanceof itowns.ColorLayer) { + if (direction < 0) { + itowns.ColorLayersOrdering.moveLayerDown(state.view, layer.id); + } else { + itowns.ColorLayersOrdering.moveLayerUp(state.view, layer.id); + } + } + updateActiveList(); + }, + onRemove() { + state.view.removeLayer(layer.id); + state.view.notifyChange(); + updateActiveList(); + }, + onOpacity(opacity) { + if (layer instanceof itowns.ColorLayer) { + layer.opacity = opacity; + state.view.notifyChange(layer); + } + }, + })); + updateActiveList(); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + } + } + + for (const checkbox of /** @type {NodeListOf} */ ( + pickerEl.querySelectorAll('input:checked') + )) { + checkbox.checked = false; + } +}); diff --git a/package-lock.json b/package-lock.json index e28a9dc9ad..b2d479d07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@babel/preset-env": "^7.29.0", "@babel/preset-typescript": "^7.28.5", "@babel/register": "^7.28.6", + "@camptocamp/ogc-client": "^1.3.0", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.1.0", "@types/three": "^0.182.0", @@ -1765,6 +1766,48 @@ "node": ">=18" } }, + "node_modules/@camptocamp/ogc-client": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@camptocamp/ogc-client/-/ogc-client-1.3.0.tgz", + "integrity": "sha512-E1g11pBbVyWRudlWMVJrHtSfwK6nvCP0bLy8+RyCWLjlEEg92BICKep1Eg/daS/0d2Gjxlpnr1/aXowvvnosMA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@rgrove/parse-xml": "^4.1.0", + "node-fetch": "^3.3.1" + }, + "peerDependencies": { + "ol": ">5.x", + "proj4": ">2.8" + }, + "peerDependenciesMeta": { + "ol": { + "optional": true + }, + "proj4": { + "optional": true + } + } + }, + "node_modules/@camptocamp/ogc-client/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@conventional-changelog/git-client": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz", @@ -3113,6 +3156,16 @@ } } }, + "node_modules/@rgrove/parse-xml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.2.3.tgz", + "integrity": "sha512-Jhlb+0zYez1T1yXUQs3F1qAtFuJljBVNdy9TKmLDauAXkxsOXopKYOyQ5Wm6SvP3fycav0GviX4Y15WWhGetMw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -6065,6 +6118,16 @@ "integrity": "sha512-sCNc1OHobc+Erc1HqiswYgHdVNpSJUlk/Hz8vzOCsER7rl+oF/4+v8GXFUyCgtXpoCX6+bnmg07DedLvBLwYKQ==", "license": "Apache-2.0" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7030,6 +7093,30 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7223,6 +7310,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9286,6 +9386,27 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12255,6 +12376,16 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", diff --git a/package.json b/package.json index f8e72e6216..e40929f7fa 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@babel/preset-env": "^7.29.0", "@babel/preset-typescript": "^7.28.5", "@babel/register": "^7.28.6", + "@camptocamp/ogc-client": "^1.3.0", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.1.0", "@types/three": "^0.182.0", From a031e9d23fa2a18b936b1ea43439381a3239806e Mon Sep 17 00:00:00 2001 From: ftoromanoff Date: Mon, 3 Aug 2026 18:11:03 +0200 Subject: [PATCH 2/4] feat(examples): source_ogc_client -> add url and type params --- examples/source_ogc_client.html | 2 +- examples/source_ogc_client.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/examples/source_ogc_client.html b/examples/source_ogc_client.html index 128c6109be..e0a1c3b5b5 100644 --- a/examples/source_ogc_client.html +++ b/examples/source_ogc_client.html @@ -76,7 +76,7 @@
    Connect to an OGC service - +