From abbd0e48030655ad5ae2938355ebe4efd0d2cd35 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 11 Jul 2024 16:02:03 +0200 Subject: [PATCH] chore: move layers to modules/ This is a small step in the direction of spliting the data part and the rendering part. Basically in modules/data relies the data part, and in modules/rendering the rendering part, which at some point in the history should be the only place where we use and inherit from Leaflet, including utils and such. --- .gitignore | 2 +- umap/static/umap/js/modules/caption.js | 18 +- umap/static/umap/js/modules/data/layer.js | 1283 ++++++++++++ umap/static/umap/js/modules/global.js | 11 +- .../umap/js/modules/rendering/layers/base.js | 105 + .../js/modules/rendering/layers/cluster.js | 103 + .../umap/js/modules/rendering/layers/heat.js | 176 ++ .../js/modules/rendering/layers/relative.js | 364 ++++ umap/static/umap/js/modules/tableeditor.js | 2 +- umap/static/umap/js/umap.controls.js | 101 - umap/static/umap/js/umap.forms.js | 10 +- umap/static/umap/js/umap.layer.js | 1837 ----------------- umap/templates/umap/js.html | 7 +- 13 files changed, 2054 insertions(+), 1965 deletions(-) create mode 100644 umap/static/umap/js/modules/data/layer.js create mode 100644 umap/static/umap/js/modules/rendering/layers/base.js create mode 100644 umap/static/umap/js/modules/rendering/layers/cluster.js create mode 100644 umap/static/umap/js/modules/rendering/layers/heat.js create mode 100644 umap/static/umap/js/modules/rendering/layers/relative.js delete mode 100644 umap/static/umap/js/umap.layer.js 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 @@ -