refactor(carto): use maplibre instead of mapbox

This commit is contained in:
Paul Chavard 2022-02-08 12:49:51 +01:00
parent ea6aec8b1a
commit 1f661325a5
39 changed files with 1753 additions and 1412 deletions

View file

@ -41,7 +41,7 @@
} }
.map-style-panel { .map-style-panel {
z-index: 99; z-index: 1;
padding: $default-spacer; padding: $default-spacer;
margin-bottom: $default-spacer; margin-bottom: $default-spacer;
@ -60,6 +60,7 @@
} }
.cadastres-selection-control { .cadastres-selection-control {
z-index: 1;
position: absolute; position: absolute;
top: 135px; top: 135px;
left: 10px; left: 10px;

View file

@ -58,7 +58,7 @@
// Labels that we only want for screen readers // Labels that we only want for screen readers
// https://www.coolfields.co.uk/2016/05/text-for-screen-readers-only-updated/ // https://www.coolfields.co.uk/2016/05/text-for-screen-readers-only-updated/
.screen-reader-text { .sr-only {
border: none; border: none;
clip: rect(1px, 1px, 1px, 1px); clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%); clip-path: inset(50%);

View file

@ -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;

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

View file

@ -1,7 +1,6 @@
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import PropTypes from 'prop-types';
import { import {
Combobox, Combobox,
ComboboxInput, ComboboxInput,
@ -14,11 +13,33 @@ import invariant from 'tiny-invariant';
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks'; import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
function defaultTransformResults(_, results) { type TransformResults<Result> = (term: string, results: unknown) => Result[];
return results; 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, onChange,
value: controlledValue, value: controlledValue,
scope, scope,
@ -26,26 +47,28 @@ function ComboSearch({
minimumInputLength, minimumInputLength,
transformResult, transformResult,
allowInputValues = false, allowInputValues = false,
transformResults = defaultTransformResults, transformResults = (_, results) => results as Result[],
id, id,
describedby, describedby,
...props ...props
}) { }: ComboSearchProps<Result>) {
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required'); 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 [externalValue, setExternalValue, hiddenField] = useHiddenField(group);
const [, setExternalId] = useHiddenField(group, 'external_id'); const [, setExternalId] = useHiddenField(group, 'external_id');
const initialValue = externalValue ? externalValue : controlledValue; const initialValue = externalValue ? externalValue : controlledValue;
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebounce(searchTerm, 300); const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const resultsMap = useRef({}); const resultsMap = useRef<
const getLabel = (result) => { Record<string, { key: string; value: string; result: Result }>
>({});
const getLabel = (result: Result) => {
const [, value, label] = transformResult(result); const [, value, label] = transformResult(result);
return label ?? value; return label ?? value;
}; };
const setExternalValueAndId = useCallback((label) => { const setExternalValueAndId = useCallback((label: string) => {
const { key, value, result } = resultsMap.current[label]; const { key, value, result } = resultsMap.current[label];
if (onChange) { if (onChange) {
onChange(value, result); onChange(value, result);
@ -77,22 +100,22 @@ function ComboSearch({
[minimumInputLength] [minimumInputLength]
); );
const handleOnSelect = useCallback((value) => { const handleOnSelect = useCallback((value: string) => {
setExternalValueAndId(value); setExternalValueAndId(value);
setValue(value); setValue(value);
setSearchTerm(''); setSearchTerm('');
awaitFormSubmit.done(); awaitFormSubmit.done();
}, []); }, []);
const { isSuccess, data } = useQuery( const { isSuccess, data } = useQuery<void, void, unknown, QueryKey>(
[scope, debouncedSearchTerm, scopeExtra], [scope, debouncedSearchTerm, scopeExtra],
{ {
enabled: !!debouncedSearchTerm, enabled: !!debouncedSearchTerm,
notifyOnStatusChange: false,
refetchOnMount: false refetchOnMount: false
} }
); );
const results = isSuccess ? transformResults(debouncedSearchTerm, data) : []; const results =
isSuccess && data ? transformResults(debouncedSearchTerm, data) : [];
const onBlur = useCallback(() => { const onBlur = useCallback(() => {
if (!allowInputValues && isSuccess && results[0]) { 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; export default ComboSearch;

View file

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

View 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');
}

View 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']
];

View file

@ -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
};
}

View file

@ -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>
</>
);
}

View 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é nest 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é nest 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 na 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];
}

View file

@ -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&apos;aide ?&nbsp;
<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;

View 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&apos;aide ?&nbsp;
<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>
</>
);
}

View file

@ -1,14 +1,18 @@
import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js'; import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js';
import { generateId } from '../shared/mapbox/utils'; 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 isGpxFile = file.name.includes('.gpx');
const reader = new FileReader(); const reader = new FileReader();
return new Promise((resolve) => { return new Promise<ReturnType<typeof normalizeFeatureCollection>>(
reader.onload = (event) => { (resolve) => {
reader.onload = (event: FileReaderEventMap['load']) => {
const result = event.target?.result;
const xml = new DOMParser().parseFromString( const xml = new DOMParser().parseFromString(
event.target.result, result as string,
'text/xml' 'text/xml'
); );
const featureCollection = normalizeFeatureCollection( const featureCollection = normalizeFeatureCollection(
@ -19,11 +23,15 @@ export function readGeoFile(file) {
resolve(featureCollection); resolve(featureCollection);
}; };
reader.readAsText(file, 'UTF-8'); reader.readAsText(file, 'UTF-8');
}); }
);
} }
function normalizeFeatureCollection(featureCollection, filename) { function normalizeFeatureCollection(
const features = []; featureCollection: FeatureCollection,
filename: string
) {
const features: Feature[] = [];
for (const feature of featureCollection.features) { for (const feature of featureCollection.features) {
switch (feature.geometry.type) { switch (feature.geometry.type) {
case 'MultiPoint': case 'MultiPoint':
@ -76,13 +84,13 @@ function normalizeFeatureCollection(featureCollection, filename) {
} }
} }
featureCollection.filename = `${generateId()}-${filename}`; const featureCollectionFilename = `${generateId()}-${filename}`;
featureCollection.features = features.map((feature) => ({ featureCollection.features = features.map((feature) => ({
...feature, ...feature,
properties: { properties: {
...feature.properties, ...feature.properties,
filename: featureCollection.filename filename: featureCollectionFilename
} }
})); }));
return featureCollection; return { ...featureCollection, filename: featureCollectionFilename };
} }

View file

@ -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é nest 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é nest 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 na 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]);
}

View file

@ -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;
}

View 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'
};

View file

@ -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;

View 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;

View file

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

View file

@ -82,7 +82,7 @@ const TypeDeChamp = sortableElement(
}} }}
> >
<TrashIcon className="icon-size" /> <TrashIcon className="icon-size" />
<span className="screen-reader-text">Supprimer</span> <span className="sr-only">Supprimer</span>
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,17 +1,26 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; 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( return createPortal(
<div className="flash_message center"> <div className="flash_message center">
<div className={flashClassName(level, sticky, fixed)}>{message}</div> <div className={flashClassName(level, sticky, fixed)}>{message}</div>
</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 = const className =
level == 'notice' ? ['alert', 'alert-success'] : ['alert', 'alert-danger']; level == 'notice' ? ['alert', 'alert-success'] : ['alert', 'alert-danger'];
@ -23,10 +32,3 @@ function flashClassName(level, sticky = false, fixed = false) {
} }
return className.join(' '); return className.join(' ');
} }
FlashMessage.propTypes = {
message: PropTypes.string,
level: PropTypes.string,
sticky: PropTypes.bool,
fixed: PropTypes.bool
};

