umap/umap/static/umap/js/umap.layer.js

1792 lines
50 KiB
JavaScript

U.Layer = {
browsable: true,
getType: function () {
const proto = Object.getPrototypeOf(this)
return proto.constructor.TYPE
},
getName: function () {
const proto = Object.getPrototypeOf(this)
return proto.constructor.NAME
},
getFeatures: function () {
return this._layers
},
getEditableOptions: function () {
return []
},
onEdit: function () {},
hasDataVisible: function () {
return !!Object.keys(this._layers).length
},
}
U.Layer.Default = L.FeatureGroup.extend({
statics: {
NAME: L._('Default'),
TYPE: 'Default',
},
includes: [U.Layer],
initialize: function (datalayer) {
this.datalayer = datalayer
L.FeatureGroup.prototype.initialize.call(this)
},
})
U.MarkerCluster = L.MarkerCluster.extend({
// Custom class so we can call computeTextColor
// when element is already on the DOM.
_initIcon: function () {
L.MarkerCluster.prototype._initIcon.call(this)
const div = this._icon.querySelector('div')
// Compute text color only when icon is added to the DOM.
div.style.color = this._iconObj.computeTextColor(div)
},
})
U.Layer.Cluster = L.MarkerClusterGroup.extend({
statics: {
NAME: L._('Clustered'),
TYPE: 'Cluster',
},
includes: [U.Layer],
initialize: function (datalayer) {
this.datalayer = datalayer
if (!U.Utils.isObject(this.datalayer.options.cluster)) {
this.datalayer.options.cluster = {}
}
const options = {
polygonOptions: {
color: this.datalayer.getColor(),
},
iconCreateFunction: function (cluster) {
return new U.Icon.Cluster(datalayer, cluster)
},
}
if (this.datalayer.options.cluster && this.datalayer.options.cluster.radius) {
options.maxClusterRadius = this.datalayer.options.cluster.radius
}
L.MarkerClusterGroup.prototype.initialize.call(this, options)
this._markerCluster = U.MarkerCluster
this._layers = []
},
onRemove: function (map) {
// In some situation, the onRemove is called before the layer is really
// added to the map: basically when combining a defaultView=data + max/minZoom
// and loading the map at a zoom outside of that zoom range.
// FIXME: move this upstream (_unbindEvents should accept a map parameter
// instead of relying on this._map)
this._map = map
return L.MarkerClusterGroup.prototype.onRemove.call(this, map)
},
addLayer: function (layer) {
this._layers.push(layer)
return L.MarkerClusterGroup.prototype.addLayer.call(this, layer)
},
removeLayer: function (layer) {
this._layers.splice(this._layers.indexOf(layer), 1)
return L.MarkerClusterGroup.prototype.removeLayer.call(this, layer)
},
getEditableOptions: function () {
return [
[
'options.cluster.radius',
{
handler: 'BlurIntInput',
placeholder: L._('Clustering radius'),
helpText: L._('Override clustering radius (default 80)'),
},
],
[
'options.cluster.textColor',
{
handler: 'TextColorPicker',
placeholder: L._('Auto'),
helpText: L._('Text color for the cluster label'),
},
],
]
},
onEdit: function (field, builder) {
if (field === 'options.cluster.radius') {
// No way to reset radius of an already instanciated MarkerClusterGroup...
this.datalayer.resetLayer(true)
return
}
if (field === 'options.color') {
this.options.polygonOptions.color = this.datalayer.getColor()
}
},
})
U.Layer.Choropleth = L.FeatureGroup.extend({
statics: {
NAME: L._('Choropleth'),
TYPE: 'Choropleth',
},
includes: [U.Layer],
// Have defaults that better suit the choropleth mode.
defaults: {
color: 'white',
fillColor: 'red',
fillOpacity: 0.7,
weight: 2,
},
MODES: {
kmeans: L._('K-means'),
equidistant: L._('Equidistant'),
jenks: L._('Jenks-Fisher'),
quantiles: L._('Quantiles'),
manual: L._('Manual'),
},
initialize: function (datalayer) {
this.datalayer = datalayer
if (!U.Utils.isObject(this.datalayer.options.choropleth)) {
this.datalayer.options.choropleth = {}
}
L.FeatureGroup.prototype.initialize.call(
this,
[],
this.datalayer.options.choropleth
)
this.datalayer.onceDataLoaded(() => {
this.redraw()
this.datalayer.on('datachanged', this.redraw, this)
})
},
redraw: function () {
this.computeBreaks()
if (this._map) this.eachLayer(this._map.addLayer, this._map)
},
_getValue: function (feature) {
const key = this.datalayer.options.choropleth.property || 'value'
return +feature.properties[key] // TODO: should we catch values non castable to int ?
},
getValues: function () {
const values = []
this.datalayer.eachLayer((layer) => {
let value = this._getValue(layer)
if (!isNaN(value)) values.push(value)
})
return values
},
computeBreaks: function () {
const values = this.getValues()
if (!values.length) {
this.options.breaks = []
this.options.colors = []
return
}
let mode = this.datalayer.options.choropleth.mode,
classes = +this.datalayer.options.choropleth.classes || 5,
breaks
classes = Math.min(classes, values.length)
if (mode === 'manual') {
const manualBreaks = this.datalayer.options.choropleth.breaks
if (manualBreaks) {
breaks = manualBreaks
.split(',')
.map((b) => +b)
.filter((b) => !isNaN(b))
}
} else if (mode === 'equidistant') {
breaks = ss.equalIntervalBreaks(values, classes)
} else if (mode === 'jenks') {
breaks = ss.jenks(values, classes)
} else if (mode === 'quantiles') {
const quantiles = [...Array(classes)].map((e, i) => i / classes).concat(1)
breaks = ss.quantile(values, quantiles)
} else {
breaks = ss.ckmeans(values, classes).map((cluster) => cluster[0])
breaks.push(ss.max(values)) // Needed for computing the legend
}
this.options.breaks = breaks || []
this.datalayer.options.choropleth.breaks = this.options.breaks
.map((b) => +b.toFixed(2))
.join(',')
const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor
let colorScheme = this.datalayer.options.choropleth.brewer
if (!colorbrewer[colorScheme]) colorScheme = 'Blues'
this.options.colors = colorbrewer[colorScheme][this.options.breaks.length - 1] || []
},
getColor: function (feature) {
if (!feature) return // FIXME shold not happen
const featureValue = this._getValue(feature)
// Find the bucket/step/limit that this value is less than and give it that color
for (let i = 1; i < this.options.breaks.length; i++) {
if (featureValue <= this.options.breaks[i]) {
return this.options.colors[i - 1]
}
}
},
getOption: function (option, feature) {
if (feature && option === feature.staticOptions.mainColor) {
return this.getColor(feature)
}
},
addLayer: function (layer) {
// Do not add yet the layer to the map
// wait for datachanged event, so we want compute breaks once
var id = this.getLayerId(layer)
this._layers[id] = layer
return this
},
onAdd: function (map) {
this.computeBreaks()
L.FeatureGroup.prototype.onAdd.call(this, map)
},
onEdit: function (field, builder) {
// Only compute the breaks if we're dealing with choropleth
if (!field.startsWith('options.choropleth')) return
// If user touches the breaks, then force manual mode
if (field === 'options.choropleth.breaks') {
this.datalayer.options.choropleth.mode = 'manual'
if (builder) builder.helpers['options.choropleth.mode'].fetch()
}
this.computeBreaks()
// If user changes the mode or the number of classes,
// then update the breaks input value
if (field === 'options.choropleth.mode' || field === 'options.choropleth.classes') {
if (builder) builder.helpers['options.choropleth.breaks'].fetch()
}
},
getEditableOptions: function () {
const brewerSchemes = Object.keys(colorbrewer)
.filter((k) => k !== 'schemeGroups')
.sort()
return [
[
'options.choropleth.property',
{
handler: 'Select',
selectOptions: this.datalayer._propertiesIndex,
label: L._('Choropleth property value'),
},
],
[
'options.choropleth.brewer',
{
handler: 'Select',
label: L._('Choropleth color palette'),
selectOptions: brewerSchemes,
},
],
[
'options.choropleth.classes',
{
handler: 'Range',
min: 3,
max: 9,
step: 1,
label: L._('Choropleth classes'),
helpText: L._('Number of desired classes (default 5)'),
},
],
[
'options.choropleth.breaks',
{
handler: 'BlurInput',
label: L._('Choropleth breakpoints'),
helpText: L._(
'Comma separated list of numbers, including min and max values.'
),
},
],
[
'options.choropleth.mode',
{
handler: 'MultiChoice',
default: 'kmeans',
choices: Object.entries(this.MODES),
label: L._('Choropleth mode'),
},
],
]
},
renderLegend: function (container) {
const parent = L.DomUtil.create('ul', '', container)
let li, color, label
this.options.breaks.slice(0, -1).forEach((limit, index) => {
li = L.DomUtil.create('li', '', parent)
color = L.DomUtil.create('span', 'datalayer-color', li)
color.style.backgroundColor = this.options.colors[index]
label = L.DomUtil.create('span', '', li)
label.textContent = `${+this.options.breaks[index].toFixed(
1
)} - ${+this.options.breaks[index + 1].toFixed(1)}`
})
},
})
U.Layer.Heat = L.HeatLayer.extend({
statics: {
NAME: L._('Heatmap'),
TYPE: 'Heat',
},
includes: [U.Layer],
browsable: false,
initialize: function (datalayer) {
this.datalayer = datalayer
L.HeatLayer.prototype.initialize.call(this, [], this.datalayer.options.heat)
if (!U.Utils.isObject(this.datalayer.options.heat)) {
this.datalayer.options.heat = {}
}
},
addLayer: function (layer) {
if (layer instanceof L.Marker) {
let latlng = layer.getLatLng(),
alt
if (
this.datalayer.options.heat &&
this.datalayer.options.heat.intensityProperty
) {
alt = parseFloat(
layer.properties[this.datalayer.options.heat.intensityProperty || 0]
)
latlng = new L.LatLng(latlng.lat, latlng.lng, alt)
}
this.addLatLng(latlng)
}
},
clearLayers: function () {
this.setLatLngs([])
},
getFeatures: function () {
return {}
},
getBounds: function () {
return L.latLngBounds(this._latlngs)
},
getEditableOptions: function () {
return [
[
'options.heat.radius',
{
handler: 'Range',
min: 10,
max: 100,
step: 5,
label: L._('Heatmap radius'),
helpText: L._('Override heatmap radius (default 25)'),
},
],
[
'options.heat.intensityProperty',
{
handler: 'BlurInput',
placeholder: L._('Heatmap intensity property'),
helpText: L._('Optional intensity property for heatmap'),
},
],
]
},
onEdit: function (field, builder) {
if (field === 'options.heat.intensityProperty') {
this.datalayer.resetLayer(true) // We need to repopulate the latlngs
return
}
if (field === 'options.heat.radius') {
this.options.radius = this.datalayer.options.heat.radius
}
this._updateOptions()
},
redraw: function () {
// setlalngs call _redraw through setAnimFrame, thus async, so this
// can ends with race condition if we remove the layer very faslty after.
// TODO: PR in upstream Leaflet.heat
if (!this._map) return
L.HeatLayer.prototype.redraw.call(this)
},
_redraw: function () {
// Import patch from https://github.com/Leaflet/Leaflet.heat/pull/78
// Remove me when this get merged and released.
if (!this._map) {
return
}
var data = [],
r = this._heat._r,
size = this._map.getSize(),
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
cellSize = r / 2,
grid = [],
panePos = this._map._getMapPanePos(),
offsetX = panePos.x % cellSize,
offsetY = panePos.y % cellSize,
i,
len,
p,
cell,
x,
y,
j,
len2
this._max = 1
for (i = 0, len = this._latlngs.length; i < len; i++) {
p = this._map.latLngToContainerPoint(this._latlngs[i])
x = Math.floor((p.x - offsetX) / cellSize) + 2
y = Math.floor((p.y - offsetY) / cellSize) + 2
var alt =
this._latlngs[i].alt !== undefined
? this._latlngs[i].alt
: this._latlngs[i][2] !== undefined
? +this._latlngs[i][2]
: 1
grid[y] = grid[y] || []
cell = grid[y][x]
if (!cell) {
cell = grid[y][x] = [p.x, p.y, alt]
cell.p = p
} else {
cell[0] = (cell[0] * cell[2] + p.x * alt) / (cell[2] + alt) // x
cell[1] = (cell[1] * cell[2] + p.y * alt) / (cell[2] + alt) // y
cell[2] += alt // cumulated intensity value
}
// Set the max for the current zoom level
if (cell[2] > this._max) {
this._max = cell[2]
}
}
this._heat.max(this._max)
for (i = 0, len = grid.length; i < len; i++) {
if (grid[i]) {
for (j = 0, len2 = grid[i].length; j < len2; j++) {
cell = grid[i][j]
if (cell && bounds.contains(cell.p)) {
data.push([
Math.round(cell[0]),
Math.round(cell[1]),
Math.min(cell[2], this._max),
])
}
}
}
}
this._heat.data(data).draw(this.options.minOpacity)
this._frame = null
},
})
U.DataLayer = L.Evented.extend({
options: {
displayOnLoad: true,
inCaption: true,
browsable: true,
editMode: 'advanced',
},
initialize: function (map, data, sync) {
this.map = map
this._index = Array()
this._layers = {}
this._geojson = null
this._propertiesIndex = []
this._loaded = false // Are layer metadata loaded
this._dataloaded = false // Are layer data loaded
this.parentPane = this.map.getPane('overlayPane')
this.pane = this.map.createPane(`datalayer${L.stamp(this)}`, this.parentPane)
this.pane.dataset.id = L.stamp(this)
this.renderer = L.svg({ pane: this.pane })
let isDirty = false
let isDeleted = false
const self = this
try {
Object.defineProperty(this, 'isDirty', {
get: function () {
return isDirty
},
set: function (status) {
if (!isDirty && status) self.fire('dirty')
isDirty = status
if (status) {
self.map.addDirtyDatalayer(self)
// A layer can be made dirty by indirect action (like dragging layers)
// we need to have it loaded before saving it.
if (!self.isLoaded()) self.fetchData()
} else {
self.map.removeDirtyDatalayer(self)
self.isDeleted = false
}
},
})
} catch (e) {
// Certainly IE8, which has a limited version of defineProperty
}
try {
Object.defineProperty(this, 'isDeleted', {
get: function () {
return isDeleted
},
set: function (status) {
if (!isDeleted && status) self.fire('deleted')
isDeleted = status
if (status) self.isDirty = status
},
})
} catch (e) {
// Certainly IE8, which has a limited version of defineProperty
}
this.setUmapId(data.id)
this.setOptions(data)
if (!U.Utils.isObject(this.options.remoteData)) {
this.options.remoteData = {}
}
// Retrocompat
if (this.options.remoteData && this.options.remoteData.from) {
this.options.fromZoom = this.options.remoteData.from
delete this.options.remoteData.from
}
if (this.options.remoteData && this.options.remoteData.to) {
this.options.toZoom = this.options.remoteData.to
delete this.options.remoteData.to
}
this.backupOptions()
this.connectToMap()
this.permissions = new U.DataLayerPermissions(this)
if (!this.umap_id) {
if (this.showAtLoad()) this.show()
this.isDirty = true
}
this.onceLoaded(function () {
this.map.on('moveend', this.onMoveEnd, this)
})
// 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.autoLoaded()) this.map.on('zoomend', this.onZoomEnd, this)
this.on('datachanged', this.map.onDataLayersChanged, this.map)
if (sync !== false) {
const { engine, subject, metadata } = this.getSyncMetadata()
engine.upsert(subject, metadata, this.options)
}
},
getSyncMetadata: function () {
return {
engine: this.map.sync,
subject: 'datalayer',
metadata: {
id: this.umap_id,
},
}
},
render: function (fields, builder) {
let impacts = U.Utils.getImpactsFromSchema(fields)
for (let impact of impacts) {
switch (impact) {
case 'ui':
this.map.onDataLayersChanged()
break
case 'data':
if (fields.includes('options.type')) {
this.resetLayer()
}
this.hide()
fields.forEach((field) => {
this.layer.onEdit(field, builder)
})
this.redraw()
this.show()
break
case 'remote-data':
this.fetchRemoteData()
break
}
}
},
onMoveEnd: function (e) {
if (this.isRemoteLayer() && this.showAtZoom()) this.fetchRemoteData()
},
onZoomEnd: function (e) {
if (this._forcedVisibility) return
if (!this.showAtZoom() && this.isVisible()) this.hide()
if (this.showAtZoom() && !this.isVisible()) this.show()
},
showAtLoad: function () {
return this.autoLoaded() && this.showAtZoom()
},
autoLoaded: function () {
if (!this.map.datalayersFromQueryString) return this.options.displayOnLoad
const datalayerIds = this.map.datalayersFromQueryString
let loadMe = datalayerIds.includes(this.umap_id.toString())
if (this.options.old_id) {
loadMe = loadMe || datalayerIds.includes(this.options.old_id.toString())
}
return loadMe
},
insertBefore: function (other) {
if (!other) return
this.parentPane.insertBefore(this.pane, other.pane)
},
insertAfter: function (other) {
if (!other) return
this.parentPane.insertBefore(this.pane, other.pane.nextSibling)
},
bringToTop: function () {
this.parentPane.appendChild(this.pane)
},
hasDataVisible: function () {
return this.layer.hasDataVisible()
},
resetLayer: function (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.map.removeLayer(this.layer)
const Class = U.Layer[this.options.type] || U.Layer.Default
this.layer = new Class(this)
this.eachLayer(this.showFeature)
if (visible) this.show()
this.propagateRemote()
},
eachLayer: function (method, context) {
for (const i in this._layers) {
method.call(context || this, this._layers[i])
}
return this
},
eachFeature: function (method, context) {
if (this.isBrowsable()) {
for (let i = 0; i < this._index.length; i++) {
method.call(context || this, this._layers[this._index[i]])
}
}
return this
},
fetchData: async function () {
if (!this.umap_id) return
if (this._loading) return
this._loading = true
const [geojson, response, error] = await this.map.server.get(this._dataUrl())
if (!error) {
this._reference_version = response.headers.get('X-Datalayer-Version')
// FIXME: for now this 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.fire('loaded')
this._loading = false
}
},
fromGeoJSON: function (geojson) {
this.addData(geojson)
this._geojson = geojson
this._dataloaded = true
this.fire('dataloaded')
this.fire('datachanged')
},
fromUmapGeoJSON: async function (geojson) {
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat
if (geojson._umap_options) this.setOptions(geojson._umap_options)
if (this.isRemoteLayer()) await this.fetchRemoteData()
else this.fromGeoJSON(geojson)
this._loaded = true
},
clear: function () {
this.layer.clearLayers()
this._layers = {}
this._index = Array()
if (this._geojson) {
this.backupData()
this._geojson = null
}
this.fire('datachanged')
},
backupData: function () {
this._geojson_bk = U.Utils.CopyJSON(this._geojson)
},
reindex: function () {
const features = []
this.eachFeature((feature) => features.push(feature))
U.Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang)
this._index = []
for (let i = 0; i < features.length; i++) {
this._index.push(L.Util.stamp(features[i]))
}
},
showAtZoom: function () {
const from = parseInt(this.options.fromZoom, 10),
to = parseInt(this.options.toZoom, 10),
zoom = this.map.getZoom()
return !((!isNaN(from) && zoom < from) || (!isNaN(to) && zoom > to))
},
hasDynamicData: function () {
return !!(this.options.remoteData && this.options.remoteData.dynamic)
},
fetchRemoteData: async function (force) {
if (!this.isRemoteLayer()) return
if (!this.hasDynamicData() && this.hasDataLoaded() && !force) return
if (!this.isVisible()) return
let url = this.map.localizeUrl(this.options.remoteData.url)
if (this.options.remoteData.proxy) {
url = this.map.proxyUrl(url, this.options.remoteData.ttl)
}
const response = await this.map.request.get(url)
if (response && response.ok) {
this.clear()
this.rawToGeoJSON(
await response.text(),
this.options.remoteData.format,
(geojson) => this.fromGeoJSON(geojson)
)
}
},
onceLoaded: function (callback, context) {
if (this.isLoaded()) callback.call(context || this, this)
else this.once('loaded', callback, context)
return this
},
onceDataLoaded: function (callback, context) {
if (this.hasDataLoaded()) callback.call(context || this, this)
else this.once('dataloaded', callback, context)
return this
},
isLoaded: function () {
return !this.umap_id || this._loaded
},
hasDataLoaded: function () {
return this._dataloaded
},
setUmapId: function (id) {
// Datalayer is null when listening creation form
if (!this.umap_id && id) this.umap_id = id
},
backupOptions: function () {
this._backupOptions = U.Utils.CopyJSON(this.options)
},
resetOptions: function () {
this.options = U.Utils.CopyJSON(this._backupOptions)
},
setOptions: function (options) {
delete options.geojson
this.options = U.Utils.CopyJSON(U.DataLayer.prototype.options) // Start from fresh.
this.updateOptions(options)
},
updateOptions: function (options) {
L.Util.setOptions(this, options)
this.resetLayer()
},
connectToMap: function () {
const id = L.stamp(this)
if (!this.map.datalayers[id]) {
this.map.datalayers[id] = this
if (L.Util.indexOf(this.map.datalayers_index, this) === -1)
this.map.datalayers_index.push(this)
this.map.onDataLayersChanged()
}
},
_dataUrl: function () {
const template = this.map.options.urls.datalayer_view
let url = U.Utils.template(template, {
pk: this.umap_id,
map_id: this.map.options.umap_id,
})
// No browser cache for owners/editors.
if (this.map.hasEditMode()) url = `${url}?${Date.now()}`
return url
},
isRemoteLayer: function () {
return Boolean(
this.options.remoteData &&
this.options.remoteData.url &&
this.options.remoteData.format
)
},
isClustered: function () {
return this.options.type === 'Cluster'
},
showFeature: function (feature) {
if (feature.isFiltered()) return
this.layer.addLayer(feature)
},
addLayer: function (feature) {
const id = L.stamp(feature)
feature.connectToDataLayer(this)
this._index.push(id)
this._layers[id] = feature
this.indexProperties(feature)
this.map.features_index[feature.getSlug()] = feature
this.showFeature(feature)
if (this.hasDataLoaded()) this.fire('datachanged')
},
removeLayer: function (feature) {
const id = L.stamp(feature)
feature.disconnectFromDataLayer(this)
this._index.splice(this._index.indexOf(id), 1)
delete this._layers[id]
this.layer.removeLayer(feature)
delete this.map.features_index[feature.getSlug()]
if (this.hasDataLoaded()) this.fire('datachanged')
},
indexProperties: function (feature) {
for (const i in feature.properties)
if (typeof feature.properties[i] !== 'object') this.indexProperty(i)
},
indexProperty: function (name) {
if (!name) return
if (name.indexOf('_') === 0) return
if (L.Util.indexOf(this._propertiesIndex, name) !== -1) return
this._propertiesIndex.push(name)
},
deindexProperty: function (name) {
const idx = this._propertiesIndex.indexOf(name)
if (idx !== -1) this._propertiesIndex.splice(idx, 1)
},
addData: function (geojson) {
try {
// Do not fail if remote data is somehow invalid,
// otherwise the layer becomes uneditable.
this.geojsonToFeatures(geojson)
} catch (err) {
console.log('Error with DataLayer', this.umap_id)
console.error(err)
}
},
addRawData: function (c, type) {
this.rawToGeoJSON(c, type, (geojson) => this.addData(geojson))
},
rawToGeoJSON: function (c, type, callback) {
const toDom = (x) => {
const doc = new DOMParser().parseFromString(x, 'text/xml')
const errorNode = doc.querySelector('parsererror')
if (errorNode) {
this.map.alert.open({ content: L._('Cannot parse data'), level: 'error' })
}
return doc
}
// TODO add a duck typing guessType
if (type === 'csv') {
csv2geojson.csv2geojson(
c,
{
delimiter: 'auto',
includeLatLon: false,
},
(err, result) => {
// csv2geojson fallback to null geometries when it cannot determine
// lat or lon columns. This is valid geojson, but unwanted from a user
// point of view.
if (result && result.features.length) {
if (result.features[0].geometry === null) {
err = {
type: 'Error',
message: L._('Cannot determine latitude and longitude columns.'),
}
}
}
if (err) {
let message
if (err.type === 'Error') {
message = err.message
} else {
message = L._('{count} errors during import: {message}', {
count: err.length,
message: err[0].message,
})
}
this.map.alert.open({ content: message, level: 'error', duration: 10000 })
console.error(err)
}
if (result && result.features.length) {
callback(result)
}
}
)
} else if (type === 'gpx') {
callback(toGeoJSON.gpx(toDom(c)))
} else if (type === 'georss') {
callback(GeoRSSToGeoJSON(toDom(c)))
} else if (type === 'kml') {
callback(toGeoJSON.kml(toDom(c)))
} else if (type === 'osm') {
let d
try {
d = JSON.parse(c)
} catch (e) {
d = toDom(c)
}
callback(osmtogeojson(d, { flatProperties: true }))
} else if (type === 'geojson') {
try {
const gj = JSON.parse(c)
callback(gj)
} catch (err) {
this.map.alert.open({ content: `Invalid JSON file: ${err}` })
return
}
}
},
geojsonToFeatures: function (geojson) {
if (!geojson) return
const features = geojson instanceof Array ? geojson : geojson.features
let i
let len
if (features) {
U.Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang)
for (i = 0, len = features.length; i < len; i++) {
this.geojsonToFeatures(features[i])
}
return this // Why returning "this" ?
}
const geometry = geojson.type === 'Feature' ? geojson.geometry : geojson
let feature = this.geometryToFeature({ geometry, geojson })
if (feature) {
this.addLayer(feature)
feature.onCommit()
return feature
}
},
/**
* Create or update Leaflet features from GeoJSON geometries.
*
* If no `feature` is provided, a new feature will be created.
* If `feature` is provided, it will be updated with the passed geometry.
*
* GeoJSON and Leaflet use incompatible formats to encode coordinates.
* This method takes care of the convertion.
*
* @param geometry GeoJSON geometry field
* @param geojson Enclosing GeoJSON. If none is provided, a new one will
* be created
* @param id Id of the feature
* @param feature Leaflet feature that should be updated with the new geometry
* @returns Leaflet feature.
*/
geometryToFeature: function ({
geometry,
geojson = null,
id = null,
feature = null,
} = {}) {
if (!geometry) return // null geometry is valid geojson.
const coords = geometry.coordinates
let latlng, latlngs
// Create a default geojson if none is provided
geojson ??= { type: 'Feature', geometry: geometry }
switch (geometry.type) {
case 'Point':
try {
latlng = L.GeoJSON.coordsToLatLng(coords)
} catch (e) {
console.error('Invalid latlng object from', coords)
break
}
if (feature) {
feature.setLatLng(latlng)
return feature
}
return this._pointToLayer(geojson, latlng, id)
case 'MultiLineString':
case 'LineString':
latlngs = L.GeoJSON.coordsToLatLngs(
coords,
geometry.type === 'LineString' ? 0 : 1
)
if (!latlngs.length) break
if (feature) {
feature.setLatLngs(latlngs)
return feature
}
return this._lineToLayer(geojson, latlngs, id)
case 'MultiPolygon':
case 'Polygon':
latlngs = L.GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2)
if (feature) {
feature.setLatLngs(latlngs)
return feature
}
return this._polygonToLayer(geojson, latlngs, id)
case 'GeometryCollection':
return this.geojsonToFeatures(geometry.geometries)
default:
this.map.alert.open({
content: L._('Skipping unknown geometry.type: {type}', {
type: geometry.type || 'undefined',
}),
level: 'error',
})
}
},
_pointToLayer: function (geojson, latlng, id) {
return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id)
},
_lineToLayer: function (geojson, latlngs, id) {
return new U.Polyline(
this.map,
latlngs,
{
geojson: geojson,
datalayer: this,
color: null,
},
id
)
},
_polygonToLayer: function (geojson, latlngs, id) {
// Ensure no empty hole
// for (let i = latlngs.length - 1; i > 0; i--) {
// if (!latlngs.slice()[i].length) latlngs.splice(i, 1);
// }
return new U.Polygon(this.map, latlngs, { geojson: geojson, datalayer: this }, id)
},
importRaw: function (raw, type) {
this.addRawData(raw, type)
this.isDirty = true
this.zoomTo()
},
importFromFiles: function (files, type) {
for (let i = 0, f; (f = files[i]); i++) {
this.importFromFile(f, type)
}
},
importFromFile: function (f, type) {
const reader = new FileReader()
type = type || U.Utils.detectFileType(f)
reader.readAsText(f)
reader.onload = (e) => this.importRaw(e.target.result, type)
},
importFromUrl: async function (uri, type) {
uri = this.map.localizeUrl(uri)
const response = await this.map.request.get(uri)
if (response && response.ok) {
this.importRaw(await response.text(), type)
}
},
getColor: function () {
return this.options.color || this.map.getOption('color')
},
getDeleteUrl: function () {
return U.Utils.template(this.map.options.urls.datalayer_delete, {
pk: this.umap_id,
map_id: this.map.options.umap_id,
})
},
getVersionsUrl: function () {
return U.Utils.template(this.map.options.urls.datalayer_versions, {
pk: this.umap_id,
map_id: this.map.options.umap_id,
})
},
getVersionUrl: function (name) {
return U.Utils.template(this.map.options.urls.datalayer_version, {
pk: this.umap_id,
map_id: this.map.options.umap_id,
name: name,
})
},
_delete: function () {
this.isDeleted = true
this.erase()
},
empty: function () {
if (this.isRemoteLayer()) return
this.clear()
this.isDirty = true
},
clone: function () {
const options = U.Utils.CopyJSON(this.options)
options.name = L._('Clone of {name}', { name: this.options.name })
delete options.id
const geojson = U.Utils.CopyJSON(this._geojson),
datalayer = this.map.createDataLayer(options)
datalayer.fromGeoJSON(geojson)
return datalayer
},
erase: function () {
this.hide()
delete this.map.datalayers[L.stamp(this)]
this.map.datalayers_index.splice(this.getRank(), 1)
this.parentPane.removeChild(this.pane)
this.map.onDataLayersChanged()
this.off('datachanged', this.map.onDataLayersChanged, this.map)
this.fire('erase')
this._leaflet_events_bk = this._leaflet_events
this.map.off('moveend', this.onMoveEnd, this)
this.map.off('zoomend', this.onZoomEnd, this)
this.off()
this.clear()
delete this._loaded
delete this._dataloaded
},
reset: function () {
if (!this.umap_id) this.erase()
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: function () {
if (!this.isVisible()) return
this.hide()
this.show()
},
edit: function () {
if (!this.map.editEnabled || !this.isLoaded()) {
return
}
const container = L.DomUtil.create('div', 'umap-layer-properties-container'),
metadataFields = [
'options.name',
'options.description',
['options.type', { handler: 'LayerTypeChooser', label: L._('Type of layer') }],
['options.displayOnLoad', { label: L._('Display on load'), handler: 'Switch' }],
[
'options.browsable',
{
label: L._('Data is browsable'),
handler: 'Switch',
helpEntries: 'browsable',
},
],
[
'options.inCaption',
{
label: L._('Show this layer in the caption'),
handler: 'Switch',
},
],
]
L.DomUtil.createTitle(container, L._('Layer properties'), 'icon-layers')
let builder = new U.FormBuilder(this, metadataFields, {
callback: function (e) {
this.map.onDataLayersChanged()
if (e.helper.field === 'options.type') {
this.edit()
}
},
})
container.appendChild(builder.build())
const layerOptions = this.layer.getEditableOptions()
if (layerOptions.length) {
builder = new U.FormBuilder(this, layerOptions, {
id: 'datalayer-layer-properties',
})
const layerProperties = L.DomUtil.createFieldset(
container,
`${this.layer.getName()}: ${L._('settings')}`
)
layerProperties.appendChild(builder.build())
}
let shapeOptions = [
'options.color',
'options.iconClass',
'options.iconUrl',
'options.iconOpacity',
'options.opacity',
'options.stroke',
'options.weight',
'options.fill',
'options.fillColor',
'options.fillOpacity',
]
builder = new U.FormBuilder(this, shapeOptions, {
id: 'datalayer-advanced-properties',
})
const shapeProperties = L.DomUtil.createFieldset(container, L._('Shape properties'))
shapeProperties.appendChild(builder.build())
let optionsFields = [
'options.smoothFactor',
'options.dashArray',
'options.zoomTo',
'options.fromZoom',
'options.toZoom',
'options.labelKey',
]
builder = new U.FormBuilder(this, optionsFields, {
id: 'datalayer-advanced-properties',
})
const advancedProperties = L.DomUtil.createFieldset(
container,
L._('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 U.FormBuilder(this, popupFields)
const popupFieldset = L.DomUtil.createFieldset(
container,
L._('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 (!U.Utils.isObject(this.options.remoteData)) {
this.options.remoteData = {}
}
const remoteDataFields = [
[
'options.remoteData.url',
{ handler: 'Url', label: L._('Url'), helpEntries: 'formatURL' },
],
['options.remoteData.format', { handler: 'DataFormat', label: L._('Format') }],
'options.fromZoom',
'options.toZoom',
[
'options.remoteData.dynamic',
{ handler: 'Switch', label: L._('Dynamic'), helpEntries: 'dynamicRemoteData' },
],
[
'options.remoteData.licence',
{
label: L._('Licence'),
helpText: L._('Please be sure the licence is compliant with your use.'),
},
],
]
if (this.map.options.urls.ajax_proxy) {
remoteDataFields.push([
'options.remoteData.proxy',
{
handler: 'Switch',
label: L._('Proxy request'),
helpEntries: 'proxyRemoteData',
},
])
remoteDataFields.push([
'options.remoteData.ttl',
{ handler: 'ProxyTTLSelect', label: L._('Cache proxied request') },
])
}
const remoteDataContainer = L.DomUtil.createFieldset(container, L._('Remote data'))
builder = new U.FormBuilder(this, remoteDataFields)
remoteDataContainer.appendChild(builder.build())
L.DomUtil.createButton(
'button umap-verify',
remoteDataContainer,
L._('Verify remote URL'),
() => this.fetchRemoteData(true),
this
)
if (this.map.options.urls.datalayer_versions) this.buildVersionsFieldset(container)
const advancedActions = L.DomUtil.createFieldset(container, L._('Advanced actions'))
const advancedButtons = L.DomUtil.create('div', 'button-bar half', advancedActions)
const deleteLink = L.DomUtil.createButton(
'button delete_datalayer_button umap-delete',
advancedButtons,
L._('Delete'),
function () {
this._delete()
this.map.editPanel.close()
},
this
)
if (!this.isRemoteLayer()) {
const emptyLink = L.DomUtil.createButton(
'button umap-empty',
advancedButtons,
L._('Empty'),
this.empty,
this
)
}
const cloneLink = L.DomUtil.createButton(
'button umap-clone',
advancedButtons,
L._('Clone'),
function () {
const datalayer = this.clone()
datalayer.edit()
},
this
)
if (this.umap_id) {
const download = L.DomUtil.createLink(
'button umap-download',
advancedButtons,
L._('Download'),
this._dataUrl(),
'_blank'
)
}
const button = L.DomUtil.create('li', '')
L.DomUtil.create('i', 'icon icon-16 icon-back', button)
button.title = L._('Back to layers')
// Fixme: remove me when this is merged and released
// https://github.com/Leaflet/Leaflet/pull/9052
L.DomEvent.disableClickPropagation(button)
L.DomEvent.on(button, 'click', this.map.editDatalayers, this.map)
this.map.editPanel.open({
content: container,
actions: [button],
})
},
getOwnOption: function (option) {
if (U.Utils.usableOption(this.options, option)) return this.options[option]
},
getOption: function (option, feature) {
if (this.layer && this.layer.getOption) {
const value = this.layer.getOption(option, feature)
if (typeof value !== 'undefined') return value
}
if (typeof this.getOwnOption(option) !== 'undefined') {
return this.getOwnOption(option)
} else if (this.layer && this.layer.defaults && this.layer.defaults[option]) {
return this.layer.defaults[option]
} else {
return this.map.getOption(option)
}
},
buildVersionsFieldset: async function (container) {
const appendVersion = (data) => {
const date = new Date(parseInt(data.at, 10))
const content = `${date.toLocaleString(L.lang)} (${parseInt(data.size) / 1000}Kb)`
const el = L.DomUtil.create('div', 'umap-datalayer-version', versionsContainer)
const button = L.DomUtil.createButton(
'',
el,
'',
() => this.restore(data.name),
this
)
button.title = L._('Restore this version')
L.DomUtil.add('span', '', el, content)
}
const versionsContainer = L.DomUtil.createFieldset(container, L._('Versions'), {
callback: async function () {
const [{ versions }, response, error] = await this.map.server.get(
this.getVersionsUrl()
)
if (!error) versions.forEach(appendVersion)
},
context: this,
})
},
restore: async function (version) {
if (!this.map.editEnabled) return
if (!confirm(L._('Are you sure you want to restore this version?'))) return
const [geojson, response, error] = await this.map.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: function () {
const features = []
this.eachLayer((layer) => features.push(layer.toGeoJSON()))
return features
},
show: async function () {
this.map.addLayer(this.layer)
if (!this.isLoaded()) await this.fetchData()
this.fire('show')
},
hide: function () {
this.map.removeLayer(this.layer)
this.fire('hide')
},
toggle: function () {
// From now on, do not try to how/hide
// automatically this layer.
this._forcedVisibility = true
if (!this.isVisible()) this.show()
else this.hide()
},
zoomTo: function () {
if (!this.isVisible()) return
const bounds = this.layer.getBounds()
if (bounds.isValid()) {
const options = { maxZoom: this.getOption('zoomTo') }
this.map.fitBounds(bounds, options)
}
},
// Is this layer type browsable in theorie
isBrowsable: function () {
return this.layer && this.layer.browsable
},
// Is this layer browsable in theorie
// AND the user allows it
allowBrowse: function () {
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: function () {
return this.allowBrowse() && this.isVisible() && this.hasData()
},
count: function () {
return this._index.length
},
hasData: function () {
return !!this._index.length
},
isVisible: function () {
return Boolean(this.layer && this.map.hasLayer(this.layer))
},
getFeatureByIndex: function (index) {
if (index === -1) index = this._index.length - 1
const id = this._index[index]
return this._layers[id]
},
// TODO Add an index
// For now, iterate on all the features.
getFeatureById: function (id) {
return Object.values(this._layers).find((feature) => feature.id === id)
},
getNextFeature: function (feature) {
const id = this._index.indexOf(L.stamp(feature))
const nextId = this._index[id + 1]
return nextId ? this._layers[nextId] : this.getNextBrowsable().getFeatureByIndex(0)
},
getPreviousFeature: function (feature) {
if (this._index <= 1) {
return null
}
const id = this._index.indexOf(L.stamp(feature))
const previousId = this._index[id - 1]
return previousId
? this._layers[previousId]
: this.getPreviousBrowsable().getFeatureByIndex(-1)
},
getPreviousBrowsable: function () {
let id = this.getRank()
let next
const index = this.map.datalayers_index
while (((id = index[++id] ? id : 0), (next = index[id]))) {
if (next === this || next.canBrowse()) break
}
return next
},
getNextBrowsable: function () {
let id = this.getRank()
let prev
const index = this.map.datalayers_index
while (((id = index[--id] ? id : index.length - 1), (prev = index[id]))) {
if (prev === this || prev.canBrowse()) break
}
return prev
},
umapGeoJSON: function () {
return {
type: 'FeatureCollection',
features: this.isRemoteLayer() ? [] : this.featuresToGeoJSON(),
_umap_options: this.options,
}
},
getRank: function () {
return this.map.datalayers_index.indexOf(this)
},
isReadOnly: function () {
// isReadOnly must return true if unset
return this.options.editMode === 'disabled'
},
isDataReadOnly: function () {
// This layer cannot accept features
return this.isReadOnly() || this.isRemoteLayer()
},
save: async function () {
if (this.isDeleted) return 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.map.urls.get('datalayer_save', {
map_id: this.map.options.umap_id,
pk: this.umap_id,
})
const headers = this._reference_version
? { 'X-Datalayer-Reference': this._reference_version }
: {}
await this._trySave(saveUrl, headers, formData)
this._geojson = geojson
},
_trySave: async function (url, headers, formData) {
const [data, response, error] = await this.map.server.post(url, headers, formData)
if (error) {
if (response && response.status === 412) {
const msg = L._(
'Woops! Someone else seems to have edited the data. You can save anyway, but this will erase the changes made by others.'
)
const actions = [
{
label: L._('Save anyway'),
callback: async () => {
// Save again,
// but do not pass the reference version this time
await this._trySave(url, {}, formData)
},
},
{
label: L._('Cancel'),
},
]
this.map.alert.open({
content: msg,
level: 'error',
duration: 100000,
actions: actions,
})
}
} 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
}
this._reference_version = response.headers.get('X-Datalayer-Version')
this.setUmapId(data.id)
this.updateOptions(data)
this.backupOptions()
this.connectToMap()
this._loaded = true
this.redraw() // Needed for reordering features
this.isDirty = false
this.permissions.save()
}
},
saveDelete: async function () {
if (this.umap_id) {
await this.map.server.post(this.getDeleteUrl())
}
this.isDirty = false
this.map.continueSaving()
},
getMap: function () {
return this.map
},
getName: function () {
return this.options.name || L._('Untitled layer')
},
tableEdit: function () {
if (this.isRemoteLayer() || !this.isVisible()) return
const editor = new U.TableEditor(this)
editor.edit()
},
})
L.TileLayer.include({
toJSON: function () {
return {
minZoom: this.options.minZoom,
maxZoom: this.options.maxZoom,
attribution: this.options.attribution,
url_template: this._url,
name: this.options.name,
tms: this.options.tms,
}
},
getAttribution: function () {
return U.Utils.toHTML(this.options.attribution)
},
})