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]); }