pdfkit ページヘッダを付ける

Python pdfkit で、ページ番号などページヘッダを付ける方法のメモ
wkhtmltopdf のオプション説明 Help を読めばいいのですが、
https://wkhtmltopdf.org/usage/wkhtmltopdf.txt

オプションに、header-left で、ヘッダ左端に付与する文字列、
header-right でヘッダ右端に付与する文字列を
header-center でヘッダ中央に付与する文字列を
指定することになっており、

'header-left': '[webpage]',
'header-right': 'Page  [page]  of  [toPage]'

で、
[webpage] → URL参照のパス
[page] → ページ番号
[toPage] → 総ページ数

に置き換わります。

# -*- coding: UTF-8 -*-
import pdfkit

options = {
    'page-size': 'A4',
    'orientation': 'Portrait',
    'margin-top': '0.4in',
    'margin-right': '0.4in',
    'margin-bottom': '0.4in',
    'margin-left': '0.4in',
    'encoding': "UTF-8",
    'no-outline': None,
    'header-left': '[webpage]',
    'header-right': 'Page  [page]  of  [toPage]'
}
pdfkit.from_file("sample.html", "out.pdf", options=options)

このように Python スクリプトを書くことになります。

フッタを付与する場合も、単純に、"header" → "footer" に置き換わった指定をするだけです。
footer-left :フッタ左端
footer-right :フッタ右端
footer-center :フッタ中央

また、ヘッダ部分を別のHTML で用意するカスタムヘッダの方法もあり、
その場合は、--header-html で、ヘッダHTMLを指定します。

options = {
    'page-size': 'A4',
    'orientation': 'Portrait',
    'margin-top': '0.4in',
    'margin-right': '0.4in',
    'margin-bottom': '0.4in',
    'margin-left': '0.4in',
    'encoding': "UTF-8",
    'no-outline': None,
    '--header-html': 'header.html'
}

のように option を指定してます。

Wicket 8.4.0 がリリース

Wicket 8.4.0 がリリースされた。

やっと修正されたバグ
[WICKET-6639] - PageStoreManager$SessionEntry.clear produces NullPointerException

https://issues.apache.org/jira/projects/WICKET/issues/WICKET-6639?filter=allissues

監視プロセスからの PageStoreManager のページエントリキャッシュのクリアのバグで
緊急性は低く、害は無かったものの、ログにこの NullPointerException が出ると
後味が悪かった。。

過去見つけた、これが解決するのだ。
http://oboe2uran.hatenablog.com/entry/2018/10/28/152613

Java→Python Pdfkit実行→PDF受け取る

Wicket で、Python Pdfkit実行してPDFダウンロードさせる方法を考えました。
このメリットは、
・作成するPDFが、PdfkitがHTMLからPDFへの変換であることから
 WicketでブラウザにWeb Page 表示してPDF作成イメージを確認できること。
・HTMLでPDFのテンプレートを構成できることです。

実行する Pytyon スクリプトは標準入力で、URL、ページ種別(A4 等)、縦横の向きを
認識して、標準出力でPDFを出力します。
呼出し元のJava 側では、プロセス起動で Pythonスクリプト実行して、
 標準出力ストリーム → Pytyon スクリプト標準入力
 標準入力ストリーム ← Pytyon スクリプト標準出力
になるようにします。
これは、yipuran-core にある ScriptExecutor.runStream を使用します。
↓↓↓
https://github.com/yipuran/yipuran-core/wiki/Script_exec

URL,ページ種別, 縦横の向き(Portrait or Landscape)を受信してPDFを標準出力するスクリプト
WebPageToPdf.py

# -*- coding: UTF-8 -*-
import sys
import pdfkit

def pageToPdf(url, pagesize='A4', orientation='Portrait'):
    options = {
        'page-size': pagesize,
        'orientation': orientation,
        'margin-top': '0.4in',
        'margin-right': '0.4in',
        'margin-bottom': '0.4in',
        'margin-left': '0.4in',
        'encoding': "UTF-8",
        'no-outline': None
    }
    pdf = pdfkit.from_url("%s" % url, False, options=options)
    sys.stdout.buffer.write(pdf)

inlist = []
try:
    while True:
        inp = input('')
        if inp == '': break
        inlist.append(inp)
except EOFError:
    sys.stdout.buffer.write("ERROR")
    pass
