Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to spread divs around without overlapping each other

I'm building an event scheduler and I've gotten into a point that I can't figure out a way to spread events around without overlapping each other. (It might have events at the same time and without limits. Whenever is possible, use 100% of the available width)

Here it is a picture of this scenario. enter image description here

Some considerations:

  • The events is wrapped inside a div with position: relative and all the events has position:absolute.

  • Using javascript, I have to figure out what needs to be the value of top left width and height of each "div event" on the fly.

  • Events are an array of objects like the code below:

    { startAt: "12:00:30", endsAt: "13:00:00", description: "evt1", id: '00001' }

  • I'm using Vue.js to develop this project. But this is not an issue if you don't know Vue. I've build a small project using jsbin so you can just play around with a javascript function.

Live Code: https://jsbin.com/bipesoy/

Where I'm having problem?

I can't find a algorithm to calculate the top left width and height on the fly based on an array of events.

Some considerations about the jsbin code:

  • All the code to find the 4 properties above is inside the function parsedEvents
  • Inside parsedEvents you can access the array of events using: this.events
  • The job of parsedEvents is loop through the array of events and add the style propertie to each one and then return a new array of events with the style object.
  • Each 30 minutes has a height of 40px;

Any ideas how to accomplish it or a better solution?

like image 541
Rodrigo Pereira Avatar asked Oct 17 '22 19:10

Rodrigo Pereira


1 Answers

After some time playing with this challenge I think it's time to give up from the idea. It is possible to program many possible scenarios of arranging events, but when you dig deep you realize it's very difficult to even write what exactly you want to be done and even if you manage, some of your decisions just don't look good on the screen. This is only with expanding events in width. Positioning and even re-positioning them to fill gaps is solved and not too difficult.

