chartlet 0.19.0

Compile structured data into deterministic, accessible static charts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
use serde::{Deserialize, Serialize};

use super::{ChartSpec, MAX_SERIES, sequence::is_identifier, validate_number, validate_text};
use crate::{
    error::{ChartError, ChartWarning},
    time::{TimeValue, TimeZone},
};

/// How many items and markers a timeline holds.
pub(crate) const MAX_ITEMS: usize = 60;
pub(crate) const MAX_MARKERS: usize = 6;

/// A timeline: phases that run from a start to an end, milestones on a day, lines for days that
/// matter, and which item follows which. Gantt charts, roadmaps, the course of a procedure.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TimelineSpec {
    pub items: Vec<TimelineItemSpec>,
    /// Days drawn as a line across the whole timeline, such as today or a deadline.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub markers: Vec<MarkerSpec>,
    /// Puts the rows of one group together, in the order the groups first appear, instead of in
    /// the order of `items`. Needs groups.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub group_rows: bool,
    /// The zone the days are read in: UTC or a fixed offset such as `+02:00`. Days given as a bare
    /// date are midnight there; Unix seconds are not shifted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timezone: Option<String>,
    /// Shades Saturdays and Sundays behind the rows, while the axis spans at most 120 days.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub weekends: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TimelineItemSpec {
    /// Names the item for `after`; needed only by an item another one follows.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub label: String,
    /// A phase runs from `start` to `end`, a milestone happens `at` a day: one or the other.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub start: Option<TimeValue>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub end: Option<TimeValue>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub at: Option<TimeValue>,
    /// Items of one group share a color and an entry in the legend: either every item names a
    /// group or none does, and there are at most four.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    /// The `id`s of the items this one follows: an arrow runs from each of them to it.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub after: Vec<String>,
    /// How much of a phase is done, from 0 to 1: that share of its bar is drawn darker.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub progress: Option<f64>,
    /// The `id`s of the phases this one sums up: its progress is that of those phases, each
    /// weighted by its length. Instead of `progress`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub rollup: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MarkerSpec {
    pub label: String,
    pub at: TimeValue,
}

/// An item with its days resolved to Unix seconds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Span {
    Phase { start: i64, end: i64 },
    Milestone { at: i64 },
}

impl Span {
    pub(crate) const fn begin(self) -> i64 {
        match self {
            Self::Phase { start, .. } => start,
            Self::Milestone { at } => at,
        }
    }

    pub(crate) const fn finish(self) -> i64 {
        match self {
            Self::Phase { end, .. } => end,
            Self::Milestone { at } => at,
        }
    }
}

/// The zone of a timeline that names none.
pub(crate) const fn zone() -> TimeZone {
    TimeZone::utc()
}

impl TimelineItemSpec {
    /// The days of the item; only on a validated timeline.
    pub(crate) fn span(&self, zone: TimeZone) -> Span {
        let resolve = |value: &Option<TimeValue>| {
            value
                .as_ref()
                .expect("validated")
                .resolve_wall(zone)
                .expect("validated")
        };
        match (&self.start, &self.end) {
            (Some(_), Some(_)) => Span::Phase {
                start: resolve(&self.start),
                end: resolve(&self.end),
            },
            _ => Span::Milestone {
                at: resolve(&self.at),
            },
        }
    }
}

impl TimelineSpec {
    /// The zone the days are read in; UTC unless `timezone` names another, which validation
    /// has checked.
    pub(crate) fn zone(&self) -> TimeZone {
        self.timezone
            .as_deref()
            .and_then(TimeZone::parse_named)
            .unwrap_or_else(zone)
    }

    pub(crate) fn item(&self, id: &str) -> Option<usize> {
        self.items
            .iter()
            .position(|item| item.id.as_deref() == Some(id))
    }

    /// The row of every item: the order of `items`, or with `group_rows` the items of one group
    /// together, in the order the groups first appear.
    pub(crate) fn rows(&self) -> Vec<usize> {
        let mut order: Vec<usize> = (0..self.items.len()).collect();
        if self.group_rows {
            let groups = self.groups();
            let rank = |item: &TimelineItemSpec| {
                item.group
                    .as_deref()
                    .and_then(|group| groups.iter().position(|known| *known == group))
                    .unwrap_or(0)
            };
            crate::sort::by(&mut order, |a, b| {
                rank(&self.items[*a]).cmp(&rank(&self.items[*b]))
            });
        }
        let mut rows = vec![0; order.len()];
        for (row, item) in order.into_iter().enumerate() {
            rows[item] = row;
        }
        rows
    }

    /// How much of item `index` is done: its own `progress`, or for a summary phase the progress
    /// of the phases it sums up, weighted by their lengths (a phase without progress counts as
    /// not begun); `None` for an item that has neither.
    pub(crate) fn progress(&self, index: usize) -> Option<f64> {
        let item = &self.items[index];
        if item.rollup.is_empty() {
            return item.progress;
        }
        let zone = self.zone();
        let (mut done, mut length) = (0.0, 0.0);
        for id in &item.rollup {
            let part = &self.items[self.item(id)?];
            let Span::Phase { start, end } = part.span(zone) else {
                continue;
            };
            #[allow(clippy::cast_precision_loss)]
            let seconds = (end - start).max(1) as f64;
            done += seconds * part.progress.unwrap_or(0.0);
            length += seconds;
        }
        (length > 0.0).then(|| done / length)
    }

