diff --git a/loko/assets/management/commands/import_cse_parking.py b/loko/assets/management/commands/import_cse_parking.py new file mode 100644 index 0000000..479896c --- /dev/null +++ b/loko/assets/management/commands/import_cse_parking.py @@ -0,0 +1,278 @@ +import json +from pathlib import Path +from django.core.management.base import BaseCommand +from django.contrib.gis.geos import Point, Polygon, MultiPolygon +from django.db import transaction +from shapely.geometry import shape, Point as ShpPoint + +from common.models import Thematic, Municipality +from assets.models import AssetCategory +from assets.models.sign import SignStreet +from assets.models.parking import ParkingLocation, ParkingSpot + +class Command(BaseCommand): + help = "Import parking areas, bicycle parking stands and PMR/VE spots for Court-Saint-Étienne" + + def handle(self, *args, **options): + self.stdout.write("=== Importing Parking & Bicycle Spots for Court-Saint-Étienne ===") + + parking_thematic = Thematic.objects.filter(code='parking').first() + parking_cat = AssetCategory.objects.filter(code='parking_spot').first() + bike_cat = AssetCategory.objects.filter(code='bike_parking_spot').first() + + cse = Municipality.objects.get(niscode='25014') + cse_geom_wgs84 = cse.geom.clone() + cse_geom_wgs84.transform(4326) + cse_poly = shape(json.loads(cse_geom_wgs84.geojson)) + + streets = list(SignStreet.objects.filter(geom__isnull=False)) + if not streets: + self.stderr.write("No SignStreet found in database!") + return + + osm_json_path = Path("/tmp/osm_cse_sign_parking.json") + if not osm_json_path.exists(): + self.stderr.write(f"OSM JSON not found: {osm_json_path}") + return + + with open(osm_json_path, 'r', encoding='utf-8') as f: + osm_data = json.load(f) + + elements = osm_data.get('elements', []) + + # Filter strictly inside CSE + in_cse = [] + for el in elements: + lat = el.get('lat') or (el.get('center') and el['center'].get('lat')) + lon = el.get('lon') or (el.get('center') and el['center'].get('lon')) + if lat is not None and lon is not None: + p = ShpPoint(lon, lat) + if cse_poly.contains(p): + el['_pt_wgs84'] = Point(lon, lat, srid=4326) + in_cse.append(el) + + self.stdout.write(f"Elements strictly in CSE: {len(in_cse)}") + + # ─── 1. ParkingLocation (Zones de stationnement) ─────────────────── + self.stdout.write("--- Step 1: ParkingLocation (Aires/Parcs) ---") + parking_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking'] + self.stdout.write(f"Parking lots in CSE: {len(parking_elements)}") + + # Existing locations + existing_loc_codes = set(ParkingLocation.objects.values_list('code', flat=True)) + max_loc_num = 0 + for c in existing_loc_codes: + if c and c.startswith('CSE-PKG-'): + try: + num = int(c.split('-')[-1]) + if num > max_loc_num: + max_loc_num = num + except ValueError: + pass + + new_locations = [] + for p in parking_elements: + tags = p.get('tags', {}) + pt_4326 = p['_pt_wgs84'] + pt_3812 = pt_4326.transform(3812, clone=True) + + closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812)) + + # Determine Name + osm_name = tags.get('name') + if osm_name: + name_fr = osm_name.strip() + else: + name_fr = f"Parking {closest_street.name_fr or 'public'}" + + # Capacity + try: + cap = int(tags.get('capacity', 15)) + except (ValueError, TypeError): + cap = 15 + + # Size proportional to capacity + size = max(16.0, min(65.0, (cap or 15) * 1.8)) + half = size / 2.0 + cx, cy = pt_3812.x, pt_3812.y + poly = Polygon([ + (cx - half, cy - half), + (cx + half, cy - half), + (cx + half, cy + half), + (cx - half, cy + half), + (cx - half, cy - half), + ], srid=3812) + mpoly = MultiPolygon([poly]) + + access = tags.get('access', 'public') + fee = tags.get('fee', 'non payant') + parking_type = tags.get('parking', 'surface') + desc = f"Aire de stationnement ({parking_type}). Accès: {access}. Tarification: {fee}. Capacité estimée: {cap} places." + + max_loc_num += 1 + code = f"CSE-PKG-{max_loc_num:04d}" + + loc = ParkingLocation( + code=code, + name_fr=name_fr, + geom=mpoly, + description=desc, + address=f"{closest_street.name_fr or 'Court-Saint-Étienne'}, 1490 Court-Saint-Étienne", + + ) + # Sync + loc.sync_geom_and_geojson() + new_locations.append(loc) + + with transaction.atomic(): + ParkingLocation.objects.bulk_create(new_locations) + self.stdout.write(self.style.SUCCESS(f"Created {len(new_locations)} ParkingLocations. Total in DB: {ParkingLocation.objects.count()}")) + + # Re-fetch all locations with spatial index + all_locations = list(ParkingLocation.objects.filter(geom__isnull=False)) + + # ─── 2. ParkingSpot (Vélos & Emplacements individuels) ──────────── + self.stdout.write("--- Step 2: ParkingSpot (Vélos, PMR, VE) ---") + bike_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'bicycle_parking'] + space_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking_space'] + + self.stdout.write(f"Bicycle parkings in CSE: {len(bike_elements)}") + self.stdout.write(f"Parking spaces in CSE: {len(space_elements)}") + + max_spot_num = 0 + for sc in ParkingSpot.objects.values_list('code', flat=True): + if sc: + try: + num = int(sc.split('-')[-1]) + if num > max_spot_num: + max_spot_num = num + except ValueError: + pass + + new_spots = [] + + # A. Bicycle parkings + for b in bike_elements: + tags = b.get('tags', {}) + pt_4326 = b['_pt_wgs84'] + pt_3812 = pt_4326.transform(3812, clone=True) + + closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812)) + closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None + + # Capacity & stands + try: + cap = int(tags.get('capacity', 6)) + except (ValueError, TypeError): + cap = 6 + + btype_osm = (tags.get('bicycle_parking') or '').lower() + if 'box' in btype_osm or 'shed' in btype_osm or 'secure' in btype_osm: + stand_type = 'box' + stands_label = "Box / Abri sécurisé" + elif 'rack' in btype_osm or 'wall' in btype_osm: + stand_type = 'rack' + stands_label = "Râteau / Pince-roue" + else: + stand_type = 'normal' + stands_label = "Arceaux U standard" + + # 3m x 2m polygon in 3812 + cx, cy = pt_3812.x, pt_3812.y + poly = Polygon([ + (cx - 1.5, cy - 1.0), + (cx + 1.5, cy - 1.0), + (cx + 1.5, cy + 1.0), + (cx - 1.5, cy + 1.0), + (cx - 1.5, cy - 1.0), + ], srid=3812) + + max_spot_num += 1 + code = f"CSE-PKV-{max_spot_num:04d}" + osm_name = tags.get('name') + name_fr = osm_name if osm_name else f"Stationnement vélos ({stands_label}) - {closest_street.name_fr or 'Rue'}" + + spot = ParkingSpot( + code=code, + name_fr=name_fr, + spot_type='bicycle', + stand_type=stand_type, + capacity=cap, + standard_stand_count=max(1, cap // 2), + geom=poly, + location=closest_loc, + street=closest_street, + + category=bike_cat, + + is_compliant=True, + ) + spot.sync_geom_and_geojson() + new_spots.append(spot) + + # B. Parking Spaces (PMR, EV charging, Car) + for s in space_elements: + tags = s.get('tags', {}) + pt_4326 = s['_pt_wgs84'] + pt_3812 = pt_4326.transform(3812, clone=True) + + closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812)) + closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None + + # Check PMR vs EV vs standard + is_pmr = ( + tags.get('capacity:disabled') in ('1', 'yes') or + tags.get('disabled') in ('yes', 'designated') or + tags.get('access') == 'disabled' or + 'pmr' in str(tags).lower() or + 'disabled' in str(tags).lower() + ) + is_ev = ( + 'charging' in str(tags).lower() or + tags.get('socket:type2') or + tags.get('amenity') == 'charging_station' + ) + + if is_pmr: + spot_type = 'pmr' + spot_label = "Emplacement PMR" + elif is_ev: + spot_type = 'ev_charging' + spot_label = "Borne recharge VE" + else: + spot_type = 'car_parking' + spot_label = "Emplacement automobile" + + # 2.5m x 5m polygon + cx, cy = pt_3812.x, pt_3812.y + poly = Polygon([ + (cx - 1.25, cy - 2.5), + (cx + 1.25, cy - 2.5), + (cx + 1.25, cy + 2.5), + (cx - 1.25, cy + 2.5), + (cx - 1.25, cy - 2.5), + ], srid=3812) + + max_spot_num += 1 + code = f"CSE-PKS-{max_spot_num:04d}" + name_fr = f"{spot_label} - {closest_street.name_fr or 'Rue'}" + + spot = ParkingSpot( + code=code, + name_fr=name_fr, + spot_type=spot_type, + capacity=1, + geom=poly, + location=closest_loc, + street=closest_street, + + category=parking_cat, + + is_compliant=True, + ) + spot.sync_geom_and_geojson() + new_spots.append(spot) + + with transaction.atomic(): + ParkingSpot.objects.bulk_create(new_spots) + self.stdout.write(self.style.SUCCESS(f"Created {len(new_spots)} ParkingSpots (total: {ParkingSpot.objects.count()}).")) diff --git a/loko/assets/management/commands/import_cse_signage.py b/loko/assets/management/commands/import_cse_signage.py new file mode 100644 index 0000000..d159819 --- /dev/null +++ b/loko/assets/management/commands/import_cse_signage.py @@ -0,0 +1,293 @@ +import json +import os +import shutil +from pathlib import Path +from django.core.management.base import BaseCommand +from django.conf import settings +from django.contrib.gis.geos import Point, GEOSGeometry +from django.db import transaction + +from common.models import Thematic +from assets.models import AssetCategory +from assets.models.sign import SignStreet, SignPole, SignPanel, SignPanelType + +BELGIAN_PANEL_TYPES = [ + # Priorité + ('B1', 'Cédez le passage', 'Voorrang verlenen', 'B/B1.svg'), + ('B5', 'Arrêt obligatoire (Stop)', 'Stop', 'B/B5.svg'), + ('B9', 'Route prioritaire', 'Voorrangsweg', 'B/B9.svg'), + ('B15', 'Fin de route prioritaire', 'Einde voorrangsweg', 'B/B15.svg'), + + # Danger + ('A1a', 'Virage dangereux à droite', 'Gevaarlijke bocht naar rechts', 'A/A1A.svg'), + ('A1b', 'Virage dangereux à gauche', 'Gevaarlijke bocht naar links', 'A/A1B.svg'), + ('A7a', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'A/A7A.svg'), + ('A14', 'Présence d\'enfants', 'Kinderen', 'A/A14.svg'), + ('A23', 'Travaux', 'Werken', 'A/A23.svg'), + ('A25', 'Ralentisseur de trafic', 'Verkeersdrempel', 'A/A25.svg'), + + # Interdiction + ('C1', 'Sens interdit', 'Verboden richting', 'C/C1.svg'), + ('C3', 'Accès interdit dans les deux sens', 'Verboden toegang in beide richtingen', 'C/C3.svg'), + ('C11', 'Accès interdit aux cycles', 'Verboden toegang voor fietsers', 'C/C11.svg'), + ('C43', 'Vitesse maximale limitée (50/30 km/h)', 'Maximumsnelheid', 'C/C43.svg'), + ('C45', 'Fin de limitation de vitesse', 'Einde snelheidsbeperking', 'C/C45.svg'), + + # Obligation + ('D7', 'Piste cyclable obligatoire', 'Verplicht fietspad', 'D/D7.svg'), + ('D9', 'Partage piétons / cyclistes', 'Gedeeld voetpad en fietspad', 'D/D9.svg'), + + # Stationnement + ('E1', 'Stationnement interdit', 'Parkeerverbod', 'E/E1.svg'), + ('E3', 'Arrêt et stationnement interdits', 'Stilstand- en parkeerverbod', 'E/E3.svg'), + ('E9a', 'Stationnement autorisé', 'Parkeren toegelaten', 'E/E9A.svg'), + ('E9b', 'Stationnement réservé aux PMR', 'Parkeren voor personen met een handicap', 'E/E9B.svg'), + ('E9c', 'Stationnement camionnettes / camions', 'Parkeren voor vrachtwagens', 'E/E9C.svg'), + ('E9h', 'Stationnement véhicules électriques (recharge)', 'Parkeren voor elektrische voertuigen', 'E/E9H.svg'), + + # Indications + ('F4a', 'Début de zone 30', 'Begin van een zone 30', 'F/F4A.svg'), + ('F4b', 'Fin de zone 30', 'Einde van een zone 30', 'F/F4B.svg'), + ('F12a', 'Zone résidentielle ou de rencontre', 'Woonerf of erf', 'F/F12A.svg'), + ('F19', 'Voie publique à sens unique', 'Eenrichtingsverkeer', 'F/F19.svg'), + ('F45', 'Impasse', 'Doodlopende weg', 'F/F45.svg'), + ('F49', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'F/F49.svg'), + ('F99a', 'Chemin réservé piétons / cyclistes', 'Weg voorbehouden voor voetgangers en fietsers', 'F/F99A.svg'), + ('F101a', 'Fin de chemin réservé', 'Einde weg voorbehouden', 'F/F101A.svg'), +] + +class Command(BaseCommand): + help = "Import signage (PICC poles and road panels) for Court-Saint-Étienne" + + def handle(self, *args, **options): + self.stdout.write("=== Importing Signage for Court-Saint-Étienne ===") + + # 1. SignPanelType catalog + self.stdout.write("--- Step 1: SignPanelType catalog ---") + media_target_dir = Path(settings.MEDIA_ROOT) / "sign" / "signpanels_types" + media_target_dir.mkdir(parents=True, exist_ok=True) + + static_road_signs = Path(settings.BASE_DIR) / "assets" / "static" / "assets" / "road_signs" + if not static_road_signs.exists(): + # Fallback check + static_road_signs = Path("/app/static/assets/road_signs") + + created_types = 0 + for code, name_fr, name_nl, rel_svg in BELGIAN_PANEL_TYPES: + pt, created = SignPanelType.objects.get_or_create( + code=code, + defaults={ + 'name_fr': name_fr, + 'name_nl': name_nl, + 'default_width': 50.0, + 'default_height': 50.0, + } + ) + # Copy SVG icon if exists + svg_source = static_road_signs / rel_svg + if svg_source.exists(): + dst_name = f"{code.lower()}.svg" + dst_path = media_target_dir / dst_name + if not dst_path.exists(): + shutil.copyfile(svg_source, dst_path) + pt.sgv_picture = f"sign/signpanels_types/{dst_name}" + pt.save(update_fields=['sgv_picture']) + if created: + created_types += 1 + self.stdout.write(f"SignPanelTypes ensured: {SignPanelType.objects.count()} (created {created_types})") + + # 2. Import PICC Poles + self.stdout.write("--- Step 2: PICC Poles ---") + sign_thematic = Thematic.objects.filter(code='sign').first() + pole_category = AssetCategory.objects.filter(code='SI_POLE').first() + + streets = list(SignStreet.objects.filter(geom__isnull=False)) + if not streets: + self.stderr.write("No SignStreet found in database!") + return + + # Preload existing poles to avoid duplicates (< 1.5m) + existing_poles = list(SignPole.objects.all().values('id', 'code', 'geom')) + self.stdout.write(f"Existing poles in DB: {len(existing_poles)}") + + # Find highest code number + max_num = 0 + for ep in existing_poles: + c = ep.get('code') or '' + if c.startswith('CSE-SGP-'): + try: + num = int(c.split('-')[-1]) + if num > max_num: + max_num = num + except ValueError: + pass + + geojson_path = Path("/tmp/cse_picc_poles.geojson") + if not geojson_path.exists(): + self.stderr.write(f"GeoJSON not found: {geojson_path}") + return + + with open(geojson_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + features = data.get('features', []) + self.stdout.write(f"PICC poles in GeoJSON: {len(features)}") + + # Street poles counters for names + street_counters = {} + for s in streets: + street_counters[s.id] = SignPole.objects.filter(street=s).count() + + new_poles = [] + skipped_existing = 0 + + for feat in features: + coords = feat['geometry']['coordinates'] + x, y = coords[0], coords[1] + pt = Point(x, y, srid=3812) + + # Check if existing pole is close (< 1.5m) + is_duplicate = False + for ep in existing_poles: + eg = ep.get('geom') + if eg and eg.distance(pt) < 1.5: + is_duplicate = True + break + + if is_duplicate: + skipped_existing += 1 + continue + + # Find closest street + closest_street = min(streets, key=lambda st: st.geom.distance(pt)) + street_counters[closest_street.id] = street_counters.get(closest_street.id, 0) + 1 + street_num = street_counters[closest_street.id] + + max_num += 1 + code = f"CSE-SGP-{max_num:04d}" + name = f"Poteau signalisation - {closest_street.name_fr or 'Rue'} #{street_num}" + + pole = SignPole( + code=code, + name_fr=name, + geom=pt, + street=closest_street, + + category=pole_category, + status='active', + orientation=90 + ) + # Sync lon, lat, geojson + pole.sync_geom_and_geojson() + new_poles.append(pole) + + # Add to local cache for duplicate checking + existing_poles.append({'id': None, 'code': code, 'geom': pt}) + + if new_poles: + with transaction.atomic(): + SignPole.objects.bulk_create(new_poles, batch_size=500) + self.stdout.write(self.style.SUCCESS(f"Created {len(new_poles)} new SignPoles (skipped {skipped_existing} existing). Total in DB: {SignPole.objects.count()}")) + else: + self.stdout.write(f"No new poles created (skipped {skipped_existing} duplicates).") + + # 3. Import Panels from OSM + self.stdout.write("--- Step 3: SignPanels from OSM ---") + panel_category = AssetCategory.objects.filter(code='SI_PANEL').first() + osm_json_path = Path("/tmp/osm_cse_sign_parking.json") + + if not osm_json_path.exists(): + self.stderr.write(f"OSM JSON not found: {osm_json_path}") + return + + with open(osm_json_path, 'r', encoding='utf-8') as f: + osm_data = json.load(f) + + osm_elements = osm_data.get('elements', []) + osm_signs = [ + e for e in osm_elements + if 'traffic_sign' in e.get('tags', {}) or e.get('tags', {}).get('highway') in ('give_way', 'stop') + ] + self.stdout.write(f"OSM signs found in dataset: {len(osm_signs)}") + + all_poles = list(SignPole.objects.select_related('street').all()) + type_b1 = SignPanelType.objects.filter(code='B1').first() + type_b5 = SignPanelType.objects.filter(code='B5').first() + type_f99a = SignPanelType.objects.filter(code='F99a').first() + + max_panel_num = 0 + for sp in SignPanel.objects.all().values_list('code', flat=True): + if sp and sp.startswith('CSE-PAN-'): + try: + num = int(sp.split('-')[-1]) + if num > max_panel_num: + max_panel_num = num + except ValueError: + pass + + panels_created = 0 + for s in osm_signs: + tags = s.get('tags', {}) + lat = s.get('lat') or (s.get('center') and s['center'].get('lat')) + lon = s.get('lon') or (s.get('center') and s['center'].get('lon')) + if lat is None or lon is None: + continue + + pt_4326 = Point(lon, lat, srid=4326) + pt_3812 = pt_4326.transform(3812, clone=True) + + # Determine SignPanelType + sig = tags.get('traffic_sign') or '' + hw = tags.get('highway') or '' + panel_type = None + + if 'B1' in sig or hw == 'give_way': + panel_type = type_b1 + elif 'B5' in sig or hw == 'stop': + panel_type = type_b5 + elif 'F99a' in sig: + panel_type = type_f99a + else: + # Try to extract code from string like "BE:B1", "BE:C43" + clean_code = sig.replace('BE:', '').split(';')[0].strip() + panel_type = SignPanelType.objects.filter(code__iexact=clean_code).first() + + if not panel_type: + panel_type = type_b1 # default fallback + + # Find nearest pole within 30 meters + nearest_pole = None + min_dist = float('inf') + for p in all_poles: + d = p.geom.distance(pt_3812) + if d < min_dist: + min_dist = d + nearest_pole = p + + if not nearest_pole or min_dist > 35.0: + # If no pole close enough, attach to closest pole anyway or skip + if not nearest_pole: + continue + + max_panel_num += 1 + pcode = f"CSE-PAN-{max_panel_num:04d}" + pname = f"Panneau {panel_type.code} - {nearest_pole.street.name_fr if nearest_pole.street else 'Rue'}" + + panel = SignPanel( + code=pcode, + name_fr=pname, + pole=nearest_pole, + street=nearest_pole.street, + signpanel_type=panel_type, + category=panel_category, + + status='active', + orientation=nearest_pole.orientation or 90, + support_type='pole', + geom=nearest_pole.geom, + ) + panel.sync_geom_and_geojson() + panel.save() + panels_created += 1 + + self.stdout.write(self.style.SUCCESS(f"Created {panels_created} SignPanels attached to poles. Total panels: {SignPanel.objects.count()}")) diff --git a/loko/assets/static/assets/asset_symbology.js b/loko/assets/static/assets/asset_symbology.js index b82e974..062d392 100644 --- a/loko/assets/static/assets/asset_symbology.js +++ b/loko/assets/static/assets/asset_symbology.js @@ -244,6 +244,15 @@ glyphCodepoint: 'f5e1', mapImage: 'icon-naturebench', }, + natureplanter: { + thematic: 'nature', + color: '#d97706', + iconUrl: '/static/assets/nature_planter_icon.svg', + iconSize: 38, + iconClass: 'bi-flower2', + glyphCodepoint: 'f3ce', + mapImage: 'icon-natureplanter', + }, naturerwiasset: { thematic: 'nature', color: '#0277bd', @@ -345,11 +354,21 @@ }, roadfurniture: { thematic: 'roads', - color: '#607d8b', - iconUrl: null, - iconClass: 'bi-map-fill', - glyphCodepoint: 'f47e', - mapImage: 'icon-roads', + color: '#e67700', + iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920', + iconSize: 44, + iconClass: 'bi-triangle-half', + glyphCodepoint: 'f66d', + mapImage: 'icon-speedbump', + }, + streetfurniture: { + thematic: 'roads', + color: '#e67700', + iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920', + iconSize: 44, + iconClass: 'bi-triangle-half', + glyphCodepoint: 'f66d', + mapImage: 'icon-speedbump', }, // Clean @@ -363,11 +382,12 @@ }, cleanglasscontainer: { thematic: 'clean', - color: '#5e5e5e', - iconUrl: null, - iconClass: 'bi-trash3-fill', + color: '#16a34a', + iconUrl: '/static/assets/clean_glass_container_icon.svg?v=20260920', + iconSize: 46, + iconClass: 'bi-recycle', glyphCodepoint: 'f78a', - mapImage: 'icon-clean', + mapImage: 'icon-cleanglasscontainer', }, // Artworks @@ -531,6 +551,14 @@ } // 3. Specific asset_type override + if (assetType === 'naturebench') { + const isPlanter = (item.asset_sub_type === 'planter') || + ['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) || + (typeof item.code === 'string' && item.code.startsWith('CSE-BAC')); + if (isPlanter && ASSET_TYPE_OVERRIDES.natureplanter) { + return Object.assign({}, ASSET_TYPE_OVERRIDES.natureplanter); + } + } if (assetType && ASSET_TYPE_OVERRIDES[assetType]) { return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]); } @@ -602,6 +630,7 @@ roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl, roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl, naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.iconUrl, + natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl), artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl, artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl, }; @@ -622,11 +651,13 @@ { name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' }, { name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' }, { name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' }, + { name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' }, { name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' }, { name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' }, { name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' }, { name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' }, { name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' }, + { name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' }, { name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' }, { name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, { name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, @@ -638,6 +669,7 @@ { name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' }, { name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' }, { name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, + { name: 'icon-speedbump', codepoint: 'f66d', color: '#e67700' }, { name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' }, { name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' }, { name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' }, @@ -695,11 +727,26 @@ ['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment', ['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment', ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet', + ['==', ['get', 'asset_type'], 'cleanglasscontainer'], 'icon-cleanglasscontainer', + ['==', ['get', 'category'], 'Bulle'], 'icon-cleanglasscontainer', + ['==', ['get', 'category'], 'Bulle à verre'], 'icon-cleanglasscontainer', ['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain', ['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument', + ['any', + ['==', ['get', 'model'], 'Bac à fleurs / Jardinière'], + ['==', ['get', 'model'], 'Bloembak'], + ['==', ['get', 'model'], 'Jardinière'], + ['==', ['get', 'model'], 'Bac à fleurs'], + ['==', ['get', 'model'], 'Bac à plantes'], + ['==', ['get', 'asset_sub_type'], 'planter'], + ['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'], + ], 'icon-natureplanter', ['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench', ['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain', + ['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump', + ['==', ['get', 'asset_type'], 'streetfurniture'], 'icon-speedbump', + ['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump', ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset', ['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset', ['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone', @@ -772,12 +819,24 @@ ['coalesce', ['get', 'color'], '#2e7d32'], ['==', ['get', 'thematic'], 'parking'], getParkingColorExpr(), + ['==', ['get', 'thematic'], 'roads'], + [ + 'match', + ['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']], + ['1', '1 - Très mauvais'], '#e03131', + ['2', '2 - Mauvais'], '#f76707', + ['3', '3 - Bon'], '#fcc419', + ['4', '4 - Très bon'], '#74b816', + ['5', '5 - Excellent'], '#2f9e44', + '#4a4a4a' + ], getThematicColorExpr() ], 'fill-opacity': [ 'case', ['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8, ['==', ['get', 'thematic'], 'parking'], 0.38, + ['==', ['get', 'thematic'], 'roads'], 0.28, 0.65 ], 'fill-antialias': false, @@ -811,14 +870,30 @@ ['coalesce', ['get', 'color'], '#1b5e20'], ['==', ['get', 'thematic'], 'parking'], getParkingColorExpr(), + ['==', ['get', 'thematic'], 'roads'], + [ + 'match', + ['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']], + ['1', '1 - Très mauvais'], '#c92a2a', + ['2', '2 - Mauvais'], '#e8590c', + ['3', '3 - Bon'], '#f59f00', + ['4', '4 - Très bon'], '#66a80f', + ['5', '5 - Excellent'], '#2b8a3e', + '#4a4a4a' + ], getThematicColorExpr() ], 'line-width': [ 'case', ['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4, + ['==', ['get', 'thematic'], 'roads'], 1.5, 2 ], - 'line-opacity': 0.95, + 'line-opacity': [ + 'case', + ['==', ['get', 'thematic'], 'roads'], 0.65, + 0.95 + ], }, minzoom: 8, }; diff --git a/loko/assets/static/assets/assets_index.css b/loko/assets/static/assets/assets_index.css index 8c8ffc7..773d47d 100644 --- a/loko/assets/static/assets/assets_index.css +++ b/loko/assets/static/assets/assets_index.css @@ -3,16 +3,16 @@ } .intervention-btn { - color: var(--brand-color); + color: #0f172a; margin-right: 4px; } .intersection-btn-toggle{ - color: var(--brand-color); + color: #0f172a; } .separator-top { - border-top: 1px solid var(--brand-color); + border-top: 1px solid #e2e8f0; margin-top: 2rem; /* optionnel */ padding-top: 2rem; /* optionnel */ } \ No newline at end of file diff --git a/loko/assets/static/assets/clean_glass_container_icon.svg b/loko/assets/static/assets/clean_glass_container_icon.svg new file mode 100644 index 0000000..fb4b9ab --- /dev/null +++ b/loko/assets/static/assets/clean_glass_container_icon.svg @@ -0,0 +1,48 @@ + diff --git a/loko/assets/static/assets/nature_planter_icon.svg b/loko/assets/static/assets/nature_planter_icon.svg new file mode 100644 index 0000000..75dd986 --- /dev/null +++ b/loko/assets/static/assets/nature_planter_icon.svg @@ -0,0 +1,60 @@ + diff --git a/loko/assets/static/assets/road_speedbump_icon.svg b/loko/assets/static/assets/road_speedbump_icon.svg new file mode 100644 index 0000000..111fc6f --- /dev/null +++ b/loko/assets/static/assets/road_speedbump_icon.svg @@ -0,0 +1,38 @@ + diff --git a/loko/assets/static/assets/unified_index.js b/loko/assets/static/assets/unified_index.js index edcf412..4446502 100644 --- a/loko/assets/static/assets/unified_index.js +++ b/loko/assets/static/assets/unified_index.js @@ -510,34 +510,66 @@ function zoomFormatter(value, row) { function buildColumns() { return [ - { field: 'check', checkbox: true, width: 36 }, - { field: 'actions', title: '', formatter: zoomFormatter, width: 50 }, + { field: 'check', checkbox: true, width: 36, widthUnit: 'px', align: 'center', valign: 'middle' }, + { field: 'actions', title: '', formatter: zoomFormatter, width: 44, widthUnit: 'px', align: 'center', valign: 'middle' }, { - field: 'code', title: gettext('Code'), sortable: true, + field: 'code', title: gettext('Code'), sortable: true, width: 135, widthUnit: 'px', valign: 'middle', formatter: function (v, row) { - if (row.detail_url) return `${v || '-'}`; - return v || '-'; + if (row.detail_url) return `${escapeHtml(v || '-')}`; + return `${escapeHtml(v || '-')}`; } }, - { field: 'name', title: gettext('Nom'), sortable: true }, - { field: 'element_type', title: gettext('Type'), sortable: true }, - { field: 'thematic_name', title: gettext('Thématique'), sortable: true }, - { field: 'category', title: gettext('Catégorie'), sortable: true, visible: false }, - { field: 'model', title: gettext('Modèle'), sortable: true, visible: false }, { - field: 'status', title: gettext('Statut'), sortable: true, visible: false, + field: 'name', title: gettext('Nom'), sortable: true, minWidth: 220, valign: 'middle', + formatter: function (v) { + return `${escapeHtml(v || '-')}`; + } + }, + { + field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle', + formatter: function (v, row) { + const table = $('#unified-table'); + let isCatVisible = false; + try { + isCatVisible = table.length && typeof table.bootstrapTable === 'function' && + table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category'); + } catch (e) {} + if (!isCatVisible && row && row.is_location && row.category) { + return `${escapeHtml(v || '-')} ${escapeHtml(row.category)}`; + } + return escapeHtml(v || '-'); + } + }, + { field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' }, + { + field: 'category', title: gettext('Catégorie d\'assets'), sortable: true, width: 175, widthUnit: 'px', visible: false, valign: 'middle', + formatter: function (v, row) { + if (!v) return '-'; + if (row && row.color && (row.thematic === 'publicbuildings' || row.thematic === 'buildings')) { + return `${escapeHtml(v)}`; + } + return `${escapeHtml(v)}`; + } + }, + { field: 'model', title: gettext('Modèle'), sortable: true, width: 140, widthUnit: 'px', visible: false, valign: 'middle' }, + { + field: 'status', title: gettext('Statut'), sortable: true, width: 110, widthUnit: 'px', visible: false, valign: 'middle', formatter: function (v) { return v ? ` ${v}` : ''; } }, { - field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true, + field: 'validation_status', title: gettext('Statut de validation'), sortable: true, width: 135, widthUnit: 'px', visible: true, valign: 'middle', formatter: function (v) { const labels = { - 'to_be_validated': gettext('À valider'), - 'validated': gettext('Validé'), - 'to_be_completed': gettext('À compléter'), - 'rejected': gettext('Rejeté') + 'to_be_validated': { text: gettext('À valider'), cls: 'bg-warning-subtle text-warning-emphasis border-warning-subtle' }, + 'validated': { text: gettext('Validé'), cls: 'bg-success-subtle text-success border-success-subtle' }, + 'to_be_completed': { text: gettext('À compléter'), cls: 'bg-info-subtle text-info-emphasis border-info-subtle' }, + 'rejected': { text: gettext('Rejeté'), cls: 'bg-danger-subtle text-danger border-danger-subtle' } }; - return labels[v] || v || '-'; + const item = labels[v]; + if (item) { + return `${item.text}`; + } + return v || '-'; } }, ]; @@ -597,7 +629,18 @@ function applyVisibleColumnsPreference(visibleColumns) { * explicit thematic selection (GET params / locked_thematic_code). */ function applyInitialPreferences() { - if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE || (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS)) return; + if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE) return; + + if (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS) { + if (!activeCategories || activeCategories.size === 0) { + getVisibleCategoryOptions().forEach(category => { + if (activeThematics.has(category.thematicCode)) { + activeCategories.add(category.value); + } + }); + } + return; + } if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) { const prefs = ASSETS_INDEX_PREFS; @@ -722,7 +765,7 @@ $(document).ready(function () { } function calcHeight() { - return Math.max(300, $(window).height() - 210); + return Math.max(300, $(window).height() - 250); } $('#unified-table').bootstrapTable({ @@ -761,8 +804,13 @@ $(document).ready(function () { document.querySelectorAll('.thematic-checkbox').forEach(cb => { cb.addEventListener('change', function () { - if (this.checked) activeThematics.add(this.value); - else activeThematics.delete(this.value); + if (this.checked) { + activeThematics.add(this.value); + const newCats = THEMATIC_CATEGORIES[this.value] || []; + newCats.forEach(c => activeCategories.add(`${this.value}::${c.name || ''}`)); + } else { + activeThematics.delete(this.value); + } renderCategoryFilters(); renderModelFilters(); renderConditionalFilters(); @@ -774,6 +822,7 @@ $(document).ready(function () { cb.checked = true; activeThematics.add(cb.value); }); + getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value)); renderCategoryFilters(); renderModelFilters(); renderConditionalFilters(); @@ -1271,6 +1320,10 @@ function hasTriggerFilter() { if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true; if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true; if (outdoorFilters.pmr_accessible) return true; + const publicbuildingsFilters = activeConditionalFilters.publicbuildings || activeConditionalFilters.buildings || {}; + if (Array.isArray(publicbuildingsFilters.usage_type) && publicbuildingsFilters.usage_type.length) return true; + if (publicbuildingsFilters.is_main_location) return true; + if (publicbuildingsFilters.is_erp) return true; return false; } @@ -1353,6 +1406,17 @@ function buildLocationsGeojsonPostBody(thematicCode) { if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location; } + if (['publicbuildings', 'buildings'].includes(thematicCode)) { + if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) { + payload.usage_type = thematicFilters.usage_type; + } + if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location; + if (thematicFilters.is_erp) payload.is_erp = thematicFilters.is_erp; + if (Array.isArray(thematicFilters.location_type) && thematicFilters.location_type.length) { + payload.location_type = thematicFilters.location_type; + } + } + if (thematicCode === 'structures') { if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) { payload.structure_type = thematicFilters.structure_type; @@ -1426,6 +1490,17 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) { payload.asset_begin_date = thematicFilters.asset_begin_date; } } + if (['publicbuildings', 'buildings'].includes(thematicCode)) { + if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) { + payload.usage_type = thematicFilters.usage_type; + } + if (thematicFilters.is_main_location) { + payload.is_main_location = thematicFilters.is_main_location; + } + if (thematicFilters.is_erp) { + payload.is_erp = thematicFilters.is_erp; + } + } if (thematicCode === 'structures') { if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) { payload.structure_type = thematicFilters.structure_type; @@ -1898,6 +1973,7 @@ function collectClickedLocations(clickEvent) { thematic: p.thematic || '', thematic_name: f._thematic_name || p.thematic || '', element_type: p.element_type || '', + usage_type_display: p.usage_type_display || '', detail_url: p.detail_url || null, feature_id: id, }); @@ -1987,6 +2063,8 @@ function showAssetClickPopup(clickEvent) { const code = escapeHtml(l.code || '-'); const type = escapeHtml(l.element_type || ''); const thematic = escapeHtml(l.thematic_name || l.thematic || ''); + const usage = l.usage_type_display ? ` • ${escapeHtml(l.usage_type_display)}` : ''; + const dotHtml = l.color ? `` : ''; const name = escapeHtml(l.name || ''); const detailAnchor = l.detail_url ? `${code}` @@ -1996,7 +2074,7 @@ function showAssetClickPopup(clickEvent) {
${detailAnchor}
-
${type}${thematic ? ' - ' + thematic : ''}
+
${dotHtml}${type}${usage}${thematic ? ' - ' + thematic : ''}
${name ? `
${name}
` : ''}