Storyteller Suite - Timeline System
Overview
The timeline system visualizes events chronologically with support for:
- Point-in-time events
- Duration-based events (ranges)
- Gantt chart view with progress bars
- Event dependencies (arrows)
- Multiple grouping options
- Filtering by character, location, group
- Milestone highlighting
Technology
Built on vis-timeline library with timeline-arrows for dependencies.
Timeline View (TimelineView.ts)
class TimelineView extends ItemView {
private timeline: Timeline;
private items: DataSet<TimelineItem>;
private groups: DataSet<TimelineGroup>;
async onOpen(): Promise<void> {
await this.loadEvents();
this.createTimeline();
this.setupEventHandlers();
}
private createTimeline(): void {
this.timeline = new Timeline(
this.containerEl,
this.items,
this.groups,
this.getOptions()
);
}
}
Event to Timeline Item Conversion
// TimelineRenderer.ts
function eventToTimelineItem(event: Event): TimelineItem {
return {
id: event.id,
content: event.name,
start: parseDate(event.dateTime),
end: event.endDateTime ? parseDate(event.endDateTime) : undefined,
type: event.endDateTime ? 'range' : 'point',
group: getGroupId(event),
className: buildClassName(event),
title: buildTooltip(event),
};
}
Timeline Options
const options: TimelineOptions = {
// Time axis
orientation: 'top',
showCurrentTime: true,
zoomable: true,
moveable: true,
// Item behavior
editable: {
add: false,
updateTime: settings.editMode, // Drag to reschedule
updateGroup: true,
remove: false,
},
// Stacking
stack: settings.defaultStacking,
stackSubgroups: true,
// Grouping
groupOrder: 'content',
// Zoom limits
zoomMin: 1000 * 60 * 60 * 24, // 1 day
zoomMax: 1000 * 60 * 60 * 24 * 365 * 100, // 100 years
// Callbacks
onMove: (item, callback) => { ... },
onUpdate: (item, callback) => { ... },
};
Grouping Options
No Grouping
All events on single track.
By Location
function groupByLocation(events: Event[]): TimelineGroup[] {
const groups: TimelineGroup[] = [];
const locationIds = new Set(events.map(e => e.location));
for (const locId of locationIds) {
const location = await getLocation(locId);
groups.push({
id: locId || 'unknown',
content: location?.name || 'Unknown Location',
});
}
return groups;
}
By Group/Faction
function groupByFaction(events: Event[]): TimelineGroup[] {
// Group events by their associated groups
}
By Character
function groupByCharacter(events: Event[]): TimelineGroup[] {
// Group events by involved characters
}
Gantt View
Toggle between timeline and Gantt modes:
// Gantt-specific options
const ganttOptions = {
type: 'range', // All items as bars
// Progress bars
template: (item: TimelineItem) => {
if (settings.showProgressBarsInGantt && item.progress !== undefined) {
return `
<div class="gantt-item">
<span class="gantt-label">${item.content}</span>
<div class="gantt-progress-bar" style="width: ${item.progress}%"></div>
</div>
`;
}
return item.content;
},
// Default duration for point events
// Settings: defaultGanttDuration (days)
};
Dependencies (Arrows)
Using timeline-arrows library:
// Add dependency arrows
const arrows = new Arrow(timeline, []);
function addDependencyArrows(events: Event[]): void {
const arrowData: ArrowData[] = [];
for (const event of events) {
if (event.dependencies) {
for (const depId of event.dependencies) {
arrowData.push({
id: `${depId}-${event.id}`,
id_item_1: depId,
id_item_2: event.id,
});
}
}
}
arrows.setArrows(arrowData);
}
Arrow Styles
Configurable in settings:
- Solid -
dependency-arrow-solid - Dashed -
dependency-arrow-dashed - Dotted -
dependency-arrow-dotted
Filtering
TimelineFilterBuilder.ts
class TimelineFilterBuilder {
private filters: TimelineFilters = {};
filterByCharacter(characterId: string): this {
this.filters.characterId = characterId;
return this;
}
filterByLocation(locationId: string): this {
this.filters.locationId = locationId;
return this;
}
filterByGroup(groupId: string): this {
this.filters.groupId = groupId;
return this;
}
filterByDateRange(start: Date, end: Date): this {
this.filters.dateRange = { start, end };
return this;
}
filterMilestonesOnly(value: boolean): this {
this.filters.milestonesOnly = value;
return this;
}
apply(events: Event[]): Event[] {
return events.filter(event => {
if (this.filters.characterId &&
!event.charactersInvolved?.includes(this.filters.characterId)) {
return false;
}
if (this.filters.locationId &&
event.location !== this.filters.locationId) {
return false;
}
if (this.filters.milestonesOnly && !event.isMilestone) {
return false;
}
// ... more filters
return true;
});
}
}
Controls
TimelineControlsBuilder.ts
class TimelineControlsBuilder {
buildControls(container: HTMLElement): void {
// Zoom preset buttons
this.addZoomPresets(container);
// Grouping dropdown
this.addGroupingDropdown(container);
// Filter panel
this.addFilterPanel(container);
// Edit mode toggle
this.addEditModeToggle(container);
// View mode toggle (Timeline/Gantt)
this.addViewModeToggle(container);
// Legend toggle
this.addLegendToggle(container);
}
private addZoomPresets(container: HTMLElement): void {
const presets = ['fit', 'decade', 'century'];
for (const preset of presets) {
new ButtonComponent(container)
.setButtonText(t(preset + 'Option'))
.onClick(() => this.timeline.setWindow(
this.getPresetWindow(preset)
));
}
}
}
Edit Mode
When enabled, events can be rescheduled by dragging:
timeline.on('itemover', (props) => {
if (settings.editMode) {
showTooltip(props.item, 'Drag to reschedule');
}
});
timeline.on('changed', async (item) => {
const event = await getEvent(item.id);
event.dateTime = item.start.toISOString();
if (item.end) {
event.endDateTime = item.end.toISOString();
}
await saveEvent(event);
new Notice(t('eventRescheduled', event.name));
});
Milestones
Events marked as milestones get special styling:
if (event.isMilestone) {
item.className += ' timeline-milestone';
}
.timeline-milestone {
background: linear-gradient(45deg, #ffd700, #ffaa00);
border: 2px solid #ff8800;
}
.timeline-milestone::before {
content: '⭐';
}
Custom Today
Override "today" for historical fiction:
if (settings.customToday) {
const customDate = parseDate(settings.customToday);
timeline.setCurrentTime(customDate);
}
Era Support
Time periods with custom dating systems:
// EraManager.ts
interface Era {
id: string;
name: string;
startDate: string;
endDate?: string;
color?: string;
}
class EraManager {
addEra(era: Era): void;
getEraForDate(date: Date): Era | undefined;
formatDateInEra(date: Date, era: Era): string;
}
Timeline Tracks
Multiple parallel timelines:
// TimelineTrackManager.ts
interface TimelineTrack {
id: string;
name: string;
color?: string;
visible: boolean;
}
class TimelineTrackManager {
addTrack(track: TimelineTrack): void;
toggleTrackVisibility(trackId: string): void;
assignEventToTrack(eventId: string, trackId: string): void;
}
Tag-Based Timeline
Generate timeline from tags:
// TagTimelineGenerator.ts
class TagTimelineGenerator {
generateTimeline(tag: string): TimelineItem[] {
// Find all entities with this tag
// Extract dates from their content
// Create timeline items
}
}
Timeline Modal (TimelineModal.ts)
Standalone modal for timeline view:
class TimelineModal extends ResponsiveModal {
onOpen(): void {
this.contentEl.addClass('storyteller-timeline-modal');
// Controls bar
const controls = this.contentEl.createDiv('timeline-controls');
new TimelineControlsBuilder(this).buildControls(controls);
// Timeline container
const container = this.contentEl.createDiv('timeline-container');
// Initialize timeline
this.initTimeline(container);
}
}
Settings
Timeline-related settings:
| Setting | Type | Default | Description |
|---|---|---|---|
forwardDateBias |
boolean | true | Prefer future dates in parsing |
customToday |
string | '' | Override current date |
defaultTimelineGrouping |
string | 'none' | Default grouping |
defaultZoomPreset |
string | 'fit' | Initial zoom |
defaultStacking |
boolean | true | Stack overlapping items |
defaultDensity |
number | 1 | Item density |
showLegendByDefault |
boolean | false | Show legend |
timelineDefaultHeight |
string | '400px' | Container height |
showProgressBarsInGantt |
boolean | true | Progress overlays |
defaultGanttDuration |
number | 7 | Days for point events |
dependencyArrowStyle |
string | 'solid' | Arrow line style |
Date Parsing
Natural language dates via chrono-node:
// DateParsing.ts
import * as chrono from 'chrono-node';
function parseDate(text: string, referenceDate?: Date): Date | null {
const results = chrono.parse(text, referenceDate, {
forwardDate: settings.forwardDateBias
});
if (results.length > 0) {
return results[0].date();
}
return null;
}
// Examples:
parseDate("June 15, 1225") // Historical date
parseDate("next Friday") // Relative date
parseDate("in two weeks") // Future date
parseDate("3 days ago") // Past date