use crate::color::parse_color;
use crate::ir::*;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
struct RawSpec {
#[serde(rename = "type")]
chart_type: String,
data: RawData,
#[serde(default)]
options: RawOptions,
}
#[derive(Deserialize, Default)]
struct RawOptions {
#[serde(rename = "indexAxis")]
index_axis: Option<String>,
#[serde(default)]
plugins: RawPlugins,
#[serde(default)]
theme: Option<RawTheme>,
#[serde(default)]
scales: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct RawTheme {
#[serde(default)]
palette: Option<Vec<String>>,
#[serde(rename = "gridColor", default)]
grid_color: Option<String>,
#[serde(rename = "textColor", default)]
text_color: Option<String>,
#[serde(rename = "backgroundColor", default)]
background_color: Option<String>,
#[serde(rename = "fontSize", default)]
font_size: Option<f64>,
}
#[derive(Deserialize, Default)]
struct RawPlugins {
title: Option<RawTitle>,
legend: Option<RawLegend>,
datalabels: Option<RawDataLabels>,
}
#[derive(Deserialize)]
struct RawDataLabels {
#[serde(default)]
display: Option<bool>,
}
#[derive(Deserialize)]
struct RawTitle {
#[serde(default)]
display: bool,
#[serde(default)]
text: String,
}
#[derive(Deserialize)]
struct RawLegend {
#[serde(default = "default_true")]
display: bool,
position: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Deserialize)]
struct RawData {
#[serde(default)]
labels: Vec<String>,
datasets: Vec<RawDataset>,
}
#[derive(Deserialize)]
struct RawDataset {
#[serde(default)]
label: String,
#[serde(rename = "type", default)]
dataset_type: Option<String>,
data: DataField,
#[serde(rename = "backgroundColor")]
background_color: Option<ScalarOrArray<String>>,
#[serde(rename = "borderColor")]
border_color: Option<ScalarOrArray<String>>,
#[serde(rename = "borderWidth")]
border_width: Option<f64>,
#[serde(default)]
fill: FillSpec,
#[serde(default)]
tension: f64,
#[serde(rename = "pointRadius", default)]
point_radius: Option<f64>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum DataField {
Nums(Vec<f64>),
Boxes(Vec<Vec<f64>>),
Points(Vec<RawPoint>),
}
#[derive(Deserialize, Clone)]
struct RawPoint {
x: f64,
y: f64,
#[serde(default)]
r: Option<f64>,
}
impl DataField {
fn into_values(self) -> Vec<f64> {
match self {
DataField::Nums(v) => v,
_ => vec![],
}
}
fn into_points(self) -> Vec<Point> {
match self {
DataField::Points(ps) => ps
.into_iter()
.map(|p| Point {
x: p.x,
y: p.y,
r: p.r,
})
.collect(),
_ => vec![],
}
}
fn into_box_points(self) -> Vec<crate::ir::BoxPoint> {
match self {
DataField::Boxes(rows) => rows
.into_iter()
.map(|row| {
if row.len() != 5 {
return crate::ir::BoxPoint {
min: f64::NAN,
q1: f64::NAN,
median: f64::NAN,
q3: f64::NAN,
max: f64::NAN,
};
}
crate::ir::BoxPoint {
min: row[0],
q1: row[1],
median: row[2],
q3: row[3],
max: row[4],
}
})
.collect(),
_ => vec![],
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ScalarOrArray<T> {
One(T),
Many(Vec<T>),
}
impl<T: Clone> ScalarOrArray<T> {
fn into_vec(self) -> Vec<T> {
match self {
ScalarOrArray::One(v) => vec![v],
ScalarOrArray::Many(v) => v,
}
}
}
#[derive(Deserialize, Default)]
#[serde(untagged)]
enum FillSpec {
Bool(bool),
Mode(#[allow(dead_code)] String),
#[default]
Absent,
}
impl FillSpec {
fn is_filled(&self) -> bool {
match self {
FillSpec::Bool(b) => *b,
FillSpec::Mode(_) => true,
FillSpec::Absent => false,
}
}
}
pub fn parse(json: &str, strict: bool) -> Result<ChartSpec, String> {
{
let chart_type = serde_json::from_str::<serde_json::Value>(json)
.ok()
.and_then(|v| {
v.get("type")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
});
if chart_type.as_deref() == Some("matrix") {
if strict {
check_unknown_keys_matrix(json)?;
}
return parse_matrix(json);
}
if matches!(chart_type.as_deref(), Some("gauge") | Some("radialGauge")) {
let radial = chart_type.as_deref() == Some("radialGauge");
if strict {
check_unknown_keys_gauge(json)?;
}
return parse_gauge(json, radial);
}
if matches!(
chart_type.as_deref(),
Some("progress") | Some("progressBar")
) {
if strict {
check_unknown_keys_progress(json)?;
}
} else if strict {
check_unknown_keys(json)?;
}
}
let raw: RawSpec = serde_json::from_str(json).map_err(|e| e.to_string())?;
let stacked = raw
.options
.scales
.as_ref()
.map(|s| {
let f = |axis: &str| {
s.get(axis)
.and_then(|a| a.get("stacked"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
f("x") || f("y")
})
.unwrap_or(false);
let base_series_type = match raw.chart_type.as_str() {
"line" | "sparkline" => SeriesType::Line,
_ => SeriesType::Bar,
};
let resolve_series_type = |dt: &Option<String>| -> SeriesType {
match dt.as_deref() {
Some("bar") => SeriesType::Bar,
Some("line") => SeriesType::Line,
_ => base_series_type,
}
};
let is_mixable_base = matches!(raw.chart_type.as_str(), "bar" | "line");
for ds in &raw.data.datasets {
if let Some(t) = &ds.dataset_type {
if !is_mixable_base {
return Err(format!(
"dataset の type は基本 type が bar/line のときのみ指定できます(基本 type={})",
raw.chart_type
));
}
if t != "bar" && t != "line" {
return Err(format!("未対応の dataset type: {t}"));
}
}
}
let series_types: Vec<SeriesType> = raw
.data
.datasets
.iter()
.map(|ds| resolve_series_type(&ds.dataset_type))
.collect();
let has_bar = series_types.contains(&SeriesType::Bar);
let has_line = series_types.contains(&SeriesType::Line);
let bar_kind = || ChartKind::Bar {
horizontal: raw.options.index_axis.as_deref() == Some("y"),
stacked,
};
if is_mixable_base && has_bar && has_line {
let horizontal = raw.options.index_axis.as_deref() == Some("y");
if horizontal || stacked {
return Err(
"混合チャート(bar+line)は横棒(indexAxis:y)・積み上げ(stacked)と併用できません"
.to_string(),
);
}
}
let kind = if is_mixable_base && has_bar && has_line {
ChartKind::Mixed
} else if is_mixable_base && has_line && !has_bar {
ChartKind::Line
} else if is_mixable_base && has_bar && !has_line {
bar_kind()
} else {
match raw.chart_type.as_str() {
"bar" => bar_kind(),
"line" => ChartKind::Line,
"pie" => ChartKind::Pie { donut_ratio: 0.0 },
"doughnut" => ChartKind::Pie { donut_ratio: 0.5 },
"scatter" => ChartKind::Scatter,
"bubble" => ChartKind::Bubble,
"radar" => ChartKind::Radar,
"progress" | "progressBar" => ChartKind::Progress,
"boxplot" => ChartKind::BoxPlot,
"polarArea" => ChartKind::PolarArea,
"sparkline" => ChartKind::Sparkline,
other => return Err(format!("未対応の type: {other}")),
}
};
let data_labels = match (&raw.options.plugins.datalabels, &kind) {
(Some(dl), _) => dl.display != Some(false),
(None, ChartKind::Progress) => true,
(None, _) => false,
};
let theme = build_theme(raw.options.theme);
let is_pie = matches!(kind, ChartKind::Pie { .. } | ChartKind::PolarArea);
let is_progress = matches!(kind, ChartKind::Progress);
let is_point_based = matches!(kind, ChartKind::Scatter | ChartKind::Bubble);
let is_boxplot = matches!(kind, ChartKind::BoxPlot);
let is_sparkline = matches!(kind, ChartKind::Sparkline);
for ds in &raw.data.datasets {
let mismatched = match &ds.data {
DataField::Nums(v) => (is_point_based || is_boxplot) && !v.is_empty(),
DataField::Boxes(v) => !is_boxplot && !v.is_empty(),
DataField::Points(v) => !is_point_based && !v.is_empty(),
};
if mismatched {
return Err(format!(
"チャート種 {} とデータ形状が一致しません",
raw.chart_type
));
}
}
let series: Vec<Series> = raw
.data
.datasets
.into_iter()
.enumerate()
.map(|(i, ds)| {
let (values, points, box_points) = if is_point_based {
(vec![], ds.data.into_points(), vec![])
} else if is_boxplot {
(vec![], vec![], ds.data.into_box_points())
} else {
(ds.data.into_values(), vec![], vec![])
};
let n = if is_point_based {
points.len()
} else if is_boxplot {
box_points.len()
} else {
values.len()
};
let fill_alpha = if is_pie || is_progress {
1.0_f32
} else {
0.5_f32
};
let has_explicit_bg = ds.background_color.is_some();
let fill = resolve_colors(
ds.background_color,
is_pie,
i,
n,
&theme.palette,
fill_alpha,
theme.is_custom_palette,
);
let border_color = ds.border_color;
let stroke = if is_point_based && has_explicit_bg && border_color.is_none() {
vec![
Color {
r: 0,
g: 0,
b: 0,
a: 0.1
};
fill.len()
]
} else {
resolve_colors(
border_color,
is_pie,
i,
n,
&theme.palette,
1.0,
theme.is_custom_palette,
)
};
let series_type = series_types[i];
Series {
name: ds.label,
values,
points,
fill,
stroke,
stroke_width: ds.border_width.unwrap_or(default_border_width(series_type)),
area: ds.fill.is_filled(),
tension: ds.tension,
series_type,
point_radius: ds.point_radius,
box_points,
}
})
.collect();
if matches!(kind, ChartKind::Radar)
&& series
.iter()
.any(|s: &Series| s.values.iter().any(|v| v.is_finite() && *v < 0.0))
{
return Err("レーダーチャートは負の値に未対応です".to_string());
}
let is_horizontal = matches!(
kind,
ChartKind::Bar {
horizontal: true,
..
}
);
let value_begin_at_zero = !is_point_based && !is_sparkline;
let scales_val = raw.options.scales.as_ref();
let x_baz_json = scales_val
.and_then(|s| s.get("x"))
.and_then(|a| a.get("beginAtZero"))
.and_then(|v| v.as_bool());
let y_baz_json = scales_val
.and_then(|s| s.get("y"))
.and_then(|a| a.get("beginAtZero"))
.and_then(|v| v.as_bool());
let x_begin_at_zero = x_baz_json.unwrap_or(is_horizontal && value_begin_at_zero);
let y_begin_at_zero =
y_baz_json.unwrap_or(!is_horizontal && value_begin_at_zero && !is_boxplot);
let suggested_min_y = scales_val
.and_then(|s| s.get("y"))
.and_then(|a| a.get("suggestedMin"))
.and_then(|v| v.as_f64());
let suggested_max_y = scales_val
.and_then(|s| s.get("y"))
.and_then(|a| a.get("suggestedMax"))
.and_then(|v| v.as_f64());
let suggested_min_x = scales_val
.and_then(|s| s.get("x"))
.and_then(|a| a.get("suggestedMin"))
.and_then(|v| v.as_f64());
let suggested_max_x = scales_val
.and_then(|s| s.get("x"))
.and_then(|a| a.get("suggestedMax"))
.and_then(|v| v.as_f64());
Ok(ChartSpec {
kind,
series,
categories: raw.data.labels,
x_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: suggested_min_x,
suggested_max: suggested_max_x,
begin_at_zero: x_begin_at_zero,
grid: true,
},
y_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: suggested_min_y,
suggested_max: suggested_max_y,
begin_at_zero: y_begin_at_zero,
grid: true,
},
legend: legend_pos(&raw.options.plugins.legend),
title: raw
.options
.plugins
.title
.filter(|t| t.display)
.map(|t| t.text),
width: 800.0,
height: 450.0,
data_labels,
theme,
})
}
fn build_theme(raw: Option<RawTheme>) -> Theme {
let mut theme = Theme::default();
let Some(raw) = raw else {
return theme;
};
if let Some(entries) = raw.palette {
let parsed: Vec<Color> = entries.iter().filter_map(|c| parse_color(c)).collect();
if !parsed.is_empty() {
theme.palette = parsed;
theme.is_custom_palette = true;
}
}
if let Some(c) = raw.grid_color.as_deref().and_then(parse_color) {
theme.grid_color = c;
}
if let Some(c) = raw.text_color.as_deref().and_then(parse_color) {
theme.text_color = c;
}
if let Some(c) = raw.background_color.as_deref().and_then(parse_color) {
theme.background = Some(c);
}
if let Some(sz) = raw.font_size {
if sz.is_finite() && sz > 0.0 {
theme.font_size = sz;
}
}
theme
}
fn default_border_width(series_type: SeriesType) -> f64 {
match series_type {
SeriesType::Line => 3.0,
SeriesType::Bar => 1.0,
}
}
fn resolve_colors(
spec: Option<ScalarOrArray<String>>,
is_pie: bool,
series_index: usize,
n: usize,
palette: &[Color],
default_alpha: f32,
is_custom_palette: bool,
) -> Vec<Color> {
let pick = |i: usize| {
let c = palette[i % palette.len()];
Color {
a: if is_custom_palette && c.a < 1.0 {
c.a
} else {
default_alpha
},
..c
}
};
match spec {
Some(s) => s
.into_vec()
.iter()
.enumerate()
.map(|(idx, c)| {
parse_color(c).unwrap_or_else(|| {
if is_pie {
pick(idx)
} else {
pick(series_index)
}
})
})
.collect(),
None if is_pie => (0..n).map(pick).collect(),
None => vec![pick(series_index)],
}
}
fn legend_pos(l: &Option<RawLegend>) -> LegendPos {
match l {
Some(l) if !l.display => LegendPos::None,
Some(l) => match l.position.as_deref() {
Some("bottom") => LegendPos::Bottom,
Some("left") => LegendPos::Left,
Some("right") => LegendPos::Right,
_ => LegendPos::Top,
},
None => LegendPos::Top,
}
}
fn check_unknown_keys(json: &str) -> Result<(), String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return Ok(()), };
let Some(top) = value.as_object() else {
return Ok(()); };
check_object(top, &["type", "data", "options"], "")?;
if let Some(data) = top.get("data").and_then(|v| v.as_object()) {
check_object(data, &["labels", "datasets"], "data")?;
if let Some(datasets) = data.get("datasets").and_then(|v| v.as_array()) {
for (i, ds) in datasets.iter().enumerate() {
if let Some(ds) = ds.as_object() {
check_object(
ds,
&[
"label",
"type",
"data",
"backgroundColor",
"borderColor",
"borderWidth",
"fill",
"tension",
"pointRadius",
],
&format!("data.datasets[{i}]"),
)?;
if let Some(points) = ds.get("data").and_then(|v| v.as_array()) {
for (j, pt) in points.iter().enumerate() {
if let Some(pt) = pt.as_object() {
check_object(
pt,
&["x", "y", "r"],
&format!("data.datasets[{i}].data[{j}]"),
)?;
}
}
}
}
}
}
}
if let Some(options) = top.get("options").and_then(|v| v.as_object()) {
check_object(
options,
&["indexAxis", "plugins", "scales", "theme"],
"options",
)?;
if let Some(plugins) = options.get("plugins").and_then(|v| v.as_object()) {
check_object(
plugins,
&["title", "legend", "datalabels"],
"options.plugins",
)?;
if let Some(dl) = plugins.get("datalabels").and_then(|v| v.as_object()) {
check_object(dl, &["display"], "options.plugins.datalabels")?;
}
}
if let Some(theme) = options.get("theme").and_then(|v| v.as_object()) {
check_object(
theme,
&[
"palette",
"gridColor",
"textColor",
"backgroundColor",
"fontSize",
],
"options.theme",
)?;
}
if let Some(scales) = options.get("scales").and_then(|v| v.as_object()) {
check_object(scales, &["x", "y"], "options.scales")?;
for axis in ["x", "y"] {
if let Some(ax) = scales.get(axis).and_then(|v| v.as_object()) {
check_object(
ax,
&[
"stacked",
"min",
"max",
"title",
"grid",
"beginAtZero",
"suggestedMin",
"suggestedMax",
],
&format!("options.scales.{axis}"),
)?;
}
}
}
}
Ok(())
}
fn check_unknown_keys_matrix(json: &str) -> Result<(), String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return Ok(()),
};
let Some(top) = value.as_object() else {
return Ok(());
};
check_object(top, &["type", "data", "options"], "")?;
if let Some(data) = top.get("data").and_then(|v| v.as_object()) {
check_object(data, &["datasets"], "data")?;
if let Some(datasets) = data.get("datasets").and_then(|v| v.as_array()) {
for (i, ds) in datasets.iter().enumerate() {
if let Some(ds) = ds.as_object() {
check_object(
ds,
&[
"label",
"data",
"backgroundColor",
"borderColor",
"borderWidth",
],
&format!("data.datasets[{i}]"),
)?;
if let Some(points) = ds.get("data").and_then(|v| v.as_array()) {
for (j, pt) in points.iter().enumerate() {
if let Some(pt) = pt.as_object() {
check_object(
pt,
&["x", "y", "v"],
&format!("data.datasets[{i}].data[{j}]"),
)?;
}
}
}
}
}
}
}
if let Some(options) = top.get("options").and_then(|v| v.as_object()) {
check_object(options, &["plugins", "theme"], "options")?;
if let Some(plugins) = options.get("plugins").and_then(|v| v.as_object()) {
check_object(plugins, &["title", "legend"], "options.plugins")?;
}
if let Some(theme) = options.get("theme").and_then(|v| v.as_object()) {
check_object(
theme,
&[
"palette",
"gridColor",
"textColor",
"backgroundColor",
"fontSize",
],
"options.theme",
)?;
}
}
Ok(())
}
fn check_unknown_keys_gauge(json: &str) -> Result<(), String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return Ok(()),
};
let Some(top) = value.as_object() else {
return Ok(());
};
check_object(top, &["type", "data", "options"], "")?;
if let Some(data) = top.get("data").and_then(|v| v.as_object()) {
check_object(data, &["datasets"], "data")?;
if let Some(datasets) = data.get("datasets").and_then(|v| v.as_array()) {
for (i, ds) in datasets.iter().enumerate() {
if let Some(ds) = ds.as_object() {
check_object(
ds,
&["label", "value", "minValue", "data", "backgroundColor"],
&format!("data.datasets[{i}]"),
)?;
}
}
}
}
if let Some(options) = top.get("options").and_then(|v| v.as_object()) {
check_object(
options,
&[
"domain",
"trackColor",
"centerPercentage",
"roundedCorners",
"centerArea",
"needle",
"valueLabel",
"plugins",
"theme",
],
"options",
)?;
if let Some(plugins) = options.get("plugins").and_then(|v| v.as_object()) {
check_object(plugins, &["title"], "options.plugins")?;
}
if let Some(ca) = options.get("centerArea").and_then(|v| v.as_object()) {
check_object(
ca,
&[
"displayText",
"fontSize",
"fontColor",
"text",
"subText",
"padding",
],
"options.centerArea",
)?;
}
if let Some(nd) = options.get("needle").and_then(|v| v.as_object()) {
check_object(nd, &["color"], "options.needle")?;
}
if let Some(vl) = options.get("valueLabel").and_then(|v| v.as_object()) {
check_object(
vl,
&[
"display",
"formatter",
"color",
"backgroundColor",
"borderRadius",
"padding",
"bottomMarginPercentage",
"fontSize",
],
"options.valueLabel",
)?;
}
if let Some(theme) = options.get("theme").and_then(|v| v.as_object()) {
check_object(
theme,
&[
"palette",
"gridColor",
"textColor",
"backgroundColor",
"fontSize",
],
"options.theme",
)?;
}
}
Ok(())
}
fn check_unknown_keys_progress(json: &str) -> Result<(), String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return Ok(()),
};
let Some(top) = value.as_object() else {
return Ok(());
};
check_object(top, &["type", "data", "options"], "")?;
if let Some(data) = top.get("data").and_then(|v| v.as_object()) {
check_object(data, &["labels", "datasets"], "data")?;
if let Some(datasets) = data.get("datasets").and_then(|v| v.as_array()) {
for (i, ds) in datasets.iter().enumerate() {
if let Some(ds) = ds.as_object() {
check_object(
ds,
&["label", "data", "backgroundColor"],
&format!("data.datasets[{i}]"),
)?;
}
}
}
}
if let Some(options) = top.get("options").and_then(|v| v.as_object()) {
check_object(options, &["plugins", "theme"], "options")?;
if let Some(plugins) = options.get("plugins").and_then(|v| v.as_object()) {
check_object(plugins, &["title", "datalabels"], "options.plugins")?;
if let Some(dl) = plugins.get("datalabels").and_then(|v| v.as_object()) {
check_object(dl, &["display"], "options.plugins.datalabels")?;
}
}
if let Some(theme) = options.get("theme").and_then(|v| v.as_object()) {
check_object(
theme,
&[
"palette",
"gridColor",
"textColor",
"backgroundColor",
"fontSize",
],
"options.theme",
)?;
}
}
Ok(())
}
fn parse_matrix(json: &str) -> Result<ChartSpec, String> {
#[derive(Deserialize)]
struct MatrixWrapper {
data: MatrixRawData,
#[serde(default)]
options: RawOptions,
}
#[derive(Deserialize)]
struct MatrixRawData {
datasets: Vec<MatrixRawDataset>,
}
#[derive(Deserialize)]
struct MatrixRawDataset {
#[allow(dead_code)]
#[serde(default)]
label: String,
data: Vec<MatrixRawCell>,
#[serde(rename = "backgroundColor", default)]
background_color: Option<ScalarOrArray<String>>,
#[serde(rename = "borderColor", default)]
border_color: Option<ScalarOrArray<String>>,
#[serde(rename = "borderWidth", default)]
border_width: Option<f64>,
}
#[derive(Deserialize)]
struct MatrixRawCell {
x: String,
y: String,
v: f64,
}
let raw: MatrixWrapper = serde_json::from_str(json).map_err(|e| e.to_string())?;
if raw.data.datasets.len() > 1 {
return Err("matrix チャートは dataset が 1 つのみサポートされます".to_string());
}
if raw.data.datasets.is_empty() {
return Err("matrix チャートには dataset が 1 つ必要です".to_string());
}
let ds = raw.data.datasets.into_iter().next().unwrap();
let mut x_cats: Vec<String> = Vec::new();
let mut x_idx: HashMap<String, usize> = HashMap::new();
let mut y_cats: Vec<String> = Vec::new();
let mut y_idx: HashMap<String, usize> = HashMap::new();
for cell in &ds.data {
if !x_idx.contains_key(&cell.x) {
x_idx.insert(cell.x.clone(), x_cats.len());
x_cats.push(cell.x.clone());
}
if !y_idx.contains_key(&cell.y) {
y_idx.insert(cell.y.clone(), y_cats.len());
y_cats.push(cell.y.clone());
}
}
let n_cols = x_cats.len();
let n_rows = y_cats.len();
if n_cols.saturating_mul(n_rows) > 10_000 {
return Err(format!(
"matrix grid too large: {}×{} = {} cells (limit 10000)",
n_cols,
n_rows,
n_cols * n_rows
));
}
let mut grid: Vec<Vec<f64>> = vec![vec![f64::NAN; n_cols]; n_rows];
for cell in &ds.data {
let ci = x_idx[&cell.x];
let ri = y_idx[&cell.y];
grid[ri][ci] = cell.v;
}
let theme = build_theme(raw.options.theme);
let color_hi = ds
.background_color
.as_ref()
.and_then(|c| match c {
ScalarOrArray::One(v) => parse_color(v),
ScalarOrArray::Many(vs) => vs.first().and_then(|v| parse_color(v)),
})
.unwrap_or(theme.palette[0]);
let color_lo = Color {
r: 255,
g: 255,
b: 255,
a: 1.0,
};
let stroke_color: Vec<Color> = ds
.border_color
.as_ref()
.and_then(|c| match c {
ScalarOrArray::One(v) => parse_color(v),
ScalarOrArray::Many(vs) => vs.first().and_then(|v| parse_color(v)),
})
.map(|c| vec![c])
.unwrap_or_default();
let series: Vec<Series> = y_cats
.iter()
.enumerate()
.map(|(i, name)| Series {
name: name.clone(),
values: grid[i].clone(),
points: vec![],
fill: vec![color_hi],
stroke: stroke_color.clone(),
stroke_width: ds.border_width.unwrap_or(0.0),
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
})
.collect();
Ok(ChartSpec {
kind: ChartKind::Matrix { color_lo, color_hi },
series,
categories: x_cats,
x_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None,
suggested_max: None,
begin_at_zero: false,
grid: false,
},
y_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None,
suggested_max: None,
begin_at_zero: false,
grid: false,
},
legend: legend_pos(&raw.options.plugins.legend),
title: raw
.options
.plugins
.title
.filter(|t| t.display)
.map(|t| t.text),
width: 800.0,
height: 450.0,
data_labels: false,
theme,
})
}
fn parse_gauge(json: &str, radial: bool) -> Result<ChartSpec, String> {
use crate::ir::ChartKind;
#[derive(Deserialize)]
struct GaugeWrapper {
data: GaugeRawData,
#[serde(default)]
options: serde_json::Value,
}
#[derive(Deserialize)]
struct GaugeRawData {
datasets: Vec<GaugeRawDataset>,
}
#[derive(Deserialize)]
struct GaugeRawDataset {
#[serde(default)]
value: Option<f64>,
#[serde(rename = "minValue", default)]
min_value: Option<f64>,
#[serde(default)]
data: Vec<f64>,
#[serde(rename = "backgroundColor", default)]
background_color: Option<ScalarOrArray<String>>,
}
let raw: GaugeWrapper = serde_json::from_str(json).map_err(|e| e.to_string())?;
if raw.data.datasets.len() != 1 {
return Err("gauge/radialGauge チャートには dataset が 1 つ必要です".to_string());
}
let ds = raw.data.datasets.into_iter().next().unwrap();
let opt = &raw.options;
let raw_theme: Option<RawTheme> = opt
.get("theme")
.and_then(|t| serde_json::from_value(t.clone()).ok());
let theme = build_theme(raw_theme);
let title = opt
.get("plugins")
.and_then(|p| p.get("title"))
.filter(|t| t.get("display").and_then(|d| d.as_bool()).unwrap_or(false))
.and_then(|t| {
t.get("text")
.and_then(|s| s.as_str())
.map(|s| s.to_string())
});
let colors: Vec<crate::ir::Color> = ds
.background_color
.map(|c| c.into_vec())
.unwrap_or_default()
.iter()
.enumerate()
.map(|(i, s)| parse_color(s).unwrap_or_else(|| theme.palette[i % theme.palette.len()]))
.collect();
let (kind, values, fill) = if radial {
if ds.data.len() != 1 {
return Err("radialGauge の datasets[0].data は単一値のみ対応です".to_string());
}
let value = ds.data.first().copied().unwrap_or(0.0);
let domain = opt.get("domain").and_then(|d| d.as_array());
let min = domain
.and_then(|a| a.first())
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let max = domain
.and_then(|a| a.get(1))
.and_then(|v| v.as_f64())
.unwrap_or(100.0);
let track = opt
.get("trackColor")
.and_then(|v| v.as_str())
.and_then(parse_color)
.unwrap_or(crate::ir::Color {
r: 204,
g: 221,
b: 238,
a: 1.0,
});
let center_pct = opt
.get("centerPercentage")
.and_then(|v| v.as_f64())
.filter(|p| p.is_finite() && *p >= 0.0 && *p < 100.0)
.unwrap_or(80.0);
let rounded = opt
.get("roundedCorners")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let display_text = opt
.get("centerArea")
.and_then(|c| c.get("displayText"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let center_font_size = opt
.get("centerArea")
.and_then(|c| c.get("fontSize"))
.and_then(|v| v.as_f64())
.filter(|s| s.is_finite() && *s > 0.0);
let fill = if colors.is_empty() {
vec![theme.palette[0]]
} else {
vec![colors[0]]
};
(
ChartKind::RadialGauge {
min,
max,
track,
inner_ratio: center_pct / 100.0,
rounded,
display_text,
center_font_size,
},
vec![value],
fill,
)
} else {
let value = ds.value.unwrap_or(0.0);
let min = ds.min_value.unwrap_or(0.0);
let needle = opt
.get("needle")
.and_then(|n| n.get("color"))
.and_then(|v| v.as_str())
.and_then(parse_color)
.unwrap_or(crate::ir::Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
});
let vl = opt.get("valueLabel");
let label = vl
.and_then(|v| v.get("display"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let label_color = vl
.and_then(|v| v.get("color"))
.and_then(|v| v.as_str())
.and_then(parse_color)
.unwrap_or(crate::ir::Color {
r: 255,
g: 255,
b: 255,
a: 1.0,
});
let label_bg = vl
.and_then(|v| v.get("backgroundColor"))
.and_then(|v| v.as_str())
.and_then(parse_color)
.unwrap_or(crate::ir::Color {
r: 0,
g: 0,
b: 0,
a: 1.0,
});
let n = ds.data.len();
let fill: Vec<crate::ir::Color> = if colors.is_empty() {
(0..n)
.map(|i| theme.palette[i % theme.palette.len()])
.collect()
} else {
colors
};
(
ChartKind::Gauge {
value,
min,
needle,
label,
label_color,
label_bg,
},
ds.data.clone(),
fill,
)
};
let series = vec![Series {
name: String::new(),
values,
points: vec![],
fill,
stroke: vec![],
stroke_width: 0.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
}];
Ok(ChartSpec {
kind,
series,
categories: vec![],
x_axis: zero_axis(),
y_axis: zero_axis(),
legend: LegendPos::None,
title,
width: 800.0,
height: 450.0,
data_labels: false,
theme,
})
}
fn zero_axis() -> AxisSpec {
AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None,
suggested_max: None,
begin_at_zero: false,
grid: false,
}
}
fn check_object(
obj: &serde_json::Map<String, serde_json::Value>,
allowed: &[&str],
path: &str,
) -> Result<(), String> {
for key in obj.keys() {
if !allowed.contains(&key.as_str()) {
let full = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
return Err(format!("未知のキー: {full}"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_boxplot_basic() {
let json = r#"{
"type": "boxplot",
"data": {
"labels": ["Mon", "Tue"],
"datasets": [{
"label": "Values",
"data": [
[10, 25, 50, 75, 90],
[5, 20, 45, 70, 95]
]
}]
}
}"#;
let spec = parse(json, false).expect("parse error");
assert!(matches!(spec.kind, crate::ir::ChartKind::BoxPlot));
assert_eq!(spec.series.len(), 1);
let bp = &spec.series[0].box_points;
assert_eq!(bp.len(), 2);
assert_eq!(bp[0].min, 10.0);
assert_eq!(bp[0].q1, 25.0);
assert_eq!(bp[0].median, 50.0);
assert_eq!(bp[0].q3, 75.0);
assert_eq!(bp[0].max, 90.0);
assert_eq!(bp[1].min, 5.0);
assert_eq!(bp[1].median, 45.0);
}
#[test]
fn parse_boxplot_rejects_flat_nums() {
let json = r#"{
"type": "boxplot",
"data": {
"labels": ["A"],
"datasets": [{"data": [10, 25, 50, 75, 90]}]
}
}"#;
assert!(
parse(json, false).is_err(),
"boxplot with flat numbers should fail"
);
}
#[test]
fn auto_fill_gets_half_alpha() {
let json = r#"{
"type": "bar",
"data": {
"labels": ["A", "B"],
"datasets": [{"label": "S1", "data": [1, 2]}]
}
}"#;
let spec = parse(json, false).expect("parse error");
let fill_alpha = spec.series[0].fill[0].a;
assert!(
(fill_alpha - 0.5).abs() < 1e-6,
"fill alpha は 0.5 であるべき、実際は {}",
fill_alpha
);
}
#[test]
fn auto_stroke_gets_full_alpha() {
let json = r#"{
"type": "bar",
"data": {
"labels": ["A", "B"],
"datasets": [{"label": "S1", "data": [1, 2]}]
}
}"#;
let spec = parse(json, false).expect("parse error");
let stroke_alpha = spec.series[0].stroke[0].a;
assert!(
(stroke_alpha - 1.0).abs() < 1e-6,
"stroke alpha は 1.0 であるべき、実際は {}",
stroke_alpha
);
}
#[test]
fn pie_auto_fill_gets_full_alpha() {
let json =
r#"{"type": "pie", "data": {"labels": ["A", "B"], "datasets": [{"data": [1, 2]}]}}"#;
let spec = parse(json, false).expect("parse error");
let fill_alpha = spec.series[0].fill[0].a;
assert!(
(fill_alpha - 1.0).abs() < 1e-6,
"pie の fill alpha は 1.0 であるべき、実際は {}",
fill_alpha
);
}
#[test]
fn bubble_no_border_color_stroke_is_global_default() {
let json = r##"{
"type": "bubble",
"data": {
"datasets": [{"backgroundColor": "#9966ff", "data": [{"x":1,"y":2,"r":5}]}]
}
}"##;
let spec = parse(json, false).expect("parse error");
let stroke = spec.series[0].stroke[0];
assert_eq!(stroke.r, 0, "stroke.r must be 0 (black)");
assert_eq!(stroke.g, 0, "stroke.g must be 0 (black)");
assert_eq!(stroke.b, 0, "stroke.b must be 0 (black)");
assert!(
(stroke.a - 0.1).abs() < 1e-6,
"stroke alpha must be 0.1 (global default), got {}",
stroke.a
);
}
#[test]
fn scatter_no_border_color_stroke_is_global_default() {
let json = r##"{
"type": "scatter",
"data": {
"datasets": [{"backgroundColor": "#36a2eb", "data": [{"x":1,"y":2}]}]
}
}"##;
let spec = parse(json, false).expect("parse error");
let stroke = spec.series[0].stroke[0];
assert_eq!(stroke.r, 0, "stroke.r must be 0 (black)");
assert_eq!(stroke.g, 0, "stroke.g must be 0 (black)");
assert_eq!(stroke.b, 0, "stroke.b must be 0 (black)");
assert!(
(stroke.a - 0.1).abs() < 1e-6,
"stroke alpha must be 0.1 (global default), got {}",
stroke.a
);
}
#[test]
fn scatter_no_colors_stroke_derives_from_auto_fill() {
let json = r#"{
"type": "scatter",
"data": {
"datasets": [{"data": [{"x":1,"y":2}]}]
}
}"#;
let spec = parse(json, false).expect("parse error");
let fill = spec.series[0].fill[0];
let stroke = spec.series[0].stroke[0];
assert_eq!(
stroke.r, fill.r,
"stroke.r must match fill.r (palette color)"
);
assert_eq!(
stroke.g, fill.g,
"stroke.g must match fill.g (palette color)"
);
assert_eq!(
stroke.b, fill.b,
"stroke.b must match fill.b (palette color)"
);
assert!(
(stroke.a - 1.0).abs() < 1e-6,
"stroke alpha must be 1.0, got {}",
stroke.a
);
assert!(
(fill.a - 0.5).abs() < 1e-6,
"fill alpha must be 0.5, got {}",
fill.a
);
}
#[test]
fn scatter_explicit_border_color_is_respected() {
let json = r##"{
"type": "scatter",
"data": {
"datasets": [{"backgroundColor": "#ff0000", "borderColor": "#0000ff", "data": [{"x":1,"y":2}]}]
}
}"##;
let spec = parse(json, false).expect("parse error");
let stroke = spec.series[0].stroke[0];
assert_eq!(stroke.r, 0);
assert_eq!(stroke.g, 0);
assert_eq!(stroke.b, 255);
}
#[test]
fn sparkline_parses_to_sparkline_kind() {
let json = r#"{"type":"sparkline","data":{"datasets":[{"data":[1,2,3]}]}}"#;
let spec = parse(json, false).unwrap();
assert!(matches!(spec.kind, crate::ir::ChartKind::Sparkline));
}
}