Snippet is here (or JSBin if you prefer: http://jsbin.com/humiyi/99/edit?html,js,console,output).

Green events are detected as "expandable". Grey o nes cannot expand. To clarify problems with logic of expanding, some examples:

  • evt 1 and evt3? it can be like this or evt3 goes right and they both expand
  • evt7 and evt12? Many ways to expand this... how to define a rule?
  • imagine evt7 and evt11 ar merged into one big event. How to expand evt10, evt7/11 and evt 12? ... now try to write rules to consistently answer on above 3 (and many many more possible scenarios not in this example)

My conclusion is that writing rules and developing this is not worth the effort. UI usability will not get much. They will even loose in some scenarios, i.e. some events would be visually bigger just because they have space and not because they're more important.

I'd suggest layout similar or exactly the same as the one in example. Events just don't expand. I don't know how much daily events you expect, what's real life scenario, but only upgrade I'd potentially do is to split vertically calendar in separated regions - point in time which is not overlapping with any event, like line between evt7 and evt11 in example. Then run this same script per region independently. That will recalculate vertical slots per region so region with evt10 i evt11 will have only 2 vertical slots filling space 50% each. This maybe be worth it if your calendar has few crowded hours and only a few events later/before. This would fix an issue of too narrow events later in the day without spending much time. But if events are all over the day and overlapping alot I don't think it's worth it.

let events = [
  { startAt: "00:00", endsAt: "01:00", description: "evt1", id: '00001' },
  { startAt: "01:30", endsAt: "08:00", description: "evt2", id: '00002' },
  { startAt: "01:30", endsAt: "04:00", description: "evt3", id: '00003' },
  { startAt: "00:30", endsAt: "02:30", description: "evt3", id: '00013' },
  { startAt: "00:00", endsAt: "01:00", description: "evt3", id: '00014' },
  { startAt: "03:00", endsAt: "06:00", description: "evt4", id: '00004' },
  { startAt: "01:30", endsAt: "04:30", description: "evt5", id: '00005' },
  { startAt: "01:30", endsAt: "07:00", description: "evt6", id: '00006' },
  { startAt: "06:30", endsAt: "09:00", description: "evt7", id: '00007' },
  { startAt: "04:30", endsAt: "06:00", description: "evt8", id: '00008' },
  { startAt: "05:00", endsAt: "06:00", description: "evt9", id: '00009' },
  { startAt: "09:00", endsAt: "10:00", description: "evt10", id: '00010' },
  { startAt: "09:00", endsAt: "10:30", description: "evt11", id: '00011' },
  { startAt: "07:00", endsAt: "08:00", description: "evt12", id: '00012' }
]

console.time()

// will store counts of events in each 30-min chunk
// each element represents 30 min chunk starting from midnight
// ... so indexOf * 30 minutes = start time
// it will also store references to events for each chunk
// each element format will be: { count: <int>, eventIds: <array_of_ids> }
let counter = []

// helper to convert time to counter index
time2index = (time) => {
  let splitTime = time.split(":")
  return parseInt(splitTime[0]) * 2 + parseInt(splitTime[1])/30
}

// loop through events and fill up counter with data
events.map(event => {
  for (let i = time2index(event.startAt); i < time2index(event.endsAt); i++) {
    if (counter[i] && counter[i].count) {
      counter[i].count++
      counter[i].eventIds.push(event.id)
    } else {
      counter[i] = { count: 1, eventIds: [event.id] }
    }
  }
})

//find chunk with most items. This will become number of slots (vertical spaces) for our calendar grid
let calSlots = Math.max( ...counter.filter(c=>c).map(c=>c.count) ) // filtering out undefined elements
console.log("number of calendar slots: " + calSlots)

// loop through events and add some more props to each:
// - overlaps: all overlapped events (by ref)
// - maxOverlapsInChunk: number of overlapped events in the most crowded chunk
//   (1/this is maximum number of slots event can occupy)
// - pos: position of event from left (in which slot it starts)
// - expandable: if maxOverlapsInChunk = calSlot, this event is not expandable for sure
events.map(event => {
  let overlappedEvents = events.filter(comp => {
    return !(comp.endsAt <= event.startAt || comp.startAt >= event.endsAt || comp.id === event.id)
  })
  event.overlaps = overlappedEvents //stores overlapped events by reference!
  event.maxOverlapsInChunk = Math.max( ...counter.filter(c=>c).map(c=>c.eventIds.indexOf(event.id) > -1 ? c.count : 0))
  event.expandable = event.maxOverlapsInChunk !== calSlots
  event.pos = Math.max( ...counter.filter(c=>c).map( c => {
    let p = c.eventIds.indexOf(event.id)
    return p > -1 ? p+1 : 1
  }))
})

// loop to move events leftmost possible and fill gaps if any
// some expandable events will stop being expandable if they fit gap perfectly - we will recheck those later
events.map(event => {
  if (event.pos > 1) {
    //find positions of overlapped events on the left side
    let vertSlotsTakenLeft = event.overlaps.reduce((result, cur) => {
      if (result.indexOf(cur.pos) < 0 && cur.pos < event.pos) result.push(cur.pos)
      return result
    }, [])
    
    // check if empty space on the left
    for (i = 1; i < event.pos; i++) {
      if (vertSlotsTakenLeft.indexOf(i) < 0) {
        event.pos = i
        console.log("moving " + event.description + " left to pos " + i)
        break
      }
    }
  }
})

// fix moved events if they became non-expandable because of moving
events.filter(event=>event.expandable).map(event => {
  let leftFixed = event.overlaps.filter(comp => {
    return event.pos - 1 === comp.pos && comp.maxOverlapsInChunk === calSlots
  })
  let rightFixed = event.overlaps.filter(comp => {
    return event.pos + 1 === comp.pos && comp.maxOverlapsInChunk === calSlots
  })
  event.expandable = (!leftFixed.length || !rightFixed.length)
})

//settings for calendar (positioning events)
let calendar = {width: 300, chunkHeight: 30}

// one more loop through events to calculate top, left, width and height
events.map(event => {
  event.top = time2index(event.startAt) * calendar.chunkHeight
  event.height = time2index(event.endsAt) * calendar.chunkHeight - event.top
  //event.width = 1/event.maxOverlapsInChunk * calendar.width
  event.width = calendar.width/calSlots // TODO: temporary width is 1 slot 
  event.left = (event.pos - 1) * calendar.width/calSlots 
})

console.timeEnd()

// TEST drawing divs
events.map(event => {
  $("body").append(`<div style="position: absolute;
    top: ${event.top}px;
    left: ${event.left}px;
    width: ${event.width}px;
    height: ${event.height}px;
    background-color: ${event.expandable ? "green" : "grey"};
    border: solid black 1px;
    ">${event.description}</div>`)
})

//console.log(events)
<!DOCTYPE html>
<html>
<head>
  <script src="https://code.jquery.com/jquery-3.1.0.js"></script>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
</head>
<body>

</body>
</html>
like image 160
Mirko Vukušić Avatar answered Oct 20 '22 09:10

Mirko Vukušić