refactor(carto): use maplibre instead of mapbox
This commit is contained in:
parent
ea6aec8b1a
commit
1f661325a5
39 changed files with 1753 additions and 1412 deletions
|
@ -41,7 +41,7 @@
|
|||
}
|
||||
|
||||
.map-style-panel {
|
||||
z-index: 99;
|
||||
z-index: 1;
|
||||
padding: $default-spacer;
|
||||
margin-bottom: $default-spacer;
|
||||
|
||||
|
@ -60,6 +60,7 @@
|
|||
}
|
||||
|
||||
.cadastres-selection-control {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 135px;
|
||||
left: 10px;
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
|
||||
// Labels that we only want for screen readers
|
||||
// https://www.coolfields.co.uk/2016/05/text-for-screen-readers-only-updated/
|
||||
.screen-reader-text {
|
||||
.sr-only {
|
||||
border: none;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
clip-path: inset(50%);
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ComboSearch from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
function ComboAdresseSearch({
|
||||
transformResult = ({ properties: { label } }) => [label, label],
|
||||
allowInputValues = true,
|
||||
...props
|
||||
}) {
|
||||
const transformResults = useCallback((_, { features }) => features);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
allowInputValues={allowInputValues}
|
||||
scope="adresse"
|
||||
minimumInputLength={2}
|
||||
transformResult={transformResult}
|
||||
transformResults={transformResults}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ComboAdresseSearch.propTypes = {
|
||||
transformResult: PropTypes.func,
|
||||
allowInputValues: PropTypes.bool
|
||||
};
|
||||
|
||||
export default ComboAdresseSearch;
|
31
app/javascript/components/ComboAdresseSearch.tsx
Normal file
31
app/javascript/components/ComboAdresseSearch.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import type { FeatureCollection, Geometry } from 'geojson';
|
||||
|
||||
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
type RawResult = FeatureCollection<Geometry, { label: string }>;
|
||||
type AdresseResult = RawResult['features'][0];
|
||||
type ComboAdresseSearchProps = Omit<
|
||||
ComboSearchProps<AdresseResult>,
|
||||
'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope'
|
||||
>;
|
||||
|
||||
export default function ComboAdresseSearch({
|
||||
allowInputValues = true,
|
||||
...props
|
||||
}: ComboAdresseSearchProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch<AdresseResult>
|
||||
{...props}
|
||||
allowInputValues={allowInputValues}
|
||||
scope="adresse"
|
||||
minimumInputLength={2}
|
||||
transformResult={({ properties: { label } }) => [label, label, label]}
|
||||
transformResults={(_, result) => (result as RawResult).features}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useQuery } from 'react-query';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
|
@ -14,11 +13,33 @@ import invariant from 'tiny-invariant';
|
|||
|
||||
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
|
||||
|
||||
function defaultTransformResults(_, results) {
|
||||
return results;
|
||||
}
|
||||
type TransformResults<Result> = (term: string, results: unknown) => Result[];
|
||||
type TransformResult<Result> = (
|
||||
result: Result
|
||||
) => [key: string, value: string, label: string];
|
||||
|
||||
function ComboSearch({
|
||||
export type ComboSearchProps<Result> = {
|
||||
onChange?: (value: string | null, result?: Result) => void;
|
||||
value?: string;
|
||||
scope: string;
|
||||
scopeExtra?: string;
|
||||
minimumInputLength: number;
|
||||
transformResults: TransformResults<Result>;
|
||||
transformResult: TransformResult<Result>;
|
||||
allowInputValues?: boolean;
|
||||
id?: string;
|
||||
describedby?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type QueryKey = readonly [
|
||||
scope: string,
|
||||
term: string,
|
||||
extra: string | undefined
|
||||
];
|
||||
|
||||
function ComboSearch<Result>({
|
||||
onChange,
|
||||
value: controlledValue,
|
||||
scope,
|
||||
|
@ -26,26 +47,28 @@ function ComboSearch({
|
|||
minimumInputLength,
|
||||
transformResult,
|
||||
allowInputValues = false,
|
||||
transformResults = defaultTransformResults,
|
||||
transformResults = (_, results) => results as Result[],
|
||||
id,
|
||||
describedby,
|
||||
...props
|
||||
}) {
|
||||
}: ComboSearchProps<Result>) {
|
||||
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required');
|
||||
|
||||
const group = !onChange ? groupId(id) : null;
|
||||
const group = !onChange && id ? groupId(id) : undefined;
|
||||
const [externalValue, setExternalValue, hiddenField] = useHiddenField(group);
|
||||
const [, setExternalId] = useHiddenField(group, 'external_id');
|
||||
const initialValue = externalValue ? externalValue : controlledValue;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const resultsMap = useRef({});
|
||||
const getLabel = (result) => {
|
||||
const resultsMap = useRef<
|
||||
Record<string, { key: string; value: string; result: Result }>
|
||||
>({});
|
||||
const getLabel = (result: Result) => {
|
||||
const [, value, label] = transformResult(result);
|
||||
return label ?? value;
|
||||
};
|
||||
const setExternalValueAndId = useCallback((label) => {
|
||||
const setExternalValueAndId = useCallback((label: string) => {
|
||||
const { key, value, result } = resultsMap.current[label];
|
||||
if (onChange) {
|
||||
onChange(value, result);
|
||||
|
@ -77,22 +100,22 @@ function ComboSearch({
|
|||
[minimumInputLength]
|
||||
);
|
||||
|
||||
const handleOnSelect = useCallback((value) => {
|
||||
const handleOnSelect = useCallback((value: string) => {
|
||||
setExternalValueAndId(value);
|
||||
setValue(value);
|
||||
setSearchTerm('');
|
||||
awaitFormSubmit.done();
|
||||
}, []);
|
||||
|
||||
const { isSuccess, data } = useQuery(
|
||||
const { isSuccess, data } = useQuery<void, void, unknown, QueryKey>(
|
||||
[scope, debouncedSearchTerm, scopeExtra],
|
||||
{
|
||||
enabled: !!debouncedSearchTerm,
|
||||
notifyOnStatusChange: false,
|
||||
refetchOnMount: false
|
||||
}
|
||||
);
|
||||
const results = isSuccess ? transformResults(debouncedSearchTerm, data) : [];
|
||||
const results =
|
||||
isSuccess && data ? transformResults(debouncedSearchTerm, data) : [];
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
if (!allowInputValues && isSuccess && results[0]) {
|
||||
|
@ -136,18 +159,4 @@ function ComboSearch({
|
|||
);
|
||||
}
|
||||
|
||||
ComboSearch.propTypes = {
|
||||
value: PropTypes.string,
|
||||
scope: PropTypes.string,
|
||||
minimumInputLength: PropTypes.number,
|
||||
transformResult: PropTypes.func,
|
||||
transformResults: PropTypes.func,
|
||||
allowInputValues: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
scopeExtra: PropTypes.string,
|
||||
mandatory: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
describedby: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboSearch;
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import type { Point } from 'geojson';
|
||||
|
||||
import ComboAdresseSearch from '../../ComboAdresseSearch';
|
||||
import { useFlyTo } from '../../shared/maplibre/hooks';
|
||||
|
||||
export function AddressInput() {
|
||||
const flyTo = useFlyTo();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
>
|
||||
<ComboAdresseSearch
|
||||
className="no-margin"
|
||||
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
||||
allowInputValues={false}
|
||||
onChange={(_, result) => {
|
||||
const geometry = result?.geometry as Point;
|
||||
flyTo(17, geometry.coordinates as [number, number]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
164
app/javascript/components/MapEditor/components/CadastreLayer.tsx
Normal file
164
app/javascript/components/MapEditor/components/CadastreLayer.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import type { Feature, FeatureCollection } from 'geojson';
|
||||
|
||||
import { useMapLibre } from '../../shared/maplibre/MapLibre';
|
||||
import {
|
||||
useEvent,
|
||||
useMapEvent,
|
||||
EventHandler
|
||||
} from '../../shared/maplibre/hooks';
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
findFeature
|
||||
} from '../../shared/maplibre/utils';
|
||||
|
||||
import { SOURCE_CADASTRE, CreateFeatures, DeleteFeatures } from '../hooks';
|
||||
|
||||
export function CadastreLayer({
|
||||
featureCollection,
|
||||
createFeatures,
|
||||
deleteFeatures,
|
||||
enabled
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
createFeatures: CreateFeatures;
|
||||
deleteFeatures: DeleteFeatures;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const selectedCadastresRef = useRef(new Set<string>());
|
||||
|
||||
const highlightFeature = useCallback((cid: string, highlight: boolean) => {
|
||||
if (highlight) {
|
||||
selectedCadastresRef.current.add(cid);
|
||||
} else {
|
||||
selectedCadastresRef.current.delete(cid);
|
||||
}
|
||||
if (selectedCadastresRef.current.size == 0) {
|
||||
map.setFilter('parcelle-highlighted', ['in', 'id', '']);
|
||||
} else {
|
||||
map.setFilter('parcelle-highlighted', [
|
||||
'in',
|
||||
'id',
|
||||
...selectedCadastresRef.current
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hoverFeature = useCallback((feature: Feature, hover: boolean) => {
|
||||
if (!selectedCadastresRef.current.has(feature.properties?.id)) {
|
||||
map.setFeatureState(
|
||||
{
|
||||
source: 'cadastre',
|
||||
sourceLayer: 'parcelles',
|
||||
id: String(feature.id)
|
||||
},
|
||||
{ hover }
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useCadastres(featureCollection, {
|
||||
hoverFeature,
|
||||
createFeatures,
|
||||
deleteFeatures,
|
||||
enabled
|
||||
});
|
||||
|
||||
useMapEvent('styledata', () => {
|
||||
selectedCadastresRef.current = new Set(
|
||||
filterFeatureCollection(featureCollection, SOURCE_CADASTRE).features.map(
|
||||
({ properties }) => properties?.cid
|
||||
)
|
||||
);
|
||||
if (selectedCadastresRef.current.size > 0) {
|
||||
map.setFilter('parcelle-highlighted', [
|
||||
'in',
|
||||
'id',
|
||||
...selectedCadastresRef.current
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const onHighlight = useCallback(
|
||||
({ detail }) => {
|
||||
highlightFeature(detail.cid, detail.highlight);
|
||||
},
|
||||
[highlightFeature]
|
||||
);
|
||||
|
||||
useEvent('map:internal:cadastre:highlight', onHighlight);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useCadastres(
|
||||
featureCollection: FeatureCollection,
|
||||
{
|
||||
enabled,
|
||||
hoverFeature,
|
||||
createFeatures,
|
||||
deleteFeatures
|
||||
}: {
|
||||
enabled: boolean;
|
||||
hoverFeature: (feature: Feature, flag: boolean) => void;
|
||||
createFeatures: CreateFeatures;
|
||||
deleteFeatures: DeleteFeatures;
|
||||
}
|
||||
) {
|
||||
const hoveredFeature = useRef<Feature | null>();
|
||||
|
||||
const onMouseMove = useCallback<EventHandler>(
|
||||
(event) => {
|
||||
if (enabled && event.features && 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, hoverFeature]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback<EventHandler>(() => {
|
||||
if (hoveredFeature.current) {
|
||||
hoverFeature(hoveredFeature.current, false);
|
||||
}
|
||||
hoveredFeature.current = null;
|
||||
}, [hoverFeature]);
|
||||
|
||||
const onClick = useCallback<EventHandler>(
|
||||
async (event) => {
|
||||
if (enabled && event.features && 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
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, featureCollection, createFeatures, deleteFeatures]
|
||||
);
|
||||
|
||||
useMapEvent('click', onClick, 'parcelles-fill');
|
||||
useMapEvent('mousemove', onMouseMove, 'parcelles-fill');
|
||||
useMapEvent('mouseleave', onMouseLeave, 'parcelles-fill');
|
||||
}
|
183
app/javascript/components/MapEditor/components/DrawLayer.tsx
Normal file
183
app/javascript/components/MapEditor/components/DrawLayer.tsx
Normal file
|
@ -0,0 +1,183 @@
|
|||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import type { LngLatBoundsLike } from 'maplibre-gl';
|
||||
import DrawControl from '@mapbox/mapbox-gl-draw';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
import { useMapLibre } from '../../shared/maplibre/MapLibre';
|
||||
import {
|
||||
useFitBounds,
|
||||
useEvent,
|
||||
useMapEvent
|
||||
} from '../../shared/maplibre/hooks';
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
findFeature,
|
||||
getBounds
|
||||
} from '../../shared/maplibre/utils';
|
||||
import {
|
||||
SOURCE_SELECTION_UTILISATEUR,
|
||||
CreateFeatures,
|
||||
UpdateFatures,
|
||||
DeleteFeatures
|
||||
} from '../hooks';
|
||||
|
||||
export function DrawLayer({
|
||||
featureCollection,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures,
|
||||
enabled
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
createFeatures: CreateFeatures;
|
||||
updateFeatures: UpdateFatures;
|
||||
deleteFeatures: DeleteFeatures;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const drawRef = useRef<DrawControl | null>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawRef.current && enabled) {
|
||||
const draw = new DrawControl({
|
||||
displayControlsDefault: false,
|
||||
controls: {
|
||||
point: true,
|
||||
line_string: true,
|
||||
polygon: true,
|
||||
trash: true
|
||||
}
|
||||
});
|
||||
map.addControl(draw as any, 'top-left');
|
||||
draw.set(
|
||||
filterFeatureCollection(featureCollection, SOURCE_SELECTION_UTILISATEUR)
|
||||
);
|
||||
drawRef.current = draw;
|
||||
|
||||
for (const [selector, translation] of translations) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
element.setAttribute('title', translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (drawRef.current) {
|
||||
map.removeControl(drawRef.current as any);
|
||||
drawRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const onSetId = useCallback(({ detail }) => {
|
||||
drawRef.current?.setFeatureProperty(detail.lid, 'id', detail.id);
|
||||
}, []);
|
||||
const onAddFeature = useCallback(({ detail }) => {
|
||||
drawRef.current?.add(detail.feature);
|
||||
}, []);
|
||||
const onDeleteFature = useCallback(({ detail }) => {
|
||||
drawRef.current?.delete(detail.id);
|
||||
}, []);
|
||||
|
||||
useMapEvent('draw.create', createFeatures);
|
||||
useMapEvent('draw.update', updateFeatures);
|
||||
useMapEvent('draw.delete', deleteFeatures);
|
||||
|
||||
useEvent('map:internal:draw:setId', onSetId);
|
||||
useEvent('map:internal:draw:add', onAddFeature);
|
||||
useEvent('map:internal:draw:delete', onDeleteFature);
|
||||
|
||||
useExternalEvents(featureCollection, {
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useExternalEvents(
|
||||
featureCollection: FeatureCollection,
|
||||
{
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures
|
||||
}: {
|
||||
createFeatures: CreateFeatures;
|
||||
updateFeatures: UpdateFatures;
|
||||
deleteFeatures: DeleteFeatures;
|
||||
}
|
||||
) {
|
||||
const fitBounds = useFitBounds();
|
||||
|
||||
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: [{ type: 'Feature', 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]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fitBounds(featureCollection.bbox as LngLatBoundsLike);
|
||||
}, []);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
useEvent('map:feature:create', onFeatureCreate);
|
||||
useEvent('map:feature:update', onFeatureUpdate);
|
||||
useEvent('map:feature:delete', onFeatureDelete);
|
||||
}
|
||||
|
||||
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']
|
||||
];
|
|
@ -0,0 +1,132 @@
|
|||
import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import { readGeoFile } from '../readGeoFile';
|
||||
import { generateId } from '../../shared/maplibre/utils';
|
||||
import { CreateFeatures, DeleteFeatures } from '../hooks';
|
||||
|
||||
export function ImportFileInput({
|
||||
featureCollection,
|
||||
createFeatures,
|
||||
deleteFeatures
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
createFeatures: CreateFeatures;
|
||||
deleteFeatures: DeleteFeatures;
|
||||
}) {
|
||||
const { inputs, addInputFile, removeInputFile, onFileChange } =
|
||||
useImportFiles(featureCollection, { createFeatures, deleteFeatures });
|
||||
|
||||
return (
|
||||
<div className="file-import" style={{ marginBottom: '10px' }}>
|
||||
<button className="button send primary" onClick={addInputFile}>
|
||||
Ajouter un fichier GPX ou KML
|
||||
</button>
|
||||
<div>
|
||||
{inputs.map((input) => (
|
||||
<div key={input.id}>
|
||||
<input
|
||||
title="Choisir un fichier gpx ou kml"
|
||||
style={{ marginTop: '15px' }}
|
||||
id={input.id}
|
||||
type="file"
|
||||
accept=".gpx, .kml"
|
||||
disabled={input.disabled}
|
||||
onChange={(e) => onFileChange(e, input.id)}
|
||||
/>
|
||||
{input.hasValue && (
|
||||
<span
|
||||
title="Supprimer le fichier"
|
||||
className="icon refuse"
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => removeInputFile(e, input.id)}
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FileInput = {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
hasValue: boolean;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
function useImportFiles(
|
||||
featureCollection: FeatureCollection,
|
||||
{
|
||||
createFeatures,
|
||||
deleteFeatures
|
||||
}: { createFeatures: CreateFeatures; deleteFeatures: DeleteFeatures }
|
||||
) {
|
||||
const [inputs, setInputs] = useState<FileInput[]>([]);
|
||||
const addInput = useCallback(
|
||||
(input: FileInput) => {
|
||||
setInputs((inputs) => [...inputs, input]);
|
||||
},
|
||||
[setInputs]
|
||||
);
|
||||
const removeInput = useCallback(
|
||||
(inputId: string) => {
|
||||
setInputs((inputs) => inputs.filter((input) => input.id !== inputId));
|
||||
},
|
||||
[setInputs]
|
||||
);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>, inputId: string) => {
|
||||
invariant(event.target.files, '');
|
||||
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]
|
||||
);
|
||||
|
||||
const addInputFile = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
addInput({
|
||||
id: generateId(),
|
||||
disabled: false,
|
||||
hasValue: false,
|
||||
filename: ''
|
||||
});
|
||||
},
|
||||
[addInput]
|
||||
);
|
||||
|
||||
const removeInputFile = useCallback(
|
||||
(event: MouseEvent, inputId: string) => {
|
||||
event.preventDefault();
|
||||
const filename = inputs.find((input) => input.id === inputId)?.filename;
|
||||
const features = featureCollection.features.filter(
|
||||
(feature) => feature.properties?.filename == filename
|
||||
);
|
||||
deleteFeatures({ features, external: true });
|
||||
removeInput(inputId);
|
||||
},
|
||||
[inputs, removeInput, deleteFeatures, featureCollection]
|
||||
);
|
||||
|
||||
return {
|
||||
inputs,
|
||||
onFileChange,
|
||||
addInputFile,
|
||||
removeInputFile
|
||||
};
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import React, { useState } from 'react';
|
||||
import { fire } from '@utils';
|
||||
import type { Feature } from 'geojson';
|
||||
import { PlusIcon, LocationMarkerIcon } from '@heroicons/react/outline';
|
||||
import { useId } from '@reach/auto-id';
|
||||
import CoordinateInput from 'react-coordinate-input';
|
||||
|
||||
import { useFlyTo } from '../../shared/maplibre/hooks';
|
||||
|
||||
export function PointInput() {
|
||||
const flyTo = useFlyTo();
|
||||
|
||||
const inputId = useId();
|
||||
const [value, setValue] = useState('');
|
||||
const [feature, setFeature] = useState<Feature | null>(null);
|
||||
const getCurrentPosition = () => {
|
||||
navigator.geolocation &&
|
||||
navigator.geolocation.getCurrentPosition(({ coords }) => {
|
||||
setValue(
|
||||
`${coords.latitude.toPrecision(6)}, ${coords.longitude.toPrecision(
|
||||
6
|
||||
)}`
|
||||
);
|
||||
});
|
||||
};
|
||||
const addPoint = () => {
|
||||
if (feature) {
|
||||
fire(document, 'map:feature:create', feature);
|
||||
setValue('');
|
||||
setFeature(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
className="areas-title mt-1"
|
||||
htmlFor={inputId}
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
Ajouter un point sur la carte
|
||||
</label>
|
||||
<div className="flex align-center mt-1 mb-2">
|
||||
{navigator.geolocation ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button mr-1"
|
||||
onClick={getCurrentPosition}
|
||||
title="Localiser votre position"
|
||||
>
|
||||
<span className="sr-only">Localiser votre position</span>
|
||||
<LocationMarkerIcon className="icon-size-big" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<CoordinateInput
|
||||
id={inputId}
|
||||
className="m-0 mr-1"
|
||||
value={value}
|
||||
onChange={(value: string, { dd }: { dd: [number, number] }) => {
|
||||
setValue(value);
|
||||
if (dd.length) {
|
||||
const coordinates: [number, number] = [dd[1], dd[0]];
|
||||
setFeature({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates
|
||||
},
|
||||
properties: {}
|
||||
});
|
||||
flyTo(17, coordinates);
|
||||
} else {
|
||||
setFeature(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={addPoint}
|
||||
disabled={!feature}
|
||||
title="Ajouter le point avec les coordonnées saisies sur la carte"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Ajouter le point avec les coordonnées saisies sur la carte
|
||||
</span>
|
||||
<PlusIcon className="icon-size-big" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
208
app/javascript/components/MapEditor/hooks.ts
Normal file
208
app/javascript/components/MapEditor/hooks.ts
Normal file
|
@ -0,0 +1,208 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { getJSON, ajax, fire } from '@utils';
|
||||
import type { Feature, FeatureCollection, Geometry } from 'geojson';
|
||||
|
||||
export const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur';
|
||||
export const SOURCE_CADASTRE = 'cadastre';
|
||||
|
||||
export type CreateFeatures = (params: {
|
||||
features: Feature<Geometry>[];
|
||||
source?: string;
|
||||
external?: true;
|
||||
}) => void;
|
||||
export type UpdateFatures = (params: {
|
||||
features: Feature<Geometry>[];
|
||||
source?: string;
|
||||
external?: true;
|
||||
}) => void;
|
||||
export type DeleteFeatures = (params: {
|
||||
features: Feature<Geometry>[];
|
||||
source?: string;
|
||||
external?: true;
|
||||
}) => void;
|
||||
|
||||
export function useFeatureCollection(
|
||||
initialFeatureCollection: FeatureCollection,
|
||||
{ url, enabled = true }: { url: string; enabled: boolean }
|
||||
) {
|
||||
const [error, onError] = useError();
|
||||
const [featureCollection, setFeatureCollection] = useState(
|
||||
initialFeatureCollection
|
||||
);
|
||||
const updateFeatureCollection = useCallback<
|
||||
(callback: (features: Feature[]) => Feature[]) => void
|
||||
>(
|
||||
(callback) => {
|
||||
setFeatureCollection(({ features }) => ({
|
||||
type: 'FeatureCollection',
|
||||
features: callback(features)
|
||||
}));
|
||||
ajax({ url, type: 'GET' })
|
||||
.then(() => fire(document, 'ds:page:update'))
|
||||
.catch(() => null);
|
||||
},
|
||||
[url, setFeatureCollection]
|
||||
);
|
||||
|
||||
const addFeatures = useCallback(
|
||||
(features: (Feature & { lid?: string })[], external: boolean) => {
|
||||
for (const feature of features) {
|
||||
if (feature.lid) {
|
||||
fire(document, 'map:internal:draw:setId', {
|
||||
lid: feature.lid,
|
||||
id: feature.properties?.id
|
||||
});
|
||||
delete feature.lid;
|
||||
}
|
||||
if (external) {
|
||||
if (feature.properties?.source == SOURCE_SELECTION_UTILISATEUR) {
|
||||
fire(document, 'map:internal:draw:add', {
|
||||
feature: {
|
||||
id: feature.properties.id,
|
||||
...feature
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fire(document, 'map:internal:cadastre:highlight', {
|
||||
cid: feature.properties?.cid,
|
||||
highlight: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeFeatures = useCallback(
|
||||
(features: Feature[], external: boolean) => {
|
||||
if (external) {
|
||||
for (const feature of features) {
|
||||
if (feature.properties?.source == SOURCE_SELECTION_UTILISATEUR) {
|
||||
fire(document, 'map:internal:draw:delete', { id: feature.id });
|
||||
} else {
|
||||
fire(document, 'map:internal:cadastre:highlight', {
|
||||
cid: feature.properties?.cid,
|
||||
highlight: false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const createFeatures = useCallback<CreateFeatures>(
|
||||
async ({
|
||||
features,
|
||||
source = SOURCE_SELECTION_UTILISATEUR,
|
||||
external = false
|
||||
}) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newFeatures: Feature[] = [];
|
||||
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]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
onError('Le polygone dessiné n’est pas valide.');
|
||||
}
|
||||
},
|
||||
[enabled, url, updateFeatureCollection, addFeatures, onError]
|
||||
);
|
||||
|
||||
const updateFeatures = useCallback<UpdateFatures>(
|
||||
async ({
|
||||
features,
|
||||
source = SOURCE_SELECTION_UTILISATEUR,
|
||||
external = false
|
||||
}) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newFeatures: Feature[] = [];
|
||||
for (const feature of features) {
|
||||
const id = feature.properties?.id;
|
||||
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, onError]
|
||||
);
|
||||
|
||||
const deleteFeatures = useCallback<DeleteFeatures>(
|
||||
async ({ features, external = false }) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const deletedFeatures = [];
|
||||
for (const feature of features) {
|
||||
const id = feature.properties?.id;
|
||||
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)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
onError('Le polygone n’a pas pu être supprimé.');
|
||||
}
|
||||
},
|
||||
[enabled, url, updateFeatureCollection, removeFeatures, onError]
|
||||
);
|
||||
|
||||
return {
|
||||
featureCollection,
|
||||
error,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures
|
||||
};
|
||||
}
|
||||
|
||||
function useError(): [string | undefined, (message: string) => void] {
|
||||
const [error, onError] = useState<string | undefined>();
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onError(undefined), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error]);
|
||||
|
||||
return [error, onError];
|
||||
}
|
|
@ -1,263 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactMapboxGl, { ZoomControl } from 'react-mapbox-gl';
|
||||
import DrawControl from 'react-mapbox-gl-draw';
|
||||
import {
|
||||
CursorClickIcon,
|
||||
PlusIcon,
|
||||
LocationMarkerIcon
|
||||
} from '@heroicons/react/outline';
|
||||
import CoordinateInput from 'react-coordinate-input';
|
||||
import { fire } from '@utils';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { useId } from '@reach/auto-id';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
|
||||
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
|
||||
import { FlashMessage } from '../shared/FlashMessage';
|
||||
|
||||
import ComboAdresseSearch from '../ComboAdresseSearch';
|
||||
import { useMapboxEditor } from './useMapboxEditor';
|
||||
|
||||
const Mapbox = ReactMapboxGl({});
|
||||
|
||||
function MapEditor({ featureCollection, url, options, preview }) {
|
||||
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
||||
const [coords, setCoords] = useState([1.7, 46.9]);
|
||||
const [zoom, setZoom] = useState([5]);
|
||||
const {
|
||||
isSupported,
|
||||
error,
|
||||
inputs,
|
||||
onLoad,
|
||||
onStyleChange,
|
||||
onFileChange,
|
||||
drawRef,
|
||||
createFeatures,
|
||||
updateFeatures,
|
||||
deleteFeatures,
|
||||
addInputFile,
|
||||
removeInputFile
|
||||
} = useMapboxEditor(featureCollection, {
|
||||
url,
|
||||
enabled: !preview,
|
||||
cadastreEnabled
|
||||
});
|
||||
const { style, layers, setStyle, setLayerEnabled, setLayerOpacity } =
|
||||
useMapStyle(options.layers, {
|
||||
onStyleChange,
|
||||
cadastreEnabled
|
||||
});
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<p>
|
||||
Nous ne pouvons pas afficher notre éditeur de carte car il est
|
||||
imcompatible avec votre navigateur. Nous vous conseillons de le mettre à
|
||||
jour ou utiliser les dernières versions de Chrome, Firefox ou Safari
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||
<div>
|
||||
<p style={{ marginBottom: '20px' }}>
|
||||
Besoin d'aide ?
|
||||
<a
|
||||
href="https://doc.demarches-simplifiees.fr/pour-aller-plus-loin/cartographie"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
consulter les tutoriels video
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="file-import" style={{ marginBottom: '10px' }}>
|
||||
<button className="button send primary" onClick={addInputFile}>
|
||||
Ajouter un fichier GPX ou KML
|
||||
</button>
|
||||
<div>
|
||||
{inputs.map((input) => (
|
||||
<div key={input.id}>
|
||||
<input
|
||||
title="Choisir un fichier gpx ou kml"
|
||||
style={{ marginTop: '15px' }}
|
||||
id={input.id}
|
||||
type="file"
|
||||
accept=".gpx, .kml"
|
||||
disabled={input.disabled}
|
||||
onChange={(e) => onFileChange(e, input.id)}
|
||||
/>
|
||||
{input.hasValue && (
|
||||
<span
|
||||
title="Supprimer le fichier"
|
||||
className="icon refuse"
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={(e) => removeInputFile(e, input.id)}
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
>
|
||||
<ComboAdresseSearch
|
||||
className="no-margin"
|
||||
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
|
||||
allowInputValues={false}
|
||||
onChange={(_, { geometry: { coordinates } }) => {
|
||||
setCoords(coordinates);
|
||||
setZoom([17]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Mapbox
|
||||
onStyleLoad={(map) => onLoad(map)}
|
||||
center={coords}
|
||||
zoom={zoom}
|
||||
style={style}
|
||||
containerStyle={{ height: '500px' }}
|
||||
>
|
||||
{!cadastreEnabled && (
|
||||
<DrawControl
|
||||
ref={drawRef}
|
||||
onDrawCreate={createFeatures}
|
||||
onDrawUpdate={updateFeatures}
|
||||
onDrawDelete={deleteFeatures}
|
||||
displayControlsDefault={false}
|
||||
controls={{
|
||||
point: true,
|
||||
line_string: true,
|
||||
polygon: true,
|
||||
trash: true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MapStyleControl
|
||||
style={style.id}
|
||||
layers={layers}
|
||||
setStyle={setStyle}
|
||||
setLayerEnabled={setLayerEnabled}
|
||||
setLayerOpacity={setLayerOpacity}
|
||||
/>
|
||||
<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' : ''}
|
||||
>
|
||||
<CursorClickIcon className="icon-size" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Mapbox>
|
||||
<PointInput />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PointInput() {
|
||||
const inputId = useId();
|
||||
const [value, setValue] = useState('');
|
||||
const [feature, setFeature] = useState(null);
|
||||
const getCurrentPosition = () => {
|
||||
navigator.geolocation &&
|
||||
navigator.geolocation.getCurrentPosition(({ coords }) => {
|
||||
setValue(
|
||||
`${coords.latitude.toPrecision(6)}, ${coords.longitude.toPrecision(
|
||||
6
|
||||
)}`
|
||||
);
|
||||
});
|
||||
};
|
||||
const addPoint = () => {
|
||||
if (feature) {
|
||||
fire(document, 'map:feature:create', feature);
|
||||
setValue('');
|
||||
setFeature(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
className="areas-title mt-1"
|
||||
htmlFor={inputId}
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
Ajouter un point sur la carte
|
||||
</label>
|
||||
<div className="flex align-center mt-1 mb-2">
|
||||
{navigator.geolocation ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button mr-1"
|
||||
onClick={getCurrentPosition}
|
||||
title="Localiser votre position"
|
||||
>
|
||||
<VisuallyHidden>Localiser votre position</VisuallyHidden>
|
||||
<LocationMarkerIcon className="icon-size-big" />
|
||||
</button>
|
||||
) : null}
|
||||
<CoordinateInput
|
||||
id={inputId}
|
||||
className="m-0 mr-1"
|
||||
value={value}
|
||||
onChange={(value, { dd }) => {
|
||||
setValue(value);
|
||||
setFeature(
|
||||
dd.length
|
||||
? {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: dd.reverse()
|
||||
},
|
||||
properties: {}
|
||||
}
|
||||
: null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={addPoint}
|
||||
disabled={!feature}
|
||||
title="Ajouter le point avec les coordonnées saisies sur la carte"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
Ajouter le point avec les coordonnées saisies sur la carte
|
||||
</VisuallyHidden>
|
||||
<PlusIcon className="icon-size-big" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
MapEditor.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
url: PropTypes.string,
|
||||
preview: PropTypes.bool,
|
||||
options: PropTypes.shape({ layers: PropTypes.array })
|
||||
};
|
||||
|
||||
export default MapEditor;
|
91
app/javascript/components/MapEditor/index.tsx
Normal file
91
app/javascript/components/MapEditor/index.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
import React, { useState } from 'react';
|
||||
import { CursorClickIcon } from '@heroicons/react/outline';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
import { MapLibre } from '../shared/maplibre/MapLibre';
|
||||
import { useFeatureCollection } from './hooks';
|
||||
import { DrawLayer } from './components/DrawLayer';
|
||||
import { CadastreLayer } from './components/CadastreLayer';
|
||||
import { AddressInput } from './components/AddressInput';
|
||||
import { PointInput } from './components/PointInput';
|
||||
import { ImportFileInput } from './components/ImportFileInput';
|
||||
import { FlashMessage } from '../shared/FlashMessage';
|
||||
|
||||
export default function MapEditor({
|
||||
featureCollection: initialFeatureCollection,
|
||||
url,
|
||||
options,
|
||||
preview
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
url: string;
|
||||
preview: boolean;
|
||||
options: { layers: string[] };
|
||||
}) {
|
||||
const [cadastreEnabled, setCadastreEnabled] = useState(false);
|
||||
|
||||
const { featureCollection, error, ...actions } = useFeatureCollection(
|
||||
initialFeatureCollection,
|
||||
{ url, enabled: !preview }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p style={{ marginBottom: '20px' }}>
|
||||
Besoin d'aide ?
|
||||
<a
|
||||
href="https://doc.demarches-simplifiees.fr/pour-aller-plus-loin/cartographie"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
consulter les tutoriels video
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{error && <FlashMessage message={error} level="alert" fixed={true} />}
|
||||
<MapLibre
|
||||
layers={options.layers}
|
||||
header={
|
||||
<>
|
||||
<ImportFileInput
|
||||
featureCollection={featureCollection}
|
||||
{...actions}
|
||||
/>
|
||||
<AddressInput />
|
||||
</>
|
||||
}
|
||||
footer={<PointInput />}
|
||||
>
|
||||
<DrawLayer
|
||||
featureCollection={featureCollection}
|
||||
{...actions}
|
||||
enabled={!preview && !cadastreEnabled}
|
||||
/>
|
||||
{options.layers.includes('cadastres') ? (
|
||||
<>
|
||||
<CadastreLayer
|
||||
featureCollection={featureCollection}
|
||||
{...actions}
|
||||
enabled={!preview && cadastreEnabled}
|
||||
/>
|
||||
<div className="cadastres-selection-control mapboxgl-ctrl-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCadastreEnabled((cadastreEnabled) => !cadastreEnabled)
|
||||
}
|
||||
title="Sélectionner les parcelles cadastrales"
|
||||
className={cadastreEnabled ? 'on' : ''}
|
||||
>
|
||||
<CursorClickIcon className="icon-size" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</MapLibre>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,29 +1,37 @@
|
|||
import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js';
|
||||
import { generateId } from '../shared/mapbox/utils';
|
||||
import type { FeatureCollection, Feature } from 'geojson';
|
||||
|
||||
export function readGeoFile(file) {
|
||||
import { generateId } from '../shared/maplibre/utils';
|
||||
|
||||
export function readGeoFile(file: 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),
|
||||
file.name
|
||||
);
|
||||
return new Promise<ReturnType<typeof normalizeFeatureCollection>>(
|
||||
(resolve) => {
|
||||
reader.onload = (event: FileReaderEventMap['load']) => {
|
||||
const result = event.target?.result;
|
||||
const xml = new DOMParser().parseFromString(
|
||||
result as string,
|
||||
'text/xml'
|
||||
);
|
||||
const featureCollection = normalizeFeatureCollection(
|
||||
isGpxFile ? gpx(xml) : kml(xml),
|
||||
file.name
|
||||
);
|
||||
|
||||
resolve(featureCollection);
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
resolve(featureCollection);
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFeatureCollection(featureCollection, filename) {
|
||||
const features = [];
|
||||
function normalizeFeatureCollection(
|
||||
featureCollection: FeatureCollection,
|
||||
filename: string
|
||||
) {
|
||||
const features: Feature[] = [];
|
||||
for (const feature of featureCollection.features) {
|
||||
switch (feature.geometry.type) {
|
||||
case 'MultiPoint':
|
||||
|
@ -76,13 +84,13 @@ function normalizeFeatureCollection(featureCollection, filename) {
|
|||
}
|
||||
}
|
||||
|
||||
featureCollection.filename = `${generateId()}-${filename}`;
|
||||
const featureCollectionFilename = `${generateId()}-${filename}`;
|
||||
featureCollection.features = features.map((feature) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
filename: featureCollection.filename
|
||||
filename: featureCollectionFilename
|
||||
}
|
||||
}));
|
||||
return featureCollection;
|
||||
return { ...featureCollection, filename: featureCollectionFilename };
|
||||
}
|
|
@ -1,548 +0,0 @@
|
|||
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]);
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { useRef } from 'react';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
import { useMapLibre } from '../../shared/maplibre/MapLibre';
|
||||
import { useMapEvent } from '../../shared/maplibre/hooks';
|
||||
import { filterFeatureCollection } from '../../shared/maplibre/utils';
|
||||
|
||||
export function CadastreLayer({
|
||||
featureCollection
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const selectedCadastresRef = useRef<Set<string>>();
|
||||
|
||||
useMapEvent('styledata', () => {
|
||||
selectedCadastresRef.current = new Set(
|
||||
filterFeatureCollection(featureCollection, 'cadastre').features.map(
|
||||
({ properties }) => properties?.cid
|
||||
)
|
||||
);
|
||||
if (selectedCadastresRef.current.size > 0) {
|
||||
map.setFilter('parcelle-highlighted', [
|
||||
'in',
|
||||
'id',
|
||||
...selectedCadastresRef.current
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
237
app/javascript/components/MapReader/components/GeoJSONLayer.tsx
Normal file
237
app/javascript/components/MapReader/components/GeoJSONLayer.tsx
Normal file
|
@ -0,0 +1,237 @@
|
|||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Popup, LngLatBoundsLike } from 'maplibre-gl';
|
||||
import type { Feature, FeatureCollection } from 'geojson';
|
||||
|
||||
import { useMapLibre } from '../../shared/maplibre/MapLibre';
|
||||
import {
|
||||
useFitBounds,
|
||||
useEvent,
|
||||
EventHandler,
|
||||
useMapEvent
|
||||
} from '../../shared/maplibre/hooks';
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
findFeature,
|
||||
getBounds,
|
||||
getCenter,
|
||||
filterFeatureCollectionByGeometryType
|
||||
} from '../../shared/maplibre/utils';
|
||||
|
||||
export function GeoJSONLayer({
|
||||
featureCollection
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const popup = useMemo(
|
||||
() =>
|
||||
new Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const onMouseEnter = useCallback<EventHandler>(
|
||||
(event) => {
|
||||
const feature = event.features && event.features[0];
|
||||
if (feature?.properties && feature.properties.description) {
|
||||
const coordinates = getCenter(feature.geometry, event.lngLat);
|
||||
const description = feature.properties.description;
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
popup.setLngLat(coordinates).setHTML(description).addTo(map);
|
||||
} else {
|
||||
popup.remove();
|
||||
}
|
||||
},
|
||||
[popup]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
popup.remove();
|
||||
}, [popup]);
|
||||
|
||||
useExternalEvents(featureCollection);
|
||||
|
||||
const polygons = filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'Polygon'
|
||||
);
|
||||
const lines = filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'LineString'
|
||||
);
|
||||
const points = filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'Point'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{polygons.features.map((feature) => (
|
||||
<PolygonLayer
|
||||
key={feature.properties?.id}
|
||||
feature={feature}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
))}
|
||||
{lines.features.map((feature) => (
|
||||
<LineStringLayer
|
||||
key={feature.properties?.id}
|
||||
feature={feature}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
))}
|
||||
{points.features.map((feature) => (
|
||||
<PointLayer
|
||||
key={feature.properties?.id}
|
||||
feature={feature}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useExternalEvents(featureCollection: FeatureCollection) {
|
||||
const fitBounds = useFitBounds();
|
||||
const onFeatureFocus = useCallback(({ detail }) => {
|
||||
const { id } = detail;
|
||||
const feature = findFeature(featureCollection, id);
|
||||
if (feature) {
|
||||
fitBounds(getBounds(feature.geometry));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fitBounds(featureCollection.bbox as LngLatBoundsLike);
|
||||
}, []);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
}
|
||||
|
||||
function LineStringLayer({
|
||||
feature,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}: {
|
||||
feature: Feature;
|
||||
onMouseEnter: EventHandler;
|
||||
onMouseLeave: EventHandler;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const sourceId = String(feature.properties?.id);
|
||||
const layerId = `${sourceId}-layer`;
|
||||
|
||||
useEffect(() => {
|
||||
map
|
||||
.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: feature
|
||||
})
|
||||
.addLayer({
|
||||
id: layerId,
|
||||
source: sourceId,
|
||||
type: 'line',
|
||||
paint: lineStringSelectionLine
|
||||
});
|
||||
}, []);
|
||||
|
||||
useMapEvent('mouseenter', onMouseEnter, layerId);
|
||||
useMapEvent('mouseleave', onMouseLeave, layerId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PointLayer({
|
||||
feature,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}: {
|
||||
feature: Feature;
|
||||
onMouseEnter: EventHandler;
|
||||
onMouseLeave: EventHandler;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const sourceId = String(feature.properties?.id);
|
||||
const layerId = `${sourceId}-layer`;
|
||||
|
||||
useEffect(() => {
|
||||
map
|
||||
.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: feature
|
||||
})
|
||||
.addLayer({
|
||||
id: layerId,
|
||||
source: sourceId,
|
||||
type: 'circle',
|
||||
paint: pointSelectionCircle
|
||||
});
|
||||
}, []);
|
||||
|
||||
useMapEvent('mouseenter', onMouseEnter, layerId);
|
||||
useMapEvent('mouseleave', onMouseLeave, layerId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PolygonLayer({
|
||||
feature,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}: {
|
||||
feature: Feature;
|
||||
onMouseEnter: EventHandler;
|
||||
onMouseLeave: EventHandler;
|
||||
}) {
|
||||
const map = useMapLibre();
|
||||
const sourceId = String(feature.properties?.id);
|
||||
const layerId = `${sourceId}-layer`;
|
||||
const lineLayerId = `${sourceId}-line-layer`;
|
||||
|
||||
useEffect(() => {
|
||||
map
|
||||
.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: feature
|
||||
})
|
||||
.addLayer({
|
||||
id: lineLayerId,
|
||||
source: sourceId,
|
||||
type: 'line',
|
||||
paint: polygonSelectionLine
|
||||
})
|
||||
.addLayer({
|
||||
id: layerId,
|
||||
source: sourceId,
|
||||
type: 'fill',
|
||||
paint: polygonSelectionFill
|
||||
});
|
||||
}, []);
|
||||
|
||||
useMapEvent('mouseenter', onMouseEnter, layerId);
|
||||
useMapEvent('mouseleave', onMouseLeave, layerId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const polygonSelectionFill = {
|
||||
'fill-color': '#EC3323',
|
||||
'fill-opacity': 0.5
|
||||
};
|
||||
const polygonSelectionLine = {
|
||||
'line-color': 'rgba(255, 0, 0, 1)',
|
||||
'line-width': 4
|
||||
};
|
||||
const lineStringSelectionLine = {
|
||||
'line-color': 'rgba(55, 42, 127, 1.00)',
|
||||
'line-width': 3
|
||||
};
|
||||
const pointSelectionCircle = {
|
||||
'circle-color': '#EC3323'
|
||||
};
|
|
@ -1,190 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import ReactMapboxGl, { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl';
|
||||
import PropTypes from 'prop-types';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
filterFeatureCollectionByGeometryType
|
||||
} from '../shared/mapbox/utils';
|
||||
import { useMapbox } from './useMapbox';
|
||||
|
||||
const Mapbox = ReactMapboxGl({});
|
||||
|
||||
const MapReader = ({ featureCollection, options }) => {
|
||||
const { isSupported, onLoad, onStyleChange, onMouseEnter, onMouseLeave } =
|
||||
useMapbox(featureCollection);
|
||||
const { style, layers, setStyle, setLayerEnabled, setLayerOpacity } =
|
||||
useMapStyle(options.layers, { onStyleChange });
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<p>
|
||||
Nous ne pouvons pas afficher la carte car elle est imcompatible avec
|
||||
votre navigateur. Nous vous conseillons de le mettre à jour ou utiliser
|
||||
les dernières versions de Chrome, Firefox ou Safari
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Mapbox
|
||||
onStyleLoad={(map) => onLoad(map)}
|
||||
style={style}
|
||||
containerStyle={{ height: '500px' }}
|
||||
>
|
||||
<SelectionUtilisateurPolygonLayer
|
||||
featureCollection={featureCollection}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
<SelectionUtilisateurLineLayer
|
||||
featureCollection={featureCollection}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
<SelectionUtilisateurPointLayer
|
||||
featureCollection={featureCollection}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
|
||||
<MapStyleControl
|
||||
style={style.id}
|
||||
layers={layers}
|
||||
setStyle={setStyle}
|
||||
setLayerEnabled={setLayerEnabled}
|
||||
setLayerOpacity={setLayerOpacity}
|
||||
/>
|
||||
<ZoomControl />
|
||||
</Mapbox>
|
||||
);
|
||||
};
|
||||
|
||||
const polygonSelectionFill = {
|
||||
'fill-color': '#EC3323',
|
||||
'fill-opacity': 0.5
|
||||
};
|
||||
const polygonSelectionLine = {
|
||||
'line-color': 'rgba(255, 0, 0, 1)',
|
||||
'line-width': 4
|
||||
};
|
||||
const lineStringSelectionLine = {
|
||||
'line-color': 'rgba(55, 42, 127, 1.00)',
|
||||
'line-width': 3
|
||||
};
|
||||
const pointSelectionFill = {
|
||||
'circle-color': '#EC3323'
|
||||
};
|
||||
|
||||
function SelectionUtilisateurPolygonLayer({
|
||||
featureCollection,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'Polygon'
|
||||
),
|
||||
[featureCollection]
|
||||
);
|
||||
|
||||
return (
|
||||
<GeoJSONLayer
|
||||
data={data}
|
||||
fillPaint={polygonSelectionFill}
|
||||
linePaint={polygonSelectionLine}
|
||||
fillOnMouseEnter={onMouseEnter}
|
||||
fillOnMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionUtilisateurLineLayer({
|
||||
featureCollection,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'LineString'
|
||||
),
|
||||
[featureCollection]
|
||||
);
|
||||
return (
|
||||
<GeoJSONLayer
|
||||
data={data}
|
||||
linePaint={lineStringSelectionLine}
|
||||
lineOnMouseEnter={onMouseEnter}
|
||||
lineOnMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionUtilisateurPointLayer({
|
||||
featureCollection,
|
||||
onMouseEnter,
|
||||
onMouseLeave
|
||||
}) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
filterFeatureCollectionByGeometryType(
|
||||
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
|
||||
'Point'
|
||||
),
|
||||
[featureCollection]
|
||||
);
|
||||
return (
|
||||
<GeoJSONLayer
|
||||
data={data}
|
||||
circlePaint={pointSelectionFill}
|
||||
circleOnMouseEnter={onMouseEnter}
|
||||
circleOnMouseLeave={onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SelectionUtilisateurPolygonLayer.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func
|
||||
};
|
||||
|
||||
SelectionUtilisateurLineLayer.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func
|
||||
};
|
||||
|
||||
SelectionUtilisateurPointLayer.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func
|
||||
};
|
||||
|
||||
MapReader.propTypes = {
|
||||
featureCollection: PropTypes.shape({
|
||||
bbox: PropTypes.array,
|
||||
features: PropTypes.array
|
||||
}),
|
||||
options: PropTypes.shape({ layers: PropTypes.array })
|
||||
};
|
||||
|
||||
export default MapReader;
|
24
app/javascript/components/MapReader/index.tsx
Normal file
24
app/javascript/components/MapReader/index.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
import { MapLibre } from '../shared/maplibre/MapLibre';
|
||||
import { CadastreLayer } from './components/CadastreLayer';
|
||||
import { GeoJSONLayer } from './components/GeoJSONLayer';
|
||||
|
||||
const MapReader = ({
|
||||
featureCollection,
|
||||
options
|
||||
}: {
|
||||
featureCollection: FeatureCollection;
|
||||
options: { layers: string[] };
|
||||
}) => {
|
||||
return (
|
||||
<MapLibre layers={options.layers}>
|
||||
<GeoJSONLayer featureCollection={featureCollection} />
|
||||
<CadastreLayer featureCollection={featureCollection} />
|
||||
</MapLibre>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapReader;
|
|
@ -1,104 +0,0 @@
|
|||
import { useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import mapboxgl, { Popup } from 'mapbox-gl';
|
||||
|
||||
import {
|
||||
filterFeatureCollection,
|
||||
findFeature,
|
||||
getBounds,
|
||||
getCenter
|
||||
} from '../shared/mapbox/utils';
|
||||
|
||||
const SOURCE_CADASTRE = 'cadastre';
|
||||
|
||||
export function useMapbox(featureCollection) {
|
||||
const mapRef = useRef();
|
||||
const selectedCadastresRef = useRef(() => new Set());
|
||||
const isSupported = useMemo(() => mapboxgl.supported());
|
||||
|
||||
const fitBounds = useCallback((bbox) => {
|
||||
mapRef.current.fitBounds(bbox, { padding: 100 });
|
||||
}, []);
|
||||
|
||||
const onLoad = useCallback(
|
||||
(map) => {
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = map;
|
||||
mapRef.current.fitBounds(featureCollection.bbox, { padding: 100 });
|
||||
onStyleChange();
|
||||
}
|
||||
},
|
||||
[featureCollection]
|
||||
);
|
||||
|
||||
const onStyleChange = useCallback(() => {
|
||||
if (mapRef.current) {
|
||||
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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [featureCollection]);
|
||||
|
||||
const popup = useMemo(
|
||||
() =>
|
||||
new Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false
|
||||
})
|
||||
);
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(event) => {
|
||||
const feature = event.features[0];
|
||||
if (feature.properties && feature.properties.description) {
|
||||
const coordinates = getCenter(feature.geometry, event.lngLat);
|
||||
const description = feature.properties.description;
|
||||
mapRef.current.getCanvas().style.cursor = 'pointer';
|
||||
popup.setLngLat(coordinates).setHTML(description).addTo(mapRef.current);
|
||||
} else {
|
||||
popup.remove();
|
||||
}
|
||||
},
|
||||
[popup]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
mapRef.current.getCanvas().style.cursor = '';
|
||||
popup.remove();
|
||||
}, [popup]);
|
||||
|
||||
useExternalEvents(featureCollection, { fitBounds });
|
||||
|
||||
return { isSupported, onLoad, onStyleChange, onMouseEnter, onMouseLeave };
|
||||
}
|
||||
|
||||
function useExternalEvents(featureCollection, { fitBounds }) {
|
||||
const onFeatureFocus = useCallback(
|
||||
({ detail }) => {
|
||||
const { id } = detail;
|
||||
const feature = findFeature(featureCollection, id);
|
||||
if (feature) {
|
||||
fitBounds(getBounds(feature.geometry));
|
||||
}
|
||||
},
|
||||
[featureCollection, fitBounds]
|
||||
);
|
||||
|
||||
useEvent('map:feature:focus', onFeatureFocus);
|
||||
}
|
||||
|
||||
export function useEvent(eventName, callback) {
|
||||
return useEffect(() => {
|
||||
addEventListener(eventName, callback);
|
||||
return () => removeEventListener(eventName, callback);
|
||||
}, [eventName, callback]);
|
||||
}
|
|
@ -82,7 +82,7 @@ const TypeDeChamp = sortableElement(
|
|||
}}
|
||||
>
|
||||
<TrashIcon className="icon-size" />
|
||||
<span className="screen-reader-text">Supprimer</span>
|
||||
<span className="sr-only">Supprimer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,17 +1,26 @@
|
|||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export function FlashMessage({ message, level, sticky, fixed }) {
|
||||
export function FlashMessage({
|
||||
message,
|
||||
level,
|
||||
sticky,
|
||||
fixed
|
||||
}: {
|
||||
message: string;
|
||||
level: string;
|
||||
sticky?: boolean;
|
||||
fixed?: boolean;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div className="flash_message center">
|
||||
<div className={flashClassName(level, sticky, fixed)}>{message}</div>
|
||||
</div>,
|
||||
document.getElementById('flash_messages')
|
||||
document.getElementById('flash_messages')!
|
||||
);
|
||||
}
|
||||
|
||||
function flashClassName(level, sticky = false, fixed = false) {
|
||||
function flashClassName(level: string, sticky = false, fixed = false) {
|
||||
const className =
|
||||
level == 'notice' ? ['alert', 'alert-success'] : ['alert', 'alert-danger'];
|
||||
|
||||
|
@ -23,10 +32,3 @@ function flashClassName(level, sticky = false, fixed = false) {
|
|||
}
|
||||
return className.join(' ');
|
||||
}
|
||||
|
||||
FlashMessage.propTypes = {
|
||||
message: PropTypes.string,
|
||||
level: PropTypes.string,
|
||||
sticky: PropTypes.bool,
|
||||
fixed: PropTypes.bool
|
||||
};
|
|
@ -1,15 +1,18 @@
|
|||
import { useRef, useCallback, useMemo, useState } from 'react';
|
||||
import { fire } from '@utils';
|
||||
|
||||
export function useDeferredSubmit(input) {
|
||||
export function useDeferredSubmit(input?: HTMLInputElement): {
|
||||
(callback: () => void): void;
|
||||
done: () => void;
|
||||
} {
|
||||
const calledRef = useRef(false);
|
||||
const awaitFormSubmit = useCallback(
|
||||
(callback) => {
|
||||
(callback: () => void) => {
|
||||
const form = input?.form;
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const interceptFormSubmit = (event) => {
|
||||
const interceptFormSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
runCallback();
|
||||
form.submit();
|
||||
|
@ -27,17 +30,24 @@ export function useDeferredSubmit(input) {
|
|||
},
|
||||
[input]
|
||||
);
|
||||
awaitFormSubmit.done = () => {
|
||||
const done = () => {
|
||||
calledRef.current = true;
|
||||
};
|
||||
return awaitFormSubmit;
|
||||
return Object.assign(awaitFormSubmit, { done });
|
||||
}
|
||||
|
||||
export function groupId(id) {
|
||||
export function groupId(id: string) {
|
||||
return `#champ-${id.replace(/-input$/, '')}`;
|
||||
}
|
||||
|
||||
export function useHiddenField(group, name = 'value') {
|
||||
export function useHiddenField(
|
||||
group?: string,
|
||||
name = 'value'
|
||||
): [
|
||||
value: string | undefined,
|
||||
setValue: (value: string) => void,
|
||||
input: HTMLInputElement | undefined
|
||||
] {
|
||||
const hiddenField = useMemo(
|
||||
() => selectInputInGroup(group, name),
|
||||
[group, name]
|
||||
|
@ -53,13 +63,16 @@ export function useHiddenField(group, name = 'value') {
|
|||
fire(hiddenField, 'autosave:trigger');
|
||||
}
|
||||
},
|
||||
hiddenField
|
||||
hiddenField ?? undefined
|
||||
];
|
||||
}
|
||||
|
||||
function selectInputInGroup(group, name) {
|
||||
function selectInputInGroup(
|
||||
group: string | undefined,
|
||||
name: string
|
||||
): HTMLInputElement | undefined | null {
|
||||
if (group) {
|
||||
return document.querySelector(
|
||||
return document.querySelector<HTMLInputElement>(
|
||||
`${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]`
|
||||
);
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import { LngLatBounds } from 'mapbox-gl';
|
||||
|
||||
export function getBounds(geometry) {
|
||||
const bbox = new LngLatBounds();
|
||||
|
||||
if (geometry.type === 'Point') {
|
||||
return [geometry.coordinates, geometry.coordinates];
|
||||
} else if (geometry.type === 'LineString') {
|
||||
for (const coordinate of geometry.coordinates) {
|
||||
bbox.extend(coordinate);
|
||||
}
|
||||
} else {
|
||||
for (const coordinate of geometry.coordinates[0]) {
|
||||
bbox.extend(coordinate);
|
||||
}
|
||||
}
|
||||
return bbox;
|
||||
}
|
||||
|
||||
export function findFeature(featureCollection, value, property = 'id') {
|
||||
return featureCollection.features.find(
|
||||
(feature) => feature.properties[property] === value
|
||||
);
|
||||
}
|
||||
|
||||
export function filterFeatureCollection(featureCollection, source) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: featureCollection.features.filter(
|
||||
(feature) => feature.properties.source === source
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function filterFeatureCollectionByGeometryType(featureCollection, type) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: featureCollection.features.filter(
|
||||
(feature) => feature.geometry.type === type
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function generateId() {
|
||||
return Math.random().toString(20).substr(2, 6);
|
||||
}
|
||||
|
||||
export function getCenter(geometry, lngLat) {
|
||||
const bbox = new LngLatBounds();
|
||||
|
||||
switch (geometry.type) {
|
||||
case 'Point':
|
||||
return [...geometry.coordinates];
|
||||
case 'LineString':
|
||||
return [lngLat.lng, lngLat.lat];
|
||||
default:
|
||||
for (const coordinate of geometry.coordinates[0]) {
|
||||
bbox.extend(coordinate);
|
||||
}
|
||||
return bbox.getCenter();
|
||||
}
|
||||
}
|
||||
|
||||
export function defer() {
|
||||
const deferred = {};
|
||||
const promise = new Promise(function (resolve, reject) {
|
||||
deferred.resolve = resolve;
|
||||
deferred.reject = reject;
|
||||
});
|
||||
deferred.promise = promise;
|
||||
return deferred;
|
||||
}
|
104
app/javascript/components/shared/maplibre/MapLibre.tsx
Normal file
104
app/javascript/components/shared/maplibre/MapLibre.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
createContext
|
||||
} from 'react';
|
||||
import maplibre, { Map, Style, NavigationControl } from 'maplibre-gl';
|
||||
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import { useStyle } from './hooks';
|
||||
import { StyleControl } from './StyleControl';
|
||||
|
||||
const Context = createContext<{ map?: Map | null }>({});
|
||||
|
||||
type MapLibreProps = {
|
||||
layers: string[];
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function useMapLibre() {
|
||||
const context = useContext(Context);
|
||||
invariant(context.map, 'Maplibre not initialized');
|
||||
return context.map;
|
||||
}
|
||||
|
||||
export function MapLibre({ children, header, footer, layers }: MapLibreProps) {
|
||||
const isSupported = useMemo(
|
||||
() => maplibre.supported({ failIfMajorPerformanceCaveat: true }) && !isIE(),
|
||||
[]
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [map, setMap] = useState<Map | null>();
|
||||
|
||||
const onStyleChange = (style: Style) => {
|
||||
if (map) {
|
||||
map.setStyle(style);
|
||||
}
|
||||
};
|
||||
const { style, ...mapStyleProps } = useStyle(layers, onStyleChange);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupported && !map) {
|
||||
invariant(containerRef.current, 'Map container not found');
|
||||
const map = new Map({
|
||||
container: containerRef.current,
|
||||
style
|
||||
});
|
||||
map.addControl(new NavigationControl({}), 'top-right');
|
||||
map.on('load', () => {
|
||||
setMap(map);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div
|
||||
style={{ marginBottom: '20px' }}
|
||||
className="outdated-browser-banner site-banner"
|
||||
>
|
||||
<div className="container">
|
||||
<div className="site-banner-icon">⚠️</div>
|
||||
<div className="site-banner-text">
|
||||
Nous ne pouvons pas afficher la carte car elle est imcompatible avec
|
||||
votre navigateur. Nous vous conseillons de le mettre à jour ou
|
||||
d’utiliser{' '}
|
||||
<a
|
||||
href="https://browser-update.org/fr/update.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
un navigateur plus récent
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ map }}>
|
||||
{map ? header : null}
|
||||
<div ref={containerRef} style={{ height: '500px' }}>
|
||||
<StyleControl styleId={style.id} {...mapStyleProps} />
|
||||
{map ? children : null}
|
||||
</div>
|
||||
{map ? footer : null}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function isIE() {
|
||||
const ua = window.navigator.userAgent;
|
||||
const msie = ua.indexOf('MSIE ');
|
||||
const trident = ua.indexOf('Trident/');
|
||||
return msie > 0 || trident > 0;
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { Popover, RadioGroup } from '@headlessui/react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { MapIcon } from '@heroicons/react/outline';
|
||||
|
@ -7,7 +6,7 @@ import { Slider } from '@reach/slider';
|
|||
import { useId } from '@reach/auto-id';
|
||||
import '@reach/slider/styles.css';
|
||||
|
||||
import { getMapStyle, getLayerName, NBS } from './styles';
|
||||
import { LayersMap, NBS } from './styles';
|
||||
|
||||
const STYLES = {
|
||||
ortho: 'Satellite',
|
||||
|
@ -15,68 +14,22 @@ const STYLES = {
|
|||
ign: 'Carte IGN'
|
||||
};
|
||||
|
||||
function optionalLayersMap(optionalLayers) {
|
||||
return Object.fromEntries(
|
||||
optionalLayers.map((layer) => [
|
||||
layer,
|
||||
{
|
||||
configurable: layer != 'cadastres',
|
||||
enabled: true,
|
||||
opacity: 70,
|
||||
name: getLayerName(layer)
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function useMapStyle(
|
||||
optionalLayers,
|
||||
{ onStyleChange, cadastreEnabled }
|
||||
) {
|
||||
const [styleId, setStyle] = useState('ortho');
|
||||
const [layers, setLayers] = useState(() => optionalLayersMap(optionalLayers));
|
||||
const setLayerEnabled = (layer, enabled) =>
|
||||
setLayers((optionalLayers) => {
|
||||
optionalLayers[layer].enabled = enabled;
|
||||
return { ...optionalLayers };
|
||||
});
|
||||
const setLayerOpacity = (layer, opacity) =>
|
||||
setLayers((optionalLayers) => {
|
||||
optionalLayers[layer].opacity = opacity;
|
||||
return { ...optionalLayers };
|
||||
});
|
||||
const enabledLayers = Object.entries(layers).filter(
|
||||
([, { enabled }]) => enabled
|
||||
);
|
||||
const layerIds = enabledLayers.map(
|
||||
([layer, { opacity }]) => `${layer}-${opacity}`
|
||||
);
|
||||
const style = useMemo(
|
||||
() =>
|
||||
getMapStyle(
|
||||
styleId,
|
||||
enabledLayers.map(([layer]) => layer),
|
||||
Object.fromEntries(
|
||||
enabledLayers.map(([layer, { opacity }]) => [layer, opacity])
|
||||
)
|
||||
),
|
||||
[styleId, layerIds]
|
||||
);
|
||||
|
||||
useEffect(() => onStyleChange(), [styleId, layerIds, cadastreEnabled]);
|
||||
|
||||
return { style, layers, setStyle, setLayerEnabled, setLayerOpacity };
|
||||
}
|
||||
|
||||
function MapStyleControl({
|
||||
style,
|
||||
export function StyleControl({
|
||||
styleId,
|
||||
layers,
|
||||
setStyle,
|
||||
setLayerEnabled,
|
||||
setLayerOpacity
|
||||
}: {
|
||||
styleId: string;
|
||||
setStyle: (style: string) => void;
|
||||
layers: LayersMap;
|
||||
setLayerEnabled: (layer: string, enabled: boolean) => void;
|
||||
setLayerOpacity: (layer: string, opacity: number) => void;
|
||||
}) {
|
||||
const [buttonElement, setButtonElement] = useState();
|
||||
const [panelElement, setPanelElement] = useState();
|
||||
const [buttonElement, setButtonElement] =
|
||||
useState<HTMLButtonElement | null>();
|
||||
const [panelElement, setPanelElement] = useState<HTMLDivElement | null>();
|
||||
const { styles, attributes } = usePopper(buttonElement, panelElement, {
|
||||
placement: 'bottom-end'
|
||||
});
|
||||
|
@ -86,7 +39,10 @@ function MapStyleControl({
|
|||
const mapId = useId();
|
||||
|
||||
return (
|
||||
<div className="form map-style-control mapboxgl-ctrl-group">
|
||||
<div
|
||||
className="form map-style-control mapboxgl-ctrl-group"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<Popover>
|
||||
<Popover.Button
|
||||
ref={setButtonElement}
|
||||
|
@ -102,7 +58,7 @@ function MapStyleControl({
|
|||
{...attributes.popper}
|
||||
>
|
||||
<RadioGroup
|
||||
value={style}
|
||||
value={styleId}
|
||||
onChange={setStyle}
|
||||
className="styles-list"
|
||||
as="ul"
|
||||
|
@ -175,13 +131,3 @@ function MapStyleControl({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MapStyleControl.propTypes = {
|
||||
style: PropTypes.string,
|
||||
layers: PropTypes.object,
|
||||
setStyle: PropTypes.func,
|
||||
setLayerEnabled: PropTypes.func,
|
||||
setLayerOpacity: PropTypes.func
|
||||
};
|
||||
|
||||
export default MapStyleControl;
|
110
app/javascript/components/shared/maplibre/hooks.ts
Normal file
110
app/javascript/components/shared/maplibre/hooks.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import type {
|
||||
LngLatBoundsLike,
|
||||
LngLat,
|
||||
MapLayerEventType,
|
||||
Style
|
||||
} from 'maplibre-gl';
|
||||
import type { Feature, Geometry } from 'geojson';
|
||||
|
||||
import { getMapStyle, getLayerName, LayersMap } from './styles';
|
||||
import { useMapLibre } from './MapLibre';
|
||||
|
||||
export function useFitBounds() {
|
||||
const map = useMapLibre();
|
||||
return useCallback((bbox: LngLatBoundsLike) => {
|
||||
map.fitBounds(bbox, { padding: 100 });
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useFlyTo() {
|
||||
const map = useMapLibre();
|
||||
return useCallback((zoom: number, center: [number, number]) => {
|
||||
map.flyTo({ zoom, center });
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useEvent(eventName: string, callback: EventListener) {
|
||||
return useEffect(() => {
|
||||
addEventListener(eventName, callback);
|
||||
return () => removeEventListener(eventName, callback);
|
||||
}, [eventName, callback]);
|
||||
}
|
||||
|
||||
export type EventHandler = (event: {
|
||||
features: Feature<Geometry>[];
|
||||
lngLat: LngLat;
|
||||
}) => void;
|
||||
|
||||
export function useMapEvent(
|
||||
eventName: string,
|
||||
callback: EventHandler,
|
||||
target?: string
|
||||
) {
|
||||
const map = useMapLibre();
|
||||
return useEffect(() => {
|
||||
if (target) {
|
||||
map.on(eventName as keyof MapLayerEventType, target, callback as any);
|
||||
} else {
|
||||
map.on(eventName, callback);
|
||||
}
|
||||
return () => {
|
||||
if (target) {
|
||||
map.off(eventName as keyof MapLayerEventType, target, callback as any);
|
||||
} else {
|
||||
map.off(eventName, callback);
|
||||
}
|
||||
};
|
||||
}, [map, eventName, target, callback]);
|
||||
}
|
||||
|
||||
function optionalLayersMap(optionalLayers: string[]): LayersMap {
|
||||
return Object.fromEntries(
|
||||
optionalLayers.map((layer) => [
|
||||
layer,
|
||||
{
|
||||
configurable: layer != 'cadastres',
|
||||
enabled: true,
|
||||
opacity: 70,
|
||||
name: getLayerName(layer)
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function useStyle(
|
||||
optionalLayers: string[],
|
||||
onStyleChange: (style: Style) => void
|
||||
) {
|
||||
const [styleId, setStyle] = useState('ortho');
|
||||
const [layers, setLayers] = useState(() => optionalLayersMap(optionalLayers));
|
||||
const setLayerEnabled = (layer: string, enabled: boolean) =>
|
||||
setLayers((optionalLayers) => {
|
||||
optionalLayers[layer].enabled = enabled;
|
||||
return { ...optionalLayers };
|
||||
});
|
||||
const setLayerOpacity = (layer: string, opacity: number) =>
|
||||
setLayers((optionalLayers) => {
|
||||
optionalLayers[layer].opacity = opacity;
|
||||
return { ...optionalLayers };
|
||||
});
|
||||
const enabledLayers = useMemo(
|
||||
() => Object.entries(layers).filter(([, { enabled }]) => enabled),
|
||||
[layers]
|
||||
);
|
||||
const style = useMemo(
|
||||
() =>
|
||||
getMapStyle(
|
||||
styleId,
|
||||
enabledLayers.map(([layer]) => layer),
|
||||
Object.fromEntries(
|
||||
enabledLayers.map(([layer, { opacity }]) => [layer, opacity])
|
||||
)
|
||||
),
|
||||
[styleId, enabledLayers]
|
||||
);
|
||||
|
||||
useEffect(() => onStyleChange(style), [style]);
|
||||
|
||||
return { style, layers, setStyle, setLayerEnabled, setLayerOpacity };
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
import type { AnyLayer, Style, RasterLayer, RasterSource } from 'maplibre-gl';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import cadastreLayers from './layers/cadastre';
|
||||
|
||||
const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk';
|
||||
const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk'; // ggignore
|
||||
|
||||
function ignServiceURL(layer, format = 'image/png') {
|
||||
function ignServiceURL(layer: string, format = 'image/png') {
|
||||
const url = `https://wxs.ign.fr/${IGN_TOKEN}/geoportail/wmts`;
|
||||
const query =
|
||||
'service=WMTS&request=GetTile&version=1.0.0&tilematrixset=PM&tilematrix={z}&tilecol={x}&tilerow={y}&style=normal';
|
||||
|
@ -10,7 +13,7 @@ function ignServiceURL(layer, format = 'image/png') {
|
|||
return `${url}?${query}&layer=${layer}&format=${format}`;
|
||||
}
|
||||
|
||||
const OPTIONAL_LAYERS = [
|
||||
const OPTIONAL_LAYERS: { label: string; id: string; layers: string[][] }[] = [
|
||||
{
|
||||
label: 'UNESCO',
|
||||
id: 'unesco',
|
||||
|
@ -127,7 +130,7 @@ function buildSources() {
|
|||
);
|
||||
}
|
||||
|
||||
function rasterSource(tiles, attribution) {
|
||||
function rasterSource(tiles: string[], attribution: string): RasterSource {
|
||||
return {
|
||||
type: 'raster',
|
||||
tiles,
|
||||
|
@ -138,7 +141,7 @@ function rasterSource(tiles, attribution) {
|
|||
};
|
||||
}
|
||||
|
||||
function rasterLayer(source, opacity) {
|
||||
function rasterLayer(source: string, opacity: number): RasterLayer {
|
||||
return {
|
||||
id: source,
|
||||
source,
|
||||
|
@ -147,10 +150,13 @@ function rasterLayer(source, opacity) {
|
|||
};
|
||||
}
|
||||
|
||||
export function buildOptionalLayers(ids, opacity) {
|
||||
export function buildOptionalLayers(
|
||||
ids: string[],
|
||||
opacity: Record<string, number>
|
||||
): AnyLayer[] {
|
||||
return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id))
|
||||
.flatMap(({ layers, id }) =>
|
||||
layers.map(([, code]) => [code, opacity[id] / 100])
|
||||
layers.map(([, code]) => [code, opacity[id] / 100] as const)
|
||||
)
|
||||
.flatMap(([code, opacity]) =>
|
||||
code === 'CADASTRE'
|
||||
|
@ -159,16 +165,15 @@ export function buildOptionalLayers(ids, opacity) {
|
|||
);
|
||||
}
|
||||
|
||||
export const NBS = ' ';
|
||||
export const NBS = ' ' as const;
|
||||
|
||||
export function getLayerName(layer) {
|
||||
return OPTIONAL_LAYERS.find(({ id }) => id == layer).label.replace(
|
||||
/\s/g,
|
||||
NBS
|
||||
);
|
||||
export function getLayerName(layer: string): string {
|
||||
const name = OPTIONAL_LAYERS.find(({ id }) => id == layer);
|
||||
invariant(name, `Layer "${layer}" not found`);
|
||||
return name.label.replace(/\s/g, NBS);
|
||||
}
|
||||
|
||||
function getLayerCode(code) {
|
||||
function getLayerCode(code: string) {
|
||||
return code.toLowerCase().replace(/\./g, '-');
|
||||
}
|
||||
|
||||
|
@ -220,4 +225,4 @@ export default {
|
|||
},
|
||||
sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite',
|
||||
glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf'
|
||||
};
|
||||
} as Style;
|
|
@ -1,3 +1,5 @@
|
|||
import type { Style } from 'maplibre-gl';
|
||||
|
||||
import baseStyle, { buildOptionalLayers, getLayerName, NBS } from './base';
|
||||
import orthoStyle from './layers/ortho';
|
||||
import vectorStyle from './layers/vector';
|
||||
|
@ -5,7 +7,21 @@ import ignLayers from './layers/ign';
|
|||
|
||||
export { getLayerName, NBS };
|
||||
|
||||
export function getMapStyle(id, layers, opacity) {
|
||||
export type LayersMap = Record<
|
||||
string,
|
||||
{
|
||||
configurable: boolean;
|
||||
enabled: boolean;
|
||||
opacity: number;
|
||||
name: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function getMapStyle(
|
||||
id: string,
|
||||
layers: string[],
|
||||
opacity: Record<string, number>
|
||||
): Style & { id: string } {
|
||||
const style = { ...baseStyle, id };
|
||||
|
||||
switch (id) {
|
||||
|
@ -23,7 +39,7 @@ export function getMapStyle(id, layers, opacity) {
|
|||
break;
|
||||
}
|
||||
|
||||
style.layers = style.layers.concat(buildOptionalLayers(layers, opacity));
|
||||
style.layers = style.layers?.concat(buildOptionalLayers(layers, opacity));
|
||||
|
||||
return style;
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
export default [
|
||||
import { AnyLayer } from 'maplibre-gl';
|
||||
|
||||
const layers: AnyLayer[] = [
|
||||
{
|
||||
id: 'batiments-line',
|
||||
type: 'line',
|
||||
|
@ -104,3 +106,5 @@ export default [
|
|||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default layers;
|
|
@ -1,4 +1,6 @@
|
|||
export default [
|
||||
import type { RasterLayer } from 'maplibre-gl';
|
||||
|
||||
const layers: RasterLayer[] = [
|
||||
{
|
||||
id: 'ign',
|
||||
source: 'plan-ign',
|
||||
|
@ -6,3 +8,5 @@ export default [
|
|||
paint: { 'raster-resampling': 'linear' }
|
||||
}
|
||||
];
|
||||
|
||||
export default layers;
|
|
@ -1,4 +1,6 @@
|
|||
export default [
|
||||
import type { AnyLayer } from 'maplibre-gl';
|
||||
|
||||
const layers: AnyLayer[] = [
|
||||
{
|
||||
id: 'photographies-aeriennes',
|
||||
type: 'raster',
|
||||
|
@ -2129,7 +2131,7 @@ export default [
|
|||
[10, 'point'],
|
||||
[11, 'line']
|
||||
]
|
||||
},
|
||||
} as any,
|
||||
'symbol-spacing': 200,
|
||||
'text-field': '{ref}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
|
@ -2160,7 +2162,7 @@ export default [
|
|||
[10, 'point'],
|
||||
[11, 'line']
|
||||
]
|
||||
},
|
||||
} as any,
|
||||
'symbol-spacing': 200,
|
||||
'text-field': '{ref}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
|
@ -2262,7 +2264,7 @@ export default [
|
|||
'text-letter-spacing': 0,
|
||||
'icon-padding': 2,
|
||||
'symbol-placement': 'point',
|
||||
'symbol-z-order': 'auto',
|
||||
'symbol-z-order': 'auto' as any,
|
||||
'text-line-height': 1.2,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
|
@ -2637,3 +2639,5 @@ export default [
|
|||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default layers;
|
|
@ -1,4 +1,6 @@
|
|||
export default [
|
||||
import type { AnyLayer } from 'maplibre-gl';
|
||||
|
||||
const layers: AnyLayer[] = [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
|
@ -113,7 +115,7 @@ export default [
|
|||
[0, false],
|
||||
[9, true]
|
||||
]
|
||||
},
|
||||
} as any,
|
||||
'fill-color': '#6a4',
|
||||
'fill-opacity': 0.1,
|
||||
'fill-outline-color': 'hsla(0, 0%, 0%, 0.03)'
|
||||
|
@ -324,7 +326,7 @@ export default [
|
|||
[6, [2, 0]],
|
||||
[8, [0, 0]]
|
||||
]
|
||||
}
|
||||
} as any
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -427,7 +429,7 @@ export default [
|
|||
[14, [0, 0]],
|
||||
[16, [-2, -2]]
|
||||
]
|
||||
}
|
||||
} as any
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -2322,7 +2324,7 @@ export default [
|
|||
[10, 'point'],
|
||||
[11, 'line']
|
||||
]
|
||||
},
|
||||
} as any,
|
||||
'symbol-spacing': 200,
|
||||
'text-field': '{ref}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
|
@ -2354,7 +2356,7 @@ export default [
|
|||
[7, 'line'],
|
||||
[8, 'line']
|
||||
]
|
||||
},
|
||||
} as any,
|
||||
'symbol-spacing': 200,
|
||||
'text-field': '{ref}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
|
@ -2385,7 +2387,7 @@ export default [
|
|||
[10, 'point'],
|
||||
[11, 'line']
|
||||
]
|
||||
},
|
||||
} as any,
|
||||
'symbol-spacing': 200,
|
||||
'text-field': '{ref}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
|
@ -2837,3 +2839,5 @@ export default [
|
|||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default layers;
|
93
app/javascript/components/shared/maplibre/utils.ts
Normal file
93
app/javascript/components/shared/maplibre/utils.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
LngLatBounds,
|
||||
LngLat,
|
||||
LngLatLike,
|
||||
LngLatBoundsLike
|
||||
} from 'maplibre-gl';
|
||||
import type { Geometry, FeatureCollection, Feature } from 'geojson';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
export function getBounds(geometry: Geometry): LngLatBoundsLike {
|
||||
const bbox = new LngLatBounds();
|
||||
|
||||
if (geometry.type === 'Point') {
|
||||
return [geometry.coordinates, geometry.coordinates] as [
|
||||
[number, number],
|
||||
[number, number]
|
||||
];
|
||||
} else if (geometry.type === 'LineString') {
|
||||
for (const coordinate of geometry.coordinates) {
|
||||
bbox.extend(coordinate as [number, number]);
|
||||
}
|
||||
} else {
|
||||
invariant(
|
||||
geometry.type != 'GeometryCollection',
|
||||
'GeometryCollection not supported'
|
||||
);
|
||||
for (const coordinate of geometry.coordinates[0]) {
|
||||
bbox.extend(coordinate as [number, number]);
|
||||
}
|
||||
}
|
||||
return bbox;
|
||||
}
|
||||
|
||||
export function findFeature<G extends Geometry>(
|
||||
featureCollection: FeatureCollection<G>,
|
||||
value: unknown,
|
||||
property = 'id'
|
||||
): Feature<G> | null {
|
||||
return (
|
||||
featureCollection.features.find(
|
||||
(feature) => feature.properties && feature.properties[property] === value
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function filterFeatureCollection<G extends Geometry>(
|
||||
featureCollection: FeatureCollection<G>,
|
||||
source: string
|
||||
): FeatureCollection<G> {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: featureCollection.features.filter(
|
||||
(feature) => feature.properties?.source === source
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function filterFeatureCollectionByGeometryType<G extends Geometry>(
|
||||
featureCollection: FeatureCollection<G>,
|
||||
type: Geometry['type']
|
||||
): FeatureCollection<G> {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: featureCollection.features.filter(
|
||||
(feature) => feature.geometry.type === type
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(20).substring(2, 6);
|
||||
}
|
||||
|
||||
export function getCenter(geometry: Geometry, lngLat: LngLat): LngLatLike {
|
||||
const bbox = new LngLatBounds();
|
||||
|
||||
invariant(
|
||||
geometry.type != 'GeometryCollection',
|
||||
'GeometryCollection not supported'
|
||||
);
|
||||
|
||||
switch (geometry.type) {
|
||||
case 'Point':
|
||||
return [...geometry.coordinates] as [number, number];
|
||||
case 'LineString':
|
||||
return [lngLat.lng, lngLat.lat];
|
||||
default:
|
||||
for (const coordinate of geometry.coordinates[0]) {
|
||||
bbox.extend(coordinate as [number, number]);
|
||||
}
|
||||
return bbox.getCenter();
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { QueryClient } from 'react-query';
|
||||
import { QueryClient, QueryFunction } from 'react-query';
|
||||
import { getJSON, isNumeric } from '@utils';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
|
||||
|
@ -16,17 +16,15 @@ const API_ADRESSE_QUERY_LIMIT = 5;
|
|||
const API_GEO_COMMUNES_QUERY_LIMIT = 60;
|
||||
|
||||
const { api_geo_url, api_adresse_url, api_education_url } =
|
||||
gon.autocomplete || {};
|
||||
(window as any).gon.autocomplete || {};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: defaultQueryFn
|
||||
}
|
||||
}
|
||||
});
|
||||
type QueryKey = readonly [
|
||||
scope: string,
|
||||
term: string,
|
||||
extra: string | undefined
|
||||
];
|
||||
|
||||
function buildURL(scope, term, extra) {
|
||||
function buildURL(scope: string, term: string, extra?: string) {
|
||||
term = encodeURIComponent(term.replace(/\(|\)/g, ''));
|
||||
if (scope === 'adresse') {
|
||||
return `${api_adresse_url}/search?q=${term}&limit=${API_ADRESSE_QUERY_LIMIT}`;
|
||||
|
@ -48,7 +46,7 @@ function buildURL(scope, term, extra) {
|
|||
return `${api_geo_url}/${scope}?nom=${term}&limit=${API_GEO_QUERY_LIMIT}`;
|
||||
}
|
||||
|
||||
function buildOptions() {
|
||||
function buildOptions(): [RequestInit, AbortController | null] {
|
||||
if (window.AbortController) {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
@ -57,7 +55,9 @@ function buildOptions() {
|
|||
return [{}, null];
|
||||
}
|
||||
|
||||
async function defaultQueryFn({ queryKey: [scope, term, extra] }) {
|
||||
const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
|
||||
queryKey: [scope, term, extra]
|
||||
}) => {
|
||||
if (scope == 'pays') {
|
||||
return matchSorter(await getPays(), term, { keys: ['label'] });
|
||||
}
|
||||
|
@ -70,14 +70,22 @@ async function defaultQueryFn({ queryKey: [scope, term, extra] }) {
|
|||
}
|
||||
throw new Error(`Error fetching from "${scope}" API`);
|
||||
});
|
||||
promise.cancel = () => controller && controller.abort();
|
||||
(promise as any).cancel = () => controller && controller.abort();
|
||||
return promise;
|
||||
}
|
||||
};
|
||||
|
||||
let paysCache;
|
||||
async function getPays() {
|
||||
let paysCache: { label: string }[];
|
||||
async function getPays(): Promise<{ label: string }[]> {
|
||||
if (!paysCache) {
|
||||
paysCache = await getJSON('/api/pays', null);
|
||||
}
|
||||
return paysCache;
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: defaultQueryFn as any
|
||||
}
|
||||
}
|
||||
});
|
4
app/javascript/types.d.ts
vendored
4
app/javascript/types.d.ts
vendored
|
@ -1,4 +1,6 @@
|
|||
declare module '@tmcw/togeojson' {
|
||||
// This file contains type definitions for untyped packages. We are lucky to have only two ;)
|
||||
|
||||
declare module '@tmcw/togeojson/dist/togeojson.es.js' {
|
||||
import { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
|
||||
|
||||
export function kml(doc: Document): FeatureCollection;
|
||||
|
|
|
@ -24,7 +24,7 @@ if (!Array.isArray(nodeModulesLoader.exclude)) {
|
|||
nodeModulesLoader.exclude == null ? [] : [nodeModulesLoader.exclude];
|
||||
}
|
||||
nodeModulesLoader.exclude.push(
|
||||
path.resolve(__dirname, '..', '..', 'node_modules/mapbox-gl')
|
||||
path.resolve(__dirname, '..', '..', 'node_modules/maplibre-gl')
|
||||
);
|
||||
|
||||
// Uncoment next lines to run webpack-bundle-analyzer
|
||||
|
|
10
package.json
10
package.json
|
@ -4,7 +4,7 @@
|
|||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@heroicons/react": "^1.0.1",
|
||||
"@mapbox/mapbox-gl-draw": "^1.2.2",
|
||||
"@mapbox/mapbox-gl-draw": "^1.3.0",
|
||||
"@popperjs/core": "^2.9.2",
|
||||
"@rails/actiontext": "^6.1.4-1",
|
||||
"@rails/activestorage": "^6.1.4-1",
|
||||
|
@ -13,7 +13,6 @@
|
|||
"@reach/auto-id": "^0.16.0",
|
||||
"@reach/combobox": "^0.13.0",
|
||||
"@reach/slider": "^0.15.0",
|
||||
"@reach/visually-hidden": "^0.15.2",
|
||||
"@sentry/browser": "6.12.0",
|
||||
"@tmcw/togeojson": "^4.3.0",
|
||||
"babel-plugin-macros": "^2.8.0",
|
||||
|
@ -23,18 +22,17 @@
|
|||
"debounce": "^1.2.1",
|
||||
"dom4": "^2.1.6",
|
||||
"email-butler": "^1.0.13",
|
||||
"geojson": "^0.5.0",
|
||||
"highcharts": "^9.0.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"is-hotkey": "^0.2.0",
|
||||
"mapbox-gl": "^1.3.0",
|
||||
"maplibre-gl": "^1.15.2",
|
||||
"match-sorter": "^6.2.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.1",
|
||||
"react-coordinate-input": "^1.0.0-rc.2",
|
||||
"react-coordinate-input": "^1.0.0",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-mapbox-gl": "^5.1.1",
|
||||
"react-mapbox-gl-draw": "^2.0.4",
|
||||
"react-popper": "^2.2.5",
|
||||
"react-query": "^3.9.7",
|
||||
"react-sortable-hoc": "^1.11.0",
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2019"],
|
||||
"module": "es6",
|
||||
|
@ -12,6 +11,7 @@
|
|||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"~/*": ["./app/javascript/*"],
|
||||
"@utils": ["./app/javascript/shared/utils.ts"]
|
||||
|
|
Loading…
Add table
Reference in a new issue