use crate::ir::*;
use crate::palette::vegalite_theme;
use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
pub fn parse(json: &str, strict: bool) -> Result<ChartSpec, String> {
parse_with_limits(json, strict, &crate::guard::InputLimits::default())
}
pub fn parse_with_limits(
json: &str,
strict: bool,
limits: &crate::guard::InputLimits,
) -> 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 mut 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)?;
}
ChartKind::VegaRect { .. } => {
let xf = require_field(&x_field, "x")?;
let yf = require_field(&y_field, "y")?;
let cf = require_field(&color_field, "color")?;
validate_category(&records, xf)?;
validate_category(&records, yf)?;
for r in &records {
match r.get(cf) {
Some(Value::Number(_) | Value::String(_) | Value::Bool(_)) => {}
Some(Value::Null) | None => {
return Err(format!(
"フィールド {cf} が見つかりません(typo? または null)"
));
}
Some(_) => {
return Err(format!(
"フィールド {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();
if matches!(kind, ChartKind::VegaRect { .. }) {
let color_type_hint = encoding
.get("color")
.and_then(Value::as_object)
.and_then(|o| o.get("type"))
.and_then(Value::as_str);
let cf = require_field(&color_field, "color")?;
let color_type = infer_color_type(&records, cf, color_type_hint);
if color_type == ColorType::Quantitative
&& !records.is_empty()
&& !records.iter().any(|r| {
r.get(cf)
.and_then(Value::as_f64)
.is_some_and(f64::is_finite)
})
{
return Err(format!("フィールド {cf} は数値である必要があります"));
}
let aggregate_hint = encoding
.get("color")
.and_then(Value::as_object)
.and_then(|o| o.get("aggregate"))
.and_then(Value::as_str);
let aggregate = match aggregate_hint {
Some("mean") => Aggregate::Mean,
Some("sum") => Aggregate::Sum,
_ => Aggregate::None,
};
if strict && aggregate != Aggregate::None && color_type == ColorType::Nominal {
return Err(format!(
"rect の color は実データから nominal と推論されました。aggregate: \"{}\" は指定できません(集計対象がカテゴリ)",
aggregate_hint.unwrap_or("")
));
}
kind = parse_rect_kind(
&records,
&x_field,
&y_field,
&color_field,
color_type,
aggregate,
&theme.palette,
limits,
)?;
}
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),
ChartKind::VegaRect { .. } => vec![],
_ => 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![],
ChartKind::VegaRect { .. } => vec![],
_ => distinct_categories(&records, x_field.as_deref()),
};
let y_begin_at_zero = !matches!(kind, ChartKind::Scatter | ChartKind::VegaRect { .. });
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,
offset: 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,
offset: false,
grid: true,
},
legend: LegendPos::Top,
title,
width,
height,
data_labels: false,
theme,
decimation: Decimation::default(),
})
}
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,
placement_stacked: false,
value_stacked: false,
}),
"line" => Ok(ChartKind::Line),
"point" => Ok(ChartKind::Scatter),
"circle" => Ok(ChartKind::Scatter),
"rect" => Ok(ChartKind::VegaRect {
x_labels: Vec::new(),
y_labels: Vec::new(),
cells: Vec::new(),
}),
"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 seen: HashSet<String> = HashSet::new();
let mut out: Vec<String> = Vec::new();
for r in records {
let v = field_category(r, field);
if seen.insert(v.clone()) {
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![],
tree: vec![],
links: 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![],
tree: vec![],
links: 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![],
tree: vec![],
links: 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) {
let allowed: &[&str] = match read_mark_name(top) {
Some("bar" | "line" | "point" | "circle") => &["x", "y", "color"],
Some("arc") => &["theta", "color", "x", "y"],
Some("rect") => &["x", "y", "color"],
_ => return Ok(()),
};
check_object(encoding, allowed, "encoding")?;
for channel in allowed {
if let Some(ch) = encoding.get(*channel).and_then(Value::as_object) {
let channel_allowed: &[&str] =
if matches!(read_mark_name(top), Some("rect")) && *channel == "color" {
&["field", "type", "aggregate"]
} else {
&["field", "type"]
};
check_object(ch, channel_allowed, &format!("encoding.{channel}"))?;
}
}
if matches!(read_mark_name(top), Some("rect")) {
for axis in ["x", "y"] {
if let Some(ch) = encoding.get(axis).and_then(Value::as_object) {
match ch.get("type") {
None => {}
Some(Value::String(t)) if t == "nominal" || t == "ordinal" => {}
Some(Value::String(t)) => {
return Err(format!(
"rect の encoding.{axis}.type: \"{t}\" は未対応です(nominal / ordinal のみ)"
));
}
Some(other) => {
return Err(format!(
"rect の encoding.{axis}.type は文字列 \"nominal\" または \"ordinal\" のみ受理: {other}"
));
}
}
}
}
if let Some(color) = encoding.get("color").and_then(Value::as_object) {
match color.get("type") {
None => {}
Some(Value::String(t))
if t == "quantitative" || t == "nominal" || t == "ordinal" => {}
Some(Value::String(t)) => {
return Err(format!(
"rect の encoding.color.type: \"{t}\" は未対応です(quantitative / nominal / ordinal のみ)"
));
}
Some(other) => {
return Err(format!(
"rect の encoding.color.type は文字列 \"quantitative\" / \"nominal\" / \"ordinal\" のみ受理: {other}"
));
}
}
let agg = match color.get("aggregate") {
None => None,
Some(Value::String(s)) if s == "mean" || s == "sum" => Some(s.as_str()),
Some(Value::String(s)) => {
return Err(format!(
"rect の encoding.color.aggregate: \"{s}\" は未対応です(mean/sum のみ)"
));
}
Some(other) => {
return Err(format!(
"rect の encoding.color.aggregate は文字列 \"mean\" または \"sum\" のみ受理: {other}"
));
}
};
let color_type = color.get("type").and_then(Value::as_str);
if let (Some(ct), Some(a)) = (color_type, agg)
&& (ct == "nominal" || ct == "ordinal")
{
return Err(format!(
"rect の encoding.color.type: \"{ct}\" に aggregate: \"{a}\" は指定できません(集計対象がカテゴリ)"
));
}
}
}
}
Ok(())
}
fn read_mark_name(top: &Map<String, Value>) -> Option<&str> {
match top.get("mark")? {
Value::String(s) => Some(s.as_str()),
Value::Object(o) => o.get("type").and_then(Value::as_str),
_ => None,
}
}
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(())
}
const RECT_COLOR_LO: Color = Color {
r: 255,
g: 255,
b: 255,
a: 1.0,
};
const RECT_COLOR_HI: Color = Color {
r: 76,
g: 120,
b: 168,
a: 1.0,
};
fn lerp_rect_color(t: f64) -> Color {
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
Color {
r: (RECT_COLOR_LO.r as f64 + (RECT_COLOR_HI.r as f64 - RECT_COLOR_LO.r as f64) * t).round()
as u8,
g: (RECT_COLOR_LO.g as f64 + (RECT_COLOR_HI.g as f64 - RECT_COLOR_LO.g as f64) * t).round()
as u8,
b: (RECT_COLOR_LO.b as f64 + (RECT_COLOR_HI.b as f64 - RECT_COLOR_LO.b as f64) * t).round()
as u8,
a: 1.0, }
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ColorType {
Quantitative,
Nominal,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Aggregate {
None,
Mean,
Sum,
}
fn infer_color_type(
records: &[Map<String, Value>],
color_field: &str,
explicit: Option<&str>,
) -> ColorType {
match explicit {
Some("quantitative") => ColorType::Quantitative,
Some("nominal" | "ordinal") => ColorType::Nominal,
_ => {
let all_numeric = records
.iter()
.all(|r| matches!(r.get(color_field), Some(Value::Number(_))));
if all_numeric {
ColorType::Quantitative
} else {
ColorType::Nominal
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn parse_rect_kind(
records: &[Map<String, Value>],
x_field: &Option<String>,
y_field: &Option<String>,
color_field: &Option<String>,
color_type: ColorType,
aggregate: Aggregate,
palette: &[Color],
limits: &crate::guard::InputLimits,
) -> Result<ChartKind, String> {
let xf = require_field(x_field, "x")?;
let yf = require_field(y_field, "y")?;
let cf = require_field(color_field, "color")?;
let (x_labels, y_labels, cells) =
build_rect(records, xf, yf, cf, color_type, aggregate, palette, limits)?;
Ok(ChartKind::VegaRect {
x_labels,
y_labels,
cells,
})
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn build_rect(
records: &[Map<String, Value>],
x_field: &str,
y_field: &str,
color_field: &str,
color_type: ColorType,
aggregate: Aggregate,
palette: &[Color],
limits: &crate::guard::InputLimits,
) -> Result<(Vec<String>, Vec<String>, Vec<Vec<Option<Color>>>), String> {
if records.len() > limits.max_categorical_primitives {
return Err(format!(
"VegaRect の records 数 {} が上限 {} を超えています(parse 時 pre-aggregation 検査)",
records.len(),
limits.max_categorical_primitives,
));
}
let x_labels = distinct_categories(records, Some(x_field));
let y_labels = distinct_categories(records, Some(y_field));
if x_labels.len() > limits.max_categories {
return Err(format!(
"VegaRect の x_labels 数 {} が上限 {} を超えています(parse 時 pre-allocation 検査)",
x_labels.len(),
limits.max_categories,
));
}
if y_labels.len() > limits.max_categories {
return Err(format!(
"VegaRect の y_labels 数 {} が上限 {} を超えています(parse 時 pre-allocation 検査)",
y_labels.len(),
limits.max_categories,
));
}
let grid = x_labels.len().saturating_mul(y_labels.len());
if grid > limits.max_categorical_primitives {
return Err(format!(
"VegaRect のグリッドセル数 {} が上限 {} を超えています(parse 時 pre-allocation 検査)",
grid, limits.max_categorical_primitives,
));
}
let x_idx: HashMap<&str, usize> = x_labels
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let y_idx: HashMap<&str, usize> = y_labels
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
match color_type {
ColorType::Quantitative => {
let mut buckets: Vec<Vec<Vec<f64>>> =
vec![vec![Vec::new(); x_labels.len()]; y_labels.len()];
for r in records {
let xk = field_category(r, Some(x_field));
let yk = field_category(r, Some(y_field));
let (Some(&col), Some(&row)) = (x_idx.get(xk.as_str()), y_idx.get(yk.as_str()))
else {
continue;
};
if let Some(v) = r.get(color_field).and_then(Value::as_f64)
&& v.is_finite()
{
buckets[row][col].push(v);
}
}
let cell_values: Vec<Vec<Option<f64>>> = buckets
.iter()
.map(|row| {
row.iter()
.map(|b| match (aggregate, b.as_slice()) {
(_, []) => None,
(Aggregate::Mean, vs) => {
let n = vs.len() as f64;
let mut running = 0.0_f64;
for (i, v) in vs.iter().enumerate() {
running += (v - running) / (i + 1) as f64;
}
if running.is_finite() {
Some(running)
} else {
let naive_sum: f64 = vs.iter().sum();
let naive = naive_sum / n;
if naive.is_finite() {
Some(naive)
} else {
let scaled_sum: f64 = vs.iter().map(|v| v / n).sum();
scaled_sum.is_finite().then_some(scaled_sum)
}
}
}
(Aggregate::Sum, vs) => {
let sum: f64 = vs.iter().sum();
sum.is_finite().then_some(sum)
}
(Aggregate::None, vs) => vs.last().copied(),
})
.collect()
})
.collect();
let (mut min_v, mut max_v) = (f64::INFINITY, f64::NEG_INFINITY);
for row in &cell_values {
for v in row.iter().flatten() {
if v.is_finite() {
if *v < min_v {
min_v = *v;
}
if *v > max_v {
max_v = *v;
}
}
}
}
let raw_range = max_v - min_v;
let range = if raw_range.abs() < f64::EPSILON || !raw_range.is_finite() {
1.0
} else {
raw_range
};
let degenerate =
!min_v.is_finite() || raw_range.abs() < f64::EPSILON || !raw_range.is_finite();
let cells: Vec<Vec<Option<Color>>> = cell_values
.iter()
.map(|row| {
row.iter()
.map(|v| match v {
Some(v) if v.is_finite() => {
if degenerate {
Some(RECT_COLOR_HI)
} else {
Some(lerp_rect_color((*v - min_v) / range))
}
}
_ => None,
})
.collect()
})
.collect();
Ok((x_labels, y_labels, cells))
}
ColorType::Nominal => {
let color_cats = distinct_categories(records, Some(color_field));
let color_idx: HashMap<&str, usize> = color_cats
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let mut cells: Vec<Vec<Option<Color>>> =
vec![vec![None; x_labels.len()]; y_labels.len()];
for r in records {
let xk = field_category(r, Some(x_field));
let yk = field_category(r, Some(y_field));
let ck = field_category(r, Some(color_field));
let (Some(&col), Some(&row), Some(&ci)) = (
x_idx.get(xk.as_str()),
y_idx.get(yk.as_str()),
color_idx.get(ck.as_str()),
) else {
continue;
};
cells[row][col] = Some(palette[ci % palette.len()]);
}
Ok((x_labels, y_labels, cells))
}
}
}