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 ?? '') })
}
return 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,
),
}))
}
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)
}