package logbook.gui; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.MessageFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.TimeZone; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.stream.Collectors; import java.util.stream.Stream; import javafx.application.Platform; import javafx.embed.swt.FXCanvas; import javafx.scene.Scene; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.util.StringConverter; import logbook.config.AppConfig; import logbook.constants.AppConstants; import logbook.gui.listener.SaveWindowLocationAdapter; import logbook.gui.listener.SelectedListener; import logbook.gui.logic.CreateReportLogic; import logbook.gui.logic.LayoutLogic; import logbook.util.ImageWriter; import org.apache.commons.lang3.time.DateUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.SashForm; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.layout.RowLayout; import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.DateTime; import org.eclipse.swt.widgets.Dialog; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.FileDialog; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Menu; import org.eclipse.swt.widgets.MenuItem; import org.eclipse.swt.widgets.MessageBox; import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Table; import org.eclipse.swt.widgets.TableColumn; import org.eclipse.swt.widgets.TableItem; import org.eclipse.wb.swt.SWTResourceManager; /** * 資材チャートのダイアログ * */ public final class ResourceChartDialogEx extends Dialog { static { Platform.setImplicitExit(false); } private static class LoggerHolder { /** ロガー */ private static final Logger LOG = LogManager.getLogger(ResourceChartDialogEx.class); } /** 資材テーブルに表示する資材のフォーマット */ private static final String COMPARE_FORMAT = "{0,number,0}({1,number,+0;-0})"; private Shell shell; private NumberAxis xaxis; private NumberAxis yaxis; private LineChart<Number, Number> chart; private Combo combo; private DateTime dateTimeFrom; private DateTime dateTimeTo; private Button fuelBtn; private Button ammoBtn; private Button metalBtn; private Button bauxiteBtn; private Button bucketBtn; private Button burnerBtn; private Button researchBtn; private FXCanvas fxCanvas; private Button forceZeroBtn; private Table table; /** 資材テーブルのヘッダ */ private final String[] header = Arrays.copyOfRange(CreateReportLogic.getMaterialHeader(), 1, 9); /** 資材テーブルのボディ */ private final List<String[]> body = new ArrayList<>(); /** * Create the dialog. * @param parent */ public ResourceChartDialogEx(Shell parent) { super(parent, SWT.SHELL_TRIM | SWT.MODELESS); this.setText("資材チャート"); } /** * Open the dialog. */ public void open() { this.createContents(); this.shell.open(); this.shell.layout(); Display display = this.getParent().getDisplay(); while (!this.shell.isDisposed()) { if (!display.readAndDispatch()) { display.sleep(); } } } /** * Create contents of the dialog. */ private void createContents() { this.shell = new Shell(this.getParent(), this.getStyle()); this.shell.setMinimumSize(450, 300); this.shell.setSize(800, 650); this.shell.setText(this.getText()); // ウインドウ位置を復元 LayoutLogic.applyWindowLocation(this.getClass(), this.shell); // 閉じた時にウインドウ位置を保存 this.shell.addShellListener(new SaveWindowLocationAdapter(this.getClass())); this.shell.setLayout(new GridLayout(1, false)); SashForm sashForm = new SashForm(this.shell, SWT.SMOOTH | SWT.VERTICAL); sashForm.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); Composite mainComposite = new Composite(sashForm, SWT.NONE); mainComposite.setLayout(new GridLayout(1, false)); Composite rangeComposite = new Composite(mainComposite, SWT.NONE); rangeComposite.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); rangeComposite.setLayout(new RowLayout(SWT.HORIZONTAL)); Label label1 = new Label(rangeComposite, SWT.NONE); label1.setText("期間"); this.combo = new Combo(rangeComposite, SWT.READ_ONLY); this.combo.setItems( Arrays.stream(ScaleOption.values()) .map(e -> e.toString()) .toArray(String[]::new) ); this.combo.select(2); this.combo.addSelectionListener((SelectedListener) e -> { this.setRange(); this.reload(); }); Label label2 = new Label(rangeComposite, SWT.NONE); label2.setText("開始"); this.dateTimeFrom = new DateTime(rangeComposite, SWT.BORDER | SWT.DROP_DOWN); this.dateTimeFrom.addSelectionListener((SelectedListener) e -> this.reload()); Label label3 = new Label(rangeComposite, SWT.NONE); label3.setText("終了"); this.dateTimeTo = new DateTime(rangeComposite, SWT.BORDER | SWT.DROP_DOWN); this.dateTimeTo.addSelectionListener((SelectedListener) e -> this.reload()); Composite checkComposite = new Composite(mainComposite, SWT.NONE); checkComposite.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); checkComposite.setLayout(new RowLayout(SWT.HORIZONTAL)); this.fuelBtn = new Button(checkComposite, SWT.CHECK); this.fuelBtn.setText("燃料"); this.fuelBtn.setSelection(true); this.fuelBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.ammoBtn = new Button(checkComposite, SWT.CHECK); this.ammoBtn.setText("弾薬"); this.ammoBtn.setSelection(true); this.ammoBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.metalBtn = new Button(checkComposite, SWT.CHECK); this.metalBtn.setText("鋼材"); this.metalBtn.setSelection(true); this.metalBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.bauxiteBtn = new Button(checkComposite, SWT.CHECK); this.bauxiteBtn.setText("ボーキ"); this.bauxiteBtn.setSelection(true); this.bauxiteBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.bucketBtn = new Button(checkComposite, SWT.CHECK); this.bucketBtn.setText("高速修復材"); this.bucketBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.burnerBtn = new Button(checkComposite, SWT.CHECK); this.burnerBtn.setText("高速建造材"); this.burnerBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.researchBtn = new Button(checkComposite, SWT.CHECK); this.researchBtn.setText("開発資材"); this.researchBtn.addSelectionListener((SelectedListener) e -> this.reload()); this.forceZeroBtn = new Button(checkComposite, SWT.CHECK); this.forceZeroBtn.setText("ゼロを基準"); this.forceZeroBtn.addSelectionListener((SelectedListener) e -> { this.yaxis.setForceZeroInRange(this.forceZeroBtn.getSelection()); this.reload(); }); try { // Create an FXCanvas this.fxCanvas = new FXCanvas(mainComposite, SWT.NONE); this.fxCanvas.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); VBox group = new VBox(); this.xaxis = new NumberAxis(); this.yaxis = new NumberAxis(); this.yaxis.setForceZeroInRange(false); this.chart = new LineChart<Number, Number>(this.xaxis, this.yaxis); this.chart.setPrefHeight(800); this.chart.setCreateSymbols(false); // アニメーションを有効にするとなぜかaxisの描画が正しく行われない this.chart.setAnimated(false); this.chart.getStylesheets().add(AppConstants.CHART_STYLESHEET_FILE.toUri().toString()); group.getChildren().add(this.chart); // データを用意する this.setRange(); this.reload(); Scene scene = new Scene(group, Color.rgb( this.shell.getBackground().getRed(), this.shell.getBackground().getGreen(), this.shell.getBackground().getBlue())); this.fxCanvas.setScene(scene); Menu menu = new Menu(this.fxCanvas); this.fxCanvas.setMenu(menu); MenuItem saveimage = new MenuItem(menu, SWT.NONE); saveimage.addSelectionListener((SelectedListener) e -> { try { FileDialog dialog = new FileDialog(this.shell, SWT.SAVE); dialog.setFileName("資材チャート.png"); dialog.setFilterExtensions(new String[] { "*.png" }); String filename = dialog.open(); if (filename != null) { Path path = Paths.get(filename); if (Files.exists(path)) { MessageBox messageBox = new MessageBox(this.shell, SWT.YES | SWT.NO); messageBox.setText("確認"); messageBox.setMessage("指定されたファイルは存在します。\n上書きしますか?"); if (messageBox.open() == SWT.NO) { return; } } new ImageWriter(path) .format(SWT.IMAGE_PNG) .write(this.fxCanvas); } } catch (IOException ex) { LoggerHolder.LOG.warn("資材チャートのイメージを作成中に例外が発生しました", ex); } }); saveimage.setText("画像ファイルとして保存"); } catch (Exception e) { LoggerHolder.LOG.warn("資材チャートを作成中に例外が発生しました", e); } Composite compositeTable = new Composite(sashForm, SWT.NONE); compositeTable.setLayout(new GridLayout(1, false)); this.table = new Table(compositeTable, SWT.BORDER | SWT.FULL_SELECTION); this.table.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); this.table.setHeaderVisible(true); this.table.setLinesVisible(true); sashForm.setWeights(new int[] { 3, 1 }); // 資材ログのテーブル this.setTableHeader(); try { createTableBody(Paths.get(AppConfig.get().getReportPath(), AppConstants.LOG_RESOURCE), this.body); this.setTableBody(); this.packTableHeader(); } catch (Exception e) { LoggerHolder.LOG.warn("資材ログのテーブルを作成中例外が発生しました", e); } } /** * 設定されている期間で資材ログを読み込み資材チャートを描画する */ private void reload() { this.changeRange(); } /** * 期間のコンボボックスを変更した時の処理 */ private void setRange() { int idx = this.combo.getSelectionIndex(); int days = ScaleOption.values()[idx].getDay() - 1; // Toを取得 Calendar base = getCalendar(this.dateTimeTo); // コンボボックスで選択された日数だけ日を減らす base.add(Calendar.DAY_OF_YEAR, -days); // Fromを変更 setCalendar(base, this.dateTimeFrom); } /** * 期間が変更された時の処理 */ private void changeRange() { int idx = this.combo.getSelectionIndex(); ScaleOption option = ScaleOption.values()[idx]; // 開始 Date from = getCalendar(this.dateTimeFrom).getTime(); // 終了 Calendar calTo = getCalendar(this.dateTimeTo); // ログを読み込む日付はその日の23:59.59までなので1日加算する calTo.add(Calendar.DAY_OF_YEAR, 1); Date to = calTo.getTime(); this.xaxis.setAutoRanging(false); this.xaxis.setLowerBound(0); this.xaxis.setUpperBound(to.getTime() - from.getTime()); this.xaxis.setTickUnit(option.getTickUnit()); this.xaxis.setTickLabelFormatter(new DateTimeConverter(from, option.getFormat())); this.loadSeries(from, to); } /** * 資材ログを読み込む * * @param from 開始(自身を含む) * @param to 終了(自身を含まない) */ private void loadSeries(Date from, Date to) { // Seriesに直接addすると1回addするごとにXYChart$Series$1.onChanged()が呼ばれて非常にパフォーマンスが悪いのでArrayListに入れてからaddAllする List<XYChart.Data<Number, Number>> fuelList = new ArrayList<>(); List<XYChart.Data<Number, Number>> ammoList = new ArrayList<>(); List<XYChart.Data<Number, Number>> metalList = new ArrayList<>(); List<XYChart.Data<Number, Number>> bauxiteList = new ArrayList<>(); List<XYChart.Data<Number, Number>> bucketList = new ArrayList<>(); List<XYChart.Data<Number, Number>> burnerList = new ArrayList<>(); List<XYChart.Data<Number, Number>> researchList = new ArrayList<>(); try { try (Stream<String> stream = Files.lines( Paths.get(AppConfig.get().getReportPath(), AppConstants.LOG_RESOURCE), AppConstants.CHARSET)) { stream.skip(1) .map(Log::new) .filter(e -> e.date != null) .filter(e -> e.date.compareTo(from) >= 0) .filter(e -> e.date.compareTo(to) < 0) .forEach( e -> { long time = e.date.getTime() - from.getTime(); fuelList.add(new XYChart.Data<Number, Number>(time, e.fuel)); ammoList.add(new XYChart.Data<Number, Number>(time, e.ammo)); metalList.add(new XYChart.Data<Number, Number>(time, e.metal)); bauxiteList.add(new XYChart.Data<Number, Number>(time, e.bauxite)); bucketList.add(new XYChart.Data<Number, Number>(time, e.bucket)); burnerList.add(new XYChart.Data<Number, Number>(time, e.burner)); researchList.add(new XYChart.Data<Number, Number>(time, e.research)); }); } } catch (Exception e) { LoggerHolder.LOG.warn("資材チャートの読み込み中に例外が発生しました", e); } XYChart.Series<Number, Number> fuel = new XYChart.Series<>(); XYChart.Series<Number, Number> ammo = new XYChart.Series<>(); XYChart.Series<Number, Number> metal = new XYChart.Series<>(); XYChart.Series<Number, Number> bauxite = new XYChart.Series<>(); XYChart.Series<Number, Number> bucket = new XYChart.Series<>(); XYChart.Series<Number, Number> burner = new XYChart.Series<>(); XYChart.Series<Number, Number> research = new XYChart.Series<>(); fuel.setName("燃料"); ammo.setName("弾薬"); metal.setName("鋼材"); bauxite.setName("ボーキ"); bucket.setName("高速修復材"); burner.setName("高速建造材"); research.setName("開発資材"); if (this.fuelBtn.getSelection()) fuel.getData().addAll(fuelList); if (this.ammoBtn.getSelection()) ammo.getData().addAll(ammoList); if (this.metalBtn.getSelection()) metal.getData().addAll(metalList); if (this.bauxiteBtn.getSelection()) bauxite.getData().addAll(bauxiteList); if (this.bucketBtn.getSelection()) bucket.getData().addAll(bucketList); if (this.burnerBtn.getSelection()) burner.getData().addAll(burnerList); if (this.researchBtn.getSelection()) research.getData().addAll(researchList); this.chart.getData().clear(); List<XYChart.Series<Number, Number>> list = new ArrayList<>(); list.add(fuel); list.add(ammo); list.add(metal); list.add(bauxite); list.add(bucket); list.add(burner); list.add(research); this.chart.getData().addAll(list); } /** * テーブルヘッダーをセットする */ private void setTableHeader() { for (int i = 0; i < this.header.length; i++) { TableColumn col = new TableColumn(this.table, SWT.LEFT); col.setText(this.header[i]); } this.packTableHeader(); } /** * テーブルヘッダーの幅を調節する */ private void packTableHeader() { TableColumn[] columns = this.table.getColumns(); for (int i = 0; i < columns.length; i++) { columns[i].pack(); } } /** * テーブルボディーをセットする */ private void setTableBody() { for (int i = 0; i < this.body.size(); i++) { String[] line = this.body.get(i); TableItem item = new TableItem(this.table, SWT.NONE); item.setText(line); // 偶数行に背景色を付ける if ((i % 2) != 0) { item.setBackground(SWTResourceManager.getColor(AppConstants.ROW_BACKGROUND)); } else { item.setBackground(null); } } } /** * 資材テーブルのボディを作成する * * @param log 資材ログ * @param body テーブルボディ * @throws IOException */ private static void createTableBody(Path path, List<String[]> body) throws IOException { SimpleDateFormat format = new SimpleDateFormat(AppConstants.DATE_DAYS_FORMAT); format.setTimeZone(AppConstants.TIME_ZONE_MISSION); try (Stream<String> stream = Files.lines(path, AppConstants.CHARSET)) { BiConsumer<Map<String, Log>, Log> accumulator = (t, u) -> { String key = format.format(u.date); Log get = t.get(key); if ((get == null) || (get.date.compareTo(u.date) < 0)) t.put(key, u); }; BiConsumer<Map<String, Log>, Map<String, Log>> combiner = (t, u) -> { for (Entry<String, Log> entry : u.entrySet()) { String key = format.format(entry.getValue().date); Log get = t.get(key); if ((get == null) || (get.date.compareTo(entry.getValue().date) < 0)) t.put(key, entry.getValue()); } }; List<Entry<String, Log>> list = stream.skip(1) .map(Log::new) .filter(e -> e.date != null) .collect(HashMap<String, Log>::new, accumulator, combiner) .entrySet() .stream() .sorted((a, b) -> a.getKey().compareTo(b.getKey())) .collect(Collectors.toList()); MessageFormat compare = new MessageFormat(COMPARE_FORMAT); Log before = null; for (Entry<String, Log> entry : list) { Log val = entry.getValue(); int[] material = { val.fuel, val.ammo, val.metal, val.bauxite, val.bucket, val.burner, val.research }; int[] materialCompare = new int[material.length]; if (before != null) { int[] materialBefore = { before.fuel, before.ammo, before.metal, before.bauxite, before.bucket, before.burner, before.research }; for (int i = 0; i < material.length; i++) { materialCompare[i] = material[i] - materialBefore[i]; } } before = val; String[] line = new String[material.length + 1]; line[0] = entry.getKey(); for (int i = 0; i < material.length; i++) { line[i + 1] = compare.format(new Object[] { material[i], materialCompare[i] }); } body.add(line); } } Collections.reverse(body); } /** * DateTimeで選択されている日付からCalendarインスタンスを作成します * * @param dateTime * @return */ private static Calendar getCalendar(DateTime dateTime) { Calendar cal = DateUtils.truncate(Calendar.getInstance(TimeZone.getDefault()), Calendar.DAY_OF_MONTH); cal.set(Calendar.YEAR, dateTime.getYear()); cal.set(Calendar.MONTH, dateTime.getMonth()); cal.set(Calendar.DAY_OF_MONTH, dateTime.getDay()); return cal; } /** * DateTimeにCalendarの年月日をセットします * * @param cal * @param dateTime */ private static void setCalendar(Calendar cal, DateTime dateTime) { dateTime.setDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)); } /** * 資材ログ * */ private static class Log { /** 日付 */ private Date date; /** 燃料 */ private int fuel; /** 弾薬 */ private int ammo; /** 鋼材 */ private int metal; /** ボーキ */ private int bauxite; /** 高速修復材 */ private int bucket; /** 高速建造材 */ private int burner; /** 開発資材 */ private int research; public Log(String line) { try { String[] cols = line.split(",", -1); SimpleDateFormat format = new SimpleDateFormat(AppConstants.DATE_FORMAT); this.date = format.parse(cols[0]); this.fuel = Integer.parseInt(cols[1]); this.ammo = Integer.parseInt(cols[2]); this.metal = Integer.parseInt(cols[3]); this.bauxite = Integer.parseInt(cols[4]); this.bucket = Integer.parseInt(cols[5]); this.burner = Integer.parseInt(cols[6]); this.research = Integer.parseInt(cols[7]); } catch (ParseException e) { LoggerHolder.LOG.warn("資材ログを読み込み中に例外が発生しました:日付の形式が間違っています", e); LoggerHolder.LOG.warn(line); } catch (NumberFormatException e) { LoggerHolder.LOG.warn("資材ログを読み込み中に例外が発生しました:数値型に変換出来ません", e); LoggerHolder.LOG.warn(line); } catch (IndexOutOfBoundsException e) { LoggerHolder.LOG.warn("資材ログを読み込み中に例外が発生しました:項目数が一致しません", e); LoggerHolder.LOG.warn(line); } } } /** * チャートの時間軸ラベルに表示するテキスト * */ private static class DateTimeConverter extends StringConverter<Number> { /** チャートに設定する最小の時刻 */ private final long from; /** フォーマッター */ private final SimpleDateFormat format; /** * @param from チャートに設定する最小の時刻 */ public DateTimeConverter(Date from, String format) { this.from = from.getTime(); this.format = new SimpleDateFormat(format); } @Override public Number fromString(String str) { throw new UnsupportedOperationException(); } @Override public String toString(Number n) { return this.format.format(new Date(n.longValue() + this.from)); } } /** * スケールの選択肢 * */ private static enum ScaleOption { /** 1日 */ ONE_DAY("1日", "HH:mm", 1, TimeUnit.HOURS.toMillis(2)), /** 1週間 */ ONE_WEEK("1週間", "M月d日", 7, TimeUnit.DAYS.toMillis(1)), /** 2週間 */ TWO_WEEK("2週間", "M月d日", 14, TimeUnit.DAYS.toMillis(1)), /** 1ヶ月 */ ONE_MONTH("1ヶ月", "M月d日", 30, TimeUnit.DAYS.toMillis(2)), /** 2ヶ月 */ TWO_MONTH("2ヶ月", "M月d日", 60, TimeUnit.DAYS.toMillis(5)), /** 3ヶ月 */ THREE_MONTH("3ヶ月", "M月d日", 90, TimeUnit.DAYS.toMillis(10)), /** 半年 */ HALF_YEAR("半年", "M月d日", 180, TimeUnit.DAYS.toMillis(15)), /** 1年 */ ONE_YEAR("1年", "M月d日", 365, TimeUnit.DAYS.toMillis(30)); private String name; private String format; private int day; private long tickUnit; private ScaleOption(String name, String format, int day, long tickUnit) { this.name = name; this.format = format; this.day = day; this.tickUnit = tickUnit; } public String getFormat() { return this.format; } public int getDay() { return this.day; } public long getTickUnit() { return this.tickUnit; } @Override public String toString() { return this.name; } } }