Compare commits

...

35 commits

Author SHA1 Message Date
Yohan Boniface
2b2098410b chore: add test for template_list view 2025-04-23 17:52:12 +02:00
Yohan Boniface
f01f737e7b wip: mark template maps in maps list 2025-04-23 17:52:12 +02:00
Yohan Boniface
164b5a01bd wip: add link to open template in a new window 2025-04-23 17:52:12 +02:00
Yohan Boniface
71c6974c3c wip: display templates in importer by origin (me/staff/community)
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-23 17:52:12 +02:00
Yohan Boniface
23f7337a98 fixup: fix tests
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-23 17:52:12 +02:00
Yohan Boniface
b4fcbd1541 wip: first naive version of map templates
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-23 17:52:12 +02:00
Yohan Boniface
efaa765b82
chore: cleaning core utils and removing DomUtil/DomEvent from modules (#2671) 2025-04-23 17:51:45 +02:00
Yohan Boniface
92ca581fb6
fix: allow to save a remote data with unloaded data (#2657)
When loading remote data fails, the layer is in state "unloaded", and
thus it was not possible to save it.
This "isLoaded" check is made for non remote layer so that we don't save
it by mistake with no data.

Also, let's allow to edit non loaded layers, this check was there when
settings was not on db, so not all loaded at map init, again not to
override with incomplete metadata.
2025-04-23 17:49:12 +02:00
Yohan Boniface
6687cd53ba
feat: add a back button in rules form (#2673)
To go back in rules list.

fix #2631


![image](https://github.com/user-attachments/assets/d0ad67fe-90c1-45c5-8bca-5ac30ef8366a)
2025-04-23 17:43:22 +02:00
Yohan Boniface
4997ee8860
feat: naive support for GeometryCollection as Feature geometry (#2658)
Currently, we just skip those, but sometimes togeojson produces this
output from a KML.
This will create one feature per geometry, while in an ideal world this
should be a multi, but we lack reliable geometry tools to merge the
geometries without risking to create mistakes.
So let's say it's a first improvement.
2025-04-23 17:43:05 +02:00
Yohan Boniface
a708316604
fix: make rules reordering syncable, savable and undoable (#2672) 2025-04-23 17:42:47 +02:00
Yohan Boniface
71d92aa610
fix: show an error message if saving layer failed (#2670)
This could occur when server is down or there is no network.
2025-04-23 17:40:19 +02:00
Yohan Boniface
41752bd0c6
fix: do not display "saved" message if some request failed (#2669) 2025-04-23 17:39:59 +02:00
Yohan Boniface
5b6138a210
chore: move pure Leaflet controls to modules (#2668)
pure == inheriting from Leaflet itself, not from some plugin (which
aren't ESM ready…)
2025-04-23 17:39:33 +02:00
Yohan Boniface
07363fa5fe chore: remove unused DomEvent.once 2025-04-23 16:52:55 +02:00
Yohan Boniface
7991d6cdbe chore: remove DomUtil.after/before 2025-04-23 16:50:13 +02:00
Yohan Boniface
60ac4b35f2 chore: remove most of DomUtil/DomEvent from browser.js 2025-04-23 16:24:15 +02:00
Yohan Boniface
bf2e9dc175 chore: remove call to DomUtil.createIcon in umap.js 2025-04-23 11:33:12 +02:00
Yohan Boniface
67ed6d5b44 feat: add a back button in rules form
To go back in rules list.

fix #2631
2025-04-23 10:55:39 +02:00
Yohan Boniface
fad182c5f3 fix: make rules reordering syncable, savable and undoable 2025-04-23 10:19:19 +02:00
Yohan Boniface
3f5d282477 chore: remove DomUtil and DomEvent dependency from rules.js 2025-04-23 10:14:22 +02:00
Yohan Boniface
49496348d2 chore: remove unused L.DomUtil.createLink method 2025-04-23 09:21:35 +02:00
Yohan Boniface
f8b13639aa fix: show an error message if saving layer failed
This could occur when server is down or there is no network.
2025-04-23 09:02:25 +02:00
Yohan Boniface
4c71710641 fix: do not display "saved" message if some request failed 2025-04-23 08:58:17 +02:00
Yohan Boniface
92d5c47844 chore: move pure Leaflet controls to modules
pure == inheriting from Leaflet itself, not from some plugin
(which aren't ESM ready…)
2025-04-22 20:14:06 +02:00
Yohan Boniface
05493d8a48
chore: bump uvicorn from 0.34.1 to 0.34.2 (#2664)
Some checks failed
Release Charts / release (push) Has been cancelled
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
2025-04-22 15:06:34 +02:00
Yohan Boniface
fd8f403a69
chore: bump mkdocs-material from 9.6.11 to 9.6.12 (#2665) 2025-04-22 15:06:19 +02:00
Yohan Boniface
a6412c9539
chore: bump ruff from 0.11.4 to 0.11.6 (#2666) 2025-04-22 15:06:01 +02:00
Yohan Boniface
fe65a5351b
chore: bump moto[s3] from 5.1.3 to 5.1.4 (#2667) 2025-04-22 15:05:44 +02:00
dependabot[bot]
2ede25f541
chore: bump moto[s3] from 5.1.3 to 5.1.4
Bumps [moto[s3]](https://github.com/getmoto/moto) from 5.1.3 to 5.1.4.
- [Release notes](https://github.com/getmoto/moto/releases)
- [Changelog](https://github.com/getmoto/moto/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getmoto/moto/compare/5.1.3...5.1.4)

---
updated-dependencies:
- dependency-name: moto[s3]
  dependency-version: 5.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 17:46:08 +00:00
dependabot[bot]
b7acdcfd93
chore: bump ruff from 0.11.4 to 0.11.6
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.11.4 to 0.11.6.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.11.4...0.11.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.11.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 17:39:19 +00:00
dependabot[bot]
a3d2330661
chore: bump mkdocs-material from 9.6.11 to 9.6.12
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.6.11 to 9.6.12.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.6.11...9.6.12)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-version: 9.6.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 17:33:21 +00:00
dependabot[bot]
20ae374173
chore: bump uvicorn from 0.34.1 to 0.34.2
Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.34.1 to 0.34.2.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/docs/release-notes.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.34.1...0.34.2)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 17:29:12 +00:00
Yohan Boniface
b0fe95dbd9 feat: naive support for GeometryCollection as Feature geometry
Currently, we just skip those, but sometimes togeojson produces this
output from a KML.
This will create one feature per geometry, while in an ideal world this
should be a multi, but we lack reliable geometry tools to merge the
geometries without risking to create mistakes.
So let's say it's a first improvement.
2025-04-16 18:28:23 +02:00
Yohan Boniface
169be73488 fix: allow to save a remote data with unloaded data
When loading remote data fails, the layer is in state "unloaded",
and thus it was not possible to save it.
This "isLoaded" check is made for non remote layer so that we don't
save it by mistake with no data.

Also, let's allow to edit non loaded layers, this check was there
when settings was not on db, so not all loaded at map init, again
not to override with incomplete metadata.
2025-04-16 16:00:26 +02:00
37 changed files with 890 additions and 567 deletions

View file

@ -1,5 +1,5 @@
# Force rtfd to use a recent version of mkdocs # Force rtfd to use a recent version of mkdocs
mkdocs==1.6.1 mkdocs==1.6.1
pymdown-extensions==10.14.3 pymdown-extensions==10.14.3
mkdocs-material==9.6.11 mkdocs-material==9.6.12
mkdocs-static-i18n==1.3.0 mkdocs-static-i18n==1.3.0

View file

@ -1,5 +1,5 @@
# Force rtfd to use a recent version of mkdocs # Force rtfd to use a recent version of mkdocs
mkdocs==1.6.1 mkdocs==1.6.1
pymdown-extensions==10.14.3 pymdown-extensions==10.14.3
mkdocs-material==9.6.11 mkdocs-material==9.6.12
mkdocs-static-i18n==1.3.0 mkdocs-static-i18n==1.3.0

View file

@ -44,10 +44,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"hatch==1.14.1", "hatch==1.14.1",
"ruff==0.11.4", "ruff==0.11.6",
"djlint==1.36.4", "djlint==1.36.4",
"mkdocs==1.6.1", "mkdocs==1.6.1",
"mkdocs-material==9.6.11", "mkdocs-material==9.6.12",
"mkdocs-static-i18n==1.3.0", "mkdocs-static-i18n==1.3.0",
"vermin==1.6.0", "vermin==1.6.0",
"pymdown-extensions==10.14.3", "pymdown-extensions==10.14.3",
@ -62,10 +62,10 @@ test = [
"pytest-playwright==0.7.0", "pytest-playwright==0.7.0",
"pytest-rerunfailures==15.0", "pytest-rerunfailures==15.0",
"pytest-xdist>=3.5.0,<4", "pytest-xdist>=3.5.0,<4",
"moto[s3]==5.1.3" "moto[s3]==5.1.4"
] ]
docker = [ docker = [
"uvicorn==0.34.1", "uvicorn==0.34.2",
] ]
s3 = [ s3 = [
"django-storages[s3]==1.14.6", "django-storages[s3]==1.14.6",

View file

@ -91,7 +91,7 @@ class MapSettingsForm(forms.ModelForm):
return self.cleaned_data["center"] return self.cleaned_data["center"]
class Meta: class Meta:
fields = ("settings", "name", "center", "slug", "tags") fields = ("settings", "name", "center", "slug", "tags", "is_template")
model = Map model = Map

View file

@ -1,10 +1,37 @@
from django.db.models import Manager from django.db import models
class PublicManager(Manager): class PublicManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return ( return (
super(PublicManager, self) super(PublicManager, self)
.get_queryset() .get_queryset()
.filter(share_status=self.model.PUBLIC) .filter(share_status=self.model.PUBLIC)
) )
def starred_by_staff(self):
from .models import Star, User
staff = User.objects.filter(is_staff=True)
stars = Star.objects.filter(by__in=staff).values("map")
return self.get_queryset().filter(pk__in=stars)
class PrivateQuerySet(models.QuerySet):
def for_user(self, user):
qs = self.exclude(share_status__in=[self.model.DELETED, self.model.BLOCKED])
teams = user.teams.all()
qs = (
qs.filter(owner=user)
.union(qs.filter(editors=user))
.union(qs.filter(team__in=teams))
)
return qs
class PrivateManager(models.Manager):
def get_queryset(self):
return PrivateQuerySet(self.model, using=self._db)
def for_user(self, user):
return self.get_queryset().for_user(user)

View file

@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-04-17 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("umap", "0027_map_tags"),
]
operations = [
migrations.AddField(
model_name="map",
name="is_template",
field=models.BooleanField(
default=False,
help_text="This map is a template map.",
verbose_name="save as template",
),
),
]

View file

@ -12,7 +12,7 @@ from django.urls import reverse
from django.utils.functional import classproperty from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .managers import PublicManager from .managers import PrivateManager, PublicManager
from .utils import _urls_for_js from .utils import _urls_for_js
@ -238,9 +238,15 @@ class Map(NamedModel):
blank=True, null=True, verbose_name=_("settings"), default=dict blank=True, null=True, verbose_name=_("settings"), default=dict
) )
tags = ArrayField(models.CharField(max_length=200), blank=True, default=list) tags = ArrayField(models.CharField(max_length=200), blank=True, default=list)
is_template = models.BooleanField(
default=False,
verbose_name=_("save as template"),
help_text=_("This map is a template map."),
)
objects = models.Manager() objects = models.Manager()
public = PublicManager() public = PublicManager()
private = PrivateManager()
@property @property
def description(self): def description(self):
@ -289,14 +295,17 @@ class Map(NamedModel):
datalayer.delete() datalayer.delete()
return super().delete(**kwargs) return super().delete(**kwargs)
def generate_umapjson(self, request): def generate_umapjson(self, request, include_data=True):
umapjson = self.settings umapjson = self.settings
umapjson["type"] = "umap" umapjson["type"] = "umap"
umapjson["properties"].pop("is_template", None)
umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url()) umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url())
datalayers = [] datalayers = []
for datalayer in self.datalayers: for datalayer in self.datalayers:
with datalayer.geojson.open("rb") as f: layer = {}
layer = json.loads(f.read()) if include_data:
with datalayer.geojson.open("rb") as f:
layer = json.loads(f.read())
if datalayer.settings: if datalayer.settings:
datalayer.settings.pop("id", None) datalayer.settings.pop("id", None)
layer["_umap_options"] = datalayer.settings layer["_umap_options"] = datalayer.settings

View file

@ -165,7 +165,6 @@ h2.tabs a:hover {
min-height: var(--map-fragment-height); min-height: var(--map-fragment-height);
} }
.tag-list { .tag-list {
margin-top: var(--text-margin);
margin-bottom: var(--text-margin); margin-bottom: var(--text-margin);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -205,6 +204,7 @@ h2.tabs a:hover {
margin-bottom: 0; margin-bottom: 0;
flex-grow: 1; flex-grow: 1;
gap: var(--gutter); gap: var(--gutter);
margin-top: var(--text-margin);
} }
.card h3 { .card h3 {
margin-bottom: 0; margin-bottom: 0;

View file

@ -4,7 +4,7 @@
margin-top: 100px; margin-top: 100px;
width: var(--dialog-width); width: var(--dialog-width);
max-width: 100vw; max-width: 100vw;
max-height: 50vh; max-height: 80vh;
padding: 20px; padding: 20px;
border: 1px solid #222; border: 1px solid #222;
background-color: var(--background-color); background-color: var(--background-color);
@ -12,11 +12,14 @@
border-radius: 5px; border-radius: 5px;
overflow-y: auto; overflow-y: auto;
height: fit-content; height: fit-content;
max-height: 90vh;
} }
.umap-dialog ul + h4 { .umap-dialog ul + h4 {
margin-top: var(--box-margin); margin-top: var(--box-margin);
} }
.umap-dialog .body {
max-height: 50vh;
overflow-y: auto;
}
:where([data-component="no-dialog"]:not([hidden])) { :where([data-component="no-dialog"]:not([hidden])) {
display: block; display: block;
position: relative; position: relative;

View file

@ -59,3 +59,16 @@
.tooltip-accent li:last-of-type { .tooltip-accent li:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }
.umap-tooltip-container.tooltip-right:before {
right: 100%;
top: calc(50% - var(--arrow-size));
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-right-color: var(--tooltip-color);
border-width: var(--arrow-size);
}

View file

@ -7,6 +7,7 @@ import {
import { translate } from './i18n.js' import { translate } from './i18n.js'
import { Request, ServerRequest } from './request.js' import { Request, ServerRequest } from './request.js'
import { escapeHTML, generateId } from './utils.js' import { escapeHTML, generateId } from './utils.js'
import * as Utils from './utils.js'
export class BaseAutocomplete { export class BaseAutocomplete {
constructor(parent, options) { constructor(parent, options) {
@ -291,10 +292,9 @@ class BaseServerAjax extends BaseAjax {
export const SingleMixin = (Base) => export const SingleMixin = (Base) =>
class extends Base { class extends Base {
initSelectedContainer() { initSelectedContainer() {
return DomUtil.after( const el = Utils.loadTemplate('<div class="umap-singleresult"></div>')
this.input, this.input.parentNode.insertBefore(el, this.input.nextSibling)
DomUtil.element({ tagName: 'div', className: 'umap-singleresult' }) return el
)
} }
displaySelected(result) { displaySelected(result) {
@ -322,10 +322,9 @@ export const SingleMixin = (Base) =>
export const MultipleMixin = (Base) => export const MultipleMixin = (Base) =>
class extends Base { class extends Base {
initSelectedContainer() { initSelectedContainer() {
return DomUtil.after( const el = Utils.loadTemplate('<ul class="umap-multiresult"></ul>')
this.input, this.input.parentNode.insertBefore(el, this.input.nextSibling)
DomUtil.element({ tagName: 'ul', className: 'umap-multiresult' }) return el
)
} }
displaySelected(result) { displaySelected(result) {

View file

@ -5,6 +5,7 @@ import { translate } from './i18n.js'
import * as Icon from './rendering/icon.js' import * as Icon from './rendering/icon.js'
import ContextMenu from './ui/contextmenu.js' import ContextMenu from './ui/contextmenu.js'
import * as Utils from './utils.js' import * as Utils from './utils.js'
import { SCHEMA } from './schema.js'
export default class Browser { export default class Browser {
constructor(umap, leafletMap) { constructor(umap, leafletMap) {
@ -21,35 +22,24 @@ export default class Browser {
addFeature(feature, parent) { addFeature(feature, parent) {
if (feature.isFiltered()) return if (feature.isFiltered()) return
if (this.options.inBbox && !feature.isOnScreen(this.bounds)) return if (this.options.inBbox && !feature.isOnScreen(this.bounds)) return
const row = DomUtil.create('li', `${feature.getClassName()} feature`) const template = `
const zoom_to = DomUtil.createButtonIcon( <li class="feature ${feature.getClassName()}">
row, <button class="icon icon-16 icon-zoom" title="${translate('Bring feature to center')}" data-ref=zoom></button>
'icon-zoom', <button class="icon icon-16 show-on-edit icon-edit" title="${translate('Edit this feature')}" data-ref=edit></button>
translate('Bring feature to center') <button class="icon icon-16 show-on-edit icon-delete" title="${translate('Delete this feature')}" data-ref=remove></button>
) <i class="icon icon-16 icon-${feature.getClassName()} feature-color" data-ref=colorBox></i>
const edit = DomUtil.createButtonIcon( <span class="feature-title" data-ref=label></span>
row, </li>
'show-on-edit icon-edit', `
translate('Edit this feature') const [row, { zoom, edit, remove, colorBox, label }] =
) Utils.loadTemplateWithRefs(template)
const del = DomUtil.createButtonIcon( label.textContent = label.title = feature.getDisplayName() || '—'
row,
'show-on-edit icon-delete',
translate('Delete this feature')
)
const colorBox = DomUtil.create(
'i',
`icon icon-16 icon-${feature.getClassName()} feature-color`,
row
)
const title = DomUtil.create('span', 'feature-title', row)
const symbol = feature._getIconUrl const symbol = feature._getIconUrl
? Icon.formatUrl(feature._getIconUrl(), feature) ? Icon.formatUrl(feature._getIconUrl(), feature)
: null : null
title.textContent = title.title = feature.getDisplayName() || '—'
const bgcolor = feature.getPreviewColor() const bgcolor = feature.getPreviewColor()
colorBox.style.backgroundColor = bgcolor colorBox.style.backgroundColor = bgcolor
if (symbol && symbol !== U.SCHEMA.iconUrl.default) { if (symbol && symbol !== SCHEMA.iconUrl.default) {
const icon = Icon.makeElement(symbol, colorBox) const icon = Icon.makeElement(symbol, colorBox)
Icon.setContrast(icon, colorBox, symbol, bgcolor) Icon.setContrast(icon, colorBox, symbol, bgcolor)
} else if (DomUtil.contrastedColor(colorBox, bgcolor)) { } else if (DomUtil.contrastedColor(colorBox, bgcolor)) {
@ -58,10 +48,10 @@ export default class Browser {
const viewFeature = (e) => { const viewFeature = (e) => {
feature.zoomTo({ ...e, callback: () => feature.view() }) feature.zoomTo({ ...e, callback: () => feature.view() })
} }
DomEvent.on(zoom_to, 'click', viewFeature) zoom.addEventListener('click', viewFeature)
DomEvent.on(title, 'click', viewFeature) label.addEventListener('click', viewFeature)
DomEvent.on(edit, 'click', feature.edit, feature) edit.addEventListener('click', () => feature.edit())
DomEvent.on(del, 'click', feature.del, feature) remove.addEventListener('click', () => feature.del())
// HOTFIX. Remove when this is released: // HOTFIX. Remove when this is released:
// https://github.com/Leaflet/Leaflet/pull/9052 // https://github.com/Leaflet/Leaflet/pull/9052
DomEvent.disableClickPropagation(row) DomEvent.disableClickPropagation(row)
@ -75,45 +65,51 @@ export default class Browser {
addDataLayer(datalayer, parent) { addDataLayer(datalayer, parent) {
let className = `datalayer ${datalayer.getHidableClass()}` let className = `datalayer ${datalayer.getHidableClass()}`
if (this.mode !== 'layers') className += ' show-list' if (this.mode !== 'layers') className += ' show-list'
const container = DomUtil.create('div', className, parent) const [container, { headline, toolbox, toggle, label }] =
const headline = DomUtil.create('h5', '', container) Utils.loadTemplateWithRefs(`
container.id = this.datalayerId(datalayer) <div class="${className}" id="${this.datalayerId(datalayer)}">
const ul = DomUtil.create('ul', '', container) <h5 data-ref=headline>
<i class="icon icon-16 datalayer-toggle-list" data-ref=toggle></i>
<span data-ref=toolbox></span>
<span class="datalayer-name" data-id="${datalayer.id}" data-ref=label></span>
<span class="datalayer-counter"></span>
</h5>
<ul></ul>
</div>
`)
datalayer.renderToolbox(toolbox)
parent.appendChild(container)
const toggleList = () => parent.classList.toggle('show-list')
toggle.addEventListener('click', toggleList)
label.addEventListener('click', toggleList)
this.updateDatalayer(datalayer) this.updateDatalayer(datalayer)
} }
updateDatalayer(datalayer) { updateDatalayer(datalayer) {
// Compute once, but use it for each feature later. // Compute once, but use it for each feature later.
this.bounds = this._leafletMap.getBounds() this.bounds = this._leafletMap.getBounds()
const parent = DomUtil.get(this.datalayerId(datalayer)) const id = this.datalayerId(datalayer)
const parent = document.getElementById(id)
// Panel is not open // Panel is not open
if (!parent) return if (!parent) return
parent.classList.toggle('off', !datalayer.isVisible()) parent.classList.toggle('off', !datalayer.isVisible())
const label = parent.querySelector('.datalayer-name')
const container = parent.querySelector('ul') const container = parent.querySelector('ul')
const headline = parent.querySelector('h5')
const toggleList = () => parent.classList.toggle('show-list')
headline.innerHTML = ''
const toggle = DomUtil.create('i', 'icon icon-16 datalayer-toggle-list', headline)
DomEvent.on(toggle, 'click', toggleList)
datalayer.renderToolbox(headline)
const name = DomUtil.create('span', 'datalayer-name', headline)
name.textContent = name.title = datalayer.options.name
DomEvent.on(name, 'click', toggleList)
container.innerHTML = '' container.innerHTML = ''
datalayer.eachFeature((feature) => this.addFeature(feature, container)) datalayer.eachFeature((feature) => this.addFeature(feature, container))
datalayer.propagate(['properties.name'])
const total = datalayer.count() const total = datalayer.count()
if (!total) return if (!total) return
const current = container.querySelectorAll('li').length const current = container.querySelectorAll('li').length
const count = total === current ? total : `${current}/${total}` const count = total === current ? total : `${current}/${total}`
const counter = DomUtil.create('span', 'datalayer-counter', headline) const counter = parent.querySelector('.datalayer-counter')
counter.textContent = `(${count})` counter.textContent = `(${count})`
counter.title = translate(`Features in this layer: ${count}`) counter.title = translate(`Features in this layer: ${count}`)
} }
toggleBadge() { toggleBadge() {
U.Utils.toggleBadge(this.filtersTitle, this.hasFilters()) Utils.toggleBadge(this.filtersTitle, this.hasFilters())
U.Utils.toggleBadge('.umap-control-browse', this.hasFilters()) Utils.toggleBadge('.umap-control-browse', this.hasFilters())
} }
onFormChange() { onFormChange() {
@ -157,21 +153,51 @@ export default class Browser {
open(mode) { open(mode) {
// Force only if mode is known, otherwise keep current mode. // Force only if mode is known, otherwise keep current mode.
if (mode) this.mode = mode if (mode) this.mode = mode
const container = DomUtil.create('div') const template = `
<div>
<h3><i class="icon icon-16 icon-layers"></i>${translate('Data browser')}</h3>
<details class="filters" data-ref="details">
<summary data-ref=filtersTitle><i class="icon icon-16 icon-filters"></i>${translate('Filters')}</summary>
<fieldset>
<div data-ref=formContainer>
</div>
<button class="flat" type="button" data-ref=reset><i class="icon icon-16 icon-restore" title=""></i>${translate('Reset all')}</button>
</fieldset>
</details>
<div class="main-toolbox">
<i class="icon icon-16 icon-eye" title="${translate('show/hide all layers')}" data-ref="toggle"></i>
<i class="icon icon-16 icon-zoom" title="${translate('zoom to data extent')}" data-ref="fitBounds"></i>
<i class="icon icon-16 icon-download" title="${translate('download visible data')}" data-ref="download"></i>
</div>
<div data-ref=dataContainer></div>
</div>
`
const [
container,
{
details,
filtersTitle,
toggle,
fitBounds,
download,
dataContainer,
formContainer,
reset,
},
] = Utils.loadTemplateWithRefs(template)
// HOTFIX. Remove when this is released: // HOTFIX. Remove when this is released:
// https://github.com/Leaflet/Leaflet/pull/9052 // https://github.com/Leaflet/Leaflet/pull/9052
DomEvent.disableClickPropagation(container) DomEvent.disableClickPropagation(container)
details.open = this.mode === 'filters'
toggle.addEventListener('click', () => this.toggleLayers())
fitBounds.addEventListener('click', () => this._umap.fitDataBounds())
download.addEventListener('click', () => this.downloadVisible(download))
download.hidden = this._umap.getProperty('embedControl') === false
DomUtil.createTitle(container, translate('Data browser'), 'icon-layers') this.filtersTitle = filtersTitle
this.formContainer = DomUtil.createFieldset(container, L._('Filters'), { this.dataContainer = dataContainer
on: this.mode === 'filters', this.formContainer = formContainer
className: 'filters',
icon: 'icon-filters',
})
this.filtersTitle = container.querySelector('summary')
this.toggleBadge() this.toggleBadge()
this.addMainToolbox(container)
this.dataContainer = DomUtil.create('div', '', container)
let fields = [ let fields = [
[ [
@ -184,27 +210,19 @@ export default class Browser {
builder.on('set', () => this.onFormChange()) builder.on('set', () => this.onFormChange())
let filtersBuilder let filtersBuilder
this.formContainer.appendChild(builder.build()) this.formContainer.appendChild(builder.build())
DomEvent.on(builder.form, 'reset', () => { builder.form.addEventListener('reset', () => {
window.setTimeout(builder.syncAll.bind(builder)) window.setTimeout(builder.syncAll.bind(builder))
}) })
if (this._umap.properties.facetKey) { if (this._umap.properties.facetKey) {
fields = this._umap.facets.build() fields = this._umap.facets.build()
filtersBuilder = new Form(this._umap.facets, fields) filtersBuilder = new Form(this._umap.facets, fields)
filtersBuilder.on('set', () => this.onFormChange()) filtersBuilder.on('set', () => this.onFormChange())
DomEvent.on(filtersBuilder.form, 'reset', () => { filtersBuilder.form.addEventListener('reset', () => {
window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder)) window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder))
}) })
this.formContainer.appendChild(filtersBuilder.build()) this.formContainer.appendChild(filtersBuilder.build())
} }
const reset = DomUtil.createButton('flat', this.formContainer, '', () => reset.addEventListener('click', () => this.resetFilters())
this.resetFilters()
)
DomUtil.createIcon(reset, 'icon-restore')
DomUtil.element({
tagName: 'span',
parent: reset,
textContent: translate('Reset all'),
})
this._umap.panel.open({ this._umap.panel.open({
content: container, content: container,
@ -220,21 +238,6 @@ export default class Browser {
} }
} }
addMainToolbox(container) {
const [toolbox, { toggle, fitBounds, download }] = Utils.loadTemplateWithRefs(`
<div class="main-toolbox">
<i class="icon icon-16 icon-eye" title="${translate('show/hide all layers')}" data-ref="toggle"></i>
<i class="icon icon-16 icon-zoom" title="${translate('zoom to data extent')}" data-ref="fitBounds"></i>
<i class="icon icon-16 icon-download" title="${translate('download visible data')}" data-ref="download"></i>
</div>
`)
container.appendChild(toolbox)
toggle.addEventListener('click', () => this.toggleLayers())
fitBounds.addEventListener('click', () => this._umap.fitDataBounds())
download.addEventListener('click', () => this.downloadVisible(download))
download.hidden = this._umap.getProperty('embedControl') === false
}
downloadVisible(element) { downloadVisible(element) {
const menu = new ContextMenu({ fixed: true }) const menu = new ContextMenu({ fixed: true })
const items = [] const items = []
@ -265,15 +268,13 @@ export default class Browser {
} }
static backButton(umap) { static backButton(umap) {
const button = DomUtil.createButtonIcon( const button = Utils.loadTemplate(
DomUtil.create('li', '', undefined), `<button class="icon icon-16 icon-back" title="${translate('Back to browser')}"></button>`
'icon-back',
translate('Back to browser')
) )
// Fixme: remove me when this is merged and released // Fixme: remove me when this is merged and released
// https://github.com/Leaflet/Leaflet/pull/9052 // https://github.com/Leaflet/Leaflet/pull/9052
DomEvent.disableClickPropagation(button) DomEvent.disableClickPropagation(button)
DomEvent.on(button, 'click', () => umap.openBrowser()) button.addEventListener('click', () => umap.openBrowser())
return button return button
} }
} }

View file

@ -130,6 +130,10 @@ export class DataLayer {
} }
render(fields, builder) { render(fields, builder) {
// Propagate will remove the fields it has already
// processed
fields = this.propagate(fields)
const impacts = Utils.getImpactsFromSchema(fields) const impacts = Utils.getImpactsFromSchema(fields)
for (const impact of impacts) { for (const impact of impacts) {
@ -153,6 +157,29 @@ export class DataLayer {
} }
} }
// 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('.datalayer-name', (el) => {
if (el.dataset.id === this.id) {
el.textContent = this.getName()
el.title = this.getName()
}
})
},
}
for (const [field, impact] of Object.entries(impacts)) {
if (!fields.length || fields.includes(field)) {
impact()
fields = fields.filter((item) => item !== field)
}
}
return fields
}
showAtLoad() { showAtLoad() {
return this.autoLoaded() && this.showAtZoom() return this.autoLoaded() && this.showAtZoom()
} }
@ -492,8 +519,19 @@ export class DataLayer {
const features = [] const features = []
this.sortFeatures(collection) this.sortFeatures(collection)
for (const featureJson of collection) { for (const featureJson of collection) {
const feature = this.makeFeature(featureJson, sync) if (featureJson.geometry?.type === 'GeometryCollection') {
if (feature) features.push(feature) for (const geometry of featureJson.geometry.geometries) {
const feature = this.makeFeature({
type: 'Feature',
geometry,
properties: featureJson.properties,
})
if (feature) features.push(feature)
}
} else {
const feature = this.makeFeature(featureJson, sync)
if (feature) features.push(feature)
}
} }
return features return features
} }
@ -645,7 +683,7 @@ export class DataLayer {
} }
edit() { edit() {
if (!this._umap.editEnabled || !this.isLoaded()) { if (!this._umap.editEnabled) {
return return
} }
const container = DomUtil.create('div', 'umap-layer-properties-container') const container = DomUtil.create('div', 'umap-layer-properties-container')
@ -1105,7 +1143,7 @@ export class DataLayer {
async save() { async save() {
if (this.isDeleted) return await this.saveDelete() if (this.isDeleted) return await this.saveDelete()
if (!this.isLoaded()) return if (!this.isRemoteLayer() && !this.isLoaded()) return
const geojson = this.umapGeoJSON() const geojson = this.umapGeoJSON()
const formData = new FormData() const formData = new FormData()
formData.append('name', this.options.name) formData.append('name', this.options.name)
@ -1146,6 +1184,9 @@ export class DataLayer {
await this._umap.saveAll() await this._umap.saveAll()
} }
) )
} else {
console.debug(error)
Alert.error(translate('Cannot save layer, please try again in a few minutes.'))
} }
} else { } else {
// Response contains geojson only if save has conflicted and conflicts have // Response contains geojson only if save has conflicted and conflicts have

View file

@ -725,7 +725,7 @@ Fields.IconUrl = class extends Fields.BlurInput {
<button class="flat tab-url" data-ref=url>${translate('URL')}</button> <button class="flat tab-url" data-ref=url>${translate('URL')}</button>
</div> </div>
`) `)
this.tabs.appendChild(root) ;[recent, symbols, chars, url].forEach((node) => this.tabs.appendChild(node))
if (Icon.RECENT.length) { if (Icon.RECENT.length) {
recent.addEventListener('click', (event) => { recent.addEventListener('click', (event) => {
event.stopPropagation() event.stopPropagation()

View file

@ -97,6 +97,9 @@ export default class Importer extends Utils.WithTemplate {
case 'banfr': case 'banfr':
import('./importers/banfr.js').then(register) import('./importers/banfr.js').then(register)
break break
case 'templates':
import('./importers/templates.js').then(register)
break
} }
} }
} }

View file

@ -0,0 +1,95 @@
import { DomEvent, DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
import { uMapAlert as Alert } from '../../components/alerts/alert.js'
import { BaseAjax, SingleMixin } from '../autocomplete.js'
import { translate } from '../i18n.js'
import * as Utils from '../utils.js'
const TEMPLATE = `
<div>
<h3>${translate('Load map template')}</h3>
<p>${translate('Use a template to initialize your map')}.</p>
<div class="formbox">
<div class="flat-tabs" data-ref="tabs">
<button type="button" class="flat" data-value="mine" data-ref="mine">${translate('My templates')}</button>
<button type="button" class="flat" data-value="staff">${translate('Staff templates')}</button>
<button type="button" class="flat" data-value="community">${translate('Community templates')}</button>
</div>
<div data-ref="body" class="body"></div>
<label>
<input type="checkbox" name="include_data" />
${translate('Include template data, if any')}
</label>
</div>
</div>
`
export class Importer {
constructor(umap, options = {}) {
this.umap = umap
this.name = options.name || 'Templates'
this.id = 'templates'
}
async open(importer) {
const [root, { tabs, include_data, body, mine }] =
Utils.loadTemplateWithRefs(TEMPLATE)
const uri = this.umap.urls.get('template_list')
const userIsAuth = Boolean(this.umap.properties.user?.id)
const defaultTab = userIsAuth ? 'mine' : 'staff'
mine.hidden = !userIsAuth
const loadTemplates = async (source) => {
const [data, response, error] = await this.umap.server.get(
`${uri}?source=${source}`
)
if (!error) {
body.innerHTML = ''
for (const template of data.templates) {
const item = Utils.loadTemplate(
`<dl>
<dt><label><input type="radio" value="${template.id}" name="template" />${template.name}</label></dt>
<dd>${template.description}&nbsp;<a href="${template.url}" target="_blank">${translate('Open')}</a></dd>
</dl>`
)
body.appendChild(item)
}
tabs.querySelectorAll('button').forEach((el) => el.classList.remove('on'))
tabs.querySelector(`[data-value="${source}"]`).classList.add('on')
} else {
console.error(response)
}
}
loadTemplates(defaultTab)
tabs
.querySelectorAll('button')
.forEach((el) =>
el.addEventListener('click', () => loadTemplates(el.dataset.value))
)
const confirm = (form) => {
console.log(form)
if (!form.template) {
Alert.error(translate('You must select a template.'))
return false
}
let url = this.umap.urls.get('map_download', {
map_id: form.template,
})
if (!form.include_data) {
url = `${url}?include_data=0`
}
importer.url = url
importer.format = 'umap'
importer.submit()
this.umap.editPanel.close()
}
importer.dialog
.open({
template: root,
className: `${this.id} importer dark`,
accept: translate('Use this template'),
cancel: false,
})
.then(confirm)
}
}

View file

@ -0,0 +1,251 @@
import { Control } from '../../../vendors/leaflet/leaflet-src.esm.js'
import * as Utils from '../utils.js'
import { translate } from '../i18n.js'
export const HomeControl = Control.extend({
options: {
position: 'topleft',
},
onAdd: (map) => {
const path = map._umap.getStaticPathFor('home.svg')
const container = Utils.loadTemplate(
`<a href="/" class="home-button" title="${translate('Back to home')}"><img src="${path}" alt="${translate('Home logo')}" width="38px" height="38px" /></a>`
)
return container
},
})
export const EditControl = Control.extend({
options: {
position: 'topright',
},
onAdd: (map) => {
const template = `
<div class="edit-enable">
<button type="button" data-ref="button">${translate('Edit')}</button>
</div>
`
const [container, { button }] = Utils.loadTemplateWithRefs(template)
button.addEventListener('click', () => map._umap.enableEdit())
button.addEventListener('mouseover', () => {
map._umap.tooltip.open({
content: map._umap.help.displayLabel('TOGGLE_EDIT'),
anchor: button,
position: 'bottom',
delay: 750,
duration: 5000,
})
})
return container
},
})
export const MoreControl = Control.extend({
options: {
position: 'topleft',
},
onAdd: function (map) {
const pos = this.getPosition()
const corner = map._controlCorners[pos]
const className = 'umap-more-controls'
const template = `
<div class="umap-control-text">
<button class="umap-control-more" type="button" data-ref="button"></button>
</div>
`
const [container, { button }] = Utils.loadTemplateWithRefs(template)
button.addEventListener('click', () => corner.classList.toggle(className))
button.addEventListener('mouseover', () => {
const extended = corner.classList.contains(className)
map._umap.tooltip.open({
content: extended ? translate('Hide controls') : translate('More controls'),
anchor: button,
position: 'right',
delay: 750,
})
})
return container
},
})
export const PermanentCreditsControl = Control.extend({
options: {
position: 'bottomleft',
},
onAdd: (map) => {
const container = Utils.loadTemplate(
`<div class="umap-permanent-credits-container text">${Utils.toHTML(map.options.permanentCredit)}</div>`
)
const background = map.options.permanentCreditBackground ? '#FFFFFFB0' : ''
container.style.backgroundColor = background
return container
},
})
const BaseButton = Control.extend({
initialize: function (umap, options) {
this._umap = umap
Control.prototype.initialize.call(this, options)
},
onAdd: function (map) {
const template = `
<div class="${this.options.className} umap-control">
<button type="button" title="${this.options.title}" data-ref="button"></button>
</div>
`
const [container, { button }] = Utils.loadTemplateWithRefs(template)
button.addEventListener('click', (event) => {
event.stopPropagation()
this.onClick()
})
button.addEventListener('dblclick', (event) => {
event.stopPropagation()
})
this.afterAdd(container)
return container
},
afterAdd: (container) => {},
})
export const DataLayersControl = BaseButton.extend({
options: {
position: 'topleft',
className: 'umap-control-browse',
title: translate('Open browser'),
},
afterAdd: function (container) {
Utils.toggleBadge(container, this._umap.browser?.hasFilters())
},
onClick: function () {
this._umap.openBrowser()
},
})
export const CaptionControl = BaseButton.extend({
options: {
position: 'topleft',
className: 'umap-control-caption',
title: translate('About'),
},
onClick: function () {
this._umap.openCaption()
},
})
export const EmbedControl = BaseButton.extend({
options: {
position: 'topleft',
title: translate('Share and download'),
className: 'leaflet-control-embed',
},
onClick: function () {
this._umap.share.open()
},
})
export const AttributionControl = Control.Attribution.extend({
options: {
prefix: '',
},
_update: function () {
// Layer is no more on the map
if (!this._map) return
Control.Attribution.prototype._update.call(this)
const shortCredit = this._map._umap.getProperty('shortCredit')
const captionMenus = this._map._umap.getProperty('captionMenus')
// Use our own container, so we can hide/show on small screens
const originalCredits = this._container.innerHTML
this._container.innerHTML = ''
const template = `
<div class="attribution-container">
${originalCredits}
<span data-ref="short"> ${Utils.toHTML(shortCredit)}</span>
<a href="#" data-ref="caption"> ${translate('Open caption')}</a>
<a href="/" data-ref="home"> ${translate('Home')}</a>
<a href="https://umap-project.org/" data-ref="site"> ${translate('Powered by uMap')}</a>
<a href="#" class="attribution-toggle"></a>
</div>
`
const [container, { short, caption, home, site }] =
Utils.loadTemplateWithRefs(template)
caption.addEventListener('click', () => this._map._umap.openCaption())
this._container.appendChild(container)
short.hidden = !shortCredit
caption.hidden = !captionMenus
site.hidden = !captionMenus
home.hidden = this._map._umap.isEmbed || !captionMenus
},
})
/* Used in edit mode to define the default tilelayer */
export const TileLayerChooser = BaseButton.extend({
options: {
position: 'topleft',
},
onClick: function () {
this.openSwitcher({ edit: true })
},
openSwitcher: function (options = {}) {
const template = `
<div class="umap-edit-tilelayers">
<h3><i class="icon icon-16 icon-tilelayer" title=""></i><span class="">${translate('Change tilelayers')}</span></h3>
<ul data-ref="tileContainer"></ul>
</div>
`
const [container, { tileContainer }] = Utils.loadTemplateWithRefs(template)
this.buildList(tileContainer, options)
const panel = options.edit ? this._umap.editPanel : this._umap.panel
panel.open({ content: container, highlight: 'tilelayers' })
},
buildList: function (container, options) {
this._umap._leafletMap.eachTileLayer((tilelayer) => {
const browserIsHttps = window.location.protocol === 'https:'
const tileLayerIsHttp = tilelayer.options.url_template.indexOf('http:') === 0
if (browserIsHttps && tileLayerIsHttp) return
container.appendChild(this.addTileLayerElement(tilelayer, options))
})
},
addTileLayerElement: function (tilelayer, options) {
const selectedClass = this._umap._leafletMap.hasLayer(tilelayer) ? 'selected' : ''
const src = Utils.template(
tilelayer.options.url_template,
this._umap._leafletMap.options.demoTileInfos
)
const template = `
<li>
<img src="${src}" loading="lazy" />
<div>${tilelayer.options.name}</div>
</li>
`
const li = Utils.loadTemplate(template)
li.addEventListener('click', () => {
const oldTileLayer = this._umap.properties.tilelayer
this._umap._leafletMap.selectTileLayer(tilelayer)
this._umap._leafletMap._controls.tilelayers.setLayers()
if (options?.edit) {
this._umap.properties.tilelayer = tilelayer.toJSON()
this._umap.sync.update(
'properties.tilelayer',
this._umap.properties.tilelayer,
oldTileLayer
)
}
})
return li
},
})

View file

@ -11,6 +11,17 @@ import {
import { uMapAlert as Alert } from '../../components/alerts/alert.js' import { uMapAlert as Alert } from '../../components/alerts/alert.js'
import DropControl from '../drop.js' import DropControl from '../drop.js'
import { translate } from '../i18n.js' import { translate } from '../i18n.js'
import {
AttributionControl,
CaptionControl,
DataLayersControl,
EmbedControl,
EditControl,
HomeControl,
MoreControl,
PermanentCreditsControl,
TileLayerChooser,
} from './controls.js'
import * as Utils from '../utils.js' import * as Utils from '../utils.js'
import * as Icon from './icon.js' import * as Icon from './icon.js'
@ -40,15 +51,15 @@ const ControlsMixin = {
this._controls = {} this._controls = {}
if (this._umap.hasEditMode() && !this.options.noControl) { if (this._umap.hasEditMode() && !this.options.noControl) {
new U.EditControl(this).addTo(this) new EditControl(this).addTo(this)
} }
this._controls.home = new U.HomeControl(this._umap) this._controls.home = new HomeControl(this._umap)
this._controls.zoom = new Control.Zoom({ this._controls.zoom = new Control.Zoom({
zoomInTitle: translate('Zoom in'), zoomInTitle: translate('Zoom in'),
zoomOutTitle: translate('Zoom out'), zoomOutTitle: translate('Zoom out'),
}) })
this._controls.datalayers = new U.DataLayersControl(this._umap) this._controls.datalayers = new DataLayersControl(this._umap)
this._controls.caption = new U.CaptionControl(this._umap) this._controls.caption = new CaptionControl(this._umap)
this._controls.locate = new U.Locate(this, { this._controls.locate = new U.Locate(this, {
strings: { strings: {
title: translate('Center map on your location'), title: translate('Center map on your location'),
@ -69,8 +80,8 @@ const ControlsMixin = {
}, },
}) })
this._controls.search = new U.SearchControl() this._controls.search = new U.SearchControl()
this._controls.embed = new Control.Embed(this._umap) this._controls.embed = new EmbedControl(this._umap)
this._controls.tilelayersChooser = new U.TileLayerChooser(this) this._controls.tilelayersChooser = new TileLayerChooser(this._umap)
this._controls.editinosm = new Control.EditInOSM({ this._controls.editinosm = new Control.EditInOSM({
position: 'topleft', position: 'topleft',
widgetOptions: { widgetOptions: {
@ -80,9 +91,9 @@ const ControlsMixin = {
}, },
}) })
this._controls.measure = new L.MeasureControl().initHandler(this) this._controls.measure = new L.MeasureControl().initHandler(this)
this._controls.more = new U.MoreControls() this._controls.more = new MoreControl()
this._controls.scale = L.control.scale() this._controls.scale = L.control.scale()
this._controls.permanentCredit = new U.PermanentCreditsControl(this) this._controls.permanentCredit = new PermanentCreditsControl(this)
this._umap.drop = new DropControl(this._umap, this, this._container) this._umap.drop = new DropControl(this._umap, this, this._container)
this._controls.tilelayers = new U.TileLayerControl(this) this._controls.tilelayers = new U.TileLayerControl(this)
}, },
@ -93,7 +104,7 @@ const ControlsMixin = {
} }
if (this.options.noControl) return if (this.options.noControl) return
this._controls.attribution = new U.AttributionControl().addTo(this) this._controls.attribution = new AttributionControl().addTo(this)
if (this.options.miniMap) { if (this.options.miniMap) {
this.whenReady(function () { this.whenReady(function () {
if (this.selectedTilelayer) { if (this.selectedTilelayer) {

View file

@ -1,4 +1,4 @@
import { DomEvent, DomUtil, stamp } from '../../vendors/leaflet/leaflet-src.esm.js' import { stamp } from '../../vendors/leaflet/leaflet-src.esm.js'
import { AutocompleteDatalist } from './autocomplete.js' import { AutocompleteDatalist } from './autocomplete.js'
import { MutatingForm } from './form/builder.js' import { MutatingForm } from './form/builder.js'
import { translate } from './i18n.js' import { translate } from './i18n.js'
@ -119,10 +119,9 @@ class Rule {
'options.smoothFactor', 'options.smoothFactor',
'options.dashArray', 'options.dashArray',
] ]
const container = DomUtil.create('div')
const builder = new MutatingForm(this, options) const builder = new MutatingForm(this, options)
const defaultShapeProperties = DomUtil.add('div', '', container) const container = document.createElement('div')
defaultShapeProperties.appendChild(builder.build()) container.appendChild(builder.build())
const autocomplete = new AutocompleteDatalist(builder.helpers.condition.input) const autocomplete = new AutocompleteDatalist(builder.helpers.condition.input)
const properties = this._umap.allProperties() const properties = this._umap.allProperties()
autocomplete.suggestions = properties autocomplete.suggestions = properties
@ -137,43 +136,45 @@ class Rule {
.map((str) => `${value}${str || ''}`) .map((str) => `${value}${str || ''}`)
} }
}) })
this._umap.editPanel.open({ content: container, highlight: 'settings' }) const backButton = Utils.loadTemplate(`
<button class="flat" type="button" data-ref="add">
<i class="icon icon-16 icon-back" title="${translate('Back to list')}"></i>
</button>`)
backButton.addEventListener('click', () =>
this._umap.edit().then(() => {
this._umap.editPanel.container.querySelector('details#rules').open = true
})
)
this._umap.editPanel.open({
content: container,
highlight: 'settings',
actions: [backButton],
})
} }
renderToolbox(row) { renderToolbox(ul) {
row.classList.toggle('off', !this.active) const template = `
const toggle = DomUtil.createButtonIcon( <li data-id="${stamp(this)}" class="orderable">
row, <button class="icon icon-16 icon-eye" title="${translate('Toggle rule')}" data-ref=toggle></button>
'icon-eye', <button class="icon icon-16 icon-edit show-on-edit" title="${translate('Edit')}" data-ref=edit></button>
translate('Show/hide layer') <button class="icon icon-16 icon-delete show-on-edit" title="${translate('Delete rule')}" data-ref=remove></button>
) <span>${this.condition || translate('empty rule')}</span>
const edit = DomUtil.createButtonIcon( <i class="icon icon-16 icon-drag" title="${translate('Drag to reorder')}"></i>
row, </li>
'icon-edit show-on-edit', `
translate('Edit') const [li, { toggle, edit, remove }] = Utils.loadTemplateWithRefs(template)
) ul.appendChild(li)
const remove = DomUtil.createButtonIcon( li.classList.toggle('off', !this.active)
row, edit.addEventListener('click', () => this.edit())
'icon-delete show-on-edit', remove.addEventListener('click', () => {
translate('Delete layer') if (!confirm(translate('Are you sure you want to delete this rule?'))) return
) this._delete()
DomEvent.on(edit, 'click', this.edit, this) this._umap.editPanel.close()
DomEvent.on( })
remove, toggle.addEventListener('click', () => {
'click',
function () {
if (!confirm(translate('Are you sure you want to delete this rule?'))) return
this._delete()
this._umap.editPanel.close()
},
this
)
DomUtil.add('span', '', row, this.condition || translate('empty rule'))
DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder'))
row.dataset.id = stamp(this)
DomEvent.on(toggle, 'click', () => {
this.active = !this.active this.active = !this.active
row.classList.toggle('off', !this.active) li.classList.toggle('off', !this.active)
this._umap.render(['rules']) this._umap.render(['rules'])
}) })
} }
@ -207,8 +208,9 @@ export default class Rules {
} }
onReorder(src, dst, initialIndex, finalIndex) { onReorder(src, dst, initialIndex, finalIndex) {
const moved = this.rules.find((rule) => stamp(rule) === src.dataset.id) const oldRules = Utils.CopyJSON(this._umap.properties.rules || {})
const reference = this.rules.find((rule) => stamp(rule) === dst.dataset.id) const moved = this.rules.find((rule) => stamp(rule) === +src.dataset.id)
const reference = this.rules.find((rule) => stamp(rule) === +dst.dataset.id)
const movedIdx = this.rules.indexOf(moved) const movedIdx = this.rules.indexOf(moved)
let referenceIdx = this.rules.indexOf(reference) let referenceIdx = this.rules.indexOf(reference)
const minIndex = Math.min(movedIdx, referenceIdx) const minIndex = Math.min(movedIdx, referenceIdx)
@ -222,20 +224,28 @@ export default class Rules {
this.rules.splice(newIdx, 0, moved) this.rules.splice(newIdx, 0, moved)
this._umap.render(['rules']) this._umap.render(['rules'])
this.commit() this.commit()
this._umap.sync.update('properties.rules', this._umap.properties.rules, oldRules)
} }
edit(container) { edit(container) {
const body = DomUtil.createFieldset(container, translate('Conditional style rules')) const template = `
<details id="rules">
<summary>${translate('Conditional style rules')}</summary>
<fieldset>
<ul data-ref=ul></ul>
<button class="umap-add" type="button" data-ref=add>${translate('Add rule')}</button>
</fieldset>
</details>
`
const [body, { ul, add }] = Utils.loadTemplateWithRefs(template)
if (this.rules.length) { if (this.rules.length) {
const ul = DomUtil.create('ul', '', body)
for (const rule of this.rules) { for (const rule of this.rules) {
rule.renderToolbox(DomUtil.create('li', 'orderable', ul)) rule.renderToolbox(ul)
} }
const orderable = new Orderable(ul, this.onReorder.bind(this)) const orderable = new Orderable(ul, this.onReorder.bind(this))
} }
add.addEventListener('click', () => this.addRule())
DomUtil.createButton('umap-add', body, translate('Add rule'), this.addRule, this) container.appendChild(body)
} }
addRule() { addRule() {

View file

@ -253,6 +253,12 @@ export const SCHEMA = {
inheritable: true, inheritable: true,
default: true, default: true,
}, },
is_template: {
type: Boolean,
impacts: ['ui'],
label: translate('This map is a template'),
default: false,
},
labelDirection: { labelDirection: {
type: String, type: String,
impacts: ['data'], impacts: ['data'],

View file

@ -225,13 +225,14 @@ export class SyncEngine {
} }
for (const [obj, operations] of needSave.entries()) { for (const [obj, operations] of needSave.entries()) {
const ok = await obj.save() const ok = await obj.save()
if (!ok) break if (!ok) return false
for (const operation of operations) { for (const operation of operations) {
operation.dirty = false operation.dirty = false
} }
} }
this.saved() this.saved()
this._undoManager.toggleState() this._undoManager.toggleState()
return true
} }
saved() { saved() {

View file

@ -37,6 +37,7 @@ const TOP_BAR_TEMPLATE = `
<i class="icon icon-16 icon-save-disabled"></i> <i class="icon icon-16 icon-save-disabled"></i>
<span hidden data-ref="saveLabel">${translate('Save')}</span> <span hidden data-ref="saveLabel">${translate('Save')}</span>
<span hidden data-ref="saveDraftLabel">${translate('Save draft')}</span> <span hidden data-ref="saveDraftLabel">${translate('Save draft')}</span>
<span hidden data-ref="saveTemplateLabel">${translate('Save template')}</span>
</button> </button>
</div> </div>
</div>` </div>`
@ -167,8 +168,11 @@ export class TopBar extends WithTemplate {
const syncEnabled = this._umap.getProperty('syncEnabled') const syncEnabled = this._umap.getProperty('syncEnabled')
this.elements.peers.hidden = !syncEnabled this.elements.peers.hidden = !syncEnabled
this.elements.view.disabled = this._umap.sync._undoManager.isDirty() this.elements.view.disabled = this._umap.sync._undoManager.isDirty()
this.elements.saveLabel.hidden = this._umap.permissions.isDraft() const isDraft = this._umap.permissions.isDraft()
this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft() const isTemplate = this._umap.getProperty('is_template')
this.elements.saveLabel.hidden = isDraft || isTemplate
this.elements.saveDraftLabel.hidden = !isDraft || isTemplate
this.elements.saveTemplateLabel.hidden = !isTemplate
this._umap.sync._undoManager.toggleState() this._umap.sync._undoManager.toggleState()
} }
} }

View file

@ -4,6 +4,8 @@ export class Positioned {
this.anchorTop(anchor) this.anchorTop(anchor)
} else if (anchor && position === 'bottom') { } else if (anchor && position === 'bottom') {
this.anchorBottom(anchor) this.anchorBottom(anchor)
} else if (anchor && position === 'right') {
this.anchorRight(anchor)
} else { } else {
this.anchorAbsolute() this.anchorAbsolute()
} }
@ -12,6 +14,7 @@ export class Positioned {
toggleClassPosition(position) { toggleClassPosition(position) {
this.container.classList.toggle('tooltip-bottom', position === 'bottom') this.container.classList.toggle('tooltip-bottom', position === 'bottom')
this.container.classList.toggle('tooltip-top', position === 'top') this.container.classList.toggle('tooltip-top', position === 'top')
this.container.classList.toggle('tooltip-right', position === 'right')
} }
anchorTop(el) { anchorTop(el) {
@ -33,6 +36,16 @@ export class Positioned {
}) })
} }
anchorRight(el) {
this.toggleClassPosition('right')
const coords = this.getPosition(el)
console.log(coords)
this.setPosition({
left: coords.right + 11,
top: coords.top,
})
}
anchorAbsolute() { anchorAbsolute() {
const left = const left =
this.parent.offsetLeft + this.parent.offsetLeft +

View file

@ -107,7 +107,7 @@ export default class Umap {
if (geojson.properties.schema) this.overrideSchema(geojson.properties.schema) if (geojson.properties.schema) this.overrideSchema(geojson.properties.schema)
// Do not display in an iframe. // Do not display in an iframe.
if (window.self !== window.top) { if (this.isEmbed) {
this.properties.homeControl = false this.properties.homeControl = false
} }
@ -258,6 +258,10 @@ export default class Umap {
} }
} }
get isEmbed() {
return window.self !== window.top
}
setPropertiesFromQueryString() { setPropertiesFromQueryString() {
const asBoolean = (key) => { const asBoolean = (key) => {
const value = this.searchParams.get(key) const value = this.searchParams.get(key)
@ -691,7 +695,8 @@ export default class Umap {
if (!this.isDirty) return if (!this.isDirty) return
if (this._defaultExtent) this._setCenterAndZoom() if (this._defaultExtent) this._setCenterAndZoom()
this.backup() this.backup()
await this.sync.save() const status = await this.sync.save()
if (!status) return
// Do a blind render for now, as we are not sure what could // 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 // have changed, we'll be more subtil when we'll remove the
// save action // save action
@ -763,7 +768,11 @@ export default class Umap {
if (!this.editEnabled) return if (!this.editEnabled) return
if (this.properties.editMode !== 'advanced') return if (this.properties.editMode !== 'advanced') return
const container = DomUtil.create('div') const container = DomUtil.create('div')
const metadataFields = ['properties.name', 'properties.description'] const metadataFields = [
'properties.name',
'properties.description',
'properties.is_template',
]
DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption') DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption')
const builder = new MutatingForm(this, metadataFields, { const builder = new MutatingForm(this, metadataFields, {
@ -1177,7 +1186,7 @@ export default class Umap {
} }
this._advancedActions(container) this._advancedActions(container)
this.editPanel.open({ return this.editPanel.open({
content: container, content: container,
className: 'dark', className: 'dark',
highlight: 'settings', highlight: 'settings',
@ -1192,6 +1201,7 @@ export default class Umap {
} }
const formData = new FormData() const formData = new FormData()
formData.append('name', this.properties.name) formData.append('name', this.properties.name)
formData.append('is_template', Boolean(this.properties.is_template))
formData.append('center', JSON.stringify(this.geometry())) formData.append('center', JSON.stringify(this.geometry()))
formData.append('tags', this.properties.tags || []) formData.append('tags', this.properties.tags || [])
formData.append('settings', JSON.stringify(geojson)) formData.append('settings', JSON.stringify(geojson))
@ -1508,12 +1518,17 @@ export default class Umap {
editDatalayers() { editDatalayers() {
if (!this.editEnabled) return if (!this.editEnabled) return
const container = DomUtil.create('div') const template = `
DomUtil.createTitle(container, translate('Manage layers'), 'icon-layers') <div>
const ul = DomUtil.create('ul', '', container) <h3><i class="icon icon-16 icon-layers"></i>${translate('Manage layers')}</h3>
<ul data-ref=ul></ul>
</div>
`
const [container, { ul }] = Utils.loadTemplateWithRefs(template)
this.eachDataLayerReverse((datalayer) => { this.eachDataLayerReverse((datalayer) => {
const row = DomUtil.create('li', 'orderable', ul) const row = Utils.loadTemplate(
DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder')) `<li class="orderable"><i class="icon icon-16 icon-drag" title="${translate('Drag to reorder')}"></i></li>`
)
datalayer.renderToolbox(row) datalayer.renderToolbox(row)
const builder = new MutatingForm( const builder = new MutatingForm(
datalayer, datalayer,
@ -1524,6 +1539,7 @@ export default class Umap {
row.appendChild(form) row.appendChild(form)
row.classList.toggle('off', !datalayer.isVisible()) row.classList.toggle('off', !datalayer.isVisible())
row.dataset.id = datalayer.id row.dataset.id = datalayer.id
ul.appendChild(row)
}) })
const onReorder = (src, dst, initialIndex, finalIndex) => { const onReorder = (src, dst, initialIndex, finalIndex) => {
const movedLayer = this.datalayers[src.dataset.id] const movedLayer = this.datalayers[src.dataset.id]

View file

@ -1,183 +1,3 @@
U.HomeControl = L.Control.extend({
options: {
position: 'topleft',
},
onAdd: (map) => {
const path = map._umap.getStaticPathFor('home.svg')
const container = U.Utils.loadTemplate(
`<a href="/" class="home-button" title="${L._('Back to home')}"><img src="${path}" alt="${L._('Home logo')}" width="38px" height="38px" /></a>`
)
return container
},
})
U.EditControl = L.Control.extend({
options: {
position: 'topright',
},
onAdd: function (map) {
const container = L.DomUtil.create('div', 'edit-enable')
const enableEditing = L.DomUtil.createButton(
'',
container,
L._('Edit'),
map._umap.enableEdit,
map._umap
)
L.DomEvent.on(
enableEditing,
'mouseover',
() => {
map._umap.tooltip.open({
content: map._umap.help.displayLabel('TOGGLE_EDIT'),
anchor: enableEditing,
position: 'bottom',
delay: 750,
duration: 5000,
})
},
this
)
return container
},
})
U.MoreControls = L.Control.extend({
options: {
position: 'topleft',
},
onAdd: function () {
const container = L.DomUtil.create('div', 'umap-control-text')
const moreButton = L.DomUtil.createButton(
'umap-control-more',
container,
L._('More controls'),
this.toggle,
this
)
const lessButton = L.DomUtil.createButton(
'umap-control-less',
container,
L._('Hide controls'),
this.toggle,
this
)
return container
},
toggle: function () {
const pos = this.getPosition()
const corner = this._map._controlCorners[pos]
const className = 'umap-more-controls'
if (L.DomUtil.hasClass(corner, className)) L.DomUtil.removeClass(corner, className)
else L.DomUtil.addClass(corner, className)
},
})
U.PermanentCreditsControl = L.Control.extend({
options: {
position: 'bottomleft',
},
initialize: function (map, options) {
this.map = map
L.Control.prototype.initialize.call(this, options)
},
onAdd: function () {
this.paragraphContainer = L.DomUtil.create(
'div',
'umap-permanent-credits-container text'
)
this.setCredits()
this.setBackground()
return this.paragraphContainer
},
setCredits: function () {
this.paragraphContainer.innerHTML = U.Utils.toHTML(this.map.options.permanentCredit)
},
setBackground: function () {
if (this.map.options.permanentCreditBackground) {
this.paragraphContainer.style.backgroundColor = '#FFFFFFB0'
} else {
this.paragraphContainer.style.backgroundColor = ''
}
},
})
L.Control.Button = L.Control.extend({
initialize: function (umap, options) {
this._umap = umap
L.Control.prototype.initialize.call(this, options)
},
getClassName: function () {
return this.options.className
},
onAdd: function (map) {
const container = L.DomUtil.create('div', `${this.getClassName()} umap-control`)
const button = L.DomUtil.createButton(
'',
container,
this.options.title,
this.onClick,
this
)
L.DomEvent.on(button, 'dblclick', L.DomEvent.stopPropagation)
this.afterAdd(container)
return container
},
afterAdd: (container) => {},
})
U.DataLayersControl = L.Control.Button.extend({
options: {
position: 'topleft',
className: 'umap-control-browse',
title: L._('Open browser'),
},
afterAdd: function (container) {
U.Utils.toggleBadge(container, this._umap.browser?.hasFilters())
},
onClick: function () {
this._umap.openBrowser()
},
})
U.CaptionControl = L.Control.Button.extend({
options: {
position: 'topleft',
className: 'umap-control-caption',
title: L._('About'),
},
onClick: function () {
this._umap.openCaption()
},
})
L.Control.Embed = L.Control.Button.extend({
options: {
position: 'topleft',
title: L._('Share and download'),
className: 'leaflet-control-embed umap-control',
},
onClick: function () {
this._umap.share.open()
},
})
/* Used in view mode to define the current tilelayer */ /* Used in view mode to define the current tilelayer */
U.TileLayerControl = L.Control.IconLayers.extend({ U.TileLayerControl = L.Control.IconLayers.extend({
initialize: function (map, options) { initialize: function (map, options) {
@ -238,128 +58,6 @@ U.TileLayerControl = L.Control.IconLayers.extend({
}, },
}) })
/* Used in edit mode to define the default tilelayer */
U.TileLayerChooser = L.Control.extend({
options: {
position: 'topleft',
},
initialize: function (map, options = {}) {
this.map = map
L.Control.prototype.initialize.call(this, options)
},
onAdd: function () {
const container = L.DomUtil.create('div', 'leaflet-control-tilelayers umap-control')
const changeMapBackgroundButton = L.DomUtil.createButton(
'',
container,
L._('Change map background'),
this.openSwitcher,
this
)
L.DomEvent.on(changeMapBackgroundButton, 'dblclick', L.DomEvent.stopPropagation)
return container
},
openSwitcher: function (options = {}) {
const container = L.DomUtil.create('div', 'umap-edit-tilelayers')
L.DomUtil.createTitle(container, L._('Change tilelayers'), 'icon-tilelayer')
this._tilelayers_container = L.DomUtil.create('ul', '', container)
this.buildList(options)
const panel = options.edit ? this.map._umap.editPanel : this.map._umap.panel
panel.open({ content: container, highlight: 'tilelayers' })
},
buildList: function (options) {
this.map.eachTileLayer(function (tilelayer) {
if (
window.location.protocol === 'https:' &&
tilelayer.options.url_template.indexOf('http:') === 0
)
return
this.addTileLayerElement(tilelayer, options)
}, this)
},
addTileLayerElement: function (tilelayer, options) {
const selectedClass = this.map.hasLayer(tilelayer) ? 'selected' : ''
const el = L.DomUtil.create('li', selectedClass, this._tilelayers_container)
const img = L.DomUtil.create('img', '', el)
const name = L.DomUtil.create('div', '', el)
img.src = U.Utils.template(
tilelayer.options.url_template,
this.map.options.demoTileInfos
)
img.loading = 'lazy'
name.textContent = tilelayer.options.name
L.DomEvent.on(
el,
'click',
() => {
const oldTileLayer = this.map._umap.properties.tilelayer
this.map.selectTileLayer(tilelayer)
this.map._controls.tilelayers.setLayers()
if (options?.edit) {
this.map._umap.properties.tilelayer = tilelayer.toJSON()
this.map._umap.isDirty = true
this.map._umap.sync.update(
'properties.tilelayer',
this.map._umap.properties.tilelayer,
oldTileLayer
)
}
},
this
)
},
})
U.AttributionControl = L.Control.Attribution.extend({
options: {
prefix: '',
},
_update: function () {
// Layer is no more on the map
if (!this._map) return
L.Control.Attribution.prototype._update.call(this)
// Use our own container, so we can hide/show on small screens
const credits = this._container.innerHTML
this._container.innerHTML = ''
const container = L.DomUtil.create('div', 'attribution-container', this._container)
container.innerHTML = credits
const shortCredit = this._map._umap.getProperty('shortCredit')
const captionMenus = this._map._umap.getProperty('captionMenus')
if (shortCredit) {
L.DomUtil.element({
tagName: 'span',
parent: container,
safeHTML: `${U.Utils.toHTML(shortCredit)}`,
})
}
if (captionMenus) {
const link = L.DomUtil.add('a', '', container, `${L._('Open caption')}`)
L.DomEvent.on(link, 'click', L.DomEvent.stop)
.on(link, 'click', () => this._map._umap.openCaption())
.on(link, 'dblclick', L.DomEvent.stop)
}
if (window.top === window.self && captionMenus) {
// We are not in iframe mode
L.DomUtil.createLink('', container, `${L._('Home')}`, '/')
}
if (captionMenus) {
L.DomUtil.createLink(
'',
container,
`${L._('Powered by uMap')}`,
'https://umap-project.org/'
)
}
L.DomUtil.createLink('attribution-toggle', this._container, '')
},
})
/* /*
* Take control over L.Control.Locate to be able to * Take control over L.Control.Locate to be able to
* call start() before adding the control (and thus the button) to the map. * call start() before adding the control (and thus the button) to the map.

View file

@ -65,18 +65,6 @@ L.DomUtil.createButton = (className, container, content, callback, context) => {
return el return el
} }
L.DomUtil.createLink = (className, container, content, url, target, title) => {
const el = L.DomUtil.add('a', className, container, content)
el.href = url
if (target) {
el.target = target
}
if (title) {
el.title = title
}
return el
}
L.DomUtil.createIcon = (parent, className, title, size = 16) => { L.DomUtil.createIcon = (parent, className, title, size = 16) => {
return L.DomUtil.element({ return L.DomUtil.element({
tagName: 'i', tagName: 'i',
@ -140,16 +128,6 @@ L.DomUtil.element = ({ tagName, parent, ...attrs }) => {
return el return el
} }
L.DomUtil.before = (target, el) => {
target.parentNode.insertBefore(el, target)
return el
}
L.DomUtil.after = (target, el) => {
target.parentNode.insertBefore(el, target.nextSibling)
return el
}
// From https://gist.github.com/Accudio/b9cb16e0e3df858cef0d31e38f1fe46f // From https://gist.github.com/Accudio/b9cb16e0e3df858cef0d31e38f1fe46f
// convert colour in range 0-255 to the modifier used within luminance calculation // convert colour in range 0-255 to the modifier used within luminance calculation
L.DomUtil.colourMod = (colour) => { L.DomUtil.colourMod = (colour) => {
@ -214,24 +192,6 @@ L.DomUtil.contrastedColor = (el, bgcolor) => {
if (bgcolor) _CACHE_CONSTRAST[bgcolor] = out if (bgcolor) _CACHE_CONSTRAST[bgcolor] = out
return out return out
} }
L.DomEvent.once = (el, types, fn, context) => {
// cf https://github.com/Leaflet/Leaflet/pull/3528#issuecomment-134551575
if (typeof types === 'object') {
for (const type in types) {
L.DomEvent.once(el, type, types[type], fn)
}
return L.DomEvent
}
const handler = L.bind(() => {
L.DomEvent.off(el, types, fn, context).off(el, types, handler, context)
}, L.DomEvent)
// add a listener that's executed once and removed after that
return L.DomEvent.on(el, types, fn, context).on(el, types, handler, context)
}
L.LatLng.prototype.isValid = function () { L.LatLng.prototype.isValid = function () {
return ( return (
Number.isFinite(this.lat) && Number.isFinite(this.lat) &&

View file

@ -96,27 +96,20 @@ html[dir="rtl"] .leaflet-tooltip-pane > * {
background-color: white; background-color: white;
min-height: initial; min-height: initial;
} }
.leaflet-control.display-on-more, .leaflet-control.display-on-more {
.umap-control-less {
display: none; display: none;
} }
.umap-control-more, .umap-control-more {
.umap-control-less {
background-image: url('./img/24-white.svg'); background-image: url('./img/24-white.svg');
background-position: -72px -402px; background-position: -72px -402px;
text-indent: -9999px;
margin-bottom: 0; margin-bottom: 0;
} }
.umap-control-less { .umap-more-controls .umap-control-more {
background-position: -108px -402px; background-position: -108px -402px;
} }
.umap-more-controls .display-on-more, .umap-more-controls .display-on-more {
.umap-more-controls .umap-control-less {
display: block; display: block;
} }
.umap-more-controls .umap-control-more {
display: none;
}
.leaflet-control-embed [type="button"] { .leaflet-control-embed [type="button"] {
background-position: 0 -180px; background-position: 0 -180px;
} }

View file

@ -301,6 +301,15 @@
</form> </form>
</fieldset> </fieldset>
</details> </details>
<details open>
<summary>With tabs</summary>
<div class="flat-tabs" data-ref="tabs">
<button class="flat on" data-ref="recent">Récents</button>
<button class="flat" data-ref="symbols">Symbole</button>
<button class="flat" data-ref="chars">Emoji &amp; texte</button>
<button class="flat" data-ref="url">URL</button>
</div>
</details>
</div> </div>
<h4>Importers</h4> <h4>Importers</h4>
<div class="umap-dialog window importers dark"> <div class="umap-dialog window importers dark">

View file

@ -12,14 +12,14 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<h3>{{ map_inst.name }}</h3> <h3>{% if map_inst.is_template %}<mark class="template-map">[{% trans "template" %}]</mark>{% endif %} {{ map_inst.name }}</h3>
{% with author=map_inst.get_author %} {% with author=map_inst.get_author %}
{% if author %} {% if author %}
<p>{% trans "by" %} <a href="{{ author.get_url }}">{{ author }}</a></p> <p>{% trans "by" %} <a href="{{ author.get_url }}">{{ author }}</a></p>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
<a class="main" href="{{ map_inst.get_absolute_url }}">{% translate "See the map" %}</a> <a class="main" href="{{ map_inst.get_absolute_url }}">{% if map_inst.is_template %}{% translate "See the template" %}{% else %}{% translate "See the map" %}{% endif %}</a>
</hgroup> </hgroup>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -465,19 +465,19 @@ def test_main_toolbox_toggle_all_layers(live_server, map, page):
expect(page.locator(".datalayer.off")).to_have_count(1) expect(page.locator(".datalayer.off")).to_have_count(1)
# Click on button # Click on button
page.locator(".umap-browser [data-ref=toggle]").click() page.locator(".umap-browser").get_by_title("Show/hide all layers").click()
# Should have hidden the two other layers # Should have hidden the two other layers
expect(page.locator(".datalayer.off")).to_have_count(3) expect(page.locator(".datalayer.off")).to_have_count(3)
expect(markers).to_have_count(0) expect(markers).to_have_count(0)
# Click again # Click again
page.locator(".umap-browser [data-ref=toggle]").click() page.locator(".umap-browser").get_by_title("Show/hide all layers").click()
# Should shown all layers # Should shown all layers
expect(page.locator(".datalayer.off")).to_have_count(0) expect(page.locator(".datalayer.off")).to_have_count(0)
expect(markers).to_have_count(3) expect(markers).to_have_count(3)
# Click again # Click again
page.locator(".umap-browser [data-ref=toggle]").click() page.locator(".umap-browser").get_by_title("Show/hide all layers").click()
# Should hidden again all layers # Should hidden again all layers
expect(page.locator(".datalayer.off")).to_have_count(3) expect(page.locator(".datalayer.off")).to_have_count(3)
expect(markers).to_have_count(0) expect(markers).to_have_count(0)

View file

@ -281,10 +281,10 @@ def test_can_deactive_rule_from_list(live_server, page, openmap):
page.get_by_role("button", name="Edit").click() page.get_by_role("button", name="Edit").click()
page.get_by_role("button", name="Map advanced properties").click() page.get_by_role("button", name="Map advanced properties").click()
page.get_by_text("Conditional style rules").click() page.get_by_text("Conditional style rules").click()
page.get_by_role("button", name="Show/hide layer").click() page.get_by_role("button", name="Toggle rule").click()
colors = getColors(markers) colors = getColors(markers)
assert colors.count("rgb(240, 248, 255)") == 0 assert colors.count("rgb(240, 248, 255)") == 0
page.get_by_role("button", name="Show/hide layer").click() page.get_by_role("button", name="Toggle rule").click()
colors = getColors(markers) colors = getColors(markers)
assert colors.count("rgb(240, 248, 255)") == 3 assert colors.count("rgb(240, 248, 255)") == 3

View file

@ -180,9 +180,9 @@ def test_sortkey_impacts_datalayerindex(map, live_server, page):
first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0) first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0)
second_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(1) second_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(1)
third_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(2) third_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(2)
assert "X Third" == first_listed_feature.text_content() assert "X Third" == first_listed_feature.text_content().strip()
assert "Y Second" == second_listed_feature.text_content() assert "Y Second" == second_listed_feature.text_content().strip()
assert "Z First" == third_listed_feature.text_content() assert "Z First" == third_listed_feature.text_content().strip()
# Change the default sortkey to be "key" # Change the default sortkey to be "key"
page.get_by_role("button", name="Edit").click() page.get_by_role("button", name="Edit").click()
@ -201,9 +201,9 @@ def test_sortkey_impacts_datalayerindex(map, live_server, page):
first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0) first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0)
second_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(1) second_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(1)
third_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(2) third_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(2)
assert "Z First" == first_listed_feature.text_content() assert "Z First" == first_listed_feature.text_content().strip()
assert "Y Second" == second_listed_feature.text_content() assert "Y Second" == second_listed_feature.text_content().strip()
assert "X Third" == third_listed_feature.text_content() assert "X Third" == third_listed_feature.text_content().strip()
def test_hover_tooltip_setting_should_be_persistent(live_server, map, page): def test_hover_tooltip_setting_should_be_persistent(live_server, map, page):

View file

@ -435,6 +435,63 @@ def test_import_geometry_collection(live_server, page, tilelayer):
expect(paths).to_have_count(2) expect(paths).to_have_count(2)
def test_import_geometry_collection_in_feature(live_server, page, tilelayer):
data = {
"type": "Feature",
"properties": {"name": "foobar"},
"geometry": {
"type": "GeometryCollection",
"geometries": [
{"type": "Point", "coordinates": [-80.6608, 35.0493]},
{
"type": "Polygon",
"coordinates": [
[
[-80.6645, 35.0449],
[-80.6634, 35.0460],
[-80.6625, 35.0455],
[-80.6638, 35.0442],
[-80.6645, 35.0449],
]
],
},
{
"type": "LineString",
"coordinates": [
[-80.66237, 35.05950],
[-80.66269, 35.05926],
[-80.66284, 35.05893],
[-80.66308, 35.05833],
[-80.66385, 35.04387],
[-80.66303, 35.04371],
],
},
],
},
}
page.goto(f"{live_server.url}/map/new/")
page.get_by_title("Open browser").click()
layers = page.locator(".umap-browser .datalayer")
markers = page.locator(".leaflet-marker-icon")
paths = page.locator("path")
expect(markers).to_have_count(0)
expect(paths).to_have_count(0)
expect(layers).to_have_count(0)
button = page.get_by_title("Import data")
expect(button).to_be_visible()
button.click()
textarea = page.locator(".umap-import textarea")
textarea.fill(json.dumps(data))
page.locator('select[name="format"]').select_option("geojson")
page.get_by_role("button", name="Import data", exact=True).click()
# A layer has been created
expect(layers).to_have_count(1)
expect(markers).to_have_count(1)
expect(paths).to_have_count(2)
# Geometries are treated as separate features.
expect(page.get_by_text("foobar")).to_have_count(3)
def test_import_multipolygon(live_server, page, tilelayer): def test_import_multipolygon(live_server, page, tilelayer):
data = { data = {
"type": "Feature", "type": "Feature",

View file

@ -63,5 +63,5 @@ def test_zoom_control(map, live_server, datalayer, page):
expect(control).to_be_visible() expect(control).to_be_visible()
page.goto(f"{live_server.url}{map.get_absolute_url()}?zoomControl=null") page.goto(f"{live_server.url}{map.get_absolute_url()}?zoomControl=null")
expect(control).to_be_hidden() expect(control).to_be_hidden()
page.get_by_title("More controls").click() page.locator(".umap-control-more").click()
expect(control).to_be_visible() expect(control).to_be_visible()

View file

@ -528,3 +528,62 @@ def test_can_find_small_usernames(client):
data = json.loads(response.content)["data"] data = json.loads(response.content)["data"]
assert len(data) == 1 assert len(data) == 1
assert data[0]["label"] == "JoeJoe" assert data[0]["label"] == "JoeJoe"
@pytest.mark.django_db
def test_templates_list(client, user, user2):
public = MapFactory(
owner=user,
name="A public template",
share_status=Map.PUBLIC,
is_template=True,
)
link_only = MapFactory(
owner=user,
name="A link-only template",
share_status=Map.OPEN,
is_template=True,
)
private = MapFactory(
owner=user,
name="A link-only template",
share_status=Map.PRIVATE,
is_template=True,
)
someone_else = MapFactory(
owner=user2,
name="A public template from someone else",
share_status=Map.PUBLIC,
is_template=True,
)
staff = UserFactory(username="Staff", is_staff=True)
Star.objects.create(by=staff, map=someone_else)
client.login(username=user.username, password="123123")
url = reverse("template_list")
# Ask for mine
response = client.get(f"{url}?source=mine")
templates = json.loads(response.content)["templates"]
ids = [t["id"] for t in templates]
assert public.pk in ids
assert link_only.pk in ids
assert private.pk in ids
assert someone_else.pk not in ids
# Ask for staff ones
response = client.get(f"{url}?source=staff")
templates = json.loads(response.content)["templates"]
ids = [t["id"] for t in templates]
assert public.pk not in ids
assert link_only.pk not in ids
assert private.pk not in ids
assert someone_else.pk in ids
# Ask for community ones
response = client.get(f"{url}?source=community")
templates = json.loads(response.content)["templates"]
ids = [t["id"] for t in templates]
assert public.pk in ids
assert link_only.pk not in ids
assert private.pk not in ids
assert someone_else.pk in ids

View file

@ -73,6 +73,11 @@ i18n_urls = [
views.PictogramJSONList.as_view(), views.PictogramJSONList.as_view(),
name="pictogram_list_json", name="pictogram_list_json",
), ),
re_path(
r"^templates/json/$",
views.TemplateList.as_view(),
name="template_list",
),
] ]
i18n_urls += decorated_patterns( i18n_urls += decorated_patterns(
[can_view_map, cache_control(must_revalidate=True)], [can_view_map, cache_control(must_revalidate=True)],

View file

@ -138,9 +138,7 @@ class PublicMapsMixin(object):
return maps return maps
def get_highlighted_maps(self): def get_highlighted_maps(self):
staff = User.objects.filter(is_staff=True) qs = Map.public.starred_by_staff()
stars = Star.objects.filter(by__in=staff).values("map")
qs = Map.public.filter(pk__in=stars)
maps = qs.order_by("-modified_at") maps = qs.order_by("-modified_at")
return maps return maps
@ -332,10 +330,10 @@ class TeamMaps(PaginatorMixin, DetailView):
class SearchMixin: class SearchMixin:
def get_search_queryset(self, **kwargs): def get_search_queryset(self, qs=None, **kwargs):
q = self.request.GET.get("q") q = self.request.GET.get("q")
tags = [t for t in self.request.GET.getlist("tags") if t] tags = [t for t in self.request.GET.getlist("tags") if t]
qs = Map.objects.all() qs = qs or Map.public.all()
if q: if q:
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION) vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
query = SearchQuery( query = SearchQuery(
@ -382,14 +380,8 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin):
return self.get_queryset().get(pk=self.request.user.pk) return self.get_queryset().get(pk=self.request.user.pk)
def get_maps(self): def get_maps(self):
qs = self.get_search_queryset() or Map.objects.all() qs = Map.private.for_user(self.object)
qs = qs.exclude(share_status__in=[Map.DELETED, Map.BLOCKED]) qs = self.get_search_queryset(qs) or qs
teams = self.object.teams.all()
qs = (
qs.filter(owner=self.object)
.union(qs.filter(editors=self.object))
.union(qs.filter(team__in=teams))
)
return qs.order_by("-modified_at") return qs.order_by("-modified_at")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -408,9 +400,9 @@ class UserDownload(DetailView, SearchMixin):
return self.get_queryset().get(pk=self.request.user.pk) return self.get_queryset().get(pk=self.request.user.pk)
def get_maps(self): def get_maps(self):
qs = Map.objects.filter(id__in=self.request.GET.getlist("map_id")) qs = Map.private.filter(id__in=self.request.GET.getlist("map_id"))
qs = qs.filter(owner=self.object).union(qs.filter(editors=self.object)) qsu = qs.for_user(self.object)
return qs.order_by("-modified_at") return qsu.order_by("-modified_at")
def render_to_response(self, context, *args, **kwargs): def render_to_response(self, context, *args, **kwargs):
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
@ -802,7 +794,10 @@ class MapDownload(DetailView):
return reverse("map_download", args=(self.object.pk,)) return reverse("map_download", args=(self.object.pk,))
def render_to_response(self, context, *args, **kwargs): def render_to_response(self, context, *args, **kwargs):
umapjson = self.object.generate_umapjson(self.request) include_data = self.request.GET.get("include_data") != "0"
umapjson = self.object.generate_umapjson(
self.request, include_data=include_data
)
response = simple_json_response(**umapjson) response = simple_json_response(**umapjson)
response["Content-Disposition"] = ( response["Content-Disposition"] = (
f'attachment; filename="umap_backup_{self.object.slug}.umap"' f'attachment; filename="umap_backup_{self.object.slug}.umap"'
@ -1456,3 +1451,26 @@ class LoginPopupEnd(TemplateView):
if backend in settings.DEPRECATED_AUTHENTICATION_BACKENDS: if backend in settings.DEPRECATED_AUTHENTICATION_BACKENDS:
return HttpResponseRedirect(reverse("user_profile")) return HttpResponseRedirect(reverse("user_profile"))
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
class TemplateList(ListView):
model = Map
def render_to_response(self, context, **response_kwargs):
source = self.request.GET.get("source")
if source == "mine":
qs = Map.private.filter(is_template=True).for_user(self.request.user)
elif source == "community":
qs = Map.public.filter(is_template=True)
elif source == "staff":
qs = Map.public.starred_by_staff().filter(is_template=True)
templates = [
{
"id": m.id,
"name": m.name,
"description": m.description,
"url": m.get_absolute_url(),
}
for m in qs.order_by("-modified_at")
]
return simple_json_response(templates=templates)