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
}