You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
175 lines
5.9 KiB
JavaScript
175 lines
5.9 KiB
JavaScript
import * as utils from './utils.js';
|
|
|
|
export default class ScheduleGrid {
|
|
constructor(args) {
|
|
this.title = args.title;
|
|
this.granularity = args.granularity;
|
|
this.start_time = args.start_time;
|
|
this.end_time = args.end_time;
|
|
|
|
this.events = ScheduleGrid.assignTracks(args.events);
|
|
|
|
this.tracks = [...new Set(this.events.flatMap(e => e.tracks))].sort()
|
|
if (args.single_column) {
|
|
// TODO Handle this.
|
|
}
|
|
}
|
|
|
|
static assignTracks(events) {
|
|
const eventsWithoutTracks = events.filter(e => e.track === undefined);
|
|
if (eventsWithoutTracks.length === 0) return events;
|
|
|
|
// A track is a column in the schedule grid. An event must be in at
|
|
// least one track. This is a list of tracks where each track is a
|
|
// collection of the events in that track. Two events may not be in
|
|
// the same track at the same time, unless one is "squishable".
|
|
const tracks = [];
|
|
|
|
// Events may have explicitly specified tracks. Fill in that
|
|
// information first.
|
|
const eventsWithTracks = events.filter(e => e.track !== undefined);
|
|
for (const event of eventsWithTracks) {
|
|
const eventTracks = event.tracks;
|
|
// If the track is "all", then eventTracks will be empty.
|
|
if (eventTracks.length > 0) {
|
|
const maxTrack = Math.max(...eventTracks);
|
|
if (!Number.isInteger(maxTrack)) {
|
|
throw "Invalid tracks for event: " + event.track;
|
|
}
|
|
while (maxTrack >= tracks.length) tracks.push([]);
|
|
for (const eventTrack of eventTracks) tracks[eventTrack].push(event);
|
|
}
|
|
}
|
|
|
|
// Greedily assign events to tracks, sorted by start time.
|
|
eventsWithoutTracks.sort((a, b) => a.start_time.cmp(b.start_time));
|
|
for (const event of eventsWithoutTracks) {
|
|
// Put the event in the first track with space for it.
|
|
tracks.forEach((track, trackNum) => {
|
|
if (event.track === undefined) {
|
|
const hasOverlappingEvents = track.some(e => e.at_same_time_as(event));
|
|
if (!hasOverlappingEvents) {
|
|
track.push(event);
|
|
event.track = trackNum;
|
|
}
|
|
}
|
|
});
|
|
|
|
// No track had space for it, create a new track to put it in.
|
|
if (event.track === undefined) {
|
|
event.track = tracks.length;
|
|
tracks.push([event]);
|
|
}
|
|
}
|
|
|
|
// The above code doesn't consider events with track "all".
|
|
// If another event happens at the same time, we give that event
|
|
// precedence and split the "all" event to only be in the other
|
|
// tracks.
|
|
function fixOverlappingAllTrackEvents(event) {
|
|
if (event.track !== 'all') return [event];
|
|
|
|
const overlappingEvents = events
|
|
.filter(e => e !== event && e.at_same_time_as(event));
|
|
if (overlappingEvents.length === 0) return [event];
|
|
|
|
const overlappingTracks = new Set(overlappingEvents.flatMap(e => e.tracks));
|
|
const eventTracks = [[]];
|
|
for (let i = 0; i < tracks.length; i++) {
|
|
if (!overlappingTracks.has(i)) {
|
|
const last = eventTracks[eventTracks.length-1];
|
|
if (last.length === 0 || last[last.length-1] === i-1) {
|
|
last.push(i);
|
|
} else {
|
|
eventTracks.push([i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return eventTracks.map(t => {
|
|
const eventClone = event.clone();
|
|
eventClone.track = t;
|
|
});
|
|
}
|
|
|
|
return events.flatMap(fixOverlappingAllTrackEvents);
|
|
}
|
|
|
|
get timeRange() {
|
|
return new utils.TimeRange(this);
|
|
}
|
|
|
|
get numRows() {
|
|
return this.timeRange.duration.dividedBy(this.granularity);
|
|
}
|
|
|
|
toHtml(tableClasses) {
|
|
const table = document.createElement('table');
|
|
|
|
table.classList.add('schedule');
|
|
if (tableClasses) {
|
|
for (const cls of tableClasses) {
|
|
table.classList.add(cls);
|
|
}
|
|
}
|
|
|
|
const numRows = this.numRows;
|
|
for (let rowIdx = 0; rowIdx < numRows; rowIdx++) {
|
|
const row = document.createElement('tr');
|
|
|
|
const rowTime = this.start_time.plus(this.granularity.times(rowIdx));
|
|
const timeCell = document.createElement('th');
|
|
const timeSpan = document.createElement('span');
|
|
timeSpan.innerText = rowTime.to12HourString();
|
|
timeSpan.classList.add('row_time');
|
|
// TODO Custom granularity to highlight?
|
|
if (rowTime.minute === 0 && rowTime.second === 0) {
|
|
timeSpan.classList.add('hour');
|
|
}
|
|
timeCell.appendChild(timeSpan);
|
|
row.appendChild(timeCell);
|
|
|
|
for (const track of this.tracks) {
|
|
// Select events of this track in this row.
|
|
const events = this.events
|
|
.filter(e => (e.track === 'all' || e.tracks.includes(track))
|
|
&& e.start_time.cmp(rowTime) <= 0 && rowTime.cmp(e.end_time) < 0);
|
|
if (events.length === 0) {
|
|
row.appendChild(document.createElement('td'));
|
|
} else if (events.length > 1) {
|
|
throw "Overlapping events: " + events;
|
|
} else {
|
|
const event = events[0];
|
|
const allTracksEvent = event.track === 'all';
|
|
const allTracksTrack = event.tracks.length > 1
|
|
? track === Math.min(...event.tracks)
|
|
: track === 0;
|
|
|
|
if (event.start_time.cmp(rowTime) === 0
|
|
&& (!allTracksEvent || allTracksTrack)
|
|
&& (event.tracks.length <= 1 || allTracksTrack)) {
|
|
const eventNumRows = event.timeRange.duration
|
|
.dividedBy(this.granularity);
|
|
const cell = document.createElement('td');
|
|
cell.rowSpan = eventNumRows;
|
|
cell.colSpan = allTracksEvent
|
|
? this.tracks.length
|
|
: event.tracks.length;
|
|
cell.classList.add('event');
|
|
for (const eventClass of event.classes) {
|
|
cell.classList.add(eventClass);
|
|
}
|
|
cell.appendChild(event.description);
|
|
row.appendChild(cell);
|
|
}
|
|
}
|
|
}
|
|
|
|
row.appendChild(timeCell.cloneNode(true));
|
|
table.appendChild(row);
|
|
}
|
|
|
|
return table;
|
|
}
|
|
}
|