@opendatasoft/visualizations
Version:
Opendatasoft's components to easily build dashboards and visualizations.
194 lines (162 loc) • 6.47 kB
text/typescript
/**
* 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<TimeCartesianAxisConfiguration['timeUnit']>;
/**
* 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<TimeUnit, number> = {
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';
}