mirror of
https://github.com/umap-project/umap.git
synced 2025-04-28 19:42:36 +02:00
1310 lines
35 KiB
JavaScript
1310 lines
35 KiB
JavaScript
// FIXME: this module should not depend on Leaflet
|
|
import {
|
|
DomUtil,
|
|
DomEvent,
|
|
stamp,
|
|
GeoJSON,
|
|
} from '../../../vendors/leaflet/leaflet-src.esm.js'
|
|
import * as Utils from '../utils.js'
|
|
import { Default as DefaultLayer } from '../rendering/layers/base.js'
|
|
import { Cluster } from '../rendering/layers/cluster.js'
|
|
import { Heat } from '../rendering/layers/heat.js'
|
|
import { Categorized, Choropleth, Circles } from '../rendering/layers/classified.js'
|
|
import {
|
|
uMapAlert as Alert,
|
|
uMapAlertConflict as AlertConflict,
|
|
} from '../../components/alerts/alert.js'
|
|
import { translate } from '../i18n.js'
|
|
import { DataLayerPermissions } from '../permissions.js'
|
|
import { Point, LineString, Polygon } from './features.js'
|
|
import TableEditor from '../tableeditor.js'
|
|
import { ServerStored } from '../saving.js'
|
|
import * as Schema from '../schema.js'
|
|
import { MutatingForm } from '../form/builder.js'
|
|
|
|
export const LAYER_TYPES = [
|
|
DefaultLayer,
|
|
Cluster,
|
|
Heat,
|
|
Choropleth,
|
|
Categorized,
|
|
Circles,
|
|
]
|
|
|
|
const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
|
|
acc[klass.TYPE] = klass
|
|
return acc
|
|
}, {})
|
|
|
|
export class DataLayer extends ServerStored {
|
|
constructor(umap, leafletMap, data = {}) {
|
|
super()
|
|
this._umap = umap
|
|
this.sync = umap.syncEngine.proxy(this)
|
|
this._index = Array()
|
|
this._features = {}
|
|
this._geojson = null
|
|
this._propertiesIndex = []
|
|
this._loaded = false // Are layer metadata loaded
|
|
this._dataloaded = false // Are layer data loaded
|
|
|
|
this._leafletMap = leafletMap
|
|
this.parentPane = this._leafletMap.getPane('overlayPane')
|
|
this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane)
|
|
this.pane.dataset.id = stamp(this)
|
|
// FIXME: should be on layer
|
|
this.renderer = L.svg({ pane: this.pane })
|
|
this.defaultOptions = {
|
|
displayOnLoad: true,
|
|
inCaption: true,
|
|
browsable: true,
|
|
editMode: 'advanced',
|
|
}
|
|
|
|
this._isDeleted = false
|
|
this._referenceVersion = data._referenceVersion
|
|
// Do not save it later.
|
|
delete data._referenceVersion
|
|
data.id = data.id || crypto.randomUUID()
|
|
|
|
this.setOptions(data)
|
|
|
|
if (!Utils.isObject(this.options.remoteData)) {
|
|
this.options.remoteData = {}
|
|
}
|
|
// Retrocompat
|
|
if (this.options.remoteData?.from) {
|
|
this.options.fromZoom = this.options.remoteData.from
|
|
delete this.options.remoteData.from
|
|
}
|
|
if (this.options.remoteData?.to) {
|
|
this.options.toZoom = this.options.remoteData.to
|
|
delete this.options.remoteData.to
|
|
}
|
|
this.backupOptions()
|
|
this.connectToMap()
|
|
this.permissions = new DataLayerPermissions(this._umap, this)
|
|
|
|
if (!this.createdOnServer) {
|
|
if (this.showAtLoad()) this.show()
|
|
}
|
|
|
|
// Only layers that are displayed on load must be hidden/shown
|
|
// Automatically, others will be shown manually, and thus will
|
|
// be in the "forced visibility" mode
|
|
if (this.isVisible()) this.propagateShow()
|
|
}
|
|
|
|
get id() {
|
|
return this.options.id
|
|
}
|
|
|
|
get createdOnServer() {
|
|
return Boolean(this._referenceVersion)
|
|
}
|
|
|
|
onDirty(status) {
|
|
if (status) {
|
|
// A layer can be made dirty by indirect action (like dragging layers)
|
|
// we need to have it loaded before saving it.
|
|
if (!this.isLoaded()) this.fetchData()
|
|
} else {
|
|
this.isDeleted = false
|
|
}
|
|
}
|
|
|
|
set isDeleted(status) {
|
|
this._isDeleted = status
|
|
if (status) this.isDirty = status
|
|
}
|
|
|
|
get isDeleted() {
|
|
return this._isDeleted
|
|
}
|
|
|
|
get cssId() {
|
|
return `datalayer-${stamp(this)}`
|
|
}
|
|
|
|
getSyncMetadata() {
|
|
return {
|
|
subject: 'datalayer',
|
|
metadata: { id: this.id },
|
|
}
|
|
}
|
|
|
|
render(fields, builder) {
|
|
const impacts = Utils.getImpactsFromSchema(fields)
|
|
|
|
for (const impact of impacts) {
|
|
switch (impact) {
|
|
case 'ui':
|
|
this._umap.onDataLayersChanged()
|
|
break
|
|
case 'data':
|
|
if (fields.includes('options.type')) {
|
|
this.resetLayer()
|
|
}
|
|
this.hide()
|
|
for (const field of fields) {
|
|
this.layer.onEdit(field, builder)
|
|
}
|
|
this.show()
|
|
break
|
|
case 'remote-data':
|
|
this.fetchRemoteData()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
showAtLoad() {
|
|
return this.autoLoaded() && this.showAtZoom()
|
|
}
|
|
|
|
autoLoaded() {
|
|
if (!this._umap.datalayersFromQueryString) return this.options.displayOnLoad
|
|
const datalayerIds = this._umap.datalayersFromQueryString
|
|
let loadMe = datalayerIds.includes(this.id.toString())
|
|
if (this.options.old_id) {
|
|
loadMe = loadMe || datalayerIds.includes(this.options.old_id.toString())
|
|
}
|
|
return loadMe
|
|
}
|
|
|
|
insertBefore(other) {
|
|
if (!other) return
|
|
this.parentPane.insertBefore(this.pane, other.pane)
|
|
}
|
|
|
|
insertAfter(other) {
|
|
if (!other) return
|
|
this.parentPane.insertBefore(this.pane, other.pane.nextSibling)
|
|
}
|
|
|
|
bringToTop() {
|
|
this.parentPane.appendChild(this.pane)
|
|
}
|
|
|
|
hasDataVisible() {
|
|
return this.layer.hasDataVisible()
|
|
}
|
|
|
|
resetLayer(force) {
|
|
// Only reset if type is defined (undefined is the default) and different from current type
|
|
if (
|
|
this.layer &&
|
|
(!this.options.type || this.options.type === this.layer.getType()) &&
|
|
!force
|
|
) {
|
|
return
|
|
}
|
|
const visible = this.isVisible()
|
|
if (this.layer) this.layer.clearLayers()
|
|
// delete this.layer?
|
|
if (visible) this._leafletMap.removeLayer(this.layer)
|
|
const Class = LAYER_MAP[this.options.type] || DefaultLayer
|
|
this.layer = new Class(this)
|
|
// Rendering layer changed, so let's force reset the feature rendering too.
|
|
this.eachFeature((feature) => feature.makeUI())
|
|
this.eachFeature(this.showFeature)
|
|
if (visible) this.show()
|
|
this.propagateRemote()
|
|
}
|
|
|
|
eachFeature(method, context) {
|
|
for (const idx of this._index) {
|
|
method.call(context || this, this._features[idx])
|
|
}
|
|
return this
|
|
}
|
|
|
|
async fetchData() {
|
|
if (!this.createdOnServer) return
|
|
if (this._loading) return
|
|
this._loading = true
|
|
const [geojson, response, error] = await this._umap.server.get(this._dataUrl())
|
|
if (!error) {
|
|
this.setReferenceVersion({ response, sync: false })
|
|
// FIXME: for now the _umap_options property is set dynamically from backend
|
|
// And thus it's not in the geojson file in the server
|
|
// So do not let all options to be reset
|
|
// Fix is a proper migration so all datalayers settings are
|
|
// in DB, and we remove it from geojson flat files.
|
|
if (geojson._umap_options) {
|
|
geojson._umap_options.editMode = this.options.editMode
|
|
}
|
|
// In case of maps pre 1.0 still around
|
|
if (geojson._storage) geojson._storage.editMode = this.options.editMode
|
|
await this.fromUmapGeoJSON(geojson)
|
|
this.backupOptions()
|
|
this._loading = false
|
|
}
|
|
}
|
|
|
|
dataChanged() {
|
|
if (!this.hasDataLoaded()) return
|
|
this._umap.onDataLayersChanged()
|
|
this.layer.dataChanged()
|
|
}
|
|
|
|
fromGeoJSON(geojson, sync = true) {
|
|
if (!geojson) return []
|
|
const features = this.addData(geojson, sync)
|
|
this._geojson = geojson
|
|
this.onDataLoaded()
|
|
this.dataChanged()
|
|
return features
|
|
}
|
|
|
|
onDataLoaded() {
|
|
this._dataloaded = true
|
|
this.renderLegend()
|
|
}
|
|
|
|
async fromUmapGeoJSON(geojson) {
|
|
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat
|
|
geojson._umap_options.id = this.id
|
|
if (geojson._umap_options) this.setOptions(geojson._umap_options)
|
|
if (this.isRemoteLayer()) await this.fetchRemoteData()
|
|
else this.fromGeoJSON(geojson, false)
|
|
this._loaded = true
|
|
}
|
|
|
|
clear() {
|
|
this.layer.clearLayers()
|
|
this._features = {}
|
|
this._index = Array()
|
|
if (this._geojson) {
|
|
this.backupData()
|
|
this._geojson = null
|
|
}
|
|
this.dataChanged()
|
|
}
|
|
|
|
backupData() {
|
|
if (this._geojson) {
|
|
this._geojson_bk = Utils.CopyJSON(this._geojson)
|
|
}
|
|
}
|
|
|
|
reindex() {
|
|
const features = Object.values(this._features)
|
|
this.sortFeatures(features)
|
|
this._index = features.map((feature) => stamp(feature))
|
|
}
|
|
|
|
showAtZoom() {
|
|
const from = Number.parseInt(this.options.fromZoom, 10)
|
|
const to = Number.parseInt(this.options.toZoom, 10)
|
|
const zoom = this._leafletMap.getZoom()
|
|
return !((!Number.isNaN(from) && zoom < from) || (!Number.isNaN(to) && zoom > to))
|
|
}
|
|
|
|
hasDynamicData() {
|
|
return this.isRemoteLayer() && Boolean(this.options.remoteData?.dynamic)
|
|
}
|
|
|
|
async getUrl(url, initialUrl) {
|
|
const response = await this._umap.request.get(url)
|
|
return new Promise((resolve) => {
|
|
if (response?.ok) return resolve(response.text())
|
|
Alert.error(
|
|
translate('Cannot load remote data for layer "{layer}" with url "{url}"', {
|
|
layer: this.getName(),
|
|
url: initialUrl || url,
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
async fetchRemoteData(force) {
|
|
if (!this.isRemoteLayer()) return
|
|
if (!this.hasDynamicData() && this.hasDataLoaded() && !force) return
|
|
if (!this.isVisible()) return
|
|
// Keep non proxied url for later use in Alert.
|
|
const remoteUrl = this._umap.renderUrl(this.options.remoteData.url)
|
|
let url = remoteUrl
|
|
if (this.options.remoteData.proxy) {
|
|
url = this._umap.proxyUrl(url, this.options.remoteData.ttl)
|
|
}
|
|
return await this.getUrl(url, remoteUrl).then((raw) => {
|
|
this.clear()
|
|
return this._umap.formatter
|
|
.parse(raw, this.options.remoteData.format)
|
|
.then((geojson) => this.fromGeoJSON(geojson))
|
|
.catch((error) => {
|
|
Alert.error(
|
|
translate('Cannot parse remote data for layer "{layer}" with url "{url}"', {
|
|
layer: this.getName(),
|
|
url: remoteUrl,
|
|
})
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
isLoaded() {
|
|
return !this.createdOnServer || this._loaded
|
|
}
|
|
|
|
hasDataLoaded() {
|
|
return this._dataloaded
|
|
}
|
|
|
|
backupOptions() {
|
|
this._backupOptions = Utils.CopyJSON(this.options)
|
|
}
|
|
|
|
resetOptions() {
|
|
this.options = Utils.CopyJSON(this._backupOptions)
|
|
}
|
|
|
|
setOptions(options) {
|
|
delete options.geojson
|
|
this.options = Utils.CopyJSON(this.defaultOptions) // Start from fresh.
|
|
this.updateOptions(options)
|
|
}
|
|
|
|
updateOptions(options) {
|
|
this.options = Object.assign(this.options, options)
|
|
this.resetLayer()
|
|
}
|
|
|
|
connectToMap() {
|
|
const id = stamp(this)
|
|
if (!this._umap.datalayers[id]) {
|
|
this._umap.datalayers[id] = this
|
|
}
|
|
if (!this._umap.datalayersIndex.includes(this)) {
|
|
this._umap.datalayersIndex.push(this)
|
|
}
|
|
this._umap.onDataLayersChanged()
|
|
}
|
|
|
|
_dataUrl() {
|
|
let url = this._umap.urls.get('datalayer_view', {
|
|
pk: this.id,
|
|
map_id: this._umap.id,
|
|
})
|
|
|
|
// No browser cache for owners/editors.
|
|
if (this._umap.hasEditMode()) url = `${url}?${Date.now()}`
|
|
return url
|
|
}
|
|
|
|
isRemoteLayer() {
|
|
return Boolean(this.options.remoteData?.url && this.options.remoteData.format)
|
|
}
|
|
|
|
isClustered() {
|
|
return this.options.type === 'Cluster'
|
|
}
|
|
|
|
showFeature(feature) {
|
|
if (feature.isFiltered()) return
|
|
this.layer.addLayer(feature.ui)
|
|
}
|
|
|
|
hideFeature(feature) {
|
|
this.layer.removeLayer(feature.ui)
|
|
}
|
|
|
|
addFeature(feature) {
|
|
const id = stamp(feature)
|
|
feature.connectToDataLayer(this)
|
|
this._index.push(id)
|
|
this._features[id] = feature
|
|
this.indexProperties(feature)
|
|
this._umap.featuresIndex[feature.getSlug()] = feature
|
|
this.showFeature(feature)
|
|
this.dataChanged()
|
|
}
|
|
|
|
removeFeature(feature, sync) {
|
|
const id = stamp(feature)
|
|
if (sync !== false) feature.sync.delete()
|
|
this.hideFeature(feature)
|
|
delete this._umap.featuresIndex[feature.getSlug()]
|
|
feature.disconnectFromDataLayer(this)
|
|
this._index.splice(this._index.indexOf(id), 1)
|
|
delete this._features[id]
|
|
if (this.isVisible()) this.dataChanged()
|
|
}
|
|
|
|
indexProperties(feature) {
|
|
for (const i in feature.properties)
|
|
if (typeof feature.properties[i] !== 'object') this.indexProperty(i)
|
|
}
|
|
|
|
indexProperty(name) {
|
|
if (!name) return
|
|
if (name.indexOf('_') === 0) return
|
|
if (!this._propertiesIndex.includes(name)) {
|
|
this._propertiesIndex.push(name)
|
|
this._propertiesIndex.sort()
|
|
}
|
|
}
|
|
|
|
deindexProperty(name) {
|
|
const idx = this._propertiesIndex.indexOf(name)
|
|
if (idx !== -1) this._propertiesIndex.splice(idx, 1)
|
|
}
|
|
|
|
allProperties() {
|
|
return this._propertiesIndex
|
|
}
|
|
|
|
sortedValues(property) {
|
|
return Object.values(this._features)
|
|
.map((feature) => feature.properties[property])
|
|
.filter((val, idx, arr) => arr.indexOf(val) === idx)
|
|
.sort(Utils.naturalSort)
|
|
}
|
|
|
|
addData(geojson, sync) {
|
|
try {
|
|
// Do not fail if remote data is somehow invalid,
|
|
// otherwise the layer becomes uneditable.
|
|
return this.makeFeatures(geojson, sync)
|
|
} catch (err) {
|
|
console.debug('Error with DataLayer', this.id)
|
|
console.error(err)
|
|
return []
|
|
}
|
|
}
|
|
|
|
sortFeatures(collection) {
|
|
const sortKeys = this.getOption('sortKey') || U.DEFAULT_LABEL_KEY
|
|
return Utils.sortFeatures(collection, sortKeys, U.lang)
|
|
}
|
|
|
|
makeFeatures(geojson = {}, sync = true) {
|
|
if (geojson.type === 'Feature' || geojson.coordinates) {
|
|
geojson = [geojson]
|
|
}
|
|
const collection = Array.isArray(geojson)
|
|
? geojson
|
|
: geojson.features || geojson.geometries
|
|
if (!collection) return
|
|
const features = []
|
|
this.sortFeatures(collection)
|
|
for (const featureJson of collection) {
|
|
const feature = this.makeFeature(featureJson, sync)
|
|
if (feature) features.push(feature)
|
|
}
|
|
return features
|
|
}
|
|
|
|
makeFeature(geojson = {}, sync = true, id = null) {
|
|
// Both Feature and Geometry are valid geojson objects.
|
|
const geometry = geojson.geometry || geojson
|
|
let feature
|
|
|
|
switch (geometry.type) {
|
|
case 'Point':
|
|
// FIXME: deal with MultiPoint
|
|
feature = new Point(this._umap, this, geojson, id)
|
|
break
|
|
case 'MultiLineString':
|
|
case 'LineString':
|
|
feature = new LineString(this._umap, this, geojson, id)
|
|
break
|
|
case 'MultiPolygon':
|
|
case 'Polygon':
|
|
feature = new Polygon(this._umap, this, geojson, id)
|
|
break
|
|
default:
|
|
console.debug(geojson)
|
|
Alert.error(
|
|
translate('Skipping unknown geometry.type: {type}', {
|
|
type: geometry.type || 'undefined',
|
|
})
|
|
)
|
|
}
|
|
if (feature) {
|
|
this.addFeature(feature)
|
|
if (sync) feature.onCommit()
|
|
return feature
|
|
}
|
|
}
|
|
|
|
async importRaw(raw, format) {
|
|
return this._umap.formatter
|
|
.parse(raw, format)
|
|
.then((geojson) => this.addData(geojson))
|
|
.then((data) => {
|
|
if (data?.length) this.isDirty = true
|
|
return data
|
|
})
|
|
.catch((error) => {
|
|
Alert.error(translate('Import failed: invalid data'))
|
|
})
|
|
}
|
|
|
|
readFile(f) {
|
|
return new Promise((resolve) => {
|
|
const reader = new FileReader()
|
|
reader.onloadend = () => resolve(reader.result)
|
|
reader.readAsText(f)
|
|
})
|
|
}
|
|
|
|
async importFromFiles(files, type) {
|
|
const toLoad = []
|
|
for (const file of files) {
|
|
toLoad.push(this.importFromFile(file, type))
|
|
}
|
|
const features = await Promise.all(toLoad)
|
|
return new Promise((resolve) => {
|
|
resolve([].concat(...features))
|
|
})
|
|
}
|
|
|
|
async importFromFile(file, type) {
|
|
type = type || Utils.detectFileType(f)
|
|
const raw = await this.readFile(file)
|
|
return this.importRaw(raw, type)
|
|
}
|
|
|
|
async importFromUrl(uri, type) {
|
|
uri = this._umap.renderUrl(uri)
|
|
return await this.getUrl(uri).then((raw) => {
|
|
return this.importRaw(raw, type)
|
|
})
|
|
}
|
|
|
|
getColor() {
|
|
return this.options.color || this._umap.getProperty('color')
|
|
}
|
|
|
|
getDeleteUrl() {
|
|
return this._umap.urls.get('datalayer_delete', {
|
|
pk: this.id,
|
|
map_id: this._umap.id,
|
|
})
|
|
}
|
|
|
|
getVersionsUrl() {
|
|
return this._umap.urls.get('datalayer_versions', {
|
|
pk: this.id,
|
|
map_id: this._umap.id,
|
|
})
|
|
}
|
|
|
|
getVersionUrl(ref) {
|
|
return this._umap.urls.get('datalayer_version', {
|
|
pk: this.id,
|
|
map_id: this._umap.id,
|
|
ref: ref,
|
|
})
|
|
}
|
|
|
|
del(sync = true) {
|
|
this.erase()
|
|
if (sync) {
|
|
this.isDeleted = true
|
|
this.sync.delete()
|
|
}
|
|
}
|
|
|
|
empty() {
|
|
if (this.isRemoteLayer()) return
|
|
this.clear()
|
|
this.isDirty = true
|
|
}
|
|
|
|
clone() {
|
|
const options = Utils.CopyJSON(this.options)
|
|
options.name = translate('Clone of {name}', { name: this.options.name })
|
|
delete options.id
|
|
const geojson = Utils.CopyJSON(this._geojson)
|
|
const datalayer = this._umap.createDirtyDataLayer(options)
|
|
datalayer.fromGeoJSON(geojson)
|
|
return datalayer
|
|
}
|
|
|
|
erase() {
|
|
this.hide()
|
|
this._umap.datalayersIndex.splice(this.getRank(), 1)
|
|
this.parentPane.removeChild(this.pane)
|
|
this._umap.onDataLayersChanged()
|
|
this.layer.onDelete(this._leafletMap)
|
|
this.propagateDelete()
|
|
this._leaflet_events_bk = this._leaflet_events
|
|
this.clear()
|
|
delete this._loaded
|
|
delete this._dataloaded
|
|
}
|
|
|
|
reset() {
|
|
if (!this.createdOnServer) {
|
|
this.erase()
|
|
return
|
|
}
|
|
|
|
this.resetOptions()
|
|
this.parentPane.appendChild(this.pane)
|
|
if (this._leaflet_events_bk && !this._leaflet_events) {
|
|
this._leaflet_events = this._leaflet_events_bk
|
|
}
|
|
this.clear()
|
|
this.hide()
|
|
if (this.isRemoteLayer()) this.fetchRemoteData()
|
|
else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk)
|
|
this._loaded = true
|
|
this.show()
|
|
this.isDirty = false
|
|
}
|
|
|
|
redraw() {
|
|
if (!this.isVisible()) return
|
|
this.hide()
|
|
this.show()
|
|
}
|
|
|
|
edit() {
|
|
if (!this._umap.editEnabled || !this.isLoaded()) {
|
|
return
|
|
}
|
|
const container = DomUtil.create('div', 'umap-layer-properties-container')
|
|
const metadataFields = [
|
|
'options.name',
|
|
'options.description',
|
|
[
|
|
'options.type',
|
|
{ handler: 'LayerTypeChooser', label: translate('Type of layer') },
|
|
],
|
|
'options.labelKey',
|
|
[
|
|
'options.displayOnLoad',
|
|
{ label: translate('Display on load'), handler: 'Switch' },
|
|
],
|
|
[
|
|
'options.browsable',
|
|
{
|
|
label: translate('Data is browsable'),
|
|
handler: 'Switch',
|
|
helpEntries: ['browsable'],
|
|
},
|
|
],
|
|
[
|
|
'options.inCaption',
|
|
{
|
|
label: translate('Show this layer in the caption'),
|
|
handler: 'Switch',
|
|
},
|
|
],
|
|
]
|
|
DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers')
|
|
let builder = new MutatingForm(this, metadataFields)
|
|
builder.on('set', ({ detail }) => {
|
|
this._umap.onDataLayersChanged()
|
|
if (detail.helper.field === 'options.type') {
|
|
this.edit()
|
|
}
|
|
})
|
|
container.appendChild(builder.build())
|
|
|
|
const layerOptions = this.layer.getEditableOptions()
|
|
|
|
if (layerOptions.length) {
|
|
builder = new MutatingForm(this, layerOptions, {
|
|
id: 'datalayer-layer-properties',
|
|
})
|
|
const layerProperties = DomUtil.createFieldset(
|
|
container,
|
|
`${this.layer.getName()}: ${translate('settings')}`
|
|
)
|
|
layerProperties.appendChild(builder.build())
|
|
}
|
|
|
|
const shapeOptions = [
|
|
'options.color',
|
|
'options.iconClass',
|
|
'options.iconUrl',
|
|
'options.iconOpacity',
|
|
'options.opacity',
|
|
'options.stroke',
|
|
'options.weight',
|
|
'options.fill',
|
|
'options.fillColor',
|
|
'options.fillOpacity',
|
|
]
|
|
|
|
builder = new MutatingForm(this, shapeOptions, {
|
|
id: 'datalayer-advanced-properties',
|
|
})
|
|
const shapeProperties = DomUtil.createFieldset(
|
|
container,
|
|
translate('Shape properties')
|
|
)
|
|
shapeProperties.appendChild(builder.build())
|
|
|
|
const optionsFields = [
|
|
'options.smoothFactor',
|
|
'options.dashArray',
|
|
'options.zoomTo',
|
|
'options.fromZoom',
|
|
'options.toZoom',
|
|
'options.sortKey',
|
|
]
|
|
|
|
builder = new MutatingForm(this, optionsFields, {
|
|
id: 'datalayer-advanced-properties',
|
|
})
|
|
builder.on('set', ({ detail }) => {
|
|
if (detail.helper.field === 'options.sortKey') {
|
|
this.reindex()
|
|
}
|
|
})
|
|
const advancedProperties = DomUtil.createFieldset(
|
|
container,
|
|
translate('Advanced properties')
|
|
)
|
|
advancedProperties.appendChild(builder.build())
|
|
|
|
const popupFields = [
|
|
'options.popupShape',
|
|
'options.popupTemplate',
|
|
'options.popupContentTemplate',
|
|
'options.showLabel',
|
|
'options.labelDirection',
|
|
'options.labelInteractive',
|
|
'options.outlinkTarget',
|
|
'options.interactive',
|
|
]
|
|
builder = new MutatingForm(this, popupFields)
|
|
const popupFieldset = DomUtil.createFieldset(
|
|
container,
|
|
translate('Interaction options')
|
|
)
|
|
popupFieldset.appendChild(builder.build())
|
|
|
|
// XXX I'm not sure **why** this is needed (as it's set during `this.initialize`)
|
|
// but apparently it's needed.
|
|
if (!Utils.isObject(this.options.remoteData)) {
|
|
this.options.remoteData = {}
|
|
}
|
|
|
|
const remoteDataFields = [
|
|
[
|
|
'options.remoteData.url',
|
|
{ handler: 'Url', label: translate('Url'), helpEntries: ['formatURL'] },
|
|
],
|
|
[
|
|
'options.remoteData.format',
|
|
{ handler: 'DataFormat', label: translate('Format') },
|
|
],
|
|
'options.fromZoom',
|
|
'options.toZoom',
|
|
[
|
|
'options.remoteData.dynamic',
|
|
{
|
|
handler: 'Switch',
|
|
label: translate('Dynamic'),
|
|
helpEntries: ['dynamicRemoteData'],
|
|
},
|
|
],
|
|
[
|
|
'options.remoteData.licence',
|
|
{
|
|
label: translate('Licence'),
|
|
helpText: translate('Please be sure the licence is compliant with your use.'),
|
|
},
|
|
],
|
|
]
|
|
if (this._umap.properties.urls.ajax_proxy) {
|
|
remoteDataFields.push([
|
|
'options.remoteData.proxy',
|
|
{
|
|
handler: 'Switch',
|
|
label: translate('Proxy request'),
|
|
helpEntries: ['proxyRemoteData'],
|
|
},
|
|
])
|
|
remoteDataFields.push('options.remoteData.ttl')
|
|
}
|
|
|
|
const remoteDataContainer = DomUtil.createFieldset(
|
|
container,
|
|
translate('Remote data')
|
|
)
|
|
builder = new MutatingForm(this, remoteDataFields)
|
|
remoteDataContainer.appendChild(builder.build())
|
|
DomUtil.createButton(
|
|
'button umap-verify',
|
|
remoteDataContainer,
|
|
translate('Verify remote URL'),
|
|
() => this.fetchRemoteData(true),
|
|
this
|
|
)
|
|
|
|
if (this._umap.properties.urls.datalayer_versions)
|
|
this.buildVersionsFieldset(container)
|
|
|
|
const advancedActions = DomUtil.createFieldset(
|
|
container,
|
|
translate('Advanced actions')
|
|
)
|
|
const advancedButtons = DomUtil.create('div', 'button-bar half', advancedActions)
|
|
const deleteButton = Utils.loadTemplate(`
|
|
<button class="button" type="button">
|
|
<i class="icon icon-24 icon-delete"></i>${translate('Delete')}
|
|
</button>`)
|
|
deleteButton.addEventListener('click', () => {
|
|
this.del()
|
|
this._umap.editPanel.close()
|
|
})
|
|
advancedButtons.appendChild(deleteButton)
|
|
|
|
if (!this.isRemoteLayer()) {
|
|
const emptyLink = DomUtil.createButton(
|
|
'button umap-empty',
|
|
advancedButtons,
|
|
translate('Empty'),
|
|
this.empty,
|
|
this
|
|
)
|
|
}
|
|
const cloneLink = DomUtil.createButton(
|
|
'button umap-clone',
|
|
advancedButtons,
|
|
translate('Clone'),
|
|
function () {
|
|
const datalayer = this.clone()
|
|
datalayer.edit()
|
|
},
|
|
this
|
|
)
|
|
if (this.createdOnServer) {
|
|
const filename = `${Utils.slugify(this.options.name)}.geojson`
|
|
const download = Utils.loadTemplate(`
|
|
<a class="button" href="${this._dataUrl()}" download="${filename}">
|
|
<i class="icon icon-24 icon-download"></i>${translate('Download')}
|
|
</a>`)
|
|
advancedButtons.appendChild(download)
|
|
}
|
|
const backButton = DomUtil.createButtonIcon(
|
|
undefined,
|
|
'icon-back',
|
|
translate('Back to layers')
|
|
)
|
|
// Fixme: remove me when this is merged and released
|
|
// https://github.com/Leaflet/Leaflet/pull/9052
|
|
DomEvent.disableClickPropagation(backButton)
|
|
DomEvent.on(backButton, 'click', this._umap.editDatalayers, this._umap)
|
|
|
|
this._umap.editPanel.open({
|
|
content: container,
|
|
highlight: 'layers',
|
|
actions: [backButton],
|
|
})
|
|
}
|
|
|
|
getOwnOption(option) {
|
|
if (Utils.usableOption(this.options, option)) return this.options[option]
|
|
}
|
|
|
|
getOption(option, feature) {
|
|
if (this.layer?.getOption) {
|
|
const value = this.layer.getOption(option, feature)
|
|
if (value !== undefined) return value
|
|
}
|
|
if (this.getOwnOption(option) !== undefined) {
|
|
return this.getOwnOption(option)
|
|
}
|
|
if (this.layer?.defaults?.[option]) {
|
|
return this.layer.defaults[option]
|
|
}
|
|
return this._umap.getProperty(option, feature)
|
|
}
|
|
|
|
async buildVersionsFieldset(container) {
|
|
const appendVersion = (data) => {
|
|
const date = new Date(Number.parseInt(data.at, 10))
|
|
const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)`
|
|
const el = DomUtil.create('div', 'umap-datalayer-version', versionsContainer)
|
|
const button = DomUtil.createButton('', el, '', () => this.restore(data.ref))
|
|
button.title = translate('Restore this version')
|
|
DomUtil.add('span', '', el, content)
|
|
}
|
|
|
|
const versionsContainer = DomUtil.createFieldset(container, translate('Versions'), {
|
|
async callback() {
|
|
const [{ versions }, response, error] = await this._umap.server.get(
|
|
this.getVersionsUrl()
|
|
)
|
|
if (!error) versions.forEach(appendVersion)
|
|
},
|
|
context: this,
|
|
})
|
|
}
|
|
|
|
async restore(version) {
|
|
if (!this._umap.editEnabled) return
|
|
this._umap.dialog
|
|
.confirm(translate('Are you sure you want to restore this version?'))
|
|
.then(async () => {
|
|
const [geojson, response, error] = await this._umap.server.get(
|
|
this.getVersionUrl(version)
|
|
)
|
|
if (!error) {
|
|
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat.
|
|
if (geojson._umap_options) this.setOptions(geojson._umap_options)
|
|
this.empty()
|
|
if (this.isRemoteLayer()) this.fetchRemoteData()
|
|
else this.addData(geojson)
|
|
this.isDirty = true
|
|
}
|
|
})
|
|
}
|
|
|
|
featuresToGeoJSON() {
|
|
const features = []
|
|
this.eachFeature((feature) => features.push(feature.toGeoJSON()))
|
|
return features
|
|
}
|
|
|
|
async show() {
|
|
this._leafletMap.addLayer(this.layer)
|
|
if (!this.isLoaded()) await this.fetchData()
|
|
this.propagateShow()
|
|
}
|
|
|
|
hide() {
|
|
this._leafletMap.removeLayer(this.layer)
|
|
this.propagateHide()
|
|
}
|
|
|
|
toggle() {
|
|
// From now on, do not try to how/hidedataChanged
|
|
// automatically this layer.
|
|
this._forcedVisibility = true
|
|
if (!this.isVisible()) this.show()
|
|
else this.hide()
|
|
}
|
|
|
|
zoomTo() {
|
|
if (!this.isVisible()) return
|
|
const bounds = this.layer.getBounds()
|
|
this.zoomToBounds(bounds)
|
|
}
|
|
|
|
zoomToBounds(bounds) {
|
|
if (bounds.isValid()) {
|
|
const options = { maxZoom: this.getOption('zoomTo') }
|
|
this._leafletMap.fitBounds(bounds, options)
|
|
}
|
|
}
|
|
|
|
// Is this layer type browsable in theorie
|
|
isBrowsable() {
|
|
return this.layer?.browsable
|
|
}
|
|
|
|
// Is this layer browsable in theorie
|
|
// AND the user allows it
|
|
allowBrowse() {
|
|
return !!this.options.browsable && this.isBrowsable()
|
|
}
|
|
|
|
// Is this layer browsable in theorie
|
|
// AND the user allows it
|
|
// AND it makes actually sense (is visible, it has data…)
|
|
canBrowse() {
|
|
return this.allowBrowse() && this.isVisible() && this.hasData()
|
|
}
|
|
|
|
count() {
|
|
return this._index.length
|
|
}
|
|
|
|
hasData() {
|
|
return !!this._index.length
|
|
}
|
|
|
|
isVisible() {
|
|
return Boolean(this.layer && this._leafletMap.hasLayer(this.layer))
|
|
}
|
|
|
|
getFeatureByIndex(index) {
|
|
if (index === -1) index = this._index.length - 1
|
|
const id = this._index[index]
|
|
return this._features[id]
|
|
}
|
|
|
|
// TODO Add an index
|
|
// For now, iterate on all the features.
|
|
getFeatureById(id) {
|
|
return Object.values(this._features).find((feature) => feature.id === id)
|
|
}
|
|
|
|
getNextFeature(feature) {
|
|
const id = this._index.indexOf(stamp(feature))
|
|
const nextId = this._index[id + 1]
|
|
return nextId
|
|
? this._features[nextId]
|
|
: this.getNextBrowsable().getFeatureByIndex(0)
|
|
}
|
|
|
|
getPreviousFeature(feature) {
|
|
if (this._index <= 1) {
|
|
return null
|
|
}
|
|
const id = this._index.indexOf(stamp(feature))
|
|
const previousId = this._index[id - 1]
|
|
return previousId
|
|
? this._features[previousId]
|
|
: this.getPreviousBrowsable().getFeatureByIndex(-1)
|
|
}
|
|
|
|
getPreviousBrowsable() {
|
|
let id = this.getRank()
|
|
let next
|
|
const index = this._umap.datalayersIndex
|
|
while (((id = index[++id] ? id : 0), (next = index[id]))) {
|
|
if (next === this || next.canBrowse()) break
|
|
}
|
|
return next
|
|
}
|
|
|
|
getNextBrowsable() {
|
|
let id = this.getRank()
|
|
let prev
|
|
const index = this._umap.datalayersIndex
|
|
while (((id = index[--id] ? id : index.length - 1), (prev = index[id]))) {
|
|
if (prev === this || prev.canBrowse()) break
|
|
}
|
|
return prev
|
|
}
|
|
|
|
umapGeoJSON() {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: this.isRemoteLayer() ? [] : this.featuresToGeoJSON(),
|
|
_umap_options: this.options,
|
|
}
|
|
}
|
|
|
|
getRank() {
|
|
return this._umap.datalayersIndex.indexOf(this)
|
|
}
|
|
|
|
isReadOnly() {
|
|
// isReadOnly must return true if unset
|
|
return this.options.editMode === 'disabled'
|
|
}
|
|
|
|
isDataReadOnly() {
|
|
// This layer cannot accept features
|
|
return this.isReadOnly() || this.isRemoteLayer()
|
|
}
|
|
|
|
setReferenceVersion({ response, sync }) {
|
|
this._referenceVersion = response.headers.get('X-Datalayer-Version')
|
|
if (sync) this.sync.update('_referenceVersion', this._referenceVersion)
|
|
}
|
|
|
|
async save() {
|
|
if (this.isDeleted) return await this.saveDelete()
|
|
if (!this.isLoaded()) {
|
|
return
|
|
}
|
|
const geojson = this.umapGeoJSON()
|
|
const formData = new FormData()
|
|
formData.append('name', this.options.name)
|
|
formData.append('display_on_load', !!this.options.displayOnLoad)
|
|
formData.append('rank', this.getRank())
|
|
formData.append('settings', JSON.stringify(this.options))
|
|
// Filename support is shaky, don't do it for now.
|
|
const blob = new Blob([JSON.stringify(geojson)], { type: 'application/json' })
|
|
formData.append('geojson', blob)
|
|
const saveURL = this._umap.urls.get('datalayer_save', {
|
|
map_id: this._umap.id,
|
|
pk: this.id,
|
|
created: this.createdOnServer,
|
|
})
|
|
const headers = this._referenceVersion
|
|
? { 'X-Datalayer-Reference': this._referenceVersion }
|
|
: {}
|
|
const status = await this._trySave(saveURL, headers, formData)
|
|
this._geojson = geojson
|
|
return status
|
|
}
|
|
|
|
async _trySave(url, headers, formData) {
|
|
const [data, response, error] = await this._umap.server.post(url, headers, formData)
|
|
if (error) {
|
|
if (response && response.status === 412) {
|
|
AlertConflict.error(
|
|
translate(
|
|
'Whoops! Other contributor(s) changed some of the same map elements as you. ' +
|
|
'This situation is tricky, you have to choose carefully which version is pertinent.'
|
|
),
|
|
async () => {
|
|
// Save again this layer
|
|
const status = await this._trySave(url, {}, formData)
|
|
if (status) {
|
|
this.isDirty = false
|
|
|
|
// Call the main save, in case something else needs to be saved
|
|
// as the conflict stopped the saving flow
|
|
await this._umap.saveAll()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
} else {
|
|
// Response contains geojson only if save has conflicted and conflicts have
|
|
// been resolved. So we need to reload to get extra data (added by someone else)
|
|
if (data.geojson) {
|
|
this.clear()
|
|
this.fromGeoJSON(data.geojson)
|
|
delete data.geojson
|
|
}
|
|
delete data.id
|
|
delete data._referenceVersion
|
|
this.updateOptions(data)
|
|
|
|
this.setReferenceVersion({ response, sync: true })
|
|
|
|
this.backupOptions()
|
|
this.backupData()
|
|
this.connectToMap()
|
|
this._loaded = true
|
|
this.redraw() // Needed for reordering features
|
|
return true
|
|
}
|
|
}
|
|
|
|
async saveDelete() {
|
|
if (this.createdOnServer) {
|
|
await this._umap.server.post(this.getDeleteUrl())
|
|
}
|
|
this.commitDelete()
|
|
return true
|
|
}
|
|
|
|
commitDelete() {
|
|
delete this._umap.datalayers[stamp(this)]
|
|
}
|
|
|
|
getName() {
|
|
return this.options.name || translate('Untitled layer')
|
|
}
|
|
|
|
tableEdit() {
|
|
if (!this.isVisible()) return
|
|
const editor = new TableEditor(this._umap, this, this._leafletMap)
|
|
editor.open()
|
|
}
|
|
|
|
getFilterKeys() {
|
|
// This keys will be used to filter feature from the browser text input.
|
|
// By default, it will we use the "name" property, which is also the one used as label in the features list.
|
|
// When map owner has configured another label or sort key, we try to be smart and search in the same keys.
|
|
if (this._umap.properties.filterKey) return this._umap.properties.filterKey
|
|
if (this.getOption('labelKey')) return this.getOption('labelKey')
|
|
if (this._umap.properties.sortKey) return this._umap.properties.sortKey
|
|
return 'displayName'
|
|
}
|
|
|
|
renderLegend() {
|
|
for (const container of document.querySelectorAll(
|
|
`.${this.cssId} .datalayer-legend`
|
|
)) {
|
|
container.innerHTML = ''
|
|
if (this.layer.renderLegend) return this.layer.renderLegend(container)
|
|
const color = DomUtil.create('span', 'datalayer-color', container)
|
|
color.style.backgroundColor = this.getColor()
|
|
}
|
|
}
|
|
|
|
renderToolbox(container) {
|
|
const toggle = DomUtil.createButtonIcon(
|
|
container,
|
|
'icon-eye',
|
|
translate('Show/hide layer')
|
|
)
|
|
const zoomTo = DomUtil.createButtonIcon(
|
|
container,
|
|
'icon-zoom',
|
|
translate('Zoom to layer extent')
|
|
)
|
|
const edit = DomUtil.createButtonIcon(
|
|
container,
|
|
'icon-edit show-on-edit',
|
|
translate('Edit')
|
|
)
|
|
const table = DomUtil.createButtonIcon(
|
|
container,
|
|
'icon-table show-on-edit',
|
|
translate('Edit properties in a table')
|
|
)
|
|
const remove = DomUtil.createButtonIcon(
|
|
container,
|
|
'icon-delete show-on-edit',
|
|
translate('Delete layer')
|
|
)
|
|
if (this.isReadOnly()) {
|
|
DomUtil.addClass(container, 'readonly')
|
|
} else {
|
|
DomEvent.on(edit, 'click', this.edit, this)
|
|
DomEvent.on(table, 'click', this.tableEdit, this)
|
|
DomEvent.on(
|
|
remove,
|
|
'click',
|
|
function () {
|
|
if (!this.isVisible()) return
|
|
this._umap.dialog
|
|
.confirm(translate('Are you sure you want to delete this layer?'))
|
|
.then(() => {
|
|
this.del()
|
|
})
|
|
},
|
|
this
|
|
)
|
|
}
|
|
DomEvent.on(toggle, 'click', this.toggle, this)
|
|
DomEvent.on(zoomTo, 'click', this.zoomTo, this)
|
|
container.classList.add(this.getHidableClass())
|
|
container.classList.toggle('off', !this.isVisible())
|
|
}
|
|
|
|
getHidableElements() {
|
|
return document.querySelectorAll(`.${this.getHidableClass()}`)
|
|
}
|
|
|
|
getHidableClass() {
|
|
return `show_with_datalayer_${stamp(this)}`
|
|
}
|
|
|
|
propagateDelete() {
|
|
const els = this.getHidableElements()
|
|
for (const el of els) {
|
|
DomUtil.remove(el)
|
|
}
|
|
}
|
|
|
|
propagateRemote() {
|
|
const els = this.getHidableElements()
|
|
for (const el of els) {
|
|
el.classList.toggle('remotelayer', this.isRemoteLayer())
|
|
}
|
|
}
|
|
|
|
propagateHide() {
|
|
const els = this.getHidableElements()
|
|
for (let i = 0; i < els.length; i++) {
|
|
DomUtil.addClass(els[i], 'off')
|
|
}
|
|
}
|
|
|
|
propagateShow() {
|
|
const els = this.getHidableElements()
|
|
for (let i = 0; i < els.length; i++) {
|
|
DomUtil.removeClass(els[i], 'off')
|
|
}
|
|
}
|
|
}
|