if inlist.__len__()==2:
    pageToPdf(inlist[0], inlist[1])
elif inlist.__len__()==3:
    pageToPdf(inlist[0], inlist[1], inlist[2])
else:
    pageToPdf(inlist[0])


WicketPage AJAX Dowanload Behavior として、記述するコード例

この中の AJAXDownload は、以下を参照
https://github.com/yipuran/yipuran-wicketcustom/wiki

ボタンクリックで実行する SerialThrowableConsumer は、以下を参照
https://github.com/yipuran/yipuran-wicketcustom/blob/master/src/main/java/org/yipuran/wicketcustom/function/SerialThrowableConsumer.javayipuran-wicketcustom/SerialThrowableConsumer.java at master · yipuran/yipuran-wicketcustom · GitHub

final AJAXDownload sampleDownload = AJAXDownload.of(out->{
   // Python スクリプト
   String script = "python " + dirctorypath + "/WebPageToPdf.py";
   // URL セット
   list.add( urlstring );
   list.add("\n");
   list.add("A4");
   list.add("\n");
   list.add("Portrait");
   list.add("\n");
   list.add("\n");
   int sts = ScriptExecutor.runStream(()->script, ()->list, inst->{
      try{
         inst.transferTo(out);
         out.flush();
         out.close();
      }catch(Exception ex){
         throw new RuntimeException(ex);
      }
   }, (e, x)->{
      logger.error(x.getMessage(), x);
   });
}, ()->"application/pdf", ()->"test.pdf");

queue(new Button("pdfdownload").add(AjaxFormSubmitBehavior.onSubmit("click"
, SerialThrowableConsumer.of(t->{
   // callback!!
   sampleDownload.callBackDownload(t);
}, (u, x)->{
   logger.error(x.getMessage(), x);
}))).add(sampleDownload) );

Python スクリプトが、PDF変換前のURL、ページ種別、向きだけを受信するように
書いていますが、動的に変化するPDF作成なら、
Java からのPython スクリプト実行の入力リストにパラメータを追加して、
Python スクリプトで、PDF変換前のURL受信したパラメータをURLパラメータに
追加すれば、動的なWebPage→ 動的に変化するPDF作成
になるはずです。

PDFの改ページを自由に配置するには、
PDF に変換される WebPage のHTML で

<div style="page-break-after:always;"></div>

を差し込みます。
つまり、CSSで、

     page-break-after : always;

を効かせてあげれば良いわけです。
pdfkit が内部で、wkhtmltopdf を実行するからです。
wkhtmltopdf もインストールして置く必要あります。

pdfkit の from_url

pdfkit.from_file( HTMLファイルPATH , 出力PDFファイルPATH , options=options)
pdfkit の from_file だけでなく憶えときたいもの。。

pdfkit.from_url( url , 出力PDFファイルPATH , options=options)

変数に出力 : False を付ける
pdf = pdfkit.from_url( url , False , options=options)

pdf = pdfkit.from_file( HTMLファイルPATH , False , options=options)

変数に出力したものを、import sys で、sys.stdout.buffer.write で書き出せば標準出力

pdfkit Python で作成するPDFのサイズ

データーベースなどから読み込んだデータをPDF帳票出力する時に、表組出力~罫線等で囲んで
連続した出力をする場合、

Jasperreports on Java 詳細で複雑な表でも可能だが、
XMLテンプレート作成がとても面倒で労力が必要
ReportLab on Python テンプレート作成ではなくフラグメント作成→連続出力だが、
機能を習得するのがとてもたいへんで学習コストがかかる

そんな壁があるなら、一度HTMLで、ul-li タグや、table タグで出力して、
HTMLからPDFに変換すれば良い。
なにも学習コストで時間を費やす必要がなく効率的と考える。

PDF出力で必要なレイアウト決定の為に、
A4サイズ、マージン=0.4 inch で、1ページの縦横サイズを、
以下、Python と HTMLで求めた。
pdfkit · PyPI が必要なので、インストールしておき、

 pdfkit.from_file( HTMLファイルPATH , 出力PDFファイルPATH , options=options)

で、HTML→PDF作成である。

A4 向き Portrait HTML→PDF
'orientation': 'Portrait' を指定する。default は、Portrait でよく省略されてる。

