use std::collections::{BTreeMap, HashMap, HashSet};
use std::f64::consts::TAU;
use std::sync::{Arc, OnceLock};
use resvg::{tiny_skia, usvg};
use uuid::Uuid;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::Store;
const RING_SPACING_BASE: f64 = 240.0;
const SVG_MARGIN: f64 = 120.0;
const CHAR_WIDTH: f64 = 7.2;
const LINE_HEIGHT: f64 = 14.4;
const PADDING_X: f64 = 18.0;
const PADDING_Y: f64 = 10.0;
const MIN_HALF_W: f64 = 36.0;
const MIN_HALF_H: f64 = 18.0;
const WRAP_LINE_CHARS: usize = 22;
const MAX_LINES: usize = 4;
pub struct StoryRender {
pub width: u32,
pub height: u32,
pub png_bytes: Vec<u8>,
pub image: image::DynamicImage,
}
pub fn build_story_png(
store: &Store,
hierarchy: &Hierarchy,
book_id: Uuid,
) -> Result<StoryRender, String> {
let book = hierarchy
.get(book_id)
.ok_or_else(|| format!("book {book_id} missing from hierarchy"))?
.clone();
if book.kind != NodeKind::Book {
return Err(format!("`{}` is not a Book node", book.title));
}
let graph = build_graph(store, hierarchy, &book);
let svg = render_svg(&graph);
svg_to_png(&svg)
}
pub fn build_story_png_for_paragraph(
store: &Store,
hierarchy: &Hierarchy,
paragraph_id: Uuid,
) -> Result<StoryRender, String> {
let para = hierarchy
.get(paragraph_id)
.ok_or_else(|| format!("paragraph {paragraph_id} missing from hierarchy"))?
.clone();
if para.kind != NodeKind::Paragraph {
return Err(format!("`{}` is not a Paragraph node", para.title));
}
let graph = build_paragraph_graph(store, hierarchy, ¶);
if graph.nodes.is_empty() {
return Err("graph is empty — nothing to render".into());
}
let svg = render_svg(&graph);
svg_to_png(&svg)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShapeKind {
Folder, Box, Octagon, Ellipse, Note, Parallelogram, Cds, Egg, Diamond, Hexagon, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EdgeStyle {
Structural,
WikiLink,
Lexicon,
}
struct GraphNode {
id: Uuid,
label_lines: Vec<String>,
shape: ShapeKind,
fill: &'static str,
x: f64,
y: f64,
half_w: f64,
half_h: f64,
}
struct GraphEdge {
from: Uuid,
to: Uuid,
style: EdgeStyle,
}
struct Graph {
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
extent: f64,
}
fn build_paragraph_graph(
store: &Store,
hierarchy: &Hierarchy,
centre: &Node,
) -> Graph {
let all = hierarchy.flatten();
let outgoing: Vec<&Node> = centre
.linked_paragraphs
.iter()
.filter_map(|id| all.iter().find_map(|(n, _)| (n.id == *id).then_some(*n)))
.collect();
let incoming: Vec<&Node> = all
.iter()
.filter_map(|(n, _)| {
if n.kind == NodeKind::Paragraph
&& n.id != centre.id
&& n.linked_paragraphs.contains(¢re.id)
{
Some(*n)
} else {
None
}
})
.collect();
let body_lower: String = centre
.file
.as_ref()
.and_then(|rel| std::fs::read(store.project_root().join(rel)).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.map(|t| t.to_lowercase())
.unwrap_or_default();
let mut lexicon_mentioned: Vec<&Node> = Vec::new();
if !body_lower.is_empty() {
for (n, _) in &all {
if n.kind != NodeKind::Paragraph {
continue;
}
if !is_under_system_book(
hierarchy,
n,
&["characters", "places", "artefacts"],
) {
continue;
}
let needle = n.title.trim().to_lowercase();
if needle.is_empty() {
continue;
}
if body_lower.contains(&needle) {
lexicon_mentioned.push(*n);
}
}
}
let mut sized: HashMap<Uuid, (Vec<String>, f64, f64)> = HashMap::new();
for n in std::iter::once(centre)
.chain(outgoing.iter().copied())
.chain(incoming.iter().copied())
.chain(lexicon_mentioned.iter().copied())
{
let lines = wrap_label(&n.title);
let (hw, hh) = size_for_label(&lines);
sized.entry(n.id).or_insert((lines, hw, hh));
}
let max_half_w = sized
.values()
.map(|(_, hw, _)| *hw)
.fold(0.0_f64, f64::max);
let ring_spacing = RING_SPACING_BASE.max(2.0 * max_half_w + 60.0);
let mut positions: HashMap<Uuid, (f64, f64)> = HashMap::new();
positions.insert(centre.id, (0.0, 0.0));
let inner_radius = ring_spacing;
let place_half = |list: &[&Node], theta_start: f64, theta_end: f64,
out: &mut HashMap<Uuid, (f64, f64)>| {
if list.is_empty() {
return;
}
let span = theta_end - theta_start;
let step = span / list.len() as f64;
for (i, n) in list.iter().enumerate() {
let theta = theta_start + step * (i as f64 + 0.5);
out.insert(
n.id,
(inner_radius * theta.cos(), inner_radius * theta.sin()),
);
}
};
use std::f64::consts::{FRAC_PI_2, PI};
place_half(&outgoing, -FRAC_PI_2, FRAC_PI_2, &mut positions);
place_half(&incoming, FRAC_PI_2, FRAC_PI_2 + PI, &mut positions);
let lex_radius = ring_spacing * 2.0;
let mut angle_collisions: HashMap<i32, usize> = HashMap::new();
for (i, n) in lexicon_mentioned.iter().enumerate() {
let base = TAU * (i as f64 / lexicon_mentioned.len().max(1) as f64);
let bucket = (base.to_degrees() / 5.0).round() as i32;
let count = angle_collisions.entry(bucket).or_insert(0);
let nudge = (*count as f64) * 8.0_f64.to_radians();
*count += 1;
let theta = base + nudge;
positions
.insert(n.id, (lex_radius * theta.cos(), lex_radius * theta.sin()));
}
let mut graph_nodes: Vec<GraphNode> = Vec::new();
let mut max_extent: f64 = 0.0;
let mut node_lookup: std::collections::HashSet<Uuid> =
std::collections::HashSet::new();
let push_node = |n: &Node,
shape: ShapeKind,
fill: &'static str,
positions: &HashMap<Uuid, (f64, f64)>,
sized: &HashMap<Uuid, (Vec<String>, f64, f64)>,
graph_nodes: &mut Vec<GraphNode>,
node_lookup: &mut std::collections::HashSet<Uuid>,
max_extent: &mut f64| {
let Some(&(x, y)) = positions.get(&n.id) else {
return;
};
let (label_lines, hw, hh) = sized
.get(&n.id)
.cloned()
.unwrap_or_else(|| (vec![n.title.clone()], MIN_HALF_W, MIN_HALF_H));
graph_nodes.push(GraphNode {
id: n.id,
label_lines,
shape,
fill,
x,
y,
half_w: hw,
half_h: hh,
});
node_lookup.insert(n.id);
*max_extent = max_extent.max(x.abs() + hw).max(y.abs() + hh);
};
{
let (s, f) = shape_for(centre);
push_node(
centre,
s,
f,
&positions,
&sized,
&mut graph_nodes,
&mut node_lookup,
&mut max_extent,
);
}
for n in &outgoing {
let (s, f) = shape_for(n);
push_node(
n,
s,
f,
&positions,
&sized,
&mut graph_nodes,
&mut node_lookup,
&mut max_extent,
);
}
for n in &incoming {
let (s, f) = shape_for(n);
push_node(
n,
s,
f,
&positions,
&sized,
&mut graph_nodes,
&mut node_lookup,
&mut max_extent,
);
}
for n in &lexicon_mentioned {
let (s, f) = lexicon_shape_for(n, hierarchy);
push_node(
n,
s,
f,
&positions,
&sized,
&mut graph_nodes,
&mut node_lookup,
&mut max_extent,
);
}
let mut edges: Vec<GraphEdge> = Vec::new();
for n in &outgoing {
if node_lookup.contains(&n.id) {
edges.push(GraphEdge {
from: centre.id,
to: n.id,
style: EdgeStyle::WikiLink,
});
}
}
for n in &incoming {
if node_lookup.contains(&n.id) {
edges.push(GraphEdge {
from: n.id,
to: centre.id,
style: EdgeStyle::WikiLink,
});
}
}
for n in &lexicon_mentioned {
if node_lookup.contains(&n.id) {
edges.push(GraphEdge {
from: n.id,
to: centre.id,
style: EdgeStyle::Lexicon,
});
}
}
Graph {
nodes: graph_nodes,
edges,
extent: max_extent,
}
}
fn build_graph(store: &Store, hierarchy: &Hierarchy, book: &Node) -> Graph {
let all = hierarchy.flatten();
let in_book: HashSet<Uuid> = collect_subtree_ids(hierarchy, book.id);
let mut structural: BTreeMap<Uuid, Vec<Uuid>> = BTreeMap::new();
for (n, _) in &all {
if !in_book.contains(&n.id) {
continue;
}
if let Some(pid) = n.parent_id {
if in_book.contains(&pid) {
structural.entry(pid).or_default().push(n.id);
}
}
}
let mut order_of: HashMap<Uuid, u32> = HashMap::new();
for (n, _) in &all {
order_of.insert(n.id, n.order);
}
for kids in structural.values_mut() {
kids.sort_by_key(|id| order_of.get(id).copied().unwrap_or(0));
}
let structural_children: HashMap<Uuid, Vec<Uuid>> =
structural.into_iter().collect();
let mut leaves: HashMap<Uuid, usize> = HashMap::new();
count_leaves(book.id, &structural_children, &mut leaves);
let max_depth = max_depth_of(book.id, &structural_children);
let lexicon: Vec<&Node> = all
.iter()
.filter(|(n, _)| {
n.kind == NodeKind::Paragraph
&& is_under_system_book(
hierarchy,
n,
&["characters", "places", "artefacts"],
)
})
.map(|(n, _)| *n)
.collect();
let mut sized: HashMap<Uuid, (Vec<String>, f64, f64)> = HashMap::new();
for (n, _) in &all {
if in_book.contains(&n.id) {
let lines = wrap_label(&n.title);
let (hw, hh) = size_for_label(&lines);
sized.insert(n.id, (lines, hw, hh));
}
}
for lex in &lexicon {
let lines = wrap_label(&lex.title);
let (hw, hh) = size_for_label(&lines);
sized.insert(lex.id, (lines, hw, hh));
}
let max_half_w = sized
.values()
.map(|(_, hw, _)| *hw)
.fold(0.0_f64, f64::max);
let ring_spacing = RING_SPACING_BASE.max(2.0 * max_half_w + 60.0);
let mut positions: HashMap<Uuid, (f64, f64)> = HashMap::new();
place_radial(
book.id,
0.0,
TAU,
0,
&structural_children,
&leaves,
ring_spacing,
&mut positions,
);
let mut bodies: HashMap<Uuid, String> = HashMap::new();
let book_nodes: Vec<&Node> = all
.iter()
.filter(|(n, _)| in_book.contains(&n.id))
.map(|(n, _)| *n)
.collect();
for n in &book_nodes {
if n.kind != NodeKind::Paragraph {
continue;
}
let ct = n.content_type.as_deref().unwrap_or("typst");
if ct != "typst" && !ct.is_empty() {
continue;
}
if let Some(rel) = &n.file {
let abs = store.project_root().join(rel);
if let Ok(bytes) = std::fs::read(&abs) {
if let Ok(text) = String::from_utf8(bytes) {
bodies.insert(n.id, text.to_lowercase());
}
}
}
}
let mut lex_targets: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for lex in &lexicon {
let needle = lex.title.trim().to_lowercase();
if needle.is_empty() {
continue;
}
let mut hits: Vec<Uuid> = Vec::new();
for (pid, body) in &bodies {
if body.contains(&needle) {
hits.push(*pid);
}
}
if !hits.is_empty() {
lex_targets.insert(lex.id, hits);
}
}
let lex_ring = (max_depth as f64 + 1.0) * ring_spacing;
let mut angle_collisions: HashMap<i32, usize> = HashMap::new();
for lex in &lexicon {
let Some(targets) = lex_targets.get(&lex.id) else {
continue;
};
let Some(&first) = targets.first() else { continue };
let Some(&(tx, ty)) = positions.get(&first) else {
continue;
};
let mut theta = ty.atan2(tx);
if theta < 0.0 {
theta += TAU;
}
let bucket = (theta.to_degrees() / 5.0).round() as i32;
let n_in_bucket = angle_collisions.entry(bucket).or_insert(0);
let nudge = (*n_in_bucket as f64) * (8.0_f64.to_radians());
*n_in_bucket += 1;
theta += nudge;
let (x, y) = (lex_ring * theta.cos(), lex_ring * theta.sin());
positions.insert(lex.id, (x, y));
}
let mut nodes: Vec<GraphNode> = Vec::new();
let mut max_extent: f64 = 0.0;
let mut node_lookup: HashSet<Uuid> = HashSet::new();
for n in &book_nodes {
let Some(&(x, y)) = positions.get(&n.id) else {
continue;
};
let (shape, fill) = shape_for(n);
let (label_lines, half_w, half_h) = sized
.get(&n.id)
.cloned()
.unwrap_or_else(|| {
let lines = wrap_label(&n.title);
let (hw, hh) = size_for_label(&lines);
(lines, hw, hh)
});
nodes.push(GraphNode {
id: n.id,
label_lines,
shape,
fill,
x,
y,
half_w,
half_h,
});
node_lookup.insert(n.id);
max_extent = max_extent.max(x.abs() + half_w).max(y.abs() + half_h);
}
for lex in &lexicon {
if !lex_targets.contains_key(&lex.id) {
continue;
}
let Some(&(x, y)) = positions.get(&lex.id) else {
continue;
};
let (shape, fill) = lexicon_shape_for(lex, hierarchy);
let (label_lines, half_w, half_h) = sized
.get(&lex.id)
.cloned()
.unwrap_or_else(|| {
let lines = wrap_label(&lex.title);
let (hw, hh) = size_for_label(&lines);
(lines, hw, hh)
});
nodes.push(GraphNode {
id: lex.id,
label_lines,
shape,
fill,
x,
y,
half_w,
half_h,
});
node_lookup.insert(lex.id);
max_extent = max_extent.max(x.abs() + half_w).max(y.abs() + half_h);
}
let mut edges: Vec<GraphEdge> = Vec::new();
for (parent, kids) in &structural_children {
for child in kids {
if node_lookup.contains(parent) && node_lookup.contains(child) {
edges.push(GraphEdge {
from: *parent,
to: *child,
style: EdgeStyle::Structural,
});
}
}
}
for n in &book_nodes {
if n.kind != NodeKind::Paragraph {
continue;
}
for target in &n.linked_paragraphs {
if node_lookup.contains(&n.id) && node_lookup.contains(target) {
edges.push(GraphEdge {
from: n.id,
to: *target,
style: EdgeStyle::WikiLink,
});
}
}
}
for (lex_id, targets) in &lex_targets {
for t in targets {
if node_lookup.contains(lex_id) && node_lookup.contains(t) {
edges.push(GraphEdge {
from: *lex_id,
to: *t,
style: EdgeStyle::Lexicon,
});
}
}
}
Graph {
nodes,
edges,
extent: max_extent,
}
}
fn count_leaves(
n: Uuid,
children: &HashMap<Uuid, Vec<Uuid>>,
out: &mut HashMap<Uuid, usize>,
) -> usize {
let kids = children.get(&n).cloned().unwrap_or_default();
if kids.is_empty() {
out.insert(n, 1);
return 1;
}
let mut total = 0;
for k in kids {
total += count_leaves(k, children, out);
}
out.insert(n, total);
total
}
fn max_depth_of(root: Uuid, children: &HashMap<Uuid, Vec<Uuid>>) -> usize {
fn recurse(
n: Uuid,
depth: usize,
children: &HashMap<Uuid, Vec<Uuid>>,
) -> usize {
let kids = children.get(&n).cloned().unwrap_or_default();
if kids.is_empty() {
return depth;
}
kids.into_iter()
.map(|k| recurse(k, depth + 1, children))
.max()
.unwrap_or(depth)
}
recurse(root, 0, children)
}
fn place_radial(
node: Uuid,
theta_start: f64,
theta_end: f64,
depth: usize,
children: &HashMap<Uuid, Vec<Uuid>>,
leaves: &HashMap<Uuid, usize>,
ring_spacing: f64,
out: &mut HashMap<Uuid, (f64, f64)>,
) {
let theta_mid = (theta_start + theta_end) / 2.0;
let radius = depth as f64 * ring_spacing;
let (x, y) = if depth == 0 {
(0.0, 0.0)
} else {
(radius * theta_mid.cos(), radius * theta_mid.sin())
};
out.insert(node, (x, y));
let kids = children.get(&node).cloned().unwrap_or_default();
if kids.is_empty() {
return;
}
let total_leaves: usize = kids.iter().map(|c| *leaves.get(c).unwrap_or(&1)).sum();
if total_leaves == 0 {
return;
}
let span = theta_end - theta_start;
let mut cursor = theta_start;
for k in kids {
let kl = *leaves.get(&k).unwrap_or(&1) as f64;
let wedge = span * kl / total_leaves as f64;
place_radial(
k,
cursor,
cursor + wedge,
depth + 1,
children,
leaves,
ring_spacing,
out,
);
cursor += wedge;
}
}
fn shape_for(n: &Node) -> (ShapeKind, &'static str) {
match n.kind {
NodeKind::Book => (ShapeKind::Folder, "#fff7e6"),
NodeKind::Chapter => (ShapeKind::Box, "#e6f4ff"),
NodeKind::Subchapter => (ShapeKind::Octagon, "#e6fff4"),
NodeKind::Paragraph => match n.content_type.as_deref() {
Some("hjson") => (ShapeKind::Note, "#fff0f5"),
Some("bund") => (ShapeKind::Parallelogram, "#fff5e0"),
_ => (ShapeKind::Ellipse, "#ffffff"),
},
NodeKind::Script => (ShapeKind::Parallelogram, "#fff5e0"),
NodeKind::Image => (ShapeKind::Cds, "#e0e7ff"),
}
}
fn lexicon_shape_for(n: &Node, hierarchy: &Hierarchy) -> (ShapeKind, &'static str) {
let book = hierarchy
.ancestors(n)
.into_iter()
.find(|a| a.kind == NodeKind::Book);
let tag = book
.and_then(|b| b.system_tag.clone())
.unwrap_or_default()
.to_lowercase();
match tag.as_str() {
"characters" => (ShapeKind::Egg, "#fffacd"),
"places" => (ShapeKind::Diamond, "#d0f0ff"),
"artefacts" => (ShapeKind::Hexagon, "#ffdab9"),
_ => (ShapeKind::Box, "#eeeeee"),
}
}
fn is_under_system_book(hierarchy: &Hierarchy, n: &Node, tags: &[&str]) -> bool {
hierarchy.ancestors(n).into_iter().any(|a| {
a.kind == NodeKind::Book
&& a.system_tag
.as_deref()
.map(|t| {
let t = t.to_lowercase();
tags.iter().any(|want| t == *want)
})
.unwrap_or(false)
})
}
fn collect_subtree_ids(hierarchy: &Hierarchy, root_id: Uuid) -> HashSet<Uuid> {
let mut out = HashSet::new();
let mut stack = vec![root_id];
while let Some(id) = stack.pop() {
if !out.insert(id) {
continue;
}
for (n, _) in hierarchy.flatten() {
if n.parent_id == Some(id) {
stack.push(n.id);
}
}
}
out
}
fn wrap_label(raw: &str) -> Vec<String> {
let normalised: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if normalised.is_empty() {
return vec!["(untitled)".to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in normalised.split(' ') {
let mut remaining = word.to_string();
while remaining.chars().count() > WRAP_LINE_CHARS {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
let head: String = remaining.chars().take(WRAP_LINE_CHARS).collect();
let tail: String = remaining.chars().skip(WRAP_LINE_CHARS).collect();
lines.push(head);
remaining = tail;
}
if remaining.is_empty() {
continue;
}
let candidate_len = if current.is_empty() {
remaining.chars().count()
} else {
current.chars().count() + 1 + remaining.chars().count()
};
if candidate_len > WRAP_LINE_CHARS && !current.is_empty() {
lines.push(std::mem::take(&mut current));
current = remaining;
} else if current.is_empty() {
current = remaining;
} else {
current.push(' ');
current.push_str(&remaining);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.len() > MAX_LINES {
lines.truncate(MAX_LINES);
if let Some(last) = lines.last_mut() {
let max_with_ellipsis = WRAP_LINE_CHARS.saturating_sub(1);
if last.chars().count() > max_with_ellipsis {
*last = last.chars().take(max_with_ellipsis).collect::<String>();
}
last.push('…');
}
}
lines
}
fn size_for_label(label_lines: &[String]) -> (f64, f64) {
let widest_chars = label_lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(1);
let text_w = widest_chars as f64 * CHAR_WIDTH;
let text_h = label_lines.len() as f64 * LINE_HEIGHT;
let half_w = ((text_w + PADDING_X) * 0.5).max(MIN_HALF_W);
let half_h = ((text_h + PADDING_Y) * 0.5).max(MIN_HALF_H);
(half_w, half_h)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn render_svg(g: &Graph) -> String {
let dim = g.extent + SVG_MARGIN;
let total = (dim * 2.0) as u32;
let cx = dim;
let cy = dim;
let node_box: HashMap<Uuid, (f64, f64, f64, f64)> = g
.nodes
.iter()
.map(|n| (n.id, (n.x + cx, n.y + cy, n.half_w, n.half_h)))
.collect();
let mut s = String::new();
s.push_str(&format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="{total}" viewBox="0 0 {total} {total}">
<defs>
<marker id="ah-grey" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#555555"/>
</marker>
<marker id="ah-purple" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#7755aa"/>
</marker>
<marker id="ah-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#11883a"/>
</marker>
</defs>
<style>
text {{ font-family: 'Helvetica', 'Arial', sans-serif; font-size: 12px; fill: #222222; }}
</style>
<rect width="{total}" height="{total}" fill="#ffffff"/>
"##,
));
for e in &g.edges {
let (Some(&(ax, ay, ahw, ahh)), Some(&(bx, by, bhw, bhh))) =
(node_box.get(&e.from), node_box.get(&e.to))
else {
continue;
};
let ar = (ahw * ahw + ahh * ahh).sqrt() * 0.85;
let br = (bhw * bhw + bhh * bhh).sqrt() * 0.85;
let (x1, y1, x2, y2) = truncate_segment(ax, ay, bx, by, ar, br);
let (stroke, dash, marker) = match e.style {
EdgeStyle::Structural => ("#555555", "", "url(#ah-grey)"),
EdgeStyle::WikiLink => ("#7755aa", "6,4", "url(#ah-purple)"),
EdgeStyle::Lexicon => ("#11883a", "5,3", "url(#ah-green)"),
};
s.push_str(&format!(
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{stroke}\" stroke-width=\"1.4\" stroke-dasharray=\"{dash}\" marker-end=\"{marker}\"/>\n"
));
}
for n in &g.nodes {
let (px, py) = (n.x + cx, n.y + cy);
let shape_svg = shape_svg(n.shape, px, py, n.half_w, n.half_h, n.fill);
s.push_str(&shape_svg);
let line_count = n.label_lines.len() as f64;
let first_dy = -(line_count - 1.0) * 0.5 * LINE_HEIGHT;
s.push_str(&format!(
"<text x=\"{px:.1}\" y=\"{py:.1}\" text-anchor=\"middle\" dominant-baseline=\"middle\">"
));
for (i, line) in n.label_lines.iter().enumerate() {
let dy = if i == 0 { first_dy } else { LINE_HEIGHT };
s.push_str(&format!(
"<tspan x=\"{px:.1}\" dy=\"{dy:.1}\">{label}</tspan>",
label = escape_xml(line),
));
}
s.push_str("</text>\n");
}
s.push_str("</svg>\n");
s
}
fn truncate_segment(
ax: f64,
ay: f64,
bx: f64,
by: f64,
ar: f64,
br: f64,
) -> (f64, f64, f64, f64) {
let dx = bx - ax;
let dy = by - ay;
let len = (dx * dx + dy * dy).sqrt();
if len < ar + br {
return (ax, ay, bx, by);
}
let nx = dx / len;
let ny = dy / len;
(ax + nx * ar, ay + ny * ar, bx - nx * br, by - ny * br)
}
fn shape_svg(shape: ShapeKind, x: f64, y: f64, w: f64, h: f64, fill: &str) -> String {
let stroke = "#444444";
let sw = "1.2";
match shape {
ShapeKind::Box => format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"4\" ry=\"4\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
x - w, y - h, 2.0 * w, 2.0 * h,
),
ShapeKind::Ellipse => format!(
"<ellipse cx=\"{x:.1}\" cy=\"{y:.1}\" rx=\"{}\" ry=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
w, h,
),
ShapeKind::Folder => {
let tab_w = w * 0.4;
let tab_h = h * 0.4;
format!(
"<path d=\"M {tlx:.1},{tly:.1} L {tbx:.1},{tly:.1} L {tbx2:.1},{tlym:.1} L {trx:.1},{tlym:.1} L {trx:.1},{bry:.1} L {tlx:.1},{bry:.1} Z\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
tlx = x - w,
tly = y - h - tab_h,
tbx = x - w + tab_w,
tbx2 = x - w + tab_w + 8.0,
tlym = y - h,
trx = x + w,
bry = y + h,
)
}
ShapeKind::Octagon => polygon_points(x, y, w, h, &[
(-0.6, -1.0), (0.6, -1.0), (1.0, -0.5), (1.0, 0.5),
(0.6, 1.0), (-0.6, 1.0), (-1.0, 0.5), (-1.0, -0.5),
], fill, stroke, sw),
ShapeKind::Hexagon => polygon_points(x, y, w, h, &[
(-0.7, -1.0), (0.7, -1.0), (1.0, 0.0),
(0.7, 1.0), (-0.7, 1.0), (-1.0, 0.0),
], fill, stroke, sw),
ShapeKind::Diamond => polygon_points(x, y, w * 0.95, h * 1.5, &[
(0.0, -1.0), (1.0, 0.0), (0.0, 1.0), (-1.0, 0.0),
], fill, stroke, sw),
ShapeKind::Egg => {
format!(
"<path d=\"M {lx:.1},{cy:.1} C {lx:.1},{ty:.1} {rx:.1},{ty:.1} {rx:.1},{cy:.1} C {rx:.1},{by:.1} {lx:.1},{by:.1} {lx:.1},{cy:.1} Z\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
lx = x - w * 0.7,
rx = x + w * 0.7,
ty = y - h * 1.7,
cy = y - h * 0.2,
by = y + h * 1.6,
)
}
ShapeKind::Note => {
let fold = h * 0.6;
format!(
"<path d=\"M {lx:.1},{ty:.1} L {rxf:.1},{ty:.1} L {rx:.1},{tyf:.1} L {rx:.1},{by:.1} L {lx:.1},{by:.1} Z\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n\
<path d=\"M {rxf:.1},{ty:.1} L {rxf:.1},{tyf:.1} L {rx:.1},{tyf:.1}\" fill=\"none\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
lx = x - w,
rx = x + w,
rxf = x + w - fold,
ty = y - h,
tyf = y - h + fold,
by = y + h,
)
}
ShapeKind::Parallelogram => polygon_points(x, y, w, h, &[
(-0.7, -1.0), (1.0, -1.0), (0.7, 1.0), (-1.0, 1.0),
], fill, stroke, sw),
ShapeKind::Cds => {
polygon_points(x, y, w, h, &[
(-1.0, -1.0), (0.6, -1.0), (1.0, 0.0),
(0.6, 1.0), (-1.0, 1.0),
], fill, stroke, sw)
}
}
}
fn polygon_points(
cx: f64,
cy: f64,
rx: f64,
ry: f64,
norm: &[(f64, f64)],
fill: &str,
stroke: &str,
sw: &str,
) -> String {
let pts: Vec<String> = norm
.iter()
.map(|(nx, ny)| format!("{:.1},{:.1}", cx + nx * rx, cy + ny * ry))
.collect();
format!(
"<polygon points=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
pts.join(" "),
)
}
static STORY_FONTDB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
fn story_fontdb() -> Arc<usvg::fontdb::Database> {
STORY_FONTDB
.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
db.load_system_fonts();
db.set_serif_family("Times New Roman");
db.set_sans_serif_family("Helvetica");
db.set_cursive_family("Comic Sans MS");
db.set_fantasy_family("Impact");
db.set_monospace_family("Courier New");
Arc::new(db)
})
.clone()
}
fn svg_to_png(svg_string: &str) -> Result<StoryRender, String> {
let opts = usvg::Options {
fontdb: story_fontdb(),
..usvg::Options::default()
};
let tree =
usvg::Tree::from_str(svg_string, &opts).map_err(|e| format!("svg parse: {e}"))?;
let int_size = tree.size().to_int_size();
let (w, h) = (int_size.width(), int_size.height());
if w == 0 || h == 0 {
return Err("rendered SVG has zero size".into());
}
let mut pixmap = tiny_skia::Pixmap::new(w, h)
.ok_or_else(|| format!("cannot allocate {w}×{h} pixmap"))?;
pixmap.fill(tiny_skia::Color::WHITE);
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
let png_bytes = pixmap.encode_png().map_err(|e| format!("encode PNG: {e}"))?;
let rgba = image::RgbaImage::from_raw(w, h, pixmap.data().to_vec())
.ok_or_else(|| "pixmap dimensions disagree with buffer".to_owned())?;
let image = image::DynamicImage::ImageRgba8(rgba);
Ok(StoryRender {
width: w,
height: h,
png_bytes,
image,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_xml_handles_metas() {
let out = escape_xml("a < b & c > d \"e\"");
assert!(out.contains("<"));
assert!(out.contains("&"));
assert!(out.contains(">"));
assert!(out.contains("""));
}
#[test]
fn wrap_short_label_stays_one_line() {
let lines = wrap_label("The storm");
assert_eq!(lines, vec!["The storm".to_string()]);
}
#[test]
fn wrap_long_label_breaks_on_words() {
let lines = wrap_label("Chapter three: the bell tower at dawn");
assert!(lines.len() >= 2, "expected wrap, got {lines:?}");
for line in &lines {
assert!(
line.chars().count() <= WRAP_LINE_CHARS,
"line over cap: {line:?}",
);
}
}
#[test]
fn wrap_extra_long_falls_back_to_ellipsis() {
let lines = wrap_label(&"word ".repeat(60));
assert!(lines.len() <= MAX_LINES);
assert!(lines.last().unwrap().ends_with('…'));
}
#[test]
fn size_grows_with_label() {
let small = size_for_label(&vec!["x".to_string()]);
let big = size_for_label(&vec![
"Long line one".into(),
"Long line two".into(),
"Long line three".into(),
]);
assert!(big.0 >= small.0, "wider label should not shrink");
assert!(big.1 > small.1, "more lines should be taller");
}
#[test]
fn radial_places_root_at_origin() {
let mut children: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
let root = Uuid::nil();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
children.insert(root, vec![a, b]);
let mut leaves = HashMap::new();
count_leaves(root, &children, &mut leaves);
let mut positions = HashMap::new();
let ring = 220.0;
place_radial(root, 0.0, TAU, 0, &children, &leaves, ring, &mut positions);
let &(rx, ry) = positions.get(&root).unwrap();
assert!(rx.abs() < 1e-9 && ry.abs() < 1e-9);
let &(ax, ay) = positions.get(&a).unwrap();
let &(bx, by) = positions.get(&b).unwrap();
let ar = (ax * ax + ay * ay).sqrt();
let br = (bx * bx + by * by).sqrt();
assert!((ar - ring).abs() < 1e-6);
assert!((br - ring).abs() < 1e-6);
}
#[test]
#[ignore]
fn end_to_end_render_smoke() {
let mk = |id: u128, x: f64, y: f64, lines: &[&str], shape, fill| {
let label_lines: Vec<String> =
lines.iter().map(|s| s.to_string()).collect();
let (half_w, half_h) = size_for_label(&label_lines);
GraphNode {
id: Uuid::from_u128(id),
label_lines,
shape,
fill,
x,
y,
half_w,
half_h,
}
};
let ring = 220.0;
let graph = Graph {
nodes: vec![
mk(0, 0.0, 0.0, &["Root book"], ShapeKind::Folder, "#fff7e6"),
mk(
1,
ring,
0.0,
&["Chapter three:", "bell tower"],
ShapeKind::Box,
"#e6f4ff",
),
mk(2, 0.0, ring, &["A"], ShapeKind::Ellipse, "#ffffff"),
],
edges: vec![
GraphEdge {
from: Uuid::from_u128(0),
to: Uuid::from_u128(1),
style: EdgeStyle::Structural,
},
GraphEdge {
from: Uuid::from_u128(0),
to: Uuid::from_u128(2),
style: EdgeStyle::WikiLink,
},
],
extent: ring + 80.0,
};
let svg = render_svg(&graph);
let render = svg_to_png(&svg).expect("rasterise");
assert!(render.width > 0 && render.height > 0);
assert_eq!(
&render.png_bytes[..8],
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
);
let tspan_count = svg.matches("<tspan").count();
assert!(tspan_count >= 2, "expected >=2 tspans, got {tspan_count}");
}
}