fulgur-chart 0.13.1

Render chart.js-compatible JSON specs to deterministic static SVG/PNG
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
use fulgur_chart::font::DEFAULT_FONT;
use fulgur_chart::frontend::chartjs;
use fulgur_chart::layout::line;
use fulgur_chart::raster_direct::render_chart_to_png;
use fulgur_chart::render::render_chart;
use fulgur_chart::scene::Prim;
use fulgur_chart::text::TextMeasurer;
fn render(json: &str) -> String {
    render_chart(&chartjs::parse(json, false).unwrap())
}

/// PNG バイト列(scale 1.0)。決定性・SVG↔PNG 一致テスト用。
fn render_png(json: &str) -> Vec<u8> {
    render_chart_to_png(&chartjs::parse(json, false).unwrap(), 1.0, DEFAULT_FONT).unwrap()
}

#[test]
fn line_has_polyline_and_markers() {
    let svg = render(
        r#"{"type":"line","data":{"labels":["A","B","C"],"datasets":[{"label":"s","data":[1,3,2]}]}}"#,
    );
    assert!(svg.contains("<polyline"));
    assert!(svg.matches("<circle").count() >= 3); // 各点にマーカー
    assert!(!svg.contains("NaN") && !svg.contains("inf"));
    assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
}

#[test]
fn area_emits_filled_path_with_opacity() {
    let svg = render(
        r#"{"type":"line","data":{"labels":["A","B"],"datasets":[{"data":[1,2],"fill":true}]}}"#,
    );
    assert!(svg.contains("<path"));
    assert!(svg.contains("fill-opacity=")); // 半透明 area
    assert!(svg.contains("Z\"")); // 閉じたパス
}

#[test]
fn tension_uses_bezier_path() {
    let svg = render(
        r#"{"type":"line","data":{"labels":["A","B","C"],"datasets":[{"data":[1,3,2],"tension":0.4}]}}"#,
    );
    assert!(svg.contains("<path")); // 曲線はpath
    assert!(svg.contains(" C ")); // ベジエコマンド
}

#[test]
fn line_deterministic() {
    let j = r#"{"type":"line","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#;
    assert_eq!(render(j), render(j));
}

#[test]
fn line_snapshot() {
    let svg = render(
        r#"{"type":"line","data":{"labels":["1月","2月","3月"],"datasets":[{"label":"売上","data":[120,200,150]}]},"options":{"plugins":{"title":{"display":true,"text":"推移"}}}}"#,
    );
    insta::assert_snapshot!(svg);
}

#[test]
fn area_snapshot() {
    let svg = render(
        r#"{"type":"line","data":{"labels":["Q1","Q2","Q3"],"datasets":[{"label":"累計","data":[30,75,130],"fill":true}]}}"#,
    );
    insta::assert_snapshot!(svg);
}

#[test]
fn tension_snapshot() {
    let svg = render(
        r#"{"type":"line","data":{"labels":["A","B","C","D"],"datasets":[{"label":"曲線","data":[1,3,2,4],"tension":0.4}]}}"#,
    );
    insta::assert_snapshot!(svg);
}

#[test]
fn offset_snapshot() {
    // chart.js options.scales.x.offset:true: 点・ラベルを band 中心へ寄せ、bar と同じ
    // chartArea(端余白なし)で描く。既定の edge-to-edge とは別の出力。
    let svg = render(
        r#"{"type":"line","data":{"labels":["1月","2月","3月"],"datasets":[{"label":"売上","data":[120,200,150]}]},"options":{"scales":{"x":{"offset":true}}}}"#,
    );
    insta::assert_snapshot!(svg);
}

// --- デシメーション配線(Task 7/8)---

