diff --git a/src/renderer/components/properties/TileServiceProperties.css b/src/renderer/components/properties/TileServiceProperties.css index d519580..c090dcd 100644 --- a/src/renderer/components/properties/TileServiceProperties.css +++ b/src/renderer/components/properties/TileServiceProperties.css @@ -42,3 +42,22 @@ border-radius: 3px; border-width: 1px; } + +.import-button { + margin-top: 8px; + padding: 8px 16px; + background-color: var(--color-accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.import-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.import-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/renderer/components/properties/TileServiceProperties.js b/src/renderer/components/properties/TileServiceProperties.js index c6c968c..d6cb901 100644 --- a/src/renderer/components/properties/TileServiceProperties.js +++ b/src/renderer/components/properties/TileServiceProperties.js @@ -134,7 +134,9 @@ const TileServiceProperties = props => { const handleEntryClick = id => dispatch({ type: 'select', id }) const handleFilterChange = ({ target }) => setFilter(target.value) - const layerList = ['WMS', 'WMTS'].includes(service.type) + const showLayerList = ['WMS', 'WMTS', 'TileJSONDiscovery'].includes(service.type) + + const layerList = showLayerList ?
{ list.entries.map((layer, index) => ( @@ -151,7 +153,7 @@ const TileServiceProperties = props => {
: null - const filterField = ['WMS', 'WMTS'].includes(service.type) + const filterField = showLayerList ? <> : null @@ -164,7 +166,7 @@ const TileServiceProperties = props => { - + { filterField } { layerList }
diff --git a/src/renderer/store/TileLayerStore.js b/src/renderer/store/TileLayerStore.js index 0762bc1..f1e9d15 100644 --- a/src/renderer/store/TileLayerStore.js +++ b/src/renderer/store/TileLayerStore.js @@ -16,7 +16,26 @@ const fetchCapabilities = async service => { if ([400, 404, 500].includes(response.status)) { throw new Error(response.statusText) } + + const contentType = response.headers.get('content-type') || '' const text = await response.text() + + // Try JSON first (TileJSON) + if (contentType.includes('application/json') || text.trim().startsWith('[') || text.trim().startsWith('{')) { + try { + const json = JSON.parse(text) + // Services list from mbtileserver + if (Array.isArray(json)) { + return TileService.adapters.TileJSONDiscovery({ url: service.url, services: json }) + } + // Single TileJSON document + if (json.tiles && Array.isArray(json.tiles)) { + return TileService.adapters.TileJSON({ url: service.url, tileJSON: json }) + } + } catch (e) { /* not valid JSON, continue */ } + } + + // Try XML (existing logic) return TileService.adapter(text) } catch (err) { return TileService.adapters.XYZ({ @@ -152,6 +171,31 @@ TileLayerStore.prototype.toggleActiveLayer = async function (key, id) { } +/** + * Import selected tilesets from a TileJSON discovery service. + * Creates a new TileJSON tile service for each selected tileset. + */ +TileLayerStore.prototype.importTilesets = async function (baseUrl, selectedTilesets) { + const tuples = await Promise.all(selectedTilesets.map(async tileset => { + const tileJSONUrl = new URL(tileset.id, baseUrl).href + const response = await fetch(tileJSONUrl) + const tileJSON = await response.json() + + const key = ID.tileServiceId() + const value = { + type: 'TileJSON', + name: tileJSON.name || tileset.title, + url: tileJSONUrl, + capabilities: { url: tileJSONUrl, tileJSON } + } + return [key, value] + })) + + await this.store.update(tuples.map(t => t[0]), tuples.map(t => t[1])) + return tuples.map(t => t[0]) +} + + /** * */ @@ -201,9 +245,11 @@ TileLayerStore.prototype.updatePreset = async function () { const adapter = ([key, { type, capabilities }]) => [key, TileService.adapters[type](capabilities)] const adapters = Object.fromEntries(services.map(adapter)) + // Single-layer types (OSM, XYZ, TileJSON) get one layer automatically. + // Multi-layer types (WMS, WMTS, TileJSONDiscovery) use service.active array. const activeLayerIds = services.flatMap(([key, service]) => - ['OSM', 'XYZ'].includes(service.type) - ? ID.tileLayerId(key) + ['OSM', 'XYZ', 'TileJSON'].includes(service.type) + ? [ID.tileLayerId(key)] : (service.active || []).map(id => ID.tileLayerId(key, id)) ) @@ -211,7 +257,7 @@ TileLayerStore.prototype.updatePreset = async function () { const layerName = id => { const [key, service] = findService(ID.tileServiceId(id)) - return ['OSM', 'XYZ'].includes(service.type) + return ['OSM', 'XYZ', 'TileJSON'].includes(service.type) ? service.name : adapters[key].layerName(ID.containedId(id)) } @@ -219,7 +265,7 @@ TileLayerStore.prototype.updatePreset = async function () { const layer = id => ({ id, opacity: 1.0, visible: false }) const additions = activeLayerIds.filter(x => !currentLayers.includes(x)).map(layer) - // Propagate name changes from service to preset (only for OSM, XYZ.) + // Propagate name changes from service to preset (for OSM, XYZ, TileJSON). // const propagateName = layer => ({ ...layer, name: layerName(layer.id) }) const preset = (currentPreset.concat(additions)) diff --git a/src/renderer/store/tileServiceAdapters.js b/src/renderer/store/tileServiceAdapters.js index 40ef788..d177dc4 100644 --- a/src/renderer/store/tileServiceAdapters.js +++ b/src/renderer/store/tileServiceAdapters.js @@ -2,6 +2,7 @@ import WMTSCapabilities from 'ol/format/WMTSCapabilities' import WMSCapabilities from 'ol/format/WMSCapabilities' import WMTS, { optionsFromCapabilities } from 'ol/source/WMTS' import TileWMS from 'ol/source/TileWMS' +import TileJSON from 'ol/source/TileJSON' import { OSM, XYZ } from 'ol/source' /** @@ -110,6 +111,55 @@ const osmAdapter = () => ({ }) +/** + * Adapter for TileJSON discovery endpoints that return an array of available tilesets. + * Each service object should have at least `name` and `url` properties. + * Works like WMS/WMTS - select tilesets to add them to the background maps. + * The TileJSON source automatically respects minzoom/maxzoom from the TileJSON document. + */ +const tileJSONDiscoveryAdapter = caps => { + const findService = id => caps.services.find(s => s.name === id) + + return { + type: 'TileJSONDiscovery', + capabilities: caps, + title: 'TileJSON Server', + abstract: null, + layers: () => caps.services.map(s => ({ + id: s.name, + title: s.name, + abstract: s.description || '' + })), + boundingBox: id => findService(id)?.bounds, + layerName: id => findService(id)?.name || id, + source: id => { + if (!id) return null + const service = findService(id) + if (!service?.url) return null + // Resolve service URL against base URL (handles both relative and absolute URLs) + const tileJSONUrl = new URL(service.url, caps.url).href + return new TileJSON({ url: tileJSONUrl, crossOrigin: 'anonymous' }) + } + } +} + + +/** + * Adapter for individual TileJSON tileset. + * The TileJSON source automatically respects minzoom/maxzoom from the TileJSON document. + */ +const tileJSONAdapter = caps => ({ + type: 'TileJSON', + capabilities: caps, + title: caps.tileJSON?.name, + abstract: caps.tileJSON?.description || null, + layers: () => [], + boundingBox: () => caps.tileJSON?.bounds, + layerName: () => caps.tileJSON?.name, + source: () => new TileJSON({ url: caps.url, crossOrigin: 'anonymous' }) +}) + + /** * */ @@ -117,7 +167,9 @@ export const adapters = { WMTS: wmtsAdapter, WMS: wmsAdapter, XYZ: xyzAdapter, - OSM: osmAdapter + OSM: osmAdapter, + TileJSONDiscovery: tileJSONDiscoveryAdapter, + TileJSON: tileJSONAdapter }