From 01c558953b6dd352e7caf1a958074005db327fdf Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 May 2021 18:47:58 +0200 Subject: [PATCH 01/11] Remove API GEO legacy adapter --- app/lib/api_carto/api.rb | 23 -------- app/lib/api_carto/cadastre_adapter.rb | 29 ---------- app/services/api_carto_service.rb | 25 -------- config/initializers/urls.rb | 1 - spec/lib/api_carto/api_spec.rb | 39 ------------- spec/lib/api_carto/cadastre_adapter_spec.rb | 63 --------------------- 6 files changed, 180 deletions(-) delete mode 100644 app/lib/api_carto/api.rb delete mode 100644 app/lib/api_carto/cadastre_adapter.rb delete mode 100644 app/services/api_carto_service.rb delete mode 100644 spec/lib/api_carto/api_spec.rb delete mode 100644 spec/lib/api_carto/cadastre_adapter_spec.rb diff --git a/app/lib/api_carto/api.rb b/app/lib/api_carto/api.rb deleted file mode 100644 index 7cf076dd6..000000000 --- a/app/lib/api_carto/api.rb +++ /dev/null @@ -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 diff --git a/app/lib/api_carto/cadastre_adapter.rb b/app/lib/api_carto/cadastre_adapter.rb deleted file mode 100644 index 65c939def..000000000 --- a/app/lib/api_carto/cadastre_adapter.rb +++ /dev/null @@ -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 diff --git a/app/services/api_carto_service.rb b/app/services/api_carto_service.rb deleted file mode 100644 index 9c9379beb..000000000 --- a/app/services/api_carto_service.rb +++ /dev/null @@ -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 diff --git a/config/initializers/urls.rb b/config/initializers/urls.rb index 36c058067..c36c51aa2 100644 --- a/config/initializers/urls.rb +++ b/config/initializers/urls.rb @@ -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") diff --git a/spec/lib/api_carto/api_spec.rb b/spec/lib/api_carto/api_spec.rb deleted file mode 100644 index 246cd5ce4..000000000 --- a/spec/lib/api_carto/api_spec.rb +++ /dev/null @@ -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 diff --git a/spec/lib/api_carto/cadastre_adapter_spec.rb b/spec/lib/api_carto/cadastre_adapter_spec.rb deleted file mode 100644 index fcbb64fa4..000000000 --- a/spec/lib/api_carto/cadastre_adapter_spec.rb +++ /dev/null @@ -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 From e74dcb0056ab7a8d785dce2ae292a94896219407 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 May 2021 18:48:24 +0200 Subject: [PATCH 02/11] Remove ign feature flag --- app/models/champs/carte_champ.rb | 5 +---- config/initializers/flipper.rb | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/models/champs/carte_champ.rb b/app/models/champs/carte_champ.rb index 0c97eed26..e56e98f67 100644 --- a/app/models/champs/carte_champ.rb +++ b/app/models/champs/carte_champ.rb @@ -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 diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index e6ded11d0..cec532155 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -27,7 +27,6 @@ end features = [ :administrateur_routage, :administrateur_web_hook, - :carte_ign, :dossier_pdf_vide, :expert_not_allowed_to_invite, :hide_instructeur_email, From 3b85ade440794d64f76dd79169e377091ce3808c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 May 2021 18:50:06 +0200 Subject: [PATCH 03/11] Add compatibility cadsatre layer with old API GEO --- app/controllers/champs/carte_controller.rb | 89 ++++------ app/graphql/schema.graphql | 18 +- .../geo_areas/parcelle_cadastrale_type.rb | 18 +- .../geo_areas/selection_utilisateur_type.rb | 2 + app/helpers/champ_helper.rb | 2 +- app/models/geo_area.rb | 163 ++++++++++++++---- app/views/champs/carte/index.js.erb | 4 +- .../shared/champs/carte/_geo_areas.html.haml | 2 +- .../champs/carte_controller_spec.rb | 51 ++---- spec/factories/geo_area.rb | 10 +- spec/serializers/champ_serializer_spec.rb | 2 +- 11 files changed, 206 insertions(+), 155 deletions(-) diff --git a/app/controllers/champs/carte_controller.rb b/app/controllers/champs/carte_controller.rb index f0e51754a..11989e116 100644 --- a/app/controllers/champs/carte_controller.rb +++ b/app/controllers/champs/carte_controller.rb @@ -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 diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 6e0df05e6..d2c597811 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -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: "L‘information n‘est 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: "L‘information n‘est 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! diff --git a/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb b/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb index 22b40c5d9..fdbecf76d 100644 --- a/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb +++ b/app/graphql/types/geo_areas/parcelle_cadastrale_type.rb @@ -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: 'L‘information n‘est plus disponible.' + field :surface_intersection, Float, null: false, deprecation_reason: 'L‘information n‘est plus disponible.' + field :surface_parcelle, Float, null: false, deprecation_reason: 'Utilisez le champ `surface` à la place.' end end diff --git a/app/graphql/types/geo_areas/selection_utilisateur_type.rb b/app/graphql/types/geo_areas/selection_utilisateur_type.rb index 004f07583..6de50ab73 100644 --- a/app/graphql/types/geo_areas/selection_utilisateur_type.rb +++ b/app/graphql/types/geo_areas/selection_utilisateur_type.rb @@ -1,5 +1,7 @@ module Types::GeoAreas class SelectionUtilisateurType < Types::BaseObject implements Types::GeoAreaType + + field :description, String, null: false end end diff --git a/app/helpers/champ_helper.rb b/app/helpers/champ_helper.rb index 42b8d9310..41881adf7 100644 --- a/app/helpers/champ_helper.rb +++ b/app/helpers/champ_helper.rb @@ -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) diff --git a/app/models/geo_area.rb b/app/models/geo_area.rb index 46df9af73..b8dcd31fe 100644 --- a/app/models/geo_area.rb +++ b/app/models/geo_area.rb @@ -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 diff --git a/app/views/champs/carte/index.js.erb b/app/views/champs/carte/index.js.erb index c77c1eb8f..37ef6c3a7 100644 --- a/app/views/champs/carte/index.js.erb +++ b/app/views/champs/carte/index.js.erb @@ -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 %> diff --git a/app/views/shared/champs/carte/_geo_areas.html.haml b/app/views/shared/champs/carte/_geo_areas.html.haml index f5a7348f5..6d594b3da 100644 --- a/app/views/shared/champs/carte/_geo_areas.html.haml +++ b/app/views/shared/champs/carte/_geo_areas.html.haml @@ -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) diff --git a/spec/controllers/champs/carte_controller_spec.rb b/spec/controllers/champs/carte_controller_spec.rb index da7f2a7af..d618a1dad 100644 --- a/spec/controllers/champs/carte_controller_spec.rb +++ b/spec/controllers/champs/carte_controller_spec.rb @@ -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 diff --git a/spec/factories/geo_area.rb b/spec/factories/geo_area.rb index 4e3a20275..5a5909be7 100644 --- a/spec/factories/geo_area.rb +++ b/spec/factories/geo_area.rb @@ -1,17 +1,11 @@ FactoryBot.define do factory :geo_area do association :champ + properties { {} } trait :cadastre do source { GeoArea.sources.fetch(:cadastre) } - numero { '42' } - feuille { 'A11' } - end - - trait :quartier_prioritaire do - source { GeoArea.sources.fetch(:quartier_prioritaire) } - nom { 'XYZ' } - commune { 'Paris' } + properties { { numero: '42', section: 'A11', commune: '75127' } } end trait :selection_utilisateur do diff --git a/spec/serializers/champ_serializer_spec.rb b/spec/serializers/champ_serializer_spec.rb index a80922f5e..27868f291 100644 --- a/spec/serializers/champ_serializer_spec.rb +++ b/spec/serializers/champ_serializer_spec.rb @@ -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 } From 19440afebfa51d895734e580c81cf9494a7c13d3 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 May 2021 18:51:19 +0200 Subject: [PATCH 04/11] Improuve mapbox utilis and shared components --- app/assets/stylesheets/carte.scss | 37 +++++++++ app/assets/stylesheets/forms.scss | 8 +- .../components/ComboAdresseSearch.jsx | 5 +- app/javascript/components/ComboSearch.jsx | 7 +- .../shared/mapbox/MapStyleControl.jsx | 69 ++++++++++++++++ .../components/shared/mapbox/Mapbox.js | 3 - .../shared/mapbox/SwitchMapStyle.jsx | 81 ------------------- .../components/shared/mapbox/styles/base.js | 22 ++--- .../components/shared/mapbox/styles/index.js | 32 ++++---- .../cadastre.js} | 2 +- .../shared/mapbox/styles/layers/ign.js | 8 ++ .../{ortho-style.js => layers/ortho.js} | 0 .../{vector-style.js => layers/vector.js} | 0 .../components/shared/mapbox/utils.js | 30 +++---- 14 files changed, 168 insertions(+), 136 deletions(-) create mode 100644 app/javascript/components/shared/mapbox/MapStyleControl.jsx delete mode 100644 app/javascript/components/shared/mapbox/Mapbox.js delete mode 100644 app/javascript/components/shared/mapbox/SwitchMapStyle.jsx rename app/javascript/components/shared/mapbox/styles/{cadastre-layers.js => layers/cadastre.js} (98%) create mode 100644 app/javascript/components/shared/mapbox/styles/layers/ign.js rename app/javascript/components/shared/mapbox/styles/{ortho-style.js => layers/ortho.js} (100%) rename app/javascript/components/shared/mapbox/styles/{vector-style.js => layers/vector.js} (100%) diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index 8728a4915..4e4a6cf70 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -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); + } + } +} diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index cfe13af7d..a73a7d8e2 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -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; } } diff --git a/app/javascript/components/ComboAdresseSearch.jsx b/app/javascript/components/ComboAdresseSearch.jsx index 150be5f37..78177dbe8 100644 --- a/app/javascript/components/ComboAdresseSearch.jsx +++ b/app/javascript/components/ComboAdresseSearch.jsx @@ -11,13 +11,15 @@ function ComboAdresseSearch({ hiddenFieldId, onChange, transformResult = ({ properties: { label } }) => [label, label], - allowInputValues = true + allowInputValues = true, + className }) { const transformResults = useCallback((_, { features }) => features); return ( 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 ( +
+ +
+ ); +} + +MapStyleControl.propTypes = { + style: PropTypes.string, + setStyle: PropTypes.func +}; + +export default MapStyleControl; diff --git a/app/javascript/components/shared/mapbox/Mapbox.js b/app/javascript/components/shared/mapbox/Mapbox.js deleted file mode 100644 index 3acc17e94..000000000 --- a/app/javascript/components/shared/mapbox/Mapbox.js +++ /dev/null @@ -1,3 +0,0 @@ -import ReactMapboxGl from 'react-mapbox-gl'; - -export default ReactMapboxGl({}); diff --git a/app/javascript/components/shared/mapbox/SwitchMapStyle.jsx b/app/javascript/components/shared/mapbox/SwitchMapStyle.jsx deleted file mode 100644 index d971219f9..000000000 --- a/app/javascript/components/shared/mapbox/SwitchMapStyle.jsx +++ /dev/null @@ -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 ( -
setStyle(nextStyle)} - > -
- {title} -
- {title} -
-
-
- ); -} - -SwitchMapStyle.propTypes = { - style: PropTypes.string, - setStyle: PropTypes.func, - ign: PropTypes.bool -}; - -export default SwitchMapStyle; diff --git a/app/javascript/components/shared/mapbox/styles/base.js b/app/javascript/components/shared/mapbox/styles/base.js index f0e9c2242..e233e607f 100644 --- a/app/javascript/components/shared/mapbox/styles/base.js +++ b/app/javascript/components/shared/mapbox/styles/base.js @@ -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: { diff --git a/app/javascript/components/shared/mapbox/styles/index.js b/app/javascript/components/shared/mapbox/styles/index.js index 97257b33f..218cd5ddf 100644 --- a/app/javascript/components/shared/mapbox/styles/index.js +++ b/app/javascript/components/shared/mapbox/styles/index.js @@ -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; } diff --git a/app/javascript/components/shared/mapbox/styles/cadastre-layers.js b/app/javascript/components/shared/mapbox/styles/layers/cadastre.js similarity index 98% rename from app/javascript/components/shared/mapbox/styles/cadastre-layers.js rename to app/javascript/components/shared/mapbox/styles/layers/cadastre.js index 46e02c9fe..0aed8996c 100644 --- a/app/javascript/components/shared/mapbox/styles/cadastre-layers.js +++ b/app/javascript/components/shared/mapbox/styles/layers/cadastre.js @@ -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 diff --git a/app/javascript/components/shared/mapbox/styles/layers/ign.js b/app/javascript/components/shared/mapbox/styles/layers/ign.js new file mode 100644 index 000000000..e7e7614a7 --- /dev/null +++ b/app/javascript/components/shared/mapbox/styles/layers/ign.js @@ -0,0 +1,8 @@ +export default [ + { + id: 'ign', + source: 'plan-ign', + type: 'raster', + paint: { 'raster-resampling': 'linear' } + } +]; diff --git a/app/javascript/components/shared/mapbox/styles/ortho-style.js b/app/javascript/components/shared/mapbox/styles/layers/ortho.js similarity index 100% rename from app/javascript/components/shared/mapbox/styles/ortho-style.js rename to app/javascript/components/shared/mapbox/styles/layers/ortho.js diff --git a/app/javascript/components/shared/mapbox/styles/vector-style.js b/app/javascript/components/shared/mapbox/styles/layers/vector.js similarity index 100% rename from app/javascript/components/shared/mapbox/styles/vector-style.js rename to app/javascript/components/shared/mapbox/styles/layers/vector.js diff --git a/app/javascript/components/shared/mapbox/utils.js b/app/javascript/components/shared/mapbox/utils.js index c54ae3fb9..721a0bfb5 100644 --- a/app/javascript/components/shared/mapbox/utils.js +++ b/app/javascript/components/shared/mapbox/utils.js @@ -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; +} From 2244263b49b5cacd6679dc987b9833d22b58a881 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 May 2021 18:51:53 +0200 Subject: [PATCH 05/11] Add cadastres to MapEditor --- app/javascript/components/MapEditor/index.jsx | 352 +++-------- .../MapEditor/{utils.js => readGeoFile.js} | 60 +- .../components/MapEditor/useMapboxEditor.js | 553 ++++++++++++++++++ 3 files changed, 653 insertions(+), 312 deletions(-) rename app/javascript/components/MapEditor/{utils.js => readGeoFile.js} (82%) create mode 100644 app/javascript/components/MapEditor/useMapboxEditor.js diff --git a/app/javascript/components/MapEditor/index.jsx b/app/javascript/components/MapEditor/index.jsx index 96e5ceea0..fcedfba4f 100644 --- a/app/javascript/components/MapEditor/index.jsx +++ b/app/javascript/components/MapEditor/index.jsx @@ -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é n’est 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é n’est 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 (

Nous ne pouvons pas afficher notre éditeur de carte car il est @@ -263,9 +53,7 @@ function MapEditor({ featureCollection, url, preview, options }) { return ( <> - {errorMessage && ( - - )} + {error && }

Besoin d'aide ?  @@ -278,12 +66,12 @@ function MapEditor({ featureCollection, url, preview, options }) {

-
+
- {importInputs.map((input) => ( + {inputs.map((input) => (
onFileImport(e, input.id)} + onChange={(e) => onFileChange(e, input.id)} /> {input.hasValue && (
{ @@ -323,38 +112,43 @@ function MapEditor({ featureCollection, url, preview, options }) { />
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 ? ( - - ) : null} - - + )} + + {options.layers.includes('cadastres') && ( +
+ +
+ )}
); @@ -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; diff --git a/app/javascript/components/MapEditor/utils.js b/app/javascript/components/MapEditor/readGeoFile.js similarity index 82% rename from app/javascript/components/MapEditor/utils.js rename to app/javascript/components/MapEditor/readGeoFile.js index 955c92859..59f972f02 100644 --- a/app/javascript/components/MapEditor/utils.js +++ b/app/javascript/components/MapEditor/readGeoFile.js @@ -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'); - }); -} diff --git a/app/javascript/components/MapEditor/useMapboxEditor.js b/app/javascript/components/MapEditor/useMapboxEditor.js new file mode 100644 index 000000000..aa2bd7f0b --- /dev/null +++ b/app/javascript/components/MapEditor/useMapboxEditor.js @@ -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é 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]); +} From 1b0cc62fc243ef324599e936bdb98fc8b1341c6c Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 6 May 2021 18:52:02 +0200 Subject: [PATCH 06/11] Add cadastres to MapReader --- app/javascript/components/MapReader/index.jsx | 318 +++++++++--------- .../components/MapReader/useMapbox.js | 104 ++++++ 2 files changed, 255 insertions(+), 167 deletions(-) create mode 100644 app/javascript/components/MapReader/useMapbox.js diff --git a/app/javascript/components/MapReader/index.jsx b/app/javascript/components/MapReader/index.jsx index 9a14202cb..c976464f8 100644 --- a/app/javascript/components/MapReader/index.jsx +++ b/app/javascript/components/MapReader/index.jsx @@ -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 (

Nous ne pouvons pas afficher la carte car elle est imcompatible avec @@ -147,58 +34,155 @@ const MapReader = ({ featureCollection, options }) => { return ( onMapLoad(map)} - fitBounds={boundData} - fitBoundsOptions={{ padding: 100 }} - style={mapStyle} - containerStyle={{ - height: '400px', - width: '100%' - }} + onStyleLoad={(map) => onLoad(map)} + style={style} + containerStyle={{ height: '400px' }} > - - - - {hasCadastres ? ( - - ) : null} - + ); }; -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 ( + + ); +} + +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 }), - 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; diff --git a/app/javascript/components/MapReader/useMapbox.js b/app/javascript/components/MapReader/useMapbox.js new file mode 100644 index 000000000..d58b4e463 --- /dev/null +++ b/app/javascript/components/MapReader/useMapbox.js @@ -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]); +} From 55080706ce5005e06805fbb662ce6edc5c48742d Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 13 May 2021 11:48:41 +0200 Subject: [PATCH 07/11] Convert geo_areas properties to jsonb --- ...316_use_jsonb_in_geo_areas_properties.rake | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 lib/tasks/deployment/20210513093316_use_jsonb_in_geo_areas_properties.rake diff --git a/lib/tasks/deployment/20210513093316_use_jsonb_in_geo_areas_properties.rake b/lib/tasks/deployment/20210513093316_use_jsonb_in_geo_areas_properties.rake new file mode 100644 index 000000000..0f075cb6d --- /dev/null +++ b/lib/tasks/deployment/20210513093316_use_jsonb_in_geo_areas_properties.rake @@ -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 From c5f2faa3d2e45882a4fccd8a1a6f7c5683d42f0a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 13 May 2021 12:46:42 +0200 Subject: [PATCH 08/11] add tests for backward compatibility of geo_areas --- spec/factories/geo_area.rb | 7 ++++++- spec/models/geo_area_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/spec/factories/geo_area.rb b/spec/factories/geo_area.rb index 5a5909be7..3c4e428e5 100644 --- a/spec/factories/geo_area.rb +++ b/spec/factories/geo_area.rb @@ -5,7 +5,12 @@ FactoryBot.define do trait :cadastre do source { GeoArea.sources.fetch(:cadastre) } - properties { { numero: '42', section: 'A11', commune: '75127' } } + properties { { numero: '42', section: 'A11', prefixe: '000', commune: '75127', contenance: '1234', id: '75127000A1142' } } + end + + 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 diff --git a/spec/models/geo_area_spec.rb b/spec/models/geo_area_spec.rb index 0279ce746..ac3e1aab2 100644 --- a/spec/models/geo_area_spec.rb +++ b/spec/models/geo_area_spec.rb @@ -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 From 84d5c95a0daa6a9586ea075b515f5c3d7cd90085 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 May 2021 07:58:22 +0000 Subject: [PATCH 09/11] Bump browserslist from 4.12.0 to 4.16.6 Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.12.0 to 4.16.6. - [Release notes](https://github.com/browserslist/browserslist/releases) - [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md) - [Commits](https://github.com/browserslist/browserslist/compare/4.12.0...4.16.6) Signed-off-by: dependabot[bot] --- yarn.lock | 90 +++++++++++++++++++------------------------------------ 1 file changed, 30 insertions(+), 60 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9259fad90..aed914713 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2925,14 +2925,15 @@ browserify-zlib@^0.2.0: pako "~1.0.5" browserslist@^4.0.0, browserslist@^4.11.1, browserslist@^4.6.4, browserslist@^4.8.5: - version "4.12.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.0.tgz#06c6d5715a1ede6c51fc39ff67fd647f740b656d" - integrity sha512-UH2GkcEDSI0k/lRkuDSzFl9ZZ87skSy9w2XAn1MsZnL+4c4rqbBd3e82UWHbYDpztABrPBhZsTEeuxVfHppqDg== + version "4.16.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" + integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== dependencies: - caniuse-lite "^1.0.30001043" - electron-to-chromium "^1.3.413" - node-releases "^1.1.53" - pkg-up "^2.0.0" + caniuse-lite "^1.0.30001219" + colorette "^1.2.2" + electron-to-chromium "^1.3.723" + escalade "^3.1.1" + node-releases "^1.1.71" btoa-lite@^1.0.0: version "1.0.0" @@ -3175,10 +3176,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001043: - version "1.0.30001055" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001055.tgz#7b52c3537f7a8c0408aca867e83d2b04268b54cd" - integrity sha512-MbwsBmKrBSKIWldfdIagO5OJWZclpJtS4h0Jrk/4HFrXJxTdVdH23Fd+xCiHriVGvYcWyW8mR/CPsYajlH8Iuw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001219: + version "1.0.30001228" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa" + integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A== cardinal@^2.1.1: version "2.1.1" @@ -3582,6 +3583,11 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.2" +colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== + colors@^1.1.2, colors@^1.2.1: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" @@ -4720,10 +4726,10 @@ ejs@^2.6.1: resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== -electron-to-chromium@^1.3.413: - version "1.3.435" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.435.tgz#22a7008e8f5a317a6d2d80802bddacebb19ae025" - integrity sha512-BVXnq+NCefidU7GOFPx4CPBfPcccLCRBKZYSbvBJMSn2kwGD7ML+eUA9tqfHAumRqy3oX5zaeTI1Bpt7qVat0Q== +electron-to-chromium@^1.3.723: + version "1.3.737" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.737.tgz#196f2e9656f4f3c31930750e1899c091b72d36b5" + integrity sha512-P/B84AgUSQXaum7a8m11HUsYL8tj9h/Pt5f7Hg7Ty6bm5DxlFq+e5+ouHUoNQMsKDJ7u4yGfI8mOErCmSH9wyg== elf-tools@^1.1.1: version "1.1.2" @@ -4915,6 +4921,11 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + escape-goat@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" @@ -5547,13 +5558,6 @@ find-up@^1.0.0: path-exists "^2.0.0" pinkie-promise "^2.0.0" -find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - find-up@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" @@ -7593,14 +7597,6 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -8577,10 +8573,10 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-releases@^1.1.53: - version "1.1.55" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.55.tgz#8af23b7c561d8e2e6e36a46637bab84633b07cee" - integrity sha512-H3R3YR/8TjT5WPin/wOoHOUPHgvj8leuU/Keta/rwelEQN9pA/S2Dx8/se4pZ2LBxSd0nAGzsNzhqwa77v7F1w== +node-releases@^1.1.71: + version "1.1.72" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" + integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== node-sass@^4.13.1: version "4.14.1" @@ -9093,13 +9089,6 @@ p-is-promise@^2.0.0: resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -9107,13 +9096,6 @@ p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0: dependencies: p-try "^2.0.0" -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - p-locate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" @@ -9166,11 +9148,6 @@ p-timeout@^3.0.0, p-timeout@^3.1.0: dependencies: p-finally "^1.0.0" -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -9447,13 +9424,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" - integrity sha1-yBmscoBZpGHKscOImivjxJoATX8= - dependencies: - find-up "^2.1.0" - pnp-webpack-plugin@^1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149" From d93342e1d7122492b39378f16f2a36146f906aba Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Tue, 11 May 2021 14:44:18 +0200 Subject: [PATCH 10/11] config: cleanup allowed tags after Rails 6.1 migration --- config/application.rb | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/config/application.rb b/config/application.rb index 62cc0206e..f5ab729ae 100644 --- a/config/application.rb +++ b/config/application.rb @@ -38,12 +38,7 @@ module TPS config.assets.paths << Rails.root.join('app', 'assets', 'fonts') config.assets.precompile += ['.woff'] - # The default list used to be accessible through `ActionView::Base.sanitized_allowed_tags`, - # but a regression in Rails 6.0 makes it unavailable. - # It should be fixed in Rails 6.1. - # See https://github.com/rails/rails/issues/39586 - # default_allowed_tags = ActionView::Base.sanitized_allowed_tags - default_allowed_tags = ['strong', 'em', 'b', 'i', 'p', 'code', 'pre', 'tt', 'samp', 'kbd', 'var', 'sub', 'sup', 'dfn', 'cite', 'big', 'small', 'address', 'hr', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'abbr', 'acronym', 'a', 'img', 'blockquote', 'del', 'ins'] + default_allowed_tags = ActionView::Base.sanitized_allowed_tags config.action_view.sanitized_allowed_tags = default_allowed_tags + ['u'] # Some mobile browsers have a behaviour where, although they will delete the session From 4b6196e6f62f68fb5599865120d9c2325aa34a51 Mon Sep 17 00:00:00 2001 From: kara Diaby Date: Tue, 25 May 2021 10:54:04 +0200 Subject: [PATCH 11/11] verify avis privacy --- spec/features/experts/expert_spec.rb | 46 +++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/spec/features/experts/expert_spec.rb b/spec/features/experts/expert_spec.rb index df81c2bd5..6c2f45c05 100644 --- a/spec/features/experts/expert_spec.rb +++ b/spec/features/experts/expert_spec.rb @@ -67,11 +67,49 @@ feature 'Inviting an expert:' do expect(page).to have_text('1 avis donné') end - # TODO - # scenario 'I can read other experts advices' do - # end - # scenario 'I can invite other experts' do # end end + + context 'when there are two experts' do + let(:expert_1) { create(:expert) } + let(:expert_2) { create(:expert) } + let(:instructeur) { create(:instructeur) } + let(:procedure) { create(:procedure, :published, instructeurs: [instructeur]) } + let(:experts_procedure_1) { create(:experts_procedure, expert: expert_1, procedure: procedure) } + let(:experts_procedure_2) { create(:experts_procedure, expert: expert_2, procedure: procedure) } + let(:dossier) { create(:dossier, :en_construction, :with_dossier_link, procedure: procedure) } + let!(:avis_1) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_1, confidentiel: true) } + let!(:avis_2) { create(:avis, dossier: dossier, claimant: instructeur, experts_procedure: experts_procedure_2, confidentiel: false) } + + scenario 'As a expert_1, I can read expert_2 advice because it is not confidential' do + login_as expert_1.user, scope: :user + + visit expert_all_avis_path + expect(page).to have_text('1 avis à donner') + expect(page).to have_text('0 avis donnés') + + click_on '1 avis à donner' + click_on avis_1.dossier.user.email + within('.tabs') { click_on 'Avis' } + expect(page).to have_text("Demandeur : #{avis_1.claimant.email}") + expect(page).to have_text("Vous") + expect(page).to have_text(avis_2.expert.email.to_s) + end + + scenario 'As a expert_2, I cannot read expert_1 advice because it is confidential' do + login_as expert_2.user, scope: :user + + visit expert_all_avis_path + expect(page).to have_text('1 avis à donner') + expect(page).to have_text('0 avis donnés') + + click_on '1 avis à donner' + click_on avis_2.dossier.user.email + within('.tabs') { click_on 'Avis' } + expect(page).to have_text("Demandeur : #{avis_2.claimant.email}") + expect(page).to have_text("Vous") + expect(page).not_to have_text(avis_1.expert.email.to_s) + end + end end