1use crate::date::Date;
4
5const MINUTES_PER_DAY: i64 = 24 * 60;
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
9#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
10pub struct TimePoint(i64);
11
12#[cfg_attr(feature = "live", ravenrs_live::expose)]
13impl TimePoint {
14 pub const fn from_minutes(minutes: i64) -> Self {
16 Self(minutes)
17 }
18
19 pub fn from_date(date: Date) -> Self {
21 Self(date.to_days() * MINUTES_PER_DAY)
22 }
23
24 pub fn at(date: Date, hour: u32, minute: u32) -> Self {
26 Self(date.to_days() * MINUTES_PER_DAY + i64::from(hour) * 60 + i64::from(minute))
27 }
28
29 pub const fn minutes(self) -> i64 {
31 self.0
32 }
33
34 pub fn date(self) -> Date {
36 Date::from_days(self.0.div_euclid(MINUTES_PER_DAY))
37 }
38
39 pub fn hour_minute(self) -> (u32, u32) {
41 let minutes = self.0.rem_euclid(MINUTES_PER_DAY);
42 ((minutes / 60) as u32, (minutes % 60) as u32)
43 }
44
45 pub const fn plus_minutes(self, minutes: i64) -> Self {
47 Self(self.0 + minutes)
48 }
49
50 pub const fn plus_days(self, days: i64) -> Self {
52 Self(self.0 + days * MINUTES_PER_DAY)
53 }
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
60pub enum TimeScale {
61 Hours,
63 #[default]
65 Days,
66 Weeks,
68 Months,
70}
71
72const ZOOM_LEVELS: [(TimeScale, f32); 8] = [
74 (TimeScale::Months, 56.0),
75 (TimeScale::Months, 110.0),
76 (TimeScale::Weeks, 60.0),
77 (TimeScale::Weeks, 110.0),
78 (TimeScale::Days, 40.0),
79 (TimeScale::Days, 80.0),
80 (TimeScale::Hours, 36.0),
81 (TimeScale::Hours, 72.0),
82];
83
84#[derive(Clone, Copy, Debug, PartialEq)]
86#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
87pub struct TimeAxis {
88 pub start: TimePoint,
90 pub end: TimePoint,
92 pub scale: TimeScale,
94 pub tick_width: f32,
96}
97
98#[derive(Clone, Debug, PartialEq)]
100pub(crate) struct Tick {
101 pub x: f32,
102 pub width: f32,
103 pub start: TimePoint,
104}
105
106#[cfg_attr(feature = "live", ravenrs_live::expose)]
107impl TimeAxis {
108 pub fn days(from: Date, count: u32) -> Self {
110 let start = TimePoint::from_date(from);
111 Self {
112 start,
113 end: start.plus_days(i64::from(count.max(1))),
114 scale: TimeScale::Days,
115 tick_width: 40.0,
116 }
117 }
118
119 pub fn weeks(from: Date, count: u32) -> Self {
121 let start = TimePoint::from_date(from.add_days(-i32::from(from.weekday())));
122 let end = start.plus_days(7 * i64::from(count.max(1)));
123 Self { start, end, scale: TimeScale::Weeks, tick_width: 60.0 }
124 }
125
126 pub fn months(from: Date, count: u32) -> Self {
128 let first = from.first_of_month();
129 let last = first.add_months(i32::try_from(count.max(1)).unwrap_or(1));
130 Self {
131 start: TimePoint::from_date(first),
132 end: TimePoint::from_date(last),
133 scale: TimeScale::Months,
134 tick_width: 56.0,
135 }
136 }
137
138 pub fn hours(day: Date) -> Self {
140 let start = TimePoint::from_date(day);
141 Self { start, end: start.plus_days(1), scale: TimeScale::Hours, tick_width: 36.0 }
142 }
143
144 pub fn zoomed_in(&self) -> Option<Self> {
146 self.zoomed(1)
147 }
148
149 pub fn zoomed_out(&self) -> Option<Self> {
151 self.zoomed(-1)
152 }
153
154 fn zoomed(&self, step: i32) -> Option<Self> {
155 let here = ZOOM_LEVELS
156 .iter()
157 .rposition(|(scale, width)| *scale == self.scale && *width <= self.tick_width)
158 .or_else(|| ZOOM_LEVELS.iter().position(|(scale, _)| *scale == self.scale))?;
159 let there = i32::try_from(here).ok()? + step;
160 let (scale, tick_width) = *ZOOM_LEVELS.get(usize::try_from(there).ok()?)?;
161 Some(Self { scale, tick_width, ..*self })
162 }
163
164 pub fn shifted(&self, steps: i64) -> Self {
166 let length = self.end.minutes() - self.start.minutes();
167 match self.scale {
168 TimeScale::Months => {
169 let months = (length as f64 / (30.44 * MINUTES_PER_DAY as f64)).round() as i32;
170 let by = i32::try_from(steps).unwrap_or(0) * months.max(1);
171 Self {
172 start: TimePoint::from_date(self.start.date().add_months(by)),
173 end: TimePoint::from_date(self.end.date().add_months(by)),
174 ..*self
175 }
176 }
177 _ => Self {
178 start: self.start.plus_minutes(length * steps),
179 end: self.end.plus_minutes(length * steps),
180 ..*self
181 },
182 }
183 }
184
185 fn pixels_per_minute(&self) -> f64 {
186 let tick_minutes = match self.scale {
187 TimeScale::Hours => 60.0,
188 TimeScale::Days => MINUTES_PER_DAY as f64,
189 TimeScale::Weeks => 7.0 * MINUTES_PER_DAY as f64,
190 TimeScale::Months => 30.0 * MINUTES_PER_DAY as f64,
191 };
192 f64::from(self.tick_width.max(1.0)) / tick_minutes
193 }
194
195 pub fn width(&self) -> f32 {
197 self.x_of(self.end).max(0.0)
198 }
199
200 pub fn x_of(&self, time: TimePoint) -> f32 {
202 ((time.minutes() - self.start.minutes()) as f64 * self.pixels_per_minute()) as f32
203 }
204
205 pub fn time_at(&self, x: f32) -> TimePoint {
207 let minutes = (f64::from(x) / self.pixels_per_minute()).round() as i64;
208 self.start.plus_minutes(minutes)
209 }
210
211 pub fn snap_minutes(&self) -> i64 {
214 match self.scale {
215 TimeScale::Hours => 15,
216 _ => MINUTES_PER_DAY,
217 }
218 }
219
220 pub fn snap(&self, time: TimePoint) -> TimePoint {
222 let step = self.snap_minutes();
223 TimePoint::from_minutes((time.minutes() + step / 2).div_euclid(step) * step)
224 }
225
226 fn next_tick(&self, tick: TimePoint) -> TimePoint {
227 match self.scale {
228 TimeScale::Hours => tick.plus_minutes(60),
229 TimeScale::Days => tick.plus_days(1),
230 TimeScale::Weeks => tick.plus_days(7),
231 TimeScale::Months => TimePoint::from_date(tick.date().first_of_month().add_months(1)),
232 }
233 }
234
235 pub(crate) fn ticks(&self, from: f32, to: f32) -> Vec<Tick> {
237 let mut ticks = Vec::new();
238 let mut tick = self.start;
239 while tick < self.end {
241 let next = self.next_tick(tick).min(self.end);
242 let (x, end) = (self.x_of(tick), self.x_of(next));
243 if end > from && x < to {
244 ticks.push(Tick { x, width: end - x, start: tick });
245 }
246 if x >= to {
247 break;
248 }
249 tick = next;
250 }
251 ticks
252 }
253
254 pub(crate) fn starts_group(&self, tick: TimePoint, previous: Option<TimePoint>) -> bool {
256 let Some(previous) = previous else { return true };
257 let (now, then) = (tick.date(), previous.date());
258 match self.scale {
259 TimeScale::Hours => now != then,
260 TimeScale::Days | TimeScale::Weeks => now.month() != then.month() || now.year() != then.year(),
261 TimeScale::Months => now.year() != then.year(),
262 }
263 }
264}
265
266pub(crate) fn assign_lanes(spans: &[(TimePoint, TimePoint)]) -> (Vec<usize>, usize) {
270 let mut order: Vec<usize> = (0..spans.len()).collect();
271 order.sort_by_key(|i| (spans[*i].0, spans[*i].1));
272 let mut lanes = vec![0; spans.len()];
273 let mut ends: Vec<TimePoint> = Vec::new();
274 for i in order {
275 let (start, end) = spans[i];
276 let end = end.max(start.plus_minutes(1));
278 match ends.iter().position(|free_at| *free_at <= start) {
279 Some(lane) => {
280 ends[lane] = end;
281 lanes[i] = lane;
282 }
283 None => {
284 lanes[i] = ends.len();
285 ends.push(end);
286 }
287 }
288 }
289 (lanes, ends.len().max(1))
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 fn date(y: i32, m: u8, d: u8) -> Date {
297 Date::new(y, m, d).unwrap_or_else(|| panic!("invalid date"))
298 }
299
300 fn at(d: u8, h: u32) -> TimePoint {
301 TimePoint::at(date(2026, 11, d), h, 0)
302 }
303
304 #[test]
305 fn a_moment_knows_its_day_and_hour() {
306 let time = TimePoint::at(date(1969, 12, 31), 23, 30);
307 assert_eq!(time.date(), date(1969, 12, 31), "before 1970 too");
308 assert_eq!(time.hour_minute(), (23, 30));
309 }
310
311 #[test]
312 fn positions_and_moments_are_inverses() {
313 let axis = TimeAxis::days(date(2026, 11, 1), 30);
314 assert_eq!(axis.width(), 30.0 * 40.0);
315 assert_eq!(axis.x_of(at(2, 0)), 40.0);
316 assert_eq!(axis.x_of(at(2, 12)), 60.0, "time runs evenly through a day");
317 assert_eq!(axis.time_at(60.0), at(2, 12));
318 }
319
320 #[test]
321 fn events_snap_to_days_and_to_quarters_of_an_hour() {
322 let days = TimeAxis::days(date(2026, 11, 1), 30);
323 assert_eq!(days.snap(at(3, 11)), at(3, 0));
324 assert_eq!(days.snap(at(3, 13)), at(4, 0));
325 let hours = TimeAxis::hours(date(2026, 11, 3));
326 assert_eq!(
327 hours.snap(TimePoint::at(date(2026, 11, 3), 9, 7)),
328 TimePoint::at(date(2026, 11, 3), 9, 0)
329 );
330 assert_eq!(
331 hours.snap(TimePoint::at(date(2026, 11, 3), 9, 8)),
332 TimePoint::at(date(2026, 11, 3), 9, 15)
333 );
334 }
335
336 #[test]
337 fn months_have_their_own_lengths() {
338 let axis = TimeAxis::months(date(2026, 2, 15), 2);
339 let ticks = axis.ticks(0.0, axis.width());
340 assert_eq!(ticks.len(), 2);
341 assert_eq!(ticks[0].start.date(), date(2026, 2, 1));
342 assert_eq!(ticks[1].start.date(), date(2026, 3, 1));
343 assert!(ticks[0].width < ticks[1].width, "February is shorter than March");
344 }
345
346 #[test]
347 fn only_the_ticks_in_view_are_made() {
348 let axis = TimeAxis::days(date(2026, 1, 1), 365);
349 let ticks = axis.ticks(400.0, 500.0);
350 assert_eq!(ticks.len(), 3, "from the 11th to the 13th");
351 assert_eq!(ticks[0].x, 400.0);
352 }
353
354 #[test]
355 fn weeks_start_on_sunday() {
356 let axis = TimeAxis::weeks(date(2026, 11, 4), 2);
358 assert_eq!(axis.start.date(), date(2026, 11, 1));
359 assert_eq!(axis.end.date(), date(2026, 11, 15));
360 }
361
362 #[test]
363 fn zoom_walks_through_the_levels_and_stops_at_the_ends() {
364 let mut axis = TimeAxis::months(date(2026, 1, 1), 12);
365 let range = (axis.start, axis.end);
366 let mut steps = 0;
367 while let Some(nearer) = axis.zoomed_in() {
368 assert!(nearer.width() > axis.width(), "nearer is always wider");
369 assert_eq!((nearer.start, nearer.end), range, "over the same time");
370 axis = nearer;
371 steps += 1;
372 }
373 assert_eq!(steps, ZOOM_LEVELS.len() - 1);
374 assert_eq!(axis.scale, TimeScale::Hours);
375 while let Some(farther) = axis.zoomed_out() {
376 axis = farther;
377 }
378 assert_eq!(axis.scale, TimeScale::Months);
379 }
380
381 #[test]
382 fn an_axis_moves_by_its_own_length() {
383 let days = TimeAxis::days(date(2026, 11, 1), 7).shifted(1);
384 assert_eq!(days.start.date(), date(2026, 11, 8));
385 let months = TimeAxis::months(date(2026, 11, 1), 3).shifted(-1);
386 assert_eq!(months.start.date(), date(2026, 8, 1));
387 assert_eq!(months.end.date(), date(2026, 11, 1));
388 }
389
390 #[test]
391 fn overlapping_events_take_separate_lanes() {
392 let spans = [(at(1, 0), at(3, 0)), (at(2, 0), at(4, 0)), (at(3, 0), at(5, 0)), (at(1, 0), at(6, 0))];
393 let (lanes, count) = assign_lanes(&spans);
394 assert_eq!(count, 3);
395 assert_ne!(lanes[0], lanes[1]);
396 assert_ne!(lanes[0], lanes[3]);
397 assert_eq!(lanes[2], lanes[0], "the third starts as the first ends, so it can reuse its lane");
398 }
399
400 #[test]
401 fn a_row_with_nothing_still_has_a_lane() {
402 assert_eq!(assign_lanes(&[]), (vec![], 1));
403 }
404
405 #[test]
406 fn moments_with_no_length_do_not_land_on_each_other() {
407 let (lanes, count) = assign_lanes(&[(at(1, 0), at(1, 0)), (at(1, 0), at(1, 0))]);
408 assert_eq!(count, 2);
409 assert_ne!(lanes[0], lanes[1]);
410 }
411}