/// scene 内の全 Polyline の点数合計。間引きで線が消えていないこと/間引かれたことの検証に使う。
fn polyline_pts(json: &str) -> usize {
    let spec = chartjs::parse(json, false).unwrap();
    let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
    let scene = line::build(&spec, &m);
    scene
        .items
        .iter()
        .filter_map(|p| match p {
            Prim::Polyline { points, .. } => Some(points.len()),
            _ => None,
        })
        .sum()
}

#[test]
fn large_line_is_decimated_vs_disabled() {
    let n = 8000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
    let body = format!(
        "\"labels\":[{}],\"datasets\":[{{\"data\":[{}]}}]",
        labels.join(","),
        data.join(",")
    );
    let on = format!(r#"{{"type":"line","data":{{{body}}}}}"#);
    let off = format!(
        r#"{{"type":"line","data":{{{body}}},"options":{{"plugins":{{"decimation":{{"enabled":false}}}}}}}}"#
    );
    let on_pts = polyline_pts(&on);
    let off_pts = polyline_pts(&off);
    assert_eq!(off_pts, n, "disabled must keep all points (single segment)");
    assert!(
        on_pts > 0 && on_pts < off_pts,
        "default must decimate: {on_pts} vs {off_pts}"
    );
}

#[test]
fn small_line_polyline_unchanged() {
    // 3点 line → 間引きされず 3点のまま(既存 golden と整合)。
    let pts = polyline_pts(
        r#"{"type":"line","data":{"labels":["a","b","c"],"datasets":[{"data":[1,2,3]}]}}"#,
    );
    assert_eq!(pts, 3);
}

/// scene 内の Circle(マーカー)数。
fn circle_count(json: &str) -> usize {
    let spec = chartjs::parse(json, false).unwrap();
    let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
    let scene = line::build(&spec, &m);
    scene
        .items
        .iter()
        .filter(|p| matches!(p, Prim::Circle { .. }))
        .count()
}

#[test]
fn large_line_suppresses_markers_by_default() {
    let n = 5000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", i % 50)).collect();
    let json = format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
        labels.join(","),
        data.join(",")
    );
    assert_eq!(
        circle_count(&json),
        0,
        "large line should suppress markers by default"
    );
}

#[test]
fn large_line_keeps_markers_when_pointradius_set() {
    let n = 5000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", i % 50)).collect();
    let json = format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}],"pointRadius":2}}]}}}}"#,
        labels.join(","),
        data.join(",")
    );
    assert!(
        circle_count(&json) > 0,
        "explicit pointRadius should keep markers"
    );
}

#[test]
fn small_line_markers_unchanged() {
    // 3点 → markers drawn as before (radius 3, not suppressed)。
    assert_eq!(
        circle_count(
            r#"{"type":"line","data":{"labels":["a","b","c"],"datasets":[{"data":[1,2,3]}]}}"#
        ),
        3
    );
}

/// scene 内の各 Polyline の点列を順に返す(セグメント数・各セグメント点数・座標有限性の検証用)。
fn polylines(spec: &fulgur_chart::ir::ChartSpec) -> Vec<Vec<(f64, f64)>> {
    let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
    let scene = line::build(spec, &m);
    scene
        .items
        .iter()
        .filter_map(|p| match p {
            Prim::Polyline { points, .. } => Some(points.clone()),
            _ => None,
        })
        .collect()
}

