diff --git a/.gitignore b/.gitignore
index 297f145a..cb657c7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,7 @@ site/*
.pytest_cache/
node_modules
umap.conf
-data
+./data
./static
### Python ###
diff --git a/umap/static/umap/js/modules/caption.js b/umap/static/umap/js/modules/caption.js
index cf8c0de2..36c63c4c 100644
--- a/umap/static/umap/js/modules/caption.js
+++ b/umap/static/umap/js/modules/caption.js
@@ -43,16 +43,14 @@ export default class Caption {
const p = DomUtil.create('p', 'datalayer-legend', container)
const legend = DomUtil.create('span', '', p)
const headline = DomUtil.create('strong', '', p)
- datalayer.onceLoaded(() => {
- datalayer.renderLegend(legend)
- if (datalayer.options.description) {
- DomUtil.element({
- tagName: 'span',
- parent: p,
- safeHTML: Utils.toHTML(datalayer.options.description),
- })
- }
- })
+ datalayer.renderLegend(legend)
+ if (datalayer.options.description) {
+ DomUtil.element({
+ tagName: 'span',
+ parent: p,
+ safeHTML: Utils.toHTML(datalayer.options.description),
+ })
+ }
datalayer.renderToolbox(headline)
DomUtil.add('span', '', headline, `${datalayer.options.name} `)
}
diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js
new file mode 100644
index 00000000..11cf3f40
--- /dev/null
+++ b/umap/static/umap/js/modules/data/layer.js
@@ -0,0 +1,1283 @@
+// Uses U.DataLayerPermissions, U.Marker, U.Polygon, U.Polyline, U.TableEditor not yet ES modules
+// Uses U.FormBuilder not available as ESM
+
+// 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 } from '../rendering/layers/relative.js'
+import {
+ uMapAlert as Alert,
+ uMapAlertConflict as AlertConflict,
+} from '../../components/alerts/alert.js'
+import { translate } from '../i18n.js'
+
+export const LAYER_TYPES = [DefaultLayer, Cluster, Heat, Choropleth, Categorized]
+
+const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
+ acc[klass.TYPE] = klass
+ return acc
+}, {})
+
+export class DataLayer {
+ constructor(map, data) {
+ this.map = map
+ this.sync = map.sync_engine.proxy(this)
+ 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${stamp(this)}`, this.parentPane)
+ this.pane.dataset.id = stamp(this)
+ this.renderer = L.svg({ pane: this.pane })
+ this.defaultOptions = {
+ displayOnLoad: true,
+ inCaption: true,
+ browsable: true,
+ editMode: 'advanced',
+ }
+
+ this._isDirty = false
+ this._isDeleted = false
+ this.setUmapId(data.id)
+ 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 U.DataLayerPermissions(this)
+ if (!this.umap_id) {
+ if (this.showAtLoad()) this.show()
+ this.isDirty = true
+ }
+
+ // 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()
+ }
+
+ set isDirty(status) {
+ this._isDirty = status
+ if (status) {
+ this.map.addDirtyDatalayer(this)
+ // 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.map.removeDirtyDatalayer(this)
+ this.isDeleted = false
+ }
+ }
+
+ get isDirty() {
+ return this._isDirty
+ }
+
+ set isDeleted(status) {
+ this._isDeleted = status
+ if (status) this.isDirty = status
+ }
+
+ get isDeleted() {
+ return this._isDeleted
+ }
+
+ getSyncMetadata() {
+ return {
+ subject: 'datalayer',
+ metadata: {
+ id: this.umap_id || null,
+ },
+ }
+ }
+
+ render(fields, builder) {
+ const impacts = Utils.getImpactsFromSchema(fields)
+
+ for (const impact of impacts) {
+ switch (impact) {
+ case 'ui':
+ this.map.onDataLayersChanged()
+ break
+ case 'data':
+ if (fields.includes('options.type')) {
+ this.resetLayer()
+ }
+ this.hide()
+ for (const field of fields) {
+ this.layer.onEdit(field, builder)
+ }
+ this.redraw()
+ this.show()
+ break
+ case 'remote-data':
+ this.fetchRemoteData()
+ break
+ }
+ }
+ }
+
+ showAtLoad() {
+ return this.autoLoaded() && this.showAtZoom()
+ }
+
+ autoLoaded() {
+ 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(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.map.removeLayer(this.layer)
+ const Class = LAYER_MAP[this.options.type] || DefaultLayer
+ this.layer = new Class(this)
+ this.eachLayer(this.showFeature)
+ if (visible) this.show()
+ this.propagateRemote()
+ }
+
+ eachLayer(method, context) {
+ for (const i in this._layers) {
+ method.call(context || this, this._layers[i])
+ }
+ return this
+ }
+
+ eachFeature(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
+ }
+
+ async fetchData() {
+ 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._loading = false
+ }
+ }
+
+ dataChanged() {
+ this.map.onDataLayersChanged()
+ this.layer.dataChanged()
+ }
+
+ fromGeoJSON(geojson, sync = true) {
+ this.addData(geojson, sync)
+ this._geojson = geojson
+ this._dataloaded = true
+ this.dataChanged()
+ }
+
+ async fromUmapGeoJSON(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, false)
+ this._loaded = true
+ }
+
+ clear() {
+ this.layer.clearLayers()
+ this._layers = {}
+ this._index = Array()
+ if (this._geojson) {
+ this.backupData()
+ this._geojson = null
+ }
+ this.dataChanged()
+ }
+
+ backupData() {
+ this._geojson_bk = Utils.CopyJSON(this._geojson)
+ }
+
+ reindex() {
+ const features = []
+ this.eachFeature((feature) => features.push(feature))
+ Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang)
+ this._index = []
+ for (let i = 0; i < features.length; i++) {
+ this._index.push(stamp(features[i]))
+ }
+ }
+
+ showAtZoom() {
+ const from = Number.parseInt(this.options.fromZoom, 10)
+ const to = Number.parseInt(this.options.toZoom, 10)
+ const zoom = this.map.getZoom()
+ return !((!Number.isNaN(from) && zoom < from) || (!Number.isNaN(to) && zoom > to))
+ }
+
+ hasDynamicData() {
+ return !!this.options.remoteData?.dynamic
+ }
+
+ async fetchRemoteData(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?.ok) {
+ this.clear()
+ this.map.formatter
+ .parse(await response.text(), this.options.remoteData.format)
+ .then((geojson) => this.fromGeoJSON(geojson))
+ }
+ }
+
+ isLoaded() {
+ return !this.umap_id || this._loaded
+ }
+
+ hasDataLoaded() {
+ return this._dataloaded
+ }
+
+ setUmapId(id) {
+ // Datalayer is null when listening creation form
+ if (!this.umap_id && id) this.umap_id = id
+ }
+
+ 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.map.datalayers[id]) {
+ this.map.datalayers[id] = this
+ if (!this.map.datalayers_index.includes(this)) {
+ this.map.datalayers_index.push(this)
+ }
+ this.map.onDataLayersChanged()
+ }
+ }
+
+ _dataUrl() {
+ const template = this.map.options.urls.datalayer_view
+
+ let url = 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() {
+ 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)
+ }
+
+ addLayer(feature) {
+ const id = 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.dataChanged()
+ }
+
+ removeLayer(feature, sync) {
+ const id = stamp(feature)
+ if (sync !== false) feature.sync.delete()
+ this.layer.removeLayer(feature)
+ feature.disconnectFromDataLayer(this)
+ this._index.splice(this._index.indexOf(id), 1)
+ delete this._layers[id]
+ delete this.map.features_index[feature.getSlug()]
+ if (this.hasDataLoaded() && 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)
+ }
+
+ sortedValues(property) {
+ return Object.values(this._layers)
+ .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.
+ this.geojsonToFeatures(geojson, sync)
+ } catch (err) {
+ console.log('Error with DataLayer', this.umap_id)
+ console.error(err)
+ }
+ }
+
+ // The choice of the name is not ours, because it is required by Leaflet.
+ // It is misleading, as the returned objects are uMap objects, and not
+ // GeoJSON features.
+ geojsonToFeatures(geojson, sync) {
+ if (!geojson) return
+ const features = Array.isArray(geojson) ? geojson : geojson.features
+ let i
+ let len
+
+ if (features) {
+ 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
+
+ const feature = this.geoJSONToLeaflet({ geometry, geojson })
+ if (feature) {
+ this.addLayer(feature)
+ if (sync) 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.
+ */
+ geoJSONToLeaflet({ geometry, geojson = null, id = null, feature = null } = {}) {
+ if (!geometry) return // null geometry is valid geojson.
+ const coords = geometry.coordinates
+ let latlng
+ let latlngs
+
+ // Create a default geojson if none is provided
+ if (geojson === undefined) geojson = { type: 'Feature', geometry: geometry }
+
+ switch (geometry.type) {
+ case 'Point':
+ try {
+ latlng = 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 = 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 = 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:
+ Alert.error(
+ translate('Skipping unknown geometry.type: {type}', {
+ type: geometry.type || 'undefined',
+ })
+ )
+ }
+ }
+
+ _pointToLayer(geojson, latlng, id) {
+ return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id)
+ }
+
+ _lineToLayer(geojson, latlngs, id) {
+ return new U.Polyline(
+ this.map,
+ latlngs,
+ {
+ geojson: geojson,
+ datalayer: this,
+ color: null,
+ },
+ id
+ )
+ }
+
+ _polygonToLayer(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)
+ }
+
+ async importRaw(raw, format) {
+ this.map.formatter
+ .parse(raw, format)
+ .then((geojson) => this.addData(geojson))
+ .then(() => this.zoomTo())
+ this.isDirty = true
+ }
+
+ importFromFiles(files, type) {
+ for (let i = 0, f; (f = files[i]); i++) {
+ this.importFromFile(f, type)
+ }
+ }
+
+ importFromFile(f, type) {
+ const reader = new FileReader()
+ type = type || Utils.detectFileType(f)
+ reader.readAsText(f)
+ reader.onload = (e) => this.importRaw(e.target.result, type)
+ }
+
+ async importFromUrl(uri, type) {
+ uri = this.map.localizeUrl(uri)
+ const response = await this.map.request.get(uri)
+ if (response?.ok) {
+ this.importRaw(await response.text(), type)
+ }
+ }
+
+ getColor() {
+ return this.options.color || this.map.getOption('color')
+ }
+
+ getDeleteUrl() {
+ return Utils.template(this.map.options.urls.datalayer_delete, {
+ pk: this.umap_id,
+ map_id: this.map.options.umap_id,
+ })
+ }
+
+ getVersionsUrl() {
+ return Utils.template(this.map.options.urls.datalayer_versions, {
+ pk: this.umap_id,
+ map_id: this.map.options.umap_id,
+ })
+ }
+
+ getVersionUrl(name) {
+ return Utils.template(this.map.options.urls.datalayer_version, {
+ pk: this.umap_id,
+ map_id: this.map.options.umap_id,
+ name: name,
+ })
+ }
+
+ _delete() {
+ this.isDeleted = true
+ this.erase()
+ }
+
+ 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.map.createDataLayer(options)
+ datalayer.fromGeoJSON(geojson)
+ return datalayer
+ }
+
+ erase() {
+ this.hide()
+ delete this.map.datalayers[stamp(this)]
+ this.map.datalayers_index.splice(this.getRank(), 1)
+ this.parentPane.removeChild(this.pane)
+ this.map.onDataLayersChanged()
+ this.layer.onDelete(this.map)
+ this.propagateDelete()
+ this._leaflet_events_bk = this._leaflet_events
+ this.clear()
+ delete this._loaded
+ delete this._dataloaded
+ }
+
+ reset() {
+ 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() {
+ if (!this.isVisible()) return
+ this.hide()
+ this.show()
+ }
+
+ edit() {
+ if (!this.map.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.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 U.FormBuilder(this, metadataFields, {
+ callback(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 = 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 U.FormBuilder(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.labelKey',
+ ]
+
+ builder = new U.FormBuilder(this, optionsFields, {
+ id: 'datalayer-advanced-properties',
+ })
+ 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 U.FormBuilder(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.map.options.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 U.FormBuilder(this, remoteDataFields)
+ remoteDataContainer.appendChild(builder.build())
+ DomUtil.createButton(
+ 'button umap-verify',
+ remoteDataContainer,
+ translate('Verify remote URL'),
+ () => this.fetchRemoteData(true),
+ this
+ )
+
+ if (this.map.options.urls.datalayer_versions) this.buildVersionsFieldset(container)
+
+ const advancedActions = DomUtil.createFieldset(
+ container,
+ translate('Advanced actions')
+ )
+ const advancedButtons = DomUtil.create('div', 'button-bar half', advancedActions)
+ const deleteLink = DomUtil.createButton(
+ 'button delete_datalayer_button umap-delete',
+ advancedButtons,
+ translate('Delete'),
+ function () {
+ this._delete()
+ this.map.editPanel.close()
+ },
+ this
+ )
+ 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.umap_id) {
+ const download = DomUtil.createLink(
+ 'button umap-download',
+ advancedButtons,
+ translate('Download'),
+ this._dataUrl(),
+ '_blank'
+ )
+ }
+ 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.map.editDatalayers, this.map)
+
+ this.map.editPanel.open({
+ content: container,
+ 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 (typeof value !== 'undefined') return value
+ }
+ if (typeof this.getOwnOption(option) !== 'undefined') {
+ return this.getOwnOption(option)
+ }
+ if (this.layer?.defaults?.[option]) {
+ return this.layer.defaults[option]
+ }
+ return this.map.getOption(option, feature)
+ }
+
+ async buildVersionsFieldset(container) {
+ const appendVersion = (data) => {
+ const date = new Date(Number.parseInt(data.at, 10))
+ const content = `${date.toLocaleString(L.lang)} (${Number.parseInt(data.size) / 1000}Kb)`
+ const el = DomUtil.create('div', 'umap-datalayer-version', versionsContainer)
+ const button = DomUtil.createButton(
+ '',
+ el,
+ '',
+ () => this.restore(data.name),
+ this
+ )
+ 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.map.server.get(
+ this.getVersionsUrl()
+ )
+ if (!error) versions.forEach(appendVersion)
+ },
+ context: this,
+ })
+ }
+
+ async restore(version) {
+ if (!this.map.editEnabled) return
+ if (!confirm(translate('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() {
+ const features = []
+ this.eachLayer((layer) => features.push(layer.toGeoJSON()))
+ return features
+ }
+
+ async show() {
+ this.map.addLayer(this.layer)
+ if (!this.isLoaded()) await this.fetchData()
+ this.propagateShow()
+ }
+
+ hide() {
+ this.map.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()
+ if (bounds.isValid()) {
+ const options = { maxZoom: this.getOption('zoomTo') }
+ this.map.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.map.hasLayer(this.layer))
+ }
+
+ getFeatureByIndex(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(id) {
+ return Object.values(this._layers).find((feature) => feature.id === id)
+ }
+
+ getNextFeature(feature) {
+ const id = this._index.indexOf(stamp(feature))
+ const nextId = this._index[id + 1]
+ return nextId ? this._layers[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._layers[previousId]
+ : this.getPreviousBrowsable().getFeatureByIndex(-1)
+ }
+
+ getPreviousBrowsable() {
+ 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() {
+ 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() {
+ return {
+ type: 'FeatureCollection',
+ features: this.isRemoteLayer() ? [] : this.featuresToGeoJSON(),
+ _umap_options: this.options,
+ }
+ }
+
+ getRank() {
+ return this.map.datalayers_index.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()
+ }
+
+ async save() {
+ 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
+ }
+
+ async _trySave(url, headers, formData) {
+ const [data, response, error] = await this.map.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 () => {
+ await this._trySave(url, {}, formData)
+ }
+ )
+ }
+ } 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.sync.update('_reference_version', this._reference_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()
+ }
+ }
+
+ async saveDelete() {
+ if (this.umap_id) {
+ await this.map.server.post(this.getDeleteUrl())
+ }
+ this.isDirty = false
+ this.map.continueSaving()
+ }
+
+ getMap() {
+ return this.map
+ }
+
+ getName() {
+ return this.options.name || translate('Untitled layer')
+ }
+
+ tableEdit() {
+ if (!this.isVisible()) return
+ const editor = new U.TableEditor(this)
+ 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.map.options.filterKey) return this.map.options.filterKey
+ if (this.options.labelKey) return this.options.labelKey
+ if (this.map.options.sortKey) return this.map.options.sortKey
+ return 'name'
+ }
+
+ renderLegend(container) {
+ 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
+ if (!confirm(translate('Are you sure you want to delete this layer?'))) return
+ this._delete()
+ },
+ 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')
+ }
+ }
+}
diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js
index 492a625d..d96e3ff1 100644
--- a/umap/static/umap/js/modules/global.js
+++ b/umap/static/umap/js/modules/global.js
@@ -1,9 +1,12 @@
import {
uMapAlert as Alert,
- uMapAlertConflict as AlertConflict,
uMapAlertCreation as AlertCreation,
} from '../components/alerts/alert.js'
-import { AjaxAutocomplete, AjaxAutocompleteMultiple, AutocompleteDatalist } from './autocomplete.js'
+import {
+ AjaxAutocomplete,
+ AjaxAutocompleteMultiple,
+ AutocompleteDatalist,
+} from './autocomplete.js'
import Browser from './browser.js'
import Caption from './caption.js'
import Facets from './facets.js'
@@ -23,6 +26,7 @@ import TableEditor from './tableeditor.js'
import Tooltip from './ui/tooltip.js'
import URLs from './urls.js'
import * as Utils from './utils.js'
+import { DataLayer, LAYER_TYPES } from './data/layer.js'
// Import modules and export them to the global scope.
// For the not yet module-compatible JS out there.
@@ -31,12 +35,12 @@ import * as Utils from './utils.js'
window.U = {
Alert,
AlertCreation,
- AlertConflict,
AjaxAutocomplete,
AjaxAutocompleteMultiple,
AutocompleteDatalist,
Browser,
Caption,
+ DataLayer,
Dialog,
EditPanel,
Facets,
@@ -45,6 +49,7 @@ window.U = {
Help,
HTTPError,
Importer,
+ LAYER_TYPES,
NOKError,
Orderable,
Panel,
diff --git a/umap/static/umap/js/modules/rendering/layers/base.js b/umap/static/umap/js/modules/rendering/layers/base.js
new file mode 100644
index 00000000..d20b5ea2
--- /dev/null
+++ b/umap/static/umap/js/modules/rendering/layers/base.js
@@ -0,0 +1,105 @@
+import { FeatureGroup, TileLayer } from '../../../../vendors/leaflet/leaflet-src.esm.js'
+import { translate } from '../../i18n.js'
+import * as Utils from '../../utils.js'
+
+export const LayerMixin = {
+ browsable: true,
+
+ onInit: function (map) {
+ if (this.datalayer.autoLoaded()) map.on('zoomend', this.onZoomEnd, this)
+ },
+
+ onDelete: function (map) {
+ map.off('zoomend', this.onZoomEnd, this)
+ },
+
+ onAdd: function (map) {
+ map.on('moveend', this.onMoveEnd, this)
+ },
+
+ onRemove: function (map) {
+ map.off('moveend', this.onMoveEnd, this)
+ },
+
+ 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: () => [],
+
+ onEdit: () => {},
+
+ hasDataVisible: function () {
+ return !!Object.keys(this._layers).length
+ },
+
+ // Called when data changed on the datalayer
+ dataChanged: () => {},
+
+ onMoveEnd: function () {
+ if (this.datalayer.isRemoteLayer() && this.datalayer.showAtZoom()) {
+ this.datalayer.fetchRemoteData()
+ }
+ },
+
+ onZoomEnd() {
+ if (this.datalayer._forcedVisibility) return
+ if (!this.datalayer.showAtZoom() && this.datalayer.isVisible()) {
+ this.datalayer.hide()
+ }
+ if (this.datalayer.showAtZoom() && !this.datalayer.isVisible()) {
+ this.datalayer.show()
+ }
+ },
+}
+
+export const Default = FeatureGroup.extend({
+ statics: {
+ NAME: translate('Default'),
+ TYPE: 'Default',
+ },
+ includes: [LayerMixin],
+
+ initialize: function (datalayer) {
+ this.datalayer = datalayer
+ FeatureGroup.prototype.initialize.call(this)
+ LayerMixin.onInit.call(this, this.datalayer.map)
+ },
+
+ onAdd: function (map) {
+ LayerMixin.onAdd.call(this, map)
+ return FeatureGroup.prototype.onAdd.call(this, map)
+ },
+
+ onRemove: function (map) {
+ LayerMixin.onRemove.call(this, map)
+ return FeatureGroup.prototype.onRemove.call(this, map)
+ },
+})
+
+TileLayer.include({
+ toJSON() {
+ 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() {
+ return Utils.toHTML(this.options.attribution)
+ },
+})
diff --git a/umap/static/umap/js/modules/rendering/layers/cluster.js b/umap/static/umap/js/modules/rendering/layers/cluster.js
new file mode 100644
index 00000000..14d6f900
--- /dev/null
+++ b/umap/static/umap/js/modules/rendering/layers/cluster.js
@@ -0,0 +1,103 @@
+// WARNING must be loaded dynamically, or at least after leaflet.markercluster
+// Uses global L.MarkerCluster and L.MarkerClusterGroup, not exposed as ESM
+// Uses global U.Icon not yet a module
+import { translate } from '../../i18n.js'
+import { LayerMixin } from './base.js'
+import * as Utils from '../../utils.js'
+
+const 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)
+ },
+})
+
+export const Cluster = L.MarkerClusterGroup.extend({
+ statics: {
+ NAME: translate('Clustered'),
+ TYPE: 'Cluster',
+ },
+ includes: [LayerMixin],
+
+ initialize: function (datalayer) {
+ this.datalayer = datalayer
+ if (!Utils.isObject(this.datalayer.options.cluster)) {
+ this.datalayer.options.cluster = {}
+ }
+ const options = {
+ polygonOptions: {
+ color: this.datalayer.getColor(),
+ },
+ iconCreateFunction: (cluster) => new U.Icon.Cluster(datalayer, cluster),
+ }
+ if (this.datalayer.options.cluster?.radius) {
+ options.maxClusterRadius = this.datalayer.options.cluster.radius
+ }
+ L.MarkerClusterGroup.prototype.initialize.call(this, options)
+ LayerMixin.onInit.call(this, this.datalayer.map)
+ this._markerCluster = MarkerCluster
+ this._layers = []
+ },
+
+ onAdd: function (map) {
+ LayerMixin.onAdd.call(this, map)
+ return L.MarkerClusterGroup.prototype.onAdd.call(this, map)
+ },
+
+
+ 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
+ LayerMixin.onRemove.call(this, 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: () => [
+ [
+ 'options.cluster.radius',
+ {
+ handler: 'BlurIntInput',
+ placeholder: translate('Clustering radius'),
+ helpText: translate('Override clustering radius (default 80)'),
+ },
+ ],
+ [
+ 'options.cluster.textColor',
+ {
+ handler: 'TextColorPicker',
+ placeholder: translate('Auto'),
+ helpText: translate('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()
+ }
+ },
+})
diff --git a/umap/static/umap/js/modules/rendering/layers/heat.js b/umap/static/umap/js/modules/rendering/layers/heat.js
new file mode 100644
index 00000000..71813319
--- /dev/null
+++ b/umap/static/umap/js/modules/rendering/layers/heat.js
@@ -0,0 +1,176 @@
+// Uses global L.HeatLayer, not exposed as ESM
+import { Marker, LatLng, latLngBounds, Bounds, point } from '../../../../vendors/leaflet/leaflet-src.esm.js'
+import { LayerMixin } from './base.js'
+import * as Utils from '../../utils.js'
+import { translate } from '../../i18n.js'
+
+export const Heat = L.HeatLayer.extend({
+ statics: {
+ NAME: translate('Heatmap'),
+ TYPE: 'Heat',
+ },
+ includes: [LayerMixin],
+ browsable: false,
+
+ initialize: function (datalayer) {
+ this.datalayer = datalayer
+ L.HeatLayer.prototype.initialize.call(this, [], this.datalayer.options.heat)
+ LayerMixin.onInit.call(this, this.datalayer.map)
+ if (!Utils.isObject(this.datalayer.options.heat)) {
+ this.datalayer.options.heat = {}
+ }
+ },
+
+ addLayer: function (layer) {
+ if (layer instanceof Marker) {
+ let latlng = layer.getLatLng()
+ let alt
+ if (this.datalayer.options.heat?.intensityProperty) {
+ alt = Number.parseFloat(
+ layer.properties[this.datalayer.options.heat.intensityProperty || 0]
+ )
+ latlng = new LatLng(latlng.lat, latlng.lng, alt)
+ }
+ this.addLatLng(latlng)
+ }
+ },
+
+ onAdd: function (map) {
+ LayerMixin.onAdd.call(this, map)
+ return L.HeatLayer.prototype.onAdd.call(this, map)
+ },
+
+ onRemove: function (map) {
+ LayerMixin.onRemove.call(this, map)
+ return L.HeatLayer.prototype.onRemove.call(this, map)
+ },
+
+ clearLayers: function () {
+ this.setLatLngs([])
+ },
+
+ getFeatures: () => ({}),
+
+ getBounds: function () {
+ return latLngBounds(this._latlngs)
+ },
+
+ getEditableOptions: () => [
+ [
+ 'options.heat.radius',
+ {
+ handler: 'Range',
+ min: 10,
+ max: 100,
+ step: 5,
+ label: translate('Heatmap radius'),
+ helpText: translate('Override heatmap radius (default 25)'),
+ },
+ ],
+ [
+ 'options.heat.intensityProperty',
+ {
+ handler: 'BlurInput',
+ placeholder: translate('Heatmap intensity property'),
+ helpText: translate('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
+ }
+ const data = []
+ const r = this._heat._r
+ const size = this._map.getSize()
+ const bounds = new Bounds(point([-r, -r]), size.add([r, r]))
+ const cellSize = r / 2
+ const grid = []
+ const panePos = this._map._getMapPanePos()
+ const offsetX = panePos.x % cellSize
+ const offsetY = panePos.y % cellSize
+ let i
+ let len
+ let p
+ let cell
+ let x
+ let y
+ let j
+ let 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
+
+ const 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
+ },
+})
diff --git a/umap/static/umap/js/modules/rendering/layers/relative.js b/umap/static/umap/js/modules/rendering/layers/relative.js
new file mode 100644
index 00000000..26d1b2ad
--- /dev/null
+++ b/umap/static/umap/js/modules/rendering/layers/relative.js
@@ -0,0 +1,364 @@
+import { FeatureGroup, DomUtil } from '../../../../vendors/leaflet/leaflet-src.esm.js'
+import { translate } from '../../i18n.js'
+import { LayerMixin } from './base.js'
+import * as Utils from '../../utils.js'
+
+// Layer where each feature color is relative to the others,
+// so we need all features before behing able to set one
+// feature layer
+const RelativeColorLayerMixin = {
+ initialize: function (datalayer) {
+ this.datalayer = datalayer
+ this.colorSchemes = Object.keys(colorbrewer)
+ .filter((k) => k !== 'schemeGroups')
+ .sort()
+ const key = this.getType().toLowerCase()
+ if (!Utils.isObject(this.datalayer.options[key])) {
+ this.datalayer.options[key] = {}
+ }
+ FeatureGroup.prototype.initialize.call(this, [], this.datalayer.options[key])
+ LayerMixin.onInit.call(this, this.datalayer.map)
+ },
+
+ dataChanged: function () {
+ this.redraw()
+ },
+
+ redraw: function () {
+ this.compute()
+ if (this._map) this.eachLayer(this._map.addLayer, this._map)
+ },
+
+ 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 can compute breaks only once
+ const id = this.getLayerId(layer)
+ this._layers[id] = layer
+ return this
+ },
+
+ onAdd: function (map) {
+ this.compute()
+ LayerMixin.onAdd.call(this, map)
+ return FeatureGroup.prototype.onAdd.call(this, map)
+ },
+
+ onRemove: function (map) {
+ LayerMixin.onRemove.call(this, map)
+ return FeatureGroup.prototype.onRemove.call(this, map)
+ },
+
+ getValues: function () {
+ const values = []
+ this.datalayer.eachLayer((layer) => {
+ const value = this._getValue(layer)
+ if (value !== undefined) values.push(value)
+ })
+ return values
+ },
+
+ renderLegend: function (container) {
+ const parent = DomUtil.create('ul', '', container)
+ const items = this.getLegendItems()
+ for (const [color, label] of items) {
+ const li = DomUtil.create('li', '', parent)
+ const colorEl = DomUtil.create('span', 'datalayer-color', li)
+ colorEl.style.backgroundColor = color
+ const labelEl = DomUtil.create('span', '', li)
+ labelEl.textContent = label
+ }
+ },
+
+ getColorSchemes: function (classes) {
+ return this.colorSchemes.filter((scheme) => Boolean(colorbrewer[scheme][classes]))
+ },
+}
+
+export const Choropleth = FeatureGroup.extend({
+ statics: {
+ NAME: translate('Choropleth'),
+ TYPE: 'Choropleth',
+ },
+ includes: [LayerMixin, RelativeColorLayerMixin],
+ // Have defaults that better suit the choropleth mode.
+ defaults: {
+ color: 'white',
+ fillColor: 'red',
+ fillOpacity: 0.7,
+ weight: 2,
+ },
+ MODES: {
+ kmeans: translate('K-means'),
+ equidistant: translate('Equidistant'),
+ jenks: translate('Jenks-Fisher'),
+ quantiles: translate('Quantiles'),
+ manual: translate('Manual'),
+ },
+
+ _getValue: function (feature) {
+ const key = this.datalayer.options.choropleth.property || 'value'
+ const value = +feature.properties[key]
+ if (!Number.isNaN(value)) return value
+ },
+
+ compute: function () {
+ const values = this.getValues()
+
+ if (!values.length) {
+ this.options.breaks = []
+ this.options.colors = []
+ return
+ }
+ const mode = this.datalayer.options.choropleth.mode
+ let classes = +this.datalayer.options.choropleth.classes || 5
+ let 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) => !Number.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 should 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]
+ }
+ }
+ },
+
+ 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.compute()
+ // 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 () {
+ return [
+ [
+ 'options.choropleth.property',
+ {
+ handler: 'Select',
+ selectOptions: this.datalayer._propertiesIndex,
+ label: translate('Choropleth property value'),
+ },
+ ],
+ [
+ 'options.choropleth.brewer',
+ {
+ handler: 'Select',
+ label: translate('Choropleth color palette'),
+ selectOptions: this.colorSchemes,
+ },
+ ],
+ [
+ 'options.choropleth.classes',
+ {
+ handler: 'Range',
+ min: 3,
+ max: 9,
+ step: 1,
+ label: translate('Choropleth classes'),
+ helpText: translate('Number of desired classes (default 5)'),
+ },
+ ],
+ [
+ 'options.choropleth.breaks',
+ {
+ handler: 'BlurInput',
+ label: translate('Choropleth breakpoints'),
+ helpText: translate(
+ 'Comma separated list of numbers, including min and max values.'
+ ),
+ },
+ ],
+ [
+ 'options.choropleth.mode',
+ {
+ handler: 'MultiChoice',
+ default: 'kmeans',
+ choices: Object.entries(this.MODES),
+ label: translate('Choropleth mode'),
+ },
+ ],
+ ]
+ },
+
+ getLegendItems: function () {
+ return this.options.breaks.slice(0, -1).map((el, index) => {
+ const from = +this.options.breaks[index].toFixed(1)
+ const to = +this.options.breaks[index + 1].toFixed(1)
+ return [this.options.colors[index], `${from} - ${to}`]
+ })
+ },
+})
+
+export const Categorized = FeatureGroup.extend({
+ statics: {
+ NAME: translate('Categorized'),
+ TYPE: 'Categorized',
+ },
+ includes: [LayerMixin, RelativeColorLayerMixin],
+ MODES: {
+ manual: translate('Manual'),
+ alpha: translate('Alphabetical'),
+ },
+ defaults: {
+ color: 'white',
+ fillColor: 'red',
+ fillOpacity: 0.7,
+ weight: 2,
+ },
+
+ _getValue: function (feature) {
+ const key =
+ this.datalayer.options.categorized.property || this.datalayer._propertiesIndex[0]
+ return feature.properties[key]
+ },
+
+ getColor: function (feature) {
+ if (!feature) return // FIXME should not happen
+ const featureValue = this._getValue(feature)
+ for (let i = 0; i < this.options.categories.length; i++) {
+ if (featureValue === this.options.categories[i]) {
+ return this.options.colors[i]
+ }
+ }
+ },
+
+ compute: function () {
+ const values = this.getValues()
+
+ if (!values.length) {
+ this.options.categories = []
+ this.options.colors = []
+ return
+ }
+ const mode = this.datalayer.options.categorized.mode
+ let categories = []
+ if (mode === 'manual') {
+ const manualCategories = this.datalayer.options.categorized.categories
+ if (manualCategories) {
+ categories = manualCategories.split(',')
+ }
+ } else {
+ categories = values
+ .filter((val, idx, arr) => arr.indexOf(val) === idx)
+ .sort(Utils.naturalSort)
+ }
+ this.options.categories = categories
+ this.datalayer.options.categorized.categories = this.options.categories.join(',')
+ const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor
+ const colorScheme = this.datalayer.options.categorized.brewer
+ this._classes = this.options.categories.length
+ if (colorbrewer[colorScheme]?.[this._classes]) {
+ this.options.colors = colorbrewer[colorScheme][this._classes]
+ } else {
+ this.options.colors = colorbrewer?.Accent[this._classes]
+ ? colorbrewer?.Accent[this._classes]
+ : U.COLORS // Fixme: move COLORS to modules/
+ }
+ },
+
+ getEditableOptions: function () {
+ return [
+ [
+ 'options.categorized.property',
+ {
+ handler: 'Select',
+ selectOptions: this.datalayer._propertiesIndex,
+ label: translate('Category property'),
+ },
+ ],
+ [
+ 'options.categorized.brewer',
+ {
+ handler: 'Select',
+ label: translate('Color palette'),
+ selectOptions: this.getColorSchemes(this._classes),
+ },
+ ],
+ [
+ 'options.categorized.categories',
+ {
+ handler: 'BlurInput',
+ label: translate('Categories'),
+ helpText: translate('Comma separated list of categories.'),
+ },
+ ],
+ [
+ 'options.categorized.mode',
+ {
+ handler: 'MultiChoice',
+ default: 'alpha',
+ choices: Object.entries(this.MODES),
+ label: translate('Categories mode'),
+ },
+ ],
+ ]
+ },
+
+ onEdit: function (field, builder) {
+ // Only compute the categories if we're dealing with categorized
+ if (!field.startsWith('options.categorized')) return
+ // If user touches the categories, then force manual mode
+ if (field === 'options.categorized.categories') {
+ this.datalayer.options.categorized.mode = 'manual'
+ if (builder) builder.helpers['options.categorized.mode'].fetch()
+ }
+ this.compute()
+ // If user changes the mode
+ // then update the categories input value
+ if (field === 'options.categorized.mode') {
+ if (builder) builder.helpers['options.categorized.categories'].fetch()
+ }
+ },
+
+ getLegendItems: function () {
+ return this.options.categories.map((limit, index) => {
+ return [this.options.colors[index], this.options.categories[index]]
+ })
+ },
+})
diff --git a/umap/static/umap/js/modules/tableeditor.js b/umap/static/umap/js/modules/tableeditor.js
index c4019dd2..958176a8 100644
--- a/umap/static/umap/js/modules/tableeditor.js
+++ b/umap/static/umap/js/modules/tableeditor.js
@@ -318,7 +318,7 @@ export default class TableEditor extends WithTemplate {
feature.del()
}
this.datalayer.show()
- this.datalayer.fire('datachanged')
+ this.datalayer.dataChanged()
this.renderBody()
if (this.map.browser.isOpen()) {
this.map.browser.resetFilters()
diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js
index 5b8fa700..1bbe8c87 100644
--- a/umap/static/umap/js/umap.controls.js
+++ b/umap/static/umap/js/umap.controls.js
@@ -562,107 +562,6 @@ L.Control.Embed = L.Control.Button.extend({
},
})
-U.DataLayer.include({
- renderLegend: function (container) {
- if (this.layer.renderLegend) return this.layer.renderLegend(container)
- const color = L.DomUtil.create('span', 'datalayer-color', container)
- color.style.backgroundColor = this.getColor()
- },
-
- renderToolbox: function (container) {
- const toggle = L.DomUtil.createButtonIcon(
- container,
- 'icon-eye',
- L._('Show/hide layer')
- )
- const zoomTo = L.DomUtil.createButtonIcon(
- container,
- 'icon-zoom',
- L._('Zoom to layer extent')
- )
- const edit = L.DomUtil.createButtonIcon(
- container,
- 'icon-edit show-on-edit',
- L._('Edit')
- )
- const table = L.DomUtil.createButtonIcon(
- container,
- 'icon-table show-on-edit',
- L._('Edit properties in a table')
- )
- const remove = L.DomUtil.createButtonIcon(
- container,
- 'icon-delete show-on-edit',
- L._('Delete layer')
- )
- if (this.isReadOnly()) {
- L.DomUtil.addClass(container, 'readonly')
- } else {
- L.DomEvent.on(edit, 'click', this.edit, this)
- L.DomEvent.on(table, 'click', this.tableEdit, this)
- L.DomEvent.on(
- remove,
- 'click',
- function () {
- if (!this.isVisible()) return
- if (!confirm(L._('Are you sure you want to delete this layer?'))) return
- this._delete()
- },
- this
- )
- }
- L.DomEvent.on(toggle, 'click', this.toggle, this)
- L.DomEvent.on(zoomTo, 'click', this.zoomTo, this)
- container.classList.add(this.getHidableClass())
- container.classList.toggle('off', !this.isVisible())
- },
-
- getHidableElements: function () {
- return document.querySelectorAll(`.${this.getHidableClass()}`)
- },
-
- getHidableClass: function () {
- return `show_with_datalayer_${L.stamp(this)}`
- },
-
- propagateDelete: function () {
- const els = this.getHidableElements()
- for (const el of els) {
- L.DomUtil.remove(el)
- }
- },
-
- propagateRemote: function () {
- const els = this.getHidableElements()
- for (const el of els) {
- el.classList.toggle('remotelayer', this.isRemoteLayer())
- }
- },
-
- propagateHide: function () {
- const els = this.getHidableElements()
- for (let i = 0; i < els.length; i++) {
- L.DomUtil.addClass(els[i], 'off')
- }
- },
-
- propagateShow: function () {
- this.onceLoaded(function () {
- const els = this.getHidableElements()
- for (let i = 0; i < els.length; i++) {
- L.DomUtil.removeClass(els[i], 'off')
- }
- }, this)
- },
-})
-
-U.DataLayer.addInitHook(function () {
- this.on('hide', this.propagateHide)
- this.on('show', this.propagateShow)
- this.on('erase', this.propagateDelete)
- if (this.isVisible()) this.propagateShow()
-})
-
const ControlsMixin = {
HIDDABLE_CONTROLS: [
'zoom',
diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js
index a7705ae0..60b2b19d 100644
--- a/umap/static/umap/js/umap.forms.js
+++ b/umap/static/umap/js/umap.forms.js
@@ -342,14 +342,7 @@ L.FormBuilder.TextColorPicker = L.FormBuilder.ColorPicker.extend({
L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({
getOptions: () => {
- const layer_classes = [
- U.Layer.Default,
- U.Layer.Cluster,
- U.Layer.Heat,
- U.Layer.Choropleth,
- U.Layer.Categorized,
- ]
- return layer_classes.map((class_) => [class_.TYPE, class_.NAME])
+ return U.LAYER_TYPES.map((class_) => [class_.TYPE, class_.NAME])
},
})
@@ -367,6 +360,7 @@ L.FormBuilder.DataLayerSwitcher = L.FormBuilder.Select.extend({
getOptions: function () {
const options = []
this.builder.map.eachDataLayerReverse((datalayer) => {
+ console.log(datalayer.isLoaded(), datalayer.isDataReadOnly(), datalayer.isBrowsable())
if (
datalayer.isLoaded() &&
!datalayer.isDataReadOnly() &&
diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js
deleted file mode 100644
index ca3da686..00000000
--- a/umap/static/umap/js/umap.layer.js
+++ /dev/null
@@ -1,1837 +0,0 @@
-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: () => [],
-
- onEdit: () => {},
-
- 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: (cluster) => new U.Icon.Cluster(datalayer, cluster),
- }
- if (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: () => [
- [
- '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()
- }
- },
-})
-
-// Layer where each feature color is relative to the others,
-// so we need all features before behing able to set one
-// feature layer
-U.RelativeColorLayer = L.FeatureGroup.extend({
- initialize: function (datalayer) {
- this.datalayer = datalayer
- this.colorSchemes = Object.keys(colorbrewer)
- .filter((k) => k !== 'schemeGroups')
- .sort()
- const key = this.getType().toLowerCase()
- if (!U.Utils.isObject(this.datalayer.options[key])) {
- this.datalayer.options[key] = {}
- }
- L.FeatureGroup.prototype.initialize.call(this, [], this.datalayer.options[key])
- this.datalayer.onceDataLoaded(() => {
- this.redraw()
- this.datalayer.on('datachanged', this.redraw, this)
- })
- },
-
- redraw: function () {
- this.compute()
- if (this._map) this.eachLayer(this._map.addLayer, this._map)
- },
-
- 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 can compute breaks only once
- const id = this.getLayerId(layer)
- this._layers[id] = layer
- return this
- },
-
- onAdd: function (map) {
- this.compute()
- L.FeatureGroup.prototype.onAdd.call(this, map)
- },
-
- getValues: function () {
- const values = []
- this.datalayer.eachLayer((layer) => {
- const value = this._getValue(layer)
- if (value !== undefined) values.push(value)
- })
- return values
- },
-
- renderLegend: function (container) {
- const parent = L.DomUtil.create('ul', '', container)
- const items = this.getLegendItems()
- for (const [color, label] of items) {
- const li = L.DomUtil.create('li', '', parent)
- const colorEl = L.DomUtil.create('span', 'datalayer-color', li)
- colorEl.style.backgroundColor = color
- const labelEl = L.DomUtil.create('span', '', li)
- labelEl.textContent = label
- }
- },
-
- getColorSchemes: function (classes) {
- return this.colorSchemes.filter((scheme) => Boolean(colorbrewer[scheme][classes]))
- },
-})
-
-U.Layer.Choropleth = U.RelativeColorLayer.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'),
- },
-
- _getValue: function (feature) {
- const key = this.datalayer.options.choropleth.property || 'value'
- const value = +feature.properties[key]
- if (!Number.isNaN(value)) return value
- },
-
- compute: function () {
- const values = this.getValues()
-
- if (!values.length) {
- this.options.breaks = []
- this.options.colors = []
- return
- }
- const mode = this.datalayer.options.choropleth.mode
- let classes = +this.datalayer.options.choropleth.classes || 5
- let 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) => !Number.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 should 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]
- }
- }
- },
-
- 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.compute()
- // 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 () {
- 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: this.colorSchemes,
- },
- ],
- [
- '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'),
- },
- ],
- ]
- },
-
- getLegendItems: function () {
- return this.options.breaks.slice(0, -1).map((el, index) => {
- const from = +this.options.breaks[index].toFixed(1)
- const to = +this.options.breaks[index + 1].toFixed(1)
- return [this.options.colors[index], `${from} - ${to}`]
- })
- },
-})
-
-U.Layer.Categorized = U.RelativeColorLayer.extend({
- statics: {
- NAME: L._('Categorized'),
- TYPE: 'Categorized',
- },
- includes: [U.Layer],
- MODES: {
- manual: L._('Manual'),
- alpha: L._('Alphabetical'),
- },
- defaults: {
- color: 'white',
- fillColor: 'red',
- fillOpacity: 0.7,
- weight: 2,
- },
-
- _getValue: function (feature) {
- const key =
- this.datalayer.options.categorized.property || this.datalayer._propertiesIndex[0]
- return feature.properties[key]
- },
-
- getColor: function (feature) {
- if (!feature) return // FIXME should not happen
- const featureValue = this._getValue(feature)
- for (let i = 0; i < this.options.categories.length; i++) {
- if (featureValue === this.options.categories[i]) {
- return this.options.colors[i]
- }
- }
- },
-
- compute: function () {
- const values = this.getValues()
-
- if (!values.length) {
- this.options.categories = []
- this.options.colors = []
- return
- }
- const mode = this.datalayer.options.categorized.mode
- let categories = []
- if (mode === 'manual') {
- const manualCategories = this.datalayer.options.categorized.categories
- if (manualCategories) {
- categories = manualCategories.split(',')
- }
- } else {
- categories = values
- .filter((val, idx, arr) => arr.indexOf(val) === idx)
- .sort(U.Utils.naturalSort)
- }
- this.options.categories = categories
- this.datalayer.options.categorized.categories = this.options.categories.join(',')
- const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor
- const colorScheme = this.datalayer.options.categorized.brewer
- this._classes = this.options.categories.length
- if (colorbrewer[colorScheme]?.[this._classes]) {
- this.options.colors = colorbrewer[colorScheme][this._classes]
- } else {
- this.options.colors = colorbrewer?.Accent[this._classes]
- ? colorbrewer?.Accent[this._classes]
- : U.COLORS
- }
- },
-
- getEditableOptions: function () {
- return [
- [
- 'options.categorized.property',
- {
- handler: 'Select',
- selectOptions: this.datalayer._propertiesIndex,
- label: L._('Category property'),
- },
- ],
- [
- 'options.categorized.brewer',
- {
- handler: 'Select',
- label: L._('Color palette'),
- selectOptions: this.getColorSchemes(this._classes),
- },
- ],
- [
- 'options.categorized.categories',
- {
- handler: 'BlurInput',
- label: L._('Categories'),
- helpText: L._('Comma separated list of categories.'),
- },
- ],
- [
- 'options.categorized.mode',
- {
- handler: 'MultiChoice',
- default: 'alpha',
- choices: Object.entries(this.MODES),
- label: L._('Categories mode'),
- },
- ],
- ]
- },
-
- onEdit: function (field, builder) {
- // Only compute the categories if we're dealing with categorized
- if (!field.startsWith('options.categorized')) return
- // If user touches the categories, then force manual mode
- if (field === 'options.categorized.categories') {
- this.datalayer.options.categorized.mode = 'manual'
- if (builder) builder.helpers['options.categorized.mode'].fetch()
- }
- this.compute()
- // If user changes the mode
- // then update the categories input value
- if (field === 'options.categorized.mode') {
- if (builder) builder.helpers['options.categorized.categories'].fetch()
- }
- },
-
- getLegendItems: function () {
- return this.options.categories.map((limit, index) => {
- return [this.options.colors[index], this.options.categories[index]]
- })
- },
-})
-
-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()
- let alt
- if (this.datalayer.options.heat?.intensityProperty) {
- alt = Number.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: () => ({}),
-
- getBounds: function () {
- return L.latLngBounds(this._latlngs)
- },
-
- getEditableOptions: () => [
- [
- '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
- }
- const data = []
- const r = this._heat._r
- const size = this._map.getSize()
- const bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r]))
- const cellSize = r / 2
- const grid = []
- const panePos = this._map._getMapPanePos()
- const offsetX = panePos.x % cellSize
- const offsetY = panePos.y % cellSize
- let i
- let len
- let p
- let cell
- let x
- let y
- let j
- let 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
-
- const 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) {
- this.map = map
- this.sync = map.sync_engine.proxy(this)
- 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
- try {
- Object.defineProperty(this, 'isDirty', {
- get: () => isDirty,
- set: (status) => {
- if (!isDirty && status) this.fire('dirty')
- isDirty = status
- if (status) {
- this.map.addDirtyDatalayer(this)
- // 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.map.removeDirtyDatalayer(this)
- this.isDeleted = false
- }
- },
- })
- } catch (e) {
- // Certainly IE8, which has a limited version of defineProperty
- }
- try {
- Object.defineProperty(this, 'isDeleted', {
- get: () => isDeleted,
- set: (status) => {
- if (!isDeleted && status) this.fire('deleted')
- isDeleted = status
- if (status) this.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?.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 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)
- },
-
- getSyncMetadata: function () {
- return {
- subject: 'datalayer',
- metadata: {
- id: this.umap_id || null,
- },
- }
- },
-
- render: function (fields, builder) {
- const impacts = U.Utils.getImpactsFromSchema(fields)
-
- for (const impact of impacts) {
- switch (impact) {
- case 'ui':
- this.map.onDataLayersChanged()
- break
- case 'data':
- if (fields.includes('options.type')) {
- this.resetLayer()
- }
- this.hide()
- for (const field of fields) {
- 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, sync = true) {
- this.addData(geojson, sync)
- this._geojson = geojson
- this._dataloaded = true
- this.fire('datachanged')
- this.fire('dataloaded')
- },
-
- 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, false)
- 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 = Number.parseInt(this.options.fromZoom, 10)
- const to = Number.parseInt(this.options.toZoom, 10)
- const zoom = this.map.getZoom()
- return !((!Number.isNaN(from) && zoom < from) || (!Number.isNaN(to) && zoom > to))
- },
-
- hasDynamicData: function () {
- return !!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?.ok) {
- this.clear()
- this.map.formatter
- .parse(await response.text(), this.options.remoteData.format)
- .then((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?.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, sync) {
- const id = L.stamp(feature)
- if (sync !== false) feature.sync.delete()
- this.layer.removeLayer(feature)
- feature.disconnectFromDataLayer(this)
- this._index.splice(this._index.indexOf(id), 1)
- delete this._layers[id]
- delete this.map.features_index[feature.getSlug()]
- if (this.hasDataLoaded() && this.isVisible()) 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)
- this._propertiesIndex.sort()
- },
-
- deindexProperty: function (name) {
- const idx = this._propertiesIndex.indexOf(name)
- if (idx !== -1) this._propertiesIndex.splice(idx, 1)
- },
-
- sortedValues: function (property) {
- return Object.values(this._layers)
- .map((feature) => feature.properties[property])
- .filter((val, idx, arr) => arr.indexOf(val) === idx)
- .sort(U.Utils.naturalSort)
- },
-
- addData: function (geojson, sync) {
- try {
- // Do not fail if remote data is somehow invalid,
- // otherwise the layer becomes uneditable.
- this.geojsonToFeatures(geojson, sync)
- } catch (err) {
- console.log('Error with DataLayer', this.umap_id)
- console.error(err)
- }
- },
-
- // The choice of the name is not ours, because it is required by Leaflet.
- // It is misleading, as the returned objects are uMap objects, and not
- // GeoJSON features.
- geojsonToFeatures: function (geojson, sync) {
- if (!geojson) return
- const features = Array.isArray(geojson) ? 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
-
- const feature = this.geoJSONToLeaflet({ geometry, geojson })
- if (feature) {
- this.addLayer(feature)
- if (sync) 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.
- */
- geoJSONToLeaflet: function ({
- geometry,
- geojson = null,
- id = null,
- feature = null,
- } = {}) {
- if (!geometry) return // null geometry is valid geojson.
- const coords = geometry.coordinates
- let latlng
- let latlngs
-
- // Create a default geojson if none is provided
- if (geojson === undefined) 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:
- U.Alert.error(
- L._('Skipping unknown geometry.type: {type}', {
- type: geometry.type || 'undefined',
- })
- )
- }
- },
-
- _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: async function (raw, format) {
- this.map.formatter
- .parse(raw, format)
- .then((geojson) => this.addData(geojson))
- .then(() => this.zoomTo())
- this.isDirty = true
- },
-
- 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?.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)
- const 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')
- const 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())
- }
-
- const 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())
-
- const 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')
- }
-
- 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 backButton = L.DomUtil.createButtonIcon(
- undefined,
- 'icon-back',
- L._('Back to layers')
- )
- // Fixme: remove me when this is merged and released
- // https://github.com/Leaflet/Leaflet/pull/9052
- L.DomEvent.disableClickPropagation(backButton)
- L.DomEvent.on(backButton, 'click', this.map.editDatalayers, this.map)
-
- this.map.editPanel.open({
- content: container,
- actions: [backButton],
- })
- },
-
- getOwnOption: function (option) {
- if (U.Utils.usableOption(this.options, option)) return this.options[option]
- },
-
- getOption: function (option, feature) {
- if (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)
- }
- if (this.layer?.defaults?.[option]) {
- return this.layer.defaults[option]
- }
- return this.map.getOption(option, feature)
- },
-
- buildVersionsFieldset: async function (container) {
- const appendVersion = (data) => {
- const date = new Date(Number.parseInt(data.at, 10))
- const content = `${date.toLocaleString(L.lang)} (${Number.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?.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) {
- U.AlertConflict.error(
- L._(
- '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 () => {
- await this._trySave(url, {}, formData)
- }
- )
- }
- } 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.sync.update('_reference_version', this._reference_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.isVisible()) return
- const editor = new U.TableEditor(this)
- editor.open()
- },
-
- getFilterKeys: function () {
- // 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.map.options.filterKey) return this.map.options.filterKey
- if (this.options.labelKey) return this.options.labelKey
- if (this.map.options.sortKey) return this.map.options.sortKey
- return 'name'
- },
-})
-
-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)
- },
-})
diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html
index 9ae5857b..a997056d 100644
--- a/umap/templates/umap/js.html
+++ b/umap/templates/umap/js.html
@@ -5,6 +5,9 @@
+
+
{% if locale %}
{% with "umap/locale/"|add:locale|add:".js" as path %}
@@ -21,14 +24,11 @@
-
-
@@ -49,7 +49,6 @@
-