Marché du logement et tension immobilière

Volet 3 — Analyse multi-échelon (DEP · EPCI · ZE)

Rapport d’analyse
Author

Vincent R.

Published

15 February 2026

Synthèse exécutive

2 725 EUR/m² Prix médian France DVF 2024 · facteur 12.4 entre extrêmes

823k Logements commencés SITADEL cumul 2022-2024

67,1 M m² Surface commencée SITADEL cumul 2022-2024

3,0 % Vacance 2+ ans Médiane DEP · LOVAC 2024

Note4 résultats clés — Volet logement

1. Prix ≠ attractivité — Corrélation prix × SMA = 0,34 (ZE). Le prix capte autant la rente foncière que l’attractivité courante. Corrélation prix × revenu bien plus forte (0,75 DEP).

2. Construction en chute — 823k logements commencés (22-24), soit 67,1 M m² de surface. Médiane 12,2 logements / 1 000 hab.

3. Vacance = thermomètre déprise — Vacance 2+ ans corrèle à 0,02 avec le SMA (DEP). 174 EPCI voient leur vacance augmenter 2020-2024.

4. Loyers en hausse modérée — Médiane loyer appt 11,5 EUR/m² (+11,4 % 22-25), maison 10,2 EUR/m².

Données : DVF 2014-2024 · LOVAC 2020-2024 · SITADEL 2019-2024 · ANIL 2022-2025 · INSEE RP 2016-2022

Périmètre : 96 départements · 1244 EPCI · 287 zones d’emploi (hors DOM)

Hypothèses : H4 La pression immobilière est un proxy de l’attractivité résidentielle · H6 Le cycle foncier prédit les dynamiques territoriales


Cadrage : distributions clés

Le marché du logement est un révélateur structurel des dynamiques territoriales. Contrairement aux flux migratoires — qui captent le mouvement des personnes — le logement traduit la résultante matérielle de l’attractivité : prix, vacance, construction. Un territoire qui attire voit ses prix monter, sa vacance baisser et sa construction accélérer.

plot_distribution(dep, "logd_px2_global_24", bins = 25,
  title = "Prix concentrés sous 3 000 EUR/m²",
  xlab = "Prix médian m² (EUR)", fill = col_cyan) +
  geom_vline(xintercept = fr_px, linetype = "solid", color = col_orange, linewidth = 0.7) +
  annotate("text", x = fr_px + 200, y = Inf,
           label = glue("France {fmt_fr(fr_px, 0)}"),
           color = col_orange, size = 3, vjust = 2, fontface = "italic") +
  labs(y = "Nombre de DEP",
       caption = make_source("DVF 2024"))
plot_distribution(dep, "logv_vac2ans_pct_24", bins = 20,
  title = "Vacance bimodale : tension vs déprise",
  xlab = "Vacance longue durée (%)", fill = col_green) +
  labs(y = "Nombre de DEP",
       caption = make_source("LOVAC 2024"))
plot_distribution(dep, "logs_logcom_tx1000_2224", bins = 20,
  title = "Construction neuve très inégale",
  xlab = "Logements commencés / 1 000 hab", fill = col_magenta) +
  labs(y = "Nombre de DEP",
       caption = make_source("SITADEL 2022-2024"))
Warning: Removed 3 rows containing non-finite outside the scale range (`stat_bin()`).
Warning: Removed 3 rows containing missing values or values outside the scale range (`geom_rug()`).

Les distributions révèlent des marchés asymétriques. Les prix sont concentrés sous 3 000 EUR/m² avec queue droite (IDF, littoral). La vacance est bimodale (tension <2% vs déprise >5%). La construction neuve varie d’un facteur 10 entre départements.


QE3.1 — Géographie des prix

16 départements en baisse de prix 2022-2024 (médiane : +2,64 %). Le retournement post-2022 touche inégalement les territoires.

