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
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.