2

I am currently trying to generate a View with React Typescript where I can show all Appointments of a day (similar to outlook calendar). But I am facing an issue. When Appointments are overlapping how can I determine the with and position? So I guess that I probably need an algorithm accomplish this issue.

Here an example how it could be structured: Example Calendar

Every box stands for an Appointment

The classes of the Object looks like this:

class AppointmentStructured {
  appointment: Appointment
  width?: number
  left?: number
}

class Appointment {
start: Date
end: Date
subject:string
duration: number
}

I am looking for a solution where I can determine the width (max. 1 = 100%) of the specific appointment and additionally I need the position. The easiest would be to how far it is from the left (max. 1). When there are more than 2 appointments starting at the same time, the appointments are ordered by the appointment with the longest duration.

Example the third box from the image would be:

  • width: 0.333
  • left: 0.666

Out of this data I can use CSS to design them. The generated CSS then would look more or less like this:

 position: absolute;
 width: calc(100% * 0.33333);
 top: 20rem; //doesn't matter for the algorithm
 left: calc(100% * 0.66666)

--Edit This is how far i came. It starts with the method generateAppointmentTile([])

export interface AppointmentStructured {
    appointment: Appointment
    width: string | number;
    left: string | number
}

export const generateAppointmentTile = (appointments: Appointment[]): AppointmentStructured[] => {
    var appointmentsStructured = initAppointmentStructured(appointments);
    var response: AppointmentStructured[] = [];

    for (var i = 0; i < appointmentsStructured.length; ++i) {
        var width = 1;

        var previous = getPreviousOverlapping(appointmentsStructured, i);
        var previousWidth = previous
            .map((item: AppointmentStructured): number => item.width as number)
            .reduce((a: number, b: number): number => a + b, 0)

        var forward = getForwardOverlapping(appointmentsStructured, i);
        var forwardOverlays = appointmentOverlays(forward);

        var previousHasOverlayWithForward = false;
        previous.forEach((structured: AppointmentStructured) => checkAppointmentOverlaySingle(structured, forward) !== 0 ? previousHasOverlayWithForward = true :  null);


        width = (width - previousWidth) / (!previousHasOverlayWithForward && forwardOverlays !== 0 ? forwardOverlays : 1);
        appointmentsStructured[i].width = width;
        response.push(appointmentsStructured[i]);
    }

    response.forEach((value: AppointmentStructured): void => {
        value.width = `calc((100% - 8rem) * ${value.width})`;
    });

    return response
}
const appointmentOverlays = (reading: AppointmentStructured[]): number => {
    var highestNumber = 0;

    reading.forEach((structured: AppointmentStructured): void => {
        var start = checkAppointmentOverlaySingle(structured, reading) + 1;
        highestNumber = start > highestNumber ? start : highestNumber;
    });

    return highestNumber;
}

const checkAppointmentOverlaySingle = (structured: AppointmentStructured, reading: AppointmentStructured[]): number => {
    var start = 0;

    reading.forEach((item: AppointmentStructured): void => {
        if (item.appointment.id !== structured.appointment.id) {
            if ((structured.appointment.start <= item.appointment.start && structured.appointment.end >= item.appointment.start)
                || (structured.appointment.start >= item.appointment.start && structured.appointment.start <= item.appointment.end)) {
                start += 1;
            }
        }
    });

    return start;
}

const getPreviousOverlapping = (appointmentsStructured: AppointmentStructured[], index: number): AppointmentStructured[] => {
    var response: AppointmentStructured[] = [];

    for (var i = index - 1; i >= 0; --i) {
        if (appointmentsStructured[index].appointment.start >= appointmentsStructured[i].appointment.start
            && appointmentsStructured[index].appointment.start <= appointmentsStructured[i].appointment.end) {
            response.push(appointmentsStructured[i]);
        }
    }
    return response;
}

