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
use super::WorkItemMovement;
use crate::font::{load_system_font_bytes, measure_text_height, measure_text_width};
use svg::node::element::{Circle, Group, Line, Path, Rectangle, Style, Text};
use svg::Document;
pub fn render_work_item_movement_svg(
chart: &WorkItemMovement,
default_width: u32,
font_name: &str,
) -> (Document, u32, u32) {
// Use config width if present, otherwise use default
let width = chart
.config
.as_ref()
.and_then(|c| c.width)
.unwrap_or(default_width);
let font_data = load_system_font_bytes(font_name);
// Layout constants
let margin = 20.0;
let title_font_size = 20.0;
let column_font_size = 16.0;
let item_font_size = 14.0;
let column_height = 40.0;
let item_height = 50.0;
let circle_radius = 15.0;
let arrow_size = 12.0;
let vertical_label_offset = 5.0; // Distance from line to start of text for vertical arrows
// Calculate title height
let (title_height, title_gap) = if chart.title.is_some() {
let text_height = if let Some(ref font_data) = font_data {
measure_text_height(font_data, title_font_size) as f64
} else {
title_font_size as f64
};
(text_height, 20.0)
} else {
(0.0, 0.0)
};
// Measure column text widths to calculate proper positioning
let column_widths: Vec<f64> = chart
.columns
.iter()
.map(|col| {
if let Some(ref font_data) = font_data {
measure_text_width(col, font_data, column_font_size) as f64
} else {
col.len() as f64 * 8.0
}
})
.collect();
// Calculate column positions based on vertical line placement
let num_columns = chart.columns.len();
let column_positions: Vec<f64> = if num_columns == 1 {
vec![width as f64 / 2.0]
} else {
// Calculate the exact positions for first and last lines
let first_line_pos = margin + column_widths[0] / 2.0;
let last_line_pos = width as f64 - margin - column_widths[num_columns - 1] / 2.0;
// Create positions array with first and last fixed, middle distributed evenly
let mut positions = vec![0.0; num_columns];
positions[0] = first_line_pos;
positions[num_columns - 1] = last_line_pos;
// Distribute middle positions evenly between first and last
if num_columns > 2 {
let spacing = (last_line_pos - first_line_pos) / (num_columns - 1) as f64;
for i in 1..num_columns - 1 {
positions[i] = first_line_pos + i as f64 * spacing;
}
}
positions
};
// Calculate height needed - account for vertical arrows that need extra space
let content_top = margin + title_height + title_gap;
let items_top = content_top + column_height + 20.0;
// Calculate total height needed, accounting for vertical arrows
let line_extension = 15.0; // Must match the line_extension used for column lines
let vertical_arrow_spacing = 80.0; // Space between circles in vertical arrows
let height = if chart.items.is_empty() {
(items_top + margin) as u32
} else {
// Calculate total height by simulating the same logic as rendering
let mut calc_y = items_top;
for item in &chart.items {
let from_idx = chart
.columns
.iter()
.position(|c| c == &item.from_state)
.unwrap_or(0);
let to_idx = chart
.columns
.iter()
.position(|c| c == &item.to_state)
.unwrap_or(0);
if from_idx == to_idx {
// Vertical arrow takes more space
calc_y += vertical_arrow_spacing + item_height;
} else {
// Regular horizontal arrow
calc_y += item_height;
}
}
// Subtract one item_height since we added it for the last item
// Then add circle radius and margin
let final_y = calc_y - item_height + circle_radius;
(final_y + line_extension + margin) as u32
};
// Create SVG document
let mut document = Document::new()
.set("viewBox", (0, 0, width, height))
.set("width", "100%")
.set("height", height)
.set("xmlns", "http://www.w3.org/2000/svg")
.set(
"style",
format!("max-width: {}px; background-color: white;", width),
);
// Add CSS styles
let style = Style::new(&format!(
r#"
.chart-title {{ text-anchor: middle; font-size: {}px; fill: #131300; font-family: "{}", sans-serif; }}
.column-label {{ font-size: {}px; fill: #131300; font-family: "{}", sans-serif; text-anchor: middle; }}
.column-line {{ stroke: #e0e0e0; stroke-width: 1px; }}
.item-label {{ font-size: {}px; fill: #131300; font-family: "{}", sans-serif; text-anchor: middle; }}
.item-circle {{ fill: #131300; }}
.item-arrow {{ stroke: #131300; stroke-width: 1px; fill: none; }}
.arrow-head {{ fill: #131300; }}
.circle-text {{ fill: white; font-size: {}px; font-family: \"{}\", sans-serif; text-anchor: middle; dominant-baseline: middle; font-weight: bold; }}
"#,
title_font_size,
font_name,
column_font_size,
font_name,
item_font_size,
font_name,
16.0,
font_name
));
document = document.add(style);
// Background
document = document.add(
Rectangle::new()
.set("fill", "white")
.set("width", width)
.set("height", height),
);
// Main group
let mut main_group = Group::new().set("class", "main");
// Title
if let Some(title) = &chart.title {
let title_y = margin + title_height / 2.0;
main_group = main_group.add(
Text::new(title)
.set("class", "chart-title")
.set("x", width as f64 / 2.0)
.set("y", title_y)
.set("text-anchor", "middle")
.set("dominant-baseline", "middle"),
);
}
// Column labels and lines
for (i, column) in chart.columns.iter().enumerate() {
let x = column_positions[i];
// Column label
main_group = main_group.add(
Text::new(column)
.set("class", "column-label")
.set("x", x)
.set("y", content_top + column_height / 2.0),
);
// Vertical line
// Calculate where the first and last items would be
let line_extension = 15.0; // Extra pixels above/below circles
let first_item_y = items_top;
// Calculate the actual last item position accounting for vertical arrows
let last_item_y = if chart.items.is_empty() {
first_item_y
} else {
let mut calc_y = items_top;
for item in &chart.items {
let from_idx = chart
.columns
.iter()
.position(|c| c == &item.from_state)
.unwrap_or(0);
let to_idx = chart
.columns
.iter()
.position(|c| c == &item.to_state)
.unwrap_or(0);
if from_idx == to_idx {
calc_y += vertical_arrow_spacing;
}
calc_y += item_height;
}
calc_y - item_height // Subtract the last increment
};
main_group = main_group.add(
Line::new()
.set("class", "column-line")
.set("x1", x)
.set("y1", first_item_y - circle_radius - line_extension)
.set("x2", x)
.set("y2", last_item_y + circle_radius + line_extension),
);
}
// Work items - calculate Y positions accounting for vertical arrows
let mut current_y = items_top;
for (_item_idx, item) in chart.items.iter().enumerate() {
let y = current_y;
// Find column indices
let from_idx = chart
.columns
.iter()
.position(|c| c == &item.from_state)
.unwrap_or(0);
let to_idx = chart
.columns
.iter()
.position(|c| c == &item.to_state)
.unwrap_or(0);
let from_x = column_positions[from_idx];
let to_x = column_positions[to_idx];
if from_idx == to_idx {
// Same column - draw vertical arrow
let x = from_x;
// Draw circle at start (top)
main_group = main_group.add(
Circle::new()
.set("class", "item-circle")
.set("cx", x)
.set("cy", y)
.set("r", circle_radius),
);
// Add from points text in start circle
main_group = main_group.add(
Text::new(&item.from_points.to_string())
.set("class", "circle-text")
.set("x", x)
.set("y", y)
.set("text-anchor", "middle")
.set("dominant-baseline", "middle"),
);
// Draw circle at end (bottom)
let end_y = y + vertical_arrow_spacing; // Use longer spacing
main_group = main_group.add(
Circle::new()
.set("class", "item-circle")
.set("cx", x)
.set("cy", end_y)
.set("r", circle_radius),
);
// Add to points text in end circle
main_group = main_group.add(
Text::new(&item.to_points.to_string())
.set("class", "circle-text")
.set("x", x)
.set("y", end_y)
.set("text-anchor", "middle")
.set("dominant-baseline", "middle"),
);
// Draw vertical arrow line
let arrow_start_y = y + circle_radius;
let arrow_end_y = end_y - circle_radius - arrow_size;
main_group = main_group.add(
Line::new()
.set("class", "item-arrow")
.set("x1", x)
.set("y1", arrow_start_y)
.set("x2", x)
.set("y2", arrow_end_y),
);
// Draw downward arrow head
let arrow_tip_y = end_y - circle_radius;
let arrow_points = format!(
"{},{} {},{} {},{}",
x,
arrow_tip_y,
x - arrow_size / 2.0,
arrow_tip_y - arrow_size,
x + arrow_size / 2.0,
arrow_tip_y - arrow_size
);
main_group = main_group.add(
Path::new()
.set("class", "arrow-head")
.set("d", format!("M {} Z", arrow_points)),
);
// Draw item label - position based on column
let label_y = (y + end_y) / 2.0; // Middle of the arrow
let mut label_text = item.id.clone();
let points_change = item.points_change();
if points_change != 0 {
label_text.push_str(&format!(
": {}{}",
if points_change > 0 { "+" } else { "" },
points_change
));
}
// Check if this is the last column
let is_last_column = from_idx == chart.columns.len() - 1;
if is_last_column {
// For last column, put label on the left
let label_x = x - vertical_label_offset;
main_group = main_group.add(
Text::new(label_text)
.set("class", "item-label")
.set("x", label_x)
.set("y", label_y)
.set("style", "text-anchor: end") // End anchor so text ends at x position
.set("dominant-baseline", "middle"),
);
} else {
// For other columns, put label on the right
let label_x = x + vertical_label_offset;
main_group = main_group.add(
Text::new(label_text)
.set("class", "item-label")
.set("x", label_x)
.set("y", label_y)
.set("style", "text-anchor: start") // Use inline style to override CSS class
.set("dominant-baseline", "middle"),
);
}
} else {
// Different columns - draw horizontal arrow
// Draw circle at start
main_group = main_group.add(
Circle::new()
.set("class", "item-circle")
.set("cx", from_x)
.set("cy", y)
.set("r", circle_radius),
);
// Add from points text in start circle
main_group = main_group.add(
Text::new(&item.from_points.to_string())
.set("class", "circle-text")
.set("x", from_x)
.set("y", y)
.set("text-anchor", "middle")
.set("dominant-baseline", "middle"),
);
// Draw circle at end
main_group = main_group.add(
Circle::new()
.set("class", "item-circle")
.set("cx", to_x)
.set("cy", y)
.set("r", circle_radius),
);
// Add to points text in end circle
main_group = main_group.add(
Text::new(&item.to_points.to_string())
.set("class", "circle-text")
.set("x", to_x)
.set("y", y)
.set("text-anchor", "middle")
.set("dominant-baseline", "middle"),
);
// Draw horizontal arrow line
let arrow_start_x = if from_idx < to_idx {
from_x + circle_radius
} else {
from_x - circle_radius
};
let arrow_end_x = if from_idx < to_idx {
to_x - circle_radius - arrow_size
} else {
to_x + circle_radius + arrow_size
};
main_group = main_group.add(
Line::new()
.set("class", "item-arrow")
.set("x1", arrow_start_x)
.set("y1", y)
.set("x2", arrow_end_x)
.set("y2", y),
);
// Draw horizontal arrow head pointing to circle edge
let arrow_points = if from_idx < to_idx {
// Right-pointing arrow
let arrow_tip_x = to_x - circle_radius;
format!(
"{},{} {},{} {},{}",
arrow_tip_x,
y,
arrow_tip_x - arrow_size,
y - arrow_size / 2.0,
arrow_tip_x - arrow_size,
y + arrow_size / 2.0
)
} else {
// Left-pointing arrow
let arrow_tip_x = to_x + circle_radius;
format!(
"{},{} {},{} {},{}",
arrow_tip_x,
y,
arrow_tip_x + arrow_size,
y - arrow_size / 2.0,
arrow_tip_x + arrow_size,
y + arrow_size / 2.0
)
};
main_group = main_group.add(
Path::new()
.set("class", "arrow-head")
.set("d", format!("M {} Z", arrow_points)),
);
// Draw item label above the line
let label_x = (from_x + to_x) / 2.0;
let label_y = y - 5.0; // Just 5 pixels above the line
let mut label_text = item.id.clone();
let points_change = item.points_change();
if points_change != 0 {
label_text.push_str(&format!(
": {}{}",
if points_change > 0 { "+" } else { "" },
points_change
));
}
main_group = main_group.add(
Text::new(label_text)
.set("class", "item-label")
.set("x", label_x)
.set("y", label_y)
.set("dominant-baseline", "text-after-edge"),
);
}
// Update current_y for next item
if from_idx == to_idx {
// Vertical arrow takes more space
current_y += vertical_arrow_spacing + item_height;
} else {
// Regular horizontal arrow
current_y += item_height;
}
}
document = document.add(main_group);
(document, width, height)
}