import { closestIndex } from './chart-model.ts' export type TimeAxisUnit = 'hour' | 'day' | 'week' | 'month' | 'year' const HOUR = 60 * 60 * 1000 const DAY = 24 * HOUR export function chooseTimeAxisStep(spanMs: number): { unit: TimeAxisUnit; step: number } { const span = Math.max(0, spanMs) if (span <= 2 * DAY) return pickStep(span, HOUR, [1, 2, 3, 6, 12], 'hour') if (span <= 40 * DAY) return pickStep(span, DAY, [1, 2, 3, 7, 14], 'day') if (span <= 4 * 365 * DAY) return pickStep(span, 30.44 * DAY, [1, 2, 3, 6], 'month') return pickStep(span, 365.25 * DAY, [1, 2], 'year') } export function pickStep( spanMs: number, unitMs: number, steps: number[], unit: TimeAxisUnit, ): { unit: TimeAxisUnit; step: number } { let chosen = steps[0] ?? 1 for (const step of steps) { chosen = step if (spanMs / (unitMs * step) <= 7) break } return { unit, step: chosen } } export function timeAxisTicks( points: Array<{ at: string }>, options: { locale?: string } = {}, ): Array<{ index: number; label: string }> { if (points.length === 0) return [] const startMs = Date.parse(points[0]?.at ?? '') const endMs = Date.parse(points[points.length - 1]?.at ?? '') if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return [] const span = Math.max(0, endMs - startMs) if (points.length === 1 || span === 0) { return [ { index: 0, label: formatTick(new Date(startMs), chooseTimeAxisStep(0).unit, null, options.locale, false), }, ] } const { unit, step } = chooseTimeAxisStep(span) const times = [startMs] let cursor = addAligned(startOfUnit(new Date(startMs), unit), unit, step) while (cursor.getTime() < endMs) { const next = cursor.getTime() if (next - times[times.length - 1]! >= span * 0.08) times.push(next) cursor = addAligned(cursor, unit, step) } if (endMs - times[times.length - 1]! >= span * 0.08) times.push(endMs) else times[times.length - 1] = endMs const ticks: Array<{ index: number; label: string; at: number }> = [] for (const at of times) { const index = closestIndex(points, new Date(at).toISOString()) if (ticks.some((tick) => tick.index === index)) continue ticks.push({ index, label: '', at: Date.parse(points[index]?.at ?? '') }) } const labeled = ticks.map((tick, order) => ({ index: tick.index, label: formatTick( new Date(tick.at), unit, order > 0 ? new Date(ticks[order - 1]!.at) : null, options.locale, order === 0 || order === ticks.length - 1, ), })) // Drop consecutive duplicate labels (common when month ticks snap onto the same month name). return labeled.filter((tick, order) => order === 0 || tick.label !== labeled[order - 1]!.label) } export function startOfUnit(date: Date, unit: TimeAxisUnit): Date { const next = new Date(date.getTime()) if (unit === 'year') { next.setMonth(0, 1) next.setHours(0, 0, 0, 0) } else if (unit === 'month') { next.setDate(1) next.setHours(0, 0, 0, 0) } else if (unit === 'week') { next.setDate(next.getDate() - next.getDay()) next.setHours(0, 0, 0, 0) } else if (unit === 'day') { next.setHours(0, 0, 0, 0) } else { next.setMinutes(0, 0, 0) } return next } export function addAligned(date: Date, unit: TimeAxisUnit, step: number): Date { const next = new Date(date.getTime()) if (unit === 'year') next.setFullYear(next.getFullYear() + step) else if (unit === 'month') next.setMonth(next.getMonth() + step) else if (unit === 'week') next.setDate(next.getDate() + 7 * step) else if (unit === 'day') next.setDate(next.getDate() + step) else next.setHours(next.getHours() + step) return next } export function formatTick( date: Date, unit: TimeAxisUnit, previous: Date | null, locale: string | undefined, edge: boolean, ): string { const yearChanged = !previous || previous.getFullYear() !== date.getFullYear() const dayChanged = !previous || previous.toDateString() !== date.toDateString() if (unit === 'hour') { return new Intl.DateTimeFormat(locale, { ...(dayChanged ? { month: 'short', day: 'numeric' } : {}), hour: 'numeric', }).format(date) } if (unit === 'day' || unit === 'week') { return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', ...(yearChanged ? { year: 'numeric' } : {}), }).format(date) } if (unit === 'month') { if (edge && date.getDate() !== 1) { return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', ...(yearChanged ? { year: 'numeric' } : {}), }).format(date) } return new Intl.DateTimeFormat(locale, { month: 'short', ...(yearChanged ? { year: 'numeric' } : {}), }).format(date) } return new Intl.DateTimeFormat(locale, { year: 'numeric' }).format(date) }