#[test]
fn gapped_large_line_keeps_segments_and_decimates() {
    // segment-first 設計の回帰テスト: gap のある巨大系列が、間引き後も
    //   (a) セグメント融合せず ≥2 本の Polyline を保ち、
    //   (b) 間引きが効いて総点数が大幅に減り、
    //   (c) どのセグメントも崩壊・消失しない
    // ことを保証する。素朴な「間引き後に cat で再分割」実装ではここで全点が gap 扱いになり
    // 各点が長さ1セグメントへ割れて Polyline が 0 本になる((a) が FAIL する)。
    //
    // JSON の data は untagged `Nums(Vec<f64>)` で非有限値を表現できないため(null も 1e400 も
    // parse エラー)、parse 後に中央へ NaN を注入して gap を作る。これは line の gap 分割が
    // 認識する表現そのもの(`valid` フィルタが非有限値を落とし cat 不連続を生む)。
    let n = 8000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
    let json = format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
        labels.join(","),
        data.join(",")
    );
    let mut spec = chartjs::parse(&json, false).unwrap();
    spec.series[0].values[n / 2] = f64::NAN; // 中央に gap を作る

    let polys = polylines(&spec);
    let counts: Vec<usize> = polys.iter().map(|p| p.len()).collect();

    // (a) gap が保たれ 2 セグメント以上(gap をまたいで融合していない/線が消えていない)。
    assert!(
        counts.len() >= 2,
        "gap must yield >=2 polylines, got {}: {counts:?}",
        counts.len()
    );
    // (c) どのセグメントも 2 点以上(崩壊・消失していない)。
    assert!(
        counts.iter().all(|&c| c >= 2),
        "every polyline must have >=2 points: {counts:?}"
    );
    // (b) 間引きが効いて総点数が大幅に減る(gap で 1 点落ちた n-1 ではなく、明確に半分未満)。
    let total: usize = counts.iter().sum();
    assert!(
        total < n / 2,
        "decimation must substantially reduce total points: {total} vs n={n}"
    );
    // 念のため: y スケールが NaN 汚染されておらず、出力座標がすべて有限。
    assert!(
        polys
            .iter()
            .flatten()
            .all(|&(x, y)| x.is_finite() && y.is_finite()),
        "all emitted polyline points must be finite"
    );
}

#[test]
fn gapped_large_line_lttb_prorates_segment_budget() {
    // 回帰: LTTB × gap 多数セグメント時、per-segment に full samples を与えると
    // 合計 samples×セグメント数 点に膨れる(fulgur-chart-vzd)。セグメント長で
    // 予算を按分し、合計を samples+3×セグメント数 以下に上限化することを検証する。
    // JSON は非有限値を表現できないため parse 後に NaN を注入して gap を作る。
    let n = 8000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
    // samples/threshold を明示し、確実に LTTB 間引きを発動させる。
    let json = format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}},"options":{{"plugins":{{"decimation":{{"enabled":true,"algorithm":"lttb","samples":100,"threshold":500}}}}}}}}"#,
        labels.join(","),
        data.join(",")
    );
    let mut spec = chartjs::parse(&json, false).unwrap();
    // 3 箇所に孤立 NaN を注入 → 3 gap → 4 セグメント。
    for p in [n / 4, n / 2, 3 * n / 4] {
        spec.series[0].values[p] = f64::NAN;
    }

    let polys = polylines(&spec);
    let counts: Vec<usize> = polys.iter().map(|p| p.len()).collect();
    let num_seg = counts.len();
    let total: usize = counts.iter().sum();

    // 複数セグメントに割れている(gap が保たれている)。
    assert!(
        num_seg >= 2,
        "gaps must yield >=2 polylines, got {counts:?}"
    );
    // 各セグメントは崩壊していない。
    assert!(
        counts.iter().all(|&c| c >= 2),
        "no segment collapse: {counts:?}"
    );
    // 証明済み上限: samples(=100) + 3×num_segments。素朴実装なら 100×num_seg に膨れる。
    assert!(
        total <= 100 + 3 * num_seg,
        "budget must be prorated across segments: total={total}, num_seg={num_seg}"
    );
    // 素朴 per-segment 予算(samples×num_seg)を明確に下回る。
    assert!(
        total < 100 * num_seg,
        "must beat naive per-segment budget: total={total}"
    );
}

/// spec から Circle マーカー数を数える(NaN 注入など parse 後に変異させた spec 用)。
fn circle_count_spec(spec: &fulgur_chart::ir::ChartSpec) -> usize {
    let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
    let scene = line::build(spec, &m);
    scene
        .items
        .iter()
        .filter(|p| matches!(p, Prim::Circle { .. }))
        .count()
}

