Cadrage national & focus Hauts-de-France

Date de publication

Édition 1 septembre 2026

1 Synthèse

Logistique — de quoi parle-t-on ?

Cette note décrit Logistique (entreposage, poste et courrier), d’abord à l’échelle nationale puis en Hauts-de-France. Les chiffres de volume, de dynamique et de géographie portent tous sur un périmètre cœur de 730 k salariés privés (URSSAF 2025), soit 11 sous-classes d’activité réparties en 3 divisions :

  • Transport terrestre — 60 % : Fret routier interurbain (31 %) · Fret routier de proximité (25 %) · Location de camions avec chauffeur (3 %) · Transports ferroviaires de fret (1 %)
  • Entreposage — 38 % : Entreposage non frigorifique (15 %) · Affrètement et organisation des transports (12 %) · Messagerie, fret express (5 %) · Entreposage frigorifique (4 %) · Manutention portuaire (1 %) · Manutention non portuaire (1 %)
  • Poste-courrier — 2 % : Autres activités de poste et de courrier (2 %)

Le profil de main-d’œuvre et les cartes de spécialisation ne sont pas disponibles à ce niveau de détail. Elles reposent sur un périmètre élargi calé sur les divisions Transport terrestre et Entreposage prises en entier — 1,2 M salariés (FLORES 2024, emploi privé et public). Ce second périmètre ne se superpose pas au premier : il retient des divisions entières, donc des activités voisines du cœur, sans nécessairement en couvrir toutes les sous-classes. Les deux ne donnent pas les mêmes parts — chaque figure précise celui dont elle relève, et la section Méthode chiffre l’écart.

Sources : URSSAF-ACOSS · INSEE FLORES · INSEE EEC · INSEE MOBZELT 2022

Synthèse nationale

Indicateurs clés · Logistique · France

Poids du secteur 2025
3,7 % de l'emploi privé (cœur)
730 k salariés privés
4,6 % de l'emploi total (A88)
Dynamique emploi
+13 % vs +6 % tous secteurs
+83 k emplois / 2019
Croissance annuelle de l'emploi (%/an)
Logistiquetous secteurs
+3,8+1,6
16-19
+2,6+1,6
19-23
+0,9-0,0
23-25

Source : URSSAF-ACOSS · emploi salarié privé · hachures = ZE de moins de 200 emplois du secteur

Profil des emplois salariés Niveau 2025 et évolution en points depuis 2019

% de femmes
% d’infra-bac
% 50 ans et plus
% jeunes (< 25 ans)
% sous-emploi
Logistique
21,3+1,7
46,2−9,6
34,4+4,2
7,9
2,7−0,4
tous secteurs
48,8+0,3
30,7−5,8
32,9+1,9
9,4
4,3−1,0

Ce qu’il faut retenir

  • Un secteur de masse qui croît quand l’emploi privé s’arrête — 730 000 salariés privés, 3,7 % de l’emploi privé, 83 000 créations nettes depuis 2019.
  • La croissance vient des entrepôts, pas des camions — l’entreposage progresse de 4,0 % par an, le fret interurbain de 0,8 %, la messagerie recule de 3,0 %.
  • Sept bassins d’emploi sur dix créent des emplois logistiques — mais quinze d’entre eux en concentrent la moitié : la croissance est partagée, le volume reste concentré.
  • Des emplois durables, un mur démographique — 21 % de femmes et 34 % de 50 ans et plus, mais moitié moins de temps partiel et moins de contrats courts qu’ailleurs dans le privé.

Synthèse territoriale — Hauts-de-France

Indicateurs clés · Logistique · Hauts-de-France

Poids du secteur 2025
5,3 % de l'emploi privé régional (cœur)
81 k salariés privés
5,4 % de l'emploi total régional (A88)
Dynamique emploi
+16 % vs +5 % tous secteurs (rég.)
+11 k emplois / 2019
Croissance annuelle de l'emploi (%/an)
Logistiquetous secteurs
+4,0+0,9
16-19
+2,7+1,3
19-23
+2,1-0,0
23-25

Source : URSSAF-ACOSS · emploi salarié privé · hachures = ZE de moins de 200 emplois du secteur

Profil des emplois salariés Territoire comparé au même secteur au national · MOBZELT 2022

% de femmes
% temps partiel
% 50 ans et plus
Âge moyen
% dans leur commune
Logistique
21,1FR 22,0
8,0FR 9,3
28,4FR 32,3
42,2FR 43,3
64,4FR 69,3
tous secteurs
48,3
17,1
29,2
41,8
74,4

Ce qu’il faut retenir — Hauts-de-France

  • Une des toutes premières régions logistiques du pays — 81 000 salariés privés, 5,3 % de l’emploi privé régional contre 3,7 % au national.
  • Le secteur crée des emplois là où l’économie n’en crée plus — 11 000 postes gagnés depuis 2019, au rythme de 2,5 % par an, dans une région dont l’emploi privé total stagne.
  • La croissance a basculé vers le sud — la Somme progresse de 7,8 % par an et l’Oise de 2,7 %, quand le Nord et le Pas-de-Calais, déjà équipés, plafonnent à 1 %.
  • Une région qui stocke plus qu’elle n’organise — l’entreposage pèse 29 % de l’emploi logistique régional contre 19 % en France, et porte la moitié des créations.

2 Méthode, données et axes d’analyse

Cette note caractérise le secteur Logistique (entreposage, poste et courrier) selon quatre axes : son poids et sa dynamique récente, la structure et le profil de ses emplois, sa géographie nationale, puis un focus territorial sur Hauts-de-France.

Périmètre du secteur

Le périmètre est décliné selon les sous-classes APE (cœur exact, ci-dessous) : le volume, la dynamique, la composition et la géographie en relèvent. Seules les dimensions qu’aucune source ne fournit à ce niveau de finesse — l’emploi total (FLORES, privé et public) et le profil de main-d’œuvre (EEC) — passent par des divisions A88 entières (49, 52), qui sur-captent des activités hors-cœur. Les chiffres de ces deux familles ne sont donc pas directement comparables aux autres.

Périmètre cœur — 11 sous-classes APE (salariés privés 2025)
Répartition interne au secteur (% du secteur) et poids dans l'emploi privé France (% emploi privé FR)
Code APE Libellé Salariés privés % du secteur % emploi privé FR
49 — Transport terrestre
4941A Fret interurbain 228 990 31.4 % 1.1 %
4941B Fret proximité 184 199 25.2 % 0.9 %
4941C Location camions 18 692 2.6 % 0.1 %
4920Z Fret ferroviaire 7 892 1.1 % 0.0 %
Sous-total division 439 773 60.2 % 2.2 %
52 — Entreposage
5210B Entrepôt non frigo 112 508 15.4 % 0.6 %
5229B Affrètement 87 393 12.0 % 0.4 %
5229A Messagerie/express 37 000 5.1 % 0.2 %
5210A Entrepôt frigo 26 135 3.6 % 0.1 %
5224A Manut. portuaire 8 561 1.2 % 0.0 %
5224B Manut. non port. 7 778 1.1 % 0.0 %
Sous-total division 279 375 38.3 % 1.4 %
53 — Poste-courrier
5320Z Autre poste-courrier 11 113 1.5 % 0.1 %
Sous-total division 11 113 1.5 % 0.1 %
Source : URSSAF-ACOSS
AstuceQuel périmètre pour les analyses qui ne descendent pas sous la division ?
Ce que chaque division apporte au secteur, et ce qu’elle y ajoute d’étranger
Salariés privés 2025 · « apport » = part du secteur portée par la division · « hors-cœur » = part de la division qui n'appartient PAS au secteur
Division Libellé Salariés du secteur (APE) Division entière (A88) Apport au secteur Hors-cœur Retenu
49 Transports terrestres et transport par conduites 439 773 829 240 60,2 % 47,0 % oui
52 Entreposage et services auxiliaires des transports 279 375 412 042 38,3 % 32,2 % oui
53 Activités de poste et de courrier 11 113 176 679 1,5 % 93,7 %
Retenu (49+52) Périmètre servant aux analyses A88 719 148 1 241 282 98,5 % 42,1 %
Total Secteur complet — toutes divisions du cœur 730 261 1 417 961 100,0 % 48,5 %
Source : URSSAF-ACOSS

Comment lire. Les analyses qui ne descendent pas sous la division — emploi total (FLORES) et profil de main-d’œuvre (EEC) — ne peuvent pas retenir les seules sous-classes du secteur : elles prennent des divisions entières. Le jeu retenu ici est 49 et 52. Il couvre 98,5 % des salariés du secteur ; en sens inverse, 42,1 % des salariés de ces divisions n’en font pas partie.

Sources mobilisées

  • URSSAF-ACOSS — effectifs salariés privés communaux par NA88 (2006-2025). Source de référence pour le volume et la dynamique.
  • INSEE FLORES — effectifs salariés totaux (privé + public, hors particuliers employeurs), 2017-2024. Sert au poids public et à la spécialisation territoriale.
  • INSEE EEC — profil emploi national par NA88 (millésimes 2022 et 2024) : sexe, âge, diplôme, statut, temps partiel.
  • INSEE MOBZELT 2022 — profil territorial par zone d’emploi × NA88 (lieu de travail).

Conventions de calcul

Les évolutions sont mesurées en TCAM (taux de croissance annuel moyen) sur deux horizons : tendance longue 2019-2025 et dynamique récente 2023-2025 (URSSAF ; 2019-2024 et 2023-2024 pour FLORES). L’indice de spécialisation (IS) rapporte la part du secteur dans l’emploi local à sa part nationale (France = 100) ; il est colorisé en échelle divergente. Les moyennes de profil sont pondérées (EXTRIAN pour l’EEC, IPONDI pour MOBZELT). Validation cross-source URSSAF vs EEC à ±10 %.

3 Vue nationale

Contexte et enjeux

Maillon devenu stratégique des chaînes d’approvisionnement, la logistique a été portée par l’essor du e-commerce et la massification des entrepôts : entreposage, messagerie et fret routier en forment le cœur.

La logistique n’est pas une activité mais une chaîne de métiers aux logiques distinctes. Le transport routier de marchandises en assure le mouvement, l’entreposage la mise en stock et la préparation de commande, l’affrètement et la commission de transport l’organisation des flux sans posséder de véhicules ; s’y ajoutent la messagerie et le colis, la manutention portuaire et aéroportuaire, le fret ferroviaire et fluvial. Ces métiers relèvent de conventions collectives, de modèles économiques et de contraintes d’implantation distincts : les agréger sous une même étiquette éclaire un poids d’ensemble, jamais un mécanisme.

Le commerce en ligne a externalisé la logistique ; l’automatisation en découple désormais l’emploi. La bascule d’une part du commerce vers la vente en ligne a transféré vers des prestataires spécialisés des fonctions auparavant internes au commerce de détail, et fait émerger des entrepôts de très grande taille au service de bassins de consommation entiers — un mouvement que documente l’Afilog côté immobilier logistique. L’automatisation de ces entrepôts — convoyeurs, préparation vocale, navettes robotisées — découple la surface exploitée du nombre d’emplois : un site peut croître fortement sans embaucher à proportion.

Foncier rare, main-d’œuvre sous tension, réglementation resserrée : l’implantation se négocie sous triple contrainte. Un entrepôt moderne réclame des dizaines d’hectares et un raccordement autoroutier, ce qui exclut les tissus denses et met les territoires en concurrence. La conduite routière et la préparation de commande connaissent des difficultés de recrutement durables — la conduite figure de façon récurrente parmi les métiers en tension suivis par la DARES —, avec une pyramide des âges vieillissante côté conduite. La loi Climat et résilience de 2021 encadre l’artificialisation des sols (trajectoire dite de zéro artificialisation nette), les zones à faibles émissions restreignent la circulation en agglomération, et la décarbonation des flottes oriente autant le choix des sites que celui des motorisations.

Le tissu est dual : quelques grands opérateurs concentrent les plateformes, une myriade de transporteurs assure la proximité. Les grands groupes internationaux et les filiales logistiques de l’industrie ou de la distribution détiennent les plateformes de grande taille, tandis qu’une multitude de petites entreprises de transport, souvent sous-traitantes, prend en charge les flux de proximité. La taille moyenne d’établissement, faible, ne dit donc rien de la concentration réelle du secteur.

Repères : stratégie nationale logistique portée par le CILOG, travaux de France Stratégie sur la filière, données SDES et DGITM sur le transport de marchandises.

La région est un couloir logistique avant d’être une destination. Située sur la dorsale nord-européenne, entre les ports de la Manche et de la mer du Nord, le tunnel sous la Manche et le premier bassin de consommation français, elle capte des flux qui la traversent autant qu’ils la desservent. Cette position de couloir explique l’ancienneté de l’implantation logistique et sa dépendance aux infrastructures de transit davantage qu’à la demande locale.

Portrait d’ensemble : poids et dynamique récente

Cette partie procède en deux temps, à ne pas confondre : d’abord la photographie — combien de salariés, quel poids dans l’emploi privé, quelles activités composent le secteur — puis le film : la trajectoire depuis 2019, les segments qui portent la croissance et ceux qui décrochent.

Poids et dynamique — le secteur et ses deux grandes familles
URSSAF-ACOSS · emploi salarié privé · 2025 · trait = emploi privé total
Part de l’emploi privé
Croissance 2019-2025
Croissance 2023-2025
Logistique
3,7
2,0+13,8privé 1,1
0,9+6,7privé -0,0
Transport terrestre
2,2
1,6+6,6
0,2+1,1
Entreposage
1,4
2,7+6,8
2,0+5,5
Voir le code
.sect_cap <- .cap0(fr_secteur_phrase(params$secteur_label, prep = "de"))
# Comparaison de DEUX TAUX : seuil absolu (l'écart relatif explose quand le réf ≈ 0).
# La prose ne répète PAS volume/part (désormais en gros sur la carte KPI ci-dessus) → elle cadre la dynamique.
.gap_dyn <- tcam_us - tcam_tot
.insight_dyn <- dplyr::case_when(
  tcam_us > 0.3 & tcam_tot < 0.3 ~ glue::glue("{.sect_cap} continue de créer de l'emploi (**{fmt_sign_1dec(z0(tcam_us))} %/an**) quand l'emploi privé total marque le pas (**{fmt_sign_1dec(z0(tcam_tot))} %/an**)"),
  .gap_dyn >  0.3 ~ glue::glue("{.sect_cap} progresse plus vite que l'emploi privé total (**{fmt_sign_1dec(z0(tcam_us))}** contre **{fmt_sign_1dec(z0(tcam_tot))} %/an**)"),
  .gap_dyn < -0.3 ~ glue::glue("{.sect_cap} progresse moins vite que l'emploi privé total (**{fmt_sign_1dec(z0(tcam_us))}** contre **{fmt_sign_1dec(z0(tcam_tot))} %/an**)"),
  TRUE            ~ glue::glue("{.sect_cap} suit le rythme de l'emploi privé total (**{fmt_sign_1dec(z0(tcam_us))}** contre **{fmt_sign_1dec(z0(tcam_tot))} %/an**)"))
.phase <- dplyr::case_when(
  tcam_lng > tcam_us + 0.4 ~ "après un rebond post-Covid plus vif, la croissance s'est assagie sur les deux dernières années",
  tcam_us  > tcam_lng + 0.4 ~ "et la dynamique s'est même accélérée sur les deux dernières années",
  TRUE                      ~ "sur un rythme régulier depuis 2019")
.role <- dplyr::case_when(
  tcam_us >  0.3 ~ "Cette résilience en fait un **contributeur net** à l'emploi privé, dont le poids progresse à mesure que d'autres branches reculent.",
  tcam_us < -0.3 ~ "Le secteur érode ainsi sa part dans l'emploi privé, qu'il contribue à tirer vers le bas.",
  TRUE           ~ "Le secteur maintient sa part dans l'emploi privé sans la renforcer.")
cat(prose_cc("nat_portrait", function() glue::glue(
  "[{.insight_dyn}]{{.insight}}\n\n",
  "Sur le temps long, {.phase}. {.role}"
)))

Un salarié du privé sur 27 travaille dans la logistique — 730 000 personnes, 82 000 de plus qu’en 2019

La photographie d’abord. Le secteur emploie 730 261 salariés privés fin 2025, soit 3,7 % de l’emploi privé — l’équivalent de la population active d’une métropole régionale. Ils se répartissent sur 36 739 établissements de 20 salariés en moyenne, une taille modeste qui masque une réalité duale : une multitude de petits transporteurs et quelques très grandes plateformes.

Le film ensuite. La logistique n’a pas connu de retournement : là où l’emploi privé français plafonne depuis 2023, le secteur a poursuivi son expansion — 2,0 % par an sur 2019-2025, encore 0,9 % par an sur les deux dernières années quand l’ensemble du privé recule légèrement. Six ans de croissance ininterrompue, dans une économie qui n’en offre plus.

Ce mouvement n’est pas une conjoncture, c’est une recomposition. Le basculement d’une part croissante du commerce vers la vente en ligne a déplacé des fonctions autrefois internes au commerce de détail — stockage, préparation de commande, livraison du dernier kilomètre — vers des prestataires spécialisés qui les comptabilisent sous leur propre code d’activité. Une partie de la croissance affichée relève donc d’un transfert entre secteurs autant que d’une création nette, mécanisme qu’aucune source d’emploi ne permet d’isoler.

De quoi ce volume est-il fait ? C’est la question suivante.

De quoi ce secteur est-il fait ? Le détail par sous-classe d’activité en donne la composition interne — qui pèse, qui accélère, qui décroche.

Voir le code
# INSIGHT + idées-clés AVANT la figure (conv 260820). `.t2` sert aussi au fallback auto.
.t2 <- ape_wide |> arrange(desc(part_pct)) |> slice_head(n = 2)
cat(prose_cc_pre("nat_compo", function() glue::glue(
  "Le secteur se compose de **{nrow(ape_wide)} sous-classes** d'activité au poids très inégal *(figure ci-contre)*. ",
  "**{fr_naf(.t2$libelle[1], .naf_genre_of(.t2$code_ape[1]), cap=TRUE)}** ({fmt_pct_1dec(.t2$part_pct[1])} de l'emploi) et ",
  "**{fr_naf(.t2$libelle[2], .naf_genre_of(.t2$code_ape[2]))}** ({fmt_pct_1dec(.t2$part_pct[2])}) ",
  "concentrent à elles seules **{fmt_pct_1dec(sum(.t2$part_pct))}** des effectifs, reléguant les autres segments au rang d'activités d'appoint."
)))

Le fret routier concentre 57 % de l’emploi du secteur — l’entreposage, son image publique, n’en pèse que 19 %

Le secteur ne forme pas un bloc homogène. Le fret routier en concentre à lui seul 57 % — 228 990 salariés pour le transport interurbain, 184 199 pour le transport de proximité — quand l’entreposage, image d’Épinal de la logistique moderne, n’en représente que 19 % (138 643 salariés, frigorifique compris) (figure ci-dessous).

Voir le code
# Treemap gradient (format zmjcn tree-gradient-it) : aire = effectifs, couleur = dynamique récente.
# Divergent bleu→bordeaux recentré sur 0 ; évolution écrite sur la tuile.
plot_tree_bygroup(
  ape_wide,
  area_col     = "emp_ref",
  label_col    = "libelle",
  subgroup_col = "grp_naf",
  fill_mode    = "gradient",          # couleur = variable continue (dynamique)
  fill_var     = "tcam_p1p2",         # TCAM récent p1-p2
  gradient_center = 0,                # 0 = blanc, hausse bordeaux / recul bleu
  label_extra_col = "tcam_p1p2", label_extra_unit = " %/an",  # évol visible sur la tuile
  legend_title = sprintf("TCAM\n%d-%d", p_evol[1], p_evol[2]),
  label_mode   = "pct_vol",           # part % + volume k dans chaque cellule
  # Titre = LE MESSAGE, et un message QUE LA PROSE NE PORTE PAS. L'insight au-dessus dit déjà la
  # concentration en VOLUME (« deux sous-classes font X % ») : répéter cela ici enfreindrait la règle
  # « ne jamais doubler insight textuel et titre autoporté sur le même message ». Le titre parle donc
  # de la COULEUR, c'est-à-dire de la dynamique, dimension que la prose ne commente pas encore. Il est
  # calculé, pas écrit : vrai par construction sur n'importe quelle filière. 260828
  title    = local({
    .big  <- ape_wide$code_ape[which.max(ape_wide$emp_ref)]
    .fast <- ape_wide$code_ape[which.max(ape_wide$tcam_p1p2)]
    if (identical(.big, .fast)) "Le segment le plus gros porte aussi la croissance"
    else "Les segments les plus gros ne sont pas les plus dynamiques"
  }),
  subtitle = sprintf("Aire = salariés privés %d · couleur = TCAM %d-%d (bordeaux = hausse, bleu = recul) · halo = famille NA88",
                     p_evol[2], p_evol[1], p_evol[2]),
  caption  = make_source("URSSAF-ACOSS APE")
)

Voir le code
# DÉTAIL / mécanisme APRÈS la figure. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("nat_compo"))

Ce déséquilibre tient à la nature même des métiers. Le transport routier reste une activité intensive en main-d’œuvre, où chaque véhicule mobilise un conducteur : le volume d’emploi y suit mécaniquement le volume de marchandises. L’entreposage, à l’inverse, s’automatise vite — convoyeurs, préparation vocale, robotisation des navettes — et gagne en surface bien plus qu’en effectifs. Les deux segments racontent donc deux économies différentes sous une même étiquette.

Les activités restantes se répartissent entre l’affrètement et l’organisation du transport (87 393 salariés), fonction d’intermédiation sans actifs propres, et une longue traîne de spécialités — messagerie et fret express (37 000), location de camions, manutention portuaire, fret ferroviaire — dont aucune ne dépasse 5 % du total. Le fret ferroviaire, avec 7 892 salariés, pèse moins que la seule manutention portuaire.

Cette structure figée dans les volumes est-elle aussi figée dans les dynamiques ?

Cette composition connue, on situe le secteur dans l’économie nationale : son positionnement parmi les grands secteurs, son poids relatif et son orientation récente.

Voir le code
.eco_rk   <- eco_a21 |> arrange(desc(emp_ref)) |> mutate(rk = dplyr::row_number())
# Le cœur peut relever de PLUSIEURS grands secteurs A21 (ex tourisme : I hébergement-resto + N services) →
# on retient le DOMINANT (le plus d'emploi cœur) pour la narration de rang ; "principalement" si multiple.
.multi_a21 <- length(sect_a21_lbl) > 1
# A21 dominant = celui où le CŒUR concentre le plus d'emploi (PAS la plus grosse section A21 globale :
# ex tourisme, 79 est dans "Services administratifs" — section énorme — mais le cœur est surtout en I).
.coeur_a21 <- dat$urssaf |> filter(measure == "EFF_SAL", year == ann_ref, code_a88 %in% proxy_a88) |>
  left_join(map_a88_a21, by = "code_a88") |> filter(!is.na(a21)) |>
  group_by(a21) |> summarise(e = sum(value, na.rm = TRUE), .groups = "drop") |> slice_max(e, n = 1)
.sect_rw  <- .eco_rk |> filter(libelle == unname(.a21_lbl[.coeur_a21$a21]))
.sect_sh  <- .sect_rw$emp_ref / sum(eco_a21$emp_ref) * 100
.eco_evol <- (sum(eco_a21[[paste0("y", ann_ref)]]) / sum(eco_a21[[paste0("y", 2019L)]]) - 1) * 100
.sect_dir <- dplyr::case_when(
  .sect_rw$evol > .eco_evol + 1 ~ "plus vite que l'ensemble",
  .sect_rw$evol < .eco_evol - 1 ~ "moins vite que l'ensemble",
  TRUE                          ~ "au rythme de l'ensemble")
# Le rang est explicitement qualifié « par le volume » : le TOOLTIP du nuage affiche, lui, un rang PAR
# CROISSANCE (`rank(-evol)` dans jcp_psect_scatter_a38). Deux classements légitimes mais différents —
# sans le dire, leur juxtaposition se lit comme une contradiction. 260820
log_result("a21_rank", list(section = as.character(.sect_rw$libelle), rk_volume = .sect_rw$rk,
  n_sections = nrow(.eco_rk), part_pct = .sect_sh, evol_section = .sect_rw$evol, evol_ensemble = .eco_evol),
  interpret = sprintf("A21 (URSSAF privé %d-%d) : %s, rang volume %d/%d, %.1f%% du privé, évol %+.1f%% vs %+.1f%% ensemble",
                      2019L, ann_ref, .sect_rw$libelle, .sect_rw$rk, nrow(.eco_rk), .sect_sh, .sect_rw$evol, .eco_evol))
cat(prose_cc_pre("nat_a21", function() glue::glue(
  "{sect_phrase_cap} relève {ifelse(.multi_a21, 'principalement ', '')}du grand secteur **{tolower(.sect_rw$libelle)}**, ",
  "**{.sect_rw$rk}{ifelse(.sect_rw$rk==1,'er','e')} employeur privé** de France par le volume ",
  "sur les {nrow(.eco_rk)} grands secteurs représentés (**{fmt_pct_1dec(.sect_sh)} de l'emploi salarié privé**). ",
  "Sur {2019L}-{ann_ref}, les effectifs de ce grand secteur progressent de **{fmt_sign_1dec(.sect_rw$evol)} %**, ",
  "soit {.sect_dir} (**{fmt_sign_1dec(.eco_evol)} %**)."
)))

Le secteur de la logistique relève du grand secteur transports-entreposage, 7e employeur privé de France par le volume sur les 18 grands secteurs représentés (7,5 % de l’emploi salarié privé). Sur 2019-2025, les effectifs de ce grand secteur progressent de +4,3 %, soit moins vite que l’ensemble (+6,5 %).

Voir le code
if (!exists("jcp_psect_scatter_a38", mode = "function") && file.exists("helpers/_load.R")) source("helpers/_load.R")
# Sous-titre COURT : l'ancien listait les cinq encodages et sortait TRONQUÉ à droite (« périmètre
# exa… »). Les axes portent déjà leur propre libellé, la légende dit la couleur : ne reste ici que
# ce qu'aucun des deux ne dit — la taille, et le repère de la cible. 260825
.sub_sc <- sprintf("Taille = emploi %d · cible en ambre · molette = zoom", ann_ref)
# Cible sur le PÉRIMÈTRE EXACT (coeur_ape) si dispo → cohérent avec le headline ; fond = grands secteurs A88.
.ape_sc   <- if (isTRUE(has_ape_curated)) dat$urssaf_ape else NULL
.coeur_sc <- if (isTRUE(has_ape_curated)) coeur_ape     else NULL
render_girafe(jcp_psect_scatter_a38(
  dat$urssaf, proxy_a88, base_year = 2019L, p1_year = p_evol[1], ref_year = ann_ref, level = "a21",
  target_label = params$secteur_short %||% sect_lbl, n_label = 21,
  dat_ape = .ape_sc, coeur_ape = .coeur_sc,
  title = sprintf("%s parmi les 21 grands secteurs", params$secteur_short %||% sect_lbl), subtitle = .sub_sc),
  width_svg = 10, height_svg = 6.5, zoom = TRUE, max_zoom = 8)
Voir le code
render_girafe(jcp_psect_scatter_a38(
  dat$urssaf, proxy_a88, base_year = 2019L, p1_year = p_evol[1], ref_year = ann_ref, level = "a38",
  target_label = params$secteur_short %||% sect_lbl, n_label = 14,
  dat_ape = .ape_sc, coeur_ape = .coeur_sc,
  title = sprintf("%s parmi les 38 grands secteurs", params$secteur_short %||% sect_lbl), subtitle = .sub_sc),
  width_svg = 10, height_svg = 6.5, zoom = TRUE, max_zoom = 8)
Voir le code
cat(prose_cc_post("nat_a21"))

Reste à suivre cette trajectoire dans le temps : l’emploi sectoriel se lit en évolution agrégée depuis 2019, en parallèle pour l’emploi privé (URSSAF) et l’emploi total, tous statuts (FLORES).

