oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package 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.
14type 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.
43func 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.
114const minChipPct = 1.4
115
116// clusters groups events joined transitively by overlap. Input must be
117// sorted by start.
118func 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
141func 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.
154func round2(f float64) float64 {
155 return float64(int(f*100+0.5)) / 100
156}
157