import pdfkit

options = {
    'page-size': 'A4',
    'orientation': 'Portrait',
    'margin-top': '0.4in',
    'margin-right': '0.4in',
    'margin-bottom': '0.4in',
    'margin-left': '0.4in',
    'encoding': "UTF-8",
    'no-outline': None
}
pdfkit.from_file("portrait.html", "out_port.pdf", options=options)

A4 向き Portrait サイズ確認用 HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="content-language" content="ja">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>portrait</title>
<style type="text/css">
html{ width:100%; height:100%; }
body{
	margin: 0;
	padding: 0;
}
#content{
    border: 1px solid #000099;
    width: 1432px;
    height: 2074px;
}
</style>
</head>
<body>
<div id="content"></div>
</body>
</html>

A4 横向き Landscape HTML→PDF
 'orientation': 'Landscape' を指定する。

import pdfkit

options = {
    'page-size': 'A4',
    'orientation': 'Landscape',
    'margin-top': '0.4in',
    'margin-right': '0.4in',
    'margin-bottom': '0.4in',
    'margin-left': '0.4in',
    'encoding': "UTF-8",
    'no-outline': None
}
pdfkit.from_file("landscape.html", "out_landscape.pdf", options=options)

A4 横向き Landscape サイズ確認用 HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="content-language" content="ja">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>landscape</title>
<style type="text/css">
html{ width:100%; height:100%; }
body{
    margin: 0;
	padding: 0;
}
#content{
    border: 1px solid #000099;
    width: 2092px;
    height: 1432px;
}
</style>
</head>
<body>
<div id="content">
</div>
</body>
</html>

まとめると、
PDF変換元HTMLは、
A4サイズ PDF(左右上下マージン=0.4inch)
を作成するために、

A4 width height
Portrait(縦向き) 1432px 2074px
Landscape(横向き) 2092px 1432px

で、HTMLの CSSレイアウトを組む必要がある。

Google gson MalformedJsonException のパターン

Google gsonJSON読込みした時に発生する MalformedJsonException
のパターンとは、以下の表にまとめることができる。
エラーのパターンは、これ以外あるかもしれないが、だいたいこんなところ。

例外のメッセージ エラーの意味 JSONエラーの例
Unterminated object value表現エラー "date" : 2019/12/03
Unterminated String value表現エラー
(文字列が期待されてるのに文字列認識できない)
"a": '
Unterminated array value表現エラー
(配列が期待されてるのに、配列認識できない)
"a" : ["A":12]
Expected name キーの表現が解釈できない(想定できない) {} : 2
: 2
Expected ':' key-value 区切り文字が ':' でない 'a';1
"a"\t 2
Expected value value 表現解釈できない(想定できない) "data": /03
Unexpected value 想定外の value "a":

日付を value として表現する クォートもダブルクォートでも括らない

 "date" : 2019-12-03

は、エラーにならないが、

 "date" : 2019/12/03

は、Unterminated object になる。
Unexpected value や、Expected value 等は、沢山ケースがありそうだ。
これら、MalformedJsonException 発生として捕捉できるエラーであるが、
では、先日作成した
oboe2uran.hatenablog.com

この中の エラー捕捉処理として上の表のエラー詳細の種類まで、
認識してラムダで処理できるように、
先日作ったものを改良すべきか悩んでいる。。
(改良することは簡単なのだが、ラムダ式の引数が増えるので、仕様として躊躇している。)

2019-4-6 結局、ラムダ式の引数を追加した。。。

malformedcatch · yipuran/yipuran-gsonhelper Wiki · GitHub

https://github.com/yipuran/yipuran-gsonhelper/blob/master/src/main/java/org/yipuran/gsonhelper/JsonErrorConsumer.java

Chart.js Time グラフの為のデータJSONを作成するクラス

Chart.js https://www.chartjs.org/ X軸=時刻 の線グラフを描画するためのJSONデータ作成する
Java クラスを作成して、AJAX通信によるJSONデータ → Chart.js グラフ描画をデザインを除いた
可変の JSONデータ生成だけでも汎用的にならないかと考えました。

グラフ描画の点となるデータの定義の Javaオブジェクト
整数(int) と 小数点 (double) 各々のケースを考慮して用意します。

import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * LocalDateTime - 整数値 
 */
