Skip to main content

fulgur_chart/
ir.rs

1//! IR: フロントエンド(DSL) と描画コアの安定境界。
2
3/// 解決済みの色(不透明 RGB + アルファ)。
4#[derive(Clone, Copy, Debug, PartialEq)]
5pub struct Color {
6    pub r: u8,
7    pub g: u8,
8    pub b: u8,
9    pub a: f32, // 0.0–1.0
10}
11
12/// 散布図(scatter)/バブル(bubble)の点データ。`x`/`y` は線形座標、`r` は任意の半径。
13/// カテゴリ系チャート(bar/line/pie)はこれを使わず `values` を使う。
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct Point {
16    pub x: f64,
17    pub y: f64,
18    pub r: Option<f64>,
19}
20
21/// BoxPlot の5数要約。[min, q1, median, q3, max]。
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct BoxPoint {
24    pub min: f64,
25    pub q1: f64,
26    pub median: f64,
27    pub q3: f64,
28    pub max: f64,
29}
30
31/// ワードクラウドの 1 単語エントリ。
32#[derive(Clone, Debug, PartialEq)]
33pub struct WordEntry {
34    /// 表示テキスト。
35    pub text: String,
36    /// フォントサイズ (px)。入力 data[] の値をそのまま使う。
37    pub size: f64,
38    /// 塗り色。None のときはパレット巡回。
39    pub color: Option<Color>,
40}
41
42/// treemap の階層ノード。リーフは children 空・value はリーフ値。
43/// グループは value=子の合算・children=サブノード。任意の深さにネストできる。
44#[derive(Clone, Debug, PartialEq)]
45pub struct TreeNode {
46    pub label: String,
47    pub value: f64,
48    pub children: Vec<TreeNode>,
49}
50
51/// sankey のリンク(フロー)。ノード間のフロー量を表す。from/to はノードID(文字列)。
52/// per-link 色上書き: chartjs-chart-sankey の data 要素 `color`/`colorFrom`/`colorTo` に対応。
53/// None なら dataset レベル(`ChartKind::Sankey.color_from` / `color_to`)にフォールバック。
54/// - `color_from`: from 側 stop 上書き
55/// - `color_to`: to 側 stop 上書き
56/// - `color` は parse 時に解決(color_from/color_to が個別未指定なら両方に流し込む)ため IR には持たない。
57#[derive(Clone, Debug, PartialEq)]
58pub struct SankeyLink {
59    pub from: String,
60    pub to: String,
61    pub flow: f64,
62    pub color_from: Option<Color>,
63    pub color_to: Option<Color>,
64}
65
66/// 系列ごとの描画種別。混合チャート(bar+line)で dataset 別 type を表す。
67/// 単一種別チャートでは全系列が同じ値になる(描画に影響しない既定は Bar)。
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub enum SeriesType {
70    Bar,
71    Line,
72}
73
74/// 色は**データ点ごと**に持てる(pie のスライス別色が標準形のため)。
75/// 長さ 1 のときは全点へブロードキャストする。`fill_at`/`stroke_at` で安全に参照する。
76#[derive(Clone, Debug, PartialEq)]
77pub struct Series {
78    pub name: String,
79    pub values: Vec<f64>,
80    /// scatter/bubble の点データ。カテゴリ系チャートでは空。
81    pub points: Vec<Point>,
82    pub fill: Vec<Color>,   // len==1 でブロードキャスト、または点ごと
83    pub stroke: Vec<Color>, // 同上
84    pub stroke_width: f64,
85    pub area: bool,   // line のとき塗りつぶすか
86    pub tension: f64, // 0.0 = 直線
87    /// 描画種別。混合チャートでのみ意味を持つ(単一種別では未使用)。
88    pub series_type: SeriesType,
89    /// scatter のマーカー半径(chart.js pointRadius)。None なら既定値。
90    /// bubble では point.r を優先し、欠落時のフォールバックに使う。
91    pub point_radius: Option<f64>,
92    /// boxplot の5数要約データ。boxplot 種別のみ使用、他は空。
93    pub box_points: Vec<BoxPoint>,
94    /// treemap の階層データ (トップレベルノードの forest)。treemap 種別のみ使用、他は空。
95    pub tree: Vec<TreeNode>,
96    /// sankey のリンク(フロー)配列。sankey 種別のみ使用、他は空。
97    pub links: Vec<SankeyLink>,
98}
99
100impl Series {
101    /// i 番目のデータ点の塗り色。空なら黒、len==1 ならブロードキャスト。
102    pub fn fill_at(&self, i: usize) -> Color {
103        color_at(&self.fill, i)
104    }
105    pub fn stroke_at(&self, i: usize) -> Color {
106        color_at(&self.stroke, i)
107    }
108}
109
110/// 要素番号 i から色を解決する共有ルール(空なら黒、len==1 ならブロードキャスト、
111/// それ以外は i % len)。レンダラ(`fill_at`/`stroke_at` 経由)と意味モデル
112/// (`model::colors_to_strings`)が同一経路を使い、モデルと描画の差異を防ぐ。
113pub fn color_at(colors: &[Color], i: usize) -> Color {
114    match colors.len() {
115        0 => Color {
116            r: 0,
117            g: 0,
118            b: 0,
119            a: 1.0,
120        },
121        1 => colors[0],
122        _ => colors[i % colors.len()],
123    }
124}
125
126#[derive(Clone, Debug, PartialEq)]
127pub struct AxisSpec {
128    pub title: Option<String>,
129    pub min: Option<f64>,
130    pub max: Option<f64>,
131    pub suggested_min: Option<f64>,
132    pub suggested_max: Option<f64>,
133    pub begin_at_zero: bool,
134    /// chart.js category スケールの offset。true でカテゴリを band 中心へ寄せる
135    /// (bar の既定挙動)。false は edge-to-edge(line の既定)。現状 line レイアウトの
136    /// x 軸のみが消費する(y は line の値軸なので無描画)。
137    pub offset: bool,
138    pub grid: bool,
139}
140
141#[derive(Clone, Copy, Debug, PartialEq)]
142pub enum LegendPos {
143    Top,
144    Bottom,
145    Left,
146    Right,
147    None,
148}
149
150/// outlabeledPie / outlabeledDoughnut の引き出しラベル設定。
151#[derive(Clone, Debug, PartialEq)]
152pub struct OutlabelConfig {
153    /// ラベルテキストテンプレート。%l=カテゴリ名, %v=値, %p=パーセント。
154    pub text: String,
155    /// ラベル文字色。
156    pub color: Color,
157    /// ラベル背景色。None = スライス色を使用。
158    pub background: Option<Color>,
159    /// 引き出し線の長さ(px)。外周からこの距離だけ外側へ伸びる。
160    pub stretch: f64,
161}
162
163impl Default for OutlabelConfig {
164    fn default() -> Self {
165        OutlabelConfig {
166            text: "%l\n%p%".to_string(),
167            color: Color {
168                r: 255,
169                g: 255,
170                b: 255,
171                a: 1.0,
172            },
173            background: None,
174            stretch: 40.0,
175        }
176    }
177}
178
179/// sankey リンクの配色モード。chartjs-chart-sankey の colorMode に対応。
180#[derive(Clone, Copy, Debug, PartialEq)]
181pub enum SankeyColorMode {
182    From,
183    To,
184    Gradient,
185}
186
187/// sankey の x 方向レイアウトモード。chartjs の modeX に対応。
188#[derive(Clone, Copy, Debug, PartialEq)]
189pub enum SankeyModeX {
190    Edge,
191    Even,
192}
193
194/// sankey のノードサイズ算出方式。chartjs の size に対応(max=既定)。
195#[derive(Clone, Copy, Debug, PartialEq)]
196pub enum SankeySize {
197    Min,
198    Max,
199}
200
201#[derive(Clone, Debug, PartialEq)]
202pub enum ChartKind {
203    Bar {
204        horizontal: bool,
205        /// index 軸 stacked: 配置(同スロット vs dodge)
206        placement_stacked: bool,
207        /// 値軸 stacked: 値累積・値域計算
208        value_stacked: bool,
209    },
210    Line, // area/tension は Series 側
211    Pie {
212        donut_ratio: f64,
213    }, // 0.0 = pie, >0 = doughnut
214    Scatter, // 線形 x × 線形 y。点データ(Series.points)を使う
215    Bubble, // scatter と同じ枠組み。半径は point.r(第3次元)を使う
216    Radar, // 極座標。カテゴリ=スポーク、系列ごとに多角形を重ねる
217    Mixed, // 共有カテゴリ x・線形 y に bar+line を重ねる。種別は Series.series_type
218    Matrix {
219        color_lo: Color, // min 値のセル色(白固定)
220        color_hi: Color, // max 値のセル色(backgroundColor 由来)
221    },
222    /// Vega-Lite `mark: "rect"` (ヒートマップ)。
223    /// scale 解決済み色を per-cell で持つ純粋 grid。`None` セルは描画スキップ(透過)。
224    /// x_labels/y_labels は categories/series 経由ではなくここに直接持ち、layout 側は
225    /// この variant の情報だけで描画する(既存 ChartKind::Matrix パスを触らないため)。
226    VegaRect {
227        /// 列ラベル(横軸カテゴリ)、first-seen 順。
228        x_labels: Vec<String>,
229        /// 行ラベル(縦軸カテゴリ)、first-seen 順。
230        y_labels: Vec<String>,
231        /// cells[row][col] = 解決済み Color または None(欠損/skip)。
232        /// row: y_labels の index、col: x_labels の index。
233        cells: Vec<Vec<Option<Color>>>,
234    },
235    /// QuickChart 互換の progress バー。軸なし水平バー。
236    /// series[0].values=各バーの値、series.get(1).values=per-bar max(省略時100)。
237    Progress,
238    /// QuickChart 互換の boxplot。カテゴリ×5数要約(min/q1/median/q3/max)。
239    BoxPlot,
240    /// QuickChart 互換のスパークライン。軸・ラベル・凡例なしのミニマル折れ線。
241    Sparkline,
242    /// Chart.js v4 polarArea: 角度等分・半径が値に比例する極座標チャート。
243    PolarArea,
244    /// QuickChart radialGauge: 全円。値まで塗りつぶす弧 + トラック + 中央値テキスト。
245    /// series[0].values[0]=値、series[0].fill[0]=塗り色。スカラ構造値はここに持つ。
246    RadialGauge {
247        min: f64,
248        max: f64,
249        track: Color,
250        inner_ratio: f64, // centerPercentage/100
251        rounded: bool,
252        display_text: bool,
253        /// centerArea.fontSize の上書き(px)。None なら内径比で自動算出。
254        center_font_size: Option<f64>,
255    },
256    /// QuickChart gauge: 半円。color zone(series[0].values=累積閾値, series[0].fill=ゾーン色)
257    /// + 針 + 値ラベル。value=針値、min=下端(max は閾値末尾)。
258    Gauge {
259        value: f64,
260        min: f64,
261        needle: Color,
262        label: bool,        // valueLabel.display
263        label_color: Color, // valueLabel.color
264        label_bg: Color,    // valueLabel.backgroundColor
265    },
266    /// QuickChart 互換の outlabeledPie / outlabeledDoughnut。
267    /// 各スライスから円外側へ引き出し線を描き、ラベルを外に配置する。
268    OutlabeledPie {
269        donut_ratio: f64,
270        outlabel: OutlabelConfig,
271    },
272    /// QuickChart / chartjs-chart-treemap 互換の treemap。階層データを squarified で
273    /// ネストした矩形に分割し、深さに応じた色で塗る。データは series[0].tree に持つ。
274    Treemap,
275    /// QuickChart / chartjs-chart-wordcloud 互換のワードクラウド。
276    /// 単語の重要度をフォントサイズで表現し、アルキメデス螺旋で非重複配置する。
277    WordCloud {
278        entries: Vec<WordEntry>,
279        /// 最小回転角度 (度)。デフォルト: -90.0
280        min_rotation: f64,
281        /// 最大回転角度 (度)。デフォルト: 0.0
282        max_rotation: f64,
283        /// 離散回転ステップ数。デフォルト: 2
284        rotation_steps: u32,
285        /// 各単語の周囲パディング (px)。デフォルト: 2.0
286        padding: f64,
287    },
288    /// QuickChart / chartjs-chart-sankey 互換の sankey。ノード間フロー量を帯幅で表す。
289    /// データは series[0].links に持つ。設定値は kind に保持(Gauge 同様)。
290    Sankey {
291        color_from: Color,
292        color_to: Color,
293        color_mode: SankeyColorMode,
294        /// リンク塗りの不透明度(0.0–1.0)。chartjs default 0.5。
295        alpha: f32,
296        node_width: f64,
297        node_padding: f64,
298        mode_x: SankeyModeX,
299        size: SankeySize,
300        border: Color,
301        border_width: f64,
302        label_color: Color,
303        /// ノードID→表示ラベル上書き。未登録は ID をそのまま表示。
304        labels: std::collections::HashMap<String, String>,
305        /// ノードID→priority(列内ソートキー)。空なら priority レイアウト無効。
306        priority: std::collections::HashMap<String, f64>,
307        /// ノードID→列番号(手動 x 指定)。
308        columns: std::collections::HashMap<String, usize>,
309    },
310}
311
312/// 視覚トークンのテーマ。`options.theme` で上書きできる解決済みの値。
313/// `Default` は現行の描画定数と**完全一致**する(テーマ未指定時の byte 一致を保証)。
314#[derive(Clone, Debug, PartialEq)]
315pub struct Theme {
316    /// 系列/スライスの自動配色に使う巡回パレット。
317    pub palette: Vec<Color>,
318    /// カスタムパレットが指定されているかどうか。
319    pub is_custom_palette: bool,
320    /// グリッド線の色。
321    pub grid_color: Color,
322    /// テキスト/インクの色。
323    pub text_color: Color,
324    /// キャンバス背景色。None は背景なし(現行挙動)。
325    pub background: Option<Color>,
326    /// ラベル基準フォントサイズ(px)。タイトルは固定。
327    pub font_size: f64,
328}
329
330impl Default for Theme {
331    fn default() -> Self {
332        Theme {
333            palette: crate::palette::PALETTE.to_vec(),
334            is_custom_palette: false,
335            grid_color: Color {
336                r: 224,
337                g: 224,
338                b: 224,
339                a: 1.0,
340            },
341            text_color: Color {
342                r: 102,
343                g: 102,
344                b: 102,
345                a: 1.0,
346            },
347            background: None,
348            font_size: 12.0,
349        }
350    }
351}
352
353/// デシメーションアルゴリズム(Chart.js 互換)。
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum DecimationAlgorithm {
356    MinMax,
357    Lttb,
358}
359
360/// options.plugins.decimation の解決済み設定。
361/// 既定は自動オン(enabled=true)。Chart.js(false)からの意図的乖離。
362#[derive(Debug, Clone, Copy, PartialEq)]
363pub struct Decimation {
364    pub enabled: bool,
365    pub algorithm: DecimationAlgorithm,
366    /// lttb の目標サンプル数。None なら論理プロット幅px。
367    pub samples: Option<f64>,
368    /// 間引き発動の点数しきい値。None なら論理プロット幅px × 4。
369    pub threshold: Option<f64>,
370}
371
372impl Default for Decimation {
373    fn default() -> Self {
374        Decimation {
375            enabled: true,
376            algorithm: DecimationAlgorithm::MinMax,
377            samples: None,
378            threshold: None,
379        }
380    }
381}
382
383#[derive(Clone, Debug, PartialEq)]
384pub struct ChartSpec {
385    pub kind: ChartKind,
386    pub series: Vec<Series>,
387    pub categories: Vec<String>,
388    pub x_axis: AxisSpec,
389    pub y_axis: AxisSpec,
390    pub legend: LegendPos,
391    pub title: Option<String>,
392    pub width: f64,
393    pub height: f64,
394    /// データラベルを描画するか(frontend で解決済み)。
395    pub data_labels: bool,
396    /// 視覚トークンのテーマ(frontend で解決済み)。
397    pub theme: Theme,
398    /// line/area 用デシメーション設定(frontend で解決済み)。
399    pub decimation: Decimation,
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    fn c(r: u8, g: u8, b: u8) -> Color {
407        Color { r, g, b, a: 1.0 }
408    }
409
410    #[test]
411    fn fill_at_broadcasts_single_color() {
412        let s = Series {
413            name: "x".into(),
414            values: vec![1.0, 2.0, 3.0],
415            points: vec![],
416            fill: vec![c(1, 2, 3)],
417            stroke: vec![],
418            stroke_width: 1.0,
419            area: false,
420            tension: 0.0,
421            series_type: SeriesType::Bar,
422            point_radius: None,
423            box_points: vec![],
424            tree: vec![],
425            links: vec![],
426        };
427        assert_eq!(s.fill_at(0), c(1, 2, 3));
428        assert_eq!(s.fill_at(2), c(1, 2, 3)); // ブロードキャスト
429    }
430
431    #[test]
432    fn fill_at_indexes_per_point_colors() {
433        let s = Series {
434            name: "x".into(),
435            values: vec![1.0, 2.0],
436            points: vec![],
437            fill: vec![c(10, 0, 0), c(0, 20, 0)],
438            stroke: vec![],
439            stroke_width: 1.0,
440            area: false,
441            tension: 0.0,
442            series_type: SeriesType::Bar,
443            point_radius: None,
444            box_points: vec![],
445            tree: vec![],
446            links: vec![],
447        };
448        assert_eq!(s.fill_at(0), c(10, 0, 0));
449        assert_eq!(s.fill_at(1), c(0, 20, 0));
450        assert_eq!(s.fill_at(2), c(10, 0, 0)); // 巡回
451    }
452
453    #[test]
454    fn stroke_at_empty_is_black() {
455        let s = Series {
456            name: "x".into(),
457            values: vec![1.0],
458            points: vec![],
459            fill: vec![],
460            stroke: vec![],
461            stroke_width: 1.0,
462            area: false,
463            tension: 0.0,
464            series_type: SeriesType::Bar,
465            point_radius: None,
466            box_points: vec![],
467            tree: vec![],
468            links: vec![],
469        };
470        assert_eq!(s.stroke_at(0), c(0, 0, 0));
471    }
472
473    #[test]
474    fn theme_default_palette_is_not_custom() {
475        let t = Theme::default();
476        assert!(!t.is_custom_palette);
477    }
478
479    #[test]
480    fn box_point_fields_accessible() {
481        let bp = BoxPoint {
482            min: 1.0,
483            q1: 2.0,
484            median: 3.0,
485            q3: 4.0,
486            max: 5.0,
487        };
488        assert_eq!(bp.median, 3.0);
489        assert_eq!(bp.max - bp.min, 4.0);
490    }
491
492    #[test]
493    fn outlabel_config_default_values() {
494        let c = OutlabelConfig::default();
495        assert_eq!(c.text, "%l\n%p%");
496        assert!((c.stretch - 40.0).abs() < 1e-9);
497        assert!(c.background.is_none());
498        assert_eq!(c.color.r, 255);
499        assert_eq!(c.color.a, 1.0);
500    }
501
502    #[test]
503    fn tree_node_is_recursive() {
504        let leaf = TreeNode {
505            label: "a".into(),
506            value: 3.0,
507            children: vec![],
508        };
509        let group = TreeNode {
510            label: "g".into(),
511            value: 3.0,
512            children: vec![leaf.clone()],
513        };
514        assert_eq!(group.children.len(), 1);
515        assert_eq!(group.children[0].value, 3.0);
516        assert!(leaf.children.is_empty());
517    }
518}