---
title: "Observatoire Territorial"
toc: false
number-sections: false
---

```{=html}
<style>
/* Banner */
.landing-banner {
  background: #1e3a5f;
  color: white;
  padding: 1.5rem 3rem;
  width: 100vw;
  margin-left: calc(-50vw + 50%);
  min-height: 90px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  font-family: Inter, system-ui, sans-serif;
}
.landing-banner .banner-title-row {
  display: flex;
  align-items: center;
  gap: 0.8rem;
}
.landing-banner .banner-logo {
  flex-shrink: 0;
  opacity: 0.9;
  transition: opacity 0.2s;
}
.landing-banner .banner-logo:hover { opacity: 1; }
.landing-banner h1 {
  margin: 0;
  font-size: 1.8em;
  font-weight: 700;
  letter-spacing: 0.02em;
}
.landing-banner .baseline {
  margin-top: 0.3em;
  font-size: 0.95em;
  font-weight: 400;
  opacity: 0.85;
}

/* Masquer title-block Quarto par défaut */
header.quarto-title-block,
.quarto-title-block { display: none !important; }

/* Layout 40/60 */
.landing-layout {
  display: grid;
  grid-template-columns: 2fr 3fr;
  gap: 2.5rem;
  margin-top: 1.5rem;
}
@media (max-width: 900px) {
  .landing-layout { grid-template-columns: 1fr; }
}

/* Panneau gauche */
.landing-left .intro-block {
  font-size: 0.93em;
  color: #444;
  line-height: 1.6;
  margin-bottom: 1.5rem;
}
.landing-left .intro-block a { color: #1696d2; }

/* Grille tuiles */
.volet-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.8rem;
}
.volet-card {
  border-left: 4px solid #ccc;
  background: #fff;
  padding: 0.8rem 1rem;
  border-radius: 0 6px 6px 0;
  cursor: pointer;
  transition: all 0.2s;
  text-decoration: none !important;
  color: inherit !important;
  display: block;
  box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.volet-card:hover {
  box-shadow: 0 4px 12px rgba(0,0,0,0.12);
  transform: translateY(-2px);
  text-decoration: none !important;
}
.volet-card .volet-icon { font-size: 1.5em; margin-bottom: 0.3em; }
.volet-card .volet-title { font-weight: 600; font-size: 0.9em; color: #333; }
.volet-card .volet-desc { font-size: 0.78em; color: #666; margin-top: 0.2em; line-height: 1.4; }

/* Panneau droit */
.landing-right .showcase-section { margin-bottom: 1.5rem; }
.showcase-title {
  font-size: 0.85em;
  font-weight: 600;
  color: #666;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 0.5rem;
  border-bottom: 1px solid #eee;
  padding-bottom: 0.3em;
}

/* KPI strip */
.kpi-strip {
  display: flex;
  gap: 1.5rem;
  flex-wrap: wrap;
  margin: 1rem 0;
}
.kpi-item { text-align: center; }
.kpi-item .kpi-value {
  font-size: 1.6em;
  font-weight: 700;
  color: #1696d2;
  line-height: 1.2;
}
.kpi-item .kpi-label {
  font-size: 0.75em;
  color: #888;
  margin-top: 0.15em;
}

/* Contrôles carte (details pliable) */
.map-controls {
  margin-top: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  background: #f8f9fa;
}
.map-controls summary {
  cursor: pointer;
  padding: 0.5rem 0.8rem;
  font-size: 0.82em;
  font-weight: 600;
  color: #555;
  list-style: none;
  display: flex;
  align-items: center;
  gap: 0.4rem;
}
.map-controls summary::before {
  content: "▸";
  transition: transform 0.2s;
  font-size: 0.9em;
}
.map-controls[open] summary::before { transform: rotate(90deg); }
.map-controls summary::-webkit-details-marker { display: none; }
.map-controls .controls-inner {
  padding: 0.5rem 0.8rem 0.8rem;
  border-top: 1px solid #e8e8e8;
}

/* Footer */
.landing-footer {
  margin-top: 2rem;
  padding: 1rem 0;
  border-top: 1px solid #eee;
  font-size: 0.8em;
  color: #999;
  text-align: center;
}
</style>
```