public class IntPoint implements Serializable{
   /** グラフX軸の時刻. */
   public LocalDateTime time;
   /** グラフY軸の値. */
   public Integer value;
   public IntPoint(){}
   public IntPoint(LocalDateTime time, int value){
      this.time = time;
      this.value = value;
   }
   public void setTime(LocalDateTime time){
      this.time = time;
   }
   public void setValue(int value){
      this.value = value;
   }
   @Override
   public String toString() {
      return "Pointer(" + time + ", " + value + ")";
   }
}
import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * LocalDateTime - 小数点
 */
public class DoublePoint implements Serializable{
   /** グラフX軸の時刻. */
   public LocalDateTime time;
   /** グラフY軸の値. */
   public Double value;
   public DoublePoint(){}
   public DoublePoint(LocalDateTime time, double value){
      this.time = time;
      this.value = value;
   }
   public void setTime(LocalDateTime time) {
      this.time = time;
   }
   public void setValue(Double value) {
      this.value = value;
   }
}

JSON生成するクラス、Google gson を使います。

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.yipuran.gsonhelper.LocalDateTimeAdapter;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
 * Time Chart Json Builder.
 * <PRE>
 *
 * String json = new TimeChartJsonBuilder()
 *              .addIntegers(list)
 *              .addYAxesMin(10)
 *              .addYAxesMax(500)
 *              .addStepSize(20)
 *              .addFontSize(12)
 *              .build();
 *
 * String json = new TimeChartJsonBuilder()
 *              .addDoubles(list)
 *              .addMaxTicksLimit(5)
 *              .build();
 * </PRE>
 */
public final class TimeChartJsonBuilder{
   private Gson gson;
   private Map<String, Object> map;
   private int min = 0;
   private int max = 100;
   private Integer yAxesMax = null;
   private int yAxesStepSize = 10;
   private Double doubleStepsize = null;
   private int fontSize = 12;
   private Integer maxTicksLimit = null;
   private LocalDateTime xAxesMin = null;
   private LocalDateTime xAxesMax = null;
   private List<Map<String, Object>> mlist = new ArrayList<>();

   public TimeChartJsonBuilder(){
      map = new HashMap<>();
      gson = new GsonBuilder().registerTypeHierarchyAdapter(LocalDateTime.class, LocalDateTimeAdapter.of("yyyy/MM/dd HH:mm:ss.SSS")).create();
   }
   public TimeChartJsonBuilder addIntegers(List<IntPoint> list){
      if (list.size() > 0){
         max = list.stream().max((a, b)->a.value.compareTo(b.value)).map(e->e.value.intValue()).orElse(100);
         LocalDateTime tmin = list.stream().map(e->e.time).min((a, b)->a.compareTo(b)).orElse(null);
         if (tmin != null){
            xAxesMin = xAxesMin==null ? tmin : xAxesMin.compareTo(tmin) < 0 ? xAxesMin : tmin;
         }
         LocalDateTime tmax = list.stream().map(e->e.time).max((a, b)->a.compareTo(b)).orElse(null);
         if (tmax != null){
            xAxesMax = xAxesMax==null ? tmax : xAxesMax.compareTo(tmax) < 0 ? tmax : xAxesMax;
         }
         Map<String, Object> map = new HashMap<>();
         map.put("plist", list);
         mlist.add(map);
      }
      return this;
   }
   public TimeChartJsonBuilder addDoubles(List<DoublePoint> list){
      if (list.size() > 0){
         max = list.stream().max((a, b)->a.value.compareTo(b.value)).map(e->e.value.intValue()).orElse(100);
         LocalDateTime tmin = list.stream().map(e->e.time).min((a, b)->a.compareTo(b)).orElse(null);
         if (tmin != null){
            xAxesMin = xAxesMin==null ? tmin : xAxesMin.compareTo(tmin) < 0 ? xAxesMin : tmin;
         }
         LocalDateTime tmax = list.stream().map(e->e.time).max((a, b)->a.compareTo(b)).orElse(null);
         if (tmax != null){
            xAxesMax = xAxesMax==null ? tmax : xAxesMax.compareTo(tmax) < 0 ? tmax : xAxesMax;
         }
         Map<String, Object> map = new HashMap<>();
         map.put("plist", list);
         mlist.add(map);
      }
      return this;
   }

