Merge pull request #6152 from tchak/use-cadastre-information-from-layers

Use cadastre information contained in layers
This commit is contained in:
Paul Chavard 2021-05-25 08:56:53 +02:00 committed by GitHub
commit 8c8f5c564c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1328 additions and 953 deletions

View file

@ -1,3 +1,5 @@
@import "colors";
.areas-title {
font-weight: bold;
margin-top: 5px;
@ -15,3 +17,38 @@
.form [data-react-class='MapEditor'] [data-reach-combobox-input] {
margin-bottom: 0;
}
.map-style-control {
position: absolute;
bottom: 4px;
left: 10px;
img {
width: 100%;
}
button {
padding: 0;
border: none;
cursor: pointer;
> div {
position: absolute;
bottom: 5px;
left: 5px;
}
}
}
.cadastres-selection-control {
position: absolute;
top: 135px;
left: 10px;
button {
&.on,
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
}

View file

@ -163,7 +163,7 @@
}
}
input[type=text]:not([data-address='true']),
input[type=text],
input[type=email],
input[type=password],
input[type=date],
@ -178,6 +178,10 @@
&.small-margin {
margin-bottom: $default-spacer;
}
&.no-margin {
margin-bottom: 0;
}
}
.add-row {
@ -475,7 +479,7 @@
}
[data-react-class]:not([data-react-class="ComboMultipleDropdownList"]) {
[data-reach-combobox-input] {
[data-reach-combobox-input]:not(.no-margin) {
margin-bottom: $default-fields-spacer;
}
}

View file

@ -4,29 +4,26 @@ class Champs::CarteController < ApplicationController
def index
@selector = ".carte-#{params[:champ_id]}"
@champ = policy_scope(Champ).find(params[:champ_id])
@update_cadastres = params[:cadastres]
if @champ.cadastres? && @update_cadastres
@champ.geo_areas.cadastres.destroy_all
@champ.geo_areas += GeoArea.from_feature_collection(cadastres_features_collection(@champ.to_feature_collection))
@champ.save!
end
rescue APICarto::API::ResourceNotFound
flash.alert = 'Les données cartographiques sont temporairement indisponibles. Réessayez dans un instant.'
response.status = 503
@focus = params[:focus].present?
end
def create
champ = policy_scope(Champ).find(params[:champ_id])
geo_area = champ.geo_areas.selections_utilisateur.new
save_feature!(geo_area, params_feature)
geo_area = if params_source == GeoArea.sources.fetch(:cadastre)
champ.geo_areas.find_by("properties->>'id' = :id", id: params_feature[:properties][:id])
end
if geo_area.nil?
geo_area = champ.geo_areas.build(source: params_source, properties: {})
save_feature!(geo_area, params_feature)
end
render json: { feature: geo_area.to_feature }, status: :created
end
def update
champ = policy_scope(Champ).find(params[:champ_id])
geo_area = champ.geo_areas.selections_utilisateur.find(params[:id])
geo_area = champ.geo_areas.find(params[:id])
save_feature!(geo_area, params_feature)
head :no_content
@ -34,66 +31,42 @@ class Champs::CarteController < ApplicationController
def destroy
champ = policy_scope(Champ).find(params[:champ_id])
champ.geo_areas.selections_utilisateur.find(params[:id]).destroy!
champ.geo_areas.find(params[:id]).destroy!
head :no_content
end
def import
champ = policy_scope(Champ).find(params[:champ_id])
params_features.each do |feature|
geo_area = champ.geo_areas.selections_utilisateur.new
save_feature!(geo_area, feature)
end
render json: champ.to_feature_collection, status: :created
end
private
def params_feature
params[:feature]
def params_source
params[:source]
end
def params_features
params[:features]
def params_feature
params.require(:feature).permit(properties: [
:filename,
:description,
:arpente,
:commune,
:contenance,
:created,
:id,
:numero,
:prefixe,
:section,
:updated
]).tap do |feature|
feature[:geometry] = params[:feature][:geometry]
end
end
def save_feature!(geo_area, feature)
if feature[:geometry]
geo_area.geometry = feature[:geometry]
end
if feature[:properties] && feature[:properties][:description]
geo_area.description = feature[:properties][:description]
if feature[:properties]
geo_area.properties.merge!(feature[:properties])
end
geo_area.save!
end
def cadastres_features_collection(feature_collection)
coordinates = feature_collection[:features].filter do |feature|
feature[:properties][:source] == GeoArea.sources.fetch(:selection_utilisateur) && feature[:geometry] && feature[:geometry]['type'] == 'Polygon'
end.map do |feature|
feature[:geometry]['coordinates'][0].map { |(lng, lat)| { 'lng' => lng, 'lat' => lat } }
end
if coordinates.present?
cadastres = APICartoService.generate_cadastre(coordinates)
{
type: 'FeatureCollection',
features: cadastres.map do |cadastre|
{
type: 'Feature',
geometry: cadastre.delete(:geometry),
properties: cadastre.merge(source: GeoArea.sources.fetch(:cadastre))
}
end
}
else
{
type: 'FeatureCollection',
features: []
}
end
end
end

View file

@ -1467,18 +1467,21 @@ type PageInfo {
}
type ParcelleCadastrale implements GeoArea {
codeArr: String!
codeCom: String!
codeDep: String!
feuille: Int!
codeArr: String! @deprecated(reason: "Utilisez le champ `prefixe` à la place.")
codeCom: String! @deprecated(reason: "Utilisez le champ `commune` à la place.")
codeDep: String! @deprecated(reason: "Utilisez le champ `commune` à la place.")
commune: String!
feuille: Int! @deprecated(reason: "Linformation nest plus disponible.")
geometry: GeoJSON!
id: ID!
nomCom: String!
nomCom: String! @deprecated(reason: "Utilisez le champ `commune` à la place.")
numero: String!
prefixe: String!
section: String!
source: GeoAreaSource!
surfaceIntersection: Float!
surfaceParcelle: Float!
surface: String!
surfaceIntersection: Float! @deprecated(reason: "Linformation nest plus disponible.")
surfaceParcelle: Float! @deprecated(reason: "Utilisez le champ `surface` à la place.")
}
type PersonneMorale implements Demandeur {
@ -1587,6 +1590,7 @@ type Revision {
}
type SelectionUtilisateur implements GeoArea {
description: String!
geometry: GeoJSON!
id: ID!
source: GeoAreaSource!

View file

@ -2,14 +2,18 @@ module Types::GeoAreas
class ParcelleCadastraleType < Types::BaseObject
implements Types::GeoAreaType
field :surface_intersection, Float, null: false
field :surface_parcelle, Float, null: false
field :numero, String, null: false
field :feuille, Int, null: false
field :section, String, null: false
field :code_dep, String, null: false
field :nom_com, String, null: false
field :code_com, String, null: false
field :code_arr, String, null: false
field :surface, String, null: false
field :prefixe, String, null: false
field :commune, String, null: false
field :code_dep, String, null: false, deprecation_reason: 'Utilisez le champ `commune` à la place.'
field :nom_com, String, null: false, deprecation_reason: 'Utilisez le champ `commune` à la place.'
field :code_com, String, null: false, deprecation_reason: 'Utilisez le champ `commune` à la place.'
field :code_arr, String, null: false, deprecation_reason: 'Utilisez le champ `prefixe` à la place.'
field :feuille, Int, null: false, deprecation_reason: 'Linformation nest plus disponible.'
field :surface_intersection, Float, null: false, deprecation_reason: 'Linformation nest plus disponible.'
field :surface_parcelle, Float, null: false, deprecation_reason: 'Utilisez le champ `surface` à la place.'
end
end

View file

@ -1,5 +1,7 @@
module Types::GeoAreas
class SelectionUtilisateurType < Types::BaseObject
implements Types::GeoAreaType
field :description, String, null: false
end
end

View file

@ -36,7 +36,7 @@ module ChampHelper
case geo_area.source
when GeoArea.sources.fetch(:cadastre)
capture do
concat "Parcelle n° #{geo_area.numero} - Feuille #{geo_area.code_arr} #{geo_area.section} #{geo_area.feuille} - #{geo_area.surface_parcelle.round} m"
concat "Parcelle n° #{geo_area.numero} - Feuille #{geo_area.prefixe} #{geo_area.section} - #{geo_area.surface.round} m"
concat tag.sup("2")
end
when GeoArea.sources.fetch(:selection_utilisateur)

View file

@ -11,13 +11,15 @@ function ComboAdresseSearch({
hiddenFieldId,
onChange,
transformResult = ({ properties: { label } }) => [label, label],
allowInputValues = true
allowInputValues = true,
className
}) {
const transformResults = useCallback((_, { features }) => features);
return (
<QueryClientProvider client={queryClient}>
<ComboSearch
className={className}
placeholder={placeholder}
required={mandatory}
hiddenFieldId={hiddenFieldId}
@ -33,6 +35,7 @@ function ComboAdresseSearch({
}
ComboAdresseSearch.propTypes = {
className: PropTypes.string,
placeholder: PropTypes.string,
mandatory: PropTypes.bool,
hiddenFieldId: PropTypes.string,

View file

@ -25,7 +25,8 @@ function ComboSearch({
minimumInputLength,
transformResult,
allowInputValues = false,
transformResults = defaultTransformResults
transformResults = defaultTransformResults,
className
}) {
const label = scope;
const hiddenValueField = useMemo(
@ -93,6 +94,7 @@ function ComboSearch({
return (
<Combobox aria-label={label} onSelect={handleOnSelect}>
<ComboboxInput
className={className}
placeholder={placeholder}
onChange={handleOnChange}
value={value}
@ -134,7 +136,8 @@ ComboSearch.propTypes = {
transformResult: PropTypes.func,
transformResults: PropTypes.func,
allowInputValues: PropTypes.bool,
onChange: PropTypes.func
onChange: PropTypes.func,
className: PropTypes.string
};
export default ComboSearch;

View file

@ -1,257 +1,47 @@
import React, {
useState,
useCallback,
useRef,
useMemo,
useEffect
} from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import mapboxgl from 'mapbox-gl';
import { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl';
import ReactMapboxGl, { ZoomControl } from 'react-mapbox-gl';
import DrawControl from 'react-mapbox-gl-draw';
import { MapIcon } from '@heroicons/react/outline';
import 'mapbox-gl/dist/mapbox-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
import { getJSON, ajax, fire } from '@utils';
import Mapbox from '../shared/mapbox/Mapbox';
import { getMapStyle } from '../shared/mapbox/styles';
import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle';
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
import { FlashMessage } from '../shared/FlashMessage';
import ComboAdresseSearch from '../ComboAdresseSearch';
import {
polygonCadastresFill,
polygonCadastresLine,
readGeoFile
} from './utils';
import {
noop,
filterFeatureCollection,
fitBounds,
generateId,
useEvent,
findFeature
} from '../shared/mapbox/utils';
import { useMapboxEditor } from './useMapboxEditor';
function MapEditor({ featureCollection, url, preview, options }) {
const drawControl = useRef(null);
const [currentMap, setCurrentMap] = useState(null);
const Mapbox = ReactMapboxGl({});
const [errorMessage, setErrorMessage] = useState();
const [style, setStyle] = useState('ortho');
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 [bbox, setBbox] = useState(featureCollection.bbox);
const [importInputs, setImportInputs] = useState([]);
const [cadastresFeatureCollection, setCadastresFeatureCollection] = useState(
filterFeatureCollection(featureCollection, 'cadastre')
);
const mapStyle = useMemo(() => getMapStyle(style, options.layers), [
style,
options
]);
const hasCadastres = useMemo(() => options.layers.includes('cadastres'));
const {
isSupported,
error,
inputs,
onLoad,
onStyleChange,
onFileChange,
drawRef,
createFeatures,
updateFeatures,
deleteFeatures,
addInputFile,
removeInputFile
} = useMapboxEditor(featureCollection, {
url,
enabled: !preview,
cadastreEnabled
});
const [style, setStyle] = useMapStyle(options.layers, {
onStyleChange,
cadastreEnabled
});
useEffect(() => {
const timer = setTimeout(() => setErrorMessage(null), 5000);
return () => clearTimeout(timer);
}, [errorMessage]);
const translations = [
['.mapbox-gl-draw_line', 'Tracer une ligne'],
['.mapbox-gl-draw_polygon', 'Dessiner un polygone'],
['.mapbox-gl-draw_point', 'Ajouter un point'],
['.mapbox-gl-draw_trash', 'Supprimer']
];
for (const [selector, translation] of translations) {
const element = document.querySelector(selector);
if (element) {
element.setAttribute('title', translation);
}
}
const onFeatureFocus = useCallback(
({ detail }) => {
const { id } = detail;
const featureCollection = drawControl.current.draw.getAll();
const feature = findFeature(featureCollection, id);
if (feature) {
fitBounds(currentMap, feature);
}
},
[currentMap, drawControl.current]
);
const onFeatureUpdate = useCallback(
async ({ detail }) => {
const { id, properties } = detail;
const featureCollection = drawControl.current.draw.getAll();
const feature = findFeature(featureCollection, id);
if (feature) {
getJSON(`${url}/${id}`, { feature: { properties } }, 'patch');
}
},
[url, drawControl.current]
);
const updateFeaturesList = useCallback(
async (features) => {
const cadastres = features.find(
({ geometry }) => geometry.type === 'Polygon'
);
await ajax({
url,
type: 'get',
data: cadastres ? 'cadastres=update' : ''
});
fire(document, 'ds:page:update');
},
[url]
);
const onCadastresUpdate = useCallback(({ detail }) => {
setCadastresFeatureCollection(
filterFeatureCollection(detail.featureCollection, 'cadastre')
);
}, []);
useEvent('map:feature:focus', onFeatureFocus);
useEvent('map:feature:update', onFeatureUpdate);
useEvent('cadastres:update', onCadastresUpdate);
function setFeatureId(lid, feature) {
const draw = drawControl.current.draw;
draw.setFeatureProperty(lid, 'id', feature.properties.id);
}
function updateImportInputs(inputs, inputId) {
const updatedInputs = inputs.filter((input) => input.id !== inputId);
setImportInputs(updatedInputs);
}
async function onDrawCreate({ features }) {
try {
for (const feature of features) {
const data = await getJSON(url, { feature }, 'post');
setFeatureId(feature.id, data.feature);
}
updateFeaturesList(features);
} catch {
setErrorMessage('Le polygone dessiné nest pas valide.');
}
}
async function onDrawUpdate({ features }) {
try {
for (const feature of features) {
const { id } = feature.properties;
if (id) {
await getJSON(`${url}/${id}`, { feature }, 'patch');
} else {
const data = await getJSON(url, { feature }, 'post');
setFeatureId(feature.id, data.feature);
}
}
updateFeaturesList(features);
} catch {
setErrorMessage('Le polygone dessiné nest pas valide.');
}
}
async function onDrawDelete({ features }) {
for (const feature of features) {
const { id } = feature.properties;
await getJSON(`${url}/${id}`, null, 'delete');
}
updateFeaturesList(features);
}
function onMapLoad(map) {
setCurrentMap(map);
drawControl.current.draw.set(
filterFeatureCollection(featureCollection, 'selection_utilisateur')
);
}
const onFileImport = async (e, inputId) => {
try {
const featureCollection = await readGeoFile(e.target.files[0]);
const resultFeatureCollection = await getJSON(
`${url}/import`,
featureCollection,
'post'
);
let inputs = [...importInputs];
const setInputs = inputs.map((input) => {
if (input.id === inputId) {
input.disabled = true;
input.hasValue = true;
resultFeatureCollection.features.forEach((resultFeature) => {
featureCollection.features.forEach((feature) => {
if (
JSON.stringify(resultFeature.geometry) ===
JSON.stringify(feature.geometry)
) {
input.featureIds.push(resultFeature.properties.id);
}
});
});
}
return input;
});
drawControl.current.draw.set(
filterFeatureCollection(
resultFeatureCollection,
'selection_utilisateur'
)
);
updateFeaturesList(resultFeatureCollection.features);
setImportInputs(setInputs);
setBbox(resultFeatureCollection.bbox);
} catch {
setErrorMessage('Le fichier importé contient des polygones invalides.');
}
};
const addInputFile = (e) => {
e.preventDefault();
let inputs = [...importInputs];
inputs.push({
id: generateId(),
disabled: false,
featureIds: [],
hasValue: false
});
setImportInputs(inputs);
};
const removeInputFile = async (e, inputId) => {
e.preventDefault();
const draw = drawControl.current.draw;
const featureCollection = draw.getAll();
let inputs = [...importInputs];
const inputToRemove = inputs.find((input) => input.id === inputId);
for (const feature of featureCollection.features) {
if (inputToRemove.featureIds.includes(feature.properties.id)) {
const featureToRemove = draw.get(feature.id);
await getJSON(`${url}/${feature.properties.id}`, null, 'delete');
draw.delete(feature.id).getAll();
updateFeaturesList([featureToRemove]);
}
}
updateImportInputs(inputs, inputId);
};
if (!mapboxgl.supported()) {
if (!isSupported) {
return (
<p>
Nous ne pouvons pas afficher notre éditeur de carte car il est
@ -263,9 +53,7 @@ function MapEditor({ featureCollection, url, preview, options }) {
return (
<>
{errorMessage && (
<FlashMessage message={errorMessage} level="alert" fixed={true} />
)}
{error && <FlashMessage message={error} level="alert" fixed={true} />}
<div>
<p style={{ marginBottom: '20px' }}>
Besoin d&apos;aide ?&nbsp;
@ -278,12 +66,12 @@ function MapEditor({ featureCollection, url, preview, options }) {
</a>
</p>
</div>
<div className="file-import" style={{ marginBottom: '20px' }}>
<div className="file-import" style={{ marginBottom: '10px' }}>
<button className="button send primary" onClick={addInputFile}>
Ajouter un fichier GPX ou KML
</button>
<div>
{importInputs.map((input) => (
{inputs.map((input) => (
<div key={input.id}>
<input
title="Choisir un fichier gpx ou kml"
@ -292,7 +80,7 @@ function MapEditor({ featureCollection, url, preview, options }) {
type="file"
accept=".gpx, .kml"
disabled={input.disabled}
onChange={(e) => onFileImport(e, input.id)}
onChange={(e) => onFileChange(e, input.id)}
/>
{input.hasValue && (
<span
@ -310,10 +98,11 @@ function MapEditor({ featureCollection, url, preview, options }) {
</div>
<div
style={{
marginBottom: '50px'
marginBottom: '10px'
}}
>
<ComboAdresseSearch
className="no-margin"
placeholder="Rechercher une adresse : saisissez au moins 2 caractères"
allowInputValues={false}
onChange={(_, { geometry: { coordinates } }) => {
@ -323,38 +112,43 @@ function MapEditor({ featureCollection, url, preview, options }) {
/>
</div>
<Mapbox
onStyleLoad={(map) => onMapLoad(map)}
fitBounds={bbox}
fitBoundsOptions={{ padding: 100 }}
onStyleLoad={(map) => onLoad(map)}
center={coords}
zoom={zoom}
style={mapStyle}
containerStyle={{
height: '500px'
}}
style={style}
containerStyle={{ height: '500px' }}
>
{hasCadastres ? (
<GeoJSONLayer
data={cadastresFeatureCollection}
fillPaint={polygonCadastresFill}
linePaint={polygonCadastresLine}
{!cadastreEnabled && (
<DrawControl
ref={drawRef}
onDrawCreate={createFeatures}
onDrawUpdate={updateFeatures}
onDrawDelete={deleteFeatures}
displayControlsDefault={false}
controls={{
point: true,
line_string: true,
polygon: true,
trash: true
}}
/>
) : null}
<DrawControl
ref={drawControl}
onDrawCreate={preview ? noop : onDrawCreate}
onDrawUpdate={preview ? noop : onDrawUpdate}
onDrawDelete={preview ? noop : onDrawDelete}
displayControlsDefault={false}
controls={{
point: true,
line_string: true,
polygon: true,
trash: true
}}
/>
<SwitchMapStyle style={style} setStyle={setStyle} ign={options.ign} />
)}
<MapStyleControl style={style.id} setStyle={setStyle} />
<ZoomControl />
{options.layers.includes('cadastres') && (
<div className="cadastres-selection-control mapboxgl-ctrl-group">
<button
type="button"
onClick={() =>
setCadastreEnabled((cadastreEnabled) => !cadastreEnabled)
}
title="Sélectionner les parcelles cadastrales"
className={cadastreEnabled ? 'on' : ''}
>
<MapIcon className="icon-size" />
</button>
</div>
)}
</Mapbox>
</>
);
@ -363,15 +157,11 @@ function MapEditor({ featureCollection, url, preview, options }) {
MapEditor.propTypes = {
featureCollection: PropTypes.shape({
bbox: PropTypes.array,
features: PropTypes.array,
id: PropTypes.number
features: PropTypes.array
}),
url: PropTypes.string,
preview: PropTypes.bool,
options: PropTypes.shape({
layers: PropTypes.array,
ign: PropTypes.bool
})
options: PropTypes.shape({ layers: PropTypes.array })
};
export default MapEditor;

View file

@ -1,17 +1,28 @@
import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js';
import { generateId } from '../shared/mapbox/utils';
export const polygonCadastresFill = {
'fill-color': '#EC3323',
'fill-opacity': 0.3
};
export function readGeoFile(file) {
const isGpxFile = file.name.includes('.gpx');
const reader = new FileReader();
export const polygonCadastresLine = {
'line-color': 'rgba(255, 0, 0, 1)',
'line-width': 4,
'line-dasharray': [1, 1]
};
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
);
export function normalizeFeatureCollection(featureCollection) {
resolve(featureCollection);
};
reader.readAsText(file, 'UTF-8');
});
}
function normalizeFeatureCollection(featureCollection, filename) {
const features = [];
for (const feature of featureCollection.features) {
switch (feature.geometry.type) {
@ -65,26 +76,13 @@ export function normalizeFeatureCollection(featureCollection) {
}
}
featureCollection.features = features;
featureCollection.filename = `${generateId()}-${filename}`;
featureCollection.features = features.map((feature) => ({
...feature,
properties: {
...feature.properties,
filename: featureCollection.filename
}
}));
return featureCollection;
}
export function readGeoFile(file) {
const isGpxFile = file.name.includes('.gpx');
const reader = new FileReader();
return new Promise((resolve) => {
reader.onload = (event) => {
const xml = new DOMParser().parseFromString(
event.target.result,
'text/xml'
);
const featureCollection = normalizeFeatureCollection(
isGpxFile ? gpx(xml) : kml(xml)
);
resolve(featureCollection);
};
reader.readAsText(file, 'UTF-8');
});
}

View file

@ -0,0 +1,553 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import mapboxgl from 'mapbox-gl';
import { getJSON, ajax, fire } from '@utils';
import { readGeoFile } from './readGeoFile';
import {
filterFeatureCollection,
generateId,
findFeature,
getBounds,
defer
} from '../shared/mapbox/utils';
const SOURCE_SELECTION_UTILISATEUR = 'selection_utilisateur';
const SOURCE_CADASTRE = 'cadastre';
export function useMapboxEditor(
featureCollection,
{ url, enabled = true, cadastreEnabled = true }
) {
const [isLoaded, setLoaded] = useState(false);
const mapRef = useRef();
const drawRef = useRef();
const loadedRef = useRef(defer());
const selectedCadastresRef = useRef(() => new Set());
const isSupported = useMemo(() => mapboxgl.supported());
useEffect(() => {
const translations = [
['.mapbox-gl-draw_line', 'Tracer une ligne'],
['.mapbox-gl-draw_polygon', 'Dessiner un polygone'],
['.mapbox-gl-draw_point', 'Ajouter un point'],
['.mapbox-gl-draw_trash', 'Supprimer']
];
for (const [selector, translation] of translations) {
const element = document.querySelector(selector);
if (element) {
element.setAttribute('title', translation);
}
}
}, [isLoaded]);
const addEventListener = useCallback((eventName, target, callback) => {
loadedRef.current.promise.then(() => {
mapRef.current.on(eventName, target, callback);
});
return () => {
if (mapRef.current) {
mapRef.current.off(eventName, target, callback);
}
};
}, []);
const highlightFeature = useCallback((cid, highlight) => {
if (highlight) {
selectedCadastresRef.current.add(cid);
} else {
selectedCadastresRef.current.delete(cid);
}
if (selectedCadastresRef.current.size == 0) {
mapRef.current.setFilter('parcelle-highlighted', ['in', 'id', '']);
} else {
mapRef.current.setFilter('parcelle-highlighted', [
'in',
'id',
...selectedCadastresRef.current
]);
}
}, []);
const fitBounds = useCallback((bbox) => {
mapRef.current.fitBounds(bbox, { padding: 100 });
}, []);
const hoverFeature = useCallback((feature, hover) => {
if (!selectedCadastresRef.current.has(feature.properties.id)) {
mapRef.current.setFeatureState(
{
source: 'cadastre',
sourceLayer: 'parcelles',
id: feature.id
},
{ hover }
);
}
}, []);
const addFeatures = useCallback((features, external) => {
for (const feature of features) {
if (feature.lid) {
drawRef.current?.draw.setFeatureProperty(
feature.lid,
'id',
feature.properties.id
);
delete feature.lid;
}
if (external) {
if (feature.properties.source == SOURCE_SELECTION_UTILISATEUR) {
drawRef.current?.draw.add({ id: feature.properties.id, ...feature });
} else {
highlightFeature(feature.properties.cid, true);
}
}
}
}, []);
const removeFeatures = useCallback((features, external) => {
if (external) {
for (const feature of features) {
if (feature.properties.source == SOURCE_SELECTION_UTILISATEUR) {
drawRef.current?.draw.delete(feature.id);
} else {
highlightFeature(feature.properties.cid, false);
}
}
}
}, []);
const onLoad = useCallback(
(map) => {
if (!mapRef.current) {
mapRef.current = map;
mapRef.current.fitBounds(props.featureCollection.bbox, {
padding: 100
});
onStyleChange();
setLoaded(true);
loadedRef.current.resolve();
}
},
[featureCollection]
);
const addEventListeners = useCallback((events) => {
const unsubscribe = Object.entries(
events
).map(([eventName, [target, callback]]) =>
addEventListener(eventName, target, callback)
);
return () => unsubscribe.map((unsubscribe) => unsubscribe());
}, []);
const {
createFeatures,
updateFeatures,
deleteFeatures,
...props
} = useFeatureCollection(featureCollection, {
url,
enabled: isSupported && enabled,
addFeatures,
removeFeatures
});
const onStyleChange = useCallback(() => {
if (mapRef.current) {
const featureCollection = props.featureCollection;
if (!cadastreEnabled) {
drawRef.current?.draw.set(
filterFeatureCollection(
featureCollection,
SOURCE_SELECTION_UTILISATEUR
)
);
}
selectedCadastresRef.current = new Set(
filterFeatureCollection(
featureCollection,
SOURCE_CADASTRE
).features.map(({ properties }) => properties.cid)
);
if (selectedCadastresRef.current.size > 0) {
mapRef.current.setFilter('parcelle-highlighted', [
'in',
'id',
...selectedCadastresRef.current
]);
}
}
}, [props.featureCollection, cadastreEnabled]);
useExternalEvents(props.featureCollection, {
fitBounds,
createFeatures,
updateFeatures,
deleteFeatures
});
useCadastres(props.featureCollection, {
addEventListeners,
hoverFeature,
createFeatures,
deleteFeatures,
enabled: cadastreEnabled
});
return {
isSupported,
onLoad,
onStyleChange,
drawRef,
createFeatures,
updateFeatures,
deleteFeatures,
...props,
...useImportFiles(props.featureCollection, {
createFeatures,
deleteFeatures
})
};
}
function useFeatureCollection(
initialFeatureCollection,
{ url, addFeatures, removeFeatures, enabled = true }
) {
const [error, onError] = useError();
const [featureCollection, setFeatureCollection] = useState(
initialFeatureCollection
);
const updateFeatureCollection = useCallback(
(callback) => {
setFeatureCollection(({ features }) => ({
type: 'FeatureCollection',
features: callback(features)
}));
ajax({ url, type: 'GET' })
.then(() => fire(document, 'ds:page:update'))
.catch(() => {});
},
[setFeatureCollection]
);
const createFeatures = useCallback(
async ({ features, source = SOURCE_SELECTION_UTILISATEUR, external }) => {
if (!enabled) {
return;
}
try {
const newFeatures = [];
for (const feature of features) {
const data = await getJSON(url, { feature, source }, 'post');
if (data) {
if (source == SOURCE_SELECTION_UTILISATEUR) {
data.feature.lid = feature.id;
}
newFeatures.push(data.feature);
}
}
addFeatures(newFeatures, external);
updateFeatureCollection(
(features) => [...features, ...newFeatures],
external
);
} catch (error) {
console.error(error);
onError('Le polygone dessiné nest pas valide.');
}
},
[enabled, url, updateFeatureCollection, addFeatures]
);
const updateFeatures = useCallback(
async ({ features, source = SOURCE_SELECTION_UTILISATEUR, external }) => {
if (!enabled) {
return;
}
try {
const newFeatures = [];
for (const feature of features) {
const { id } = feature.properties;
if (id) {
await getJSON(`${url}/${id}`, { feature }, 'patch');
} else {
const data = await getJSON(url, { feature, source }, 'post');
if (data) {
if (source == SOURCE_SELECTION_UTILISATEUR) {
data.feature.lid = feature.id;
}
newFeatures.push(data.feature);
}
}
}
if (newFeatures.length > 0) {
addFeatures(newFeatures, external);
updateFeatureCollection((features) => [...features, ...newFeatures]);
}
} catch (error) {
console.error(error);
onError('Le polygone dessiné nest pas valide.');
}
},
[enabled, url, updateFeatureCollection, addFeatures]
);
const deleteFeatures = useCallback(
async ({ features, external }) => {
if (!enabled) {
return;
}
try {
const deletedFeatures = [];
for (const feature of features) {
const { id } = feature.properties;
await getJSON(`${url}/${id}`, null, 'delete');
deletedFeatures.push(feature);
}
removeFeatures(deletedFeatures, external);
const deletedFeatureIds = deletedFeatures.map(
({ properties }) => properties.id
);
updateFeatureCollection(
(features) =>
features.filter(
({ properties }) => !deletedFeatureIds.includes(properties.id)
),
external
);
} catch (error) {
console.error(error);
onError('Le polygone na pas pu être supprimé.');
}
},
[enabled, url, updateFeatureCollection, removeFeatures]
);
return {
featureCollection,
createFeatures,
updateFeatures,
deleteFeatures,
error
};
}
function useImportFiles(featureCollection, { createFeatures, deleteFeatures }) {
const [inputs, setInputs] = useState([]);
const addInput = useCallback(
(input) => {
setInputs((inputs) => [...inputs, input]);
},
[setInputs]
);
const removeInput = useCallback(
(inputId) => {
setInputs((inputs) => inputs.filter((input) => input.id !== inputId));
},
[setInputs]
);
const onFileChange = useCallback(
async (event, inputId) => {
const { features, filename } = await readGeoFile(event.target.files[0]);
createFeatures({ features, external: true });
setInputs((inputs) => {
return inputs.map((input) => {
if (input.id === inputId) {
return { ...input, disabled: true, hasValue: true, filename };
}
return input;
});
});
},
[setInputs, createFeatures, featureCollection]
);
const addInputFile = useCallback(
(event) => {
event.preventDefault();
addInput({
id: generateId(),
disabled: false,
hasValue: false,
filename: ''
});
},
[addInput]
);
const removeInputFile = useCallback(
(event, inputId) => {
event.preventDefault();
const { filename } = inputs.find((input) => input.id === inputId);
const features = featureCollection.features.filter(
(feature) => feature.properties.filename == filename
);
deleteFeatures({ features, external: true });
removeInput(inputId);
},
[removeInput, deleteFeatures, featureCollection]
);
return {
inputs,
onFileChange,
addInputFile,
removeInputFile
};
}
function useExternalEvents(
featureCollection,
{ fitBounds, createFeatures, updateFeatures, deleteFeatures }
) {
const onFeatureFocus = useCallback(
({ detail }) => {
const { id, bbox } = detail;
if (id) {
const feature = findFeature(featureCollection, id);
if (feature) {
fitBounds(getBounds(feature.geometry));
}
} else if (bbox) {
fitBounds(bbox);
}
},
[featureCollection, fitBounds]
);
const onFeatureCreate = useCallback(
({ detail }) => {
const { geometry, properties } = detail;
if (geometry) {
createFeatures({
features: [{ geometry, properties }],
external: true
});
}
},
[createFeatures]
);
const onFeatureUpdate = useCallback(
({ detail }) => {
const { id, properties } = detail;
const feature = findFeature(featureCollection, id);
if (feature) {
feature.properties = { ...feature.properties, ...properties };
updateFeatures({ features: [feature], external: true });
}
},
[featureCollection, updateFeatures]
);
const onFeatureDelete = useCallback(
({ detail }) => {
const { id } = detail;
const feature = findFeature(featureCollection, id);
if (feature) {
deleteFeatures({ features: [feature], external: true });
}
},
[featureCollection, deleteFeatures]
);
useEvent('map:feature:focus', onFeatureFocus);
useEvent('map:feature:create', onFeatureCreate);
useEvent('map:feature:update', onFeatureUpdate);
useEvent('map:feature:delete', onFeatureDelete);
}
function useCadastres(
featureCollection,
{
addEventListeners,
hoverFeature,
createFeatures,
deleteFeatures,
enabled = true
}
) {
const hoveredFeature = useRef();
const onMouseMove = useCallback(
(event) => {
if (event.features.length > 0) {
const feature = event.features[0];
if (hoveredFeature.current?.id != feature.id) {
if (hoveredFeature.current) {
hoverFeature(hoveredFeature.current, false);
}
hoveredFeature.current = feature;
hoverFeature(feature, true);
}
}
},
[hoverFeature]
);
const onMouseLeave = useCallback(() => {
if (hoveredFeature.current) {
hoverFeature(hoveredFeature.current, false);
}
hoveredFeature.current = null;
}, [hoverFeature]);
const onClick = useCallback(
async (event) => {
if (event.features.length > 0) {
const currentId = event.features[0].properties.id;
const feature = findFeature(
filterFeatureCollection(featureCollection, SOURCE_CADASTRE),
currentId,
'cid'
);
if (feature) {
deleteFeatures({
features: [feature],
source: SOURCE_CADASTRE,
external: true
});
} else {
createFeatures({
features: event.features,
source: SOURCE_CADASTRE,
external: true
});
}
}
},
[featureCollection, createFeatures, deleteFeatures]
);
useEffect(() => {
if (enabled) {
return addEventListeners({
click: ['parcelles-fill', onClick],
mousemove: ['parcelles-fill', onMouseMove],
mouseleave: ['parcelles-fill', onMouseLeave]
});
}
}, [onClick, onMouseMove, onMouseLeave, enabled]);
}
function useError() {
const [error, onError] = useState();
useEffect(() => {
const timer = setTimeout(() => onError(null), 5000);
return () => clearTimeout(timer);
}, [error]);
return [error, onError];
}
export function useEvent(eventName, callback) {
return useEffect(() => {
addEventListener(eventName, callback);
return () => removeEventListener(eventName, callback);
}, [eventName, callback]);
}

View file

@ -1,141 +1,28 @@
import React, { useState, useCallback, useMemo } from 'react';
import { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl';
import mapboxgl, { Popup } from 'mapbox-gl';
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 Mapbox from '../shared/mapbox/Mapbox';
import { getMapStyle } from '../shared/mapbox/styles';
import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle';
import MapStyleControl, { useMapStyle } from '../shared/mapbox/MapStyleControl';
import {
filterFeatureCollection,
filterFeatureCollectionByGeometryType,
useEvent,
findFeature,
fitBounds,
getCenter
filterFeatureCollectionByGeometryType
} from '../shared/mapbox/utils';
import { useMapbox } from './useMapbox';
const Mapbox = ReactMapboxGl({});
const MapReader = ({ featureCollection, options }) => {
const [currentMap, setCurrentMap] = useState(null);
const [style, setStyle] = useState('ortho');
const cadastresFeatureCollection = useMemo(
() => filterFeatureCollection(featureCollection, 'cadastre'),
[featureCollection]
);
const selectionsUtilisateurFeatureCollection = useMemo(
() => filterFeatureCollection(featureCollection, 'selection_utilisateur'),
[featureCollection]
);
const selectionsLineFeatureCollection = useMemo(
() =>
filterFeatureCollectionByGeometryType(
selectionsUtilisateurFeatureCollection,
'LineString'
),
[selectionsUtilisateurFeatureCollection]
);
const selectionsPolygonFeatureCollection = useMemo(
() =>
filterFeatureCollectionByGeometryType(
selectionsUtilisateurFeatureCollection,
'Polygon'
),
[selectionsUtilisateurFeatureCollection]
);
const selectionsPointFeatureCollection = useMemo(
() =>
filterFeatureCollectionByGeometryType(
selectionsUtilisateurFeatureCollection,
'Point'
),
[selectionsUtilisateurFeatureCollection]
);
const hasCadastres = useMemo(() => options.layers.includes('cadastres'));
const mapStyle = useMemo(() => getMapStyle(style, options.layers), [
style,
options
]);
const popup = useMemo(
() =>
new Popup({
closeButton: false,
closeOnClick: false
})
);
const {
isSupported,
onLoad,
onStyleChange,
onMouseEnter,
onMouseLeave
} = useMapbox(featureCollection);
const [style, setStyle] = useMapStyle(options.layers, { onStyleChange });
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;
currentMap.getCanvas().style.cursor = 'pointer';
popup.setLngLat(coordinates).setHTML(description).addTo(currentMap);
} else {
popup.remove();
}
},
[currentMap, popup]
);
const onMouseLeave = useCallback(() => {
currentMap.getCanvas().style.cursor = '';
popup.remove();
}, [currentMap, popup]);
const onFeatureFocus = useCallback(
({ detail }) => {
const feature = findFeature(featureCollection, detail.id);
if (feature) {
fitBounds(currentMap, feature);
}
},
[currentMap, featureCollection]
);
useEvent('map:feature:focus', onFeatureFocus);
const [a1, a2, b1, b2] = featureCollection.bbox;
const boundData = [
[a1, a2],
[b1, b2]
];
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'
};
const polygonCadastresFill = {
'fill-color': '#FAD859',
'fill-opacity': 0.5
};
const polygonCadastresLine = {
'line-color': 'rgba(156, 160, 144, 255)',
'line-width': 2,
'line-dasharray': [1, 1]
};
function onMapLoad(map) {
setCurrentMap(map);
}
if (!mapboxgl.supported()) {
if (!isSupported) {
return (
<p>
Nous ne pouvons pas afficher la carte car elle est imcompatible avec
@ -147,58 +34,155 @@ const MapReader = ({ featureCollection, options }) => {
return (
<Mapbox
onStyleLoad={(map) => onMapLoad(map)}
fitBounds={boundData}
fitBoundsOptions={{ padding: 100 }}
style={mapStyle}
containerStyle={{
height: '400px',
width: '100%'
}}
onStyleLoad={(map) => onLoad(map)}
style={style}
containerStyle={{ height: '400px' }}
>
<GeoJSONLayer
data={selectionsPolygonFeatureCollection}
fillPaint={polygonSelectionFill}
linePaint={polygonSelectionLine}
fillOnMouseEnter={onMouseEnter}
fillOnMouseLeave={onMouseLeave}
<SelectionUtilisateurPolygonLayer
featureCollection={featureCollection}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
<GeoJSONLayer
data={selectionsLineFeatureCollection}
linePaint={lineStringSelectionLine}
lineOnMouseEnter={onMouseEnter}
lineOnMouseLeave={onMouseLeave}
<SelectionUtilisateurLineLayer
featureCollection={featureCollection}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
<GeoJSONLayer
data={selectionsPointFeatureCollection}
circlePaint={pointSelectionFill}
circleOnMouseEnter={onMouseEnter}
circleOnMouseLeave={onMouseLeave}
<SelectionUtilisateurPointLayer
featureCollection={featureCollection}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
{hasCadastres ? (
<GeoJSONLayer
data={cadastresFeatureCollection}
fillPaint={polygonCadastresFill}
linePaint={polygonCadastresLine}
/>
) : null}
<SwitchMapStyle style={style} setStyle={setStyle} ign={options.ign} />
<MapStyleControl style={style.id} setStyle={setStyle} />
<ZoomControl />
</Mapbox>
);
};
MapReader.propTypes = {
const polygonSelectionFill = {
'fill-color': '#EC3323',
'fill-opacity': 0.5
};
const polygonSelectionLine = {
'line-color': 'rgba(255, 0, 0, 1)',
'line-width': 4
};
const lineStringSelectionLine = {
'line-color': 'rgba(55, 42, 127, 1.00)',
'line-width': 3
};
const pointSelectionFill = {
'circle-color': '#EC3323'
};
function SelectionUtilisateurPolygonLayer({
featureCollection,
onMouseEnter,
onMouseLeave
}) {
const data = useMemo(
() =>
filterFeatureCollectionByGeometryType(
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
'Polygon'
),
[featureCollection]
);
return (
<GeoJSONLayer
data={data}
fillPaint={polygonSelectionFill}
linePaint={polygonSelectionLine}
fillOnMouseEnter={onMouseEnter}
fillOnMouseLeave={onMouseLeave}
/>
);
}
function SelectionUtilisateurLineLayer({
featureCollection,
onMouseEnter,
onMouseLeave
}) {
const data = useMemo(
() =>
filterFeatureCollectionByGeometryType(
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
'LineString'
),
[featureCollection]
);
return (
<GeoJSONLayer
data={data}
linePaint={lineStringSelectionLine}
lineOnMouseEnter={onMouseEnter}
lineOnMouseLeave={onMouseLeave}
/>
);
}
function SelectionUtilisateurPointLayer({
featureCollection,
onMouseEnter,
onMouseLeave
}) {
const data = useMemo(
() =>
filterFeatureCollectionByGeometryType(
filterFeatureCollection(featureCollection, 'selection_utilisateur'),
'Point'
),
[featureCollection]
);
return (
<GeoJSONLayer
data={data}
circlePaint={pointSelectionFill}
circleOnMouseEnter={onMouseEnter}
circleOnMouseLeave={onMouseLeave}
/>
);
}
SelectionUtilisateurPolygonLayer.propTypes = {
featureCollection: PropTypes.shape({
type: PropTypes.string,
bbox: PropTypes.array,
features: PropTypes.array
}),
options: PropTypes.shape({
layers: PropTypes.array,
ign: PropTypes.bool
})
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func
};
SelectionUtilisateurLineLayer.propTypes = {
featureCollection: PropTypes.shape({
type: PropTypes.string,
bbox: PropTypes.array,
features: PropTypes.array
}),
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func
};
SelectionUtilisateurPointLayer.propTypes = {
featureCollection: PropTypes.shape({
type: PropTypes.string,
bbox: PropTypes.array,
features: PropTypes.array
}),
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func
};
MapReader.propTypes = {
featureCollection: PropTypes.shape({
bbox: PropTypes.array,
features: PropTypes.array
}),
options: PropTypes.shape({ layers: PropTypes.array })
};
export default MapReader;

View file

@ -0,0 +1,104 @@
import { useCallback, useRef, useEffect, useMemo } from 'react';
import mapboxgl, { Popup } from 'mapbox-gl';
import {
filterFeatureCollection,
findFeature,
getBounds,
getCenter
} from '../shared/mapbox/utils';
const SOURCE_CADASTRE = 'cadastre';
export function useMapbox(featureCollection) {
const mapRef = useRef();
const selectedCadastresRef = useRef(() => new Set());
const isSupported = useMemo(() => mapboxgl.supported());
const fitBounds = useCallback((bbox) => {
mapRef.current.fitBounds(bbox, { padding: 100 });
}, []);
const onLoad = useCallback(
(map) => {
if (!mapRef.current) {
mapRef.current = map;
mapRef.current.fitBounds(featureCollection.bbox, { padding: 100 });
onStyleChange();
}
},
[featureCollection]
);
const onStyleChange = useCallback(() => {
if (mapRef.current) {
selectedCadastresRef.current = new Set(
filterFeatureCollection(
featureCollection,
SOURCE_CADASTRE
).features.map(({ properties }) => properties.cid)
);
if (selectedCadastresRef.current.size > 0) {
mapRef.current.setFilter('parcelle-highlighted', [
'in',
'id',
...selectedCadastresRef.current
]);
}
}
}, [featureCollection]);
const popup = useMemo(
() =>
new Popup({
closeButton: false,
closeOnClick: false
})
);
const onMouseEnter = useCallback(
(event) => {
const feature = event.features[0];
if (feature.properties && feature.properties.description) {
const coordinates = getCenter(feature.geometry, event.lngLat);
const description = feature.properties.description;
mapRef.current.getCanvas().style.cursor = 'pointer';
popup.setLngLat(coordinates).setHTML(description).addTo(mapRef.current);
} else {
popup.remove();
}
},
[popup]
);
const onMouseLeave = useCallback(() => {
mapRef.current.getCanvas().style.cursor = '';
popup.remove();
}, [popup]);
useExternalEvents(featureCollection, { fitBounds });
return { isSupported, onLoad, onStyleChange, onMouseEnter, onMouseLeave };
}
function useExternalEvents(featureCollection, { fitBounds }) {
const onFeatureFocus = useCallback(
({ detail }) => {
const { id } = detail;
const feature = findFeature(featureCollection, id);
if (feature) {
fitBounds(getBounds(feature.geometry));
}
},
[featureCollection, fitBounds]
);
useEvent('map:feature:focus', onFeatureFocus);
}
export function useEvent(eventName, callback) {
return useEffect(() => {
addEventListener(eventName, callback);
return () => removeEventListener(eventName, callback);
}, [eventName, callback]);
}

View file

@ -0,0 +1,69 @@
import React, { useMemo, useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { getMapStyle } from './styles';
import ortho from './styles/images/preview-ortho.png';
import vector from './styles/images/preview-vector.png';
const STYLES = {
ortho: {
title: 'Satellite',
preview: ortho,
color: '#fff'
},
vector: {
title: 'Vectoriel',
preview: vector,
color: '#000'
},
ign: {
title: 'Carte IGN',
preview: vector,
color: '#000'
}
};
function getNextStyle(style) {
const styleNames = Object.keys(STYLES);
const index = styleNames.indexOf(style) + 1;
if (index === styleNames.length) {
return styleNames[0];
}
return styleNames[index];
}
export function useMapStyle(
optionalLayers,
{ onStyleChange, cadastreEnabled }
) {
const [styleId, setStyle] = useState('ortho');
const style = useMemo(() => getMapStyle(styleId, optionalLayers), [
styleId,
optionalLayers
]);
useEffect(() => onStyleChange(), [styleId, cadastreEnabled]);
return [style, setStyle];
}
function MapStyleControl({ style, setStyle }) {
const nextStyle = getNextStyle(style);
const { title, preview, color } = STYLES[nextStyle];
return (
<div className="map-style-control">
<button type="button" onClick={() => setStyle(nextStyle)}>
<img alt={title} src={preview} />
<div style={{ color }}>{title}</div>
</button>
</div>
);
}
MapStyleControl.propTypes = {
style: PropTypes.string,
setStyle: PropTypes.func
};
export default MapStyleControl;

View file

@ -1,3 +0,0 @@
import ReactMapboxGl from 'react-mapbox-gl';
export default ReactMapboxGl({});

View file

@ -1,81 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ortho from './styles/images/preview-ortho.png';
import vector from './styles/images/preview-vector.png';
const STYLES = {
ortho: {
title: 'Satellite',
preview: ortho,
color: '#fff'
},
vector: {
title: 'Vectoriel',
preview: vector,
color: '#000'
}
};
const IGN_STYLES = {
...STYLES,
ign: {
title: 'Carte IGN',
preview: vector,
color: '#000'
}
};
function getNextStyle(style, ign) {
const styles = Object.keys(ign ? IGN_STYLES : STYLES);
let index = styles.indexOf(style) + 1;
if (index === styles.length) {
return styles[0];
}
return styles[index];
}
function SwitchMapStyle({ style, setStyle, ign }) {
const nextStyle = getNextStyle(style, ign);
const { title, preview, color } = (ign ? IGN_STYLES : STYLES)[nextStyle];
const imgStyle = {
width: '100%',
height: '100%',
cursor: 'pointer'
};
const textStyle = {
position: 'relative',
bottom: '26px',
left: '4px',
color
};
return (
<div
className="style-switch"
style={{
position: 'absolute',
bottom: 0,
left: 0
}}
onClick={() => setStyle(nextStyle)}
>
<div className="switch-style mapboxgl-ctrl-swith-map-style">
<img alt={title} style={imgStyle} src={preview} />
<div className="text" style={textStyle}>
{title}
</div>
</div>
</div>
);
}
SwitchMapStyle.propTypes = {
style: PropTypes.string,
setStyle: PropTypes.func,
ign: PropTypes.bool
};
export default SwitchMapStyle;

View file

@ -1,4 +1,4 @@
import cadastreLayers from './cadastre-layers';
import cadastreLayers from './layers/cadastre';
const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk';
@ -138,7 +138,16 @@ function rasterSource(tiles, attribution) {
};
}
export function buildLayers(ids) {
function rasterLayer(source) {
return {
id: source,
source,
type: 'raster',
paint: { 'raster-resampling': 'linear' }
};
}
export function buildOptionalLayers(ids) {
return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id))
.flatMap(({ layers }) => layers)
.flatMap(([, code]) =>
@ -148,15 +157,6 @@ export function buildLayers(ids) {
);
}
export function rasterLayer(source) {
return {
id: source,
source,
type: 'raster',
paint: { 'raster-resampling': 'linear' }
};
}
export default {
version: 8,
metadat: {

View file

@ -1,29 +1,27 @@
import baseStyle, { rasterLayer, buildLayers } from './base';
import orthoStyle from './ortho-style';
import vectorStyle from './vector-style';
import baseStyle, { buildOptionalLayers } from './base';
import orthoStyle from './layers/ortho';
import vectorStyle from './layers/vector';
import ignLayers from './layers/ign';
export function getMapStyle(style, optionalLayers) {
const mapStyle = { ...baseStyle };
export function getMapStyle(id, optionalLayers) {
const style = { ...baseStyle, id };
switch (style) {
switch (id) {
case 'ortho':
mapStyle.layers = orthoStyle;
mapStyle.id = 'ortho';
mapStyle.name = 'Photographies aériennes';
style.layers = orthoStyle;
style.name = 'Photographies aériennes';
break;
case 'vector':
mapStyle.layers = vectorStyle;
mapStyle.id = 'vector';
mapStyle.name = 'Carte OSM';
style.layers = vectorStyle;
style.name = 'Carte OSM';
break;
case 'ign':
mapStyle.layers = [rasterLayer('plan-ign')];
mapStyle.id = 'ign';
mapStyle.name = 'Carte IGN';
style.layers = ignLayers;
style.name = 'Carte IGN';
break;
}
mapStyle.layers = mapStyle.layers.concat(buildLayers(optionalLayers));
style.layers = style.layers.concat(buildOptionalLayers(optionalLayers));
return mapStyle;
return style;
}

View file

@ -82,7 +82,7 @@ export default [
type: 'fill',
source: 'cadastre',
'source-layer': 'parcelles',
filter: ['==', 'id', ''],
filter: ['in', 'id', ''],
paint: {
'fill-color': 'rgba(1, 129, 0, 1)',
'fill-opacity': 0.7

View file

@ -0,0 +1,8 @@
export default [
{
id: 'ign',
source: 'plan-ign',
type: 'raster',
paint: { 'raster-resampling': 'linear' }
}
];

View file

@ -1,5 +1,4 @@
import { LngLatBounds } from 'mapbox-gl';
import { useEffect } from 'react';
export function getBounds(geometry) {
const bbox = new LngLatBounds();
@ -18,15 +17,9 @@ export function getBounds(geometry) {
return bbox;
}
export function fitBounds(map, feature) {
if (map) {
map.fitBounds(getBounds(feature.geometry), { padding: 100 });
}
}
export function findFeature(featureCollection, id) {
export function findFeature(featureCollection, value, property = 'id') {
return featureCollection.features.find(
(feature) => feature.properties.id === id
(feature) => feature.properties[property] === value
);
}
@ -48,19 +41,10 @@ export function filterFeatureCollectionByGeometryType(featureCollection, type) {
};
}
export function noop() {}
export function generateId() {
return Math.random().toString(20).substr(2, 6);
}
export function useEvent(eventName, callback) {
return useEffect(() => {
addEventListener(eventName, callback);
return () => removeEventListener(eventName, callback);
}, [eventName, callback]);
}
export function getCenter(geometry, lngLat) {
const bbox = new LngLatBounds();
@ -76,3 +60,13 @@ export function getCenter(geometry, lngLat) {
return bbox.getCenter();
}
}
export function defer() {
const deferred = {};
const promise = new Promise(function (resolve, reject) {
deferred.resolve = resolve;
deferred.reject = reject;
});
deferred.promise = promise;
return deferred;
}

View file

@ -1,23 +0,0 @@
class APICarto::API
class ResourceNotFound < StandardError
end
def self.search_cadastre(geojson)
url = [API_CARTO_URL, "cadastre", "geometrie"].join("/")
call(url, geojson)
end
private
def self.call(url, geojson)
response = Typhoeus.post(url, body: geojson.to_s, headers: { 'content-type' => 'application/json' })
if response.success?
response.body
else
message = response.code == 0 ? response.return_message : response.code.to_s
Rails.logger.error "[APICarto] Error on #{url}: #{message}"
raise ResourceNotFound
end
end
end

View file

@ -1,29 +0,0 @@
class APICarto::CadastreAdapter
def initialize(coordinates)
@coordinates = GeojsonService.to_json_polygon_for_cadastre(coordinates)
end
def data_source
@data_source ||= JSON.parse(APICarto::API.search_cadastre(@coordinates), symbolize_names: true)
end
def results
data_source[:features].map do |feature|
filter_properties(feature[:properties]).merge({ geometry: feature[:geometry] })
end
end
def filter_properties(properties)
properties.slice(
:surface_intersection,
:surface_parcelle,
:numero,
:feuille,
:section,
:code_dep,
:nom_com,
:code_com,
:code_arr
)
end
end

View file

@ -62,10 +62,7 @@ class Champs::CarteChamp < Champ
end
def render_options
{
ign: Flipper.enabled?(:carte_ign, procedure),
layers: optional_layers
}
{ layers: optional_layers }
end
def position

View file

@ -14,25 +14,33 @@
class GeoArea < ApplicationRecord
belongs_to :champ, optional: false
store :properties, accessors: [
:description,
:surface_intersection,
:surface_parcelle,
:numero,
:feuille,
:section,
:code_dep,
:nom_com,
:code_com,
:code_arr,
:code,
:nom,
:commune,
:culture,
:code_culture,
:surface,
:bio
]
# FIXME: once geo_areas are migrated to not use YAML serialization we can enable store_accessor
# store_accessor :properties, :description, :numero, :section
def properties
value = read_attribute(:properties)
if value.is_a? String
ActiveRecord::Coders::YAMLColumn.new(:properties).load(value)
else
value
end
end
def description
properties['description']
end
def numero
properties['numero']
end
def section
properties['section']
end
def filename
properties['filename']
end
enum source: {
cadastre: 'cadastre',
@ -48,10 +56,12 @@ class GeoArea < ApplicationRecord
{
type: 'Feature',
geometry: safe_geometry,
properties: properties.symbolize_keys.merge(
properties: cadastre_properties.merge(
source: source,
area: area,
length: length,
description: description,
filename: filename,
id: id,
champ_id: champ.stable_id,
dossier_id: champ.dossier_id
@ -69,16 +79,6 @@ class GeoArea < ApplicationRecord
nil
end
def self.from_feature_collection(feature_collection)
feature_collection[:features].map do |feature|
GeoArea.new(
source: feature[:properties].delete(:source),
properties: feature[:properties],
geometry: feature[:geometry]
)
end
end
def area
if polygon?
GeojsonService.area(geometry.deep_symbolize_keys).round(1)
@ -108,4 +108,107 @@ class GeoArea < ApplicationRecord
def point?
geometry['type'] == 'Point'
end
def legacy_cadastre?
cadastre? && properties['surface_intersection'].present?
end
def cadastre?
source == GeoArea.sources.fetch(:cadastre)
end
def cadastre_properties
if cadastre?
{
cid: cid,
numero: numero,
section: section,
prefixe: prefixe,
commune: commune,
surface: surface
}
else
{}
end
end
def code_dep
if legacy_cadastre?
properties['code_dep']
else
properties['commune'][0..1]
end
end
def code_com
if legacy_cadastre?
properties['code_com']
else
properties['commune'][2...commune.size]
end
end
def nom_com
if legacy_cadastre?
properties['nom_com']
else
''
end
end
def surface_intersection
if legacy_cadastre?
properties['surface_intersection']
else
''
end
end
def feuille
if legacy_cadastre?
properties['feuille']
else
1
end
end
def code_arr
prefixe
end
def surface_parcelle
surface
end
def surface
if legacy_cadastre?
properties['surface_parcelle']
else
properties['contenance']
end
end
def prefixe
if legacy_cadastre?
properties['code_arr']
else
properties['prefixe']
end
end
def commune
if legacy_cadastre?
"#{properties['code_dep']}#{properties['code_com']}"
else
properties['commune']
end
end
def cid
if legacy_cadastre?
"#{code_dep}#{code_com}#{code_arr}#{section}#{numero}"
else
properties['id']
end
end
end

View file

@ -1,25 +0,0 @@
class APICartoService
def self.generate_qp(coordinates)
coordinates.flat_map do |coordinate|
APICarto::QuartiersPrioritairesAdapter.new(
coordinate.map { |element| [element['lng'], element['lat']] }
).results
end
end
def self.generate_cadastre(coordinates)
coordinates.flat_map do |coordinate|
APICarto::CadastreAdapter.new(
coordinate.map { |element| [element['lng'], element['lat']] }
).results
end
end
def self.generate_rpg(coordinates)
coordinates.flat_map do |coordinate|
ApiGeo::RPGAdapter.new(
coordinate.map { |element| [element['lng'], element['lat']] }
).results
end
end
end

View file

@ -4,6 +4,6 @@
partial: 'shared/champs/carte/geo_areas',
locals: { champ: @champ, editing: true }) %>
<% if @update_cadastres %>
<%= fire_event('cadastres:update', { featureCollection: @champ.to_feature_collection }.to_json) %>
<% if @focus %>
<%= fire_event('map:feature:focus', { bbox: @champ.bounding_box }.to_json) %>
<% end %>

View file

@ -7,7 +7,7 @@
- if editing
= link_to '#', data: { geo_area: geo_area.id } do
= geo_area_label(geo_area)
= text_field_tag :description, geo_area.description, data: { geo_area: geo_area.id }, placeholder: 'Description de la sélection'
= text_field_tag :description, geo_area.description, data: { geo_area: geo_area.id }, placeholder: 'Description de la sélection', class: 'no-margin'
- else
= link_to '#', data: { geo_area: geo_area.id } do
= geo_area_label(geo_area)

View file

@ -27,7 +27,6 @@ end
features = [
:administrateur_routage,
:administrateur_web_hook,
:carte_ign,
:dossier_pdf_vide,
:expert_not_allowed_to_invite,
:hide_instructeur_email,

View file

@ -1,6 +1,5 @@
# rubocop:disable DS/ApplicationName
# API URLs
API_CARTO_URL = ENV.fetch("API_CARTO_URL", "https://sandbox.geo.api.gouv.fr/apicarto")
API_ENTREPRISE_URL = ENV.fetch("API_ENTREPRISE_URL", "https://entreprise.api.gouv.fr/v2")
API_EDUCATION_URL = ENV.fetch("API_EDUCATION_URL", "https://data.education.gouv.fr/api/records/1.0")
HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2")

View file

@ -0,0 +1,22 @@
namespace :after_party do
desc 'Deployment task: use_jsonb_in_geo_areas_properties'
task use_jsonb_in_geo_areas_properties: :environment do
puts "Running deploy task 'use_jsonb_in_geo_areas_properties'"
geo_areas = GeoArea.where("properties::text LIKE ?", "%--- !ruby%")
progress = ProgressReport.new(geo_areas.count)
geo_areas.find_each do |geo_area|
geo_area.properties = geo_area.properties
if !geo_area.save
geo_area.destroy
end
progress.inc
end
progress.finish
# Update task as completed. If you remove the line below, the task will
# run with every deploy (or every time you call after_party:run).
AfterParty::TaskRecord
.create version: AfterParty::TaskRecorder.new(__FILE__).timestamp
end
end

View file

@ -25,7 +25,8 @@ describe Champs::CarteController, type: :controller do
let(:params) do
{
champ_id: champ.id,
feature: feature
feature: feature,
source: GeoArea.sources.fetch(:selection_utilisateur)
}
end
@ -98,67 +99,37 @@ describe Champs::CarteController, type: :controller do
it { expect(response.status).to eq 204 }
end
describe 'POST #import' do
describe 'GET #index' do
render_views
let(:params) do
{
champ_id: champ.id,
features: [feature]
}
{ champ_id: champ.id }
end
before do
post :import, params: params
end
it {
expect(response.status).to eq 201
expect(response.body).to include("bbox")
}
end
describe 'GET #index' do
render_views
before do
request.accept = "application/javascript"
request.content_type = "application/javascript"
get :index, params: params
end
context 'with cadastres update' do
let(:params) do
{
champ_id: champ.id,
cadastres: 'update'
}
end
before do
get :index, params: params
end
context "update list" do
it {
expect(response.body).not_to include("DS.fire('map:feature:focus'")
expect(response.status).to eq 200
expect(response.body).to include("DS.fire('cadastres:update'")
}
end
context 'without cadastres update' do
context "update list and focus" do
let(:params) do
{
champ_id: champ.id
champ_id: champ.id,
focus: true
}
end
before do
get :index, params: params
end
it {
expect(response.body).to include("DS.fire('map:feature:focus'")
expect(response.status).to eq 200
expect(response.body).not_to include("DS.fire('cadastres:update'")
}
end
end

View file

@ -1,17 +1,16 @@
FactoryBot.define do
factory :geo_area do
association :champ
properties { {} }
trait :cadastre do
source { GeoArea.sources.fetch(:cadastre) }
numero { '42' }
feuille { 'A11' }
properties { { numero: '42', section: 'A11', prefixe: '000', commune: '75127', contenance: '1234', id: '75127000A1142' } }
end
trait :quartier_prioritaire do
source { GeoArea.sources.fetch(:quartier_prioritaire) }
nom { 'XYZ' }
commune { 'Paris' }
trait :legacy_cadastre do
source { GeoArea.sources.fetch(:cadastre) }
properties { { numero: '42', section: 'A11', code_com: '127', code_dep: '75', code_arr: '000', surface_parcelle: '1234', surface_intersection: 1234 } }
end
trait :selection_utilisateur do

View file

@ -1,39 +0,0 @@
describe APICarto::API do
describe '.search_cadastre' do
subject { described_class.search_cadastre(geojson) }
before do
stub_request(:post, "https://sandbox.geo.api.gouv.fr/apicarto/cadastre/geometrie")
.with(:body => /.*/,
:headers => { 'Content-Type' => 'application/json' })
.to_return(status: status, body: body)
end
context 'when geojson is empty' do
let(:geojson) { '' }
let(:status) { 404 }
let(:body) { '' }
it 'raises APICarto::API::ResourceNotFound' do
expect { subject }.to raise_error(APICarto::API::ResourceNotFound)
end
end
context 'when geojson exist' do
let(:geojson) { File.read('spec/fixtures/files/api_carto/request_cadastre.json') }
let(:status) { 200 }
let(:body) { 'toto' }
it 'returns response body' do
expect(subject).to eq(body)
end
context 'when geojson is at format JSON' do
let(:geojson) { JSON.parse(File.read('spec/fixtures/files/api_carto/request_cadastre.json')) }
it 'returns response body' do
expect(subject).to eq(body)
end
end
end
end
end

View file

@ -1,63 +0,0 @@
describe APICarto::CadastreAdapter do
subject { described_class.new(coordinates).results }
before do
stub_request(:post, "https://sandbox.geo.api.gouv.fr/apicarto/cadastre/geometrie")
.with(:body => /.*/,
:headers => { 'Content-Type' => 'application/json' })
.to_return(status: status, body: body)
end
context 'coordinates are filled' do
let(:coordinates) { '[[2.252728, 43.27151][2.323223, 32.835332]]' }
let(:status) { 200 }
let(:body) { File.read('spec/fixtures/files/api_carto/response_cadastre.json') }
it { expect(subject).to be_a_instance_of(Array) }
it { expect(subject.size).to eq(16) }
describe 'Attribut filter' do
let(:adapter) { described_class.new(coordinates) }
subject { adapter.filter_properties(adapter.data_source[:features].first[:properties]) }
it { expect(subject.size).to eq(9) }
it do
expect(subject.keys).to eq([
:surface_intersection,
:surface_parcelle,
:numero,
:feuille,
:section,
:code_dep,
:nom_com,
:code_com,
:code_arr
])
end
end
describe 'Attributes' do
subject { super().first }
it { expect(subject[:surface_intersection]).to eq('0.0202') }
it { expect(subject[:surface_parcelle]).to eq(220.0664659755941) }
it { expect(subject[:numero]).to eq('0082') }
it { expect(subject[:feuille]).to eq(1) }
it { expect(subject[:section]).to eq('0J') }
it { expect(subject[:code_dep]).to eq('94') }
it { expect(subject[:nom_com]).to eq('Maisons-Alfort') }
it { expect(subject[:code_com]).to eq('046') }
it { expect(subject[:code_arr]).to eq('000') }
it { expect(subject[:geometry]).to eq({ type: "MultiPolygon", coordinates: [[[[2.4362443, 48.8092078], [2.436384, 48.8092043], [2.4363802, 48.8091414]]]] }) }
end
end
context 'coordinates are empty' do
let(:coordinates) { '' }
let(:status) { 404 }
let(:body) { '' }
it { expect { subject }.to raise_error(APICarto::API::ResourceNotFound) }
end
end

View file

@ -80,4 +80,24 @@ RSpec.describe GeoArea, type: :model do
it { expect(geo_area.valid?).to be_falsey }
end
end
describe "cadastre properties" do
let(:geo_area) { build(:geo_area, :cadastre) }
let(:legacy_geo_area) { build(:geo_area, :legacy_cadastre) }
it "should be backward compatible" do
expect("#{geo_area.code_dep}#{geo_area.code_com}").to eq(geo_area.commune)
expect(geo_area.code_arr).to eq(geo_area.prefixe)
expect(geo_area.surface_parcelle).to eq(geo_area.surface)
end
context "(legacy)" do
it "should be forward compatible" do
expect("#{legacy_geo_area.code_dep}#{legacy_geo_area.code_com}").to eq(legacy_geo_area.commune)
expect(legacy_geo_area.code_arr).to eq(legacy_geo_area.prefixe)
expect(legacy_geo_area.surface_parcelle).to eq(legacy_geo_area.surface)
expect(legacy_geo_area.cid).to eq(geo_area.cid)
end
end
end
end

View file

@ -85,7 +85,7 @@ describe ChampSerializer do
source: GeoArea.sources.fetch(:cadastre),
geometry: geo_json,
numero: '42',
feuille: 'A11'
section: 'A11'
)
expect(subject[:geo_areas].first.key?(:nom)).to be_falsey
}