548 lines
14 KiB
JavaScript
548 lines
14 KiB
JavaScript
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||
import mapboxgl from 'mapbox-gl';
|
||
import { getJSON, ajax, fire } from '@utils';
|
||
|
||
import { readGeoFile } from './readGeoFile';
|
||
import {
|
||
filterFeatureCollection,
|
||
generateId,
|
||
findFeature,
|
||
getBounds,
|
||
defer
|
||
} from '../shared/mapbox/utils';
|
||
|
||
const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur';
|
||
const SOURCE_CADASTRE = 'cadastre';
|
||
|
||
export function useMapboxEditor(
|
||
featureCollection,
|
||
{ url, enabled = true, cadastreEnabled = true }
|
||
) {
|
||
const [isLoaded, setLoaded] = useState(false);
|
||
const mapRef = useRef();
|
||
const drawRef = useRef();
|
||
const loadedRef = useRef(defer());
|
||
const selectedCadastresRef = useRef(() => new Set());
|
||
const isSupported = useMemo(() => mapboxgl.supported());
|
||
|
||
useEffect(() => {
|
||
const translations = [
|
||
['.mapbox-gl-draw_line', 'Tracer une ligne'],
|
||
['.mapbox-gl-draw_polygon', 'Dessiner un polygone'],
|
||
['.mapbox-gl-draw_point', 'Ajouter un point'],
|
||
['.mapbox-gl-draw_trash', 'Supprimer']
|
||
];
|
||
for (const [selector, translation] of translations) {
|
||
const element = document.querySelector(selector);
|
||
if (element) {
|
||
element.setAttribute('title', translation);
|
||
}
|
||
}
|
||
}, [isLoaded]);
|
||
|
||
const addEventListener = useCallback((eventName, target, callback) => {
|
||
loadedRef.current.promise.then(() => {
|
||
mapRef.current.on(eventName, target, callback);
|
||
});
|
||
return () => {
|
||
if (mapRef.current) {
|
||
mapRef.current.off(eventName, target, callback);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const highlightFeature = useCallback((cid, highlight) => {
|
||
if (highlight) {
|
||
selectedCadastresRef.current.add(cid);
|
||
} else {
|
||
selectedCadastresRef.current.delete(cid);
|
||
}
|
||
if (selectedCadastresRef.current.size == 0) {
|
||
mapRef.current.setFilter('parcelle-highlighted', ['in', 'id', '']);
|
||
} else {
|
||
mapRef.current.setFilter('parcelle-highlighted', [
|
||
'in',
|
||
'id',
|
||
...selectedCadastresRef.current
|
||
]);
|
||
}
|
||
}, []);
|
||
|
||
const fitBounds = useCallback((bbox) => {
|
||
mapRef.current.fitBounds(bbox, { padding: 100 });
|
||
}, []);
|
||
|
||
const hoverFeature = useCallback((feature, hover) => {
|
||
if (!selectedCadastresRef.current.has(feature.properties.id)) {
|
||
mapRef.current.setFeatureState(
|
||
{
|
||
source: 'cadastre',
|
||
sourceLayer: 'parcelles',
|
||
id: feature.id
|
||
},
|
||
{ hover }
|
||
);
|
||
}
|
||
}, []);
|
||
|
||
const addFeatures = useCallback((features, external) => {
|
||
for (const feature of features) {
|
||
if (feature.lid) {
|
||
drawRef.current?.draw.setFeatureProperty(
|
||
feature.lid,
|
||
'id',
|
||
feature.properties.id
|
||
);
|
||
delete feature.lid;
|
||
}
|
||
if (external) {
|
||
if (feature.properties.source == SOURCE_SELECTION_UTILISATEUR) {
|
||
drawRef.current?.draw.add({ id: feature.properties.id, ...feature });
|
||
} else {
|
||
highlightFeature(feature.properties.cid, true);
|
||
}
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const removeFeatures = useCallback((features, external) => {
|
||
if (external) {
|
||
for (const feature of features) {
|
||
if (feature.properties.source == SOURCE_SELECTION_UTILISATEUR) {
|
||
drawRef.current?.draw.delete(feature.id);
|
||
} else {
|
||
highlightFeature(feature.properties.cid, false);
|
||
}
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const onLoad = useCallback(
|
||
(map) => {
|
||
if (!mapRef.current) {
|
||
mapRef.current = map;
|
||
mapRef.current.fitBounds(props.featureCollection.bbox, {
|
||
padding: 100
|
||
});
|
||
onStyleChange();
|
||
setLoaded(true);
|
||
loadedRef.current.resolve();
|
||
}
|
||
},
|
||
[featureCollection]
|
||
);
|
||
|
||
const addEventListeners = useCallback((events) => {
|
||
const unsubscribe = Object.entries(events).map(
|
||
([eventName, [target, callback]]) =>
|
||
addEventListener(eventName, target, callback)
|
||
);
|
||
return () => unsubscribe.map((unsubscribe) => unsubscribe());
|
||
}, []);
|
||
|
||
const { createFeatures, updateFeatures, deleteFeatures, ...props } =
|
||
useFeatureCollection(featureCollection, {
|
||
url,
|
||
enabled: isSupported && enabled,
|
||
addFeatures,
|
||
removeFeatures
|
||
});
|
||
|
||
const onStyleChange = useCallback(() => {
|
||
if (mapRef.current) {
|
||
const featureCollection = props.featureCollection;
|
||
if (!cadastreEnabled) {
|
||
drawRef.current?.draw.set(
|
||
filterFeatureCollection(
|
||
featureCollection,
|
||
SOURCE_SELECTION_UTILISATEUR
|
||
)
|
||
);
|
||
}
|
||
selectedCadastresRef.current = new Set(
|
||
filterFeatureCollection(
|
||
featureCollection,
|
||
SOURCE_CADASTRE
|
||
).features.map(({ properties }) => properties.cid)
|
||
);
|
||
if (selectedCadastresRef.current.size > 0) {
|
||
mapRef.current.setFilter('parcelle-highlighted', [
|
||
'in',
|
||
'id',
|
||
...selectedCadastresRef.current
|
||
]);
|
||
}
|
||
}
|
||
}, [props.featureCollection, cadastreEnabled]);
|
||
|
||
useExternalEvents(props.featureCollection, {
|
||
fitBounds,
|
||
createFeatures,
|
||
updateFeatures,
|
||
deleteFeatures
|
||
});
|
||
useCadastres(props.featureCollection, {
|
||
addEventListeners,
|
||
hoverFeature,
|
||
createFeatures,
|
||
deleteFeatures,
|
||
enabled: cadastreEnabled
|
||
});
|
||
|
||
return {
|
||
isSupported,
|
||
onLoad,
|
||
onStyleChange,
|
||
drawRef,
|
||
createFeatures,
|
||
updateFeatures,
|
||
deleteFeatures,
|
||
...props,
|
||
...useImportFiles(props.featureCollection, {
|
||
createFeatures,
|
||
deleteFeatures
|
||
})
|
||
};
|
||
}
|
||
|
||
function useFeatureCollection(
|
||
initialFeatureCollection,
|
||
{ url, addFeatures, removeFeatures, enabled = true }
|
||
) {
|
||
const [error, onError] = useError();
|
||
const [featureCollection, setFeatureCollection] = useState(
|
||
initialFeatureCollection
|
||
);
|
||
const updateFeatureCollection = useCallback(
|
||
(callback) => {
|
||
setFeatureCollection(({ features }) => ({
|
||
type: 'FeatureCollection',
|
||
features: callback(features)
|
||
}));
|
||
ajax({ url, type: 'GET' })
|
||
.then(() => fire(document, 'ds:page:update'))
|
||
.catch(() => {});
|
||
},
|
||
[setFeatureCollection]
|
||
);
|
||
|
||
const createFeatures = useCallback(
|
||
async ({ features, source = SOURCE_SELECTION_UTILISATEUR, external }) => {
|
||
if (!enabled) {
|
||
return;
|
||
}
|
||
try {
|
||
const newFeatures = [];
|
||
for (const feature of features) {
|
||
const data = await getJSON(url, { feature, source }, 'post');
|
||
if (data) {
|
||
if (source == SOURCE_SELECTION_UTILISATEUR) {
|
||
data.feature.lid = feature.id;
|
||
}
|
||
newFeatures.push(data.feature);
|
||
}
|
||
}
|
||
addFeatures(newFeatures, external);
|
||
updateFeatureCollection(
|
||
(features) => [...features, ...newFeatures],
|
||
external
|
||
);
|
||
} catch (error) {
|
||
console.error(error);
|
||
onError('Le polygone dessiné n’est pas valide.');
|
||
}
|
||
},
|
||
[enabled, url, updateFeatureCollection, addFeatures]
|
||
);
|
||
|
||
const updateFeatures = useCallback(
|
||
async ({ features, source = SOURCE_SELECTION_UTILISATEUR, external }) => {
|
||
if (!enabled) {
|
||
return;
|
||
}
|
||
try {
|
||
const newFeatures = [];
|
||
for (const feature of features) {
|
||
const { id } = feature.properties;
|
||
if (id) {
|
||
await getJSON(`${url}/${id}`, { feature }, 'patch');
|
||
} else {
|
||
const data = await getJSON(url, { feature, source }, 'post');
|
||
if (data) {
|
||
if (source == SOURCE_SELECTION_UTILISATEUR) {
|
||
data.feature.lid = feature.id;
|
||
}
|
||
newFeatures.push(data.feature);
|
||
}
|
||
}
|
||
}
|
||
if (newFeatures.length > 0) {
|
||
addFeatures(newFeatures, external);
|
||
updateFeatureCollection((features) => [...features, ...newFeatures]);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
onError('Le polygone dessiné n’est pas valide.');
|
||
}
|
||
},
|
||
[enabled, url, updateFeatureCollection, addFeatures]
|
||
);
|
||
|
||
const deleteFeatures = useCallback(
|
||
async ({ features, external }) => {
|
||
if (!enabled) {
|
||
return;
|
||
}
|
||
try {
|
||
const deletedFeatures = [];
|
||
for (const feature of features) {
|
||
const { id } = feature.properties;
|
||
await getJSON(`${url}/${id}`, null, 'delete');
|
||
deletedFeatures.push(feature);
|
||
}
|
||
removeFeatures(deletedFeatures, external);
|
||
const deletedFeatureIds = deletedFeatures.map(
|
||
({ properties }) => properties.id
|
||
);
|
||
updateFeatureCollection(
|
||
(features) =>
|
||
features.filter(
|
||
({ properties }) => !deletedFeatureIds.includes(properties.id)
|
||
),
|
||
external
|
||
);
|
||
} catch (error) {
|
||
console.error(error);
|
||
onError('Le polygone n’a pas pu être supprimé.');
|
||
}
|
||
},
|
||
[enabled, url, updateFeatureCollection, removeFeatures]
|
||
);
|
||
|
||
return {
|
||
featureCollection,
|
||
createFeatures,
|
||
updateFeatures,
|
||
deleteFeatures,
|
||
error
|
||
};
|
||
}
|
||
|
||
function useImportFiles(featureCollection, { createFeatures, deleteFeatures }) {
|
||
const [inputs, setInputs] = useState([]);
|
||
const addInput = useCallback(
|
||
(input) => {
|
||
setInputs((inputs) => [...inputs, input]);
|
||
},
|
||
[setInputs]
|
||
);
|
||
const removeInput = useCallback(
|
||
(inputId) => {
|
||
setInputs((inputs) => inputs.filter((input) => input.id !== inputId));
|
||
},
|
||
[setInputs]
|
||
);
|
||
|
||
const onFileChange = useCallback(
|
||
async (event, inputId) => {
|
||
const { features, filename } = await readGeoFile(event.target.files[0]);
|
||
createFeatures({ features, external: true });
|
||
setInputs((inputs) => {
|
||
return inputs.map((input) => {
|
||
if (input.id === inputId) {
|
||
return { ...input, disabled: true, hasValue: true, filename };
|
||
}
|
||
return input;
|
||
});
|
||
});
|
||
},
|
||
[setInputs, createFeatures, featureCollection]
|
||
);
|
||
|
||
const addInputFile = useCallback(
|
||
(event) => {
|
||
event.preventDefault();
|
||
addInput({
|
||
id: generateId(),
|
||
disabled: false,
|
||
hasValue: false,
|
||
filename: ''
|
||
});
|
||
},
|
||
[addInput]
|
||
);
|
||
|
||
const removeInputFile = useCallback(
|
||
(event, inputId) => {
|
||
event.preventDefault();
|
||
const { filename } = inputs.find((input) => input.id === inputId);
|
||
const features = featureCollection.features.filter(
|
||
(feature) => feature.properties.filename == filename
|
||
);
|
||
deleteFeatures({ features, external: true });
|
||
removeInput(inputId);
|
||
},
|
||
[removeInput, deleteFeatures, featureCollection]
|
||
);
|
||
|
||
return {
|
||
inputs,
|
||
onFileChange,
|
||
addInputFile,
|
||
removeInputFile
|
||
};
|
||
}
|
||
|
||
function useExternalEvents(
|
||
featureCollection,
|
||
{ fitBounds, createFeatures, updateFeatures, deleteFeatures }
|
||
) {
|
||
const onFeatureFocus = useCallback(
|
||
({ detail }) => {
|
||
const { id, bbox } = detail;
|
||
if (id) {
|
||
const feature = findFeature(featureCollection, id);
|
||
if (feature) {
|
||
fitBounds(getBounds(feature.geometry));
|
||
}
|
||
} else if (bbox) {
|
||
fitBounds(bbox);
|
||
}
|
||
},
|
||
[featureCollection, fitBounds]
|
||
);
|
||
|
||
const onFeatureCreate = useCallback(
|
||
({ detail }) => {
|
||
const { geometry, properties } = detail;
|
||
|
||
if (geometry) {
|
||
createFeatures({
|
||
features: [{ geometry, properties }],
|
||
external: true
|
||
});
|
||
}
|
||
},
|
||
[createFeatures]
|
||
);
|
||
|
||
const onFeatureUpdate = useCallback(
|
||
({ detail }) => {
|
||
const { id, properties } = detail;
|
||
const feature = findFeature(featureCollection, id);
|
||
|
||
if (feature) {
|
||
feature.properties = { ...feature.properties, ...properties };
|
||
updateFeatures({ features: [feature], external: true });
|
||
}
|
||
},
|
||
[featureCollection, updateFeatures]
|
||
);
|
||
|
||
const onFeatureDelete = useCallback(
|
||
({ detail }) => {
|
||
const { id } = detail;
|
||
const feature = findFeature(featureCollection, id);
|
||
|
||
if (feature) {
|
||
deleteFeatures({ features: [feature], external: true });
|
||
}
|
||
},
|
||
[featureCollection, deleteFeatures]
|
||
);
|
||
|
||
useEvent('map:feature:focus', onFeatureFocus);
|
||
useEvent('map:feature:create', onFeatureCreate);
|
||
useEvent('map:feature:update', onFeatureUpdate);
|
||
useEvent('map:feature:delete', onFeatureDelete);
|
||
}
|
||
|
||
function useCadastres(
|
||
featureCollection,
|
||
{
|
||
addEventListeners,
|
||
hoverFeature,
|
||
createFeatures,
|
||
deleteFeatures,
|
||
enabled = true
|
||
}
|
||
) {
|
||
const hoveredFeature = useRef();
|
||
|
||
const onMouseMove = useCallback(
|
||
(event) => {
|
||
if (event.features.length > 0) {
|
||
const feature = event.features[0];
|
||
if (hoveredFeature.current?.id != feature.id) {
|
||
if (hoveredFeature.current) {
|
||
hoverFeature(hoveredFeature.current, false);
|
||
}
|
||
hoveredFeature.current = feature;
|
||
hoverFeature(feature, true);
|
||
}
|
||
}
|
||
},
|
||
[hoverFeature]
|
||
);
|
||
|
||
const onMouseLeave = useCallback(() => {
|
||
if (hoveredFeature.current) {
|
||
hoverFeature(hoveredFeature.current, false);
|
||
}
|
||
hoveredFeature.current = null;
|
||
}, [hoverFeature]);
|
||
|
||
const onClick = useCallback(
|
||
async (event) => {
|
||
if (event.features.length > 0) {
|
||
const currentId = event.features[0].properties.id;
|
||
const feature = findFeature(
|
||
filterFeatureCollection(featureCollection, SOURCE_CADASTRE),
|
||
currentId,
|
||
'cid'
|
||
);
|
||
if (feature) {
|
||
deleteFeatures({
|
||
features: [feature],
|
||
source: SOURCE_CADASTRE,
|
||
external: true
|
||
});
|
||
} else {
|
||
createFeatures({
|
||
features: event.features,
|
||
source: SOURCE_CADASTRE,
|
||
external: true
|
||
});
|
||
}
|
||
}
|
||
},
|
||
[featureCollection, createFeatures, deleteFeatures]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (enabled) {
|
||
return addEventListeners({
|
||
click: ['parcelles-fill', onClick],
|
||
mousemove: ['parcelles-fill', onMouseMove],
|
||
mouseleave: ['parcelles-fill', onMouseLeave]
|
||
});
|
||
}
|
||
}, [onClick, onMouseMove, onMouseLeave, enabled]);
|
||
}
|
||
|
||
function useError() {
|
||
const [error, onError] = useState();
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => onError(null), 5000);
|
||
return () => clearTimeout(timer);
|
||
}, [error]);
|
||
|
||
return [error, onError];
|
||
}
|
||
|
||
export function useEvent(eventName, callback) {
|
||
return useEffect(() => {
|
||
addEventListener(eventName, callback);
|
||
return () => removeEventListener(eventName, callback);
|
||
}, [eventName, callback]);
|
||
}
|