   public TimeChartJsonBuilder addYAxesMin(int min){
      this.min = min;
      return this;
   }
   public TimeChartJsonBuilder addYAxesMax(int yAxesMax){
      this.yAxesMax = yAxesMax;
      return this;
   }

   public TimeChartJsonBuilder addFontSize(int fontSize){
      this.fontSize = fontSize;
      return this;
   }
   public TimeChartJsonBuilder addStepSize(int yAxesStepSize){
      this.yAxesStepSize = yAxesStepSize;
      return this;
   }
   public TimeChartJsonBuilder addStepSize(double yAxesStepSize){
      this.doubleStepsize = yAxesStepSize;
      return this;
   }
   public TimeChartJsonBuilder addMaxTicksLimit(int maxTicksLimit){
      if (maxTicksLimit < 1) throw new IllegalArgumentException("maxTicksLimit must be over 1");
      this.maxTicksLimit = maxTicksLimit;
      return this;
   }
   public TimeChartJsonBuilder addXAxesMin(LocalDateTime xAxesMin){
      this.xAxesMin = xAxesMin;
      return this;
   }
   public TimeChartJsonBuilder addXAxesMax(LocalDateTime xAxesMax){
      this.xAxesMax = xAxesMax;
      return this;
   }

   public String build(){
      map.put("list", mlist);
      map.put("yAxesMin", min);
      map.put("yAxesStepSize", yAxesStepSize <= 0 ? max : yAxesStepSize);
      max = (max / yAxesStepSize + 1) * yAxesStepSize;
      map.put("yAxesMax", yAxesMax==null ? max : yAxesMax);
      if (doubleStepsize != null) map.put("yAxesStepSize", doubleStepsize);
      if (maxTicksLimit != null) map.put("maxTicksLimit", maxTicksLimit);
      map.put("yAxesFontSize", fontSize);
      map.put("xAxesMin", xAxesMin);
      map.put("xAxesMax", xAxesMax);
      return gson.toJson(map);
   }
}

Wicket Pageでの使用

List<IntPoint> list ;
// list に描画するデータを格納
TimeChartJsonBuilder builder = new TimeChartJsonBuilder().addIntegers(list)
.addYAxesMax(200)
.addStepSize(20);
String json = builder.build();
try(StringResourceStream s = new StringResourceStream(json)){
    getRequestCycle().scheduleRequestHandlerAfterCurrent(
         new ResourceStreamRequestHandler(s)
    );
}catch(IOException e){
    logger.warn(e.getMessage(), e);
}

注意しなければならないのは、TimeChartJsonBuilder の addIntegers で渡すリストは
グラフ描画の為の時系列に並んでなければならない。必要であればメソッドの実行前にソート
する必要がある。

HTML Chart.js と moment-with-locale.js を使用できるように ヘッダは記述しておく。
https://momentjs.com/

<div class="chart">
   <canvas id="myChart"></canvas>
</div>

JSソース

var graph = {
   xLabels: [],
   yLabels: [],
   datasets: [{
      label: "Value",
      lineTension: 0,
      backgroundColor: "rgba(185, 64, 71, 0.6)",
      borderColor: "rgba(185, 64, 71, 0.6)",
      borderWidth: 1,
      borderCapStyle: 'round',
      borderDash: [],
      borderDashOffset: 0.0,
      borderJoinStyle: "round",
      pointBorderColor: "rgba(185, 64, 71, 0.6)",
      pointBackgroundColor: "rgba(185, 64, 71, 0.6)",
      pointBorderWidth: 0,
      pointHoverRadius: 4,
      pointHoverBackgroundColor: "rgba(185, 64, 71, 0.6)",
      pointHoverBorderColor: "rgba(185, 64, 71, 0.6)",
      pointHoverBorderWidth: 4,
      pointRadius: 0,
      pointHitRadius: 10,
      fill: false,
      spanGaps: false,
      data: [],
      yAxisID: "y-axis-1",
   }]
};
var options = {
   responsive: true,
   title:{ display:true,
      text:'Sample'
   },
   scales: {
      xAxes: [{ display: true,
         scaleLabel: { display: true, labelString: 'Time' },
         type: "time",
         time: {
            displayFormat: true,
            displayFormats: { minute: "HH:mm" },
            stepSize: 2
         },
      }],
      yAxes: [{ display: true,
         id: "y-axis-1",
         offset: true,
         scaleLabel: { display: true, labelString: 'Value',  fontSize: 22 },
         ticks: {}
      }]
   },
   chartArea: {
      /* グラフ領域の背景色 */
      backgroundColor: 'rgba(240, 248, 255, 1)'
   },
   tooltips: {
      callbacks: {
         title: function(tooltipItem, data){
            return moment(tooltipItem[0].xLabel._d).format('YYYY年M月D日(ddd)A hh:mm:ss');
         },
         label: function(tooltipItem, data){
            return  "value:" + tooltipItem.yLabel;
         }
      }
   },
};

