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
/// A single task (or milestone) in a Gantt chart.
pub struct GanttTask {
pub label: String,
/// Start time (x-axis value). Equal to `end` for milestones.
pub start: f64,
/// End time (x-axis value). Equal to `start` for milestones.
pub end: f64,
/// Optional group / phase name. Tasks with the same group are placed together.
pub group: Option<String>,
/// Completion fraction in `[0, 1]`. Rendered as a darker fill inside the bar.
pub progress: Option<f64>,
/// Per-task color override.
pub color: Option<String>,
/// When `true`, rendered as a diamond instead of a bar.
pub is_milestone: bool,
}
/// A rendered display row — either a collapsible group header or a task bar.
pub enum GanttDisplayRow {
/// Group header row; drawn with a background band.
GroupHeader(String),
/// Index into [`GanttPlot::tasks`].
Task(usize),
}
/// Builder for a Gantt chart.
///
/// Tasks are grouped by phase (optional). Within each phase, tasks appear in
/// insertion order. Milestones are rendered as diamonds. An optional "now"
/// line marks the current date/time. Progress fills show task completion.
///
/// # Example
///
/// ```rust,no_run
/// use kuva::prelude::*;
///
/// let gantt = GanttPlot::new()
/// .with_task_group("Design", "Wireframes", 0.0, 3.0)
/// .with_task_group("Design", "Prototyping", 2.0, 5.0)
/// .with_task_group("Dev", "Backend API", 3.0, 8.0)
/// .with_task_group_progress("Dev", "Frontend", 4.0, 9.0, 0.4)
/// .with_milestone("Launch", 10.0)
/// .with_now_line(6.0);
///
/// let plots = vec![Plot::from(gantt)];
/// let layout = Layout::auto_from_plots(&plots)
/// .with_title("Project Plan")
/// .with_x_label("Week");
/// ```
pub struct GanttPlot {
pub tasks: Vec<GanttTask>,
/// Explicit group ordering. Groups not listed appear in insertion order after.
pub group_order: Vec<String>,
/// If `Some(v)`, draw a vertical dashed line at x = v.
pub now_line: Option<f64>,
/// Bar height as a fraction of row height. Default `0.6`.
pub bar_height_frac: f64,
/// Diamond half-size in pixels for milestones. Default `7.0`.
pub milestone_size: f64,
/// When `true`, task labels are drawn inside (or beside) bars. Default `true`.
pub show_labels: bool,
/// Minimum bar pixel width to attempt an inside label. Default `40.0`.
pub label_min_width: f64,
/// Default bar color when no group color or per-task color applies. Default `"steelblue"`.
pub color: String,
/// Background band color for group header rows. Default `"#ebebeb"`.
pub group_bg: String,
pub legend_label: Option<String>,
}
impl Default for GanttPlot {
fn default() -> Self {
Self::new()
}
}
impl GanttPlot {
pub fn new() -> Self {
Self {
tasks: vec![],
group_order: vec![],
now_line: None,
bar_height_frac: 0.6,
milestone_size: 7.0,
show_labels: true,
label_min_width: 40.0,
color: "steelblue".into(),
group_bg: "#ebebeb".into(),
legend_label: None,
}
}
/// Add an ungrouped task.
pub fn with_task(
mut self,
label: impl Into<String>,
start: impl Into<f64>,
end: impl Into<f64>,
) -> Self {
self.tasks.push(GanttTask {
label: label.into(),
start: start.into(),
end: end.into(),
group: None,
progress: None,
color: None,
is_milestone: false,
});
self
}
/// Add a task belonging to a named group/phase.
pub fn with_task_group(
mut self,
group: impl Into<String>,
label: impl Into<String>,
start: impl Into<f64>,
end: impl Into<f64>,
) -> Self {
self.tasks.push(GanttTask {
label: label.into(),
start: start.into(),
end: end.into(),
group: Some(group.into()),
progress: None,
color: None,
is_milestone: false,
});
self
}
/// Add an ungrouped task with a progress fill (`0.0`–`1.0`).
pub fn with_task_progress(
mut self,
label: impl Into<String>,
start: impl Into<f64>,
end: impl Into<f64>,
progress: impl Into<f64>,
) -> Self {
self.tasks.push(GanttTask {
label: label.into(),
start: start.into(),
end: end.into(),
group: None,
progress: Some(progress.into().clamp(0.0, 1.0)),
color: None,
is_milestone: false,
});
self
}
/// Add a grouped task with a progress fill.
pub fn with_task_group_progress(
mut self,
group: impl Into<String>,
label: impl Into<String>,
start: impl Into<f64>,
end: impl Into<f64>,
progress: impl Into<f64>,
) -> Self {
self.tasks.push(GanttTask {
label: label.into(),
start: start.into(),
end: end.into(),
group: Some(group.into()),
progress: Some(progress.into().clamp(0.0, 1.0)),
color: None,
is_milestone: false,
});
self
}
/// Add an ungrouped task with a per-task color override.
pub fn with_colored_task(
mut self,
label: impl Into<String>,
start: impl Into<f64>,
end: impl Into<f64>,
color: impl Into<String>,
) -> Self {
self.tasks.push(GanttTask {
label: label.into(),
start: start.into(),
end: end.into(),
group: None,
progress: None,
color: Some(color.into()),
is_milestone: false,
});
self
}
/// Add a milestone (diamond marker) with no group.
pub fn with_milestone(mut self, label: impl Into<String>, at: impl Into<f64>) -> Self {
let at = at.into();
self.tasks.push(GanttTask {
label: label.into(),
start: at,
end: at,
group: None,
progress: None,
color: None,
is_milestone: true,
});
self
}
/// Add a milestone belonging to a named group/phase.
pub fn with_milestone_group(
mut self,
group: impl Into<String>,
label: impl Into<String>,
at: impl Into<f64>,
) -> Self {
let at = at.into();
self.tasks.push(GanttTask {
label: label.into(),
start: at,
end: at,
group: Some(group.into()),
progress: None,
color: None,
is_milestone: true,
});
self
}
/// Set the x-value for the vertical "now" reference line.
pub fn with_now_line(mut self, value: impl Into<f64>) -> Self {
self.now_line = Some(value.into());
self
}
/// Override the display order of groups. Unlisted groups follow in insertion order.
pub fn with_group_order(mut self, groups: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.group_order = groups.into_iter().map(|s| s.into()).collect();
self
}
/// Set bar height as fraction of row height. Default `0.6`.
pub fn with_bar_height(mut self, frac: f64) -> Self {
self.bar_height_frac = frac.clamp(0.1, 1.0);
self
}
/// Set milestone diamond half-size in pixels. Default `7.0`.
pub fn with_milestone_size(mut self, size: f64) -> Self {
self.milestone_size = size;
self
}
/// Show or hide task labels. Default `true`.
pub fn with_show_labels(mut self, show: bool) -> Self {
self.show_labels = show;
self
}
/// Set the default bar color (used when there are no groups). Default `"steelblue"`.
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = color.into();
self
}
/// Set the group header row background color. Default `"#ebebeb"`.
pub fn with_group_bg(mut self, color: impl Into<String>) -> Self {
self.group_bg = color.into();
self
}
/// Attach a legend label (shows a colored rect entry).
pub fn with_legend(mut self, label: impl Into<String>) -> Self {
self.legend_label = Some(label.into());
self
}
/// Returns groups in effective display order.
/// Named groups appear first (in group_order, then insertion order),
/// ungrouped tasks last (represented as `None`).
pub fn effective_group_order(&self) -> Vec<Option<String>> {
let mut ordered: Vec<Option<String>> = vec![];
for g in &self.group_order {
if !ordered.contains(&Some(g.clone())) {
ordered.push(Some(g.clone()));
}
}
for task in &self.tasks {
if let Some(ref g) = task.group {
if !ordered.contains(&Some(g.clone())) {
ordered.push(Some(g.clone()));
}
}
}
if self.tasks.iter().any(|t| t.group.is_none()) {
ordered.push(None);
}
ordered
}
/// Returns display rows in top-to-bottom order.
pub fn ordered_display_rows(&self) -> Vec<GanttDisplayRow> {
let groups = self.effective_group_order();
let has_groups = groups.iter().any(|g| g.is_some());
let mut rows = vec![];
for group_key in &groups {
if has_groups {
if let Some(ref g) = group_key {
rows.push(GanttDisplayRow::GroupHeader(g.clone()));
}
}
for (i, task) in self.tasks.iter().enumerate() {
let belongs = match group_key {
Some(g) => task.group.as_deref() == Some(g.as_str()),
None => task.group.is_none(),
};
if belongs {
rows.push(GanttDisplayRow::Task(i));
}
}
}
rows
}
/// Row labels in top-to-bottom display order (used to build y_categories).
pub fn row_labels(&self) -> Vec<String> {
self.ordered_display_rows()
.into_iter()
.map(|r| match r {
GanttDisplayRow::GroupHeader(g) => g,
GanttDisplayRow::Task(i) => self.tasks[i].label.clone(),
})
.collect()
}
/// Compute x-axis bounds across all tasks and the now line.
pub fn x_bounds(&self) -> Option<(f64, f64)> {
let mut x_min = f64::INFINITY;
let mut x_max = f64::NEG_INFINITY;
for t in &self.tasks {
x_min = x_min.min(t.start);
x_max = x_max.max(t.end);
}
if let Some(now) = self.now_line {
x_min = x_min.min(now);
x_max = x_max.max(now);
}
if x_min.is_finite() {
Some((x_min, x_max))
} else {
None
}
}
}