---
title: "CATAL Showcase JCN — Helpers Dataviz R & Python"
subtitle: "Démonstration fonctions jcn-*.R / jcn-*.py + plugins avancés | Données ZE France"
date: today
date-format: "DD MMMM YYYY"
execute:
  echo: true
---

```{r}
#| label: setup-r
#| include: false
source("_setup-common.R")
library(sf)
library(tmap)
library(reticulate)
use_python("C:/ScoopApps/apps/python/current/python.exe", required = TRUE)
source("C:/Users/vince/hh/pq/PDS/mutils/jrr/jcn-map.R")

# Shapefile ZE
shp_ze <- st_read(
  file.path(PROJECT_ROOT, "data/external/geo-shape/nodom_zones-emploi_2025.geojson"),
  quiet = TRUE
) %>%
  rename(code = ze2020) %>%
  mutate(code = sprintf("%04d", as.integer(code)))

ze_map <- ze %>% mutate(code = sprintf("%04d", as.integer(code)))
map_ze <- shp_ze %>% left_join(ze_map, by = "code", suffix = c("", ".data"))
```

```{python}
#| label: setup-python
#| include: false

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
pio.renderers.default = "notebook"
import seaborn as sns
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

from pathlib import Path
import sys
MUTILS_JPY = Path(r"C:\Users\vince\hh\pq\PDS\mutils\jpy")
if str(MUTILS_JPY) not in sys.path:
    sys.path.insert(0, str(MUTILS_JPY))

import importlib.util
_spec = importlib.util.spec_from_file_location("jcn_graph", MUTILS_JPY / "jcn-graph.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
_mod.init_style()
COL_CYAN, COL_ORANGE = _mod.COL_CYAN, _mod.COL_ORANGE
COL_GREEN, COL_RED = _mod.COL_GREEN, _mod.COL_RED
plotly_layout = _mod.plotly_layout
correlation_matrix = _mod.correlation_matrix

PROJECT_ROOT = Path(r"C:\Users\vince\hh\pq\PDS\ptod-ttrajObserDev")
df = pd.read_csv(PROJECT_ROOT / "data/output/dbcln-ze-vf.csv", encoding='utf-8-sig')
df = df[~df['code'].isin(['FR', '00FR', 'ZZZZZ'])].copy()
```

::: {.callout-note appearance="minimal"}
**CATAL Showcase JCN** — Catalogue de visualisations sur données réelles (`r N_ZE` ZE, `r ncol(ze)` indicateurs). Structure :

- **Parties 1-4** : Helpers JCN maison (`jcn-setup.R`, `jcn-graph.R`, `jcn-table.R`, `jcn-map.R`, `jcn-graph.py`) — showcase des fonctions existantes
- **Parties 5-6** : Plugins avancés R (`{jrr}`) et Python (`{jpy}`) — fonctionnalités poussées au max
- **Partie 7** : Layout Quarto — patterns de mise en page
:::


# JCN Graphs R — jcn-graph.R + jcn-setup.R {jrr} {#sec-jcn-graph}

## jcn-setup.R — Palette col_* & pal_urbn_* {jrr}

Constantes couleur Urban Institute importées globalement. 10 couleurs primaires + 8 pastel + 8 palettes (catégorielle, divergente, séquentielle).

```{r}
#| label: fig-jcn-palette
#| fig-cap: "jcn-setup.R — Palette Urban Institute complète"
#| fig-height: 5

cols_main <- c(col_cyan, col_yellow, col_green, col_red, col_orange,
               col_magenta, col_darkblue, col_spacegray, col_gray, col_black)
names_main <- c("col_cyan", "col_yellow", "col_green", "col_red", "col_orange",
                "col_magenta", "col_darkblue", "col_spacegray", "col_gray", "col_black")

pal_data <- tibble(
  couleur = factor(names_main, levels = rev(names_main)),
  hex = cols_main
)

p1 <- ggplot(pal_data, aes(x = 1, y = couleur, fill = hex)) +
  geom_tile(width = 0.8, height = 0.8) +
  geom_text(aes(label = hex), color = "white", fontface = "bold", size = 3.5) +
  scale_fill_identity() +
  labs(title = "Couleurs primaires jcn-setup.R", x = NULL, y = NULL) +
  theme(axis.text.x = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank())

p2 <- ggplot(data.frame(x = seq_along(pal_urbn_div), fill = pal_urbn_div),
             aes(x = x, y = 1, fill = fill)) +
  geom_tile() + scale_fill_identity() +
  labs(title = "pal_urbn_div (8 couleurs)", x = NULL, y = NULL) +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank())

gridExtra::grid.arrange(p1, p2, ncol = 1, heights = c(3, 1))
```


## jcn-graph.R — theme_urbn() {jrr}

Thème ggplot2 appliqué automatiquement via `theme_set()` au chargement de `jcn-graph.R`. Titre aligné à gauche, légende en haut, grille minimale.

```{r}
#| label: fig-jcn-theme
#| fig-cap: "jcn-graph.R — theme_urbn() auto-appliqué"

ggplot(ze, aes(x = dm_sma_vtcam_1622, y = logd_px2_global_24)) +
  geom_point(aes(color = typo_insee_lib, size = P22_POP), alpha = 0.6) +
  scale_size_continuous(range = c(1, 7), guide = "none") +
  scale_y_continuous(labels = label_comma(big.mark = "\u202f")) +
  labs(title = "theme_urbn() — titre aligné gauche, légende haut, grille légère",
       subtitle = "Automatiquement appliqué par jcn-graph.R via theme_set()",
       x = "SMA TCAM 16-22 (%/an)", y = "Prix m² global (EUR)",
       color = "Typologie", caption = SOURCE_INSEE)
```


## jcn-graph.R — plot_bar_ranked() {jrr}

Classement horizontal top N (cyan) + bottom N (orange) via Plotly. Paramètres : `n_show`, `max_bars`, `fill_top`, `fill_bot`.

