use serde::{Deserialize, Serialize};
use super::{ChartSpec, MAX_SERIES, sequence::is_identifier, validate_number, validate_text};
use crate::{
error::{ChartError, ChartWarning},
time::{TimeValue, TimeZone},
};
pub(crate) const MAX_ITEMS: usize = 60;
pub(crate) const MAX_MARKERS: usize = 6;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TimelineSpec {
pub items: Vec<TimelineItemSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub markers: Vec<MarkerSpec>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub group_rows: bool,
#[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 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub label: String,
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub after: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub progress: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MarkerSpec {
pub label: String,
pub at: TimeValue,
}
#[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,
}
}
}
pub(crate) const fn zone() -> TimeZone {
TimeZone::utc()
}
impl TimelineItemSpec {
pub(crate) fn span(&self) -> Span {
let resolve = |value: &Option<TimeValue>| {
value
.as_ref()
.expect("validated")
.resolve(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 {
pub(crate) fn item(&self, id: &str) -> Option<usize> {
self.items
.iter()
.position(|item| item.id.as_deref() == Some(id))
}
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
}
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 {
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 !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(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",
));
}
}
validate_progress(item, &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(zone()).map_err(|message| {
ChartError::new("invalid_time", format!("{path}/at"), message)
})?;
}
Ok(())
}
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().begin() < self.items[before].span().finish() {
warnings.push(ChartWarning::new(
"follows_overlap",
path,
format!("this item starts before \"{id}\" ends; the arrow runs backwards"),
));
}
}
}
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(())
}
}
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(())
}