use crate::ir::*;
use crate::palette::vegalite_theme;
use serde_json::{Map, Value};
pub fn parse(json: &str, strict: bool) -> Result<ChartSpec, String> {
if strict {
check_unknown_keys(json)?;
}
let value: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let top = value
.as_object()
.ok_or_else(|| "トップレベルは object でなければなりません".to_string())?;
let kind = parse_mark(top.get("mark"))?;
let records = parse_data_values(top.get("data"))?;
let encoding = top
.get("encoding")
.and_then(Value::as_object)
.ok_or_else(|| "encoding がありません".to_string())?;
let x_field = channel_field(encoding, "x");
let y_field = channel_field(encoding, "y");
let color_field = channel_field(encoding, "color");
let theta_field = channel_field(encoding, "theta");
match &kind {
ChartKind::Bar { .. } | ChartKind::Line => {
let xf = require_field(&x_field, "x")?;
let yf = require_field(&y_field, "y")?;
validate_category(&records, xf)?;
validate_numeric(&records, yf)?;
if let Some(cf) = color_field.as_deref() {
validate_category(&records, cf)?;
}
}
ChartKind::Scatter => {
let xf = require_field(&x_field, "x")?;
let yf = require_field(&y_field, "y")?;
validate_numeric(&records, xf)?;
validate_numeric(&records, yf)?;
if let Some(cf) = color_field.as_deref() {
validate_category(&records, cf)?;
}
}
ChartKind::Pie { .. } => {
let vf = theta_field
.as_deref()
.or(y_field.as_deref())
.ok_or_else(|| {
"arc には encoding.theta.field または y.field が必要です".to_string()
})?;
validate_numeric(&records, vf)?;
let cf = color_field
.as_deref()
.or(x_field.as_deref())
.ok_or_else(|| {
"arc には encoding.color.field または x.field が必要です".to_string()
})?;
validate_category(&records, cf)?;
}
_ => {}
}
if matches!(kind, ChartKind::Line) && color_field.is_some() {
let cats = distinct_categories(&records, x_field.as_deref());
let groups = distinct_categories(&records, color_field.as_deref());
for group in &groups {
for cat in &cats {
let present = records.iter().any(|r| {
&field_category(r, x_field.as_deref()) == cat
&& &field_category(r, color_field.as_deref()) == group
});
if !present {
return Err(
"色分け折れ線(line + color)は全カテゴリに値が揃ったデータのみ対応です(疎なデータは未対応)"
.to_string(),
);
}
}
}
}
let theme = vegalite_theme();
let series = match &kind {
ChartKind::Pie { .. } => build_pie(
&records,
&x_field,
&y_field,
&color_field,
&theta_field,
&theme,
),
ChartKind::Scatter => build_scatter(&records, &x_field, &y_field, &color_field, &theme),
_ => build_categorical(&kind, &records, &x_field, &y_field, &color_field, &theme),
};
let categories = match &kind {
ChartKind::Pie { .. } => {
let cat_field = color_field.as_deref().or(x_field.as_deref());
distinct_categories(&records, cat_field)
}
ChartKind::Scatter => vec![],
_ => distinct_categories(&records, x_field.as_deref()),
};
let y_begin_at_zero = !matches!(kind, ChartKind::Scatter);
let width = top
.get("width")
.and_then(Value::as_f64)
.filter(|w| w.is_finite() && *w > 0.0)
.unwrap_or(800.0);
let height = top
.get("height")
.and_then(Value::as_f64)
.filter(|h| h.is_finite() && *h > 0.0)
.unwrap_or(450.0);
let title = match top.get("title") {
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
Some(Value::Object(o)) => o
.get("text")
.and_then(Value::as_str)
.filter(|t| !t.is_empty())
.map(str::to_string),
_ => None,
};
Ok(ChartSpec {
kind,
series,
categories,
x_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None, suggested_max: None, begin_at_zero: false,
grid: true,
},
y_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None, suggested_max: None, begin_at_zero: y_begin_at_zero,
grid: true,
},
legend: LegendPos::Top,
title,
width,
height,
data_labels: false,
theme,
})
}
fn parse_mark(mark: Option<&Value>) -> Result<ChartKind, String> {
let mark = mark.ok_or_else(|| "mark がありません".to_string())?;
let name = match mark {
Value::String(s) => s.as_str(),
Value::Object(o) => o
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| "mark.type がありません".to_string())?,
_ => return Err("mark は文字列または object でなければなりません".to_string()),
};
match name {
"bar" => Ok(ChartKind::Bar {
horizontal: false,
stacked: false,
}),
"line" => Ok(ChartKind::Line),
"point" => Ok(ChartKind::Scatter),
"arc" => Ok(ChartKind::Pie { donut_ratio: 0.0 }),
other => Err(format!("未対応の mark: {other}")),
}
}
fn parse_data_values(data: Option<&Value>) -> Result<Vec<Map<String, Value>>, String> {
let data = data
.and_then(Value::as_object)
.ok_or_else(|| "data がありません".to_string())?;
if data.contains_key("url") {
return Err("data.url(URL データ)は未対応です。data.values を使ってください".to_string());
}
let values = data
.get("values")
.and_then(Value::as_array)
.ok_or_else(|| "data.values(インライン配列)がありません".to_string())?;
let mut records = Vec::with_capacity(values.len());
for v in values {
let obj = v
.as_object()
.ok_or_else(|| "data.values の各要素は object でなければなりません".to_string())?;
records.push(obj.clone());
}
Ok(records)
}
fn channel_field(encoding: &Map<String, Value>, channel: &str) -> Option<String> {
encoding
.get(channel)
.and_then(Value::as_object)
.and_then(|o| o.get("field"))
.and_then(Value::as_str)
.map(str::to_owned)
}
fn require_field<'a>(field: &'a Option<String>, channel: &str) -> Result<&'a str, String> {
field
.as_deref()
.ok_or_else(|| format!("encoding.{channel}.field が必要です"))
}
fn validate_category(records: &[Map<String, Value>], field: &str) -> Result<(), String> {
for r in records {
match r.get(field) {
Some(Value::String(_) | Value::Number(_) | Value::Bool(_)) => {}
Some(_) => {
return Err(format!(
"フィールド {field} はカテゴリ値(文字列/数値/真偽)である必要があります"
));
}
None => return Err(format!("フィールド {field} が見つかりません(typo?)")),
}
}
Ok(())
}
fn validate_numeric(records: &[Map<String, Value>], field: &str) -> Result<(), String> {
for r in records {
match r.get(field) {
Some(Value::Number(_)) => {}
Some(_) => return Err(format!("フィールド {field} は数値である必要があります")),
None => return Err(format!("フィールド {field} が見つかりません(typo?)")),
}
}
Ok(())
}
fn field_f64(record: &Map<String, Value>, field: Option<&str>) -> f64 {
field
.and_then(|f| record.get(f))
.and_then(Value::as_f64)
.unwrap_or(0.0)
}
fn field_category(record: &Map<String, Value>, field: Option<&str>) -> String {
match field.and_then(|f| record.get(f)) {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
_ => String::new(),
}
}
fn distinct_categories(records: &[Map<String, Value>], field: Option<&str>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for r in records {
let v = field_category(r, field);
if !out.iter().any(|x| x == &v) {
out.push(v);
}
}
out
}
fn palette_pick(palette: &[Color], i: usize) -> Color {
palette[i % palette.len()]
}
fn build_categorical(
kind: &ChartKind,
records: &[Map<String, Value>],
x_field: &Option<String>,
y_field: &Option<String>,
color_field: &Option<String>,
theme: &Theme,
) -> Vec<Series> {
let categories = distinct_categories(records, x_field.as_deref());
let series_type = match kind {
ChartKind::Line => SeriesType::Line,
_ => SeriesType::Bar,
};
let stroke_width = match series_type {
SeriesType::Line => 3.0,
SeriesType::Bar => 1.0,
};
let group_names: Vec<String> = match color_field {
Some(_) => distinct_categories(records, color_field.as_deref()),
None => vec![y_field.clone().unwrap_or_default()],
};
group_names
.iter()
.enumerate()
.map(|(si, group)| {
let values: Vec<f64> = categories
.iter()
.map(|cat| {
records
.iter()
.filter(|r| {
&field_category(r, x_field.as_deref()) == cat
&& match color_field {
Some(_) => &field_category(r, color_field.as_deref()) == group,
None => true,
}
})
.map(|r| field_f64(r, y_field.as_deref()))
.sum()
})
.collect();
let color = palette_pick(&theme.palette, si);
Series {
name: group.clone(),
values,
points: vec![],
fill: vec![color],
stroke: vec![color],
stroke_width,
area: false,
tension: 0.0,
series_type,
point_radius: None,
box_points: vec![],
}
})
.collect()
}
fn build_scatter(
records: &[Map<String, Value>],
x_field: &Option<String>,
y_field: &Option<String>,
color_field: &Option<String>,
theme: &Theme,
) -> Vec<Series> {
let group_names: Vec<String> = match color_field {
Some(_) => distinct_categories(records, color_field.as_deref()),
None => vec![String::new()],
};
group_names
.iter()
.enumerate()
.map(|(si, group)| {
let points: Vec<Point> = records
.iter()
.filter(|r| match color_field {
Some(_) => &field_category(r, color_field.as_deref()) == group,
None => true,
})
.map(|r| Point {
x: field_f64(r, x_field.as_deref()),
y: field_f64(r, y_field.as_deref()),
r: None,
})
.collect();
let color = palette_pick(&theme.palette, si);
Series {
name: group.clone(),
values: vec![],
points,
fill: vec![color],
stroke: vec![color],
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
}
})
.collect()
}
fn build_pie(
records: &[Map<String, Value>],
x_field: &Option<String>,
y_field: &Option<String>,
color_field: &Option<String>,
theta_field: &Option<String>,
theme: &Theme,
) -> Vec<Series> {
let cat_field = color_field.as_deref().or(x_field.as_deref());
let categories = distinct_categories(records, cat_field);
let value_field = theta_field.as_deref().or(y_field.as_deref());
let values: Vec<f64> = categories
.iter()
.map(|cat| {
records
.iter()
.filter(|r| &field_category(r, cat_field) == cat)
.map(|r| field_f64(r, value_field))
.sum()
})
.collect();
let n = categories.len();
let colors: Vec<Color> = (0..n).map(|i| palette_pick(&theme.palette, i)).collect();
vec![Series {
name: String::new(),
values,
points: vec![],
fill: colors.clone(),
stroke: colors,
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
}]
}
fn check_unknown_keys(json: &str) -> Result<(), String> {
let value: 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,
&[
"mark", "data", "encoding", "$schema", "width", "height", "title",
],
"",
)?;
if let Some(encoding) = top.get("encoding").and_then(Value::as_object) {
check_object(encoding, &["x", "y", "color", "theta"], "encoding")?;
for channel in ["x", "y", "color", "theta"] {
if let Some(ch) = encoding.get(channel).and_then(Value::as_object) {
check_object(ch, &["field", "type"], &format!("encoding.{channel}"))?;
}
}
}
Ok(())
}
fn check_object(obj: &Map<String, 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(())
}