/** * 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; }