use super::constants::*;
use super::templates;
use super::parser::{VennDiagram, VennSet};
use crate::theme::Theme;
use std::f64::consts::PI;
#[derive(Clone, Debug)]
struct Circle {
x: f64,
y: f64,
r: f64,
}
fn layout_circles(sets: &[VennSet], w: f64, h: f64) -> Vec<Circle> {
let n = sets.len();
if n == 0 {
return Vec::new();
}
let base_r = (w.min(h) / 2.0 * 0.55).max(30.0);
let cx = w / 2.0;
let cy = h / 2.0;
match n {
1 => vec![Circle {
x: cx,
y: cy,
r: base_r,
}],
2 => {
let r = base_r;
let sep = r * TWO_SET_SEP_FACTOR; vec![
Circle {
x: cx - sep / 2.0,
y: cy,
r,
},
Circle {
x: cx + sep / 2.0,
y: cy,
r,
},
]
}
3 => {
let r = base_r * THREE_SET_R_FACTOR;
let dist = r * THREE_SET_DIST_FACTOR;
vec![
Circle {
x: cx,
y: cy - dist * 0.65,
r,
},
Circle {
x: cx - dist * 0.60,
y: cy + dist * 0.45,
r,
},
Circle {
x: cx + dist * 0.60,
y: cy + dist * 0.45,
r,
},
]
}
_ => {
let r = (w.min(h) / 2.0 / (1.0 + 1.0 / (PI / n as f64).sin())).min(base_r);
let ring_r = r * 1.0;
(0..n)
.map(|i| {
let angle = 2.0 * PI * i as f64 / n as f64 - PI / 2.0;
Circle {
x: cx + ring_r * angle.cos(),
y: cy + ring_r * angle.sin(),
r,
}
})
.collect()
}
}
}
pub fn render(diag: &VennDiagram, theme: Theme) -> String {
let vars = theme.resolve();
let title_h = if diag.title.is_some() {
TITLE_HEIGHT
} else {
0.0
};
let chart_h = SVG_HEIGHT - title_h;
let circles = layout_circles(&diag.sets, SVG_WIDTH, chart_h);
let _set_index: std::collections::HashMap<&str, usize> = diag
.sets
.iter()
.enumerate()
.map(|(i, s)| (s.id.as_str(), i))
.collect();
let mut out = String::new();
out.push_str(&templates::svg_root(
SVG_ID,
&fmt(SVG_WIDTH),
&fmt(SVG_HEIGHT),
));
out.push_str(&templates::style_block(
SVG_ID,
vars.font_family,
vars.text_color,
));
if let Some(t) = &diag.title {
out.push_str(&templates::title_text(
&fmt(SVG_WIDTH / 2.0),
&fmt(32.0 * SCALE),
vars.font_family,
&fmt(32.0 * SCALE),
vars.title_color,
&esc(t),
));
}
out.push_str(&templates::translate_group(&fmt(title_h)));
for (i, (set, circ)) in diag.sets.iter().zip(circles.iter()).enumerate() {
let color = get_set_color(set, i, diag);
let stroke_w = 5.0 * SCALE;
let font_size = 48.0 * SCALE;
out.push_str(&templates::venn_circle_group_open(i));
out.push_str(&templates::set_circle_path(
&circle_path(circ.x, circ.y, circ.r),
color,
&fmt(stroke_w),
));
let label = set.label.as_deref().unwrap_or(&set.id);
let label_y = circ.y - circ.r * 0.6;
out.push_str(&templates::set_label_text(
&fmt(circ.x),
&fmt(label_y),
&fmt(font_size),
&darken_color(color),
&esc(label),
));
out.push_str("</g>");
}
for inter in diag.intersections.iter() {
let center = intersection_center(&inter.sets, &diag.sets, &circles);
let font_size = 48.0 * SCALE;
out.push_str(r#"<g class="venn-intersection">"#);
out.push_str(&templates::intersection_path(&circle_path(
center.0, center.1, 5.0,
)));
if let Some(label) = &inter.label {
out.push_str(&templates::intersection_label_text(
&fmt(center.0),
&fmt(center.1),
&fmt(font_size),
vars.text_color,
&esc(label),
));
}
out.push_str("</g>");
}
if !diag.text_nodes.is_empty() {
out.push_str(r#"<g class="venn-text-nodes">"#);
for tn in &diag.text_nodes {
let center = intersection_center(&tn.sets, &diag.sets, &circles);
let label = tn.label.as_deref().unwrap_or(&tn.id);
let font_size = 40.0 * SCALE;
out.push_str(&templates::text_node(
&fmt(center.0),
&fmt(center.1),
vars.font_family,
&fmt(font_size),
vars.text_color,
&esc(label),
));
}
out.push_str("</g>");
}
out.push_str("</g>"); out.push_str("</svg>");
out
}
fn get_set_color(set: &VennSet, index: usize, diag: &VennDiagram) -> &'static str {
for se in &diag.style_entries {
if se.targets.len() == 1 && se.targets[0] == set.id {
if let Some(fill) = se.styles.get("fill") {
let _ = fill;
}
}
}
VENN_COLORS[index % VENN_COLORS.len()]
}
fn intersection_center(sets: &[String], all_sets: &[VennSet], circles: &[Circle]) -> (f64, f64) {
let mut sx = 0.0_f64;
let mut sy = 0.0_f64;
let mut count = 0usize;
for sid in sets {
if let Some(idx) = all_sets.iter().position(|s| &s.id == sid) {
if idx < circles.len() {
sx += circles[idx].x;
sy += circles[idx].y;
count += 1;
}
}
}
if count == 0 {
(
SVG_WIDTH / 2.0,
(SVG_HEIGHT
- if circles.is_empty() {
0.0
} else {
TITLE_HEIGHT
})
/ 2.0,
)
} else {
(sx / count as f64, sy / count as f64)
}
}
fn circle_path(cx: f64, cy: f64, r: f64) -> String {
format!(
"M {},{} A {},{} 0 1,0 {},{} A {},{} 0 1,0 {},{}",
fmt(cx - r),
fmt(cy),
fmt(r),
fmt(r),
fmt(cx + r),
fmt(cy),
fmt(r),
fmt(r),
fmt(cx - r),
fmt(cy),
)
}
fn darken_color(color: &str) -> String {
if color.starts_with('#') && color.len() == 7 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&color[1..3], 16),
u8::from_str_radix(&color[3..5], 16),
u8::from_str_radix(&color[5..7], 16),
) {
let factor = 0.6_f64;
let dr = (r as f64 * factor) as u8;
let dg = (g as f64 * factor) as u8;
let db = (b as f64 * factor) as u8;
return format!("#{:02X}{:02X}{:02X}", dr, dg, db);
}
}
color.to_string()
}
fn fmt(v: f64) -> String {
let s = format!("{:.3}", v);
let s = s.trim_end_matches('0');
let s = s.trim_end_matches('.');
s.to_string()
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::super::parser;
use super::*;
const VENN_BASIC: &str = "vennDiagram\n title Sets\n set A\n set B\n A&B";
#[test]
fn basic_render_produces_svg() {
let diag = parser::parse(VENN_BASIC).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("<svg"), "missing <svg tag");
}
#[test]
fn dark_theme() {
let diag = parser::parse(VENN_BASIC).diagram;
let svg = render(&diag, Theme::Dark);
assert!(svg.contains("<svg"), "missing <svg tag");
}
#[test]
fn snapshot_default_theme() {
let diag = parser::parse(VENN_BASIC).diagram;
let svg = render(&diag, crate::theme::Theme::Default);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}