```{r}
#| label: fig-jcn-bar-ranked
#| fig-cap: "jcn-graph.R — plot_bar_ranked()"
plot_bar_ranked(ze, "logd_px2_global_24", "libelle",
                title = "Prix immobilier m² global",
                subtitle = "Top 15 (cyan) et bottom 15 (orange)",
                xlab = "Prix m² (EUR)")
```


## jcn-graph.R — scale_fill_urbn_div() {jrr}

Échelle divergente orange → blanc → bleu centrée sur 0. Idéal pour TCAM, évolutions ±.

```{r}
#| label: fig-jcn-scale-div
#| fig-cap: "jcn-graph.R — scale_fill_urbn_div() sur TCAM SMA"
ggplot(ze %>% filter(!is.na(typo_insee_lib)),
       aes(x = reorder(libelle, dm_sma_vtcam_1622), y = dm_sma_vtcam_1622,
           fill = dm_sma_vtcam_1622)) +
  geom_col() +
  scale_fill_urbn_div(limits = c(-2, 2), name = "SMA (%/an)") +
  coord_flip() +
  labs(title = "scale_fill_urbn_div() — divergente centrée 0",
       x = NULL, y = "SMA TCAM 16-22 (%/an)") +
  theme(axis.text.y = element_text(size = 5))
```


## jcn-graph.R — fmt_fr(), format_tcam() {jrr}

Formatage français : `fmt_fr(1234.5, 1)` → `"1 234,5"`, `format_tcam(0.023)` → `"+0.02%"`.

```{r}
#| label: demo-fmt
cat("fmt_fr(12345.67, 1)    =", fmt_fr(12345.67, 1), "\n")
cat("fmt_pct(23.456, 1)     =", fmt_pct(23.456, 1), "\n")
cat("fmt_k(1234567)         =", fmt_k(1234567), "\n")
cat("fmt_sign(-3.14, 2)     =", fmt_sign(-3.14, 2), "\n")
cat("format_tcam(0.0234)    =", format_tcam(0.0234), "\n")
cat("tcam(120, 100, 5)      =", tcam(120, 100, 5), "\n")
```


## _setup-common.R — plot_distribution() {jrr}

Histogramme + rug + médiane. Helper projet pour distributions rapides.

```{r}
#| label: fig-jcn-distribution
#| fig-cap: "_setup-common.R — plot_distribution()"
plot_distribution(ze, "logd_px2_global_24", bins = 30,
                  title = "Distribution asymétrique droite — quelques ZE très chères",
                  xlab = "Prix médian global m² (EUR)")
```


## _setup-common.R — scatter_quadrants() {jrr}

Scatter 2D avec quadrants, labels auto ggrepel, taille population. Helper projet pour hypothèses bivariées.

```{r}
#| label: fig-jcn-scatter-quadrants
#| fig-cap: "_setup-common.R — scatter_quadrants()"
scatter_quadrants(ze, x = "logd_px2_global_24", y = "idxlogtens_ind_22",
                  title = "Prix élevé + tension élevée = marché sous pression",
                  xlab = "Prix m² global (EUR)", ylab = "Idx tension logement (0-100)",
                  hline = 50, vline = median(ze$logd_px2_global_24, na.rm = TRUE),
                  quad_labels = c("Cher + tendu", "Abordable + tendu",
                                  "Abordable + détendu", "Cher + détendu"))
```


# JCN Tables R — jcn-table.R {jrr} {#sec-jcn-table}

## jcn-table.R — rt_styled() + theme_rt_urban {jrr}

Table reactable avec thème Urban (header gris, bordure cyan 2px), scroll auto, tri intégré.

```{r}
#| label: tbl-jcn-rt-styled
#| tbl-cap: "jcn-table.R — rt_styled() avec colonnes typées"

ze %>%
  select(libelle, typo_insee_lib, P22_POP, logd_px2_global_24,
         dm_sma_vtcam_1622, idxlogtens_ind_22) %>%
  slice_max(P22_POP, n = 20) %>%
  rt_styled(
    columns = list(
      libelle = colDef(name = "Zone d'emploi", minWidth = 150),
      typo_insee_lib = colDef(name = "Typologie", minWidth = 140),
      P22_POP = col_pop(ze, "P22_POP"),
      logd_px2_global_24 = col_num("Prix m²", unit = "EUR"),
      dm_sma_vtcam_1622 = col_variation(ze, "dm_sma_vtcam_1622", label = "SMA", unit = "%/an"),
      idxlogtens_ind_22 = col_num("Idx tension", digits = 0)
    )
  )
```


## jcn-table.R — bar_variation() + bar_pct() {jrr}

Barres inline divergentes (vert/rouge pour ±) et barres proportionnelles (0-100). Renderers cellule.

```{r}
#| label: tbl-jcn-bars
#| tbl-cap: "jcn-table.R — bar_variation() et bar_pct() dans les cellules"

tb_data <- ze %>%
  filter(!is.na(logd_px2_global_vevol_1624), !is.na(idxlogtens_ind_22)) %>%
  select(libelle, dm_sma_vtcam_1622, logd_px2_global_vevol_1624, idxlogtens_ind_22) %>%
  slice_max(abs(dm_sma_vtcam_1622), n = 25)

stats_sma <- compute_bar_stats(tb_data$dm_sma_vtcam_1622)
stats_px <- compute_bar_stats(tb_data$logd_px2_global_vevol_1624)

reactable(
  tb_data,
  compact = TRUE, pagination = FALSE, theme = theme_rt_urban,
  columns = list(
    libelle = colDef(name = "ZE", minWidth = 150),
    dm_sma_vtcam_1622 = colDef(name = "SMA 16-22", cell = bar_variation(stats_sma)),
    logd_px2_global_vevol_1624 = colDef(name = "Evol prix %", cell = bar_variation(stats_px)),
    idxlogtens_ind_22 = colDef(name = "Idx tension", cell = bar_pct())
  )
)
```


## jcn-table.R — style_divergent() + groupBy {jrr}

Coloration conditionnelle vert/rouge + regroupement hiérarchique avec agrégats.