Voir le code
# Trajectoire agrégée — indice base 100 = 2019.
#   Mis en avant : Emploi privé (URSSAF) + Emploi total (FLORES) du cœur.
#   Contexte gris : les sous-secteurs (divisions cœur) si ≥ 2.
#   Référence pointillée : emploi privé France (mean_ref).
.an_idx <- 2019L
us_raw <- dat$urssaf |> filter(measure == "EFF_SAL", year >= .an_idx)      # tous secteurs (réf France)
fl_raw <- dat$flores_com |> filter(measure == "EFF_SAL", year >= .an_idx)
# Source cœur URSSAF : flux APE EXACT (coeur_ape, nos codes retenus) si dispo, sinon divisions A88
.us_traj <- if (isTRUE(has_ape_curated)) {
  dat$urssaf_ape |> filter(measure == "EFF_SAL", year >= .an_idx) |> dplyr::rename(code_a88 = code_ape)
} else {
  us_raw |> filter(code_a88 %in% proxy_a88)
}

# Cœur en bleu fort : emploi privé sur NOS codes retenus — label = secteur en MAJUSCULES (ex « LOGISTIQUE »)
.coeur_lab <- toupper(sect_lbl)
us_coeur <- .us_traj |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  mutate(grp = .coeur_lab)
# ORANGE : emploi total FLORES — divisions précisées (FLORES ne descend pas sous l'A88)
.fl_lbl  <- sprintf("Emploi total (FLORES, div. %s)", paste(proxy_a88, collapse = "/"))
fl_coeur <- fl_raw |> filter(code_a88 %in% proxy_a88) |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  mutate(grp = .fl_lbl)
# Sous-secteurs = NOS codes flux RÉAGRÉGÉS par division A88 (pas les divisions entières) — contexte coloré
sub_series <- if (isTRUE(has_ape_curated)) {
  .us_traj |> mutate(.div = substr(code_a88, 1, 2)) |>
    group_by(.div, year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
    mutate(grp = .naf_lbl(.div)) |> select(year, emp, grp)
} else if (length(proxy_a88) >= 2) {
  us_raw |> filter(code_a88 %in% proxy_a88) |>
    group_by(code_a88, year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
    mutate(grp = .naf_lbl(code_a88)) |> select(year, emp, grp)
} else NULL

.emph <- .coeur_lab                          # seul focus bold ; FLORES total = réf pointillée légère (mean_ref2)
dyn_long <- bind_rows(us_coeur, sub_series) |>
  group_by(grp) |> arrange(year) |>
  mutate(indice = emp / emp[year == .an_idx] * 100) |> ungroup()
# FLORES total (divisions A88) → référence pointillée légère
fl_ref <- fl_coeur |> arrange(year) |> mutate(Total = emp / emp[year == .an_idx] * 100) |> select(year, Total)
# Ordre : mis en avant en dernier (tracés au-dessus du contexte)
dyn_long$grp <- factor(dyn_long$grp, levels = c(setdiff(unique(dyn_long$grp), .emph), .emph))

# Référence emploi privé France (tous secteurs URSSAF) → mean_ref pointillé
fr_ref <- us_raw |> group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  arrange(year) |> mutate(France = emp / emp[year == .an_idx] * 100) |> select(year, France)

# URSSAF au périmètre A88 — SERVIRA UNIQUEMENT à la phrase privé/public, jamais au graphique.
# ⚠️ La ligne bold du graphique est au périmètre APE EXACT, la ligne FLORES au périmètre A88 : les
# opposer revient à mesurer d'un coup le statut (privé vs total) ET le périmètre (11 sous-classes vs
# 2 divisions entières), puis à attribuer le tout à l'emploi public. Le rapport affichait ainsi
# « total +7,6 %, privé +11,6 %, ce qui situe le poids de l'emploi public » — un écart dont la plus
# grosse part venait du périmètre. Pour isoler le statut il faut la MÊME maille des deux côtés. 260828
us_a88_ref <- us_raw |> filter(code_a88 %in% proxy_a88) |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  arrange(year) |> mutate(indice = emp / emp[year == .an_idx] * 100) |> select(year, indice)

# Creux du cœur privé (covid) — narration
sect_serie <- dyn_long |> filter(grp %in% .emph)   # ligne cœur (robuste au label LOGISTIQUE)
creux_row  <- sect_serie |> filter(year > .an_idx) |> slice_min(indice, n = 1)
log_result("dyn_indices_ref", setNames(
  round(c(us_coeur$emp[us_coeur$year==ann_ref]/us_coeur$emp[us_coeur$year==.an_idx]*100,
          fr_ref$France[fr_ref$year==ann_ref]), 1), c("coeur_urssaf", "france")))
Voir le code
# Narration trajectoire — générique (aucune branche superflue). Ligne cœur = grp %in% .emph (label LOGISTIQUE).
.su          <- dyn_long |> filter(grp %in% .emph)
.an_us       <- max(.su$year); .ind_end <- .su$indice[.su$year == .an_us]
.sf          <- fl_ref |> dplyr::rename(indice = Total)
.an_fl       <- max(.sf$year)
.ind_fl_end  <- .sf$indice[.sf$year == .an_fl]
.ind_su_atfl <- .su$indice[.su$year == .an_fl]
.a_repli     <- creux_row$indice < 99   # vrai repli covid (indice < 99) vs croissance continue

# Insight : gain depuis 2019 (+ creux covid si repli) — accolades obligatoires (else sur nouvelle ligne)
.insight_txt <- if (.a_repli) {
  glue::glue("Depuis {2019L}, l'emploi du secteur a regagné **{fmt_sign_1dec(.ind_end - 100)} %** (base 100), après un creux à l'indice **{round(creux_row$indice)}** en {creux_row$year}.")
} else {
  glue::glue("Depuis {2019L}, l'emploi du secteur progresse sans rupture, gagnant **{fmt_sign_1dec(.ind_end - 100)} %** (base 100).")
}

# Écart privé (URSSAF) / total tous statuts (FLORES) = poids de l'emploi public — À PÉRIMÈTRE ÉGAL.
# On compare la série FLORES A88 à la série URSSAF A88 (`us_a88_ref`), PAS à la ligne bold du
# graphique qui est au périmètre APE exact : sinon l'écart mesure aussi le périmètre (cf chunk
# précédent). `.ind_su_atfl` reste calculé pour l'écart affiché à l'écran, mais ne sert plus ici.
.ind_a88_atfl <- us_a88_ref$indice[us_a88_ref$year == .an_fl]
.gap_pp  <- .ind_fl_end - .ind_a88_atfl
.gap_txt <- if (abs(.gap_pp) < 1.5) "suivent la même trajectoire" else "s'écartent"
.gap_lec <- if (abs(.gap_pp) < 1.5)
  "l'emploi public du secteur évolue donc au même rythme que le privé" else if (.gap_pp < 0)
  "l'emploi public du secteur progresse donc moins vite que le privé" else
  "l'emploi public du secteur progresse donc plus vite que le privé"

cat(prose_cc_pre("nat_privepublic", function() glue::glue(
  "[{.insight_txt}]{{.insight}}\n\n",
  "L'emploi privé (URSSAF) et l'emploi total tous statuts (FLORES) **{.gap_txt}** : à fin {.an_fl} et ",
  "**à périmètre identique** (divisions {paste(proxy_a88, collapse = '/')}, seule maille que FLORES ",
  "atteigne), l'emploi total a gagné **{fmt_sign_1dec(.ind_fl_end - 100)} %** depuis {2019L} contre ",
  "**{fmt_sign_1dec(.ind_a88_atfl - 100)} %** pour le seul privé — {.gap_lec}. ",
  "La courbe en gras, elle, suit le périmètre exact du secteur et n'est pas comparable terme à terme ",
  "à la série FLORES."
)))

Depuis 2019, l’emploi du secteur progresse sans rupture, gagnant +12,7 % (base 100).

L’emploi privé (URSSAF) et l’emploi total tous statuts (FLORES) suivent la même trajectoire : à fin 2024 et à périmètre identique (divisions 49/52, seule maille que FLORES atteigne), l’emploi total a gagné +7,6 % depuis 2019 contre +7,9 % pour le seul privé — l’emploi public du secteur évolue donc au même rythme que le privé. La courbe en gras, elle, suit le périmètre exact du secteur et n’est pas comparable terme à terme à la série FLORES.

Voir le code
# TROIS corrections demandées (260825) :
#
# 1. ÉVOLUTION EN % au lieu de l'indice. « 113 » se lisait comme un volume — d'autant qu'il est
#    accolé au libellé, exactement comme les « 229 k » du treemap. On retranche 100 partout et on
#    recentre sur 0 : l'étiquette devient « 13 », l'axe dit « % depuis 2019 ». Aucune donnée n'est
#    modifiée, c'est le même objet lu dans l'unité qui parle.
# 2. LISSAGE. Les séries annuelles reliées en segments droits donnaient un profil cassé. Spline
#    MONOTONE (monoH.FC) : elle passe EXACTEMENT par les points observés et n'invente aucun
#    extremum entre deux années — contrairement à une spline classique, qui peut faire dépasser la
#    courbe au-delà des valeurs réelles et suggérer un pic qui n'a pas eu lieu.
# 3. ÉTIQUETTES agrandies, et les deux références RACCOURCIES : « Total FLORES (div. 49/52) » et
#    « Emploi privé France » se chevauchaient en bas à droite, leurs valeurs étant proches.
.to_pct <- function(d, col) { d[[col]] <- d[[col]] - 100; d }
.smooth_serie <- function(d, xk = "year", yk = "indice", gk = "grp", n = 120) {
  do.call(rbind, lapply(split(d, d[[gk]]), function(g) {
    g <- g[order(g[[xk]]), ]
    if (nrow(g) < 3) return(g)
    xx <- seq(min(g[[xk]]), max(g[[xk]]), length.out = n)
    out <- g[rep(1, length(xx)), , drop = FALSE]
    out[[xk]] <- xx
    out[[yk]] <- stats::splinefun(g[[xk]], g[[yk]], method = "monoH.FC")(xx)
    out
  }))
}
.dl  <- .smooth_serie(.to_pct(dyn_long, "indice"))
.fr2 <- .to_pct(fr_ref, "France"); .fl2 <- .to_pct(fl_ref, "Total")

plot_line_indice100(
  .dl, x = "year", idx = "indice", serie = "grp",
  ref_value = 0, points = FALSE, name_in_plot = TRUE, value_labels = TRUE, name_box = FALSE,
  emphasis = .emph, emphasis_col = "#12719e",
  context_pal = c("#9ecae1", "#6baed6", "#4292c6", "#c6dbef"),
  # Libellés très courts : les deux références finissent à ~2 points d'écart (6 et 8), donc leurs
  # étiquettes se télescopent quelle que soit leur hauteur — seule la LARGEUR peut être réduite.
  mean_ref = .fr2, mean_lab = "Privé FR", mean_col = "#9a9a9a",
  mean_ref2 = .fl2, mean_lab2 = "FLORES", mean_col2 = "#bdbdbd",
  # Unité sur l'étiquette : « 13 » se lisait comme un volume, exactement le reproche fait à l'indice
  # « 113 » qu'on vient de retirer. Le signe explicite dit en plus le sens sans lire l'axe. 260828
  value_fmt = function(v) sprintf("%+.0f %%", v),
  value_size_top = 3.8, value_size_bot = 3.0, name_size = 3.4, line_size = 1.25,
  x_breaks = seq(.an_idx, ann_ref, by = 2),
  title    = sprintf("Emploi %s — évolution depuis %d", sect_lbl, .an_idx),
  subtitle = "Cœur en bleu fort · sous-secteurs (divisions) en bleu clair · pointillés gris = références France",
  y_lab    = sprintf("%% depuis %d", .an_idx),
  caption  = make_source("URSSAF-ACOSS", "INSEE FLORES"))

Voir le code
cat(prose_cc_post("nat_privepublic"))
Voir le code
# H3 + intro conditionnels (n'apparaissent que si le détail APE existe)
cat(glue::glue(
  "### Deux périodes, deux rythmes : quels segments portent la croissance ?\n\n",
  "La décomposition de la croissance **{2019L}-{p_evol[2]}** en deux temps — rebond post-Covid ",
  "**{2019L}-{p_evol[1]}** puis dynamique récente **{p_evol[1]}-{p_evol[2]}** — isole les moteurs et les freins du secteur.\n"
))

Deux périodes, deux rythmes : quels segments portent la croissance ?

La décomposition de la croissance 2019-2025 en deux temps — rebond post-Covid 2019-2023 puis dynamique récente 2023-2025 — isole les moteurs et les freins du secteur.

Voir le code
# INSIGHT + idées-clés AVANT la figure (conv 260820). Calculs communs au pre ET au fallback auto.
.best   <- ape_wide |> arrange(desc(tcam_p1p2)) |> slice_head(n = 1)
.worst  <- ape_wide |> arrange(tcam_p1p2) |> slice_head(n = 1)
# Segment qui décélère le plus (positif sur longue période, ralentit sur récente)
.decel  <- ape_wide |> mutate(d = tcam_p1p2 - tcam_long) |> arrange(d) |> slice_head(n = 1)
.n_pos  <- sum(ape_wide$tcam_p1p2 > 0)

cat(prose_cc_pre("nat_deuxtemps", function() glue::glue(
  "[**{fr_naf(.best$libelle, .naf_genre_of(.best$code_ape), cap=TRUE)}** mène la croissance récente (**{fmt_sign_1dec(.best$tcam_p1p2)} % par an** sur {p_evol[1]}-{p_evol[2]}) ",
  "quand **{fr_naf(.worst$libelle, .naf_genre_of(.worst$code_ape))}** décroche (**{fmt_sign_1dec(.worst$tcam_p1p2)} % par an**)]{{.insight}}\n\n",
  "Sur les {nrow(ape_wide)} sous-classes, **{.n_pos}** progressent encore sur la période récente. ",
  "Le graphique sépare la croissance totale en deux temps : le **rebond {2019L}-{p_evol[1]}** (teinte claire) ",
  "et la **dynamique récente {p_evol[1]}-{p_evol[2]}** (bleu, en haut). ",
  "**{fr_naf(.decel$libelle, .naf_genre_of(.decel$code_ape), cap=TRUE)}** illustre le reflux : sa croissance retombe de {fmt_sign_1dec(.decel$tcam_long)} à {fmt_sign_1dec(.decel$tcam_p1p2)} % par an. ",
  "Les barres bleues du haut désignent les segments qui portent encore la croissance aujourd'hui."
)))

L’entreposage progresse de 4,0 % par an quand la messagerie recule de 3,0 % — deux trajectoires opposées sous une même étiquette

La croissance récente ne se répartit pas, elle se concentre. Sur 2023-2025, l’entreposage non frigorifique progresse de 4,0 % par an et devient le premier moteur du secteur : avec 112 508 salariés, il combine une base large et le rythme le plus élevé. L’entreposage frigorifique (2,5 %) et l’affrètement (2,5 %) suivent, portés par la même logique de massification et d’externalisation (figure ci-dessous).

Voir le code
# « GLOBALEMENT » — le cadrage d'ensemble AVANT le détail par segment : combien d'emplois nets, et
# quelle part tient dans les 3 premiers. Émis HORS prose_cc (donc affiché même quand le bloc est
# rédigé à la main), parce que c'est la clé de lecture de la figure qui suit. 260825
.nv   <- ape_wide[[paste0("y", p_evol[2])]] - ape_wide[[paste0("y", 2019L)]]
.solde <- sum(.nv, na.rm = TRUE); .gtot <- sum(.nv[.nv > 0], na.rm = TRUE)
.top3  <- head(order(-.nv), 3); .p3 <- sum(.nv[.top3]) / .gtot * 100
log_result("decomp_concentration",
  list(solde_net = .solde, creations_brutes = .gtot, top3_part = .p3,
       top3 = paste(ape_wide$libelle[.top3], collapse = " · ")),
  interpret = sprintf("Décompo : solde net %+d emplois ; 3 premiers segments = %.0f %% des créations (%s)",
                      round(.solde), .p3, paste(ape_wide$libelle[.top3], collapse = ", ")))
cat(glue::glue(
  "\n\nGlobalement, le secteur {ifelse(.solde >= 0, 'a créé', 'a perdu')} **{fmt_k(abs(.solde))} emplois nets** ",
  "sur {2019L}-{p_evol[2]}. **Trois segments concentrent {round(.p3)} %** des créations : ",
  "{paste(tolower(ape_wide$libelle[.top3]), collapse = ', ')}.\n\n"))

Globalement, le secteur a créé 83 k emplois nets sur 2019-2025. Trois segments concentrent 71 % des créations : entrepôt non frigo, fret interurbain, fret proximité.

Voir le code
# Décomposition additive en 2 sous-périodes : rebond (2019→p1) + récent (p1→p2) = variation totale.
#
# ⚠️ AXE EN VOLUME D'EMPLOIS, plus en pourcentage. L'ancienne version divisait par la base DE CHAQUE
# SEGMENT : ce n'était donc pas une « contribution » mais un TAUX DE CROISSANCE, malgré l'axe intitulé
# « Contribution (%) ». Conséquences : le fret ferroviaire affichait +160 % (5 000 emplois sur une
# base minuscule) et écrasait l'échelle à 200 pendant que tous les autres tenaient sous 30 ; et la
# somme des barres valait 262 % au lieu des 12,7 % de croissance réelle du secteur.
# En volume, les barres s'ADDITIONNENT à la variation nette du secteur, l'échelle est lisible sans
# outlier, et le graphique répond enfin à la question de son titre. 260825
.lab_p1 <- sprintf("%d-%d", 2019L, p_evol[1])
.lab_p2 <- sprintf("%d-%d", p_evol[1], p_evol[2])
ape_decomp <- ape_wide |>
  mutate(g_rebond = .data[[paste0("y", p_evol[1])]] - .data[[paste0("y", 2019L)]],
         g_recent = .data[[paste0("y", p_evol[2])]] - .data[[paste0("y", p_evol[1])]]) |>
  select(libelle, g_rebond, g_recent) |>
  tidyr::pivot_longer(c(g_rebond, g_recent), names_to = "periode", values_to = "gain") |>
  mutate(periode = factor(ifelse(periode == "g_rebond", .lab_p1, .lab_p2),
                          levels = c(.lab_p2, .lab_p1)))  # récent au-dessus, rebond en bas

# 2e ligne du label total : ÉVOLUTION TOTALE du segment sur la période, en %. La barre donne le
# VOLUME (comparable, additif), l'étiquette donne l'INTENSITÉ — deux informations complémentaires
# qu'un seul encodage ne peut pas porter. C'est aussi ce qui remet le fret ferroviaire à sa place :
# sa croissance reste spectaculaire en %, mais sur une barre visiblement petite.
# (Une part « du total des créations » a été essayée puis retirée : étiquettes trop longues, elles
#  se chevauchaient d'une barre à l'autre — la concentration est dite en prose, avant la figure.)
.net_vol  <- ape_wide[[paste0("y", p_evol[2])]] - ape_wide[[paste0("y", 2019L)]]
.base_19  <- ape_wide[[paste0("y", 2019L)]]
.pct_extra <- setNames(ifelse(.base_19 > 0, sprintf("%+.0f %%", .net_vol / .base_19 * 100), ""),
                       ape_wide$libelle)

# label_threshold : sans lui, l'étiquette d'un segment MINCE (texte blanc, calé sur le bleu foncé)
# débordait sur le bleu pâle voisin → blanc sur fond clair, illisible. On masque les segments trop
# petits pour porter leur valeur ; le total au-dessus de la barre reste toujours lisible. 260825
plot_bar_stack_decomp_bipolar(
  ape_decomp, entite = "libelle", composante = "periode", val = "gain",
  # Teinte « rebond » ASSOMBRIE (#cfe3ef -> #a8cde3). Le helper choisit bien un texte foncé sur fond
  # clair, mais du #333 en corps 2,7 sur un bleu à 222 de luminance se délave — l'étiquette existait
  # sans être lisible. Assez sombre pour porter le texte, assez clair pour rester « atténué ». 260825
  colors = setNames(c("#1696d2", "#a8cde3"), c(.lab_p2, .lab_p1)),
  label_threshold = max(abs(.net_vol), na.rm = TRUE) * 0.06,
  label_fmt = function(v) paste0(ifelse(v >= 0, "+", "−"), fmt_k(abs(v))),
  total_fmt = function(v) paste0(ifelse(v >= 0, "+", "−"), fmt_k(abs(v))),
  total_extra = .pct_extra,
  title    = sprintf("Trois segments portent l'essentiel des créations %d-%d", 2019L, p_evol[2]),
  subtitle = sprintf("Variation NETTE d'effectifs salariés privés · les barres s'additionnent au solde du secteur · rebond %s (teinte claire) + récent %s (bleu)",
                     .lab_p1, .lab_p2),
  x_lab = NULL, y_lab = "Variation nette d'effectifs",
  caption = make_source("URSSAF-ACOSS APE"))

Voir le code
cat(prose_cc_post("nat_deuxtemps"))

Le fret routier, pourtant majoritaire en volume, ne joue plus ce rôle. Le transport interurbain progresse à peine (0,8 % par an), le transport de proximité stagne (recul de 0,1 %) : la pénurie durable de conducteurs y plafonne la capacité d’embauche, indépendamment de la demande. Le secteur croît donc là où il stocke, pas là où il roule.

Trois segments décrochent nettement. La manutention non portuaire perd 4,7 % par an, la messagerie et le fret express 3,0 %, le fret ferroviaire 2,9 %. Le cas de la messagerie mérite attention : c’est précisément le métier du dernier kilomètre, censé profiter du commerce en ligne. Son recul dans l’emploi salarié traduit moins une baisse d’activité qu’un basculement vers des statuts non salariés — micro-entrepreneurs et sous-traitance individuelle — que l’URSSAF ne compte pas dans ce périmètre.

Structure et profil d’emplois

La structure du secteur se lit à deux périmètres complémentaires — d’où deux tableaux. Le premier mesure le flux exact (sous-classes APE retenues, source URSSAF) : volume d’emploi et d’établissements réellement dans le périmètre, fret compris. Le second décrit le profil de la main-d’œuvre (féminisation, âge, qualification, statuts), mesurable seulement sur l’EEC au niveau A88 — plus large : il couvre l’ensemble des divisions cœur (Transport terrestre, Entreposage) sans pouvoir isoler les seules sous-classes du flux. Les totaux des deux tableaux diffèrent donc, et c’est normal : ils ne portent pas sur le même périmètre.

Voir le code
# Tableau VOLUME — périmètre FLUX EXACT (URSSAF, sous-classes APE retenues), distinct du profil EEC (A88) ci-dessous.
# Effectifs = dat$urssaf_ape (EFF_SAL déjà chargé) ; Établissements = NB_ETAB lu en ciblé (data-load ne garde que EFF_SAL).
# Sous-colonnes = divisions du flux ; valeurs = sous-classes RETENUES seulement (ex 49 = fret, pas le transport de voyageurs).

# Effectifs du flux par division + références
.fx_eff <- dat$urssaf_ape |>
  dplyr::filter(measure == "EFF_SAL", year == ann_ref) |>
  dplyr::mutate(div = substr(code_ape, 1, 2)) |>
  dplyr::group_by(div) |> dplyr::summarise(eff = sum(value, na.rm = TRUE), .groups = "drop")

# Établissements du flux (NB_ETAB) — agrégé en arrow (pushdown) puis regroupé par division en R
.fx_etab <- arrow::open_dataset(URSSAF_APE) |>
  dplyr::filter(measure == "NB_ETAB", year == ann_ref, code_ape %in% coeur_ape) |>
  dplyr::group_by(code_ape) |> dplyr::summarise(etab = sum(value), .groups = "drop") |> dplyr::collect() |>
  dplyr::mutate(div = substr(code_ape, 1, 2)) |>
  dplyr::group_by(div) |> dplyr::summarise(etab = sum(etab, na.rm = TRUE), .groups = "drop")

.fr_eff  <- sum(dat$urssaf$value[dat$urssaf$year == ann_ref], na.rm = TRUE)   # France privé (EFF_SAL déjà en mémoire)
.fr_etab <- arrow::open_dataset(URSSAF_TIDY) |>
  dplyr::filter(measure == "NB_ETAB", year == ann_ref) |>
  dplyr::summarise(v = sum(value)) |> dplyr::collect() |> dplyr::pull(v)

# Assemblage : divisions du flux (ordre A88) + cœur agrégé + France
.fx <- .fx_eff |> dplyr::left_join(.fx_etab, by = "div") |> dplyr::arrange(div) |>
  # Label flux : override éditorial (ex 49 → « Transport (fret) ») > libellé division générique.
  dplyr::mutate(lbl = dplyr::coalesce(unname(.flux_div_short[div]), .naf_lbl(div)))
.coeur_eff  <- sum(.fx$eff, na.rm = TRUE); .coeur_etab <- sum(.fx$etab, na.rm = TRUE)
.dlbl <- .fx$lbl

log_result("flux_ape_volume", list(coeur_eff = .coeur_eff, coeur_etab = .coeur_etab,
  part_priv = .coeur_eff / .fr_eff * 100, eff_moy_etab = .coeur_eff / .coeur_etab),
  interpret = sprintf("Flux APE %d : %s sal. / %s étab. (%.2f%% privé)", ann_ref,
                      fmt_k(.coeur_eff), fmt_k(.coeur_etab), .coeur_eff / .fr_eff * 100))

# Matrice valeurs : 4 indicateurs × [cœur, divisions, France]
.eff_v  <- c(.coeur_eff, .fx$eff, .fr_eff)
.etab_v <- c(.coeur_etab, .fx$etab, .fr_etab)
.cols_v <- c(sect_lbl, .dlbl, "France")
disp1 <- data.frame(Indicateur = c("Effectifs salariés", "Établissements",
                                    "Effectif moyen / établissement", "Part dans l'emploi privé"),
                    check.names = FALSE, stringsAsFactors = FALSE)
.mat <- rbind(fmt_k(.eff_v), fmt_k(.etab_v),
              formatC(.eff_v / .etab_v, format = "f", digits = 1, decimal.mark = ","),
              paste0(fmt_fr(.eff_v / .fr_eff * 100, 1), " %"))
for (j in seq_along(.cols_v)) disp1[[.cols_v[j]]] <- .mat[, j]

tbl1 <- gt::gt(disp1)
if (exists("theme_gt_urban", mode = "function")) tbl1 <- theme_gt_urban(tbl1)
tbl1 <- tbl1 |>
  gt::cols_align("left", columns = "Indicateur") |>
  gt::cols_align("center", columns = dplyr::all_of(c(sect_lbl, .dlbl, "France"))) |>
  gt::cols_label(.list = setNames(
    c(list(gt::md(paste0("**", sect_lbl, "**<br><span style='font-weight:400;color:#999;font-size:9px'>flux exact</span>"))),
      lapply(c(.dlbl, "France"), function(x) gt::md(paste0("<span style='color:#8a8a8a'>", x, "</span>")))),
    c(sect_lbl, .dlbl, "France"))) |>
  gt::tab_spanner(label = gt::md(paste0("**Composition du flux · ", sect_lbl, "**")),
                  columns = dplyr::all_of(.dlbl)) |>
  gt::tab_header(
    title = gt::md(sprintf("**%s salariés dans %s établissements — périmètre du flux exact**",
                           fmt_k(.coeur_eff), fmt_k(.coeur_etab))),
    subtitle = gt::md(sprintf("URSSAF privé · sous-classes APE retenues · France %d · périmètre du **FLUX exact** (≠ profil EEC A88 ci-dessous)", ann_ref))) |>
  gt::tab_options(table.font.size = gt::px(12), column_labels.font.size = gt::px(12),
                  data_row.padding = gt::px(4), table.border.top.style = "none") |>
  gt::tab_style(style = gt::cell_fill(color = "#dbeefb"),
                locations = list(gt::cells_column_labels(columns = sect_lbl),
                                 gt::cells_body(columns = sect_lbl))) |>
  gt::tab_style(style = gt::cell_text(size = gt::px(11)),
                locations = gt::cells_body(columns = dplyr::all_of(c(.dlbl, "France")))) |>
  gt::cols_width(Indicateur ~ gt::px(220))
tbl1
730 k salariés dans 37 k établissements — périmètre du flux exact
URSSAF privé · sous-classes APE retenues · France 2025 · périmètre du FLUX exact (≠ profil EEC A88 ci-dessous)
Indicateur Logistique
flux exact
Composition du flux · Logistique
France
Transport (fret) Entreposage Courrier privé
Effectifs salariés 730 k 440 k 279 k 11 k 20,0 M
Établissements 37 k 26 k 10 k 595 1,9 M
Effectif moyen / établissement 19,9 16,6 28,7 18,7 10,5
Part dans l'emploi privé 3,7 % 2,2 % 1,4 % 0,1 % 100,0 %

Périmètre flux : effectifs et établissements URSSAF sur les sous-classes APE retenues (4941A, 4941B, 4941C, 5210A, 5210B, 5224A, 5224B, 5229A, 5229B, 5320Z, 4920Z). Les sous-colonnes ne retiennent, dans chaque division, que ces sous-classes (ex. la division Transport terrestre n’apporte ici que le fret, pas le transport de voyageurs). À comparer au profil EEC ci-dessous, mesuré au niveau A88 (périmètre plus large). Source : URSSAF-ACOSS 2025.

Voir le code
# Sources : EEC (national) — agrégat tous statuts millésime ref
eec_24_data <- dat$eec |> filter(annee == ann_eec)

# Helper agrégation pondérée (sum en dernier pour éviter masquage colonne)
# TODO pipeline EEC : ajouter lab_30moins_pct, lab_anc20p_pct, lab_infbac_pct,
#   lab_bac2p_pct, lab_bac5p_pct (cf gdtodo-enrich-eec-pipeline_260602.md)
.weighted_agg <- function(df, label) {
  df |> summarise(
    lab_fem_pct      = weighted.mean(lab_fem_pct, lab_eff_vol),
    lab_agemoy       = weighted.mean(lab_agemoy, lab_eff_vol),
    lab_25moins_pct  = weighted.mean(lab_25moins_pct, lab_eff_vol),
    lab_50plus_pct   = weighted.mean(lab_50plus_pct, lab_eff_vol),
    lab_65plus_pct   = weighted.mean(lab_65plus_pct, lab_eff_vol),
    lab_anc5p_pct    = weighted.mean(lab_anc5p_pct, lab_eff_vol),
    lab_infrabac_pct = weighted.mean(lab_infrabac_pct, lab_eff_vol),
    lab_bac2plus_pct = weighted.mean(lab_bac2plus_pct, lab_eff_vol),
    lab_bac5plus_pct = weighted.mean(lab_bac5plus_pct, lab_eff_vol),
    lab_bacplus_pct  = weighted.mean(lab_bacplus_pct, lab_eff_vol),
    lab_cdd_pct      = weighted.mean(lab_cdd_pct, lab_eff_vol),
    lab_tpart_pct    = weighted.mean(lab_tpart_pct, lab_eff_vol),
    lab_souempl_pct  = weighted.mean(lab_souempl_pct, lab_eff_vol),
    lab_pub_pct      = weighted.mean(lab_pub_pct, lab_eff_vol),
    lab_pe_pct       = weighted.mean(lab_pe_pct, lab_eff_vol),
    lab_indep_pct    = weighted.mean(lab_indep_pct, lab_eff_vol),
    lab_etranger_pct = weighted.mean(lab_etranger_pct, lab_eff_vol),  # NA en 2019/2022 (NATIO 2024 seul)
    lab_eff_vol      = sum(lab_eff_vol)
  ) |> mutate(label = label)
}

# Labels propres pour affichage (pas de codes NA88 dans le rendu)
# unname() obligatoire : vapply retourne un vecteur nommé qui casse tidyselect (select renomme)
sect_lbl  <- params$secteur_short %||% params$secteur_label
# Sous-secteurs du profil EEC = divisions DU CŒUR A88 (proxy_a88), JAMAIS les divisions ramenées par le
# flux APE. Sinon une division hors cœur (ex logistique : 49 transport — qui inclut les VOYAGEURS) s'affiche
# comme « sous-secteur » alors qu'elle est ABSENTE du cœur EEC → cœur ≠ somme des sous-secteurs (incohérent).
# EEC/FLORES ne descendent pas sous l'A88 ; le fret routier (49) reste visible dans le VOLUME URSSAF.
# Cf rgd-guide/gdreadm-psect-sources-perimetres_260616.md §5.
prof_sub  <- proxy_a88
naf_lbls  <- unname(vapply(prof_sub, .naf_lbl, character(1)))

# Total emploi national (référence) et secteur cœur agrégé
eec_fr   <- .weighted_agg(eec_24_data, "FR total")
eec_sect <- .weighted_agg(eec_24_data |> filter(code_naf88 %in% proxy_a88), sect_lbl)

# --- ÉVOLUTION : profil secteur au millésime de base (2019) → delta en points ---
# ⚠️ rupture de série refonte EEC 2021 → lecture en ordre de grandeur (encadré). NA = non comparable
# (65+/Bac+5/CDD/<25/étrangers absents ou non isolables en 2019). Cf reference_eec_pipe_v2_evolution.
ann_eec_base  <- min(dat$eec$annee, na.rm = TRUE)            # 2019
.eec_base     <- dat$eec |> filter(annee == ann_eec_base)
eec_sect_base <- .weighted_agg(.eec_base |> filter(code_naf88 %in% proxy_a88), sect_lbl)
eec_fr_base   <- .weighted_agg(.eec_base, "FR total")
.evol_pts <- function(v) if (is.null(eec_sect[[v]]) || is.null(eec_sect_base[[v]])) NA_real_ else eec_sect[[v]] - eec_sect_base[[v]]
eec_evol <- vapply(setdiff(names(eec_sect), c("label", "lab_eff_vol")), .evol_pts, numeric(1))
eec_evol["lab_eff_vol"]      <- NA_real_   # volume : pas un delta en points
eec_evol["part_emp_tot_pct"] <- (eec_sect$lab_eff_vol / eec_fr$lab_eff_vol -
                                 eec_sect_base$lab_eff_vol / eec_fr_base$lab_eff_vol) * 100

# NAF individuels (1 ligne par code proxy_a88, dynamique avec label propre)
.profile_cols <- c("label", "lab_eff_vol",
                   "lab_fem_pct", "lab_agemoy", "lab_25moins_pct", "lab_50plus_pct",
                   "lab_65plus_pct", "lab_anc5p_pct",
                   "lab_infrabac_pct", "lab_bac2plus_pct", "lab_bac5plus_pct", "lab_bacplus_pct",
                   "lab_cdd_pct", "lab_tpart_pct", "lab_souempl_pct",
                   "lab_pub_pct", "lab_pe_pct", "lab_indep_pct", "lab_etranger_pct")

eec_individuels <- purrr::map_dfr(prof_sub, function(cd) {
  eec_24_data |> filter(code_naf88 == cd) |> mutate(label = .naf_lbl(cd))
}) |> select(all_of(.profile_cols))

# Profil 2019 par sous-secteur (même ordre prof_sub) → évolution 2019→2024 par sous-secteur
eec_indiv_base <- purrr::map_dfr(prof_sub, function(cd) {
  .eec_base |> filter(code_naf88 == cd) |> mutate(label = .naf_lbl(cd))
}) |> select(all_of(.profile_cols))
.num_pc <- setdiff(.profile_cols, "label")
eec_indiv_evol <- eec_individuels
eec_indiv_evol[.num_pc] <- eec_individuels[.num_pc] - eec_indiv_base[.num_pc]  # delta en points (NA si 2019 absent)

# Tous regroupés
all_cols <- bind_rows(eec_sect, eec_individuels, eec_fr) |>
  select(all_of(.profile_cols))

# Part dans emp tot
emp_tot_global <- eec_fr$lab_eff_vol
all_cols$part_emp_tot_pct <- all_cols$lab_eff_vol / emp_tot_global * 100

# Logging des chiffres clés
log_result("eec_sect_profile", list(
  sect_eff = eec_sect$lab_eff_vol, sect_fem = eec_sect$lab_fem_pct,
  sect_tp = eec_sect$lab_tpart_pct, sect_cdd = eec_sect$lab_cdd_pct,
  sect_pub = eec_sect$lab_pub_pct, sect_anc5p = eec_sect$lab_anc5p_pct,
  sect_souempl = eec_sect$lab_souempl_pct
))

# Calculs des écarts secteur vs FR (réutilisés narration analytique)
delta_qualif <- list(
  fem    = eec_sect$lab_fem_pct    - eec_fr$lab_fem_pct,
  tpart  = eec_sect$lab_tpart_pct  - eec_fr$lab_tpart_pct,
  cdd    = eec_sect$lab_cdd_pct    - eec_fr$lab_cdd_pct,
  pub    = eec_sect$lab_pub_pct    - eec_fr$lab_pub_pct,
  anc5p  = eec_sect$lab_anc5p_pct  - eec_fr$lab_anc5p_pct,
  bacplus = eec_sect$lab_bacplus_pct - eec_fr$lab_bacplus_pct,
  souempl = eec_sect$lab_souempl_pct - eec_fr$lab_souempl_pct,
  agemoy = eec_sect$lab_agemoy     - eec_fr$lab_agemoy
)
log_result("_detail_delta_qualif", delta_qualif,
           interpret = sprintf("Écarts secteur vs FR (pts) : fem %+.1f · TP %+.1f · pub %+.1f · CDD %+.1f · bac+ %+.1f",
                              delta_qualif$fem, delta_qualif$tpart, delta_qualif$pub,
                              delta_qualif$cdd, delta_qualif$bacplus))
Voir le code
# Analyse insight-first. Les valeurs chiffrées + l'écart vs France vivent dans le TABLEAU ci-dessous
# (colonne France + barre Écart) → la prose QUALIFIE et explique, sans répéter les % ni les écarts en pts.
# Qualifs alignés sur breaks_ratio gt_jumelles_pool. Régen : /rgen-write-anal profil_natnl
qualif_fem    <- fr_ecart_qualif(eec_sect$lab_fem_pct,      eec_fr$lab_fem_pct,      mode = "rel")
qualif_p50    <- fr_ecart_qualif(eec_sect$lab_50plus_pct,   eec_fr$lab_50plus_pct,   mode = "rel")
qualif_infra  <- fr_ecart_qualif(eec_sect$lab_infrabac_pct, eec_fr$lab_infrabac_pct, mode = "rel")
qualif_cdd    <- fr_ecart_qualif(eec_sect$lab_cdd_pct,      eec_fr$lab_cdd_pct,      mode = "rel")
qualif_souemp <- fr_ecart_qualif(eec_sect$lab_souempl_pct,  eec_fr$lab_souempl_pct,  mode = "rel")
qualif_pub    <- fr_ecart_qualif(eec_sect$lab_pub_pct,      eec_fr$lab_pub_pct,      mode = "rel")

# Contraste interne UNIQUEMENT si ≥ 2 divisions cœur (sinon = comparer une division à elle-même)
fem_par_naf <- eec_individuels |> dplyr::arrange(dplyr::desc(lab_fem_pct))
naf_fem_max <- fem_par_naf$label[1];                 v_fem_max <- fem_par_naf$lab_fem_pct[1]
naf_fem_min <- fem_par_naf$label[nrow(fem_par_naf)]; v_fem_min <- fem_par_naf$lab_fem_pct[nrow(fem_par_naf)]
.multi_div <- nrow(eec_individuels) >= 2
.contraste_fem <- if (.multi_div) glue::glue(
  "Le contraste interne est net : **{naf_fem_max}** ({fmt_pct_1dec(v_fem_max)}) tire la moyenne à la hausse face à **{naf_fem_min}** ({fmt_pct_1dec(v_fem_min)}). ") else ""

cat(prose_cc_pre("nat_profil", function() glue::glue(
  # INSIGHT — 2 chiffres-choc en tête (féminisation + emploi public)
  "[Le secteur compte **{fmt_pct_1dec(eec_sect$lab_fem_pct)} de femmes** et **{fmt_pct_1dec(eec_sect$lab_pub_pct)} d'emploi public**, ",
  "deux marqueurs qui le situent à l'écart du tissu d'emploi national]{{.insight}}\n\n",

  # Para 1 — démographie : féminisation + vieillissement (QUALIF seul, valeurs dans le tableau)
  "Côté démographie, la part des femmes est {qualif_fem} salariat français et le poids des 50 ans et plus {qualif_p50} salariat national. ",
  "{.contraste_fem}",
  "Ces deux traits dessinent un profil de main-d'œuvre nettement typé au regard de la moyenne nationale.\n\n",

  # Para 2 — qualification + précarité : sous-emploi mis en avant plutôt que temps partiel
  "Côté conditions d'emploi, la part de salariés sans le baccalauréat est {qualif_infra} salariat national. ",
  "La précarité se lit davantage dans le **sous-emploi** ({qualif_souemp} salariat national) et les **CDD** ({qualif_cdd} salariat national) que dans le temps partiel. ",
  "L'**emploi public** est enfin {qualif_pub} salariat national, rappelant que le périmètre déborde le seul champ marchand."
)))

Une femme pour quatre salariés — vingt-sept points sous la moyenne nationale, l’un des écarts les plus marqués de l’économie

Le profil de main-d’œuvre est atypique sur deux registres opposés, et c’est ce contraste qui le caractérise le mieux.

Voir le code
# Tableau profil — modèle stabilisé : Écart vs France en BARRE divergente (ecart_html, jcn-gtable),
# unité fusionnée dans l'indicateur, groupes par catégorie (ligne parent seule, indicateurs enfants).
# 3 catégories resserrées. Cœur = secteur ; réf = France ; + sous-secteurs (divisions cœur).
indic_def <- tibble::tribble(
  ~var,                ~categorie,              ~indicateur,                     ~unite, ~type,
  "lab_eff_vol",       "Volume et poids",       "Effectifs salariés",            "",     "vol",
  "part_emp_tot_pct",  "Volume et poids",       "Part dans l'emploi total",      "%",    "pct",
  "lab_pub_pct",       "Volume et poids",       "Part d'emploi public",          "%",    "pct",
  "lab_indep_pct",     "Volume et poids",       "Part d'indépendants",           "%",    "pct",
  "lab_fem_pct",       "Démographie",           "Part de femmes",                "%",    "pct",
  "lab_etranger_pct",  "Démographie",           "Part d'étrangers",              "%",    "pct",
  "lab_agemoy",        "Démographie",           "Âge moyen",                     "ans",  "ratio",
  "lab_25moins_pct",   "Démographie",           "Part de jeunes (< 25 ans)",     "%",    "pct",
  "lab_50plus_pct",    "Démographie",           "Part des 50 ans et plus",       "%",    "pct",
  "lab_anc5p_pct",     "Démographie",           "Part ancienneté 5 ans et plus", "%",    "pct",
  "lab_infrabac_pct",  "Qualification",         "Part infra-bac",                "%",    "pct",
  "lab_bac2plus_pct",  "Qualification",         "Part Bac+2 et plus",            "%",    "pct",
  "lab_bac5plus_pct",  "Qualification",         "Part Bac+5 et plus",            "%",    "pct",
  "lab_cdd_pct",       "Structure de l'emploi", "Part en CDD",                   "%",    "pct",
  "lab_tpart_pct",     "Structure de l'emploi", "Part à temps partiel",          "%",    "pct",
  "lab_souempl_pct",   "Structure de l'emploi", "Part en sous-emploi",           "%",    "pct"
)

tab_wide <- all_cols |> pivot_longer(-label, names_to = "var", values_to = "value") |>
  inner_join(indic_def |> select(var, indicateur), by = "var") |>
  select(indicateur, label, value) |>
  pivot_wider(names_from = label, values_from = value) |> rename(France = `FR total`)
panel <- indic_def |> inner_join(tab_wide, by = "indicateur") |>
  mutate(indicateur = factor(indicateur, levels = indic_def$indicateur)) |> arrange(indicateur) |>
  mutate(indicateur = as.character(indicateur))

# Écart vs France → barre divergente (compute_ecart_byrow + ecart_html, MÊME rendu que jumelles)
.focus <- panel[[sect_lbl]]; .refv <- panel$France
.ec <- compute_ecart_byrow(.focus, .refv, panel$type)
.gap <- ifelse(!is.na(.refv) & .refv != 0, .focus / .refv - 1, NA)
.scale <- sign(.gap) * pmin(2, abs(.gap) / 0.4); .scale[panel$type == "vol"] <- NA
.scale[panel$var == "part_emp_tot_pct"] <- NA   # part dans l'emploi total : écart vs France (=100 %) sans objet
.oneline <- function(s) gsub("[\r\n]+[[:space:]]*", "", s)
ecart_cell <- .oneline(ecart_html(raw = .ec$num, scale = .scale, mode = "bar",
                                  disp_txt = .ec$disp, bw = 46, bh = 12))

# Colonne ÉVOLUTION (pts) : flèche + valeur signée ; "n.c." si non comparable (NA = 65+/Bac+5/CDD/<25/étrangers
# absents 2019). ▲ vert / ▼ rouge = DIRECTION (pas un jugement) ; encadré rupture EEC 2021 sous le tableau.
.evol_cell <- vapply(panel$var, function(v) {
  d <- unname(eec_evol[v])
  if (is.na(d)) return("<span style='color:#c0c0c0;font-size:10px'>n.c.</span>")
  if (abs(d) < 0.05) return("<span style='color:#888;font-size:11px'>= 0,0</span>")
  sprintf("<span style='color:%s;font-size:11px;white-space:nowrap'>%s %s</span>",
          if (d > 0) "#2e7d32" else "#c0392b", if (d > 0) "▲" else "▼", fmt_sign_1dec(d))
}, character(1))

# Valeur formatée (unité-aware) + Indicateur avec unité fusionnée en petit
.fv <- function(v, ty) ifelse(is.na(v), "—",
  ifelse(ty == "vol", fmt_k(v),
  ifelse(ty == "ratio", formatC(v, format = "f", digits = 1, decimal.mark = ","),
         paste0(formatC(v, format = "f", digits = 1, decimal.mark = ","), " %"))))
ind_html <- ifelse(panel$unite == "",
  sprintf("<span style='font-size:12px'>%s</span>", panel$indicateur),
  sprintf("<span style='font-size:12px'>%s</span> <span style='font-style:italic;color:#aaa;font-size:9px'>(%s)</span>", panel$indicateur, panel$unite))

disp <- data.frame(Indicateur = ind_html, check.names = FALSE, stringsAsFactors = FALSE)
disp[[sect_lbl]] <- .fv(.focus, panel$type)
disp[["Évol."]]  <- lapply(.evol_cell, gt::html)   # évolution 2019→2024 (pts), HTML brut
# Écart = HTML brut (gt::html) → JAMAIS via fmt_markdown : en contexte Quarto, commonmark mange le
# <div> bloc de la barre et hisse le <span> valeur dehors (valeur dupliquée + <br><br> parasites).
disp[["Écart"]]  <- lapply(ecart_cell, gt::html)
disp[["France"]] <- .fv(.refv, panel$type)
# Sous-secteurs : valeur 2024 + évolution 2019→2024 compacte (▲▼) dans la même cellule (HTML brut).
.sub_cell <- function(value, d) {
  if (is.na(d) || abs(d) < 0.05) return(sprintf("<div style='line-height:1.1'>%s</div>", value))
  sprintf("<div style='line-height:1.1'>%s<br><span style='font-size:8.5px;color:%s'>%s %s</span></div>",
          value, if (d > 0) "#2e7d32" else "#c0392b", if (d > 0) "▲" else "▼", fmt_sign_1dec(d))
}
for (i in seq_along(naf_lbls)) {
  nc  <- naf_lbls[i]
  .v  <- .fv(panel[[nc]], panel$type)
  .dd <- vapply(panel$var, function(v) if (v %in% names(eec_indiv_evol)) eec_indiv_evol[[v]][i] else NA_real_, numeric(1))
  .dd[panel$type == "vol"] <- NA_real_   # delta en points sans objet pour les volumes
  disp[[nc]] <- lapply(mapply(.sub_cell, .v, .dd, SIMPLIFY = TRUE, USE.NAMES = FALSE), gt::html)
}
disp[["grp"]] <- panel$categorie

# Titre insight dynamique (2 marqueurs les + distinctifs) — secteur en minuscule (pas de majuscule parasite)
.markers <- tibble::tribble(~lbl, ~v, ~ref,
  "féminisation", eec_sect$lab_fem_pct, eec_fr$lab_fem_pct,
  "sous-emploi", eec_sect$lab_souempl_pct, eec_fr$lab_souempl_pct,
  "poids de l'emploi public", eec_sect$lab_pub_pct, eec_fr$lab_pub_pct,
  "faible qualification", eec_sect$lab_infrabac_pct, eec_fr$lab_infrabac_pct
) |> mutate(gap_rel = abs(v / ref - 1)) |> arrange(desc(gap_rel))

tbl <- gt::gt(disp, groupname_col = "grp")
if (exists("theme_gt_urban", mode = "function")) tbl <- theme_gt_urban(tbl)
.sub <- function(t) sprintf("<br><span style='font-weight:400;color:#999;font-size:9px'>%s</span>", t)
tbl <- tbl |>
  gt::fmt_markdown(columns = "Indicateur") |>
  gt::cols_align("left", columns = "Indicateur") |>
  gt::cols_align("center", columns = dplyr::all_of(c(sect_lbl, "Évol.", "Écart", "France", naf_lbls))) |>
  gt::cols_label(.list = setNames(
    c(list(gt::md(paste0("**", sect_lbl, "**", .sub("cœur"))),
           gt::md(paste0(sprintf("**Évol. %s-%s**", substr(ann_eec_base, 3, 4), substr(ann_eec, 3, 4)), .sub("pts"))),
           gt::md(paste0("**Écart / FR**", .sub("pts ou %")))),
      lapply(c("France", naf_lbls), function(x) gt::md(paste0("<span style='color:#8a8a8a'>", x, "</span>", .sub("réf."))))),
    c(sect_lbl, "Évol.", "Écart", "France", naf_lbls))) |>
  gt::tab_spanner(label = gt::md(paste0("**Sous-secteurs · ", sect_lbl, "**")),
                  columns = dplyr::all_of(naf_lbls)) |>
  gt::tab_header(
    title = gt::md(sprintf("**Profil d'emploi : %s et %s distinguent %s du tissu national**",
                           .markers$lbl[1], .markers$lbl[2], sect_court)),
    subtitle = gt::md(sprintf("Salariés · INSEE EEC %d · **périmètre A88** (%s) · écart en pts vs France · évol. %d→%d en pts (⚠ rupture refonte EEC 2021)",
                              ann_eec, paste(.naf_lbl(proxy_a88), collapse = " + "), ann_eec_base, ann_eec))) |>
  gt::tab_options(table.font.size = gt::px(12), column_labels.font.size = gt::px(12),
                  row_group.font.size = gt::px(12), row_group.font.weight = "700",
                  row_group.padding = gt::px(4), data_row.padding = gt::px(4),
                  table.border.top.style = "none") |>
  gt::tab_style(style = list(gt::cell_fill(color = "#f4f4f3"), gt::cell_text(color = "#555")),
                locations = gt::cells_row_groups()) |>
  gt::tab_style(style = gt::cell_fill(color = "#dbeefb"),
                locations = gt::cells_column_labels(columns = sect_lbl)) |>
  gt::tab_style(style = gt::cell_text(size = gt::px(11)),
                locations = gt::cells_body(columns = dplyr::all_of(c("France", naf_lbls)))) |>
  gt::cols_width(Indicateur ~ gt::px(200), `Évol.` ~ gt::px(76), Écart ~ gt::px(86))
tbl
Profil d’emploi : féminisation et faible qualification distinguent la logistique du tissu national
Salariés · INSEE EEC 2024 · périmètre A88 (Transport terrestre + Entreposage) · écart en pts vs France · évol. 2019→2024 en pts (⚠ rupture refonte EEC 2021)
Indicateur Logistique
cœur
Évol. 19-24
pts
Écart / FR
pts ou %
France
réf.
Sous-secteurs · Logistique
Transport terrestre
réf.
Entreposage
réf.
Volume et poids
Effectifs salariés 1,2 M n.c. 29,0 M
800 k
411 k
Part dans l’emploi total (%) 4,2 % ▲ +0,1 100,0 %
2,8 %
1,4 %
Part d’emploi public (%) 18,4 % ▼ -0,7
−5,1
23,5 %
21,5 %
▼ -1,6
12,4 %
▲ +3,7
Part d’indépendants (%) 7,9 % ▼ -0,4
−5,4
13,3 %
11,5 %
▲ +0,4
1,0 %
▼ -0,1
Démographie
Part de femmes (%) 21,3 % ▲ +1,7
−27,5
48,8 %
18,1 %
▲ +1,4
27,6 %
▲ +0,3
Part d’étrangers (%) 8,6 % n.c.
+1,1
7,5 %
8,2 %
9,3 %
Âge moyen (ans) 42,8 ▲ +0,5
+1 %
42,2
43,7
▲ +0,8
41,0
▲ +0,3
Part de jeunes (< 25 ans) (%) 7,9 % n.c.
−1,6
9,4 %
6,5 %
10,6 %
Part des 50 ans et plus (%) 34,4 % ▲ +4,2
+1,5
32,9 %
37,1 %
▲ +5,1
29,1 %
▲ +3,9
Part ancienneté 5 ans et plus (%) 56,8 % ▲ +2,8
−0,2
57,0 %
57,5 %
▲ +3,6
55,6 %
▲ +1,0
Qualification
Part infra-bac (%) 46,2 % ▼ -9,6
+15,5
30,7 %
49,7 %
▼ -7,0
39,3 %
▼ -14,0
Part Bac+2 et plus (%) 26,2 % ▲ +5,6
−21,0
47,2 %
22,5 %
▲ +3,3
33,5 %
▲ +8,9
Part Bac+5 et plus (%) 5,8 % n.c.
−12,3
18,2 %
5,1 %
7,3 %
Structure de l'emploi
Part en CDD (%) 4,8 % n.c.
−2,8
7,6 %
5,5 %
3,6 %
Part à temps partiel (%) 9,5 % ▼ -0,2
−8,1
17,6 %
10,5 %
7,4 %
Part en sous-emploi (%) 2,7 % ▼ -0,4
−1,6
4,3 %
2,6 %
▼ -0,9
2,9 %
▲ +0,7

Lecture : la colonne cœur (bleutée) donne la valeur du secteur ; la barre Écart mesure l’écart vs France (rose = au-dessus, bleu = en deçà, longueur = intensité) ; les colonnes de droite détaillent les sous-secteurs et la référence France. Effectifs et profil : mesurés sur l’EEC au niveau A88 (Transport terrestre + Entreposage) — à distinguer du volume URSSAF du flux détaillé présenté plus haut, qui peut couvrir des sous-classes d’autres divisions. Part d’emploi public : salariés sous statut public (fonction publique d’État, territoriale, hospitalière). Source : INSEE EEC 2024 pondérée EXTRIAN, actifs occupés.

Sur la démographie et la qualification, l’écart est massif et défavorable. La part des femmes tombe à 21 % contre 49 % dans le salariat français — vingt-sept points, l’un des écarts les plus larges de l’économie. Il tient à la structure des métiers : conduite, manutention, préparation de commande, restés très masculins malgré les campagnes de recrutement. La part de diplômés du supérieur est inférieure de quinze points à la moyenne, et plus d’un tiers des salariés ont 50 ans et plus. La combinaison est problématique : un vivier de recrutement étroit, sur des métiers peu attractifs, face à une vague de départs en retraite déjà engagée.

Sur les conditions d’emploi, en revanche, le secteur fait mieux que la moyenne. Le temps partiel n’y concerne que 9,5 % des salariés contre 17,6 % au national, les contrats à durée déterminée 4,8 % contre 7,6 %, et le sous-emploi reste marginal à 2,7 %. La logistique offre des emplois à temps complet, durables, accessibles sans diplôme — un profil devenu rare. L’ancienneté, elle, ne se distingue plus : 57 % des salariés ont plus de cinq ans de maison, exactement la moyenne nationale.

Une mesure approchée. Ce profil est mesuré sur les divisions entières 49 et 52, seul niveau auquel l’enquête Emploi descend. Il inclut donc des activités hors périmètre — transport de voyageurs, services auxiliaires — soit environ 40 % des effectifs comptés ici. Deux conséquences à garder en tête : l’emploi public y ressort à 18 %, cinq points sous la moyenne nationale, mais ce chiffre reflète surtout la présence d’opérateurs publics de transport de voyageurs, pas une logistique publique. Et la féminisation réelle du secteur est vraisemblablement un peu plus élevée que les 21 % affichés, le transport de voyageurs tirant la mesure vers le bas.

Voir le code
# Liste codes ZE du territoire focus via TDC (réutilisé par srpt-07) + shapes ZE/région
ze_terr_focus <- tdc |>
  filter(DEP %in% params$territoire_codes) |>
  pull(ZE2020) |> unique() |> sort()

shp_ze <- sf::st_read(SHP_ZE_FILE, quiet = TRUE)
code_col <- intersect(c("code_insee", "code", "ZE2020", "ze2020"), names(shp_ze))[1]
shp_ze$code_ze <- sprintf("%04s", as.character(shp_ze[[code_col]]))
lib_col <- intersect(c("libze2020", "nom_officiel", "lib", "libelle"), names(shp_ze))[1]
shp_ze$lib_ze <- if (!is.na(lib_col)) shp_ze[[lib_col]] else shp_ze$code_ze
ze_terr_focus <- intersect(ze_terr_focus, shp_ze$code_ze)

shp_reg_path <- file.path(GEO, "nodom_region_2025.geojson")
shp_reg <- if (file.exists(shp_reg_path)) sf::st_read(shp_reg_path, quiet = TRUE) else NULL

Géographie nationale : où le secteur se concentre-t-il en France ?

L’implantation logistique épouse les grands corridors d’échange (vallée de la Seine, dorsale Lille–Paris–Lyon–Marseille) et les portes portuaires, où se massent les plateformes de distribution.

Voir le code
# Agrégation sectorielle par maille via sector_geo_agg (helper canonique).
#   IS statique (lq, centré 1 = × moy. nat.)   ← source pilotée par geo_source : "urssaf" = privé au
#     périmètre APE exact (défaut logistique), "flores" = emploi total mais divisions A88 entières
#   Dynamique compétitive (lq_dyn_long, c.100)   ← URSSAF privé (shift-share, ref ann_ref)
#   Croissance (tcam_long / tcam_court)          ← URSSAF privé
.yrs    <- c(base_long = 2019, base_court = 2023, ref = ann_ref)
.fl_ref <- max(dat$flores_com$year, na.rm = TRUE)        # FLORES s'arrête à 2024
.yrs_fl <- c(base_long = 2019, base_court = 2023, ref = .fl_ref)

# URSSAF privé : dynamique compétitive + croissance (dep + ze)
# ⚠️ PÉRIMÈTRE EXACT. `proxy_a88` est l'approximation par divisions entières, imposée par FLORES qui
# ne descend pas sous l'A88. URSSAF, lui, a le détail APE : ces cartes n'ont donc AUCUN besoin de
# l'approximation — elles l'héritaient de l'ancien alias `coeur`. Sur la logistique, cela les
# restreignait aux divisions 52+53, soit 40 % du secteur, le fret routier (60 %) exclu des cartes.
# `dat$urssaf_ape` est filtré aux codes du secteur → il n'a pas de dénominateur ; d'où `dat_sect` :
# numérateur au périmètre fin, total sur l'univers `dat$urssaf`. Même source des deux côtés. 260821
.ape_geo <- if (isTRUE(has_ape_curated)) dat$urssaf_ape else NULL
ze_tab  <- sector_geo_agg(dat$urssaf, proxy_a88, geo_col = "code_ze", years = .yrs,
                          dat_sect = .ape_geo)
dep_tab <- sector_geo_agg(dat$urssaf, proxy_a88, geo_col = "DEP", years = .yrs,
                          tdc = tdc, tdc_key = "CODGEO", dat_sect = .ape_geo)
# FLORES total : indice de spécialisation (dep + ze)
ze_tab_fl  <- sector_geo_agg(dat$flores_ze,  proxy_a88, geo_col = "code_ze", years = .yrs_fl)
dep_tab_fl <- sector_geo_agg(dat$flores_com, proxy_a88, geo_col = "DEP", years = .yrs_fl,
                             tdc = tdc, tdc_key = "CODGEO")

dep_data    <- shp_dep |> left_join(dep_tab,    by = c("code_dep" = "geo"))
ze_data     <- shp_ze  |> left_join(ze_tab,     by = c("code_ze"  = "geo"))
dep_data_fl <- shp_dep |> left_join(dep_tab_fl, by = c("code_dep" = "geo"))
ze_data_fl  <- shp_ze  |> left_join(ze_tab_fl,  by = c("code_ze"  = "geo"))

# Parts nationales de référence (indice 100) : URSSAF privé + FLORES total
# part_nat suit le MÊME périmètre que les cartes URSSAF ci-dessus (exact si dispo). Auparavant il
# était calculé sur `proxy_a88` (A88) : la fiche affichait 3,7 % au portrait et 2,9 % ici, sans un mot.
part_nat    <- if (isTRUE(has_ape_curated)) {
  with(dplyr::filter(dat$urssaf_ape, year == ann_ref), sum(value, na.rm = TRUE)) /
    with(dplyr::filter(dat$urssaf, year == ann_ref), sum(value, na.rm = TRUE)) * 100
} else {
  with(dplyr::filter(dat$urssaf, year == ann_ref),
       sum(value[code_a88 %in% proxy_a88], na.rm = TRUE) / sum(value, na.rm = TRUE) * 100)
}
part_nat_fl <- with(dplyr::filter(dat$flores_com, year == .fl_ref),
                    sum(value[code_a88 %in% proxy_a88], na.rm = TRUE) / sum(value, na.rm = TRUE) * 100)

# AIGUILLAGE de la SPÉCIALISATION (geo_source, déclaré par filière — cf _setup-common.R) :
#   "flores" → emploi TOTAL (privé + public), mais périmètre A88 : garde le public (indispensable
#              aux secteurs comme le SAP), au prix de divisions entières donc d'un périmètre approché.
#   "urssaf" → emploi PRIVÉ seul, périmètre APE EXACT : le secteur est décrit précisément, mais un
#              territoire où l'activité est publique disparaît de la carte.
# Le compromis se joue par filière, jamais globalement. Un seul jeu de variables en aval → les
# cartes, les bins et la prose suivent automatiquement.
.is_dep  <- if (geo_source == "urssaf") dep_data else dep_data_fl
.is_ze   <- if (geo_source == "urssaf") ze_data  else ze_data_fl
.is_pnat <- if (geo_source == "urssaf") part_nat else part_nat_fl
.is_emp  <- if (geo_source == "urssaf") "emploi privé" else "emploi total"
.is_src  <- if (geo_source == "urssaf")
  sprintf("URSSAF-ACOSS · emploi salarié privé · périmètre %s", if (isTRUE(has_ape_curated)) "APE exact" else "A88") else
  sprintf("INSEE FLORES · emploi total (privé + public) · divisions A88 %s", paste(proxy_a88, collapse = "/"))
# Phrase de justification : dire ce qu'on gagne ET ce qu'on perd avec la source retenue. Le lecteur
# doit savoir qu'un territoire peut manquer à l'appel (emploi public en URSSAF) ou que le périmètre
# est approché (divisions entières en FLORES) — les deux se voient sur la carte sans s'expliquer.
.is_why <- if (geo_source == "urssaf") glue::glue(
  "La spécialisation se mesure ici sur l'**emploi privé** (URSSAF), au **périmètre exact** du secteur ",
  "— au prix des emplois publics, hors champ : un territoire où l'activité est portée par le public ",
  "n'y apparaît pas.") else glue::glue(
  "La spécialisation se mesure ici sur l'**emploi total** (FLORES, privé + public), pour intégrer le ",
  "poids du secteur public — au prix d'un périmètre approché par **divisions entières** ",
  "({paste(proxy_a88, collapse = '/')}), plus large que le cœur du secteur.")

# Bins partagés dep ↔ ze par indicateur → légende identique gauche/droite (comparabilité)
# center = 1 : en « × moy. nat. » la France EST la valeur 1, donc le repère est dans l'échelle et
# aucun marqueur de légende n'est nécessaire. Asymétrique : l'IS est log-normal, les classes
# miroir d'un divergent symétrique laissaient des bins vides côté bas. 260828
# BINS DISSOCIÉS département / zone d'emploi (260828). Les partager forçait un compromis : l'étendue
# des ZE (0 à 3,7) écrasait celle des départements (0,2 à 2,0), qui n'utilisaient plus que la moitié
# des classes — 144 territoires tombaient dans un seul bin « 1 à 2,5 ». Or personne ne compare un
# département à une zone d'emploi : les deux cartes répondent à la même question à deux grains, elles
# n'ont pas à partager une échelle. Chacune reprend donc la sienne, sur 8 classes.
# min_eff : sous ce seuil d'emploi sectoriel, le ratio est du bruit -> NA -> hachures (cf cartes de
# synthèse, qui appliquaient déjà cette règle alors que le corps ne la posait nulle part).
.MIN_EFF_IS <- 200
.is_dep$lq[!is.na(.is_dep$eff_coeur) & .is_dep$eff_coeur < .MIN_EFF_IS] <- NA_real_
.is_ze$lq[ !is.na(.is_ze$eff_coeur)  & .is_ze$eff_coeur  < .MIN_EFF_IS] <- NA_real_
.bins_is_dep <- make_bins_divergent_asym(.is_dep$lq[is.finite(.is_dep$lq)],
                                         center = 1, n_total = 8, palette = pal_div_bb_9)
.bins_is_ze  <- make_bins_divergent_asym(.is_ze$lq[is.finite(.is_ze$lq)],
                                         center = 1, n_total = 8, palette = pal_div_bb_9)
.bins_is     <- .bins_is_ze   # rétrocompat pour les consommateurs restants
.bins_dyn  <- make_bins_divergent(c(dep_data$lq_dyn_long,    ze_data$lq_dyn_long),    center = 100, palette = pal_div_bb_7)
.bins_tcl  <- auto_bins(c(dep_data$tcam_long,  ze_data$tcam_long)[is.finite(c(dep_data$tcam_long,  ze_data$tcam_long))],  var_name = "tcam_long")
.bins_tcc  <- auto_bins(c(dep_data$tcam_court, ze_data$tcam_court)[is.finite(c(dep_data$tcam_court, ze_data$tcam_court))], var_name = "tcam_court")

# Facteur de sur/sous-specialisation, MULTIPLICATIF (l IS est log-normal, pas additif) : x1,5 d un
# cote, /1,5 de l autre. Defini ICI car consomme des le comptage des ZE, et reutilise plus bas pour
# les departements — il etait declare apres son premier usage. 260828
.fac_is <- 1.5
.dep_map <- dep_data; .ze_map <- ze_data
for (.v in c("tcam_long", "tcam_court")) {
  .dep_map[[.v]][is.na(.dep_map$eff_coeur) | .dep_map$eff_coeur < .MIN_EFF_IS] <- NA_real_
  .ze_map[[.v]][ is.na(.ze_map$eff_coeur)  | .ze_map$eff_coeur  < .MIN_EFF_IS] <- NA_real_
}

.zd <- .is_ze |> sf::st_drop_geometry() |> filter(!is.na(lq))
n_ze_total <- nrow(.zd)
# Seuils en MULTIPLES (la variable est passée de l'indice base 100 au ratio) et MULTIPLICATIVEMENT
# symétriques, comme pour les départements : ×1,5 au-dessus, ÷1,5 en dessous. Les anciens 110/90
# comparaient des indices à un ratio — d'où « 0 ZE au-dessus, 267 en dessous », c'est-à-dire toutes.
n_ze_sur   <- sum(.zd$lq > .fac_is, na.rm = TRUE)
n_ze_sous  <- sum(.zd$lq < 1 / .fac_is, na.rm = TRUE)
log_result("cartes_quantiles", list(part_nat = part_nat, part_nat_fl = part_nat_fl,
                                     n_sur = n_ze_sur, n_sous = n_ze_sous,
                                     perim_urssaf = if (isTRUE(has_ape_curated)) "APE exact" else "A88",
                                     perim_flores = "A88",
                                     # Source EFFECTIVE de la spécialisation + sa référence nationale :
                                     # sans ça, impossible de savoir en relisant le JSON à quelle base
                                     # se rapportent n_sur/n_sous — donc impossible de rédiger juste.
                                     geo_source = geo_source, is_part_nat = .is_pnat),
           interpret = sprintf("Spécialisation via %s : référence nationale %.2f %% · %d ZE au-dessus de x%.1f, %d en dessous de /%.1f",
                               toupper(geo_source), .is_pnat, n_ze_sur, .fac_is, n_ze_sous, .fac_is))

# Phrase de référence : DIRE que les deux parts ne portent pas sur le même périmètre quand c'est le
# cas. Depuis que part_nat est au périmètre exact, l'emploi « total » FLORES peut afficher une part
# PLUS FAIBLE que le privé — arithmétiquement impossible à périmètre égal, donc lu comme une erreur
# si on n'explicite pas que les périmètres diffèrent. 260821
# ⚠️ BRANCHER SUR `geo_source`, PAS SUR `has_ape_curated`. Cette phrase annonçait une lecture FLORES
# au périmètre A88 quelle que soit la source réellement utilisée par les cartes — elle est donc
# devenue FAUSSE le jour où geo_source est passé à "urssaf" : le texte décrivait un emploi total
# public compris, sous des cartes d'emploi privé au périmètre exact. Une phrase de cadrage doit dire
# ce que les cartes FONT, pas ce qu'elles faisaient. 260828
.ref_txt <- if (geo_source == "urssaf") glue::glue(
  "pèse **{fmt_pct_1dec(part_nat)}** de l'emploi privé, au **périmètre exact** du secteur ",
  "(URSSAF, sous-classes APE). C'est la référence à laquelle chaque territoire se compare : un ",
  "multiple de **1** signifie « au niveau de la moyenne nationale », **2** « deux fois plus »"
) else glue::glue(
  "pèse **{fmt_pct_1dec(part_nat_fl)}** de l'emploi **total** (FLORES, privé et public), mesuré sur les ",
  "**divisions entières {paste(proxy_a88, collapse = '/')}** — seul niveau auquel cette source descend. ",
  "Un multiple de **1** signifie « au niveau de la moyenne nationale », **2** « deux fois plus ». À ne pas ",
  "confondre avec les **{fmt_pct_1dec(part_nat)}** du périmètre exact retenu pour les volumes : champs différents")

Le secteur de la logistique pèse 3,7 % de l’emploi privé, au périmètre exact du secteur (URSSAF, sous-classes APE). C’est la référence à laquelle chaque territoire se compare : un multiple de 1 signifie « au niveau de la moyenne nationale », 2 « deux fois plus ». La séquence pose d’abord quel visage le secteur prend localement, puis mesure son intensité en double maille — départements à gauche, zones d’emploi à droite : où il pèse (spécialisation), où il gagne des parts (dynamique compétitive), puis sa croissance sur deux horizons.

Visage local : quelle activité du secteur domine où ?

Source : URSSAF-ACOSS · emploi salarié privé · activité la plus sur-représentée localement (au moins 1,25 fois sa part nationale) · gris = aucune sur-représentation · hachures = moins de 200 salariés du secteur

126 zones d’emploi portent la marque transport terrestre, 48 celle de entreposage — 101 suivent simplement le mix national

Une même étiquette sectorielle recouvre des métiers différents selon les territoires. La carte retient, pour chaque zone d’emploi, l’activité la plus sur-représentée par rapport au mix national — et non la plus grosse en volume, qui serait partout la même. Le gris signale les territoires sans signature : leur composition suit le mix national. Les hachures disent autre chose — trop peu d’emplois du secteur pour qu’une dominante ait un sens.

Spécialisation : quels territoires concentrent le secteur ?

Voir le code
.dd  <- .is_dep |> sf::st_drop_geometry() |> filter(!is.na(lq))
.top <- .dd |> slice_max(lq, n = 3); .bot <- .dd |> slice_min(lq, n = 3)
# Seuils MULTIPLICATIVEMENT symétriques (l'IS est log-normal, pas additif) : ×1,5 = 150 (sur-spé),
# ÷1,5 = 67 (sous-doté). Le "> 100" simple est biaisé (100 = moyenne PONDÉRÉE par l'emploi → la
# médiane des territoires est mécaniquement sous 100 vu la concentration). On cite donc la médiane.
n_dep_sur  <- sum(.dd$lq > .fac_is)
n_dep_sous <- sum(.dd$lq < 1 / .fac_is)
.med_is    <- round(median(.dd$lq, na.rm = TRUE), 2)
log_result("cartes_is", list(n_dep_sur = n_dep_sur, n_dep_sous = n_dep_sous, med_is = .med_is,
                             dep_top = .top$lib_dep[1], dep_top_ind = round(.top$lq[1], 2)))
# Prose géo fluide (jcn-prose) — SANS comptage >100/<100 (biaisé pour un ratio) : on nomme les extrêmes.
.fmt_is <- function(x) paste0(formatC(x, format = "f", digits = 1, decimal.mark = ","), " ×")
.dd$lib_art <- vapply(.dd$lib_dep, fr_terr_dep, character(1))
dep_fluid <- geo_distrib_phrase(
  geo_distrib(.dd, "lq", "lib_art", n = 3, pivot = 1),
  terr_lbl = "départements", fmt = .fmt_is,
  style = "fluid", lead_top = "les plus spécialisés sont", lead_bot = "les moins dotés",
  show_count = FALSE, show_outliers = FALSE)
ze_fluid <- geo_distrib_phrase(
  geo_distrib(.zd, "lq", "lib_ze", n = 3, pivot = 1),
  terr_lbl = "zones d'emploi", fmt = .fmt_is,
  style = "fluid", lead_top = "les plus spécialisées sont", lead_bot = "les moins dotées",
  show_count = FALSE, show_outliers = FALSE)
cat(prose_cc_pre("nat_specialisation", function() glue::glue(
  "[Une géographie très concentrée : **{n_dep_sur} départements** dépassent **1,5× la moyenne** nationale ",
  "(indice&nbsp;>&nbsp;150), la médiane départementale restant à **{.med_is}**]{{.insight}}\n\n",
  "{.is_why} ",
  "L'indice rapporte la part locale du secteur à sa part nationale (**100&nbsp;= moyenne**, pondérée par l'emploi) ; ",
  "quelques pôles concentrant l'activité, la majorité des territoires se situe sous cette moyenne. ",
  "{dep_fluid}\n\n",
  "{ze_fluid} L'indice est inscrit sur chaque département *(carte de gauche)* ; la maille zone d'emploi *(droite)* affine la lecture.\n"
)))

Le Havre consacre 13 % de son emploi privé à la logistique, Paris 0,7 % — le secteur a quitté la ville

Les deux cartes rapportent le poids local du secteur à son poids national (3,7 % de l’emploi privé). Un territoire à 1,0 a exactement le profil français ; à 2,0 il consacre deux fois plus d’emplois que la moyenne à la logistique. Le seuil retenu ici est 1,5 fois dans un sens, 1,5 fois moins dans l’autre — un écart multiplicatif, car l’indice se lit en proportions, pas en points. Les territoires de moins de 200 salariés du secteur apparaissent hachurés : leur indice existe mais ne mesure rien de stable.

Voir le code
# bins passé EXPLICITEMENT à psect_inset : `+ th_map_sm` a déjà détaché attr(p, "bins").
.p_is_dep <- build_map(.is_dep, "lq", bins = .bins_is_dep, interactive = TRUE, show_tooltip = TRUE,
    titre = "Indice de spécialisation sectoriel — départements", source_txt = NULL,
    label_col = "lib_dep", id_col = "code_dep",
    n_top = 4, n_bottom = 4, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.2,
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "× moy. nat.") + th_map_sm
render_girafe(
  psect_inset(.p_is_dep, .is_dep, "lq", bins = .bins_is_dep, maille = "dep"),
  width_svg = 4.5, height_svg = 4.95)
Voir le code
.p_is_ze <- build_map(.is_ze, "lq", bins = .bins_is_ze, interactive = TRUE, show_tooltip = TRUE,
    titre = "Indice de spécialisation sectoriel — zones d'emploi", source_txt = NULL,
    label_col = "lib_ze", id_col = "code_ze",
    n_top = 4, n_bottom = 4, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.15,
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "× moy. nat.") + th_map_sm
render_girafe(
  psect_inset(.p_is_ze, .is_ze, "lq", bins = .bins_is_ze, maille = "ze"),
  width_svg = 4.5, height_svg = 4.95)

Source : URSSAF-ACOSS · emploi salarié privé · périmètre APE exact · Logistique · spécialisation 2025, en multiple de la moyenne nationale (France = 1)

Le territoire français typique n’est ni spécialisé ni démuni : la médiane départementale s’établit à 1,0. C’est la signature d’une activité diffuse — le fret routier dessert tout le territoire — sur laquelle se détachent quelques pôles très marqués. Onze départements dépassent 1,5 fois la moyenne, seize restent sous 0,67 — soit une fois et demie moins (figure ci-dessous).

À la maille départementale, la couronne francilienne domine. Le Loiret vient en tête (2,0 fois, soit 7,2 % de son emploi privé), devant l’Oise, l’Essonne et le Val-d’Oise, tous entre 1,8 et 1,9. C’est là que se sont installées les plateformes qui approvisionnent le premier bassin de consommation du pays. Le contraste le plus parlant se joue à l’intérieur même de cette métropole : Paris tombe à 0,2 fois la moyenne — 0,7 % de son emploi privé, le niveau le plus bas de France — et les Hauts-de-Seine à 0,3, près de dix fois moins que leurs voisins de grande couronne. Un entrepôt réclame des hectares et un accès poids lourds ; ni l’un ni l’autre n’existe dans le tissu dense. La logistique a été repoussée à trente ou cinquante kilomètres, exactement là où elle sert la même clientèle.

La maille fine fait apparaître des spécialisations bien plus fortes, que la moyenne départementale dilue. Le Havre atteint 3,7 fois la moyenne nationale, c’est-à-dire 13,4 % de son emploi privé — un salarié sur sept — pour 13 072 postes : la logistique portuaire y structure le bassin. Roissy suit à 2,8 fois (10,2 % de l’emploi local), mais avec 33 918 salariés, soit le premier volume du pays. Vire Normandie (3,0 fois, 10,8 %) et Soissons (2,9 fois, 10,5 %) affichent des indices comparables sur des bases dix à trente fois plus petites.

Cette différence mérite d’être retenue : l’indice dit la dépendance du territoire, pas son poids dans le secteur. Un bassin très spécialisé sur 1 200 emplois est vulnérable à la fermeture d’un site unique ; Roissy, à indice voisin, pèse trente fois plus dans le total national. Les deux lectures sont utiles, elles ne répondent pas à la même question.

Le Tarn-et-Garonne (1,8) illustre enfin l’autre logique d’implantation : ni port, ni métropole, mais la position sur l’axe Toulouse-Bordeaux. Rien de tout cela ne doit à la démographie locale — la logistique s’installe où le foncier est disponible et l’accès rapide, non où résident les actifs.

Reste à décliner cet indice métier par métier : les grandes familles du secteur dessinent-elles la même carte ?

Les pôles les plus spécialisés combinent façade portuaire (Le Havre, en Seine-Maritime), fret aérien (zone de Roissy) et grandes plateformes de distribution de l’axe Paris–Nord (Douai et le bassin minier) ainsi que de l’axe rhodanien (Bourgoin-Jallieu). À l’opposé, les territoires les moins dotés sont les espaces ruraux et industriels anciens, à l’écart des grands corridors d’échange.

Focus : où se concentre chaque grand métier du secteur ?

La carte du visage local ne retient qu’un métier par territoire, et l’indice d’ensemble les mélange tous. Ce focus les combine : une famille à la fois, partout, avec le même indice — 1 = le mix national, 2 = deux fois plus présent qu’ailleurs. Un bassin bien doté en entreposage mais encore plus atypique sur transport terrestre n’apparaît qu’ici. La seconde rangée ajoute la croissance de chaque famille depuis 2019, aux mêmes couleurs que les cartes de croissance d’ensemble.

Le périmètre reste le cœur du secteur. Les familles ne font que regrouper ses sous-classes par grande branche : une famille peut donc contenir plusieurs segments du graphique de composition, et ses parts sont mécaniquement plus larges — sans changement de périmètre. Seules les familles pesant au moins 10 % du secteur ont leurs cartes.

Transport terrestre — 60,2 % du secteur. Bassins les plus dotés : Vire Normandie (4,9 fois le mix national, 1 208 sal.), Coulommiers (4,1).

Entreposage — 38,3 % du secteur. Bassins les plus dotés : Le Havre (7,7 fois le mix national, 10 460 sal.), Douai (5,5).

Source : URSSAF-ACOSS · emploi salarié privé · indice = part de la famille dans l’emploi privé local rapportée à sa part nationale (1 = mix national) · hachures = moins de 200 salariés de la famille

Source : URSSAF-ACOSS · emploi salarié privé · TCAM 2019-2025 de la famille · hachures = moins de 200 salariés de la famille

Croissance de l’emploi : tendance longue et dynamique récente

Voir le code
.zt <- ze_data |> sf::st_drop_geometry() |> filter(!is.na(tcam_court))
# ⚠️ MOYENNE NON PONDÉRÉE des TCAM de ZE (chaque bassin compte pour 1) — ce N'EST PAS le TCAM
# national, qui est agrégé et donc dominé par les gros bassins. Les deux peuvent être de SIGNES
# OPPOSÉS (logistique : ZE moyenne -1,2 %/an vs national +2,0 %/an) : une majorité de bassins recule
# pendant qu'une poignée de plateformes capte les créations. Nommer explicitement pour ne plus
# confondre — l'ancien `fr_tcam_*` était présenté comme « à l'échelle nationale ». 260819
ze_tcam_moy_court <- mean(.zt$tcam_court, na.rm = TRUE)
ze_tcam_moy_long  <- mean(.zt$tcam_long,  na.rm = TRUE)

# Concentration des créations d'emploi (courbe de Lorenz) : créations nettes du secteur par ZE,
# sur les 2 horizons. Périmètre EXACT (APE) si dispo — même correction que les cartes ci-dessus,
# sinon ce comptage aurait porté sur 40 % du secteur pendant que les cartes en montrent 100 %.
# `code_ze` est natif dans les DEUX cadres URSSAF : la jointure commune→ZE via TDC était superflue.
.ze_src <- if (isTRUE(has_ape_curated)) dat$urssaf_ape else dplyr::filter(dat$urssaf, code_a88 %in% proxy_a88)
.ze_e  <- .ze_src |> dplyr::filter(measure == "EFF_SAL", year %in% c(2019L, p_evol[1], ann_ref),
                                   !is.na(code_ze)) |>
  dplyr::group_by(ZE2020 = code_ze, year) |> dplyr::summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  tidyr::pivot_wider(names_from = year, values_from = emp, names_prefix = "y")
.ze_e$g_long  <- .ze_e[[paste0("y", ann_ref)]] - .ze_e[[paste0("y", 2019L)]]
.ze_e$g_court <- .ze_e[[paste0("y", ann_ref)]] - .ze_e[[paste0("y", p_evol[1])]]
# .conc : combien de ZE (les plus créatrices) totalisent 50 % des emplois créés + parts hausse/baisse
.conc <- function(g) {
  g <- g[is.finite(g)]; gp <- sort(g[g > 0], decreasing = TRUE)
  k50 <- if (length(gp)) which(cumsum(gp) / sum(gp) >= 0.5)[1] else NA_integer_
  list(n_tot = length(g), n_up = sum(g > 0), n_dn = sum(g < 0),
       k50 = k50, pct50 = if (is.na(k50)) NA else k50 / length(g) * 100)
}
.cl <- .conc(.ze_e$g_long); .cc <- .conc(.ze_e$g_court)
log_result("cartes_tcam", list(nat_long = tcam_lng, nat_court = tcam_us,
  ze_moy_long = ze_tcam_moy_long, ze_moy_court = ze_tcam_moy_court,
  conc_long_k50 = .cl$k50, conc_long_pct50 = .cl$pct50, n_up_long = .cl$n_up, n_dn_long = .cl$n_dn,
  conc_court_k50 = .cc$k50, conc_court_pct50 = .cc$pct50, n_up_court = .cc$n_up))

.conc_dir <- dplyr::case_when(
  is.na(.cc$pct50) || is.na(.cl$pct50) ~ "se maintient",
  .cc$pct50 < .cl$pct50 - 1 ~ "se renforce encore",
  .cc$pct50 > .cl$pct50 + 1 ~ "s'atténue légèrement",
  TRUE                      ~ "reste comparable")
# ZE qui concentrent la moitié des créations (nommées) + top/bottom croissance fluide
.zelib <- stats::setNames(as.character(ze_data$lib_ze), as.character(as.integer(ze_data$code_ze)))
.ze_e$lib <- unname(.zelib[as.character(as.integer(.ze_e$ZE2020))])
.top_cr <- .ze_e[is.finite(.ze_e$g_long) & .ze_e$g_long > 0, ]
.top_cr <- .top_cr[order(-.top_cr$g_long), ][seq_len(min(3, nrow(.top_cr))), ]
.cr_nm  <- paste(sprintf("**%s**", .top_cr$lib), collapse = ", ")
.fmt_pa <- function(x) paste0(fmt_sign_1dec(x), " %/an")
.dd_gr <- dep_data |> sf::st_drop_geometry() |> dplyr::filter(!is.na(tcam_long))
.dd_gr$lib_art <- vapply(.dd_gr$lib_dep, fr_terr_dep, character(1))
.zt_gr <- ze_data |> sf::st_drop_geometry() |> dplyr::filter(!is.na(tcam_long))
dep_gr_fluid <- geo_distrib_phrase(geo_distrib(.dd_gr, "tcam_long", "lib_art", n = 4, pivot = 0),
  terr_lbl = "départements", fmt = .fmt_pa, style = "fluid",
  lead_top = "les plus dynamiques sont", lead_bot = "les plus en recul", show_count = FALSE, show_outliers = FALSE)
ze_gr_fluid <- geo_distrib_phrase(geo_distrib(.zt_gr, "tcam_long", "lib_ze", n = 4, pivot = 0),
  terr_lbl = "zones d'emploi", fmt = .fmt_pa, style = "fluid",
  lead_top = "les plus dynamiques sont", lead_bot = "les plus en recul", show_count = FALSE, show_outliers = FALSE)
cat(prose_cc_pre("nat_croissance", function() glue::glue(
  "[**{.cl$k50} zones d'emploi** — soit **{fmt_pct_1dec(.cl$pct50)} des {.cl$n_tot}** — concentrent à elles seules ",
  "**la moitié des emplois créés** par le secteur sur 2019-{ann_ref}]{{.insight}}\n\n",
  "La **croissance annualisée** brute (TCAM, URSSAF privé) se lit sur deux horizons : la **tendance longue 2019-{ann_ref}** ",
  "puis la **dynamique récente {p_evol[1]}-{ann_ref}**. Agrégé à l'échelle nationale, le secteur progresse de ",
  "**{fmt_sign_1dec(tcam_lng)} % par an** sur la période longue, puis de **{fmt_sign_1dec(tcam_us)} % par an** récemment. ",
  "La **zone d'emploi moyenne**, où chaque bassin compte pour un, évolue elle de **{fmt_sign_1dec(ze_tcam_moy_long)} % par an** : ",
  "l'écart entre les deux mesure à lui seul le degré de **polarisation** du secteur.\n\n",
  "Mais cette croissance est **très inégalement répartie**. Sur 2019-{ann_ref}, **{.cl$n_up} zones d'emploi** gagnent des emplois ",
  "quand **{.cl$n_dn}** en perdent ; les **{.cl$k50} plus créatrices** ({fmt_pct_1dec(.cl$pct50)} des bassins) suffisent à totaliser ",
  "la moitié des créations nettes. Sur la période récente {p_evol[1]}-{ann_ref}, la concentration **{.conc_dir}** ",
  "(**{.cc$k50} zones d'emploi**, {fmt_pct_1dec(.cc$pct50)} des bassins, pour la moitié des gains) : les créations se polarisent sur quelques pôles, menées par {.cr_nm}.\n\n",
  "En rythme, {dep_gr_fluid}\n\n",
  "{ze_gr_fluid}\n"
)))

Quatre départements sur cinq gagnent des emplois logistiques — mais quinze bassins en concentrent la moitié

La croissance du secteur est largement partagée, et c’est ce qui le distingue. 81 départements sur 96 en gagnent, et 221 zones d’emploi sur 304. Aux deux mailles, le territoire médian progresse de 1,4 % par an.

Voir le code
render_girafe(
  build_map(.dep_map, "tcam_long", bins = .bins_tcl, interactive = TRUE, show_tooltip = TRUE,
    titre = sprintf("Croissance %d-%d — départements", 2019L, ann_ref), source_txt = NULL, label_col = "lib_dep", id_col = "code_dep",
    n_top = 4, n_bottom = 4, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.2,
    # Hachures : un territoire sous le seuil de fiabilite doit se VOIR comme tel, pas sortir
    # en blanc muet — indistinct d'une absence de donnee. Meme convention que les autres
    # cartes de la fiche, qui hachuraient deja. 260828
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "%/an") + th_map_sm,
  width_svg = 4.5, height_svg = 4.95)
Voir le code
render_girafe(
  build_map(.ze_map, "tcam_long", bins = .bins_tcl, interactive = TRUE, show_tooltip = TRUE,
    titre = sprintf("Croissance %d-%d — zones d'emploi", 2019L, ann_ref), source_txt = NULL, label_col = "lib_ze", id_col = "code_ze",
    n_top = 4, n_bottom = 4, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.15,
    # Hachures : un territoire sous le seuil de fiabilite doit se VOIR comme tel, pas sortir
    # en blanc muet — indistinct d'une absence de donnee. Meme convention que les autres
    # cartes de la fiche, qui hachuraient deja. 260828
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "%/an") + th_map_sm,
  width_svg = 4.5, height_svg = 4.95)

Source : URSSAF-ACOSS · privé salarié · TCAM 2019-2025 · Logistique

Voir le code
render_girafe(
  build_map(.dep_map, "tcam_court", bins = .bins_tcc, interactive = TRUE, show_tooltip = TRUE,
    titre = sprintf("Croissance %d-%d — départements", 2023L, ann_ref), source_txt = NULL, label_col = "lib_dep", id_col = "code_dep",
    n_top = 4, n_bottom = 4, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.2,
    # Hachures : un territoire sous le seuil de fiabilite doit se VOIR comme tel, pas sortir
    # en blanc muet — indistinct d'une absence de donnee. Meme convention que les autres
    # cartes de la fiche, qui hachuraient deja. 260828
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "%/an") + th_map_sm,
  width_svg = 4.5, height_svg = 4.95)
Voir le code
render_girafe(
  build_map(.ze_map, "tcam_court", bins = .bins_tcc, interactive = TRUE, show_tooltip = TRUE,
    titre = sprintf("Croissance %d-%d — zones d'emploi", 2023L, ann_ref), source_txt = NULL, label_col = "lib_ze", id_col = "code_ze",
    n_top = 4, n_bottom = 4, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.15,
    # Hachures : un territoire sous le seuil de fiabilite doit se VOIR comme tel, pas sortir
    # en blanc muet — indistinct d'une absence de donnee. Meme convention que les autres
    # cartes de la fiche, qui hachuraient deja. 260828
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "%/an") + th_map_sm,
  width_svg = 4.5, height_svg = 4.95)

Source : URSSAF-ACOSS · privé salarié · TCAM 2023-2025 · Logistique

Cette diffusion tient à la nature du fret routier : il dessert tout le territoire, donc il emploie partout. C’est la différence avec l’entreposage, dont l’implantation obéit à une logique de site et se concentre par nature.

La lecture départementale désigne les gagnants du redéploiement. La Moselle vient en tête (8,3 % par an), portée par la position du sillon lorrain sur l’axe nord-sud européen, devant l’Essonne (6,3 %), Paris (5,0 %) et le Loiret (4,7 %) — trois territoires de la sphère francilienne, où la desserte du premier bassin de consommation se réorganise. À l’opposé, l’Indre recule de 5,4 % par an, le Doubs de 2,0 % : des départements à faible base, dont les fonctions logistiques migrent vers des plateformes régionales plus vastes.

La maille fine révèle ce que le département lisse. Les zones d’emploi qui décollent — Rambouillet, Creil, Metz, Bollène-Pierrelatte, toutes au-delà de 11 % par an — sont des bassins de taille moyenne où l’ouverture d’une plateforme suffit à déplacer la courbe. Le mouvement est le même à l’autre bout : Guingamp perd 9 % par an et Saint-Dié-des-Vosges 7 %, sur des effectifs modestes.

Le volume, lui, reste concentré — et sans contradiction avec ce qui précède. Les quinze bassins les plus créateurs, soit 5 % du total, totalisent la moitié des créations nettes. Beaucoup de territoires gagnent un peu, quelques-uns gagnent beaucoup. Une plateforme moderne emploie plusieurs centaines de personnes sur un site unique, choisi pour son accès autoroutier et des dizaines d’hectares disponibles — critères que peu de territoires réunissent.

La croissance du secteur rejaillit donc sur la majorité des bassins, mais l’implantation d’une grande plateforme reste un événement rare, et disputé.

Accélération : où la dynamique change de régime ?

Voir le code
render_girafe(
  build_map(.zacc, "acc", bins = .bins_acc, interactive = TRUE, show_tooltip = TRUE,
    titre = "Qui accélère, qui ralentit — zones d'emploi",
    source_txt = NULL, label_col = "lib_ze", id_col = "code_ze",
    n_top = 3, n_bottom = 3, show_values = FALSE, label_position = "repel", label_style = "text",
    contour_geo = shp_reg, border_color = "#cccccc", border_lw = 0.15,
    na_color = "#ffffff", na_hatch = TRUE, na_hatch_col = "#c2c2c2", na_hatch_lwd = 0.35,
    legend_title = "pts de %/an") + th_map_sm,
  width_svg = 5, height_svg = 5.3, zoom = TRUE)

Source : URSSAF-ACOSS · privé salarié · écart entre le TCAM 2023-2025 et le TCAM 2019-2025, en points de %/an · hachures = moins de 200 salariés du secteur

Voir le code
cat(prose_cc_pre("nat_acceleration", function() glue::glue(
  "[{.n_acc_dn} zones d'emploi sur {nrow(.ad)} ralentissent — le territoire médian perd ",
  "{fmt_fr(abs(.med_acc), 1)} point de croissance annuelle]{{.insight}}

",
  "Les deux cartes précédentes donnent des niveaux de croissance ; celle-ci donne l'**écart entre ",
  "les deux fenêtres**, c'est-à-dire un changement de régime. La distinction compte : la position ",
  "d'un territoire ici ne se déduit pas de sa croissance longue.\n\n",
  # Les comptages et la médiane sont portés par l'insight — le corps donne le contrepoint (qui
  # accélère) et le mécanisme, jamais les mêmes chiffres deux fois.
  "**Le ralentissement est le cas général**, traduction locale du reflux national du secteur — mais ",
  "pas une fatalité : {.n_acc_up} bassins accélèrent au contraire, et ce sont eux que la carte fait ",
  "ressortir en foncé.\n\n",
  "**Les écarts extrêmes tiennent à des événements de site.** {.acc_top$lib_ze[1]} gagne ",
  "**{fmt_fr(.acc_top$acc[1], 1)} points** et {.acc_top$lib_ze[2]} {fmt_fr(.acc_top$acc[2], 1)} : ",
  "des bassins où une implantation récente pèse lourd dans un total modeste. À l'inverse, ",
  "{.acc_bot$lib_ze[1]} perd {fmt_fr(abs(.acc_bot$acc[1]), 1)} points. Sur des effectifs de cette ",
  "taille, l'ouverture ou la fermeture d'une seule plateforme déplace la trajectoire entière — ",
  "raison pour laquelle les territoires sous {.MIN_EFF_IS} salariés sont hachurés plutôt que colorés."
)))

171 zones d’emploi sur 267 ralentissent — le territoire médian perd 0,9 point de croissance annuelle

Les deux cartes précédentes donnent des niveaux de croissance ; celle-ci donne l’écart entre les deux fenêtres, c’est-à-dire un changement de régime. La distinction compte : la position d’un territoire ici ne se déduit pas de sa croissance longue.

Le ralentissement est le cas général, traduction locale du reflux national du secteur — mais pas une fatalité : 96 bassins accélèrent au contraire, et ce sont eux que la carte fait ressortir en foncé.

Les écarts extrêmes tiennent à des événements de site. Rambouillet gagne 15,2 points et Agde-Pézenas 13,3 : des bassins où une implantation récente pèse lourd dans un total modeste. À l’inverse, Guingamp perd 21,0 points. Sur des effectifs de cette taille, l’ouverture ou la fermeture d’une seule plateforme déplace la trajectoire entière — raison pour laquelle les territoires sous 200 salariés sont hachurés plutôt que colorés.

Récapitulatif : le secteur territoire par territoire

Les cartes montrent une dimension à la fois. Le tableau les réunit — poids, spécialisation, croissance longue, croissance récente et accélération — pour comparer les territoires sur tous les critères d’un coup. Il est triable et filtrable.

Voir le code
.rz <- .zacc |> sf::st_drop_geometry() |>
  dplyr::filter(!is.na(eff_coeur), eff_coeur >= .MIN_EFF_IS) |>
  dplyr::transmute(libelle = lib_ze, eff = round(eff_coeur), part = part,
                   lq = lq, tcam_long = tcam_long, tcam_court = tcam_court, acc = acc) |>
  dplyr::arrange(dplyr::desc(eff))
log_table_auto("recap_ze_table", .rz, label_col = "libelle")
rt_table(
  data = .rz,
  cols = list(
    libelle    = rt_col_lib(.rz, "libelle", "Zone d'emploi", indent = FALSE, mw = 150),
    eff        = rt_col_level(.rz, "eff", sprintf("Salariés %d", ann_ref), unit = "n", bw = 72, bh = 16, mw = 122, light = TRUE),
    part       = rt_col_level(.rz, "part", "Part de l'emploi privé", unit = "%", bw = 55, bh = 16, mw = 100),
    lq         = rt_col_level(.rz, "lq", "Spécialisation", unit = "x moy. nat.", bw = 65, bh = 16, mw = 108, ref = 1),
    tcam_long  = rt_col_var(.rz, "tcam_long", sprintf("TCAM %d-%d", 2019L, ann_ref), unit = "%/an", bw = 58, bh = 16, mw = 100),
    tcam_court = rt_col_var(.rz, "tcam_court", sprintf("TCAM %d-%d", 2023L, ann_ref), unit = "%/an", bw = 58, bh = 16, mw = 100),
    acc        = rt_col_var(.rz, "acc", "Accélération", unit = "pts", bw = 58, bh = 16, mw = 100)
  ),
  title = "Le secteur zone d'emploi par zone d'emploi — poids, spécialisation, dynamique",
  subtitle = sprintf("Zones d'emploi d'au moins %d salariés du secteur — Source : URSSAF-ACOSS, emploi salarié privé",
                     .MIN_EFF_IS),
  # rt_table expose page_size, PAS pagination (celui-ci appartient a reactable() brut) : passer
  # `pagination` levait "argument inutilise" et le tableau ne sortait pas. page_size au-dela du
  # nombre de lignes = tout en une page, le scroll etant assure par height. 260828
  searchable = TRUE, page_size = 400, height = 420)
Le secteur zone d'emploi par zone d'emploi — poids, spécialisation, dynamique
Zones d'emploi d'au moins 200 salariés du secteur — Source : URSSAF-ACOSS, emploi salarié privé
⇅ triable
Voir le code
.rd <- dep_data |> sf::st_drop_geometry() |>
  dplyr::mutate(acc = tcam_court - tcam_long) |>
  dplyr::filter(!is.na(eff_coeur), eff_coeur >= .MIN_EFF_IS) |>
  dplyr::transmute(libelle = lib_dep, eff = round(eff_coeur), part = part,
                   lq = lq, tcam_long = tcam_long, tcam_court = tcam_court, acc = acc) |>
  dplyr::arrange(dplyr::desc(eff))
log_table_auto("recap_dep_table", .rd, label_col = "libelle")
rt_table(
  data = .rd,
  cols = list(
    libelle    = rt_col_lib(.rd, "libelle", "Département", indent = FALSE, mw = 150),
    eff        = rt_col_level(.rd, "eff", sprintf("Salariés %d", ann_ref), unit = "n", bw = 72, bh = 16, mw = 122, light = TRUE),
    part       = rt_col_level(.rd, "part", "Part de l'emploi privé", unit = "%", bw = 55, bh = 16, mw = 100),
    lq         = rt_col_level(.rd, "lq", "Spécialisation", unit = "x moy. nat.", bw = 65, bh = 16, mw = 108, ref = 1),
    tcam_long  = rt_col_var(.rd, "tcam_long", sprintf("TCAM %d-%d", 2019L, ann_ref), unit = "%/an", bw = 58, bh = 16, mw = 100),
    tcam_court = rt_col_var(.rd, "tcam_court", sprintf("TCAM %d-%d", 2023L, ann_ref), unit = "%/an", bw = 58, bh = 16, mw = 100),
    acc        = rt_col_var(.rd, "acc", "Accélération", unit = "pts", bw = 58, bh = 16, mw = 100)
  ),
  title = "Le secteur département par département — poids, spécialisation, dynamique",
  subtitle = sprintf("Départements d'au moins %d salariés du secteur — Source : URSSAF-ACOSS, emploi salarié privé",
                     .MIN_EFF_IS),
  # rt_table expose page_size, PAS pagination (celui-ci appartient a reactable() brut) : passer
  # `pagination` levait "argument inutilise" et le tableau ne sortait pas. page_size au-dela du
  # nombre de lignes = tout en une page, le scroll etant assure par height. 260828
  searchable = TRUE, page_size = 400, height = 420)
Le secteur département par département — poids, spécialisation, dynamique
Départements d'au moins 200 salariés du secteur — Source : URSSAF-ACOSS, emploi salarié privé
⇅ triable

4 Vue territoriale : Hauts-de-France

En synthèse — Hauts-de-France

D’une échelle à l’autre, le même constat s’impose : les territoires qui concentrent le plus d’emplois logistiques ne sont jamais ceux où le secteur pèse le plus lourd. Le volume est métropolitain, l’intensité périurbaine — et c’est la seconde qui mesure la dépendance d’un territoire à la santé du secteur.

La logistique pèse 5,3 % de l’emploi privé régional contre 3,7 % au national — et progresse quand la région stagne
L’entreposage occupe 29 % des salariés logistiques de la région contre 19 % en France
L’entreposage fournit la moitié des 11 100 emplois logistiques gagnés par la région depuis 2019
La Somme progresse de 7,8 % par an quand le Nord plafonne à 1,0 % — la logistique régionale se déplace vers le sud
Soissons consacre 10,5 % de son emploi privé à la logistique — trois fois la part de Lille, qui en concentre pourtant le plus gros volume
Dourges consacre 36 % de son emploi privé à la logistique, Lille — premier volume régional — seulement 2 %

  • 81 k salariés privés, soit 5,3 % de l’emploi privé régional — contre 3,7 % en moyenne nationale
  • +11 k emplois entre 2019 et 2025, soit +2,5 % par an — contre +2,0 % au national sur la même période
  • quand l’emploi privé régional tous secteurs progresse de +0,9 % par an sur cette même fenêtre

Hauts-de-France : poids et dynamique régionale du secteur

Voir le code
# Agrégat régional URSSAF privé (focus = territoire_codes) — volume, part, dynamique
.us_reg <- dat$urssaf |>
  filter(measure == "EFF_SAL", year %in% c(2019L, p_evol[1], ann_ref)) |>
  left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% params$territoire_codes)
# Secteur régional : flux APE EXACT (territoire) si dispo, sinon le proxy A88 (rétrocompat)
.rsec <- if (isTRUE(has_ape_curated)) {
  dat$urssaf_ape |> filter(measure == "EFF_SAL", year %in% c(2019L, p_evol[1], ann_ref)) |>
    left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
    filter(DEP %in% params$territoire_codes) |>
    group_by(year) |> summarise(e = sum(value, na.rm = TRUE), .groups = "drop")
} else {
  .us_reg |> filter(code_a88 %in% proxy_a88) |> group_by(year) |> summarise(e = sum(value, na.rm = TRUE), .groups = "drop")
}
.rtot <- .us_reg |> group_by(year) |> summarise(e = sum(value, na.rm = TRUE), .groups = "drop")
reg_vol_ref  <- .rsec$e[.rsec$year == ann_ref]
reg_part     <- reg_vol_ref / .rtot$e[.rtot$year == ann_ref] * 100
reg_tcam_rec <- ((reg_vol_ref / .rsec$e[.rsec$year == p_evol[1]])^(1/(ann_ref - p_evol[1])) - 1) * 100
reg_tcam_lng <- ((reg_vol_ref / .rsec$e[.rsec$year == 2019L])^(1/(ann_ref - 2019L)) - 1) * 100
# TCAM tous secteurs régional (référence "reste de l'économie" pour la prose d'ouverture)
reg_tcam_tot <- ((.rtot$e[.rtot$year == ann_ref] / .rtot$e[.rtot$year == p_evol[1]])^(1/(ann_ref - p_evol[1])) - 1) * 100
log_result("terr_overview", list(reg_vol = reg_vol_ref, reg_part = reg_part,
                                  reg_tcam_rec = reg_tcam_rec, reg_tcam_lng = reg_tcam_lng, reg_tcam_tot = reg_tcam_tot))
Voir le code
htmltools::tagList(
  kpi_row(wide = TRUE,
    kpi_band(category = "% emploi régional", value = fmt_pct_1dec(reg_part),
             ref = sprintf("soit <strong>%s</strong> salariés privés", fmt_k(reg_vol_ref))),
    kpi_band(category = sprintf("Evol %d-%d", p_evol[1], ann_ref),
             value = sprintf("%s %%/an", fmt_sign_1dec(reg_tcam_rec)),
             ref = sprintf("vs <strong>%s %%/an</strong> national", fmt_sign_1dec(tcam_us))),
    kpi_band(category = sprintf("Tendance longue %d-%d", 2019L, ann_ref),
             value = sprintf("%s %%/an", fmt_sign_1dec(reg_tcam_lng)),
             ref = "effectifs salariés privés")
  ),
  kpi_source(sprintf("URSSAF-ACOSS · %s · %d", params$territoire_focus, ann_ref))
)
% emploi régional
5,3 % soit 81 k salariés privés
Evol 2023-2025
+2,1 %/an vs +0,9 %/an national
Tendance longue 2019-2025
+2,5 %/an effectifs salariés privés
URSSAF-ACOSS · Hauts-de-France · 2025
Voir le code
# INSIGHT + idées-clés AVANT la figure (conv 260820) — cf terr_overview_narr_post.
# DÉMARRAGE ESSENTIEL (user) : chiffres CONCRETS — poids régional vs national + dynamique vs national
# ET vs reste de l'économie régionale. Qualificatif directionnel maison (seuils en points de %/an).
.dq <- dplyr::case_when(
  reg_tcam_rec > tcam_us + 1   ~ "nettement plus vite qu'",
  reg_tcam_rec > tcam_us + 0.3 ~ "plus vite qu'",
  reg_tcam_rec < tcam_us - 1   ~ "nettement moins vite qu'",
  reg_tcam_rec < tcam_us - 0.3 ~ "moins vite qu'",
  TRUE                         ~ "au même rythme qu'")
cat(prose_cc_pre("terr_poids", function() glue::glue(
  "[En **{params$territoire_focus}**, {sect_phrase} pèse **{fmt_pct_1dec(reg_part)}** de l'emploi privé local ",
  "(contre **{fmt_pct_1dec(part_secteur)}** au national) et y progresse de **{fmt_sign_1dec(z0(reg_tcam_rec))} %/an** — ",
  "{.dq}au national (**{fmt_sign_1dec(z0(tcam_us))} %/an**) et que l'ensemble de l'économie régionale ",
  "(**{fmt_sign_1dec(z0(reg_tcam_tot))} %/an**, tous secteurs)]{{.insight}}\n\n",
  "Le secteur y emploie **{fmt_k(reg_vol_ref)} salariés privés**. ",
  "La suite décompose ce poids et cette dynamique par département, zone d'emploi et commune."
)))

La logistique pèse 5,3 % de l’emploi privé régional contre 3,7 % au national — et progresse quand la région stagne

Les Hauts-de-France sont une région logistique au sens strict. Le secteur y emploie 80 957 salariés privés, soit 5,3 % de l’emploi privé local contre 3,7 % en moyenne nationale : une intensité supérieure de moitié, qui place la région parmi les toutes premières du pays.

L’écart de dynamique est plus frappant encore. Sur 2019-2025, la logistique régionale progresse de 2,5 % par an, au-dessus des 2,0 % nationaux — mais surtout dans une région dont l’emploi privé total est à l’arrêt (recul de 0,01 % par an). Autrement dit, le secteur crée des emplois là où l’économie n’en crée plus : sur les six dernières années, il a apporté environ 11 000 postes à un tissu productif par ailleurs stable (figure ci-contre).

La trajectoire régionale se lit face au rythme national du secteur : la région suit-elle, devance-t-elle ou décroche-t-elle ? (figure ci-contre)

Voir le code
.an_idx <- 2019L
# Trajectoire territoriale — flux APE (territoire) si dispo, sinon le proxy A88. Réf = même secteur au national.
.reg_traj <- if (isTRUE(has_ape_curated)) {
  dat$urssaf_ape |> filter(measure == "EFF_SAL", year >= .an_idx) |>
    left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
    filter(DEP %in% params$territoire_codes) |> dplyr::rename(code_a88 = code_ape)
} else {
  dat$urssaf |> filter(measure == "EFF_SAL", year >= .an_idx, code_a88 %in% proxy_a88) |>
    left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
    filter(DEP %in% params$territoire_codes)
}
reg_coeur <- .reg_traj |> group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  mutate(grp = sprintf("%s en %s", sect_lbl, params$territoire_focus))
# Sous-secteurs = flux régional réagrégé par division A88
reg_sub <- if (isTRUE(has_ape_curated)) {
  .reg_traj |> mutate(.div = substr(code_a88, 1, 2)) |> group_by(.div, year) |>
    summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
    mutate(grp = .naf_lbl(.div)) |> select(year, emp, grp)
} else NULL
.emph_reg <- sprintf("%s en %s", sect_lbl, params$territoire_focus)
reg_long <- bind_rows(reg_coeur, reg_sub) |> group_by(grp) |> arrange(year) |>
  mutate(indice = emp / emp[year == .an_idx] * 100) |> ungroup()
reg_long$grp <- factor(reg_long$grp, levels = c(setdiff(unique(reg_long$grp), .emph_reg), .emph_reg))
# Référence : même secteur au NATIONAL (flux), indice base 100 = pointillé
.nat_ref <- (if (isTRUE(has_ape_curated)) dat$urssaf_ape |> filter(measure == "EFF_SAL", year >= .an_idx)
             else dat$urssaf |> filter(measure == "EFF_SAL", year >= .an_idx, code_a88 %in% proxy_a88)) |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  arrange(year) |> mutate(France = emp / emp[year == .an_idx] * 100) |> select(year, France)
Voir le code
plot_line_indice100(
  reg_long, x = "year", idx = "indice", serie = "grp",
  ref_value = 100, points = FALSE, name_in_plot = TRUE, value_labels = TRUE, name_box = FALSE,
  emphasis = .emph_reg, emphasis_col = "#12719e", context_pal = c("#9ecae1", "#6baed6", "#4292c6", "#c6dbef"),
  value_size_top = 3.8, value_size_bot = 3.2, name_size = 3.4,
  mean_ref = .nat_ref, mean_lab = sprintf("%s · France", sect_lbl), mean_col = "#9a9a9a",
  x_breaks = seq(.an_idx, ann_ref, by = 2),
  title    = sprintf("Evolution de l'emploi dans le secteur %s en %s · indice base 100 = %d", sect_lbl, params$territoire_focus, .an_idx),
  subtitle = "Cœur en bleu fort · sous-divisions (NA88) en bleu clair · pointillé gris = même secteur au national",
  y_lab    = sprintf("Indice (%d = 100)", .an_idx),
  caption  = make_source("URSSAF-ACOSS"))

Voir le code
# DÉTAIL / mécanisme APRÈS la figure. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("terr_poids"))

Cette position ne doit rien au hasard. La région cumule les trois conditions d’implantation : des portes maritimes sur la Manche et la mer du Nord, le passage de la dorsale européenne reliant le Benelux à l’Île-de-France, et un foncier disponible à bas coût sur d’anciens sites industriels reconvertis. S’y ajoute la proximité immédiate du premier bassin de consommation français, à moins de deux heures de route.

Reste à savoir si ce dynamisme régional profite uniformément aux cinq départements.

Positionnement régional : Logistique parmi les grands secteurs

Voir le code
if (!exists("jcp_psect_scatter_a38", mode = "function") && file.exists("helpers/_load.R")) source("helpers/_load.R")
.urssaf_reg <- dat$urssaf |>
  dplyr::left_join(tdc |> dplyr::select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  dplyr::filter(DEP %in% params$territoire_codes)
# Cible sur le PÉRIMÈTRE EXACT (coeur_ape) au grain régional → cohérent avec le headline régional.
.urssaf_ape_reg <- if (isTRUE(has_ape_curated)) {
  dat$urssaf_ape |>
    dplyr::left_join(tdc |> dplyr::select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
    dplyr::filter(DEP %in% params$territoire_codes)
} else NULL
.coeur_sc <- if (isTRUE(has_ape_curated)) coeur_ape else NULL
.scatter_sub <- sprintf("x = TCAM %d-%d (récent) · y = TCAM %d-%d (rebond) · taille = emploi %d (surface = effectif) · couleur = grand secteur · cible = périmètre exact · molette = zoom",
                        p_evol[1], ann_ref, 2019L, p_evol[1], ann_ref)
.scatter_src <- sprintf("URSSAF-ACOSS · %s · effectifs salariés privés", params$territoire_focus)
Voir le code
render_girafe(jcp_psect_scatter_a38(
  .urssaf_reg, proxy_a88, base_year = 2019L, p1_year = p_evol[1], ref_year = ann_ref, level = "a21",
  target_label = params$secteur_short %||% sect_lbl, n_label = 21,
  dat_ape = .urssaf_ape_reg, coeur_ape = .coeur_sc,
  subtitle = .scatter_sub, source_txt = .scatter_src),
  width_svg = 10, height_svg = 6.5, zoom = TRUE, max_zoom = 8)
Voir le code
render_girafe(jcp_psect_scatter_a38(
  .urssaf_reg, proxy_a88, base_year = 2019L, p1_year = p_evol[1], ref_year = ann_ref, level = "a38",
  target_label = params$secteur_short %||% sect_lbl, n_label = 14,
  dat_ape = .urssaf_ape_reg, coeur_ape = .coeur_sc,
  subtitle = .scatter_sub, source_txt = .scatter_src),
  width_svg = 10, height_svg = 6.5, zoom = TRUE, max_zoom = 8)
Voir le code
cat(glue::glue(
  "### Composition : quelles activités portent le secteur dans la région ?\n\n",
  "La composition du secteur en {params$territoire_focus} se décompose par sous-classe d'activité (APE) : ",
  "l'aire de chaque tuile donne le volume d'emploi régional, la couleur sa dynamique récente *(figure ci-dessous)*.\n"
))

Composition : quelles activités portent le secteur dans la région ?

La composition du secteur en Hauts-de-France se décompose par sous-classe d’activité (APE) : l’aire de chaque tuile donne le volume d’emploi régional, la couleur sa dynamique récente (figure ci-dessous).

Voir le code
# Données de la treemap RÉGIONALE, isolées du chunk graphique pour que la prose d'insight
# (terr_treemap_ape_pre) puisse les citer AVANT la figure (conv 260820).
ape_reg_wide <- dat$urssaf_ape |>
  filter(measure == "EFF_SAL", code_ape %in% ape_codes,
         year %in% c(2019L, p_evol[1], p_evol[2])) |>
  left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% params$territoire_codes) |>
  group_by(code_ape, year) |>
  summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  pivot_wider(names_from = year, values_from = emp, names_prefix = "y") |>
  mutate(
    libelle   = .ape_lbl(code_ape),
    grp_naf   = .naf_lbl(substr(code_ape, 1, 2)),
    emp_ref   = .data[[paste0("y", p_evol[2])]],
    tcam_p1p2 = ((.data[[paste0("y", p_evol[2])]] /
                  .data[[paste0("y", p_evol[1])]])^(1 / (p_evol[2] - p_evol[1])) - 1) * 100
  ) |>
  filter(!is.na(emp_ref), emp_ref > 0, is.finite(tcam_p1p2))
ape_reg_wide$part_pct <- ape_reg_wide$emp_ref / sum(ape_reg_wide$emp_ref) * 100
Voir le code
# INSIGHT + idées-clés AVANT la figure (conv 260820). `.t2r` sert aussi au fallback auto.
.t2r <- ape_reg_wide |> arrange(desc(part_pct)) |> slice_head(n = 2)
cat(prose_cc_pre("terr_compo", function() glue::glue(
  "**{.t2r$libelle[1]}** ({fmt_pct_1dec(.t2r$part_pct[1])} de l'emploi régional du secteur) et ",
  "**{.t2r$libelle[2]}** ({fmt_pct_1dec(.t2r$part_pct[2])}) en forment le socle, concentrant à elles deux ",
  "**{fmt_pct_1dec(sum(.t2r$part_pct))}** des effectifs *(figure ci-dessous)*."
)))

L’entreposage occupe 29 % des salariés logistiques de la région contre 19 % en France

La composition régionale n’est pas une réduction à l’échelle de la composition nationale. Le fret interurbain reste la première activité avec 26 174 salariés (32 % du secteur régional), mais l’entreposage non frigorifique le talonne avec 20 309 salariés (25 %), là où il ne pèse que 15 % au national (figure ci-contre). Entrepôts frigorifiques compris, le stockage mobilise 29 % de la logistique régionale, une fois et demie son poids français.

Voir le code
# Treemap composition APE RÉGIONALE (miroir du national dyn_treemap, restreint au territoire focus).
# Aire = effectifs régionaux par sous-classe APE · couleur = TCAM récent (gradient divergent centré 0).
log_table_auto("terr_treemap_ape_reg", ape_reg_wide, label_col = "libelle")

plot_tree_bygroup(
  ape_reg_wide, area_col = "emp_ref", label_col = "libelle", subgroup_col = "grp_naf",
  fill_mode = "gradient", fill_var = "tcam_p1p2", gradient_center = 0,
  label_extra_col = "tcam_p1p2", label_extra_unit = " %/an",
  legend_title = sprintf("TCAM\n%d-%d", p_evol[1], p_evol[2]),
  label_mode = "pct_vol",
  title    = sprintf("Composition de l'emploi %s en %s — aire = effectifs, couleur = croissance récente",
                     sect_lbl, params$territoire_focus),
  subtitle = sprintf("Salariés privés %d (URSSAF, %s) · couleur = TCAM %d-%d (bordeaux = hausse, bleu = recul) · halo = famille NA88",
                     p_evol[2], params$territoire_focus, p_evol[1], p_evol[2]),
  caption  = make_source("URSSAF-ACOSS APE"))

Voir le code
# DÉTAIL / mécanisme APRÈS la figure. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("terr_compo"))

Ce surpoids a une contrepartie mécanique : l’organisation du transport est sous-représentée. L’affrètement, fonction d’intermédiation qui vend de la capacité sans posséder de camions, ne rassemble que 6 585 salariés (8 % du secteur régional contre 12 % en France). Ces emplois de commercialisation et de pilotage restent logés dans les sièges sociaux, majoritairement franciliens. La région exécute les flux davantage qu’elle ne les organise — une division du travail qui pèse sur le niveau de qualification des postes implantés.

Le fret de proximité confirme cette lecture : avec 16 336 salariés (20 % contre 25 % au national), il est le seul segment routier en retrait, parce que le dernier kilomètre suit la consommation des ménages, plus dense en Île-de-France que dans le Nord. Autre surprise, la manutention portuaire ne représente que 882 salariés, soit à peine 1 % du secteur : malgré Dunkerque, Calais et Boulogne, l’emploi de quai reste concentré au Havre et à Marseille, et l’automatisation des terminaux y a réduit les effectifs bien avant les entrepôts.

La couleur des tuiles ajoute une dernière information : l’entreposage non frigorifique croît de 6,2 % par an depuis 2023, nettement au-dessus des 4,0 % relevés au national, quand la messagerie recule de 2,6 % par an. Le secteur régional ne fait donc pas que pencher vers le stockage, il y penche de plus en plus.

Voir le code
# INSIGHT + idées-clés AVANT la figure (conv 260820).
# Contribution récente (base = total secteur, cohérent avec le graphe) — pas le taux propre de l'activité.
.y0 <- function(v) { v[is.na(v)] <- 0; v }
.sb  <- sum(.y0(ape_reg_wide[[paste0("y", 2019L)]]))
.dr  <- tibble::tibble(
    libelle = ape_reg_wide$libelle,
    contrib = (.y0(ape_reg_wide[[paste0("y", p_evol[2])]]) - .y0(ape_reg_wide[[paste0("y", p_evol[1])]])) / .sb * 100) |>
  arrange(desc(contrib)) |> slice_head(n = 1)
cat(prose_cc_pre("terr_contrib", function() glue::glue(
  "Sur la période récente {p_evol[1]}-{p_evol[2]}, **{.dr$libelle}** apporte la plus forte contribution ",
  "à la croissance régionale du secteur (**{fmt_sign_1dec(.dr$contrib)} pt**) *(figure ci-dessous)*."
)))

L’entreposage fournit la moitié des 11 100 emplois logistiques gagnés par la région depuis 2019

Depuis 2019, la logistique régionale a gagné 11 146 salariés, une progression de 16 %. Une seule activité en explique la moitié : l’entreposage non frigorifique apporte 8,2 points de ces 16, soit 5 742 emplois nets (figure ci-contre). Le fret interurbain suit loin derrière avec 3,2 points, le fret de proximité 1,1 point.

Voir le code
# Décompo additive RÉGIONALE : rebond (2019→p1) + récent (p1→p2) = variation totale du secteur.
# ⚠️ BASE = TOTAL secteur régional 2019 (PAS la base de chaque activité) → vraie CONTRIBUTION :
# la somme des contributions = croissance du secteur, PROPORTIONNELLE au volume. Corrige l'explosion
# d'une micro-activité (ex Transports ferroviaires +176 % sur sa base propre, mais ~+5 k en volume).
# ⚠️ BUG IDENTIQUE au national (vue-nationale dyn_decomp : .base = base par activité) → à corriger là-bas.
.lab_p1 <- sprintf("%d-%d", 2019L, p_evol[1])
.lab_p2 <- sprintf("%d-%d", p_evol[1], p_evol[2])
.y0 <- function(v) { v[is.na(v)] <- 0; v }            # effectifs manquants (activité absente une année) = 0
.b19 <- .y0(ape_reg_wide[[paste0("y", 2019L)]])
.bp1 <- .y0(ape_reg_wide[[paste0("y", p_evol[1])]])
.bp2 <- .y0(ape_reg_wide[[paste0("y", p_evol[2])]])
.sect_base <- sum(.b19)                                # dénominateur COMMUN = effectifs régionaux secteur 2019
ape_reg_decomp <- tibble::tibble(
    libelle  = ape_reg_wide$libelle,
    c_rebond = (.bp1 - .b19) / .sect_base * 100,
    c_recent = (.bp2 - .bp1) / .sect_base * 100) |>
  tidyr::pivot_longer(c(c_rebond, c_recent), names_to = "periode", values_to = "contrib") |>
  mutate(periode = factor(ifelse(periode == "c_rebond", .lab_p1, .lab_p2), levels = c(.lab_p2, .lab_p1)))
.net_vol   <- .bp2 - .b19
.vol_extra <- setNames(paste0(ifelse(.net_vol > 0, "+", ""), fmt_k(.net_vol)), ape_reg_wide$libelle)
plot_bar_stack_decomp_bipolar(
  ape_reg_decomp, entite = "libelle", composante = "periode", val = "contrib",
  colors = setNames(c("#1696d2", "#cfe3ef"), c(.lab_p2, .lab_p1)),
  total_extra = .vol_extra,
  label_fmt = function(v) sprintf("%+.0f %%", v),
  total_fmt = function(v) sprintf("%+.0f %%", v),
  title    = sprintf("Décomposition : qui a porté la croissance régionale %d-%d ?", 2019L, p_evol[2]),
  subtitle = sprintf("Contribution à la croissance du secteur (%% des effectifs régionaux %d) · total = %% et volume net · rebond %s + récent %s",
                     2019L, .lab_p1, .lab_p2),
  x_lab = NULL, y_lab = "Contribution (%)",
  caption = make_source("URSSAF-ACOSS APE"))

Voir le code
# DÉTAIL / mécanisme APRÈS la figure. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("terr_contrib"))

La décomposition en deux temps montre un moteur qui ralentit sans caler. Le rebond 2019-2023 a produit 11,4 points de croissance en quatre ans, la période récente 4,6 points en deux ans, soit un rythme annuel qui passe de 2,7 % à 2,0 %. Mais la dépendance à l’entreposage s’accentue : il pèse pour 3,3 des 4,6 points récents, sept dixièmes du total. Une région dont la croissance sectorielle repose sur un seul segment devient sensible à un retournement du marché de l’immobilier logistique.

Trois activités contribuent négativement sur la période récente, toutes de petite taille : la messagerie et le fret express (recul de 168 emplois depuis 2023), la manutention non portuaire et l’affrètement. Aucune ne suffit à peser sur le solde, mais leur convergence avec les tendances nationales confirme qu’il ne s’agit pas d’accidents locaux.

La lecture en contribution corrige enfin un effet d’optique. Le fret ferroviaire régional a plus que doublé depuis 2019, passant de 453 à 1 090 salariés : spectaculaire rapporté à sa propre base, l’événement ne représente que 0,9 point de la croissance du secteur. Rapporter chaque activité au total régional, et non à ses propres effectifs de départ, évite de confondre une envolée sur petite base avec un véritable moteur.

Départements : quelles dynamiques infra-régionales ?

Voir le code
# Calculs par DEP pour le territoire focus
dep_focus <- params$territoire_codes
# Libellés DEP dynamiques depuis TDC (champ DEPlib) — réplicable
dep_libs <- tdc |>
  filter(DEP %in% dep_focus) |>
  distinct(DEP, DEPlib) |>
  tibble::deframe()

# URSSAF : emp SAP + emp total par DEP × année
us_dep <- dat$urssaf |>
  filter(measure == "EFF_SAL", year %in% c(ann_base, p_evol[1], ann_ref)) |>
  left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% dep_focus)

# Volume secteur (flux APE exact si has_ape_curated, sinon A88 cœur — cf sect_emp_comm) par DEP × année
emp_sap_dep <- sect_emp_comm |>
  filter(year %in% c(ann_base, p_evol[1], ann_ref)) |>
  left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% dep_focus) |>
  group_by(DEP, year) |>
  summarise(emp = sum(value, na.rm = TRUE), .groups = "drop")

