diff --git a/src/features/map/hooks/useReportMapLayers.ts b/src/features/map/hooks/useReportMapLayers.ts index cc89c88..274c530 100644 --- a/src/features/map/hooks/useReportMapLayers.ts +++ b/src/features/map/hooks/useReportMapLayers.ts @@ -3,6 +3,8 @@ import { useEffect, useRef } from 'react'; import Feature from 'ol/Feature'; import type { FeatureLike } from 'ol/Feature'; import { createEmpty, extend, getCenter, isEmpty } from 'ol/extent'; +import LineString from 'ol/geom/LineString'; +import Point from 'ol/geom/Point'; import LayerGroup from 'ol/layer/Group'; import VectorLayer from 'ol/layer/Vector'; import type OlMap from 'ol/Map'; @@ -19,7 +21,19 @@ import { getLocalReportDraftFromMapFeature, } from '@/features/map/utils/reportMapFeatures'; import { loadReportsInMapBbox } from '@/features/map/utils/loadReportsInMapBbox'; -import { styleLocalReportDraftMapFeature, styleReportMapFeature } from '@/features/map/utils/reportStatusMapMarkerStyle'; +import { + areClusteredFeaturesAtSamePoint, + clusterCenterCoordinate, + clusterSamePointKey, + computeSpiderfyLayout, + getClusteredSubFeatures, +} from '@/features/map/utils/reportClusterSpiderfy'; +import { + styleLocalReportDraftMapFeature, + styleReportMapFeature, + styleSpiderfyLegFeature, + styleSpiderfySatelliteFeature, +} from '@/features/map/utils/reportStatusMapMarkerStyle'; import { MY_LOCAL_DRAFTS_MAP_LAYER_NAME, MY_REPORTS_MAP_LAYER_NAME, @@ -41,6 +55,12 @@ interface UseReportMapLayersOptions { onLocalDraftSelect: (draft: LocalReportDraft) => void; } +/** + * Couche des marqueurs individuels « éclatés » (spiderfy) et des traits qui les relient à leur + * point d'origine — voir {@link areClusteredFeaturesAtSamePoint}. + */ +const SPIDERFY_MAP_LAYER_NAME = 'ReportSpiderfyMapLayer'; + function findReportLayerGroup(map: OlMap): LayerGroup | null { for (const layer of map.getLayers().getArray()) { if (layer instanceof LayerGroup && layer.get('name') === REPORT_MAP_LAYER_GROUP_NAME) { @@ -99,6 +119,10 @@ export function useReportMapLayers({ onReportSelectRef.current = onReportSelect; const onLocalDraftSelectRef = useRef(onLocalDraftSelect); onLocalDraftSelectRef.current = onLocalDraftSelect; + // Clé (voir `clusterSamePointKey`) du cluster « même point » actuellement éclaté (spiderfy), + // ou `null`. Un `ref` : lu en direct par les fonctions de style à chaque rendu OpenLayers, + // sans dépendre d'un re-render React. + const spiderfiedClusterKeyRef = useRef(null); useEffect(() => { if (!map || !isMapReady) { @@ -113,7 +137,7 @@ export function useReportMapLayers({ const myReportsLayer = new VectorLayer({ source: myReportsClusterSource, - style: (feature) => styleReportMapFeature(feature as Feature), + style: (feature) => styleReportMapFeature(feature as Feature, spiderfiedClusterKeyRef.current), properties: { name: MY_REPORTS_MAP_LAYER_NAME, title: 'Mes signalements', @@ -130,7 +154,7 @@ export function useReportMapLayers({ const localDraftsLayer = new VectorLayer({ source: localDraftsClusterSource, - style: (feature) => styleLocalReportDraftMapFeature(feature as Feature), + style: (feature) => styleLocalReportDraftMapFeature(feature as Feature, spiderfiedClusterKeyRef.current), properties: { name: MY_LOCAL_DRAFTS_MAP_LAYER_NAME, title: 'Mes brouillons', @@ -139,13 +163,28 @@ export function useReportMapLayers({ zIndex: REPORT_MAP_LAYER_Z_INDEX, }); + const spiderfySource = new VectorSource(); + const spiderfyLayer = new VectorLayer({ + source: spiderfySource, + style: (feature) => + (feature as Feature).get('spiderfyLeg') + ? styleSpiderfyLegFeature() + : styleSpiderfySatelliteFeature(feature as Feature), + properties: { + name: SPIDERFY_MAP_LAYER_NAME, + displayInLayerSwitcher: false, + }, + // Dernière de la liste : rendue au-dessus des pastilles pile des autres couches. + zIndex: REPORT_MAP_LAYER_Z_INDEX, + }); + const reportLayerGroup = new LayerGroup({ properties: { name: REPORT_MAP_LAYER_GROUP_NAME, title: 'Signalements', displayInLayerSwitcher: false, }, - layers: [myReportsLayer, localDraftsLayer], + layers: [myReportsLayer, localDraftsLayer, spiderfyLayer], zIndex: REPORT_MAP_LAYER_Z_INDEX, }); @@ -163,19 +202,23 @@ export function useReportMapLayers({ const myReportsLayer = getReportLayerByName(map, MY_REPORTS_MAP_LAYER_NAME); const localDraftsLayer = getReportLayerByName(map, MY_LOCAL_DRAFTS_MAP_LAYER_NAME); + const spiderfyLayer = getReportLayerByName(map, SPIDERFY_MAP_LAYER_NAME); const reportLayerGroup = findReportLayerGroup(map); - if (!myReportsLayer || !localDraftsLayer || !reportLayerGroup) { + if (!myReportsLayer || !localDraftsLayer || !spiderfyLayer || !reportLayerGroup) { return; } myReportsLayer.setVisible(visibility.myReports); localDraftsLayer.setVisible(visibility.myReports); + spiderfyLayer.setVisible(visibility.myReports); reportLayerGroup.setVisible(visibility.myReports); if (!visibility.myReports) { clearClusteredLayerSource(myReportsLayer); clearClusteredLayerSource(localDraftsLayer); + spiderfyLayer.getSource()?.clear(true); + spiderfiedClusterKeyRef.current = null; } }, [isMapReady, map, visibility.myReports]); @@ -262,6 +305,69 @@ export function useReportMapLayers({ return; } + const collapseSpiderfy = () => { + if (spiderfiedClusterKeyRef.current === null) { + return; + } + + spiderfiedClusterKeyRef.current = null; + getReportLayerByName(map, SPIDERFY_MAP_LAYER_NAME)?.getSource()?.clear(true); + // Force le recalcul du style des deux couches : le marqueur pile, masqué pendant + // l'éclatement (voir `hiddenClusterKey`), doit redevenir visible. + getReportLayerByName(map, MY_REPORTS_MAP_LAYER_NAME)?.changed(); + getReportLayerByName(map, MY_LOCAL_DRAFTS_MAP_LAYER_NAME)?.changed(); + }; + + /** Éclate un cluster « même point » : un marqueur par signalement, en éventail autour du point. */ + const expandSpiderfy = ( + clusterFeature: Feature, + clusteredFeatures: Feature[], + key: string, + fallbackCenter: number[], + ) => { + const spiderfyLayer = getReportLayerByName(map, SPIDERFY_MAP_LAYER_NAME); + const spiderfySource = spiderfyLayer?.getSource(); + if (!spiderfySource) { + return; + } + + const center = clusterCenterCoordinate(clusterFeature) ?? fallbackCenter; + const centerPixel = map.getPixelFromCoordinate(center) ?? map.getPixelFromCoordinate(fallbackCenter); + if (!centerPixel) { + return; + } + + const layout = computeSpiderfyLayout(clusteredFeatures.length); + const newFeatures: Feature[] = []; + + clusteredFeatures.forEach((originalFeature, index) => { + const { angle, radius } = layout[index]; + const satellitePixel = [ + centerPixel[0] + radius * Math.cos(angle), + centerPixel[1] + radius * Math.sin(angle), + ]; + const satelliteCoordinate = map.getCoordinateFromPixel(satellitePixel); + if (!satelliteCoordinate) { + return; + } + + const satellite = originalFeature.clone(); + satellite.setId(`spiderfy-${originalFeature.getId() ?? index}`); + satellite.setGeometry(new Point(satelliteCoordinate)); + newFeatures.push(satellite); + + const leg = new Feature({ geometry: new LineString([center, satelliteCoordinate]) }); + leg.set('spiderfyLeg', true); + newFeatures.push(leg); + }); + + spiderfySource.clear(true); + spiderfySource.addFeatures(newFeatures); + spiderfiedClusterKeyRef.current = key; + getReportLayerByName(map, MY_REPORTS_MAP_LAYER_NAME)?.changed(); + getReportLayerByName(map, MY_LOCAL_DRAFTS_MAP_LAYER_NAME)?.changed(); + }; + const handleMapClick = (event: { pixel: number[]; coordinate: number[] }) => { if (!visibility.myReports) { return; @@ -282,7 +388,11 @@ export function useReportMapLayers({ } const layerName = layer.get('name'); - if (layerName !== MY_REPORTS_MAP_LAYER_NAME && layerName !== MY_LOCAL_DRAFTS_MAP_LAYER_NAME) { + if ( + layerName !== MY_REPORTS_MAP_LAYER_NAME && + layerName !== MY_LOCAL_DRAFTS_MAP_LAYER_NAME && + layerName !== SPIDERFY_MAP_LAYER_NAME + ) { return undefined; } @@ -296,13 +406,56 @@ export function useReportMapLayers({ }, ); + // Marqueur éclaté (spiderfy) : sélectionne son signalement. Un trait de liaison ne + // sélectionne rien (il n'a pas de statut/signalement associé). + if (hit.layerName === SPIDERFY_MAP_LAYER_NAME && hit.feature) { + if (hit.feature.get('spiderfyLeg')) { + return; + } + + if (hit.feature.get('reportSource') === 'local') { + const draft = getLocalReportDraftFromMapFeature(hit.feature); + if (draft) { + onLocalDraftSelectRef.current(draft); + } + return; + } + + const report = getGroupReportFromMapFeature(hit.feature); + if (report) { + onReportSelectRef.current(report); + } + return; + } + if (!hit.feature) { + // Clic dans le vide : referme un éventuel éclatement en cours. + collapseSpiderfy(); return; } const selectedFeature = hit.feature; - const clusteredFeatures = selectedFeature.get('features') as Feature[] | undefined; - if (Array.isArray(clusteredFeatures) && clusteredFeatures.length > 1) { + const clusteredFeatures = getClusteredSubFeatures(selectedFeature); + + if (clusteredFeatures.length > 1) { + if (areClusteredFeaturesAtSamePoint(clusteredFeatures)) { + const key = clusterSamePointKey( + hit.layerName === MY_LOCAL_DRAFTS_MAP_LAYER_NAME ? 'local' : 'server', + selectedFeature, + ); + const wasAlreadyExpanded = key !== null && key === spiderfiedClusterKeyRef.current; + + collapseSpiderfy(); + + if (!wasAlreadyExpanded && key) { + expandSpiderfy(selectedFeature, clusteredFeatures, key, event.coordinate); + } + return; + } + + // Points proches mais distincts : le zoom finit par les séparer visuellement. + collapseSpiderfy(); + const clusterExtent = createEmpty(); for (const clusterFeature of clusteredFeatures) { const geometry = clusterFeature.getGeometry(); @@ -325,7 +478,8 @@ export function useReportMapLayers({ return; } - const targetFeature = clusteredFeatures?.[0] ?? selectedFeature; + collapseSpiderfy(); + const targetFeature = clusteredFeatures[0]; if (hit.layerName === MY_LOCAL_DRAFTS_MAP_LAYER_NAME) { const draft = getLocalReportDraftFromMapFeature(targetFeature); @@ -341,10 +495,16 @@ export function useReportMapLayers({ } }; + // La position des satellites est calculée pour la vue courante : tout déplacement (y + // compris le zoom déclenché par le clic sur un cluster spatial ci-dessus) la périme. + const handleMoveStart = () => collapseSpiderfy(); + map.on('singleclick', handleMapClick); + map.on('movestart', handleMoveStart); return () => { map.un('singleclick', handleMapClick); + map.un('movestart', handleMoveStart); }; }, [isMapReady, map, visibility.myReports]); } diff --git a/src/features/map/utils/reportClusterSpiderfy.ts b/src/features/map/utils/reportClusterSpiderfy.ts new file mode 100644 index 0000000..e3153f6 --- /dev/null +++ b/src/features/map/utils/reportClusterSpiderfy.ts @@ -0,0 +1,89 @@ +import type { Coordinate } from 'ol/coordinate'; +import type Feature from 'ol/Feature'; +import type Point from 'ol/geom/Point'; + +/** + * En dessous de ce seuil (mètres, projection carte), des entités regroupées sont considérées + * comme faites sur le même point : zoomer ne peut jamais les séparer (leur distance à l'écran + * reste nulle quel que soit le niveau de zoom), il faut les « éclater » (spiderfy) autour du + * point plutôt que zoomer dessus. + */ +const SAME_POINT_TOLERANCE_METERS = 3; + +/** Entités regroupées par le `Cluster` source, ou la seule entité si elle n'est pas groupée. */ +export function getClusteredSubFeatures(feature: Feature): Feature[] { + const clustered = feature.get('features') as Feature[] | undefined; + return Array.isArray(clustered) && clustered.length > 0 ? clustered : [feature]; +} + +function featurePointCoordinate(feature: Feature): Coordinate | null { + const geometry = feature.getGeometry(); + if (!geometry || typeof (geometry as Point).getCoordinates !== 'function') { + return null; + } + return (geometry as Point).getCoordinates(); +} + +/** True si toutes les entités regroupées sont à la même position (repositionnement GPS mis à part). */ +export function areClusteredFeaturesAtSamePoint(features: readonly Feature[]): boolean { + const coordinates = features + .map(featurePointCoordinate) + .filter((coordinate): coordinate is Coordinate => coordinate !== null); + + if (coordinates.length < 2) { + return true; + } + + const [first, ...rest] = coordinates; + return rest.every(([x, y]) => Math.hypot(x - first[0], y - first[1]) <= SAME_POINT_TOLERANCE_METERS); +} + +/** Position de l'entité groupée (centroïde du cluster), utilisée pour centrer l'éclatement. */ +export function clusterCenterCoordinate(clusterFeature: Feature): Coordinate | null { + return featurePointCoordinate(clusterFeature); +} + +/** Clé stable identifiant un cluster « même point » — sert à savoir lequel est actuellement éclaté. */ +export function clusterSamePointKey(prefix: string, clusterFeature: Feature): string | null { + const coordinate = featurePointCoordinate(clusterFeature); + if (!coordinate) { + return null; + } + return `${prefix}:${Math.round(coordinate[0])}:${Math.round(coordinate[1])}`; +} + +export interface SpiderfyLayoutEntry { + /** Angle en radians. */ + angle: number; + /** Distance au centre, en pixels écran. */ + radius: number; +} + +/** + * Répartit `count` entités en éventail autour d'un point (anneaux concentriques au-delà de 8 + * entités, pour ne pas les faire se chevaucher). Angle et rayon sont en pixels écran, à + * convertir en coordonnées carte via `map.getCoordinateFromPixel`. + */ +export function computeSpiderfyLayout(count: number): SpiderfyLayoutEntry[] { + const MAX_PER_RING = 8; + const RING_BASE_RADIUS = 46; + const RING_STEP = 34; + + const layout: SpiderfyLayoutEntry[] = []; + let remaining = count; + let ring = 0; + + while (remaining > 0) { + const inRing = Math.min(remaining, MAX_PER_RING); + const radius = RING_BASE_RADIUS + ring * RING_STEP; + // Décale un anneau sur deux d'un demi-pas pour ne pas aligner ses pointes sur l'anneau précédent. + const angleOffset = ring % 2 === 0 ? 0 : Math.PI / inRing; + for (let i = 0; i < inRing; i += 1) { + layout.push({ angle: (i / inRing) * 2 * Math.PI + angleOffset, radius }); + } + remaining -= inRing; + ring += 1; + } + + return layout; +} diff --git a/src/features/map/utils/reportStatusMapMarkerStyle.ts b/src/features/map/utils/reportStatusMapMarkerStyle.ts index f528399..b4225db 100644 --- a/src/features/map/utils/reportStatusMapMarkerStyle.ts +++ b/src/features/map/utils/reportStatusMapMarkerStyle.ts @@ -4,6 +4,11 @@ import type { Style } from 'ol/style'; import { Icon, Style as OlStyle, Text, Fill, Stroke } from 'ol/style'; import type { LocalReportDraftStatus } from '@/domain/report/localReportDraft'; +import { + areClusteredFeaturesAtSamePoint, + clusterSamePointKey, + getClusteredSubFeatures, +} from '@/features/map/utils/reportClusterSpiderfy'; import { getLocalReportDraftStatusColors } from '@/features/report/utils/localReportDraftStatus'; import { getColorCode, resolveCssColor } from '@/shared/utils/color'; import { getStatusColors } from '@/shared/utils/reportStatus'; @@ -108,14 +113,77 @@ function createReportClusterMapMarkerStyle(count: number): OlStyle { return style; } -export function styleReportMapFeature(feature: Feature): Style { - const clusteredFeatures = feature.get('features') as Feature[] | undefined; - if (Array.isArray(clusteredFeatures) && clusteredFeatures.length > 1) { +/** + * Icône « pile » (pin + badge nombre) — plusieurs signalements faits sur exactement le même + * point. Distincte de la pastille de cluster ({@link createReportClusterMapMarkerStyle}, ronde + * et sans forme de pin), qui elle ne regroupe que des points proches mais distincts. + */ +function createStackedPinMarkerSvg(color: string, count: number): string { + const label = count > 9 ? '9+' : String(count); + return ` + + + + + + + + + + + + ${label} + + `; +} + +const stackedMarkerStyleCache = new Map(); + +/** Couleur neutre : un « même point » peut regrouper des signalements de statuts différents. */ +function resolveStackedMarkerColor(): string { + return getColorCode('secondary') || getColorCode('medium') || '#4A7FB5'; +} + +function createStackedReportMarkerStyle(count: number): OlStyle { + const cached = stackedMarkerStyleCache.get(count); + if (cached) { + return cached; + } + + const style = new OlStyle({ + image: new Icon({ + src: encodeMarkerSvg(createStackedPinMarkerSvg(resolveStackedMarkerColor(), count)), + // Le badge élargit le canevas à droite : l'ancre reste sur la pointe du pin (x=18 sur 40). + anchor: [18 / 40, 1], + rotateWithView: false, + }), + }); + + stackedMarkerStyleCache.set(count, style); + return style; +} + +/** + * @param hiddenClusterKey Clé (voir {@link clusterSamePointKey}) du cluster « même point » + * actuellement éclaté (spiderfy) : son marqueur pile est masqué, remplacé par les satellites. + */ +export function styleReportMapFeature(feature: Feature, hiddenClusterKey: string | null = null): Style | undefined { + const clusteredFeatures = getClusteredSubFeatures(feature); + if (clusteredFeatures.length > 1) { + if (areClusteredFeaturesAtSamePoint(clusteredFeatures)) { + const key = clusterSamePointKey('server', feature); + if (key && key === hiddenClusterKey) { + return undefined; + } + return createStackedReportMarkerStyle(clusteredFeatures.length); + } return createReportClusterMapMarkerStyle(clusteredFeatures.length); } - const targetFeature = clusteredFeatures?.[0] ?? feature; - const status = targetFeature.get('status') ?? ReportStatus.Pending; + const status = clusteredFeatures[0].get('status') ?? ReportStatus.Pending; return createReportStatusMapMarkerStyle(status); } @@ -145,13 +213,41 @@ export function createLocalReportDraftMapMarkerStyle(status: LocalReportDraftSta return style; } -export function styleLocalReportDraftMapFeature(feature: Feature): Style { - const clusteredFeatures = feature.get('features') as Feature[] | undefined; - if (Array.isArray(clusteredFeatures) && clusteredFeatures.length > 1) { +/** @param hiddenClusterKey Voir {@link styleReportMapFeature}. */ +export function styleLocalReportDraftMapFeature( + feature: Feature, + hiddenClusterKey: string | null = null, +): Style | undefined { + const clusteredFeatures = getClusteredSubFeatures(feature); + if (clusteredFeatures.length > 1) { + if (areClusteredFeaturesAtSamePoint(clusteredFeatures)) { + const key = clusterSamePointKey('local', feature); + if (key && key === hiddenClusterKey) { + return undefined; + } + return createStackedReportMarkerStyle(clusteredFeatures.length); + } return createReportClusterMapMarkerStyle(clusteredFeatures.length); } - const targetFeature = clusteredFeatures?.[0] ?? feature; - const status = (targetFeature.get('status') as LocalReportDraftStatus | undefined) ?? 'not_sent'; + const status = (clusteredFeatures[0].get('status') as LocalReportDraftStatus | undefined) ?? 'not_sent'; return createLocalReportDraftMapMarkerStyle(status); } + +/** Marqueur d'un signalement individuel « éclaté » (spiderfy) hors de son cluster même-point. */ +export function styleSpiderfySatelliteFeature(feature: Feature): Style { + if (feature.get('reportSource') === 'local') { + const status = (feature.get('status') as LocalReportDraftStatus | undefined) ?? 'not_sent'; + return createLocalReportDraftMapMarkerStyle(status); + } + + const status = feature.get('status') ?? ReportStatus.Pending; + return createReportStatusMapMarkerStyle(status); +} + +/** Trait discret reliant un satellite éclaté (spiderfy) à son point d'origine. */ +export function styleSpiderfyLegFeature(): Style { + return new OlStyle({ + stroke: new Stroke({ color: 'rgba(74, 127, 181, 0.55)', width: 1.5, lineDash: [2, 3] }), + }); +} diff --git a/src/pages/report/MyReportsPage.module.css b/src/pages/report/MyReportsPage.module.css index 29509ee..93d5a75 100644 --- a/src/pages/report/MyReportsPage.module.css +++ b/src/pages/report/MyReportsPage.module.css @@ -1,7 +1,10 @@ .page { display: flex; flex-direction: column; - min-height: 100dvh; + /* Hauteur fixe (pas `min-height`) : sans elle, la page grandit avec son contenu au lieu + que `.main` défile seul, et la barre d'onglets suit le dernier élément au lieu de + rester fixée en bas (cf. `.mapPage` dans MapPage.module.css, même principe). */ + height: 100dvh; background: var(--color-bg-page); } diff --git a/src/pages/report/ReportDetailPage.module.css b/src/pages/report/ReportDetailPage.module.css index af89151..a4546a0 100644 --- a/src/pages/report/ReportDetailPage.module.css +++ b/src/pages/report/ReportDetailPage.module.css @@ -1,7 +1,10 @@ .page { display: flex; flex-direction: column; - min-height: 100dvh; + /* Hauteur fixe (pas `min-height`) : sans elle, la page grandit avec son contenu au lieu + que `.main` défile seul, et la barre d'onglets suit le dernier élément au lieu de + rester fixée en bas (cf. `.mapPage` dans MapPage.module.css, même principe). */ + height: 100dvh; background: var(--color-bg-subtle); }