```{r}
#| label: tbl-jcn-groupby
#| tbl-cap: "jcn-table.R — style_divergent() + groupBy agrégats"

ze %>%
  filter(!is.na(typo_insee_lib)) %>%
  select(typo_insee_lib, libelle, P22_POP, logd_px2_global_24,
         dm_sma_vtcam_1622, idxlogtens_ind_22) %>%
  reactable(
    groupBy = "typo_insee_lib",
    pagination = FALSE, theme = theme_rt_urban,
    columns = list(
      typo_insee_lib = colDef(name = "Typologie", minWidth = 180),
      libelle = colDef(name = "ZE", aggregate = "count",
                       format = colFormat(suffix = " ZE")),
      P22_POP = colDef(name = "Pop. totale", aggregate = "sum",
                       format = colFormat(separators = TRUE, digits = 0)),
      logd_px2_global_24 = colDef(name = "Prix m² méd.", aggregate = "median",
                                  format = colFormat(separators = TRUE, digits = 0)),
      dm_sma_vtcam_1622 = colDef(name = "SMA méd.", aggregate = "median",
                                  format = colFormat(digits = 2),
                                  style = style_divergent()),
      idxlogtens_ind_22 = colDef(name = "Idx tension méd.", aggregate = "median",
                                  format = colFormat(digits = 0))
    )
  )
```


# JCN Cartes R — jcn-map.R {jrr} {#sec-jcn-map}

## jcn-map.R — make_carte_france() {jrr}

Carte Leaflet interactive avec bins hybrides, légende, zoom, hover. Paramètres : `var`, `titre`, `bins`, `palette`, `label_col`, `suffix`, `source`.

```{r}
#| label: fig-jcn-carte-france
#| fig-cap: "jcn-map.R — make_carte_france() Leaflet interactif"

make_carte_france(
  map_ze, var = "logd_px2_global_24",
  titre = "Prix m² (EUR)", titre_carte = "Prix immobilier — DVF 2024",
  bins = c(0, 1000, 1500, 2000, 2500, 3000, 4000, 6000, 12000),
  label_col = "libze2020", suffix = " EUR",
  source = "Source : DVF 2024"
)
```


## jcn-map.R — make_pal() + pal_urbn_div {jrr}

Générateur de rampe couleur + palette divergente. Utilisable dans tmap et ggplot2.

```{r}
#| label: fig-jcn-tmap-grille
#| fig-cap: "jcn-map.R — make_pal(pal_urbn_div) dans tmap grille comparative"
#| fig-width: 14
#| fig-height: 7

tmap_mode("plot")
breaks_vac <- c(0, 3, 5, 7, 10, 13, 16, 20, 35)

m1 <- tm_shape(map_ze) +
  tm_polygons(
    fill = "log_vac_pct_22",
    fill.scale = tm_scale_intervals(breaks = breaks_vac,
                                    values = rev(make_pal(pal_urbn_div, 8)), midpoint = NA),
    fill.legend = tm_legend(title = "Vacance %"),
    col = "white", lwd = 0.3
  ) +
  tm_title("Vacance RP 2022")

m2 <- tm_shape(map_ze) +
  tm_polygons(
    fill = "logv_vac2ans_pct_24",
    fill.scale = tm_scale_intervals(breaks = c(0, 1, 2, 3, 4, 5, 7, 10, 20),
                                    values = rev(make_pal(pal_urbn_div, 8)), midpoint = NA),
    fill.legend = tm_legend(title = "Vacance 2+ans %"),
    col = "white", lwd = 0.3
  ) +
  tm_title("Vacance LOVAC 2+ ans 2024")

tmap_arrange(m1, m2, ncol = 2)
```


# JCN Python — jcn-graph.py {jpy} {#sec-jcn-python}

## jcn-graph.py — init_style() + plotly_layout() {jpy}

`init_style()` initialise le template Plotly "urban" + configure matplotlib/seaborn. `plotly_layout()` applique titre gauche, caption bas, légende haut.

```{python}
#| label: fig-jcn-plotly-layout
#| fig-cap: "jcn-graph.py — plotly_layout() avec titre/subtitle/caption"

dfp = df.dropna(subset=['logd_px2_global_24', 'dm_sma_vtcam_1622', 'typo_insee_lib']).copy()
fig = px.scatter(dfp, x='dm_sma_vtcam_1622', y='logd_px2_global_24',
                 size='P22_POP', color='typo_insee_lib',
                 hover_name='libelle', size_max=25, opacity=0.6,
                 labels={'dm_sma_vtcam_1622': 'SMA TCAM 16-22 (%/an)',
                         'logd_px2_global_24': 'Prix m² global (EUR)'})
fig = plotly_layout(fig,
                    title="plotly_layout() — Style Urban appliqué",
                    subtitle="Taille = population, couleur = typologie INSEE",
                    caption="Source : DVF 2024, INSEE RP 2022")
fig.update_layout(height=550)
fig.show()
```


## jcn-graph.py — correlation_matrix() {jpy}

Matrice corrélation triangle inférieur, palette RdBu_r, annotations. Paramètres : `cols`, `labels`, `threshold`, `figsize`.

```{python}
#| label: fig-jcn-corr-matrix
#| fig-cap: "jcn-graph.py — correlation_matrix() heatmap"

cols_corr = ['log_vac_pct_22', 'log_prop_pct_22', 'log_emmenrec_pct_22',
             'logd_px2_global_24', 'logv_vac2ans_pct_24', 'rev_med_21',
             'dm_sma_vtcam_1622', 'idxlogtens_ind_22', 'idxresid_dyn_ind_1623']
labels_corr = dict(zip(cols_corr, [
    'Vacance RP', 'Propriétaires', 'Emménagés',
    'Prix m²', 'Vac 2+ans', 'Rev médian',
    'SMA', 'Idx tension', 'Idx résid.']))

fig = correlation_matrix(df, cols=cols_corr, labels=labels_corr,
                         threshold=0.10, figsize=(650, 550))
fig.show()
```


# Plugins avancés R {jrr} {#sec-plugins-r}

