gantt
This commit is contained in:
@ -9,13 +9,17 @@ import {
|
||||
epicScopeSprintProgress,
|
||||
formatGanttSprintSubtitleLines,
|
||||
formatSprintRangeFr,
|
||||
ganttDayColumns,
|
||||
ganttMonthBands,
|
||||
ganttRangeFromSprintsAndMilestones,
|
||||
ganttSubheaderTicks,
|
||||
ganttVerticalGuideXs,
|
||||
milestoneTooltipText,
|
||||
msToX,
|
||||
parseIsoMs,
|
||||
pixelsPerDay,
|
||||
timelineTicks,
|
||||
timelineWidthPx,
|
||||
type GanttDayColumn,
|
||||
type GanttTimeScale,
|
||||
sprintBarBounds,
|
||||
sprintBarFillPercent,
|
||||
@ -84,6 +88,57 @@ const SCALE_LABELS: Record<GanttTimeScale, string> = {
|
||||
month: 'Mois',
|
||||
}
|
||||
|
||||
function GanttTimelineBackdrop({
|
||||
dayColumns,
|
||||
guideXs,
|
||||
todayX,
|
||||
todayClamped,
|
||||
compact,
|
||||
}: {
|
||||
dayColumns: GanttDayColumn[]
|
||||
guideXs: number[]
|
||||
todayX: number
|
||||
todayClamped: 'before' | 'inside' | 'after'
|
||||
compact?: boolean
|
||||
}) {
|
||||
const insetY = compact ? 'bottom-1 top-1' : 'inset-y-0'
|
||||
return (
|
||||
<>
|
||||
{dayColumns
|
||||
.filter((c) => c.isWeekend)
|
||||
.map((c) => (
|
||||
<div
|
||||
key={c.dayStartMs}
|
||||
className={`pointer-events-none absolute z-0 bg-slate-800/[0.38] ${insetY}`}
|
||||
style={{ left: `${c.x0}px`, width: `${Math.max(0, c.x1 - c.x0)}px` }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
{guideXs.map((gx, i) => (
|
||||
<div
|
||||
key={`vg-${i}-${Math.round(gx * 10)}`}
|
||||
className={`pointer-events-none absolute z-[1] w-px bg-slate-600/50 ${insetY}`}
|
||||
style={{ left: `${gx}px` }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`pointer-events-none absolute z-[2] w-0 border-l-2 border-dashed border-emerald-400/95 shadow-[0_0_10px_rgba(52,211,153,0.35)] ${insetY} ${
|
||||
todayClamped !== 'inside' ? 'opacity-45' : ''
|
||||
}`}
|
||||
style={{ left: `${todayX}px` }}
|
||||
title={
|
||||
todayClamped === 'inside'
|
||||
? 'Aujourd’hui'
|
||||
: todayClamped === 'before'
|
||||
? 'Aujourd’hui (avant la période affichée)'
|
||||
: 'Aujourd’hui (après la période affichée)'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SprintGanttView({
|
||||
sprints,
|
||||
milestones,
|
||||
@ -132,10 +187,21 @@ export function SprintGanttView({
|
||||
[startMs, endMs, ppd],
|
||||
)
|
||||
|
||||
const maxTicks = useMemo(() => Math.floor(widthPx / 72), [widthPx])
|
||||
const ticks = useMemo(
|
||||
() => timelineTicks(timeScale, startMs, endMs, maxTicks),
|
||||
[timeScale, startMs, endMs, maxTicks],
|
||||
const dayColumns = useMemo(
|
||||
() => ganttDayColumns(startMs, endMs, widthPx),
|
||||
[startMs, endMs, widthPx],
|
||||
)
|
||||
const monthBands = useMemo(
|
||||
() => ganttMonthBands(startMs, endMs, widthPx),
|
||||
[startMs, endMs, widthPx],
|
||||
)
|
||||
const subheaderTicks = useMemo(
|
||||
() => ganttSubheaderTicks(timeScale, startMs, endMs, widthPx),
|
||||
[timeScale, startMs, endMs, widthPx],
|
||||
)
|
||||
const guideXs = useMemo(
|
||||
() => ganttVerticalGuideXs(timeScale, startMs, endMs, widthPx),
|
||||
[timeScale, startMs, endMs, widthPx],
|
||||
)
|
||||
|
||||
const todayLine = useMemo(() => {
|
||||
@ -204,8 +270,9 @@ export function SprintGanttView({
|
||||
</h2>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
|
||||
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
|
||||
timeline s’étire en pixels par jour — faites défiler horizontalement. Barre = charge
|
||||
(champ Sprint) ou avancement calendaire. Losanges = jalons (survol pour le détail).
|
||||
timeline s’étire en pixels par jour — faites défiler horizontalement. En-tête : mois puis
|
||||
jours / repères ; week-ends en fond plus sombre. Barre = charge (champ Sprint) ou
|
||||
avancement calendaire. Losanges = jalons (survol pour le détail).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@ -310,49 +377,50 @@ export function SprintGanttView({
|
||||
width: `${220 + widthPx}px`,
|
||||
}}
|
||||
>
|
||||
<div className="sticky left-0 z-30 border-b border-r border-white/[0.08] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm">
|
||||
<div className="sticky left-0 z-40 flex min-h-[4.25rem] flex-col justify-center border-b border-r border-white/[0.08] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm">
|
||||
Piste
|
||||
</div>
|
||||
<div className="relative h-9 border-b border-white/[0.08] bg-slate-900/40">
|
||||
{ticks.map((t) => {
|
||||
const x = msToX(t.ms, startMs, endMs, widthPx)
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-[4.25rem] border-b border-white/[0.08] bg-slate-900/55"
|
||||
style={{ width: `${widthPx}px` }}
|
||||
>
|
||||
<div className="relative h-8 border-b border-white/15">
|
||||
{monthBands.map((b) => (
|
||||
<div
|
||||
key={b.monthStartMs}
|
||||
className="absolute inset-y-0 flex items-center justify-center border-r border-white/20 bg-slate-900/80 text-[11px] font-semibold capitalize tracking-tight text-slate-200"
|
||||
style={{ left: `${b.x0}px`, width: `${Math.max(0, b.x1 - b.x0)}px` }}
|
||||
>
|
||||
<span className="truncate px-1.5 text-center" title={b.label}>
|
||||
{b.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative h-7">
|
||||
{subheaderTicks.map((t, idx) => (
|
||||
<span
|
||||
key={t.ms}
|
||||
className="absolute top-1 -translate-x-1/2 whitespace-nowrap text-[10px] text-slate-400"
|
||||
style={{ left: `${x}px` }}
|
||||
key={`${timeScale}-${t.ms}-${idx}`}
|
||||
className={`absolute top-1 -translate-x-1/2 select-none whitespace-nowrap text-[10px] ${
|
||||
t.major ? 'font-semibold text-slate-300' : 'text-slate-500'
|
||||
}`}
|
||||
style={{ left: `${t.x}px` }}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky left-0 z-30 flex items-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-violet-300/90 backdrop-blur-sm">
|
||||
Jalons
|
||||
</div>
|
||||
<div className="relative h-12 border-b border-white/[0.06] bg-slate-900/50">
|
||||
{ticks.map((t) => (
|
||||
<div
|
||||
key={`jg-${t.ms}`}
|
||||
className={`pointer-events-none absolute bottom-0 top-0 w-px ${
|
||||
t.major ? 'bg-slate-500/50' : 'border-l border-dashed border-slate-600/40'
|
||||
}`}
|
||||
style={{ left: `${msToX(t.ms, startMs, endMs, widthPx)}px` }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-emerald-400 shadow-[0_0_10px_rgba(52,211,153,0.65)] ${
|
||||
todayLine.clamped !== 'inside' ? 'opacity-45' : ''
|
||||
}`}
|
||||
style={{ left: `${todayLine.x}px` }}
|
||||
title={
|
||||
todayLine.clamped === 'inside'
|
||||
? 'Aujourd’hui'
|
||||
: todayLine.clamped === 'before'
|
||||
? 'Aujourd’hui (avant la période affichée)'
|
||||
: 'Aujourd’hui (après la période affichée)'
|
||||
}
|
||||
<GanttTimelineBackdrop
|
||||
dayColumns={dayColumns}
|
||||
guideXs={guideXs}
|
||||
todayX={todayLine.x}
|
||||
todayClamped={todayLine.clamped}
|
||||
/>
|
||||
{sortedMilestones.map((m) => {
|
||||
const ms = parseIsoMs(`${m.date}T12:00:00`)
|
||||
@ -406,18 +474,12 @@ export function SprintGanttView({
|
||||
</div>
|
||||
<div className="relative min-h-[48px] border-b border-white/[0.06] bg-slate-900/30 py-2">
|
||||
<div className="relative mx-0 h-10">
|
||||
{ticks.map((t) => (
|
||||
<div
|
||||
key={`sg-${s.id}-${t.ms}`}
|
||||
className="pointer-events-none absolute bottom-1 top-1 w-px bg-slate-700/35"
|
||||
style={{ left: `${msToX(t.ms, startMs, endMs, widthPx)}px` }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`pointer-events-none absolute bottom-1 top-1 z-10 w-px bg-emerald-400/85 ${
|
||||
todayLine.clamped !== 'inside' ? 'opacity-40' : ''
|
||||
}`}
|
||||
style={{ left: `${todayLine.x}px` }}
|
||||
<GanttTimelineBackdrop
|
||||
dayColumns={dayColumns}
|
||||
guideXs={guideXs}
|
||||
todayX={todayLine.x}
|
||||
todayClamped={todayLine.clamped}
|
||||
compact
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1 top-1 overflow-hidden rounded-full bg-gradient-to-r from-slate-700/90 to-sky-950/50 ring-1 ring-sky-500/25"
|
||||
|
||||
@ -109,7 +109,8 @@ export function timelineTicks(
|
||||
return ticks
|
||||
}
|
||||
|
||||
function startOfWeekMondayLocal(ms: number): number {
|
||||
/** Premier instant (midi local) du lundi de la semaine calendaire contenant `ms`. */
|
||||
export function startOfWeekMondayLocal(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
const day = d.getDay()
|
||||
@ -199,6 +200,194 @@ export function monthTicksBetween(startMs: number, endMs: number): { ms: number;
|
||||
return ticks
|
||||
}
|
||||
|
||||
/** Colonne calendaire (jour) projetée sur la timeline — week-ends, grille jour. */
|
||||
export type GanttDayColumn = {
|
||||
dayStartMs: number
|
||||
weekday: number
|
||||
x0: number
|
||||
x1: number
|
||||
isWeekend: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Une entrée par jour local qui intersecte [startMs, endMs] (bornes en pixels pour le fond).
|
||||
*/
|
||||
export function ganttDayColumns(startMs: number, endMs: number, widthPx: number): GanttDayColumn[] {
|
||||
const cols: GanttDayColumn[] = []
|
||||
const iter = new Date(startMs)
|
||||
iter.setHours(0, 0, 0, 0)
|
||||
if (iter.getTime() > startMs) iter.setDate(iter.getDate() - 1)
|
||||
|
||||
let guard = 0
|
||||
while (iter.getTime() < endMs + MS_DAY && guard++ < 800) {
|
||||
const dayStart = iter.getTime()
|
||||
const wd = iter.getDay()
|
||||
const next = new Date(iter)
|
||||
next.setDate(next.getDate() + 1)
|
||||
const dayEnd = next.getTime()
|
||||
if (dayEnd > startMs && dayStart < endMs) {
|
||||
const x0 = msToX(Math.max(dayStart, startMs), startMs, endMs, widthPx)
|
||||
const x1 = msToX(Math.min(dayEnd, endMs), startMs, endMs, widthPx)
|
||||
if (x1 > x0 + 0.02) {
|
||||
cols.push({
|
||||
dayStartMs: dayStart,
|
||||
weekday: wd,
|
||||
x0,
|
||||
x1,
|
||||
isWeekend: wd === 0 || wd === 6,
|
||||
})
|
||||
}
|
||||
}
|
||||
iter.setDate(iter.getDate() + 1)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
/** Bandeau « mois + année » en tête de Gantt (aligné sur les mois calendaires). */
|
||||
export type GanttMonthBand = {
|
||||
label: string
|
||||
x0: number
|
||||
x1: number
|
||||
monthStartMs: number
|
||||
}
|
||||
|
||||
export function ganttMonthBands(startMs: number, endMs: number, widthPx: number): GanttMonthBand[] {
|
||||
const bands: GanttMonthBand[] = []
|
||||
const cur = new Date(startMs)
|
||||
cur.setDate(1)
|
||||
cur.setHours(0, 0, 0, 0)
|
||||
if (cur.getTime() > startMs) cur.setMonth(cur.getMonth() - 1)
|
||||
|
||||
let guard = 0
|
||||
while (cur.getTime() < endMs && guard++ < 48) {
|
||||
const monthStart = cur.getTime()
|
||||
const next = new Date(cur)
|
||||
next.setMonth(next.getMonth() + 1)
|
||||
const monthEnd = next.getTime()
|
||||
const x0 = msToX(Math.max(monthStart, startMs), startMs, endMs, widthPx)
|
||||
const x1 = msToX(Math.min(monthEnd, endMs), startMs, endMs, widthPx)
|
||||
if (x1 > x0 + 2) {
|
||||
const label = new Date(monthStart).toLocaleDateString('fr-FR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
bands.push({ label, x0, x1, monthStartMs: monthStart })
|
||||
}
|
||||
cur.setMonth(cur.getMonth() + 1)
|
||||
}
|
||||
return bands
|
||||
}
|
||||
|
||||
export type GanttSubheaderTick = { ms: number; label: string; x: number; major: boolean }
|
||||
|
||||
function thinSubheaderTicks(ticks: GanttSubheaderTick[], max: number): GanttSubheaderTick[] {
|
||||
if (ticks.length <= max) return ticks
|
||||
const step = Math.ceil(ticks.length / max)
|
||||
return ticks.filter((_, i) => i % step === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellés de la rangée inférieure (jours / semaines / repères mois) sous les bandeaux mensuels.
|
||||
*/
|
||||
export function ganttSubheaderTicks(
|
||||
scale: GanttTimeScale,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
widthPx: number,
|
||||
): GanttSubheaderTick[] {
|
||||
const spanDays = Math.max(1 / 24, (endMs - startMs) / MS_DAY)
|
||||
const maxLabels = Math.max(10, Math.min(64, Math.floor(widthPx / 48)))
|
||||
|
||||
if (scale === 'day') {
|
||||
const cols = ganttDayColumns(startMs, endMs, widthPx)
|
||||
if (cols.length === 0) return []
|
||||
const avgW = widthPx / spanDays
|
||||
const step =
|
||||
avgW >= 24 ? 1 : avgW >= 15 ? 2 : avgW >= 10 ? 3 : Math.max(1, Math.ceil(cols.length / maxLabels))
|
||||
const out: GanttSubheaderTick[] = []
|
||||
for (let i = 0; i < cols.length; i += step) {
|
||||
const c = cols[i]!
|
||||
const d = new Date(c.dayStartMs)
|
||||
const label =
|
||||
step === 1 && avgW >= 26
|
||||
? d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
: d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
|
||||
const x = (c.x0 + c.x1) / 2
|
||||
const major = d.getDate() === 1 || d.getDay() === 1
|
||||
out.push({ ms: c.dayStartMs, label, x, major })
|
||||
}
|
||||
return thinSubheaderTicks(out, maxLabels)
|
||||
}
|
||||
|
||||
if (scale === 'week') {
|
||||
const out: GanttSubheaderTick[] = []
|
||||
let t = startOfWeekMondayLocal(startMs - 8 * MS_DAY)
|
||||
let guard = 0
|
||||
while (t <= endMs + MS_DAY && guard++ < 140) {
|
||||
if (t + 6 * MS_DAY >= startMs) {
|
||||
const d = new Date(t)
|
||||
const label = d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
const x = msToX(t + 3.5 * MS_DAY, startMs, endMs, widthPx)
|
||||
out.push({ ms: t, label, x, major: true })
|
||||
}
|
||||
t += 7 * MS_DAY
|
||||
}
|
||||
return thinSubheaderTicks(out, maxLabels)
|
||||
}
|
||||
|
||||
/* month */
|
||||
const out: GanttSubheaderTick[] = []
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
if (d.getTime() > startMs) d.setMonth(d.getMonth() - 1)
|
||||
let g = 0
|
||||
while (d.getTime() < endMs && g++ < 40) {
|
||||
const y = d.getFullYear()
|
||||
const m = d.getMonth()
|
||||
for (const dom of [1, 8, 15, 22]) {
|
||||
const cur = new Date(y, m, dom, 12, 0, 0, 0)
|
||||
const ms = cur.getTime()
|
||||
if (ms < startMs || ms > endMs) continue
|
||||
const x = msToX(ms, startMs, endMs, widthPx)
|
||||
out.push({
|
||||
ms,
|
||||
label: String(dom),
|
||||
x,
|
||||
major: dom === 1,
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return thinSubheaderTicks(out, maxLabels)
|
||||
}
|
||||
|
||||
/** Positions X des guides verticaux (limiter le nombre quand le zoom est faible). */
|
||||
export function ganttVerticalGuideXs(
|
||||
scale: GanttTimeScale,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
widthPx: number,
|
||||
): number[] {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
const ppdEff = widthPx / (span / MS_DAY)
|
||||
|
||||
if (ppdEff >= 2.4) {
|
||||
return ganttDayColumns(startMs, endMs, widthPx).map((c) => c.x0)
|
||||
}
|
||||
if (ppdEff >= 0.95 || scale === 'week') {
|
||||
const xs: number[] = []
|
||||
let t = startOfWeekMondayLocal(startMs - 3 * MS_DAY)
|
||||
let guard = 0
|
||||
while (t <= endMs + MS_DAY && guard++ < 220) {
|
||||
xs.push(msToX(t, startMs, endMs, widthPx))
|
||||
t += 7 * MS_DAY
|
||||
}
|
||||
return xs
|
||||
}
|
||||
return ganttMonthBands(startMs, endMs, widthPx).map((b) => b.x0)
|
||||
}
|
||||
|
||||
export function pctOnSpan(ms: number, startMs: number, endMs: number): number {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
return ((ms - startMs) / span) * 100
|
||||
|
||||
Reference in New Issue
Block a user