').addClass(apid);
line.append($('').text(item.bssid));
@@ -1431,7 +1493,7 @@ editor = {
$collector.find('.wifi-count').text(editor._scancollector_data.wifi.length);
$collector.siblings('[name=data]').val(JSON.stringify(editor._scancollector_data));
},
- _scancollector_ibeacon_result: function(data) {
+ _scancollector_ibeacon_result: function (data) {
var $collector = $('#sidebar').find('.scancollector.running'),
$table = $collector.find('.ibeacon-table tbody'),
item, i, line, beaconid, color = Date.now(), match;
@@ -1439,14 +1501,14 @@ editor = {
if (!data.length) return;
$table.find('tr').addClass('old');
- for (i=0; i < data.length; i++) {
+ for (i = 0; i < data.length; i++) {
item = data[i];
- beaconid = 'beacon-'+item.uuid+'-'+item.major+'-'+item.minor;
- line = $table.find('tr.'+beaconid);
+ beaconid = 'beacon-' + item.uuid + '-' + item.major + '-' + item.minor;
+ line = $table.find('tr.' + beaconid);
color = Math.max(0, Math.min(50, item.distance));
- color = 'rgb('+String(color*5)+', '+String(200-color*4)+', 0)';
+ color = 'rgb(' + String(color * 5) + ', ' + String(200 - color * 4) + ', 0)';
if (line.length) {
- line.removeClass('old').find(':last-child').text(Math.round(item.distance*100)/100).css('color', color);
+ line.removeClass('old').find(':last-child').text(Math.round(item.distance * 100) / 100).css('color', color);
} else {
match = editor._scancollector_lookup.ibeacons?.[item.uuid]?.[item.major]?.[item.minor];
if (match && match.point) {
@@ -1460,7 +1522,7 @@ editor = {
line.append($(' | ').text(item.major));
line.append($(' | ').text(item.minor));
line.append($(' | ').text(match ? match.name : ''));
- line.append($(' | ').text(Math.round(item.distance*100)/100).css('color', color));
+ line.append($(' | ').text(Math.round(item.distance * 100) / 100).css('color', color));
$table.append(line);
}
}
@@ -1469,7 +1531,7 @@ editor = {
$collector.siblings('[name=data]').val(JSON.stringify(editor._scancollector_data));
},
_wifi_scan_waits: false,
- _scancollector_wifi_scan_perhaps: function() {
+ _scancollector_wifi_scan_perhaps: function () {
if (!editor._wifi_scan_waits && $('#sidebar').find('.scancollector.running').length) {
editor._wifi_scan_waits = true;
mobileclient.scanNow();
@@ -1487,21 +1549,21 @@ function ibeacon_results_available() {
LevelControl = L.Control.extend({
options: {
- position: 'bottomright',
+ position: 'bottomright',
addClasses: ''
- },
+ },
- onAdd: function () {
- this._container = L.DomUtil.create('div', 'leaflet-control-levels leaflet-bar '+this.options.addClasses);
- this._levelButtons = [];
- //noinspection JSUnusedGlobalSymbols
+ onAdd: function () {
+ this._container = L.DomUtil.create('div', 'leaflet-control-levels leaflet-bar ' + this.options.addClasses);
+ this._levelButtons = [];
+ //noinspection JSUnusedGlobalSymbols
this.current_level_id = null;
- this.level_ids = [];
- this._disabled = true;
- this._expanded = false;
- this.hide();
+ this.level_ids = [];
+ this._disabled = true;
+ this._expanded = false;
+ this.hide();
- if (!L.Browser.android) {
+ if (!L.Browser.android) {
L.DomEvent.on(this._container, {
mouseenter: this.expand,
mouseleave: this.collapse
@@ -1514,28 +1576,28 @@ LevelControl = L.Control.extend({
this._map.on('click', this.collapse, this);
- return this._container;
- },
+ return this._container;
+ },
- addLevel: function (id, title, href, current) {
+ addLevel: function (id, title, href, current) {
this.level_ids.push(parseInt(id));
- if (current) this.current_level_id = parseInt(id);
+ if (current) this.current_level_id = parseInt(id);
- var link = L.DomUtil.create('a', (current ? 'current' : ''), this._container);
- link.innerHTML = title;
- link.href = href;
+ var link = L.DomUtil.create('a', (current ? 'current' : ''), this._container);
+ link.innerHTML = title;
+ link.href = href;
- L.DomEvent
- .on(link, 'mousedown dblclick', L.DomEvent.stopPropagation)
- .on(link, 'click', this._levelClick, this);
+ L.DomEvent
+ .on(link, 'mousedown dblclick', L.DomEvent.stopPropagation)
+ .on(link, 'click', this._levelClick, this);
this._levelButtons.push(link);
- return link;
- },
+ return link;
+ },
- clearLevels: function() {
+ clearLevels: function () {
this.current_level_id = null;
- this.level_ids = [];
+ this.level_ids = [];
for (var i = 0; i < this._levelButtons.length; i++) {
L.DomUtil.remove(this._levelButtons[i]);
}
@@ -1576,20 +1638,20 @@ LevelControl = L.Control.extend({
editor.sidebar_get(e.target.href);
this.collapse();
}
- },
+ },
expand: function () {
if (this._disabled) return;
this._expanded = true;
- L.DomUtil.addClass(this._container, 'leaflet-control-levels-expanded');
- return this;
- },
+ L.DomUtil.addClass(this._container, 'leaflet-control-levels-expanded');
+ return this;
+ },
- collapse: function () {
+ collapse: function () {
this._expanded = false;
- L.DomUtil.removeClass(this._container, 'leaflet-control-levels-expanded');
- return this;
- }
+ L.DomUtil.removeClass(this._container, 'leaflet-control-levels-expanded');
+ return this;
+ }
});
OverlayControl = L.Control.extend({
@@ -1601,7 +1663,7 @@ OverlayControl = L.Control.extend({
onAdd: function () {
this._initialActiveOverlays = JSON.parse(localStorage.getItem('c3nav.editor.overlays.active-overlays') ?? '[]');
- this._initialCollapsedGroups = JSON.parse(localStorage.getItem('c3nav.editor.overlays.collapsedGroups') ?? '[]');
+ this._initialCollapsedGroups = JSON.parse(localStorage.getItem('c3nav.editor.overlays.collapsed-groups') ?? '[]');
const pinned = JSON.parse(localStorage.getItem('c3nav.editor.overlays.pinned') ?? 'false');
this._container = L.DomUtil.create('div', 'leaflet-control-overlays ' + this.options.addClasses);
@@ -1635,9 +1697,9 @@ OverlayControl = L.Control.extend({
}
for (const group of this._initialCollapsedGroups) {
- if (group in this._groups) {
- this._groups[group].expanded = false;
- }
+ if (group in this._groups) {
+ this._groups[group].expanded = false;
+ }
}
this.render();
@@ -1677,7 +1739,7 @@ OverlayControl = L.Control.extend({
this.render();
},
- updateOverlay: function(id) {
+ updateOverlay: function (id) {
const overlay = this._overlays[id];
if (overlay.visible) {
overlay.layer.addTo(this._map);
@@ -1732,7 +1794,7 @@ OverlayControl = L.Control.extend({
return this;
},
- toggleGroup: function(name) {
+ toggleGroup: function (name) {
const group = this._groups[name];
group.expanded = !group.expanded;
group.el.classList.toggle('expanded', group.expanded);
@@ -1740,7 +1802,7 @@ OverlayControl = L.Control.extend({
localStorage.setItem('c3nav.editor.overlays.collapsed-groups', JSON.stringify(collapsedGroups));
},
- togglePinned: function() {
+ togglePinned: function () {
this._pinned = !this._pinned;
if (this._pinned) {
this._expanded = true;
diff --git a/src/c3nav/editor/templates/editor/edit.html b/src/c3nav/editor/templates/editor/edit.html
index 6c844ba4..dd042d6c 100644
--- a/src/c3nav/editor/templates/editor/edit.html
+++ b/src/c3nav/editor/templates/editor/edit.html
@@ -3,6 +3,10 @@
{% include 'editor/fragment_levels.html' %}
+{% if extra_json_data %}
+ {{ extra_json_data|json_script:"sidebar-extra-data" }}
+{% endif %}
+
{% if new %}
{% blocktrans %}New {{ model_title }}{% endblocktrans %}
diff --git a/src/c3nav/editor/templates/editor/fragment_child_models.html b/src/c3nav/editor/templates/editor/fragment_child_models.html
index 3ddd6e08..784d084a 100644
--- a/src/c3nav/editor/templates/editor/fragment_child_models.html
+++ b/src/c3nav/editor/templates/editor/fragment_child_models.html
@@ -12,4 +12,7 @@
{% trans 'Graph' %}
{% endif %}
+ {% if overlays_url %}
+ {% trans 'Overlays' %}
+ {% endif %}
diff --git a/src/c3nav/editor/templates/editor/level.html b/src/c3nav/editor/templates/editor/level.html
index a0834c39..fdd08ee3 100644
--- a/src/c3nav/editor/templates/editor/level.html
+++ b/src/c3nav/editor/templates/editor/level.html
@@ -26,7 +26,8 @@
{% url 'editor.levels.graph' level=level.pk as graph_url %}
-{% include 'editor/fragment_child_models.html' with graph_url=graph_url %}
+{% url 'editor.levels.overlays' level=level.pk as overlays_url %}
+{% include 'editor/fragment_child_models.html' with graph_url=graph_url overlays_url=overlays_url %}
diff --git a/src/c3nav/editor/templates/editor/overlay_features.html b/src/c3nav/editor/templates/editor/overlay_features.html
new file mode 100644
index 00000000..c457922c
--- /dev/null
+++ b/src/c3nav/editor/templates/editor/overlay_features.html
@@ -0,0 +1,45 @@
+{% load bootstrap3 %}
+{% load i18n %}
+
+{% include 'editor/fragment_levels.html' %}
+
+{% if extra_json_data %}
+ {{ extra_json_data|json_script:"sidebar-extra-data" }}
+{% endif %}
+
+
+ {% blocktrans %}Overlay "{{ title }}"{% endblocktrans %}
+ {% with level.title as level_title %}
+ {% blocktrans %}on level {{ level_title }}{% endblocktrans %}
+ {% endwith %}
+
+
+{% bootstrap_messages %}
+{% if can_create %}
+
+ {% blocktrans %}New feature{% endblocktrans %}
+
+{% endif %}
+
+{% trans 'Edit' as edit_caption %}
+
+
diff --git a/src/c3nav/editor/templates/editor/overlays.html b/src/c3nav/editor/templates/editor/overlays.html
new file mode 100644
index 00000000..54685c63
--- /dev/null
+++ b/src/c3nav/editor/templates/editor/overlays.html
@@ -0,0 +1,17 @@
+{% load bootstrap3 %}
+{% load i18n %}
+{% include 'editor/fragment_levels.html' %}
+
+{% trans 'Data Overlays' %}
+{% bootstrap_messages %}
+
+ « {% trans 'back to level' %}
+
+
+
diff --git a/src/c3nav/editor/urls.py b/src/c3nav/editor/urls.py
index c8d1ffe7..ee7f5b2a 100644
--- a/src/c3nav/editor/urls.py
+++ b/src/c3nav/editor/urls.py
@@ -5,6 +5,7 @@ from c3nav.editor.converters import EditPkConverter
from c3nav.editor.views.account import change_password_view, login_view, logout_view, register_view
from c3nav.editor.views.changes import changeset_detail, changeset_edit, changeset_redirect
from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, sourceimage, space_detail
+from c3nav.editor.views.overlays import overlays_list, overlay_features, overlay_feature_edit
from c3nav.editor.views.users import user_detail, user_redirect
register_converter(EditPkConverter, 'editpk')
@@ -42,6 +43,10 @@ urlpatterns = [
name='editor.levels_on_top.create'),
path('levels//graph/', graph_edit, name='editor.levels.graph'),
path('spaces//graph/', graph_edit, name='editor.spaces.graph'),
+ path('levels//overlays/', overlays_list, name='editor.levels.overlays'),
+ path('levels//overlays//', overlay_features, name='editor.levels.overlay'),
+ path('levels//overlays//create', overlay_feature_edit, name='editor.levels.overlay.create'),
+ path('levels//overlays//features/', overlay_feature_edit, name='editor.levels.overlay.edit'),
path('changeset/', changeset_redirect, name='editor.changesets.current'),
path('changesets//', changeset_detail, name='editor.changesets.detail'),
path('changesets//edit', changeset_edit, name='editor.changesets.edit'),
@@ -66,6 +71,7 @@ urlpatterns.extend(add_editor_urls('AccessRestrictionGroup'))
urlpatterns.extend(add_editor_urls('Source'))
urlpatterns.extend(add_editor_urls('LabelSettings'))
urlpatterns.extend(add_editor_urls('Theme'))
+urlpatterns.extend(add_editor_urls('DataOverlay'))
urlpatterns.extend(add_editor_urls('Building', 'Level'))
urlpatterns.extend(add_editor_urls('Space', 'Level', explicit_edit=True))
urlpatterns.extend(add_editor_urls('Door', 'Level'))
diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py
index 849ddfb5..2f8b1baf 100644
--- a/src/c3nav/editor/views/edit.py
+++ b/src/c3nav/editor/views/edit.py
@@ -60,6 +60,7 @@ def main_index(request):
child_model(request, 'LabelSettings'),
child_model(request, 'Source'),
child_model(request, 'Theme'),
+ child_model(request, 'DataOverlay'),
],
}, fields=('can_create_level', 'child_models'))
diff --git a/src/c3nav/editor/views/overlays.py b/src/c3nav/editor/views/overlays.py
new file mode 100644
index 00000000..0e37e64d
--- /dev/null
+++ b/src/c3nav/editor/views/overlays.py
@@ -0,0 +1,272 @@
+from c3nav.editor.views.base import (APIHybridError, APIHybridFormTemplateResponse,
+ APIHybridMessageRedirectResponse, APIHybridTemplateContextResponse,
+ editor_etag_func, sidebar_view)
+from django.shortcuts import get_object_or_404
+from django.views.decorators.http import etag
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
+from django.contrib import messages
+from django.db import IntegrityError
+
+from c3nav.editor.utils import DefaultEditUtils, LevelChildEditUtils
+from c3nav.editor.views.edit import get_changeset_exceeded
+
+
+@etag(editor_etag_func)
+@sidebar_view(api_hybrid=True)
+def overlays_list(request, level):
+ Level = request.changeset.wrap_model('Level')
+ DataOverlay = request.changeset.wrap_model('DataOverlay')
+
+ queryset = DataOverlay.objects.all().order_by('id')
+ if hasattr(DataOverlay, 'q_for_request'):
+ queryset = queryset.filter(DataOverlay.q_for_request(request))
+
+ level = get_object_or_404(Level.objects.filter(Level.q_for_request(request)), pk=level)
+ edit_utils = LevelChildEditUtils(level, request)
+
+ ctx = {
+ 'levels': Level.objects.filter(Level.q_for_request(request), on_top_of__isnull=True),
+ 'level': level,
+ 'level_url': 'editor.levels.overlays',
+ 'geometry_url': edit_utils.geometry_url,
+ 'overlays': queryset,
+ }
+
+ return APIHybridTemplateContextResponse('editor/overlays.html', ctx, fields=('overlays',))
+
+@etag(editor_etag_func)
+@sidebar_view(api_hybrid=True)
+def overlay_features(request, level, pk):
+ Level = request.changeset.wrap_model('Level')
+ DataOverlay = request.changeset.wrap_model('DataOverlay')
+ DataOverlayFeature = request.changeset.wrap_model('DataOverlayFeature')
+
+ ctx = {
+ 'path': request.path,
+ 'overlay_id': pk,
+ }
+
+ queryset = DataOverlayFeature.objects.filter(level_id=level, overlay_id=pk).order_by('id')
+ reverse_kwargs = {}
+
+ add_cols = []
+
+ reverse_kwargs['level'] = level
+ reverse_kwargs['overlay'] = pk
+ level = get_object_or_404(Level.objects.filter(Level.q_for_request(request)), pk=level)
+ overlay = get_object_or_404(DataOverlay.objects.filter(DataOverlay.q_for_request(request)), pk=pk)
+ edit_utils = LevelChildEditUtils(level, request)
+ ctx.update({
+ 'title': overlay.title,
+ 'back_url': reverse('editor.levels.overlays', kwargs={'level': level.pk}),
+ 'back_title': _('back to overlays'),
+ 'levels': Level.objects.filter(Level.q_for_request(request), on_top_of__isnull=True),
+ 'level': level,
+
+ # TODO: this makes the level switcher always link to the overview of all overlays, rather than the current overlay
+ # unclear how to make it possible to switch to the correct overlay
+ 'level_url': 'editor.levels.overlays',
+ })
+
+ for obj in queryset:
+ reverse_kwargs['pk'] = obj.pk
+ obj.edit_url = reverse('editor.levels.overlay.edit', kwargs=reverse_kwargs)
+ obj.add_cols = tuple(getattr(obj, col) for col in add_cols)
+ reverse_kwargs.pop('pk', None)
+
+
+ ctx.update({
+ 'can_create': True,
+ 'geometry_url': edit_utils.geometry_url,
+ 'add_cols': add_cols,
+ 'create_url': reverse('editor.levels.overlay.create', kwargs={'level': level.pk, 'overlay': overlay.pk}),
+ 'features': queryset,
+ 'extra_json_data': {
+ 'activeOverlayId': overlay.pk
+ },
+ })
+
+ return APIHybridTemplateContextResponse('editor/overlay_features.html', ctx,
+ fields=('can_create', 'create_url', 'objects'))
+
+@etag(editor_etag_func)
+@sidebar_view(api_hybrid=True)
+def overlay_feature_edit(request, level, overlay, pk=None):
+ changeset_exceeded = get_changeset_exceeded(request)
+ model_changes = {}
+ if changeset_exceeded:
+ model_changes = request.changeset.get_changed_objects_by_model('DataOverlayFeature')
+
+ Level = request.changeset.wrap_model('Level')
+ DataOverlay = request.changeset.wrap_model('DataOverlay')
+ DataOverlayFeature = request.changeset.wrap_model('DataOverlayFeature')
+
+ can_edit_changeset = request.changeset.can_edit(request)
+
+ obj = None
+ edit_utils = DefaultEditUtils(request)
+ if pk is not None:
+ # Edit existing map item
+ kwargs = {'pk': pk}
+ qs = DataOverlayFeature.objects.all()
+ if hasattr(DataOverlayFeature, 'q_for_request'):
+ qs = qs.filter(DataOverlayFeature.q_for_request(request))
+
+ kwargs.update({'level__pk': level})
+ qs = qs.select_related('level')
+ utils_cls = LevelChildEditUtils
+
+ obj = get_object_or_404(qs, **kwargs)
+ level = obj.level
+ overlay = obj.overlay
+ edit_utils = utils_cls.from_obj(obj, request)
+ else:
+ level = get_object_or_404(Level.objects.filter(Level.q_for_request(request)), pk=level)
+ overlay = get_object_or_404(DataOverlay.objects.filter(DataOverlay.q_for_request(request)), pk=overlay)
+ edit_utils = LevelChildEditUtils(level, request)
+
+ new = obj is None
+
+ if new and not edit_utils.can_create:
+ raise PermissionDenied
+
+ geometry_url = edit_utils.geometry_url
+
+ # noinspection PyProtectedMember
+ ctx = {
+ 'path': request.path,
+ 'pk': pk,
+ 'model_name': DataOverlayFeature.__name__.lower(),
+ 'model_title': DataOverlayFeature._meta.verbose_name,
+ 'can_edit': can_edit_changeset,
+ 'new': new,
+ 'title': obj.title if obj else None,
+ 'geometry_url': geometry_url,
+ 'geomtype': 'polygon,linestring,point',
+ }
+
+ space_id = None
+
+ ctx.update({
+ 'level': level,
+ 'back_url': reverse('editor.levels.overlay', kwargs={'level': level.pk, 'pk': overlay.pk}),
+ })
+
+ nosave = False
+ if changeset_exceeded:
+ if new:
+ return APIHybridMessageRedirectResponse(
+ level='error', message=_('You can not create new objects because your changeset is full.'),
+ redirect_to=ctx['back_url'], status_code=409,
+ )
+ elif obj.pk not in model_changes:
+ messages.warning(request, _('You can not edit this object because your changeset is full.'))
+ nosave = True
+
+ ctx.update({
+ 'nosave': nosave
+ })
+
+ if new:
+ ctx.update({
+ 'nozoom': True
+ })
+
+ error = None
+ delete = getattr(request, 'is_delete', None)
+
+ if request.method == 'POST' or (not new and delete):
+ if nosave:
+ return APIHybridMessageRedirectResponse(
+ level='error', message=_('You can not edit this object because your changeset is full.'),
+ redirect_to=request.path, status_code=409,
+ )
+
+ if not can_edit_changeset:
+ return APIHybridMessageRedirectResponse(
+ level='error', message=_('You can not edit changes on this changeset.'),
+ redirect_to=request.path, status_code=403,
+ )
+
+ if not new and ((request.POST.get('delete') == '1' and delete is not False) or delete):
+ # Delete this mapitem!
+ try:
+ if not request.changeset.get_changed_object(obj).can_delete():
+ raise PermissionError
+ except (ObjectDoesNotExist, PermissionError):
+ return APIHybridMessageRedirectResponse(
+ level='error',
+ message=_('You can not delete this object because other objects still depend on it.'),
+ redirect_to=request.path, status_code=409,
+ )
+
+ if request.POST.get('delete_confirm') == '1' or delete:
+ with request.changeset.lock_to_edit(request) as changeset:
+ if changeset.can_edit(request):
+ obj.delete()
+ else:
+ return APIHybridMessageRedirectResponse(
+ level='error',
+ message=_('You can not edit changes on this changeset.'),
+ redirect_to=request.path, status_code=403,
+ )
+
+ redirect_to = ctx['back_url']
+ return APIHybridMessageRedirectResponse(
+ level='success',
+ message=_('Object was successfully deleted.'),
+ redirect_to=redirect_to
+ )
+ ctx['obj_title'] = obj.title
+ return APIHybridTemplateContextResponse('editor/delete.html', ctx, fields=())
+
+ json_body = getattr(request, 'json_body', None)
+ data = json_body if json_body is not None else request.POST
+ form = DataOverlayFeature.EditorForm(instance=DataOverlayFeature() if new else obj, data=data, is_json=json_body is not None,
+ request=request, space_id=space_id,
+ geometry_editable=edit_utils.can_access_child_base_mapdata)
+ if form.is_valid():
+ # Update/create objects
+ obj = form.save(commit=False)
+
+ obj.level = level
+ obj.overlay = overlay
+
+ with request.changeset.lock_to_edit(request) as changeset:
+ if changeset.can_edit(request):
+ try:
+ obj.save()
+ except IntegrityError as e:
+ error = APIHybridError(status_code=400, message=_('Duplicate entry.'))
+ else:
+ if form.redirect_slugs is not None:
+ for slug in form.add_redirect_slugs:
+ obj.redirects.create(slug=slug)
+
+ for slug in form.remove_redirect_slugs:
+ obj.redirects.filter(slug=slug).delete()
+
+ form.save_m2m()
+ return APIHybridMessageRedirectResponse(
+ level='success',
+ message=_('Object was successfully saved.'),
+ redirect_to=ctx['back_url']
+ )
+ else:
+ error = APIHybridError(status_code=403, message=_('You can not edit changes on this changeset.'))
+
+ else:
+ form = DataOverlayFeature.EditorForm(instance=obj, request=request, space_id=space_id,
+ geometry_editable=edit_utils.can_access_child_base_mapdata)
+
+ ctx.update({
+ 'form': form,
+ 'extra_json_data': {
+ 'activeOverlayId': overlay.pk
+ }
+ })
+
+ return APIHybridFormTemplateResponse('editor/edit.html', ctx, form=form, error=error)
+
diff --git a/src/c3nav/mapdata/api/mapdata.py b/src/c3nav/mapdata/api/mapdata.py
index 02bd1944..b24d2e76 100644
--- a/src/c3nav/mapdata/api/mapdata.py
+++ b/src/c3nav/mapdata/api/mapdata.py
@@ -8,7 +8,7 @@ from c3nav.api.auth import auth_responses, validate_responses
from c3nav.api.exceptions import API404
from c3nav.mapdata.api.base import api_etag, optimize_query
from c3nav.mapdata.models import (Area, Building, Door, Hole, Level, LocationGroup, LocationGroupCategory, Source,
- Space, Stair)
+ Space, Stair, DataOverlay, DataOverlayFeature)
from c3nav.mapdata.models.access import AccessRestriction, AccessRestrictionGroup
from c3nav.mapdata.models.geometry.space import (POI, Column, CrossDescription, LeaveDescription, LineObstacle,
Obstacle, Ramp)
@@ -20,7 +20,8 @@ from c3nav.mapdata.schemas.models import (AccessRestrictionGroupSchema, AccessRe
BuildingSchema, ColumnSchema, CrossDescriptionSchema, DoorSchema,
DynamicLocationSchema, HoleSchema, LeaveDescriptionSchema, LevelSchema,
LineObstacleSchema, LocationGroupCategorySchema, LocationGroupSchema,
- ObstacleSchema, POISchema, RampSchema, SourceSchema, SpaceSchema, StairSchema)
+ ObstacleSchema, POISchema, RampSchema, SourceSchema, SpaceSchema, StairSchema,
+ DataOverlaySchema, DataOverlayFeatureSchema)
mapdata_api_router = APIRouter(tags=["mapdata"])
@@ -487,3 +488,33 @@ def dynamiclocation_list(request):
@api_etag()
def dynamiclocation_by_id(request, dynamiclocation_id: int):
return mapdata_retrieve_endpoint(request, DynamicLocation, pk=dynamiclocation_id)
+
+
+"""
+Data overlays
+"""
+
+
+@mapdata_api_router.get('/overlays/', summary="data overlay list",
+ tags=["mapdata-root"], description=schema_description(DynamicLocationSchema),
+ response={200: list[DataOverlaySchema], **auth_responses})
+@api_etag()
+def dataoverlay_list(request):
+ return mapdata_list_endpoint(request, model=DataOverlay)
+
+
+@mapdata_api_router.get('/overlays/{overlay_id}/', summary="features for overlay by overlay ID",
+ tags=["mapdata-root"], description=schema_description(DynamicLocationSchema),
+ response={200: list[DataOverlayFeatureSchema], **API404.dict(), **auth_responses})
+# @api_etag()
+def dataoverlay_by_id(request, overlay_id: int):
+ qs = optimize_query(
+ DataOverlayFeature.qs_for_request(request)
+ )
+
+ qs = qs.filter(overlay_id=overlay_id)
+
+ # order_by
+ qs = qs.order_by('pk')
+
+ return qs
diff --git a/src/c3nav/mapdata/fields.py b/src/c3nav/mapdata/fields.py
index 71b06785..27f4e71f 100644
--- a/src/c3nav/mapdata/fields.py
+++ b/src/c3nav/mapdata/fields.py
@@ -109,10 +109,10 @@ class GeometryField(models.JSONField):
'multipolygon': (Polygon, MultiPolygon),
'linestring': (LineString, ),
'point': (Point, )
- }[self.geomtype]
+ }.get(self.geomtype, None)
def _validate_geomtype(self, value, exception: typing.Type[Exception] = ValidationError):
- if not isinstance(value, self.classes):
+ if self.classes is not None and not isinstance(value, self.classes):
# if you get this error with wrappedgeometry, looked into wrapped_geom
raise TypeError('Expected %s instance, got %s, %s instead.' % (
' or '.join(c.__name__ for c in self.classes),
diff --git a/src/c3nav/mapdata/migrations/0111_dataoverlay_dataoverlayfeature.py b/src/c3nav/mapdata/migrations/0111_dataoverlay_dataoverlayfeature.py
new file mode 100644
index 00000000..d128c069
--- /dev/null
+++ b/src/c3nav/mapdata/migrations/0111_dataoverlay_dataoverlayfeature.py
@@ -0,0 +1,62 @@
+# Generated by Django 5.0.8 on 2024-11-21 10:43
+
+import c3nav.mapdata.fields
+import django.core.serializers.json
+import django.db.models.deletion
+import django_pydantic_field.compat.django
+import django_pydantic_field.fields
+import types
+import typing
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mapdata', '0110_theme_icon_path_theme_leaflet_marker_config'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DataOverlay',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', c3nav.mapdata.fields.I18nField(blank=True, fallback_any=True, fallback_value='{model} {pk}', plural_name='titles', verbose_name='Title')),
+ ('description', models.TextField(blank=True, verbose_name='Description')),
+ ('stroke_color', models.TextField(blank=True, null=True, verbose_name='default stroke color')),
+ ('stroke_width', models.FloatField(blank=True, null=True, verbose_name='default stroke width')),
+ ('fill_color', models.TextField(blank=True, null=True, verbose_name='default fill color')),
+ ('pull_url', models.URLField(blank=True, null=True, verbose_name='pull URL')),
+ ('pull_headers', django_pydantic_field.fields.PydanticSchemaField(config=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, schema=django_pydantic_field.compat.django.GenericContainer(typing.Union, (django_pydantic_field.compat.django.GenericContainer(dict, (str, str)), types.NoneType)), verbose_name='headers for pull http request (JSON object)')),
+ ('pull_interval', models.DurationField(blank=True, null=True, verbose_name='pull interval')),
+ ],
+ options={
+ 'verbose_name': 'Data Overlay',
+ 'verbose_name_plural': 'Data Overlays',
+ 'default_related_name': 'data_overlays',
+ },
+ ),
+ migrations.CreateModel(
+ name='DataOverlayFeature',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', c3nav.mapdata.fields.I18nField(blank=True, fallback_any=True, fallback_value='{model} {pk}', plural_name='titles', verbose_name='Title')),
+ ('import_tag', models.CharField(blank=True, max_length=64, null=True, verbose_name='import tag')),
+ ('geometry', c3nav.mapdata.fields.GeometryField(default=None, help_text=None)),
+ ('external_url', models.URLField(blank=True, null=True, verbose_name='external URL')),
+ ('stroke_color', models.CharField(blank=True, max_length=255, null=True, verbose_name='stroke color')),
+ ('stroke_width', models.FloatField(blank=True, null=True, verbose_name='stroke width')),
+ ('fill_color', models.CharField(blank=True, max_length=255, null=True, verbose_name='fill color')),
+ ('show_label', models.BooleanField(default=False, verbose_name='show label')),
+ ('show_geometry', models.BooleanField(default=True, verbose_name='show geometry')),
+ ('interactive', models.BooleanField(default=True, help_text='disable to make this feature click-through', verbose_name='interactive')),
+ ('point_icon', models.CharField(blank=True, help_text='use this material icon to display points, instead of drawing a small circle (only makes sense if the geometry is a point)', max_length=255, null=True, verbose_name='point icon')),
+ ('extra_data', django_pydantic_field.fields.PydanticSchemaField(blank=True, config=None, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True, schema=django_pydantic_field.compat.django.GenericContainer(typing.Union, (django_pydantic_field.compat.django.GenericContainer(dict, (str, str)), types.NoneType)), verbose_name='extra data (JSON object)')),
+ ('level', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='data_overlay_features', to='mapdata.level', verbose_name='level')),
+ ('overlay', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='mapdata.dataoverlay', verbose_name='Overlay')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/src/c3nav/mapdata/models/__init__.py b/src/c3nav/mapdata/models/__init__.py
index 9739f452..277bd505 100644
--- a/src/c3nav/mapdata/models/__init__.py
+++ b/src/c3nav/mapdata/models/__init__.py
@@ -8,3 +8,4 @@ from c3nav.mapdata.models.locations import Location, LocationSlug, LocationGroup
from c3nav.mapdata.models.source import Source # noqa
from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa
from c3nav.mapdata.models.theme import Theme # noqa
+from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa
\ No newline at end of file
diff --git a/src/c3nav/mapdata/models/overlay.py b/src/c3nav/mapdata/models/overlay.py
new file mode 100644
index 00000000..7075096a
--- /dev/null
+++ b/src/c3nav/mapdata/models/overlay.py
@@ -0,0 +1,84 @@
+from typing import Optional
+
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django_pydantic_field import SchemaField
+
+from c3nav.mapdata.fields import GeometryField
+from c3nav.mapdata.models.base import TitledMixin
+from c3nav.mapdata.models.geometry.base import GeometryMixin
+from c3nav.mapdata.utils.geometry import smart_mapping
+from c3nav.mapdata.utils.json import format_geojson
+
+
+class DataOverlay(TitledMixin, models.Model):
+ description = models.TextField(blank=True, verbose_name=_('Description'))
+ stroke_color = models.TextField(blank=True, null=True, verbose_name=_('default stroke color'))
+ stroke_width = models.FloatField(blank=True, null=True, verbose_name=_('default stroke width'))
+ fill_color = models.TextField(blank=True, null=True, verbose_name=_('default fill color'))
+ pull_url = models.URLField(blank=True, null=True, verbose_name=_('pull URL'))
+ pull_headers: dict[str, str] = SchemaField(schema=dict[str, str], null=True,
+ verbose_name=_('headers for pull http request (JSON object)'))
+ pull_interval = models.DurationField(blank=True, null=True, verbose_name=_('pull interval'))
+
+
+ class Meta:
+ verbose_name = _('Data Overlay')
+ verbose_name_plural = _('Data Overlays')
+ default_related_name = 'data_overlays'
+
+class DataOverlayFeature(TitledMixin, GeometryMixin, models.Model):
+ overlay = models.ForeignKey('mapdata.DataOverlay', on_delete=models.CASCADE, verbose_name=_('Overlay'), related_name='features')
+ geometry = GeometryField()
+ level = models.ForeignKey('mapdata.Level', on_delete=models.CASCADE, verbose_name=_('level'), related_name='data_overlay_features')
+ external_url = models.URLField(blank=True, null=True, verbose_name=_('external URL'))
+ stroke_color = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('stroke color'))
+ stroke_width = models.FloatField(blank=True, null=True, verbose_name=_('stroke width'))
+ fill_color = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('fill color'))
+ show_label = models.BooleanField(default=False, verbose_name=_('show label'))
+ show_geometry = models.BooleanField(default=True, verbose_name=_('show geometry'))
+ interactive = models.BooleanField(default=True, verbose_name=_('interactive'),
+ help_text=_('disable to make this feature click-through'))
+ point_icon = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('point icon'),
+ help_text=_(
+ 'use this material icon to display points, instead of drawing a small circle (only makes sense if the geometry is a point)'))
+ extra_data: Optional[dict[str, str]] = SchemaField(schema=dict[str, str], blank=True, null=True, default=None,
+ verbose_name=_('extra data (JSON object)'))
+
+
+ def to_geojson(self, instance=None) -> dict:
+ result = {
+ 'type': 'Feature',
+ 'properties': {
+ 'type': 'dataoverlayfeature',
+ 'id': self.id,
+ 'level': self.level_id,
+ 'overlay': self.overlay_id,
+ },
+ 'geometry': format_geojson(smart_mapping(self.geometry), rounded=False),
+ }
+ original_geometry = getattr(self, 'original_geometry', None)
+ if original_geometry:
+ result['original_geometry'] = format_geojson(smart_mapping(original_geometry), rounded=False)
+ return result
+
+ def get_geojson_key(self):
+ return 'dataoverlayfeature', self.id
+
+
+ def _serialize(self, **kwargs):
+ result = super()._serialize(**kwargs)
+ result.update({
+ 'level_id': self.level_id,
+ 'stroke_color': self.stroke_color,
+ 'stroke_width': self.stroke_width,
+ 'fill_color': self.fill_color,
+ 'show_label': self.show_label,
+ 'show_geometry': self.show_geometry,
+ 'interactive': self.interactive,
+ 'point_icon': self.point_icon,
+ 'external_url': self.external_url,
+ 'extra_data': self.extra_data,
+ })
+ result['level_id'] = self.level_id
+ return result
\ No newline at end of file
diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py
index 2f061f64..5539d683 100644
--- a/src/c3nav/mapdata/schemas/models.py
+++ b/src/c3nav/mapdata/schemas/models.py
@@ -4,7 +4,7 @@ from pydantic import Discriminator
from pydantic import Field as APIField
from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt
-from c3nav.api.schema import BaseSchema, GeometrySchema, PointSchema
+from c3nav.api.schema import BaseSchema, GeometrySchema, PointSchema, AnyGeometrySchema
from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.models import LocationGroup
from c3nav.mapdata.schemas.model_base import (AnyLocationID, AnyPositionID, CustomLocationID, DjangoModelSchema,
@@ -318,6 +318,27 @@ class DynamicLocationSchema(SpecificLocationSchema, DjangoModelSchema):
pass
+class DataOverlaySchema(TitledSchema, DjangoModelSchema):
+ # TODO
+ pass
+
+
+class DataOverlayFeatureSchema(TitledSchema, DjangoModelSchema):
+ geometry: AnyGeometrySchema
+ level_id: PositiveInt
+ stroke_color: Optional[str]
+ stroke_width: Optional[float]
+ fill_color: Optional[str]
+ show_label: bool
+ show_geometry: bool
+ interactive: bool
+ point_icon: Optional[str]
+ external_url: Optional[str]
+ extra_data: Optional[dict[str, str]]
+ # TODO
+ pass
+
+
class SourceSchema(WithAccessRestrictionSchema, DjangoModelSchema):
"""
A source image that can be traced in the editor.
@@ -653,7 +674,6 @@ SlimLocationSchema = Annotated[
Discriminator("locationtype"),
]
-
listable_location_definitions = schema_definitions(
(LevelSchema, SpaceSchema, AreaSchema, POISchema, DynamicLocationSchema, LocationGroupSchema)
)
@@ -848,4 +868,4 @@ class LegendItemSchema(BaseSchema):
class LegendSchema(BaseSchema):
base: list[LegendItemSchema]
groups: list[LegendItemSchema]
- obstacles: list[LegendItemSchema]
\ No newline at end of file
+ obstacles: list[LegendItemSchema]
diff --git a/src/c3nav/mapdata/utils/user.py b/src/c3nav/mapdata/utils/user.py
index a34326f5..21e5722d 100644
--- a/src/c3nav/mapdata/utils/user.py
+++ b/src/c3nav/mapdata/utils/user.py
@@ -3,6 +3,7 @@ from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
+from c3nav.mapdata.models import DataOverlay
from c3nav.mapdata.models.access import AccessPermission, AccessRestriction
from c3nav.mapdata.models.locations import Position
@@ -30,6 +31,18 @@ def get_user_data(request):
})
if request.user.is_authenticated:
result['title'] = request.user.username
+
+ # TODO: permissions for overlays
+ result.update({
+ 'overlays': [{
+ 'id': overlay.pk,
+ 'name': overlay.title,
+ 'group': None, # TODO
+ 'stroke_color': overlay.stroke_color,
+ 'stroke_width': overlay.stroke_width,
+ 'fill_color': overlay.fill_color,
+ } for overlay in DataOverlay.objects.all()]
+ })
return result
diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss
index e6eb383d..1173c532 100644
--- a/src/c3nav/site/static/site/css/c3nav.scss
+++ b/src/c3nav/site/static/site/css/c3nav.scss
@@ -1058,7 +1058,7 @@ main:not([data-view=route-result]) #route-summary {
font-size: 20px;
}
-.leaflet-bar, .leaflet-touch .leaflet-bar, .leaflet-control-key {
+.leaflet-bar, .leaflet-touch .leaflet-bar, .leaflet-control-key, .leaflet-control-overlays {
overflow: hidden;
background-color: var(--color-control-background);
border-radius: var(--border-radius-leaflet-control);
@@ -1676,10 +1676,10 @@ blink {
margin-top: 48px;
}
-.leaflet-control-key {
+.leaflet-control-key, .leaflet-control-overlays {
background-clip: padding-box;
- &.leaflet-control-key-expanded > .collapsed-toggle {
+ &.leaflet-control-key-expanded > .collapsed-toggle, &.leaflet-control-overlays-expanded > .collapsed-toggle {
display: none;
}
@@ -1699,7 +1699,6 @@ blink {
&::before {
font-family: 'Material Symbols Outlined';
- content: 'legend_toggle';
font-size: 26px;
line-height: 26px;
}
@@ -1736,23 +1735,36 @@ blink {
}
}
- &.leaflet-control-key-expanded > .pin-toggle {
+ &.leaflet-control-key-expanded > .pin-toggle, &.leaflet-control-overlays-expanded > .pin-toggle {
display: block;
.leaflet-touch & {
display: none;
}
}
-
> .content {
display: none;
- padding: 1rem 3rem 1rem 1rem;
- gap: 1rem;
- grid-template-columns: 2rem 1fr;
+ padding: 1rem 4rem 1rem 1rem;
.leaflet-touch & {
padding: 1rem;
}
+ }
+
+ &.leaflet-control-key-expanded > .content {
+ display: grid;
+ }
+}
+
+
+.leaflet-control-key {
+ > .collapsed-toggle::before {
+ content: 'legend_toggle';
+ }
+
+ > .content {
+ gap: 1rem;
+ grid-template-columns: 2rem 1fr;
> .key {
display: grid;
@@ -1774,6 +1786,59 @@ blink {
}
}
+.leaflet-control-overlays {
+ > .collapsed-toggle::before {
+ content: 'stacks';
+ }
+
+ > .content {
+ flex-direction: column;
+ gap: 1rem;
+
+ .overlay-group {
+ display: flex;
+ flex-direction: column;
+ h4 {
+ margin-top: 0;
+ margin-bottom: 0;
+ cursor: pointer;
+
+ &::before {
+ font-family: 'Material Symbols Outlined';
+ content: 'arrow_right';
+ vertical-align: middle;
+ }
+ }
+
+ label {
+ cursor: pointer;
+ margin-left: 3ch;
+ margin-bottom: 0;
+ }
+
+ input[type=checkbox] {
+ margin-right: 0.5rem;
+ margin-bottom: 0;
+ }
+ }
+
+ .overlay-group.expanded h4::before {
+ content: 'arrow_drop_down';
+ }
+
+ .overlay-group:not(.expanded) label {
+ height: 0;
+ overflow: hidden;
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ }
+
+ &.leaflet-control-overlays-expanded > .content {
+ display: flex;
+ }
+}
+
.leaflet-top.leaflet-right {
z-index: 2000;
}
@@ -1798,4 +1863,43 @@ blink {
.leaflet-top.leaflet-right {
margin-top: var(--control-container-minus-size);
}
+}
+
+.overlay-point-icon {
+ > span {
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ line-height: 24px;
+ text-align: center;
+ font-size: 24px;
+ font-family: 'Material Symbols Outlined';
+ }
+}
+
+.data-overlay-popup {
+ .leaflet-popup-content {
+ margin: 0;
+
+ > h4, a {
+ margin: 8px 12px 4px;
+ }
+
+ > table {
+ width: calc(100% + 2px);
+ margin: 4px -2px;
+ border-collapse: collapse;
+
+ th, td {
+ padding: 4px 12px;
+ border: 1px solid var(--color-border);
+ &:first-child {
+ border-left: 0;
+ }
+ &:last-child {
+ border-right: 0;
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/src/c3nav/site/static/site/js/c3nav.js b/src/c3nav/site/static/site/js/c3nav.js
index ba39664e..19102990 100644
--- a/src/c3nav/site/static/site/js/c3nav.js
+++ b/src/c3nav/site/static/site/js/c3nav.js
@@ -1449,6 +1449,7 @@ c3nav = {
c3nav._routeLayers = {};
c3nav._routeLayerBounds = {};
c3nav._userLocationLayers = {};
+ c3nav._overlayLayers = {};
c3nav._firstRouteLevel = null;
c3nav._labelLayer = L.LayerGroup.collision({margin: 5}).addTo(c3nav.map);
for (i = c3nav.levels.length - 1; i >= 0; i--) {
@@ -1458,6 +1459,7 @@ c3nav = {
c3nav._locationLayers[level[0]] = L.layerGroup().addTo(layerGroup);
c3nav._routeLayers[level[0]] = L.layerGroup().addTo(layerGroup);
c3nav._userLocationLayers[level[0]] = L.layerGroup().addTo(layerGroup);
+ c3nav._overlayLayers[level[0]] = L.layerGroup().addTo(layerGroup);
}
c3nav._levelControl.finalize();
c3nav._levelControl.setLevel(c3nav.initial_level);
@@ -1480,6 +1482,8 @@ c3nav = {
position: 'bottomright'
}).addTo(c3nav.map);
+ c3nav._update_overlays();
+
c3nav.map.on('click', c3nav._click_anywhere);
c3nav.schedule_fetch_updates();
@@ -1855,12 +1859,34 @@ c3nav = {
_set_user_data: function (data) {
c3nav_api.authenticate();
c3nav.user_data = data;
+ c3nav._update_overlays();
var $user = $('header #user');
$user.find('span').text(data.title);
$user.find('small').text(data.subtitle || '');
$('.position-buttons').toggle(data.has_positions);
if (window.mobileclient) mobileclient.setUserData(JSON.stringify(data));
},
+ _current_overlays_key: null,
+ _update_overlays: function () {
+ if (!c3nav.map) return;
+
+ const key = c3nav.user_data.overlays.map(o => o.id).join(',');
+ if (key === c3nav._current_overlays_key) return;
+ c3nav._current_overlays_key = key;
+
+ const control = new OverlayControl({levels: c3nav._overlayLayers});
+ for (const overlay of c3nav.user_data.overlays) {
+ control.addOverlay(new DataOverlay(overlay));
+ }
+
+ if (c3nav._overlayControl) {
+ c3nav.map.removeControl(c3nav._overlayControl);
+ }
+
+ if (c3nav.user_data.overlays.length > 0) {
+ c3nav._overlayControl = control.addTo(c3nav.map);
+ }
+ },
_hasLocationPermission: undefined,
hasLocationPermission: function (nocache) {
@@ -2489,7 +2515,7 @@ KeyControl = L.Control.extend({
this._pin = L.DomUtil.create('div', 'pin-toggle material-symbols', this._container);
this._pin.classList.toggle('active', pinned);
this._pin.innerText = 'push_pin';
- this._collapsed = L.DomUtil.create('a', 'collapsed-toggle leaflet-control-key-toggle', this._container);
+ this._collapsed = L.DomUtil.create('a', 'collapsed-toggle', this._container);
this._collapsed.href = '#';
this._expanded = pinned;
this._pinned = pinned;
@@ -2502,7 +2528,6 @@ KeyControl = L.Control.extend({
}
-
if (L.Browser.touch) {
this._pinned = false;
console.log('installing touch handlers')
@@ -2588,6 +2613,162 @@ KeyControl = L.Control.extend({
},
});
+OverlayControl = L.Control.extend({
+ options: {position: 'topright', addClasses: '', levels: {}},
+ _overlays: {},
+ _groups: {},
+ _initialActiveOverlays: null,
+ _initialCollapsedGroups: null,
+
+ initialize: function ({levels, ...config}) {
+ this.config = config;
+ this._levels = levels;
+ },
+
+ onAdd: function () {
+ this._initialActiveOverlays = JSON.parse(localStorage.getItem('c3nav.overlays.active-overlays') ?? '[]');
+ this._initialCollapsedGroups = JSON.parse(localStorage.getItem('c3nav.overlays.collapsed-groups') ?? '[]');
+ const pinned = JSON.parse(localStorage.getItem('c3nav.overlays.pinned') ?? 'false');
+
+ this._container = L.DomUtil.create('div', 'leaflet-control-overlays ' + this.options.addClasses);
+ this._container.classList.toggle('leaflet-control-overlays-expanded', pinned);
+ this._content = L.DomUtil.create('div', 'content');
+ const collapsed = L.DomUtil.create('div', 'collapsed-toggle');
+ this._pin = L.DomUtil.create('div', 'pin-toggle material-symbols');
+ this._pin.classList.toggle('active', pinned);
+ this._pin.innerText = 'push_pin';
+ this._container.append(this._pin, this._content, collapsed);
+ this._expanded = pinned;
+ this._pinned = pinned;
+
+ if (!L.Browser.android) {
+ L.DomEvent.on(this._container, {
+ mouseenter: this.expand,
+ mouseleave: this.collapse
+ }, this);
+ }
+
+ if (!L.Browser.touch) {
+ L.DomEvent.on(this._container, 'focus', this.expand, this);
+ L.DomEvent.on(this._container, 'blur', this.collapse, this);
+ }
+
+ for (const overlay of this._initialActiveOverlays) {
+ if (overlay in this._overlays) {
+ this._overlays[overlay].visible = true;
+ this._overlays[overlay].enable(this._levels);
+ }
+ }
+
+ for (const group of this._initialCollapsedGroups) {
+ if (group in this._groups) {
+ this._groups[group].expanded = false;
+ }
+ }
+
+ this.render();
+
+ $(this._container).on('change', 'input[type=checkbox]', e => {
+ this._overlays[e.target.dataset.id].visible = e.target.checked;
+ this.updateOverlay(e.target.dataset.id);
+ });
+ $(this._container).on('click', 'div.pin-toggle', e => {
+ this.togglePinned();
+ });
+ $(this._container).on('click', '.content h4', e => {
+ this.toggleGroup(e.target.parentElement.dataset.group);
+ });
+ $(this._container).on('mousedown pointerdown wheel', e => {
+ e.stopPropagation();
+ });
+ return this._container;
+ },
+
+ addOverlay: function (overlay) {
+ this._overlays[overlay.id] = overlay;
+ if (overlay.group in this._groups) {
+ this._groups[overlay.group].overlays.push(overlay);
+ } else {
+ this._groups[overlay.group] = {
+ expanded: this._initialCollapsedGroups === null || !this._initialCollapsedGroups.includes(overlay.group),
+ overlays: [overlay],
+ };
+ }
+ this.render();
+ },
+
+ updateOverlay: function (id) {
+ const overlay = this._overlays[id];
+ if (overlay.visible) {
+ overlay.enable(this._levels);
+ } else {
+ overlay.disable(this._levels);
+ }
+ const activeOverlays = Object.keys(this._overlays).filter(k => this._overlays[k].visible);
+ localStorage.setItem('c3nav.overlays.active-overlays', JSON.stringify(activeOverlays));
+ },
+
+ render: function () {
+ if (!this._content) return;
+ const groups = document.createDocumentFragment();
+ for (const group in this._groups) {
+ const group_container = document.createElement('div');
+ group_container.classList.add('overlay-group');
+ if (this._groups[group].expanded) {
+ group_container.classList.add('expanded');
+ }
+ this._groups[group].el = group_container;
+ group_container.dataset.group = group;
+ const title = document.createElement('h4');
+ title.innerText = group;
+ group_container.append(title);
+ for (const overlay of this._groups[group].overlays) {
+ const label = document.createElement('label');
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.dataset.id = overlay.id;
+ if (overlay.visible) {
+ checkbox.checked = true;
+ }
+ label.append(checkbox, overlay.name);
+ group_container.append(label);
+ }
+ groups.append(group_container);
+ }
+ this._content.replaceChildren(...groups.children);
+ },
+
+ expand: function () {
+ if (this._pinned) return;
+ this._expanded = true;
+ this._container.classList.add('leaflet-control-overlays-expanded');
+ return this;
+ },
+
+ collapse: function () {
+ if (this._pinned) return;
+ this._expanded = false;
+ this._container.classList.remove('leaflet-control-overlays-expanded');
+ return this;
+ },
+
+ toggleGroup: function (name) {
+ const group = this._groups[name];
+ group.expanded = !group.expanded;
+ group.el.classList.toggle('expanded', group.expanded);
+ const collapsedGroups = Object.keys(this._groups).filter(k => !this._groups[k].expanded);
+ localStorage.setItem('c3nav.overlays.collapsed-groups', JSON.stringify(collapsedGroups));
+ },
+
+ togglePinned: function () {
+ this._pinned = !this._pinned;
+ if (this._pinned) {
+ this._expanded = true;
+ }
+ this._pin.classList.toggle('active', this._pinned);
+ localStorage.setItem('c3nav.overlays.pinned', JSON.stringify(this._pinned));
+ },
+});
var SvgIcon = L.Icon.extend({
options: {
@@ -2645,4 +2826,99 @@ var SvgIcon = L.Icon.extend({
return svgEl;
},
-});
\ No newline at end of file
+});
+
+class DataOverlay {
+ levels = null;
+
+ constructor(options) {
+ this.id = options.id;
+ this.name = options.name;
+ this.group = options.group ?? 'ungrouped';
+ this.default_stroke_color = options.stroke_color;
+ this.default_stroke_width = options.stroke_width;
+ this.default_fill_color = options.fill_color;
+ }
+
+ async create() {
+ const features = await c3nav_api.get(`mapdata/overlays/${this.id}/`);
+
+ const levels = {};
+ for (const feature of features) {
+ const level_id = feature.level_id;
+ if (!(level_id in levels)) {
+ levels[level_id] = L.layerGroup([]);
+ }
+ const style = {
+ 'color': feature.stroke_color ?? this.default_stroke_color ?? 'var(--color-map-overlay)',
+ 'weight': feature.stroke_width ?? this.default_stroke_width ?? 1,
+ 'fillColor': feature.fill_color ?? this.default_fill_color ?? 'var(--color-map-overlay)',
+ };
+ const layer = L.geoJson(feature.geometry, {
+ style,
+ interactive: feature.interactive,
+ pointToLayer: (geom, latlng) => {
+ if (feature.point_icon !== null) {
+ return L.marker(latlng, {
+ title: feature.title,
+ icon: L.divIcon({
+ className: 'overlay-point-icon',
+ html: `${feature.point_icon}`,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ })
+ });
+ } else {
+ return L.circleMarker(latlng, {
+ title: feature.title,
+ ...style
+ });
+ }
+ }
+ });
+ if (feature.interactive) {
+ layer.bindPopup(() => {
+ let html = `${feature.title}`;
+ if (feature.external_url != null) {
+ html += `open external link`;
+ }
+ if (feature.extra_data != null) {
+ html += '';
+ for (const key in feature.extra_data) {
+ html += `${key} | ${feature.extra_data[key]} | `;
+ }
+
+ html += ' ';
+ }
+ return html;
+ }, {
+ className: 'data-overlay-popup'
+ });
+ }
+ levels[level_id].addLayer(layer);
+ }
+
+ this.levels = levels;
+ }
+
+ async enable(levels) {
+ if (!this.levels) {
+ await this.create();
+ }
+ for (const id in levels) {
+ if (id in this.levels) {
+ levels[id].addLayer(this.levels[id]);
+ }
+ }
+ }
+
+ disable(levels) {
+ for (const id in levels) {
+ if (id in this.levels) {
+ levels[id].removeLayer(this.levels[id]);
+ }
+ }
+ }
+
+
+}
\ No newline at end of file
|