# Volume tot privé par DEP × année (référence pour part SAP locale)
emp_tot_dep <- us_dep |>
  group_by(DEP, year) |>
  summarise(emp_tot = sum(value, na.rm = TRUE), .groups = "drop")

# Wide pivot
sap_wide <- emp_sap_dep |>
  pivot_wider(names_from = year, values_from = emp, names_prefix = "y") |>
  rename_with(~ paste0("emp_", sub("y", "", .x)), starts_with("y")) |>
  left_join(
    emp_tot_dep |> filter(year == ann_ref) |> select(DEP, emp_tot_24 = emp_tot),
    by = "DEP"
  ) |>
  mutate(
    libelle = dep_libs[DEP],
    part_sap_pct = .data[[paste0("emp_", ann_ref)]] / emp_tot_24 * 100,
    tcam_1924 = ((.data[[paste0("emp_", ann_ref)]] /
                  .data[[paste0("emp_", p_evol[1])]])^(1 / (p_evol[2] - p_evol[1])) - 1) * 100,
    tcam_1424 = ((.data[[paste0("emp_", ann_ref)]] /
                  .data[[paste0("emp_", ann_base)]])^(1 / (ann_ref - ann_base)) - 1) * 100
  )

# IS = (part_sap_dep / part_sap_fr)
sap_wide$is_sap <- sap_wide$part_sap_pct / part_secteur * 100

# Profil EEC pondéré par effectif DEP (proxy : on prend le profil FR car EEC pas dispo DEP)
# Pour V1 on utilise MOBZELT au niveau ZE puis remonté en DEP via TDC (approximation)
mobz_dep <- dat$mobz_ze |>
  left_join(tdc |> select(ZE2020, DEP) |> distinct(),
            by = c("code_ze" = "ZE2020")) |>
  filter(DEP %in% dep_focus, code_naf88 %in% proxy_a88) |>
  group_by(DEP) |>
  summarise(
    lab_fem_pct = weighted.mean(lab_fem_pct, lab_eff_vol, na.rm = TRUE),
    lab_50plus_pct = weighted.mean(lab_50plus_pct, lab_eff_vol, na.rm = TRUE),
    .groups = "drop"
  )

sap_dep_full <- sap_wide |>
  left_join(mobz_dep, by = "DEP") |>
  arrange(desc(.data[[paste0("emp_", ann_ref)]]))

log_result("decompo_dep_ref", sap_dep_full |>
             select(DEP, libelle, all_of(paste0("emp_", ann_ref)),
                    part_sap_pct, tcam_1924, is_sap, lab_fem_pct, lab_50plus_pct))
Voir le code
# INSIGHT + idées-clés AVANT le tableau (conv 260820) — cf decompo_dep_narration_post.
top_dep <- sap_dep_full[1, ]
bot_dep <- sap_dep_full[nrow(sap_dep_full), ]
ratio_top_bot <- top_dep[[paste0("emp_", ann_ref)]] / bot_dep[[paste0("emp_", ann_ref)]]

# Articles définis FR (fr_article jcn-prose) : "l'Ille-et-Vilaine", "les Côtes-d'Armor", "le Morbihan"
.cap1   <- function(s) paste0(toupper(substr(s, 1, 1)), substring(s, 2))
top_art <- .cap1(paste0(fr_article(top_dep$libelle), top_dep$libelle))   # début d'insight → majuscule
bot_art <- paste0(fr_article(bot_dep$libelle), bot_dep$libelle)          # milieu de phrase → minuscule

cat(prose_cc_pre("terr_dep", function() glue::glue(
  "[**{top_art}** concentre **{fmt_k(top_dep[[paste0('emp_', ann_ref)]])} emplois** du secteur cœur, ",
  "soit **{round(ratio_top_bot, 1)}×** plus que **{bot_art}** ",
  "({fmt_k(bot_dep[[paste0('emp_', ann_ref)]])})]{{.insight}}\n\n",
  "L'indice de spécialisation (part du secteur rapportée à sa moyenne nationale, France = 1) varie de ",
  "**×{format(round(min(sap_dep_full$is_sap) / 100, 1), decimal.mark = ',')}** à **×{format(round(max(sap_dep_full$is_sap) / 100, 1), decimal.mark = ',')}** entre les ",
  "{nrow(sap_dep_full)} départements de {params$territoire_focus}, ",
  "signe d'une **{ifelse(max(sap_dep_full$is_sap) - min(sap_dep_full$is_sap) > 20, 'hétérogénéité marquée', 'cohésion forte')}** ",
  "du tissu sectoriel. La dynamique récente {p_evol[1]}-{p_evol[2]} oscille entre ",
  "**{fmt_sign(min(sap_dep_full$tcam_1924))} % par an** et **{fmt_sign(max(sap_dep_full$tcam_1924))} % par an** selon les départements."
)))

