Une carte entre dans le foyer, et la première couche y pose ses ZFE

Le socle d'abord : un contrat `ElementCarte`, un registre où une couche
est un fichier et une ligne, et un rendu Leaflet qui ne sait rien des
ZFE — c'est ce qui permettra d'ajouter la sécheresse, l'iode et les
PPRT sans rouvrir le composant.

Trois tons et pas une palette : ce qui te concerne, ce qui te
concernera à une date connue, ce qui existe à côté. La carte ne compte
rien, ne coche rien, ne se tient pas à jour.

Sous son seuil, un contour de quelques pixels ment sur la forme sans
rien apprendre : la pastille prend le relais. Le seuil se calcule par
objet, pas par une constante.

Rien n'est demandé au réseau avant un clic — ni Leaflet, ni une tuile
IGN. Les tuiles sont demandées par le navigateur, donc l'IP du visiteur
y arrive : sur une fiche publique ce serait subi, après un clic c'est
demandé.

Le nom des agglomérations est écrit à la main et en git : la Base
Nationale ne renseigne `nom` que sur 2 zones sur 35, et choisir
« Eurométropole de Strasbourg » est un jugement, pas un pipeline
(règle de tri de D-004). Les 24 périmètres relevés sont couverts et
testés.

L'aperçu d'un foyer non abonné montre l'ancre et des contours gris : où,
jamais quoi — la révélation graduée du § 10.2 appliquée à la carte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gautier Stefanini 2026-07-29 10:48:19 +00:00
parent 1be8756773
commit e9ddddaaf6
18 changed files with 1357 additions and 2 deletions

View file

@ -2,6 +2,10 @@
Les zones à faibles émissions ne s'appliquent pas d'un coup : chaque collectivité publie un calendrier qui étend progressivement la restriction aux classes Crit'Air suivantes. Ces dates figurent dans des arrêtés déjà pris, parfois plusieurs années à l'avance — elles sont publiques, opposables, et pratiquement personne ne les surveille.
## Les zones publiées, et leur calendrier
<!-- carte:zfe -->
## Pourquoi c'est une échéance et pas une information
Un durcissement annoncé pour dans dix-huit mois ne demande aucune action immédiate. Il change en revanche la fenêtre dans laquelle il vaut mieux prendre certaines décisions :

View file

@ -2,6 +2,10 @@
Une zone à faibles émissions mobilité restreint la circulation des véhicules les plus polluants sur un périmètre défini par la collectivité. Apprendre que sa voiture fait partie des classes refusées n'est pas une bonne nouvelle — mais c'est presque toujours moins bloquant qu'il n'y paraît, à condition de lire l'arrêté local.
## Où sont les zones, et ce qu'elles refusent
<!-- carte:zfe -->
## Trois choses à vérifier avant toute conclusion
**Les horaires.** Beaucoup de zones ne restreignent qu'en semaine, en journée. Une zone active « du lundi au vendredi de 8 h à 20 h » laisse les soirées et les week-ends entièrement libres — ce qui suffit à la majorité des trajets occasionnels.

25
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@react-email/render": "^2.1.0",
"better-auth": "^1.6.24",
"drizzle-orm": "^0.45.2",
"leaflet": "^1.9.4",
"lucide-react": "^1.26.0",
"marked": "^18.0.7",
"next": "^15.3.0",
@ -27,6 +28,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.6",
"@tailwindcss/postcss": "^4.3.3",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.0",
@ -3259,6 +3261,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3273,6 +3282,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@ -7052,6 +7071,12 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View file

@ -27,6 +27,7 @@
"@react-email/render": "^2.1.0",
"better-auth": "^1.6.24",
"drizzle-orm": "^0.45.2",
"leaflet": "^1.9.4",
"lucide-react": "^1.26.0",
"marked": "^18.0.7",
"next": "^15.3.0",
@ -42,6 +43,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.6",
"@tailwindcss/postcss": "^4.3.3",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.0",

View file

@ -0,0 +1,70 @@
import { chargerCarte } from "@/carte/registre";
import { assetsDuFoyer } from "@/carte/couches/zfe";
import { Carte } from "@/components/carte/carte";
import { SectionTitle } from "@/components/ui/primitives";
/**
* La carte du foyer ce que la réglementation dessine autour d'une adresse.
*
* Elle ne répond qu'à une question, et c'est ce qui la justifie : **suis-je dedans ?** Aucune
* couche n'est là parce qu'elle est jolie ; chacune est un périmètre dont la frontière EST la
* règle. Une donnée attachée à la commune (radon, arrêtés CatNat) n'y a pas sa place on sait
* déjà on habite, la colorier n'apprend rien.
*
* Ce n'est pas un tableau de bord : rien à cocher, rien à tenir à jour, aucun compteur.
*
* L'aperçu d'un foyer non abonné montre **l'ancre seule** : les contours en gris disent qu'on a
* trouvé quelque chose près de chez lui, le clic n'ouvre rien. Montrer où sans dire quoi — c'est
* la révélation graduée du § 10.2 appliquée à la carte, l'inventaire sans le mode d'emploi.
*/
export async function CarteFoyer({
householdId,
logement,
unlocked,
}: {
householdId: string;
logement: { lat: number; lon: number } | null;
unlocked: boolean;
}) {
if (!logement) return null;
const assets = await assetsDuFoyer(householdId);
const { elements, couches } = await chargerCarte({
logement,
assets,
aujourdhui: new Date().toISOString().slice(0, 10),
echelle: "foyer",
});
if (!elements.length) return null;
const concernes = elements.filter((e) => e.ton === "concerne").length;
return (
<section>
<SectionTitle>Autour de chez toi</SectionTitle>
<p className="mt-2 text-muted">
{unlocked
? concernes > 0
? "Les périmètres réglementaires à portée de ton adresse. Ceux qui te concernent sont en trait plein."
: "Les périmètres réglementaires à portée de ton adresse. Aucun ne te concerne aujourd'hui."
: "Des périmètres réglementaires touchent les environs de ton adresse. Le détail fait partie de l'abonnement."}
</p>
<div className="mt-5">
<Carte
elements={unlocked ? elements : elements.map((e) => ({ ...e, ton: "voisin" as const, detail: [] }))}
couches={couches}
ancre={logement}
vue="foyer"
detailsVisibles={unlocked}
invite={
unlocked
? "Zones à faibles émissions et périmètres réglementaires autour de ton logement."
: "Situe ton logement et les périmètres réglementaires des environs."
}
/>
</div>
</section>
);
}

View file