$(function(){
   /* moment.js 設定 */
   moment.locale('ja');
   Chart.pluginService.register({
      beforeDraw: function(c){
         if (c.config.options.chartArea && c.config.options.chartArea.backgroundColor) {
            var ctx = c.chart.ctx;
            var chartArea = c.chartArea;
            ctx.fillStyle = "rgba(255, 255, 255, 1)";           // 外側背景色の指定
            ctx.fillRect(0, 0, c.chart.width, c.chart.height);  // 外側背景色描画
            ctx.save();
            ctx.fillStyle = c.config.options.chartArea.backgroundColor;
            ctx.fillRect(chartArea.left
, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);
            ctx.restore();
         }
      }
   });
   var ctx = document.getElementById("myChart").getContext("2d");
   var myChart = new Chart(ctx, {
       type: 'line',
       data: graph,
       options: options
   });

   var drawGraph = function(e){
      graph.datasets[0].data = [];
      myChart.options.scales.yAxes[0].ticks['min'] = parseInt(e.yAxesMin, 10);
      myChart.options.scales.yAxes[0].ticks['max'] = parseInt(e.yAxesMax, 10);
      myChart.options.scales.yAxes[0].ticks['stepSize'] = parseInt(e.yAxesStepSize, 10);
      myChart.options.scales.yAxes[0].ticks['fontSize'] = parseInt(e.yAxesFontSize, 10);
      if (e.xAxesMin != null){
         myChart.options.scales.xAxes[0].time['min']
          = moment(e.xAxesMin, "YYYY/MM/DD HH:mm:ss.SSS");
      }
      if (e.xAxesMax != null){
         myChart.options.scales.xAxes[0].time['max']
          = moment(e.xAxesMax, "YYYY/MM/DD HH:mm:ss.SSS");
      }
      if (e.maxTicksLimit != undefined){
         myChart.options.scales.yAxes[0].ticks['maxTicksLimit'] = e.maxTicksLimit;
      }
      $.each(e.list, function(i, data){
         $.each(data.plist, function(n, v){
            graph.datasets[i].data.push(
               { t:moment(v.time, "YYYY-MM-DD HH:mm:ss.SSS"), y:v.value }
            );
         });
      });
      myChart.update();
   };

   var init_url = "/chartsample/line"
   $.ajax({
      type: 'POST',
      timeout: 10000,
      url: init_url,
      data: { },
      dataType: 'json',
      cache: false,
   }).done(function(e){
      drawGraph(e);
   }).fail(function(e){
      // 通信エラー
   }).always(function(e){
   });
});

グラフの線、太さ、線の種類、色、背景色、スケールの文字フォントサイズ、
タイトルの文字フォントサイズ、ツールチップの表示コンテンツ、、、
沢山、JSで書かなくてはならないのだが、
1本の線だけではなく複数の線を描画するケースを考慮して、
TimeChartJsonBuilder は、複数の線のJSONデータを生成するために、
List → 複数の線、
Map → 各線のJSON 配列

private List<Map<String, Object>> mlist = new ArrayList<>();

で構成して、AJAX通信で受信したJSプログラム側が、Chart.js グラフオブジェクトに展開する時に、
1本ずつ展開する。
時刻フォーマットは、TimeChartJsonBuilder で決められており、
時刻のデータ moment-with-locale.js で生成する。

      $.each(e.list, function(i, data){
         $.each(data.plist, function(n, v){
            graph.datasets[i].data.push(
               { t:moment(v.time, "YYYY-MM-DD HH:mm:ss.SSS"), y:v.value }
            );
         });
      });

サンプルグラフ描画
f:id:posturan:20190401095816j:plain