build_communes_data.py 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import json
  4. import csv
  5. import time
  6. import difflib
  7. import math
  8. import unicodedata
  9. from concurrent.futures import ThreadPoolExecutor, as_completed
  10. from io import StringIO
  11. from pathlib import Path
  12. from typing import Iterable
  13. from urllib.parse import urlencode
  14. from urllib.request import Request, urlopen
  15. ROOT_DIR = Path(__file__).resolve().parents[1]
  16. DATA_DIR = ROOT_DIR / "data"
  17. TERRITORY_DIR = DATA_DIR / "territories"
  18. API_BASE_URL = "https://geo.api.gouv.fr"
  19. REGIONS_GEOJSON_URL = (
  20. "https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/"
  21. "regions-version-simplifiee.geojson"
  22. )
  23. DEPARTMENTS_GEOJSON_URL = (
  24. "https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/"
  25. "departements-version-simplifiee.geojson"
  26. )
  27. REGION_COMMUNES_GEOJSON_BASE_URL = (
  28. "https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions"
  29. )
  30. COMMUNE_TERRITORIAL_COMPETENCE_URL = (
  31. "https://www.data.gouv.fr/api/1/datasets/r/c53cd4d4-4623-4772-9b8c-bc72a9cdf4c2"
  32. )
  33. GENDARMERIE_PUBLIC_UNITS_URL = (
  34. "https://www.data.gouv.fr/api/1/datasets/r/17320fe6-a896-4686-93e6-502be2ad23f2"
  35. )
  36. SERVICE_PUBLIC_ANNUAIRE_API_URL = (
  37. "https://api-lannuaire.service-public.gouv.fr/api/explore/v2.1/catalog/datasets/"
  38. "api-lannuaire-administration/records"
  39. )
  40. REQUEST_HEADERS = {
  41. "User-Agent": "GiePlaces data builder",
  42. "Accept": "application/json",
  43. }
  44. REGION_ENTITY_TYPE = "region"
  45. DEPARTMENT_ENTITY_TYPE = "departement"
  46. COMMUNE_ENTITY_TYPE = "commune"
  47. OVERSEAS_TERRITORIES = [
  48. {"code": "971", "label": "Guadeloupe", "shortLabel": "Guadeloupe"},
  49. {"code": "972", "label": "Martinique", "shortLabel": "Martinique"},
  50. {"code": "973", "label": "Guyane", "shortLabel": "Guyane"},
  51. {"code": "974", "label": "La Réunion", "shortLabel": "La Réunion"},
  52. {"code": "976", "label": "Mayotte", "shortLabel": "Mayotte"},
  53. {"code": "975", "label": "Saint-Pierre-et-Miquelon", "shortLabel": "SPM"},
  54. {"code": "977", "label": "Saint-Barthélemy", "shortLabel": "Saint-Barth"},
  55. {"code": "978", "label": "Saint-Martin", "shortLabel": "Saint-Martin"},
  56. {"code": "986", "label": "Wallis-et-Futuna", "shortLabel": "Wallis"},
  57. {"code": "987", "label": "Polynésie française", "shortLabel": "Polynésie"},
  58. {"code": "988", "label": "Nouvelle-Calédonie", "shortLabel": "Nouvelle-Calédonie"},
  59. ]
  60. POLYNESIE_RETAINED_COMMUNES = {
  61. "Arue",
  62. "Faaa",
  63. "Hitiaa O Te Ra",
  64. "Mahina",
  65. "Moorea-Maiao",
  66. "Paea",
  67. "Papara",
  68. "Papeete",
  69. "Pirae",
  70. "Punaauia",
  71. "Taiarapu-Est",
  72. "Taiarapu-Ouest",
  73. "Teva I Uta",
  74. }
  75. POLYNESIE_TRIMMED_COMMUNES = {
  76. "Taiarapu-Est",
  77. }
  78. SPECIAL_TERRITORY_SIMPLIFICATION = {
  79. "polynesie-francaise": {
  80. "minDistance": 0.0018,
  81. "minArea": 0.0000018,
  82. },
  83. "nouvelle-caledonie": {
  84. "minDistance": 0.0014,
  85. "minArea": 0.0000012,
  86. },
  87. }
  88. SPECIAL_TERRITORY_LAYOUTS = {
  89. "polynesie-francaise": {
  90. "marquises": {"target": (-151.2, -16.4), "scale": 1.0},
  91. "societe": {"target": (-150.8, -16.8), "scale": 1.0},
  92. "tuamotu-ouest": {"target": (-150.4, -16.8), "scale": 1.0},
  93. "tuamotu-centre": {"target": (-150.0, -16.8), "scale": 1.0},
  94. "tuamotu-est": {"target": (-149.6, -16.8), "scale": 1.0},
  95. "australes": {"target": (-150.2, -17.2), "scale": 1.0},
  96. "gambier": {"target": (-149.8, -17.2), "scale": 1.0},
  97. },
  98. "wallis-et-futuna": {
  99. "uvea": {"target": (-176.22, -13.32), "scale": 1.0},
  100. "futuna": {"target": (-176.68, -13.88), "scale": 1.0},
  101. },
  102. }
  103. SPECIAL_PLANAR_LAYOUT_ROWS = {}
  104. PLANAR_TERRITORY_COMPACTION = {
  105. "polynesie-francaise": {
  106. "factor": 1.0,
  107. },
  108. "nouvelle-caledonie": {
  109. "factor": 0.6,
  110. },
  111. }
  112. SPECIAL_TERRITORY_QUANTIZATION = {
  113. "polynesie-francaise": 4,
  114. "nouvelle-caledonie": 4,
  115. }
  116. PREPROJECTED_TERRITORIES = {
  117. "polynesie-francaise",
  118. "nouvelle-caledonie",
  119. }
  120. SPECIAL_GROUP_DISTANCE_REDUCTION = {}
  121. SPECIAL_GROUP_MIN_GAP_FACTOR = {
  122. "polynesie-francaise": 0.0005,
  123. }
  124. SPECIAL_GROUP_PACK_BY_FEATURE = {
  125. "polynesie-francaise": True,
  126. }
  127. SPECIAL_GROUP_EFFECTIVE_BOUNDS_FACTOR = {
  128. "polynesie-francaise": 0.18,
  129. }
  130. DAY_LABELS = [
  131. ("lundi", "Lun"),
  132. ("mardi", "Mar"),
  133. ("mercredi", "Mer"),
  134. ("jeudi", "Jeu"),
  135. ("vendredi", "Ven"),
  136. ("samedi", "Sam"),
  137. ("dimanche", "Dim"),
  138. ("jours_feries", "Fériés"),
  139. ]
  140. def fetch_json(url: str, retries: int = 3) -> dict | list:
  141. last_error = None
  142. for attempt in range(1, retries + 1):
  143. try:
  144. request = Request(url, headers=REQUEST_HEADERS)
  145. with urlopen(request, timeout=60) as response:
  146. return json.load(response)
  147. except Exception as exc: # pragma: no cover - network failures are not deterministic
  148. last_error = exc
  149. if attempt == retries:
  150. raise
  151. time.sleep(attempt * 0.75)
  152. raise RuntimeError(f"Unable to fetch {url}: {last_error}")
  153. def fetch_text(url: str, retries: int = 3) -> str:
  154. last_error = None
  155. for attempt in range(1, retries + 1):
  156. try:
  157. request = Request(url, headers=REQUEST_HEADERS)
  158. with urlopen(request, timeout=60) as response:
  159. return response.read().decode("utf-8-sig")
  160. except Exception as exc: # pragma: no cover - network failures are not deterministic
  161. last_error = exc
  162. if attempt == retries:
  163. raise
  164. time.sleep(attempt * 0.75)
  165. raise RuntimeError(f"Unable to fetch {url}: {last_error}")
  166. def slugify(value: str) -> str:
  167. normalized = unicodedata.normalize("NFD", str(value))
  168. ascii_value = "".join(char for char in normalized if unicodedata.category(char) != "Mn")
  169. chunks = []
  170. for char in ascii_value.lower():
  171. chunks.append(char if char.isalnum() else "-")
  172. slug = "".join(chunks)
  173. while "--" in slug:
  174. slug = slug.replace("--", "-")
  175. return slug.strip("-")
  176. def normalize_sort_text(value: str) -> str:
  177. normalized = unicodedata.normalize("NFD", str(value))
  178. ascii_value = "".join(char for char in normalized if unicodedata.category(char) != "Mn")
  179. lowered = ascii_value.lower()
  180. cleaned = []
  181. for char in lowered:
  182. cleaned.append(char if char.isalnum() else " ")
  183. return " ".join("".join(cleaned).split())
  184. def department_territory_key(department_code: str) -> str:
  185. return f"departement-{slugify(department_code)}"
  186. def region_territory_key(region_name: str) -> str:
  187. return f"region-{slugify(region_name)}"
  188. def get_department_metadata() -> list[dict]:
  189. url = f"{API_BASE_URL}/departements?fields=code,nom,codeRegion,zone&format=json"
  190. return fetch_json(url)
  191. def get_region_metadata() -> list[dict]:
  192. url = f"{API_BASE_URL}/regions?fields=code,nom&format=json"
  193. return fetch_json(url)
  194. def get_commune_postal_codes_by_code() -> dict[str, list[str]]:
  195. url = f"{API_BASE_URL}/communes?fields=code,codesPostaux&format=json"
  196. payload = fetch_json(url)
  197. return {
  198. item["code"]: sorted({str(postal_code) for postal_code in item.get("codesPostaux", []) if postal_code})
  199. for item in payload
  200. }
  201. def build_service_key(institution: str, service_id: str, service_label: str, commune_code: str) -> str:
  202. normalized_institution = (institution or "UNK").upper()
  203. normalized_service_id = str(service_id or "").strip()
  204. normalized_service_label = str(service_label or "").strip()
  205. if normalized_institution == "GN" and normalized_service_id:
  206. return f"gn:{normalized_service_id}"
  207. if normalized_institution == "PN" and normalized_service_label:
  208. return f"pn:{slugify(normalized_service_label)}"
  209. if normalized_service_label:
  210. return f"{slugify(normalized_institution)}:{slugify(normalized_service_label)}"
  211. return f"unk:{commune_code}"
  212. def normalize_unit_match_text(value: str) -> str:
  213. normalized = unicodedata.normalize("NFD", str(value))
  214. ascii_value = "".join(char for char in normalized if unicodedata.category(char) != "Mn")
  215. lowered = ascii_value.lower().replace("'", " ")
  216. cleaned = []
  217. for char in lowered:
  218. cleaned.append(char if char.isalnum() else " ")
  219. return " ".join("".join(cleaned).split())
  220. def get_label_similarity(left: str, right: str) -> float:
  221. return difflib.SequenceMatcher(None, normalize_unit_match_text(left), normalize_unit_match_text(right)).ratio()
  222. def normalize_service_label(institution: str, service_label: str) -> str:
  223. normalized_label = str(service_label or "").strip()
  224. normalized_institution = (institution or "").upper()
  225. if normalized_label:
  226. return normalized_label
  227. if normalized_institution == "PN":
  228. return "Police nationale"
  229. if normalized_institution == "GN":
  230. return "Gendarmerie nationale"
  231. return "Compétence territoriale non renseignée"
  232. def get_commune_territorial_competence_data() -> tuple[dict[str, dict], dict[str, dict], dict[str, set[str]]]:
  233. payload = fetch_text(COMMUNE_TERRITORIAL_COMPETENCE_URL)
  234. reader = csv.DictReader(StringIO(payload), delimiter=";")
  235. rows_by_commune_code: dict[str, list[dict]] = {}
  236. service_registry: dict[str, dict] = {}
  237. commune_codes_by_service_key: dict[str, set[str]] = {}
  238. for row in reader:
  239. commune_code = str(row.get("code_commune", "")).strip()
  240. if not commune_code:
  241. continue
  242. institution = str(row.get("institution", "")).strip().upper()
  243. service_id = str(row.get("id_service", "")).strip()
  244. service_label = normalize_service_label(institution, row.get("service", ""))
  245. service_key = build_service_key(institution, service_id, service_label, commune_code)
  246. service_registry[service_key] = {
  247. "i": institution or "UNK",
  248. "l": service_label,
  249. }
  250. commune_codes_by_service_key.setdefault(service_key, set()).add(commune_code)
  251. rows_by_commune_code.setdefault(commune_code, []).append(
  252. {
  253. "institution": institution or "UNK",
  254. "serviceKey": service_key,
  255. "serviceLabel": service_label,
  256. }
  257. )
  258. normalized_competence_by_commune_code: dict[str, dict] = {}
  259. for commune_code, rows in rows_by_commune_code.items():
  260. unique_rows = []
  261. seen_rows = set()
  262. for row in rows:
  263. row_key = (row["institution"], row["serviceKey"], row["serviceLabel"])
  264. if row_key in seen_rows:
  265. continue
  266. seen_rows.add(row_key)
  267. unique_rows.append(row)
  268. if len(unique_rows) == 1:
  269. normalized_competence_by_commune_code[commune_code] = {
  270. "i": unique_rows[0]["institution"],
  271. "u": unique_rows[0]["serviceKey"],
  272. }
  273. continue
  274. institutions = {row["institution"] for row in unique_rows}
  275. if "GN" in institutions and "PN" in institutions:
  276. institution = "MX"
  277. elif "PN" in institutions:
  278. institution = "PN"
  279. elif "GN" in institutions:
  280. institution = "GN"
  281. else:
  282. institution = "UNK"
  283. normalized_competence_by_commune_code[commune_code] = {
  284. "i": institution,
  285. "m": " / ".join(row["serviceLabel"] for row in unique_rows),
  286. "v": [row["serviceKey"] for row in unique_rows],
  287. }
  288. return normalized_competence_by_commune_code, service_registry, commune_codes_by_service_key
  289. def format_public_unit_hours(row: dict) -> str:
  290. day_chunks = []
  291. for day_key, day_label in DAY_LABELS:
  292. ranges = []
  293. for slot in range(1, 4):
  294. start = str(row.get(f"{day_key}_plage{slot}_debut", "")).strip()
  295. end = str(row.get(f"{day_key}_plage{slot}_fin", "")).strip()
  296. if start and end:
  297. ranges.append(f"{start}-{end}")
  298. if ranges:
  299. day_chunks.append(f"{day_label} {' / '.join(ranges)}")
  300. return "; ".join(day_chunks)
  301. def parse_annuaire_sequence(value: object) -> list[dict]:
  302. if isinstance(value, list):
  303. return [item for item in value if isinstance(item, dict)]
  304. if isinstance(value, str):
  305. raw_value = value.strip()
  306. if not raw_value:
  307. return []
  308. try:
  309. parsed = json.loads(raw_value)
  310. except json.JSONDecodeError:
  311. return []
  312. if isinstance(parsed, list):
  313. return [item for item in parsed if isinstance(item, dict)]
  314. return []
  315. def fetch_annuaire_records(where: str) -> list[dict]:
  316. results = []
  317. offset = 0
  318. limit = 100
  319. while True:
  320. query = urlencode({"where": where, "limit": limit, "offset": offset})
  321. payload = fetch_json(f"{SERVICE_PUBLIC_ANNUAIRE_API_URL}?{query}")
  322. batch = payload.get("results", [])
  323. if not batch:
  324. break
  325. results.extend(batch)
  326. offset += len(batch)
  327. if offset >= payload.get("total_count", 0):
  328. break
  329. return results
  330. def format_annuaire_hours(opening_ranges: object) -> str:
  331. parsed_ranges = parse_annuaire_sequence(opening_ranges)
  332. day_chunks = []
  333. for entry in parsed_ranges:
  334. start_day = str(entry.get("nom_jour_debut", "")).strip()
  335. end_day = str(entry.get("nom_jour_fin", "")).strip()
  336. day_label = start_day if not end_day or end_day == start_day else f"{start_day}-{end_day}"
  337. ranges = []
  338. for slot in range(1, 3):
  339. start = str(entry.get(f"valeur_heure_debut_{slot}", "")).strip()
  340. end = str(entry.get(f"valeur_heure_fin_{slot}", "")).strip()
  341. if start and end:
  342. ranges.append(f"{start[:5]}-{end[:5]}")
  343. if day_label and ranges:
  344. day_chunks.append(f"{day_label} {' / '.join(ranges)}")
  345. return "; ".join(day_chunks)
  346. def get_annuaire_address_value(addresses: object) -> str:
  347. parsed_addresses = parse_annuaire_sequence(addresses)
  348. if not parsed_addresses:
  349. return ""
  350. address = parsed_addresses[0]
  351. chunks = [
  352. str(address.get("numero_voie", "")).strip(),
  353. str(address.get("complement1", "")).strip(),
  354. str(address.get("complement2", "")).strip(),
  355. str(address.get("service_distribution", "")).strip(),
  356. str(address.get("code_postal", "")).strip(),
  357. str(address.get("nom_commune", "")).strip(),
  358. ]
  359. return " ".join(chunk for chunk in chunks if chunk)
  360. def get_annuaire_phone_value(phones: object) -> str:
  361. parsed_phones = parse_annuaire_sequence(phones)
  362. if not parsed_phones:
  363. return ""
  364. return str(parsed_phones[0].get("valeur", "")).strip()
  365. def get_gendarmerie_public_units_by_service_key(service_registry: dict[str, dict]) -> dict[str, dict]:
  366. payload = fetch_text(GENDARMERIE_PUBLIC_UNITS_URL)
  367. reader = csv.DictReader(StringIO(payload), delimiter=";")
  368. units_by_service_key: dict[str, dict] = {}
  369. for row in reader:
  370. service_id = str(row.get("identifiant_public_unite", "")).strip()
  371. if not service_id:
  372. continue
  373. service_key = f"gn:{service_id}"
  374. service = service_registry.get(service_key)
  375. if not service or service.get("i") != "GN":
  376. continue
  377. longitude = str(row.get("geocodage_x_GPS", "")).strip()
  378. latitude = str(row.get("geocodage_y_GPS", "")).strip()
  379. if not longitude or not latitude:
  380. continue
  381. units_by_service_key[service_key] = {
  382. "s": service_key,
  383. "i": "GN",
  384. "n": service.get("l") or str(row.get("service", "")).strip() or f"Unité {service_id}",
  385. "x": round(float(longitude), 5),
  386. "y": round(float(latitude), 5),
  387. "c": str(row.get("code_commune_insee", "")).strip(),
  388. "m": str(row.get("commune", "")).strip(),
  389. "a": str(row.get("adresse_geographique", "")).strip(),
  390. "t": str(row.get("telephone", "")).strip(),
  391. "h": format_public_unit_hours(row),
  392. "u": str(row.get("url", "")).strip(),
  393. }
  394. return units_by_service_key
  395. def get_police_public_units_by_service_key(
  396. service_registry: dict[str, dict],
  397. commune_codes_by_service_key: dict[str, set[str]],
  398. ) -> dict[str, dict]:
  399. source_rows = fetch_annuaire_records("partenaire = 'commissariat'")
  400. annuaire_records = [
  401. {
  402. "serviceKey": build_service_key("PN", "", row.get("nom", ""), ""),
  403. "name": str(row.get("nom", "")).strip(),
  404. "communeCode": str(row.get("code_insee_commune", "")).strip(),
  405. "hostCommune": str((parse_annuaire_sequence(row.get("adresse")) or [{}])[0].get("nom_commune", "")).strip(),
  406. "address": get_annuaire_address_value(row.get("adresse")),
  407. "phone": get_annuaire_phone_value(row.get("telephone")),
  408. "hours": format_annuaire_hours(row.get("plage_ouverture")),
  409. "url": str(row.get("url_service_public", "")).strip(),
  410. "longitude": str((parse_annuaire_sequence(row.get("adresse")) or [{}])[0].get("longitude", "")).strip(),
  411. "latitude": str((parse_annuaire_sequence(row.get("adresse")) or [{}])[0].get("latitude", "")).strip(),
  412. }
  413. for row in source_rows
  414. ]
  415. units_by_service_key: dict[str, dict] = {}
  416. police_service_keys = [
  417. service_key
  418. for service_key, service in service_registry.items()
  419. if service.get("i") == "PN"
  420. ]
  421. for service_key in police_service_keys:
  422. service_label = service_registry[service_key]["l"]
  423. commune_codes = commune_codes_by_service_key.get(service_key, set())
  424. commune_candidates = [
  425. record
  426. for record in annuaire_records
  427. if record["communeCode"] and record["communeCode"] in commune_codes
  428. ]
  429. best_match = None
  430. best_ratio = 0.0
  431. for record in commune_candidates or annuaire_records:
  432. similarity = get_label_similarity(service_label, record["name"])
  433. if similarity > best_ratio:
  434. best_ratio = similarity
  435. best_match = record
  436. if not best_match:
  437. continue
  438. minimum_ratio = 0.45 if commune_candidates else 0.84
  439. if best_ratio < minimum_ratio:
  440. continue
  441. if not best_match["longitude"] or not best_match["latitude"]:
  442. continue
  443. units_by_service_key[service_key] = {
  444. "s": service_key,
  445. "i": "PN",
  446. "n": service_label,
  447. "x": round(float(best_match["longitude"]), 5),
  448. "y": round(float(best_match["latitude"]), 5),
  449. "c": best_match["communeCode"],
  450. "m": best_match["hostCommune"],
  451. "a": best_match["address"],
  452. "t": best_match["phone"],
  453. "h": best_match["hours"],
  454. "u": best_match["url"],
  455. }
  456. return units_by_service_key
  457. def get_department_code_from_commune_code(commune_code: str) -> str:
  458. code = str(commune_code)
  459. if code.startswith("2A") or code.startswith("2B"):
  460. return code[:2]
  461. if code.startswith("97") or code.startswith("98"):
  462. return code[:3]
  463. return code[:2]
  464. def quantize_pair(pair: Iterable[float], precision: int = 5) -> list[float]:
  465. return [round(float(pair[0]), precision), round(float(pair[1]), precision)]
  466. def quantize_geometry(geometry: dict, precision: int = 5) -> dict:
  467. geometry_type = geometry.get("type")
  468. coordinates = geometry.get("coordinates")
  469. if geometry_type == "Polygon":
  470. return {
  471. "type": "Polygon",
  472. "coordinates": [
  473. [quantize_pair(point, precision) for point in ring]
  474. for ring in coordinates
  475. ],
  476. }
  477. if geometry_type == "MultiPolygon":
  478. return {
  479. "type": "MultiPolygon",
  480. "coordinates": [
  481. [
  482. [quantize_pair(point, precision) for point in ring]
  483. for ring in polygon
  484. ]
  485. for polygon in coordinates
  486. ],
  487. }
  488. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  489. def iter_geometry_points(geometry: dict) -> Iterable[list[float]]:
  490. geometry_type = geometry.get("type")
  491. coordinates = geometry.get("coordinates", [])
  492. if geometry_type == "Polygon":
  493. for ring in coordinates:
  494. for point in ring:
  495. yield point
  496. return
  497. if geometry_type == "MultiPolygon":
  498. for polygon in coordinates:
  499. for ring in polygon:
  500. for point in ring:
  501. yield point
  502. return
  503. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  504. def get_geometry_center(geometry: dict) -> tuple[float, float]:
  505. min_longitude = float("inf")
  506. min_latitude = float("inf")
  507. max_longitude = float("-inf")
  508. max_latitude = float("-inf")
  509. for longitude, latitude in iter_geometry_points(geometry):
  510. min_longitude = min(min_longitude, longitude)
  511. min_latitude = min(min_latitude, latitude)
  512. max_longitude = max(max_longitude, longitude)
  513. max_latitude = max(max_latitude, latitude)
  514. return (
  515. (min_longitude + max_longitude) / 2,
  516. (min_latitude + max_latitude) / 2,
  517. )
  518. def simplify_ring(ring: list[list[float]], min_distance: float, min_area: float) -> list[list[float]]:
  519. if len(ring) < 4:
  520. return ring
  521. is_closed = ring[0] == ring[-1]
  522. points = ring[:-1] if is_closed else ring[:]
  523. if len(points) < 3:
  524. return ring
  525. filtered = [points[0]]
  526. min_distance_sq = min_distance * min_distance
  527. for point in points[1:]:
  528. delta_longitude = point[0] - filtered[-1][0]
  529. delta_latitude = point[1] - filtered[-1][1]
  530. if delta_longitude * delta_longitude + delta_latitude * delta_latitude >= min_distance_sq:
  531. filtered.append(point)
  532. if len(filtered) < 3:
  533. filtered = points[:]
  534. changed = True
  535. while changed and len(filtered) > 3:
  536. changed = False
  537. reduced = [filtered[0]]
  538. for index in range(1, len(filtered) - 1):
  539. previous_point = reduced[-1]
  540. point = filtered[index]
  541. next_point = filtered[index + 1]
  542. cross_product = abs(
  543. (point[0] - previous_point[0]) * (next_point[1] - previous_point[1])
  544. - (point[1] - previous_point[1]) * (next_point[0] - previous_point[0])
  545. )
  546. if cross_product <= min_area:
  547. changed = True
  548. continue
  549. reduced.append(point)
  550. reduced.append(filtered[-1])
  551. filtered = reduced
  552. if is_closed:
  553. filtered.append(filtered[0])
  554. return filtered
  555. def simplify_geometry(geometry: dict, min_distance: float, min_area: float) -> dict:
  556. geometry_type = geometry.get("type")
  557. coordinates = geometry.get("coordinates", [])
  558. if geometry_type == "Polygon":
  559. return {
  560. "type": "Polygon",
  561. "coordinates": [simplify_ring(ring, min_distance, min_area) for ring in coordinates],
  562. }
  563. if geometry_type == "MultiPolygon":
  564. return {
  565. "type": "MultiPolygon",
  566. "coordinates": [
  567. [simplify_ring(ring, min_distance, min_area) for ring in polygon]
  568. for polygon in coordinates
  569. ],
  570. }
  571. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  572. def get_ring_area(ring: list[list[float]]) -> float:
  573. if len(ring) < 3:
  574. return 0.0
  575. area = 0.0
  576. previous_x, previous_y = ring[-1]
  577. for point_x, point_y in ring:
  578. area += previous_x * point_y - point_x * previous_y
  579. previous_x, previous_y = point_x, point_y
  580. return abs(area) / 2
  581. def get_polygon_area(polygon: list[list[list[float]]]) -> float:
  582. if not polygon:
  583. return 0.0
  584. outer_area = get_ring_area(polygon[0])
  585. inner_area = sum(get_ring_area(ring) for ring in polygon[1:])
  586. return max(0.0, outer_area - inner_area)
  587. def get_polygon_center(polygon: list[list[list[float]]]) -> tuple[float, float]:
  588. min_x = math.inf
  589. min_y = math.inf
  590. max_x = -math.inf
  591. max_y = -math.inf
  592. for ring in polygon:
  593. for point_x, point_y in ring:
  594. min_x = min(min_x, point_x)
  595. min_y = min(min_y, point_y)
  596. max_x = max(max_x, point_x)
  597. max_y = max(max_y, point_y)
  598. return (min_x + max_x) / 2, (min_y + max_y) / 2
  599. def trim_special_commune_polygons(territory_key: str, commune_name: str, geometry: dict) -> dict:
  600. if (
  601. territory_key != "polynesie-francaise"
  602. or commune_name not in POLYNESIE_TRIMMED_COMMUNES
  603. or geometry.get("type") != "MultiPolygon"
  604. ):
  605. return geometry
  606. polygons = geometry.get("coordinates", [])
  607. if len(polygons) <= 1:
  608. return geometry
  609. polygon_areas = [get_polygon_area(polygon) for polygon in polygons]
  610. main_index = max(range(len(polygons)), key=lambda index: polygon_areas[index])
  611. main_polygon = polygons[main_index]
  612. return {
  613. "type": "MultiPolygon",
  614. "coordinates": [main_polygon],
  615. }
  616. def get_special_layout_group_id(territory_key: str, longitude: float, latitude: float) -> str | None:
  617. if territory_key == "wallis-et-futuna":
  618. return "uvea" if longitude > -177.0 else "futuna"
  619. if territory_key == "polynesie-francaise":
  620. if latitude > -12.0:
  621. return "marquises"
  622. if latitude <= -21.2:
  623. return "gambier" if longitude >= -140.5 else "australes"
  624. if longitude < -148.5:
  625. return "societe"
  626. if longitude < -145.8:
  627. return "tuamotu-ouest"
  628. if longitude < -143.5:
  629. return "tuamotu-centre"
  630. return "tuamotu-est"
  631. return None
  632. def get_layout_source_centers(territory_key: str, features: list[dict]) -> dict[str, tuple[float, float]]:
  633. layout_config = SPECIAL_TERRITORY_LAYOUTS.get(territory_key)
  634. if not layout_config:
  635. return {}
  636. grouped_centers: dict[str, list[tuple[float, float]]] = {}
  637. for feature in features:
  638. center_longitude, center_latitude = get_geometry_center(feature["geometry"])
  639. group_id = get_special_layout_group_id(territory_key, center_longitude, center_latitude)
  640. if not group_id:
  641. continue
  642. grouped_centers.setdefault(group_id, []).append((center_longitude, center_latitude))
  643. source_centers = {}
  644. for group_id, centers in grouped_centers.items():
  645. source_centers[group_id] = (
  646. sum(center[0] for center in centers) / len(centers),
  647. sum(center[1] for center in centers) / len(centers),
  648. )
  649. return source_centers
  650. def transform_point_for_layout(
  651. territory_key: str,
  652. longitude: float,
  653. latitude: float,
  654. source_centers: dict[str, tuple[float, float]],
  655. ) -> list[float]:
  656. group_id = get_special_layout_group_id(territory_key, longitude, latitude)
  657. layout_config = SPECIAL_TERRITORY_LAYOUTS.get(territory_key, {})
  658. group_layout = layout_config.get(group_id or "")
  659. source_center = source_centers.get(group_id or "")
  660. if not group_layout or not source_center:
  661. return [round(float(longitude), 5), round(float(latitude), 5)]
  662. target_longitude, target_latitude = group_layout["target"]
  663. scale = group_layout["scale"]
  664. return [
  665. round(target_longitude + scale * (longitude - source_center[0]), 5),
  666. round(target_latitude + scale * (latitude - source_center[1]), 5),
  667. ]
  668. def transform_geometry_for_layout(
  669. territory_key: str,
  670. geometry: dict,
  671. source_centers: dict[str, tuple[float, float]],
  672. ) -> dict:
  673. geometry_type = geometry.get("type")
  674. coordinates = geometry.get("coordinates", [])
  675. if geometry_type == "Polygon":
  676. return {
  677. "type": "Polygon",
  678. "coordinates": [
  679. [
  680. transform_point_for_layout(territory_key, point[0], point[1], source_centers)
  681. for point in ring
  682. ]
  683. for ring in coordinates
  684. ],
  685. }
  686. if geometry_type == "MultiPolygon":
  687. return {
  688. "type": "MultiPolygon",
  689. "coordinates": [
  690. [
  691. [
  692. transform_point_for_layout(territory_key, point[0], point[1], source_centers)
  693. for point in ring
  694. ]
  695. for ring in polygon
  696. ]
  697. for polygon in coordinates
  698. ],
  699. }
  700. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  701. def project_point_to_planar_mercator(point: list[float]) -> list[float]:
  702. longitude = math.radians(float(point[0]))
  703. latitude = math.radians(float(point[1]))
  704. clamped_latitude = max(min(latitude, math.radians(89.9999)), math.radians(-89.9999))
  705. mercator_y = math.log(math.tan(math.pi / 4 + clamped_latitude / 2))
  706. return [round(longitude, 6), round(-mercator_y, 6)]
  707. def project_geometry_to_planar_mercator(geometry: dict) -> dict:
  708. geometry_type = geometry.get("type")
  709. coordinates = geometry.get("coordinates", [])
  710. if geometry_type == "Polygon":
  711. return {
  712. "type": "Polygon",
  713. "coordinates": [
  714. [project_point_to_planar_mercator(point) for point in ring]
  715. for ring in coordinates
  716. ],
  717. }
  718. if geometry_type == "MultiPolygon":
  719. return {
  720. "type": "MultiPolygon",
  721. "coordinates": [
  722. [
  723. [project_point_to_planar_mercator(point) for point in ring]
  724. for ring in polygon
  725. ]
  726. for polygon in coordinates
  727. ],
  728. }
  729. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  730. def project_units_to_planar_mercator(units: list[dict]) -> list[dict]:
  731. projected_units = []
  732. for unit in units:
  733. projected_longitude, projected_latitude = project_point_to_planar_mercator([unit["x"], unit["y"]])
  734. projected_unit = dict(unit)
  735. projected_unit["x"] = projected_longitude
  736. projected_unit["y"] = projected_latitude
  737. projected_units.append(projected_unit)
  738. return projected_units
  739. def compact_planar_point(point_x: float, point_y: float, origin_x: float, origin_y: float, compact_factor: float) -> list[float]:
  740. return [
  741. round(origin_x + (point_x - origin_x) * compact_factor, 6),
  742. round(origin_y + (point_y - origin_y) * compact_factor, 6),
  743. ]
  744. def compact_planar_geometry(
  745. geometry: dict,
  746. origin_x: float,
  747. origin_y: float,
  748. compact_factor: float,
  749. ) -> dict:
  750. geometry_type = geometry.get("type")
  751. coordinates = geometry.get("coordinates", [])
  752. if geometry_type == "Polygon":
  753. return {
  754. "type": "Polygon",
  755. "coordinates": [
  756. [compact_planar_point(point_x, point_y, origin_x, origin_y, compact_factor) for point_x, point_y in ring]
  757. for ring in coordinates
  758. ],
  759. }
  760. if geometry_type == "MultiPolygon":
  761. return {
  762. "type": "MultiPolygon",
  763. "coordinates": [
  764. [
  765. [
  766. compact_planar_point(point_x, point_y, origin_x, origin_y, compact_factor)
  767. for point_x, point_y in ring
  768. ]
  769. for ring in polygon
  770. ]
  771. for polygon in coordinates
  772. ],
  773. }
  774. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  775. def compact_planar_features(
  776. projected_features: list[dict],
  777. compact_factor: float,
  778. ) -> tuple[list[dict], tuple[float, float] | None]:
  779. if compact_factor >= 1 or not projected_features:
  780. return projected_features, None
  781. bounds_min_x = math.inf
  782. bounds_min_y = math.inf
  783. bounds_max_x = -math.inf
  784. bounds_max_y = -math.inf
  785. for feature in projected_features:
  786. min_x, min_y, max_x, max_y = get_geometry_bounds(feature["geometry"])
  787. bounds_min_x = min(bounds_min_x, min_x)
  788. bounds_min_y = min(bounds_min_y, min_y)
  789. bounds_max_x = max(bounds_max_x, max_x)
  790. bounds_max_y = max(bounds_max_y, max_y)
  791. if not math.isfinite(bounds_min_x) or not math.isfinite(bounds_max_x):
  792. return projected_features, None
  793. origin_x = (bounds_min_x + bounds_max_x) / 2
  794. origin_y = (bounds_min_y + bounds_max_y) / 2
  795. compacted_features = [
  796. {
  797. "type": "Feature",
  798. "properties": dict(feature["properties"]),
  799. "geometry": compact_planar_geometry(feature["geometry"], origin_x, origin_y, compact_factor),
  800. }
  801. for feature in projected_features
  802. ]
  803. return compacted_features, (origin_x, origin_y)
  804. def compact_planar_units(units: list[dict], origin_x: float, origin_y: float, compact_factor: float) -> list[dict]:
  805. if compact_factor >= 1 or not units:
  806. return units
  807. compacted_units = []
  808. for unit in units:
  809. compact_unit = dict(unit)
  810. compact_unit["x"] = round(origin_x + (float(unit["x"]) - origin_x) * compact_factor, 6)
  811. compact_unit["y"] = round(origin_y + (float(unit["y"]) - origin_y) * compact_factor, 6)
  812. compacted_units.append(compact_unit)
  813. return compacted_units
  814. def get_geometry_bounds(geometry: dict) -> tuple[float, float, float, float]:
  815. min_x = math.inf
  816. min_y = math.inf
  817. max_x = -math.inf
  818. max_y = -math.inf
  819. coordinates = geometry.get("coordinates", [])
  820. polygons = coordinates if geometry.get("type") == "MultiPolygon" else [coordinates]
  821. for polygon in polygons:
  822. for ring in polygon:
  823. for point_x, point_y in ring:
  824. min_x = min(min_x, point_x)
  825. min_y = min(min_y, point_y)
  826. max_x = max(max_x, point_x)
  827. max_y = max(max_y, point_y)
  828. return min_x, min_y, max_x, max_y
  829. def translate_geometry(geometry: dict, offset_x: float, offset_y: float) -> dict:
  830. geometry_type = geometry.get("type")
  831. coordinates = geometry.get("coordinates", [])
  832. if geometry_type == "Polygon":
  833. return {
  834. "type": "Polygon",
  835. "coordinates": [
  836. [[round(point_x + offset_x, 6), round(point_y + offset_y, 6)] for point_x, point_y in ring]
  837. for ring in coordinates
  838. ],
  839. }
  840. if geometry_type == "MultiPolygon":
  841. return {
  842. "type": "MultiPolygon",
  843. "coordinates": [
  844. [
  845. [[round(point_x + offset_x, 6), round(point_y + offset_y, 6)] for point_x, point_y in ring]
  846. for ring in polygon
  847. ]
  848. for polygon in coordinates
  849. ],
  850. }
  851. raise ValueError(f"Unsupported geometry type: {geometry_type}")
  852. def build_planar_group_offsets(territory_key: str, group_bounds: dict[str, tuple[float, float, float, float]]) -> dict[str, tuple[float, float]]:
  853. layout_rows = SPECIAL_PLANAR_LAYOUT_ROWS.get(territory_key)
  854. if not layout_rows or not group_bounds:
  855. return {}
  856. widths = [max_x - min_x for min_x, _min_y, max_x, _max_y in group_bounds.values()]
  857. heights = [max_y - min_y for _min_x, min_y, _max_x, max_y in group_bounds.values()]
  858. gap_x = (max(widths) if widths else 0) * 0.18
  859. gap_y = (max(heights) if heights else 0) * 0.28
  860. remaining_groups = [group_id for group_id in group_bounds if group_id not in {item for row in layout_rows for item in row}]
  861. rows = [row[:] for row in layout_rows]
  862. if remaining_groups:
  863. rows.append(remaining_groups)
  864. offsets: dict[str, tuple[float, float]] = {}
  865. cursor_y = 0.0
  866. for row in rows:
  867. row_groups = [group_id for group_id in row if group_id in group_bounds]
  868. if not row_groups:
  869. continue
  870. row_height = max(group_bounds[group_id][3] - group_bounds[group_id][1] for group_id in row_groups)
  871. cursor_x = 0.0
  872. for group_id in row_groups:
  873. min_x, min_y, max_x, max_y = group_bounds[group_id]
  874. width = max_x - min_x
  875. height = max_y - min_y
  876. target_min_x = cursor_x
  877. target_min_y = cursor_y + (row_height - height) / 2
  878. offsets[group_id] = (target_min_x - min_x, target_min_y - min_y)
  879. cursor_x += width + gap_x
  880. cursor_y += row_height + gap_y
  881. return offsets
  882. def apply_special_planar_layout(
  883. territory_key: str,
  884. raw_features: list[dict],
  885. projected_features: list[dict],
  886. raw_units: list[dict],
  887. projected_units: list[dict],
  888. ) -> tuple[list[dict], list[dict]]:
  889. if territory_key not in SPECIAL_PLANAR_LAYOUT_ROWS:
  890. return projected_features, projected_units
  891. projected_group_bounds: dict[str, list[float]] = {}
  892. feature_group_by_code: dict[str, str] = {}
  893. for raw_feature, projected_feature in zip(raw_features, projected_features):
  894. center_longitude, center_latitude = get_geometry_center(raw_feature["geometry"])
  895. group_id = get_special_layout_group_id(territory_key, center_longitude, center_latitude)
  896. if not group_id:
  897. continue
  898. feature_group_by_code[raw_feature["properties"]["c"]] = group_id
  899. min_x, min_y, max_x, max_y = get_geometry_bounds(projected_feature["geometry"])
  900. current_bounds = projected_group_bounds.get(group_id)
  901. if current_bounds is None:
  902. projected_group_bounds[group_id] = [min_x, min_y, max_x, max_y]
  903. else:
  904. current_bounds[0] = min(current_bounds[0], min_x)
  905. current_bounds[1] = min(current_bounds[1], min_y)
  906. current_bounds[2] = max(current_bounds[2], max_x)
  907. current_bounds[3] = max(current_bounds[3], max_y)
  908. offsets = build_planar_group_offsets(
  909. territory_key,
  910. {
  911. group_id: (bounds[0], bounds[1], bounds[2], bounds[3])
  912. for group_id, bounds in projected_group_bounds.items()
  913. },
  914. )
  915. if not offsets:
  916. return projected_features, projected_units
  917. translated_features = []
  918. for raw_feature, projected_feature in zip(raw_features, projected_features):
  919. group_id = feature_group_by_code.get(raw_feature["properties"]["c"])
  920. offset_x, offset_y = offsets.get(group_id, (0.0, 0.0))
  921. translated_features.append(
  922. {
  923. "type": "Feature",
  924. "properties": dict(projected_feature["properties"]),
  925. "geometry": translate_geometry(projected_feature["geometry"], offset_x, offset_y),
  926. }
  927. )
  928. translated_units = []
  929. for raw_unit, projected_unit in zip(raw_units, projected_units):
  930. group_id = get_special_layout_group_id(territory_key, raw_unit["x"], raw_unit["y"])
  931. offset_x, offset_y = offsets.get(group_id, (0.0, 0.0))
  932. translated_unit = dict(projected_unit)
  933. translated_unit["x"] = round(float(translated_unit["x"]) + offset_x, 6)
  934. translated_unit["y"] = round(float(translated_unit["y"]) + offset_y, 6)
  935. translated_units.append(translated_unit)
  936. return translated_features, translated_units
  937. def reduce_group_distances_without_overlap(
  938. territory_key: str,
  939. raw_features: list[dict],
  940. projected_features: list[dict],
  941. raw_units: list[dict],
  942. projected_units: list[dict],
  943. reduction_ratio: float,
  944. ) -> tuple[list[dict], list[dict]]:
  945. if reduction_ratio <= 0 or not projected_features:
  946. return projected_features, projected_units
  947. clamped_reduction_ratio = min(max(float(reduction_ratio), 0.0), 1.0)
  948. distance_factor = 1.0 - clamped_reduction_ratio
  949. feature_group_by_code: dict[str, str] = {}
  950. group_bounds: dict[str, list[float]] = {}
  951. group_centers: dict[str, tuple[float, float]] = {}
  952. pack_by_feature = SPECIAL_GROUP_PACK_BY_FEATURE.get(territory_key, False)
  953. for raw_feature, projected_feature in zip(raw_features, projected_features):
  954. center_longitude, center_latitude = get_geometry_center(raw_feature["geometry"])
  955. feature_code = str(raw_feature["properties"]["c"])
  956. group_id = feature_code if pack_by_feature else get_special_layout_group_id(
  957. territory_key, center_longitude, center_latitude
  958. )
  959. if not group_id:
  960. continue
  961. feature_group_by_code[feature_code] = group_id
  962. min_x, min_y, max_x, max_y = get_geometry_bounds(projected_feature["geometry"])
  963. current_bounds = group_bounds.get(group_id)
  964. if current_bounds is None:
  965. group_bounds[group_id] = [min_x, min_y, max_x, max_y]
  966. else:
  967. current_bounds[0] = min(current_bounds[0], min_x)
  968. current_bounds[1] = min(current_bounds[1], min_y)
  969. current_bounds[2] = max(current_bounds[2], max_x)
  970. current_bounds[3] = max(current_bounds[3], max_y)
  971. if not group_bounds:
  972. return projected_features, projected_units
  973. min_all_x = min(bounds[0] for bounds in group_bounds.values())
  974. min_all_y = min(bounds[1] for bounds in group_bounds.values())
  975. max_all_x = max(bounds[2] for bounds in group_bounds.values())
  976. max_all_y = max(bounds[3] for bounds in group_bounds.values())
  977. global_center_x = (min_all_x + max_all_x) / 2
  978. global_center_y = (min_all_y + max_all_y) / 2
  979. max_dimension = 0.0
  980. group_layout = {}
  981. for group_id, bounds in group_bounds.items():
  982. min_x, min_y, max_x, max_y = bounds
  983. center_x = (min_x + max_x) / 2
  984. center_y = (min_y + max_y) / 2
  985. width = max_x - min_x
  986. height = max_y - min_y
  987. max_dimension = max(max_dimension, width, height)
  988. target_x = global_center_x + (center_x - global_center_x) * distance_factor
  989. target_y = global_center_y + (center_y - global_center_y) * distance_factor
  990. group_centers[group_id] = (center_x, center_y)
  991. group_layout[group_id] = {
  992. "center_x": target_x,
  993. "center_y": target_y,
  994. "target_x": target_x,
  995. "target_y": target_y,
  996. "width": width,
  997. "height": height,
  998. }
  999. group_gap_factor = SPECIAL_GROUP_MIN_GAP_FACTOR.get(territory_key, 0.04)
  1000. effective_bounds_factor = SPECIAL_GROUP_EFFECTIVE_BOUNDS_FACTOR.get(territory_key, 1.0)
  1001. gap = max_dimension * group_gap_factor
  1002. group_ids = list(group_layout.keys())
  1003. for _ in range(180):
  1004. moved = False
  1005. for index in range(len(group_ids)):
  1006. left_group = group_layout[group_ids[index]]
  1007. for right_index in range(index + 1, len(group_ids)):
  1008. right_group = group_layout[group_ids[right_index]]
  1009. delta_x = right_group["center_x"] - left_group["center_x"]
  1010. delta_y = right_group["center_y"] - left_group["center_y"]
  1011. required_x = (
  1012. (left_group["width"] + right_group["width"]) * effective_bounds_factor / 2 + gap
  1013. )
  1014. required_y = (
  1015. (left_group["height"] + right_group["height"]) * effective_bounds_factor / 2 + gap
  1016. )
  1017. overlap_x = required_x - abs(delta_x)
  1018. overlap_y = required_y - abs(delta_y)
  1019. if overlap_x <= 0 or overlap_y <= 0:
  1020. continue
  1021. moved = True
  1022. if overlap_x < overlap_y:
  1023. direction_x = 1 if delta_x >= 0 else -1
  1024. shift = overlap_x / 2
  1025. left_group["center_x"] -= direction_x * shift
  1026. right_group["center_x"] += direction_x * shift
  1027. else:
  1028. direction_y = 1 if delta_y >= 0 else -1
  1029. shift = overlap_y / 2
  1030. left_group["center_y"] -= direction_y * shift
  1031. right_group["center_y"] += direction_y * shift
  1032. for group in group_layout.values():
  1033. group["center_x"] += (group["target_x"] - group["center_x"]) * 0.08
  1034. group["center_y"] += (group["target_y"] - group["center_y"]) * 0.08
  1035. if not moved:
  1036. break
  1037. for _ in range(120):
  1038. moved = False
  1039. for index in range(len(group_ids)):
  1040. left_group = group_layout[group_ids[index]]
  1041. for right_index in range(index + 1, len(group_ids)):
  1042. right_group = group_layout[group_ids[right_index]]
  1043. delta_x = right_group["center_x"] - left_group["center_x"]
  1044. delta_y = right_group["center_y"] - left_group["center_y"]
  1045. required_x = (
  1046. (left_group["width"] + right_group["width"]) * effective_bounds_factor / 2 + gap
  1047. )
  1048. required_y = (
  1049. (left_group["height"] + right_group["height"]) * effective_bounds_factor / 2 + gap
  1050. )
  1051. overlap_x = required_x - abs(delta_x)
  1052. overlap_y = required_y - abs(delta_y)
  1053. if overlap_x <= 0 or overlap_y <= 0:
  1054. continue
  1055. moved = True
  1056. if overlap_x < overlap_y:
  1057. direction_x = 1 if delta_x >= 0 else -1
  1058. shift = overlap_x / 2
  1059. left_group["center_x"] -= direction_x * shift
  1060. right_group["center_x"] += direction_x * shift
  1061. else:
  1062. direction_y = 1 if delta_y >= 0 else -1
  1063. shift = overlap_y / 2
  1064. left_group["center_y"] -= direction_y * shift
  1065. right_group["center_y"] += direction_y * shift
  1066. if not moved:
  1067. break
  1068. group_offsets = {}
  1069. for group_id, group in group_layout.items():
  1070. center_x, center_y = group_centers[group_id]
  1071. group_offsets[group_id] = (group["center_x"] - center_x, group["center_y"] - center_y)
  1072. translated_features = []
  1073. for raw_feature, projected_feature in zip(raw_features, projected_features):
  1074. group_id = feature_group_by_code.get(raw_feature["properties"]["c"])
  1075. offset_x, offset_y = group_offsets.get(group_id, (0.0, 0.0))
  1076. translated_features.append(
  1077. {
  1078. "type": "Feature",
  1079. "properties": dict(projected_feature["properties"]),
  1080. "geometry": translate_geometry(projected_feature["geometry"], offset_x, offset_y),
  1081. }
  1082. )
  1083. translated_units = []
  1084. for raw_unit, projected_unit in zip(raw_units, projected_units):
  1085. unit_commune_code = str(raw_unit.get("c", ""))
  1086. group_id = unit_commune_code if pack_by_feature and unit_commune_code else get_special_layout_group_id(
  1087. territory_key, raw_unit["x"], raw_unit["y"]
  1088. )
  1089. offset_x, offset_y = group_offsets.get(group_id, (0.0, 0.0))
  1090. translated_unit = dict(projected_unit)
  1091. translated_unit["x"] = round(float(translated_unit["x"]) + offset_x, 6)
  1092. translated_unit["y"] = round(float(translated_unit["y"]) + offset_y, 6)
  1093. translated_units.append(translated_unit)
  1094. return translated_features, translated_units
  1095. def optimize_special_territory_features(territory_key: str, features: list[dict]) -> list[dict]:
  1096. simplification = SPECIAL_TERRITORY_SIMPLIFICATION.get(territory_key)
  1097. source_centers = get_layout_source_centers(territory_key, features)
  1098. optimized_features = []
  1099. precision = SPECIAL_TERRITORY_QUANTIZATION.get(territory_key, 5)
  1100. for feature in features:
  1101. geometry = feature["geometry"]
  1102. commune_name = feature["properties"].get("n", "")
  1103. if simplification:
  1104. geometry = simplify_geometry(
  1105. geometry,
  1106. simplification["minDistance"],
  1107. simplification["minArea"],
  1108. )
  1109. geometry = trim_special_commune_polygons(territory_key, commune_name, geometry)
  1110. if source_centers:
  1111. geometry = transform_geometry_for_layout(territory_key, geometry, source_centers)
  1112. optimized_features.append(
  1113. {
  1114. "type": "Feature",
  1115. "properties": dict(feature["properties"]),
  1116. "geometry": quantize_geometry(geometry, precision),
  1117. }
  1118. )
  1119. return optimized_features
  1120. def optimize_special_territory_units(
  1121. territory_key: str,
  1122. units: list[dict],
  1123. source_centers: dict[str, tuple[float, float]],
  1124. ) -> list[dict]:
  1125. if not source_centers:
  1126. return [dict(unit) for unit in units]
  1127. optimized_units = []
  1128. for unit in units:
  1129. optimized_longitude, optimized_latitude = transform_point_for_layout(
  1130. territory_key,
  1131. unit["x"],
  1132. unit["y"],
  1133. source_centers,
  1134. )
  1135. optimized_unit = dict(unit)
  1136. optimized_unit["x"] = optimized_longitude
  1137. optimized_unit["y"] = optimized_latitude
  1138. optimized_units.append(optimized_unit)
  1139. return optimized_units
  1140. def fetch_department_commune_features(
  1141. department_code: str,
  1142. competence_by_commune_code: dict[str, dict] | None = None,
  1143. ) -> list[dict]:
  1144. query = urlencode(
  1145. {
  1146. "codeDepartement": department_code,
  1147. "fields": "nom,code,codeDepartement,codeRegion,centre,contour",
  1148. "format": "geojson",
  1149. "geometry": "contour",
  1150. }
  1151. )
  1152. url = f"{API_BASE_URL}/communes?{query}"
  1153. payload = fetch_json(url)
  1154. features = payload.get("features", [])
  1155. normalized_features = []
  1156. for feature in features:
  1157. properties = feature["properties"]
  1158. competence = (competence_by_commune_code or {}).get(properties["code"], {})
  1159. normalized_properties = {
  1160. "c": properties["code"],
  1161. "n": properties["nom"],
  1162. "d": properties["codeDepartement"],
  1163. "r": properties["codeRegion"],
  1164. "k": COMMUNE_ENTITY_TYPE,
  1165. }
  1166. if competence.get("i"):
  1167. normalized_properties["i"] = competence["i"]
  1168. if competence.get("u"):
  1169. normalized_properties["u"] = competence["u"]
  1170. if competence.get("m"):
  1171. normalized_properties["m"] = competence["m"]
  1172. if competence.get("v"):
  1173. normalized_properties["v"] = competence["v"]
  1174. normalized_features.append(
  1175. {
  1176. "type": "Feature",
  1177. "properties": normalized_properties,
  1178. "geometry": quantize_geometry(feature["geometry"]),
  1179. }
  1180. )
  1181. return normalized_features
  1182. def fetch_region_commune_features(
  1183. territory: dict,
  1184. competence_by_commune_code: dict[str, dict] | None = None,
  1185. ) -> list[dict]:
  1186. region_slug = territory["key"].removeprefix("region-")
  1187. url = (
  1188. f"{REGION_COMMUNES_GEOJSON_BASE_URL}/{region_slug}/"
  1189. f"communes-{region_slug}.geojson"
  1190. )
  1191. payload = fetch_json(url)
  1192. features = payload.get("features", [])
  1193. normalized_features = []
  1194. for feature in features:
  1195. properties = feature["properties"]
  1196. code = properties["code"]
  1197. competence = (competence_by_commune_code or {}).get(code, {})
  1198. normalized_properties = {
  1199. "c": code,
  1200. "n": properties["nom"],
  1201. "d": get_department_code_from_commune_code(code),
  1202. "r": territory["regionCode"],
  1203. "k": COMMUNE_ENTITY_TYPE,
  1204. }
  1205. if competence.get("i"):
  1206. normalized_properties["i"] = competence["i"]
  1207. if competence.get("u"):
  1208. normalized_properties["u"] = competence["u"]
  1209. if competence.get("m"):
  1210. normalized_properties["m"] = competence["m"]
  1211. if competence.get("v"):
  1212. normalized_properties["v"] = competence["v"]
  1213. normalized_features.append(
  1214. {
  1215. "type": "Feature",
  1216. "properties": normalized_properties,
  1217. "geometry": quantize_geometry(feature["geometry"]),
  1218. }
  1219. )
  1220. return normalized_features
  1221. def fetch_france_region_features(
  1222. region_metadata_by_code: dict[str, dict],
  1223. metro_region_codes: list[str],
  1224. region_key_by_code: dict[str, str],
  1225. ) -> list[dict]:
  1226. payload = fetch_json(REGIONS_GEOJSON_URL)
  1227. features_by_code = {}
  1228. for feature in payload.get("features", []):
  1229. code = feature["properties"]["code"]
  1230. if code not in region_metadata_by_code or code not in region_key_by_code:
  1231. continue
  1232. metadata = region_metadata_by_code[code]
  1233. features_by_code[code] = {
  1234. "type": "Feature",
  1235. "properties": {
  1236. "c": code,
  1237. "n": metadata["nom"],
  1238. "d": "",
  1239. "r": code,
  1240. "k": REGION_ENTITY_TYPE,
  1241. "x": region_key_by_code[code],
  1242. },
  1243. "geometry": quantize_geometry(feature["geometry"]),
  1244. }
  1245. return [features_by_code[code] for code in metro_region_codes if code in features_by_code]
  1246. def fetch_metropolitan_department_features(
  1247. department_metadata_by_code: dict[str, dict],
  1248. metro_department_codes: list[str],
  1249. ) -> list[dict]:
  1250. payload = fetch_json(DEPARTMENTS_GEOJSON_URL)
  1251. features_by_code = {}
  1252. for feature in payload.get("features", []):
  1253. code = feature["properties"]["code"]
  1254. if code not in department_metadata_by_code:
  1255. continue
  1256. metadata = department_metadata_by_code[code]
  1257. features_by_code[code] = {
  1258. "type": "Feature",
  1259. "properties": {
  1260. "c": code,
  1261. "n": metadata["nom"],
  1262. "d": code,
  1263. "r": metadata["codeRegion"],
  1264. "k": DEPARTMENT_ENTITY_TYPE,
  1265. "x": department_territory_key(code),
  1266. },
  1267. "geometry": quantize_geometry(feature["geometry"]),
  1268. }
  1269. return [features_by_code[code] for code in metro_department_codes if code in features_by_code]
  1270. def build_index_entries(
  1271. features: list[dict],
  1272. territory_key: str,
  1273. commune_postal_codes_by_code: dict[str, list[str]],
  1274. is_hidden: bool = False,
  1275. ) -> list[dict]:
  1276. entries = []
  1277. for feature in features:
  1278. properties = feature["properties"]
  1279. entry = {
  1280. "c": properties["c"],
  1281. "n": properties["n"],
  1282. "t": territory_key,
  1283. "k": properties["k"],
  1284. }
  1285. if properties["k"] == COMMUNE_ENTITY_TYPE:
  1286. entry["p"] = commune_postal_codes_by_code.get(properties["c"], [])
  1287. if is_hidden:
  1288. entry["h"] = 1
  1289. entries.append(entry)
  1290. return entries
  1291. def write_json(path: Path, payload: dict | list) -> None:
  1292. path.parent.mkdir(parents=True, exist_ok=True)
  1293. with path.open("w", encoding="utf-8") as file:
  1294. json.dump(payload, file, ensure_ascii=False, separators=(",", ":"))
  1295. def clear_generated_files() -> None:
  1296. TERRITORY_DIR.mkdir(parents=True, exist_ok=True)
  1297. for path in TERRITORY_DIR.glob("*.json"):
  1298. path.unlink()
  1299. for path in [DATA_DIR / "communes-index.json", DATA_DIR / "territories.json"]:
  1300. if path.exists():
  1301. path.unlink()
  1302. def build_france_territory(metro_department_codes: list[str]) -> dict:
  1303. return {
  1304. "key": "france",
  1305. "label": "France",
  1306. "shortLabel": "France",
  1307. "entityType": REGION_ENTITY_TYPE,
  1308. "entityLabelSingular": "région de gendarmerie",
  1309. "entityLabelPlural": "régions de gendarmerie",
  1310. "departmentCodes": metro_department_codes,
  1311. "navVisible": True,
  1312. }
  1313. def build_region_territories(metro_regions: list[dict], department_codes_by_region: dict[str, list[str]]) -> list[dict]:
  1314. territories = []
  1315. for region in metro_regions:
  1316. territories.append(
  1317. {
  1318. "key": region_territory_key(region["nom"]),
  1319. "label": region["nom"],
  1320. "shortLabel": region["nom"],
  1321. "entityType": DEPARTMENT_ENTITY_TYPE,
  1322. "entityLabelSingular": "groupement",
  1323. "entityLabelPlural": "groupements",
  1324. "departmentCodes": department_codes_by_region.get(region["code"], []),
  1325. "regionCode": region["code"],
  1326. "navVisible": True,
  1327. }
  1328. )
  1329. return territories
  1330. def build_department_territories(metro_departments: list[dict], region_key_by_code: dict[str, str]) -> list[dict]:
  1331. territories = []
  1332. for department in metro_departments:
  1333. territories.append(
  1334. {
  1335. "key": department_territory_key(department["code"]),
  1336. "label": department["nom"],
  1337. "shortLabel": department["nom"],
  1338. "entityType": COMMUNE_ENTITY_TYPE,
  1339. "entityLabelSingular": "commune",
  1340. "entityLabelPlural": "communes",
  1341. "departmentCodes": [department["code"]],
  1342. "regionCode": department["codeRegion"],
  1343. "parentTerritoryKey": region_key_by_code[department["codeRegion"]],
  1344. "groupementView": True,
  1345. "navVisible": False,
  1346. }
  1347. )
  1348. return territories
  1349. def build_overseas_territories() -> list[dict]:
  1350. territories = []
  1351. for territory in OVERSEAS_TERRITORIES:
  1352. territories.append(
  1353. {
  1354. "key": slugify(territory["label"]),
  1355. "label": territory["label"],
  1356. "shortLabel": territory["shortLabel"],
  1357. "entityType": COMMUNE_ENTITY_TYPE,
  1358. "entityLabelSingular": "commune",
  1359. "entityLabelPlural": "communes",
  1360. "departmentCodes": [territory["code"]],
  1361. "groupementView": True,
  1362. "navVisible": True,
  1363. }
  1364. )
  1365. return territories
  1366. def build_data() -> None:
  1367. DATA_DIR.mkdir(parents=True, exist_ok=True)
  1368. TERRITORY_DIR.mkdir(parents=True, exist_ok=True)
  1369. clear_generated_files()
  1370. department_metadata = get_department_metadata()
  1371. region_metadata = get_region_metadata()
  1372. commune_postal_codes_by_code = get_commune_postal_codes_by_code()
  1373. competence_by_commune_code, service_registry, commune_codes_by_service_key = get_commune_territorial_competence_data()
  1374. public_units_by_service_key = get_gendarmerie_public_units_by_service_key(service_registry)
  1375. public_units_by_service_key.update(
  1376. get_police_public_units_by_service_key(service_registry, commune_codes_by_service_key)
  1377. )
  1378. department_metadata_by_code = {department["code"]: department for department in department_metadata}
  1379. region_metadata_by_code = {region["code"]: region for region in region_metadata}
  1380. metro_departments = [department for department in department_metadata if department.get("zone") == "metro"]
  1381. metro_department_codes = [department["code"] for department in metro_departments]
  1382. department_codes_by_region: dict[str, list[str]] = {}
  1383. for department in metro_departments:
  1384. department_codes_by_region.setdefault(department["codeRegion"], []).append(department["code"])
  1385. metro_regions = [
  1386. region
  1387. for region in region_metadata
  1388. if region["code"] in department_codes_by_region
  1389. ]
  1390. metro_region_codes = [region["code"] for region in metro_regions]
  1391. region_key_by_code = {region["code"]: region_territory_key(region["nom"]) for region in metro_regions}
  1392. france_territory = build_france_territory(metro_department_codes)
  1393. region_territories = build_region_territories(metro_regions, department_codes_by_region)
  1394. department_territories = build_department_territories(metro_departments, region_key_by_code)
  1395. overseas_territories = build_overseas_territories()
  1396. territory_definitions = [
  1397. france_territory,
  1398. *region_territories,
  1399. *overseas_territories,
  1400. *department_territories,
  1401. ]
  1402. france_region_features = fetch_france_region_features(
  1403. region_metadata_by_code,
  1404. metro_region_codes,
  1405. region_key_by_code,
  1406. )
  1407. print(f"France: {len(france_region_features)} régions")
  1408. metropolitan_department_features = fetch_metropolitan_department_features(
  1409. department_metadata_by_code,
  1410. metro_department_codes,
  1411. )
  1412. region_department_features_by_key = {territory["key"]: [] for territory in region_territories}
  1413. for feature in metropolitan_department_features:
  1414. region_key = region_key_by_code.get(feature["properties"]["r"])
  1415. if region_key:
  1416. region_department_features_by_key[region_key].append(feature)
  1417. print(f"Départements métropolitains: {len(metropolitan_department_features)}")
  1418. department_commune_features_by_key = {territory["key"]: [] for territory in department_territories}
  1419. with ThreadPoolExecutor(max_workers=8) as executor:
  1420. future_by_key = {
  1421. executor.submit(fetch_region_commune_features, territory, competence_by_commune_code): territory["key"]
  1422. for territory in region_territories
  1423. }
  1424. for future in as_completed(future_by_key):
  1425. region_key = future_by_key[future]
  1426. features = future.result()
  1427. for feature in features:
  1428. department_key = department_territory_key(feature["properties"]["d"])
  1429. if department_key in department_commune_features_by_key:
  1430. department_commune_features_by_key[department_key].append(feature)
  1431. print(f"{region_key}: {len(features)} communes réparties par département")
  1432. overseas_commune_features_by_key: dict[str, list[dict]] = {}
  1433. overseas_masked_commune_features_by_key: dict[str, list[dict]] = {}
  1434. with ThreadPoolExecutor(max_workers=6) as executor:
  1435. future_by_key = {
  1436. executor.submit(
  1437. fetch_department_commune_features,
  1438. territory["departmentCodes"][0],
  1439. competence_by_commune_code,
  1440. ): territory["key"]
  1441. for territory in overseas_territories
  1442. }
  1443. for future in as_completed(future_by_key):
  1444. territory_key = future_by_key[future]
  1445. features = future.result()
  1446. if territory_key == "polynesie-francaise":
  1447. hidden_features = [
  1448. feature
  1449. for feature in features
  1450. if feature["properties"]["n"] not in POLYNESIE_RETAINED_COMMUNES
  1451. ]
  1452. features = [
  1453. feature
  1454. for feature in features
  1455. if feature["properties"]["n"] in POLYNESIE_RETAINED_COMMUNES
  1456. ]
  1457. overseas_masked_commune_features_by_key[territory_key] = hidden_features
  1458. overseas_commune_features_by_key[territory_key] = features
  1459. print(f"{territory_key}: {len(features)} communes")
  1460. territories_manifest = []
  1461. index_entries = []
  1462. for territory in territory_definitions:
  1463. masked_territory_features: list[dict] = []
  1464. if territory["key"] == "france":
  1465. territory_features = france_region_features
  1466. elif territory["entityType"] == DEPARTMENT_ENTITY_TYPE:
  1467. territory_features = region_department_features_by_key[territory["key"]]
  1468. elif territory.get("parentTerritoryKey"):
  1469. territory_features = department_commune_features_by_key[territory["key"]]
  1470. else:
  1471. territory_features = overseas_commune_features_by_key[territory["key"]]
  1472. masked_territory_features = overseas_masked_commune_features_by_key.get(territory["key"], [])
  1473. payload_features = territory_features
  1474. projected_unit_origin: tuple[float, float] | None = None
  1475. projected_unit_source: list[dict] = []
  1476. special_layout_source_centers: dict[str, tuple[float, float]] = {}
  1477. planar_compaction = PLANAR_TERRITORY_COMPACTION.get(territory["key"])
  1478. compact_factor = planar_compaction["factor"] if planar_compaction else 1.0
  1479. group_distance_reduction = SPECIAL_GROUP_DISTANCE_REDUCTION.get(territory["key"], 0.0)
  1480. if territory["key"] in SPECIAL_TERRITORY_LAYOUTS or territory["key"] in SPECIAL_TERRITORY_SIMPLIFICATION:
  1481. special_layout_source_centers = get_layout_source_centers(territory["key"], territory_features)
  1482. payload_features = optimize_special_territory_features(territory["key"], territory_features)
  1483. if territory["key"] in PREPROJECTED_TERRITORIES:
  1484. payload_features = [
  1485. {
  1486. "type": "Feature",
  1487. "properties": dict(feature["properties"]),
  1488. "geometry": project_geometry_to_planar_mercator(feature["geometry"]),
  1489. }
  1490. for feature in payload_features
  1491. ]
  1492. if compact_factor < 1:
  1493. payload_features, projected_unit_origin = compact_planar_features(payload_features, compact_factor)
  1494. payload = {
  1495. "type": "FeatureCollection",
  1496. "features": payload_features,
  1497. }
  1498. if territory["key"] in PREPROJECTED_TERRITORIES:
  1499. payload["coordinateSpace"] = (
  1500. "planar-layout" if territory["key"] in SPECIAL_PLANAR_LAYOUT_ROWS or compact_factor < 1 else "planar-mercator"
  1501. )
  1502. service_lookup = {}
  1503. unit_lookup = {}
  1504. for feature in territory_features:
  1505. service_keys = []
  1506. if feature["properties"].get("u"):
  1507. service_keys.append(feature["properties"]["u"])
  1508. service_keys.extend(feature["properties"].get("v", []))
  1509. for service_key in service_keys:
  1510. if service_key in service_registry:
  1511. service_lookup[service_key] = service_registry[service_key]
  1512. if service_key in public_units_by_service_key and territory.get("groupementView"):
  1513. unit_lookup[service_key] = public_units_by_service_key[service_key]
  1514. if service_lookup:
  1515. payload["services"] = service_lookup
  1516. if unit_lookup:
  1517. optimized_units = list(unit_lookup.values())
  1518. if special_layout_source_centers:
  1519. optimized_units = optimize_special_territory_units(
  1520. territory["key"],
  1521. optimized_units,
  1522. special_layout_source_centers,
  1523. )
  1524. if territory["key"] in PREPROJECTED_TERRITORIES:
  1525. projected_unit_source = optimized_units
  1526. optimized_units = project_units_to_planar_mercator(optimized_units)
  1527. if compact_factor < 1 and projected_unit_origin:
  1528. origin_x, origin_y = projected_unit_origin
  1529. optimized_units = compact_planar_units(optimized_units, origin_x, origin_y, compact_factor)
  1530. if territory["key"] in SPECIAL_PLANAR_LAYOUT_ROWS:
  1531. payload_features, optimized_units = apply_special_planar_layout(
  1532. territory["key"],
  1533. territory_features,
  1534. payload_features,
  1535. projected_unit_source or [],
  1536. optimized_units,
  1537. )
  1538. payload["features"] = payload_features
  1539. if group_distance_reduction > 0 and territory["key"] in PREPROJECTED_TERRITORIES:
  1540. payload_features, optimized_units = reduce_group_distances_without_overlap(
  1541. territory["key"],
  1542. territory_features,
  1543. payload_features,
  1544. projected_unit_source or [],
  1545. optimized_units,
  1546. group_distance_reduction,
  1547. )
  1548. payload["features"] = payload_features
  1549. payload["units"] = sorted(
  1550. optimized_units,
  1551. key=lambda item: (normalize_sort_text(item["n"]), item["n"].lower(), item["s"]),
  1552. )
  1553. elif territory["key"] in SPECIAL_PLANAR_LAYOUT_ROWS:
  1554. payload_features, _ = apply_special_planar_layout(
  1555. territory["key"],
  1556. territory_features,
  1557. payload_features,
  1558. [],
  1559. [],
  1560. )
  1561. payload["features"] = payload_features
  1562. if group_distance_reduction > 0 and territory["key"] in PREPROJECTED_TERRITORIES and not unit_lookup:
  1563. payload_features, _ = reduce_group_distances_without_overlap(
  1564. territory["key"],
  1565. territory_features,
  1566. payload["features"],
  1567. [],
  1568. [],
  1569. group_distance_reduction,
  1570. )
  1571. payload["features"] = payload_features
  1572. if masked_territory_features:
  1573. masked_communes = sorted(
  1574. [
  1575. {
  1576. "c": feature["properties"]["c"],
  1577. "n": feature["properties"]["n"],
  1578. "p": commune_postal_codes_by_code.get(feature["properties"]["c"], []),
  1579. }
  1580. for feature in masked_territory_features
  1581. ],
  1582. key=lambda item: (normalize_sort_text(item["n"]), item["n"].lower(), item["c"]),
  1583. )
  1584. masked_commune_name_by_code = {item["c"]: item["n"] for item in masked_communes}
  1585. masked_units_by_service_key: dict[str, dict] = {}
  1586. for feature in masked_territory_features:
  1587. properties = feature["properties"]
  1588. service_keys = []
  1589. if properties.get("u"):
  1590. service_keys.append(properties["u"])
  1591. service_keys.extend(properties.get("v", []))
  1592. for service_key in service_keys:
  1593. service = service_registry.get(service_key, {})
  1594. public_unit = public_units_by_service_key.get(service_key, {})
  1595. unit_label = public_unit.get("n") or service.get("l") or service_key
  1596. institution = public_unit.get("i") or service.get("i") or "UNK"
  1597. if service_key not in masked_units_by_service_key:
  1598. masked_units_by_service_key[service_key] = {
  1599. "s": service_key,
  1600. "i": institution,
  1601. "n": unit_label,
  1602. "c": set(),
  1603. }
  1604. masked_units_by_service_key[service_key]["c"].add(properties["c"])
  1605. masked_units = []
  1606. for unit in masked_units_by_service_key.values():
  1607. commune_codes = sorted(unit["c"])
  1608. commune_names = sorted(
  1609. {masked_commune_name_by_code.get(code, code) for code in commune_codes},
  1610. key=normalize_sort_text,
  1611. )
  1612. masked_units.append(
  1613. {
  1614. "s": unit["s"],
  1615. "i": unit["i"],
  1616. "n": unit["n"],
  1617. "c": commune_codes,
  1618. "m": commune_names,
  1619. }
  1620. )
  1621. masked_units.sort(key=lambda item: (normalize_sort_text(item["n"]), item["n"].lower(), item["s"]))
  1622. payload["masked"] = {
  1623. "communes": masked_communes,
  1624. "units": masked_units,
  1625. }
  1626. write_json(TERRITORY_DIR / f"{territory['key']}.json", payload)
  1627. territories_manifest.append(
  1628. {
  1629. "key": territory["key"],
  1630. "label": territory["label"],
  1631. "shortLabel": territory["shortLabel"],
  1632. "departmentCodes": territory["departmentCodes"],
  1633. "entityType": territory["entityType"],
  1634. "entityLabelSingular": territory["entityLabelSingular"],
  1635. "entityLabelPlural": territory["entityLabelPlural"],
  1636. "entityCount": len(territory_features),
  1637. "file": f"./data/territories/{territory['key']}.json",
  1638. "navVisible": territory.get("navVisible", True),
  1639. "parentTerritoryKey": territory.get("parentTerritoryKey"),
  1640. "groupementView": territory.get("groupementView", False),
  1641. "regionCode": territory.get("regionCode"),
  1642. }
  1643. )
  1644. index_entries.extend(
  1645. build_index_entries(
  1646. territory_features,
  1647. territory["key"],
  1648. commune_postal_codes_by_code,
  1649. )
  1650. )
  1651. if masked_territory_features:
  1652. index_entries.extend(
  1653. build_index_entries(
  1654. masked_territory_features,
  1655. territory["key"],
  1656. commune_postal_codes_by_code,
  1657. is_hidden=True,
  1658. )
  1659. )
  1660. print(
  1661. f"{territory['label']}: {len(territory_features)} {territory['entityLabelPlural']}"
  1662. )
  1663. index_entries.sort(key=lambda item: (normalize_sort_text(item["n"]), item["n"].lower(), item["c"], item["t"]))
  1664. write_json(DATA_DIR / "communes-index.json", index_entries)
  1665. write_json(DATA_DIR / "territories.json", territories_manifest)
  1666. print(f"Index written: {len(index_entries)} entrées")
  1667. if __name__ == "__main__":
  1668. build_data()