## ggridges — Ridgeline densité multi-groupes {jrr}

Superpose densités par catégorie avec gradient couleur + lignes de quantiles (Q25, Q50, Q75). Révèle bimodalités cachées par les boxplots.

```{r}
#| label: fig-ridgeline-max
#| fig-cap: "ggridges — Ridgeline gradient + quantiles + annotation"
#| fig-height: 8
library(ggridges)
ze_ridg <- ze %>%
  filter(!is.na(typo_insee_lib), !is.na(logd_px2_global_24)) %>%
  mutate(typo = reorder(typo_insee_lib, logd_px2_global_24, FUN = median))

# Médiane globale pour annotation
med_global <- median(ze_ridg$logd_px2_global_24, na.rm = TRUE)

ggplot(ze_ridg, aes(x = logd_px2_global_24, y = typo, fill = after_stat(x))) +
  geom_density_ridges_gradient(scale = 1.8, rel_min_height = 0.01,
                                quantile_lines = TRUE, quantiles = c(0.25, 0.5, 0.75),
                                jittered_points = TRUE, point_size = 0.8,
                                point_alpha = 0.3, position = position_raincloud(adjust_vlines = TRUE)) +
  geom_vline(xintercept = med_global, linetype = "dashed", color = col_orange, linewidth = 0.6) +
  annotate("text", x = med_global + 100, y = 0.5, label = paste0("Médiane FR\n", fmt_fr(med_global, 0), " EUR"),
           color = col_orange, size = 2.8, hjust = 0, fontface = "italic") +
  scale_fill_viridis_c(name = "Prix m²", option = "C") +
  scale_x_continuous(labels = label_comma(big.mark = "\u202f")) +
  labs(title = "Les grandes métropoles forment un second mode au-dessus de 3 000 EUR",
       subtitle = "Lignes = Q25, médiane, Q75 | Points = observations individuelles",
       x = "Prix m² global (EUR)", y = NULL)
```


## ggplot2 — Lollipop segmenté + couleur encodée {jrr}

Lollipop avec couleur du point = variable secondaire. Encode 2 dimensions dans un ranking.

```{r}
#| label: fig-lollipop-max
#| fig-cap: "ggplot2 — Lollipop + couleur = SMA (2 dimensions)"

top20 <- ze %>%
  filter(!is.na(logd_px2_global_vevol_1624), !is.na(dm_sma_vtcam_1622)) %>%
  slice_max(logd_px2_global_vevol_1624, n = 20)

ggplot(top20, aes(x = logd_px2_global_vevol_1624,
                  y = reorder(libelle, logd_px2_global_vevol_1624))) +
  geom_segment(aes(xend = 0, yend = libelle), color = col_gray, linewidth = 0.6) +
  geom_point(aes(color = dm_sma_vtcam_1622), size = 4) +
  geom_vline(xintercept = 0, color = "#333", linewidth = 0.4) +
  geom_text(aes(label = paste0("+", round(logd_px2_global_vevol_1624, 1), "%")),
            hjust = -0.3, size = 2.5, color = col_spacegray) +
  scale_color_gradient2(low = col_red, mid = "white", high = col_green,
                        midpoint = 0, name = "SMA (%/an)") +
  labs(title = "Les ZE littorales et périurbaines mènent la hausse",
       subtitle = "Couleur = SMA — les hausses de prix ne corrèlent pas toujours avec l'attractivité",
       x = "Évolution prix m² 2016-2024 (%)", y = NULL)
```


## ggplot2 — Dumbbell T1→T2 avec flèches directionnelles {jrr}

Montre le changement entre deux dates. Segment + points + annotation direction.

```{r}
#| label: fig-dumbbell-max
#| fig-cap: "ggplot2 — Dumbbell + flèche direction + annotation ∆"

top15 <- ze %>%
  filter(!is.na(logv_vac2ans_pct_20), !is.na(logv_vac2ans_pct_24)) %>%
  mutate(delta = logv_vac2ans_pct_24 - logv_vac2ans_pct_20,
         direction = ifelse(delta > 0, "Hausse", "Baisse")) %>%
  slice_max(abs(delta), n = 15)

ggplot(top15, aes(y = reorder(libelle, delta))) +
  geom_segment(aes(x = logv_vac2ans_pct_20, xend = logv_vac2ans_pct_24, yend = libelle,
                   color = direction), linewidth = 0.8,
               arrow = arrow(length = unit(0.15, "cm"), type = "closed")) +
  geom_point(aes(x = logv_vac2ans_pct_20), size = 3, color = col_orange) +
  geom_point(aes(x = logv_vac2ans_pct_24), size = 3, color = col_cyan) +
  geom_text(aes(x = logv_vac2ans_pct_24, label = sprintf("%+.1f pt", delta)),
            hjust = -0.2, size = 2.3, color = col_spacegray) +
  scale_color_manual(values = c("Hausse" = col_red, "Baisse" = col_green), guide = "none") +
  labs(title = "Vacance longue durée : 2020 (orange) → 2024 (bleu)",
       subtitle = "Flèche rouge = aggravation, verte = amélioration | 15 ZE plus forte variation",
       x = "Vacance LOVAC 2+ ans (%)", y = NULL)
```


## ggplot2 — Slope chart T1→T2 + highlight trajectoire {jrr}

Pente entre deux périodes. Highlight les trajectoires extrêmes, grisé les autres.

