Files
gestion_projets/js/timeline.js
Bertrand Benjamin 4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:07:31 +02:00

752 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,
LIBELLES_STATUT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
aujourdhui,
bornesHorizon,
bornesProjet,
bornesReference,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
etatProjet,
formaterDateLongue,
formaterHorizon,
formulerDerive,
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}${LIBELLES_STATUT[phase.status]}${mentionImposee(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);
}
// Une date imposée se lit sur la barre, 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.
const impose = phase.fixed ? ' barre--imposee' : '';
if (phase.milestone) {
const losange = document.createElement('div');
losange.className =
`jalon jalon--${phase.status}${phase.fixed ? ' jalon--impose' : ''}` +
(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';
// 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${LIBELLES_STATUT[phase.status]}${mentionImposee(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.className =
`barre barre--${phase.status}${impose}` + (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]}${mentionImposee(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 qui suit le statut dans l'infobulle d'une phase à date imposée.
*
* Accolé au statut, et non sur sa propre ligne : les deux se lisent ensemble —
* « en cours, date imposée » — et occupent le même registre, celui de ce que la
* phase est aujourd'hui.
*/
function mentionImposee(phase) {
return phase.fixed ? ' — date imposée, non emportée par un décalage du projet' : '';
}
/**
* 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;
}