mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -06:00
Merge PR #621 from NikitkaRa: SmartArt: organisation charts with assistants, segmented/multidirectional cycles, cycle matrix, segmented pyramid, pie
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +372,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");
|
||||
|
||||
@@ -2599,9 +2599,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