Use narrower month interval for calendar dates

This commit is contained in:
aNNiMON 2025-06-28 23:46:25 +03:00 committed by Victor Melnik
parent f61ea43592
commit d7259edb4d
4 changed files with 107 additions and 43 deletions

View file

@ -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");
}
}
}

16
src/TimeUnit.ts Normal file
View file

@ -0,0 +1,16 @@
export enum TimeUnit {
Month = "month",
Year = "year",
}
type TimeUnitData = {
daysInUnit: number;
};
export const TIME_UNITS: Record<TimeUnit, TimeUnitData> = {
[TimeUnit.Month]: {
daysInUnit: 31,
},
[TimeUnit.Year]: {
daysInUnit: 365.2422,
},
};

View file

@ -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<Moment>;
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<Moment>(), min!, max!, unit, 0);
return { dates, unit };
}
// Recursively split dates for building a calendar
private splitDates(
dates: Set<Moment>,
a: Moment,
b: Moment,
unit: TimeUnit,
level: number,
): Set<Moment> {
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();
}
}

View file

@ -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<number>(), 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<number>,
a: number,
b: number,
level: number,
): Set<number> {
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;