|
|
@@ -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;
|