View file

@ -1,15 +1,18 @@
import { useRef, useCallback, useMemo, useState } from 'react'; import { useRef, useCallback, useMemo, useState } from 'react';
import { fire } from '@utils'; import { fire } from '@utils';
export function useDeferredSubmit(input) { export function useDeferredSubmit(input?: HTMLInputElement): {
(callback: () => void): void;
done: () => void;
} {
const calledRef = useRef(false); const calledRef = useRef(false);
const awaitFormSubmit = useCallback( const awaitFormSubmit = useCallback(
(callback) => { (callback: () => void) => {
const form = input?.form; const form = input?.form;
if (!form) { if (!form) {
return; return;
} }
const interceptFormSubmit = (event) => { const interceptFormSubmit = (event: Event) => {
event.preventDefault(); event.preventDefault();
runCallback(); runCallback();
form.submit(); form.submit();
@ -27,17 +30,24 @@ export function useDeferredSubmit(input) {
}, },
[input] [input]
); );
awaitFormSubmit.done = () => { const done = () => {
calledRef.current = true; calledRef.current = true;
}; };
return awaitFormSubmit; return Object.assign(awaitFormSubmit, { done });
} }
export function groupId(id) { export function groupId(id: string) {
return `#champ-${id.replace(/-input$/, '')}`; 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( const hiddenField = useMemo(
() => selectInputInGroup(group, name), () => selectInputInGroup(group, name),
[group, name] [group, name]
@ -53,13 +63,16 @@ export function useHiddenField(group, name = 'value') {
fire(hiddenField, 'autosave:trigger'); fire(hiddenField, 'autosave:trigger');
} }
}, },
hiddenField hiddenField ?? undefined
]; ];
} }
function selectInputInGroup(group, name) { function selectInputInGroup(
group: string | undefined,
name: string
): HTMLInputElement | undefined | null {
if (group) { if (group) {
return document.querySelector( return document.querySelector<HTMLInputElement>(
`${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]` `${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]`
); );
} }

View file

@ -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;
}

View 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
dutiliser{' '}
<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;
}

View file

@ -1,5 +1,4 @@
import React, { useMemo, useState, useEffect } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Popover, RadioGroup } from '@headlessui/react'; import { Popover, RadioGroup } from '@headlessui/react';
import { usePopper } from 'react-popper'; import { usePopper } from 'react-popper';
import { MapIcon } from '@heroicons/react/outline'; import { MapIcon } from '@heroicons/react/outline';
@ -7,7 +6,7 @@ import { Slider } from '@reach/slider';
import { useId } from '@reach/auto-id'; import { useId } from '@reach/auto-id';
import '@reach/slider/styles.css'; import '@reach/slider/styles.css';
import { getMapStyle, getLayerName, NBS } from './styles'; import { LayersMap, NBS } from './styles';
const STYLES = { const STYLES = {
ortho: 'Satellite', ortho: 'Satellite',
@ -15,68 +14,22 @@ const STYLES = {
ign: 'Carte IGN' ign: 'Carte IGN'
}; };
function optionalLayersMap(optionalLayers) { export function StyleControl({
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, 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,
layers, layers,
setStyle, setStyle,
setLayerEnabled, setLayerEnabled,
setLayerOpacity 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 [buttonElement, setButtonElement] =
const [panelElement, setPanelElement] = useState(); useState<HTMLButtonElement | null>();
const [panelElement, setPanelElement] = useState<HTMLDivElement | null>();
const { styles, attributes } = usePopper(buttonElement, panelElement, { const { styles, attributes } = usePopper(buttonElement, panelElement, {
placement: 'bottom-end' placement: 'bottom-end'
}); });
@ -86,7 +39,10 @@ function MapStyleControl({
const mapId = useId(); const mapId = useId();
return ( return (
<div className="form map-style-control mapboxgl-ctrl-group"> <div
className="form map-style-control mapboxgl-ctrl-group"
style={{ zIndex: 10 }}
>
<Popover> <Popover>
<Popover.Button <Popover.Button
ref={setButtonElement} ref={setButtonElement}
@ -102,7 +58,7 @@ function MapStyleControl({
{...attributes.popper} {...attributes.popper}
> >
<RadioGroup <RadioGroup
value={style} value={styleId}
onChange={setStyle} onChange={setStyle}
className="styles-list" className="styles-list"
as="ul" as="ul"
@ -175,13 +131,3 @@ function MapStyleControl({
</div> </div>
); );
} }
MapStyleControl.propTypes = {
style: PropTypes.string,
layers: PropTypes.object,
setStyle: PropTypes.func,
setLayerEnabled: PropTypes.func,
setLayerOpacity: PropTypes.func
};
export default MapStyleControl;

View 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 };
}

View file

@ -1,8 +1,11 @@
import type { AnyLayer, Style, RasterLayer, RasterSource } from 'maplibre-gl';
import invariant from 'tiny-invariant';
import cadastreLayers from './layers/cadastre'; 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 url = `https://wxs.ign.fr/${IGN_TOKEN}/geoportail/wmts`;
const query = const query =
'service=WMTS&request=GetTile&version=1.0.0&tilematrixset=PM&tilematrix={z}&tilecol={x}&tilerow={y}&style=normal'; '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}`; return `${url}?${query}&layer=${layer}&format=${format}`;
} }
const OPTIONAL_LAYERS = [ const OPTIONAL_LAYERS: { label: string; id: string; layers: string[][] }[] = [
{ {
label: 'UNESCO', label: 'UNESCO',
id: 'unesco', id: 'unesco',
@ -127,7 +130,7 @@ function buildSources() {
); );
} }
function rasterSource(tiles, attribution) { function rasterSource(tiles: string[], attribution: string): RasterSource {
return { return {
type: 'raster', type: 'raster',
tiles, tiles,
@ -138,7 +141,7 @@ function rasterSource(tiles, attribution) {
}; };
} }
function rasterLayer(source, opacity) { function rasterLayer(source: string, opacity: number): RasterLayer {
return { return {
id: source, id: source,
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)) return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id))
.flatMap(({ layers, id }) => .flatMap(({ layers, id }) =>
layers.map(([, code]) => [code, opacity[id] / 100]) layers.map(([, code]) => [code, opacity[id] / 100] as const)
) )
.flatMap(([code, opacity]) => .flatMap(([code, opacity]) =>
code === 'CADASTRE' code === 'CADASTRE'
@ -159,16 +165,15 @@ export function buildOptionalLayers(ids, opacity) {
); );
} }
export const NBS = ' '; export const NBS = ' ' as const;
export function getLayerName(layer) { export function getLayerName(layer: string): string {
return OPTIONAL_LAYERS.find(({ id }) => id == layer).label.replace( const name = OPTIONAL_LAYERS.find(({ id }) => id == layer);
/\s/g, invariant(name, `Layer "${layer}" not found`);
NBS return name.label.replace(/\s/g, NBS);
);
} }
function getLayerCode(code) { function getLayerCode(code: string) {
return code.toLowerCase().replace(/\./g, '-'); return code.toLowerCase().replace(/\./g, '-');
} }
@ -220,4 +225,4 @@ export default {
}, },
sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite', sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite',
glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf' glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf'
}; } as Style;

View file

@ -1,3 +1,5 @@
import type { Style } from 'maplibre-gl';
import baseStyle, { buildOptionalLayers, getLayerName, NBS } from './base'; import baseStyle, { buildOptionalLayers, getLayerName, NBS } from './base';
import orthoStyle from './layers/ortho'; import orthoStyle from './layers/ortho';
import vectorStyle from './layers/vector'; import vectorStyle from './layers/vector';
@ -5,7 +7,21 @@ import ignLayers from './layers/ign';
export { getLayerName, NBS }; 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 }; const style = { ...baseStyle, id };
switch (id) { switch (id) {
@ -23,7 +39,7 @@ export function getMapStyle(id, layers, opacity) {
break; break;
} }
style.layers = style.layers.concat(buildOptionalLayers(layers, opacity)); style.layers = style.layers?.concat(buildOptionalLayers(layers, opacity));
return style; return style;
} }

View file

@ -1,4 +1,6 @@
export default [ import { AnyLayer } from 'maplibre-gl';
const layers: AnyLayer[] = [
{ {
id: 'batiments-line', id: 'batiments-line',
type: 'line', type: 'line',
@ -104,3 +106,5 @@ export default [
} }
} }
]; ];
export default layers;

View file

@ -1,4 +1,6 @@
export default [ import type { RasterLayer } from 'maplibre-gl';
const layers: RasterLayer[] = [
{ {
id: 'ign', id: 'ign',
source: 'plan-ign', source: 'plan-ign',
@ -6,3 +8,5 @@ export default [
paint: { 'raster-resampling': 'linear' } paint: { 'raster-resampling': 'linear' }
} }
]; ];
export default layers;

View file

@ -1,4 +1,6 @@
export default [ import type { AnyLayer } from 'maplibre-gl';
const layers: AnyLayer[] = [
{ {
id: 'photographies-aeriennes', id: 'photographies-aeriennes',
type: 'raster', type: 'raster',
@ -2129,7 +2131,7 @@ export default [
[10, 'point'], [10, 'point'],
[11, 'line'] [11, 'line']
] ]
}, } as any,
'symbol-spacing': 200, 'symbol-spacing': 200,
'text-field': '{ref}', 'text-field': '{ref}',
'text-font': ['Noto Sans Regular'], 'text-font': ['Noto Sans Regular'],
@ -2160,7 +2162,7 @@ export default [
[10, 'point'], [10, 'point'],
[11, 'line'] [11, 'line']
] ]
}, } as any,
'symbol-spacing': 200, 'symbol-spacing': 200,
'text-field': '{ref}', 'text-field': '{ref}',
'text-font': ['Noto Sans Regular'], 'text-font': ['Noto Sans Regular'],
@ -2262,7 +2264,7 @@ export default [
'text-letter-spacing': 0, 'text-letter-spacing': 0,
'icon-padding': 2, 'icon-padding': 2,
'symbol-placement': 'point', 'symbol-placement': 'point',
'symbol-z-order': 'auto', 'symbol-z-order': 'auto' as any,
'text-line-height': 1.2, 'text-line-height': 1.2,
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,
@ -2637,3 +2639,5 @@ export default [
} }
} }
]; ];
export default layers;

View file

@ -1,4 +1,6 @@
export default [ import type { AnyLayer } from 'maplibre-gl';
const layers: AnyLayer[] = [
{ {
id: 'background', id: 'background',
type: 'background', type: 'background',
@ -113,7 +115,7 @@ export default [
[0, false], [0, false],
[9, true] [9, true]
] ]
}, } as any,
'fill-color': '#6a4', 'fill-color': '#6a4',
'fill-opacity': 0.1, 'fill-opacity': 0.1,
'fill-outline-color': 'hsla(0, 0%, 0%, 0.03)' 'fill-outline-color': 'hsla(0, 0%, 0%, 0.03)'
@ -324,7 +326,7 @@ export default [
[6, [2, 0]], [6, [2, 0]],
[8, [0, 0]] [8, [0, 0]]
] ]
} } as any
} }
}, },
{ {
@ -427,7 +429,7 @@ export default [
[14, [0, 0]], [14, [0, 0]],
[16, [-2, -2]] [16, [-2, -2]]
] ]
} } as any
} }
}, },
{ {
@ -2322,7 +2324,7 @@ export default [
[10, 'point'], [10, 'point'],
[11, 'line'] [11, 'line']
] ]
}, } as any,
'symbol-spacing': 200, 'symbol-spacing': 200,
'text-field': '{ref}', 'text-field': '{ref}',
'text-font': ['Noto Sans Regular'], 'text-font': ['Noto Sans Regular'],
@ -2354,7 +2356,7 @@ export default [
[7, 'line'], [7, 'line'],
[8, 'line'] [8, 'line']
] ]
}, } as any,
'symbol-spacing': 200, 'symbol-spacing': 200,
'text-field': '{ref}', 'text-field': '{ref}',
'text-font': ['Noto Sans Regular'], 'text-font': ['Noto Sans Regular'],
@ -2385,7 +2387,7 @@ export default [
[10, 'point'], [10, 'point'],
[11, 'line'] [11, 'line']
] ]
}, } as any,
'symbol-spacing': 200, 'symbol-spacing': 200,
'text-field': '{ref}', 'text-field': '{ref}',
'text-font': ['Noto Sans Regular'], 'text-font': ['Noto Sans Regular'],
@ -2837,3 +2839,5 @@ export default [
} }
} }
]; ];
export default layers;

View 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();
}
}

View file

@ -1,4 +1,4 @@
import { QueryClient } from 'react-query'; import { QueryClient, QueryFunction } from 'react-query';
import { getJSON, isNumeric } from '@utils'; import { getJSON, isNumeric } from '@utils';
import { matchSorter } from 'match-sorter'; 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_COMMUNES_QUERY_LIMIT = 60;
const { api_geo_url, api_adresse_url, api_education_url } = const { api_geo_url, api_adresse_url, api_education_url } =
gon.autocomplete || {}; (window as any).gon.autocomplete || {};
export const queryClient = new QueryClient({ type QueryKey = readonly [
defaultOptions: { scope: string,
queries: { term: string,
queryFn: defaultQueryFn extra: string | undefined
} ];
}
});
function buildURL(scope, term, extra) { function buildURL(scope: string, term: string, extra?: string) {
term = encodeURIComponent(term.replace(/\(|\)/g, '')); term = encodeURIComponent(term.replace(/\(|\)/g, ''));
if (scope === 'adresse') { if (scope === 'adresse') {
return `${api_adresse_url}/search?q=${term}&limit=${API_ADRESSE_QUERY_LIMIT}`; 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}`; return `${api_geo_url}/${scope}?nom=${term}&limit=${API_GEO_QUERY_LIMIT}`;
} }
function buildOptions() { function buildOptions(): [RequestInit, AbortController | null] {
if (window.AbortController) { if (window.AbortController) {
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
@ -57,7 +55,9 @@ function buildOptions() {
return [{}, null]; return [{}, null];
} }
async function defaultQueryFn({ queryKey: [scope, term, extra] }) { const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
queryKey: [scope, term, extra]
}) => {
if (scope == 'pays') { if (scope == 'pays') {
return matchSorter(await getPays(), term, { keys: ['label'] }); 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`); throw new Error(`Error fetching from "${scope}" API`);
}); });
promise.cancel = () => controller && controller.abort(); (promise as any).cancel = () => controller && controller.abort();
return promise; return promise;
} };
let paysCache; let paysCache: { label: string }[];
async function getPays() { async function getPays(): Promise<{ label: string }[]> {
if (!paysCache) { if (!paysCache) {
paysCache = await getJSON('/api/pays', null); paysCache = await getJSON('/api/pays', null);
} }
return paysCache; return paysCache;
} }
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: defaultQueryFn as any
}
}
});

View file

@ -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'; import { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
export function kml(doc: Document): FeatureCollection; export function kml(doc: Document): FeatureCollection;

View file

@ -24,7 +24,7 @@ if (!Array.isArray(nodeModulesLoader.exclude)) {
nodeModulesLoader.exclude == null ? [] : [nodeModulesLoader.exclude]; nodeModulesLoader.exclude == null ? [] : [nodeModulesLoader.exclude];
} }
nodeModulesLoader.exclude.push( 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 // Uncoment next lines to run webpack-bundle-analyzer

View file

@ -4,7 +4,7 @@
"@babel/preset-typescript": "^7.16.7", "@babel/preset-typescript": "^7.16.7",
"@headlessui/react": "^1.3.0", "@headlessui/react": "^1.3.0",
"@heroicons/react": "^1.0.1", "@heroicons/react": "^1.0.1",
"@mapbox/mapbox-gl-draw": "^1.2.2", "@mapbox/mapbox-gl-draw": "^1.3.0",
"@popperjs/core": "^2.9.2", "@popperjs/core": "^2.9.2",
"@rails/actiontext": "^6.1.4-1", "@rails/actiontext": "^6.1.4-1",
"@rails/activestorage": "^6.1.4-1", "@rails/activestorage": "^6.1.4-1",
@ -13,7 +13,6 @@
"@reach/auto-id": "^0.16.0", "@reach/auto-id": "^0.16.0",
"@reach/combobox": "^0.13.0", "@reach/combobox": "^0.13.0",
"@reach/slider": "^0.15.0", "@reach/slider": "^0.15.0",
"@reach/visually-hidden": "^0.15.2",
"@sentry/browser": "6.12.0", "@sentry/browser": "6.12.0",
"@tmcw/togeojson": "^4.3.0", "@tmcw/togeojson": "^4.3.0",
"babel-plugin-macros": "^2.8.0", "babel-plugin-macros": "^2.8.0",
@ -23,18 +22,17 @@
"debounce": "^1.2.1", "debounce": "^1.2.1",
"dom4": "^2.1.6", "dom4": "^2.1.6",
"email-butler": "^1.0.13", "email-butler": "^1.0.13",
"geojson": "^0.5.0",
"highcharts": "^9.0.0", "highcharts": "^9.0.0",
"intersection-observer": "^0.12.0", "intersection-observer": "^0.12.0",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",
"mapbox-gl": "^1.3.0", "maplibre-gl": "^1.15.2",
"match-sorter": "^6.2.0", "match-sorter": "^6.2.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^17.0.1", "react": "^17.0.1",
"react-coordinate-input": "^1.0.0-rc.2", "react-coordinate-input": "^1.0.0",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-intersection-observer": "^8.31.0", "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-popper": "^2.2.5",
"react-query": "^3.9.7", "react-query": "^3.9.7",
"react-sortable-hoc": "^1.11.0", "react-sortable-hoc": "^1.11.0",

View file

@ -1,7 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"declaration": false, "declaration": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"lib": ["DOM", "DOM.Iterable", "ES2019"], "lib": ["DOM", "DOM.Iterable", "ES2019"],
"module": "es6", "module": "es6",
@ -12,6 +11,7 @@
"noEmit": true, "noEmit": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"strict": true,
"paths": { "paths": {
"~/*": ["./app/javascript/*"], "~/*": ["./app/javascript/*"],
"@utils": ["./app/javascript/shared/utils.ts"] "@utils": ["./app/javascript/shared/utils.ts"]