data overlays

This commit is contained in:
Gwendolyn 2024-11-21 11:56:31 +01:00
parent 60de7857d6
commit 7904a95b80
22 changed files with 1230 additions and 219 deletions

View file

@ -53,6 +53,7 @@ def geometrystyles(request):
'altitudemarker': '#0000FF',
'beaconmeasurement': '#DDDD00',
'rangingbeacon': '#CC00CC',
'dataoverlayfeature': '#3366ff',
}

View file

@ -52,6 +52,9 @@ def _get_geometries_for_one_level(level):
results.append(door)
results.extend(sorted(spaces.values(), key=space_sorting_func))
results.extend(level.data_overlay_features.all())
return results
@ -121,6 +124,7 @@ def get_level_geometries_result(request, level_id: int, update_cache_key: str, u
LocationGroup = request.changeset.wrap_model('LocationGroup')
BeaconMeasurement = request.changeset.wrap_model('BeaconMeasurement')
RangingBeacon = request.changeset.wrap_model('RangingBeacon')
DataOverlayFeature = request.changeset.wrap_model('DataOverlayFeature')
try:
level = Level.objects.filter(Level.q_for_request(request)).get(pk=level_id)
@ -151,7 +155,8 @@ def get_level_geometries_result(request, level_id: int, update_cache_key: str, u
Prefetch('spaces__altitudemarkers', AltitudeMarker.objects.only('geometry', 'space')),
Prefetch('spaces__beacon_measurements', BeaconMeasurement.objects.only('geometry', 'space')),
Prefetch('spaces__ranging_beacons', RangingBeacon.objects.only('geometry', 'space')),
Prefetch('spaces__graphnodes', graphnodes_qs)
Prefetch('spaces__graphnodes', graphnodes_qs),
Prefetch('data_overlay_features', DataOverlayFeature.objects.only('geometry', 'overlay_id', 'level'))
)
levels = {s.pk: s for s in levels}

View file

@ -63,6 +63,7 @@ class EditorGeometriesPropertiesSchema(BaseSchema):
Annotated[str, APIField(title="color")],
Annotated[None, APIField(title="no color")]
] = None
overlay: Optional[EditorID] = None
opacity: Optional[float] = None # todo: range

View file

@ -439,6 +439,8 @@ def create_editor_form(editor_model):
'icon_path', 'leaflet_marker_config',
'color_background', 'color_wall_fill', 'color_wall_border', 'color_door_fill',
'color_ground_fill', 'color_obstacles_default_fill', 'color_obstacles_default_border',
'stroke_color', 'stroke_width', 'fill_color', 'interactive', 'point_icon', 'extra_data',
'show_label', 'show_geometry', 'external_url',
]
field_names = [field.name for field in editor_model._meta.get_fields() if not field.one_to_many]
existing_fields = [name for name in possible_fields if name in field_names]

View file

