diff --git a/src/DateFormatter.ts b/src/DateFormatter.ts index 3f40eb3..4c6f393 100644 --- a/src/DateFormatter.ts +++ b/src/DateFormatter.ts @@ -1,9 +1,11 @@ import type { Moment } from "moment"; import { TimeliveSettings } from "./TimeliveSettings.ts"; +import { TimeUnit } from "./TimeUnit.ts"; export interface DateFormatter { formatSpan(fromDate: Moment, toDate: Moment): string; formatDate(date: Moment): string; + formatCalendarDate(date: Moment, unit: TimeUnit): string; } export class TimeliveDateFormatter implements DateFormatter { @@ -20,4 +22,12 @@ export class TimeliveDateFormatter implements DateFormatter { public formatDate(date: Moment): string { return date.format(this.settings.previewTitleDateFormat); } + + public formatCalendarDate(date: Moment, unit: TimeUnit): string { + if (unit === TimeUnit.Month) { + return date.format("YYYY-MM"); + } else { + return date.format("YYYY"); + } + } } diff --git a/src/TimeUnit.ts b/src/TimeUnit.ts new file mode 100644 index 0000000..bbbe3ed --- /dev/null +++ b/src/TimeUnit.ts @@ -0,0 +1,16 @@ +export enum TimeUnit { + Month = "month", + Year = "year", +} +type TimeUnitData = { + daysInUnit: number; +}; + +export const TIME_UNITS: Record = { + [TimeUnit.Month]: { + daysInUnit: 31, + }, + [TimeUnit.Year]: { + daysInUnit: 365.2422, + }, +}; diff --git a/src/TimelineBuilder.ts b/src/TimelineBuilder.ts index f9dde6a..da75b8b 100644 --- a/src/TimelineBuilder.ts +++ b/src/TimelineBuilder.ts @@ -1,39 +1,48 @@ import type { Moment } from "moment"; +import { TIME_UNITS, TimeUnit } from "./TimeUnit.ts"; -const DAYS_IN_YEAR = 365.2422; - -export type TimeSpan = { min?: Moment; max?: Moment }; -export type YearRange = { fromYear: number; toYear: number }; +export type TimeSpan = { min?: Moment; max?: Moment; unit: TimeUnit }; +export type CalendarDates = { + dates: Set; + unit: TimeUnit; +}; export interface TimelineBuilder { addDate(date: Moment): void; getActualTimeSpan(): TimeSpan; - getYearRange(): YearRange; + getCalendarTimeSpan(): TimeSpan; + buildCalendarDates(): CalendarDates; calculatePosition(date: Moment): number; } export class DynamicTimelineBuilder implements TimelineBuilder { - private actualSpan: TimeSpan = {}; - private startOfYear?: Moment; - private deltaYears = 0.0; + private actualSpan: TimeSpan = { unit: TimeUnit.Month }; + private startOfUnit?: Moment; + private deltaInUnit = 0.0; addDate(date: Moment): void { const time = date.valueOf(); - if (!this.actualSpan.min || !this.actualSpan.max) { + const { min, max, unit } = this.actualSpan; + if (!min || !max) { this.actualSpan.min = date; this.actualSpan.max = date; - this.startOfYear = date.clone().startOf("year"); + this.startOfUnit = date.clone().startOf(unit); } else { - const { min, max } = this.actualSpan; if (time < min.valueOf()) { this.actualSpan.min = date; - this.startOfYear = date.clone().startOf("year"); + this.startOfUnit = date.clone().startOf(unit); } else if (time > max.valueOf()) { this.actualSpan.max = date; } else return; // no updates to min/max dates -> skip - this.deltaYears = 1 + this.actualSpan.max.diff(this.startOfYear, "years"); + + const newUnit = this.determineUnit(); + if (newUnit !== unit) { + this.actualSpan.unit = newUnit; + this.startOfUnit = this.actualSpan.min!.clone().startOf(newUnit); + } + this.deltaInUnit = 1 + this.actualSpan.max!.diff(this.startOfUnit, this.actualSpan.unit); } } @@ -41,23 +50,67 @@ export class DynamicTimelineBuilder implements TimelineBuilder { return this.actualSpan; } - getYearRange(): YearRange { - const fromYear = this.getFromYear(); + getCalendarTimeSpan(): TimeSpan { + const { min, max, unit } = this.actualSpan; + const start = (min?.clone() ?? this.now()).startOf(unit); return { - fromYear: fromYear, - toYear: 1 + (this.actualSpan.max?.year() ?? fromYear), + min: start, + max: (max ?? start).clone().add(1, unit), + unit, }; } calculatePosition(date: Moment): number { - const deltaDays = date.diff(this.startOfYear, "days"); - const totalDays = this.deltaYears * DAYS_IN_YEAR; + const unit = this.actualSpan.unit; + const deltaDays = date.diff(this.startOfUnit, "days"); + const totalDays = this.deltaInUnit * TIME_UNITS[unit].daysInUnit; const value = (deltaDays / totalDays) * 100; // Round to 3 decimal points return Math.round(value * 1000) / 1000; } - private getFromYear(): number { - return this.startOfYear?.year() ?? (new Date().getFullYear()); + buildCalendarDates(): CalendarDates { + const { min, max, unit } = this.getCalendarTimeSpan(); + const dates = this.splitDates(new Set(), min!, max!, unit, 0); + return { dates, unit }; + } + + // Recursively split dates for building a calendar + private splitDates( + dates: Set, + a: Moment, + b: Moment, + unit: TimeUnit, + level: number, + ): Set { + if (level < 3 && !this.datesMatchInUnit(a, b, unit)) { + dates.add(a).add(b); + const mid = a.clone().add(b.diff(a, unit, true) / 2, unit); + this.splitDates(dates, a, mid, unit, level + 1); + this.splitDates(dates, mid, b, unit, level + 1); + } + return dates; + } + + private datesMatchInUnit(a: Moment, b: Moment, unit: TimeUnit): boolean { + if (unit === TimeUnit.Year) { + return a.year() === b.year(); + } else { + return a.year() === b.year() && a.month() === b.month(); + } + } + + private determineUnit(): TimeUnit { + const { min, max } = this.actualSpan; + if (!min || !max) return TimeUnit.Month; + const deltaMonths = max.diff(min, "months"); + if (deltaMonths <= 6) return TimeUnit.Month; + return TimeUnit.Year; + } + + private now(): Moment { + // @ts-ignore: deno lack of type + const moment: MomentCallable = globalThis.moment; + return moment(); } } diff --git a/src/Timelive.ts b/src/Timelive.ts index 6591307..0847166 100644 --- a/src/Timelive.ts +++ b/src/Timelive.ts @@ -59,17 +59,18 @@ export class Timelive { public render() { this.root.empty(); this.root.style.minWidth = "100%"; - this.renderYears(); + this.renderCalendar(); this.renderLine(); } - private renderYears() { + private renderCalendar() { const yearsContainer = this.root.createDiv({ cls: "tlv-years" }); - const { fromYear, toYear } = this.timelineBuilder.getYearRange(); - const years = this.splitYears(new Set(), fromYear, toYear, 0); - [...years] + const { dates, unit } = this.timelineBuilder.buildCalendarDates(); + const fmt = this.transformer.formatter; + const formattedDates = [...dates].map((date) => fmt.formatCalendarDate(date, unit)); + [...new Set(formattedDates)] .sort() - .forEach((year) => yearsContainer.createSpan({ text: `${year}` })); + .forEach((date) => yearsContainer.createSpan({ text: `${date}` })); } private renderLine() { @@ -176,22 +177,6 @@ export class Timelive { parent.append(...children); } - // Recursively split years for building a years line - private splitYears( - years: Set, - a: number, - b: number, - level: number, - ): Set { - if (level < 3 && a != b) { - years.add(a).add(b); - const average = Math.floor(a / 2 + b / 2); - this.splitYears(years, a, average, level + 1); - this.splitYears(years, average, b, level + 1); - } - return years; - } - private getPopoverPosition(marker: HTMLElement): { x: number; y: number } { const rect = marker.getBoundingClientRect(); const x = rect.left + globalThis.scrollX;