Files
quartier/prototypes/axo-test/template.html
T
Jules NényandClaude Opus 5.5 731531dbe2 Test axonométrique du 27/09 : pipeline de fabrication et prototype
pipeline/axo : extraction du Rhino, recalage sur la planche (1:640), maisons,
arbres (positions réelles des blocs, couleurs de la planche), scène en mètres,
sols depuis le PDF Illustrator vectoriel (calques pilotés), bulles de la page 15,
planche de l'eau nettoyée. prototypes/axo-test : la page de test et son assembleur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:46:14 +02:00

803 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Quelque part en 2035</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Barlow+Condensed:wght@500;600&display=swap">
<style>
/* Une seule ambiance, volontairement : le papier des planches TMIP. Couleurs posées explicitement. */
:root {
--papier: #f1ece0; --papier-2: #fbf9f3; --encre: #2a2925; --encre-2: #66615a; --trait: #dcd4c3;
--eau: #2d86bf; --vivant: #5a9837; --chaleur: #c4850f; --quartier: #d4662a; --neutre: #6c665b;
--ombre: 0 10px 30px rgba(42, 41, 37, .16);
color-scheme: light;
}
[hidden] { display: none !important; }
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: var(--papier); color: var(--encre);
font: 15px/1.45 Barlow, "Segoe UI", system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; cursor: pointer; }
button:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--eau); outline-offset: 2px; }
#scene { position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
#scene.tire { cursor: grabbing; }
#repli { position: fixed; inset: 0; background: var(--papier) center / cover no-repeat; }
/* ---------- barre du haut ---------- */
.barre { position: fixed; z-index: 5; top: calc(env(safe-area-inset-top, 0px) + 10px); left: 12px; right: 12px;
display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; flex-wrap: wrap; pointer-events: none; }
.barre > * { pointer-events: auto; }
.titre { background: rgba(251, 249, 243, .92); border: 1px solid var(--trait); border-radius: 12px; padding: 7px 12px; box-shadow: var(--ombre); }
.titre h1 { margin: 0; font: 600 20px/1.1 "Barlow Condensed", Barlow, sans-serif; letter-spacing: .01em; }
.titre p { margin: 1px 0 0; font-size: 12.5px; color: var(--encre-2); }
.commandes { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.groupe { display: flex; background: rgba(251, 249, 243, .92); border: 1px solid var(--trait); border-radius: 12px; padding: 3px; box-shadow: var(--ombre); }
.groupe button { border: 0; background: transparent; border-radius: 9px; padding: 0 11px; min-height: 38px; min-width: 40px; font-weight: 500; font-size: 14px; }
.groupe button[aria-pressed="true"] { background: var(--encre); color: var(--papier-2); }
.groupe svg { width: 20px; height: 20px; display: block; margin: auto; }
@media (max-width: 520px) { .titre p { display: none; } .titre h1 { font-size: 18px; } }
/* ---------- fiches ---------- */
#fiches { position: fixed; z-index: 6; left: 0; right: 0; bottom: 0; background: var(--papier-2); border-top: 1px solid var(--trait);
border-radius: 16px 16px 0 0; box-shadow: 0 -8px 28px rgba(42, 41, 37, .14); padding: 10px 14px calc(env(safe-area-inset-bottom, 0px) + 10px);
display: flex; flex-direction: column; gap: 8px; max-height: 50%; }
@media (min-width: 900px) { #fiches { left: 16px; right: auto; bottom: 16px; width: 430px; border: 1px solid var(--trait); border-radius: 16px; max-height: calc(100% - 110px); } }
.onglets { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; flex: none; }
.onglets::-webkit-scrollbar { display: none; }
.onglets button { flex: none; display: flex; align-items: center; gap: 6px; border: 1px solid var(--trait); background: #fff; border-radius: 999px; padding: 5px 11px; min-height: 34px; font-size: 13.5px; font-weight: 500; }
.onglets button i { width: 9px; height: 9px; border-radius: 50%; background: var(--c, var(--neutre)); }
.onglets button[aria-pressed="true"] { background: var(--c, var(--encre)); border-color: var(--c, var(--encre)); color: #fff; }
.onglets button[aria-pressed="true"] i { background: #fff; }
.fiche { display: grid; grid-template-columns: 56px 1fr; gap: 12px; overflow-y: auto; min-height: 0; }
.fiche .icone { width: 56px; height: 56px; border-radius: 50%; display: block; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.fiche .icone svg, .fiche .icone img { width: 56px; height: 56px; display: block; border-radius: 50%; }
.couche { font: 600 12.5px/1.2 "Barlow Condensed", Barlow, sans-serif; text-transform: uppercase; letter-spacing: .07em; color: var(--c, var(--neutre)); }
.fiche h2 { margin: 2px 0 5px; font-size: 19px; line-height: 1.22; font-weight: 600; text-wrap: balance; }
.fiche p { margin: 0 0 8px; }
.chiffres { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 2px 0 6px; }
.chiffres div { font-variant-numeric: tabular-nums; }
.chiffres b { display: block; font-size: 21px; font-weight: 700; line-height: 1.1; color: var(--c, var(--encre)); }
.chiffres small { display: block; font-size: 12.5px; line-height: 1.3; color: var(--encre-2); max-width: 22ch; }
.source { font-size: 11.5px; color: var(--encre-2); }
.action { margin-top: 8px; border: 0; background: var(--eau); color: #fff; font-weight: 600; border-radius: 10px; padding: 10px 14px; min-height: 42px; }
.pied { display: flex; align-items: center; gap: 8px; flex: none; }
.barre-temps { flex: 1; height: 3px; background: var(--trait); border-radius: 2px; overflow: hidden; }
.barre-temps span { display: block; height: 100%; width: 0; background: var(--c, var(--encre)); }
.pied button { border: 1px solid var(--trait); background: #fff; border-radius: 10px; width: 40px; height: 40px; display: grid; place-items: center; }
.pied svg { width: 18px; height: 18px; }
.compteur { font-size: 12.5px; color: var(--encre-2); font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
.maquette { font-size: 11px; color: var(--encre-2); }
/* ---------- le cycle de l'eau (planche 13) ---------- */
#cycle { position: fixed; inset: 0; z-index: 20; background: var(--papier); display: grid; grid-template-rows: auto 1fr auto; }
#cycle header { display: flex; align-items: center; gap: 10px; padding: calc(env(safe-area-inset-top, 0px) + 10px) 14px 8px; }
#cycle header button { border: 1px solid var(--trait); background: #fff; border-radius: 10px; min-height: 40px; padding: 0 12px; font-weight: 500; }
#cycle header h2 { margin: 0; font: 600 20px/1.1 "Barlow Condensed", Barlow, sans-serif; }
#cycle header p { margin: 0; font-size: 12.5px; color: var(--encre-2); }
.vue-planche { position: relative; overflow: hidden; touch-action: none; cursor: grab; }
.plateau { position: absolute; left: 0; top: 0; width: 1615px; height: 860px; transform-origin: 0 0; will-change: transform; }
.plateau img { position: absolute; inset: 0; width: 100%; height: 100%; user-select: none; -webkit-user-drag: none; pointer-events: none; }
.plateau svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.flux { fill: none; stroke: var(--eau); stroke-width: 7; stroke-linecap: round; stroke-dasharray: 2 16; opacity: .25; transition: opacity .5s; }
.flux.actif { opacity: .95; animation: couler 1.1s linear infinite; }
@keyframes couler { to { stroke-dashoffset: -18; } }
.pe { cursor: pointer; }
.pe .halo { fill: var(--eau); opacity: 0; }
.pe .coeur { fill: #fff; stroke: var(--eau); stroke-width: 3; }
.pe text { font: 700 15px Barlow, sans-serif; fill: var(--eau); text-anchor: middle; dominant-baseline: central; pointer-events: none; }
.pe.actif .coeur { fill: var(--eau); }
.pe.actif text { fill: #fff; }
.pe.actif .halo { animation: halo 1.6s ease-out infinite; }
@keyframes halo { 0% { opacity: .45; r: 16; } 100% { opacity: 0; r: 46; } }
#cycle .bas { background: var(--papier-2); border-top: 1px solid var(--trait); padding: 10px 14px calc(env(safe-area-inset-bottom, 0px) + 10px); display: flex; flex-direction: column; gap: 8px; }
@media (min-width: 900px) { #cycle .bas { position: absolute; left: 16px; bottom: 16px; width: 430px; border: 1px solid var(--trait); border-radius: 16px; box-shadow: var(--ombre); } #cycle { grid-template-rows: auto 1fr; } }
#cycle .etape h3 { margin: 2px 0 5px; font-size: 18px; font-weight: 600; }
#cycle .etape p { margin: 0 0 6px; }
@media (prefers-reduced-motion: reduce) { .flux.actif, .pe.actif .halo { animation: none; } .pe.actif .halo { opacity: .25; } }
</style>
<canvas id="scene" aria-label="Maquette en trois dimensions du quartier type TMIP en 2035"></canvas>
<div id="repli" hidden></div>
<div class="barre">
<div class="titre">
<h1>Quelque part en 2035</h1>
<p>un quartier pavillonnaire, un jour de marché</p>
</div>
<div class="commandes">
<div class="groupe" role="group" aria-label="Époque">
<button type="button" id="b-auj" aria-pressed="false">Aujourd'hui</button>
<button type="button" id="b-2035" aria-pressed="true">2035</button>
</div>
<div class="groupe" role="group" aria-label="Vue">
<button type="button" id="b-axo" aria-pressed="true">Axo</button>
<button type="button" id="b-plan" aria-pressed="false">Plan</button>
</div>
<div class="groupe">
<button type="button" id="b-tourner" aria-label="Tourner d'un quart de tour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg></button>
</div>
</div>
</div>
<section id="fiches" aria-label="Fiches du quartier">
<div class="onglets" id="onglets"></div>
<article class="fiche" id="fiche" aria-live="polite"></article>
<div class="pied">
<div class="barre-temps" aria-hidden="true"><span id="temps"></span></div>
<span class="compteur" id="compteur"></span>
<button type="button" id="b-prec" aria-label="Fiche précédente"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg></button>
<button type="button" id="b-lecture" aria-label="Pause"><svg viewBox="0 0 24 24" fill="currentColor" id="i-lecture"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg></button>
<button type="button" id="b-suiv" aria-label="Fiche suivante"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg></button>
</div>
<div class="maquette">Maquette de test du 27/09 · plan et dessins : Jules Nény, concours Habiter Demain 2020 et TMIP 2024</div>
</section>
<section id="cycle" hidden aria-label="Le cycle de l'eau">
<header>
<button type="button" id="b-retour">← Retour au quartier</button>
<div><h2>Le cycle de l'eau</h2><p>de la gouttière à la parcelle, en onze étapes</p></div>
</header>
<div class="vue-planche" id="vue-planche">
<div class="plateau" id="plateau">
<img id="img-eau" src="__EAU__" alt="Planche du réseau hydrique alternatif : récupération, assainissement, maîtrise hydraulique, phytoépuration, production de biogaz. Dessins de Jules Nény.">
<svg viewBox="0 0 1615 860" id="svg-eau"></svg>
</div>
</div>
<div class="bas">
<article class="etape" id="etape" aria-live="polite"></article>
<div class="pied">
<div class="barre-temps" aria-hidden="true"><span id="temps-eau" style="background: var(--eau)"></span></div>
<span class="compteur" id="compteur-eau"></span>
<button type="button" id="e-prec" aria-label="Étape précédente"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg></button>
<button type="button" id="e-suiv" aria-label="Étape suivante"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg></button>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.min.js"></script>
<script>
(() => {
'use strict';
const D = __SCENE__;
const BULLES = __BULLES__;
const EAU = __EAUPTS__;
const IMG = { sol2035: '__SOL2035__', solauj: '__SOLAUJ__' };
const reduit = matchMedia('(prefers-reduced-motion: reduce)').matches;
const $ = (id) => document.getElementById(id);
const COUL = { eau: '#2d86bf', vivant: '#5a9837', chaleur: '#c4850f', quartier: '#d4662a' };
const NOM = { eau: "L'eau", vivant: 'Le vivant nourricier', chaleur: "L'enveloppe et la chaleur", quartier: 'La vie de quartier' };
// coordonnées : pixels du plan à 100 dpi -> mètres monde (x vers l'est, z vers le sud)
const P = (x, y) => ({ x: +((x - D.W / 2) / D.s).toFixed(1), z: +((y - D.H / 2) / D.s).toFixed(1) });
// ---------------------------------------------------------------- icônes des fiches (même style que les bulles)
const svgIcone = (coul, corps) => `<svg viewBox="0 0 56 56" role="img" aria-hidden="true"><circle cx="28" cy="28" r="25" fill="#fff" stroke="${coul}" stroke-width="2.5"/>${corps}</svg>`;
const ICONES = {
toit: svgIcone(COUL.eau, '<path d="M14 27l12-10 12 10" fill="none" stroke="#2d86bf" stroke-width="2.6" stroke-linejoin="round"/><rect x="33" y="31" width="9" height="12" rx="2" fill="#9fd0ec" stroke="#2d86bf" stroke-width="2"/><path d="M38 22c2 3 3 4.5 3 6a3 3 0 0 1-6 0c0-1.5 1-3 3-6z" fill="#2d86bf"/><path d="M17 30v12h12V30" fill="none" stroke="#2d86bf" stroke-width="2.2"/>'),
rigole: svgIcone(COUL.eau, '<path d="M13 22h9l6 6h8l7 7" fill="none" stroke="#2d86bf" stroke-width="2.6" stroke-dasharray="3 3" stroke-linecap="round"/><path d="M39 31l4 4-5 1" fill="none" stroke="#2d86bf" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M18 36c2 3 3 4.5 3 6a3 3 0 0 1-6 0c0-1.5 1-3 3-6z" fill="#2d86bf"/>'),
noue: svgIcone(COUL.eau, '<path d="M11 34c4 5 9 7 17 7s13-2 17-7" fill="#bfe0f2" stroke="#2d86bf" stroke-width="2.4"/><path d="M20 33v-9M24 33v-12M32 33v-10M36 33v-7" stroke="#5a9837" stroke-width="2.4" stroke-linecap="round"/>'),
bassin: svgIcone(COUL.eau, '<path d="M11 30h34" stroke="#8a847a" stroke-width="2"/><path d="M13 31c3 8 8 11 15 11s12-3 15-11z" fill="#bfe0f2" stroke="#2d86bf" stroke-width="2.4"/><path d="M19 36c3-2 6 2 9 0s6-2 9 0" fill="none" stroke="#2d86bf" stroke-width="2"/><path d="M34 29v-9M38 29v-6" stroke="#5a9837" stroke-width="2.4" stroke-linecap="round"/>'),
paille: svgIcone(COUL.chaleur, '<path d="M14 28l14-12 14 12" fill="none" stroke="#c4850f" stroke-width="2.6" stroke-linejoin="round"/><rect x="17" y="27" width="22" height="15" fill="#f1cf6b" stroke="#c4850f" stroke-width="2.2"/><path d="M21 31h6M29 35h6M21 39h6" stroke="#c4850f" stroke-width="1.6"/><path d="M34 14v7" stroke="#8a847a" stroke-width="3"/>'),
quartier: svgIcone(COUL.vivant, '<path d="M9 36l7-6 7 6v7H9zM24 33l8-7 8 7v10H24zM37 37l5-4 5 4v6H37z" fill="#fff" stroke="#6c665b" stroke-width="2" stroke-linejoin="round"/><circle cx="17" cy="21" r="4" fill="#9dc07a"/><circle cx="41" cy="24" r="3.5" fill="#f0a860"/>'),
liens: svgIcone(COUL.quartier, '<path d="M28 12l-8 31M28 12l8 31M22 30h12M24 22h8" fill="none" stroke="#6c665b" stroke-width="2.4" stroke-linecap="round"/><path d="M10 44c6-4 12-4 18 0s12 4 18 0" fill="none" stroke="#d4662a" stroke-width="2.2" stroke-dasharray="3 3"/>'),
route: svgIcone(COUL.quartier, '<rect x="13" y="26" width="30" height="10" rx="4" fill="#fff" stroke="#6c665b" stroke-width="2.4"/><path d="M18 26l4-6h12l4 6" fill="none" stroke="#6c665b" stroke-width="2.4" stroke-linejoin="round"/><circle cx="20" cy="37" r="3" fill="#6c665b"/><circle cx="36" cy="37" r="3" fill="#6c665b"/>'),
};
const icone = (k) => ICONES[k] ? ICONES[k] : (BULLES[k] ? `<img src="${BULLES[k]}" alt="">` : ICONES.quartier);
// ---------------------------------------------------------------- les fiches
const C2035 = [
{ id: 'intro', ic: 'quartier', t: 'Quelque part en 2035', p: "Un quartier pavillonnaire ordinaire, vingt ans après. Les maisons sont isolées, les jardins nourrissent, l'eau reste sur place et la place a retrouvé un marché. Chaque couleur suit un système.", ch: [], s: 'TMIP 2024, p. 15 et 20', cam: { ...P(827, 560), w: 210 } },
{ id: 'toits', c: 'eau', ic: 'toit', t: 'Les toits récoltent la pluie', p: "Chaque maison transformée capte l'eau de son toit : gouttière, filtre, cuve. Cette eau suffit pour les WC, le lave-linge et le jardin.", ch: [['+80 %', "d'eau de pluie récupérée par maison"], ['98 %', "des usages n'ont pas besoin d'eau potable"]], s: 'TMIP 2024, p. 10-11', cam: { ...P(898, 712), w: 62 }, fx: 'pluie' },
{ id: 'rigoles', c: 'eau', ic: 'rigole', t: 'De petites rigoles vers les noues', p: "L'eau grise de la maison et le trop-plein des cuves partent à l'air libre, par une rigole, vers la noue plantée au fond du jardin.", ch: [['82 %', 'des rejets de la maison sont des eaux grises']], s: 'TMIP 2024, p. 13', cam: { ...P(1330, 790), w: 95 }, fx: 'liaisons' },
{ id: 'noues', c: 'eau', ic: 'noue', t: 'Les noues épurent et arrosent', p: "Une noue est un fossé planté. Les racines filtrent l'eau, la ralentissent et arrosent les haies fruitières qui la bordent.", ch: [['−80 %', "d'eau rejetée à l'égout"], ['−250 €', 'par an et par maison']], s: 'TMIP 2024, p. 11 et 13', cam: { ...P(560, 400), w: 150 }, fx: 'noues' },
{ id: 'mare', c: 'eau', ic: 'oiseaux', t: "L'eau se rassemble dans la mare", p: "Les noues descendent en pente douce jusqu'à la mare de la place. Elle rafraîchit l'été, accueille oiseaux et libellules, et retient l'orage.", ch: [], s: 'TMIP 2024, p. 13 et 15', cam: { ...P(700, 450), w: 64 }, fx: 'mare' },
{ id: 'bassins', c: 'eau', ic: 'bassin', t: 'Des bassins à ciel ouvert', p: "Le long de la route, un bassin planté recueille les grosses pluies. Il fait le travail d'un bassin d'orage, et il épure, rafraîchit, nourrit la vie.", ch: [], s: 'TMIP 2024, p. 13 §4-5', cam: { ...P(720, 760), w: 82 }, fx: 'bassins', action: "Entrer dans le cycle de l'eau" },
{ id: 'haies', c: 'vivant', ic: 'pomme', t: 'Les limites deviennent des haies fruitières', p: "Sur trois côtés du jardin, une haie de pommiers, poiriers et petits fruits. Après cinq ans, elle produit plus que la famille ne consomme.", ch: [['+12', 'personnes nourries en fruits, par maison'], ['85 m', "de haie autour d'un jardin de 750 m²"]], s: 'TMIP 2024, p. 11', cam: { ...P(270, 760), w: 95 }, fx: 'haies' },
{ id: 'sentiers', c: 'vivant', ic: 'promeneurs', t: "Le sentier d'entretien s'ouvre aux voisins", p: "La loi impose deux mètres entre un arbre et la limite voisine. Cette bande devient un sentier, ouvert par un droit d'usage : on y passe, on y cueille.", ch: [['2 m', 'de recul obligatoire, devenus un chemin']], s: 'TMIP 2024, p. 14', cam: { ...P(1330, 800), w: 110 }, fx: 'sentiers' },
{ id: 'foret', c: 'vivant', ic: 'abeille', t: 'La forêt nourricière', p: "À l'est, une forêt de fruitiers, de mellifères et d'essences qui dépolluent l'air et le sol. Des ruches, du miel, un chemin qui la traverse.", ch: [], s: 'TMIP 2024, p. 14-15', cam: { ...P(1470, 330), w: 120 }, fx: 'foret' },
{ id: 'maison', c: 'chaleur', ic: 'paille', t: 'La maison se couvre de paille', p: "Isolation par l'extérieur en bottes de paille et enduit terre, une serre au sud, un poêle à bois d'appoint. La facture de chauffage fond.", ch: [['−85 %', "de consommation d'énergie"], ['1 000 à 5 000 €', "d'économies par an"]], s: 'TMIP 2024, p. 11-12', cam: { ...P(1180, 712), w: 58 }, fx: 'maisons' },
{ id: 'chantier', c: 'chaleur', ic: 'outils', t: 'On isole ensemble', p: "Un chantier-école par maison : les voisins apprennent en faisant. En autoconstruction accompagnée, l'isolation coûte deux à trois fois moins cher.", ch: [['11 700 à 23 400 €', 'en autoconstruction accompagnée'], ['39 900 à 56 100 €', 'par une entreprise']], s: 'TMIP 2024, p. 16 et 28', cam: { ...P(760, 100), w: 58 }, fx: 'maisons' },
{ id: 'marche', c: 'quartier', ic: 'marche', t: 'Le parking devient le marché', p: "Sur la place, les emplacements de stationnement accueillent les étals : fruits des haies, conserves, pain, miel.", ch: [], s: 'TMIP 2024, p. 15 et 20', cam: { ...P(505, 300), w: 56 }, fx: 'marche' },
{ id: 'communs', c: 'quartier', ic: 'biere', t: 'Le grand bâtiment devient un lieu commun', p: "Brasserie, cantine, atelier de réparation, potagers devant la porte. Les activités qu'on allait chercher en zone commerciale reviennent dans le quartier.", ch: [], s: 'TMIP 2024, p. 14-15', cam: { ...P(330, 540), w: 62 }, fx: 'communs' },
{ id: 'mobilite', c: 'quartier', ic: 'enfant', t: 'On se déplace à pied et à vélo', p: "Les sentiers relient les jardins, la forêt et la place. La route se calme, les enfants jouent dehors.", ch: [['80 km', "de voiture par jour et par habitant, aujourd'hui"]], s: 'concours Habiter Demain 2020, planche 1', cam: { ...P(720, 520), w: 130 }, fx: 'mobilite' },
{ id: 'travail', c: 'quartier', ic: 'route', t: 'Ce qui ne change pas : le travail', p: "La voiture du matin vers le bassin d'emploi reste. TMIP ne prétend pas y répondre ; le montrer rend le reste crédible.", ch: [], s: 'proposition de la maquette, à valider', cam: { ...P(230, 300), w: 340 }, fx: 'travail' },
{ id: 'horschamp', ic: 'liens', t: "Ce dont le quartier s'est libéré", p: "Autour, les grandes infrastructures dont il dépendait : centrale et gaz, supermarché, station d'épuration, gravière, agriculture lointaine. Leurs liens se sont détendus. Celui du travail reste tendu.", ch: [['850 km', 'parcourus par le contenu des rayons'], ['3 %', "des fruits mangés en Île-de-France y poussent"]], s: 'TMIP 2024, p. 10', cam: { x: -20, z: 0, w: 900 }, fx: 'flux' },
];
const CAUJ = [
{ id: 'auj1', ic: 'quartier', t: "Aujourd'hui : un quartier ordinaire", p: "Pelouses, haies de thuyas, une voiture par adulte. La pluie part à l'égout, la chaleur vient du gaz, les courses du supermarché.", ch: [['23 %', "du bâti français est de l'habitat individuel périurbain"], ['750 m²', 'de jardin en moyenne']], s: 'TMIP 2024, p. 7', cam: { ...P(827, 560), w: 210 } },
{ id: 'auj2', ic: 'liens', t: 'Tout vient de loin', p: "Eau, énergie, nourriture, travail : chaque besoin tire un fil vers une grande infrastructure. Le ménage paie tous les fils.", ch: [['80 km', 'de voiture par jour et par habitant'], ['1/3', 'des revenus au crédit, sur 30 ans']], s: 'concours Habiter Demain 2020, planches 1 et 2', cam: { x: -20, z: 0, w: 900 }, fx: 'flux' },
{ id: 'auj3', ic: 'route', t: 'Sur la place, un parking', p: "La place sert à garer les voitures. Pour croiser ses voisins, il reste le supermarché.", ch: [], s: 'plan du quartier actuel, concours 2020', cam: { ...P(505, 300), w: 56 }, fx: 'parking' },
];
// bulles de la planche 2030 : fiche liée, ou une ligne quand il n'y en a pas
const SIGNES = {
marche: ['marche'], pomme: ['haies'], abeille: ['foret'], miel: ['foret'], fleur: ['foret', 'Des plantes mellifères et médicinales en lisière.'], arbre: ['foret'],
biere: ['communs'], couverts: ['communs'], outils: ['communs'], oiseaux: ['mare'], promeneurs: ['sentiers'], enfant: ['mobilite'],
film: [null, 'Un ciné-club dans un garage ouvert.', 'quartier'], ballons: [null, 'Les délaissés deviennent terrains de jeu.', 'quartier'],
barbecue: [null, 'Un barbecue partagé au bord de la place.', 'quartier'], confiture: [null, 'Une conserverie : le surplus des haies en bocaux.', 'quartier'],
bouteille: [null, 'Jus et cidre des vergers du quartier.', 'quartier'], musique: [null, 'Un bal sur la place, au bord de la mare.', 'quartier'],
bols: [null, 'Un atelier de céramique, celui dont rêvait Sophia en 2020.', 'quartier'], soin: [null, 'Un cabinet de soin, une herboristerie.', 'quartier'],
entraide: [null, "Garde d'enfants partagée, entraide entre voisins.", 'quartier'], reemploi: [null, 'Une recyclerie et un compost de quartier.', 'quartier'],
garage: [null, 'Un garage associatif de réparation.', 'quartier'], apaise1: ['mobilite'], apaise2: ['mobilite'],
};
const VERT = new Set(['pomme', 'abeille', 'fleur', 'arbre', 'oiseaux']);
// ---------------------------------------------------------------- moteur des fiches
const E = { epoque: '2035', filtre: 'tout', i: 0, lecture: !reduit, t0: performance.now(), pause: 0, mini: null };
const DUREE = 10000;
const liste = () => (E.epoque === 'auj' ? CAUJ : C2035).filter((c) => E.filtre === 'tout' || c.c === E.filtre);
function onglets() {
const o = $('onglets');
if (E.epoque === 'auj') { o.innerHTML = ''; o.hidden = true; return; }
o.hidden = false;
o.innerHTML = [['tout', 'Tout'], ['eau', 'Eau'], ['vivant', 'Vivant'], ['chaleur', 'Chaleur'], ['quartier', 'Quartier']]
.map(([k, n]) => `<button type="button" data-f="${k}" aria-pressed="${E.filtre === k}" style="${COUL[k] ? '--c:' + COUL[k] : ''}"><i></i>${n}</button>`).join('');
o.querySelectorAll('button').forEach((b) => b.addEventListener('click', () => { E.filtre = b.dataset.f; E.i = 0; onglets(); montrer(); }));
}
function montrer(voler = true) {
const L = liste(); if (!L.length) return;
E.i = (E.i + L.length) % L.length;
const c = E.mini || L[E.i];
const f = $('fiches'); f.style.setProperty('--c', c.c ? COUL[c.c] : 'var(--encre)');
$('fiche').innerHTML = `<div class="icone">${icone(c.ic)}</div><div>
<div class="couche">${c.c ? NOM[c.c] : (E.epoque === 'auj' ? "Aujourd'hui" : '2035')}</div>
<h2>${c.t}</h2><p>${c.p}</p>
${c.ch && c.ch.length ? `<div class="chiffres">${c.ch.map(([v, l]) => `<div><b>${v}</b><small>${l}</small></div>`).join('')}</div>` : ''}
<div class="source">${c.ch && c.ch.length ? 'Chiffres pour une maison · ' : ''}source : ${c.s}</div>
${c.action ? `<button type="button" class="action" id="b-cycle">${c.action} →</button>` : ''}</div>`;
const b = $('b-cycle'); if (b) b.addEventListener('click', ouvrirCycle);
$('compteur').textContent = E.mini ? '' : `${E.i + 1} / ${L.length}`;
E.t0 = performance.now();
if (T3) T3.activer(c, voler);
}
function aller(d) { E.mini = null; E.i += d; montrer(); }
function suspendre(ms = 25000) { E.pause = performance.now() + ms; }
$('b-prec').addEventListener('click', () => { suspendre(); aller(-1); });
$('b-suiv').addEventListener('click', () => { suspendre(); aller(1); });
$('b-lecture').addEventListener('click', () => { E.lecture = !E.lecture; E.pause = 0; E.t0 = performance.now(); majLecture(); });
function majLecture() {
$('b-lecture').setAttribute('aria-label', E.lecture ? 'Pause' : 'Lecture');
$('i-lecture').innerHTML = E.lecture ? '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>' : '<path d="M7 5l12 7-12 7z"/>';
}
function tic(now) {
if (!$('cycle').hidden) return;
const actif = E.lecture && now > E.pause && !E.mini;
if (!actif) { if (now <= E.pause) E.t0 = now - (DUREE * (parseFloat($('temps').style.width) || 0)) / 100; return; }
const k = (now - E.t0) / DUREE;
$('temps').style.width = Math.min(100, k * 100) + '%';
if (k >= 1) aller(1);
}
// ---------------------------------------------------------------- la scène 3D
let T3 = null;
try { T3 = window.THREE ? scene3D() : null; } catch (e) { console.error(e); T3 = null; }
if (!T3) { $('scene').hidden = true; const r = $('repli'); r.hidden = false; r.style.backgroundImage = `url(${IMG.sol2035})`; }
function scene3D() {
const canvas = $('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
if (!renderer.getContext()) return null;
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.useLegacyLights = true;
renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#ece6d8');
const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 4000);
const sRGB = (h) => new THREE.Color(h);
const rnd = (i) => { const x = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
// lumière : ciel chaud + soleil bas du sud-ouest
scene.add(new THREE.HemisphereLight(0xfffbf2, 0xd8d0bd, 0.78));
const soleil = new THREE.DirectionalLight(0xfff0d8, 0.36);
soleil.position.set(-110, 170, 120); soleil.target.position.set(0, 0, 0);
soleil.castShadow = true; soleil.shadow.mapSize.set(2048, 2048);
Object.assign(soleil.shadow.camera, { left: -170, right: 170, top: 130, bottom: -130, near: 20, far: 520 });
soleil.shadow.bias = -0.0006; soleil.shadow.normalBias = 0.35;
scene.add(soleil, soleil.target);
// sol : le plan vectoriel, en deux états
const chargeur = new THREE.TextureLoader();
const tex = (url) => { const t = chargeur.load(url, () => (besoin = true)); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = renderer.capabilities.getMaxAnisotropy(); return t; };
const papier = new THREE.Mesh(new THREE.PlaneGeometry(5000, 5000).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#e6dfcf' }));
papier.position.y = -2.3; papier.receiveShadow = true; scene.add(papier);
// socle de maquette : le plan posé sur une épaisseur
const socle = new THREE.Mesh(new THREE.BoxGeometry(D.Wm, 2.2, D.Hm), new THREE.MeshLambertMaterial({ color: '#d9cfba' }));
socle.position.y = -1.13; socle.receiveShadow = true; scene.add(socle);
const geoSol = new THREE.PlaneGeometry(D.Wm, D.Hm).rotateX(-Math.PI / 2);
const sol35 = new THREE.Mesh(geoSol, new THREE.MeshLambertMaterial({ map: tex(IMG.sol2035) }));
sol35.receiveShadow = true; scene.add(sol35);
const matAuj = new THREE.MeshLambertMaterial({ map: tex(IMG.solauj), transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 });
const solAuj = new THREE.Mesh(geoSol, matAuj); solAuj.receiveShadow = true; solAuj.position.y = 0.02; scene.add(solAuj);
const G = { v2035: new THREE.Group(), vAuj: new THREE.Group() };
scene.add(G.v2035, G.vAuj);
const surv = { maisons: [], haies: null, foret: null, etals: [], grand: null, noues: [], bassins: [], liaisons: [], sentiers: [], chemins: [], flux: {}, bulles: [], pluie: [], ondes: [], fumee: [] };
const cliquables = [];
// ---------- maisons
const mat = {
mur: new THREE.MeshLambertMaterial({ color: '#f8f5ee' }), toit: new THREE.MeshLambertMaterial({ color: '#cfc8ba', side: THREE.DoubleSide }),
paille: new THREE.MeshLambertMaterial({ color: '#efc85a', emissive: '#000000' }), toitT: new THREE.MeshLambertMaterial({ color: '#c9bfab', side: THREE.DoubleSide }),
verre: new THREE.MeshLambertMaterial({ color: '#cde9e5', transparent: true, opacity: 0.55, depthWrite: false }), bord: new THREE.LineBasicMaterial({ color: '#86ada7' }),
chem: new THREE.MeshLambertMaterial({ color: '#a19a8e' }), cuve: new THREE.MeshLambertMaterial({ color: '#6fb0d8' }),
grandMur: new THREE.MeshLambertMaterial({ color: '#f6f1e7' }), grandToit: new THREE.MeshLambertMaterial({ color: '#d7a07e', side: THREE.DoubleSide, emissive: '#000000' }),
};
function toit(Lu, Lv, eave, rh, o) {
const hu = Lu / 2 + o, hv = Lv / 2 + o, ry = eave + rh, yb = eave - rh * (o / (Lv / 2));
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute([
-hu, yb, -hv, hu, yb, -hv, hu, ry, 0, -hu, yb, -hv, hu, ry, 0, -hu, ry, 0,
-hu, yb, hv, -hu, ry, 0, hu, ry, 0, -hu, yb, hv, hu, ry, 0, hu, yb, hv], 3));
g.computeVertexNormals(); return g;
}
function pignons(Lu, Lv, eave, rh) {
const g = new THREE.BufferGeometry(), x = Lu / 2, v = Lv / 2, ry = eave + rh;
g.setAttribute('position', new THREE.Float32BufferAttribute([-x, eave, -v, -x, eave, v, -x, ry, 0, x, eave, v, x, eave, -v, x, ry, 0], 3));
g.computeVertexNormals(); return g;
}
function maison(h, variante) {
const p = h.p.map(([x, z]) => new THREE.Vector2(x, z));
const c = p.reduce((a, b) => a.add(b), new THREE.Vector2()).multiplyScalar(0.25);
const e1 = p[1].clone().sub(p[0]), e2 = p[2].clone().sub(p[1]);
const fw = new THREE.Vector2(Math.cos(h.f), -Math.sin(h.f));
let u, Lu, Lv;
if (Math.abs(e1.clone().normalize().dot(fw)) >= Math.abs(e2.clone().normalize().dot(fw))) { u = e1.clone().normalize(); Lu = e1.length(); Lv = e2.length(); }
else { u = e2.clone().normalize(); Lu = e2.length(); Lv = e1.length(); }
const g = new THREE.Group(); g.position.set(c.x, 0, c.y); g.rotation.y = Math.atan2(-u.y, u.x);
const grand = h.t === 'grand', transfo = h.t === 'transformee' && variante === '2035';
const eave = grand ? 7.4 : 5.4, rh = Math.min(grand ? 5 : 3.4, Lv * (grand ? 0.27 : 0.31)), ite = transfo ? 0.7 : 0;
const mMur = grand ? mat.grandMur : transfo ? mat.paille : mat.mur, mToit = grand ? mat.grandToit : transfo ? mat.toitT : mat.toit;
const murs = new THREE.Mesh(new THREE.BoxGeometry(Lu + ite, eave, Lv + ite), mMur); murs.position.y = eave / 2;
const t = new THREE.Mesh(toit(Lu + ite, Lv + ite, eave, rh, 0.55), mToit);
const pg = new THREE.Mesh(pignons(Lu + ite, Lv + ite, eave, rh), new THREE.MeshLambertMaterial({ color: mMur.color, side: THREE.DoubleSide }));
if (transfo) pg.material = mat.paille;
[murs, t, pg].forEach((m) => { m.castShadow = true; m.receiveShadow = true; g.add(m); });
if (transfo) {
// serre sur la face la plus au sud
const th = g.rotation.y, faces = [[1, 0, Math.cos(th), -Math.sin(th)], [-1, 0, -Math.cos(th), Math.sin(th)], [0, 1, Math.sin(th), Math.cos(th)], [0, -1, -Math.sin(th), -Math.cos(th)]];
const f = faces.reduce((a, b) => (b[3] > a[3] ? b : a));
const sh = 3.3, d = 2.4, cote = f[0] !== 0 ? Lv : Lu;
const serre = new THREE.Mesh(new THREE.BoxGeometry(f[0] !== 0 ? d : cote * 0.7, sh, f[0] !== 0 ? cote * 0.7 : d), mat.verre);
serre.position.set(f[0] * ((Lu + ite) / 2 + d / 2), sh / 2, f[1] * ((Lv + ite) / 2 + d / 2)); serre.renderOrder = 1; g.add(serre);
const ar = new THREE.LineSegments(new THREE.EdgesGeometry(serre.geometry), mat.bord); ar.position.copy(serre.position); g.add(ar);
const ch = new THREE.Mesh(new THREE.BoxGeometry(0.7, 2.6, 0.7), mat.chem); ch.position.set(Lu * 0.2, eave + rh * 0.62 + 0.7, -f[1] * Lv * 0.12); ch.castShadow = true; g.add(ch);
// la cuve, au pied de la descente de gouttière, à l'opposé de la serre
const bout = (Lu + ite) / 2 + 1.1;
const cu = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 1.8, 16), mat.cuve);
if (f[0] !== 0) cu.position.set(-f[0] * bout, 0.9, Lv / 2 - 1); else cu.position.set(bout, 0.9, -f[1] * (Lv / 2 - 1));
cu.castShadow = true; g.add(cu);
g.updateMatrixWorld(true);
surv.maisons.push({ g, chem: ch.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, 1.4, 0)), cuve: cu.getWorldPosition(new THREE.Vector3()), eave, c });
}
if (grand) surv.grand = g;
g.userData = { t: h.t };
return g;
}
for (const h of D.maisons) {
if (h.t === 'transformee') { G.v2035.add(maison(h, '2035')); G.vAuj.add(maison(h, 'auj')); }
else scene.add(maison(h, '2035'));
}
G.v2035.children.forEach((g) => cliquables.push({ o: g, carte: 'maison' }));
if (surv.grand) cliquables.push({ o: surv.grand, carte: 'communs' });
// ---------- arbres (positions et couleurs tirées du plan)
const geoC = new THREE.IcosahedronGeometry(1, 1), geoT = new THREE.CylinderGeometry(0.1, 0.17, 1, 5).translate(0, 0.5, 0);
const TYPES = [{ base: 1.3, rmin: 1.1, sy: 1.05, tronc: true, g: 'v2035' }, { base: 2.6, rmin: 1.5, sy: 1.2, tronc: true, g: 'v2035' }, { base: 3.0, rmin: 2.0, sy: 1.15, tronc: true, g: 'tous' }, { base: 0, rmin: 0.75, sy: 0.8, tronc: false, g: 'tous' }];
const matTronc = new THREE.MeshLambertMaterial({ color: '#8a7258' });
const couleurs = D.pal.map(([r, g, b]) => new THREE.Color().setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace));
TYPES.forEach((T, k) => {
const A = D.arbres.filter((a) => a[4] === k); if (!A.length) return;
const m = new THREE.MeshLambertMaterial({ color: '#ffffff', emissive: '#000000' });
const im = new THREE.InstancedMesh(geoC, m, A.length); im.castShadow = true;
const it = T.tronc ? new THREE.InstancedMesh(geoT, matTronc, A.length) : null;
const M = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3(), V = new THREE.Vector3();
A.forEach((a, i) => {
const j = 0.88 + rnd(i + k * 7919) * 0.24, r = Math.max(T.rmin, a[2] * (k === 1 ? 1.25 : 1)) * j;
V.set(a[0], T.base + r * T.sy * 0.85, a[1]); S.set(r, r * T.sy, r); Q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), rnd(i) * 6.28);
im.setMatrixAt(i, M.compose(V, Q, S)); im.setColorAt(i, couleurs[a[3]]);
if (it) { V.set(a[0], 0, a[1]); S.set(1, T.base + r * 0.4, 1); it.setMatrixAt(i, M.compose(V, new THREE.Quaternion(), S)); }
});
im.instanceColor.needsUpdate = true;
const cible = T.g === 'v2035' ? G.v2035 : scene; cible.add(im); if (it) cible.add(it);
if (k === 0) surv.haies = m; if (k === 1) surv.foret = m;
});
// ---------- personnages
const geoP = new THREE.CapsuleGeometry(0.28, 0.95, 3, 8);
const PAL_P = ['#d86a3a', '#3d6fa8', '#e3b23c', '#6b8f3a', '#b0556b', '#4a4a4a', '#c9c2b0', '#8a5a9e'].map(sRGB);
const imP = new THREE.InstancedMesh(geoP, new THREE.MeshLambertMaterial({ color: '#ffffff' }), D.persos.length); imP.castShadow = true;
D.persos.forEach(([x, z], i) => { imP.setMatrixAt(i, new THREE.Matrix4().makeTranslation(x, 0.76, z)); imP.setColorAt(i, PAL_P[i % PAL_P.length]); });
imP.instanceColor.needsUpdate = true; G.v2035.add(imP);
// ---------- étals du marché (2035) / voitures garées (aujourd'hui)
const PAL_E = ['#e98a4a', '#f2c14e', '#e36d5a', '#7fb069', '#5aa2d6'];
const matEtal = PAL_E.map((c) => new THREE.MeshLambertMaterial({ color: c, emissive: '#000000' }));
const matPoteau = new THREE.MeshLambertMaterial({ color: '#7b6a55' }), matTable = new THREE.MeshLambertMaterial({ color: '#c49a64' });
function axes(pts) {
const p = pts.map(([x, z]) => new THREE.Vector2(x, z)), c = p.reduce((a, b) => a.add(b), new THREE.Vector2()).multiplyScalar(0.25);
const e1 = p[1].clone().sub(p[0]), e2 = p[2].clone().sub(p[1]); const [u, L, W] = e1.length() >= e2.length() ? [e1.clone().normalize(), e1.length(), e2.length()] : [e2.clone().normalize(), e2.length(), e1.length()];
return { c, rot: Math.atan2(-u.y, u.x), L, W };
}
const PAL_V = ['#8f98a3', '#b24a3b', '#3e5f8a', '#d9d4c7', '#565a5e', '#7a8b5a'].map(sRGB);
function voiture(k) {
const g = new THREE.Group(), m = new THREE.MeshLambertMaterial({ color: PAL_V[k % PAL_V.length] });
const a = new THREE.Mesh(new THREE.BoxGeometry(4.2, 0.9, 1.8), m); a.position.y = 0.65;
const b = new THREE.Mesh(new THREE.BoxGeometry(2.3, 0.75, 1.62), m); b.position.set(-0.25, 1.45, 0);
[a, b].forEach((x) => { x.castShadow = true; g.add(x); }); return g;
}
D.etals.forEach((e, i) => {
const a = axes(e.p), g = new THREE.Group(); g.position.set(a.c.x, 0, a.c.y); g.rotation.y = a.rot;
const L = Math.min(a.L, 4.5), W = Math.min(a.W, 3);
const toile = new THREE.Mesh(new THREE.BoxGeometry(L + 0.4, 0.16, W + 0.4), matEtal[i % matEtal.length]); toile.position.y = 2.45; toile.castShadow = true; g.add(toile);
for (const [sx, sz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) { const po = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.45, 5), matPoteau); po.position.set(sx * L / 2, 1.22, sz * W / 2); g.add(po); }
const tb = new THREE.Mesh(new THREE.BoxGeometry(L * 0.9, 0.85, W * 0.45), matTable); tb.position.set(0, 0.42, W * 0.18); tb.castShadow = true; g.add(tb);
G.v2035.add(g); surv.etals.push(g); cliquables.push({ o: g, carte: 'marche' });
const v = voiture(i); v.position.set(a.c.x, 0, a.c.y); v.rotation.y = a.rot + Math.PI / 2; G.vAuj.add(v);
});
// ---------- eau, sentiers : surfaces qui s'allument
const aire = (pts) => Math.abs(pts.reduce((s, p, i) => { const q = pts[(i + 1) % pts.length]; return s + p[0] * q[1] - q[0] * p[1]; }, 0)) / 2;
function surface(pts, couleur, y) {
const sh = new THREE.Shape(pts.map(([x, z]) => new THREE.Vector2(x, -z)));
const g = new THREE.ShapeGeometry(sh).rotateX(-Math.PI / 2);
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color: couleur, transparent: true, opacity: 0, depthWrite: false }));
m.position.y = y; m.renderOrder = 2; G.v2035.add(m); return m;
}
D.noues.forEach((pts) => { const m = surface(pts, '#1f7fc0', 0.08); surv.noues.push(m); cliquables.push({ o: m, carte: 'noues' }); });
D.plantes.forEach((pts) => { if (pts.length > 3 && aire(pts) > 60) { const m = surface(pts, '#1c9aa8', 0.08); surv.bassins.push(m); cliquables.push({ o: m, carte: 'bassins' }); } });
D.sentiers.forEach((pts) => surv.sentiers.push(surface(pts, '#e3a92c', 0.07)));
// rubans en pointillés qui coulent (liaisons, chemins, liens du hors-champ)
const vs = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }';
const fs = 'uniform float uT; uniform float uA; uniform float uV; uniform float uP; uniform vec3 uC; varying vec2 vUv; void main(){ float d = fract(vUv.x / uP - uT * uV); float t = smoothstep(0.0, 0.08, d) * (1.0 - smoothstep(0.45, 0.55, d)); float e = 1.0 - smoothstep(0.3, 0.5, abs(vUv.y - 0.5)); gl_FragColor = vec4(uC, uA * (0.22 + 0.78 * t) * e); }';
const hex3 = (h) => { const c = parseInt(h.slice(1), 16); return new THREE.Vector3((c >> 16 & 255) / 255, (c >> 8 & 255) / 255, (c & 255) / 255); };
function ruban(pts, largeur, couleur, vitesse, pas, y, groupe) {
const pos = [], uv = [], idx = []; let L = 0;
for (let i = 0; i < pts.length; i++) {
const [x, z] = pts[i], a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
let dx = b[0] - a[0], dz = b[1] - a[1]; const n = Math.hypot(dx, dz) || 1; dx /= n; dz /= n;
const rx = -dz * largeur / 2, rz = dx * largeur / 2;
if (i > 0) L += Math.hypot(x - pts[i - 1][0], z - pts[i - 1][1]);
pos.push(x - rx, 0, z - rz, x + rx, 0, z + rz); uv.push(L, 0, L, 1);
if (i > 0) { const k = 2 * i; idx.push(k - 2, k - 1, k, k - 1, k + 1, k); }
}
const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx);
const m = new THREE.ShaderMaterial({ vertexShader: vs, fragmentShader: fs, transparent: true, depthWrite: false, side: THREE.DoubleSide,
uniforms: { uT: { value: 0 }, uA: { value: 0 }, uV: { value: vitesse }, uP: { value: pas }, uC: { value: hex3(couleur) } } });
const mesh = new THREE.Mesh(g, m); mesh.position.y = y; mesh.renderOrder = 3; (groupe || G.v2035).add(mesh); return mesh;
}
const sommetsNoues = D.noues.flat();
const dNoue = ([x, z]) => Math.min(...sommetsNoues.map(([a, b]) => (a - x) ** 2 + (b - z) ** 2));
D.liaisons.forEach((l) => { const pts = dNoue(l[0]) < dNoue(l[l.length - 1]) ? l.slice().reverse() : l; surv.liaisons.push(ruban(pts, 1.0, '#1f7fc0', 0.9, 2.2, 0.14)); });
D.chemins.forEach((l) => { let L = 0; for (let i = 1; i < l.length; i++) L += Math.hypot(l[i][0] - l[i - 1][0], l[i][1] - l[i - 1][1]); if (L > 12) surv.chemins.push(ruban(l, 1.1, '#d0901f', 0.35, 3.0, 0.13)); });
// pluie des toits vers les cuves, ondes sur la mare, fumée des poêles
const disque = (() => { const c = document.createElement('canvas'); c.width = c.height = 64; const x = c.getContext('2d'); const g = x.createRadialGradient(32, 32, 2, 32, 32, 30); g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)'); x.fillStyle = g; x.fillRect(0, 0, 64, 64); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
surv.maisons.forEach((m, k) => {
for (let i = 0; i < 7; i++) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: disque, color: '#2d86bf', transparent: true, opacity: 0, depthWrite: false }));
s.scale.set(0.9, 0.9, 1); s.userData = { m, ph: i / 7 }; G.v2035.add(s); surv.pluie.push(s);
}
for (let i = 0; i < 6; i++) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: disque, color: '#b9b2a6', transparent: true, opacity: 0.5, depthWrite: false }));
s.userData = { m, ph: (i + rnd(k * 13 + i)) / 6 }; G.v2035.add(s); surv.fumee.push(s);
}
});
const MARE = P(700, 468);
for (let i = 0; i < 3; i++) {
const o = new THREE.Mesh(new THREE.RingGeometry(0.85, 1.15, 48).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: '#1f7fc0', transparent: true, opacity: 0, depthWrite: false }));
o.position.set(MARE.x, 0.16, MARE.z); o.userData = { ph: i / 3 }; G.v2035.add(o); surv.ondes.push(o);
}
// ---------- véhicules et marcheurs le long des voies
function trajet(pts) { const c = [0]; for (let i = 1; i < pts.length; i++) c.push(c[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])); return { pts, c, L: c[c.length - 1] }; }
function surTrajet(t, s) {
s = Math.max(0, Math.min(t.L, s)); let i = 1; while (i < t.c.length - 1 && t.c[i] < s) i++;
const a = t.pts[i - 1], b = t.pts[i], k = (s - t.c[i - 1]) / ((t.c[i] - t.c[i - 1]) || 1);
return { x: a[0] + (b[0] - a[0]) * k, z: a[1] + (b[1] - a[1]) * k, dx: b[0] - a[0], dz: b[1] - a[1] };
}
function velo(k) {
const g = new THREE.Group(), cadre = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.12, 0.14), new THREE.MeshLambertMaterial({ color: '#3b3a36' })); cadre.position.y = 0.62;
const r = new THREE.Mesh(new THREE.CapsuleGeometry(0.26, 0.7, 3, 8), new THREE.MeshLambertMaterial({ color: PAL_P[(k + 2) % PAL_P.length] })); r.position.y = 1.35;
[cadre, r].forEach((x) => { x.castShadow = true; g.add(x); }); return g;
}
const mobiles = [];
const R = D.routes; const tr = {}; for (const k in R) tr[k] = trajet(R[k]);
const cheminsLongs = D.chemins.map(trajet).filter((t) => t.L > 40).sort((a, b) => b.L - a.L);
const ajoute = (obj, t, vitesse, decal, depart, groupe) => { groupe.add(obj); mobiles.push({ obj, t, v: vitesse, d: decal, s: depart * t.L, sens: 1 }); };
ajoute(velo(0), tr.principale, 4.2, 2.8, 0.1, G.v2035); ajoute(velo(1), tr.principale, 3.8, 2.8, 0.65, G.v2035);
ajoute(velo(2), tr.verticale, 4.0, 2.2, 0.4, G.v2035); ajoute(velo(3), tr.anneau, 3.5, 2.0, 0.2, G.v2035);
ajoute(voiture(0), tr.principale, 8, 1.8, 0.45, G.v2035); ajoute(voiture(2), tr.verticale, 7, 1.6, 0.8, G.v2035);
if (cheminsLongs[0]) ajoute(velo(4), cheminsLongs[0], 3.2, 0, 0.3, G.v2035);
cheminsLongs.slice(0, 5).forEach((t, i) => { const p = new THREE.Mesh(geoP, new THREE.MeshLambertMaterial({ color: PAL_P[(i + 3) % PAL_P.length] })); p.castShadow = true; const g = new THREE.Group(); p.position.y = 0.76; g.add(p); ajoute(g, t, 1.3, 0, rnd(i + 3), G.v2035); });
[['principale', 0.05], ['principale', 0.35], ['principale', 0.7], ['verticale', 0.2], ['verticale', 0.6], ['liaison_anneau', 0.5], ['anneau', 0.3], ['droite', 0.5]]
.forEach(([k, d], i) => ajoute(voiture(i + 1), tr[k], 9, 1.8, d, G.vAuj));
// ---------- bulles de la planche 2030, plantées comme des panneaux
const matTige = { v: new THREE.MeshLambertMaterial({ color: '#8fb76a' }), q: new THREE.MeshLambertMaterial({ color: '#e1a35c' }) };
const geoTige = new THREE.CylinderGeometry(0.1, 0.1, 1, 6).translate(0, 0.5, 0);
D.bulles.forEach((b, i) => {
const [x, z] = b.w, vert = VERT.has(b.id);
const tige = new THREE.Mesh(geoTige, vert ? matTige.v : matTige.q); tige.position.set(x, 0, z); tige.scale.y = 8.4; G.v2035.add(tige);
const t = chargeur.load(BULLES[b.id], () => (besoin = true)); t.colorSpace = THREE.SRGBColorSpace;
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, transparent: true, depthTest: false }));
const petit = b.id.startsWith('apaise'); s.scale.set(petit ? 3.4 : 6.4, petit ? 3.4 : 6.4, 1); s.position.set(x, petit ? 7.4 : 10.4, z); s.renderOrder = 10;
s.userData = { id: b.id, base: s.position.y, ph: rnd(i) * 6.28, taille: s.scale.x }; G.v2035.add(s); surv.bulles.push(s);
});
// ---------- hors-champ : les grandes infrastructures et leurs liens
const matI = (c) => new THREE.MeshLambertMaterial({ color: c });
function etiquette(texte, couleur) {
const c = document.createElement('canvas'); c.width = 640; c.height = 120; const x = c.getContext('2d');
x.fillStyle = 'rgba(251,249,243,0.94)'; x.beginPath(); if (x.roundRect) x.roundRect(4, 4, 632, 112, 22); else x.rect(4, 4, 632, 112); x.fill(); x.strokeStyle = couleur; x.lineWidth = 5; x.stroke();
x.fillStyle = '#2a2925'; x.font = '600 50px "Barlow Condensed", Barlow, sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(texte, 320, 62);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, transparent: true, depthTest: false })); s.scale.set(64, 12, 1); s.renderOrder = 9; return s;
}
const INFRA = [
{ k: 'centrale', n: 'Centrale, gaz importé', c: '#c4850f', x: -130, z: -330, sys: 'chaleur' },
{ k: 'potab', n: 'Puisement, potabilisation', c: '#2d86bf', x: 170, z: -300, sys: 'eau' },
{ k: 'agri', n: 'Agriculture industrielle · 850 km', c: '#5a9837', x: 360, z: -40, sys: 'vivant' },
{ k: 'step', n: "Station d'épuration", c: '#2d86bf', x: 110, z: 300, sys: 'eau' },
{ k: 'super', n: 'Supermarché', c: '#d4662a', x: -230, z: 260, sys: 'quartier' },
{ k: 'ciment', n: 'Gravière, cimenterie', c: '#c4850f', x: -380, z: 70, sys: 'chaleur' },
{ k: 'emploi', n: "Bassin d'emploi", c: '#6c665b', x: -400, z: -200, sys: 'travail' },
];
const HC = new THREE.Group(); HC.position.y = -2.3; scene.add(HC); // posé sur le papier, au pied du socle
for (const f of INFRA) {
const g = new THREE.Group(); g.position.set(f.x, 0, f.z);
const add = (geo, m, x, y, z) => { const o = new THREE.Mesh(geo, m); o.position.set(x, y, z); o.castShadow = true; g.add(o); return o; };
if (f.k === 'centrale') { add(new THREE.CylinderGeometry(8, 12, 34, 24), matI('#dcd6ca'), -12, 17, 0); add(new THREE.CylinderGeometry(8, 12, 34, 24), matI('#dcd6ca'), 14, 17, 8); add(new THREE.BoxGeometry(16, 14, 16), matI('#c9c3b7'), 2, 7, -22); }
if (f.k === 'potab') { add(new THREE.CylinderGeometry(2.5, 2.5, 26, 12), matI('#d3cec4'), 0, 13, 0); add(new THREE.CylinderGeometry(9, 6, 8, 20), matI('#bfd6e2'), 0, 30, 0); }
if (f.k === 'agri') { for (let i = 0; i < 6; i++) add(new THREE.BoxGeometry(120, 0.3, 16), matI(['#dcd49a', '#c8d49a', '#e4d08c'][i % 3]), 0, 0.15, -48 + i * 17); for (let i = 0; i < 3; i++) add(new THREE.CylinderGeometry(4, 4, 22, 14), matI('#cdc7bb'), -70, 11, -12 + i * 10); }
if (f.k === 'step') { for (let i = 0; i < 3; i++) { add(new THREE.CylinderGeometry(12, 12, 2.4, 32), matI('#c9c5bd'), -30 + i * 28, 1.2, 0); add(new THREE.CylinderGeometry(11, 11, 0.2, 32), matI('#9fc9dd'), -30 + i * 28, 2.45, 0); } }
if (f.k === 'super') { add(new THREE.BoxGeometry(72, 9, 44), matI('#e2dbcd'), 0, 4.5, 0); add(new THREE.BoxGeometry(90, 0.2, 42), matI('#d3cfc6'), 0, 0.1, 48); }
if (f.k === 'ciment') { add(new THREE.ConeGeometry(14, 11, 18), matI('#cfbd9d'), -14, 5.5, 8); add(new THREE.ConeGeometry(10, 8, 18), matI('#c7b48f'), 14, 4, 16); add(new THREE.CylinderGeometry(4, 4, 34, 14), matI('#bdb6aa'), 6, 17, -14); }
if (f.k === 'emploi') { add(new THREE.BoxGeometry(16, 44, 16), matI('#cfd3d8'), -12, 22, 0); add(new THREE.BoxGeometry(14, 30, 14), matI('#c3c8ce'), 12, 15, 10); add(new THREE.BoxGeometry(12, 22, 12), matI('#d8dbe0'), 6, 11, -16); }
const e = etiquette(f.n, f.c); e.position.set(0, 58, 0); g.add(e);
HC.add(g);
// le lien : une courbe vers le cœur du quartier
const pts = []; const cx = f.x * 0.45 + (f.z > 0 ? 30 : -30), cz = f.z * 0.45;
for (let i = 0; i <= 40; i++) { const t = i / 40, a = (1 - t) * (1 - t), b = 2 * (1 - t) * t, c = t * t; pts.push([a * f.x + b * cx + c * MARE.x, a * f.z + b * cz + c * MARE.z]); }
surv.flux[f.k] = { r: ruban(pts, 4, f.c, 0.35, 14, 0.2, HC), sys: f.sys };
}
// ---------- caméra : axonométrie orthographique, pilotable au doigt
const vue = { x: 0, z: 0, az: 0, el: 0.62, w: 210 };
let vol = null, besoin = true;
const taille = () => ({ W: canvas.clientWidth, H: canvas.clientHeight });
function place() {
const { W, H } = taille(), a = W / H, vh = vue.w / a;
cam.left = -vue.w / 2; cam.right = vue.w / 2; cam.top = vh / 2; cam.bottom = -vh / 2; cam.updateProjectionMatrix();
const d = 1500; cam.position.set(vue.x + Math.sin(vue.az) * Math.cos(vue.el) * d, Math.sin(vue.el) * d, vue.z + Math.cos(vue.az) * Math.cos(vue.el) * d);
cam.lookAt(vue.x, 0, vue.z);
}
function decalagePanneau(w) {
const { W, H } = taille(); const f = $('fiches').getBoundingClientRect(); const haut = 64;
const bas = W >= 900 ? 0 : Math.max(0, H - f.top);
return ((bas - haut) / 2) * (w / W) / Math.sin(vue.el);
}
function versCible(c0, duree = 1500) {
// en portrait, on resserre le cadrage : la largeur de l'écran est la dimension qui manque
const { W, H } = taille(), c = { ...c0, w: W < H && c0.w < 400 ? c0.w * 0.72 : c0.w };
const avant = { ...vue }, off = decalagePanneau(c.w);
const fx = -Math.sin(vue.az), fz = -Math.cos(vue.az);
const apres = { x: c.x - fx * off, z: c.z - fz * off, az: vue.az, el: vue.el, w: c.w };
if (reduit || duree === 0) { Object.assign(vue, apres); besoin = true; return; }
vol = { avant, apres, t0: performance.now(), d: duree };
}
const bornes = () => { vue.w = Math.max(18, Math.min(1000, vue.w)); vue.x = Math.max(-450, Math.min(450, vue.x)); vue.z = Math.max(-450, Math.min(450, vue.z)); };
function solSous(px, py) {
const { W, H } = taille(), wpp = vue.w / W, sx = px - W / 2, sy = py - H / 2;
const rx = Math.cos(vue.az), rz = -Math.sin(vue.az), fx = -Math.sin(vue.az), fz = -Math.cos(vue.az), k = wpp / Math.sin(vue.el);
return { x: vue.x + rx * sx * wpp - fx * sy * k, z: vue.z + rz * sx * wpp - fz * sy * k };
}
function zoomVers(px, py, f) { const p = solSous(px, py); vue.w *= f; vue.x = p.x + (vue.x - p.x) * f; vue.z = p.z + (vue.z - p.z) * f; bornes(); besoin = true; }
// gestes
const ptrs = new Map(); let geste = null;
canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); geste = { t: performance.now(), bouge: 0, x: e.clientX, y: e.clientY }; vol = null; canvas.classList.add('tire'); suspendre(); });
canvas.addEventListener('pointermove', (e) => {
if (!ptrs.has(e.pointerId)) return;
const avant = [...ptrs.values()].map((p) => ({ ...p })); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); const apres = [...ptrs.values()];
const { W } = taille(), wpp = vue.w / W;
const cA = avant.reduce((a, p) => ({ x: a.x + p.x / avant.length, y: a.y + p.y / avant.length }), { x: 0, y: 0 });
const cB = apres.reduce((a, p) => ({ x: a.x + p.x / apres.length, y: a.y + p.y / apres.length }), { x: 0, y: 0 });
const dx = cB.x - cA.x, dy = cB.y - cA.y; if (geste) geste.bouge += Math.abs(dx) + Math.abs(dy);
const rx = Math.cos(vue.az), rz = -Math.sin(vue.az), fx = -Math.sin(vue.az), fz = -Math.cos(vue.az), k = wpp / Math.sin(vue.el);
vue.x -= rx * dx * wpp - fx * dy * k; vue.z -= rz * dx * wpp - fz * dy * k;
if (apres.length === 2 && avant.length === 2) {
const dA = Math.hypot(avant[0].x - avant[1].x, avant[0].y - avant[1].y), dB = Math.hypot(apres[0].x - apres[1].x, apres[0].y - apres[1].y);
if (dA > 0 && dB > 0) zoomVers(cB.x, cB.y, dA / dB);
const aA = Math.atan2(avant[1].y - avant[0].y, avant[1].x - avant[0].x), aB = Math.atan2(apres[1].y - apres[0].y, apres[1].x - apres[0].x);
let da = aB - aA; if (da > Math.PI) da -= 2 * Math.PI; if (da < -Math.PI) da += 2 * Math.PI; vue.az -= da;
}
bornes(); besoin = true;
});
const fin = (e) => {
ptrs.delete(e.pointerId); if (!ptrs.size) canvas.classList.remove('tire');
if (geste && !ptrs.size && geste.bouge < 8 && performance.now() - geste.t < 450) toucher(e.clientX, e.clientY);
if (!ptrs.size) geste = null;
};
canvas.addEventListener('pointerup', fin); canvas.addEventListener('pointercancel', fin);
canvas.addEventListener('wheel', (e) => { e.preventDefault(); vol = null; zoomVers(e.clientX, e.clientY, e.deltaY > 0 ? 1.12 : 1 / 1.12); suspendre(); }, { passive: false });
const ray = new THREE.Raycaster();
function toucher(px, py) {
const { W, H } = taille(); ray.setFromCamera(new THREE.Vector2((px / W) * 2 - 1, -(py / H) * 2 + 1), cam);
if (E.epoque === '2035') {
const hb = ray.intersectObjects(surv.bulles, false)[0];
if (hb) { const id = hb.object.userData.id, s = SIGNES[id] || [null, '', 'quartier']; return s[0] ? allerA(s[0]) : mini(id, s[1], s[2]); }
}
const cand = cliquables.filter((c) => c.o.parent && c.o.parent.visible !== false && (c.o.parent === scene || c.o.parent.visible));
const hits = ray.intersectObjects(cand.map((c) => c.o), true);
if (hits.length) { let o = hits[0].object; const trouve = cand.find((c) => { let x = o; while (x) { if (x === c.o) return true; x = x.parent; } return false; }); if (trouve && E.epoque === '2035') allerA(trouve.carte); }
}
function allerA(id) { const L = liste(); let i = L.findIndex((c) => c.id === id); if (i < 0) { E.filtre = 'tout'; onglets(); i = liste().findIndex((c) => c.id === id); } if (i >= 0) { E.mini = null; E.i = i; montrer(); } }
function mini(id, texte, couche) {
const b = D.bulles.find((x) => x.id === id);
E.mini = { id: 'mini', c: couche, ic: id, t: texte.split(/[.:]/)[0], p: texte, ch: [], s: 'bulle de la planche « le quartier en 2030 », TMIP 2024, p. 15', cam: { x: b.w[0], z: b.w[1], w: 50 } };
montrer();
}
// ---------- état : époque, vue, surlignage
const etat = { epoque: '2035', fx: null, auj: 0, cibleAuj: 0 };
function epoque(e) {
etat.epoque = e; etat.cibleAuj = e === 'auj' ? 1 : 0;
G.v2035.visible = e === '2035'; G.vAuj.visible = e === 'auj'; besoin = true;
}
function activer(c, voler) { etat.fx = c.fx || null; if (voler && c.cam) versCible(c.cam); besoin = true; }
function vueAxo(axo) { vol = { avant: { ...vue }, apres: { ...vue, el: axo ? 0.62 : 1.54 }, t0: performance.now(), d: reduit ? 0 : 900 }; }
function tourner() { vol = { avant: { ...vue }, apres: { ...vue, az: vue.az + Math.PI / 2 }, t0: performance.now(), d: reduit ? 0 : 900 }; }
// ---------- boucle
const horloge = new THREE.Clock(); let t = 0;
const ease = (k) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2);
function redim() { const { W, H } = taille(); renderer.setSize(W, H, false); besoin = true; }
addEventListener('resize', redim); redim();
function boucle(now) {
requestAnimationFrame(boucle);
tic(now);
if (document.hidden) return;
const dt = Math.min(0.05, horloge.getDelta()); t += dt;
if (vol) { const k = Math.min(1, (now - vol.t0) / (vol.d || 1)), e = ease(k); for (const key of ['x', 'z', 'az', 'el', 'w']) vue[key] = vol.avant[key] + (vol.apres[key] - vol.avant[key]) * e; if (k >= 1) vol = null; }
// fondu des sols
etat.auj += (etat.cibleAuj - etat.auj) * Math.min(1, dt * 4); matAuj.opacity = etat.auj;
const puls = 0.5 + 0.5 * Math.sin(t * 2.6), fx = etat.fx, eau = E.epoque === '2035';
surv.noues.forEach((m) => (m.material.opacity = fx === 'noues' ? 0.3 + 0.3 * puls : fx === 'mare' || fx === 'liaisons' ? 0.22 : 0));
surv.bassins.forEach((m) => (m.material.opacity = fx === 'bassins' ? 0.3 + 0.3 * puls : 0));
surv.sentiers.forEach((m) => (m.material.opacity = fx === 'sentiers' ? 0.3 + 0.35 * puls : 0));
surv.liaisons.forEach((m) => { m.material.uniforms.uT.value = t; m.material.uniforms.uA.value = fx === 'liaisons' || fx === 'pluie' ? 0.95 : fx === 'noues' ? 0.45 : 0; });
surv.chemins.forEach((m) => { m.material.uniforms.uT.value = t; m.material.uniforms.uA.value = fx === 'sentiers' || fx === 'mobilite' ? 0.9 : fx === 'foret' ? 0.5 : 0; });
for (const k in surv.flux) {
const f = surv.flux[k], u = f.r.material.uniforms; u.uT.value = t;
const tendu = etat.epoque === 'auj' || f.sys === 'travail';
const vise = fx === 'flux' || (fx === 'travail' && f.sys === 'travail');
u.uA.value = tendu ? (vise ? 0.95 : 0.7) : (vise ? 0.22 : 0); u.uV.value = tendu ? 0.45 : 0.05;
}
if (surv.haies) surv.haies.emissive.setRGB(0.12 * puls * (fx === 'haies'), 0.26 * puls * (fx === 'haies'), 0.02 * (fx === 'haies'));
if (surv.foret) surv.foret.emissive.setRGB(0.08 * puls * (fx === 'foret'), 0.2 * puls * (fx === 'foret'), 0.04 * puls * (fx === 'foret'));
mat.paille.emissive.setRGB(fx === 'maisons' ? 0.28 * puls : 0, fx === 'maisons' ? 0.17 * puls : 0, 0);
mat.grandToit.emissive.setRGB(fx === 'communs' ? 0.35 * puls : 0, fx === 'communs' ? 0.12 * puls : 0, 0);
matEtal.forEach((m) => m.emissive.setRGB(fx === 'marche' ? 0.25 * puls : 0, fx === 'marche' ? 0.14 * puls : 0, 0));
surv.pluie.forEach((s) => {
const on = fx === 'pluie'; s.material.opacity = on ? 0.85 : 0; if (!on) return;
const k = (t * 0.55 + s.userData.ph) % 1, m = s.userData.m; s.position.set(m.cuve.x, 0.2 + (m.eave + 0.6) * (1 - k), m.cuve.z);
});
surv.fumee.forEach((s) => {
const k = (t * 0.12 + s.userData.ph) % 1, m = s.userData.m; s.position.set(m.chem.x - k * 5, m.chem.y + k * 9, m.chem.z - k * 2.5);
const sc = 1.2 + k * 4.5; s.scale.set(sc, sc, 1); s.material.opacity = (fx === 'maisons' ? 0.75 : 0.5) * (1 - k) * Math.min(1, k * 6);
});
surv.ondes.forEach((o) => { const k = (t * 0.35 + o.userData.ph) % 1, on = fx === 'mare' || fx === 'noues'; o.scale.setScalar(1 + k * 11); o.material.opacity = on ? 0.6 * (1 - k) : 0; });
const lieesA = { marche: ['marche', 'musique'], haies: ['pomme', 'confiture', 'bouteille'], foret: ['abeille', 'miel', 'fleur', 'arbre'], communs: ['biere', 'couverts', 'outils'], mare: ['oiseaux', 'musique'], sentiers: ['promeneurs'], mobilite: ['enfant', 'promeneurs', 'apaise1', 'apaise2', 'ballons'] }[fx] || [];
surv.bulles.forEach((s) => { const u = s.userData, on = lieesA.includes(u.id), k = on ? 1.35 : 1; s.position.y = u.base + Math.sin(t * 1.4 + u.ph) * 0.35; s.scale.set(u.taille * k, u.taille * k, 1); s.material.opacity = lieesA.length && !on ? 0.55 : 1; });
mobiles.forEach((m) => {
if (!m.obj.parent.visible) return; m.s += m.v * dt * m.sens; if (m.s > m.t.L) { m.s = m.t.L; m.sens = -1; } if (m.s < 0) { m.s = 0; m.sens = 1; }
const p = surTrajet(m.t, m.s); let dx = p.dx * m.sens, dz = p.dz * m.sens; const n = Math.hypot(dx, dz) || 1; dx /= n; dz /= n;
m.obj.position.set(p.x - dz * m.d, 0, p.z + dx * m.d); m.obj.rotation.y = Math.atan2(-dz, dx);
});
place(); renderer.render(scene, cam); besoin = false;
}
place(); requestAnimationFrame(boucle);
return { activer, epoque, vueAxo, tourner, versCible };
}
// ---------------------------------------------------------------- commandes
function setEpoque(e) {
E.epoque = e; E.filtre = 'tout'; E.i = 0; E.mini = null;
$('b-auj').setAttribute('aria-pressed', e === 'auj'); $('b-2035').setAttribute('aria-pressed', e === '2035');
if (T3) T3.epoque(e); onglets(); montrer();
}
$('b-auj').addEventListener('click', () => setEpoque('auj'));
$('b-2035').addEventListener('click', () => setEpoque('2035'));
$('b-axo').addEventListener('click', () => { $('b-axo').setAttribute('aria-pressed', 'true'); $('b-plan').setAttribute('aria-pressed', 'false'); if (T3) T3.vueAxo(true); });
$('b-plan').addEventListener('click', () => { $('b-plan').setAttribute('aria-pressed', 'true'); $('b-axo').setAttribute('aria-pressed', 'false'); if (T3) T3.vueAxo(false); });
$('b-tourner').addEventListener('click', () => { if (T3) T3.tourner(); });
// ---------------------------------------------------------------- le cycle de l'eau
const ETAPES = [
{ n: 1, h: ['h1', 'h1b'], t: 'Potentiels', p: "La maison dispose des ressources pour être autonome en eau : un toit, un jardin, de la place.", ch: [['+80 %', "d'eau récupérée"], ['98 %', "des usages sans eau potable"]], f: ['pluie'] },
{ n: 2, h: ['h2'], t: 'Séparer les flux', p: "Les eaux grises partent vers une noue à l'air libre. Les eaux noires vont au centre de phytoépuration.", ch: [['82 %', 'des rejets sont des eaux grises']], f: ['usees'] },
{ n: 3, h: ['h3'], t: 'Phytoépuration', p: "Des plantes aquifères épurent les eaux grises : graisses, sucres, amidon.", ch: [], f: ['usees'] },
{ n: 4, h: ['h4'], t: 'Maîtriser les pluies', p: "Noues et bassins absorbent l'essentiel des précipitations. Les stations d'épuration ne débordent plus à l'orage.", ch: [['−21 %', "de charge sur le réseau national, si la moitié des maisons le faisaient (estimation, p. 29)"]], f: ['pluie'] },
{ n: 5, h: ['h5', 'h5b'], t: 'Un écosystème', p: "Même rôle qu'un maillage de bassins d'orage, plus rentable à long terme : il épure, accueille la biodiversité, soutient l'agriculture.", ch: [['−250 €', 'par an et par maison']], f: ['pluie'] },
{ n: 6, h: ['h6'], t: 'Cuve de décantation', p: "Les eaux vannes décantent ; la matière se dépose et part vers le biométhaniseur.", ch: [], f: ['usees'] },
{ n: 7, h: ['h7'], t: 'Filtre vertical', p: "L'essentiel de l'épuration : les bactéries des rhizomes changent la boue en terreau. Une ligne curée chaque année donne des tonnes de compost.", ch: [], f: ['usees'] },
{ n: 8, h: ['h8'], t: 'Biométhanisation', p: "Le biogaz produit reste modeste : au mieux, le gaz de la cuisine.", ch: [], f: ['usees'] },
{ n: 9, h: ['h9'], t: 'Filtre horizontal', p: "Entre les rhizomes, les plantes traitent ce qui reste : métaux, composés chimiques, médicaments.", ch: [], f: ['usees'] },
{ n: 10, h: ['h10'], t: 'Lagunage', p: "Dernière étape : l'eau s'oxygène, les UV éliminent bactéries et microbes.", ch: [], f: ['usees'] },
{ n: 11, h: ['h11'], t: 'Rejet à la parcelle', p: "L'eau épurée n'est pas encore reconnue conforme par l'administration : on l'absorbe sur place.", ch: [], f: ['usees'] },
];
const svg = $('svg-eau'), NS = 'http://www.w3.org/2000/svg';
for (const [k, l] of Object.entries(EAU.flux)) { const p = document.createElementNS(NS, 'path'); p.setAttribute('d', 'M' + l.map((q) => q.join(' ')).join(' L')); p.setAttribute('class', 'flux'); p.dataset.f = k; svg.appendChild(p); }
const etapeDe = {}; ETAPES.forEach((e, i) => e.h.forEach((h) => (etapeDe[h] = i)));
for (const [k, [x, y]] of Object.entries(EAU.pts)) {
const g = document.createElementNS(NS, 'g'); g.setAttribute('class', 'pe'); g.setAttribute('transform', `translate(${x} ${y})`); g.dataset.h = k;
g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button'); g.setAttribute('aria-label', 'Étape ' + ETAPES[etapeDe[k]].n + ' : ' + ETAPES[etapeDe[k]].t);
g.innerHTML = `<circle class="halo" r="16"/><circle class="coeur" r="14"/><text>${ETAPES[etapeDe[k]].n}</text>`;
const go = () => { C.i = etapeDe[k]; C.pause = performance.now() + 20000; etape(); };
g.addEventListener('click', go); g.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
svg.appendChild(g);
}
const C = { i: 0, t0: 0, pause: 0, s: 1, x: 0, y: 0, vol: null };
const plateau = $('plateau'), vp = $('vue-planche');
const appliqueP = () => (plateau.style.transform = `translate(${C.x}px, ${C.y}px) scale(${C.s})`);
function ajuste() { const r = vp.getBoundingClientRect(); return Math.min(r.width / 1615, r.height / 860); }
function borneP(o, r) {
// la planche couvre la vue quand elle est assez grande ; sinon elle reste centrée
const w = 1615 * o.s, h = 860 * o.s;
o.x = w >= r.width ? Math.min(0, Math.max(r.width - w, o.x)) : (r.width - w) / 2;
o.y = h >= r.height ? Math.min(0, Math.max(r.height - h, o.y)) : (r.height - h) / 2;
return o;
}
function viser(x, y, s) {
const r = vp.getBoundingClientRect(), fin = borneP({ s, x: r.width / 2 - x * s, y: r.height / 2 - y * s }, r);
if (reduit) { Object.assign(C, fin); appliqueP(); return; }
const debut = { s: C.s, x: C.x, y: C.y }, t0 = performance.now();
const pas = (now) => { const k = Math.min(1, (now - t0) / 900), e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; C.s = debut.s + (fin.s - debut.s) * e; C.x = debut.x + (fin.x - debut.x) * e; C.y = debut.y + (fin.y - debut.y) * e; appliqueP(); if (k < 1) C.vol = requestAnimationFrame(pas); };
cancelAnimationFrame(C.vol); C.vol = requestAnimationFrame(pas);
}
function etape() {
const e = ETAPES[C.i];
$('etape').innerHTML = `<div class="couche" style="--c: var(--eau)">Étape ${e.n} sur 11</div><h3>${e.t}</h3><p>${e.p}</p>${e.ch.length ? `<div class="chiffres" style="--c: var(--eau)">${e.ch.map(([v, l]) => `<div><b>${v}</b><small>${l}</small></div>`).join('')}</div>` : ''}<div class="source">source : TMIP 2024, p. 13 ; concours Habiter Demain 2020, planche 6</div>`;
$('compteur-eau').textContent = `${e.n} / 11`;
svg.querySelectorAll('.pe').forEach((g) => g.classList.toggle('actif', e.h.includes(g.dataset.h)));
svg.querySelectorAll('.flux').forEach((p) => p.classList.toggle('actif', e.f.includes(p.dataset.f)));
const [x, y] = EAU.pts[e.h[0]], sf = ajuste(), r = vp.getBoundingClientRect();
// en portrait, on zoome assez pour que le dessin remplisse la hauteur
const zoom = r.width < r.height ? Math.max(sf * 2.3, (r.height * 0.98) / 860) : sf * 2.3;
viser(x, y, Math.min(Math.max(zoom, sf), 1.25));
C.t0 = performance.now();
}
function ouvrirCycle() { $('cycle').hidden = false; C.i = 0; C.pause = 0; const sf = ajuste(); C.s = sf; C.x = 0; C.y = 0; appliqueP(); etape(); $('b-retour').focus(); }
function fermerCycle() { $('cycle').hidden = true; E.t0 = performance.now(); }
$('b-retour').addEventListener('click', fermerCycle);
addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('cycle').hidden) fermerCycle(); });
$('e-prec').addEventListener('click', () => { C.i = (C.i + 10) % 11; C.pause = performance.now() + 20000; etape(); });
$('e-suiv').addEventListener('click', () => { C.i = (C.i + 1) % 11; C.pause = performance.now() + 20000; etape(); });
// pan et pince sur la planche
const pp = new Map();
vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.pe')) return; vp.setPointerCapture(e.pointerId); pp.set(e.pointerId, [e.clientX, e.clientY]); C.pause = performance.now() + 20000; cancelAnimationFrame(C.vol); });
vp.addEventListener('pointermove', (e) => {
if (!pp.has(e.pointerId)) return; const av = [...pp.values()].map((p) => [...p]); pp.set(e.pointerId, [e.clientX, e.clientY]); const ap = [...pp.values()];
const c = (L) => [L.reduce((s, p) => s + p[0], 0) / L.length, L.reduce((s, p) => s + p[1], 0) / L.length], a = c(av), b = c(ap);
C.x += b[0] - a[0]; C.y += b[1] - a[1];
if (av.length === 2 && ap.length === 2) { const r = vp.getBoundingClientRect(), dA = Math.hypot(av[0][0] - av[1][0], av[0][1] - av[1][1]), dB = Math.hypot(ap[0][0] - ap[1][0], ap[0][1] - ap[1][1]); const k = dB / dA, ns = Math.max(ajuste() * 0.9, Math.min(2.5, C.s * k)), kk = ns / C.s, px = b[0] - r.left, py = b[1] - r.top; C.x = px - (px - C.x) * kk; C.y = py - (py - C.y) * kk; C.s = ns; }
appliqueP();
});
const finP = (e) => pp.delete(e.pointerId); vp.addEventListener('pointerup', finP); vp.addEventListener('pointercancel', finP);
vp.addEventListener('wheel', (e) => { e.preventDefault(); const r = vp.getBoundingClientRect(), ns = Math.max(ajuste() * 0.9, Math.min(2.5, C.s * (e.deltaY > 0 ? 0.9 : 1.11))), kk = ns / C.s, px = e.clientX - r.left, py = e.clientY - r.top; C.x = px - (px - C.x) * kk; C.y = py - (py - C.y) * kk; C.s = ns; appliqueP(); C.pause = performance.now() + 20000; }, { passive: false });
(function ticEau(now) {
requestAnimationFrame(ticEau);
if ($('cycle').hidden) return;
if (now < C.pause) { C.t0 = now; $('temps-eau').style.width = '0%'; return; }
const k = (now - C.t0) / 8000; $('temps-eau').style.width = Math.min(100, k * 100) + '%';
if (k >= 1 && !reduit) { C.i = (C.i + 1) % 11; etape(); }
})(performance.now());
// ---------------------------------------------------------------- départ
majLecture(); onglets(); montrer(false);
if (!T3) { /* sans WebGL : les fiches restent lisibles sur l'image du plan */ }
document.fonts && document.fonts.ready.then(() => {});
if (!T3) (function boucleSans(now) { requestAnimationFrame(boucleSans); tic(now); })(performance.now());
// démarrage : un premier survol sur la première fiche ; liens directs #aujourdhui et #cycle
if (location.hash === '#aujourdhui') setEpoque('auj');
if (T3) setTimeout(() => montrer(true), 400);
if (location.hash === '#cycle') setTimeout(ouvrirCycle, 300);
})();
</script>