    /// The groups in the order they first appear.
    pub(crate) fn groups(&self) -> Vec<&str> {
        let mut groups: Vec<&str> = Vec::new();
        for group in self.items.iter().filter_map(|item| item.group.as_deref()) {
            if !groups.contains(&group) {
                groups.push(group);
            }
        }
        groups
    }
}

impl ChartSpec {
    /// Items that are a phase or a milestone, with unique ids, groups all or none, and arrows
    /// between existing items that run in no circle.
    pub(super) fn validate_timeline(&self) -> Result<Vec<ChartWarning>, ChartError> {
        self.reject_map_options("a timeline", "timeline.items")?;
        let Some(timeline) = &self.timeline else {
            return Err(ChartError::new(
                "missing_timeline",
                "/timeline",
                "a timeline requires a timeline block",
            ));
        };
        if timeline
            .timezone
            .as_deref()
            .is_some_and(|zone| TimeZone::parse_named(zone).is_none())
        {
            return Err(ChartError::new(
                "invalid_timezone",
                "/timeline/timezone",
                "expected UTC, a fixed offset such as +02:00, or a zone name such as Europe/Berlin",
            ));
        }
        if !self.time_axis.is_default() {
            return Err(ChartError::new(
                "option_not_supported",
                "/timeAxis",
                "a timeline reads its days from its items; remove this field",
            ));
        }
        if timeline.items.is_empty() {
            return Err(ChartError::new(
                "empty_data",
                "/timeline/items",
                "provide at least one item",
            ));
        }
        if timeline.items.len() > MAX_ITEMS {
            return Err(ChartError::new(
                "too_many_nodes",
                "/timeline/items",
                format!("at most {MAX_ITEMS} items are supported"),
            ));
        }
        let mut warnings = Vec::new();
        timeline.validate_items(&mut warnings)?;
        timeline.validate_markers()?;
        timeline.validate_follows(&mut warnings)?;
        Ok(warnings)
    }
}

impl TimelineSpec {
    fn validate_items(&self, warnings: &mut Vec<ChartWarning>) -> Result<(), ChartError> {
        let grouped = self.items.iter().any(|item| item.group.is_some());
        if self.group_rows && !grouped {
            return Err(ChartError::new(
                "option_not_supported",
                "/timeline/groupRows",
                "groupRows puts the rows of a group together; give the items groups",
            ));
        }
        let mut groups: Vec<&str> = Vec::new();
        for (index, item) in self.items.iter().enumerate() {
            let path = format!("/timeline/items/{index}");
            validate_text(&item.label, &format!("{path}/label"), 100)?;
            if let Some(id) = &item.id {
                if !is_identifier(id) {
                    return Err(ChartError::new(
                        "invalid_id",
                        format!("{path}/id"),
                        "use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
                    ));
                }
                if self.items[..index]
                    .iter()
                    .any(|other| other.id.as_deref() == Some(id))
                {
                    return Err(ChartError::new(
                        "duplicate_id",
                        format!("{path}/id"),
                        format!("the id \"{id}\" is already taken by an earlier item"),
                    ));
                }
            }
            let day = |value: &Option<TimeValue>, name: &str| -> Result<Option<i64>, ChartError> {
                value
                    .as_ref()
                    .map(|value| {
                        value.resolve_wall(self.zone()).map_err(|message| {
                            ChartError::new("invalid_time", format!("{path}/{name}"), message)
                        })
                    })
                    .transpose()
            };
            let (start, end, at) = (
                day(&item.start, "start")?,
                day(&item.end, "end")?,
                day(&item.at, "at")?,
            );
            match (start, end, at) {
                (Some(start), Some(end), None) => {
                    if end < start {
                        return Err(ChartError::new(
                            "invalid_range",
                            format!("{path}/end"),
                            "end must not be before start",
                        ));
                    }
                }
                (None, None, Some(_)) => {}
                _ => {
                    return Err(ChartError::new(
                        "invalid_item",
                        path,
                        "give a phase both start and end, or a milestone at; not a mix",
                    ));
                }
            }
            self.validate_progress_of(index, &path, at.is_some())?;
            match (&item.group, grouped) {
                (Some(group), _) => {
                    validate_text(group, &format!("{path}/group"), 60)?;
                    if !groups.contains(&group.as_str()) {
                        groups.push(group);
                    }
                    if groups.len() > MAX_SERIES {
                        return Err(ChartError::new(
                            "too_many_series",
                            format!("{path}/group"),
                            format!(
                                "at most {MAX_SERIES} groups are supported, one per palette color"
                            ),
                        ));
                    }
                }
                (None, true) => {
                    return Err(ChartError::new(
                        "missing_group",
                        format!("{path}/group"),
                        "give every item a group, or none",
                    ));
                }
                (None, false) => {}
            }
        }
        if self.items.len() > 30 {
            warnings.push(ChartWarning::new(
                "dense_chart",
                "/timeline/items",
                "more than 30 items need a tall canvas to be read",
            ));
        }
        Ok(())
    }