La Somme progresse de 7,8 % par an quand le Nord plafonne à 1,0 % — la logistique régionale se déplace vers le sud

Le volume et la dynamique ne désignent pas les mêmes territoires, et l’écart raconte un déplacement en cours.

Le Nord reste le socle avec 33 260 salariés, soit 41 % du total régional, devant le Pas-de-Calais (21 383). Mais leur croissance est la plus faible de la région — respectivement 1,0 % et 1,2 % par an — signe d’un appareil logistique déjà mature, où les sites disponibles se raréfient et où le renouvellement remplace l’extension (figure ci-contre).

Le mouvement s’est déplacé vers le sud. La Somme affiche 7,8 % par an, un rythme trois fois supérieur à la moyenne régionale, tandis que l’Oise progresse de 2,7 % et l’Aisne de 2,4 %. L’Oise cumule d’ailleurs les deux qualités : le secteur y pèse 1,9 fois sa moyenne nationale, ce qui en fait le département le plus intensément logistique de la région, effet direct de sa position sur les axes de distribution francilienne — le secteur y occupe près de sept salariés du privé sur cent.

Voir le code
# Tableau dept avec rt_table + helpers jcn-reactable (pattern RT-FILL bars élastiques)
# Colonnes génériques (réplicable autre secteur : SAP, tourisme, etc.)
tab_dep <- sap_dep_full |>
  transmute(
    libelle = libelle,
    emp_sect = .data[[paste0("emp_", ann_ref)]],
    is_sect = is_sap / 100,   # RATIO France = 1 (conv synthèse « × moy. nat. ») — l'affichage seul
                              # change : `is_sap` base 100 reste la donnée loggée/JSON.
    tcam_pevol_pct = tcam_1924,
    tcam_full_pct = tcam_1424,
    fem_pct = lab_fem_pct,
    p50_pct = lab_50plus_pct
  )

# Logging AVANT widget (règle log_*() avant la dernière expression)
log_table_auto("decompo_dep_table", tab_dep, label_col = "libelle")

rt_table(
  data = tab_dep,
  cols = list(
    # bw = "fill" partout (260901) : les barres à largeur FIXE (55-70 px) flottaient dans des
    # colonnes plus larges — moitié de la ligne en blanc, tableau « pas clean » (user). En fill,
    # la barre épouse sa colonne quelle que soit la largeur que reactable lui donne.
    # IS affiché en RATIO « × nat. » (France = 1), même langage que les cartes et la synthèse —
    # l'ancien « 188 (FR=100) » et le « ×1,9 » de la synthèse disaient la même chose en deux codes.
    libelle        = rt_col_lib(tab_dep, "libelle", "Département",
                                indent = FALSE, mw = 140),
    emp_sect       = rt_col_level(tab_dep, "emp_sect",
                                   sprintf("Emp. %d", ann_ref), unit = "n",
                                   bw = "fill", bh = 16, mw = 125),
    is_sect        = rt_col_idx(tab_dep, "is_sect", "Spécialisation", unit = "× nat.",
                                 bw = "fill", bh = 16, mw = 110, center = 1),
    tcam_pevol_pct = rt_col_var(tab_dep, "tcam_pevol_pct",
                                 sprintf("TCAM %s-%s", substr(p_evol[1], 3, 4), substr(p_evol[2], 3, 4)),
                                 unit = "%/an",
                                 bw = "fill", bh = 16, mw = 105),
    tcam_full_pct  = rt_col_var(tab_dep, "tcam_full_pct",
                                 sprintf("TCAM %s-%s", substr(ann_base, 3, 4), substr(ann_ref, 3, 4)),
                                 unit = "%/an",
                                 bw = "fill", bh = 16, mw = 105),
    fem_pct        = rt_col_level(tab_dep, "fem_pct", "% Femmes", unit = "%",
                                   bw = "fill", bh = 16, mw = 88),
    p50_pct        = rt_col_level(tab_dep, "p50_pct", "% 50+", unit = "%",
                                   bw = "fill", bh = 16, mw = 88)
  ),
  title = sprintf("Décomposition %s par département — volume, spécialisation, dynamique",
                  params$territoire_focus),
  subtitle = sprintf("Effectifs %s en %d · spécialisation = part DEP / part France (France = 1) · profil démographique MOBZELT 2022",
                     sect_lbl, ann_ref),
  searchable = FALSE,
  full_width = FALSE,
  footnote = "Sources : URSSAF-ACOSS (volume, spécialisation, TCAM) · INSEE MOBZELT 2022 remonté ZE→DEP (profil démographique). EEC indisponible au niveau DEP."
)
Décomposition Hauts-de-France par département — volume, spécialisation, dynamique
Effectifs Logistique en 2025 · spécialisation = part DEP / part France (France = 1) · profil démographique MOBZELT 2022
⇅ triable
Sources : URSSAF-ACOSS (volume, spécialisation, TCAM) · INSEE MOBZELT 2022 remonté ZE→DEP (profil démographique). EEC indisponible au niveau DEP.

Lecture : barre bleue = volume relatif au plus grand DEP. au-dessus de ×1,1 = département sur-spécialisé, sous ×0,9 = sous-spécialisé (France = 1). TCAM = taux de croissance annuel moyen. Sources : URSSAF privé (volume, IS, TCAM) · INSEE MOBZELT 2022 remonté ZE→DEP (profil démographique).

Voir le code
# DÉTAIL / mécanisme APRÈS le tableau. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("terr_dep"))

Le contraste entre le Nord (×1,2) et l’Oise (×1,9) mérite d’être souligné : le Nord concentre deux fois et demie plus d’emplois logistiques, mais dans une économie beaucoup plus diversifiée. Le poids absolu et la spécialisation mesurent deux choses distinctes, et seule la seconde signale une dépendance du territoire à la santé du secteur.

Voir le code
# Small multiples modernisés via plot_line_facet (jcn-graph-line) : indice base 100 par DEP
# + référence secteur France tracée dans chaque panneau (pointillé).
us_all <- dat$urssaf |> filter(year >= ann_base)

# Série par DEP focus — emp brut (le helper calcule l'indice base 100 = première année)
us_dep_long <- sect_emp_comm |>
  filter(year >= ann_base) |>
  left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% dep_focus) |>
  group_by(DEP, year) |>
  summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  mutate(lib = paste0(DEP, " · ", dep_libs[DEP]))

# Référence secteur France (indice base 100 = ann_base) → col .fref attendue par plot_line_facet
fref_sect <- sect_emp_comm |>
  filter(year >= ann_base) |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  arrange(year) |> mutate(.fref = emp / emp[year == ann_base] * 100) |>
  select(year, .fref)
# Référence secteur RÉGIONAL (total des DEP focus, indice base 100) → col .fref2 (2e pointillé facet)
fref_reg <- sect_emp_comm |>
  left_join(tdc |> select(CODGEO, DEP), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% dep_focus, year >= ann_base) |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  arrange(year) |> mutate(.fref2 = emp / emp[year == ann_base] * 100) |>
  select(year, .fref2)