```{r}
#| label: setup
#| include: false

source("_setup-common.R")

# &s &INDEX_DATA_PREP - Données minimales pour visuels index

# --- DEP ---
dep <- read.csv(file.path(PROJECT_ROOT, "data/output/dbcln-dep-vf.csv"),
                stringsAsFactors = FALSE, fileEncoding = "UTF-8-BOM") %>%
  filter(!code %in% c("FR", "00FR", "ZZZZZ"))

# Labels DEP depuis TDC
tdc_dep <- read.csv(file.path(PROJECT_ROOT, "data/external/geo-tdc/tdc_geo_sat3col_departement_2025.csv"),
                    stringsAsFactors = FALSE, fileEncoding = "UTF-8-BOM", sep = ";")

dep <- dep %>%
  left_join(tdc_dep %>% select(DEP, DEPlib), by = c("code" = "DEP")) %>%
  mutate(libelle = coalesce(libelle, DEPlib)) %>%
  select(-DEPlib)

# Labels ZE depuis TDC
tdc_ze <- read.csv(file.path(PROJECT_ROOT, "data/external/geo-tdc/tdc_geo_sat3col_ze2020_2025.csv"),
                   stringsAsFactors = FALSE, fileEncoding = "UTF-8-BOM", sep = ";")

# ZE codes: "0051" dans CSV, "51" dans TDC → match via integer
ze <- ze %>%
  mutate(ze_code_int = as.integer(code)) %>%
  left_join(tdc_ze %>% mutate(ze_code_int = as.integer(ZE2020)) %>% select(ze_code_int, ZE2020lib),
            by = "ze_code_int") %>%
  mutate(libelle = coalesce(libelle, ZE2020lib)) %>%
  select(-ze_code_int, -ZE2020lib)

# Subset OJS carte DEP (6 colonnes — prix ajouté)
dep_ojs <- dep %>%
  transmute(
    code, libelle,
    sma = dm_sma_vtcam_1622,
    tcam = dm_pop_vtcam_1622,
    iv = dmv_iv_ind_22,
    prix = logd_px2_global_24
  )

# Topojson string pour OJS
topo_dep_str <- readLines(file.path(PROJECT_ROOT, "reports/data/nodom_departement_2025.topojson"),
                          warn = FALSE) %>% paste(collapse = "\n")

# Subset OJS scatter ZE (5 colonnes)
ze_ojs <- ze %>%
  transmute(
    code, libelle,
    tcam = dm_pop_vtcam_1622,
    sma = dm_sma_vtcam_1622,
    pop = P22_POP
  )

ojs_define(
  dep_ojs_data = dep_ojs,
  topo_dep_str = topo_dep_str,
  ze_ojs_data = ze_ojs
)

# KPI
pop_tot <- sum(ze$P22_POP, na.rm = TRUE)
tcam_med <- median(ze$dm_pop_vtcam_1622, na.rm = TRUE)
sma_med <- median(ze$dm_sma_vtcam_1622, na.rm = TRUE)
n_dep <- nrow(dep)

# &e
```

```{r}
#| label: banner
#| results: asis
cat(glue('<div class="landing-banner">
<div class="banner-title-row">
<a href="index.html" class="banner-logo" title="Accueil"><svg width="38" height="42" viewBox="0 0 38 42" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 1 L35 11 L35 31 L19 41 L3 31 L3 11 Z" stroke="white" stroke-width="2" fill="rgba(255,255,255,0.12)"/><circle cx="19" cy="21" r="4" fill="rgba(255,255,255,0.7)"/></svg></a>
<h1>Observatoire Territorial France</h1>
</div>
<div class="baseline">Dynamiques démographiques, économiques et immobilières — {N_ZE} zones d\'emploi, {n_dep} départements, 35 000 communes</div>
</div>'))
```

:::::: {.landing-layout}

::::: {.landing-left}

::: {.intro-block}
Cet observatoire analyse les recompositions territoriales françaises à travers trois prismes complémentaires : **démographie et migrations**, **économie et emploi**, **logement et immobilier**. Le solde migratoire a remplacé le solde naturel comme moteur de croissance locale — les flux de population redessinent la carte des territoires.

Le pipeline mobilise 8 sources INSEE (RP, MIGCOM, DVF, Filosofi, URSSAF, SIDE, LOVAC, SITADEL), `r ncol(ze)` indicateurs calculés et 5 indices composites. [Méthodologie et sources →](rpt-pdemog-rapport-demogresid_260217.qmd)
:::

