Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/lineage

src/ui/time-axis.ts

143 lines · 4.4 KB · TypeScript
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)
}