From 7904a95b8084a22271a5c10427cef6c9b087e4d7 Mon Sep 17 00:00:00 2001 From: Gwendolyn Date: Thu, 21 Nov 2024 11:56:31 +0100 Subject: [PATCH] data overlays --- src/c3nav/editor/api/endpoints.py | 1 + src/c3nav/editor/api/geometries.py | 7 +- src/c3nav/editor/api/schemas.py | 1 + src/c3nav/editor/forms.py | 2 + src/c3nav/editor/static/editor/js/editor.js | 458 ++++++++++-------- src/c3nav/editor/templates/editor/edit.html | 4 + .../editor/fragment_child_models.html | 3 + src/c3nav/editor/templates/editor/level.html | 3 +- .../templates/editor/overlay_features.html | 45 ++ .../editor/templates/editor/overlays.html | 17 + src/c3nav/editor/urls.py | 6 + src/c3nav/editor/views/edit.py | 1 + src/c3nav/editor/views/overlays.py | 272 +++++++++++ src/c3nav/mapdata/api/mapdata.py | 35 +- src/c3nav/mapdata/fields.py | 4 +- .../0111_dataoverlay_dataoverlayfeature.py | 62 +++ src/c3nav/mapdata/models/__init__.py | 1 + src/c3nav/mapdata/models/overlay.py | 84 ++++ src/c3nav/mapdata/schemas/models.py | 26 +- src/c3nav/mapdata/utils/user.py | 13 + src/c3nav/site/static/site/css/c3nav.scss | 122 ++++- src/c3nav/site/static/site/js/c3nav.js | 282 ++++++++++- 22 files changed, 1230 insertions(+), 219 deletions(-) create mode 100644 src/c3nav/editor/templates/editor/overlay_features.html create mode 100644 src/c3nav/editor/templates/editor/overlays.html create mode 100644 src/c3nav/editor/views/overlays.py create mode 100644 src/c3nav/mapdata/migrations/0111_dataoverlay_dataoverlayfeature.py create mode 100644 src/c3nav/mapdata/models/overlay.py diff --git a/src/c3nav/editor/api/endpoints.py b/src/c3nav/editor/api/endpoints.py index 1691464a..3bb8fe16 100644 --- a/src/c3nav/editor/api/endpoints.py +++ b/src/c3nav/editor/api/endpoints.py @@ -53,6 +53,7 @@ def geometrystyles(request): 'altitudemarker': '#0000FF', 'beaconmeasurement': '#DDDD00', 'rangingbeacon': '#CC00CC', + 'dataoverlayfeature': '#3366ff', } diff --git a/src/c3nav/editor/api/geometries.py b/src/c3nav/editor/api/geometries.py index bd4301ea..050cc48a 100644 --- a/src/c3nav/editor/api/geometries.py +++ b/src/c3nav/editor/api/geometries.py @@ -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} diff --git a/src/c3nav/editor/api/schemas.py b/src/c3nav/editor/api/schemas.py index f8cf25ee..12b03c8e 100644 --- a/src/c3nav/editor/api/schemas.py +++ b/src/c3nav/editor/api/schemas.py @@ -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 diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py index 6935ec37..f39ef208 100644 --- a/src/c3nav/editor/forms.py +++ b/src/c3nav/editor/forms.py @@ -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] diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 64f438e1..9c000e73 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1,12 +1,12 @@ editor = { options: { - position: 'bottomright' - }, + position: 'bottomright' + }, init: function () { // Init Map editor.map = L.map('map', { - renderer: L.svg({ padding: 2 }), + renderer: L.svg({padding: 2}), zoom: 2, maxZoom: 10, minZoom: -5, @@ -55,11 +55,11 @@ editor = { L.control.scale({imperial: false}).addTo(editor.map); - $('#show_map').click(function(e) { + $('#show_map').click(function (e) { e.preventDefault(); $('body').addClass('show-map'); }); - $('#show_details').click(function(e) { + $('#show_details').click(function (e) { e.preventDefault(); $('body').removeClass('show-map'); }); @@ -74,7 +74,7 @@ editor = { editor.init_scancollector(); editor.sidebar_content_loaded(); }, - _inform_mobile_client: function(elem) { + _inform_mobile_client: function (elem) { if (!window.mobileclient || !elem.length) return; var data = JSON.parse(elem.attr('data-user-data')); data.changes_count_display = elem.attr('data-count-display'); @@ -82,7 +82,7 @@ editor = { data.has_changeset = elem.is('[data-has-changeset]'); mobileclient.setUserData(JSON.stringify(data)); }, - _onbeforeunload: function(e) { + _onbeforeunload: function (e) { if ($('#sidebar').find('[data-onbeforeunload]').length) { e.returnValue = true; } @@ -142,26 +142,26 @@ editor = { get_location_path: function () { return window.location.pathname + window.location.search; }, - init_sidebar: function() { + init_sidebar: function () { // init the sidebar. sed listeners for form submits and link clicks $('#sidebar').find('.content').on('click', 'a[href]', editor._sidebar_link_click) - .on('click', 'button[type=submit]', editor._sidebar_submit_btn_click) - .on('submit', 'form', editor._sidebar_submit); + .on('click', 'button[type=submit]', editor._sidebar_submit_btn_click) + .on('submit', 'form', editor._sidebar_submit); $('nav.navbar').on('click', 'a[href]', editor._sidebar_link_click); var location_path = editor.get_location_path(); editor._sidebar_loaded(); history.replaceState({}, '', location_path); - window.onpopstate = function() { + window.onpopstate = function () { editor.sidebar_get(editor.get_location_path(), true); }; }, - sidebar_get: function(location, no_push) { + sidebar_get: function (location, no_push) { // load a new page into the sidebar using a GET request if (!no_push) history.pushState({}, '', location); editor._sidebar_unload(); $.get(location, editor._sidebar_loaded).fail(editor._sidebar_error); }, - _sidebar_unload: function() { + _sidebar_unload: function () { // unload the sidebar. called on sidebar_get and form submit. editor._level_control.disable(); editor._sublevel_control.disable(); @@ -202,12 +202,15 @@ editor = { level_control.current_id = parseInt(level_list.attr('data-current-id')); }, - sidebar_content_loaded: function() { + sidebar_content_loaded: function () { if (document.querySelector('#sidebar [data-themed-color]')) { editor.theme_editor_loaded(); } + if (document.querySelector('TODO')) { + + } }, - theme_editor_loaded: function() { + theme_editor_loaded: function () { const filter_show_all = () => { for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) { input.parentElement.classList.remove('theme-color-hidden'); @@ -263,7 +266,8 @@ editor = { }, _in_modal: false, - _sidebar_loaded: function(data) { + 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'); if (data !== undefined) { @@ -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,13 +323,15 @@ 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'); var source_width = (parseFloat(content.find('[name=right]').val()) || 0) - (parseFloat(content.find('[name=left]').val()) || 0), source_height = (parseFloat(content.find('[name=top]').val()) || 0) - (parseFloat(content.find('[name=bottom]').val()) || 0); - editor._source_aspect_ratio = source_width/(source_height || 1); + editor._source_aspect_ratio = source_width / (source_height || 1); } if (content.find('[name=left]').length) { group = $('
'); @@ -402,8 +416,8 @@ editor = { var level_control_offset = $(editor._level_control_container).position(); var offset_parent = $(editor._level_control_container).offsetParent(); $(editor._sublevel_control._container).css({ - bottom: offset_parent.outerHeight()-level_control_offset.top-editor._level_control_container.outerHeight()-parseInt(editor._level_control_container.css('margin-bottom')), - right: offset_parent.outerWidth()-level_control_offset.left + bottom: offset_parent.outerHeight() - level_control_offset.top - editor._level_control_container.outerHeight() - parseInt(editor._level_control_container.css('margin-bottom')), + right: offset_parent.outerWidth() - level_control_offset.left }); } else { $body.removeClass('show-map'); @@ -433,12 +447,12 @@ editor = { data_field.after(collector); } }, - _sidebar_error: function(data) { - $('#sidebar').removeClass('loading').find('.content').html('

Error '+data.status+'

'+data.statusText); + _sidebar_error: function (data) { + $('#sidebar').removeClass('loading').find('.content').html('

Error ' + data.status + '

' + data.statusText); editor._level_control.hide(); editor._sublevel_control.hide(); }, - _sidebar_link_click: function(e) { + _sidebar_link_click: function (e) { // listener for link-clicks in the sidebar. var href = $(this).attr('href'); if (href && !href.startsWith('/editor/')) return; @@ -449,14 +463,14 @@ editor = { if ($(this).is('[data-no-next-zoom]')) editor._next_zoom = false; editor.sidebar_get($(this).attr('href')); }, - _sidebar_submit_btn_click: function() { + _sidebar_submit_btn_click: function () { // listener for submit-button-clicks in the sidebar, so the submit event will know which button submitted. if (editor._loading_geometry) return; - $(this).closest('form').data('btn', $(this)).clearQueue().delay(300).queue(function() { + $(this).closest('form').data('btn', $(this)).clearQueue().delay(300).queue(function () { $(this).data('btn', null); }); }, - _sidebar_submit: function(e) { + _sidebar_submit: function (e) { // listener for form submits in the sidebar. e.preventDefault(); if (editor._loading_geometry || $(this).is('.creation-lock') || $(this).is('.scan-lock')) return; @@ -485,17 +499,17 @@ editor = { _source_image_aspect_ratio: 0, _source_image_untouched: 0, _source_image_layer: null, - _source_name_selected: function() { + _source_name_selected: function () { if (editor._source_image_layer) { editor._source_image_layer.remove(); editor._source_image_layer = null; } // noinspection HtmlRequiredAltAttribute - $('').on('load', editor._source_name_selected_ajax_callback); + $('').on('load', editor._source_name_selected_ajax_callback); $('#sidebar form').removeClass('show-source-wizard'); $('body').removeClass('map-enabled'); }, - _source_name_selected_ajax_callback: function() { + _source_name_selected_ajax_callback: function () { if ($(this).attr('src').endsWith($('#sidebar [name=name]').val())) { $('#sidebar form').addClass('show-source-wizard'); $(this).appendTo('body').hide(); @@ -505,26 +519,26 @@ editor = { $('body').addClass('map-enabled'); var content = $('#sidebar'); if (content.find('[data-new]').length || isNaN(parseFloat(content.find('[name=right]').val())) || isNaN(parseFloat(content.find('[name=left]').val())) || isNaN(parseFloat(content.find('[name=top]').val())) || isNaN(parseFloat(content.find('[name=bottom]').val()))) { - editor._source_aspect_ratio = $(this).width()/$(this).height(); + editor._source_aspect_ratio = $(this).width() / $(this).height(); content.find('[name=left]').val(0).data('oldval', 0); content.find('[name=bottom]').val(0).data('oldval', 0); var factor = 1; - while(factor < 1000 && (editor._source_image_orig_width/factor)>1500) { + while (factor < 1000 && (editor._source_image_orig_width / factor) > 1500) { factor *= 10; } - var width = (editor._source_image_orig_width/factor).toFixed(2), - height = (editor._source_image_orig_height/factor).toFixed(2); + var width = (editor._source_image_orig_width / factor).toFixed(2), + height = (editor._source_image_orig_height / factor).toFixed(2); content.find('[name=right]').val(width).data('oldval', width); content.find('[name=top]').val(height).data('oldval', height); - content.find('[name=scale_x]').val(1/factor); - content.find('[name=scale_y]').val(1/factor); + content.find('[name=scale_x]').val(1 / factor); + content.find('[name=scale_y]').val(1 / factor); } else { editor._source_image_calculate_scale(); } editor._source_image_repositioned(); } }, - _source_image_repositioned: function() { + _source_image_repositioned: function () { var content = $('#sidebar'); if (isNaN(parseFloat(content.find('[name=right]').val())) || isNaN(parseFloat(content.find('[name=left]').val())) || isNaN(parseFloat(content.find('[name=top]').val())) || isNaN(parseFloat(content.find('[name=bottom]').val()))) { return; @@ -536,40 +550,48 @@ 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]}); } } }, - _source_image_calculate_scale: function() { + _source_image_calculate_scale: function () { var content = $('#sidebar'); var source_width = parseFloat(content.find('[name=right]').val()) - parseFloat(content.find('[name=left]').val()), source_height = parseFloat(content.find('[name=top]').val()) - parseFloat(content.find('[name=bottom]').val()); if (isNaN(source_width) || isNaN(source_height)) return; - var scale_x = (source_width/editor._source_image_orig_width).toFixed(3), - scale_y = (source_height/editor._source_image_orig_height).toFixed(3); + var scale_x = (source_width / editor._source_image_orig_width).toFixed(3), + scale_y = (source_height / editor._source_image_orig_height).toFixed(3); content.find('[name=scale_x]').val(scale_x); content.find('[name=scale_y]').val(scale_y); if (scale_x !== scale_y) { content.find('[name=lock_aspect]').prop('checked', false); } }, - _source_image_bounds_changed: function() { + _source_image_bounds_changed: function () { var content = $('#sidebar'), lock_scale = content.find('[name=lock_scale]').prop('checked'), oldval = $(this).data('oldval'), newval = $(this).val(), - diff = parseFloat(newval)-parseFloat(oldval); + diff = parseFloat(newval) - parseFloat(oldval); $(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')], - other_field = content.find('[name='+other_field_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)) { - other_field.val((other_val+diff).toFixed(2)).data('oldval', other_val); + other_field.val((other_val + diff).toFixed(2)).data('oldval', other_val); } } } else { @@ -577,12 +599,12 @@ editor = { } editor._source_image_repositioned(); }, - _source_image_scale_changed: function() { + _source_image_scale_changed: function () { var content = $('#sidebar'), lock_aspect = content.find('[name=lock_scale]').prop('checked'); if (lock_aspect) { var other_field_name = {scale_x: 'scale_y', scale_y: 'scale_x'}[$(this).attr('name')], - other_field = content.find('[name='+other_field_name+']'); + other_field = content.find('[name=' + other_field_name + ']'); other_field.val($(this).val()); } var f_scale_x = content.find('[name=scale_x]'), @@ -601,14 +623,14 @@ editor = { if (isNaN(scale_x) || isNaN(scale_y) || isNaN(fixed_x) || isNaN(fixed_y) || isNaN(left) || isNaN(bottom) || isNaN(right) || isNaN(top)) return; - var fixed_x_relative = (fixed_x-left)/(right-left), - fixed_y_relative = (fixed_y-bottom)/(top-bottom), - width = editor._source_image_orig_width*scale_x, - height = editor._source_image_orig_height*scale_y; - left = fixed_x-(width*fixed_x_relative); - bottom = fixed_y-(height*fixed_y_relative); - right = left+width; - top = bottom+height; + var fixed_x_relative = (fixed_x - left) / (right - left), + fixed_y_relative = (fixed_y - bottom) / (top - bottom), + width = editor._source_image_orig_width * scale_x, + height = editor._source_image_orig_height * scale_y; + left = fixed_x - (width * fixed_x_relative); + bottom = fixed_y - (height * fixed_y_relative); + right = left + width; + top = bottom + height; left = left.toFixed(2); bottom = bottom.toFixed(2); @@ -622,7 +644,7 @@ editor = { editor._source_image_repositioned(); }, - _fixed_point_changed: function() { + _fixed_point_changed: function () { var content = $('#sidebar'), fixed_x = parseFloat(content.find('[name=fixed_x]').val()), fixed_y = parseFloat(content.find('[name=fixed_y]').val()), @@ -637,7 +659,7 @@ editor = { editor._fixed_point_layer = null; } } else if (valid) { - editor._fixed_point_layer = L.marker(latlng, {draggable: true, autoPan: true}).on('dragend', function(e) { + editor._fixed_point_layer = L.marker(latlng, {draggable: true, autoPan: true}).on('dragend', function (e) { var coords = L.GeoJSON.latLngToCoords(e.target.getLatLng()); content.find('[name=fixed_x]').val(coords[0].toFixed(3)); content.find('[name=fixed_y]').val(coords[1].toFixed(3)); @@ -645,14 +667,14 @@ editor = { editor._fixed_point_layer.addTo(editor.map); } }, - _copy_from_changed: function() { + _copy_from_changed: function () { var content = $('#sidebar'), value = JSON.parse($(this).val()); $(this).val(''); - if (!confirm('Are you sure you want to copy settings from '+value.name+'?')) return; + if (!confirm('Are you sure you want to copy settings from ' + value.name + '?')) return; delete value.name; for (var key in value) { - if (value.hasOwnProperty(key)) content.find('[name='+key+']').val(value[key]); + if (value.hasOwnProperty(key)) content.find('[name=' + key + ']').val(value[key]); } editor._source_image_calculate_scale(); editor._source_image_repositioned(); @@ -690,8 +712,8 @@ editor = { editor._highlight_layer = L.layerGroup().addTo(editor.map); $('#sidebar').find('.content').on('mouseenter', '.itemtable tr[data-pk]', editor._hover_mapitem_row) - .on('mouseleave', '.itemtable tr[data-pk]', editor._unhover_mapitem_row) - .on('click', '.itemtable tr[data-pk] td:not(:last-child)', editor._click_mapitem_row); + .on('mouseleave', '.itemtable tr[data-pk]', editor._unhover_mapitem_row) + .on('click', '.itemtable tr[data-pk] td:not(:last-child)', editor._click_mapitem_row); editor.map.on('editable:drawing:commit', editor._done_creating); editor.map.on('editable:editing', editor._update_editing); @@ -699,27 +721,27 @@ editor = { editor.map.on('editable:vertex:click', function () { editor.map.doubleClickZoom.disable(); }); - editor.map.on('editable:drawing:start editable:drawing:end', function() { + editor.map.on('editable:drawing:start editable:drawing:end', function () { editor._last_vertex = null; editor._num_vertices = 0; }); - editor.map.on('editable:vertex:new', function(e) { + editor.map.on('editable:vertex:new', function (e) { if (editor._shift_pressed && editor._creating && editor._creating_type === 'polygon' && editor._num_vertices === 1) { var firstPoint = new L.Point(editor._last_vertex.latlng.lng, editor._last_vertex.latlng.lat), secondPoint = new L.Point(e.vertex.latlng.lng, e.vertex.latlng.lat), - center = new L.Point((firstPoint.x+secondPoint.x)/2, (firstPoint.y+secondPoint.y)/2), - radius = firstPoint.distanceTo(secondPoint)/2, + center = new L.Point((firstPoint.x + secondPoint.x) / 2, (firstPoint.y + secondPoint.y) / 2), + radius = firstPoint.distanceTo(secondPoint) / 2, options = e.layer.options, - points = Math.min(32, 8+Math.floor(radius*5)*2), + points = Math.min(32, 8 + Math.floor(radius * 5) * 2), vertices = []; - for (var i=0;i { var geometries = [], feature, new_cache = {}, feature_type, feature_id; // geometries cache logic - for (var i=0;i 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, @@ -900,11 +931,11 @@ editor = { } else { defs.innerHTML = ''; } - for(i=0;i'); + defs.insertAdjacentHTML('beforeend', ''); } } @@ -916,23 +947,23 @@ editor = { editor.load_geometries(editor._last_geometry_url); } }, - _weight_for_zoom: function() { - return Math.pow(2, editor.map.getZoom())*0.1; + _weight_for_zoom: function () { + return Math.pow(2, editor.map.getZoom()) * 0.1; }, - _adjust_line_zoom: function() { - var weight = Math.pow(2, editor.map.getZoom())*0.1, + _adjust_line_zoom: function () { + var weight = Math.pow(2, editor.map.getZoom()) * 0.1, factor = Math.pow(2, editor.map.getZoom()); editor._arrow_colors = []; - for(var i=0;i { + editor._creating_type = geomtype; + editor._creating = true; + if (editor._current_editing_shape) { + editor._current_editing_shape.remove(); + } + if (geomtype === 'polygon') { + editor._current_editing_shape = editor.map.editTools.startPolygon(null, options); + } else if (geomtype === 'linestring') { + options = editor._line_draw_geometry_style(options); + editor._current_editing_shape = editor.map.editTools.startPolyline(null, options); + } else if (geomtype === 'point') { + editor._current_editing_shape = editor.map.editTools.startMarker(null, options); + } } - editor._creating = true; + + if (geomtypes.length > 1) { + const selector = $(''); + const geomtypeNames = { + polygon: 'Polygon', + linestring: 'Line string', + point: 'Point' + }; // TODO: translations + for(const geomtype of geomtypes) { + selector.append(``); + } + + selector.on('change', e => startGeomEditing(e.target.value)); + form.prepend(selector); + } + startGeomEditing(geomtypes[0]); } } }, - _cancel_editing: function() { + _cancel_editing: function () { // called on sidebar unload. cancel all editing and creating. if (editor._creating) { editor._creating = false; @@ -1285,7 +1345,7 @@ editor = { //e.layer.remove(); } }, - _done_creating: function(e) { + _done_creating: function (e) { // called when creating is completed (by clicking on the last point). fills in the form and switches to editing. if (editor._creating) { editor._creating = false; @@ -1319,16 +1379,16 @@ editor = { editor._highlight_layer = L.layerGroup().addTo(editor.map); $('#sidebar').on('click', '.scancollector .start', editor._scancollector_start) - .on('click', '.scancollector .stop', editor._scancollector_stop) - .on('click', '.scancollector .reset', editor._scancollector_reset); + .on('click', '.scancollector .stop', editor._scancollector_stop) + .on('click', '.scancollector .reset', editor._scancollector_reset); window.setInterval(editor._scancollector_wifi_scan_perhaps, 1000); }, _scancollector_lookup: {}, load_scancollector_lookup: function () { c3nav_api.get('editor/beacons-lookup') - .then(data => { - editor._scancollector_lookup = data; - }) + .then(data => { + editor._scancollector_lookup = data; + }) }, _scancollector_data: { wifi: [], @@ -1350,7 +1410,7 @@ editor = { // todo: maybe reset if either is empty? if (!editor._scancollector_data.wifi.length && editor._scancollector_data.ibeacon.length) return editor._scancollector_reset(); var $collector = $('#sidebar').find('.scancollector'); - $collector.removeClass('running').delay(1000).queue(function(n) { + $collector.removeClass('running').delay(1000).queue(function (n) { $(this).addClass('done'); n(); }); @@ -1358,25 +1418,27 @@ 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(); }, _scancollector_wifi_last_max_last: 0, _scancollector_wifi_last_result: 0, - _scancollector_wifi_result: function(data) { + _scancollector_wifi_result: function (data) { var $collector = $('#sidebar').find('.scancollector.running'), $table = $collector.find('.wifi-table tbody'), item, i, line, apid, color, max_last = 0, now = Date.now(), match; editor._wifi_scan_waits = false; if (!data.length) return; - if (now-2000 < editor._scancollector_wifi_last_result) return; + if (now - 2000 < editor._scancollector_wifi_last_result) return; editor._scancollector_wifi_last_result = now; // ignore this scan? - for (i=0; i < data.length; i++) { + for (i = 0; i < data.length; i++) { item = data[i]; if (item.last) { max_last = Math.max(max_last, item.last); @@ -1386,7 +1448,7 @@ editor = { editor._scancollector_wifi_last_max_last = max_last; $table.find('tr').addClass('old'); - for (i=0; i < data.length; i++) { + for (i = 0; i < data.length; i++) { item = data[i]; // App version < 4.2.4 use level instead fo rssi if (item.level !== undefined) { @@ -1400,10 +1462,10 @@ editor = { delete item.rtt; } - apid = 'ap-'+item.bssid.replace(/:/g, '-'); - line = $table.find('tr.'+apid); - color = Math.max(0, Math.min(50, item.rssi+80)); - color = 'rgb('+String(250-color*5)+', '+String(color*4)+', 0)'; + apid = 'ap-' + item.bssid.replace(/:/g, '-'); + line = $table.find('tr.' + apid); + color = Math.max(0, Math.min(50, item.rssi + 80)); + color = 'rgb(' + String(250 - color * 5) + ', ' + String(color * 4) + ', 0)'; if (line.length) { line.removeClass('old').find(':last-child').text(item.rssi).css('color', color); } else { @@ -1417,7 +1479,7 @@ editor = { } shortened_ssid = item.ssid; if (shortened_ssid.length > 20) { - shortened_ssid = shortened_ssid.slice(0, 20)+'…'; + shortened_ssid = shortened_ssid.slice(0, 20) + '…'; } line = $('').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 %} + + + + {% for feature in features %} + {% if forloop.counter0|divisibleby:10 %} + + + + + {% endif %} + + + + + {% endfor %} + + + + + +
« {{ back_title }}
{{ feature.title }}{{ edit_caption }}
« {{ back_title }}
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' %} +

+ +
+ {% for overlay in overlays %} + + {{ overlay.title }} + + {% endfor %} +
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 += ``; + } + + html += '
${key}${feature.extra_data[key]}
'; + } + 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