data overlays
This commit is contained in:
parent
60de7857d6
commit
7904a95b80
22 changed files with 1230 additions and 219 deletions
|
@ -53,6 +53,7 @@ def geometrystyles(request):
|
|||
'altitudemarker': '#0000FF',
|
||||
'beaconmeasurement': '#DDDD00',
|
||||
'rangingbeacon': '#CC00CC',
|
||||
'dataoverlayfeature': '#3366ff',
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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 %}
|
||||
|
|
|
@ -12,4 +12,7 @@
|
|||
{% trans 'Graph' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if overlays_url %}
|
||||
<a href="{{ overlays_url }}" class="list-group-item">{% trans 'Overlays' %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
45
src/c3nav/editor/templates/editor/overlay_features.html
Normal file
45
src/c3nav/editor/templates/editor/overlay_features.html
Normal 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>« {{ 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>« {{ back_title }}</a></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
17
src/c3nav/editor/templates/editor/overlays.html
Normal file
17
src/c3nav/editor/templates/editor/overlays.html
Normal 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 %}">« {% 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>
|
|
@ -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'))
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
272
src/c3nav/editor/views/overlays.py
Normal file
272
src/c3nav/editor/views/overlays.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
84
src/c3nav/mapdata/models/overlay.py
Normal file
84
src/c3nav/mapdata/models/overlay.py
Normal 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
|
|
@ -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]
|
||||
obstacles: list[LegendItemSchema]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue