From 1f661325a514c72d99765ccfb9fd3267dfd25532 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 8 Feb 2022 12:49:51 +0100 Subject: [PATCH] refactor(carto): use maplibre instead of mapbox --- app/assets/stylesheets/carte.scss | 3 +- app/assets/stylesheets/labels.scss | 2 +- .../components/ComboAdresseSearch.jsx | 34 -- .../components/ComboAdresseSearch.tsx | 31 + .../{ComboSearch.jsx => ComboSearch.tsx} | 67 ++- .../MapEditor/components/AddressInput.tsx | 27 + .../MapEditor/components/CadastreLayer.tsx | 164 ++++++ .../MapEditor/components/DrawLayer.tsx | 183 ++++++ .../MapEditor/components/ImportFileInput.tsx | 132 +++++ .../MapEditor/components/PointInput.tsx | 92 +++ app/javascript/components/MapEditor/hooks.ts | 208 +++++++ app/javascript/components/MapEditor/index.jsx | 263 --------- app/javascript/components/MapEditor/index.tsx | 91 +++ .../{readGeoFile.js => readGeoFile.ts} | 50 +- .../components/MapEditor/useMapboxEditor.js | 548 ------------------ .../MapReader/components/CadastreLayer.tsx | 32 + .../MapReader/components/GeoJSONLayer.tsx | 237 ++++++++ app/javascript/components/MapReader/index.jsx | 190 ------ app/javascript/components/MapReader/index.tsx | 24 + .../components/MapReader/useMapbox.js | 104 ---- .../components/TypeDeChamp.jsx | 2 +- .../{FlashMessage.jsx => FlashMessage.tsx} | 24 +- .../components/shared/{hooks.js => hooks.ts} | 33 +- .../components/shared/mapbox/utils.js | 72 --- .../components/shared/maplibre/MapLibre.tsx | 104 ++++ .../StyleControl.tsx} | 90 +-- .../components/shared/maplibre/hooks.ts | 110 ++++ .../base.js => maplibre/styles/base.ts} | 35 +- .../index.js => maplibre/styles/index.ts} | 20 +- .../styles/layers/cadastre.ts} | 6 +- .../ign.js => maplibre/styles/layers/ign.ts} | 6 +- .../styles/layers/ortho.ts} | 12 +- .../styles/layers/vector.ts} | 18 +- .../components/shared/maplibre/utils.ts | 93 +++ .../shared/{queryClient.js => queryClient.ts} | 40 +- app/javascript/types.d.ts | 4 +- config/webpack/environment.js | 2 +- package.json | 10 +- tsconfig.json | 2 +- 39 files changed, 1753 insertions(+), 1412 deletions(-) delete mode 100644 app/javascript/components/ComboAdresseSearch.jsx create mode 100644 app/javascript/components/ComboAdresseSearch.tsx rename app/javascript/components/{ComboSearch.jsx => ComboSearch.tsx} (72%) create mode 100644 app/javascript/components/MapEditor/components/AddressInput.tsx create mode 100644 app/javascript/components/MapEditor/components/CadastreLayer.tsx create mode 100644 app/javascript/components/MapEditor/components/DrawLayer.tsx create mode 100644 app/javascript/components/MapEditor/components/ImportFileInput.tsx create mode 100644 app/javascript/components/MapEditor/components/PointInput.tsx create mode 100644 app/javascript/components/MapEditor/hooks.ts delete mode 100644 app/javascript/components/MapEditor/index.jsx create mode 100644 app/javascript/components/MapEditor/index.tsx rename app/javascript/components/MapEditor/{readGeoFile.js => readGeoFile.ts} (62%) delete mode 100644 app/javascript/components/MapEditor/useMapboxEditor.js create mode 100644 app/javascript/components/MapReader/components/CadastreLayer.tsx create mode 100644 app/javascript/components/MapReader/components/GeoJSONLayer.tsx delete mode 100644 app/javascript/components/MapReader/index.jsx create mode 100644 app/javascript/components/MapReader/index.tsx delete mode 100644 app/javascript/components/MapReader/useMapbox.js rename app/javascript/components/shared/{FlashMessage.jsx => FlashMessage.tsx} (57%) rename app/javascript/components/shared/{hooks.js => hooks.ts} (65%) delete mode 100644 app/javascript/components/shared/mapbox/utils.js create mode 100644 app/javascript/components/shared/maplibre/MapLibre.tsx rename app/javascript/components/shared/{mapbox/MapStyleControl.jsx => maplibre/StyleControl.tsx} (64%) create mode 100644 app/javascript/components/shared/maplibre/hooks.ts rename app/javascript/components/shared/{mapbox/styles/base.js => maplibre/styles/base.ts} (85%) rename app/javascript/components/shared/{mapbox/styles/index.js => maplibre/styles/index.ts} (61%) rename app/javascript/components/shared/{mapbox/styles/layers/cadastre.js => maplibre/styles/layers/cadastre.ts} (95%) rename app/javascript/components/shared/{mapbox/styles/layers/ign.js => maplibre/styles/layers/ign.ts} (52%) rename app/javascript/components/shared/{mapbox/styles/layers/ortho.js => maplibre/styles/layers/ortho.ts} (99%) rename app/javascript/components/shared/{mapbox/styles/layers/vector.js => maplibre/styles/layers/vector.ts} (99%) create mode 100644 app/javascript/components/shared/maplibre/utils.ts rename app/javascript/components/shared/{queryClient.js => queryClient.ts} (78%) diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index 33f065583..46809bd99 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -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; diff --git a/app/assets/stylesheets/labels.scss b/app/assets/stylesheets/labels.scss index 50be851d2..1281fb21e 100644 --- a/app/assets/stylesheets/labels.scss +++ b/app/assets/stylesheets/labels.scss @@ -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%); diff --git a/app/javascript/components/ComboAdresseSearch.jsx b/app/javascript/components/ComboAdresseSearch.jsx deleted file mode 100644 index 5691d76e0..000000000 --- a/app/javascript/components/ComboAdresseSearch.jsx +++ /dev/null @@ -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 ( - - - - ); -} - -ComboAdresseSearch.propTypes = { - transformResult: PropTypes.func, - allowInputValues: PropTypes.bool -}; - -export default ComboAdresseSearch; diff --git a/app/javascript/components/ComboAdresseSearch.tsx b/app/javascript/components/ComboAdresseSearch.tsx new file mode 100644 index 000000000..b9c91afc3 --- /dev/null +++ b/app/javascript/components/ComboAdresseSearch.tsx @@ -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; +type AdresseResult = RawResult['features'][0]; +type ComboAdresseSearchProps = Omit< + ComboSearchProps, + 'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope' +>; + +export default function ComboAdresseSearch({ + allowInputValues = true, + ...props +}: ComboAdresseSearchProps) { + return ( + + + {...props} + allowInputValues={allowInputValues} + scope="adresse" + minimumInputLength={2} + transformResult={({ properties: { label } }) => [label, label, label]} + transformResults={(_, result) => (result as RawResult).features} + /> + + ); +} diff --git a/app/javascript/components/ComboSearch.jsx b/app/javascript/components/ComboSearch.tsx similarity index 72% rename from app/javascript/components/ComboSearch.jsx rename to app/javascript/components/ComboSearch.tsx index 647bf5d9d..4e322c453 100644 --- a/app/javascript/components/ComboSearch.jsx +++ b/app/javascript/components/ComboSearch.tsx @@ -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 = (term: string, results: unknown) => Result[]; +type TransformResult = ( + result: Result +) => [key: string, value: string, label: string]; -function ComboSearch({ +export type ComboSearchProps = { + onChange?: (value: string | null, result?: Result) => void; + value?: string; + scope: string; + scopeExtra?: string; + minimumInputLength: number; + transformResults: TransformResults; + transformResult: TransformResult; + allowInputValues?: boolean; + id?: string; + describedby?: string; + className?: string; + placeholder?: string; +}; + +type QueryKey = readonly [ + scope: string, + term: string, + extra: string | undefined +]; + +function ComboSearch({ 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) { 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 + >({}); + 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( [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; diff --git a/app/javascript/components/MapEditor/components/AddressInput.tsx b/app/javascript/components/MapEditor/components/AddressInput.tsx new file mode 100644 index 000000000..9becf7034 --- /dev/null +++ b/app/javascript/components/MapEditor/components/AddressInput.tsx @@ -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 ( +
+ { + const geometry = result?.geometry as Point; + flyTo(17, geometry.coordinates as [number, number]); + }} + /> +
+ ); +} diff --git a/app/javascript/components/MapEditor/components/CadastreLayer.tsx b/app/javascript/components/MapEditor/components/CadastreLayer.tsx new file mode 100644 index 000000000..4ebb14523 --- /dev/null +++ b/app/javascript/components/MapEditor/components/CadastreLayer.tsx @@ -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()); + + 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(); + + const onMouseMove = useCallback( + (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(() => { + if (hoveredFeature.current) { + hoverFeature(hoveredFeature.current, false); + } + hoveredFeature.current = null; + }, [hoverFeature]); + + const onClick = useCallback( + 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'); +} diff --git a/app/javascript/components/MapEditor/components/DrawLayer.tsx b/app/javascript/components/MapEditor/components/DrawLayer.tsx new file mode 100644 index 000000000..f5292da9a --- /dev/null +++ b/app/javascript/components/MapEditor/components/DrawLayer.tsx @@ -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(); + + 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'] +]; diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx new file mode 100644 index 000000000..9abd79c62 --- /dev/null +++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx @@ -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 ( +
+ +
+ {inputs.map((input) => ( +
+ onFileChange(e, input.id)} + /> + {input.hasValue && ( + removeInputFile(e, input.id)} + > + )} +
+ ))} +
+
+ ); +} + +type FileInput = { + id: string; + disabled: boolean; + hasValue: boolean; + filename: string; +}; + +function useImportFiles( + featureCollection: FeatureCollection, + { + createFeatures, + deleteFeatures + }: { createFeatures: CreateFeatures; deleteFeatures: DeleteFeatures } +) { + const [inputs, setInputs] = useState([]); + 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, 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 + }; +} diff --git a/app/javascript/components/MapEditor/components/PointInput.tsx b/app/javascript/components/MapEditor/components/PointInput.tsx new file mode 100644 index 000000000..22a4f9ea0 --- /dev/null +++ b/app/javascript/components/MapEditor/components/PointInput.tsx @@ -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(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 ( + <> + +
+ {navigator.geolocation ? ( + + ) : null} + { + 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); + } + }} + /> + +
+ + ); +} diff --git a/app/javascript/components/MapEditor/hooks.ts b/app/javascript/components/MapEditor/hooks.ts new file mode 100644 index 000000000..bc74f63c3 --- /dev/null +++ b/app/javascript/components/MapEditor/hooks.ts @@ -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[]; + source?: string; + external?: true; +}) => void; +export type UpdateFatures = (params: { + features: Feature[]; + source?: string; + external?: true; +}) => void; +export type DeleteFeatures = (params: { + features: Feature[]; + 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( + 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( + 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( + 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(); + useEffect(() => { + const timer = setTimeout(() => onError(undefined), 5000); + return () => clearTimeout(timer); + }, [error]); + + return [error, onError]; +} diff --git a/app/javascript/components/MapEditor/index.jsx b/app/javascript/components/MapEditor/index.jsx deleted file mode 100644 index 20fa26dd4..000000000 --- a/app/javascript/components/MapEditor/index.jsx +++ /dev/null @@ -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 ( -

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

- ); - } - - return ( - <> - {error && } -
-

- Besoin d'aide ?  - - consulter les tutoriels video - -

-
-
- -
- {inputs.map((input) => ( -
- onFileChange(e, input.id)} - /> - {input.hasValue && ( - removeInputFile(e, input.id)} - > - )} -
- ))} -
-
-
- { - setCoords(coordinates); - setZoom([17]); - }} - /> -
- onLoad(map)} - center={coords} - zoom={zoom} - style={style} - containerStyle={{ height: '500px' }} - > - {!cadastreEnabled && ( - - )} - - - {options.layers.includes('cadastres') && ( -
- -
- )} -
- - - ); -} - -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 ( - <> - -
- {navigator.geolocation ? ( - - ) : null} - { - setValue(value); - setFeature( - dd.length - ? { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: dd.reverse() - }, - properties: {} - } - : null - ); - }} - /> - -
- - ); -} - -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; diff --git a/app/javascript/components/MapEditor/index.tsx b/app/javascript/components/MapEditor/index.tsx new file mode 100644 index 000000000..a84094835 --- /dev/null +++ b/app/javascript/components/MapEditor/index.tsx @@ -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 ( + <> +
+

+ Besoin d'aide ?  + + consulter les tutoriels video + +

+
+ {error && } + + + + + } + footer={} + > + + {options.layers.includes('cadastres') ? ( + <> + +
+ +
+ + ) : null} +
+ + ); +} diff --git a/app/javascript/components/MapEditor/readGeoFile.js b/app/javascript/components/MapEditor/readGeoFile.ts similarity index 62% rename from app/javascript/components/MapEditor/readGeoFile.js rename to app/javascript/components/MapEditor/readGeoFile.ts index 59f972f02..d9f66f10f 100644 --- a/app/javascript/components/MapEditor/readGeoFile.js +++ b/app/javascript/components/MapEditor/readGeoFile.ts @@ -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>( + (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 }; } diff --git a/app/javascript/components/MapEditor/useMapboxEditor.js b/app/javascript/components/MapEditor/useMapboxEditor.js deleted file mode 100644 index bc875b344..000000000 --- a/app/javascript/components/MapEditor/useMapboxEditor.js +++ /dev/null @@ -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]); -} diff --git a/app/javascript/components/MapReader/components/CadastreLayer.tsx b/app/javascript/components/MapReader/components/CadastreLayer.tsx new file mode 100644 index 000000000..243610eb1 --- /dev/null +++ b/app/javascript/components/MapReader/components/CadastreLayer.tsx @@ -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>(); + + 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; +} diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx new file mode 100644 index 000000000..956a71fc4 --- /dev/null +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -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( + (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) => ( + + ))} + {lines.features.map((feature) => ( + + ))} + {points.features.map((feature) => ( + + ))} + + ); +} + +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' +}; diff --git a/app/javascript/components/MapReader/index.jsx b/app/javascript/components/MapReader/index.jsx deleted file mode 100644 index b20dde570..000000000 --- a/app/javascript/components/MapReader/index.jsx +++ /dev/null @@ -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 ( -

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

- ); - } - - return ( - onLoad(map)} - style={style} - containerStyle={{ height: '500px' }} - > - - - - - - - - ); -}; - -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 ( - - ); -} - -function SelectionUtilisateurLineLayer({ - featureCollection, - onMouseEnter, - onMouseLeave -}) { - const data = useMemo( - () => - filterFeatureCollectionByGeometryType( - filterFeatureCollection(featureCollection, 'selection_utilisateur'), - 'LineString' - ), - [featureCollection] - ); - return ( - - ); -} - -function SelectionUtilisateurPointLayer({ - featureCollection, - onMouseEnter, - onMouseLeave -}) { - const data = useMemo( - () => - filterFeatureCollectionByGeometryType( - filterFeatureCollection(featureCollection, 'selection_utilisateur'), - 'Point' - ), - [featureCollection] - ); - return ( - - ); -} - -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; diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx new file mode 100644 index 000000000..1f9fb07d4 --- /dev/null +++ b/app/javascript/components/MapReader/index.tsx @@ -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 ( + + + + + ); +}; + +export default MapReader; diff --git a/app/javascript/components/MapReader/useMapbox.js b/app/javascript/components/MapReader/useMapbox.js deleted file mode 100644 index d58b4e463..000000000 --- a/app/javascript/components/MapReader/useMapbox.js +++ /dev/null @@ -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]); -} diff --git a/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx b/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx index bce4e7103..68a83fc9c 100644 --- a/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx +++ b/app/javascript/components/TypesDeChampEditor/components/TypeDeChamp.jsx @@ -82,7 +82,7 @@ const TypeDeChamp = sortableElement( }} > - Supprimer + Supprimer diff --git a/app/javascript/components/shared/FlashMessage.jsx b/app/javascript/components/shared/FlashMessage.tsx similarity index 57% rename from app/javascript/components/shared/FlashMessage.jsx rename to app/javascript/components/shared/FlashMessage.tsx index 027b1c709..6a092ff60 100644 --- a/app/javascript/components/shared/FlashMessage.jsx +++ b/app/javascript/components/shared/FlashMessage.tsx @@ -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(
{message}
, - 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 -}; diff --git a/app/javascript/components/shared/hooks.js b/app/javascript/components/shared/hooks.ts similarity index 65% rename from app/javascript/components/shared/hooks.js rename to app/javascript/components/shared/hooks.ts index a07191d75..483d5477f 100644 --- a/app/javascript/components/shared/hooks.js +++ b/app/javascript/components/shared/hooks.ts @@ -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( `${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]` ); } diff --git a/app/javascript/components/shared/mapbox/utils.js b/app/javascript/components/shared/mapbox/utils.js deleted file mode 100644 index 721a0bfb5..000000000 --- a/app/javascript/components/shared/mapbox/utils.js +++ /dev/null @@ -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; -} diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx new file mode 100644 index 000000000..b123733b2 --- /dev/null +++ b/app/javascript/components/shared/maplibre/MapLibre.tsx @@ -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(null); + const [map, setMap] = useState(); + + 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 ( +
+
+
⚠️
+
+ Nous ne pouvons pas afficher la carte car elle est imcompatible avec + votre navigateur. Nous vous conseillons de le mettre à jour ou + d’utiliser{' '} + + un navigateur plus récent + + . +
+
+
+ ); + } + + return ( + + {map ? header : null} +
+ + {map ? children : null} +
+ {map ? footer : null} +
+ ); +} + +function isIE() { + const ua = window.navigator.userAgent; + const msie = ua.indexOf('MSIE '); + const trident = ua.indexOf('Trident/'); + return msie > 0 || trident > 0; +} diff --git a/app/javascript/components/shared/mapbox/MapStyleControl.jsx b/app/javascript/components/shared/maplibre/StyleControl.tsx similarity index 64% rename from app/javascript/components/shared/mapbox/MapStyleControl.jsx rename to app/javascript/components/shared/maplibre/StyleControl.tsx index f6bc98a04..d959e41d4 100644 --- a/app/javascript/components/shared/mapbox/MapStyleControl.jsx +++ b/app/javascript/components/shared/maplibre/StyleControl.tsx @@ -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(); + const [panelElement, setPanelElement] = useState(); const { styles, attributes } = usePopper(buttonElement, panelElement, { placement: 'bottom-end' }); @@ -86,7 +39,10 @@ function MapStyleControl({ const mapId = useId(); return ( -
+
); } - -MapStyleControl.propTypes = { - style: PropTypes.string, - layers: PropTypes.object, - setStyle: PropTypes.func, - setLayerEnabled: PropTypes.func, - setLayerOpacity: PropTypes.func -}; - -export default MapStyleControl; diff --git a/app/javascript/components/shared/maplibre/hooks.ts b/app/javascript/components/shared/maplibre/hooks.ts new file mode 100644 index 000000000..dc6aaef97 --- /dev/null +++ b/app/javascript/components/shared/maplibre/hooks.ts @@ -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[]; + 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 }; +} diff --git a/app/javascript/components/shared/mapbox/styles/base.js b/app/javascript/components/shared/maplibre/styles/base.ts similarity index 85% rename from app/javascript/components/shared/mapbox/styles/base.js rename to app/javascript/components/shared/maplibre/styles/base.ts index 8e7ecd0e1..cc7c880ee 100644 --- a/app/javascript/components/shared/mapbox/styles/base.js +++ b/app/javascript/components/shared/maplibre/styles/base.ts @@ -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 +): 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; diff --git a/app/javascript/components/shared/mapbox/styles/index.js b/app/javascript/components/shared/maplibre/styles/index.ts similarity index 61% rename from app/javascript/components/shared/mapbox/styles/index.js rename to app/javascript/components/shared/maplibre/styles/index.ts index ed97b2aee..b7d865e29 100644 --- a/app/javascript/components/shared/mapbox/styles/index.js +++ b/app/javascript/components/shared/maplibre/styles/index.ts @@ -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 +): 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; } diff --git a/app/javascript/components/shared/mapbox/styles/layers/cadastre.js b/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts similarity index 95% rename from app/javascript/components/shared/mapbox/styles/layers/cadastre.js rename to app/javascript/components/shared/maplibre/styles/layers/cadastre.ts index 0aed8996c..759504f97 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/cadastre.js +++ b/app/javascript/components/shared/maplibre/styles/layers/cadastre.ts @@ -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; diff --git a/app/javascript/components/shared/mapbox/styles/layers/ign.js b/app/javascript/components/shared/maplibre/styles/layers/ign.ts similarity index 52% rename from app/javascript/components/shared/mapbox/styles/layers/ign.js rename to app/javascript/components/shared/maplibre/styles/layers/ign.ts index e7e7614a7..545d41911 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/ign.js +++ b/app/javascript/components/shared/maplibre/styles/layers/ign.ts @@ -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; diff --git a/app/javascript/components/shared/mapbox/styles/layers/ortho.js b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts similarity index 99% rename from app/javascript/components/shared/mapbox/styles/layers/ortho.js rename to app/javascript/components/shared/maplibre/styles/layers/ortho.ts index 0e255a426..5423d46ce 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/ortho.js +++ b/app/javascript/components/shared/maplibre/styles/layers/ortho.ts @@ -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; diff --git a/app/javascript/components/shared/mapbox/styles/layers/vector.js b/app/javascript/components/shared/maplibre/styles/layers/vector.ts similarity index 99% rename from app/javascript/components/shared/mapbox/styles/layers/vector.js rename to app/javascript/components/shared/maplibre/styles/layers/vector.ts index d69b812a2..b91bf9cb3 100644 --- a/app/javascript/components/shared/mapbox/styles/layers/vector.js +++ b/app/javascript/components/shared/maplibre/styles/layers/vector.ts @@ -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; diff --git a/app/javascript/components/shared/maplibre/utils.ts b/app/javascript/components/shared/maplibre/utils.ts new file mode 100644 index 000000000..8ab08c181 --- /dev/null +++ b/app/javascript/components/shared/maplibre/utils.ts @@ -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( + featureCollection: FeatureCollection, + value: unknown, + property = 'id' +): Feature | null { + return ( + featureCollection.features.find( + (feature) => feature.properties && feature.properties[property] === value + ) ?? null + ); +} + +export function filterFeatureCollection( + featureCollection: FeatureCollection, + source: string +): FeatureCollection { + return { + type: 'FeatureCollection', + features: featureCollection.features.filter( + (feature) => feature.properties?.source === source + ) + }; +} + +export function filterFeatureCollectionByGeometryType( + featureCollection: FeatureCollection, + type: Geometry['type'] +): FeatureCollection { + 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(); + } +} diff --git a/app/javascript/components/shared/queryClient.js b/app/javascript/components/shared/queryClient.ts similarity index 78% rename from app/javascript/components/shared/queryClient.js rename to app/javascript/components/shared/queryClient.ts index e37e4fb7f..14164f831 100644 --- a/app/javascript/components/shared/queryClient.js +++ b/app/javascript/components/shared/queryClient.ts @@ -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 = 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 + } + } +}); diff --git a/app/javascript/types.d.ts b/app/javascript/types.d.ts index 27592bb47..200e8886e 100644 --- a/app/javascript/types.d.ts +++ b/app/javascript/types.d.ts @@ -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; diff --git a/config/webpack/environment.js b/config/webpack/environment.js index deb5c4c44..c5e252282 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -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 diff --git a/package.json b/package.json index aade98581..a65a2247b 100644 --- a/package.json +++ b/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", diff --git a/tsconfig.json b/tsconfig.json index d0f042196..6995789e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"]