Compare commits
38 commits
16c5b6fd9a
...
a16aaf21d8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a16aaf21d8 | ||
![]() |
e24695b68c | ||
![]() |
db72bfad8d | ||
![]() |
738af24dfc | ||
![]() |
06f963c07f | ||
![]() |
c6ed896a6d | ||
![]() |
1344fe6b46 | ||
![]() |
39f38a9cdf | ||
![]() |
e2f154f62e | ||
![]() |
7196ffe101 | ||
![]() |
99ea09e79e | ||
![]() |
b3f587ccf3 | ||
![]() |
64cf408233 | ||
![]() |
e26e8edc8a | ||
![]() |
1b8e217768 | ||
![]() |
5259cab027 | ||
![]() |
7ede27bf0f | ||
![]() |
5807cfbbcd | ||
![]() |
e41ad4e069 | ||
![]() |
c933df585c | ||
![]() |
50f2c08ecb | ||
![]() |
d61e045903 | ||
![]() |
6b2038e83e | ||
![]() |
0389e9a185 | ||
![]() |
a2e864ad73 | ||
![]() |
d0fb85d552 | ||
![]() |
fa83764c8b | ||
![]() |
d438a007e4 | ||
![]() |
be52e7ca2f | ||
![]() |
172de0e2d0 | ||
![]() |
77da6425c2 | ||
![]() |
093ed061c1 | ||
![]() |
5cb7cb2738 | ||
![]() |
dfdfae0080 | ||
![]() |
4fd066387d | ||
fa3ba46ca8 | |||
![]() |
cb46a5f875 | ||
![]() |
cc2625bfac |
|
@ -1,5 +1,5 @@
|
|||
# Force rtfd to use a recent version of mkdocs
|
||||
mkdocs==1.6.1
|
||||
pymdown-extensions==10.14.3
|
||||
mkdocs-material==9.6.9
|
||||
mkdocs-material==9.6.10
|
||||
mkdocs-static-i18n==1.3.0
|
||||
|
|
|
@ -323,7 +323,10 @@ CREATE EXTENSION btree_gin;
|
|||
ALTER TEXT SEARCH CONFIGURATION umapdict ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;
|
||||
|
||||
# Now create the index
|
||||
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status);
|
||||
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status, tags);
|
||||
|
||||
# You should also create an index for tag filtering:
|
||||
CREATE INDEX IF NOT EXISTS tags_idx ON umap_map USING GIN(share_status, tags);
|
||||
```
|
||||
|
||||
Then set:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# Force rtfd to use a recent version of mkdocs
|
||||
mkdocs==1.6.1
|
||||
pymdown-extensions==10.14.3
|
||||
mkdocs-material==9.6.9
|
||||
mkdocs-material==9.6.10
|
||||
mkdocs-static-i18n==1.3.0
|
||||
|
|
|
@ -28,7 +28,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"Django==5.1.7",
|
||||
"Django==5.1.8",
|
||||
"django-agnocomplete==2.2.0",
|
||||
"django-environ==0.12.0",
|
||||
"django-probes==1.7.0",
|
||||
|
@ -47,7 +47,7 @@ dev = [
|
|||
"ruff==0.11.2",
|
||||
"djlint==1.36.4",
|
||||
"mkdocs==1.6.1",
|
||||
"mkdocs-material==9.6.9",
|
||||
"mkdocs-material==9.6.10",
|
||||
"mkdocs-static-i18n==1.3.0",
|
||||
"vermin==1.6.0",
|
||||
"pymdown-extensions==10.14.3",
|
||||
|
@ -71,7 +71,7 @@ s3 = [
|
|||
"django-storages[s3]==1.14.5",
|
||||
]
|
||||
sync = [
|
||||
"pydantic==2.10.6",
|
||||
"pydantic==2.11.1",
|
||||
"redis==5.2.1",
|
||||
]
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ def settings(request):
|
|||
"UMAP_DEMO_SITE": djsettings.UMAP_DEMO_SITE,
|
||||
"UMAP_HOST_INFOS": djsettings.UMAP_HOST_INFOS,
|
||||
"UMAP_ALLOW_EDIT_PROFILE": djsettings.UMAP_ALLOW_EDIT_PROFILE,
|
||||
"UMAP_TAGS": djsettings.UMAP_TAGS,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model
|
|||
from django.contrib.gis.geos import Point
|
||||
from django.forms.utils import ErrorList
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import DataLayer, Map, Team
|
||||
|
||||
|
@ -92,7 +91,7 @@ class MapSettingsForm(forms.ModelForm):
|
|||
return self.cleaned_data["center"]
|
||||
|
||||
class Meta:
|
||||
fields = ("settings", "name", "center", "slug")
|
||||
fields = ("settings", "name", "center", "slug", "tags")
|
||||
model = Map
|
||||
|
||||
|
||||
|
|
23
umap/migrations/0027_map_tags.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.1.6 on 2025-02-26 16:18
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("umap", "0026_datalayer_modified_at_datalayer_share_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="map",
|
||||
name="tags",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=200),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -4,6 +4,7 @@ import uuid
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.gis.db import models
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.files.base import File
|
||||
from django.core.files.storage import storages
|
||||
from django.core.signing import Signer
|
||||
|
@ -236,6 +237,7 @@ class Map(NamedModel):
|
|||
settings = models.JSONField(
|
||||
blank=True, null=True, verbose_name=_("settings"), default=dict
|
||||
)
|
||||
tags = ArrayField(models.CharField(max_length=200), blank=True, default=list)
|
||||
|
||||
objects = models.Manager()
|
||||
public = PublicManager()
|
||||
|
@ -415,12 +417,17 @@ class Map(NamedModel):
|
|||
datalayer.clone(map_inst=new)
|
||||
return new
|
||||
|
||||
def get_tags_display(self):
|
||||
labels = dict(settings.UMAP_TAGS)
|
||||
return [(t, labels.get(t, t)) for t in self.tags]
|
||||
|
||||
@classproperty
|
||||
def extra_schema(self):
|
||||
return {
|
||||
"iconUrl": {
|
||||
"default": "%sumap/img/marker.svg" % settings.STATIC_URL,
|
||||
}
|
||||
},
|
||||
"tags": {"choices": sorted(settings.UMAP_TAGS, key=lambda i: i[0])},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ from email.utils import parseaddr
|
|||
|
||||
import environ
|
||||
from django.conf.locale import LANG_INFO
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import umap as project_module
|
||||
|
||||
|
@ -290,6 +291,25 @@ UMAP_HOME_FEED = "latest"
|
|||
UMAP_IMPORTERS = {}
|
||||
UMAP_HOST_INFOS = {}
|
||||
UMAP_LABEL_KEYS = ["name", "title"]
|
||||
UMAP_TAGS = (
|
||||
("arts", _("Art and Culture")),
|
||||
("cycling", _("Cycling")),
|
||||
("business", _("Business")),
|
||||
("environment", _("Environment")),
|
||||
("education", _("Education")),
|
||||
("food", _("Food and Agriculture")),
|
||||
("geopolitics", _("Geopolitics")),
|
||||
("health", _("Health")),
|
||||
("hiking", _("Hiking")),
|
||||
("history", _("History")),
|
||||
("public", _("Public sector")),
|
||||
("science", _("Science")),
|
||||
("shopping", _("Shopping")),
|
||||
("sport", _("Sport and Leisure")),
|
||||
("travel", _("Travel")),
|
||||
("transports", _("Transports")),
|
||||
("tourism", _("Tourism")),
|
||||
)
|
||||
|
||||
UMAP_READONLY = env("UMAP_READONLY", default=False)
|
||||
UMAP_GZIP = True
|
||||
|
|
|
@ -16,6 +16,10 @@ input::-webkit-input-placeholder, ::-webkit-input-placeholder {
|
|||
input:-moz-placeholder, :-moz-placeholder {
|
||||
color: #a5a5a5;
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
|
||||
/* **************** */
|
||||
|
@ -167,7 +171,17 @@ h2.tabs a:hover {
|
|||
.more_button {
|
||||
min-height: var(--map-fragment-height);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
margin-top: var(--text-margin);
|
||||
margin-bottom: var(--text-margin);
|
||||
}
|
||||
.tag-list li {
|
||||
border: 1px solid var(--color-darkCyan);
|
||||
border-radius: 3vmin;
|
||||
display: inline-block;
|
||||
padding: var(--button-padding-small);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* **************************** */
|
||||
/* colors */
|
||||
|
|
|
@ -14,11 +14,12 @@
|
|||
background-color: inherit;
|
||||
}
|
||||
.leaflet-container .edit-save,
|
||||
.leaflet-container .edit-cancel,
|
||||
.leaflet-container .edit-undo,
|
||||
.leaflet-container .edit-redo,
|
||||
.leaflet-container .edit-disable,
|
||||
.leaflet-container .connected-peers
|
||||
{
|
||||
display: block;
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
line-height: 30px;
|
||||
padding: 0 20px;
|
||||
|
@ -39,7 +40,8 @@
|
|||
color: var(--color-darkGray);
|
||||
}
|
||||
|
||||
.leaflet-container .edit-cancel:hover,
|
||||
.leaflet-container .edit-undo:hover,
|
||||
.leaflet-container .edit-redo:hover,
|
||||
.leaflet-container .edit-disable:hover {
|
||||
border: 0.5px solid rgba(153, 153, 153, 0.80);
|
||||
text-decoration: none;
|
||||
|
@ -76,19 +78,13 @@
|
|||
background: rgba(66, 236, 230, 0.10);
|
||||
}
|
||||
.leaflet-container .edit-save,
|
||||
.leaflet-container .edit-cancel,
|
||||
.leaflet-container .edit-disable,
|
||||
.umap-edit-enabled .edit-enable {
|
||||
display: none;
|
||||
}
|
||||
.umap-edit-enabled .edit-save,
|
||||
.umap-edit-enabled .edit-disable,
|
||||
.umap-edit-enabled.umap-is-dirty .edit-cancel {
|
||||
.umap-edit-enabled .edit-disable {
|
||||
display: inline-block;
|
||||
}
|
||||
.umap-is-dirty .edit-disable {
|
||||
display: none;
|
||||
}
|
||||
.umap-caption-bar {
|
||||
display: none;
|
||||
}
|
||||
|
@ -115,8 +111,6 @@
|
|||
.umap-right-edit-toolbox {
|
||||
display: flex;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.umap-right-edit-toolbox {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
@ -135,17 +129,20 @@
|
|||
text-indent: -9999px;
|
||||
}
|
||||
.umap-main-edit-toolbox .map-name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: bold;
|
||||
text-align: start;
|
||||
}
|
||||
.truncate {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.umap-main-edit-toolbox .username {
|
||||
max-width: 100px;
|
||||
}
|
||||
.umap-main-edit-toolbox .share-status {
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.map-name:after {
|
||||
content: '\00a0';
|
||||
|
@ -206,6 +203,7 @@
|
|||
line-height: initial;
|
||||
height: initial;
|
||||
width: auto;
|
||||
padding: 0 var(--text-margin);
|
||||
}
|
||||
.umap-caption-bar-enabled {
|
||||
--current-footer-height: var(--footer-height);
|
||||
|
@ -242,3 +240,14 @@
|
|||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@media all and (max-width: 980px) {
|
||||
.umap-main-edit-toolbox button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media all and (max-width: 770px) {
|
||||
.umap-main-edit-toolbox .umap-help-link,
|
||||
.umap-main-edit-toolbox .share-status {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,10 +61,7 @@ textarea {
|
|||
select {
|
||||
border: 1px solid #222;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: var(--box-margin);
|
||||
padding: var(--button-padding);
|
||||
}
|
||||
.dark select {
|
||||
color: #efefef;
|
||||
|
|
|
@ -167,11 +167,15 @@ html[dir="rtl"] .icon {
|
|||
.icon-profile {
|
||||
background-position: 0 calc(var(--tile) * 4);
|
||||
}
|
||||
.icon-redo {
|
||||
background-position: calc(var(--tile) * 3) calc(var(--tile) * 7);
|
||||
}
|
||||
.icon-resize {
|
||||
background-position: calc(var(--tile) * 3) calc(var(--tile) * 6);
|
||||
}
|
||||
.icon-undo,
|
||||
.icon-restore {
|
||||
background-position: calc(var(--tile) * 5) calc(var(--tile) * 3);
|
||||
background-position: calc(var(--tile) * 2) calc(var(--tile) * 7);
|
||||
}
|
||||
.expanded .icon-resize {
|
||||
background-position: calc(var(--tile) * 2) calc(var(--tile) * 6);
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
<clipPath id="clip0_3071_861">
|
||||
<rect id="rect3" width="18" height="20" fill="#fff"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip0_241_10857-3">
|
||||
<rect id="rect586-6" width="18.05" height="19.01" fill="#fff"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<metadata id="metadata7">
|
||||
<rdf:RDF>
|
||||
|
@ -67,9 +70,12 @@
|
|||
</g>
|
||||
</g>
|
||||
<path id="path4873" d="m108.15 816.36v3.8267h3.8544v-3.8267zm0.51755 4.3517-1.2459 2.3132 1.1669 0.61848 1.244-2.3151zm-1.8689 3.4717-0.27666 0.51571h-2.426v2.2441l-4.0916 4.064 1.3626 1.3528 3.862-3.8342h2.7214v-3.6959l0.015-0.028-0.015-8e-3v-0.0953h-0.17879l-0.97303-0.51571z" color="#000000" color-rendering="auto" fill="#f2f2f2" fill-rule="evenodd" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
|
||||
<g id="g4244" transform="matrix(.51357 -.54309 .54309 .51357 -518.02 506.23)">
|
||||
<g id="g4244" transform="matrix(.51357 -.54309 .54309 .51357 -590.02 601.73)">
|
||||
<path id="path4240" transform="matrix(.91922 .97205 -.97205 .91922 152.14 647.93)" d="m220.49 133.52c-0.33017 0.01-0.66239 0.0456-0.99414 0.10742-2.2249 0.41425-4.0267 1.9575-4.832 4.0098l-0.87696-1.8164a0.50005 0.50005 0 0 0-0.83398-0.11328 0.50005 0.50005 0 0 0-0.0664 0.54883l1.459 3.0195a0.50005 0.50005 0 0 0 0.60742 0.25586l2.8438-0.94532a0.50028 0.50028 0 1 0-0.31641-0.94922l-2.002 0.66797c0.61312-1.8902 2.2073-3.3241 4.2012-3.6953 2.2474-0.41845 4.5146 0.59912 5.6953 2.5566 1.1807 1.9575 1.022 4.4377-0.39648 6.2305-1.4185 1.7928-3.7961 2.5154-5.9727 1.8164a0.50005 0.50005 0 1 0-0.30469 0.95117c2.5704 0.82539 5.3874-0.0294 7.0625-2.1465 0.4188-0.52924 0.74532-1.1114 0.97657-1.7207 0.69373-1.828 0.53599-3.9147-0.50977-5.6484-1.22-2.0227-3.4291-3.1977-5.7402-3.1289z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
|
||||
</g>
|
||||
<g id="g4244-6" transform="matrix(-.51357 -.54309 -.54309 .51357 734.02 601.73)">
|
||||
<path id="path4240-7" transform="matrix(.91922 .97205 -.97205 .91922 152.14 647.93)" d="m220.49 133.52c-0.33017 0.01-0.66239 0.0456-0.99414 0.10742-2.2249 0.41425-4.0267 1.9575-4.832 4.0098l-0.87696-1.8164a0.50005 0.50005 0 0 0-0.83398-0.11328 0.50005 0.50005 0 0 0-0.0664 0.54883l1.459 3.0195a0.50005 0.50005 0 0 0 0.60742 0.25586l2.8438-0.94532a0.50028 0.50028 0 1 0-0.31641-0.94922l-2.002 0.66797c0.61312-1.8902 2.2073-3.3241 4.2012-3.6953 2.2474-0.41845 4.5146 0.59912 5.6953 2.5566 1.1807 1.9575 1.022 4.4377-0.39648 6.2305-1.4185 1.7928-3.7961 2.5154-5.9727 1.8164a0.50005 0.50005 0 1 0-0.30469 0.95117c2.5704 0.82539 5.3874-0.0294 7.0625-2.1465 0.4188-0.52924 0.74532-1.1114 0.97657-1.7207 0.69373-1.828 0.53599-3.9147-0.50977-5.6484-1.22-2.0227-3.4291-3.1977-5.7402-3.1289z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
|
||||
</g>
|
||||
<g id="delete-vertex" transform="translate(-90,-64)">
|
||||
<path id="path4349-2-2-9-3-8-3-5" transform="translate(0 852.36)" d="m227.55 53.105-6.0547 3.0273h-3.4414v3.5176l-2.9512 5.9023 1.7891 0.89453 3.1562-6.3145h2.0059v-2.043l6.3906-3.1953z" color="#000000" color-rendering="auto" fill="#f2f2f2" image-rendering="auto" shape-rendering="auto" solid-color="#000000" stroke="#999" stroke-width=".25" style="isolation:auto;mix-blend-mode:normal;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-transform:none;white-space:normal"/>
|
||||
<path id="path4353-1-6-1-3-5-1-9" d="m217 907.36 6 6" fill="none" stroke="#fff" stroke-width="2.482"/>
|
||||
|
@ -143,6 +149,10 @@
|
|||
<path id="path580" d="m1.07 4.41h9.42c0.9234-0.0066 1.8391 0.16957 2.6941 0.5184 0.8551 0.34883 1.6327 0.8634 2.288 1.5141s1.1754 1.4246 1.5303 2.2771c0.3549 0.85256 0.5376 1.767 0.5376 2.6904 0.0067 0.9277-0.1712 1.8474-0.5231 2.7058-0.3519 0.8583-0.871 1.6382-1.527 2.2941-0.656 0.656-1.4358 1.1751-2.2941 1.527-0.8584 0.352-1.7781 0.5298-2.7058 0.5231h-9.42"/>
|
||||
<path id="path582" d="m4.75 8.45-4.04-4.05 4.04-4.05"/>
|
||||
</g>
|
||||
<g id="undo-5" transform="matrix(-.71301 0 0 .66261 90.012 938.13)" clip-path="url(#clip0_241_10857-3)" fill="none" stroke="#f2f2f2" stroke-miterlimit="10" stroke-width="1.4549">
|
||||
<path id="path580-3" d="m1.07 4.41h9.42c0.9234-0.0066 1.8391 0.16957 2.6941 0.5184 0.8551 0.34883 1.6327 0.8634 2.288 1.5141s1.1754 1.4246 1.5303 2.2771c0.3549 0.85256 0.5376 1.767 0.5376 2.6904 0.0067 0.9277-0.1712 1.8474-0.5231 2.7058-0.3519 0.8583-0.871 1.6382-1.527 2.2941-0.656 0.656-1.4358 1.1751-2.2941 1.527-0.8584 0.352-1.7781 0.5298-2.7058 0.5231h-9.42"/>
|
||||
<path id="path582-5" d="m4.75 8.45-4.04-4.05 4.04-4.05"/>
|
||||
</g>
|
||||
<g id="g1" transform="translate(144 -24)" fill="none" stroke="#999">
|
||||
<path id="path438" d="m9 849.94v4.05c0 0.20708 0.1679 0.375 0.375 0.375h5.25c0.20708 0 0.375-0.16792 0.375-0.375v-4.05c0-0.20708-0.16792-0.375-0.375-0.375h-5.25c-0.2071 0-0.375 0.16792-0.375 0.375z"/>
|
||||
<path id="save" d="m15.213 842.36h-8.8376c-0.2071 0-0.375 0.1679-0.375 0.37499v11.25c0 0.20708 0.1679 0.375 0.375 0.375h11.25c0.20708 0 0.375-0.16792 0.375-0.375v-8.6766c0-0.0953-0.0363-0.18697-0.1014-0.25648l-2.4124-2.5733c-0.07095-0.0756-0.16995-0.11853-0.2736-0.11853z"/>
|
||||
|
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 28 KiB |
|
@ -21,8 +21,11 @@
|
|||
<clipPath id="clip0_3071_861">
|
||||
<rect width="18" height="20" fill="#ffffff" id="rect3" x="0" y="0" />
|
||||
</clipPath>
|
||||
<clipPath id="clip0_241_10857-3">
|
||||
<rect width="18.049999" height="19.01" fill="#ffffff" id="rect586-6" x="0" y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="22.311152" inkscape:cx="196.69536" inkscape:cy="36.932203" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" inkscape:window-width="1920" inkscape:window-height="1011" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" showguides="true" inkscape:guide-bbox="true" inkscape:snap-grids="true" inkscape:snap-to-guides="true" inkscape:showpageshadow="2" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1">
|
||||
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="4.4320814" inkscape:cx="116.08541" inkscape:cy="109.65503" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" inkscape:window-width="1920" inkscape:window-height="1011" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" showguides="true" inkscape:guide-bbox="true" inkscape:snap-grids="true" inkscape:snap-to-guides="true" inkscape:showpageshadow="2" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1">
|
||||
<inkscape:grid type="xygrid" id="grid3004" empspacing="4" visible="true" enabled="true" snapvisiblegridlinesonly="true" originx="0" originy="0" spacingy="1" spacingx="1" units="px" />
|
||||
<inkscape:grid id="grid1" units="px" originx="0" originy="0" spacingx="24" spacingy="24" empcolor="#203fff" empopacity="0.85490196" color="#3f3fff" opacity="0.1254902" empspacing="1" enabled="true" visible="true" />
|
||||
</sodipodi:namedview>
|
||||
|
@ -78,9 +81,12 @@
|
|||
</g>
|
||||
</g>
|
||||
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:evenodd;stroke:#999999;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 108.14555,816.36218 v 3.8267 h 3.85445 v -3.8267 z m 0.51755,4.35174 -1.24591,2.31321 1.16687,0.61848 1.24404,-2.31507 z m -1.86888,3.47168 -0.27666,0.51571 h -2.42597 v 2.24408 l -4.09159,4.06399 1.36261,1.3528 3.86198,-3.83417 h 2.72145 v -3.6959 l 0.015,-0.028 -0.015,-0.008 v -0.0953 h -0.17879 l -0.97303,-0.51571 z" id="path4873" inkscape:connector-curvature="0" />
|
||||
<g id="g4244" transform="matrix(0.51357238,-0.54309229,0.54309229,0.51357238,-518.0199,506.22551)">
|
||||
<g id="g4244" transform="matrix(0.51357238,-0.54309229,0.54309229,0.51357238,-590.0195,601.72586)">
|
||||
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 220.49219,133.52344 c -0.33017,0.01 -0.66239,0.0456 -0.99414,0.10742 -2.22487,0.41425 -4.02666,1.95747 -4.83203,4.00976 l -0.87696,-1.8164 a 0.50004998,0.50004998 0 0 0 -0.83398,-0.11328 0.50004998,0.50004998 0 0 0 -0.0664,0.54883 l 1.45899,3.01953 a 0.50004998,0.50004998 0 0 0 0.60742,0.25586 l 2.84375,-0.94532 a 0.50028339,0.50028339 0 1 0 -0.31641,-0.94922 l -2.00195,0.66797 c 0.61312,-1.89015 2.20733,-3.32407 4.20117,-3.69531 2.24744,-0.41845 4.51458,0.59912 5.69531,2.55664 1.18073,1.95754 1.02202,4.43774 -0.39648,6.23047 -1.41851,1.79275 -3.79606,2.51535 -5.97266,1.81641 a 0.50004998,0.50004998 0 1 0 -0.30469,0.95117 c 2.57038,0.82539 5.38736,-0.0294 7.0625,-2.14649 0.4188,-0.52924 0.74532,-1.11137 0.97657,-1.7207 0.69373,-1.828 0.53599,-3.91467 -0.50977,-5.64844 -1.22005,-2.02271 -3.42908,-3.19767 -5.74023,-3.1289 z" transform="matrix(0.91921787,0.9720541,-0.9720541,0.91921787,152.1356,647.93271)" id="path4240" inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
<g id="g4244-6" transform="matrix(-0.51357238,-0.54309229,-0.54309229,0.51357238,734.0195,601.72586)">
|
||||
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 220.49219,133.52344 c -0.33017,0.01 -0.66239,0.0456 -0.99414,0.10742 -2.22487,0.41425 -4.02666,1.95747 -4.83203,4.00976 l -0.87696,-1.8164 a 0.50004998,0.50004998 0 0 0 -0.83398,-0.11328 0.50004998,0.50004998 0 0 0 -0.0664,0.54883 l 1.45899,3.01953 a 0.50004998,0.50004998 0 0 0 0.60742,0.25586 l 2.84375,-0.94532 a 0.50028339,0.50028339 0 1 0 -0.31641,-0.94922 l -2.00195,0.66797 c 0.61312,-1.89015 2.20733,-3.32407 4.20117,-3.69531 2.24744,-0.41845 4.51458,0.59912 5.69531,2.55664 1.18073,1.95754 1.02202,4.43774 -0.39648,6.23047 -1.41851,1.79275 -3.79606,2.51535 -5.97266,1.81641 a 0.50004998,0.50004998 0 1 0 -0.30469,0.95117 c 2.57038,0.82539 5.38736,-0.0294 7.0625,-2.14649 0.4188,-0.52924 0.74532,-1.11137 0.97657,-1.7207 0.69373,-1.828 0.53599,-3.91467 -0.50977,-5.64844 -1.22005,-2.02271 -3.42908,-3.19767 -5.74023,-3.1289 z" transform="matrix(0.91921787,0.9720541,-0.9720541,0.91921787,152.1356,647.93271)" id="path4240-7" inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
<g id="delete-vertex" transform="translate(-90,-64)">
|
||||
<path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:#999999;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 227.55273,53.105469 -6.05468,3.027343 h -3.44141 v 3.517579 l -2.95117,5.902343 1.78906,0.894532 3.15625,-6.314454 h 2.00586 v -2.042968 l 6.39063,-3.195313 z" transform="translate(0,852.36218)" id="path4349-2-2-9-3-8-3-5" inkscape:connector-curvature="0" />
|
||||
<path sodipodi:nodetypes="cc" style="fill:none;stroke:#ffffff;stroke-width:2.482;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 217,907.36218 6,6" id="path4353-1-6-1-3-5-1-9" inkscape:connector-curvature="0" />
|
||||
|
@ -154,6 +160,10 @@
|
|||
<path d="m 1.07001,4.41003 h 9.41999 c 0.9234,-0.0066 1.8391,0.16957 2.6941,0.5184 0.8551,0.34883 1.6327,0.8634 2.288,1.51407 0.6553,0.65067 1.1754,1.42458 1.5303,2.27713 0.3549,0.85256 0.5376,1.76697 0.5376,2.69037 0.0067,0.9277 -0.1712,1.8474 -0.5231,2.7058 -0.3519,0.8583 -0.871,1.6382 -1.527,2.2941 -0.656,0.656 -1.4358,1.1751 -2.2941,1.527 -0.8584,0.352 -1.7781,0.5298 -2.7058,0.5231 h -9.41999" stroke="#f2f2f2" stroke-miterlimit="10" id="path580" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path d="m 4.75002,8.44998 -4.039998,-4.05 4.039998,-4.050004" stroke="#f2f2f2" stroke-miterlimit="10" id="path582" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
<g clip-path="url(#clip0_241_10857-3)" id="undo-5" transform="matrix(-0.71300568,0,0,0.66260978,90.012499,938.13028)" style="fill:none;stroke:#f2f2f2;stroke-width:1.45488;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1">
|
||||
<path d="m 1.07001,4.41003 h 9.41999 c 0.9234,-0.0066 1.8391,0.16957 2.6941,0.5184 0.8551,0.34883 1.6327,0.8634 2.288,1.51407 0.6553,0.65067 1.1754,1.42458 1.5303,2.27713 0.3549,0.85256 0.5376,1.76697 0.5376,2.69037 0.0067,0.9277 -0.1712,1.8474 -0.5231,2.7058 -0.3519,0.8583 -0.871,1.6382 -1.527,2.2941 -0.656,0.656 -1.4358,1.1751 -2.2941,1.527 -0.8584,0.352 -1.7781,0.5298 -2.7058,0.5231 h -9.41999" stroke="#f2f2f2" stroke-miterlimit="10" id="path580-3" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path d="m 4.75002,8.44998 -4.039998,-4.05 4.039998,-4.050004" stroke="#f2f2f2" stroke-miterlimit="10" id="path582-5" style="fill:none;stroke:#f2f2f2;stroke-width:1.45486;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
<g id="g1" transform="translate(144,-24.00004)">
|
||||
<path d="m 9,849.93721 v 4.04997 c 0,0.20708 0.167895,0.375 0.375,0.375 h 5.25 c 0.207075,0 0.375,-0.16792 0.375,-0.375 v -4.04997 c 0,-0.20708 -0.167925,-0.375 -0.375,-0.375 h -5.25 c -0.207105,0 -0.375,0.16792 -0.375,0.375 z" stroke="#f2f2f2" id="path438" style="fill:none;stroke:#999999;stroke-width:0.999997;stroke-opacity:1" />
|
||||
<path d="m 15.21255,842.36218 h -8.83755 c -0.207105,0 -0.375,0.1679 -0.375,0.37499 v 11.24993 c 0,0.20708 0.167895,0.375 0.375,0.375 h 11.25 c 0.207075,0 0.375,-0.16792 0.375,-0.375 v -8.67664 c 0,-0.0953 -0.0363,-0.18697 -0.1014,-0.25648 l -2.41245,-2.57327 c -0.07095,-0.0756 -0.16995,-0.11853 -0.2736,-0.11853 z" stroke="#f2f2f2" id="save" style="fill:none;stroke:#999999;stroke-width:0.999997;stroke-opacity:1" />
|
||||
|
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 50 KiB |
1
umap/static/umap/img/tags/arts.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M2 1S1 1 1 2v5.158C1 8.888 1.354 11 4.5 11H5V8L2.5 9s0-2.5 2.5-2.5V5c0-.708.087-1.32.5-1.775.381-.42 1.005-1.258 2.656-.471L9 3.303V2s0-1-1-1c-.708 0-1.978 1-3 1S2.787 1 2 1zm1 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm4 1S6 4 6 5v5c0 2 1 4 4 4s4-2 4-4V5c0-1-1-1-1-1-.708 0-1.978 1-3 1S7.787 4 7 4zm1 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm-4.5 4h5s0 2.5-2.5 2.5S7.5 10 7.5 10z"/></svg>
|
After Width: | Height: | Size: 472 B |
1
umap/static/umap/img/tags/business.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M4.5 4V2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2H12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h1.5Zm1-2v2h4V2h-4Z"/></svg>
|
After Width: | Height: | Size: 205 B |
1
umap/static/umap/img/tags/cycling.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M10 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM8.145 2.994a.5.5 0 0 0-.348.143l-2.64 2.5a.5.5 0 0 0 .042.763L7 7.75v2.75c-.01.676 1.01.676 1 0v-3a.5.5 0 0 0-.2-.4l-.767-.577 1.818-1.72.749.998A.5.5 0 0 0 10 6h1.5c.676.01.676-1.01 0-1h-1.25L9.5 4l-.6-.8a.5.5 0 0 0-.384-.206h-.371zM3 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm9 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM3 8a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm9 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>
|
After Width: | Height: | Size: 481 B |
1
umap/static/umap/img/tags/education.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="M10 0v15H0V0h10zM9 1H1v13h8V1zM2.5 4h5c.5 0 .5 1 0 1h-5C2 5 2 4 2.5 4zm0 2h5c.5 0 .5 1 0 1h-5C2 7 2 6 2.5 6zm0 2h5c.5 0 .5 1 0 1h-5C2 9 2 8 2.5 8zm0 2h5c.5 0 .5 1 0 1h-5c-.5 0-.5-1 0-1zM11 13c.5.5 2.5.5 3 0 0 0-1 2-1.5 2S11 13 11 13zm0-10c0 .5 3 .5 3 0v9c0 .5-3 .5-3 0V3zm1.5-3C11 0 11 .5 11 1v1c0 .5 3 .5 3 0V1c0-.5 0-1-1.5-1z"/></svg>
|
After Width: | Height: | Size: 405 B |
1
umap/static/umap/img/tags/environment.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M2.456 8.613c-.338.598-.955 1.69.137 2.418.343.227.728.384 1.131.462.307.045.323.518-.038.507-.385-.02-2.26-.193-2.561-1.6-.156-.82.02-1.557.504-2.355l.697-1.233-1.306-.743L4.5 4v4l-1.306-.694-.738 1.307zM6.7 2.034c1.155-.628 1.823.43 2.191 1.007l.806 1.263-1.266.808L12 6.986l-.197-4.026-1.264.807-.76-1.189c-.522-.746-.904-1.297-1.835-1.545C6.307.72 5.301 2.619 5.311 2.607c-.164.287.216.54.451.21.258-.32.577-.586.938-.783zm6.594 6.187c-.088-.19-.549-.141-.419.267.131.39.184.8.157 1.21C12.939 11.01 11.684 11 11 11H9.5V9.5l-3.5 2 3.488 2.025L9.493 12H11c.89.015 1.6-.176 2.2-.713 1.2-1.061.094-3.066.094-3.066z"/></svg>
|
After Width: | Height: | Size: 695 B |
1
umap/static/umap/img/tags/food.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="m3.5 0-1 5.5c-.146.805 1.782 1.181 1.75 2L4 14c-.038 1 1 1 1 1s1.038 0 1-1l-.25-6.5c-.031-.818 1.733-1.18 1.75-2L6.5 0H6l.25 4-.75.5L5.25 0h-.5L4.5 4.5 3.75 4 4 0h-.5zM12 0c-.736 0-1.964.655-2.455 1.637C9.135 2.373 9 4.018 9 5v2.5c0 .818 1.09 1 1.5 1L10 14c-.09.996 1 1 1 1s1 0 1-1V0z"/></svg>
|
After Width: | Height: | Size: 365 B |
1
umap/static/umap/img/tags/geopolitics.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M6.65 2C5.43 2 4.48 3.38 4.11 3.82a.49.49 0 0 0-.11.32v4.4a.44.44 0 0 0 .72.36 3 3 0 0 1 1.93-1.17C8.06 7.73 8.6 9 10.07 9a5.28 5.28 0 0 0 2.73-1.09.49.49 0 0 0 .2-.4V2.45a.44.44 0 0 0-.62-.45 5.75 5.75 0 0 1-2.31 1.06C8.6 3.08 8.12 2 6.65 2zM2.5 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2zM3 4v9.48a.5.5 0 0 1-1 0V4a.5.5 0 0 1 1 0z"/></svg>
|
After Width: | Height: | Size: 400 B |
1
umap/static/umap/img/tags/health.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M7 1c-.6 0-1 .4-1 1v4H2c-.6 0-1 .4-1 1v1c0 .6.4 1 1 1h4v4c0 .6.4 1 1 1h1c.6 0 1-.4 1-1V9h4c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1H9V2c0-.6-.4-1-1-1H7z"/></svg>
|
After Width: | Height: | Size: 222 B |
1
umap/static/umap/img/tags/hiking.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="tiny" overflow="inherit" version="1.2" viewBox="0 0 50 50"><path d="M6.43 21.55c-.22.45-.37.94-.46 1.47-.04.26-.06.53-.06.79l.19 12.13-4.2 9.02c-.14.27-.25.58-.3.9-.23 1.49.8 2.88 2.3 3.11 1.16.18 2.27-.41 2.8-1.39l4.63-9.86c.1-.23.18-.47.22-.73l.03-.37-.04-7.5 7.11 3.09 1.15 7.33a2.732 2.732 0 0 0 2.26 2.11c1.5.22 2.89-.81 3.12-2.28.04-.25.04-.51.01-.75l-1.4-8.84c-.17-.85-.74-1.59-1.53-1.96l-6.35-2.81L19.96 18l2.01 2.54c.21.23.47.42.77.54l7.65 2.23a2.14 2.14 0 0 0 2.45-1.29 2.14 2.14 0 0 0-1.18-2.8l-.11-.04-6.64-1.95-5-5.98A5.079 5.079 0 0 0 17 9.71c-2.03-.3-3.96.66-4.97 2.3l-5.56 9.57zm21.94 17.38-.48 3.63-13.16 3.19.13 2.25h32.09c1.14 0 2.06-.91 2.06-2.04l-.04-43.07-4.4-1.02-2.53 11.32-4.22 1.77-3.74 10.44 3.56 7.99-1 3.07-8.26 2.48zM19.44 9.15c2.26 0 4.1-1.83 4.1-4.08S21.7.99 19.45.99s-4.1 1.83-4.1 4.08 1.84 4.08 4.1 4.08zm-8.15.64c.31-.55.13-1.27-.43-1.59L8.88 7.05c-.57-.31-1.28-.13-1.61.43l-6.11 10.5c-.31.55-.13 1.26.43 1.59l1.99 1.14c.56.32 1.27.13 1.59-.42l6.11-10.5z"/></svg>
|
After Width: | Height: | Size: 1 KiB |
1
umap/static/umap/img/tags/history.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M7.5 0 1 3.5V4h13v-.5L7.5 0zM2 5v5l-1 1.6V13h13v-1.4L13 10V5H2zm2 1h1v5.5H4V6zm3 0h1v5.5H7V6zm3 0h1v5.5h-1V6z"/></svg>
|
After Width: | Height: | Size: 190 B |
1
umap/static/umap/img/tags/public.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="M9 14v-3H6v3H1V6.5c0-.28.22-.5.5-.5H4v-.5c0-.28.22-.5.5-.5H7V1l2-1 2 1 2-1v3l-2 1-2-1-1 .5V5h2.5c.28 0 .5.22.5.5V6h2.5c.28 0 .5.22.5.5V14H9Zm3-6v2h1V8h-1Zm-2 0v2h1V8h-1Zm0 3v2h1v-2h-1Zm2 0v2h1v-2h-1Zm-8 0v2h1v-2H4Zm-2 0v2h1v-2H2Zm6-3v2h1V8H8ZM6 8v2h1V8H6ZM4 8v2h1V8H4ZM2 8v2h1V8H2Z"/></svg>
|
After Width: | Height: | Size: 359 B |
1
umap/static/umap/img/tags/science.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="m8.34 6.15 3.62 8.15a.501.501 0 1 1-.92.4L9.84 12H5.16l-1.2 2.7a.501.501 0 1 1-.92-.4l3.19-7.17-1.74.81-1.27-2.71 6.79-3.17 1.27 2.71-2.94 1.38Zm-.84.58L5.6 11h3.8L7.5 6.73Zm2.76-5.34L12.98.12l1.69 3.63-2.72 1.27-1.69-3.63Zm-10 5.77 2.72-1.27.84 1.81L1.1 8.97.26 7.16Z"/></svg>
|
After Width: | Height: | Size: 346 B |
1
umap/static/umap/img/tags/shopping.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M13.33 5H11.5l-.39-2.33A2 2 0 0 0 9.7 1.18 3.76 3.76 0 0 0 8.62 1H6.38a3.76 3.76 0 0 0-1.08.18 2 2 0 0 0-1.41 1.49L3.5 5H1.67a.5.5 0 0 0-.48.65l1.88 6.3A1.5 1.5 0 0 0 4.5 13h6a1.5 1.5 0 0 0 1.42-1.05l1.88-6.3a.5.5 0 0 0-.47-.65zM4.52 5l.36-2.17a.91.91 0 0 1 .74-.7c.246-.078.502-.121.76-.13h2.24c.261.008.52.051.77.13a.91.91 0 0 1 .74.7L10.48 5h-6z"/></svg>
|
After Width: | Height: | Size: 429 B |
1
umap/static/umap/img/tags/sport.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="M11.968 10.227a3.812 3.812 0 0 1-1.913.984L3.768 4.934a4.028 4.028 0 0 1 1.005-1.902C7.03.774 9.98.224 12.378 2.622s1.848 5.348-.41 7.605Zm-6.987 1.61a3.842 3.842 0 0 1 1.168-.559A4.533 4.533 0 0 1 8 11.445L3.546 7a4.413 4.413 0 0 1 .157 1.922 3.664 3.664 0 0 1-.521 1.116C2.11 11.301 1.05 11.765 1.05 12.226a1.838 1.838 0 0 0 1.724 1.724c.46 0 .918-1.013 2.207-2.113Z"/></svg>
|
After Width: | Height: | Size: 449 B |
1
umap/static/umap/img/tags/tourism.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15"><path d="m5.36 1.67-.01 4.02a4.452 4.452 0 0 0-1.1-.11c-.37.1-.74.63-1.1.76a4.202 4.202 0 0 1 2.21-4.67Zm2.41-.64L9.8 4.48a3.183 3.183 0 0 1 .84-.61c.36-.1.94.17 1.34.11a4.202 4.202 0 0 0-4.21-2.95ZM1 13h13c-.66-.66-2.64-1.11-4.34-1.33l-1.87-7c.52-.05 1.15.03 1.53 0l-2.11-3.6H7.2a6.174 6.174 0 0 0-.7.14 4.38 4.38 0 0 0-.64.22l-.01 4.15c.35-.17.84-.54 1.3-.74l1.8 6.74c-.58-.05-1.09-.08-1.45-.08C6.03 11.5 2 12 1 13Z"/></svg>
|
After Width: | Height: | Size: 489 B |
1
umap/static/umap/img/tags/transports.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="M8 1v1h1.5l1.41 1.41c.38.38.59.89.59 1.42V11c0 1.1-.9 2-2 2h-4c-1.1 0-2-.9-2-2V4.83c0-.53.21-1.04.59-1.42L5.5 2H7V1H5.5C5.22 1 5 .78 5 .5s.22-.5.5-.5h4c.28 0 .5.22.5.5s-.22.5-.5.5H8ZM6.25 13.5 5.5 15H4l.75-1.5h1.5Zm4 0L11 15H9.5l-.75-1.5h1.5ZM8.5 12h1c.55 0 1-.45 1-1v-1c-1.1 0-2 .9-2 2Zm-2 0c0-1.1-.9-2-2-2v1c0 .55.45 1 1 1h1Zm-2-6.5v3c0 .28.22.5.5.5h5c.28 0 .5-.22.5-.5v-3c0-.28-.22-.5-.5-.5H5c-.28 0-.5.22-.5.5Zm1-2c0 .28.22.5.5.5h3c.28 0 .5-.22.5-.5S9.28 3 9 3H6c-.28 0-.5.22-.5.5Z"/></svg>
|
After Width: | Height: | Size: 563 B |
1
umap/static/umap/img/tags/travel.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M3.98 36h12.04a.98.98 0 0 0 .98-.98V17a.98.98 0 0 0-1.8-.54L3.17 34.48A.98.98 0 0 0 3.98 36ZM20 36h23.99A1 1 0 0 0 45 34.98C44.58 16.6 32.84.76 20.03 0A.99.99 0 0 0 19 1v34a1 1 0 0 0 1 1ZM46 39H2a2 2 0 0 0-2 2 7 7 0 0 0 7 7h34a7 7 0 0 0 7-7 2 2 0 0 0-2-2Z"/></svg>
|
After Width: | Height: | Size: 333 B |
|
@ -91,6 +91,7 @@ class Feature {
|
|||
}
|
||||
|
||||
set geometry(value) {
|
||||
this._geometry_bk = Utils.CopyJSON(this._geometry)
|
||||
this._geometry = value
|
||||
this.pushGeometry()
|
||||
}
|
||||
|
@ -104,13 +105,15 @@ class Feature {
|
|||
}
|
||||
|
||||
pullGeometry(sync = true) {
|
||||
const oldGeometry = Utils.CopyJSON(this._geometry)
|
||||
this.fromLatLngs(this._getLatLngs())
|
||||
if (sync) {
|
||||
this.sync.update('geometry', this.geometry)
|
||||
this.sync.update('geometry', this.geometry, oldGeometry)
|
||||
}
|
||||
}
|
||||
|
||||
fromLatLngs(latlngs) {
|
||||
this._geometry_bk = Utils.CopyJSON(this._geometry)
|
||||
this._geometry = this.convertLatLngs(latlngs)
|
||||
}
|
||||
|
||||
|
@ -145,8 +148,15 @@ class Feature {
|
|||
onCommit() {
|
||||
// When the layer is a remote layer, we don't want to sync the creation of the
|
||||
// points via the websocket, as the other peers will get them themselves.
|
||||
const oldGeoJSON = this._just_married ? null : Utils.CopyJSON(this.toGeoJSON())
|
||||
this.pullGeometry(false)
|
||||
if (this.datalayer?.isRemoteLayer()) return
|
||||
this.sync.upsert(this.toGeoJSON())
|
||||
if (this._just_married) {
|
||||
this.sync.upsert(this.toGeoJSON(), null)
|
||||
this._just_married = false
|
||||
} else {
|
||||
this.sync.update('geometry', this.geometry, this._geometry_bk)
|
||||
}
|
||||
}
|
||||
|
||||
isReadOnly() {
|
||||
|
|
|
@ -16,7 +16,6 @@ import { Default as DefaultLayer } from '../rendering/layers/base.js'
|
|||
import { Categorized, Choropleth, Circles } from '../rendering/layers/classified.js'
|
||||
import { Cluster } from '../rendering/layers/cluster.js'
|
||||
import { Heat } from '../rendering/layers/heat.js'
|
||||
import { ServerStored } from '../saving.js'
|
||||
import * as Schema from '../schema.js'
|
||||
import TableEditor from '../tableeditor.js'
|
||||
import * as Utils from '../utils.js'
|
||||
|
@ -36,9 +35,8 @@ const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
|
|||
return acc
|
||||
}, {})
|
||||
|
||||
export class DataLayer extends ServerStored {
|
||||
export class DataLayer {
|
||||
constructor(umap, leafletMap, data = {}) {
|
||||
super()
|
||||
this._umap = umap
|
||||
this.sync = umap.syncEngine.proxy(this)
|
||||
this._index = Array()
|
||||
|
@ -49,7 +47,6 @@ export class DataLayer extends ServerStored {
|
|||
this._leafletMap = leafletMap
|
||||
this.parentPane = this._leafletMap.getPane('overlayPane')
|
||||
this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane)
|
||||
this.pane.dataset.id = stamp(this)
|
||||
// FIXME: should be on layer
|
||||
this.renderer = L.svg({ pane: this.pane })
|
||||
this.defaultOptions = {
|
||||
|
@ -66,6 +63,7 @@ export class DataLayer extends ServerStored {
|
|||
data.id = data.id || crypto.randomUUID()
|
||||
|
||||
this.setOptions(data)
|
||||
this.pane.dataset.id = this.id
|
||||
|
||||
if (!Utils.isObject(this.options.remoteData)) {
|
||||
this.options.remoteData = {}
|
||||
|
@ -114,7 +112,6 @@ export class DataLayer extends ServerStored {
|
|||
|
||||
set isDeleted(status) {
|
||||
this._isDeleted = status
|
||||
if (status) this.isDirty = status
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
|
@ -269,13 +266,11 @@ export class DataLayer extends ServerStored {
|
|||
}
|
||||
|
||||
clear() {
|
||||
this.layer.clearLayers()
|
||||
this._features = {}
|
||||
this._index = Array()
|
||||
if (this._geojson) {
|
||||
this.backupData()
|
||||
this._geojson = null
|
||||
this.sync.startBatch()
|
||||
for (const feature of Object.values(this._features)) {
|
||||
feature.del()
|
||||
}
|
||||
this.sync.commitBatch()
|
||||
this.dataChanged()
|
||||
}
|
||||
|
||||
|
@ -366,9 +361,8 @@ export class DataLayer extends ServerStored {
|
|||
}
|
||||
|
||||
connectToMap() {
|
||||
const id = stamp(this)
|
||||
if (!this._umap.datalayers[id]) {
|
||||
this._umap.datalayers[id] = this
|
||||
if (!this._umap.datalayers[this.id]) {
|
||||
this._umap.datalayers[this.id] = this
|
||||
}
|
||||
if (!this._umap.datalayersIndex.includes(this)) {
|
||||
this._umap.datalayersIndex.push(this)
|
||||
|
@ -417,7 +411,10 @@ export class DataLayer extends ServerStored {
|
|||
|
||||
removeFeature(feature, sync) {
|
||||
const id = stamp(feature)
|
||||
if (sync !== false) feature.sync.delete()
|
||||
if (sync !== false) {
|
||||
const oldValue = feature.toGeoJSON()
|
||||
feature.sync.delete(oldValue)
|
||||
}
|
||||
this.hideFeature(feature)
|
||||
delete this._umap.featuresIndex[feature.getSlug()]
|
||||
feature.disconnectFromDataLayer(this)
|
||||
|
@ -460,7 +457,10 @@ export class DataLayer extends ServerStored {
|
|||
try {
|
||||
// Do not fail if remote data is somehow invalid,
|
||||
// otherwise the layer becomes uneditable.
|
||||
return this.makeFeatures(geojson, sync)
|
||||
this.sync.startBatch()
|
||||
const features = this.makeFeatures(geojson, sync)
|
||||
this.sync.commitBatch()
|
||||
return features
|
||||
} catch (err) {
|
||||
console.debug('Error with DataLayer', this.id)
|
||||
console.error(err)
|
||||
|
@ -518,7 +518,7 @@ export class DataLayer extends ServerStored {
|
|||
}
|
||||
if (feature && !feature.isEmpty()) {
|
||||
this.addFeature(feature)
|
||||
if (sync) feature.onCommit()
|
||||
if (sync) feature.sync.upsert(feature.toGeoJSON(), null)
|
||||
return feature
|
||||
}
|
||||
}
|
||||
|
@ -527,10 +527,6 @@ export class DataLayer extends ServerStored {
|
|||
return this._umap.formatter
|
||||
.parse(raw, format)
|
||||
.then((geojson) => this.addData(geojson))
|
||||
.then((data) => {
|
||||
if (data?.length) this.isDirty = true
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(error)
|
||||
Alert.error(translate('Import failed: invalid data'))
|
||||
|
@ -596,17 +592,17 @@ export class DataLayer extends ServerStored {
|
|||
}
|
||||
|
||||
del(sync = true) {
|
||||
const oldValue = Utils.CopyJSON(this.umapGeoJSON())
|
||||
this.erase()
|
||||
if (sync) {
|
||||
this.isDeleted = true
|
||||
this.sync.delete()
|
||||
this.sync.delete(oldValue)
|
||||
}
|
||||
}
|
||||
|
||||
empty() {
|
||||
if (this.isRemoteLayer()) return
|
||||
this.clear()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
clone() {
|
||||
|
@ -630,25 +626,6 @@ export class DataLayer extends ServerStored {
|
|||
this.clear()
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.createdOnServer) {
|
||||
this.erase()
|
||||
return
|
||||
}
|
||||
|
||||
this.resetOptions()
|
||||
this.parentPane.appendChild(this.pane)
|
||||
if (this._leaflet_events_bk && !this._leaflet_events) {
|
||||
this._leaflet_events = this._leaflet_events_bk
|
||||
}
|
||||
this.clear()
|
||||
this.hide()
|
||||
if (this.isRemoteLayer()) this.fetchRemoteData()
|
||||
else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk)
|
||||
this.show()
|
||||
this.isDirty = false
|
||||
}
|
||||
|
||||
redraw() {
|
||||
if (!this.isVisible()) return
|
||||
this.eachFeature((feature) => feature.redraw())
|
||||
|
@ -940,11 +917,14 @@ export class DataLayer extends ServerStored {
|
|||
)
|
||||
if (!error) {
|
||||
if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat.
|
||||
if (geojson._umap_options) this.setOptions(geojson._umap_options)
|
||||
if (geojson._umap_options) {
|
||||
const oldOptions = Utils.CopyJSON(this.options)
|
||||
this.setOptions(geojson._umap_options)
|
||||
this.sync.update('options', this.options, oldOptions)
|
||||
}
|
||||
this.empty()
|
||||
if (this.isRemoteLayer()) this.fetchRemoteData()
|
||||
else this.addData(geojson)
|
||||
this.isDirty = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -1098,7 +1078,11 @@ export class DataLayer extends ServerStored {
|
|||
|
||||
setReferenceVersion({ response, sync }) {
|
||||
this._referenceVersion = response.headers.get('X-Datalayer-Version')
|
||||
if (sync) this.sync.update('_referenceVersion', this._referenceVersion)
|
||||
if (sync) {
|
||||
this.sync.update('_referenceVersion', this._referenceVersion, null, {
|
||||
undo: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
|
@ -1127,6 +1111,10 @@ export class DataLayer extends ServerStored {
|
|||
}
|
||||
|
||||
async _trySave(url, headers, formData) {
|
||||
if (this._forceSave) {
|
||||
headers = {}
|
||||
this._forceSave = false
|
||||
}
|
||||
const [data, response, error] = await this._umap.server.post(url, headers, formData)
|
||||
if (error) {
|
||||
if (response && response.status === 412) {
|
||||
|
@ -1136,16 +1124,9 @@ export class DataLayer extends ServerStored {
|
|||
'This situation is tricky, you have to choose carefully which version is pertinent.'
|
||||
),
|
||||
async () => {
|
||||
// Save again this layer
|
||||
const status = await this._trySave(url, {}, formData)
|
||||
if (status) {
|
||||
this.isDirty = false
|
||||
|
||||
// Call the main save, in case something else needs to be saved
|
||||
// as the conflict stopped the saving flow
|
||||
this._forceSave = true
|
||||
await this._umap.saveAll()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
@ -1179,7 +1160,7 @@ export class DataLayer extends ServerStored {
|
|||
}
|
||||
|
||||
commitDelete() {
|
||||
delete this._umap.datalayers[stamp(this)]
|
||||
delete this._umap.datalayers[this.id]
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
|
|
@ -135,7 +135,13 @@ export default class Facets {
|
|||
for (const [property, { label, type }] of parsed) {
|
||||
dumped.push([property, label, type].filter(Boolean).join('|'))
|
||||
}
|
||||
return dumped.join(',')
|
||||
const oldValue = this._umap.properties.facetKey
|
||||
this._umap.properties.facetKey = dumped.join(',')
|
||||
this._umap.sync.update(
|
||||
'properties.facetKey',
|
||||
this._umap.properties.facetKey,
|
||||
oldValue
|
||||
)
|
||||
}
|
||||
|
||||
has(property) {
|
||||
|
@ -146,15 +152,13 @@ export default class Facets {
|
|||
const defined = this.getDefined()
|
||||
if (!defined.has(property)) {
|
||||
defined.set(property, { label, type })
|
||||
this._umap.properties.facetKey = this.dumps(defined)
|
||||
this._umap.isDirty = true
|
||||
this.dumps(defined)
|
||||
}
|
||||
}
|
||||
|
||||
remove(property) {
|
||||
const defined = this.getDefined()
|
||||
defined.delete(property)
|
||||
this._umap.properties.facetKey = this.dumps(defined)
|
||||
this._umap.isDirty = true
|
||||
this.dumps(defined)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,21 +70,7 @@ export class Form extends Utils.WithEvents {
|
|||
}
|
||||
|
||||
setter(field, value) {
|
||||
const path = field.split('.')
|
||||
let obj = this.obj
|
||||
let what
|
||||
for (let i = 0, l = path.length; i < l; i++) {
|
||||
what = path[i]
|
||||
if (what === path[l - 1]) {
|
||||
if (typeof value === 'undefined') {
|
||||
delete obj[what]
|
||||
} else {
|
||||
obj[what] = value
|
||||
}
|
||||
} else {
|
||||
obj = obj[what]
|
||||
}
|
||||
}
|
||||
Utils.setObjectValue(this.obj, field, value)
|
||||
}
|
||||
|
||||
restoreField(field) {
|
||||
|
@ -141,6 +127,7 @@ export class MutatingForm extends Form {
|
|||
facetKey: 'PropertyInput',
|
||||
slugKey: 'PropertyInput',
|
||||
labelKey: 'PropertyInput',
|
||||
tags: 'TagsEditor',
|
||||
}
|
||||
for (const [key, defaults] of Object.entries(SCHEMA)) {
|
||||
const properties = Object.assign({}, defaults)
|
||||
|
@ -152,6 +139,8 @@ export class MutatingForm extends Form {
|
|||
} else if (properties.type === Number) {
|
||||
if (properties.step) properties.handler = 'Range'
|
||||
else properties.handler = 'IntInput'
|
||||
} else if (properties.type === Array) {
|
||||
properties.handler = 'CheckBoxes'
|
||||
} else if (properties.choices) {
|
||||
const text_length = properties.choices.reduce(
|
||||
(acc, [_, label]) => acc + label.length,
|
||||
|
@ -190,13 +179,17 @@ export class MutatingForm extends Form {
|
|||
}
|
||||
|
||||
setter(field, value) {
|
||||
const oldValue = this.getter(field)
|
||||
if ('setter' in this.obj) {
|
||||
this.obj.setter(field, value)
|
||||
} else {
|
||||
super.setter(field, value)
|
||||
this.obj.isDirty = true
|
||||
}
|
||||
if ('render' in this.obj) {
|
||||
this.obj.render([field], this)
|
||||
}
|
||||
if ('sync' in this.obj) {
|
||||
this.obj.sync.update(field, value)
|
||||
this.obj.sync.update(field, value, oldValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -324,6 +324,41 @@ Fields.CheckBox = class extends BaseElement {
|
|||
}
|
||||
}
|
||||
|
||||
Fields.CheckBoxes = class extends BaseElement {
|
||||
getInputTemplate(value, label) {
|
||||
return `<label><input type=checkbox value="${value}" name="${this.name}" data-ref=input />${label}</label>`
|
||||
}
|
||||
|
||||
build() {
|
||||
const initial = this.get() || []
|
||||
for (const [value, label] of this.properties.choices) {
|
||||
const [root, { input }] = Utils.loadTemplateWithRefs(
|
||||
this.getInputTemplate(value, label)
|
||||
)
|
||||
this.container.appendChild(root)
|
||||
input.checked = initial.includes(value)
|
||||
input.addEventListener('change', () => this.sync())
|
||||
}
|
||||
super.build()
|
||||
}
|
||||
|
||||
value() {
|
||||
return Array.from(this.root.querySelectorAll('input:checked')).map((el) => el.value)
|
||||
}
|
||||
}
|
||||
|
||||
Fields.TagsEditor = class extends Fields.CheckBoxes {
|
||||
getInputTemplate(value, label) {
|
||||
const path = SCHEMA.iconUrl.default.replace('marker.svg', `tags/${value}.svg`)
|
||||
return `
|
||||
<label>
|
||||
<input type=checkbox value="${value}" name="${this.name}" data-ref=input />
|
||||
<img class="tag-icon" src="${path}" alt="" /> ${label}
|
||||
</label>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
Fields.Select = class extends BaseElement {
|
||||
getTemplate() {
|
||||
return `<select name="${this.name}" data-ref=select></select>`
|
||||
|
@ -541,14 +576,14 @@ Fields.DataLayerSwitcher = class extends Fields.Select {
|
|||
!datalayer.isDataReadOnly() &&
|
||||
datalayer.isBrowsable()
|
||||
) {
|
||||
options.push([L.stamp(datalayer), datalayer.getName()])
|
||||
options.push([datalayer.id, datalayer.getName()])
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
toHTML() {
|
||||
return L.stamp(this.obj.datalayer)
|
||||
return this.obj.datalayer.id
|
||||
}
|
||||
|
||||
toJS() {
|
||||
|
@ -1296,13 +1331,14 @@ Fields.ManageEditors = class extends BaseElement {
|
|||
placeholder: translate("Type editor's username"),
|
||||
}
|
||||
this.autocomplete = new AjaxAutocompleteMultiple(this.container, options)
|
||||
this._values = this.toHTML()
|
||||
if (this._values)
|
||||
this._values = this.toHTML() || []
|
||||
if (this._values) {
|
||||
for (let i = 0; i < this._values.length; i++)
|
||||
this.autocomplete.displaySelected({
|
||||
item: { value: this._values[i].id, label: this._values[i].name },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
value() {
|
||||
return this._values
|
||||
|
|
|
@ -249,7 +249,7 @@ export default class Importer extends Utils.WithTemplate {
|
|||
tagName: 'option',
|
||||
parent: layerSelect,
|
||||
textContent: datalayer.options.name,
|
||||
value: L.stamp(datalayer),
|
||||
value: datalayer.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -2,17 +2,15 @@ import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
|
|||
import { uMapAlert as Alert } from '../components/alerts/alert.js'
|
||||
import { MutatingForm } from './form/builder.js'
|
||||
import { translate } from './i18n.js'
|
||||
import { ServerStored } from './saving.js'
|
||||
import * as Utils from './utils.js'
|
||||
|
||||
// Dedicated object so we can deal with a separate dirty status, and thus
|
||||
// call the endpoint only when needed, saving one call at each save.
|
||||
export class MapPermissions extends ServerStored {
|
||||
export class MapPermissions {
|
||||
constructor(umap) {
|
||||
super()
|
||||
this.setProperties(umap.properties.permissions)
|
||||
this._umap = umap
|
||||
this._isDirty = false
|
||||
this.sync = umap.syncEngine.proxy(this)
|
||||
}
|
||||
|
||||
setProperties(properties) {
|
||||
|
@ -28,6 +26,13 @@ export class MapPermissions extends ServerStored {
|
|||
)
|
||||
}
|
||||
|
||||
getSyncMetadata() {
|
||||
return {
|
||||
subject: 'mappermissions',
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this._umap.render(['properties.permissions'])
|
||||
}
|
||||
|
@ -188,7 +193,6 @@ export class MapPermissions extends ServerStored {
|
|||
}
|
||||
|
||||
async save() {
|
||||
if (!this.isDirty) return
|
||||
const formData = new FormData()
|
||||
if (!this.isAnonymousMap() && this.properties.editors) {
|
||||
const editors = this.properties.editors.map((u) => u.id)
|
||||
|
@ -247,9 +251,8 @@ export class MapPermissions extends ServerStored {
|
|||
}
|
||||
}
|
||||
|
||||
export class DataLayerPermissions extends ServerStored {
|
||||
export class DataLayerPermissions {
|
||||
constructor(umap, datalayer) {
|
||||
super()
|
||||
this._umap = umap
|
||||
this.properties = Object.assign(
|
||||
{
|
||||
|
@ -259,6 +262,14 @@ export class DataLayerPermissions extends ServerStored {
|
|||
)
|
||||
|
||||
this.datalayer = datalayer
|
||||
this.sync = umap.syncEngine.proxy(this)
|
||||
}
|
||||
|
||||
getSyncMetadata() {
|
||||
return {
|
||||
subject: 'datalayerpermissions',
|
||||
metadata: { id: this.datalayer.id },
|
||||
}
|
||||
}
|
||||
|
||||
edit(container) {
|
||||
|
@ -289,7 +300,6 @@ export class DataLayerPermissions extends ServerStored {
|
|||
}
|
||||
|
||||
async save() {
|
||||
if (!this.isDirty) return
|
||||
const formData = new FormData()
|
||||
formData.append('edit_status', this.properties.edit_status)
|
||||
const [data, response, error] = await this._umap.server.post(
|
||||
|
|
|
@ -117,7 +117,7 @@ export const Choropleth = FeatureGroup.extend({
|
|||
},
|
||||
|
||||
_getValue: function (feature) {
|
||||
const key = this.datalayer.options.choropleth.property || 'value'
|
||||
const key = this.datalayer.options.choropleth?.property || 'value'
|
||||
const value = +feature.properties[key]
|
||||
if (!Number.isNaN(value)) return value
|
||||
},
|
||||
|
@ -130,12 +130,12 @@ export const Choropleth = FeatureGroup.extend({
|
|||
this.options.colors = []
|
||||
return
|
||||
}
|
||||
const mode = this.datalayer.options.choropleth.mode
|
||||
let classes = +this.datalayer.options.choropleth.classes || 5
|
||||
const mode = this.datalayer.options.choropleth?.mode
|
||||
let classes = +this.datalayer.options.choropleth?.classes || 5
|
||||
let breaks
|
||||
classes = Math.min(classes, values.length)
|
||||
if (mode === 'manual') {
|
||||
const manualBreaks = this.datalayer.options.choropleth.breaks
|
||||
const manualBreaks = this.datalayer.options.choropleth?.breaks
|
||||
if (manualBreaks) {
|
||||
breaks = manualBreaks
|
||||
.split(',')
|
||||
|
|
|
@ -97,7 +97,6 @@ const FeatureMixin = {
|
|||
},
|
||||
|
||||
onCommit: function () {
|
||||
this.feature.pullGeometry(false)
|
||||
this.feature.onCommit()
|
||||
},
|
||||
}
|
||||
|
@ -112,7 +111,7 @@ const PointMixin = {
|
|||
this.on('dragend', (event) => {
|
||||
this.isDirty = true
|
||||
this.feature.edit(event)
|
||||
this.feature.pullGeometry(false)
|
||||
// this.feature.pullGeometry(false)
|
||||
})
|
||||
if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging)
|
||||
this.on('mouseout', this._onMouseOut)
|
||||
|
@ -303,13 +302,13 @@ const PathMixin = {
|
|||
this._container = null
|
||||
FeatureMixin.onAdd.call(this, map)
|
||||
this.setStyle()
|
||||
if (this.editing?.enabled()) this.editing.addHooks()
|
||||
if (this.editor?.enabled()) this.editor.addHooks()
|
||||
this.resetTooltip()
|
||||
this._path.dataset.feature = this.feature.id
|
||||
},
|
||||
|
||||
onRemove: function (map) {
|
||||
if (this.editing?.enabled()) this.editing.removeHooks()
|
||||
if (this.editor?.enabled()) this.editor.removeHooks()
|
||||
FeatureMixin.onRemove.call(this, map)
|
||||
},
|
||||
|
||||
|
@ -362,6 +361,13 @@ const PathMixin = {
|
|||
isOnScreen: function (bounds) {
|
||||
return bounds.overlaps(this.getBounds())
|
||||
},
|
||||
|
||||
_setLatLngs: function (latlngs) {
|
||||
this.parentClass.prototype._setLatLngs.call(this, latlngs)
|
||||
if (this.editor?.enabled()) {
|
||||
this.editor.reset()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const LeafletPolyline = Polyline.extend({
|
||||
|
|
|
@ -17,20 +17,10 @@ class Rule {
|
|||
this.parse()
|
||||
}
|
||||
|
||||
get isDirty() {
|
||||
return this._isDirty
|
||||
}
|
||||
|
||||
set isDirty(status) {
|
||||
this._isDirty = status
|
||||
if (status) this._umap.isDirty = status
|
||||
}
|
||||
|
||||
constructor(umap, condition = '', options = {}) {
|
||||
// TODO make this public properties when browser coverage is ok
|
||||
// cf https://caniuse.com/?search=public%20class%20field
|
||||
this._condition = null
|
||||
this._isDirty = false
|
||||
this.OPERATORS = [
|
||||
['>', this.gt],
|
||||
['<', this.lt],
|
||||
|
@ -190,17 +180,25 @@ class Rule {
|
|||
|
||||
_delete() {
|
||||
this._umap.rules.rules = this._umap.rules.rules.filter((rule) => rule !== this)
|
||||
this._umap.rules.commit()
|
||||
}
|
||||
|
||||
setter(key, value) {
|
||||
const oldRules = Utils.CopyJSON(this._umap.properties.rules || {})
|
||||
Utils.setObjectValue(this, key, value)
|
||||
this._umap.rules.commit()
|
||||
this._umap.sync.update('properties.rules', this._umap.properties.rules, oldRules)
|
||||
}
|
||||
}
|
||||
|
||||
export default class Rules {
|
||||
constructor(umap) {
|
||||
this._umap = umap
|
||||
this.rules = []
|
||||
this.load()
|
||||
}
|
||||
|
||||
load() {
|
||||
this.rules = []
|
||||
if (!this._umap.properties.rules?.length) return
|
||||
for (const { condition, options } of this._umap.properties.rules) {
|
||||
if (!condition) continue
|
||||
|
@ -222,8 +220,8 @@ export default class Rules {
|
|||
else if (finalIndex > initialIndex) newIdx = referenceIdx
|
||||
else newIdx = referenceIdx + 1
|
||||
this.rules.splice(newIdx, 0, moved)
|
||||
moved.isDirty = true
|
||||
this._umap.render(['rules'])
|
||||
this.commit()
|
||||
}
|
||||
|
||||
edit(container) {
|
||||
|
@ -242,7 +240,6 @@ export default class Rules {
|
|||
|
||||
addRule() {
|
||||
const rule = new Rule(this._umap)
|
||||
rule.isDirty = true
|
||||
this.rules.push(rule)
|
||||
rule.edit(map)
|
||||
}
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
const _queue = new Set()
|
||||
|
||||
export let isDirty = false
|
||||
|
||||
export async function save() {
|
||||
for (const obj of _queue) {
|
||||
const ok = await obj.save()
|
||||
if (!ok) break
|
||||
remove(obj)
|
||||
}
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
_queue.clear()
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
function add(obj) {
|
||||
_queue.add(obj)
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
function remove(obj) {
|
||||
_queue.delete(obj)
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
function has(obj) {
|
||||
return _queue.has(obj)
|
||||
}
|
||||
|
||||
function onUpdate() {
|
||||
isDirty = Boolean(_queue.size)
|
||||
document.body.classList.toggle('umap-is-dirty', isDirty)
|
||||
}
|
||||
|
||||
export class ServerStored {
|
||||
set isDirty(status) {
|
||||
if (status) {
|
||||
add(this)
|
||||
} else {
|
||||
remove(this)
|
||||
}
|
||||
this.onDirty(status)
|
||||
}
|
||||
|
||||
get isDirty() {
|
||||
return has(this)
|
||||
}
|
||||
|
||||
onDirty(status) {}
|
||||
}
|
|
@ -44,6 +44,10 @@ export const SCHEMA = {
|
|||
type: Object,
|
||||
impacts: ['data'],
|
||||
},
|
||||
center: {
|
||||
type: Object,
|
||||
impacts: [], // default center, doesn't need any update of the map
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
impacts: ['data'],
|
||||
|
@ -118,6 +122,9 @@ export const SCHEMA = {
|
|||
default: false,
|
||||
label: translate('Animated transitions'),
|
||||
},
|
||||
edit_status: {
|
||||
type: Number,
|
||||
},
|
||||
editinosmControl: {
|
||||
type: Boolean,
|
||||
impacts: ['ui'],
|
||||
|
@ -125,6 +132,9 @@ export const SCHEMA = {
|
|||
label: translate('Display the control to open OpenStreetMap editor'),
|
||||
default: null,
|
||||
},
|
||||
editors: {
|
||||
type: Array,
|
||||
},
|
||||
embedControl: {
|
||||
type: Boolean,
|
||||
impacts: ['ui'],
|
||||
|
@ -362,6 +372,9 @@ export const SCHEMA = {
|
|||
type: Object,
|
||||
impacts: ['background'],
|
||||
},
|
||||
owner: {
|
||||
type: Object,
|
||||
},
|
||||
permanentCredit: {
|
||||
type: 'Text',
|
||||
impacts: ['ui'],
|
||||
|
@ -436,6 +449,9 @@ export const SCHEMA = {
|
|||
label: translate('Display the search control'),
|
||||
default: true,
|
||||
},
|
||||
share_status: {
|
||||
type: Number,
|
||||
},
|
||||
shortCredit: {
|
||||
type: String,
|
||||
impacts: ['ui'],
|
||||
|
@ -500,6 +516,12 @@ export const SCHEMA = {
|
|||
helpEntries: ['sync'],
|
||||
default: false,
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
},
|
||||
team: {
|
||||
type: Object,
|
||||
},
|
||||
tilelayer: {
|
||||
type: Object,
|
||||
impacts: ['background'],
|
||||
|
@ -566,7 +588,6 @@ export const SCHEMA = {
|
|||
type: Object,
|
||||
impacts: ['data'],
|
||||
},
|
||||
|
||||
_referenceVersion: {
|
||||
type: Number,
|
||||
impacts: ['data'],
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import * as SaveManager from '../saving.js'
|
||||
import * as Utils from '../utils.js'
|
||||
import { HybridLogicalClock } from './hlc.js'
|
||||
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
|
||||
import { UndoManager } from './undo.js'
|
||||
import {
|
||||
DataLayerUpdater,
|
||||
FeatureUpdater,
|
||||
MapUpdater,
|
||||
MapPermissionsUpdater,
|
||||
DataLayerPermissionsUpdater,
|
||||
} from './updaters.js'
|
||||
import { WebSocketTransport } from './websocket.js'
|
||||
|
||||
// Start reconnecting after 2 seconds, then double the delay each time
|
||||
|
@ -55,6 +61,8 @@ export class SyncEngine {
|
|||
map: new MapUpdater(umap),
|
||||
feature: new FeatureUpdater(umap),
|
||||
datalayer: new DataLayerUpdater(umap),
|
||||
mappermissions: new MapPermissionsUpdater(umap),
|
||||
datalayerpermissions: new DataLayerPermissionsUpdater(umap),
|
||||
}
|
||||
this.transport = undefined
|
||||
this._operations = new Operations()
|
||||
|
@ -64,6 +72,7 @@ export class SyncEngine {
|
|||
this.websocketConnected = false
|
||||
this.closeRequested = false
|
||||
this.peerId = Utils.generateId()
|
||||
this._undoManager = new UndoManager(umap, this.updaters, this)
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
|
@ -122,16 +131,107 @@ export class SyncEngine {
|
|||
await this.authenticate()
|
||||
}, this._reconnectDelay)
|
||||
}
|
||||
upsert(subject, metadata, value) {
|
||||
this._send({ verb: 'upsert', subject, metadata, value })
|
||||
|
||||
startBatch() {
|
||||
this._batch = []
|
||||
}
|
||||
|
||||
update(subject, metadata, key, value) {
|
||||
this._send({ verb: 'update', subject, metadata, key, value })
|
||||
commitBatch(subject, metadata) {
|
||||
if (!this._batch.length) {
|
||||
this._batch = null
|
||||
return
|
||||
}
|
||||
const operations = this._batch.map((stage) => stage.operation)
|
||||
const operation = { verb: 'batch', operations, subject, metadata }
|
||||
this._undoManager.add({ operation, stages: this._batch })
|
||||
this._send(operation)
|
||||
this._batch = null
|
||||
}
|
||||
|
||||
delete(subject, metadata, key) {
|
||||
this._send({ verb: 'delete', subject, metadata, key })
|
||||
upsert(subject, metadata, value, oldValue) {
|
||||
const operation = {
|
||||
verb: 'upsert',
|
||||
subject,
|
||||
metadata,
|
||||
value,
|
||||
}
|
||||
const stage = {
|
||||
operation,
|
||||
newValue: value,
|
||||
oldValue: oldValue,
|
||||
}
|
||||
if (this._batch) {
|
||||
this._batch.push(stage)
|
||||
return
|
||||
}
|
||||
this._undoManager.add(stage)
|
||||
this._send(operation)
|
||||
}
|
||||
|
||||
update(subject, metadata, key, value, oldValue, { undo } = { undo: true }) {
|
||||
const operation = {
|
||||
verb: 'update',
|
||||
subject,
|
||||
metadata,
|
||||
key,
|
||||
value,
|
||||
}
|
||||
const stage = {
|
||||
operation,
|
||||
oldValue: oldValue,
|
||||
newValue: value,
|
||||
}
|
||||
if (this._batch) {
|
||||
this._batch.push(stage)
|
||||
return
|
||||
}
|
||||
if (undo) this._undoManager.add(stage)
|
||||
this._send(operation)
|
||||
}
|
||||
|
||||
delete(subject, metadata, oldValue) {
|
||||
const operation = {
|
||||
verb: 'delete',
|
||||
subject,
|
||||
metadata,
|
||||
}
|
||||
const stage = {
|
||||
operation,
|
||||
oldValue: oldValue,
|
||||
}
|
||||
if (this._batch) {
|
||||
this._batch.push(stage)
|
||||
return
|
||||
}
|
||||
this._undoManager.add(stage)
|
||||
this._send(operation)
|
||||
}
|
||||
|
||||
async save() {
|
||||
const needSave = new Map()
|
||||
if (!this._umap.id) {
|
||||
// There is no operation for first map save
|
||||
needSave.set(this._umap, [])
|
||||
}
|
||||
for (const operation of this._operations.sorted()) {
|
||||
if (operation.dirty) {
|
||||
const updater = this._getUpdater(operation.subject)
|
||||
const obj = updater.getStoredObject(operation.metadata)
|
||||
if (!needSave.has(obj)) {
|
||||
needSave.set(obj, [])
|
||||
}
|
||||
needSave.get(obj).push(operation)
|
||||
}
|
||||
}
|
||||
for (const [obj, operations] of needSave.entries()) {
|
||||
const ok = await obj.save()
|
||||
if (!ok) break
|
||||
for (const operation of operations) {
|
||||
operation.dirty = false
|
||||
}
|
||||
}
|
||||
this.saved()
|
||||
this._undoManager.toggleState()
|
||||
}
|
||||
|
||||
saved() {
|
||||
|
@ -144,8 +244,8 @@ export class SyncEngine {
|
|||
}
|
||||
}
|
||||
|
||||
_send(inputMessage) {
|
||||
const message = this._operations.addLocal(inputMessage)
|
||||
_send(operation) {
|
||||
const message = this._operations.addLocal(operation)
|
||||
|
||||
if (this.offline) return
|
||||
if (this.transport) {
|
||||
|
@ -153,7 +253,11 @@ export class SyncEngine {
|
|||
}
|
||||
}
|
||||
|
||||
_getUpdater(subject, metadata) {
|
||||
_getUpdater(subject, metadata, sync) {
|
||||
// For now, prevent permissions to be synced, for security reasons
|
||||
if (sync && (subject === 'mappermissions' || subject === 'datalayerpermissions')) {
|
||||
return
|
||||
}
|
||||
if (Object.keys(this.updaters).includes(subject)) {
|
||||
return this.updaters[subject]
|
||||
}
|
||||
|
@ -161,7 +265,15 @@ export class SyncEngine {
|
|||
}
|
||||
|
||||
_applyOperation(operation) {
|
||||
if (operation.verb === 'batch') {
|
||||
operation.operations.map((op) => this._applyOperation(op))
|
||||
return
|
||||
}
|
||||
const updater = this._getUpdater(operation.subject, operation.metadata)
|
||||
if (!updater) {
|
||||
debug('No updater for', operation)
|
||||
return
|
||||
}
|
||||
updater.applyMessage(operation)
|
||||
}
|
||||
|
||||
|
@ -304,9 +416,8 @@ export class SyncEngine {
|
|||
|
||||
onSavedMessage({ sender, lastKnownHLC }) {
|
||||
debug(`received saved message from peer ${sender}`, lastKnownHLC)
|
||||
if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) {
|
||||
SaveManager.clear()
|
||||
}
|
||||
this._operations.saved(lastKnownHLC)
|
||||
this._undoManager.toggleState()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -356,7 +467,7 @@ export class SyncEngine {
|
|||
const handler = {
|
||||
get(target, prop) {
|
||||
// Only proxy these methods
|
||||
if (['upsert', 'update', 'delete'].includes(prop)) {
|
||||
if (['upsert', 'update', 'delete', 'commitBatch'].includes(prop)) {
|
||||
const { subject, metadata } = object.getSyncMetadata()
|
||||
// Reflect.get is calling the original method.
|
||||
// .bind is adding the parameters automatically
|
||||
|
@ -378,16 +489,22 @@ export class Operations {
|
|||
this._operations = new Array()
|
||||
}
|
||||
|
||||
saved(hlc) {
|
||||
for (const operation of this.getOperationsBefore(hlc)) {
|
||||
operation.dirty = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick the clock and store the passed message in the operations list.
|
||||
*
|
||||
* @param {*} inputMessage
|
||||
* @returns {*} clock-aware message
|
||||
*/
|
||||
addLocal(inputMessage) {
|
||||
const message = { ...inputMessage, hlc: this._hlc.tick() }
|
||||
this._operations.push(message)
|
||||
return message
|
||||
addLocal(operation) {
|
||||
operation.hlc = this._hlc.tick()
|
||||
this._operations.push(operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -445,6 +562,11 @@ export class Operations {
|
|||
return this._operations.filter((op) => op.hlc > hlc)
|
||||
}
|
||||
|
||||
getOperationsBefore(hlc) {
|
||||
if (!hlc) return this._operations
|
||||
return this._operations.filter((op) => op.hlc <= hlc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last known HLC value.
|
||||
*/
|
||||
|
|
101
umap/static/umap/js/modules/sync/undo.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
import * as Utils from '../utils.js'
|
||||
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
|
||||
|
||||
export class UndoManager {
|
||||
constructor(umap, updaters, syncEngine) {
|
||||
this._umap = umap
|
||||
this._syncEngine = syncEngine
|
||||
this.updaters = updaters
|
||||
this._undoStack = []
|
||||
this._redoStack = []
|
||||
}
|
||||
|
||||
toggleState() {
|
||||
// document is undefined during unittests
|
||||
if (typeof document === 'undefined') return
|
||||
const undoButton = document.querySelector('.edit-undo')
|
||||
const redoButton = document.querySelector('.edit-redo')
|
||||
if (undoButton) undoButton.disabled = !this._undoStack.length
|
||||
if (redoButton) redoButton.disabled = !this._redoStack.length
|
||||
const dirty = this.isDirty()
|
||||
document.body.classList.toggle('umap-is-dirty', dirty)
|
||||
for (const button of document.querySelectorAll('.disabled-on-dirty')) {
|
||||
button.disabled = dirty
|
||||
}
|
||||
for (const button of document.querySelectorAll('.enabled-on-dirty')) {
|
||||
button.disabled = !dirty
|
||||
}
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
if (!this._umap.id) return true
|
||||
for (const stage of this._undoStack) {
|
||||
if (stage.operation.dirty) return true
|
||||
}
|
||||
for (const stage of this._redoStack) {
|
||||
if (stage.operation.dirty) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
add(stage) {
|
||||
stage.operation.dirty = true
|
||||
this._redoStack = []
|
||||
this._undoStack.push(stage)
|
||||
this.toggleState()
|
||||
}
|
||||
|
||||
copyOperation(stage, redo) {
|
||||
const operation = Utils.CopyJSON(stage.operation)
|
||||
const value = redo ? stage.newValue : stage.oldValue
|
||||
operation.value = value
|
||||
if (['delete', 'upsert'].includes(operation.verb)) {
|
||||
operation.verb = value === null || value === undefined ? 'delete' : 'upsert'
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
undo(redo = false) {
|
||||
const fromStack = redo ? this._redoStack : this._undoStack
|
||||
const toStack = redo ? this._undoStack : this._redoStack
|
||||
const stage = fromStack.pop()
|
||||
if (!stage) return
|
||||
stage.operation.dirty = !stage.operation.dirty
|
||||
if (stage.operation.verb === 'batch') {
|
||||
for (const st of stage.stages) {
|
||||
this.applyOperation(this.copyOperation(st, redo))
|
||||
}
|
||||
} else {
|
||||
this.applyOperation(this.copyOperation(stage, redo))
|
||||
}
|
||||
toStack.push(stage)
|
||||
this.toggleState()
|
||||
}
|
||||
|
||||
redo() {
|
||||
this.undo(true)
|
||||
}
|
||||
|
||||
applyOperation(operation) {
|
||||
const updater = this._getUpdater(operation.subject, operation.metadata)
|
||||
switch (operation.verb) {
|
||||
case 'update':
|
||||
updater.update(operation)
|
||||
break
|
||||
case 'delete':
|
||||
updater.delete(operation)
|
||||
break
|
||||
case 'upsert':
|
||||
updater.upsert(operation)
|
||||
break
|
||||
}
|
||||
this._syncEngine._send(operation)
|
||||
}
|
||||
|
||||
_getUpdater(subject, metadata) {
|
||||
if (Object.keys(this.updaters).includes(subject)) {
|
||||
return this.updaters[subject]
|
||||
}
|
||||
throw new Error(`Unknown updater ${subject}, ${metadata}`)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { fieldInSchema } from '../utils.js'
|
||||
import * as Utils from '../utils.js'
|
||||
|
||||
/**
|
||||
* Updaters are classes able to convert messages
|
||||
|
@ -10,27 +10,6 @@ class BaseUpdater {
|
|||
this._umap = umap
|
||||
}
|
||||
|
||||
updateObjectValue(obj, key, value) {
|
||||
const parts = key.split('.')
|
||||
const lastKey = parts.pop()
|
||||
|
||||
// Reduce the current list of attributes,
|
||||
// to find the object to set the property onto
|
||||
const objectToSet = parts.reduce((currentObj, part) => {
|
||||
if (currentObj !== undefined && part in currentObj) return currentObj[part]
|
||||
}, obj)
|
||||
|
||||
// In case the given path doesn't exist, stop here
|
||||
if (objectToSet === undefined) return
|
||||
|
||||
// Set the value (or delete it)
|
||||
if (typeof value === 'undefined') {
|
||||
delete objectToSet[lastKey]
|
||||
} else {
|
||||
objectToSet[lastKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
getDataLayerFromID(layerId) {
|
||||
return this._umap.getDataLayerByUmapId(layerId)
|
||||
}
|
||||
|
@ -43,13 +22,17 @@ class BaseUpdater {
|
|||
|
||||
export class MapUpdater extends BaseUpdater {
|
||||
update({ key, value }) {
|
||||
if (fieldInSchema(key)) {
|
||||
this.updateObjectValue(this._umap, key, value)
|
||||
if (Utils.fieldInSchema(key)) {
|
||||
Utils.setObjectValue(this._umap, key, value)
|
||||
}
|
||||
|
||||
this._umap.onPropertiesUpdated([key])
|
||||
this._umap.render([key])
|
||||
}
|
||||
|
||||
getStoredObject() {
|
||||
return this._umap
|
||||
}
|
||||
}
|
||||
|
||||
export class DataLayerUpdater extends BaseUpdater {
|
||||
|
@ -58,14 +41,21 @@ export class DataLayerUpdater extends BaseUpdater {
|
|||
try {
|
||||
this.getDataLayerFromID(value.id)
|
||||
} catch {
|
||||
this._umap.createDataLayer(value, false)
|
||||
const datalayer = this._umap.createDataLayer(value._umap_options || value, false)
|
||||
if (value.features) {
|
||||
// FIXME: this will create new stages in the undoStack, thus this will empty
|
||||
// the redoStack
|
||||
datalayer.addData(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update({ key, metadata, value }) {
|
||||
const datalayer = this.getDataLayerFromID(metadata.id)
|
||||
if (fieldInSchema(key)) {
|
||||
this.updateObjectValue(datalayer, key, value)
|
||||
if (key === 'options') {
|
||||
datalayer.setOptions(value)
|
||||
} else if (Utils.fieldInSchema(key)) {
|
||||
Utils.setObjectValue(datalayer, key, value)
|
||||
} else {
|
||||
console.debug(
|
||||
'Not applying update for datalayer because key is not in the schema',
|
||||
|
@ -82,6 +72,10 @@ export class DataLayerUpdater extends BaseUpdater {
|
|||
datalayer.commitDelete()
|
||||
}
|
||||
}
|
||||
|
||||
getStoredObject(metadata) {
|
||||
return this.getDataLayerFromID(metadata.id)
|
||||
}
|
||||
}
|
||||
|
||||
export class FeatureUpdater extends BaseUpdater {
|
||||
|
@ -114,7 +108,7 @@ export class FeatureUpdater extends BaseUpdater {
|
|||
const feature = this.getFeatureFromMetadata(metadata)
|
||||
feature.geometry = value
|
||||
} else {
|
||||
this.updateObjectValue(feature, key, value)
|
||||
Utils.setObjectValue(feature, key, value)
|
||||
feature.datalayer.indexProperties(feature)
|
||||
}
|
||||
|
||||
|
@ -127,4 +121,32 @@ export class FeatureUpdater extends BaseUpdater {
|
|||
const feature = this.getFeatureFromMetadata(metadata)
|
||||
if (feature) feature.del(false)
|
||||
}
|
||||
|
||||
getStoredObject(metadata) {
|
||||
return this.getDataLayerFromID(metadata.layerId)
|
||||
}
|
||||
}
|
||||
|
||||
export class MapPermissionsUpdater extends BaseUpdater {
|
||||
update({ key, value }) {
|
||||
if (Utils.fieldInSchema(key)) {
|
||||
Utils.setObjectValue(this._umap.permissions, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
getStoredObject(metadata) {
|
||||
return this._umap.permissions
|
||||
}
|
||||
}
|
||||
|
||||
export class DataLayerPermissionsUpdater extends BaseUpdater {
|
||||
update({ key, value, metadata }) {
|
||||
if (Utils.fieldInSchema(key)) {
|
||||
Utils.setObjectValue(this.getDataLayerFromID(metadata.id), key, value)
|
||||
}
|
||||
}
|
||||
|
||||
getStoredObject(metadata) {
|
||||
return this.getDataLayerFromID(metadata.id).permissions
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,16 @@ const TOP_BAR_TEMPLATE = `
|
|||
<div class="umap-main-edit-toolbox with-transition dark">
|
||||
<div class="umap-left-edit-toolbox" data-ref="left">
|
||||
<div class="logo"><a class="" href="/" title="${translate('Go to the homepage')}">uMap</a></div>
|
||||
<button class="map-name flat" type="button" data-ref="name"></button>
|
||||
<button class="share-status flat" type="button" data-ref="share"></button>
|
||||
<button class="map-name flat truncate" type="button" data-ref="name"></button>
|
||||
<button class="share-status flat truncate" type="button" data-ref="share"></button>
|
||||
<button class="edit-undo round" type="button" data-ref="undo" disabled>
|
||||
<i class="icon icon-16 icon-undo"></i>
|
||||
<span>${translate('Undo')}</span>
|
||||
</button>
|
||||
<button class="edit-redo round" type="button" data-ref="redo" disabled>
|
||||
<i class="icon icon-16 icon-redo"></i>
|
||||
<span>${translate('Redo')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="umap-right-edit-toolbox" data-ref="right">
|
||||
<button class="connected-peers round" type="button" data-ref="peers">
|
||||
|
@ -19,18 +27,14 @@ const TOP_BAR_TEMPLATE = `
|
|||
</button>
|
||||
<button class="umap-user flat" type="button" data-ref="user">
|
||||
<i class="icon icon-16 icon-profile"></i>
|
||||
<span class="username" data-ref="username"></span>
|
||||
<span class="username truncate" data-ref="username"></span>
|
||||
</button>
|
||||
<button class="umap-help-link flat" type="button" title="${translate('Help')}" data-ref="help">${translate('Help')}</button>
|
||||
<button class="edit-cancel round" type="button" data-ref="cancel">
|
||||
<i class="icon icon-16 icon-restore"></i>
|
||||
<span class="">${translate('Cancel edits')}</span>
|
||||
</button>
|
||||
<button class="edit-disable round" type="button" data-ref="view">
|
||||
<button class="edit-disable round disabled-on-dirty" type="button" data-ref="view">
|
||||
<i class="icon icon-16 icon-eye"></i>
|
||||
<span class="">${translate('View')}</span>
|
||||
<span>${translate('View')}</span>
|
||||
</button>
|
||||
<button class="edit-save button round" type="button" data-ref="save">
|
||||
<button class="edit-save button round enabled-on-dirty" type="button" data-ref="save">
|
||||
<i class="icon icon-16 icon-save"></i>
|
||||
<i class="icon icon-16 icon-save-disabled"></i>
|
||||
<span hidden data-ref="saveLabel">${translate('Save')}</span>
|
||||
|
@ -118,11 +122,12 @@ export class TopBar extends WithTemplate {
|
|||
})
|
||||
|
||||
this.elements.help.addEventListener('click', () => this._umap.help.showGetStarted())
|
||||
this.elements.cancel.addEventListener('click', () => this._umap.askForReset())
|
||||
this.elements.cancel.addEventListener('mouseover', () => {
|
||||
this.elements.redo.addEventListener('click', () => this._umap.redo())
|
||||
this.elements.undo.addEventListener('click', () => this._umap.undo())
|
||||
this.elements.undo.addEventListener('mouseover', () => {
|
||||
this._umap.tooltip.open({
|
||||
content: this._umap.help.displayLabel('CANCEL'),
|
||||
anchor: this.elements.cancel,
|
||||
anchor: this.elements.undo,
|
||||
position: 'bottom',
|
||||
delay: 500,
|
||||
duration: 5000,
|
||||
|
@ -154,9 +159,10 @@ export class TopBar extends WithTemplate {
|
|||
redraw() {
|
||||
const syncEnabled = this._umap.getProperty('syncEnabled')
|
||||
this.elements.peers.hidden = !syncEnabled
|
||||
this.elements.cancel.hidden = syncEnabled
|
||||
this.elements.view.disabled = this._umap.sync._undoManager.isDirty()
|
||||
this.elements.saveLabel.hidden = this._umap.permissions.isDraft()
|
||||
this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft()
|
||||
this._umap.sync._undoManager.toggleState()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,10 +276,7 @@ export class EditBar extends WithTemplate {
|
|||
DomEvent.disableClickPropagation(this.element)
|
||||
this._onClick('marker', () => this._leafletMap.editTools.startMarker())
|
||||
this._onClick('polyline', () => this._leafletMap.editTools.startPolyline())
|
||||
this._onClick('multiline', () => {
|
||||
console.log('click click')
|
||||
this._umap.editedFeature.ui.editor.newShape()
|
||||
})
|
||||
this._onClick('multiline', () => this._umap.editedFeature.ui.editor.newShape())
|
||||
this._onClick('polygon', () => this._leafletMap.editTools.startPolygon())
|
||||
this._onClick('multipolygon', () => this._umap.editedFeature.ui.editor.newShape())
|
||||
this._onClick('caption', () => this._umap.editCaption())
|
||||
|
|
|
@ -22,8 +22,6 @@ import { MapPermissions } from './permissions.js'
|
|||
import { LeafletMap } from './rendering/map.js'
|
||||
import { Request, ServerRequest } from './request.js'
|
||||
import Rules from './rules.js'
|
||||
import { ServerStored } from './saving.js'
|
||||
import * as SAVEMANAGER from './saving.js'
|
||||
import { SCHEMA } from './schema.js'
|
||||
import Share from './share.js'
|
||||
import Slideshow from './slideshow.js'
|
||||
|
@ -36,9 +34,8 @@ import Tooltip from './ui/tooltip.js'
|
|||
import URLs from './urls.js'
|
||||
import * as Utils from './utils.js'
|
||||
|
||||
export default class Umap extends ServerStored {
|
||||
export default class Umap {
|
||||
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
|
||||
|
@ -96,6 +93,10 @@ export default class Umap extends ServerStored {
|
|||
this._leafletMap.latLng(center)
|
||||
}
|
||||
|
||||
// Needed for permissions
|
||||
this.syncEngine = new SyncEngine(this)
|
||||
this.sync = this.syncEngine.proxy(this)
|
||||
|
||||
// Needed to render controls
|
||||
this.permissions = new MapPermissions(this)
|
||||
this.urls = new URLs(this.properties.urls)
|
||||
|
@ -130,9 +131,6 @@ export default class Umap extends ServerStored {
|
|||
this.share = new Share(this)
|
||||
this.rules = new Rules(this)
|
||||
|
||||
this.syncEngine = new SyncEngine(this)
|
||||
this.sync = this.syncEngine.proxy(this)
|
||||
|
||||
if (this.hasEditMode()) {
|
||||
this.editPanel = new EditPanel(this, this._leafletMap)
|
||||
this.fullPanel = new FullPanel(this, this._leafletMap)
|
||||
|
@ -196,7 +194,6 @@ export default class Umap extends ServerStored {
|
|||
// Creation mode
|
||||
if (!this.id) {
|
||||
if (!this.properties.preview) {
|
||||
this.isDirty = true
|
||||
this.enableEdit()
|
||||
}
|
||||
this._defaultExtent = true
|
||||
|
@ -212,10 +209,14 @@ export default class Umap extends ServerStored {
|
|||
this.propagate()
|
||||
}
|
||||
|
||||
window.onbeforeunload = () => (this.editEnabled && SAVEMANAGER.isDirty) || null
|
||||
window.onbeforeunload = () => (this.editEnabled && this.isDirty) || null
|
||||
this.backup()
|
||||
}
|
||||
|
||||
get isDirty() {
|
||||
return this.sync._undoManager.isDirty()
|
||||
}
|
||||
|
||||
get editedFeature() {
|
||||
return this._editedFeature
|
||||
}
|
||||
|
@ -349,7 +350,7 @@ export default class Umap extends ServerStored {
|
|||
const items = []
|
||||
if (this.hasEditMode()) {
|
||||
if (this.editEnabled) {
|
||||
if (!SAVEMANAGER.isDirty) {
|
||||
if (!this.isDirty) {
|
||||
items.push({
|
||||
label: this.help.displayLabel('STOP_EDIT'),
|
||||
action: () => this.disableEdit(),
|
||||
|
@ -543,19 +544,17 @@ export default class Umap extends ServerStored {
|
|||
let used = true
|
||||
switch (event.key) {
|
||||
case 'e':
|
||||
if (!SAVEMANAGER.isDirty) this.disableEdit()
|
||||
if (!this.isDirty) this.disableEdit()
|
||||
break
|
||||
case 's':
|
||||
if (SAVEMANAGER.isDirty) this.saveAll()
|
||||
if (this.isDirty) this.saveAll()
|
||||
break
|
||||
case 'z':
|
||||
if (Utils.isWritable(event.target)) {
|
||||
used = false
|
||||
break
|
||||
}
|
||||
if (SAVEMANAGER.isDirty) {
|
||||
this.askForReset()
|
||||
}
|
||||
this.sync._undoManager.undo()
|
||||
break
|
||||
case 'm':
|
||||
this._leafletMap.editTools.startMarker()
|
||||
|
@ -671,10 +670,10 @@ export default class Umap extends ServerStored {
|
|||
}
|
||||
|
||||
async saveAll() {
|
||||
if (!SAVEMANAGER.isDirty) return
|
||||
if (!this.isDirty) return
|
||||
if (this._defaultExtent) this._setCenterAndZoom()
|
||||
this.backup()
|
||||
await SAVEMANAGER.save()
|
||||
await this.sync.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
|
||||
|
@ -685,7 +684,6 @@ export default class Umap extends ServerStored {
|
|||
Alert.success(translate('Map has been saved!'))
|
||||
})
|
||||
}
|
||||
this.sync.saved()
|
||||
this.fire('saved')
|
||||
}
|
||||
|
||||
|
@ -757,6 +755,12 @@ export default class Umap extends ServerStored {
|
|||
const form = builder.build()
|
||||
container.appendChild(form)
|
||||
|
||||
const tags = DomUtil.createFieldset(container, translate('Tags'))
|
||||
const tagsFields = ['properties.tags']
|
||||
const tagsBuilder = new MutatingForm(this, tagsFields, {
|
||||
umap: this,
|
||||
})
|
||||
tags.appendChild(tagsBuilder.build())
|
||||
const credits = DomUtil.createFieldset(container, translate('Credits'))
|
||||
const creditsFields = [
|
||||
'properties.licence',
|
||||
|
@ -1019,35 +1023,36 @@ export default class Umap extends ServerStored {
|
|||
'button',
|
||||
boundsButtons,
|
||||
translate('Use current bounds'),
|
||||
function () {
|
||||
() => {
|
||||
const bounds = this._leafletMap.getBounds()
|
||||
const oldLimitBounds = { ...this.properties.limitBounds }
|
||||
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
|
||||
this.sync.update(
|
||||
'properties.limitBounds',
|
||||
this.properties.limitBounds,
|
||||
oldLimitBounds
|
||||
)
|
||||
DomUtil.createButton(
|
||||
'button',
|
||||
boundsButtons,
|
||||
translate('Empty'),
|
||||
function () {
|
||||
this._leafletMap.handleLimitBounds()
|
||||
}
|
||||
)
|
||||
DomUtil.createButton('button', boundsButtons, translate('Empty'), () => {
|
||||
const oldLimitBounds = { ...this.properties.limitBounds }
|
||||
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
|
||||
this.sync.update(
|
||||
'properties.limitBounds',
|
||||
this.properties.limitBounds,
|
||||
oldLimitBounds
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
_editSlideshow(container) {
|
||||
|
@ -1160,23 +1165,7 @@ export default class Umap extends ServerStored {
|
|||
})
|
||||
}
|
||||
|
||||
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.onDataLayersChanged()
|
||||
this.isDirty = !this.id
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.rules.commit()
|
||||
const geojson = {
|
||||
type: 'Feature',
|
||||
geometry: this.geometry(),
|
||||
|
@ -1185,6 +1174,7 @@ export default class Umap extends ServerStored {
|
|||
const formData = new FormData()
|
||||
formData.append('name', this.properties.name)
|
||||
formData.append('center', JSON.stringify(this.geometry()))
|
||||
formData.append('tags', this.properties.tags || [])
|
||||
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)
|
||||
|
@ -1301,16 +1291,6 @@ export default class Umap extends ServerStored {
|
|||
this._leafletMap.fire(name)
|
||||
}
|
||||
|
||||
askForReset(e) {
|
||||
if (this.getProperty('syncEnabled')) return
|
||||
this.dialog
|
||||
.confirm(translate('Are you sure you want to cancel your changes?'))
|
||||
.then(() => {
|
||||
this.reset()
|
||||
this.disableEdit()
|
||||
})
|
||||
}
|
||||
|
||||
async initSyncEngine() {
|
||||
// this.properties.websocketEnabled is set by the server admin
|
||||
if (this.properties.websocketEnabled === false) return
|
||||
|
@ -1324,7 +1304,6 @@ export default class Umap extends ServerStored {
|
|||
|
||||
getSyncMetadata() {
|
||||
return {
|
||||
engine: this.sync,
|
||||
subject: 'map',
|
||||
}
|
||||
}
|
||||
|
@ -1348,6 +1327,9 @@ export default class Umap extends ServerStored {
|
|||
this.bottomBar.redraw()
|
||||
break
|
||||
case 'data':
|
||||
if (fields.includes('properties.rules')) {
|
||||
this.rules.load()
|
||||
}
|
||||
this.eachVisibleDataLayer((datalayer) => {
|
||||
datalayer.redraw()
|
||||
})
|
||||
|
@ -1522,7 +1504,7 @@ export default class Umap extends ServerStored {
|
|||
const form = builder.build()
|
||||
row.appendChild(form)
|
||||
row.classList.toggle('off', !datalayer.isVisible())
|
||||
row.dataset.id = stamp(datalayer)
|
||||
row.dataset.id = datalayer.id
|
||||
})
|
||||
const onReorder = (src, dst, initialIndex, finalIndex) => {
|
||||
const movedLayer = this.datalayers[src.dataset.id]
|
||||
|
@ -1553,7 +1535,7 @@ export default class Umap extends ServerStored {
|
|||
}
|
||||
|
||||
getDataLayerByUmapId(id) {
|
||||
const datalayer = this.findDataLayer((d) => d.id === id)
|
||||
const datalayer = this.datalayers[id]
|
||||
if (!datalayer) throw new Error(`Can't find datalayer with id ${id}`)
|
||||
return datalayer
|
||||
}
|
||||
|
@ -1669,7 +1651,6 @@ export default class Umap extends ServerStored {
|
|||
)
|
||||
this.render(fields)
|
||||
this._leafletMap._setDefaultCenter()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
importUmapFile(file) {
|
||||
|
@ -1768,13 +1749,26 @@ export default class Umap extends ServerStored {
|
|||
}
|
||||
|
||||
_setCenterAndZoom() {
|
||||
const oldCenter = { ...this.properties.center }
|
||||
const oldZoom = this.properties.zoom
|
||||
this.properties.center = this._leafletMap.getCenter()
|
||||
this.properties.zoom = this._leafletMap.getZoom()
|
||||
this.isDirty = true
|
||||
this._defaultExtent = false
|
||||
this.sync.startBatch()
|
||||
this.sync.update('properties.center', this.properties.center, oldCenter)
|
||||
this.sync.update('properties.zoom', this.properties.zoom, oldZoom)
|
||||
this.sync.commitBatch()
|
||||
}
|
||||
|
||||
getStaticPathFor(name) {
|
||||
return SCHEMA.iconUrl.default.replace('marker.svg', name)
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.sync._undoManager.undo()
|
||||
}
|
||||
|
||||
redo() {
|
||||
this.sync._undoManager.redo()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -484,6 +484,27 @@ export const debounce = (callback, wait) => {
|
|||
}
|
||||
}
|
||||
|
||||
export function setObjectValue(obj, key, value) {
|
||||
const parts = key.split('.')
|
||||
const lastKey = parts.pop()
|
||||
|
||||
// Reduce the current list of attributes,
|
||||
// to find the object to set the property onto
|
||||
const objectToSet = parts.reduce((currentObj, part) => {
|
||||
if (currentObj !== undefined && part in currentObj) return currentObj[part]
|
||||
}, obj)
|
||||
|
||||
// In case the given path doesn't exist, stop here
|
||||
if (objectToSet === undefined) return
|
||||
|
||||
// Set the value (or delete it)
|
||||
if (typeof value === 'undefined') {
|
||||
delete objectToSet[lastKey]
|
||||
} else {
|
||||
objectToSet[lastKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
export const COLORS = [
|
||||
'Black',
|
||||
'Navy',
|
||||
|
|
|
@ -297,6 +297,7 @@ U.TileLayerChooser = L.Control.extend({
|
|||
el,
|
||||
'click',
|
||||
() => {
|
||||
const oldTileLayer = this.map._umap.properties.tilelayer
|
||||
this.map.selectTileLayer(tilelayer)
|
||||
this.map._controls.tilelayers.setLayers()
|
||||
if (options?.edit) {
|
||||
|
@ -304,7 +305,8 @@ U.TileLayerChooser = L.Control.extend({
|
|||
this.map._umap.isDirty = true
|
||||
this.map._umap.sync.update(
|
||||
'properties.tilelayer',
|
||||
this.map._umap.properties.tilelayer
|
||||
this.map._umap.properties.tilelayer,
|
||||
oldTileLayer
|
||||
)
|
||||
}
|
||||
},
|
||||
|
@ -606,7 +608,7 @@ U.Editable = L.Editable.extend({
|
|||
this.on('editable:editing', (event) => {
|
||||
const feature = event.layer.feature
|
||||
feature.isDirty = true
|
||||
feature.pullGeometry(false)
|
||||
// feature.pullGeometry(false)
|
||||
})
|
||||
this.on('editable:vertex:ctrlclick', (event) => {
|
||||
const index = event.vertex.getIndex()
|
||||
|
@ -624,18 +626,20 @@ U.Editable = L.Editable.extend({
|
|||
|
||||
createPolyline: function (latlngs) {
|
||||
const datalayer = this._umap.defaultEditDataLayer()
|
||||
const point = new U.LineString(this._umap, datalayer, {
|
||||
const line = new U.LineString(this._umap, datalayer, {
|
||||
geometry: { type: 'LineString', coordinates: [] },
|
||||
})
|
||||
return point.ui
|
||||
line._just_married = true
|
||||
return line.ui
|
||||
},
|
||||
|
||||
createPolygon: function (latlngs) {
|
||||
const datalayer = this._umap.defaultEditDataLayer()
|
||||
const point = new U.Polygon(this._umap, datalayer, {
|
||||
const poly = new U.Polygon(this._umap, datalayer, {
|
||||
geometry: { type: 'Polygon', coordinates: [] },
|
||||
})
|
||||
return point.ui
|
||||
poly._just_married = true
|
||||
return poly.ui
|
||||
},
|
||||
|
||||
createMarker: function (latlng) {
|
||||
|
@ -643,6 +647,7 @@ U.Editable = L.Editable.extend({
|
|||
const point = new U.Point(this._umap, datalayer, {
|
||||
geometry: { type: 'Point', coordinates: [latlng.lng, latlng.lat] },
|
||||
})
|
||||
point._just_married = true
|
||||
return point.ui
|
||||
},
|
||||
|
||||
|
@ -734,6 +739,7 @@ U.Editable = L.Editable.extend({
|
|||
// Leaflet.Editable will delete the drawn shape if invalid
|
||||
// (eg. line has only one drawn point)
|
||||
// So let's check if the layer has no more shape
|
||||
event.layer.feature.pullGeometry(false)
|
||||
if (!event.layer.feature.hasGeom()) {
|
||||
event.layer.feature.del()
|
||||
} else {
|
||||
|
|
|
@ -935,6 +935,20 @@ a.umap-control-caption,
|
|||
display: block;
|
||||
}
|
||||
|
||||
/* **** */
|
||||
/* Tags */
|
||||
/* **** */
|
||||
|
||||
.tag-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-bottom: -4px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.dark .tag-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* *************************** */
|
||||
/* Overriding leaflet defaults */
|
||||
/* *************************** */
|
||||
|
@ -986,7 +1000,7 @@ a.umap-control-caption,
|
|||
.umap-main-edit-toolbox .umap-user span,
|
||||
.leaflet-container .leaflet-control-edit-save span,
|
||||
.leaflet-container .leaflet-control-edit-disable span,
|
||||
.leaflet-container .leaflet-control-edit-cancel span {
|
||||
.leaflet-container .edit-cancel span {
|
||||
display: none;
|
||||
}
|
||||
.umap-main-edit-toolbox .umap-help-button {
|
||||
|
|
|
@ -49,63 +49,6 @@ describe('#dispatch', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Updaters', () => {
|
||||
describe('BaseUpdater', () => {
|
||||
let updater
|
||||
let map
|
||||
let obj
|
||||
|
||||
beforeEach(() => {
|
||||
map = {}
|
||||
updater = new MapUpdater(map)
|
||||
obj = {}
|
||||
})
|
||||
it('should be able to set object properties', () => {
|
||||
let obj = {}
|
||||
updater.updateObjectValue(obj, 'foo', 'foo')
|
||||
expect(obj).deep.equal({ foo: 'foo' })
|
||||
})
|
||||
|
||||
it('should be able to set object properties recursively on existing objects', () => {
|
||||
let obj = { foo: {} }
|
||||
updater.updateObjectValue(obj, 'foo.bar', 'foo')
|
||||
expect(obj).deep.equal({ foo: { bar: 'foo' } })
|
||||
})
|
||||
|
||||
it('should be able to set object properties recursively on deep objects', () => {
|
||||
let obj = { foo: { bar: { baz: {} } } }
|
||||
updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value')
|
||||
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
|
||||
})
|
||||
|
||||
it('should be able to replace object properties recursively on deep objects', () => {
|
||||
let obj = { foo: { bar: { baz: { test: 'test' } } } }
|
||||
updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value')
|
||||
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
|
||||
})
|
||||
|
||||
it('should not set object properties recursively on non-existing objects', () => {
|
||||
let obj = { foo: {} }
|
||||
updater.updateObjectValue(obj, 'bar.bar', 'value')
|
||||
|
||||
expect(obj).deep.equal({ foo: {} })
|
||||
})
|
||||
|
||||
it('should delete keys for undefined values', () => {
|
||||
let obj = { foo: 'foo' }
|
||||
updater.updateObjectValue(obj, 'foo', undefined)
|
||||
|
||||
expect(obj).deep.equal({})
|
||||
})
|
||||
|
||||
it('should delete keys for undefined values, recursively', () => {
|
||||
let obj = { foo: { bar: 'bar' } }
|
||||
updater.updateObjectValue(obj, 'foo.bar', undefined)
|
||||
|
||||
expect(obj).deep.equal({ foo: {} })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Operations', () => {
|
||||
describe('haveSameContext', () => {
|
||||
|
|
|
@ -862,4 +862,51 @@ describe('Utils', () => {
|
|||
assert.equal(Utils.isObject(''), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setObjectValue', () => {
|
||||
it('should be able to set object properties', () => {
|
||||
let obj = {}
|
||||
Utils.setObjectValue(obj, 'foo', 'foo')
|
||||
expect(obj).deep.equal({ foo: 'foo' })
|
||||
})
|
||||
|
||||
it('should be able to set object properties recursively on existing objects', () => {
|
||||
let obj = { foo: {} }
|
||||
Utils.setObjectValue(obj, 'foo.bar', 'foo')
|
||||
expect(obj).deep.equal({ foo: { bar: 'foo' } })
|
||||
})
|
||||
|
||||
it('should be able to set object properties recursively on deep objects', () => {
|
||||
let obj = { foo: { bar: { baz: {} } } }
|
||||
Utils.setObjectValue(obj, 'foo.bar.baz.test', 'value')
|
||||
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
|
||||
})
|
||||
|
||||
it('should be able to replace object properties recursively on deep objects', () => {
|
||||
let obj = { foo: { bar: { baz: { test: 'test' } } } }
|
||||
Utils.setObjectValue(obj, 'foo.bar.baz.test', 'value')
|
||||
expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } })
|
||||
})
|
||||
|
||||
it('should not set object properties recursively on non-existing objects', () => {
|
||||
let obj = { foo: {} }
|
||||
Utils.setObjectValue(obj, 'bar.bar', 'value')
|
||||
|
||||
expect(obj).deep.equal({ foo: {} })
|
||||
})
|
||||
|
||||
it('should delete keys for undefined values', () => {
|
||||
let obj = { foo: 'foo' }
|
||||
Utils.setObjectValue(obj, 'foo', undefined)
|
||||
|
||||
expect(obj).deep.equal({})
|
||||
})
|
||||
|
||||
it('should delete keys for undefined values, recursively', () => {
|
||||
let obj = { foo: { bar: 'bar' } }
|
||||
Utils.setObjectValue(obj, 'foo.bar', undefined)
|
||||
|
||||
expect(obj).deep.equal({ foo: {} })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Literal, Optional, Union
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
|
||||
|
@ -14,10 +14,11 @@ class OperationMessage(BaseModel):
|
|||
"""Message sent from one peer to all the others"""
|
||||
|
||||
kind: Literal["OperationMessage"] = "OperationMessage"
|
||||
verb: Literal["upsert", "update", "delete"]
|
||||
verb: Literal["upsert", "update", "delete", "batch"]
|
||||
subject: Literal["map", "datalayer", "feature"]
|
||||
metadata: Optional[dict] = None
|
||||
key: Optional[str] = None
|
||||
operations: Optional[List] = None
|
||||
|
||||
|
||||
class PeerMessage(BaseModel):
|
||||
|
|
|
@ -4,6 +4,13 @@
|
|||
<div>
|
||||
{% map_fragment map_inst prefix=prefix page=request.GET.p %}
|
||||
<hgroup>
|
||||
{% if map_inst.tags %}
|
||||
<ul class="tag-list">
|
||||
{% for tag, label in map_inst.get_tags_display %}
|
||||
<li><a href="{% url 'search' %}?tags={{ tag }}">{{ label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<h3><a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a></h3>
|
||||
{% with author=map_inst.get_author %}
|
||||
{% if author %}
|
||||
|
|
|
@ -4,15 +4,23 @@
|
|||
{% trans "Search maps" as default_placeholder %}
|
||||
<div class="wrapper search_wrapper">
|
||||
<div class="row">
|
||||
<form action="{% firstof action search_url %}" method="get">
|
||||
<div class="col two-third mwide">
|
||||
<form class="search-form" action="{% firstof action search_url %}" method="get">
|
||||
<div class="col half mwide">
|
||||
<input name="q"
|
||||
type="search"
|
||||
placeholder="{% firstof placeholder default_placeholder %}"
|
||||
aria-label="{% firstof placeholder default_placeholder %}"
|
||||
value="{{ request.GET.q|default:"" }}" />
|
||||
</div>
|
||||
<div class="col third mwide">
|
||||
<div class="col quarter mwide">
|
||||
<select name="tags">
|
||||
<option value="">{% trans "Any category" %}</option>
|
||||
{% for value, label in UMAP_TAGS %}
|
||||
<option value="{{ value }}" {% if request.GET.tags == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col quarter mwide">
|
||||
<input type="submit" value="{% trans "Search" %}" class="neutral" />
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -18,6 +18,7 @@ DATALAYER_DATA = {
|
|||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "ExNTQ",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [14.68896484375, 48.55297816440071],
|
||||
|
@ -41,7 +42,7 @@ class LicenceFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
|
||||
class TileLayerFactory(factory.django.DjangoModelFactory):
|
||||
name = "Test zoom layer"
|
||||
name = "Test tilelayer"
|
||||
url_template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution = "Test layer attribution"
|
||||
|
||||
|
|
|
@ -350,8 +350,7 @@ def test_should_redraw_list_on_feature_delete(live_server, openmap, page, bootst
|
|||
buttons.first.click()
|
||||
page.locator("dialog").get_by_role("button", name="OK").click()
|
||||
expect(buttons).to_have_count(2)
|
||||
page.get_by_role("button", name="Cancel edits").click()
|
||||
page.locator("dialog").get_by_role("button", name="OK").click()
|
||||
page.get_by_role("button", name="Undo").click()
|
||||
expect(buttons).to_have_count(3)
|
||||
|
||||
|
||||
|
|
|
@ -261,6 +261,9 @@ def test_can_create_new_rule(live_server, page, openmap):
|
|||
page.get_by_title("AliceBlue").first.click()
|
||||
colors = getColors(markers)
|
||||
assert colors.count("rgb(240, 248, 255)") == 3
|
||||
page.get_by_role("button", name="Undo").click()
|
||||
colors = getColors(markers)
|
||||
assert colors.count("rgb(240, 248, 255)") == 0
|
||||
|
||||
|
||||
def test_can_deactive_rule_from_list(live_server, page, openmap):
|
||||
|
|
|
@ -64,8 +64,7 @@ def test_cancel_deleting_datalayer_should_restore(
|
|||
page.get_by_role("button", name="OK").click()
|
||||
expect(markers).to_have_count(0)
|
||||
expect(page.get_by_text("test datalayer")).to_be_hidden()
|
||||
page.get_by_role("button", name="Cancel edits").click()
|
||||
page.locator("dialog").get_by_role("button", name="OK").click()
|
||||
page.get_by_role("button", name="Undo").click()
|
||||
expect(markers).to_have_count(1)
|
||||
expect(page.locator(".umap-browser").get_by_text("test datalayer")).to_be_visible()
|
||||
|
||||
|
@ -160,7 +159,6 @@ def test_can_create_new_datalayer(live_server, openmap, page, datalayer):
|
|||
page.locator('input[name="name"]').click()
|
||||
page.locator('input[name="name"]').fill("Layer A with a new name")
|
||||
expect(page.get_by_text("Layer A with a new name")).to_be_visible()
|
||||
page.get_by_role("button", name="Save").click()
|
||||
with page.expect_response(re.compile(".*/datalayer/update/.*")):
|
||||
page.get_by_role("button", name="Save").click()
|
||||
assert DataLayer.objects.count() == 2
|
||||
|
|
|
@ -226,3 +226,18 @@ def test_hover_tooltip_setting_should_be_persistent(live_server, map, page):
|
|||
- text: always never on hover
|
||||
""")
|
||||
expect(page.locator(".umap-field-showLabel input[value=null]")).to_be_checked()
|
||||
|
||||
|
||||
def test_can_edit_map_tags(live_server, map, page):
|
||||
map.settings["properties"]["tags"] = ["arts"]
|
||||
map.edit_status = Map.ANONYMOUS
|
||||
map.save()
|
||||
page.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||
page.get_by_role("button", name="Edit map name and caption").click()
|
||||
page.get_by_text("Tags").click()
|
||||
expect(page.get_by_label("Art and Culture")).to_be_checked()
|
||||
page.get_by_label("Cycling").check()
|
||||
with page.expect_response(re.compile("./update/settings/.*")):
|
||||
page.get_by_role("button", name="Save").click()
|
||||
saved = Map.objects.get(pk=map.pk)
|
||||
assert saved.tags == ["arts", "cycling"]
|
||||
|
|
|
@ -117,8 +117,7 @@ def test_should_reset_style_on_cancel(live_server, openmap, page, bootstrap):
|
|||
expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count(
|
||||
1
|
||||
)
|
||||
page.get_by_role("button", name="Cancel edits").click()
|
||||
page.locator("dialog").get_by_role("button", name="OK").click()
|
||||
page.get_by_role("button", name="Undo").click()
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1)
|
||||
|
||||
|
||||
|
|
|
@ -292,9 +292,10 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
|
|||
# Change name on page two and save
|
||||
page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"])
|
||||
page_two.locator('input[name="name"]').fill("name from page two")
|
||||
page_two.wait_for_timeout(300) # Time for the input debounce.
|
||||
|
||||
# Map should be in dirty status
|
||||
expect(page_two.get_by_text("Cancel edits")).to_be_visible()
|
||||
expect(page_two.get_by_text("Save", exact=True)).to_be_enabled()
|
||||
with page_two.expect_response(re.compile(r".*/datalayer/update/.*")):
|
||||
page_two.get_by_role("button", name="Save").click()
|
||||
|
||||
|
@ -306,7 +307,7 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
|
|||
# We should have an alert with some actions
|
||||
expect(page_two.get_by_text("Whoops! Other contributor(s) changed")).to_be_visible()
|
||||
# Map should still be in dirty status
|
||||
expect(page_two.get_by_text("Cancel edits")).to_be_visible()
|
||||
expect(page_two.get_by_text("Save", exact=True)).to_be_enabled()
|
||||
|
||||
# Override data from page two
|
||||
with page_two.expect_response(re.compile(r".*/datalayer/update/.*")):
|
||||
|
@ -317,4 +318,4 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
|
|||
data = json.loads(Path(saved.geojson.path).read_text())
|
||||
assert data["features"][0]["properties"]["name"] == "name from page two"
|
||||
# Map should not be in dirty status anymore
|
||||
expect(page_two.get_by_text("Cancel edits")).to_be_hidden()
|
||||
expect(page_two.get_by_text("Save", exact=True)).to_be_disabled()
|
||||
|
|
|
@ -16,17 +16,15 @@ def test_reseting_map_would_remove_from_save_queue(
|
|||
page.on("request", register_request)
|
||||
page.locator('input[name="name"]').click()
|
||||
page.locator('input[name="name"]').fill("new name")
|
||||
page.get_by_role("button", name="Cancel edits").click()
|
||||
page.get_by_role("button", name="OK").click()
|
||||
page.get_by_role("button", name="Undo").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.get_by_role("button", name="Edit").click()
|
||||
page.get_by_role("button", name="Manage layers").click()
|
||||
page.get_by_role("button", name="Edit", exact=True).click()
|
||||
page.locator('input[name="name"]').click()
|
||||
page.locator('input[name="name"]').fill("new datalayer name")
|
||||
page.wait_for_timeout(300) # Time of the Input debounce
|
||||
with page.expect_response(re.compile(".*/datalayer/update/.*")):
|
||||
page.get_by_role("button", name="Save").click()
|
||||
page.get_by_role("button", name="Save", exact=True).click()
|
||||
assert len(requests) == 1
|
||||
assert requests == [
|
||||
(
|
||||
|
|
267
umap/tests/integration/test_undo_redo.py
Normal file
|
@ -0,0 +1,267 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from umap.models import Map, TileLayer
|
||||
|
||||
from ..base import DataLayerFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DATALAYER_DATA = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "name poly",
|
||||
},
|
||||
"id": "gyNzM",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[11.25, 53.585984],
|
||||
[10.151367, 52.975108],
|
||||
[12.689209, 52.167194],
|
||||
[14.084473, 53.199452],
|
||||
[12.634277, 53.618579],
|
||||
[11.25, 53.585984],
|
||||
[11.25, 53.585984],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def map_with_polygon(map, live_server):
|
||||
map.settings["properties"]["zoom"] = 6
|
||||
map.settings["geometry"] = {
|
||||
"type": "Point",
|
||||
"coordinates": [8.429, 53.239],
|
||||
}
|
||||
map.edit_status = Map.ANONYMOUS
|
||||
map.save()
|
||||
DataLayerFactory(map=map, data=DATALAYER_DATA)
|
||||
return map
|
||||
|
||||
|
||||
def test_can_undo_redo_map_name_change(page, live_server, tilelayer):
|
||||
page.goto(f"{live_server.url}/en/map/new/")
|
||||
|
||||
expect(page.locator(".edit-undo")).to_be_disabled()
|
||||
expect(page.locator(".edit-redo")).to_be_disabled()
|
||||
page.get_by_title("Edit map name and caption").click()
|
||||
name_input = page.locator('.map-metadata input[name="name"]')
|
||||
expect(name_input).to_be_visible()
|
||||
name_input.click()
|
||||
name_input.press("Control+a")
|
||||
name_input.fill("New map name")
|
||||
expect(page.locator(".edit-undo")).to_be_enabled()
|
||||
expect(page.locator(".edit-redo")).to_be_disabled()
|
||||
map_name = page.locator(".umap-main-edit-toolbox .map-name")
|
||||
expect(map_name).to_have_text("New map name")
|
||||
name_input.fill("New name again")
|
||||
expect(map_name).to_have_text("New name again")
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(map_name).to_have_text("New map name")
|
||||
expect(page.locator(".edit-undo")).to_be_enabled()
|
||||
expect(page.locator(".edit-redo")).to_be_enabled()
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
expect(map_name).to_have_text("New name again")
|
||||
expect(page.locator(".edit-undo")).to_be_enabled()
|
||||
expect(page.locator(".edit-redo")).to_be_disabled()
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(map_name).to_have_text("New map name")
|
||||
expect(page.locator(".edit-undo")).to_be_enabled()
|
||||
expect(page.locator(".edit-redo")).to_be_enabled()
|
||||
|
||||
|
||||
def test_can_undo_redo_layer_color_change(
|
||||
page, map_with_polygon, live_server, tilelayer
|
||||
):
|
||||
page.goto(f"{live_server.url}{map_with_polygon.get_absolute_url()}?edit")
|
||||
|
||||
expect(page.locator(".edit-undo")).to_be_disabled()
|
||||
expect(page.locator(".edit-redo")).to_be_disabled()
|
||||
page.get_by_role("button", name="Manage layers").click()
|
||||
page.locator(".panel").get_by_title("Edit", exact=True).click()
|
||||
page.get_by_text("Shape properties").click()
|
||||
page.locator(".umap-field-color .define").click()
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1)
|
||||
page.get_by_title("DarkRed").first.click()
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1)
|
||||
expect(page.locator(".edit-undo")).to_be_enabled()
|
||||
expect(page.locator(".edit-redo")).to_be_disabled()
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1)
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(0)
|
||||
expect(page.locator(".edit-undo")).to_be_disabled()
|
||||
expect(page.locator(".edit-redo")).to_be_enabled()
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1)
|
||||
expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(0)
|
||||
expect(page.locator(".edit-undo")).to_be_enabled()
|
||||
expect(page.locator(".edit-redo")).to_be_disabled()
|
||||
|
||||
|
||||
def test_can_undo_redo_tilelayer_change(live_server, page, openmap, tilelayer):
|
||||
TileLayer.objects.create(
|
||||
url_template="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||
attribution="OSM/Carto",
|
||||
name="Black Tiles",
|
||||
)
|
||||
page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit")
|
||||
old_pattern = re.compile(
|
||||
r"https://[abc]{1}.tile.openstreetmap.fr/osmfr/\d+/\d+/\d+.png"
|
||||
)
|
||||
tiles = page.locator(".leaflet-tile-pane img")
|
||||
expect(tiles.first).to_have_attribute("src", old_pattern)
|
||||
|
||||
new_pattern = re.compile(
|
||||
r"https://[abcd]{1}.basemaps.cartocdn.com/dark_all/\d+/\d+/\d+.png"
|
||||
)
|
||||
page.get_by_role("button", name="Change tilelayers").click()
|
||||
page.locator("li").filter(has_text="Black Tiles").get_by_role("img").click()
|
||||
|
||||
tiles = page.locator(".leaflet-tile-pane img")
|
||||
expect(tiles.first).to_have_attribute("src", new_pattern)
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
tiles = page.locator(".leaflet-tile-pane img")
|
||||
expect(tiles.first).to_have_attribute("src", old_pattern)
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
tiles = page.locator(".leaflet-tile-pane img")
|
||||
expect(tiles.first).to_have_attribute("src", new_pattern)
|
||||
|
||||
|
||||
def test_can_undo_redo_marker_drag(live_server, page, tilelayer):
|
||||
page.goto(f"{live_server.url}/en/map/new")
|
||||
|
||||
marker = page.locator(".leaflet-marker-icon")
|
||||
map = page.locator("#map")
|
||||
|
||||
# Create a marker
|
||||
page.get_by_title("Draw a marker").click()
|
||||
map.click(position={"x": 225, "y": 225})
|
||||
expect(marker).to_have_count(1)
|
||||
|
||||
# Drag marker
|
||||
old_bbox = marker.bounding_box()
|
||||
marker.first.drag_to(map, target_position={"x": 250, "y": 250})
|
||||
assert marker.bounding_box() != old_bbox
|
||||
|
||||
# Undo
|
||||
page.locator(".edit-undo").click()
|
||||
assert marker.bounding_box() == old_bbox
|
||||
|
||||
# Redo
|
||||
page.locator(".edit-redo").click()
|
||||
assert marker.bounding_box() != old_bbox
|
||||
|
||||
|
||||
def test_can_undo_redo_polygon_geometry_change(live_server, page, tilelayer):
|
||||
page.goto(f"{live_server.url}/en/map/new")
|
||||
|
||||
# Click on the Draw a polygon button on a new map.
|
||||
page.get_by_title("Draw a polygon").click()
|
||||
|
||||
polygon = page.locator("path[fill='DarkBlue']")
|
||||
expect(polygon).to_have_count(0)
|
||||
|
||||
# Click on the map, it will create a polygon.
|
||||
map = page.locator("#map")
|
||||
map.click(position={"x": 200, "y": 200})
|
||||
map.click(position={"x": 100, "y": 200})
|
||||
map.click(position={"x": 100, "y": 100})
|
||||
map.click(position={"x": 100, "y": 100})
|
||||
|
||||
# It is created on peerA, and should be on peerB
|
||||
expect(polygon).to_have_count(1)
|
||||
old_bbox = polygon.bounding_box()
|
||||
|
||||
edited_vertex = page.locator(".leaflet-middle-icon:nth-child(3)").first
|
||||
edited_vertex.drag_to(map, target_position={"x": 250, "y": 250})
|
||||
page.keyboard.press("Escape")
|
||||
|
||||
assert polygon.bounding_box() != old_bbox
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
assert polygon.bounding_box() == old_bbox
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
assert polygon.bounding_box() != old_bbox
|
||||
|
||||
|
||||
def test_can_undo_redo_marker_create(live_server, page, tilelayer):
|
||||
page.goto(f"{live_server.url}/en/map/new")
|
||||
|
||||
page.get_by_title("Open Browser").click()
|
||||
marker = page.locator(".leaflet-marker-icon")
|
||||
map = page.locator("#map")
|
||||
|
||||
# Create a marker
|
||||
page.get_by_title("Draw a marker").click()
|
||||
map.click(position={"x": 600, "y": 100})
|
||||
expect(marker).to_have_count(1)
|
||||
expect(page.locator(".panel .datalayer")).to_have_count(1)
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(marker).to_have_count(0)
|
||||
# Layer still exists
|
||||
expect(page.locator(".panel .datalayer")).to_have_count(1)
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(page.locator(".panel .datalayer")).to_have_count(0)
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
expect(page.locator(".panel .datalayer")).to_have_count(1)
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
expect(marker).to_have_count(1)
|
||||
|
||||
|
||||
def test_undo_redo_import(live_server, page, tilelayer):
|
||||
page.goto(f"{live_server.url}/map/new/")
|
||||
page.get_by_title("Open Browser").click()
|
||||
|
||||
page.get_by_title("Import data").click()
|
||||
file_input = page.locator("input[type='file']")
|
||||
with page.expect_file_chooser() as fc_info:
|
||||
file_input.click()
|
||||
file_chooser = fc_info.value
|
||||
path = Path(__file__).parent.parent / "fixtures/test_upload_data.json"
|
||||
file_chooser.set_files(path)
|
||||
page.get_by_role("button", name="Import data", exact=True).click()
|
||||
# Close the import panel
|
||||
page.keyboard.press("Escape")
|
||||
|
||||
layers = page.locator(".umap-browser .datalayer")
|
||||
expect(layers).to_have_count(1)
|
||||
|
||||
features_count = page.locator(".umap-browser .datalayer-counter")
|
||||
expect(features_count).to_have_text("(5)")
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(features_count).to_be_hidden()
|
||||
expect(layers).to_have_count(1)
|
||||
|
||||
page.locator(".edit-undo").click()
|
||||
expect(layers).to_have_count(0)
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
expect(layers).to_have_count(1)
|
||||
|
||||
page.locator(".edit-redo").click()
|
||||
expect(features_count).to_have_text("(5)")
|
|
@ -659,3 +659,73 @@ def test_should_sync_line_on_escape(new_page, asgi_live_server, tilelayer):
|
|||
|
||||
expect(peerA.locator("path")).to_have_count(1)
|
||||
expect(peerB.locator("path")).to_have_count(1)
|
||||
|
||||
|
||||
@pytest.mark.xdist_group(name="websockets")
|
||||
def test_should_sync_datalayer_clear(
|
||||
new_page, asgi_live_server, tilelayer, map, datalayer
|
||||
):
|
||||
map.settings["properties"]["syncEnabled"] = True
|
||||
map.edit_status = Map.ANONYMOUS
|
||||
map.save()
|
||||
|
||||
# Create two tabs
|
||||
peerA = new_page("Page A")
|
||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||
peerB = new_page("Page B")
|
||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1)
|
||||
expect(peerB.locator(".leaflet-marker-icon")).to_have_count(1)
|
||||
|
||||
# Clear layer in peer A
|
||||
peerA.get_by_role("button", name="Manage layers").click()
|
||||
peerA.get_by_role("button", name="Edit", exact=True).click()
|
||||
peerA.locator("summary").filter(has_text="Advanced actions").click()
|
||||
peerA.get_by_role("button", name="Empty").click()
|
||||
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(0)
|
||||
expect(peerB.locator(".leaflet-marker-icon")).to_have_count(0)
|
||||
|
||||
# Undo in peer A
|
||||
peerA.get_by_role("button", name="Undo").click()
|
||||
expect(peerA.locator(".leaflet-marker-icon")).to_have_count(1)
|
||||
expect(peerB.locator(".leaflet-marker-icon")).to_have_count(1)
|
||||
|
||||
|
||||
@pytest.mark.xdist_group(name="websockets")
|
||||
def test_should_save_remote_dirty_datalayers(new_page, asgi_live_server, tilelayer):
|
||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||
map.settings["properties"]["syncEnabled"] = True
|
||||
map.save()
|
||||
|
||||
assert not DataLayer.objects.count()
|
||||
|
||||
# Create two tabs
|
||||
peerA = new_page("Page A")
|
||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||
peerB = new_page("Page B")
|
||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||
|
||||
# Create a new layer from peerA
|
||||
peerA.get_by_role("button", name="Manage layers").click()
|
||||
peerA.get_by_role("button", name="Add a layer").click()
|
||||
|
||||
# Create a new layer from peerB
|
||||
peerB.get_by_role("button", name="Manage layers").click()
|
||||
peerB.get_by_role("button", name="Add a layer").click()
|
||||
|
||||
# Save from peerA to the server
|
||||
counter = 0
|
||||
|
||||
def on_response(response):
|
||||
nonlocal counter
|
||||
if "/datalayer/create/" in response.url:
|
||||
counter += 1
|
||||
# Wait for the two datalayer saves
|
||||
if counter == 2:
|
||||
return True
|
||||
return False
|
||||
|
||||
with peerA.expect_response(on_response):
|
||||
peerA.get_by_role("button", name="Save").click()
|
||||
|
||||
assert DataLayer.objects.count() == 2
|
||||
|
|
|
@ -103,6 +103,7 @@ def test_get_version(map, datalayer):
|
|||
],
|
||||
"type": "Point",
|
||||
},
|
||||
"id": "ExNTQ",
|
||||
"properties": {
|
||||
"_umap_options": {
|
||||
"color": "DarkCyan",
|
||||
|
|
|
@ -694,6 +694,7 @@ def test_download(client, map, datalayer):
|
|||
"coordinates": [14.68896484375, 48.55297816440071],
|
||||
"type": "Point",
|
||||
},
|
||||
"id": "ExNTQ",
|
||||
"properties": {
|
||||
"_umap_options": {"color": "DarkCyan", "iconClass": "Ball"},
|
||||
"description": "Da place anonymous again 755",
|
||||
|
|
|
@ -486,3 +486,27 @@ def test_cannot_search_deleted_map(client, map):
|
|||
url = reverse("search")
|
||||
response = client.get(url + "?q=Blé")
|
||||
assert "Blé dur" not in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_filter_by_tag(client, map):
|
||||
# Very basic search, that do not deal with accent nor case.
|
||||
# See install.md for how to have a smarter dict + index.
|
||||
map.name = "Blé dur"
|
||||
map.tags = ["bike"]
|
||||
map.save()
|
||||
url = reverse("search")
|
||||
response = client.get(url + "?tags=bike")
|
||||
assert "Blé dur" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_can_combine_search_and_filter(client, map):
|
||||
# Very basic search, that do not deal with accent nor case.
|
||||
# See install.md for how to have a smarter dict + index.
|
||||
map.name = "Blé dur"
|
||||
map.tags = ["bike"]
|
||||
map.save()
|
||||
url = reverse("search")
|
||||
response = client.get(url + "?q=dur&tags=bike")
|
||||
assert "Blé dur" in response.content.decode()
|
||||
|
|
|
@ -334,12 +334,18 @@ class TeamMaps(PaginatorMixin, DetailView):
|
|||
class SearchMixin:
|
||||
def get_search_queryset(self, **kwargs):
|
||||
q = self.request.GET.get("q")
|
||||
tags = [t for t in self.request.GET.getlist("tags") if t]
|
||||
qs = Map.objects.all()
|
||||
if q:
|
||||
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
|
||||
query = SearchQuery(
|
||||
q, config=settings.UMAP_SEARCH_CONFIGURATION, search_type="websearch"
|
||||
)
|
||||
return Map.objects.annotate(search=vector).filter(search=query)
|
||||
qs = qs.annotate(search=vector).filter(search=query)
|
||||
if tags:
|
||||
qs = qs.filter(tags__contains=tags)
|
||||
if q or tags:
|
||||
return qs
|
||||
|
||||
|
||||
class Search(PaginatorMixin, TemplateView, PublicMapsMixin, SearchMixin):
|
||||
|
|