@ -206,6 +206,9 @@ editor = {
if (document.querySelector('#sidebar [data-themed-color]')) {
editor.theme_editor_loaded();
}
if (document.querySelector('TODO')) {
}
},
theme_editor_loaded: function () {
const filter_show_all = () => {
@ -263,6 +266,7 @@ editor = {
},
_in_modal: false,
sidebar_extra_data: {},
_sidebar_loaded: function (data) {
// sidebar was loaded. load the content. check if there are any redirects. call _check_start_editing.
var content = $('#sidebar').removeClass('loading').find('.content');
@ -287,6 +291,14 @@ editor = {
editor._beacon_layer.clearLayers();
const extraData = content.find('#sidebar-extra-data').first().text();
if (extraData) {
editor.sidebar_extra_data = JSON.parse(extraData);
} else {
editor.sidebar_extra_data = null;
}
var group;
if (content.find('[name=fixed_x]').length) {
$('[name=name]').change(editor._source_name_selected).change();
@ -311,7 +323,9 @@ editor = {
content.find('[name=left], [name=bottom], [name=right], [name=top]').change(editor._source_image_bounds_changed);
content.find('[name=scale_x], [name=scale_y]').change(editor._source_image_scale_changed);
content.find('[name=left], [name=bottom], [name=right], [name=top]').each(function() { $(this).data('oldval', $(this).val()); });
content.find('[name=left], [name=bottom], [name=right], [name=top]').each(function () {
$(this).data('oldval', $(this).val());
});
content.find('[name=lock_aspect], [name=lock_scale]').closest('.form-group').addClass('source-wizard');
@ -536,7 +550,10 @@ editor = {
if (editor._source_image_layer) {
editor._source_image_layer.setBounds(bounds)
} else {
editor._source_image_layer = L.imageOverlay('/editor/sourceimage/'+content.find('[name=name]').val(), bounds, {opacity: 0.3, zIndex: 10000});
editor._source_image_layer = L.imageOverlay('/editor/sourceimage/' + content.find('[name=name]').val(), bounds, {
opacity: 0.3,
zIndex: 10000
});
editor._source_image_layer.addTo(editor.map);
if (content.find('[data-new]').length) {
editor.map.fitBounds(bounds, {padding: [30, 50]});
@ -565,7 +582,12 @@ editor = {
$(this).data('oldval', newval);
if (lock_scale) {
if (!isNaN(diff)) {
var other_field_name = {left: 'right', right: 'left', top: 'bottom', bottom: 'top'}[$(this).attr('name')],
var other_field_name = {
left: 'right',
right: 'left',
top: 'bottom',
bottom: 'top'
}[$(this).attr('name')],
other_field = content.find('[name=' + other_field_name + ']'),
other_val = parseFloat(other_field.val());
if (!isNaN(other_val)) {
@ -869,8 +891,17 @@ editor = {
geometries.splice(remove_feature, 1);
}
if (editor._last_graph_path === null) {
geometries = geometries.filter(function(val) { return val.properties.type !== 'graphnode' && val.properties.type !== 'graphedge' })
geometries = geometries.filter(function (val) {
return val.properties.type !== 'graphnode' && val.properties.type !== 'graphedge'
})
}
if (editor.sidebar_extra_data?.activeOverlayId) {
geometries = geometries.filter(g => g.properties.type !== 'dataoverlayfeature' || g.properties.overlay === editor.sidebar_extra_data.activeOverlayId);
} else {
geometries = geometries.filter(g => g.properties.type !== 'dataoverlayfeature');
}
editor._geometries_layer = L.geoJSON(geometries, {
style: editor._get_geometry_style,
pointToLayer: editor._point_to_layer,
@ -990,6 +1021,13 @@ editor = {
if (feature.properties.opacity !== null) {
style.fillOpacity = feature.properties.opacity;
}
if (feature.properties.type === 'dataoverlayfeature') {
style.stroke = true;
style.weight = 3;
style.fillOpacity = 0.5;
}
return style
},
_get_mapitem_type_style: function (mapitem_type) {
@ -1136,7 +1174,6 @@ editor = {
// highlight a geometries layer and itemtable row if they both exist
var geometry = editor._highlight_geometries[id];
if (!geometry) return;
if (Object.keys(geometry._bounds).length === 0) return; // ignore geometries with empty bounds
geometry.setStyle({
color: '#FFFFDD',
weight: 3,
@ -1149,7 +1186,6 @@ editor = {
// unhighlight whatever is highlighted currently
var geometry = editor._highlight_geometries[id];
if (!geometry) return;
if (Object.keys(geometry._bounds).length === 0) return; // ignore geometries with empty bounds
geometry.setStyle({
weight: 3,
opacity: 0,
@ -1237,7 +1273,9 @@ editor = {
}
if (options) {
editor._editing_layer = L.geoJSON(JSON.parse(geometry_field.val()), {
style: function() { return options; },
style: function () {
return options;
},
pointToLayer: editor._point_to_layer,
}).getLayers()[0].addTo(editor._geometries_layer);
editor._editing_layer.enableEdit();
@ -1252,17 +1290,39 @@ editor = {
options.fillOpacity = 0.5;
}
form.addClass('creation-lock');
var geomtype = form.attr('data-geomtype');
const geomtypes = form.attr('data-geomtype').split(',');
const startGeomEditing = (geomtype) => {
editor._creating_type = geomtype;
editor._creating = true;
if (editor._current_editing_shape) {
editor._current_editing_shape.remove();
}
if (geomtype === 'polygon') {
editor.map.editTools.startPolygon(null, options);
editor._current_editing_shape = editor.map.editTools.startPolygon(null, options);
} else if (geomtype === 'linestring') {
options = editor._line_draw_geometry_style(options);
editor.map.editTools.startPolyline(null, options);
editor._current_editing_shape = editor.map.editTools.startPolyline(null, options);
} else if (geomtype === 'point') {
editor.map.editTools.startMarker(null, options);
editor._current_editing_shape = editor.map.editTools.startMarker(null, options);
}
editor._creating = true;
}
if (geomtypes.length > 1) {
const selector = $('<select id="geomtype-selector"></select>');
const geomtypeNames = {
polygon: 'Polygon',
linestring: 'Line string',
point: 'Point'
}; // TODO: translations
for(const geomtype of geomtypes) {
selector.append(`<option value="${geomtype}">${geomtypeNames[geomtype]}</option>`);
}
selector.on('change', e => startGeomEditing(e.target.value));
form.prepend(selector);
}
startGeomEditing(geomtypes[0]);
}
}
},
@ -1358,7 +1418,9 @@ editor = {
},
_scancollector_reset: function () {
var $collector = $('#sidebar').find('.scancollector');
$collector.removeClass('done').removeClass('running').addClass('empty').find('table tbody').each(function(elem) {elem.innerHTML = "";});
$collector.removeClass('done').removeClass('running').addClass('empty').find('table tbody').each(function (elem) {
elem.innerHTML = "";
});
$collector.siblings('[name=data]').val('');
$collector.closest('form').addClass('scan-lock');
editor._beacon_layer.clearLayers();
@ -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);

View file

@ -3,6 +3,10 @@
{% include 'editor/fragment_levels.html' %}
{% if extra_json_data %}
{{ extra_json_data|json_script:"sidebar-extra-data" }}
{% endif %}
<h3>
{% if new %}
{% blocktrans %}New {{ model_title }}{% endblocktrans %}

View file

@ -12,4 +12,7 @@
{% trans 'Graph' %}
</a>
{% endif %}
{% if overlays_url %}
<a href="{{ overlays_url }}" class="list-group-item">{% trans 'Overlays' %}</a>
{% endif %}
</div>

View file

@ -26,7 +26,8 @@
</p>
{% 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 %}
<div class="clearfix"></div>

View file

@ -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 %}
<h3>
{% blocktrans %}Overlay "{{ title }}"{% endblocktrans %}
{% with level.title as level_title %}
<small>{% blocktrans %}on level {{ level_title }}{% endblocktrans %}</small>
{% endwith %}
</h3>
{% bootstrap_messages %}
{% if can_create %}
<a class="btn btn-default btn-xs" accesskey="n" href="{{ create_url }}">
<i class="glyphicon glyphicon-plus"></i> {% blocktrans %}New feature{% endblocktrans %}
</a>
{% endif %}
{% trans 'Edit' as edit_caption %}
<table class="table table-condensed itemtable" data-nozoom data-list="dataoverlayfeature" data-overlay-id="{{ overlay_id }}">
<tbody>
{% for feature in features %}
{% if forloop.counter0|divisibleby:10 %}
<tr>
<td><a href="{{ back_url }}" data-no-next-zoom>&laquo; {{ back_title }}</a></td>
<td></td>
</tr>
{% endif %}
<tr data-pk="{{ feature.pk }}">
<td>{{ feature.title }}</td>
<td><a href="{{ feature.edit_url }}">{{ edit_caption }}</a></td>
</tr>
{% endfor %}
<tr>
<td><a href="{{ back_url }}" data-no-next-zoom>&laquo; {{ back_title }}</a></td>
<td></td>
</tr>
</tbody>
</table>

View file

@ -0,0 +1,17 @@
{% load bootstrap3 %}
{% load i18n %}
{% include 'editor/fragment_levels.html' %}
<h3>{% trans 'Data Overlays' %}</h3>
{% bootstrap_messages %}
<p>
<a href="{% url 'editor.levels.detail' pk=level.pk %}">&laquo; {% trans 'back to level' %}</a>
</p>
<div class="list-group">
{% for overlay in overlays %}
<a href="{% url 'editor.levels.overlay' level=level.pk pk=overlay.pk %}" class="list-group-item">
{{ overlay.title }}
</a>
{% endfor %}
</div>

View file

@ -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/<editpk:level>/graph/', graph_edit, name='editor.levels.graph'),
path('spaces/<editpk:space>/graph/', graph_edit, name='editor.spaces.graph'),
path('levels/<editpk:level>/overlays/', overlays_list, name='editor.levels.overlays'),
path('levels/<editpk:level>/overlays/<editpk:pk>/', overlay_features, name='editor.levels.overlay'),
path('levels/<editpk:level>/overlays/<editpk:overlay>/create', overlay_feature_edit, name='editor.levels.overlay.create'),
path('levels/<editpk:level>/overlays/<editpk:overlay>/features/<editpk:pk>', overlay_feature_edit, name='editor.levels.overlay.edit'),
path('changeset/', changeset_redirect, name='editor.changesets.current'),
path('changesets/<editpk:pk>/', changeset_detail, name='editor.changesets.detail'),
path('changesets/<editpk:pk>/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'))

View file

@ -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'))

View file

@ -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)

View file

@ -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

View file

@ -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),

View file

@ -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,
},
),
]

View file

@ -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

View file

@ -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

View file

@ -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)
)

View file

@ -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

View file

@ -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;
}
@ -1799,3 +1864,42 @@ blink {
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;
}
}
}
}
}

View file

@ -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: {
@ -2646,3 +2827,98 @@ var SvgIcon = L.Icon.extend({
return svgEl;
},
});
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: `<span style="color: ${style.color}">${feature.point_icon}</span>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
})
});
} else {
return L.circleMarker(latlng, {
title: feature.title,
...style
});
}
}
});
if (feature.interactive) {
layer.bindPopup(() => {
let html = `<h4>${feature.title}</h4>`;
if (feature.external_url != null) {
html += `<a href="${feature.external_url}" target="_blank">open external link</a>`;
}
if (feature.extra_data != null) {
html += '<table>';
for (const key in feature.extra_data) {
html += `<tr><th>${key}</th><td>${feature.extra_data[key]}</td></tr>`;
}
html += '</table>';
}
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]);
}
}
}
}