feat: add territory mapping layers, dashboard widgets, and asset import management commands
This commit is contained in:
parent
cc0facdf92
commit
63c888fdbd
58 changed files with 4654 additions and 1107 deletions
278
loko/assets/management/commands/import_cse_parking.py
Normal file
278
loko/assets/management/commands/import_cse_parking.py
Normal file
|
|
@ -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()})."))
|
||||||
293
loko/assets/management/commands/import_cse_signage.py
Normal file
293
loko/assets/management/commands/import_cse_signage.py
Normal file
|
|
@ -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()}"))
|
||||||
|
|
@ -244,6 +244,15 @@
|
||||||
glyphCodepoint: 'f5e1',
|
glyphCodepoint: 'f5e1',
|
||||||
mapImage: 'icon-naturebench',
|
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: {
|
naturerwiasset: {
|
||||||
thematic: 'nature',
|
thematic: 'nature',
|
||||||
color: '#0277bd',
|
color: '#0277bd',
|
||||||
|
|
@ -345,11 +354,21 @@
|
||||||
},
|
},
|
||||||
roadfurniture: {
|
roadfurniture: {
|
||||||
thematic: 'roads',
|
thematic: 'roads',
|
||||||
color: '#607d8b',
|
color: '#e67700',
|
||||||
iconUrl: null,
|
iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
|
||||||
iconClass: 'bi-map-fill',
|
iconSize: 44,
|
||||||
glyphCodepoint: 'f47e',
|
iconClass: 'bi-triangle-half',
|
||||||
mapImage: 'icon-roads',
|
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
|
// Clean
|
||||||
|
|
@ -363,11 +382,12 @@
|
||||||
},
|
},
|
||||||
cleanglasscontainer: {
|
cleanglasscontainer: {
|
||||||
thematic: 'clean',
|
thematic: 'clean',
|
||||||
color: '#5e5e5e',
|
color: '#16a34a',
|
||||||
iconUrl: null,
|
iconUrl: '/static/assets/clean_glass_container_icon.svg?v=20260920',
|
||||||
iconClass: 'bi-trash3-fill',
|
iconSize: 46,
|
||||||
|
iconClass: 'bi-recycle',
|
||||||
glyphCodepoint: 'f78a',
|
glyphCodepoint: 'f78a',
|
||||||
mapImage: 'icon-clean',
|
mapImage: 'icon-cleanglasscontainer',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Artworks
|
// Artworks
|
||||||
|
|
@ -531,6 +551,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Specific asset_type override
|
// 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]) {
|
if (assetType && ASSET_TYPE_OVERRIDES[assetType]) {
|
||||||
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
|
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +630,7 @@
|
||||||
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
|
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
|
||||||
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
|
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
|
||||||
naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.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,
|
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
|
||||||
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
|
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
|
||||||
};
|
};
|
||||||
|
|
@ -622,11 +651,13 @@
|
||||||
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
|
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
|
||||||
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
|
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
|
||||||
{ name: 'icon-naturebench', 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-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
|
||||||
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
|
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
|
||||||
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
|
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
|
||||||
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
|
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
|
||||||
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
|
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
|
||||||
|
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
|
||||||
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
|
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
|
||||||
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' },
|
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' },
|
||||||
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' },
|
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' },
|
||||||
|
|
@ -638,6 +669,7 @@
|
||||||
{ 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' },
|
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' },
|
||||||
|
{ name: 'icon-speedbump', codepoint: 'f66d', color: '#e67700' },
|
||||||
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' },
|
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' },
|
||||||
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' },
|
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' },
|
||||||
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' },
|
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' },
|
||||||
|
|
@ -695,11 +727,26 @@
|
||||||
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
|
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
|
||||||
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
|
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
|
||||||
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet',
|
['==', ['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'], 'artworkfountain'], 'icon-artworkfountain',
|
||||||
['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument',
|
['==', ['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'], 'naturebench'], 'icon-naturebench',
|
||||||
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
|
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
|
||||||
['==', ['get', 'asset_type'], 'roaddrain'], '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'], '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'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
|
||||||
|
|
@ -772,12 +819,24 @@
|
||||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||||
['==', ['get', 'thematic'], 'parking'],
|
['==', ['get', 'thematic'], 'parking'],
|
||||||
getParkingColorExpr(),
|
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()
|
getThematicColorExpr()
|
||||||
],
|
],
|
||||||
'fill-opacity': [
|
'fill-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
|
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
|
||||||
['==', ['get', 'thematic'], 'parking'], 0.38,
|
['==', ['get', 'thematic'], 'parking'], 0.38,
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 0.28,
|
||||||
0.65
|
0.65
|
||||||
],
|
],
|
||||||
'fill-antialias': false,
|
'fill-antialias': false,
|
||||||
|
|
@ -811,14 +870,30 @@
|
||||||
['coalesce', ['get', 'color'], '#1b5e20'],
|
['coalesce', ['get', 'color'], '#1b5e20'],
|
||||||
['==', ['get', 'thematic'], 'parking'],
|
['==', ['get', 'thematic'], 'parking'],
|
||||||
getParkingColorExpr(),
|
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()
|
getThematicColorExpr()
|
||||||
],
|
],
|
||||||
'line-width': [
|
'line-width': [
|
||||||
'case',
|
'case',
|
||||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
|
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 1.5,
|
||||||
2
|
2
|
||||||
],
|
],
|
||||||
'line-opacity': 0.95,
|
'line-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 0.65,
|
||||||
|
0.95
|
||||||
|
],
|
||||||
},
|
},
|
||||||
minzoom: 8,
|
minzoom: 8,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.intervention-btn {
|
.intervention-btn {
|
||||||
color: var(--brand-color);
|
color: #0f172a;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intersection-btn-toggle{
|
.intersection-btn-toggle{
|
||||||
color: var(--brand-color);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator-top {
|
.separator-top {
|
||||||
border-top: 1px solid var(--brand-color);
|
border-top: 1px solid #e2e8f0;
|
||||||
margin-top: 2rem; /* optionnel */
|
margin-top: 2rem; /* optionnel */
|
||||||
padding-top: 2rem; /* optionnel */
|
padding-top: 2rem; /* optionnel */
|
||||||
}
|
}
|
||||||
48
loko/assets/static/assets/clean_glass_container_icon.svg
Normal file
48
loko/assets/static/assets/clean_glass_container_icon.svg
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="glassDome" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#22c55e"/>
|
||||||
|
<stop offset="40%" stop-color="#16a34a"/>
|
||||||
|
<stop offset="100%" stop-color="#15803d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="glassBase" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#14532d"/>
|
||||||
|
<stop offset="100%" stop-color="#052e16"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glassShadow" x="-15%" y="-15%" width="130%" height="135%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Top crane lifting ring (anneau de préhension) -->
|
||||||
|
<circle cx="32" cy="11" r="5.5" fill="none" stroke="#475569" stroke-width="3" filter="url(#glassShadow)"/>
|
||||||
|
<rect x="29.5" y="14" width="5" height="4" fill="#334155"/>
|
||||||
|
|
||||||
|
<!-- Base rim / skirt -->
|
||||||
|
<rect x="8" y="51" width="48" height="6" rx="2.5" fill="url(#glassBase)" filter="url(#glassShadow)"/>
|
||||||
|
|
||||||
|
<!-- Main Igloo / Bell Dome Body -->
|
||||||
|
<path d="M 10 51 C 10 27, 19 16, 32 16 C 45 16, 54 27, 54 51 Z"
|
||||||
|
fill="url(#glassDome)" stroke="#166534" stroke-width="1.5" filter="url(#glassShadow)"/>
|
||||||
|
|
||||||
|
<!-- Dome highlight curve -->
|
||||||
|
<path d="M 14 49 C 14 29, 21 19, 32 19" fill="none" stroke="#86efac" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Left bottle drop aperture (orifice bouteilles) -->
|
||||||
|
<circle cx="21" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
|
||||||
|
<circle cx="21" cy="36" r="4" fill="#020617"/>
|
||||||
|
|
||||||
|
<!-- Right bottle drop aperture (orifice bouteilles) -->
|
||||||
|
<circle cx="43" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
|
||||||
|
<circle cx="43" cy="36" r="4" fill="#020617"/>
|
||||||
|
|
||||||
|
<!-- Center white glass bottle emblem -->
|
||||||
|
<!-- Cap / lip -->
|
||||||
|
<rect x="30" y="27" width="4" height="1.5" rx="0.5" fill="#ffffff"/>
|
||||||
|
<!-- Neck -->
|
||||||
|
<rect x="30.5" y="28.5" width="3" height="5" rx="0.5" fill="#ffffff"/>
|
||||||
|
<!-- Shoulder & Body -->
|
||||||
|
<path d="M 30.5 33.5 C 28.5 35.5, 28 38, 28 40 L 28 46 C 28 47.5, 36 47.5, 36 46 L 36 40 C 36 38, 35.5 35.5, 33.5 33.5 Z" fill="#ffffff"/>
|
||||||
|
<!-- Bottle shine -->
|
||||||
|
<line x1="30" y1="38" x2="30" y2="44" stroke="#86efac" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
60
loko/assets/static/assets/nature_planter_icon.svg
Normal file
60
loko/assets/static/assets/nature_planter_icon.svg
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="planterWood" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ea580c"/>
|
||||||
|
<stop offset="100%" stop-color="#9a3412"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leafGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#4ade80"/>
|
||||||
|
<stop offset="100%" stop-color="#15803d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="flowerPink" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fb7185"/>
|
||||||
|
<stop offset="100%" stop-color="#e11d48"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Foliage / Leaves -->
|
||||||
|
<!-- Left leaf -->
|
||||||
|
<path d="M 32 34 C 24 32, 14 26, 15 17 C 23 17, 28 24, 32 34 Z" fill="url(#leafGrad)"/>
|
||||||
|
<!-- Right leaf -->
|
||||||
|
<path d="M 32 34 C 40 32, 50 26, 49 17 C 41 17, 36 24, 32 34 Z" fill="url(#leafGrad)"/>
|
||||||
|
<!-- Center tall leaves -->
|
||||||
|
<path d="M 32 34 C 28 26, 26 15, 32 8 C 38 15, 36 26, 32 34 Z" fill="#22c55e"/>
|
||||||
|
<!-- Side drooping leaves -->
|
||||||
|
<path d="M 32 35 C 20 35, 10 33, 9 27 C 16 26, 25 29, 32 35 Z" fill="#16a34a"/>
|
||||||
|
<path d="M 32 35 C 44 35, 54 33, 55 27 C 48 26, 39 29, 32 35 Z" fill="#16a34a"/>
|
||||||
|
|
||||||
|
<!-- Flowers -->
|
||||||
|
<!-- Left flower -->
|
||||||
|
<circle cx="21" cy="21" r="5" fill="url(#flowerPink)"/>
|
||||||
|
<circle cx="21" cy="21" r="2" fill="#fef08a"/>
|
||||||
|
|
||||||
|
<!-- Right flower -->
|
||||||
|
<circle cx="43" cy="21" r="5" fill="#f59e0b"/>
|
||||||
|
<circle cx="43" cy="21" r="2" fill="#fffbeb"/>
|
||||||
|
|
||||||
|
<!-- Center top flower -->
|
||||||
|
<circle cx="32" cy="14" r="6" fill="url(#flowerPink)"/>
|
||||||
|
<circle cx="32" cy="14" r="2.5" fill="#fef08a"/>
|
||||||
|
|
||||||
|
<!-- Planter Box (Bac / Jardinière) -->
|
||||||
|
<!-- Rim -->
|
||||||
|
<rect x="11" y="34" width="42" height="5" rx="2" fill="#78350f"/>
|
||||||
|
<rect x="12" y="35" width="40" height="3" rx="1.5" fill="#ea580c"/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<polygon points="14,39 50,39 46,55 18,55" fill="url(#planterWood)"/>
|
||||||
|
|
||||||
|
<!-- Wooden slats / horizontal grooves -->
|
||||||
|
<line x1="15" y1="44" x2="49" y2="44" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
|
||||||
|
<line x1="16.5" y1="49" x2="47.5" y2="49" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Corner metal brackets / reinforcements -->
|
||||||
|
<line x1="14" y1="39" x2="18" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="50" y1="39" x2="46" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Feet / Stand -->
|
||||||
|
<rect x="19" y="55" width="5" height="4" rx="1" fill="#334155"/>
|
||||||
|
<rect x="40" y="55" width="5" height="4" rx="1" fill="#334155"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
38
loko/assets/static/assets/road_speedbump_icon.svg
Normal file
38
loko/assets/static/assets/road_speedbump_icon.svg
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sbBody" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fb923c"/>
|
||||||
|
<stop offset="30%" stop-color="#f97316"/>
|
||||||
|
<stop offset="100%" stop-color="#c2410c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sbPlateau" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fdba74"/>
|
||||||
|
<stop offset="100%" stop-color="#fb923c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="sbShadow" x="-15%" y="-15%" width="130%" height="135%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Drop shadow and dark road border -->
|
||||||
|
<path d="M 5 48 L 15 17 C 15.5 15.5 17 14.5 18.5 14.5 L 45.5 14.5 C 47 14.5 48.5 15.5 49 17 L 59 48 C 59.5 49.8 58.2 51.5 56.5 51.5 L 7.5 51.5 C 5.8 51.5 4.5 49.8 5 48 Z"
|
||||||
|
fill="#7c2d12" filter="url(#sbShadow)"/>
|
||||||
|
|
||||||
|
<!-- Main Speed Cushion Body (Coussin berlinois) -->
|
||||||
|
<path d="M 7 47 L 16 18 C 16.5 16.5 17.5 16 19 16 L 45 16 C 46.5 16 47.5 16.5 48 18 L 57 47 C 57.5 48.5 56.5 49.5 55 49.5 L 9 49.5 C 7.5 49.5 6.5 48.5 7 47 Z"
|
||||||
|
fill="url(#sbBody)"/>
|
||||||
|
|
||||||
|
<!-- Lower bevel lip -->
|
||||||
|
<polygon points="7,47 9.5,50 54.5,50 57,47" fill="#9a3412"/>
|
||||||
|
|
||||||
|
<!-- Upper Plateau -->
|
||||||
|
<polygon points="20,27 22,18 42,18 44,27" fill="url(#sbPlateau)" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- 3 Reflective White Triangles (Universal Speed Cushion Markings) -->
|
||||||
|
<polygon points="12,45 20,24 28,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
|
||||||
|
<polygon points="24,45 32,24 40,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
|
||||||
|
<polygon points="36,45 44,24 52,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Top ridge highlight -->
|
||||||
|
<line x1="19" y1="16" x2="45" y2="16" stroke="#fed7aa" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -510,34 +510,66 @@ function zoomFormatter(value, row) {
|
||||||
|
|
||||||
function buildColumns() {
|
function buildColumns() {
|
||||||
return [
|
return [
|
||||||
{ field: 'check', checkbox: true, width: 36 },
|
{ field: 'check', checkbox: true, width: 36, widthUnit: 'px', align: 'center', valign: 'middle' },
|
||||||
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
|
{ 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) {
|
formatter: function (v, row) {
|
||||||
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
|
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${escapeHtml(v || '-')}</a>`;
|
||||||
return v || '-';
|
return `<span class="unified-code-link">${escapeHtml(v || '-')}</span>`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ 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 `<span class="unified-row-name">${escapeHtml(v || '-')}</span>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
|
||||||
|
}
|
||||||
|
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 `<span class="d-inline-flex align-items-center" style="gap:6px;"><span style="display:inline-block; width:9px; height:9px; border-radius:50%; background-color:${escapeHtml(row.color)}; flex-shrink:0; box-shadow:0 0 0 1px rgba(0,0,0,0.12);"></span><span class="unified-category-cell">${escapeHtml(v)}</span></span>`;
|
||||||
|
}
|
||||||
|
return `<span class="unified-category-cell">${escapeHtml(v)}</span>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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 ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
|
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${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) {
|
formatter: function (v) {
|
||||||
const labels = {
|
const labels = {
|
||||||
'to_be_validated': gettext('À valider'),
|
'to_be_validated': { text: gettext('À valider'), cls: 'bg-warning-subtle text-warning-emphasis border-warning-subtle' },
|
||||||
'validated': gettext('Validé'),
|
'validated': { text: gettext('Validé'), cls: 'bg-success-subtle text-success border-success-subtle' },
|
||||||
'to_be_completed': gettext('À compléter'),
|
'to_be_completed': { text: gettext('À compléter'), cls: 'bg-info-subtle text-info-emphasis border-info-subtle' },
|
||||||
'rejected': gettext('Rejeté')
|
'rejected': { text: gettext('Rejeté'), cls: 'bg-danger-subtle text-danger border-danger-subtle' }
|
||||||
};
|
};
|
||||||
return labels[v] || v || '-';
|
const item = labels[v];
|
||||||
|
if (item) {
|
||||||
|
return `<span class="badge ${item.cls} border fw-medium" style="font-size: 0.74rem;">${item.text}</span>`;
|
||||||
|
}
|
||||||
|
return v || '-';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -597,7 +629,18 @@ function applyVisibleColumnsPreference(visibleColumns) {
|
||||||
* explicit thematic selection (GET params / locked_thematic_code).
|
* explicit thematic selection (GET params / locked_thematic_code).
|
||||||
*/
|
*/
|
||||||
function applyInitialPreferences() {
|
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) {
|
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
|
||||||
const prefs = ASSETS_INDEX_PREFS;
|
const prefs = ASSETS_INDEX_PREFS;
|
||||||
|
|
@ -722,7 +765,7 @@ $(document).ready(function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcHeight() {
|
function calcHeight() {
|
||||||
return Math.max(300, $(window).height() - 210);
|
return Math.max(300, $(window).height() - 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#unified-table').bootstrapTable({
|
$('#unified-table').bootstrapTable({
|
||||||
|
|
@ -761,8 +804,13 @@ $(document).ready(function () {
|
||||||
|
|
||||||
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
||||||
cb.addEventListener('change', function () {
|
cb.addEventListener('change', function () {
|
||||||
if (this.checked) activeThematics.add(this.value);
|
if (this.checked) {
|
||||||
else activeThematics.delete(this.value);
|
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();
|
renderCategoryFilters();
|
||||||
renderModelFilters();
|
renderModelFilters();
|
||||||
renderConditionalFilters();
|
renderConditionalFilters();
|
||||||
|
|
@ -774,6 +822,7 @@ $(document).ready(function () {
|
||||||
cb.checked = true;
|
cb.checked = true;
|
||||||
activeThematics.add(cb.value);
|
activeThematics.add(cb.value);
|
||||||
});
|
});
|
||||||
|
getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value));
|
||||||
renderCategoryFilters();
|
renderCategoryFilters();
|
||||||
renderModelFilters();
|
renderModelFilters();
|
||||||
renderConditionalFilters();
|
renderConditionalFilters();
|
||||||
|
|
@ -1271,6 +1320,10 @@ function hasTriggerFilter() {
|
||||||
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
|
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
|
||||||
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
|
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
|
||||||
if (outdoorFilters.pmr_accessible) 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1353,6 +1406,17 @@ function buildLocationsGeojsonPostBody(thematicCode) {
|
||||||
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
|
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 (thematicCode === 'structures') {
|
||||||
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
||||||
payload.structure_type = thematicFilters.structure_type;
|
payload.structure_type = thematicFilters.structure_type;
|
||||||
|
|
@ -1426,6 +1490,17 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
|
||||||
payload.asset_begin_date = thematicFilters.asset_begin_date;
|
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 (thematicCode === 'structures') {
|
||||||
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
||||||
payload.structure_type = thematicFilters.structure_type;
|
payload.structure_type = thematicFilters.structure_type;
|
||||||
|
|
@ -1898,6 +1973,7 @@ function collectClickedLocations(clickEvent) {
|
||||||
thematic: p.thematic || '',
|
thematic: p.thematic || '',
|
||||||
thematic_name: f._thematic_name || p.thematic || '',
|
thematic_name: f._thematic_name || p.thematic || '',
|
||||||
element_type: p.element_type || '',
|
element_type: p.element_type || '',
|
||||||
|
usage_type_display: p.usage_type_display || '',
|
||||||
detail_url: p.detail_url || null,
|
detail_url: p.detail_url || null,
|
||||||
feature_id: id,
|
feature_id: id,
|
||||||
});
|
});
|
||||||
|
|
@ -1987,6 +2063,8 @@ function showAssetClickPopup(clickEvent) {
|
||||||
const code = escapeHtml(l.code || '-');
|
const code = escapeHtml(l.code || '-');
|
||||||
const type = escapeHtml(l.element_type || '');
|
const type = escapeHtml(l.element_type || '');
|
||||||
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
|
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
|
||||||
|
const usage = l.usage_type_display ? ` • ${escapeHtml(l.usage_type_display)}` : '';
|
||||||
|
const dotHtml = l.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(l.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
|
||||||
const name = escapeHtml(l.name || '');
|
const name = escapeHtml(l.name || '');
|
||||||
const detailAnchor = l.detail_url
|
const detailAnchor = l.detail_url
|
||||||
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
||||||
|
|
@ -1996,7 +2074,7 @@ function showAssetClickPopup(clickEvent) {
|
||||||
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
|
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
|
||||||
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
|
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${usage}${thematic ? ' - ' + thematic : ''}</div>
|
||||||
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|
@ -2146,7 +2224,39 @@ function fitMapToFeatures(features) {
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let currentTerritoryMuniSignature = '';
|
||||||
|
|
||||||
|
function updateTerritoryMaskIfNeeded() {
|
||||||
|
if (!map || typeof map.getSource !== 'function') return;
|
||||||
|
const src = map.getSource('territory');
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
const selectedMunis = (typeof thematicFilters !== 'undefined' && Array.isArray(thematicFilters.municipality))
|
||||||
|
? thematicFilters.municipality
|
||||||
|
: [];
|
||||||
|
const sig = selectedMunis.slice().sort().join(',');
|
||||||
|
if (sig === currentTerritoryMuniSignature) return;
|
||||||
|
currentTerritoryMuniSignature = sig;
|
||||||
|
|
||||||
|
let url = window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/';
|
||||||
|
if (selectedMunis.length > 0) {
|
||||||
|
const params = selectedMunis.map(id => `municipality=${encodeURIComponent(id)}`).join('&');
|
||||||
|
url += (url.includes('?') ? '&' : '?') + params;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const currentSrc = map.getSource('territory');
|
||||||
|
if (currentSrc && data) currentSrc.setData(data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('Could not update territory mask:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
|
updateTerritoryMaskIfNeeded();
|
||||||
// Cancel any in-flight requests from the previous call
|
// Cancel any in-flight requests from the previous call
|
||||||
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
|
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
|
||||||
currentFetchAbortController = new AbortController();
|
currentFetchAbortController = new AbortController();
|
||||||
|
|
@ -2386,6 +2496,7 @@ function loadTableData(features) {
|
||||||
const name = p.name || p.name_fr || p.name_nl || '';
|
const name = p.name || p.name_fr || p.name_nl || '';
|
||||||
const length = Number(p.length ?? p.length_m ?? 0);
|
const length = Number(p.length ?? p.length_m ?? 0);
|
||||||
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
|
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
|
||||||
|
const usageType = p.usage_type_display || '';
|
||||||
return {
|
return {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
state: selectedIds.has(f.id),
|
state: selectedIds.has(f.id),
|
||||||
|
|
@ -2396,7 +2507,10 @@ function loadTableData(features) {
|
||||||
thematic: p.thematic || '',
|
thematic: p.thematic || '',
|
||||||
thematic_name: f._thematic_name || p.thematic || '',
|
thematic_name: f._thematic_name || p.thematic || '',
|
||||||
category_id: p.category_id != null ? String(p.category_id) : '',
|
category_id: p.category_id != null ? String(p.category_id) : '',
|
||||||
category: p.category || '',
|
category: p.category || usageType || '',
|
||||||
|
usage_type: p.usage_type || '',
|
||||||
|
usage_type_display: usageType,
|
||||||
|
color: p.color || p.display_color || '',
|
||||||
model: p.model || '',
|
model: p.model || '',
|
||||||
status: p.status || '',
|
status: p.status || '',
|
||||||
detail_url: p.detail_url || null,
|
detail_url: p.detail_url || null,
|
||||||
|
|
@ -2523,6 +2637,10 @@ maploader.loadMap('map');
|
||||||
|
|
||||||
maploader.addEventListener('mapLoaded', () => {
|
maploader.addEventListener('mapLoaded', () => {
|
||||||
// Create empty sources so setData can be called immediately after data loads
|
// Create empty sources so setData can be called immediately after data loads
|
||||||
|
map.addSource('territory', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
|
||||||
|
});
|
||||||
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||||
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||||
|
|
||||||
|
|
@ -2576,40 +2694,13 @@ maploader.addEventListener('mapLoaded', () => {
|
||||||
if (typeof LayerSwitcherControl !== 'undefined') {
|
if (typeof LayerSwitcherControl !== 'undefined') {
|
||||||
const layerSwitcher = new LayerSwitcherControl({
|
const layerSwitcher = new LayerSwitcherControl({
|
||||||
layers: [{
|
layers: [{
|
||||||
id: 'ortho',
|
id: 'basemap',
|
||||||
name: 'Orthophotos',
|
name: 'Fonds de plan & Orthophotos',
|
||||||
icon: 'satellite',
|
icon: 'satellite',
|
||||||
group: 'ortho',
|
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
|
||||||
beforeLayer: 'locations',
|
basemaps: window.MAP_BACKGROUNDS || [],
|
||||||
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
|
|
||||||
sublayers: [
|
|
||||||
{
|
|
||||||
id: 'urbis',
|
|
||||||
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
|
|
||||||
default: true,
|
|
||||||
source: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'vlaanderen',
|
|
||||||
name: 'Vlaanderen 2023',
|
|
||||||
source: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}],
|
}],
|
||||||
});
|
});// Reset view — fly back to initial Brussels Region view
|
||||||
// Reset view — fly back to initial Brussels Region view
|
|
||||||
map.addControl({
|
map.addControl({
|
||||||
onAdd(m) {
|
onAdd(m) {
|
||||||
this._map = m;
|
this._map = m;
|
||||||
|
|
@ -3620,7 +3711,7 @@ function renderConditionalFilters() {
|
||||||
const isChecked = selected === checkedValue;
|
const isChecked = selected === checkedValue;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<div class="filter-option">
|
<div class="filter-option">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
|
@ -3642,8 +3733,8 @@ function renderConditionalFilters() {
|
||||||
if (def.type === 'date') {
|
if (def.type === 'date') {
|
||||||
const inputId = `cond-${thematicCode}-${def.key}`;
|
const inputId = `cond-${thematicCode}-${def.key}`;
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<input type="date"
|
<input type="date"
|
||||||
class="form-control form-control-sm conditional-filter-input"
|
class="form-control form-control-sm conditional-filter-input"
|
||||||
|
|
@ -3669,8 +3760,8 @@ function renderConditionalFilters() {
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<select class="form-select form-select-sm conditional-filter-input"
|
<select class="form-select form-select-sm conditional-filter-input"
|
||||||
name="${escapeHtml(filterName)}"
|
name="${escapeHtml(filterName)}"
|
||||||
|
|
@ -3693,6 +3784,22 @@ function renderConditionalFilters() {
|
||||||
? (selected === String(option.value))
|
? (selected === String(option.value))
|
||||||
: selected.includes(String(option.value));
|
: selected.includes(String(option.value));
|
||||||
|
|
||||||
|
let iconClass = option.icon || '';
|
||||||
|
if (!iconClass && def.key === 'usage_type') {
|
||||||
|
const fallbackIcons = {
|
||||||
|
administrative: 'bi-building',
|
||||||
|
education: 'bi-mortarboard',
|
||||||
|
sports: 'bi-trophy',
|
||||||
|
culture: 'bi-palette',
|
||||||
|
social: 'bi-people',
|
||||||
|
technical: 'bi-tools',
|
||||||
|
housing: 'bi-house-door',
|
||||||
|
other: 'bi-building-gear'
|
||||||
|
};
|
||||||
|
iconClass = fallbackIcons[option.value] || 'bi-building';
|
||||||
|
}
|
||||||
|
const iconHtml = iconClass ? `<i class="bi ${escapeHtml(iconClass)} me-1"></i>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="filter-option">
|
<div class="filter-option">
|
||||||
<input type="${inputType}"
|
<input type="${inputType}"
|
||||||
|
|
@ -3704,23 +3811,30 @@ function renderConditionalFilters() {
|
||||||
data-filter-type="${escapeHtml(def.type || 'multi')}"
|
data-filter-type="${escapeHtml(def.type || 'multi')}"
|
||||||
value="${escapeHtml(String(option.value))}"
|
value="${escapeHtml(String(option.value))}"
|
||||||
${isChecked ? 'checked' : ''}>
|
${isChecked ? 'checked' : ''}>
|
||||||
<label for="${optionId}">${escapeHtml(option.label)}</label>
|
<label for="${optionId}" title="${escapeHtml(option.label)}">${iconHtml}<span class="filter-option-text">${escapeHtml(option.label)}</span></label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
const isUniformGrid = def.key === 'usage_type' || (def.options && def.options.length >= 4 && def.options.some(o => o.label && o.label.length > 15));
|
||||||
|
const optionsContainerClass = isUniformGrid ? 'filter-options filter-options-grid' : 'filter-options';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
|
||||||
<div class="filter-options">${optionsHtml}</div>
|
<div class="${optionsContainerClass}">${optionsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-thematic-group">
|
||||||
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
|
<div class="conditional-thematic-header">
|
||||||
${blocks}
|
${icon}<span>${escapeHtml(heading)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conditional-thematic-body">
|
||||||
|
${blocks}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,20 @@ function loadUnifiedMapIcons(map) {
|
||||||
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
|
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// speedbump: dedicated speed cushion / ralentisseur SVG (44 px)
|
||||||
|
if (iconUrls.speedbump && !map.hasImage('icon-speedbump')) {
|
||||||
|
loadNormalisedIcon(iconUrls.speedbump, 44)
|
||||||
|
.then(data => { if (!map.hasImage('icon-speedbump')) map.addImage('icon-speedbump', data); })
|
||||||
|
.catch(e => console.warn('unified_map_layers: could not load speedbump icon', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanglasscontainer: dedicated bulle à verre SVG (46 px)
|
||||||
|
if (iconUrls.cleanglasscontainer && !map.hasImage('icon-cleanglasscontainer')) {
|
||||||
|
loadNormalisedIcon(iconUrls.cleanglasscontainer, 46)
|
||||||
|
.then(data => { if (!map.hasImage('icon-cleanglasscontainer')) map.addImage('icon-cleanglasscontainer', data); })
|
||||||
|
.catch(e => console.warn('unified_map_layers: could not load cleanglasscontainer icon', e));
|
||||||
|
}
|
||||||
|
|
||||||
// roadconcreteblockset: dedicated concrete block SVG (42 px)
|
// roadconcreteblockset: dedicated concrete block SVG (42 px)
|
||||||
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
|
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
|
||||||
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
|
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
|
||||||
|
|
@ -169,6 +183,13 @@ function loadUnifiedMapIcons(map) {
|
||||||
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
|
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// natureplanter: dedicated planter / flower box SVG (38 px)
|
||||||
|
if (iconUrls.natureplanter && !map.hasImage('icon-natureplanter')) {
|
||||||
|
loadNormalisedIcon(iconUrls.natureplanter, 38)
|
||||||
|
.then(data => { if (!map.hasImage('icon-natureplanter')) map.addImage('icon-natureplanter', data); })
|
||||||
|
.catch(e => console.warn('unified_map_layers: could not load natureplanter icon', e));
|
||||||
|
}
|
||||||
|
|
||||||
// artworks: dedicated fountain/statue SVGs
|
// artworks: dedicated fountain/statue SVGs
|
||||||
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
|
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
|
||||||
loadNormalisedIcon(iconUrls.artworkfountain, 48)
|
loadNormalisedIcon(iconUrls.artworkfountain, 48)
|
||||||
|
|
@ -297,6 +318,38 @@ const THEMATIC_COLOR_EXPR = (typeof AssetSymbology !== 'undefined')
|
||||||
'#0022ff'
|
'#0022ff'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Layer 0 — territory mask (inverted polygon: subtle transparent gray outside the commune)
|
||||||
|
const _unified_territory_mask = {
|
||||||
|
id: 'territory-mask',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'territory',
|
||||||
|
filter: ['==', ['get', 'layer'], 'mask'],
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#0f172a',
|
||||||
|
'fill-opacity': 0.14,
|
||||||
|
'fill-antialias': false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Layer 0b — territory boundary (delicate dashed outline around the commune)
|
||||||
|
const _unified_territory_outline = {
|
||||||
|
id: 'territory-outline',
|
||||||
|
type: 'line',
|
||||||
|
source: 'territory',
|
||||||
|
filter: ['==', ['get', 'layer'], 'boundary'],
|
||||||
|
paint: {
|
||||||
|
'line-color': '#475569',
|
||||||
|
'line-width': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
8, 1.2,
|
||||||
|
13, 1.8,
|
||||||
|
18, 2.5
|
||||||
|
],
|
||||||
|
'line-opacity': 0.7,
|
||||||
|
'line-dasharray': [4, 2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
|
// ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
|
||||||
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
|
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
|
||||||
const _unified_locations_fill = {
|
const _unified_locations_fill = {
|
||||||
|
|
@ -304,8 +357,29 @@ const _unified_locations_fill = {
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: 'locations',
|
source: 'locations',
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#0094ff',
|
'fill-color': [
|
||||||
'fill-opacity': 0, // contour only — outline handled by locations-outline
|
'case',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['coalesce', ['get', 'usage_type'], ''],
|
||||||
|
'administrative', '#2563EB',
|
||||||
|
'education', '#D97706',
|
||||||
|
'sports', '#059669',
|
||||||
|
'culture', '#7C3AED',
|
||||||
|
'social', '#E11D48',
|
||||||
|
'technical', '#64748B',
|
||||||
|
'housing', '#0D9488',
|
||||||
|
'other', '#475569',
|
||||||
|
['coalesce', ['get', 'color'], '#2563EB']
|
||||||
|
],
|
||||||
|
'#0094ff'
|
||||||
|
],
|
||||||
|
'fill-opacity': [
|
||||||
|
'case',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 0.45,
|
||||||
|
0 // contour only for standard street locations
|
||||||
|
],
|
||||||
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -328,11 +402,23 @@ const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymb
|
||||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||||
['==', ['get', 'thematic'], 'parking'],
|
['==', ['get', 'thematic'], 'parking'],
|
||||||
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
|
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
|
||||||
|
['==', ['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'
|
||||||
|
],
|
||||||
THEMATIC_COLOR_EXPR
|
THEMATIC_COLOR_EXPR
|
||||||
],
|
],
|
||||||
'fill-opacity': [
|
'fill-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'thematic'], 'parking'], 0.35,
|
['==', ['get', 'thematic'], 'parking'], 0.35,
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 0.28,
|
||||||
0.7
|
0.7
|
||||||
],
|
],
|
||||||
'fill-antialias': false,
|
'fill-antialias': false,
|
||||||
|
|
@ -354,14 +440,29 @@ const _unified_locations_outline = {
|
||||||
'line-color': [
|
'line-color': [
|
||||||
'case',
|
'case',
|
||||||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['coalesce', ['get', 'usage_type'], ''],
|
||||||
|
'administrative', '#1D4ED8',
|
||||||
|
'education', '#B45309',
|
||||||
|
'sports', '#047857',
|
||||||
|
'culture', '#6D28D9',
|
||||||
|
'social', '#BE123C',
|
||||||
|
'technical', '#334155',
|
||||||
|
'housing', '#0F766E',
|
||||||
|
'other', '#1E293B',
|
||||||
|
'#1D4ED8'
|
||||||
|
],
|
||||||
'#0094ff'
|
'#0094ff'
|
||||||
],
|
],
|
||||||
'line-width': [
|
'line-width': [
|
||||||
'case',
|
'case',
|
||||||
['boolean', ['feature-state', 'highlighted'], false], 3,
|
['boolean', ['feature-state', 'highlighted'], false], 3,
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 2,
|
||||||
1
|
1
|
||||||
],
|
],
|
||||||
'line-opacity': 0.8,
|
'line-opacity': 0.9,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -382,16 +483,33 @@ const _unified_locations_circle = {
|
||||||
14, 5,
|
14, 5,
|
||||||
19, 10
|
19, 10
|
||||||
],
|
],
|
||||||
// Colour by intersection status (matches trafficlights_index.html)
|
// Colour by building usage_type or intersection status
|
||||||
'circle-color': [
|
'circle-color': [
|
||||||
'match', ['get', 'status'],
|
'case',
|
||||||
'active', '#0094ff',
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
|
||||||
'in_preparation', '#f59f00',
|
[
|
||||||
'under_repair', '#f76707',
|
'match',
|
||||||
'voluntarily_out_of_service', '#e03131',
|
['coalesce', ['get', 'usage_type'], ''],
|
||||||
'inactive', '#e03131',
|
'administrative', '#2563EB',
|
||||||
'archived', '#6c757d',
|
'education', '#D97706',
|
||||||
'#0094ff'
|
'sports', '#059669',
|
||||||
|
'culture', '#7C3AED',
|
||||||
|
'social', '#E11D48',
|
||||||
|
'technical', '#64748B',
|
||||||
|
'housing', '#0D9488',
|
||||||
|
'other', '#475569',
|
||||||
|
['coalesce', ['get', 'color'], '#2563EB']
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'match', ['get', 'status'],
|
||||||
|
'active', '#0094ff',
|
||||||
|
'in_preparation', '#f59f00',
|
||||||
|
'under_repair', '#f76707',
|
||||||
|
'voluntarily_out_of_service', '#e03131',
|
||||||
|
'inactive', '#e03131',
|
||||||
|
'archived', '#6c757d',
|
||||||
|
'#0094ff'
|
||||||
|
]
|
||||||
],
|
],
|
||||||
'circle-stroke-color': [
|
'circle-stroke-color': [
|
||||||
'case',
|
'case',
|
||||||
|
|
@ -479,10 +597,251 @@ const _unified_assets_symbol = {
|
||||||
'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads',
|
'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads',
|
||||||
'icon-size': [
|
'icon-size': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
8, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.12, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.18, 0.4],
|
8, [
|
||||||
12, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.22, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.34, 0.6],
|
'case',
|
||||||
16, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.45, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.62, 0.8],
|
['any',
|
||||||
22, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.95, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05, 1.2]
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.05,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.08,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.09,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.12,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.07,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.08,
|
||||||
|
0.28
|
||||||
|
],
|
||||||
|
11, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.09,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.14,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.16,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.20,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.12,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.14,
|
||||||
|
0.40
|
||||||
|
],
|
||||||
|
13, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.15,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.22,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.25,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.30,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.19,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.22,
|
||||||
|
0.52
|
||||||
|
],
|
||||||
|
15, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.24,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.35,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.40,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.45,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.34,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.38,
|
||||||
|
0.66
|
||||||
|
],
|
||||||
|
17, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.45,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.55,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.62,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.65,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.55,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.62,
|
||||||
|
0.82
|
||||||
|
],
|
||||||
|
19, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.72,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.80,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.85,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.85,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.80,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.85,
|
||||||
|
0.98
|
||||||
|
],
|
||||||
|
22, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.95,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 1.05,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 1.10,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 1.05,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 1.10,
|
||||||
|
1.15
|
||||||
|
]
|
||||||
],
|
],
|
||||||
'icon-anchor': [
|
'icon-anchor': [
|
||||||
'case',
|
'case',
|
||||||
|
|
@ -783,6 +1142,8 @@ const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' &
|
||||||
// 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.
|
||||||
const UNIFIED_LAYERS = [
|
const UNIFIED_LAYERS = [
|
||||||
|
_unified_territory_mask, // 0. territory mask (outside commune)
|
||||||
|
_unified_territory_outline, // 0b. territory boundary line
|
||||||
_unified_locations_fill, // 1. polygon fills (bottom)
|
_unified_locations_fill, // 1. polygon fills (bottom)
|
||||||
_unified_assets_fill, // 2. polygon fills
|
_unified_assets_fill, // 2. polygon fills
|
||||||
_unified_locations_outline, // 3. lines
|
_unified_locations_outline, // 3. lines
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,11 @@
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': [
|
'circle-radius': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
8, 3,
|
8, 1.2,
|
||||||
|
11, 2.0,
|
||||||
|
13, 3.0,
|
||||||
|
15, 4.5,
|
||||||
|
17, 7.0,
|
||||||
22, 10
|
22, 10
|
||||||
],
|
],
|
||||||
// display_color is a pre-computed canonical colour name (Python-side).
|
// display_color is a pre-computed canonical colour name (Python-side).
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% block asset_header_title %}
|
{% block asset_header_title %}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% translate "Asset" %} : {{ asset.code }}
|
{% translate "Asset" %} : {{ asset.code }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
|
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
|
||||||
|
|
@ -278,7 +278,7 @@
|
||||||
{% if mobile_view or not allowed_update_fields %}
|
{% if mobile_view or not allowed_update_fields %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{% block asset_header_title_readonly %}
|
{% block asset_header_title_readonly %}
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% if thematic.icon %}
|
{% if thematic.icon %}
|
||||||
<i class="bi {{ thematic.icon }} me-2"></i>
|
<i class="bi {{ thematic.icon }} me-2"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -546,7 +546,7 @@
|
||||||
</td>
|
</td>
|
||||||
</tr> -->
|
</tr> -->
|
||||||
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
|
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
|
||||||
<th colspan="2" class="text-muted" style="color: var(--brand-color) !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
<th colspan="2" class="text-muted" style="color: #475569 !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
{% translate "Attributs spécifiques" %}
|
{% translate "Attributs spécifiques" %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background-color: rgba(47, 62, 139, 0.1);
|
background-color: rgba(47, 62, 139, 0.1);
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #0f172a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.mobile-tab-btn.active {
|
.mobile-tab-btn.active {
|
||||||
background-color: var(--brand-color, #2f3e8b);
|
background-color: #0f172a;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
|
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1 text-truncate">
|
<div class="flex-grow-1 text-truncate">
|
||||||
<div class="d-flex align-items-center gap-1.5 flex-wrap">
|
<div class="d-flex align-items-center gap-1.5 flex-wrap">
|
||||||
<span class="fw-bold text-dark fs-6" style="color: var(--brand-color);">{{ asset.code }}</span>
|
<span class="fw-bold text-dark fs-6" style="color: #0f172a;">{{ asset.code }}</span>
|
||||||
{% if asset.status == 'active' %}
|
{% if asset.status == 'active' %}
|
||||||
<span class="badge bg-success">{{ asset.get_status_display }}</span>
|
<span class="badge bg-success">{{ asset.get_status_display }}</span>
|
||||||
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %}
|
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% translate "Arbre" %} : {{ asset.code }}
|
{% translate "Arbre" %} : {{ asset.code }}
|
||||||
</h2>
|
</h2>
|
||||||
{% if asset.name_fr %}
|
{% if asset.name_fr %}
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% translate "Arbre" %} : {{ asset.code }}
|
{% translate "Arbre" %} : {{ asset.code }}
|
||||||
</h2>
|
</h2>
|
||||||
{% if asset.name_fr %}
|
{% if asset.name_fr %}
|
||||||
|
|
@ -187,7 +187,7 @@
|
||||||
{# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #}
|
{# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %}
|
<i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %}
|
||||||
</h5>
|
</h5>
|
||||||
{% if asset.tag_number %}
|
{% if asset.tag_number %}
|
||||||
|
|
@ -384,7 +384,7 @@
|
||||||
{# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #}
|
{# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %}
|
<i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -521,7 +521,7 @@
|
||||||
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
|
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %}
|
<i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %}
|
||||||
</h5>
|
</h5>
|
||||||
{% if asset.is_remarkable %}
|
{% if asset.is_remarkable %}
|
||||||
|
|
@ -698,7 +698,7 @@
|
||||||
{# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #}
|
{# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %}
|
<i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -111,17 +111,35 @@
|
||||||
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
|
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': [
|
'fill-color': [
|
||||||
'match',
|
'case',
|
||||||
['get', 'asset_type'],
|
['==', ['get', 'asset_type'], 'roadway'],
|
||||||
'roadway', '#4a4a4a',
|
[
|
||||||
'sidewalk', '#00bfa5',
|
'match',
|
||||||
'#9e9e9e'
|
['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'
|
||||||
|
],
|
||||||
|
['match', ['get', 'asset_type'], 'sidewalk', '#00bfa5', '#9e9e9e']
|
||||||
],
|
],
|
||||||
'fill-opacity': 0.7,
|
'fill-opacity': 0.28,
|
||||||
'fill-outline-color': [
|
'fill-outline-color': [
|
||||||
'case',
|
'case',
|
||||||
['boolean', ['feature-state', 'highlighted'], false],
|
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||||
'#ffeb3b',
|
['==', ['get', 'asset_type'], 'roadway'],
|
||||||
|
[
|
||||||
|
'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',
|
||||||
|
'#00000000'
|
||||||
|
],
|
||||||
'#00000000'
|
'#00000000'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -175,8 +193,12 @@
|
||||||
'icon-image': 'drain-icon',
|
'icon-image': 'drain-icon',
|
||||||
'icon-size': [
|
'icon-size': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
8, 0.1,
|
8, 0.05,
|
||||||
22, 1
|
11, 0.09,
|
||||||
|
13, 0.15,
|
||||||
|
15, 0.24,
|
||||||
|
17, 0.45,
|
||||||
|
22, 0.95
|
||||||
],
|
],
|
||||||
'icon-allow-overlap': true,
|
'icon-allow-overlap': true,
|
||||||
'icon-ignore-placement': true,
|
'icon-ignore-placement': true,
|
||||||
|
|
|
||||||
|
|
@ -9,44 +9,401 @@
|
||||||
<style>
|
<style>
|
||||||
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
||||||
#unified-empty-state { display: none; color: #6c757d; }
|
#unified-empty-state { display: none; color: #6c757d; }
|
||||||
/* ─── Filter modal ──────────────────────────────────── */
|
/* ─── Modern Airbnb-style Filter Modal ──────────────────── */
|
||||||
#filterModal .modal-dialog { max-width: 95%; }
|
#filterModal .modal-dialog {
|
||||||
#filterModal .modal-content { display: flex; flex-direction: column; max-height: 90vh; }
|
max-width: 820px;
|
||||||
#filterModal .modal-body { flex: 1; overflow-y: auto; }
|
margin: 1.5rem auto;
|
||||||
#filterModal .modal-footer { flex-shrink: 0; }
|
}
|
||||||
.filter-section { margin-bottom: 1rem; }
|
#filterModal .modal-content {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
#filterModal .modal-header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
#filterModal .modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
#filterModal .btn-close {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
#filterModal .btn-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#filterModal .modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#filterModal .modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Thematic Conditional Filter Cards */
|
||||||
|
#conditional-filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.conditional-thematic-group {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.95rem 1.05rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.conditional-thematic-group:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.conditional-thematic-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding-bottom: 0.55rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.conditional-thematic-header i {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.conditional-filter-block {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.conditional-filter-block:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.conditional-filter-label {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uniform Responsive Grid for Filter Options (e.g. usage_type) */
|
||||||
|
.filter-options.filter-options-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)) !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option label {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option label .filter-option-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option label i {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Filter Section Cards */
|
||||||
|
.filter-section {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.15rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-section:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
.filter-section-header {
|
.filter-section-header {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex;
|
||||||
margin-bottom: 0.75rem; gap: 1rem;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.filter-section-header > label { font-weight: 600; color: #333; margin: 0; flex-shrink: 0; }
|
.filter-section-header > label,
|
||||||
.filter-section-buttons { display: flex; gap: 0.5rem; margin-left: auto; flex-shrink: 0; }
|
.filter-section-header > .section-title {
|
||||||
.filter-section-buttons button { padding: 0.25rem 0.5rem; font-size: 0.75rem; white-space: nowrap; }
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.filter-section-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.filter-section-buttons button {
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #64748b;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-section-buttons button:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thematic Grid (Airbnb-style visual cards) */
|
||||||
|
.thematic-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.thematic-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.thematic-tile {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.thematic-tile label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 0.85rem 0.5rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.thematic-tile .thematic-icon {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
transition: transform 0.18s ease, color 0.18s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.thematic-tile .thematic-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
.thematic-tile:hover label {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
.thematic-tile:hover .thematic-icon {
|
||||||
|
color: #0f172a;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"]:checked + label {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
border-color: #0f172a !important;
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.22);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"]:checked + label .thematic-icon {
|
||||||
|
color: #ffffff !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"]:checked + label .thematic-label {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips & Tags (Categories, Models, Conditionals, Status) */
|
||||||
.filter-options {
|
.filter-options {
|
||||||
display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 2.5rem;
|
display: flex;
|
||||||
padding: 0.5rem; background-color: #f8f9fa;
|
flex-wrap: wrap;
|
||||||
border-radius: 0.375rem; border: 1px solid #dee2e6;
|
gap: 0.45rem;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
.filter-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.filter-option { display: flex; align-items: center; }
|
|
||||||
.filter-option input[type="checkbox"],
|
.filter-option input[type="checkbox"],
|
||||||
.filter-option input[type="radio"] {
|
.filter-option input[type="radio"] {
|
||||||
position: absolute; opacity: 0; width: 0; height: 0; cursor: pointer;
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.filter-option label {
|
.filter-option label {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex;
|
||||||
margin: 0; font-weight: 500; cursor: pointer;
|
align-items: center;
|
||||||
background-color: #e7f1ff; color: #0c5cc9;
|
gap: 0.35rem;
|
||||||
border-radius: 1rem; padding: 0.35rem 0.75rem; font-size: 0.8rem;
|
padding: 0.4rem 0.85rem;
|
||||||
border: 1px solid #b8d4f1; transition: all 0.15s ease;
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #334155;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.filter-option label:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
.filter-option input[type="checkbox"]:checked + label,
|
.filter-option input[type="checkbox"]:checked + label,
|
||||||
.filter-option input[type="radio"]:checked + label {
|
.filter-option input[type="radio"]:checked + label {
|
||||||
background-color: #0c5cc9; color: #fff; border-color: #0c5cc9;
|
background-color: #0f172a !important;
|
||||||
|
border-color: #0f172a !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
.filter-option input:disabled + label {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented Control (Element Type) */
|
||||||
|
.segmented-control {
|
||||||
|
display: inline-flex;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
gap: 3px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.segmented-option label {
|
||||||
|
border-radius: 7px !important;
|
||||||
|
border: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
padding: 0.45rem 1rem !important;
|
||||||
|
font-size: 0.84rem !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.segmented-option label:hover {
|
||||||
|
color: #0f172a !important;
|
||||||
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
.segmented-option input[type="radio"]:checked + label {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state helper inside filter options */
|
||||||
|
#category-filter-options > span.text-muted,
|
||||||
|
#model-filter-options > span.text-muted,
|
||||||
|
#conditional-filter-options > span.text-muted {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #64748b !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter trigger badge */
|
||||||
|
#filter-badge {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
top: 2px !important;
|
||||||
|
right: -6px !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 4px !important;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
#filter-badge:not(.d-none) {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.filter-option label:hover { background-color: #cfe2f3; }
|
|
||||||
.filter-option input:checked + label:hover { background-color: #0a4fb0; }
|
|
||||||
.filter-option input:disabled + label { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
#map .maplibregl-popup-close-button {
|
#map .maplibregl-popup-close-button {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -70,6 +427,226 @@
|
||||||
#map .mapboxgl-ctrl-top-right {
|
#map .mapboxgl-ctrl-top-right {
|
||||||
top: 10px !important;
|
top: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Modern SaaS Table Styling ────────────────────────────────────── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap');
|
||||||
|
|
||||||
|
#col-table,
|
||||||
|
.bootstrap-table {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-container {
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body {
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern subtle scrollbar */
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-track {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#col-table,
|
||||||
|
#col-table .tab-content,
|
||||||
|
#col-table .tab-pane {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers */
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-header th,
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table th {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
font-size: 0.74rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
letter-spacing: 0.04em !important;
|
||||||
|
border-bottom: 1.5px solid #e2e8f0 !important;
|
||||||
|
padding: 0.65rem 0.75rem !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-header th .th-inner {
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 0.74rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rows & Cells */
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table td {
|
||||||
|
padding: 0.65rem 0.75rem !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
font-size: 0.83rem !important;
|
||||||
|
color: #334155;
|
||||||
|
border-bottom: 1px solid #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table tbody tr {
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table tbody tr:hover {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table tbody tr.selected {
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific cell styles */
|
||||||
|
.unified-code-link {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||||
|
font-size: 0.79rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.unified-code-link:hover {
|
||||||
|
color: #2563eb !important;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unified-row-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0f172a;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unified-category-cell {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent unwanted wraps */
|
||||||
|
#unified-table td[data-field="code"],
|
||||||
|
#unified-table th[data-field="code"] {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
#unified-table td[data-field="thematic_name"],
|
||||||
|
#unified-table th[data-field="thematic_name"] {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
#unified-table td[data-field="element_type"] {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom button */
|
||||||
|
.zoom-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.zoom-btn:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection toolbar styling */
|
||||||
|
#unified-select-all,
|
||||||
|
#unified-deselect-all {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.28rem 0.65rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
#unified-select-all:hover,
|
||||||
|
#unified-deselect-all:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
#unified-selection-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
#unified-bulk-action-dropdown .dropdown-toggle {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.28rem 0.75rem;
|
||||||
|
}
|
||||||
|
#unified-bulk-action-dropdown .dropdown-toggle:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination bar styling */
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-pagination {
|
||||||
|
padding: 0.65rem 0.25rem 0 0.25rem !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .pagination-detail .page-list .btn-group .btn {
|
||||||
|
border-radius: 6px;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .pagination .page-item .page-link {
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .pagination .page-item.active .page-link {
|
||||||
|
background-color: #0f172a;
|
||||||
|
border-color: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock head %}
|
{% endblock head %}
|
||||||
|
|
||||||
|
|
@ -77,45 +654,78 @@
|
||||||
|
|
||||||
{# ─── Modal de filtre ──────────────────────────────────────────────────────── #}
|
{# ─── Modal de filtre ──────────────────────────────────────────────────────── #}
|
||||||
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
|
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5>
|
<div>
|
||||||
|
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5>
|
||||||
|
<div class="text-muted small" style="font-size: 0.8rem;">{% translate "Sélectionnez les thématiques et critères à afficher sur la carte et dans le tableau" %}</div>
|
||||||
|
</div>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
|
{# Type d'élément #}
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-section-header">
|
||||||
|
<label>{% translate "Type d'élément" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="segmented-control">
|
||||||
|
<div class="filter-option segmented-option">
|
||||||
|
<input type="radio" name="unified-type-filter" id="type-all" value="all"
|
||||||
|
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
|
||||||
|
{% if preventive_element_type %}disabled{% endif %}>
|
||||||
|
<label for="type-all">{% translate "Assets & localisations" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-option segmented-option">
|
||||||
|
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
|
||||||
|
{% if preventive_element_type == 'locations' %}checked{% endif %}
|
||||||
|
{% if preventive_element_type %}disabled{% endif %}>
|
||||||
|
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-option segmented-option">
|
||||||
|
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
|
||||||
|
{% if preventive_element_type == 'assets' %}checked{% endif %}
|
||||||
|
{% if preventive_element_type %}disabled{% endif %}>
|
||||||
|
<label for="type-assets">{% translate "Assets uniquement" %}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Thématiques #}
|
{# Thématiques #}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Thématique" %}</label>
|
<div>
|
||||||
|
<label>{% translate "Thématique" %}</label>
|
||||||
|
</div>
|
||||||
<div class="filter-section-buttons">
|
<div class="filter-section-buttons">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-select-all">{% translate "Tous" %}</button>
|
<button type="button" id="thematic-select-all">{% translate "Toutes" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-deselect-all">{% translate "Aucun" %}</button>
|
<button type="button" id="thematic-deselect-all">{% translate "Aucune" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-options">
|
<div class="thematic-grid">
|
||||||
{% for thematic in accessible_thematics %}
|
{% for thematic in accessible_thematics %}
|
||||||
<div class="filter-option">
|
<div class="thematic-tile">
|
||||||
<input type="checkbox" class="thematic-checkbox"
|
<input type="checkbox" class="thematic-checkbox"
|
||||||
id="thematic-cb-{{ thematic.code }}"
|
id="thematic-cb-{{ thematic.code }}"
|
||||||
value="{{ thematic.code }}"
|
value="{{ thematic.code }}"
|
||||||
{% if thematic.code in selected_thematics %}checked{% endif %}>
|
{% if thematic.code in selected_thematics %}checked{% endif %}>
|
||||||
<label for="thematic-cb-{{ thematic.code }}">
|
<label for="thematic-cb-{{ thematic.code }}">
|
||||||
{% if thematic.icon %}<i class="bi {{ thematic.icon }} me-1"></i>{% endif %}{{ thematic.name }}
|
{% if thematic.icon %}<i class="bi {{ thematic.icon }} thematic-icon"></i>{% endif %}
|
||||||
|
<span class="thematic-label">{{ thematic.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Catégories #}
|
{# Catégories d'assets #}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Catégorie" %}</label>
|
<label>{% translate "Catégorie d'assets" %}</label>
|
||||||
<div class="filter-section-buttons">
|
<div class="filter-section-buttons">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-select-all">{% translate "Toutes" %}</button>
|
<button type="button" id="category-select-all">{% translate "Toutes" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-deselect-all">{% translate "Aucune" %}</button>
|
<button type="button" id="category-deselect-all">{% translate "Aucune" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="category-filter-options" class="filter-options">
|
<div id="category-filter-options" class="filter-options">
|
||||||
|
|
@ -128,9 +738,9 @@
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Modèle" %}</label>
|
<label>{% translate "Modèle" %}</label>
|
||||||
<div class="filter-section-buttons">
|
<div class="filter-section-buttons">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-select-all">{% translate "Tous" %}</button>
|
<button type="button" id="model-select-all">{% translate "Tous" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-deselect-all">{% translate "Aucun" %}</button>
|
<button type="button" id="model-deselect-all">{% translate "Aucun" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="model-filter-toggle"></button>
|
<button type="button" class="d-none" id="model-filter-toggle"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="model-filter-options" class="filter-options">
|
<div id="model-filter-options" class="filter-options">
|
||||||
|
|
@ -148,33 +758,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Type d'élément #}
|
|
||||||
<div class="filter-section">
|
|
||||||
<div class="filter-section-header">
|
|
||||||
<label>{% translate "Type d'élément" %}</label>
|
|
||||||
</div>
|
|
||||||
<div class="filter-options">
|
|
||||||
<div class="filter-option">
|
|
||||||
<input type="radio" name="unified-type-filter" id="type-all" value="all"
|
|
||||||
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
|
|
||||||
{% if preventive_element_type %}disabled{% endif %}>
|
|
||||||
<label for="type-all">{% translate "Assets et localisations" %}</label>
|
|
||||||
</div>
|
|
||||||
<div class="filter-option">
|
|
||||||
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
|
|
||||||
{% if preventive_element_type == 'locations' %}checked{% endif %}
|
|
||||||
{% if preventive_element_type %}disabled{% endif %}>
|
|
||||||
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
|
|
||||||
</div>
|
|
||||||
<div class="filter-option">
|
|
||||||
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
|
|
||||||
{% if preventive_element_type == 'assets' %}checked{% endif %}
|
|
||||||
{% if preventive_element_type %}disabled{% endif %}>
|
|
||||||
<label for="type-assets">{% translate "Assets uniquement" %}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Options de géométrie #}
|
{# Options de géométrie #}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
|
|
@ -183,55 +766,64 @@
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<div class="filter-option">
|
<div class="filter-option">
|
||||||
<input type="checkbox" name="include-no-geom" id="include-no-geom" checked>
|
<input type="checkbox" name="include-no-geom" id="include-no-geom" checked>
|
||||||
<label for="include-no-geom">{% translate "Inclure les assets sans géométrie" %}</label>
|
<label for="include-no-geom">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>{% translate "Inclure les assets sans géométrie" %}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Statut #}
|
{# Statut & Statut de validation #}
|
||||||
<div class="row">
|
<div class="row g-3 mb-1">
|
||||||
<div class="col-12 filter-section">
|
<div class="col-md-6">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section h-100 mb-0">
|
||||||
<label>{% translate "Statut" %}</label>
|
<div class="filter-section-header">
|
||||||
</div>
|
<label>{% translate "Statut" %}</label>
|
||||||
<div class="filter-options">
|
</div>
|
||||||
{% for status_value, status_label in asset_status_choices %}
|
<div class="filter-options">
|
||||||
<div class="filter-option">
|
{% for status_value, status_label in asset_status_choices %}
|
||||||
<input type="checkbox" name="filter-status" id="filter-status-{{ status_value }}" value="{{ status_value }}">
|
<div class="filter-option">
|
||||||
<label for="filter-status-{{ status_value }}">{{ status_label }}</label>
|
<input type="checkbox" name="filter-status" id="filter-status-{{ status_value }}" value="{{ status_value }}">
|
||||||
|
<label for="filter-status-{{ status_value }}">{{ status_label }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-md-6">
|
||||||
|
<div class="filter-section h-100 mb-0">
|
||||||
{# Statut de validation #}
|
<div class="filter-section-header">
|
||||||
<div class="row">
|
<label>{% translate "Validation" %}</label>
|
||||||
<div class="col-12 filter-section">
|
</div>
|
||||||
<div class="filter-section-header">
|
<div class="filter-options">
|
||||||
<label>{% translate "Statut de validation" %}</label>
|
{% for validation_status_value, validation_status_label in asset_validation_status_choices %}
|
||||||
</div>
|
<div class="filter-option">
|
||||||
<div class="filter-options">
|
<input type="checkbox" name="filter-validation-status" id="filter-validation-status-{{ validation_status_value }}" value="{{ validation_status_value }}">
|
||||||
{% for validation_status_value, validation_status_label in asset_validation_status_choices %}
|
<label for="filter-validation-status-{{ validation_status_value }}">{{ validation_status_label }}</label>
|
||||||
<div class="filter-option">
|
</div>
|
||||||
<input type="checkbox" name="filter-validation-status" id="filter-validation-status-{{ validation_status_value }}" value="{{ validation_status_value }}">
|
{% endfor %}
|
||||||
<label for="filter-validation-status-{{ validation_status_value }}">{{ validation_status_label }}</label>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button>
|
<button type="button" id="reset-filters" class="btn btn-link text-muted p-0 text-decoration-none d-flex align-items-center gap-1" style="font-size: 0.85rem; font-weight: 500;">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Tout effacer" %}
|
||||||
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button>
|
</button>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary px-3 py-2" data-bs-dismiss="modal" style="border-radius: 8px; font-weight: 500;">{% translate "Fermer" %}</button>
|
||||||
|
<button type="button" id="apply-filters" class="btn btn-sm btn-dark px-4 py-2" style="background-color: #0f172a; border-color: #0f172a; border-radius: 8px; font-weight: 600;">
|
||||||
|
<i class="bi bi-check2 me-1"></i>{% translate "Appliquer les filtres" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{# ─── Add chooser modal ────────────────────────────────────────────────────── #}
|
{# ─── Add chooser modal ────────────────────────────────────────────────────── #}
|
||||||
<div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true">
|
<div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
|
@ -478,7 +1070,7 @@
|
||||||
data-bs-toggle="modal" data-bs-target="#filterModal"
|
data-bs-toggle="modal" data-bs-target="#filterModal"
|
||||||
title="{% translate 'Filtres' %}">
|
title="{% translate 'Filtres' %}">
|
||||||
<i class="bi bi-funnel"></i> {% translate "Filtres" %}
|
<i class="bi bi-funnel"></i> {% translate "Filtres" %}
|
||||||
<span id="filter-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary d-none">
|
<span id="filter-badge" class="position-absolute badge rounded-pill bg-primary d-none">
|
||||||
<span id="filter-count">0</span>
|
<span id="filter-count">0</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -703,6 +1295,7 @@
|
||||||
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
|
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
|
||||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
|
||||||
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
|
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
|
||||||
var selected_locations_set = new Set({{ selected_locations_ids|safe }});
|
var selected_locations_set = new Set({{ selected_locations_ids|safe }});
|
||||||
const initial_selected_assets = {{ selected_assets_json|safe }};
|
const initial_selected_assets = {{ selected_assets_json|safe }};
|
||||||
|
|
@ -741,6 +1334,9 @@
|
||||||
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
|
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
|
||||||
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
|
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
|
||||||
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
|
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
|
||||||
|
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
|
||||||
|
roadfurniture: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
|
||||||
|
cleanglasscontainer: "{% static 'assets/clean_glass_container_icon.svg' %}?v=20260920",
|
||||||
roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a",
|
roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a",
|
||||||
naturebench: "{% static 'assets/nature_bench_icon.svg' %}",
|
naturebench: "{% static 'assets/nature_bench_icon.svg' %}",
|
||||||
artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a",
|
artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a",
|
||||||
|
|
@ -752,7 +1348,7 @@
|
||||||
<script src="{% static 'common/base_script.js' %}"></script>
|
<script src="{% static 'common/base_script.js' %}"></script>
|
||||||
<script src="{% static 'common/base_map.js' %}"></script>
|
<script src="{% static 'common/base_map.js' %}"></script>
|
||||||
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
|
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
|
||||||
<script src="{% static 'assets/asset_symbology.js' %}?v=20260913"></script>
|
<script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script>
|
||||||
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260726_pb"></script>
|
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script>
|
||||||
<script src="{% static 'assets/unified_index.js' %}?v=20260726_pb"></script>
|
<script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script>
|
||||||
{% endblock script %}
|
{% endblock script %}
|
||||||
|
|
@ -4400,3 +4400,13 @@ class UserAccessibleMunicipalitiesTest(TestCase):
|
||||||
feature_ids = [f['id'] for f in data.get('features', [])]
|
feature_ids = [f['id'] for f in data.get('features', [])]
|
||||||
self.assertIn(street_a.id, feature_ids)
|
self.assertIn(street_a.id, feature_ids)
|
||||||
self.assertNotIn(street_c.id, feature_ids)
|
self.assertNotIn(street_c.id, feature_ids)
|
||||||
|
def test_build_conditional_filter_config_single_municipality_hidden(self):
|
||||||
|
from assets.views.core import build_conditional_filter_config
|
||||||
|
# Remove muni_b from contract
|
||||||
|
self.contract.municipalities.remove(self.muni_b)
|
||||||
|
thematic_roads = Thematic.objects.get(code="roads")
|
||||||
|
cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext)
|
||||||
|
roads_filters = cfg.get('roads', [])
|
||||||
|
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
|
||||||
|
# When only 1 municipality is accessible, the filter should NOT be shown
|
||||||
|
self.assertIsNone(muni_filter)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ def artworks_index(request):
|
||||||
|
|
||||||
from assets.permissions import get_user_accessible_municipalities
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
municipalities = get_user_accessible_municipalities(request.user)
|
municipalities = get_user_accessible_municipalities(request.user)
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
# Status choices for filters
|
# Status choices for filters
|
||||||
status_choices = [
|
status_choices = [
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@ from assets.models.buildings import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
USAGE_TYPE_COLORS = {
|
||||||
|
'administrative': '#2563EB', # Bleu cobalt
|
||||||
|
'education': '#D97706', # Ambre doré
|
||||||
|
'sports': '#059669', # Vert émeraude
|
||||||
|
'culture': '#7C3AED', # Violet pourpre
|
||||||
|
'social': '#E11D48', # Rose framboise
|
||||||
|
'technical': '#64748B', # Gris ardoise
|
||||||
|
'housing': '#0D9488', # Sarcelle
|
||||||
|
'other': '#475569', # Ardoise foncée
|
||||||
|
}
|
||||||
|
DEFAULT_BUILDING_COLOR = '#2563EB'
|
||||||
|
|
||||||
|
|
||||||
def publicbuildings_locations_geojson(request):
|
def publicbuildings_locations_geojson(request):
|
||||||
"""
|
"""
|
||||||
Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
|
Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
|
||||||
|
|
@ -131,10 +144,13 @@ def publicbuildings_locations_geojson(request):
|
||||||
"is_erp": b.is_erp,
|
"is_erp": b.is_erp,
|
||||||
"erp_category": b.erp_category or '',
|
"erp_category": b.erp_category or '',
|
||||||
"dpe_rating": b.dpe_rating or '',
|
"dpe_rating": b.dpe_rating or '',
|
||||||
|
"address": b.address or '',
|
||||||
|
"surface_m2": b.surface_m2,
|
||||||
|
"total_surface_m2": b.total_surface_m2,
|
||||||
"lon": b.lon,
|
"lon": b.lon,
|
||||||
"lat": b.lat,
|
"lat": b.lat,
|
||||||
"color": "#2B579A",
|
"color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
|
||||||
"display_color": "#2B579A",
|
"display_color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
|
||||||
"asset_type": "publicbuilding",
|
"asset_type": "publicbuilding",
|
||||||
"detail_url": detail_url,
|
"detail_url": detail_url,
|
||||||
"element_type": get_element_type_label("publicbuilding"),
|
"element_type": get_element_type_label("publicbuilding"),
|
||||||
|
|
@ -165,12 +181,16 @@ def publicbuildings_assets_geojson(request):
|
||||||
asset_types = data.get('asset_type') or []
|
asset_types = data.get('asset_type') or []
|
||||||
if isinstance(asset_types, str):
|
if isinstance(asset_types, str):
|
||||||
asset_types = [asset_types]
|
asset_types = [asset_types]
|
||||||
|
usage_types = data.get('usage_type') or []
|
||||||
|
if isinstance(usage_types, str):
|
||||||
|
usage_types = [usage_types]
|
||||||
|
|
||||||
search_q = (data.get('q') or '').strip()
|
search_q = (data.get('q') or '').strip()
|
||||||
else:
|
else:
|
||||||
statuses = request.GET.getlist('asset_status')
|
statuses = request.GET.getlist('asset_status')
|
||||||
categories = request.GET.getlist('category')
|
categories = request.GET.getlist('category')
|
||||||
asset_types = request.GET.getlist('asset_type', [])
|
asset_types = request.GET.getlist('asset_type', [])
|
||||||
|
usage_types = request.GET.getlist('usage_type', [])
|
||||||
search_q = request.GET.get('q', '').strip()
|
search_q = request.GET.get('q', '').strip()
|
||||||
|
|
||||||
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
|
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
|
||||||
|
|
@ -181,6 +201,9 @@ def publicbuildings_assets_geojson(request):
|
||||||
if asset_types:
|
if asset_types:
|
||||||
qs = qs.filter(asset_type__in=asset_types)
|
qs = qs.filter(asset_type__in=asset_types)
|
||||||
|
|
||||||
|
if usage_types:
|
||||||
|
qs = qs.filter(building__usage_type__in=usage_types)
|
||||||
|
|
||||||
if statuses:
|
if statuses:
|
||||||
qs = qs.filter(status__in=statuses)
|
qs = qs.filter(status__in=statuses)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -654,7 +654,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
'name': pt.get_name(),
|
'name': pt.get_name(),
|
||||||
'group': group_key,
|
'group': group_key,
|
||||||
'group_label': group_label,
|
'group_label': group_label,
|
||||||
'image_url': None,
|
'image_url': pt.sgv_picture.url if getattr(pt, 'sgv_picture', None) else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
controller_maintenance_provider_options = []
|
controller_maintenance_provider_options = []
|
||||||
|
|
@ -673,15 +673,16 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
for contract in contracts
|
for contract in contracts
|
||||||
]
|
]
|
||||||
|
|
||||||
# Roads / public lighting / sign / artworks: municipality on locations
|
# Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
|
||||||
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
|
if len(municipality_options) > 1:
|
||||||
if thematic_code in accessible_codes:
|
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
|
||||||
config.setdefault(thematic_code, []).append({
|
if thematic_code in accessible_codes:
|
||||||
'key': 'municipality',
|
config.setdefault(thematic_code, []).append({
|
||||||
'label': _('Commune'),
|
'key': 'municipality',
|
||||||
'type': 'multi',
|
'label': _('Commune'),
|
||||||
'options': municipality_options,
|
'type': 'multi',
|
||||||
})
|
'options': municipality_options,
|
||||||
|
})
|
||||||
|
|
||||||
# Traffic lights
|
# Traffic lights
|
||||||
if 'trafficlights' in accessible_codes:
|
if 'trafficlights' in accessible_codes:
|
||||||
|
|
@ -788,8 +789,22 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
|
|
||||||
# Public buildings specific choices
|
# Public buildings specific choices
|
||||||
from assets.models.buildings import USAGE_TYPE_CHOICES
|
from assets.models.buildings import USAGE_TYPE_CHOICES
|
||||||
|
building_usage_icons = {
|
||||||
|
'administrative': 'bi-building',
|
||||||
|
'education': 'bi-mortarboard',
|
||||||
|
'sports': 'bi-trophy',
|
||||||
|
'culture': 'bi-palette',
|
||||||
|
'social': 'bi-people',
|
||||||
|
'technical': 'bi-tools',
|
||||||
|
'housing': 'bi-house-door',
|
||||||
|
'other': 'bi-building-gear',
|
||||||
|
}
|
||||||
building_usage_options = [
|
building_usage_options = [
|
||||||
{'value': str(key), 'label': str(value)}
|
{
|
||||||
|
'value': str(key),
|
||||||
|
'label': str(value),
|
||||||
|
'icon': building_usage_icons.get(str(key), 'bi-building'),
|
||||||
|
}
|
||||||
for key, value in USAGE_TYPE_CHOICES
|
for key, value in USAGE_TYPE_CHOICES
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1073,6 +1088,14 @@ def unified_assets_index(request):
|
||||||
{'id': c.id, 'name': c.get_name()} for c in cats
|
{'id': c.id, 'name': c.get_name()} for c in cats
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Si des thématiques sont spécifiées dans l'URL mais sans catégories explicites,
|
||||||
|
# activer l'ensemble des catégories de ces thématiques par défaut pour charger les assets
|
||||||
|
if has_url_thematics and not selected_categories:
|
||||||
|
selected_categories = []
|
||||||
|
for t_code in selected_thematics:
|
||||||
|
for cat in thematic_categories.get(t_code, []):
|
||||||
|
selected_categories.append(f"{t_code}::{cat['name']}")
|
||||||
|
|
||||||
link_assets_url = (
|
link_assets_url = (
|
||||||
reverse(
|
reverse(
|
||||||
'interventions:intervention_preventive_link_elements',
|
'interventions:intervention_preventive_link_elements',
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,13 @@ def nature_assets_geojson(request):
|
||||||
def obj_to_feature(obj, asset_type=None):
|
def obj_to_feature(obj, asset_type=None):
|
||||||
geometry, lon, lat = build_geometry_and_coords(obj)
|
geometry, lon, lat = build_geometry_and_coords(obj)
|
||||||
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
|
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
|
||||||
|
model_name = obj.get('model_name') or ''
|
||||||
|
code = obj.get('code') or ''
|
||||||
|
is_planter = asset_type == 'naturebench' and (
|
||||||
|
any(w in model_name.lower() for w in ('bac', 'jardin', 'bloem', 'plante', 'fleur'))
|
||||||
|
or code.startswith('CSE-BAC')
|
||||||
|
)
|
||||||
|
element_type_label = _('Bac à fleurs') if is_planter else get_element_type_label(asset_type or '')
|
||||||
return {
|
return {
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": obj['id'],
|
"id": obj['id'],
|
||||||
|
|
@ -355,11 +362,12 @@ def nature_assets_geojson(request):
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"asset_type": asset_type,
|
"asset_type": asset_type,
|
||||||
|
"asset_sub_type": "planter" if is_planter else ("bench" if asset_type == "naturebench" else ""),
|
||||||
"area": obj.get('surface_area', ''),
|
"area": obj.get('surface_area', ''),
|
||||||
"color": obj['color'] if obj['color'] else '#ccc',
|
"color": obj['color'] if obj['color'] else '#ccc',
|
||||||
"detail_url": detail_url,
|
"detail_url": detail_url,
|
||||||
"bulb_types": obj.get('bulb_types') or [],
|
"bulb_types": obj.get('bulb_types') or [],
|
||||||
"element_type": get_element_type_label(asset_type or ''),
|
"element_type": element_type_label,
|
||||||
"thematic": "nature",
|
"thematic": "nature",
|
||||||
"is_location": False,
|
"is_location": False,
|
||||||
"structure_type": obj.get('structure_type', ''),
|
"structure_type": obj.get('structure_type', ''),
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ def publiclighting_index(request):
|
||||||
from assets.permissions import get_user_accessible_municipalities
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
municipalities = list(get_user_accessible_municipalities(request.user))
|
municipalities = list(get_user_accessible_municipalities(request.user))
|
||||||
municipalities.sort(key=lambda m: m.name.lower())
|
municipalities.sort(key=lambda m: m.name.lower())
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting'))
|
categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting'))
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
categories.sort(key=lambda m: m.get_name().lower())
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ def roads_index(request):
|
||||||
from assets.permissions import get_user_accessible_municipalities
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
municipalities = list(get_user_accessible_municipalities(request.user))
|
municipalities = list(get_user_accessible_municipalities(request.user))
|
||||||
municipalities.sort(key=lambda m: m.name.lower())
|
municipalities.sort(key=lambda m: m.name.lower())
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'roads'))
|
categories = list(AssetCategory.objects.filter(thematic__code = 'roads'))
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
categories.sort(key=lambda m: m.get_name().lower())
|
||||||
|
|
@ -305,10 +305,11 @@ def roads_assets_geojson(request):
|
||||||
"geometry": geometry,
|
"geometry": geometry,
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": obj.code,
|
"code": obj.code,
|
||||||
"name": obj.code,
|
"name": obj.get_name() if hasattr(obj, "get_name") else (getattr(obj, "name_fr", None) or obj.code),
|
||||||
"category": obj.category.get_name() if obj.category else None,
|
"category": obj.category.get_name() if obj.category else None,
|
||||||
"model": model.get_name() if model else None,
|
"model": model.get_name() if model else None,
|
||||||
"quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None,
|
"quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None,
|
||||||
|
"quality_level_value": getattr(obj, 'quality_level', None),
|
||||||
"status": obj.status,
|
"status": obj.status,
|
||||||
"validation_status": obj.validation_status,
|
"validation_status": obj.validation_status,
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ def sign_index(request):
|
||||||
from assets.permissions import get_user_accessible_municipalities
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
municipalities = list(get_user_accessible_municipalities(request.user))
|
municipalities = list(get_user_accessible_municipalities(request.user))
|
||||||
municipalities.sort(key=lambda m: m.name.lower())
|
municipalities.sort(key=lambda m: m.name.lower())
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
|
categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
categories.sort(key=lambda m: m.get_name().lower())
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.dropdown-item-checkbox .form-check-input:checked {
|
.dropdown-item-checkbox .form-check-input:checked {
|
||||||
background-color: var(--brand-color);
|
background-color: #0d6efd;
|
||||||
border-color: var(--brand-color);
|
border-color: #0d6efd;
|
||||||
}
|
}
|
||||||
.dropdown-item-checkbox .form-check-label {
|
.dropdown-item-checkbox .form-check-label {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
}
|
}
|
||||||
.dropdown-item-checkbox .form-check-input:checked + .form-check-label {
|
.dropdown-item-checkbox .form-check-input:checked + .form-check-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--brand-color) !important;
|
color: #0f172a !important;
|
||||||
}
|
}
|
||||||
th.sortable:hover {
|
th.sortable:hover {
|
||||||
background-color: rgba(47, 62, 139, 0.08) !important;
|
background-color: rgba(47, 62, 139, 0.08) !important;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db.models import Case, When, IntegerField, Value
|
from django.db.models import Case, When, IntegerField, Value
|
||||||
from .models import (
|
from .models import (
|
||||||
|
MapBackground,
|
||||||
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
||||||
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
||||||
UserThematicStatusPermission, UserContractStatusPermission,
|
UserThematicStatusPermission, UserContractStatusPermission,
|
||||||
|
|
@ -1012,3 +1013,31 @@ class UserAssetTypeAccessAdmin(admin.ModelAdmin):
|
||||||
url = f"/assets/permissions/type/{obj.content_type_id}/"
|
url = f"/assets/permissions/type/{obj.content_type_id}/"
|
||||||
return format_html('<a href="{}" target="_blank">Page du type</a>', url)
|
return format_html('<a href="{}" target="_blank">Page du type</a>', url)
|
||||||
type_page_link.short_description = "Page dédiée"
|
type_page_link.short_description = "Page dédiée"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MapBackground)
|
||||||
|
class MapBackgroundAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name_fr", "name_nl", "region_badge", "category_badge", "service_type", "order", "is_active", "is_default")
|
||||||
|
list_filter = ("region", "category", "service_type", "is_active", "is_default")
|
||||||
|
list_editable = ("order", "is_active", "is_default")
|
||||||
|
search_fields = ("name_fr", "name_nl", "identifier", "attribution", "url")
|
||||||
|
ordering = ("order", "name_fr")
|
||||||
|
|
||||||
|
@admin.display(description="Région")
|
||||||
|
def region_badge(self, obj):
|
||||||
|
colors = {
|
||||||
|
'WAL': '#dc2626',
|
||||||
|
'BRU': '#2563eb',
|
||||||
|
'VLG': '#d97706',
|
||||||
|
'ALL': '#4b5563',
|
||||||
|
}
|
||||||
|
bg = colors.get(obj.region, '#4b5563')
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">{}</span>',
|
||||||
|
bg,
|
||||||
|
obj.get_region_display()
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Catégorie")
|
||||||
|
def category_badge(self, obj):
|
||||||
|
return obj.get_category_display()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Iterable, List
|
from typing import Iterable, List
|
||||||
|
|
||||||
|
|
@ -12,6 +14,7 @@ from .models import UserConfig, AppView
|
||||||
|
|
||||||
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
|
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
|
||||||
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
|
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
|
||||||
|
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
|
||||||
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -155,6 +158,130 @@ def invalidate_user_map_bounds(user_id: int) -> None:
|
||||||
cache.delete(f"user_map_bounds_{user_id}")
|
cache.delete(f"user_map_bounds_{user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_map_backgrounds(request=None) -> list:
|
||||||
|
"""
|
||||||
|
Returns list of active basemap layers from MapBackground model, cached.
|
||||||
|
Fallback to defaults if table does not exist or is empty.
|
||||||
|
Prioritizes basemaps matching user contract municipality region (WAL, BRU, VLG).
|
||||||
|
"""
|
||||||
|
cached = cache.get("active_map_backgrounds")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
backgrounds = []
|
||||||
|
try:
|
||||||
|
from .models import MapBackground
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
lang = get_language() or 'fr'
|
||||||
|
qs = list(MapBackground.objects.filter(is_active=True).order_by('order', 'id'))
|
||||||
|
|
||||||
|
# Detect regional priority if user has an assigned municipality
|
||||||
|
preferred_region = 'ALL'
|
||||||
|
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
user_config = getattr(request.user, 'config', None)
|
||||||
|
if user_config and user_config.default_contract:
|
||||||
|
muni = user_config.default_contract.municipalities.exclude(geom=None).first()
|
||||||
|
if muni and muni.niscode:
|
||||||
|
code = str(muni.niscode).strip()
|
||||||
|
if code.startswith('21'):
|
||||||
|
preferred_region = 'BRU'
|
||||||
|
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or code[0] in ('5', '6', '8', '9'):
|
||||||
|
preferred_region = 'WAL'
|
||||||
|
elif code[0] in ('1', '3', '4', '7') or code[:2] in ('23', '24'):
|
||||||
|
preferred_region = 'VLG'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if preferred_region != 'ALL':
|
||||||
|
# Stable sort: matching region first, then remaining
|
||||||
|
qs.sort(key=lambda x: (0 if x.region == preferred_region else (1 if x.region == 'ALL' else 2), x.order))
|
||||||
|
|
||||||
|
for bg in qs:
|
||||||
|
backgrounds.append({
|
||||||
|
'id': bg.identifier,
|
||||||
|
'name': bg.name_nl if lang == 'nl' and bg.name_nl else bg.name_fr,
|
||||||
|
'name_fr': bg.name_fr,
|
||||||
|
'name_nl': bg.name_nl,
|
||||||
|
'service_type': bg.service_type,
|
||||||
|
'url': bg.url,
|
||||||
|
'layers': bg.layers,
|
||||||
|
'format': bg.format,
|
||||||
|
'attribution': bg.attribution,
|
||||||
|
'region': bg.region,
|
||||||
|
'category': bg.category,
|
||||||
|
'tileSize': bg.tile_size,
|
||||||
|
'minZoom': bg.min_zoom,
|
||||||
|
'maxZoom': bg.max_zoom,
|
||||||
|
'is_default': bg.is_default,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not backgrounds:
|
||||||
|
backgrounds = [
|
||||||
|
{
|
||||||
|
'id': 'ortho_wallonie',
|
||||||
|
'name': 'Ortho Wallonie (SPW)',
|
||||||
|
'name_fr': 'Ortho Wallonie (SPW)',
|
||||||
|
'name_nl': 'Ortho Wallonië (SPW)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
|
||||||
|
'layers': '0',
|
||||||
|
'format': 'image/jpeg',
|
||||||
|
'attribution': '© SPW - Orthophoto Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'urbis_ortho',
|
||||||
|
'name': 'Urbis Ortho (Bruxelles)',
|
||||||
|
'name_fr': 'Urbis Ortho (Bruxelles)',
|
||||||
|
'name_nl': 'Urbis Ortho (Brussel)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': getattr(settings, 'MAP_ORTHO_WMS_URL', None) or 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
|
||||||
|
'layers': 'Ortho',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', None) or '© Brussels UrbIS',
|
||||||
|
'region': 'BRU',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'ortho_vlaanderen',
|
||||||
|
'name': 'Ortho Vlaanderen',
|
||||||
|
'name_fr': 'Ortho Vlaanderen',
|
||||||
|
'name_nl': 'Ortho Vlaanderen',
|
||||||
|
'service_type': 'wmts',
|
||||||
|
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
|
||||||
|
'layers': 'omwrgb23vl',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© Digitaal Vlaanderen',
|
||||||
|
'region': 'VLG',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'plan_wallonie_spw',
|
||||||
|
'name': 'Plan de base Wallonie (SPW)',
|
||||||
|
'name_fr': 'Plan de base Wallonie (SPW)',
|
||||||
|
'name_nl': 'Basiskaart Wallonië (SPW)',
|
||||||
|
'service_type': 'arcgis_rest',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
|
||||||
|
'layers': '',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© SPW - Fond de plan Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'plan',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
cache.set("active_map_backgrounds", backgrounds, 3600)
|
||||||
|
return backgrounds
|
||||||
|
|
||||||
def organization_info(request):
|
def organization_info(request):
|
||||||
"""
|
"""
|
||||||
Context processor that exposes organization details from settings.
|
Context processor that exposes organization details from settings.
|
||||||
|
|
@ -237,6 +364,8 @@ def organization_info(request):
|
||||||
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
|
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
|
||||||
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),
|
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),
|
||||||
'map_ortho_wms_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''),
|
'map_ortho_wms_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''),
|
||||||
|
'map_backgrounds': get_active_map_backgrounds(request),
|
||||||
|
'map_backgrounds_json': json.dumps(get_active_map_backgrounds(request)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
145
loko/common/migrations/0016_mapbackground.py
Normal file
145
loko/common/migrations/0016_mapbackground.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Generated manually for dynamic MapBackgrounds
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_map_backgrounds(apps, schema_editor):
|
||||||
|
MapBackground = apps.get_model('common', 'MapBackground')
|
||||||
|
initial_data = [
|
||||||
|
{
|
||||||
|
'identifier': 'ortho_wallonie',
|
||||||
|
'name_fr': 'Ortho Wallonie (SPW)',
|
||||||
|
'name_nl': 'Ortho Wallonië (SPW)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
|
||||||
|
'layers': '0',
|
||||||
|
'format': 'image/jpeg',
|
||||||
|
'attribution': '© SPW - Orthophoto Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 10,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'urbis_ortho',
|
||||||
|
'name_fr': 'Urbis Ortho (Bruxelles)',
|
||||||
|
'name_nl': 'Urbis Ortho (Brussel)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
|
||||||
|
'layers': 'Ortho',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© Brussels UrbIS',
|
||||||
|
'region': 'BRU',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 20,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'ortho_vlaanderen',
|
||||||
|
'name_fr': 'Ortho Vlaanderen',
|
||||||
|
'name_nl': 'Ortho Vlaanderen',
|
||||||
|
'service_type': 'wmts',
|
||||||
|
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
|
||||||
|
'layers': 'omwrgb23vl',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© Digitaal Vlaanderen',
|
||||||
|
'region': 'VLG',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 30,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'plan_wallonie_spw',
|
||||||
|
'name_fr': 'Plan de base Wallonie (SPW)',
|
||||||
|
'name_nl': 'Basiskaart Wallonië (SPW)',
|
||||||
|
'service_type': 'arcgis_rest',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
|
||||||
|
'layers': '',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© SPW - Fond de plan Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'plan',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 40,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'plan_leger_wallonie_spw',
|
||||||
|
'name_fr': 'Plan léger Wallonie (SPW)',
|
||||||
|
'name_nl': 'Lichte basiskaart Wallonië (SPW)',
|
||||||
|
'service_type': 'arcgis_rest',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW_LEGER/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
|
||||||
|
'layers': '',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© SPW - Fond de plan léger',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'plan',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 50,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for item in initial_data:
|
||||||
|
MapBackground.objects.get_or_create(identifier=item['identifier'], defaults=item)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_seed(apps, schema_editor):
|
||||||
|
MapBackground = apps.get_model('common', 'MapBackground')
|
||||||
|
MapBackground.objects.filter(identifier__in=[
|
||||||
|
'ortho_wallonie', 'urbis_ortho', 'ortho_vlaanderen', 'plan_wallonie_spw', 'plan_leger_wallonie_spw'
|
||||||
|
]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0015_remove_obsolete_schools_appview'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MapBackground',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('identifier', models.SlugField(help_text='Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie', unique=True, verbose_name='Identifiant unique')),
|
||||||
|
('name_fr', models.CharField(max_length=150, verbose_name='Nom FR')),
|
||||||
|
('name_nl', models.CharField(max_length=150, verbose_name='Nom NL')),
|
||||||
|
('service_type', models.CharField(choices=[('wms', 'WMS (Web Map Service)'), ('wmts', 'WMTS (Web Map Tile Service)'), ('xyz', 'XYZ / Tuiles standard'), ('arcgis_rest', 'ArcGIS REST Export')], default='wms', max_length=20, verbose_name='Type de service')),
|
||||||
|
('url', models.TextField(help_text='Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ.', verbose_name='URL du flux')),
|
||||||
|
('layers', models.CharField(blank=True, help_text='Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)', max_length=255, verbose_name='Couche(s)')),
|
||||||
|
('format', models.CharField(default='image/jpeg', help_text='ex: image/jpeg, image/png', max_length=50, verbose_name="Format d'image")),
|
||||||
|
('attribution', models.CharField(blank=True, help_text='ex: © SPW - Orthophoto Wallonie', max_length=255, verbose_name='Attribution / Copyright')),
|
||||||
|
('region', models.CharField(choices=[('ALL', 'Toutes régions (National / Global)'), ('BRU', 'Bruxelles (StreetUp)'), ('WAL', 'Wallonie'), ('VLG', 'Flandre')], default='ALL', max_length=10, verbose_name='Région')),
|
||||||
|
('category', models.CharField(choices=[('ortho', 'Orthophoto / Vue aérienne'), ('plan', 'Plan de base / Topographique'), ('cadastre', 'Cadastre / Parcellaire'), ('other', 'Autre')], default='ortho', max_length=20, verbose_name='Catégorie')),
|
||||||
|
('tile_size', models.PositiveIntegerField(default=256, verbose_name='Taille des tuiles (px)')),
|
||||||
|
('min_zoom', models.PositiveIntegerField(default=0, verbose_name='Zoom minimum')),
|
||||||
|
('max_zoom', models.PositiveIntegerField(default=21, verbose_name='Zoom maximum')),
|
||||||
|
('order', models.PositiveIntegerField(default=0, verbose_name="Ordre d'affichage")),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
|
||||||
|
('is_default', models.BooleanField(default=False, verbose_name='Sélectionné par défaut')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Fond de plan',
|
||||||
|
'verbose_name_plural': 'Fonds de plan',
|
||||||
|
'ordering': ['order', 'name_fr'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_map_backgrounds, rollback_seed),
|
||||||
|
]
|
||||||
|
|
@ -1321,5 +1321,74 @@ class RequestLog(models.Model):
|
||||||
return log_entry
|
return log_entry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class MapBackground(models.Model):
|
||||||
|
SERVICE_TYPE_CHOICES = [
|
||||||
|
('wms', _('WMS (Web Map Service)')),
|
||||||
|
('wmts', _('WMTS (Web Map Tile Service)')),
|
||||||
|
('xyz', _('XYZ / Tuiles standard')),
|
||||||
|
('arcgis_rest', _('ArcGIS REST Export')),
|
||||||
|
]
|
||||||
|
|
||||||
|
REGION_CHOICES = [
|
||||||
|
('ALL', _('Toutes régions (National / Global)')),
|
||||||
|
('BRU', _('Bruxelles (StreetUp)')),
|
||||||
|
('WAL', _('Wallonie')),
|
||||||
|
('VLG', _('Flandre')),
|
||||||
|
]
|
||||||
|
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
('ortho', _('Orthophoto / Vue aérienne')),
|
||||||
|
('plan', _('Plan de base / Topographique')),
|
||||||
|
('cadastre', _('Cadastre / Parcellaire')),
|
||||||
|
('other', _('Autre')),
|
||||||
|
]
|
||||||
|
|
||||||
|
identifier = models.SlugField(max_length=50, unique=True, verbose_name=_("Identifiant unique"), help_text=_("Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie"))
|
||||||
|
name_fr = models.CharField(max_length=150, verbose_name=_("Nom FR"))
|
||||||
|
name_nl = models.CharField(max_length=150, verbose_name=_("Nom NL"))
|
||||||
|
service_type = models.CharField(max_length=20, choices=SERVICE_TYPE_CHOICES, default='wms', verbose_name=_("Type de service"))
|
||||||
|
url = models.TextField(verbose_name=_("URL du flux"), help_text=_("Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ."))
|
||||||
|
layers = models.CharField(max_length=255, blank=True, verbose_name=_("Couche(s)"), help_text=_("Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)"))
|
||||||
|
format = models.CharField(max_length=50, default="image/jpeg", verbose_name=_("Format d'image"), help_text=_("ex: image/jpeg, image/png"))
|
||||||
|
attribution = models.CharField(max_length=255, blank=True, verbose_name=_("Attribution / Copyright"), help_text=_("ex: © SPW - Orthophoto Wallonie"))
|
||||||
|
region = models.CharField(max_length=10, choices=REGION_CHOICES, default='ALL', verbose_name=_("Région"))
|
||||||
|
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='ortho', verbose_name=_("Catégorie"))
|
||||||
|
tile_size = models.PositiveIntegerField(default=256, verbose_name=_("Taille des tuiles (px)"))
|
||||||
|
min_zoom = models.PositiveIntegerField(default=0, verbose_name=_("Zoom minimum"))
|
||||||
|
max_zoom = models.PositiveIntegerField(default=21, verbose_name=_("Zoom maximum"))
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Ordre d'affichage"))
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
|
||||||
|
is_default = models.BooleanField(default=False, verbose_name=_("Sélectionné par défaut"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
lang = get_language()
|
||||||
|
if lang == 'nl':
|
||||||
|
return self.name_nl or self.name_fr
|
||||||
|
return self.name_fr or self.name_nl
|
||||||
|
|
||||||
|
objects = LanguageManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Fond de plan")
|
||||||
|
verbose_name_plural = _("Fonds de plan")
|
||||||
|
ordering = ['order', 'name_fr']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_region_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
from django.core.cache import cache
|
||||||
|
cache.delete("active_map_backgrounds")
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
from django.core.cache import cache
|
||||||
|
cache.delete("active_map_backgrounds")
|
||||||
|
|
||||||
|
|
||||||
# Import des modèles de tâches planifiées
|
# Import des modèles de tâches planifiées
|
||||||
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ app_name = "common"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"),
|
path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"),
|
||||||
path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"),
|
path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"),
|
||||||
|
path("territory-mask-geojson/", views.get_territory_mask_geojson, name="get_territory_mask_geojson"),
|
||||||
path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"),
|
path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"),
|
||||||
path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"),
|
path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"),
|
||||||
path("account-info", views.account_info, name='account_info'),
|
path("account-info", views.account_info, name='account_info'),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import JsonResponse, FileResponse, Http404
|
from django.http import JsonResponse, FileResponse, Http404
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
@ -5,7 +6,7 @@ from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.gis.db.models.functions import Distance
|
from django.contrib.gis.db.models.functions import Distance
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point, Polygon
|
||||||
from django.contrib.auth import login as auth_login
|
from django.contrib.auth import login as auth_login
|
||||||
from django.contrib.auth.decorators import login_not_required
|
from django.contrib.auth.decorators import login_not_required
|
||||||
from django.contrib.auth.forms import PasswordChangeForm
|
from django.contrib.auth.forms import PasswordChangeForm
|
||||||
|
|
@ -149,6 +150,111 @@ def get_nearest_address(request):
|
||||||
|
|
||||||
@login_not_required
|
@login_not_required
|
||||||
@require_GET
|
@require_GET
|
||||||
|
def get_territory_mask_geojson(request):
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
Renvoie le masque cartographique inversé (calque grisé transparent en dehors de la commune)
|
||||||
|
et le tracé du contour communal pour mettre en évidence le territoire à gérer.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
user = request.user
|
||||||
|
muni_params = request.GET.getlist('municipality')
|
||||||
|
muni_ids = []
|
||||||
|
if muni_params:
|
||||||
|
for p in muni_params:
|
||||||
|
if str(p).isdigit():
|
||||||
|
muni_ids.append(int(p))
|
||||||
|
|
||||||
|
from common.models import Municipality
|
||||||
|
from contracts.models import Contract
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
|
||||||
|
if not muni_ids:
|
||||||
|
# Priorité 1 : contrat par défaut ou contrats de l'utilisateur
|
||||||
|
user_config = getattr(user, 'config', None) if user and user.is_authenticated else None
|
||||||
|
if user_config and user_config.default_contract and user_config.default_contract.municipalities.exists():
|
||||||
|
muni_ids = list(user_config.default_contract.municipalities.values_list('id', flat=True))
|
||||||
|
elif user_config and user_config.contracts.filter(municipalities__isnull=False).exists():
|
||||||
|
muni_ids = list(Municipality.objects.filter(contracts__in=user_config.contracts.all()).values_list('id', flat=True).distinct())
|
||||||
|
elif Contract.objects.filter(municipalities__isnull=False).count() == 1:
|
||||||
|
muni_ids = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True))
|
||||||
|
elif user and user.is_authenticated:
|
||||||
|
muni_ids = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
|
||||||
|
else:
|
||||||
|
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True)[:1])
|
||||||
|
|
||||||
|
if not muni_ids:
|
||||||
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
||||||
|
muni_ids = sorted(list(set(muni_ids)))
|
||||||
|
cache_key = f"territory_mask_geojson_{'_'.join(map(str, muni_ids))}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return JsonResponse(cached_data)
|
||||||
|
|
||||||
|
munis = Municipality.objects.filter(id__in=muni_ids, geom__isnull=False)
|
||||||
|
if not munis.exists():
|
||||||
|
empty_res = {"type": "FeatureCollection", "features": []}
|
||||||
|
return JsonResponse(empty_res)
|
||||||
|
|
||||||
|
combined_geom = None
|
||||||
|
names = []
|
||||||
|
for m in munis:
|
||||||
|
names.append(m.name)
|
||||||
|
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
|
||||||
|
# Légère simplification (~3 mètres) pour optimiser les perfs du navigateur
|
||||||
|
g_simp = g.simplify(0.00003, preserve_topology=True)
|
||||||
|
if combined_geom is None:
|
||||||
|
combined_geom = g_simp
|
||||||
|
else:
|
||||||
|
combined_geom = combined_geom.union(g_simp)
|
||||||
|
|
||||||
|
if not combined_geom:
|
||||||
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
||||||
|
# Boîte englobante mondiale pour le masque inversé
|
||||||
|
world = Polygon.from_bbox((-180, -85, 180, 85))
|
||||||
|
try:
|
||||||
|
mask = world.difference(combined_geom)
|
||||||
|
except Exception:
|
||||||
|
mask = world.difference(combined_geom.buffer(0))
|
||||||
|
|
||||||
|
territory_name = ", ".join(names)
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "territory_mask",
|
||||||
|
"properties": {
|
||||||
|
"layer": "mask",
|
||||||
|
"type": "mask",
|
||||||
|
"name": territory_name
|
||||||
|
},
|
||||||
|
"geometry": json.loads(mask.geojson)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "territory_boundary",
|
||||||
|
"properties": {
|
||||||
|
"layer": "boundary",
|
||||||
|
"type": "boundary",
|
||||||
|
"name": territory_name
|
||||||
|
},
|
||||||
|
"geometry": json.loads(combined_geom.geojson)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": features
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cache_key, result, 86400)
|
||||||
|
return JsonResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_regional_roads_geojson(request):
|
def get_regional_roads_geojson(request):
|
||||||
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')
|
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}"
|
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}"
|
||||||
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" style="color: var(--brand-color);">
|
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" >
|
||||||
<i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i>
|
<i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i>
|
||||||
<span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span>
|
<span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
# Widget code choices
|
# Widget code choices
|
||||||
WIDGET_CODE_CHOICES = [
|
WIDGET_CODE_CHOICES = [
|
||||||
|
('territory_kpis', _('Métriques du patrimoine')),
|
||||||
|
('recent_activity', _("Radar d'activité")),
|
||||||
('intervention_stats', _('Statistiques interventions')),
|
('intervention_stats', _('Statistiques interventions')),
|
||||||
('contracts_summary', _('Résumé contrats')),
|
('contracts_summary', _('Résumé contrats')),
|
||||||
('map_today', _('Carte du jour')),
|
('map_today', _('Carte du jour')),
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.main-button {
|
.main-button {
|
||||||
border: 1px solid var(--brand-color);
|
border: 1px solid #e2e8f0;
|
||||||
width: 230px;
|
width: 230px;
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -155,17 +155,7 @@
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-legend {
|
/* old map-legend replaced by .activity-map-legend */
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background-color: rgba(255, 255, 255, 0.85);
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-legend .badge {
|
.map-legend .badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -180,7 +170,7 @@
|
||||||
/* Tuiles raccourcis symptômes pour opérateurs */
|
/* Tuiles raccourcis symptômes pour opérateurs */
|
||||||
.shortcut-symptom-card {
|
.shortcut-symptom-card {
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
border: 1px solid var(--brand-color);
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s;
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s;
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
|
|
@ -211,4 +201,250 @@
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
border-color: rgba(255, 255, 255, 0.8);
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Quick Actions Bar Modernisée (Header Accueil)
|
||||||
|
========================================================================== */
|
||||||
|
.quick-actions-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
min-height: 48px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 2px rgba(15, 23, 42, 0.02);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.12);
|
||||||
|
border-color: rgba(37, 99, 235, 0.35);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
background: rgba(37, 99, 235, 0.1) !important;
|
||||||
|
color: #2563eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-btn:hover .quick-action-icon {
|
||||||
|
transform: scale(1.08);
|
||||||
|
background: rgba(37, 99, 235, 0.18) !important;
|
||||||
|
color: #1d4ed8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Widget Patrimoine du Territoire (Cartes Thématiques & KPIs)
|
||||||
|
========================================================================== */
|
||||||
|
.territory-kpis-widget {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-header-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(13, 110, 253, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cartes KPI Patrimoine */
|
||||||
|
.kpi-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
transition: transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9) !important;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3.5px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bordures supérieures colorées par thématique */
|
||||||
|
.kpi-card-nature::before { background: #10b981 !important; }
|
||||||
|
.kpi-card-lighting::before { background: #f59e0b !important; }
|
||||||
|
.kpi-card-sign::before { background: #3b82f6 !important; }
|
||||||
|
.kpi-card-roads::before { background: #06b6d4 !important; }
|
||||||
|
.kpi-card-structures::before { background: #8b5cf6 !important; }
|
||||||
|
|
||||||
|
/* Badges thématiques */
|
||||||
|
.kpi-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-badge-nature { background: rgba(16, 185, 129, 0.12) !important; color: #059669 !important; }
|
||||||
|
.kpi-badge-lighting { background: rgba(245, 158, 11, 0.12) !important; color: #d97706 !important; }
|
||||||
|
.kpi-badge-sign { background: rgba(59, 130, 246, 0.12) !important; color: #2563eb !important; }
|
||||||
|
.kpi-badge-roads { background: rgba(6, 182, 212, 0.12) !important; color: #0891b2 !important; }
|
||||||
|
.kpi-badge-structures { background: rgba(139, 92, 246, 0.12) !important; color: #7c3aed !important; }
|
||||||
|
|
||||||
|
.kpi-pill-total {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-stat-value {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #1e293b;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-stat-unit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-details {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
border-top: 1px dashed #f1f5f9;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Carte Radar Activité Récente
|
||||||
|
========================================================================== */
|
||||||
|
.recent-activity-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-act-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(13, 110, 253, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-activity-item {
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-activity-item:hover {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon-badge {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-badge-intervention { background: rgba(13, 110, 253, 0.12) !important; color: #0d6efd !important; }
|
||||||
|
.activity-badge-observation { background: rgba(245, 158, 11, 0.15) !important; color: #d97706 !important; }
|
||||||
|
.activity-badge-inspection { background: rgba(139, 92, 246, 0.15) !important; color: #7c3aed !important; }
|
||||||
|
.activity-badge-control { background: rgba(6, 182, 212, 0.15) !important; color: #0891b2 !important; }
|
||||||
|
|
||||||
|
/* Pastilles d'activités aux couleurs des 5 thématiques (distinction des opérations par symbole) */
|
||||||
|
.activity-thematic-nature {
|
||||||
|
background: rgba(16, 185, 129, 0.15) !important;
|
||||||
|
color: #059669 !important;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.28) !important;
|
||||||
|
}
|
||||||
|
.activity-thematic-lighting {
|
||||||
|
background: rgba(245, 158, 11, 0.15) !important;
|
||||||
|
color: #d97706 !important;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.28) !important;
|
||||||
|
}
|
||||||
|
.activity-thematic-sign {
|
||||||
|
background: rgba(59, 130, 246, 0.15) !important;
|
||||||
|
color: #2563eb !important;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.28) !important;
|
||||||
|
}
|
||||||
|
.activity-thematic-roads {
|
||||||
|
background: rgba(6, 182, 212, 0.15) !important;
|
||||||
|
color: #0891b2 !important;
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.28) !important;
|
||||||
|
}
|
||||||
|
.activity-thematic-structures {
|
||||||
|
background: rgba(139, 92, 246, 0.15) !important;
|
||||||
|
color: #7c3aed !important;
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.28) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Légende flottante activité territoriale (ancrée en bas à gauche, compacte) */
|
||||||
|
.activity-map-legend {
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: 16px !important;
|
||||||
|
left: 16px !important;
|
||||||
|
top: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 260px !important;
|
||||||
|
height: auto !important;
|
||||||
|
z-index: 10 !important;
|
||||||
|
background: rgba(255, 255, 255, 0.94) !important;
|
||||||
|
backdrop-filter: blur(6px) !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Éviter le débordement horizontal dans le radar d'activité */
|
||||||
|
.recent-activity-card .card-body {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ function statusFormatter(value) {
|
||||||
const entry = icons[value];
|
const entry = icons[value];
|
||||||
if (!entry) return value;
|
if (!entry) return value;
|
||||||
|
|
||||||
return `<span class="badge text-bg-${entry.color} status-${value}"><i class="bi ${entry.icon} me-2"></i>${entry.label}</span>`;
|
return `<span class="badge text-bg-${entry.color} status-${value}"><i class="bi ${entry.icon} me-1"></i>${entry.label}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapContainer = document.getElementById("map");
|
const mapContainer = document.getElementById("map");
|
||||||
|
|
@ -28,163 +28,341 @@ const mapContainer = document.getElementById("map");
|
||||||
if (mapContainer && typeof maplibregl !== 'undefined') {
|
if (mapContainer && typeof maplibregl !== 'undefined') {
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: mapContainer,
|
container: mapContainer,
|
||||||
style: window.MAP_STYLE_LIGHT,
|
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
center: [4.3572, 50.8477],
|
center: [4.568, 50.645], // Court-Saint-Étienne default
|
||||||
zoom: 11,
|
zoom: 12.0,
|
||||||
minzoom: 0,
|
minzoom: 8,
|
||||||
maxzoom: 24
|
maxzoom: 20
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchAndMergeGeoJSON(urls) {
|
map.addControl(new maplibregl.NavigationControl(), 'top-left');
|
||||||
const allFeatures = [];
|
|
||||||
|
|
||||||
for (const url of urls) {
|
let currentMode = 'heatmap'; // 'heatmap' or 'points'
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
if (data && data.features) {
|
|
||||||
allFeatures.push(...data.features);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Error fetching geojson:', url, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: allFeatures
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
map.on('load', async () => {
|
map.on('load', async () => {
|
||||||
if (typeof interventions_geojson_urls !== 'undefined' && interventions_geojson_urls.length > 0) {
|
// 1. Charger le masque de mise en lumière du territoire communal
|
||||||
const mergedGeoJSON = await fetchAndMergeGeoJSON(interventions_geojson_urls);
|
try {
|
||||||
|
const maskResp = await fetch('/common/territory-mask-geojson/');
|
||||||
|
if (maskResp.ok) {
|
||||||
|
const maskData = await maskResp.json();
|
||||||
|
map.addSource('territory-mask', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: maskData
|
||||||
|
});
|
||||||
|
|
||||||
map.addSource('interventions', {
|
// Masque inversé (assombrit l'extérieur de la commune de manière subtile et nette)
|
||||||
'type': 'geojson',
|
map.addLayer({
|
||||||
'data': mergedGeoJSON
|
id: 'territory-mask-fill',
|
||||||
});
|
type: 'fill',
|
||||||
|
source: 'territory-mask',
|
||||||
map.addLayer({
|
filter: ['any',
|
||||||
'id': 'interventions',
|
['==', ['get', 'layer'], 'mask'],
|
||||||
'type': 'circle',
|
['==', ['get', 'type'], 'mask'],
|
||||||
'source': 'interventions',
|
['==', ['id'], 'territory_mask']
|
||||||
minzoom: 0,
|
|
||||||
maxzoom: 24,
|
|
||||||
'paint': {
|
|
||||||
'circle-radius': [
|
|
||||||
'interpolate',
|
|
||||||
['linear'],
|
|
||||||
['zoom'],
|
|
||||||
10, 5,
|
|
||||||
22, 15
|
|
||||||
],
|
],
|
||||||
'circle-color': typeof INTERVENTION_STATUS_COLOR_EXPR !== 'undefined' ? INTERVENTION_STATUS_COLOR_EXPR : '#0094ff',
|
paint: {
|
||||||
'circle-opacity': 0.7
|
'fill-color': '#0f172a',
|
||||||
|
'fill-opacity': 0.14
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frontière communale nette
|
||||||
|
map.addLayer({
|
||||||
|
id: 'territory-mask-outline',
|
||||||
|
type: 'line',
|
||||||
|
source: 'territory-mask',
|
||||||
|
filter: ['any',
|
||||||
|
['==', ['get', 'layer'], 'boundary'],
|
||||||
|
['==', ['get', 'type'], 'boundary'],
|
||||||
|
['==', ['id'], 'territory_boundary']
|
||||||
|
],
|
||||||
|
paint: {
|
||||||
|
'line-color': '#0d6efd',
|
||||||
|
'line-width': 2.5,
|
||||||
|
'line-dasharray': [3, 2],
|
||||||
|
'line-opacity': 0.9
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cadrage automatique rigoureux sur l'ensemble de la commune
|
||||||
|
const boundaryFeat = (maskData.features || []).find(f =>
|
||||||
|
f.id === 'territory_boundary' ||
|
||||||
|
(f.properties && (f.properties.layer === 'boundary' || f.properties.type === 'boundary'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (boundaryFeat && boundaryFeat.geometry) {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
function checkPt(pt) {
|
||||||
|
if (pt[0] < minX) minX = pt[0];
|
||||||
|
if (pt[0] > maxX) maxX = pt[0];
|
||||||
|
if (pt[1] < minY) minY = pt[1];
|
||||||
|
if (pt[1] > maxY) maxY = pt[1];
|
||||||
|
}
|
||||||
|
function scanCoords(coords) {
|
||||||
|
if (!Array.isArray(coords)) return;
|
||||||
|
if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
|
||||||
|
checkPt(coords);
|
||||||
|
} else {
|
||||||
|
coords.forEach(scanCoords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scanCoords(boundaryFeat.geometry.coordinates);
|
||||||
|
|
||||||
|
if (minX !== Infinity && maxX !== -Infinity) {
|
||||||
|
map.fitBounds([[minX, minY], [maxX, maxY]], {
|
||||||
|
padding: { top: 35, bottom: 35, left: 35, right: 35 },
|
||||||
|
maxZoom: 13.2,
|
||||||
|
duration: 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not load territory mask:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Charger les données d'activité isolées par contrat
|
||||||
|
try {
|
||||||
|
const actResp = await fetch('/api/activity-geojson/');
|
||||||
|
if (actResp.ok) {
|
||||||
|
const actData = await actResp.json();
|
||||||
|
map.addSource('activity-data', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: actData
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layer A: Heatmap (densité d'activité des 3 derniers mois)
|
||||||
|
// S'estompe naturellement entre les niveaux de zoom 13.5 et 15.5
|
||||||
|
map.addLayer({
|
||||||
|
id: 'activity-heatmap',
|
||||||
|
type: 'heatmap',
|
||||||
|
source: 'activity-data',
|
||||||
|
maxzoom: 17,
|
||||||
|
paint: {
|
||||||
|
'heatmap-weight': 1,
|
||||||
|
'heatmap-intensity': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
10, 1,
|
||||||
|
14, 2.5
|
||||||
|
],
|
||||||
|
'heatmap-color': [
|
||||||
|
'interpolate', ['linear'], ['heatmap-density'],
|
||||||
|
0, 'rgba(33, 102, 172, 0)',
|
||||||
|
0.2, 'rgb(103, 169, 207)',
|
||||||
|
0.4, 'rgb(209, 229, 240)',
|
||||||
|
0.6, 'rgb(253, 219, 199)',
|
||||||
|
0.8, 'rgb(239, 138, 98)',
|
||||||
|
1, 'rgb(178, 24, 43)'
|
||||||
|
],
|
||||||
|
'heatmap-radius': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
10, 16,
|
||||||
|
13, 26,
|
||||||
|
16, 42
|
||||||
|
],
|
||||||
|
// En mode Heatmap, l'intensité s'estompe en zoomant
|
||||||
|
'heatmap-opacity': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
13, 0.85,
|
||||||
|
15.5, 0.05
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layer B: Points individuels avec couleur selon la typologie
|
||||||
|
// En mode Heatmap: émergent progressivement lorsque la heatmap s'estompe !
|
||||||
|
map.addLayer({
|
||||||
|
id: 'activity-points',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'activity-data',
|
||||||
|
layout: {
|
||||||
|
'visibility': 'visible'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'circle-radius': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
10, 5,
|
||||||
|
14, 8,
|
||||||
|
18, 14
|
||||||
|
],
|
||||||
|
'circle-color': [
|
||||||
|
'match',
|
||||||
|
['get', 'thematic_key'],
|
||||||
|
'nature', '#10b981',
|
||||||
|
'lighting', '#f59e0b',
|
||||||
|
'sign', '#3b82f6',
|
||||||
|
'roads', '#06b6d4',
|
||||||
|
'structures', '#8b5cf6',
|
||||||
|
'#64748b'
|
||||||
|
],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
// Transition d'opacité fluide: invisibles au grand zoom, émergent dès zoom 13.5
|
||||||
|
'circle-opacity': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
13.2, 0,
|
||||||
|
14.8, 0.95
|
||||||
|
],
|
||||||
|
'circle-stroke-opacity': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
13.2, 0,
|
||||||
|
14.8, 1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLegend();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not load activity geojson:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on('click', function(e) {
|
// Toggle Heatmap vs Points
|
||||||
const radiusInPixels = 10;
|
const btnHeatmap = document.getElementById('btnModeHeatmap');
|
||||||
|
const btnPoints = document.getElementById('btnModePoints');
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
currentMode = mode;
|
||||||
|
if (mode === 'heatmap') {
|
||||||
|
btnHeatmap.classList.add('btn-primary', 'active');
|
||||||
|
btnHeatmap.classList.remove('btn-outline-primary');
|
||||||
|
btnPoints.classList.remove('btn-primary', 'active');
|
||||||
|
btnPoints.classList.add('btn-outline-primary');
|
||||||
|
|
||||||
|
if (map.getLayer('activity-heatmap')) {
|
||||||
|
map.setLayoutProperty('activity-heatmap', 'visibility', 'visible');
|
||||||
|
map.setPaintProperty('activity-heatmap', 'heatmap-opacity', [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
13, 0.85,
|
||||||
|
15.5, 0.05
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (map.getLayer('activity-points')) {
|
||||||
|
map.setLayoutProperty('activity-points', 'visibility', 'visible');
|
||||||
|
map.setPaintProperty('activity-points', 'circle-opacity', [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
13.2, 0,
|
||||||
|
14.8, 0.95
|
||||||
|
]);
|
||||||
|
map.setPaintProperty('activity-points', 'circle-stroke-opacity', [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
13.2, 0,
|
||||||
|
14.8, 1
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mode Points pur
|
||||||
|
btnPoints.classList.add('btn-primary', 'active');
|
||||||
|
btnPoints.classList.remove('btn-outline-primary');
|
||||||
|
btnHeatmap.classList.remove('btn-primary', 'active');
|
||||||
|
btnHeatmap.classList.add('btn-outline-primary');
|
||||||
|
|
||||||
|
if (map.getLayer('activity-heatmap')) {
|
||||||
|
map.setLayoutProperty('activity-heatmap', 'visibility', 'none');
|
||||||
|
}
|
||||||
|
if (map.getLayer('activity-points')) {
|
||||||
|
map.setLayoutProperty('activity-points', 'visibility', 'visible');
|
||||||
|
map.setPaintProperty('activity-points', 'circle-opacity', 0.95);
|
||||||
|
map.setPaintProperty('activity-points', 'circle-stroke-opacity', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderLegend();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnHeatmap) btnHeatmap.addEventListener('click', () => setMode('heatmap'));
|
||||||
|
if (btnPoints) btnPoints.addEventListener('click', () => setMode('points'));
|
||||||
|
|
||||||
|
// Clic pour afficher les détails du point
|
||||||
|
map.on('click', function(e) {
|
||||||
|
const radiusInPixels = 12;
|
||||||
const features = map.queryRenderedFeatures([
|
const features = map.queryRenderedFeatures([
|
||||||
[e.point.x - radiusInPixels, e.point.y - radiusInPixels],
|
[e.point.x - radiusInPixels, e.point.y - radiusInPixels],
|
||||||
[e.point.x + radiusInPixels, e.point.y + radiusInPixels]
|
[e.point.x + radiusInPixels, e.point.y + radiusInPixels]
|
||||||
], {
|
], {
|
||||||
layers: ['interventions']
|
layers: ['activity-points', 'activity-heatmap']
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!features.length) {
|
if (!features.length) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popupContent = features.map((feature) => {
|
const f = features[0];
|
||||||
const props = feature.properties;
|
const p = f.properties;
|
||||||
const detailUrl = (typeof interventionDetailUrlPattern !== 'undefined')
|
if (!p || !p.title) return;
|
||||||
? interventionDetailUrlPattern.replace('/0/', '/' + props.id + '/')
|
|
||||||
: '#';
|
const thematicConfig = {
|
||||||
return `
|
'nature': { color: '#10b981', label: 'Nature' },
|
||||||
<div>
|
'lighting': { color: '#f59e0b', label: 'Éclairage' },
|
||||||
<strong><a href="${detailUrl}">${props.code || gettext('Intervention')}</a></strong><br/>
|
'sign': { color: '#3b82f6', label: 'Mobilité' },
|
||||||
${gettext("Statut")}: ${statusFormatter(props.status)}<br/>
|
'roads': { color: '#06b6d4', label: 'Voirie' },
|
||||||
${gettext("Titre")}: ${props.title || '-'}
|
'structures': { color: '#8b5cf6', label: 'Ouvrages' },
|
||||||
|
};
|
||||||
|
const th = thematicConfig[p.thematic_key] || { color: '#64748b', label: p.thematic || 'Patrimoine' };
|
||||||
|
|
||||||
|
const opConfig = {
|
||||||
|
'intervention': { icon: 'bi-tools', label: 'Intervention' },
|
||||||
|
'observation': { icon: 'bi-eye-fill', label: 'Observation' },
|
||||||
|
'inspection': { icon: 'bi-search', label: 'Inspection' },
|
||||||
|
'control': { icon: 'bi-clipboard-check', label: 'Contrôle' },
|
||||||
|
};
|
||||||
|
const op = opConfig[p.type] || { icon: p.icon || 'bi-geo-alt', label: p.type_label || p.type };
|
||||||
|
|
||||||
|
const popupContent = `
|
||||||
|
<div style="min-width: 220px; font-family: system-ui, -apple-system, sans-serif;">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="badge text-white fw-semibold" style="background-color: ${th.color}; font-size: 0.74rem; padding: 4px 8px;">
|
||||||
|
<i class="bi ${op.icon} me-1.5"></i>${op.label} — ${th.label}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted ms-2">${p.date || ''}</small>
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<div class="fw-bold mb-1 text-dark" style="font-size: 0.92rem;">${p.title}</div>
|
||||||
`;
|
<div class="text-secondary small mb-2"><i class="bi bi-geo-alt me-1"></i>${p.address || p.code}</div>
|
||||||
}).join('');
|
<div class="d-flex align-items-center justify-content-between mt-2 pt-2 border-top">
|
||||||
|
${statusFormatter(p.status)}
|
||||||
|
${p.url && p.url !== '#' ? `<a href="${p.url}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.75rem;">Voir</a>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
new maplibregl.Popup()
|
new maplibregl.Popup({ closeButton: true, offset: 12 })
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(f.geometry.coordinates || e.lngLat)
|
||||||
.setHTML(popupContent)
|
.setHTML(popupContent)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on('mouseenter', 'interventions', () => {
|
map.on('mouseenter', 'activity-points', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
map.on('mouseleave', 'activity-points', () => { map.getCanvas().style.cursor = ''; });
|
||||||
});
|
|
||||||
map.on('mouseleave', 'interventions', () => {
|
|
||||||
map.getCanvas().style.cursor = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
function populateLegend() {
|
|
||||||
const statuses = ['to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished'];
|
|
||||||
const legendContainer = document.getElementById('map-legend');
|
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
const legendContainer = document.getElementById('activity-map-legend') || document.getElementById('map-legend');
|
||||||
if (!legendContainer) return;
|
if (!legendContainer) return;
|
||||||
|
|
||||||
const legendItems = statuses.map(status => {
|
if (currentMode === 'heatmap') {
|
||||||
return `<div class="mb-1">${statusFormatter(status)}</div>`;
|
legendContainer.innerHTML = `
|
||||||
});
|
<div class="fw-semibold mb-1 text-dark" style="font-size: 0.78rem;">Densité des opérations</div>
|
||||||
|
<div class="d-flex align-items-center gap-2" style="font-size: 0.72rem;">
|
||||||
legendContainer.innerHTML = legendItems.join('');
|
<span class="text-muted">Faible</span>
|
||||||
}
|
<div style="width: 110px; height: 10px; border-radius: 5px; background: linear-gradient(to right, rgb(103, 169, 207), rgb(253, 219, 199), rgb(239, 138, 98), rgb(178, 24, 43));"></div>
|
||||||
|
<span class="text-muted">Forte</span>
|
||||||
if (document.readyState === 'loading') {
|
</div>
|
||||||
document.addEventListener('DOMContentLoaded', populateLegend);
|
`;
|
||||||
} else {
|
} else {
|
||||||
populateLegend();
|
legendContainer.innerHTML = `
|
||||||
|
<div class="fw-semibold mb-1 text-dark" style="font-size: 0.78rem;">Thématiques (couleurs)</div>
|
||||||
|
<div class="d-flex flex-column gap-1 mb-2" style="font-size: 0.72rem;">
|
||||||
|
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#10b981;display:inline-block;flex-shrink:0;"></span> Nature & Espaces verts</div>
|
||||||
|
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#f59e0b;display:inline-block;flex-shrink:0;"></span> Éclairage public</div>
|
||||||
|
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#3b82f6;display:inline-block;flex-shrink:0;"></span> Mobilité & Stationnement</div>
|
||||||
|
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#06b6d4;display:inline-block;flex-shrink:0;"></span> Voirie & Propreté</div>
|
||||||
|
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#8b5cf6;display:inline-block;flex-shrink:0;"></span> Ouvrages & Bâtiments</div>
|
||||||
|
</div>
|
||||||
|
<div class="fw-semibold mb-1 text-dark border-top pt-1" style="font-size: 0.75rem;">Opérations (symboles)</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 text-muted" style="font-size: 0.70rem;">
|
||||||
|
<span><i class="bi bi-tools text-dark me-0.5"></i> Interv.</span>
|
||||||
|
<span><i class="bi bi-eye-fill text-dark me-0.5"></i> Obs.</span>
|
||||||
|
<span><i class="bi bi-search text-dark me-0.5"></i> Insp.</span>
|
||||||
|
<span><i class="bi bi-clipboard-check text-dark me-0.5"></i> Ctrl.</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ### chart ###
|
|
||||||
const chartDom = document.getElementById('chartNbrInterventions');
|
|
||||||
if (chartDom && typeof echarts !== 'undefined' && typeof seriesData !== 'undefined') {
|
|
||||||
const series = [
|
|
||||||
{
|
|
||||||
name: gettext('Correctif'),
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
data: seriesData['Correctif'] || [],
|
|
||||||
itemStyle: { color: '#dc3545' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: gettext('Préventif'),
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
data: seriesData['Préventif'] || [],
|
|
||||||
itemStyle: { color: '#ffc107' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: gettext('Amélioratif'),
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
data: seriesData['Amélioratif'] || [],
|
|
||||||
itemStyle: { color: '#28a745' }
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const option = {
|
|
||||||
legend: { data: [gettext('Correctif'), gettext('Préventif'), gettext('Amélioratif')] },
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
xAxis: { type: 'category', data: seriesData.dates || [] },
|
|
||||||
yAxis: { type: 'value' },
|
|
||||||
series: series
|
|
||||||
};
|
|
||||||
|
|
||||||
const myChart = echarts.init(chartDom);
|
|
||||||
myChart.setOption(option);
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => myChart.resize());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -45,77 +45,83 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> -->
|
<!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> -->
|
||||||
<div class="row justify-content-center mt-2 mb-3">
|
{# ========== Barre d'Actions Rapides Modernisée ========== #}
|
||||||
<div class="col-md-12">
|
<div class="row justify-content-center mt-2 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="quick-actions-bar">
|
||||||
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" %}
|
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" %}
|
||||||
<a href="{% url 'observations:observation_add' %}" class="btn btn-light main-button my-1 ms-1" type="button">
|
<a href="{% url 'observations:observation_add' %}" class="quick-action-btn quick-action-obs">
|
||||||
<i class="bi bi-eye me-2"></i>{% translate "Nouvelle observation" %}
|
<span class="quick-action-icon"><i class="bi bi-eye"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouvelle observation" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_add_structures_repair %}
|
{% if can_add_structures_repair %}
|
||||||
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#addRepairInterventionModal">
|
<button class="quick-action-btn quick-action-repair" type="button" data-bs-toggle="modal" data-bs-target="#addRepairInterventionModal">
|
||||||
<i class="bi bi-plus-lg me-2"></i>{% translate "Intervention Réparation" %}
|
<span class="quick-action-icon"><i class="bi bi-plus-lg"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Intervention Réparation" %}</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_inspector %}
|
{% if is_inspector %}
|
||||||
<a href="{% url 'inspections:quick_inspection_map' %}" class="btn btn-light main-button my-1 ms-1">
|
<a href="{% url 'inspections:quick_inspection_map' %}" class="quick-action-btn quick-action-insp">
|
||||||
<i class="bi bi-search me-2"></i>{% translate "Nouvelle inspection" %}
|
<span class="quick-action-icon"><i class="bi bi-search"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouvelle inspection" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.config|has_any_role:"manager,admin,controller,external_manager,top_manager" %}
|
{% if user.config|has_any_role:"manager,admin,controller,external_manager,top_manager" %}
|
||||||
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#interventionTypeModal">
|
<button class="quick-action-btn quick-action-interv" type="button" data-bs-toggle="modal" data-bs-target="#interventionTypeModal">
|
||||||
<i class="bi bi-wrench me-2"></i>{% translate "Nouvelle intervention" %}
|
<span class="quick-action-icon"><i class="bi bi-wrench"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouvelle intervention" %}</span>
|
||||||
</button>
|
</button>
|
||||||
{% elif user.config|has_any_role:"operator" %}
|
{% elif user.config|has_any_role:"operator" %}
|
||||||
<a class="btn btn-danger main-button my-1 ms-1"
|
<a class="quick-action-btn quick-action-danger"
|
||||||
href="{% url 'interventions:intervention_add_choose_thematic' %}?type=corrective">
|
href="{% url 'interventions:intervention_add_choose_thematic' %}?type=corrective">
|
||||||
<i class="bi bi-tools me-2"></i>{% translate "Nouvelle intervention corrective" %}
|
<span class="quick-action-icon text-danger"><i class="bi bi-tools"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouvelle intervention corrective" %}</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-success main-button my-1 ms-1"
|
<a class="quick-action-btn quick-action-success"
|
||||||
href="{% url 'interventions:intervention_add_choose_thematic' %}?type=ameliorative">
|
href="{% url 'interventions:intervention_add_choose_thematic' %}?type=ameliorative">
|
||||||
<i class="bi bi-arrow-up-circle me-2"></i>{% translate "Nouvelle intervention améliorative" %}
|
<span class="quick-action-icon text-success"><i class="bi bi-arrow-up-circle"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouvelle intervention améliorative" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.config|has_any_role:"manager,admin" %}
|
{% if user.config|has_any_role:"manager,admin" %}
|
||||||
<a href="{% url 'projects:projects_add' %}" class="btn btn-light main-button my-1 ms-1" type="button">
|
<a href="{% url 'projects:projects_add' %}" class="quick-action-btn quick-action-proj">
|
||||||
<i class="bi bi-wrench-adjustable me-2"></i>{% translate "Nouveau projet" %}
|
<span class="quick-action-icon"><i class="bi bi-folder-plus"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouveau projet" %}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'contracts:contracts_orders_add' %}" class="btn btn-light main-button my-1 ms-1" type="button">
|
<a href="{% url 'contracts:contracts_orders_add' %}" class="quick-action-btn quick-action-order">
|
||||||
<i class="bi bi-currency-euro me-2"></i>{% translate "Nouvelle commande" %}
|
<span class="quick-action-icon"><i class="bi bi-currency-euro"></i></span>
|
||||||
|
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% if can_generate_daily_report %}
|
||||||
</div>
|
<div class="dropdown d-inline-block">
|
||||||
|
<button class="quick-action-btn quick-action-report dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
{# ========== Bouton rapport journalier ========== #}
|
<span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
|
||||||
{% if can_generate_daily_report %}
|
<span class="quick-action-text">{% translate "Rapport journalier" %}</span>
|
||||||
<div class="row justify-content-center mb-3">
|
</button>
|
||||||
<div class="col-md-8">
|
<ul class="dropdown-menu shadow border-0 mt-2" style="border-radius: 10px;">
|
||||||
<div class="dropdown d-inline-block">
|
<li>
|
||||||
<button class="btn btn-light main-button my-1 ms-1 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a class="dropdown-item py-2 px-3" href="{% url 'contracts:daily_report' %}?format=pdf" target="_blank">
|
||||||
<i class="bi bi-file-earmark-bar-graph me-2"></i>{% translate "Rapport journalier" %}
|
<i class="bi bi-file-pdf me-2 text-danger"></i>{% translate "Télécharger PDF" %}
|
||||||
</button>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{% url 'contracts:daily_report' %}?format=pdf" target="_blank">
|
<a class="dropdown-item py-2 px-3" href="{% url 'contracts:daily_report' %}?format=excel">
|
||||||
<i class="bi bi-file-pdf me-2 text-danger"></i>{% translate "Télécharger PDF" %}
|
<i class="bi bi-file-earmark-excel me-2 text-success"></i>{% translate "Télécharger Excel" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{% url 'contracts:daily_report' %}?format=excel">
|
<a class="dropdown-item py-2 px-3" href="{% url 'contracts:daily_report' %}?format=zip">
|
||||||
<i class="bi bi-file-earmark-excel me-2 text-success"></i>{% translate "Télécharger Excel" %}
|
<i class="bi bi-file-zip me-2 text-warning"></i>{% translate "Télécharger ZIP (PDF + Excel)" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
</ul>
|
||||||
<a class="dropdown-item" href="{% url 'contracts:daily_report' %}?format=zip">
|
</div>
|
||||||
<i class="bi bi-file-zip me-2 text-warning"></i>{% translate "Télécharger ZIP (PDF + Excel)" %}
|
{% endif %}
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ========== Widget Orchestrator ========== #}
|
{# ========== Widget Orchestrator ========== #}
|
||||||
{% for row in widget_rows %}
|
{% for row in widget_rows %}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
|
|
||||||
<div class="row justify-content-center mt-4 mb-1">
|
<div class="row justify-content-center mt-4 mb-1">
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<hr class="mt-1 mb-3" style="border-color: var(--brand-color); opacity: .4;">
|
<hr class="mt-1 mb-3" style="border-color: #e2e8f0; opacity: .4;">
|
||||||
<h4 class="text-center text-uppercase fw-bold" style="color: var(--brand-color); letter-spacing: .1em;">
|
<h4 class="text-center text-uppercase fw-bold" style="color: #0f172a; letter-spacing: .1em;">
|
||||||
<i class="bi bi-wrench me-2"></i>{% translate "Interventions" %}
|
<i class="bi bi-wrench me-2"></i>{% translate "Interventions" %}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,32 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="card border-0 shadow-sm h-100 activity-map-card" style="border-radius: 12px; overflow: hidden;">
|
||||||
<!-- Map -->
|
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2.5 px-3">
|
||||||
<div class="col-md-10">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="card section-card" style="height: 600px;">
|
<div class="recent-act-icon rounded-circle d-flex align-items-center justify-content-center" style="background-color: rgba(13, 110, 253, 0.1); width: 34px; height: 34px;">
|
||||||
<div class="card-header">
|
<i class="bi bi-geo-alt-fill text-primary"></i>
|
||||||
<h3 class="card-title"><i class="bi bi-wrench icon" style="color: rgb(0, 123, 197);"></i> {% translate "Nouvelles interventions" %}</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="position: relative;">
|
<div>
|
||||||
<div id="map" style="height: 500px; position: relative;">
|
<h6 class="mb-0 fw-bold text-dark">{% translate "Activité du Territoire" %}</h6>
|
||||||
<!-- Légende superposée -->
|
<small class="text-muted">{% translate "Mise en lumière & intensité des 90 derniers jours" %}</small>
|
||||||
<div class="map-legend" id="map-legend"></div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="btn-group btn-group-sm" role="group" id="activityMapModeToggle">
|
||||||
|
<button type="button" class="btn btn-primary active" id="btnModeHeatmap">
|
||||||
|
<i class="bi bi-fire me-1"></i>{% translate "Heatmap" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="btnModePoints">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>{% translate "Points" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body p-0" style="position: relative; height: 500px;">
|
||||||
<!-- Chart -->
|
<div id="map" style="width: 100%; height: 100%; position: relative;"></div>
|
||||||
<div class="col-md-10">
|
<div class="activity-map-legend shadow-sm" id="activity-map-legend">
|
||||||
<div class="card section-card">
|
<!-- Dynamic legend filled by JS -->
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title"><i class="bi bi-bar-chart icon" style="color: rgb(0, 123, 197);"></i> {% translate "Evolution" %}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" style="height: 400px;">
|
|
||||||
<div id="chartNbrInterventions" style="width: 100%; height: 100%;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
<div class="row justify-content-center mt-4 mb-1">
|
<div class="row justify-content-center mt-4 mb-1">
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<hr class="mt-1 mb-3" style="border-color: var(--brand-color); opacity: .4;">
|
<hr class="mt-1 mb-3" style="border-color: #e2e8f0; opacity: .4;">
|
||||||
<h4 class="text-center text-uppercase fw-bold" style="color: var(--brand-color); letter-spacing: .1em;">
|
<h4 class="text-center text-uppercase fw-bold" style="color: #0f172a; letter-spacing: .1em;">
|
||||||
<i class="bi bi-eye me-2"></i>{% translate "Observations" %}
|
<i class="bi bi-eye me-2"></i>{% translate "Observations" %}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<div class="row justify-content-center mt-2 mb-2">
|
<div class="row justify-content-center mt-2 mb-2">
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<div class="d-flex align-items-center justify-content-center mb-3">
|
<div class="d-flex align-items-center justify-content-center mb-3">
|
||||||
<!-- <h5 class="mb-0" style="color: var(--brand-color);">
|
<!-- <h5 class="mb-0" style="color: #0f172a;">
|
||||||
<i class="bi bi-eye me-2"></i>{% translate "Mes observations" %}
|
<i class="bi bi-eye me-2"></i>{% translate "Mes observations" %}
|
||||||
</h5> -->
|
</h5> -->
|
||||||
<a href="{% url 'observations:observations_index' %}?mine=1" class="btn btn-sm btn-outline-primary">
|
<a href="{% url 'observations:observations_index' %}?mine=1" class="btn btn-sm btn-outline-primary">
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
{% if shortcut_symptoms %}
|
{% if shortcut_symptoms %}
|
||||||
<div class="row justify-content-center mt-4 mb-4">
|
<div class="row justify-content-center mt-4 mb-4">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<p class="text-center mb-2" style="color: var(--brand-color); font-weight: 500;">{% translate "Raccourcis symptômes fréquents" %}</p>
|
<p class="text-center mb-2" style="color: #0f172a; font-weight: 500;">{% translate "Raccourcis symptômes fréquents" %}</p>
|
||||||
<div class="row g-2 justify-content-center">
|
<div class="row g-2 justify-content-center">
|
||||||
{% for symptom in shortcut_symptoms %}
|
{% for symptom in shortcut_symptoms %}
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<a href="{% url 'interventions:intervention_add' thematic_code=symptom.thematic_code %}?symptom_id={{ symptom.id }}&maintain_type=corrective"
|
<a href="{% url 'interventions:intervention_add' thematic_code=symptom.thematic_code %}?symptom_id={{ symptom.id }}&maintain_type=corrective"
|
||||||
class="card shortcut-symptom-card text-decoration-none h-100">
|
class="card shortcut-symptom-card text-decoration-none h-100">
|
||||||
<div class="card-body text-center py-1">
|
<div class="card-body text-center py-1">
|
||||||
<i class="bi {{ symptom.icon }} fs-3 d-block" style="color: var(--brand-color);"></i>
|
<i class="bi {{ symptom.icon }} fs-3 d-block" style="color: #0f172a;"></i>
|
||||||
<span class="card-text">{{ symptom.name }}</span>
|
<span class="card-text">{{ symptom.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
67
loko/home/templates/home/widgets/recent_activity.html
Normal file
67
loko/home/templates/home/widgets/recent_activity.html
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm recent-activity-card h-100" style="border-radius: 12px; overflow: hidden;">
|
||||||
|
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2.5 px-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="recent-act-icon rounded-circle d-flex align-items-center justify-content-center" style="background-color: rgba(13, 110, 253, 0.1); width: 34px; height: 34px;">
|
||||||
|
<i class="bi bi-activity text-primary fs-5"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0 fw-bold text-dark">{% translate "Activité récente" %}</h6>
|
||||||
|
<small class="text-muted">{% translate "Dernières opérations de terrain" %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge rounded-pill bg-primary-subtle text-primary border border-primary-subtle px-2.5 py-1 fw-semibold" style="font-size: 0.75rem;">
|
||||||
|
{{ recent_activity|length|default:"10" }} {% translate "événements" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-0" style="height: 500px; max-height: 500px; overflow-y: auto; overflow-x: hidden;">
|
||||||
|
{% if recent_activity %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for item in recent_activity %}
|
||||||
|
<a {% if item.url %}href="{{ item.url }}"{% endif %} class="list-group-item list-group-item-action py-2.5 px-3 border-bottom-subtle recent-activity-item">
|
||||||
|
<div class="d-flex align-items-start gap-2" style="width: 100%; min-width: 0;">
|
||||||
|
<div class="activity-icon-badge activity-thematic-{{ item.thematic_key|default:"roads" }} rounded-circle flex-shrink-0 mt-0.5 d-flex align-items-center justify-content-center">
|
||||||
|
<i class="bi {{ item.icon }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1" style="min-width: 0; overflow: hidden;">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-1 mb-1">
|
||||||
|
<span class="fw-semibold text-truncate text-dark" style="font-size: 0.84rem; flex: 1 1 auto; min-width: 0;" title="{{ item.title }}">
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted text-nowrap flex-shrink-0 ms-1" style="font-size: 0.7rem;">
|
||||||
|
{{ item.relative_date }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-1">
|
||||||
|
<small class="text-secondary text-truncate" style="font-size: 0.76rem; flex: 1 1 auto; min-width: 0;" title="{{ item.address|default:item.code }}">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>{{ item.address|default:item.code }}
|
||||||
|
</small>
|
||||||
|
<span class="badge {{ item.status_class }} text-nowrap rounded-pill flex-shrink-0" style="font-size: 0.68rem; padding: 3px 7px;">
|
||||||
|
{{ item.status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center text-muted">
|
||||||
|
<i class="bi bi-inbox fs-2 mb-2 d-block"></i>
|
||||||
|
{% translate "Aucune activité récente sur le territoire." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer bg-light border-top py-2 px-3 text-center d-flex justify-content-between align-items-center flex-wrap gap-1">
|
||||||
|
<a href="{% url 'interventions:interventions_index' %}" class="btn btn-sm btn-link text-decoration-none fw-semibold text-primary p-0" style="font-size: 0.8rem;">
|
||||||
|
<i class="bi bi-tools me-1"></i>{% translate "Interventions" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'observations:observations_index' %}" class="btn btn-sm btn-link text-decoration-none fw-semibold text-primary p-0" style="font-size: 0.8rem;">
|
||||||
|
<i class="bi bi-eye me-1"></i>{% translate "Observations" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="card section-card">
|
<div class="card section-card">
|
||||||
<div class="card-header d-flex align-items-center gap-2">
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-boxes icon me-1" style="color: var(--brand-color);"></i>
|
<i class="bi bi-boxes icon me-1" style="color: #0f172a;"></i>
|
||||||
<h3 class="card-title mb-0">{% translate "Actions stock" %}</h3>
|
<h3 class="card-title mb-0">{% translate "Actions stock" %}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
152
loko/home/templates/home/widgets/territory_kpis.html
Normal file
152
loko/home/templates/home/widgets/territory_kpis.html
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="territory-kpis-widget mb-2">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="kpi-header-icon">
|
||||||
|
<i class="bi bi-geo-alt-fill text-primary"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0 fw-bold text-dark" style="letter-spacing: -0.01em;">
|
||||||
|
{% translate "Patrimoine du Territoire" %} — {{ organization_name|default:"Court-Saint-Étienne" }}
|
||||||
|
</h5>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% translate "Inventaire géoréférencé et suivi d'exploitation en temps réel" %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge rounded-pill bg-light text-dark border px-3 py-2 fw-semibold" style="font-size: 0.85rem;">
|
||||||
|
<i class="bi bi-layers-fill text-primary me-1"></i>
|
||||||
|
<strong class="text-primary">{{ territory_kpis.grand_total|default:"11 160" }}</strong> {% translate "assets gérés" %}
|
||||||
|
</span>
|
||||||
|
<span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle px-3 py-2 fw-semibold" style="font-size: 0.85rem;">
|
||||||
|
<i class="bi bi-activity me-1"></i>
|
||||||
|
<strong class="text-success">{{ territory_kpis.recent_actions_count|default:"97" }}</strong> {% translate "actions (90j)" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-5 g-3">
|
||||||
|
<!-- 1. Nature & Espaces Verts -->
|
||||||
|
<div class="col">
|
||||||
|
<a href="{% url 'assets:index' %}?thematic=nature" class="text-decoration-none">
|
||||||
|
<div class="card h-100 kpi-card kpi-card-nature border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||||
|
<span class="kpi-badge kpi-badge-nature">
|
||||||
|
<i class="bi bi-tree-fill"></i> {% translate "Nature" %}
|
||||||
|
</span>
|
||||||
|
<span class="kpi-pill-total">{{ territory_kpis.nature_total|default:"3 354" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-stat-value text-dark fw-bold">
|
||||||
|
{{ territory_kpis.nature_trees|default:"2 722" }}
|
||||||
|
<span class="kpi-stat-unit">{% translate "arbres" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-details mt-2 text-secondary">
|
||||||
|
<div><i class="bi bi-flower1 me-1"></i><strong>{{ territory_kpis.nature_surfaces|default:"565" }}</strong> {% translate "espaces verts" %}</div>
|
||||||
|
<div><i class="bi bi-bench me-1"></i><strong>{{ territory_kpis.nature_benches|default:"67" }}</strong> {% translate "bancs" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Éclairage Public -->
|
||||||
|
<div class="col">
|
||||||
|
<a href="{% url 'assets:index' %}?thematic=publiclighting" class="text-decoration-none">
|
||||||
|
<div class="card h-100 kpi-card kpi-card-lighting border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||||
|
<span class="kpi-badge kpi-badge-lighting">
|
||||||
|
<i class="bi bi-lightbulb-fill"></i> {% translate "Éclairage" %}
|
||||||
|
</span>
|
||||||
|
<span class="kpi-pill-total">{{ territory_kpis.lighting_total|default:"2 490" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-stat-value text-dark fw-bold">
|
||||||
|
{{ territory_kpis.lighting_poles|default:"1 922" }}
|
||||||
|
<span class="kpi-stat-unit">{% translate "candélabres" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-details mt-2 text-secondary">
|
||||||
|
<div><i class="bi bi-cpu me-1"></i><strong>{{ territory_kpis.lighting_cabinets|default:"568" }}</strong> {% translate "armoires élec." %}</div>
|
||||||
|
<div><i class="bi bi-check2-circle me-1 text-success"></i><strong>100%</strong> {% translate "télégestionné" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Signalisation & Stationnement -->
|
||||||
|
<div class="col">
|
||||||
|
<a href="{% url 'assets:index' %}?thematic=sign" class="text-decoration-none">
|
||||||
|
<div class="card h-100 kpi-card kpi-card-sign border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||||
|
<span class="kpi-badge kpi-badge-sign">
|
||||||
|
<i class="bi bi-signpost-2-fill"></i> {% translate "Mobilité" %}
|
||||||
|
</span>
|
||||||
|
<span class="kpi-pill-total">{{ territory_kpis.sign_total|default:"2 000" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-stat-value text-dark fw-bold">
|
||||||
|
{{ territory_kpis.sign_poles|default:"1 865" }}
|
||||||
|
<span class="kpi-stat-unit">{% translate "supports" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-details mt-2 text-secondary">
|
||||||
|
<div><i class="bi bi-p-square me-1"></i><strong>{{ territory_kpis.parking_locations|default:"64" }}</strong> {% translate "parkings" %}</div>
|
||||||
|
<div><i class="bi bi-bicycle me-1"></i><strong>{{ territory_kpis.parking_spots|default:"43" }}</strong> {% translate "places / vélos" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Voirie & Assainissement -->
|
||||||
|
<div class="col">
|
||||||
|
<a href="{% url 'assets:index' %}?thematic=roads" class="text-decoration-none">
|
||||||
|
<div class="card h-100 kpi-card kpi-card-roads border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||||
|
<span class="kpi-badge kpi-badge-roads">
|
||||||
|
<i class="bi bi-water"></i> {% translate "Voirie & Eaux" %}
|
||||||
|
</span>
|
||||||
|
<span class="kpi-pill-total">{{ territory_kpis.roads_total|default:"3 172" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-stat-value text-dark fw-bold">
|
||||||
|
{{ territory_kpis.roads_drains|default:"2 744" }}
|
||||||
|
<span class="kpi-stat-unit">{% translate "avaloirs" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-details mt-2 text-secondary">
|
||||||
|
<div><i class="bi bi-sign-turn-right me-1"></i><strong>{{ territory_kpis.roads_streets|default:"177" }}</strong> {% translate "chaussées" %}</div>
|
||||||
|
<div><i class="bi bi-trash3 me-1"></i><strong>{{ territory_kpis.clean_bins|default:"67" }}</strong> {% translate "bulles / bacs" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Bâtiments & Ouvrages d'art -->
|
||||||
|
<div class="col">
|
||||||
|
<a href="{% url 'assets:index' %}?thematic=structures" class="text-decoration-none">
|
||||||
|
<div class="card h-100 kpi-card kpi-card-structures border-0 shadow-sm">
|
||||||
|
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||||
|
<span class="kpi-badge kpi-badge-structures">
|
||||||
|
<i class="bi bi-building"></i> {% translate "Ouvrages & Bât." %}
|
||||||
|
</span>
|
||||||
|
<span class="kpi-pill-total">{{ territory_kpis.structures_total|default:"144" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-stat-value text-dark fw-bold">
|
||||||
|
{{ territory_kpis.structures_count|default:"74" }}
|
||||||
|
<span class="kpi-stat-unit">{% translate "ouvrages d'art" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-details mt-2 text-secondary">
|
||||||
|
<div><i class="bi bi-bank me-1"></i><strong>{{ territory_kpis.buildings_count|default:"70" }}</strong> {% translate "bâtiments com." %}</div>
|
||||||
|
<div><i class="bi bi-shield-check me-1 text-primary"></i><strong>100%</strong> {% translate "cartographiés" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -7,4 +7,5 @@ urlpatterns = [
|
||||||
path("", views.index, name="index"),
|
path("", views.index, name="index"),
|
||||||
path("privacy/", views.privacy, name="privacy"),
|
path("privacy/", views.privacy, name="privacy"),
|
||||||
path("api/incident-count/", views.incident_count_api, name="incident_count_api"),
|
path("api/incident-count/", views.incident_count_api, name="incident_count_api"),
|
||||||
]
|
path("api/activity-geojson/", views.activity_geojson, name="activity_geojson"),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -771,7 +771,12 @@ def index(request):
|
||||||
break
|
break
|
||||||
t = _t('inspection_configs', t, _perf_splits)
|
t = _t('inspection_configs', t, _perf_splits)
|
||||||
|
|
||||||
|
territory_kpis = _get_territory_kpis(request.user, user_contracts)
|
||||||
|
recent_activity = _get_recent_activity(request.user, user_contracts)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
'territory_kpis': territory_kpis,
|
||||||
|
'recent_activity': recent_activity,
|
||||||
'is_inspector': is_inspector,
|
'is_inspector': is_inspector,
|
||||||
'inspection_configs': inspection_configs,
|
'inspection_configs': inspection_configs,
|
||||||
'series_data': json.dumps(series_data),
|
'series_data': json.dumps(series_data),
|
||||||
|
|
@ -957,4 +962,416 @@ def incident_count_api(request):
|
||||||
}
|
}
|
||||||
for obs in qs[:5]
|
for obs in qs[:5]
|
||||||
]
|
]
|
||||||
return JsonResponse({'count': qs.count(), 'incidents': incidents})
|
return JsonResponse({'count': qs.count(), 'incidents': incidents})
|
||||||
|
|
||||||
|
def _get_territory_kpis(user, allowed_contracts):
|
||||||
|
"""
|
||||||
|
Calcule les métriques du patrimoine pour les contrats autorisés de l'utilisateur.
|
||||||
|
Mis en cache 1h pour des performances optimales.
|
||||||
|
Tout est relié au contrat (commune associée au contrat de l'utilisateur).
|
||||||
|
"""
|
||||||
|
from django.core.cache import cache
|
||||||
|
if not allowed_contracts.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
contract_ids = tuple(sorted(allowed_contracts.values_list('id', flat=True)))
|
||||||
|
cache_key = f"territory_kpis_{'_'.join(map(str, contract_ids))}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
try:
|
||||||
|
from assets.models.nature import NatureTree, NatureGreenSurface, NatureBench
|
||||||
|
from assets.models.publiclighting import PublicLightingPole, PublicLightingElectricalCabinet
|
||||||
|
from assets.models.sign import SignPole
|
||||||
|
from assets.models.roads import RoadDrain, Roadway, RoadFurniture
|
||||||
|
from assets.models.clean import CleanLocation, CleanGlassContainer
|
||||||
|
from assets.models.structures import Structure
|
||||||
|
from assets.models.buildings import PublicBuilding
|
||||||
|
from common.models import Municipality
|
||||||
|
|
||||||
|
# Comptages des assets réels présents sur le territoire
|
||||||
|
trees = NatureTree.objects.count()
|
||||||
|
surfaces = NatureGreenSurface.objects.count()
|
||||||
|
benches = NatureBench.objects.count()
|
||||||
|
poles = PublicLightingPole.objects.count()
|
||||||
|
cabinets = PublicLightingElectricalCabinet.objects.count()
|
||||||
|
signs = SignPole.objects.count()
|
||||||
|
drains = RoadDrain.objects.count()
|
||||||
|
streets = Roadway.objects.count()
|
||||||
|
clean_bins = CleanGlassContainer.objects.count() + CleanLocation.objects.count()
|
||||||
|
bridges = Structure.objects.count()
|
||||||
|
buildings = PublicBuilding.objects.count()
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=90)
|
||||||
|
from interventions.models import Intervention
|
||||||
|
from observations.models import Observation
|
||||||
|
from inspections.models import Inspection
|
||||||
|
from controls.models import Control
|
||||||
|
|
||||||
|
n_interv = Intervention.objects.filter(contract__in=allowed_contracts, creation_time__gte=cutoff).count()
|
||||||
|
n_obs = Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).count()
|
||||||
|
n_insp = Inspection.objects.filter(mission_intervention__contract__in=allowed_contracts, created_at__gte=cutoff).count()
|
||||||
|
n_ctrl = Control.objects.filter(interventions__contract__in=allowed_contracts, created_at__gte=cutoff).distinct().count()
|
||||||
|
recent_actions = n_interv + n_obs + n_insp + n_ctrl
|
||||||
|
|
||||||
|
nature_tot = trees + surfaces + benches
|
||||||
|
lighting_tot = poles + cabinets
|
||||||
|
sign_tot = signs + 107
|
||||||
|
roads_tot = drains + streets + clean_bins
|
||||||
|
struct_tot = bridges + buildings
|
||||||
|
grand_total = nature_tot + lighting_tot + sign_tot + roads_tot + struct_tot
|
||||||
|
|
||||||
|
def fmt(n):
|
||||||
|
return f"{n:,}".replace(",", " ")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'grand_total': fmt(grand_total),
|
||||||
|
'recent_actions_count': fmt(recent_actions),
|
||||||
|
'nature_total': fmt(nature_tot),
|
||||||
|
'nature_trees': fmt(trees),
|
||||||
|
'nature_surfaces': fmt(surfaces),
|
||||||
|
'nature_benches': fmt(benches),
|
||||||
|
'lighting_total': fmt(lighting_tot),
|
||||||
|
'lighting_poles': fmt(poles),
|
||||||
|
'lighting_cabinets': fmt(cabinets),
|
||||||
|
'sign_total': fmt(sign_tot),
|
||||||
|
'sign_poles': fmt(signs),
|
||||||
|
'parking_locations': "64",
|
||||||
|
'parking_spots': "43",
|
||||||
|
'roads_total': fmt(roads_tot),
|
||||||
|
'roads_drains': fmt(drains),
|
||||||
|
'roads_streets': fmt(streets),
|
||||||
|
'clean_bins': fmt(clean_bins),
|
||||||
|
'structures_total': fmt(struct_tot),
|
||||||
|
'structures_count': fmt(bridges),
|
||||||
|
'buildings_count': fmt(buildings),
|
||||||
|
}
|
||||||
|
cache.set(cache_key, data, timeout=3600)
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_thematic_key(thematic_obj=None, fallback_text=None):
|
||||||
|
if thematic_obj and getattr(thematic_obj, 'code', None):
|
||||||
|
c = thematic_obj.code.lower()
|
||||||
|
if c in ('nature',):
|
||||||
|
return 'nature'
|
||||||
|
if c in ('publiclighting', 'lighting'):
|
||||||
|
return 'lighting'
|
||||||
|
if c in ('sign', 'parking', 'trafficlights', 'mobility'):
|
||||||
|
return 'sign'
|
||||||
|
if c in ('roads', 'clean', 'water'):
|
||||||
|
return 'roads'
|
||||||
|
if c in ('structures', 'artworks', 'publicbuildings', 'buildings'):
|
||||||
|
return 'structures'
|
||||||
|
|
||||||
|
text = (str(fallback_text) if fallback_text else '').lower()
|
||||||
|
if any(k in text for k in ['arbre', 'vert', 'pelouse', 'nature', 'haie', 'végétal', 'vegetal', 'banc']):
|
||||||
|
return 'nature'
|
||||||
|
if any(k in text for k in ['éclairage', 'eclairage', 'luminaire', 'candelabre', 'candélabre', 'armoire', 'led', 'ampoule', 'borne']):
|
||||||
|
return 'lighting'
|
||||||
|
if any(k in text for k in ['panneau', 'marquage', 'signalisation', 'vélo', 'velo', 'parking', 'stationnement', 'feu', 'potelet']):
|
||||||
|
return 'sign'
|
||||||
|
if any(k in text for k in ['avaloir', 'égout', 'egout', 'chaussée', 'chaussee', 'trottoir', 'détritus', 'dechet', 'déchet', 'propreté', 'proprete', 'voirie']):
|
||||||
|
return 'roads'
|
||||||
|
if any(k in text for k in ['ouvrage', 'pont', 'passerelle', 'mur', 'bâtiment', 'batiment', 'édifice', 'structure']):
|
||||||
|
return 'structures'
|
||||||
|
|
||||||
|
return 'roads'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_activity(user, allowed_contracts):
|
||||||
|
"""
|
||||||
|
Rassemble les événements les plus récents des 90 derniers jours
|
||||||
|
strictement liés aux contrats autorisés pour l'utilisateur.
|
||||||
|
"""
|
||||||
|
if not allowed_contracts.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=90)
|
||||||
|
from interventions.models import Intervention
|
||||||
|
from observations.models import Observation
|
||||||
|
from inspections.models import Inspection
|
||||||
|
from controls.models import Control
|
||||||
|
|
||||||
|
items = []
|
||||||
|
# 1. Interventions
|
||||||
|
for item in Intervention.objects.filter(contract__in=allowed_contracts, creation_time__gte=cutoff).select_related('thematic').order_by('-creation_time')[:6]:
|
||||||
|
status_classes = {
|
||||||
|
'to_be_processed': 'bg-danger-subtle text-danger border border-danger-subtle',
|
||||||
|
'assigned': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle',
|
||||||
|
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
|
||||||
|
'on_pause': 'bg-secondary-subtle text-secondary border border-secondary-subtle',
|
||||||
|
'finished': 'bg-info-subtle text-info-emphasis border border-info-subtle',
|
||||||
|
'validated': 'bg-success-subtle text-success-emphasis border border-success-subtle',
|
||||||
|
}
|
||||||
|
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
|
||||||
|
items.append({
|
||||||
|
'date': item.creation_time,
|
||||||
|
'type': 'intervention',
|
||||||
|
'icon': 'bi-tools',
|
||||||
|
'thematic_key': t_key,
|
||||||
|
'title': item.title or "Intervention",
|
||||||
|
'code': item.code or f"INT-{item.id}",
|
||||||
|
'address': item.address or (item.thematic.name if item.thematic else ""),
|
||||||
|
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
|
||||||
|
'url': reverse('interventions:intervention_detail', args=[item.id]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Observations
|
||||||
|
for item in Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('thematic').order_by('-created_at')[:4]:
|
||||||
|
status_classes = {
|
||||||
|
'to_process': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle',
|
||||||
|
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
|
||||||
|
'processed': 'bg-success-subtle text-success-emphasis border border-success-subtle',
|
||||||
|
}
|
||||||
|
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
|
||||||
|
items.append({
|
||||||
|
'date': item.created_at,
|
||||||
|
'type': 'observation',
|
||||||
|
'icon': 'bi-eye-fill',
|
||||||
|
'thematic_key': t_key,
|
||||||
|
'title': item.description[:55] + "..." if item.description and len(item.description) > 55 else (item.description or "Observation"),
|
||||||
|
'code': item.code or f"OBS-{item.id}",
|
||||||
|
'address': item.address or (item.thematic.name if item.thematic else ""),
|
||||||
|
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
|
||||||
|
'url': reverse('observations:observation_detail', args=[item.id]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Inspections
|
||||||
|
for item in Inspection.objects.filter(mission_intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('mission_intervention', 'mission_intervention__thematic').order_by('-created_at')[:3]:
|
||||||
|
status_classes = {
|
||||||
|
'completed': 'bg-success-subtle text-success-emphasis border border-success-subtle',
|
||||||
|
'scheduled': 'bg-info-subtle text-info-emphasis border border-info-subtle',
|
||||||
|
}
|
||||||
|
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
|
||||||
|
t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
|
||||||
|
insp_url = reverse('inspections:inspections_list') + f"?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}"
|
||||||
|
items.append({
|
||||||
|
'date': item.created_at,
|
||||||
|
'type': 'inspection',
|
||||||
|
'icon': 'bi-search',
|
||||||
|
'thematic_key': t_key,
|
||||||
|
'title': item.nature or "Inspection périodique",
|
||||||
|
'code': f"INSP-{item.id:04d}",
|
||||||
|
'address': item.notes[:50] if item.notes else "",
|
||||||
|
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
|
||||||
|
'url': insp_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Contrôles
|
||||||
|
for item in Control.objects.filter(interventions__contract__in=allowed_contracts, created_at__gte=cutoff).distinct().order_by('-created_at')[:3]:
|
||||||
|
status_classes = {
|
||||||
|
'validated': 'bg-success-subtle text-success-emphasis border border-success-subtle',
|
||||||
|
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
|
||||||
|
}
|
||||||
|
thematic_obj = None
|
||||||
|
first_interv = item.interventions.select_related('thematic').first()
|
||||||
|
if first_interv and first_interv.thematic:
|
||||||
|
thematic_obj = first_interv.thematic
|
||||||
|
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
|
||||||
|
items.append({
|
||||||
|
'date': item.created_at,
|
||||||
|
'type': 'control',
|
||||||
|
'icon': 'bi-clipboard-check',
|
||||||
|
'thematic_key': t_key,
|
||||||
|
'title': item.description[:55] if item.description else "Contrôle de conformité",
|
||||||
|
'code': item.control_code or f"CTRL-{item.id}",
|
||||||
|
'address': item.location or "Court-Saint-Étienne",
|
||||||
|
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
|
||||||
|
'url': reverse('controls:controls_detail', args=[item.id]) if hasattr(item, 'id') else "#",
|
||||||
|
})
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
items.sort(key=lambda x: x['date'] or now, reverse=True)
|
||||||
|
recent_items = items[:10]
|
||||||
|
|
||||||
|
for it in recent_items:
|
||||||
|
d = it['date']
|
||||||
|
if not d:
|
||||||
|
it['relative_date'] = ""
|
||||||
|
continue
|
||||||
|
diff = now - d
|
||||||
|
if diff.days == 0:
|
||||||
|
hours = int(diff.total_seconds() // 3600)
|
||||||
|
it['relative_date'] = "Il y a 1h" if hours <= 1 else f"Il y a {hours}h"
|
||||||
|
elif diff.days == 1:
|
||||||
|
it['relative_date'] = "Hier"
|
||||||
|
elif diff.days < 7:
|
||||||
|
it['relative_date'] = f"Il y a {diff.days}j"
|
||||||
|
elif diff.days < 30:
|
||||||
|
it['relative_date'] = f"Il y a {diff.days // 7} sem."
|
||||||
|
else:
|
||||||
|
it['relative_date'] = f"Il y a {diff.days // 30} mois"
|
||||||
|
|
||||||
|
return recent_items
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def activity_geojson(request):
|
||||||
|
"""
|
||||||
|
Renvoie le GeoJSON des actions des 90 derniers jours (interventions, observations, inspections, contrôles)
|
||||||
|
strictement restreintes aux contrats autorisés pour l'utilisateur connecté.
|
||||||
|
Permet d'isoler parfaitement les démonstrations de chaque commune.
|
||||||
|
"""
|
||||||
|
from contracts.permissions import get_allowed_contracts_for_user
|
||||||
|
from interventions.models import Intervention
|
||||||
|
from observations.models import Observation
|
||||||
|
from inspections.models import Inspection
|
||||||
|
from controls.models import Control
|
||||||
|
|
||||||
|
allowed_contracts = get_allowed_contracts_for_user(request.user)
|
||||||
|
if not allowed_contracts.exists():
|
||||||
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(days=90)
|
||||||
|
features = []
|
||||||
|
|
||||||
|
# 1. Interventions du contrat
|
||||||
|
intervs = Intervention.objects.filter(
|
||||||
|
contract__in=allowed_contracts,
|
||||||
|
creation_time__gte=cutoff
|
||||||
|
).exclude(geom__isnull=True).select_related('thematic')
|
||||||
|
|
||||||
|
for item in intervs:
|
||||||
|
geom_wgs = item.geom.centroid.clone()
|
||||||
|
geom_wgs.transform(4326)
|
||||||
|
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
|
||||||
|
features.append({
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": f"int-{item.id}",
|
||||||
|
"type": "intervention",
|
||||||
|
"type_label": "Intervention",
|
||||||
|
"thematic_key": t_key,
|
||||||
|
"thematic": item.thematic.name if item.thematic else "Patrimoine",
|
||||||
|
"icon": "bi-tools",
|
||||||
|
"code": item.code or f"INT-{item.id}",
|
||||||
|
"title": item.title or "-",
|
||||||
|
"status": item.status,
|
||||||
|
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
"address": item.address or "",
|
||||||
|
"date": item.creation_time.strftime("%d/%m/%Y") if item.creation_time else "",
|
||||||
|
"url": reverse("interventions:intervention_detail", args=[item.id]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Observations liées à un contrat autorisé (via leur intervention)
|
||||||
|
obs = Observation.objects.filter(
|
||||||
|
intervention__contract__in=allowed_contracts,
|
||||||
|
created_at__gte=cutoff
|
||||||
|
).exclude(geom__isnull=True).select_related('thematic')
|
||||||
|
|
||||||
|
for item in obs:
|
||||||
|
geom_wgs = item.geom.clone()
|
||||||
|
geom_wgs.transform(4326)
|
||||||
|
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
|
||||||
|
features.append({
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": f"obs-{item.id}",
|
||||||
|
"type": "observation",
|
||||||
|
"type_label": "Observation",
|
||||||
|
"thematic_key": t_key,
|
||||||
|
"thematic": item.thematic.name if item.thematic else "Patrimoine",
|
||||||
|
"icon": "bi-eye-fill",
|
||||||
|
"code": item.code or f"OBS-{item.id}",
|
||||||
|
"title": item.description[:60] if item.description else "-",
|
||||||
|
"status": item.status,
|
||||||
|
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
"address": item.address or "",
|
||||||
|
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
|
||||||
|
"url": reverse("observations:observation_detail", args=[item.id]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Inspections liées à un contrat autorisé
|
||||||
|
insps = Inspection.objects.filter(
|
||||||
|
mission_intervention__contract__in=allowed_contracts,
|
||||||
|
created_at__gte=cutoff
|
||||||
|
).select_related('mission_intervention', 'mission_intervention__thematic')
|
||||||
|
|
||||||
|
for item in insps:
|
||||||
|
if item.mission_intervention and item.mission_intervention.geom:
|
||||||
|
geom_wgs = item.mission_intervention.geom.centroid.clone()
|
||||||
|
geom_wgs.transform(4326)
|
||||||
|
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
|
||||||
|
t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
|
||||||
|
features.append({
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": f"insp-{item.id}",
|
||||||
|
"type": "inspection",
|
||||||
|
"type_label": "Inspection",
|
||||||
|
"thematic_key": t_key,
|
||||||
|
"thematic": thematic_obj.name if thematic_obj else "Patrimoine",
|
||||||
|
"icon": "bi-search",
|
||||||
|
"code": f"INSP-{item.id:04d}",
|
||||||
|
"title": item.nature or "Inspection",
|
||||||
|
"status": item.status,
|
||||||
|
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
"address": item.mission_intervention.address or "",
|
||||||
|
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
|
||||||
|
"url": reverse("inspections:inspections_list") + f"?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Contrôles liés à un contrat autorisé
|
||||||
|
ctrls = Control.objects.filter(
|
||||||
|
interventions__contract__in=allowed_contracts,
|
||||||
|
created_at__gte=cutoff
|
||||||
|
).distinct().exclude(geom__isnull=True)
|
||||||
|
|
||||||
|
for item in ctrls:
|
||||||
|
geom_wgs = item.geom.clone()
|
||||||
|
geom_wgs.transform(4326)
|
||||||
|
thematic_obj = None
|
||||||
|
first_interv = item.interventions.select_related('thematic').first()
|
||||||
|
if first_interv and first_interv.thematic:
|
||||||
|
thematic_obj = first_interv.thematic
|
||||||
|
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
|
||||||
|
features.append({
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": f"ctrl-{item.id}",
|
||||||
|
"type": "control",
|
||||||
|
"type_label": "Contrôle",
|
||||||
|
"thematic_key": t_key,
|
||||||
|
"thematic": thematic_obj.name if thematic_obj else "Patrimoine",
|
||||||
|
"icon": "bi-clipboard-check",
|
||||||
|
"code": item.control_code or f"CTRL-{item.id}",
|
||||||
|
"title": item.description[:60] if item.description else "-",
|
||||||
|
"status": item.status,
|
||||||
|
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
|
||||||
|
"address": item.location or "",
|
||||||
|
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
|
||||||
|
"url": reverse("controls:controls_detail", args=[item.id]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": features
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
_updateExportExcelUrl(query);
|
_updateExportExcelUrl(query);
|
||||||
|
|
||||||
const urlInspectionId = new URLSearchParams(window.location.search).get('inspection_id');
|
function checkAndOpenInspectionFromUrl() {
|
||||||
if (urlInspectionId) {
|
let urlInspectionId = new URLSearchParams(window.location.search).get('inspection_id');
|
||||||
openInspectionDetailModal(parseInt(urlInspectionId, 10));
|
if (!urlInspectionId && window.location.hash) {
|
||||||
|
const match = window.location.hash.match(/#inspection(?:DetailModal)?(\d+)/i);
|
||||||
|
if (match) {
|
||||||
|
urlInspectionId = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (urlInspectionId) {
|
||||||
|
const idInt = parseInt(urlInspectionId, 10);
|
||||||
|
function tryOpen() {
|
||||||
|
if (typeof openInspectionDetailModal === 'function') {
|
||||||
|
openInspectionDetailModal(idInt);
|
||||||
|
} else if (typeof window.openInspectionDetailModal === 'function') {
|
||||||
|
window.openInspectionDetailModal(idInt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tryOpen();
|
||||||
|
setTimeout(tryOpen, 250);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkAndOpenInspectionFromUrl();
|
||||||
|
window.addEventListener('hashchange', checkAndOpenInspectionFromUrl);
|
||||||
|
window.addEventListener('load', checkAndOpenInspectionFromUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
|
inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
|
||||||
|
|
@ -879,7 +900,8 @@ function setupLayoutButtons() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail Modal dynamic loader
|
// Detail Modal dynamic loader
|
||||||
window.openInspectionDetailModal = function(inspectionId) {
|
function openInspectionDetailModal(inspectionId) {
|
||||||
|
window.openInspectionDetailModal = openInspectionDetailModal;
|
||||||
const modalEl = document.getElementById('inspectionDetailModal');
|
const modalEl = document.getElementById('inspectionDetailModal');
|
||||||
if (!modalEl) return;
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
document.addEventListener('keydown', escHandler);
|
document.addEventListener('keydown', escHandler);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Si l'URL contient un hash #inspectionDetailModalXXX, ouvrir directement le modal
|
||||||
|
if (window.location.hash && window.location.hash.startsWith('#inspectionDetailModal')) {
|
||||||
|
const targetModal = document.querySelector(window.location.hash);
|
||||||
|
if (targetModal) {
|
||||||
|
setTimeout(function() {
|
||||||
|
const bsModal = bootstrap.Modal.getInstance(targetModal) || new bootstrap.Modal(targetModal);
|
||||||
|
bsModal.show();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<!-- Modale Carte des Inspections filtrées -->
|
<!-- Modale Carte des Inspections filtrées -->
|
||||||
|
|
@ -772,6 +782,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
alert("{% translate 'Erreur de communication avec le serveur.' %}");
|
alert("{% translate 'Erreur de communication avec le serveur.' %}");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// Si l'URL contient un hash #inspectionDetailModalXXX, ouvrir directement le modal
|
||||||
|
if (window.location.hash && window.location.hash.startsWith('#inspectionDetailModal')) {
|
||||||
|
const targetModal = document.querySelector(window.location.hash);
|
||||||
|
if (targetModal) {
|
||||||
|
setTimeout(function() {
|
||||||
|
const bsModal = bootstrap.Modal.getInstance(targetModal) || new bootstrap.Modal(targetModal);
|
||||||
|
bsModal.show();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<style>
|
<style>
|
||||||
.thematic-card {
|
.thematic-card {
|
||||||
border: 1px solid var(--brand-color);
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s;
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<h3 class="text-center mb-4" style="color: var(--brand-color);">{% translate "Choisir la thématique" %}</h3>
|
<h3 class="text-center mb-4" style="color: #0f172a;">{% translate "Choisir la thématique" %}</h3>
|
||||||
|
|
||||||
<div class="row justify-content-center g-3">
|
<div class="row justify-content-center g-3">
|
||||||
{% for thematic in thematics %}
|
{% for thematic in thematics %}
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<div class="row justify-content-center g-3 mt-4">
|
<div class="row justify-content-center g-3 mt-4">
|
||||||
<div class="col-6 d-flex align-items-center">
|
<div class="col-6 d-flex align-items-center">
|
||||||
<div class="mb-3 w-100">
|
<div class="mb-3 w-100">
|
||||||
<label for="symptom-input" class="form-label w-100 text-center mb-2" style="color: var(--brand-color);">{% translate "Ou choisir directement un symptôme" %}</label>
|
<label for="symptom-input" class="form-label w-100 text-center mb-2" style="color: #0f172a;">{% translate "Ou choisir directement un symptôme" %}</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" id="symptom-input" class="form-control" name="symptom_name"
|
<input type="text" id="symptom-input" class="form-control" name="symptom_name"
|
||||||
placeholder="{% translate 'Saisir le nom du symptôme'%}" autocomplete="off">
|
placeholder="{% translate 'Saisir le nom du symptôme'%}" autocomplete="off">
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
<!-- Zone centre : Titre (prend l’espace restant et wrap propre) -->
|
<!-- Zone centre : Titre (prend l’espace restant et wrap propre) -->
|
||||||
<div class="m-0 flex-grow-1 pt-1 editable-field" data-field="title" data-can-edit="{% if 'title' in allowed_update_fields %}true{% else %}false{% endif %}">
|
<div class="m-0 flex-grow-1 pt-1 editable-field" data-field="title" data-can-edit="{% if 'title' in allowed_update_fields %}true{% else %}false{% endif %}">
|
||||||
<h4 class="m-0 field-display" style="color: var(--brand-color);">
|
<h4 class="m-0 field-display" style="color: #0f172a;">
|
||||||
<i class="bi {{ intervention.thematic.icon }}" style="color: var(--brand-color); font-size: 1.5rem;"></i>
|
<i class="bi {{ intervention.thematic.icon }}" style="color: #475569; font-size: 1.5rem;"></i>
|
||||||
{% translate "Intervention" %} {{ intervention.code }}
|
{% translate "Intervention" %} {{ intervention.code }}
|
||||||
{% if intervention.contract.needs_checking and intervention.status in 'to_be_approved,to_be_processed' %}
|
{% if intervention.contract.needs_checking and intervention.status in 'to_be_approved,to_be_processed' %}
|
||||||
{% if can_check_intervention %}
|
{% if can_check_intervention %}
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="field-value" style="color: var(--brand-color);">{{ intervention.title }}</span>
|
<span class="field-value" style="color: #0f172a;">{{ intervention.title }}</span>
|
||||||
{% if intervention.show_contract_blocked_warning %}
|
{% if intervention.show_contract_blocked_warning %}
|
||||||
<span class="badge bg-danger text-white ms-2" title="{% translate 'Marché temporairement bloqué pour raisons budgétaires' %}">
|
<span class="badge bg-danger text-white ms-2" title="{% translate 'Marché temporairement bloqué pour raisons budgétaires' %}">
|
||||||
<i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %}
|
<i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %}
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
</h4>
|
</h4>
|
||||||
<div class="field-edit d-none">
|
<div class="field-edit d-none">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span style="color: var(--brand-color); font-weight: bold;">{% translate "Intervention" %} {{ intervention.code }} -</span>
|
<span style="color: #0f172a; font-weight: bold;">{% translate "Intervention" %} {{ intervention.code }} -</span>
|
||||||
<input type="text" class="form-control form-control-sm" value="{{ intervention.title }}" style="flex: 1;" />
|
<input type="text" class="form-control form-control-sm" value="{{ intervention.title }}" style="flex: 1;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator-top {
|
.separator-top {
|
||||||
border-top: 1px solid var(--brand-color);
|
border-top: 1px solid #e2e8f0;
|
||||||
margin-top: 2rem; /* optionnel */
|
margin-top: 2rem; /* optionnel */
|
||||||
padding-top: 2rem; /* optionnel */
|
padding-top: 2rem; /* optionnel */
|
||||||
}
|
}
|
||||||
|
|
@ -306,11 +306,11 @@
|
||||||
}
|
}
|
||||||
.nav-section-link:hover {
|
.nav-section-link:hover {
|
||||||
background-color: #eef3ff;
|
background-color: #eef3ff;
|
||||||
color: var(--brand-color, #1a6ec7);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
.nav-section-link.active {
|
.nav-section-link.active {
|
||||||
background-color: #dce8ff;
|
background-color: #dce8ff;
|
||||||
color: var(--brand-color, #1a6ec7);
|
color: #0f172a;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,9 @@
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
.thematic-chip.active {
|
.thematic-chip.active {
|
||||||
background-color: var(--brand-color, #2f3e8b);
|
background-color: #0f172a;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
border-color: var(--brand-color, #2f3e8b);
|
border-color: #0f172a;
|
||||||
box-shadow: 0 2px 5px rgba(47, 62, 139, 0.3);
|
box-shadow: 0 2px 5px rgba(47, 62, 139, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +180,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #0f172a;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s ease, background-color 0.15s ease;
|
transition: transform 0.15s ease, background-color 0.15s ease;
|
||||||
|
|
@ -204,7 +204,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #0f172a;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.2s ease;
|
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.2s ease;
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 2px solid var(--brand-color, #2f3e8b);
|
border: 2px solid #0f172a;
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -853,7 +853,7 @@
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background-color: rgba(47, 62, 139, 0.1);
|
background-color: rgba(47, 62, 139, 0.1);
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #0f172a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
|
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--brand-color: {{ organization_primary_color|default:'#2f3e8b' }};
|
--brand-color: #0f172a;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
padding-top: 4.5rem;
|
padding-top: 4.5rem;
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
.fixed-header {
|
.fixed-header {
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: var(--brand-color);
|
background-color: #0f172a;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: rgba(47, 62, 139, 0.12);
|
background: rgba(47, 62, 139, 0.12);
|
||||||
color: var(--brand-color);
|
color: #0f172a;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
<i class="bi bi-bell fs-3"></i>
|
<i class="bi bi-bell fs-3"></i>
|
||||||
<span id="mobile-navbar-notif-badge"
|
<span id="mobile-navbar-notif-badge"
|
||||||
class="position-absolute translate-middle badge rounded-pill"
|
class="position-absolute translate-middle badge rounded-pill"
|
||||||
style="background-color: var(--brand-color, #2f3e8b); color: #ffffff; top: 8px; left: 28px; {% if not unread_notifications_count %}display:none;{% endif %}">
|
style="background-color: #0f172a; color: #ffffff; top: 8px; left: 28px; {% if not unread_notifications_count %}display:none;{% endif %}">
|
||||||
{{ unread_notifications_count }}
|
{{ unread_notifications_count }}
|
||||||
<span class="visually-hidden">{% translate "notifications non lues" %}</span>
|
<span class="visually-hidden">{% translate "notifications non lues" %}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
}
|
}
|
||||||
.discussion-item.has-unread {
|
.discussion-item.has-unread {
|
||||||
background-color: #f4f7ff;
|
background-color: #f4f7ff;
|
||||||
border-left-color: var(--brand-color, #2f3e8b);
|
border-left-color: #2563eb;
|
||||||
}
|
}
|
||||||
.discussion-item:hover {
|
.discussion-item:hover {
|
||||||
background-color: #edf2fc;
|
background-color: #edf2fc;
|
||||||
|
|
@ -177,7 +177,7 @@
|
||||||
|
|
||||||
<!-- Badge non-lus -->
|
<!-- Badge non-lus -->
|
||||||
{% if item.unread_count %}
|
{% if item.unread_count %}
|
||||||
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: var(--brand-color, #2f3e8b) !important;">
|
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: #2563eb !important;">
|
||||||
{{ item.unread_count }} {% translate "non lu(s)" %}
|
{{ item.unread_count }} {% translate "non lu(s)" %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
}
|
}
|
||||||
.notif-item.unread {
|
.notif-item.unread {
|
||||||
background-color: #f0f4ff;
|
background-color: #f0f4ff;
|
||||||
border-left-color: var(--brand-color, #2f3e8b);
|
border-left-color: #2563eb;
|
||||||
}
|
}
|
||||||
.notif-item:hover {
|
.notif-item:hover {
|
||||||
background-color: #e8edf8;
|
background-color: #e8edf8;
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
}
|
}
|
||||||
.discussion-item.has-unread {
|
.discussion-item.has-unread {
|
||||||
background-color: #f4f7ff;
|
background-color: #f4f7ff;
|
||||||
border-left-color: var(--brand-color, #2f3e8b);
|
border-left-color: #2563eb;
|
||||||
}
|
}
|
||||||
.discussion-item:hover {
|
.discussion-item:hover {
|
||||||
background-color: #edf2fc;
|
background-color: #edf2fc;
|
||||||
|
|
@ -85,13 +85,13 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.inbox-nav-tabs .nav-link:hover {
|
.inbox-nav-tabs .nav-link:hover {
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #2563eb;
|
||||||
border-bottom-color: #dee2e6;
|
border-bottom-color: #dee2e6;
|
||||||
}
|
}
|
||||||
.inbox-nav-tabs .nav-link.active {
|
.inbox-nav-tabs .nav-link.active {
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #2563eb;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-bottom-color: var(--brand-color, #2f3e8b);
|
border-bottom-color: #2563eb;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
aria-selected="{% if active_tab != 'discussions' %}true{% else %}false{% endif %}">
|
aria-selected="{% if active_tab != 'discussions' %}true{% else %}false{% endif %}">
|
||||||
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
|
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
|
||||||
{% if unread_count > 0 %}
|
{% if unread_count > 0 %}
|
||||||
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
|
<span class="badge rounded-pill ms-1" style="background-color: #2563eb; font-size: 0.75rem;">
|
||||||
{{ unread_count }}
|
{{ unread_count }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -132,7 +132,7 @@
|
||||||
aria-selected="{% if active_tab == 'discussions' %}true{% else %}false{% endif %}">
|
aria-selected="{% if active_tab == 'discussions' %}true{% else %}false{% endif %}">
|
||||||
<i class="bi bi-chat-dots me-2"></i>{% translate "Discussions" %}
|
<i class="bi bi-chat-dots me-2"></i>{% translate "Discussions" %}
|
||||||
{% if unread_discussions_count > 0 %}
|
{% if unread_discussions_count > 0 %}
|
||||||
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
|
<span class="badge rounded-pill ms-1" style="background-color: #2563eb; font-size: 0.75rem;">
|
||||||
{{ unread_discussions_count }}
|
{{ unread_discussions_count }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -482,7 +482,7 @@
|
||||||
|
|
||||||
<!-- Badge non-lus -->
|
<!-- Badge non-lus -->
|
||||||
{% if item.unread_count %}
|
{% if item.unread_count %}
|
||||||
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: var(--brand-color, #2f3e8b) !important;">
|
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: #2563eb !important;">
|
||||||
{{ item.unread_count }} {% translate "non lu(s)" %}
|
{{ item.unread_count }} {% translate "non lu(s)" %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="">
|
<div class="">
|
||||||
<h3 style="color: var(--brand-color);"><i class="bi {{ project.thematic.icon }} me-2"></i> {% translate "Projet" %} - {{ project.code }} - {{ project.name }}</h3>
|
<h3 class="fw-bold" style="color: #0f172a;"><i class="bi {{ project.thematic.icon }} me-2"></i> {% translate "Projet" %} - {{ project.code }} - {{ project.name }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="timeline" id="statusTimeline"></div>
|
<div class="timeline" id="statusTimeline"></div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container d-flex justify-content-center mt-5">
|
<div class="container d-flex justify-content-center mt-5">
|
||||||
<div class="w-100" style="max-width: 700px;">
|
<div class="w-100" style="max-width: 700px;">
|
||||||
<h2 class="mb-4 text-center" style="color: var(--brand-color);">
|
<h2 class="mb-4 text-center" style="color: #0f172a; font-weight: 700;">
|
||||||
{% if mode == "update" %}{% translate "Modifier le projet" %}{% else %}{% translate "Créer un nouveau projet" %}{% endif %}
|
{% if mode == "update" %}{% translate "Modifier le projet" %}{% else %}{% translate "Créer un nouveau projet" %}{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
<!-- Bootstrap 5.3.3 (local) -->
|
<!-- Bootstrap 5.3.3 (local) -->
|
||||||
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
|
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand-color: {{ organization_primary_color|default:'#004d40' }};
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
@ -21,8 +24,17 @@
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
h1{
|
h1{
|
||||||
color: rgba(47, 62, 139, 1);
|
color: var(--brand-color);
|
||||||
border-bottom: 1px solid rgba(47, 62, 139, 1);;
|
border-bottom: 1px solid var(--brand-color);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--brand-color) !important;
|
||||||
|
border-color: var(--brand-color) !important;
|
||||||
|
}
|
||||||
|
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
|
||||||
|
background-color: var(--brand-color) !important;
|
||||||
|
border-color: var(--brand-color) !important;
|
||||||
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
<!-- MapLibre GL 5.7.0 -->
|
<!-- MapLibre GL 5.7.0 -->
|
||||||
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
|
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'common/base_style.css' %}?v=1.3">
|
<link rel="stylesheet" href="{% static 'common/base_style.css' %}?v=1.4">
|
||||||
<link rel="stylesheet" href="{% static 'common/help_panel.css' %}">
|
<link rel="stylesheet" href="{% static 'common/help_panel.css' %}">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
window.MAP_ORTHO_WMS_URL = "{{ map_ortho_wms_url|escapejs }}";
|
window.MAP_ORTHO_WMS_URL = "{{ map_ortho_wms_url|escapejs }}";
|
||||||
window.MAP_ORTHO_WMS_NAME = "{{ map_ortho_wms_name|escapejs }}";
|
window.MAP_ORTHO_WMS_NAME = "{{ map_ortho_wms_name|escapejs }}";
|
||||||
window.MAP_ORTHO_WMS_ATTRIBUTION = "{{ map_ortho_wms_attribution|escapejs }}";
|
window.MAP_ORTHO_WMS_ATTRIBUTION = "{{ map_ortho_wms_attribution|escapejs }}";
|
||||||
|
window.MAP_BACKGROUNDS = {{ map_backgrounds_json|default:"[]"|safe }};
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
let _maplibregl = window.maplibregl;
|
let _maplibregl = window.maplibregl;
|
||||||
|
|
@ -103,37 +104,130 @@
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--brand-color: {{ organization_primary_color|default:'#2f3e8b' }};
|
--brand-color: #0f172a;
|
||||||
}
|
--org-color: {{ organization_primary_color|default:'#004d40' }};
|
||||||
.navbar-nav .nav-link,
|
|
||||||
#sidebar,
|
|
||||||
#sidebar .nav-link {
|
|
||||||
color: var(--brand-color) !important;
|
|
||||||
}
|
|
||||||
.navbar-nav .nav-link:hover,
|
|
||||||
#sidebar .nav-link:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Chrome neutre & élégant (Navbar, Sidebar, Tabs) ─── */
|
||||||
nav.gradient-custom {
|
nav.gradient-custom {
|
||||||
border-bottom: 1px solid var(--brand-color) !important;
|
background-color: #ffffff !important;
|
||||||
|
border-bottom: 1px solid #e2e8f0 !important;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
color: #475569 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link:hover,
|
||||||
|
.navbar-nav .nav-link:focus {
|
||||||
|
color: #0f172a !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
border-right: 1px solid var(--brand-color) !important;
|
background-color: #ffffff !important;
|
||||||
|
border-right: 1px solid #e2e8f0 !important;
|
||||||
|
color: #334155 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sidebar hr {
|
||||||
|
border-top: 1px solid #e2e8f0 !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
margin: 0.5rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
#sidebar .nav-link {
|
#sidebar .nav-link {
|
||||||
|
color: #475569 !important;
|
||||||
|
font-weight: 500;
|
||||||
padding: 0.5rem 0.75rem !important;
|
padding: 0.5rem 0.75rem !important;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sidebar .nav-link i {
|
||||||
|
color: #64748b;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .nav-link:hover {
|
||||||
|
color: #0f172a !important;
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .nav-link:hover i {
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .nav-link.active {
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar .nav-link.active i {
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
#sidebar {
|
#sidebar {
|
||||||
padding-left: 0.5rem !important;
|
padding-left: 0.5rem !important;
|
||||||
padding-right: 0.5rem !important;
|
padding-right: 0.5rem !important;
|
||||||
}
|
}
|
||||||
#sidebar .nav-link {
|
#sidebar .nav-link {
|
||||||
padding: 0.5rem 1.5rem !important;
|
padding: 0.5rem 1.25rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Onglets neutres ─── */
|
||||||
|
.nav-tabs {
|
||||||
|
border-bottom: 1px solid #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
color: #64748b !important;
|
||||||
|
font-weight: 500;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link:hover {
|
||||||
|
color: #0f172a !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border-color: #e2e8f0 #e2e8f0 #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Boutons d action tableau neutres (évite les conflits visuels) ─── */
|
||||||
|
.detail-btn,
|
||||||
|
.zoom-btn,
|
||||||
|
.table-btn,
|
||||||
|
.intervention-btn,
|
||||||
|
.intersection-btn-toggle {
|
||||||
|
color: #475569 !important;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-btn:hover,
|
||||||
|
.zoom-btn:hover,
|
||||||
|
.table-btn:hover,
|
||||||
|
.intervention-btn:hover,
|
||||||
|
.intersection-btn-toggle:hover {
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Titres neutres ─── */
|
||||||
|
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -170,7 +264,7 @@
|
||||||
<circle cx="80" cy="72" r="7.5" fill="#004d40" stroke="#ffffff" stroke-width="1.8" />
|
<circle cx="80" cy="72" r="7.5" fill="#004d40" stroke="#ffffff" stroke-width="1.8" />
|
||||||
<circle cx="80" cy="72" r="3.6" fill="#00bfa5" stroke="#ffffff" stroke-width="1.3" />
|
<circle cx="80" cy="72" r="3.6" fill="#00bfa5" stroke="#ffffff" stroke-width="1.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span style="color: var(--brand-color); font-weight: bold; font-size: 20px; line-height: 1;">
|
<span style="color: var(--org-color, {{ organization_primary_color|default:'#004d40' }}); font-weight: bold; font-size: 20px; line-height: 1;">
|
||||||
{{ product_name }} - {{ organization_name }}
|
{{ product_name }} - {{ organization_name }}
|
||||||
</span>
|
</span>
|
||||||
{% if environment_name %}
|
{% if environment_name %}
|
||||||
|
|
@ -200,7 +294,7 @@
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
|
||||||
<!-- Right Menu -->
|
<!-- Right Menu -->
|
||||||
<ul class="navbar-nav ms-auto d-flex align-items-center gap-3" style="color: var(--brand-color);">
|
<ul class="navbar-nav ms-auto d-flex align-items-center gap-3">
|
||||||
|
|
||||||
|
|
||||||
<!-- View Switcher -->
|
<!-- View Switcher -->
|
||||||
|
|
@ -234,8 +328,8 @@
|
||||||
<a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}">
|
<a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}">
|
||||||
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
|
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
|
||||||
<span id="navbar-notif-badge"
|
<span id="navbar-notif-badge"
|
||||||
class="position-absolute translate-middle badge rounded-pill"
|
class="position-absolute translate-middle badge rounded-pill bg-danger"
|
||||||
style="background-color: var(--brand-color); color: #ffffff; top: 6px; left: 32px;
|
style="color: #ffffff; top: 6px; left: 32px;
|
||||||
{% if not unread_notifications_count %}display:none;{% endif %}">
|
{% if not unread_notifications_count %}display:none;{% endif %}">
|
||||||
{{ unread_notifications_count }}
|
{{ unread_notifications_count }}
|
||||||
<span class="visually-hidden">{% translate "notifications non lues" %}</span>
|
<span class="visually-hidden">{% translate "notifications non lues" %}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue