@@ -0,0 +1,802 @@
< 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 10 px 30 px 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 : 15 px / 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 : 2 px solid var ( - - eau ) ; outline-offset : 2 px ; }
# 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 , 0 px ) + 10 px ) ; left : 12 px ; right : 12 px ;
display : flex ; justify-content : space-between ; align-items : flex-start ; gap : 8 px ; flex-wrap : wrap ; pointer-events : none ; }
. barre > * { pointer-events : auto ; }
. titre { background : rgba ( 251 , 249 , 243 , .92 ) ; border : 1 px solid var ( - - trait ) ; border-radius : 12 px ; padding : 7 px 12 px ; box-shadow : var ( - - ombre ) ; }
. titre h1 { margin : 0 ; font : 600 20 px / 1.1 "Barlow Condensed" , Barlow , sans-serif ; letter-spacing : .01 em ; }
. titre p { margin : 1 px 0 0 ; font-size : 12.5 px ; color : var ( - - encre -2 ) ; }
. commandes { display : flex ; gap : 6 px ; flex-wrap : wrap ; justify-content : flex-end ; }
. groupe { display : flex ; background : rgba ( 251 , 249 , 243 , .92 ) ; border : 1 px solid var ( - - trait ) ; border-radius : 12 px ; padding : 3 px ; box-shadow : var ( - - ombre ) ; }
. groupe button { border : 0 ; background : transparent ; border-radius : 9 px ; padding : 0 11 px ; min-height : 38 px ; min-width : 40 px ; font-weight : 500 ; font-size : 14 px ; }
. groupe button [ aria-pressed = "true" ] { background : var ( - - encre ) ; color : var ( - - papier -2 ) ; }
. groupe svg { width : 20 px ; height : 20 px ; display : block ; margin : auto ; }
@ media ( max-width : 520px ) { . titre p { display : none ; } . titre h1 { font-size : 18 px ; } }
/* ---------- fiches ---------- */
# fiches { position : fixed ; z-index : 6 ; left : 0 ; right : 0 ; bottom : 0 ; background : var ( - - papier -2 ) ; border-top : 1 px solid var ( - - trait ) ;
border-radius : 16 px 16 px 0 0 ; box-shadow : 0 -8 px 28 px rgba ( 42 , 41 , 37 , .14 ) ; padding : 10 px 14 px calc ( env ( safe - area - inset - bottom , 0 px ) + 10 px ) ;
display : flex ; flex-direction : column ; gap : 8 px ; max-height : 50 % ; }
@ media ( min-width : 900px ) { # fiches { left : 16 px ; right : auto ; bottom : 16 px ; width : 430 px ; border : 1 px solid var ( - - trait ) ; border-radius : 16 px ; max-height : calc ( 100 % - 110 px ) ; } }
. onglets { display : flex ; gap : 6 px ; overflow-x : auto ; scrollbar-width : none ; flex : none ; }
. onglets :: -webkit-scrollbar { display : none ; }
. onglets button { flex : none ; display : flex ; align-items : center ; gap : 6 px ; border : 1 px solid var ( - - trait ) ; background : #fff ; border-radius : 999 px ; padding : 5 px 11 px ; min-height : 34 px ; font-size : 13.5 px ; font-weight : 500 ; }
. onglets button i { width : 9 px ; height : 9 px ; 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 : 56 px 1 fr ; gap : 12 px ; overflow-y : auto ; min-height : 0 ; }
. fiche . icone { width : 56 px ; height : 56 px ; border-radius : 50 % ; display : block ; background : #fff ; box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , .08 ) ; }
. fiche . icone svg , . fiche . icone img { width : 56 px ; height : 56 px ; display : block ; border-radius : 50 % ; }
. couche { font : 600 12.5 px / 1.2 "Barlow Condensed" , Barlow , sans-serif ; text-transform : uppercase ; letter-spacing : .07 em ; color : var ( - - c , var ( - - neutre ) ) ; }
. fiche h2 { margin : 2 px 0 5 px ; font-size : 19 px ; line-height : 1.22 ; font-weight : 600 ; text-wrap : balance ; }
. fiche p { margin : 0 0 8 px ; }
. chiffres { display : flex ; flex-wrap : wrap ; gap : 8 px 16 px ; margin : 2 px 0 6 px ; }
. chiffres div { font-variant-numeric : tabular-nums ; }
. chiffres b { display : block ; font-size : 21 px ; font-weight : 700 ; line-height : 1.1 ; color : var ( - - c , var ( - - encre ) ) ; }
. chiffres small { display : block ; font-size : 12.5 px ; line-height : 1.3 ; color : var ( - - encre -2 ) ; max-width : 22 ch ; }
. source { font-size : 11.5 px ; color : var ( - - encre -2 ) ; }
. action { margin-top : 8 px ; border : 0 ; background : var ( - - eau ) ; color : #fff ; font-weight : 600 ; border-radius : 10 px ; padding : 10 px 14 px ; min-height : 42 px ; }
. pied { display : flex ; align-items : center ; gap : 8 px ; flex : none ; }
. barre-temps { flex : 1 ; height : 3 px ; background : var ( - - trait ) ; border-radius : 2 px ; overflow : hidden ; }
. barre-temps span { display : block ; height : 100 % ; width : 0 ; background : var ( - - c , var ( - - encre ) ) ; }
. pied button { border : 1 px solid var ( - - trait ) ; background : #fff ; border-radius : 10 px ; width : 40 px ; height : 40 px ; display : grid ; place-items : center ; }
. pied svg { width : 18 px ; height : 18 px ; }
. compteur { font-size : 12.5 px ; color : var ( - - encre -2 ) ; font-variant-numeric : tabular-nums ; min-width : 42 px ; text-align : right ; }
. maquette { font-size : 11 px ; 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 1 fr auto ; }
# cycle header { display : flex ; align-items : center ; gap : 10 px ; padding : calc ( env ( safe - area - inset - top , 0 px ) + 10 px ) 14 px 8 px ; }
# cycle header button { border : 1 px solid var ( - - trait ) ; background : #fff ; border-radius : 10 px ; min-height : 40 px ; padding : 0 12 px ; font-weight : 500 ; }
# cycle header h2 { margin : 0 ; font : 600 20 px / 1.1 "Barlow Condensed" , Barlow , sans-serif ; }
# cycle header p { margin : 0 ; font-size : 12.5 px ; color : var ( - - encre -2 ) ; }
. vue-planche { position : relative ; overflow : hidden ; touch-action : none ; cursor : grab ; }
. plateau { position : absolute ; left : 0 ; top : 0 ; width : 1615 px ; height : 860 px ; 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 .5 s ; }
. flux . actif { opacity : .95 ; animation : couler 1.1 s 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 15 px 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.6 s ease-out infinite ; }
@ keyframes halo { 0 % { opacity : .45 ; r : 16 ; } 100 % { opacity : 0 ; r : 46 ; } }
# cycle . bas { background : var ( - - papier -2 ) ; border-top : 1 px solid var ( - - trait ) ; padding : 10 px 14 px calc ( env ( safe - area - inset - bottom , 0 px ) + 10 px ) ; display : flex ; flex-direction : column ; gap : 8 px ; }
@ media ( min-width : 900px ) { # cycle . bas { position : absolute ; left : 16 px ; bottom : 16 px ; width : 430 px ; border : 1 px solid var ( - - trait ) ; border-radius : 16 px ; box-shadow : var ( - - ombre ) ; } # cycle { grid-template-rows : auto 1 fr ; } }
# cycle . etape h3 { margin : 2 px 0 5 px ; font-size : 18 px ; font-weight : 600 ; }
# cycle . etape p { margin : 0 0 6 px ; }
@ 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 >