tm_shape(map_dep) +
  tm_polygons(fill = "logd_px2_global_24",
    fill.scale = tm_scale_intervals(breaks = breaks_px,
      values = make_pal(pal_urbn_seq_blue, 8)),
    fill.legend = tm_legend_clean("EUR/m²"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Prix m² global — DVF 2024") +
  tm_source(make_source("DVF"))
tm_shape(map_dep) +
  tm_polygons(fill = "logd_px2_global_vevol_2224",
    fill.scale = tm_scale_intervals(
      breaks = c(-15, -10, -5, -2, 0, 2, 5, 10, 15),
      values = make_pal(pal_urbn_div, 8), midpoint = NA),
    fill.legend = tm_legend_clean("% 22-24"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Évolution prix 2022-2024") +
  tm_source(make_source("DVF"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

Gauche : prix au m² médian DVF 2024. Droite : évolution récente 2022-2024. Le retournement post-COVID (hausse des taux) touche surtout l’IDF et les grandes métropoles.

plot_bar_ranked(
  dep, var = "logd_px2_global_24", label_col = "libelle",
  n_show = 15,
  title = glue("Facteur {ratio_px} entre extrêmes"),
  subtitle = glue("Top/Bottom 15 départements — prix m² DVF 2024"),
  xlab = "Prix m² (EUR)",
  caption = make_source("DVF 2024")
)
Warning: Specifying width/height in layout() is now deprecated.
Please specify in ggplotly() or plot_ly()
tm_shape(map_epci) +
  tm_polygons(fill = "logd_px2_global_24",
    fill.scale = tm_scale_intervals(breaks = breaks_px,
      values = make_pal(pal_urbn_seq_blue, 8)),
    fill.legend = tm_legend_clean("EUR/m²"),
    col = "grey90", lwd = 0.1) +
  tm_urbn("Prix m² global — EPCI, DVF 2024") +
  tm_source(make_source("DVF"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

plot_bar_ranked(
  epci %>% filter(!is.na(logd_px2_global_24)),
  var = "logd_px2_global_24", label_col = "libelle",
  n_show = 20,
  title = "Extrêmes de prix au m² — EPCI",
  subtitle = glue("Top/Bottom 20 — {N_EPCI} EPCI métropolitains"),
  xlab = "Prix m² (EUR)",
  caption = make_source("DVF 2024")
)
Warning: Specifying width/height in layout() is now deprecated.
Please specify in ggplotly() or plot_ly()
labs_px <- label_top_bottom(ze, "logd_px2_global_24", n = 10)

ggplot(ze, aes(x = dm_sma_vtcam_1622, y = logd_px2_global_24)) +
  geom_hline(yintercept = fr_px, linetype = "dashed", color = col_orange, linewidth = 0.5) +
  geom_vline(xintercept = 0, linetype = "dashed", color = col_gray) +
  geom_point(aes(size = P22_POP), alpha = 0.5, color = col_cyan) +
  geom_text_repel(
    data = ze %>% filter(libelle %in% labs_px),
    aes(label = libelle), size = 2.8, max.overlaps = 20,
    color = col_spacegray, segment.color = col_gray
  ) +
  scale_size_continuous(range = c(1, 7), guide = "none") +
  scale_y_continuous(labels = label_comma(big.mark = "\u202f")) +
  labs(
    title = "Prix élevé ≠ attractivité migratoire",
    subtitle = glue("r = {fmt_fr(cor_px_sma_ze, 2)} | {N_ZE} ZE"),
    x = "SMA TCAM 2016-2022 (%/an)", y = "Prix médian m² (EUR)",
    caption = make_source("DVF", "MIGCOM")
  )
Warning: Removed 23 rows containing missing values or values outside the scale range (`geom_point()`).

Taille = population 2022. Orange = France (2 725 EUR/m²). ZE en bas-droite (SMA positif, prix modérés) = attractivité non encore capitalisée.


QE3.2 — Construction neuve et surfaces (SITADEL)

67,1 millions de m² commencés 2022-2024 — la concentration de la construction est plus forte que celle de la population (Gini = 0,448).

La construction neuve traduit la capacité d’un territoire à répondre à la demande. Le taux de construction (logements commencés pour 1 000 habitants) est l’indicateur le plus discriminant : il reflète à la fois la pression démographique, la disponibilité foncière et l’effort des collectivités.

tm_shape(map_dep) +
  tm_polygons(fill = "logs_logcom_tx1000_2224",
    fill.scale = tm_scale_intervals(n = 7,
      values = make_pal(pal_urbn_seq_green, 7)),
    fill.legend = tm_legend_clean("log/1000 hab"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Logements commencés / 1 000 hab") +
  tm_source(make_source("SITADEL 2022-2024"))
tm_shape(map_dep) +
  tm_polygons(fill = "logs_sdpcom_vol_2224",
    fill.scale = tm_scale_intervals(n = 7, style = "quantile",
      values = make_pal(pal_urbn_purple_green, 7)),
    fill.legend = tm_legend_clean("m² cumul 22-24"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Surface de plancher commencée (m²)") +
  tm_source(make_source("SITADEL 2022-2024"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

Gauche : taux de construction rapporté à la population. Droite : volume brut de surface de plancher commencée (m²). Les littoraux et couronnes métropolitaines construisent le plus.

plot_bar_ranked(
  dep, var = "logs_logcom_tx1000_2224", label_col = "libelle",
  n_show = 15,
  title = "Taux de construction — Top/Bottom 15 DEP",
  subtitle = "Logements commencés / 1 000 hab (2022-2024)",
  xlab = "Log commencés / 1 000 hab",
  caption = make_source("SITADEL")
)
plot_bar_ranked(
  dep, var = "logs_sdpcom_vol_2224", label_col = "libelle",
  n_show = 15,
  title = "Surface m² commencée — Top/Bottom 15 DEP",
  subtitle = "Cumul 2022-2024 (m² de plancher)",
  xlab = "Surface commencée (m²)",
  caption = make_source("SITADEL")
)
Warning: Specifying width/height in layout() is now deprecated.
Please specify in ggplotly() or plot_ly()
Warning: Specifying width/height in layout() is now deprecated.
Please specify in ggplotly() or plot_ly()
tm_shape(map_epci) +
  tm_polygons(fill = "logs_logcom_tx1000_2224",
    fill.scale = tm_scale_intervals(n = 7,
      values = make_pal(pal_urbn_seq_green, 7)),
    fill.legend = tm_legend_clean("log/1000 hab"),
    col = "grey90", lwd = 0.1) +
  tm_urbn("Logements commencés / 1 000 hab — EPCI") +
  tm_source(make_source("SITADEL 2022-2024"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

sdp_vals <- dep$logs_sdpcom_vol_2224[!is.na(dep$logs_sdpcom_vol_2224) &
            dep$logs_sdpcom_vol_2224 > 0]
sdp_sorted <- sort(sdp_vals)
n_sdp <- length(sdp_sorted)
cum_share <- cumsum(sdp_sorted) / sum(sdp_sorted)
pop_share <- seq_len(n_sdp) / n_sdp
lorenz_df <- data.frame(pop = c(0, pop_share), sdp = c(0, cum_share))

p80_idx <- which.min(abs(pop_share - 0.80))
sdp_at_80 <- cum_share[p80_idx] * 100

ggplot(lorenz_df, aes(x = pop, y = sdp)) +
  geom_line(color = col_cyan, linewidth = 1.2) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = col_gray) +
  geom_segment(aes(x = 0.80, xend = 0.80, y = 0, yend = cum_share[p80_idx]),
               linetype = "dotted", color = col_orange) +
  geom_segment(aes(x = 0, xend = 0.80, y = cum_share[p80_idx], yend = cum_share[p80_idx]),
               linetype = "dotted", color = col_orange) +
  annotate("text", x = 0.55, y = cum_share[p80_idx] + 0.04,
           label = glue("80% des DEP = {fmt_pct(sdp_at_80, 0)} m²"),
           size = 3.5, color = col_orange, fontface = "bold") +
  annotate("text", x = 0.35, y = 0.85,
           label = glue("Gini = {fmt_fr(gini_sdp, 3)}"),
           size = 4, color = col_cyan, fontface = "bold") +
  scale_x_continuous(labels = percent_format(), expand = c(0, 0.01)) +
  scale_y_continuous(labels = percent_format(), expand = c(0, 0.01)) +
  labs(title = "Construction neuve : forte concentration territoriale",
       subtitle = "Courbe de Lorenz — surface commencée par département",
       x = "Part cumulée des départements",
       y = "Part cumulée des m² commencés",
       caption = make_source("SITADEL 2022-2024"))
Warning in geom_segment(aes(x = 0.8, xend = 0.8, y = 0, yend = cum_share[p80_idx]), : All aesthetics have length 1, but the data has 97 rows.
ℹ Please consider using `annotate()` or provide this layer with data containing a single row.
Warning in geom_segment(aes(x = 0, xend = 0.8, y = cum_share[p80_idx], yend = cum_share[p80_idx]), : All aesthetics have length 1, but the data has 97 rows.
ℹ Please consider using `annotate()` or provide this layer with data containing a single row.

Courbe de Lorenz sur la surface de plancher commencée (m²). Plus la courbe s’éloigne de la diagonale, plus la construction est concentrée géographiquement. Gini = 0,448.

scatter_quadrants(
  dep %>% filter(!is.na(logs_logcom_tx1000_2224)),
  x = "dm_pop_vtcam_1622", y = "logs_logcom_tx1000_2224",
  title = "Construction neuve suit la croissance démographique",
  subtitle = glue("r = {fmt_fr(cor(dep$dm_pop_vtcam_1622, dep$logs_logcom_tx1000_2224, use='complete.obs'), 2)} | {N_DEP} DEP"),
  xlab = "TCAM pop 2016-2022 (%/an)",
  ylab = "Log commencés / 1 000 hab",
  hline = med_txcom, vline = 0,
  quad_labels = c("Croissant\n+ construit", "Déclinant\n+ construit",
                  "Déclinant\n- construit", "Croissant\n- construit")
) +
  labs(caption = make_source("SITADEL", "INSEE RP"))


QE3.3 — Vacance structurelle

Vacance 2+ ans : 174 EPCI en hausse 2020-2024. La corrélation vacance × SMA = 0,02 (DEP) confirme le lien entre déprise démographique et abandon du parc.

La vacance structurelle (LOVAC >2 ans) discrimine mieux la dévitalisation que la vacance du recensement. Elle capte les logements durablement hors marché — un signal fort de déprise territoriale.

tm_shape(map_dep) +
  tm_polygons(fill = "logv_vac2ans_pct_24",
    fill.scale = tm_scale_intervals(breaks = breaks_vac,
      values = rev(make_pal(pal_urbn_seq_green, 8))),
    fill.legend = tm_legend_clean("% vac 2+ans"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Vacance 2+ ans — LOVAC 2024") +
  tm_source(make_source("LOVAC"))
tm_shape(map_dep) +
  tm_polygons(fill = "logv_vac2ans_vdifp_2024",
    fill.scale = tm_scale_intervals(n = 7, style = "quantile",
      values = make_pal(pal_urbn_purple_green, 7)),
    fill.legend = tm_legend_clean("pts 20-24"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Évolution vacance 2020-2024 (pts)") +
  tm_source(make_source("LOVAC"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily
[scale] tm_polygons:() the data variable assigned to 'fill' contains positive and negative values, so midpoint is set to 0. Set 'midpoint = NA' in 'fill.scale = tm_scale_intervals(<HERE>)' to use all visual values (e.g. colors)

Gauche : taux de vacance longue durée 2024. Droite : évolution 2020-2024 (points de %). Nord-est et Massif central concentrent vacance élevée et aggravation.

tm_shape(map_epci) +
  tm_polygons(fill = "logv_vac2ans_pct_24",
    fill.scale = tm_scale_intervals(breaks = breaks_vac,
      values = rev(make_pal(pal_urbn_seq_green, 8))),
    fill.legend = tm_legend_clean("% vac 2+ans"),
    col = "grey90", lwd = 0.1) +
  tm_urbn("Vacance 2+ ans — EPCI, LOVAC 2024") +
  tm_source(make_source("LOVAC"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

scatter_quadrants(
  dep, x = "dm_sma_vtcam_1622", y = "logv_vac2ans_pct_24",
  title = "Déclin démographique = vacance longue durée",
  subtitle = glue("r = {fmt_fr(cor_vac_sma_dep, 2)} | LOVAC 2+ ans vs SMA 16-22 | {N_DEP} DEP"),
  xlab = "SMA TCAM 16-22 (%/an)", ylab = "Vacance 2+ ans (%)",
  hline = med_vac_dep, vline = 0,
  quad_labels = c("Attractif tendu", "Attractif détendu",
                  "Déclin détendu", "Déclin tendu")
) +
  labs(caption = make_source("LOVAC", "INSEE RP"))

plot_bar_ranked(
  dep, var = "logv_vac2ans_pct_24", label_col = "libelle",
  n_show = 15,
  title = "Vacance 2+ ans — Top/Bottom 15 DEP",
  subtitle = glue("LOVAC 2024 | Médiane = {fmt_pct(med_vac_dep)}"),
  xlab = "Vacance 2+ ans (%)",
  caption = make_source("LOVAC 2024")
)
Warning: Specifying width/height in layout() is now deprecated.
Please specify in ggplotly() or plot_ly()

QE3.4 — Loyers et accessibilité (ANIL)

Loyer médian appt : 11,5 EUR/m² (hausse médiane +11,4 % 22-25). L’accessibilité se dégrade dans les marchés déjà tendus.

Les données ANIL (Observatoires Locaux des Loyers) permettent de compléter le tableau DVF par les prix à la location. La comparaison prix d’achat / loyer / revenu médian donne un indicateur d’accessibilité territoriale.

tm_shape(map_dep) +
  tm_polygons(fill = "logl_app_m2_25",
    fill.scale = tm_scale_intervals(n = 7,
      values = make_pal(pal_urbn_seq_blue, 7)),
    fill.legend = tm_legend_clean("EUR/m²"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Loyer appt (tous types) — 2025") +
  tm_source(make_source("ANIL/OLL"))
tm_shape(map_dep) +
  tm_polygons(fill = "logl_app_evol_2225",
    fill.scale = tm_scale_intervals(
      breaks = c(-5, 0, 2, 4, 6, 8, 10, 15, 25),
      values = make_pal(pal_urbn_div, 8), midpoint = NA),
    fill.legend = tm_legend_clean("% 22-25"),
    col = "grey70", lwd = 0.3) +
  tm_urbn("Évolution loyers appt 2022-2025") +
  tm_source(make_source("ANIL/OLL"))
[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

[v3->v4] `tm_layout()`: use text.fontfamily instead of fontfamily

Gauche : loyer médian appt tous types (EUR/m²) 2025. Droite : évolution 2022-2025 (%). Les loyers augmentent partout, mais l’accélération est plus forte en province qu’en IDF.

plot_bar_ranked(
  dep %>% filter(!is.na(logl_app_m2_25)),
  var = "logl_app_m2_25", label_col = "libelle",
  n_show = 15,
  title = "Loyers appt — Top/Bottom 15 DEP",
  subtitle = glue("EUR/m² 2025 | Médiane = {fmt_fr(med_loyer_app, 1)} EUR/m²"),
  xlab = "Loyer appt (EUR/m²)",
  caption = make_source("ANIL/OLL 2025")
)
Warning: Specifying width/height in layout() is now deprecated.
Please specify in ggplotly() or plot_ly()
labs_loyer <- label_top_bottom(dep, "logl_app_m2_25", n = 8)

ggplot(dep %>% filter(!is.na(logl_app_m2_25), !is.na(logl_mai_m2_25)),
       aes(x = logl_mai_m2_25, y = logl_app_m2_25)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = col_gray) +
  geom_point(aes(size = P22_POP), alpha = 0.5, color = col_cyan) +
  geom_text_repel(
    data = dep %>% filter(libelle %in% labs_loyer),
    aes(label = libelle), size = 2.8, max.overlaps = 15,
    color = col_spacegray, segment.color = col_gray
  ) +
  scale_size_continuous(range = c(1, 7), guide = "none") +
  labs(
    title = "Loyer appt systématiquement supérieur au loyer maison",
    subtitle = glue("{N_DEP} départements | Diagonale = parité"),
    x = "Loyer maison (EUR/m²)", y = "Loyer appt (EUR/m²)",
    caption = make_source("ANIL/OLL 2025")
  )
Warning: ggrepel: 8 unlabeled data points (too many overlaps). Consider increasing max.overlaps

Tous les points sont au-dessus de la diagonale : le loyer au m² des appartements est toujours plus élevé que celui des maisons. L’écart s’accentue dans les marchés tendus (IDF, métropoles).


QE3.5 — Indices composites : tension et gentrification

idxlogtens corrèle à 0,76 avec les prix et -0,28 avec la vacance — un signal composite cohérent.

L’indice de tension logement synthétise prix, vacance et dynamique du parc. Sa corrélation modérée avec l’indice résidentiel confirme que les deux ne sont pas redondants : l’idxresid capte les flux humains, l’idxlogtens traduit la résultante immobilière.

scatter_quadrants(
  ze, x = "idxresid_dyn_ind_1623", y = "idxlogtens_ind_22",
  title = "Flux humains ≠ tension immobilière",
  subtitle = glue("r = {fmt_fr(cor_lt_resid, 2)} | Résidentiel vs tension logement"),
  xlab = "Indice résidentiel T2 (0-100)", ylab = "Indice tension logement (0-100)",
  hline = 50, vline = 50,
  quad_labels = c("Attractif + tendu", "Peu attractif + tendu",
                  "Peu attractif + détendu", "Attractif + détendu")
) +
  labs(caption = make_source("MIGCOM", "DVF", "LOVAC"))
labs_g <- label_top_bottom(ze, "idxgentri_ind_1622", n = 10)

ggplot(ze, aes(x = dmf_tmi_cscadre_22, y = idxgentri_ind_1622)) +
  geom_hline(yintercept = 50, linetype = "dashed", color = col_gray) +
  geom_vline(xintercept = 0, linetype = "dashed", color = col_gray) +
  geom_point(aes(size = P22_POP, color = logd_px2_global_vevol_1624), alpha = 0.7) +
  geom_text_repel(
    data = ze %>% filter(libelle %in% labs_g),
    aes(label = libelle), size = 2.8, max.overlaps = 20,
    color = col_spacegray, segment.color = col_gray
  ) +
  scale_size_continuous(range = c(1, 7), guide = "none") +
  scale_color_gradient2(
    low = col_magenta, mid = "grey90", high = col_green,
    midpoint = 0, name = "Evol prix (%)"
  ) +
  labs(
    title = "Gentrification = afflux cadres + hausse prix",
    subtitle = glue("r cadres = {fmt_fr(cor_gentri_cadre, 2)} | r prix = {fmt_fr(cor_gentri_prix, 2)}"),
    x = "TMI cadres 2022 (pts)", y = "Indice gentrification (0-100)",
    caption = make_source("MIGCOM", "DVF")
  )

Gauche : les quadrants montrent que tension et attractivité ne coïncident pas toujours — certaines ZE sont tendues sans être attractives (inertie foncière). Droite : couleur = évolution prix (vert = hausse, violet = baisse).


Synthèse

synth <- dep %>%
  summarise(
    across(
      c(logd_px2_global_24, logv_vac2ans_pct_24, logs_logcom_tx1000_2224,
        logs_sdpcom_vol_2224, logl_app_m2_25),
      list(med = ~median(.x, na.rm = TRUE),
           q10 = ~quantile(.x, 0.10, na.rm = TRUE),
           q90 = ~quantile(.x, 0.90, na.rm = TRUE)),
      .names = "{.col}__{.fn}")
  ) %>%
  tidyr::pivot_longer(everything(), names_to = c("var", "stat"), names_sep = "__") %>%
  tidyr::pivot_wider(names_from = stat, values_from = value) %>%
  mutate(
    label = case_when(
      var == "logd_px2_global_24" ~ "Prix m² global (EUR)",
      var == "logv_vac2ans_pct_24" ~ "Vacance 2+ ans (%)",
      var == "logs_logcom_tx1000_2224" ~ "Construction / 1 000 hab",
      var == "logs_sdpcom_vol_2224" ~ "Surface commencée (m²)",
      var == "logl_app_m2_25" ~ "Loyer appt (EUR/m²)"
    ),
    ecart = q90 - q10
  ) %>%
  select(label, med, q10, q90, ecart)

rt_styled(
  synth,
  columns = list(
    label = colDef(name = "Indicateur (DEP)", minWidth = 170, style = list(fontWeight = "500")),
    med = col_num("Médiane", digits = 1),
    q10 = col_num("P10", digits = 1),
    q90 = col_num("P90", digits = 1),
    ecart = colDef(
      header = header_unit("Écart P90-P10", "dispersion"),
      cell = bar_simple(max(synth$ecart, na.rm = TRUE), fill = col_cyan, bar_width = 60),
      minWidth = 150
    )
  )
)
Table 1
Tip4 constats principaux

1. Prix ≠ attractivité — Corrélation prix × SMA = 0,34. Le prix mélange rente foncière et attractivité. Facteur 12.4 entre extrêmes.

2. Construction concentrée — Gini m² = 0,448. 67,1 M m² commencés 22-24, très inégalement répartis.

3. Vacance = thermomètre de la déprise — Corrélation DEP vacance × SMA = 0,02. 174 EPCI en hausse.

4. Tension composite cohérente — idxlogtens × prix = 0,76, × vacance = -0,28. Complémentaire avec idxresid (0,26).

Bilan hypothèses

Hypothèse Résultat
H4 : Prix DVF = proxy attractivité Partiellement confirmée — proxy imparfait (r = 0,34)
H6 : Cycle foncier prédictif Non testée — nécessite séries DVF pré-2014

Annexe — Données détaillées

df_px_dep <- bind_rows(
  dep %>% filter(!is.na(logd_px2_global_24)) %>%
    slice_max(logd_px2_global_24, n = 15) %>%
    mutate(rang = row_number(), groupe = "Top 15 — Prix élevés"),
  dep %>% filter(!is.na(logd_px2_global_24)) %>%
    slice_min(logd_px2_global_24, n = 15) %>%
    mutate(rang = row_number(), groupe = "Bottom 15 — Prix bas")
) %>%
  select(groupe, rang, libelle, logd_px2_global_24, logd_px2_global_vevol_2224,
         logv_vac2ans_pct_24, logl_app_m2_25, dm_sma_vtcam_1622)

rt_styled(
  df_px_dep,
  columns = list(
    groupe = colDef(show = FALSE),
    rang = colDef(name = "#", width = 40),
    libelle = colDef(name = "Département", minWidth = 150),
    logd_px2_global_24 = colDef(
      header = header_unit("Prix m²", "EUR 2024"),
      cell = bar_simple(compute_bar_stats(df_px_dep$logd_px2_global_24)$p98,
                        fill = col_cyan, bar_width = 55),
      minWidth = 145
    ),
    logd_px2_global_vevol_2224 = col_variation(df_px_dep, "logd_px2_global_vevol_2224",
                                               "Evol prix", "% 22-24"),
    logv_vac2ans_pct_24 = col_num("Vac 2+ans", "%"),
    logl_app_m2_25 = col_num("Loyer appt", "EUR/m²"),
    dm_sma_vtcam_1622 = col_variation(df_px_dep, "dm_sma_vtcam_1622", "SMA", "%/an")
  ),
  groupBy = "groupe",
  defaultExpanded = TRUE,
  height = 550
)
Table 2
df_con <- bind_rows(
  dep %>% filter(!is.na(logs_logcom_tx1000_2224)) %>%
    slice_max(logs_logcom_tx1000_2224, n = 15) %>%
    mutate(rang = row_number(), groupe = "Top 15 — Forte construction"),
  dep %>% filter(!is.na(logs_logcom_tx1000_2224)) %>%
    slice_min(logs_logcom_tx1000_2224, n = 15) %>%
    mutate(rang = row_number(), groupe = "Bottom 15 — Faible construction")
) %>%
  select(groupe, rang, libelle, logs_logcom_tx1000_2224, logs_sdpcom_vol_2224,
         logd_px2_global_24, dm_pop_vtcam_1622)

rt_styled(
  df_con,
  columns = list(
    groupe = colDef(show = FALSE),
    rang = colDef(name = "#", width = 40),
    libelle = colDef(name = "Département", minWidth = 150),
    logs_logcom_tx1000_2224 = colDef(
      header = header_unit("Log/1000 hab", "22-24"),
      cell = bar_simple(compute_bar_stats(df_con$logs_logcom_tx1000_2224)$p98,
                        fill = col_green, bar_width = 55),
      minWidth = 130
    ),
    logs_sdpcom_vol_2224 = col_num("Surface m²", "cumul 22-24", digits = 0),
    logd_px2_global_24 = col_num("Prix m²", "EUR"),
    dm_pop_vtcam_1622 = col_variation(df_con, "dm_pop_vtcam_1622", "TCAM pop", "%/an")
  ),
  groupBy = "groupe",
  defaultExpanded = TRUE,
  height = 550
)
Table 3
df_vac <- bind_rows(
  epci %>% filter(!is.na(logv_vac2ans_pct_24)) %>%
    slice_max(logv_vac2ans_pct_24, n = 20) %>%
    mutate(rang = row_number(), groupe = "Top 20 — Forte vacance"),
  epci %>% filter(!is.na(logv_vac2ans_pct_24)) %>%
    slice_min(logv_vac2ans_pct_24, n = 20) %>%
    mutate(rang = row_number(), groupe = "Bottom 20 — Faible vacance")
) %>%
  select(groupe, rang, libelle, logv_vac2ans_pct_24, logv_vac2ans_vdifp_2024,
         logd_px2_global_24, dm_sma_vtcam_1622)

rt_styled(
  df_vac,
  columns = list(
    groupe = colDef(show = FALSE),
    rang = colDef(name = "#", width = 40),
    libelle = colDef(name = "EPCI", minWidth = 200),
    logv_vac2ans_pct_24 = colDef(
      header = header_unit("Vac 2+ans", "% 2024"),
      cell = bar_simple(compute_bar_stats(df_vac$logv_vac2ans_pct_24)$p98,
                        fill = col_orange, bar_width = 55),
      minWidth = 130
    ),
    logv_vac2ans_vdifp_2024 = col_variation(df_vac, "logv_vac2ans_vdifp_2024",
                                             "Evol vac", "pts 20-24"),
    logd_px2_global_24 = col_num("Prix m²", "EUR"),
    dm_sma_vtcam_1622 = col_variation(df_vac, "dm_sma_vtcam_1622", "SMA", "%/an")
  ),
  groupBy = "groupe",
  defaultExpanded = TRUE,
  height = 600
)
Table 4
df_loyer <- dep %>%
  filter(!is.na(logl_app_m2_25)) %>%
  arrange(desc(logl_app_m2_25)) %>%
  head(30) %>%
  select(libelle, logl_app_m2_25, logl_app_evol_2225,
         logl_mai_m2_25, logl_mai_evol_2225,
         logd_px2_global_24, rev_med_21)

rt_styled(
  df_loyer,
  columns = list(
    libelle = colDef(name = "Département", minWidth = 150, style = list(fontWeight = "500")),
    logl_app_m2_25 = col_num("Loyer appt", "EUR/m²"),
    logl_app_evol_2225 = col_variation(df_loyer, "logl_app_evol_2225", "Evol appt", "% 22-25"),
    logl_mai_m2_25 = col_num("Loyer maison", "EUR/m²"),
    logl_mai_evol_2225 = col_variation(df_loyer, "logl_mai_evol_2225", "Evol maison", "% 22-25"),
    logd_px2_global_24 = col_num("Prix m²", "EUR"),
    rev_med_21 = col_num("Revenu méd.", "EUR")
  ),
  height = 500
)
Table 5