/** @jsxImportSource react */ import { Axis, AxisConfig, AxisInstance } from "./Axis"; import { VDOM } from "../../ui/Widget"; import { Stack } from "./Stack"; import { Format } from "../../ui/Format"; import { isNumber } from "../../util/isNumber"; import { zeroTime } from "../../util/date/zeroTime"; import { Console } from "../../util/Console"; import { parseDateInvariant } from "../../util"; import { RenderingContext } from "../../ui/RenderingContext"; import { NumberProp, Prop, StringProp } from "../../ui/Prop"; export interface TimeAxisConfig extends AxisConfig { /** Minimum value. */ min?: Prop; /** Maximum value. */ max?: Prop; /** Base CSS class to be applied to the element. Defaults to `timeaxis`. */ baseClass?: string; /** A number ranged between `0-2`. `0` means that the range is aligned with the lowest ticks. Default value is `1`, which means that the range is aligned with medium ticks. Use value `2` to align with major ticks. */ snapToTicks?: 0 | 1 | 2 | false; tickDivisions?: Record; minTickUnit?: string; /** Set to true to apply precise label distances from minLabelDistanceFormatOverride based on the resolved label format. */ useLabelDistanceFormatOverrides?: boolean; /** Mapping of formats to label distances, i.e. { "datetime;YYYYMM": 80 } */ minLabelDistanceFormatOverride?: Record; minLabelDistanceFormatOverrideDefaults?: Record; /** Axis labels format string override. */ format?: StringProp; /** Custom date decoder function. */ decode?: (date: string) => string; /** Size of a zone reserved for labels for both lower and upper end of the axis. */ deadZone?: number; /** Size of a zone reserved for labels near the lower end of the axis. */ lowerDeadZone?: number; /** Size of a zone reserved for labels near the upper (higher) end of the axis. */ upperDeadZone?: number; } Format.registerFactory("yearOrMonth", (format: string) => { let year = Format.parse("datetime;yyyy"); let month = Format.parse("datetime;MMM"); return function (date: any): string { let d = parseDateInvariant(date); if (d.getMonth() == 0) return year(d); else return month(d); }; }); Format.registerFactory("monthOrDay", (format: string) => { let month = Format.parse("datetime;MMM"); let day = Format.parse("datetime;dd"); return function (date: any): string { let d = parseDateInvariant(date); if (d.getDate() == 1) return month(d); else return day(d); }; }); export class TimeAxis extends Axis { declare deadZone: number; declare lowerDeadZone: number; declare upperDeadZone: number; declare snapToTicks: number | false; declare tickDivisions: Record; declare minTickUnit: string; declare useLabelDistanceFormatOverrides: boolean; declare minLabelDistanceFormatOverride: Record; declare minLabelDistanceFormatOverrideDefaults: Record; declare format: string; declare decode: TimeAxisConfig["decode"]; constructor(config: TimeAxisConfig) { super(config); } init(): void { if (this.labelAnchor == "auto") this.labelAnchor = this.vertical ? this.secondary ? "start" : "end" : "start"; if (this.labelDx == "auto") this.labelDx = this.vertical ? 0 : "5px"; if (this.deadZone) { this.lowerDeadZone = this.deadZone; this.upperDeadZone = this.deadZone; } this.minLabelDistanceFormatOverride = { ...this.minLabelDistanceFormatOverrideDefaults, ...this.minLabelDistanceFormatOverride, }; super.init(); } declareData(...args: any[]): void { super.declareData( { anchors: undefined, min: undefined, max: undefined, inverted: undefined, lowerDeadZone: undefined, upperDeadZone: undefined, }, ...args, ); } initInstance(context: RenderingContext, instance: AxisInstance): void { instance.calculator = new TimeScale(); } explore(context: RenderingContext, instance: AxisInstance): void { super.explore(context, instance); let { min, max, normalized, inverted, lowerDeadZone, upperDeadZone } = instance.data; instance.calculator.reset( min, max, this.snapToTicks, this.tickDivisions, Math.max(1, this.minTickDistance), Math.max(1, this.minLabelDistance), normalized, inverted, this.minTickUnit, lowerDeadZone, upperDeadZone, this.decode, this.useLabelDistanceFormatOverrides ? this.minLabelDistanceFormatOverride : {}, this.format, ); } render( context: RenderingContext, instance: AxisInstance, key: string, ): React.ReactNode { let { data, cached, calculator } = instance; cached.axis = calculator.hash(); if (!data.bounds.valid()) return null; let format = calculator.resolvedFormat; let minLabelDistance = calculator.resolvedMinLabelDistance; let formatter = Format.parse(format); return ( {this.renderTicksAndLabels( context, instance, formatter, minLabelDistance, )} ); } } Axis.alias("time", TimeAxis); TimeAxis.prototype.baseClass = "timeaxis"; TimeAxis.prototype.tickDivisions = { second: [[1, 5, 15, 30]], minute: [[1, 5, 15, 30]], hour: [ [1, 2, 4, 8], [1, 3, 6, 12], ], day: [[1]], week: [[1]], month: [[1, 3, 6]], year: [ [1, 2, 10], [1, 5, 10], [5, 10, 50], [10, 50, 100], ], }; const TimeFormats = { fullDateAndTime: "datetime;yyyy MMM dd HH mm ss n", shortMonthDate: "datetime;yyyy MMM dd", }; TimeAxis.prototype.snapToTicks = 0; TimeAxis.prototype.tickSize = 15; TimeAxis.prototype.minLabelDistance = 60; TimeAxis.prototype.minTickDistance = 60; TimeAxis.prototype.minTickUnit = "second"; TimeAxis.prototype.useLabelDistanceFormatOverrides = false; TimeAxis.prototype.minLabelDistanceFormatOverrideDefaults = { [TimeFormats.fullDateAndTime]: 150, [TimeFormats.shortMonthDate]: 90, }; function monthNumber(date: Date): number { return date.getFullYear() * 12 + date.getMonth() + (date.getDate() - 1) / 31; } function yearNumber(date: Date): number { return monthNumber(date) / 12; } const milliSeconds: Record = { second: 1000, minute: 60 * 1000, hour: 3600 * 1000, day: 3600 * 24 * 1000, week: 3600 * 24 * 7 * 1000, month: 3600 * 24 * 30 * 1000, year: 3600 * 24 * 365 * 1000, }; interface TickSize { size: number; measure: string; noLabels?: boolean; } interface TimeScaleRange { factor: number; min: number; max: number; minPadding: number; maxPadding: number; } class TimeScale { dateCache: Record; min: number | null; max: number | null; snapToTicks: number | false; tickDivisions: Record; minLabelDistance: number; minTickDistance: number; tickSizes: TickSize[]; normalized: boolean; minTickUnit: string; inverted: boolean; lowerDeadZone: number; upperDeadZone: number; minValue?: number; maxValue?: number; minValuePadded?: number; maxValuePadded?: number; stacks: Record; decode?: (date: string) => string; minLabelDistanceFormatOverride: Record; format: string; origin: number; scale: TimeScaleRange; a: number; b: number; tickMeasure: string; resolvedFormat: string | null; resolvedMinLabelDistance: number; shouldUpdate: boolean; reset( min: any, max: any, snapToTicks: number | false, tickDivisions: Record, minTickDistance: number, minLabelDistance: number, normalized: boolean, inverted: boolean, minTickUnit: string, lowerDeadZone: number, upperDeadZone: number, decode: ((date: string) => string) | undefined, minLabelDistanceFormatOverride: Record, format: string, ): void { this.dateCache = {}; this.min = min != null ? this.decodeValue(min) : null; this.max = max != null ? this.decodeValue(max) : null; this.snapToTicks = snapToTicks; this.tickDivisions = tickDivisions; this.minLabelDistance = minLabelDistance; this.minTickDistance = minTickDistance; this.tickSizes = []; this.normalized = normalized; this.minTickUnit = minTickUnit; this.inverted = inverted; this.lowerDeadZone = lowerDeadZone || 0; this.upperDeadZone = upperDeadZone || 0; this.minValue = undefined; this.maxValue = undefined; this.minValuePadded = undefined; this.maxValuePadded = undefined; this.stacks = {}; this.decode = decode; this.minLabelDistanceFormatOverride = minLabelDistanceFormatOverride; this.format = format; } decodeValue(date: any): number { if (date instanceof Date) return date.getTime(); switch (typeof date) { case "string": let v = this.dateCache[date]; if (!v) { if (this.decode) date = this.decode(date); v = this.dateCache[date] = parseDateInvariant(date).getTime(); } return v; case "number": return parseDateInvariant(date).getTime(); } return 0; } encodeValue(v: number): string { return new Date(v).toISOString(); } getFormat(unit: string, scale: TimeScaleRange): string { switch (unit) { case "year": return "datetime;yyyy"; case "month": if ( new Date(scale.min).getFullYear() != new Date(scale.max).getFullYear() ) return "yearOrMonth"; return "datetime;yyyy MMM"; case "week": return "datetime;MMMdd"; case "day": if ( new Date(scale.min).getFullYear() != new Date(scale.max).getFullYear() || new Date(scale.min).getMonth() != new Date(scale.max).getMonth() ) return "monthOrDay"; return TimeFormats.shortMonthDate; case "hour": return "datetime;HH mm n"; case "minute": return "datetime;HH mm n"; case "second": return "datetime;mm ss"; default: return TimeFormats.fullDateAndTime; } } map(v: any, offset: number = 0): number { return ( this.origin + (this.decodeValue(v) + offset - this.scale.min + this.scale.minPadding) * this.scale.factor ); } constrainValue(v: number): number { return Math.max(this.scale.min, Math.min(this.scale.max, v)); } trackValue( v: number, offset: number = 0, constrain: boolean = false, ): number { let value = (v - this.origin) / this.scale.factor - offset + this.scale.min - this.scale.minPadding; if (constrain) value = this.constrainValue(value); return value; } hash(): Record { let r: any = { origin: this.origin, factor: this.scale.factor, min: this.scale.min, max: this.scale.max, minPadding: this.scale.minPadding, maxPadding: this.scale.maxPadding, }; r.stacks = Object.keys(this.stacks) .map((s) => this.stacks[s].info?.join(",")) .join(":"); return r; } isSame(x: any): boolean { let hash = this.hash(); let same = x && !Object.keys(hash).some((k) => x[k] !== hash[k]); this.shouldUpdate = !same; return same; } measure(a: number, b: number): void { this.a = a; this.b = b; for (let s in this.stacks) { let info = this.stacks[s].measure(this.normalized); let [min, max] = info; if (this.minValue == null || min < this.minValue) this.minValue = min; if (this.max == null || max > this.maxValue!) this.maxValue = max; this.stacks[s].info = info; } if (this.min == null) { if (this.minValue != null) this.min = this.minValue; else this.min = 0; } if (this.max == null) { if (this.maxValue != null) this.max = this.maxValue; else this.max = this.normalized ? 1 : 100; } this.origin = this.inverted ? this.b : this.a; this.calculateTicks(); if (this.scale == null) { this.scale = this.getScale(); } } getTimezoneOffset(date: Date): number { return date.getTimezoneOffset() * 60 * 1000; } getScale( tickSize?: number | null, measure?: string, minRange: number = 1000, ): TimeScaleRange { let { min, max, upperDeadZone, lowerDeadZone } = this; let smin: number = min!; let smax: number = max!; if (tickSize) { let minDate = new Date(min!); let maxDate = new Date(max!); switch (measure) { case "second": case "minute": case "hour": case "day": default: let minOffset = this.getTimezoneOffset(minDate); let maxOffset = this.getTimezoneOffset(maxDate); let mondayOffset = 4 * milliSeconds.day; //new Date(0).getDay() => 4 smin = Math.floor((smin - minOffset - mondayOffset) / tickSize) * tickSize + minOffset + mondayOffset; smax = Math.ceil((smax - maxOffset - mondayOffset) / tickSize) * tickSize + maxOffset + mondayOffset; break; case "month": tickSize /= milliSeconds.month; let minMonth = monthNumber(minDate); let maxMonth = monthNumber(maxDate); minMonth = Math.floor(minMonth / tickSize) * tickSize; maxMonth = Math.ceil(maxMonth / tickSize) * tickSize; smin = new Date( Math.floor(minMonth / 12), minMonth % 12, 1, ).getTime(); smax = new Date( Math.floor(maxMonth / 12), maxMonth % 12, 1, ).getTime(); break; case "year": tickSize /= milliSeconds.year; let minYear = yearNumber(minDate); let maxYear = yearNumber(maxDate); minYear = Math.floor(minYear / tickSize) * tickSize; maxYear = Math.ceil(maxYear / tickSize) * tickSize; smin = new Date(minYear, 0, 1).getTime(); smax = new Date(maxYear, 0, 1).getTime(); break; } } else { if (this.minValue == min) smin = this.minValuePadded!; if (this.maxValue == max) smax = this.maxValuePadded!; } if (smax - smin < minRange) { let delta = (minRange - (smax - smin)) / 2; smin -= delta; smax += delta; } //padding should be activated only if using min/max obtained from the data let minPadding = this.minValue === min ? Math.max(0, smin - this.minValuePadded!) : 0; let maxPadding = this.maxValue === max ? Math.max(0, this.maxValuePadded! - smax) : 0; let factor = smin < smax ? Math.abs(this.b - this.a) / (smax - smin + minPadding + maxPadding) : 0; if (factor > 0 && (upperDeadZone > 0 || lowerDeadZone > 0)) { smin -= lowerDeadZone / factor; smax += upperDeadZone / factor; minPadding = this.minValuePadded != null ? Math.max(0, smin - this.minValuePadded) : 0; maxPadding = this.maxValuePadded != null ? Math.max(0, this.maxValuePadded - smax) : 0; factor = smin < smax ? Math.abs(this.b - this.a) / (smax - smin + minPadding + maxPadding) : 0; } let sign = this.b > this.a ? 1 : -1; return { factor: sign * (this.inverted ? -factor : factor), min: smin, max: smax, minPadding, maxPadding, }; } acknowledge(value: any, width: number = 0, offset: number = 0): void { value = this.decodeValue(value); if ( this.minValue == null || value + offset - width / 2 < this.minValuePadded! ) { this.minValue = value; this.minValuePadded = value + offset - width / 2; } if ( this.maxValue == null || value + offset + width / 2 > this.maxValuePadded! ) { this.maxValue = value; this.maxValuePadded = value + offset + width / 2; } } getStack(name: string): Stack { let s = this.stacks[name]; if (!s) s = this.stacks[name] = new Stack(); return s; } stacknowledge(name: string, ordinal: string, value: number | null): void { return this.getStack(name).acknowledge(ordinal, value); } stack(name: string, ordinal: string, value: number | null): number | null { let v = this.getStack(name).stack(ordinal, value); return v != null ? this.map(v) : null; } findTickSize(minPxDist: number): TickSize | undefined { return this.tickSizes.find( ({ size, noLabels }) => !noLabels && size * Math.abs(this.scale.factor) >= minPxDist, ); } getTickSizes(): TickSize[] { return this.tickSizes; } calculateTicks(): void { let minReached = false; let minRange = 1000; for (let unit in milliSeconds) { if (!minReached) { if (unit == this.minTickUnit) minReached = true; else continue; } let unitSize = milliSeconds[unit]; let divisions = this.tickDivisions[unit]; if (this.tickSizes.length > 0) { //add ticks from higher levels this.tickSizes.push( ...divisions[0].map((s) => ({ size: s * unitSize, measure: unit })), ); break; } let bestLabelDistance = Infinity; let bestMinLabelDistance = this.minLabelDistance; let bestTicks: number[] = []; let bestScale: TimeScaleRange | null = null; let bestFormat: string | null = null; this.tickMeasure = unit; for (let i = 0; i < divisions.length; i++) { let divs = divisions[i]; for (let d = 0; d < divs.length; d++) { //if (useSnapToTicks && d < Math.min(divs.length - 1, this.snapToTicks)) continue; let tickSize = divs[d] * unitSize; let scale = this.getScale(null, unit, tickSize); let format = this.format ?? this.getFormat(unit, scale); let minLabelDistance = this.minLabelDistanceFormatOverride[format] ?? this.minLabelDistance; let labelDistance = tickSize * Math.abs(scale.factor); if ( labelDistance >= minLabelDistance && labelDistance < bestLabelDistance ) { bestScale = scale; bestTicks = divs.map((s) => s * unitSize); bestLabelDistance = labelDistance; bestFormat = format; bestMinLabelDistance = minLabelDistance; minRange = tickSize; } } } this.scale = bestScale!; this.tickSizes = bestTicks .filter( (ts) => ts * Math.abs(bestScale!.factor) >= this.minTickDistance, ) .map((size) => ({ size, measure: this.tickMeasure })); this.resolvedFormat = bestFormat; this.resolvedMinLabelDistance = bestMinLabelDistance; } let lowerTickUnit: string | null = null; switch (this.tickMeasure) { case "year": lowerTickUnit = "month"; break; case "month": lowerTickUnit = "day"; break; case "week": lowerTickUnit = "day"; break; case "day": lowerTickUnit = "hour"; break; case "hour": lowerTickUnit = "minute"; break; case "minute": lowerTickUnit = "second"; break; } if ( lowerTickUnit && this.minTickUnit && milliSeconds[lowerTickUnit] < milliSeconds[this.minTickUnit] ) lowerTickUnit = this.minTickUnit == this.tickMeasure ? null : this.minTickUnit; if (lowerTickUnit != null && this.scale) { let bestMinorTickSize = Infinity; let divisions = this.tickDivisions[lowerTickUnit]; let unitSize = milliSeconds[lowerTickUnit]; for (let i = 0; i < divisions.length; i++) { let divs = divisions[i]; for (let d = 0; d < divs.length; d++) { let tickSize = divs[d] * unitSize; if ( tickSize * Math.abs(this.scale.factor) >= this.minTickDistance && tickSize < bestMinorTickSize ) { bestMinorTickSize = tickSize; } } } if (bestMinorTickSize != Infinity) { this.tickSizes.unshift({ size: bestMinorTickSize, measure: lowerTickUnit, noLabels: true, }); if (this.tickSizes.length > 1) { let labelStep = this.tickSizes[1].size; let lowerScale = this.getScale(null, lowerTickUnit, minRange); if (lowerScale.max - lowerScale.min >= labelStep) this.scale = lowerScale; } } } if ( isNumber(this.snapToTicks) && this.snapToTicks >= 0 && this.tickSizes.length > 0 ) { let tickSize = this.tickSizes[Math.min(this.tickSizes.length - 1, this.snapToTicks)]; this.scale = this.getScale(tickSize.size, tickSize.measure, minRange); } } getTicks(tickSizes: TickSize[]): (number | Date)[][] { return tickSizes.map(({ size, measure }) => { let result: (number | Date)[] = [], start: number, end: number, minDate: Date, maxDate: Date; if (measure == "year") { size /= milliSeconds.year; minDate = new Date(this.scale.min - this.scale.minPadding); maxDate = new Date(this.scale.max + this.scale.maxPadding); start = Math.ceil(yearNumber(minDate) / size) * size; end = Math.floor(yearNumber(maxDate) / size) * size; for (let i = start; i <= end; i += size) result.push(new Date(i, 0, 1).getTime()); } else if (measure == "month") { size /= milliSeconds.month; minDate = new Date(this.scale.min - this.scale.minPadding); maxDate = new Date(this.scale.max + this.scale.maxPadding); start = Math.ceil(monthNumber(minDate) / size) * size; end = Math.floor(monthNumber(maxDate) / size) * size; for (let i = start; i <= end; i += size) result.push(new Date(Math.floor(i / 12), i % 12, 1).getTime()); } else if (measure == "day" || measure == "week") { let multiplier = measure == "week" ? 7 : 1; size /= milliSeconds.day; minDate = new Date(this.scale.min - this.scale.minPadding); maxDate = new Date(this.scale.max + this.scale.maxPadding); let date = zeroTime(minDate); while (date.getTime() < minDate.getTime()) date.setDate(date.getDate() + 1); if (measure == "week") { //start on monday while (date.getDay() != 1) { date.setDate(date.getDate() + 1); } } while (date.getTime() <= maxDate.getTime()) { result.push(date); date = new Date(date); date.setDate(date.getDate() + multiplier); } } else { let minOffset = this.getTimezoneOffset( new Date(this.scale.min - this.scale.minPadding), ); let mondayOffset = 4 * milliSeconds.day; let date = Math.ceil( (this.scale.min - this.scale.minPadding - minOffset - mondayOffset) / size, ) * size + minOffset + mondayOffset; while (date <= this.scale.max + this.scale.maxPadding) { result.push(date); date += size; } } return result; }); } mapGridlines(): number[] { if (this.tickSizes.length == 0) return []; return this.getTicks([this.tickSizes[0]])[0].map((x) => this.map(x)); } book(): void { Console.warn( "TimeAxis does not support the autoSize flag for column and bar graphs.", ); } }