```{r}
#| label: fig-slope-max
#| fig-cap: "ggplot2 — Slope chart avec highlight montée/descente"
#| fig-height: 8

top_ze <- ze %>% slice_max(P22_POP, n = 20)
slope_data <- top_ze %>%
  mutate(delta = idxresid_dyn_ind_1623 - idxresid_dyn_ind_1116,
         highlight = case_when(
           delta > 5 ~ "Montée forte",
           delta < -5 ~ "Descente forte",
           TRUE ~ "Stable"
         )) %>%
  select(libelle, T1 = idxresid_dyn_ind_1116, T2 = idxresid_dyn_ind_1623, highlight) %>%
  pivot_longer(cols = c(T1, T2), names_to = "periode", values_to = "idx")

ggplot(slope_data, aes(x = periode, y = idx, group = libelle)) +
  geom_line(data = slope_data %>% filter(highlight == "Stable"),
            color = col_gray, linewidth = 0.5, alpha = 0.5) +
  geom_line(data = slope_data %>% filter(highlight != "Stable"),
            aes(color = highlight), linewidth = 1.2) +
  geom_point(data = slope_data %>% filter(highlight != "Stable"),
             aes(color = highlight), size = 3) +
  geom_text_repel(data = slope_data %>% filter(periode == "T2", highlight != "Stable"),
                  aes(label = libelle, color = highlight),
                  hjust = -0.15, size = 3, direction = "y") +
  scale_x_discrete(expand = expansion(mult = c(0.05, 0.35))) +
  scale_color_manual(values = c("Montée forte" = col_green, "Descente forte" = col_red,
                                "Stable" = col_gray)) +
  labs(title = "Qui monte, qui descend entre T1 (11-16) et T2 (16-22) ?",
       subtitle = "Vert = hausse >5 pts, rouge = baisse >5 pts, gris = stable",
       x = NULL, y = "Indice résidentiel (0-100)") +
  guides(color = "none")
```


## ggplot2 — Small multiples facet_wrap + R² par groupe {jrr}

Même graphique répliqué par catégorie. Ajout R² et droite régression par facette.

```{r}
#| label: fig-small-mult-max
#| fig-cap: "ggplot2 — Small multiples + R² par facette"
#| fig-width: 12
#| fig-height: 8

ze_facet <- ze %>% filter(!is.na(typo_insee_lib))

# Calcul R² par groupe
r2_by_typo <- ze_facet %>%
  group_by(typo_insee_lib) %>%
  summarise(r2 = cor(logd_px2_global_24, dm_sma_vtcam_1622, use = "complete.obs")^2,
            n = n(),
            x_pos = max(logd_px2_global_24, na.rm = TRUE) * 0.7,
            y_pos = max(dm_sma_vtcam_1622, na.rm = TRUE) * 0.9,
            .groups = "drop")

ggplot(ze_facet, aes(x = logd_px2_global_24, y = dm_sma_vtcam_1622)) +
  geom_point(alpha = 0.5, color = col_cyan, size = 1.5) +
  geom_smooth(method = "lm", se = TRUE, color = col_orange, linewidth = 0.6, fill = col_yellow, alpha = 0.15) +
  geom_hline(yintercept = 0, linetype = "dashed", color = col_gray) +
  geom_text(data = r2_by_typo, aes(x = x_pos, y = y_pos,
                                    label = paste0("R²=", round(r2, 3), "\nn=", n)),
            color = col_spacegray, size = 2.8, hjust = 0.5) +
  facet_wrap(~ typo_insee_lib, scales = "free_x") +
  labs(title = "La relation prix × SMA n'est pas homogène entre typologies",
       subtitle = "Intervalle de confiance 95% + R² par groupe — attention aux n faibles",
       x = "Prix m² (EUR)", y = "SMA TCAM 16-22 (%/an)")
```


## sf + ggplot2 — Choroplèthe statique publication {jrr}

Carte statique haute qualité avec légende intégrée, labels grandes ZE, source.

:::: {.column-body-outset}

```{r}
#| label: fig-sf-choro-max
#| fig-cap: "sf + ggplot2 — Choroplèthe statique publication-ready"
#| fig-width: 10
#| fig-height: 8

top_ze_labels <- map_ze %>%
  filter(!is.na(logd_px2_global_24)) %>%
  slice_max(P22_POP, n = 12) %>%
  st_centroid()

ggplot(map_ze) +
  geom_sf(aes(fill = logd_px2_global_24), color = "white", linewidth = 0.15) +
  geom_sf_text(data = top_ze_labels, aes(label = libze2020),
               size = 2, color = col_black, fontface = "bold",
               check_overlap = TRUE) +
  scale_fill_viridis_c(name = "Prix m² (EUR)", option = "C",
                       labels = label_comma(big.mark = "\u202f"),
                       breaks = c(1000, 2000, 3000, 5000, 8000),
                       na.value = col_gray) +
  labs(title = "Prix immobilier global — Zones d'emploi",
       subtitle = "Labels = 12 ZE les plus peuplées",
       caption = "Source : DVF 2024 | Fond : IGN AdminExpress 2025") +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank(), legend.position = c(0.15, 0.35))
```

::::


## tmap — Carte bivariée 3×3 croissance × chômage {jrr}

Croise deux variables en 9 classes (ntile 3×3). Palette Joshua Stevens — chaque cellule encode 2 dimensions simultanément. Révèle des patterns invisibles en univarié.

:::: {.column-body-outset}

```{r}
#| label: fig-bivar-max
#| fig-cap: "tmap — Carte bivariée 3×3 : Croissance pop × Chômage"
#| fig-width: 12
#| fig-height: 10

map_ze_biv <- map_ze %>%
  mutate(
    q_pop = ntile(dm_pop_vtcam_1622, 3),
    q_chom = ntile(soc_txchom_1564_22, 3),
    bivar_class = paste0(q_pop, "-", q_chom)
  )

biv_colors <- c(
  "1-1" = "#e8e8e8", "1-2" = "#dfb0d6", "1-3" = "#be64ac",
  "2-1" = "#ace4e4", "2-2" = "#a5add3", "2-3" = "#8c62aa",
  "3-1" = "#5ac8c8", "3-2" = "#5698b9", "3-3" = "#3b4994"
)

tmap_mode("plot")
tm_shape(map_ze_biv) +
  tm_polygons(
    fill = "bivar_class",
    fill.scale = tm_scale_categorical(values = biv_colors),
    fill.legend = tm_legend(show = FALSE),
    col = "white", lwd = 0.3
  ) +
  tm_title("Croissance population (\u2192) \u00d7 Ch\u00f4mage (\u2191)") +
  tm_credits(
    "Violet fonc\u00e9 = d\u00e9clin + ch\u00f4mage \u00e9lev\u00e9 | Cyan = croissance + faible ch\u00f4mage\nPalette Joshua Stevens (3\u00d73 ntile cross)",
    position = c("left", "bottom"), size = 0.8
  )
```

