diff --git a/map-view/src/api/__mocks__/mock-data.ts b/map-view/src/api/__mocks__/mock-data.ts index 64ca76f7..4db79dbb 100644 --- a/map-view/src/api/__mocks__/mock-data.ts +++ b/map-view/src/api/__mocks__/mock-data.ts @@ -45,4 +45,6 @@ export const mockMapConfig: MapConfig = { }, traffic_sign_icons_url: "http://127.0.0.1:8000/static/traffic_control/svg/traffic_sign_icons/", featureTypeEditNameMapping: {}, + icon_scale: 0.1, + icon_type: "svg", }; diff --git a/map-view/src/common/Map.ts b/map-view/src/common/Map.ts index dbce44f8..1d180e43 100644 --- a/map-view/src/common/Map.ts +++ b/map-view/src/common/Map.ts @@ -422,7 +422,7 @@ class Map { } private createNonClusteredOverlayLayerGroup(mapConfig: MapConfig) { - const { overlayConfig, traffic_sign_icons_url } = mapConfig; + const { overlayConfig, traffic_sign_icons_url, icon_scale, icon_type } = mapConfig; const { layers, sourceUrl } = overlayConfig; const overlayLayers = layers .filter(({ clustered }) => !clustered) @@ -443,7 +443,8 @@ class Map { const vectorLayer = new VectorLayer({ source: vectorSource, - style: (feature: FeatureLike) => getSinglePointStyle(feature, use_traffic_sign_icons, traffic_sign_icons_url), + style: (feature: FeatureLike) => + getSinglePointStyle(feature, use_traffic_sign_icons, traffic_sign_icons_url, icon_scale, icon_type), visible: false, opacity: identifier.includes("plan") ? 0.5 : 1, // 100% opacity for reals, 50% opacity for plans }); @@ -458,7 +459,7 @@ class Map { } private createClusteredOverlayLayerGroup(mapConfig: MapConfig) { - const { overlayConfig, traffic_sign_icons_url } = mapConfig; + const { overlayConfig, traffic_sign_icons_url, icon_scale, icon_type } = mapConfig; const { layers, sourceUrl } = overlayConfig; // Fetch device layers const overlayLayers = layers @@ -504,7 +505,13 @@ class Map { styleCache[size] = style; } else { const feature = clusterFeature.get("features")[0]; - style = getSinglePointStyle(feature, use_traffic_sign_icons, traffic_sign_icons_url); + style = getSinglePointStyle( + feature, + use_traffic_sign_icons, + traffic_sign_icons_url, + icon_scale, + icon_type, + ); styleCache[feature.get("device_type_code")] = style; } } diff --git a/map-view/src/common/MapUtils.ts b/map-view/src/common/MapUtils.ts index ab9a2a34..156f8615 100644 --- a/map-view/src/common/MapUtils.ts +++ b/map-view/src/common/MapUtils.ts @@ -74,13 +74,15 @@ export function getSinglePointStyle( feature: FeatureLike, use_traffic_sign_icons: boolean, traffic_sign_icons_url: string, + icon_scale: number, + icon_type: string, ) { if (use_traffic_sign_icons && feature.get("device_type_code") !== null) { // Traffic sign style return new Style({ image: new Icon({ - src: `${traffic_sign_icons_url}${feature.get("device_type_code")}.svg`, - scale: 0.075, + src: getIconSrc(traffic_sign_icons_url, icon_type, feature.get("device_type_code")), + scale: icon_scale, }), }); } @@ -89,6 +91,15 @@ export function getSinglePointStyle( return getStylesForGeometry(geometry); } +function getIconSrc(traffic_sign_icons_url: string, icon_type: string, device_type_code: string) { + const base_src = `${traffic_sign_icons_url}${device_type_code}.svg`; + if (icon_type === "png") { + return base_src.concat(".png"); + } else { + return base_src; + } +} + export function getStylesForGeometry(geometry: Geometry | RenderFeature | undefined) { if (geometry === undefined) { return geometryStyles["unknown"]; diff --git a/map-view/src/models.ts b/map-view/src/models.ts index f9c9e005..399a6634 100644 --- a/map-view/src/models.ts +++ b/map-view/src/models.ts @@ -26,6 +26,8 @@ export interface MapConfig { overviewConfig: OverviewConfig; traffic_sign_icons_url: string; featureTypeEditNameMapping: Record; + icon_scale: number; + icon_type: string; } export interface FeatureProperties { diff --git a/map/admin.py b/map/admin.py index ce545f7e..ed5d2116 100644 --- a/map/admin.py +++ b/map/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from map.models import FeatureTypeEditMapping, Layer +from map.models import FeatureTypeEditMapping, IconDrawingConfig, Layer @admin.register(Layer) @@ -22,3 +22,8 @@ class LayerAdmin(admin.ModelAdmin): @admin.register(FeatureTypeEditMapping) class FeatureTypeEditMappingAdmin(admin.ModelAdmin): list_display = ("name", "edit_name") + + +@admin.register(IconDrawingConfig) +class IconDrawingInfoAdmin(admin.ModelAdmin): + list_display = ("name", "image_type", "png_size", "active") diff --git a/map/migrations/0008_icondrawingconfig.py b/map/migrations/0008_icondrawingconfig.py new file mode 100644 index 00000000..634c0327 --- /dev/null +++ b/map/migrations/0008_icondrawingconfig.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.20 on 2025-05-19 09:59 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('map', '0007_layer_clustered'), + ] + + operations = [ + migrations.CreateModel( + name='IconDrawingConfig', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Name')), + ('active', models.BooleanField(default=False, verbose_name='Active')), + ('image_type', models.CharField(choices=[('png', 'png'), ('svg', 'svg')], max_length=10, verbose_name='Image Type')), + ('png_size', models.IntegerField(choices=[(32, 'Small'), (64, 'Medium'), (128, 'Large'), (256, 'Extra Large')], verbose_name='PNG Size')), + ('scale', models.FloatField(default=None, null=True, verbose_name='Scale')), + ], + ), + ] diff --git a/map/models.py b/map/models.py index 153b5191..1ecc1faf 100644 --- a/map/models.py +++ b/map/models.py @@ -1,6 +1,6 @@ import uuid -from django.db import models +from django.db import models, NotSupportedError from django.utils.translation import gettext_lazy as _ @@ -43,3 +43,45 @@ class FeatureTypeEditMapping(models.Model): @staticmethod def get_featuretype_edit_name_mapping(): return {mapping.name: mapping.edit_name for mapping in FeatureTypeEditMapping.objects.all()} + + +class IconDrawingConfig(models.Model): + DEFAULT_ICON_URL = "traffic_control/svg/traffic_sign_icons/" + DEFAULT_ICON_SCALE = 0.075 + + class ImageType(models.TextChoices): + PNG = "png", _("png") + SVG = "svg", _("svg") + + class PngSize(models.IntegerChoices): + SMALL = 32 + MEDIUM = 64 + LARGE = 128 + EXTRA_LARGE = 256 + + id = models.UUIDField(primary_key=True, unique=True, editable=False, default=uuid.uuid4) + name = models.CharField(_("Name"), max_length=200, unique=True) + active = models.BooleanField(_("Active"), default=False) + image_type = models.CharField(_("Image Type"), choices=ImageType.choices, max_length=10, null=False, blank=False) + png_size = models.IntegerField(_("PNG Size"), choices=PngSize.choices, null=False, blank=False) + scale = models.FloatField(_("Scale"), null=True, blank=False, default=None) + + constraints = [ + models.UniqueConstraint( + fields=["image_type", "png_size"], + name="%(app_label)s_%(class)s_unique_image_type_png_size", + ), + models.UniqueConstraint( + fields=["active"], + condition=models.Q(active=True), + name="%(app_label)s_%(class)s_unique_active", + ), + ] + + @property + def icons_relative_url(self) -> str: + if self.image_type == IconDrawingConfig.ImageType.SVG: + return self.DEFAULT_ICON_URL + elif self.image_type == IconDrawingConfig.ImageType.PNG: + return f"traffic_control/png/traffic_sign_icons/{self.png_size}/" + raise NotSupportedError(f"No support for image type: {self.image_type}") diff --git a/map/tests/factories.py b/map/tests/factories.py new file mode 100644 index 00000000..ac633d3a --- /dev/null +++ b/map/tests/factories.py @@ -0,0 +1,15 @@ +import factory.django + +from map.models import IconDrawingConfig + + +class IconDrawingConfigFactory(factory.django.DjangoModelFactory): + class Meta: + model = IconDrawingConfig + django_get_or_create = ("name",) + + name = factory.Sequence(lambda n: f"iconDrawConfig{n}") + active = False + image_type = "svg" + png_size = 32 + scale = IconDrawingConfig.DEFAULT_ICON_SCALE diff --git a/map/tests/test_views.py b/map/tests/test_views.py index 45b638d7..5edf6854 100644 --- a/map/tests/test_views.py +++ b/map/tests/test_views.py @@ -4,7 +4,8 @@ from django.test import RequestFactory, TestCase from django.urls import reverse -from map.models import FeatureTypeEditMapping, Layer +from map.models import FeatureTypeEditMapping, IconDrawingConfig, Layer +from map.tests.factories import IconDrawingConfigFactory from map.views import map_config, map_view from traffic_control.tests.factories import get_user @@ -32,6 +33,38 @@ class MapConfigTestCase(TestCase): def setUp(self): self.factory = RequestFactory() + def test_with_no_icon_draw_config(self): + """Test that without any active IconDrawingConfig the default values are used.""" + request = self.factory.get(reverse("map-config")) + request.LANGUAGE_CODE = "en" + + response = map_config(request) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content) + self.assertEqual(response_data["icon_scale"], IconDrawingConfig.DEFAULT_ICON_SCALE) + self.assertEqual(response_data["icon_type"], "svg") + self.assertEqual( + response_data["traffic_sign_icons_url"], + f"{request.build_absolute_uri(settings.STATIC_URL)}traffic_control/svg/traffic_sign_icons/", + ) + + def test_with_icon_draw_config(self): + """Test that with an active IconDrawingConfig the values are actually used.""" + idc = IconDrawingConfigFactory(active=True, scale=IconDrawingConfig.DEFAULT_ICON_SCALE + 0.1, image_type="png") + request = self.factory.get(reverse("map-config")) + request.LANGUAGE_CODE = "en" + + response = map_config(request) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content) + + self.assertEqual(response_data["icon_scale"], idc.scale) + self.assertEqual(response_data["icon_type"], idc.image_type) + self.assertEqual( + response_data["traffic_sign_icons_url"], + f"{request.build_absolute_uri(settings.STATIC_URL)}traffic_control/png/traffic_sign_icons/{idc.png_size}/", + ) + def test_layer_config_return_ok(self): Layer.objects.create( identifier="basemap", diff --git a/map/views.py b/map/views.py index a24c6cdc..a7375ff1 100644 --- a/map/views.py +++ b/map/views.py @@ -5,6 +5,8 @@ from django.urls import reverse from django.utils.translation import gettext as _ +from traffic_control.services.icon_draw_config import get_icons_relative_url, get_icons_scale, get_icons_type + from .models import FeatureTypeEditMapping, Layer @@ -37,7 +39,7 @@ def map_config(request): } ) - traffic_sign_icons_url = f"{request.build_absolute_uri(settings.STATIC_URL)}traffic_control/svg/traffic_sign_icons/" + traffic_sign_icons_url = f"{request.build_absolute_uri(settings.STATIC_URL)}{get_icons_relative_url()}" config = { "basemapConfig": { "name": _("Basemaps"), @@ -55,6 +57,8 @@ def map_config(request): "imageExtent": _get_overview_image_extent(), }, "traffic_sign_icons_url": traffic_sign_icons_url, + "icon_scale": get_icons_scale(), + "icon_type": get_icons_type(), "featureTypeEditNameMapping": FeatureTypeEditMapping.get_featuretype_edit_name_mapping(), } return JsonResponse(config) diff --git a/traffic_control/services/icon_draw_config.py b/traffic_control/services/icon_draw_config.py new file mode 100644 index 00000000..cb401110 --- /dev/null +++ b/traffic_control/services/icon_draw_config.py @@ -0,0 +1,25 @@ +from django.db import models + +from map.models import IconDrawingConfig + + +def get_active_icon_drawing_config(): + try: + return IconDrawingConfig.objects.get(active=True) + except models.ObjectDoesNotExist: + return None + + +def get_icons_relative_url(): + config = get_active_icon_drawing_config() + return get_active_icon_drawing_config().icons_relative_url if config else IconDrawingConfig.DEFAULT_ICON_URL + + +def get_icons_scale(): + config = get_active_icon_drawing_config() + return get_active_icon_drawing_config().scale if config else IconDrawingConfig.DEFAULT_ICON_SCALE + + +def get_icons_type(): + config = get_active_icon_drawing_config() + return get_active_icon_drawing_config().image_type if config else "svg"