Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 169 additions & 9 deletions src/features/map/hooks/useReportMapLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string | null>(null);

useEffect(() => {
if (!map || !isMapReady) {
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -139,13 +163,28 @@ export function useReportMapLayers({
zIndex: REPORT_MAP_LAYER_Z_INDEX,
});

const spiderfySource = new VectorSource<Feature>();
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,
});

Expand All @@ -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]);

Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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]);
}
89 changes: 89 additions & 0 deletions src/features/map/utils/reportClusterSpiderfy.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading