diff --git a/app/assets/stylesheets/new_design/carte.scss b/app/assets/stylesheets/new_design/carte.scss new file mode 100644 index 000000000..b23ec84e6 --- /dev/null +++ b/app/assets/stylesheets/new_design/carte.scss @@ -0,0 +1,13 @@ +.areas-title { + font-weight: bold; + margin-top: 5px; + margin-bottom: 5px; +} + +.areas { + margin-bottom: 10px; + + input { + margin-top: 5px; + } +} diff --git a/app/assets/stylesheets/new_design/map.scss b/app/assets/stylesheets/new_design/map.scss deleted file mode 100644 index 588030137..000000000 --- a/app/assets/stylesheets/new_design/map.scss +++ /dev/null @@ -1,80 +0,0 @@ -.carte { - height: 400px; - margin-bottom: 16px; - z-index: 0; -} - -.leaflet-container path { - cursor: url("/assets/edit.png"), default !important; -} - -.carte { - &.edit { - g path.leaflet-polygon { - transition: all 0.25s; - stroke-width: 4px; - stroke-opacity: 1; - stroke: #D7217E; - position: absolute; - z-index: 100; - fill: #D7217E; - fill-opacity: 0.75; - } - - div.leaflet-edge { - box-shadow: 0 0 0 2px #FFFFFF, 0 0 10px rgba(0, 0, 0, 0.35); - border: 5px solid #D7217E; - border-radius: 10px; - transition: opacity 0.25s; - cursor: move; - opacity: 0; - pointer-events: none; - box-sizing: border-box; - width: 0 !important; - height: 0 !important; - } - } - - &.mode-create { - cursor: url("/assets/pencil.png"), crosshair !important; - } - - &.mode-edit div.leaflet-edge { - opacity: 1; - pointer-events: all; - } - - &.mode-delete path.leaflet-polygon { - cursor: no-drop !important; - - &:hover { - fill: #4D4D4D !important; - } - } -} - -.editable-champ-carte { - .toolbar { - display: flex; - align-items: center; - margin-bottom: 10px; - - .button { - width: 200px; - margin-right: 10px; - } - - .select2-container { - margin-bottom: 0; - } - } -} - -.areas-title { - font-weight: bold; - margin-bottom: 5px; -} - -.areas { - margin-bottom: 10px; -} diff --git a/app/assets/stylesheets/new_design/utils.scss b/app/assets/stylesheets/new_design/utils.scss index 9e5b14290..62e44fab0 100644 --- a/app/assets/stylesheets/new_design/utils.scss +++ b/app/assets/stylesheets/new_design/utils.scss @@ -53,3 +53,7 @@ .mt-2 { margin-top: 2 * $default-spacer; } + +.mb-2 { + margin-bottom: 2 * $default-spacer; +} diff --git a/app/controllers/champs/carte_controller.rb b/app/controllers/champs/carte_controller.rb index 6790a9767..fc9a55833 100644 --- a/app/controllers/champs/carte_controller.rb +++ b/app/controllers/champs/carte_controller.rb @@ -19,7 +19,7 @@ class Champs::CarteController < ApplicationController def create champ = policy_scope(Champ).find(params[:champ_id]) geo_area = champ.geo_areas.selections_utilisateur.new - save_geometry!(geo_area, params_feature) + save_feature!(geo_area, params_feature) render json: { feature: geo_area.to_feature }, status: :created end @@ -27,7 +27,7 @@ class Champs::CarteController < ApplicationController def update champ = policy_scope(Champ).find(params[:champ_id]) geo_area = champ.geo_areas.selections_utilisateur.find(params[:id]) - save_geometry!(geo_area, params_feature) + save_feature!(geo_area, params_feature) head :no_content end @@ -43,7 +43,7 @@ class Champs::CarteController < ApplicationController champ = policy_scope(Champ).find(params[:champ_id]) params_features.each do |feature| geo_area = champ.geo_areas.selections_utilisateur.new - save_geometry!(geo_area, feature) + save_feature!(geo_area, feature) end render json: champ.to_feature_collection, status: :created @@ -59,8 +59,13 @@ class Champs::CarteController < ApplicationController params[:features] end - def save_geometry!(geo_area, feature) - geo_area.geometry = feature[:geometry] + 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] + end geo_area.save! end diff --git a/app/javascript/components/MapEditor/index.js b/app/javascript/components/MapEditor/index.js index d98fbdc13..7d3921fc7 100644 --- a/app/javascript/components/MapEditor/index.js +++ b/app/javascript/components/MapEditor/index.js @@ -1,69 +1,102 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; import mapboxgl from 'mapbox-gl'; import ReactMapboxGl, { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl'; import DrawControl from 'react-mapbox-gl-draw'; -import SwitchMapStyle from './SwitchMapStyle'; -import SearchInput from './SearchInput'; -import { getJSON, ajax } from '@utils'; import { gpx, kml } from '@tmcw/togeojson/dist/togeojson.es.js'; -import ortho from '../MapStyles/ortho.json'; -import orthoCadastre from '../MapStyles/orthoCadastre.json'; -import vector from '../MapStyles/vector.json'; -import vectorCadastre from '../MapStyles/vectorCadastre.json'; -import { polygonCadastresFill, polygonCadastresLine } from './utils'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; +import { getJSON, ajax, fire } from '@utils'; + +import SwitchMapStyle from './SwitchMapStyle'; +import { getMapStyle } from '../MapStyles'; + +import SearchInput from './SearchInput'; +import { polygonCadastresFill, polygonCadastresLine } from './utils'; +import { + noop, + filterFeatureCollection, + fitBounds, + generateId, + useEvent, + findFeature +} from '../shared/map'; + const Map = ReactMapboxGl({}); -function filterFeatureCollection(featureCollection, source) { - return { - type: 'FeatureCollection', - features: featureCollection.features.filter( - (feature) => feature.properties.source === source - ) - }; -} - -function noop() {} - function MapEditor({ featureCollection, url, preview, hasCadastres }) { const drawControl = useRef(null); + const [currentMap, setCurrentMap] = useState(null); + const [style, setStyle] = useState('ortho'); const [coords, setCoords] = useState([1.7, 46.9]); const [zoom, setZoom] = useState([5]); - const [currentMap, setCurrentMap] = useState({}); const [bbox, setBbox] = useState(featureCollection.bbox); const [importInputs, setImportInputs] = useState([]); - let mapStyle = style === 'ortho' ? ortho : vector; + const [cadastresFeatureCollection, setCadastresFeatureCollection] = useState( + filterFeatureCollection(featureCollection, 'cadastre') + ); + const mapStyle = getMapStyle(style, hasCadastres); - if (hasCadastres) { - mapStyle = style === 'ortho' ? orthoCadastre : vectorCadastre; - } - - const cadastresFeatureCollection = filterFeatureCollection( - featureCollection, - 'cadastre' + 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] ); - function updateFeaturesList(features) { - const cadastres = features.find( - ({ geometry }) => geometry.type === 'Polygon' + 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') ); - ajax({ url, type: 'get', data: cadastres ? 'cadastres=update' : '' }); - } + }, []); + + 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); } - const generateId = () => Math.random().toString(20).substr(2, 6); - - const updateImportInputs = (inputs, inputId) => { + function updateImportInputs(inputs, inputId) { const updatedInputs = inputs.filter((input) => input.id !== inputId); setImportInputs(updatedInputs); - }; + } async function onDrawCreate({ features }) { for (const feature of features) { @@ -92,23 +125,13 @@ function MapEditor({ featureCollection, url, preview, hasCadastres }) { updateFeaturesList(features); } - const onMapLoad = (map) => { + function onMapLoad(map) { setCurrentMap(map); drawControl.current.draw.set( filterFeatureCollection(featureCollection, 'selection_utilisateur') ); - }; - - const onCadastresUpdate = (evt) => { - if (currentMap) { - currentMap - .getSource('cadastres-layer') - .setData( - filterFeatureCollection(evt.detail.featureCollection, 'cadastre') - ); - } - }; + } const onFileImport = (e, inputId) => { const isGpxFile = e.target.files[0].name.includes('.gpx'); @@ -190,11 +213,6 @@ function MapEditor({ featureCollection, url, preview, hasCadastres }) { updateImportInputs(inputs, inputId); }; - useEffect(() => { - addEventListener('cadastres:update', onCadastresUpdate); - return () => removeEventListener('cadastres:update', onCadastresUpdate); - }); - if (!mapboxgl.supported()) { return (

diff --git a/app/javascript/components/MapReader/index.js b/app/javascript/components/MapReader/index.js index c2d4e7d1c..145007b0a 100644 --- a/app/javascript/components/MapReader/index.js +++ b/app/javascript/components/MapReader/index.js @@ -1,25 +1,100 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import ReactMapboxGl, { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl'; -import mapboxgl from 'mapbox-gl'; -import SwitchMapStyle from './SwitchMapStyle'; -import ortho from '../MapStyles/ortho.json'; -import orthoCadastre from '../MapStyles/orthoCadastre.json'; -import vector from '../MapStyles/vector.json'; -import vectorCadastre from '../MapStyles/vectorCadastre.json'; +import mapboxgl, { Popup } from 'mapbox-gl'; import PropTypes from 'prop-types'; +import SwitchMapStyle from './SwitchMapStyle'; +import { getMapStyle } from '../MapStyles'; + +import { + filterFeatureCollection, + filterFeatureCollectionByGeometryType, + useEvent, + findFeature, + fitBounds, + getCenter +} from '../shared/map'; + const Map = ReactMapboxGl({}); const MapReader = ({ featureCollection }) => { + const [currentMap, setCurrentMap] = useState(null); const [style, setStyle] = useState('ortho'); - const hasCadastres = featureCollection.features.find( - (feature) => feature.properties.source === 'cadastre' + 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 mapStyle = useMemo( + () => getMapStyle(style, cadastresFeatureCollection.length), + [style, cadastresFeatureCollection] + ); + const popup = useMemo( + () => + new Popup({ + closeButton: false, + closeOnClick: false + }) ); - let mapStyle = style === 'ortho' ? ortho : vector; - if (hasCadastres) { - mapStyle = style === 'ortho' ? orthoCadastre : vectorCadastre; - } + 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 = [ @@ -27,26 +102,6 @@ const MapReader = ({ featureCollection }) => { [b1, b2] ]; - const cadastresFeatureCollection = { - type: 'FeatureCollection', - features: [] - }; - - const selectionsLineFeatureCollection = { - type: 'FeatureCollection', - features: [] - }; - - const selectionsPolygonFeatureCollection = { - type: 'FeatureCollection', - features: [] - }; - - const selectionsPointFeatureCollection = { - type: 'FeatureCollection', - features: [] - }; - const polygonSelectionFill = { 'fill-color': '#EC3323', 'fill-opacity': 0.5 @@ -77,25 +132,8 @@ const MapReader = ({ featureCollection }) => { 'line-dasharray': [1, 1] }; - for (let feature of featureCollection.features) { - switch (feature.properties.source) { - case 'selection_utilisateur': - switch (feature.geometry.type) { - case 'LineString': - selectionsLineFeatureCollection.features.push(feature); - break; - case 'Polygon': - selectionsPolygonFeatureCollection.features.push(feature); - break; - case 'Point': - selectionsPointFeatureCollection.features.push(feature); - break; - } - break; - case 'cadastre': - cadastresFeatureCollection.features.push(feature); - break; - } + function onMapLoad(map) { + setCurrentMap(map); } if (!mapboxgl.supported()) { @@ -110,6 +148,7 @@ const MapReader = ({ featureCollection }) => { return ( onMapLoad(map)} fitBounds={boundData} fitBoundsOptions={{ padding: 100 }} style={mapStyle} @@ -122,14 +161,20 @@ const MapReader = ({ featureCollection }) => { data={selectionsPolygonFeatureCollection} fillPaint={polygonSelectionFill} linePaint={polygonSelectionLine} + fillOnMouseEnter={onMouseEnter} + fillOnMouseLeave={onMouseLeave} /> feature.properties.id === id + ); +} + +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 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(); + + 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(); + } +} diff --git a/app/javascript/new_design/champs/carte.js b/app/javascript/new_design/champs/carte.js new file mode 100644 index 000000000..dbb569c50 --- /dev/null +++ b/app/javascript/new_design/champs/carte.js @@ -0,0 +1,40 @@ +import { delegate, fire, debounce } from '@utils'; + +const inputHandlers = new Map(); + +addEventListener('ds:page:update', () => { + const inputs = document.querySelectorAll('.areas input[data-geo-area]'); + + for (const input of inputs) { + input.addEventListener('focus', (event) => { + const id = parseInt(event.target.dataset.geoArea); + fire(document, 'map:feature:focus', { id }); + }); + } +}); + +delegate('click', '.areas a[data-geo-area]', (event) => { + event.preventDefault(); + const id = parseInt(event.target.dataset.geoArea); + fire(document, 'map:feature:focus', { id }); +}); + +delegate('input', '.areas input[data-geo-area]', (event) => { + const id = parseInt(event.target.dataset.geoArea); + + let handler = inputHandlers.get(id); + if (!handler) { + handler = debounce(() => { + const input = document.querySelector(`input[data-geo-area="${id}"]`); + if (input) { + fire(document, 'map:feature:update', { + id, + properties: { description: input.value.trim() } + }); + } + }, 200); + inputHandlers.set(id, handler); + } + + handler(); +}); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index bbafd9344..70897efcc 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -24,6 +24,7 @@ import '../new_design/support'; import '../new_design/dossiers/auto-save'; import '../new_design/dossiers/auto-upload'; +import '../new_design/champs/carte'; import '../new_design/champs/linked-drop-down-list'; import '../new_design/champs/repetition'; diff --git a/app/models/geo_area.rb b/app/models/geo_area.rb index fe0ca9f2f..e576ccbe8 100644 --- a/app/models/geo_area.rb +++ b/app/models/geo_area.rb @@ -2,6 +2,7 @@ class GeoArea < ApplicationRecord belongs_to :champ store :properties, accessors: [ + :description, :surface_intersection, :surface_parcelle, :numero, diff --git a/app/views/champs/carte/index.js.erb b/app/views/champs/carte/index.js.erb index 0d7fdfded..c77c1eb8f 100644 --- a/app/views/champs/carte/index.js.erb +++ b/app/views/champs/carte/index.js.erb @@ -2,7 +2,7 @@ <%= render_to_element("#{@selector} + .geo-areas", partial: 'shared/champs/carte/geo_areas', - locals: { champ: @champ }) %> + locals: { champ: @champ, editing: true }) %> <% if @update_cadastres %> <%= fire_event('cadastres:update', { featureCollection: @champ.to_feature_collection }.to_json) %> diff --git a/app/views/shared/champs/carte/_geo_areas.html.haml b/app/views/shared/champs/carte/_geo_areas.html.haml index 180c2b18f..f5a7348f5 100644 --- a/app/views/shared/champs/carte/_geo_areas.html.haml +++ b/app/views/shared/champs/carte/_geo_areas.html.haml @@ -3,19 +3,17 @@ .areas %ul - champ.selections_utilisateur.each do |geo_area| - %li= geo_area_label(geo_area) - -- if champ.quartiers_prioritaires? - .areas-title Quartiers prioritaires - .areas - - if !champ.geometry? - Aucune zone tracée - - elsif champ.quartiers_prioritaires.blank? - = t('errors.messages.quartiers_prioritaires_empty', count: champ.selections_utilisateur.size) - - else - %ul - - champ.quartiers_prioritaires.each do |geo_area| - %li= geo_area_label(geo_area) + %li{ class: editing ? '' : 'flex column mb-2' } + - 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' + - else + = link_to '#', data: { geo_area: geo_area.id } do + = geo_area_label(geo_area) + - if geo_area.description.present? + %span + = geo_area.description - if champ.cadastres? .areas-title Parcelles cadastrales @@ -27,16 +25,6 @@ - else %ul - champ.cadastres.each do |geo_area| - %li= geo_area_label(geo_area) - -- if champ.parcelles_agricoles? - .areas-title Parcelles agricoles (RPG) - .areas - - if !champ.geometry? - Aucune zone tracée - - elsif champ.parcelles_agricoles.blank? - = t('errors.messages.parcelles_agricoles_empty', count: champ.selections_utilisateur.size) - - else - %ul - - champ.parcelles_agricoles.each do |geo_area| - %li= geo_area_label(geo_area) + %li.flex.column.mb-2 + = link_to '#', data: { geo_area: geo_area.id } do + = geo_area_label(geo_area) diff --git a/app/views/shared/champs/carte/_show.html.haml b/app/views/shared/champs/carte/_show.html.haml index b59690562..122b94f64 100644 --- a/app/views/shared/champs/carte/_show.html.haml +++ b/app/views/shared/champs/carte/_show.html.haml @@ -1,4 +1,4 @@ - if champ.geometry? = react_component("MapReader", { featureCollection: champ.to_feature_collection } ) .geo-areas - = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ } + = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, editing: false } diff --git a/app/views/shared/dossiers/editable_champs/_carte.html.haml b/app/views/shared/dossiers/editable_champs/_carte.html.haml index 25fe30db8..21328444b 100644 --- a/app/views/shared/dossiers/editable_champs/_carte.html.haml +++ b/app/views/shared/dossiers/editable_champs/_carte.html.haml @@ -2,4 +2,4 @@ = react_component("MapEditor", { featureCollection: champ.to_feature_collection, url: champs_carte_features_path(preview ? 'preview' : champ), preview: preview, hasCadastres: !!champ.cadastres? }, class: "carte-#{champ.id}") .geo-areas - = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ } + = render partial: 'shared/champs/carte/geo_areas', locals: { champ: champ, editing: true } diff --git a/spec/controllers/champs/carte_controller_spec.rb b/spec/controllers/champs/carte_controller_spec.rb index 30a30a393..da7f2a7af 100644 --- a/spec/controllers/champs/carte_controller_spec.rb +++ b/spec/controllers/champs/carte_controller_spec.rb @@ -44,19 +44,43 @@ describe Champs::CarteController, type: :controller do end describe 'PATCH #update' do - let(:params) do - { - champ_id: champ.id, - id: geo_area.id, - feature: feature - } - end - before do patch :update, params: params end - it { expect(response.status).to eq 204 } + context 'update geometry' do + let(:params) do + { + champ_id: champ.id, + id: geo_area.id, + feature: feature + } + end + + it { expect(response.status).to eq 204 } + end + + context 'update description' do + let(:feature) do + { + properties: { + description: 'un point' + } + } + end + let(:params) do + { + champ_id: champ.id, + id: geo_area.id, + feature: feature + } + end + + it { + expect(response.status).to eq 204 + expect(geo_area.reload.description).to eq('un point') + } + end end describe 'DELETE #destroy' do