1use super::svg::{self, SvgOptions};
4use crate::{Explanation, Result, ShapError};
5
6pub fn global_bar(explanation: &Explanation, options: &SvgOptions) -> Result<String> {
8 document(
9 "Global SHAP importance",
10 &svg::global_bar(explanation, options)?,
11 )
12}
13
14pub fn waterfall(
16 explanation: &Explanation,
17 sample: usize,
18 output: usize,
19 options: &SvgOptions,
20) -> Result<String> {
21 document(
22 "SHAP waterfall",
23 &svg::waterfall(explanation, sample, output, options)?,
24 )
25}
26
27pub fn document(title: &str, svg: &str) -> Result<String> {
29 if title.trim().is_empty() || !svg.trim_start().starts_with("<svg") || !svg.ends_with("</svg>")
30 {
31 return Err(ShapError::InvalidConfiguration(
32 "interactive HTML requires a title and complete SVG".into(),
33 ));
34 }
35 let title = escape(title);
36 Ok(format!(
37 r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{title}</title><style>body{{font-family:system-ui,sans-serif;margin:1rem;background:#fff;color:#222}}.controls{{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}}button{{padding:.45rem .7rem}}figure{{margin:1rem 0;overflow:auto}}svg [data-tooltip],svg rect,svg circle,svg polygon{{cursor:help}}#tooltip{{position:fixed;display:none;pointer-events:none;background:#111;color:#fff;padding:.35rem .5rem;border-radius:.25rem;font-size:.85rem}}.contrast{{filter:contrast(1.4) saturate(1.2)}}.legend span{{margin-right:1rem}}.positive::before,.negative::before{{content:"";display:inline-block;width:.8rem;height:.8rem;margin-right:.3rem}}.positive::before{{background:#e53935}}.negative::before{{background:#1e88e5}}</style></head><body><main><h1>{title}</h1><div class="controls" aria-label="Plot controls"><button id="contrast" type="button" aria-pressed="false">High contrast</button><button id="download" type="button">Download SVG</button><span id="status" role="status" aria-live="polite"></span></div><div class="legend" aria-label="Contribution legend"><span class="positive">Positive contribution</span><span class="negative">Negative contribution</span></div><figure aria-labelledby="caption">{svg}<figcaption id="caption">{title}. Focus controls with the keyboard; hover plot marks for element details.</figcaption></figure><div id="tooltip" role="tooltip"></div></main><script>(()=>{{const s=document.querySelector('svg'),t=document.querySelector('#tooltip'),status=document.querySelector('#status'),contrast=document.querySelector('#contrast');contrast.onclick=()=>{{const on=s.classList.toggle('contrast');contrast.setAttribute('aria-pressed',on);status.textContent=on?'High contrast enabled':'High contrast disabled'}};document.querySelector('#download').onclick=()=>{{const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([s.outerHTML],{{type:'image/svg+xml'}}));a.download='shap.svg';a.click();URL.revokeObjectURL(a.href);status.textContent='SVG download prepared'}};s.addEventListener('pointermove',e=>{{const mark=e.target.closest('rect,circle,polygon');if(!mark)return;t.textContent=mark.dataset.tooltip||mark.getAttribute('aria-label')||mark.tagName.toLowerCase()+' plot mark';t.style.display='block';t.style.left=e.clientX+12+'px';t.style.top=e.clientY+12+'px'}});s.addEventListener('pointerleave',()=>t.style.display='none')}})();</script></body></html>"#
38 ))
39}
40
41fn escape(value: &str) -> String {
42 value
43 .replace('&', "&")
44 .replace('<', "<")
45 .replace('>', ">")
46 .replace('"', """)
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52 use ndarray::{array, Array3};
53
54 #[test]
55 fn creates_accessible_standalone_interactive_output() {
56 let explanation = Explanation::new(
57 Array3::from_shape_vec((1, 2, 1), vec![1.0, -2.0]).unwrap(),
58 array![[0.0]],
59 array![[3.0, 4.0]],
60 )
61 .unwrap();
62 let html = global_bar(&explanation, &SvgOptions::default()).unwrap();
63 assert!(html.starts_with("<!doctype html>"));
64 assert!(html.contains("aria-live=\"polite\""));
65 assert!(html.contains("Download SVG"));
66 assert!(html.contains("<svg"));
67 }
68}