use crate::Result;
use crate::generated::kanban_text_overrides_11_12_2 as kanban_text_overrides;
use crate::model::{Bounds, KanbanDiagramLayout, KanbanItemLayout, KanbanSectionLayout};
use crate::text::{TextMeasurer, TextStyle, WrapMode};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
struct KanbanNode {
id: String,
label: String,
#[serde(default, rename = "isGroup")]
is_group: bool,
#[serde(default, rename = "parentId")]
parent_id: Option<String>,
#[serde(default)]
ticket: Option<String>,
#[serde(default)]
priority: Option<String>,
#[serde(default)]
assigned: Option<String>,
#[serde(default)]
icon: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct KanbanModel {
#[serde(default)]
nodes: Vec<KanbanNode>,
#[serde(rename = "type")]
diagram_type: String,
}
fn cfg_f64(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
let mut cur = cfg;
for k in path {
cur = cur.get(*k)?;
}
cur.as_f64()
}
fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
let mut cur = cfg;
for k in path {
cur = cur.get(*k)?;
}
cur.as_str().map(|s| s.to_string())
}
fn parse_css_px_to_f64(s: &str) -> Option<f64> {
let s = s.trim();
let raw = s.strip_suffix("px").unwrap_or(s).trim();
raw.parse::<f64>().ok().filter(|value| value.is_finite())
}
fn json_f64_css_px(v: &serde_json::Value) -> Option<f64> {
v.as_f64()
.or_else(|| v.as_i64().map(|n| n as f64))
.or_else(|| v.as_u64().map(|n| n as f64))
.or_else(|| v.as_str().and_then(parse_css_px_to_f64))
}
fn cfg_font_size(cfg: &serde_json::Value) -> f64 {
cfg.get("themeVariables")
.and_then(|v| v.get("fontSize"))
.and_then(json_f64_css_px)
.or_else(|| cfg.get("fontSize").and_then(json_f64_css_px))
.unwrap_or(16.0)
.max(1.0)
}
fn kanban_text_style(effective_config: &serde_json::Value) -> TextStyle {
let font_family = cfg_string(effective_config, &["fontFamily"])
.or_else(|| cfg_string(effective_config, &["themeVariables", "fontFamily"]))
.or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
let font_size = cfg_font_size(effective_config);
TextStyle {
font_family,
font_size,
font_weight: None,
}
}
pub fn layout_kanban_diagram(
semantic: &serde_json::Value,
effective_config: &serde_json::Value,
measurer: &dyn TextMeasurer,
) -> Result<KanbanDiagramLayout> {
let model: KanbanModel = crate::json::from_value_ref(semantic)?;
let _ = model.diagram_type.as_str();
let section_width = cfg_f64(effective_config, &["kanban", "sectionWidth"])
.unwrap_or(200.0)
.max(1.0);
let viewbox_padding = cfg_f64(effective_config, &["mindmap", "padding"])
.or_else(|| cfg_f64(effective_config, &["kanban", "padding"]))
.unwrap_or(8.0)
.max(0.0);
let padding = kanban_text_overrides::kanban_section_padding_px();
let section_rect_y = -(section_width * 3.0) / 2.0;
let legend_style = kanban_text_style(effective_config);
let font_scale = legend_style.font_size / 16.0;
let section_label_height_baseline =
kanban_text_overrides::kanban_section_label_height_baseline_px() * font_scale;
let section_label_fo_height =
kanban_text_overrides::kanban_label_foreign_object_height_px() * font_scale;
let item_one_row_height = kanban_text_overrides::kanban_item_one_row_height_px() * font_scale;
let item_two_row_height = kanban_text_overrides::kanban_item_two_row_height_px() * font_scale;
let item_label_line_height =
kanban_text_overrides::kanban_item_label_line_height_px() * font_scale;
let mut max_label_height = section_label_height_baseline;
let mut sections: Vec<KanbanSectionLayout> = Vec::new();
let mut items: Vec<KanbanItemLayout> = Vec::new();
let section_nodes: Vec<&KanbanNode> = model.nodes.iter().filter(|n| n.is_group).collect();
for (i, section) in section_nodes.iter().enumerate() {
let index = (i + 1) as i64;
let center_x = section_width * (index as f64) + ((index - 1) as f64 * padding) / 2.0;
let center_y = 0.0;
let label_metrics = measurer.measure_wrapped(
§ion.label,
&legend_style,
Some(section_width),
WrapMode::HtmlLike,
);
let label_height = label_metrics.height.max(section_label_fo_height);
max_label_height = max_label_height.max(label_height);
sections.push(KanbanSectionLayout {
id: section.id.clone(),
label: section.label.clone(),
index,
center_x,
center_y,
width: section_width,
rect_y: section_rect_y,
rect_height: (section_width * 3.0).max(1.0),
rx: 5.0,
ry: 5.0,
label_width: label_metrics.width.max(0.0),
label_height,
});
}
for section in sections.iter_mut() {
let top = section_rect_y + max_label_height;
let mut y = top;
let section_items: Vec<&KanbanNode> = model
.nodes
.iter()
.filter(|n| n.parent_id.as_deref() == Some(section.id.as_str()))
.collect();
for item in section_items {
let width = (section_width - 1.5 * padding).max(1.0);
let inner_max_w =
(width - kanban_text_overrides::kanban_item_label_inset_x_px()).max(0.0);
let item_label_style = legend_style.clone();
let raw_title_metrics =
measurer.measure_wrapped(&item.label, &item_label_style, None, WrapMode::HtmlLike);
let title_metrics = if inner_max_w > 0.0 && raw_title_metrics.width > inner_max_w {
measurer.measure_wrapped(
&item.label,
&item_label_style,
Some(inner_max_w),
WrapMode::HtmlLike,
)
} else {
raw_title_metrics
};
let has_details_row = item.ticket.is_some() || item.assigned.is_some();
let base_height = if has_details_row {
item_two_row_height
} else {
item_one_row_height
};
let extra_title_height = (title_metrics.height - item_label_line_height).max(0.0);
let height = base_height + extra_title_height;
let center_x = section.center_x;
let center_y = y + height / 2.0;
items.push(KanbanItemLayout {
id: item.id.clone(),
label: item.label.clone(),
parent_id: section.id.clone(),
center_x,
center_y,
width,
height: height.max(1.0),
rx: 5.0,
ry: 5.0,
ticket: item.ticket.clone(),
assigned: item.assigned.clone(),
priority: item.priority.clone(),
icon: item.icon.clone(),
});
y = center_y + height / 2.0 + padding / 2.0;
}
let min_section_height = 50.0 * font_scale;
let height = (y - top + 3.0 * padding).max(min_section_height)
+ (max_label_height - section_label_height_baseline);
section.rect_height = height.max(1.0);
}
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for s in §ions {
let left = s.center_x - s.width / 2.0;
let right = left + s.width;
let top = s.rect_y;
let bottom = s.rect_y + s.rect_height;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
for n in &items {
let left = n.center_x - n.width / 2.0;
let right = n.center_x + n.width / 2.0;
let top = n.center_y - n.height / 2.0;
let bottom = n.center_y + n.height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
let bounds = if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
{
Some(Bounds {
min_x: min_x - viewbox_padding,
min_y: min_y - viewbox_padding,
max_x: max_x + viewbox_padding,
max_y: max_y + viewbox_padding,
})
} else {
None
};
Ok(KanbanDiagramLayout {
bounds,
section_width,
padding,
max_label_height,
viewbox_padding,
sections,
items,
})
}
#[cfg(test)]
mod tests {
use super::layout_kanban_diagram;
use crate::generated::kanban_text_overrides_11_12_2 as kanban_text_overrides;
use crate::text::DeterministicTextMeasurer;
use serde_json::json;
#[test]
fn kanban_text_constants_are_generated() {
assert_eq!(
kanban_text_overrides::kanban_section_label_height_baseline_px(),
25.0
);
assert_eq!(kanban_text_overrides::kanban_section_padding_px(), 10.0);
assert_eq!(
kanban_text_overrides::kanban_label_foreign_object_height_px(),
24.0
);
assert_eq!(kanban_text_overrides::kanban_item_one_row_height_px(), 44.0);
assert_eq!(kanban_text_overrides::kanban_item_label_inset_x_px(), 10.0);
assert_eq!(kanban_text_overrides::kanban_item_two_row_height_px(), 56.0);
assert_eq!(
kanban_text_overrides::kanban_item_label_line_height_px(),
24.0
);
}
#[test]
fn kanban_layout_uses_generated_padding() {
let semantic = json!({
"type": "kanban",
"nodes": [
{"id": "todo", "label": "Todo", "isGroup": true},
{"id": "doing", "label": "Doing", "isGroup": true},
{"id": "task-1", "label": "Task", "parentId": "todo"}
]
});
let measurer = DeterministicTextMeasurer {
char_width_factor: 8.0,
line_height_factor: 16.0,
};
let layout = layout_kanban_diagram(&semantic, &json!({}), &measurer).unwrap();
assert_eq!(
layout.padding,
kanban_text_overrides::kanban_section_padding_px()
);
assert_eq!(
layout.items[0].width,
layout.section_width - 1.5 * kanban_text_overrides::kanban_section_padding_px()
);
}
}