Files
gestion_projets/js/timeline.js
Bertrand Benjamin eaca2f7813 Revoir l'ergonomie des panneaux et de la colonne des projets
Les panneaux d'édition étaient modaux et la colonne de gauche tronquait
tout ce qu'elle portait. Sept corrections, mesurées sur data/exemple.json.

Panneaux
- Le voile qui recouvrait la page disparaît : la frise reste manipulable
  pendant l'édition, et cliquer une autre barre y bascule la sélection.
  Il rendait aussi le raccourci flèches inatteignable, le focus partant
  dans le champ « Nom » sans qu'on puisse cliquer ailleurs pour en sortir.
- Un clic dans une zone inerte referme le panneau : ce que rendait le
  voile, sans le calque qui figeait la frise.
- Les paramètres d'un projet quittent le <dialog> modal pour le même
  panneau qu'une phase, avec la même édition au fil de la saisie.
  « Nouveau projet » crée donc le projet aussitôt, comme le fait déjà
  l'ajout d'une phase : plus aucune création ne passe par un formulaire.

Colonne des projets
- Le nom ne disposait que de 56 px sur 250, et « client » s'affichait
  « c. ». Les commandes passent en surimpression au survol, les tags
  cèdent en bloc derrière un compteur, et le nom cède en dernier.
- La colonne se redimensionne, largeur conservée dans localStorage.
- L'œil disparaît : il faisait doublon avec l'entrée « Masquer » du menu.
  Un « + » le remplace, l'ajout d'une phase étant l'action la plus
  fréquente, et le nom du projet devient cliquable — il portait déjà un
  curseur « main » sans aucune action derrière.
- Plus de commande « Ajouter un jalon » : un jalon est une phase dont on
  coche la case, pas un autre objet.

Frise
- Le nom d'une phase reste accroché au bord visible quand sa barre
  s'enfonce sous la colonne.
- Cliquer un nom de phase hors écran ramène la frise sur elle.
- La barre cumulative d'un projet s'affiche aussi quand il est déplié,
  amincie pour ne pas se faire passer pour une barre de phase.

Arbitrages consignés dans docs/decisions.md, sections 8 et 17 à 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:58:47 +02:00

616 lines
22 KiB
JavaScript