const getForwardOverlapping = (appointmentsStructured: AppointmentStructured[], index: number): AppointmentStructured[] => {
    var response: AppointmentStructured[] = [];

    for (var i = index; i < appointmentsStructured.length; ++i) {
        if (appointmentsStructured[index].appointment.start >= appointmentsStructured[i].appointment.start
            && appointmentsStructured[index].appointment.start <= appointmentsStructured[i].appointment.end) {
            response.push(appointmentsStructured[i]);
        }
    }
    return response;
}

const initAppointmentStructured = (appointments: Appointment[]): AppointmentStructured[] => {
    var appointmentsStructured: AppointmentStructured[] = appointments
        .sort((a: Appointment, b: Appointment): number => a.start.getTime() - b.start.getTime())
        .map((appointment: Appointment): AppointmentStructured => ({ appointment, width: 100, left: 0 }));
    var response: AppointmentStructured[] = [];

    // sort in a intelligent way
    for (var i = 0; i < appointmentsStructured.length; ++i) {
        var duration = appointmentsStructured[i].appointment.end.getTime() - appointmentsStructured[i].appointment.start.getTime();
        var sameStartAppointments = findAppointmentWithSameStart(appointmentsStructured[i], appointmentsStructured);
        var hasLongerAppointment: boolean = false;
        sameStartAppointments.forEach((structured: AppointmentStructured) => (structured.appointment.end.getTime() - structured.appointment.start.getTime()) > duration ? hasLongerAppointment = true : null);

        if (!hasLongerAppointment) {
            response.push(appointmentsStructured[i]);
            appointmentsStructured.splice(i, 1);
            i = -1;
        }
    }

    return response.sort((a: AppointmentStructured, b: AppointmentStructured): number => a.appointment.start.getTime() - b.appointment.start.getTime());
}

const findAppointmentWithSameStart = (structured: AppointmentStructured, all: AppointmentStructured[]): AppointmentStructured[] => {
    var response: AppointmentStructured[] = [];
    all.forEach((appointmentStructured: AppointmentStructured) => appointmentStructured.appointment.start === structured.appointment.start
        && appointmentStructured.appointment.id !== structured.appointment.id ? response.push(appointmentStructured) : null)
    return response;
}

Even some pseudo code would help me a lot.

Athii
  • 559
  • 5
  • 19
  • What have **YOU** tried / researched so far? Share **YOUR** ideas / findings /code. – MrSmith42 Jan 15 '21 at 17:44
  • @MrSmith42 i put now my current state of my code. I hope it helps – Athii Jan 15 '21 at 18:02
  • There is some room for interpretation here. How do you want to handle this case: Two appointments start at the same time. One of them ends. Then two new appointments start alongside the ongoing. Should all three remaining have 1/3 width or should the ongoing one have 1/2 width and the two new ones have 1/4 width? Do you have some specs for this? – Josef Wittmann Jan 18 '21 at 09:53
  • @JosefWittmann In this case the two new appointments would have a 1/4 width. I think that one of the difficult scenarios that makes the algorithm so difficult to create. It's comparable with the two appointments starting at 10:30 in the image (1/6 width). Hope this helps you. I'm sorry when I wasn't clear enough with my explanation. – Athii Jan 18 '21 at 10:31
  • I think you'll run into problems with this approach, since it follows the rule "keep a box as wide, as it was at its start". So when a box starts alone, it would never shrink, if you would construct your algorithm that way. I'd do something like counting overlapping appointments and make their width maximal for that. – Josef Wittmann Jan 18 '21 at 10:53

1 Answers1

2

This solution is based on this answer: Visualization of calendar events. Algorithm to layout events with maximum width

I've just ported the script from C# to JS and extracted some steps.

  1. Group all your intervals/appointments into distinct groups, where one group does not influence another.
  2. For each group, fill columns from left to right (with each appointment getting the same width). Just use as many columns as you need.
  3. Stretch each appointment maximally.
  4. Make the actual boxes from that and render them.

This procedure is not perfect. Sometimes you will have a thin box on the left and the box to the right got stretched all the way. This happens mainly when the left box has a long "overlap chain" to the bottom, which does not really happen in practice.

/*
interface Interval {
  start: number;
  end: number;
}
*/