```{=html}
<div class="volet-grid">
  <a class="volet-card" href="https://ptod-observatoire-territorial.netlify.app/jottd-exd-explor-dyn" style="border-left-color: #2171b5;">
    <div class="volet-icon">🗺️</div>
    <div class="volet-title">Exploration multi-échelle</div>
    <div class="volet-desc">60+ indicateurs × 8 échelons — carte, scatter, table</div>
  </a>
  <a class="volet-card" href="https://ptod-observatoire-territorial.netlify.app/dash-exdtc-template-commune" style="border-left-color: #6366f1;">
    <div class="volet-icon">🏘️</div>
    <div class="volet-title">Communes</div>
    <div class="volet-desc">35 000 communes, DuckDB Parquet, zoom infra</div>
  </a>
  <a class="volet-card" href="https://ptod-observatoire-territorial.netlify.app/dash-exdeco-ze" style="border-left-color: #d97706;">
    <div class="volet-icon">📊</div>
    <div class="volet-title">Économie ZE</div>
    <div class="volet-desc">FLORES A5/A21, URSSAF, Krugman, butterfly</div>
  </a>
  <a class="volet-card" href="https://ptod-observatoire-territorial.netlify.app/dash-exdattract-ze" style="border-left-color: #059669;">
    <div class="volet-icon">🎯</div>
    <div class="volet-title">Attractivité</div>
    <div class="volet-desc">Indices résidentiel × productif, trajectoires</div>
  </a>
  <a class="volet-card" href="https://ptod-observatoire-territorial.netlify.app/dash-exdlog" style="border-left-color: #e11d48;">
    <div class="volet-icon">🏠</div>
    <div class="volet-title">Logement</div>
    <div class="volet-desc">DVF prix, LOVAC vacance, SITADEL construction</div>
  </a>
  <a class="volet-card" href="rpt-pdemog-rapport-demogresid_260217.qmd" style="border-left-color: #1696d2;">
    <div class="volet-icon">📄</div>
    <div class="volet-title">Rapports Quarto</div>
    <div class="volet-desc">3 volets thématiques rédigés, insight-first</div>
  </a>
</div>
```

```{=html}
<details class="map-controls" open>
<summary>Options carte</summary>
<div class="controls-inner">
```

```{ojs}
//| label: ojs-radio-dep
//| echo: false
viewof indic_dep = Inputs.radio(
  ["SMA", "TCAM pop", "Vieillissement", "Prix m²"],
  {value: "SMA", label: "Indicateur carte"}
)
```

```{=html}
</div>
</details>
```

:::::

::::: {.landing-right}

::: {.showcase-section}

::: {.showcase-title}
Carte départementale
:::

```{ojs}
//| label: ojs-carte-dep
//| echo: false

topo = JSON.parse(topo_dep_str)
geo = topojson.feature(topo, topo.objects.data)
dep_data = transpose(dep_ojs_data)
depMap = new Map(dep_data.map(d => [d.code, d]))

// Palette prix YlOrRd + violet P95 (alignée pal_prix_ylord_violet)
SEQ_PRIX = ["#ffffb2","#fecc5c","#fd8d3c","#e31a1c","#bd0026","#7b2ff2"]

// Config par indicateur : colonne, label, unité, mode, palette, formatage
// SMA/TCAM : RdBu = rouge(bas)→bleu(haut) → polarité positive OK
// Vieillissement : RdBu reverse = rouge(haut=vieux)→bleu(bas=jeune) → polarité négative
depConfig = ({
  "SMA":             {key: "sma",  label: "SMA 16-22 (%/an)",           unit: " %/an",  mode: "ecart", scheme: "RdBu", reverse: false, fmt: 2},
  "TCAM pop":        {key: "tcam", label: "TCAM pop 16-22 (%/an)",      unit: " %/an",  mode: "ecart", scheme: "RdBu", reverse: false, fmt: 2},
  "Vieillissement":  {key: "iv",   label: "Indice vieillissement 2022", unit: "",        mode: "ecart", scheme: "RdBu", reverse: true,  fmt: 1},
  "Prix m²":         {key: "prix", label: "Prix médian m² (2024)",      unit: " €/m²",  mode: "niveau", fmt: 0}
})[indic_dep]

// Calcul seuils quantiles pour mode "niveau" (prix)
depValues = dep_data.map(d => d[depConfig.key]).filter(v => v != null).sort(d3.ascending)
depQuantiles = depConfig.mode === "niveau"
  ? [0.20, 0.40, 0.60, 0.80, 0.95].map(q => d3.quantile(depValues, q))
  : null

// Construction échelle couleur selon mode
depColorOpts = depConfig.mode === "niveau"
  ? {type: "threshold", domain: depQuantiles, range: SEQ_PRIX, legend: true, label: depConfig.label}
  : {type: "diverging", scheme: depConfig.scheme, symmetric: true,
     reverse: depConfig.reverse,
     legend: true, label: depConfig.label}

Plot.plot({
  projection: {type: "mercator", domain: geo},
  color: depColorOpts,
  marks: [
    Plot.geo(geo, {
      fill: d => {
        const r = depMap.get(d.properties.code_insee);
        return r ? r[depConfig.key] : undefined;
      },
      stroke: "#999", strokeWidth: 0.3,
      tip: true,
      title: d => {
        const r = depMap.get(d.properties.code_insee);
        if (!r) return "";
        const v = r[depConfig.key];
        return `${r.libelle} (${d.properties.code_insee})\n${depConfig.label} : ${v != null ? v.toFixed(depConfig.fmt) : "n/d"}${depConfig.unit}`;
      }
    })
  ],
  width: 580, height: 500,
  style: {fontFamily: "Inter, sans-serif", fontSize: "11px"}
})
```