#[test]
fn all_singleton_gaps_keep_markers_when_decimated() {
    // 回帰: 非有限値で全ての有限点が「孤立点(単点セグメント)」になる巨大系列では、
    // resolve は有限点数(>threshold)で間引きを有効化するが、単点セグメントは Polyline を
    // 出さない。マーカーまで一律抑制すると線もマーカーも無い「空チャート」になる(以前は
    // 点として見えていた回帰)。単点セグメントの孤立点はマーカーを保持すること。
    // JSON は非有限値を表現できないため parse 後に奇数 index を NaN 化して孤立点を作る。
    let n = 12000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 37) % 101)).collect();
    let json = format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
        labels.join(","),
        data.join(",")
    );
    let mut spec = chartjs::parse(&json, false).unwrap();
    // 奇数 index を NaN 化 → 偶数 index の有限点はすべて cat 不連続の単点セグメント。
    for i in (1..n).step_by(2) {
        spec.series[0].values[i] = f64::NAN;
    }
    // 有限点(=n/2=6000)は threshold を超え間引きが有効化されるが、全て単点 → Polyline は 0 本。
    assert!(polylines(&spec).is_empty(), "all singletons → no polyline");
    // マーカーは孤立点の唯一の表現なので保持される(空チャートにしない)。
    assert!(
        circle_count_spec(&spec) > 0,
        "singleton (undrawable-as-line) points must keep markers when decimated"
    );
}

// --- 決定性・no-op サニティ・SVG↔PNG 一致(Task 9)---

#[test]
fn disabled_decimation_keeps_all_points_sanity() {
    // サニティ: enabled:false の巨大 line は単一セグメント全点を保持し、間引きされない。
    // (pre-feature バイト不変の真の保証は threshold 未満で緑のままの既存小 golden。
    //   これは passthrough = 非間引きと同形であることの確認のみ。)
    let n = 3000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 13) % 50)).collect();
    let off = format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}},"options":{{"plugins":{{"decimation":{{"enabled":false}}}}}}}}"#,
        labels.join(","),
        data.join(",")
    );
    assert_eq!(polyline_pts(&off), n);
}

/// 5000 点(threshold 超過確実)の自動間引き line spec を組み立てる。
fn big_decimated_line_json() -> String {
    let n = 5000;
    let labels: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
    let data: Vec<String> = (0..n).map(|i| format!("{}", (i * 29) % 83)).collect();
    format!(
        r#"{{"type":"line","data":{{"labels":[{}],"datasets":[{{"data":[{}]}}]}}}}"#,
        labels.join(","),
        data.join(",")
    )
}

#[test]
fn decimated_line_is_deterministic() {
    // 同一入力 → 同一バイト列(SVG・PNG 双方)。間引き経路の決定性を担保。
    let json = big_decimated_line_json();
    assert_eq!(render(&json), render(&json), "SVG must be byte-identical");
    assert_eq!(
        render_png(&json),
        render_png(&json),
        "PNG must be byte-identical"
    );
}

#[test]
fn decimated_line_renders_svg_and_png_consistently() {
    // SVG/PNG は build() の同一 Scene を消費するため、間引き-on の line が
    // 両出力でエラー無く・決定的にレンダされることを確認(geometry 共有の担保)。
    let json = big_decimated_line_json();
    let svg = render(&json);
    assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
    assert!(!svg.contains("NaN") && !svg.contains("inf"));
    assert!(svg.contains("<polyline"));

    let png = render_png(&json);
    assert!(png.len() > 8, "PNG must be non-empty");
    // 妥当な PNG であること(デコードできる)を tiny-skia で確認。
    let pix = tiny_skia::Pixmap::decode_png(&png).expect("decimated PNG must decode");
    assert!(pix.width() > 0 && pix.height() > 0);
}