// Just for demonstration purposes.
function generateIntervals({ count, maxStart, maxEnd, minLength, maxLength, segments }) {
  return Array.from(Array(count), () => randomInterval())

  function randomInterval() {
    const start = randomInt(0, maxStart * segments) / segments
    const length = randomInt(minLength * segments, maxLength * segments) / segments
    return {
      start,
      end: Math.min(start + length, maxEnd),
      text: "Here could be your advertising :)"
    }
  }

  function randomInt(start, end) {
    return Math.floor(Math.random() * (end - start) + start)
  }
}

function isOverlapping(interval1, interval2) {
  const start = Math.max(interval1.start, interval2.start)
  const end = Math.min(interval1.end, interval2.end)
  return start < end
}

// Sorts by start ascending.
function sortIntervalsByStart(intervals) {
  return intervals
    .slice()
    .sort(({ start: s1 }, { start: s2 }) => s1 - s2)
}

// Split intervals into groups, which are independent of another.
function groupIntervals(intervals) {
  const groups = []
  let latestIntervalEnd = -Infinity
  for (const interval of sortIntervalsByStart(intervals)) {
    const { start, end } = interval
    // There is no overlap to previous intervals. Create a new group.
    if (start >= latestIntervalEnd) {
      groups.push([])
    }
    groups[groups.length - 1].push(interval)
    latestIntervalEnd = Math.max(latestIntervalEnd, end)
  }
  return groups
}

// Fill columns with equal width from left to right.
function putIntervalsIntoColumns(intervals) {
  const columns = []
  for (const interval of intervals) {
    let columnIndex = findFreeColumn(interval)
    columns[columnIndex] = (columns[columnIndex] || []).concat([interval])
  }
  return columns

  function findFreeColumn(interval) {
    let columnIndex = 0
    while (
      columns?.[columnIndex]
        ?.some(otherInterval => isOverlapping(interval, otherInterval))
    ) {
      columnIndex++
    }
    return columnIndex
  }
}

// Expand columns maximally.
function makeBoxes(columns, containingInterval) {
  const boxes = []
  for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
    for (const interval of columns[columnIndex]) {
      const columnSpan = findColumnSpan(columnIndex, interval)
      const box = {
        ...interval,
        top: (interval.start - containingInterval.start) / (containingInterval.end - containingInterval.start),
        height: (interval.end - interval.start) / (containingInterval.end - containingInterval.start),
        left: columnIndex / columns.length,
        width: columnSpan / columns.length
      }
      boxes.push(box)
    }
  }
  return boxes

  function findColumnSpan(columnIndex, interval) {
    let columnSpan = 1
    while (
      columns
        ?.[columnIndex + columnSpan]
        ?.every(otherInterval => !isOverlapping(interval, otherInterval))
    ) {
      columnSpan++
    }
    return columnSpan
  }
}

function computeBoxes(intervals, containingInterval) {
  return groupIntervals(intervals)
    .map(intervals => putIntervalsIntoColumns(intervals))
    .flatMap(columns => makeBoxes(columns, containingInterval))
}

function renderBoxes(boxes) {
  const calendar = document.querySelector("#calendar")

  for (const { top, left, width, height, text = "" } of boxes) {
    const el = document.createElement("div")
    el.style.position = "absolute"
    el.style.top = `${top * 100}%`
    el.style.left = `${left * 100}%`
    el.style.width = `${width * 100}%`
    el.style.height = `${height * 100}%`
    el.textContent = text
    calendar.appendChild(el)
  }
}

const hours = 24
const intervals = generateIntervals({
  count: 20,
  maxStart: 22,
  maxEnd: hours,
  maxLength: 4,
  minLength: 1,
  segments: 2,
})
const boxes = computeBoxes(intervals, { start: 0, end: hours })
renderBoxes(boxes)
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

#calendar {
  height: 100vh;
  width: 100vw;
  min-height: 800px;
  position: relative;
}

#calendar > * {
  border: 1px solid black;
  background-color: #b89d9d;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  overflow-y: auto;
}
<div id="calendar"></div>
<script src="./index.js"></script>
Josef Wittmann
  • 1,259
  • 9
  • 16