# Référence emploi privé tous secteurs (loggée pour narration)
fref_tot <- us_all |>
  group_by(year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
  arrange(year) |> mutate(ind = emp / emp[year == ann_base] * 100)
log_result("smallmultiples_fr_refs", list(
  sect_fr_indref = fref_sect$.fref[fref_sect$year == ann_ref],
  tot_fr_indref  = fref_tot$ind[fref_tot$year == ann_ref]))

plot_line_facet(
  us_dep_long, x = "year", y = "emp", facet_col = "lib",
  mode = "indice100", scales = "fixed", points = TRUE, label_points = "ends",
  x_breaks = seq(ann_base, ann_ref, 2),
  france_ref = fref_sect, france_lab = "Secteur France",
  france_ref2 = fref_reg, france_lab2 = "Secteur région", value_size = 3.3,
  title    = sprintf("Emploi sectoriel par département · indice base 100 = %d", ann_base),
  subtitle = sprintf("Départements de %s (plein) · pointillé gris foncé = secteur France · pointillé clair = secteur dans la région",
                     params$territoire_focus),
  y_lab = "Indice", caption = make_source(sprintf("URSSAF-ACOSS · %s", sect_lbl)))

Zones d’emploi : où le secteur pèse-t-il, où progresse-t-il ?

Voir le code
# Données URSSAF par commune × NA88 × année pour le focus territoire
# Filtre : communes appartenant aux DEP du territoire focus (via TDC)

# Agrégation par ZE × année (cœur + total) — sur communes région focus
us_reg <- dat$urssaf |>
  filter(measure == "EFF_SAL",
         year %in% c(p_evol[1], p_evol[2], ann_base)) |>
  left_join(tdc |> select(CODGEO, DEP, ZE2020) |> distinct(),
            by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% params$territoire_codes,
         ZE2020 %in% ze_terr_focus)

# Volume secteur (flux APE exact ou A88 cœur — cf sect_emp_comm) par ZE × année
emp_sect_ze <- sect_emp_comm |>
  filter(year %in% c(p_evol[1], p_evol[2], ann_base)) |>
  left_join(tdc |> select(CODGEO, DEP, ZE2020) |> distinct(), by = c("code_geo" = "CODGEO")) |>
  filter(DEP %in% params$territoire_codes, ZE2020 %in% ze_terr_focus) |>
  group_by(ZE2020, year) |>
  summarise(emp = sum(value, na.rm = TRUE), .groups = "drop")

# Volume total privé par ZE × année (pour part secteur locale)
emp_tot_ze <- us_reg |>
  group_by(ZE2020, year) |>
  summarise(emp_tot = sum(value, na.rm = TRUE), .groups = "drop")

# Pivot wide + indicateurs
sect_ze_wide <- emp_sect_ze |>
  pivot_wider(names_from = year, values_from = emp, names_prefix = "y") |>
  left_join(
    emp_tot_ze |> filter(year == p_evol[2]) |> select(ZE2020, emp_tot_ref = emp_tot),
    by = "ZE2020"
  ) |>
  mutate(
    code_ze       = sprintf("%04s", as.character(ZE2020)),  # même convention que shp_ze$code_ze (prélude)
    emp_ref       = .data[[paste0("y", p_evol[2])]],
    emp_base      = .data[[paste0("y", ann_base)]],
    emp_p1        = .data[[paste0("y", p_evol[1])]],
    part_sect_pct = .data[[paste0("y", p_evol[2])]] / emp_tot_ref * 100,
    # TCAM annualisés (remplacent les évolutions totales sur les cartes)
    tcam_court    = ((.data[[paste0("y", p_evol[2])]] /
                      .data[[paste0("y", p_evol[1])]])^(1 / (p_evol[2] - p_evol[1])) - 1) * 100,
    tcam_long     = ((.data[[paste0("y", p_evol[2])]] /
                      .data[[paste0("y", ann_base)]])^(1 / (p_evol[2] - ann_base)) - 1) * 100
  ) |>
  left_join(
    shp_ze |> sf::st_drop_geometry() |> select(code_ze, lib_ze),
    by = "code_ze"
  ) |>
  arrange(desc(.data[[paste0("y", p_evol[2])]]))

# IS = part_locale / part_FR × 100 (réutilise part_secteur calculé en 1.1)
sect_ze_wide$is_sect <- sect_ze_wide$part_sect_pct / part_secteur * 100

# Tooltip évolution en volume absolu (delta emploi, formaté)
.fmt_delta <- function(v2, v1) {
  d <- round(v2 - v1)
  ifelse(is.na(d) | !is.finite(d), "n.d.",
    paste0(ifelse(d >= 0, "+", ""), format(d, big.mark = " "), " emp."))
}
sect_ze_wide <- sect_ze_wide |>
  mutate(
    tip_evol_court = paste0(lib_ze, "\n",
      sprintf("TCAM %d-%d : %+.1f %%/an", p_evol[1], p_evol[2], tcam_court), "\n",
      sprintf("Variation volume : %s (%d→%d)",
        .fmt_delta(emp_ref, emp_p1), p_evol[1], p_evol[2])),
    tip_evol_long = paste0(lib_ze, "\n",
      sprintf("TCAM %d-%d : %+.1f %%/an", ann_base, p_evol[2], tcam_long), "\n",
      sprintf("Variation volume : %s (%d→%d)",
        .fmt_delta(emp_ref, emp_base), ann_base, p_evol[2]))
  )

log_table_auto("recap_region_ze", sect_ze_wide, label_col = "lib_ze")

# sf focus pour les cartes ZE (part + IS + TCAM + volume bulles) — construit ICI (data)
# pour être partagé par la paire de cartes ET les cartes commune (bg_geo).
# fh_ze : *1.35 pour agrandir les cartes (HdF est très large → trop court sinon)
shp_ze_focus <- shp_ze |>
  filter(code_ze %in% ze_terr_focus) |>
  left_join(sect_ze_wide |> select(code_ze, part_sect_pct, is_sect,
                                   tcam_court, tcam_long, emp_ref,
                                   tip_evol_court, tip_evol_long),
            by = "code_ze")
fh_ze <- round(map_fig_h(shp_ze_focus, w = 4.5) * 1.35, 2)

# &s &ZE_COURONNE - Couronne de ZE limitrophes sur les cartes du corps
# Les 4 cartes ZE du corps dessinaient la RÉGION SEULE, flottant sur du blanc, alors que la synthèse
# territoriale la dessine avec ses ZE limitrophes, la mer en blanc et le périmètre régional en navy.
# Même maille, deux traitements dans le même document — et le plus lisible était enfermé dans un
# chunk de la synthèse. Recette reprise via helpers/jcp_psect-carto.R. 260824
#
# ⚠️ REVERT : `ZE_COURONNE <- FALSE` ci-dessous restaure exactement le comportement d'avant
# (région seule, pas de fond, pas de contour) sans rien d'autre à toucher.
ZE_COURONNE <- TRUE

# Valeurs des ZE LIMITROPHES : recalculées sur TOUTES leurs communes — hors région, le filtre
# départemental du bloc ci-dessus les tronquerait et les colorerait faux.
# ⚠️ Les ZE DE LA RÉGION gardent la valeur de `sect_ze_wide`, calculée sur les seules communes du
# territoire : c'est elle qui alimente le tableau, la prose et le JSON. Les recalculer au national
# les ferait bouger (une ZE déborde des limites départementales) et désynchroniserait la carte du
# tableau juste en dessous. La couronne est donc purement ADDITIVE : aucun chiffre existant ne change.
.cr <- NULL
if (isTRUE(ZE_COURONNE) && exists("psect_ze_couronne", mode = "function")) {
  .tdc_ze  <- tdc |> select(CODGEO, ZE2020) |> distinct()
  .voi_tot <- dat$urssaf |> filter(measure == "EFF_SAL", year == p_evol[2]) |>
    left_join(.tdc_ze, by = c("code_geo" = "CODGEO")) |>
    group_by(ZE2020) |> summarise(emp_tot_ref = sum(value, na.rm = TRUE), .groups = "drop")
  .voi <- sect_emp_comm |> filter(year %in% c(ann_base, p_evol[1], p_evol[2])) |>
    left_join(.tdc_ze, by = c("code_geo" = "CODGEO")) |>
    group_by(ZE2020, year) |> summarise(emp = sum(value, na.rm = TRUE), .groups = "drop") |>
    pivot_wider(names_from = year, values_from = emp, names_prefix = "y") |>
    left_join(.voi_tot, by = "ZE2020") |>
    mutate(code_ze       = sprintf("%04s", as.character(ZE2020)),
           emp_ref       = .data[[paste0("y", p_evol[2])]],
           part_sect_pct = emp_ref / emp_tot_ref * 100,
           is_sect       = part_sect_pct / part_secteur * 100,
           tcam_court    = ((emp_ref / .data[[paste0("y", p_evol[1])]])^(1 / (p_evol[2] - p_evol[1])) - 1) * 100,
           tcam_long     = ((emp_ref / .data[[paste0("y", ann_base)]])^(1 / (p_evol[2] - ann_base)) - 1) * 100) |>
    left_join(shp_ze |> sf::st_drop_geometry() |> select(code_ze, lib_ze), by = "code_ze") |>
    filter(!code_ze %in% ze_terr_focus) |>          # la région garde SES valeurs
    mutate(tip_evol_court = paste0(lib_ze, " (hors ", params$territoire_focus, ")\n",
             sprintf("TCAM %d-%d : %+.1f %%/an", p_evol[1], p_evol[2], tcam_court)),
           tip_evol_long  = paste0(lib_ze, " (hors ", params$territoire_focus, ")\n",
             sprintf("TCAM %d-%d : %+.1f %%/an", ann_base, p_evol[2], tcam_long)))
  .cols_map <- c("code_ze", "part_sect_pct", "is_sect", "tcam_court", "tcam_long",
                 "emp_ref", "tip_evol_court", "tip_evol_long")
  .ze_vals  <- bind_rows(sect_ze_wide |> select(all_of(.cols_map)), .voi |> select(all_of(.cols_map)))
  .cr <- psect_ze_couronne(shp_ze |> left_join(.ze_vals, by = "code_ze"),
                           params$territoire_codes, tdc, shp_dep = get0("shp_dep"))
}
# Repli silencieux sur la région seule si la préparation géo échoue (sf absent, shapefile inattendu).
shp_ze_map   <- if (!is.null(.cr)) .cr$ze else shp_ze_focus
.cr_args     <- if (exists("psect_map_couronne_args", mode = "function")) psect_map_couronne_args(.cr) else list()
# Les voisines COLORENT le contexte mais ne doivent pas VOLER les étiquettes top/bottom : sans ça, un
# « 1. Roissy » viendrait coiffer un classement dont tout le texte parle en termes régionaux.
.ze_lbl_excl <- if (!is.null(.cr)) setdiff(shp_ze_map$code_ze, ze_terr_focus) else NULL
fh_ze        <- round(map_fig_h(shp_ze_map, w = 4.5) * 1.35, 2)
.th_sea_ze   <- get0(".th_sea", ifnotfound = NULL)   # fond mer posé par la synthèse ; NULL = no-op ggplot
# &e

Sur les 22 zones d’emploi de Hauts-de-France, le secteur de la logistique affiche un poids hétérogène : la part dans l’emploi local varie de 1.6 % à 10.5 %, et l’indice de spécialisation de ×0,4 à ×2,9 de la moyenne nationale. Deux questions distinctes se posent alors, et les cartes les mettent côte à côte : où le secteur pèse le plus lourd (spécialisation, en multiple de la moyenne nationale), et où il progresse (cartes ci-dessous). Un territoire peut très bien faire l’un sans l’autre. Les zones d’emploi limitrophes sont dessinées elles aussi, en dehors du liseré foncé qui marque le périmètre régional : un bassin ne s’arrête pas à la frontière administrative, et la couronne montre si le territoire prolonge une dynamique voisine ou tranche avec elle. Le classement et les étiquettes, eux, ne portent que sur les 22 zones de Hauts-de-France.

Voir le code
.zd_is <- geo_distrib(sect_ze_wide, "is_sect", "lib_ze", n = 3, pivot = 100)
cat(geo_distrib_phrase(.zd_is, terr_lbl = "zones d'emploi",
  # ratio « ×2,9 » et non « indice 287 » — même langage que cartes, tableaux, synthèse (260901)
  fmt = function(x) paste0("×", format(round(x / 100, 1), decimal.mark = ",")),
  verbe = c("dépassent la référence française", "restent en deçà"),
  style = "fluid", lead_top = "les plus spécialisées sont", lead_bot = "les moins spécialisées",
  show_outliers = FALSE))  # niveau dispersé : top = outliers → on coupe la redite (outliers réservés aux évolutions)

Sur les 22 zones d’emploi, 17 dépassent la référence française quand 5 restent en deçà — les plus spécialisées sont Soissons (×2,9), Douai (×2,7), Lens (×2,5) ; les moins spécialisées Berck (×0,4), Béthune (×0,6), Maubeuge (×0,6).

Voir le code
# INSIGHT + idées-clés AVANT la paire de cartes (conv 260820) — cf recap_region_evol_post.
# Fallback auto : top2 / bottom2 par TCAM récent.
.ze_ord <- sect_ze_wide |> filter(is.finite(tcam_court)) |> arrange(desc(tcam_court))
.ze_top <- head(.ze_ord, 2); .ze_bot <- tail(.ze_ord, 2)
cat(prose_cc_pre("terr_ze", function() glue::glue(
  "[**Une dynamique portée par les zones d'emploi de {.ze_top$lib_ze[1]} et {.ze_top$lib_ze[2]}, ",
  "un recul dans {.ze_bot$lib_ze[2]} et {.ze_bot$lib_ze[1]}**]{{.insight}}\n\n"
)))

Soissons consacre 10,5 % de son emploi privé à la logistique — trois fois la part de Lille, qui en concentre pourtant le plus gros volume

À l’échelle des bassins d’emploi, volume et intensité désignent des territoires presque opposés.

Lille domine largement en volume avec 13 191 salariés, devant Lens (8 852) et Amiens (5 946). Mais rapportée à son emploi privé, la logistique n’y représente que 3,5 % — la dix-huitième intensité sur vingt-deux bassins, à peine au-dessus de la moyenne nationale. Dans une métropole diversifiée, un gros volume logistique reste une composante mineure (figure ci-contre).

L’intensité se loge ailleurs, dans des bassins de taille moyenne où le secteur structure l’économie locale : Soissons (10,5 % de l’emploi privé), Douai (9,9 %), Lens (9,1 %) et Arras (8,6 %). Ces quatre territoires dépassent le double de la moyenne nationale ; pour eux, la logistique n’est pas un secteur parmi d’autres mais un employeur de premier rang, avec l’exposition que cela implique en cas de retournement.

Voir le code
# BINS CALCULÉS À PART — `girafe_legend` en a besoin pour dessiner ses pastilles, et surtout
# `+ th_map_sm` renvoie un NOUVEL objet ggplot qui PERD `attr(p, "bins")`. Les passer explicitement
# est donc la seule façon fiable. n_total = 7 (et non 9) : à 9 classes la légende faisait presque la
# hauteur de la carte — c'est ce que le rendu précédent avait de « trop lourd ». 260828
# IS en RATIO (France = 1, « × moy. nat. ») : même unité que la carte de synthèse, les tableaux et
# la prose depuis le 260901 — le « France = 100 » ne survit plus nulle part dans le volet.
shp_ze_map$is_ratio <- shp_ze_map$is_sect / 100
.bins_is_terr <- make_bins_divergent_asym(
  shp_ze_map$is_ratio[is.finite(shp_ze_map$is_ratio)], center = 1, n_total = 7,
  palette = get0("pal_div_bb_7", ifnotfound = NULL))
.bins_ev_terr <- make_bins_divergent_asym(
  shp_ze_map$tcam_long[is.finite(shp_ze_map$tcam_long)], center = 0, n_total = 7)
Voir le code
# PAIRE « Spécialisation | Dynamique » calquée sur la synthèse pnatter (user 260828). Trois cartes
# ont disparu au passage, et aucune n'emportait d'information propre :
#   · « part dans l'emploi local » = l'IS à un facteur près (IS = part / part France × 100) — deux
#     cartes pour la même géographie ;
#   · les bulles de volume, illisibles à 39 territoires, et le volume est dans le tableau ;
#   · le TCAM 2023-2025, resté dans le tableau et le survol : sur deux ans, une seule implantation
#     suffit à faire basculer une zone, la fenêtre longue est plus lisible en carte.
# Reste ce qui s'oppose vraiment : où le secteur PÈSE, et où il PROGRESSE.
# `subtitle` EXPLICITE : le sous-titre auto de build_map disait « 39 terr. · méd. = 127 » — une
# médiane calculée sur région + COURONNE, contredisant le tableau (22 ZE) et la prose (méd. 5,4 %)
# trois écrans plus bas. Trois populations, trois médianes : on nomme le périmètre à la place. 260828
.sub_ze <- sprintf("%d zones d'emploi de %s + voisines (liseré foncé = périmètre régional)",
                   nrow(sect_ze_wide), params$territoire_focus)
p_is <- do.call(build_map, c(list(shp_ze_map, "is_ratio", bins = .bins_is_terr,
  legend_title = "× nat.",
  titre = "Spécialisation (× moy. nat.)", title_info = TRUE,
  title_extra = "Sur une zone à 2,5 : la part du secteur dans l'emploi de la zone est 2,5 fois plus élevée que sa part dans l'emploi national. 1 = moyenne nationale.",
  subtitle = .sub_ze, source_txt = NULL,
  label_col = "lib_ze", id_col = "code_ze", label_exclude = .ze_lbl_excl,
  n_top = 3, n_bottom = 0, show_values = FALSE, show_counts = FALSE,
  label_position = "repel", label_style = "text",
  interactive = TRUE, hover_key = TRUE), .cr_args))
# Parenthèses OBLIGATOIRES : `|>` lie plus fort que `+`, sans elles c'est `.th_sea_ze` qui partirait
# dans girafe_legend au lieu de la carte.
(p_is + th_map_sm + .th_sea_ze) |>
  girafe_legend(.bins_is_terr, width_svg = 4.5, height_svg = fh_ze, title = "× nat.", zoom = TRUE)
× nat.clic & ctrl-clic
Voir le code
p_evl <- do.call(build_map, c(list(shp_ze_map, "tcam_long", bins = .bins_ev_terr,
  interactive = TRUE, hover_key = TRUE,
  # Préfixe « Dynamique : » = parité avec la paire communale (Niveau | Dynamique). La carte de gauche
  # garde « Spécialisation » : sur un indice, « Niveau » serait trompeur.
  titre = sprintf("Dynamique : croissance annuelle %d-%d", ann_base, p_evol[2]),
  subtitle = .sub_ze, source_txt = NULL,
  label_col = "lib_ze", id_col = "code_ze", label_exclude = .ze_lbl_excl,
  n_top = 2, n_bottom = 2, label_size = 3.2,
  show_values = FALSE, show_counts = FALSE, label_position = "repel",
  label_fill_sign = FALSE, label_style = "text", legend_title = "%/an",
  tooltip_col = "tip_evol_long"), .cr_args))
(p_evl + th_map_sm + .th_sea_ze) |>
  girafe_legend(.bins_ev_terr, width_svg = 4.5, height_svg = fh_ze, title = "%/an", zoom = TRUE)
%/anclic & ctrl-clic

Source : URSSAF-ACOSS · privé salarié · Logistique · le rythme 2023-2025 figure dans le tableau et au survol

Voir le code
# DÉTAIL / mécanisme APRÈS les cartes. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("terr_ze"))

Les trajectoires récentes redistribuent encore les cartes. Creil progresse de 12,1 % par an depuis 2019, de loin le rythme le plus élevé de la région, suivi d’Amiens (4,9 %) et Calais (4,2 %). À l’inverse, Béthune recule de 5,0 % par an et Château-Thierry de 2,6 %. Fait notable, la médiane régionale d’intensité atteint 5,4 % : même le bassin médian des Hauts-de-France est plus logistique que la France moyenne.

Voir le code
# Tableau récap par ZE — rt_table avec barres élastiques (body width)
tab_ze <- sect_ze_wide |>
  transmute(
    libelle       = lib_ze,
    emp_sect      = emp_ref,
    part_sect_pct = part_sect_pct,
    is_sect       = is_sect / 100,   # ratio France = 1 (conv synthese « x moy. nat. »)
    tcam_court    = tcam_court,
    tcam_long     = tcam_long
  )

log_table_auto("recap_region_table", tab_ze, label_col = "libelle")

# ⚠️ `mw` de rt_col_* est un **minWidth**, pas un maxWidth : 180 × 6 colonnes = 1 030 px de largeur
# PLANCHER pour un corps de 1 000 px → le tableau débordait forcément, dernière colonne tronquée.
# Valeurs ramenées à ~100-150 ; `auto_w()` (interne aux helpers) relève au besoin selon le contenu,
# donc rien ne se retrouve à l'étroit. En-têtes raccourcis en « 23-25 », l'unité %/an étant déjà
# portée par la 2e ligne d'en-tête. 260824
rt_table(
  data = tab_ze,
  cols = list(
    libelle        = rt_col_lib(tab_ze, "libelle", "Zone d'emploi",
                                 indent = FALSE, mw = 150),
    emp_sect       = rt_col_level(tab_ze, "emp_sect",
                                   sprintf("Emp. %d", p_evol[2]), unit = "n",
                                   bw = "fill", bh = 16, mw = 125),
    part_sect_pct  = rt_col_level(tab_ze, "part_sect_pct",
                                   "Part secteur", unit = "%",
                                   bw = "fill", bh = 16, mw = 100),
    is_sect        = rt_col_idx(tab_ze, "is_sect", "Spécialisation", unit = "× nat.",
                                 bw = "fill", bh = 16, mw = 110, center = 1),
    tcam_court     = rt_col_var(tab_ze, "tcam_court",
                                 sprintf("TCAM %s-%s", substr(p_evol[1], 3, 4), substr(p_evol[2], 3, 4)),
                                 unit = "%/an",
                                 bw = "fill", bh = 16, mw = 105),
    tcam_long      = rt_col_var(tab_ze, "tcam_long",
                                 sprintf("TCAM %s-%s", substr(ann_base, 3, 4), substr(p_evol[2], 3, 4)),
                                 unit = "%/an",
                                 bw = "fill", bh = 16, mw = 105)
  ),
  title = sprintf("Récap territorial — %d zones d'emploi de %s",
                  nrow(tab_ze), params$territoire_focus),
  subtitle = sprintf("Effectifs secteur cœur (%s) · spécialisation = part ZE / part France (France = 1) · TCAM = taux de croissance annuel moyen",
                     .perim_lbl),
  searchable = TRUE,
  full_width = FALSE,
  footnote = "Sources : URSSAF-ACOSS (volume, part, IS, TCAM) · TDC INSEE (mapping commune → ZE2020) · périmètre filtré sur les communes des départements du territoire focus."
)
Récap territorial — 22 zones d'emploi de Hauts-de-France
Effectifs secteur cœur (flux APE, 11 sous-classes) · spécialisation = part ZE / part France (France = 1) · TCAM = taux de croissance annuel moyen
⇅ triable
Sources : URSSAF-ACOSS (volume, part, IS, TCAM) · TDC INSEE (mapping commune → ZE2020) · périmètre filtré sur les communes des départements du territoire focus.

Lecture : barre bleue = volume relatif à la plus grande ZE de la région. au-dessus de ×1,1 = ZE sur-spécialisée par rapport à la France ; sous ×0,9 = sous-spécialisée. Les dynamiques sont exprimées en TCAM (taux de croissance annuel moyen, %/an). Sources : URSSAF privé via TDC commune vers ZE.

Voir le code
cat(glue::glue(
  "### Spécialisation fine : quelle sous-activité fait la signature de chaque zone d'emploi ?\n\n",
  "La heatmap croise chaque zone d'emploi de {params$territoire_focus} avec les sous-classes d'activité (APE) ",
  "du secteur. Chaque cellule = **indice de spécialisation** (France = 1,0) : au-dessus de 1, la ZE est ",
  "**sur-représentée** dans cette activité ; en deçà, sous-représentée *(tableau ci-dessous)*.\n"
))

Spécialisation fine : quelle sous-activité fait la signature de chaque zone d’emploi ?

La heatmap croise chaque zone d’emploi de Hauts-de-France avec les sous-classes d’activité (APE) du secteur. Chaque cellule = indice de spécialisation (France = 1,0) : au-dessus de 1, la ZE est sur-représentée dans cette activité ; en deçà, sous-représentée (tableau ci-dessous).

Voir le code
jcp_lq_ape_heatmap(
  dat$urssaf_ape, dat$urssaf, tdc, ape_codes,
  ape_lib  = setNames(.ape_lbl(ape_codes), ape_codes),
  year     = ann_ref, geo = "ze", geo_focus = params$territoire_codes,
  geo_keep = ze_terr_focus,
  geo_lib  = setNames(sect_ze_wide$lib_ze, sect_ze_wide$ZE2020),
  show_div = TRUE,
  title    = sprintf("Chaque zone d'emploi de %s a sa signature d'activités", params$territoire_focus),
  subtitle = sprintf("Indice de spécialisation (LQ = part locale / part France, France = 1,0, échelle log) par sous-classe APE · URSSAF %d", ann_ref),
  footnote = "Lecture : > 1 (rouge) = activité sur-représentée dans la ZE vs France ; < 1 (bleu) = sous-représentée. En-têtes groupés par division NA88 (colonnes = sous-classes APE).Cellule vide = activité absente de la ZE.")
Chaque zone d'emploi de Hauts-de-France a sa signature d'activités
Indice de spécialisation (LQ = part locale / part France, France = 1,0, échelle log) par sous-classe APE · URSSAF 2025
⇅ triable
0,1 1,0 8,0
Lecture : > 1 (rouge) = activité sur-représentée dans la ZE vs France ; < 1 (bleu) = sous-représentée. En-têtes groupés par division NA88 (colonnes = sous-classes APE).Cellule vide = activité absente de la ZE.

Communes : où le secteur s’ancre-t-il localement ?

Voir le code
# Données URSSAF par commune pour le focus territoire (communes >5k habitants)
SEUIL_POP <- 5000

# TDC enrichi : commune > seuil pop, DEP focus + coords lat/lon
tdc_comm_focus <- tdc |>
  filter(DEP %in% params$territoire_codes,
         TYPECOM == "COM" | is.na(TYPECOM),
         P22_POP >= SEUIL_POP) |>
  select(CODGEO, LIBGEO, DEP, DEPlib, ZE2020, P22_POP, lat, lon) |>
  mutate(lat = as.numeric(lat), lon = as.numeric(lon)) |>
  filter(!is.na(lat), !is.na(lon))

# Données URSSAF par commune × année (sur communes >5k de la région focus)
us_comm <- dat$urssaf |>
  filter(measure == "EFF_SAL",
         year %in% c(p_evol[1], p_evol[2], ann_base),
         code_geo %in% tdc_comm_focus$CODGEO)

# Volume secteur (flux APE exact ou A88 cœur — cf sect_emp_comm) par commune × année
emp_sect_comm <- sect_emp_comm |>
  filter(year %in% c(p_evol[1], p_evol[2], ann_base), code_geo %in% tdc_comm_focus$CODGEO) |>
  group_by(code_geo, year) |>
  summarise(emp = sum(value, na.rm = TRUE), .groups = "drop")

# Volume tot privé par commune × année
emp_tot_comm <- us_comm |>
  group_by(code_geo, year) |>
  summarise(emp_tot = sum(value, na.rm = TRUE), .groups = "drop")

# Pivot wide + indicateurs
sect_comm_wide <- emp_sect_comm |>
  pivot_wider(names_from = year, values_from = emp, names_prefix = "y") |>
  left_join(
    emp_tot_comm |> filter(year == p_evol[2]) |> select(code_geo, emp_tot_ref = emp_tot),
    by = "code_geo"
  ) |>
  left_join(tdc_comm_focus, by = c("code_geo" = "CODGEO")) |>
  mutate(
    emp_sect      = .data[[paste0("y", p_evol[2])]],   # volume secteur ref (size_col cartes + filtre)
    emp_sect_p1   = .data[[paste0("y", p_evol[1])]],
    emp_sect_base = .data[[paste0("y", ann_base)]],
    part_sect_pct = emp_sect / emp_tot_ref * 100,
    # TCAM annualisés (remplacent évolutions totales sur les cartes)
    # = NA si base < 50 emplois → évite l'explosion sur petites bases (ex +600 %/an : commune passant
    #   de ~5 à 100 emplois). La carte "part" garde son filtre ≥100 en 2025 (ces communes y restent).
    tcam_court    = ifelse(emp_sect_p1   >= 50, ((emp_sect / emp_sect_p1)^(1 / (p_evol[2] - p_evol[1])) - 1) * 100, NA_real_),
    tcam_long     = ifelse(emp_sect_base >= 50, ((emp_sect / emp_sect_base)^(1 / (p_evol[2] - ann_base)) - 1) * 100, NA_real_),
    is_sect       = part_sect_pct / part_secteur * 100,
    # Tooltip : évolution en volume absolu
    tip_evol_court = paste0(LIBGEO, "\n",
      sprintf("TCAM %d-%d : %+.1f %%/an", p_evol[1], p_evol[2], tcam_court), "\n",
      sprintf("Variation : %s (%d→%d)",
        .fmt_delta(emp_sect, .data[[paste0("y", p_evol[1])]]), p_evol[1], p_evol[2])),
    tip_evol_long = paste0(LIBGEO, "\n",
      sprintf("TCAM %d-%d : %+.1f %%/an", ann_base, p_evol[2], tcam_long), "\n",
      sprintf("Variation : %s (%d→%d)",
        .fmt_delta(emp_sect, .data[[paste0("y", ann_base)]]), ann_base, p_evol[2]))
  ) |>
  # Filtre 100 emplois secteur minimum (réduit le bruit statistique des très petites communes)
  filter(!is.na(part_sect_pct), !is.infinite(part_sect_pct), emp_sect >= 100) |>
  arrange(desc(emp_sect))

# Convertir en sf POINT pour build_map_points
sect_comm_sf <- sect_comm_wide |>
  filter(!is.na(lat), !is.na(lon)) |>
  sf::st_as_sf(coords = c("lon", "lat"), crs = 4326)

log_table_auto("commune_recap_table", sect_comm_wide, label_col = "LIBGEO")

La vue communale complète la lecture par zone d’emploi en isolant les communes ≥ 5 000 habitants des 5 départements du focus avec au moins 100 emplois sectoriels. Sur les 88 communes ainsi retenues, la part la logistique dans l’emploi local varie de 0.5 % à 36.1 %.

Voir le code
# INSIGHT + idées-clés AVANT la paire de cartes (conv 260820) — cf commune_evol_post.
# Fallback auto : top2 / bottom2 communes par TCAM récent.
.co_ord <- sect_comm_wide |> filter(is.finite(tcam_court)) |> arrange(desc(tcam_court))
.co_top <- head(.co_ord, 2); .co_bot <- tail(.co_ord, 2)
cat(prose_cc_pre("terr_comm", function() glue::glue(
  "[**Une dynamique portée par les communes de {.co_top$LIBGEO[1]} et {.co_top$LIBGEO[2]}, ",
  "un recul dans {.co_bot$LIBGEO[2]} et {.co_bot$LIBGEO[1]}**]{{.insight}}\n\n"
)))

Dourges consacre 36 % de son emploi privé à la logistique, Lille — premier volume régional — seulement 2 %

À l’échelle communale, l’inversion entre volume et intensité devient extrême. Lille rassemble le plus gros effectif du secteur avec 2 366 salariés, mais la logistique n’y représente que 2,0 % de l’emploi privé — à peine plus de la moitié de la moyenne nationale (×0,55) : une ville nettement sous-spécialisée dans le secteur qui structure sa région.

L’intensité se joue dans des communes de quelques milliers d’habitants, greffées sur un échangeur ou une plateforme : Dourges (36,1 % de son emploi privé), Courcelles-lès-Lens (32,6 %), Avion (31,9 %) et Loon-Plage (31,3 %). Onze communes de la région dépassent le seuil de 20 % (figure ci-contre). La médiane des 88 communes retenues s’établit à 7,2 %, près du double de la moyenne française — le fait logistique y est diffus, pas seulement concentré sur quelques sites.

Voir le code
# Mêmes bins calculés à part que pour les ZE, mêmes raisons (cf recap_region_bins). n_total = 7 :
# la version à 9 classes remplissait un tiers de la hauteur de figure rien qu'en légende.
.bins_part_com <- auto_bins(sect_comm_sf$part_sect_pct[is.finite(sect_comm_sf$part_sect_pct)],
                            var_name = "part_sect_pct")
.bins_ev_com   <- make_bins_divergent_asym(
  sect_comm_sf$tcam_long[is.finite(sect_comm_sf$tcam_long)], center = 0, n_total = 7)
Voir le code
# PAIRE « Niveau | Dynamique » à la maille communale, transposée du patron pnatter (user 260828).
# Le flottant à droite et la carte du rythme 2023-2025 disparaissent : trois cartes pour 88 points,
# c'était la page la plus chargée de la fiche alors que deux suffisent à porter l'opposition.
# `interactive = TRUE` est OBLIGATOIRE avec girafe_legend — c'est lui qui pose le data_id sur chaque
# point ; sans lui la légende n'a rien à quoi s'accrocher et ne filtre rien, en silence.
# La taille reste indexée sur l'effectif, mais sa légende disparaît (girafe_legend ne garde que les
# couleurs) : le sous-titre le dit, ce qui économise un bloc de légende par carte.
(build_map_points(sect_comm_sf, "part_sect_pct", bins = .bins_part_com,
  size_col = "emp_sect", size_range = c(1.6, 9), label_col = "LIBGEO",
  label_style = "text", n_top = 3, n_bottom = 0,
  bg_geo = shp_ze_focus, contour_geo = shp_ze_focus, contour_color = "#bbbbbb", contour_lw = 0.3,
  interactive = TRUE, show_counts = FALSE,
  titre = "Niveau : part dans l'emploi communal",
  subtitle = "% de l'emploi privé local · taille = effectif du secteur",
  source_txt = NULL, legend_title = "% emploi local") + th_map_sm) |>
  girafe_legend(.bins_part_com, width_svg = 4.5, height_svg = 4.95,
                title = "% emploi local", zoom = TRUE)
% emploi localclic & ctrl-clic
Voir le code
(build_map_points(sect_comm_sf, "tcam_long", bins = .bins_ev_com,
  size_col = "emp_sect", size_range = c(1.6, 9), label_col = "LIBGEO",
  label_style = "text", n_top = 2, n_bottom = 2,
  bg_geo = shp_ze_focus, contour_geo = shp_ze_focus, contour_color = "#bbbbbb", contour_lw = 0.3,
  interactive = TRUE, show_counts = FALSE,
  titre = sprintf("Dynamique : croissance annuelle %d-%d", ann_base, p_evol[2]),
  subtitle = "%/an · taille = effectif du secteur",
  source_txt = NULL, legend_title = "%/an",
  tooltip_col = "tip_evol_long") + th_map_sm) |>
  girafe_legend(.bins_ev_com, width_svg = 4.5, height_svg = 4.95, title = "%/an", zoom = TRUE)
%/anclic & ctrl-clic

Source : URSSAF-ACOSS · privé salarié · Logistique · communes ≥ 5 000 hab. et ≥ 100 emp. secteur · le rythme 2023-2025 figure dans le tableau

Voir le code
# DÉTAIL / mécanisme APRÈS les cartes. Vide si le sidecar n'a pas de marqueur `<!-- ++ -->`.
cat(prose_cc_post("terr_comm"))

Le mécanisme est celui de l’unité de production indivisible : un entrepôt, c’est un site, donc une commune. Senlis est passée de 64 à 1 882 salariés du secteur entre 2019 et 2025, soit quatre emplois du secteur pour cinq à Lille, avec quinze fois moins d’habitants ; Avion de 163 à 875. Une seule implantation modifie durablement le profil économique d’une commune entière, sans effet mesurable sur ses voisines.

Ce même mécanisme impose la prudence sur les rythmes. Loos affiche une croissance de 72,6 % par an depuis 2023, Comines de 63,4 % : dans les deux cas, l’effectif de départ tenait en moins d’une centaine de postes. Les cartes écartent d’ailleurs les communes sous 50 emplois pour cette raison. Il faut ajouter que l’URSSAF rattache les salariés à la commune d’immatriculation de l’établissement : un changement de code d’activité ou un transfert administratif produit la même envolée qu’une ouverture réelle.

Les reculs racontent l’histoire symétrique. Lillers tombe de 402 à 129 salariés depuis 2023, Roubaix de 632 à 282 depuis 2019. Ces communes portaient des activités de messagerie et de fret insérées dans le tissu urbain ancien, que les plateformes périurbaines absorbent à mesure qu’elles se massifient.

Un dernier chiffre remet ces communes à leur place : les 88 retenues ne rassemblent que 37 614 des 80 957 salariés régionaux, soit 46 %. La majorité de l’emploi logistique se situe hors des villes de plus de 5 000 habitants, dispersée dans le périurbain des échangeurs autoroutiers — là précisément où les politiques d’aménagement se décident à l’échelle intercommunale plutôt que communale.

Voir le code
# Tableau récap par commune — top par volume (rt_table avec barres)
tab_comm <- sect_comm_wide |>
  transmute(
    libelle       = LIBGEO,
    departement   = DEPlib,
    pop           = P22_POP,
    emp_sect      = emp_sect,
    part_sect_pct = part_sect_pct,
    is_sect       = is_sect / 100,   # ratio France = 1 (conv synthese)
    tcam_court    = tcam_court,
    tcam_long     = tcam_long
  ) |>
  slice_head(n = 30)  # Top 30 communes par effectif secteur

rt_table(
  data = tab_comm,
  cols = list(
    # 8 colonnes — le tableau le plus large de la fiche : plancher resserré au maximum (cf ZE).
    libelle        = rt_col_lib(tab_comm, "libelle", "Commune",
                                 indent = FALSE, mw = 145),
    departement    = colDef(name = "DEP", maxWidth = 110,
                            style = list(fontSize = "11px", color = "#666")),
    emp_sect       = rt_col_level(tab_comm, "emp_sect",
                                   sprintf("Emp. %d", p_evol[2]), unit = "n",
                                   bw = "fill", bh = 16, mw = 118),
    tcam_court     = rt_col_var(tab_comm, "tcam_court",
                                 sprintf("TCAM %s-%s", substr(p_evol[1], 3, 4), substr(p_evol[2], 3, 4)),
                                 unit = "%/an",
                                 bw = "fill", bh = 16, mw = 100),
    tcam_long      = rt_col_var(tab_comm, "tcam_long",
                                 sprintf("TCAM %s-%s", substr(ann_base, 3, 4), substr(p_evol[2], 3, 4)),
                                 unit = "%/an",
                                 bw = "fill", bh = 16, mw = 100),
    is_sect        = rt_col_idx(tab_comm, "is_sect", "Spécialisation", unit = "× nat.",
                                 bw = "fill", bh = 16, mw = 110, center = 1),
    pop            = rt_col_level(tab_comm, "pop", "Pop. 2022", unit = "hab",
                                   bw = "fill", bh = 16, mw = 100),
    part_sect_pct  = rt_col_level(tab_comm, "part_sect_pct", "Part secteur", unit = "%",
                                   bw = "fill", bh = 16, mw = 100)
  ),
  title = sprintf("Communes >5k hab. et ≥100 emp. secteur de %s — top %d par volume sectoriel",
                  params$territoire_focus, nrow(tab_comm)),
  subtitle = sprintf("Effectifs secteur cœur (%s) en %d · spécialisation = part commune / part France (France = 1) · TCAM = croissance annuelle moyenne",
                     .perim_lbl, p_evol[2]),
  searchable = TRUE,
  full_width = FALSE,
  footnote = sprintf("Sources : URSSAF-ACOSS (volume, part, IS, TCAM) · INSEE RP %d (population) · TDC commune. Périmètre filtré aux communes ≥ %s habitants et ≥ 100 emplois sectoriels.",
                    2022, format(SEUIL_POP, big.mark = " "))
)
Communes >5k hab. et ≥100 emp. secteur de Hauts-de-France — top 30 par volume sectoriel
Effectifs secteur cœur (flux APE, 11 sous-classes) en 2025 · spécialisation = part commune / part France (France = 1) · TCAM = croissance annuelle moyenne
⇅ triable
Sources : URSSAF-ACOSS (volume, part, IS, TCAM) · INSEE RP 2022 (population) · TDC commune. Périmètre filtré aux communes ≥ 5 000 habitants et ≥ 100 emplois sectoriels.

Lecture : barre = volume relatif à la plus grande commune affichée. au-dessus de ×1,1 = commune sur-spécialisée par rapport à la France ; sous ×0,9 = sous-spécialisée. Les dynamiques sont exprimées en TCAM (%/an). Filtre : communes ≥ 5 000 habitants en 2022 et ≥ 100 emplois du secteur, ce qui réduit le bruit des très petits effectifs. Tri par volume décroissant, top 30.

Voir le code
cat(glue::glue(
  "### Signature communale : où chaque sous-activité se concentre-t-elle ?\n\n",
  "À l'échelle communale (communes d'au moins 100 emplois du secteur), la heatmap révèle des ",
  "**pôles spécialisés** : telle commune concentre l'entreposage, telle autre le fret ou la messagerie ",
  "*(tableau ci-dessous, indice de spécialisation France = 1,0)*.\n"
))

Signature communale : où chaque sous-activité se concentre-t-elle ?

À l’échelle communale (communes d’au moins 100 emplois du secteur), la heatmap révèle des pôles spécialisés : telle commune concentre l’entreposage, telle autre le fret ou la messagerie (tableau ci-dessous, indice de spécialisation France = 1,0).

Voir le code
jcp_lq_ape_heatmap(
  dat$urssaf_ape, dat$urssaf, tdc, ape_codes,
  ape_lib  = setNames(.ape_lbl(ape_codes), ape_codes),
  year     = ann_ref, geo = "commune", geo_focus = params$territoire_codes,
  min_emp  = 100, max_rows = 45,
  # Cap couleur ADAPTATIF : le ×8 par défaut (calibré ZE) saturait toutes les cellules de ce top 45
  # de communes-pôles (LQ 12,8 → 1600) → heatmap en aplat rouge uniforme. "auto" = P90 des valeurs
  # affichées, la gradation revient. La maille ZE, elle, garde son ×8 : ses LQ restent sous ~20 et
  # les deux sens (sur/sous-représenté) y existent vraiment. 260831
  cap_factor = "auto",
  geo_lib  = setNames(tdc$LIBGEO, tdc$CODGEO),
  show_div = TRUE,
  title    = sprintf("Communes-pôles : la signature d'activités des communes les plus spécialisées de %s", params$territoire_focus),
  subtitle = sprintf("Indice de spécialisation (LQ, France = 1,0, échelle log) par sous-classe APE · communes ≥ 100 emplois secteur · top 45 · URSSAF %d", ann_ref),
  # Footnote adaptée au régime réel de CE tableau : trié sur les communes les plus spécialisées, il
  # ne contient (presque) que des sur-représentations — expliquer le bleu en premier serait décrire
  # des cellules qui n'existent pas ici. La lecture porte donc sur l'INTENSITÉ du rouge.
  footnote = "Lecture : tableau limité aux communes-pôles, donc presque tout y est sur-représenté (rouge) — l'intensité de la couleur gradue l'ampleur (échelle log, saturée au P90 des valeurs affichées). Un 108 = la part locale de l'activité est 108 fois sa part nationale, marque d'une commune mono-site. Bleu (rare) = sous-représenté. En-têtes groupés par division NA88 · cellule vide = activité absente.")
Communes-pôles : la signature d'activités des communes les plus spécialisées de Hauts-de-France
Indice de spécialisation (LQ, France = 1,0, échelle log) par sous-classe APE · communes ≥ 100 emplois secteur · top 45 · URSSAF 2025
⇅ triable
0,0 1,0 97,0
Lecture : tableau limité aux communes-pôles, donc presque tout y est sur-représenté (rouge) — l'intensité de la couleur gradue l'ampleur (échelle log, saturée au P90 des valeurs affichées). Un 108 = la part locale de l'activité est 108 fois sa part nationale, marque d'une commune mono-site. Bleu (rare) = sous-représenté. En-têtes groupés par division NA88 · cellule vide = activité absente.
Voir le code
# Nombre de ZE du focus (le poids/dynamique régionaux viennent des AGRÉGATS reg_part /
# reg_tcam_rec calculés en 06 — PAS d'une moyenne de ratios par ZE qui surpondère les petites ZE).
terr_n_ze <- ze_data |> sf::st_drop_geometry() |>
  filter(code_ze %in% ze_terr_focus, !is.na(part)) |> nrow()
Voir le code
# Briques GÉNÉRIQUES de la synthèse (aucune valeur sector-specific en dur) :
#   (a) contrastes internes entre divisions NAF cœur · (b) poids du public · (c) qualité d'emploi.
.naf_prof  <- eec_individuels |> filter(label %in% naf_lbls)
.multi_naf <- nrow(.naf_prof) >= 2
if (.multi_naf) {
  .pub_max <- .naf_prof |> slice_max(lab_pub_pct, n = 1); .pub_min <- .naf_prof |> slice_min(lab_pub_pct, n = 1)
  .fem_max <- .naf_prof |> slice_max(lab_fem_pct, n = 1); .fem_min <- .naf_prof |> slice_min(lab_fem_pct, n = 1)
}

# Poids du secteur public = part de l'emploi total (FLORES) hors champ privé (URSSAF), millésime FLORES commun
.us_flref    <- sum(dat$urssaf$value[dat$urssaf$code_a88 %in% proxy_a88 & dat$urssaf$year == .fl_ref], na.rm = TRUE)
.fl_flref    <- sum(dat$flores_com$value[dat$flores_com$code_a88 %in% proxy_a88 & dat$flores_com$year == .fl_ref], na.rm = TRUE)
public_share <- if (is.finite(.fl_flref) && .fl_flref > 0) max(0, (1 - .us_flref / .fl_flref) * 100) else NA_real_

# Qualité d'emploi : compte de marqueurs nettement au-dessus de la moyenne nationale
.n_fragile <- sum(c(delta_qualif$tpart > 2, delta_qualif$cdd > 1, delta_qualif$souempl > 0.5))
.q_verdict <- if (.n_fragile >= 2) {
  "Ces marqueurs, plus marqués que dans le reste de l'économie, signalent des conditions d'emploi plus fragiles."
} else {
  "Le secteur reste globalement dans la moyenne nationale sur ces dimensions."
}

log_result("synth_generic", list(public_share = round(public_share, 1), n_fragile = .n_fragile,
  contrast = if (.multi_naf) sprintf("pub %s vs %s · fem %s vs %s", .pub_max$label, .pub_min$label, .fem_max$label, .fem_min$label) else "mono-division"))

Hauts-de-France — 22 zones d’emploi : - Part du secteur dans l’emploi privé local : 5,3 % (vs 3,7 % moyenne nationale) - Dynamique 2023-2025 : +2,1 %/an (vs +0,9 %/an national)

Grands établissements : où se concentrent les employeurs du secteur ?

La cartographie des établissements SIRENE situe physiquement les grands employeurs la logistique du territoire, au-delà des volumes agrégés par zone d’emploi. Chaque point est un établissement (un SIRET) d’au moins 100 salariés, dimensionné par sa tranche d’effectif et coloré par son activité précise.

Voir le code
source("scripts/sp-psect-sirene-etab_260609.R")
# `proxy_a88` = divisions NA88 du cœur sectoriel, résolu depuis params$filiere par _setup-moteur.R
etab_sir <- load_sirene_etab(proxy_a88, params$territoire_codes,
                             terr_type = params$territoire_type %||% "DEP",
                             seuil_tranche = "21",
                             # Périmètre EXACT : codes APE curatés (exclut services aux. hors flux,
                             # La Poste publique ; inclut le fret div. 49). Fallback division si non curaté.
                             naf_ape = if (isTRUE(has_ape_curated)) coeur_ape else NULL) |>
  dplyr::filter(eff_min >= 100)

# COULEUR = GRANDE DIVISION, pas la classe NAF fine (user 260828). L'ancien mapping recollait les
# libellés courts des sous-classes par « / » (« Fret interurbain/Fret proximité/Location camions ») :
# une légende de 6-8 entrées composites, illisible et différente à chaque filière. La division NA88
# (via `.naf_lbl`, mêmes libellés que le cartel d'ouverture et la treemap) donne 2 à 4 familles
# franches — pour la logistique : Transport terrestre / Entreposage / Poste-courrier. Le DÉTAIL de
# l'activité (sous-classe APE) n'est pas perdu : il reste dans le tooltip de chaque point.
etab_sir <- etab_sir |>
  dplyr::mutate(
    act = .naf_lbl(substr(ape, 1, 2)),
    tip = paste0("<b>", tolower(nom), "</b><br/>",
                 tools::toTitleCase(tolower(lib_commune)), " · ", tr_label, " sal.<br/>",
                 ape_court, "<br/><i>", statut, "</i>"))

# Garde-fou robustesse conservé : une filière atypique qui éclaterait quand même en trop de familles
# (légende plus haute que la carte → coord_sf comprime le panneau → ggrepel plante sur viewport nul)
# retombe sur les .MAX_ACT plus fréquentes + « Autres activités ».
.MAX_ACT <- 6
if (dplyr::n_distinct(etab_sir$act) > .MAX_ACT) {
  .keep_act <- etab_sir |> dplyr::count(act, sort = TRUE) |>
    dplyr::slice_head(n = .MAX_ACT) |> dplyr::pull(act)
  etab_sir$act <- ifelse(etab_sir$act %in% .keep_act, etab_sir$act, "Autres activités")
}

etab_sf <- sf::st_as_sf(etab_sir, coords = c("lon", "lat"), crs = 4326) |>
  sf::st_transform(2154)

aa_sir_n      <- nrow(etab_sf)
aa_sir_nferme <- sum(etab_sir$statut == "Fermé récent")
.bigsir       <- etab_sir |> dplyr::arrange(dplyr::desc(eff_min)) |> dplyr::slice_head(n = 1)
aa_sir_top    <- tools::toTitleCase(tolower(.bigsir$nom))
aa_sir_topcom <- tools::toTitleCase(tolower(.bigsir$lib_commune))

En Hauts-de-France, 161 établissements du secteur emploient au moins 100 salariés (figure ci-contre). Le plus gros, Amazon France Logistique Sas à Lauwin-Planque, ancre l’emploi sectoriel local ; la couleur distingue l’activité fine, la taille la tranche d’effectif, et un liseré rouge signale les 4 fermetures récentes.

Voir le code
p_sir <- build_map_points(
  etab_sf, "act", fill_mode = "cat",
  size_col = "eff_min", size_range = c(1.5, 8.5), size_title = "Effectif salarié",
  size_breaks = c(100, 250, 500, 1000, 5000),
  size_labels = c("100", "250", "500", "1 000", "5 000+"),
  label_col = "nom", n_top = 10, label_value = FALSE, label_lower = TRUE,
  label_style = "text",
  bg_geo = shp_ze_focus, bg_fill = "#f4f2ec",
  interactive = knitr::is_html_output(), tooltip_col = "tip",
  legend_title = "Famille d'activités",   # = division NA88 (cf sirene_data) ; le détail APE est au survol
  titre = sprintf("Les grands employeurs %s en %s", sect_court, params$territoire_focus),
  subtitle = "Établissements de 100 salariés et plus · taille = effectif · 10 plus gros nommés",
  source_txt = make_source("INSEE SIRENE (stock établissements)"))
log_result("sirene_etab", list(n = aa_sir_n, n_ferme = aa_sir_nferme, top = aa_sir_top))
if (knitr::is_html_output()) render_girafe(p_sir, width_svg = 6.5, height_svg = 5.5) else p_sir

Les grands employeurs suivent l’armature urbaine et les chefs-lieux départementaux, où se concentrent hôpitaux, structures médico-sociales et services à domicile. Le survol de chaque point révèle son nom, sa commune, son effectif et son activité exacte.

Lecture : SIRENE fournit une tranche d’effectif (50-99, 100-199…), jamais l’effectif exact — la taille des points est donc indicative. L’état « fermé » mêle vraies cessations et transferts administratifs de SIRET ; les fermetures récentes (depuis 2021) sont signalées à titre indicatif. Les établissements sans coordonnées (environ un cinquième) sont reportés au centroïde de leur commune.

En synthèse — l’essentiel en chiffres

Voir le code
# Comparaison de DEUX TAUX : seuil absolu (fr_ecart_qualif relatif explose si le réf ≈ 0)
.dir1 <- dplyr::case_when(
  tcam_us > tcam_tot + 0.3 ~ "nettement plus rapide que",
  tcam_us < tcam_tot - 0.3 ~ "plus lent que",
  TRUE                     ~ "comparable à")
.dir3 <- dplyr::case_when(
  reg_tcam_rec > tcam_us + 0.3 ~ "plus dynamique que",
  reg_tcam_rec < tcam_us - 0.3 ~ "moins dynamique que",
  TRUE                         ~ "proche de")

.m <- c(
  glue::glue("**Une croissance soutenue** — {sect_phrase_cap} progresse de **{fmt_sign_1dec(z0(tcam_us))} % par an** sur {p_evol[1]}-{p_evol[2]} (URSSAF privé), un rythme {.dir1} celui de l'emploi privé total (**{fmt_sign_1dec(z0(tcam_tot))} % par an**)."),
  glue::glue("**Un profil d'emploi singulier** — **{fmt_pct_1dec(eec_sect$lab_fem_pct)} de femmes** (contre {fmt_pct_1dec(eec_fr$lab_fem_pct)} tous secteurs) et **{fmt_pct_1dec(eec_sect$lab_tpart_pct)} de temps partiel** (contre {fmt_pct_1dec(eec_fr$lab_tpart_pct)} au national)."),
  glue::glue("**Le focus {params$territoire_focus}** — Le secteur y pèse **{fmt_pct_1dec(reg_part)}** de l'emploi privé local (contre {fmt_pct_1dec(part_secteur)} en moyenne nationale) et y progresse de **{fmt_sign_1dec(z0(reg_tcam_rec))} % par an**, un rythme {.dir3} la moyenne nationale.")
)
if (.multi_naf) .m <- c(.m, glue::glue(
  "**Des sous-segments contrastés** — **{.pub_max$label}** est la composante la plus publique ({fmt_pct_1dec(.pub_max$lab_pub_pct)} d'emploi public) quand **{.pub_min$label}** relève surtout du privé ({fmt_pct_1dec(.pub_min$lab_pub_pct)}) ; **{.fem_max$label}** est la plus féminisée ({fmt_pct_1dec(.fem_max$lab_fem_pct)}) face à **{.fem_min$label}** ({fmt_pct_1dec(.fem_min$lab_fem_pct)})."))
.m <- c(.m,
  glue::glue("**Qualité et stabilité de l'emploi** — La part de CDD atteint **{fmt_pct_1dec(eec_sect$lab_cdd_pct)}** (contre {fmt_pct_1dec(eec_fr$lab_cdd_pct)} au national) et le sous-emploi **{fmt_pct_1dec(eec_sect$lab_souempl_pct)}** (contre {fmt_pct_1dec(eec_fr$lab_souempl_pct)}). {.q_verdict}"),
  glue::glue("**Deux périmètres de mesure** — Environ **{fmt_pct_1dec(public_share)}** de l'emploi du secteur échappe à l'URSSAF (emploi public, hors champ privé) : la dynamique s'appuie sur l'**emploi privé** (URSSAF, {ann_ref}), la spécialisation sur l'**emploi total** (FLORES, privé + public, {.fl_ref}). Les particuliers employeurs (emploi à domicile direct) restent hors champ des deux sources.")
)
cat(paste(sprintf("%d. %s", seq_along(.m), .m), collapse = "\n\n"))
  1. Une croissance soutenue — Le secteur de la logistique progresse de +0,9 % par an sur 2023-2025 (URSSAF privé), un rythme nettement plus rapide que celui de l’emploi privé total (0,0 % par an).

  2. Un profil d’emploi singulier21,3 % de femmes (contre 48,8 % tous secteurs) et 9,5 % de temps partiel (contre 17,6 % au national).

  3. Le focus Hauts-de-France — Le secteur y pèse 5,3 % de l’emploi privé local (contre 3,7 % en moyenne nationale) et y progresse de +2,1 % par an, un rythme plus dynamique que la moyenne nationale.

  4. Des sous-segments contrastésTransport terrestre est la composante la plus publique (21,5 % d’emploi public) quand Entreposage relève surtout du privé (12,4 %) ; Entreposage est la plus féminisée (27,6 %) face à Transport terrestre (18,1 %).

  5. Qualité et stabilité de l’emploi — La part de CDD atteint 4,8 % (contre 7,6 % au national) et le sous-emploi 2,7 % (contre 4,3 %). Le secteur reste globalement dans la moyenne nationale sur ces dimensions.

  6. Deux périmètres de mesure — Environ 0,0 % de l’emploi du secteur échappe à l’URSSAF (emploi public, hors champ privé) : la dynamique s’appuie sur l’emploi privé (URSSAF, 2025), la spécialisation sur l’emploi total (FLORES, privé + public, 2024). Les particuliers employeurs (emploi à domicile direct) restent hors champ des deux sources.

Sources & méthodologie

  • URSSAF-ACOSS (URSSAF-COM-tidy-A88.parquet) — Effectifs salariés privés communaux par NA88, 2006-2024
  • INSEE FLORES (FLORES-COM-tidy-A88.parquet) — Effectifs salariés total (privé + public, hors PE), 2017-2024
  • INSEE EEC (EEC-AGG-naf88-annee.parquet) — Profil emploi national par NA88, millésimes 2022 + 2024
  • INSEE MOBZELT 2022 (MOBZELT-AGG-naf88-ze.parquet) — Profil territorial ZE2020 × NA88

Convention indicateurs : eco_emppriv_* (URSSAF privé), eco_empstot_* (FLORES total), lab_* (profil EEC/MOBZELT). Validation cross-source ±10% sur écarts FLORES-EEC pour le secteur.

Limites V1 : EEC limité aux millésimes 2022+2024 (sans recodage PCS 2003→2020 pour antériorité). FLORES non encore intégré au pipeline PTB transverse (consommation directe du tidy DBD).

Modèle de référence : tplj-modele-plan-fiche-sectoriel.md (structure cadre psect-fiche).