umap/umap/static/umap/js/modules/umap.js
Yohan Boniface 06c2438ff1
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
fixup: fix save center and zoom (#2371)
Broken since map split.
2024-12-16 18:30:37 +01:00

1678 lines
49 KiB
JavaScript

import {
DomUtil,
Util as LeafletUtil,
stamp,
latLngBounds,
} from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate, setLocale, getLocale } from './i18n.js'
import * as Utils from './utils.js'
import { ServerStored } from './saving.js'
import * as SAVEMANAGER from './saving.js'
import { SyncEngine } from './sync/engine.js'
import { LeafletMap } from './rendering/map.js'
import URLs from './urls.js'
import { Panel, EditPanel, FullPanel } from './ui/panel.js'
import Dialog from './ui/dialog.js'
import { BottomBar, TopBar } from './ui/bar.js'
import Tooltip from './ui/tooltip.js'
import ContextMenu from './ui/contextmenu.js'
import { Request, ServerRequest } from './request.js'
import Help from './help.js'
import { Formatter } from './formatter.js'
import Slideshow from './slideshow.js'
import { MapPermissions } from './permissions.js'
import { SCHEMA } from './schema.js'
import { DataLayer } from './data/layer.js'
import Facets from './facets.js'
import Browser from './browser.js'
import Caption from './caption.js'
import Importer from './importer.js'
import Rules from './rules.js'
import Share from './share.js'
import {
uMapAlertCreation as AlertCreation,
uMapAlert as Alert,
} from '../components/alerts/alert.js'
import Orderable from './orderable.js'
export default class Umap extends ServerStored {
constructor(element, geojson) {
super()
// We need to call async function in the init process,
// the init itself does not need to be awaited, but some calls
// in the process must be blocker
this.init(element, geojson)
}
get id() {
return this.properties.id
}
async init(element, geojson) {
this.properties = Object.assign(
{
enableMarkerDraw: true,
enablePolygonDraw: true,
enablePolylineDraw: true,
hash: true,
limitBounds: {},
},
geojson.properties
)
this.searchParams = new URLSearchParams(window.location.search)
this.sync_engine = new SyncEngine(this)
this.sync = this.sync_engine.proxy(this)
// Locale name (pt_PT, en_US…)
// To be used for Django localization
if (geojson.properties.locale) setLocale(geojson.properties.locale)
// Language code (pt-pt, en-us…)
// To be used in javascript APIs
if (geojson.properties.lang) U.lang = geojson.properties.lang
// Make it available to utils, without needing a reference to `Umap`.
U.LABEL_KEYS = geojson.properties.defaultLabelKeys || []
U.DEFAULT_LABEL_KEY = U.LABEL_KEYS[0] || 'name'
this.setPropertiesFromQueryString()
// Needed for actions labels
this.help = new Help(this)
// Prevent default creation of controls
const zoomControl = this.properties.zoomControl
const fullscreenControl = this.properties.fullscreenControl
const center = geojson.geometry
this.properties.zoomControl = false
this.properties.fullscreenControl = false
this._leafletMap = new LeafletMap(this, element)
this.properties.zoomControl = zoomControl !== undefined ? zoomControl : true
this.properties.fullscreenControl =
fullscreenControl !== undefined ? fullscreenControl : true
if (center) {
this._leafletMap.options.center = this._leafletMap.latLng(center)
}
// Needed to render controls
this.permissions = new MapPermissions(this)
this.urls = new URLs(this.properties.urls)
this.slideshow = new Slideshow(this, this._leafletMap)
this._leafletMap.setup()
if (geojson.properties.schema) this.overrideSchema(geojson.properties.schema)
this.panel = new Panel(this, this._leafletMap)
this.dialog = new Dialog({ className: 'dark' })
this.topBar = new TopBar(this, this._leafletMap._controlContainer)
this.bottomBar = new BottomBar(
this,
this.slideshow,
this._leafletMap._controlContainer
)
this.tooltip = new Tooltip(this._leafletMap._controlContainer)
this.contextmenu = new ContextMenu()
this.server = new ServerRequest()
this.request = new Request()
this.facets = new Facets(this)
this.browser = new Browser(this, this._leafletMap)
this.caption = new Caption(this, this._leafletMap)
this.importer = new Importer(this)
this.share = new Share(this)
this.rules = new Rules(this)
if (this.hasEditMode()) {
this.editPanel = new EditPanel(this, this._leafletMap)
this.fullPanel = new FullPanel(this, this._leafletMap)
this._leafletMap.initEditTools()
this.topBar.setup()
}
this.datalayersFromQueryString = this.searchParams.get('datalayers')
if (this.datalayersFromQueryString) {
this.datalayersFromQueryString = this.datalayersFromQueryString
.toString()
.split(',')
}
// Retrocompat
if (
this.properties.slideshow?.delay &&
this.properties.slideshow.active === undefined
) {
this.properties.slideshow.active = true
}
if (this.properties.advancedFilterKey) {
this.properties.facetKey = this.properties.advancedFilterKey
delete this.properties.advancedFilterKey
}
// Global storage for retrieving datalayers and features.
this.datalayers = {} // All datalayers, including deleted.
this.datalayersIndex = [] // Datalayers actually on the map and ordered.
this.featuresIndex = {}
this.formatter = new Formatter(this)
this.initDataLayers()
if (this.properties.displayCaptionOnLoad) {
// Retrocompat
if (!this.properties.onLoadPanel) {
this.properties.onLoadPanel = 'caption'
}
delete this.properties.displayCaptionOnLoad
}
if (this.properties.displayDataBrowserOnLoad) {
// Retrocompat
if (!this.properties.onLoadPanel) {
this.properties.onLoadPanel = 'databrowser'
}
delete this.properties.displayDataBrowserOnLoad
}
if (this.properties.datalayersControl === 'expanded') {
if (!this.properties.onLoadPanel) {
this.properties.onLoadPanel = 'datalayers'
}
delete this.properties.datalayersControl
}
if (this.properties.onLoadPanel === 'facet') {
this.properties.onLoadPanel = 'datafilters'
}
// Creation mode
if (!this.id) {
if (!this.properties.preview) {
this.isDirty = true
this.enableEdit()
}
this._defaultExtent = true
this.properties.name = translate('Untitled map')
await this.loadDataFromQueryString()
}
if (!this.properties.noControl) {
this.initShortcuts()
this._leafletMap.on('contextmenu', (e) => this.onContextMenu(e))
this.onceDataLoaded(this.setViewFromQueryString)
this.bottomBar.setup()
this.propagate()
}
window.onbeforeunload = () => (this.editEnabled && SAVEMANAGER.isDirty) || null
this.backup()
}
get editedFeature() {
return this._editedFeature
}
set editedFeature(feature) {
if (this._editedFeature && this._editedFeature !== feature) {
this._editedFeature.endEdit()
}
this._editedFeature = feature
this.fire('seteditedfeature')
}
setPropertiesFromQueryString() {
const asBoolean = (key) => {
const value = this.searchParams.get(key)
if (value !== undefined && value !== null) {
this.properties[key] = value === '1' || value === 'true'
}
}
const asNullableBoolean = (key) => {
if (this.searchParams.has(key)) {
let value = this.searchParams.get(key)
if (value === 'null') value = null
else if (value === '0' || value === 'false') value = false
else value = true
this.properties[key] = value
}
}
const asNumber = (key) => {
const value = +this.searchParams.get(key)
if (!Number.isNaN(value)) this.properties[name] = value
}
// FIXME retrocompat
asBoolean('displayDataBrowserOnLoad')
asBoolean('displayCaptionOnLoad')
for (const [key, schema] of Object.entries(SCHEMA)) {
switch (schema.type) {
case Boolean:
if (schema.nullable) asNullableBoolean(key)
else asBoolean(key)
break
case Number:
asNumber(key)
break
case String: {
if (this.searchParams.has(key)) {
const value = this.searchParams.get(key)
if (value !== undefined) this.properties[key] = value
break
}
}
}
}
// Specific case for datalayersControl
// which accepts "expanded" value, on top of true/false/null
if (this.searchParams.get('datalayersControl') === 'expanded') {
if (!this.properties.onLoadPanel) {
this.properties.onLoadPanel = 'datalayers'
}
}
}
async setViewFromQueryString() {
if (this.properties.noControl) return
// TODO: move to a "initPanel" function
if (this.searchParams.has('share')) {
this.share.open()
} else if (this.properties.onLoadPanel === 'databrowser') {
this.panel.setDefaultMode('expanded')
this.openBrowser('data')
} else if (this.properties.onLoadPanel === 'datalayers') {
this.panel.setDefaultMode('condensed')
this.openBrowser('layers')
} else if (this.properties.onLoadPanel === 'datafilters') {
this.panel.setDefaultMode('expanded')
this.openBrowser('filters')
} else if (this.properties.onLoadPanel === 'caption') {
this.panel.setDefaultMode('condensed')
this.openCaption()
}
// Comes after default panels, so if it opens in a panel it will
// take precedence.
const slug = this.searchParams.get('feature')
if (slug && this.featuresIndex[slug]) this.featuresIndex[slug].view()
if (this.searchParams.has('edit')) {
if (this.hasEditMode()) this.enableEdit()
// Sometimes users share the ?edit link by mistake, let's remove
// this search parameter from URL to prevent this
const url = new URL(window.location)
url.searchParams.delete('edit')
history.pushState({}, '', url)
}
if (this.searchParams.has('download')) {
const download_url = this.urls.get('map_download', {
map_id: this.id,
})
window.location = download_url
}
}
async loadDataFromQueryString() {
let data = this.searchParams.get('data')
const dataUrls = this.searchParams.getAll('dataUrl')
const dataFormat = this.searchParams.get('dataFormat') || 'geojson'
if (dataUrls.length) {
for (let dataUrl of dataUrls) {
dataUrl = decodeURIComponent(dataUrl)
dataUrl = this.renderUrl(dataUrl)
dataUrl = this.proxyUrl(dataUrl)
const datalayer = this.createDataLayer()
await datalayer
.importFromUrl(dataUrl, dataFormat)
.then(() => datalayer.zoomTo())
}
} else if (data) {
data = decodeURIComponent(data)
const datalayer = this.createDataLayer()
await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo())
}
}
getOwnContextMenuItems(event) {
const items = []
if (this.hasEditMode()) {
if (this.editEnabled) {
if (!SAVEMANAGER.isDirty) {
items.push({
label: this.help.displayLabel('STOP_EDIT'),
action: () => this.disableEdit(),
})
}
if (this.properties.enableMarkerDraw) {
items.push({
label: this.help.displayLabel('DRAW_MARKER'),
action: () => this._leafletMap.startMarker(event),
})
}
if (this.properties.enablePolylineDraw) {
items.push({
label: this.help.displayLabel('DRAW_POLYGON'),
action: () => this._leafletMap.startPolygon(event),
})
}
if (this.properties.enablePolygonDraw) {
items.push({
label: this.help.displayLabel('DRAW_LINE'),
action: () => this._leafletMap.startPolyline(event),
})
}
items.push('-')
items.push({
label: translate('Help'),
action: () => this.help.show('edit'),
})
} else {
items.push({
label: this.help.displayLabel('TOGGLE_EDIT'),
action: () => this.enableEdit(),
})
}
}
if (items.length) {
items.push('-')
}
items.push(
{
label: translate('Open browser'),
action: () => this.openBrowser('layers'),
},
{
label: translate('Browse data'),
action: () => this.openBrowser('data'),
}
)
if (this.properties.facetKey) {
items.push({
label: translate('Filter data'),
action: () => this.openBrowser('filters'),
})
}
items.push(
{
label: translate('Open caption'),
action: () => this.openCaption(),
},
{
label: this.help.displayLabel('SEARCH'),
action: () => this.search(),
}
)
return items
}
getSharedContextMenuItems(event) {
const items = []
if (this.properties.urls.routing) {
items.push('-', {
label: translate('Directions from here'),
action: () => this.openExternalRouting(event),
})
}
if (this.properties.urls.edit_in_osm) {
items.push('-', {
label: translate('Edit in OpenStreetMap'),
action: () => this.editInOSM(event),
})
}
return items
}
onContextMenu(event) {
const items = this.getOwnContextMenuItems(event).concat(
this.getSharedContextMenuItems(event)
)
this.contextmenu.open(event.originalEvent, items)
}
// Merge the given schema with the default one
// Missing keys inside the schema are merged with the default ones.
overrideSchema(schema) {
for (const [key, extra] of Object.entries(schema)) {
SCHEMA[key] = Object.assign({}, SCHEMA[key], extra)
}
}
search() {
if (this._leafletMap._controls.search) this._leafletMap._controls.search.open()
}
hasEditMode() {
const editMode = this.properties.editMode
return editMode === 'simple' || editMode === 'advanced'
}
getProperty(key, feature) {
if (feature) {
const value = this.rules.getOption(key, feature)
if (value !== undefined) return value
}
if (Utils.usableOption(this.properties, key)) return this.properties[key]
return SCHEMA[key]?.default
}
getOption(key, feature) {
// TODO: remove when umap.forms.js is refactored and does not call blindly
// obj.getOption anymore
return this.getProperty(key, feature)
}
getGeoContext() {
const bounds = this._leafletMap.getBounds()
const center = this._leafletMap.getCenter()
const context = {
bbox: bounds.toBBoxString(),
north: bounds.getNorthEast().lat,
east: bounds.getNorthEast().lng,
south: bounds.getSouthWest().lat,
west: bounds.getSouthWest().lng,
lat: center.lat,
lng: center.lng,
zoom: this._leafletMap.getZoom(),
}
context.left = context.west
context.bottom = context.south
context.right = context.east
context.top = context.north
return context
}
renderUrl(url) {
return Utils.greedyTemplate(url, this.getGeoContext(), true)
}
initShortcuts() {
const globalShortcuts = (event) => {
if (event.key === 'Escape') {
if (this.importer.dialog.visible) {
this.importer.dialog.close()
} else if (this.editEnabled && this._leafletMap.editTools.drawing()) {
this._leafletMap.editTools.onEscape()
} else if (this._leafletMap.measureTools.enabled()) {
this._leafletMap.measureTools.stopDrawing()
} else if (this.fullPanel?.isOpen()) {
this.fullPanel?.close()
} else if (this.editPanel?.isOpen()) {
this.editPanel?.close()
} else if (this.panel.isOpen()) {
this.panel.close()
}
}
// From now on, only ctrl/meta shortcut
if (!(event.ctrlKey || event.metaKey) || event.shiftKey) return
if (event.key === 'f') {
event.stopPropagation()
event.preventDefault()
this.search()
}
/* Edit mode only shortcuts */
if (!this.hasEditMode()) return
// Edit mode Off
if (!this.editEnabled) {
switch (event.key) {
case 'e':
event.stopPropagation()
event.preventDefault()
this.enableEdit()
break
}
return
}
// Edit mode on
let used = true
switch (event.key) {
case 'e':
if (!SAVEMANAGER.isDirty) this.disableEdit()
break
case 's':
if (SAVEMANAGER.isDirty) this.saveAll()
break
case 'z':
if (SAVEMANAGER.isDirty) this.askForReset()
break
case 'm':
this._leafletMap.editTools.startMarker()
break
case 'p':
this._leafletMap.editTools.startPolygon()
break
case 'l':
this._leafletMap.editTools.startPolyline()
break
case 'i':
this.importer.open()
break
case 'o':
this.importer.openFiles()
break
case 'h':
this.help.showGetStarted()
break
default:
used = false
}
if (used) {
event.stopPropagation()
event.preventDefault()
}
}
document.addEventListener('keydown', globalShortcuts)
}
async initDataLayers(datalayers) {
datalayers = datalayers || this.properties.datalayers
for (const options of datalayers) {
// `false` to not propagate syncing elements served from uMap
this.createDataLayer(options, false)
}
this.datalayersLoaded = true
this.fire('datalayersloaded')
const toLoad = []
for (const datalayer of this.datalayersIndex) {
if (datalayer.showAtLoad()) toLoad.push(datalayer.show())
}
await Promise.all(toLoad)
this.dataloaded = true
this.fire('dataloaded')
}
createDataLayer(options = {}, sync = true) {
options.name =
options.name || `${translate('Layer')} ${this.datalayersIndex.length + 1}`
const datalayer = new DataLayer(this, this._leafletMap, options)
if (sync !== false) {
datalayer.sync.upsert(datalayer.options)
}
return datalayer
}
newDataLayer() {
const datalayer = this.createDataLayer({})
datalayer.edit()
}
reindexDataLayers() {
this.eachDataLayer((datalayer) => datalayer.reindex())
this.onDataLayersChanged()
}
indexDatalayers() {
const panes = this._leafletMap.getPane('overlayPane')
this.datalayersIndex = []
for (const pane of panes.children) {
if (!pane.dataset || !pane.dataset.id) continue
this.datalayersIndex.push(this.datalayers[pane.dataset.id])
}
this.onDataLayersChanged()
}
onceDatalayersLoaded(callback, context) {
// Once datalayers **metadata** have been loaded
if (this.datalayersLoaded) {
callback.call(context || this, this)
} else {
this._leafletMap.once('datalayersloaded', callback, context)
}
return this
}
onceDataLoaded(callback, context) {
// Once datalayers **data** have been loaded
if (this.dataloaded) {
callback.call(context || this, this)
} else {
this._leafletMap.once('dataloaded', callback, context || this)
}
return this
}
onDataLayersChanged() {
if (this.browser) this.browser.update()
this.caption.refresh()
}
async saveAll() {
if (!SAVEMANAGER.isDirty) return
if (this._defaultExtent) this._setCenterAndZoom()
this.backup()
await SAVEMANAGER.save()
// Do a blind render for now, as we are not sure what could
// have changed, we'll be more subtil when we'll remove the
// save action
this.render(['name', 'user', 'permissions'])
if (!this._leafletMap.listens('saved')) {
// When we save only layers, we don't have the map feedback message
this._leafletMap.on('saved', () => {
Alert.success(translate('Map has been saved!'))
})
}
this.fire('saved')
}
getDisplayName() {
return this.properties.name || translate('Untitled map')
}
backup() {
this.backupProperties()
this._datalayersIndex_bk = [].concat(this.datalayersIndex)
}
backupProperties() {
this._backupProperties = Object.assign({}, this.properties)
this._backupProperties.tilelayer = Object.assign({}, this.properties.tilelayer)
this._backupProperties.limitBounds = Object.assign({}, this.properties.limitBounds)
this._backupProperties.permissions = Object.assign({}, this.permissions.properties)
}
resetProperties() {
this.properties = Object.assign({}, this._backupProperties)
this.properties.tilelayer = Object.assign({}, this._backupProperties.tilelayer)
this.permissions.properties = Object.assign({}, this._backupProperties.permissions)
}
setProperties(newProperties) {
for (const key of Object.keys(SCHEMA)) {
if (newProperties[key] !== undefined) {
this.properties[key] = newProperties[key]
if (key === 'rules') this.rules.load()
if (key === 'slideshow') this.slideshow.load()
// TODO: sync ?
}
}
}
hasData() {
for (const datalayer of this.datalayersIndex) {
if (datalayer.hasData()) return true
}
}
hasLayers() {
return Boolean(this.datalayersIndex.length)
}
allProperties() {
return [].concat(...this.datalayersIndex.map((dl) => dl.allProperties()))
}
sortedValues(property) {
return []
.concat(...this.datalayersIndex.map((dl) => dl.sortedValues(property)))
.filter((val, idx, arr) => arr.indexOf(val) === idx)
.sort(U.Utils.naturalSort)
}
editCaption() {
if (!this.editEnabled) return
if (this.properties.editMode !== 'advanced') return
const container = DomUtil.create('div', 'umap-edit-container')
const metadataFields = ['properties.name', 'properties.description']
DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption')
const builder = new U.FormBuilder(this, metadataFields, {
className: 'map-metadata',
umap: this,
})
const form = builder.build()
container.appendChild(form)
const credits = DomUtil.createFieldset(container, translate('Credits'))
const creditsFields = [
'properties.licence',
'properties.shortCredit',
'properties.longCredit',
'properties.permanentCredit',
'properties.permanentCreditBackground',
]
const creditsBuilder = new U.FormBuilder(this, creditsFields, { umap: this })
credits.appendChild(creditsBuilder.build())
this.editPanel.open({ content: container })
}
_editControls(container) {
let UIFields = []
for (const name of this._leafletMap.HIDDABLE_CONTROLS) {
UIFields.push(`properties.${name}Control`)
}
UIFields = UIFields.concat([
'properties.moreControl',
'properties.scrollWheelZoom',
'properties.miniMap',
'properties.scaleControl',
'properties.onLoadPanel',
'properties.defaultView',
'properties.displayPopupFooter',
'properties.captionBar',
'properties.captionMenus',
])
const builder = new U.FormBuilder(this, UIFields, { umap: this })
const controlsOptions = DomUtil.createFieldset(
container,
translate('User interface options')
)
controlsOptions.appendChild(builder.build())
}
_editShapeProperties(container) {
const shapeOptions = [
'properties.color',
'properties.iconClass',
'properties.iconUrl',
'properties.iconOpacity',
'properties.opacity',
'properties.weight',
'properties.fill',
'properties.fillColor',
'properties.fillOpacity',
'properties.smoothFactor',
'properties.dashArray',
]
const builder = new U.FormBuilder(this, shapeOptions, { umap: this })
const defaultShapeProperties = DomUtil.createFieldset(
container,
translate('Default shape properties')
)
defaultShapeProperties.appendChild(builder.build())
}
_editDefaultProperties(container) {
const optionsFields = [
'properties.zoomTo',
'properties.easing',
'properties.labelKey',
'properties.sortKey',
'properties.filterKey',
'properties.facetKey',
'properties.slugKey',
]
const builder = new U.FormBuilder(this, optionsFields, { umap: this })
const defaultProperties = DomUtil.createFieldset(
container,
translate('Default properties')
)
defaultProperties.appendChild(builder.build())
}
_editInteractionsProperties(container) {
const popupFields = [
'properties.popupShape',
'properties.popupTemplate',
'properties.popupContentTemplate',
'properties.showLabel',
'properties.labelDirection',
'properties.labelInteractive',
'properties.outlinkTarget',
]
const builder = new U.FormBuilder(this, popupFields, { umap: this })
const popupFieldset = DomUtil.createFieldset(
container,
translate('Default interaction options')
)
popupFieldset.appendChild(builder.build())
}
_editTilelayer(container) {
if (!Utils.isObject(this.properties.tilelayer)) {
this.properties.tilelayer = {}
}
const tilelayerFields = [
[
'properties.tilelayer.name',
{ handler: 'BlurInput', placeholder: translate('display name') },
],
[
'properties.tilelayer.url_template',
{
handler: 'BlurInput',
helpText: `${translate('Supported scheme')}: http://{s}.domain.com/{z}/{x}/{y}.png`,
placeholder: 'url',
type: 'url',
},
],
[
'properties.tilelayer.maxZoom',
{
handler: 'BlurIntInput',
placeholder: translate('max zoom'),
min: 0,
max: this.properties.maxZoomLimit,
},
],
[
'properties.tilelayer.minZoom',
{
handler: 'BlurIntInput',
placeholder: translate('min zoom'),
min: 0,
max: this.properties.maxZoomLimit,
},
],
[
'properties.tilelayer.attribution',
{ handler: 'BlurInput', placeholder: translate('attribution') },
],
[
'properties.tilelayer.tms',
{ handler: 'Switch', label: translate('TMS format') },
],
]
const customTilelayer = DomUtil.createFieldset(
container,
translate('Custom background')
)
const builder = new U.FormBuilder(this, tilelayerFields, { umap: this })
customTilelayer.appendChild(builder.build())
}
_editOverlay(container) {
if (!Utils.isObject(this.properties.overlay)) {
this.properties.overlay = {}
}
const overlayFields = [
[
'properties.overlay.url_template',
{
handler: 'BlurInput',
helpText: `${translate('Supported scheme')}: http://{s}.domain.com/{z}/{x}/{y}.png`,
placeholder: 'url',
label: translate('Background overlay url'),
type: 'url',
},
],
[
'properties.overlay.maxZoom',
{
handler: 'BlurIntInput',
placeholder: translate('max zoom'),
min: 0,
max: this.properties.maxZoomLimit,
},
],
[
'properties.overlay.minZoom',
{
handler: 'BlurIntInput',
placeholder: translate('min zoom'),
min: 0,
max: this.properties.maxZoomLimit,
},
],
[
'properties.overlay.attribution',
{ handler: 'BlurInput', placeholder: translate('attribution') },
],
[
'properties.overlay.opacity',
{ handler: 'Range', min: 0, max: 1, step: 0.1, label: translate('Opacity') },
],
['properties.overlay.tms', { handler: 'Switch', label: translate('TMS format') }],
]
const overlay = DomUtil.createFieldset(container, translate('Custom overlay'))
const builder = new U.FormBuilder(this, overlayFields, { umap: this })
overlay.appendChild(builder.build())
}
_editBounds(container) {
if (!Utils.isObject(this.properties.limitBounds)) {
this.properties.limitBounds = {}
}
const limitBounds = DomUtil.createFieldset(container, translate('Limit bounds'))
const boundsFields = [
[
'properties.limitBounds.south',
{ handler: 'BlurFloatInput', placeholder: translate('max South') },
],
[
'properties.limitBounds.west',
{ handler: 'BlurFloatInput', placeholder: translate('max West') },
],
[
'properties.limitBounds.north',
{ handler: 'BlurFloatInput', placeholder: translate('max North') },
],
[
'properties.limitBounds.east',
{ handler: 'BlurFloatInput', placeholder: translate('max East') },
],
]
const boundsBuilder = new U.FormBuilder(this, boundsFields, { umap: this })
limitBounds.appendChild(boundsBuilder.build())
const boundsButtons = DomUtil.create('div', 'button-bar half', limitBounds)
DomUtil.createButton(
'button',
boundsButtons,
translate('Use current bounds'),
function () {
const bounds = this._leafletMap.getBounds()
this.properties.limitBounds.south = LeafletUtil.formatNum(bounds.getSouth())
this.properties.limitBounds.west = LeafletUtil.formatNum(bounds.getWest())
this.properties.limitBounds.north = LeafletUtil.formatNum(bounds.getNorth())
this.properties.limitBounds.east = LeafletUtil.formatNum(bounds.getEast())
boundsBuilder.fetchAll()
this.sync.update(this, 'properties.limitBounds', this.properties.limitBounds)
this.isDirty = true
this._leafletMap.handleLimitBounds()
},
this
)
DomUtil.createButton(
'button',
boundsButtons,
translate('Empty'),
function () {
this.properties.limitBounds.south = null
this.properties.limitBounds.west = null
this.properties.limitBounds.north = null
this.properties.limitBounds.east = null
boundsBuilder.fetchAll()
this.isDirty = true
this._leafletMap.handleLimitBounds()
},
this
)
}
_editSlideshow(container) {
const slideshow = DomUtil.createFieldset(container, translate('Slideshow'))
const slideshowFields = [
[
'properties.slideshow.active',
{ handler: 'Switch', label: translate('Activate slideshow mode') },
],
[
'properties.slideshow.delay',
{
handler: 'SlideshowDelay',
helpText: translate('Delay between two transitions when in play mode'),
},
],
[
'properties.slideshow.easing',
{
handler: 'Switch',
label: translate('Animated transitions'),
inheritable: true,
},
],
[
'properties.slideshow.autoplay',
{ handler: 'Switch', label: translate('Autostart when map is loaded') },
],
]
const slideshowBuilder = new U.FormBuilder(this, slideshowFields, {
callback: () => {
this.slideshow.load()
// FIXME when we refactor formbuilder: this callback is called in a 'postsync'
// event, which comes after the call of `setter` method, which will call the
// map.render method, which should do this redraw.
this.bottomBar.redraw()
},
umap: this,
})
slideshow.appendChild(slideshowBuilder.build())
}
_editSync(container) {
const sync = DomUtil.createFieldset(container, translate('Real-time collaboration'))
const builder = new U.FormBuilder(this, ['properties.syncEnabled'], { umap: this })
sync.appendChild(builder.build())
}
_advancedActions(container) {
const advancedActions = DomUtil.createFieldset(
container,
translate('Advanced actions')
)
const advancedButtons = DomUtil.create('div', 'button-bar half', advancedActions)
if (this.permissions.isOwner()) {
const deleteButton = Utils.loadTemplate(`
<button class="button" type="button">
<i class="icon icon-24 icon-delete"></i>${translate('Delete')}
</button>`)
deleteButton.addEventListener('click', () => this.del())
advancedButtons.appendChild(deleteButton)
DomUtil.createButton(
'button umap-empty',
advancedButtons,
translate('Clear data'),
this.emptyDataLayers,
this
)
DomUtil.createButton(
'button umap-empty',
advancedButtons,
translate('Remove layers'),
this.removeDataLayers,
this
)
}
DomUtil.createButton(
'button umap-clone',
advancedButtons,
translate('Clone this map'),
this.clone,
this
)
DomUtil.createButton(
'button umap-download',
advancedButtons,
translate('Open share & download panel'),
this.share.open,
this.share
)
}
edit() {
if (!this.editEnabled) return
if (this.properties.editMode !== 'advanced') return
const container = DomUtil.create('div')
DomUtil.createTitle(
container,
translate('Map advanced properties'),
'icon-settings'
)
this._editControls(container)
this._editShapeProperties(container)
this._editDefaultProperties(container)
this._editInteractionsProperties(container)
this.rules.edit(container)
this._editTilelayer(container)
this._editOverlay(container)
this._editBounds(container)
this._editSlideshow(container)
if (this.properties.websocketEnabled) {
this._editSync(container)
}
this._advancedActions(container)
this.editPanel.open({ content: container, className: 'dark' })
}
reset() {
if (this._leafletMap.editTools) this._leafletMap.editTools.stopDrawing()
this.resetProperties()
this.datalayersIndex = [].concat(this._datalayersIndex_bk)
// Iter over all datalayers, including deleted if any.
for (const datalayer of Object.values(this.datalayers)) {
if (datalayer.isDeleted) datalayer.connectToMap()
if (datalayer.isDirty) datalayer.reset()
}
this.ensurePanesOrder()
this._leafletMap.initTileLayers()
this.isDirty = false
this.onDataLayersChanged()
}
async save() {
this.rules.commit()
const geojson = {
type: 'Feature',
geometry: this.geometry(),
properties: this.exportProperties(),
}
const formData = new FormData()
formData.append('name', this.properties.name)
formData.append('center', JSON.stringify(this.geometry()))
formData.append('settings', JSON.stringify(geojson))
const uri = this.urls.get('map_save', { map_id: this.id })
const [data, _, error] = await this.server.post(uri, {}, formData)
// FIXME: login_required response will not be an error, so it will not
// stop code while it should
if (error) {
return
}
// TOOD: map.save may not always be the first call during save process
// since SAVEMANAGER refactor
if (data.login_required) {
window.onLogin = () => this.saveAll()
window.open(data.login_required)
return
}
this.properties.user = data.user
if (!this.id) {
this.properties.id = data.id
this.permissions.setProperties(data.permissions)
this.permissions.commit()
if (data.permissions?.anonymous_edit_url) {
this._leafletMap.once('saved', () => {
AlertCreation.info(
translate('Your map has been created with an anonymous account!'),
Number.Infinity,
data.permissions.anonymous_edit_url,
this.properties.urls.map_send_edit_link
? this.sendEditLinkEmail.bind(this)
: null
)
})
} else {
this._leafletMap.once('saved', () => {
Alert.success(translate('Congratulations, your map has been created!'))
})
}
} else {
if (!this.permissions.isDirty) {
// Do not override local changes to permissions,
// but update in case some other editors changed them in the meantime.
this.permissions.setProperties(data.permissions)
this.permissions.commit()
}
this._leafletMap.once('saved', () => {
Alert.success(data.info || translate('Map has been saved!'))
})
}
// Update URL in case the name has changed.
if (history?.pushState) {
history.pushState({}, this.properties.name, data.url)
} else {
window.location = data.url
}
return true
}
exportProperties() {
const properties = {}
for (const key of Object.keys(SCHEMA)) {
if (this.properties[key] !== undefined) {
properties[key] = this.properties[key]
}
}
return properties
}
geometry() {
/* Return a GeoJSON geometry Object */
const latlng = this._leafletMap.latLng(
this.properties.center || this._leafletMap.getCenter()
)
return {
type: 'Point',
coordinates: [latlng.lng, latlng.lat],
}
}
toGeoJSON() {
let features = []
this.eachDataLayer((datalayer) => {
if (datalayer.isVisible()) {
features = features.concat(datalayer.featuresToGeoJSON())
}
})
const geojson = {
type: 'FeatureCollection',
features: features,
}
return geojson
}
enableEdit() {
document.body.classList.add('umap-edit-enabled')
this.editEnabled = true
this.drop.enable()
this.fire('edit:enabled')
this.initSyncEngine()
}
disableEdit() {
if (this.isDirty) return
this.drop.disable()
document.body.classList.remove('umap-edit-enabled')
this.editedFeature = null
this.editEnabled = false
this.fire('edit:disabled')
this.editPanel.close()
this.fullPanel.close()
this.sync.stop()
this._leafletMap.closeInplaceToolbar()
}
fire(name) {
this._leafletMap.fire(name)
}
askForReset(e) {
this.dialog
.confirm(translate('Are you sure you want to cancel your changes?'))
.then(() => {
this.reset()
this.disableEdit()
})
}
async initSyncEngine() {
if (this.properties.websocketEnabled === false) return
if (this.properties.syncEnabled !== true) {
this.sync.stop()
} else {
const ws_token_uri = this.urls.get('map_websocket_auth_token', {
map_id: this.id,
})
await this.sync.authenticate(
ws_token_uri,
this.properties.websocketURI,
this.server
)
}
}
getSyncMetadata() {
return {
engine: this.sync,
subject: 'map',
}
}
render(fields = []) {
// Propagate will remove the fields it has already
// processed
fields = this.propagate(fields)
const impacts = Utils.getImpactsFromSchema(fields)
for (const impact of impacts) {
switch (impact) {
case 'ui':
this._leafletMap.renderUI()
this.browser.redraw()
this.topBar.redraw()
this.bottomBar.redraw()
break
case 'data':
this.eachVisibleDataLayer((datalayer) => {
datalayer.redraw()
})
break
case 'datalayer-index':
this.reindexDataLayers()
break
case 'background':
this._leafletMap.initTileLayers()
break
case 'bounds':
this._leafletMap.handleLimitBounds()
break
case 'sync':
this.initSyncEngine()
}
}
}
// This method does a targeted update of the UI,
// it whould be merged with `render`` method and the
// SCHEMA at some point
propagate(fields = []) {
const impacts = {
'properties.name': () => {
Utils.eachElement('.map-name', (el) => {
el.textContent = this.getDisplayName()
})
},
user: () => {
Utils.eachElement('.umap-user .username', (el) => {
if (this.properties.user?.id) {
el.textContent = this.properties.user.name
}
})
},
'properties.permissions': () => {
const status = this.permissions.getShareStatusDisplay()
if (status) {
Utils.eachElement('.share-status', (el) => {
el.textContent = translate('Visibility: {status}', {
status: status,
})
})
}
this.topBar.redraw()
},
numberOfConnectedPeers: () => {
Utils.eachElement('.connected-peers span', (el) => {
el.textContent = this.sync.getNumberOfConnectedPeers()
})
},
}
for (const [field, impact] of Object.entries(impacts)) {
if (!fields.length || fields.includes(field)) {
impact()
fields = fields.filter((item) => item !== field)
}
}
return fields
}
// TODO: allow to control the default datalayer
// (edit and viewing)
// cf https://github.com/umap-project/umap/issues/585
defaultEditDataLayer() {
let datalayer
let fallback
datalayer = this.lastUsedDataLayer
if (
datalayer &&
!datalayer.isDataReadOnly() &&
datalayer.isBrowsable() &&
datalayer.isVisible()
) {
return datalayer
}
datalayer = this.findDataLayer((datalayer) => {
if (!datalayer.isDataReadOnly() && datalayer.isBrowsable()) {
fallback = datalayer
if (datalayer.isVisible()) return true
}
})
if (datalayer) return datalayer
if (fallback) {
// No datalayer visible, let's force one
fallback.show()
return fallback
}
return this.createDataLayer()
}
findDataLayer(method, context) {
for (let i = this.datalayersIndex.length - 1; i >= 0; i--) {
if (method.call(context, this.datalayersIndex[i])) {
return this.datalayersIndex[i]
}
}
}
eachDataLayer(method, context) {
for (let i = 0; i < this.datalayersIndex.length; i++) {
method.call(context, this.datalayersIndex[i])
}
}
eachDataLayerReverse(method, context, filter) {
for (let i = this.datalayersIndex.length - 1; i >= 0; i--) {
if (filter && !filter.call(context, this.datalayersIndex[i])) continue
method.call(context, this.datalayersIndex[i])
}
}
eachBrowsableDataLayer(method, context) {
this.eachDataLayerReverse(method, context, (d) => d.allowBrowse())
}
eachVisibleDataLayer(method, context) {
this.eachDataLayerReverse(method, context, (d) => d.isVisible())
}
eachFeature(callback, context) {
this.eachBrowsableDataLayer((datalayer) => {
if (datalayer.isVisible()) datalayer.eachFeature(callback, context)
})
}
removeDataLayers() {
this.eachDataLayerReverse((datalayer) => {
datalayer._delete()
})
}
emptyDataLayers() {
this.eachDataLayerReverse((datalayer) => {
datalayer.empty()
})
}
editDatalayers() {
if (!this.editEnabled) return
const container = DomUtil.create('div')
DomUtil.createTitle(container, translate('Manage layers'), 'icon-layers')
const ul = DomUtil.create('ul', '', container)
this.eachDataLayerReverse((datalayer) => {
const row = DomUtil.create('li', 'orderable', ul)
DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder'))
datalayer.renderToolbox(row)
const title = DomUtil.add('span', '', row, datalayer.options.name)
row.classList.toggle('off', !datalayer.isVisible())
title.textContent = datalayer.options.name
row.dataset.id = stamp(datalayer)
})
const onReorder = (src, dst, initialIndex, finalIndex) => {
const movedLayer = this.datalayers[src.dataset.id]
const targetLayer = this.datalayers[dst.dataset.id]
const minIndex = Math.min(movedLayer.getRank(), targetLayer.getRank())
const maxIndex = Math.max(movedLayer.getRank(), targetLayer.getRank())
if (finalIndex === 0) movedLayer.bringToTop()
else if (finalIndex > initialIndex) movedLayer.insertBefore(targetLayer)
else movedLayer.insertAfter(targetLayer)
this.eachDataLayerReverse((datalayer) => {
if (datalayer.getRank() >= minIndex && datalayer.getRank() <= maxIndex)
datalayer.isDirty = true
})
this.indexDatalayers()
}
const orderable = new Orderable(ul, onReorder)
const bar = DomUtil.create('div', 'button-bar', container)
DomUtil.createButton(
'show-on-edit block add-datalayer button',
bar,
translate('Add a layer'),
this.newDataLayer,
this
)
this.editPanel.open({ content: container })
}
getDataLayerByUmapId(id) {
const datalayer = this.findDataLayer((d) => d.id === id)
if (!datalayer) throw new Error(`Can't find datalayer with id ${id}`)
return datalayer
}
firstVisibleDatalayer() {
return this.findDataLayer((datalayer) => {
if (datalayer.isVisible()) return true
})
}
ensurePanesOrder() {
this.eachDataLayer((datalayer) => {
datalayer.bringToTop()
})
}
openBrowser(mode) {
this.onceDatalayersLoaded(() => this.browser.open(mode))
}
openCaption() {
this.onceDatalayersLoaded(() => this.caption.open())
}
addAuthorLink(container) {
const author = this.properties.author
if (author?.name) {
const el = Utils.loadTemplate(
`<span class="umap-map-author"> ${translate('by')} <a href="${author.url}">${author.name}</a></span>`
)
container.appendChild(el)
}
}
async star() {
if (!this.id) {
return Alert.error(translate('Please save the map first'))
}
const url = this.urls.get('map_star', { map_id: this.id })
const [data, response, error] = await this.server.post(url)
if (error) {
return
}
this.properties.starred = data.starred
Alert.success(
data.starred
? translate('Map has been starred')
: translate('Map has been unstarred')
)
this.render(['starred'])
}
processFileToImport(file, layer, type) {
type = type || Utils.detectFileType(file)
if (!type) {
Alert.error(
translate('Unable to detect format of file {filename}', {
filename: file.name,
})
)
return
}
if (type === 'umap') {
this.importUmapFile(file, 'umap')
} else {
if (!layer) layer = this.createDataLayer({ name: file.name })
layer.importFromFile(file, type)
}
}
async importFromUrl(uri) {
const response = await this.request.get(uri)
if (response?.ok) {
this.importRaw(await response.text())
}
}
importRaw(rawData) {
const importedData = JSON.parse(rawData)
this.setProperties(importedData.properties)
if (importedData.geometry) {
this.properties.center = this._leafletMap.latLng(importedData.geometry)
}
for (const geojson of importedData.layers) {
if (!geojson._umap_options && geojson._storage) {
geojson._umap_options = geojson._storage
delete geojson._storage
}
delete geojson._umap_options?.id // Never trust an id at this stage
const dataLayer = this.createDataLayer(geojson._umap_options)
dataLayer.fromUmapGeoJSON(geojson)
}
// For now render->propagate expect a `properties.` prefix.
// Remove this when we have refactored schema and render.
const fields = Object.keys(importedData.properties).map(
(field) => `properties.${field}`
)
this.render(fields)
this._leafletMap._setDefaultCenter()
this.isDirty = true
}
importUmapFile(file) {
const reader = new FileReader()
reader.readAsText(file)
reader.onload = (e) => {
const rawData = e.target.result
try {
this.importRaw(rawData)
} catch (e) {
console.error('Error importing data', e)
U.Alert.error(
translate('Invalid umap data in {filename}', { filename: file.name })
)
}
}
}
async del() {
this.dialog
.confirm(translate('Are you sure you want to delete this map?'))
.then(async () => {
const url = this.urls.get('map_delete', { map_id: this.id })
const [data, response, error] = await this.server.post(url)
if (data.redirect) window.location = data.redirect
})
}
async clone() {
this.dialog
.confirm(
translate('Are you sure you want to clone this map and all its datalayers?')
)
.then(async () => {
const url = this.urls.get('map_clone', { map_id: this.id })
const [data, response, error] = await this.server.post(url)
if (data.redirect) window.location = data.redirect
})
}
async sendEditLinkEmail(formData) {
const sendLink =
this.properties.urls.map_send_edit_link &&
this.urls.get('map_send_edit_link', {
map_id: this.id,
})
await this.server.post(sendLink, {}, formData)
}
getLayersBounds() {
const bounds = new latLngBounds()
this.eachBrowsableDataLayer((d) => {
if (d.isVisible()) bounds.extend(d.layer.getBounds())
})
return bounds
}
fitDataBounds() {
const bounds = this.getLayersBounds()
if (!this.hasData() || !bounds.isValid()) return false
this._leafletMap.fitBounds(bounds)
}
proxyUrl(url, ttl) {
if (this.properties.urls.ajax_proxy) {
url = Utils.greedyTemplate(this.properties.urls.ajax_proxy, {
url: encodeURIComponent(url),
ttl: ttl,
})
}
return url
}
openExternalRouting(event) {
const url = this.urls.get('routing', {
lat: event.latlng.lat,
lng: event.latlng.lng,
locale: getLocale(),
zoom: this._leafletMap.getZoom(),
})
if (url) window.open(url)
}
editInOSM(event) {
const url = this.urls.get('edit_in_osm', {
lat: event.latlng.lat,
lng: event.latlng.lng,
zoom: Math.max(this._leafletMap.getZoom(), 16),
})
if (url) window.open(url)
}
setCenterAndZoom() {
this._setCenterAndZoom()
Alert.success(translate('The zoom and center have been modified.'))
}
_setCenterAndZoom() {
this.properties.center = this._leafletMap.getCenter()
this.properties.zoom = this._leafletMap.getZoom()
this.isDirty = true
this._defaultExtent = false
}
}