feat: add parking thematic with dedicated management views, models, and configurable funding program field

This commit is contained in:
kdeterme 2026-08-07 11:30:00 +02:00
parent dad5acc4fb
commit c74e96bf29
12 changed files with 913 additions and 26 deletions

View file

@ -0,0 +1,74 @@
from django.db import migrations
def create_parking_thematic(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
ContentType = apps.get_model('contenttypes', 'ContentType')
thematic, _created = Thematic.objects.get_or_create(
code='parking',
defaults={
'name_fr': 'Stationnement & Mobilité',
'name_nl': 'Parkeren & Mobiliteit',
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
'icon': 'bi-p-square',
'color': '#0d6efd',
}
)
category, _cat_created = AssetCategory.objects.get_or_create(
code='parking_spot',
defaults={
'thematic': thematic,
'name_fr': 'Emplacements de stationnement',
'name_nl': 'Parkeerplaatsen',
}
)
if category.thematic_id != thematic.id:
category.thematic = thematic
category.save()
# Link ContentTypes
try:
spot_ct = ContentType.objects.get(app_label='assets', model='parkingspot')
category.allowed_models.add(spot_ct)
except Exception:
pass
try:
loc_ct = ContentType.objects.get(app_label='assets', model='parkinglocation')
category.allowed_models.add(loc_ct)
except Exception:
pass
# Assign category to existing ParkingSpot records
ParkingSpot = apps.get_model('assets', 'ParkingSpot')
ParkingSpot.objects.filter(category__isnull=True).update(category=category)
# Grant thematic permissions only to admin user configurations
UserConfig = apps.get_model('common', 'UserConfig')
UserThematics = apps.get_model('common', 'UserThematics')
for uc in UserConfig.objects.filter(roles__name='admin').distinct():
UserThematics.objects.get_or_create(
user_config=uc,
thematic=thematic,
defaults={'can_view_assets': True, 'can_edit_assets': True}
)
def remove_parking_thematic(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('assets', '0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more'),
('common', '0001_initial'),
]
operations = [
migrations.RunPython(create_parking_thematic, remove_parking_thematic),
]

View file

@ -0,0 +1,213 @@
# Generated by Django 6.0.7 on 2026-08-07 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0010_add_parking_thematic'),
]
operations = [
migrations.AddField(
model_name='artworkfountain',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='artworkmonument',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='buildingasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='itsgeoasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturebench',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturegreensurface',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturerwiasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='natureshrub',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturetree',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='outdoorasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='parkingspot',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='publiclightingpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roaddrain',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadfurniture',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadsidewalk',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadway',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='signpanel',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='signparkingspot',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='signpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='structuregeoasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightaccessory',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightcable',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightcontroller',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightcontrollerhardware',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightdetector',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightelectricalcabinet',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightelectricalcabinetcontent',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightlantern',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightnetworkhardware',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightprogramming',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightradar',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightradarpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightvoltagehardware',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
]

View file

@ -1,6 +1,8 @@
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.utils.translation import get_language, gettext_lazy as _ from django.utils.translation import get_language, gettext_lazy as _
from common.models import Thematic from common.models import Thematic
@ -280,10 +282,20 @@ class AbstractAsset(models.Model):
marked_as_removed_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_marked_as_removed', verbose_name="Marqué comme retiré par") marked_as_removed_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_marked_as_removed', verbose_name="Marqué comme retiré par")
note = models.TextField(null=True, blank=True, verbose_name="Note") note = models.TextField(null=True, blank=True, verbose_name="Note")
validation_status = models.CharField(max_length=50, choices=ASSET_VALIDATION_STATUS_CHOICES, null=True, blank=True, verbose_name="Statut de validation") validation_status = models.CharField(max_length=50, choices=ASSET_VALIDATION_STATUS_CHOICES, null=True, blank=True, verbose_name="Statut de validation")
funding_program = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Programme de financement / Projet"))
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_updated_by', verbose_name="Mis à jour par") updated_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_updated_by', verbose_name="Mis à jour par")
asset_inspections = GenericRelation(
'assets.AssetInspection',
content_type_field='asset_content_type',
object_id_field='asset_object_id',
related_query_name='%(app_label)s_%(class)s'
)
def get_replacement_chain(self): def get_replacement_chain(self):
"""Get the complete replacement chain starting from this asset.""" """Get the complete replacement chain starting from this asset."""
chain = [self] chain = [self]

