/** * Time Scale Validation * * Prevents ChartJS from crashing when a time unit is incompatible with the data range. * Example: timeUnit="day" with data spanning 1600-2020 (420 years) would generate * ~153,000 ticks, exceeding ChartJS's 100,000 limit and causing a hard crash. */ import { DateTime } from 'luxon'; import type { DataFrame } from 'types'; import type { TimeCartesianAxisConfiguration } from './types'; export type TimeUnit = NonNullable; /** * ChartJS crashes if ticks count exceeds 100,000 (see chart.js scale.time.js). * We use 90,000 as safety margin to account for differences between our * approximate unit durations and Luxon's calendar-based computations. */ const CHARTJS_MAX_TICKS = 90_000; /** Duration of each time unit in milliseconds. */ const TIME_UNIT_MS: Record = { millisecond: 1, second: 1_000, minute: 60 * 1_000, hour: 60 * 60 * 1_000, day: 24 * 60 * 60 * 1_000, week: 7 * 24 * 60 * 60 * 1_000, month: 30 * 24 * 60 * 60 * 1_000, quarter: 90 * 24 * 60 * 60 * 1_000, year: 365 * 24 * 60 * 60 * 1_000, }; /** Time units ordered from most precise to least precise. */ const TIME_UNITS_BY_PRECISION: TimeUnit[] = [ 'millisecond', 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year', ]; // ============================================================================ // Parsing // ============================================================================ /** Parses date strings using Luxon (ISO, SQL, HTTP, RFC2822). */ function tryParseLuxonFormats(value: string): number | null { const parsers = [ () => DateTime.fromISO(value), () => DateTime.fromSQL(value), () => DateTime.fromHTTP(value), () => DateTime.fromRFC2822(value), ]; let result: number | null = null; parsers.some((parse) => { const dt = parse(); if (dt.isValid) { result = dt.toMillis(); return true; } return false; }); return result; } /** Parses "2024" or "2024-06" format (common in aggregated data). */ function tryParseYearMonth(value: string): number | null { const match = value.match(/^(\d{4})(?:-(\d{2}))?$/); if (!match) return null; const dt = DateTime.fromObject({ year: parseInt(match[1], 10), month: match[2] ? parseInt(match[2], 10) : 1, }); return dt.isValid ? dt.toMillis() : null; } /** Converts any date-like value to Unix timestamp in milliseconds. */ function parseToTimestamp(value: unknown): number | null { if (value == null) return null; if (typeof value === 'number') { // Heuristic: |value| < 1e11 means seconds, otherwise milliseconds. // 1e11 seconds ≈ year 5138, 1e11 ms ≈ March 1973. return Math.abs(value) < 1e11 ? value * 1000 : value; } if (value instanceof Date) { const ts = value.getTime(); return Number.isNaN(ts) ? null : ts; } if (typeof value === 'string') { return tryParseLuxonFormats(value) ?? tryParseYearMonth(value); } return null; } // ============================================================================ // Core validation // ============================================================================ /** Returns max safe range (in ms) for a time unit before ChartJS crashes. */ function getMaxSafeRange(unit: TimeUnit): number { return CHARTJS_MAX_TICKS * TIME_UNIT_MS[unit]; } /** Checks if a range (in ms) is safe for a given time unit. */ function isRangeSafe(rangeMs: number, unit: TimeUnit): boolean { return rangeMs <= getMaxSafeRange(unit); } /** Finds the most precise unit that won't crash for the given range. */ function findSafeUnit(rangeMs: number): TimeUnit | undefined { return TIME_UNITS_BY_PRECISION.find((unit) => isRangeSafe(rangeMs, unit)); } /** Extracts min/max timestamps from a DataFrame column. */ function getRange(dataFrame: DataFrame, column: string): { min: number; max: number } | null { const result = dataFrame.reduce<{ min: number; max: number }>( (acc, row) => { const ts = parseToTimestamp(row[column]); if (ts !== null) { return { min: Math.min(acc.min, ts), max: Math.max(acc.max, ts) }; } return acc; }, { min: Infinity, max: -Infinity } ); return result.min === Infinity ? null : result; } // ============================================================================ // Public API // ============================================================================ /** * Checks if a time unit is safe to use with the given min/max values. * Used by the Platform to filter available timescale options in the UI. * * @example * isTimeUnitCompatible("1600-01-01", "2024-01-01", "day") // false (424 years!) * isTimeUnitCompatible("1600-01-01", "2024-01-01", "year") // true * isTimeUnitCompatible("2024-01-01", "2024-12-31", "day") // true */ export function isTimeUnitCompatible(min: unknown, max: unknown, unit: TimeUnit): boolean { const minTs = parseToTimestamp(min); const maxTs = parseToTimestamp(max); if (minTs === null || maxTs === null) return true; // Can't validate, allow return isRangeSafe(Math.abs(maxTs - minTs), unit); } /** * Returns a safe time unit for ChartJS, or undefined to let it auto-detect. * Used internally by scales.ts to prevent crashes. * * @param dataFrame - The chart data * @param requestedUnit - The user-configured time unit (may be undefined) * @param labelColumn - The column containing date values (default: 'x') */ export function getSafeTimeUnit( dataFrame: DataFrame, requestedUnit: TimeUnit | undefined, labelColumn = 'x' ): TimeUnit | undefined { // No unit requested -> let ChartJS auto-detect if (!requestedUnit) return undefined; const range = getRange(dataFrame, labelColumn); // Can't determine range from data — pass through the requested unit. // ChartJS may still parse the dates via its own adapter. if (!range) return requestedUnit; const rangeMs = range.max - range.min; // Requested unit is safe -> use it if (isRangeSafe(rangeMs, requestedUnit)) return requestedUnit; // Requested unit would crash -> find a safe alternative, default to 'year' return findSafeUnit(rangeMs) ?? 'year'; }