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>
752 lines
29 KiB
JavaScript
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;
|
|
}
|