Add cadastres to MapEditor
This commit is contained in:
parent
19440afebf
commit
2244263b49
3 changed files with 653 additions and 312 deletions
|
@ -1,257 +1,47 @@
|
||||||
import React, {
|
import React, { useState } from 'react';
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useMemo,
|
|
||||||
useEffect
|
|
||||||
} from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import ReactMapboxGl, { ZoomControl } from 'react-mapbox-gl';
|
||||||
import { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl';
|
|
||||||
import DrawControl from 'react-mapbox-gl-draw';
|
import DrawControl from 'react-mapbox-gl-draw';
|
||||||
|
import { MapIcon } from '@heroicons/react/outline';
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||||
|
|
||||||
import { getJSON, ajax, fire } from '@utils';
|
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
|
||||||
|
|
||||||
import Mapbox from '../shared/mapbox/Mapbox';
|
|
||||||
import { getMapStyle } from '../shared/mapbox/styles';
|
|
||||||
import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle';
|
|
||||||
import { FlashMessage } from '../shared/FlashMessage';
|
import { FlashMessage } from '../shared/FlashMessage';
|
||||||
|
|
||||||
import ComboAdresseSearch from '../ComboAdresseSearch';
|
import ComboAdresseSearch from '../ComboAdresseSearch';
|
||||||
import {
|
import { useMapboxEditor } from './useMapboxEditor';
|
||||||
polygonCadastresFill,
|
|
||||||
polygonCadastresLine,
|
|
||||||
readGeoFile
|
|
||||||
} from './utils';
|
|
||||||
import {
|
|
||||||
noop,
|
|
||||||
filterFeatureCollection,
|
|
||||||
fitBounds,
|
|
||||||
generateId,
|
|
||||||
useEvent,
|
|
||||||
findFeature
|
|
||||||
} from '../shared/mapbox/utils';
|
|
||||||
|
|
||||||
function MapEditor({ featureCollection, url, preview, options }) {
|
const Mapbox = ReactMapboxGl({});
|
||||||
const drawControl = useRef(null);
|
|
||||||
const [currentMap, setCurrentMap] = useState(null);
|
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState();
|
function MapEditor({ featureCollection, url, options, preview }) {
|
||||||
const [style, setStyle] = useState('ortho');
|
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
||||||
const [coords, setCoords] = useState([1.7, 46.9]);
|
const [coords, setCoords] = useState([1.7, 46.9]);
|
||||||
const [zoom, setZoom] = useState([5]);
|
const [zoom, setZoom] = useState([5]);
|
||||||
const [bbox, setBbox] = useState(featureCollection.bbox);
|
const {
|
||||||
const [importInputs, setImportInputs] = useState([]);
|
isSupported,
|
||||||
const [cadastresFeatureCollection, setCadastresFeatureCollection] = useState(
|
error,
|
||||||
filterFeatureCollection(featureCollection, 'cadastre')
|
inputs,
|
||||||
);
|
onLoad,
|
||||||
const mapStyle = useMemo(() => getMapStyle(style, options.layers), [
|
onStyleChange,
|
||||||
style,
|
onFileChange,
|
||||||
options
|
drawRef,
|
||||||
]);
|
createFeatures,
|
||||||
const hasCadastres = useMemo(() => options.layers.includes('cadastres'));
|
updateFeatures,
|
||||||
|
deleteFeatures,
|
||||||
|
addInputFile,
|
||||||
|
removeInputFile
|
||||||
|
} = useMapboxEditor(featureCollection, {
|
||||||
|
url,
|
||||||
|
enabled: !preview,
|
||||||
|
cadastreEnabled
|
||||||
|
});
|
||||||
|
const [style, setStyle] = useMapStyle(options.layers, {
|
||||||
|
onStyleChange,
|
||||||
|
cadastreEnabled
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
if (!isSupported) {
|
||||||
const timer = setTimeout(() => setErrorMessage(null), 5000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [errorMessage]);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFeatureFocus = useCallback(
|
|
||||||
({ detail }) => {
|
|
||||||
const { id } = detail;
|
|
||||||
const featureCollection = drawControl.current.draw.getAll();
|
|
||||||
const feature = findFeature(featureCollection, id);
|
|
||||||
if (feature) {
|
|
||||||
fitBounds(currentMap, feature);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentMap, drawControl.current]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFeatureUpdate = useCallback(
|
|
||||||
async ({ detail }) => {
|
|
||||||
const { id, properties } = detail;
|
|
||||||
const featureCollection = drawControl.current.draw.getAll();
|
|
||||||
const feature = findFeature(featureCollection, id);
|
|
||||||
|
|
||||||
if (feature) {
|
|
||||||
getJSON(`${url}/${id}`, { feature: { properties } }, 'patch');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[url, drawControl.current]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateFeaturesList = useCallback(
|
|
||||||
async (features) => {
|
|
||||||
const cadastres = features.find(
|
|
||||||
({ geometry }) => geometry.type === 'Polygon'
|
|
||||||
);
|
|
||||||
await ajax({
|
|
||||||
url,
|
|
||||||
type: 'get',
|
|
||||||
data: cadastres ? 'cadastres=update' : ''
|
|
||||||
});
|
|
||||||
fire(document, 'ds:page:update');
|
|
||||||
},
|
|
||||||
[url]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onCadastresUpdate = useCallback(({ detail }) => {
|
|
||||||
setCadastresFeatureCollection(
|
|
||||||
filterFeatureCollection(detail.featureCollection, 'cadastre')
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEvent('map:feature:focus', onFeatureFocus);
|
|
||||||
useEvent('map:feature:update', onFeatureUpdate);
|
|
||||||
useEvent('cadastres:update', onCadastresUpdate);
|
|
||||||
|
|
||||||
function setFeatureId(lid, feature) {
|
|
||||||
const draw = drawControl.current.draw;
|
|
||||||
draw.setFeatureProperty(lid, 'id', feature.properties.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateImportInputs(inputs, inputId) {
|
|
||||||
const updatedInputs = inputs.filter((input) => input.id !== inputId);
|
|
||||||
setImportInputs(updatedInputs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDrawCreate({ features }) {
|
|
||||||
try {
|
|
||||||
for (const feature of features) {
|
|
||||||
const data = await getJSON(url, { feature }, 'post');
|
|
||||||
setFeatureId(feature.id, data.feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFeaturesList(features);
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('Le polygone dessiné n’est pas valide.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDrawUpdate({ features }) {
|
|
||||||
try {
|
|
||||||
for (const feature of features) {
|
|
||||||
const { id } = feature.properties;
|
|
||||||
if (id) {
|
|
||||||
await getJSON(`${url}/${id}`, { feature }, 'patch');
|
|
||||||
} else {
|
|
||||||
const data = await getJSON(url, { feature }, 'post');
|
|
||||||
setFeatureId(feature.id, data.feature);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFeaturesList(features);
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('Le polygone dessiné n’est pas valide.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDrawDelete({ features }) {
|
|
||||||
for (const feature of features) {
|
|
||||||
const { id } = feature.properties;
|
|
||||||
await getJSON(`${url}/${id}`, null, 'delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFeaturesList(features);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMapLoad(map) {
|
|
||||||
setCurrentMap(map);
|
|
||||||
|
|
||||||
drawControl.current.draw.set(
|
|
||||||
filterFeatureCollection(featureCollection, 'selection_utilisateur')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFileImport = async (e, inputId) => {
|
|
||||||
try {
|
|
||||||
const featureCollection = await readGeoFile(e.target.files[0]);
|
|
||||||
const resultFeatureCollection = await getJSON(
|
|
||||||
`${url}/import`,
|
|
||||||
featureCollection,
|
|
||||||
'post'
|
|
||||||
);
|
|
||||||
let inputs = [...importInputs];
|
|
||||||
const setInputs = inputs.map((input) => {
|
|
||||||
if (input.id === inputId) {
|
|
||||||
input.disabled = true;
|
|
||||||
input.hasValue = true;
|
|
||||||
resultFeatureCollection.features.forEach((resultFeature) => {
|
|
||||||
featureCollection.features.forEach((feature) => {
|
|
||||||
if (
|
|
||||||
JSON.stringify(resultFeature.geometry) ===
|
|
||||||
JSON.stringify(feature.geometry)
|
|
||||||
) {
|
|
||||||
input.featureIds.push(resultFeature.properties.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
drawControl.current.draw.set(
|
|
||||||
filterFeatureCollection(
|
|
||||||
resultFeatureCollection,
|
|
||||||
'selection_utilisateur'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
updateFeaturesList(resultFeatureCollection.features);
|
|
||||||
setImportInputs(setInputs);
|
|
||||||
setBbox(resultFeatureCollection.bbox);
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('Le fichier importé contient des polygones invalides.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addInputFile = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
let inputs = [...importInputs];
|
|
||||||
inputs.push({
|
|
||||||
id: generateId(),
|
|
||||||
disabled: false,
|
|
||||||
featureIds: [],
|
|
||||||
hasValue: false
|
|
||||||
});
|
|
||||||
setImportInputs(inputs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeInputFile = async (e, inputId) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const draw = drawControl.current.draw;
|
|
||||||
const featureCollection = draw.getAll();
|
|
||||||
let inputs = [...importInputs];
|
|
||||||
const inputToRemove = inputs.find((input) => input.id === inputId);
|
|
||||||
|
|
||||||
for (const feature of featureCollection.features) {
|
|
||||||
if (inputToRemove.featureIds.includes(feature.properties.id)) {
|
|
||||||
const featureToRemove = draw.get(feature.id);
|
|
||||||
await getJSON(`${url}/${feature.properties.id}`, null, 'delete');
|
|
||||||
draw.delete(feature.id).getAll();
|
|
||||||
updateFeaturesList([featureToRemove]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateImportInputs(inputs, inputId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!mapboxgl.supported()) {
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
Nous ne pouvons pas afficher notre éditeur de carte car il est
|
Nous ne pouvons pas afficher notre éditeur de carte car il est
|
||||||
|
@ -263,9 +53,7 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{errorMessage && (
|
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||||
<FlashMessage message={errorMessage} level="alert" fixed={true} />
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<p style={{ marginBottom: '20px' }}>
|
<p style={{ marginBottom: '20px' }}>
|
||||||
Besoin d'aide ?
|
Besoin d'aide ?
|
||||||
|
@ -278,12 +66,12 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="file-import" style={{ marginBottom: '20px' }}>
|
<div className="file-import" style={{ marginBottom: '10px' }}>
|
||||||
<button className="button send primary" onClick={addInputFile}>
|
<button className="button send primary" onClick={addInputFile}>
|
||||||
Ajouter un fichier GPX ou KML
|
Ajouter un fichier GPX ou KML
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
{importInputs.map((input) => (
|
{inputs.map((input) => (
|
||||||
<div key={input.id}>
|
<div key={input.id}>
|
||||||
<input
|
<input
|
||||||
title="Choisir un fichier gpx ou kml"
|
title="Choisir un fichier gpx ou kml"
|
||||||
|
@ -292,7 +80,7 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
||||||
type="file"
|
type="file"
|
||||||
accept=".gpx, .kml"
|
accept=".gpx, .kml"
|
||||||
disabled={input.disabled}
|
disabled={input.disabled}
|
||||||
onChange={(e) => onFileImport(e, input.id)}
|
onChange={(e) => onFileChange(e, input.id)}
|
||||||
/>
|
/>
|
||||||
{input.hasValue && (
|
{input.hasValue && (
|
||||||
<span
|
<span
|
||||||
|
@ -310,10 +98,11 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: '50px'
|
marginBottom: '10px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ComboAdresseSearch
|
<ComboAdresseSearch
|
||||||
|
className="no-margin"
|
||||||
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
||||||
allowInputValues={false}
|
allowInputValues={false}
|
||||||
onChange={(_, { geometry: { coordinates } }) => {
|
onChange={(_, { geometry: { coordinates } }) => {
|
||||||
|
@ -323,38 +112,43 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Mapbox
|
<Mapbox
|
||||||
onStyleLoad={(map) => onMapLoad(map)}
|
onStyleLoad={(map) => onLoad(map)}
|
||||||
fitBounds={bbox}
|
|
||||||
fitBoundsOptions={{ padding: 100 }}
|
|
||||||
center={coords}
|
center={coords}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
style={mapStyle}
|
style={style}
|
||||||
containerStyle={{
|
containerStyle={{ height: '500px' }}
|
||||||
height: '500px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{hasCadastres ? (
|
{!cadastreEnabled && (
|
||||||
<GeoJSONLayer
|
<DrawControl
|
||||||
data={cadastresFeatureCollection}
|
ref={drawRef}
|
||||||
fillPaint={polygonCadastresFill}
|
onDrawCreate={createFeatures}
|
||||||
linePaint={polygonCadastresLine}
|
onDrawUpdate={updateFeatures}
|
||||||
|
onDrawDelete={deleteFeatures}
|
||||||
|
displayControlsDefault={false}
|
||||||
|
controls={{
|
||||||
|
point: true,
|
||||||
|
line_string: true,
|
||||||
|
polygon: true,
|
||||||
|
trash: true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
<DrawControl
|
<MapStyleControl style={style.id} setStyle={setStyle} />
|
||||||
ref={drawControl}
|
|
||||||
onDrawCreate={preview ? noop : onDrawCreate}
|
|
||||||
onDrawUpdate={preview ? noop : onDrawUpdate}
|
|
||||||
onDrawDelete={preview ? noop : onDrawDelete}
|
|
||||||
displayControlsDefault={false}
|
|
||||||
controls={{
|
|
||||||
point: true,
|
|
||||||
line_string: true,
|
|
||||||
polygon: true,
|
|
||||||
trash: true
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SwitchMapStyle style={style} setStyle={setStyle} ign={options.ign} />
|
|
||||||
<ZoomControl />
|
<ZoomControl />
|
||||||
|
{options.layers.includes('cadastres') && (
|
||||||
|
<div className="cadastres-selection-control mapboxgl-ctrl-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCadastreEnabled((cadastreEnabled) => !cadastreEnabled)
|
||||||
|
}
|
||||||
|
title="Sélectionner les parcelles cadastrales"
|
||||||
|
className={cadastreEnabled ? 'on' : ''}
|
||||||
|
>
|
||||||
|
<MapIcon className="icon-size" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Mapbox>
|
</Mapbox>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -363,15 +157,11 @@ function MapEditor({ featureCollection, url, preview, options }) {
|
||||||
MapEditor.propTypes = {
|
MapEditor.propTypes = {
|
||||||
featureCollection: PropTypes.shape({
|
featureCollection: PropTypes.shape({
|
||||||
bbox: PropTypes.array,
|
bbox: PropTypes.array,
|
||||||
features: PropTypes.array,
|
features: PropTypes.array
|
||||||
id: PropTypes.number
|
|
||||||
}),
|
}),
|
||||||
url: PropTypes.string,
|
url: PropTypes.string,
|
||||||
preview: PropTypes.bool,
|
preview: PropTypes.bool,
|
||||||
options: PropTypes.shape({
|
options: PropTypes.shape({ layers: PropTypes.array })
|
||||||
layers: PropTypes.array,
|
|
||||||
ign: PropTypes.bool
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MapEditor;
|
export default MapEditor;
|
||||||
|
|
|
@ -1,17 +1,28 @@
|
||||||
import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js';
|
import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js';
|
||||||
|
import { generateId } from '../shared/mapbox/utils';
|
||||||
|
|
||||||
export const polygonCadastresFill = {
|
export function readGeoFile(file) {
|
||||||
'fill-color': '#EC3323',
|
const isGpxFile = file.name.includes('.gpx');
|
||||||
'fill-opacity': 0.3
|
const reader = new FileReader();
|
||||||
};
|
|
||||||
|
|
||||||
export const polygonCadastresLine = {
|
return new Promise((resolve) => {
|
||||||
'line-color': 'rgba(255, 0, 0, 1)',
|
reader.onload = (event) => {
|
||||||
'line-width': 4,
|
const xml = new DOMParser().parseFromString(
|
||||||
'line-dasharray': [1, 1]
|
event.target.result,
|
||||||
};
|
'text/xml'
|
||||||
|
);
|
||||||
|
const featureCollection = normalizeFeatureCollection(
|
||||||
|
isGpxFile ? gpx(xml) : kml(xml),
|
||||||
|
file.name
|
||||||
|
);
|
||||||
|
|
||||||
export function normalizeFeatureCollection(featureCollection) {
|
resolve(featureCollection);
|
||||||
|
};
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFeatureCollection(featureCollection, filename) {
|
||||||
const features = [];
|
const features = [];
|
||||||
for (const feature of featureCollection.features) {
|
for (const feature of featureCollection.features) {
|
||||||
switch (feature.geometry.type) {
|
switch (feature.geometry.type) {
|
||||||
|
@ -65,26 +76,13 @@ export function normalizeFeatureCollection(featureCollection) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
featureCollection.features = features;
|
featureCollection.filename = `${generateId()}-${filename}`;
|
||||||
|
featureCollection.features = features.map((feature) => ({
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
filename: featureCollection.filename
|
||||||
|
}
|
||||||
|
}));
|
||||||
return featureCollection;
|
return featureCollection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readGeoFile(file) {
|
|
||||||
const isGpxFile = file.name.includes('.gpx');
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const xml = new DOMParser().parseFromString(
|
|
||||||
event.target.result,
|
|
||||||
'text/xml'
|
|
||||||
);
|
|
||||||
const featureCollection = normalizeFeatureCollection(
|
|
||||||
isGpxFile ? gpx(xml) : kml(xml)
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve(featureCollection);
|
|
||||||
};
|
|
||||||
reader.readAsText(file, 'UTF-8');
|
|
||||||
});
|
|
||||||
}
|
|
553
app/javascript/components/MapEditor/useMapboxEditor.js
Normal file
553
app/javascript/components/MapEditor/useMapboxEditor.js
Normal file
|
@ -0,0 +1,553 @@
|
||||||
|
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]);
|
||||||
|
}
|
Loading…
Reference in a new issue