| 1 | package calendar |
| 2 | |
| 3 | // Packing overlapping events into columns is normally a job for |
| 4 | // JavaScript measuring boxes in the browser. It is done here, on the |
| 5 | // server, for the reason every affordance in this app is: the scriptless |
| 6 | // page is the product, not the fallback. |
| 7 | // |
| 8 | // The arithmetic is percentages, emitted as inline style on each chip. |
| 9 | // rastrillo's Content-Security-Policy allows inline styles — its own |
| 10 | // partials carry them — so this needs no exception and no stylesheet |
| 11 | // generated per request. |
| 12 | |
| 13 | // Placed is one event positioned inside a day column. |
| 14 | type Placed struct { |
| 15 | Event EventView |
| 16 | |
| 17 | // TopPct and HeightPct locate the chip in the day, 0-100. |
| 18 | TopPct float64 |
| 19 | HeightPct float64 |
| 20 | |
| 21 | // LeftPct and WidthPct split the column between events that |
| 22 | // overlap. A lone event is 0 and 100. |
| 23 | LeftPct float64 |
| 24 | WidthPct float64 |
| 25 | } |
| 26 | |
| 27 | // PackDay lays out one day's timed events between dayStart and dayEnd |
| 28 | // (UTC seconds, half-open). |
| 29 | // |
| 30 | // Events are grouped into CLUSTERS — runs joined transitively by |
| 31 | // overlap — and lanes are assigned per cluster rather than per day. The |
| 32 | // difference matters: two meetings at 09:00 and a third at 16:00 should |
| 33 | // leave the third full width, not a third of it, and a day-wide lane |
| 34 | // count would squeeze every chip to the width of the worst hour. |
| 35 | // |
| 36 | // Within a cluster a chip takes the first lane whose previous event has |
| 37 | // already ended. That is the greedy interval-graph colouring, and for |
| 38 | // this shape it uses the minimum number of lanes. |
| 39 | // |
| 40 | // All-day events are not packed: they belong in the day's header strip, |
| 41 | // not in the timed grid, and giving them a height would make a birthday |
| 42 | // look like a 24-hour meeting. |
| 43 | func PackDay(events []EventView, dayStart, dayEnd int64) []Placed { |
| 44 | span := float64(dayEnd - dayStart) |
| 45 | if span <= 0 { |
| 46 | return nil |
| 47 | } |
| 48 | |
| 49 | timed := make([]EventView, 0, len(events)) |
| 50 | for _, e := range events { |
| 51 | if e.AllDay { |
| 52 | continue |
| 53 | } |
| 54 | timed = append(timed, e) |
| 55 | } |
| 56 | if len(timed) == 0 { |
| 57 | return nil |
| 58 | } |
| 59 | sortOccurrences(timed) |
| 60 | |
| 61 | out := make([]Placed, 0, len(timed)) |
| 62 | for _, cluster := range clusters(timed) { |
| 63 | // laneEnd[i] is when the last event in lane i finishes. |
| 64 | var laneEnd []int64 |
| 65 | lane := make([]int, len(cluster)) |
| 66 | for i, e := range cluster { |
| 67 | start := clamp(e.OccStartUTC, dayStart, dayEnd) |
| 68 | placedInLane := -1 |
| 69 | for l, end := range laneEnd { |
| 70 | if end <= start { |
| 71 | laneEnd[l] = clamp(e.OccEndUTC, dayStart, dayEnd) |
| 72 | placedInLane = l |
| 73 | break |
| 74 | } |
| 75 | } |
| 76 | if placedInLane < 0 { |
| 77 | laneEnd = append(laneEnd, clamp(e.OccEndUTC, dayStart, dayEnd)) |
| 78 | placedInLane = len(laneEnd) - 1 |
| 79 | } |
| 80 | lane[i] = placedInLane |
| 81 | } |
| 82 | |
| 83 | lanes := float64(len(laneEnd)) |
| 84 | for i, e := range cluster { |
| 85 | start := clamp(e.OccStartUTC, dayStart, dayEnd) |
| 86 | end := clamp(e.OccEndUTC, dayStart, dayEnd) |
| 87 | height := float64(end-start) / span * 100 |
| 88 | |
| 89 | // A zero-length event is a real thing somebody typed, and a |
| 90 | // chip with no height cannot be clicked or read. Give it |
| 91 | // the smallest height that still shows its title. |
| 92 | if height < minChipPct { |
| 93 | height = minChipPct |
| 94 | } |
| 95 | top := float64(start-dayStart) / span * 100 |
| 96 | if top+height > 100 { |
| 97 | top = 100 - height |
| 98 | } |
| 99 | |
| 100 | out = append(out, Placed{ |
| 101 | Event: e, |
| 102 | TopPct: round2(top), |
| 103 | HeightPct: round2(height), |
| 104 | LeftPct: round2(float64(lane[i]) / lanes * 100), |
| 105 | WidthPct: round2(100 / lanes), |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | return out |
| 110 | } |
| 111 | |
| 112 | // minChipPct is about twenty minutes of a day — enough vertical room |
| 113 | // for one line of text at the grid's height. |
| 114 | const minChipPct = 1.4 |
| 115 | |
| 116 | // clusters groups events joined transitively by overlap. Input must be |
| 117 | // sorted by start. |
| 118 | func clusters(sorted []EventView) [][]EventView { |
| 119 | var out [][]EventView |
| 120 | var cur []EventView |
| 121 | var curEnd int64 |
| 122 | |
| 123 | for _, e := range sorted { |
| 124 | if len(cur) == 0 || e.OccStartUTC < curEnd { |
| 125 | cur = append(cur, e) |
| 126 | if e.OccEndUTC > curEnd { |
| 127 | curEnd = e.OccEndUTC |
| 128 | } |
| 129 | continue |
| 130 | } |
| 131 | out = append(out, cur) |
| 132 | cur = []EventView{e} |
| 133 | curEnd = e.OccEndUTC |
| 134 | } |
| 135 | if len(cur) > 0 { |
| 136 | out = append(out, cur) |
| 137 | } |
| 138 | return out |
| 139 | } |
| 140 | |
| 141 | func clamp(v, lo, hi int64) int64 { |
| 142 | if v < lo { |
| 143 | return lo |
| 144 | } |
| 145 | if v > hi { |
| 146 | return hi |
| 147 | } |
| 148 | return v |
| 149 | } |
| 150 | |
| 151 | // round2 keeps the emitted style attribute short. Sub-percent precision |
| 152 | // is below one pixel on any real column width, and full float precision |
| 153 | // would put seventeen digits in the HTML. |
| 154 | func round2(f float64) float64 { |
| 155 | return float64(int(f*100+0.5)) / 100 |
| 156 | } |
| 157 | |