use crate::color::parse_color;
use crate::ir::*;
use serde::Deserialize;
#[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>),
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,
DataField::Points(_) => 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(),
DataField::Nums(_) => 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> {
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" => 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,
other => return Err(format!("未対応の type: {other}")),
}
};
let data_labels = match &raw.options.plugins.datalabels {
Some(dl) => dl.display != Some(false),
None => false,
};
let theme = build_theme(raw.options.theme);
let is_pie = matches!(kind, ChartKind::Pie { .. });
let is_point_based = matches!(kind, ChartKind::Scatter | ChartKind::Bubble);
for ds in &raw.data.datasets {
let mismatched = match &ds.data {
DataField::Nums(v) => is_point_based && !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) = if is_point_based {
(vec![], ds.data.into_points())
} else {
(ds.data.into_values(), vec![])
};
let n = if is_point_based {
points.len()
} else {
values.len()
};
let fill = resolve_colors(ds.background_color, is_pie, i, n, &theme.palette);
let stroke = resolve_colors(ds.border_color, is_pie, i, n, &theme.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,
}
})
.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 y_begin_at_zero = !is_point_based;
Ok(ChartSpec {
kind,
series,
categories: raw.data.labels,
x_axis: AxisSpec {
title: None,
min: None,
max: None,
begin_at_zero: false,
grid: true,
},
y_axis: AxisSpec {
title: None,
min: None,
max: None,
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;
}
}
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],
) -> Vec<Color> {
let pick = |i: usize| palette[i % palette.len()];
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"],
&format!("options.scales.{axis}"),
)?;
}
}
}
}
Ok(())
}
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(())
}