From b0384ebb3a92540a6df850e4422e9579088efbd2 Mon Sep 17 00:00:00 2001 From: Greg Guyotte Date: Thu, 22 Oct 2026 16:24:26 -0400 Subject: [PATCH] Enhance Data Browser waveform inspection with ChartFX history --- app/databrowser/pom.xml | 15 + .../trends/databrowser3/Messages.java | 10 + .../ui/waveformview/WaveformHistoryChart.java | 622 ++++++++++++++++++ .../ui/waveformview/WaveformView.java | 593 ++++++++--------- .../trends/databrowser3/messages.properties | 11 +- .../databrowser3/messages_fr.properties | 10 + .../WaveformHistoryChartTest.java | 32 + 7 files changed, 971 insertions(+), 322 deletions(-) create mode 100644 app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/waveformview/WaveformHistoryChart.java create mode 100644 app/databrowser/src/test/java/org/csstudio/trends/databrowser3/ui/waveformview/WaveformHistoryChartTest.java diff --git a/app/databrowser/pom.xml b/app/databrowser/pom.xml index 39513f75e2..fbf2b2e32d 100644 --- a/app/databrowser/pom.xml +++ b/app/databrowser/pom.xml @@ -8,6 +8,21 @@ app-databrowser ${project.groupId}:${project.artifactId} + + io.fair-acc + chartfx + 11.3.1 + + + org.openjfx + javafx-base + + + org.openjfx + javafx-controls + + + org.junit.jupiter junit-jupiter diff --git a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/Messages.java b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/Messages.java index 69a2b45b61..51cd8f70c6 100644 --- a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/Messages.java +++ b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/Messages.java @@ -14,6 +14,14 @@ */ public class Messages { + public static String WaveformHistoryStaggerTT; + public static String WaveformHistoryCursorTT; + public static String WaveformHistorySample; + public static String WaveformHistoryLimitSummary; + public static String WaveformHistoryLimitTT; + public static String WaveformHistoryNonWaveformSummary; + public static String WaveformHistoryNonWaveformTT; + // --- // --- Keep alphabetically sorted and 'in sync' with messages.properties! // --- @@ -324,6 +332,8 @@ public class Messages ValueAxes, ValueAxisName, ValueColumn, + WaveformHistoryPrevious, + WaveformHistoryPreviousTT, WaveformIndex, WaveformIndexCol, WaveformIndexColTT, diff --git a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/waveformview/WaveformHistoryChart.java b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/waveformview/WaveformHistoryChart.java new file mode 100644 index 0000000000..9cd4cc3e87 --- /dev/null +++ b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/waveformview/WaveformHistoryChart.java @@ -0,0 +1,622 @@ +package org.csstudio.trends.databrowser3.ui.waveformview; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.csstudio.javafx.rtplot.Activator; +import org.csstudio.trends.databrowser3.Messages; + +import io.fair_acc.chartfx.XYChart; +import io.fair_acc.chartfx.axes.spi.DefaultNumericAxis; +import io.fair_acc.chartfx.plugins.ChartPlugin; +import io.fair_acc.chartfx.plugins.DataPointTooltip; +import io.fair_acc.chartfx.plugins.EditAxis; +import io.fair_acc.chartfx.plugins.Zoomer; +import io.fair_acc.chartfx.renderer.spi.HistoryDataSetRenderer; +import io.fair_acc.chartfx.ui.geometry.Side; +import io.fair_acc.dataset.DataSet; +import io.fair_acc.dataset.spi.DoubleDataSet; + +import javafx.application.Platform; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Insets; +import javafx.geometry.Bounds; +import javafx.geometry.Point2D; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToolBar; +import javafx.scene.control.Tooltip; +import javafx.scene.effect.ColorAdjust; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.scene.input.MouseEvent; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Line; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; + +/** + * Displays the current waveform and fading history for one or more PVs. + * Each PV has an independent Y-axis; the toolbar can stagger their ranges. + */ +public class WaveformHistoryChart extends BorderPane { + + /** + * A named PV and its waveforms ordered oldest to newest. + */ + public record WaveformSeries(String name, List waveforms) { + } + + private static final double HISTORY_FADING = 0.9; + private static final double ICON_SCALE = 0.8; + private static final double STAGGER_GAP = 0.1; + private static final Font AXIS_TITLE_FONT = Font.font("System", FontWeight.BOLD, 12); + + private final DefaultNumericAxis xAxis; + private final DefaultNumericAxis mainYAxis; + private final XYChart chart; + private final ToolBar toolbar; + + private final List waveforms = new ArrayList<>(); + private final List renderers = new ArrayList<>(); + private final List extraYAxes = new ArrayList<>(); + private final List valueRanges = new ArrayList<>(); + private final WaveformCursor waveformCursor = new WaveformCursor(); + + private int historyLength; + private boolean axisColorUpdatePending; + + public WaveformHistoryChart(int historyLength) { + xAxis = new DefaultNumericAxis("Sample"); + mainYAxis = new DefaultNumericAxis("Value"); + xAxis.setAutoRanging(true); + mainYAxis.setAutoRanging(true); + xAxis.setUnit(null); + mainYAxis.setUnit(null); + + chart = new XYChart(xAxis, mainYAxis); + chart.setAnimated(false); + chart.setLegendVisible(false); + + chart.getPlugins().add(new Zoomer()); + chart.getPlugins().add(new EditAxis()); + chart.getPlugins().add(new DataPointTooltip()); + chart.getPlugins().add(waveformCursor); + + // ChartFX dataset line colors are resolved by JavaFX CSS after the + // chart is attached to a Scene. + chart.sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene != null) { + scheduleAxisColorUpdate(); + } + }); + + setHistoryLength(historyLength); + toolbar = createToolbar(); + setTop(toolbar); + setCenter(chart); + } + + private ToolBar createToolbar() { + chart.toolBarSideProperty().bind(new SimpleObjectProperty<>(Side.TOP)); + chart.getMenuPane().setTop(null); + + var sourceToolbar = chart.getToolBar(); + List controls = new ArrayList<>(sourceToolbar.getChildren()); + sourceToolbar.getChildren().clear(); + + HBox buttonBox = new HBox(4); + for (Node control : controls) { + if (control instanceof HBox innerBox) { + buttonBox.getChildren().addAll(new ArrayList<>(innerBox.getChildren())); + innerBox.getChildren().clear(); + } else { + buttonBox.getChildren().add(control); + } + } + + while (!buttonBox.getChildren().isEmpty() + && buttonBox.getChildren().get(0) instanceof Separator) { + buttonBox.getChildren().remove(0); + } + + // Same icon as the original RTPlot Stagger button. + Button stagger = new Button(); + stagger.setTooltip(new Tooltip(Messages.WaveformHistoryStaggerTT)); + stagger.setGraphic(new ImageView(Activator.getIcon("stagger"))); + stagger.setOnAction(event -> staggerWaveforms()); + // Insert ahead of ChartFX's existing zoom/pan controls. + buttonBox.getChildren().add(0, stagger); + + // Kay-style crosshair: a toggle that reads the current sample of + // every selected waveform at the same X position. + ToggleButton crosshair = new ToggleButton(); + crosshair.setTooltip(new Tooltip(Messages.WaveformHistoryCursorTT)); + crosshair.setGraphic(new ImageView(Activator.getIcon("crosshair"))); + crosshair.setOnAction(event -> waveformCursor.setActive(crosshair.isSelected())); + buttonBox.getChildren().add(1, crosshair); + + for (Node control : buttonBox.getChildren()) { + // Preserve the original Stagger and Crosshair icon contrast. + compactControl(control, (control == crosshair || control == stagger) ? 0.0 : 0.45); + sizeButton(control, 34, 26); + } + + ToolBar result = new ToolBar(buttonBox); + result.setPadding(new Insets(2, 4, 2, 4)); + result.setMinHeight(Region.USE_PREF_SIZE); + result.setMaxHeight(Region.USE_PREF_SIZE); + result.setStyle("-fx-font-size: 10px;" + + "-fx-background-color: transparent;" + + "-fx-border-width: 0;"); + return result; + } + + /** + * Place Inspect Waveforms controls on the same toolbar as the chart tools. + * Call on the JavaFX application thread. + */ + public void addToolbarControls(Node... controls) { + toolbar.getItems().addAll(controls); + } + + private static void compactControl(Node node, double brightness) { + if (node instanceof ButtonBase button) { + Node graphic = button.getGraphic(); + if (graphic != null) { + button.setGraphic(null); + graphic.setScaleX(graphic.getScaleX() * ICON_SCALE); + graphic.setScaleY(graphic.getScaleY() * ICON_SCALE); + ColorAdjust adjust = new ColorAdjust(); + adjust.setBrightness(brightness); + graphic.setEffect(adjust); + button.setGraphic(new Group(graphic)); + } + } else if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + compactControl(child, brightness); + } + } + } + + /** + * Change the number of previous waveforms retained (on FX thread). + */ + public void setHistoryLength(int historyLength) { + final int length = Math.max(0, historyLength); + if (this.historyLength == length) { + return; + } + this.historyLength = length; + resetHistoryRenderers(); + chart.invalidate(); + } + + /** + * Recreate renderers without changing the current Y-axis arrangement. + */ + private void resetHistoryRenderers() { + // Old cursor markers/readouts refer to the previous datasets. + waveformCursor.hideCursor(); + renderers.clear(); + for (int pv = 0; pv < waveforms.size(); pv++) { + HistoryDataSetRenderer renderer = new HistoryDataSetRenderer(historyLength); + renderer.setIntensityFading(HISTORY_FADING); + renderer.setGlobalIndexOffset(pv); + renderer.getDatasets().add(waveforms.get(pv)); + renderer.getAxes().addAll(xAxis, axisForPv(pv)); + renderers.add(renderer); + } + chart.getRenderers().setAll(renderers); + scheduleAxisColorUpdate(); + } + + /** + * Schedule one CSS color refresh per JavaFX pulse, even if renderers + * have been recreated repeatedly before that pulse executes. + */ + private void scheduleAxisColorUpdate() { + if (axisColorUpdatePending) { + return; + } + axisColorUpdatePending = true; + Platform.runLater(() -> { + axisColorUpdatePending = false; + updateAxisColorsFromRenderers(); + }); + } + + /** + * Match each Y-axis title to the CSS-resolved line color of its PV. + * Using DataSetNode avoids maintaining a separate color palette. + */ + private void updateAxisColorsFromRenderers() { + if (chart.getScene() == null) { + return; + } + + chart.applyCss(); + for (int pv = 0; pv < renderers.size(); pv++) { + HistoryDataSetRenderer renderer = renderers.get(pv); + if (renderer.getDatasetNodes().isEmpty()) { + continue; + } + var lineColor = renderer.getDatasetNodes().get(0).getLineColor(); + if (lineColor != null) { + axisForPv(pv).getAxisLabel().setFill(lineColor); + } + } + } + + private DefaultNumericAxis axisForPv(int pv) { + // Every PV has its own Y-axis, even before Stagger is pressed. + return pv == 0 ? mainYAxis : extraYAxes.get(pv - 1); + } + + /** + * Replace all displayed PVs and their histories (on FX thread). + */ + public void setWaveformSeries(List series) { + if (!Platform.isFxApplicationThread()) { + throw new IllegalStateException( + "setWaveformSeries must run on the JavaFX application thread"); + } + + // Changing the PV selection resets any manually staggered ranges. + // Navigating samples within the same selection preserves them. + if (!hasSamePvSelection(series)) { + clearStaggering(); + } + + // Detach old renderers before dropping any axes no longer needed. + chart.getRenderers().clear(); + waveforms.clear(); + ensureYAxes(series.size()); + valueRanges.clear(); + for (WaveformSeries item : series) { + waveforms.add(new DoubleDataSet(item.name(), 100)); + valueRanges.add(rangeOf(item.waveforms())); + } + // Label each Y-axis with the corresponding PV name. + for (int pv = 0; pv < series.size(); pv++) { + DefaultNumericAxis axis = axisForPv(pv); + axis.setName(series.get(pv).name()); + axis.setUnit(null); + axis.getAxisLabel().setFont(AXIS_TITLE_FONT); + } + resetHistoryRenderers(); + + // Each renderer owns one independent history buffer, so each PV can + // be loaded separately without inserting blank history frames. + for (int pv = 0; pv < series.size(); pv++) { + List values = series.get(pv).waveforms(); + DoubleDataSet dataset = waveforms.get(pv); + HistoryDataSetRenderer renderer = renderers.get(pv); + int from = Math.max(0, values.size() - historyLength - 1); + for (int idx = from; idx < values.size(); idx++) { + if (idx > from) { + renderer.shiftHistory(); + } + replaceWaveform(dataset, values.get(idx)); + } + } + chart.invalidate(); + } + + private boolean hasSamePvSelection(List series) { + if (series.size() != waveforms.size()) { + return false; + } + for (int pv = 0; pv < series.size(); pv++) { + if (!waveforms.get(pv).getName().equals(series.get(pv).name())) { + return false; + } + } + return true; + } + + /** + * One-shot Stagger action based on RTPlot/PlotProcessor.stagger(). + * Each PV's values occupy one of N nonoverlapping vertical bands with + * 10% internal padding. Raw sample values are never changed. + */ + public void staggerWaveforms() { + if (!Platform.isFxApplicationThread()) { + throw new IllegalStateException("staggerWaveforms must run on the FX thread"); + } + int count = waveforms.size(); + if (count < 2) { + return; + } + + for (int pv = 0; pv < count; pv++) { + DefaultNumericAxis axis = axisForPv(pv); + double low = valueRanges.get(pv)[0]; + double high = valueRanges.get(pv)[1]; + if (!Double.isFinite(low) || !Double.isFinite(high) || low > high) { + continue; + } + if (low == high) { + double half = Math.abs(low / 2.0); + if (half == 0) half = 1.0; + low -= half; + high += half; + } + double span = high - low; + low -= STAGGER_GAP * span; + high += STAGGER_GAP * span; + span = high - low; + + // Match Kay's rule: first PV on top, last PV on bottom. + low -= (count - pv - 1) * span; + high += pv * span; + + axis.setAutoRanging(false); + axis.setMin(low); + axis.setMax(high); + + HistoryDataSetRenderer renderer = renderers.get(pv); + renderer.getAxes().setAll(xAxis, axis); + + // Refresh the renderer's cached xAxis and yAxis references. + renderer.updateAxes(); + } + chart.invalidate(); + } + + private void ensureYAxes(int count) { + int required = Math.max(0, count - 1); + + // Create the axes at PV-selection time, not on the first Stagger click. + while (extraYAxes.size() < required) { + DefaultNumericAxis axis = new DefaultNumericAxis("Value"); + axis.setSide(Side.LEFT); + axis.setUnit(null); + axis.setAutoRanging(true); + extraYAxes.add(axis); + chart.getAxes().add(axis); + } + + // Retire axes when fewer PVs are selected. + while (extraYAxes.size() > required) { + DefaultNumericAxis axis = extraYAxes.remove(extraYAxes.size() - 1); + chart.getAxes().remove(axis); + } + } + + /** + * Clear the manual stagger ranges, but keep one Y-axis per PV. + * Unused axes are removed by ensureYAxes() when the PV count changes. + */ + private void clearStaggering() { + mainYAxis.setAutoRanging(true); + for (DefaultNumericAxis axis : extraYAxes) { + axis.setAutoRanging(true); + } + } + + static double[] rangeOf(List history) { + double low = Double.POSITIVE_INFINITY; + double high = Double.NEGATIVE_INFINITY; + for (double[] values : history) { + if (values == null) { + continue; + } + for (double value : values) { + if (!Double.isFinite(value)) { + continue; + } + low = Math.min(low, value); + high = Math.max(high, value); + } + } + return new double[]{low, high}; + } + + private static void replaceWaveform(DoubleDataSet dataset, double[] values) { + dataset.clearData(); + for (int sample = 0; sample < values.length; sample++) { + dataset.add(sample, values[sample]); + } + } + + /** + * A ChartFX plugin that displays a common sample cursor across the + * independently scaled Y-axes. Only the latest waveform for each PV + * contributes to the readout; history traces remain visually unchanged. + */ + private final class WaveformCursor extends ChartPlugin { + private final Line vertical = new Line(); + private final Line horizontal = new Line(); + private final Group markers = new Group(); + private final VBox readout = new VBox(2); + private final Label sampleLabel = new Label(); + private final List