1use crate::error::DispatchError;
9use serde_json::Value;
10
11#[derive(Debug, Clone, Default)]
13pub struct RenderOptions {
14 pub title: Option<String>,
17}
18
19#[derive(Debug, Clone)]
21pub struct Envelope {
22 pub blocks: Vec<Value>,
23 pub summary: Option<String>,
24 pub verified: Option<bool>,
28}
29
30pub fn parse_envelope(raw: &str) -> Result<Envelope, DispatchError> {
45 let fenced = extract_fenced_block(raw);
46 let balanced = extract_balanced_object(raw);
47 let candidates: [Option<&str>; 3] = [Some(raw.trim()), fenced.as_deref(), balanced.as_deref()];
48
49 for candidate in candidates.into_iter().flatten() {
50 if let Some(parsed) = try_parse_object(candidate) {
51 if let Some(obj) = parsed.as_object() {
52 if let Some(Value::Array(_)) = obj.get("blocks") {
53 return Ok(normalize_envelope(obj));
54 }
55 }
56 }
57 }
58
59 Err(DispatchError::new(
60 "could not find a render envelope ({ \"blocks\": [...] }) in the agent output",
61 ))
62}
63
64fn try_parse_object(text: &str) -> Option<Value> {
65 serde_json::from_str(text).ok()
66}
67
68fn extract_fenced_block(raw: &str) -> Option<String> {
69 let lower = raw.to_ascii_lowercase();
71 let mut search_from = 0usize;
72 loop {
73 let rel_start = lower[search_from..].find("```")?;
74 let start = search_from + rel_start;
75 let mut cursor = start + 3;
76 if lower[cursor..].starts_with("json") {
78 cursor += 4;
79 }
80 let bytes = raw.as_bytes();
82 let mut i = cursor;
83 while i < bytes.len() && (bytes[i] as char).is_whitespace() && bytes[i] != b'\n' {
84 i += 1;
85 }
86 if i >= bytes.len() || bytes[i] != b'\n' {
87 search_from = start + 3;
89 continue;
90 }
91 let content_start = i + 1;
92 if let Some(rel_end) = raw[content_start..].find("\n```") {
93 let content_end = content_start + rel_end;
94 return Some(raw[content_start..content_end].trim().to_string());
95 }
96 return None;
97 }
98}
99
100fn extract_balanced_object(raw: &str) -> Option<String> {
102 let chars: Vec<char> = raw.chars().collect();
103 let start = chars.iter().position(|&c| c == '{')?;
104 for i in start..chars.len() {
105 if chars[i] != '{' {
106 continue;
107 }
108 if let Some(end) = match_closing_brace(&chars, i) {
109 let slice: String = chars[i..=end].iter().collect();
110 if slice.contains("\"blocks\"") {
111 return Some(slice);
112 }
113 }
114 }
115 None
116}
117
118fn match_closing_brace(text: &[char], open: usize) -> Option<usize> {
120 let mut depth: i64 = 0;
121 let mut in_string = false;
122 let mut escaped = false;
123 for (i, &ch) in text.iter().enumerate().skip(open) {
124 if in_string {
125 if escaped {
126 escaped = false;
127 } else if ch == '\\' {
128 escaped = true;
129 } else if ch == '"' {
130 in_string = false;
131 }
132 continue;
133 }
134 if ch == '"' {
135 in_string = true;
136 } else if ch == '{' {
137 depth += 1;
138 } else if ch == '}' {
139 depth -= 1;
140 if depth == 0 {
141 return Some(i);
142 }
143 }
144 }
145 None
146}
147
148fn normalize_envelope(obj: &serde_json::Map<String, Value>) -> Envelope {
149 let blocks = match obj.get("blocks") {
150 Some(Value::Array(arr)) => arr.clone(),
151 _ => Vec::new(),
152 };
153 let summary = match obj.get("summary") {
154 Some(Value::String(s)) => Some(s.clone()),
155 _ => None,
156 };
157 let verified = obj.get("verified").and_then(Value::as_bool);
158 Envelope {
159 blocks,
160 summary,
161 verified,
162 }
163}
164
165const STYLES: &str = r#":root { color-scheme: light dark; }
170* { box-sizing: border-box; }
171body {
172 margin: 0; padding: 2rem;
173 font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
174 background: #f6f7f9; color: #1c1e21;
175}
176@media (prefers-color-scheme: dark) {
177 body { background: #16181c; color: #e6e8eb; }
178 .card, .panel { background: #22252b !important; border-color: #33373f !important; }
179 th { background: #2a2e35 !important; }
180 tr:nth-child(even) td { background: #1c1f24 !important; }
181}
182h1 { font-size: 1.4rem; margin: 0 0 1.25rem; }
183.title-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; margin: 0 0 1.25rem; }
184.title-row h1 { margin: 0; }
185.verified-pill {
186 display: inline-flex; align-items: center; gap: .3rem;
187 font-size: .78rem; font-weight: 600; padding: .2rem .55rem; border-radius: 999px;
188 background: #dafbe1; color: #1a7f37; border: 1px solid #aceebb;
189}
190@media (prefers-color-scheme: dark) {
191 .verified-pill { background: #12331d !important; color: #4ac26b !important; border-color: #2a5a38 !important; }
192 .definition code, .definition pre { background: #16181c !important; }
193}
194.definition pre {
195 margin: .35rem 0 .75rem; padding: .6rem .75rem; border-radius: 6px; background: #f0f2f5;
196 overflow-x: auto; font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
197}
198.definition .meta { font-size: .85rem; }
199.definition .meta .k { opacity: .6; margin-right: .35rem; }
200.definition .note { font-size: .75rem; opacity: .6; margin-top: .5rem; font-style: italic; }
201h2 { font-size: 1rem; margin: 0 0 .75rem; font-weight: 600; opacity: .85; }
202.kpi-row { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
203.card {
204 background: #fff; border: 1px solid #e3e6ea; border-radius: 10px;
205 padding: 1rem 1.25rem; min-width: 160px; flex: 1 1 160px;
206}
207.card .label { font-size: .8rem; opacity: .7; margin-bottom: .35rem; }
208.card .value { font-size: 1.75rem; font-weight: 650; letter-spacing: -.01em; }
209.card .unit { font-size: .9rem; opacity: .6; margin-left: .25rem; font-weight: 400; }
210.card .delta { font-size: .8rem; margin-top: .35rem; }
211.delta.up { color: #1a7f37; } .delta.down { color: #cf222e; }
212.panel {
213 background: #fff; border: 1px solid #e3e6ea; border-radius: 10px;
214 padding: 1.25rem; margin-bottom: 1.5rem; overflow-x: auto;
215}
216table { border-collapse: collapse; width: 100%; font-size: .9rem; }
217th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid #e3e6ea; }
218th { background: #f0f2f5; font-weight: 600; }
219td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
220.summary { opacity: .8; max-width: 60ch; }
221.status-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
222.status-pill {
223 display: inline-flex; align-items: center; text-transform: uppercase; letter-spacing: .03em;
224 font-size: .72rem; font-weight: 700; padding: .2rem .55rem; border-radius: 999px;
225 background: #eaeef2; color: #414852; border: 1px solid #d5dae0;
226}
227.status-pill.ok { background: #dafbe1; color: #1a7f37; border-color: #aceebb; }
228.status-pill.warn { background: #fff3d4; color: #9a6700; border-color: #f2ddab; }
229.status-pill.bad { background: #ffd8d3; color: #cf222e; border-color: #f7bfba; }
230.status-label { font-weight: 600; }
231.status-sev { font-size: .75rem; opacity: .6; text-transform: uppercase; letter-spacing: .03em; }
232.status-detail { margin-top: .5rem; font-size: .9rem; opacity: .8; }
233@media (prefers-color-scheme: dark) {
234 .status-pill { background: #2a2e35 !important; color: #c8ccd2 !important; border-color: #3a3f47 !important; }
235 .status-pill.ok { background: #12331d !important; color: #4ac26b !important; border-color: #2a5a38 !important; }
236 .status-pill.warn { background: #3a2f10 !important; color: #d9a441 !important; border-color: #5a4a1e !important; }
237 .status-pill.bad { background: #3a1a1a !important; color: #f2837c !important; border-color: #5c2b2b !important; }
238}
239.warble-foot { margin-top: 2rem; font-size: .75rem; opacity: .45; }"#;
240
241const PALETTE: [&str; 6] = [
243 "#4c78a8", "#f58518", "#54a24b", "#e45756", "#72b7b2", "#b279a2",
244];
245
246fn esc(value: &str) -> String {
247 value
248 .replace('&', "&")
249 .replace('<', "<")
250 .replace('>', ">")
251 .replace('"', """)
252}
253
254fn esc_value(value: &Value) -> String {
257 esc(&value_to_display_string(value))
258}
259
260fn value_to_display_string(value: &Value) -> String {
261 match value {
262 Value::Null => String::new(),
263 Value::String(s) => s.clone(),
264 Value::Bool(b) => b.to_string(),
265 Value::Number(n) => number_to_js_string(n),
266 other => other.to_string(),
267 }
268}
269
270fn is_numeric(value: &Value) -> bool {
272 match value.as_f64() {
273 Some(n) => n.is_finite(),
274 None => false,
275 }
276}
277
278fn number_to_js_string(n: &serde_json::Number) -> String {
280 if let Some(i) = n.as_i64() {
281 return i.to_string();
282 }
283 if let Some(u) = n.as_u64() {
284 return u.to_string();
285 }
286 n.as_f64().map(|f| f.to_string()).unwrap_or_default()
287}
288
289fn fmt_number(n: f64) -> String {
291 if n.fract() == 0.0 && n.is_finite() {
292 return format!("{}", n as i64);
293 }
294 let rounded = (n * 100.0).round() / 100.0;
295 if rounded.fract() == 0.0 {
296 format!("{}", rounded as i64)
297 } else {
298 let s = format!("{:.2}", rounded);
300 let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
301 s
302 }
303}
304
305fn fmt_cell(value: &Value) -> String {
306 if value.is_null() {
307 return String::new();
308 }
309 if is_numeric(value) {
310 return fmt_number(value.as_f64().unwrap());
311 }
312 value_to_display_string(value)
313}
314
315fn block_type(block: &Value) -> &str {
316 block.get("type").and_then(Value::as_str).unwrap_or("")
317}
318
319pub fn render_envelope_to_html(envelope: &Envelope, options: &RenderOptions) -> String {
323 let title = options.title.as_deref().unwrap_or("Dashboard");
324 let kpis: Vec<&Value> = envelope
325 .blocks
326 .iter()
327 .filter(|b| block_type(b) == "kpi_card")
328 .collect();
329 let others: Vec<&Value> = envelope
330 .blocks
331 .iter()
332 .filter(|b| block_type(b) != "kpi_card")
333 .collect();
334
335 let verified_pill = if envelope.verified == Some(true) {
337 r#"<span class="verified-pill">✓ Verified</span>"#
338 } else {
339 ""
340 };
341 let mut parts: Vec<String> = vec![format!(
342 r#"<div class="title-row"><h1>{}</h1>{}</div>"#,
343 esc(title),
344 verified_pill
345 )];
346
347 if !kpis.is_empty() {
348 let cards: String = kpis.iter().map(|b| render_kpi_card(b)).collect();
349 parts.push(format!(r#"<div class="kpi-row">{}</div>"#, cards));
350 }
351 for block in &others {
352 parts.push(render_block(block));
353 }
354 if let Some(summary) = &envelope.summary {
355 if !summary.is_empty() {
356 parts.push(format!(
357 r#"<div class="panel"><h2>Summary</h2><p class="summary">{}</p></div>"#,
358 esc(summary)
359 ));
360 }
361 }
362 parts.push(
363 r#"<div class="warble-foot">Rendered by the Warble reference renderer (programmatic render flavor).</div>"#
364 .to_string(),
365 );
366
367 [
368 "<!doctype html>".to_string(),
369 r#"<html lang="en">"#.to_string(),
370 "<head>".to_string(),
371 r#"<meta charset="utf-8">"#.to_string(),
372 r#"<meta name="viewport" content="width=device-width, initial-scale=1">"#.to_string(),
373 format!("<title>{}</title>", esc(title)),
374 format!("<style>{}</style>", STYLES),
375 "</head>".to_string(),
376 "<body>".to_string(),
377 parts.join("\n"),
378 "</body>".to_string(),
379 "</html>".to_string(),
380 String::new(),
381 ]
382 .join("\n")
383}
384
385fn render_block(block: &Value) -> String {
386 match block_type(block) {
387 "table" => render_table(block),
388 "chart" => render_chart(block),
389 "narrative" => render_narrative(block),
390 "definition" => render_definition(block),
391 "status" => render_status(block),
392 "diff" => render_diff(block),
393 _ => render_unknown(block),
394 }
395}
396
397fn render_narrative(block: &Value) -> String {
402 let title = block
403 .get("title")
404 .and_then(Value::as_str)
405 .filter(|t| !t.is_empty())
406 .map(|t| format!("<h2>{}</h2>", esc(t)))
407 .unwrap_or_else(|| "<h2>Narrative</h2>".to_string());
408 let text = block
409 .get("text")
410 .or_else(|| block.get("body"))
411 .or_else(|| block.get("markdown"))
412 .map(value_to_display_string)
413 .unwrap_or_default();
414 let paragraphs: String = text
415 .split("\n\n")
416 .map(str::trim)
417 .filter(|p| !p.is_empty())
418 .map(|p| format!("<p>{}</p>", esc(p).replace('\n', "<br>")))
419 .collect();
420 format!(r#"<div class="panel">{title}{paragraphs}</div>"#)
421}
422
423fn render_definition(block: &Value) -> String {
429 let sql = block
430 .get("sql")
431 .map(value_to_display_string)
432 .filter(|s| !s.is_empty());
433 let string_list = |key: &str| -> Vec<String> {
434 block
435 .get(key)
436 .and_then(Value::as_array)
437 .map(|arr| arr.iter().map(value_to_display_string).collect())
438 .unwrap_or_default()
439 };
440 let source_tables = string_list("source_tables");
441 let filters = string_list("filters");
442
443 let mut inner = String::from(r#"<h2>Definition — how this was computed</h2>"#);
444 if let Some(sql) = sql {
445 inner.push_str(&format!("<pre>{}</pre>", esc(&sql)));
446 }
447 if !source_tables.is_empty() {
448 inner.push_str(&format!(
449 r#"<div class="meta"><span class="k">Source tables</span>{}</div>"#,
450 esc(&source_tables.join(", "))
451 ));
452 }
453 if !filters.is_empty() {
454 inner.push_str(&format!(
455 r#"<div class="meta"><span class="k">Filters</span>{}</div>"#,
456 esc(&filters.join("; "))
457 ));
458 }
459 inner.push_str(
460 r#"<div class="note">Shallow provenance — the query behind this run. Unit, owner, and formal metric lineage arrive with MDL introspection (Phase 2).</div>"#,
461 );
462 format!(r#"<div class="panel definition">{inner}</div>"#)
463}
464
465fn render_status(block: &Value) -> String {
471 let state = block
472 .get("state")
473 .and_then(Value::as_str)
474 .unwrap_or("")
475 .to_ascii_lowercase();
476 let severity = block
477 .get("severity")
478 .and_then(Value::as_str)
479 .unwrap_or("")
480 .to_ascii_lowercase();
481 let signal = |s: &str| matches!(s, "stale" | "critical" | "breach" | "fail" | "down");
483 let warnish = |s: &str| matches!(s, "warn" | "warning" | "degraded" | "late");
484 let cls = if signal(&state) || signal(&severity) {
485 "bad"
486 } else if warnish(&state) || warnish(&severity) {
487 "warn"
488 } else if state.is_empty() {
489 "neutral"
490 } else {
491 "ok"
492 };
493 let pill_text = if state.is_empty() {
494 "status".to_string()
495 } else {
496 state.clone()
497 };
498 let label = block
499 .get("label")
500 .and_then(Value::as_str)
501 .filter(|s| !s.is_empty())
502 .map(|s| format!(r#"<span class="status-label">{}</span>"#, esc(s)))
503 .unwrap_or_default();
504 let severity_badge = if severity.is_empty() {
505 String::new()
506 } else {
507 format!(r#" <span class="status-sev">{}</span>"#, esc(&severity))
508 };
509 let detail = block
510 .get("detail")
511 .or_else(|| block.get("message"))
512 .map(value_to_display_string)
513 .filter(|s| !s.is_empty())
514 .map(|s| format!(r#"<div class="status-detail">{}</div>"#, esc(&s)))
515 .unwrap_or_default();
516 format!(
517 r#"<div class="panel status"><div class="status-row"><span class="status-pill {cls}">{}</span>{label}{severity_badge}</div>{detail}</div>"#,
518 esc(&pill_text)
519 )
520}
521
522fn render_diff(block: &Value) -> String {
529 let path = block
530 .get("path")
531 .map(value_to_display_string)
532 .filter(|s| !s.is_empty());
533 let diff = block.get("diff").map(value_to_display_string);
534
535 let mut inner = String::from(r#"<h2>Proposed change (dry-run)</h2>"#);
536 if let Some(path) = path {
537 inner.push_str(&format!(
538 r#"<div class="meta"><span class="k">Path</span>{}</div>"#,
539 esc(&path)
540 ));
541 }
542 if let Some(diff) = diff {
543 inner.push_str(&format!("<pre>{}</pre>", esc(&diff)));
544 }
545 inner.push_str(
546 r#"<div class="note">Dry-run only — nothing has been applied. Apply is gated on a blast-radius check and human approval.</div>"#,
547 );
548 format!(r#"<div class="panel definition">{inner}</div>"#)
549}
550
551fn render_kpi_card(block: &Value) -> String {
552 let value_field = block.get("value").cloned().unwrap_or(Value::Null);
553 let value = if is_numeric(&value_field) {
554 fmt_number(value_field.as_f64().unwrap())
555 } else {
556 esc_value(&value_field)
557 };
558 let unit = match block.get("unit") {
559 Some(u) => format!(r#"<span class="unit">{}</span>"#, esc_value(u)),
560 None => String::new(),
561 };
562 let mut delta = String::new();
563 if let Some(d) = block.get("delta").and_then(Value::as_f64) {
564 if d.is_finite() {
565 let dir = if d >= 0.0 { "up" } else { "down" };
566 let sign = if d >= 0.0 { "\u{25B2}" } else { "\u{25BC}" };
567 delta = format!(
568 r#"<div class="delta {}">{} {}</div>"#,
569 dir,
570 sign,
571 fmt_number(d.abs())
572 );
573 }
574 }
575 let label = block.get("label").cloned().unwrap_or(Value::Null);
576 [
577 r#"<div class="card">"#.to_string(),
578 format!(r#"<div class="label">{}</div>"#, esc_value(&label)),
579 format!(r#"<div class="value">{}{}</div>"#, value, unit),
580 delta,
581 "</div>".to_string(),
582 ]
583 .join("")
584}
585
586fn render_table(block: &Value) -> String {
587 let columns: Vec<Value> = block
588 .get("columns")
589 .and_then(Value::as_array)
590 .cloned()
591 .unwrap_or_default();
592 let rows: Vec<Value> = block
593 .get("rows")
594 .and_then(Value::as_array)
595 .cloned()
596 .unwrap_or_default();
597
598 let numeric_col: Vec<bool> = columns
600 .iter()
601 .enumerate()
602 .map(|(c, _)| {
603 !rows.is_empty()
604 && rows.iter().all(|row| {
605 let cell = row
606 .as_array()
607 .and_then(|r| r.get(c))
608 .cloned()
609 .unwrap_or(Value::Null);
610 cell.is_null()
611 || matches!(&cell, Value::String(s) if s.is_empty())
612 || is_numeric(&cell)
613 })
614 })
615 .collect();
616
617 let head: String = columns
618 .iter()
619 .enumerate()
620 .map(|(c, col)| {
621 format!(
622 r#"<th class="{}">{}</th>"#,
623 if numeric_col[c] { "num" } else { "" },
624 esc_value(col)
625 )
626 })
627 .collect();
628
629 let body: String = rows
630 .iter()
631 .map(|row| {
632 let cells: String = columns
633 .iter()
634 .enumerate()
635 .map(|(c, _)| {
636 let cell = row
637 .as_array()
638 .and_then(|r| r.get(c))
639 .cloned()
640 .unwrap_or(Value::Null);
641 format!(
642 r#"<td class="{}">{}</td>"#,
643 if numeric_col[c] { "num" } else { "" },
644 esc(&fmt_cell(&cell))
645 )
646 })
647 .collect();
648 format!("<tr>{}</tr>", cells)
649 })
650 .collect();
651
652 format!(
653 r#"<div class="panel"><h2>Table</h2><table><thead><tr>{}</tr></thead><tbody>{}</tbody></table></div>"#,
654 head, body
655 )
656}
657
658struct ChartPoint {
660 x: String,
661 values: Vec<f64>,
662}
663
664fn normalize_chart_rows(block: &Value) -> Vec<ChartPoint> {
665 let series: Vec<String> = block
666 .get("series")
667 .and_then(Value::as_array)
668 .map(|arr| {
669 arr.iter()
670 .filter_map(|v| v.as_str().map(String::from))
671 .collect()
672 })
673 .unwrap_or_default();
674 let x_key = block.get("x").and_then(Value::as_str).unwrap_or("");
675 let rows: Vec<Value> = block
676 .get("rows")
677 .and_then(Value::as_array)
678 .cloned()
679 .unwrap_or_default();
680
681 rows.iter()
682 .map(|row| {
683 if let Some(arr) = row.as_array() {
684 let x = fmt_cell(&arr.first().cloned().unwrap_or(Value::Null));
685 let values: Vec<f64> = series
686 .iter()
687 .enumerate()
688 .map(|(i, _)| to_number(&arr.get(i + 1).cloned().unwrap_or(Value::Null)))
689 .collect();
690 ChartPoint { x, values }
691 } else {
692 let obj = row.as_object();
693 let x = fmt_cell(
694 &obj.and_then(|o| o.get(x_key))
695 .cloned()
696 .unwrap_or(Value::Null),
697 );
698 let values: Vec<f64> = series
699 .iter()
700 .map(|name| {
701 to_number(
702 &obj.and_then(|o| o.get(name))
703 .cloned()
704 .unwrap_or(Value::Null),
705 )
706 })
707 .collect();
708 ChartPoint { x, values }
709 }
710 })
711 .collect()
712}
713
714fn to_number(value: &Value) -> f64 {
715 if is_numeric(value) {
716 return value.as_f64().unwrap();
717 }
718 if let Value::String(s) = value {
719 if let Ok(n) = s.trim().parse::<f64>() {
720 if n.is_finite() {
721 return n;
722 }
723 }
724 }
725 0.0
726}
727
728const CHART_W: f64 = 640.0;
729const CHART_H: f64 = 260.0;
730const PAD_TOP: f64 = 16.0;
731const PAD_RIGHT: f64 = 16.0;
732const PAD_BOTTOM: f64 = 40.0;
733const PAD_LEFT: f64 = 48.0;
734
735fn render_chart(block: &Value) -> String {
736 let points = normalize_chart_rows(block);
737 let series: Vec<String> = block
738 .get("series")
739 .and_then(Value::as_array)
740 .map(|arr| {
741 arr.iter()
742 .filter_map(|v| v.as_str().map(String::from))
743 .collect()
744 })
745 .unwrap_or_default();
746 let chart_type = block
747 .get("chart_type")
748 .and_then(Value::as_str)
749 .unwrap_or("");
750
751 let svg = if points.is_empty() || series.is_empty() {
752 r#"<p class="summary">No chart data.</p>"#.to_string()
753 } else {
754 render_chart_svg(chart_type, &points, &series)
755 };
756
757 let legend = if series.len() > 1 {
758 let items: String = series
759 .iter()
760 .enumerate()
761 .map(|(i, name)| {
762 format!(
763 r#"<span style="margin-right:1rem"><span style="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;background:{};margin-right:.3rem"></span>{}</span>"#,
764 PALETTE[i % PALETTE.len()],
765 esc(name)
766 )
767 })
768 .collect();
769 format!(
770 r#"<div style="margin-top:.5rem;font-size:.8rem">{}</div>"#,
771 items
772 )
773 } else {
774 String::new()
775 };
776
777 let x_field = block.get("x").and_then(Value::as_str).unwrap_or("");
778 let heading = if !x_field.is_empty() {
779 format!("{} chart by {}", esc(chart_type), esc(x_field))
780 } else {
781 format!("{} chart", esc(chart_type))
782 };
783
784 format!(
785 r#"<div class="panel"><h2>{}</h2>{}{}</div>"#,
786 heading, svg, legend
787 )
788}
789
790fn render_chart_svg(chart_type: &str, points: &[ChartPoint], series: &[String]) -> String {
791 let plot_w = CHART_W - PAD_LEFT - PAD_RIGHT;
792 let plot_h = CHART_H - PAD_TOP - PAD_BOTTOM;
793 let all_values: Vec<f64> = points
794 .iter()
795 .flat_map(|p| p.values.iter().copied())
796 .collect();
797 let max_raw = all_values.iter().copied().fold(0.0_f64, f64::max);
798 let min_raw = all_values.iter().copied().fold(0.0_f64, f64::min);
799 let max = if max_raw == min_raw {
800 max_raw + 1.0
801 } else {
802 max_raw
803 };
804 let min = min_raw;
805 let y_of = |v: f64| PAD_TOP + plot_h - ((v - min) / (max - min)) * plot_h;
806 let band_w = plot_w / points.len() as f64;
807
808 if chart_type == "pie" {
809 return render_pie_svg(points);
810 }
811
812 let axes = [
813 format!(
814 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" opacity=".25"/>"#,
815 PAD_LEFT,
816 PAD_TOP,
817 PAD_LEFT,
818 PAD_TOP + plot_h
819 ),
820 format!(
821 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" opacity=".25"/>"#,
822 PAD_LEFT,
823 y_of(0.0),
824 PAD_LEFT + plot_w,
825 y_of(0.0)
826 ),
827 format!(
828 r#"<text x="{}" y="{}" font-size="10" text-anchor="end" fill="currentColor" opacity=".6">{}</text>"#,
829 PAD_LEFT - 6.0,
830 y_of(max),
831 esc(&fmt_number(max))
832 ),
833 format!(
834 r#"<text x="{}" y="{}" font-size="10" text-anchor="end" fill="currentColor" opacity=".6">{}</text>"#,
835 PAD_LEFT - 6.0,
836 y_of(min) + 3.0,
837 esc(&fmt_number(min))
838 ),
839 ]
840 .join("");
841
842 let x_labels: String = points
843 .iter()
844 .enumerate()
845 .map(|(i, p)| {
846 let cx = PAD_LEFT + band_w * i as f64 + band_w / 2.0;
847 format!(
848 r#"<text x="{}" y="{}" font-size="10" text-anchor="middle" fill="currentColor" opacity=".6">{}</text>"#,
849 cx,
850 PAD_TOP + plot_h + 14.0,
851 esc(&p.x)
852 )
853 })
854 .collect();
855
856 let marks = if chart_type == "bar" {
857 let group_w = band_w * 0.7;
858 let bar_w = group_w / series.len() as f64;
859 points
860 .iter()
861 .enumerate()
862 .map(|(i, p)| {
863 p.values
864 .iter()
865 .enumerate()
866 .map(|(s, &v)| {
867 let x = PAD_LEFT
868 + band_w * i as f64
869 + (band_w - group_w) / 2.0
870 + bar_w * s as f64;
871 let y = y_of(v).min(y_of(0.0));
872 let h = (y_of(v) - y_of(0.0)).abs();
873 format!(
874 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
875 round(x),
876 round(y),
877 round(bar_w - 1.0),
878 round(h),
879 PALETTE[s % PALETTE.len()]
880 )
881 })
882 .collect::<String>()
883 })
884 .collect::<String>()
885 } else {
886 series
888 .iter()
889 .enumerate()
890 .map(|(s, _)| {
891 let coords: Vec<(f64, f64)> = points
892 .iter()
893 .enumerate()
894 .map(|(i, p)| {
895 let x = PAD_LEFT + band_w * i as f64 + band_w / 2.0;
896 let y = y_of(*p.values.get(s).unwrap_or(&0.0));
897 (x, y)
898 })
899 .collect();
900 let color = PALETTE[s % PALETTE.len()];
901 let path = coords
902 .iter()
903 .enumerate()
904 .map(|(i, (x, y))| {
905 format!(
906 "{}{},{}",
907 if i == 0 { "M" } else { "L" },
908 round(*x),
909 round(*y)
910 )
911 })
912 .collect::<Vec<_>>()
913 .join(" ");
914 let dots: String = coords
915 .iter()
916 .map(|(x, y)| {
917 format!(
918 r#"<circle cx="{}" cy="{}" r="2.5" fill="{}"/>"#,
919 round(*x),
920 round(*y),
921 color
922 )
923 })
924 .collect();
925 if chart_type == "scatter" {
926 return dots;
927 }
928 let area = if chart_type == "area" {
929 let (last_x, _) = coords[coords.len() - 1];
930 let (first_x, _) = coords[0];
931 format!(
932 r#"<path d="{} L{},{} L{},{} Z" fill="{}" opacity=".15"/>"#,
933 path,
934 round(last_x),
935 round(y_of(0.0)),
936 round(first_x),
937 round(y_of(0.0)),
938 color
939 )
940 } else {
941 String::new()
942 };
943 format!(
944 r#"{}<path d="{}" fill="none" stroke="{}" stroke-width="2"/>{}"#,
945 area, path, color, dots
946 )
947 })
948 .collect::<String>()
949 };
950
951 format!(
952 r#"<svg viewBox="0 0 {} {}" width="100%" role="img" style="max-width:{}px">{}{}{}</svg>"#,
953 CHART_W, CHART_H, CHART_W, axes, x_labels, marks
954 )
955}
956
957fn render_pie_svg(points: &[ChartPoint]) -> String {
958 let total: f64 = points
959 .iter()
960 .map(|p| *p.values.first().unwrap_or(&0.0))
961 .sum();
962 let cx = CHART_H / 2.0;
963 let cy = CHART_H / 2.0;
964 let r = CHART_H / 2.0 - 16.0;
965 if total <= 0.0 {
966 return r#"<p class="summary">No chart data.</p>"#.to_string();
967 }
968 let mut angle = -std::f64::consts::FRAC_PI_2;
969 let slices: String = points
970 .iter()
971 .enumerate()
972 .map(|(i, p)| {
973 let v = *p.values.first().unwrap_or(&0.0);
974 let frac = v / total;
975 let next = angle + frac * 2.0 * std::f64::consts::PI;
976 let large = if frac > 0.5 { 1 } else { 0 };
977 let x1 = cx + r * angle.cos();
978 let y1 = cy + r * angle.sin();
979 let x2 = cx + r * next.cos();
980 let y2 = cy + r * next.sin();
981 let path = format!(
982 r#"<path d="M{},{} L{},{} A{},{} 0 {} 1 {},{} Z" fill="{}"/>"#,
983 round(cx),
984 round(cy),
985 round(x1),
986 round(y1),
987 r,
988 r,
989 large,
990 round(x2),
991 round(y2),
992 PALETTE[i % PALETTE.len()]
993 );
994 angle = next;
995 path
996 })
997 .collect();
998 let legend: String = points
999 .iter()
1000 .enumerate()
1001 .map(|(i, p)| {
1002 format!(
1003 r#"<div style="font-size:.8rem"><span style="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;background:{};margin-right:.4rem"></span>{} — {}</div>"#,
1004 PALETTE[i % PALETTE.len()],
1005 esc(&p.x),
1006 esc(&fmt_number(*p.values.first().unwrap_or(&0.0)))
1007 )
1008 })
1009 .collect();
1010 format!(
1011 r#"<div style="display:flex;gap:1.5rem;align-items:center;flex-wrap:wrap"><svg viewBox="0 0 {} {}" width="{}" role="img">{}</svg><div>{}</div></div>"#,
1012 CHART_H, CHART_H, CHART_H, slices, legend
1013 )
1014}
1015
1016fn round(n: f64) -> f64 {
1018 (n * 100.0).round() / 100.0
1019}
1020
1021fn render_unknown(block: &Value) -> String {
1022 let json = esc(&serde_json::to_string_pretty(block).unwrap_or_default());
1023 let type_label = block
1024 .get("type")
1025 .cloned()
1026 .unwrap_or(Value::String(String::new()));
1027 format!(
1028 r#"<div class="panel"><h2>{} (no reference renderer)</h2><pre style="margin:0;white-space:pre-wrap">{}</pre></div>"#,
1029 esc_value(&type_label),
1030 json
1031 )
1032}