How to implement a multi-row timeline with dragable items in Angular

Issue

I want to create a timeline grid similar to this one: https://demo.mobiscroll.com/angular/timeline/month-view#drag-drop=true&themeVariant=light

The goal is to have a timeline along the x-axis, and a scalable amount of rows along the y-axis (basically a regular calendar week-view, but with non-fixed amount of days). I want to be able to drag and drop items on a fine-grained level along the x-axis (with minute precision), as well as drag and drop them to new rows. How can this be achieved in Angular?

I’ve tried to come up with a solution using Angular Material’s Drag and Drop feature, but can’t really figure out how I would represent the finely grained x-axis, without creating a long list with items representing every minute of the day. Is this a viable technique, or are there simpler ways?

Solution

I’ve been creating a similar component lately, but I haven’t implemented the timeline mode yet. A demo can be found here.

What I did is

Keep the resource groups in a BehaviorSubject

resources$ = new BehaviorSubject<(Resource | ResourceGroup)[]>([]);

Interfaces:

export interface ResourceGroup {
    description: string;
    children: (ResourceGroup | Resource)[];
}

export interface Resource {
    description: string;
    events: SchedulerEvent[];
}

export interface SchedulerEvent {
    start: Date;
    end: Date;
    color: string;
    description: string;
}

The events are a mapping of the Resources->EventGroups:

this.events$ = this.resources$
  .pipe(map((resourcesOrGroups) => resourcesOrGroups.map(resOrGroup => this.getResourcesForGroup(resOrGroup))))
  .pipe(map(jaggedResources => jaggedResources.reduce((flat, toFlatten) => flat.concat(toFlatten), [])))
  .pipe(map(resources => resources.map(res => res.events)))
  .pipe(map(jaggedEvents => jaggedEvents.reduce((flat, toFlatten) => flat.concat(toFlatten), [])));

Then you need to split these events into parts (for the calendar mode, timeline mode will be splitting per week/month) per day basis:

this.eventParts$ = this.events$.pipe(
  map((events) =>  events.map((ev) => this.timelineService.splitInParts(ev)))
);

This is the code which splits an event into parts per day:

export class BsTimelineService {

  public splitInParts(event: SchedulerEvent | PreviewEvent) {
    let startTime = event.start;
    const result: SchedulerEventPart[] = [];
    const eventOrNull = 'color' in event ? event : null;
    while (!this.dateEquals(startTime, event.end)) {
      const end = new Date(startTime.getFullYear(), startTime.getMonth(), startTime.getDate() + 1, 0, 0, 0);
      result.push({ start: startTime, end: end, event: eventOrNull });
      startTime = end;
    }
    if (startTime != event.end) {
      result.push({ start: startTime, end: event.end, event: eventOrNull });
    }

    return <SchedulerEventWithParts>{ event: event, parts: result };
  }
  
  private dateEquals(date1: Date, date2: Date) {
    return (
      date1.getFullYear() === date2.getFullYear() &&
      date1.getMonth() === date2.getMonth() &&
      date1.getDate() === date2.getDate()
    );
  }

}

Interfaces:

export interface SchedulerEventWithParts {
    event: SchedulerEvent;
    parts: SchedulerEventPart[];
}

And at last you can filter out only the event parts for this week:

this.eventPartsForThisWeek$ = combineLatest([
  this.daysOfWeekWithTimestamps$,
  this.eventParts$
    .pipe(map(eventParts => eventParts.map(evp => evp.parts)))
    .pipe(map(jaggedParts =>  jaggedParts.reduce((flat, toFlatten) => flat.concat(toFlatten), [])))
  ])
  .pipe(map(([startAndEnd, eventParts]) => {
    return eventParts.filter(eventPart => {
      return !((eventPart.end.getTime() <= startAndEnd.start) || (eventPart.start.getTime() >= startAndEnd.end));
    });
  }));

Which you can render in your view using the async pipe:

<div *ngFor="let eventPart of (eventPartsForThisWeek$ | async)"></div>

In your case you’ll be fine with this, however when you want a calendar mode (like the one I started with) you’ll still need to display these events in multiple columns. Therefor I created another observable:

this.timelinedEventPartsForThisWeek$ = this.eventPartsForThisWeek$
  .pipe(map(eventParts => {
    // We'll only use the events for this week
    const events = eventParts.map(ep => ep.event)
      .filter((e, i, list) => list.indexOf(e) === i)
      .filter((e) => !!e)
      .map((e) => <SchedulerEvent>e);
    const timeline = this.timelineService.getTimeline(events);

    const result = timeline.map(track => track.events.map(ev => ({ event: ev, index: track.index })))
      .reduce((flat, toFlatten) => flat.concat(toFlatten), [])
      .map((evi) => eventParts.filter(p => p.event === evi.event).map(p => ({ part: p, index: evi.index })))
      .reduce((flat, toFlatten) => flat.concat(toFlatten), []);

    return {
      total: timeline.length,
      parts: result
    };
  }));

The following method is creating a timeline with tracks for the given events:

public getTimeline(events: SchedulerEvent[]) {
    const timestamps = this.getTimestamps(events);
    const tracks: TimelineTrack[] = [];

    timestamps.forEach((timestamp, tIndex) => {
        const starting = events.filter((e) => e.start === timestamp);
        // const ending = events.filter((e) => e.end === timestamp);

        starting.forEach((startedEvent, eIndex) => {
            const freeTracks = tracks.filter(t => this.trackIsFreeAt(t, startedEvent));
            if (freeTracks.length === 0) {
                tracks.push({ index: tracks.length, events: [startedEvent] });
            } else {
                freeTracks[0].events.push(startedEvent);
            }
        });
    });

    return tracks;
}

private getTimestamps(events: SchedulerEvent[]) {
    const allTimestamps = events.map(e => [e.start, e.end])
        .reduce((flat, toFlatten) => flat.concat(toFlatten), []);

    return allTimestamps
        .filter((t, i) => allTimestamps.indexOf(t) === i)
        .sort((t1, t2) => <any>t1 - <any>t2);
}

private trackIsFreeAt(track: TimelineTrack, event: SchedulerEvent) {
    if (track.events.every((ev) => (ev.end <= event.start) || (event.end <= ev.start))) {
        return true;
    } else {
        return false;
    }
}

Note that I had to omit the TimelineService for events created by dragging-dropping, or events being moved around. This causes too much computations through the TimelineService and is too intense for a webbrowser to handle.

Answered By – Pieterjan

Answer Checked By – Jay B. (AngularFixing Admin)

Leave a Reply

Your email address will not be published.