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>
616 lines
22 KiB
JavaScript
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;
|
|
}
|