Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/renderer/components/properties/TileServiceProperties.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 5 additions & 3 deletions src/renderer/components/properties/TileServiceProperties.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
? <div className='layer-list'>
{
list.entries.map((layer, index) => (
Expand All @@ -151,7 +153,7 @@ const TileServiceProperties = props => {
</div>
: null

const filterField = ['WMS', 'WMTS'].includes(service.type)
const filterField = showLayerList
? <><TextField id='tsp-filter' label='Filter' value={filter} onChange={handleFilterChange}/><Tooltip anchorSelect='#tsp-filter' content='Filter the list of layers' /></>
: null

Expand All @@ -164,7 +166,7 @@ const TileServiceProperties = props => {
<FlexColumnGap>
<Name {...props}/>
<TextField id='tsp-url' label='URL' value={url.value} onChange={handleUrlChange} onBlur={handleUrlBlur}/>
<Tooltip anchorSelect='#tsp-url' content='Z/X/Y, WMS or WMTS URL' delayShow={750} />
<Tooltip anchorSelect='#tsp-url' content='Z/X/Y, WMS, WMTS or TileJSON URL' delayShow={750} />
{ filterField }
{ layerList }
<div className='map-preview' id='map-preview'></div>
Expand Down
54 changes: 50 additions & 4 deletions src/renderer/store/TileLayerStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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])
}


/**
*
*/
Expand Down Expand Up @@ -201,25 +245,27 @@ 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))
)

const removals = currentLayers.filter(x => !activeLayerIds.includes(x))

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))
}

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))
Expand Down
54 changes: 53 additions & 1 deletion src/renderer/store/tileServiceAdapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -110,14 +111,65 @@ 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' })
})


/**
*
*/
export const adapters = {
WMTS: wmtsAdapter,
WMS: wmsAdapter,
XYZ: xyzAdapter,
OSM: osmAdapter
OSM: osmAdapter,
TileJSONDiscovery: tileJSONDiscoveryAdapter,
TileJSON: tileJSONAdapter
}


Expand Down