    fn validate_markers(&self) -> Result<(), ChartError> {
        if self.markers.len() > MAX_MARKERS {
            return Err(ChartError::new(
                "too_many_markers",
                "/timeline/markers",
                format!("at most {MAX_MARKERS} markers are supported"),
            ));
        }
        for (index, marker) in self.markers.iter().enumerate() {
            let path = format!("/timeline/markers/{index}");
            validate_text(&marker.label, &format!("{path}/label"), 60)?;
            marker.at.resolve_wall(self.zone()).map_err(|message| {
                ChartError::new("invalid_time", format!("{path}/at"), message)
            })?;
        }
        Ok(())
    }

    /// Every `after` names another item, and following runs in no circle. An item that starts
    /// before the one it follows ends is a warning: its arrow runs backwards.
    fn validate_follows(&self, warnings: &mut Vec<ChartWarning>) -> Result<(), ChartError> {
        for (index, item) in self.items.iter().enumerate() {
            for (at, id) in item.after.iter().enumerate() {
                let path = format!("/timeline/items/{index}/after/{at}");
                let Some(before) = self.item(id) else {
                    return Err(ChartError::new(
                        "unknown_node",
                        path,
                        format!("no item has the id \"{id}\""),
                    ));
                };
                if before == index {
                    return Err(ChartError::new(
                        "circular_dependency",
                        path,
                        "an item cannot follow itself",
                    ));
                }
                if item.span(self.zone()).begin() < self.items[before].span(self.zone()).finish() {
                    warnings.push(ChartWarning::new(
                        "follows_overlap",
                        path,
                        format!("this item starts before \"{id}\" ends; the arrow runs backwards"),
                    ));
                }
            }
        }
        // A circle of items that follow each other: walk the arrows from every item.
        for start in 0..self.items.len() {
            let mut seen = vec![false; self.items.len()];
            let mut stack = vec![start];
            while let Some(at) = stack.pop() {
                for id in &self.items[at].after {
                    let next = self.item(id).expect("checked above");
                    if next == start {
                        return Err(ChartError::new(
                            "circular_dependency",
                            format!("/timeline/items/{start}/after"),
                            "these items follow each other in a circle",
                        ));
                    }
                    if !seen[next] {
                        seen[next] = true;
                        stack.push(next);
                    }
                }
            }
        }
        Ok(())
    }
}

/// Progress from 0 to 1, on a phase only.
fn validate_progress(
    item: &TimelineItemSpec,
    path: &str,
    milestone: bool,
) -> Result<(), ChartError> {
    let Some(progress) = item.progress else {
        return Ok(());
    };
    validate_number(progress, &format!("{path}/progress"))?;
    if !(0.0..=1.0).contains(&progress) {
        return Err(ChartError::new(
            "invalid_value",
            format!("{path}/progress"),
            "progress runs from 0 to 1",
        ));
    }
    if milestone {
        return Err(ChartError::new(
            "progress_on_milestone",
            format!("{path}/progress"),
            "only a phase has progress",
        ));
    }
    Ok(())
}

impl TimelineSpec {
    /// The progress of item `index`, or what it sums up, on a phase only.
    fn validate_progress_of(
        &self,
        index: usize,
        path: &str,
        milestone: bool,
    ) -> Result<(), ChartError> {
        validate_progress(&self.items[index], path, milestone)?;
        self.validate_rollup(index, path)
    }

    /// A summary phase names phases, not milestones or other summaries, and has no progress of
    /// its own.
    fn validate_rollup(&self, index: usize, path: &str) -> Result<(), ChartError> {
        let item = &self.items[index];
        if item.rollup.is_empty() {
            return Ok(());
        }
        if item.progress.is_some() {
            return Err(ChartError::new(
                "conflicting_progress",
                format!("{path}/rollup"),
                "give a phase either progress or rollup, not both",
            ));
        }
        if item.at.is_some() {
            return Err(ChartError::new(
                "progress_on_milestone",
                format!("{path}/rollup"),
                "only a phase can sum up phases",
            ));
        }
        for (at, id) in item.rollup.iter().enumerate() {
            let Some(part) = self.item(id) else {
                return Err(ChartError::new(
                    "unknown_node",
                    format!("{path}/rollup/{at}"),
                    format!("no item has the id \"{id}\""),
                ));
            };
            if part == index || self.items[part].at.is_some() || !self.items[part].rollup.is_empty()
            {
                return Err(ChartError::new(
                    "invalid_rollup",
                    format!("{path}/rollup/{at}"),
                    "a summary names phases that do not sum up others, not itself or a milestone",
                ));
            }
        }
        Ok(())
    }
}