瀏覽代碼

Finalise carte gendarmerie: halo maritime, outre-mer et corrections UI

benjamin 7 月之前
父節點
當前提交
07a602e6ce
共有 51 個文件被更改,包括 780 次插入 和 46 次删除
  1. 57 21
      README.md
  2. 0 0
      data/territories/departement-06.json
  3. 0 0
      data/territories/departement-11.json
  4. 0 0
      data/territories/departement-13.json
  5. 0 0
      data/territories/departement-14.json
  6. 0 0
      data/territories/departement-17.json
  7. 0 0
      data/territories/departement-22.json
  8. 0 0
      data/territories/departement-27.json
  9. 0 0
      data/territories/departement-29.json
  10. 0 0
      data/territories/departement-2a.json
  11. 0 0
      data/territories/departement-2b.json
  12. 0 0
      data/territories/departement-30.json
  13. 0 0
      data/territories/departement-33.json
  14. 0 0
      data/territories/departement-34.json
  15. 0 0
      data/territories/departement-35.json
  16. 0 0
      data/territories/departement-40.json
  17. 0 0
      data/territories/departement-44.json
  18. 0 0
      data/territories/departement-50.json
  19. 0 0
      data/territories/departement-56.json
  20. 0 0
      data/territories/departement-59.json
  21. 0 0
      data/territories/departement-62.json
  22. 0 0
      data/territories/departement-64.json
  23. 0 0
      data/territories/departement-66.json
  24. 0 0
      data/territories/departement-76.json
  25. 0 0
      data/territories/departement-80.json
  26. 0 0
      data/territories/departement-83.json
  27. 0 0
      data/territories/departement-85.json
  28. 0 0
      data/territories/france.json
  29. 0 0
      data/territories/guadeloupe.json
  30. 0 0
      data/territories/guyane.json
  31. 0 0
      data/territories/la-reunion.json
  32. 0 0
      data/territories/martinique.json
  33. 0 0
      data/territories/mayotte.json
  34. 0 0
      data/territories/nouvelle-caledonie.json
  35. 0 0
      data/territories/polynesie-francaise.json
  36. 0 0
      data/territories/region-bretagne.json
  37. 0 0
      data/territories/region-corse.json
  38. 0 0
      data/territories/region-hauts-de-france.json
  39. 0 0
      data/territories/region-normandie.json
  40. 0 0
      data/territories/region-nouvelle-aquitaine.json
  41. 0 0
      data/territories/region-occitanie.json
  42. 0 0
      data/territories/region-pays-de-la-loire.json
  43. 0 0
      data/territories/region-provence-alpes-cote-d-azur.json
  44. 0 0
      data/territories/saint-barthelemy.json
  45. 0 0
      data/territories/saint-martin.json
  46. 0 0
      data/territories/saint-pierre-et-miquelon.json
  47. 0 0
      data/territories/wallis-et-futuna.json
  48. 3 3
      index.html
  49. 414 20
      script.js
  50. 285 0
      scripts/build_communes_data.py
  51. 21 2
      styles.css

+ 57 - 21
README.md

@@ -1,25 +1,34 @@
 # GiePlaces
 
-Carte de France interactive avec une vue France en départements, une vue par région en communes, et les territoires DOM, TOM et COM.
+Application web statique de cartographie administrative orientée gendarmerie.
 
-## Fonctionnalités
+## Hiérarchie des vues
 