View file

@ -16,36 +16,37 @@ ASSET_EDITABLE_FIELDS_BY_ROLE = {
'admin': [ 'admin': [
'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'manager': [ 'manager': [
'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'controller': [ 'controller': [
'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'external_manager': [ 'external_manager': [
'name_fr', 'name_nl', 'category', 'brand', 'serial_number' 'name_fr', 'name_nl', 'category', 'brand', 'serial_number', 'funding_program'
], ],
'technician': ['name_fr', 'name_nl', 'category', 'brand', 'serial_number'], 'technician': ['name_fr', 'name_nl', 'category', 'brand', 'serial_number', 'funding_program'],
'operator': [], 'operator': [],
'observer': [], 'observer': [],
'viewer': [], 'viewer': [],
'editor': [ 'editor': [
'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
} }
# Actions allowed by role for assets # Actions allowed by role for assets
ASSET_ACTIONS_BY_ROLE = { ASSET_ACTIONS_BY_ROLE = {
'admin': ['edit', 'create', 'archive', 'replace', 'bulk_archive'], 'admin': ['edit', 'create', 'archive', 'replace', 'bulk_archive'],
@ -65,11 +66,17 @@ def get_user_asset_thematic_access(user, thematic):
Check if user has access to view/edit assets for a given thematic. Check if user has access to view/edit assets for a given thematic.
Returns a tuple (can_view, can_edit). Returns a tuple (can_view, can_edit).
""" """
if getattr(user, 'is_superuser', False):
return True, True
try: try:
user_config = UserConfig.objects.get(user=user) user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist: except UserConfig.DoesNotExist:
return False, False return False, False
if user_config.roles.filter(name__in=['admin', 'operator']).exists():
return True, True
try: try:
user_thematic = UserThematics.objects.get( user_thematic = UserThematics.objects.get(
user_config=user_config, user_config=user_config,
@ -80,6 +87,7 @@ def get_user_asset_thematic_access(user, thematic):
return False, False return False, False
def _check_instance_permission(user, obj, permission_field, thematic_fallback_fn=None): def _check_instance_permission(user, obj, permission_field, thematic_fallback_fn=None):
""" """
Shared logic for per-instance permission checks (assets and locations). Shared logic for per-instance permission checks (assets and locations).

View file

@ -38,7 +38,8 @@ var locationsFetchSignatures = {};
var assetsFetchSignatures = {}; var assetsFetchSignatures = {};
// Assets: thematics that always load all assets (small datasets) // Assets: thematics that always load all assets (small datasets)
const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters']); const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', 'parking']);
const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox' var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'
@ -1795,6 +1796,7 @@ function collectClickedAssets(clickEvent) {
const layers = [ const layers = [
'assets', 'assets',
'assets-fill', 'assets-fill',
'assets-polygon-outline',
'assets-line', 'assets-line',
'assets-point', 'assets-point',
'sign-panels-circles', 'sign-panels-circles',
@ -1803,6 +1805,7 @@ function collectClickedAssets(clickEvent) {
'sign-poles', 'sign-poles',
'sign-poles-vertical', 'sign-poles-vertical',
].filter(layerId => map.getLayer(layerId)); ].filter(layerId => map.getLayer(layerId));
if (!layers.length) return []; if (!layers.length) return [];
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers }); const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
@ -2374,8 +2377,9 @@ function loadTableData(features) {
// ─── Map integration ────────────────────────────────────────────────────────── // ─── Map integration ──────────────────────────────────────────────────────────
var locationshighlightState = {}; var locationshighlightState = { previousId: null };
var assetshighlightState = {}; var assetshighlightState = { previousId: null };
function isPanelTextSpriteId(missingId) { function isPanelTextSpriteId(missingId) {
if (!missingId) return false; if (!missingId) return false;

View file

@ -216,8 +216,22 @@ function loadUnifiedMapIcons(map) {
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' }, { name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' }, { name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris { name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' }, // bicycle — vert mobilité active
{ name: 'icon-parkingspot-scooter_drop_zone', codepoint: 'f8ab', color: '#10b981' }, // scooter — vert mobilité active
{ name: 'icon-parkingspot-pmr', codepoint: 'f8fb', color: '#0d6efd' }, // person-wheelchair — bleu voiture PMR
{ name: 'icon-parkingspot-ev_charging', codepoint: 'f839', color: '#0d6efd' }, // ev-station-fill — bleu voiture IRVE
{ name: 'icon-parkingspot-delivery', codepoint: 'f820', color: '#f59e0b' }, // truck-front-fill — orange camion livraison
{ name: 'icon-parkingspot-kiss_and_ride', codepoint: 'f291', color: '#0d6efd' }, // clock-fill — bleu voiture CD
{ name: 'icon-parkingspot-carsharing', codepoint: 'f7e0', color: '#0d6efd' }, // car-front-fill — bleu voiture autopartage
{ name: 'icon-parkinglocation', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement
]; ];
GLYPH_ICONS.forEach(({ name, codepoint, color }) => { GLYPH_ICONS.forEach(({ name, codepoint, color }) => {
// Skip if a file-based icon URL is being loaded for this name (async race condition) // Skip if a file-based icon URL is being loaded for this name (async race condition)
if (FILE_ICON_NAMES.has(name)) return; if (FILE_ICON_NAMES.has(name)) return;
@ -293,7 +307,9 @@ const THEMATIC_COLOR_EXPR = [
'controlcenters', '#7b1fa2', // purple — control centres 'controlcenters', '#7b1fa2', // purple — control centres
'publicbuildings', '#2B579A', // navy blue — public buildings 'publicbuildings', '#2B579A', // navy blue — public buildings
'buildings', '#2B579A', // navy blue — public buildings 'buildings', '#2B579A', // navy blue — public buildings
'parking', '#0d6efd', // blue — parking & mobility
'roads', [ 'roads', [
'match', ['get', 'asset_type'], 'match', ['get', 'asset_type'],
'roadway', '#4a4a4a', 'roadway', '#4a4a4a',
'sidewalk', '#979898ff', 'sidewalk', '#979898ff',
@ -339,9 +355,22 @@ const _unified_assets_fill = {
], ],
['==', ['get', 'thematic'], 'nature'], ['==', ['get', 'thematic'], 'nature'],
['coalesce', ['get', 'color'], '#2e7d32'], ['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'],
[
'match', ['get', 'spot_type'],
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
'delivery', '#f59e0b', // Orange/Ambre camion livraison
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
],
THEMATIC_COLOR_EXPR THEMATIC_COLOR_EXPR
], ],
'fill-opacity': 0.7,
'fill-opacity': [
'case',
['==', ['get', 'thematic'], 'parking'], 0.35,
0.7
],
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices 'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
'fill-outline-color': [ 'fill-outline-color': [
'case', 'case',
@ -503,6 +532,16 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset', ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset', ['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
['==', ['get', 'spot_type'], 'pmr'], 'icon-parkingspot-pmr',
['==', ['get', 'spot_type'], 'ev_charging'], 'icon-parkingspot-ev_charging',
['==', ['get', 'spot_type'], 'delivery'], 'icon-parkingspot-delivery',
['==', ['get', 'spot_type'], 'kiss_and_ride'], 'icon-parkingspot-kiss_and_ride',
['==', ['get', 'spot_type'], 'carsharing'], 'icon-parkingspot-carsharing',
['match', ['get', 'spot_type'], ['bicycle', 'cargo_bike'], true, false], 'icon-parkingspot-bicycle',
['==', ['get', 'asset_type'], 'parkingspot'], 'icon-parkingspot',
// fallback: match by thematic // fallback: match by thematic
[ [
'match', ['get', 'thematic'], 'match', ['get', 'thematic'],
@ -518,9 +557,11 @@ const _unified_assets_symbol = {
'controlcenters', 'icon-controlcenters', 'controlcenters', 'icon-controlcenters',
'publicbuildings', 'icon-publicbuildings', 'publicbuildings', 'icon-publicbuildings',
'publicbuilding', 'icon-publicbuilding', 'publicbuilding', 'icon-publicbuilding',
'parking', 'icon-parking',
'roads', 'icon-roads', 'roads', 'icon-roads',
'icon-roads' // fallback 'icon-roads' // fallback
] ]
], ],
'icon-size': [ 'icon-size': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
@ -801,6 +842,39 @@ const _unified_assets_point = {
minzoom: 8, minzoom: 8,
}; };
// ─── Layer 3c — asset polygon outline (line layer for polygon asset borders) ───
const _unified_assets_polygon_outline = {
id: 'assets-polygon-outline',
type: 'line',
source: 'assets',
filter: ['any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon']
],
paint: {
'line-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
['==', ['get', 'thematic'], 'parking'],
[
'match', ['get', 'spot_type'],
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
'delivery', '#f59e0b', // Orange/Ambre camion livraison
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
],
THEMATIC_COLOR_EXPR
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 4,
2
],
'line-opacity': 0.9,
},
minzoom: 8,
};
// ─── Ordered layer list ─────────────────────────────────────────────────────── // ─── Ordered layer list ───────────────────────────────────────────────────────
// MapLibre renders layers in insertion order, so the array below defines the // MapLibre renders layers in insertion order, so the array below defines the
// visual stacking: fills at the bottom, then lines, then circles/symbols on top. // visual stacking: fills at the bottom, then lines, then circles/symbols on top.
@ -809,6 +883,7 @@ const UNIFIED_LAYERS = [
_unified_assets_fill, // 2. polygon fills _unified_assets_fill, // 2. polygon fills
_unified_locations_outline, // 3. lines _unified_locations_outline, // 3. lines
_unified_assets_line, // 3b. asset lines _unified_assets_line, // 3b. asset lines
_unified_assets_polygon_outline, // 3c. polygon asset outlines
_unified_locations_circle, // 4. circles _unified_locations_circle, // 4. circles
_unified_locations_radar_outline, _unified_locations_radar_outline,
_unified_locations_radar, _unified_locations_radar,
@ -820,3 +895,4 @@ const UNIFIED_LAYERS = [
_unified_sign_poles_vertical, _unified_sign_poles_vertical,
_unified_sign_panels_symbols, _unified_sign_panels_symbols,
]; ];

View file

@ -359,6 +359,21 @@
</div> </div>
</td> </td>
</tr> </tr>
<!-- Programme de financement / Projet -->
{% if asset.funding_program or 'funding_program' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="funding_program" data-can-edit="{% if 'funding_program' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Programme / Financement" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.funding_program|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.funding_program|default:'' }}">
</div>
</td>
</tr>
{% endif %}
<!-- Durée de garantie --> <!-- Durée de garantie -->
<tr class="asset-editable-field" data-field="warranty_duration" data-can-edit="{% if 'warranty_duration' in allowed_update_fields %}true{% else %}false{% endif %}"> <tr class="asset-editable-field" data-field="warranty_duration" data-can-edit="{% if 'warranty_duration' in allowed_update_fields %}true{% else %}false{% endif %}">

View file

@ -1,13 +1,34 @@
from django.test import TestCase from django.test import TestCase, Client
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.gis.geos import Polygon, MultiPolygon from django.contrib.gis.geos import Polygon, MultiPolygon
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from assets.models import ParkingLocation, ParkingSpot, AssetInspection, ParkingSpotInspection, SignPole, SignPanel from assets.models import ParkingLocation, ParkingSpot, AssetInspection, ParkingSpotInspection
from common.models import Thematic, UserConfig, UserThematics
class ParkingSpotAndInspectionTest(TestCase): class ParkingSpotAndInspectionTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user(username="inspector_user", password="password123") self.user = User.objects.create_user(username="inspector_user", password="password123")
self.client = Client()
# Create thematic and user thematic permissions
self.thematic, _ = Thematic.objects.get_or_create(
code="parking",
defaults={
"name_fr": "Stationnement & Mobilité",
"name_nl": "Parkeren & Mobiliteit"
}
)
self.user_config = UserConfig.objects.create(user=self.user)
UserThematics.objects.create(
user_config=self.user_config,
thematic=self.thematic,
can_view_assets=True
)
self.client.force_login(self.user)
poly = Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812) poly = Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812)
multi_poly = MultiPolygon(poly, srid=3812) multi_poly = MultiPolygon(poly, srid=3812)
@ -41,7 +62,6 @@ class ParkingSpotAndInspectionTest(TestCase):
self.assertIsNotNone(self.spot.geojson) self.assertIsNotNone(self.spot.geojson)
def test_parking_spot_inspection_updates_asset(self): def test_parking_spot_inspection_updates_asset(self):
# Using specialized ParkingSpotInspection model
inspection = ParkingSpotInspection.objects.create( inspection = ParkingSpotInspection.objects.create(
asset=self.spot, asset=self.spot,
inspector=self.user, inspector=self.user,
@ -65,7 +85,6 @@ class ParkingSpotAndInspectionTest(TestCase):
self.assertTrue(self.spot.is_compliant) self.assertTrue(self.spot.is_compliant)
def test_generic_asset_inspection_non_compliance(self): def test_generic_asset_inspection_non_compliance(self):
# Generic inspection works for any asset
inspection = AssetInspection.objects.create( inspection = AssetInspection.objects.create(
asset=self.spot, asset=self.spot,
inspector=self.user, inspector=self.user,
@ -79,3 +98,35 @@ class ParkingSpotAndInspectionTest(TestCase):
self.assertFalse(self.spot.is_compliant) self.assertFalse(self.spot.is_compliant)
self.assertEqual(self.spot.inspection_status, "non_compliant") self.assertEqual(self.spot.inspection_status, "non_compliant")
self.assertEqual(self.spot.non_compliance_reason, "Arceau cargo tordu suite à un choc de livraison") self.assertEqual(self.spot.non_compliance_reason, "Arceau cargo tordu suite à un choc de livraison")
def test_parking_assets_geojson_view(self):
url = reverse('assets:parking_assets_geojson')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data.get("type"), "FeatureCollection")
features = data.get("features", [])
self.assertEqual(len(features), 1)
feat = features[0]
self.assertEqual(feat["properties"]["code"], "SPOT-VELO-01")
self.assertEqual(feat["properties"]["standard_stand_count"], 3)
self.assertEqual(feat["properties"]["cargo_stand_count"], 2)
self.assertEqual(feat["properties"]["total_stand_count"], 5)
self.assertTrue(feat["properties"]["has_scooter_drop_zone"])
def test_parking_assets_autocomplete_view(self):
url = reverse('assets:parking_autocomplete_assets')
response = self.client.get(url, {'q': 'Grand Place'})
self.assertEqual(response.status_code, 200)
data = response.json()
results = data.get("results", [])
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["code"], "SPOT-VELO-01")
def test_parking_thematic_config(self):
from assets.views.core import build_thematic_config
config = build_thematic_config([self.thematic])
self.assertIn("parking", config)
self.assertEqual(config["parking"]["assets_geojson_url"], reverse("assets:parking_assets_geojson"))
self.assertEqual(config["parking"]["locations_geojson_url"], reverse("assets:parking_locations_geojson"))

View file

@ -1,10 +1,17 @@
from django.urls import path from django.urls import path
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking
app_name = "assets" app_name = "assets"
urlpatterns = [ urlpatterns = [
path("", core.unified_assets_index, name="index"), path("", core.unified_assets_index, name="index"),
path("parking/", parking.parking_index, name="parking_index"),
path("parking/assets/", parking.parking_assets, name="parking_assets"),
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),
path("parking/assets/autocomplete/", parking.parking_assets_autocomplete, name="parking_autocomplete_assets"),
path("parking/locations/geojson/", parking.parking_locations_geojson, name="parking_locations_geojson"),
path("parking/<str:asset_model>/<int:asset_id>/", parking.parking_assets_detail, name="parking_assets_detail"),
path("buildings/", buildings.buildings_index, name="buildings_index"), path("buildings/", buildings.buildings_index, name="buildings_index"),
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"), path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"), path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"),

View file

@ -229,6 +229,9 @@ def index(request):
return redirect('assets:sign_index') return redirect('assets:sign_index')
elif default_thematic == 'controlcenters': elif default_thematic == 'controlcenters':
return redirect('assets:controlcenters_index') return redirect('assets:controlcenters_index')
elif default_thematic == 'parking':
return redirect('assets:parking_index')
# Add more conditions for other thematics # Add more conditions for other thematics
else: else:
return redirect('assets:trafficlights_index') # Fallback URL if no match is found return redirect('assets:trafficlights_index') # Fallback URL if no match is found
@ -487,7 +490,12 @@ def build_thematic_config(thematics):
'locations_geojson_url': reverse('assets:outdoor_locations_geojson'), 'locations_geojson_url': reverse('assets:outdoor_locations_geojson'),
'assets_geojson_url': reverse('assets:outdoor_assets_geojson'), 'assets_geojson_url': reverse('assets:outdoor_assets_geojson'),
}, },
'parking': {
'locations_geojson_url': reverse('assets:parking_locations_geojson'),
'assets_geojson_url': reverse('assets:parking_assets_geojson'),
},
} }
result = {} result = {}
for thematic in thematics: for thematic in thematics:
code = thematic.code code = thematic.code
@ -804,9 +812,47 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'options': panel_type_options, 'options': panel_type_options,
}) })
# Parking specific filter
if 'parking' in accessible_codes:
config['parking'] = [
{
'key': 'spot_type',
'label': _("Type d'emplacement"),
'type': 'multi',
'options': [
{'value': 'bicycle', 'label': str(_('Stationnement vélo'))},
{'value': 'cargo_bike', 'label': str(_('Stationnement vélo cargo'))},
{'value': 'scooter_drop_zone', 'label': str(_('Drop zone trottinettes / EDPM'))},
{'value': 'delivery', 'label': str(_('Zone de livraison'))},
{'value': 'pmr', 'label': str(_('Emplacement PMR'))},
{'value': 'kiss_and_ride', 'label': str(_('Arrêt minute (CD)'))},
{'value': 'car_parking', 'label': str(_('Stationnement automobile'))},
],
},
{
'key': 'has_scooter_drop_zone',
'label': _('Drop zone trottinettes'),
'type': 'single',
'options': [
{'value': 'true', 'label': str(_('Oui'))},
{'value': 'false', 'label': str(_('Non'))},
],
},
{
'key': 'is_compliant',
'label': _('Conformité'),
'type': 'single',
'options': [
{'value': 'true', 'label': str(_('Conforme'))},
{'value': 'false', 'label': str(_('Non conforme'))},
],
},
]
return config return config
def build_unified_add_modal_config(accessible_thematics, user=None): def build_unified_add_modal_config(accessible_thematics, user=None):
""" """
Returns the thematic choices allowed for the unified add modal. Returns the thematic choices allowed for the unified add modal.

View file

@ -0,0 +1,367 @@
import json
import unicodedata
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, Http404, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.contrib.gis.geos import GEOSGeometry
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.translation import gettext as _, get_language
from common.models import Thematic
from common.utils import check_thematic_access
from assets.models.parking import (
ParkingLocation, ParkingSpot,
SPOT_TYPE_CHOICES, STAND_TYPE_CHOICES, MARKING_QUALITY_CHOICES
)
from assets.views.core import get_asset_edit_context
MAX_ASSET_FEATURES = 5000
@login_required
def parking_index(request):
"""Page d'accueil de la thématique Stationnement & Mobilité."""
check_thematic_access(request.user, 'parking')
parking_thematic = Thematic.objects.filter(code='parking').first()
context = {
'thematic': parking_thematic,
'spot_types': SPOT_TYPE_CHOICES,
'stand_types': STAND_TYPE_CHOICES,
'marking_qualities': MARKING_QUALITY_CHOICES,
}
return render(request, 'assets/parking_index.html', context)
def parking_assets_geojson(request):
"""
Vue GeoJSON renvoyant les emplacements de stationnement (ParkingSpot).
Prend en charge le filtrage par type d'emplacement, type d'arceau, drop zone, statut, conformité, géométrie et recherche texte.
"""
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
spot_types = data.get('spot_type') or []
if isinstance(spot_types, str):
spot_types = [spot_types]
stand_types = data.get('stand_type') or []
if isinstance(stand_types, str):
stand_types = [stand_types]
categories = data.get('categories') or data.get('category') or []
if isinstance(categories, (str, int)):
categories = [categories]
has_drop_zone = data.get('has_scooter_drop_zone')
is_compliant = data.get('is_compliant')
inspection_status = data.get('inspection_status')
status = data.get('status')
filter_geom_raw = data.get("filter_geom")
if filter_geom_raw:
try:
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
filter_geom.transform(3812)
except Exception as e:
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
else:
filter_geom = None
search_q = (data.get('q') or '').strip()
else:
spot_types = request.GET.getlist('spot_type', [])
stand_types = request.GET.getlist('stand_type', [])
categories = request.GET.getlist('category', [])
has_drop_zone = request.GET.get('has_scooter_drop_zone')
is_compliant = request.GET.get('is_compliant')
inspection_status = request.GET.get('inspection_status')
status = request.GET.get('status')
filter_geom = None
search_q = request.GET.get('q', '').strip()
qs = ParkingSpot.objects.select_related('category', 'location').all()
if categories:
qs = qs.filter(category_id__in=categories)
if spot_types:
qs = qs.filter(spot_type__in=spot_types)
if stand_types:
qs = qs.filter(stand_type__in=stand_types)
if has_drop_zone in ['true', '1', True]:
qs = qs.filter(has_scooter_drop_zone=True)
elif has_drop_zone in ['false', '0', False]:
qs = qs.filter(has_scooter_drop_zone=False)
if is_compliant in ['true', '1', True]:
qs = qs.filter(is_compliant=True)
elif is_compliant in ['false', '0', False]:
qs = qs.filter(is_compliant=False)
if inspection_status:
qs = qs.filter(inspection_status=inspection_status)
if status:
qs = qs.filter(status=status)
if filter_geom:
qs = qs.filter(geom__intersects=filter_geom)
if search_q:
qs = qs.filter(
Q(code__icontains=search_q) |
Q(name_fr__icontains=search_q) |
Q(name_nl__icontains=search_q) |
Q(note__icontains=search_q)
)
qs = qs[:MAX_ASSET_FEATURES]
features = []
for spot in qs:
geometry = None
if spot.geojson:
try:
geometry = json.loads(spot.geojson)
except Exception:
pass
if not geometry and spot.geom:
try:
geom_wgs84 = spot.geom.clone()
geom_wgs84.transform(4326)
geometry = json.loads(geom_wgs84.json)
except Exception:
pass
if not geometry:
continue
detail_url = reverse('assets:parking_assets_detail', kwargs={
'asset_model': 'parkingspot',
'asset_id': spot.id
})
category_name = spot.category.get_name() if spot.category else _("Emplacements de stationnement")
category_id = spot.category_id if spot.category else None
lon = getattr(spot, 'lon', None)
lat = getattr(spot, 'lat', None)
if (lon is None or lat is None) and spot.geom:
try:
centroid = spot.geom.centroid
centroid.transform(4326)
lon = centroid.x
lat = centroid.y
except Exception:
pass
properties = {
"id": spot.id,
"code": spot.code,
"name_fr": spot.get_name(),
"name_nl": spot.name_nl or spot.name_fr or "",
"lon": lon,
"lat": lat,
"thematic": "parking",
"asset_type": "parkingspot",
"category": category_name,
"category_name": category_name,
"category_id": category_id,
"spot_type": spot.spot_type,
"spot_type_display": spot.get_spot_type_display(),
"standard_stand_count": spot.standard_stand_count,
"cargo_stand_count": spot.cargo_stand_count,
"total_stand_count": spot.total_stand_count,
"stand_type": spot.stand_type,
"stand_type_display": spot.get_stand_type_display(),
"has_scooter_drop_zone": spot.has_scooter_drop_zone,
"scooter_capacity": spot.scooter_capacity,
"capacity": spot.capacity,
"marking_quality": spot.marking_quality,
"is_compliant": spot.is_compliant,
"inspection_status": spot.inspection_status,
"funding_program": spot.funding_program,
"status": spot.status,
"status_display": spot.get_status_display(),
"last_inspection_date": spot.last_inspection_date.isoformat() if spot.last_inspection_date else None,
"detail_url": detail_url,
"asset_model": "parkingspot",
"element_type": "asset"
}
features.append({
"type": "Feature",
"id": spot.id,
"geometry": geometry,
"properties": properties
})
return JsonResponse({
"type": "FeatureCollection",
"features": features
})
def parking_locations_geojson(request):
"""
Vue GeoJSON renvoyant les zones/secteurs de stationnement (ParkingLocation).
"""
qs = ParkingLocation.objects.all()
search_q = request.GET.get('q', '').strip()
if search_q:
qs = qs.filter(
Q(code__icontains=search_q) |
Q(name_fr__icontains=search_q) |
Q(name_nl__icontains=search_q) |
Q(address__icontains=search_q)
)
features = []
for loc in qs:
geometry = None
if loc.geojson:
try:
geometry = json.loads(loc.geojson)
except Exception:
pass
if not geometry and loc.geom:
try:
geom_wgs84 = loc.geom.clone()
geom_wgs84.transform(4326)
geometry = json.loads(geom_wgs84.json)
except Exception:
pass
if not geometry:
continue
loc_lon = getattr(loc, 'lon', None)
loc_lat = getattr(loc, 'lat', None)
if (loc_lon is None or loc_lat is None) and loc.geom:
try:
centroid = loc.geom.centroid
centroid.transform(4326)
loc_lon = centroid.x
loc_lat = centroid.y
except Exception:
pass
features.append({
"type": "Feature",
"id": loc.id,
"geometry": geometry,
"properties": {
"id": loc.id,
"code": loc.code,
"name_fr": loc.get_name(),
"address": loc.address or "",
"lon": loc_lon,
"lat": loc_lat,
"thematic": "parking",
"location_type": "parkinglocation",
"element_type": "location"
}
})
return JsonResponse({
"type": "FeatureCollection",
"features": features
})
def parking_assets_autocomplete(request):
"""
API d'autocomplétion et de recherche pour les emplacements de stationnement.
"""
raw_query = (request.GET.get('q') or '').strip()
qn = unicodedata.normalize('NFD', raw_query).encode('ascii', 'ignore').decode('ascii').lower()
qs = ParkingSpot.objects.filter(status='active')
if qn:
qs = qs.filter(
Q(code__icontains=qn) |
Q(name_fr__icontains=qn) |
Q(name_nl__icontains=qn) |
Q(note__icontains=qn)
)
qs = qs.order_by('code')[:20]
results = []
for spot in qs:
results.append({
'id': spot.id,
'code': spot.code,
'name': spot.get_name(),
'spot_type': spot.spot_type,
'spot_type_display': spot.get_spot_type_display(),
'total_stand_count': spot.total_stand_count,
'has_scooter_drop_zone': spot.has_scooter_drop_zone,
'inspection_status': spot.inspection_status,
'detail_url': reverse('assets:parking_assets_detail', kwargs={'asset_model': 'parkingspot', 'asset_id': spot.id})
})
return JsonResponse({'results': results})
@login_required
def parking_assets(request):
"""Vue de la liste et recherche des emplacements de stationnement."""
check_thematic_access(request.user, 'parking')
spots = ParkingSpot.objects.all().select_related('location', 'street')
search_q = request.GET.get('q', '').strip()
if search_q:
spots = spots.filter(
Q(code__icontains=search_q) |
Q(name_fr__icontains=search_q) |
Q(name_nl__icontains=search_q) |
Q(note__icontains=search_q)
)
context = {
'spots': spots,
'spot_types': SPOT_TYPE_CHOICES,
'search_q': search_q,
}
return render(request, 'assets/parking_assets.html', context)
@login_required
def parking_assets_detail(request, asset_model, asset_id):
"""Vue de détail pour un emplacement de stationnement."""
check_thematic_access(request.user, 'parking')
spot = get_object_or_404(ParkingSpot, id=asset_id)
content_type = ContentType.objects.get_for_model(ParkingSpot)
from assets.models.inspection import AssetInspection
inspections = AssetInspection.objects.filter(
asset_content_type=content_type,
asset_object_id=spot.id
).order_by('-inspection_date')
from interventions.models import Intervention
from interventions.permissions import filter_viewable_interventions_for_user
base_interventions = filter_viewable_interventions_for_user(request.user)
interventions = base_interventions.filter(
interventionasset__content_type=content_type,
interventionasset__object_id=spot.id
).distinct().order_by('-creation_time')
context = {
'asset': spot,
'spot': spot,
'content_type': content_type,
'inspections': inspections,
'interventions': interventions,
}
context.update(get_asset_edit_context(request, spot, content_type))
return render(request, "assets/detail_generic.html", context)

View file

@ -278,24 +278,38 @@ function registerZoomAction(tableSelector, buttonClass, map, zoomLevel = 16) {
* @param {object} highlightState - Object to track the currently highlighted feature. * @param {object} highlightState - Object to track the currently highlighted feature.
*/ */
function highlightMapFeature(map, sourceId, featureId, highlightState) { function highlightMapFeature(map, sourceId, featureId, highlightState) {
if (!highlightState || !map) return;
// Remove highlight from the previous feature // Remove highlight from the previous feature
if (highlightState.previousId !== null) { if (highlightState.previousId !== null && highlightState.previousId !== undefined) {
map.setFeatureState( try {
{ source: sourceId, id: highlightState.previousId }, map.setFeatureState(
{ highlighted: false } { source: sourceId, id: highlightState.previousId },
); { highlighted: false }
);
} catch (e) {
// Ignore feature state errors if layer/source is not present
}
} }
// Set highlight on the newly selected feature // Set highlight on the newly selected feature
highlightState.previousId = featureId; if (featureId !== null && featureId !== undefined) {
map.setFeatureState( highlightState.previousId = featureId;
{ source: sourceId, id: featureId }, try {
{ highlighted: true } map.setFeatureState(
); { source: sourceId, id: featureId },
{ highlighted: true }
);
} catch (e) {
// Ignore feature state errors if layer/source is not present
}
} else {
highlightState.previousId = null;
}
} }
function reverseGeoJson(data) { function reverseGeoJson(data) {
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) { if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
return { return {