Time spans support (#2)

* Add spans support
* Add unit tests
* Update docs
This commit is contained in:
Victor Melnik 2025-04-06 12:58:41 +02:00 committed by GitHub
parent 92544845fe
commit 1a599f9555
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 211 additions and 27 deletions

View file

@ -6,7 +6,9 @@ Plugin for [Obsidian](https://obsidian.md) that turns a list of dates into a tim
The key difference from other timeline plugins is that Timelive doesn't use a code block. It's much easier to format any date event, provide a link or block of code, add an image or a video, etc.
To build a timeline, you need to define a list of dates, enclosed by the `|` symbol. The date format is automatically detected, but in case of ambiguity, the preferred format can be selected in the preferences.
To build a timeline, you need to define a list of dates, enclosed by the `|` symbol. The date format is automatically detected, but in case of ambiguity, the preferred format can be selected in the preferences.
There is also support for time spans. Just define two dates separated by ` - ` and they will be turned into a span.
```
## Demo
@ -14,6 +16,7 @@ To build a timeline, you need to define a list of dates, enclosed by the `|` sym
- |2021/04/28| Second event
- |21 6 12| Third event
It's also a valid date
- |2018-07-01 - 2021-11-14| Time span
- |2025-02-05| **bold**, *italic*, ~~strikethrough~~, link to another note [[Welcome]]
- |2025-02-06| Merged events
Test links and multiline code:
@ -26,6 +29,7 @@ To build a timeline, you need to define a list of dates, enclosed by the `|` sym
column-gap: 0.5rem;
color: var(--text-normal);
}
```
- |now| Dynamic event, means that the timeline (e.g. your project) is **live**
- |2056-05-24| Some future event
```

View file

@ -6,4 +6,5 @@
- |2025-02-24| Improved years line rendering
- |2025-03-01| Added settings, pre-release
- |2025-03-02| Switched to Deno, added an editor command
- |2025-03-28 - 2025-04-06| Implementing time spans
- |present| Developing the `Timelive` plugin

View file

@ -2,6 +2,7 @@ import type { Moment } from "moment";
import { TimeliveSettings } from "./TimeliveSettings.ts";
export interface DateFormatter {
formatSpan(fromDate: Moment, toDate: Moment): string;
formatDate(date: Moment): string;
}
@ -12,6 +13,10 @@ export class TimeliveDateFormatter implements DateFormatter {
this.settings = settings;
}
public formatSpan(fromDate: Moment, toDate: Moment): string {
return this.formatDate(fromDate) + " - " + this.formatDate(toDate);
}
public formatDate(date: Moment): string {
return date.format(this.settings.previewTitleDateFormat);
}

View file

@ -2,6 +2,7 @@ import type { Moment } from "moment";
import { TimeliveSettings } from "./TimeliveSettings.ts";
export interface DateParser {
parseSpan(datesString: string): Moment[];
parseDate(dateString: string): Moment;
}
@ -41,6 +42,10 @@ export class TimeliveDateParser implements DateParser {
this.settings = settings;
}
public parseSpan(datesString: string): Moment[] {
return datesString.split(" - ").map((d) => this.parseDate(d.trim()));
}
public parseDate(dateString: string): Moment {
// @ts-ignore: deno lack of type
const moment: MomentCallable = globalThis.moment;

25
src/TimeEvent.ts Normal file
View file

@ -0,0 +1,25 @@
import type { Moment } from "moment";
export enum TimeEventType {
Single = "single",
Span = "span",
}
export interface TimeEvent {
content: string;
type: TimeEventType;
}
export interface SingleTimeEvent extends TimeEvent {
date: Moment;
position: number;
type: TimeEventType.Single;
}
export interface SpanTimeEvent extends TimeEvent {
fromDate: Moment;
toDate: Moment;
position: number;
width: number;
type: TimeEventType.Span;
}

View file

@ -1,14 +1,9 @@
import type { Moment } from "moment";
import { DateTransformer } from "./DateTransformer.ts";
import { SingleTimeEvent, SpanTimeEvent, TimeEvent, TimeEventType } from "./TimeEvent.ts";
const DAYS_IN_YEAR = 365.2422;
interface TimeEvent {
date: Moment;
content: string;
position: number;
}
export class Timelive {
private root: HTMLElement;
private transformer: DateTransformer;
@ -25,9 +20,45 @@ export class Timelive {
}
public addEvent(dateString: string, content: string) {
const date = this.transformer.parser.parseDate(dateString.trim().toLowerCase());
this.events.push({ date, content, position: 0 });
// Recalculate min/max dates
const dates = this.transformer.parser.parseSpan(dateString.trim().toLowerCase());
switch (dates.length) {
case 0:
return;
case 1:
return this.addSingleEvent(dates[0], content);
default:
return this.addSpan(dates[0], dates[1], content);
}
}
private addSingleEvent(date: Moment, content: string) {
this.events.push(
{
date,
content,
position: 0,
type: TimeEventType.Single,
} as SingleTimeEvent,
);
this.recalculateMinMaxDates(date);
}
private addSpan(fromDate: Moment, toDate: Moment, content: string) {
this.events.push(
{
fromDate,
toDate,
content,
position: 0,
width: 0,
type: TimeEventType.Span,
} as SpanTimeEvent,
);
this.recalculateMinMaxDates(fromDate);
this.recalculateMinMaxDates(toDate);
}
private recalculateMinMaxDates(date: Moment) {
const time = date.valueOf();
if (!this.minDate || !this.maxDate) {
this.minDate = date;
@ -74,30 +105,62 @@ export class Timelive {
}
this.mergeClosestEvents(this.events)
.forEach((event) => {
const marker = timelineLine.createDiv({ cls: "tlv-marker" });
marker.style.left = `${event.position}%`;
const popup = marker.createDiv({ cls: "tlv-popup popover hover-popover" });
popup.innerHTML = this.formatEvent(event);
marker.onmouseover = marker.ontouchstart = () => {
popup.style.display = "block";
};
marker.onmouseout = marker.ontouchend = () => {
popup.style.display = "none";
};
switch (event.type) {
case TimeEventType.Single:
this.createMarker(timelineLine, event as SingleTimeEvent);
break;
case TimeEventType.Span:
this.createSpan(timelineLine, event as SpanTimeEvent);
break;
}
});
}
private createMarker(line: HTMLElement, event: SingleTimeEvent) {
const marker = line.createDiv({ cls: "tlv-marker" });
marker.style.left = `${event.position}%`;
this.createPopover(marker, this.formatSingleEvent(event));
}
private createSpan(line: HTMLElement, event: SpanTimeEvent) {
const span = line.createDiv({ cls: "tlv-span" });
span.style.left = `${event.position}%`;
span.style.width = `${event.width}%`;
this.createPopover(span, this.formatSpan(event));
}
private createPopover(marker: HTMLElement, html: string) {
const popup = marker.createDiv({ cls: "tlv-popup popover hover-popover" });
popup.innerHTML = html;
marker.onmouseover = marker.ontouchstart = () => {
popup.style.display = "block";
};
marker.onmouseout = marker.ontouchend = () => {
popup.style.display = "none";
};
}
private mergeClosestEvents(events: TimeEvent[]): TimeEvent[] {
const CLOSEST_DELTA = 1; // %
const merged: TimeEvent[] = [];
let lastPosition: number;
events
.filter((event) => event.type === TimeEventType.Span)
.map((event) => event as SpanTimeEvent)
.sort((a, b) => a.fromDate.valueOf() - b.fromDate.valueOf())
.forEach((event) => {
event.position = this.calculatePosition(event.fromDate);
event.width = this.calculatePosition(event.toDate) - event.position;
merged.push(event);
});
events
.filter((event) => event.type === TimeEventType.Single)
.map((event) => event as SingleTimeEvent)
.sort((a, b) => a.date.valueOf() - b.date.valueOf())
.forEach((event, i) => {
event.position = this.calculatePosition(event.date);
if (i > 0 && (event.position - lastPosition) < CLOSEST_DELTA) {
merged[merged.length - 1].content += this.formatEvent(event, "<hr/>");
merged[merged.length - 1].content += this.formatSingleEvent(event, "<hr/>");
} else {
lastPosition = event.position;
merged.push(event);
@ -106,13 +169,20 @@ export class Timelive {
return merged;
}
private formatEvent(event: TimeEvent, before = ""): string {
private formatSingleEvent(event: SingleTimeEvent, before = ""): string {
const date = this.transformer.formatter.formatDate(event.date);
return before +
`<h4 class="tlv-date-title">${date}</h4>` +
event.content;
}
private formatSpan(event: SpanTimeEvent, before = ""): string {
const date = this.transformer.formatter.formatSpan(event.fromDate, event.toDate);
return before +
`<h4 class="tlv-date-title">${date}</h4>` +
event.content;
}
private calculatePosition(date: Moment): number {
const deltaDays = date.diff(this.startDateTime, "days");
const value = (deltaDays / this.totalDays) * 100;

View file

@ -21,17 +21,28 @@
z-index: 1;
}
.tlv-marker {
.tlv-marker,
.tlv-span {
position: absolute;
background-color: var(--color-base-50);
cursor: pointer;
}
.tlv-marker {
top: -5px;
width: 12px;
height: 12px;
background-color: var(--color-base-50);
border-radius: 50%;
cursor: pointer;
transform: translateX(-50%);
z-index: 2;
z-index: 3;
}
.tlv-span {
top: -1px;
height: 5px;
border-radius: 6px;
z-index: 2;
background-color: var(--color-base-60);
}
.tlv-span h4.tlv-date-title,
.tlv-marker h4.tlv-date-title {
margin-block-start: 0;
}

View file

@ -61,3 +61,66 @@ Deno.test("DateParser", async (test) => {
}
});
});
Deno.test("DateParser spans", async (test) => {
// @ts-ignore: deno lack of type
const expectedFrom = moment("2008-02-09").toDate();
const expectedTo = moment("2012-09-12").toDate();
await test.step("should parse spans with dates in ymd format", () => {
const dates = [
"2008-02-09 - 2012-09-12",
"2008/02/09 - 2012 09 12",
"2008-2-9 - 2012 9 12",
"08-2-9 - 12 9 12",
"2008-02/09 - 2012/09-12",
];
const dateParser: DateParser = new TimeliveDateParser(
MockTimeliveSettings.ofParseDateFormat(DateFormat.YMD),
);
for (const dateStr of dates) {
const result = dateParser.parseSpan(dateStr);
expect(result.length).toEqual(2);
expect(result[0].toDate()).toEqual(expectedFrom);
expect(result[1].toDate()).toEqual(expectedTo);
}
});
await test.step("should parse spans with dates in dmy format", () => {
const dates = [
"09-02-2008 - 12-09-2012",
"09/02/2008 - 12 09 2012",
"9-2-2008 - 12 9 2012",
"9-2-08 - 12 9 12",
"09/02-2008 - 12-09/2012",
];
const dateParser: DateParser = new TimeliveDateParser(
MockTimeliveSettings.ofParseDateFormat(DateFormat.DMY),
);
for (const dateStr of dates) {
const result = dateParser.parseSpan(dateStr);
expect(result.length).toEqual(2);
expect(result[0].toDate()).toEqual(expectedFrom);
expect(result[1].toDate()).toEqual(expectedTo);
}
});
await test.step("should parse spans with dates in mdy format", () => {
const dates = [
"02-09-2008 - 09-12-2012",
"02/09/2008 - 09 12 2012",
"2-9-2008 - 9 12 2012",
"2-9-08 - 9 12 12",
"02/09-2008 - 09-12/2012",
];
const dateParser: DateParser = new TimeliveDateParser(
MockTimeliveSettings.ofParseDateFormat(DateFormat.MDY),
);
for (const dateStr of dates) {
const result = dateParser.parseSpan(dateStr);
expect(result.length).toEqual(2);
expect(result[0].toDate()).toEqual(expectedFrom);
expect(result[1].toDate()).toEqual(expectedTo);
}
});
});