@ -17,6 +17,7 @@ import { TimelineAnnee, LegendePiliers, type PointAnnee } from "@/components/tim
import type { Asset, AssetType, Pilier } from "@/knowledge/schema";
import { Activer, LienVersActiver } from "./activer";
import { Declarations } from "./declarations";
import { CarteFoyer } from "./carte-foyer";
import { Rappels } from "./rappels";
import { GarderLien } from "./garder-lien";
import { MemoriserFoyer } from "./memoriser-foyer";
@ -162,7 +163,14 @@ export default async function FoyerPage({
}));
const hub = await buildHub(id);
// Un seul logement possible par foyer à ce jour — pas de liste à gérer.
const adresseLabel = assets.find((a) => a.type === "logement")?.attributes.adresseLabel as string | undefined;
const logement = assets.find((a) => a.type === "logement");
const adresseLabel = logement?.attributes.adresseLabel as string | undefined;
// L'ancre de la carte. Absente quand l'écran d'adresse du quiz a été passé : la carte ne
// s'affiche alors pas du tout, plutôt que de centrer sur un point par défaut qui ne veut rien dire.
const lat = Number(logement?.attributes.lat);
const lon = Number(logement?.attributes.lon);
const pointLogement = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
// Ne jamais redemander une adresse déjà donnée : la relance serait vécue comme un péage.
const adresseConnue = Boolean(session) || (await hasLead(id));
@ -277,6 +285,9 @@ export default async function FoyerPage({
<Container width="wide" className="pb-8">
<div className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_19rem] xl:gap-10">
<div className="min-w-0 space-y-10">
{/* ── Ce que la réglementation dessine autour de l'adresse ───── */}
<CarteFoyer householdId={id} logement={pointLogement} unlocked={unlocked} />
{/* ── Les prochaines, entièrement révélées ──────────────────── */}
{/* Seule brèche volontaire dans « révéler l'inventaire, jamais le mode d'emploi »
(§ 10.2) : un échantillon borné (2-3 échéances RÉELLES du foyer, en entier) plutôt

View file

@ -0,0 +1,44 @@
import { chargerCarte } from "@/carte/registre";
import { coucheParId } from "@/carte/registre";
import { Carte } from "@/components/carte/carte";
/**
* La carte d'une fiche : les mêmes couches, sans foyer.
*
* Rien n'est dupliqué du hub `chargerCarte` est appelée avec un contexte sans logement ni
* assets, ce qui met mécaniquement tout en ton « voisin » : sur une page publique, il n'y a
* personne à concerner. C'est l'intérêt d'avoir mis le ton dans la couche et non dans le rendu.
*
* Elle est déclarée dans le markdown de la fiche par un jeton `<!-- carte:zfe -->`, pour que
* l'auteur choisisse elle tombe dans son texte. Les fiches restent du markdown : pas de MDX,
* pas de chaîne d'outils en plus.
*/
export async function CarteGuide({ coucheId }: { coucheId: string }) {
if (!coucheParId(coucheId)) return null;
const { elements, couches } = await chargerCarte({
logement: null,
assets: [],
aujourdhui: new Date().toISOString().slice(0, 10),
echelle: "france",
});
const retenus = elements.filter((e) => e.couche === coucheId);
if (!retenus.length) return null;
return (
<figure className="my-8">
<Carte
elements={retenus}
couches={couches.filter((c) => c.id === coucheId)}
vue="france"
detailsVisibles
invite={`Les ${retenus.length} périmètres publiés en France, avec ce qui y est refusé et depuis quand.`}
/>
<figcaption className="mt-2 text-sm text-faint">
Source : Base Nationale des ZFE (transport.data.gouv.fr), resynchronisée chaque semaine.
L&apos;arrêté de la collectivité fait foi.
</figcaption>
</figure>
);
}

View file

@ -10,6 +10,7 @@ import { Container } from "@/components/ui/primitives";
import { buttonClass } from "@/components/ui/button-styles";
import { RetourFoyer } from "./retour-foyer";
import { RelatedGuides, ListeLiee } from "./related-guides";
import { CarteGuide } from "./carte-guide";
export function generateStaticParams() {
return loadSeoPages().map((p) => ({ slug: p.slug }));
@ -37,6 +38,17 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
// par cette page : seul son habillage (badge, encart, guides liés) est enrichi ici.
const html = await marked.parse(page.markdown);
/**
* `<!-- carte:zfe -->` dans le markdown place la carte de la couche nommée. Le jeton est
* cherché dans le HTML rendu et non dans le markdown source : `marked` laisse les commentaires
* HTML intacts, et couper le HTML garantit qu'on ne scinde jamais un paragraphe en deux.
*/
const jeton = html.match(/<!--\s*carte:([a-z-]+)\s*-->/);
const coucheDemandee = jeton?.[1] ?? null;
const [avantCarte, apresCarte] = jeton
? [html.slice(0, jeton.index), html.slice(jeton.index! + jeton[0].length)]
: [html, ""];
const pilier = PILIER_META[page.template.pilier];
const enjeu = ENJEU_META[page.template.enjeu.type];
const EnjeuIcon = enjeu.Icon;
@ -97,7 +109,15 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
</div>
)}
<article className="prose-fokan mt-6" dangerouslySetInnerHTML={{ __html: html }} />
{/*
Le markdown peut demander une carte à l'endroit qu'il choisit, par un jeton
`<!-- carte:<couche> -->`. On coupe le HTML rendu en deux et on insère le composant
entre les morceaux : l'auteur garde la main sur le placement sans qu'on ait à
basculer les 72 fiches en MDX.
*/}
<article className="prose-fokan mt-6" dangerouslySetInnerHTML={{ __html: avantCarte }} />
{coucheDemandee && <CarteGuide coucheId={coucheDemandee} />}
{apresCarte && <article className="prose-fokan" dangerouslySetInnerHTML={{ __html: apresCarte }} />}
{!unlocked && (
<aside className="surface-card mt-12 bg-surface-alt p-6 text-center sm:p-8">

View file

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import type { ZoneZfe } from "@/knowledge/zfe";
import { tonDuPerimetre } from "./zfe";
const AUJOURDHUI = "2026-07-29";
function phase(p: Partial<ZoneZfe>): ZoneZfe {
return {
zfeId: "x", nom: null, vpCritair: null, vpHoraires: null,
dateDebut: null, dateFin: null, urlInfo: null, urlArrete: null,
centre: null, rayonZoneKm: null, contour: null,
...p,
};
}
const CRITAIR4 = { label: "PEUGEOT 308", classe: "4" as const };
const CRITAIR1 = { label: "TESLA MODEL 3", classe: "1" as const };
describe("tonDuPerimetre", () => {
it("dit « concerne » quand une phase en vigueur vise un véhicule du foyer", () => {
const { ton, detail } = tonDuPerimetre(
[phase({ vpCritair: "V4", dateDebut: "2024-01-01" })],
[CRITAIR4],
AUJOURDHUI,
);
expect(ton).toBe("concerne");
expect(detail).toContainEqual({ ligne: "Refusé aujourd'hui", valeur: "Crit'Air 4 et au-delà" });
expect(detail).toContainEqual({ ligne: "Depuis le", valeur: "01/01/2024" });
expect(detail).toContainEqual({ ligne: "Ton véhicule concerné", valeur: "PEUGEOT 308" });
});
it("dit « voisin » quand aucun véhicule n'est visé", () => {
const { ton } = tonDuPerimetre(
[phase({ vpCritair: "V4", dateDebut: "2024-01-01" })],
[CRITAIR1],
AUJOURDHUI,
);
expect(ton).toBe("voisin");
});
it("dit « a_venir » quand seul un durcissement daté visera le véhicule", () => {
const { ton, detail } = tonDuPerimetre(
[
phase({ vpCritair: "V4", dateDebut: "2024-01-01" }),
phase({ vpCritair: "V2", dateDebut: "2028-01-01" }),
],
[CRITAIR1], // Crit'Air 1 n'est visé ni par V4 ni par V2… mais V2 vise 2 et au-delà
AUJOURDHUI,
);
expect(ton).toBe("voisin");
expect(detail).toContainEqual({ ligne: "Ensuite", valeur: "Crit'Air 2 et au-delà au 01/01/2028" });
});
it("bascule en « a_venir » pour un Crit'Air 2 face à un durcissement V2 programmé", () => {
const { ton } = tonDuPerimetre(
[
phase({ vpCritair: "V4", dateDebut: "2024-01-01" }),
phase({ vpCritair: "V2", dateDebut: "2028-01-01" }),
],
[{ label: "RENAULT CLIO", classe: "2" }],
AUJOURDHUI,
);
expect(ton).toBe("a_venir");
});
it("ignore une phase expirée", () => {
const { ton } = tonDuPerimetre(
[phase({ vpCritair: "V4", dateDebut: "2022-01-01", dateFin: "2024-12-31" })],
[CRITAIR4],
AUJOURDHUI,
);
expect(ton).toBe("voisin");
});
it("n'invente rien quand la source ne publie aucun seuil voiture", () => {
const { ton, detail } = tonDuPerimetre(
[phase({ vpCritair: null, dateDebut: "2019-05-02" })],
[CRITAIR4],
AUJOURDHUI,
);
expect(ton).toBe("voisin");
expect(detail).toContainEqual({
ligne: "Refusé aujourd'hui",
valeur: "non précisé pour les voitures par la source",
});
});
it("nomme tous les véhicules visés, jamais leur nombre", () => {
const { detail } = tonDuPerimetre(
[phase({ vpCritair: "V4", dateDebut: "2024-01-01" })],
[CRITAIR4, { label: "CITROEN C3", classe: "5" }],
AUJOURDHUI,
);
expect(detail).toContainEqual({ ligne: "Tes véhicules concernés", valeur: "PEUGEOT 308, CITROEN C3" });
});
it("reporte les horaires, qui décident souvent à eux seuls", () => {
const { detail } = tonDuPerimetre(
[phase({ vpCritair: "V4", dateDebut: "2024-01-01", vpHoraires: "lun-ven 8h-20h" })],
[CRITAIR4],
AUJOURDHUI,
);
expect(detail).toContainEqual({ ligne: "Horaires", valeur: "lun-ven 8h-20h" });
});
it("traite un foyer sans véhicule comme un voisinage, sans erreur", () => {
const { ton } = tonDuPerimetre([phase({ vpCritair: "V4", dateDebut: "2024-01-01" })], [], AUJOURDHUI);
expect(ton).toBe("voisin");
});
});

215
src/carte/couches/zfe.ts Normal file
View file

@ -0,0 +1,215 @@
import { eq } from "drizzle-orm";
import { db, assets as assetsTable, zfeZones } from "@/db";
import { classeInterdite, classerCritair, CLASSES_CRITAIR, type ClasseCritair } from "@/knowledge/critair";
import { distanceKm, RAYON_ALERTE_KM, type Point, type ZoneZfe } from "@/knowledge/zfe";
import { nommerZfe } from "@/knowledge/zfe-noms";
import { cadre, centreDuCadre, largeurKm, simplifier, zoomDuContour } from "../geometrie";
import { TOLERANCE, type ContexteCarte, type CoucheCarte, type ElementCarte, type LigneDetail, type Ton } from "../types";
/**
* La couche des zones à faibles émissions.
*
* C'est la seule dont toute la donnée était déjà en base (contours, seuil Crit'Air, dates) : le
* passage weekly `syncZfe` l'alimente depuis la Base Nationale des ZFE, purge comprise.
*
* Le ton d'une zone ne se décide pas ici en partant de zéro : `classeInterdite` est la règle de
* lecture des seuils publiés, déjà écrite et testée pour le moteur d'échéances. La carte
* consomme le moteur, elle ne le réimplémente pas sans quoi une carte et un rappel pourraient
* un jour dire deux choses différentes du même véhicule.
*/
/** La classe Crit'Air d'un véhicule, telle que le moteur la détermine ailleurs. */
function classeDuVehicule(attrs: Record<string, unknown>): ClasseCritair | undefined {
if ((CLASSES_CRITAIR as readonly string[]).includes(attrs.critair as string)) {
return attrs.critair as ClasseCritair;
}
return classerCritair({ energie: attrs.energie, anneeImmat: attrs.date_premiere_immat ?? attrs.annee });
}
const LIBELLE_SEUIL: Record<string, string> = {
NC: "les véhicules non classés",
V5: "Crit'Air 5 et au-delà",
V4: "Crit'Air 4 et au-delà",
V3: "Crit'Air 3 et au-delà",
V2: "Crit'Air 2 et au-delà",
V1: "Crit'Air 1 et au-delà",
};
function libelleSeuil(seuil: string | null): string | null {
if (!seuil) return null;
return LIBELLE_SEUIL[seuil.trim().toUpperCase()] ?? seuil;
}
function dateFr(iso: string | null): string | null {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
const [a, m, j] = iso.split("-");
return `${j}/${m}/${a}`;
}
function enVigueur(zone: ZoneZfe, aujourdhui: string): boolean {
if (zone.dateDebut && zone.dateDebut > aujourdhui) return false;
if (zone.dateFin && zone.dateFin < aujourdhui) return false;
return true;
}
/** Les zones de la base, dans la forme que le moteur manipule déjà. */
async function chargerZones(): Promise<ZoneZfe[]> {
const rows = await db().select().from(zfeZones);
return rows.map((z) => ({
zfeId: z.zfeId,
nom: z.nom,
vpCritair: z.vpCritair,
vpHoraires: z.vpHoraires,
dateDebut: z.dateDebut,
dateFin: z.dateFin,
urlInfo: z.urlInfo,
urlArrete: z.urlArrete,
centre: z.centreLat && z.centreLon ? { lat: Number(z.centreLat), lon: Number(z.centreLon) } : null,
rayonZoneKm: z.rayonKm === null ? null : Number(z.rayonKm),
contour: (z.contour as [number, number][] | null) ?? null,
}));
}
/**
* Les phases d'un même périmètre sont regroupées : la Base Nationale publie une ligne par
* durcissement (Strasbourg en a trois sur le même tracé), et trois contours superposés à
* l'écran ne sont pas trois zones — c'est une seule zone dont la règle change avec le temps.
* Le regroupement se fait sur le nom résolu ET le tracé, jamais sur le nom seul : quatre
* communes de l'Eurométropole ont leurs propres périmètres.
*/
function clePerimetre(zone: ZoneZfe): string {
const c = zone.centre;
return `${nommerZfe(zone) ?? zone.zfeId}|${c ? `${c.lat.toFixed(3)},${c.lon.toFixed(3)}` : "?"}|${zone.rayonZoneKm?.toFixed(1) ?? "?"}`;
}
type Vehicule = { label: string; classe: ClasseCritair | undefined };
/**
* Le ton d'un périmètre pour ce foyer, et le détail qui l'explique.
*
* Trois cas, dans cet ordre : une phase en vigueur vise un véhicule du foyer « concerne » ;
* une phase datée à venir le visera « a_venir » ; sinon « voisin ». Le troisième cas couvre
* aussi les zones dont la source ne publie aucun seuil pour les voitures (huit sur trente-cinq,
* souvent des ZFE réservées aux poids lourds) : on les montre, sans rien affirmer de plus.
*/
export function tonDuPerimetre(
phases: ZoneZfe[],
vehicules: Vehicule[],
aujourdhui: string,
): { ton: Ton; detail: LigneDetail[] } {
const detail: LigneDetail[] = [];
const actuelles = phases.filter((p) => enVigueur(p, aujourdhui));
const futures = phases
.filter((p) => p.dateDebut && p.dateDebut > aujourdhui)
.sort((a, b) => (a.dateDebut! < b.dateDebut! ? -1 : 1));
const seuilActuel = actuelles.find((p) => p.vpCritair)?.vpCritair ?? null;
const libelle = libelleSeuil(seuilActuel);
detail.push({
ligne: "Refusé aujourd'hui",
valeur: libelle ?? "non précisé pour les voitures par la source",
});
const depuis = dateFr(actuelles.find((p) => p.vpCritair)?.dateDebut ?? null);
if (depuis) detail.push({ ligne: "Depuis le", valeur: depuis });
const horaires = actuelles.find((p) => p.vpHoraires)?.vpHoraires;
if (horaires) detail.push({ ligne: "Horaires", valeur: horaires });
const vises = vehicules.filter((v) => actuelles.some((p) => classeInterdite(v.classe, p.vpCritair)));
if (vises.length) {
detail.push({ ligne: vises.length > 1 ? "Tes véhicules concernés" : "Ton véhicule concerné", valeur: vises.map((v) => v.label).join(", ") });
}
const prochaine = futures.find((p) => vehicules.some((v) => classeInterdite(v.classe, p.vpCritair)))
?? futures.find((p) => p.vpCritair);
if (prochaine?.dateDebut) {
const l = libelleSeuil(prochaine.vpCritair);
detail.push({ ligne: "Ensuite", valeur: `${l ?? "durcissement"} au ${dateFr(prochaine.dateDebut)}` });
}
if (vises.length) return { ton: "concerne", detail };
const viseeAVenir = futures.some((p) => vehicules.some((v) => classeInterdite(v.classe, p.vpCritair)));
return { ton: viseeAVenir ? "a_venir" : "voisin", detail };
}
async function charger(ctx: ContexteCarte): Promise<ElementCarte[]> {
const zones = await chargerZones();
if (!zones.length) return [];
const vehicules: Vehicule[] = ctx.assets
.filter((a) => a.assetType === "vehicule")
.map((a) => ({ label: a.label, classe: classeDuVehicule(a.attributes) }));
// À l'échelle du foyer, on ne charge que ce qui est à portée de déplacement quotidien : la
// même borne que le moteur d'échéances (§ « une ZFE concerne ceux qui y entrent »).
const retenues = ctx.logement
? zones.filter((z) => {
if (!z.centre || z.rayonZoneKm === null) return false;
return distanceKm(ctx.logement!, z.centre) - z.rayonZoneKm <= RAYON_ALERTE_KM;
})
: zones;
const groupes = new Map<string, ZoneZfe[]>();
for (const z of retenues) {
const cle = clePerimetre(z);
groupes.set(cle, [...(groupes.get(cle) ?? []), z]);
}
const tolerance = TOLERANCE[ctx.echelle];
const elements: ElementCarte[] = [];
for (const [cle, phases] of groupes) {
// Le tracé le plus détaillé du groupe fait référence : les phases partagent le périmètre.
const contour = phases.map((p) => p.contour).filter((c): c is [number, number][] => !!c?.length)
.sort((a, b) => b.length - a.length)[0];
const anneaux = contour ? [simplifier(contour, tolerance)] : [];
const c = anneaux.length ? cadre(anneaux) : null;
const ancre: Point | null = c
? centreDuCadre(c)
: (phases.find((p) => p.centre)?.centre ?? null);
if (!ancre) continue;
const { ton, detail } = tonDuPerimetre(phases, vehicules, ctx.aujourdhui);
const nom = nommerZfe(phases[0]) ?? phases[0].nom;
elements.push({
id: `zfe:${cle}`,
couche: "zfe",
ton,
titre: nom ? `ZFE — ${nom}` : "Zone à faibles émissions",
anneaux,
ancre,
zoomContour: c ? zoomDuContour(largeurKm(c), ancre.lat) : 18,
detail,
lienOfficiel: phases.find((p) => p.urlInfo)?.urlInfo ?? phases.find((p) => p.urlArrete)?.urlArrete ?? undefined,
guide: "zfe-vehicule-non-autorise",
});
}
return elements;
}
export const coucheZfe: CoucheCarte = {
id: "zfe",
label: "Zones à faibles émissions",
pilier: "vehicules",
resume: "Les périmètres où une classe Crit'Air est refusée, et depuis quand.",
charger,
};
/** Les assets d'un foyer, dans la forme attendue par le contexte de carte. */
export async function assetsDuFoyer(householdId: string): Promise<ContexteCarte["assets"]> {
const rows = await db()
.select({ assetType: assetsTable.assetType, label: assetsTable.label, attributes: assetsTable.attributes })
.from(assetsTable)
.where(eq(assetsTable.householdId, householdId));
return rows.map((r) => ({
assetType: r.assetType,
label: r.label,
attributes: (r.attributes ?? {}) as Record<string, unknown>,
}));
}

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { cadre, centreDuCadre, cercle, largeurKm, simplifier, zoomDuContour, type Anneau } from "./geometrie";
describe("simplifier", () => {
it("laisse intact ce qui a deux points ou moins", () => {
const a: Anneau = [[1, 1], [2, 2]];
expect(simplifier(a, 0.1)).toEqual(a);
});
it("écrase une ligne droite en ses deux extrémités", () => {
const droite: Anneau = Array.from({ length: 50 }, (_, i) => [i / 100, 45] as [number, number]);
expect(simplifier(droite, 0.001)).toEqual([[0, 45], [0.49, 45]]);
});
it("garde le sommet d'un détour plus grand que la tolérance", () => {
const detour: Anneau = [[0, 45], [0.5, 45.1], [1, 45]];
expect(simplifier(detour, 0.01)).toHaveLength(3);
expect(simplifier(detour, 1)).toHaveLength(2);
});
it("conserve le premier et le dernier point, donc referme un anneau", () => {
const anneau: Anneau = [[0, 45], [1, 45], [1, 46], [0, 46], [0, 45]];
const reduit = simplifier(anneau, 10);
expect(reduit[0]).toEqual([0, 45]);
expect(reduit[reduit.length - 1]).toEqual([0, 45]);
});
it("tient un anneau de 3 000 points quasi alignés sans déborder la pile", () => {
const long: Anneau = Array.from(
{ length: 3000 },
(_, i) => [i / 10000, 45 + (i % 2) * 1e-9] as [number, number],
);
expect(() => simplifier(long, 1e-6)).not.toThrow();
expect(simplifier(long, 1e-6).length).toBeLessThan(long.length);
});
it("réduit massivement un contour réaliste sans le déformer à l'échelle France", () => {
// Un cercle de 10 km échantillonné finement : c'est l'ordre de grandeur d'une ZFE.
const zone = cercle({ lat: 45.76, lon: 4.84 }, 10, 0.5);
const reduit = simplifier(zone, 0.005);
expect(reduit.length).toBeLessThan(zone.length / 5);
const c = cadre([reduit])!;
// Le cadre ne bouge pas de plus d'un kilomètre : la forme générale est préservée.
expect(Math.abs(largeurKm(c) - largeurKm(cadre([zone])!))).toBeLessThan(1);
});
});
describe("cadre", () => {
it("rend null sur une liste vide", () => {
expect(cadre([])).toBeNull();
});
it("englobe plusieurs anneaux", () => {
expect(cadre([[[0, 45], [1, 46]], [[-1, 44], [2, 47]]])).toEqual([-1, 44, 2, 47]);
});
});
describe("cercle", () => {
it("est fermé", () => {
const c = cercle({ lat: 48, lon: 2 }, 20);
expect(c[0]).toEqual(c[c.length - 1]);
});
it("a le rayon demandé, à la précision du pas", () => {
const rayon = 20;
const c = cercle({ lat: 48, lon: 2 }, rayon);
const l = largeurKm(cadre([c])!);
expect(l).toBeGreaterThan(2 * rayon - 1);
expect(l).toBeLessThan(2 * rayon + 1);
});
});
describe("zoomDuContour", () => {
it("demande un zoom d'autant plus fort que l'objet est petit", () => {
const petit = zoomDuContour(2, 48);
const grand = zoomDuContour(20, 48);
expect(petit).toBeGreaterThan(grand);
});
it("reste dans les bornes de Leaflet", () => {
expect(zoomDuContour(0, 48)).toBeLessThanOrEqual(18);
expect(zoomDuContour(5000, 48)).toBeGreaterThanOrEqual(0);
});
it("bascule une ZFE de 10 km à un zoom de niveau agglomération", () => {
const z = zoomDuContour(10, 45.76);
expect(z).toBeGreaterThanOrEqual(8);
expect(z).toBeLessThanOrEqual(11);
});
});
describe("centreDuCadre", () => {
it("rend le milieu", () => {
expect(centreDuCadre([0, 44, 2, 46])).toEqual({ lat: 45, lon: 1 });
});
});

138
src/carte/geometrie.ts Normal file
View file

@ -0,0 +1,138 @@
import { distanceKm, type Point } from "@/knowledge/zfe";
/**
* Géométrie de la carte tout ce qui se calcule sans base, sans réseau et sans navigateur.
*
* Séparé du rendu pour la même raison que le moteur d'échéances est séparé de la base
* (invariant n° 5) : ce sont ces fonctions qui décident ce que le foyer voit, et une erreur de
* projection ou de simplification est silencieuse un contour mal réduit ne lève aucune
* exception, il dessine simplement une zone qui n'existe pas.
*/
/** Un anneau extérieur, en convention GeoJSON : [lon, lat]. */
export type Anneau = [number, number][];
/**
* Distance perpendiculaire d'un point au segment [a, b], en degrés.
*
* Approximation plane assumée : sur l'emprise d'une zone (quelques dizaines de km au plus), la
* courbure est négligeable devant la tolérance de simplification. On corrige tout de même la
* longitude par le cosinus de la latitude, sans quoi un degré de longitude compterait autant
* qu'un degré de latitude et la simplification serait deux fois plus agressive d'est en ouest
* que du nord au sud visible à l'œil sur une zone allongée.
*/
function distanceAuSegment(p: [number, number], a: [number, number], b: [number, number]): number {
const k = Math.cos((p[1] * Math.PI) / 180);
const px = p[0] * k, py = p[1];
const ax = a[0] * k, ay = a[1];
const bx = b[0] * k, by = b[1];
const dx = bx - ax, dy = by - ay;
if (dx === 0 && dy === 0) return Math.hypot(px - ax, py - ay);
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/**
* Simplification de Ramer-Douglas-Peucker.
*
* Sans elle, la couche ZFE seule pèse 47 205 points (890 ko) pour 35 zones : à l'échelle de la
* France une zone de 10 km tient dans 7 pixels, donc l'immense majorité de ces points ne peut
* pas être distinguée on paierait la bande passante d'un détail que personne ne verra.
*
* La tolérance est en degrés, et c'est volontaire : elle se règle par usage (l'échelle France
* tolère ~500 m, la carte d'un foyer ~20 m), pas par une constante unique.
*
* Le premier et le dernier point sont toujours conservés sur un anneau fermé, c'est ce qui
* garantit qu'il reste fermé.
*/
export function simplifier(anneau: Anneau, toleranceDeg: number): Anneau {
if (anneau.length <= 2 || toleranceDeg <= 0) return anneau;
const garder = new Uint8Array(anneau.length);
garder[0] = 1;
garder[anneau.length - 1] = 1;
// Pile explicite plutôt que récursion : un anneau de 3 000 points déborderait la pile d'appels
// dans le pire cas (points quasi alignés), et ce cas existe — les contours ZFE suivent des voies.
const pile: [number, number][] = [[0, anneau.length - 1]];
while (pile.length) {
const [debut, fin] = pile.pop()!;
let pire = 0;
let index = -1;
for (let i = debut + 1; i < fin; i++) {
const d = distanceAuSegment(anneau[i], anneau[debut], anneau[fin]);
if (d > pire) {
pire = d;
index = i;
}
}
if (index !== -1 && pire > toleranceDeg) {
garder[index] = 1;
pile.push([debut, index], [index, fin]);
}
}
return anneau.filter((_, i) => garder[i] === 1);
}
/** Cadre englobant, en [ouest, sud, est, nord]. */
export function cadre(anneaux: Anneau[]): [number, number, number, number] | null {
let o = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
for (const anneau of anneaux) {
for (const [lon, lat] of anneau) {
if (lon < o) o = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
}
return Number.isFinite(o) ? [o, s, e, n] : null;
}
/**
* Un cercle, rendu en anneau pour les règles dont le périmètre EST un rayon et non un tracé
* (les 20 km autour d'une centrale nucléaire, § comprimés d'iode). Le pas de 5° suffit : à
* 20 km de rayon, la corde d'un secteur de 5° mesure moins de 2 km, invisible au zoom concerné.
*/
export function cercle(centre: Point, rayonKm: number, pas = 5): Anneau {
const anneau: Anneau = [];
const dLat = rayonKm / 111.32;
const dLon = rayonKm / (111.32 * Math.cos((centre.lat * Math.PI) / 180));
for (let a = 0; a <= 360; a += pas) {
const r = (a * Math.PI) / 180;
anneau.push([centre.lon + dLon * Math.sin(r), centre.lat + dLat * Math.cos(r)]);
}
return anneau;
}
/**
* Le zoom Leaflet à partir duquel un objet de cette taille mérite son contour plutôt qu'une
* pastille. En dessous, le contour se réduit à une tache de quelques pixels : il ment sur la
* forme sans rien apprendre, alors qu'une pastille ne prétend rien.
*
* On vise ~40 px de large. À l'équateur, une tuile de 256 px couvre 360°/2^z ; en France on
* corrige par le cosinus de la latitude.
*/
export function zoomDuContour(largeurKm: number, latitude: number, pixelsVises = 40): number {
// Objet sans étendue (un point) : il n'y a pas de contour à attendre, on reste sur la pastille
// jusqu'au zoom maximal. 18 et non 20 — au-delà, Leaflet n'a plus de tuile à afficher.
if (largeurKm <= 0) return 18;
const kmParDegreLon = 111.32 * Math.cos((latitude * Math.PI) / 180);
const largeurDeg = largeurKm / kmParDegreLon;
// 256 px = 360 / 2^z degrés → z = log2(360 * pixelsVises / (256 * largeurDeg))
const z = Math.log2((360 * pixelsVises) / (256 * largeurDeg));
return Math.max(0, Math.min(18, Math.round(z)));
}
/** Le point le plus au centre du cadre — là où se pose la pastille quand le contour est trop petit. */
export function centreDuCadre(c: [number, number, number, number]): Point {
return { lat: (c[1] + c[3]) / 2, lon: (c[0] + c[2]) / 2 };
}
/** Largeur du cadre en kilomètres, à sa latitude — l'entrée de `zoomDuContour`. */
export function largeurKm(c: [number, number, number, number]): number {
const centre = centreDuCadre(c);
return distanceKm({ lat: centre.lat, lon: c[0] }, { lat: centre.lat, lon: c[2] });
}

51
src/carte/registre.ts Normal file
View file

@ -0,0 +1,51 @@
import { coucheZfe } from "./couches/zfe";
import type { ContexteCarte, CoucheCarte, ElementCarte } from "./types";
/**
* Le registre des couches.
*
* Ajouter une couche zones inondables, obligations de débroussaillement, périmètres iode
* est un fichier dans `couches/` et une ligne ici. Aucun composant de rendu n'est touché :
* c'est la même règle que « un pilier est de la configuration, jamais du code » (invariant n° 7).
*
* L'ordre compte : il fixe l'ordre de la légende, et l'ordre de dessin. Les couches les plus
* larges d'abord, pour qu'une petite zone ne disparaisse pas sous une grande.
*/
export const COUCHES: CoucheCarte[] = [coucheZfe];
export function coucheParId(id: string): CoucheCarte | undefined {
return COUCHES.find((c) => c.id === id);
}
/**
* Charge toutes les couches pour un contexte, en tolérant qu'une échoue.
*
* Une couche qui dépend d'un tiers indisponible ne doit pas emporter la carte entière : le
* foyer verra ses ZFE même si la sécheresse ne répond pas. C'est le même principe de
* dégradation gracieuse que `sendMail` et que l'enrichissement Géorisques.
*/
export async function chargerCarte(ctx: ContexteCarte): Promise<{
elements: ElementCarte[];
couches: { id: string; label: string; pilier: string; resume: string; nombre: number }[];
}> {
const resultats = await Promise.allSettled(COUCHES.map((c) => c.charger(ctx)));
const elements: ElementCarte[] = [];
const couches: { id: string; label: string; pilier: string; resume: string; nombre: number }[] = [];
resultats.forEach((r, i) => {
const couche = COUCHES[i];
if (r.status === "rejected") {
console.error(`[carte] couche ${couche.id} indisponible`, r.reason);
return;
}
elements.push(...r.value);
// Une couche vide n'entre pas dans la légende : proposer d'éteindre ce qui n'est pas
// affiché est une case à cocher qui ne fait rien.
if (r.value.length) {
couches.push({ id: couche.id, label: couche.label, pilier: couche.pilier, resume: couche.resume, nombre: r.value.length });
}
});
return { elements, couches };
}

81
src/carte/types.ts Normal file
View file

@ -0,0 +1,81 @@
import type { Pilier } from "@/knowledge/schema";
import type { Point } from "@/knowledge/zfe";
import type { Anneau } from "./geometrie";
/**
* Le contrat de la carte.
*
* Une couche est de la configuration, jamais du code de carte même règle que « un pilier est
* un asset_type, pas une table » (invariant n° 7). Ajouter les zones inondables ou les
* obligations de débroussaillement doit être un fichier de plus dans `couches/` et une ligne
* dans le registre, sans qu'aucun composant de rendu ne soit touché.
*
* Rien ici ne dépend de Leaflet, du DOM ni de la base : ce fichier décrit ce qu'on affiche,
* pas comment.
*/
/**
* Trois tons, jamais une palette.
*
* La constitution refuse le rouge, le retard et la comptabilité morale : une carte qui
* coloriserait douze niveaux de gravité redeviendrait un tableau de bord à tenir. On dit donc
* seulement trois choses ça te concerne, ça te concernera à une date connue, ça existe à côté.
*/
export type Ton = "concerne" | "a_venir" | "voisin";
/** Une ligne du panneau de détail — libellé à gauche, valeur à droite. */
export type LigneDetail = { ligne: string; valeur: string };
/**
* Ce qu'une couche produit : un objet à dessiner, et de quoi l'expliquer au clic.
*
* La géométrie est toujours une liste d'anneaux (un polygone simple en a un, une zone à
* enclaves en a plusieurs) ou un simple point. Un cercle est converti en anneau par la couche
* elle-même : le rendu n'a pas à connaître la notion de rayon.
*/
export type ElementCarte = {
id: string;
couche: string;
ton: Ton;
titre: string;
/** Les anneaux à tracer. Vide pour un objet ponctuel. */
anneaux: Anneau[];
/** Où poser la pastille quand le contour est trop petit pour être lu. */
ancre: Point;
/** Zoom à partir duquel on trace le contour plutôt que la pastille. */
zoomContour: number;
detail: LigneDetail[];
/** Le lien qui fait foi — arrêté, site de la collectivité. */
lienOfficiel?: string;
/** Slug d'une fiche : le pont vers la bicéphalie du § 11.2, jamais une URL en dur. */
guide?: string;
};
/** Ce qu'une couche reçoit pour décider quoi montrer, et sur quel ton. */
export type ContexteCarte = {
/** Le logement, quand il y en a un. Absent sur la carte publique d'un guide. */
logement: Point | null;
/** Les assets du foyer — c'est ce qui qualifie une zone (un véhicule Crit'Air 4, par exemple). */
assets: { assetType: string; label: string; attributes: Record<string, unknown> }[];
aujourdhui: string;
/**
* Échelle visée. `foyer` charge ce qui entoure une adresse avec un tracé fin ; `france` charge
* tout, simplifié c'est la même couche, appelée deux fois, jamais deux implémentations.
*/
echelle: "foyer" | "france";
};
export type CoucheCarte = {
id: string;
label: string;
pilier: Pilier;
/** Une phrase pour la légende — ce que la couche montre, pas ce qu'elle est. */
resume: string;
charger(ctx: ContexteCarte): Promise<ElementCarte[]>;
};
/** Tolérance de simplification par échelle, en degrés (~20 m près de chez soi, ~500 m sur la France). */
export const TOLERANCE: Record<ContexteCarte["echelle"], number> = {
foyer: 0.0002,
france: 0.005,
};

View file

@ -0,0 +1,246 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Map as MapLeaflet, Layer } from "leaflet";
import { ExternalLink, X } from "lucide-react";
import Link from "next/link";
import type { ElementCarte, Ton } from "@/carte/types";
/**
* Le rendu de la carte. Leaflet et rien d'autre : polygones, pastilles, panneau au clic et
* extinction de couche suffisent, pour ~45 ko un moteur vectoriel en pèserait quatre fois plus
* pour un zoom plus fluide dont personne n'a besoin ici.
*
* Ce composant ne sait rien des ZFE, de la sécheresse ni des centrales : il dessine des
* `ElementCarte`. C'est ce qui permet d'ajouter une couche sans y revenir.
*
* Les tuiles viennent de la Géoplateforme de l'IGN établissement public français, service
* ouvert sans clé. Elles sont demandées par le navigateur, donc l'adresse IP du visiteur y est
* connue : c'est la raison pour laquelle ce composant n'est monté qu'après un geste explicite
* (voir `carte.tsx`), jamais au chargement d'une page.
*/
type Props = {
elements: ElementCarte[];
couches: { id: string; label: string; resume: string; nombre: number }[];
/** Le logement, quand il y en a un — le point autour duquel la carte s'ouvre. */
ancre?: { lat: number; lon: number } | null;
/** Cadrage initial : le foyer et ses environs, ou la France entière. */
vue: "foyer" | "france";
/** Sans détail, le clic n'ouvre rien : l'aperçu montre où, jamais quoi. */
detailsVisibles: boolean;
};
const STYLE: Record<Ton, { couleur: string; poids: number; opacite: number; remplissage: number; pointille?: string }> = {
concerne: { couleur: "var(--accent)", poids: 2.5, opacite: 1, remplissage: 0.16 },
a_venir: { couleur: "var(--accent)", poids: 2, opacite: 0.9, remplissage: 0.06, pointille: "5 4" },
voisin: { couleur: "var(--ink-soft)", poids: 1, opacite: 0.45, remplissage: 0 },
};
/** L'ordre de dessin : le voisinage sous ce qui concerne, jamais l'inverse. */
const ORDRE_TON: Ton[] = ["voisin", "a_venir", "concerne"];
export function CarteLeaflet({ elements, couches, ancre, vue, detailsVisibles }: Props) {
const conteneur = useRef<HTMLDivElement>(null);
const carte = useRef<MapLeaflet | null>(null);
/**
* L'ancre est un objet littéral, donc d'identité neuve à chaque rendu : la mettre en
* dépendance d'effet détruirait et reconstruirait la carte au moindre changement d'état
* (une case de légende décochée suffirait). On dépend de ses coordonnées, qui sont des nombres.
*/
const ancreLat = ancre?.lat ?? null;
const ancreLon = ancre?.lon ?? null;
const dessins = useRef<Map<string, { contour: Layer; pastille: Layer }>>(new Map());
const [eteintes, setEteintes] = useState<Set<string>>(new Set());
const [ouvert, setOuvert] = useState<ElementCarte | null>(null);
const [zoom, setZoom] = useState(vue === "foyer" ? 11 : 6);
// ── Montage : une seule fois, Leaflet n'aime pas être remonté ────────────
useEffect(() => {
if (!conteneur.current || carte.current) return;
let annule = false;
// Capturé ici plutôt que lu au démontage : la table des tracés est la même à l'aller et au
// retour, mais la lire dans le nettoyage ferait dépendre celui-ci d'un `.current` futur.
const traces = dessins.current;
(async () => {
const L = (await import("leaflet")).default;
if (annule || !conteneur.current) return;
const aAncre = ancreLat !== null && ancreLon !== null;
// Sans logement (carte d'une fiche), on cadre la France métropolitaine.
const centre: [number, number] = aAncre ? [ancreLat, ancreLon] : [46.6, 2.4];
const zoomInitial = vue === "foyer" ? 11 : 6;
const map = L.map(conteneur.current, {
center: centre,
zoom: zoomInitial,
// La molette zoome la page, pas la carte, tant qu'on ne l'a pas saisie : sur un article,
// une carte qui capture le défilement est un piège.
scrollWheelZoom: false,
attributionControl: true,
});
L.tileLayer("https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM&FORMAT=image/png&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}", {
maxZoom: 18,
attribution: "© IGN — Géoplateforme",
}).addTo(map);
if (aAncre) {
L.circleMarker([ancreLat, ancreLon], {
radius: 6, color: "var(--accent)", weight: 2, fillColor: "var(--accent)", fillOpacity: 1,
})
.addTo(map)
.bindTooltip("Ton logement", { direction: "top" });
}
map.on("zoomend", () => setZoom(map.getZoom()));
carte.current = map;
setZoom(map.getZoom());
})();
return () => {
annule = true;
carte.current?.remove();
carte.current = null;
traces.clear();
};
}, [ancreLat, ancreLon, vue]);
// ── Les objets : dessinés une fois, montrés ou cachés ensuite ────────────
useEffect(() => {
const map = carte.current;
if (!map) return;
let annule = false;
(async () => {
const L = (await import("leaflet")).default;
if (annule || !carte.current) return;
for (const el of [...elements].sort((a, b) => ORDRE_TON.indexOf(a.ton) - ORDRE_TON.indexOf(b.ton))) {
if (dessins.current.has(el.id)) continue;
const s = STYLE[el.ton];
const contour = L.polygon(
el.anneaux.map((a) => a.map(([lon, lat]) => [lat, lon] as [number, number])),
{
color: s.couleur, weight: s.poids, opacity: s.opacite,
fillColor: s.couleur, fillOpacity: s.remplissage, dashArray: s.pointille,
},
);
const pastille = L.circleMarker([el.ancre.lat, el.ancre.lon], {
radius: el.ton === "voisin" ? 4 : 6,
color: s.couleur, weight: 2, opacity: s.opacite,
fillColor: s.couleur, fillOpacity: el.ton === "voisin" ? 0.3 : 0.8,
});
for (const couche of [contour, pastille]) {
couche.bindTooltip(el.titre, { direction: "top" });
if (detailsVisibles) couche.on("click", () => setOuvert(el));
}
dessins.current.set(el.id, { contour, pastille });
}
})();
return () => { annule = true; };
}, [elements, detailsVisibles]);
// ── Ce qui est visible : fonction du zoom et des couches allumées ────────
useEffect(() => {
const map = carte.current;
if (!map) return;
for (const el of elements) {
const paire = dessins.current.get(el.id);
if (!paire) continue;
const allumee = !eteintes.has(el.couche);
// Sous son seuil, un contour de quelques pixels ment sur la forme sans rien apprendre :
// la pastille prend le relais, et ne prétend à aucune frontière.
const montrerContour = allumee && el.anneaux.length > 0 && zoom >= el.zoomContour;
const montrerPastille = allumee && !montrerContour;
for (const [couche, montrer] of [[paire.contour, montrerContour], [paire.pastille, montrerPastille]] as const) {
const present = map.hasLayer(couche);
if (montrer && !present) couche.addTo(map);
if (!montrer && present) map.removeLayer(couche);
}
}
}, [elements, eteintes, zoom]);
function basculer(id: string) {
setEteintes((prec) => {
const suivant = new Set(prec);
if (suivant.has(id)) suivant.delete(id);
else suivant.add(id);
return suivant;
});
}
return (
<div className="surface-card overflow-hidden">
<div ref={conteneur} className="h-[22rem] w-full sm:h-[30rem]" role="application" aria-label="Carte des zones réglementées autour du foyer" />
{/* La légende EST le contrôle : une seule liste dit ce qu'on voit et permet de l'éteindre. */}
{couches.length > 0 && (
<div className="flex flex-wrap gap-x-5 gap-y-2 border-t border-line px-4 py-3">
{couches.map((c) => (
<label key={c.id} className="flex cursor-pointer items-center gap-2 text-[0.925rem]">
<input
type="checkbox"
checked={!eteintes.has(c.id)}
onChange={() => basculer(c.id)}
className="size-4 accent-[var(--accent)]"
/>
<span className={eteintes.has(c.id) ? "text-faint" : "text-ink-soft"}>{c.label}</span>
<span className="tnum text-xs text-faint">{c.nombre}</span>
</label>
))}
</div>
)}
{ouvert && (
<div className="border-t border-line p-4 sm:p-5">
<div className="flex items-start justify-between gap-4">
<p className="font-display font-semibold text-balance">{ouvert.titre}</p>
<button
type="button"
onClick={() => setOuvert(null)}
aria-label="Fermer le détail"
className="shrink-0 text-muted hover:text-ink"
>
<X size={17} aria-hidden />
</button>
</div>
<dl className="mt-3 grid gap-x-4 gap-y-1.5 text-[0.925rem] sm:grid-cols-[auto_1fr]">
{ouvert.detail.map((d) => (
<div key={d.ligne} className="contents">
<dt className="text-faint">{d.ligne}</dt>
<dd className="text-ink-soft">{d.valeur}</dd>
</div>
))}
</dl>
<div className="mt-4 flex flex-wrap gap-4 text-sm font-medium">
{ouvert.guide && (
<Link href={`/guide/${ouvert.guide}`} className="text-accent underline underline-offset-2">
Le guide
</Link>
)}
{ouvert.lienOfficiel && (
<a
href={ouvert.lienOfficiel}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-accent underline underline-offset-2"
>
La source officielle
<ExternalLink size={13} aria-hidden />
</a>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { Map as MapIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ElementCarte } from "@/carte/types";
import "leaflet/dist/leaflet.css";
/**
* L'enveloppe de la carte, et le seul endroit qui décide QUAND elle se charge.
*
* Rien n'est demandé au réseau tant que le lecteur n'a pas cliqué : ni les ~45 ko de Leaflet, ni
* la moindre tuile. Trois raisons, dans cet ordre :
*
* 1. Vie privée les tuiles sont demandées par le navigateur, donc l'IP du visiteur arrive
* chez l'IGN, et les coordonnées de tuile trahissent la zone regardée. Sur une page de guide
* publique, ce serait subi ; après un clic, c'est demandé.
* 2. SEO les fiches sont prégénérées et vivent de leur poids ; une carte qui se monte au
* chargement ferait payer à chaque visiteur un composant que la plupart ne dérouleront pas.
* 3. Le tableau reste ce qu'un robot lit, et ce qu'un lecteur sans JavaScript garde.
*/
const CarteLeaflet = dynamic(() => import("./carte-leaflet").then((m) => m.CarteLeaflet), {
ssr: false,
loading: () => (
<div className="surface-card flex h-[22rem] items-center justify-center text-muted sm:h-[30rem]">
Chargement de la carte
</div>
),
});
export function Carte({
elements,
couches,
ancre,
vue,
detailsVisibles = true,
invite,
}: {
elements: ElementCarte[];
couches: { id: string; label: string; resume: string; nombre: number }[];
ancre?: { lat: number; lon: number } | null;
vue: "foyer" | "france";
detailsVisibles?: boolean;
/** Ce qu'on annonce avant le clic — jamais « cliquez ici », toujours ce qu'on va montrer. */
invite: string;
}) {
const [ouverte, setOuverte] = useState(false);
if (!ouverte) {
return (
<div className="surface-card flex flex-col items-start gap-4 bg-surface-alt p-5 sm:flex-row sm:items-center sm:justify-between sm:p-6">
<p className="text-[0.925rem] text-muted">
{invite}{" "}
<span className="text-faint">
Le fond de carte vient de l&apos;IGN : il n&apos;est chargé qu&apos;à ton clic.
</span>
</p>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setOuverte(true)}>
<MapIcon size={16} aria-hidden />
Afficher la carte
</Button>
</div>
);
}
return <CarteLeaflet elements={elements} couches={couches} ancre={ancre} vue={vue} detailsVisibles={detailsVisibles} />;
}

View file

@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { AGGLOMERATIONS_ZFE, nommerZfe } from "./zfe-noms";
/**
* Les 24 périmètres réellement publiés par la Base Nationale des ZFE au 29/07/2026, relevés en
* base. Ce tableau est la raison d'être du test : c'est lui qui rougira le jour la source
* publiera un périmètre qu'on ne sait pas nommer.
*/
const RELEVE_REEL: { zfeId: string; url: string | null; attendu: string }[] = [
{ zfeId: "217500016-ZFE-001", url: "https://www.zonefaiblesemissionsmetropolitaine.fr/", attendu: "Métropole du Grand Paris" },
{ zfeId: "200046977-ZFE-001", url: "https://www.grandlyon.com/actions/zfe.html#c20726", attendu: "Métropole de Lyon" },
{ zfeId: "200054807-ZFE-001", url: "https://ampmetropole.fr/missions/qualite-de-lair/", attendu: "Aix-Marseille-Provence" },
{ zfeId: "243100518-ZFE-002", url: "https://metropole.toulouse.fr/recherche?key=zfe", attendu: "Toulouse Métropole" },
{ zfeId: "243300316-ZFE-001", url: "https://participation.bordeaux-metropole.fr/rails/x.pdf", attendu: "Bordeaux Métropole" },
{ zfeId: "243400017-ZFE-001", url: "https://www.montpellier3m.fr/vivre-transport/zone-faibles-emissions", attendu: "Montpellier Méditerranée Métropole" },
{ zfeId: "244400404-ZFE-001", url: "https://metropole.nantes.fr/services/voiture/vignette-crit-air-zfe", attendu: "Nantes Métropole" },
{ zfeId: "243500139-ZFE-001", url: "https://metropole.rennes.fr/la-zone-faibles-emissions", attendu: "Rennes Métropole" },
{ zfeId: "200040715-ZFE-001", url: "https://www.mobilites-m.fr/pdf/arrete_ZFE_VUL_PL.pdf", attendu: "Grenoble-Alpes Métropole" },
{ zfeId: "2000234140-ZFE-003", url: "https://zfe.metropole-rouen-normandie.fr/", attendu: "Métropole Rouen Normandie" },
{ zfeId: "244200770-ZFE-002", url: "https://fr.calameo.com/read/000544113e25e9f1669fb", attendu: "Saint-Étienne Métropole" },
{ zfeId: "245400676-ZFE-001", url: "https://www.grandnancy.eu/vivre-habiter/qualite-de-lair/zone-a-faible-emission", attendu: "Métropole du Grand Nancy" },
{ zfeId: "200067213-ZFE-002", url: "https://www.grandreims.fr/les-competences/zfem-14426.html", attendu: "Grand Reims" },
{ zfeId: "242100410-ZFE-001", url: "https://www.dijon-metropole.fr/qualite-de-lair/zfem/", attendu: "Dijon Métropole" },
{ zfeId: "244900015-ZFE-001", url: "https://www.angersloiremetropole.fr/fileadmin/arrete_2025_zfem.pdf", attendu: "Angers Loire Métropole" },
{ zfeId: "246300701-ZFE-001", url: "https://clermont-ferrand.fr/sites/default/files/arrete_zfe_du_29_06_2023.pdf", attendu: "Clermont Auvergne Métropole" },
{ zfeId: "1", url: "https://www.grandannecy.fr/zfem/pourquoi-une-zfe-m-dans-le-grand-annecy", attendu: "Grand Annecy" },
{ zfeId: "4", url: "https://www.grandannecy.fr/zfem/pourquoi-une-zfe-m-dans-le-grand-annecy", attendu: "Grand Annecy" },
{ zfeId: "200011773-ZFE-001", url: "https://www.annemasse-agglo.fr/sites/default/files/CC_2024_0094.pdf", attendu: "Annemasse Agglo" },
{ zfeId: "246700488-ZFE-002", url: "https://zfe.strasbourg.eu/", attendu: "Eurométropole de Strasbourg" },
{ zfeId: "67482-ZFE-004", url: "https://zfe.strasbourg.eu/", attendu: "Strasbourg" },
{ zfeId: "67447-ZFE-005", url: "https://zfe.strasbourg.eu/", attendu: "Schiltigheim" },
{ zfeId: "67365-ZFE-006", url: "https://zfe.strasbourg.eu/", attendu: "Ostwald" },
{ zfeId: "67212-ZFE-007", url: "https://zfe.strasbourg.eu/", attendu: "Holtzheim" },
];
describe("nommerZfe", () => {
it.each(RELEVE_REEL)("nomme $zfeId", ({ zfeId, url, attendu }) => {
expect(nommerZfe({ zfeId, urlInfo: url })).toBe(attendu);
});
it("couvre les 24 périmètres relevés en base", () => {
expect(RELEVE_REEL.filter((r) => nommerZfe({ zfeId: r.zfeId, urlInfo: r.url }) === null)).toEqual([]);
});
it("rend null plutôt que d'inventer un nom", () => {
expect(nommerZfe({ zfeId: "999999999-ZFE-001", urlInfo: "https://exemple.test/" })).toBeNull();
expect(nommerZfe({ zfeId: "999999999-ZFE-001" })).toBeNull();
});
it("se rabat sur l'arrêté quand le site d'information manque", () => {
expect(nommerZfe({ zfeId: "x", urlInfo: null, urlArrete: "https://www.grandlyon.com/a.pdf" }))
.toBe("Métropole de Lyon");
});
it("sépare Strasbourg de son Eurométropole par le préfixe, l'hôte étant commun aux cinq", () => {
const url = "https://zfe.strasbourg.eu/";
expect(nommerZfe({ zfeId: "246700488-ZFE-002", urlInfo: url })).toBe("Eurométropole de Strasbourg");
expect(nommerZfe({ zfeId: "67482-ZFE-004", urlInfo: url })).toBe("Strasbourg");
});
it("ignore un www. et une URL illisible", () => {
expect(nommerZfe({ zfeId: "x", urlInfo: "https://www.grandlyon.com/x" })).toBe("Métropole de Lyon");
expect(nommerZfe({ zfeId: "200046977-ZFE-001", urlInfo: "pas une url" })).toBe("Métropole de Lyon");
});
it("fait primer l'hôte sur le préfixe — le garde-fou des identifiants « 1 » à « 4 »", () => {
// Une future collectivité publiant l'identifiant « 1 » ne doit pas hériter d'Annecy.
expect(nommerZfe({ zfeId: "1", urlInfo: "https://www.grandlyon.com/x" })).toBe("Métropole de Lyon");
});
it("n'a ni nom ni préfixe en double", () => {
const noms = AGGLOMERATIONS_ZFE.map((a) => a.nom);
expect(new Set(noms).size).toBe(noms.length);
const prefixes = AGGLOMERATIONS_ZFE.flatMap((a) => a.prefixes);
expect(new Set(prefixes).size).toBe(prefixes.length);
const hotes = AGGLOMERATIONS_ZFE.flatMap((a) => a.hotes ?? []);
expect(new Set(hotes).size).toBe(hotes.length);
});
});

90
src/knowledge/zfe-noms.ts Normal file
View file

@ -0,0 +1,90 @@
/**
* Le nom des agglomérations qui portent une ZFE.
*
* Pourquoi cette table existe : la Base Nationale des ZFE ne renseigne le champ `nom` que sur
* 2 zones sur 35. Sans nom, une carte des ZFE est une constellation de points anonymes et un
* tableau ne se lit pas. Le nom doit donc venir d'ailleurs.
*
* Pourquoi elle est écrite à la main, et en git : par la règle de tri de D-004 « si je
* supprime la table et rejoue le pipeline, est-ce que je récupère exactement la même chose ? ».
* Ici non : choisir « Eurométropole de Strasbourg » plutôt que « Strasbourg », ou rattacher
* quatre communes à leur EPCI, est un jugement. C'est donc de la Connaissance, relue par diff,
* et non un référentiel en base.
*
* L'alternative écartée était le reverse-géocodage du centroïde : automatique, mais il rend une
* commune et non une agglomération le centroïde de l'Eurométropole tombe sur un village de
* banlieue, et la carte aurait nommé la zone d'après lui.
*
* Chaque entrée est vérifiable : le préfixe est le code SIREN de l'EPCI (ou le code INSEE de la
* commune pour les zones communales), et l'hôte est celui que la source publie elle-même dans
* `url_site_information` ou `url_arrete`.
*/
export type AgglomerationZfe = {
nom: string;
/** Préfixes de `zfe_id`, avant le premier tiret — code SIREN d'EPCI ou code INSEE de commune. */
prefixes: string[];
/** Hôtes des URL officielles publiées par la source. Priment sur le préfixe (voir `nommerZfe`). */
hotes?: string[];
};
export const AGGLOMERATIONS_ZFE: AgglomerationZfe[] = [
{ nom: "Métropole du Grand Paris", prefixes: ["217500016"], hotes: ["zonefaiblesemissionsmetropolitaine.fr"] },
{ nom: "Métropole de Lyon", prefixes: ["200046977"], hotes: ["grandlyon.com"] },
{ nom: "Aix-Marseille-Provence", prefixes: ["200054807"], hotes: ["ampmetropole.fr"] },
{ nom: "Toulouse Métropole", prefixes: ["243100518"], hotes: ["metropole.toulouse.fr"] },
{ nom: "Bordeaux Métropole", prefixes: ["243300316"], hotes: ["participation.bordeaux-metropole.fr", "bordeaux-metropole.fr"] },
{ nom: "Montpellier Méditerranée Métropole", prefixes: ["243400017"], hotes: ["montpellier3m.fr"] },
{ nom: "Nantes Métropole", prefixes: ["244400404"], hotes: ["metropole.nantes.fr"] },
{ nom: "Rennes Métropole", prefixes: ["243500139"], hotes: ["metropole.rennes.fr"] },
{ nom: "Grenoble-Alpes Métropole", prefixes: ["200040715"], hotes: ["mobilites-m.fr"] },
{ nom: "Métropole Rouen Normandie", prefixes: ["2000234140"], hotes: ["zfe.metropole-rouen-normandie.fr"] },
{ nom: "Saint-Étienne Métropole", prefixes: ["244200770"] },
{ nom: "Métropole du Grand Nancy", prefixes: ["245400676"], hotes: ["grandnancy.eu"] },
{ nom: "Grand Reims", prefixes: ["200067213"], hotes: ["grandreims.fr"] },
{ nom: "Dijon Métropole", prefixes: ["242100410"], hotes: ["dijon-metropole.fr"] },
{ nom: "Angers Loire Métropole", prefixes: ["244900015"], hotes: ["angersloiremetropole.fr"] },
{ nom: "Clermont Auvergne Métropole", prefixes: ["246300701"], hotes: ["clermont-ferrand.fr"] },
{ nom: "Grand Annecy", prefixes: ["1", "2", "3", "4"], hotes: ["grandannecy.fr"] },
{ nom: "Annemasse Agglo", prefixes: ["200011773"], hotes: ["annemasse-agglo.fr"] },
/*
* Strasbourg publie deux niveaux : la zone de l'Eurométropole, et quatre zones communales qui
* ouvrent en 2028. Elles partagent le même hôte, donc seul le préfixe les distingue c'est le
* cas qui justifie de garder les deux clés plutôt qu'une seule.
*/
{ nom: "Eurométropole de Strasbourg", prefixes: ["246700488"] },
{ nom: "Strasbourg", prefixes: ["67482"] },
{ nom: "Schiltigheim", prefixes: ["67447"] },
{ nom: "Ostwald", prefixes: ["67365"] },
{ nom: "Holtzheim", prefixes: ["67212"] },
];
function hoteDe(url: string | null | undefined): string | null {
if (!url) return null;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return null;
}
}
/**
* Nomme une zone. Rend `null` quand on ne sait pas un nom inventé serait pire que pas de nom :
* la carte sait afficher « Zone à faibles émissions » sans mentir, et l'absence se voit, donc
* se corrige.
*
* L'hôte prime sur le préfixe. Le Grand Annecy publie ses zones sous les identifiants « 1 » à
* « 4 », sans espace de nommage : le jour une autre collectivité publiera un « 1 », le
* préfixe seul lui attribuerait Annecy en silence. L'hôte, lui, ne peut appartenir qu'à un.
*/
export function nommerZfe(zone: { zfeId: string; urlInfo?: string | null; urlArrete?: string | null }): string | null {
const hote = hoteDe(zone.urlInfo) ?? hoteDe(zone.urlArrete);
if (hote) {
const parHote = AGGLOMERATIONS_ZFE.find((a) => a.hotes?.includes(hote));
if (parHote) return parHote.nom;
}
const prefixe = zone.zfeId.split("-")[0];
return AGGLOMERATIONS_ZFE.find((a) => a.prefixes.includes(prefixe))?.nom ?? null;
}