/**
* Rendu de la frise : échelle temporelle, graduations, couloirs, barres,
* jalons, barre segmentée du mode plié et barre fantôme de référence.
*
* Le rendu est complet à chaque appel : on reconstruit le DOM depuis l'état
* plutôt que d'appliquer des différences. À l'échelle visée — quelques dizaines
* de phases — c'est instantané, et ça supprime toute une classe de bugs de
* synchronisation entre l'état et l'affichage.
*
* Aucun SVG : des éléments positionnés en absolu suffisent depuis que les
* dépendances ont été retirées (docs/decisions.md, section 4).
*/
import {
LIBELLES_STATUT,
ajouterJours,
aujourdhui,
bornesProjet,
bornesReference,
cleTag,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
formulerDerive,
lundiDeLaSemaine,
projetFiltre,
semaineISO,
teinteTag,
versUTC,
} from './model.js';
/**
* Échelle unique : 6 pixels par jour, soit 42 pixels par semaine.
*
* Il n'y a délibérément plus de niveaux de zoom. L'en-tête empile déjà l'année,
* le mois et le numéro de semaine : les trois repères utiles sont visibles en
* permanence, et changer d'échelle n'apportait qu'un réglage de plus à
* comprendre. Voir docs/decisions.md, section 14.
*/
export const PIXELS_PAR_JOUR = 6;
/**
* Largeur minimale, en pixels, qu'une cellule doit occuper pour porter son
* libellé : en dessous, elle reste vide plutôt que d'afficher un texte tronqué.
*/
const LIBELLE_MINI = { annee: 40, mois: 34, semaine: 30 };
const MOIS_COURTS = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.',
];
const MOIS_LONGS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
/**
* Calcule l'échelle : origine, largeur totale et conversions date <-> pixel.
* Toute la géométrie de la frise passe par cet objet.
*
* La fenêtre affichée est décidée par app.js, qui l'élargit au fil du
* défilement — l'échelle ne la déduit plus des bornes du planning.
*
* @param {{debut: string, fin: string}} fenetre
*/
export function creerEchelle(fenetre) {
const { debut, fin } = fenetre;
const jours = ecartJours(debut, fin) + 1;
const pixelsParJour = PIXELS_PAR_JOUR;
return {
debut,
fin,
pixelsParJour,
largeur: jours * pixelsParJour,
/** Date « AAAA-MM-JJ » vers l'abscisse de son bord gauche. */
x(date) {
return ecartJours(this.debut, date) * pixelsParJour;
},
/** Largeur d'une phase, date de fin incluse. */
largeurDe(debutPhase, finPhase) {
return (ecartJours(debutPhase, finPhase) + 1) * pixelsParJour;
},
/** Abscisse vers la date correspondante — l'inverse de `x`. */
dateEn(abscisse) {
return ajouterJours(this.debut, Math.round(abscisse / pixelsParJour));
},
};
}
/**
* Reconstruit toute la frise.
*
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
* que ce projet existe, mais il encombre ma frise ».
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant
* @param {object} options { fenetre, selection, tagsActifs }
*/
export function rendre(refs, planning, { fenetre, selection, tagsActifs = [] }) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const visibles = retenus.filter((projet) => !projet.hidden);
refs.entete.style.width = `${echelle.largeur}px`;
refs.couloirs.style.width = `${echelle.largeur}px`;
refs.entete.replaceChildren(...construireEntete(echelle));
refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs));
refs.couloirs.replaceChildren(
...construireTraits(echelle, hauteurTotale(visibles)),
...construireCouloirs(visibles, echelle, selection)
);
return echelle;
}
function hauteurTotale(projets) {
const lignes = projets.reduce(
(total, projet) => total + 1 + (projet.collapsed ? 0 : projet.phases.length),
0
);
return lignes * 38; // --hauteur-ligne
}
// En-tête de dates : trois bandes empilées
// ---------------------------------------------------------------------------
/**
* L'en-tête empile trois bandes — année, mois, semaine ISO — plutôt qu'une
* seule rangée de graduations. Chaque cellule est un bloc couvrant exactement
* son intervalle, ce qui place le libellé au centre de la période qu'il désigne
* au lieu de le laisser flotter après un trait.
*
* La semaine est le grain le plus fin : on ne descend jamais au jour.
*/
function construireEntete(echelle) {
return [
bande(echelle, [...segmentsAnnees(echelle)], 'annee', LIBELLE_MINI.annee),
bande(echelle, [...segmentsMois(echelle)], 'mois', LIBELLE_MINI.mois),
bande(echelle, [...segmentsSemaines(echelle)], 'semaine', LIBELLE_MINI.semaine),
];
}
/**
* @param {number} largeurMiniLibelle largeur en pixels sous laquelle la cellule
* reste vide : mieux vaut pas de texte qu'un texte tronqué illisible.
*/
function bande(echelle, segments, nom, largeurMiniLibelle) {
const conteneur = document.createElement('div');
conteneur.className = `bande bande--${nom}`;
for (const segment of segments) {
const largeur = echelle.largeurDe(segment.debut, segment.fin);
const cellule = document.createElement('div');
cellule.className = 'bande__cellule';
cellule.style.left = `${echelle.x(segment.debut)}px`;
cellule.style.width = `${largeur}px`;
if (largeur >= largeurMiniLibelle) cellule.textContent = segment.libelle;
cellule.title = segment.titre ?? segment.libelle;
conteneur.append(cellule);
}
return conteneur;
}
function plusTard(a, b) {
return versUTC(a) >= versUTC(b) ? a : b;
}
function plusTot(a, b) {
return versUTC(a) <= versUTC(b) ? a : b;
}
function* segmentsAnnees(echelle) {
const derniere = Number(echelle.fin.slice(0, 4));
for (let annee = Number(echelle.debut.slice(0, 4)); annee <= derniere; annee += 1) {
yield {
debut: plusTard(`${annee}-01-01`, echelle.debut),
fin: plusTot(`${annee}-12-31`, echelle.fin),
libelle: String(annee),
};
}
}
function* segmentsMois(echelle) {
let annee = Number(echelle.debut.slice(0, 4));
let mois = Number(echelle.debut.slice(5, 7));
const finUTC = versUTC(echelle.fin);
while (true) {
const premier = `${annee}-${String(mois).padStart(2, '0')}-01`;
if (versUTC(premier) > finUTC) return;
// Le dernier jour du mois s'obtient en reculant d'un jour depuis le
// premier du mois suivant, ce qui gère les années bissextiles sans table.
const suivant = moisSuivant(annee, mois);
const premierSuivant = `${suivant.annee}-${String(suivant.mois).padStart(2, '0')}-01`;
yield {
debut: plusTard(premier, echelle.debut),
fin: plusTot(ajouterJours(premierSuivant, -1), echelle.fin),
libelle: MOIS_COURTS[mois - 1],
titre: `${MOIS_LONGS[mois - 1]} ${annee}`,
};
({ annee, mois } = suivant);
}
}
function* segmentsSemaines(echelle) {
const finUTC = versUTC(echelle.fin);
let lundi = lundiDeLaSemaine(echelle.debut);
while (versUTC(lundi) <= finUTC) {
const { annee, numero } = semaineISO(lundi);
const dimanche = dimancheDeLaSemaine(lundi);
yield {
debut: plusTard(lundi, echelle.debut),
fin: plusTot(dimanche, echelle.fin),
libelle: `S${numero}`,
titre: `Semaine ${numero} de ${annee} — du ${formaterDateLongue(lundi)} au ${formaterDateLongue(dimanche)}`,
};
lundi = ajouterJours(lundi, 7);
}
}
// ---------------------------------------------------------------------------
// Traits verticaux des couloirs
// ---------------------------------------------------------------------------
/**
* Les traits reprennent le découpage de l'en-tête : un trait fort à chaque
* début de mois, un trait léger à chaque lundi.
*/
function construireTraits(echelle, hauteur) {
const elements = [];
for (const semaine of segmentsSemaines(echelle)) {
elements.push(trait(echelle, semaine.debut, hauteur, ''));
}
for (const mois of segmentsMois(echelle)) {
elements.push(trait(echelle, mois.debut, hauteur, ' trait--majeur'));
}
// Repère « aujourd'hui », seulement s'il tombe dans la fenêtre affichée.
const jour = aujourdhui();
if (versUTC(jour) >= versUTC(echelle.debut) && versUTC(jour) <= versUTC(echelle.fin)) {
const repere = trait(echelle, jour, hauteur, ' trait--aujourdhui');
repere.title = `Aujourd'hui, ${formaterDateLongue(jour)}`;
elements.push(repere);
}
return elements;
}
function trait(echelle, date, hauteur, suffixeClasse) {
const element = document.createElement('div');
element.className = `trait${suffixeClasse}`;
element.style.left = `${echelle.x(date)}px`;
element.style.height = `${hauteur}px`;
return element;
}
function moisSuivant(annee, mois) {
return mois === 12 ? { annee: annee + 1, mois: 1 } : { annee, mois: mois + 1 };
}
export function formaterDateLongue(date) {
const [a, m, j] = date.split('-');
return `${j} ${MOIS_COURTS[Number(m) - 1]} ${a}`;
}
// ---------------------------------------------------------------------------
// Colonne des libellés
// ---------------------------------------------------------------------------
function construireLibelles(projets, tagsActifs) {
const elements = [];
for (const projet of projets) {
if (projet.hidden) {
elements.push(libelleProjet(projet, tagsActifs));
continue;
}
elements.push(libelleProjet(projet, tagsActifs));
if (projet.collapsed) continue;
for (const phase of projet.phases) {
const ligne = document.createElement('div');
ligne.className = 'libelle-phase';
ligne.textContent = phase.name;
ligne.title = `${phase.name}${LIBELLES_STATUT[phase.status]}`;
ligne.dataset.projet = projet.id;
ligne.dataset.phase = phase.id;
elements.push(ligne);
}
}
return elements;
}
function libelleProjet(projet, tagsActifs = []) {
const ligne = document.createElement('div');
ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`;
ligne.style.setProperty('--couleur-projet', projet.color);
const plier = document.createElement('button');
plier.className = 'plier';
plier.type = 'button';
plier.dataset.action = 'plier';
plier.dataset.projet = projet.id;
plier.setAttribute('aria-expanded', String(!projet.collapsed));
plier.setAttribute(
'aria-label',
projet.collapsed ? `Déplier ${projet.name}` : `Plier ${projet.name}`
);
plier.textContent = '▶';
ligne.append(plier);
const nom = document.createElement('span');
nom.className = 'libelle-projet__nom';
nom.textContent = projet.name;
nom.dataset.action = 'parametres';
nom.dataset.projet = projet.id;
// Les pastilles rognent le nom quand la colonne se remplit : l'infobulle
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
// Le grisé de la ligne dit qu'il se passe quelque chose ; encore faut-il
// pouvoir apprendre quoi, et comment revenir en arrière.
(projet.hidden ? '\n\nMasqué de la frise — voir le menu ⋯ pour le rétablir.' : '') +
'\n\nCliquer pour renommer, changer la couleur et les tags.';
ligne.append(nom);
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
const derive = formulerDerive(deriveProjet(projet));
if (derive) {
const etiquette = document.createElement('span');
etiquette.className = 'libelle-projet__derive';
etiquette.dataset.sens = derive.startsWith('+') ? 'retard' : 'avance';
etiquette.textContent = derive;
etiquette.title = projet.baselineDate
? `Écart au planning figé le ${formaterDateLongue(projet.baselineDate)}`
: 'Écart au planning de référence';
ligne.append(etiquette);
}
// Les commandes sont posées *au-dessus* de la ligne, révélées au survol,
// plutôt que rangées dans son flux. Elles y occupaient une soixantaine de
// pixels en permanence — de quoi tronquer le nom de tous les projets dans une
// colonne de largeur courante. En surimpression, le nom dispose de toute la
// colonne et rien ne se déplace à l'approche du curseur.
const commandes = document.createElement('span');
commandes.className = 'libelle-projet__commandes';
// Ajouter une phase est de loin l'action la plus fréquente : elle mérite son
// bouton, pas une entrée à aller chercher dans un menu.
const ajout = document.createElement('button');
ajout.className = 'commande-projet';
ajout.type = 'button';
ajout.dataset.action = 'ajouter';
ajout.dataset.projet = projet.id;
ajout.textContent = '+';
ajout.title = `Ajouter une phase à ${projet.name}`;
ajout.setAttribute('aria-label', ajout.title);
commandes.append(ajout);
// Bouton de menu explicite : les actions du projet passaient auparavant par
// un clic sur son nom, ce que rien n'indiquait.
const menu = document.createElement('button');
menu.className = 'commande-projet';
menu.type = 'button';
menu.dataset.action = 'menu';
menu.dataset.projet = projet.id;
menu.textContent = '⋯';
menu.title = `Actions sur ${projet.name}`;
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
menu.setAttribute('aria-haspopup', 'menu');
commandes.append(menu);
ligne.append(commandes);
return ligne;
}
/**
* Pastilles de tags d'un projet, dans sa ligne de libellé.
*
* Chaque pastille est un bouton : cliquer un tag l'ajoute au filtre, le
* recliquer l'en retire. C'est le geste le plus court pour passer d'un projet
* repéré à « montre-moi tous ceux qui lui ressemblent », sans remonter à la
* barre de filtres.
*
* Au-delà de `PASTILLES_VISIBLES`, les tags se résument à un compteur. La
* colonne est étroite et disputée : montrer quatre pastilles y revenait à les
* rogner toutes jusqu'à l'initiale, ce qui coûtait la place d'un tag sans en
* livrer le nom. Les tags masqués restent lisibles dans l'infobulle, et tous
* sont cliquables depuis la barre de filtres.
*/
const PASTILLES_VISIBLES = 2;
function pastillesTags(tags, tagsActifs) {
const conteneur = document.createElement('span');
conteneur.className = 'tags';
// Deux projets peuvent écrire le même tag différemment (« Client », « client ») :
// c'est la clé, pas la graphie, qui décide s'il est actif.
const clesActives = new Set(tagsActifs.map(cleTag));
// Un tag présent dans le filtre courant passe devant : c'est celui qui
// explique pourquoi le projet est à l'écran, il ne doit pas tomber dans le
// compteur.
const ordonnes = [...tags].sort(
(a, b) => Number(clesActives.has(cleTag(b))) - Number(clesActives.has(cleTag(a)))
);
const montres = ordonnes.slice(0, PASTILLES_VISIBLES);
const restants = ordonnes.slice(PASTILLES_VISIBLES);
for (const tag of montres) {
const pastille = document.createElement('button');
pastille.type = 'button';
pastille.className = 'tag';
pastille.style.setProperty('--teinte-tag', String(teinteTag(tag)));
pastille.textContent = tag;
pastille.dataset.action = 'tag';
pastille.dataset.tag = tag;
const actif = clesActives.has(cleTag(tag));
pastille.setAttribute('aria-pressed', String(actif));
pastille.title = actif ? `Retirer « ${tag} » du filtre` : `Filtrer sur « ${tag} »`;
conteneur.append(pastille);
}
if (restants.length) {
const compteur = document.createElement('span');
compteur.className = 'tag--reste';
compteur.textContent = `+${restants.length}`;
compteur.title = restants.join(', ');
conteneur.append(compteur);
}
return conteneur;
}
// ---------------------------------------------------------------------------
// Couloirs
// ---------------------------------------------------------------------------
function construireCouloirs(projets, echelle, selection) {
const elements = [];
for (const projet of projets) {
const couloirProjet = document.createElement('div');
couloirProjet.className = 'couloir couloir--projet';
couloirProjet.style.setProperty('--couleur-projet', projet.color);
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
const barre = construireBarreCumulative(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
elements.push(couloirProjet);
if (projet.collapsed) continue;
for (const phase of projet.phases) {
const couloir = document.createElement('div');
couloir.className = 'couloir';
couloir.style.setProperty('--couleur-projet', projet.color);
couloir.append(...construirePhase(projet, phase, echelle, selection));
elements.push(couloir);
}
}
return elements;
}
function construirePhase(projet, phase, echelle, selection) {
const selectionnee =
selection && selection.projet === projet.id && selection.phase === phase.id;
const elements = [];
// La référence se dessine sous la barre, donc en premier.
if (phase.baseline) {
const fantome = document.createElement('div');
fantome.className = 'fantome';
fantome.style.left = `${echelle.x(phase.baseline.start)}px`;
fantome.style.width = `${echelle.largeurDe(phase.baseline.start, phase.baseline.end)}px`;
fantome.title = `Référence : ${formaterDateLongue(phase.baseline.start)}${formaterDateLongue(phase.baseline.end)}`;
elements.push(fantome);
}
if (phase.milestone) {
const losange = document.createElement('div');
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
losange.style.left = `${echelle.x(phase.start)}px`;
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
losange.dataset.role = 'barre';
losange.title = `${phase.name}${formaterDateLongue(phase.start)}`;
elements.push(losange);
const nom = document.createElement('span');
nom.className = 'jalon-nom';
nom.style.left = `${echelle.x(phase.start)}px`;
nom.textContent = phase.name;
elements.push(nom);
return elements;
}
const barre = document.createElement('div');
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`;
barre.style.left = `${echelle.x(phase.start)}px`;
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
barre.dataset.projet = projet.id;
barre.dataset.phase = phase.id;
barre.dataset.role = 'barre';
barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '');
// Le nom vit dans son propre élément plutôt que directement dans la barre :
// il est `sticky`, ce qui le maintient visible quand la barre s'enfonce sous
// la colonne des libellés. Une phase de plusieurs mois pouvait autrement
// occuper tout l'écran sans qu'on lise son nom, parti hors cadre à gauche.
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = phase.name;
barre.append(nom);
for (const bord of ['debut', 'fin']) {
const poignee = document.createElement('div');
poignee.className = `poignee poignee--${bord}`;
poignee.dataset.role = 'poignee';
poignee.dataset.bord = bord;
barre.append(poignee);
}
elements.push(barre);
return elements;
}
/**
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
*
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
*/
function construireBarreCumulative(projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return null;
const conteneur = document.createElement('div');
// Dépliée, elle voisine avec les barres de phases : elle s'amincit pour
// qu'on ne la prenne pas pour l'une d'elles et qu'on n'essaie pas de la
// glisser. Pliée, elle est seule sur sa ligne et porte toute l'information.
conteneur.className = `barre-cumulative${projet.collapsed ? '' : ' barre-cumulative--fine'}`;
conteneur.style.left = `${echelle.x(bornes.start)}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
const derive = formulerDerive(deriveProjet(projet));
conteneur.title =
`${projet.name}\n${formaterDateLongue(bornes.start)}${formaterDateLongue(bornes.end)}` +
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
(derive ? `\nÉcart à la référence : ${derive}` : '') +
// Déplié, les barres modifiables sont juste en dessous : le dire serait
// enfoncer une porte ouverte.
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
const origine = echelle.x(bornes.start);
for (const phase of projet.phases) {
const segment = document.createElement('div');
segment.className = `segment segment--${phase.status}`;
segment.style.left = `${echelle.x(phase.start) - origine}px`;
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
segment.style.width = phase.milestone
? '3px'
: `${echelle.largeurDe(phase.start, phase.end)}px`;
conteneur.append(segment);
}
return conteneur;
}
/** Barre fantôme du projet, reflétant l'étendue de référence. */
function construireFantome(projet, echelle) {
const reference = bornesReference(projet);
if (!reference) return null;
const fantome = document.createElement('div');
// Le fantôme se cale sous la barre qu'il double : il doit suivre celle-ci
// quand elle s'amincit, sans quoi il flotterait sous elle.
fantome.className = `fantome${projet.collapsed ? '' : ' fantome--fin'}`;
fantome.style.left = `${echelle.x(reference.start)}px`;
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
fantome.title = `Référence : ${formaterDateLongue(reference.start)}${formaterDateLongue(reference.end)}`;
return fantome;
}