Files
gestion_projets/js/timeline.js
Bertrand Benjamin 815e144d9a
All checks were successful
Build / build (push) Successful in 1m2s
Rendre lisibles le statut et la date imposée
Statut : quatre boutons peints de leur texture, si bien que le panneau
sert enfin de légende. « En cours » se raye et « terminé » redevient vif
— la trame les distingue, la teinte n'a plus à le faire.

Date imposée : un cadre tireté cerne la forme entière. Les taquets
tombaient sous les poignées de redimensionnement et disparaissaient sur
une barre courte. Son libellé passe dans le modèle, ce qui la fait
apparaître dans la vue par mois, qui la taisait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:17:15 +02:00

742 lines
29 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_ETAT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
aujourdhui,
bornesHorizon,
bornesProjet,
bornesReference,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
etatProjet,
formaterDateLongue,
formaterHorizon,
formulerDerive,
libelleStatut,
lundiDeLaSemaine,
phasesDecalables,
projetADater,
projetFiltre,
projetFiltreEtat,
semaineISO,
versUTC,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { pastillesTags } from './tags.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 };
/**
* 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.
*
* Trois 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 ». Le **filtre par état** en
* fait autant, mais en élargissant à chaque case cochée plutôt qu'en resserrant,
* un projet n'ayant qu'un état : il répond à « je ne veux pas voir le
* cimetière ». 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, curseur, tagsActifs, etatsActifs }
*/
export function rendre(
refs,
planning,
{ fenetre, curseur, tagsActifs = [], etatsActifs = [] }
) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
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, curseur));
refs.couloirs.replaceChildren(
...construireTraits(echelle, hauteurTotale(visibles)),
...construireCouloirs(visibles, echelle, curseur)
);
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 };
}
// ---------------------------------------------------------------------------
// Colonne des libellés
// ---------------------------------------------------------------------------
/**
* La colonne est aussi la règle graduée du curseur clavier : c'est le seul
* endroit qui porte une ligne pour *chaque* position atteignable, y compris
* celles qui n'ont pas de barre en face — un projet plié, un projet masqué, ou
* simplement la ligne d'un projet. `app.js` compte donc ses enfants pour savoir
* où amener le défilement, et l'ordre produit ici est celui qu'il énumère.
*/
function construireLibelles(projets, tagsActifs, curseur) {
const elements = [];
for (const projet of projets) {
const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
elements.push(libelleProjet(projet, tagsActifs, surLeProjet));
// Un projet masqué n'a plus que sa ligne : ses phases ne se dessinent nulle
// part, et le curseur ne les traverse pas davantage qu'il ne les affiche.
if (projet.hidden || projet.collapsed) continue;
for (const phase of projet.phases) {
const sousCurseur = curseur && curseur.projet === projet.id && curseur.phase === phase.id;
const ligne = document.createElement('div');
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
ligne.textContent = phase.name;
ligne.title = `${phase.name}${libelleStatut(phase)}`;
ligne.dataset.projet = projet.id;
ligne.dataset.phase = phase.id;
elements.push(ligne);
}
}
return elements;
}
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
const etat = etatProjet(projet);
const ligne = document.createElement('div');
ligne.className =
'libelle-projet' +
(projet.hidden ? ' libelle-projet--masque' : '') +
(sousCurseur ? ' libelle-projet--curseur' : '');
ligne.style.setProperty('--couleur-projet', projet.color);
ligne.dataset.projet = projet.id;
// L'état se peint en CSS plutôt qu'en classes empilées : il est exclusif, et
// un attribut qui ne peut porter qu'une valeur le dit mieux que quatre
// modificateurs dont un seul doit être posé à la fois.
ligne.dataset.etat = etat;
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) +
`\n${LIBELLES_ETAT[etat]}${legendeEtat(projet, etat)}` +
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
// elles se lisent — un projet n'a pas de barre à lui.
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
// 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));
// Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu — le
// dit sur sa ligne. C'est le garde-fou qui empêche les envisagés de devenir
// une décharge : on ne peut pas ne pas le voir en parcourant la colonne, et
// il ne s'éteint qu'en repoussant l'horizon ou en écartant le projet.
if (projetADater(projet)) {
const alerte = document.createElement('span');
alerte.className = 'libelle-projet__adater';
alerte.textContent = 'à dater';
alerte.title = projet.horizon
? `Horizon dépassé (${formaterHorizon(projet.horizon)}) — repousser ou écarter.`
: "Aucun horizon : ce projet n'est situé nulle part sur la frise.";
ligne.append(alerte);
}
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;
}
/**
* Ce qui suit le nom de l'état dans une infobulle : l'horizon d'un projet
* envisagé, la date de l'acte pour les deux qu'on prononce.
*/
function legendeEtat(projet, etat) {
if (etat === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (etat === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
if (etat === 'discarded') return ` le ${formaterDateLongue(projet.discardedDate)}`;
return '';
}
// ---------------------------------------------------------------------------
// Couloirs
// ---------------------------------------------------------------------------
function construireCouloirs(projets, echelle, curseur) {
const elements = [];
for (const projet of projets) {
// Le curseur clavier se pose aussi sur la ligne d'un projet, où il commande
// le pli, les paramètres et l'ajout d'une phase. Elle n'a pas de barre à
// souligner : c'est le couloir entier qui porte la marque.
const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
const couloirProjet = document.createElement('div');
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
couloirProjet.style.setProperty('--couleur-projet', projet.color);
couloirProjet.dataset.etat = etatProjet(projet);
// 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.
//
// Un projet envisagé n'a pas de phase à cumuler : c'est son horizon qui
// occupe la ligne, et rien d'autre ne s'affiche sous elle.
const barre = construireBarreCumulative(projet, echelle) ?? construireHorizon(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, curseur));
elements.push(couloir);
}
}
return elements;
}
function construirePhase(projet, phase, echelle, curseur) {
const selectionnee =
curseur && curseur.projet === projet.id && curseur.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.classList.add('jalon', `jalon--${phase.status}`);
// Une date imposée se lit sur le losange, et pas seulement dans le panneau :
// un planning se parcourt des yeux, et savoir ce qui suivra un décalage doit
// se voir avant de faire le geste, pas après.
if (phase.fixed) losange.classList.add('jalon--imposee');
if (selectionnee) losange.classList.add('jalon--selectionne');
losange.style.left = `${echelle.x(phase.start)}px`;
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
losange.dataset.role = 'barre';
// Un jalon porte des notes comme une tâche — souvent les plus utiles, un
// jalon étant justement le point où l'on consigne une décision. Son
// infobulle les taisait, alors que celle d'une barre les montrait.
losange.title =
`${phase.name}${formaterDateLongue(phase.start)}` +
`\n${libelleStatut(phase)}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
elements.push(losange);
const nom = document.createElement('span');
nom.className = 'jalon-nom';
nom.style.left = `${echelle.x(phase.start)}px`;
nom.textContent = phase.name;
// Le nom vit hors du losange — il déborderait de ses 12 pixels. Il porte donc
// sa propre identité, faute de quoi le décalage d'un projet emporterait le
// jalon en laissant son nom sur place.
nom.dataset.projet = projet.id;
nom.dataset.phase = phase.id;
elements.push(nom);
return elements;
}
const barre = document.createElement('div');
barre.classList.add('barre', `barre--${phase.status}`);
// Voir le losange plus haut : c'est la même marque, un cadre tireté qui cerne
// la forme — laquelle, le CSS s'en arrange.
if (phase.fixed) barre.classList.add('barre--imposee');
if (selectionnee) barre.classList.add('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${libelleStatut(phase)}` +
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
// source markdown rendrait la note moins lisible qu'avant qu'on ne
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
// portent du sens — une puce pour un tiret, une case dessinée pour `[x]`.
(phase.notes ? `\n\n${aplatirMarkdown(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é. Elle se saisit pour repousser le projet
* entier, à condition qu'il reste quelque chose à repousser : un projet dont
* toutes les phases sont terminées n'a rien qui bouge, et sa barre reste inerte
* plutôt que d'offrir une prise qui ne mènerait à rien (docs/decisions.md,
* section 8).
*/
function construireBarreCumulative(projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return null;
const decalables = phasesDecalables(projet);
const conteneur = document.createElement('div');
conteneur.className = 'barre-cumulative';
conteneur.dataset.projet = projet.id;
// Rôle distinct de `barre` et de `horizon` : drag.js y reconnaît qu'il décale
// un projet entier, et non l'objet unique qu'il a sous le pointeur.
if (decalables.length) conteneur.dataset.role = 'cumul';
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}` : '') +
`\n\n${modeDEmploiDecalage(projet, decalables.length)}`;
for (const phase of projet.phases) {
const segment = document.createElement('div');
segment.className = `segment segment--${phase.status}`;
// Le geste rejoue la géométrie sur ces mêmes nœuds à chaque mouvement de
// souris, et le décalage retrie les phases : c'est l'identifiant qui les
// apparie, jamais leur rang.
segment.dataset.phase = phase.id;
conteneur.append(segment);
}
poserBarreCumulative(conteneur, projet, echelle);
return conteneur;
}
/**
* Ce que dit l'infobulle du résumé sur le geste qu'il offre.
*
* La restriction aux phases non terminées ne s'annonce que lorsqu'elle joue :
* sur un projet dont rien n'est clos — le cas courant — le geste emporte tout,
* et il n'y a aucune nuance à apprendre.
*/
function modeDEmploiDecalage(projet, nombreDecalables) {
if (!nombreDecalables) {
return 'Rien à repousser : ses phases sont toutes terminées ou à date imposée.';
}
if (nombreDecalables === projet.phases.length) return 'Glisser pour repousser le projet.';
const retenues = projet.phases.length - nombreDecalables;
const compte =
retenues > 1 ? `${retenues} de ses phases ne suivront pas` : 'Une de ses phases ne suivra pas';
return `Glisser pour repousser le projet. ${compte} — terminée${retenues > 1 ? 's' : ''} ou à date imposée.`;
}
/**
* Pose la géométrie de la barre cumulative et de ses segments d'après l'état du
* projet.
*
* Séparée de sa construction parce que `drag.js` la rejoue à chaque mouvement de
* souris : décaler un projet dont une partie est terminée ne translate pas cette
* barre, il la déforme — le bord gauche tient, le reste s'en va. Elle ne peut
* donc pas se contenter d'un `left` mis à jour, et la formule doit vivre au même
* endroit que celle du rendu, sous peine de voir l'aperçu et le résultat
* diverger.
*/
export function poserBarreCumulative(conteneur, projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return;
const origine = echelle.x(bornes.start);
conteneur.style.left = `${origine}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
const segments = new Map();
for (const segment of conteneur.children) segments.set(segment.dataset.phase, segment);
for (const phase of projet.phases) {
const segment = segments.get(phase.id);
if (!segment) continue;
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`;
}
}
/**
* Barre d'horizon d'un projet envisagé : pâle, aux bords fondus.
*
* Elle se glisse et se redimensionne comme une barre de phase, mais **accrochée
* au mois** là où celle d'une phase l'est au lundi. C'est le même parti pris
* qu'à la décision 9, poussé d'un cran : on ne pose pas un horizon au jour près,
* donc le geste ne le permet pas — tandis qu'interdire le glisser tout court, ce
* qui fut d'abord essayé, obligeait à passer par le panneau pour un ajustement
* qu'on veut faire en regardant les voisins.
*
* Sa largeur *est* l'incertitude : un projet vague occupe une large plage floue,
* un projet mûr un seul mois. Rien à écrire pour le dire, l'œil le lit.
*/
function construireHorizon(projet, echelle) {
const bornes = bornesHorizon(projet);
if (!bornes) return null;
const barre = document.createElement('div');
barre.className = 'barre-horizon';
barre.style.left = `${echelle.x(bornes.start)}px`;
barre.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
barre.dataset.projet = projet.id;
// Un rôle distinct de celui d'une phase : `drag.js` y reconnaît qu'il déplace
// des mois et non des jours, et qu'il n'y a pas de phase à aller chercher.
barre.dataset.role = 'horizon';
if (projetADater(projet)) barre.classList.add('barre-horizon--depasse');
barre.title =
`${projet.name}\nEnvisagé — ${formaterHorizon(projet.horizon)}` +
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
'\n\nSe glisse au mois. Ajouter une phase engage le projet et lui donne des dates fermes.';
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = projet.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);
}
return barre;
}
/** 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');
// Se cale sous la barre cumulative, plus mince qu'une barre de phase : sans
// cette variante, il flotterait dans le vide sous elle.
fantome.className = 'fantome fantome--cumulatif';
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;
}