use crate::cluster::Cluster;
use crate::orb::adjust_saturation;
pub(crate) const STYLE_WIDTH: u32 = 1080;
pub(crate) const STYLE_HEIGHT: u32 = 1920;
#[derive(Debug, Clone)]
pub struct StyleOptions {
pub orb_size: f32,
pub blur: f32,
pub saturation: f32,
pub background: [u8; 4],
}
impl Default for StyleOptions {
fn default() -> Self {
Self {
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
background: [0, 0, 0, 255],
}
}
}
pub fn render_svg(clusters: &[Cluster], opts: &StyleOptions) -> String {
let blur = opts.blur.clamp(0.0, 1.0);
let saturation = opts.saturation.max(0.0);
let orb_size = opts.orb_size.max(0.0);
let width = STYLE_WIDTH as f32;
let height = STYLE_HEIGHT as f32;
let base_radius_unit = width.min(height) * 0.25 * orb_size;
let mid_pct = ((1.0 - blur * 0.8).clamp(0.05, 0.95) * 100.0).round() as i32;
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {STYLE_WIDTH} {STYLE_HEIGHT}\" width=\"{STYLE_WIDTH}\" height=\"{STYLE_HEIGHT}\">\n"
));
let [bg_r, bg_g, bg_b, bg_a] = opts.background;
if bg_a > 0 {
if bg_a == 255 {
s.push_str(&format!(
" <rect width=\"100%\" height=\"100%\" fill=\"rgb({bg_r},{bg_g},{bg_b})\"/>\n"
));
} else {
let opacity = bg_a as f32 / 255.0;
s.push_str(&format!(
" <rect width=\"100%\" height=\"100%\" fill=\"rgb({bg_r},{bg_g},{bg_b})\" fill-opacity=\"{opacity:.3}\"/>\n"
));
}
}
let visible: Vec<(usize, &Cluster, f32)> = clusters
.iter()
.enumerate()
.filter_map(|(i, c)| {
let w = c.weight.max(0.0);
let r = base_radius_unit * w.sqrt();
if r > 0.0 {
Some((i, c, r))
} else {
None
}
})
.collect();
s.push_str(" <defs>\n");
for (i, cluster, _) in &visible {
let [r, g, b] = adjust_saturation(cluster.color, saturation);
s.push_str(&format!(
" <radialGradient id=\"orb-{i}\" cx=\"50%\" cy=\"50%\" r=\"50%\">\n"
));
s.push_str(&format!(
" <stop offset=\"0%\" stop-color=\"rgb({r},{g},{b})\" stop-opacity=\"1\"/>\n"
));
s.push_str(&format!(
" <stop offset=\"{mid_pct}%\" stop-color=\"rgb({r},{g},{b})\" stop-opacity=\"0.5\"/>\n"
));
s.push_str(&format!(
" <stop offset=\"100%\" stop-color=\"rgb({r},{g},{b})\" stop-opacity=\"0\"/>\n"
));
s.push_str(" </radialGradient>\n");
}
s.push_str(" </defs>\n");
for (i, cluster, radius) in &visible {
let cx = (cluster.centroid.x.clamp(0.0, 1.0) * width).round() as i32;
let cy = (cluster.centroid.y.clamp(0.0, 1.0) * height).round() as i32;
let r_px = radius.round() as i32;
s.push_str(&format!(
" <circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r_px}\" fill=\"url(#orb-{i})\"/>\n"
));
}
s.push_str("</svg>\n");
s
}
pub fn render_css(clusters: &[Cluster], opts: &StyleOptions) -> String {
let blur = opts.blur.clamp(0.0, 1.0);
let saturation = opts.saturation.max(0.0);
let orb_size = opts.orb_size.max(0.0);
let mid_factor = (1.0 - blur * 0.8).clamp(0.05, 0.95);
let [bg_r, bg_g, bg_b, bg_a] = opts.background;
let bg_css = if bg_a == 0 {
"transparent".to_string()
} else if bg_a == 255 {
format!("rgb({bg_r}, {bg_g}, {bg_b})")
} else {
let opacity = bg_a as f32 / 255.0;
format!("rgba({bg_r}, {bg_g}, {bg_b}, {opacity:.3})")
};
let mut s = String::new();
s.push_str("/* orber-generated background.\n");
s.push_str(" Apply to <body> or any block element:\n");
s.push_str(" body {\n");
s.push_str(" margin: 0;\n");
s.push_str(" min-height: 100vh;\n");
s.push_str(" background-color: var(--orber-bg-color);\n");
s.push_str(" background-image: var(--orber-bg);\n");
s.push_str(" }\n");
s.push_str(" Generated as CSS variables; reference with var(--orber-bg) and var(--orber-bg-color). */\n");
s.push_str(":root {\n");
s.push_str(&format!(" --orber-bg-color: {bg_css};\n"));
let mut gradients: Vec<String> = Vec::new();
for cluster in clusters {
let weight = cluster.weight.max(0.0);
if weight <= 0.0 {
continue;
}
let [r, g, b] = adjust_saturation(cluster.color, saturation);
let x = (cluster.centroid.x.clamp(0.0, 1.0) * 100.0).round() as i32;
let y = (cluster.centroid.y.clamp(0.0, 1.0) * 100.0).round() as i32;
let end_f = (weight.sqrt() * 30.0 * orb_size).clamp(2.0, 100.0);
let end_pct = end_f.round() as i32;
let mid_pct = (end_f * mid_factor).round() as i32;
let mid_pct = mid_pct.clamp(0, end_pct - 1);
gradients.push(format!(
"radial-gradient(circle at {x}% {y}%, rgba({r},{g},{b},1) 0%, rgba({r},{g},{b},0.5) {mid_pct}%, rgba({r},{g},{b},0) {end_pct}%)"
));
}
if gradients.is_empty() {
s.push_str(" --orber-bg: none;\n");
} else {
s.push_str(" --orber-bg:\n");
for (i, g) in gradients.iter().enumerate() {
s.push_str(" ");
s.push_str(g);
if i + 1 < gradients.len() {
s.push_str(",\n");
} else {
s.push_str(";\n");
}
}
}
s.push_str("}\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::{Centroid, Cluster};
fn cluster(color: [u8; 3], cx: f32, cy: f32, weight: f32) -> Cluster {
Cluster {
color,
centroid: Centroid { x: cx, y: cy },
weight,
}
}
fn six_clusters() -> Vec<Cluster> {
vec![
cluster([200, 100, 50], 0.1, 0.1, 0.3),
cluster([50, 200, 100], 0.5, 0.2, 0.2),
cluster([100, 50, 200], 0.9, 0.3, 0.15),
cluster([255, 255, 0], 0.2, 0.7, 0.15),
cluster([0, 255, 255], 0.5, 0.8, 0.1),
cluster([255, 0, 255], 0.8, 0.9, 0.1),
]
}
#[test]
fn svg_contains_expected_elements() {
let svg = render_svg(&six_clusters(), &StyleOptions::default());
assert_eq!(svg.matches("<radialGradient").count(), 6);
assert_eq!(svg.matches("<circle ").count(), 6);
assert!(svg.contains("viewBox=\"0 0 1080 1920\""));
assert!(svg.contains("<rect width=\"100%\" height=\"100%\" fill=\"rgb(0,0,0)\""));
assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
assert!(svg.ends_with("</svg>\n"));
}
#[test]
fn css_contains_expected_gradients() {
let css = render_css(&six_clusters(), &StyleOptions::default());
assert_eq!(css.matches("radial-gradient(").count(), 6);
assert!(css.contains("--orber-bg:"));
let semi = css.find(";\n}").expect("CSS must end with ;\\n}");
let before_semi: char = css[..semi].chars().next_back().unwrap();
assert_eq!(
before_semi, ')',
"last char before ';' must be ')', got {before_semi:?}"
);
}
#[test]
fn deterministic_svg() {
let clusters = six_clusters();
let opts = StyleOptions::default();
let a = render_svg(&clusters, &opts);
let b = render_svg(&clusters, &opts);
assert_eq!(a, b);
}
#[test]
fn deterministic_css() {
let clusters = six_clusters();
let opts = StyleOptions::default();
let a = render_css(&clusters, &opts);
let b = render_css(&clusters, &opts);
assert_eq!(a, b);
}
#[test]
fn saturation_zero_grays_colors_svg() {
let opts = StyleOptions {
saturation: 0.0,
..StyleOptions::default()
};
let svg = render_svg(&six_clusters(), &opts);
let mut found_gray = false;
for line in svg.lines() {
if let Some(start) = line.find("rgb(") {
let rest = &line[start + 4..];
if let Some(end) = rest.find(')') {
let nums = &rest[..end];
let parts: Vec<&str> = nums.split(',').collect();
if parts.len() == 3 {
let r: i32 = parts[0].trim().parse().unwrap();
let g: i32 = parts[1].trim().parse().unwrap();
let b: i32 = parts[2].trim().parse().unwrap();
if (r - g).abs() <= 1 && (g - b).abs() <= 1 && (r - b).abs() <= 1 {
found_gray = true;
break;
}
}
}
}
}
assert!(found_gray, "saturation=0 should produce a grayscale stop");
}
fn extract_css_stops(css: &str) -> Vec<(i32, i32, i32)> {
fn read_pct_after(hay: &str, marker: &str, from: usize) -> Option<(i32, usize)> {
let pos = hay[from..].find(marker)? + from;
let after = pos + marker.len();
let bytes = hay.as_bytes();
let mut i = after;
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == start || i >= bytes.len() || bytes[i] != b'%' {
return None;
}
let n = hay[start..i].parse::<i32>().ok()?;
Some((n, i + 1))
}
let mut out = Vec::new();
let mut cursor = 0usize;
while let Some(rel) = css[cursor..].find("radial-gradient(") {
let base = cursor + rel + "radial-gradient(".len();
let (s0, p1) = match read_pct_after(css, ",1) ", base) {
Some(v) => v,
None => break,
};
let (mid, p2) = match read_pct_after(css, ",0.5) ", p1) {
Some(v) => v,
None => break,
};
let (end, p3) = match read_pct_after(css, ",0) ", p2) {
Some(v) => v,
None => break,
};
out.push((s0, mid, end));
cursor = p3;
}
out
}
#[test]
fn css_stops_strictly_monotonic_default() {
let css = render_css(&six_clusters(), &StyleOptions::default());
let stops = extract_css_stops(&css);
assert_eq!(stops.len(), 6);
for (s0, m, e) in stops {
assert_eq!(s0, 0, "first stop must be 0%");
assert!(m < e, "mid ({m}) < end ({e}) must hold");
assert!(m >= 0, "mid ({m}) must be >= 0");
}
}
#[test]
fn css_stops_strictly_monotonic_boundary_values() {
let extreme_clusters = vec![
cluster([200, 100, 50], 0.5, 0.5, 1.0), cluster([50, 200, 100], 0.5, 0.5, 0.001), ];
for blur in [0.0_f32, 0.5, 1.0] {
for orb_size in [0.1_f32, 1.0, 2.0, 4.0] {
let opts = StyleOptions {
orb_size,
blur,
saturation: 1.0,
..Default::default()
};
let css = render_css(&extreme_clusters, &opts);
let stops = extract_css_stops(&css);
assert!(!stops.is_empty(), "blur={blur} orb_size={orb_size}");
for (s0, m, e) in &stops {
assert_eq!(*s0, 0);
assert!(
m < e,
"monotonic stops violated at blur={blur} orb_size={orb_size}: 0/{m}/{e}"
);
}
}
}
}
#[test]
fn empty_clusters_produces_valid_svg() {
let svg = render_svg(&[], &StyleOptions::default());
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("<rect")); assert_eq!(svg.matches("<radialGradient").count(), 0);
assert_eq!(svg.matches("<circle ").count(), 0);
let css = render_css(&[], &StyleOptions::default());
assert!(css.contains("--orber-bg: none"));
assert_eq!(css.matches("radial-gradient(").count(), 0);
}
}