use serde_json::{Value, json};
use crate::{
ChartSpec, RenderFormat, RenderOptions,
error::{ChartError, ChartWarning},
render_json,
};
const DOCS: &str = "docs/guides/warnings-and-errors/";
const MAX_WIDTH: u32 = 2_400;
const WIDEN: [f64; 3] = [1.25, 1.5, 2.0];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemedyKind {
Layout,
Content,
}
impl RemedyKind {
const fn name(self) -> &'static str {
match self {
Self::Layout => "layout",
Self::Content => "content",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PatchOperation {
pub op: &'static str,
pub path: String,
pub value: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Remedy {
pub id: &'static str,
pub kind: RemedyKind,
pub summary: String,
pub patch: Vec<PatchOperation>,
pub validated: bool,
pub resolves: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Help {
pub code: &'static str,
pub path: String,
pub field: String,
pub docs: &'static str,
pub note: Option<String>,
pub remedies: Vec<Remedy>,
}
impl Help {
#[must_use]
pub fn to_json(&self) -> Value {
let mut help = json!({
"code": self.code,
"path": self.path,
"field": self.field,
"docs": self.docs,
"remedies": self.remedies.iter().map(|remedy| json!({
"id": remedy.id,
"kind": remedy.kind.name(),
"summary": remedy.summary,
"validated": remedy.validated,
"resolves": remedy.resolves,
"patch": remedy.patch.iter().map(|operation| json!({
"op": operation.op,
"path": operation.path,
"value": operation.value,
})).collect::<Vec<Value>>(),
})).collect::<Vec<Value>>(),
});
if let Some(note) = &self.note {
help["note"] = json!(note);
}
help
}
}
#[must_use]
pub fn advise(spec: &str, options: &RenderOptions, warnings: &[ChartWarning]) -> Vec<Help> {
let Ok(parsed) = serde_json::from_str::<Value>(spec) else {
return Vec::new();
};
let Ok(chart) = ChartSpec::from_json(spec) else {
return Vec::new();
};
let baseline = layout_warnings(spec, options);
let mut help = Vec::new();
for warning in warnings {
let entry = match warning.code {
"canvas_too_small" => canvas_too_small(warning, &parsed, options, &baseline),
"text_truncated" => Some(text_truncated(warning, &parsed, &chart, options, &baseline)),
"label_overlap" | "dense_chart" => Some(guidance(warning)),
_ => None,
};
help.extend(entry);
}
help
}
fn layout_warnings(spec: &str, options: &RenderOptions) -> Vec<(&'static str, String)> {
render_json(spec, RenderFormat::Svg, options).map_or_else(
|_| Vec::new(),
|output| {
output
.warnings
.into_iter()
.filter(|warning| matches!(warning.code, "canvas_too_small" | "text_truncated"))
.map(|warning| (warning.code, warning.path))
.collect()
},
)
}
fn resolves(
patched: &str,
options: &RenderOptions,
code: &str,
path: &str,
baseline: &[(&'static str, String)],
) -> bool {
let Ok(output) = render_json(patched, RenderFormat::Svg, options) else {
return false;
};
output.warnings.iter().all(|warning| {
(warning.code != code || warning.path != path)
&& (!matches!(warning.code, "canvas_too_small" | "text_truncated")
|| baseline
.iter()
.any(|(known, at)| *known == warning.code && *at == warning.path))
})
}
fn with_field(parsed: &Value, field: &str, value: u32) -> Option<(String, PatchOperation)> {
let mut patched = parsed.clone();
let key = field.strip_prefix('/')?;
let exists = patched.get(key).is_some();
patched
.as_object_mut()?
.insert(key.to_owned(), json!(value));
Some((
patched.to_string(),
PatchOperation {
op: if exists { "replace" } else { "add" },
path: field.to_owned(),
value: json!(value),
},
))
}
fn canvas_too_small(
warning: &ChartWarning,
parsed: &Value,
options: &RenderOptions,
baseline: &[(&'static str, String)],
) -> Option<Help> {
let field = warning.path.as_str();
if (field != "/width" && field != "/height")
|| !baseline
.iter()
.any(|(code, path)| *code == warning.code && path == field)
{
return None;
}
let needed: u32 = warning
.message
.split("needs ")
.nth(1)?
.split_whitespace()
.next()?
.parse()
.ok()?;
let (patched, operation) = with_field(parsed, field, needed)?;
let fixed = resolves(&patched, options, warning.code, field, baseline);
Some(Help {
code: "canvas_too_small",
path: warning.path.clone(),
field: warning.path.clone(),
docs: DOCS,
note: Some(
"Changing the canvas is a layout change: no label, value or connection is touched. Shorter labels need a smaller canvas."
.to_owned(),
),
remedies: vec![Remedy {
id: "raise-canvas",
kind: RemedyKind::Layout,
summary: format!("Set {} to {needed} pixels, the size the diagram needs.", &field[1..]),
patch: vec![operation],
validated: true,
resolves: fixed,
}],
})
}
fn text_truncated(
warning: &ChartWarning,
parsed: &Value,
chart: &ChartSpec,
options: &RenderOptions,
baseline: &[(&'static str, String)],
) -> Help {
let mut remedies = Vec::new();
let mut note =
"The full text stays in the specification and in the data table; only the drawing is shortened."
.to_owned();
let in_desktop_layout = baseline
.iter()
.any(|(code, path)| *code == warning.code && *path == warning.path);
if in_desktop_layout {
for factor in WIDEN {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_lossless
)]
let width = (f64::from(chart.width) * factor).ceil() as u32;
if width > MAX_WIDTH || width <= chart.width {
break;
}
let Some((patched, operation)) = with_field(parsed, "/width", width) else {
break;
};
if resolves(&patched, options, warning.code, &warning.path, baseline) {
remedies.push(Remedy {
id: "widen-canvas",
kind: RemedyKind::Layout,
summary: format!(
"Set width to {width} pixels (from {}): the text fits in the compiler's render.",
chart.width
),
patch: vec![operation],
validated: true,
resolves: true,
});
break;
}
}
} else {
note.push_str(" This text is shortened in the mobile layout: widen the mobile width, or shorten the text.");
}
remedies.push(Remedy {
id: "shorten-text",
kind: RemedyKind::Content,
summary: format!(
"Shorten the text at {} yourself, or move detail to the description. chartlet does not rewrite your content.",
warning.path
),
patch: Vec::new(),
validated: false,
resolves: false,
});
Help {
code: "text_truncated",
path: warning.path.clone(),
field: warning.path.clone(),
docs: DOCS,
note: Some(note),
remedies,
}
}
fn guidance(warning: &ChartWarning) -> Help {
let (code, note) = match warning.code {
"label_overlap" => (
"label_overlap",
"chartlet cannot move the labels apart without changing what they say or where they point. Options: a larger canvas, fewer or shorter labels, or a different orientation. None is applied for you.",
),
_ => (
"dense_chart",
"A dense chart cannot be thinned automatically without dropping or aggregating data, which chartlet never does for you. Options: split it into several charts, or filter the data yourself.",
),
};
Help {
code,
path: warning.path.clone(),
field: warning.path.clone(),
docs: DOCS,
note: Some(note.to_owned()),
remedies: Vec::new(),
}
}
pub fn advise_json(spec: &str, options: &RenderOptions) -> Result<Vec<Help>, ChartError> {
let output = render_json(spec, RenderFormat::Svg, options)?;
Ok(advise(spec, options, &output.warnings))
}