vg_helper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/vg_helper.rb ADDED
@@ -0,0 +1,1626 @@
1
+ require "vega"
2
+ require "rover"
3
+
4
+ # パラメータを任意に足せる/変更できるようにするには?
5
+ # 第2軸の設定は?
6
+ # 垂直線も入れたい
7
+ # 日付軸の水平線 点線未対応
8
+
9
+ # Use Rover::DataFrame, Not Daru
10
+ # Usage: VGHelper::draw_xdate(df, "datetime", "value", ["category1", "category2"])
11
+
12
+ module VGHelper
13
+
14
+ # functionalize
15
+ DStep = 86400000
16
+
17
+ module_function
18
+
19
+ def udate(*params)
20
+ t = if params.length == 1 && params[0].is_a?(Time)
21
+ params[0].utc
22
+ elsif params.length == 1 && params[0].respond_to?(:to_time)
23
+ params[0].to_time.utc
24
+ elsif params.length == 1 && params[0].is_a?(String)
25
+ parts = params[0].split(/[\s\/\-T:]/).map(&:to_i)
26
+ Time.local(*parts[0..5])
27
+ else
28
+
29
+ # Array ex: [2025,7,31,0,0,8] or [2025, 7, 1]
30
+ Time.local(*params)
31
+ end
32
+
33
+ return (t.to_i * 1000).to_i
34
+ end
35
+
36
+ def msec_to_s(msec, mode = :day)
37
+ t = Time.at(msec / 1000.0).getlocal
38
+
39
+ case mode
40
+ when :day
41
+ t.strftime("%Y-%m-%dT00:00:00")
42
+ when :exact
43
+ t.strftime("%Y-%m-%dT%H:%M:%S")
44
+ else
45
+ raise ArgumentError, "unknown time_mode: #{mode}"
46
+ end
47
+ end
48
+
49
+ def floor_day_msec(msec)
50
+ t = Time.at(msec / 1000.0).getlocal
51
+ Time.local(t.year, t.month, t.day).to_i * 1000
52
+ end
53
+
54
+ def draw_xdate(df, x_col, y_col, category_col,
55
+ df2: nil, x2_col: nil, y2_col: nil, y2_category_col: nil,
56
+ horizontallines: [], horizontalcolors: nil,
57
+ verticallines: [], verticalcolors: nil,
58
+ minxdefault: nil, maxxdefault: nil,
59
+ minx: nil, maxx: nil,
60
+ miny: nil, maxy: nil,
61
+ markkind: nil, colorset: nil,
62
+ xslide: true,
63
+ yslide: true,
64
+ category_check: true,
65
+ yrangechanger: "number",
66
+ time_mode: :day,
67
+ reverse_orient: false,
68
+ width: 640, height: 360,
69
+ title: nil, categories: nil)
70
+
71
+ x_col = x_col.to_s
72
+ y_col = y_col.to_s
73
+
74
+
75
+ ############################
76
+ # Multi Category Rows Setting
77
+ ############################
78
+ values1 = df.dup
79
+
80
+ if category_col.is_a?(Array)
81
+ values1["multicategory"] =
82
+ values1[category_col].to_a.map { _1.values.join("_") }
83
+ category_col = "multicategory"
84
+ else
85
+ category_col = category_col.to_s
86
+ end
87
+ ############################
88
+ # Default Settings
89
+ ############################
90
+
91
+ categories ||= values1[category_col].to_a.uniq
92
+
93
+ markkind ||= {
94
+ type: "line",
95
+ point: { filled: true, size: 40 },
96
+ tooltip: true,
97
+ clip: true
98
+ }
99
+ title ||= y_col
100
+
101
+ colorset ||= (["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
102
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
103
+ "#393b79", "#637939", "#8c6d31", "#843c39", "#7b4173"] * 10)[0...(categories.length)]
104
+ horizontalcolors ||= (["#0000ff", "#ff0000"] * 6)[0...(horizontallines.length)]
105
+ colorset += horizontalcolors
106
+ verticalcolors ||= (["#0000ff", "#ff0000"] * 6)[0...(verticallines.length)]
107
+ colorset += verticalcolors
108
+
109
+ x1_msec = values1[x_col].to_a.map { |v| udate(v) }
110
+
111
+ if time_mode == :day
112
+ x1_msec.map! { |v| floor_day_msec(v) }
113
+ end
114
+
115
+ values1[x_col] = x1_msec
116
+ select_cols1 = [x_col, y_col, category_col].uniq
117
+ values1 = values1[select_cols1]
118
+ values1["dummy"] = "none"
119
+
120
+ v1_array = values1.to_a.map do |row|
121
+ r = row.transform_keys(&:to_s)
122
+ r["_x_date_str"] = msec_to_s(r[x_col], time_mode)
123
+ r["_data_src"] = "main"
124
+ r
125
+ end
126
+
127
+ # df2の前処理
128
+ v2_array = []
129
+ x2_col = (x2_col || x_col).to_s
130
+ y2_col = y2_col&.to_s
131
+ y2_category_col = y2_category_col&.to_s
132
+
133
+ x2_msec = []
134
+ if df2 && y2_col
135
+ v2 = df2.dup
136
+ x2_msec = v2[x2_col].to_a.map { |v| udate(v) }
137
+
138
+ if time_mode == :day
139
+ x2_msec.map! { |v| floor_day_msec(v) }
140
+ end
141
+
142
+ v2[x2_col] = x2_msec
143
+ v2[y2_col] = v2[y2_col].to_a.map { |v| v.to_f }
144
+
145
+ select_cols2 = [x2_col, y2_col]
146
+ select_cols2 << y2_category_col if y2_category_col
147
+ v2 = v2[select_cols2.uniq]
148
+
149
+ raw_v2 = v2.is_a?(Rover::DataFrame) ? v2.to_a : v2
150
+ v2_array = raw_v2.map do |row|
151
+ r = row.transform_keys(&:to_s)
152
+ r["_x_date_str"] = msec_to_s(r[x2_col], time_mode)
153
+ r["_data_src"] = "bar"
154
+ r
155
+ end
156
+ end
157
+
158
+ ############################
159
+ # Range Settings
160
+ ############################
161
+
162
+ # x range
163
+
164
+ if !v2_array.empty?
165
+ all_x_min = [x1_msec.min, x2_msec.min].min
166
+ all_x_max = [x1_msec.max, x2_msec.max].max
167
+ else
168
+ all_x_min = x1_msec.min
169
+ all_x_max = x1_msec.max
170
+ end
171
+
172
+ if time_mode == :day
173
+ all_x_min = floor_day_msec(all_x_min)
174
+ all_x_max = floor_day_msec(all_x_max)
175
+ end
176
+
177
+ minxdefault ||= all_x_min
178
+ maxxdefault ||= all_x_max
179
+
180
+ minx ||= all_x_min
181
+ maxx ||= all_x_max
182
+
183
+ # y range
184
+
185
+ miny ||= values1[y_col].min
186
+ maxy ||= values1[y_col].max
187
+
188
+ if !horizontallines.empty?
189
+ horizmin = horizontallines.map { _1[0] }.min
190
+ horizmax = horizontallines.map { _1[0] }.max
191
+ miny = [horizmin, miny].min
192
+ maxy = [horizmax, maxy].max
193
+ end
194
+
195
+ # Array 指定時
196
+ minxdefault = udate(*minxdefault) if minxdefault.is_a? Array
197
+ maxxdefault = udate(*maxxdefault) if maxxdefault.is_a? Array
198
+ minx = udate(*minx) if minx.is_a? Array
199
+ maxx = udate(*maxx) if maxx.is_a? Array
200
+
201
+
202
+ # 垂直線データ
203
+ vertical_array = verticallines.map do |vv|
204
+ x_msec = udate(vv[0])
205
+ x_msec = floor_day_msec(x_msec) if time_mode == :day
206
+
207
+ {
208
+ "_x_date_str" => msec_to_s(x_msec, time_mode),
209
+ "_vertical_name" => vv[1].to_s,
210
+ "_vertical_dash" => vv[2] || 0,
211
+ "_data_src" => "vertical"
212
+ }
213
+ end
214
+
215
+ all_values = v1_array + v2_array + vertical_array
216
+
217
+ # X軸
218
+ x_encoding = {
219
+ field: "_x_date_str",
220
+ type: "temporal",
221
+ scale: {
222
+ domain: [
223
+ { expr: "min_x" },
224
+ { expr: "max_x" }
225
+ ]
226
+ },
227
+ axis: {
228
+ format: "%Y/%m/%d",
229
+ titleFontSize: 16,
230
+ labelFontSize: 14
231
+ }
232
+ }
233
+
234
+ #if time_mode == :day
235
+ # x_encoding[:timeUnit] = "utcyearmonthdate"
236
+ #end
237
+
238
+ # 水平線データの構築
239
+ horiz_layer = []
240
+ if horizontallines && !horizontallines.empty?
241
+ x_vals_for_horiz = (values1[x_col].to_a + [minxdefault, maxxdefault]).uniq.sort
242
+
243
+ horizontallines.each do |hv|
244
+ horiz_rows = x_vals_for_horiz.map do |x_val|
245
+ {
246
+ x_col => x_val,
247
+ y_col => hv[0],
248
+ category_col => hv[1],
249
+ "dummy" => "horiz",
250
+ "_x_date_str" => msec_to_s(x_val, time_mode),
251
+ "_data_src" => "main"
252
+ }
253
+ end
254
+ all_values.concat(horiz_rows)
255
+ end
256
+
257
+ horiz_layer = [{
258
+ mark: { type: "line", point: false, tooltip: false, clip: true },
259
+ transform: [
260
+ { filter: "datum['_data_src'] == 'main' && datum.dummy == 'horiz'" }
261
+ ],
262
+ encoding: {
263
+ x: x_encoding,
264
+ y: {
265
+ field: y_col,
266
+ type: "quantitative",
267
+ scale: { domain: [{expr: "min_y"}, {expr: "max_y"}] },
268
+ axis: nil
269
+ },
270
+ color: {
271
+ field: category_col,
272
+ type: "nominal",
273
+ scale: {
274
+ domain: horizontallines.map { _1[1] },
275
+ range: horizontalcolors
276
+ }
277
+ }
278
+ }
279
+ }]
280
+ end
281
+
282
+ vertical_layer = []
283
+ if verticallines && !verticallines.empty?
284
+ # 垂直線レイヤ
285
+ vertical_layer += [{
286
+ mark: {
287
+ type: "rule",
288
+ strokeWidth: 2,
289
+ clip: true
290
+ },
291
+ transform: [
292
+ { filter: "datum['_data_src'] == 'vertical'" }
293
+ ],
294
+ encoding: {
295
+ x: x_encoding,
296
+ color: {
297
+ field: "_vertical_name",
298
+ type: "nominal",
299
+ scale: {
300
+ domain: verticallines.map { _1[1].to_s },
301
+ range: verticalcolors
302
+ }
303
+ },
304
+ strokeDash: {
305
+ field: "_vertical_dash",
306
+ type: "nominal",
307
+ legend: false
308
+ }
309
+ }
310
+ }]
311
+ end
312
+
313
+ ############################
314
+ # filtering parameter settings
315
+ ############################
316
+
317
+ # categ_params = categories.map do |c|
318
+ # {
319
+ # name: "show_#{c}",
320
+ # value: true,
321
+ # bind: { input: "checkbox", name: c }
322
+ # }
323
+ # end
324
+
325
+ params = []
326
+
327
+ if xslide
328
+ params += [
329
+ {
330
+ name: "min_x",
331
+ value: minxdefault,
332
+ bind: {
333
+ input: "range",
334
+ min: minx,
335
+ max: maxx,
336
+ step: DStep
337
+ }
338
+ },
339
+ {
340
+ name: "max_x",
341
+ value: maxxdefault,
342
+ bind: {
343
+ input: "range",
344
+ min: minx,
345
+ max: maxx,
346
+ step: DStep
347
+ }
348
+ }
349
+ ]
350
+ else
351
+ params += [
352
+ { name: "min_x", value: minxdefault },
353
+ { name: "max_x", value: maxxdefault }
354
+ ]
355
+ end
356
+
357
+ params += [
358
+ {
359
+ name: "min_x_label",
360
+ expr: "timeFormat(min_x, '%Y/%m/%d', 'utc')"
361
+ },
362
+ {
363
+ name: "max_x_label",
364
+ expr: "timeFormat(max_x, '%Y/%m/%d', 'utc')"
365
+ }
366
+ ]
367
+
368
+ if yslide
369
+ params += [
370
+ {
371
+ name: "min_y",
372
+ value: miny,
373
+ bind: {
374
+ input: yrangechanger, # yrangechanger: "range" も可能
375
+ min: miny,
376
+ max: maxy
377
+ }
378
+ },
379
+ {
380
+ name: "max_y",
381
+ value: maxy,
382
+ bind: {
383
+ input: yrangechanger,
384
+ min: miny,
385
+ max: maxy
386
+ }
387
+ }
388
+ ]
389
+ else
390
+ params += [
391
+ { name: "min_y", value: miny },
392
+ { name: "max_y", value: maxy }
393
+ ]
394
+ end
395
+
396
+ categ_params = categories.map do |c|
397
+ ctp = {
398
+ name: "show_#{c}",
399
+ value: true
400
+ }
401
+
402
+ if category_check
403
+ ctp[:bind] = {
404
+ input: "checkbox",
405
+ name: c
406
+ }
407
+ end
408
+
409
+ ctp
410
+ end
411
+
412
+ params += categ_params
413
+
414
+ categ_transforms = categories.map {|c| "(show_#{c} && datum.#{category_col} == '#{c}')" }.join(" || ")
415
+
416
+ # 第二縦軸
417
+ bar_layer = []
418
+ if !v2_array.empty?
419
+ bar_encoding = {
420
+ x: {
421
+ field: "_x_date_str",
422
+ type: "temporal",
423
+ scale: {
424
+ domain: [
425
+ { expr: "min_x" },
426
+ { expr: "max_x" }
427
+ ],
428
+ padding: 24
429
+ },
430
+ #axis: nil
431
+ },
432
+ y: {
433
+ field: y2_col,
434
+ type: "quantitative",
435
+ axis: {
436
+ orient: reverse_orient ? :left : :right,
437
+ title: y2_col,
438
+ titleFontSize: 16,
439
+ labelFontSize: 14
440
+ }
441
+ }
442
+ }
443
+
444
+ if y2_category_col
445
+ bar_encoding[:color] = {
446
+ field: y2_category_col,
447
+ type: "nominal",
448
+ legend: { title: y2_category_col }
449
+ }
450
+ end
451
+
452
+ bar_layer = [{
453
+ mark: {type: "bar", opacity: 0.55, size: 24, clip: true },
454
+ transform: [
455
+ { filter: "datum['_data_src'] == 'bar'" }
456
+ ],
457
+ encoding: bar_encoding
458
+ }]
459
+ end
460
+
461
+ # メインレイヤ
462
+ main_layer = [{
463
+ transform: [
464
+ { filter: "datum['_data_src'] == 'main' && datum.dummy == 'none'" },
465
+ { filter: categ_transforms }
466
+ ],
467
+ selection: {
468
+ brush: {
469
+ type: "interval",
470
+ encodings: ["x"],
471
+ mark: {fill: "#dddddd", fillOpacity: 0.35 }
472
+ }
473
+ },
474
+ mark: { **markkind },
475
+ encoding: {
476
+ x: x_encoding,
477
+ y: {
478
+ field: y_col,
479
+ type: "quantitative",
480
+ title: y_col,
481
+ axis: {
482
+ orient: reverse_orient ? :right : :left,
483
+ titleFontSize: 16,
484
+ labelFontSize: 14
485
+ },
486
+ scale: { domain: [{expr: "min_y"}, {expr: "max_y"}] }
487
+ },
488
+ color: {
489
+ field: category_col,
490
+ type: "nominal",
491
+ scale: { domain: categories, range: colorset }
492
+ }
493
+ }
494
+ }]
495
+
496
+ # 情報表示レイヤ
497
+ info_layer1 = [{
498
+ transform: [
499
+ {
500
+ filter:
501
+ "datum['#{x_col}'] >= min_x && datum['#{x_col}'] <= max_x"
502
+ },
503
+ {
504
+ filter:
505
+ "datum['_data_src'] == 'main' && datum.dummy == 'none'"
506
+ },
507
+ {
508
+ filter: categ_transforms
509
+ },
510
+ {
511
+ filter: { param: "brush" }
512
+ },
513
+ {
514
+ aggregate: [
515
+ { op: "mean", field: y_col, as: "avg" },
516
+ { op: "stdev", field: y_col, as: "std" },
517
+ { op: "max", field: y_col, as: "max" },
518
+ { op: "min", field: y_col, as: "min" },
519
+ { op: "count", as: "n" },
520
+
521
+ { op: "min", field: x_col, as: "xmin" },
522
+ { op: "max", field: x_col, as: "xmax" }
523
+ ]
524
+ },
525
+ {
526
+ calculate:
527
+ "'#{y_col} n: ' + format(datum.n, 'd')" \
528
+ " + ' avg: ' + format(datum.avg, '.2f')" \
529
+ " + ' std: ' + format(datum.std, '.2f')" \
530
+ " + ' max: ' + format(datum.max, '.2f')" \
531
+ " + ' min: ' + format(datum.min, '.2f')",
532
+ as: "label"
533
+ }
534
+ ],
535
+
536
+ mark: {
537
+ type: "text",
538
+ fontSize: 13,
539
+ align: "left"
540
+ },
541
+
542
+ encoding: {
543
+ text: { field: "label" },
544
+ x: { value: 10 },
545
+ y: { value: 10 }
546
+ }
547
+ }]
548
+
549
+ info_layer2 = []
550
+
551
+ unless v2_array.empty?
552
+ info_layer2 = [{
553
+ transform: [
554
+ {
555
+ filter:
556
+ "datum['#{x2_col}'] >= min_x && datum['#{x2_col}'] <= max_x"
557
+ },
558
+
559
+ {
560
+ filter:
561
+ "datum['_data_src'] == 'bar'"
562
+ },
563
+
564
+ {
565
+ filter: {
566
+ param: "brush"
567
+ }
568
+ },
569
+
570
+ # まず同じx位置の棒を合計
571
+ {
572
+ aggregate: [
573
+ {
574
+ op: "sum",
575
+ field: y2_col,
576
+ as: "stack_total"
577
+ }
578
+ ],
579
+ groupby: [
580
+ #x2_col
581
+ "_x_date_str"
582
+ ]
583
+ },
584
+
585
+ # その積み上げ合計群で統計
586
+ {
587
+ aggregate: [
588
+ {
589
+ op: "mean",
590
+ field: "stack_total",
591
+ as: "avg"
592
+ },
593
+ {
594
+ op: "stdev",
595
+ field: "stack_total",
596
+ as: "std"
597
+ },
598
+ {
599
+ op: "max",
600
+ field: "stack_total",
601
+ as: "max"
602
+ },
603
+ {
604
+ op: "min",
605
+ field: "stack_total",
606
+ as: "min"
607
+ },
608
+ {
609
+ op: "count",
610
+ as: "n"
611
+ }
612
+ ]
613
+ },
614
+
615
+ {
616
+ calculate:
617
+ "'#{y2_col} n: ' + format(datum.n, 'd')" \
618
+ " + ' avg: ' + format(datum.avg, '.2f')" \
619
+ " + ' std: ' + format(datum.std, '.2f')" \
620
+ " + ' max: ' + format(datum.max, '.2f')" \
621
+ " + ' min: ' + format(datum.min, '.2f')",
622
+ as: "label"
623
+ }
624
+ ],
625
+
626
+ mark: {
627
+ type: "text",
628
+ fontSize: 13,
629
+ align: "left"
630
+ },
631
+
632
+ encoding: {
633
+ text: {
634
+ field: "label"
635
+ },
636
+
637
+ x: {
638
+ value: 10
639
+ },
640
+
641
+ y: {
642
+ value: 28
643
+ }
644
+ }
645
+ }]
646
+ end
647
+
648
+ layers = bar_layer + horiz_layer + vertical_layer + main_layer + info_layer1 + info_layer2
649
+
650
+ builder = Vega.lite
651
+ .title(title)
652
+ .data(values: all_values)
653
+ .params(params)
654
+ .layer(layers)
655
+ .width(width)
656
+ .height(height)
657
+
658
+ if !v2_array.empty?
659
+ builder = builder.resolve(
660
+ scale: { y: "independent", color: "independent", x: "shared" },
661
+ axis: { x: "shared" }
662
+ )
663
+ end
664
+
665
+ builder
666
+
667
+ end
668
+
669
+ def draw_xnominal(
670
+ df, x_col, y_col, category_col,
671
+ df2: nil, x2_col: nil, y2_col: nil, y2_category_col: nil,
672
+ categories: nil,
673
+ miny: nil, maxy: nil,
674
+ minydefault: nil, maxydefault: nil,
675
+ initialxpoints: 300,
676
+ markkind: nil,
677
+ colorset: nil,
678
+ yrangechanger: "number",
679
+ horizontallines: [],
680
+ horizontalcolors: nil,
681
+ verticallines: [],
682
+ verticalcolors: nil,
683
+ width: 640,
684
+ height: 360,
685
+ title: nil,
686
+ category_check: true,
687
+ xslide: true,
688
+ yslide: true,
689
+ reverse_orient: false
690
+ )
691
+
692
+ ############################
693
+ # Column settings
694
+ ############################
695
+
696
+ x_col_str =
697
+ x_col.is_a?(Array) ? "multix" : x_col.to_s
698
+
699
+ y_col_str = y_col.to_s
700
+
701
+ category_col_str =
702
+ category_col.is_a?(Array) ? "multicategory" : category_col.to_s
703
+
704
+ x2_source = x2_col || x_col
705
+
706
+ x2_col_str =
707
+ x2_source.is_a?(Array) ? "multix2" : x2_source.to_s
708
+
709
+ y2_col_str = y2_col&.to_s
710
+
711
+ y2_category_col_str =
712
+ if y2_category_col.is_a?(Array)
713
+ "multicategory2"
714
+ else
715
+ y2_category_col&.to_s
716
+ end
717
+
718
+ title ||= y_col_str
719
+
720
+ ############################
721
+ # Mark settings
722
+ ############################
723
+
724
+ markkind = {
725
+ type: "line",
726
+ tooltip: true,
727
+ point: {
728
+ filled: false,
729
+ size: 20
730
+ },
731
+ clip: true
732
+ }.merge(markkind || {})
733
+
734
+ ############################
735
+ # Main dataframe
736
+ ############################
737
+
738
+ dfdup = df.dup
739
+
740
+ if x_col.is_a?(Array)
741
+ dfdup["multix"] =
742
+ dfdup[x_col].to_a.map { _1.values.join("_") }
743
+ end
744
+
745
+ if category_col.is_a?(Array)
746
+ dfdup["multicategory"] =
747
+ dfdup[category_col].to_a.map { _1.values.join("_") }
748
+ end
749
+
750
+ categories ||=
751
+ dfdup[category_col_str]
752
+ .uniq
753
+ .to_a
754
+ .map(&:to_s)
755
+
756
+ ############################
757
+ # Second dataframe
758
+ ############################
759
+
760
+ df2dup = nil
761
+
762
+ if df2 && y2_col_str
763
+ df2dup = df2.dup
764
+
765
+ if x2_source.is_a?(Array)
766
+ df2dup["multix2"] =
767
+ df2dup[x2_source].to_a.map { _1.values.join("_") }
768
+ end
769
+
770
+ if y2_category_col.is_a?(Array)
771
+ df2dup["multicategory2"] =
772
+ df2dup[y2_category_col].to_a.map { _1.values.join("_") }
773
+ end
774
+ end
775
+
776
+ ############################
777
+ # X values / ordering
778
+ ############################
779
+
780
+ x_all =
781
+ dfdup[x_col_str]
782
+ .to_a
783
+ .map(&:to_s)
784
+
785
+ if df2dup
786
+ x_all +=
787
+ df2dup[x2_col_str]
788
+ .to_a
789
+ .map(&:to_s)
790
+ end
791
+
792
+ # 元データの出現順を保持
793
+ xcs = x_all.uniq
794
+
795
+ xcsh =
796
+ xcs.each_with_index.to_h
797
+
798
+ ############################
799
+ # Main data
800
+ ############################
801
+
802
+ v1_array =
803
+ dfdup.to_a.map do |row|
804
+ r = row.transform_keys(&:to_s)
805
+
806
+ x_value =
807
+ r[x_col_str].to_s
808
+
809
+ r["_x_value"] = x_value
810
+ r["_order"] = xcsh[x_value]
811
+ r["_src"] = "main"
812
+
813
+ r
814
+ end
815
+
816
+ ############################
817
+ # Second-axis data
818
+ ############################
819
+
820
+ v2_array = []
821
+
822
+ if df2dup
823
+ v2_array =
824
+ df2dup.to_a.map do |row|
825
+ r = row.transform_keys(&:to_s)
826
+
827
+ x_value =
828
+ r[x2_col_str].to_s
829
+
830
+ r["_x_value"] = x_value
831
+ r[y2_col_str] =
832
+ r[y2_col_str].to_f
833
+
834
+ r["_order"] =
835
+ xcsh[x_value]
836
+
837
+ r["_src"] = "bar"
838
+
839
+ r
840
+ end
841
+ end
842
+
843
+ ############################
844
+ # Horizontal-line data
845
+ ############################
846
+
847
+ horiz_array = []
848
+
849
+ unless horizontallines.empty?
850
+ horizontallines.each do |hv|
851
+ xcs.each do |x_value|
852
+ horiz_array << {
853
+ "_x_value" => x_value,
854
+ y_col_str => hv[0],
855
+ category_col_str => hv[1].to_s,
856
+ "_order" => xcsh[x_value],
857
+ "_dummy2" => hv[2] || 0,
858
+ "_src" => "horiz"
859
+ }
860
+ end
861
+ end
862
+ end
863
+
864
+ ############################
865
+ # Vertical-line data
866
+ ############################
867
+
868
+ vertical_array = verticallines.map do |vv|
869
+ {
870
+ "_x_value" => vv[0].to_s,
871
+ "_vertical_name" => vv[1].to_s,
872
+ "_vertical_dash" => vv[2] || 0,
873
+ "_src" => "vertical"
874
+ }
875
+ end
876
+
877
+ ############################
878
+ # All data
879
+ ############################
880
+
881
+ all_data =
882
+ v1_array +
883
+ horiz_array +
884
+ vertical_array +
885
+ v2_array
886
+
887
+ ############################
888
+ # Y range
889
+ ############################
890
+
891
+ miny ||= dfdup[y_col_str].min
892
+ maxy ||= dfdup[y_col_str].max
893
+
894
+ unless horizontallines.empty?
895
+ horiz_min =
896
+ horizontallines.map { _1[0] }.min
897
+
898
+ horiz_max =
899
+ horizontallines.map { _1[0] }.max
900
+
901
+ miny =
902
+ [miny, horiz_min].min
903
+
904
+ maxy =
905
+ [maxy, horiz_max].max
906
+ end
907
+
908
+ minydefault ||= miny
909
+ maxydefault ||= maxy
910
+
911
+ ############################
912
+ # Colors
913
+ ############################
914
+
915
+ colorset ||= (
916
+ [
917
+ "#1f77b4",
918
+ "#ff7f0e",
919
+ "#2ca02c",
920
+ "#d62728",
921
+ "#9467bd",
922
+ "#8c564b",
923
+ "#e377c2",
924
+ "#7f7f7f",
925
+ "#bcbd22",
926
+ "#17becf",
927
+ "#393b79",
928
+ "#637939",
929
+ "#8c6d31",
930
+ "#843c39",
931
+ "#7b4173"
932
+ ] * 10
933
+ )[0...categories.length]
934
+
935
+ horizontalcolors ||= (
936
+ ["#0000ff", "#ff0000"] * 10
937
+ )[0...horizontallines.length]
938
+
939
+ verticalcolors ||= (["#0000ff", "#ff0000"] * 6)[0...(verticallines.length)]
940
+
941
+ ############################
942
+ # X slider
943
+ ############################
944
+
945
+ max_order =
946
+ xcs.length - 1
947
+
948
+ default_start =
949
+ [
950
+ max_order -
951
+ (initialxpoints - 1),
952
+ 0
953
+ ].max
954
+
955
+ params = []
956
+
957
+ if xslide
958
+ params += [
959
+ {
960
+ name: "start",
961
+ value: default_start,
962
+ bind: {
963
+ input: "range",
964
+ min: 0,
965
+ max: max_order,
966
+ step: 1
967
+ }
968
+ },
969
+ {
970
+ name: "end",
971
+ value: max_order,
972
+ bind: {
973
+ input: "range",
974
+ min: 0,
975
+ max: max_order,
976
+ step: 1
977
+ }
978
+ }
979
+ ]
980
+ end
981
+
982
+ xslide_transform =
983
+ if xslide
984
+ [
985
+ {
986
+ filter:
987
+ "datum._order >= start && datum._order <= end"
988
+ }
989
+ ]
990
+ else
991
+ []
992
+ end
993
+
994
+ ############################
995
+ # Y slider
996
+ ############################
997
+
998
+ if yslide
999
+ params += [
1000
+ {
1001
+ name: "min_y",
1002
+ value: minydefault,
1003
+ bind: {
1004
+ input: yrangechanger,
1005
+ min: miny,
1006
+ max: maxy
1007
+ }
1008
+ },
1009
+ {
1010
+ name: "max_y",
1011
+ value: maxydefault,
1012
+ bind: {
1013
+ input: yrangechanger,
1014
+ min: miny,
1015
+ max: maxy
1016
+ }
1017
+ }
1018
+ ]
1019
+
1020
+ ydomain = [
1021
+ { expr: "min_y" },
1022
+ { expr: "max_y" }
1023
+ ]
1024
+ else
1025
+ ydomain = [
1026
+ minydefault,
1027
+ maxydefault
1028
+ ]
1029
+ end
1030
+
1031
+ ############################
1032
+ # Category checkbox
1033
+ ############################
1034
+
1035
+ category_transforms = []
1036
+
1037
+ if category_check
1038
+ category_params =
1039
+ categories.map do |c|
1040
+ {
1041
+ name: "show_#{c}",
1042
+ value: true,
1043
+ bind: {
1044
+ input: "checkbox",
1045
+ name: c
1046
+ }
1047
+ }
1048
+ end
1049
+
1050
+ params += category_params
1051
+
1052
+ categ_filter_str =
1053
+ categories.map do |c|
1054
+ "(show_#{c} && datum['#{category_col_str}'] == '#{c}')"
1055
+ end.join(" || ")
1056
+
1057
+ category_transforms = [
1058
+ {
1059
+ filter:
1060
+ categ_filter_str
1061
+ }
1062
+ ]
1063
+ end
1064
+
1065
+ ############################
1066
+ # Common X encoding
1067
+ ############################
1068
+
1069
+ x_encoding = {
1070
+ field: "_x_value",
1071
+ type: "nominal",
1072
+
1073
+ # ここ重要。
1074
+ # Step_001 → Step_002 → ... の順序を保持。
1075
+ sort: xcs,
1076
+
1077
+ scale: {
1078
+ type: "band"
1079
+ },
1080
+
1081
+ axis: {
1082
+ title: x_col_str,
1083
+ labelOverlap: "parity",
1084
+ titleFontSize: 16,
1085
+ labelFontSize: 14
1086
+ }
1087
+ }
1088
+
1089
+ ############################
1090
+ # Layers
1091
+ ############################
1092
+
1093
+ layers = []
1094
+
1095
+ ############################
1096
+ # Second-axis bars
1097
+ ############################
1098
+
1099
+ unless v2_array.empty?
1100
+ bar_encoding = {
1101
+ y: {
1102
+ field: y2_col_str,
1103
+ type: "quantitative",
1104
+
1105
+ axis: {
1106
+ orient:
1107
+ reverse_orient ? :left : :right,
1108
+
1109
+ title:
1110
+ y2_col_str,
1111
+
1112
+ titleFontSize: 16,
1113
+ labelFontSize: 14
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ if y2_category_col_str
1119
+ bar_encoding[:color] = {
1120
+ field:
1121
+ y2_category_col_str,
1122
+
1123
+ type:
1124
+ "nominal",
1125
+
1126
+ legend: {
1127
+ title:
1128
+ y2_category_col_str
1129
+ }
1130
+ }
1131
+ end
1132
+
1133
+ layers << {
1134
+ mark: {
1135
+ type: "bar",
1136
+ opacity: 0.55,
1137
+ size: 24,
1138
+ clip: true
1139
+ },
1140
+
1141
+ transform:
1142
+ xslide_transform +
1143
+ [
1144
+ {
1145
+ filter:
1146
+ "datum._src == 'bar'"
1147
+ }
1148
+ ],
1149
+
1150
+ encoding:
1151
+ bar_encoding
1152
+ }
1153
+ end
1154
+
1155
+ ############################
1156
+ # Horizontal lines
1157
+ ############################
1158
+
1159
+ unless horizontallines.empty?
1160
+ layers << {
1161
+ mark: {
1162
+ type: "line",
1163
+ point: false,
1164
+ tooltip: false,
1165
+ clip: true
1166
+ },
1167
+
1168
+ transform:
1169
+ xslide_transform +
1170
+ [
1171
+ {
1172
+ filter:
1173
+ "datum._src == 'horiz'"
1174
+ }
1175
+ ],
1176
+
1177
+ encoding: {
1178
+ y: {
1179
+ field:
1180
+ y_col_str,
1181
+
1182
+ type:
1183
+ "quantitative",
1184
+
1185
+ scale: {
1186
+ domain:
1187
+ ydomain
1188
+ },
1189
+
1190
+ axis:
1191
+ nil
1192
+ },
1193
+
1194
+ color: {
1195
+ field:
1196
+ category_col_str,
1197
+
1198
+ type:
1199
+ "nominal",
1200
+
1201
+ scale: {
1202
+ domain:
1203
+ horizontallines.map {
1204
+ _1[1].to_s
1205
+ },
1206
+
1207
+ range:
1208
+ horizontalcolors
1209
+ }
1210
+ },
1211
+
1212
+ strokeDash: {
1213
+ field:
1214
+ "_dummy2",
1215
+
1216
+ type:
1217
+ "nominal",
1218
+
1219
+ title:
1220
+ "",
1221
+
1222
+ legend:
1223
+ false
1224
+ }
1225
+ }
1226
+ }
1227
+ end
1228
+
1229
+ ############################
1230
+ # Vertical lines
1231
+ ############################
1232
+
1233
+ unless verticallines.empty?
1234
+ layers << {
1235
+ mark: {
1236
+ type: "rule",
1237
+ strokeWidth: 2,
1238
+ clip: true
1239
+ },
1240
+ transform: [
1241
+ { filter: "datum['_src'] == 'vertical'" }
1242
+ ],
1243
+ encoding: {
1244
+ x: x_encoding,
1245
+ color: {
1246
+ field: "_vertical_name",
1247
+ type: "nominal",
1248
+ scale: {
1249
+ domain: verticallines.map { _1[1].to_s },
1250
+ range: verticalcolors
1251
+ }
1252
+ },
1253
+ strokeDash: {
1254
+ field: "_vertical_dash",
1255
+ type: "nominal",
1256
+ legend: false
1257
+ }
1258
+ }
1259
+ }
1260
+ end
1261
+
1262
+ ############################
1263
+ # Main line
1264
+ ############################
1265
+
1266
+ layers << {
1267
+ mark:
1268
+ markkind,
1269
+
1270
+ transform:
1271
+ xslide_transform +
1272
+ [
1273
+ {
1274
+ filter:
1275
+ "datum._src == 'main'"
1276
+ }
1277
+ ] +
1278
+ category_transforms,
1279
+
1280
+ selection: {
1281
+ brush: {
1282
+ type:
1283
+ "interval",
1284
+
1285
+ encodings:
1286
+ ["x"],
1287
+
1288
+ mark: {
1289
+ fill:
1290
+ "#dddddd",
1291
+
1292
+ fillOpacity:
1293
+ 0.35
1294
+ }
1295
+ }
1296
+ },
1297
+
1298
+ encoding: {
1299
+ y: {
1300
+ field:
1301
+ y_col_str,
1302
+
1303
+ type:
1304
+ "quantitative",
1305
+
1306
+ axis: {
1307
+ orient:
1308
+ reverse_orient ? :right : :left,
1309
+
1310
+ title:
1311
+ y_col_str,
1312
+
1313
+ titleFontSize:
1314
+ 16,
1315
+
1316
+ labelFontSize:
1317
+ 14
1318
+ },
1319
+
1320
+ scale: {
1321
+ domain:
1322
+ ydomain
1323
+ }
1324
+ },
1325
+
1326
+ color: {
1327
+ field:
1328
+ category_col_str,
1329
+
1330
+ type:
1331
+ "nominal",
1332
+
1333
+ scale: {
1334
+ domain:
1335
+ categories,
1336
+
1337
+ range:
1338
+ colorset
1339
+ }
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ ############################
1345
+ # Main-axis statistics
1346
+ ############################
1347
+
1348
+ layers << {
1349
+ transform:
1350
+ xslide_transform +
1351
+ [
1352
+ {
1353
+ filter:
1354
+ "datum._src == 'main'"
1355
+ }
1356
+ ] +
1357
+ category_transforms +
1358
+ [
1359
+ {
1360
+ filter: {
1361
+ param:
1362
+ "brush"
1363
+ }
1364
+ },
1365
+
1366
+ {
1367
+ aggregate: [
1368
+ {
1369
+ op: "mean",
1370
+ field: y_col_str,
1371
+ as: "avg"
1372
+ },
1373
+ {
1374
+ op: "stdev",
1375
+ field: y_col_str,
1376
+ as: "std"
1377
+ },
1378
+ {
1379
+ op: "max",
1380
+ field: y_col_str,
1381
+ as: "max"
1382
+ },
1383
+ {
1384
+ op: "min",
1385
+ field: y_col_str,
1386
+ as: "min"
1387
+ },
1388
+ {
1389
+ op: "count",
1390
+ as: "n"
1391
+ }
1392
+ ]
1393
+ },
1394
+
1395
+ {
1396
+ calculate:
1397
+ "'#{y_col_str} n: ' + format(datum.n, 'd')" \
1398
+ " + ' avg: ' + format(datum.avg, '.2f')" \
1399
+ " + ' std: ' + format(datum.std, '.2f')" \
1400
+ " + ' max: ' + format(datum.max, '.2f')" \
1401
+ " + ' min: ' + format(datum.min, '.2f')",
1402
+
1403
+ as:
1404
+ "label"
1405
+ }
1406
+ ],
1407
+
1408
+ mark: {
1409
+ type:
1410
+ "text",
1411
+
1412
+ fontSize:
1413
+ 13,
1414
+
1415
+ align:
1416
+ "left"
1417
+ },
1418
+
1419
+ encoding: {
1420
+ text: {
1421
+ field:
1422
+ "label"
1423
+ },
1424
+
1425
+ x: {
1426
+ value:
1427
+ 10
1428
+ },
1429
+
1430
+ y: {
1431
+ value:
1432
+ 10
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ ############################
1438
+ # Second-axis statistics
1439
+ ############################
1440
+
1441
+ unless v2_array.empty?
1442
+ layers << {
1443
+ transform:
1444
+ xslide_transform +
1445
+ [
1446
+ {
1447
+ filter:
1448
+ "datum._src == 'bar'"
1449
+ },
1450
+
1451
+ {
1452
+ filter: {
1453
+ param: "brush"
1454
+ }
1455
+ },
1456
+
1457
+ # --------------------------------
1458
+ # まず各Xについて積み上げ棒の高さを求める
1459
+ # --------------------------------
1460
+ {
1461
+ aggregate: [
1462
+ {
1463
+ op: "sum",
1464
+ field: y2_col_str,
1465
+ as: "stack_total"
1466
+ }
1467
+ ],
1468
+ groupby: [
1469
+ "_x_value"
1470
+ ]
1471
+ },
1472
+
1473
+ # --------------------------------
1474
+ # stack_total を一つの観測値として統計
1475
+ # --------------------------------
1476
+ {
1477
+ aggregate: [
1478
+ {
1479
+ op: "mean",
1480
+ field: "stack_total",
1481
+ as: "avg"
1482
+ },
1483
+ {
1484
+ op: "stdev",
1485
+ field: "stack_total",
1486
+ as: "std"
1487
+ },
1488
+ {
1489
+ op: "max",
1490
+ field: "stack_total",
1491
+ as: "max"
1492
+ },
1493
+ {
1494
+ op: "min",
1495
+ field: "stack_total",
1496
+ as: "min"
1497
+ },
1498
+ {
1499
+ op: "count",
1500
+ as: "n"
1501
+ }
1502
+ ]
1503
+ },
1504
+
1505
+ {
1506
+ calculate:
1507
+ "'#{y2_col_str} n: ' + format(datum.n, 'd')" \
1508
+ " + ' avg: ' + format(datum.avg, '.2f')" \
1509
+ " + ' std: ' + format(datum.std, '.2f')" \
1510
+ " + ' max: ' + format(datum.max, '.2f')" \
1511
+ " + ' min: ' + format(datum.min, '.2f')",
1512
+
1513
+ as: "label"
1514
+ }
1515
+ ],
1516
+
1517
+ mark: {
1518
+ type: "text",
1519
+ fontSize: 13,
1520
+ align: "left"
1521
+ },
1522
+
1523
+ encoding: {
1524
+ text: {
1525
+ field: "label"
1526
+ },
1527
+
1528
+ x: {
1529
+ value: 10
1530
+ },
1531
+
1532
+ y: {
1533
+ value: 28
1534
+ }
1535
+ }
1536
+ }
1537
+ end
1538
+
1539
+ ############################
1540
+ # Build
1541
+ ############################
1542
+
1543
+ builder =
1544
+ Vega.lite
1545
+ .data(values: all_data)
1546
+ .params(params)
1547
+ .encoding(x: x_encoding)
1548
+ .layer(layers)
1549
+ .width(width)
1550
+ .height(height)
1551
+ .title(title)
1552
+
1553
+ unless v2_array.empty?
1554
+ builder =
1555
+ builder.resolve(
1556
+ scale: {
1557
+ y:
1558
+ "independent",
1559
+
1560
+ color:
1561
+ "independent"
1562
+ }
1563
+ )
1564
+ end
1565
+
1566
+ builder
1567
+ end
1568
+
1569
+ def write_htmls(charts, out)
1570
+ ## set .js
1571
+ #thedir = __dir__
1572
+ #
1573
+ #vegas = ["#{thedir}/vega.min.js",
1574
+ # "#{thedir}/vega-lite.min.js",
1575
+ # "#{thedir}/vega-embed.min.js"]
1576
+
1577
+ asset_dir = File.join(__dir__, "vg_helper", "assets")
1578
+ vegas = [
1579
+ File.join(asset_dir, "vega.min.js"),
1580
+ File.join(asset_dir, "vega-lite.min.js"),
1581
+ File.join(asset_dir, "vega-embed.min.js")
1582
+ ]
1583
+
1584
+ vegas_lines = vegas.map { |vf|
1585
+ "<script>#{File.read(vf, encoding: 'cp932')}</script>"
1586
+ }.join("\n")
1587
+
1588
+ ## header, footer (No Slider Caption)
1589
+ html_head = <<-HTML
1590
+ <!doctype html>
1591
+ <html lang="ja">
1592
+ <head>
1593
+ <meta charset="utf-8" />
1594
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1595
+ <title>Vega chart</title>
1596
+ </head>
1597
+ <style>
1598
+ input[type=range]+span {
1599
+ color: transparent !important;
1600
+ }
1601
+
1602
+ .vega-bind {
1603
+ display: inline-block !important;
1604
+ margin-right: 20px;
1605
+ vertical-align: middle;
1606
+ }
1607
+ </style>
1608
+ <body>
1609
+ <div id="vis"></div>
1610
+ #{vegas_lines}
1611
+ HTML
1612
+
1613
+ html_foot = <<~'FOOT'
1614
+ </body>
1615
+ </html>
1616
+ FOOT
1617
+
1618
+ ## save
1619
+ open(out, "w") do |o|
1620
+ o.write html_head
1621
+ [charts].flatten.each { o.write _1 }
1622
+ o.write html_foot
1623
+ end
1624
+ end
1625
+
1626
+ end