:::

::: {.showcase-section}

::: {.showcase-title}
Scatter — Zones d'emploi
:::

```{ojs}
//| label: ojs-scatter-ze
//| echo: false

ze_data = transpose(ze_ojs_data)

ze_tcam_med = d3.median(ze_data, d => d.tcam)
ze_sma_med = d3.median(ze_data, d => d.sma)

Plot.plot({
  x: {label: "TCAM population 16-22 (%/an)"},
  y: {label: "SMA 16-22 (%/an)"},
  r: {range: [2, 18]},
  color: {
    type: "diverging", scheme: "RdBu", symmetric: true,
    legend: false
  },
  marks: [
    Plot.ruleX([ze_tcam_med], {stroke: "#ccc", strokeDasharray: "4,3"}),
    Plot.ruleY([ze_sma_med], {stroke: "#ccc", strokeDasharray: "4,3"}),
    Plot.dot(ze_data, {
      x: "tcam", y: "sma",
      r: d => Math.sqrt(d.pop / 5000),
      fill: "sma",
      fillOpacity: 0.65,
      stroke: "#666", strokeWidth: 0.3,
      tip: true,
      title: d => `${d.libelle}\nTCAM : ${d.tcam?.toFixed(2)} %/an\nSMA : ${d.sma?.toFixed(2)} %/an\nPop : ${(d.pop / 1000).toFixed(0)}k`
    }),
    Plot.text([
      {x: d3.max(ze_data, d => d.tcam), y: d3.max(ze_data, d => d.sma), text: "Croissance + attractif"},
      {x: d3.min(ze_data, d => d.tcam), y: d3.min(ze_data, d => d.sma), text: "Déclin + répulsif"}
    ], {
      x: "x", y: "y", text: "text",
      fontSize: 10, fontWeight: 600, fill: "#999",
      textAnchor: d => d.text.includes("Croissance") ? "end" : "start",
      dy: d => d.text.includes("Croissance") ? -8 : 8
    })
  ],
  width: 580, height: 340,
  style: {fontFamily: "Inter, sans-serif", fontSize: "11px"}
})
```

:::

::: {.showcase-section}

::: {.showcase-title}
Top 10 — Solde migratoire apparent
:::

```{r}
#| label: table-top10-sma

top10 <- ze %>%
  filter(!is.na(dm_sma_vtcam_1622)) %>%
  slice_max(order_by = dm_sma_vtcam_1622, n = 10) %>%
  mutate(rang = row_number()) %>%
  transmute(
    Rang = rang,
    `Zone d'emploi` = libelle,
    `SMA (%/an)` = dm_sma_vtcam_1622,
    `Pop 2022` = P22_POP
  )

reactable(
  top10,
  columns = list(
    Rang = colDef(maxWidth = 55, align = "center",
                  style = list(fontWeight = "700", color = "#1696d2")),
    `Zone d'emploi` = colDef(minWidth = 160),
    `SMA (%/an)` = colDef(maxWidth = 100, align = "right", format = colFormat(digits = 2)),
    `Pop 2022` = colDef(maxWidth = 110, align = "right",
                        cell = function(value) fmt_k(value))
  ),
  compact = TRUE,
  theme = theme_rt_urban,
  pagination = FALSE,
  height = 280
)
```

:::

::: {.showcase-section}

```{r}
#| label: kpi-strip
#| results: asis

cat(glue('
<div class="kpi-strip">
<div class="kpi-item">
<div class="kpi-value">{N_ZE}</div>
<div class="kpi-label">zones d\'emploi</div>
</div>
<div class="kpi-item">
<div class="kpi-value">{n_dep}</div>
<div class="kpi-label">départements</div>
</div>
<div class="kpi-item">
<div class="kpi-value">{fmt_fr(pop_tot / 1e6, 1)}M</div>
<div class="kpi-label">habitants (2022)</div>
</div>
<div class="kpi-item">
<div class="kpi-value">{fmt_sign(tcam_med)}\u202f%</div>
<div class="kpi-label">TCAM pop médian</div>
</div>
<div class="kpi-item">
<div class="kpi-value">{fmt_sign(sma_med)}\u202f%</div>
<div class="kpi-label">SMA médian</div>
</div>
</div>
'))
```

:::

:::::

::::::

::: {.landing-footer}
Observatoire Territorial France | © 2026 Vincent Roue | [GitHub](https://github.com/vincentroue) | [Portfolio](https://vincentroue-portfolio.netlify.app) | Données : INSEE RP, MIGCOM, DVF, Filosofi, URSSAF, SIDE, LOVAC, SITADEL | `r format(Sys.Date(), "%B %Y")`
:::
