Skip to main content

cqlib_core/visualization/state/
plot.rs

1// This code is part of Cqlib.
2//
3// (C) Copyright China Telecom Quantum Group 2026
4//
5// This code is licensed under the Apache License, Version 2.0. You may
6// obtain a copy of this license in the LICENSE.txt file in the root directory
7// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8//
9// Any modifications or derivative works of this code must retain this
10// copyright notice, and modified files need to carry a notice indicating
11// that they have been altered from the originals.
12
13//! Bloch-vector, state-city, and Pauli-vector rendering for quantum states.
14
15use super::data::{StateVisualizationSource, basis_labels, state_to_density_matrix};
16use super::math::{clamp_bloch, local_bloch_vectors, pauli_coefficients};
17use super::options::StatePlotOptions;
18use crate::visualization::VisualizationError;
19use crate::visualization::svg::{escape_attr, escape_text, render_svg_to_file};
20
21/// Plot a single Bloch vector as SVG.
22///
23/// # Arguments
24///
25/// * `vector` - Bloch coordinates `(x, y, z)` with components typically in `[-1, 1]`.
26/// * `options` - Plot styling options such as title and figure size.
27///
28/// # Returns
29///
30/// SVG markup as a UTF-8 string.
31///
32/// # Examples
33///
34/// ```rust
35/// use cqlib_core::visualization::{StatePlotOptions, plot_bloch_vector};
36///
37/// let svg = plot_bloch_vector([0.0, 0.0, 1.0], &StatePlotOptions::default()).unwrap();
38/// assert!(svg.contains("<svg"));
39/// ```
40pub fn plot_bloch_vector(
41    vector: [f64; 3],
42    options: &StatePlotOptions,
43) -> Result<String, VisualizationError> {
44    Ok(render_bloch_grid(
45        &[("q0".to_string(), clamp_bloch(vector))],
46        options.title.as_deref(),
47        options,
48    ))
49}
50
51/// Plot one reduced Bloch vector per qubit as SVG.
52///
53/// # Arguments
54///
55/// * `state` - Input core state object accepted by state visualization routines.
56/// * `options` - Plot styling options such as title, colors, and bit order.
57///
58/// # Returns
59///
60/// SVG markup as a UTF-8 string.
61///
62/// # Errors
63///
64/// Returns [`VisualizationError::InvalidInput`] when the state payload is invalid.
65///
66/// # Examples
67///
68/// ```rust
69/// use cqlib_core::qis::Statevector;
70/// use cqlib_core::visualization::{StatePlotOptions, plot_bloch_multivector};
71/// use num_complex::Complex64;
72///
73/// let state = Statevector::from_state(
74///     1,
75///     vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)],
76/// ).unwrap();
77/// let svg = plot_bloch_multivector(&state, &StatePlotOptions::default()).unwrap();
78/// assert!(svg.contains("<svg"));
79/// ```
80pub fn plot_bloch_multivector<S: StateVisualizationSource + ?Sized>(
81    state: &S,
82    options: &StatePlotOptions,
83) -> Result<String, VisualizationError> {
84    let vectors = local_bloch_vectors(state)?;
85    let ordered = order_by_display(vectors, options.reverse_bits)
86        .into_iter()
87        .map(|(qubit, vector)| (format!("q{qubit}"), vector))
88        .collect::<Vec<_>>();
89    Ok(render_bloch_grid(
90        &ordered,
91        options.title.as_deref(),
92        options,
93    ))
94}
95
96/// Plot real and imaginary density-matrix components as an SVG state-city view.
97///
98/// # Arguments
99///
100/// * `state` - Input core state object accepted by state visualization routines.
101/// * `options` - Plot styling options such as title, opacity, and bit order.
102///
103/// # Returns
104///
105/// SVG markup as a UTF-8 string.
106///
107/// # Errors
108///
109/// Returns [`VisualizationError::InvalidInput`] when the state payload is invalid.
110///
111/// # Examples
112///
113/// ```rust
114/// use cqlib_core::qis::Statevector;
115/// use cqlib_core::visualization::{StatePlotOptions, plot_state_city};
116/// use num_complex::Complex64;
117///
118/// let state = Statevector::from_state(
119///     1,
120///     vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)],
121/// ).unwrap();
122/// let svg = plot_state_city(&state, &StatePlotOptions::default()).unwrap();
123/// assert!(svg.contains("Re[rho]"));
124/// ```
125pub fn plot_state_city<S: StateVisualizationSource + ?Sized>(
126    state: &S,
127    options: &StatePlotOptions,
128) -> Result<String, VisualizationError> {
129    let (num_qubits, rho) = state_to_density_matrix(state)?;
130    let dim = 1usize << num_qubits;
131    let labels = basis_labels(num_qubits, options.reverse_bits);
132    // Labels are display-order dependent; matrix lookup must map each displayed
133    // row and column back to the stored density-matrix index independently.
134    let matrix_value = |row: usize, col: usize| {
135        let data_row = display_to_density_index(row, num_qubits, options.reverse_bits);
136        let data_col = display_to_density_index(col, num_qubits, options.reverse_bits);
137        rho[data_row * dim + data_col]
138    };
139    let max_abs = rho
140        .iter()
141        .map(|value| value.norm())
142        .fold(0.0_f64, f64::max)
143        .max(1e-12);
144    let (width, height) = options
145        .figsize
146        .map(|(w, h)| (w.max(4.0) * 100.0, h.max(3.0) * 100.0))
147        .unwrap_or((980.0, 520.0));
148    let mut out = svg_start(width, height);
149    if let Some(title) = &options.title {
150        out.push_str(&svg_text(width / 2.0, 28.0, title, 18, "middle", "#202124"));
151    }
152    let top = if options.title.is_some() { 52.0 } else { 28.0 };
153    let has_imaginary = rho.iter().any(|value| value.im.abs() > 1e-12);
154    let panel_gap = 32.0;
155    let side_margin = 36.0;
156    let panel_h = height - top - 34.0;
157    let panel_w = if has_imaginary {
158        (width - 2.0 * side_margin - panel_gap) / 2.0
159    } else {
160        width - 2.0 * side_margin
161    };
162    draw_matrix_panel(
163        &mut out,
164        "Re[rho]",
165        side_margin,
166        top,
167        panel_w,
168        panel_h,
169        dim,
170        &labels,
171        |row, col| matrix_value(row, col).re,
172        max_abs,
173        options.alpha,
174    );
175    if has_imaginary {
176        draw_matrix_panel(
177            &mut out,
178            "Im[rho]",
179            side_margin + panel_w + panel_gap,
180            top,
181            panel_w,
182            panel_h,
183            dim,
184            &labels,
185            |row, col| matrix_value(row, col).im,
186            max_abs,
187            options.alpha,
188        );
189    } else {
190        out.push_str("<!-- Im[rho] omitted: zero imaginary component -->");
191    }
192    out.push_str("</svg>");
193    Ok(out)
194}
195
196/// Plot Pauli-basis expectation values as an SVG bar chart.
197///
198/// By default, labels follow [`crate::qis::pauli::PauliString`]: the rightmost
199/// character acts on qubit 0. For example, `IX` measures X on qubit 0 of a
200/// two-qubit state. Setting `options.reverse_bits` reverses the characters in
201/// each label without changing the state or the corresponding expectation value.
202///
203/// # Arguments
204///
205/// * `state` - Input core state object accepted by state visualization routines.
206/// * `options` - Plot styling options such as title, colors, and bit order.
207///
208/// # Returns
209///
210/// SVG markup as a UTF-8 string.
211///
212/// # Errors
213///
214/// Returns [`VisualizationError::InvalidInput`] when the state payload is invalid.
215///
216/// # Examples
217///
218/// ```rust
219/// use cqlib_core::qis::Statevector;
220/// use cqlib_core::visualization::{StatePlotOptions, plot_state_paulivec};
221/// use num_complex::Complex64;
222///
223/// let state = Statevector::from_state(
224///     1,
225///     vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)],
226/// ).unwrap();
227/// let svg = plot_state_paulivec(&state, &StatePlotOptions::default()).unwrap();
228/// assert!(svg.contains("<svg"));
229/// ```
230pub fn plot_state_paulivec<S: StateVisualizationSource + ?Sized>(
231    state: &S,
232    options: &StatePlotOptions,
233) -> Result<String, VisualizationError> {
234    let (num_qubits, rho) = state_to_density_matrix(state)?;
235    let coeffs = pauli_coefficients(num_qubits, &rho, options.reverse_bits)?;
236    let max_abs = coeffs
237        .iter()
238        .map(|(_, value)| value.abs())
239        .fold(0.0_f64, f64::max)
240        .max(1.0);
241    let (width, height) = options
242        .figsize
243        .map(|(w, h)| (w.max(4.0) * 100.0, h.max(3.0) * 100.0))
244        .unwrap_or((820.0, 480.0));
245    let left = 66.0;
246    let right = 28.0;
247    let top = if options.title.is_some() { 58.0 } else { 34.0 };
248    let bottom = 94.0;
249    let plot_w = width - left - right;
250    let plot_h = height - top - bottom;
251    let zero_y = top + plot_h / 2.0;
252    let bar_w = (plot_w / coeffs.len().max(1) as f64 * 0.72).clamp(2.0, 28.0);
253    let color_pos = options
254        .color
255        .first()
256        .map(String::as_str)
257        .unwrap_or("#4569d4");
258    let color_neg = options
259        .color
260        .get(1)
261        .map(String::as_str)
262        .unwrap_or("#d64b5f");
263
264    let mut out = svg_start(width, height);
265    if let Some(title) = &options.title {
266        out.push_str(&svg_text(width / 2.0, 28.0, title, 18, "middle", "#202124"));
267    }
268    for tick in -2..=2 {
269        let value = tick as f64 * max_abs / 2.0;
270        let y = zero_y - (value / max_abs) * (plot_h / 2.0);
271        out.push_str(&svg_line(left, y, left + plot_w, y, "#dfe3eb", 1.0));
272        out.push_str(&svg_text(
273            left - 8.0,
274            y + 4.0,
275            &format!("{value:.2}"),
276            11,
277            "end",
278            "#5f6673",
279        ));
280    }
281    out.push_str(&svg_line(left, top, left, top + plot_h, "#303642", 1.2));
282    out.push_str(&svg_line(
283        left,
284        zero_y,
285        left + plot_w,
286        zero_y,
287        "#303642",
288        1.2,
289    ));
290
291    for (idx, (label, value)) in coeffs.iter().enumerate() {
292        let cx = left + (idx as f64 + 0.5) * plot_w / coeffs.len() as f64;
293        let h = (value.abs() / max_abs) * (plot_h / 2.0);
294        let y = if *value >= 0.0 { zero_y - h } else { zero_y };
295        let color = if *value >= 0.0 { color_pos } else { color_neg };
296        out.push_str(&format!(
297            "<rect x=\"{:.3}\" y=\"{y:.3}\" width=\"{bar_w:.3}\" height=\"{:.3}\" rx=\"2\" fill=\"{}\"/>",
298            cx - bar_w / 2.0,
299            h.max(1.0),
300            escape_attr(color)
301        ));
302        if coeffs.len() <= 64 {
303            let ly = top + plot_h + 18.0;
304            out.push_str(&format!(
305                "<text x=\"{cx:.3}\" y=\"{ly:.3}\" font-family=\"Arial, sans-serif\" font-size=\"10\" fill=\"#303642\" text-anchor=\"end\" transform=\"rotate(-55 {cx:.3} {ly:.3})\">{}</text>",
306                escape_text(label)
307            ));
308        }
309    }
310    out.push_str("</svg>");
311    Ok(out)
312}
313
314/// Render SVG markup to `.svg` or rasterized `.png`.
315///
316/// # Arguments
317///
318/// * `svg` - SVG markup produced by a state plotting routine.
319/// * `output_path` - Target file path. `.svg` writes vector output, `.png` writes raster output.
320///
321/// # Errors
322///
323/// Returns [`VisualizationError::Io`] or [`VisualizationError::SvgRenderFailed`] when file
324/// output or PNG rasterization fails.
325///
326/// # Examples
327///
328/// ```no_run
329/// use cqlib_core::qis::Statevector;
330/// use cqlib_core::visualization::{
331///     StatePlotOptions, plot_bloch_multivector, render_state_plot_to_file,
332/// };
333/// use num_complex::Complex64;
334///
335/// let state = Statevector::from_state(
336///     1,
337///     vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)],
338/// ).unwrap();
339/// let svg = plot_bloch_multivector(&state, &StatePlotOptions::default()).unwrap();
340/// render_state_plot_to_file(&svg, "bloch.svg").unwrap();
341/// ```
342pub fn render_state_plot_to_file(svg: &str, output_path: &str) -> Result<(), VisualizationError> {
343    render_svg_to_file(svg, output_path, 1.0)
344}
345
346fn render_bloch_grid(
347    items: &[(String, [f64; 3])],
348    title: Option<&str>,
349    options: &StatePlotOptions,
350) -> String {
351    let count = items.len().max(1);
352    let cols = count.min(4);
353    let rows = count.div_ceil(cols);
354    let title_h = if title.is_some() { 42.0 } else { 14.0 };
355    let (width, height) = options
356        .figsize
357        .map(|(w, h)| (w.max(3.0) * 100.0, h.max(3.0) * 100.0))
358        .unwrap_or((
359            (cols as f64 * 270.0).max(300.0),
360            rows as f64 * 270.0 + title_h,
361        ));
362    let mut out = svg_start(width, height);
363    if let Some(title) = title {
364        out.push_str(&svg_text(width / 2.0, 28.0, title, 18, "middle", "#202124"));
365    }
366    let cell_w = width / cols as f64;
367    let cell_h = (height - title_h) / rows as f64;
368    for (idx, (label, vector)) in items.iter().enumerate() {
369        let row = idx / cols;
370        let col = idx % cols;
371        let cx = col as f64 * cell_w + cell_w / 2.0;
372        let cy = title_h + row as f64 * cell_h + cell_h / 2.0 + 8.0;
373        draw_bloch(&mut out, cx, cy, cell_w.min(cell_h) * 0.39, label, *vector);
374    }
375    out.push_str("</svg>");
376    out
377}
378
379/// Draw a pseudo-3D Bloch sphere and one vector arrow into the active SVG buffer.
380///
381/// The sphere is still pure SVG: grid curves are sampled in logical Bloch coordinates
382/// and projected into two dimensions by [`project_bloch_point`].
383fn draw_bloch(out: &mut String, cx: f64, cy: f64, r: f64, label: &str, vector: [f64; 3]) {
384    let grad_id = format!("bloch-grad-{:.0}-{:.0}", cx, cy);
385    let arrow_id = format!("bloch-arrow-{:.0}-{:.0}", cx, cy);
386    let clip_id = format!("bloch-clip-{:.0}-{:.0}", cx, cy);
387    out.push_str(&format!(
388        "<defs><radialGradient id=\"{}\" cx=\"36%\" cy=\"24%\" r=\"78%\"><stop offset=\"0%\" stop-color=\"#ffffff\" stop-opacity=\"0.88\"/><stop offset=\"58%\" stop-color=\"#d7e7ee\" stop-opacity=\"0.50\"/><stop offset=\"100%\" stop-color=\"#b6ccd6\" stop-opacity=\"0.38\"/></radialGradient><marker id=\"{}\" markerWidth=\"7\" markerHeight=\"7\" refX=\"6.0\" refY=\"3.5\" orient=\"auto\" markerUnits=\"strokeWidth\"><path d=\"M0,0 L7,3.5 L0,7 z\" fill=\"#D62728\"/></marker><clipPath id=\"{}\"><circle cx=\"{cx:.3}\" cy=\"{cy:.3}\" r=\"{r:.3}\"/></clipPath></defs>",
389        escape_attr(&grad_id),
390        escape_attr(&arrow_id),
391        escape_attr(&clip_id)
392    ));
393    out.push_str("<g data-cqlib-bloch-3d=\"true\">");
394    out.push_str(&format!(
395        "<circle cx=\"{cx:.3}\" cy=\"{cy:.3}\" r=\"{r:.3}\" fill=\"url(#{})\" stroke=\"#c7c7c7\" stroke-width=\"1.0\"/>",
396        escape_attr(&grad_id)
397    ));
398    out.push_str(&format!(
399        "<g clip-path=\"url(#{})\">",
400        escape_attr(&clip_id)
401    ));
402
403    for z in [
404        -0.83, -0.67, -0.50, -0.33, -0.17, 0.17, 0.33, 0.50, 0.67, 0.83,
405    ] {
406        let radius = (1.0_f64 - z * z).sqrt();
407        let points = (0..=96)
408            .map(|idx| {
409                let theta = 2.0 * std::f64::consts::PI * idx as f64 / 96.0;
410                project_bloch_point(cx, cy, r, [radius * theta.cos(), radius * theta.sin(), z])
411            })
412            .collect::<Vec<_>>();
413        out.push_str(&svg_projected_path(&points, "#8c8c8c", 0.55, 0.36, None));
414    }
415
416    for phi in (0..180).step_by(15) {
417        let phi = (phi as f64).to_radians();
418        let points = (0..=96)
419            .map(|idx| {
420                let theta = std::f64::consts::PI * idx as f64 / 96.0;
421                project_bloch_point(
422                    cx,
423                    cy,
424                    r,
425                    [
426                        theta.sin() * phi.cos(),
427                        theta.sin() * phi.sin(),
428                        theta.cos(),
429                    ],
430                )
431            })
432            .collect::<Vec<_>>();
433        out.push_str(&svg_projected_path(&points, "#8c8c8c", 0.50, 0.30, None));
434    }
435
436    draw_reference_circle(out, cx, cy, r, ReferenceCircle::Equator);
437    draw_reference_circle(out, cx, cy, r, ReferenceCircle::PrimeMeridian);
438    out.push_str("</g>");
439
440    draw_bloch_axis(out, cx, cy, r, [1.0, 0.0, 0.0], "X");
441    draw_bloch_axis(out, cx, cy, r, [0.0, 1.0, 0.0], "Y");
442    draw_bloch_axis(out, cx, cy, r, [0.0, 0.0, 1.0], "Z");
443
444    let (ex, ey, _) = project_bloch_point(cx, cy, r * 0.92, vector);
445    let (sx, sy, _) = project_bloch_point(cx, cy, r, [0.0, 0.0, 0.0]);
446    out.push_str(&format!(
447        "<line x1=\"{sx:.3}\" y1=\"{sy:.3}\" x2=\"{ex:.3}\" y2=\"{ey:.3}\" stroke=\"#D62728\" stroke-width=\"2.3\" stroke-linecap=\"round\" marker-end=\"url(#{})\"/>",
448        escape_attr(&arrow_id)
449    ));
450    out.push_str("</g>");
451    out.push_str(&svg_text(cx, cy - r - 16.0, label, 18, "middle", "#000000"));
452}
453
454/// Draw one labelled Bloch axis through the sphere center.
455fn draw_bloch_axis(out: &mut String, cx: f64, cy: f64, r: f64, axis: [f64; 3], label: &str) {
456    let neg = [-axis[0], -axis[1], -axis[2]];
457    let (x1, y1, _) = project_bloch_point(cx, cy, r, neg);
458    let (x2, y2, _) = project_bloch_point(cx, cy, r, axis);
459    out.push_str(&svg_line(x1, y1, x2, y2, "#444444", 1.0));
460    let (tx, ty, _) = project_bloch_point(cx, cy, r * 1.08, axis);
461    out.push_str(&svg_text(tx, ty + 4.0, label, 15, "middle", "#000000"));
462}
463
464#[derive(Clone, Copy)]
465enum ReferenceCircle {
466    Equator,
467    PrimeMeridian,
468}
469
470fn draw_reference_circle(out: &mut String, cx: f64, cy: f64, r: f64, circle: ReferenceCircle) {
471    let points = (0..=160)
472        .map(|idx| {
473            let theta = 2.0 * std::f64::consts::PI * idx as f64 / 160.0;
474            let point = match circle {
475                ReferenceCircle::Equator => [theta.cos(), theta.sin(), 0.0],
476                ReferenceCircle::PrimeMeridian => [0.0, theta.sin(), theta.cos()],
477            };
478            project_bloch_point(cx, cy, r, point)
479        })
480        .collect::<Vec<_>>();
481    match circle {
482        ReferenceCircle::Equator => {
483            out.push_str(&svg_projected_path(&points, "#000000", 1.10, 0.92, None));
484        }
485        ReferenceCircle::PrimeMeridian => {
486            out.push_str(&svg_projected_path(
487                &points,
488                "#000000",
489                1.00,
490                0.88,
491                Some("5 4"),
492            ));
493        }
494    }
495}
496
497/// Project a Bloch-sphere point into SVG coordinates.
498///
499/// The returned depth is reserved for callers that need front/back ordering; current
500/// grid rendering keeps a deterministic draw order instead.
501fn project_bloch_point(cx: f64, cy: f64, r: f64, point: [f64; 3]) -> (f64, f64, f64) {
502    let [x, y, z] = point;
503    let sx = 0.88 * x + 0.42 * y;
504    let sy = 0.08 * x - 0.40 * y - 0.90 * z;
505    let depth = 0.54 * x + 0.58 * y + 0.24 * z;
506    (cx + sx * r, cy + sy * r, depth)
507}
508
509/// Reorder qubit-indexed vectors for display without changing their labels.
510fn order_by_display(
511    mut vectors: Vec<(usize, [f64; 3])>,
512    reverse_bits: bool,
513) -> Vec<(usize, [f64; 3])> {
514    if reverse_bits {
515        vectors.reverse();
516    }
517    vectors
518}
519
520/// Draw one real or imaginary density-matrix panel for the state-city plot.
521///
522/// Cell area scales with `sqrt(abs(value) / max_abs)` so small amplitudes remain visible
523/// while the sign is encoded by color.
524#[allow(clippy::too_many_arguments)]
525fn draw_matrix_panel<F>(
526    out: &mut String,
527    title: &str,
528    x: f64,
529    y: f64,
530    w: f64,
531    h: f64,
532    dim: usize,
533    labels: &[String],
534    value_at: F,
535    max_abs: f64,
536    alpha: f64,
537) where
538    F: Fn(usize, usize) -> f64,
539{
540    out.push_str(&svg_text(
541        x + w / 2.0,
542        y + 18.0,
543        title,
544        14,
545        "middle",
546        "#202124",
547    ));
548    let title_h = 32.0;
549    let left_label_w = if dim <= 16 { 34.0 } else { 10.0 };
550    let bottom_label_h = if dim <= 16 { 46.0 } else { 12.0 };
551    let right_pad = 10.0;
552    let available_w = (w - left_label_w - right_pad).max(40.0);
553    let available_h = (h - title_h - bottom_label_h).max(40.0);
554    let grid = available_w.min(available_h).max(40.0);
555    let gx = x + left_label_w + ((available_w - grid) / 2.0).max(0.0);
556    let gy = y + title_h + ((available_h - grid) / 2.0).max(0.0);
557    let cell = grid / dim as f64;
558    out.push_str(&format!(
559        "<rect x=\"{gx:.3}\" y=\"{gy:.3}\" width=\"{grid:.3}\" height=\"{grid:.3}\" fill=\"#f7f8fb\" stroke=\"#c7ceda\"/>"
560    ));
561    for row in 0..dim {
562        for col in 0..dim {
563            let value = value_at(row, col);
564            let mag = (value.abs() / max_abs).min(1.0);
565            let fill = if value >= 0.0 { "#4569d4" } else { "#d64b5f" };
566            let inset = cell * (1.0 - mag.sqrt()) * 0.42;
567            let rx = gx + col as f64 * cell + inset;
568            let ry = gy + row as f64 * cell + inset;
569            let size = (cell - 2.0 * inset).max(1.0);
570            out.push_str(&format!(
571                "<rect x=\"{rx:.3}\" y=\"{ry:.3}\" width=\"{size:.3}\" height=\"{size:.3}\" fill=\"{fill}\" fill-opacity=\"{:.3}\"/>",
572                alpha.clamp(0.05, 1.0)
573            ));
574        }
575    }
576    if dim <= 16 {
577        for (idx, label) in labels.iter().enumerate() {
578            let tx = gx + (idx as f64 + 0.5) * cell;
579            let ty = gy + grid + 14.0;
580            out.push_str(&format!(
581                "<text x=\"{tx:.3}\" y=\"{ty:.3}\" font-family=\"Arial, sans-serif\" font-size=\"12\" fill=\"#303642\" text-anchor=\"end\" transform=\"rotate(-55 {tx:.3} {ty:.3})\">{}</text>",
582                escape_text(label)
583            ));
584            out.push_str(&svg_text(
585                gx - 6.0,
586                gy + (idx as f64 + 0.5) * cell + 3.0,
587                label,
588                12,
589                "end",
590                "#303642",
591            ));
592        }
593    }
594}
595
596/// Map a displayed basis row or column back to the density-matrix storage index.
597fn display_to_density_index(index: usize, num_qubits: usize, reverse_bits: bool) -> usize {
598    if reverse_bits {
599        bit_reverse_index(index, num_qubits)
600    } else {
601        index
602    }
603}
604
605/// Reverse bit order within the fixed-width basis index.
606fn bit_reverse_index(index: usize, num_bits: usize) -> usize {
607    let mut out = 0usize;
608    for bit in 0..num_bits {
609        if ((index >> bit) & 1) == 1 {
610            out |= 1usize << (num_bits - 1 - bit);
611        }
612    }
613    out
614}
615
616/// Start a white-background SVG document.
617fn svg_start(width: f64, height: f64) -> String {
618    format!(
619        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" viewBox=\"0 0 {width:.3} {height:.3}\"><rect width=\"100%\" height=\"100%\" fill=\"white\"/>"
620    )
621}
622
623/// SVG line element with escaped color attribute.
624fn svg_line(x1: f64, y1: f64, x2: f64, y2: f64, color: &str, width: f64) -> String {
625    format!(
626        "<line x1=\"{x1:.3}\" y1=\"{y1:.3}\" x2=\"{x2:.3}\" y2=\"{y2:.3}\" stroke=\"{}\" stroke-width=\"{width:.3}\"/>",
627        escape_attr(color)
628    )
629}
630
631/// SVG text element with escaped text and attributes.
632fn svg_text(x: f64, y: f64, text: &str, size: u8, anchor: &str, color: &str) -> String {
633    format!(
634        "<text x=\"{x:.3}\" y=\"{y:.3}\" font-family=\"Arial, sans-serif\" font-size=\"{size}\" fill=\"{}\" text-anchor=\"{}\">{}</text>",
635        escape_attr(color),
636        escape_attr(anchor),
637        escape_text(text)
638    )
639}
640
641/// Convert projected points into an SVG path.
642fn svg_projected_path(
643    points: &[(f64, f64, f64)],
644    color: &str,
645    width: f64,
646    opacity: f64,
647    dasharray: Option<&str>,
648) -> String {
649    if points.is_empty() {
650        return String::new();
651    }
652    let mut path = format!("M {:.3} {:.3}", points[0].0, points[0].1);
653    for (x, y, _) in points.iter().skip(1) {
654        path.push_str(&format!(" L {x:.3} {y:.3}"));
655    }
656    let dash = dasharray
657        .map(|value| format!(" stroke-dasharray=\"{}\"", escape_attr(value)))
658        .unwrap_or_default();
659    format!(
660        "<path d=\"{path}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width:.3}\" stroke-opacity=\"{opacity:.3}\"{dash}/>",
661        escape_attr(color)
662    )
663}