diff --git a/src/c3nav/editor/views/overlays.py b/src/c3nav/editor/views/overlays.py index bae5a223..129cfcbb 100644 --- a/src/c3nav/editor/views/overlays.py +++ b/src/c3nav/editor/views/overlays.py @@ -91,7 +91,6 @@ def overlay_feature_edit(request, level=None, overlay=None, pk=None): 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} diff --git a/src/c3nav/mapdata/api/mapdata.py b/src/c3nav/mapdata/api/mapdata.py index 0ca6fcd0..0f7c839c 100644 --- a/src/c3nav/mapdata/api/mapdata.py +++ b/src/c3nav/mapdata/api/mapdata.py @@ -27,8 +27,9 @@ from c3nav.mapdata.schemas.models import (AccessRestrictionGroupSchema, AccessRe LineObstacleSchema, LocationGroupCategorySchema, LocationGroupSchema, ObstacleSchema, POISchema, RampSchema, SourceSchema, SpaceSchema, StairSchema, DataOverlaySchema, DataOverlayFeatureSchema, LocationRedirectSchema, - WayTypeSchema, - DataOverlayFeatureUpdateSchema, DataOverlayFeatureBulkUpdateSchema) + WayTypeSchema, DataOverlayFeatureGeometrySchema, + DataOverlayFeatureUpdateSchema, DataOverlayFeatureBulkUpdateSchema, + ) mapdata_api_router = APIRouter(tags=["mapdata"]) @@ -77,14 +78,16 @@ class MapdataEndpoint: model: Type[Model] schema: Type[BaseSchema] filters: Type[FilterSchema] | None = None + no_cache: bool = False + name: Optional[str] = None @property def model_name(self): return self.model._meta.model_name @property - def model_name_plural(self): - return self.model._meta.default_related_name + def endpoint_name(self): + return self.name if self.name is not None else self.model._meta.default_related_name @dataclass @@ -110,7 +113,7 @@ class MapdataAPIBuilder: add_call_params = {} call_param_values = set(add_call_params.values()) call_params = ( - *(f"{name}={name}" for name in set(view_params.keys())-call_param_values), + *(f"{name}={name}" for name in set(view_params.keys()) - call_param_values), *(f"{name}={value}" for name, value in add_call_params.items()), ) method_code = "\n".join(( @@ -139,12 +142,15 @@ class MapdataAPIBuilder: ) list_func.__name__ = f"{endpoint.model_name}_list" - self.router.get(f"/{endpoint.model_name_plural}/", summary=f"{endpoint.model_name} list", + if not endpoint.no_cache: + list_func = api_etag()(list_func) + + self.router.get(f"/{endpoint.endpoint_name}/", summary=f"{endpoint.model_name} list", tags=[f"mapdata-{tag}"], description=schema_description(endpoint.schema), response={200: list[endpoint.schema], **(validate_responses if endpoint.filters else {}), **auth_responses})( - api_etag()(list_func) + list_func ) def add_by_id_endpoint(self, endpoint: MapdataEndpoint, tag: str): @@ -160,7 +166,7 @@ class MapdataAPIBuilder: ) list_func.__name__ = f"{endpoint.model_name}_by_id" - self.router.get(f'/{endpoint.model_name_plural}/{{{id_field}}}/', summary=f"{endpoint.model_name} by ID", + self.router.get(f'/{endpoint.endpoint_name}/{{{id_field}}}/', summary=f"{endpoint.model_name} by ID", tags=[f"mapdata-{tag}"], description=schema_description(endpoint.schema), response={200: endpoint.schema, **API404.dict(), **auth_responses})( api_etag()(list_func) @@ -227,6 +233,14 @@ mapdata_endpoints: dict[str, list[MapdataEndpoint]] = { model=DataOverlayFeature, schema=DataOverlayFeatureSchema, filters=ByOverlayFilter, + no_cache=True, + ), + MapdataEndpoint( + model=DataOverlayFeature, + schema=DataOverlayFeatureGeometrySchema, + filters=ByOverlayFilter, + no_cache=True, + name='dataoverlayfeaturegeometries' ), MapdataEndpoint( model=WayType, @@ -304,12 +318,11 @@ mapdata_endpoints: dict[str, list[MapdataEndpoint]] = { ], } - MapdataAPIBuilder(router=mapdata_api_router).build_all_endpoints(mapdata_endpoints) @mapdata_api_router.post('/dataoverlayfeatures/{id}', summary="update a data overlay feature (including geometries)", - response={204: None, **API404.dict(), **auth_permission_responses}) + response={204: None, **API404.dict(), **auth_permission_responses}) def update_data_overlay_feature(request, id: int, parameters: DataOverlayFeatureUpdateSchema): """ update the data overlay feature @@ -317,7 +330,8 @@ def update_data_overlay_feature(request, id: int, parameters: DataOverlayFeature feature = get_object_or_404(DataOverlayFeature, id=id) - if feature.overlay.edit_access_restriction_id is None or feature.overlay.edit_access_restriction_id not in AccessPermission.get_for_request(request): + if feature.overlay.edit_access_restriction_id is None or feature.overlay.edit_access_restriction_id not in AccessPermission.get_for_request( + request): raise APIPermissionDenied('You are not allowed to edit this object.') updates = parameters.dict(exclude_unset=True) @@ -329,10 +343,10 @@ def update_data_overlay_feature(request, id: int, parameters: DataOverlayFeature return 204, None + @mapdata_api_router.post('/dataoverlayfeatures-bulk', summary="bulk-update data overlays (including geometries)", response={204: None, **API404.dict(), **auth_permission_responses}) def update_data_overlay_features_bulk(request, parameters: DataOverlayFeatureBulkUpdateSchema): - permissions = AccessPermission.get_for_request(request) updates = { @@ -343,7 +357,8 @@ def update_data_overlay_features_bulk(request, parameters: DataOverlayFeatureBul forbidden_object_ids = [] with transaction.atomic(): - features = DataOverlayFeature.objects.filter(id__in=updates.keys()).annotate(edit_access_restriction_id=F('overlay__edit_access_restriction_id')) + features = DataOverlayFeature.objects.filter(id__in=updates.keys()).annotate( + edit_access_restriction_id=F('overlay__edit_access_restriction_id')) for feature in features: if feature.edit_access_restriction_id is None or feature.edit_access_restriction_id not in permissions: @@ -362,4 +377,4 @@ def update_data_overlay_features_bulk(request, parameters: DataOverlayFeatureBul raise APIPermissionDenied('You are not allowed to edit the objects with the following ids: %s.' % ", ".join([str(x) for x in forbidden_object_ids])) - return 204, None \ No newline at end of file + return 204, None diff --git a/src/c3nav/mapdata/migrations/0131_dataoverlay_update_interval_and_more.py b/src/c3nav/mapdata/migrations/0131_dataoverlay_update_interval_and_more.py new file mode 100644 index 00000000..73b15222 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0131_dataoverlay_update_interval_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.3 on 2024-12-26 19:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0130_dataoverlay_edit_access_restriction'), + ] + + operations = [ + migrations.AddField( + model_name='dataoverlay', + name='update_interval', + field=models.PositiveIntegerField(blank=True, help_text='in seconds', null=True, verbose_name='frontend update interval'), + ), + migrations.AlterField( + model_name='dataoverlay', + name='fill_opacity', + field=models.FloatField(blank=True, null=True, verbose_name='default fill opacity'), + ), + ] diff --git a/src/c3nav/mapdata/models/overlay.py b/src/c3nav/mapdata/models/overlay.py index 89b170cd..a66da733 100644 --- a/src/c3nav/mapdata/models/overlay.py +++ b/src/c3nav/mapdata/models/overlay.py @@ -24,16 +24,21 @@ class DataOverlay(TitledMixin, AccessRestrictionMixin, models.Model): stroke_width = models.FloatField(blank=True, null=True, verbose_name=_('default stroke width')) stroke_opacity = models.FloatField(blank=True, null=True, verbose_name=_('stroke opacity')) fill_color = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('default fill color')) - fill_opacity = models.FloatField(blank=True, null=True, verbose_name=_('fill opacity')) + fill_opacity = models.FloatField(blank=True, null=True, verbose_name=_('default fill opacity')) cluster_points = models.BooleanField(default=False, verbose_name=_('cluster points together when zoomed out')) - default_geomtype = models.CharField(max_length=255, blank=True, null=True, choices=GeometryType, verbose_name=_('default geometry type')) + default_geomtype = models.CharField(max_length=255, blank=True, null=True, choices=GeometryType, + verbose_name=_('default geometry type')) 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')) + + update_interval = models.PositiveIntegerField(blank=True, null=True, verbose_name=_('frontend update interval'), + help_text=_('in seconds')) + edit_access_restriction = models.ForeignKey(AccessRestriction, null=True, blank=True, related_name='edit_access_restrictions', verbose_name=_('Editor Access Restriction'), @@ -46,7 +51,8 @@ class DataOverlay(TitledMixin, AccessRestrictionMixin, models.Model): class DataOverlayFeature(TitledMixin, LevelGeometryMixin, models.Model): - overlay = models.ForeignKey('mapdata.DataOverlay', on_delete=models.CASCADE, verbose_name=_('Overlay'), related_name='features') + 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')) @@ -62,9 +68,10 @@ class DataOverlayFeature(TitledMixin, LevelGeometryMixin, models.Model): 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|int|bool]] = SchemaField(schema=dict[str, str|int|bool], blank=True, null=True, - default=None, - verbose_name=_('extra data (JSON object)')) + extra_data: Optional[dict[str, str | int | bool]] = SchemaField(schema=dict[str, str | int | bool], blank=True, + null=True, + default=None, + verbose_name=_('extra data (JSON object)')) def to_geojson(self, instance=None) -> dict: result = { diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py index dd4a1563..fe415a93 100644 --- a/src/c3nav/mapdata/schemas/models.py +++ b/src/c3nav/mapdata/schemas/models.py @@ -376,14 +376,13 @@ class DataOverlaySchema(TitledSchema, DjangoModelSchema): fill_color: Optional[str] fill_opacity: Optional[float] cluster_points: bool + update_interval: Optional[PositiveInt] - -class DataOverlayFeatureSchema(TitledSchema, WithGeometrySchema, DjangoModelSchema): +class DataOverlayFeatureSchema(TitledSchema, DjangoModelSchema): """ A feature (any kind of geometry) to be displayed as part of a data overlay. """ - geometry: AnyGeometrySchema level_id: PositiveInt stroke_color: Optional[str] stroke_width: Optional[float] @@ -397,6 +396,13 @@ class DataOverlayFeatureSchema(TitledSchema, WithGeometrySchema, DjangoModelSche external_url: Optional[str] extra_data: Optional[dict[str, str | int | float]] + +class DataOverlayFeatureGeometrySchema(WithGeometrySchema, DjangoModelSchema): + """ + A feature (any kind of geometry) to be displayed as part of a data overlay. + """ + geometry: AnyGeometrySchema + class DataOverlayFeatureUpdateSchema(BaseSchema): """ An update to a data overlay feature. diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss index 970d4817..5ec2c8a2 100644 --- a/src/c3nav/site/static/site/css/c3nav.scss +++ b/src/c3nav/site/static/site/css/c3nav.scss @@ -1975,29 +1975,25 @@ blink { } .marker-cluster { - background-color: color-mix(in srgb, transparent, var(--color-primary) 60%); - background-clip: padding-box; - border-radius: 20px; - - div { - background-color: white; - color: var(--color-primary); + span { + display: inline-block; width: 30px; height: 30px; - margin-left: 5px; - margin-top: 5px; - + line-height: 30px; text-align: center; - border-radius: 15px; - font-size: 12px; + background-color: white; + color: var(--cluster-marker-color); + font-weight: bold; + border-radius: 100%; - span { - line-height: 30px; - font-weight: bold; - } + box-shadow: 0 0 0 5px color-mix(in srgb, transparent, var(--cluster-marker-color) 60%); + + transition: color, background-color 150ms ease-in-out; + + cursor: pointer; &:hover { - background-color: var(--color-primary); + background-color: var(--cluster-marker-color); color: white; } } diff --git a/src/c3nav/site/static/site/js/c3nav.js b/src/c3nav/site/static/site/js/c3nav.js index d20616e7..e40abc1e 100644 --- a/src/c3nav/site/static/site/js/c3nav.js +++ b/src/c3nav/site/static/site/js/c3nav.js @@ -47,6 +47,17 @@ }; }()); +function makeClusterIconCreate(color) { + return function(cluster) { + const childCount = cluster.getChildCount(); + return new L.DivIcon({ + html: `