use super::voronoi::{
generate_seeds, generate_circular_seeds,
lloyd_relax, lloyd_relax_circular,
scatter_colors, voronoi_cells,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Layout {
Voronoi,
Grid,
Constellation,
}
#[derive(Debug, Clone)]
pub struct SvgConfig {
pub width: f64,
pub height: f64,
pub layout: Layout,
pub seed: u64,
pub relax_iters: usize,
pub cols: usize,
pub background: String,
pub stroke: String,
pub stroke_width: f64,
pub circular: bool,
pub color_scatter: bool,
}
impl Default for SvgConfig {
fn default() -> Self {
SvgConfig {
width: 400.0,
height: 400.0,
layout: Layout::Voronoi,
seed: 42,
relax_iters: 5,
cols: 8,
background: "#0f0f23".to_string(),
stroke: "#14142a".to_string(),
stroke_width: 1.5,
circular: false,
color_scatter: true,
}
}
}
pub fn render_svg(colors: &[&str], config: &SvgConfig) -> String {
match config.layout {
Layout::Voronoi => render_voronoi(colors, config),
Layout::Grid => render_grid(colors, config),
Layout::Constellation => render_constellation(colors, config),
}
}
fn render_voronoi(colors: &[&str], config: &SvgConfig) -> String {
let n = colors.len();
let mut seeds = if config.circular {
generate_circular_seeds(n, config.width, config.height, config.seed)
} else {
generate_seeds(n, config.width, config.height, config.seed)
};
if config.circular {
lloyd_relax_circular(&mut seeds, config.width, config.height, config.relax_iters);
} else {
lloyd_relax(&mut seeds, config.width, config.height, config.relax_iters);
}
let (color_order, original_idx): (Vec<&str>, Vec<usize>) = if config.color_scatter {
let perm = scatter_colors(&seeds, colors, config.seed, 8000);
(perm.iter().map(|&i| colors[i]).collect(), perm)
} else {
(colors.to_vec(), (0..n).collect())
};
let cells = voronoi_cells(&seeds, config.width, config.height);
let mut svg = String::with_capacity(n * 200 + 500);
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
viewBox=\"0 0 {w} {h}\" width=\"{w}\" height=\"{h}\">\n",
w = config.width,
h = config.height,
));
if config.circular {
let cr = config.width.min(config.height) / 2.0;
let cx = config.width / 2.0;
let cy = config.height / 2.0;
svg.push_str("<defs>\n");
svg.push_str(&format!(
" <clipPath id=\"circle-clip\">\
<circle cx=\"{cx:.1}\" cy=\"{cy:.1}\" r=\"{r:.1}\"/>\
</clipPath>\n",
cx = cx,
cy = cy,
r = cr - 1.0,
));
svg.push_str("</defs>\n");
svg.push_str("<g clip-path=\"url(#circle-clip)\">\n");
}
svg.push_str(&format!(
"<rect width=\"{w}\" height=\"{h}\" fill=\"{bg}\"/>\n",
w = config.width,
h = config.height,
bg = config.background,
));
for (i, cell) in cells.iter().enumerate() {
if i >= n || cell.vertices.len() < 3 {
continue;
}
svg.push_str(&format!(
"<polygon points=\"{pts}\" fill=\"{color}\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\" \
stroke-linejoin=\"round\" data-idx=\"{idx}\"/>\n",
pts = cell.svg_points(),
color = color_order[i],
stroke = config.stroke,
sw = config.stroke_width,
idx = original_idx[i],
));
}
if config.circular {
svg.push_str("</g>\n");
}
svg.push_str("</svg>\n");
svg
}
fn render_grid(colors: &[&str], config: &SvgConfig) -> String {
let n = colors.len();
let cols = config.cols.max(1);
let rows = (n + cols - 1) / cols;
let cell_w = config.width / cols as f64;
let cell_h = config.height / rows as f64;
let mut svg = String::with_capacity(n * 150 + 300);
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
viewBox=\"0 0 {w} {h}\" width=\"{w}\" height=\"{h}\">\n",
w = config.width,
h = config.height,
));
svg.push_str(&format!(
"<rect width=\"{w}\" height=\"{h}\" fill=\"{bg}\"/>\n",
w = config.width,
h = config.height,
bg = config.background,
));
for (i, color) in colors.iter().enumerate() {
let col = i % cols;
let row = i / cols;
let x = col as f64 * cell_w;
let y = row as f64 * cell_h;
let inset = config.stroke_width / 2.0;
svg.push_str(&format!(
"<rect x=\"{x:.1}\" y=\"{y:.1}\" \
width=\"{w:.1}\" height=\"{h:.1}\" \
fill=\"{color}\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\" data-idx=\"{idx}\"/>\n",
x = x + inset,
y = y + inset,
w = cell_w - config.stroke_width,
h = cell_h - config.stroke_width,
color = color,
stroke = config.stroke,
sw = config.stroke_width,
idx = i,
));
}
svg.push_str("</svg>\n");
svg
}
fn render_constellation(colors: &[&str], config: &SvgConfig) -> String {
let n = colors.len();
let mut seeds = generate_seeds(n, config.width, config.height, config.seed);
lloyd_relax(&mut seeds, config.width, config.height, config.relax_iters);
let avg_spacing = (config.width * config.height / n as f64).sqrt();
let dot_r = avg_spacing * 0.3;
let mut svg = String::with_capacity(n * 120 + 300);
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
viewBox=\"0 0 {w} {h}\" width=\"{w}\" height=\"{h}\">\n",
w = config.width,
h = config.height,
));
svg.push_str(&format!(
"<rect width=\"{w}\" height=\"{h}\" fill=\"{bg}\"/>\n",
w = config.width,
h = config.height,
bg = config.background,
));
for (i, color) in colors.iter().enumerate() {
if i >= seeds.len() {
break;
}
svg.push_str(&format!(
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"{r:.1}\" fill=\"{color}\" data-idx=\"{idx}\"/>\n",
x = seeds[i].x,
y = seeds[i].y,
r = dot_r,
color = color,
idx = i,
));
}
svg.push_str("</svg>\n");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_voronoi_basic() {
let colors = vec!["#440255", "#2a788e", "#7ad151", "#fde725"];
let config = SvgConfig {
width: 100.0,
height: 100.0,
..Default::default()
};
let svg = render_svg(&colors, &config);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("#440255"));
assert!(svg.contains("<polygon"));
}
#[test]
fn test_render_grid_basic() {
let colors = vec!["#ff0000", "#00ff00", "#0000ff", "#ffff00"];
let config = SvgConfig {
layout: Layout::Grid,
cols: 2,
width: 100.0,
height: 100.0,
..Default::default()
};
let svg = render_svg(&colors, &config);
assert!(svg.contains("<rect"));
assert!(svg.contains("#ff0000"));
assert_eq!(svg.matches("<rect").count(), 5);
}
#[test]
fn test_render_constellation_basic() {
let colors = vec!["#440255", "#2a788e", "#7ad151"];
let config = SvgConfig {
layout: Layout::Constellation,
width: 100.0,
height: 100.0,
..Default::default()
};
let svg = render_svg(&colors, &config);
assert!(svg.contains("<circle"));
assert_eq!(svg.matches("<circle").count(), 3);
}
#[test]
fn test_render_voronoi_circular() {
let colors = vec!["#440255", "#2a788e"];
let config = SvgConfig {
circular: true,
width: 100.0,
height: 100.0,
..Default::default()
};
let svg = render_svg(&colors, &config);
assert!(svg.contains("clipPath"));
assert!(svg.contains("circle-clip"));
}
#[test]
fn test_render_deterministic() {
let colors = vec!["#440255", "#2a788e", "#7ad151", "#fde725"];
let config = SvgConfig::default();
let a = render_svg(&colors, &config);
let b = render_svg(&colors, &config);
assert_eq!(a, b, "Same inputs should produce identical SVG");
}
#[test]
fn test_render_voronoi_color_scatter() {
let colors = vec!["#ff0000", "#ff0000", "#00ff00", "#0000ff"];
let config = SvgConfig {
width: 100.0,
height: 100.0,
color_scatter: true,
..Default::default()
};
let svg = render_svg(&colors, &config);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("#ff0000"));
assert!(svg.contains("<polygon"));
assert_eq!(svg.matches("<polygon").count(), 4);
}
}