::::


## tmap — Facettes par typologie INSEE {jrr}

Même indicateur, décliné par profil territorial. `tm_facets(free.coords = FALSE)` maintient la même échelle géographique — les différences de pattern sautent aux yeux.

:::: {.column-body-outset}

```{r}
#| label: fig-facets-typo-max
#| fig-cap: "tmap — Facettes TCAM population par profil INSEE (7 types)"
#| fig-width: 14
#| fig-height: 10

map_ze_facet <- map_ze %>%
  filter(!is.na(typo_insee_7)) %>%
  mutate(typo_label = case_when(
    typo_insee_7 == 1 ~ "1-M\u00e9tropoles",
    typo_insee_7 == 2 ~ "2-Gdes agglo",
    typo_insee_7 == 3 ~ "3-R\u00e9sidentielles",
    typo_insee_7 == 4 ~ "4-Agriculture",
    typo_insee_7 == 5 ~ "5-Industrie",
    typo_insee_7 == 6 ~ "6-Tourisme",
    typo_insee_7 == 7 ~ "7-Diversifi\u00e9es"
  ))

breaks_pop <- c(-2, -1, -0.5, 0, 0.3, 0.6, 1, 1.5, 3)

tm_shape(map_ze_facet) +
  tm_polygons(
    fill = "dm_pop_vtcam_1622",
    fill.scale = tm_scale_intervals(
      breaks = breaks_pop, values = make_pal(pal_urbn_div, 8), midpoint = NA
    ),
    fill.legend = tm_legend(show = FALSE),
    col = "white", lwd = 0.2
  ) +
  tm_facets(by = "typo_label", ncol = 4, free.coords = FALSE) +
  tm_title("TCAM Population 2016-2022 par profil INSEE")
```

::::


## mapview — Exploration interactive hover {jrr}

Tooltip au survol sans clic. Idéal pour EDA rapide. `mapview::mapview()` génère un Leaflet léger avec label au hover.

```{r}
#| label: fig-mapview-max
#| fig-cap: "mapview — Carte interactive SMA avec tooltip hover multi-champs"

library(mapview)

mapview(map_ze, zcol = "dm_sma_vtcam_1622",
  layer.name = "TCAM SMA 16-22",
  col.regions = make_pal(pal_urbn_div, 8),
  alpha.regions = 0.8,
  label = paste0(
    map_ze$libelle, " | SMA: ", round(map_ze$dm_sma_vtcam_1622, 2), "%/an",
    " | Pop: ", format(map_ze$P22_POP, big.mark = "\u202f")
  ),
  legend = TRUE, homebutton = FALSE)
```


# Plugins avancés Python {jpy} {#sec-plugins-py}

## seaborn — Violin + strip + annotations stats {jpy}

Densité complète + points individuels + médiane annotée par groupe.

```{python}
#| label: fig-violin-max
#| fig-cap: "seaborn — Violin + strip + médiane annotée"
#| fig-height: 8

dv = df.dropna(subset=['typo_insee_lib', 'log_vac_pct_22']).copy()
order = dv.groupby('typo_insee_lib')['log_vac_pct_22'].median().sort_values(ascending=False).index
medians = dv.groupby('typo_insee_lib')['log_vac_pct_22'].median()

fig, ax = plt.subplots(figsize=(10, 8))
sns.violinplot(data=dv, y='typo_insee_lib', x='log_vac_pct_22',
               order=order, inner=None, palette='viridis', alpha=0.35, ax=ax)
sns.stripplot(data=dv, y='typo_insee_lib', x='log_vac_pct_22',
              order=order, size=5, alpha=0.6, color=COL_CYAN, ax=ax, jitter=0.2)

# Annotate medians
for i, typo in enumerate(order):
    med = medians[typo]
    ax.plot(med, i, 'D', color=COL_ORANGE, markersize=8, zorder=5)
    ax.annotate(f'{med:.1f}%', xy=(med, i), xytext=(med + 0.5, i - 0.3),
                fontsize=8, color=COL_ORANGE, fontweight='bold')

ax.axvline(dv['log_vac_pct_22'].median(), color=COL_ORANGE, linestyle='--', alpha=0.5, label='Médiane FR')
ax.set_title('Les ZE rurales ont une vacance plus dispersée', fontweight='bold', fontsize=14)
ax.set_xlabel('Taux de vacance RP 2022 (%)')
ax.set_ylabel(None)
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
```


## plotly.express — Scatter 5D interactif hover enrichi {jpy}

5 dimensions visuelles : x, y, taille, couleur, hover multi-champs. Animation frame possible.

```{python}
#| label: fig-scatter-5d-max
#| fig-cap: "plotly.express — Scatter 5D : x, y, size, color, hover détaillé"

dfp = df.dropna(subset=['logd_px2_global_24', 'dm_sma_vtcam_1622',
                         'typo_insee_lib', 'idxlogtens_ind_22']).copy()
dfp['hover_text'] = dfp.apply(
    lambda r: f"<b>{r['libelle']}</b><br>"
              f"Pop: {r['P22_POP']:,.0f}<br>"
              f"Prix: {r['logd_px2_global_24']:,.0f} EUR/m²<br>"
              f"SMA: {r['dm_sma_vtcam_1622']:+.2f}%/an<br>"
              f"Idx tension: {r['idxlogtens_ind_22']:.0f}/100<br>"
              f"Vacance 2+ans: {r.get('logv_vac2ans_pct_24', 0):.1f}%",
    axis=1)

fig = px.scatter(dfp, x='dm_sma_vtcam_1622', y='logd_px2_global_24',
                 size='P22_POP', color='idxlogtens_ind_22',
                 hover_name='libelle', size_max=30, opacity=0.7,
                 color_continuous_scale='RdYlBu_r', range_color=[10, 90],
                 custom_data=['hover_text'],
                 labels={'dm_sma_vtcam_1622': 'SMA TCAM 16-22 (%/an)',
                         'logd_px2_global_24': 'Prix m² global (EUR)',
                         'idxlogtens_ind_22': 'Tension'})
fig.update_traces(hovertemplate='%{customdata[0]}<extra></extra>')
fig.add_hline(y=dfp['logd_px2_global_24'].median(), line_dash="dash",
              line_color=COL_ORANGE, annotation_text="Médiane prix")
fig.add_vline(x=0, line_dash="dash", line_color="gray",
              annotation_text="SMA = 0")
fig = plotly_layout(fig,
                    title="Prix vs attractivité — 5 dimensions",
                    subtitle="Taille=pop, couleur=tension, hover=multi-indicateurs",
                    caption="Source : DVF 2024, INSEE RP, LOVAC")
fig.update_layout(height=600)
fig.show()
```


