Les tags s'affichent en pastilles colorées dans la colonne des libellés, et une barre de filtres apparaît sous la barre d'outils dès qu'un tag existe. Cliquer un tag — dans cette barre ou directement sur la pastille d'un projet — restreint la frise ; recliquer le retire. Le filtre est conjonctif : un projet doit porter tous les tags cochés. Cocher un tag de plus resserre donc toujours la sélection, sans jamais faire se repeupler la frise. Un message dédié prend la place de la frise quand plus rien ne passe le filtre, sans quoi elle laisserait croire que les projets ont disparu. Le filtre est un état de vue, jamais enregistré, au même titre que la fenêtre temporelle. Il ne fait pas le même travail que le masquage par l'œil, qui reste enregistré : le filtre retire le projet de la vue entière, l'œil garde son libellé grisé et n'ôte que les barres. Deux ajustements de confort : créer un projet sous filtre actif préremplit ses tags avec ceux du filtre, sinon le nouveau venu naîtrait invisible ; et un tag que plus aucun projet ne porte est retiré du filtre, sinon il masquerait tout. Le dialogue d'un projet gagne un champ de saisie et propose en un clic les tags déjà employés ailleurs, seul garde-fou contre la dérive orthographique. Dans la colonne des libellés, les pastilles rognent le nom du projet mais lui laissent quatre caractères, et s'effacent à leur tour plutôt que de repousser l'œil et le menu hors de la colonne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
554 lines
19 KiB
JavaScript
554 lines
19 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;
|
|
// 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;
|
|
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);
|
|
}
|
|
|
|
const oeil = document.createElement('button');
|
|
oeil.className = 'icone-oeil';
|
|
oeil.type = 'button';
|
|
oeil.dataset.action = 'masquer';
|
|
oeil.dataset.projet = projet.id;
|
|
oeil.textContent = projet.hidden ? '🚫' : '👁';
|
|
oeil.setAttribute('aria-label', projet.hidden ? `Afficher ${projet.name}` : `Masquer ${projet.name}`);
|
|
ligne.append(oeil);
|
|
|
|
// 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 = 'menu-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');
|
|
ligne.append(menu);
|
|
|
|
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.
|
|
*/
|
|
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));
|
|
|
|
for (const tag of tags) {
|
|
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);
|
|
}
|
|
|
|
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);
|
|
|
|
if (projet.collapsed) {
|
|
const barre = construireBarrePliee(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.textContent = phase.name;
|
|
barre.title =
|
|
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
|
`\n${LIBELLES_STATUT[phase.status]}` +
|
|
(phase.notes ? `\n\n${phase.notes}` : '');
|
|
|
|
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 mode plié, segmentée par phase.
|
|
* Lecture seule : aucun `data-role`, donc drag.js l'ignore.
|
|
*/
|
|
function construireBarrePliee(projet, echelle) {
|
|
const bornes = bornesProjet(projet);
|
|
if (!bornes) return null;
|
|
|
|
const conteneur = document.createElement('div');
|
|
conteneur.className = 'barre-pliee';
|
|
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}` : '') +
|
|
'\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 plié, 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');
|
|
fantome.className = 'fantome';
|
|
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;
|
|
}
|