Frise multi-projets : outil de planification macro
Une frise chronologique unique où plusieurs projets s'empilent en couloirs pliables, pour voir d'un coup d'œil où en est chacun et comment ils se situent les uns par rapport aux autres. Ce n'est pas un outil de suivi quotidien : une phase se compte en semaines, et il n'y a ni sous-tâches, ni tickets, ni dépendances entre phases. - Manipulation directe des barres (glisser, redimensionner), accrochées au lundi - Couloirs pliables : plié, un projet devient une barre segmentée par phase - Planning de référence figeable, avec barre fantôme et calcul de dérive - En-tête à trois bandes : année, mois, numéro de semaine ISO 8601 - Frise qui s'élargit au défilement, pour planifier dans un futur encore vide - Micro-serveur Python (bibliothèque standard) exposant GET/PUT sur /api/data, avec sauvegarde horodatée avant chaque écriture - Zéro build : modules ES natifs, aucune dépendance à installer 50 tests unitaires sur la logique métier (node --test, sans dépendance). Vérifié dans Chromium et Firefox. Les arbitrages de conception et surtout leurs raisons sont consignés dans docs/decisions.md — notamment l'abandon des dépendances entre phases, de la File System Access API, du SVG et des niveaux de zoom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
191
js/drag.js
Normal file
191
js/drag.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Glisser et redimensionner les barres.
|
||||
*
|
||||
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
|
||||
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
|
||||
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
|
||||
* un clic. Sans ce seuil, ouvrir le panneau décalerait la phase d'un jour à
|
||||
* chaque fois que la souris frémit.
|
||||
*
|
||||
* Pendant le geste, seule la géométrie de l'élément est modifiée — l'état n'est
|
||||
* touché qu'au relâchement. Cela évite de reconstruire toute la frise à chaque
|
||||
* mouvement de souris, et laisse la possibilité d'annuler avec Échap.
|
||||
*/
|
||||
|
||||
import {
|
||||
ajouterJours,
|
||||
deplacerPhase,
|
||||
lundiDeLaSemaine,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
|
||||
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
|
||||
const SEUIL_GLISSER = 4;
|
||||
|
||||
/**
|
||||
* Installe la gestion du glisser sur le conteneur des couloirs.
|
||||
*
|
||||
* @param {HTMLElement} conteneur
|
||||
* @param {object} rappels
|
||||
* - echelle() : l'échelle courante
|
||||
* - trouverPhase(idProjet, idPhase)
|
||||
* - surDeplacement(idProjet, idPhase, phase) : appelé au relâchement
|
||||
* - surClic(idProjet, idPhase)
|
||||
*/
|
||||
export function installerGlisser(conteneur, rappels) {
|
||||
let geste = null;
|
||||
|
||||
conteneur.addEventListener('pointerdown', (evenement) => {
|
||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||
if (evenement.button !== 0) return;
|
||||
|
||||
const cible = evenement.target.closest('[data-role="barre"]');
|
||||
if (!cible) return;
|
||||
|
||||
const { projet: idProjet, phase: idPhase } = cible.dataset;
|
||||
const phase = rappels.trouverPhase(idProjet, idPhase);
|
||||
if (!phase) return;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
|
||||
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
|
||||
geste = {
|
||||
element: cible,
|
||||
idProjet,
|
||||
idPhase,
|
||||
phaseInitiale: phase,
|
||||
phaseCourante: phase,
|
||||
mode,
|
||||
xDepart: evenement.clientX,
|
||||
gaucheInitiale: cible.offsetLeft,
|
||||
largeurInitiale: cible.offsetWidth,
|
||||
demarre: false,
|
||||
};
|
||||
|
||||
cible.setPointerCapture(evenement.pointerId);
|
||||
// Empêche la sélection de texte et le glisser natif de l'élément.
|
||||
evenement.preventDefault();
|
||||
});
|
||||
|
||||
conteneur.addEventListener('pointermove', (evenement) => {
|
||||
if (!geste) return;
|
||||
|
||||
const decalage = evenement.clientX - geste.xDepart;
|
||||
|
||||
if (!geste.demarre) {
|
||||
if (Math.abs(decalage) < SEUIL_GLISSER) return;
|
||||
geste.demarre = true;
|
||||
geste.element.classList.add('barre--glissee');
|
||||
document.body.classList.add('glisse-en-cours');
|
||||
}
|
||||
|
||||
appliquerGeste(geste, decalage, rappels.echelle());
|
||||
});
|
||||
|
||||
conteneur.addEventListener('pointerup', (evenement) => {
|
||||
if (!geste) return;
|
||||
const acheve = geste;
|
||||
geste = null;
|
||||
|
||||
acheve.element.releasePointerCapture?.(evenement.pointerId);
|
||||
acheve.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
|
||||
if (!acheve.demarre) {
|
||||
rappels.surClic(acheve.idProjet, acheve.idPhase);
|
||||
return;
|
||||
}
|
||||
|
||||
// Le geste n'a rien changé (revenu à son point de départ) : inutile de
|
||||
// déclencher une sauvegarde.
|
||||
const avant = acheve.phaseInitiale;
|
||||
const apres = acheve.phaseCourante;
|
||||
if (apres.start === avant.start && apres.end === avant.end) {
|
||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, null);
|
||||
return;
|
||||
}
|
||||
|
||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
|
||||
});
|
||||
|
||||
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (evenement.key !== 'Escape' || !geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
|
||||
// Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit
|
||||
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
|
||||
conteneur.addEventListener('pointercancel', () => {
|
||||
if (!geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la géométrie de l'élément et mémorise les dates correspondantes.
|
||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||
*/
|
||||
function appliquerGeste(geste, decalage, echelle) {
|
||||
const { phaseInitiale: phase, mode } = geste;
|
||||
|
||||
if (mode === 'deplacer') {
|
||||
const debut = accrocher(phase.start, decalage, echelle);
|
||||
geste.phaseCourante = deplacerPhase(phase, debut);
|
||||
geste.element.style.left = `${echelle.x(debut)}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'redimensionner-debut') {
|
||||
let debut = accrocher(phase.start, decalage, echelle);
|
||||
// La borne opposée fait butée : une phase ne peut pas se retourner.
|
||||
if (versUTC(debut) > versUTC(phase.end)) debut = phase.end;
|
||||
|
||||
geste.phaseCourante = redimensionnerPhase(phase, 'debut', debut);
|
||||
geste.element.style.left = `${echelle.x(debut)}px`;
|
||||
geste.element.style.width = `${echelle.largeurDe(debut, phase.end)}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
let fin = accrocher(phase.end, decalage, echelle);
|
||||
if (versUTC(fin) < versUTC(phase.start)) fin = phase.start;
|
||||
|
||||
geste.phaseCourante = redimensionnerPhase(phase, 'fin', fin);
|
||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale une date du nombre de jours correspondant au déplacement, puis
|
||||
* l'accroche au lundi de sa semaine.
|
||||
*/
|
||||
function accrocher(date, decalagePixels, echelle) {
|
||||
const jours = Math.round(decalagePixels / echelle.pixelsParJour);
|
||||
return lundiDeLaSemaine(ajouterJours(date, jours));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale une phase au clavier, pour un ajustement fin sans souris.
|
||||
* Utilisé par app.js sur les flèches gauche/droite quand une phase est
|
||||
* sélectionnée.
|
||||
*/
|
||||
export function decalerAuClavier(phase, jours) {
|
||||
return deplacerPhase(phase, ajouterJours(phase.start, jours));
|
||||
}
|
||||
|
||||
/** Allonge ou raccourcit une phase au clavier, sans jamais la retourner. */
|
||||
export function etirerAuClavier(phase, jours) {
|
||||
if (phase.milestone) return phase;
|
||||
const fin = ajouterJours(phase.end, jours);
|
||||
if (versUTC(fin) < versUTC(phase.start)) return phase;
|
||||
return { ...phase, end: fin };
|
||||
}
|
||||
Reference in New Issue
Block a user