This commit is contained in:
Bastien COIGNOUX
2026-04-26 10:38:53 +02:00
parent 020f5d11de
commit f32e74c713
2 changed files with 302 additions and 51 deletions

View File

@ -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