gantt
This commit is contained in:
@ -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