diff --git a/app/assets/stylesheets/map_info.scss b/app/assets/stylesheets/map_info.scss
new file mode 100644
index 000000000..00f7442c7
--- /dev/null
+++ b/app/assets/stylesheets/map_info.scss
@@ -0,0 +1,64 @@
+@import "colors";
+
+$dep-nothing: #E3E3FD; // blue-france-925
+$dep-small: #CACAFB; // blue-france-850
+$dep-medium: #8585F6; // blue-france-625
+$dep-large: #313178; // blue-france-200
+$dep-xlarge: #272747; // blue-france-125
+
+#map-svg {
+ max-width: 100%;
+ height: auto;
+}
+
+#map-infos {
+ min-width: 328px;
+}
+
+.departement.nothing {
+ fill: $dep-nothing;
+}
+
+.departement.small {
+ fill: $dep-small;
+}
+
+.departement.medium {
+ fill: $dep-medium;
+}
+
+.departement.large {
+ fill: $dep-large;
+}
+
+.departement.xlarge {
+ fill: $dep-xlarge;
+}
+
+.legends {
+ .legend {
+ width: 60px;
+ height: 10px;
+ display: inline-block;
+ }
+
+ .nothing {
+ background-color: $dep-nothing;
+ }
+
+ .small {
+ background-color: $dep-small;
+ }
+
+ .medium {
+ background-color: $dep-medium;
+ }
+
+ .large {
+ background-color: $dep-large;
+ }
+
+ .xlarge {
+ background-color: $dep-xlarge;
+ }
+}
diff --git a/app/controllers/carte_controller.rb b/app/controllers/carte_controller.rb
new file mode 100644
index 000000000..1b9c95f1e
--- /dev/null
+++ b/app/controllers/carte_controller.rb
@@ -0,0 +1,23 @@
+class CarteController < ApplicationController
+ def show
+ @map_filter = MapFilter.new(params)
+ @map_filter.stats = stats
+ end
+
+ private
+
+ def stats
+ departements_sql = "select departement, count(procedures.id) as nb_demarches, sum(procedures.estimated_dossiers_count) as nb_dossiers from services inner join procedures on services.id = procedures.service_id where procedures.hidden_at is null and procedures.aasm_state in ('publiee', 'close', 'depubliee')"
+ departements_sql += " and procedures.published_at >= '#{@map_filter.year}-01-01' and procedures.published_at <= '#{@map_filter.year}-12-31'" if @map_filter.year.present?
+ departements_sql += " group by services.departement"
+ departements = ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql(departements_sql))
+ departements.to_a.reduce(Hash.new({ nb_demarches: 0, nb_dossiers: 0 })) do |h, v|
+ h.merge(
+ v["departement"] => {
+ 'nb_demarches': v["nb_demarches"].presence || 0,
+ 'nb_dossiers': v['nb_dossiers'].presence || 0
+ }
+ )
+ end
+ end
+end
diff --git a/app/helpers/carte_helper.rb b/app/helpers/carte_helper.rb
new file mode 100644
index 000000000..928feec00
--- /dev/null
+++ b/app/helpers/carte_helper.rb
@@ -0,0 +1,19 @@
+module CarteHelper
+ def svg_path(map_filter, departement, d)
+ tag.path(
+ class: "land departement departement#{departement} #{map_filter.css_class_for_departement(departement)}",
+ 'stroke-width': ".5",
+ d: d,
+ data: {
+ departement: name_for_departement(departement),
+ demarches: map_filter.nb_demarches_for_departement(departement),
+ dossiers: map_filter.nb_dossiers_for_departement(departement),
+ action: "mouseenter->map-info#showInfo mouseout->map-info#hideInfo"
+ }
+ )
+ end
+
+ def name_for_departement(departement)
+ "#{departement.upcase} - #{APIGeoService.departement_name(departement.upcase)}"
+ end
+end
diff --git a/app/javascript/controllers/map_info_controller.ts b/app/javascript/controllers/map_info_controller.ts
new file mode 100644
index 000000000..43e32597f
--- /dev/null
+++ b/app/javascript/controllers/map_info_controller.ts
@@ -0,0 +1,32 @@
+import { Controller } from '@hotwired/stimulus';
+import { hide, show } from '@utils';
+
+export class MapInfoController extends Controller {
+ static targets = ['infos', 'departement', 'demarches', 'dossiers'];
+
+ declare readonly infosTarget: HTMLDivElement;
+ declare readonly departementTarget: HTMLDivElement;
+ declare readonly demarchesTarget: HTMLDivElement;
+ declare readonly dossiersTarget: HTMLDivElement;
+
+ showInfo(event: Event) {
+ const target = event.target as HTMLElement;
+ if (target && target.dataset && target.dataset.departement) {
+ target.setAttribute('stroke-width', '2.5');
+ this.departementTarget.innerHTML = target.dataset.departement;
+ this.demarchesTarget.innerHTML = Number(
+ target.dataset.demarches
+ ).toLocaleString();
+ this.dossiersTarget.innerHTML = Number(
+ target.dataset.dossiers
+ ).toLocaleString();
+ }
+ show(this.infosTarget);
+ }
+
+ hideInfo(event: Event) {
+ hide(this.infosTarget);
+ const target = event.target as HTMLElement;
+ target.removeAttribute('stroke-width');
+ }
+}
diff --git a/app/models/map_filter.rb b/app/models/map_filter.rb
new file mode 100644
index 000000000..06837bdb1
--- /dev/null
+++ b/app/models/map_filter.rb
@@ -0,0 +1,84 @@
+class MapFilter
+ # https://api.rubyonrails.org/v7.1.1/classes/ActiveModel/Errors.html
+
+ include ActiveModel::Conversion
+ extend ActiveModel::Translation
+ extend ActiveModel::Naming
+
+ LEGEND = {
+ nb_demarches: { 'nothing': -1, 'small': 20, 'medium': 50, 'large': 100, 'xlarge': 500 },
+ nb_dossiers: { 'nothing': -1, 'small': 500, 'medium': 2000, 'large': 10000, 'xlarge': 50000 }
+ }
+
+ attr_accessor :stats
+ attr_reader :errors
+
+ def initialize(params)
+ @params = params[:map_filter]&.permit(:kind, :year) || {}
+ @errors = ActiveModel::Errors.new(self)
+ end
+
+ def persisted?
+ false
+ end
+
+ def kind
+ @params[:kind]&.to_sym || :nb_demarches
+ end
+
+ def year
+ @params[:year].presence
+ end
+
+ def kind_buttons
+ LEGEND.keys.map do
+ { label: I18n.t("kind.#{_1}", scope:), value: _1 }
+ end
+ end
+
+ def kind_legend_keys
+ LEGEND[kind].keys
+ end
+
+ def css_class_for_departement(departement)
+ if kind == :nb_demarches
+ kind_legend_keys.reverse.find do
+ nb_demarches_for_departement(departement) > LEGEND[kind][_1]
+ end
+ else
+ kind_legend_keys.reverse.find do
+ nb_dossiers_for_departement(departement) > LEGEND[kind][_1]
+ end
+ end
+ end
+
+ def nb_demarches_for_departement(departement)
+ stats[departement.upcase] ? stats[departement.upcase][:nb_demarches] : 0
+ end
+
+ def nb_dossiers_for_departement(departement)
+ stats[departement.upcase] ? stats[departement.upcase][:nb_dossiers] : 0
+ end
+
+ def legende_for(legende)
+ limit = LEGEND[kind][legende]
+ index = LEGEND[kind].keys.index(legende.to_sym)
+ next_limit = LEGEND[kind].to_a[index + 1]
+ if next_limit
+ I18n.t(:legend, min_thresold: limit + 1, max_thresold: next_limit[1], scope:)
+ else
+ "> #{limit}"
+ end
+ end
+
+ def detailed_title
+ add_on = I18n.t(:specific_year_add_on, year:, scope:) if year
+ I18n.t("detailed_title_for_#{kind}", add_on:, scope:)
+ end
+
+ private
+
+ def scope
+ 'activemodel.attributes.map_filter'
+ end
+end
diff --git a/app/views/carte/show.html.erb b/app/views/carte/show.html.erb
new file mode 100644
index 000000000..04d1aed9b
--- /dev/null
+++ b/app/views/carte/show.html.erb
@@ -0,0 +1,276 @@
+<% content_for(:title, t(".title")) %>
+<% content_for :footer do %>
+ <%= render partial: "root/footer" %>
+<% end %>
+
+
<%= t('.title') %>
+
+
+
+
+
+
+
+
+
+ <%= form_for @map_filter, method: :get, url: carte_path, data: { turbo: true, controller: 'autosubmit' } do |map_form| %>
+ <%= render Dsfr::RadioButtonListComponent.new(form: map_form, target: :kind, buttons: @map_filter.kind_buttons) do %>
+ <%= t('.map_choice_title') %>
+ <% end %>
+
+ <%= map_form.label :year, class: 'fr-label' %>
+ <%= map_form.select(:year, (2018..Date.current.year).to_a.reverse, { include_blank: t(:from_beginning, scope: 'activemodel.attributes.map_filter') }, { class: "fr-select" }) %>
+
+ <%= map_form.submit(name: nil, class: 'hidden', data: { autosubmit_target: 'submitter' } ) %>
+ <% end %>
+
+
<%= @map_filter.detailed_title %>
+ <% @map_filter.kind_legend_keys.each do |legend| %>
+
+ <%= @map_filter.legende_for(legend) %>
+
+ <% end %>
+
+
+ <%= render Dsfr::CalloutComponent.new(title: nil) do |c| %>
+ <% c.with_body do %>
+
+
démarches
+
dossiers
+ <% end %>
+ <% end %>
+
+
+
+
+
diff --git a/config/locales/models/map_filter/en.yml b/config/locales/models/map_filter/en.yml
new file mode 100644
index 000000000..3786690a6
--- /dev/null
+++ b/config/locales/models/map_filter/en.yml
@@ -0,0 +1,15 @@
+en:
+ activemodel:
+ attributes:
+ map_filter:
+ year: Year
+ from_beginning: From the beginning
+ specific_year_add_on: "(in %{year})"
+
+ kind:
+ nb_demarches: Number of procedures
+ nb_dossiers: Number of files
+ detailed_title_for_nb_demarches: "Number of published or closed procedures %{add_on}"
+ detailed_title_for_nb_dossiers: "Number of submitted files %{add_on}"
+ legend: "Between %{min_thresold} and %{max_thresold}"
+
diff --git a/config/locales/models/map_filter/fr.yml b/config/locales/models/map_filter/fr.yml
new file mode 100644
index 000000000..9ee516875
--- /dev/null
+++ b/config/locales/models/map_filter/fr.yml
@@ -0,0 +1,14 @@
+fr:
+ activemodel:
+ attributes:
+ map_filter:
+ year: Année
+ from_beginning: Depuis le début
+ specific_year_add_on: "(en %{year})"
+
+ kind:
+ nb_demarches: Nombre de démarches
+ nb_dossiers: Nombre de dossiers
+ detailed_title_for_nb_demarches: "Nombre de démarches publiées ou closes %{add_on}"
+ detailed_title_for_nb_dossiers: "Nombre de dossiers déposés %{add_on}"
+ legend: "Entre %{min_thresold} et %{max_thresold}"
diff --git a/config/locales/views/carte.en.yml b/config/locales/views/carte.en.yml
new file mode 100644
index 000000000..70c79cae6
--- /dev/null
+++ b/config/locales/views/carte.en.yml
@@ -0,0 +1,6 @@
+en:
+ carte:
+ show:
+ title: Deployment by department
+ map_choice_title: Which map do you want ?
+
diff --git a/config/locales/views/carte.fr.yml b/config/locales/views/carte.fr.yml
new file mode 100644
index 000000000..c5382273c
--- /dev/null
+++ b/config/locales/views/carte.fr.yml
@@ -0,0 +1,5 @@
+fr:
+ carte:
+ show:
+ title: Déploiement par département
+ map_choice_title: Quelle carte souhaitez vous ?
diff --git a/config/routes.rb b/config/routes.rb
index 4443b6525..06ae1267b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -208,6 +208,8 @@ Rails.application.routes.draw do
get "mentions-legales", to: "static_pages#legal_notice"
get "declaration-accessibilite", to: "static_pages#accessibility_statement"
+ get "carte", to: "carte#show"
+
post "webhooks/sendinblue", to: "webhook#sendinblue"
post "webhooks/helpscout", to: "webhook#helpscout"
post "webhooks/helpscout_support_dev", to: "webhook#helpscout_support_dev"
diff --git a/spec/controllers/carte_controller_spec.rb b/spec/controllers/carte_controller_spec.rb
new file mode 100644
index 000000000..941fd081b
--- /dev/null
+++ b/spec/controllers/carte_controller_spec.rb
@@ -0,0 +1,22 @@
+describe CarteController do
+ describe '#show' do
+ let(:service) { create(:service, departement: '63') }
+ let(:service2) { create(:service, departement: '75') }
+ let(:service3) { create(:service, departement: '75') }
+ let!(:procedure) { create(:procedure, :published, service:, estimated_dossiers_count: 4) }
+ let!(:procedure2) { create(:procedure, :published, service: service2, estimated_dossiers_count: 20, published_at: Date.parse('2020-07-14')) }
+ let!(:procedure3) { create(:procedure, :published, service: service3, estimated_dossiers_count: 30, published_at: Date.parse('2021-07-14')) }
+ let(:subject) { assigns(:map_filter) }
+
+ it 'give stats for each departement' do
+ get :show
+ expect(subject.stats['63']).to eq({ nb_demarches: 1, nb_dossiers: 4 })
+ expect(subject.stats['75']).to eq({ nb_demarches: 2, nb_dossiers: 50 })
+ end
+
+ it 'give stats for each departement for a specific year' do
+ get :show, params: { map_filter: { year: 2020 } }
+ expect(subject.stats['75']).to eq({ nb_demarches: 1, nb_dossiers: 20 })
+ end
+ end
+end
diff --git a/spec/models/map_filter_spec.rb b/spec/models/map_filter_spec.rb
new file mode 100644
index 000000000..adb5d6d36
--- /dev/null
+++ b/spec/models/map_filter_spec.rb
@@ -0,0 +1,23 @@
+describe MapFilter do
+ let(:map_filter) do
+ mf = MapFilter.new(params)
+ mf.stats = { '63' => { nb_demarches: 51, nb_dossiers: 2001 } }
+ mf
+ end
+
+ describe 'css_class_for_departement' do
+ let(:params) { { kind: :nb_demarches } }
+ context 'for nb_demarches' do
+ it 'return class css' do
+ expect(map_filter.css_class_for_departement('63')).to eq :medium
+ end
+ end
+
+ context 'fr nb_dossiers' do
+ let(:params) { { kind: :nb_dossiers } }
+ it 'return class css' do
+ expect(map_filter.css_class_for_departement('63')).to eq :medium
+ end
+ end
+ end
+end