Storyteller Suite - Timeline System

Overview

The timeline system visualizes events chronologically with support for:

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:

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