use super::{
ButterflyDiagram, CartesianCurve, CartesianDiagram, ConeDiagram, DiagramScale,
FloodlightCartesianDiagram, HeatmapDiagram, IsocandelaDiagram, IsoluxDiagram, PolarDiagram,
YScale,
};
use crate::units::UnitSystem;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DetailLevel {
Full,
#[default]
Standard,
Compact,
Minimal,
}
impl DetailLevel {
pub fn from_width(width: f64) -> Self {
if width >= 500.0 {
Self::Full
} else if width >= 400.0 {
Self::Standard
} else if width >= 300.0 {
Self::Compact
} else {
Self::Minimal
}
}
pub fn font_scale(&self) -> f64 {
match self {
Self::Full => 1.0,
Self::Standard => 1.0,
Self::Compact => 0.85,
Self::Minimal => 0.75,
}
}
pub fn show_legend(&self) -> bool {
!matches!(self, Self::Minimal)
}
pub fn show_axis_labels(&self) -> bool {
!matches!(self, Self::Minimal)
}
pub fn show_summary(&self) -> bool {
matches!(self, Self::Full | Self::Standard)
}
pub fn grid_divisions(&self) -> usize {
match self {
Self::Full => 5,
Self::Standard => 5,
Self::Compact => 4,
Self::Minimal => 3,
}
}
pub fn angle_label_step(&self) -> f64 {
match self {
Self::Full => 30.0,
Self::Standard => 30.0,
Self::Compact => 45.0,
Self::Minimal => 90.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SvgLabels {
pub intensity_unit: String,
pub gamma_axis: String,
pub intensity_axis: String,
pub plane_c0_c180: String,
pub plane_c90_c270: String,
pub beam: String,
pub field: String,
pub beam_50_percent: String,
pub field_10_percent: String,
pub cie_label: String,
pub efficacy_label: String,
pub max_label: String,
pub sh_ratio_label: String,
pub c_plane_axis: String,
pub gamma_angle_axis: String,
pub heatmap_title: String,
pub no_data: String,
pub bug_forward_light: String,
pub bug_back_light: String,
pub bug_uplight: String,
pub bug_total: String,
pub bug_sum: String,
pub bug_zone_low: String,
pub bug_zone_medium: String,
pub bug_zone_high: String,
pub bug_zone_very_high: String,
}
impl Default for SvgLabels {
fn default() -> Self {
Self::english()
}
}
impl SvgLabels {
pub fn english() -> Self {
Self {
intensity_unit: "cd/1000lm".to_string(),
gamma_axis: "Gamma (γ)".to_string(),
intensity_axis: "Intensity (cd/klm)".to_string(),
plane_c0_c180: "C0-C180".to_string(),
plane_c90_c270: "C90-C270".to_string(),
beam: "Beam".to_string(),
field: "Field".to_string(),
beam_50_percent: "Beam 50%".to_string(),
field_10_percent: "Field 10%".to_string(),
cie_label: "CIE:".to_string(),
efficacy_label: "Eff:".to_string(),
max_label: "Max:".to_string(),
sh_ratio_label: "S/H:".to_string(),
c_plane_axis: "C-Plane Angle (°)".to_string(),
gamma_angle_axis: "Gamma Angle (°)".to_string(),
heatmap_title: "Intensity Heatmap (Candela)".to_string(),
no_data: "No data".to_string(),
bug_forward_light: "Forward Light".to_string(),
bug_back_light: "Back Light".to_string(),
bug_uplight: "Uplight".to_string(),
bug_total: "Total".to_string(),
bug_sum: "Sum".to_string(),
bug_zone_low: "Low".to_string(),
bug_zone_medium: "Medium".to_string(),
bug_zone_high: "High".to_string(),
bug_zone_very_high: "Very High".to_string(),
}
}
#[cfg(feature = "i18n")]
pub fn from_locale(locale: &eulumdat_i18n::Locale) -> Self {
Self {
intensity_unit: locale.diagram.units.intensity.clone(),
gamma_axis: locale.diagram.axis.gamma.clone(),
intensity_axis: locale.diagram.axis.intensity.clone(),
plane_c0_c180: locale.diagram.plane.c0_c180.clone(),
plane_c90_c270: locale.diagram.plane.c90_c270.clone(),
beam: locale.diagram.angle.beam.clone(),
field: locale.diagram.angle.field.clone(),
beam_50_percent: locale.diagram.angle.beam_50.clone(),
field_10_percent: locale.diagram.angle.field_10.clone(),
cie_label: locale.diagram.metrics.cie.clone(),
efficacy_label: locale.diagram.metrics.efficacy.clone(),
max_label: locale.diagram.metrics.max.clone(),
sh_ratio_label: locale.diagram.metrics.sh_ratio.clone(),
c_plane_axis: locale.diagram.axis.c_plane.clone(),
gamma_angle_axis: locale.diagram.axis.gamma_angle.clone(),
heatmap_title: locale.diagram.title.heatmap.clone(),
no_data: locale.diagram.placeholder.no_data.clone(),
bug_forward_light: locale.diagram.bug.forward_light.clone(),
bug_back_light: locale.diagram.bug.back_light.clone(),
bug_uplight: locale.diagram.bug.uplight.clone(),
bug_total: locale.diagram.bug.total.clone(),
bug_sum: locale.diagram.bug.sum.clone(),
bug_zone_low: locale.diagram.bug.zone_low.clone(),
bug_zone_medium: locale.diagram.bug.zone_medium.clone(),
bug_zone_high: locale.diagram.bug.zone_high.clone(),
bug_zone_very_high: locale.diagram.bug.zone_very_high.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SvgTheme {
pub background: String,
pub surface: String,
pub grid: String,
pub axis: String,
pub text: String,
pub text_secondary: String,
pub legend_bg: String,
pub curve_c0_c180: String,
pub curve_c0_c180_fill: String,
pub curve_c90_c270: String,
pub curve_c90_c270_fill: String,
pub font_family: String,
pub labels: SvgLabels,
}
impl Default for SvgTheme {
fn default() -> Self {
Self::light()
}
}
impl SvgTheme {
pub fn light() -> Self {
Self {
background: "#ffffff".to_string(),
surface: "#f8fafc".to_string(),
grid: "#e2e8f0".to_string(),
axis: "#94a3b8".to_string(),
text: "#1e293b".to_string(),
text_secondary: "#64748b".to_string(),
legend_bg: "rgba(255,255,255,0.9)".to_string(),
curve_c0_c180: "#3b82f6".to_string(),
curve_c0_c180_fill: "rgba(59,130,246,0.15)".to_string(),
curve_c90_c270: "#ef4444".to_string(),
curve_c90_c270_fill: "rgba(239,68,68,0.15)".to_string(),
font_family: "system-ui, -apple-system, sans-serif".to_string(),
labels: SvgLabels::default(),
}
}
pub fn dark() -> Self {
Self {
background: "#0f172a".to_string(),
surface: "#1e293b".to_string(),
grid: "#334155".to_string(),
axis: "#64748b".to_string(),
text: "#f1f5f9".to_string(),
text_secondary: "#94a3b8".to_string(),
legend_bg: "rgba(30,41,59,0.9)".to_string(),
curve_c0_c180: "#60a5fa".to_string(),
curve_c0_c180_fill: "rgba(96,165,250,0.2)".to_string(),
curve_c90_c270: "#f87171".to_string(),
curve_c90_c270_fill: "rgba(248,113,113,0.2)".to_string(),
font_family: "system-ui, -apple-system, sans-serif".to_string(),
labels: SvgLabels::default(),
}
}
pub fn css_variables() -> Self {
Self {
background: "var(--diagram-bg, #ffffff)".to_string(),
surface: "var(--diagram-surface, #f8fafc)".to_string(),
grid: "var(--diagram-grid, #e2e8f0)".to_string(),
axis: "var(--diagram-axis, #94a3b8)".to_string(),
text: "var(--diagram-text, #1e293b)".to_string(),
text_secondary: "var(--diagram-text-secondary, #64748b)".to_string(),
legend_bg: "var(--diagram-legend-bg, rgba(255,255,255,0.9))".to_string(),
curve_c0_c180: "var(--diagram-c90, #3b82f6)".to_string(),
curve_c0_c180_fill: "var(--diagram-c90-fill, rgba(59,130,246,0.15))".to_string(),
curve_c90_c270: "var(--diagram-c0, #ef4444)".to_string(),
curve_c90_c270_fill: "var(--diagram-c0-fill, rgba(239,68,68,0.15))".to_string(),
font_family: "system-ui, -apple-system, sans-serif".to_string(),
labels: SvgLabels::default(),
}
}
pub fn with_labels(mut self, labels: SvgLabels) -> Self {
self.labels = labels;
self
}
#[cfg(feature = "i18n")]
pub fn light_with_locale(locale: &eulumdat_i18n::Locale) -> Self {
Self::light().with_labels(SvgLabels::from_locale(locale))
}
#[cfg(feature = "i18n")]
pub fn dark_with_locale(locale: &eulumdat_i18n::Locale) -> Self {
Self::dark().with_labels(SvgLabels::from_locale(locale))
}
#[cfg(feature = "i18n")]
pub fn css_variables_with_locale(locale: &eulumdat_i18n::Locale) -> Self {
Self::css_variables().with_labels(SvgLabels::from_locale(locale))
}
pub fn c_plane_color(&self, index: usize) -> &str {
const COLORS: &[&str] = &[
"#3b82f6", "#ef4444", "#22c55e", "#f97316", "#8b5cf6", "#ec4899", "#06b6d4", "#eab308", ];
COLORS[index % COLORS.len()]
}
}
impl PolarDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
let size = width.min(height);
let center = size / 2.0;
let margin = 60.0;
let radius = (size / 2.0) - margin;
let scale = self.scale.scale_max / radius;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{size}" height="{size}" fill="{}"/>"#,
theme.background
));
let num_circles = self.scale.grid_values.len();
for (i, &value) in self.scale.grid_values.iter().enumerate() {
let r = value / scale;
let is_major = i == num_circles - 1 || i == num_circles / 2;
let stroke_color = if is_major { &theme.axis } else { &theme.grid };
let stroke_width = if is_major { "1.5" } else { "1" };
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{r:.1}" fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}"/>"#
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" fill="{}" font-family="{}">{:.0}</text>"#,
center + 5.0,
center + r + 12.0,
theme.text_secondary,
theme.font_family,
value
));
}
for i in 0..=6 {
if i == 3 {
continue; }
let angle_deg = i as f64 * 30.0;
let angle_rad = angle_deg.to_radians();
let x1_left = center - radius * angle_rad.sin();
let y1_left = center + radius * angle_rad.cos();
let x1_right = center + radius * angle_rad.sin();
let y1_right = center + radius * angle_rad.cos();
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1_left:.1}" y2="{y1_left:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1_right:.1}" y2="{y1_right:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
if angle_deg > 0.0 && angle_deg < 180.0 {
let label_offset = radius + 18.0;
let label_x_left = center - label_offset * angle_rad.sin();
let label_y_left = center + label_offset * angle_rad.cos();
let label_x_right = center + label_offset * angle_rad.sin();
let label_y_right = center + label_offset * angle_rad.cos();
svg.push_str(&format!(
r#"<text x="{label_x_left:.1}" y="{label_y_left:.1}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{angle_deg:.0}°</text>"#,
theme.text_secondary, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{label_x_right:.1}" y="{label_y_right:.1}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{angle_deg:.0}°</text>"#,
theme.text_secondary, theme.font_family
));
}
}
svg.push_str(&format!(
r#"<text x="{center}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}">180°</text>"#,
center - radius - 20.0,
theme.text_secondary,
theme.font_family
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{center}" x2="{:.1}" y2="{center}" stroke="{}" stroke-width="1.5"/>"#,
center - radius,
center + radius,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{center}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">90°</text>"#,
center - radius - 20.0,
theme.text_secondary,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{center}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">90°</text>"#,
center + radius + 20.0,
theme.text_secondary,
theme.font_family
));
let path_c0_c180 = self.c0_c180_curve.to_svg_path(center, center, scale);
if !path_c0_c180.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5"/>"#,
path_c0_c180, theme.curve_c0_c180_fill, theme.curve_c0_c180
));
}
if self.show_c90_c270() {
let path_c90_c270 = self.c90_c270_curve.to_svg_path(center, center, scale);
if !path_c90_c270.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5" stroke-dasharray="6,4"/>"#,
path_c90_c270,
theme.curve_c90_c270_fill,
theme.curve_c90_c270
));
}
}
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="3" fill="{}"/>"#,
theme.text
));
svg.push_str(&format!(
r#"<g transform="translate(15, {:.1})">"#,
size - 55.0
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="16" height="16" fill="{}" stroke="{}" stroke-width="2" rx="2"/>"#,
theme.curve_c0_c180_fill,
theme.curve_c0_c180
));
svg.push_str(&format!(
r#"<text x="22" y="12" font-size="12" fill="{}" font-family="{}">{}</text>"#,
theme.text, theme.font_family, theme.labels.plane_c0_c180
));
svg.push_str("</g>");
if self.show_c90_c270() {
svg.push_str(&format!(
r#"<g transform="translate(15, {:.1})">"#,
size - 32.0
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="16" height="16" fill="{}" stroke="{}" stroke-width="2" stroke-dasharray="4,2" rx="2"/>"#,
theme.curve_c90_c270_fill,
theme.curve_c90_c270
));
svg.push_str(&format!(
r#"<text x="22" y="12" font-size="12" fill="{}" font-family="{}">{}</text>"#,
theme.text, theme.font_family, theme.labels.plane_c90_c270
));
svg.push_str("</g>");
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="11" fill="{}" font-family="{}">{}</text>"#,
size - 15.0,
size - 15.0,
theme.text_secondary,
theme.font_family,
theme.labels.intensity_unit
));
svg.push_str("</svg>");
svg
}
pub fn to_svg_responsive(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
detail: DetailLevel,
) -> String {
let size = width.min(height);
let center = size / 2.0;
let margin = match detail {
DetailLevel::Full | DetailLevel::Standard => 60.0,
DetailLevel::Compact => 50.0,
DetailLevel::Minimal => 40.0,
};
let radius = (size / 2.0) - margin;
let scale = self.scale.scale_max / radius;
let font_scale = detail.font_scale();
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg" class="diagram-polar" preserveAspectRatio="xMidYMid meet">"#
));
svg.push_str(&format!(
r#"<style>
.diagram-label {{ font-family: {}; }}
.detail-compact, .detail-full {{ display: block; }}
@media (max-width: 400px) {{ .detail-full {{ display: none; }} }}
@media (max-width: 300px) {{ .detail-compact, .detail-full {{ display: none; }} }}
</style>"#,
theme.font_family
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{size}" height="{size}" fill="{}"/>"#,
theme.background
));
let grid_step = match detail {
DetailLevel::Full | DetailLevel::Standard => 1,
DetailLevel::Compact => 2,
DetailLevel::Minimal => 2,
};
let num_circles = self.scale.grid_values.len();
for (i, &value) in self.scale.grid_values.iter().enumerate() {
if i % grid_step != 0 && i != num_circles - 1 {
continue;
}
let r = value / scale;
let is_major = i == num_circles - 1 || i == num_circles / 2;
let stroke_color = if is_major { &theme.axis } else { &theme.grid };
let stroke_width = if is_major { "1.5" } else { "1" };
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{r:.1}" fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}"/>"#
));
if detail.show_axis_labels() {
let class = if i == num_circles - 1 {
""
} else {
r#" class="detail-compact""#
};
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="{:.0}" fill="{}"{class}>{:.0}</text>"#,
center + 5.0,
center + r + 12.0,
11.0 * font_scale,
theme.text_secondary,
value
));
}
}
let angle_step = detail.angle_label_step() as usize / 30;
for i in 0..=6 {
if i == 3 {
continue; }
let angle_deg = i as f64 * 30.0;
let angle_rad = angle_deg.to_radians();
let x1_left = center - radius * angle_rad.sin();
let y1_left = center + radius * angle_rad.cos();
let x1_right = center + radius * angle_rad.sin();
let y1_right = center + radius * angle_rad.cos();
let draw_line = i % angle_step == 0 || matches!(detail, DetailLevel::Full);
if draw_line {
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1_left:.1}" y2="{y1_left:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1_right:.1}" y2="{y1_right:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
}
if detail.show_axis_labels() && angle_deg > 0.0 && angle_deg < 180.0 {
let show_this_label = (i * 30) as f64 % detail.angle_label_step() == 0.0;
if show_this_label {
let label_offset = radius + 18.0;
let label_x_left = center - label_offset * angle_rad.sin();
let label_y_left = center + label_offset * angle_rad.cos();
let label_x_right = center + label_offset * angle_rad.sin();
let label_y_right = center + label_offset * angle_rad.cos();
svg.push_str(&format!(
r#"<text x="{label_x_left:.1}" y="{label_y_left:.1}" text-anchor="middle" dominant-baseline="middle" font-size="{:.0}" fill="{}">{angle_deg:.0}°</text>"#,
11.0 * font_scale, theme.text_secondary
));
svg.push_str(&format!(
r#"<text x="{label_x_right:.1}" y="{label_y_right:.1}" text-anchor="middle" dominant-baseline="middle" font-size="{:.0}" fill="{}">{angle_deg:.0}°</text>"#,
11.0 * font_scale, theme.text_secondary
));
}
}
}
if detail.show_axis_labels() {
svg.push_str(&format!(
r#"<text x="{center}" y="{:.1}" text-anchor="middle" font-size="{:.0}" fill="{}" class="detail-compact">180°</text>"#,
center - radius - 15.0,
11.0 * font_scale,
theme.text_secondary
));
}
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{center}" x2="{:.1}" y2="{center}" stroke="{}" stroke-width="1.5"/>"#,
center - radius,
center + radius,
theme.axis
));
if detail.show_axis_labels() {
svg.push_str(&format!(
r#"<text x="{:.1}" y="{center}" text-anchor="middle" dominant-baseline="middle" font-size="{:.0}" fill="{}">90°</text>"#,
center - radius - 15.0,
11.0 * font_scale,
theme.text_secondary
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{center}" text-anchor="middle" dominant-baseline="middle" font-size="{:.0}" fill="{}">90°</text>"#,
center + radius + 15.0,
11.0 * font_scale,
theme.text_secondary
));
}
let path_c0_c180 = self.c0_c180_curve.to_svg_path(center, center, scale);
if !path_c0_c180.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5"/>"#,
path_c0_c180, theme.curve_c0_c180_fill, theme.curve_c0_c180
));
}
if self.show_c90_c270() {
let path_c90_c270 = self.c90_c270_curve.to_svg_path(center, center, scale);
if !path_c90_c270.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5" stroke-dasharray="6,4"/>"#,
path_c90_c270,
theme.curve_c90_c270_fill,
theme.curve_c90_c270
));
}
}
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="3" fill="{}"/>"#,
theme.text
));
if detail.show_legend() {
svg.push_str(&format!(
r#"<g transform="translate(15, {:.1})" class="detail-compact">"#,
size - 55.0
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="16" height="16" fill="{}" stroke="{}" stroke-width="2" rx="2"/>"#,
theme.curve_c0_c180_fill,
theme.curve_c0_c180
));
svg.push_str(&format!(
r#"<text x="22" y="12" font-size="{:.0}" fill="{}">{}</text>"#,
12.0 * font_scale,
theme.text,
theme.labels.plane_c0_c180
));
svg.push_str("</g>");
if self.show_c90_c270() {
svg.push_str(&format!(
r#"<g transform="translate(15, {:.1})" class="detail-compact">"#,
size - 32.0
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="16" height="16" fill="{}" stroke="{}" stroke-width="2" stroke-dasharray="4,2" rx="2"/>"#,
theme.curve_c90_c270_fill,
theme.curve_c90_c270
));
svg.push_str(&format!(
r#"<text x="22" y="12" font-size="{:.0}" fill="{}">{}</text>"#,
12.0 * font_scale,
theme.text,
theme.labels.plane_c90_c270
));
svg.push_str("</g>");
}
}
if detail.show_axis_labels() {
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="{:.0}" fill="{}" class="detail-compact">{}</text>"#,
size - 15.0,
size - 15.0,
11.0 * font_scale,
theme.text_secondary,
theme.labels.intensity_unit
));
}
svg.push_str("</svg>");
svg
}
pub fn to_svg_with_summary(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
summary: &crate::calculations::PhotometricSummary,
) -> String {
let size = width.min(height);
let center = size / 2.0;
let margin = 60.0;
let radius = (size / 2.0) - margin;
let scale = self.scale.scale_max / radius;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{size}" height="{size}" fill="{}"/>"#,
theme.background
));
let num_circles = self.scale.grid_values.len();
for (i, &value) in self.scale.grid_values.iter().enumerate() {
let r = value / scale;
let is_major = i == num_circles - 1 || i == num_circles / 2;
let stroke_color = if is_major { &theme.axis } else { &theme.grid };
let stroke_width = if is_major { "1.5" } else { "1" };
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{r:.1}" fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}"/>"#
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" fill="{}" font-family="{}">{:.0}</text>"#,
center + 5.0,
center + r + 12.0,
theme.text_secondary,
theme.font_family,
value
));
}
for i in 0..=6 {
if i == 3 {
continue;
}
let angle_deg = i as f64 * 30.0;
let angle_rad = angle_deg.to_radians();
let x1_left = center - radius * angle_rad.sin();
let y1_left = center + radius * angle_rad.cos();
let x1_right = center + radius * angle_rad.sin();
let y1_right = center + radius * angle_rad.cos();
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1_left:.1}" y2="{y1_left:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1_right:.1}" y2="{y1_right:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
if angle_deg > 0.0 && angle_deg < 180.0 {
let label_offset = radius + 18.0;
let label_x_left = center - label_offset * angle_rad.sin();
let label_y_left = center + label_offset * angle_rad.cos();
let label_x_right = center + label_offset * angle_rad.sin();
let label_y_right = center + label_offset * angle_rad.cos();
svg.push_str(&format!(
r#"<text x="{label_x_left:.1}" y="{label_y_left:.1}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{angle_deg:.0}°</text>"#,
theme.text_secondary, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{label_x_right:.1}" y="{label_y_right:.1}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{angle_deg:.0}°</text>"#,
theme.text_secondary, theme.font_family
));
}
}
svg.push_str(&format!(
r#"<text x="{center}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}">180°</text>"#,
center - radius - 20.0,
theme.text_secondary,
theme.font_family
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{center}" x2="{:.1}" y2="{center}" stroke="{}" stroke-width="1.5"/>"#,
center - radius,
center + radius,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{center}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">90°</text>"#,
center - radius - 20.0,
theme.text_secondary,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{center}" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">90°</text>"#,
center + radius + 20.0,
theme.text_secondary,
theme.font_family
));
let green = "#22c55e"; let orange = "#f97316"; let blue = "#3b82f6";
let half_beam = summary.beam_angle / 2.0;
if half_beam > 0.0 && half_beam < 90.0 {
let beam_rad = half_beam.to_radians();
let arc_radius = radius * 0.85;
let x1 = center - arc_radius * beam_rad.sin();
let y1 = center + arc_radius * beam_rad.cos();
let x2 = center + arc_radius * beam_rad.sin();
let y2 = center + arc_radius * beam_rad.cos();
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8"/>"#,
center, center, x1, y1, green
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8"/>"#,
center, center, x2, y2, green
));
svg.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="4" fill="{}" opacity="0.8"/>"#,
x1, y1, green
));
svg.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="4" fill="{}" opacity="0.8"/>"#,
x2, y2, green
));
}
let half_field = summary.field_angle / 2.0;
if half_field > 0.0 && half_field < 90.0 {
let field_rad = half_field.to_radians();
let arc_radius = radius * 0.9;
let x1 = center - arc_radius * field_rad.sin();
let y1 = center + arc_radius * field_rad.cos();
let x2 = center + arc_radius * field_rad.sin();
let y2 = center + arc_radius * field_rad.cos();
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="2,3" opacity="0.7"/>"#,
center, center, x1, y1, orange
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="2,3" opacity="0.7"/>"#,
center, center, x2, y2, orange
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="6" height="6" fill="{}" opacity="0.7" transform="rotate(45 {} {})"/>"#,
x1 - 3.0, y1 - 3.0, orange, x1, y1
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="6" height="6" fill="{}" opacity="0.7" transform="rotate(45 {} {})"/>"#,
x2 - 3.0, y2 - 3.0, orange, x2, y2
));
}
let half_cie_beam = summary.beam_angle_cie / 2.0;
if summary.is_batwing && half_cie_beam > 0.0 && half_cie_beam < 90.0 {
let cie_rad = half_cie_beam.to_radians();
let arc_radius = radius * 0.80; let x1 = center - arc_radius * cie_rad.sin();
let y1 = center + arc_radius * cie_rad.cos();
let x2 = center + arc_radius * cie_rad.sin();
let y2 = center + arc_radius * cie_rad.cos();
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" opacity="0.8"/>"#,
center, center, x1, y1, blue
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" opacity="0.8"/>"#,
center, center, x2, y2, blue
));
svg.push_str(&format!(
r#"<polygon points="{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}" fill="{}" opacity="0.8"/>"#,
x1, y1 - 5.0, x1 - 4.0, y1 + 3.0, x1 + 4.0, y1 + 3.0, blue
));
svg.push_str(&format!(
r#"<polygon points="{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}" fill="{}" opacity="0.8"/>"#,
x2, y2 - 5.0, x2 - 4.0, y2 + 3.0, x2 + 4.0, y2 + 3.0, blue
));
}
let purple = "#a855f7"; let half_upward_beam = summary.upward_beam_angle / 2.0;
if half_upward_beam > 0.0 && half_upward_beam < 90.0 {
let upward_rad = half_upward_beam.to_radians();
let arc_radius = radius * 0.85;
let x1 = center - arc_radius * upward_rad.sin();
let y1 = center - arc_radius * upward_rad.cos(); let x2 = center + arc_radius * upward_rad.sin();
let y2 = center - arc_radius * upward_rad.cos();
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8"/>"#,
center, center, x1, y1, purple
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8"/>"#,
center, center, x2, y2, purple
));
svg.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="4" fill="{}" opacity="0.8"/>"#,
x1, y1, purple
));
svg.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="4" fill="{}" opacity="0.8"/>"#,
x2, y2, purple
));
}
let pink = "#ec4899"; let half_upward_field = summary.upward_field_angle / 2.0;
if half_upward_field > 0.0 && half_upward_field < 90.0 {
let upward_field_rad = half_upward_field.to_radians();
let arc_radius = radius * 0.9;
let x1 = center - arc_radius * upward_field_rad.sin();
let y1 = center - arc_radius * upward_field_rad.cos();
let x2 = center + arc_radius * upward_field_rad.sin();
let y2 = center - arc_radius * upward_field_rad.cos();
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="2,3" opacity="0.7"/>"#,
center, center, x1, y1, pink
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1.5" stroke-dasharray="2,3" opacity="0.7"/>"#,
center, center, x2, y2, pink
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="6" height="6" fill="{}" opacity="0.7" transform="rotate(45 {} {})"/>"#,
x1 - 3.0, y1 - 3.0, pink, x1, y1
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="6" height="6" fill="{}" opacity="0.7" transform="rotate(45 {} {})"/>"#,
x2 - 3.0, y2 - 3.0, pink, x2, y2
));
}
let path_c0_c180 = self.c0_c180_curve.to_svg_path(center, center, scale);
if !path_c0_c180.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5"/>"#,
path_c0_c180, theme.curve_c0_c180_fill, theme.curve_c0_c180
));
}
if self.show_c90_c270() {
let path_c90_c270 = self.c90_c270_curve.to_svg_path(center, center, scale);
if !path_c90_c270.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5" stroke-dasharray="6,4"/>"#,
path_c90_c270,
theme.curve_c90_c270_fill,
theme.curve_c90_c270
));
}
}
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="3" fill="{}"/>"#,
theme.text
));
if summary.max_intensity > 0.0 {
let _peak_y = center + (summary.max_intensity / scale).min(radius);
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="start" font-size="10" fill="{}" font-family="{}" font-weight="bold">↑ {:.0}</text>"#,
center + 8.0,
center + 15.0,
theme.text,
theme.font_family,
summary.max_intensity
));
}
let box_x = size - 145.0;
let box_y = 10.0;
let box_w = 135.0;
let has_upward = summary.upward_beam_angle > 0.0;
let extra_lines =
(if summary.is_batwing { 1 } else { 0 }) + (if has_upward { 2 } else { 0 });
let box_h = 95.0 + (extra_lines as f64 * 14.0);
svg.push_str(&format!(
r#"<rect x="{box_x}" y="{box_y}" width="{box_w}" height="{box_h}" fill="{}" stroke="{}" stroke-width="1" rx="4" opacity="0.95"/>"#,
theme.legend_bg,
theme.axis
));
let text_x = box_x + 8.0;
let mut text_y = box_y + 16.0;
let line_height = 14.0;
svg.push_str(&format!(
r#"<text x="{text_x}" y="{text_y}" font-size="10" fill="{}" font-family="{}" font-weight="bold">{} {}</text>"#,
theme.text, theme.font_family, theme.labels.cie_label, summary.cie_flux_codes
));
text_y += line_height;
let beam_label = if has_upward {
format!("{} ↓", theme.labels.beam)
} else {
theme.labels.beam.clone()
};
svg.push_str(&format!(
r#"<text x="{}" y="{}" font-size="10" fill="{}"><tspan fill="{}">●</tspan> {} {:.0}°</text>"#,
text_x, text_y, theme.text, green, beam_label, summary.beam_angle
));
text_y += line_height;
if summary.is_batwing {
svg.push_str(&format!(
r#"<text x="{}" y="{}" font-size="10" fill="{}"><tspan fill="{}">▲</tspan> {} {:.0}° (CIE)</text>"#,
text_x, text_y, theme.text, blue, theme.labels.beam, summary.beam_angle_cie
));
text_y += line_height;
}
if has_upward {
svg.push_str(&format!(
r#"<text x="{}" y="{}" font-size="10" fill="{}"><tspan fill="{}">●</tspan> {} ↑ {:.0}°</text>"#,
text_x, text_y, theme.text, purple, theme.labels.beam, summary.upward_beam_angle
));
text_y += line_height;
svg.push_str(&format!(
r#"<text x="{}" y="{}" font-size="10" fill="{}"><tspan fill="{}">◆</tspan> {} ↑ {:.0}°</text>"#,
text_x, text_y, theme.text, pink, theme.labels.field, summary.upward_field_angle
));
text_y += line_height;
}
svg.push_str(&format!(
r#"<text x="{}" y="{}" font-size="10" fill="{}"><tspan fill="{}">◆</tspan> {} {:.0}°</text>"#,
text_x, text_y, theme.text, orange, theme.labels.field, summary.field_angle
));
text_y += line_height;
svg.push_str(&format!(
r#"<text x="{text_x}" y="{text_y}" font-size="10" fill="{}" font-family="{}">{} {:.0} lm/W</text>"#,
theme.text, theme.font_family, theme.labels.efficacy_label, summary.luminaire_efficacy
));
text_y += line_height;
svg.push_str(&format!(
r#"<text x="{text_x}" y="{text_y}" font-size="10" fill="{}" font-family="{}">{} {:.1}×{:.1}</text>"#,
theme.text, theme.font_family, theme.labels.sh_ratio_label, summary.spacing_c0, summary.spacing_c90
));
svg.push_str(&format!(
r#"<g transform="translate(15, {:.1})">"#,
size - 55.0
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="16" height="16" fill="{}" stroke="{}" stroke-width="2" rx="2"/>"#,
theme.curve_c0_c180_fill,
theme.curve_c0_c180
));
svg.push_str(&format!(
r#"<text x="22" y="12" font-size="12" fill="{}" font-family="{}">{}</text>"#,
theme.text, theme.font_family, theme.labels.plane_c0_c180
));
svg.push_str("</g>");
if self.show_c90_c270() {
svg.push_str(&format!(
r#"<g transform="translate(15, {:.1})">"#,
size - 32.0
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="16" height="16" fill="{}" stroke="{}" stroke-width="2" stroke-dasharray="4,2" rx="2"/>"#,
theme.curve_c90_c270_fill,
theme.curve_c90_c270
));
svg.push_str(&format!(
r#"<text x="22" y="12" font-size="12" fill="{}" font-family="{}">{}</text>"#,
theme.text, theme.font_family, theme.labels.plane_c90_c270
));
svg.push_str("</g>");
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="11" fill="{}" font-family="{}">{}</text>"#,
size - 15.0,
size - 15.0,
theme.text_secondary,
theme.font_family,
theme.labels.intensity_unit
));
svg.push_str("</svg>");
svg
}
pub fn to_svg_with_beam_field_angles(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
analysis: &crate::calculations::BeamFieldAnalysis,
show_both_definitions: bool,
) -> String {
let size = width.min(height);
let center = size / 2.0;
let margin = 70.0; let radius = (size / 2.0) - margin;
let scale = self.scale.scale_max / radius;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg" class="diagram-polar-beam-angle">"#
));
svg.push_str(
r##"<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22c55e"/>
</marker>
<marker id="arrowhead-cie" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6"/>
</marker>
</defs>"##,
);
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{size}" height="{size}" fill="{}"/>"#,
theme.background
));
for (i, &value) in self.scale.grid_values.iter().enumerate() {
let r = value / scale;
let num_circles = self.scale.grid_values.len();
let is_major = i == num_circles - 1;
let stroke_color = if is_major { &theme.axis } else { &theme.grid };
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{r:.1}" fill="none" stroke="{stroke_color}" stroke-width="1" opacity="0.5"/>"#
));
}
for i in 0..=6 {
if i == 3 {
continue;
}
let angle_deg = i as f64 * 30.0;
let angle_rad = angle_deg.to_radians();
let x_left = center - radius * angle_rad.sin();
let y_left = center + radius * angle_rad.cos();
let x_right = center + radius * angle_rad.sin();
let y_right = center + radius * angle_rad.cos();
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x_left:.1}" y2="{y_left:.1}" stroke="{}" stroke-width="0.5" opacity="0.5"/>"#,
theme.grid
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x_right:.1}" y2="{y_right:.1}" stroke="{}" stroke-width="0.5" opacity="0.5"/>"#,
theme.grid
));
if angle_deg > 0.0 && angle_deg <= 90.0 {
let label_offset = radius + 15.0;
let label_x_right = center + label_offset * angle_rad.sin();
let label_y_right = center + label_offset * angle_rad.cos();
svg.push_str(&format!(
r#"<text x="{label_x_right:.1}" y="{label_y_right:.1}" text-anchor="middle" dominant-baseline="middle" font-size="10" fill="{}" font-family="{}">{angle_deg:.0}°</text>"#,
theme.text_secondary, theme.font_family
));
}
}
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{center}" x2="{:.1}" y2="{center}" stroke="{}" stroke-width="1"/>"#,
center - radius,
center + radius,
theme.axis
));
let path = self.c0_c180_curve.to_svg_path(center, center, scale);
let intensity_color = "#eab308";
if !path.is_empty() {
svg.push_str(&format!(
r#"<path d="{path}" fill="rgba(234,179,8,0.4)" stroke="{intensity_color}" stroke-width="2"/>"#
));
}
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="4" fill="{}"/>"#,
theme.text
));
let draw_angle_arc = |svg: &mut String,
half_angle: f64,
color: &str,
label: &str,
offset: f64,
dashed: bool| {
if half_angle <= 0.0 || half_angle > 90.0 {
return;
}
let arc_radius = radius * 0.85 - offset;
let angle_rad = half_angle.to_radians();
let x1 = center + arc_radius * angle_rad.sin();
let y1 = center + arc_radius * angle_rad.cos();
let x2 = center - arc_radius * angle_rad.sin();
let y2 = center + arc_radius * angle_rad.cos();
let large_arc = if half_angle > 45.0 { 1 } else { 0 };
let dash = if dashed {
r#" stroke-dasharray="6,3""#
} else {
""
};
svg.push_str(&format!(
r#"<path d="M {x1:.1} {y1:.1} A {arc_radius:.1} {arc_radius:.1} 0 {large_arc} 1 {x2:.1} {y2:.1}" fill="none" stroke="{color}" stroke-width="2.5"{dash}/>"#
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x1:.1}" y2="{y1:.1}" stroke="{color}" stroke-width="1.5"{dash}/>"#
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x2:.1}" y2="{y2:.1}" stroke="{color}" stroke-width="1.5"{dash}/>"#
));
let label_y = center + arc_radius + 18.0;
svg.push_str(&format!(
r#"<text x="{center}" y="{label_y:.1}" text-anchor="middle" font-size="12" font-weight="bold" fill="{color}" font-family="{}">{label} {:.0}°</text>"#,
theme.font_family, half_angle * 2.0
));
};
draw_angle_arc(
&mut svg,
analysis.beam_angle_ies / 2.0,
"#22c55e",
"Beam (IES):",
0.0,
false,
);
draw_angle_arc(
&mut svg,
analysis.field_angle_ies / 2.0,
"#f97316",
"Field (IES):",
30.0,
true,
);
if show_both_definitions && analysis.is_batwing {
draw_angle_arc(
&mut svg,
analysis.beam_angle_cie / 2.0,
"#3b82f6",
"Beam (CIE):",
60.0,
false,
);
}
let beam_threshold_r = analysis.beam_threshold_ies / scale;
let field_threshold_r = analysis.field_threshold_ies / scale;
let beam_color = "#22c55e";
let field_color = "#f97316";
if beam_threshold_r > 0.0 && beam_threshold_r < radius {
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{beam_threshold_r:.1}" fill="none" stroke="{beam_color}" stroke-width="1" stroke-dasharray="4,4" opacity="0.7"/>"#
));
let tx = center + beam_threshold_r + 3.0;
let ty = center - 3.0;
svg.push_str(&format!(
r#"<text x="{tx:.1}" y="{ty:.1}" font-size="9" fill="{beam_color}" font-family="{}">50%</text>"#,
theme.font_family
));
}
if field_threshold_r > 0.0 && field_threshold_r < radius {
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{field_threshold_r:.1}" fill="none" stroke="{field_color}" stroke-width="1" stroke-dasharray="4,4" opacity="0.7"/>"#
));
let tx = center + field_threshold_r + 3.0;
let ty = center - 3.0;
svg.push_str(&format!(
r#"<text x="{tx:.1}" y="{ty:.1}" font-size="9" fill="{field_color}" font-family="{}">10%</text>"#,
theme.font_family
));
}
svg.push_str(&format!(
r#"<text x="{center}" y="25" text-anchor="middle" font-size="14" font-weight="bold" fill="{}" font-family="{}">Luminous Intensity Distribution</text>"#,
theme.text, theme.font_family
));
let legend_x = 15.0;
let legend_y = size - 90.0;
svg.push_str(&format!(
r#"<rect x="{legend_x}" y="{legend_y}" width="170" height="75" fill="{}" stroke="{}" rx="4"/>"#,
theme.legend_bg, theme.grid
));
let cie_color = "#3b82f6";
let lx1 = legend_x + 8.0;
let lx2 = legend_x + 28.0;
let ltx = legend_x + 35.0;
svg.push_str(&format!(
r#"<line x1="{lx1}" y1="{}" x2="{lx2}" y2="{}" stroke="{intensity_color}" stroke-width="3"/>"#,
legend_y + 15.0, legend_y + 15.0
));
svg.push_str(&format!(
r#"<text x="{ltx}" y="{}" font-size="10" fill="{}" font-family="{}">Intensity (cd/klm)</text>"#,
legend_y + 18.0, theme.text, theme.font_family
));
svg.push_str(&format!(
r#"<line x1="{lx1}" y1="{}" x2="{lx2}" y2="{}" stroke="{beam_color}" stroke-width="2.5"/>"#,
legend_y + 32.0, legend_y + 32.0
));
svg.push_str(&format!(
r#"<text x="{ltx}" y="{}" font-size="10" fill="{}" font-family="{}">Beam angle (50% I_max)</text>"#,
legend_y + 35.0, theme.text, theme.font_family
));
svg.push_str(&format!(
r#"<line x1="{lx1}" y1="{}" x2="{lx2}" y2="{}" stroke="{field_color}" stroke-width="2.5" stroke-dasharray="6,3"/>"#,
legend_y + 49.0, legend_y + 49.0
));
svg.push_str(&format!(
r#"<text x="{ltx}" y="{}" font-size="10" fill="{}" font-family="{}">Field angle (10% I_max)</text>"#,
legend_y + 52.0, theme.text, theme.font_family
));
if show_both_definitions && analysis.is_batwing {
svg.push_str(&format!(
r#"<line x1="{lx1}" y1="{}" x2="{lx2}" y2="{}" stroke="{cie_color}" stroke-width="2.5"/>"#,
legend_y + 66.0, legend_y + 66.0
));
svg.push_str(&format!(
r#"<text x="{ltx}" y="{}" font-size="10" fill="{}" font-family="{}">Beam angle CIE (50% I_center)</text>"#,
legend_y + 69.0, theme.text, theme.font_family
));
}
if analysis.is_batwing {
svg.push_str(&format!(
r#"<text x="{}" y="{}" text-anchor="end" font-size="10" fill="{}" font-family="{}">Distribution: {}</text>"#,
size - 15.0, 45.0, theme.text_secondary, theme.font_family, analysis.distribution_type()
));
svg.push_str(&format!(
r#"<text x="{}" y="{}" text-anchor="end" font-size="10" fill="{}" font-family="{}">I_center/I_max: {:.0}%</text>"#,
size - 15.0, 58.0, theme.text_secondary, theme.font_family, analysis.center_to_max_ratio() * 100.0
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="10" fill="{}" font-family="{}">{}</text>"#,
size - 15.0,
size - 15.0,
theme.text_secondary,
theme.font_family,
theme.labels.intensity_unit
));
svg.push_str("</svg>");
svg
}
}
impl CartesianDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
let margin_left = self.margin_left;
let margin_top = self.margin_top;
let plot_width = self.plot_width;
let plot_height = self.plot_height;
let y_max = self.scale.scale_max;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<rect x="{margin_left}" y="{margin_top}" width="{plot_width}" height="{plot_height}" fill="{}" stroke="{}" stroke-width="1"/>"#,
theme.surface,
theme.axis
));
for &v in &self.y_ticks {
let y = margin_top + plot_height * (1.0 - v / y_max);
svg.push_str(&format!(
r#"<line x1="{margin_left}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="{}" stroke-width="1"/>"#,
margin_left + plot_width,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{v:.0}</text>"#,
margin_left - 8.0,
theme.text_secondary,
theme.font_family
));
}
for &v in &self.x_ticks {
let x = margin_left + plot_width * (v / self.max_gamma);
svg.push_str(&format!(
r#"<line x1="{x:.1}" y1="{margin_top}" x2="{x:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
margin_top + plot_height,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}">{v:.0}°</text>"#,
margin_top + plot_height + 18.0,
theme.text_secondary,
theme.font_family
));
}
for curve in &self.curves {
let path = curve.to_svg_path();
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>"#,
path,
curve.color.to_rgb_string()
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">{}</text>"#,
margin_left + plot_width / 2.0,
height - 8.0,
theme.text,
theme.font_family,
theme.labels.gamma_axis
));
svg.push_str(&format!(
r#"<text x="18" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 18, {:.1})">{}</text>"#,
margin_top + plot_height / 2.0,
theme.text,
theme.font_family,
margin_top + plot_height / 2.0,
theme.labels.intensity_axis
));
let legend_height = self.curves.len() as f64 * 18.0 + 10.0;
svg.push_str(&format!(
r#"<g transform="translate({:.1}, {:.1})">"#,
margin_left + 10.0,
margin_top + 10.0
));
svg.push_str(&format!(
r#"<rect x="-5" y="-5" width="90" height="{legend_height:.1}" fill="{}" stroke="{}" stroke-width="1" rx="4"/>"#,
theme.legend_bg,
theme.axis
));
for (i, curve) in self.curves.iter().enumerate() {
let y = i as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5"/>"#,
curve.color.to_rgb_string()
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
curve.label
));
}
svg.push_str("</g>");
svg.push_str(&format!(
r#"<text x="{:.1}" y="20" text-anchor="end" font-size="11" fill="{}" font-family="{}">{} {:.0} cd/klm</text>"#,
width - 15.0,
theme.text_secondary,
theme.font_family,
theme.labels.max_label,
self.scale.max_intensity
));
svg.push_str("</svg>");
svg
}
pub fn to_svg_with_summary(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
summary: &crate::calculations::PhotometricSummary,
) -> String {
let margin_left = self.margin_left;
let margin_top = self.margin_top;
let plot_width = self.plot_width;
let plot_height = self.plot_height;
let y_max = self.scale.scale_max;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<rect x="{margin_left}" y="{margin_top}" width="{plot_width}" height="{plot_height}" fill="{}" stroke="{}" stroke-width="1"/>"#,
theme.surface,
theme.axis
));
for &v in &self.y_ticks {
let y = margin_top + plot_height * (1.0 - v / y_max);
svg.push_str(&format!(
r#"<line x1="{margin_left}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="{}" stroke-width="1"/>"#,
margin_left + plot_width,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{v:.0}</text>"#,
margin_left - 8.0,
theme.text_secondary,
theme.font_family
));
}
for &v in &self.x_ticks {
let x = margin_left + plot_width * (v / self.max_gamma);
svg.push_str(&format!(
r#"<line x1="{x:.1}" y1="{margin_top}" x2="{x:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
margin_top + plot_height,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}">{v:.0}°</text>"#,
margin_top + plot_height + 18.0,
theme.text_secondary,
theme.font_family
));
}
let green = "#22c55e";
let orange = "#f97316";
let half_beam = summary.beam_angle / 2.0;
if half_beam > 0.0 && half_beam < self.max_gamma {
let beam_x = margin_left + plot_width * (half_beam / self.max_gamma);
svg.push_str(&format!(
r#"<line x1="{beam_x:.1}" y1="{margin_top}" x2="{beam_x:.1}" y2="{:.1}" stroke="{}" stroke-width="2" stroke-dasharray="6,4" opacity="0.8"/>"#,
margin_top + plot_height,
green
));
svg.push_str(&format!(
r#"<text x="{beam_x:.1}" y="{:.1}" text-anchor="middle" font-size="9" fill="{}" font-weight="bold">{} {:.0}°</text>"#,
margin_top - 5.0,
green,
theme.labels.beam,
summary.beam_angle
));
}
let half_field = summary.field_angle / 2.0;
if half_field > 0.0 && half_field < self.max_gamma {
let field_x = margin_left + plot_width * (half_field / self.max_gamma);
svg.push_str(&format!(
r#"<line x1="{field_x:.1}" y1="{margin_top}" x2="{field_x:.1}" y2="{:.1}" stroke="{}" stroke-width="2" stroke-dasharray="4,3" opacity="0.7"/>"#,
margin_top + plot_height,
orange
));
svg.push_str(&format!(
r#"<text x="{field_x:.1}" y="{:.1}" text-anchor="middle" font-size="9" fill="{}" font-weight="bold">{} {:.0}°</text>"#,
margin_top - 5.0,
orange,
theme.labels.field,
summary.field_angle
));
}
for curve in &self.curves {
let path = curve.to_svg_path();
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>"#,
path,
curve.color.to_rgb_string()
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">{}</text>"#,
margin_left + plot_width / 2.0,
height - 8.0,
theme.text,
theme.font_family,
theme.labels.gamma_axis
));
svg.push_str(&format!(
r#"<text x="18" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 18, {:.1})">{}</text>"#,
margin_top + plot_height / 2.0,
theme.text,
theme.font_family,
margin_top + plot_height / 2.0,
theme.labels.intensity_axis
));
let legend_height = self.curves.len() as f64 * 18.0 + 45.0; svg.push_str(&format!(
r#"<g transform="translate({:.1}, {:.1})">"#,
margin_left + 10.0,
margin_top + 10.0
));
svg.push_str(&format!(
r#"<rect x="-5" y="-5" width="100" height="{legend_height:.1}" fill="{}" stroke="{}" stroke-width="1" rx="4"/>"#,
theme.legend_bg,
theme.axis
));
for (i, curve) in self.curves.iter().enumerate() {
let y = i as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5"/>"#,
curve.color.to_rgb_string()
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
curve.label
));
}
let base_y = self.curves.len() as f64 * 18.0 + 15.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{:.1}" x2="18" y2="{:.1}" stroke="{}" stroke-width="2" stroke-dasharray="6,4"/>"#,
base_y, base_y, green
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="10" fill="{}">{}</text>"#,
base_y + 4.0,
theme.text,
theme.labels.beam_50_percent
));
svg.push_str(&format!(
r#"<line x1="0" y1="{:.1}" x2="18" y2="{:.1}" stroke="{}" stroke-width="2" stroke-dasharray="4,3"/>"#,
base_y + 16.0, base_y + 16.0, orange
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="10" fill="{}">{}</text>"#,
base_y + 20.0,
theme.text,
theme.labels.field_10_percent
));
svg.push_str("</g>");
let info_x = width - 130.0;
let info_y = margin_top + 10.0;
svg.push_str(&format!(
r#"<rect x="{info_x}" y="{info_y}" width="115" height="55" fill="{}" stroke="{}" stroke-width="1" rx="4" opacity="0.95"/>"#,
theme.legend_bg,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}">{} {}</text>"#,
info_x + 5.0,
info_y + 14.0,
theme.text,
theme.labels.cie_label,
summary.cie_flux_codes
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}">{} {:.0} lm/W</text>"#,
info_x + 5.0,
info_y + 28.0,
theme.text,
theme.labels.efficacy_label,
summary.luminaire_efficacy
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}">{} {:.0} cd/klm</text>"#,
info_x + 5.0,
info_y + 42.0,
theme.text,
theme.labels.max_label,
summary.max_intensity
));
svg.push_str("</svg>");
svg
}
}
impl HeatmapDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
if self.is_empty() {
return format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg"><rect width="{width}" height="{height}" fill="{}"/><text x="{:.1}" y="{:.1}" text-anchor="middle" fill="{}">{}</text></svg>"#,
theme.background,
width / 2.0,
height / 2.0,
theme.text_secondary,
theme.labels.no_data
);
}
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="25" text-anchor="middle" font-size="14" fill="{}" font-weight="600" font-family="{}">{}</text>"#,
width / 2.0,
theme.text,
theme.font_family,
theme.labels.heatmap_title
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="none" stroke="{}" stroke-width="1"/>"#,
self.margin_left,
self.margin_top,
self.plot_width,
self.plot_height,
theme.grid
));
for cell in &self.cells {
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"><title>C{:.0}° γ{:.0}°: {:.1} cd ({:.1} cd/klm)</title></rect>"#,
cell.x,
cell.y,
cell.width,
cell.height,
cell.color.to_rgb_string(),
cell.c_angle,
cell.g_angle,
cell.candela,
cell.intensity
));
}
let num_c = self.c_angles.len();
let step = if num_c <= 10 {
1
} else if num_c <= 20 {
2
} else {
5
};
let cell_width = self.plot_width / num_c as f64;
for (i, &c) in self.c_angles.iter().enumerate() {
if i % step == 0 {
let x = self.margin_left + (i as f64 + 0.5) * cell_width;
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="9" fill="{}" font-family="{}">{c:.0}</text>"#,
self.margin_top + self.plot_height + 15.0,
theme.text_secondary,
theme.font_family
));
}
}
let num_g = self.g_angles.len();
let step = if num_g <= 10 {
1
} else if num_g <= 20 {
2
} else {
5
};
let cell_height = self.plot_height / num_g as f64;
for (i, &g) in self.g_angles.iter().enumerate() {
if i % step == 0 {
let y = self.margin_top + (i as f64 + 0.5) * cell_height;
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="9" fill="{}" font-family="{}">{g:.0}</text>"#,
self.margin_left - 8.0,
theme.text_secondary,
theme.font_family
));
}
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">{}</text>"#,
self.margin_left + self.plot_width / 2.0,
height - 10.0,
theme.text,
theme.font_family,
theme.labels.c_plane_axis
));
svg.push_str(&format!(
r#"<text x="18" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 18, {:.1})">{}</text>"#,
self.margin_top + self.plot_height / 2.0,
theme.text,
theme.font_family,
self.margin_top + self.plot_height / 2.0,
theme.labels.gamma_angle_axis
));
let legend_x = width - 80.0;
let legend_width = 20.0;
let num_segments = 50;
let segment_height = self.plot_height / num_segments as f64;
for (normalized, color, _) in &self.legend_entries {
let i = ((1.0 - normalized) * (num_segments as f64 - 1.0)) as usize;
let sy = self.margin_top + i as f64 * segment_height;
svg.push_str(&format!(
r#"<rect x="{legend_x:.1}" y="{sy:.1}" width="{legend_width:.1}" height="{:.1}" fill="{}"/>"#,
segment_height + 0.5,
color.to_rgb_string()
));
}
svg.push_str(&format!(
r#"<rect x="{legend_x:.1}" y="{:.1}" width="{legend_width:.1}" height="{:.1}" fill="none" stroke="{}" stroke-width="1"/>"#,
self.margin_top,
self.plot_height,
theme.grid
));
let num_labels = 5;
for i in 0..=num_labels {
let frac = i as f64 / num_labels as f64;
let value = self.max_candela * (1.0 - frac);
let ly = self.margin_top + frac * self.plot_height;
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{ly:.1}" x2="{:.1}" y2="{ly:.1}" stroke="{}" stroke-width="1"/>"#,
legend_x + legend_width,
legend_x + legend_width + 5.0,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{ly:.1}" dominant-baseline="middle" font-size="9" fill="{}" font-family="{}">{value:.0}</text>"#,
legend_x + legend_width + 8.0,
theme.text_secondary,
theme.font_family
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="10" fill="{}" font-family="{}">cd</text>"#,
legend_x + legend_width / 2.0,
self.margin_top - 8.0,
theme.text,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="25" text-anchor="end" font-size="11" fill="{}" font-family="{}">Max: {:.0} cd</text>"#,
width - 15.0,
theme.text_secondary,
theme.font_family,
self.max_candela
));
svg.push_str("</svg>");
svg
}
pub fn to_svg_with_summary(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
summary: &crate::calculations::PhotometricSummary,
) -> String {
if self.is_empty() {
return self.to_svg(width, height, theme);
}
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="25" text-anchor="middle" font-size="14" fill="{}" font-weight="600" font-family="{}">{}</text>"#,
width / 2.0,
theme.text,
theme.font_family,
theme.labels.heatmap_title
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="none" stroke="{}" stroke-width="1"/>"#,
self.margin_left,
self.margin_top,
self.plot_width,
self.plot_height,
theme.grid
));
for cell in &self.cells {
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"><title>C{:.0}° γ{:.0}°: {:.1} cd ({:.1} cd/klm)</title></rect>"#,
cell.x,
cell.y,
cell.width,
cell.height,
cell.color.to_rgb_string(),
cell.c_angle,
cell.g_angle,
cell.candela,
cell.intensity
));
}
let zone_angles = [30.0, 60.0, 90.0, 120.0, 150.0];
let num_g = self.g_angles.len();
let cell_height = self.plot_height / num_g as f64;
for &angle in &zone_angles {
if let Some(idx) = self.g_angles.iter().position(|&g| (g - angle).abs() < 1.0) {
let y = self.margin_top + idx as f64 * cell_height;
let white = "#ffffff";
let black = "#000000";
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="2" stroke-dasharray="4,2" opacity="0.7"/>"#,
self.margin_left, y,
self.margin_left + self.plot_width, y,
if angle == 90.0 { white } else { black }
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}" font-family="{}" font-weight="bold">{:.0}°</text>"#,
self.margin_left + 4.0,
y - 3.0,
theme.text,
theme.font_family,
angle
));
}
}
let num_c = self.c_angles.len();
let step = if num_c <= 10 {
1
} else if num_c <= 20 {
2
} else {
5
};
let cell_width = self.plot_width / num_c as f64;
for (i, &c) in self.c_angles.iter().enumerate() {
if i % step == 0 {
let x = self.margin_left + (i as f64 + 0.5) * cell_width;
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="9" fill="{}" font-family="{}">{c:.0}</text>"#,
self.margin_top + self.plot_height + 15.0,
theme.text_secondary,
theme.font_family
));
}
}
let step = if num_g <= 10 {
1
} else if num_g <= 20 {
2
} else {
5
};
for (i, &g) in self.g_angles.iter().enumerate() {
if i % step == 0 {
let y = self.margin_top + (i as f64 + 0.5) * cell_height;
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="9" fill="{}" font-family="{}">{g:.0}</text>"#,
self.margin_left - 8.0,
theme.text_secondary,
theme.font_family
));
}
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">{}</text>"#,
self.margin_left + self.plot_width / 2.0,
height - 10.0,
theme.text,
theme.font_family,
theme.labels.c_plane_axis
));
svg.push_str(&format!(
r#"<text x="18" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 18, {:.1})">{}</text>"#,
self.margin_top + self.plot_height / 2.0,
theme.text,
theme.font_family,
self.margin_top + self.plot_height / 2.0,
theme.labels.gamma_angle_axis
));
let panel_x = width - 135.0;
let panel_y = self.margin_top;
let panel_w = 125.0;
let panel_h = 125.0;
svg.push_str(&format!(
r#"<rect x="{panel_x}" y="{panel_y}" width="{panel_w}" height="{panel_h}" fill="{}" stroke="{}" stroke-width="1" rx="4" opacity="0.95"/>"#,
theme.legend_bg,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}" font-weight="bold">Zonal Lumens</text>"#,
panel_x + 8.0,
panel_y + 15.0,
theme.text,
theme.font_family
));
let zonal = &summary.zonal_lumens;
let zones = [
("0-30°", zonal.zone_0_30),
("30-60°", zonal.zone_30_60),
("60-90°", zonal.zone_60_90),
("90-120°", zonal.zone_90_120),
("120-150°", zonal.zone_120_150),
("150-180°", zonal.zone_150_180),
];
let bar_x = panel_x + 55.0;
let bar_w = 60.0;
let mut y = panel_y + 28.0;
let line_h = 14.0;
for (label, value) in zones {
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="9" fill="{}" font-family="{}">{}</text>"#,
bar_x - 5.0,
y + 3.0,
theme.text_secondary,
theme.font_family,
label
));
svg.push_str(&format!(
r#"<rect x="{bar_x}" y="{:.1}" width="{bar_w}" height="8" fill="{}" opacity="0.3" rx="2"/>"#,
y - 4.0,
theme.grid
));
let fill_w = (value / 100.0).min(1.0) * bar_w;
let color = if y < panel_y + 70.0 {
"#22c55e"
} else {
"#f97316"
}; svg.push_str(&format!(
r#"<rect x="{bar_x}" y="{:.1}" width="{:.1}" height="8" fill="{}" rx="2"/>"#,
y - 4.0,
fill_w,
color
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="8" fill="{}" font-family="{}">{:.0}%</text>"#,
bar_x + bar_w + 3.0,
y + 2.0,
theme.text_secondary,
theme.font_family,
value
));
y += line_h;
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}" font-family="{}">{} {}</text>"#,
panel_x + 8.0,
panel_y + panel_h - 8.0,
theme.text,
theme.font_family,
theme.labels.cie_label,
summary.cie_flux_codes
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="25" text-anchor="end" font-size="11" fill="{}" font-family="{}">Max: {:.0} cd</text>"#,
panel_x - 10.0,
theme.text_secondary,
theme.font_family,
self.max_candela
));
svg.push_str("</svg>");
svg
}
}
impl ButterflyDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
let cx = width / 2.0;
let cy = height / 2.0 + 25.0;
let margin = 70.0;
let max_radius = (width.min(height) / 2.0) - margin;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<ellipse cx="{cx}" cy="{cy}" rx="{:.1}" ry="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>"#,
max_radius + 10.0,
(max_radius + 10.0) * 0.5,
theme.surface,
theme.axis
));
for (i, points) in self.grid_circles.iter().enumerate() {
let value = self.scale.grid_values.get(i).copied().unwrap_or(0.0);
if points.len() > 1 {
let mut path = format!("M {:.1} {:.1}", points[0].x, points[0].y);
for p in &points[1..] {
path.push_str(&format!(" L {:.1} {:.1}", p.x, p.y));
}
path.push_str(" Z");
svg.push_str(&format!(
r#"<path d="{path}" fill="none" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">{value:.0}</text>"#,
cx + 5.0,
cy - (value / self.scale.scale_max) * max_radius * 0.5 - 5.0,
theme.text_secondary,
theme.font_family
));
}
}
for (c_angle, start, end) in &self.c_plane_lines {
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
start.x, start.y, end.x, end.y,
theme.axis
));
let label_offset = 1.15;
let lx = cx + (end.x - cx) * label_offset;
let ly = cy + (end.y - cy) * label_offset;
svg.push_str(&format!(
r#"<text x="{lx:.1}" y="{ly:.1}" text-anchor="middle" dominant-baseline="middle" font-size="10" fill="{}" font-family="{}">C{:.0}</text>"#,
theme.text_secondary,
theme.font_family,
c_angle
));
}
for wing in self.wings.iter().rev() {
let path = wing.to_svg_path();
svg.push_str(&format!(
r#"<path d="{path}" fill="{}" stroke="{}" stroke-width="1.5" opacity="0.85"/>"#,
wing.fill_color.to_rgba_string(0.6),
wing.stroke_color.to_rgb_string()
));
}
svg.push_str(&format!(
r#"<circle cx="{cx}" cy="{cy}" r="4" fill="{}"/>"#,
theme.text
));
svg.push_str(&format!(
r#"<text x="{cx}" y="25" text-anchor="middle" font-size="11" fill="{}" font-family="{}">0° (nadir)</text>"#,
theme.text,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="15" y="25" font-size="12" fill="{}" font-weight="500" font-family="{}">3D Butterfly Diagram</text>"#,
theme.text,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="11" fill="{}" font-family="{}">cd/klm</text>"#,
width - 15.0,
height - 12.0,
theme.text_secondary,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="15" y="{:.1}" font-size="11" fill="{}" font-family="{}">Symmetry: {}</text>"#,
height - 12.0,
theme.text_secondary,
theme.font_family,
self.symmetry.description()
));
svg.push_str("</svg>");
svg
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConeDiagramLabels {
pub beam_angle: String,
pub field_angle: String,
pub mounting_height: String,
pub beam_diameter: String,
pub field_diameter: String,
pub intensity_50: String,
pub intensity_10: String,
pub floor: String,
pub meter: String,
pub c_plane_label: String,
}
impl Default for ConeDiagramLabels {
fn default() -> Self {
Self {
beam_angle: "Beam Angle".to_string(),
field_angle: "Field Angle".to_string(),
mounting_height: "Mounting Height".to_string(),
beam_diameter: "Beam ⌀".to_string(),
field_diameter: "Field ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Floor".to_string(),
meter: "m".to_string(),
c_plane_label: "C-Plane".to_string(),
}
}
}
impl ConeDiagramLabels {
pub fn german() -> Self {
Self {
beam_angle: "Abstrahlwinkel".to_string(),
field_angle: "Feldwinkel".to_string(),
mounting_height: "Montagehöhe".to_string(),
beam_diameter: "Strahl ⌀".to_string(),
field_diameter: "Feld ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Boden".to_string(),
meter: "m".to_string(),
c_plane_label: "C-Ebene".to_string(),
}
}
pub fn chinese() -> Self {
Self {
beam_angle: "光束角".to_string(),
field_angle: "照射角".to_string(),
mounting_height: "安装高度".to_string(),
beam_diameter: "光束 ⌀".to_string(),
field_diameter: "照射 ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "地面".to_string(),
meter: "m".to_string(),
c_plane_label: "C平面".to_string(),
}
}
pub fn french() -> Self {
Self {
beam_angle: "Angle de faisceau".to_string(),
field_angle: "Angle de champ".to_string(),
mounting_height: "Hauteur de montage".to_string(),
beam_diameter: "Faisceau ⌀".to_string(),
field_diameter: "Champ ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Sol".to_string(),
meter: "m".to_string(),
c_plane_label: "Plan C".to_string(),
}
}
pub fn italian() -> Self {
Self {
beam_angle: "Angolo del fascio".to_string(),
field_angle: "Angolo di campo".to_string(),
mounting_height: "Altezza di montaggio".to_string(),
beam_diameter: "Fascio ⌀".to_string(),
field_diameter: "Campo ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Pavimento".to_string(),
meter: "m".to_string(),
c_plane_label: "Piano C".to_string(),
}
}
pub fn russian() -> Self {
Self {
beam_angle: "Угол луча".to_string(),
field_angle: "Угол поля".to_string(),
mounting_height: "Высота монтажа".to_string(),
beam_diameter: "Луч ⌀".to_string(),
field_diameter: "Поле ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Пол".to_string(),
meter: "м".to_string(),
c_plane_label: "C-плоскость".to_string(),
}
}
pub fn spanish() -> Self {
Self {
beam_angle: "Ángulo del haz".to_string(),
field_angle: "Ángulo de campo".to_string(),
mounting_height: "Altura de montaje".to_string(),
beam_diameter: "Haz ⌀".to_string(),
field_diameter: "Campo ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Suelo".to_string(),
meter: "m".to_string(),
c_plane_label: "Plano C".to_string(),
}
}
pub fn portuguese_brazil() -> Self {
Self {
beam_angle: "Ângulo do feixe".to_string(),
field_angle: "Ângulo de campo".to_string(),
mounting_height: "Altura de montagem".to_string(),
beam_diameter: "Feixe ⌀".to_string(),
field_diameter: "Campo ⌀".to_string(),
intensity_50: "50%".to_string(),
intensity_10: "10%".to_string(),
floor: "Piso".to_string(),
meter: "m".to_string(),
c_plane_label: "Plano C".to_string(),
}
}
}
impl ConeDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
self.to_svg_with_labels(width, height, theme, &ConeDiagramLabels::default())
}
pub fn to_svg_with_units(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
labels: &ConeDiagramLabels,
units: UnitSystem,
) -> String {
let mut labels = labels.clone();
labels.meter = units.distance_label().to_string();
let mut converted = self.clone();
converted.mounting_height = units.convert_meters(self.mounting_height);
converted.beam_diameter = units.convert_meters(self.beam_diameter);
converted.field_diameter = units.convert_meters(self.field_diameter);
converted.to_svg_with_labels(width, height, theme, &labels)
}
pub fn to_svg_with_labels(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
labels: &ConeDiagramLabels,
) -> String {
let margin_top = 60.0;
let margin_bottom = 80.0;
let margin_side = 60.0;
let cone_height = height - margin_top - margin_bottom;
let cone_width = width - 2.0 * margin_side;
let cx = width / 2.0;
let luminaire_y = margin_top;
let floor_y = margin_top + cone_height;
let beam_half_angle = self.beam_angle.to_radians();
let field_half_angle = self.field_angle.to_radians();
let max_spread = (field_half_angle.tan() * cone_height).max(cone_width / 2.0 * 0.9);
let scale = (cone_width / 2.0 * 0.85) / max_spread;
let beam_x_offset = beam_half_angle.tan() * cone_height * scale;
let field_x_offset = field_half_angle.tan() * cone_height * scale;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" class="diagram-cone">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<defs>
<linearGradient id="beamGradient" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="{}" stop-opacity="0.8"/>
<stop offset="100%" stop-color="{}" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="fieldGradient" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="{}" stop-opacity="0.5"/>
<stop offset="100%" stop-color="{}" stop-opacity="0.15"/>
</linearGradient>
</defs>"#,
"#fbbf24",
"#fbbf24", "#f97316",
"#f97316" ));
svg.push_str(&format!(
r#"<path d="M {cx} {luminaire_y} L {:.1} {floor_y} L {:.1} {floor_y} Z" fill="url(#fieldGradient)" stroke="{}" stroke-width="1.5" stroke-dasharray="6,3"/>"#,
cx - field_x_offset,
cx + field_x_offset,
"#f97316"
));
svg.push_str(&format!(
r#"<path d="M {cx} {luminaire_y} L {:.1} {floor_y} L {:.1} {floor_y} Z" fill="url(#beamGradient)" stroke="{}" stroke-width="2"/>"#,
cx - beam_x_offset,
cx + beam_x_offset,
"#fbbf24"
));
svg.push_str(&format!(
r#"<line x1="{cx}" y1="{luminaire_y}" x2="{cx}" y2="{floor_y}" stroke="{}" stroke-width="1" stroke-dasharray="4,4"/>"#,
theme.text_secondary
));
let lum_width = 40.0;
let lum_height = 12.0;
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{lum_width}" height="{lum_height}" fill="{}" stroke="{}" stroke-width="1.5" rx="2"/>"#,
cx - lum_width / 2.0,
luminaire_y - lum_height / 2.0,
theme.surface,
theme.text
));
svg.push_str(&format!(
r#"<circle cx="{cx}" cy="{luminaire_y}" r="3" fill="{}"/>"#,
"#fbbf24"
));
svg.push_str(&format!(
r#"<line x1="{margin_side}" y1="{floor_y}" x2="{:.1}" y2="{floor_y}" stroke="{}" stroke-width="2"/>"#,
width - margin_side,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="11" fill="{}" font-family="{}">{}</text>"#,
width - margin_side - 5.0,
floor_y - 5.0,
theme.text_secondary,
theme.font_family,
labels.floor
));
let dim_x = margin_side - 25.0;
svg.push_str(&format!(
r#"<line x1="{dim_x}" y1="{luminaire_y}" x2="{dim_x}" y2="{floor_y}" stroke="{}" stroke-width="1"/>"#,
theme.text_secondary
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{luminaire_y}" x2="{:.1}" y2="{luminaire_y}" stroke="{}" stroke-width="1"/>"#,
dim_x - 5.0, dim_x + 5.0, theme.text_secondary
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{floor_y}" x2="{:.1}" y2="{floor_y}" stroke="{}" stroke-width="1"/>"#,
dim_x - 5.0, dim_x + 5.0, theme.text_secondary
));
svg.push_str(&format!(
r#"<text x="{dim_x}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}" transform="rotate(-90, {dim_x}, {:.1})">{:.1}{}</text>"#,
luminaire_y + cone_height / 2.0,
theme.text,
theme.font_family,
luminaire_y + cone_height / 2.0,
self.mounting_height,
labels.meter
));
let beam_dim_y = floor_y + 20.0;
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{floor_y}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
cx - beam_x_offset, cx - beam_x_offset, beam_dim_y + 5.0, "#fbbf24"
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{floor_y}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
cx + beam_x_offset, cx + beam_x_offset, beam_dim_y + 5.0, "#fbbf24"
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{beam_dim_y}" x2="{:.1}" y2="{beam_dim_y}" stroke="{}" stroke-width="1.5"/>"#,
cx - beam_x_offset, cx + beam_x_offset, "#fbbf24"
));
svg.push_str(&format!(
r#"<text x="{cx}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}" font-weight="600">{} {:.2}{}</text>"#,
beam_dim_y - 4.0,
"#b45309", theme.font_family,
labels.beam_diameter,
self.beam_diameter,
labels.meter
));
let field_dim_y = floor_y + 45.0;
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
cx - field_x_offset,
beam_dim_y + 10.0,
cx - field_x_offset,
field_dim_y + 5.0,
"#f97316"
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
cx + field_x_offset,
beam_dim_y + 10.0,
cx + field_x_offset,
field_dim_y + 5.0,
"#f97316"
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{field_dim_y}" x2="{:.1}" y2="{field_dim_y}" stroke="{}" stroke-width="1.5" stroke-dasharray="4,2"/>"#,
cx - field_x_offset, cx + field_x_offset, "#f97316"
));
svg.push_str(&format!(
r#"<text x="{cx}" y="{:.1}" text-anchor="middle" font-size="10" fill="{}" font-family="{}">{} {:.2}{}</text>"#,
field_dim_y - 4.0,
"#c2410c", theme.font_family,
labels.field_diameter,
self.field_diameter,
labels.meter
));
let arc_radius = 50.0;
let beam_arc_end_x = cx + arc_radius * beam_half_angle.sin();
let beam_arc_end_y = luminaire_y + arc_radius * beam_half_angle.cos();
svg.push_str(&format!(
r#"<path d="M {cx} {:.1} A {arc_radius} {arc_radius} 0 0 1 {beam_arc_end_x:.1} {beam_arc_end_y:.1}" fill="none" stroke="{}" stroke-width="1.5"/>"#,
luminaire_y + arc_radius,
"#fbbf24"
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" fill="{}" font-family="{}" font-weight="600">{:.0}° ({})</text>"#,
cx + arc_radius + 8.0,
luminaire_y + arc_radius / 2.0 + 4.0,
"#b45309",
theme.font_family,
self.beam_angle,
labels.intensity_50
));
let field_arc_radius = 70.0;
let field_arc_end_x = cx + field_arc_radius * field_half_angle.sin();
let field_arc_end_y = luminaire_y + field_arc_radius * field_half_angle.cos();
svg.push_str(&format!(
r#"<path d="M {cx} {:.1} A {field_arc_radius} {field_arc_radius} 0 0 1 {field_arc_end_x:.1} {field_arc_end_y:.1}" fill="none" stroke="{}" stroke-width="1" stroke-dasharray="4,2"/>"#,
luminaire_y + field_arc_radius,
"#f97316"
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">{:.0}° ({})</text>"#,
cx + field_arc_radius + 8.0,
luminaire_y + field_arc_radius / 2.0 + 20.0,
"#c2410c",
theme.font_family,
self.field_angle,
labels.intensity_10
));
let legend_x = 15.0;
let legend_y = 15.0;
svg.push_str(&format!(
r#"<rect x="{legend_x}" y="{legend_y}" width="130" height="50" fill="{}" stroke="{}" stroke-width="1" rx="4" opacity="0.95"/>"#,
theme.legend_bg,
theme.axis
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="16" height="10" fill="{}" rx="2"/>"#,
legend_x + 8.0,
legend_y + 10.0,
"#fbbf24"
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">{} ({})</text>"#,
legend_x + 30.0,
legend_y + 18.0,
theme.text,
theme.font_family,
labels.beam_angle,
labels.intensity_50
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="16" height="10" fill="{}" stroke="{}" stroke-dasharray="3,1" rx="2"/>"#,
legend_x + 8.0, legend_y + 28.0, "#f9731640", "#f97316"
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">{} ({})</text>"#,
legend_x + 30.0,
legend_y + 36.0,
theme.text,
theme.font_family,
labels.field_angle,
labels.intensity_10
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="30" text-anchor="end" font-size="12" fill="{}" font-family="{}" font-weight="600">{}</text>"#,
width - 15.0,
theme.text,
theme.font_family,
self.beam_classification()
));
if let Some(c) = self.selected_c_plane {
svg.push_str(&format!(
r#"<text x="{:.1}" y="48" text-anchor="end" font-size="11" fill="{}" font-family="{}">{} C{:.0}°</text>"#,
width - 15.0,
theme.text_secondary,
theme.font_family,
labels.c_plane_label,
c
));
}
svg.push_str("</svg>");
svg
}
pub fn to_svg_wikipedia(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
let _labels = ConeDiagramLabels::default();
let margin_top = 70.0;
let margin_bottom = 100.0;
let margin_side = 80.0;
let cone_height = height - margin_top - margin_bottom;
let cone_width = width - 2.0 * margin_side;
let cx = width / 2.0;
let luminaire_y = margin_top;
let floor_y = margin_top + cone_height;
let beam_half_angle = self.beam_angle.to_radians();
let field_half_angle = self.field_angle.to_radians();
let max_spread = (field_half_angle.tan() * cone_height).max(cone_width / 2.0 * 0.9);
let scale = (cone_width / 2.0 * 0.85) / max_spread;
let beam_x_offset = beam_half_angle.tan() * cone_height * scale;
let field_x_offset = field_half_angle.tan() * cone_height * scale;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" class="diagram-cone-wikipedia">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<text x="{cx}" y="25" text-anchor="middle" font-size="16" font-weight="bold" fill="{}" font-family="{}">Beam Angle and Field Angle</text>"#,
theme.text, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{cx}" y="45" text-anchor="middle" font-size="12" fill="{}" font-family="{}">Light distribution from a luminaire</text>"#,
theme.text_secondary, theme.font_family
));
let beam_color = "#22c55e"; let field_color = "#f97316"; let beam_fill = "rgba(34, 197, 94, 0.3)";
let field_fill = "rgba(249, 115, 22, 0.15)";
svg.push_str(&format!(
r#"<defs>
<linearGradient id="beamGradWiki" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="{beam_color}" stop-opacity="0.6"/>
<stop offset="100%" stop-color="{beam_color}" stop-opacity="0.2"/>
</linearGradient>
<linearGradient id="fieldGradWiki" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="{field_color}" stop-opacity="0.4"/>
<stop offset="100%" stop-color="{field_color}" stop-opacity="0.1"/>
</linearGradient>
</defs>"#
));
svg.push_str(&format!(
r#"<path d="M {cx} {luminaire_y} L {:.1} {floor_y} L {:.1} {floor_y} Z" fill="url(#fieldGradWiki)" stroke="{field_color}" stroke-width="2" stroke-dasharray="8,4"/>"#,
cx - field_x_offset,
cx + field_x_offset
));
svg.push_str(&format!(
r#"<path d="M {cx} {luminaire_y} L {:.1} {floor_y} L {:.1} {floor_y} Z" fill="url(#beamGradWiki)" stroke="{beam_color}" stroke-width="2.5"/>"#,
cx - beam_x_offset,
cx + beam_x_offset
));
svg.push_str(&format!(
r#"<line x1="{cx}" y1="{luminaire_y}" x2="{cx}" y2="{floor_y}" stroke="{}" stroke-width="1.5" stroke-dasharray="6,4"/>"#,
theme.text_secondary
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">Beam axis</text>"#,
cx + 8.0, luminaire_y + cone_height * 0.6, theme.text_secondary, theme.font_family
));
let lum_width = 50.0;
let lum_height = 14.0;
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{lum_width}" height="{lum_height}" fill="{}" stroke="{}" stroke-width="2" rx="3"/>"#,
cx - lum_width / 2.0,
luminaire_y - lum_height / 2.0,
theme.surface,
theme.text
));
svg.push_str(&format!(
r#"<text x="{cx}" y="{:.1}" text-anchor="middle" font-size="9" fill="{}" font-family="{}">Luminaire</text>"#,
luminaire_y - lum_height / 2.0 - 5.0, theme.text_secondary, theme.font_family
));
let light_color = "#fbbf24";
svg.push_str(&format!(
r#"<circle cx="{cx}" cy="{luminaire_y}" r="4" fill="{light_color}" stroke="{}" stroke-width="1"/>"#,
theme.text
));
svg.push_str(&format!(
r#"<line x1="{margin_side}" y1="{floor_y}" x2="{:.1}" y2="{floor_y}" stroke="{}" stroke-width="2.5"/>"#,
width - margin_side,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" fill="{}" font-family="{}">Work plane / Floor</text>"#,
width - margin_side - 5.0, floor_y - 8.0, theme.text_secondary, theme.font_family
));
let arc_r = 60.0;
let beam_arc_x = cx + arc_r * beam_half_angle.sin();
let beam_arc_y = luminaire_y + arc_r * beam_half_angle.cos();
svg.push_str(&format!(
r#"<path d="M {cx} {:.1} A {arc_r} {arc_r} 0 0 1 {beam_arc_x:.1} {beam_arc_y:.1}" fill="none" stroke="{beam_color}" stroke-width="2.5"/>"#,
luminaire_y + arc_r
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="13" font-weight="bold" fill="{beam_color}" font-family="{}">Beam angle</text>"#,
cx + arc_r + 15.0, luminaire_y + arc_r / 2.0 - 5.0, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="12" fill="{beam_color}" font-family="{}">{:.0}° (50% I_max)</text>"#,
cx + arc_r + 15.0, luminaire_y + arc_r / 2.0 + 12.0, theme.font_family, self.beam_angle
));
let field_arc_r = 85.0;
let field_arc_x = cx + field_arc_r * field_half_angle.sin();
let field_arc_y = luminaire_y + field_arc_r * field_half_angle.cos();
svg.push_str(&format!(
r#"<path d="M {cx} {:.1} A {field_arc_r} {field_arc_r} 0 0 1 {field_arc_x:.1} {field_arc_y:.1}" fill="none" stroke="{field_color}" stroke-width="2" stroke-dasharray="6,3"/>"#,
luminaire_y + field_arc_r
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="12" font-weight="bold" fill="{field_color}" font-family="{}">Field angle</text>"#,
cx + field_arc_r + 15.0, luminaire_y + field_arc_r / 2.0 + 30.0, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" fill="{field_color}" font-family="{}">{:.0}° (10% I_max)</text>"#,
cx + field_arc_r + 15.0, luminaire_y + field_arc_r / 2.0 + 45.0, theme.font_family, self.field_angle
));
let dim_x = margin_side - 35.0;
svg.push_str(&format!(
r#"<line x1="{dim_x}" y1="{luminaire_y}" x2="{dim_x}" y2="{floor_y}" stroke="{}" stroke-width="1"/>"#,
theme.text_secondary
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{luminaire_y}" x2="{:.1}" y2="{luminaire_y}" stroke="{}" stroke-width="1"/>"#,
dim_x - 6.0, dim_x + 6.0, theme.text_secondary
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{floor_y}" x2="{:.1}" y2="{floor_y}" stroke="{}" stroke-width="1"/>"#,
dim_x - 6.0, dim_x + 6.0, theme.text_secondary
));
svg.push_str(&format!(
r#"<text x="{dim_x}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}" transform="rotate(-90, {dim_x}, {:.1})">Mounting height: {:.1}m</text>"#,
luminaire_y + cone_height / 2.0,
theme.text,
theme.font_family,
luminaire_y + cone_height / 2.0,
self.mounting_height
));
let beam_dim_y = floor_y + 25.0;
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{floor_y}" x2="{:.1}" y2="{:.1}" stroke="{beam_color}" stroke-width="1"/>"#,
cx - beam_x_offset, cx - beam_x_offset, beam_dim_y + 5.0
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{floor_y}" x2="{:.1}" y2="{:.1}" stroke="{beam_color}" stroke-width="1"/>"#,
cx + beam_x_offset, cx + beam_x_offset, beam_dim_y + 5.0
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{beam_dim_y}" x2="{:.1}" y2="{beam_dim_y}" stroke="{beam_color}" stroke-width="2"/>"#,
cx - beam_x_offset, cx + beam_x_offset
));
svg.push_str(&format!(
r#"<text x="{cx}" y="{:.1}" text-anchor="middle" font-size="11" font-weight="bold" fill="{beam_color}" font-family="{}">Beam ⌀ {:.2}m</text>"#,
beam_dim_y - 6.0, theme.font_family, self.beam_diameter
));
let field_dim_y = floor_y + 55.0;
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{field_color}" stroke-width="1"/>"#,
cx - field_x_offset, beam_dim_y + 10.0, cx - field_x_offset, field_dim_y + 5.0
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{field_color}" stroke-width="1"/>"#,
cx + field_x_offset, beam_dim_y + 10.0, cx + field_x_offset, field_dim_y + 5.0
));
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{field_dim_y}" x2="{:.1}" y2="{field_dim_y}" stroke="{field_color}" stroke-width="1.5" stroke-dasharray="6,3"/>"#,
cx - field_x_offset, cx + field_x_offset
));
svg.push_str(&format!(
r#"<text x="{cx}" y="{:.1}" text-anchor="middle" font-size="10" fill="{field_color}" font-family="{}">Field ⌀ {:.2}m</text>"#,
field_dim_y - 6.0, theme.font_family, self.field_diameter
));
let legend_x = 15.0;
let legend_y = 60.0;
svg.push_str(&format!(
r#"<rect x="{legend_x}" y="{legend_y}" width="180" height="78" fill="{}" stroke="{}" stroke-width="1" rx="4"/>"#,
theme.legend_bg, theme.grid
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" font-weight="bold" fill="{}" font-family="{}">Definitions (IES):</text>"#,
legend_x + 8.0, legend_y + 16.0, theme.text, theme.font_family
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="14" height="10" fill="{beam_fill}" stroke="{beam_color}" stroke-width="1.5" rx="2"/>"#,
legend_x + 8.0, legend_y + 26.0
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">Beam: I ≥ 50% of I_max</text>"#,
legend_x + 28.0, legend_y + 34.0, theme.text, theme.font_family
));
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="14" height="10" fill="{field_fill}" stroke="{field_color}" stroke-width="1.5" stroke-dasharray="3,1" rx="2"/>"#,
legend_x + 8.0, legend_y + 44.0
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">Field: I ≥ 10% of I_max</text>"#,
legend_x + 28.0, legend_y + 52.0, theme.text, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="10" fill="{}" font-family="{}">Classification: {}</text>"#,
legend_x + 8.0, legend_y + 70.0, theme.text, theme.font_family, self.beam_classification()
));
svg.push_str(&format!(
r#"<text x="{cx}" y="{:.1}" text-anchor="middle" font-size="9" fill="{}" font-family="{}">Beam diameter = 2 × height × tan(beam_angle / 2)</text>"#,
height - 12.0, theme.text_secondary, theme.font_family
));
svg.push_str("</svg>");
svg
}
}
impl IsoluxDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
self.to_svg_with_units(width, height, theme, UnitSystem::default())
}
pub fn to_svg_with_units(
&self,
width: f64,
height: f64,
theme: &SvgTheme,
units: UnitSystem,
) -> String {
let margin_left = self.margin_left;
let margin_top = self.margin_top;
let plot_width = self.plot_width;
let plot_height = self.plot_height;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
let dist_label = units.distance_label();
let h_display = units.convert_meters(self.params.mounting_height);
svg.push_str(&format!(
r#"<text x="{:.1}" y="22" text-anchor="middle" font-size="14" font-weight="bold" fill="{}" font-family="{}">Isolux Footprint (h={h_display:.1}{dist_label}, tilt={:.0}°)</text>"#,
width / 2.0,
theme.text,
theme.font_family,
self.params.tilt_angle
));
for cell in &self.cells {
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"/>"#,
cell.sx,
cell.sy,
cell.width,
cell.height,
cell.color.to_rgb_string()
));
}
let contour_colors = [
"rgba(255,255,255,0.9)",
"rgba(255,255,255,0.85)",
"rgba(255,255,255,0.8)",
"rgba(255,255,255,0.75)",
"rgba(255,255,255,0.7)",
"rgba(255,255,255,0.65)",
"rgba(255,255,255,0.6)",
"rgba(255,255,255,0.55)",
"rgba(255,255,255,0.5)",
];
for (i, contour) in self.contours.iter().enumerate() {
let color = contour_colors.get(i).unwrap_or(&"rgba(255,255,255,0.6)");
for path in &contour.paths {
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="1.5"/>"#,
path, color
));
}
if let Some(first_path) = contour.paths.first() {
if let Some(coords) = first_path.strip_prefix("M ") {
if let Some(space_idx) = coords.find(' ') {
let x_str = &coords[..space_idx];
if let Ok(x) = x_str.parse::<f64>() {
let y_str = coords[space_idx + 1..].split(' ').next().unwrap_or("0");
if let Ok(y) = y_str.parse::<f64>() {
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="white" font-family="{}" font-weight="bold" paint-order="stroke" stroke="{}" stroke-width="2">{}</text>"#,
x + 3.0, y - 3.0,
theme.font_family,
"rgba(0,0,0,0.5)",
contour.label
));
}
}
}
}
}
}
let cx = margin_left + plot_width / 2.0;
let cy = margin_top + plot_height / 2.0;
svg.push_str(&format!(
r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="4" fill="white" stroke="black" stroke-width="1.5"/>"#
));
let hw = self.params.area_half_width;
let hd = self.params.area_half_depth;
let x_label_positions = [-1.0, -0.5, 0.0, 0.5, 1.0];
for &frac in &x_label_positions {
let x = margin_left + plot_width * ((frac + 1.0) / 2.0);
let val = units.convert_meters(hw * frac);
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="10" fill="{}" font-family="{}">{val:.0}{dist_label}</text>"#,
margin_top + plot_height + 16.0,
theme.text_secondary,
theme.font_family,
));
}
for &frac in &x_label_positions {
let y = margin_top + plot_height * ((1.0 - frac) / 2.0);
let val = units.convert_meters(hd * frac);
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="10" fill="{}" font-family="{}">{val:.0}{dist_label}</text>"#,
margin_left - 6.0,
theme.text_secondary,
theme.font_family,
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">X ({dist_label})</text>"#,
margin_left + plot_width / 2.0,
height - 6.0,
theme.text,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="14" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 14, {:.1})">Y ({dist_label})</text>"#,
margin_top + plot_height / 2.0,
theme.text,
theme.font_family,
margin_top + plot_height / 2.0
));
let legend_x = margin_left + plot_width + 10.0;
let legend_h = plot_height;
let num_segments = 50;
let seg_h = legend_h / num_segments as f64;
for i in 0..num_segments {
let normalized = 1.0 - i as f64 / num_segments as f64;
let color = super::color::heatmap_color(normalized);
let y = margin_top + i as f64 * seg_h;
svg.push_str(&format!(
r#"<rect x="{legend_x:.1}" y="{y:.1}" width="15" height="{seg_h:.1}" fill="{}"/>"#,
color.to_rgb_string()
));
}
let illu_label = units.illuminance_label();
let legend_labels = [
(
0.0,
format!("{:.0} {illu_label}", units.convert_lux(self.max_lux)),
),
(
0.5,
format!("{:.0} {illu_label}", units.convert_lux(self.max_lux * 0.5)),
),
(1.0, format!("0 {illu_label}")),
];
for &(frac, ref label) in &legend_labels {
let y = margin_top + frac * legend_h;
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" font-size="9" fill="{}" font-family="{}" dominant-baseline="middle">{}</text>"#,
legend_x + 20.0,
theme.text_secondary,
theme.font_family,
label
));
}
svg.push_str(&format!(
r#"<rect x="{margin_left}" y="{margin_top}" width="{plot_width}" height="{plot_height}" fill="none" stroke="{}" stroke-width="1"/>"#,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="10" fill="{}" font-family="{}">E_max = {:.1} {illu_label}</text>"#,
margin_left + plot_width,
margin_top + plot_height + 38.0,
theme.text_secondary,
theme.font_family,
units.convert_lux(self.max_lux)
));
svg.push_str("</svg>");
svg
}
}
impl IsocandelaDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
let margin_left = self.margin_left;
let margin_top = self.margin_top;
let plot_width = self.plot_width;
let plot_height = self.plot_height;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="22" text-anchor="middle" font-size="14" font-weight="bold" fill="{}" font-family="{}">Isocandela Contour Plot</text>"#,
width / 2.0,
theme.text,
theme.font_family
));
for cell in &self.cells {
svg.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"/>"#,
cell.sx,
cell.sy,
cell.width,
cell.height,
cell.color.to_rgb_string()
));
}
let contour_colors = [
"rgba(255,255,255,0.95)",
"rgba(255,255,255,0.85)",
"rgba(255,255,255,0.8)",
"rgba(255,255,255,0.7)",
"rgba(255,255,255,0.6)",
];
for (i, contour) in self.contours.iter().enumerate() {
let color = contour_colors.get(i).unwrap_or(&"rgba(255,255,255,0.6)");
for path in &contour.paths {
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="1.5"/>"#,
path, color
));
}
if let Some(first_path) = contour.paths.first() {
if let Some(coords) = first_path.strip_prefix("M ") {
if let Some(space_idx) = coords.find(' ') {
let x_str = &coords[..space_idx];
if let Ok(x) = x_str.parse::<f64>() {
let y_str = coords[space_idx + 1..].split(' ').next().unwrap_or("0");
if let Ok(y) = y_str.parse::<f64>() {
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="white" font-family="{}" font-weight="bold" paint-order="stroke" stroke="{}" stroke-width="2">{}</text>"#,
x + 3.0, y - 3.0,
theme.font_family,
"rgba(0,0,0,0.5)",
contour.label
));
}
}
}
}
}
}
let h_ticks = [-90.0, -60.0, -30.0, 0.0, 30.0, 60.0, 90.0];
let v_ticks = [-90.0, -60.0, -30.0, 0.0, 30.0, 60.0, 90.0];
for &h in &h_ticks {
let x = margin_left + plot_width * ((h - self.h_min) / (self.h_max - self.h_min));
svg.push_str(&format!(
r#"<line x1="{x:.1}" y1="{margin_top}" x2="{x:.1}" y2="{:.1}" stroke="rgba(255,255,255,0.2)" stroke-width="0.5"/>"#,
margin_top + plot_height
));
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="10" fill="{}" font-family="{}">{h:.0}°</text>"#,
margin_top + plot_height + 16.0,
theme.text_secondary,
theme.font_family
));
}
for &v in &v_ticks {
let y = margin_top + plot_height * ((self.v_max - v) / (self.v_max - self.v_min));
svg.push_str(&format!(
r#"<line x1="{margin_left}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="rgba(255,255,255,0.2)" stroke-width="0.5"/>"#,
margin_left + plot_width
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="10" fill="{}" font-family="{}">{v:.0}°</text>"#,
margin_left - 6.0,
theme.text_secondary,
theme.font_family
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">Horizontal Angle H (°)</text>"#,
margin_left + plot_width / 2.0,
height - 6.0,
theme.text,
theme.font_family
));
svg.push_str(&format!(
r#"<text x="14" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 14, {:.1})">Vertical Angle V (°)</text>"#,
margin_top + plot_height / 2.0,
theme.text,
theme.font_family,
margin_top + plot_height / 2.0
));
let legend_x = margin_left + plot_width + 10.0;
let legend_h = plot_height;
let num_segments = 50;
let seg_h = legend_h / num_segments as f64;
for i in 0..num_segments {
let normalized = 1.0 - i as f64 / num_segments as f64;
let color = super::color::heatmap_color(normalized);
let y = margin_top + i as f64 * seg_h;
svg.push_str(&format!(
r#"<rect x="{legend_x:.1}" y="{y:.1}" width="15" height="{seg_h:.1}" fill="{}"/>"#,
color.to_rgb_string()
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}" font-family="{}" dominant-baseline="middle">{:.0}</text>"#,
legend_x + 20.0, margin_top,
theme.text_secondary, theme.font_family,
self.i_max
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}" font-family="{}" dominant-baseline="middle">cd/klm</text>"#,
legend_x + 20.0, margin_top + 12.0,
theme.text_secondary, theme.font_family
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="9" fill="{}" font-family="{}" dominant-baseline="middle">0</text>"#,
legend_x + 20.0, margin_top + legend_h,
theme.text_secondary, theme.font_family
));
svg.push_str(&format!(
r#"<rect x="{margin_left}" y="{margin_top}" width="{plot_width}" height="{plot_height}" fill="none" stroke="{}" stroke-width="1"/>"#,
theme.axis
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="10" fill="{}" font-family="{}">I_max = {:.0} cd/klm</text>"#,
margin_left + plot_width,
margin_top + plot_height + 38.0,
theme.text_secondary,
theme.font_family,
self.i_max
));
svg.push_str("</svg>");
svg
}
}
impl FloodlightCartesianDiagram {
pub fn to_svg(&self, width: f64, height: f64, theme: &SvgTheme) -> String {
let margin_left = self.margin_left;
let margin_top = self.margin_top;
let plot_width = self.plot_width;
let plot_height = self.plot_height;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<rect x="{margin_left}" y="{margin_top}" width="{plot_width}" height="{plot_height}" fill="{}" stroke="{}" stroke-width="1"/>"#,
theme.surface, theme.axis
));
for tick in &self.y_ticks {
let y = self.map_y_tick(*tick, margin_top, plot_height);
svg.push_str(&format!(
r#"<line x1="{margin_left}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="{}" stroke-width="0.5" stroke-dasharray="4,3"/>"#,
margin_left + plot_width,
theme.grid
));
let label = match self.y_scale {
YScale::Logarithmic => {
if *tick >= 1.0 {
format!("{:.0}", tick)
} else {
format!("{:.1}", tick)
}
}
YScale::Linear => format!("{:.0}", tick),
};
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{label}</text>"#,
margin_left - 8.0,
theme.text_secondary,
theme.font_family
));
}
for &angle in &self.x_ticks {
let x = margin_left + plot_width * ((angle + 90.0) / 180.0);
svg.push_str(&format!(
r#"<line x1="{x:.1}" y1="{margin_top}" x2="{x:.1}" y2="{:.1}" stroke="{}" stroke-width="0.5" stroke-dasharray="4,3"/>"#,
margin_top + plot_height,
theme.grid
));
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}">{angle:.0}°</text>"#,
margin_top + plot_height + 18.0,
theme.text_secondary,
theme.font_family
));
}
let x_zero = margin_left + plot_width * 0.5;
svg.push_str(&format!(
r#"<line x1="{x_zero:.1}" y1="{margin_top}" x2="{x_zero:.1}" y2="{:.1}" stroke="{}" stroke-width="1" opacity="0.5"/>"#,
margin_top + plot_height,
theme.axis
));
let h_path = self.h_curve.to_svg_path();
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>"#,
h_path,
self.h_curve.color.to_rgb_string()
));
let v_path = self.v_curve.to_svg_path();
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>"#,
v_path,
self.v_curve.color.to_rgb_string()
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">Angle (°)</text>"#,
margin_left + plot_width / 2.0,
height - 8.0,
theme.text,
theme.font_family
));
let y_label = match self.y_scale {
YScale::Linear => "Intensity (cd/klm)",
YScale::Logarithmic => "Intensity (cd/klm) — log",
};
svg.push_str(&format!(
r#"<text x="18" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}" transform="rotate(-90, 18, {:.1})">{}</text>"#,
margin_top + plot_height / 2.0,
theme.text,
theme.font_family,
margin_top + plot_height / 2.0,
y_label
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="20" text-anchor="middle" font-size="14" font-weight="bold" fill="{}" font-family="{}">Floodlight V-H Diagram</text>"#,
width / 2.0,
theme.text,
theme.font_family
));
let legend_x = margin_left + plot_width - 130.0;
let legend_y = margin_top + 10.0;
svg.push_str(&format!(
r#"<g transform="translate({legend_x:.1}, {legend_y:.1})">"#
));
svg.push_str(&format!(
r#"<rect x="-5" y="-5" width="130" height="50" fill="{}" stroke="{}" stroke-width="1" rx="4" opacity="0.9"/>"#,
theme.legend_bg, theme.axis
));
svg.push_str(&format!(
r#"<line x1="0" y1="8" x2="18" y2="8" stroke="{}" stroke-width="2.5"/>"#,
self.h_curve.color.to_rgb_string()
));
svg.push_str(&format!(
r#"<text x="24" y="12" font-size="11" fill="{}" font-family="{}">{}</text>"#,
theme.text, theme.font_family, self.h_curve.label
));
svg.push_str(&format!(
r#"<line x1="0" y1="28" x2="18" y2="28" stroke="{}" stroke-width="2.5"/>"#,
self.v_curve.color.to_rgb_string()
));
svg.push_str(&format!(
r#"<text x="24" y="32" font-size="11" fill="{}" font-family="{}">{}</text>"#,
theme.text, theme.font_family, self.v_curve.label
));
svg.push_str("</g>");
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="10" fill="{}" font-family="{}">I_max = {:.0} cd/klm</text>"#,
margin_left + plot_width - 5.0,
margin_top + plot_height + 38.0,
theme.text_secondary,
theme.font_family,
self.i_max
));
svg.push_str("</svg>");
svg
}
fn map_y_tick(&self, value: f64, margin_top: f64, plot_height: f64) -> f64 {
match self.y_scale {
YScale::Linear => {
let y_max = self.scale.scale_max;
if y_max > 0.0 {
margin_top + plot_height * (1.0 - value / y_max)
} else {
margin_top + plot_height
}
}
YScale::Logarithmic => {
let y_max = self.scale.scale_max;
let y_min = self.y_ticks.first().copied().unwrap_or(0.1).max(0.1);
let log_range = y_max.log10() - y_min.log10();
if log_range > 0.0 {
let normalized = (value.max(y_min).log10() - y_min.log10()) / log_range;
margin_top + plot_height * (1.0 - normalized)
} else {
margin_top + plot_height
}
}
}
}
}
impl PolarDiagram {
pub fn to_overlay_svg(
a: &PolarDiagram,
b: &PolarDiagram,
width: f64,
height: f64,
theme: &SvgTheme,
label_a: &str,
label_b: &str,
) -> String {
let size = width.min(height);
let center = size / 2.0;
let margin = 60.0;
let radius = (size / 2.0) - margin;
let max_val = a.scale.scale_max.max(b.scale.scale_max);
let unified_scale = DiagramScale::from_max_intensity(max_val, 5);
let scale = unified_scale.scale_max / radius;
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{size}" height="{size}" fill="{}"/>"#,
theme.background
));
for (i, &value) in unified_scale.grid_values.iter().enumerate() {
let r = value / scale;
let is_major = i == unified_scale.grid_values.len() - 1;
let stroke_color = if is_major { &theme.axis } else { &theme.grid };
let stroke_width = if is_major { "1.5" } else { "1" };
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="{r:.1}" fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}"/>"#
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" font-size="11" fill="{}" font-family="{}">{:.0}</text>"#,
center + 5.0,
center + r + 12.0,
theme.text_secondary,
theme.font_family,
value
));
}
for i in 0..=6 {
if i == 3 {
continue;
}
let angle_deg = i as f64 * 30.0;
let angle_rad = angle_deg.to_radians();
let x_left = center - radius * angle_rad.sin();
let y_left = center + radius * angle_rad.cos();
let x_right = center + radius * angle_rad.sin();
let y_right = center + radius * angle_rad.cos();
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x_left:.1}" y2="{y_left:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
svg.push_str(&format!(
r#"<line x1="{center}" y1="{center}" x2="{x_right:.1}" y2="{y_right:.1}" stroke="{}" stroke-width="1"/>"#,
theme.grid
));
}
svg.push_str(&format!(
r#"<line x1="{:.1}" y1="{center}" x2="{:.1}" y2="{center}" stroke="{}" stroke-width="1.5"/>"#,
center - radius,
center + radius,
theme.axis
));
let color_a_c0 = &theme.curve_c0_c180;
let fill_a_c0 = &theme.curve_c0_c180_fill;
let color_a_c90 = &theme.curve_c90_c270;
let color_b_c0 = "#22c55e"; let fill_b_c0 = "rgba(34,197,94,0.12)";
let color_b_c90 = "#f97316";
let path = a.c0_c180_curve.to_svg_path(center, center, scale);
if !path.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5"/>"#,
path, fill_a_c0, color_a_c0
));
}
if a.show_c90_c270() {
let path = a.c90_c270_curve.to_svg_path(center, center, scale);
if !path.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-dasharray="6,4"/>"#,
path, color_a_c90
));
}
}
let path = b.c0_c180_curve.to_svg_path(center, center, scale);
if !path.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="{}" stroke="{}" stroke-width="2.5"/>"#,
path, fill_b_c0, color_b_c0
));
}
if b.show_c90_c270() {
let path = b.c90_c270_curve.to_svg_path(center, center, scale);
if !path.is_empty() {
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-dasharray="6,4"/>"#,
path, color_b_c90
));
}
}
svg.push_str(&format!(
r#"<circle cx="{center}" cy="{center}" r="3" fill="{}"/>"#,
theme.text
));
let a_has_c90 = a.show_c90_c270();
let b_has_c90 = b.show_c90_c270();
let entry_count = 1 + a_has_c90 as usize + 1 + b_has_c90 as usize;
let legend_height = entry_count as f64 * 18.0 + 10.0;
let legend_y = size - legend_height - 15.0;
svg.push_str(&format!(r#"<g transform="translate(15, {legend_y:.1})">"#));
svg.push_str(&format!(
r#"<rect x="-5" y="-5" width="170" height="{legend_height:.1}" fill="{}" stroke="{}" stroke-width="1" rx="4"/>"#,
theme.legend_bg, theme.axis
));
let mut row = 0;
let y = row as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5"/>"#,
color_a_c0
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{} {}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
label_a,
a.c0_c180_curve.label
));
row += 1;
if a_has_c90 {
let y = row as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5" stroke-dasharray="4,2"/>"#,
color_a_c90
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{} {}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
label_a,
a.c90_c270_curve.label
));
row += 1;
}
let y = row as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5"/>"#,
color_b_c0
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{} {}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
label_b,
b.c0_c180_curve.label
));
row += 1;
if b_has_c90 {
let y = row as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5" stroke-dasharray="4,2"/>"#,
color_b_c90
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{} {}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
label_b,
b.c90_c270_curve.label
));
}
svg.push_str("</g>");
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="end" font-size="11" fill="{}" font-family="{}">{}</text>"#,
size - 15.0,
size - 15.0,
theme.text_secondary,
theme.font_family,
theme.labels.intensity_unit
));
svg.push_str("</svg>");
svg
}
}
impl CartesianDiagram {
pub fn to_overlay_svg(
a: &CartesianDiagram,
b: &CartesianDiagram,
width: f64,
height: f64,
theme: &SvgTheme,
label_a: &str,
label_b: &str,
) -> String {
let margin_left = a.margin_left;
let margin_top = a.margin_top;
let plot_width = a.plot_width;
let plot_height = a.plot_height;
let y_max = a.scale.scale_max.max(b.scale.scale_max);
let max_gamma = a.max_gamma.max(b.max_gamma);
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">"#
));
svg.push_str(&format!(
r#"<rect x="0" y="0" width="{width}" height="{height}" fill="{}"/>"#,
theme.background
));
svg.push_str(&format!(
r#"<rect x="{margin_left}" y="{margin_top}" width="{plot_width}" height="{plot_height}" fill="{}" stroke="{}" stroke-width="1"/>"#,
theme.surface, theme.axis
));
let unified_y = DiagramScale::from_max_intensity(y_max, 5);
for &v in &unified_y.grid_values {
let y = margin_top + plot_height * (1.0 - v / unified_y.scale_max);
svg.push_str(&format!(
r#"<line x1="{margin_left}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="{}" stroke-width="1"/>"#,
margin_left + plot_width, theme.grid
));
svg.push_str(&format!(
r#"<text x="{:.1}" y="{y:.1}" text-anchor="end" dominant-baseline="middle" font-size="11" fill="{}" font-family="{}">{v:.0}</text>"#,
margin_left - 8.0, theme.text_secondary, theme.font_family
));
}
for &v in &a.x_ticks {
let x = margin_left + plot_width * (v / max_gamma);
svg.push_str(&format!(
r#"<line x1="{x:.1}" y1="{margin_top}" x2="{x:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"#,
margin_top + plot_height, theme.grid
));
svg.push_str(&format!(
r#"<text x="{x:.1}" y="{:.1}" text-anchor="middle" font-size="11" fill="{}" font-family="{}">{v:.0}°</text>"#,
margin_top + plot_height + 18.0, theme.text_secondary, theme.font_family
));
}
let b_colors = [
"#22c55e", "#f97316", "#a855f7", "#06b6d4", "#eab308", "#ec4899", "#84cc16", "#6366f1",
];
for curve in &a.curves {
let path = Self::rescale_curve_path(
curve,
margin_left,
margin_top,
plot_width,
plot_height,
unified_y.scale_max,
max_gamma,
);
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>"#,
path, curve.color.to_rgb_string()
));
}
for (i, curve) in b.curves.iter().enumerate() {
let color = b_colors[i % b_colors.len()];
let path = Self::rescale_curve_path(
curve,
margin_left,
margin_top,
plot_width,
plot_height,
unified_y.scale_max,
max_gamma,
);
svg.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="2.5" stroke-dasharray="8,4" stroke-linecap="round" stroke-linejoin="round"/>"#,
path, color
));
}
svg.push_str(&format!(
r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" font-size="12" fill="{}" font-family="{}">{}</text>"#,
margin_left + plot_width / 2.0, height - 8.0,
theme.text, theme.font_family, theme.labels.gamma_axis
));
let a_count = a.curves.len().min(2);
let b_count = b.curves.len().min(2);
let legend_entries = a_count + b_count;
let legend_height = legend_entries as f64 * 18.0 + 10.0;
svg.push_str(&format!(
r#"<g transform="translate({:.1}, {:.1})">"#,
margin_left + 10.0,
margin_top + 10.0
));
svg.push_str(&format!(
r#"<rect x="-5" y="-5" width="145" height="{legend_height:.1}" fill="{}" stroke="{}" stroke-width="1" rx="4"/>"#,
theme.legend_bg, theme.axis
));
let mut row = 0;
for curve in a.curves.iter().take(2) {
let y = row as f64 * 18.0 + 8.0;
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5"/>"#,
curve.color.to_rgb_string()
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{} {}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
label_a,
curve.label
));
row += 1;
}
for (i, curve) in b.curves.iter().take(2).enumerate() {
let y = row as f64 * 18.0 + 8.0;
let color = b_colors[i % b_colors.len()];
svg.push_str(&format!(
r#"<line x1="0" y1="{y:.1}" x2="18" y2="{y:.1}" stroke="{}" stroke-width="2.5" stroke-dasharray="4,2"/>"#,
color
));
svg.push_str(&format!(
r#"<text x="24" y="{:.1}" font-size="11" fill="{}" font-family="{}">{} {}</text>"#,
y + 4.0,
theme.text,
theme.font_family,
label_b,
curve.label
));
row += 1;
}
svg.push_str("</g>");
svg.push_str("</svg>");
svg
}
fn rescale_curve_path(
curve: &CartesianCurve,
margin_left: f64,
margin_top: f64,
plot_width: f64,
plot_height: f64,
y_max: f64,
max_gamma: f64,
) -> String {
let mut path = String::new();
for (i, pt) in curve.points.iter().enumerate() {
let x = margin_left + plot_width * (pt.gamma / max_gamma);
let y = margin_top + plot_height * (1.0 - pt.intensity / y_max);
if i == 0 {
path.push_str(&format!("M {x:.1} {y:.1}"));
} else {
path.push_str(&format!(" L {x:.1} {y:.1}"));
}
}
path
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Eulumdat;
#[allow(clippy::field_reassign_with_default)]
fn create_test_ldt() -> Eulumdat {
let mut ldt = Eulumdat::default();
ldt.symmetry = crate::Symmetry::BothPlanes;
ldt.c_angles = vec![0.0, 30.0, 60.0, 90.0];
ldt.g_angles = vec![0.0, 30.0, 60.0, 90.0];
ldt.intensities = vec![
vec![100.0, 90.0, 70.0, 40.0],
vec![95.0, 85.0, 65.0, 35.0],
vec![90.0, 80.0, 60.0, 30.0],
vec![85.0, 75.0, 55.0, 25.0],
];
ldt
}
#[test]
fn test_polar_to_svg() {
let ldt = create_test_ldt();
let polar = PolarDiagram::from_eulumdat(&ldt);
let svg = polar.to_svg(500.0, 500.0, &SvgTheme::light());
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("C0-C180"));
assert!(svg.contains("cd/1000lm"));
}
#[test]
fn test_cartesian_to_svg() {
let ldt = create_test_ldt();
let cartesian = CartesianDiagram::from_eulumdat(&ldt, 500.0, 380.0, 8);
let svg = cartesian.to_svg(500.0, 380.0, &SvgTheme::light());
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("Gamma"));
}
#[test]
fn test_theme_css_variables() {
let theme = SvgTheme::css_variables();
assert!(theme.background.starts_with("var("));
}
#[test]
fn test_dark_theme() {
let ldt = create_test_ldt();
let polar = PolarDiagram::from_eulumdat(&ldt);
let svg = polar.to_svg(500.0, 500.0, &SvgTheme::dark());
assert!(svg.contains("#0f172a")); }
}