How to show boxes properly? (Algorithm, UI)

Viewed 177

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.

1 Answers

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>

Related