/** * 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, deriveProjet, dimancheDeLaSemaine, ecartJours, formulerDerive, lundiDeLaSemaine, semaineISO, 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. * * @param {object} refs éléments DOM ciblés (libelles, entete, couloirs) * @param {object} planning l'état courant * @param {object} options { fenetre, selection } */ export function rendre(refs, planning, { fenetre, selection }) { const echelle = creerEchelle(fenetre); const visibles = planning.projects.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(planning)); 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(planning) { const elements = []; for (const projet of planning.projects) { if (projet.hidden) { elements.push(libelleProjet(projet)); continue; } elements.push(libelleProjet(projet)); 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) { 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.title = projet.name; ligne.append(nom); 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; } // --------------------------------------------------------------------------- // 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; }