mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
SmartArt: organisation charts with assistants, segmented and multidirectional cycles, cycle matrix, segmented pyramid, pie
Organization Chart and Half Circle Organization Chart with elbow connectors; assistant nodes (Add Assistant, written as asst points and read back) sit beside their parent on a row of their own. Segmented Cycle, Multidirectional Cycle, Cycle Matrix, Segmented Pyramid and Basic Pie. Venn labels move further out so the third circle's no longer runs into the overlap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8;
|
||||
const MAX_SHAPES: usize = 40;
|
||||
|
||||
/// DeckCraft's layouts, with the names PowerPoint's gallery gives them.
|
||||
pub const LAYOUTS: [(&str, &str); 28] = [
|
||||
pub const LAYOUTS: [(&str, &str); 35] = [
|
||||
("list", "Vertical Bullet List"),
|
||||
("blockList", "Basic Block List"),
|
||||
("hBulletList", "Horizontal Bullet List"),
|
||||
@@ -52,6 +52,13 @@ pub const LAYOUTS: [(&str, &str); 28] = [
|
||||
("gridMatrix", "Grid Matrix"),
|
||||
("gear", "Gear"),
|
||||
("funnel", "Funnel"),
|
||||
("segmentedCycle", "Segmented Cycle"),
|
||||
("multidirectionalCycle", "Multidirectional Cycle"),
|
||||
("orgChart", "Organization Chart"),
|
||||
("halfCircleOrgChart", "Half Circle Organization Chart"),
|
||||
("cycleMatrix", "Cycle Matrix"),
|
||||
("segmentedPyramid", "Segmented Pyramid"),
|
||||
("pie", "Basic Pie"),
|
||||
];
|
||||
/// SmartArt Styles.
|
||||
pub const STYLES: [(&str, &str); 9] = [
|
||||
@@ -115,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Shape",
|
||||
["SmartArt Design", "Create Graphic"],
|
||||
None,
|
||||
"{id?, index?, position?: after|before|below|above, text?} → {index} (default: after the last node)",
|
||||
"{id?, index?, position?: after|before|below|above|assistant, text?} → {index} (default: after the last node)",
|
||||
has_selection,
|
||||
add_node
|
||||
),
|
||||
@@ -135,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Change Layout",
|
||||
["SmartArt Design", "Layouts"],
|
||||
None,
|
||||
"{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel}",
|
||||
"{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess|pictureHList|timeline|venn|target|textCycle|continuousCycle|horizontalHierarchy|gridMatrix|gear|funnel|segmentedCycle|multidirectionalCycle|orgChart|halfCircleOrgChart|cycleMatrix|segmentedPyramid|pie}",
|
||||
has_selection,
|
||||
change_layout
|
||||
),
|
||||
@@ -176,7 +183,7 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<Dia
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let tabs = l.chars().take_while(|c| *c == '\t').count();
|
||||
DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), picture: None }
|
||||
DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), picture: None, assistant: false }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -208,7 +215,7 @@ fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize {
|
||||
/// new node's index.
|
||||
pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, position: &str, text: &str) -> usize {
|
||||
let Some(i) = index.filter(|i| *i < nodes.len()) else {
|
||||
nodes.push(DiagramNode { text: text.into(), level: 0, picture: None });
|
||||
nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false });
|
||||
return nodes.len() - 1;
|
||||
};
|
||||
let lv = nodes[i].level;
|
||||
@@ -222,9 +229,10 @@ pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, position: &str, t
|
||||
}
|
||||
(i, lv)
|
||||
}
|
||||
"assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)),
|
||||
_ => (subtree_end(nodes, i), lv),
|
||||
};
|
||||
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None });
|
||||
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant" });
|
||||
at
|
||||
}
|
||||
|
||||
@@ -585,6 +593,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
match d.kind.as_str() {
|
||||
"hierarchy" => return hierarchy(d, area, false),
|
||||
"horizontalHierarchy" => return hierarchy(d, area, true),
|
||||
"orgChart" | "halfCircleOrgChart" => return org_chart(d, area, d.kind == "halfCircleOrgChart"),
|
||||
"list" | "hBulletList" => {
|
||||
// Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below.
|
||||
let vertical = d.kind == "list";
|
||||
@@ -842,6 +851,145 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
"segmentedCycle" | "pie" => {
|
||||
// Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges.
|
||||
// The text sits in a clear box over each segment's middle.
|
||||
let ring = d.kind == "segmentedCycle";
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let gap = if ring { 2.0 } else { 0.0 };
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
// Angles clockwise from 3 o'clock, starting at the top.
|
||||
let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0;
|
||||
let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0;
|
||||
let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0);
|
||||
let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round();
|
||||
// blockArc runs from its first angle to its second clockwise; so does pie.
|
||||
sh.geom = Geom::Preset {
|
||||
name: if ring { "blockArc" } else { "pie" }.into(),
|
||||
adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] },
|
||||
};
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
let m = ((a0 + a1) / 2.0).to_radians();
|
||||
let rr = if ring { s * 0.4375 } else { s * 0.3 };
|
||||
let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9);
|
||||
let th = s * 0.16;
|
||||
let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true);
|
||||
paint(d, &mut t, i, false);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
t.effects = None;
|
||||
out.push((t, g.clone()));
|
||||
}
|
||||
}
|
||||
"multidirectionalCycle" => {
|
||||
// Multidirectional Cycle: nodes around a circle, double-headed arrows between them.
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55);
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
|
||||
titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g);
|
||||
if n > 1 {
|
||||
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
|
||||
let s = (dd * 0.5).max(8.0);
|
||||
let mut sh = deco(
|
||||
"leftRightArrow",
|
||||
Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4),
|
||||
(i as f64 + 0.5) / nf * 360.0,
|
||||
);
|
||||
paint(d, &mut sh, i, true);
|
||||
out.push((sh, vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
"cycleMatrix" => {
|
||||
// Cycle Matrix: four quarters of a circle in the middle with the top-level text, each
|
||||
// quarter's sub-points in a box at its corner.
|
||||
let s = area.h.min(area.w);
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let c = s * 0.56;
|
||||
for (i, g) in gs.iter().take(4).enumerate() {
|
||||
let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]);
|
||||
let b = s * 0.4;
|
||||
let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b);
|
||||
if g.len() > 1 {
|
||||
let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false);
|
||||
paint(d, &mut sh, i, true);
|
||||
if let Some(body) = &mut sh.text {
|
||||
// Text in the box's outer part, clear of the circle.
|
||||
body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 });
|
||||
body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 });
|
||||
body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom });
|
||||
}
|
||||
out.push((sh, g[1..].to_vec()));
|
||||
}
|
||||
}
|
||||
for (i, g) in gs.iter().take(4).enumerate() {
|
||||
let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]);
|
||||
let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5;
|
||||
let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0);
|
||||
sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] };
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, vec![]));
|
||||
let q = c * 0.3;
|
||||
let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true);
|
||||
paint(d, &mut t, i, false);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
t.effects = None;
|
||||
out.push((t, g[..1].to_vec()));
|
||||
}
|
||||
}
|
||||
"segmentedPyramid" => {
|
||||
// Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node,
|
||||
// every other one upside down.
|
||||
let rows = (nf.sqrt().ceil() as usize).max(1);
|
||||
let s = area.h.min(area.w / 1.15);
|
||||
let pw = s * 1.15;
|
||||
let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0);
|
||||
let (tw, th) = (pw / rows as f64, s / rows as f64);
|
||||
let mut k = 0;
|
||||
'rows: for r in 0..rows {
|
||||
let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0;
|
||||
for c in 0..(2 * r + 1) {
|
||||
let Some(g) = gs.get(k) else { break 'rows };
|
||||
let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th);
|
||||
let mut sh = text_shape("rect", x, &nodes(g), true);
|
||||
// Drawn as a polygon (not a flipped preset) so upside-down text stays upright.
|
||||
let path = if c % 2 == 1 {
|
||||
format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0)
|
||||
} else {
|
||||
format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0)
|
||||
};
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath {
|
||||
w: tw,
|
||||
h: th,
|
||||
d: path,
|
||||
fill: deckcraft_geom::preset::FillMode::Norm,
|
||||
stroke: true,
|
||||
}],
|
||||
};
|
||||
if let Some(b) = &mut sh.text {
|
||||
// The text in the triangle's wide half.
|
||||
b.body.inset_l = Some(tw * 0.22);
|
||||
b.body.inset_r = Some(tw * 0.22);
|
||||
if c % 2 == 1 {
|
||||
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
b.body.inset_b = Some(th * 0.35);
|
||||
} else {
|
||||
b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom);
|
||||
b.body.inset_t = Some(th * 0.35);
|
||||
}
|
||||
}
|
||||
paint(d, &mut sh, k, false);
|
||||
out.push((sh, g.clone()));
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
"gridMatrix" => {
|
||||
// Grid Matrix: four quadrants on crossed axes.
|
||||
let s = area.h.min(area.w);
|
||||
@@ -957,7 +1105,7 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
}
|
||||
if let Some(b) = &mut sh.text {
|
||||
// Text towards the circle's outer side, clear of the overlap.
|
||||
let p = dc * 0.27;
|
||||
let p = dc * 0.36;
|
||||
let (dx, dy) = (a.cos() * p, a.sin() * p);
|
||||
b.body.inset_l = Some((7.2 + dx).max(2.0));
|
||||
b.body.inset_r = Some((7.2 - dx).max(2.0));
|
||||
@@ -1003,6 +1151,135 @@ fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on
|
||||
/// a row of their own between the parent and its other children. `half_circle` draws each node as
|
||||
/// its text in an open half ring (Half Circle Organization Chart).
|
||||
fn org_chart(d: &Diagram, area: Xfrm, half_circle: bool) -> Vec<Item> {
|
||||
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
|
||||
if nodes.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut parent: Vec<Option<usize>> = vec![None; nodes.len()];
|
||||
let mut stack: Vec<usize> = vec![];
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
while stack.last().is_some_and(|&p| nodes[p].level >= n.level) {
|
||||
stack.pop();
|
||||
}
|
||||
parent[i] = stack.last().copied();
|
||||
stack.push(i);
|
||||
}
|
||||
let asst = |i: usize| nodes[i].assistant && parent[i].is_some();
|
||||
let kids = |i: Option<usize>| -> Vec<usize> { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() };
|
||||
let mut leaves = vec![0usize; nodes.len()];
|
||||
for i in (0..nodes.len()).rev() {
|
||||
let k = kids(Some(i));
|
||||
leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() };
|
||||
}
|
||||
// Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent.
|
||||
let mut slot = vec![0usize; nodes.len()];
|
||||
for i in 0..nodes.len() {
|
||||
slot[i] = match parent[i] {
|
||||
None => 0,
|
||||
Some(p) if asst(i) => slot[p] + 1,
|
||||
Some(p) => slot[p] + 2,
|
||||
};
|
||||
}
|
||||
let mut used: Vec<usize> = slot.clone();
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0);
|
||||
let rows = used.len().max(1);
|
||||
let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::<usize>().max(1);
|
||||
let cell_w = area.w / total as f64;
|
||||
let row_h = area.h / rows as f64;
|
||||
let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62);
|
||||
let mut left = vec![0usize; nodes.len()];
|
||||
let mut next_root = 0;
|
||||
for i in 0..nodes.len() {
|
||||
if asst(i) {
|
||||
continue;
|
||||
}
|
||||
let mut start = match parent[i] {
|
||||
None => {
|
||||
let s = next_root;
|
||||
next_root += leaves[i];
|
||||
s
|
||||
}
|
||||
Some(p) => left[p],
|
||||
};
|
||||
if let Some(p) = parent[i] {
|
||||
for s in kids(Some(p)) {
|
||||
if s == i {
|
||||
break;
|
||||
}
|
||||
start += leaves[s];
|
||||
}
|
||||
}
|
||||
left[i] = start;
|
||||
}
|
||||
let mut side = vec![0usize; nodes.len()];
|
||||
let boxes: Vec<Xfrm> = (0..nodes.len())
|
||||
.map(|i| {
|
||||
let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0;
|
||||
match parent[i] {
|
||||
Some(p) if asst(i) => {
|
||||
// Assistants alternate right and left of the parent's trunk.
|
||||
let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count();
|
||||
side[i] = k;
|
||||
let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w;
|
||||
let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1;
|
||||
let x = if k % 2 == 0 { pc + off } else { pc - off - bw };
|
||||
Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh)
|
||||
}
|
||||
_ => {
|
||||
let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w;
|
||||
Xfrm::new(cx - bw / 2.0, y, bw, bh)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<Item> = vec![];
|
||||
for i in 0..nodes.len() {
|
||||
let Some(p) = parent[i] else { continue };
|
||||
let (a, b) = (boxes[p], boxes[i]);
|
||||
let pc = a.x + a.w / 2.0;
|
||||
if asst(i) {
|
||||
let my = b.y + b.h / 2.0;
|
||||
out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![]));
|
||||
let ex = if b.x > pc { b.x } else { b.x + b.w };
|
||||
out.push((line_shape((pc, my), (ex, my)), vec![]));
|
||||
} else {
|
||||
let mid = b.y - (row_h - bh) / 2.0;
|
||||
let cc = b.x + b.w / 2.0;
|
||||
out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![]));
|
||||
out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![]));
|
||||
out.push((line_shape((cc, mid), (cc, b.y)), vec![]));
|
||||
}
|
||||
}
|
||||
let _ = side;
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let depth = slot[i] / 2;
|
||||
if half_circle {
|
||||
let s = boxes[i].h.min(boxes[i].w);
|
||||
let x = boxes[i];
|
||||
let mut arc = deco("blockArc", Xfrm::new(x.x + (x.w - s) / 2.0, x.y, s, s), 0.0);
|
||||
arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![180.0 * 60_000.0, 0.0, 8_000.0] };
|
||||
paint(d, &mut arc, depth, false);
|
||||
out.push((arc, vec![]));
|
||||
let mut t = text_shape("rect", x, &[n], true);
|
||||
paint(d, &mut t, depth, true);
|
||||
t.fill = Some(Fill::None);
|
||||
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
out.push((t, vec![i]));
|
||||
} else {
|
||||
let mut sh = text_shape("rect", boxes[i], &[n], true);
|
||||
paint(d, &mut sh, depth, false);
|
||||
out.push((sh, vec![i]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves.
|
||||
fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec<Item> {
|
||||
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
|
||||
@@ -1167,7 +1444,7 @@ fn index(p: &Value, cmd: &str) -> Result<usize> {
|
||||
pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Result<Value> {
|
||||
let kind = if is_kind(kind) { kind } else { "process" };
|
||||
if nodes.is_empty() {
|
||||
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None }).collect();
|
||||
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false }).collect();
|
||||
}
|
||||
normalize(&mut nodes);
|
||||
let d = Diagram { kind: kind.into(), nodes, ..Default::default() };
|
||||
@@ -1316,7 +1593,7 @@ mod tests {
|
||||
n.iter().map(|x| x.level).collect()
|
||||
}
|
||||
fn mk(levels: &[u8]) -> Vec<DiagramNode> {
|
||||
levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None }).collect()
|
||||
levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None, assistant: false }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1413,4 +1690,17 @@ mod tests {
|
||||
assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3);
|
||||
assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistants_sit_beside_their_parent() {
|
||||
let mut v = mk(&[0, 1, 1]);
|
||||
let a = add(&mut v, Some(0), "assistant", "Aide");
|
||||
assert_eq!((a, v[1].level, v[1].assistant), (1, 1, true));
|
||||
let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() };
|
||||
let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 400.0));
|
||||
let boxed = |k: usize| items.iter().find(|(_, m)| m == &vec![k]).and_then(|(s, _)| s.xfrm).unwrap_or_default();
|
||||
let (ceo, aide, kid) = (boxed(0), boxed(1), boxed(2));
|
||||
assert!(aide.y > ceo.y && aide.y < kid.y, "the assistant's row is between its parent and the children");
|
||||
assert!(aide.x > ceo.x, "beside the parent's trunk");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +365,9 @@ pub struct DiagramNode {
|
||||
/// The picture shown in the node's picture placeholder (picture layouts).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<MediaId>,
|
||||
/// An assistant (organisation charts): drawn beside its parent, off the row of its siblings.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub assistant: bool,
|
||||
}
|
||||
|
||||
impl Default for Shape {
|
||||
|
||||
+70
-14
@@ -122,6 +122,13 @@ pub fn layout_id(kind: &str) -> &'static str {
|
||||
"gridMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix2",
|
||||
"gear" => "urn:microsoft.com/office/officeart/2005/8/layout/gear1",
|
||||
"funnel" => "urn:microsoft.com/office/officeart/2005/8/layout/funnel1",
|
||||
"segmentedCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle8",
|
||||
"multidirectionalCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle7",
|
||||
"orgChart" => "urn:microsoft.com/office/officeart/2005/8/layout/orgChart1",
|
||||
"halfCircleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/HalfCircleOrganizationChart",
|
||||
"cycleMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle4",
|
||||
"segmentedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid4",
|
||||
"pie" => "urn:microsoft.com/office/officeart/2005/8/layout/chart3",
|
||||
_ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2",
|
||||
}
|
||||
}
|
||||
@@ -163,6 +170,13 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str {
|
||||
"gridMatrix",
|
||||
"gear",
|
||||
"funnel",
|
||||
"segmentedCycle",
|
||||
"multidirectionalCycle",
|
||||
"orgChart",
|
||||
"halfCircleOrgChart",
|
||||
"cycleMatrix",
|
||||
"segmentedPyramid",
|
||||
"pie",
|
||||
"list",
|
||||
] {
|
||||
if layout_id == layout_id_of(k) {
|
||||
@@ -180,7 +194,9 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str {
|
||||
"target"
|
||||
} else if s.contains("timeline") {
|
||||
"timeline"
|
||||
} else if s.contains("hierarchy") || s.contains("orgchart") {
|
||||
} else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") {
|
||||
"orgChart"
|
||||
} else if s.contains("hierarchy") {
|
||||
"hierarchy"
|
||||
} else if s.contains("radial") {
|
||||
"radial"
|
||||
@@ -211,9 +227,11 @@ fn category(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" | "timeline" | "gear" | "funnel" => "process",
|
||||
"venn" | "target" => "relationship",
|
||||
"cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" => "cycle",
|
||||
"hierarchy" | "horizontalHierarchy" => "hierarchy",
|
||||
"pyramid" | "invertedPyramid" => "pyramid",
|
||||
"cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => {
|
||||
"cycle"
|
||||
}
|
||||
"hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" => "hierarchy",
|
||||
"pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid",
|
||||
"matrix" | "titledMatrix" | "gridMatrix" => "matrix",
|
||||
_ => "list",
|
||||
}
|
||||
@@ -267,7 +285,11 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin
|
||||
// Node k: model id 1 + 3k; its link's parent and sibling transitions 2 + 3k and 3 + 3k.
|
||||
let node = |k: usize| guid(seed, 1 + 3 * k);
|
||||
for (k, n) in d.nodes.iter().enumerate() {
|
||||
w.open("dgm:pt", A::new().a("modelId", node(k)));
|
||||
let mut a = A::new().a("modelId", node(k));
|
||||
if n.assistant {
|
||||
a = a.a("type", "asst");
|
||||
}
|
||||
w.open("dgm:pt", a);
|
||||
w.empty("dgm:prSet", A::new().a("phldrT", "[Text]"));
|
||||
w.empty0("dgm:spPr");
|
||||
text(&mut w, &n.text);
|
||||
@@ -367,14 +389,18 @@ pub fn node_pictures(data: &El) -> Vec<(usize, String)> {
|
||||
fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
|
||||
let Some(pts) = data.child("ptLst") else { return vec![] };
|
||||
let mut texts: HashMap<&str, String> = HashMap::new();
|
||||
let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut doc: Option<&str> = None;
|
||||
for pt in pts.children_named("pt").take(MAX_NODES * 4) {
|
||||
let Some(id) = pt.attr("modelId") else { continue };
|
||||
match pt.attr("type").unwrap_or("node") {
|
||||
"doc" => doc = doc.or(Some(id)),
|
||||
"node" => {
|
||||
ty @ ("node" | "asst") => {
|
||||
let t = pt.child("t").map(paragraphs).unwrap_or_default();
|
||||
texts.insert(id, t);
|
||||
if ty == "asst" {
|
||||
assistants.insert(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -403,7 +429,10 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
|
||||
if out.len() >= MAX_NODES || !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
out.push((id.to_string(), DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None }));
|
||||
out.push((
|
||||
id.to_string(),
|
||||
DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None, assistant: assistants.contains(id) },
|
||||
));
|
||||
if let Some(v) = kids.get(id) {
|
||||
stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1))));
|
||||
}
|
||||
@@ -432,11 +461,19 @@ fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static
|
||||
}
|
||||
"chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"),
|
||||
"verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"),
|
||||
"cycle" | "radial" | "venn" | "target" | "textCycle" | "continuousCycle" | "gear" => {
|
||||
("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse")
|
||||
}
|
||||
"cycle"
|
||||
| "radial"
|
||||
| "venn"
|
||||
| "target"
|
||||
| "textCycle"
|
||||
| "continuousCycle"
|
||||
| "gear"
|
||||
| "segmentedCycle"
|
||||
| "multidirectionalCycle"
|
||||
| "cycleMatrix"
|
||||
| "pie" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"),
|
||||
"blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"),
|
||||
"pyramid" | "invertedPyramid" => {
|
||||
"pyramid" | "invertedPyramid" | "segmentedPyramid" => {
|
||||
("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid")
|
||||
}
|
||||
"matrix" | "titledMatrix" | "gridMatrix" | "blockList" => (
|
||||
@@ -502,7 +539,7 @@ pub fn layout_xml(kind: &str) -> Vec<u8> {
|
||||
w.empty0("dgm:dir");
|
||||
w.empty("dgm:resizeHandles", A::new().a("val", "exact"));
|
||||
w.close("dgm:varLst");
|
||||
if matches!(kind, "hierarchy" | "horizontalHierarchy") {
|
||||
if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart") {
|
||||
// Each node over the row of its children, recursively.
|
||||
w.empty("dgm:alg", A::new().a("type", "hierChild"));
|
||||
w.open("dgm:shape", A::new().a("r:blip", ""));
|
||||
@@ -671,7 +708,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nodes(v: &[(&str, u8)]) -> Vec<DiagramNode> {
|
||||
v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None }).collect()
|
||||
v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,6 +735,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistants_survive_the_data_model() {
|
||||
let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]);
|
||||
v[1].assistant = true;
|
||||
let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() };
|
||||
let xml = data_xml(&d, 5, "rId1", &[]);
|
||||
assert!(String::from_utf8(xml.clone()).unwrap().contains("type=\"asst\""));
|
||||
let doc = crate::xml::parse(&xml).unwrap();
|
||||
assert_eq!(outline(&doc.root), d.nodes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_pictures_come_from_presentation_points() {
|
||||
let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() };
|
||||
@@ -734,6 +782,13 @@ mod tests {
|
||||
"gridMatrix",
|
||||
"gear",
|
||||
"funnel",
|
||||
"segmentedCycle",
|
||||
"multidirectionalCycle",
|
||||
"orgChart",
|
||||
"halfCircleOrgChart",
|
||||
"cycleMatrix",
|
||||
"segmentedPyramid",
|
||||
"pie",
|
||||
] {
|
||||
assert_eq!(kind_for(layout_id(k), ""), k);
|
||||
}
|
||||
@@ -744,7 +799,8 @@ mod tests {
|
||||
let xml = br#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="0" type="doc"/><dgm:pt modelId="1"><dgm:t><a:p xmlns:a="a"><a:r><a:t>A</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2"/></dgm:ptLst><dgm:cxnLst><dgm:cxn srcId="0" destId="1"/><dgm:cxn srcId="1" destId="2"/><dgm:cxn srcId="2" destId="1"/><dgm:cxn type="presOf" srcId="0" destId="2"/></dgm:cxnLst></dgm:dataModel>"#;
|
||||
let doc = crate::xml::parse(xml).unwrap();
|
||||
assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)]));
|
||||
assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "hierarchy");
|
||||
assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "orgChart");
|
||||
assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "orgChart");
|
||||
assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron");
|
||||
assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process");
|
||||
assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList");
|
||||
|
||||
@@ -2338,9 +2338,13 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
rows(ui, |ui| {
|
||||
let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for (label, pos) in
|
||||
[("Add Shape After", "after"), ("Add Shape Before", "before"), ("Add Shape Above", "above"), ("Add Shape Below", "below")]
|
||||
{
|
||||
for (label, pos) in [
|
||||
("Add Shape After", "after"),
|
||||
("Add Shape Before", "before"),
|
||||
("Add Shape Above", "above"),
|
||||
("Add Shape Below", "below"),
|
||||
("Add Assistant", "assistant"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let index = if n == 0 { Value::Null } else { json!(at) };
|
||||
run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos}));
|
||||
|
||||
Reference in New Issue
Block a user