diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52515f9..7b270d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+##### 2.6.2 (22 février 2026)
+
+- Correction du téléchargemnent buggé dans /tableau
+
+##### 2.6.1 (17 février 2026)
+
+- Corrections la création des liens canoniques (SEO)
+
+#### 2.6.0 (5 février 2026)
+
+- Suite de la refonte graphique
+- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
+- Joli tableau pour choisir les colonnes à afficher
+- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
+- Amélioration du SEO (liens canoniques)
+
##### 2.5.1 (29 janvier 2026)
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b4ead7d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,85 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**decp.info** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
+
+## Commands
+
+### Setup
+
+```bash
+python -m venv .venv && source .venv/bin/activate
+pip install ".[dev]"
+cp template.env .env # then customize .env
+```
+
+### Development
+
+```bash
+uv run run.py # starts Dash with debug=True and hot reload
+```
+
+### Production
+
+```bash
+gunicorn app:server
+```
+
+### Tests
+
+```bash
+uv run pytest # run all tests (Selenium-based integration tests)
+uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test
+```
+
+Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
+
+## Architecture
+
+### Multi-page Dash app
+
+- `src/app.py` — creates the Dash app instance, navbar, SEO endpoints (robots.txt, sitemap.xml), Matomo analytics
+- `src/pages/*.py` — each page registers itself with `@register_page()` and owns its own layout and callbacks
+- `run.py` — dev entry point; exports `server` (Flask) for gunicorn
+
+### Key pages
+
+| Page | URL | Purpose |
+| ----------------- | --------------- | -------------------------------------- |
+| `recherche.py` | `/` | Search homepage for buyers/contractors |
+| `acheteur.py` | `/acheteur` | Buyer detail with stats, charts, maps |
+| `titulaire.py` | `/titulaire` | Contractor detail |
+| `tableau.py` | `/tableau` | Filterable data table with exports |
+| `marche.py` | `/marche` | Individual contract detail |
+| `observatoire.py` | `/observatoire` | An interactive analytics dashboard |
+
+### Data layer
+
+- Data is stored as **Parquet** and loaded with **Polars** (fast columnar operations)
+- Path set via `DATA_FILE_PARQUET_PATH` env var; tests use `tests/test.parquet`
+- `src/utils.py` — filtering helpers, search (`search_org`), link generation, geographic data loading
+- `src/callbacks.py` — shared Dash callbacks (e.g. `get_top_org_table`)
+- `src/figures.py` — chart and map components (Plotly Express, Dash Leaflet with marker clustering)
+- a Parquet file with production data is located at `../decp-processing/decp_prod.parquet` (~ 1,5 million records)
+- the TableSchema of the dataset with the list of field and their definition is located at `../decp-processing/reference/base_schema.json`
+- `tests/test.parquet` is very small and may not contain all possible columns, only those necessary for testing
+
+### UI stack
+
+- **Dash 3.4** + **Dash Bootstrap Components** for layout
+- **Plotly Express** for charts
+- **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering
+- Custom CSS in `src/assets/css/`
+
+### Environment
+
+- `DEVELOPMENT=true` enables debug logging and is set automatically during tests
+- `.env` file is required at runtime (copy from `template.env`)
+
+### Deployment
+
+- `main` branch → manual deploy to decp.info via GitHub Actions
+- `dev` branch → auto-deploy to test.decp.info via GitHub Actions
diff --git a/README.md b/README.md
index f710191..1d71b7c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# decp.info
-> v2.5.1
+> v2.6.2
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
=> [decp.info](https://decp.info)
diff --git a/data/departements.json b/data/departements.json
index 4115895..1f346d0 100644
--- a/data/departements.json
+++ b/data/departements.json
@@ -391,8 +391,16 @@
"departement": "La Réunion",
"region": "La Réunion"
},
+ "975": {
+ "departement": "Saint-Pierre-et-Miquelon",
+ "region": "Saint-Pierre-et-Miquelon"
+ },
"976": {
"departement": "Mayotte",
"region": "Mayotte"
+ },
+ "977": {
+ "departement": "Saint-Barthelemy",
+ "region": "Saint-Barthelemy"
}
}
diff --git a/docs/superpowers/plans/2026-03-18-observatoire-link.md b/docs/superpowers/plans/2026-03-18-observatoire-link.md
new file mode 100644
index 0000000..f81a2d4
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-18-observatoire-link.md
@@ -0,0 +1,473 @@
+# Observatoire Link from Search & Tableau Results — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let users jump from search/tableau results to the observatoire page, pre-filtered for a given organization, via a 📊 link in the `_nom` columns.
+
+**Architecture:** Modify `add_links()` in `src/utils.py` to append an observatoire link to `_nom` columns. Add two callbacks to `src/pages/observatoire.py` for bidirectional URL ↔ filter sync using the existing `dcc.Location(id="dashboard_url")`. Add a share URL input and clipboard button to the observatoire layout.
+
+**Tech Stack:** Dash 3.4, Polars, `urllib.parse`, `dcc.Location`, `dcc.Clipboard`
+
+**Spec:** `docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md`
+
+---
+
+### Task 1: Add observatoire link to `acheteur_nom` in `add_links()`
+
+**Files:**
+
+- Modify: `src/utils.py:82-91` (the `acheteur_` block inside `add_links()`)
+- Test: `tests/test_main.py`
+
+**Context:** The `add_links()` function loops over column names. The `if col.startswith("acheteur_")` block (lines 82-91) currently wraps both `acheteur_nom` and `acheteur_id` in a detail page link. We must only append the observatoire link when `col == "acheteur_nom"`.
+
+- [ ] **Step 1: Write a unit test for the observatoire link in acheteur_nom**
+
+In `tests/test_main.py`, add a test that calls `add_links()` on a minimal DataFrame and checks the `acheteur_nom` column contains both the detail link and the observatoire link, while `acheteur_id` does NOT contain the observatoire link.
+
+```python
+def test_004_add_links_observatoire_acheteur():
+ import polars as pl
+
+ from src.utils import add_links
+
+ dff = pl.DataFrame(
+ {
+ "acheteur_id": ["a1"],
+ "acheteur_nom": ["ACHETEUR 1"],
+ }
+ )
+ result = add_links(dff)
+ nom_value = result["acheteur_nom"][0]
+ id_value = result["acheteur_id"][0]
+
+ # acheteur_nom should contain detail link + observatoire link
+ assert "/acheteurs/a1" in nom_value
+ assert "ACHETEUR 1" in nom_value
+ assert '/observatoire?acheteur_id=a1' in nom_value
+ assert "📊" in nom_value
+
+ # acheteur_id should NOT contain observatoire link
+ assert "/observatoire" not in id_value
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
+Expected: FAIL — `'/observatoire?acheteur_id=a1'` not found in the output string.
+
+- [ ] **Step 3: Implement the observatoire link for acheteur_nom**
+
+In `src/utils.py`, modify the `if col.startswith("acheteur_")` block (lines 82-91). Gate the observatoire link append on `col == "acheteur_nom"`:
+
+```python
+ if col.startswith("acheteur_"):
+ detail_link = (
+ ''
+ + pl.col(col)
+ + ""
+ )
+ if col == "acheteur_nom":
+ detail_link = (
+ detail_link
+ + ' 📊'
+ )
+ dff = dff.with_columns(detail_link.alias(col))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
+Expected: PASS
+
+- [ ] **Step 5: Update `test_001` to account for the new emoji in cell text**
+
+The existing `test_001` asserts `result_table.find_element(...).text == name` for `acheteur_nom`. The cell text now includes "📊" from the observatoire link. Update the assertion in `tests/test_main.py` to use `startswith` instead of exact match:
+
+```python
+ assert result_table.find_element(
+ by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
+ ).text.startswith(
+ name
+ ), f"The search result should have the right {org_type} name"
+```
+
+- [ ] **Step 6: Run `test_001` to verify it still passes**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_001_logo_and_search -v`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/utils.py tests/test_main.py
+git commit -m "Ajout du lien observatoire dans acheteur_nom via add_links() #65"
+```
+
+---
+
+### Task 2: Add observatoire link to `titulaire_nom` in `add_links()`
+
+**Files:**
+
+- Modify: `src/utils.py:64-81` (the `titulaire_` block inside `add_links()`)
+- Test: `tests/test_main.py`
+
+**Context:** The `titulaire_` block (lines 64-81) uses a `pl.when().then().otherwise()` pattern because it guards on `titulaire_typeIdentifiant` being SIRET or null. The observatoire link must be appended inside the `.then()` branch, and only when `col == "titulaire_nom"`. Note: this block requires `titulaire_typeIdentifiant` to be present in the DataFrame.
+
+- [ ] **Step 1: Write a unit test for the observatoire link in titulaire_nom**
+
+```python
+def test_005_add_links_observatoire_titulaire():
+ import polars as pl
+
+ from src.utils import add_links
+
+ dff = pl.DataFrame(
+ {
+ "titulaire_id": ["t1"],
+ "titulaire_nom": ["TITULAIRE 1"],
+ "titulaire_typeIdentifiant": ["SIRET"],
+ }
+ )
+ result = add_links(dff)
+ nom_value = result["titulaire_nom"][0]
+ id_value = result["titulaire_id"][0]
+
+ # titulaire_nom should contain detail link + observatoire link
+ assert "/titulaires/t1" in nom_value
+ assert "TITULAIRE 1" in nom_value
+ assert '/observatoire?titulaire_id=t1' in nom_value
+ assert "📊" in nom_value
+
+ # titulaire_id should NOT contain observatoire link
+ assert "/observatoire" not in id_value
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
+Expected: FAIL — `'/observatoire?titulaire_id=t1'` not found.
+
+- [ ] **Step 3: Implement the observatoire link for titulaire_nom**
+
+In `src/utils.py`, modify the `if col.startswith("titulaire_")` block (lines 64-81). The `.then()` branch must build the link differently when `col == "titulaire_nom"`:
+
+```python
+ if col.startswith("titulaire_"):
+ detail_link = (
+ ''
+ + pl.col(col)
+ + ""
+ )
+ if col == "titulaire_nom":
+ detail_link = (
+ detail_link
+ + ' 📊'
+ )
+ dff = dff.with_columns(
+ pl.when(
+ pl.Expr.or_(
+ pl.col("titulaire_typeIdentifiant").is_null(),
+ pl.col("titulaire_typeIdentifiant") == "SIRET",
+ )
+ )
+ .then(detail_link)
+ .otherwise(pl.col(col))
+ .alias(col)
+ )
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
+Expected: PASS
+
+- [ ] **Step 5: Run all tests so far to check for regressions**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
+Expected: both PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/utils.py tests/test_main.py
+git commit -m "Ajout du lien observatoire dans titulaire_nom via add_links() #65"
+```
+
+---
+
+### Task 3: Observatoire Callback A — URL → Inputs (page load)
+
+**Files:**
+
+- Modify: `src/pages/observatoire.py` (add import + new callback after line 281)
+- Test: `tests/test_main.py`
+
+**Context:** The existing `dcc.Location(id="dashboard_url")` is in the observatoire layout. A new callback reads `dashboard_url.search` on page load, parses query params, and sets `dashboard_acheteur_id.value` and/or `dashboard_titulaire_id.value`. It also clears `dashboard_url.search` to `""` to prevent re-triggering. Two imports must be added: `import urllib.parse` at the top of the file, and `no_update` to the existing `from dash import ...` line (currently: `from dash import ALL, Input, Output, State, callback, ctx, dcc, html, register_page` — add `no_update` to this).
+
+- [ ] **Step 1: Write a Selenium test for URL → Input sync**
+
+This test navigates to `/observatoire?acheteur_id=a1` and verifies the SIRET input gets populated.
+
+```python
+def test_006_observatoire_url_to_input(dash_duo: DashComposite):
+ from src.app import app
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
+
+ # Navigate to observatoire with acheteur_id query param
+ dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
+ dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
+
+ acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
+ dash_duo.wait_for_text_to_equal(
+ "#dashboard_acheteur_id", "", timeout=4
+ ) # Wait for callback
+ import time
+ time.sleep(1) # Allow callback chain to complete
+
+ assert acheteur_input.get_attribute("value") == "a1", (
+ "acheteur_id input should be populated from URL param"
+ )
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
+Expected: FAIL — the input value is empty because no callback reads URL params yet.
+
+- [ ] **Step 3: Implement Callback A**
+
+Add `import urllib.parse` to the imports at the top of `src/pages/observatoire.py` (after line 1). Also add `no_update` to the existing dash import line:
+
+```python
+from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update, register_page
+```
+
+Add the callback after the `layout` list ends, before existing callbacks:
+
+```python
+@callback(
+ Output("dashboard_acheteur_id", "value"),
+ Output("dashboard_titulaire_id", "value"),
+ Output("dashboard_url", "search"),
+ Input("dashboard_url", "search"),
+)
+def restore_filters_from_url(search):
+ if not search:
+ return no_update, no_update, no_update
+
+ params = urllib.parse.parse_qs(search.lstrip("?"))
+
+ acheteur_id = params.get("acheteur_id", [None])[0] or no_update
+ titulaire_id = params.get("titulaire_id", [None])[0] or no_update
+
+ return acheteur_id, titulaire_id, ""
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pages/observatoire.py tests/test_main.py
+git commit -m "Callback URL → filtres sur la page observatoire #65"
+```
+
+---
+
+### Task 4: Observatoire Callback B — Inputs → shareable URL + layout
+
+**Files:**
+
+- Modify: `src/pages/observatoire.py` (add layout components + new callback)
+- Test: `tests/test_main.py`
+
+**Context:** Following the tableau.py pattern (lines 237-238 for layout, lines 399-450 for callback), add a hidden `share-url` input and a `copy-container` div to the observatoire layout. The callback listens to the ID inputs and builds a shareable URL. Component IDs must be unique across the app, so use `observatoire-share-url` and `observatoire-copy-container` to avoid collisions with tableau's `share-url` and `copy-container`.
+
+- [ ] **Step 1: Write a test for the shareable URL generation**
+
+```python
+def test_007_observatoire_share_url(dash_duo: DashComposite):
+ from src.app import app
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
+
+ # Navigate to observatoire with acheteur_id query param
+ dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
+ dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
+
+ import time
+ time.sleep(1) # Allow callback chain to complete
+
+ share_url_input = dash_duo.find_element("#observatoire-share-url")
+ share_url_value = share_url_input.get_attribute("value")
+
+ assert "acheteur_id=a1" in share_url_value, (
+ f"Share URL should contain acheteur_id param, got: {share_url_value}"
+ )
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
+Expected: FAIL — `#observatoire-share-url` element does not exist yet.
+
+- [ ] **Step 3: Add layout components to observatoire**
+
+In `src/pages/observatoire.py`, add the share URL input and copy container inside the filters column (after the download button, before the closing `]` of the `id="filters"` children list, around line 264):
+
+```python
+ dcc.Input(
+ id="observatoire-share-url",
+ readOnly=True,
+ style={"display": "none"},
+ ),
+ html.Div(id="observatoire-copy-container"),
+```
+
+- [ ] **Step 4: Implement Callback B**
+
+Add after Callback A in `src/pages/observatoire.py`:
+
+```python
+@callback(
+ Output("observatoire-share-url", "value"),
+ Output("observatoire-copy-container", "children"),
+ Input("dashboard_acheteur_id", "value"),
+ Input("dashboard_titulaire_id", "value"),
+ State("dashboard_url", "href"),
+ prevent_initial_call=True,
+)
+def sync_observatoire_share_url(acheteur_id, titulaire_id, href):
+ if not href:
+ return no_update, no_update
+
+ base_url = href.split("?")[0]
+
+ params = {}
+ if acheteur_id:
+ params["acheteur_id"] = acheteur_id
+ if titulaire_id:
+ params["titulaire_id"] = titulaire_id
+
+ query_string = urllib.parse.urlencode(params)
+ full_url = f"{base_url}?{query_string}" if query_string else base_url
+
+ copy_button = dcc.Clipboard(
+ id="btn-copy-observatoire-url",
+ target_id="observatoire-share-url",
+ title="Copier l'URL de cette vue",
+ style={
+ "display": "inline-block",
+ "fontSize": 20,
+ "verticalAlign": "top",
+ "cursor": "pointer",
+ },
+ className="fa fa-link",
+ children=[
+ dbc.Button(
+ "Partager",
+ className="btn btn-primary mt-2",
+ title="Copier l'adresse de cette vue filtrée pour la partager.",
+ )
+ ],
+ )
+
+ return full_url, copy_button
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
+Expected: PASS
+
+- [ ] **Step 6: Run all tests to check for regressions**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
+Expected: all tests PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/pages/observatoire.py tests/test_main.py
+git commit -m "URL partageable pour la page observatoire #65"
+```
+
+---
+
+### Task 5: End-to-end integration test
+
+**Files:**
+
+- Test: `tests/test_main.py`
+
+**Context:** Verify the full flow: search for an organization on the homepage, see the 📊 link in results, click it, arrive on the observatoire with the correct input populated.
+
+- [ ] **Step 1: Write end-to-end test**
+
+```python
+def test_008_search_to_observatoire(dash_duo: DashComposite):
+ from src.app import app
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
+
+ # Search for an acheteur
+ search_bar = dash_duo.find_element("#search")
+ search_bar.send_keys("ACHETEUR 1")
+ search_bar.send_keys(Keys.ENTER)
+
+ dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
+
+ # Find the observatoire link in acheteur_nom column
+ observatoire_link = dash_duo.find_element(
+ '#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
+ )
+ assert "📊" in observatoire_link.text
+
+ # Click the observatoire link
+ observatoire_link.click()
+
+ # Wait for observatoire page to load
+ dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
+
+ import time
+ time.sleep(1) # Allow callback chain to complete
+
+ acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
+ assert acheteur_input.get_attribute("value") == "a1", (
+ "acheteur_id input should be populated after navigating from search"
+ )
+```
+
+- [ ] **Step 2: Run end-to-end test**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py::test_008_search_to_observatoire -v`
+Expected: PASS
+
+- [ ] **Step 3: Run the full test suite**
+
+Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
+Expected: all tests PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/test_main.py
+git commit -m "Test e2e : recherche → observatoire #65"
+```
diff --git a/docs/superpowers/specs/2026-03-18-distance-histogram-design.md b/docs/superpowers/specs/2026-03-18-distance-histogram-design.md
new file mode 100644
index 0000000..091e274
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-18-distance-histogram-design.md
@@ -0,0 +1,64 @@
+# Distance Histogram — Design Spec
+
+**Date:** 2026-03-18
+**Branch:** feature/65_observatoire
+
+## Goal
+
+Display the distribution of distances (in km) between buyers and winning contractors, to help users assess whether a buyer or contractor tends to deal locally or at a national scale.
+
+## Data
+
+- Column: `titulaire_distance` (`Int64`, km)
+- Measured at address level — values are always > 0, no zero-handling needed
+- Already selected in the observatoire LazyFrame via `cs.starts_with("titulaire")`
+- Already available on acheteur and titulaire detail pages
+
+## Figure Function
+
+**Location:** `src/figures.py`
+
+**Signature:**
+
+```python
+def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
+```
+
+**Behaviour:**
+
+- Collects `titulaire_distance` from the LazyFrame, drops nulls
+- If the resulting DataFrame is empty after dropping nulls, `px.histogram` produces a blank figure without errors — no guard logic needed. The order of operations must be: drop nulls → log-transform → histogram
+- Drop nulls first, then pre-log-transform the column (`pl.col("titulaire_distance").log(10)`) so bins are truly equal-width on a log scale. Use `px.histogram` with `nbins=50` on the transformed values
+- Set custom X-axis tick values at powers of 10 (1, 10, 100, 1000, 10000) with km labels, using `fig.update_xaxes(tickvals=[0,1,2,3,4], ticktext=["1","10","100","1 000","10 000"])`
+- Y axis: count of contracts
+- French axis labels: x = `"Distance (km)"`, y = `"Nombre de marchés"`
+- Returns a `dcc.Graph`
+
+## Integration
+
+### Observatoire (`src/pages/observatoire.py`)
+
+- `get_distance_histogram` imported and called inside `udpate_dashboard_cards`
+- Result wrapped in `make_card(title="Distance acheteur–titulaire", subtitle="en nombre de marchés, échelle logarithmique", fig=...)`
+- Card appended to the `cards` list alongside existing donuts and charts
+- No changes to the data pipeline — `titulaire_distance` is already in the LazyFrame
+
+### Acheteur page (`src/pages/acheteur.py`)
+
+The acheteur page uses a `dcc.Store` (`acheteur_data`) that holds serialised contract rows as a list of dicts. The integration follows the existing pattern used by other chart callbacks on this page:
+
+- Add a new `html.Div(id="acheteur-distance-histogram")` placeholder in the layout
+- Add a new callback with `Input("acheteur_data", "data")` that:
+ - Reconstructs `pl.LazyFrame(data)` from the store
+ - Calls `get_distance_histogram(lff)`
+ - Wraps the result in `make_card(...)` and returns it to the placeholder div
+
+### Titulaire page (`src/pages/titulaire.py`)
+
+Same pattern as acheteur: `dcc.Store` (`titulaire_data`) → new callback → `html.Div` placeholder.
+
+## Out of Scope
+
+- Filtering by distance range (could be a future filter on the observatoire page)
+- Showing distance on a map or as a trend over time
+- Bucket-based (named zone) grouping
diff --git a/docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md b/docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md
new file mode 100644
index 0000000..8843c0c
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md
@@ -0,0 +1,78 @@
+# Observatoire Link from Search & Tableau Results
+
+## Problem
+
+Users searching for an organization (acheteur or titulaire) on the search page or browsing the tableau cannot jump directly to the observatoire page filtered for that organization. They must manually navigate and re-enter the identifier.
+
+## Solution
+
+Extend `add_links()` in `src/utils.py` to append an observatoire link (📊 emoji) to `_nom` columns, and add bidirectional URL parameter sync to the observatoire page.
+
+## Changes
+
+### 1. `src/utils.py` — `add_links()` modification
+
+The existing `add_links()` loop iterates over `["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]`. The `if col.startswith("acheteur_")` and `if col.startswith("titulaire_")` blocks match both `_nom` and `_id` columns. The observatoire link must only be appended to `_nom` columns, so it must be gated on `col == "acheteur_nom"` or `col == "titulaire_nom"` explicitly.
+
+For `acheteur_nom`, append an observatoire link after the existing detail page link:
+
+```
+Before: Ville de Paris
+After: Ville de Paris📊
+```
+
+For `titulaire_nom`, same pattern but only when the existing `typeIdentifiant` guard passes (SIRET or null):
+
+```
+Before: Entreprise X
+After: Entreprise X📊
+```
+
+The identifier used in the observatoire link (`acheteur_id` / `titulaire_id`) is the same `pl.col("acheteur_id")` / `pl.col("titulaire_id")` column value already used for the detail page link.
+
+The `_id` and `uid` columns are unchanged.
+
+### 2. `src/pages/observatoire.py` — URL parameter handling
+
+#### Callback A: URL → Inputs (page load)
+
+- Trigger: `Input("dashboard_url", "search")`
+- Outputs: `Output("dashboard_acheteur_id", "value")`, `Output("dashboard_titulaire_id", "value")`, `Output("dashboard_url", "search")` (to clear it)
+- `prevent_initial_call=False` (must fire on page load to read URL params)
+- If `search` is empty or None: return `no_update` for all outputs
+- Otherwise: parse query params with `urllib.parse.parse_qs`
+- Set `dashboard_acheteur_id` from `?acheteur_id=` param, or `no_update` if absent
+- Set `dashboard_titulaire_id` from `?titulaire_id=` param, or `no_update` if absent
+- Return `""` for `dashboard_url.search` to clear the URL and prevent re-triggering
+- No validation of param values — consistent with existing input handling in the observatoire callbacks
+
+#### Callback B: Inputs → shareable URL
+
+- Trigger: `Input("dashboard_acheteur_id", "value")`, `Input("dashboard_titulaire_id", "value")`
+- State: `State("dashboard_url", "href")` for base URL
+- `prevent_initial_call=True` (avoid generating URL on initial empty state)
+- Build query string with `urllib.parse.urlencode`, omitting empty values
+- Write full URL to a new `share-url` input component
+- Render a `dcc.Clipboard` + share button (same pattern as tableau.py)
+
+#### Callback chain
+
+When navigating from search with `?acheteur_id=123`: Callback A fires on page load, sets input values, clears URL search. The input value changes then trigger both the existing `udpate_dashboard_cards` callback and Callback B. Dash handles this chaining deterministically — no race condition.
+
+#### Layout additions
+
+- A `dcc.Input(id="share-url", ...)` (hidden or read-only) to hold the shareable URL
+- A `dcc.Clipboard` share/copy button near the filters
+
+### 3. Reuse of existing `dcc.Location`
+
+The existing `dcc.Location(id="dashboard_url")` component is reused — no new Location component needed.
+
+## Future extension
+
+The bidirectional URL sync pattern is designed to extend to all observatoire filters (year, categories, departments, market type, etc.) by adding more params to both callbacks.
+
+## Files touched
+
+- `src/utils.py` — modify `add_links()`
+- `src/pages/observatoire.py` — add 2 callbacks, add share-url + clipboard to layout
diff --git a/pyproject.toml b/pyproject.toml
index 261b9bb..b4c27e3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "decp.info"
description = "Interface d'exploration et d'analyse des marchés publics français."
-version = "2.5.1"
+version = "2.6.2"
requires-python = ">= 3.10"
authors = [
{ name = "Colin Maudry", email = "colin@colmo.tech" }
@@ -17,7 +17,9 @@ dependencies = [
"plotly[express]",
"httpx",
"pandas", # utilisé pour la création de certains graphiques
- "unidecode"
+ "unidecode",
+ "dash-leaflet",
+ "dash-extensions"
]
[project.optional-dependencies]
@@ -28,6 +30,7 @@ dev = [
"selenium",
"webdriver-manager",
"dash[testing]",
+ "fastexcel"
]
[tool.pytest.ini_options]
@@ -38,6 +41,8 @@ testpaths = [
"tests"
]
env = [
- "DATA_FILE_PARQUET_PATH=tests/test.parquet"
+ "DATA_FILE_PARQUET_PATH=tests/test.parquet",
+ "DEVELOPMENT=true",
+ "DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json"
]
addopts = "-p no:warnings"
diff --git a/src/app.py b/src/app.py
index 3f45aed..7a56058 100644
--- a/src/app.py
+++ b/src/app.py
@@ -53,7 +53,7 @@ def sitemap():
base_url = "https://decp.info"
pages = [
"/",
- "/statistiques",
+ "/observatoire",
"/tableau",
"/a-propos",
]
@@ -87,6 +87,7 @@ app.index_string = """