## plotly.express — Parallel coordinates sélection interactive {jpy}

Profil multi-dimensionnel. Sélection par plage sur chaque axe pour filtrer les ZE.

```{python}
#| label: fig-parallel-max
#| fig-cap: "plotly.express — Parallel coordinates avec sélection interactive"

cols_tmi = ['dmf_tmi_cscadre_22', 'dmf_tmi_csouvrier_22',
            'dmf_tmi_a65p_22', 'dmf_tmi_a2529_22']
dfpc = df.dropna(subset=cols_tmi + ['dm_sma_vtcam_1622']).copy()

fig = px.parallel_coordinates(
    dfpc, dimensions=cols_tmi + ['dm_sma_vtcam_1622'],
    color='dm_sma_vtcam_1622',
    color_continuous_scale='RdBu', color_continuous_midpoint=0,
    labels={
        'dmf_tmi_cscadre_22': 'TMI Cadres',
        'dmf_tmi_csouvrier_22': 'TMI Ouvriers',
        'dmf_tmi_a65p_22': 'TMI 65+',
        'dmf_tmi_a2529_22': 'TMI 25-29',
        'dm_sma_vtcam_1622': 'SMA'
    }
)
fig.update_layout(
    title=dict(text="<b>Profil migratoire par catégorie — glisser pour filtrer</b><br>"
                    "<span style='font-size:12px;color:gray'>Cliquer-glisser sur un axe pour sélectionner une plage</span>"),
    height=550, font_size=12
)
fig.show()
```


## plotly.express — Treemap hiérarchique drill-down {jpy}

Hiérarchie imbriquée avec surface ∝ population, couleur = indicateur. Click pour zoomer.

```{python}
#| label: fig-treemap-max
#| fig-cap: "plotly.express — Treemap hiérarchique population × prix"

dft = df.dropna(subset=['typo_insee_lib', 'logd_px2_global_24', 'libelle']).copy()
dft = dft[dft['libelle'].str.strip() != ''].copy()
dft['prix_cat'] = pd.cut(dft['logd_px2_global_24'],
                          bins=[0, 1500, 2500, 4000, 15000],
                          labels=['< 1 500', '1 500-2 500', '2 500-4 000', '> 4 000'])
fig = px.treemap(dft, path=['typo_insee_lib', 'prix_cat', 'libelle'],
                 values='P22_POP',
                 color='logd_px2_global_24',
                 color_continuous_scale='Viridis',
                 color_continuous_midpoint=2500,
                 hover_data={'P22_POP': ':,.0f', 'logd_px2_global_24': ':,.0f'})
fig.update_layout(title="<b>Répartition population : Typologie → Gamme prix → ZE</b><br>"
                        "<span style='font-size:12px;color:gray'>Cliquer pour zoomer dans la hiérarchie</span>",
                  height=650)
fig.update_traces(textinfo='label+percent parent',
                  hovertemplate='<b>%{label}</b><br>Pop: %{value:,.0f}<br>Prix: %{color:,.0f} EUR/m²')
fig.show()
```


## plotly.go — Sankey transitions quadrants T1→T2 {jpy}

Flux entre états : épaisseur ∝ volume. Couleur des liens = quadrant source.

```{python}
#| label: fig-sankey-max
#| fig-cap: "plotly.go — Sankey transitions SN×SMA avec flux colorés"

def quad(sn, sma):
    if sn >= 0 and sma >= 0: return "Double moteur"
    if sn < 0 and sma >= 0: return "Compensation"
    if sn < 0 and sma < 0: return "Double déficit"
    return "Fuite"

cols_q = ['dm_sn_vtcam_1116','dm_sma_vtcam_1116','dm_sn_vtcam_1622','dm_sma_vtcam_1622']
dfs = df.dropna(subset=cols_q).copy()
dfs['q_t1'] = dfs.apply(lambda r: quad(r[cols_q[0]], r[cols_q[1]]), axis=1)
dfs['q_t2'] = dfs.apply(lambda r: quad(r[cols_q[2]], r[cols_q[3]]), axis=1)
flows = dfs.groupby(['q_t1','q_t2']).size().reset_index(name='n')
# Add average population per flow
flows_pop = dfs.groupby(['q_t1','q_t2'])['P22_POP'].sum().reset_index(name='pop_total')
flows = flows.merge(flows_pop, on=['q_t1','q_t2'])

cats = ["Double moteur", "Compensation", "Double déficit", "Fuite"]
colors_node = [COL_CYAN, COL_GREEN, COL_RED, "#fdbf11"]
colors_link_map = {c: f"rgba({int(h[1:3],16)},{int(h[3:5],16)},{int(h[5:7],16)},0.4)"
                   for c, h in zip(cats, colors_node)}

labels = [f"{c} (T1)" for c in cats] + [f"{c} (T2)" for c in cats]
node_colors = colors_node * 2
idx_t1 = {c: i for i, c in enumerate(cats)}
idx_t2 = {c: i + 4 for i, c in enumerate(cats)}

link_colors = [colors_link_map[r['q_t1']] for _, r in flows.iterrows()]
hover_texts = [f"{r['q_t1']} → {r['q_t2']}<br>{r['n']} ZE<br>Pop totale: {r['pop_total']:,.0f}"
               for _, r in flows.iterrows()]

fig = go.Figure(go.Sankey(
    node=dict(label=labels, color=node_colors, pad=20, thickness=25),
    link=dict(
        source=[idx_t1[r['q_t1']] for _, r in flows.iterrows()],
        target=[idx_t2[r['q_t2']] for _, r in flows.iterrows()],
        value=flows['n'].tolist(),
        color=link_colors,
        customdata=hover_texts,
        hovertemplate='%{customdata}<extra></extra>'
    )
))
fig.update_layout(
    title=dict(text="<b>Transitions démographiques T1 (11-16) → T2 (16-22)</b><br>"
                    "<span style='font-size:12px;color:gray'>Épaisseur = nombre de ZE | Couleur = quadrant source</span>"),
    height=500, font_size=13
)
fig.show()
```