-- vue `France` avec découpage départemental,
-- vue par région avec toutes les communes de la région,
-- vues spécifiques pour les COM/TOM hors régions,
-- survol d'un département ou d'une commune avec tooltip personnalisé,
-- clic sur un élément pour la sélectionner,
-- recherche avec recentrage automatique,
-- thème visuel repris de `GieWorldMap`.
+- `France` : découpage en 13 régions de gendarmerie.
+- `Région de gendarmerie` : découpage en groupements (départements).
+- `Groupement` : découpage en communes.
+- `Outre-mer` : territoires dédiés (considérés comme vues de type groupement pour l'affichage des communes et unités).
 
-## Structure
+## Fonctionnalités principales
 
-- `index.html` : structure de l'interface.
-- `styles.css` : thème et composants visuels.
-- `script.js` : interactions, recherche, rendu canvas.
-- `scripts/build_communes_data.py` : génération des données départementales et communales simplifiées.
-- `data/communes-index.json` : index léger des départements et communes exposés dans l'interface.
-- `data/territories/*.json` : géométries simplifiées par territoire.
+- rendu cartographique sur `canvas` (survol, sélection, zoom, déplacement),
+- recherche unifiée par nom/code/territoire sur régions, groupements, communes et unités,
+- colorisation des communes par unité territorialement compétente,
+- communes en zone police nationale conservées en gris,
+- marqueurs d'unités (gendarmerie et police nationale), clic ouvrant une modale détaillée,
+- surbrillance cohérente des entités multi-parties,
+- routage par hash avec navigation arrière/avant navigateur,
+
+## Particularité Polynésie française
+
+- pour des raisons de lisibilité/performance, seules les communes de Tahiti et Moorea sont affichées sur la carte,
+- les autres communes restent disponibles dans la recherche et les filtres,
+- un disclaimer indique les zones masquées et donne accès à leur détail.
+
+## Prérequis
+
+- `python3` (aucune dépendance Python externe nécessaire),
+- accès Internet pour régénérer les données.
 
 ## Génération des données
 
@@ -27,15 +36,42 @@ Carte de France interactive avec une vue France en départements, une vue par r
 python3 scripts/build_communes_data.py
 ```
 
-## Lancer en local
+Fichiers produits :
+
+- `data/territories.json` : métadonnées des territoires (navigation, labels, type d'entité),
+- `data/territories/*.json` : géométries et données associées par territoire,
+- `data/communes-index.json` : index de recherche (communes, régions, groupements, unités, zones masquées).
+
+## Lancement local
 
 ```bash
 python3 -m http.server 8008
 ```
 
-Puis ouvrir `http://localhost:8008`.
+Puis ouvrir `http://127.0.0.1:8008`.
+
+## Routage (URL)
+
+- `#/territoire/france`
+- `#/territoire/<territoireKey>`
+- `#/territoire/<territoireKey>/entite/<code>`
+- `#/territoire/<territoireKey>/unite/<serviceKey>`
+
+## Structure du projet
+
+- `index.html` : structure des panneaux, modales et zones de rendu,
+- `styles.css` : thème visuel,
+- `script.js` : rendu carte, interactions, recherche, modales, routage,
+- `scripts/build_communes_data.py` : pipeline de collecte/enrichissement/génération.
+
+## Sources de données
+
+- API Geo : `https://geo.api.gouv.fr/`
+- Géométries administratives simplifiées : `https://github.com/gregoiredavid/france-geojson`
+- Compétences territoriales : `https://www.data.gouv.fr/api/1/datasets/r/c53cd4d4-4623-4772-9b8c-bc72a9cdf4c2`
+- Unités gendarmerie : `https://www.data.gouv.fr/api/1/datasets/r/17320fe6-a896-4686-93e6-502be2ad23f2`
+- Annuaire Service-Public : `https://api-lannuaire.service-public.gouv.fr/`
 
-## Source des données
+## Auteur
 
-- API officielle : `https://geo.api.gouv.fr/`
-- départements métropolitains simplifiés : `https://github.com/gregoiredavid/france-geojson`
+- Benjamin GOUTFER (MDC)

文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-06.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-11.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-13.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-14.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-17.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-22.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-27.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-29.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-2a.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-2b.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-30.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-33.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-34.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-35.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-40.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-44.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-50.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-56.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-59.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-62.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-64.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-66.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-76.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-80.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-83.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/departement-85.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/france.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/guadeloupe.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/guyane.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/la-reunion.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/martinique.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/mayotte.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/nouvelle-caledonie.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/polynesie-francaise.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-bretagne.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-corse.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-hauts-de-france.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-normandie.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-nouvelle-aquitaine.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-occitanie.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-pays-de-la-loire.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/region-provence-alpes-cote-d-azur.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/saint-barthelemy.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/saint-martin.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/saint-pierre-et-miquelon.json


文件差異過大導致無法顯示
+ 0 - 0
data/territories/wallis-et-futuna.json


+ 3 - 3
index.html

@@ -22,14 +22,14 @@
     />
     <meta property="og:locale" content="fr_FR" />
     <meta property="og:type" content="website" />
-    <meta property="og:title" content="Carte communale de France" />
+    <meta property="og:title" content="Carte Intéractive de Gendarmerie" />
     <meta
       property="og:description"
       content="Explorez toutes les communes de France, Corse, DOM, TOM et COM avec tooltip au survol."
     />
     <meta property="og:image" content="./favicon.svg" />
     <meta name="twitter:card" content="summary" />
-    <meta name="twitter:title" content="Carte communale de France" />
+    <meta name="twitter:title" content="Carte Intéractive de Gendarmerie" />
     <meta
       name="twitter:description"
       content="Carte interactive de la Gendarmerie Départementale."
@@ -37,7 +37,7 @@
     <meta name="twitter:image" content="./favicon.svg" />
     <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
     <link rel="shortcut icon" href="./favicon.svg" />
-    <title>Carte des communes de France</title>
+    <title>Carte Intéractive de Gendarmerie</title>
     <link rel="stylesheet" href="styles.css" />
   </head>
   <body>

+ 414 - 20
script.js

@@ -2,6 +2,7 @@ const mapCanvas = document.getElementById("map-canvas");
 const mapShell = document.getElementById("map-shell");
 const mapTooltip = document.getElementById("map-tooltip");
 const mapStatus = document.getElementById("map-status");
+const mapHeader = document.querySelector(".map-header");
 const entitySidebar = document.getElementById("entity-sidebar");
 const entityListTitle = document.getElementById("entity-list-title");
 
@@ -48,6 +49,9 @@ const UNIT_MARKER_CORNER_RADIUS = 2.8;
 const UNIT_MARKER_HOVER_SIZE = 12;
 const UNIT_MARKER_HIT_RADIUS = 10;
 const UNIT_FOCUS_SCALE = 14;
+const FRANCE_REGION_BADGE_SIZE = 28.8;
+const MARITIME_GLOW_COLOR = "rgba(23, 74, 142, 1)";
+const MARITIME_GLOW_BLUR = 88;
 const TERRITORY_DISPLAY_SCALE = {};
 const REGION_ENTITY_TYPE = "region";
 const DEPARTMENT_ENTITY_TYPE = "departement";
@@ -164,6 +168,8 @@ const state = {
   activeTerritoryData: null,
   hoveredPartKey: null,
   hoveredUnitKey: null,
+  listHoveredFeatureIds: new Set(),
+  listHoveredUnitKey: null,
   selectedCommuneId: null,
   selectedPartKey: null,
   selectedUnitKey: null,
@@ -175,6 +181,8 @@ const state = {
   routeHash: "",
   routeApplying: false,
 };
+const territoryBadgeImagesByKey = new Map();
+const territoryBadgeLoadingKeys = new Set();
 
 const zoom = d3
   .zoom()
@@ -301,6 +309,17 @@ function getUnitInstitutionLabel(institution) {
   return institution === "PN" ? "Police nationale" : "Gendarmerie nationale";
 }
 
+function formatUnitFilterLabel(unit) {
+  const hostCommune = String(unit?.hostCommune || "").trim();
+  if (unit?.institution === "PN" && hostCommune) {
+    return `Commissariat de ${hostCommune}`;
+  }
+  if (unit?.institution === "GN" && hostCommune) {
+    return `Brigade de ${hostCommune}`;
+  }
+  return String(unit?.name || hostCommune || "Unité");
+}
+
 function isGroupementCommuneView(territoryMeta) {
   return territoryMeta?.entityType === COMMUNE_ENTITY_TYPE && Boolean(territoryMeta?.groupementView);
 }
@@ -309,6 +328,10 @@ function isUnitMarkerView(territoryMeta) {
   return isGroupementCommuneView(territoryMeta);
 }
 
+function usesUnitDefaultList(territoryKey = state.activeTerritoryKey) {
+  return isGroupementCommuneView(getTerritoryMeta(territoryKey));
+}
+
 function buildCompetenceColorByServiceKey(serviceByKey) {
   const serviceKeys = Array.from(serviceByKey.entries())
     .filter(([, service]) => service?.i === "GN")
@@ -397,6 +420,15 @@ function getTerritoryMeta(key) {
   return state.territoryByKey.get(key) || null;
 }
 
+function isOverseasDepartmentCode(code) {
+  return /^(97|98)/.test(String(code || "").toUpperCase());
+}
+
+function isOverseasTerritory(territoryMeta) {
+  const codes = territoryMeta?.departmentCodes || [];
+  return codes.length > 0 && codes.every(isOverseasDepartmentCode);
+}
+
 function getRegionTerritoryByCode(regionCode) {
   if (!regionCode) {
     return null;
@@ -529,9 +561,10 @@ function syncRouteFromState(options = {}) {
 function applyEntityUi() {
   const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
   const entityCopy = getEntityCopy();
+  const useUnitList = usesUnitDefaultList(state.activeTerritoryKey);
 
-  entityListTitle.textContent = entityCopy.listTitle;
-  entitySidebar.setAttribute("aria-label", entityCopy.sidebarAria);
+  entityListTitle.textContent = useUnitList ? "Unités" : entityCopy.listTitle;
+  entitySidebar.setAttribute("aria-label", useUnitList ? "Liste des unités" : entityCopy.sidebarAria);
   communeSearchInputDesktop.placeholder = entityCopy.searchPlaceholder;
   communeSearchInputMobile.placeholder = entityCopy.searchPlaceholder;
   communeListModalTitle.textContent = entityCopy.searchTitle;
@@ -559,6 +592,120 @@ function getPreparedFeatureFromPartKey(partKey) {
   return preparedPart ? getPreparedFeatureById(preparedPart.featureId) : null;
 }
 
+function getPreparedFeaturesFromIds(featureIds) {
+  if (!featureIds?.size) {
+    return [];
+  }
+
+  const preparedFeatures = [];
+  featureIds.forEach((featureId) => {
+    const preparedFeature = getPreparedFeatureById(featureId);
+    if (preparedFeature) {
+      preparedFeatures.push(preparedFeature);
+    }
+  });
+  return preparedFeatures;
+}
+
+function clearListHover(options = {}) {
+  const hadHover = state.listHoveredFeatureIds.size > 0 || Boolean(state.listHoveredUnitKey);
+  state.listHoveredFeatureIds = new Set();
+  state.listHoveredUnitKey = null;
+  if (hadHover && options.render !== false) {
+    requestRender();
+  }
+}
+
+function setListHoverFromEntry(entry) {
+  if (!entry || entry.territoryKey !== state.activeTerritoryKey) {
+    clearListHover();
+    return;
+  }
+
+  const nextFeatureIds = new Set();
+  let nextHoveredUnitKey = null;
+
+  if (entry.kind === UNIT_SEARCH_KIND) {
+    const unit = state.activeTerritoryData?.unitByServiceKey?.get(entry.id);
+    if (unit) {
+      nextHoveredUnitKey = unit.key || unit.serviceKey || entry.id;
+      getAssociatedCommunesForUnit(unit).forEach((feature) => {
+        nextFeatureIds.add(feature.properties.code);
+      });
+    }
+  } else if (!entry.isMasked) {
+    nextFeatureIds.add(entry.id);
+  }
+
+  let hasChanges =
+    nextHoveredUnitKey !== state.listHoveredUnitKey ||
+    nextFeatureIds.size !== state.listHoveredFeatureIds.size;
+
+  if (!hasChanges) {
+    nextFeatureIds.forEach((featureId) => {
+      if (!state.listHoveredFeatureIds.has(featureId)) {
+        hasChanges = true;
+      }
+    });
+  }
+
+  if (!hasChanges) {
+    return;
+  }
+
+  state.listHoveredFeatureIds = nextFeatureIds;
+  state.listHoveredUnitKey = nextHoveredUnitKey;
+  requestRender();
+}
+
+function getBoundsCenter(bounds) {
+  const [[x0, y0], [x1, y1]] = bounds;
+  return [(x0 + x1) / 2, (y0 + y1) / 2];
+}
+
+function getTerritoryBadgeImage(territoryKey) {
+  if (!territoryKey) {
+    return null;
+  }
+
+  const cachedImage = territoryBadgeImagesByKey.get(territoryKey);
+  if (cachedImage) {
+    return cachedImage;
+  }
+
+  const badgeAsset = TERRITORY_BADGE_ASSETS[territoryKey];
+  if (!badgeAsset || territoryBadgeLoadingKeys.has(territoryKey)) {
+    return null;
+  }
+
+  const image = new Image();
+  territoryBadgeLoadingKeys.add(territoryKey);
+  image.decoding = "async";
+  image.onload = () => {
+    territoryBadgeLoadingKeys.delete(territoryKey);
+    territoryBadgeImagesByKey.set(territoryKey, image);
+    requestRender();
+  };
+  image.onerror = () => {
+    territoryBadgeLoadingKeys.delete(territoryKey);
+  };
+  image.src = badgeAsset.src;
+  return null;
+}
+
+function updateMobileLayoutOffsets() {
+  if (!mapHeader || window.innerWidth > 760) {
+    return;
+  }
+
+  const headerBounds = mapHeader.getBoundingClientRect();
+  const controlsTop = Math.ceil(headerBounds.bottom + 8);
+  const legendTop = controlsTop + 54;
+  const rootStyle = document.documentElement.style;
+  rootStyle.setProperty("--mobile-controls-top", `${controlsTop}px`);
+  rootStyle.setProperty("--mobile-legend-top", `${legendTop}px`);
+}
+
 function getPreparedUnitByKey(unitKey) {
   if (!state.activeTerritoryData?.preparedUnits?.length || !unitKey) {
     return null;
@@ -762,6 +909,54 @@ function createTerritoryProjection(territoryData) {
   return projection;
 }
 
+function buildSupplementalIslandMaritimeLines(preparedFeatures) {
+  const lines = [];
+
+  preparedFeatures.forEach((preparedFeature) => {
+    if (!preparedFeature?.parts || preparedFeature.parts.length < 2 || !preparedFeature.primaryPartKey) {
+      return;
+    }
+
+    const primaryPart = preparedFeature.parts.find((part) => part.key === preparedFeature.primaryPartKey);
+    const primaryArea = primaryPart?.area || 0;
+
+    preparedFeature.parts.forEach((part) => {
+      if (!part || part.key === preparedFeature.primaryPartKey) {
+        return;
+      }
+
+      if (primaryArea > 0 && part.area > primaryArea * 0.6) {
+        return;
+      }
+
+      const outerRing = part.projectedRings?.[0];
+      if (!outerRing || outerRing.length < 3) {
+        return;
+      }
+
+      const points = outerRing.slice();
+      const firstPoint = points[0];
+      const lastPoint = points[points.length - 1];
+      if (
+        firstPoint &&
+        lastPoint &&
+        (firstPoint[0] !== lastPoint[0] || firstPoint[1] !== lastPoint[1])
+      ) {
+        points.push([firstPoint[0], firstPoint[1]]);
+      }
+
+      if (points.length >= 2) {
+        lines.push({
+          points,
+          bounds: getLineBounds(points),
+        });
+      }
+    });
+  });
+
+  return lines;
+}
+
 function buildPath2DFromPreparedParts(parts) {
   if (!window.Path2D || !parts?.length) {
     return null;
@@ -834,6 +1029,28 @@ function getUnionBounds(parts) {
   }, initial);
 }
 
+function getLineBounds(points) {
+  if (!points.length) {
+    return [
+      [0, 0],
+      [0, 0],
+    ];
+  }
+
+  const initial = [
+    [Infinity, Infinity],
+    [-Infinity, -Infinity],
+  ];
+
+  return points.reduce((accumulator, [x, y]) => {
+    accumulator[0][0] = Math.min(accumulator[0][0], x);
+    accumulator[0][1] = Math.min(accumulator[0][1], y);
+    accumulator[1][0] = Math.max(accumulator[1][0], x);
+    accumulator[1][1] = Math.max(accumulator[1][1], y);
+    return accumulator;
+  }, initial);
+}
+
 function buildDataRows(feature) {
   const territoryMeta = getTerritoryMeta(feature.properties.territoryKey);
   const entityType = feature.properties.kind || territoryMeta?.entityType || COMMUNE_ENTITY_TYPE;
@@ -1144,8 +1361,9 @@ function openMaskingModal(options = {}) {
 }
 
 function updateMaskingDisclaimer() {
+  const shouldShowDisclaimerForTerritory = state.activeTerritoryKey === "polynesie-francaise";
   const maskedData = state.activeTerritoryData?.maskedData;
-  if (!maskedData?.communes?.length) {
+  if (!shouldShowDisclaimerForTerritory || !maskedData?.communes?.length) {
     maskingDisclaimer.hidden = true;
     maskingDisclaimerText.textContent = "";
     return;
@@ -1233,8 +1451,14 @@ function sortSearchResults(term, items) {
 
 function getSearchResults(term) {
   const normalizedTerm = normalizeText(term);
+  const activeUnitSearchEntries = state.activeTerritoryData?.unitSearchEntries || [];
+  const useUnitList = usesUnitDefaultList(state.activeTerritoryKey);
 
   if (!normalizedTerm) {
+    if (useUnitList) {
+      return activeUnitSearchEntries.slice().sort(compareEntriesAlphabetically).slice(0, MAX_SEARCH_RESULTS);
+    }
+
     return state.searchEntries
       .filter((entry) => entry.territoryKey === state.activeTerritoryKey)
       .sort(compareEntriesAlphabetically)
@@ -1242,7 +1466,6 @@ function getSearchResults(term) {
   }
 
   const matches = [];
-  const activeUnitSearchEntries = state.activeTerritoryData?.unitSearchEntries || [];
 
   for (const entry of state.searchEntries) {
     if (
@@ -1269,12 +1492,17 @@ function renderCommuneResults(term) {
   const results = getSearchResults(term);
   const normalizedTerm = normalizeText(term);
   const entityCopy = getEntityCopy();
+  const useUnitList = !normalizedTerm && usesUnitDefaultList(state.activeTerritoryKey);
+  const listSingular = useUnitList ? "unité" : entityCopy.singular;
+  const listPlural = useUnitList ? "unités" : entityCopy.plural;
+  const emptyListLabel = useUnitList ? "Aucune unité trouvée." : entityCopy.noneFoundLabel;
   const resultCountLabel = normalizedTerm
     ? `${formatCount(results.length)} résultat(s) affiché(s)`
-    : `${formatEntityCount(results.length, entityCopy.singular, entityCopy.plural)} du territoire affiché`;
+    : `${formatEntityCount(results.length, listSingular, listPlural)} du territoire affiché`;
 
   communeListCountDesktop.textContent = resultCountLabel;
   communeListCountMobile.textContent = resultCountLabel;
+  clearListHover({ render: false });
 
   const containers = [communeListContainerDesktop, communeListContainerMobile];
   containers.forEach((container) => {
@@ -1283,7 +1511,7 @@ function renderCommuneResults(term) {
     if (!results.length) {
       const empty = document.createElement("p");
       empty.className = "country-list-empty";
-      empty.textContent = entityCopy.noneFoundLabel;
+      empty.textContent = emptyListLabel;
       container.appendChild(empty);
       return;
     }
@@ -1314,7 +1542,12 @@ function renderCommuneResults(term) {
           <span class="country-list-meta">${escapeHtml(metaLabel)}</span>
         </span>
       `;
+      button.addEventListener("mouseenter", () => setListHoverFromEntry(entry));
+      button.addEventListener("mouseleave", () => clearListHover());
+      button.addEventListener("focus", () => setListHoverFromEntry(entry));
+      button.addEventListener("blur", () => clearListHover());
       button.addEventListener("click", async () => {
+        clearListHover({ render: false });
         communeSearchInputDesktop.value = entry.name;
         communeSearchInputMobile.value = entry.name;
         closeCommuneListModal();
@@ -1376,17 +1609,20 @@ function prepareSearchEntries(entries) {
 }
 
 function prepareUnitSearchEntries(units, territoryKey) {
-  return units.map((unit) => ({
-    id: unit.serviceKey,
-    code: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
-    displayCode: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
-    rawCode: unit.serviceKey,
-    name: unit.name,
-    territoryKey,
-    kind: UNIT_SEARCH_KIND,
-    searchName: normalizeText(unit.name),
-    searchCode: `${unit.hostCommune} ${unit.telephone} ${unit.serviceKey}`.trim(),
-  }));
+  return units.map((unit) => {
+    const filterLabel = formatUnitFilterLabel(unit);
+    return {
+      id: unit.serviceKey,
+      code: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
+      displayCode: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
+      rawCode: unit.serviceKey,
+      name: filterLabel,
+      territoryKey,
+      kind: UNIT_SEARCH_KIND,
+      searchName: normalizeText(`${filterLabel} ${unit.name}`),
+      searchCode: `${unit.hostCommune} ${unit.telephone} ${unit.serviceKey}`.trim(),
+    };
+  });
 }
 
 function resizeCanvases() {
@@ -1409,6 +1645,7 @@ function resizeCanvases() {
     prepareTerritoryForCurrentViewport(state.activeTerritoryData);
   }
 
+  updateMobileLayoutOffsets();
   requestRender();
 }
 
@@ -1525,6 +1762,23 @@ function prepareTerritoryForCurrentViewport(territoryData) {
         })
         .filter(Boolean)
     : [];
+  territoryData.preparedMaritimeLines = (territoryData.maritimeLines || [])
+    .map((line) => {
+      const projectedPoints = line
+        .map((point) => projectPoint(projection, point, usesPlanarCoordinates))
+        .filter(Boolean);
+
+      if (projectedPoints.length < 2) {
+        return null;
+      }
+
+      return {
+        points: projectedPoints,
+        bounds: getLineBounds(projectedPoints),
+      };
+    })
+    .filter(Boolean);
+  territoryData.preparedMaritimeLines.push(...buildSupplementalIslandMaritimeLines(territoryData.preparedFeatures));
   territoryData.competenceColorByServiceKey = competenceColorByServiceKey;
   territoryData.usesCompetenceColoring = usesCompetenceColoring;
   territoryData.preparedViewportKey = viewportKey;
@@ -1631,6 +1885,80 @@ function drawFeatureOverlay(context, preparedFeature, fillColor) {
   });
 }
 
+function drawFeatureOverlays(context, preparedFeatures, fillColor, excludedFeatureId = null) {
+  preparedFeatures.forEach((preparedFeature) => {
+    if (!preparedFeature || preparedFeature.id === excludedFeatureId) {
+      return;
+    }
+    drawFeatureOverlay(context, preparedFeature, fillColor);
+  });
+}
+
+function drawMaritimeEdgeGlow(context) {
+  const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
+  if (isOverseasTerritory(territoryMeta)) {
+    return;
+  }
+
+  const maritimeLines = state.activeTerritoryData?.preparedMaritimeLines || [];
+  if (!maritimeLines.length) {
+    return;
+  }
+
+  const viewportBounds = getVisibleWorldBounds();
+  const padding = Math.max(8, 24 / state.transform.k);
+
+  const visibleLines = maritimeLines.filter((line) => boundsIntersect(line.bounds, viewportBounds, padding));
+  if (!visibleLines.length) {
+    return;
+  }
+
+  context.save();
+  context.lineJoin = "round";
+  context.lineCap = "butt";
+  context.globalCompositeOperation = "source-over";
+
+  const traceVisibleMaritimePath = () => {
+    context.beginPath();
+    visibleLines.forEach((line) => {
+      context.moveTo(line.points[0][0], line.points[0][1]);
+      for (let index = 1; index < line.points.length; index += 1) {
+        context.lineTo(line.points[index][0], line.points[index][1]);
+      }
+    });
+  };
+
+  const renderGlowPass = (lineWidth, strokeStyle, blur) => {
+    context.lineWidth = lineWidth;
+    context.strokeStyle = strokeStyle;
+    context.shadowColor = MARITIME_GLOW_COLOR;
+    context.shadowBlur = blur;
+    context.shadowOffsetX = 0;
+    context.shadowOffsetY = 0;
+    traceVisibleMaritimePath();
+    context.stroke();
+  };
+
+  const screenScale = 1 / Math.max(0.001, state.transform.k);
+  const targetRadiusPx = Math.max(152, MARITIME_GLOW_BLUR * 1.72);
+  const haloPasses = [
+    { widthPx: 2.2, alpha: 0.18, blur: Math.round(targetRadiusPx * 0.22) },
+    { widthPx: 7.4, alpha: 0.145, blur: Math.round(targetRadiusPx * 0.44) },
+    { widthPx: 14.9, alpha: 0.115, blur: Math.round(targetRadiusPx * 0.72) },
+    { widthPx: 28.4, alpha: 0.09, blur: targetRadiusPx },
+  ];
+
+  haloPasses.forEach((pass) => {
+    renderGlowPass(
+      Math.max(0.7, pass.widthPx * screenScale),
+      `rgba(23, 74, 142, ${pass.alpha})`,
+      Math.round(Math.max(1, pass.blur))
+    );
+  });
+
+  context.restore();
+}
+
 function drawVisibleBoundaries(context) {
   const viewportBounds = getVisibleWorldBounds();
   const padding = Math.max(2, 8 / state.transform.k);
@@ -1655,13 +1983,19 @@ function drawUnitMarkers(context) {
   const orderedUnits = state.activeTerritoryData.preparedUnits
     .slice()
     .sort((left, right) => {
-      const leftPriority = left.key === state.hoveredUnitKey || left.key === state.selectedUnitKey ? 1 : 0;
-      const rightPriority = right.key === state.hoveredUnitKey || right.key === state.selectedUnitKey ? 1 : 0;
+      const leftPriority =
+        left.key === state.hoveredUnitKey || left.key === state.listHoveredUnitKey || left.key === state.selectedUnitKey
+          ? 1
+          : 0;
+      const rightPriority =
+        right.key === state.hoveredUnitKey || right.key === state.listHoveredUnitKey || right.key === state.selectedUnitKey
+          ? 1
+          : 0;
       return leftPriority - rightPriority;
     });
 
   orderedUnits.forEach((preparedUnit) => {
-    const isHovered = preparedUnit.key === state.hoveredUnitKey;
+    const isHovered = preparedUnit.key === state.hoveredUnitKey || preparedUnit.key === state.listHoveredUnitKey;
     const isSelected = preparedUnit.key === state.selectedUnitKey;
     const markerSize = (isHovered || isSelected ? UNIT_MARKER_HOVER_SIZE : UNIT_MARKER_SIZE) / state.transform.k;
     const cornerRadius = UNIT_MARKER_CORNER_RADIUS / state.transform.k;
@@ -1675,6 +2009,55 @@ function drawUnitMarkers(context) {
   });
 }
 
+function drawFranceRegionBadges(context) {
+  if (state.activeTerritoryKey !== "france" || !state.activeTerritoryData?.preparedFeatures?.length) {
+    return;
+  }
+
+  const viewportBounds = getVisibleWorldBounds();
+  const maxBadgeSize = FRANCE_REGION_BADGE_SIZE / state.transform.k;
+  const padding = Math.max(4, 20 / state.transform.k);
+
+  state.activeTerritoryData.preparedFeatures.forEach((preparedFeature) => {
+    const territoryKey = preparedFeature.feature?.properties?.childTerritoryKey;
+    if (!territoryKey) {
+      return;
+    }
+
+    const badgeImage = getTerritoryBadgeImage(territoryKey);
+    if (!badgeImage) {
+      return;
+    }
+
+    const primaryPart =
+      preparedFeature.parts.find((part) => part.key === preparedFeature.primaryPartKey) || preparedFeature.parts[0];
+    if (!primaryPart || !boundsIntersect(primaryPart.bounds, viewportBounds, padding)) {
+      return;
+    }
+
+    const [centerX, centerY] = getBoundsCenter(primaryPart.bounds);
+    const naturalWidth = Math.max(1, badgeImage.naturalWidth || badgeImage.width || 1);
+    const naturalHeight = Math.max(1, badgeImage.naturalHeight || badgeImage.height || 1);
+    const aspectRatio = naturalWidth / naturalHeight;
+    let drawWidth = maxBadgeSize;
+    let drawHeight = maxBadgeSize;
+
+    if (aspectRatio >= 1) {
+      drawHeight = maxBadgeSize / aspectRatio;
+    } else {
+      drawWidth = maxBadgeSize * aspectRatio;
+    }
+
+    context.drawImage(
+      badgeImage,
+      centerX - drawWidth / 2,
+      centerY - drawHeight / 2,
+      drawWidth,
+      drawHeight
+    );
+  });
+}
+
 function renderMap() {
   state.renderQueued = false;
 
@@ -1698,6 +2081,7 @@ function renderMap() {
 
   mapContext.save();
   applyRenderTransform(mapContext);
+  drawMaritimeEdgeGlow(mapContext);
   const usesVectorBaseLayer = state.transform.k > VECTOR_RENDER_SCALE_THRESHOLD;
   if (usesVectorBaseLayer) {
     drawBaseVectorLayer(mapContext);
@@ -1709,13 +2093,16 @@ function renderMap() {
 
   const selectedPreparedFeature = getPreparedFeatureById(state.selectedCommuneId);
   const hoveredPreparedFeature = getPreparedFeatureFromPartKey(state.hoveredPartKey);
+  const listHoveredPreparedFeatures = getPreparedFeaturesFromIds(state.listHoveredFeatureIds);
   drawFeatureOverlay(mapContext, selectedPreparedFeature, themeColors.communeSelected);
+  drawFeatureOverlays(mapContext, listHoveredPreparedFeatures, themeColors.communeHover, selectedPreparedFeature?.id || null);
 
   if (!selectedPreparedFeature || hoveredPreparedFeature?.id !== selectedPreparedFeature.id) {
     drawFeatureOverlay(mapContext, hoveredPreparedFeature, themeColors.communeHover);
   }
 
   drawVisibleBoundaries(mapContext);
+  drawFranceRegionBadges(mapContext);
   drawUnitMarkers(mapContext);
   mapContext.restore();
 }
@@ -1758,11 +2145,17 @@ async function loadTerritoryData(territoryKey) {
       hours: unit.h || "",
       url: unit.u || "",
     })),
+    maritimeLines: (raw.maritime || []).map((line) =>
+      (line || [])
+        .map((point) => [Number(point[0]), Number(point[1])])
+        .filter((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]))
+    ),
     serviceByKey,
     unitByServiceKey: new Map(),
     unitSearchEntries: [],
     preparedFeatures: [],
     preparedUnits: [],
+    preparedMaritimeLines: [],
     territoryPath: null,
     featureById: new Map(),
     partByKey: new Map(),
@@ -1932,6 +2325,7 @@ async function activateTerritory(territoryKey, options = {}) {
   if (!sameTerritory) {
     closeCommuneModal({ syncRoute: false, clearSelection: false });
     closeMaskingModal();
+    clearListHover({ render: false });
   }
   state.activeTerritoryKey = territoryKey;
   state.activeTerritoryData = territoryData;

+ 285 - 0
scripts/build_communes_data.py

@@ -28,6 +28,10 @@ DEPARTMENTS_GEOJSON_URL = (
     "https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/"
     "departements-version-simplifiee.geojson"
 )
+COASTLINE_GEOJSON_URL = (
+    "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/"
+    "ne_50m_coastline.geojson"
+)
 REGION_COMMUNES_GEOJSON_BASE_URL = (
     "https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions"
 )
@@ -45,6 +49,8 @@ REQUEST_HEADERS = {
     "User-Agent": "GiePlaces data builder",
     "Accept": "application/json",
 }
+COASTLINE_GRID_SIZE = 1.0
+COASTLINE_DISTANCE_THRESHOLD = 0.08
 
 REGION_ENTITY_TYPE = "region"
 DEPARTMENT_ENTITY_TYPE = "departement"
@@ -82,6 +88,8 @@ POLYNESIE_RETAINED_COMMUNES = {
 
 POLYNESIE_TRIMMED_COMMUNES = {
     "Taiarapu-Est",
+    "Moorea-Maiao",
+    "Arue",
 }
 
 SPECIAL_TERRITORY_SIMPLIFICATION = {
@@ -1564,6 +1572,265 @@ def build_index_entries(
     return entries
 
 
+def iter_outer_rings(geometry: dict) -> Iterable[list[list[float]]]:
+    geometry_type = geometry.get("type")
+    coordinates = geometry.get("coordinates", [])
+
+    if geometry_type == "Polygon":
+        if coordinates:
+            yield coordinates[0]
+        return
+
+    if geometry_type == "MultiPolygon":
+        for polygon in coordinates:
+            if polygon:
+                yield polygon[0]
+
+
+def normalize_ring_points(ring: list[list[float]], precision: int = 6) -> list[tuple[float, float]]:
+    points = [
+        (round(float(point[0]), precision), round(float(point[1]), precision))
+        for point in ring
+        if isinstance(point, list) and len(point) >= 2
+    ]
+    if len(points) >= 2 and points[0] == points[-1]:
+        points = points[:-1]
+    return points
+
+
+def build_edge_key(start: tuple[float, float], end: tuple[float, float]) -> tuple[tuple[float, float], tuple[float, float]]:
+    return (start, end) if start <= end else (end, start)
+
+
+def build_global_edge_counts(features: list[dict], precision: int = 6) -> dict[tuple[tuple[float, float], tuple[float, float]], int]:
+    edge_counts: dict[tuple[tuple[float, float], tuple[float, float]], int] = {}
+
+    for feature in features:
+        for ring in iter_outer_rings(feature.get("geometry", {})):
+            points = normalize_ring_points(ring, precision)
+            point_count = len(points)
+            if point_count < 2:
+                continue
+
+            for index in range(point_count):
+                start = points[index]
+                end = points[(index + 1) % point_count]
+                if start == end:
+                    continue
+                edge_key = build_edge_key(start, end)
+                edge_counts[edge_key] = edge_counts.get(edge_key, 0) + 1
+
+    return edge_counts
+
+
+def build_maritime_lines_from_features(
+    features: list[dict],
+    edge_counts: dict[tuple[tuple[float, float], tuple[float, float]], int],
+    precision: int = 6,
+) -> list[list[list[float]]]:
+    maritime_lines: list[list[list[float]]] = []
+
+    for feature in features:
+        for ring in iter_outer_rings(feature.get("geometry", {})):
+            points = normalize_ring_points(ring, precision)
+            point_count = len(points)
+            if point_count < 2:
+                continue
+
+            edge_is_maritime = []
+            for index in range(point_count):
+                start = points[index]
+                end = points[(index + 1) % point_count]
+                edge_key = build_edge_key(start, end)
+                edge_is_maritime.append(edge_counts.get(edge_key, 0) == 1)
+
+            if not any(edge_is_maritime):
+                continue
+
+            start_indexes = [
+                index
+                for index in range(point_count)
+                if edge_is_maritime[index] and not edge_is_maritime[index - 1]
+            ]
+
+            if not start_indexes:
+                line_points = points + [points[0]]
+                maritime_lines.append([[point_x, point_y] for point_x, point_y in line_points])
+                continue
+
+            for start_index in start_indexes:
+                index = start_index
+                line_points = [points[start_index]]
+                while edge_is_maritime[index]:
+                    next_index = (index + 1) % point_count
+                    line_points.append(points[next_index])
+                    index = next_index
+                    if index == start_index:
+                        break
+                if len(line_points) >= 2:
+                    maritime_lines.append([[point_x, point_y] for point_x, point_y in line_points])
+
+    return maritime_lines
+
+
+def iter_line_strings(geometry: dict) -> Iterable[list[list[float]]]:
+    geometry_type = geometry.get("type")
+    coordinates = geometry.get("coordinates", [])
+
+    if geometry_type == "LineString":
+        if coordinates:
+            yield coordinates
+        return
+
+    if geometry_type == "MultiLineString":
+        for line in coordinates:
+            if line:
+                yield line
+
+
+def build_coastline_index() -> dict:
+    payload = fetch_json(COASTLINE_GEOJSON_URL)
+    segments = []
+    grid: dict[tuple[int, int], list[int]] = {}
+
+    for feature in payload.get("features", []):
+        for line in iter_line_strings(feature.get("geometry", {})):
+            for index in range(len(line) - 1):
+                start = line[index]
+                end = line[index + 1]
+                if len(start) < 2 or len(end) < 2:
+                    continue
+                start_x, start_y = float(start[0]), float(start[1])
+                end_x, end_y = float(end[0]), float(end[1])
+                if start_x == end_x and start_y == end_y:
+                    continue
+
+                min_x = min(start_x, end_x)
+                min_y = min(start_y, end_y)
+                max_x = max(start_x, end_x)
+                max_y = max(start_y, end_y)
+                segment_index = len(segments)
+                segments.append((start_x, start_y, end_x, end_y, min_x, min_y, max_x, max_y))
+
+                min_cell_x = math.floor(min_x / COASTLINE_GRID_SIZE)
+                max_cell_x = math.floor(max_x / COASTLINE_GRID_SIZE)
+                min_cell_y = math.floor(min_y / COASTLINE_GRID_SIZE)
+                max_cell_y = math.floor(max_y / COASTLINE_GRID_SIZE)
+                for cell_x in range(min_cell_x, max_cell_x + 1):
+                    for cell_y in range(min_cell_y, max_cell_y + 1):
+                        grid.setdefault((cell_x, cell_y), []).append(segment_index)
+
+    return {
+        "segments": segments,
+        "grid": grid,
+    }
+
+
+def point_to_segment_distance_squared(
+    point_x: float,
+    point_y: float,
+    start_x: float,
+    start_y: float,
+    end_x: float,
+    end_y: float,
+) -> float:
+    delta_x = end_x - start_x
+    delta_y = end_y - start_y
+    length_squared = delta_x * delta_x + delta_y * delta_y
+    if length_squared <= 1e-18:
+        return (point_x - start_x) ** 2 + (point_y - start_y) ** 2
+
+    projection = ((point_x - start_x) * delta_x + (point_y - start_y) * delta_y) / length_squared
+    projection = max(0.0, min(1.0, projection))
+    nearest_x = start_x + projection * delta_x
+    nearest_y = start_y + projection * delta_y
+    return (point_x - nearest_x) ** 2 + (point_y - nearest_y) ** 2
+
+
+def is_segment_near_coastline(
+    start: list[float],
+    end: list[float],
+    coastline_index: dict,
+    threshold_squared: float,
+) -> bool:
+    midpoint_x = (float(start[0]) + float(end[0])) / 2
+    midpoint_y = (float(start[1]) + float(end[1])) / 2
+    cell_x = math.floor(midpoint_x / COASTLINE_GRID_SIZE)
+    cell_y = math.floor(midpoint_y / COASTLINE_GRID_SIZE)
+    search_radius = 1
+    segments = coastline_index["segments"]
+    grid = coastline_index["grid"]
+    threshold = math.sqrt(threshold_squared)
+
+    candidate_indexes = set()
+    for offset_x in range(-search_radius, search_radius + 1):
+        for offset_y in range(-search_radius, search_radius + 1):
+            candidate_indexes.update(grid.get((cell_x + offset_x, cell_y + offset_y), []))
+
+    if not candidate_indexes:
+        return False
+
+    for segment_index in candidate_indexes:
+        start_x, start_y, end_x, end_y, min_x, min_y, max_x, max_y = segments[segment_index]
+        if (
+            midpoint_x < min_x - threshold
+            or midpoint_x > max_x + threshold
+            or midpoint_y < min_y - threshold
+            or midpoint_y > max_y + threshold
+        ):
+            continue
+
+        if (
+            point_to_segment_distance_squared(
+                midpoint_x,
+                midpoint_y,
+                start_x,
+                start_y,
+                end_x,
+                end_y,
+            )
+            <= threshold_squared
+        ):
+            return True
+
+    return False
+
+
+def filter_maritime_lines_by_coastline(
+    maritime_lines: list[list[list[float]]],
+    coastline_index: dict,
+    threshold: float = COASTLINE_DISTANCE_THRESHOLD,
+) -> list[list[list[float]]]:
+    threshold_squared = threshold * threshold
+    filtered_lines: list[list[list[float]]] = []
+
+    for line in maritime_lines:
+        if len(line) < 2:
+            continue
+
+        segment_flags = [
+            is_segment_near_coastline(line[index], line[index + 1], coastline_index, threshold_squared)
+            for index in range(len(line) - 1)
+        ]
+
+        current_line: list[list[float]] = []
+        for index, is_maritime in enumerate(segment_flags):
+            if is_maritime:
+                if not current_line:
+                    current_line = [line[index], line[index + 1]]
+                else:
+                    current_line.append(line[index + 1])
+            elif current_line:
+                if len(current_line) >= 2:
+                    filtered_lines.append(current_line)
+                current_line = []
+
+        if current_line and len(current_line) >= 2:
+            filtered_lines.append(current_line)
+
+    return filtered_lines
+
+
 def write_json(path: Path, payload: dict | list) -> None:
     path.parent.mkdir(parents=True, exist_ok=True)
     with path.open("w", encoding="utf-8") as file:
@@ -1703,6 +1970,7 @@ def build_data() -> None:
         department_metadata_by_code,
         metro_department_codes,
     )
+    coastline_index = build_coastline_index()
     region_department_features_by_key = {territory["key"]: [] for territory in region_territories}
     for feature in metropolitan_department_features:
         region_key = region_key_by_code.get(feature["properties"]["r"])
@@ -1931,6 +2199,23 @@ def build_data() -> None:
                 "communes": masked_communes,
                 "units": masked_units,
             }
+
+        territory_edge_counts = build_global_edge_counts(payload["features"])
+        maritime_lines = build_maritime_lines_from_features(payload["features"], territory_edge_counts)
+
+        if maritime_lines:
+            filtered_maritime_lines = filter_maritime_lines_by_coastline(maritime_lines, coastline_index)
+            if filtered_maritime_lines:
+                maritime_lines = filtered_maritime_lines
+            elif not territory.get("parentTerritoryKey") and territory["entityType"] == COMMUNE_ENTITY_TYPE:
+                # Overseas islands can be too small for coarse coastline matching; keep raw maritime edges.
+                pass
+            else:
+                maritime_lines = []
+
+        if maritime_lines:
+            payload["maritime"] = maritime_lines
+
         write_json(TERRITORY_DIR / f"{territory['key']}.json", payload)
         territories_manifest.append(
             {

+ 21 - 2
styles.css

@@ -17,6 +17,8 @@
   --unit-marker-fill: #404853;
   --unit-marker-stroke: rgba(7, 9, 11, 0.98);
   --unit-marker-hover: #5d6976;
+  --mobile-controls-top: 5.1rem;
+  --mobile-legend-top: 8.5rem;
 }
 
 * {
@@ -283,6 +285,19 @@ input {
   text-overflow: ellipsis;
 }
 
+.country-list-item {
+  align-items: flex-start;
+}
+
+.country-list-title,
+.country-list-meta {
+  white-space: normal;
+  overflow: visible;
+  text-overflow: clip;
+  overflow-wrap: anywhere;
+  word-break: break-word;
+}
+
 .country-list-empty {
   margin: 0.4rem 0;
   font-size: 0.84rem;
@@ -382,6 +397,10 @@ input {
   backdrop-filter: blur(6px);
 }
 
+.masking-disclaimer[hidden] {
+  display: none !important;
+}
+
 .masking-disclaimer-action {
   border: 1px solid var(--border);
   background: var(--panel-strong);
@@ -699,7 +718,7 @@ input {
   .mobile-country-button,
   .mobile-legend-button {
     display: inline-flex;
-    top: 5.1rem;
+    top: var(--mobile-controls-top);
   }
 
   .mobile-country-button {
@@ -717,7 +736,7 @@ input {
   .legend {
     right: 0.6rem;
     width: min(290px, calc(100vw - 1.2rem));
-    top: 8.5rem;
+    top: var(--mobile-legend-top);
     max-height: min(58vh, 430px);
     overflow: auto;
     display: none;

部分文件因文件數量過多而無法顯示