# Layout Quarto {#sec-layout}

## body-outset — graphique élargi {.column-body-outset}

Déborde du corps de texte. `:::: {.column-body-outset}` ou attribut H2.

```{r}
#| label: fig-layout-outset
#| fig-cap: "body-outset — graphique plus large que le texte"
#| fig-width: 12
ggplot(ze, aes(x = logd_px2_global_24, y = idxlogtens_ind_22, color = typo_insee_lib)) +
  geom_point(alpha = 0.6, size = 2) +
  labs(title = "Démonstration body-outset — déborde du corps de texte",
       x = "Prix m²", y = "Idx tension", color = "Typo")
```


## layout-ncol: 2 — côte à côte

Deux graphiques juxtaposés. Syntaxe : `::: {layout-ncol=2}`.

::: {layout-ncol=2}

```{r}
#| label: fig-layout-left
#| fig-cap: "Gauche — distribution prix"
#| fig-height: 4
plot_distribution(ze, "logd_px2_global_24", bins = 25,
                  title = "Prix m² global", xlab = "EUR")
```

```{r}
#| label: fig-layout-right
#| fig-cap: "Droite — distribution vacance"
#| fig-height: 4
plot_distribution(ze, "logv_vac2ans_pct_24", bins = 25,
                  title = "Vacance 2+ ans", xlab = "%", fill = col_orange)
```

:::


## panel-tabset — graphique + tableau

Pattern INSEE : synthèse graphique vs données complètes.

::: {.panel-tabset}

### Graphique

```{r}
#| label: fig-tabset-graph
plot_bar_ranked(ze, "idxlogtens_ind_22", "libelle",
                title = "Indice tension logement",
                subtitle = "Top/bottom 15 ZE", xlab = "Indice (0-100)")
```

### Tableau complet

```{r}
#| label: tbl-tabset-table
ze %>%
  select(libelle, idxlogtens_ind_22, logd_px2_global_24, logv_vac2ans_pct_24) %>%
  arrange(desc(idxlogtens_ind_22)) %>%
  rt_styled(
    searchable = TRUE, height = 400,
    columns = list(
      libelle = colDef(name = "ZE", minWidth = 150),
      idxlogtens_ind_22 = colDef(name = "Idx tension", format = colFormat(digits = 0)),
      logd_px2_global_24 = col_num("Prix m²", unit = "EUR"),
      logv_vac2ans_pct_24 = col_pct("Vac 2+ans", unit = "%")
    )
  )
```

:::


## column-margin — note latérale

::: {.column-margin}
**Note technique** : Les indices composites sont calculés par z-score (cap ±3σ, rescale 0-100, centre 50). Voir `rpt-11-idx-sensibilite.qmd` pour l'analyse de sensibilité.
:::

Le texte principal coule normalement. La note en marge s'affiche à droite sur écrans larges (> 1200px), en dessous sur mobile.

---

::: {.callout-tip appearance="minimal"}
**Récapitulatif — Helpers JCN vs Plugins**

| Fonction | Source | Type | Usage |
|----------|--------|------|-------|
| `theme_urbn()` | jcn-graph.R | Auto | Thème ggplot2 global |
| `plot_bar_ranked()` | jcn-graph.R | {jrr} | Ranking top/bottom N |
| `scale_fill_urbn_div()` | jcn-graph.R | {jrr} | Palette divergente ± |
| `fmt_fr()`, `fmt_pct()` | jcn-graph.R | {jrr} | Formatage français |
| `plot_distribution()` | _setup-common.R | {jrr} | Histogramme + rug |
| `scatter_quadrants()` | _setup-common.R | {jrr} | Scatter 2D quadrants |
| `rt_styled()` | jcn-table.R | {jrr} | Table reactable stylée |
| `col_pop()`, `col_variation()` | jcn-table.R | {jrr} | Colonnes typées |
| `bar_variation()`, `bar_pct()` | jcn-table.R | {jrr} | Barres inline ±/% |
| `style_divergent()` | jcn-table.R | {jrr} | Coloration vert/rouge |
| `make_carte_france()` | jcn-map.R | {jrr} | Leaflet interactif |
| `make_pal()` | jcn-map.R | {jrr} | Rampe couleur N |
| `plotly_layout()` | jcn-graph.py | {jpy} | Layout Plotly Urban |
| `correlation_matrix()` | jcn-graph.py | {jpy} | Heatmap corrélation |
| `init_style()` | jcn-graph.py | {jpy} | Init template global |

| Plugin externe | Type | Usage max |
|----------------|------|-----------|
| ggridges | {jrr} | Ridgeline gradient + quantiles + rain cloud |
| seaborn | {jpy} | Violin + strip + médiane annotée |
| plotly.express | {jpy} | Scatter 5D, parallel coords, treemap |
| plotly.go | {jpy} | Sankey flux colorés + hover enrichi |
| sf + ggplot2 | {jrr} | Choroplèthe + labels centroids |
| tmap | {jrr} | Grille, bivariée 3×3, facettes typo |
| mapview | {jrr} | Interactive hover, EDA rapide |
:::
