diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index ad5b8559..a41213d9 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -119,9 +119,9 @@ public void start(Stage stage) throws IOException { centerStack.getChildren().add(mediaView); mediaView.fitWidthProperty().bind(centerStack.widthProperty().subtract(10)); mediaPlayer.play(); - mediaView.setOnMouseClicked(e-> { + mediaView.setOnMouseClicked(e -> { mediaPlayer.setVolume(0.0); // Ensure final volume is zero - mediaPlayer.currentTimeProperty().removeListener(fadeListener); + mediaPlayer.currentTimeProperty().removeListener(fadeListener); mediaPlayer.stop(); centerStack.getChildren().remove(mediaView); }); @@ -155,6 +155,8 @@ public void start(Stage stage) throws IOException { scene.getStylesheets().add(CSS); CSS = StyleResourceProvider.getResource("covalent.css").toExternalForm(); scene.getStylesheets().add(CSS); + CSS = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); + scene.getStylesheets().add(CSS); //add just the dark necessities... JukeBox jukeBox = new JukeBox(scene); @@ -336,6 +338,23 @@ private void keyReleased(Stage stage, KeyEvent e) { if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.Q)) { shutdown(false); } + //J cuz i'm running out of letters + if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.J)) { + stage.getScene().getRoot().fireEvent(new ApplicationEvent(e.isShiftDown() + ? ApplicationEvent.POPOUT_STATISTICS_PANE : ApplicationEvent.SHOW_STATISTICS_PANE)); + } + if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.F)) { + stage.getScene().getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.SHOW_FEATUREVECTOR_MANAGER)); + } + if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.Y)) { + stage.getScene().getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.SHOW_PAIRWISEJPDF_PANE)); + } + if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.U)) { + stage.getScene().getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.SHOW_PAIRWISEMATRIX_PANE)); + } if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.S)) { stage.getScene().getRoot().fireEvent( new ApplicationEvent(ApplicationEvent.SHOW_SPECIALEFFECTS_PANE)); diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 672dfeb4..fac6e7ad 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -12,13 +12,18 @@ import edu.jhuapl.trinity.javafx.components.MatrixOverlay; import edu.jhuapl.trinity.javafx.components.panes.AnalysisLogPane; import edu.jhuapl.trinity.javafx.components.panes.CocoViewerPane; +import edu.jhuapl.trinity.javafx.components.panes.FeatureVectorManagerPane; import edu.jhuapl.trinity.javafx.components.panes.HyperdrivePane; +import edu.jhuapl.trinity.javafx.components.panes.HypersurfaceControlsPane; import edu.jhuapl.trinity.javafx.components.panes.ImageInspectorPane; import edu.jhuapl.trinity.javafx.components.panes.JukeBoxPane; import edu.jhuapl.trinity.javafx.components.panes.NavigatorPane; +import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; +import edu.jhuapl.trinity.javafx.components.panes.PairwiseMatrixPane; import edu.jhuapl.trinity.javafx.components.panes.PixelSelectionPane; import edu.jhuapl.trinity.javafx.components.panes.Shape3DControlPane; import edu.jhuapl.trinity.javafx.components.panes.SpecialEffectsPane; +import edu.jhuapl.trinity.javafx.components.panes.StatPdfCdfPane; import edu.jhuapl.trinity.javafx.components.panes.TextPane; import edu.jhuapl.trinity.javafx.components.panes.TrajectoryTrackerPane; import edu.jhuapl.trinity.javafx.components.panes.VideoPane; @@ -29,6 +34,9 @@ import edu.jhuapl.trinity.javafx.components.timeline.MissionTimerX; import edu.jhuapl.trinity.javafx.components.timeline.MissionTimerXBuilder; import edu.jhuapl.trinity.javafx.components.timeline.TimelineAnimation; +import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; +import edu.jhuapl.trinity.javafx.controllers.PairwiseJpdfPanePopoutController; +import edu.jhuapl.trinity.javafx.controllers.StatPdfCdfPopoutController; import edu.jhuapl.trinity.javafx.events.ApplicationEvent; import edu.jhuapl.trinity.javafx.events.AudioEvent; import edu.jhuapl.trinity.javafx.events.EffectEvent; @@ -58,6 +66,10 @@ import edu.jhuapl.trinity.javafx.javafx3d.Projections3DPane; import edu.jhuapl.trinity.javafx.javafx3d.ProjectorPane; import edu.jhuapl.trinity.javafx.javafx3d.RetroWavePane; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerServiceImpl; +import edu.jhuapl.trinity.javafx.services.FeatureVectorUtils; +import edu.jhuapl.trinity.javafx.services.InMemoryFeatureVectorRepository; import edu.jhuapl.trinity.messages.CommandTask; import edu.jhuapl.trinity.messages.MessageProcessor; import edu.jhuapl.trinity.messages.ZeroMQFeedManager; @@ -93,11 +105,10 @@ import static edu.jhuapl.trinity.App.theConfig; - /** * @author Sean Phillips */ -public class AppAsyncManager extends Task { +public class AppAsyncManager extends Task { private static final Logger LOG = LoggerFactory.getLogger(AppAsyncManager.class); Scene scene; Pane desktopPane; @@ -111,6 +122,7 @@ public class AppAsyncManager extends Task { Hyperspace3DPane hyperspace3DPane; Hypersurface3DPane hypersurface3DPane; + HypersurfaceControlsPane hypersurfaceControlsPane; ProjectorPane projectorPane; Projections3DPane projections3DPane; TrajectoryTrackerPane trajectoryTrackerPane; @@ -118,6 +130,11 @@ public class AppAsyncManager extends Task { JukeBoxPane jukeBoxPane; VideoPane videoPane; SpecialEffectsPane specialEffectsPane; + StatPdfCdfPane statPdfCdfPane; + PairwiseJpdfPane pairwiseJpdfPane; + PairwiseJpdfPanePopoutController pjpPop; + PairwiseMatrixPane pairwiseMatrixPane; + FeatureVectorManagerPane featureVectorManagerPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; WaveformPane waveformPane; @@ -144,6 +161,9 @@ public class AppAsyncManager extends Task { MissionTimerX missionTimerX; TimelineAnimation timelineAnimation; + FeatureVectorManagerPopoutController fvPop; + FeatureVectorManagerService fvService; + StatPdfCdfPopoutController statPop; public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, CircleProgressIndicator progress, Map namedParameters) { this.scene = scene; @@ -151,24 +171,22 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir this.progress = progress; this.desktopPane = desktopPane; this.centerStack = centerStack; - setOnSucceeded(e -> { - Platform.runLater(() -> { - scene.getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)); - }); - }); - setOnFailed(e -> { - Platform.runLater(() -> { - scene.getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)); - }); - }); - setOnCancelled(e -> { - Platform.runLater(() -> { - scene.getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)); - }); - }); + // Shared FeatureVector Manager service (mirrors vectors/collections into the Manager view) + fvService = new FeatureVectorManagerServiceImpl(new InMemoryFeatureVectorRepository()); + // Allow service to fire APPLY_ACTIVE_FEATUREVECTORS back to the app via scene root + if (fvService instanceof FeatureVectorManagerServiceImpl impl) { + impl.setEventTarget(scene.getRoot()); + } + fvPop = new FeatureVectorManagerPopoutController(fvService, scene); + pjpPop = new PairwiseJpdfPanePopoutController(scene); + statPop = new StatPdfCdfPopoutController(scene); + + setOnSucceeded(e -> Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); + setOnFailed(e -> Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); + setOnCancelled(e -> Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); } @Override @@ -198,7 +216,7 @@ protected Void call() throws Exception { progress.setPercentComplete(current++ / total); progress.setLabelLater("...Parsing Command Line..."); parseParameters(); - //ex: --scenario="C:\dev\cameratests" --geometry=1024x768+100+100 + //ex: --scenario="C:\\dev\\cameratests" --geometry=1024x768+100+100 boolean jukeBox = false; if (null != namedParameters) { LOG.info("Checking for geometry arguments..."); @@ -230,9 +248,7 @@ protected Void call() throws Exception { jukeBox = namedParameters.containsKey("jukebox"); if (jukeBox) { LOG.info("jukebox found... enabling music."); - Platform.runLater(() -> { - scene.getRoot().fireEvent(new AudioEvent(AudioEvent.ENABLE_MUSIC_TRACKS, true)); - }); + Platform.runLater(() -> scene.getRoot().fireEvent(new AudioEvent(AudioEvent.ENABLE_MUSIC_TRACKS, true))); } boolean surveillanceEnabled = namedParameters.containsKey("surveillance"); if (surveillanceEnabled) { @@ -530,12 +546,145 @@ protected Void call() throws Exception { } hyperspace3DPane.setVisible(false); hypersurface3DPane.setVisible(false); - Platform.runLater(() -> { - projections3DPane.setVisible(true); - }); + Platform.runLater(() -> projections3DPane.setVisible(true)); + } + }); + LOG.info("Statistics Views"); + scene.addEventHandler(ApplicationEvent.SHOW_STATISTICS_PANE, e -> { + if (null == statPdfCdfPane) { + statPdfCdfPane = new StatPdfCdfPane(scene, desktopPane); + } + if (!desktopPane.getChildren().contains(statPdfCdfPane)) { + desktopPane.getChildren().add(statPdfCdfPane); + statPdfCdfPane.slideInPane(); + } else { + statPdfCdfPane.show(); + } + if (null != e.object) { + Platform.runLater(() -> statPdfCdfPane.setFeatureVectors((List) e.object)); + } + }); + scene.addEventHandler(ApplicationEvent.POPOUT_STATISTICS_PANE, e -> { + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) { + if (null != statPdfCdfPane) { + statPop.setInitialState(statPdfCdfPane.getChartPanel().exportState()); + statPdfCdfPane.close(); + } + statPop.show(); + } else { + if (statPdfCdfPane != null) + statPop.getCurrentState().ifPresent( + statPdfCdfPane.getChartPanel()::applyState); + statPop.close(); } }); + LOG.info("FeatureVector Manager and Services"); + // Mirror NEW_FEATURE_COLLECTION into the manager (ignore when Manager itself applied it) + scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, ev -> { + // Guard: skip if this came from the Manager's Apply-to-Workspace action + if (FeatureVectorManagerService.MANAGER_APPLY_TAG.equals(ev.object2)) { + // Let FeatureVectorEventHandler handle rendering, but do NOT re-ingest into Manager + return; + } + if (ev.object instanceof FeatureCollection fc) { + String collName = FeatureVectorUtils.deriveCollectionName(ev.object2, fc.getFeatures()); + fvService.addCollection(collName, fc.getFeatures()); + } + }); + // Mirror single vector adds into the active collection + scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_FEATURE_VECTOR, ev -> { + if (ev.object instanceof FeatureVector fv) { + fvService.appendVectorsToActive(java.util.List.of(fv)); + } + }); + // Optional: keep manager’s active view consistent with global clears + scene.getRoot().addEventHandler(FeatureVectorEvent.CLEAR_ALL_FEATUREVECTORS, ev -> { + fvService.replaceActiveVectors(java.util.List.of()); + }); + scene.addEventHandler(ApplicationEvent.SHOW_FEATUREVECTOR_MANAGER, e -> { + if (null == featureVectorManagerPane) { + // Use the shared service so the view reflects mirrored events + featureVectorManagerPane = new FeatureVectorManagerPane(scene, desktopPane, fvService); + } + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) + if (!desktopPane.getChildren().contains(featureVectorManagerPane)) { + desktopPane.getChildren().add(featureVectorManagerPane); + featureVectorManagerPane.slideInPane(); + } else { + featureVectorManagerPane.show(); + } + else + featureVectorManagerPane.close(); + }); + scene.addEventHandler(ApplicationEvent.POPOUT_FEATUREVECTOR_MANAGER, e -> { + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) { + featureVectorManagerPane.close(); + fvPop.show(); + } else + fvPop.close(); + }); + scene.addEventHandler(ApplicationEvent.SHOW_PAIRWISEJPDF_PANE, e -> { + if (null == pairwiseJpdfPane) { + // Use the shared service so the view reflects mirrored events + pairwiseJpdfPane = new PairwiseJpdfPane(scene, desktopPane); + } + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) + if (!desktopPane.getChildren().contains(pairwiseJpdfPane)) { + desktopPane.getChildren().add(pairwiseJpdfPane); + pairwiseJpdfPane.slideInPane(); + } else { + pairwiseJpdfPane.show(); + } + else + pairwiseJpdfPane.close(); + }); + scene.addEventHandler(ApplicationEvent.POPOUT_PAIRWISEJPDF_JPDF, e -> { + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) { + pairwiseJpdfPane.close(); + pjpPop.show(); + } else + pjpPop.close(); + }); + scene.addEventHandler(ApplicationEvent.SHOW_PAIRWISEMATRIX_PANE, e -> { + if (null == pairwiseMatrixPane) { + // Use the shared service so the view reflects mirrored events + pairwiseMatrixPane = new PairwiseMatrixPane(scene, desktopPane); + } + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) + if (!desktopPane.getChildren().contains(pairwiseMatrixPane)) { + desktopPane.getChildren().add(pairwiseMatrixPane); + pairwiseMatrixPane.slideInPane(); + } else { + pairwiseMatrixPane.show(); + } + else + pairwiseMatrixPane.close(); + }); + + scene.addEventHandler(ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, e -> { + if (null == hypersurfaceControlsPane) { + // Use the shared service so the view reflects mirrored events + hypersurfaceControlsPane = new HypersurfaceControlsPane(scene, desktopPane, hypersurface3DPane); + } + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) + if (!desktopPane.getChildren().contains(hypersurfaceControlsPane)) { + desktopPane.getChildren().add(hypersurfaceControlsPane); + hypersurfaceControlsPane.slideInPane(); + } else { + hypersurfaceControlsPane.show(); + } + else + hypersurfaceControlsPane.close(); + }); + scene.addEventHandler(ApplicationEvent.AUTO_PROJECTION_MODE, e -> { boolean enabled = (boolean) e.object; if (enabled) { @@ -564,8 +713,6 @@ protected Void call() throws Exception { if (null != projections3DPane) { projections3DPane.setVisible(false); } - //@TODO SMP fadeOutConsole(500); - //scene.addEventHandler(CommandTerminalEvent.FADE_OUT, e -> fadeOutConsole(e.timeMS)); if (!hypersurfaceIntroShown) { hypersurface3DPane.showFA3D(); @@ -582,7 +729,6 @@ protected Void call() throws Exception { if (null != retroWavePane) { retroWavePane.animateShow(); } - //@TODO SMP fadeOutConsole(500); } else { hypersurface3DPane.setVisible(false); if (null != projections3DPane) { @@ -596,7 +742,6 @@ protected Void call() throws Exception { hyperspace3DPane.intro(1000); hyperspaceIntroShown = true; } - //@TODO SMP fadeInConsole(500); } }); scene.addEventHandler(ApplicationEvent.SHOW_SHAPE3D_CONTROLS, e -> { @@ -685,7 +830,7 @@ protected Void call() throws Exception { scene.getRoot().addEventHandler(ZeroMQEvent.ZEROMQ_ESTABLISH_CONNECTION, e -> { subscriberConfig = e.subscriberConfig; if (null != subscriberConfig) { - Task task = new Task() { + Task task = new Task<>() { @Override protected Void call() throws Exception { feed.setConfig(subscriberConfig); @@ -765,8 +910,6 @@ else if (null != projections3DPane.latestUmap) computeMetrics = (boolean) event.object2; ManifoldEvent.POINT_SOURCE source = ManifoldEvent.POINT_SOURCE.HYPERSPACE; -// if (null != event.object2) -// source = (ManifoldEvent.POINT_SOURCE) event.object2; FeatureCollection originalFC = getFeaturesBySource(source); projections3DPane.projectFeatureCollection(originalFC, params, computeMetrics); }); @@ -863,6 +1006,7 @@ else if (null != projections3DPane.latestUmap) scene.getRoot().addEventHandler(FeatureVectorEvent.RESCAN_FEATURE_LAYERS, fveh); scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_LABEL_CONFIG, fveh); scene.getRoot().addEventHandler(FeatureVectorEvent.CLEAR_ALL_FEATUREVECTORS, fveh); + scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_CYBER_REPORT, fveh); fveh.addFeatureVectorRenderer(hyperspace3DPane); progress.setLabelLater("...ManifoldEventHandler..."); @@ -1022,11 +1166,8 @@ else if (null != projections3DPane.latestUmap) progress.setLabelLater("User Interface Is Lit"); progress.setPercentComplete(1.0); - Platform.runLater(() -> { - scene.getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)); - }); - //System.out.println("Finished async load."); + Platform.runLater(() -> scene.getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR))); LOG.info("Finished async load."); return null; } diff --git a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java new file mode 100644 index 00000000..394addb5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java @@ -0,0 +1,70 @@ +package edu.jhuapl.trinity.data.files; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.jhuapl.trinity.data.messages.xai.CyberReport; +import edu.jhuapl.trinity.data.messages.xai.CyberReportIO; + +import java.awt.datatransfer.DataFlavor; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; + +/** + * @author Sean Phillips + */ +public class CyberReporterFile extends DroppableFile { + public List cyberReports = null; + + public CyberReporterFile(String pathname) { + super(pathname); + } + + public CyberReporterFile(String pathname, Boolean parseExisting) throws IOException { + super(pathname); + if (parseExisting) + parseContent(); + } + + /** + * Tests whether a given File is this type of file or not + * + * @param file The File to be tested + * @return True if the file being tested is this type of file + * @throws java.io.IOException + */ + public static boolean isFileType(File file) throws IOException { + String extension = file.getName().substring(file.getName().lastIndexOf(".")); + if (extension.equalsIgnoreCase(".json")) { + String body = Files.readString(file.toPath()); + return CyberReport.isCyberReport(body); + } + return false; + } + + @Override + public DataFlavor getDataFlavor() { + return new DataFlavor(CyberReporterFile.class, "CYBERREPORT"); + } + + @Override + public void parseContent() throws IOException { + /** Provides deserialization support for JSON messages */ + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + String message = Files.readString(this.toPath()); + // From a string: + cyberReports = CyberReportIO.readReports(message); + } + + @Override + public void writeContent() throws IOException { + if (null != cyberReports) { + ObjectMapper mapper = new ObjectMapper(); + //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.writeValue(this, cyberReports); + LOG.info("CyberReports serialized to file."); + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java b/src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java new file mode 100644 index 00000000..0ae350b9 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java @@ -0,0 +1,83 @@ +package edu.jhuapl.trinity.data.files; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.datatransfer.UnsupportedFlavorException; +import java.io.File; +import java.io.IOException; + +/** + * + * @author Sean Phillips + */ +public abstract class DroppableFile extends File implements Transferable { + public static final Logger LOG = LoggerFactory.getLogger(DroppableFile.class); + + /** + * Constructor that extends File super constructor + * + * @param pathname Full path string to the file + */ + public DroppableFile(String pathname) { + super(pathname); + } + + /** + * Constructor that allows for automatically parsing the content of the file + * + * @param pathname Full path string to the file + * @param parseExisting True to automatically parse the content of the file + * specified by pathname + * @throws java.io.IOException + */ + public DroppableFile(String pathname, Boolean parseExisting) throws IOException { + super(pathname); + if (parseExisting) + parseContent(); + } + + /** + * Parses the file specified via this object's constructor + * Assumes the file exists and is readable and conforms to expected format. + * + * @throws java.io.IOException + */ + public void parse() throws IOException { + parseContent(); + } + + public abstract DataFlavor getDataFlavor(); + + public abstract void parseContent() throws IOException; + + /** + * Writes out the content of this File at the location + * specified via this object's constructor. + * Assumes the file exists. + * + * @throws java.io.IOException + */ + public abstract void writeContent() throws IOException; + + @Override + public DataFlavor[] getTransferDataFlavors() { + return new DataFlavor[]{getDataFlavor()}; + } + + @Override + public boolean isDataFlavorSupported(DataFlavor df) { + return df == getDataFlavor(); + } + + @Override + public Object getTransferData(DataFlavor df) throws UnsupportedFlavorException, IOException { + if (df == getDataFlavor()) { + return this; + } else { + throw new UnsupportedFlavorException(df); + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java new file mode 100644 index 00000000..60e192e0 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java @@ -0,0 +1,200 @@ +package edu.jhuapl.trinity.data.messages.xai; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CyberReport { + public static final String PERCENTAGE = "Percentage of Original Network"; + public static final String GROUNDTRUTH = "Ground Truth"; + public static final String INFERENCES = "Inferences"; + public static final String ADJACENTNETWORK = "Adjacent Network"; + public static final String MOD = "Mod"; + public static final String SGTA = "S(GT, A)"; + public static final String SINTELGT = "S(intel, GT)"; + public static final String SINTELA = "S(intel, A)"; + public static final String SINFGT = "S(inf, GT)"; + public static final String DELTA = "delta"; + + // ---- small type for "Mod": [[10,"pod"], ...] ---- + @JsonFormat(shape = JsonFormat.Shape.ARRAY) // maps [10,"pod"] -> new ModEntry(10,"pod") + public static record ModEntry(int value, String label) { + } + + // ----- simple metadata ----- + + @JsonProperty(PERCENTAGE) + private Double percentage; + + @JsonProperty(GROUNDTRUTH) + private String groundTruth; + + @JsonProperty(ADJACENTNETWORK) + private String adjacentNetwork; + + @JsonProperty(INFERENCES) + private List inferences = new ArrayList<>(); + + @JsonProperty(MOD) + private List mod = new ArrayList<>(); + + // ----- score vectors (known names) ----- + @JsonProperty(SGTA) + private CyberVector sGtA; + + @JsonProperty(SINTELGT) + private CyberVector sIntelGt; + + @JsonProperty(SINTELA) + private CyberVector sIntelA; + + @JsonProperty(SINFGT) + private CyberVector sInfGt; + + // delta vector + @JsonProperty(DELTA) + private CyberVector delta; + + // ----- catch-all for any other S(... ) vectors ----- + @JsonIgnore // avoid double-serializing; we expose via @JsonAnyGetter below + private final Map extraVectors = new LinkedHashMap<>(); + + public CyberReport() { + } + + public static boolean isCyberReport(String body) { + return body != null + && body.contains(GROUNDTRUTH) + && body.contains(ADJACENTNETWORK) + && body.contains(INFERENCES); + } + + /** + * Any unrecognized property will land here. Jackson will deserialize the value + * into a CyberVector because of the method signature. + */ + @JsonAnySetter + public void putUnknown(String name, CyberVector vector) { + // Only stash S(...) style keys that aren’t already bound to explicit fields + if (name != null && name.startsWith("S(")) { + extraVectors.put(name, vector); + } + } + + /** + * When serializing back to JSON, include the extra S(...) vectors naturally. + */ + @JsonAnyGetter + public Map getExtraVectors() { + return extraVectors; + } + + // ----- convenience: expose all vectors (known + extra) as a collection ----- + @JsonIgnore + public List getAllVectors() { + List all = new ArrayList<>(); + if (sGtA != null) all.add(sGtA); + if (sIntelGt != null) all.add(sIntelGt); + if (sIntelA != null) all.add(sIntelA); + if (sInfGt != null) all.add(sInfGt); + all.addAll(extraVectors.values()); + return all; + } + + // ----- getters/setters ----- + + /** + * @return the percentage + */ + public Double getPercentage() { + return percentage; + } + + /** + * @param percentage the percentage to set + */ + public void setPercentage(Double percentage) { + this.percentage = percentage; + } + + public String getGroundTruth() { + return groundTruth; + } + + public void setGroundTruth(String groundTruth) { + this.groundTruth = groundTruth; + } + + public String getAdjacentNetwork() { + return adjacentNetwork; + } + + public void setAdjacentNetwork(String adjacentNetwork) { + this.adjacentNetwork = adjacentNetwork; + } + + public List getInferences() { + return inferences; + } + + public void setInferences(List inferences) { + this.inferences = inferences; + } + + public List getMod() { + return mod; + } + + public void setMod(List mod) { + this.mod = mod; + } + + public CyberVector getsGtA() { + return sGtA; + } + + public void setsGtA(CyberVector sGtA) { + this.sGtA = sGtA; + } + + public CyberVector getsIntelGt() { + return sIntelGt; + } + + public void setsIntelGt(CyberVector sIntelGt) { + this.sIntelGt = sIntelGt; + } + + public CyberVector getsIntelA() { + return sIntelA; + } + + public void setsIntelA(CyberVector sIntelA) { + this.sIntelA = sIntelA; + } + + public CyberVector getsInfGt() { + return sInfGt; + } + + public void setsInfGt(CyberVector sInfGt) { + this.sInfGt = sInfGt; + } + + public CyberVector getDelta() { + return delta; + } + + public void setDelta(CyberVector delta) { + this.delta = delta; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java new file mode 100644 index 00000000..710a461a --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java @@ -0,0 +1,106 @@ +package edu.jhuapl.trinity.data.messages.xai; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public final class CyberReportIO { + private CyberReportIO() { + } + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // --- Public convenience overloads --- + + /** + * Read from a File and return a flattened list of reports. + */ + public static List readReports(File file) throws IOException { + JsonNode root = MAPPER.readTree(file); + return toReports(root); + } + + /** + * Read from a Path and return a flattened list of reports. + */ + public static List readReports(Path path) throws IOException { + JsonNode root = MAPPER.readTree(path.toFile()); + return toReports(root); + } + + /** + * Read from a Reader and return a flattened list of reports. + */ + public static List readReports(Reader in) throws IOException { + JsonNode root = MAPPER.readTree(in); + return toReports(root); + } + + /** + * Read from a JSON string and return a flattened list of reports. + */ + public static List readReports(String json) throws IOException { + JsonNode root = MAPPER.readTree(json); + return toReports(root); + } + + // --- Core shape detection + flattening --- + private static List toReports(JsonNode root) throws IOException { + if (root == null || root.isNull()) return List.of(); + + // 1) Single object -> [CyberReport] + if (root.isObject()) { + CyberReport one = MAPPER.treeToValue(root, CyberReport.class); + return List.of(one); + } + + // 2) Array -> could be [object, ...] OR [[object,...], [object,...], ...] + if (root.isArray()) { + List out = new ArrayList<>(); + + // Fast path: top-level array of objects + if (allObjects(root)) { + out.addAll(MAPPER.convertValue(root, new TypeReference>() { + })); + return out; + } + + // Matrix path: top-level array of arrays + for (JsonNode group : root) { + if (group == null || group.isNull()) continue; + + if (group.isObject()) { + // Mixed arrays are tolerated: append objects directly + out.add(MAPPER.treeToValue(group, CyberReport.class)); + } else if (group.isArray()) { + // Nested array: must be objects + for (JsonNode item : group) { + if (!item.isObject()) { + throw new IOException("Unsupported JSON shape: nested array contains non-object element."); + } + out.add(MAPPER.treeToValue(item, CyberReport.class)); + } + } else { + throw new IOException("Unsupported JSON shape: array contains non-object, non-array element."); + } + } + return out; + } + + throw new IOException("Unsupported JSON shape: expected object, array, or array-of-arrays."); + } + + private static boolean allObjects(JsonNode arr) { + for (JsonNode n : arr) { + if (!n.isObject()) return false; + } + return true; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java new file mode 100644 index 00000000..ea131224 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java @@ -0,0 +1,267 @@ +package edu.jhuapl.trinity.data.messages.xai; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import edu.jhuapl.trinity.data.messages.MessageData; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * @author Sean Phillips + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class CyberVector extends MessageData { + + public static final String TYPESTRING = "cybervector"; + // + /* + "S(GT, A)": { + "Image Count": 0.7391304347826086, + "Image Distribution": 0.9869558477855777, + "Pod Count": 0.20440251572327045, + "Service Intersection": 1.0, + "Namespace Intersection” : 0.5, + "Namespaces Per Pod Distribution” : 0.5, + "Namespace Count” : 0.5, + "Role Count” : 0.5, + "Role Name Intersection” : 0.5, + "Resource Count” : 0.5, + "Resource Name Intersection” : 0.5, + "Role Permissions Intersection” : 0.5, + "Role Resource Distribution” : 0.5, + "Role Permissions Distribution” : 0.5, + "Namespace Per User Distribution” : 0.5, + }, + */ + // + + // + @JsonProperty("Image Count") + private double imageCount; + + @JsonProperty("Image Distribution") + private double imageDistribution; + + @JsonProperty("Pod Count") + private double podCount; + + @JsonProperty("Service Intersection") + private double serviceIntersection; + + @JsonProperty("Namespace Intersection") + private double namespaceIntersection; + + @JsonProperty("Namespaces Per Pod Distribution") + private double namespacesPerPodDistribution; + + @JsonProperty("Namespace Count") + private double namespaceCount; + + @JsonProperty("Role Count") + private double roleCount; + + @JsonProperty("Role Name Intersection") + private double roleNameIntersection; + + @JsonProperty("Resource Count") + private double resourceCount; + + @JsonProperty("Resource Name Intersection") + private double resourceNameIntersection; + + @JsonProperty("Role Permissions Intersection") + private double rolePermissionsIntersection; + + @JsonProperty("Role Resource Distribution") + private double roleResourceDistribution; + + @JsonProperty("Role Permissions Distribution") + private double rolePermissionsDistribution; + + @JsonProperty("Namespace Per User Distribution") + private double namespacePerUserDistribution; + + // + + public CyberVector() { + this.messageType = TYPESTRING; +// entityId = UUID.randomUUID().toString(); +// metaData.put("uuid", entityId); + } + + public static Function> mapToVectorList = (state) -> { +// "Image Count": 0.7391304347826086, +// "Image Distribution": 0.9869558477855777, +// "Pod Count": 0.20440251572327045, +// "Service Intersection": 1.0, +// "Namespace Intersection" : 0.5, +// "Namespaces Per Pod Distribution" : 0.5, +// "Namespace Count" : 0.5, +// "Role Count" : 0.5, +// "Role Name Intersection" : 0.5, +// "Resource Count" : 0.5, +// "Resource Name Intersection" : 0.5, +// "Role Permissions Intersection" : 0.5, +// "Role Resource Distribution" : 0.5, +// "Role Permissions Distribution" : 0.5, +// "Namespace Per User Distribution" : 0.5 + + List vector = new ArrayList<>(); + vector.add(state.getImageCount()); + vector.add(state.getImageDistribution()); + vector.add(state.getPodCount()); + vector.add(state.getServiceIntersection()); + vector.add(state.getNamespaceIntersection()); + vector.add(state.getNamespacesPerPodDistribution()); + vector.add(state.getNamespaceCount()); + vector.add(state.getRoleCount()); + vector.add(state.getRoleNameIntersection()); + vector.add(state.getResourceCount()); + vector.add(state.getResourceNameIntersection()); + vector.add(state.getRolePermissionsIntersection()); + vector.add(state.getRoleResourceDistribution()); + vector.add(state.getRolePermissionsDistribution()); + vector.add(state.getNamespacePerUserDistribution()); + + return vector; + }; + + public String asJSON(String objectName) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // Enable pretty printing + mapper.enable(SerializationFeature.INDENT_OUTPUT); + // Convert object to pretty-printed JSON string + return "\"" + objectName + "\": " + mapper.writeValueAsString(this); + } + // + + public double getImageCount() { + return imageCount; + } + + public void setImageCount(double imageCount) { + this.imageCount = imageCount; + } + + public double getImageDistribution() { + return imageDistribution; + } + + public void setImageDistribution(double imageDistribution) { + this.imageDistribution = imageDistribution; + } + + public double getPodCount() { + return podCount; + } + + public void setPodCount(double podCount) { + this.podCount = podCount; + } + + public double getServiceIntersection() { + return serviceIntersection; + } + + public void setServiceIntersection(double serviceIntersection) { + this.serviceIntersection = serviceIntersection; + } + + public double getNamespaceIntersection() { + return namespaceIntersection; + } + + public void setNamespaceIntersection(double namespaceIntersection) { + this.namespaceIntersection = namespaceIntersection; + } + + public double getNamespacesPerPodDistribution() { + return namespacesPerPodDistribution; + } + + public void setNamespacesPerPodDistribution(double namespacesPerPodDistribution) { + this.namespacesPerPodDistribution = namespacesPerPodDistribution; + } + + public double getNamespaceCount() { + return namespaceCount; + } + + public void setNamespaceCount(double namespaceCount) { + this.namespaceCount = namespaceCount; + } + + public double getRoleCount() { + return roleCount; + } + + public void setRoleCount(double roleCount) { + this.roleCount = roleCount; + } + + public double getRoleNameIntersection() { + return roleNameIntersection; + } + + public void setRoleNameIntersection(double roleNameIntersection) { + this.roleNameIntersection = roleNameIntersection; + } + + public double getResourceCount() { + return resourceCount; + } + + public void setResourceCount(double resourceCount) { + this.resourceCount = resourceCount; + } + + public double getResourceNameIntersection() { + return resourceNameIntersection; + } + + public void setResourceNameIntersection(double resourceNameIntersection) { + this.resourceNameIntersection = resourceNameIntersection; + } + + public double getRolePermissionsIntersection() { + return rolePermissionsIntersection; + } + + public void setRolePermissionsIntersection(double rolePermissionsIntersection) { + this.rolePermissionsIntersection = rolePermissionsIntersection; + } + + public double getRoleResourceDistribution() { + return roleResourceDistribution; + } + + public void setRoleResourceDistribution(double roleResourceDistribution) { + this.roleResourceDistribution = roleResourceDistribution; + } + + public double getRolePermissionsDistribution() { + return rolePermissionsDistribution; + } + + public void setRolePermissionsDistribution(double rolePermissionsDistribution) { + this.rolePermissionsDistribution = rolePermissionsDistribution; + } + + public double getNamespacePerUserDistribution() { + return namespacePerUserDistribution; + } + + public void setNamespacePerUserDistribution(double namespacePerUserDistribution) { + this.namespacePerUserDistribution = namespacePerUserDistribution; + } + // + +} diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureCollection.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureCollection.java index e9c3a026..0920e4dc 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureCollection.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureCollection.java @@ -48,13 +48,13 @@ public static boolean isFeatureCollection(String messageBody) { public static FeatureCollection merge(List collections) { FeatureCollection fcf = new FeatureCollection(); List featureVectors = new ArrayList<>(); - for(FeatureCollection fc : collections) { + for (FeatureCollection fc : collections) { featureVectors.addAll(fc.getFeatures()); } fcf.setFeatures(featureVectors); return fcf; } - + public float[][] convertFeaturesToFloatArray() { int vectorCount = features.size(); int vectorWidth = features.get(0).getData().size(); @@ -190,5 +190,5 @@ public ArrayList getDimensionLabels() { public void setDimensionLabels(ArrayList dimensionLabels) { this.dimensionLabels = dimensionLabels; } - // -} \ No newline at end of file + // +} diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureVector.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureVector.java index be980833..49a33b7f 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureVector.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/FeatureVector.java @@ -81,6 +81,26 @@ public FeatureVector() { metaData.put("uuid", entityId); } + /** + * Convenience constructor to initialize with data. + * The list reference is copied defensively. + */ + public FeatureVector(List data) { + this(); // keep UUID/meta init + if (data != null) { + this.data = new ArrayList<>(data); + } + } + + /** + * Convenience constructor to initialize with data and label. + * The list reference is copied defensively. + */ + public FeatureVector(List data, String label) { + this(data); + this.label = label; + } + public boolean isBBoxValid() { return null != getBbox() && !getBbox().isEmpty() && getBbox().size() > 3 && getBbox().get(2) > 0.0 && getBbox().get(3) > 0.0; @@ -378,7 +398,6 @@ public HashMap getMetaData() { public void setMetaData(HashMap metaData) { this.metaData = metaData; } - // /** * @return the text @@ -407,4 +426,5 @@ public String getMediaURL() { public void setMediaURL(String mediaURL) { this.mediaURL = mediaURL; } + // } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/AnalysisVectorBox.java b/src/main/java/edu/jhuapl/trinity/javafx/components/AnalysisVectorBox.java index bf731cc4..94b7c052 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/AnalysisVectorBox.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/AnalysisVectorBox.java @@ -18,7 +18,7 @@ public class AnalysisVectorBox extends VBox { public XYChart.Series series; private LineChart lineChart; Label vectorLabel; - + public AnalysisVectorBox(double width, double height) { setPrefSize(width, height); analysisVector = FXCollections.observableArrayList(); @@ -35,7 +35,7 @@ public AnalysisVectorBox(double width, double height) { } public void setAnalysisVector(String label, Double[] newData) { - if(null != label) { + if (null != label) { vectorLabel.setText(label); } analysisVector.clear(); @@ -49,4 +49,4 @@ public void setAnalysisVector(String label, Double[] newData) { analysisVector.add(data); } } -} \ No newline at end of file +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java b/src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java new file mode 100644 index 00000000..dd5e8a52 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java @@ -0,0 +1,560 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.utils.ResourceUtils; +import javafx.animation.FadeTransition; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.ParallelTransition; +import javafx.animation.PauseTransition; +import javafx.animation.SequentialTransition; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.embed.swing.SwingFXUtils; +import javafx.event.EventHandler; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.SnapshotParameters; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.ToggleGroup; +import javafx.scene.image.ImageView; +import javafx.scene.image.WritableImage; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderStroke; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.scene.transform.Transform; +import javafx.stage.FileChooser; +import javafx.stage.Popup; +import javafx.stage.Window; +import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.Objects; + +/** + * Hover-reveal micro toolbar for export actions (Copy, Save, More). + * Intended for placement in a title bar (e.g., mainTitleView). + *

+ * Sticky behavior: clicking the Export icon toggles a "sticky hold". + * - Sticky ON: toolbar remains expanded even when the mouse leaves (export icon shows pinned indicator). + * - Sticky OFF: toolbar collapses on mouse exit (with a short delay). + */ +public class ExportMicroToolbar { + + private static final Logger LOG = LoggerFactory.getLogger(ExportMicroToolbar.class); + + private final Pane titleBarParent; // mainTitleView (Pane or AnchorPane) + private final Node chromeNode; + private final Node contentNode; + private final Node contextTarget; + private final javafx.scene.Scene scene; + private final double iconFitWidth; + + private HBox microToolbar; + private Label mtExport; + private Label mtCopy; + private Label mtSave; + private Label mtOptions; + + private boolean expanded = false; + private boolean stickyHold = false; + + private PauseTransition hideDelay; + private ContextMenu optionsMenu; + + // Guard and geometry caches for robust animations + private ParallelTransition currentAnim; + private double collapsedWidth = -1; + private double expandedWidth = -1; + + // Options state + private boolean includeChrome = false; + private boolean transparentPng = true; + private int exportScale = 2; // 1, 2, or 3 + + // Styling radii + private final CornerRadii pillRadii = new CornerRadii(6); + private final CornerRadii shellRadii = new CornerRadii(10); + + // Shared visuals (no inline CSS) + private Background baseBg; + private Background pinnedBg; + private Border hoverBorder; + private Border pinnedBorder; + + public ExportMicroToolbar( + Pane titleBarParent, + Node chromeNode, + Node contentNode, + Node contextTarget, + javafx.scene.Scene scene, + double iconFitWidth + ) { + this.titleBarParent = Objects.requireNonNull(titleBarParent); + this.chromeNode = Objects.requireNonNull(chromeNode); + this.contentNode = Objects.requireNonNull(contentNode); + this.contextTarget = Objects.requireNonNull(contextTarget); + this.scene = Objects.requireNonNull(scene); + this.iconFitWidth = (iconFitWidth > 0) ? iconFitWidth : 32.0; + } + + /** + * Attach to the right side of the title bar (default right inset = 12, vertical center). + */ + public void installInTitleBarRight() { + installInTitleBarRight(12.0, 0.0); + } + + /** + * Attach to the right side of the title bar with custom right inset and vertical offset. + * For non-AnchorPane parents, the toolbar is kept right-aligned and vertically centered; + * verticalOffset shifts it up/down from the center (positive moves it down). + */ + public void installInTitleBarRight(double rightInset, double verticalOffset) { + buildUI(); + titleBarParent.getChildren().add(microToolbar); + + if (titleBarParent instanceof AnchorPane) { + AnchorPane.setRightAnchor(microToolbar, rightInset); + titleBarParent.heightProperty().addListener((obs, ov, nv) -> + AnchorPane.setTopAnchor(microToolbar, + Math.max(0.0, (nv.doubleValue() - microToolbar.getHeight()) / 2.0 + verticalOffset))); + microToolbar.applyCss(); + microToolbar.layout(); + AnchorPane.setTopAnchor(microToolbar, + Math.max(0.0, (titleBarParent.getHeight() - microToolbar.getHeight()) / 2.0 + verticalOffset)); + } else { + Runnable relayout = () -> { + microToolbar.applyCss(); + microToolbar.layout(); + double nx = Math.max(0.0, titleBarParent.getWidth() - microToolbar.getWidth() - rightInset); + double ny = Math.max(0.0, (titleBarParent.getHeight() - microToolbar.getHeight()) / 2.0 + verticalOffset); + microToolbar.setLayoutX(nx); + microToolbar.setLayoutY(ny); + }; + titleBarParent.widthProperty().addListener((o, ov, nv) -> relayout.run()); + titleBarParent.heightProperty().addListener((o, ov, nv) -> relayout.run()); + microToolbar.layoutBoundsProperty().addListener((o, ov, nv) -> relayout.run()); + relayout.run(); + } + + // Right-click anywhere on the contextTarget opens the options menu + contextTarget.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> { + if (e.isSecondaryButtonDown()) { + ensureMenu(); + optionsMenu.show(contextTarget, e.getScreenX(), e.getScreenY()); + e.consume(); + } + }); + + // Compute widths and start fully collapsed & unpinned + Platform.runLater(() -> { + computeWidths(); + forceCollapsedVisuals(); + stickyHold = false; + updateStickyVisuals(); + }); + } + + // ---------- UI ---------- + + private void buildUI() { + // Shared visuals + baseBg = new Background(new BackgroundFill( + Color.CYAN.deriveColor(1, 1, 1, 0.10), pillRadii, Insets.EMPTY)); + pinnedBg = new Background(new BackgroundFill( + Color.CYAN.deriveColor(1, 1, 1, 0.20), pillRadii, Insets.EMPTY)); + hoverBorder = new Border(new BorderStroke( + Color.WHITE, BorderStrokeStyle.SOLID, pillRadii, new BorderWidths(1), + new Insets(0, -3, 0, -3))); + pinnedBorder = new Border(new BorderStroke( + Color.CYAN, BorderStrokeStyle.SOLID, pillRadii, new BorderWidths(1.5))); + + ImageView exportIv = ResourceUtils.loadIcon("export", iconFitWidth); + mtExport = new Label("Tools", exportIv); + mtExport.setContentDisplay(ContentDisplay.TOP); + + ImageView copyIv = ResourceUtils.loadIcon("snapshot", iconFitWidth); + mtCopy = new Label("Copy", copyIv); + mtCopy.setContentDisplay(ContentDisplay.TOP); + + ImageView saveIv = ResourceUtils.loadIcon("save", iconFitWidth); + mtSave = new Label("Save", saveIv); + mtSave.setContentDisplay(ContentDisplay.TOP); + + ImageView optsIv = ResourceUtils.loadIcon("configuration", iconFitWidth); + mtOptions = new Label("More", optsIv); + mtOptions.setContentDisplay(ContentDisplay.TOP); + + // Base visuals + generic hover for non-export buttons + Label[] all = new Label[]{mtExport, mtCopy, mtSave, mtOptions}; + for (Label l : all) { + l.setBackground(baseBg); + l.setPadding(new Insets(4, 6, 4, 6)); + } + for (Label l : new Label[]{mtCopy, mtSave, mtOptions}) { + l.setOnMouseEntered(e -> l.setBorder(hoverBorder)); + l.setOnMouseExited(e -> l.setBorder(null)); + } + + // Export icon hover respects sticky pin + mtExport.setOnMouseEntered(e -> { + if (!stickyHold) mtExport.setBorder(hoverBorder); + }); + mtExport.setOnMouseExited(e -> { + if (stickyHold) mtExport.setBorder(pinnedBorder); + else mtExport.setBorder(null); + }); + + // Actions + mtCopy.setOnMouseClicked(e -> copySnapshotToClipboard()); + mtSave.setOnMouseClicked(e -> saveSnapshotToFile()); + mtOptions.setOnMouseClicked(e -> { + ensureMenu(); + optionsMenu.show(mtOptions, e.getScreenX(), e.getScreenY()); + }); + + // Sticky toggle behavior on the export icon + mtExport.setOnMouseClicked(e -> { + if (!expanded) { + stickyHold = true; + updateStickyVisuals(); + setExpanded(true, true); + } else { + stickyHold = !stickyHold; + updateStickyVisuals(); + if (!stickyHold && !microToolbar.isHover()) { + hideDelay.playFromStart(); + } + } + }); + + microToolbar = new HBox(8, mtExport, mtCopy, mtSave, mtOptions); + microToolbar.setPadding(new Insets(2)); + microToolbar.setAlignment(Pos.CENTER); + microToolbar.setBackground(new Background(new BackgroundFill( + Color.rgb(0, 0, 0, 0.35), shellRadii, Insets.EMPTY))); + microToolbar.setBorder(new Border(new BorderStroke( + Color.rgb(255, 255, 255, 0.15), BorderStrokeStyle.SOLID, shellRadii, BorderWidths.DEFAULT))); + microToolbar.setOpacity(0.85); + microToolbar.setPickOnBounds(false); + + // Start collapsed; visibility enforced after attachment + setExpanded(false, false); + + hideDelay = new PauseTransition(Duration.millis(200)); + hideDelay.setOnFinished(e -> { + if (!stickyHold) setExpanded(false, true); + }); + + EventHandler enter = e -> { + hideDelay.stop(); + setExpanded(true, true); + }; + EventHandler exit = e -> { + if (!stickyHold) hideDelay.playFromStart(); + }; + + microToolbar.addEventHandler(MouseEvent.MOUSE_ENTERED, enter); + microToolbar.addEventHandler(MouseEvent.MOUSE_EXITED, exit); + } + + // Pinned indicator visuals + private void updateStickyVisuals() { + if (stickyHold) { + mtExport.setBackground(pinnedBg); + mtExport.setBorder(pinnedBorder); + } else { + mtExport.setBackground(baseBg); + mtExport.setBorder(null); + } + } + + // Compute and cache definitive collapsed/expanded widths to avoid drift + private void computeWidths() { + // Measure expanded width + showExtrasImmediate(true); + microToolbar.applyCss(); + microToolbar.layout(); + expandedWidth = microToolbar.prefWidth(-1); + + // Measure collapsed width + showExtrasImmediate(false); + microToolbar.applyCss(); + microToolbar.layout(); + collapsedWidth = microToolbar.prefWidth(-1); + } + + private void forceCollapsedVisuals() { + cancelCurrentAnim(); + showExtrasImmediate(false); + microToolbar.setOpacity(0.85); + microToolbar.setPrefWidth(collapsedWidth > 0 ? collapsedWidth : Region.USE_COMPUTED_SIZE); + microToolbar.applyCss(); + microToolbar.layout(); + expanded = false; + } + + private void showExtrasImmediate(boolean show) { + mtCopy.setManaged(show); + mtCopy.setVisible(show); + mtCopy.setOpacity(show ? 1.0 : 0.0); + + mtSave.setManaged(show); + mtSave.setVisible(show); + mtSave.setOpacity(show ? 1.0 : 0.0); + + mtOptions.setManaged(show); + mtOptions.setVisible(show); + mtOptions.setOpacity(show ? 1.0 : 0.0); + } + + private void cancelCurrentAnim() { + if (currentAnim != null) { + currentAnim.stop(); + currentAnim = null; + double w = microToolbar.getWidth() > 0 ? microToolbar.getWidth() : microToolbar.prefWidth(-1); + microToolbar.setPrefWidth(w); + } + } + + private void setExpanded(boolean expand, boolean animate) { + boolean changed = (this.expanded != expand); + + if (!changed && !animate) { + microToolbar.setOpacity(expand ? 1.0 : 0.85); + return; + } + + cancelCurrentAnim(); + + if (collapsedWidth < 0 || expandedWidth < 0) { + computeWidths(); + } + + double startW = microToolbar.getWidth() > 0 ? microToolbar.getWidth() : microToolbar.prefWidth(-1); + double targetW = expand ? expandedWidth : collapsedWidth; + + FadeTransition shellFade = new FadeTransition(Duration.millis(150), microToolbar); + shellFade.setToValue(expand ? 1.0 : 0.85); + + ParallelTransition itemsFade = new ParallelTransition(); + + if (expand) { + showExtrasImmediate(true); + microToolbar.applyCss(); + microToolbar.layout(); + + FadeTransition ft1 = new FadeTransition(Duration.millis(120), mtCopy); + ft1.setFromValue(0.0); + ft1.setToValue(1.0); + FadeTransition ft2 = new FadeTransition(Duration.millis(120), mtSave); + ft2.setFromValue(0.0); + ft2.setToValue(1.0); + FadeTransition ft3 = new FadeTransition(Duration.millis(120), mtOptions); + ft3.setFromValue(0.0); + ft3.setToValue(1.0); + itemsFade.getChildren().addAll(ft1, ft2, ft3); + } else { + FadeTransition ft1 = new FadeTransition(Duration.millis(100), mtCopy); + ft1.setToValue(0.0); + FadeTransition ft2 = new FadeTransition(Duration.millis(100), mtSave); + ft2.setToValue(0.0); + FadeTransition ft3 = new FadeTransition(Duration.millis(100), mtOptions); + ft3.setToValue(0.0); + itemsFade.getChildren().addAll(ft1, ft2, ft3); + } + + SimpleDoubleProperty pw = new SimpleDoubleProperty(startW); + pw.addListener((o, ov, nv) -> microToolbar.setPrefWidth(nv.doubleValue())); + Timeline wtl = new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(pw, startW)), + new KeyFrame(Duration.millis(150), new KeyValue(pw, targetW)) + ); + + currentAnim = new ParallelTransition(shellFade, wtl, itemsFade); + currentAnim.setOnFinished(ev -> { + if (!expand) { + showExtrasImmediate(false); + } + microToolbar.setPrefWidth(targetW); + currentAnim = null; + }); + currentAnim.play(); + + this.expanded = expand; + } + + // ---------- Options menu ---------- + + private void ensureMenu() { + if (optionsMenu != null) return; + + CheckMenuItem include = new CheckMenuItem("Include window frame"); + include.setSelected(includeChrome); + include.selectedProperty().addListener((o, ov, nv) -> includeChrome = nv); + + CheckMenuItem transparent = new CheckMenuItem("Transparent background (PNG)"); + transparent.setSelected(transparentPng); + transparent.selectedProperty().addListener((o, ov, nv) -> transparentPng = nv); + + Menu scale = new Menu("Scale"); + ToggleGroup tg = new ToggleGroup(); + RadioMenuItem s1 = new RadioMenuItem("1×"); + s1.setToggleGroup(tg); + RadioMenuItem s2 = new RadioMenuItem("2×"); + s2.setToggleGroup(tg); + RadioMenuItem s3 = new RadioMenuItem("3×"); + s3.setToggleGroup(tg); + + switch (exportScale) { + case 1: + s1.setSelected(true); + break; + case 3: + s3.setSelected(true); + break; + default: + s2.setSelected(true); + } + + s1.setOnAction(e -> exportScale = 1); + s2.setOnAction(e -> exportScale = 2); + s3.setOnAction(e -> exportScale = 3); + scale.getItems().addAll(s1, s2, s3); + + MenuItem copyNow = new MenuItem("Copy snapshot"); + copyNow.setOnAction(e -> copySnapshotToClipboard()); + MenuItem saveNow = new MenuItem("Save snapshot…"); + saveNow.setOnAction(e -> saveSnapshotToFile()); + + optionsMenu = new ContextMenu(copyNow, saveNow, new SeparatorMenuItem(), include, transparent, scale); + } + + // ---------- Snapshot actions ---------- + + private void copySnapshotToClipboard() { + WritableImage wi = snapshot(includeChrome ? chromeNode : contentNode, exportScale, transparentPng); + ClipboardContent cc = new ClipboardContent(); + cc.putImage(wi); + Clipboard.getSystemClipboard().setContent(cc); + toast("Snapshot copied to clipboard"); + } + + private void saveSnapshotToFile() { + WritableImage wi = snapshot(includeChrome ? chromeNode : contentNode, exportScale, transparentPng); + FileChooser fc = new FileChooser(); + fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG Image", "*.png")); + fc.setInitialFileName("snapshot_content-" + exportScale + "x.png"); + File f = fc.showSaveDialog(scene.getWindow()); + if (f == null) return; + try { + BufferedImage bi = SwingFXUtils.fromFXImage(wi, null); + ImageIO.write(bi, "png", f); + toast("Saved: " + f.getName()); + } catch (IOException ex) { + LOG.error("Failed to save snapshot", ex); + toast("Failed to save snapshot"); + } + } + + private WritableImage snapshot(Node target, int scale, boolean transparent) { + // Ensure CSS/layout are current without assuming Node#layout() + target.applyCss(); + if (target instanceof Parent) { + ((Parent) target).layout(); + } else if (target.getParent() != null) { + target.getParent().applyCss(); + if (target.getParent() instanceof Parent) { + ((Parent) target.getParent()).layout(); + } + } + + SnapshotParameters params = new SnapshotParameters(); + params.setTransform(Transform.scale(scale, scale)); + Paint fill = transparent ? Color.TRANSPARENT : Color.WHITE; + params.setFill(fill); + + Bounds b = target.getLayoutBounds(); + int w = Math.max(1, (int) Math.ceil(b.getWidth() * scale)); + int h = Math.max(1, (int) Math.ceil(b.getHeight() * scale)); + + return target.snapshot(params, new WritableImage(w, h)); + } + + private void toast(String msg) { + Platform.runLater(() -> { + Window owner = (scene != null) ? scene.getWindow() : null; + if (owner == null || microToolbar == null || microToolbar.getScene() == null) return; + + // Build toast node (no inline CSS) + Label toast = new Label(msg); + toast.setTextFill(Color.WHITE); + toast.setBackground(new Background(new BackgroundFill( + Color.rgb(0, 0, 0, 0.75), new CornerRadii(6), Insets.EMPTY))); + toast.setPadding(new Insets(6, 10, 6, 10)); + toast.setMouseTransparent(true); + toast.setOpacity(0.0); // start transparent for fade-in + + Popup popup = new Popup(); + popup.setAutoFix(true); + popup.setAutoHide(false); + popup.setHideOnEscape(false); + popup.getContent().add(toast); + + // Position: right-aligned under the microToolbar, in screen coords + Bounds tb = microToolbar.localToScreen(microToolbar.getBoundsInLocal()); + double px = (tb != null) ? tb.getMaxX() : owner.getX() + owner.getWidth() - 24.0; + double py = (tb != null) ? tb.getMaxY() + 6.0 : owner.getY() + 48.0; + + // Show first, then measure and adjust for exact width to right-align + popup.show(owner, px, py); + toast.applyCss(); + toast.layout(); + double w = toast.prefWidth(-1); + popup.setX(px - w); // right-align under the toolbar + + // Fade in → hold → fade out, then hide + FadeTransition in = new FadeTransition(Duration.millis(150), toast); + in.setFromValue(0.0); + in.setToValue(1.0); + + PauseTransition hold = new PauseTransition(Duration.millis(600)); + + FadeTransition out = new FadeTransition(Duration.millis(300), toast); + out.setFromValue(1.0); + out.setToValue(0.0); + out.setOnFinished(e -> popup.hide()); + + new SequentialTransition(in, hold, out).play(); + }); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java new file mode 100644 index 00000000..3466a619 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -0,0 +1,513 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.ChoiceBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TitledPane; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.BorderStroke; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * FeatureVectorManagerView + * - Header with Collection selector + Sampling choice + Search field + * - Center TableView of FeatureVectors (compact/full columns) + * - Bottom stack: Details (values preview) and Metadata (formatted key/value), independent TitledPanes + * - Status bar with progress indicator + *

+ * Context menus: + * - Collection ComboBox: Apply (append) / Apply (replace) + * - Table: Apply (append) / Apply (replace) + *

+ * Note: The collection ComboBox should be bound to a live ObservableList by the container/pane. + */ +public class FeatureVectorManagerView extends BorderPane { + + public enum DetailLevel {COMPACT, FULL} + + private final StringProperty samplingMode = new SimpleStringProperty("All"); + private final StringProperty selectedCollection = new SimpleStringProperty(""); + private final ObjectProperty detailLevel = new SimpleObjectProperty<>(DetailLevel.COMPACT); + + // Header controls + private final ComboBox collectionSelector = new ComboBox<>(); + private final ChoiceBox samplingChoice = new ChoiceBox<>(); + private final TextField searchField = new TextField(); + + // Table + columns + private final TableView table = new TableView<>(); + private final TableColumn colIndex = new TableColumn<>("#"); + private final TableColumn colLabel = new TableColumn<>("Label"); + private final TableColumn colDim = new TableColumn<>("Dim"); + private final TableColumn colPreview = new TableColumn<>("Data Preview"); + private final TableColumn colScore = new TableColumn<>("Score"); + private final TableColumn colPfa = new TableColumn<>("PFA"); + private final TableColumn colLayer = new TableColumn<>("Layer"); + private final TableColumn colImageUrl = new TableColumn<>("Image URL"); + private final TableColumn colText = new TableColumn<>("Text"); + + private final ObservableList items = FXCollections.observableArrayList(); + + // Details + Metadata (independent panes) + private final TitledPane detailsSection = new TitledPane(); + private final TitledPane metadataSection = new TitledPane(); + private final TextArea valuesPreviewArea = new TextArea(); + private final TextArea metaTextArea = new TextArea(); + + // Status + private final Label statusLabel = new Label("Ready."); + private final ProgressBar progressBar = new ProgressBar(); + + // Callbacks (wired by container/pane) + private Runnable onApplyAppend; + private Runnable onApplyReplace; + private Runnable onSetAll; + + public FeatureVectorManagerView() { + buildHeader(); + buildTable(); + buildDetailAndMetadataPanes(); + + BorderPane.setMargin(table, Insets.EMPTY); + setTop(buildHeaderBar()); + setCenter(table); + + VBox bottomStack = new VBox(4, buildBottomBlock(), buildStatusBar()); + bottomStack.setPadding(new Insets(2, 2, 2, 2)); + setBottom(bottomStack); + + // Wiring (internal) + samplingChoice.getSelectionModel().selectedItemProperty().addListener((o, oldV, newV) -> { + if (newV != null) samplingMode.set(newV); + }); + collectionSelector.getSelectionModel().selectedItemProperty().addListener((o, oldV, newV) -> { + if (newV != null) selectedCollection.set(newV); + }); + detailLevel.addListener((o, oldV, newV) -> applyDetailLevel(newV)); + + detailsSection.setExpanded(false); + metadataSection.setExpanded(false); + progressBar.setVisible(false); + applyDetailLevel(detailLevel.get()); + + // Context menus + collectionSelector.setContextMenu(buildCollectionContextMenu()); + table.setContextMenu(buildTableContextMenu()); + // Also show on right-click even if ComboBox popup is open + collectionSelector.setOnContextMenuRequested(e -> { + ContextMenu cm = collectionSelector.getContextMenu(); + if (cm != null) cm.show(collectionSelector, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + } + + // Header ------------------------------------------------- + + private HBox buildHeaderBar() { + Label lblCollection = new Label("Collection:"); + Label lblSampling = new Label("Sampling:"); + Label lblSearch = new Label("Search:"); + + HBox header = new HBox(8, lblCollection, collectionSelector, lblSampling, samplingChoice, lblSearch, searchField); + header.setAlignment(Pos.CENTER_LEFT); + header.setPadding(new Insets(4, 4, 4, 4)); + header.setBackground(new Background(new BackgroundFill( + Color.color(0, 0, 0, 0.08), CornerRadii.EMPTY, Insets.EMPTY + ))); + header.setBorder(new Border(new BorderStroke( + Color.color(1, 1, 1, 0.18), BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT + ))); + + // Let inputs expand/shrink with space + collectionSelector.setMaxWidth(Double.MAX_VALUE); + samplingChoice.setMaxWidth(Double.MAX_VALUE); + searchField.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(collectionSelector, Priority.SOMETIMES); + HBox.setHgrow(samplingChoice, Priority.NEVER); + HBox.setHgrow(searchField, Priority.ALWAYS); + + return header; + } + + private void buildHeader() { + collectionSelector.setPromptText("No collections loaded"); + collectionSelector.setPrefWidth(220); + collectionSelector.setMinWidth(160); + collectionSelector.setMaxWidth(Double.MAX_VALUE); + + samplingChoice.getItems().addAll("All", "Head (1000)", "Tail (1000)", "Random (1000)"); + samplingChoice.getSelectionModel().selectFirst(); + samplingChoice.setPrefWidth(150); + samplingChoice.setMinWidth(120); + + searchField.setPromptText("Filter by label, text, or metadata…"); + Tooltip.install(searchField, new Tooltip("Case-insensitive contains; press Enter to apply, Esc to clear")); + } + + // Table -------------------------------------------------- + + private void buildTable() { + table.setItems(items); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + table.setPlaceholder(new Label("No vectors loaded.")); + + colIndex.setMinWidth(50); + colIndex.setMaxWidth(70); + colIndex.setCellValueFactory(cd -> + javafx.beans.binding.Bindings.createStringBinding( + () -> String.valueOf(table.getItems().indexOf(cd.getValue()) + 1), table.itemsProperty() + ) + ); + + colLabel.setMinWidth(120); + colLabel.setCellValueFactory(cd -> new ReadOnlyStringWrapper(opt(cd.getValue().getLabel()))); + + colDim.setMinWidth(70); + colDim.setMaxWidth(90); + colDim.setCellValueFactory(cd -> { + var data = cd.getValue().getData(); + int dim = (data == null) ? 0 : data.size(); + return new ReadOnlyStringWrapper(String.valueOf(dim)); + }); + + colPreview.setCellValueFactory(cd -> new ReadOnlyStringWrapper(previewList(cd.getValue().getData(), 8))); + + colImageUrl.setCellValueFactory(cd -> new ReadOnlyStringWrapper( + cd.getValue().getImageURL() != null ? cd.getValue().getImageURL().trim() : "")); + colText.setCellValueFactory(cd -> new ReadOnlyStringWrapper( + cd.getValue().getText() != null ? cd.getValue().getText().trim() : "")); + colScore.setMinWidth(80); + colScore.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getScore()))); + colPfa.setMinWidth(70); + colPfa.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getPfa()))); + colLayer.setMinWidth(70); + colLayer.setCellValueFactory(cd -> new ReadOnlyStringWrapper(String.valueOf(cd.getValue().getLayer()))); + + table.getColumns().setAll(colIndex, colLabel, colDim, colPreview); + + table.getSelectionModel() + .getSelectedItems() + .addListener((ListChangeListener) change -> updateDetailsPreview()); + } + + // Details & Metadata panes ------------------------------- + + private void buildDetailAndMetadataPanes() { + valuesPreviewArea.setEditable(false); + valuesPreviewArea.setPrefRowCount(8); + valuesPreviewArea.setWrapText(true); + + VBox detailsBox = new VBox(6, valuesPreviewArea); + detailsBox.setPadding(new Insets(6)); + + detailsSection.setText("Details"); + detailsSection.setContent(detailsBox); + detailsSection.setExpanded(false); + + metaTextArea.setEditable(false); + metaTextArea.setPrefRowCount(8); + metaTextArea.setWrapText(true); + + VBox metaBox = new VBox(6, metaTextArea); + metaBox.setPadding(new Insets(6)); + + metadataSection.setText("Metadata"); + metadataSection.setContent(metaBox); + metadataSection.setExpanded(false); + } + + private Node buildBottomBlock() { + VBox stack = new VBox(6, detailsSection, metadataSection); + stack.setPadding(new Insets(6)); + + BorderPane wrapper = new BorderPane(stack); + wrapper.setBorder(new Border(new BorderStroke( + Color.color(1, 1, 1, 0.15), BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT + ))); + wrapper.setBackground(new Background(new BackgroundFill( + Color.color(0, 0, 0, 0.04), CornerRadii.EMPTY, Insets.EMPTY + ))); + return wrapper; + } + + // Context Menus ----------------------------------------- + + private ContextMenu buildCollectionContextMenu() { + Menu applyMenu = new Menu("Apply to workspace"); + + MenuItem applyAppend = new MenuItem("Apply (append)"); + applyAppend.setOnAction(e -> { + if (onApplyAppend != null) onApplyAppend.run(); + }); + + MenuItem applyReplace = new MenuItem("Apply (replace)"); + applyReplace.setOnAction(e -> { + if (onApplyReplace != null) onApplyReplace.run(); + }); + + MenuItem setAllReplace = new MenuItem("Set All"); + setAllReplace.setOnAction(e -> { + if (onSetAll != null) onSetAll.run(); + }); + + applyMenu.getItems().addAll(applyAppend, applyReplace, setAllReplace); + return new ContextMenu(applyMenu); + } + + private ContextMenu buildTableContextMenu() { + Menu applyMenu = new Menu("Apply collection to workspace"); + + MenuItem applyAppend = new MenuItem("Apply (append)"); + applyAppend.setOnAction(e -> { + if (onApplyAppend != null) onApplyAppend.run(); + }); + + MenuItem applyReplace = new MenuItem("Apply (replace)"); + applyReplace.setOnAction(e -> { + if (onApplyReplace != null) onApplyReplace.run(); + }); + + applyMenu.getItems().addAll(applyAppend, applyReplace); + return new ContextMenu(applyMenu); + } + + // Status ------------------------------------------------- + + private HBox buildStatusBar() { + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + progressBar.setPrefWidth(140); + progressBar.setVisible(false); + + HBox status = new HBox(8, statusLabel, spacer, progressBar); + status.setPadding(new Insets(4, 6, 4, 6)); + status.setAlignment(Pos.CENTER_LEFT); + status.setBackground(new Background(new BackgroundFill( + Color.color(0, 0, 0, 0.06), CornerRadii.EMPTY, Insets.EMPTY + ))); + status.setBorder(new Border(new BorderStroke( + Color.color(1, 1, 1, 0.15), BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT + ))); + return status; + } + + // Helpers ----------------------------------------------- + + private static String opt(String s) { + return s == null ? "" : s; + } + + private static String trim(double d) { + String s = String.format(Locale.ROOT, "%.6f", d); + if (s.contains(".")) s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + return s; + } + + private static String previewList(List data, int firstN) { + if (data == null || data.isEmpty()) return "[]"; + int n = Math.min(firstN, data.size()); + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < n; i++) { + if (i > 0) sb.append(", "); + Double v = data.get(i); + if (v == null) sb.append("NaN"); + else { + String s = String.format(Locale.ROOT, "%.6f", v); + if (s.contains(".")) s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + sb.append(s); + } + } + if (data.size() > n) sb.append(", …"); + sb.append("]"); + return sb.toString(); + } + + private void updateDetailsPreview() { + var sel = table.getSelectionModel().getSelectedItems(); + if (sel == null || sel.isEmpty()) { + valuesPreviewArea.clear(); + metaTextArea.setText("(no metadata)"); + return; + } + FeatureVector fv = sel.get(0); + var data = fv.getData(); + + StringBuilder sb = new StringBuilder(); + sb.append("Label: ").append(opt(fv.getLabel())).append("\n"); + sb.append("Dim: ").append(data == null ? 0 : data.size()).append("\n"); + sb.append("Score: ").append(trim(fv.getScore())).append(" PFA: ").append(trim(fv.getPfa())).append("\n"); + sb.append("Layer: ").append(fv.getLayer()).append(" FrameId: ").append(fv.getFrameId()).append("\n"); + sb.append("BBox: ").append(fv.getBbox() == null ? "[]" : fv.getBbox().toString()).append("\n\n"); + + sb.append("Values (preview up to 64):\n"); + if (data != null) { + int n = Math.min(64, data.size()); + for (int i = 0; i < n; i++) { + if (i > 0) sb.append(", "); + Double v = data.get(i); + if (v == null) sb.append("NaN"); + else { + String s = String.format(Locale.ROOT, "%.6f", v); + if (s.contains(".")) s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + sb.append(s); + } + } + if (data.size() > n) sb.append(", …"); + } + valuesPreviewArea.setText(sb.toString()); + + if (fv.getMetaData() == null || fv.getMetaData().isEmpty()) { + metaTextArea.setText("(no metadata)"); + } else { + StringBuilder sbMeta = new StringBuilder(); + fv.getMetaData().forEach((k, v) -> sbMeta.append(k == null ? "(null)" : k) + .append(": ") + .append(v == null ? "(null)" : v) + .append("\n")); + metaTextArea.setText(sbMeta.toString().trim()); + } + } + + private void applyDetailLevel(DetailLevel level) { + if (level == DetailLevel.COMPACT) { + table.getColumns().setAll(colIndex, colLabel, colDim, colPreview); + } else { + table.getColumns().setAll(colIndex, colLabel, colDim, colPreview, + colScore, colPfa, colLayer, colImageUrl, colText); + } + } + + // Public API -------------------------------------------- + + public void setVectors(List vectors) { + items.setAll(vectors == null ? Collections.emptyList() : vectors); + setStatus("Loaded " + items.size() + " vectors."); + } + + public void addVectors(Collection vectors) { + if (vectors != null) items.addAll(vectors); + setStatus("Loaded " + items.size() + " vectors."); + } + + /** + * Snapshot setter (optional). If you’re binding live in the pane, you can ignore this. + */ + public void setCollections(List names) { + collectionSelector.getItems().setAll(names == null ? Collections.emptyList() : names); + if (!collectionSelector.getItems().isEmpty()) collectionSelector.getSelectionModel().selectFirst(); + } + + public void setStatus(String message) { + statusLabel.setText(message == null ? "" : message); + } + + public void showProgress(boolean show) { + progressBar.setVisible(show); + if (!show) progressBar.setProgress(0); + } + + // Exposed controls/properties for container wiring + public TableView getTable() { + return table; + } + + public ComboBox getCollectionSelector() { + return collectionSelector; + } + + public ChoiceBox getSamplingChoice() { + return samplingChoice; + } + + public TextField getSearchField() { + return searchField; + } + + public TitledPane getDetailsSection() { + return detailsSection; + } + + public TitledPane getMetadataSection() { + return metadataSection; + } + + public StringProperty samplingModeProperty() { + return samplingMode; + } + + public String getSamplingMode() { + return samplingMode.get(); + } + + public StringProperty selectedCollectionProperty() { + return selectedCollection; + } + + public String getSelectedCollection() { + return selectedCollection.get(); + } + + public ObjectProperty detailLevelProperty() { + return detailLevel; + } + + public void setDetailLevel(DetailLevel level) { + this.detailLevel.set(level); + } + + public DetailLevel getDetailLevel() { + return detailLevel.get(); + } + + // Callback setters (wired by container/pane) + public void setOnApplyAppend(Runnable r) { + this.onApplyAppend = r; + } + + public void setOnApplyReplace(Runnable r) { + this.onApplyReplace = r; + } + + public void setSetAll(Runnable r) { + this.onSetAll = r; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java new file mode 100644 index 00000000..41418f0c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java @@ -0,0 +1,441 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams.EdgePolicy; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams.LayoutKind; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +/** + * GraphControlsView + * ------------------------------------------------------------ + * Exposes layout + edge-sparsification + FR knobs. + * Fires: + * - GraphEvent.PARAMS_CHANGED with a copy of GraphLayoutParams on any change + * - GraphEvent.REBUILD_WITH_PARAMS on "Rebuild Graph" + * - GraphEvent.RESET_PARAMS on "Reset Defaults" + *

+ * NOTE: Weight mapping (DIRECT vs INVERSE_FOR_DIVERGENCE) is *not* in GraphLayoutParams. + * Choose it at the call site based on matrix kind. + */ +public final class GraphControlsView extends VBox { + + // Layout + private ComboBox layoutKindCombo; + private Spinner radiusSpinner; + + // Edge policy + private ComboBox edgePolicyCombo; + private Spinner kSpinner; + private CheckBox knnSymmetrizeCheck; + private Spinner epsSpinner; + private CheckBox buildMstCheck; + + // Edge caps + private Spinner maxEdgesSpinner; + private Spinner maxDegreeSpinner; + private Spinner minWeightSpinner; + private CheckBox norm01Check; + + // Force-FR + private Spinner itersSpinner; + private Spinner stepSpinner; + private Spinner repulseSpinner; + private Spinner attractSpinner; + private Spinner gravitySpinner; + private Spinner coolingSpinner; + + private final GraphLayoutParams params; + private final Scene scene; + + public GraphControlsView(Scene scene) { + this.scene = scene; + this.params = new GraphLayoutParams(); // your defaults + setSpacing(10); + setPadding(new Insets(6)); + getChildren().add(buildContent()); + fireParamsChanged(); // publish defaults once + } + + public GraphLayoutParams getParamsCopy() { + return copyParams(params); + } + + public void setParams(GraphLayoutParams p) { + if (p == null) return; + copyInto(p, params); + syncControlsFromParams(); + fireParamsChanged(); + } + + // UI ----------------------------------------------------------------- + + private Pane buildContent() { + return new VBox(10, + titledBox("Layout", buildLayoutGrid()), + titledBox("Edges / Sparsification", buildEdgesGrid()), + titledBox("Force-Directed (FR)", buildForceGrid()), + buildActionsBar() + ); + } + + private GridPane buildLayoutGrid() { + GridPane gp = formGrid(); + + layoutKindCombo = new ComboBox<>(); + layoutKindCombo.getItems().addAll(LayoutKind.values()); + layoutKindCombo.getSelectionModel().select(params.kind); + styleCombo(layoutKindCombo); + addRow(gp, 0, "Kind", layoutKindCombo); + + radiusSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(10.0, 100_000.0, params.radius, 10.0)); + radiusSpinner.setEditable(true); + styleSpinner(radiusSpinner); + addRow(gp, 1, "Radius", radiusSpinner); + + layoutKindCombo.setOnAction(e -> { + params.kind = layoutKindCombo.getValue(); + toggleForceSectionVisibility(); + fireParamsChanged(); + }); + radiusSpinner.valueProperty().addListener((o, ov, nv) -> { + params.radius = nv; + fireParamsChanged(); + }); + + return gp; + } + + private GridPane buildEdgesGrid() { + GridPane gp = formGrid(); + + edgePolicyCombo = new ComboBox<>(); + edgePolicyCombo.getItems().addAll(EdgePolicy.values()); + edgePolicyCombo.getSelectionModel().select(params.edgePolicy); + styleCombo(edgePolicyCombo); + addRow(gp, 0, "Policy", edgePolicyCombo); + + kSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 10_000, params.knnK, 1)); + kSpinner.setEditable(true); + styleSpinner(kSpinner); + addRow(gp, 1, "k (KNN/MST+K)", kSpinner); + + knnSymmetrizeCheck = new CheckBox("Symmetrize KNN"); + knnSymmetrizeCheck.setSelected(params.knnSymmetrize); + styleCheck(knnSymmetrizeCheck); + addRow(gp, 2, "", knnSymmetrizeCheck); + + epsSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, params.epsilon, 0.1)); + epsSpinner.setEditable(true); + styleSpinner(epsSpinner); + addRow(gp, 3, "ε (Epsilon)", epsSpinner); + + buildMstCheck = new CheckBox("Build MST (for MST+K)"); + buildMstCheck.setSelected(params.buildMst); + styleCheck(buildMstCheck); + addRow(gp, 4, "", buildMstCheck); + + maxEdgesSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 2_000_000, params.maxEdges, 100)); + maxEdgesSpinner.setEditable(true); + styleSpinner(maxEdgesSpinner); + addRow(gp, 5, "Max edges", maxEdgesSpinner); + + maxDegreeSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 10_000, params.maxDegreePerNode, 1)); + maxDegreeSpinner.setEditable(true); + styleSpinner(maxDegreeSpinner); + addRow(gp, 6, "Max degree/node", maxDegreeSpinner); + + minWeightSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, params.minEdgeWeight, 0.01)); + minWeightSpinner.setEditable(true); + styleSpinner(minWeightSpinner); + addRow(gp, 7, "Min edge weight", minWeightSpinner); + + norm01Check = new CheckBox("Normalize weights [0,1]"); + norm01Check.setSelected(params.normalizeWeights01); + styleCheck(norm01Check); + addRow(gp, 8, "", norm01Check); + + edgePolicyCombo.setOnAction(e -> { + params.edgePolicy = edgePolicyCombo.getValue(); + toggleEdgePolicyFields(); + fireParamsChanged(); + }); + kSpinner.valueProperty().addListener((o, ov, nv) -> { + params.knnK = nv; + fireParamsChanged(); + }); + knnSymmetrizeCheck.setOnAction(e -> { + params.knnSymmetrize = knnSymmetrizeCheck.isSelected(); + fireParamsChanged(); + }); + epsSpinner.valueProperty().addListener((o, ov, nv) -> { + params.epsilon = nv; + fireParamsChanged(); + }); + buildMstCheck.setOnAction(e -> { + params.buildMst = buildMstCheck.isSelected(); + fireParamsChanged(); + }); + maxEdgesSpinner.valueProperty().addListener((o, ov, nv) -> { + params.maxEdges = nv; + fireParamsChanged(); + }); + maxDegreeSpinner.valueProperty().addListener((o, ov, nv) -> { + params.maxDegreePerNode = nv; + fireParamsChanged(); + }); + minWeightSpinner.valueProperty().addListener((o, ov, nv) -> { + params.minEdgeWeight = nv; + fireParamsChanged(); + }); + norm01Check.setOnAction(e -> { + params.normalizeWeights01 = norm01Check.isSelected(); + fireParamsChanged(); + }); + + toggleEdgePolicyFields(); + return gp; + } + + private GridPane buildForceGrid() { + GridPane gp = formGrid(); + + itersSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 200_000, params.iterations, 10)); + itersSpinner.setEditable(true); + styleSpinner(itersSpinner); + addRow(gp, 0, "Iterations", itersSpinner); + + stepSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0001, 1000.0, params.step, 0.01)); + stepSpinner.setEditable(true); + styleSpinner(stepSpinner); + addRow(gp, 1, "Step", stepSpinner); + + repulseSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, params.repulsion, 1.0)); + repulseSpinner.setEditable(true); + styleSpinner(repulseSpinner); + addRow(gp, 2, "Repulsion", repulseSpinner); + + attractSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e6, params.attraction, 0.01)); + attractSpinner.setEditable(true); + styleSpinner(attractSpinner); + addRow(gp, 3, "Attraction", attractSpinner); + + gravitySpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 10.0, params.gravity, 0.01)); + gravitySpinner.setEditable(true); + styleSpinner(gravitySpinner); + addRow(gp, 4, "Gravity", gravitySpinner); + + coolingSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.5, 0.9999, params.cooling, 0.001)); + coolingSpinner.setEditable(true); + styleSpinner(coolingSpinner); + addRow(gp, 5, "Cooling", coolingSpinner); + + itersSpinner.valueProperty().addListener((o, ov, nv) -> { + params.iterations = nv; + fireParamsChanged(); + }); + stepSpinner.valueProperty().addListener((o, ov, nv) -> { + params.step = nv; + fireParamsChanged(); + }); + repulseSpinner.valueProperty().addListener((o, ov, nv) -> { + params.repulsion = nv; + fireParamsChanged(); + }); + attractSpinner.valueProperty().addListener((o, ov, nv) -> { + params.attraction = nv; + fireParamsChanged(); + }); + gravitySpinner.valueProperty().addListener((o, ov, nv) -> { + params.gravity = nv; + fireParamsChanged(); + }); + coolingSpinner.valueProperty().addListener((o, ov, nv) -> { + params.cooling = nv; + fireParamsChanged(); + }); + + toggleForceSectionVisibility(); + return gp; + } + + private HBox buildActionsBar() { + Button rebuildBtn = new Button("Rebuild Graph"); + rebuildBtn.setOnAction(e -> + fireOnRoot(new GraphEvent(GraphEvent.GRAPH_REBUILD_PARAMS, copyParams(params))) + ); + + Button resetBtn = new Button("Reset Defaults"); + resetBtn.setOnAction(e -> { + copyInto(new GraphLayoutParams(), params); + syncControlsFromParams(); + fireOnRoot(new GraphEvent(GraphEvent.GRAPH_RESET_PARAMS, null)); + fireParamsChanged(); + }); + + HBox box = new HBox(10, rebuildBtn, resetBtn); + box.setAlignment(Pos.CENTER_RIGHT); + return box; + } + + // Behavior ------------------------------------------------------------ + + private void toggleEdgePolicyFields() { + EdgePolicy p = edgePolicyCombo.getValue(); + boolean useK = (p == EdgePolicy.KNN || p == EdgePolicy.MST_PLUS_K); + boolean useEps = (p == EdgePolicy.EPSILON); + + kSpinner.setDisable(!useK); + knnSymmetrizeCheck.setDisable(!(p == EdgePolicy.KNN)); // only meaningful for KNN + epsSpinner.setDisable(!useEps); + buildMstCheck.setDisable(p != EdgePolicy.MST_PLUS_K); + } + + private void toggleForceSectionVisibility() { + boolean fr = (layoutKindCombo.getValue() == LayoutKind.FORCE_FR); + itersSpinner.setDisable(!fr); + stepSpinner.setDisable(!fr); + repulseSpinner.setDisable(!fr); + attractSpinner.setDisable(!fr); + gravitySpinner.setDisable(!fr); + coolingSpinner.setDisable(!fr); + } + + private void syncControlsFromParams() { + layoutKindCombo.getSelectionModel().select(params.kind); + radiusSpinner.getValueFactory().setValue(params.radius); + + edgePolicyCombo.getSelectionModel().select(params.edgePolicy); + kSpinner.getValueFactory().setValue(params.knnK); + knnSymmetrizeCheck.setSelected(params.knnSymmetrize); + epsSpinner.getValueFactory().setValue(params.epsilon); + buildMstCheck.setSelected(params.buildMst); + + maxEdgesSpinner.getValueFactory().setValue(params.maxEdges); + maxDegreeSpinner.getValueFactory().setValue(params.maxDegreePerNode); + minWeightSpinner.getValueFactory().setValue(params.minEdgeWeight); + norm01Check.setSelected(params.normalizeWeights01); + + itersSpinner.getValueFactory().setValue(params.iterations); + stepSpinner.getValueFactory().setValue(params.step); + repulseSpinner.getValueFactory().setValue(params.repulsion); + attractSpinner.getValueFactory().setValue(params.attraction); + gravitySpinner.getValueFactory().setValue(params.gravity); + coolingSpinner.getValueFactory().setValue(params.cooling); + + toggleEdgePolicyFields(); + toggleForceSectionVisibility(); + } + + private void fireParamsChanged() { + fireOnRoot(new GraphEvent(GraphEvent.GRAPH_PARAMS_CHANGED, copyParams(params))); + } + + private void fireOnRoot(javafx.event.Event evt) { + if (scene != null && scene.getRoot() != null) scene.getRoot().fireEvent(evt); + else this.fireEvent(evt); + } + + // Utils --------------------------------------------------------------- + + private static GridPane formGrid() { + GridPane gp = new GridPane(); + gp.setHgap(8); + gp.setVgap(6); + gp.setAlignment(Pos.TOP_LEFT); + ColumnConstraints c0 = new ColumnConstraints(); + c0.setPercentWidth(40); + ColumnConstraints c1 = new ColumnConstraints(); + c1.setPercentWidth(60); + c1.setHgrow(Priority.ALWAYS); + gp.getColumnConstraints().addAll(c0, c1); + return gp; + } + + private static void addRow(GridPane gp, int row, String label, javafx.scene.Node control) { + Label l = new Label(label); + gp.add(l, 0, row); + if (control instanceof Spinner || control instanceof ComboBox) { + GridPane.setHgrow(control, Priority.NEVER); + } else { + GridPane.setHgrow(control, Priority.ALWAYS); + if (control instanceof Control c) c.setMaxWidth(Double.MAX_VALUE); + } + gp.add(control, 1, row); + } + + private static VBox titledBox(String title, javafx.scene.Node content) { + Label t = new Label(title); + t.getStyleClass().add("section-title"); + VBox box = new VBox(6, t, new Separator(), content); + box.setPadding(new Insets(4, 2, 6, 2)); + return box; + } + + private static void styleSpinner(Spinner spinner) { + spinner.setPrefWidth(125.0); + spinner.setMaxWidth(125.0); + spinner.setMinWidth(Region.USE_PREF_SIZE); + } + + private static void styleCombo(ComboBox combo) { + combo.setPrefWidth(220.0); + combo.setMaxWidth(220.0); + combo.setMinWidth(Region.USE_PREF_SIZE); + } + + private static void styleCheck(CheckBox cb) { + cb.setPrefWidth(100.0); + cb.setMaxWidth(100.0); + cb.setMinWidth(Region.USE_PREF_SIZE); + } + + private static GraphLayoutParams copyParams(GraphLayoutParams in) { + GraphLayoutParams p = new GraphLayoutParams(); + copyInto(in, p); + return p; + } + + private static void copyInto(GraphLayoutParams src, GraphLayoutParams dst) { + // layout + dst.kind = src.kind; + dst.radius = src.radius; + + // force + dst.iterations = src.iterations; + dst.step = src.step; + dst.repulsion = src.repulsion; + dst.attraction = src.attraction; + dst.gravity = src.gravity; + dst.cooling = src.cooling; + + // edges + dst.edgePolicy = src.edgePolicy; + dst.knnK = src.knnK; + dst.knnSymmetrize = src.knnSymmetrize; + dst.epsilon = src.epsilon; + dst.buildMst = src.buildMst; + + dst.maxEdges = src.maxEdges; + dst.maxDegreePerNode = src.maxDegreePerNode; + dst.minEdgeWeight = src.minEdgeWeight; + dst.normalizeWeights01 = src.normalizeWeights01; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java new file mode 100644 index 00000000..bc8d6329 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java @@ -0,0 +1,380 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.utils.graph.GraphStyleParams; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Slider; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; + +import java.util.Objects; + +/** + * GraphStyleControlsView + * ------------------------------------------------------------ + * Separate tab view for graph appearance (nodes/edges). + *

+ * Fires: + * - GraphEvent.GRAPH_STYLE_PARAMS_CHANGED (object = GraphStyleParams snapshot) on change + * - GraphEvent.GRAPH_STYLE_RESET_DEFAULTS on "Reset Style" + *

+ * Listens (GUI-sync, model → UI): + * - GraphEvent.SET_STYLE_GUI (object = GraphStyleParams) + * - GraphEvent.SET_NODE_COLOR_GUI (object = Color) + * - GraphEvent.SET_NODE_RADIUS_GUI (object = Double) + * - GraphEvent.SET_NODE_OPACITY_GUI (object = Double) + * - GraphEvent.SET_EDGE_COLOR_GUI (object = Color) + * - GraphEvent.SET_EDGE_WIDTH_GUI (object = Double) + * - GraphEvent.SET_EDGE_OPACITY_GUI (object = Double) + *

+ * Notes: + * - Uses sliders for opacity. + * - Updates controls only when incoming GUI-sync differs (prevents event ping-pong). + * - No selection scope (global apply). + */ +public final class GraphStyleControlsView extends VBox { + + // Layout constants + private static final double SPINNER_PREF_WIDTH = 125.0; + private static final double COLOR_PICKER_PREF_WIDTH = 150.0; + + private final Scene scene; + private final GraphStyleParams params = new GraphStyleParams(); + + // Nodes + private ColorPicker nodeColorPicker; + private Spinner nodeRadiusSpinner; + private Slider nodeOpacitySlider; + + // Edges + private ColorPicker edgeColorPicker; + private Spinner edgeWidthSpinner; + private Slider edgeOpacitySlider; + + // Guard to avoid echo during GUI hydration + private boolean isUpdatingFromGuiSync = false; + + public GraphStyleControlsView(Scene scene) { + this.scene = scene; + + setSpacing(10); + setPadding(new Insets(6)); + getChildren().add(buildContent()); + wireGuiSyncHandlers(); + + // publish defaults once + fireParamsChanged(); + } + + // --------------------------------------------------------------------- + // UI construction + // --------------------------------------------------------------------- + + private VBox buildContent() { + return new VBox(10, + titledBox("Nodes", buildNodesGrid()), + titledBox("Edges", buildEdgesGrid()), + buildActionsBar() + ); + } + + private GridPane buildNodesGrid() { + GridPane gp = formGrid(); + + nodeColorPicker = new ColorPicker(params.nodeColor); + styleColorPicker(nodeColorPicker); + addRow(gp, 0, "Color", nodeColorPicker); + + nodeRadiusSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(1.0, 500.0, params.nodeRadius, 1.0)); + nodeRadiusSpinner.setEditable(true); + styleSpinner(nodeRadiusSpinner); + addRow(gp, 1, "Radius", nodeRadiusSpinner); + + nodeOpacitySlider = new Slider(0.0, 1.0, params.nodeOpacity); + nodeOpacitySlider.setShowTickMarks(true); + nodeOpacitySlider.setShowTickLabels(true); + nodeOpacitySlider.setMajorTickUnit(0.25); + nodeOpacitySlider.setBlockIncrement(0.05); + addRow(gp, 2, "Opacity", nodeOpacitySlider); + + nodeColorPicker.setOnAction(e -> { + params.nodeColor = nodeColorPicker.getValue(); + fireParamsChanged(); + }); + nodeRadiusSpinner.valueProperty().addListener((o, ov, nv) -> { + params.nodeRadius = nv; + fireParamsChanged(); + }); + nodeOpacitySlider.valueProperty().addListener((o, ov, nv) -> { + params.nodeOpacity = clamp01(nv.doubleValue()); + fireParamsChanged(); + }); + + return gp; + } + + private GridPane buildEdgesGrid() { + GridPane gp = formGrid(); + + edgeColorPicker = new ColorPicker(params.edgeColor); + styleColorPicker(edgeColorPicker); + addRow(gp, 0, "Color", edgeColorPicker); + + edgeWidthSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.5, 200.0, params.edgeWidth, 0.5)); + edgeWidthSpinner.setEditable(true); + styleSpinner(edgeWidthSpinner); + addRow(gp, 1, "Width", edgeWidthSpinner); + + edgeOpacitySlider = new Slider(0.0, 1.0, params.edgeOpacity); + edgeOpacitySlider.setShowTickMarks(true); + edgeOpacitySlider.setShowTickLabels(true); + edgeOpacitySlider.setMajorTickUnit(0.25); + edgeOpacitySlider.setBlockIncrement(0.05); + addRow(gp, 2, "Opacity", edgeOpacitySlider); + + edgeColorPicker.setOnAction(e -> { + params.edgeColor = edgeColorPicker.getValue(); + fireParamsChanged(); + }); + edgeWidthSpinner.valueProperty().addListener((o, ov, nv) -> { + params.edgeWidth = nv; + fireParamsChanged(); + }); + edgeOpacitySlider.valueProperty().addListener((o, ov, nv) -> { + params.edgeOpacity = clamp01(nv.doubleValue()); + fireParamsChanged(); + }); + + return gp; + } + + private HBox buildActionsBar() { + Button resetBtn = new Button("Reset Style"); + resetBtn.setOnAction(e -> fireOnRoot(new GraphEvent(GraphEvent.GRAPH_STYLE_RESET_DEFAULTS, null))); + + HBox box = new HBox(10, resetBtn); + box.setAlignment(Pos.CENTER_RIGHT); + return box; + } + + // --------------------------------------------------------------------- + // GUI-sync wiring (model → UI) + // --------------------------------------------------------------------- + + private void wireGuiSyncHandlers() { + if (scene == null) return; + + // Coarse hydrate + scene.addEventHandler(GraphEvent.SET_STYLE_GUI, e -> { + GraphStyleParams p = (GraphStyleParams) e.object; + if (p == null) return; + isUpdatingFromGuiSync = true; + try { + // assign directly, no copyInto() + params.nodeColor = p.nodeColor; + params.nodeRadius = p.nodeRadius; + params.nodeOpacity = clamp01(p.nodeOpacity); + params.edgeColor = p.edgeColor; + params.edgeWidth = p.edgeWidth; + params.edgeOpacity = clamp01(p.edgeOpacity); + syncControlsFromParams(); + } finally { + isUpdatingFromGuiSync = false; + } + }); + + // Fine-grained + scene.addEventHandler(GraphEvent.SET_NODE_COLOR_GUI, e -> { + Color c = (Color) e.object; + if (c == null) return; + isUpdatingFromGuiSync = true; + try { + if (!Objects.equals(nodeColorPicker.getValue(), c)) nodeColorPicker.setValue(c); + params.nodeColor = c; + } finally { + isUpdatingFromGuiSync = false; + } + }); + + scene.addEventHandler(GraphEvent.SET_NODE_RADIUS_GUI, e -> { + Double v = (Double) e.object; + if (v == null) return; + isUpdatingFromGuiSync = true; + try { + if (!Objects.equals(nodeRadiusSpinner.getValue(), v)) nodeRadiusSpinner.getValueFactory().setValue(v); + params.nodeRadius = v; + } finally { + isUpdatingFromGuiSync = false; + } + }); + + scene.addEventHandler(GraphEvent.SET_NODE_OPACITY_GUI, e -> { + Double v = (Double) e.object; + if (v == null) return; + isUpdatingFromGuiSync = true; + try { + if (differs(nodeOpacitySlider.getValue(), v)) nodeOpacitySlider.setValue(v); + params.nodeOpacity = clamp01(v); + } finally { + isUpdatingFromGuiSync = false; + } + }); + + scene.addEventHandler(GraphEvent.SET_EDGE_COLOR_GUI, e -> { + Color c = (Color) e.object; + if (c == null) return; + isUpdatingFromGuiSync = true; + try { + if (!Objects.equals(edgeColorPicker.getValue(), c)) edgeColorPicker.setValue(c); + params.edgeColor = c; + } finally { + isUpdatingFromGuiSync = false; + } + }); + + scene.addEventHandler(GraphEvent.SET_EDGE_WIDTH_GUI, e -> { + Double v = (Double) e.object; + if (v == null) return; + isUpdatingFromGuiSync = true; + try { + if (!Objects.equals(edgeWidthSpinner.getValue(), v)) edgeWidthSpinner.getValueFactory().setValue(v); + params.edgeWidth = v; + } finally { + isUpdatingFromGuiSync = false; + } + }); + + scene.addEventHandler(GraphEvent.SET_EDGE_OPACITY_GUI, e -> { + Double v = (Double) e.object; + if (v == null) return; + isUpdatingFromGuiSync = true; + try { + if (differs(edgeOpacitySlider.getValue(), v)) edgeOpacitySlider.setValue(v); + params.edgeOpacity = clamp01(v); + } finally { + isUpdatingFromGuiSync = false; + } + }); + } + + // --------------------------------------------------------------------- + // Behavior + // --------------------------------------------------------------------- + + private void fireParamsChanged() { + if (isUpdatingFromGuiSync) return; // don't echo during GUI hydration + GraphStyleParams snap = snapshotParams(); + fireOnRoot(new GraphEvent(GraphEvent.GRAPH_STYLE_PARAMS_CHANGED, snap)); + } + + private void syncControlsFromParams() { + if (!Objects.equals(nodeColorPicker.getValue(), params.nodeColor)) + nodeColorPicker.setValue(params.nodeColor); + if (!Objects.equals(nodeRadiusSpinner.getValue(), params.nodeRadius)) + nodeRadiusSpinner.getValueFactory().setValue(params.nodeRadius); + if (differs(nodeOpacitySlider.getValue(), params.nodeOpacity)) + nodeOpacitySlider.setValue(params.nodeOpacity); + + if (!Objects.equals(edgeColorPicker.getValue(), params.edgeColor)) + edgeColorPicker.setValue(params.edgeColor); + if (!Objects.equals(edgeWidthSpinner.getValue(), params.edgeWidth)) + edgeWidthSpinner.getValueFactory().setValue(params.edgeWidth); + if (differs(edgeOpacitySlider.getValue(), params.edgeOpacity)) + edgeOpacitySlider.setValue(params.edgeOpacity); + } + + private GraphStyleParams snapshotParams() { + GraphStyleParams p = new GraphStyleParams(); + p.nodeColor = nodeColorPicker.getValue(); + p.nodeRadius = nodeRadiusSpinner.getValue(); + p.nodeOpacity = clamp01(nodeOpacitySlider.getValue()); + p.edgeColor = edgeColorPicker.getValue(); + p.edgeWidth = edgeWidthSpinner.getValue(); + p.edgeOpacity = clamp01(edgeOpacitySlider.getValue()); + return p; + } + + private void fireOnRoot(javafx.event.Event evt) { + if (scene != null && scene.getRoot() != null) scene.getRoot().fireEvent(evt); + else this.fireEvent(evt); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static GridPane formGrid() { + GridPane gp = new GridPane(); + gp.setHgap(8); + gp.setVgap(6); + gp.setAlignment(Pos.TOP_LEFT); + + ColumnConstraints c0 = new ColumnConstraints(); + c0.setPercentWidth(40); + + ColumnConstraints c1 = new ColumnConstraints(); + c1.setPercentWidth(60); + c1.setHgrow(Priority.ALWAYS); + + gp.getColumnConstraints().addAll(c0, c1); + return gp; + } + + private static void addRow(GridPane gp, int row, String label, javafx.scene.Node control) { + Label l = new Label(label); + gp.add(l, 0, row); + + if (control instanceof Spinner || control instanceof ComboBox) { + GridPane.setHgrow(control, Priority.NEVER); + } else { + GridPane.setHgrow(control, Priority.ALWAYS); + if (control instanceof Control c) { + c.setMaxWidth(Double.MAX_VALUE); + } + } + gp.add(control, 1, row); + } + + private static VBox titledBox(String title, javafx.scene.Node content) { + Label t = new Label(title); + t.getStyleClass().add("section-title"); + VBox box = new VBox(6, t, new Separator(), content); + box.setPadding(new Insets(4, 2, 6, 2)); + return box; + } + + private static void styleSpinner(Spinner spinner) { + spinner.setPrefWidth(SPINNER_PREF_WIDTH); + spinner.setMaxWidth(SPINNER_PREF_WIDTH); + spinner.setMinWidth(Region.USE_PREF_SIZE); + } + + private static void styleColorPicker(ColorPicker cp) { + cp.setPrefWidth(COLOR_PICKER_PREF_WIDTH); + cp.setMaxWidth(COLOR_PICKER_PREF_WIDTH); + cp.setMinWidth(Region.USE_PREF_SIZE); + } + + private static boolean differs(double a, double b) { + return Math.abs(a - b) > 1e-9; + } + + private static double clamp01(double v) { + return Math.max(0.0, Math.min(1.0, v)); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/HoverNode.java b/src/main/java/edu/jhuapl/trinity/javafx/components/HoverNode.java index 80aba51c..933391fc 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/HoverNode.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/HoverNode.java @@ -10,28 +10,29 @@ /** * a node which displays a value on hover, but is otherwise empty + * * @author Sean Phillips */ public class HoverNode extends StackPane { - HoverNode(int index, Double value) { + HoverNode(int index, Double value) { // setPrefSize(15, 15); - Label label = new Label("Vector(" + index + ") = " + value); - label.getStyleClass().addAll("default-color0", "chart-line-symbol", "chart-series-line"); - label.setStyle("-fx-font-size: 20; -fx-font-weight: bold;"); - label.setPrefSize(Label.USE_PREF_SIZE, Label.USE_PREF_SIZE); - label.setTranslateY(-30); //Move label 25 pixels up - Color ALICEBLUETRANS = new Color(0.9411765f, 0.972549f, 1.0f, 0.3); - label.setBackground(new Background(new BackgroundFill(ALICEBLUETRANS, CornerRadii.EMPTY, Insets.EMPTY))); + Label label = new Label("Vector(" + index + ") = " + value); + label.getStyleClass().addAll("default-color0", "chart-line-symbol", "chart-series-line"); + label.setStyle("-fx-font-size: 20; -fx-font-weight: bold;"); + label.setPrefSize(Label.USE_PREF_SIZE, Label.USE_PREF_SIZE); + label.setTranslateY(-30); //Move label 25 pixels up + Color ALICEBLUETRANS = new Color(0.9411765f, 0.972549f, 1.0f, 0.3); + label.setBackground(new Background(new BackgroundFill(ALICEBLUETRANS, CornerRadii.EMPTY, Insets.EMPTY))); - setOnMouseEntered(e -> { - label.getStyleClass().add("onHover"); - getChildren().setAll(label); - toFront(); - }); - setOnMouseExited(e -> { - label.getStyleClass().remove("onHover"); - getChildren().clear(); - }); - } - } \ No newline at end of file + setOnMouseEntered(e -> { + label.getStyleClass().add("onHover"); + getChildren().setAll(label); + toFront(); + }); + setOnMouseExited(e -> { + label.getStyleClass().remove("onHover"); + getChildren().clear(); + }); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java new file mode 100644 index 00000000..cff4f686 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java @@ -0,0 +1,652 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.utils.MatrixViewUtil; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Tooltip; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; + +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +/** + * MatrixHeatmapView + * ----------------- + * Reusable JavaFX control to render a 2D numeric matrix as a heatmap. + * - Renders on Canvas (fast, lightweight). + * - Sequential and diverging palettes. + * - Auto or fixed range mapping. + * - Optional color legend (with optional title). + * - Axis labels (top / left). + * - Hover tooltips with (row, col, value). + * - Click callback with cell picking. + *

+ * Public API is intentionally generic so it can be reused by multiple Trinity tools. + * + * @author Sean Phillips + */ +public final class MatrixHeatmapView extends BorderPane { + + // -------------------- Types -------------------- + + public enum ValueMode {RAW, ABS_VALUE} + + public enum ScaleMode {AUTO, FIXED} + + /** + * Color palette styles. + */ + public enum PaletteKind { + SEQUENTIAL, // low -> high mapped 0..1 + DIVERGING // < center on cool, > center on warm (center maps to mid color) + } + + /** + * Simple click payload. + */ + public static final class MatrixClick { + public final int row; + public final int col; + public final double value; + + public MatrixClick(int row, int col, double value) { + this.row = row; + this.col = col; + this.value = value; + } + } + + // -------------------- State -------------------- + + private double[][] matrix = new double[0][0]; // row-major [rows][cols] + private String[] rowLabels = new String[0]; // optional + private String[] colLabels = new String[0]; // optional + + // Rendering & layout knobs + private final Canvas canvas = new Canvas(); + private final Insets padding = new Insets(6, 8, 8, 8); + + // Reusable node for text measurement to avoid per-call allocations + private final Text measureText = new Text(); + + // Axis label styling + private Font labelFont = Font.font("Arial", 12); + private double labelMargin = 4.0; + + // Legend controls + private boolean showLegend = true; + private double legendWidth = 16.0; + private double legendGap = 8.0; + private String legendTitle = null; // short, optional; rendered above legend inside canvas + + // Palette & range + private PaletteKind paletteKind = PaletteKind.SEQUENTIAL; + private boolean autoRange = true; + private Double fixedMin = null; + private Double fixedMax = null; + private Double divergingCenter = 0.0; // for DIVERGING only + + // Interaction + private Consumer onCellClick = null; + private final Tooltip hoverTip = new Tooltip(); + private final DecimalFormat df = new DecimalFormat("0.####"); + + // Cached draw metrics + private double contentX; + private double contentY; + private double contentW; + private double contentH; + + // Label behavior + private boolean compactColumnLabels = true; // default: strip "Comp " → just the index for columns + + // -------------------- Construction -------------------- + + public MatrixHeatmapView() { + setPadding(padding); + setCenter(canvas); + + // Resize canvas when control size changes + InvalidationListener resizer = new InvalidationListener() { + @Override + public void invalidated(Observable observable) { + layoutAndDraw(); + } + }; + widthProperty().addListener(resizer); + heightProperty().addListener(resizer); + + // Mouse events for hover + click + Tooltip.install(canvas, hoverTip); + canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMove); + canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> hoverTip.hide()); + canvas.addEventHandler(MouseEvent.MOUSE_CLICKED, this::onMouseClick); + + // Initial size preference (can be overridden by parent) + setPrefSize(640, 420); + canvas.setWidth(getPrefWidth()); + canvas.setHeight(getPrefHeight()); + } + + // -------------------- Public API -------------------- + + /** + * Set a new matrix. Null or empty → clears view. + */ + public void setMatrix(double[][] values) { + if (values == null || values.length == 0 || values[0].length == 0) { + this.matrix = new double[0][0]; + this.rowLabels = new String[0]; + this.colLabels = new String[0]; + } else { + int rows = values.length; + int cols = values[0].length; + this.matrix = new double[rows][cols]; + for (int r = 0; r < rows; r++) { + if (values[r].length != cols) { + throw new IllegalArgumentException("Matrix rows must have equal length."); + } + System.arraycopy(values[r], 0, this.matrix[r], 0, cols); + } + if (rowLabels.length != rows) { + rowLabels = defaultLabels(rows, "r"); + } + if (colLabels.length != cols) { + colLabels = defaultLabels(cols, "c"); + } + } + layoutAndDraw(); + } + + /** + * Convenience: accept List>. + */ + public void setMatrix(List> values) { + if (values == null || values.isEmpty()) { + setMatrix((double[][]) null); + return; + } + int rows = values.size(); + int cols = values.get(0).size(); + double[][] arr = new double[rows][cols]; + for (int r = 0; r < rows; r++) { + List row = values.get(r); + if (row.size() != cols) { + throw new IllegalArgumentException("Matrix rows must have equal length."); + } + for (int c = 0; c < cols; c++) { + Double v = row.get(c); + arr[r][c] = (v == null || Double.isNaN(v)) ? 0.0 : v.doubleValue(); + } + } + setMatrix(arr); + } + + public List getRowLabels() { + return Arrays.asList(rowLabels); + } + + /** + * Optional row labels (length must equal rows). + */ + public void setRowLabels(List labels) { + if (labels == null || labels.isEmpty()) { + this.rowLabels = new String[rows()]; + } else { + if (labels.size() != rows()) { + throw new IllegalArgumentException("Row labels length must match matrix rows."); + } + this.rowLabels = labels.toArray(new String[0]); + } + layoutAndDraw(); + } + + /** + * Optional column labels (length must equal cols). Applies compacting if enabled. + */ + public void setColLabels(List labels) { + if (labels == null || labels.isEmpty()) { + this.colLabels = new String[cols()]; + } else { + if (labels.size() != cols()) { + throw new IllegalArgumentException("Column labels length must match matrix cols."); + } + String[] incoming = labels.toArray(new String[0]); + if (compactColumnLabels) { + String[] compacted = new String[incoming.length]; + for (int i = 0; i < incoming.length; i++) { + compacted[i] = MatrixViewUtil.compactCompLabel(incoming[i]); + } + this.colLabels = compacted; + } else { + this.colLabels = incoming; + } + } + layoutAndDraw(); + } + + /** + * Control whether columns compact "Comp 7" → "7". Default: true. + */ + public void setCompactColumnLabels(boolean compact) { + this.compactColumnLabels = compact; + // Re-apply compaction on currently loaded labels + if (this.colLabels != null && this.colLabels.length > 0) { + String[] out = new String[this.colLabels.length]; + for (int i = 0; i < this.colLabels.length; i++) { + out[i] = compact ? MatrixViewUtil.compactCompLabel(this.colLabels[i]) : this.colLabels[i]; + } + this.colLabels = out; + layoutAndDraw(); + } + } + + /** + * Set a uniform label prefix if none provided yet (e.g., "F0..F(n-1)"). + */ + public void setDefaultAxisLabels(String rowPrefix, String colPrefix) { + if (rows() > 0 && (rowLabels == null || rowLabels.length != rows())) { + rowLabels = defaultLabels(rows(), rowPrefix == null ? "r" : rowPrefix); + } + if (cols() > 0 && (colLabels == null || colLabels.length != cols())) { + colLabels = defaultLabels(cols(), colPrefix == null ? "c" : colPrefix); + } + layoutAndDraw(); + } + + /** + * Optional legend title (short); rendered above the legend inside the canvas. + */ + public void setLegendTitle(String title) { + this.legendTitle = (title == null || title.isBlank()) ? null : title.trim(); + layoutAndDraw(); + } + + /** + * Sequential palette. + */ + public void useSequentialPalette() { + this.paletteKind = PaletteKind.SEQUENTIAL; + layoutAndDraw(); + } + + /** + * Diverging palette (values below/above center split colors). + */ + public void useDivergingPalette(double center) { + this.paletteKind = PaletteKind.DIVERGING; + this.divergingCenter = center; + layoutAndDraw(); + } + + /** + * Auto-range on (min/max derived from current matrix). + */ + public void setAutoRange(boolean on) { + this.autoRange = on; + layoutAndDraw(); + } + + /** + * Fixed range mapping. + */ + public void setFixedRange(double vmin, double vmax) { + if (!(vmax > vmin)) { + throw new IllegalArgumentException("vmax must be greater than vmin."); + } + this.autoRange = false; + this.fixedMin = vmin; + this.fixedMax = vmax; + layoutAndDraw(); + } + + /** + * Show or hide the color legend bar. + */ + public void setShowLegend(boolean show) { + this.showLegend = show; + layoutAndDraw(); + } + + /** + * Set axis label font. + */ + public void setLabelFont(Font f) { + if (f != null) { + this.labelFont = f; + layoutAndDraw(); + } + } + + /** + * Set click handler for cell picks. + */ + public void setOnCellClick(Consumer handler) { + this.onCellClick = handler; + } + + /** + * Return a defensive copy of the matrix (for external reads). + */ + public double[][] getMatrixCopy() { + int r = rows(), c = cols(); + double[][] out = new double[r][c]; + for (int i = 0; i < r; i++) { + System.arraycopy(matrix[i], 0, out[i], 0, c); + } + return out; + } + + // -------------------- Internals: sizing + render -------------------- + + private int rows() { + return matrix.length; + } + + private int cols() { + return (rows() == 0) ? 0 : matrix[0].length; + } + + private void layoutAndDraw() { + double w = Math.max(1, getWidth() - getInsets().getLeft() - getInsets().getRight()); + double h = Math.max(1, getHeight() - getInsets().getTop() - getInsets().getBottom()); + canvas.setWidth(w); + canvas.setHeight(h); + draw(); + } + + private void draw() { + GraphicsContext g = canvas.getGraphicsContext2D(); + double w = canvas.getWidth(); + double h = canvas.getHeight(); + + // Clear + g.setFill(Color.web("#1E1E1E")); + g.fillRect(0, 0, w, h); + + int r = rows(); + int c = cols(); + if (r == 0 || c == 0) { + drawCenteredMessage(g, w, h, "No data"); + return; + } + + // Compute layout boxes: + double topLabelH = estimateTopLabelHeight(g, c); + double leftLabelW = estimateLeftLabelWidth(g, r); + double legendW = showLegend ? (legendWidth + legendGap) : 0.0; + + // Content box (matrix area) + contentX = Math.ceil(leftLabelW + labelMargin); + contentY = Math.ceil(topLabelH + labelMargin); + contentW = Math.max(1, w - contentX - legendW - 6); + contentH = Math.max(1, h - contentY - 6); + + // Value mapping range + double vmin, vmax; + if (autoRange) { + double[] mm = MatrixViewUtil.minMax(matrix); + vmin = mm[0]; + vmax = mm[1]; + if (!(vmax > vmin)) vmax = vmin + 1e-9; + } else { + vmin = fixedMin == null ? 0.0 : fixedMin.doubleValue(); + vmax = fixedMax == null ? 1.0 : fixedMax.doubleValue(); + if (!(vmax > vmin)) vmax = vmin + 1e-9; + } + + // Draw matrix + drawMatrix(g, vmin, vmax); + + // Axes + drawAxisLabels(g); + + // Legend + if (showLegend) { + drawLegend(g, vmin, vmax); + } + } + + private void drawMatrix(GraphicsContext g, double vmin, double vmax) { + int r = rows(); + int c = cols(); + if (r == 0 || c == 0) return; + + double cellW = contentW / c; + double cellH = contentH / r; + + g.setImageSmoothing(false); + + for (int i = 0; i < r; i++) { + double y = contentY + i * cellH; + for (int j = 0; j < c; j++) { + double x = contentX + j * cellW; + double v = MatrixViewUtil.sanitize(matrix[i][j]); + + Color col = switch (paletteKind) { + case SEQUENTIAL -> MatrixViewUtil.sequentialColor(MatrixViewUtil.norm01(v, vmin, vmax)); + case DIVERGING -> MatrixViewUtil.divergingColor(v, vmin, vmax, + divergingCenter != null ? divergingCenter.doubleValue() : 0.0); + }; + + g.setFill(col); + g.fillRect(x, y, Math.ceil(cellW), Math.ceil(cellH)); + } + } + + // Subtle grid lines + g.setStroke(Color.color(0, 0, 0, 0.15)); + g.setLineWidth(1.0); + for (int j = 0; j <= c; j++) { + double x = contentX + j * cellW + 0.5; + g.strokeLine(x, contentY, x, contentY + contentH); + } + for (int i = 0; i <= r; i++) { + double y = contentY + i * cellH + 0.5; + g.strokeLine(contentX, y, contentX + contentW, y); + } + } + + private void drawAxisLabels(GraphicsContext g) { + g.setFill(Color.LIGHTGRAY); + g.setFont(labelFont); + g.setTextAlign(TextAlignment.CENTER); + g.setTextBaseline(VPos.TOP); + + int r = rows(); + int c = cols(); + if (r == 0 || c == 0) return; + + double cellW = contentW / c; + double cellH = contentH / r; + + // Top (columns) + for (int j = 0; j < c; j++) { + String lbl = (j < colLabels.length && colLabels[j] != null) ? colLabels[j] : ("c" + j); + double x = contentX + (j + 0.5) * cellW; + double y = contentY - labelMargin - textHeight(g, lbl); + g.fillText(lbl, x, Math.max(0, y)); + } + + // Left (rows) + g.setTextAlign(TextAlignment.RIGHT); + g.setTextBaseline(VPos.CENTER); + for (int i = 0; i < r; i++) { + String lbl = (i < rowLabels.length && rowLabels[i] != null) ? rowLabels[i] : ("r" + i); + double x = contentX - labelMargin; + double y = contentY + (i + 0.5) * cellH; + g.fillText(lbl, Math.max(0, x), y); + } + } + + private void drawLegend(GraphicsContext g, double vmin, double vmax) { + double x0 = contentX + contentW + legendGap; + double y0 = contentY; + double w = legendWidth; + double h = contentH; + + // Optional title above legend + if (legendTitle != null) { + g.save(); + g.setFill(Color.LIGHTGRAY); + g.setFont(labelFont); + g.setTextAlign(TextAlignment.CENTER); + g.setTextBaseline(VPos.TOP); + double th = textHeight(g, legendTitle); + double ty = Math.max(0, y0 - Math.min(th + 2, 18)); + g.fillText(legendTitle, x0 + w * 0.5, ty); + g.restore(); + } + + // Vertical gradient ramp + int steps = 200; + for (int k = 0; k < steps; k++) { + double t = 1.0 - (k / (double) (steps - 1)); // top (max) → bottom (min) + double v = vmin + t * (vmax - vmin); + Color col = switch (paletteKind) { + case SEQUENTIAL -> MatrixViewUtil.sequentialColor(MatrixViewUtil.norm01(v, vmin, vmax)); + case DIVERGING -> MatrixViewUtil.divergingColor(v, vmin, vmax, + divergingCenter != null ? divergingCenter.doubleValue() : 0.0); + }; + g.setFill(col); + double yy = y0 + k * (h / steps); + g.fillRect(x0, yy, w, (h / steps) + 1); + } + + // Legend border + g.setStroke(Color.gray(0.2)); + g.setLineWidth(1.0); + g.strokeRect(x0 + 0.5, y0 + 0.5, w - 1, h - 1); + + // Ticks & labels inside the bar + double pad = 2.0; + g.setFill(Color.LIGHTGRAY); + g.setFont(labelFont); + g.setTextAlign(TextAlignment.LEFT); + g.setTextBaseline(VPos.CENTER); + + g.fillText(df.format(vmax), x0 + pad, y0 + pad + textHeight(g, "X") * 0.5); + g.fillText(df.format(vmin), x0 + pad, y0 + h - pad - textHeight(g, "X") * 0.5); + + if (paletteKind == PaletteKind.DIVERGING) { + double t = MatrixViewUtil.norm01( + divergingCenter != null ? divergingCenter.doubleValue() : 0.0, vmin, vmax); + double y = y0 + (1.0 - t) * h; + g.fillText(df.format( + divergingCenter != null ? divergingCenter.doubleValue() : 0.0), x0 + pad, y); + } + } + + private void drawCenteredMessage(GraphicsContext g, double w, double h, String msg) { + g.setFill(Color.gray(0.7)); + g.setFont(Font.font("Arial", 16)); + g.setTextAlign(TextAlignment.CENTER); + g.setTextBaseline(VPos.CENTER); + g.fillText(msg == null ? "" : msg, w * 0.5, h * 0.5); + } + + // -------------------- Interaction -------------------- + + private void onMouseMove(MouseEvent e) { + int r = rows(), c = cols(); + if (r == 0 || c == 0) return; + + int[] rc = pickCell(e.getX(), e.getY()); + if (rc == null) { + hoverTip.hide(); + return; + } + int i = rc[0], j = rc[1]; + double v = MatrixViewUtil.sanitize(matrix[i][j]); + + String rl = (i < rowLabels.length && rowLabels[i] != null) ? rowLabels[i] : ("r" + i); + String cl = (j < colLabels.length && colLabels[j] != null) ? colLabels[j] : ("c" + j); + + hoverTip.setText(rl + ", " + cl + " = " + df.format(v)); + if (!hoverTip.isShowing()) { + hoverTip.show(canvas, e.getScreenX() + 12, e.getScreenY() + 12); + } else { + hoverTip.setX(e.getScreenX() + 12); + hoverTip.setY(e.getScreenY() + 12); + } + } + + private void onMouseClick(MouseEvent e) { + if (onCellClick == null) return; + int[] rc = pickCell(e.getX(), e.getY()); + if (rc == null) return; + int i = rc[0], j = rc[1]; + onCellClick.accept(new MatrixClick(i, j, MatrixViewUtil.sanitize(matrix[i][j]))); + } + + /** + * Return {row, col} or null if mouse is outside content box. + */ + private int[] pickCell(double x, double y) { + int r = rows(), c = cols(); + if (r == 0 || c == 0) return null; + if (x < contentX || y < contentY || x > contentX + contentW || y > contentY + contentH) return null; + + double cellW = contentW / c; + double cellH = contentH / r; + + int j = (int) Math.floor((x - contentX) / cellW); + int i = (int) Math.floor((y - contentY) / cellH); + + if (i < 0 || i >= r || j < 0 || j >= c) return null; + return new int[]{i, j}; + } + + // -------------------- Utilities -------------------- + + private static String[] defaultLabels(int n, String prefix) { + String p = (prefix == null || prefix.isBlank()) ? "a" : prefix.trim(); + String[] out = new String[n]; + for (int i = 0; i < n; i++) out[i] = p + i; + return out; + } + + private double estimateTopLabelHeight(GraphicsContext g, int cols) { + if (cols == 0) return 0; + g.setFont(labelFont); + return Math.ceil(textHeight(g, "X") + 2); + } + + private double estimateLeftLabelWidth(GraphicsContext g, int rows) { + if (rows == 0) return 0; + g.setFont(labelFont); + double maxW = 0; + int sample = Math.min(rows, 40); + int step = Math.max(1, rows / sample); + for (int i = 0; i < rows; i += step) { + String lbl = (i < rowLabels.length && rowLabels[i] != null) ? rowLabels[i] : ("r" + i); + double w = textWidth(g, lbl); + if (w > maxW) maxW = w; + } + return Math.ceil(maxW + 2); + } + + private double textWidth(GraphicsContext g, String s) { + if (s == null || s.isEmpty()) return 0.0; + measureText.setText(s); + measureText.setFont(g.getFont()); + return measureText.getLayoutBounds().getWidth(); + } + + private double textHeight(GraphicsContext g, String s) { + measureText.setText((s == null || s.isEmpty()) ? "Mg" : s); + measureText.setFont(g.getFont()); + return measureText.getLayoutBounds().getHeight(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java new file mode 100644 index 00000000..25bd6fed --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -0,0 +1,755 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.utils.statistics.CanonicalGridPolicy; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.HeatmapThumbnailView; +import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine; +import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine.BatchResult; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; +import edu.jhuapl.trinity.utils.statistics.PairGridPane; +import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; +import edu.jhuapl.trinity.utils.statistics.RecipeIo; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.embed.swing.SwingFXUtils; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.ComboBox; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioButton; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.Slider; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.SplitPane; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.stage.FileChooser; + +import javax.imageio.ImageIO; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * PairwiseJpdfView + * - Search/Sort are hosted in the PairGridPane header. + * - Toolbar is slim: Run, Reset, Collapse, Actions (MenuButton), Appearance (MenuButton). + * - Actions menu contains Save/Load/Clear and streaming controls (Flush N/ms, Incremental sort). + * - Appearance menu controls visual/display options applied via PairGridPane. + */ +public class PairwiseJpdfView extends BorderPane { + + // Core GUI nodes + private final PairwiseJpdfConfigPanel configPanel; + private final PairGridPane gridPane; + + // Split layout + private final SplitPane splitPane; + private final VBox controlsBox; + private final BorderPane topBar; + private final HBox topLeft; + private final Label progressLabel; + private double lastDividerPos = 0.36; + + // Buttons (visible in toolbar) + private final Button cfgResetBtn; + private final Button cfgRunBtn; + private final Button collapseBtn; + + // MenuButtons + private final MenuButton actionsBtn; + private final MenuButton appearanceBtn; + + // Streaming controls (inside Actions menu) + private final Spinner flushSizeSpinner; + private final Spinner flushIntervalSpinner; + private final CheckBox incrementalSortCheck; + + // Search / Sort controls (in grid header) + private final TextField searchField = new TextField(); + private final ComboBox sortCombo = new ComboBox<>(); + private final CheckBox sortAscCheck = new CheckBox("Asc"); + + // Appearance controls (inside Appearance menu) + private final ComboBox paletteCombo = new ComboBox<>(); + private final CheckBox legendCheck = new CheckBox("Legend"); + private final ColorPicker backgroundPicker = new ColorPicker(Color.BLACK); + + private final ToggleGroup rangeModeGroup = new ToggleGroup(); + private final RadioButton rangeAutoBtn = new RadioButton("Auto"); + private final RadioButton rangeGlobalBtn = new RadioButton("Global"); + private final RadioButton rangeFixedBtn = new RadioButton("Fixed"); + private final TextField vminField = new TextField(); + private final TextField vmaxField = new TextField(); + private final Button normalizeAllBtn = new Button("Normalize All"); + + private final Slider gammaSlider = + new Slider(0.1, 5.0, 1.0); + private final Spinner clipLowPctSp = + new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 20.0, 0.0, 0.5)); + private final Spinner clipHighPctSp = + new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 20.0, 0.0, 0.5)); + private final CheckBox smoothingCheck = new CheckBox("Smooth"); + + private final Spinner canvasWSp = + new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(60, 800, 180, 10)); + private final Spinner canvasHSp = + new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(60, 800, 140, 10)); + + // Cache (always non-null) + private final DensityCache cache; + + // State + private List cohortA = new ArrayList<>(); + private List cohortB = new ArrayList<>(); + private String cohortALabel = "A"; + private String cohortBLabel = "B"; + + private Consumer toastHandler; + private Consumer onCellClickHandler; + + private volatile Thread workerThread; + + public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { + this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); + this.configPanel = (configPanel != null) ? configPanel : new PairwiseJpdfConfigPanel(); + + // Grid (right) + this.gridPane = new PairGridPane(); + this.gridPane.setOnCellClick(click -> { + if (onCellClickHandler != null) onCellClickHandler.accept(click.item); + }); + // Wire export hook (context menu -> PNG) + this.gridPane.setOnExportRequest(req -> { + try { + FileChooser fc = new FileChooser(); + fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG Image", "*.png")); + fc.setInitialFileName(safeFilename(req.item.xLabel + "_" + req.item.yLabel) + ".png"); + File f = fc.showSaveDialog(getScene() != null ? getScene().getWindow() : null); + if (f == null) return; + WritableImage img = req.image; + ImageIO.write(SwingFXUtils.fromFXImage(img, null), "png", f); + toast("Saved " + f.getName(), false); + } catch (Exception ex) { + toast("Export failed: " + ex.getMessage(), true); + } + }); + + // Buttons from config panel + cfgResetBtn = this.configPanel.getResetButton(); + cfgRunBtn = this.configPanel.getRunButton(); + + collapseBtn = new Button("Collapse Controls"); + collapseBtn.setOnAction(e -> toggleControlsCollapsed()); + + // Streaming controls (used inside the Actions menu) + flushSizeSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 512, 6, 1)); + flushSizeSpinner.setEditable(true); + flushSizeSpinner.getEditor().setPrefColumnCount(4); + flushSizeSpinner.setMaxWidth(Region.USE_PREF_SIZE); + + flushIntervalSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 2000, 80, 10)); + flushIntervalSpinner.setEditable(true); + flushIntervalSpinner.getEditor().setPrefColumnCount(5); + flushIntervalSpinner.setMaxWidth(Region.USE_PREF_SIZE); + + incrementalSortCheck = new CheckBox("Incremental sort"); + + // --- Build the compact grid header with Search + Sort --- + searchField.setPromptText("Search: e.g., x3 vs y*"); + searchField.setPrefColumnCount(18); + sortCombo.getItems().addAll("Score", "i", "j", "label"); + sortCombo.setValue("Score"); + sortAscCheck.setSelected(false); + + HBox searchSort = new HBox(8, + new Label("Search"), searchField, + new Label("Sort"), sortCombo, sortAscCheck + ); + searchSort.setAlignment(Pos.CENTER_LEFT); + searchSort.setPadding(new Insets(4, 8, 2, 8)); + + // --- Actions (MenuButton) --- + actionsBtn = buildActionsMenu(); + + // --- Appearance (MenuButton) --- + appearanceBtn = buildAppearanceMenu(); + + // --- Slim toolbar --- + topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn, appearanceBtn); + topLeft.setAlignment(Pos.CENTER_LEFT); + topLeft.setPadding(new Insets(6, 8, 6, 8)); + + progressLabel = new Label(""); + BorderPane.setAlignment(progressLabel, Pos.CENTER_RIGHT); + BorderPane.setMargin(progressLabel, new Insets(6, 8, 6, 8)); + + topBar = new BorderPane(); + topBar.setLeft(topLeft); + topBar.setRight(progressLabel); + setTop(topBar); + + // Left controls box + controlsBox = new VBox(); + controlsBox.getChildren().addAll(this.configPanel); + VBox.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); + controlsBox.setMinWidth(0); + controlsBox.setPrefWidth(390); + + // SplitPane + splitPane = new SplitPane(controlsBox, gridPane); + splitPane.setOrientation(Orientation.HORIZONTAL); + splitPane.setDividerPositions(lastDividerPos); + splitPane.setBackground(Background.EMPTY); + setCenter(splitPane); + BorderPane.setMargin(splitPane, new Insets(6)); + + // Wiring + this.configPanel.setOnRun(this::runWithRecipe); + searchField.textProperty().addListener((obs, ov, nv) -> applySearchPredicate(nv)); + sortCombo.valueProperty().addListener((obs, ov, nv) -> applySortComparator()); + sortAscCheck.selectedProperty().addListener((obs, ov, nv) -> applySortComparator()); + applySortComparator(); + + // Inject header (search/sort row; appearance is in toolbar menu) + VBox gridHeader = new VBox(4, searchSort); + gridHeader.setAlignment(Pos.CENTER_LEFT); + gridHeader.setPadding(new Insets(4, 8, 2, 8)); + gridPane.setHeader(gridHeader); + } + + // Build the consolidated Actions menu + private MenuButton buildActionsMenu() { + MenuButton mb = new MenuButton("Actions"); + + // Basic actions + MenuItem saveItem = new MenuItem("Save Config…"); + saveItem.setOnAction(e -> doSaveRecipe()); + MenuItem loadItem = new MenuItem("Load Config…"); + loadItem.setOnAction(e -> doLoadRecipe()); + MenuItem clearItem = new MenuItem("Clear Grid"); + clearItem.setOnAction(e -> gridPane.clearItems()); + + // Streaming controls inside menu (keep menu open while adjusting) + CheckMenuItem incSortItem = new CheckMenuItem("Incremental sort"); + incSortItem.selectedProperty().bindBidirectional(incrementalSortCheck.selectedProperty()); + + HBox flushNBox = new HBox(6, new Label("Flush N"), flushSizeSpinner); + flushNBox.setAlignment(Pos.CENTER_LEFT); + CustomMenuItem flushNItem = new CustomMenuItem(flushNBox); + flushNItem.setHideOnClick(false); + + HBox flushMsBox = new HBox(6, new Label("Flush ms"), flushIntervalSpinner); + flushMsBox.setAlignment(Pos.CENTER_LEFT); + CustomMenuItem flushMsItem = new CustomMenuItem(flushMsBox); + flushMsItem.setHideOnClick(false); + + mb.getItems().addAll( + saveItem, loadItem, clearItem, + new SeparatorMenuItem(), + incSortItem, flushNItem, flushMsItem + ); + return mb; + } + + private MenuButton buildAppearanceMenu() { + MenuButton mb = new MenuButton("Appearance"); + + // ---- Palette & Legend & Background ---- + // (paletteCombo, legendCheck, backgroundPicker are class fields) + paletteCombo.getItems().setAll( + HeatmapThumbnailView.PaletteKind.SEQUENTIAL, + HeatmapThumbnailView.PaletteKind.DIVERGING + ); + if (paletteCombo.getValue() == null) { + paletteCombo.setValue(HeatmapThumbnailView.PaletteKind.SEQUENTIAL); + } + if (backgroundPicker.getValue() == null) { + backgroundPicker.setValue(Color.BLACK); + } + + // Per-scheme selectors (locals; applied to all current tiles via forEachView) + ComboBox seqSchemeCombo = new ComboBox<>(); + seqSchemeCombo.getItems().addAll(HeatmapThumbnailView.PaletteScheme.values()); + seqSchemeCombo.setValue(HeatmapThumbnailView.PaletteScheme.VIRIDIS); + + ComboBox divSchemeCombo = new ComboBox<>(); + divSchemeCombo.getItems().addAll(HeatmapThumbnailView.PaletteScheme.values()); + divSchemeCombo.setValue(HeatmapThumbnailView.PaletteScheme.BLUE_YELLOW); + + GridPane paletteGrid = new GridPane(); + paletteGrid.setHgap(8); + paletteGrid.setVgap(6); + paletteGrid.setPadding(new Insets(6, 8, 6, 8)); + paletteGrid.add(new Label("Palette"), 0, 0); + paletteGrid.add(paletteCombo, 1, 0); + paletteGrid.add(new Label("Seq Scheme"), 0, 1); + paletteGrid.add(seqSchemeCombo, 1, 1); + paletteGrid.add(new Label("Div Scheme"), 0, 2); + paletteGrid.add(divSchemeCombo, 1, 2); + paletteGrid.add(new Label("Legend"), 0, 3); + paletteGrid.add(legendCheck, 1, 3); + paletteGrid.add(new Label("Background"), 0, 4); + paletteGrid.add(backgroundPicker, 1, 4); + + CustomMenuItem paletteItem = new CustomMenuItem(paletteGrid); + paletteItem.setHideOnClick(false); + + // ---- Range (Auto / Global / Fixed) ---- + // (rangeAutoBtn, rangeGlobalBtn, rangeFixedBtn, vminField, vmaxField, normalizeAllBtn are class fields) + rangeAutoBtn.setToggleGroup(rangeModeGroup); + rangeGlobalBtn.setToggleGroup(rangeModeGroup); + rangeFixedBtn.setToggleGroup(rangeModeGroup); + if (rangeModeGroup.getSelectedToggle() == null) rangeAutoBtn.setSelected(true); + + vminField.setPromptText("vmin"); + vmaxField.setPromptText("vmax"); + vminField.setPrefColumnCount(6); + vmaxField.setPrefColumnCount(6); + vminField.setDisable(true); + vmaxField.setDisable(true); + + GridPane rangeGrid = new GridPane(); + rangeGrid.setHgap(8); + rangeGrid.setVgap(6); + rangeGrid.setPadding(new Insets(6, 8, 6, 8)); + HBox modeRow = new HBox(10, rangeAutoBtn, rangeGlobalBtn, rangeFixedBtn); + rangeGrid.add(new Label("Range"), 0, 0); + rangeGrid.add(modeRow, 1, 0); + HBox fixedRow = new HBox(6, new Label("Min"), vminField, new Label("Max"), vmaxField, normalizeAllBtn); + rangeGrid.add(new Label("Fixed"), 0, 1); + rangeGrid.add(fixedRow, 1, 1); + + CustomMenuItem rangeItem = new CustomMenuItem(rangeGrid); + rangeItem.setHideOnClick(false); + + // ---- Contrast & Rendering basics ---- + // (gammaSlider, smoothingCheck are class fields) + gammaSlider.setMin(0.1); + gammaSlider.setMax(5.0); + gammaSlider.setValue(1.0); + + // Low-end alpha: lets background show through low values (v ~ vmin) + Slider lowEndAlpha = new Slider(0.0, 1.0, 1.0); + lowEndAlpha.setBlockIncrement(0.05); + + HBox gammaRow = new HBox(8, new Label("Gamma"), gammaSlider); + HBox alphaRow = new HBox(8, new Label("Low-end α"), lowEndAlpha); + VBox contrastBox = new VBox(8, gammaRow, alphaRow, smoothingCheck); + contrastBox.setPadding(new Insets(6, 8, 6, 8)); + CustomMenuItem contrastItem = new CustomMenuItem(contrastBox); + contrastItem.setHideOnClick(false); + + // ---- Canvas size (thumbnail sizing) ---- + // (canvasWSp, canvasHSp are class fields and initialized in the constructor) + HBox sizeRow = new HBox(6, new Label("Canvas W"), canvasWSp, new Label("H"), canvasHSp); + VBox renderBox = new VBox(8, sizeRow); + renderBox.setPadding(new Insets(6, 8, 6, 8)); + CustomMenuItem renderItem = new CustomMenuItem(renderBox); + renderItem.setHideOnClick(false); + + // ---- Add blocks to menu ---- + mb.getItems().addAll(paletteItem, rangeItem, contrastItem, renderItem); + + // ---- Wiring to grid ---- + paletteCombo.valueProperty().addListener((o, ov, nv) -> gridPane.setPalette(nv)); + legendCheck.selectedProperty().addListener((o, ov, nv) -> gridPane.setLegendVisible(nv)); + backgroundPicker.valueProperty().addListener((o, ov, nv) -> gridPane.setBackgroundColor(nv)); + + // Scheme changes apply to all existing views + seqSchemeCombo.valueProperty().addListener((o, ov, nv) -> + gridPane.forEachView(v -> v.setSequentialScheme(nv))); + divSchemeCombo.valueProperty().addListener((o, ov, nv) -> + gridPane.forEachView(v -> v.setDivergingScheme(nv))); + + smoothingCheck.setSelected(true); + smoothingCheck.selectedProperty().addListener((o, ov, nv) -> gridPane.setImageSmoothing(nv)); + gammaSlider.valueProperty().addListener((o, ov, nv) -> gridPane.setGamma(nv.doubleValue())); + lowEndAlpha.valueProperty().addListener((o, ov, nv) -> + gridPane.forEachView(v -> v.setMinAlphaAtVmin(nv.doubleValue()))); + + rangeModeGroup.selectedToggleProperty().addListener((o, ov, nv) -> { + boolean fixed = nv == rangeFixedBtn; + boolean global = nv == rangeGlobalBtn; + vminField.setDisable(!fixed); + vmaxField.setDisable(!fixed); + if (fixed) { + double mn = parseDoubleSafe(vminField.getText(), Double.NaN); + double mx = parseDoubleSafe(vmaxField.getText(), Double.NaN); + if (Double.isFinite(mn) && Double.isFinite(mx)) { + gridPane.setRangeModeFixed(mn, mx); + } + } else if (global) { + gridPane.setRangeModeGlobal(); + } else { + gridPane.setRangeModeAuto(); + } + }); + + normalizeAllBtn.setOnAction(e -> { + PairGridPane.Range r = gridPane.computeGlobalRange(); + if (r != null) { + vminField.setText(String.format("%.6g", r.vmin)); + vmaxField.setText(String.format("%.6g", r.vmax)); + rangeFixedBtn.setSelected(true); + gridPane.setRangeModeFixed(r.vmin, r.vmax); + } + }); + + vminField.setOnAction(e -> applyFixedRangeFromFields()); + vmaxField.setOnAction(e -> applyFixedRangeFromFields()); + + canvasWSp.valueProperty().addListener((o, ov, nv) -> gridPane.setCellSize(nv, canvasHSp.getValue())); + canvasHSp.valueProperty().addListener((o, ov, nv) -> gridPane.setCellSize(canvasWSp.getValue(), nv)); + + return mb; + } + + private void applyFixedRangeFromFields() { + if (rangeModeGroup.getSelectedToggle() != rangeFixedBtn) return; + double mn = parseDoubleSafe(vminField.getText(), Double.NaN); + double mx = parseDoubleSafe(vmaxField.getText(), Double.NaN); + if (Double.isFinite(mn) && Double.isFinite(mx)) { + gridPane.setRangeModeFixed(mn, mx); + } + } + + // --- Public API --- + + public void setCohortA(List vectors, String label) { + this.cohortA = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); + if (label != null && !label.isBlank()) this.cohortALabel = label; + } + + public void setCohortB(List vectors, String label) { + this.cohortB = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); + if (label != null && !label.isBlank()) this.cohortBLabel = label; + } + + public void setToastHandler(Consumer handler) { + this.toastHandler = handler; + } + + public void setOnCellClick(Consumer handler) { + this.onCellClickHandler = handler; + } + + // --- Run --- + + public void runWithRecipe(JpdfRecipe recipe) { + if (recipe == null) { + toast("No recipe provided.", true); + return; + } + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty. Load or set vectors first.", true); + return; + } + + final CanonicalGridPolicy policy = CanonicalGridPolicy.get( + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default" + ); + if (policy == null) { + toast("Failed to determine canonical grid policy for recipe.", true); + return; + } + + gridPane.clearItems(); + setControlsDisabled(true); + setProgressText("Preparing…"); + toast("Computing pairwise densities…", false); + + AtomicInteger completed = new AtomicInteger(0); + final int flushN = safeSpinnerInt(flushSizeSpinner, 6); + final int flushMs = safeSpinnerInt(flushIntervalSpinner, 80); + final boolean doIncrementalSort = incrementalSortCheck.isSelected(); + + final java.util.function.Consumer onResult = job -> { + if (job == null || job.density == null) return; + + String xLbl = (job.i >= 0) ? ("Comp " + job.i) : "X"; + String yLbl = (job.j >= 0) ? ("Comp " + job.j) : "Y"; + + PairGridPane.PairItem.Builder b = PairGridPane.PairItem + .newBuilder(xLbl, yLbl) + .indices(job.i >= 0 ? job.i : null, job.j >= 0 ? job.j : null) + .from(job.density, false, true) + .palette(HeatmapThumbnailView.PaletteKind.SEQUENTIAL) + .autoRange(true) + .showLegend(false); + + if (job.rank != null) b.score(job.rank.score); + PairGridPane.PairItem item = b.build(); + + gridPane.addItemStreaming(item, flushN, flushMs, doIncrementalSort); + + int n = completed.incrementAndGet(); + if ((n % flushN) == 0) { + Platform.runLater(() -> setProgressText("Loaded " + n + "…")); + } + }; + + final JpdfRecipe runRecipe = recipe; + + Task task = new Task<>() { + @Override + protected Void call() { + JpdfBatchEngine.BatchResult batch; + long start = System.currentTimeMillis(); + try { + switch (runRecipe.getPairSelection()) { + case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, runRecipe, policy, PairwiseJpdfView.this.cache, onResult); + default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, runRecipe, policy, PairwiseJpdfView.this.cache, onResult); + } + } catch (Throwable t) { + final String msg = "Batch failed: " + t.getClass().getSimpleName() + + " - " + String.valueOf(t.getMessage()); + Platform.runLater(() -> { + setControlsDisabled(false); + setProgressText(""); + toast(msg, true); + }); + return null; + } + + final BatchResult finalBatch = batch; + final int finalCompleted = completed.get(); + final long wall = System.currentTimeMillis() - start; + + Platform.runLater(() -> { + gridPane.sortByScoreDescending(); + int total = Math.max(finalBatch.submittedPairs, finalCompleted); + setProgressText("Loaded " + finalCompleted + " / " + total); + setControlsDisabled(false); + toast("Batch complete: " + finalCompleted + + " surfaces; cacheHits=" + finalBatch.cacheHits + + "; wall=" + wall + " ms.", false); + }); + return null; + } + }; + + workerThread = new Thread(task, "PairwiseJpdfView-Worker"); + workerThread.setDaemon(true); + workerThread.start(); + } + + // --- Helpers --- + + private void toggleControlsCollapsed() { + double pos = splitPane.getDividerPositions()[0]; + if (pos > 0.02 || controlsBox.isVisible()) { + lastDividerPos = pos; + controlsBox.setManaged(false); + controlsBox.setVisible(false); + splitPane.setDividerPositions(0.0); + collapseBtn.setText("Expand Controls"); + } else { + controlsBox.setManaged(true); + controlsBox.setVisible(true); + double restore = (lastDividerPos <= 0.02) ? 0.36 : lastDividerPos; + splitPane.setDividerPositions(restore); + collapseBtn.setText("Collapse Controls"); + } + } + + private int safeSpinnerInt(Spinner sp, int fallback) { + try { + sp.commitValue(); + Integer v = sp.getValue(); + return (v == null) ? fallback : v; + } catch (Throwable ignore) { + return fallback; + } + } + + private static double parseDoubleSafe(String s, double def) { + try { + return Double.parseDouble(s); + } catch (Exception ex) { + return def; + } + } + + private static String safeFilename(String s) { + String base = (s == null ? "image" : s); + return base.replaceAll("[^a-zA-Z0-9._-]+", "_"); + } + + private void setControlsDisabled(boolean disabled) { + topBar.setDisable(disabled); + configPanel.setDisable(disabled); + } + + private void setProgressText(String text) { + progressLabel.setText(text == null ? "" : text); + } + + public void toast(String msg, boolean isError) { + if (toastHandler != null) { + toastHandler.accept((isError ? "[Error] " : "") + msg); + } else { + Platform.runLater(() -> { + this.getScene().getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), + isError ? Color.RED : Color.LIGHTGREEN)); + }); + } + } + + public PairwiseJpdfConfigPanel getConfigPanel() { + return configPanel; + } + + public PairGridPane getGridPane() { + return gridPane; + } + + public SplitPane getSplitPane() { + return splitPane; + } + + public void cancelRun() { + Thread t = workerThread; + if (t != null && t.isAlive()) { + t.interrupt(); + } + } + + public List getCohortA() { + return cohortA; + } + + public List getCohortB() { + return cohortB; + } + + public String getCohortALabel() { + return cohortALabel; + } + + public String getCohortBLabel() { + return cohortBLabel; + } + + // --- Search / Sort helpers --- + private void applySearchPredicate(String query) { + final String q = (query == null) ? "" : query.trim(); + if (q.isEmpty()) { + gridPane.setFilter(null); + return; + } + final String regex = globToRegex(q.toLowerCase()); + gridPane.setFilter(pi -> { + String a = (pi.xLabel == null ? "" : pi.xLabel.toLowerCase()); + String b = (pi.yLabel == null ? "" : pi.yLabel.toLowerCase()); + String both = (a + " vs " + b); + return a.matches(regex) || b.matches(regex) || both.matches(regex); + }); + } + + private void applySortComparator() { + final String key = sortCombo.getValue(); + final boolean asc = sortAscCheck.isSelected(); + gridPane.setSorter((a, b) -> { + int s; + switch (key) { + case "i" -> { + int ai = a.iIndex == null ? Integer.MIN_VALUE : a.iIndex; + int bi = b.iIndex == null ? Integer.MIN_VALUE : b.iIndex; + s = Integer.compare(ai, bi); + } + case "j" -> { + int aj = a.jIndex == null ? Integer.MIN_VALUE : a.jIndex; + int bj = b.jIndex == null ? Integer.MIN_VALUE : b.jIndex; + s = Integer.compare(aj, bj); + } + case "label" -> { + String al = (a.xLabel + " | " + a.yLabel); + String bl = (b.xLabel + " | " + b.yLabel); + s = al.compareToIgnoreCase(bl); + } + default -> { + double sa = (a.score == null ? Double.NEGATIVE_INFINITY : a.score); + double sb = (b.score == null ? Double.NEGATIVE_INFINITY : b.score); + s = Double.compare(sa, sb); + } + } + return asc ? s : -s; + }); + } + + private static String globToRegex(String glob) { + StringBuilder sb = new StringBuilder(); + for (char c : glob.toCharArray()) { + switch (c) { + case '*' -> sb.append(".*"); + case '?' -> sb.append('.'); + case '.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> sb.append('\\').append(c); + default -> sb.append(c); + } + } + return "^" + sb + "$"; + } + + // --- Save/Load handlers --- + private void doSaveRecipe() { + try { + JpdfRecipe r = configPanel.snapshotRecipe(); + FileChooser fc = new FileChooser(); + fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); + fc.setInitialFileName(r.getName().replaceAll("\\s+", "_") + ".json"); + var f = fc.showSaveDialog(getScene() != null ? getScene().getWindow() : null); + if (f == null) return; + try (FileOutputStream fos = new FileOutputStream(f)) { + RecipeIo.write(fos, r); + } + toast("Saved recipe to " + f.getName(), false); + } catch (Exception ex) { + toast("Save failed: " + ex.getMessage(), true); + } + } + + private void doLoadRecipe() { + try { + FileChooser fc = new FileChooser(); + fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); + var f = fc.showOpenDialog(getScene() != null ? getScene().getWindow() : null); + if (f == null) return; + JpdfRecipe r; + try (FileInputStream fis = new FileInputStream(f)) { + r = RecipeIo.read(fis); + } + configPanel.applyRecipe(r); + toast("Loaded recipe from " + f.getName(), false); + } catch (Exception ex) { + toast("Load failed: " + ex.getMessage(), true); + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java new file mode 100644 index 00000000..edc15a91 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -0,0 +1,1149 @@ +package edu.jhuapl.trinity.javafx.components; + +import com.github.trinity.supermds.SuperMDS; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.MatrixHeatmapView.MatrixClick; +import edu.jhuapl.trinity.javafx.components.dialogs.SyntheticDataDialog; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.BuildGraphFromMatrixTask; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; +import edu.jhuapl.trinity.utils.graph.MatrixToGraphAdapter; +import edu.jhuapl.trinity.utils.statistics.AxisParams; +import edu.jhuapl.trinity.utils.statistics.CanonicalGridPolicy; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.DivergenceComputer.DivergenceMetric; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixConfigPanel; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixConfigPanel.Mode; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixConfigPanel.Request; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixEngine; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixEngine.MatrixResult; +import edu.jhuapl.trinity.utils.statistics.StatisticEngine; +import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory.SyntheticMatrix; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.SplitPane; +import javafx.scene.control.TextInputDialog; +import javafx.scene.layout.Background; +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.stage.StageStyle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * PairwiseMatrixView + * ------------------ + * Hosts config (left) + heatmap (right) + toolbar. + * Runs PairwiseMatrixEngine to produce Similarity/Divergence matrices. + * Also renders per-cell JPDF surfaces (Similarity) or ΔPDF (Divergence) into Hypersurface3DPane. + *

+ * This version reuses your existing Jpdf pipeline and supports synthetic matrices by + * adopting fallback cohorts (carried by SyntheticMatrix) when needed. + */ +public final class PairwiseMatrixView extends BorderPane { + private static final Logger LOG = LoggerFactory.getLogger(PairwiseMatrixView.class); + + // --- Core GUI nodes + private final PairwiseMatrixConfigPanel configPanel; + private final MatrixHeatmapView heatmap; + + // --- Split layout + private final SplitPane splitPane; + private final VBox controlsBox; + private final BorderPane topBar; + private final HBox topLeft; + private final Label progressLabel; + + // --- Collapse/expand state + private boolean controlsCollapsed = false; + private double lastDividerPos = 0.36; + private double expandedControlsPrefWidth = 390.0; + + // --- Buttons (visible in toolbar) + private final Button cfgRunBtn; + private final Button cfgResetBtn; + private final Button collapseBtn; + + // --- Actions (for future extensibility) + private final MenuButton actionsBtn; + + // --- State (data + cache) + private List cohortA = new ArrayList<>(); + private List cohortB = new ArrayList<>(); + private String cohortALabel = "A"; + private String cohortBLabel = "B"; + private final DensityCache cache; + + // If user loaded a synthetic matrix, we may get underlying vectors—stash them here. + private List fallbackCohortA; + private List fallbackCohortB; + + // What the current heatmap represents (used to pick weight mapping for graph) + MatrixToGraphAdapter.MatrixKind currentMatrixKind = MatrixToGraphAdapter.MatrixKind.SIMILARITY; + + // --- Hooks + private Consumer toastHandler; + private Consumer onCellClickHandler; + + // Remember the last executed configuration + private PairwiseMatrixConfigPanel.Request lastRequest; + + public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache cache) { + this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); + this.configPanel = (configPanel != null) ? configPanel : new PairwiseMatrixConfigPanel(); + this.heatmap = new MatrixHeatmapView(); + + // Compact column labels: show indices only + this.heatmap.setCompactColumnLabels(true); + + // Cell click → render JPDF (Similarity) or ΔPDF (Divergence) + this.heatmap.setOnCellClick(click -> { + if (click == null) return; + + try { + Request effective = getLastRequestOrBuild(); + if (effective == null) { + toast("No configuration to render (run once or provide defaults).", true); + } else { + // Adopt fallback cohorts if needed based on the expected mode + ensureCohortsFromFallbackIfAvailable(effective.mode == Mode.DIVERGENCE); + + if (effective.mode == Mode.SIMILARITY) { + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty and no synthetic cohorts were provided.", true); + return; + } + renderPdfForCellUsingEngine(click.row, click.col, effective); + } else { + if (cohortA == null || cohortA.isEmpty() || cohortB == null || cohortB.isEmpty()) { + toast("Cohorts A/B are empty and no synthetic cohorts were provided.", true); + return; + } + renderDeltaPdfForCellUsingEngine(click.row, click.col, effective); + } + } + } catch (Throwable t) { + toast("Cell render failed: " + t.getClass().getSimpleName() + ": " + String.valueOf(t.getMessage()), true); + } + + // still notify any external listener you set via setOnCellClick(...) + if (onCellClickHandler != null) onCellClickHandler.accept(click); + }); + + // Buttons from config panel + this.cfgRunBtn = this.configPanel.getRunButton(); + this.cfgResetBtn = this.configPanel.getResetButton(); + + // Toolbar collapse toggle + collapseBtn = new Button("Collapse Controls"); + collapseBtn.setOnAction(e -> toggleControlsCollapsed()); + + // Actions menu + actionsBtn = buildActionsMenu(); + + // Slim toolbar (left = buttons; right = progress text) + topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn); + topLeft.setAlignment(Pos.CENTER_LEFT); + topLeft.setPadding(new Insets(6, 8, 6, 8)); + + progressLabel = new Label(""); + BorderPane.setAlignment(progressLabel, Pos.CENTER_RIGHT); + BorderPane.setMargin(progressLabel, new Insets(6, 8, 6, 8)); + + topBar = new BorderPane(); + topBar.setLeft(topLeft); + topBar.setRight(progressLabel); + setTop(topBar); + + // Left controls box + controlsBox = new VBox(); + controlsBox.getChildren().addAll(this.configPanel); + VBox.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); + + // Make both sides freely resizable + controlsBox.setMinWidth(0); + controlsBox.setPrefWidth(expandedControlsPrefWidth); + controlsBox.setMaxWidth(Region.USE_COMPUTED_SIZE); + + heatmap.setMinWidth(0); + heatmap.setMaxWidth(Double.MAX_VALUE); + + // SplitPane: left controls, right heatmap + splitPane = new SplitPane(controlsBox, heatmap); + splitPane.setOrientation(Orientation.HORIZONTAL); + splitPane.setDividerPositions(lastDividerPos); + splitPane.setBackground(Background.EMPTY); + setCenter(splitPane); + BorderPane.setMargin(splitPane, new Insets(6)); + + // Let SplitPane resize both children + SplitPane.setResizableWithParent(controlsBox, Boolean.TRUE); + SplitPane.setResizableWithParent(heatmap, Boolean.TRUE); + + // Track divider pos only when expanded; also remember an approximate expanded width + if (!splitPane.getDividers().isEmpty()) { + splitPane.getDividers().get(0).positionProperty().addListener((obs, ov, nv) -> { + if (!controlsCollapsed && nv != null) { + double p = nv.doubleValue(); + if (p > 0.02 && p < 0.98) { + lastDividerPos = p; + double total = splitPane.getWidth() <= 0 ? getWidth() : splitPane.getWidth(); + if (total > 0) { + expandedControlsPrefWidth = Math.max(220, Math.round(p * total)); + controlsBox.setPrefWidth(expandedControlsPrefWidth); + } + } + } + }); + } + + // Wiring + this.configPanel.setOnRun(this::runWithRequest); + } + + public PairwiseMatrixView() { + this(null, null); + } + + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- + + public void setCohortA(List vectors, String label) { + this.cohortA = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); + if (label != null && !label.isBlank()) this.cohortALabel = label; + } + + public void setCohortB(List vectors, String label) { + this.cohortB = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); + if (label != null && !label.isBlank()) this.cohortBLabel = label; + } + + public List getCohortA() { + return cohortA; + } + + public List getCohortB() { + return cohortB; + } + + public String getCohortALabel() { + return cohortALabel; + } + + public String getCohortBLabel() { + return cohortBLabel; + } + + public PairwiseMatrixConfigPanel getConfigPanel() { + return configPanel; + } + + public MatrixHeatmapView getHeatmapView() { + return heatmap; + } + + public SplitPane getSplitPane() { + return splitPane; + } + + /** + * send toast/status to Terminal/Console integration. + */ + public void setToastHandler(Consumer handler) { + this.toastHandler = handler; + } + + /** + * observe cell clicks from the heatmap. + */ + public void setOnCellClick(Consumer handler) { + this.onCellClickHandler = handler; + } + + // --------------------------------------------------------------------- + // Core run logic + // --------------------------------------------------------------------- + + private void runWithRequest(Request req) { + if (req == null) { + toast("No request provided.", true); + return; + } + this.lastRequest = req; // cache config used for "Run" + + // Build recipe used by engine runs (for matrix building; per-click builds a single-pair recipe) + JpdfRecipe recipe = buildRecipeFromRequest(req); + + // Heatmap visual settings from request + heatmap.setShowLegend(req.showLegend); + if (req.paletteDiverging) heatmap.useDivergingPalette(req.divergingCenter != null ? req.divergingCenter : 0.0); + else heatmap.useSequentialPalette(); + + if (req.autoRange) heatmap.setAutoRange(true); + else heatmap.setFixedRange( + req.fixedMin != null ? req.fixedMin : 0.0, + req.fixedMax != null ? req.fixedMax : 1.0 + ); + + setControlsDisabled(true); + setProgressText("Running…"); + + new Thread(() -> { + long start = System.currentTimeMillis(); + try { + MatrixResult result; + + if (req.mode == Mode.SIMILARITY) { + if (cohortA == null || cohortA.isEmpty()) { + Platform.runLater(() -> { + setControlsDisabled(false); + setProgressText(""); + toast("Cohort A is empty. Load or set vectors first.", true); + }); + return; + } + result = PairwiseMatrixEngine.computeSimilarityMatrix( + cohortA, recipe, req.includeDiagonal + ); + } else { + // DIVERGENCE + if (cohortA == null || cohortA.isEmpty()) { + Platform.runLater(() -> { + setControlsDisabled(false); + setProgressText(""); + toast("Cohort A is empty. Load or set vectors first.", true); + }); + return; + } + if (cohortB == null || cohortB.isEmpty()) { + Platform.runLater(() -> { + setControlsDisabled(false); + setProgressText(""); + toast("Cohort B is empty. Load or set vectors first.", true); + }); + return; + } + result = PairwiseMatrixEngine.computeDivergenceMatrix( + cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), cache + ); + } + + long wall = System.currentTimeMillis() - start; + + Platform.runLater(() -> { + if (result == null || result.matrix == null || result.matrix.length == 0) { + heatmap.setMatrix((double[][]) null); + toast("No matrix produced.", true); + } else { + heatmap.setMatrix(result.matrix); + heatmap.setRowLabels(result.labels); + heatmap.setColLabels(result.labels); + currentMatrixKind = (req.mode == Mode.SIMILARITY) + ? MatrixToGraphAdapter.MatrixKind.SIMILARITY + : MatrixToGraphAdapter.MatrixKind.DIVERGENCE; + toast((result.title != null ? result.title + " — " : "") + + "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); + } + setProgressText(""); + setControlsDisabled(false); + }); + } catch (Throwable t) { + Platform.runLater(() -> { + setControlsDisabled(false); + setProgressText(""); + toast("Matrix run failed: " + t.getClass().getSimpleName() + " - " + String.valueOf(t.getMessage()), true); + }); + } + }, "PairwiseMatrixView-Worker").start(); + } + + public PairwiseMatrixConfigPanel.Request getLastRequestOrBuild() { + if (lastRequest != null) return lastRequest; + return (configPanel != null ? configPanel.snapshotRequest() : null); + } + + // --------------------------------------------------------------------- + // Cell-click → JPDF surface (Similarity) + // --------------------------------------------------------------------- + + public void renderPdfForCellUsingEngine(int i, int j, Request req) { + if (req == null) { + toast("No configuration available to build JPDF.", true); + return; + } + // Adopt fallbacks if present + ensureCohortsFromFallbackIfAvailable(false); + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty.", true); + return; + } + + // (1) Build a single-pair whitelist recipe from the current request + JpdfRecipe recipe = buildSinglePairWhitelistRecipe(req, i, j); + + // (2) Resolve policy + cache + CanonicalGridPolicy policy = resolveCanonicalPolicy(recipe); + DensityCache useCache = resolveDensityCache(); + + // (3) Run for just this pair (Similarity uses A only) + JpdfBatchEngine.runWhitelistPairs( + cohortA, + recipe, + policy, + useCache, + (pair) -> { + GridDensityResult gdr = pair.density; + List> zGrid = gdr.pdfAsListGrid(); + getScene().getRoot().fireEvent(new HypersurfaceGridEvent( + HypersurfaceGridEvent.RENDER_PDF, + zGrid, + gdr.getxCenters(), + gdr.getyCenters(), + "Comp " + Math.min(i, j) + " | Comp " + Math.max(i, j) + " (PDF)" + )); + } + ); + + toast("Rendered JPDF for pair (" + i + "," + j + ").", false); + } + + // --------------------------------------------------------------------- + // Cell-click → ΔPDF surface (Divergence: A − B) + // --------------------------------------------------------------------- + private void renderDeltaPdfForCellUsingEngine(int i, int j, Request req) { + if (req == null) { + toast("No configuration available to build ΔPDF.", true); + return; + } + // Adopt fallbacks if present + ensureCohortsFromFallbackIfAvailable(true); + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty.", true); + return; + } + if (cohortB == null || cohortB.isEmpty()) { + toast("Cohort B is empty.", true); + return; + } + + // (1) Pair-specific recipe (single pair via WHITELIST) + JpdfRecipe recipe = buildSinglePairWhitelistRecipe(req, i, j); + + // (2) Resolve policy + cache + CanonicalGridPolicy policy = resolveCanonicalPolicy(recipe); + DensityCache useCache = resolveDensityCache(); + + // (3) Run A and B separately, grab the single job result from each batch + JpdfBatchEngine.BatchResult batchA = + JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, useCache); + JpdfBatchEngine.BatchResult batchB = + JpdfBatchEngine.runWhitelistPairs(cohortB, recipe, policy, useCache); + + if (batchA.jobs.isEmpty() || batchB.jobs.isEmpty() + || batchA.jobs.get(0).density == null || batchB.jobs.get(0).density == null) { + toast("Could not compute ΔPDF for (" + i + "," + j + ").", true); + return; + } + + GridDensityResult a = batchA.jobs.get(0).density; + GridDensityResult b = batchB.jobs.get(0).density; + + // (4) Align grids if necessary; then out = A.pdf - B.pdf + double[] ax = a.getxCenters(), ay = a.getyCenters(); + double[] bx = b.getxCenters(), by = b.getyCenters(); + + List> out; + if (sameCenters(ax, bx) && sameCenters(ay, by)) { + out = subtract(a.pdfAsListGrid(), b.pdfAsListGrid()); + } else { + out = subtract(a.pdfAsListGrid(), bilinearResample(b.pdfAsListGrid(), bx, by, ax, ay)); + } + + // (5) Render into Hypersurface3DPane (your working event) + getScene().getRoot().fireEvent(new HypersurfaceGridEvent( + HypersurfaceGridEvent.RENDER_PDF, + out, + ax, + ay, + "Comp " + Math.min(i, j) + " | Comp " + Math.max(i, j) + " (ΔPDF A−B)" + )); + + toast("Rendered ΔPDF (A−B) for pair (" + i + "," + j + ").", false); + } + + // --------------------------------------------------------------------- + // Graph build actions + // --------------------------------------------------------------------- + + private MenuButton buildActionsMenu() { + MenuButton mb = new MenuButton("Actions"); + + MenuItem clearItem = new MenuItem("Clear Matrix"); + clearItem.setOnAction(e -> heatmap.setMatrix((double[][]) null)); + + MenuItem buildGraphFromSim = new MenuItem("Build Graph from Similarity"); + buildGraphFromSim.setOnAction(e -> triggerGraphBuild(MatrixToGraphAdapter.MatrixKind.SIMILARITY)); + + MenuItem buildGraphFromDiv = new MenuItem("Build Graph from Divergence"); + buildGraphFromDiv.setOnAction(e -> triggerGraphBuild(MatrixToGraphAdapter.MatrixKind.DIVERGENCE)); + + // Synthetic cohort tools + MenuItem copyAToB = new MenuItem("Set Cohort B = Cohort A (copy)"); + copyAToB.setOnAction(e -> { + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty.", true); + return; + } + setCohortB(new ArrayList<>(cohortA), cohortALabel + " (copy)"); + toast("Cohort B set to copy of Cohort A.", false); + }); + + MenuItem splitA = new MenuItem("Split Cohort A into A/B halves"); + splitA.setOnAction(e -> { + if (cohortA == null || cohortA.size() < 2) { + toast("Need ≥2 vectors in Cohort A to split.", true); + return; + } + int mid = cohortA.size() / 2; + List first = new ArrayList<>(cohortA.subList(0, mid)); + List second = new ArrayList<>(cohortA.subList(mid, cohortA.size())); + setCohortA(first, cohortALabel + " (early)"); + setCohortB(second, cohortALabel + " (late)"); + toast("Split A into A(early)/B(late).", false); + }); + + MenuItem bRandomGaussian = new MenuItem("Set Cohort B = Gaussian noise (match N, D)"); + bRandomGaussian.setOnAction(e -> { + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty.", true); + return; + } + List b = synthGaussianLikeA(cohortA, 0.0, 1.0); + setCohortB(b, "Gaussian"); + toast("Cohort B generated: Gaussian(μ=0,σ=1)", false); + }); + + MenuItem bRandomNoise = new MenuItem("Set Cohort B = Uniform noise (match N, D)"); + bRandomNoise.setOnAction(e -> { + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty.", true); + return; + } + List b = synthUniformLikeA(cohortA, -1.0, 1.0); + setCohortB(b, "Uniform[-1,1]"); + toast("Cohort B generated: Uniform[-1,1]", false); + }); + + MenuItem bShiftOneComp = new MenuItem("Set Cohort B = A shifted on one component…"); + bShiftOneComp.setOnAction(e -> promptShiftOneComponent()); + + // Label-derived cohorts + MenuItem cohortsByLabel = new MenuItem("Derive A/B by vector label…"); + cohortsByLabel.setOnAction(e -> promptDeriveCohortsByLabel()); + + // Synthetic Data Controller + MenuItem syntheticDataDialogItem = new MenuItem("Synthetic Data Controller"); + syntheticDataDialogItem.setOnAction(e -> { + SyntheticDataDialog dlg = new SyntheticDataDialog(); + dlg.initOwner(getScene().getWindow()); + dlg.initStyle(StageStyle.TRANSPARENT); + DialogPane dialogPane = dlg.getDialogPane(); + dialogPane.setBackground(Background.EMPTY); + dialogPane.getScene().setFill(Color.TRANSPARENT); + dlg.showAndWait().ifPresent(res -> { + switch (res.kind()) { + case SIMILARITY_MATRIX, DIVERGENCE_MATRIX -> loadSyntheticMatrix(res.matrix()); + case COHORTS -> setCohorts(res.cohorts().cohortA, "A", res.cohorts().cohortB, "B"); + } + }); + }); + + mb.getItems().addAll(buildGraphFromSim, buildGraphFromDiv, + new SeparatorMenuItem(), copyAToB, splitA, bShiftOneComp, cohortsByLabel, + new SeparatorMenuItem(), bRandomGaussian, bRandomNoise, + new SeparatorMenuItem(), syntheticDataDialogItem, clearItem); + + return mb; + } + + public void triggerGraphBuildWithParams(GraphLayoutParams layout) { + double[][] M = currentMatrix(); + if (M == null || M.length == 0) { + toast("No matrix to build a graph from.", true); + return; + } + + var labels = currentRowLabels(); + var scene = getScene(); + if (scene == null) { + toast("Scene not ready.", true); + return; + } + + // Choose weight mapping here (not in params) + MatrixToGraphAdapter.WeightMode wmode = + (currentMatrixKind == MatrixToGraphAdapter.MatrixKind.SIMILARITY) + ? MatrixToGraphAdapter.WeightMode.DIRECT + : MatrixToGraphAdapter.WeightMode.INVERSE_FOR_DIVERGENCE; + + SuperMDS.Params mdsParams = new SuperMDS.Params(); + mdsParams.outputDim = 3; + + setControlsDisabled(true); + setProgressText("Building graph…"); + + BuildGraphFromMatrixTask task = new BuildGraphFromMatrixTask( + scene, M, labels, currentMatrixKind, wmode, layout, mdsParams); + + task.setOnSucceeded(ev -> { + setControlsDisabled(false); + setProgressText(""); + toast("Graph built and dispatched.", false); + }); + task.setOnFailed(ev -> { + setControlsDisabled(false); + setProgressText(""); + var t = task.getException(); + toast("Graph build failed: " + (t == null ? "unknown error" : t.getMessage()), true); + }); + + Thread th = new Thread(task, "BuildGraphFromMatrixTask"); + th.setDaemon(true); + th.start(); + } + + private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { + currentMatrixKind = kind; + GraphLayoutParams layout = new GraphLayoutParams() + .withKind(GraphLayoutParams.LayoutKind.MDS_3D) // or FORCE_FR, etc. + .withRadius(600) + .withEdgePolicy(GraphLayoutParams.EdgePolicy.KNN) + .withKnnK(8) + .withKnnSymmetrize(true) + .withMaxEdges(5000) + .withMaxDegreePerNode(32) + .withNormalizeWeights01(true); + + triggerGraphBuildWithParams(layout); + } + + // --------------------------------------------------------------------- + // Synthetic matrix & cohort helpers + // --------------------------------------------------------------------- + + /** + * Load a synthetic matrix (similarity or divergence) into the heatmap and stash fallback cohorts if provided. + */ + public void loadSyntheticMatrix(SyntheticMatrix sm) { + if (sm == null || sm.matrix == null || sm.matrix.length == 0) { + toast("Synthetic matrix is empty.", true); + heatmap.setMatrix((double[][]) null); + return; + } + + // Map factory kind to adapter kind + this.currentMatrixKind = (sm.kind == MatrixToGraphAdapter.MatrixKind.DIVERGENCE) + ? MatrixToGraphAdapter.MatrixKind.DIVERGENCE + : MatrixToGraphAdapter.MatrixKind.SIMILARITY; + + // Render the matrix + heatmap.setMatrix(sm.matrix); + if (sm.labels != null && !sm.labels.isEmpty()) { + heatmap.setRowLabels(sm.labels); + heatmap.setColLabels(sm.labels); + } else { + // fallback label set + List labels = new java.util.ArrayList<>(sm.matrix.length); + for (int i = 0; i < sm.matrix.length; i++) labels.add("F" + i); + heatmap.setRowLabels(labels); + heatmap.setColLabels(labels); + } + + // Palette + range defaults based on kind + applyHeatmapDefaultsFor(this.currentMatrixKind, sm.matrix); + + // Stash possible underlying vectors as *fallback* cohorts + this.fallbackCohortA = sm.cohortA != null ? new ArrayList<>(sm.cohortA) : null; + this.fallbackCohortB = sm.cohortB != null ? new ArrayList<>(sm.cohortB) : null; + + // If user hasn't loaded cohorts yet, adopt the fallbacks now (keeps UX simple) + if ((cohortA == null || cohortA.isEmpty()) && fallbackCohortA != null) { + setCohortA(fallbackCohortA, "A"); + } + if ((cohortB == null || cohortB.isEmpty()) && fallbackCohortB != null) { + setCohortB(fallbackCohortB, "B"); + } + + String title = (sm.title != null && !sm.title.isBlank()) ? sm.title : "Synthetic"; + toast(title + " — N=" + sm.matrix.length + " loaded.", false); + } + + /** + * Convenience: set both cohorts at once with labels. + */ + public void setCohorts(List a, String labelA, List b, String labelB) { + setCohortA(a, labelA); + setCohortB(b, labelB); + int na = (a == null ? 0 : a.size()); + int nb = (b == null ? 0 : b.size()); + toast("Cohorts set: A=" + labelA + " (" + na + "), B=" + labelB + " (" + nb + ")", false); + } + + /** + * Convenience overload with default labels. + */ + public void setCohorts(List a, List b) { + setCohorts(a, "A", b, "B"); + } + + /** + * If cohorts are empty but fallbacks are available (from synthetic data), adopt them. + */ + private void ensureCohortsFromFallbackIfAvailable(boolean needB) { + if ((cohortA == null || cohortA.isEmpty()) && fallbackCohortA != null) { + setCohortA(fallbackCohortA, (cohortALabel == null || cohortALabel.isBlank()) ? "A" : cohortALabel); + } + if (needB && (cohortB == null || cohortB.isEmpty()) && fallbackCohortB != null) { + setCohortB(fallbackCohortB, (cohortBLabel == null || cohortBLabel.isBlank()) ? "B" : cohortBLabel); + } + } + + /* ---------- internal helpers ---------- */ + + private void applyHeatmapDefaultsFor(MatrixToGraphAdapter.MatrixKind kind, double[][] M) { + // Legend ON, auto range ON + heatmap.setShowLegend(true); + heatmap.setAutoRange(true); + + // Palette: Similarity -> sequential; Divergence -> diverging centered at mid-range + if (kind == MatrixToGraphAdapter.MatrixKind.DIVERGENCE) { + double[] mm = minMax(M); + double center = (mm[0] + mm[1]) * 0.5; + heatmap.useDivergingPalette(center); + } else { + heatmap.useSequentialPalette(); + } + } + + /** + * Returns [min,max] over finite entries. + */ + private static double[] minMax(double[][] M) { + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + for (int i = 0; i < M.length; i++) { + double[] row = M[i]; + for (int j = 0; j < row.length; j++) { + double v = row[j]; + if (Double.isFinite(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + } + if (!(max > min)) { // degenerate + min = 0.0; + max = 1.0; + } + return new double[]{min, max}; + } + + private List synthGaussianLikeA(List likeA, double mean, double stddev) { + int n = likeA.size(); + int d = (likeA.get(0).getData() != null) ? likeA.get(0).getData().size() : 0; + java.util.Random rng = new java.util.Random(42); + List out = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + ArrayList row = new ArrayList<>(d); + for (int j = 0; j < d; j++) { + double z = rng.nextGaussian() * stddev + mean; + row.add(z); + } + out.add(new FeatureVector(row)); + } + return out; + } + + private List synthUniformLikeA(List likeA, double min, double max) { + int n = likeA.size(); + int d = (likeA.get(0).getData() != null) ? likeA.get(0).getData().size() : 0; + java.util.Random rng = new java.util.Random(43); + double span = max - min; + List out = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + ArrayList row = new ArrayList<>(d); + for (int j = 0; j < d; j++) { + double v = min + rng.nextDouble() * span; + row.add(v); + } + out.add(new FeatureVector(row)); + } + return out; + } + + private void promptShiftOneComponent() { + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty.", true); + return; + } + int d = (cohortA.get(0).getData() != null) ? cohortA.get(0).getData().size() : 0; + if (d == 0) { + toast("Cohort A has zero-dimensional vectors.", true); + return; + } + + TextInputDialog compDlg = new TextInputDialog("0"); + compDlg.setTitle("Shift Component"); + compDlg.setHeaderText("Shift one component in Cohort B"); + compDlg.setContentText("Component index (0.." + (d - 1) + "):"); + var compRes = compDlg.showAndWait(); + if (compRes.isEmpty()) return; + + int idx; + try { + idx = Integer.parseInt(compRes.get().trim()); + } catch (Exception ex) { + toast("Invalid component index.", true); + return; + } + if (idx < 0 || idx >= d) { + toast("Index out of range.", true); + return; + } + + TextInputDialog deltaDlg = new TextInputDialog("1.0"); + deltaDlg.setTitle("Shift Component"); + deltaDlg.setHeaderText("Shift one component in Cohort B"); + deltaDlg.setContentText("Delta to add:"); + var deltaRes = deltaDlg.showAndWait(); + if (deltaRes.isEmpty()) return; + + double delta; + try { + delta = Double.parseDouble(deltaRes.get().trim()); + } catch (Exception ex) { + toast("Invalid delta.", true); + return; + } + + List b = new ArrayList<>(cohortA.size()); + for (FeatureVector fv : cohortA) { + List src = fv.getData(); + ArrayList row = new ArrayList<>(src.size()); + for (int j = 0; j < src.size(); j++) { + double v = src.get(j); + row.add(j == idx ? v + delta : v); + } + b.add(new FeatureVector(row)); + } + setCohortB(b, cohortALabel + " (shifted comp " + idx + " by " + delta + ")"); + toast("Cohort B = A with comp " + idx + " shifted by " + delta, false); + } + + private void promptDeriveCohortsByLabel() { + if (cohortA == null || cohortA.isEmpty()) { + toast("Load a FeatureCollection (A) first; we’ll derive A/B subsets from it.", true); + return; + } + TextInputDialog aDlg = new TextInputDialog("classA"); + aDlg.setTitle("Cohorts by Label"); + aDlg.setHeaderText("Derive cohorts from vector labels in Cohort A"); + aDlg.setContentText("Label for Cohort A:"); + var aRes = aDlg.showAndWait(); + if (aRes.isEmpty()) return; + + TextInputDialog bDlg = new TextInputDialog("classB"); + bDlg.setTitle("Cohorts by Label"); + bDlg.setHeaderText("Derive cohorts from vector labels in Cohort A"); + bDlg.setContentText("Label for Cohort B:"); + var bRes = bDlg.showAndWait(); + if (bRes.isEmpty()) return; + + String la = aRes.get().trim(); + String lb = bRes.get().trim(); + if (la.isEmpty() || lb.isEmpty()) { + toast("Labels cannot be empty.", true); + return; + } + + List aList = new ArrayList<>(); + List bList = new ArrayList<>(); + + for (FeatureVector fv : cohortA) { + String lab = safeVectorLabel(fv); + if (la.equals(lab)) aList.add(fv); + else if (lb.equals(lab)) bList.add(fv); + } + + if (aList.isEmpty() || bList.isEmpty()) { + toast("No matches for one or both labels. A=" + aList.size() + ", B=" + bList.size(), true); + return; + } + + setCohortA(new ArrayList<>(aList), la); + setCohortB(new ArrayList<>(bList), lb); + toast("Derived cohorts by label: A=" + la + " (" + aList.size() + "), B=" + lb + " (" + bList.size() + ")", false); + } + + private static String safeVectorLabel(FeatureVector fv) { + try { + String lbl = fv.getLabel(); + return (lbl == null) ? "" : lbl.trim(); + } catch (Throwable t) { + return ""; + } + } + + // --------------------------------------------------------------------- + // Collapse/Expand + // --------------------------------------------------------------------- + + private void toggleControlsCollapsed() { + if (!controlsCollapsed) { + double pos = splitPane.getDividerPositions()[0]; + if (pos > 0.02 && pos < 0.98) lastDividerPos = pos; + + controlsCollapsed = true; + collapseBtn.setText("Expand Controls"); + + controlsBox.setPrefWidth(0); + controlsBox.setMaxWidth(0); + + splitPane.setDividerPositions(0.0); + splitPane.requestLayout(); + } else { + controlsCollapsed = false; + collapseBtn.setText("Collapse Controls"); + + controlsBox.setMaxWidth(Region.USE_COMPUTED_SIZE); + controlsBox.setPrefWidth(expandedControlsPrefWidth); + + final double target = (lastDividerPos > 0.02 && lastDividerPos < 0.98) ? lastDividerPos : 0.36; + Platform.runLater(() -> { + splitPane.setDividerPositions(target); + splitPane.requestLayout(); + }); + } + } + + // --------------------------------------------------------------------- + // Misc helpers + // --------------------------------------------------------------------- + + private void setControlsDisabled(boolean disabled) { + topBar.setDisable(disabled); + configPanel.setDisable(disabled); + } + + private void setProgressText(String text) { + progressLabel.setText(text == null ? "" : text); + } + + private void toast(String msg, boolean isError) { + if (toastHandler != null) { + toastHandler.accept((isError ? "[Error] " : "") + msg); + } else { + LOG.atLevel(isError ? Level.ERROR : Level.INFO).log(msg); + } + } + + private double[][] currentMatrix() { + return heatmap.getMatrixCopy(); + } + + private List currentRowLabels() { + return heatmap.getRowLabels(); + } + + private static String safeName(String s) { + String x = (s == null) ? "" : s.trim(); + return x.isEmpty() ? "PairwiseMatrix" : x; + } + + private static T nonNull(T v, T def) { + return (v != null ? v : def); + } + + // --------------------------------------------------------------------- + // Jpdf recipe builders & policy/cache resolvers + // --------------------------------------------------------------------- + + private JpdfRecipe buildRecipeFromRequest(Request req) { + // Used for matrix construction (broad run). Clicks will use the single-pair builder below. + JpdfRecipe.Builder b = JpdfRecipe.newBuilder(safeName(req.name)) + .bins(req.binsX, req.binsY) + .boundsPolicy(req.boundsPolicy) + .canonicalPolicyId(req.canonicalPolicyId == null ? "default" : req.canonicalPolicyId) + .componentPairsMode(true) + .componentIndexRange(req.componentStart, req.componentEnd) + .includeSelfPairs(req.includeDiagonal) + .orderedPairs(req.orderedPairs) + .cacheEnabled(true) + .saveThumbnails(false) + .minAvgCountPerCell(3.0); + + if (req.mode == Mode.SIMILARITY && req.similarityMetric != null) { + b.scoreMetric(req.similarityMetric); + } else { + b.scoreMetric(JpdfRecipe.ScoreMetric.PEARSON); + } + return b.build(); + } + + /** + * Build a whitelist recipe for a single (i,j) pair. + */ + private JpdfRecipe buildSinglePairWhitelistRecipe(Request req, int i, int j) { + int lo = Math.min(i, j); + int hi = Math.max(i, j); + + AxisParams x = new AxisParams(); + x.setType(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + x.setComponentIndex(lo); + + AxisParams y = new AxisParams(); + y.setType(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + y.setComponentIndex(hi); + + String name = safeName(req.name); + JpdfRecipe.Builder b = JpdfRecipe.newBuilder(name) + .pairSelection(JpdfRecipe.PairSelection.WHITELIST) + .addAxisPair(x, y) + .componentPairsMode(false) + .includeSelfPairs(true) + .orderedPairs(false) + .bins(req.binsX, req.binsY) + .boundsPolicy(req.boundsPolicy) + .canonicalPolicyId(req.canonicalPolicyId == null ? "default" : req.canonicalPolicyId) + .minAvgCountPerCell(3.0) + .outputKind(JpdfRecipe.OutputKind.PDF_AND_CDF) + .cacheEnabled(true); + + if (req.similarityMetric != null) b.scoreMetric(req.similarityMetric); + if (req.cohortALabel != null || req.cohortBLabel != null) { + b.cohortLabels( + req.cohortALabel != null ? req.cohortALabel : "A", + req.cohortBLabel != null ? req.cohortBLabel : "B" + ); + } + return b.build(); + } + + private CanonicalGridPolicy resolveCanonicalPolicy(JpdfRecipe recipe) { + return CanonicalGridPolicy.get( + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default"); + } + + private DensityCache resolveDensityCache() { + return this.cache; + } + + // --------------------------------------------------------------------- + // Grid math helpers for ΔPDF + // --------------------------------------------------------------------- + + private static boolean sameCenters(double[] a, double[] b) { + if (a == null || b == null || a.length != b.length) return false; + for (int k = 0; k < a.length; k++) if (Math.abs(a[k] - b[k]) > 1e-9) return false; + return true; + } + + private static List> subtract(List> A, List> B) { + int rows = Math.min(A.size(), B.size()); + int cols = Math.min(A.get(0).size(), B.get(0).size()); + List> out = new ArrayList<>(rows); + for (int r = 0; r < rows; r++) { + List row = new ArrayList<>(cols); + List ar = A.get(r); + List br = B.get(r); + for (int c = 0; c < cols; c++) row.add(ar.get(c) - br.get(c)); + out.add(row); + } + return out; + } + + /** + * Bilinear-resample grid G (srcX,srcY) → (dstX,dstY). Returns |dstY| x |dstX|. + */ + private static List> bilinearResample(List> G, + double[] srcX, double[] srcY, + double[] dstX, double[] dstY) { + int h = dstY.length, w = dstX.length; + List> out = new ArrayList<>(h); + + for (int r = 0; r < h; r++) { + double y = dstY[r]; + int jy = clamp(upperFloor(srcY, y), 0, srcY.length - 2); + double y0 = srcY[jy], y1 = srcY[jy + 1]; + double ty = safeT(y, y0, y1); + + List row = new ArrayList<>(w); + for (int c = 0; c < w; c++) { + double x = dstX[c]; + int ix = clamp(upperFloor(srcX, x), 0, srcX.length - 2); + double x0 = srcX[ix], x1 = srcX[ix + 1]; + double tx = safeT(x, x0, x1); + + double f00 = G.get(jy).get(ix); + double f10 = G.get(jy).get(ix + 1); + double f01 = G.get(jy + 1).get(ix); + double f11 = G.get(jy + 1).get(ix + 1); + + double f0 = lerp(f00, f10, tx); + double f1 = lerp(f01, f11, tx); + row.add(lerp(f0, f1, ty)); + } + out.add(row); + } + return out; + } + + /** + * largest k such that src[k] <= v, or 0 if v <= src[0] + */ + private static int upperFloor(double[] src, double v) { + int lo = 0, hi = src.length - 1; + if (v <= src[0]) return 0; + if (v >= src[hi]) return hi - 1; + while (lo + 1 < hi) { + int mid = (lo + hi) >>> 1; + if (src[mid] <= v) lo = mid; + else hi = mid; + } + return lo; + } + + private static int clamp(int x, int lo, int hi) { + return Math.max(lo, Math.min(hi, x)); + } + + private static double lerp(double a, double b, double t) { + return a + t * (b - a); + } + + private static double safeT(double v, double a, double b) { + double d = (b - a); + return d == 0 ? 0.0 : (v - a) / d; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java b/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java new file mode 100644 index 00000000..f4d5316d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java @@ -0,0 +1,478 @@ +package edu.jhuapl.trinity.javafx.components.dialogs; + +import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory; +import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory.Cohorts; +import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory.SyntheticMatrix; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Separator; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * SyntheticDataDialog + * ------------------- + * A reusable JavaFX dialog that lets the user generate: + * • Similarity synthetic matrices (2-cluster, K-cluster, Ring, Grid) + * • Divergence synthetic matrices (3 clusters) + * • Synthetic cohorts (Gaussian vs Uniform) for divergence workflows + *

+ * New: You can optionally attach cohorts to any generated matrix so + * PairwiseMatrixView can render JPDF/ΔPDF even without real data. + */ +public final class SyntheticDataDialog extends Dialog { + + public enum Kind {SIMILARITY_MATRIX, DIVERGENCE_MATRIX, COHORTS} + + /** + * Discriminated union for dialog output. + */ + public static final class Result { + private final Kind kind; + private final SyntheticMatrix matrix; // non-null for SIMILARITY_MATRIX or DIVERGENCE_MATRIX + private final Cohorts cohorts; // non-null for COHORTS + + private Result(Kind kind, SyntheticMatrix matrix, Cohorts cohorts) { + this.kind = Objects.requireNonNull(kind); + this.matrix = matrix; + this.cohorts = cohorts; + } + + public Kind kind() { + return kind; + } + + public SyntheticMatrix matrix() { + return matrix; + } + + public Cohorts cohorts() { + return cohorts; + } + + public static Result similarity(SyntheticMatrix m) { + return new Result(Kind.SIMILARITY_MATRIX, m, null); + } + + public static Result divergence(SyntheticMatrix m) { + return new Result(Kind.DIVERGENCE_MATRIX, m, null); + } + + public static Result cohorts(Cohorts c) { + return new Result(Kind.COHORTS, null, c); + } + } + + // ------------------------------ + // Controls shared helpers + // ------------------------------ + private static TextField tf(String text, int prefWidth) { + TextField t = new TextField(text); + t.setPrefWidth(prefWidth); + return t; + } + + private static HBox row(String label, Node control) { + Label l = new Label(label); + l.setPrefWidth(160); + HBox hb = new HBox(8, l, control); + hb.setAlignment(Pos.CENTER_LEFT); + return hb; + } + + private static VBox section(String title, Node... rows) { + Label t = new Label(title); + t.getStyleClass().add("section-title"); + VBox v = new VBox(8); + v.getChildren().add(t); + v.getChildren().add(new Separator()); + v.getChildren().addAll(rows); + return v; + } + + private static double dval(TextField tf, double def) { + try { + return Double.parseDouble(tf.getText().trim()); + } catch (Exception e) { + return def; + } + } + + private static int ival(TextField tf, int def) { + try { + return Integer.parseInt(tf.getText().trim()); + } catch (Exception e) { + return def; + } + } + + private static long lval(TextField tf, long def) { + try { + return Long.parseLong(tf.getText().trim()); + } catch (Exception e) { + return def; + } + } + + // ------------------------------ + // Tabs + // ------------------------------ + private final TabPane tabs = new TabPane(); + + // Similarity tab controls + private final ComboBox simKind = new ComboBox<>(); + private final TextField simN1 = tf("10", 80); + private final TextField simN2 = tf("10", 80); + private final TextField simK = tf("3", 80); + private final TextField simSizes = tf("8,8,8", 160); + private final TextField simRingN = tf("24", 80); + private final TextField simRingSigma = tf("3.0", 80); + private final TextField simGridNx = tf("6", 80); + private final TextField simGridNy = tf("6", 80); + private final TextField simGridSigma = tf("1.5", 80); + private final TextField simWithin = tf("0.90", 80); + private final TextField simBetween = tf("0.10", 80); + private final TextField simNoise = tf("0.02", 80); + private final TextField simSeed = tf("42", 120); + + // Similarity: optional cohorts + private final CheckBox simAttachCohorts = new CheckBox("Attach cohorts (Gaussian A vs Uniform B)"); + private final TextField simCohN = tf("400", 100); + private final TextField simCohMu = tf("0.0", 80); + private final TextField simCohSigma = tf("1.0", 80); + private final TextField simCohMin = tf("-2.0", 80); + private final TextField simCohMax = tf("2.0", 80); + private final TextField simCohSeedA = tf("100", 120); + private final TextField simCohSeedB = tf("101", 120); + private VBox simCohSection; // toggled + + // Divergence tab controls + private final TextField divSizes = tf("10,10,10", 160); + private final TextField divWithin = tf("0.10", 80); + private final TextField divBetween = tf("0.90", 80); + private final TextField divNoise = tf("0.02", 80); + private final TextField divSeed = tf("46", 120); + + // Divergence: optional cohorts + private final CheckBox divAttachCohorts = new CheckBox("Attach cohorts (Gaussian A vs Uniform B)"); + private final TextField divCohN = tf("400", 100); + private final TextField divCohD = tf("16", 100); + private final TextField divCohMu = tf("0.0", 80); + private final TextField divCohSigma = tf("1.0", 80); + private final TextField divCohMin = tf("-2.0", 80); + private final TextField divCohMax = tf("2.0", 80); + private final TextField divCohSeedA = tf("100", 120); + private final TextField divCohSeedB = tf("101", 120); + private VBox divCohSection; // toggled + + // Cohorts tab controls (standalone) + private final TextField cohN = tf("200", 100); + private final TextField cohD = tf("16", 100); + private final TextField cohMu = tf("0.0", 80); + private final TextField cohSigma = tf("1.0", 80); + private final TextField cohMin = tf("-2.0", 80); + private final TextField cohMax = tf("2.0", 80); + private final TextField cohSeedA = tf("100", 120); + private final TextField cohSeedB = tf("101", 120); + + public SyntheticDataDialog() { + setTitle("Generate Synthetic Data"); + setHeaderText("Create synthetic matrices or cohorts for visual verification."); + + // Buttons + ButtonType buildBtn = new ButtonType("Build", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelBtn = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + getDialogPane().getButtonTypes().addAll(buildBtn, cancelBtn); + + // Content + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabs.getTabs().addAll( + new Tab("Similarity", buildSimilarityContent()), + new Tab("Divergence", buildDivergenceContent()), + new Tab("Cohorts", buildCohortsContent()) + ); + getDialogPane().setContent(tabs); + getDialogPane().setPadding(new Insets(8)); + + // Result converter + setResultConverter((ButtonType param) -> { + if (param != buildBtn) return null; + Tab sel = tabs.getSelectionModel().getSelectedItem(); + if (sel == null) return null; + String name = sel.getText(); + try { + return switch (name) { + case "Similarity" -> buildSimilarity(); + case "Divergence" -> buildDivergence(); + case "Cohorts" -> buildCohorts(); + default -> null; + }; + } catch (Throwable t) { + Alert a = new Alert(Alert.AlertType.ERROR, + "Build failed: " + t.getClass().getSimpleName() + " – " + String.valueOf(t.getMessage()), + ButtonType.OK); + a.showAndWait(); + return null; + } + }); + } + + // ------------------------------ + // Similarity tab + // ------------------------------ + private Node buildSimilarityContent() { + simKind.getItems().addAll("2 Clusters", "K Clusters", "Ring", "Grid"); + simKind.getSelectionModel().selectFirst(); + + HBox sizesRow = new HBox(8, new Label("Sizes (csv)"), simSizes); + sizesRow.setAlignment(Pos.CENTER_LEFT); + + // Cohort section (hidden unless checkbox selected) + simAttachCohorts.setSelected(false); + simCohSection = section("Attach Cohorts (optional)", + row("N (vectors)", simCohN), + row("Gaussian A (μ, σ)", new HBox(6, simCohMu, simCohSigma)), + row("Uniform B [min, max]", new HBox(6, simCohMin, simCohMax)), + row("Seed A / B", new HBox(6, simCohSeedA, simCohSeedB)) + ); + simCohSection.setDisable(true); + simCohSection.setOpacity(0.45); + + simAttachCohorts.setOnAction(e -> { + boolean on = simAttachCohorts.isSelected(); + simCohSection.setDisable(!on); + simCohSection.setOpacity(on ? 1.0 : 0.45); + }); + + VBox top = new VBox(10, + section("Structure", + row("Kind", simKind), + row("N1 / N2 (2 clusters)", new HBox(6, simN1, simN2)), + row("K (K clusters)", simK), + sizesRow, + row("Ring N, σ", new HBox(6, simRingN, simRingSigma)), + row("Grid Nx, Ny, σ", new HBox(6, simGridNx, simGridNy, simGridSigma)) + ), + section("Weights / Noise", + row("Within weight", simWithin), + row("Between weight", simBetween), + row("Noise (ε)", simNoise) + ), + section("Random", + row("Seed", simSeed) + ), + new Separator(), + row("", simAttachCohorts), + simCohSection + ); + top.setPadding(new Insets(8)); + return new ScrollPane(top); + } + + private Result buildSimilarity() { + String kind = simKind.getValue(); + double within = dval(simWithin, 0.90); + double between = dval(simBetween, 0.10); + double noise = dval(simNoise, 0.02); + long seed = lval(simSeed, 42L); + + SyntheticMatrix sm; + switch (kind) { + case "2 Clusters" -> { + int n1 = ival(simN1, 10); + int n2 = ival(simN2, 10); + sm = SyntheticMatrixFactory.twoClustersSimilarity(n1, n2, within, between, noise, seed); + } + case "K Clusters" -> { + int k = Math.max(2, ival(simK, 3)); + int[] sizes = parseSizes(simSizes.getText(), k); + sm = SyntheticMatrixFactory.kClustersSimilarity(sizes, within, between, noise, seed); + } + case "Ring" -> { + int n = ival(simRingN, 24); + double sigma = dval(simRingSigma, 3.0); + sm = SyntheticMatrixFactory.ringSimilarity(n, sigma, noise, seed); + } + case "Grid" -> { + int nx = ival(simGridNx, 6); + int ny = ival(simGridNy, 6); + double sigma = dval(simGridSigma, 1.5); + sm = SyntheticMatrixFactory.gridSimilarity(nx, ny, sigma, noise, seed); + } + default -> throw new IllegalArgumentException("Unsupported similarity kind: " + kind); + } + + if (simAttachCohorts.isSelected()) { + int N = ival(simCohN, 400); + int D = sm.matrix.length; // match feature dimension to matrix size + double mu = dval(simCohMu, 0.0); + double sigma = dval(simCohSigma, 1.0); + double min = dval(simCohMin, -2.0); + double max = dval(simCohMax, 2.0); + long seedA = lval(simCohSeedA, 100L); + long seedB = lval(simCohSeedB, 101L); + + Cohorts c = SyntheticMatrixFactory.makeCohorts_GaussianVsUniform(N, D, mu, sigma, min, max, seedA, seedB); + sm = sm.withCohorts(c.cohortA, c.cohortB).withTitle(" + Cohorts(Gauss μ=" + mu + ",σ=" + sigma + " vs Uniform[" + min + "," + max + "], N=" + N + ")"); + } + + return Result.similarity(sm); + } + + // ------------------------------ + // Divergence tab + // ------------------------------ + private Node buildDivergenceContent() { + // Cohort section (hidden unless checkbox selected) + divAttachCohorts.setSelected(false); + divCohSection = section("Attach Cohorts (optional)", + row("N (vectors)", divCohN), + row("D (dimensions)", divCohD), + row("Gaussian A (μ, σ)", new HBox(6, divCohMu, divCohSigma)), + row("Uniform B [min, max]", new HBox(6, divCohMin, divCohMax)), + row("Seed A / B", new HBox(6, divCohSeedA, divCohSeedB)) + ); + divCohSection.setDisable(true); + divCohSection.setOpacity(0.45); + + divAttachCohorts.setOnAction(e -> { + boolean on = divAttachCohorts.isSelected(); + divCohSection.setDisable(!on); + divCohSection.setOpacity(on ? 1.0 : 0.45); + }); + + VBox top = new VBox(10, + section("Cluster Sizes", + row("Sizes (csv)", divSizes) + ), + section("Weights / Noise", + row("Within divergence", divWithin), + row("Between divergence", divBetween), + row("Noise (ε)", divNoise) + ), + section("Random", + row("Seed", divSeed) + ), + new Separator(), + row("", divAttachCohorts), + divCohSection + ); + top.setPadding(new Insets(8)); + return new ScrollPane(top); + } + + private Result buildDivergence() { + int[] sizes = parseSizes(divSizes.getText(), -1); + double within = dval(divWithin, 0.10); + double between = dval(divBetween, 0.90); + double noise = dval(divNoise, 0.02); + long seed = lval(divSeed, 46L); + + SyntheticMatrix sm = SyntheticMatrixFactory.threeClustersDivergence(sizes, within, between, noise, seed); + + if (divAttachCohorts.isSelected()) { + int N = ival(divCohN, 400); + int D = ival(divCohD, 16); + double mu = dval(divCohMu, 0.0); + double sigma = dval(divCohSigma, 1.0); + double min = dval(divCohMin, -2.0); + double max = dval(divCohMax, 2.0); + long seedA = lval(divCohSeedA, 100L); + long seedB = lval(divCohSeedB, 101L); + + Cohorts c = SyntheticMatrixFactory.makeCohorts_GaussianVsUniform(N, D, mu, sigma, min, max, seedA, seedB); + sm = sm.withCohorts(c.cohortA, c.cohortB).withTitle(" + Cohorts(Gauss μ=" + mu + ",σ=" + sigma + " vs Uniform[" + min + "," + max + "], N=" + N + ", D=" + D + ")"); + } + + return Result.divergence(sm); + } + + // ------------------------------ + // Cohorts tab (standalone) + // ------------------------------ + private Node buildCohortsContent() { + VBox top = new VBox(10, + section("Shape", + row("N (vectors)", cohN), + row("D (dimensions)", cohD) + ), + section("Gaussian A (μ, σ)", new HBox(6, cohMu, cohSigma)), + section("Uniform B [min, max]", new HBox(6, cohMin, cohMax)), + section("Seeds", + row("Seed A", cohSeedA), + row("Seed B", cohSeedB) + ) + ); + top.setPadding(new Insets(8)); + return new ScrollPane(top); + } + + private Result buildCohorts() { + int n = ival(cohN, 200); + int d = ival(cohD, 16); + double mu = dval(cohMu, 0.0); + double sigma = dval(cohSigma, 1.0); + double min = dval(cohMin, -2.0); + double max = dval(cohMax, 2.0); + long seedA = lval(cohSeedA, 100L); + long seedB = lval(cohSeedB, 101L); + + Cohorts c = SyntheticMatrixFactory.makeCohorts_GaussianVsUniform(n, d, mu, sigma, min, max, seedA, seedB); + return Result.cohorts(c); + } + + // ------------------------------ + // Utils + // ------------------------------ + private static int[] parseSizes(String csv, int enforceK) { + if (csv == null || csv.isBlank()) { + if (enforceK > 0) { + int[] def = new int[enforceK]; + for (int i = 0; i < enforceK; i++) def[i] = 8; + return def; + } + return new int[]{10, 10, 10}; + } + String[] toks = csv.split("[,;\\s]+"); + List vals = new ArrayList<>(); + for (String t : toks) { + try { + int v = Integer.parseInt(t.trim()); + if (v > 0) vals.add(v); + } catch (Exception ignore) { /* skip */ } + } + if (vals.isEmpty()) { + if (enforceK > 0) { + int[] def = new int[enforceK]; + for (int i = 0; i < enforceK; i++) def[i] = 8; + return def; + } + return new int[]{10, 10, 10}; + } + if (enforceK > 0 && vals.size() != enforceK) { + // Resize to K + int[] out = new int[enforceK]; + for (int i = 0; i < enforceK; i++) out[i] = (i < vals.size()) ? vals.get(i) : vals.get(vals.size() - 1); + return out; + } + int[] out = new int[vals.size()]; + for (int i = 0; i < vals.size(); i++) out[i] = vals.get(i); + return out; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java index 637da0fa..6abffcb6 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/BatchRequestManager.java @@ -4,6 +4,10 @@ * * @author Sean Phillips */ + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -19,8 +23,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Generic manager for batching and throttling REST requests with retries and @@ -43,9 +45,9 @@ public class BatchRequestManager { private int totalBatches = 0; private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); private final Map> timeouts = new ConcurrentHashMap<>(); -private final Map batchStartTimes = new ConcurrentHashMap<>(); -private final List batchDurations = Collections.synchronizedList(new ArrayList<>()); -private final Map batchEndDurations = new ConcurrentHashMap<>(); + private final Map batchStartTimes = new ConcurrentHashMap<>(); + private final List batchDurations = Collections.synchronizedList(new ArrayList<>()); + private final Map batchEndDurations = new ConcurrentHashMap<>(); private final Supplier requestIdSupplier; private final Supplier batchNumberSupplier; @@ -95,13 +97,13 @@ public interface TriFunction { } public BatchRequestManager( - int maxInFlight, - long timeoutMillis, - int maxRetries, - Supplier batchNumberSupplier, - Supplier requestIdSupplier, - TriFunction taskFactory, // (batch, batchNumber, reqId) -> Runnable - Consumer> onComplete + int maxInFlight, + long timeoutMillis, + int maxRetries, + Supplier batchNumberSupplier, + Supplier requestIdSupplier, + TriFunction taskFactory, // (batch, batchNumber, reqId) -> Runnable + Consumer> onComplete ) { this.maxInFlight = maxInFlight; this.timeoutMillis = timeoutMillis; @@ -112,84 +114,86 @@ public BatchRequestManager( this.onComplete = onComplete; } -public void enqueue(Collection batches) { - totalBatches = batches.size(); - batchesCompleted = 0; - batchDurations.clear(); - batchEndDurations.clear(); - batchStartTimes.clear(); - for (T batch : batches) { - int batchNum = batchNumberSupplier.get(); - int reqId = requestIdSupplier.get(); - pendingQueue.add(new BatchWrapper<>(batch, batchNum, reqId)); + public void enqueue(Collection batches) { + totalBatches = batches.size(); + batchesCompleted = 0; + batchDurations.clear(); + batchEndDurations.clear(); + batchStartTimes.clear(); + for (T batch : batches) { + int batchNum = batchNumberSupplier.get(); + int reqId = requestIdSupplier.get(); + pendingQueue.add(new BatchWrapper<>(batch, batchNum, reqId)); + } + // Prime the pipeline: fill all concurrency slots now + initialDispatch(); } - // Prime the pipeline: fill all concurrency slots now - initialDispatch(); -} -public void stopAndClear() { - pendingQueue.clear(); - // Cancel all outstanding timeouts - for (ScheduledFuture future : timeouts.values()) { - if (future != null && !future.isDone()) { - future.cancel(true); + + public void stopAndClear() { + pendingQueue.clear(); + // Cancel all outstanding timeouts + for (ScheduledFuture future : timeouts.values()) { + if (future != null && !future.isDone()) { + future.cancel(true); + } } + timeouts.clear(); + clearCounters(); } - timeouts.clear(); - clearCounters(); -} -public void clearCounters() { - //clear batch durations, starts, etc. - batchStartTimes.clear(); - batchEndDurations.clear(); - batchDurations.clear(); - //reset counters - inFlight.set(0); - batchesCompleted = 0; - totalBatches = 0; - LOG.warn("BatchRequestManager: All queued batches and timers stopped and cleared by user."); -} -// Call this from enqueue() only -private void initialDispatch() { - while (inFlight.get() < maxInFlight && !pendingQueue.isEmpty()) { - scheduleNextBatch(); + public void clearCounters() { + //clear batch durations, starts, etc. + batchStartTimes.clear(); + batchEndDurations.clear(); + batchDurations.clear(); + //reset counters + inFlight.set(0); + batchesCompleted = 0; + totalBatches = 0; + LOG.warn("BatchRequestManager: All queued batches and timers stopped and cleared by user."); } -} -// Call this from completion/failure/timeout -private void dispatch() { - while(inFlight.get() < maxInFlight && !pendingQueue.isEmpty()) { - scheduleNextBatch(); + // Call this from enqueue() only + private void initialDispatch() { + while (inFlight.get() < maxInFlight && !pendingQueue.isEmpty()) { + scheduleNextBatch(); + } } -} -private void scheduleNextBatch() { - BatchWrapper wrapper = pendingQueue.poll(); - if (wrapper != null) { - inFlight.incrementAndGet(); - LOG.info("Dispatching batch {} (batchNumber={}) (retry #{})", wrapper.requestId, wrapper.batchNumber, wrapper.retries); - - // Timeout for this batch - ScheduledFuture timeout = scheduler.schedule(() -> { - onTimeout(wrapper); - }, timeoutMillis, TimeUnit.MILLISECONDS); - timeouts.put(wrapper.requestId, timeout); - - // Schedule the batch with delay - scheduler.schedule(() -> { - Runnable task = taskFactory.apply(wrapper.batch, wrapper.batchNumber, wrapper.requestId); - try { - task.run(); - } catch (Exception ex) { - LOG.error("Batch {} (batchNumber={}) threw exception: {}", wrapper.requestId, wrapper.batchNumber, ex.toString()); - handleFailure(wrapper, ex); - } - }, requestDelayMillis, TimeUnit.MILLISECONDS); + // Call this from completion/failure/timeout + private void dispatch() { + while (inFlight.get() < maxInFlight && !pendingQueue.isEmpty()) { + scheduleNextBatch(); + } + } - long now = System.currentTimeMillis(); - batchStartTimes.put(wrapper.requestId, now); + private void scheduleNextBatch() { + BatchWrapper wrapper = pendingQueue.poll(); + if (wrapper != null) { + inFlight.incrementAndGet(); + LOG.info("Dispatching batch {} (batchNumber={}) (retry #{})", wrapper.requestId, wrapper.batchNumber, wrapper.retries); + + // Timeout for this batch + ScheduledFuture timeout = scheduler.schedule(() -> { + onTimeout(wrapper); + }, timeoutMillis, TimeUnit.MILLISECONDS); + timeouts.put(wrapper.requestId, timeout); + + // Schedule the batch with delay + scheduler.schedule(() -> { + Runnable task = taskFactory.apply(wrapper.batch, wrapper.batchNumber, wrapper.requestId); + try { + task.run(); + } catch (Exception ex) { + LOG.error("Batch {} (batchNumber={}) threw exception: {}", wrapper.requestId, wrapper.batchNumber, ex.toString()); + handleFailure(wrapper, ex); + } + }, requestDelayMillis, TimeUnit.MILLISECONDS); + + long now = System.currentTimeMillis(); + batchStartTimes.put(wrapper.requestId, now); + } } -} public void completeSuccess(int requestId, int batchNumber, T batch, int retries) { handleCompletion(requestId, batchNumber, batch, retries, BatchResult.Status.SUCCESS, null); @@ -199,66 +203,69 @@ public void completeFailure(int requestId, int batchNumber, T batch, int retries handleCompletion(requestId, batchNumber, batch, retries, BatchResult.Status.FAILURE, ex); } -private void onTimeout(BatchWrapper wrapper) { - LOG.warn("Batch {} (batchNumber={}) timed out after {}ms (retry #{})", + private void onTimeout(BatchWrapper wrapper) { + LOG.warn("Batch {} (batchNumber={}) timed out after {}ms (retry #{})", wrapper.requestId, wrapper.batchNumber, timeoutMillis, wrapper.retries); - handleFailure(wrapper, null); // handleFailure will call dispatch() -} + handleFailure(wrapper, null); // handleFailure will call dispatch() + } + + private void handleCompletion(int requestId, int batchNumber, T batch, int retries, BatchResult.Status status, Exception ex) { + ScheduledFuture timeout = timeouts.remove(requestId); + if (timeout != null) { + timeout.cancel(false); + } + inFlight.decrementAndGet(); + batchesCompleted++; + + //Compute and store batch duration + Long startTime = batchStartTimes.remove(requestId); + if (startTime != null) { + long duration = System.currentTimeMillis() - startTime; + batchDurations.add(duration); // keep this as "most recent" + LOG.info("Batch {} completed in {} ms (avg: {} ms)", requestId, duration, getAvgBatchDurationMillis()); + batchEndDurations.put(requestId, duration); + } -private void handleCompletion(int requestId, int batchNumber, T batch, int retries, BatchResult.Status status, Exception ex) { - ScheduledFuture timeout = timeouts.remove(requestId); - if (timeout != null) { - timeout.cancel(false); + if (onComplete != null) { + onComplete.accept(new BatchResultImpl<>(requestId, batchNumber, batch, status, retries, ex)); + } + dispatch(); } - inFlight.decrementAndGet(); - batchesCompleted++; - //Compute and store batch duration - Long startTime = batchStartTimes.remove(requestId); - if (startTime != null) { - long duration = System.currentTimeMillis() - startTime; - batchDurations.add(duration); // keep this as "most recent" - LOG.info("Batch {} completed in {} ms (avg: {} ms)", requestId, duration, getAvgBatchDurationMillis()); - batchEndDurations.put(requestId, duration); + private void handleFailure(BatchWrapper wrapper, Exception ex) { + timeouts.remove(wrapper.requestId); + inFlight.decrementAndGet(); + if (wrapper.retries < maxRetries) { + wrapper.retries++; + LOG.info("Retrying batch {} (batchNumber={}) (retry #{})", wrapper.requestId, wrapper.batchNumber, wrapper.retries); + pendingQueue.add(wrapper); + } else { + LOG.error("Batch {} (batchNumber={}) failed after {} retries", wrapper.requestId, wrapper.batchNumber, wrapper.retries); + if (onComplete != null) { + onComplete.accept(new BatchResultImpl<>(wrapper.requestId, wrapper.batchNumber, wrapper.batch, BatchResult.Status.FAILURE, wrapper.retries, ex)); + } + } + dispatch(); } - if (onComplete != null) { - onComplete.accept(new BatchResultImpl<>(requestId, batchNumber, batch, status, retries, ex)); + public double getAvgBatchDurationMillis() { + if (batchDurations.isEmpty()) return 0; + return batchDurations.stream().mapToLong(Long::longValue).average().orElse(0); } - dispatch(); -} -private void handleFailure(BatchWrapper wrapper, Exception ex) { - timeouts.remove(wrapper.requestId); - inFlight.decrementAndGet(); - if (wrapper.retries < maxRetries) { - wrapper.retries++; - LOG.info("Retrying batch {} (batchNumber={}) (retry #{})", wrapper.requestId, wrapper.batchNumber, wrapper.retries); - pendingQueue.add(wrapper); - } else { - LOG.error("Batch {} (batchNumber={}) failed after {} retries", wrapper.requestId, wrapper.batchNumber, wrapper.retries); - if (onComplete != null) { - onComplete.accept(new BatchResultImpl<>(wrapper.requestId, wrapper.batchNumber, wrapper.batch, BatchResult.Status.FAILURE, wrapper.retries, ex)); - } + public long getTotalBatchDurationMillis() { + if (batchDurations.isEmpty()) return 0; + return batchDurations.stream().mapToLong(Long::longValue).sum(); } - dispatch(); -} -public double getAvgBatchDurationMillis() { - if (batchDurations.isEmpty()) return 0; - return batchDurations.stream().mapToLong(Long::longValue).average().orElse(0); -} -public long getTotalBatchDurationMillis() { - if (batchDurations.isEmpty()) return 0; - return batchDurations.stream().mapToLong(Long::longValue).sum(); -} -public long getLastBatchDurationMillis() { - if (batchDurations.isEmpty()) return 0; - return batchDurations.get(batchDurations.size() - 1); -} -public long getBatchDurationByID(int id) { - return batchEndDurations.getOrDefault(id, 0L); -} + public long getLastBatchDurationMillis() { + if (batchDurations.isEmpty()) return 0; + return batchDurations.get(batchDurations.size() - 1); + } + + public long getBatchDurationByID(int id) { + return batchEndDurations.getOrDefault(id, 0L); + } public long getRequestDelayMillis() { return requestDelayMillis; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ChooseCaptionsTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ChooseCaptionsTask.java index dada10ad..6cd94833 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ChooseCaptionsTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ChooseCaptionsTask.java @@ -13,10 +13,10 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; -import java.util.Map; /** diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java index db8a27b4..cca12910 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/ImageEmbeddingsBatchLauncher.java @@ -3,19 +3,19 @@ import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageBatchInput; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl; import edu.jhuapl.trinity.javafx.components.listviews.EmbeddingsImageListItem; +import edu.jhuapl.trinity.messages.EmbeddingsImageCallback; import edu.jhuapl.trinity.messages.RestAccessLayer; +import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.scene.Scene; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.function.BiConsumer; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; -import edu.jhuapl.trinity.messages.EmbeddingsImageCallback; -import edu.jhuapl.trinity.utils.ResourceUtils; -import java.io.IOException; -import java.util.ArrayList; /** * Utility for launching a batch of image embedding requests and registering @@ -37,25 +37,25 @@ public ImageEmbeddingsBatchLauncher(Scene scene, String model) { * Launch a batch of embedding requests and register the completion * callback. * - * @param batch List of EmbeddingsImageListItem to process in this batch. + * @param batch List of EmbeddingsImageListItem to process in this batch. * @param batchNumber The unique batch number for this batch. - * @param reqId The request number for this REST batch submission. - * @param callback Completion callback, signature: (success, Exception) + * @param reqId The request number for this REST batch submission. + * @param callback Completion callback, signature: (success, Exception) */ public void launchBatch(List batch, int batchNumber, int reqId, - BiConsumer callback) { + BiConsumer callback) { //register the callback and fire the REST request. EmbeddingsImageCallback.completionCallbacks.put(reqId, callback); List inputs = new ArrayList<>(); - + //Serial encoding (preserves GUI thread order) for (int i = 0; i < batch.size(); i++) { EmbeddingsImageListItem item = batch.get(i); - if(!item.isImageLoaded()){ + if (!item.isImageLoaded()) { try { inputs.add(imageUrlFromImage.apply( - ResourceUtils.loadImageFile(item.getFile()))); + ResourceUtils.loadImageFile(item.getFile()))); } catch (IOException ex) { LOG.error("Failed to load Image from source: " + item.getFile().getAbsolutePath()); } @@ -75,10 +75,10 @@ public void launchBatch(List batch, int batchNumber, in try { RestAccessLayer.requestImageEmbeddings( - input, - scene, - batch.stream().map(i -> i.imageID).toList(), - reqId + input, + scene, + batch.stream().map(i -> i.imageID).toList(), + reqId ); LOG.info("Image batch (reqId={}, batchNumber={}) sent: {} items.", reqId, batchNumber, batch.size()); } catch (Exception ex) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestCaptionsTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestCaptionsTask.java index 4a7590a5..07564dcb 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestCaptionsTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestCaptionsTask.java @@ -12,10 +12,10 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; -import java.util.Map; /** diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestEmbeddingsTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestEmbeddingsTask.java index 7537d47c..4ffe5dc4 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestEmbeddingsTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestEmbeddingsTask.java @@ -14,10 +14,10 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; -import java.util.Map; /** @@ -42,6 +42,7 @@ public RequestEmbeddingsTask(Scene scene, CircleProgressIndicator progressIndica this.currentEmbeddingsModel = currentEmbeddingsModel; this.imageEmbeddingsListItems = imageEmbeddingsListItems; } + @Override protected void processTask() throws Exception { AtomicInteger atomicCount = new AtomicInteger(0); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java index 479ae32e..c14cac37 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/hyperdrive/RequestTextEmbeddingsTask.java @@ -11,8 +11,8 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; -import java.util.Map; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsImageListItem.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsImageListItem.java index 9e6b7c4d..8372b51d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsImageListItem.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsImageListItem.java @@ -43,7 +43,7 @@ public class EmbeddingsImageListItem extends HBox { private Label dimensionsLabel; private TextField labelTextField; private FeatureVector featureVector = null; - + public EmbeddingsImageListItem(File file) { this(file, true); } @@ -71,7 +71,7 @@ public EmbeddingsImageListItem(File file, boolean renderIcon) { setPrefHeight(32); featureVector = FeatureVector.EMPTY_FEATURE_VECTOR("", 3); featureVector.setImageURL(file.getAbsolutePath()); - if(renderIcon) //conserve VRAM + if (renderIcon) //conserve VRAM Tooltip.install(this, new Tooltip(file.getAbsolutePath())); imageView.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { @@ -132,11 +132,11 @@ public void setLabelWidth(double width) { fileLabel.setPrefWidth(width); } - + public boolean isImageLoaded() { return imageLoaded; } - + public Image getCurrentImage() { return imageView.getImage(); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java index 8e5e172c..171fae0d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/listviews/EmbeddingsTextListItem.java @@ -218,7 +218,7 @@ public void setFeatureVectorEntityID(String entityID) { } } else { //not smart but smarter than dumb newline delimited chunks - if(BREAK_ON_NEWLINES){ + if (BREAK_ON_NEWLINES) { return chunkByDelimiter(file, fileString, System.lineSeparator()); } //use naive bruteforce chunking @@ -234,11 +234,12 @@ public void setFeatureVectorEntityID(String entityID) { } return list; }; + public static List expandCSVAndChunk(File file, String fileString) throws IOException { - String [] csvRows = fileString.split(System.lineSeparator()); - if(csvRows.length <= 1) return Collections.EMPTY_LIST; + String[] csvRows = fileString.split(System.lineSeparator()); + if (csvRows.length <= 1) return Collections.EMPTY_LIST; //get labels - String [] labels = csvRows[0].split(","); + String[] labels = csvRows[0].split(","); List list = Arrays.asList(csvRows).stream() .skip(1) //skip the first row (label row) .map(s -> { @@ -246,8 +247,8 @@ public static List expandCSVAndChunk(File file, String f String expandedContent = expandCSV(labels, s); item.contents = expandedContent; item.getFeatureVector().setText(expandedContent); - String [] csvTokens = s.split(","); - if(AUTOLABEL_FROM_CSVCOLUMN && csvTokens.length > CSV_DEFAULTLABEL_COLUMN) + String[] csvTokens = s.split(","); + if (AUTOLABEL_FROM_CSVCOLUMN && csvTokens.length > CSV_DEFAULTLABEL_COLUMN) item.setFeatureVectorLabel(csvTokens[CSV_DEFAULTLABEL_COLUMN]); else item.setFeatureVectorLabel(file.getName()); @@ -255,15 +256,17 @@ public static List expandCSVAndChunk(File file, String f }).toList(); return list; } - public static String expandCSV(String [] labels, String csvRow) { + + public static String expandCSV(String[] labels, String csvRow) { StringBuilder sb = new StringBuilder(); - String [] tokens = csvRow.split(","); - for(int i=0;i=tokens.length) break; //in case a row is incomplete + String[] tokens = csvRow.split(","); + for (int i = 0; i < labels.length; i++) { + if (i >= tokens.length) break; //in case a row is incomplete sb.append(labels[i].trim()).append(" = ").append(tokens[i].trim()).append(System.lineSeparator()); } return sb.toString(); } + public static List chunkByDelimiter(File file, String fileString, String delimiter) throws IOException { List list = Arrays.asList(fileString.split(delimiter)).stream() .map(s -> { @@ -275,6 +278,7 @@ public static List chunkByDelimiter(File file, String fi }).toList(); return list; } + public static List chunkString(File file, String fileString) { List list = new ArrayList<>(); int len = fileString.length(); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java new file mode 100644 index 00000000..5fa7f4ce --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -0,0 +1,460 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; +import javafx.beans.binding.Bindings; +import javafx.collections.ListChangeListener; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ChoiceDialog; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Dialog; +import javafx.scene.control.ListCell; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputDialog; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.Pane; +import javafx.stage.FileChooser; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public class FeatureVectorManagerPane extends LitPathPane { + + private final FeatureVectorManagerView view; + private final FeatureVectorManagerService service; + + // simple debounce for search typing + private javafx.animation.PauseTransition searchDebounce; + + public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerService service) { + super(scene, parent, 900, 640, new FeatureVectorManagerView(), + "Feature Vectors", "Manager", 300.0, 400.0); + + this.view = (FeatureVectorManagerView) this.contentPane; + this.service = service; + + wireViewToService(); + installCollectionContextMenu(); + installTableContextMenu(); + installSearchWiring(); + } + + @Override + public void maximize() { + this.scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.POPOUT_FEATUREVECTOR_MANAGER, Boolean.TRUE)); + } + + private void wireViewToService() { + // Live-bind the table to service's displayed vectors + view.getTable().setItems(service.getDisplayedVectors()); + + // Live-bind the ComboBox to service's collection names + var combo = view.getCollectionSelector(); + combo.setItems(service.getCollectionNames()); + combo.setVisibleRowCount(15); + + // Render "(unnamed)" for empty names, both in popup and button cell + combo.setCellFactory(listView -> new ListCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? null : (item == null || item.trim().isEmpty() ? "(unnamed)" : item)); + } + }); + combo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? null : (item == null || item.trim().isEmpty() ? "(unnamed)" : item)); + } + }); + + // Two-way selection sync between view and service + view.selectedCollectionProperty().addListener((obs, o, n) -> { + if (n != null && !n.equals(service.activeCollectionNameProperty().get())) { + service.activeCollectionNameProperty().set(n); + } + }); + service.activeCollectionNameProperty().addListener((obs, o, n) -> { + if (n != null && !n.equals(view.getSelectedCollection())) { + combo.getSelectionModel().select(n); + } + }); + + // If names list changes and no selection, select the first + service.getCollectionNames().addListener((ListChangeListener) c -> { + if (combo.getSelectionModel().isEmpty() && !service.getCollectionNames().isEmpty()) { + combo.getSelectionModel().select(service.getCollectionNames().get(0)); + } + }); + + // Map sampling text to enum + view.samplingModeProperty().addListener((obs, o, s) -> { + if (s == null) return; + FeatureVectorManagerService.SamplingMode mode = switch (s) { + case "Head (1000)" -> FeatureVectorManagerService.SamplingMode.HEAD_1000; + case "Tail (1000)" -> FeatureVectorManagerService.SamplingMode.TAIL_1000; + case "Random (1000)" -> FeatureVectorManagerService.SamplingMode.RANDOM_1000; + default -> FeatureVectorManagerService.SamplingMode.ALL; + }; + if (service.samplingModeProperty().get() != mode) { + service.samplingModeProperty().set(mode); + } + }); + + // Keep bottom status roughly in sync with rows shown + view.getTable().itemsProperty().addListener((obs, o, n) -> { + view.setStatus("Showing " + (n == null ? 0 : n.size()) + " vectors."); + }); + view.getTable().itemsProperty().bind(Bindings.createObjectBinding( + () -> service.getDisplayedVectors(), service.getDisplayedVectors())); + } + + /** + * Wire the header Search TextField to the service text filter (with debounce). + */ + private void installSearchWiring() { + TextField tf = view.getSearchField(); + + // init debounce timer (~200ms after last keypress) + searchDebounce = new javafx.animation.PauseTransition(javafx.util.Duration.millis(200)); + searchDebounce.setOnFinished(e -> service.setTextFilter(tf.getText())); + + // typing -> debounce -> set filter + tf.textProperty().addListener((obs, o, n) -> searchDebounce.playFromStart()); + + // ENTER applies immediately, ESC clears + tf.addEventFilter(KeyEvent.KEY_PRESSED, e -> { + if (e.getCode() == KeyCode.ENTER) { + searchDebounce.stop(); + service.setTextFilter(tf.getText()); + e.consume(); + } else if (e.getCode() == KeyCode.ESCAPE) { + tf.clear(); + searchDebounce.stop(); + service.setTextFilter(""); + e.consume(); + } + }); + + // Also update placeholder status text if you want visual feedback (optional) + service.textFilterProperty().addListener((obs, o, n) -> { + boolean active = n != null && !n.isBlank(); + view.setStatus(active ? "Showing filtered vectors." : "Showing " + service.getDisplayedVectors().size() + " vectors."); + }); + } + + // ---------------- Context Menu: Collections ---------------- + + private void installCollectionContextMenu() { + ComboBox combo = view.getCollectionSelector(); + ContextMenu ctx = new ContextMenu(); + + MenuItem miRename = new MenuItem("Rename…"); + miRename.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + TextInputDialog dlg = new TextInputDialog(current); + dlg.setHeaderText("Rename Collection"); + dlg.setContentText("New name:"); + dlg.getEditor().setText(current); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.showAndWait().ifPresent(newName -> { + if (newName != null && !newName.trim().isEmpty()) { + service.renameCollection(current, newName.trim()); + } + }); + }); + + MenuItem miDuplicate = new MenuItem("Duplicate…"); + miDuplicate.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + TextInputDialog dlg = new TextInputDialog("Copy of " + current); + dlg.setHeaderText("Duplicate Collection"); + dlg.setContentText("New name:"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.showAndWait().ifPresent(proposed -> service.duplicateCollection(current, proposed)); + }); + + MenuItem miDelete = new MenuItem("Delete…"); + miDelete.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + Alert alert = new Alert(Alert.AlertType.CONFIRMATION, + "Delete collection \"" + current + "\"?\nThis cannot be undone.", + ButtonType.OK, ButtonType.CANCEL); + alert.setHeaderText("Delete Collection"); + alert.showAndWait().ifPresent(btn -> { + if (btn == ButtonType.OK) service.deleteCollection(current); + }); + }); + + MenuItem miMergeInto = new MenuItem("Merge into…"); + miMergeInto.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + List options = service.getCollectionNames().stream() + .filter(n -> !Objects.equals(n, current)) + .collect(Collectors.toList()); + if (options.isEmpty()) { + info("No other collections to merge into."); + return; + } + ChoiceDialog chooser = new ChoiceDialog<>(options.get(0), options); + chooser.setHeaderText("Merge \"" + current + "\" into…"); + chooser.setContentText("Target collection:"); + chooser.getDialogPane().setPadding(new Insets(10)); + chooser.showAndWait().ifPresent(target -> { + Alert dedup = new Alert(Alert.AlertType.CONFIRMATION, + "De-duplicate by entityId while merging?", + ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); + dedup.setHeaderText("Merge Options"); + dedup.showAndWait().ifPresent(resp -> { + if (resp == ButtonType.CANCEL) return; + boolean dd = (resp == ButtonType.YES); + service.mergeInto(target, current, dd); + }); + }); + }); + + Menu export = new Menu("Export"); + MenuItem miExportJson = new MenuItem("As JSON (FeatureCollection)"); + miExportJson.setOnAction(e -> exportCollection(FeatureVectorManagerService.ExportFormat.JSON)); + MenuItem miExportCsv = new MenuItem("As CSV"); + miExportCsv.setOnAction(e -> exportCollection(FeatureVectorManagerService.ExportFormat.CSV)); + export.getItems().addAll(miExportJson, miExportCsv); + + // Apply submenu (active collection) + Menu applyMenu = new Menu("Apply to workspace"); + MenuItem miApplyAppend = new MenuItem("Apply (append)"); + miApplyAppend.setOnAction(e -> service.applyActiveToWorkspace(false)); + MenuItem miApplyReplace = new MenuItem("Apply (replace)"); + miApplyReplace.setOnAction(e -> service.applyActiveToWorkspace(true)); + + MenuItem miSetAllAppend = new MenuItem("Set All (append)"); + miSetAllAppend.setOnAction(e -> service.applyAllToWorkspace(false)); + MenuItem miSetAllReplace = new MenuItem("Set All (replace)"); + miSetAllReplace.setOnAction(e -> service.applyAllToWorkspace(true)); + applyMenu.getItems().addAll(miApplyAppend, miApplyReplace, miSetAllAppend, miSetAllReplace); + + ctx.getItems().addAll(miRename, miDuplicate, miDelete, new SeparatorMenuItem(), + miMergeInto, export, new SeparatorMenuItem(), applyMenu); + + combo.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { + if (!ctx.isShowing()) ctx.show(combo, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + } + + private void exportCollection(FeatureVectorManagerService.ExportFormat fmt) { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + FileChooser fc = new FileChooser(); + fc.setInitialFileName(safeFilename(current) + (fmt == FeatureVectorManagerService.ExportFormat.JSON ? ".json" : ".csv")); + fc.getExtensionFilters().setAll( + new FileChooser.ExtensionFilter("JSON", "*.json"), + new FileChooser.ExtensionFilter("CSV", "*.csv"), + new FileChooser.ExtensionFilter("All Files", "*.*") + ); + File file = fc.showSaveDialog(getScene().getWindow()); + if (file != null) { + try { + service.exportCollection(current, file, fmt); + info("Exported \"" + current + "\" to:\n" + file.getAbsolutePath()); + } catch (Exception ex) { + error("Export failed:\n" + ex.getMessage()); + } + } + } + + // ---------------- Context Menu: Vectors (table) ---------------- + + private void installTableContextMenu() { + TableView table = view.getTable(); + ContextMenu ctx = new ContextMenu(); + + MenuItem miRemoveSel = new MenuItem("Remove Selected"); + miRemoveSel.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (!sel.isEmpty()) service.removeFromActive(sel); + }); + + MenuItem miCopyTo = new MenuItem("Copy Selected to…"); + miCopyTo.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (sel.isEmpty()) return; + + String current = service.activeCollectionNameProperty().get(); + TextInputDialog dlg = new TextInputDialog(current == null ? "NewCollection" : (current + "-copy")); + dlg.setHeaderText("Copy Selected to Collection"); + dlg.setContentText("Target name (existing or new):"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.showAndWait().ifPresent(target -> { + if (target != null && !target.trim().isEmpty()) { + service.copyToCollection(sel, target.trim()); + } + }); + }); + + MenuItem miEditLabel = new MenuItem("Edit Label(s)…"); + miEditLabel.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (sel.isEmpty()) return; + TextInputDialog dlg = new TextInputDialog(); + dlg.setHeaderText("Bulk Set Label"); + dlg.setContentText("New label:"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.showAndWait().ifPresent(lbl -> { + if (lbl != null) service.bulkSetLabelInActive(sel, lbl); + }); + }); + + MenuItem miEditMeta = new MenuItem("Edit Metadata…"); + miEditMeta.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (sel.isEmpty()) return; + + Dialog> dlg = new Dialog<>(); + dlg.setTitle("Bulk Edit Metadata"); + dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + TextArea ta = new TextArea(); + ta.setPromptText("Enter key=value pairs, one per line.\nExample:\nsource=fileA\nuuid=override-123"); + ta.setPrefRowCount(10); + ta.setWrapText(true); + dlg.getDialogPane().setContent(ta); + dlg.getDialogPane().setPadding(new Insets(10)); + + dlg.setResultConverter(btn -> { + if (btn == ButtonType.OK) { + return parseKeyValues(ta.getText()); + } + return null; + }); + + Optional> res = dlg.showAndWait(); + res.ifPresent(kv -> { + if (!kv.isEmpty()) service.bulkEditMetadataInActive(sel, kv); + }); + }); + + MenuItem miLocate = new MenuItem("Locate in 3D"); + miLocate.setOnAction(e -> { + var sel = table.getSelectionModel().getSelectedItems(); + if (sel == null || sel.isEmpty()) return; + sel.forEach(fv -> + getScene().getRoot().fireEvent(new FeatureVectorEvent(FeatureVectorEvent.LOCATE_FEATURE_VECTOR, fv)) + ); + }); + + // Apply submenu — uses selection if present, else whole active collection + Menu applyMenu = new Menu("Apply to workspace"); + + MenuItem miApplyAppend = new MenuItem("Apply (append)"); + miApplyAppend.setOnAction(e -> applySelectionOrActive(false)); + + MenuItem miApplyReplace = new MenuItem("Apply (replace)"); + miApplyReplace.setOnAction(e -> applySelectionOrActive(true)); + + applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); + + ctx.getItems().addAll(miRemoveSel, miCopyTo, new SeparatorMenuItem(), + miEditLabel, miEditMeta, new SeparatorMenuItem(), + miLocate, applyMenu); + + table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { + if (!ctx.isShowing()) ctx.show(table, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + } + + /** + * If there is a non-empty selection in the table, apply only those vectors to the workspace. + * Otherwise, fall back to applying the whole active collection via the service. + * + * @param replace true = replace in workspace, false = append + */ + private void applySelectionOrActive(boolean replace) { + TableView table = view.getTable(); + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + + if (!sel.isEmpty()) { + FeatureCollection fc = new FeatureCollection(); + fc.setFeatures(sel); + FeatureVectorEvent evt = + new FeatureVectorEvent(FeatureVectorEvent.NEW_FEATURE_COLLECTION, fc, + FeatureVectorManagerService.MANAGER_APPLY_TAG); + evt.clearExisting = replace; + + getScene().getRoot().fireEvent(evt); + } else { + service.applyActiveToWorkspace(replace); + } + } + + // ---------------- Helpers ---------------- + + private static String safeFilename(String s) { + if (s == null) return "collection"; + return s.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + private static Map parseKeyValues(String text) { + Map map = new LinkedHashMap<>(); + if (text == null || text.isBlank()) return map; + String[] lines = text.split("\\R"); + for (String line : lines) { + String ln = line.trim(); + if (ln.isEmpty()) continue; + int eq = ln.indexOf('='); + if (eq < 0) { + map.put(ln, ""); + } else { + String k = ln.substring(0, eq).trim(); + String v = ln.substring(eq + 1).trim(); + if (!k.isEmpty()) map.put(k, v); + } + } + return map; + } + + private void info(String msg) { + Alert a = new Alert(Alert.AlertType.INFORMATION, msg, ButtonType.OK); + a.setHeaderText(null); + a.showAndWait(); + } + + private void error(String msg) { + Alert a = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK); + a.setHeaderText("Error"); + a.showAndWait(); + } + + // Convenience for tests / external triggers + public void applyActiveToWorkspace() { + service.applyActiveToWorkspace(false); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java index 4d0097af..51bd2d81 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HyperdrivePane.java @@ -16,9 +16,11 @@ import edu.jhuapl.trinity.data.messages.llm.Prompts; import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.hyperdrive.BatchRequestManager; import edu.jhuapl.trinity.javafx.components.hyperdrive.CaptionChooserBox; import edu.jhuapl.trinity.javafx.components.hyperdrive.ChooseCaptionsTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.HyperdriveTask.REQUEST_STATUS; +import edu.jhuapl.trinity.javafx.components.hyperdrive.ImageEmbeddingsBatchLauncher; import edu.jhuapl.trinity.javafx.components.hyperdrive.LoadImagesTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.LoadTextTask; import edu.jhuapl.trinity.javafx.components.hyperdrive.RequestCaptionsTask; @@ -79,6 +81,7 @@ import javafx.scene.paint.Color; import javafx.scene.text.TextAlignment; import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; import javafx.stage.StageStyle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,20 +92,17 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; -import edu.jhuapl.trinity.javafx.components.hyperdrive.BatchRequestManager; -import edu.jhuapl.trinity.javafx.components.hyperdrive.ImageEmbeddingsBatchLauncher; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalSuccess; import static edu.jhuapl.trinity.javafx.events.CommandTerminalEvent.notifyTerminalWarning; import static edu.jhuapl.trinity.messages.RestAccessLayer.*; -import java.util.Collections; -import java.util.Map; -import javafx.stage.FileChooser; /** * @author Sean Phillips @@ -123,7 +123,7 @@ public class HyperdrivePane extends LitPathPane { ImageView baseImageView; BorderPane embeddingsBorderPane; StackPane embeddingsCenterStack; - TextArea baseTextArea; + TextArea baseTextArea; TabPane tabPane; Tab imageryEmbeddingsTab; @@ -150,7 +150,7 @@ public class HyperdrivePane extends LitPathPane { ChoiceBox metricChoiceBox; AtomicInteger requestNumber; AtomicInteger batchNumber; - + Map outstandingRequests; ImageEmbeddingsBatchLauncher imageBatchLauncher; BatchRequestManager> imageEmbeddingManager; @@ -191,7 +191,7 @@ public HyperdrivePane(Scene scene, Pane parent) { servicesTab = new Tab("Services"); filesTab = new Tab("Files"); - tabPane = new TabPane(imageryEmbeddingsTab, textEmbeddingsTab, + tabPane = new TabPane(imageryEmbeddingsTab, textEmbeddingsTab, similarityTab, servicesTab, filesTab); tabPane.setPadding(Insets.EMPTY); tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); @@ -223,7 +223,7 @@ public HyperdrivePane(Scene scene, Pane parent) { MenuItem selectAllTextMenuItem = new MenuItem("Select All"); selectAllTextMenuItem.setOnAction(e -> textEmbeddingsListView.getSelectionModel().selectAll()); - + MenuItem setTextLabelItem = new MenuItem("Set Label Manually"); setTextLabelItem.setOnAction(e -> { if (!textEmbeddingsListView.getSelectionModel().getSelectedItems().isEmpty()) { @@ -370,7 +370,7 @@ public HyperdrivePane(Scene scene, Pane parent) { Button imageEmbeddingsButton = new Button("Request Embeddings"); imageEmbeddingsButton.setWrapText(true); imageEmbeddingsButton.setTextAlignment(TextAlignment.CENTER); - + imageEmbeddingsButton.setOnAction(e -> { List items = imageEmbeddingsListView.getSelectionModel().getSelectedItems(); List> batches = new ArrayList<>(); @@ -389,8 +389,8 @@ public HyperdrivePane(Scene scene, Pane parent) { imageEmbeddingRequestIndicator.setPercentComplete(0); imageEmbeddingRequestIndicator.setTopLabelLater("Received 0 of " + items.size()); imageEmbeddingRequestIndicator.spin(true); - if(!imageEmbeddingRequestIndicator.inView()) - imageEmbeddingRequestIndicator.fadeBusy(false); + if (!imageEmbeddingRequestIndicator.inView()) + imageEmbeddingRequestIndicator.fadeBusy(false); imageEmbeddingManager.clearCounters(); // Launch batches imageEmbeddingManager.enqueue(batches); @@ -446,7 +446,7 @@ public HyperdrivePane(Scene scene, Pane parent) { if (!imageEmbeddingsListView.getSelectionModel().getSelectedItems().isEmpty()) { imageEmbeddingRequestIndicator.setLabelLater("Choose Captions..."); imageEmbeddingRequestIndicator.spin(true); - if(!imageEmbeddingRequestIndicator.inView()) + if (!imageEmbeddingRequestIndicator.inView()) imageEmbeddingRequestIndicator.fadeBusy(false); //LOG.info("Prompting User for Labels..."); Platform.runLater(() -> { CaptionChooserBox box = new CaptionChooserBox(); @@ -507,8 +507,8 @@ public HyperdrivePane(Scene scene, Pane parent) { imageEmbeddingRequestIndicator.setTopLabelLater("Stopped"); imageEmbeddingRequestIndicator.spin(false); imageEmbeddingRequestIndicator.fadeBusy(true); - }); - MenuItem exportEmbeddingsMenuItem = new MenuItem("Export Completed to Collection"); + }); + MenuItem exportEmbeddingsMenuItem = new MenuItem("Export Completed to Collection"); exportEmbeddingsMenuItem.setOnAction(e -> { if (!imageEmbeddingsListView.getSelectionModel().getSelectedItems().isEmpty()) { List complete = imageEmbeddingsListView.getItems() @@ -520,7 +520,7 @@ public HyperdrivePane(Scene scene, Pane parent) { exportFeatureCollection("Save FeatureCollecdtion as...", fc); } - }); + }); ContextMenu embeddingsContextMenu = new ContextMenu(selectAllMenuItem, setLabelItem, requestCaptionItem, chooseCaptionItem, textLandmarkCaptionItem, imageLandmarkCaptionItem, @@ -740,7 +740,7 @@ public HyperdrivePane(Scene scene, Pane parent) { batchSizeSpinner.setEditable(true); batchSizeSpinner.setPrefWidth(100); - Spinner timeoutSpinner = new Spinner(1, 600, requestTimeoutMS/1000, 1); + Spinner timeoutSpinner = new Spinner(1, 600, requestTimeoutMS / 1000, 1); timeoutSpinner.valueProperty().addListener(c -> { requestTimeoutMS = timeoutSpinner.getValue() * 1000; //spinner is in seconds imageEmbeddingManager.setTimeoutMillis(requestTimeoutMS); @@ -769,7 +769,7 @@ public HyperdrivePane(Scene scene, Pane parent) { }); outstandingSpinner.setEditable(true); outstandingSpinner.setPrefWidth(100); - + VBox outstandingSpinnerVBox = new VBox(20, new VBox(5, new Label("Max In Flight Batches"), outstandingSpinner), new VBox(5, new Label("Request Timeout (Seconds)"), timeoutSpinner) @@ -787,7 +787,7 @@ public HyperdrivePane(Scene scene, Pane parent) { enableCSVCheckBox.getScene().getRoot().fireEvent( new HyperdriveEvent(HyperdriveEvent.ENABLE_CSV_EXPANSION, enableCSVCheckBox.isSelected())); }); - + CheckBox labelFromColumnCheckBox = new CheckBox("Auto-label from CSV Column"); labelFromColumnCheckBox.selectedProperty().addListener(e -> { EmbeddingsTextListItem.AUTOLABEL_FROM_CSVCOLUMN = labelFromColumnCheckBox.isSelected(); @@ -803,16 +803,16 @@ public HyperdrivePane(Scene scene, Pane parent) { }); csvColumnSpinner.setEditable(true); csvColumnSpinner.setPrefWidth(100); - VBox selectColumnHBox = new VBox(5, new Label("Default CSV Label Column"),csvColumnSpinner); + VBox selectColumnHBox = new VBox(5, new Label("Default CSV Label Column"), csvColumnSpinner); selectColumnHBox.disableProperty().bind(labelFromColumnCheckBox.selectedProperty().not()); - + CheckBox breakOnNewLineCheckBox = new CheckBox("Force Break on New Lines"); breakOnNewLineCheckBox.selectedProperty().addListener(e -> { EmbeddingsTextListItem.BREAK_ON_NEWLINES = breakOnNewLineCheckBox.isSelected(); breakOnNewLineCheckBox.getScene().getRoot().fireEvent( new HyperdriveEvent(HyperdriveEvent.BREAK_ON_NEWLINES, breakOnNewLineCheckBox.isSelected())); - }); - + }); + Spinner chunkSizeSpinner = new Spinner(256, 262144, chunkSize, 256); chunkSizeSpinner.valueProperty().addListener(c -> { chunkSize = chunkSizeSpinner.getValue(); @@ -825,7 +825,7 @@ public HyperdrivePane(Scene scene, Pane parent) { VBox chunkingSpinnerVBox = new VBox(30, new HBox(20, enableJSONCheckBox, enableCSVCheckBox), - new HBox(20, labelFromColumnCheckBox, selectColumnHBox), + new HBox(20, labelFromColumnCheckBox, selectColumnHBox), new HBox(20, breakOnNewLineCheckBox, new VBox(5, new Label("Chunk Size (bytes)"), chunkSizeSpinner)) ); @@ -859,11 +859,11 @@ public HyperdrivePane(Scene scene, Pane parent) { FileChooser fc = new FileChooser(); fc.setTitle("Browse and Select FeatureCollections to Merge"); List files = fc.showOpenMultipleDialog(getScene().getWindow()); - if(!files.isEmpty()) { + if (!files.isEmpty()) { ArrayList collections = new ArrayList<>(); - for(File file : files) { + for (File file : files) { try { - if(FeatureCollectionFile.isFeatureCollectionFile(file)){ + if (FeatureCollectionFile.isFeatureCollectionFile(file)) { FeatureCollectionFile fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); collections.add(fcf.featureCollection); } @@ -871,16 +871,16 @@ public HyperdrivePane(Scene scene, Pane parent) { LOG.error(null, ex); } } - if(!collections.isEmpty()) { + if (!collections.isEmpty()) { FeatureCollection merged = FeatureCollection.merge(collections); exportFeatureCollection("Save " + collections.size() + " merged files as...", merged); } } }); VBox filesVBox = new VBox(10, mergeButton); - + filesTab.setContent(filesVBox); - + //Image Embeddings Service embeddingsLocationTextField = new TextField( RestAccessLayer.restAccessLayerconfig.getBaseRestURL() + @@ -1106,65 +1106,65 @@ public HyperdrivePane(Scene scene, Pane parent) { } }); -scene.getRoot().addEventHandler(RestEvent.NEW_EMBEDDINGS_IMAGE, event -> { - EmbeddingsImageOutput output = (EmbeddingsImageOutput) event.object; - List inputIDs = (List) event.object2; - - int totalListItems = outstandingRequests.size(); // Track total once at start - // For each result, mark as SUCCEEDED in outstandingRequests - for (int i = 0; i < output.getData().size(); i++) { - int currentInputID = inputIDs.get(i); - outstandingRequests.put(currentInputID, REQUEST_STATUS.SUCCEEDED); - // (Optionally update imageEmbeddingsListView items here as before) - EmbeddingsImageData currentOutput = output.getData().get(i); - if (currentInputID == EMBEDDINGS_IMAGE_TESTID) { - notifyTerminalSuccess("Image Embeddings Test Successful", scene); - } else { - imageEmbeddingsListView.getItems() - .filtered(fi -> fi.imageID == currentInputID) - .forEach(item -> { - item.setEmbeddings(currentOutput.getEmbedding()); - item.addMetaData("object", currentOutput.getObject()); - item.addMetaData("type", currentOutput.getType()); - }); - } - } - // Progress calculation - long succeeded = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.SUCCEEDED).count(); - long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); - long completed = succeeded + failed; - - imageEmbeddingRequestIndicator.setPercentComplete(completed / (double) totalListItems); - imageEmbeddingRequestIndicator.setTopLabelLater("Received " + completed + " of " + totalListItems); - - // When ALL are done (succeeded or failed), clean up - if (completed == totalListItems) { - imageEmbeddingRequestIndicator.spin(false); - imageEmbeddingRequestIndicator.fadeBusy(true); - outstandingRequests.clear(); - } -}); -scene.getRoot().addEventHandler(RestEvent.ERROR_EMBEDDINGS_IMAGE, event -> { - List failedInputIDs = (List) event.object; - int totalListItems = outstandingRequests.size(); + scene.getRoot().addEventHandler(RestEvent.NEW_EMBEDDINGS_IMAGE, event -> { + EmbeddingsImageOutput output = (EmbeddingsImageOutput) event.object; + List inputIDs = (List) event.object2; - for (Integer failedId : failedInputIDs) { - outstandingRequests.put(failedId, REQUEST_STATUS.FAILED); - } + int totalListItems = outstandingRequests.size(); // Track total once at start + // For each result, mark as SUCCEEDED in outstandingRequests + for (int i = 0; i < output.getData().size(); i++) { + int currentInputID = inputIDs.get(i); + outstandingRequests.put(currentInputID, REQUEST_STATUS.SUCCEEDED); + // (Optionally update imageEmbeddingsListView items here as before) + EmbeddingsImageData currentOutput = output.getData().get(i); + if (currentInputID == EMBEDDINGS_IMAGE_TESTID) { + notifyTerminalSuccess("Image Embeddings Test Successful", scene); + } else { + imageEmbeddingsListView.getItems() + .filtered(fi -> fi.imageID == currentInputID) + .forEach(item -> { + item.setEmbeddings(currentOutput.getEmbedding()); + item.addMetaData("object", currentOutput.getObject()); + item.addMetaData("type", currentOutput.getType()); + }); + } + } + // Progress calculation + long succeeded = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.SUCCEEDED).count(); + long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); + long completed = succeeded + failed; - long succeeded = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.SUCCEEDED).count(); - long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); - long completed = succeeded + failed; + imageEmbeddingRequestIndicator.setPercentComplete(completed / (double) totalListItems); + imageEmbeddingRequestIndicator.setTopLabelLater("Received " + completed + " of " + totalListItems); - imageEmbeddingRequestIndicator.setPercentComplete(completed / (double) totalListItems); - imageEmbeddingRequestIndicator.setTopLabelLater("Received " + completed + " of " + totalListItems); + // When ALL are done (succeeded or failed), clean up + if (completed == totalListItems) { + imageEmbeddingRequestIndicator.spin(false); + imageEmbeddingRequestIndicator.fadeBusy(true); + outstandingRequests.clear(); + } + }); + scene.getRoot().addEventHandler(RestEvent.ERROR_EMBEDDINGS_IMAGE, event -> { + List failedInputIDs = (List) event.object; + int totalListItems = outstandingRequests.size(); - if (completed == totalListItems) { - imageEmbeddingRequestIndicator.spin(false); - imageEmbeddingRequestIndicator.fadeBusy(true); - outstandingRequests.clear(); - } -}); + for (Integer failedId : failedInputIDs) { + outstandingRequests.put(failedId, REQUEST_STATUS.FAILED); + } + + long succeeded = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.SUCCEEDED).count(); + long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); + long completed = succeeded + failed; + + imageEmbeddingRequestIndicator.setPercentComplete(completed / (double) totalListItems); + imageEmbeddingRequestIndicator.setTopLabelLater("Received " + completed + " of " + totalListItems); + + if (completed == totalListItems) { + imageEmbeddingRequestIndicator.spin(false); + imageEmbeddingRequestIndicator.fadeBusy(true); + outstandingRequests.clear(); + } + }); scene.getRoot().addEventHandler(RestEvent.ERROR_EMBEDDINGS_TEXT, event -> { List inputFiles = (List) event.object; @@ -1350,7 +1350,7 @@ public HyperdrivePane(Scene scene, Pane parent) { imageBatchLauncher = new ImageEmbeddingsBatchLauncher(scene, currentEmbeddingsModel); // Instantiate the manager imageEmbeddingManager = new BatchRequestManager<>( - maxInFlightBatches, // Maximum concurrent requests + maxInFlightBatches, // Maximum concurrent requests requestTimeoutMS, // Timeout in ms (e.g., 60s) 3, // Maximum retries per batch batchNumber::getAndIncrement, // batchNumber supplier @@ -1361,14 +1361,14 @@ public HyperdrivePane(Scene scene, Pane parent) { if (imageEmbeddingRequestIndicator != null) { imageEmbeddingRequestIndicator.setFadeTimeMS(250); imageEmbeddingRequestIndicator.spin(true); - if(!imageEmbeddingRequestIndicator.inView()) + if (!imageEmbeddingRequestIndicator.inView()) imageEmbeddingRequestIndicator.fadeBusy(false); imageEmbeddingRequestIndicator.setLabelLater( - "Encoding and sending Batch: " + batchNum - + " of " + imageEmbeddingManager.getTotalBatches() - ); - } - }); + "Encoding and sending Batch: " + batchNum + + " of " + imageEmbeddingManager.getTotalBatches() + ); + } + }); imageBatchLauncher.launchBatch(batch, batchNum, reqId, (success, ex) -> { if (success) { imageEmbeddingManager.completeSuccess(reqId, batchNum, batch, 0); @@ -1379,9 +1379,9 @@ public HyperdrivePane(Scene scene, Pane parent) { }, // onComplete: (success/failure/timeout) result -> { - //@DEBUG SMP - //System.out.println("Batch: " + result.getBatchNumber() - // + " Request: " + result.getRequestId() + //@DEBUG SMP + //System.out.println("Batch: " + result.getBatchNumber() + // + " Request: " + result.getRequestId() // + " Status: " + result.getStatus() // + " Attempt: " + result.getRetryCount() // + " In Flight: " + imageEmbeddingManager.getInFlight() @@ -1391,15 +1391,15 @@ public HyperdrivePane(Scene scene, Pane parent) { // imageEmbeddingManager.getAvgBatchDurationMillis()/1000) + " s" // + " Total Duration: " + imageEmbeddingManager.getTotalBatchDurationMillis()/1000 + " s" //); - + Platform.runLater(() -> { // update progress indicator, show alerts on failure, etc. long failed = outstandingRequests.values().stream().filter(s -> s == REQUEST_STATUS.FAILED).count(); imageEmbeddingRequestIndicator.setLabelLater( - "Batches completed: " + imageEmbeddingManager.getBatchesCompleted() - + " of " + imageEmbeddingManager.getTotalBatches() - + (failed > 0 ? (" | Errors: " + failed) : "") - ); + "Batches completed: " + imageEmbeddingManager.getBatchesCompleted() + + " of " + imageEmbeddingManager.getTotalBatches() + + (failed > 0 ? (" | Errors: " + failed) : "") + ); }); } ); @@ -1407,13 +1407,14 @@ public HyperdrivePane(Scene scene, Pane parent) { if (this != null) { imageEmbeddingManager.shutdown(); } - })); + })); } + public static void exportFeatureCollection(String title, FeatureCollection fc) { FileChooser saver = new FileChooser(); saver.setTitle(title); File saveAsFile = saver.showSaveDialog(null); - if(null != saveAsFile){ + if (null != saveAsFile) { FeatureCollectionFile file = new FeatureCollectionFile(saveAsFile.getAbsolutePath()); file.featureCollection = fc; try { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java new file mode 100644 index 00000000..60f8ccbf --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -0,0 +1,442 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.javafx.components.GraphControlsView; +import edu.jhuapl.trinity.javafx.components.GraphStyleControlsView; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; +import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.SurfaceUtils; +import edu.jhuapl.trinity.utils.DataUtils.HeightMode; +import javafx.event.Event; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.ToggleButton; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.DrawMode; + +public class HypersurfaceControlsPane extends LitPathPane { + + private static final int PANEL_WIDTH = 400; + private static final int PANEL_HEIGHT = 640; + + public static double SPINNER_PREF_WIDTH = 125.0; + public static double COMBO_PREF_WIDTH = 220.0; + public static double CHECKBOX_PREF_WIDTH = 100.0; + public static double COLOR_PICKER_PREF_WIDTH = 150.0; + + private final Hypersurface3DPane target; + + // Core controls + private Spinner yScaleSpinner; + private Spinner surfScaleSpinner; + private Spinner xWidthSpinner; + private Spinner zWidthSpinner; + + // Rendering + private ComboBox meshTypeCombo; + private ComboBox drawModeCombo; + private ComboBox cullFaceCombo; + private ComboBox colorationCombo; + + // Processing + private ComboBox heightModeCombo; + private CheckBox enableSmoothingCheck; + private ComboBox smoothingCombo; + private Spinner smoothingRadiusSpinner; + private Spinner gaussianSigmaSpinner; + private ComboBox interpCombo; + private CheckBox enableToneMapCheck; + private ComboBox toneMapCombo; + private Spinner toneParamSpinner; + + // Scene / Lighting + private ColorPicker bgPicker; + private CheckBox skyboxCheck; + private CheckBox enableAmbientCheck; + private ColorPicker ambientColorPicker; + private CheckBox enablePointCheck; + private ColorPicker specularColorPicker; + + // Graph overlay visibility + private ToggleButton showGraphToggle; + + public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane target) { + super(scene, parent, PANEL_WIDTH, PANEL_HEIGHT, new BorderPane(), "Hypersurface Controls", "", 200.0, 300.0); + this.scene = scene; + this.target = target; + + setPickOnBounds(false); + setFocusTraversable(false); + + BorderPane bp = (BorderPane) this.contentPane; + bp.setPadding(new Insets(6)); + bp.setCenter(buildTabs()); + + // --- GUI sync from model → controls + scene.addEventHandler(HypersurfaceEvent.SET_XWIDTH_GUI, e -> { + if (xWidthSpinner != null && !xWidthSpinner.getValue().equals((Integer) e.object)) { + xWidthSpinner.getValueFactory().setValue((Integer) e.object); + } + e.consume(); + }); + scene.addEventHandler(HypersurfaceEvent.SET_ZWIDTH_GUI, e -> { + if (zWidthSpinner != null && !zWidthSpinner.getValue().equals((Integer) e.object)) { + zWidthSpinner.getValueFactory().setValue((Integer) e.object); + } + e.consume(); + }); + scene.addEventHandler(HypersurfaceEvent.SET_YSCALE_GUI, e -> { + if (yScaleSpinner != null && !yScaleSpinner.getValue().equals((Double) e.object)) { + yScaleSpinner.getValueFactory().setValue((Double) e.object); + } + e.consume(); + }); + scene.addEventHandler(HypersurfaceEvent.SET_SURFSCALE_GUI, e -> { + if (surfScaleSpinner != null && !surfScaleSpinner.getValue().equals((Double) e.object)) { + surfScaleSpinner.getValueFactory().setValue((Double) e.object); + } + e.consume(); + }); + + // Graph overlay visibility GUI sync + scene.addEventHandler(GraphEvent.SET_GRAPH_VISIBILITY_GUI, e -> { + if (showGraphToggle != null) { + boolean v = (Boolean) e.object; + if (showGraphToggle.isSelected() != v) showGraphToggle.setSelected(v); + } + e.consume(); + }); + } + + private TabPane buildTabs() { + // Initial values + double yScale0 = (target != null) ? target.yScale : 5.0; + double surfScale0 = (target != null) ? target.surfScale : 5.0; + int xWidth0 = (target != null) ? target.xWidth : Hypersurface3DPane.DEFAULT_XWIDTH; + int zWidth0 = (target != null) ? target.zWidth : Hypersurface3DPane.DEFAULT_ZWIDTH; + Color bg0 = (target != null) ? target.sceneColor : Color.BLACK; + + // === Dimensions === + GridPane dimsGrid = formGrid(); + xWidthSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, xWidth0, 4)); + zWidthSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, zWidth0, 10)); + yScaleSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 500.0, yScale0, 1.0)); + surfScaleSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 100.0, surfScale0, 1.0)); + styleSpinner(xWidthSpinner); + styleSpinner(zWidthSpinner); + styleSpinner(yScaleSpinner); + styleSpinner(surfScaleSpinner); + + xWidthSpinner.setEditable(true); + zWidthSpinner.setEditable(true); + yScaleSpinner.setEditable(true); + surfScaleSpinner.setEditable(true); + + addRow(dimsGrid, 0, "X width", xWidthSpinner); + addRow(dimsGrid, 1, "Z length", zWidthSpinner); + addRow(dimsGrid, 2, "Y scale", yScaleSpinner); + addRow(dimsGrid, 3, "Range scale", surfScaleSpinner); + + // Core: Spinner value listeners + xWidthSpinner.valueProperty().addListener((obs, oldVal, newVal) -> + fireOnRoot(HypersurfaceEvent.xWidth(newVal))); + zWidthSpinner.valueProperty().addListener((obs, oldVal, newVal) -> + fireOnRoot(HypersurfaceEvent.zWidth(newVal))); + yScaleSpinner.valueProperty().addListener((obs, oldVal, newVal) -> + fireOnRoot(HypersurfaceEvent.yScale(newVal))); + surfScaleSpinner.valueProperty().addListener((obs, oldVal, newVal) -> + fireOnRoot(HypersurfaceEvent.surfScale(newVal))); + + // === Rendering === + GridPane renderGrid = formGrid(); + meshTypeCombo = new ComboBox<>(); + meshTypeCombo.getItems().addAll("Surface", "Cylindrical"); + meshTypeCombo.getSelectionModel().select("Surface"); + styleCombo(meshTypeCombo); + addRow(renderGrid, 0, "Mesh", meshTypeCombo); + + drawModeCombo = new ComboBox<>(); + drawModeCombo.getItems().addAll(DrawMode.LINE, DrawMode.FILL); + drawModeCombo.getSelectionModel().select(DrawMode.LINE); + styleCombo(drawModeCombo); + addRow(renderGrid, 1, "Draw", drawModeCombo); + + cullFaceCombo = new ComboBox<>(); + cullFaceCombo.getItems().addAll(CullFace.FRONT, CullFace.BACK, CullFace.NONE); + cullFaceCombo.getSelectionModel().select(CullFace.NONE); + styleCombo(cullFaceCombo); + addRow(renderGrid, 2, "Cull", cullFaceCombo); + + colorationCombo = new ComboBox<>(); + colorationCombo.getItems().addAll( + Hypersurface3DPane.COLORATION.COLOR_BY_IMAGE, + Hypersurface3DPane.COLORATION.COLOR_BY_FEATURE, + Hypersurface3DPane.COLORATION.COLOR_BY_SHAPLEY + ); + colorationCombo.getSelectionModel().select(Hypersurface3DPane.COLORATION.COLOR_BY_FEATURE); + styleCombo(colorationCombo); + addRow(renderGrid, 3, "Color", colorationCombo); + + meshTypeCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.surfaceRender("Surface".equals(meshTypeCombo.getValue())))); + drawModeCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.drawMode(drawModeCombo.getValue()))); + cullFaceCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.cullFace(cullFaceCombo.getValue()))); + colorationCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.coloration(colorationCombo.getValue()))); + + // === Scene / Lighting === + GridPane sceneGrid = formGrid(); + bgPicker = new ColorPicker(bg0); + styleColorPicker(bgPicker); + skyboxCheck = new CheckBox("Skybox"); + styleCheck(skyboxCheck); + + addRow(sceneGrid, 0, "Background", bgPicker); + addRow(sceneGrid, 1, "Sky", skyboxCheck); + + bgPicker.setOnAction(e -> + fireOnRoot(new HyperspaceEvent(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, bgPicker.getValue()))); + skyboxCheck.setOnAction(e -> + fireOnRoot(new HyperspaceEvent(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, skyboxCheck.isSelected()))); + + enableAmbientCheck = new CheckBox("Ambient"); + enableAmbientCheck.setSelected(true); + styleCheck(enableAmbientCheck); + ambientColorPicker = new ColorPicker(Color.WHITE); + styleColorPicker(ambientColorPicker); + + HBox ambientBox = new HBox(6, enableAmbientCheck, ambientColorPicker); + addRow(sceneGrid, 2, "Ambient", ambientBox); + + enableAmbientCheck.setOnAction(e -> { + boolean on = enableAmbientCheck.isSelected(); + ambientColorPicker.setDisable(!on); + fireOnRoot(HypersurfaceEvent.ambientEnabled(on)); + }); + ambientColorPicker.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.ambientColor(ambientColorPicker.getValue()))); + + enablePointCheck = new CheckBox("Point light"); + enablePointCheck.setSelected(true); + styleCheck(enablePointCheck); + specularColorPicker = new ColorPicker(Color.CYAN); + styleColorPicker(specularColorPicker); + + HBox pointBox = new HBox(6, enablePointCheck, specularColorPicker); + addRow(sceneGrid, 3, "Point", pointBox); + + enablePointCheck.setOnAction(e -> { + boolean on = enablePointCheck.isSelected(); + specularColorPicker.setDisable(!on); + fireOnRoot(HypersurfaceEvent.pointEnabled(on)); + }); + specularColorPicker.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.specularColor(specularColorPicker.getValue()))); + + // --- Graph Overlay visibility --- + GridPane graphOverlayGrid = formGrid(); + showGraphToggle = new ToggleButton("Show Graph"); + showGraphToggle.setSelected(true); // will be synced on startup via SET_GRAPH_VISIBILITY_GUI + showGraphToggle.setOnAction(e -> + fireOnRoot(new GraphEvent(GraphEvent.GRAPH_VISIBILITY_CHANGED, showGraphToggle.isSelected())) + ); + addRow(graphOverlayGrid, 0, "Graph Overlay", showGraphToggle); + + VBox viewTabContent = new VBox(10, + titledBox("Dimensions", dimsGrid), + titledBox("Rendering", renderGrid), + titledBox("Scene", sceneGrid), + titledBox("Graph Overlay", graphOverlayGrid) + ); + viewTabContent.setPadding(new Insets(6)); + + // === Processing tab === + GridPane heightGrid = formGrid(); + heightModeCombo = new ComboBox<>(); + heightModeCombo.getItems().addAll(HeightMode.values()); + heightModeCombo.getSelectionModel().select(HeightMode.RAW); + styleCombo(heightModeCombo); + addRow(heightGrid, 0, "Height mode", heightModeCombo); + heightModeCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.heightMode(heightModeCombo.getValue()))); + + GridPane smoothingGrid = formGrid(); + enableSmoothingCheck = new CheckBox("Enable"); + styleCheck(enableSmoothingCheck); + smoothingCombo = new ComboBox<>(); + smoothingCombo.getItems().addAll(SurfaceUtils.Smoothing.values()); + smoothingCombo.getSelectionModel().select(SurfaceUtils.Smoothing.GAUSSIAN); + styleCombo(smoothingCombo); + smoothingRadiusSpinner = new Spinner<>(1, 25, 2, 1); + styleSpinner(smoothingRadiusSpinner); + gaussianSigmaSpinner = new Spinner<>(0.10, 10.0, 1.0, 0.10); + styleSpinner(gaussianSigmaSpinner); + + smoothingRadiusSpinner.setEditable(true); + gaussianSigmaSpinner.setEditable(true); + + addRow(smoothingGrid, 0, "Smoothing", enableSmoothingCheck); + addRow(smoothingGrid, 1, "Method", smoothingCombo); + addRow(smoothingGrid, 2, "Radius", smoothingRadiusSpinner); + addRow(smoothingGrid, 3, "Sigma", gaussianSigmaSpinner); + + enableSmoothingCheck.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.smoothingEnabled(enableSmoothingCheck.isSelected()))); + smoothingCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.smoothingMethod(smoothingCombo.getValue()))); + smoothingRadiusSpinner.valueProperty().addListener((o, ov, nv) -> + fireOnRoot(HypersurfaceEvent.smoothingRadius(nv))); + gaussianSigmaSpinner.valueProperty().addListener((o, ov, nv) -> + fireOnRoot(HypersurfaceEvent.gaussianSigma(nv))); + + GridPane interpGrid = formGrid(); + interpCombo = new ComboBox<>(); + interpCombo.getItems().addAll(SurfaceUtils.Interpolation.values()); + interpCombo.getSelectionModel().select(SurfaceUtils.Interpolation.NEAREST); + styleCombo(interpCombo); + addRow(interpGrid, 0, "Mode", interpCombo); + interpCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.interp(interpCombo.getValue()))); + + GridPane toneGrid = formGrid(); + enableToneMapCheck = new CheckBox("Enable"); + styleCheck(enableToneMapCheck); + toneMapCombo = new ComboBox<>(); + toneMapCombo.getItems().addAll(SurfaceUtils.ToneMap.values()); + toneMapCombo.getSelectionModel().select(SurfaceUtils.ToneMap.NONE); + styleCombo(toneMapCombo); + toneParamSpinner = new Spinner<>(0.10, 10.0, 2.0, 0.10); + styleSpinner(toneParamSpinner); + + toneParamSpinner.setEditable(true); + + addRow(toneGrid, 0, "Tone map", enableToneMapCheck); + addRow(toneGrid, 1, "Operator", toneMapCombo); + addRow(toneGrid, 2, "k / γ", toneParamSpinner); + + enableToneMapCheck.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.toneEnabled(enableToneMapCheck.isSelected()))); + toneMapCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.toneOperator(toneMapCombo.getValue()))); + toneParamSpinner.valueProperty().addListener((o, ov, nv) -> + fireOnRoot(HypersurfaceEvent.toneParam(nv))); + + VBox procTabContent = new VBox(10, + titledBox("Height", heightGrid), + titledBox("Smoothing", smoothingGrid), + titledBox("Interpolation", interpGrid), + titledBox("Tone Mapping", toneGrid) + ); + procTabContent.setPadding(new Insets(6)); + + // === TabPane === + TabPane tabs = new TabPane(); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabs.setPrefWidth(PANEL_WIDTH - 8); + + GraphControlsView graphLayoutView = new GraphControlsView(scene); + GraphStyleControlsView graphStyleView = new GraphStyleControlsView(scene); + + Tab t1 = new Tab("View", viewTabContent); + Tab t2 = new Tab("Processing", procTabContent); + Tab t3 = new Tab("Graph Layout", graphLayoutView); + Tab t4 = new Tab("Graph Style", graphStyleView); + + tabs.getTabs().addAll(t1, t2, t3, t4); + return tabs; + } + + private void fireOnRoot(Event evt) { + if (scene != null && scene.getRoot() != null) { + scene.getRoot().fireEvent(evt); + } else { + this.fireEvent(evt); + } + } + + private static GridPane formGrid() { + GridPane gp = new GridPane(); + gp.setHgap(8); + gp.setVgap(6); + gp.setAlignment(Pos.TOP_LEFT); + + ColumnConstraints c0 = new ColumnConstraints(); + c0.setPercentWidth(25); + + ColumnConstraints c1 = new ColumnConstraints(); + c1.setPercentWidth(75); + c1.setHgrow(Priority.ALWAYS); + + gp.getColumnConstraints().addAll(c0, c1); + return gp; + } + + private static void addRow(GridPane gp, int row, String label, javafx.scene.Node control) { + Label l = new Label(label); + gp.add(l, 0, row); + + if (control instanceof Spinner || control instanceof ComboBox) { + GridPane.setHgrow(control, Priority.NEVER); + } else { + GridPane.setHgrow(control, Priority.ALWAYS); + if (control instanceof javafx.scene.control.Control control1) { + control1.setMaxWidth(Double.MAX_VALUE); + } + } + gp.add(control, 1, row); + } + + private static VBox titledBox(String title, javafx.scene.Node content) { + Label t = new Label(title); + t.getStyleClass().add("section-title"); + VBox box = new VBox(6, t, new Separator(), content); + box.setPadding(new Insets(4, 2, 6, 2)); + return box; + } + + private static void styleSpinner(Spinner spinner) { + spinner.setPrefWidth(SPINNER_PREF_WIDTH); + spinner.setMaxWidth(SPINNER_PREF_WIDTH); + spinner.setMinWidth(Region.USE_PREF_SIZE); + } + + private static void styleCombo(ComboBox combo) { + combo.setPrefWidth(COMBO_PREF_WIDTH); + combo.setMaxWidth(COMBO_PREF_WIDTH); + combo.setMinWidth(Region.USE_PREF_SIZE); + } + + private static void styleCheck(CheckBox cb) { + cb.setPrefWidth(CHECKBOX_PREF_WIDTH); + cb.setMaxWidth(CHECKBOX_PREF_WIDTH); + cb.setMinWidth(Region.USE_PREF_SIZE); + } + + private static void styleColorPicker(ColorPicker cp) { + cp.setPrefWidth(COLOR_PICKER_PREF_WIDTH); + cp.setMaxWidth(COLOR_PICKER_PREF_WIDTH); + cp.setMinWidth(Region.USE_PREF_SIZE); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java index a092d88f..52ab58c0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.javafx.components.panes; +import edu.jhuapl.trinity.javafx.components.ExportMicroToolbar; import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.animation.FadeTransition; import javafx.animation.KeyFrame; @@ -53,7 +54,9 @@ public class LitPathPane extends PathPane { double fadeSideInset = -5; double hoverTopInset = -2; double hoverSideInset = -3; + double toolbarFitWidth = 32; double effectsFitWidth = 40; + public double mainContentBorderFrameBuffer = 64; public Background opaqueBackground; public Background defaultBackground; public Background background; @@ -157,14 +160,24 @@ public LitPathPane(Scene scene, Pane parent, int width, int height, Pane userCon setMinWidth(300); setMinHeight(200); setEffects(); + ExportMicroToolbar toolbar = new ExportMicroToolbar( + mainTitleArea, // title bar with free space + this, // whole pane (for "include frame") + contentPane, // content-only + mainTitleArea, // right-click target + this.scene, + toolbarFitWidth + ); + toolbar.installInTitleBarRight(); // or toolbar.installInTitleBarRight(12.0, 0.0); + mainContentBorderFrame.widthProperty().addListener(cl -> { if (!animating) { - contentPane.setPrefWidth(mainContentBorderFrame.getWidth() - 100); + contentPane.setPrefWidth(mainContentBorderFrame.getWidth() - mainContentBorderFrameBuffer); } }); mainContentBorderFrame.heightProperty().addListener(cl -> { if (!animating) { - contentPane.setPrefHeight(mainContentBorderFrame.getHeight() - 100); + contentPane.setPrefHeight(mainContentBorderFrame.getHeight() - mainContentBorderFrameBuffer); } }); opaqueBackground = new Background(new BackgroundFill( @@ -237,7 +250,7 @@ CornerRadii.EMPTY, new BorderWidths(1), this.scene.getRoot().addEventHandler(CovalentPaneEvent.COVALENT_PANE_CLOSE, e -> { if (e.pathPane == this) - onClose(); + close(); }); this.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> this.toFront()); gradientTimeline = setupGradientTimeline(); @@ -262,9 +275,17 @@ CornerRadii.EMPTY, new BorderWidths(1), contentPane.setOpacity(0.8); }); } + + @Override + public void close() { + super.close(); + onClose(); + } + public void onClose() { parent.getChildren().remove(this); } + public Timeline setupGradientTimeline() { Timeline timeline = new Timeline( new KeyFrame(Duration.millis(30), new KeyValue(percentComplete, 0.0)), diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java new file mode 100644 index 00000000..9c29dce9 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java @@ -0,0 +1,206 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.javafx.components.MatrixHeatmapView; +import edu.jhuapl.trinity.javafx.components.MatrixHeatmapView.MatrixClick; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import java.util.List; +import java.util.function.Consumer; + +/** + * MatrixHeatmapPane + * ----------------- + * Floating-pane wrapper around {@link MatrixHeatmapView} so it integrates with + * Trinity's windowing system (matches the pattern used by PairwiseJpdfPane). + *

+ * Usage: + * MatrixHeatmapPane pane = new MatrixHeatmapPane(scene, parent); + * pane.setMatrix(values); + * pane.setAxisLabels(labels); + * pane.useSequentialPalette(); // or pane.useDivergingPalette(0.0); + * pane.setAutoRange(true); // or pane.setFixedRange(min, max); + * pane.setShowLegend(true); + * pane.setOnCellClick(click -> { ... }); + * + * @author Sean Phillips + */ +public final class MatrixHeatmapPane extends LitPathPane { + private static final Logger LOG = LoggerFactory.getLogger(MatrixHeatmapPane.class); + private final MatrixHeatmapView view; + + /** + * Preferred constructor matching your floating-pane pattern. + */ + public MatrixHeatmapPane(Scene scene, Pane parent) { + super( + scene, + parent, + 900, // pref width + 640, // pref height + new MatrixHeatmapView(), + "Matrix Heatmap", // window title + "Analysis", // category badge + 380.0, // min width before popout + 300.0 // min height before popout + ); + this.view = (MatrixHeatmapView) this.contentPane; + + // Wire default toast handler to CommandTerminal + setToastHandler(msg -> Platform.runLater(() -> + scene.getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN) + ))); + } + + /** + * Alternate constructor allowing an already-created view (rare). + */ + public MatrixHeatmapPane(Scene scene, Pane parent, MatrixHeatmapView customView) { + super( + scene, + parent, + 900, + 640, + customView != null ? customView : new MatrixHeatmapView(), + "Matrix Heatmap", + "Analysis", + 380.0, + 300.0 + ); + this.view = (MatrixHeatmapView) this.contentPane; + + setToastHandler(msg -> Platform.runLater(() -> + scene.getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN) + ))); + } + + // --------------------------------------------------------------------- + // Public API (thin pass-throughs to MatrixHeatmapView) + // --------------------------------------------------------------------- + + /** + * Replace the matrix (null/empty clears the view). + */ + public void setMatrix(double[][] matrix) { + view.setMatrix(matrix); + } + + /** + * Convenience overload for List> matrices. + */ + public void setMatrix(List> matrix) { + view.setMatrix(matrix); + } + + /** + * Apply the same labels to rows and columns (square matrices). + */ + public void setAxisLabels(List labels) { + if (labels == null) return; + view.setRowLabels(labels); + view.setColLabels(labels); + } + + /** + * Set row labels only. + */ + public void setRowLabels(List labels) { + view.setRowLabels(labels); + } + + /** + * Set column labels only. + */ + public void setColLabels(List labels) { + view.setColLabels(labels); + } + + /** + * Use a sequential (single-hue) palette. + */ + public void useSequentialPalette() { + view.useSequentialPalette(); + } + + /** + * Use a diverging palette split around the given center value. + */ + public void useDivergingPalette(double center) { + view.useDivergingPalette(center); + } + + /** + * Map values using auto min/max derived from current matrix. + */ + public void setAutoRange(boolean on) { + view.setAutoRange(on); + } + + /** + * Map values using an explicit [vmin, vmax] range. + */ + public void setFixedRange(double vmin, double vmax) { + view.setFixedRange(vmin, vmax); + } + + /** + * Show or hide the legend bar. + */ + public void setShowLegend(boolean show) { + view.setShowLegend(show); + } + + /** + * Handle cell clicks (row, col, value). + */ + public void setOnCellClick(Consumer handler) { + view.setOnCellClick(handler); + } + + /** + * Access to the embedded view for advanced customization. + */ + public MatrixHeatmapView getView() { + return view; + } + + // --------------------------------------------------------------------- + // Toast helper (same pattern used in PairwiseJpdfPane) + // --------------------------------------------------------------------- + + private Consumer toastHandler; + + public void setToastHandler(Consumer handler) { + this.toastHandler = handler; + } + + public void toast(String msg, boolean isError) { + Consumer h = this.toastHandler; + String prefixed = (isError ? "[Error] " : "[Info] ") + (msg == null ? "" : msg); + if (h != null) { + h.accept(prefixed); + } else { + LOG.atLevel(isError ? Level.ERROR : Level.INFO).log(msg); + } + } + + // --------------------------------------------------------------------- + // Optional: You can override maximize() to emit a popout ApplicationEvent + // when you add a dedicated event type for matrix heatmaps in your app. + // --------------------------------------------------------------------- + // @Override + // public void maximize() { + // scene.getRoot().fireEvent( + // new ApplicationEvent(ApplicationEvent.POPOUT_MATRIX_HEATMAP, Boolean.TRUE) + // ); + // } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java new file mode 100644 index 00000000..be629b77 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -0,0 +1,99 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.PairwiseJpdfView; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; +import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +import java.util.List; + +public final class PairwiseJpdfPane extends LitPathPane { + + private final PairwiseJpdfView view; + + public PairwiseJpdfPane( + Scene scene, + Pane parent, + JpdfBatchEngine engine, + DensityCache cache, + PairwiseJpdfConfigPanel configPanel + ) { + super(scene, parent, + 1100, 760, + new PairwiseJpdfView(engine, cache, configPanel), + "Pairwise Joint Densities", "Batch", + 420.0, 400.0); + + this.view = (PairwiseJpdfView) this.contentPane; + + // Wire up NEW_FEATURE_COLLECTION event to update Cohort A + scene.addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, e -> { + if (e.object instanceof FeatureCollection fc && fc.getFeatures() != null) { + view.setCohortA(fc.getFeatures(), "A"); + view.toast("Loaded " + fc.getFeatures().size() + " vectors into Cohort A.", false); + } + }); + + // Wire up cell click to open in 3D + view.setOnCellClick(item -> { + if (item == null || item.res == null) return; + GridDensityResult res = item.res; + var gridList = res.pdfAsListGrid(); + scene.getRoot().fireEvent(new HypersurfaceGridEvent( + HypersurfaceGridEvent.RENDER_PDF, + gridList, + res.getxCenters(), + res.getyCenters(), + item.xLabel + " | " + item.yLabel + " (PDF)" + )); + view.toast("Opened PDF in 3D.", false); + }); + + // Wire up toast to send to terminal + view.setToastHandler(msg -> { + Platform.runLater(() -> scene.getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN))); + }); + } + + public PairwiseJpdfPane(Scene scene, Pane parent) { + this(scene, parent, null, null, null); + } + + // --- Forwarders for public API compatibility --- + public void setCohortA(List vectors, String label) { + view.setCohortA(vectors, label); + } + + public void setCohortB(List vectors, String label) { + view.setCohortB(vectors, label); + } + + public void runWithRecipe(JpdfRecipe recipe) { + view.runWithRecipe(recipe); + } + + public PairwiseJpdfView getView() { + return view; + } + + @Override + public void maximize() { + scene.getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISEJPDF_JPDF, Boolean.TRUE) + ); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java new file mode 100644 index 00000000..243e483a --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java @@ -0,0 +1,131 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.PairwiseMatrixView; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixConfigPanel; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +import java.util.List; + +/** + * PairwiseMatrixPane + * ------------------ + * Floating pane wrapper that hosts a {@link PairwiseMatrixView} and integrates + * with Trinity’s event bus (e.g., NEW_FEATURE_COLLECTION → Cohort A) and terminal toasts. + *

+ * This mirrors the structure of PairwiseJpdfPane. + * + * @author Sean Phillips + */ +public final class PairwiseMatrixPane extends LitPathPane { + + private final PairwiseMatrixView view; + + public PairwiseMatrixPane( + Scene scene, + Pane parent, + DensityCache cache, + PairwiseMatrixConfigPanel configPanel + ) { + super(scene, parent, + 1100, 760, + new PairwiseMatrixView(configPanel, cache), + "Pairwise Matrices", "Matrix", + 420.0, 400.0); + + this.view = (PairwiseMatrixView) this.contentPane; + + // Event: load Cohort A from FeatureCollection + scene.addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, e -> { + if (e.object instanceof FeatureCollection fc && fc.getFeatures() != null) { + view.setCohortA(fc.getFeatures(), "A"); + toast("Loaded " + fc.getFeatures().size() + " vectors into Cohort A.", false); + } + }); + scene.addEventHandler(GraphEvent.GRAPH_REBUILD_PARAMS, e -> { + GraphLayoutParams p = (GraphLayoutParams) e.object; + view.triggerGraphBuildWithParams(p); + }); + + // wire matrix cell clicks (i,j,value). + view.setOnCellClick(click -> { + if (click == null) return; + // Use the latest request the user executed (or rebuild one) + var req = view.getLastRequestOrBuild(); + view.renderPdfForCellUsingEngine(click.row, click.col, req); + String msg = "Cell (" + click.row + "," + click.col + ") = " + click.value; + toast(msg, false); + }); + // Forward view toasts to the command terminal + view.setToastHandler(msg -> { + Platform.runLater(() -> scene.getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN))); + }); + } + + public PairwiseMatrixPane(Scene scene, Pane parent) { + this(scene, parent, null, null); + } + + // --- Forwarders for convenience (match your JPDF pane’s public API style) --- + + public void setCohortA(List vectors, String label) { + view.setCohortA(vectors, label); + } + + public void setCohortB(List vectors, String label) { + view.setCohortB(vectors, label); + } + + public PairwiseMatrixView getView() { + return view; + } + // --- Helpers to fabricate/prepare Cohort B --- + + /** + * Copy Cohort A into Cohort B (A vs A sanity check). + */ + public void useCohortAForB(String label) { + var a = view.getCohortA(); + if (a == null || a.isEmpty()) return; + // Shallow copy of the list is fine for test purposes + setCohortB(new java.util.ArrayList<>(a), + (label != null && !label.isBlank()) ? label : view.getCohortALabel() + " (copy)"); + } + + /** + * Split Cohort A into two halves: first half -> A, second half -> B. + */ + public void splitACohortIntoAandB() { + var a = view.getCohortA(); + if (a == null || a.size() < 2) return; + int mid = a.size() / 2; + setCohortB(new java.util.ArrayList<>(a.subList(mid, a.size())), view.getCohortALabel() + " (late)"); + setCohortA(new java.util.ArrayList<>(a.subList(0, mid)), view.getCohortALabel() + " (early)"); + } + + + @Override + public void maximize() { + // You mentioned you'll wire the pop-out event yourself later. + // Keeping default behavior; call super to preserve LitPathPane's handling. + super.maximize(); + } + + private void toast(String msg, boolean isError) { + String text = (isError ? "[Error] " : "") + msg; + Platform.runLater(() -> scene.getRoot().fireEvent( + new CommandTerminalEvent(text, new Font("Consolas", 18), + isError ? Color.PINK : Color.LIGHTGREEN))); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java new file mode 100644 index 00000000..78034651 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java @@ -0,0 +1,133 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import edu.jhuapl.trinity.utils.statistics.StatPdfCdfChartPanel; +import edu.jhuapl.trinity.utils.statistics.StatisticEngine; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; + +import java.util.List; + +/** + * Floating statistics PDF/CDF pane for Trinity analytics. + * Displays a single StatPdfCdfChartPanel, fully resizable and ready for future controls. + *

+ * Popout behavior: triggered exclusively via {@link #maximize()} which + * fires an ApplicationEvent (POPOUT_STATPDFCDF) that a controller listens for. + * + * @author Sean Phillips + */ +public class StatPdfCdfPane extends LitPathPane { + + private static final int DEFAULT_WIDTH = 700; + private static final int DEFAULT_HEIGHT = 500; + + private BorderPane borderPane; + private StatPdfCdfChartPanel chartPanel; + + /** + * Create a floating PDF/CDF chart pane, with NO data or chart selected. + * User can set data and chart options later. + */ + public StatPdfCdfPane(Scene scene, Pane parent) { + super( + scene, + parent, + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + new BorderPane(), + "Statistics Chart", + "PDF and CDF", + 300.0, + 400.0 + ); + borderPane = (BorderPane) this.contentPane; + borderPane.setPadding(new Insets(8)); + + chartPanel = new StatPdfCdfChartPanel(); // empty state + chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); + borderPane.setCenter(chartPanel); + } + + /** + * Create a floating PDF/CDF chart pane with initial data and settings. + */ + public StatPdfCdfPane( + Scene scene, + Pane parent, + List vectors, + StatisticEngine.ScalarType scalarType, + int bins + ) { + super( + scene, + parent, + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + new BorderPane(), + "Statistics Chart", + "PDF and CDF", + 300.0, + 400.0 + ); + borderPane = (BorderPane) this.contentPane; + borderPane.setPadding(new Insets(8)); + + chartPanel = new StatPdfCdfChartPanel(vectors, scalarType, bins); + chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); + borderPane.setCenter(chartPanel); + } + + @Override + public void maximize() { + this.scene.getRoot().fireEvent( + new ApplicationEvent( + ApplicationEvent.POPOUT_STATISTICS_PANE, Boolean.TRUE)); + } + + private void onComputeSurface(GridDensityResult result) { + boolean useCDF = chartPanel.isSurfaceCDF(); + + List> grid = useCDF + ? result.cdfAsListGrid() + : result.pdfAsListGrid(); + + String label = (useCDF ? "CDF" : "PDF") + " : " + + chartPanel.getScalarType() + " vs " + + chartPanel.getYFeatureTypeForDisplay(); + + if (getScene() != null) { + getScene().getRoot().fireEvent( + new edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent( + useCDF + ? edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent.RENDER_CDF + : edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent.RENDER_PDF, + grid, + result.getxCenters(), + result.getyCenters(), + label + ) + ); + } + } + + public void setFeatureVectors(List vectors) { + chartPanel.setFeatureVectors(vectors); + } + + public StatPdfCdfChartPanel getChartPanel() { + return chartPanel; + } + + public StatisticEngine.ScalarType getScalarType() { + return chartPanel.getScalarType(); + } + + public int getBins() { + return chartPanel.getBins(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java index 558ae2cb..8f790075 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java @@ -29,7 +29,7 @@ public SurfaceChartPane(Scene scene, Pane parent) { fcb = new FactorControlBox(500, 400); avb = new AnalysisVectorBox(500, 400); bp = (BorderPane) this.contentPane; - + Tab factorTab = new Tab("Factor Vectors"); factorTab.setClosable(false); factorTab.setContent(fcb); @@ -37,10 +37,10 @@ public SurfaceChartPane(Scene scene, Pane parent) { Tab analysisTab = new Tab("Analysis Vector"); analysisTab.setClosable(false); analysisTab.setContent(avb); - + TabPane tabPane = new TabPane(factorTab, analysisTab); bp.setCenter(tabPane); - + this.scene.getRoot().addEventHandler(FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, e -> { fcb.setFactorVector(fcb.xFactorVector, (Double[]) e.object1); }); @@ -48,8 +48,8 @@ public SurfaceChartPane(Scene scene, Pane parent) { fcb.setFactorVector(fcb.zFactorVector, (Double[]) e.object1); }); this.scene.getRoot().addEventHandler(FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, e -> { - avb.setAnalysisVector((String)e.object1, (Double[]) e.object2); + avb.setAnalysisVector((String) e.object1, (Double[]) e.object2); }); - + } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceControlPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceControlPane.java deleted file mode 100644 index 61085b27..00000000 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceControlPane.java +++ /dev/null @@ -1,227 +0,0 @@ -package edu.jhuapl.trinity.javafx.components.panes; - -import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; -import javafx.geometry.Pos; -import javafx.scene.Scene; -import javafx.scene.control.CheckBox; -import javafx.scene.control.ColorPicker; -import javafx.scene.control.Label; -import javafx.scene.control.RadioButton; -import javafx.scene.control.Spinner; -import javafx.scene.control.SpinnerValueFactory; -import javafx.scene.control.ToggleGroup; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; -import javafx.scene.paint.PhongMaterial; -import javafx.scene.shape.CullFace; -import javafx.scene.shape.DrawMode; - -/** - * @author Sean Phillips - */ -public class SurfaceControlPane extends LitPathPane { - public static double ICON_FIT_HEIGHT = 64; - public static double ICON_FIT_WIDTH = 64; - BorderPane bp; - Hypersurface3DPane hypersurface; - Spinner xWidthSpinner, zWidthSpinner; - - private static BorderPane createContent() { - BorderPane bpOilSpill = new BorderPane(); - return bpOilSpill; - } - - public SurfaceControlPane(Scene scene, Pane parent, Hypersurface3DPane hypersurface) { - super(scene, parent, 400, 600, createContent(), "Hypersurface Controls ", "", 200.0, 300.0); - this.scene = scene; - this.hypersurface = hypersurface; - bp = (BorderPane) this.contentPane; - buildControls(); - } - - private void buildControls() { - Spinner yScaleSpinner = new Spinner( - new SpinnerValueFactory.DoubleSpinnerValueFactory( - 0.0, 100.0, hypersurface.yScale, 1.00)); - yScaleSpinner.setEditable(true); - //whenever the spinner value is changed... - yScaleSpinner.valueProperty().addListener(e -> { - hypersurface.yScale = ((Double) yScaleSpinner.getValue()).floatValue(); - hypersurface.surfPlot.setFunctionScale(hypersurface.yScale); - hypersurface.updateTheMesh(); - }); - yScaleSpinner.setPrefWidth(125); - Spinner surfScaleSpinner = new Spinner( - new SpinnerValueFactory.DoubleSpinnerValueFactory( - 0.0, 100.0, hypersurface.surfScale, 1.0)); - surfScaleSpinner.setEditable(true); - //whenever the spinner value is changed... - surfScaleSpinner.valueProperty().addListener(e -> { - hypersurface.surfScale = ((Double) surfScaleSpinner.getValue()).floatValue(); - hypersurface.surfPlot.setRangeX(hypersurface.xWidth * hypersurface.surfScale); - hypersurface.surfPlot.setRangeY(hypersurface.zWidth * hypersurface.surfScale); - hypersurface.updateTheMesh(); - hypersurface.surfPlot.setTranslateX(-(hypersurface.xWidth * hypersurface.surfScale) / 2.0); - hypersurface.surfPlot.setTranslateZ(-(hypersurface.zWidth * hypersurface.surfScale) / 2.0); - }); - surfScaleSpinner.setPrefWidth(125); -// Spinner divisionsSpinner = new Spinner( -// new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 512, 64, 4)); -// divisionsSpinner.setEditable(true); -// //whenever the spinner value is changed... -// divisionsSpinner.valueProperty().addListener(e -> { -// surfPlot.setDivisionsX((int) divisionsSpinner.getValue()); -// surfPlot.setDivisionsY((int) divisionsSpinner.getValue()); -// updateTheMesh(); -// }); -// divisionsSpinner.setPrefWidth(125); - - xWidthSpinner = new Spinner( - new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, 200, 4)); - xWidthSpinner.setEditable(true); - //whenever the spinner value is changed... - xWidthSpinner.valueProperty().addListener(e -> { - hypersurface.xWidth = ((int) xWidthSpinner.getValue()); - hypersurface.updateTheMesh(); - hypersurface.surfPlot.setTranslateX(-(hypersurface.xWidth * hypersurface.surfScale) / 2.0); - hypersurface.surfPlot.setTranslateZ(-(hypersurface.zWidth * hypersurface.surfScale) / 2.0); - }); - xWidthSpinner.setPrefWidth(125); - zWidthSpinner = new Spinner( - new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, 200, 10)); - zWidthSpinner.setEditable(true); - //whenever the spinner value is changed... - zWidthSpinner.valueProperty().addListener(e -> { - hypersurface.zWidth = ((int) zWidthSpinner.getValue()); - hypersurface.updateTheMesh(); - hypersurface.surfPlot.setTranslateX(-(hypersurface.xWidth * hypersurface.surfScale) / 2.0); - hypersurface.surfPlot.setTranslateZ(-(hypersurface.zWidth * hypersurface.surfScale) / 2.0); - }); - zWidthSpinner.setPrefWidth(125); - ToggleGroup meshTypeToggle = new ToggleGroup(); - RadioButton surfaceRadioButton = new RadioButton("Surface Projection"); - surfaceRadioButton.setSelected(true); - surfaceRadioButton.setToggleGroup(meshTypeToggle); - RadioButton cylinderRadioButton = new RadioButton("Cylindrical"); - cylinderRadioButton.setToggleGroup(meshTypeToggle); - meshTypeToggle.selectedToggleProperty().addListener(cl -> { - hypersurface.surfaceRender = surfaceRadioButton.isSelected(); - hypersurface.updateTheMesh(); - }); - HBox meshTypeHBox = new HBox(10, surfaceRadioButton, cylinderRadioButton); - - ToggleGroup drawModeToggle = new ToggleGroup(); - RadioButton drawModeLine = new RadioButton("Line"); - drawModeLine.setSelected(true); - drawModeLine.setToggleGroup(drawModeToggle); - RadioButton drawModeFill = new RadioButton("Fill"); - drawModeFill.setToggleGroup(drawModeToggle); - drawModeToggle.selectedToggleProperty().addListener(cl -> { - if (drawModeLine.isSelected()) { - hypersurface.surfPlot.setDrawMode(DrawMode.LINE); - } else { - hypersurface.surfPlot.setDrawMode(DrawMode.FILL); - } - }); - HBox drawModeHBox = new HBox(10, drawModeLine, drawModeFill); - - ToggleGroup cullFaceToggle = new ToggleGroup(); - RadioButton cullFaceFront = new RadioButton("Front"); - cullFaceFront.setToggleGroup(cullFaceToggle); - RadioButton cullFaceBack = new RadioButton("Back"); - cullFaceBack.setToggleGroup(cullFaceToggle); - RadioButton cullFaceNone = new RadioButton("None"); - cullFaceNone.setSelected(true); - cullFaceNone.setToggleGroup(cullFaceToggle); - cullFaceToggle.selectedToggleProperty().addListener(cl -> { - if (cullFaceFront.isSelected()) { - hypersurface.surfPlot.setCullFace(CullFace.FRONT); - } else if (cullFaceBack.isSelected()) { - hypersurface.surfPlot.setCullFace(CullFace.BACK); - } else { - hypersurface.surfPlot.setCullFace(CullFace.NONE); - } - }); - HBox cullFaceHBox = new HBox(10, cullFaceFront, cullFaceBack, cullFaceNone); - - //add a Point Light for better viewing of the grid coordinate system - hypersurface.pointLight.getScope().addAll(hypersurface.surfPlot); - hypersurface.sceneRoot.getChildren().add(hypersurface.pointLight); - hypersurface.pointLight.translateXProperty().bind(hypersurface.camera.translateXProperty()); - hypersurface.pointLight.translateYProperty().bind(hypersurface.camera.translateYProperty()); - hypersurface.pointLight.translateZProperty().bind(hypersurface.camera.translateZProperty().add(500)); - - hypersurface.ambientLight.getScope().addAll(hypersurface.surfPlot); - hypersurface.sceneRoot.getChildren().add(hypersurface.ambientLight); - - ColorPicker lightPicker = new ColorPicker(Color.WHITE); - hypersurface.ambientLight.colorProperty().bind(lightPicker.valueProperty()); - - ColorPicker specPicker = new ColorPicker(Color.CYAN); - specPicker.setOnAction(e -> { - ((PhongMaterial) hypersurface.surfPlot.getMaterial()).setSpecularColor(specPicker.getValue()); - }); - - CheckBox enableAmbient = new CheckBox("Enable Ambient Light"); - enableAmbient.setSelected(true); - enableAmbient.setOnAction(e -> { - if (enableAmbient.isSelected()) { - lightPicker.setDisable(false); - hypersurface.ambientLight.getScope().addAll(hypersurface.surfPlot); - } else { - lightPicker.setDisable(true); - hypersurface.ambientLight.getScope().clear(); - } - }); - - CheckBox enablePoint = new CheckBox("Enable Point Light"); - enablePoint.setSelected(true); - enablePoint.setOnAction(e -> { - if (enablePoint.isSelected()) { - specPicker.setDisable(false); - hypersurface.pointLight.getScope().addAll(hypersurface.surfPlot); - } else { - specPicker.setDisable(true); - hypersurface.pointLight.getScope().clear(); - } - }); - - Label divLabel = new Label("Divisions"); - divLabel.setPrefWidth(125); - Label xWidthLabel = new Label("Usable X Width"); - xWidthLabel.setPrefWidth(125); - Label zWidthLabel = new Label("Usable Z Length"); - zWidthLabel.setPrefWidth(125); - Label yScaleLabel = new Label("Y Scale"); - yScaleLabel.setPrefWidth(125); - Label surfScaleLabel = new Label("Surface Range Scale"); - surfScaleLabel.setPrefWidth(125); - - VBox vbox = new VBox(10, - new HBox(10, xWidthLabel, xWidthSpinner), - new HBox(10, zWidthLabel, zWidthSpinner), - new HBox(10, yScaleLabel, yScaleSpinner), - new HBox(10, surfScaleLabel, surfScaleSpinner), - new Label("Draw Mode"), - meshTypeHBox, - drawModeHBox, - new Label("Cull Face"), - cullFaceHBox, - new Label("Ambient Light Color"), - enableAmbient, - lightPicker, -// new Label("Diffuse Color"), -// diffusePicker, - new Label("Specular Color"), - enablePoint, - specPicker - ); - StackPane.setAlignment(vbox, Pos.BOTTOM_LEFT); - vbox.setPickOnBounds(false); - getChildren().add(vbox); - } -} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/CircleProgressIndicator.java b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/CircleProgressIndicator.java index 9ed1ee84..f13665df 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/CircleProgressIndicator.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/CircleProgressIndicator.java @@ -137,6 +137,7 @@ public void fadeBusy(boolean fadeOut) { ft.setOnFinished(e -> setVisible(!fadeOut)); ft.playFromStart(); } + public boolean inView() { return getOpacity() >= defaultOpacity && isVisible(); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java index f2249590..3ba0f6c8 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java @@ -230,6 +230,9 @@ public void buildMenu() { ImageView save = ResourceUtils.loadIcon("save", ITEM_FIT_WIDTH); save.setEffect(glow); + ImageView stats = ResourceUtils.loadIcon("data", ITEM_FIT_WIDTH); + stats.setEffect(glow); + ImageView refresh = ResourceUtils.loadIcon("refresh", ITEM_FIT_WIDTH); refresh.setEffect(glow); @@ -331,6 +334,12 @@ public void buildMenu() { new FeatureVectorEvent(FeatureVectorEvent.EXPORT_FEATURE_COLLECTION, file)); } })); + exportSubMenuItem.addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "Compute Stats", stats, e -> { + hyperspace3DPane.getScene().getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.SHOW_STATISTICS_PANE, + hyperspace3DPane.getAllFeatureVectors())); + })); + addMenuItem(exportSubMenuItem); addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "Refresh Render", refresh, e -> { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java new file mode 100644 index 00000000..f32c0b35 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java @@ -0,0 +1,589 @@ +package edu.jhuapl.trinity.javafx.controllers; + +import edu.jhuapl.trinity.css.StyleResourceProvider; +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; +import javafx.animation.PauseTransition; +import javafx.beans.binding.Bindings; +import javafx.collections.ListChangeListener; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ChoiceDialog; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Dialog; +import javafx.scene.control.ListCell; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputDialog; +import javafx.scene.control.ToolBar; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.FileChooser; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.stage.Window; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.prefs.Preferences; +import java.util.stream.Collectors; + +public final class FeatureVectorManagerPopoutController { + public static double BUTTON_PREF_WIDTH = 200; + private final FeatureVectorManagerService service; + private final Scene appScene; // main/original app Scene for event dispatch + private final Preferences prefs = Preferences.userNodeForPackage(FeatureVectorManagerPopoutController.class); + + private Stage stage; + private Scene scene; + private FeatureVectorManagerView view; + private PauseTransition searchDebounce; + + public FeatureVectorManagerPopoutController(FeatureVectorManagerService service, Scene appScene) { + this.service = Objects.requireNonNull(service, "service"); + this.appScene = Objects.requireNonNull(appScene, "appScene"); + } + + /** + * Open (or focus) the pop-out window. + */ + public void show() { + if (stage != null && stage.isShowing()) { + stage.toFront(); + stage.requestFocus(); + return; + } + buildStageAndWire(); + placeOnBestScreen(); + restoreWindowPrefs(); + stage.show(); + } + + /** + * Close the pop-out window (no service teardown). + */ + public void close() { + if (stage != null) stage.close(); + } + + public boolean isOpen() { + return stage != null && stage.isShowing(); + } + + /** + * Move to a secondary screen if available. + */ + public void sendToSecondScreen() { + if (stage == null) return; + Screen second = getSecondaryScreen(); + if (second != null) moveToScreen(second); + } + + // -------------------- internals -------------------- + + private void buildStageAndWire() { + view = new FeatureVectorManagerView(); + view.setDetailLevel(FeatureVectorManagerView.DetailLevel.FULL); + + // simple window toolbar (outside the View) + ToolBar tb = new ToolBar(); + tb.setBackground(Background.EMPTY); + Button btnSecond = new Button("Second Screen"); + btnSecond.setPrefWidth(BUTTON_PREF_WIDTH); + btnSecond.setOnAction(e -> sendToSecondScreen()); + btnSecond.setDisable(Screen.getScreens().size() <= 1); + Button btnFull = new Button("Full Screen"); + btnFull.setOnAction(e -> stage.setFullScreen(!stage.isFullScreen())); + btnFull.setPrefWidth(BUTTON_PREF_WIDTH); + tb.getItems().addAll(btnSecond, btnFull); + + BorderPane root = new BorderPane(); + root.setTop(tb); + root.setCenter(view); + root.setBackground(Background.EMPTY); + + scene = new Scene(root, Color.BLACK); + + //Make everything pretty + String CSS = StyleResourceProvider.getResource("styles.css").toExternalForm(); + scene.getStylesheets().add(CSS); + CSS = StyleResourceProvider.getResource("covalent.css").toExternalForm(); + scene.getStylesheets().add(CSS); + CSS = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); + scene.getStylesheets().add(CSS); + + stage = new Stage(StageStyle.DECORATED); + stage.setTitle("Trinity — Feature Vectors"); + stage.setMaximized(true); + stage.setScene(scene); + + // make dialogs owned by this window; keep main Scene for event routing + Window owner = appScene.getWindow(); + if (owner instanceof Stage ownerStage) { + stage.initOwner(ownerStage); + } + + // wire view ↔ service exactly like FeatureVectorManagerPane + wireViewToService(); + installSearchWiring(); + installCollectionContextMenu(); + installTableContextMenu(); + + stage.setOnCloseRequest(e -> { + saveWindowPrefs(); + // release references that hold UI (service is shared and remains alive) + view = null; + scene.setRoot(new VBox()); // help GC + scene = null; + stage = null; + }); + } + + private void wireViewToService() { + // live items list + view.getTable().setItems(service.getDisplayedVectors()); + view.getTable().itemsProperty().addListener((obs, o, n) -> + view.setStatus("Showing " + (n == null ? 0 : n.size()) + " vectors.") + ); + view.getTable().itemsProperty().bind(Bindings.createObjectBinding( + service::getDisplayedVectors, service.getDisplayedVectors())); + + // collections list + two-way sync + var combo = view.getCollectionSelector(); + combo.setItems(service.getCollectionNames()); + combo.setVisibleRowCount(15); + + combo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? null : (item == null || item.trim().isEmpty() ? "(unnamed)" : item)); + } + }); + combo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? null : (item == null || item.trim().isEmpty() ? "(unnamed)" : item)); + } + }); + + view.selectedCollectionProperty().addListener((obs, o, n) -> { + if (n != null && !n.equals(service.activeCollectionNameProperty().get())) { + service.activeCollectionNameProperty().set(n); + } + }); + service.activeCollectionNameProperty().addListener((obs, o, n) -> { + if (n != null && null != view && !n.equals(view.getSelectedCollection())) { + combo.getSelectionModel().select(n); + } + }); + service.getCollectionNames().addListener((ListChangeListener) c -> { + if (combo.getSelectionModel().isEmpty() && !service.getCollectionNames().isEmpty()) { + combo.getSelectionModel().select(service.getCollectionNames().get(0)); + } + }); + + // sampling mapping + view.samplingModeProperty().addListener((obs, o, s) -> { + if (s == null) return; + FeatureVectorManagerService.SamplingMode mode = switch (s) { + case "Head (1000)" -> FeatureVectorManagerService.SamplingMode.HEAD_1000; + case "Tail (1000)" -> FeatureVectorManagerService.SamplingMode.TAIL_1000; + case "Random (1000)" -> FeatureVectorManagerService.SamplingMode.RANDOM_1000; + default -> FeatureVectorManagerService.SamplingMode.ALL; + }; + if (service.samplingModeProperty().get() != mode) { + service.samplingModeProperty().set(mode); + } + }); + } + + /** + * Debounced search → service.setTextFilter; Enter applies; Esc clears. + */ + private void installSearchWiring() { + TextField tf = view.getSearchField(); + + searchDebounce = new PauseTransition(javafx.util.Duration.millis(200)); + searchDebounce.setOnFinished(e -> service.setTextFilter(tf.getText())); + + tf.textProperty().addListener((obs, o, n) -> searchDebounce.playFromStart()); + + tf.addEventFilter(KeyEvent.KEY_PRESSED, e -> { + if (e.getCode() == KeyCode.ENTER) { + searchDebounce.stop(); + service.setTextFilter(tf.getText()); + e.consume(); + } else if (e.getCode() == KeyCode.ESCAPE) { + tf.clear(); + searchDebounce.stop(); + service.setTextFilter(""); + e.consume(); + } + }); + + service.textFilterProperty().addListener((obs, o, n) -> { + boolean active = n != null && !n.isBlank(); + view.setStatus(active + ? "Showing filtered vectors." + : "Showing " + service.getDisplayedVectors().size() + " vectors."); + }); + } + + // ---- collection context menu (rename, duplicate, delete, merge, export, apply) ---- + + private void installCollectionContextMenu() { + ComboBox combo = view.getCollectionSelector(); + ContextMenu ctx = new ContextMenu(); + + MenuItem miRename = new MenuItem("Rename…"); + miRename.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + TextInputDialog dlg = new TextInputDialog(current); + dlg.setHeaderText("Rename Collection"); + dlg.setContentText("New name:"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.initOwner(stage); + dlg.showAndWait().ifPresent(newName -> { + if (newName != null && !newName.trim().isEmpty()) { + service.renameCollection(current, newName.trim()); + } + }); + }); + + MenuItem miDuplicate = new MenuItem("Duplicate…"); + miDuplicate.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + TextInputDialog dlg = new TextInputDialog("Copy of " + current); + dlg.setHeaderText("Duplicate Collection"); + dlg.setContentText("New name:"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.initOwner(stage); + dlg.showAndWait().ifPresent(proposed -> service.duplicateCollection(current, proposed)); + }); + + MenuItem miDelete = new MenuItem("Delete…"); + miDelete.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + Alert alert = new Alert(Alert.AlertType.CONFIRMATION, + "Delete collection \"" + current + "\"?\nThis cannot be undone.", + ButtonType.OK, ButtonType.CANCEL); + alert.setHeaderText("Delete Collection"); + alert.initOwner(stage); + alert.showAndWait().ifPresent(btn -> { + if (btn == ButtonType.OK) service.deleteCollection(current); + }); + }); + + MenuItem miMergeInto = new MenuItem("Merge into…"); + miMergeInto.setOnAction(e -> { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + List options = service.getCollectionNames().stream() + .filter(n -> !Objects.equals(n, current)) + .collect(Collectors.toList()); + if (options.isEmpty()) { + info("No other collections to merge into."); + return; + } + ChoiceDialog chooser = new ChoiceDialog<>(options.get(0), options); + chooser.setHeaderText("Merge \"" + current + "\" into…"); + chooser.setContentText("Target collection:"); + chooser.getDialogPane().setPadding(new Insets(10)); + chooser.initOwner(stage); + chooser.showAndWait().ifPresent(target -> { + Alert dedup = new Alert(Alert.AlertType.CONFIRMATION, + "De-duplicate by entityId while merging?", + ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); + dedup.setHeaderText("Merge Options"); + dedup.initOwner(stage); + dedup.showAndWait().ifPresent(resp -> { + if (resp == ButtonType.CANCEL) return; + boolean dd = (resp == ButtonType.YES); + service.mergeInto(target, current, dd); + }); + }); + }); + + Menu export = new Menu("Export"); + MenuItem miExportJson = new MenuItem("As JSON (FeatureCollection)"); + miExportJson.setOnAction(e -> exportCollection(FeatureVectorManagerService.ExportFormat.JSON)); + MenuItem miExportCsv = new MenuItem("As CSV"); + miExportCsv.setOnAction(e -> exportCollection(FeatureVectorManagerService.ExportFormat.CSV)); + export.getItems().addAll(miExportJson, miExportCsv); + + Menu applyMenu = new Menu("Apply to workspace"); + MenuItem miApplyAppend = new MenuItem("Apply (append)"); + miApplyAppend.setOnAction(e -> service.applyActiveToWorkspace(false)); + MenuItem miApplyReplace = new MenuItem("Apply (replace)"); + miApplyReplace.setOnAction(e -> service.applyActiveToWorkspace(true)); + + MenuItem miSetAllAppend = new MenuItem("Set All (append)"); + miSetAllAppend.setOnAction(e -> service.applyAllToWorkspace(false)); + MenuItem miSetAllReplace = new MenuItem("Set All (replace)"); + miSetAllReplace.setOnAction(e -> service.applyAllToWorkspace(true)); + applyMenu.getItems().addAll(miApplyAppend, miApplyReplace, miSetAllAppend, miSetAllReplace); + + ctx.getItems().addAll(miRename, miDuplicate, miDelete, new SeparatorMenuItem(), + miMergeInto, export, new SeparatorMenuItem(), applyMenu); + + combo.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { + if (!ctx.isShowing()) ctx.show(combo, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + } + + private void exportCollection(FeatureVectorManagerService.ExportFormat fmt) { + String current = service.activeCollectionNameProperty().get(); + if (current == null) return; + FileChooser fc = new FileChooser(); + fc.setInitialFileName(safeFilename(current) + (fmt == FeatureVectorManagerService.ExportFormat.JSON ? ".json" : ".csv")); + fc.getExtensionFilters().setAll( + new FileChooser.ExtensionFilter("JSON", "*.json"), + new FileChooser.ExtensionFilter("CSV", "*.csv"), + new FileChooser.ExtensionFilter("All Files", "*.*") + ); + File file = fc.showSaveDialog(stage); + if (file != null) { + try { + service.exportCollection(current, file, fmt); + info("Exported \"" + current + "\" to:\n" + file.getAbsolutePath()); + } catch (Exception ex) { + error("Export failed:\n" + ex.getMessage()); + } + } + } + + // ---- table context menu (remove, copy, bulk edit, locate, apply) ---- + + private void installTableContextMenu() { + TableView table = view.getTable(); + ContextMenu ctx = new ContextMenu(); + + MenuItem miRemoveSel = new MenuItem("Remove Selected"); + miRemoveSel.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (!sel.isEmpty()) service.removeFromActive(sel); + }); + + MenuItem miCopyTo = new MenuItem("Copy Selected to…"); + miCopyTo.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (sel.isEmpty()) return; + + String current = service.activeCollectionNameProperty().get(); + TextInputDialog dlg = new TextInputDialog(current == null ? "NewCollection" : (current + "-copy")); + dlg.setHeaderText("Copy Selected to Collection"); + dlg.setContentText("Target name (existing or new):"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.initOwner(stage); + dlg.showAndWait().ifPresent(target -> { + if (target != null && !target.trim().isEmpty()) { + service.copyToCollection(sel, target.trim()); + } + }); + }); + + MenuItem miEditLabel = new MenuItem("Edit Label(s)…"); + miEditLabel.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (sel.isEmpty()) return; + TextInputDialog dlg = new TextInputDialog(); + dlg.setHeaderText("Bulk Set Label"); + dlg.setContentText("New label:"); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.initOwner(stage); + dlg.showAndWait().ifPresent(lbl -> { + if (lbl != null) service.bulkSetLabelInActive(sel, lbl); + }); + }); + + MenuItem miEditMeta = new MenuItem("Edit Metadata…"); + miEditMeta.setOnAction(e -> { + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + if (sel.isEmpty()) return; + + Dialog> dlg = new Dialog<>(); + dlg.setTitle("Bulk Edit Metadata"); + dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + TextArea ta = new TextArea(); + ta.setPromptText("Enter key=value pairs, one per line.\nExample:\nsource=fileA\nuuid=override-123"); + ta.setPrefRowCount(10); + ta.setWrapText(true); + dlg.getDialogPane().setContent(ta); + dlg.getDialogPane().setPadding(new Insets(10)); + dlg.initOwner(stage); + + dlg.setResultConverter(btn -> btn == ButtonType.OK ? parseKeyValues(ta.getText()) : null); + dlg.showAndWait().ifPresent(kv -> { + if (!kv.isEmpty()) service.bulkEditMetadataInActive(sel, kv); + }); + }); + + MenuItem miLocate = new MenuItem("Locate in 3D"); + miLocate.setOnAction(e -> { + var sel = table.getSelectionModel().getSelectedItems(); + if (sel == null || sel.isEmpty()) return; + sel.forEach(fv -> appScene.getRoot().fireEvent( + new FeatureVectorEvent(FeatureVectorEvent.LOCATE_FEATURE_VECTOR, fv) + )); + }); + + Menu applyMenu = new Menu("Apply to workspace"); + MenuItem miApplyAppend = new MenuItem("Apply (append)"); + miApplyAppend.setOnAction(e -> applySelectionOrActive(false)); + MenuItem miApplyReplace = new MenuItem("Apply (replace)"); + miApplyReplace.setOnAction(e -> applySelectionOrActive(true)); + applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); + + ctx.getItems().addAll(miRemoveSel, miCopyTo, new SeparatorMenuItem(), + miEditLabel, miEditMeta, new SeparatorMenuItem(), + miLocate, applyMenu); + + table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { + if (!ctx.isShowing()) ctx.show(table, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + } + + /** + * Selection-aware apply: if selection present, fire NEW_FEATURE_COLLECTION to main scene; else use service. + */ + private void applySelectionOrActive(boolean replace) { + TableView table = view.getTable(); + List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); + + if (!sel.isEmpty()) { + FeatureCollection fc = new FeatureCollection(); + fc.setFeatures(sel); + FeatureVectorEvent evt = + new FeatureVectorEvent(FeatureVectorEvent.NEW_FEATURE_COLLECTION, fc, + FeatureVectorManagerService.MANAGER_APPLY_TAG); + evt.clearExisting = replace; + appScene.getRoot().fireEvent(evt); + } else { + service.applyActiveToWorkspace(replace); + } + } + + // -------------------- window placement & prefs -------------------- + + private void placeOnBestScreen() { + Screen target = pickBestScreen(); + moveToScreen(target); + } + + private void moveToScreen(Screen screen) { + var b = screen.getVisualBounds(); + stage.setX(b.getMinX() + 40); + stage.setY(b.getMinY() + 40); + if (!stage.isShowing()) { + stage.setWidth(Math.min(1200, b.getWidth() - 80)); + stage.setHeight(Math.min(900, b.getHeight() - 80)); + } + } + + private static Screen pickBestScreen() { + var all = Screen.getScreens(); + if (all.size() <= 1) return Screen.getPrimary(); + for (Screen s : all) if (!s.equals(Screen.getPrimary())) return s; + return Screen.getPrimary(); + } + + private static Screen getSecondaryScreen() { + for (Screen s : Screen.getScreens()) if (!s.equals(Screen.getPrimary())) return s; + return null; + } + + private void saveWindowPrefs() { + prefs.putDouble("fv_pop_x", stage.getX()); + prefs.putDouble("fv_pop_y", stage.getY()); + prefs.putDouble("fv_pop_w", stage.getWidth()); + prefs.putDouble("fv_pop_h", stage.getHeight()); + prefs.putBoolean("fv_pop_fs", stage.isFullScreen()); + } + + private void restoreWindowPrefs() { + double w = prefs.getDouble("fv_pop_w", -1); + double h = prefs.getDouble("fv_pop_h", -1); + double x = prefs.getDouble("fv_pop_x", Double.NaN); + double y = prefs.getDouble("fv_pop_y", Double.NaN); + boolean fs = prefs.getBoolean("fv_pop_fs", false); + + if (w > 0 && h > 0) { + stage.setWidth(w); + stage.setHeight(h); + } + if (!Double.isNaN(x) && !Double.isNaN(y)) { + stage.setX(x); + stage.setY(y); + } + stage.setFullScreen(fs); + } + + // -------------------- small utils -------------------- + + private static String safeFilename(String s) { + if (s == null) return "collection"; + return s.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + private static Map parseKeyValues(String text) { + Map map = new LinkedHashMap<>(); + if (text == null || text.isBlank()) return map; + String[] lines = text.split("\\R"); + for (String line : lines) { + String ln = line.trim(); + if (ln.isEmpty()) continue; + int eq = ln.indexOf('='); + if (eq < 0) { + map.put(ln, ""); + } else { + String k = ln.substring(0, eq).trim(); + String v = ln.substring(eq + 1).trim(); + if (!k.isEmpty()) map.put(k, v); + } + } + return map; + } + + private void info(String msg) { + Alert a = new Alert(Alert.AlertType.INFORMATION, msg, ButtonType.OK); + a.setHeaderText(null); + a.initOwner(stage); + a.showAndWait(); + } + + private void error(String msg) { + Alert a = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK); + a.setHeaderText("Error"); + a.initOwner(stage); + a.showAndWait(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java new file mode 100644 index 00000000..1fae3689 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java @@ -0,0 +1,212 @@ +package edu.jhuapl.trinity.javafx.controllers; + +import edu.jhuapl.trinity.css.StyleResourceProvider; +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.javafx.components.PairwiseJpdfView; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine; +import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ToolBar; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.paint.Color; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.stage.Window; + +import java.util.prefs.Preferences; + +public class PairwiseJpdfPanePopoutController { + private final Scene appScene; + private final Preferences prefs = Preferences.userNodeForPackage(PairwiseJpdfPanePopoutController.class); + + private Stage stage; + private Scene scene; + private PairwiseJpdfView view; + + private final JpdfBatchEngine engine; + private final DensityCache cache; + private final PairwiseJpdfConfigPanel configPanel; + + public PairwiseJpdfPanePopoutController(Scene scene) { + this(scene, null, null, null); + } + + public PairwiseJpdfPanePopoutController( + Scene appScene, + JpdfBatchEngine engine, + DensityCache cache, + PairwiseJpdfConfigPanel configPanel) { + this.appScene = appScene; + this.engine = (engine != null) ? engine : new JpdfBatchEngine(); + this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); + this.configPanel = (configPanel != null) ? configPanel : new PairwiseJpdfConfigPanel(); + } + + /** + * Show the popout window (or focus if already open) + */ + public void show() { + if (stage != null && stage.isShowing()) { + stage.toFront(); + stage.requestFocus(); + return; + } + buildStageAndWire(); + placeOnBestScreen(); + restoreWindowPrefs(); + stage.show(); + } + + public void close() { + if (stage != null) stage.close(); + } + + public boolean isOpen() { + return stage != null && stage.isShowing(); + } + + public void sendToSecondScreen() { + if (stage == null) return; + Screen second = getSecondaryScreen(); + if (second != null) moveToScreen(second); + } + + private void buildStageAndWire() { + view = new PairwiseJpdfView(engine, cache, configPanel); + + view.setOnCellClick(item -> { + if (item == null || item.res == null) return; + GridDensityResult res = item.res; + var gridList = res.pdfAsListGrid(); + // Use appScene.getRoot() so the event goes to the main app’s event system + appScene.getRoot().fireEvent(new HypersurfaceGridEvent( + HypersurfaceGridEvent.RENDER_PDF, + gridList, + res.getxCenters(), + res.getyCenters(), + item.xLabel + " | " + item.yLabel + " (PDF)" + )); + // Optionally, show a toast: + view.toast("Opened PDF in 3D.", false); + }); + // ---- Optional: wire up toast handler if desired ---- + // view.setToastHandler(msg -> { /* show in status bar or dialog */ }); + appScene.addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, e -> { + if (view == null) return; // Defensive, in case view is not yet built + if (e.object instanceof FeatureCollection fc && fc.getFeatures() != null) { + view.setCohortA(fc.getFeatures(), "A"); + // Optionally, show a toast to user + view.toast("Loaded " + fc.getFeatures().size() + " vectors into Cohort A.", false); + } + }); + + // ---- ToolBar for popout controls ---- + ToolBar tb = new ToolBar(); + tb.setPadding(new Insets(5)); + tb.setBackground(Background.EMPTY); + + Button btnSecond = new Button("Second Screen"); + btnSecond.setOnAction(e -> sendToSecondScreen()); + btnSecond.setDisable(Screen.getScreens().size() <= 1); + Button btnFull = new Button("Full Screen"); + btnFull.setOnAction(e -> stage.setFullScreen(!stage.isFullScreen())); + tb.getItems().addAll(btnSecond, btnFull); + + BorderPane root = new BorderPane(); + root.setTop(tb); + root.setCenter(view); + root.setBackground(Background.EMPTY); + scene = new Scene(root, 1000, 800, Color.BLACK); + + //Make everything pretty + String CSS = StyleResourceProvider.getResource("styles.css").toExternalForm(); + scene.getStylesheets().add(CSS); + CSS = StyleResourceProvider.getResource("covalent.css").toExternalForm(); + scene.getStylesheets().add(CSS); + CSS = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); + scene.getStylesheets().add(CSS); + + stage = new Stage(StageStyle.DECORATED); + stage.setTitle("Trinity — Pairwise Joint Densities"); + stage.setScene(scene); + stage.setMaximized(true); + + // Set owner for dialogs etc. + Window owner = appScene.getWindow(); + if (owner instanceof Stage ownerStage) stage.initOwner(ownerStage); + + stage.setOnCloseRequest(e -> { + saveWindowPrefs(); + view = null; + scene.setRoot(new BorderPane()); // help GC + scene = null; + stage = null; + }); + } + + private void placeOnBestScreen() { + Screen target = pickBestScreen(); + moveToScreen(target); + } + + private void moveToScreen(Screen screen) { + javafx.geometry.Rectangle2D b = screen.getVisualBounds(); + stage.setX(b.getMinX() + 40); + stage.setY(b.getMinY() + 40); + if (!stage.isShowing()) { + stage.setWidth(Math.min(1200, b.getWidth() - 80)); + stage.setHeight(Math.min(900, b.getHeight() - 80)); + } + } + + private static Screen pickBestScreen() { + var all = Screen.getScreens(); + if (all.size() <= 1) return Screen.getPrimary(); + for (Screen s : all) if (!s.equals(Screen.getPrimary())) return s; + return Screen.getPrimary(); + } + + private static Screen getSecondaryScreen() { + for (Screen s : Screen.getScreens()) if (!s.equals(Screen.getPrimary())) return s; + return null; + } + + private void saveWindowPrefs() { + prefs.putDouble("jpdf_pop_x", stage.getX()); + prefs.putDouble("jpdf_pop_y", stage.getY()); + prefs.putDouble("jpdf_pop_w", stage.getWidth()); + prefs.putDouble("jpdf_pop_h", stage.getHeight()); + prefs.putBoolean("jpdf_pop_fs", stage.isFullScreen()); + } + + private void restoreWindowPrefs() { + double w = prefs.getDouble("jpdf_pop_w", -1); + double h = prefs.getDouble("jpdf_pop_h", -1); + double x = prefs.getDouble("jpdf_pop_x", Double.NaN); + double y = prefs.getDouble("jpdf_pop_y", Double.NaN); + boolean fs = prefs.getBoolean("jpdf_pop_fs", false); + + if (w > 0 && h > 0) { + stage.setWidth(w); + stage.setHeight(h); + } + if (!Double.isNaN(x) && !Double.isNaN(y)) { + stage.setX(x); + stage.setY(y); + } + stage.setFullScreen(fs); + } + + // ---- Optional: add public getter for the view, to allow parent to set state after popout ---- + public PairwiseJpdfView getView() { + return view; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java new file mode 100644 index 00000000..d358d38c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java @@ -0,0 +1,271 @@ +package edu.jhuapl.trinity.javafx.controllers; + +import edu.jhuapl.trinity.css.StyleResourceProvider; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import edu.jhuapl.trinity.utils.statistics.StatPdfCdfChartPanel; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ToolBar; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.stage.Window; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.prefs.Preferences; + +/** + * Popout controller for the Statistics PDF/CDF panel. + *

+ * - Requires an application Scene (owner & event routing) in the constructor. + * - Accepts and applies a pure-data State snapshot for initialization. + * - Exposes the current State snapshot for the caller to retrieve and use as needed. + * - Forwards compute-surface events to the main Scene root. + */ +public final class StatPdfCdfPopoutController { + public static final double BUTTON_PREF_WIDTH = 200.0; + + private final Scene appScene; // required; used for window ownership and event routing + private final Preferences prefs = Preferences.userNodeForPackage(StatPdfCdfPopoutController.class); + + // Data-only state management + private StatPdfCdfChartPanel.State pendingState; // applied on show() if set + + // Popout window members + private Stage stage; + private Scene scene; + private StatPdfCdfChartPanel chart; // created on show() + + public StatPdfCdfPopoutController(Scene appScene) { + this.appScene = Objects.requireNonNull(appScene, "appScene"); + } + + /** + * Provide an initial state to apply to the popout chart. + */ + public void setInitialState(StatPdfCdfChartPanel.State state) { + if (state == null) return; + if (isOpen() && chart != null) { + chart.applyState(state); + } else { + this.pendingState = state; + } + } + + /** + * Retrieve the latest state: live export if open, else the last pending/applied state if available. + */ + public Optional getCurrentState() { + if (isOpen() && chart != null) { + return Optional.of(chart.exportState()); + } + return Optional.ofNullable(pendingState); + } + + /** + * Open (or focus) the popout window. + */ + public void show() { + if (stage != null && stage.isShowing()) { + stage.toFront(); + stage.requestFocus(); + return; + } + buildStageAndWire(); + placeOnBestScreen(); + restoreWindowPrefs(); + stage.show(); + } + + /** + * Close the window. The latest state remains accessible via getCurrentState(). + */ + public void close() { + if (stage == null) return; + saveWindowPrefs(); + if (chart != null) { + // Cache the last state so the caller can retrieve and apply it externally if desired. + pendingState = chart.exportState(); + } + stage.close(); + teardown(); + } + + public boolean isOpen() { + return stage != null && stage.isShowing(); + } + + /** + * Move the window to a secondary screen if available. + */ + public void sendToSecondScreen() { + if (stage == null) return; + Screen second = getSecondaryScreen(); + if (second != null) moveToScreen(second); + } + + // -------------------- internals -------------------- + + private void buildStageAndWire() { + // Build fresh chart instance + chart = new StatPdfCdfChartPanel(); + + // Apply any pending state (if provided) + if (pendingState != null) { + chart.applyState(pendingState); + } + + // Forward 3D compute results to the main Scene root + chart.setOnComputeSurface(this::forwardSurfaceToMainScene); + + // Lightweight window toolbar + ToolBar tb = new ToolBar(); + tb.setBackground(Background.EMPTY); + + Button btnSecond = new Button("Second Screen"); + btnSecond.setPrefWidth(BUTTON_PREF_WIDTH); + btnSecond.setOnAction(e -> sendToSecondScreen()); + btnSecond.setDisable(Screen.getScreens().size() <= 1); + + Button btnFull = new Button("Full Screen"); + btnFull.setPrefWidth(BUTTON_PREF_WIDTH); + btnFull.setOnAction(e -> stage.setFullScreen(!stage.isFullScreen())); + + tb.getItems().addAll(btnSecond, btnFull); + + BorderPane root = new BorderPane(); + root.setTop(tb); + root.setCenter(chart); + root.setBackground(Background.EMPTY); + + scene = new Scene(root, Color.BLACK); + applyStyles(scene); + + stage = new Stage(StageStyle.DECORATED); + stage.setTitle("Trinity — Statistics: PDF / CDF"); + stage.setMaximized(true); + stage.setScene(scene); + + // Owner: main application window + Window owner = appScene.getWindow(); + if (owner instanceof Stage ownerStage) { + stage.initOwner(ownerStage); + } + + stage.setOnCloseRequest(e -> { + saveWindowPrefs(); + // Cache the final state so the caller can retrieve it + pendingState = chart.exportState(); + teardown(); + }); + } + + private void teardown() { + if (scene != null && scene.getRoot() instanceof BorderPane bp) { + bp.setCenter(null); + } + if (scene != null) { + scene.setRoot(new VBox()); // help GC + } + chart = null; + scene = null; + stage = null; + } + + private void forwardSurfaceToMainScene(GridDensityResult result) { + if (chart == null || appScene == null) return; + + boolean useCDF = chart.isSurfaceCDF(); + + List> grid = useCDF + ? result.cdfAsListGrid() + : result.pdfAsListGrid(); + + String label = (useCDF ? "CDF" : "PDF") + " : " + + chart.getScalarType() + " vs " + + chart.getYFeatureTypeForDisplay(); + + appScene.getRoot().fireEvent( + new HypersurfaceGridEvent( + useCDF ? HypersurfaceGridEvent.RENDER_CDF : HypersurfaceGridEvent.RENDER_PDF, + grid, + result.getxCenters(), + result.getyCenters(), + label + ) + ); + } + + private static void applyStyles(Scene target) { + String css = StyleResourceProvider.getResource("styles.css").toExternalForm(); + target.getStylesheets().add(css); + css = StyleResourceProvider.getResource("covalent.css").toExternalForm(); + target.getStylesheets().add(css); + css = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); + target.getStylesheets().add(css); + } + + // -------------------- window placement & prefs -------------------- + + private void placeOnBestScreen() { + Screen target = pickBestScreen(); + moveToScreen(target); + } + + private void moveToScreen(Screen s) { + if (stage == null) return; + var b = s.getVisualBounds(); + stage.setX(b.getMinX() + 40.0); + stage.setY(b.getMinY() + 40.0); + if (!stage.isShowing()) { + stage.setWidth(Math.min(1200.0, b.getWidth() - 80.0)); + stage.setHeight(Math.min(900.0, b.getHeight() - 80.0)); + } + } + + private static Screen pickBestScreen() { + var all = Screen.getScreens(); + if (all.size() <= 1) return Screen.getPrimary(); + for (Screen s : all) if (!s.equals(Screen.getPrimary())) return s; + return Screen.getPrimary(); + } + + private static Screen getSecondaryScreen() { + for (Screen s : Screen.getScreens()) if (!s.equals(Screen.getPrimary())) return s; + return null; + } + + private void saveWindowPrefs() { + if (stage == null) return; + prefs.putDouble("statpdfcdf_pop_x", stage.getX()); + prefs.putDouble("statpdfcdf_pop_y", stage.getY()); + prefs.putDouble("statpdfcdf_pop_w", stage.getWidth()); + prefs.putDouble("statpdfcdf_pop_h", stage.getHeight()); + prefs.putBoolean("statpdfcdf_pop_fs", stage.isFullScreen()); + } + + private void restoreWindowPrefs() { + double w = prefs.getDouble("statpdfcdf_pop_w", -1.0); + double h = prefs.getDouble("statpdfcdf_pop_h", -1.0); + double x = prefs.getDouble("statpdfcdf_pop_x", Double.NaN); + double y = prefs.getDouble("statpdfcdf_pop_y", Double.NaN); + boolean fs = prefs.getBoolean("statpdfcdf_pop_fs", false); + + if (w > 0.0 && h > 0.0) { + stage.setWidth(w); + stage.setHeight(h); + } + if (!Double.isNaN(x) && !Double.isNaN(y)) { + stage.setX(x); + stage.setY(y); + } + stage.setFullScreen(fs); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java index 76a8408f..84db70d2 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -43,6 +43,15 @@ public class ApplicationEvent extends Event { public static final EventType SHOW_PIXEL_SELECTION = new EventType(ANY, "SHOW_PIXEL_SELECTION"); public static final EventType SHOW_IMAGE_INSPECTION = new EventType(ANY, "SHOW_IMAGE_INSPECTION"); public static final EventType SHOW_SPECIALEFFECTS_PANE = new EventType(ANY, "SHOW_SPECIALEFFECTS_PANE"); + public static final EventType SHOW_STATISTICS_PANE = new EventType(ANY, "SHOW_STATISTICS_PANE"); + public static final EventType POPOUT_STATISTICS_PANE = new EventType(ANY, "POPOUT_STATISTICS_PANE"); + public static final EventType SHOW_FEATUREVECTOR_MANAGER = new EventType(ANY, "SHOW_FEATUREVECTOR_MANAGER"); + public static final EventType POPOUT_FEATUREVECTOR_MANAGER = new EventType(ANY, "POPOUT_FEATUREVECTOR_MANAGER"); + public static final EventType SHOW_HYPERSPACE_CONTROLS = new EventType(ANY, "SHOW_HYPERSPACE_CONTROLS"); + public static final EventType SHOW_PAIRWISEJPDF_PANE = new EventType(ANY, "SHOW_PAIRWISEJPDF_PANE"); + public static final EventType POPOUT_PAIRWISEJPDF_JPDF = new EventType(ANY, "POPOUT_PAIRWISEJPDF_JPDF"); + public static final EventType POPOUT_MATRIX_HEATMAP = new EventType(ANY, "POPOUT_MATRIX_HEATMAP"); + public static final EventType SHOW_PAIRWISEMATRIX_PANE = new EventType(ANY, "SHOW_PAIRWISEMATRIX_PANE"); public ApplicationEvent(EventType eventType, Object object) { this(eventType); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/FactorAnalysisEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/FactorAnalysisEvent.java index e033afdc..b2a7284a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/FactorAnalysisEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/FactorAnalysisEvent.java @@ -11,7 +11,7 @@ public class FactorAnalysisEvent extends Event { public Object object1; public Object object2; - + public static final EventType SCALE_UPDATED = new EventType(ANY, "SCALE_UPDATED"); public static final EventType XFACTOR_SELECTION = new EventType(ANY, "XFACTOR_SELECTION"); public static final EventType YFACTOR_SELECTION = new EventType(ANY, "YFACTOR_SELECTION"); @@ -28,11 +28,13 @@ public FactorAnalysisEvent(EventType arg0, Object arg1) { this(arg0); object1 = arg1; } + public FactorAnalysisEvent(EventType arg0, Object arg1, Object arg2) { this(arg0); object1 = arg1; object2 = arg2; } + public FactorAnalysisEvent(Object arg0, EventTarget arg1, EventType arg2) { super(arg0, arg1, arg2); object1 = arg0; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/FeatureVectorEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/FeatureVectorEvent.java index f42ac97e..3cef7892 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/FeatureVectorEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/FeatureVectorEvent.java @@ -27,6 +27,9 @@ public class FeatureVectorEvent extends Event { public static final EventType RESCAN_FACTOR_LABELS = new EventType(ANY, "RESCAN_FACTOR_LABELS"); public static final EventType NEW_LABEL_CONFIG = new EventType(ANY, "NEW_LABEL_CONFIG"); public static final EventType CLEAR_ALL_FEATUREVECTORS = new EventType(ANY, "CLEAR_ALL_FEATUREVECTORS"); + public static final EventType APPLY_ACTIVE_FEATUREVECTORS = new EventType(ANY, "APPLY_ACTIVE_FEATURE_VECTORS"); + public static final EventType NEW_CYBER_VECTOR = new EventType(ANY, "NEW_CYBER_VECTOR"); + public static final EventType NEW_CYBER_REPORT = new EventType(ANY, "NEW_CYBER_REPORT"); public FeatureVectorEvent(EventType arg0) { super(arg0); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java index b3340cd6..a6ec89b1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java @@ -1,8 +1,10 @@ package edu.jhuapl.trinity.javafx.events; +import edu.jhuapl.trinity.utils.graph.GraphStyleParams; import javafx.event.Event; import javafx.event.EventTarget; import javafx.event.EventType; +import javafx.scene.paint.Color; /** * @author Sean Phillips @@ -15,6 +17,29 @@ public class GraphEvent extends Event { public static final EventType EXPORT_GRAPH_COLLECTION = new EventType(ANY, "EXPORT_GRAPH_COLLECTION"); public static final EventType NEW_GRAPHDIRECTED_COLLECTION = new EventType(ANY, "NEW_GRAPHDIRECTED_COLLECTION"); public static final EventType UPDATE_GRAPH_COMPONENTS = new EventType(ANY, "UPDATE_GRAPH_COMPONENTS"); + public static final EventType GRAPH_PARAMS_CHANGED = new EventType<>(ANY, "GRAPH_PARAMS_CHANGED"); + public static final EventType GRAPH_REBUILD_PARAMS = new EventType<>(ANY, "GRAPH_REBUILD_PARAMS"); + public static final EventType GRAPH_RESET_PARAMS = new EventType<>(ANY, "GRAPH_RESET_PARAMS"); + // Graph style (runtime + reset) + public static final EventType GRAPH_STYLE_PARAMS_CHANGED = new EventType<>(ANY, "GRAPH_STYLE_PARAMS_CHANGED"); + public static final EventType GRAPH_STYLE_RESET_DEFAULTS = new EventType<>(ANY, "GRAPH_STYLE_RESET_DEFAULTS"); + // GUI sync (fine-grained) — model → UI + public static final EventType SET_NODE_COLOR_GUI = new EventType<>(ANY, "SET_NODE_COLOR_GUI"); + public static final EventType SET_NODE_RADIUS_GUI = new EventType<>(ANY, "SET_NODE_RADIUS_GUI"); + public static final EventType SET_NODE_OPACITY_GUI = new EventType<>(ANY, "SET_NODE_OPACITY_GUI"); + public static final EventType SET_EDGE_COLOR_GUI = new EventType<>(ANY, "SET_EDGE_COLOR_GUI"); + public static final EventType SET_EDGE_WIDTH_GUI = new EventType<>(ANY, "SET_EDGE_WIDTH_GUI"); + public static final EventType SET_EDGE_OPACITY_GUI = new EventType<>(ANY, "SET_EDGE_OPACITY_GUI"); + // Optional: coarse-grained hydrate — model → UI + public static final EventType SET_STYLE_GUI = new EventType<>(ANY, "SET_STYLE_GUI"); + // Interaction / inspect + public static final EventType GRAPH_NODE_HOVER = new EventType<>(ANY, "GRAPH_NODE_HOVER"); + public static final EventType GRAPH_NODE_CLICK = new EventType<>(ANY, "GRAPH_NODE_CLICK"); + public static final EventType GRAPH_EDGE_HOVER = new EventType<>(ANY, "GRAPH_EDGE_HOVER"); + public static final EventType GRAPH_EDGE_CLICK = new EventType<>(ANY, "GRAPH_EDGE_CLICK"); + // Graph overlay visibility + public static final EventType GRAPH_VISIBILITY_CHANGED = new EventType<>(ANY, "GRAPH_VISIBILITY_CHANGED"); + public static final EventType SET_GRAPH_VISIBILITY_GUI = new EventType<>(ANY, "SET_GRAPH_VISIBILITY_GUI"); public GraphEvent(EventType arg0) { super(arg0); @@ -35,4 +60,41 @@ public GraphEvent(Object arg0, EventTarget arg1, EventType arg2 super(arg0, arg1, arg2); object = arg0; } + + // Convenience factory methods (optional, mirrors HypersurfaceEvent style) + public static GraphEvent styleParamsChanged(GraphStyleParams p) { + return new GraphEvent(GRAPH_STYLE_PARAMS_CHANGED, GraphStyleParams.copyOf(p)); + } + + public static GraphEvent resetStyleDefaults() { + return new GraphEvent(GRAPH_STYLE_RESET_DEFAULTS, null); + } + + public static GraphEvent setNodeColorGUI(Color c) { + return new GraphEvent(SET_NODE_COLOR_GUI, c); + } + + public static GraphEvent setNodeRadiusGUI(double r) { + return new GraphEvent(SET_NODE_RADIUS_GUI, r); + } + + public static GraphEvent setNodeOpacityGUI(double o) { + return new GraphEvent(SET_NODE_OPACITY_GUI, o); + } + + public static GraphEvent setEdgeColorGUI(Color c) { + return new GraphEvent(SET_EDGE_COLOR_GUI, c); + } + + public static GraphEvent setEdgeWidthGUI(double w) { + return new GraphEvent(SET_EDGE_WIDTH_GUI, w); + } + + public static GraphEvent setEdgeOpacityGUI(double o) { + return new GraphEvent(SET_EDGE_OPACITY_GUI, o); + } + + public static GraphEvent setStyleGUI(GraphStyleParams p) { + return new GraphEvent(SET_STYLE_GUI, GraphStyleParams.copyOf(p)); + } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java new file mode 100644 index 00000000..e049cbd4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java @@ -0,0 +1,282 @@ +package edu.jhuapl.trinity.javafx.events; + +import javafx.event.Event; +import javafx.event.EventTarget; +import javafx.event.EventType; + +/** + * Hypersurface-specific UI + render events, including + * two-way sync for GUI control values. + */ +public class HypersurfaceEvent extends Event { + + public Object object; // loose payload, mirrors HyperspaceEvent pattern + + public static final EventType ANY = + new EventType<>(Event.ANY, "HYPERSURFACE_ANY"); + + // --- Core geometry & scaling --- + public static final EventType Y_SCALE_CHANGED = + new EventType<>(ANY, "HYPERSURF_Y_SCALE_CHANGED"); // Double + public static final EventType SURF_SCALE_CHANGED = + new EventType<>(ANY, "HYPERSURF_SURF_SCALE_CHANGED"); // Double + public static final EventType XWIDTH_CHANGED = + new EventType<>(ANY, "HYPERSURF_XWIDTH_CHANGED"); // Integer + public static final EventType ZWIDTH_CHANGED = + new EventType<>(ANY, "HYPERSURF_ZWIDTH_CHANGED"); // Integer + + // --- GUI-to-GUI Sync: Set Spinner/Combo values from model --- + public static final EventType SET_XWIDTH_GUI = + new EventType<>(ANY, "HYPERSURF_SET_XWIDTH_GUI"); // Integer + public static final EventType SET_ZWIDTH_GUI = + new EventType<>(ANY, "HYPERSURF_SET_ZWIDTH_GUI"); // Integer + public static final EventType SET_YSCALE_GUI = + new EventType<>(ANY, "HYPERSURF_SET_YSCALE_GUI"); // Double + public static final EventType SET_SURFSCALE_GUI = + new EventType<>(ANY, "HYPERSURF_SET_SURFSCALE_GUI"); // Double + // (Add more as needed for other controls, e.g., combo selections.) + + // --- Rendering mode & draw settings --- + public static final EventType SURFACE_RENDER_CHANGED = + new EventType<>(ANY, "HYPERSURF_SURFACE_RENDER_CHANGED"); // Boolean + public static final EventType DRAW_MODE_CHANGED = + new EventType<>(ANY, "HYPERSURF_DRAW_MODE_CHANGED"); // DrawMode + public static final EventType CULL_FACE_CHANGED = + new EventType<>(ANY, "HYPERSURF_CULL_FACE_CHANGED"); // CullFace + + // --- Color/material mapping --- + public static final EventType COLORATION_CHANGED = + new EventType<>(ANY, "HYPERSURF_COLORATION_CHANGED"); // Hypersurface3DPane.COLORATION + + // --- Interpolation (vertex lookup) --- + public static final EventType INTERP_MODE_CHANGED = + new EventType<>(ANY, "HYPERSURF_INTERP_MODE_CHANGED"); // SurfaceUtils.Interpolation + + // --- Processing pipeline: smoothing --- + public static final EventType SMOOTHING_ENABLE_CHANGED = + new EventType<>(ANY, "HYPERSURF_SMOOTHING_ENABLE_CHANGED"); // Boolean + public static final EventType SMOOTHING_METHOD_CHANGED = + new EventType<>(ANY, "HYPERSURF_SMOOTHING_METHOD_CHANGED"); // SurfaceUtils.Smoothing + public static final EventType SMOOTHING_RADIUS_CHANGED = + new EventType<>(ANY, "HYPERSURF_SMOOTHING_RADIUS_CHANGED"); // Integer + public static final EventType GAUSSIAN_SIGMA_CHANGED = + new EventType<>(ANY, "HYPERSURF_GAUSSIAN_SIGMA_CHANGED"); // Double + + // --- Processing pipeline: tone mapping --- + public static final EventType TONEMAP_ENABLE_CHANGED = + new EventType<>(ANY, "HYPERSURF_TONEMAP_ENABLE_CHANGED"); // Boolean + public static final EventType TONEMAP_OPERATOR_CHANGED = + new EventType<>(ANY, "HYPERSURF_TONEMAP_OPERATOR_CHANGED"); // SurfaceUtils.ToneMap + public static final EventType TONEMAP_PARAM_CHANGED = + new EventType<>(ANY, "HYPERSURF_TONEMAP_PARAM_CHANGED"); // Double + + // --- Height normalization mode --- + public static final EventType HEIGHT_MODE_CHANGED = + new EventType<>(ANY, "HYPERSURF_HEIGHT_MODE_CHANGED"); // DataUtils.HeightMode + + // --- Lighting --- + public static final EventType AMBIENT_ENABLED_CHANGED = + new EventType<>(ANY, "HYPERSURF_AMBIENT_ENABLED_CHANGED"); // Boolean + public static final EventType AMBIENT_COLOR_CHANGED = + new EventType<>(ANY, "HYPERSURF_AMBIENT_COLOR_CHANGED"); // Color + public static final EventType POINT_ENABLED_CHANGED = + new EventType<>(ANY, "HYPERSURF_POINT_ENABLED_CHANGED"); // Boolean + public static final EventType SPECULAR_COLOR_CHANGED = + new EventType<>(ANY, "HYPERSURF_SPECULAR_COLOR_CHANGED"); // Color + + // --- UX / overlay toggles --- + public static final EventType HOVER_ENABLE_CHANGED = + new EventType<>(ANY, "HYPERSURF_HOVER_ENABLE_CHANGED"); // Boolean + public static final EventType SURFACE_CHARTS_ENABLE_CHANGED = + new EventType<>(ANY, "HYPERSURF_SURFACE_CHARTS_ENABLE_CHANGED"); // Boolean + public static final EventType DATA_MARKERS_ENABLE_CHANGED = + new EventType<>(ANY, "HYPERSURF_DATA_MARKERS_ENABLE_CHANGED"); // Boolean + public static final EventType CROSSHAIRS_ENABLE_CHANGED = + new EventType<>(ANY, "HYPERSURF_CROSSHAIRS_ENABLE_CHANGED"); // Boolean + + // --- Commands / actions --- + public static final EventType RESET_VIEW = + new EventType<>(ANY, "HYPERSURF_RESET_VIEW"); // no payload + public static final EventType UPDATE_RENDER = + new EventType<>(ANY, "HYPERSURF_UPDATE_RENDER"); // no payload + public static final EventType CLEAR_DATA = + new EventType<>(ANY, "HYPERSURF_CLEAR_DATA"); // no payload + public static final EventType UNROLL_REQUESTED = + new EventType<>(ANY, "HYPERSURF_UNROLL_REQUESTED"); // no payload + public static final EventType COMPUTE_VECTOR_DISTANCES = + new EventType<>(ANY, "HYPERSURF_COMPUTE_VECTOR_DISTANCES"); // no payload + public static final EventType COMPUTE_COLLECTION_DIFF = + new EventType<>(ANY, "HYPERSURF_COMPUTE_COLLECTION_DIFF"); // FeatureCollection + public static final EventType COMPUTE_COSINE_DISTANCE = + new EventType<>(ANY, "HYPERSURF_COMPUTE_COSINE_DISTANCE"); // FeatureCollection + + // --- Constructors (matching your style) --- + public HypersurfaceEvent(EventType type) { + super(type); + } + + public HypersurfaceEvent(EventType type, Object payload) { + this(type); + this.object = payload; + } + + public HypersurfaceEvent(Object source, EventTarget target, EventType type) { + super(source, target, type); + this.object = source; + } + + // --- Convenience factories --- + public static HypersurfaceEvent of(EventType type) { + return new HypersurfaceEvent(type); + } + + public static HypersurfaceEvent of(EventType type, Object payload) { + return new HypersurfaceEvent(type, payload); + } + + // Examples: (for all existing events) + public static HypersurfaceEvent yScale(double v) { + return of(Y_SCALE_CHANGED, v); + } + + public static HypersurfaceEvent surfScale(double v) { + return of(SURF_SCALE_CHANGED, v); + } + + public static HypersurfaceEvent xWidth(int v) { + return of(XWIDTH_CHANGED, v); + } + + public static HypersurfaceEvent zWidth(int v) { + return of(ZWIDTH_CHANGED, v); + } + + public static HypersurfaceEvent surfaceRender(boolean surface) { + return of(SURFACE_RENDER_CHANGED, surface); + } + + public static HypersurfaceEvent drawMode(Object drawMode) { + return of(DRAW_MODE_CHANGED, drawMode); + } + + public static HypersurfaceEvent cullFace(Object cullFace) { + return of(CULL_FACE_CHANGED, cullFace); + } + + public static HypersurfaceEvent coloration(Object colorationEnum) { + return of(COLORATION_CHANGED, colorationEnum); + } + + public static HypersurfaceEvent interp(Object interpEnum) { + return of(INTERP_MODE_CHANGED, interpEnum); + } + + public static HypersurfaceEvent smoothingEnabled(boolean b) { + return of(SMOOTHING_ENABLE_CHANGED, b); + } + + public static HypersurfaceEvent smoothingMethod(Object methodEnum) { + return of(SMOOTHING_METHOD_CHANGED, methodEnum); + } + + public static HypersurfaceEvent smoothingRadius(int r) { + return of(SMOOTHING_RADIUS_CHANGED, r); + } + + public static HypersurfaceEvent gaussianSigma(double s) { + return of(GAUSSIAN_SIGMA_CHANGED, s); + } + + public static HypersurfaceEvent toneEnabled(boolean b) { + return of(TONEMAP_ENABLE_CHANGED, b); + } + + public static HypersurfaceEvent toneOperator(Object opEnum) { + return of(TONEMAP_OPERATOR_CHANGED, opEnum); + } + + public static HypersurfaceEvent toneParam(double v) { + return of(TONEMAP_PARAM_CHANGED, v); + } + + public static HypersurfaceEvent heightMode(Object modeEnum) { + return of(HEIGHT_MODE_CHANGED, modeEnum); + } + + public static HypersurfaceEvent ambientEnabled(boolean b) { + return of(AMBIENT_ENABLED_CHANGED, b); + } + + public static HypersurfaceEvent ambientColor(Object color) { + return of(AMBIENT_COLOR_CHANGED, color); + } + + public static HypersurfaceEvent pointEnabled(boolean b) { + return of(POINT_ENABLED_CHANGED, b); + } + + public static HypersurfaceEvent specularColor(Object color) { + return of(SPECULAR_COLOR_CHANGED, color); + } + + public static HypersurfaceEvent hoverEnabled(boolean b) { + return of(HOVER_ENABLE_CHANGED, b); + } + + public static HypersurfaceEvent surfaceChartsEnabled(boolean b) { + return of(SURFACE_CHARTS_ENABLE_CHANGED, b); + } + + public static HypersurfaceEvent dataMarkersEnabled(boolean b) { + return of(DATA_MARKERS_ENABLE_CHANGED, b); + } + + public static HypersurfaceEvent crosshairsEnabled(boolean b) { + return of(CROSSHAIRS_ENABLE_CHANGED, b); + } + + public static HypersurfaceEvent resetView() { + return of(RESET_VIEW); + } + + public static HypersurfaceEvent updateRender() { + return of(UPDATE_RENDER); + } + + public static HypersurfaceEvent clearData() { + return of(CLEAR_DATA); + } + + public static HypersurfaceEvent unroll() { + return of(UNROLL_REQUESTED); + } + + public static HypersurfaceEvent computeVectorDistances() { + return of(COMPUTE_VECTOR_DISTANCES); + } + + public static HypersurfaceEvent computeCollectionDiff(Object featureCollection) { + return of(COMPUTE_COLLECTION_DIFF, featureCollection); + } + + public static HypersurfaceEvent computeCosineDistance(Object featureCollection) { + return of(COMPUTE_COSINE_DISTANCE, featureCollection); + } + + // --- Factories for GUI sync events --- + public static HypersurfaceEvent setXWidthGUI(int v) { + return of(SET_XWIDTH_GUI, v); + } + + public static HypersurfaceEvent setZWidthGUI(int v) { + return of(SET_ZWIDTH_GUI, v); + } + + public static HypersurfaceEvent setYScaleGUI(double v) { + return of(SET_YSCALE_GUI, v); + } + + public static HypersurfaceEvent setSurfScaleGUI(double v) { + return of(SET_SURFSCALE_GUI, v); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java new file mode 100644 index 00000000..36045c65 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java @@ -0,0 +1,77 @@ +package edu.jhuapl.trinity.javafx.events; + +import javafx.event.Event; +import javafx.event.EventTarget; +import javafx.event.EventType; + +import java.util.List; + +/** + * Dispatch a surface grid (PDF or CDF) to the hypersurface renderer. + *

+ * Z is row-major by Y then X (double[rowsY][colsX]). + * If your renderer expects Y-up, flip rows before rendering. + * + * @author Sean Phillips + */ +public class HypersurfaceGridEvent extends Event { + public static final EventType ANY = + new EventType<>(Event.ANY, "HYPERSURFACE_GRID_ANY"); + + public static final EventType RENDER_PDF = + new EventType<>(ANY, "HYPERSURFACE_GRID_RENDER_PDF"); + + public static final EventType RENDER_CDF = + new EventType<>(ANY, "HYPERSURFACE_GRID_RENDER_CDF"); + + private final List> zGrid; // or keep double[][] if preferred + private final double[] xCenters; + private final double[] yCenters; + private final String label; + + public HypersurfaceGridEvent( + EventType eventType, + List> zGrid, + double[] xCenters, + double[] yCenters, + String label + ) { + super(eventType); + this.zGrid = zGrid; + this.xCenters = xCenters; + this.yCenters = yCenters; + this.label = label; + } + + public HypersurfaceGridEvent( + Object source, + EventTarget target, + EventType eventType, + List> zGrid, + double[] xCenters, + double[] yCenters, + String label + ) { + super(source, target, eventType); + this.zGrid = zGrid; + this.xCenters = xCenters; + this.yCenters = yCenters; + this.label = label; + } + + public List> getZGrid() { + return zGrid; + } + + public double[] getXCenters() { + return xCenters; + } + + public double[] getYCenters() { + return yCenters; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java index c02f584e..73f62c15 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java @@ -1,9 +1,12 @@ package edu.jhuapl.trinity.javafx.handlers; +import com.fasterxml.jackson.core.JsonProcessingException; import edu.jhuapl.trinity.App; import edu.jhuapl.trinity.data.Dimension; import edu.jhuapl.trinity.data.FactorLabel; import edu.jhuapl.trinity.data.FeatureLayer; +import edu.jhuapl.trinity.data.messages.xai.CyberReport; +import edu.jhuapl.trinity.data.messages.xai.CyberVector; import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.data.messages.xai.LabelConfig; @@ -20,6 +23,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; /** @@ -49,6 +53,97 @@ public void addFeatureVectorRenderer(FeatureVectorRenderer renderer) { renderers.add(renderer); } + public static FeatureVector cyberToFeatureVector(String label, CyberVector cyberVector) { + FeatureVector fv = new FeatureVector(); + fv.setData(CyberVector.mapToVectorList.apply(cyberVector)); + if (null != label) { + fv.setLabel(label); + try { + fv.setText(cyberVector.asJSON(label)); + } catch (JsonProcessingException ex) { + + } + } + return fv; + } + + private void addNewFeatureVector(FeatureVector featureVector) { + //Have we seen this label before? + FactorLabel matchingLabel = FactorLabel.getFactorLabel(featureVector.getLabel()); + //The label is new... add a new FactorLabel row + if (null == matchingLabel) { + if (labelColorIndex > labelColorCount) { + labelColorIndex = 0; + } + FactorLabel fl = new FactorLabel(featureVector.getLabel(), + labelColorMap.getColorByIndex(labelColorIndex)); + FactorLabel.addFactorLabel(fl); + labelColorIndex++; + } + //Have we seen this layer before? + int index = featureVector.getLayer(); + FeatureLayer matchingLayer = FeatureLayer.getFeatureLayer(index); + //The layer is new... add a new FeatureLayer row + if (null == matchingLayer) { + if (layerColorIndex > layerColorCount) { + layerColorIndex = 0; + } + FeatureLayer fl = new FeatureLayer(index, + layerColorMap.getColorByIndex(layerColorIndex)); + FeatureLayer.addFeatureLayer(fl); + layerColorIndex++; + } + + for (FeatureVectorRenderer renderer : renderers) { + renderer.addFeatureVector(featureVector); + } + } + + public void handleCyberReport(FeatureVectorEvent event) { + List cyberReports = (List) event.object; + FeatureCollection fc = new FeatureCollection(); + List features = new ArrayList<>(); + + for (CyberReport cyberReport : cyberReports) { + HashMap metaData = new HashMap(); + metaData.put(CyberReport.GROUNDTRUTH, cyberReport.getGroundTruth()); + metaData.put(CyberReport.ADJACENTNETWORK, cyberReport.getAdjacentNetwork()); + metaData.put(CyberReport.INFERENCES, cyberReport.getInferences().toString()); + metaData.put(CyberReport.MOD, cyberReport.getMod().toString()); + + FeatureVector sgtaFV = cyberToFeatureVector(CyberReport.SGTA, cyberReport.getsGtA()); + sgtaFV.getMetaData().putAll(metaData); + //addNewFeatureVector(sgtaFV); + features.add(sgtaFV); + + FeatureVector sinfgtFV = cyberToFeatureVector(CyberReport.SINFGT, cyberReport.getsInfGt()); + sinfgtFV.getMetaData().putAll(metaData); + //addNewFeatureVector(sinfgtFV); + features.add(sinfgtFV); + + FeatureVector sintelaFV = cyberToFeatureVector(CyberReport.SINTELA, cyberReport.getsIntelA()); + sintelaFV.getMetaData().putAll(metaData); + //addNewFeatureVector(sintelaFV); + features.add(sintelaFV); + + FeatureVector sintelgtFV = cyberToFeatureVector(CyberReport.SINTELGT, cyberReport.getsIntelGt()); + sintelgtFV.getMetaData().putAll(metaData); + //addNewFeatureVector(sintelgtFV); + features.add(sintelgtFV); + + FeatureVector deltaFV = cyberToFeatureVector(CyberReport.DELTA, cyberReport.getDelta()); + deltaFV.getMetaData().putAll(metaData); + //addNewFeatureVector(deltaFV); + features.add(deltaFV); + } + fc.setFeatures(features); + Platform.runLater(() -> { + App.getAppScene().getRoot().fireEvent( + new FeatureVectorEvent( + FeatureVectorEvent.NEW_FEATURE_COLLECTION, fc)); + }); + } + public void handleFeatureVectorEvent(FeatureVectorEvent event) { FeatureVector featureVector = (FeatureVector) event.object; if (event.getEventType().equals(FeatureVectorEvent.LOCATE_FEATURE_VECTOR)) { @@ -57,35 +152,7 @@ public void handleFeatureVectorEvent(FeatureVectorEvent event) { } } if (event.getEventType().equals(FeatureVectorEvent.NEW_FEATURE_VECTOR)) { - //Have we seen this label before? - FactorLabel matchingLabel = FactorLabel.getFactorLabel(featureVector.getLabel()); - //The label is new... add a new FactorLabel row - if (null == matchingLabel) { - if (labelColorIndex > labelColorCount) { - labelColorIndex = 0; - } - FactorLabel fl = new FactorLabel(featureVector.getLabel(), - labelColorMap.getColorByIndex(labelColorIndex)); - FactorLabel.addFactorLabel(fl); - labelColorIndex++; - } - //Have we seen this layer before? - int index = featureVector.getLayer(); - FeatureLayer matchingLayer = FeatureLayer.getFeatureLayer(index); - //The layer is new... add a new FeatureLayer row - if (null == matchingLayer) { - if (layerColorIndex > layerColorCount) { - layerColorIndex = 0; - } - FeatureLayer fl = new FeatureLayer(index, - layerColorMap.getColorByIndex(layerColorIndex)); - FeatureLayer.addFeatureLayer(fl); - layerColorIndex++; - } - - for (FeatureVectorRenderer renderer : renderers) { - renderer.addFeatureVector(featureVector); - } + addNewFeatureVector(featureVector); } } @@ -134,7 +201,6 @@ public void scanLabelsAndLayers(List featureVectors) { FactorLabel.addAllFactorLabels(newFactorLabels); if (!newFeatureLayers.isEmpty()) FeatureLayer.addAllFeatureLayer(newFeatureLayers); - } public void handleClearAllEvent(FeatureVectorEvent event) { @@ -165,26 +231,29 @@ public void handleFeatureCollectionEvent(FeatureVectorEvent event) { } //Did the message specify any new dimensional label strings? if (null != featureCollection.getDimensionLabels() && !featureCollection.getDimensionLabels().isEmpty()) { - Dimension.removeAllDimensions(); - int counter = 0; - for (String dimensionLabel : featureCollection.getDimensionLabels()) { - Dimension.addDimension(new Dimension(dimensionLabel, - counter++, Color.ALICEBLUE)); - } - Platform.runLater(() -> { - App.getAppScene().getRoot().fireEvent( - new HyperspaceEvent(HyperspaceEvent.DIMENSION_LABELS_SET, - featureCollection.getDimensionLabels())); + updateDimensionLabels(featureCollection.getDimensionLabels()); + } + } - App.getAppScene().getRoot().fireEvent( - new CommandTerminalEvent("New Dimensional Labels set", - new Font("Consolas", 20), Color.GREEN)); - }); - //update the renderers with the new arraylist of strings - for (FeatureVectorRenderer renderer : renderers) { - renderer.setDimensionLabels(featureCollection.getDimensionLabels()); - renderer.refresh(true); - } + public void updateDimensionLabels(ArrayList labels) { + Dimension.removeAllDimensions(); + int counter = 0; + for (String dimensionLabel : labels) { + Dimension.addDimension(new Dimension(dimensionLabel, + counter++, Color.ALICEBLUE)); + } + Platform.runLater(() -> { + App.getAppScene().getRoot().fireEvent( + new HyperspaceEvent(HyperspaceEvent.DIMENSION_LABELS_SET, labels)); + + App.getAppScene().getRoot().fireEvent( + new CommandTerminalEvent("New Dimensional Labels set", + new Font("Consolas", 20), Color.GREEN)); + }); + //update the renderers with the new arraylist of strings + for (FeatureVectorRenderer renderer : renderers) { + renderer.setDimensionLabels(labels); + renderer.refresh(true); } } @@ -251,11 +320,7 @@ public void handleLabelConfigEvent(FeatureVectorEvent event) { } //Did the message specify any new dimensional label strings? if (null != labelConfig.getDimensionLabels() && !labelConfig.getDimensionLabels().isEmpty()) { - //update the renderers with the new arraylist of strings - for (FeatureVectorRenderer renderer : renderers) { - renderer.setDimensionLabels(labelConfig.getDimensionLabels()); - renderer.refresh(true); - } + updateDimensionLabels(labelConfig.getDimensionLabels()); } } @@ -276,6 +341,8 @@ else if (event.getEventType().equals(FeatureVectorEvent.RESCAN_FACTOR_LABELS) for (FeatureVectorRenderer renderer : renderers) { scanLabelsAndLayers(renderer.getAllFeatureVectors()); } + } else if (event.getEventType().equals(FeatureVectorEvent.NEW_CYBER_REPORT)) { + handleCyberReport(event); } } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java index ee720070..d6965121 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java @@ -37,12 +37,15 @@ import javafx.scene.transform.Translate; import org.fxyz3d.geometry.Face3; import org.fxyz3d.geometry.Point3D; +import org.fxyz3d.scene.paint.Palette; import org.fxyz3d.scene.paint.Palette.ColorPalette; import org.fxyz3d.scene.paint.Palette.FunctionColorPalette; import org.fxyz3d.scene.paint.Patterns.CarbonPatterns; import org.fxyz3d.shapes.primitives.helper.MeshHelper; import org.fxyz3d.shapes.primitives.helper.TextureMode; import org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper; +import org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.SectionType; +import org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.TextureType; import java.util.ArrayList; import java.util.Arrays; @@ -51,9 +54,6 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.fxyz3d.scene.paint.Palette.DEFAULT_COLOR_PALETTE; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.*; - /** * TexturedMesh is a base class that provides support for different mesh implementations * taking into account four different kind of textures @@ -276,7 +276,7 @@ public ObjectProperty textureTypeProperty() { return textureType; } - private final DoubleProperty patternScale = new SimpleDoubleProperty(DEFAULT_PATTERN_SCALE) { + private final DoubleProperty patternScale = new SimpleDoubleProperty(TriangleMeshHelper.DEFAULT_PATTERN_SCALE) { @Override protected void invalidated() { @@ -297,7 +297,7 @@ public DoubleProperty patternScaleProperty() { return patternScale; } - private final IntegerProperty colors = new SimpleIntegerProperty(DEFAULT_COLORS) { + private final IntegerProperty colors = new SimpleIntegerProperty(TriangleMeshHelper.DEFAULT_COLORS) { @Override protected void invalidated() { @@ -317,7 +317,7 @@ public IntegerProperty colorsProperty() { return colors; } - private final ObjectProperty colorPalette = new SimpleObjectProperty(DEFAULT_COLOR_PALETTE) { + private final ObjectProperty colorPalette = new SimpleObjectProperty(Palette.DEFAULT_COLOR_PALETTE) { @Override protected void invalidated() { @@ -339,7 +339,7 @@ public ObjectProperty colorPaletteProperty() { return colorPalette; } - private final ObjectProperty diffuseColor = new SimpleObjectProperty(DEFAULT_DIFFUSE_COLOR) { + private final ObjectProperty diffuseColor = new SimpleObjectProperty(TriangleMeshHelper.DEFAULT_DIFFUSE_COLOR) { @Override protected void invalidated() { @@ -347,7 +347,7 @@ protected void invalidated() { } }; - private final ObjectProperty carbonPatterns = new SimpleObjectProperty(DEFAULT_PATTERN) { + private final ObjectProperty carbonPatterns = new SimpleObjectProperty(TriangleMeshHelper.DEFAULT_PATTERN) { @Override protected void invalidated() { helper.getMaterialWithPattern(get()); @@ -379,7 +379,7 @@ public ObjectProperty diffuseColorProperty() { } - private final ObjectProperty> density = new SimpleObjectProperty>(DEFAULT_DENSITY_FUNCTION) { + private final ObjectProperty> density = new SimpleObjectProperty>(TriangleMeshHelper.DEFAULT_DENSITY_FUNCTION) { @Override protected void invalidated() { @@ -400,7 +400,7 @@ public final ObjectProperty> densityProperty() { return density; } - private final ObjectProperty> function = new SimpleObjectProperty>(DEFAULT_UNIDIM_FUNCTION) { + private final ObjectProperty> function = new SimpleObjectProperty>(TriangleMeshHelper.DEFAULT_UNIDIM_FUNCTION) { @Override protected void invalidated() { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java index 34174b77..258087c7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -453,6 +453,9 @@ private TriangleMesh createSmoothMesh(Function vertFunction, int listFaces.add(new Face3(p11, p01, p00)); } } + int[] faceSmoothingGroups = new int[listFaces.size()]; // 0 == hard edges + Arrays.fill(faceSmoothingGroups, 1); // 1: soft edges, all the faces in same surface + smoothingGroups = faceSmoothingGroups; return createMesh(); } @@ -494,6 +497,9 @@ private TriangleMesh createPlotMesh(Function function2D, double listFaces.add(new Face3(p11, p01, p00)); } } + int[] faceSmoothingGroups = new int[listFaces.size()]; // 0 == hard edges + Arrays.fill(faceSmoothingGroups, 1); // 1: soft edges, all the faces in same surface + smoothingGroups = faceSmoothingGroups; return createMesh(); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java index a838466b..3d74ca3a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java @@ -573,12 +573,12 @@ public Hyperspace3DPane(Scene scene) { if (!event.isControlDown() && event.isAltDown() && keycode == KeyCode.H) { makeHull(false, null, null); } - if (keycode == KeyCode.F) { + if (!event.isControlDown() && event.isAltDown() && keycode == KeyCode.F) { anchorIndex--; if (anchorIndex < 0) anchorIndex = 0; setSpheroidAnchor(true, anchorIndex); } - if (keycode == KeyCode.G) { + if (!event.isControlDown() && event.isAltDown() && keycode == KeyCode.G) { anchorIndex++; if (anchorIndex > scatterModel.data.size()) anchorIndex = scatterModel.data.size(); setSpheroidAnchor(true, anchorIndex); @@ -1603,7 +1603,7 @@ public void showAll() { @Override public void setSpheroidAnchor(boolean animate, int index) { - if (index >= scatterModel.data.size()) { + if (index >= scatterModel.data.size() || scatterModel.data.isEmpty()) { //System.out.println("Requested anchor index of " + index + " greater than scatterModel.data size"); return; } else if (index < 0) { @@ -1955,7 +1955,7 @@ protected Void call() throws Exception { getScene().getRoot().fireEvent( new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR, ps)); getScene().getRoot().fireEvent( - new CommandTerminalEvent("Error Adding FeatureCollection.", new Font("Consolas", 20), Color.RED)); + new CommandTerminalEvent("Error Adding FeatureCollection.", new Font("Consolas", 20), Color.RED)); }); }); Thread thread = new Thread(task); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 26da6ed5..01992eaf 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -1,9 +1,11 @@ package edu.jhuapl.trinity.javafx.javafx3d; import edu.jhuapl.trinity.App; -import edu.jhuapl.trinity.css.StyleResourceProvider; import edu.jhuapl.trinity.data.CoordinateSet; import edu.jhuapl.trinity.data.files.FeatureCollectionFile; +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import edu.jhuapl.trinity.data.graph.GraphNode; import edu.jhuapl.trinity.data.messages.bci.SemanticMap; import edu.jhuapl.trinity.data.messages.bci.SemanticMapCollection; import edu.jhuapl.trinity.data.messages.bci.SemanticReconstruction; @@ -19,12 +21,17 @@ import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; import edu.jhuapl.trinity.javafx.events.ImageEvent; import edu.jhuapl.trinity.javafx.events.ManifoldEvent; import edu.jhuapl.trinity.javafx.events.ShadowEvent; import edu.jhuapl.trinity.javafx.events.TimelineEvent; +import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationTube; +import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; import edu.jhuapl.trinity.javafx.javafx3d.images.ImageResourceProvider; import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.DBSCANClusterTask; @@ -33,12 +40,17 @@ import edu.jhuapl.trinity.javafx.javafx3d.tasks.KMeansClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.KMediodsClusterTask; import edu.jhuapl.trinity.javafx.renderers.FeatureVectorRenderer; +import edu.jhuapl.trinity.javafx.renderers.Graph3DRenderer; import edu.jhuapl.trinity.javafx.renderers.SemanticMapRenderer; import edu.jhuapl.trinity.javafx.renderers.ShapleyVectorRenderer; +import edu.jhuapl.trinity.utils.DataUtils; +import edu.jhuapl.trinity.utils.DataUtils.HeightMode; import edu.jhuapl.trinity.utils.JavaFX3DUtils; import edu.jhuapl.trinity.utils.ResourceUtils; import edu.jhuapl.trinity.utils.Utils; +import edu.jhuapl.trinity.utils.graph.GraphStyleParams; import edu.jhuapl.trinity.utils.metric.Metric; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; import javafx.animation.AnimationTimer; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; @@ -46,11 +58,12 @@ import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; import javafx.event.ActionEvent; +import javafx.event.Event; import javafx.geometry.Point2D; -import javafx.geometry.Pos; import javafx.scene.AmbientLight; import javafx.scene.Group; import javafx.scene.Node; +import javafx.scene.Parent; import javafx.scene.PerspectiveCamera; import javafx.scene.PointLight; import javafx.scene.Scene; @@ -59,19 +72,13 @@ import javafx.scene.SubScene; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; -import javafx.scene.control.CheckBox; import javafx.scene.control.CheckMenuItem; -import javafx.scene.control.ColorPicker; import javafx.scene.control.ContextMenu; import javafx.scene.control.DialogPane; import javafx.scene.control.Label; +import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; -import javafx.scene.control.RadioButton; -import javafx.scene.control.Spinner; -import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.TitledPane; -import javafx.scene.control.ToggleButton; -import javafx.scene.control.ToggleGroup; import javafx.scene.effect.Glow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; @@ -86,7 +93,6 @@ import javafx.scene.input.ScrollEvent; import javafx.scene.layout.Background; import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; @@ -122,18 +128,17 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Map.Entry; +import java.util.Map; import java.util.Optional; import java.util.Random; import java.util.function.Function; -import javafx.scene.control.Menu; /** * @author Sean Phillips */ - public class Hypersurface3DPane extends StackPane implements SemanticMapRenderer, FeatureVectorRenderer, ShapleyVectorRenderer { + private static final Logger LOG = LoggerFactory.getLogger(Hypersurface3DPane.class); public static double ICON_FIT_HEIGHT = 64; public static double DEFAULT_INTRO_DISTANCE = -30000.0; @@ -182,7 +187,7 @@ public class Hypersurface3DPane extends StackPane boolean heightChanged = false; public boolean surfaceRender = true; - enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} + public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} COLORATION colorationMethod = COLORATION.COLOR_BY_FEATURE; boolean hoverInteractionsEnabled = false; @@ -213,16 +218,11 @@ enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public float surfScale = DEFAULT_SURFSCALE; int TOTAL_COLORS = 1530; //colors used by map function - Function colorByHeight = p -> { - return p.y; //Color mapping function - }; - Function colorByShapley = p -> { - return p.f; - }; - - Function vert3DLookup = p -> { - return vertToHeight(p); - }; + Function colorByHeight = p -> p.y; //Color mapping function + Function colorByShapley = p -> p.f; + + Function vert3DLookup = p -> vertToHeight(p); + // initial rotation private final Rotate rotateX = new Rotate(0, Rotate.X_AXIS); private final Rotate rotateY = new Rotate(0, Rotate.Y_AXIS); @@ -241,7 +241,6 @@ enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} Callout anchorCallout; //For each label you'll need some Shape3D to derive a point3d from. - //For this we will use simple spheres. These can be optionally invisible. private Sphere xSphere = new Sphere(10); private Sphere ySphere = new Sphere(10); private Sphere zSphere = new Sphere(10); @@ -252,13 +251,34 @@ enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} Text hoverText = new Text("Coordinates: "); public List featureLabels = new ArrayList<>(); - public Spinner xWidthSpinner, zWidthSpinner; public Scene scene; HashMap shape3DToCalloutMap; public String imageryBasePath = ""; SurfaceChartPane surfaceChartPane; public AmbientLight ambientLight; public PointLight pointLight; + private List> originalGrid = new ArrayList<>(); + + // Event-driven state fields for settings previously set via controls + private HeightMode heightMode = HeightMode.RAW; + private boolean smoothingEnabled = false; + private SurfaceUtils.Smoothing smoothingMethod = SurfaceUtils.Smoothing.GAUSSIAN; + private int smoothingRadius = 2; + private double gaussianSigma = 1.0; + private SurfaceUtils.Interpolation interpMode = SurfaceUtils.Interpolation.NEAREST; + private boolean toneEnabled = false; + private SurfaceUtils.ToneMap toneOperator = SurfaceUtils.ToneMap.NONE; + private double toneParam = 2.0; + // --- Graph layer support --- + private final Group graphLayer = new Group(); // sits in sceneRoot + private boolean graphVisible = true; + private GraphDirectedCollection currentGraph = null; + private Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() + .withNodeRadius(20.0) + .withEdgeWidth(8.0f) + .withPositionScalar(1.0); + // Graph visual style state (synced with GraphStyleControlsView) + private GraphStyleParams styleParams = new GraphStyleParams(); public Hypersurface3DPane(Scene scene) { this.scene = scene; @@ -273,9 +293,7 @@ public Hypersurface3DPane(Scene scene) { //add our nodes to the group that will later be added to the 3D scene nodeGroup.getChildren().addAll(xSphere, ySphere, zSphere); - //attach our custom rotation transforms so we can update the labels dynamically nodeGroup.getTransforms().addAll(rotateX, rotateY, rotateZ); - //Customize the 3D nodes a bit xSphere.setTranslateX(planeSize / 2.0); xSphere.setMaterial(new PhongMaterial(Color.RED)); ySphere.setTranslateY(-planeSize / 2.0); @@ -286,7 +304,7 @@ public Hypersurface3DPane(Scene scene) { highlightedPoint.setDrawMode(DrawMode.FILL); highlightedPoint.setMouseTransparent(true); - //customize the labels to match + // Labels Font font = new Font("Consolas", 20); xLabel.setTextFill(Color.YELLOW); xLabel.setFont(font); @@ -304,17 +322,14 @@ public Hypersurface3DPane(Scene scene) { hoverText.setFont(new Font("Consolas", 30)); hoverText.setMouseTransparent(true); - //add our labels to the group that will be added to the StackPane labelGroup.getChildren().addAll(xLabel, yLabel, zLabel, hoverText); labelGroup.setManaged(false); - //Add to hashmap so updateLabels() can manage the label position shape3DToLabel.put(xSphere, xLabel); shape3DToLabel.put(ySphere, yLabel); shape3DToLabel.put(zSphere, zLabel); shape3DToLabel.put(highlightedPoint, hoverText); camera = new PerspectiveCamera(true); - //setup camera transform for rotational support cameraTransform.setTranslate(0, 0, 0); cameraTransform.getChildren().add(camera); camera.setNearClip(0.1); @@ -326,21 +341,20 @@ public Hypersurface3DPane(Scene scene) { debugGroup.setVisible(false); extrasGroup.setVisible(false); labelGroup.setVisible(false); - //Add 3D subscene stuff to 3D scene root object sceneRoot.getChildren().addAll(cameraTransform, highlightedPoint, nodeGroup, extrasGroup, debugGroup, dataXForm); - + // Add graph layer last so it draws above the surface (z-order within Group) + sceneRoot.getChildren().add(graphLayer); + graphLayer.setVisible(graphVisible); + // Sync controls with current visibility on startup + fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); subScene.setCamera(camera); - //add a Point Light for better viewing of the grid coordinate system pointLight = new PointLight(Color.WHITE); cameraTransform.getChildren().add(pointLight); pointLight.setTranslateX(camera.getTranslateX()); pointLight.setTranslateY(camera.getTranslateY()); pointLight.setTranslateZ(camera.getTranslateZ() + 500.0); - //Some camera controls... - subScene.setOnMouseEntered(event -> subScene.requestFocus()); - setOnMouseEntered(event -> subScene.requestFocus()); subScene.setOnZoom(event -> { double modifier = 50.0; double modifierFactor = 0.1; @@ -351,7 +365,6 @@ public Hypersurface3DPane(Scene scene) { }); subScene.setOnKeyPressed(event -> { - //What key did the user press? KeyCode keycode = event.getCode(); if ((keycode == KeyCode.NUMPAD0 && event.isControlDown()) @@ -362,59 +375,30 @@ public Hypersurface3DPane(Scene scene) { resetView(0, true); } double change = 10.0; - //Add shift modifier to simulate "Running Speed" - if (event.isShiftDown()) { - change = 100.0; - } + if (event.isShiftDown()) change = 100.0; - //Zoom controls - if (keycode == KeyCode.W) { - camera.setTranslateZ(camera.getTranslateZ() + change); - } - if (keycode == KeyCode.S) { - camera.setTranslateZ(camera.getTranslateZ() - change); - } - if (keycode == KeyCode.PLUS && event.isShortcutDown()) { - camera.setTranslateZ(camera.getTranslateZ() + change); - } - if (keycode == KeyCode.MINUS && event.isShortcutDown()) { - camera.setTranslateZ(camera.getTranslateZ() - change); - } + if (keycode == KeyCode.W) camera.setTranslateZ(camera.getTranslateZ() + change); + if (keycode == KeyCode.S) camera.setTranslateZ(camera.getTranslateZ() - change); + if (keycode == KeyCode.PLUS && event.isShortcutDown()) camera.setTranslateZ(camera.getTranslateZ() + change); + if (keycode == KeyCode.MINUS && event.isShortcutDown()) camera.setTranslateZ(camera.getTranslateZ() - change); - //Strafe controls - if (keycode == KeyCode.A) { - camera.setTranslateX(camera.getTranslateX() - change); - } - if (keycode == KeyCode.D) { - camera.setTranslateX(camera.getTranslateX() + change); - } - if (keycode == KeyCode.SPACE) { - camera.setTranslateY(camera.getTranslateY() + change); - } - if (keycode == KeyCode.X) { - camera.setTranslateY(camera.getTranslateY() - change); - } - //rotate controls use less sensitive modifiers - change = event.isShiftDown() ? 10.0 : 1.0; + if (keycode == KeyCode.A) camera.setTranslateX(camera.getTranslateX() - change); + if (keycode == KeyCode.D) camera.setTranslateX(camera.getTranslateX() + change); + if (keycode == KeyCode.SPACE) camera.setTranslateY(camera.getTranslateY() + change); + if (keycode == KeyCode.X) camera.setTranslateY(camera.getTranslateY() - change); - if (keycode == KeyCode.NUMPAD7 || (keycode == KeyCode.DIGIT8)) //yaw positive - cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() + change); - if (keycode == KeyCode.NUMPAD9 || (keycode == KeyCode.DIGIT8 && event.isControlDown())) //yaw negative + change = event.isShiftDown() ? 10.0 : 1.0; + if (keycode == KeyCode.NUMPAD7 || (keycode == KeyCode.DIGIT8)) cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() + change); + if (keycode == KeyCode.NUMPAD9 || (keycode == KeyCode.DIGIT8 && event.isControlDown())) cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() - change); - - if (keycode == KeyCode.NUMPAD4 || (keycode == KeyCode.DIGIT9)) //pitch positive - cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() + change); - if (keycode == KeyCode.NUMPAD6 || (keycode == KeyCode.DIGIT9 && event.isControlDown())) //pitch negative + if (keycode == KeyCode.NUMPAD4 || (keycode == KeyCode.DIGIT9)) cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() + change); + if (keycode == KeyCode.NUMPAD6 || (keycode == KeyCode.DIGIT9 && event.isControlDown())) cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() - change); - - if (keycode == KeyCode.NUMPAD1 || (keycode == KeyCode.DIGIT0)) //roll positive - cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() + change); - if (keycode == KeyCode.NUMPAD3 || (keycode == KeyCode.DIGIT0 && event.isControlDown())) //roll negative + if (keycode == KeyCode.NUMPAD1 || (keycode == KeyCode.DIGIT0)) cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() + change); + if (keycode == KeyCode.NUMPAD3 || (keycode == KeyCode.DIGIT0 && event.isControlDown())) cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() - change); - //Coordinate shifts if (keycode == KeyCode.COMMA) { - //shift coordinates to the left if (xFactorIndex > 0 && yFactorIndex > 0 && zFactorIndex > 0) { xFactorIndex -= 1; yFactorIndex -= 1; @@ -422,16 +406,7 @@ public Hypersurface3DPane(Scene scene) { Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - boolean redraw = false; - try { - //updatePNodeIndices(xFactorIndex, yFactorIndex, zFactorIndex); - redraw = true; - } catch (Exception ex) { - scene.getRoot().fireEvent( - new CommandTerminalEvent("Feature Indexing Error: (" - + xFactorIndex + ", " + yFactorIndex + ", " + zFactorIndex + ")", - new Font("Consolas", 20), Color.RED)); - } + boolean redraw = true; if (redraw) { updateView(false); notifyIndexChange(); @@ -440,8 +415,7 @@ public Hypersurface3DPane(Scene scene) { } } if (keycode == KeyCode.PERIOD) { - //shift coordinates to the right - int featureSize = featureVectors.get(0).getData().size(); + int featureSize = featureVectors.isEmpty() ? factorMaxIndex : featureVectors.get(0).getData().size(); if (xFactorIndex < factorMaxIndex - 1 && yFactorIndex < factorMaxIndex - 1 && zFactorIndex < factorMaxIndex - 1 && xFactorIndex < featureSize - 1 && yFactorIndex < featureSize - 1 && zFactorIndex < featureSize - 1) { @@ -451,58 +425,36 @@ public Hypersurface3DPane(Scene scene) { Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - boolean redraw = false; - try { - //updatePNodeIndices(xFactorIndex, yFactorIndex, zFactorIndex); - redraw = true; - } catch (ArrayIndexOutOfBoundsException ex) { - scene.getRoot().fireEvent( - new CommandTerminalEvent("Feature Indexing Error: (" - + xFactorIndex + ", " + yFactorIndex + ", " + zFactorIndex + ")", - new Font("Consolas", 20), Color.RED)); - } + boolean redraw = true; if (redraw) { updateView(false); notifyIndexChange(); } updateLabels(); } else { - scene.getRoot().fireEvent( - new CommandTerminalEvent("Feature Index Max Reached: (" - + featureSize + ")", new Font("Consolas", 20), Color.YELLOW)); + scene.getRoot().fireEvent(new CommandTerminalEvent("Feature Index Max Reached: (" + + featureSize + ")", new Font("Consolas", 20), Color.YELLOW)); } } - if (keycode == KeyCode.SLASH && event.isControlDown()) { - debugGroup.setVisible(!debugGroup.isVisible()); - } - if (keycode == KeyCode.Y) { - surfPlot.scaleHeight(1.1f); - } - if (keycode == KeyCode.H) { - surfPlot.scaleHeight(0.9f); - } + if (keycode == KeyCode.SLASH && event.isControlDown()) debugGroup.setVisible(!debugGroup.isVisible()); + if (keycode == KeyCode.Y) surfPlot.scaleHeight(1.1f); + if (keycode == KeyCode.H) surfPlot.scaleHeight(0.9f); if (keycode == KeyCode.I) { - double tz = 5; - if (event.isShiftDown()) - tz = 50; + double tz = event.isShiftDown() ? 50 : 5; glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz); } if (keycode == KeyCode.K) { - double tz = 5; - if (event.isShiftDown()) - tz = 50; + double tz = event.isShiftDown() ? 50 : 5; glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() - tz); } updateLabels(); - //update surface callouts updateCalloutHeadPoints(subScene); }); subScene.setOnMousePressed((MouseEvent me) -> { - if (me.isSynthesized()) - LOG.info("isSynthesized"); + if (me.isSynthesized()) LOG.info("isSynthesized"); mousePosX = me.getSceneX(); mousePosY = me.getSceneY(); mouseOldX = me.getSceneX(); @@ -510,43 +462,38 @@ public Hypersurface3DPane(Scene scene) { }); subScene.setOnZoom(e -> { double zoom = e.getZoomFactor(); - if (zoom > 1) { - camera.setTranslateZ(camera.getTranslateZ() + 50.0); - } else { - camera.setTranslateZ(camera.getTranslateZ() - 50.0); - } + if (zoom > 1) camera.setTranslateZ(camera.getTranslateZ() + 50.0); + else camera.setTranslateZ(camera.getTranslateZ() - 50.0); updateLabels(); - //update surface callouts updateCalloutHeadPoints(subScene); e.consume(); }); subScene.setOnScroll((ScrollEvent event) -> { double modifier = 50.0; double modifierFactor = 0.1; - - if (event.isControlDown()) { - modifier = 1; - } - if (event.isShiftDown()) { - modifier = 100.0; - } + if (event.isControlDown()) modifier = 1; + if (event.isShiftDown()) modifier = 100.0; double z = camera.getTranslateZ(); double newZ = z + event.getDeltaY() * modifierFactor * modifier; camera.setTranslateZ(newZ); updateLabels(); - //update surface callouts updateCalloutHeadPoints(subScene); }); - //Start Tracking mouse movements only when a button is pressed subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); - //Premake the SurfaceChartPane Pane pathPane = App.getAppPathPaneStack(); surfaceChartPane = new SurfaceChartPane(scene, pathPane); bp = new BorderPane(subScene); getChildren().clear(); getChildren().addAll(bp, labelGroup); + + MenuItem showControlsItem = new MenuItem("Hypersurface Controls"); + showControlsItem.setOnAction(e -> { + scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, Boolean.TRUE)); + }); + MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); copyAsImageItem.setOnAction((ActionEvent e) -> { Clipboard clipboard = Clipboard.getSystemClipboard(); @@ -567,7 +514,6 @@ public Hypersurface3DPane(Scene scene) { try { ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); } catch (IOException ioe) { - // TODO: handle exception here } } }); @@ -611,20 +557,17 @@ public Hypersurface3DPane(Scene scene) { } } }); - + Glow glow = new Glow(0.5); ImageView analysisImageView = ResourceUtils.loadIcon("analysis", ICON_FIT_HEIGHT); analysisImageView.setEffect(glow); Menu analysisMenu = new Menu("Analysis", analysisImageView, vectorDistanceItem, collectionDifferenceItem, cosineSimilarityItem - ); - + ); + CheckMenuItem enableHoverItem = new CheckMenuItem("Hover Interactions"); enableHoverItem.setOnAction(e -> { hoverInteractionsEnabled = enableHoverItem.isSelected(); - if (hoverInteractionsEnabled) { - //TODO Make coordinate labels visible - } }); CheckMenuItem surfaceChartsItem = new CheckMenuItem("Surface Charts"); @@ -638,7 +581,6 @@ public Hypersurface3DPane(Scene scene) { } if (!pp.getChildren().contains(surfaceChartPane)) { pp.getChildren().add(surfaceChartPane); - //slideInPane(surfaceChartPane); surfaceChartPane.slideInPane(); } else { surfaceChartPane.show(); @@ -653,9 +595,9 @@ public Hypersurface3DPane(Scene scene) { clearAll(); xWidth = DEFAULT_XWIDTH; zWidth = DEFAULT_ZWIDTH; - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); + syncGuiControls(); generateRandos(xWidth, zWidth, yScale); + originalGrid = deepCopyGrid(dataGrid); updateTheMesh(); updateView(true); }); @@ -664,19 +606,16 @@ public Hypersurface3DPane(Scene scene) { showDataMarkersItem.setOnAction(e -> { extrasGroup.setVisible(showDataMarkersItem.isSelected()); labelGroup.setVisible(showDataMarkersItem.isSelected()); - if (null != anchorCallout) - anchorCallout.setVisible(showDataMarkersItem.isSelected()); + if (null != anchorCallout) anchorCallout.setVisible(showDataMarkersItem.isSelected()); }); CheckMenuItem enableCrosshairsItem = new CheckMenuItem("Enable Crosshairs"); - enableCrosshairsItem.setOnAction(e -> { - crosshairsEnabled = enableCrosshairsItem.isSelected(); - }); + enableCrosshairsItem.setOnAction(e -> crosshairsEnabled = enableCrosshairsItem.isSelected()); MenuItem resetViewItem = new MenuItem("Reset View"); resetViewItem.setOnAction(e -> resetView(1000, false)); - ContextMenu cm = new ContextMenu(copyAsImageItem, saveSnapshotItem, - unrollHyperspaceItem, analysisMenu, + ContextMenu cm = new ContextMenu(showControlsItem, + copyAsImageItem, saveSnapshotItem, unrollHyperspaceItem, analysisMenu, enableHoverItem, surfaceChartsItem, showDataMarkersItem, enableCrosshairsItem, updateAllItem, clearDataItem, resetViewItem); cm.setAutoFix(true); @@ -686,14 +625,65 @@ public Hypersurface3DPane(Scene scene) { subScene.setOnMouseClicked((MouseEvent e) -> { if (e.getButton() == MouseButton.SECONDARY) { - if (!cm.isShowing()) - cm.show(this.getParent(), e.getScreenX(), e.getScreenY()); - else - cm.hide(); + if (!cm.isShowing()) cm.show(this.getParent(), e.getScreenX(), e.getScreenY()); + else cm.hide(); e.consume(); } }); - //load empty surface +// Style params changed from GraphStyleControlsView + this.scene.addEventHandler(GraphEvent.GRAPH_STYLE_PARAMS_CHANGED, e -> { + GraphStyleParams p = (GraphStyleParams) e.object; + if (p == null) return; + + // Update local style state + styleParams.nodeColor = p.nodeColor; + styleParams.nodeRadius = p.nodeRadius; + styleParams.nodeOpacity = clamp01(p.nodeOpacity); + styleParams.edgeColor = p.edgeColor; + styleParams.edgeWidth = p.edgeWidth; + styleParams.edgeOpacity = clamp01(p.edgeOpacity); + + // Apply style. Rebuild graph only if edge width changed. + applyGraphStyle(styleParams, /*rebuildIfNeeded*/ true); + }); + +// Reset style defaults + this.scene.addEventHandler(GraphEvent.GRAPH_STYLE_RESET_DEFAULTS, e -> { + styleParams = new GraphStyleParams(); // back to defaults + + // Keep renderer params consistent for rebuilds + graphParams.withNodeRadius(styleParams.nodeRadius) + .withEdgeWidth((float) styleParams.edgeWidth); + + // Rebuild (edge width) then apply everything else live + if (currentGraph != null) { + graphLayer.getChildren().setAll( + Graph3DRenderer.buildGraphGroup(currentGraph, graphParams) + ); + } + applyGraphStyle(styleParams, /*rebuildIfNeeded*/ false); + + // GUI-sync so controls show defaults + fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); + }); + + this.scene.addEventHandler(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, e -> { + if (!(e.object instanceof GraphDirectedCollection gc)) return; + currentGraph = gc; + graphLayer.getChildren().clear(); + graphLayer.getChildren().add(Graph3DRenderer.buildGraphGroup(gc, graphParams)); + + // Apply current style to the freshly built graph + applyGraphStyle(styleParams, /*rebuildIfNeeded*/ false); + + // GUI-sync so pickers/sliders reflect the active style + fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); + fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); + scene.getRoot().fireEvent(new CommandTerminalEvent( + "Rendered 3D graph: nodes=" + gc.getNodes().size() + ", edges=" + gc.getEdges().size(), + new Font("Consolas", 18), Color.LIGHTGREEN)); + }); + loadSurf3D(); this.scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { Color color = (Color) e.object; @@ -706,8 +696,8 @@ public Hypersurface3DPane(Scene scene) { Image image = (Image) e.object; int x1 = 0; int y1 = 0; - int x2 = Double.valueOf(image.getWidth()).intValue(); - int y2 = Double.valueOf(image.getHeight()).intValue(); + int x2 = (int) image.getWidth(); + int y2 = (int) image.getHeight(); if (x2 > 512 || y2 > 512) { boolean split = false; Alert alert = new Alert(Alert.AlertType.CONFIRMATION, @@ -722,16 +712,10 @@ public Hypersurface3DPane(Scene scene) { DialogPane dialogPane = alert.getDialogPane(); dialogPane.setBackground(Background.EMPTY); dialogPane.getScene().setFill(Color.TRANSPARENT); - - String DIALOGCSS = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); - dialogPane.getStylesheets().add(DIALOGCSS); - Optional optBT = alert.showAndWait(); - if (optBT.get().equals(ButtonType.CANCEL)) - return; + if (optBT.get().equals(ButtonType.CANCEL)) return; split = optBT.get().equals(ButtonType.YES); if (split) { - //@TODO SMP popup helper tool to select region scene.getRoot().fireEvent(new ApplicationEvent( ApplicationEvent.SHOW_PIXEL_SELECTION, image)); return; @@ -772,8 +756,16 @@ public Hypersurface3DPane(Scene scene) { updateView(true); notifyIndexChange(); } - } else - factorMaxIndex = newFactorMaxIndex; + } else factorMaxIndex = newFactorMaxIndex; + }); + + scene.addEventHandler(HypersurfaceGridEvent.RENDER_PDF, e -> { + applySurfaceGridToHypersurface(e.getZGrid()); + e.consume(); + }); + scene.addEventHandler(HypersurfaceGridEvent.RENDER_CDF, e -> { + applySurfaceGridToHypersurface(e.getZGrid()); + e.consume(); }); scene.addEventHandler(HyperspaceEvent.NODE_QUEUELIMIT_GUI, e -> queueLimit = (int) e.object); @@ -783,8 +775,7 @@ public Hypersurface3DPane(Scene scene) { nodeGroup.setVisible((boolean) e.object); labelGroup.setVisible((boolean) e.object); }); - scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, e -> - imageryBasePath = (String) e.object); + scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, e -> imageryBasePath = (String) e.object); Platform.runLater(() -> { updateLabels(); updateView(true); @@ -813,97 +804,165 @@ public void handle(long now) { }; surfUpdateAnimationTimer.start(); } - + + /** + * Apply style to current graph. Rebuild only if edge-width changed and requested. + */ + private void applyGraphStyle(GraphStyleParams p, boolean rebuildIfNeeded) { + if (p == null) return; + + // Determine if edge width differs from the built state + double currentEdgeWidth = graphParams.edgeWidth; // float in params, promoted to double + boolean needRebuild = Math.abs(p.edgeWidth - currentEdgeWidth) > 1e-6; + + if (needRebuild && rebuildIfNeeded && currentGraph != null) { + // Update params and rebuild to reflect edge width + node radius + graphParams.withNodeRadius(p.nodeRadius) + .withEdgeWidth((float) p.edgeWidth); + graphLayer.getChildren().setAll( + Graph3DRenderer.buildGraphGroup(currentGraph, graphParams) + ); + } + + // Apply color/opacity/radius live to existing nodes/edges + for (Node n : graphLayer.getChildren()) { + applyGraphStyleRecursive(n, p); + } + } + + private void applyGraphStyleRecursive(Node n, GraphStyleParams p) { + if (n instanceof AnimatedSphere s) { + // color + if (p.nodeColor != null) { + s.setColor(new Color( + p.nodeColor.getRed(), + p.nodeColor.getGreen(), + p.nodeColor.getBlue(), + // keep whatever alpha the sphere currently has; set below + s.getPhongMaterial().getDiffuseColor() != null + ? s.getPhongMaterial().getDiffuseColor().getOpacity() + : 1.0 + )); + } + // radius + s.setSphereRadius(p.nodeRadius); + // opacity via material alpha + s.setMaterialOpacity(p.nodeOpacity); + + } else if (n instanceof Tracer t) { + // color + if (p.edgeColor != null) { + t.setDiffuseColor(new Color( + p.edgeColor.getRed(), + p.edgeColor.getGreen(), + p.edgeColor.getBlue(), + // keep current alpha; set below + 1.0 + )); + } + // opacity via material alpha + t.setOpacityAlpha(p.edgeOpacity); + + } else if (n instanceof Parent parent) { + for (Node c : parent.getChildrenUnmodifiable()) { + applyGraphStyleRecursive(c, p); + } + } + } + + + private static double clamp01(double v) { + return Math.max(0.0, Math.min(1.0, v)); + } + public void computeCosineDistance(FeatureCollection collection) { double[][] newRayRay = collection.convertFeaturesToArray(); - //@DEBUG SMP - //System.out.println("Computing Surface Differences... "); - //long startTime = System.nanoTime(); Metric metric = Metric.getMetric("cosine"); - List cosineDistancesGrid = new ArrayList<>(); for (int rowIndex = 0; rowIndex < dataGrid.size(); rowIndex++) { - double[] dataGridVector = dataGrid.get(rowIndex).stream() - .mapToDouble(Double::doubleValue).toArray(); + double[] dataGridVector = dataGrid.get(rowIndex).stream().mapToDouble(Double::doubleValue).toArray(); double currentDistance = metric.distance(dataGridVector, newRayRay[rowIndex]); cosineDistancesGrid.add(currentDistance); } - //Utils.printTotalTime(startTime); scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Feature Collection Cosine Similarity", - cosineDistancesGrid.toArray(Double[]::new))); - System.out.println(cosineDistancesGrid.toString()); + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Feature Collection Cosine Similarity", + cosineDistancesGrid.toArray(Double[]::new))); + LOG.info("{}", cosineDistancesGrid); + } + + private void applySurfaceGridToHypersurface(List> grid) { + double userScale = 1.0; // future: user control + List> scaled = DataUtils.normalizeAndScale(grid, heightMode, userScale); + dataGrid.clear(); + dataGrid.addAll(scaled); + originalGrid = deepCopyGrid(dataGrid); // NEW + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW: run pipeline } + + public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean flipY) { + List> grid = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); + if (flipY) Collections.reverse(grid); + dataGrid.clear(); + dataGrid.addAll(grid); + originalGrid = deepCopyGrid(dataGrid); // NEW + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW + } + public void computeSurfaceDifference(FeatureCollection collection) { double[][] newRayRay = collection.convertFeaturesToArray(); - //@DEBUG SMP - //System.out.println("Computing Surface Differences... "); - //long startTime = System.nanoTime(); List> differencesGrid = new ArrayList<>(); - for (int rowIndex = 0; rowIndex < dataGrid.size(); rowIndex++) { List differenceVector = new ArrayList<>(); List currentRow = dataGrid.get(rowIndex); int width = currentRow.size(); for (int colIndex = 0; colIndex < width; colIndex++) { if (rowIndex < newRayRay.length && colIndex < newRayRay[rowIndex].length) { - differenceVector.add( - currentRow.get(colIndex) - newRayRay[rowIndex][colIndex]); - } else { - differenceVector.add(0.0); - } + differenceVector.add(currentRow.get(colIndex) - newRayRay[rowIndex][colIndex]); + } else differenceVector.add(0.0); } differencesGrid.add(differenceVector); } - //Utils.printTotalTime(startTime); - dataGrid.clear(); dataGrid.addAll(differencesGrid); + originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); - updateTheMesh(); - updateView(true); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW } + public void computeVectorDistances() { - //@DEBUG SMP - //System.out.println("Computing Vector Distances... "); - //long startTime = System.nanoTime(); Metric metric = Metric.getMetric("cosine"); List> distancesGrid = new ArrayList<>(); - - //get all the values in this row dataGrid.stream().forEach(row -> { double[] rowVector = row.stream().mapToDouble(Double::doubleValue).toArray(); List distanceVector = new ArrayList<>(); for (int i = 0; i < dataGrid.size(); i++) { - double[] xVector = dataGrid.get(i).stream() - .mapToDouble(Double::doubleValue).toArray(); + double[] xVector = dataGrid.get(i).stream().mapToDouble(Double::doubleValue).toArray(); double currentDistance = metric.distance(xVector, rowVector); distanceVector.add(currentDistance); } distancesGrid.add(distanceVector); }); - //Utils.printTotalTime(startTime); - dataGrid.clear(); dataGrid.addAll(distancesGrid); + originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); - updateTheMesh(); - updateView(true); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW } public void unrollHyperspace() { - getScene().getRoot().fireEvent( - new CommandTerminalEvent("Requesting Hyperspace Vectors...", - new Font("Consolas", 20), Color.GREEN)); - getScene().getRoot().fireEvent( - new FeatureVectorEvent(FeatureVectorEvent.REQUEST_FEATURE_COLLECTION) - ); + getScene().getRoot().fireEvent(new CommandTerminalEvent("Requesting Hyperspace Vectors...", + new Font("Consolas", 20), Color.GREEN)); + getScene().getRoot().fireEvent(new FeatureVectorEvent(FeatureVectorEvent.REQUEST_FEATURE_COLLECTION)); } public void updateCalloutHeadPoint(Shape3D node, Callout callout, SubScene subScene) { @@ -912,9 +971,7 @@ public void updateCalloutHeadPoint(Shape3D node, Callout callout, SubScene subSc } public void updateCalloutHeadPoints(SubScene subScene) { - shape3DToCalloutMap.forEach((node, callout) -> { - updateCalloutHeadPoint(node, callout, subScene); - }); + shape3DToCalloutMap.forEach((node, callout) -> updateCalloutHeadPoint(node, callout, subScene)); } public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubScene subScene) { @@ -922,21 +979,17 @@ public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubSc iv.setPreserveRatio(true); iv.setFitWidth(CHIP_FIT_WIDTH); iv.setFitHeight(CHIP_FIT_WIDTH); - TitledPane imageTP = new TitledPane(); imageTP.setContent(iv); imageTP.setText("Imagery"); - Point2D p2D = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, Callout.DEFAULT_HEAD_RADIUS + 5); StringBuilder sb = new StringBuilder(); - for (Entry entry : featureVector.getMetaData().entrySet()) { + for (Map.Entry entry : featureVector.getMetaData().entrySet()) sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); - } Text metaText = new Text(sb.toString()); TitledPane metaTP = new TitledPane(); metaTP.setContent(metaText); metaTP.setText("Metadata"); - Callout infoCallout = CalloutBuilder.create() .headPoint(p2D.getX(), p2D.getY()) .leaderLineToPoint(p2D.getX() - 100, p2D.getY() - 150) @@ -949,18 +1002,14 @@ public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubSc infoCallout.setManaged(false); addCallout(infoCallout, shape3D); infoCallout.play().setOnFinished(eh -> { - if (null == featureVector.getImageURL() || featureVector.getImageURL().isBlank()) { - imageTP.setExpanded(false); - } + if (null == featureVector.getImageURL() || featureVector.getImageURL().isBlank()) imageTP.setExpanded(false); }); return infoCallout; } public void addCallout(Callout callout, Shape3D shape3D) { - //calloutList.add(callout); callout.setManaged(false); getChildren().add(callout); - //Anchor mapping for callout in 3D space shape3DToCalloutMap.put(shape3D, callout); } @@ -978,31 +1027,20 @@ public void updateTheMesh() { tube.colorByImage = colorationMethod == COLORATION.COLOR_BY_IMAGE; tube.updateMaterial(lastImage); } - Platform.runLater(() -> { - sceneRoot.getChildren().add(tube); - }); + Platform.runLater(() -> sceneRoot.getChildren().add(tube)); } - Platform.runLater(() -> { - updatePaintMesh(); - }); + Platform.runLater(this::updatePaintMesh); } public void updatePaintMesh() { - //in case the data grid dimensions have changed - //make the painting image the same dimension as the data grid for easy math - diffusePaintImage = new WritableImage( - Double.valueOf(xWidth).intValue(), - Double.valueOf(zWidth).intValue() - ); - + diffusePaintImage = new WritableImage((int) xWidth, (int) zWidth); if (null == paintTriangleMesh) { paintTriangleMesh = new TriangleMesh(); paintMeshView = new MeshView(paintTriangleMesh); paintMeshView.setMouseTransparent(true); paintMeshView.setMesh(paintTriangleMesh); paintMeshView.setCullFace(CullFace.NONE); - paintPhong = new PhongMaterial(Color.WHITE, - diffusePaintImage, null, null, null); + paintPhong = new PhongMaterial(Color.WHITE, diffusePaintImage, null, null, null); paintPhong.setSpecularColor(Color.WHITE); paintPhong.setDiffuseColor(Color.WHITE); paintMeshView.setMaterial(paintPhong); @@ -1017,13 +1055,11 @@ public void updatePaintMesh() { TriangleMesh surfMesh = (TriangleMesh) surfPlot.getMesh(); paintTriangleMesh.getPoints().setAll(surfMesh.getPoints()); - paintTriangleMesh.getFaces().clear(); paintTriangleMesh.getTexCoords().clear(); - final int texCoordSize = 2; - Float pSkip = 2.0f; - int pskip = pSkip.intValue(); + final int texCoordSize = 2; + int pskip = 2; int subDivX = (int) diffusePaintImage.getWidth() / pskip; int subDivZ = (int) diffusePaintImage.getHeight() / pskip; int numDivX = subDivX + 1; @@ -1031,11 +1067,10 @@ public void updatePaintMesh() { float currZ, currX; float texCoords[] = new float[numVerts * texCoordSize]; int faceCount = subDivX * subDivZ * 2; - final int faceSize = 6; //should always be 6 for a triangle mesh + final int faceSize = 6; int faces[] = new int[faceCount * faceSize]; int index, p00, p01, p10, p11, tc00, tc01, tc10, tc11; - //Map the 2D data grid to UV coordinates and paint a single color to test for (int z = 0; z < subDivZ; z++) { currZ = (float) z / subDivZ; for (int x = 0; x < subDivX; x++) { @@ -1044,7 +1079,6 @@ public void updatePaintMesh() { texCoords[index] = currX; texCoords[index + 1] = currZ; - // Create faces p00 = z * numDivX + x; p01 = p00 + 1; p10 = p00 + numDivX; @@ -1061,7 +1095,6 @@ public void updatePaintMesh() { faces[index + 3] = tc10; faces[index + 4] = p11; faces[index + 5] = tc11; - index += faceSize; faces[index + 0] = p11; faces[index + 1] = tc11; @@ -1074,15 +1107,13 @@ public void updatePaintMesh() { } paintTriangleMesh.getTexCoords().setAll(texCoords); paintTriangleMesh.getFaces().setAll(faces); - //force update of the material (is this actually necessary?) paintPhong.setDiffuseMap(diffusePaintImage); - paintMeshView.setTranslateZ(-1); //slight offset "above" to avoid z fighting + paintMeshView.setTranslateZ(-1); paintMeshView.setTranslateX(-(xWidth * surfScale) / 2.0); paintMeshView.setTranslateZ(-(zWidth * surfScale) / 2.0); } public void paintSingleColor(Color color) { - //Map the 2D data grid to UV coordinates and paint a single color to test for (int z = 0; z < diffusePaintImage.getHeight(); z++) { for (int x = 0; x < diffusePaintImage.getWidth(); x++) { diffusePaintImage.getPixelWriter().setColor(x, z, color); @@ -1091,51 +1122,32 @@ public void paintSingleColor(Color color) { } public void illuminateCrosshair(Point3D center) { - if (null == diffusePaintImage) - return; + if (null == diffusePaintImage) return; int x = (int) (center.getX() / surfScale); - int z = (int) (center.getZ() / surfScale); //Image Y is projected into Z - + int z = (int) (center.getZ() / surfScale); PixelWriter pw = diffusePaintImage.getPixelWriter(); - for (int i = 0; i < diffusePaintImage.getWidth(); i++) - pw.setColor(i, z, Color.WHITE); - for (int i = 0; i < diffusePaintImage.getHeight(); i++) - pw.setColor(x, i, Color.WHITE); + for (int i = 0; i < diffusePaintImage.getWidth(); i++) pw.setColor(i, z, Color.WHITE); + for (int i = 0; i < diffusePaintImage.getHeight(); i++) pw.setColor(x, i, Color.WHITE); } private void setupSkyBox() { - //Load SkyBox image - Image - top = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()), - bottom = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()), - left = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()), - right = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()), - front = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()), - back = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); - - // Load Skybox AFTER camera is initialized + Image top = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()); + Image bottom = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()); + Image left = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + Image right = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + Image front = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + Image back = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); double size = 100000D; - skybox = new Skybox( - top, - bottom, - left, - right, - front, - back, - size, - camera - ); + skybox = new Skybox(top, bottom, left, right, front, back, size, camera); sceneRoot.getChildren().add(skybox); - //Add some ambient light so folks can see it ambientLight.getScope().addAll(skybox); skybox.setVisible(false); } private void notifyIndexChange() { - getScene().getRoot().fireEvent( - new CommandTerminalEvent("X,Y,Z Indices = (" - + xFactorIndex + ", " + yFactorIndex + ", " + zFactorIndex + ")", - new Font("Consolas", 20), Color.GREEN)); + getScene().getRoot().fireEvent(new CommandTerminalEvent("X,Y,Z Indices = (" + + xFactorIndex + ", " + yFactorIndex + ", " + zFactorIndex + ")", + new Font("Consolas", 20), Color.GREEN)); } public void resetView(double milliseconds, boolean rightNow) { @@ -1158,9 +1170,7 @@ public void outtro(double milliseconds) { } public void updateAll() { - Platform.runLater(() -> { - updateView(true); - }); + Platform.runLater(() -> updateView(true)); } private void mouseDragCamera(MouseEvent me) { @@ -1171,53 +1181,33 @@ private void mouseDragCamera(MouseEvent me) { mouseDeltaX = (mousePosX - mouseOldX); mouseDeltaY = (mousePosY - mouseOldY); double modifier = 1.0; - double modifierFactor = 0.1; //@TODO SMP connect to sensitivity property - - if (me.isControlDown()) { - modifier = 0.1; - } - if (me.isShiftDown()) { - modifier = 25.0; - } + double modifierFactor = 0.1; + if (me.isControlDown()) modifier = 0.1; + if (me.isShiftDown()) modifier = 25.0; if (me.isPrimaryButtonDown()) { - if (me.isAltDown()) { //roll - cameraTransform.rz.setAngle(((cameraTransform.rz.getAngle() + mouseDeltaX * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); // + - } else { - cameraTransform.ry.setAngle(((cameraTransform.ry.getAngle() + mouseDeltaX * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); // + - cameraTransform.rx.setAngle( - ((cameraTransform.rx.getAngle() - mouseDeltaY * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); + if (me.isAltDown()) + cameraTransform.rz.setAngle(((cameraTransform.rz.getAngle() + mouseDeltaX * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); + else { + cameraTransform.ry.setAngle(((cameraTransform.ry.getAngle() + mouseDeltaX * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); + cameraTransform.rx.setAngle(((cameraTransform.rx.getAngle() - mouseDeltaY * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); } } else if (me.isMiddleButtonDown()) { - cameraTransform.t.setX(cameraTransform.t.getX() + mouseDeltaX * modifierFactor * modifier * 0.3); // - - cameraTransform.t.setY(cameraTransform.t.getY() + mouseDeltaY * modifierFactor * modifier * 0.3); // - + cameraTransform.t.setX(cameraTransform.t.getX() + mouseDeltaX * modifierFactor * modifier * 0.3); + cameraTransform.t.setY(cameraTransform.t.getY() + mouseDeltaY * modifierFactor * modifier * 0.3); } updateLabels(); - //update surface callouts updateCalloutHeadPoints(subScene); } private void updateLabels() { shape3DToLabel.forEach((shape3D, node) -> { Point2D p2Ditty = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, 5); - //@DEBUG SMP useful debugging print - //System.out.println("subSceneToScene Coordinates: " + coordinates.toString()); double x = p2Ditty.getX(); - double y = p2Ditty.getY() - 25; //simple offset to keep labels above nodes - //update the local transform of the label. + double y = p2Ditty.getY() - 25; node.getTransforms().setAll(new Translate(x, y)); }); } - private WritableImage loadImage(String fileSource) throws IOException { - WritableImage image = null; - try { - image = ResourceUtils.loadImageFile(imageryBasePath + fileSource); - } catch (IOException ex) { - image = ResourceUtils.loadIconAsWritableImage("noimage"); - } - return image; - } - private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { ImageView iv = null; try { @@ -1226,13 +1216,11 @@ private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { featureVector.getBbox().get(0).intValue(), featureVector.getBbox().get(1).intValue(), featureVector.getBbox().get(2).intValue(), - featureVector.getBbox().get(3).intValue() - ); + featureVector.getBbox().get(3).intValue()); iv = new ImageView(image); } else if (null != featureVector.getImageURL() && !featureVector.getImageURL().isBlank()) { iv = new ImageView(ResourceUtils.loadImageFile(imageryBasePath + featureVector.getImageURL())); - } else - iv = new ImageView(ResourceUtils.loadIconFile("noimage")); + } else iv = new ImageView(ResourceUtils.loadIconFile("noimage")); } catch (Exception ex) { iv = new ImageView(ResourceUtils.loadIconFile("noimage")); } @@ -1242,87 +1230,69 @@ private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { public void updateView(boolean forcePNodeUpdate) { if (null != surfPlot) { Platform.runLater(() -> { - if (heightChanged) { //if it hasn't changed, don't call expensive height change + if (heightChanged) { heightChanged = false; } - //@DEBUG SMP Rendering timing print - //System.out.println("UpdateView setScatterDataAndEndPoints time: " - // + Utils.totalTimeString(startTime2)); - //Since we changed the mesh unfortunately we have to reset the color mode - //otherwise the triangles won't have color. - //5ms for 20k points isDirty = false; }); } } private void generateRandos(int xWidth, int zWidth, float yScale) { - if (null == dataGrid) { - dataGrid = new ArrayList<>(zWidth); - } else - dataGrid.clear(); + if (null == dataGrid) dataGrid = new ArrayList<>(zWidth); + else dataGrid.clear(); List xList; for (int z = 0; z < zWidth; z++) { xList = new ArrayList<>(xWidth); - for (int x = 0; x < xWidth; x++) { - xList.add(rando.nextDouble() * yScale); - } + for (int x = 0; x < xWidth; x++) xList.add(rando.nextDouble() * yScale); dataGrid.add(xList); } } - private Number lookupShapley(Point3D p) { - if (null != shapleyVectors && null != dataGrid) { -//@SMP This approach won't work because it is dependent on translation and scaling -// //number of rows multiplied by width of rows (stride length) -// //plus column index -// return shapleyVectors.get( -// Float.valueOf( -// ((p.z/surfScale * dataGrid.size()) * dataGrid.get(0).size()) + p.x/surfScale -// ).intValue()).getData().get(0); - } - return 0.0; + private static double frac(double v) { + v = v - Math.floor(v); + return (v < 0) ? v + 1.0 : v; } private Number vertToHeight(Vert3D p) { - if (null != dataGrid) { - if (surfaceRender) + if (dataGrid == null) return 0.0; + + if (!surfaceRender) { + // cylinder path unchanged + return findBlerpHeight(p); + } + switch (interpMode) { + case BILINEAR: + case BICUBIC: { + // Convert to grid space: index + in-cell fraction. + // If p.getX()/getY() are already grid-space, this still works. + // If they are world-space, the /surfScale fixes it. + double gx = p.xIndex + frac(p.getX() / Math.max(1.0, (double) surfScale)); + double gy = p.yIndex + frac(p.getY() / Math.max(1.0, (double) surfScale)); + return SurfaceUtils.sample(dataGrid, gx, gy, interpMode); + } + case NEAREST: + default: return lookupPoint(p); - else - return findBlerpHeight(p); - } else - return 0.0; + } } private Number lookupPoint(Vert3D p) { - //hacky bounds check - if (p.yIndex >= dataGrid.size() - || p.xIndex >= dataGrid.get(0).size()) - return 0.0; + if (p.yIndex >= dataGrid.size() || p.xIndex >= dataGrid.get(0).size()) return 0.0; return dataGrid.get(p.yIndex).get(p.xIndex); } private Number findBlerpHeight(Vert3D p) { int x1Index = p.xIndex <= 0 ? 0 : p.xIndex - 1; - if (x1Index >= dataGrid.get(0).size() - 1) - x1Index = dataGrid.get(0).size() - 1; - - int x2Index = p.xIndex >= dataGrid.get(0).size() - 1 - ? dataGrid.get(0).size() - 1 : p.xIndex + 1; - + if (x1Index >= dataGrid.get(0).size() - 1) x1Index = dataGrid.get(0).size() - 1; + int x2Index = p.xIndex >= dataGrid.get(0).size() - 1 ? dataGrid.get(0).size() - 1 : p.xIndex + 1; int y1Index = p.yIndex <= 0 ? 0 : p.yIndex - 1; - if (y1Index >= dataGrid.size() - 1) - y1Index = dataGrid.size() - 1; - int y2Index = p.yIndex >= dataGrid.size() - 1 - ? dataGrid.size() - 1 : p.yIndex + 1; - //System.out.println("x1,x2,y1,y2:" + x1Index + ", " + x2Index + ", " + y1Index + ", " + y2Index); - + if (y1Index >= dataGrid.size() - 1) y1Index = dataGrid.size() - 1; + int y2Index = p.yIndex >= dataGrid.size() - 1 ? dataGrid.size() - 1 : p.yIndex + 1; double c11 = dataGrid.get(y1Index).get(x1Index) * yScale; double c21 = dataGrid.get(y1Index).get(x2Index) * yScale; double c12 = dataGrid.get(y2Index).get(x1Index) * yScale; double c22 = dataGrid.get(y2Index).get(x2Index) * yScale; - //System.out.println("x1,x2,y1,y2:" + x1Index + ", " + x2Index + ", " + y1Index + ", " + y2Index); - return quickBlerp(c11, c21, c12, c22, p.getX(), p.getY()); } @@ -1340,8 +1310,8 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, private void loadSurf3D() { LOG.info("Rendering Hypersurface Mesh..."); generateRandos(xWidth, zWidth, yScale); - surfPlot = new HyperSurfacePlotMesh(xWidth, zWidth, - 1, 1, yScale, surfScale, vert3DLookup); + originalGrid = deepCopyGrid(dataGrid); // NEW + surfPlot = new HyperSurfacePlotMesh(xWidth, zWidth, 1, 1, yScale, surfScale, vert3DLookup); surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); surfPlot.setDrawMode(DrawMode.LINE); sceneRoot.getChildren().add(surfPlot); @@ -1360,27 +1330,20 @@ private void loadSurf3D() { int row = Float.valueOf(vertP3D.getZ() / surfScale).intValue(); int column = Float.valueOf(vertP3D.getX() / surfScale).intValue(); if (null != anchorCallout) { - if (row < featureVectors.size()) - updateCalloutByFeatureVector(anchorCallout, featureVectors.get(row)); + if (row < featureVectors.size()) updateCalloutByFeatureVector(anchorCallout, featureVectors.get(row)); setSpheroidAnchor(false, row); } - if (crosshairsEnabled) { paintSingleColor(Color.TRANSPARENT); illuminateCrosshair(vertP3D); } - if (surfaceChartsEnabled) { - //get all the values in this row - List xlist = dataGrid.get(row); + List xlist = dataGrid.get(Math.max(0, Math.min(row, dataGrid.size() - 1))); Double[] xRay = xlist.toArray(Double[]::new); - //get the column values in time. Double[] zRay = new Double[dataGrid.size()]; - for (int i = 0; i < dataGrid.size(); i++) { - zRay[i] = dataGrid.get(i).get(column); - } + for (int i = 0; i < dataGrid.size(); i++) zRay[i] = dataGrid.get(i).get(Math.max(0, Math.min(column, dataGrid.get(0).size() - 1))); String text = "Coordinates: " + column + ", " + row + System.lineSeparator(); - text = text.concat("Value: ").concat(String.valueOf(dataGrid.get(row).get(column))).concat(System.lineSeparator()); + text = text.concat("Value: ").concat(String.valueOf(dataGrid.get(Math.max(0, Math.min(row, dataGrid.size() - 1))).get(Math.max(0, Math.min(column, dataGrid.get(0).size() - 1))))).concat(System.lineSeparator()); double maxX = xlist.stream().max(Double::compare).get(); text = text.concat("Max X: ").concat(String.valueOf(maxX)).concat(System.lineSeparator()); double minX = xlist.stream().min(Double::compare).get(); @@ -1389,19 +1352,13 @@ private void loadSurf3D() { text = text.concat("Max Z: ").concat(String.valueOf(maxZ)).concat(System.lineSeparator()); double minZ = Arrays.stream(zRay).min(Double::compare).get(); text = text.concat("Min Z: ").concat(String.valueOf(minZ)).concat(System.lineSeparator()); - hoverText.setText(text); hoverText.setStrokeWidth(1); hoverText.setLayoutX(50); hoverText.setLayoutY(50); - - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, xRay)); - - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.SURFACE_ZFACTOR_VECTOR, zRay)); + scene.getRoot().fireEvent(new FactorAnalysisEvent(FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, xRay)); + scene.getRoot().fireEvent(new FactorAnalysisEvent(FactorAnalysisEvent.SURFACE_ZFACTOR_VECTOR, zRay)); } - e.consume(); } }); @@ -1436,28 +1393,21 @@ private void loadSurf3D() { eastKnob.translateZProperty().bind(glowLineBox.translateZProperty()); westKnob.translateZProperty().bind(glowLineBox.translateZProperty()); - //customize the labels to match eastLabel = new Label("Data Index"); eastLabel.setTextFill(Color.ALICEBLUE); eastLabel.setFont(new Font("calibri", 20)); westLabel = new Label("Data Index"); westLabel.setTextFill(Color.ALICEBLUE); westLabel.setFont(new Font("calibri", 20)); - //add our labels to the group that will be added to the StackPane labelGroup.getChildren().addAll(eastLabel, westLabel); - //Add to hashmap so updateLabels() can manage the label position shape3DToLabel.put(eastKnob, eastLabel); shape3DToLabel.put(westKnob, westLabel); scene.addEventHandler(TimelineEvent.TIMELINE_SAMPLE_INDEX, e -> { anchorIndex = (int) e.object; - if (anchorIndex < 0) - anchorIndex = 0; - else if (anchorIndex > dataGrid.size()) - anchorIndex = dataGrid.size(); - //move the glowLineBox based on the step index + if (anchorIndex < 0) anchorIndex = 0; + else if (anchorIndex > dataGrid.size()) anchorIndex = dataGrid.size(); glowLineBox.setTranslateZ((anchorIndex * surfScale) - ((zWidth * surfScale) / 2.0)); - //@TODO SMP this is stubbed but we should update an anchored callout setSpheroidAnchor(true, anchorIndex); eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + xWidth); westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); @@ -1467,264 +1417,253 @@ else if (anchorIndex > dataGrid.size()) scene.addEventHandler(FeatureVectorEvent.SELECT_FEATURE_VECTOR, e -> { if (null != anchorCallout) { FeatureVector fv = (FeatureVector) e.object; - //try to update the callout anchored to the lead state updateCalloutByFeatureVector(anchorCallout, fv); } }); extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); + wireEventHandlers(); + + pointLight.getScope().addAll(surfPlot, graphLayer); + sceneRoot.getChildren().add(pointLight); + pointLight.translateXProperty().bind(camera.translateXProperty()); + pointLight.translateYProperty().bind(camera.translateYProperty()); + pointLight.translateZProperty().bind(camera.translateZProperty().add(500)); + ambientLight.getScope().addAll(surfPlot, graphLayer); + sceneRoot.getChildren().add(ambientLight); + + updateLabels(); + subScene.sceneProperty().addListener(c -> { + Platform.runLater(() -> { + FeatureVector dummy = FeatureVector.EMPTY_FEATURE_VECTOR("", 3); + anchorCallout = createCallout(highlightedPoint, dummy, subScene); + anchorCallout.play().setOnFinished(fin -> anchorCallout.setVisible(false)); + }); + }); + } + + /** + * Fires HypersurfaceEvent GUI sync events for all core geometry controls + * (xWidth, zWidth, yScale, surfScale) to synchronize GUI controls with model state. + */ + public void syncGuiControls() { + // Fire events to update GUI controls in the controls pane + // These will be handled by HypersurfaceControlsPane to update Spinner values. + fireOnRoot(HypersurfaceEvent.setXWidthGUI(xWidth)); + fireOnRoot(HypersurfaceEvent.setZWidthGUI(zWidth)); + fireOnRoot(HypersurfaceEvent.setYScaleGUI(yScale)); + fireOnRoot(HypersurfaceEvent.setSurfScaleGUI(surfScale)); + } + + /** + * Helper to fire on the JavaFX root, or self as fallback (copy this if not already present) + */ + private void fireOnRoot(Event evt) { + if (scene != null && scene.getRoot() != null) { + scene.getRoot().fireEvent(evt); + } else { + this.fireEvent(evt); + } + } - Spinner yScaleSpinner = new Spinner( - new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 500.0, yScale, 1.00)); - yScaleSpinner.setEditable(true); - //whenever the spinner value is changed... - yScaleSpinner.getValueFactory().valueProperty().addListener(e -> { - yScale = ((Double) yScaleSpinner.getValue()).floatValue(); - surfPlot.setFunctionScale(yScale); + /** + * Sets up event handlers for HypersurfaceEvents sent from HypersurfaceControlsPane. + * Updates all rendering state and triggers updates as needed. + */ + private void wireEventHandlers() { + if (scene == null) return; + // Geometry / scale + scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { + this.xWidth = (int) e.object; + if (surfPlot != null) { + surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); + surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); + } updateTheMesh(); }); - //whenever the spinner value is changed... - yScaleSpinner.setOnKeyTyped(e -> { - if (e.getCode() == KeyCode.ENTER) { - yScale = ((Double) yScaleSpinner.getValue()).floatValue(); - surfPlot.setFunctionScale(yScale); - updateTheMesh(); + + scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { + this.zWidth = (int) e.object; + if (surfPlot != null) { + surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); + surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } + updateTheMesh(); }); - yScaleSpinner.setPrefWidth(125); - Spinner surfScaleSpinner = new Spinner( - new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 100.0, surfScale, 1.0)); - surfScaleSpinner.setEditable(true); - //whenever the spinner value is changed... - surfScaleSpinner.valueProperty().addListener(e -> { - surfScale = ((Double) surfScaleSpinner.getValue()).floatValue(); - surfPlot.setRangeX(xWidth * surfScale); - surfPlot.setRangeY(zWidth * surfScale); + scene.addEventHandler(HypersurfaceEvent.Y_SCALE_CHANGED, e -> { + this.yScale = ((Double) e.object).floatValue(); + if (surfPlot != null) surfPlot.setFunctionScale(yScale); updateTheMesh(); - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); - surfScaleSpinner.setPrefWidth(125); -// Spinner divisionsSpinner = new Spinner( -// new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 512, 64, 4)); -// divisionsSpinner.setEditable(true); -// //whenever the spinner value is changed... -// divisionsSpinner.valueProperty().addListener(e -> { -// surfPlot.setDivisionsX((int) divisionsSpinner.getValue()); -// surfPlot.setDivisionsY((int) divisionsSpinner.getValue()); -// updateTheMesh(); -// }); -// divisionsSpinner.setPrefWidth(125); - - xWidthSpinner = new Spinner( - new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, 200, 4)); - xWidthSpinner.setEditable(true); - //whenever the spinner value is changed... - xWidthSpinner.valueProperty().addListener(e -> { - xWidth = ((int) xWidthSpinner.getValue()); + + scene.addEventHandler(HypersurfaceEvent.SURF_SCALE_CHANGED, e -> { + this.surfScale = ((Double) e.object).floatValue(); + if (surfPlot != null) { + surfPlot.setRangeX(xWidth * surfScale); + surfPlot.setRangeY(zWidth * surfScale); + surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); + surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); + } updateTheMesh(); - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); - xWidthSpinner.setPrefWidth(125); - zWidthSpinner = new Spinner( - new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, 200, 10)); - zWidthSpinner.setEditable(true); - //whenever the spinner value is changed... - zWidthSpinner.valueProperty().addListener(e -> { - zWidth = ((int) zWidthSpinner.getValue()); + + // Rendering modes + scene.addEventHandler(HypersurfaceEvent.SURFACE_RENDER_CHANGED, e -> { + this.surfaceRender = (boolean) e.object; updateTheMesh(); - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); - zWidthSpinner.setPrefWidth(125); - - ToggleGroup colorationToggle = new ToggleGroup(); - RadioButton colorByImageRadioButton = new RadioButton("Color by Image"); - colorByImageRadioButton.setToggleGroup(colorationToggle); - - RadioButton colorByFeatureValueRadioButton = new RadioButton("Color by Feature Value"); - colorByFeatureValueRadioButton.setSelected(true); - colorByFeatureValueRadioButton.setToggleGroup(colorationToggle); - - RadioButton colorByShapleyValueRadioButton = new RadioButton("Color by Shapley Value"); - colorByShapleyValueRadioButton.setSelected(false); - colorByShapleyValueRadioButton.setToggleGroup(colorationToggle); - - colorationToggle.selectedToggleProperty().addListener(cl -> { - if (colorByImageRadioButton.isSelected()) { - colorationMethod = COLORATION.COLOR_BY_IMAGE; - File imageFile = new File(imageryBasePath + lastImageSource); - surfPlot.setTextureModeImage(imageFile.toURI().toString()); - } else if (colorByFeatureValueRadioButton.isSelected()) { - colorationMethod = COLORATION.COLOR_BY_FEATURE; - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); - } else { - colorationMethod = COLORATION.COLOR_BY_SHAPLEY; - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); - } + scene.addEventHandler(HypersurfaceEvent.DRAW_MODE_CHANGED, e -> { + if (surfPlot != null) surfPlot.setDrawMode((DrawMode) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.CULL_FACE_CHANGED, e -> { + if (surfPlot != null) surfPlot.setCullFace((CullFace) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.COLORATION_CHANGED, e -> { + this.colorationMethod = (Hypersurface3DPane.COLORATION) e.object; updateTheMesh(); }); - HBox colorationHBox = new HBox(10, colorByImageRadioButton, - colorByFeatureValueRadioButton, colorByShapleyValueRadioButton); - - ToggleGroup meshTypeToggle = new ToggleGroup(); - RadioButton surfaceRadioButton = new RadioButton("Surface Projection"); - surfaceRadioButton.setSelected(true); - surfaceRadioButton.setToggleGroup(meshTypeToggle); - RadioButton cylinderRadioButton = new RadioButton("Cylindrical"); - cylinderRadioButton.setToggleGroup(meshTypeToggle); - meshTypeToggle.selectedToggleProperty().addListener(cl -> { - surfaceRender = surfaceRadioButton.isSelected(); + + // Processing pipeline + scene.addEventHandler(HypersurfaceEvent.HEIGHT_MODE_CHANGED, e -> { + this.heightMode = (HeightMode) e.object; + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.SMOOTHING_ENABLE_CHANGED, e -> { + this.smoothingEnabled = (boolean) e.object; + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.SMOOTHING_METHOD_CHANGED, e -> { + this.smoothingMethod = (SurfaceUtils.Smoothing) e.object; + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.SMOOTHING_RADIUS_CHANGED, e -> { + this.smoothingRadius = (int) e.object; + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.GAUSSIAN_SIGMA_CHANGED, e -> { + this.gaussianSigma = (double) e.object; + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.INTERP_MODE_CHANGED, e -> { + this.interpMode = (SurfaceUtils.Interpolation) e.object; updateTheMesh(); }); - HBox meshTypeHBox = new HBox(10, surfaceRadioButton, cylinderRadioButton); - - ToggleGroup drawModeToggle = new ToggleGroup(); - RadioButton drawModeLine = new RadioButton("Line"); - drawModeLine.setSelected(true); - drawModeLine.setToggleGroup(drawModeToggle); - RadioButton drawModeFill = new RadioButton("Fill"); - drawModeFill.setToggleGroup(drawModeToggle); - drawModeToggle.selectedToggleProperty().addListener(cl -> { - if (drawModeLine.isSelected()) { - surfPlot.setDrawMode(DrawMode.LINE); - } else { - surfPlot.setDrawMode(DrawMode.FILL); - } + scene.addEventHandler(HypersurfaceEvent.TONEMAP_ENABLE_CHANGED, e -> { + this.toneEnabled = (boolean) e.object; + rebuildProcessedGridAndRefresh(); }); - HBox drawModeHBox = new HBox(10, drawModeLine, drawModeFill); - - ToggleGroup cullFaceToggle = new ToggleGroup(); - RadioButton cullFaceFront = new RadioButton("Front"); - cullFaceFront.setToggleGroup(cullFaceToggle); - RadioButton cullFaceBack = new RadioButton("Back"); - cullFaceBack.setToggleGroup(cullFaceToggle); - RadioButton cullFaceNone = new RadioButton("None"); - cullFaceNone.setSelected(true); - cullFaceNone.setToggleGroup(cullFaceToggle); - cullFaceToggle.selectedToggleProperty().addListener(cl -> { - if (cullFaceFront.isSelected()) { - surfPlot.setCullFace(CullFace.FRONT); - } else if (cullFaceBack.isSelected()) { - surfPlot.setCullFace(CullFace.BACK); - } else { - surfPlot.setCullFace(CullFace.NONE); - } + scene.addEventHandler(HypersurfaceEvent.TONEMAP_OPERATOR_CHANGED, e -> { + this.toneOperator = (SurfaceUtils.ToneMap) e.object; + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.TONEMAP_PARAM_CHANGED, e -> { + this.toneParam = (double) e.object; + rebuildProcessedGridAndRefresh(); }); - HBox cullFaceHBox = new HBox(10, cullFaceFront, cullFaceBack, cullFaceNone); - - //add a Point Light for better viewing of the grid coordinate system - pointLight.getScope().addAll(surfPlot); - sceneRoot.getChildren().add(pointLight); - pointLight.translateXProperty().bind(camera.translateXProperty()); - pointLight.translateYProperty().bind(camera.translateYProperty()); - pointLight.translateZProperty().bind(camera.translateZProperty().add(500)); - - ambientLight.getScope().addAll(surfPlot); - sceneRoot.getChildren().add(ambientLight); - ColorPicker lightPicker = new ColorPicker(Color.WHITE); - ambientLight.colorProperty().bind(lightPicker.valueProperty()); + // Lighting + scene.addEventHandler(HypersurfaceEvent.AMBIENT_ENABLED_CHANGED, e -> { + // (Optional: enable/disable ambientLight as desired) + }); + scene.addEventHandler(HypersurfaceEvent.AMBIENT_COLOR_CHANGED, e -> { + if (ambientLight != null) ambientLight.setColor((Color) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.POINT_ENABLED_CHANGED, e -> { + // (Optional: enable/disable pointLight as desired) + }); + scene.addEventHandler(HypersurfaceEvent.SPECULAR_COLOR_CHANGED, e -> { + if (surfPlot != null && surfPlot.getMaterial() instanceof PhongMaterial mat) + mat.setSpecularColor((Color) e.object); + }); - ColorPicker specPicker = new ColorPicker(Color.CYAN); - specPicker.setOnAction(e -> { - ((PhongMaterial) surfPlot.getMaterial()).setSpecularColor(specPicker.getValue()); + // UX toggles + scene.addEventHandler(HypersurfaceEvent.HOVER_ENABLE_CHANGED, e -> hoverInteractionsEnabled = (boolean) e.object); + scene.addEventHandler(HypersurfaceEvent.SURFACE_CHARTS_ENABLE_CHANGED, e -> surfaceChartsEnabled = (boolean) e.object); + scene.addEventHandler(HypersurfaceEvent.DATA_MARKERS_ENABLE_CHANGED, e -> extrasGroup.setVisible((boolean) e.object)); + scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> crosshairsEnabled = (boolean) e.object); + + // Commands/actions + scene.addEventHandler(HypersurfaceEvent.RESET_VIEW, e -> resetView(1000, false)); + scene.addEventHandler(HypersurfaceEvent.UPDATE_RENDER, e -> updateTheMesh()); + scene.addEventHandler(HypersurfaceEvent.CLEAR_DATA, e -> clearAll()); + scene.addEventHandler(HypersurfaceEvent.UNROLL_REQUESTED, e -> unrollHyperspace()); + scene.addEventHandler(HypersurfaceEvent.COMPUTE_VECTOR_DISTANCES, e -> computeVectorDistances()); + scene.addEventHandler(HypersurfaceEvent.COMPUTE_COLLECTION_DIFF, e -> computeSurfaceDifference((FeatureCollection) e.object)); + scene.addEventHandler(HypersurfaceEvent.COMPUTE_COSINE_DISTANCE, e -> computeCosineDistance((FeatureCollection) e.object)); + // Hover a node: emit its similarity row (from graph) and optionally highlight a surface row + scene.addEventHandler(GraphEvent.GRAPH_NODE_HOVER, e -> { + if (!(e.object instanceof GraphNode gNode)) return; + + // Build a row vector from the sparse graph + Double[] row = buildSimilarityRowFromGraph(gNode); + + // Publish for analysis panels/plots (reuses your existing pattern) + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Similarity Row (hover): " + String.valueOf(gNode), + row + )); + + // Optional: try to relate back to hypersurface row + highlightSurfaceRowIfPossible(gNode); }); - CheckBox enableAmbient = new CheckBox("Enable Ambient Light"); - enableAmbient.setSelected(true); - enableAmbient.setOnAction(e -> { - if (enableAmbient.isSelected()) { - lightPicker.setDisable(false); - ambientLight.getScope().addAll(surfPlot); - } else { - lightPicker.setDisable(true); - ambientLight.getScope().clear(); - } + // Click a node: same as hover but labeled and could be made "sticky" + scene.addEventHandler(GraphEvent.GRAPH_NODE_CLICK, e -> { + if (!(e.object instanceof GraphNode gNode)) return; + Double[] row = buildSimilarityRowFromGraph(gNode); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Similarity Row (click): " + String.valueOf(gNode), + row + )); + highlightSurfaceRowIfPossible(gNode); }); - CheckBox enablePoint = new CheckBox("Enable Point Light"); - enablePoint.setSelected(true); - enablePoint.setOnAction(e -> { - if (enablePoint.isSelected()) { - specPicker.setDisable(false); - pointLight.getScope().addAll(surfPlot); - } else { - specPicker.setDisable(true); - pointLight.getScope().clear(); - } + // Hover an edge: emit its endpoints + weight + scene.addEventHandler(GraphEvent.GRAPH_EDGE_HOVER, e -> { + if (!(e.object instanceof GraphEdge ge)) return; + + Optional a = currentGraph != null ? currentGraph.findNodeById(ge.getStartID()) : Optional.empty(); + Optional b = currentGraph != null ? currentGraph.findNodeById(ge.getEndID()) : Optional.empty(); + double w = getEdgeWeightSafe(ge); + + scene.getRoot().fireEvent(new CommandTerminalEvent( + "Edge hover: " + a.map(Object::toString).orElse("?") + " → " + + b.map(Object::toString).orElse("?") + " | weight = " + w, + new Font("Consolas", 16), Color.ALICEBLUE + )); }); - ToggleButton startRandos = new ToggleButton("startRandos"); - startRandos.setOnAction(e -> computeRandos = startRandos.isSelected()); - ToggleButton animate = new ToggleButton("animated"); - animate.setOnAction(e -> animated = animate.isSelected()); - - Label divLabel = new Label("Divisions"); - divLabel.setPrefWidth(125); - Label xWidthLabel = new Label("Usable X Width"); - xWidthLabel.setPrefWidth(125); - Label zWidthLabel = new Label("Usable Z Length"); - zWidthLabel.setPrefWidth(125); - Label yScaleLabel = new Label("Y Scale"); - yScaleLabel.setPrefWidth(125); - Label surfScaleLabel = new Label("Surface Range Scale"); - surfScaleLabel.setPrefWidth(125); - - VBox vbox = new VBox(10, - //new HBox(10, startRandos, animate), - //new HBox(10, divLabel, divisionsSpinner), - new HBox(10, xWidthLabel, xWidthSpinner), - new HBox(10, zWidthLabel, zWidthSpinner), - new HBox(10, yScaleLabel, yScaleSpinner), - new HBox(10, surfScaleLabel, surfScaleSpinner), - new Label("Color Method"), - colorationHBox, - new Label("Draw Mode"), - meshTypeHBox, - drawModeHBox, - new Label("Cull Face"), - cullFaceHBox, - new Label("Ambient Light Color"), - enableAmbient, - lightPicker, -// new Label("Diffuse Color"), -// diffusePicker, - new Label("Specular Color"), - enablePoint, - specPicker - ); - StackPane.setAlignment(vbox, Pos.BOTTOM_LEFT); - vbox.setPickOnBounds(false); - getChildren().add(vbox); - updateLabels(); - subScene.sceneProperty().addListener(c -> { - //create callout automatically puts the callout and node into a managed map - Platform.runLater(() -> { - FeatureVector dummy = FeatureVector.EMPTY_FEATURE_VECTOR("", 3); - anchorCallout = createCallout(highlightedPoint, dummy, subScene); - anchorCallout.play().setOnFinished(fin -> anchorCallout.setVisible(false)); - }); + // Click an edge: emit a tiny 2-entry vector [w] or a pairwise slice if you prefer + scene.addEventHandler(GraphEvent.GRAPH_EDGE_CLICK, e -> { + if (!(e.object instanceof GraphEdge ge)) return; + double w = getEdgeWeightSafe(ge); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Edge Weight (click): " + ge.getStartID() + " → " + ge.getEndID(), + new Double[]{w} + )); + }); + scene.addEventHandler(GraphEvent.GRAPH_VISIBILITY_CHANGED, e -> { + if (!(e.object instanceof Boolean b)) return; + graphVisible = b; + graphLayer.setVisible(graphVisible); }); } public void updateCalloutByFeatureVector(Callout callout, FeatureVector featureVector) { - //UPdate label callout.setMainTitleText(featureVector.getLabel()); callout.mainTitleTextNode.setText(callout.getMainTitleText()); - //update image (incoming hypersonic hack) VBox vbox = (VBox) callout.mainTitleNode; TitledPane tp0 = (TitledPane) vbox.getChildren().get(0); ImageView iv = loadImageView(featureVector, featureVector.isBBoxValid()); Image image = iv.getImage(); ((ImageView) tp0.getContent()).setImage(image); - //update metadata StringBuilder sb = new StringBuilder(); - for (Entry entry : featureVector.getMetaData().entrySet()) { + for (Map.Entry entry : featureVector.getMetaData().entrySet()) sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); - } TitledPane tp1 = (TitledPane) vbox.getChildren().get(1); ((Text) tp1.getContent()).setText(sb.toString()); } @@ -1754,13 +1693,13 @@ public void clearAll() { notifyIndexChange(); ellipsoidGroup.getChildren().clear(); shape3DToLabel.clear(); - //Add to hashmap so updateLabels() can manage the label position shape3DToLabel.put(xSphere, xLabel); shape3DToLabel.put(ySphere, yLabel); shape3DToLabel.put(zSphere, zLabel); shape3DToLabel.put(highlightedPoint, hoverText); dataGrid.clear(); featureVectors.clear(); + originalGrid.clear(); } public void showAll() { @@ -1781,8 +1720,7 @@ public void hideFA3D() { public void showFA3D() { Timeline timeline = new Timeline( - new KeyFrame(Duration.seconds(0.1), e -> - camera.setTranslateZ(DEFAULT_INTRO_DISTANCE)), + new KeyFrame(Duration.seconds(0.1), e -> camera.setTranslateZ(DEFAULT_INTRO_DISTANCE)), new KeyFrame(Duration.seconds(0.1), new KeyValue(opacityProperty(), 0.0)), new KeyFrame(Duration.seconds(0.3), e -> setVisible(true)), new KeyFrame(Duration.seconds(0.3), new KeyValue(opacityProperty(), 1.0)), @@ -1797,73 +1735,57 @@ public void setFeatureCollection(FeatureCollection fc) { } public void findClusters(ManifoldEvent.ProjectionConfig pc) { - //safety check if (pc.dataSource != ManifoldEvent.ProjectionConfig.DATA_SOURCE.HYPERSURFACE) return; - //convert featurevector space into 2D array of doubles double[][] observations = FeatureCollection.toData(featureVectors); double projectionScalar = 1000.0; - //find clusters switch (pc.clusterMethod) { case DBSCAN -> { - DBSCANClusterTask dbscanClusterTask = new DBSCANClusterTask( - scene, camera, projectionScalar, observations, pc); - if (!dbscanClusterTask.isCancelledByUser()) { - Thread t = new Thread(dbscanClusterTask); - t.setDaemon(true); - t.start(); + DBSCANClusterTask t = new DBSCANClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); } - break; } case HDDBSCAN -> { - HDDBSCANClusterTask hddbscanClusterTask = new HDDBSCANClusterTask( - scene, camera, projectionScalar, observations, pc); - if (!hddbscanClusterTask.isCancelledByUser()) { - Thread t = new Thread(hddbscanClusterTask); - t.setDaemon(true); - t.start(); + HDDBSCANClusterTask t = new HDDBSCANClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); } - break; } case KMEANS -> { - KMeansClusterTask kmeansClusterTask = new KMeansClusterTask( - scene, camera, projectionScalar, observations, pc); - if (!kmeansClusterTask.isCancelledByUser()) { - Thread t = new Thread(kmeansClusterTask); - t.setDaemon(true); - t.start(); + KMeansClusterTask t = new KMeansClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); } - break; } case KMEDIODS -> { - KMediodsClusterTask kmediodsClusterTask = new KMediodsClusterTask( - scene, camera, projectionScalar, observations, pc); - if (!kmediodsClusterTask.isCancelledByUser()) { - Thread t = new Thread(kmediodsClusterTask); - t.setDaemon(true); - t.start(); + KMediodsClusterTask t = new KMediodsClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); } - break; } - case EX_MAX -> { - ExMaxClusterTask exMaxClusterTask = new ExMaxClusterTask( - scene, camera, projectionScalar, observations, pc); - if (!exMaxClusterTask.isCancelledByUser()) { - Thread t = new Thread(exMaxClusterTask); - t.setDaemon(true); - t.start(); + ExMaxClusterTask t = new ExMaxClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); } - break; } case AFFINITY -> { - AffinityClusterTask affinityClusterTask = new AffinityClusterTask( - scene, camera, projectionScalar, observations, pc); - if (!affinityClusterTask.isCancelledByUser()) { - Thread t = new Thread(affinityClusterTask); - t.setDaemon(true); - t.start(); + AffinityClusterTask t = new AffinityClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); } - break; } } } @@ -1874,23 +1796,20 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection SemanticReconstructionMap rMap = reconstruction.getData_vars().getNeural_timeseries(); List> neuralData = rMap.getData(); LOG.info("Neural Data dimensions: {} entries at {} frame width.", neuralData.size(), neuralData.get(0).size()); - //need to add every other data point in the width dimension (goes in phase/mag pairs) long startTime = System.nanoTime(); dataGrid.clear(); List justTheMags; for (List phaseMagPairs : neuralData) { justTheMags = new ArrayList<>(neuralData.get(0).size() / 2); - for (int i = 0; i < phaseMagPairs.size(); i += 2) { - justTheMags.add(phaseMagPairs.get(i) * yScale); - } + for (int i = 0; i < phaseMagPairs.size(); i += 2) justTheMags.add(phaseMagPairs.get(i) * yScale); dataGrid.add(justTheMags); } LOG.info("Mapped Neural Magnitudes to Hypersurface: {}", Utils.totalTimeString(startTime)); zWidth = neuralData.size(); xWidth = neuralData.get(0).size() / 2; - zWidthSpinner.getValueFactory().setValue(zWidth); - xWidthSpinner.getValueFactory().setValue(xWidth); - updateTheMesh(); + syncGuiControls(); + originalGrid = deepCopyGrid(dataGrid); // NEW + rebuildProcessedGridAndRefresh(); // NEW xSphere.setTranslateX((xWidth * surfScale) / 2.0); zSphere.setTranslateZ((zWidth * surfScale) / 2.0); @@ -1933,11 +1852,8 @@ public void clearSemanticMaps() { @Override public void addFeatureCollection(FeatureCollection featureCollection, boolean clearQueue) { - if (null == dataGrid) { - dataGrid = new ArrayList<>(featureCollection.getFeatures().size()); - } else - dataGrid.clear(); - + if (null == dataGrid) dataGrid = new ArrayList<>(featureCollection.getFeatures().size()); + else dataGrid.clear(); List xList; for (FeatureVector fv : featureCollection.getFeatures()) { xList = new ArrayList<>(fv.getData().size()); @@ -1946,18 +1862,10 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl } zWidth = dataGrid.size(); xWidth = dataGrid.get(0).size(); - zWidthSpinner.getValueFactory().setValue(zWidth); - xWidthSpinner.getValueFactory().setValue(xWidth); - updateTheMesh(); - - getScene().getRoot().fireEvent( - new CommandTerminalEvent("Hypersurface updated. ", - new Font("Consolas", 20), Color.GREEN)); - //@TODO SMP fix so this works -// if(clearQueue) -// featureVectors = featureCollection.getFeatures(); -// else -// featureVectors.addAll(featureCollection.getFeatures()); + syncGuiControls(); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); + getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); featureVectors = featureCollection.getFeatures(); } @@ -1965,6 +1873,8 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl public void addFeatureVector(FeatureVector featureVector) { featureVectors.add(featureVector); dataGrid.add(featureVector.getData()); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); } @Override @@ -1976,12 +1886,12 @@ public void locateFeatureVector(FeatureVector featureVector) { public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); + originalGrid.clear(); } @Override public List getAllFeatureVectors() { - if (null == featureVectors) - return Collections.EMPTY_LIST; + if (null == featureVectors) return Collections.EMPTY_LIST; return featureVectors; } @@ -2018,33 +1928,29 @@ public void setDimensionLabels(List labelStrings) { @Override public void setSpheroidAnchor(boolean animate, int index) { double z = index * surfScale; -// int column = Float.valueOf(vertP3D.getX() / surfScale).intValue(); } private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { lastImage = image; long startTime = System.nanoTime(); LOG.info("Mapping Image Raster to Feature Vector... "); - startTime = System.nanoTime(); - int rows = Double.valueOf(image.getHeight()).intValue(); - int columns = Double.valueOf(image.getWidth()).intValue(); + int rows = (int) image.getHeight(); + int columns = (int) image.getWidth(); PixelReader pr = image.getPixelReader(); Color color = null; int rgb, r, g, b = 0; double dataValue = 0; - if (null == dataGrid) { - dataGrid = new ArrayList<>(rows); - } else - dataGrid.clear(); + if (null == dataGrid) dataGrid = new ArrayList<>(rows); + else dataGrid.clear(); featureVectors.clear(); for (int rowIndex = y1; rowIndex < y2; rowIndex++) { List currentDataRow = new ArrayList<>(); for (int colIndex = x1; colIndex < x2; colIndex++) { color = pr.getColor(colIndex, rowIndex); - rgb = ((int) pr.getArgb(colIndex, rowIndex)); + rgb = (pr.getArgb(colIndex, rowIndex)); FeatureVector fv = FeatureVector.EMPTY_FEATURE_VECTOR(color.toString(), 3); - fv.getData().set(0, Double.valueOf(colIndex) / columns); - fv.getData().set(1, Double.valueOf(rowIndex) / rows); + fv.getData().set(0, (double) colIndex / columns); + fv.getData().set(1, (double) rowIndex / rows); r = (rgb >> 16) & 0xFF; g = (rgb >> 8) & 0xFF; b = rgb & 0xFF; @@ -2060,9 +1966,9 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { startTime = System.nanoTime(); zWidth = rows; xWidth = columns; - zWidthSpinner.getValueFactory().setValue(zWidth); - xWidthSpinner.getValueFactory().setValue(xWidth); - updateTheMesh(); + syncGuiControls(); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); xSphere.setTranslateX((xWidth * surfScale) / 2.0); zSphere.setTranslateZ((zWidth * surfScale) / 2.0); Utils.printTotalTime(startTime); @@ -2074,24 +1980,20 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { lastImageSource = shapleyCollection.getSourceInput(); shapleyVectors.addAll(shapleyCollection.getValues()); try { - //method will automatically prepend base path for imagery - WritableImage wi = loadImage(shapleyCollection.getSourceInput()); + WritableImage wi = ResourceUtils.loadImageFile(imageryBasePath + shapleyCollection.getSourceInput()); if (null != wi) { - int x2 = Double.valueOf(wi.getWidth()).intValue(); - int y2 = Double.valueOf(wi.getHeight()).intValue(); + int x2 = (int) wi.getWidth(); + int y2 = (int) wi.getHeight(); tessellateImage(wi, 0, 0, x2, y2); lastImage = wi; - //sneakily insert current function values (shapley) into vertices LOG.info("injecting Shapley function values into Vertices... "); long startTime = System.nanoTime(); surfPlot.functionValues.clear(); for (int i = 0; i < shapleyVectors.size(); i++) { - surfPlot.functionValues.add( - shapleyVectors.get(i).getData().get(0) * yScale); + surfPlot.functionValues.add(shapleyVectors.get(i).getData().get(0) * yScale); } Utils.printTotalTime(startTime); - if (null == colorationMethod) - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); + if (null == colorationMethod) surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); switch (colorationMethod) { case COLOR_BY_IMAGE -> surfPlot.setTextureModeImage(imageryBasePath + lastImageSource); case COLOR_BY_FEATURE -> surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); @@ -2112,4 +2014,116 @@ public void addShapleyVector(ShapleyVector shapleyVector) { public void clearShapleyVectors() { shapleyVectors.clear(); } + + // ================= helpers for processing pipeline ================= + private static List> deepCopyGrid(List> src) { + List> out = new ArrayList<>(src.size()); + for (List row : src) out.add(new ArrayList<>(row)); + return out; + } + + private void rebuildProcessedGridAndRefresh() { + if (originalGrid == null || originalGrid.isEmpty()) return; + List> g = deepCopyGrid(originalGrid); + if (smoothingEnabled) { + g = SurfaceUtils.smooth(g, smoothingMethod, gaussianSigma, smoothingRadius); + } + if (toneEnabled) { + g = SurfaceUtils.toneMapGrid(g, toneOperator, toneParam); + } + dataGrid.clear(); + dataGrid.addAll(g); + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + updateTheMesh(); + updateView(true); + } + + /** + * Build a dense similarity/divergence row for a node from the current sparse graph. + */ + private Double[] buildSimilarityRowFromGraph(GraphNode node) { + if (currentGraph == null || node == null) return new Double[0]; + + final List nodes = currentGraph.getNodes(); + int n = nodes.size(); + double[] row = new double[n]; + + // map: GraphNode -> index (by reference) + // using indexOf is okay for typical sizes; can optimize later with a HashMap + for (GraphEdge e : currentGraph.getEdges()) { + // outgoing (node -> other) + if (currentGraph.findNodeById(e.getStartID()).filter(node::equals).isPresent()) { + currentGraph.findNodeById(e.getEndID()).ifPresent(other -> { + int j = nodes.indexOf(other); + if (j >= 0) { + double w = getEdgeWeightSafe(e); + row[j] = w; + } + }); + } + // incoming (other -> node) — include if you want undirected row + if (currentGraph.findNodeById(e.getEndID()).filter(node::equals).isPresent()) { + currentGraph.findNodeById(e.getStartID()).ifPresent(other -> { + int j = nodes.indexOf(other); + if (j >= 0) { + double w = getEdgeWeightSafe(e); + // for undirected behavior, you can take max/avg; here we keep the larger magnitude + row[j] = Math.abs(row[j]) >= Math.abs(w) ? row[j] : w; + } + }); + } + } + + Double[] out = new Double[n]; + for (int i = 0; i < n; i++) out[i] = row[i]; + return out; + } + + /** + * Prefer GraphEdge#getWeight(); fallback to 1.0 if unavailable. + */ + private double getEdgeWeightSafe(GraphEdge e) { + try { + // Adjust if your API uses a different accessor + return (double) GraphEdge.class.getMethod("getWeight").invoke(e); + } catch (Throwable t) { + return 1.0; // default if weight not present + } + } + + /** + * Optional: if a GraphNode encodes a source row index for the hypersurface, try to extract it. + */ + private Optional tryGetSourceRowIndex(GraphNode node) { + // Heuristics: + // (a) if there's a "getIndex()" method + try { + Object idx = GraphNode.class.getMethod("getIndex").invoke(node); + if (idx instanceof Number n) return Optional.of(n.intValue()); + } catch (Throwable ignored) { + } + // (b) if ID is numeric + try { + Object id = GraphNode.class.getMethod("getId").invoke(node); + if (id != null) return Optional.of(Integer.valueOf(String.valueOf(id))); + } catch (Throwable ignored) { + } + return Optional.empty(); + } + + /** + * Highlight a row on the hypersurface if we can map a node to a data row. + */ + private void highlightSurfaceRowIfPossible(GraphNode node) { + tryGetSourceRowIndex(node).ifPresent(rowIndex -> { + // Clamp row and paint crosshair + int clamped = Math.max(0, Math.min(rowIndex, Math.max(0, zWidth - 1))); + paintSingleColor(Color.TRANSPARENT); + // Crosshair expects world coordinates; Z increases with rows + Point3D center = new Point3D((xWidth * surfScale) / 2.0, 0, clamped * surfScale); + illuminateCrosshair(center); + }); + } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java index 9c630a45..d80b6b5a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java @@ -117,6 +117,7 @@ import javafx.scene.paint.PhongMaterial; import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; +import javafx.scene.shape.Polygon; import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape3D; import javafx.scene.shape.Sphere; @@ -147,7 +148,6 @@ import static edu.jhuapl.trinity.javafx.handlers.GaussianMixtureEventHandler.generateEllipsoidDiagonal; import static edu.jhuapl.trinity.utils.ResourceUtils.removeExtension; -import javafx.scene.shape.Polygon; /** * @author Sean Phillips @@ -769,7 +769,7 @@ public Projections3DPane(Scene scene) { mousePosX - selectionRectangle.getX()); selectionRectangle.setHeight( mousePosY - selectionRectangle.getY()); - lassoPolygon.getPoints().addAll(mousePosX, mousePosY); + lassoPolygon.getPoints().addAll(mousePosX, mousePosY); } else mouseDragCamera(me); }); @@ -1094,7 +1094,7 @@ public Projections3DPane(Scene scene) { subScene.setFill(color); }); this.scene.addEventHandler(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, e -> { - skybox.setVisible((Boolean) e.object); + if (null != skybox) skybox.setVisible((Boolean) e.object); }); this.scene.addEventHandler(HyperspaceEvent.FACTOR_COORDINATES_GUI, e -> { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java new file mode 100644 index 00000000..f1f3ea52 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java @@ -0,0 +1,431 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Utility helpers for sampling, smoothing, and tone-mapping height grids + * used by Hypersurface rendering. + */ +public final class SurfaceUtils { + private SurfaceUtils() { + } + + // ============================== Enums =============================== + + public enum Interpolation {NEAREST, BILINEAR, BICUBIC} + + public enum Smoothing {NONE, BOX, GAUSSIAN, MEDIAN} + + public enum ToneMap {NONE, CLAMP_01, NORMALIZE_01, LOG1P, GAMMA, ZSCORE} + + // ============================ Sampling ============================== + + public static double sample(List> grid, double x, double y, Interpolation mode) { + if (isEmpty(grid)) return 0.0; + final int h = height(grid), w = width(grid); + x = clamp(x, 0.0, w - 1.0); + y = clamp(y, 0.0, h - 1.0); + return switch (mode) { + case NEAREST -> sampleNearest(grid, x, y); + case BILINEAR -> sampleBilinear(grid, x, y); + case BICUBIC -> sampleBicubicSafe(grid, x, y); // edge-safe w/ fallback + }; + } + + public static double sample(List> grid, double x, double y) { + return sample(grid, x, y, Interpolation.BILINEAR); + } + + /** + * Kept for compatibility with older call sites. + */ + public static double sampleBicubic(List> grid, double x, double y) { + if (isEmpty(grid)) return 0.0; + final int h = height(grid), w = width(grid); + x = clamp(x, 0.0, w - 1.0); + y = clamp(y, 0.0, h - 1.0); + return sampleBicubicSafe(grid, x, y); + } + + private static double sampleNearest(List> g, double x, double y) { + final int w = width(g), h = height(g); + final int ix = (int) Math.round(clamp(x, 0, w - 1)); + final int iy = (int) Math.round(clamp(y, 0, h - 1)); + return g.get(iy).get(ix); + } + + public static double sampleBilinear(List> g, double x, double y) { + final int w = width(g), h = height(g); + + // Compute base indices and fractions from the *original* x,y + int x0 = (int) Math.floor(x); + int y0 = (int) Math.floor(y); + int x1 = x0 + 1; + int y1 = y0 + 1; + + double tx = x - x0; + double ty = y - y0; + + // Clamp indices to valid range; if we collapsed a side, zero the fraction + x0 = clamp(x0, 0, w - 1); + x1 = clamp(x1, 0, w - 1); + y0 = clamp(y0, 0, h - 1); + y1 = clamp(y1, 0, h - 1); + if (x0 == x1) tx = 0.0; + if (y0 == y1) ty = 0.0; + + double c00 = g.get(y0).get(x0); + double c10 = g.get(y0).get(x1); + double c01 = g.get(y1).get(x0); + double c11 = g.get(y1).get(x1); + + double cx0 = lerp(c00, c10, tx); + double cx1 = lerp(c01, c11, tx); + return lerp(cx0, cx1, ty); + } + + public static double sampleBicubicSafe(List> g, double x, double y) { + final int w = width(g), h = height(g); + final int ix = (int) Math.floor(x); + final int iy = (int) Math.floor(y); + // Need a 1-cell border; otherwise fall back to bilinear + if (ix < 1 || ix > w - 3 || iy < 1 || iy > h - 3) { + return sampleBilinear(g, x, y); + } + final double tx = x - ix, ty = y - iy; + final double[][] p = new double[4][4]; + for (int m = -1; m <= 2; m++) { + for (int n = -1; n <= 2; n++) { + p[m + 1][n + 1] = g.get(iy + m).get(ix + n); + } + } + final double[] col = new double[4]; + for (int row = 0; row < 4; row++) { + col[row] = cubicCatmullRom(p[row][0], p[row][1], p[row][2], p[row][3], tx); + } + return cubicCatmullRom(col[0], col[1], col[2], col[3], ty); + } + + // =========================== Smoothing ============================== + + /** + * ***New:*** Convenience wrapper to match your call site + * {@code SurfaceUtils.smooth(g, sm, sigma, radius)}. + * One iteration; GAUSSIAN uses {@code sigma}; other methods ignore it. + */ + public static List> smooth( + List> grid, + Smoothing method, + double sigma, + int radius + ) { + return smoothCopy(grid, method, radius, 1, sigma); + } + + /** + * Overload: BOX/MEDIAN/GAUSSIAN with default sigma heuristic (one iteration). + */ + public static List> smooth( + List> grid, + Smoothing method, + int radius + ) { + return smoothCopy(grid, method, radius, 1, -1.0); + } + + /** + * Overload with explicit iterations (sigma used only for GAUSSIAN). + */ + public static List> smooth( + List> grid, + Smoothing method, + int radius, + int iterations, + double sigma + ) { + return smoothCopy(grid, method, radius, iterations, sigma); + } + + /** + * Create a smoothed copy of the grid. + * + * @param radius kernel radius (>=0), size = 2r+1 + * @param iterations number of passes (>=1) + * @param sigma standard deviation for GAUSSIAN; if <=0 a heuristic is used + */ + public static List> smoothCopy( + List> grid, + Smoothing method, + int radius, + int iterations, + double sigma + ) { + if (isEmpty(grid) || method == Smoothing.NONE || radius <= 0 || iterations <= 0) { + return deepCopy(grid); + } + List> src = deepCopy(grid); + List> dst = makeZeroGrid(height(grid), width(grid)); + switch (method) { + case BOX -> { + for (int i = 0; i < iterations; i++) { + boxBlurSeparable(src, dst, radius); + List> tmp = src; + src = dst; + dst = tmp; + } + } + case GAUSSIAN -> { + double[] k = gaussianKernel(radius, sigma); + for (int i = 0; i < iterations; i++) { + convolveSeparable(src, dst, k); + List> tmp = src; + src = dst; + dst = tmp; + } + } + case MEDIAN -> { + for (int i = 0; i < iterations; i++) { + medianFilter(src, dst, radius); + List> tmp = src; + src = dst; + dst = tmp; + } + } + default -> { /* NONE already returned above */ } + } + return src; + } + + /** + * In-place smoothing (replaces {@code grid}). + */ + public static void smoothInPlace( + List> grid, + Smoothing method, + int radius, + int iterations, + double sigma + ) { + List> out = smoothCopy(grid, method, radius, iterations, sigma); + replaceContents(grid, out); + } + + // =========================== Tone mapping =========================== + + /** + * ***New:*** Alias to match your call site {@code toneMapGrid(g, tm, param)}. + */ + public static List> toneMapGrid( + List> grid, + ToneMap mode, + double param + ) { + return toneMapCopy(grid, mode, param); + } + + /** + * Create a tone-mapped copy of the grid. + */ + public static List> toneMapCopy(List> grid, ToneMap mode, double param) { + if (isEmpty(grid) || mode == ToneMap.NONE) return deepCopy(grid); + + final int h = height(grid), w = width(grid); + List> out = makeZeroGrid(h, w); + + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + double mean = 0.0, m2 = 0.0; + int n = 0; + + if (mode == ToneMap.NORMALIZE_01 || mode == ToneMap.LOG1P || mode == ToneMap.GAMMA) { + for (int y = 0; y < h; y++) { + List row = grid.get(y); + for (int x = 0; x < w; x++) { + double v = row.get(x); + if (v < min) min = v; + if (v > max) max = v; + } + } + } else if (mode == ToneMap.ZSCORE) { + for (int y = 0; y < h; y++) { + List row = grid.get(y); + for (int x = 0; x < w; x++) { + double v = row.get(x); + n++; + double d = v - mean; + mean += d / n; + double d2 = v - mean; + m2 += d * d2; + } + } + } + + final double range = (max - min); + final double gamma = (mode == ToneMap.GAMMA && param > 0) ? param : 2.2; + + for (int y = 0; y < h; y++) { + List inRow = grid.get(y), outRow = out.get(y); + for (int x = 0; x < w; x++) { + double v = inRow.get(x), r; + switch (mode) { + case CLAMP_01 -> r = clamp(v, 0.0, 1.0); + case NORMALIZE_01 -> { + r = (range <= 0) ? 0.0 : (v - min) / range; + r = clamp(r, 0.0, 1.0); + } + case LOG1P -> { + double shift = (min < 0) ? -min : 0.0; + double lv = Math.log1p(v + shift); + double lmax = Math.log1p(max + shift); + r = (lmax <= 0) ? 0.0 : (lv / lmax); + r = clamp(r, 0.0, 1.0); + } + case GAMMA -> { + double t = (range <= 0) ? 0.0 : (v - min) / range; + t = clamp(t, 0.0, 1.0); + r = Math.pow(t, 1.0 / gamma); + } + case ZSCORE -> { + double variance = (n > 1) ? (m2 / (n - 1)) : 0.0; + double std = (variance > 0) ? Math.sqrt(variance) : 1.0; + r = (v - mean) / std; + } + default -> r = v; + } + outRow.set(x, r); + } + } + return out; + } + + public static void toneMapInPlace(List> grid, ToneMap mode, double param) { + replaceContents(grid, toneMapCopy(grid, mode, param)); + } + + // ====================== Internal: smoothing ops ===================== + + private static void boxBlurSeparable(List> src, List> dst, int radius) { + double[] k = boxKernel(radius); + convolveSeparable(src, dst, k); + } + + private static void convolveSeparable(List> src, List> dst, double[] kernel) { + final int h = height(src), w = width(src), r = kernel.length / 2; + List> tmp = makeZeroGrid(h, w); + // horizontal + for (int y = 0; y < h; y++) { + List srow = src.get(y), trow = tmp.get(y); + for (int x = 0; x < w; x++) { + double acc = 0.0; + for (int i = -r; i <= r; i++) acc += srow.get(clamp(x + i, 0, w - 1)) * kernel[i + r]; + trow.set(x, acc); + } + } + // vertical + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + double acc = 0.0; + for (int i = -r; i <= r; i++) acc += tmp.get(clamp(y + i, 0, h - 1)).get(x) * kernel[i + r]; + dst.get(y).set(x, acc); + } + } + } + + private static void medianFilter(List> src, List> dst, int radius) { + final int h = height(src), w = width(src); + final int size = (2 * radius + 1) * (2 * radius + 1); + double[] win = new double[size]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int idx = 0; + for (int dy = -radius; dy <= radius; dy++) { + int yy = clamp(y + dy, 0, h - 1); + List row = src.get(yy); + for (int dx = -radius; dx <= radius; dx++) { + int xx = clamp(x + dx, 0, w - 1); + win[idx++] = row.get(xx); + } + } + java.util.Arrays.sort(win); + double med = (size % 2 == 1) ? win[size / 2] : 0.5 * (win[size / 2 - 1] + win[size / 2]); + dst.get(y).set(x, med); + } + } + } + + private static double[] boxKernel(int radius) { + int n = 2 * radius + 1; + double[] k = new double[n]; + double v = 1.0 / n; + for (int i = 0; i < n; i++) k[i] = v; + return k; + } + + private static double[] gaussianKernel(int radius, double sigma) { + if (radius <= 0) return new double[]{1.0}; + if (sigma <= 0) sigma = Math.max(1e-6, (radius + 1) / 2.0); // heuristic + int n = 2 * radius + 1; + double[] k = new double[n]; + double sum = 0.0, twoSigma2 = 2.0 * sigma * sigma; + for (int i = -radius; i <= radius; i++) { + double val = Math.exp(-(i * i) / twoSigma2); + k[i + radius] = val; + sum += val; + } + for (int i = 0; i < n; i++) k[i] /= sum; + return k; + } + + // ============================ Utilities ============================= + + private static double cubicCatmullRom(double p0, double p1, double p2, double p3, double t) { + final double t2 = t * t, t3 = t2 * t; + return 0.5 * (2.0 * p1 + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); + } + + private static double lerp(double a, double b, double t) { + return a + (b - a) * t; + } + + private static int width(List> g) { + return g.get(0).size(); + } + + private static int height(List> g) { + return g.size(); + } + + private static boolean isEmpty(List> g) { + return g == null || g.isEmpty() || g.get(0) == null || g.get(0).isEmpty(); + } + + private static int clamp(int v, int lo, int hi) { + return (v < lo) ? lo : (v > hi) ? hi : v; + } + + private static double clamp(double v, double lo, double hi) { + return (v < lo) ? lo : (v > hi) ? hi : v; + } + + private static List> deepCopy(List> g) { + if (isEmpty(g)) return g; + List> out = new ArrayList<>(g.size()); + for (List row : g) out.add(new ArrayList<>(row)); + return out; + } + + private static List> makeZeroGrid(int h, int w) { + List> out = new ArrayList<>(h); + for (int y = 0; y < h; y++) out.add(new ArrayList<>(Collections.nCopies(w, 0.0))); + return out; + } + + private static void replaceContents(List> target, List> src) { + target.clear(); + target.addAll(src); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/AnimatedSphere.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/AnimatedSphere.java index 6077120c..debc05b0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/AnimatedSphere.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/AnimatedSphere.java @@ -126,6 +126,23 @@ public void unselect() { setMaterial(phongMaterial); } + public void setMaterialOpacity(double alpha) { + double a = Math.max(0.0, Math.min(1.0, alpha)); + Color base = phongMaterial.getDiffuseColor(); + if (base == null) { + base = (color != null) ? color : Color.WHITE; + } + Color withAlpha = new Color(base.getRed(), base.getGreen(), base.getBlue(), a); + // keep the cached color field in sync + this.color = withAlpha; + phongMaterial.setDiffuseColor(withAlpha); + // Optional: match specular alpha for a more consistent look + Color spec = phongMaterial.getSpecularColor(); + if (spec != null) { + phongMaterial.setSpecularColor(new Color(spec.getRed(), spec.getGreen(), spec.getBlue(), a)); + } + } + @Override public boolean isSelected() { throw new UnsupportedOperationException("Not supported yet."); // Generated from nbfs://nbhost/SystemFileSystem/Templates/Classes/Code/GeneratedMethodBody diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java index bca1e2e7..11b7f392 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java @@ -8,6 +8,8 @@ import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.scene.paint.Color; +import javafx.scene.paint.Material; +import javafx.scene.paint.PhongMaterial; import javafx.scene.shape.TriangleMesh; import javafx.util.Duration; import org.fxyz3d.geometry.Point3D; @@ -146,4 +148,44 @@ public synchronized double getRateOfChange() { public synchronized void setRateOfChange(double rateOfChange) { this.rateOfChange = rateOfChange; } + + /** + * Update the diffuse (base) color of this edge's material at runtime, without rebuilding the mesh. + * If the current material is not a PhongMaterial, a new PhongMaterial is attached. + * + * @param color new diffuse color (ignored if null) + */ + public void setDiffuseColor(Color color) { + if (color == null) return; + this.diffuseColor = color; + if (this.meshView != null) { + Material m = this.meshView.getMaterial(); + if (m instanceof PhongMaterial pm) { + pm.setDiffuseColor(color); + } else { + this.meshView.setMaterial(new PhongMaterial(color)); + } + } + } + + public void setOpacityAlpha(double alpha) { + double a = Math.max(0.0, Math.min(1.0, alpha)); + if (this.meshView != null) { + javafx.scene.paint.Material m = this.meshView.getMaterial(); + Color base; + if (m instanceof PhongMaterial pm) { + base = pm.getDiffuseColor(); + if (base == null) base = (diffuseColor != null) ? diffuseColor : Color.WHITE; + pm.setDiffuseColor(new Color(base.getRed(), base.getGreen(), base.getBlue(), a)); + // Optional: keep specular alpha aligned + Color spec = pm.getSpecularColor(); + if (spec != null) { + pm.setSpecularColor(new Color(spec.getRed(), spec.getGreen(), spec.getBlue(), a)); + } + } else { + base = (diffuseColor != null) ? diffuseColor : Color.WHITE; + this.meshView.setMaterial(new PhongMaterial(new Color(base.getRed(), base.getGreen(), base.getBlue(), a))); + } + } + } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java new file mode 100644 index 00000000..89c275a6 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java @@ -0,0 +1,144 @@ +package edu.jhuapl.trinity.javafx.javafx3d.tasks; + +import com.github.trinity.supermds.SuperMDS; +import com.github.trinity.supermds.SuperMDS.Params; +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.javafx.components.radial.ProgressStatus; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; +import edu.jhuapl.trinity.utils.graph.MatrixToGraphAdapter; +import edu.jhuapl.trinity.utils.graph.SuperMdsEmbedding3D; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.List; + +import static edu.jhuapl.trinity.utils.Utils.totalTimeString; + +/** + * BuildGraphFromMatrixTask + * ------------------------ + * Background task that converts a SIMILARITY or DIVERGENCE matrix into a GraphDirectedCollection, + * laying out nodes using either: + * - MDS_3D (via SuperMDS), + * - FORCE_FR (Fruchterman–Reingold 3D), + * - or a static layout (CIRCLE_XZ/CIRCLE_XY/SPHERE). + *

+ * Emits GraphEvent.NEW_GRAPHDIRECTED_COLLECTION on success. + * + * @author Sean Phillips + */ +public class BuildGraphFromMatrixTask extends Task { + private static final Logger LOG = LoggerFactory.getLogger(BuildGraphFromMatrixTask.class); + + private final Scene scene; + private final double[][] matrix; // NxN similarity or divergence + private final List labels; // size N + private final MatrixToGraphAdapter.MatrixKind kind; + private final MatrixToGraphAdapter.WeightMode weightMode; + private final GraphLayoutParams layoutParams; + + // Optional: SuperMDS params if using MDS_3D + private final SuperMDS.Params mdsParams; + + public BuildGraphFromMatrixTask(Scene scene, + double[][] matrix, + List labels, + MatrixToGraphAdapter.MatrixKind kind, + MatrixToGraphAdapter.WeightMode weightMode, + GraphLayoutParams layoutParams, + SuperMDS.Params mdsParams) { + this.scene = scene; + this.matrix = matrix; + this.labels = labels; + this.kind = kind; + this.weightMode = weightMode; + this.layoutParams = layoutParams; + this.mdsParams = mdsParams; + + setOnSucceeded(e -> Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)) + )); + setOnFailed(e -> Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)) + )); + setOnCancelled(e -> Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)) + )); + } + + @Override + protected GraphDirectedCollection call() throws Exception { + if (isCancelled()) return null; + showBusy("Preparing graph layout..."); + + long start = System.nanoTime(); + Thread.sleep(Duration.ofMillis(150)); // small pacing for UX parity with other tasks + + // Select an MDS provider if the chosen layout is MDS_3D; null otherwise. + MatrixToGraphAdapter.MdsEmbedding3D mds3d = null; + if (layoutParams.kind == GraphLayoutParams.LayoutKind.MDS_3D) { + SuperMDS.Params p = (mdsParams != null) ? mdsParams : defaultMdsParams(); + if (p.outputDim != 3) p.outputDim = 3; // ensure 3D embedding + mds3d = new SuperMdsEmbedding3D(p); + } + + updateMessage("Building graph from matrix..."); + GraphDirectedCollection gc = MatrixToGraphAdapter.build( + matrix, + labels, + kind, + layoutParams, + weightMode, + mds3d + ); + + String elapsed = totalTimeString(start); + LOG.info("Graph build complete. {}", elapsed); + postConsole(elapsed); + + // Publish to the app (same event pattern used in your GraphDirectedTest) + Platform.runLater(() -> + scene.getRoot().fireEvent(new GraphEvent(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, gc)) + ); + + return gc; + } + + // --- helpers to keep parity with your busy indicator + console pattern --- + + private void showBusy(String msg) { + Platform.runLater(() -> { + ProgressStatus ps = new ProgressStatus(msg, 0.5); + ps.fillStartColor = Color.AQUA; + ps.fillEndColor = Color.DODGERBLUE; + ps.innerStrokeColor = Color.AQUA; + ps.outerStrokeColor = Color.DODGERBLUE; + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.UPDATE_BUSY_INDICATOR, ps)); + postConsole(msg); + }); + } + + private void postConsole(String msg) { + Platform.runLater(() -> + scene.getRoot().fireEvent(new CommandTerminalEvent( + msg, new Font("Consolas", 18), Color.LIGHTGREEN)) + ); + } + + private static Params defaultMdsParams() { + Params p = new Params(); + // Sensible defaults; feel free to override when constructing the task. + // e.g., p.mode = SuperMDS.Mode.SMACOF; + p.outputDim = 3; + return p; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/ManifoldClusterTask.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/ManifoldClusterTask.java index 32c2e3b3..7f64a22a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/ManifoldClusterTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/ManifoldClusterTask.java @@ -1,7 +1,6 @@ package edu.jhuapl.trinity.javafx.javafx3d.tasks; import edu.jhuapl.trinity.css.StyleResourceProvider; -import edu.jhuapl.trinity.data.Dimension; import edu.jhuapl.trinity.data.FactorLabel; import edu.jhuapl.trinity.data.Manifold; import edu.jhuapl.trinity.data.files.FeatureCollectionFile; @@ -10,13 +9,10 @@ import edu.jhuapl.trinity.javafx.components.radial.ProgressStatus; import edu.jhuapl.trinity.javafx.events.ApplicationEvent; import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; -import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.events.ManifoldEvent; import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; import edu.jhuapl.trinity.utils.JavaFX3DUtils; import edu.jhuapl.trinity.utils.ResourceUtils; -import java.io.File; -import java.io.IOException; import javafx.application.Platform; import javafx.geometry.Pos; import javafx.scene.PerspectiveCamera; @@ -37,25 +33,28 @@ import javafx.scene.paint.Color; import javafx.scene.paint.PhongMaterial; import javafx.scene.shape.CullFace; +import javafx.scene.shape.Polygon; import javafx.scene.shape.Sphere; import javafx.scene.text.Font; +import javafx.stage.FileChooser; import javafx.stage.StageStyle; import org.fxyz3d.geometry.Point3D; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Optional; -import javafx.scene.shape.Polygon; -import javafx.stage.FileChooser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * @author Sean Phillips */ public class ManifoldClusterTask extends ClusterTask { private static final Logger LOG = LoggerFactory.getLogger(ManifoldClusterTask.class); - + HashMap currentMap; Polygon lassoPolygon; Boolean export = false; @@ -64,7 +63,7 @@ public class ManifoldClusterTask extends ClusterTask { File latestDir = new File("."); public ManifoldClusterTask(Scene scene, PerspectiveCamera camera, - HashMap currentMap, Polygon lassoPolygon) { + HashMap currentMap, Polygon lassoPolygon) { super(scene, camera); this.currentMap = currentMap; this.lassoPolygon = lassoPolygon; @@ -98,7 +97,7 @@ public ManifoldClusterTask(Scene scene, PerspectiveCamera camera, CheckBox exportCheckBox = new CheckBox("Export Features"); CheckBox manifoldCheckBox = new CheckBox("Create Manifold"); - + Label nameLabel = new Label("Name"); nameLabel.setMinWidth(100); TextField labelTextField = new TextField("Selected Manifold " + ai.getAndIncrement()); @@ -158,7 +157,7 @@ protected void processTask() throws Exception { labelMatchedPoints.add(JavaFX3DUtils.toFXYZ3D.apply(p3D)); labelMatchedFeatureVectors.add(fv); } - } + } if (export) { Platform.runLater(() -> { FileChooser fc = new FileChooser(); @@ -172,8 +171,8 @@ protected void processTask() throws Exception { if (file.getParentFile().isDirectory()) latestDir = file; scene.getRoot().fireEvent( - new CommandTerminalEvent("Exporting " + labelMatchedFeatureVectors.size() + " FeatureVectors.", - new Font("Consolas", 20), Color.GREEN)); + new CommandTerminalEvent("Exporting " + labelMatchedFeatureVectors.size() + " FeatureVectors.", + new Font("Consolas", 20), Color.GREEN)); FeatureCollection featureCollection = new FeatureCollection(); featureCollection.setFeatures(labelMatchedFeatureVectors); try { @@ -182,9 +181,9 @@ protected void processTask() throws Exception { fcf.writeContent(); } catch (IOException ex) { LOG.error(null, ex); - } + } } - }); + }); } if (create && indices.size() > 4) { Manifold manifold = new Manifold(manPoints, manifoldName, manifoldName, getDiffuseColor()); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java new file mode 100644 index 00000000..a1ff9471 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java @@ -0,0 +1,150 @@ +package edu.jhuapl.trinity.javafx.renderers; + +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import edu.jhuapl.trinity.data.graph.GraphNode; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; +import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; +import edu.jhuapl.trinity.utils.JavaFX3DUtils; +import javafx.scene.Group; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import org.fxyz3d.geometry.Point3D; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Turns a GraphDirectedCollection into a 3D Group (nodes = AnimatedSphere, + * edges = Tracer). + */ +public final class Graph3DRenderer { + + public static final class Params { + + public double nodeRadius = 20.0; + public int nodeDivisions = 64; + public float edgeWidth = 6.0f; + public double positionScalar = 1.0; + public Color defaultNodeColor = Color.CYAN; + public Color defaultEdgeColor = Color.ALICEBLUE; + + public Params withNodeRadius(double r) { + nodeRadius = r; + return this; + } + + public Params withNodeDivisions(int d) { + nodeDivisions = d; + return this; + } + + public Params withEdgeWidth(float w) { + edgeWidth = w; + return this; + } + + public Params withPositionScalar(double s) { + positionScalar = s; + return this; + } + + public Params withDefaultNodeColor(Color c) { + if (c != null) { + defaultNodeColor = c; + } + return this; + } + + public Params withDefaultEdgeColor(Color c) { + if (c != null) { + defaultEdgeColor = c; + } + return this; + } + } + + private Graph3DRenderer() { + } + + public static Group buildGraphGroup(GraphDirectedCollection graph, Params params) { + Group root = new Group(); + if (graph == null) { + return root; + } + Params p = (params != null) ? params : new Params(); + + Color nodeDefault = (graph.getDefaultNodeColor() != null) + ? Color.valueOf(graph.getDefaultNodeColor()) : p.defaultNodeColor; + Color edgeDefault = (graph.getDefaultEdgeColor() != null) + ? Color.valueOf(graph.getDefaultEdgeColor()) : p.defaultEdgeColor; + + // Nodes + List nodes = new ArrayList<>(graph.getNodes().size()); + for (GraphNode gN : graph.getNodes()) { + Color nodeColor = (gN.getColor() != null) ? Color.valueOf(gN.getColor()) : nodeDefault; + PhongMaterial mat = new PhongMaterial(nodeColor); + AnimatedSphere s = new AnimatedSphere(mat, p.nodeRadius, p.nodeDivisions, true); + Point3D p3 = JavaFX3DUtils.getGraphNodePoint3D(gN, p.positionScalar); + s.setTranslateX(p3.x); + s.setTranslateY(p3.y); + s.setTranslateZ(p3.z); + s.setUserData(gN); + + // --- Inspect events + s.setOnMouseMoved(e -> { + Scene sc = s.getScene(); + if (sc != null && sc.getRoot() != null) { + sc.getRoot().fireEvent(new GraphEvent(GraphEvent.GRAPH_NODE_HOVER, gN)); + } + }); + s.setOnMouseClicked(e -> { + Scene sc = s.getScene(); + if (sc != null && sc.getRoot() != null) { + sc.getRoot().fireEvent(new GraphEvent(GraphEvent.GRAPH_NODE_CLICK, gN)); + } + }); + + nodes.add(s); + } + + // Edges + List edges = new ArrayList<>(graph.getEdges().size()); + for (GraphEdge ge : graph.getEdges()) { + Optional a = graph.findNodeById(ge.getStartID()); + Optional b = graph.findNodeById(ge.getEndID()); + if (a.isEmpty() || b.isEmpty()) { + continue; + } + Point3D pa = JavaFX3DUtils.getGraphNodePoint3D(a.get(), p.positionScalar); + Point3D pb = JavaFX3DUtils.getGraphNodePoint3D(b.get(), p.positionScalar); + Color ec = (ge.getColor() != null) ? Color.valueOf(ge.getColor()) : edgeDefault; + Tracer t = new Tracer(pa, pb, p.edgeWidth, ec); + t.setUserData(ge); + + // --- Inspect events + t.setOnMouseMoved(e -> { + Scene sc = t.getScene(); + if (sc != null && sc.getRoot() != null) { + sc.getRoot().fireEvent(new GraphEvent(GraphEvent.GRAPH_EDGE_HOVER, ge)); + } + }); + t.setOnMouseClicked(e -> { + Scene sc = t.getScene(); + if (sc != null && sc.getRoot() != null) { + sc.getRoot().fireEvent(new GraphEvent(GraphEvent.GRAPH_EDGE_CLICK, ge)); + } + }); + + edges.add(t); + } + + root.getChildren().addAll(nodes); + root.getChildren().addAll(edges); + return root; + } + +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java new file mode 100644 index 00000000..d4a91ed8 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -0,0 +1,139 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.ObservableList; +import javafx.event.EventTarget; + +import java.io.File; +import java.util.List; +import java.util.Map; + +/** + * Central manager API for FeatureVectors grouped as named collections. + */ +public interface FeatureVectorManagerService { + + String MANAGER_APPLY_TAG = "FV_MANAGER_APPLY"; + + enum SamplingMode {ALL, HEAD_1000, TAIL_1000, RANDOM_1000} + + enum ExportFormat {JSON, CSV} + + /** + * Live list of collection names (do not replace the instance; mutate it). + */ + ObservableList getCollectionNames(); + + /** + * Name of the currently active collection. + */ + StringProperty activeCollectionNameProperty(); + + /** + * Live list of vectors to display (sampling + filters applied). + */ + ObservableList getDisplayedVectors(); + + /** + * Current sampling mode. + */ + ObjectProperty samplingModeProperty(); + + /** + * Free-text filter across label/text/metadata (case-insensitive). + */ + StringProperty textFilterProperty(); + + /** + * Convenience setter for the text filter. + */ + default void setTextFilter(String text) { + textFilterProperty().set(text == null ? "" : text); + } + + /** + * Add a new collection (or replace existing if same name), selecting it active. + */ + void addCollection(String proposedName, List vectors); + + /** + * Append vectors to the currently active collection. + */ + void appendVectorsToActive(List vectors); + + /** + * Replace the vectors in the currently active collection. + */ + void replaceActiveVectors(List vectors); + + /** + * Rename a collection. + */ + void renameCollection(String oldName, String newName); + + /** + * Duplicate a collection; returns new collection name. + */ + String duplicateCollection(String sourceName, String proposedName); + + /** + * Delete a collection. + */ + void deleteCollection(String name); + + /** + * Merge source collection into target. Optionally de-duplicate by entityId. + */ + void mergeInto(String targetName, String sourceName, boolean dedupByEntityId); + + /** + * Export a collection to a file in the given format. + */ + void exportCollection(String name, File file, ExportFormat format) throws Exception; + + /** + * Remove specific vectors from the active collection. + */ + void removeFromActive(List toRemove); + + /** + * Copy specific vectors to a target collection (create if missing). + */ + void copyToCollection(List toCopy, String targetCollection); + + /** + * Bulk set label on selected vectors in active collection. + */ + void bulkSetLabelInActive(List targets, String newLabel); + + /** + * Bulk edit metadata (upsert keys) on selected vectors in active collection. + */ + void bulkEditMetadataInActive(List targets, Map kv); + + /** + * Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). + */ + void applyActiveToWorkspace(boolean replace); + + // Convenience default + default void applyActiveToWorkspace() { + applyActiveToWorkspace(false); + } + + /** + * Also Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). + */ + void applyAllToWorkspace(boolean replace); + + default void applyAllToWorkspace() { + applyAllToWorkspace(false); + } + + /** + * Optional: Where events should be fired (e.g., scene.getRoot()). + */ + void setEventTarget(EventTarget target); +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java new file mode 100644 index 00000000..6147bb3b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -0,0 +1,383 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.files.FeatureCollectionFile; +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.EventTarget; +import javafx.scene.Node; +import javafx.scene.Scene; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * In-memory implementation; keeps a map of named collections and exposes a sampled/filtered "displayed" list. + */ +public class FeatureVectorManagerServiceImpl implements FeatureVectorManagerService { + + private final ObservableList collectionNames = FXCollections.observableArrayList(); + private final Map> collections = new LinkedHashMap<>(); + private final StringProperty activeCollectionName = new SimpleStringProperty(); + private final ObservableList displayedVectors = FXCollections.observableArrayList(); + private final ObjectProperty samplingMode = new SimpleObjectProperty<>(SamplingMode.ALL); + private final StringProperty textFilter = new SimpleStringProperty(""); + + // we store the target as Node or Scene to be able to call fireEvent + private Node eventNode; + private Scene eventScene; + + public FeatureVectorManagerServiceImpl(InMemoryFeatureVectorRepository ignoredRepo) { + // keep displayedVectors in sync whenever active name, sampling mode, or filter changes + activeCollectionName.addListener((obs, o, n) -> refreshDisplayedFromActive()); + samplingMode.addListener((obs, o, n) -> refreshDisplayedFromActive()); + textFilter.addListener((obs, o, n) -> refreshDisplayedFromActive()); + } + + @Override + public ObservableList getCollectionNames() { + return collectionNames; + } + + @Override + public StringProperty activeCollectionNameProperty() { + return activeCollectionName; + } + + @Override + public ObservableList getDisplayedVectors() { + return displayedVectors; + } + + @Override + public ObjectProperty samplingModeProperty() { + return samplingMode; + } + + @Override + public StringProperty textFilterProperty() { + return textFilter; + } + + @Override + public void addCollection(String proposedName, List vectors) { + if (vectors == null) vectors = List.of(); + final String clean = FeatureVectorUtils.cleanName(proposedName); + final String name = uniquify(clean); + final List payload = FeatureVectorUtils.copyVectors(vectors); + + runFx(() -> { + collections.put(name, payload); + if (!collectionNames.contains(name)) collectionNames.add(name); + activeCollectionName.set(name); + refreshDisplayedFromActive(); + }); + } + + @Override + public void appendVectorsToActive(List vectors) { + if (vectors == null || vectors.isEmpty()) return; + runFx(() -> { + String name = ensureActiveCollection(); + collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(FeatureVectorUtils.copyVectors(vectors)); + refreshDisplayedFromActive(); + }); + } + + @Override + public void replaceActiveVectors(List vectors) { + runFx(() -> { + String name = ensureActiveCollection(); + collections.put(name, FeatureVectorUtils.copyVectors(vectors == null ? List.of() : vectors)); + refreshDisplayedFromActive(); + }); + } + + @Override + public void renameCollection(String oldName, String newName) { + if (oldName == null || newName == null) return; + final String cleanNew = uniquify(FeatureVectorUtils.cleanName(newName)); + runFx(() -> { + List existing = collections.remove(oldName); + if (existing == null) return; + collections.put(cleanNew, existing); + int idx = collectionNames.indexOf(oldName); + if (idx >= 0) collectionNames.set(idx, cleanNew); + if (Objects.equals(activeCollectionName.get(), oldName)) { + activeCollectionName.set(cleanNew); + } + refreshDisplayedFromActive(); + }); + } + + @Override + public String duplicateCollection(String sourceName, String proposedName) { + if (sourceName == null) return null; + List src = collections.get(sourceName); + if (src == null) return null; + final String newName = uniquify(FeatureVectorUtils.cleanName( + (proposedName == null || proposedName.isBlank()) ? ("Copy of " + sourceName) : proposedName)); + final List payload = FeatureVectorUtils.copyVectors(src); + runFx(() -> { + collections.put(newName, payload); + collectionNames.add(newName); + activeCollectionName.set(newName); + refreshDisplayedFromActive(); + }); + return newName; + } + + @Override + public void deleteCollection(String name) { + if (name == null) return; + runFx(() -> { + collections.remove(name); + collectionNames.remove(name); + if (Objects.equals(activeCollectionName.get(), name)) { + if (!collectionNames.isEmpty()) { + activeCollectionName.set(collectionNames.get(0)); + } else { + activeCollectionName.set(null); + displayedVectors.clear(); + } + } else { + refreshDisplayedFromActive(); + } + }); + } + + @Override + public void mergeInto(String targetName, String sourceName, boolean dedupByEntityId) { + if (targetName == null || sourceName == null) return; + runFx(() -> { + List tgt = collections.computeIfAbsent(targetName, k -> new ArrayList<>()); + List src = collections.getOrDefault(sourceName, List.of()); + if (src.isEmpty()) return; + + if (dedupByEntityId) { + Set existingIds = tgt.stream() + .map(FeatureVector::getEntityId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + for (FeatureVector fv : src) { + String id = fv.getEntityId(); + if (id == null || !existingIds.contains(id)) { + tgt.add(FeatureVectorUtils.cloneVector(fv)); + if (id != null) existingIds.add(id); + } + } + } else { + tgt.addAll(FeatureVectorUtils.copyVectors(src)); + } + refreshDisplayedFromActive(); + }); + } + + @Override + public void exportCollection(String name, File file, ExportFormat format) throws Exception { + if (name == null || file == null || format == null) return; + List src = collections.getOrDefault(name, List.of()); + if (format == ExportFormat.JSON) { + FeatureCollection fc = new FeatureCollection(); + fc.setFeatures(FeatureVectorUtils.copyVectors(src)); + FeatureCollectionFile out = new FeatureCollectionFile(file.getAbsolutePath(), false); + out.featureCollection = fc; + out.writeContent(); + } else { // CSV + FeatureVectorUtils.writeCsv(file, src); + } + } + + @Override + public void removeFromActive(List toRemove) { + if (toRemove == null || toRemove.isEmpty()) return; + runFx(() -> { + String name = activeCollectionName.get(); + if (name == null) return; + List list = collections.get(name); + if (list == null) return; + Set ids = toRemove.stream() + .map(FeatureVector::getEntityId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (!ids.isEmpty()) { + list.removeIf(fv -> fv.getEntityId() != null && ids.contains(fv.getEntityId())); + } else { + list.removeAll(toRemove); + } + refreshDisplayedFromActive(); + }); + } + + @Override + public void copyToCollection(List toCopy, String targetCollection) { + if (toCopy == null || toCopy.isEmpty() || targetCollection == null) return; + runFx(() -> { + String target = (collectionNames.contains(targetCollection)) + ? targetCollection + : uniquify(targetCollection); + collections.computeIfAbsent(target, k -> { + if (!collectionNames.contains(target)) collectionNames.add(target); + return new ArrayList<>(); + }).addAll(FeatureVectorUtils.copyVectors(toCopy)); + refreshDisplayedFromActive(); + }); + } + + @Override + public void bulkSetLabelInActive(List targets, String newLabel) { + if (targets == null || targets.isEmpty()) return; + runFx(() -> { + String name = activeCollectionName.get(); + if (name == null) return; + List list = collections.get(name); + if (list == null) return; + Set ids = targets.stream().map(FeatureVector::getEntityId).filter(Objects::nonNull).collect(Collectors.toSet()); + for (FeatureVector fv : list) { + if ((fv.getEntityId() != null && ids.contains(fv.getEntityId())) || targets.contains(fv)) { + fv.setLabel(newLabel); + } + } + refreshDisplayedFromActive(); + }); + } + + @Override + public void bulkEditMetadataInActive(List targets, Map kv) { + if (targets == null || targets.isEmpty() || kv == null || kv.isEmpty()) return; + runFx(() -> { + String name = activeCollectionName.get(); + if (name == null) return; + List list = collections.get(name); + if (list == null) return; + Set ids = targets.stream().map(FeatureVector::getEntityId).filter(Objects::nonNull).collect(Collectors.toSet()); + for (FeatureVector fv : list) { + if ((fv.getEntityId() != null && ids.contains(fv.getEntityId())) || targets.contains(fv)) { + if (fv.getMetaData() != null) { + kv.forEach((k, v) -> fv.getMetaData().put(k, v)); + } + } + } + refreshDisplayedFromActive(); + }); + } + + @Override + public void applyActiveToWorkspace(boolean replace) { + runFx(() -> { + String name = activeCollectionName.get(); + List active = (name == null) ? List.of() + : collections.getOrDefault(name, List.of()); + if (active == null || active.isEmpty()) return; + + var fc = new FeatureCollection(); + fc.setFeatures(active); + + var evt = new FeatureVectorEvent( + FeatureVectorEvent.NEW_FEATURE_COLLECTION, + fc + ); + evt.object2 = MANAGER_APPLY_TAG; // guard against re-mirroring + evt.clearExisting = replace; // replace vs. append + + if (eventNode != null) eventNode.fireEvent(evt); + else if (eventScene != null) eventScene.getRoot().fireEvent(evt); + }); + } + + @Override + public void applyAllToWorkspace(boolean replace) { + runFx(() -> { + List all = new ArrayList<>(); + collections.values().forEach(all::addAll); + var fc = new FeatureCollection(); + fc.setFeatures(all); + + var evt = new FeatureVectorEvent( + FeatureVectorEvent.NEW_FEATURE_COLLECTION, + fc + ); + evt.object2 = MANAGER_APPLY_TAG; // guard against re-mirroring + evt.clearExisting = replace; // replace vs. append + + if (eventNode != null) eventNode.fireEvent(evt); + else if (eventScene != null) eventScene.getRoot().fireEvent(evt); + }); + } + + @Override + public void setEventTarget(EventTarget target) { + if (target instanceof Node n) { + this.eventNode = n; + this.eventScene = null; + } else if (target instanceof Scene s) { + this.eventScene = s; + this.eventNode = null; + } else { + this.eventNode = null; + this.eventScene = null; + } + } + + // ---------- internals ---------- + private void refreshDisplayedFromActive() { + String name = activeCollectionName.get(); + List src = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); + + // normalize the query once + String q = edu.jhuapl.trinity.javafx.services.FeatureVectorUtils.normalize(textFilter.get()); + + // filter by label/text/metadata if query present + List filtered = (q == null || q.isEmpty()) + ? src + : src.stream() + .filter(fv -> edu.jhuapl.trinity.javafx.services.FeatureVectorUtils.matchesTextFilter(fv, q)) + .collect(java.util.stream.Collectors.toList()); + + // then apply sampling + List sampled = + edu.jhuapl.trinity.javafx.services.FeatureVectorUtils.applySampling(filtered, samplingMode.get()); + + displayedVectors.setAll(sampled); + } + + private String uniquify(String base) { + String b = (base == null || base.isBlank()) ? "Collection" : base.trim(); + String name = b; + int i = 2; + while (collectionNames.contains(name)) { + name = b + "-" + i++; + } + return name; + } + + private String ensureActiveCollection() { + String name = activeCollectionName.get(); + if (name == null) { + name = uniquify("Collection"); + collections.put(name, new ArrayList<>()); + collectionNames.add(name); + activeCollectionName.set(name); + } + return name; + } + + private static void runFx(Runnable r) { + if (Platform.isFxApplicationThread()) r.run(); + else Platform.runLater(r); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java new file mode 100644 index 00000000..1af59713 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java @@ -0,0 +1,20 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.collections.ObservableList; + +import java.util.List; + +public interface FeatureVectorRepository { + ObservableList getCollectionNames(); + + void put(String name, List vectors); + + void remove(String name); + + boolean contains(String name); + + List get(String name); + + void clear(); +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java new file mode 100644 index 00000000..d0743a60 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java @@ -0,0 +1,328 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +/** + * Grab-bag of common helpers used by FeatureVector Manager UI & service. + */ +public final class FeatureVectorUtils { + private FeatureVectorUtils() { + } + + /* ---------------- String helpers ---------------- */ + + /** + * null-safe: returns empty string when s==null + */ + public static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + /** + * Normalizes for case-insensitive contains. + */ + public static String normalize(String s) { + return s == null ? null : s.toLowerCase(Locale.ROOT).trim(); + } + + /** + * Case-insensitive "contains" against any object (via toString()); q must be normalized already. + */ + public static boolean containsIgnoreCase(Object value, String qNormalized) { + if (value == null || qNormalized == null || qNormalized.isEmpty()) return false; + String v = normalize(String.valueOf(value)); + return v != null && v.contains(qNormalized); + } + + /** + * Trims trailing zeros from a decimal representation. + */ + public static String trimDouble(double d) { + String s = String.format(Locale.ROOT, "%.6f", d); + if (s.contains(".")) s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + return s; + } + + /** + * Safe filename mapping (Windows reserved chars, etc.). + */ + public static String safeFilename(String s) { + if (s == null) return "collection"; + return s.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + /** + * Normalize/clean a collection name (null-safe trim). + */ + public static String cleanCollectionName(String s) { + return (s == null) ? "" : s.trim(); + } + + /* ---------------- Vector cloning/copying ---------------- */ + + /** + * Deep-ish clone of a FeatureVector (copies lists and metadata map). + */ + public static FeatureVector cloneVector(FeatureVector src) { + if (src == null) return null; + FeatureVector fv = new FeatureVector(); + fv.setEntityId(src.getEntityId()); + fv.setLabel(src.getLabel()); + fv.setData(src.getData() == null ? null : new ArrayList<>(src.getData())); + fv.setBbox(src.getBbox() == null ? null : new ArrayList<>(src.getBbox())); + fv.setImageId(src.getImageId()); + fv.setFrameId(src.getFrameId()); + fv.setImageURL(src.getImageURL()); + fv.setMediaURL(src.getMediaURL()); + fv.setScore(src.getScore()); + fv.setPfa(src.getPfa()); + fv.setLayer(src.getLayer()); + fv.setText(src.getText()); + if (src.getMetaData() != null) { + fv.setMetaData(new LinkedHashMap<>(src.getMetaData())); + } + return fv; + } + + /** + * Copy a list of FeatureVectors via {@link #cloneVector(FeatureVector)}. + */ + public static List copyVectors(List in) { + if (in == null || in.isEmpty()) return List.of(); + ArrayList out = new ArrayList<>(in.size()); + for (FeatureVector fv : in) out.add(cloneVector(fv)); + return out; + } + + /* ---------------- Preview/format helpers ---------------- */ + + /** + * Small numeric preview for a vector’s data list. + */ + public static String previewList(List data, int firstN) { + if (data == null || data.isEmpty()) return "[]"; + int n = Math.min(firstN, data.size()); + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < n; i++) { + if (i > 0) sb.append(", "); + Double v = data.get(i); + if (v == null) sb.append("NaN"); + else { + String s = String.format(Locale.ROOT, "%.6f", v); + if (s.contains(".")) s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + sb.append(s); + } + } + if (data.size() > n) sb.append(", …"); + sb.append("]"); + return sb.toString(); + } + + /** + * Pretty prints metadata as lines of "key: value". + */ + public static String prettyMetadata(Map md) { + if (md == null || md.isEmpty()) return "(no metadata)"; + StringBuilder sb = new StringBuilder(); + md.forEach((k, v) -> sb.append(k == null ? "(null)" : k) + .append(": ") + .append(v == null ? "(null)" : v) + .append("\n")); + // strip trailing newline + int len = sb.length(); + return len > 0 ? sb.deleteCharAt(len - 1).toString() : ""; + } + + /** + * Parses simple `key=value` lines into a LinkedHashMap (preserves input order). + */ + public static Map parseKeyValues(String text) { + Map map = new LinkedHashMap<>(); + if (text == null || text.isBlank()) return map; + String[] lines = text.split("\\R"); + for (String line : lines) { + String ln = line.trim(); + if (ln.isEmpty()) continue; + int eq = ln.indexOf('='); + if (eq < 0) map.put(ln, ""); + else { + String k = ln.substring(0, eq).trim(); + String v = ln.substring(eq + 1).trim(); + if (!k.isEmpty()) map.put(k, v); + } + } + return map; + } + + /* ---------------- Collection naming / merging ---------------- */ + + /** + * Derives a friendly collection name from a hint (filename/path or arbitrary string) + * and/or falls back to label/size, then a UUID. + */ + public static String deriveCollectionName(Object sourceHint, List fvs) { + // 1) filename-ish + if (sourceHint instanceof String s && !s.isBlank()) { + String base = s.replace('\\', '/'); + int slash = base.lastIndexOf('/'); + if (slash >= 0) base = base.substring(slash + 1); + int dot = base.lastIndexOf('.'); + if (dot > 0) base = base.substring(0, dot); + if (!base.isBlank()) return base; + } + // 2) labels + String label = (fvs != null && !fvs.isEmpty()) ? nullToEmpty(fvs.get(0).getLabel()) : ""; + if (!label.isBlank()) return label + " (" + (fvs == null ? 0 : fvs.size()) + ")"; + // 3) fallback + return "Collection-" + UUID.randomUUID().toString().substring(0, 8); + } + + /** + * Merge helper with optional dedupe by entityId (null IDs are always appended). + */ + public static List mergeVectors(List target, + Collection source, + boolean dedupeByEntityId) { + if (source == null || source.isEmpty()) return target; + if (target == null) target = new ArrayList<>(); + + if (!dedupeByEntityId) { + target.addAll(source); + return target; + } + Set seen = target.stream().map(FeatureVector::getEntityId).collect(Collectors.toSet()); + for (FeatureVector fv : source) { + Object id = fv.getEntityId(); + if (id == null || !seen.contains(id)) { + target.add(fv); + if (id != null) seen.add(id); + } + } + return target; + } + + /* ---------------- Sampling & filtering ---------------- */ + + /** + * Applies sampling policy used by the Manager. + */ + public static List applySampling(List all, + FeatureVectorManagerService.SamplingMode mode) { + if (all == null || all.isEmpty() || mode == null || mode == FeatureVectorManagerService.SamplingMode.ALL) + return all == null ? List.of() : all; + + int n = all.size(); + int k = Math.min(1000, n); + switch (mode) { + case HEAD_1000: + return all.subList(0, k); + case TAIL_1000: + return all.subList(n - k, n); + case RANDOM_1000: + if (n <= k) return all; + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + ArrayList copy = new ArrayList<>(all); + for (int i = n - 1; i > 0; i--) { + int j = rnd.nextInt(i + 1); + var tmp = copy.get(i); + copy.set(i, copy.get(j)); + copy.set(j, tmp); + } + return copy.subList(0, k); + default: + return all; + } + } + + /** + * Alias used by the service refactor; delegates to {@link #applySampling(List, FeatureVectorManagerService.SamplingMode)}. + */ + public static List sample(List src, FeatureVectorManagerService.SamplingMode mode) { + return applySampling(src, mode); + } + + /** + * Returns true if any of label, text, or metadata values contains the normalized query. + */ + public static boolean matchesTextFilter(FeatureVector fv, String normalizedQuery) { + if (fv == null) return false; + if (normalizedQuery == null || normalizedQuery.isEmpty()) return true; + if (containsIgnoreCase(fv.getLabel(), normalizedQuery)) return true; + if (containsIgnoreCase(fv.getText(), normalizedQuery)) return true; + if (fv.getMetaData() != null) { + for (var v : fv.getMetaData().values()) { + if (containsIgnoreCase(v, normalizedQuery)) return true; + } + } + return false; + } + + /* ---------------- CSV export ---------------- */ + + public static void writeCsv(File file, List src) throws IOException { + int maxDim = 0; + if (src != null) { + for (FeatureVector fv : src) { + if (fv != null && fv.getData() != null) { + maxDim = Math.max(maxDim, fv.getData().size()); + } + } + } + try (BufferedWriter w = new BufferedWriter(new FileWriter(file))) { + // header + StringBuilder header = new StringBuilder("label,score,pfa,layer"); + for (int i = 0; i < maxDim; i++) header.append(",v").append(i); + w.write(header.toString()); + w.newLine(); + // rows + if (src != null) { + for (FeatureVector fv : src) { + if (fv == null) continue; + StringBuilder row = new StringBuilder(); + row.append(escapeCsv(fv.getLabel())).append(",") + .append(fv.getScore()).append(",") + .append(fv.getPfa()).append(",") + .append(fv.getLayer()); + List data = fv.getData(); + for (int i = 0; i < maxDim; i++) { + row.append(","); + if (data != null && i < data.size()) { + Double val = data.get(i); + if (val != null) row.append(val); + } + } + w.write(row.toString()); + w.newLine(); + } + } + } + } + + public static String escapeCsv(String s) { + if (s == null) return ""; + if (s.contains(",") || s.contains("\"") || s.contains("\n")) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + return s; + } + + public static String cleanName(String s) { + if (s == null) return ""; + return s.trim(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java new file mode 100644 index 00000000..69b75a30 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java @@ -0,0 +1,51 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class InMemoryFeatureVectorRepository implements FeatureVectorRepository { + private final Map> map = new LinkedHashMap<>(); + private final ObservableList collectionNames = FXCollections.observableArrayList(); + + @Override + public ObservableList getCollectionNames() { + return collectionNames; + } + + @Override + public void put(String name, List vectors) { + if (name == null) return; + map.put(name, (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors)); + if (!collectionNames.contains(name)) collectionNames.add(name); + } + + @Override + public void remove(String name) { + if (name == null) return; + map.remove(name); + collectionNames.remove(name); + } + + @Override + public boolean contains(String name) { + return map.containsKey(name); + } + + @Override + public List get(String name) { + return map.getOrDefault(name, Collections.emptyList()); + } + + @Override + public void clear() { + map.clear(); + collectionNames.clear(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java index 58055dba..00eca460 100644 --- a/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java +++ b/src/main/java/edu/jhuapl/trinity/messages/EmbeddingsImageCallback.java @@ -2,12 +2,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageOutput; -import edu.jhuapl.trinity.javafx.events.RestEvent; import edu.jhuapl.trinity.javafx.events.ErrorEvent; +import edu.jhuapl.trinity.javafx.events.RestEvent; import javafx.application.Platform; import javafx.scene.Scene; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; diff --git a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java index 266773c7..8e3606f7 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java @@ -66,6 +66,39 @@ public static void writeAnalysisConfig(File file) throws IOException { mapper.writeValue(file, currentAnalysisConfig); } + public static double[] normalizedWeights(double[] w, int dim) { + double[] out = new double[dim]; + if (w == null || w.length == 0) { + double u = 1.0 / dim; + for (int i = 0; i < dim; i++) out[i] = u; + return out; + } + double sum = 0.0; + for (int i = 0; i < dim; i++) { + out[i] = (i < w.length ? Math.max(0.0, w[i]) : 0.0); + sum += out[i]; + } + if (sum <= 0) { + double u = 1.0 / dim; + for (int i = 0; i < dim; i++) out[i] = u; + } else { + for (int i = 0; i < dim; i++) out[i] /= sum; + } + return out; + } + + public static double clamp01(double x) { + return (x < 0) ? 0 : (x > 1 ? 1 : x); + } + + public static double clamp(double x, double lo, double hi) { + return (x < lo) ? lo : (x > hi ? hi : x); + } + + public static double clip(double v, double lo, double hi) { + return Math.max(lo, Math.min(hi, v)); + } + public static double lerp1(double start, double end, double ratio) { return start * (1 - ratio) + end * ratio; } @@ -450,4 +483,15 @@ public static double[][] transformUMAP(FeatureCollection featureCollection, Umap return projected; } + public static double cosineSimilarity(List v1, List v2) { + double dot = 0.0, norm1 = 0.0, norm2 = 0.0; + for (int i = 0; i < v1.size(); i++) { + double a = v1.get(i), b = v2.get(i); + dot += a * b; + norm1 += a * a; + norm2 += b * b; + } + return dot / (Math.sqrt(norm1) * Math.sqrt(norm2) + 1e-12); // safe divide + } + } diff --git a/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java b/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java index fe9bdff7..5f50b3db 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java @@ -39,6 +39,14 @@ */ public enum DataUtils { INSTANCE; + + public enum HeightMode { + RAW, // use incoming values as-is + NORMALIZE_01, // rescale to [0,1] + ZSCORE, // (v - mean)/std + PERCENTILE_CLIP_1_99 // clip to 1–99th, then scale to [0,1] + } + private static final Logger LOG = LoggerFactory.getLogger(DataUtils.class); private static Random rando = new Random(); @@ -54,6 +62,77 @@ public static double normalize(double rawValue, double min, double max) { return (rawValue - min) / (max - min); } + public static List> normalizeAndScale( + List> grid, + HeightMode mode, + double userScale // your existing height multiplier + ) { + if (grid == null || grid.isEmpty() || grid.get(0).isEmpty()) return grid; + + int rows = grid.size(); + int cols = grid.get(0).size(); + + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY, sum = 0.0, sum2 = 0.0; + int n = rows * cols; + + // 1) scan stats + for (List row : grid) { + for (double v : row) { + if (v < min) min = v; + if (v > max) max = v; + sum += v; + sum2 += v * v; + } + } + double mean = sum / n; + double var = Math.max(0.0, sum2 / n - mean * mean); + double std = Math.max(1e-12, Math.sqrt(var)); + double range = Math.max(1e-12, max - min); + + // For percentile mode, compute cutpoints + double p1 = 0, p99 = 1; + if (mode == HeightMode.PERCENTILE_CLIP_1_99) { + double[] flat = new double[n]; + int k = 0; + for (List row : grid) for (double v : row) flat[k++] = v; + java.util.Arrays.sort(flat); + p1 = flat[(int) Math.floor(0.01 * (n - 1))]; + p99 = flat[(int) Math.floor(0.99 * (n - 1))]; + if (p99 <= p1) { + p99 = p1 + 1e-12; + } + } + + // 2) produce output + List> out = new java.util.ArrayList<>(rows); + for (int r = 0; r < rows; r++) { + List src = grid.get(r); + List dst = new java.util.ArrayList<>(cols); + for (int c = 0; c < cols; c++) { + double v = src.get(c); + double z; + switch (mode) { + case NORMALIZE_01: + z = (v - min) / range; + break; + case ZSCORE: + z = (v - mean) / std; + break; + case PERCENTILE_CLIP_1_99: + double clamped = Math.min(p99, Math.max(p1, v)); + z = (clamped - p1) / (p99 - p1); + break; + case RAW: + default: + z = v; + } + dst.add(z * userScale); // ← apply your existing user multiplier LAST + } + out.add(dst); + } + return out; + } + public static List extractReconstructionEvents(ReconstructionAttributes attrs) { List items = new ArrayList<>(attrs.getEvents().size()); Set> set = attrs.getEvents().entrySet(); diff --git a/src/main/java/edu/jhuapl/trinity/utils/GridComparisonUtils.java b/src/main/java/edu/jhuapl/trinity/utils/GridComparisonUtils.java index 5ddf0a49..49f48a4e 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/GridComparisonUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/GridComparisonUtils.java @@ -8,7 +8,7 @@ public class GridComparisonUtils { /** * Calculates the Euclidean distance between two 2D grids. - * + * * @param grid1 the first grid * @param grid2 the second grid * @return the Euclidean distance between the two grids @@ -26,7 +26,7 @@ public static double euclideanDistance(double[][] grid1, double[][] grid2) { /** * Calculates the root mean squared error (RMSE) between two 2D grids. - * + * * @param grid1 the first grid * @param grid2 the second grid * @return the RMSE between the two grids @@ -46,7 +46,7 @@ public static double rootMeanSquaredError(double[][] grid1, double[][] grid2) { /** * Calculates the mean absolute difference (MAD) between two 2D grids. - * + * * @param grid1 the first grid * @param grid2 the second grid * @return the MAD between the two grids @@ -66,7 +66,7 @@ public static double meanAbsoluteDifference(double[][] grid1, double[][] grid2) /** * Calculates the coefficient of variation (CV) for a single 2D grid. - * + * * @param grid the grid * @return the CV of the grid */ @@ -94,7 +94,7 @@ public static double coefficientOfVariation(double[][] grid) { /** * Calculates theCharsets structural similarity index (SSIM) between two 2D grids. * Note that this implementation uses a simplified version of the SSIM formula. - * + * * @param grid1 the first grid * @param grid2 the second grid * @return the SSIM between the two grids @@ -151,4 +151,4 @@ private static double covariance(double[][] grid1, double[][] grid2, double mean } return sum / count; } -} \ No newline at end of file +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java b/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java index a6f41ac0..9728370b 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java @@ -27,6 +27,7 @@ import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; import javafx.scene.shape.Mesh; +import javafx.scene.shape.Polygon; import javafx.scene.shape.Shape3D; import javafx.scene.shape.Sphere; import javafx.scene.shape.TriangleMesh; @@ -51,7 +52,6 @@ import java.util.Comparator; import java.util.List; import java.util.function.Function; -import javafx.scene.shape.Polygon; /** * Utilities used by various 3D rendering code. @@ -134,6 +134,7 @@ public static boolean matches(javafx.geometry.Point3D p1, javafx.geometry.Point3 return new javafx.geometry.Point3D(fv.getData().get(0), fv.getData().get(1), fv.getData().get(2)); }; + public static List performLassoSelection(Polygon lassoPolygon, List shapes) { List indices = new ArrayList<>(); int totalContains = 0; @@ -148,8 +149,9 @@ public static List performLassoSelection(Polygon lassoPolygon, List pickIndicesByBox(List shapes, - Point2D upperLeft, Point2D lowerRight) { + + public static List pickIndicesByBox(List shapes, + Point2D upperLeft, Point2D lowerRight) { List indices = new ArrayList<>(); //reuse this point reference Shape3D shape3D; diff --git a/src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java b/src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java new file mode 100644 index 00000000..8d940c10 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java @@ -0,0 +1,103 @@ +package edu.jhuapl.trinity.utils; + +import javafx.scene.paint.Color; + +/** + * MatrixViewUtil + * -------------- + * Small helper utilities shared by heatmap/matrix views. + * + * @author Sean Phillips + */ +public final class MatrixViewUtil { + + private MatrixViewUtil() { + } + + // ---- math helpers ---- + public static double[] minMax(double[][] m) { + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + for (int i = 0; i < m.length; i++) { + double[] row = m[i]; + for (int j = 0; j < row.length; j++) { + double v = row[j]; + if (Double.isNaN(v) || Double.isInfinite(v)) continue; + if (v < min) min = v; + if (v > max) max = v; + } + } + if (min == Double.POSITIVE_INFINITY) { + min = 0.0; + max = 1.0; + } + if (max - min < 1e-12) max = min + 1e-12; + return new double[]{min, max}; + } + + public static double norm01(double v, double min, double max) { + double t = (v - min) / (max - min); + if (t < 0) t = 0; + if (t > 1) t = 1; + return t; + } + + public static double sanitize(double v) { + if (Double.isNaN(v) || Double.isInfinite(v)) return 0.0; + return v; + } + + // ---- palettes ---- + public static Color sequentialColor(double t) { + // dark blue → teal → yellow (simple perceptual-ish) + double r = clamp01(-0.5 + 2.2 * t); + double g = clamp01(0.1 + 1.9 * t); + double b = clamp01(1.0 - 0.9 * t); + double gamma = 0.95; + return Color.color(Math.pow(r, gamma), Math.pow(g, gamma), Math.pow(b, gamma)); + } + + public static Color divergingColor(double v, double min, double max, double center) { + // Map to [-1, 1] where 0 = center + double t = (v - center) / (Math.max(Math.abs(max - center), Math.abs(center - min)) + 1e-12); + if (t < -1) t = -1; + if (t > 1) t = 1; + if (t >= 0) { + double a = t; + double r = 1.0; + double g = 1.0 - 0.6 * a; + double b = 1.0 - 0.9 * a; + return Color.color(r, g, b); + } else { + double a = -t; + double r = 1.0 - 0.9 * a; + double g = 1.0 - 0.8 * a; + double b = 1.0; + return Color.color(r, g, b); + } + } + + private static double clamp01(double x) { + if (x < 0) return 0; + if (x > 1) return 1; + return x; + } + + // ---- labels ---- + + /** + * Replace "Comp 7" → "7"; "Comp7" → "7"; otherwise returns original. + */ + public static String compactCompLabel(String s) { + if (s == null) return null; + String x = s.trim(); + if (x.startsWith("Comp ")) { + String tail = x.substring(5).trim(); + return tail.isEmpty() ? x : tail; + } + if (x.regionMatches(true, 0, "Comp", 0, 4) && x.length() > 4) { + String tail = x.substring(4).trim(); + return tail.isEmpty() ? x : tail; + } + return x; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/MessageUtils.java b/src/main/java/edu/jhuapl/trinity/utils/MessageUtils.java index cff88bf7..9bdb5580 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/MessageUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/MessageUtils.java @@ -33,6 +33,7 @@ public static boolean probablyJSON(String possibleJson) { return (possibleJson.startsWith("{") && possibleJson.endsWith("}")) || (possibleJson.startsWith("[") && possibleJson.endsWith("]")); } + public static boolean probablyCSV(String filename) { return filename.endsWith("csv") || filename.endsWith("CSV"); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java index 4df03670..075360d6 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java @@ -7,6 +7,7 @@ import edu.jhuapl.trinity.data.files.CdcTissueGenesFile; import edu.jhuapl.trinity.data.files.ClusterCollectionFile; import edu.jhuapl.trinity.data.files.CocoAnnotationFile; +import edu.jhuapl.trinity.data.files.CyberReporterFile; import edu.jhuapl.trinity.data.files.FeatureCollectionFile; import edu.jhuapl.trinity.data.files.GaussianMixtureCollectionFile; import edu.jhuapl.trinity.data.files.GraphDirectedCollectionFile; @@ -625,6 +626,10 @@ protected Void call() throws Exception { CocoAnnotationFile cocoFile = new CocoAnnotationFile(file.getAbsolutePath(), true); Platform.runLater(() -> scene.getRoot().fireEvent( new ImageEvent(ImageEvent.NEW_COCO_ANNOTATION, cocoFile.cocoObject))); + } else if (CyberReporterFile.isFileType(file)) { + CyberReporterFile cyberReportFile = new CyberReporterFile(file.getAbsolutePath(), true); + Platform.runLater(() -> scene.getRoot().fireEvent( + new FeatureVectorEvent(FeatureVectorEvent.NEW_CYBER_REPORT, cyberReportFile.cyberReports))); } } catch (IOException ex) { LOG.error(null, ex); diff --git a/src/main/java/edu/jhuapl/trinity/utils/fun/solar/FlarePresets.java b/src/main/java/edu/jhuapl/trinity/utils/fun/solar/FlarePresets.java index b0fd7d25..e214f4b4 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/fun/solar/FlarePresets.java +++ b/src/main/java/edu/jhuapl/trinity/utils/fun/solar/FlarePresets.java @@ -1,17 +1,6 @@ package edu.jhuapl.trinity.utils.fun.solar; import edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.BlurredDiskSpec; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createAnalogGlitchImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createBlurredDiskImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createCoronaRing; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createHaloImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createHexGridImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createPixelBurstImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createPlasmaRing; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createRainbowImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createRaysImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createRotatingSpikeImage; -import static edu.jhuapl.trinity.utils.fun.solar.FlarePatternFactory.createStarImage; import javafx.scene.image.Image; import javafx.scene.paint.Color; @@ -223,7 +212,7 @@ public static List createCryoLensVortex() { double scale = rando.nextDouble() + i * 0.05; Color color = i % 2 == 0 ? Color.AQUAMARINE : Color.LIGHTBLUE; flares.add(new FlareSprite(FlarePatternFactory.createStarImage(64, color), - scale, pos, DEFAULT_OPACITY, true, "Cryo Echo " + i)); + scale, pos, FlarePresets.DEFAULT_OPACITY, true, "Cryo Echo " + i)); } return flares; @@ -296,9 +285,9 @@ public static List createJJAbrams() { public static List createTacticalHexGrid() { List list = new ArrayList<>(); - Image grid = createHexGridImage(64, Color.LIGHTBLUE, 8, 1.0); - Image star = createStarImage(128, Color.CYAN); - Image glow = createBlurredDiskImage(256, 128, Color.AQUA, 0.4, 4, 0); + Image grid = FlarePatternFactory.createHexGridImage(64, Color.LIGHTBLUE, 8, 1.0); + Image star = FlarePatternFactory.createStarImage(128, Color.CYAN); + Image glow = FlarePatternFactory.createBlurredDiskImage(256, 128, Color.AQUA, 0.4, 4, 0); list.add(new FlareSprite(glow, 1.0, 0.0, 0.3, true, "Glow")); list.add(new FlareSprite(grid, 1.5, 0.0, 0.25, true, "HUD Grid")); @@ -322,22 +311,22 @@ public static List createCRTOverloadPulse() { List flares = new ArrayList<>(); // 🌞 Sun Group - flares.add(new FlareSprite(createBlurredDiskImage(256, 120, Color.LAWNGREEN, 0.5, 0, 0, 5), 1.0, 0.0, 0.4, true, "CRT Core")); - flares.add(new FlareSprite(createAnalogGlitchImage(256, 256, Color.LAWNGREEN, 80), 1.6, 0.0, 0.1, true, "Scanlines")); - flares.add(new FlareSprite(createHexGridImage(256, Color.GREENYELLOW, 24, 1.2), 1.6, 0.0, 0.1, true, "Hex Overlay")); - flares.add(new FlareSprite(createRainbowImage(256), 2.5, 0.0, 0.1, true, "CRT Fringe")); - flares.add(new FlareSprite(createHaloImage(256, Color.LIME, 2.5), 1.6, 0.0, 0.25, true, "Lime Halo")); + flares.add(new FlareSprite(FlarePatternFactory.createBlurredDiskImage(256, 120, Color.LAWNGREEN, 0.5, 0, 0, 5), 1.0, 0.0, 0.4, true, "CRT Core")); + flares.add(new FlareSprite(FlarePatternFactory.createAnalogGlitchImage(256, 256, Color.LAWNGREEN, 80), 1.6, 0.0, 0.1, true, "Scanlines")); + flares.add(new FlareSprite(FlarePatternFactory.createHexGridImage(256, Color.GREENYELLOW, 24, 1.2), 1.6, 0.0, 0.1, true, "Hex Overlay")); + flares.add(new FlareSprite(FlarePatternFactory.createRainbowImage(256), 2.5, 0.0, 0.1, true, "CRT Fringe")); + flares.add(new FlareSprite(FlarePatternFactory.createHaloImage(256, Color.LIME, 2.5), 1.6, 0.0, 0.25, true, "Lime Halo")); // 🔗 Chain 1 – Ghost Echo (green trail) for (int i = 0; i < 12; i++) { double pos = 0.4 + i * 0.2; - flares.add(new FlareSprite(createAnalogGlitchImage(128, 128, Color.LIMEGREEN, 20), 0.2 + i * 0.02, pos, 0.27, true, "Ghost Trail " + i)); + flares.add(new FlareSprite(FlarePatternFactory.createAnalogGlitchImage(128, 128, Color.LIMEGREEN, 20), 0.2 + i * 0.02, pos, 0.27, true, "Ghost Trail " + i)); } // 🔗 Chain 2 – Rainbow Scanner for (int i = 0; i < 10; i++) { double pos = 0.6 + i * 0.25; - flares.add(new FlareSprite(createRainbowImage(128), 0.3 + i * 0.015, pos, 0.25, true, "Rainbow Scanner " + i)); + flares.add(new FlareSprite(FlarePatternFactory.createRainbowImage(128), 0.3 + i * 0.015, pos, 0.25, true, "Rainbow Scanner " + i)); } return flares; @@ -347,25 +336,25 @@ public static List createSynthwaveNovaCollider() { List flares = new ArrayList<>(); // 🌞 Sun Group A — Hot core - flares.add(new FlareSprite(createBlurredDiskImage(256, 100, Color.HOTPINK, 0.5, 0, 0, 6), 1.0, 0.0, 0.4, true, "Core Glow")); - flares.add(new FlareSprite(createStarImage(256, Color.ALICEBLUE), 1.4, 0.0, 0.25, true, "Star Overlay")); - flares.add(new FlareSprite(createPlasmaRing(256, Color.FUCHSIA, 14, 5.0), 1.8, 0.0, 0.25, true, "Plasma Shell")); - flares.add(new FlareSprite(createHaloImage(256, Color.DEEPPINK, 2.0), 2.2, 0.0, 0.15, true, "Outer Halo")); + flares.add(new FlareSprite(FlarePatternFactory.createBlurredDiskImage(256, 100, Color.HOTPINK, 0.5, 0, 0, 6), 1.0, 0.0, 0.4, true, "Core Glow")); + flares.add(new FlareSprite(FlarePatternFactory.createStarImage(256, Color.ALICEBLUE), 1.4, 0.0, 0.25, true, "Star Overlay")); + flares.add(new FlareSprite(FlarePatternFactory.createPlasmaRing(256, Color.FUCHSIA, 14, 5.0), 1.8, 0.0, 0.25, true, "Plasma Shell")); + flares.add(new FlareSprite(FlarePatternFactory.createHaloImage(256, Color.DEEPPINK, 2.0), 2.2, 0.0, 0.15, true, "Outer Halo")); // 🌞 Sun Group B — Contrast ring - flares.add(new FlareSprite(createRotatingSpikeImage(256, 12, Color.FUCHSIA), 2.8, 0.0, 0.2, true, "Rotating Spikes")); - flares.add(new FlareSprite(createRainbowImage(256), 3.0, 0.0, 0.1, true, "Iridescent Rim")); + flares.add(new FlareSprite(FlarePatternFactory.createRotatingSpikeImage(256, 12, Color.FUCHSIA), 2.8, 0.0, 0.2, true, "Rotating Spikes")); + flares.add(new FlareSprite(FlarePatternFactory.createRainbowImage(256), 3.0, 0.0, 0.1, true, "Iridescent Rim")); // 🔗 Chain 1 — Neon Flare Trail for (int i = 0; i < 15; i++) { double pos = 0.3 + i * 0.18; - flares.add(new FlareSprite(createBlurredDiskImage(128, 64, Color.HOTPINK, 0.3, 0, 0, 4), 0.2 + i * 0.02, pos, 0.25, true, "Neon Trail " + i)); + flares.add(new FlareSprite(FlarePatternFactory.createBlurredDiskImage(128, 64, Color.HOTPINK, 0.3, 0, 0, 4), 0.2 + i * 0.02, pos, 0.25, true, "Neon Trail " + i)); } // 🔗 Chain 2 — Rainbow Lens Bounce for (int i = 0; i < 12; i++) { double pos = 0.6 + i * 0.2; - flares.add(new FlareSprite(createRainbowImage(128), 0.25 + i * 0.015, pos, 0.24, true, "Rainbow Bounce " + i)); + flares.add(new FlareSprite(FlarePatternFactory.createRainbowImage(128), 0.25 + i * 0.015, pos, 0.24, true, "Rainbow Bounce " + i)); } return flares; @@ -375,19 +364,19 @@ public static List createPixelShockPulse() { List flares = new ArrayList<>(); // 🌞 Sun Group - flares.add(new FlareSprite(createRainbowImage(256), 0.5, 0.0, 0.05, true, "Iridescent Rim")); - flares.add(new FlareSprite(createBlurredDiskImage(256, 100, Color.CYAN, 0.4, 0, 0, 5), 1.0, 0.0, 0.35, true, "Cyan Core")); - flares.add(new FlareSprite(createPixelBurstImage(256, Color.CYAN, 120), 1.5, 0.0, 0.3, true, "Pixel Burst")); - flares.add(new FlareSprite(createAnalogGlitchImage(256, 256, Color.AQUA, 60), 0.4, 0.0, 0.2, true, "Glitch")); - flares.add(new FlareSprite(createRaysImage(256, 20, Color.AQUAMARINE), 2.3, 0.0, 0.25, true, "Burst Rays")); + flares.add(new FlareSprite(FlarePatternFactory.createRainbowImage(256), 0.5, 0.0, 0.05, true, "Iridescent Rim")); + flares.add(new FlareSprite(FlarePatternFactory.createBlurredDiskImage(256, 100, Color.CYAN, 0.4, 0, 0, 5), 1.0, 0.0, 0.35, true, "Cyan Core")); + flares.add(new FlareSprite(FlarePatternFactory.createPixelBurstImage(256, Color.CYAN, 120), 1.5, 0.0, 0.3, true, "Pixel Burst")); + flares.add(new FlareSprite(FlarePatternFactory.createAnalogGlitchImage(256, 256, Color.AQUA, 60), 0.4, 0.0, 0.2, true, "Glitch")); + flares.add(new FlareSprite(FlarePatternFactory.createRaysImage(256, 20, Color.AQUAMARINE), 2.3, 0.0, 0.25, true, "Burst Rays")); // 🔗 Chain — Glitch Trail Random rando = new Random(); for (int i = 0; i < 13; i++) { double pos = 0.5 + i * 0.22; double scale = rando.nextDouble() * (i / 13); - flares.add(new FlareSprite(createPixelBurstImage(128, Color.AQUA, 50), scale + 0.25, pos, 0.95 - i / 13 + 0.05, true, "Shock Fragment " + i)); - flares.add(new FlareSprite(createRaysImage(128, 20, Color.AQUAMARINE), scale, pos + 0.1, 0.5 - i / 13 + 0.05, true, "Shock Rays echo " + i)); + flares.add(new FlareSprite(FlarePatternFactory.createPixelBurstImage(128, Color.AQUA, 50), scale + 0.25, pos, 0.95 - i / 13 + 0.05, true, "Shock Fragment " + i)); + flares.add(new FlareSprite(FlarePatternFactory.createRaysImage(128, 20, Color.AQUAMARINE), scale, pos + 0.1, 0.5 - i / 13 + 0.05, true, "Shock Rays echo " + i)); } return flares; @@ -397,23 +386,23 @@ public static List createNeonWarpSpiral() { List flares = new ArrayList<>(); // 🌞 Sun Group - flares.add(new FlareSprite(createBlurredDiskImage(256, 100, Color.VIOLET, 0.4, 0, 0, 4), 1.0, 0.0, 0.35, true, "Warp Core")); - flares.add(new FlareSprite(createCoronaRing(256, Color.PURPLE, 32), 1.5, 0.0, 0.25, true, "Corona")); - flares.add(new FlareSprite(createPlasmaRing(256, Color.MEDIUMPURPLE, 12, 5.0), 1.9, 0.0, 0.25, true, "Plasma Envelope")); - flares.add(new FlareSprite(createRotatingSpikeImage(256, 32, Color.LIGHTPINK), 2.3, 0.0, 0.2, true, "Spikes")); + flares.add(new FlareSprite(FlarePatternFactory.createBlurredDiskImage(256, 100, Color.VIOLET, 0.4, 0, 0, 4), 1.0, 0.0, 0.35, true, "Warp Core")); + flares.add(new FlareSprite(FlarePatternFactory.createCoronaRing(256, Color.PURPLE, 32), 1.5, 0.0, 0.25, true, "Corona")); + flares.add(new FlareSprite(FlarePatternFactory.createPlasmaRing(256, Color.MEDIUMPURPLE, 12, 5.0), 1.9, 0.0, 0.25, true, "Plasma Envelope")); + flares.add(new FlareSprite(FlarePatternFactory.createRotatingSpikeImage(256, 32, Color.LIGHTPINK), 2.3, 0.0, 0.2, true, "Spikes")); Random rando = new Random(); // 🔗 Spiral Chain A for (int i = 0; i < 14; i++) { double pos = 0.4 + i * 0.18; - flares.add(new FlareSprite(createPlasmaRing(256, Color.HOTPINK, 12, 16), + flares.add(new FlareSprite(FlarePatternFactory.createPlasmaRing(256, Color.HOTPINK, 12, 16), 0.2 + i * 0.15, pos, rando.nextDouble(), true, "Chain Ring " + i)); } // 🔗 Spiral Chain B (Offset) for (int i = 0; i < 10; i++) { double pos = 0.6 + i * 0.22; - flares.add(new FlareSprite(createCoronaRing(256, Color.VIOLET, 12), + flares.add(new FlareSprite(FlarePatternFactory.createCoronaRing(256, Color.VIOLET, 12), 0.5 + i * 0.15, pos + 0.2, rando.nextDouble(), true, "Offset Echo " + i)); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/fun/solar/SunPositionControls.java b/src/main/java/edu/jhuapl/trinity/utils/fun/solar/SunPositionControls.java index 158dd49b..7140770a 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/fun/solar/SunPositionControls.java +++ b/src/main/java/edu/jhuapl/trinity/utils/fun/solar/SunPositionControls.java @@ -16,6 +16,7 @@ import static edu.jhuapl.trinity.javafx.events.EffectEvent.*; + /** * @author Sean Phillips */ diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java b/src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java new file mode 100644 index 00000000..ab6a93cd --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java @@ -0,0 +1,144 @@ +package edu.jhuapl.trinity.utils.graph; + +import java.util.Random; + +/** + * Simple 3D Fruchterman–Reingold force layout. + * - Uses optional edge weight matrix (symmetric) to scale attraction. + * - Adds a gravity term to avoid drift. + * - No JavaFX dependencies. + * + * @author Sean Phillips + */ +public final class ForceFrLayout3D implements GraphLayoutEngine { + + @Override + public double[][] layout(int n, + java.util.List labels, + double[][] distances, + double[][] weights, + GraphLayoutParams p) { + if (n <= 0) return new double[0][0]; + + // Positions + double[][] pos = new double[n][3]; + // Displacements + double[][] disp = new double[n][3]; + + // Init on a sphere (stable start) + initOnSphere(pos, p.radius); + + final double area = 8.0 * p.radius * p.radius; // not literal area in 3D; used in k + final double k = Math.cbrt(area / Math.max(1.0, n)); // 3D scale (heuristic) + + double t = p.step; // temperature + final Random rnd = new Random(1337); + + for (int iter = 0; iter < p.iterations; iter++) { + // zero displacements + for (int i = 0; i < n; i++) { + disp[i][0] = disp[i][1] = disp[i][2] = 0.0; + } + + // Repulsive forces (all pairs) + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + double dx = pos[i][0] - pos[j][0]; + double dy = pos[i][1] - pos[j][1]; + double dz = pos[i][2] - pos[j][2]; + double d2 = dx * dx + dy * dy + dz * dz + 1e-9; + double d = Math.sqrt(d2); + // Fr repulsion ~ (k^2 / d) + double f = (p.repulsion * k * k) / d; + double fx = f * dx / d; + double fy = f * dy / d; + double fz = f * dz / d; + + disp[i][0] += fx; + disp[i][1] += fy; + disp[i][2] += fz; + disp[j][0] -= fx; + disp[j][1] -= fy; + disp[j][2] -= fz; + } + } + + // Attractive forces (edges) + if (weights != null) { + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + double w = weights[i][j]; + if (w <= 0.0) continue; + double dx = pos[i][0] - pos[j][0]; + double dy = pos[i][1] - pos[j][1]; + double dz = pos[i][2] - pos[j][2]; + double d2 = dx * dx + dy * dy + dz * dz + 1e-9; + double d = Math.sqrt(d2); + // Fr attraction ~ (d^2 / k) + double f = p.attraction * w * (d2 / k); + double fx = f * dx / d; + double fy = f * dy / d; + double fz = f * dz / d; + + disp[i][0] -= fx; + disp[i][1] -= fy; + disp[i][2] -= fz; + disp[j][0] += fx; + disp[j][1] += fy; + disp[j][2] += fz; + } + } + } + + // Gravity (pull to origin) + if (p.gravity > 0) { + for (int i = 0; i < n; i++) { + double x = pos[i][0], y = pos[i][1], z = pos[i][2]; + disp[i][0] += -p.gravity * x; + disp[i][1] += -p.gravity * y; + disp[i][2] += -p.gravity * z; + } + } + + // Move with clipping to temperature t + for (int i = 0; i < n; i++) { + double dx = disp[i][0], dy = disp[i][1], dz = disp[i][2]; + double m = Math.sqrt(dx * dx + dy * dy + dz * dz) + 1e-9; + double lim = t / m; + pos[i][0] += dx * Math.min(1.0, lim); + pos[i][1] += dy * Math.min(1.0, lim); + pos[i][2] += dz * Math.min(1.0, lim); + } + + // Cool + t *= p.cooling; + if (t < 1e-4) t = 1e-4; + + // Tiny jitter to escape symmetry + if ((iter % 50) == 0) { + for (int i = 0; i < n; i++) { + pos[i][0] += (rnd.nextDouble() - 0.5) * 0.01; + pos[i][1] += (rnd.nextDouble() - 0.5) * 0.01; + pos[i][2] += (rnd.nextDouble() - 0.5) * 0.01; + } + } + } + return pos; + } + + private static void initOnSphere(double[][] pos, double r) { + final int n = pos.length; + // Fibonacci sphere distribution (nice uniform spread) + final double phi = Math.PI * (3.0 - Math.sqrt(5.0)); + for (int i = 0; i < n; i++) { + double y = 1.0 - (i / (double) (n - 1)) * 2.0; + double radius = Math.sqrt(1.0 - y * y); + double theta = phi * i; + double x = Math.cos(theta) * radius; + double z = Math.sin(theta) * radius; + pos[i][0] = r * x; + pos[i][1] = r * y; + pos[i][2] = r * z; + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutEngine.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutEngine.java new file mode 100644 index 00000000..6d20878a --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutEngine.java @@ -0,0 +1,27 @@ +package edu.jhuapl.trinity.utils.graph; + +import java.util.List; + +/** + * Generic 3D layout engine interface. + * + * @author Sean Phillips + */ +public interface GraphLayoutEngine { + + /** + * Compute 3D coordinates for N nodes. + * + * @param n number of nodes + * @param labels optional labels (length n) or null + * @param distances optional NxN distance matrix; may be null if the engine doesn’t need it + * @param weights optional NxN edge-weight matrix; may be null if the engine doesn’t need it + * @param params engine parameters (tunable) + * @return double[n][3] positions (x,y,z) in the same order as labels/nodes + */ + double[][] layout(int n, + List labels, + double[][] distances, + double[][] weights, + GraphLayoutParams params); +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java new file mode 100644 index 00000000..d68be5b3 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java @@ -0,0 +1,229 @@ +package edu.jhuapl.trinity.utils.graph; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Tunable parameters for 3D layouts (used by MDS wrapper and Force-FR). + *

+ * Extended to include edge-sparsification policies (ALL, KNN, EPSILON, MST_PLUS_K). + * + * @author Sean Phillips + */ +public final class GraphLayoutParams implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + // --------------------------------------------------------------------- + // Layout kinds + // --------------------------------------------------------------------- + + public enum LayoutKind { + CIRCLE_XZ, + CIRCLE_XY, + SPHERE, + MDS_3D, + FORCE_FR + } + + /** + * Which layout engine to use. + */ + public LayoutKind kind = LayoutKind.SPHERE; + + /** + * Radius / scale for static layouts and initial placement for force layouts. + */ + public double radius = 600.0; + + // --------------------------------------------------------------------- + // Force-FR knobs + // --------------------------------------------------------------------- + + /** + * Number of force iterations (ticks). + */ + public int iterations = 500; + /** + * Global step size / temperature start (will cool). + */ + public double step = 1.0; + /** + * Repulsion constant Cr (higher → more spread). + */ + public double repulsion = 900.0; + /** + * Attraction constant Ca (higher → tighter edges). + */ + public double attraction = 0.08; + /** + * Gravity pulls nodes toward origin to avoid drift. + */ + public double gravity = 0.02; + /** + * Cooling schedule factor (0..1). Smaller → faster cooling. + */ + public double cooling = 0.96; + + // --------------------------------------------------------------------- + // Edge building from matrix + // --------------------------------------------------------------------- + + /** + * Policies for selecting which edges to keep. + */ + public enum EdgePolicy { + /** + * Fully connected graph (all pairs, except diagonal). + */ + ALL, + /** + * k nearest neighbors per node. + */ + KNN, + /** + * Threshold by epsilon: keep edges with weight >= epsilon (similarity) or <= epsilon (divergence). + */ + EPSILON, + /** + * Minimum spanning tree plus k nearest neighbors. + */ + MST_PLUS_K + } + + /** + * Edge selection policy. + */ + public EdgePolicy edgePolicy = EdgePolicy.KNN; + + /** + * K for KNN and MST_PLUS_K. + */ + public int knnK = 8; + + /** + * When true, symmetrize KNN: keep edge if i∈N_k(j) OR j∈N_k(i). + */ + public boolean knnSymmetrize = true; + + /** + * Threshold epsilon (meaning depends on similarity vs divergence). + */ + public double epsilon = 0.75; + + /** + * Whether to build MST first (for MST_PLUS_K). + */ + public boolean buildMst = true; + + /** + * Keep at most this many edges total (after sparsification). + */ + public int maxEdges = 5000; + + /** + * Optional per-node degree cap; <=0 disables. + */ + public int maxDegreePerNode = 32; + + /** + * Cut edges below this weight (after transform). + */ + public double minEdgeWeight = 0.0; + + /** + * When true, normalize input matrix to [0,1] before transforms. + */ + public boolean normalizeWeights01 = true; + + // --------------------------------------------------------------------- + // Fluent builder-style setters + // --------------------------------------------------------------------- + + public GraphLayoutParams withKind(LayoutKind k) { + this.kind = k; + return this; + } + + public GraphLayoutParams withRadius(double r) { + this.radius = r; + return this; + } + + // Force + public GraphLayoutParams withIterations(int it) { + this.iterations = it; + return this; + } + + public GraphLayoutParams withStep(double s) { + this.step = s; + return this; + } + + public GraphLayoutParams withRepulsion(double r) { + this.repulsion = r; + return this; + } + + public GraphLayoutParams withAttraction(double a) { + this.attraction = a; + return this; + } + + public GraphLayoutParams withGravity(double g) { + this.gravity = g; + return this; + } + + public GraphLayoutParams withCooling(double c) { + this.cooling = c; + return this; + } + + // Edges + public GraphLayoutParams withEdgePolicy(EdgePolicy p) { + this.edgePolicy = p; + return this; + } + + public GraphLayoutParams withKnnK(int k) { + this.knnK = k; + return this; + } + + public GraphLayoutParams withKnnSymmetrize(boolean s) { + this.knnSymmetrize = s; + return this; + } + + public GraphLayoutParams withEpsilon(double e) { + this.epsilon = e; + return this; + } + + public GraphLayoutParams withBuildMst(boolean b) { + this.buildMst = b; + return this; + } + + public GraphLayoutParams withMaxEdges(int e) { + this.maxEdges = e; + return this; + } + + public GraphLayoutParams withMaxDegreePerNode(int d) { + this.maxDegreePerNode = d; + return this; + } + + public GraphLayoutParams withMinEdgeWeight(double w) { + this.minEdgeWeight = w; + return this; + } + + public GraphLayoutParams withNormalizeWeights01(boolean on) { + this.normalizeWeights01 = on; + return this; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java new file mode 100644 index 00000000..1261405d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java @@ -0,0 +1,71 @@ +package edu.jhuapl.trinity.utils.graph; + +import javafx.scene.paint.Color; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Visual styling parameters for 3D graph rendering. + */ +public final class GraphStyleParams implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + // Nodes + public Color nodeColor = Color.CYAN; // default aligned with Graph3DRenderer.Params + public double nodeRadius = 20.0; + public double nodeOpacity = 1.0; // [0..1] + + // Edges + public Color edgeColor = Color.ALICEBLUE; // default aligned with Graph3DRenderer.Params + public double edgeWidth = 8.0; // rendered as float in Tracer + public double edgeOpacity = 1.0; // [0..1] + + // Fluent setters + public GraphStyleParams withNodeColor(Color c) { + this.nodeColor = c; + return this; + } + + public GraphStyleParams withNodeRadius(double r) { + this.nodeRadius = r; + return this; + } + + public GraphStyleParams withNodeOpacity(double o) { + this.nodeOpacity = o; + return this; + } + + public GraphStyleParams withEdgeColor(Color c) { + this.edgeColor = c; + return this; + } + + public GraphStyleParams withEdgeWidth(double w) { + this.edgeWidth = w; + return this; + } + + public GraphStyleParams withEdgeOpacity(double o) { + this.edgeOpacity = o; + return this; + } + + // Shallow copy helpers + public static GraphStyleParams copyOf(GraphStyleParams src) { + GraphStyleParams p = new GraphStyleParams(); + if (src == null) { + return p; + } + p.nodeColor = src.nodeColor; + p.nodeRadius = src.nodeRadius; + p.nodeOpacity = src.nodeOpacity; + p.edgeColor = src.edgeColor; + p.edgeWidth = src.edgeWidth; + p.edgeOpacity = src.edgeOpacity; + return p; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java new file mode 100644 index 00000000..811629fa --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java @@ -0,0 +1,529 @@ +package edu.jhuapl.trinity.utils.graph; + +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import edu.jhuapl.trinity.data.graph.GraphNode; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.PriorityQueue; +import java.util.Set; + +/** + * MatrixToGraphAdapter + * -------------------- + * Converts a similarity or divergence matrix into a GraphDirectedCollection, + * and computes node positions using a chosen layout (Static, MDS-3D, Force-FR). + *

+ * New: supports edge sparsification via GraphLayoutParams.EdgePolicy: + * - ALL: fully connected (except diagonal) + * - KNN: k-nearest neighbors per node (with optional symmetrization) + * - EPSILON: keep edges with weight >= epsilon (uses transformed weights) + * - MST_PLUS_K: minimum spanning tree (built on 1/weight distances) plus KNN edges + *

+ * Notes: + * • We convert the input matrix to a symmetric, non-negative "weight" matrix first + * using {@link WeightMode} and {@link MatrixKind}. Policies operate on these weights. + * • For MDS, we build a distance matrix via {@link #toDistances(double[][], MatrixKind)}. + * • For MST, we use distances derived from the weight matrix: dist = 1/(eps + w). + * + * @author Sean Phillips + */ +public final class MatrixToGraphAdapter { + + public enum MatrixKind {SIMILARITY, DIVERGENCE} + + /** + * How to turn matrix entries into edge weights for the graph. + * SIMILARITY: higher means stronger attraction; DIVERGENCE: lower means stronger attraction. + */ + public enum WeightMode { + /** + * Use raw entries (optionally normalized 0..1). + */ + DIRECT, + /** + * For divergence: w = 1 / (eps + d). For similarity: same as DIRECT. + */ + INVERSE_FOR_DIVERGENCE + } + + /** + * Pluggable MDS-3D embedding. Return double[n][3] positions. + * Bring your own implementation and pass it into build(). + */ + public interface MdsEmbedding3D { + double[][] embed(double[][] distances, List labels); + } + + public static GraphDirectedCollection build(double[][] matrix, + List labels, + MatrixKind kind, + GraphLayoutParams layoutParams, + WeightMode weightMode, + MdsEmbedding3D mds3d) { + int n = (matrix == null) ? 0 : matrix.length; + if (n == 0) return emptyGraph(); + + // Defensive checks + for (int i = 0; i < n; i++) { + if (matrix[i] == null || matrix[i].length != n) { + throw new IllegalArgumentException("Matrix must be square and non-null."); + } + } + if (labels == null || labels.size() != n) labels = defaultLabels(n); + + // 1) Prepare a weight matrix for edges (symmetric, non-negative). + double[][] weights = toEdgeWeights(matrix, kind, weightMode, layoutParams.normalizeWeights01); + + // 2) Build edges using selected policy and post-constraints + List edgeList = buildEdgesByPolicy(weights, layoutParams, kind); + + // 3) Choose / run layout + double[][] pos; + switch (layoutParams.kind) { + case CIRCLE_XZ -> pos = StaticLayouts.circleXZ(n, layoutParams.radius); + case CIRCLE_XY -> pos = StaticLayouts.circleXY(n, layoutParams.radius); + case SPHERE -> pos = StaticLayouts.sphere(n, layoutParams.radius); + case MDS_3D -> { + // Need distances for MDS + double[][] distances = toDistances(matrix, kind); + if (mds3d == null) { + // Fallback to sphere if no MDS provided + pos = StaticLayouts.sphere(n, layoutParams.radius); + } else { + pos = mds3d.embed(distances, labels); + pos = normalizeToRadius(pos, layoutParams.radius); + } + } + case FORCE_FR -> { + GraphLayoutEngine fr = new ForceFrLayout3D(); + pos = fr.layout(n, labels, null, weights, layoutParams); + pos = normalizeToRadius(pos, layoutParams.radius); // optional post-scale + } + default -> pos = StaticLayouts.sphere(n, layoutParams.radius); + } + + // 4) Emit GraphDirectedCollection + GraphDirectedCollection gc = new GraphDirectedCollection(); + gc.setGraphId((kind == MatrixKind.SIMILARITY ? "similarity" : "divergence") + "_N" + n); + gc.setDefaultNodeColor("#33B5E5FF"); // cyan-ish default + gc.setDefaultEdgeColor("#A0A0A0FF"); // grey default + + // Nodes + List nodes = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + GraphNode gn = new GraphNode(); + gn.setEntityID("n" + i); + ArrayList vec = new ArrayList<>(3); + vec.add(pos[i][0]); + vec.add(pos[i][1]); + vec.add(pos[i][2]); + gn.setVector(vec); + + ArrayList lbls = new ArrayList<>(1); + lbls.add(labels.get(i)); + gn.setLabels(lbls); + + nodes.add(gn); + } + + // Edges + List edges = new ArrayList<>(edgeList.size()); + for (EdgeRec e : edgeList) { + GraphEdge ge = new GraphEdge(); + ge.setStartID("n" + e.i); + ge.setEndID("n" + e.j); + ge.setColor(null); // use default or color by weight in renderer + edges.add(ge); + } + + gc.setNodes(nodes); + gc.setEdges(edges); + return gc; + } + + // ===== Helpers ===== + + private static GraphDirectedCollection emptyGraph() { + GraphDirectedCollection gc = new GraphDirectedCollection(); + gc.setGraphId("empty"); + gc.setNodes(new ArrayList<>()); + gc.setEdges(new ArrayList<>()); + return gc; + } + + private static List defaultLabels(int n) { + List out = new ArrayList<>(n); + for (int i = 0; i < n; i++) out.add("F" + i); + return out; + } + + /** + * Build symmetric weight matrix for edges from similarity/divergence. + */ + private static double[][] toEdgeWeights(double[][] m, MatrixKind kind, WeightMode wmode, boolean normalize01) { + final int n = m.length; + double[][] w = new double[n][n]; + + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + if (normalize01) { + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + double v = m[i][j]; + if (Double.isFinite(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + } + if (!(max > min)) { + max = min + 1e-9; + } + } + + for (int i = 0; i < n; i++) { + w[i][i] = 0.0; + for (int j = i + 1; j < n; j++) { + double v = m[i][j]; + if (normalize01) { + v = (v - min) / (max - min); + v = Math.max(0.0, Math.min(1.0, v)); + } + + double wij; + if (kind == MatrixKind.SIMILARITY) { + // Similarity: larger value → stronger edge + wij = v; + } else { + // Divergence: interpret smaller value as "closer" → stronger edge + if (wmode == WeightMode.INVERSE_FOR_DIVERGENCE) { + wij = 1.0 / (1e-9 + v); // map small d -> big weight + } else { + wij = 1.0 - v; // map d in [0,1] -> weight (invert) + } + if (wij < 0) wij = 0; + } + w[i][j] = w[j][i] = wij; + } + } + return w; + } + + /** + * Distances for MDS: for similarity convert to distance; for divergence use as-is. + */ + private static double[][] toDistances(double[][] m, MatrixKind kind) { + final int n = m.length; + double[][] d = new double[n][n]; + + // Find min/max for similarity normalize + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + if (kind == MatrixKind.SIMILARITY) { + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + double v = m[i][j]; + if (Double.isFinite(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + } + if (!(max > min)) { + max = min + 1e-9; + } + } + + for (int i = 0; i < n; i++) { + d[i][i] = 0.0; + for (int j = i + 1; j < n; j++) { + double dij; + if (kind == MatrixKind.SIMILARITY) { + double s = (m[i][j] - min) / (max - min); + s = Math.max(0.0, Math.min(1.0, s)); + // Correlation-like → distance; sqrt(2(1-s)) preserves correlations to Euclidean + dij = Math.sqrt(Math.max(0.0, 2.0 * (1.0 - s))); + } else { + // Divergence in [0,1] already acts like a distance + dij = Math.max(0.0, m[i][j]); + } + d[i][j] = d[j][i] = dij; + } + } + return d; + } + + // ===== Edge policies ===== + + private static List buildEdgesByPolicy(double[][] weights, + GraphLayoutParams lp, + MatrixKind kind) { + return switch (lp.edgePolicy) { + case ALL -> selectAll(weights, lp); + case KNN -> selectKnn(weights, lp); + case EPSILON -> selectByEpsilon(weights, lp); + case MST_PLUS_K -> selectMstPlusK(weights, lp); + }; + } + + /** + * ALL: keep all i selectAll(double[][] w, GraphLayoutParams lp) { + int n = w.length; + List edges = new ArrayList<>(); + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + double wij = w[i][j]; + if (wij > lp.minEdgeWeight) edges.add(new EdgeRec(i, j, wij)); + } + } + // Sort by weight desc and enforce caps + edges.sort(Comparator.comparingDouble((EdgeRec e) -> e.w).reversed()); + return applyGlobalAndDegreeCaps(edges, n, lp.maxEdges, lp.maxDegreePerNode); + // (Note: this still can be dense; caps limit total/degree.) + } + + /** + * KNN: per node, take top-k neighbors by weight; union; optional symmetrization; then caps. + */ + private static List selectKnn(double[][] w, GraphLayoutParams lp) { + int n = w.length; + int k = Math.max(1, lp.knnK); + // Collect neighbors + Set edgeSet = new HashSet<>(); // packed (min,max) -> key + for (int i = 0; i < n; i++) { + // top-k for row i + PriorityQueue pq = new PriorityQueue<>(Comparator.comparingDouble(a -> a[1])); + for (int j = 0; j < n; j++) { + if (j == i) continue; + double wij = w[i][j]; + if (wij <= lp.minEdgeWeight) continue; + if (pq.size() < k) { + pq.offer(new int[]{j, (int) Double.doubleToLongBits(wij)}); + } else { + double smallest = Double.longBitsToDouble(pq.peek()[1]); + if (wij > smallest) { + pq.poll(); + pq.offer(new int[]{j, (int) Double.doubleToLongBits(wij)}); + } + } + } + // add edges + while (!pq.isEmpty()) { + int[] entry = pq.poll(); + int j = entry[0]; + double wij = Double.longBitsToDouble(entry[1]); + addUndirectedEdgeKey(edgeSet, i, j); + } + } + + if (lp.knnSymmetrize) { + // ensure i∈N_k(j) OR j∈N_k(i) → keep both directions already covered by union. + // (The union above already covers both endpoints’ selections.) + } + + List edges = unpackEdgeSet(edgeSet, w); + // Sort by weight desc & apply degree/global caps + edges.sort(Comparator.comparingDouble((EdgeRec e) -> e.w).reversed()); + return applyGlobalAndDegreeCaps(edges, n, lp.maxEdges, lp.maxDegreePerNode); + } + + /** + * EPSILON: keep all edges with weight >= epsilon (after transforms), then caps. + */ + private static List selectByEpsilon(double[][] w, GraphLayoutParams lp) { + int n = w.length; + double thr = lp.epsilon; + List edges = new ArrayList<>(); + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + double wij = w[i][j]; + if (wij >= thr && wij > lp.minEdgeWeight) + edges.add(new EdgeRec(i, j, wij)); + } + } + edges.sort(Comparator.comparingDouble((EdgeRec e) -> e.w).reversed()); + return applyGlobalAndDegreeCaps(edges, n, lp.maxEdges, lp.maxDegreePerNode); + } + + /** + * MST_PLUS_K: build MST on distances (1/(eps+w)), then union with KNN(k), then caps. + */ + private static List selectMstPlusK(double[][] w, GraphLayoutParams lp) { + int n = w.length; + // Distances from weights (avoid div by 0) + final double EPS = 1e-9; + double[][] dist = new double[n][n]; + for (int i = 0; i < n; i++) { + dist[i][i] = 0.0; + for (int j = i + 1; j < n; j++) { + double wij = w[i][j]; + double dij = 1.0 / (EPS + wij); // large when weight small + dist[i][j] = dist[j][i] = dij; + } + } + + // Build MST via Kruskal on (i,j,dist) ascending + List mst = kruskalMstFromDistances(dist, w); + + // Union with KNN edges + List knn = selectKnn(w, lp); // this already applies caps; we want raw KNN edges first + // Make a set to union without duplicates + Set edgeSet = new HashSet<>(); + for (EdgeRec e : mst) addUndirectedEdgeKey(edgeSet, e.i, e.j); + for (EdgeRec e : knn) addUndirectedEdgeKey(edgeSet, e.i, e.j); + + List union = unpackEdgeSet(edgeSet, w); + // Sort by weight desc & then apply final caps + union.sort(Comparator.comparingDouble((EdgeRec e) -> e.w).reversed()); + return applyGlobalAndDegreeCaps(union, n, lp.maxEdges, lp.maxDegreePerNode); + } + + // ===== Edge utilities ===== + + private static List kruskalMstFromDistances(double[][] dist, double[][] weights) { + int n = dist.length; + List all = new ArrayList<>(); + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + all.add(new MstEdge(i, j, dist[i][j])); + } + } + all.sort(Comparator.comparingDouble(e -> e.d)); + + UnionFind uf = new UnionFind(n); + List out = new ArrayList<>(n - 1); + for (MstEdge e : all) { + if (uf.union(e.i, e.j)) { + out.add(new EdgeRec(e.i, e.j, weights[e.i][e.j])); + if (out.size() == n - 1) break; + } + } + return out; + } + + private static void addUndirectedEdgeKey(Set set, int i, int j) { + int a = Math.min(i, j), b = Math.max(i, j); + long key = (((long) a) << 32) ^ (long) b; + set.add(key); + } + + private static List unpackEdgeSet(Set set, double[][] w) { + List list = new ArrayList<>(set.size()); + for (long key : set) { + int i = (int) (key >> 32); + int j = (int) (key & 0xFFFFFFFFL); + list.add(new EdgeRec(i, j, w[i][j])); + } + return list; + } + + private static List applyGlobalAndDegreeCaps(List edges, int n, int maxEdges, int maxDeg) { + if (edges.isEmpty()) return edges; + int[] deg = new int[n]; + List out = new ArrayList<>(Math.min(edges.size(), Math.max(1, maxEdges))); + for (EdgeRec e : edges) { + if (maxEdges > 0 && out.size() >= maxEdges) break; + if (maxDeg > 0 && (deg[e.i] >= maxDeg || deg[e.j] >= maxDeg)) continue; + out.add(e); + deg[e.i]++; + deg[e.j]++; + } + return out; + } + + private record EdgeRec(int i, int j, double w) { + } + + private record MstEdge(int i, int j, double d) { + } + + private static final class UnionFind { + private final int[] p, r; + + UnionFind(int n) { + p = new int[n]; + r = new int[n]; + for (int i = 0; i < n; i++) p[i] = i; + } + + int find(int x) { + return p[x] == x ? x : (p[x] = find(p[x])); + } + + boolean union(int a, int b) { + int pa = find(a), pb = find(b); + if (pa == pb) return false; + if (r[pa] < r[pb]) { + p[pa] = pb; + } else if (r[pa] > r[pb]) { + p[pb] = pa; + } else { + p[pb] = pa; + r[pa]++; + } + return true; + } + } + + // ---------- Static layouts & helpers ---------- + + private static final class StaticLayouts { + static double[][] circleXZ(int n, double r) { + double[][] p = new double[n][3]; + for (int i = 0; i < n; i++) { + double a = (2 * Math.PI * i) / Math.max(1, n); + p[i][0] = r * Math.cos(a); + p[i][1] = 0.0; + p[i][2] = r * Math.sin(a); + } + return p; + } + + static double[][] circleXY(int n, double r) { + double[][] p = new double[n][3]; + for (int i = 0; i < n; i++) { + double a = (2 * Math.PI * i) / Math.max(1, n); + p[i][0] = r * Math.cos(a); + p[i][1] = r * Math.sin(a); + p[i][2] = 0.0; + } + return p; + } + + static double[][] sphere(int n, double r) { + double[][] p = new double[n][3]; + final double phi = Math.PI * (3.0 - Math.sqrt(5.0)); + for (int i = 0; i < n; i++) { + double y = 1.0 - (i / (double) (Math.max(1, n - 1))) * 2.0; + double radius = Math.sqrt(Math.max(0.0, 1.0 - y * y)); + double theta = phi * i; + double x = Math.cos(theta) * radius; + double z = Math.sin(theta) * radius; + p[i][0] = r * x; + p[i][1] = r * y; + p[i][2] = r * z; + } + return p; + } + } + + private static double[][] normalizeToRadius(double[][] pos, double radius) { + double s2 = 0.0; + int n = pos.length; + for (int i = 0; i < n; i++) s2 += pos[i][0] * pos[i][0] + pos[i][1] * pos[i][1] + pos[i][2] * pos[i][2]; + double rms = Math.sqrt(s2 / Math.max(1, n)); + double scale = (rms > 1e-9) ? (radius / rms) : 1.0; + if (scale != 1.0) { + for (int i = 0; i < n; i++) { + pos[i][0] *= scale; + pos[i][1] *= scale; + pos[i][2] *= scale; + } + } + return pos; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/SuperMdsEmbedding3D.java b/src/main/java/edu/jhuapl/trinity/utils/graph/SuperMdsEmbedding3D.java new file mode 100644 index 00000000..935dd4f3 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/SuperMdsEmbedding3D.java @@ -0,0 +1,46 @@ +package edu.jhuapl.trinity.utils.graph; + +import com.github.trinity.supermds.SuperMDS; +import com.github.trinity.supermds.SuperMDS.Params; +import com.github.trinity.supermds.SuperMDSHelper; + +import java.util.List; + +/** + * SuperMdsEmbedding3D + * ------------------- + * Pluggable adapter that uses SuperMDS to produce a 3D embedding from a distance matrix. + *

+ * Input must be an NxN distance matrix (symmetric, non-negative). We normalize it + * similarly to your ProjectMdsFeaturesTask before running MDS. + * + * @author Sean Phillips + */ +public final class SuperMdsEmbedding3D implements MatrixToGraphAdapter.MdsEmbedding3D { + + private final Params params; + + public SuperMdsEmbedding3D(Params params) { + if (params == null) { + throw new IllegalArgumentException("SuperMDS Params must not be null."); + } + this.params = params; + } + + @Override + public double[][] embed(double[][] distances, List labels) { + if (distances == null || distances.length == 0) return new double[0][0]; + + // SuperMDS expects distances; normalize for stability (matches your example task). + double[][] normalized = SuperMDSHelper.normalizeDistancesParallel(distances); + + // Run SuperMDS with whatever mode/knobs you pass in Params; outputDim should be 3. + double[][] embedding = SuperMDS.runMDS(normalized, params); + + // Ensure shape [n][3]; SuperMDS should already do this if params.outputDim == 3. + if (embedding.length != distances.length || embedding[0].length < 3) { + throw new IllegalStateException("SuperMDS returned unexpected shape; expected [n][3]."); + } + return embedding; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java new file mode 100644 index 00000000..59edf55d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java @@ -0,0 +1,312 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * AbComparisonEngine + * ------------------ + * Computes aligned baseline PDF/CDF surfaces for cohorts A and B on the same axes, + * then (optionally) produces signed difference maps (A - B). Bins come from the recipe; + * bounds come from the recipe policy (unioned across cohorts for canonical alignment). + *

+ * This class does NOT select axis pairs; it assumes the caller already chose (xAxis,yAxis). + * For batch pair selection use JpdfBatchEngine (which can call into this for A/B). + *

+ * Outputs: + * - Baseline A and B GridDensityResult (PDF and/or CDF depending on recipe) + * - Signed difference maps as List> (PDF and/or CDF), same grid + * - JpdfProvenance for each produced surface + *

+ * Notes: + * - We align both cohorts on one canonical GridSpec to ensure apples-to-apples differences. + * - Bounds are the union of each cohort's axis ranges under the chosen CanonicalGridPolicy. + * - When cache is provided & enabled in the recipe, we use it; otherwise compute directly. + * + * @author Sean Phillips + */ +public final class ABComparisonEngine { + + /** + * Immutable result bundle for a single A/B compare on one (x,y) pair. + */ + public static final class AbResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public final GridSpec grid; + + public final GridDensityResult a; // baseline A (may contain both PDF/CDF) + public final GridDensityResult b; // baseline B + + public final List> pdfDiff; // A.pdf - B.pdf (nullable if not requested) + public final List> cdfDiff; // A.cdf - B.cdf (nullable if not requested) + + public final JpdfProvenance pdfProvA; // nullable when recipe does not request PDF + public final JpdfProvenance pdfProvB; // nullable when recipe does not request PDF + public final JpdfProvenance pdfProvDiff; // nullable when recipe does not request PDF + + public final JpdfProvenance cdfProvA; // nullable when recipe does not request CDF + public final JpdfProvenance cdfProvB; // nullable when recipe does not request CDF + public final JpdfProvenance cdfProvDiff; // nullable when recipe does not request CDF + + public AbResult(GridSpec grid, + GridDensityResult a, + GridDensityResult b, + List> pdfDiff, + List> cdfDiff, + JpdfProvenance pdfProvA, + JpdfProvenance pdfProvB, + JpdfProvenance pdfProvDiff, + JpdfProvenance cdfProvA, + JpdfProvenance cdfProvB, + JpdfProvenance cdfProvDiff) { + this.grid = grid; + this.a = a; + this.b = b; + this.pdfDiff = pdfDiff; + this.cdfDiff = cdfDiff; + this.pdfProvA = pdfProvA; + this.pdfProvB = pdfProvB; + this.pdfProvDiff = pdfProvDiff; + this.cdfProvA = cdfProvA; + this.cdfProvB = cdfProvB; + this.cdfProvDiff = cdfProvDiff; + } + } + + private ABComparisonEngine() { + } + + /** + * Compute an A/B comparison for given axes. Uses recipe bins & bounds policy. + * + * @param aVectors cohort A + * @param bVectors cohort B + * @param xAxis X axis params + * @param yAxis Y axis params + * @param recipe Jpdf recipe (bins/bounds/output-kind/cache flags) + * @param cache optional cache (respected if recipe.isCacheEnabled()==true) + * @param datasetIdA optional stable ID for cohort A (for cache keying) + * @param datasetIdB optional stable ID for cohort B (for cache keying) + */ + public static AbResult compare(List aVectors, + List bVectors, + AxisParams xAxis, + AxisParams yAxis, + JpdfRecipe recipe, + DensityCache cache, + String datasetIdA, + String datasetIdB) { + + Objects.requireNonNull(xAxis, "xAxis"); + Objects.requireNonNull(yAxis, "yAxis"); + Objects.requireNonNull(recipe, "recipe"); + + // 1) Build a single canonical grid shared by A and B. + GridSpec grid = buildAlignedGrid(aVectors, bVectors, xAxis, yAxis, recipe); + + // 2) Compute baselines (A and B) using cache if available/allowed. + boolean useCache = cache != null && recipe.isCacheEnabled(); + GridDensityResult resA = useCache + ? cache.getOrCompute(aVectors, xAxis, yAxis, grid, datasetIdA) + : GridDensity3DEngine.computePdfCdf2D(aVectors, xAxis, yAxis, grid); + + GridDensityResult resB = useCache + ? cache.getOrCompute(bVectors, xAxis, yAxis, grid, datasetIdB) + : GridDensity3DEngine.computePdfCdf2D(bVectors, xAxis, yAxis, grid); + + // 3) Optionally build signed differences. + boolean needPdf = recipe.getOutputKind() == JpdfRecipe.OutputKind.PDF_ONLY + || recipe.getOutputKind() == JpdfRecipe.OutputKind.PDF_AND_CDF; + boolean needCdf = recipe.getOutputKind() == JpdfRecipe.OutputKind.CDF_ONLY + || recipe.getOutputKind() == JpdfRecipe.OutputKind.PDF_AND_CDF; + + List> pdfDiff = null; + List> cdfDiff = null; + + if (needPdf) { + List> aPdf = resA.pdfAsListGrid(); + List> bPdf = resB.pdfAsListGrid(); + pdfDiff = subtractGrids(aPdf, bPdf); + } + if (needCdf) { + List> aCdf = resA.cdfAsListGrid(); + List> bCdf = resB.cdfAsListGrid(); + cdfDiff = subtractGrids(aCdf, bCdf); + } + + // 4) Provenance for A, B, and Diff (PDF and CDF separately when requested). + String recipeName = recipe.getName(); + String cohortA = recipe.getCohortALabel(); + String cohortB = recipe.getCohortBLabel(); + + // Axis & grid summaries reused across provs + JpdfProvenance.AxisSummary xSum = toAxisSummary(xAxis); + JpdfProvenance.AxisSummary ySum = toAxisSummary(yAxis); + JpdfProvenance.GridSummary gSum = toGridSummary(grid, recipe); + + JpdfProvenance pdfProvA = null, pdfProvB = null, pdfProvDiff = null; + JpdfProvenance cdfProvA = null, cdfProvB = null, cdfProvDiff = null; + + if (needPdf) { + pdfProvA = JpdfProvenance.baselinePdf(xSum, ySum, gSum, recipeName, cohortA); + pdfProvB = JpdfProvenance.baselinePdf(xSum, ySum, gSum, recipeName, cohortB); + pdfProvDiff = JpdfProvenance.signedDiff(xSum, ySum, gSum, recipeName, cohortA, cohortB); + } + if (needCdf) { + cdfProvA = JpdfProvenance.baselineCdf(xSum, ySum, gSum, recipeName, cohortA); + cdfProvB = JpdfProvenance.baselineCdf(xSum, ySum, gSum, recipeName, cohortB); + cdfProvDiff = JpdfProvenance.signedDiff(xSum, ySum, gSum, recipeName, cohortA, cohortB); + } + + return new AbResult( + grid, + resA, + resB, + pdfDiff, + cdfDiff, + pdfProvA, + pdfProvB, + pdfProvDiff, + cdfProvA, + cdfProvB, + cdfProvDiff + ); + } + + // ------------------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------------------- + + /** + * Build one canonical grid for BOTH cohorts using the recipe's bounds policy and bins. + */ + private static GridSpec buildAlignedGrid(List aVectors, + List bVectors, + AxisParams xAxis, + AxisParams yAxis, + JpdfRecipe recipe) { + int bx = recipe.getBinsX(); + int by = recipe.getBinsY(); + + GridSpec g = new GridSpec(bx, by); + + switch (recipe.getBoundsPolicy()) { + case FIXED_01 -> { + g.setMinX(0.0); + g.setMaxX(1.0); + g.setMinY(0.0); + g.setMaxY(1.0); + } + case DATA_MIN_MAX -> { + // derive per-cohort ranges then take union + CanonicalGridPolicy policy = CanonicalGridPolicy.get("minmax"); + CanonicalGridPolicy.AxisRange rxA = policy.axisRange(aVectors, xAxis, "A"); + CanonicalGridPolicy.AxisRange ryA = policy.axisRange(aVectors, yAxis, "A"); + CanonicalGridPolicy.AxisRange rxB = policy.axisRange(bVectors, xAxis, "B"); + CanonicalGridPolicy.AxisRange ryB = policy.axisRange(bVectors, yAxis, "B"); + g.setMinX(Math.min(rxA.min, rxB.min)); + g.setMaxX(Math.max(rxA.max, rxB.max)); + g.setMinY(Math.min(ryA.min, ryB.min)); + g.setMaxY(Math.max(ryA.max, ryB.max)); + } + case CANONICAL_BY_FEATURE -> { + // Use the named policy, but union A/B axis ranges to avoid clipping one cohort. + CanonicalGridPolicy policy = CanonicalGridPolicy.get(recipe.getCanonicalPolicyId()); + CanonicalGridPolicy.AxisRange rxA = policy.axisRange(aVectors, xAxis, "A"); + CanonicalGridPolicy.AxisRange ryA = policy.axisRange(aVectors, yAxis, "A"); + CanonicalGridPolicy.AxisRange rxB = policy.axisRange(bVectors, xAxis, "B"); + CanonicalGridPolicy.AxisRange ryB = policy.axisRange(bVectors, yAxis, "B"); + g.setMinX(Math.min(rxA.min, rxB.min)); + g.setMaxX(Math.max(rxA.max, rxB.max)); + g.setMinY(Math.min(ryA.min, ryB.min)); + g.setMaxY(Math.max(ryA.max, ryB.max)); + } + } + return g; + } + + /** + * Create a provenance AxisSummary from AxisParams (best-effort without extra context). + */ + private static JpdfProvenance.AxisSummary toAxisSummary(AxisParams a) { + // We don't know if a "mean" or "vector@idx" was used; treat METRIC reference as CUSTOM when present. + JpdfProvenance.AxisSummary.ReferenceKind refKind; + Integer refIndex = null; + + if (a.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + refKind = (a.getReferenceVec() != null) ? JpdfProvenance.AxisSummary.ReferenceKind.CUSTOM + : JpdfProvenance.AxisSummary.ReferenceKind.NONE; + } else if (a.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + refKind = JpdfProvenance.AxisSummary.ReferenceKind.NONE; + } else { + refKind = JpdfProvenance.AxisSummary.ReferenceKind.NONE; + } + + String label = a.getMetricName(); // harmless display hint; may be null + return new JpdfProvenance.AxisSummary( + a.getType(), + a.getMetricName(), + a.getComponentIndex(), + refKind, + refIndex, + label + ); + } + + /** + * Convert GridSpec + recipe bounds policy into a provenance GridSummary. + */ + private static JpdfProvenance.GridSummary toGridSummary(GridSpec g, JpdfRecipe recipe) { + int bx = g.getBinsX(); + int by = g.getBinsY(); + double minX = nz(g.getMinX(), 0.0); + double maxX = nz(g.getMaxX(), 1.0); + double minY = nz(g.getMinY(), 0.0); + double maxY = nz(g.getMaxY(), 1.0); + double dx = (maxX - minX) / Math.max(1, bx); + double dy = (maxY - minY) / Math.max(1, by); + + JpdfProvenance.BoundsPolicy bp = switch (recipe.getBoundsPolicy()) { + case DATA_MIN_MAX -> JpdfProvenance.BoundsPolicy.DATA_MIN_MAX; + case FIXED_01 -> JpdfProvenance.BoundsPolicy.FIXED_01; + case CANONICAL_BY_FEATURE -> JpdfProvenance.BoundsPolicy.CANONICAL_BY_FEATURE; + }; + + return new JpdfProvenance.GridSummary( + bx, by, + minX, maxX, minY, maxY, + dx, dy, + bp, + recipe.getCanonicalPolicyId() + ); + } + + private static double nz(Double v, double dflt) { + return v == null ? dflt : v; + } + + /** + * Element-wise A - B (assumes equal shape). Returns null if either is null. + */ + private static List> subtractGrids(List> a, List> b) { + if (a == null || b == null) return null; + int rows = Math.min(a.size(), b.size()); + List> out = new ArrayList<>(rows); + for (int r = 0; r < rows; r++) { + List ar = a.get(r); + List br = b.get(r); + int cols = Math.min(ar.size(), br.size()); + List rr = new ArrayList<>(cols); + for (int c = 0; c < cols; c++) rr.add(ar.get(c) - br.get(c)); + out.add(rr); + } + return out; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java new file mode 100644 index 00000000..c9b53965 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java @@ -0,0 +1,85 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.List; +import java.util.Objects; + +public class AxisParams { + private StatisticEngine.ScalarType type; + private String metricName; // for METRIC_DISTANCE_TO_MEAN + private List referenceVec; // for METRIC_DISTANCE_TO_MEAN + private Integer componentIndex; // for COMPONENT_AT_DIMENSION + + public AxisParams() { + + } + + public AxisParams(StatisticEngine.ScalarType type, + String metricName, + List referenceVec, + Integer componentIndex) { + this.type = Objects.requireNonNull(type, "type"); + this.metricName = metricName; + this.referenceVec = referenceVec; + this.componentIndex = componentIndex; + } + + /** + * @return the type + */ + public StatisticEngine.ScalarType getType() { + return type; + } + + /** + * @param type the type to set + */ + public void setType(StatisticEngine.ScalarType type) { + this.type = type; + } + + public static AxisParams of(StatisticEngine.ScalarType type) { + return new AxisParams(type, null, null, null); + } + + /** + * @return the metricName + */ + public String getMetricName() { + return metricName; + } + + /** + * @return the referenceVec + */ + public List getReferenceVec() { + return referenceVec; + } + + /** + * @return the componentIndex + */ + public Integer getComponentIndex() { + return componentIndex; + } + + /** + * @param metricName the metricName to set + */ + public void setMetricName(String metricName) { + this.metricName = metricName; + } + + /** + * @param referenceVec the referenceVec to set + */ + public void setReferenceVec(List referenceVec) { + this.referenceVec = referenceVec; + } + + /** + * @param componentIndex the componentIndex to set + */ + public void setComponentIndex(Integer componentIndex) { + this.componentIndex = componentIndex; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java new file mode 100644 index 00000000..67434a04 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java @@ -0,0 +1,486 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.AnalysisUtils; +import edu.jhuapl.trinity.utils.metric.Metric; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * CanonicalGridPolicy + * ------------------- + * Produces consistent 2D grid specs (bounds + bins) for joint PDF/CDF surfaces + * given axis definitions (AxisParams) and a dataset (FeatureVectors). + *

+ * Modes: + * - FIXED_01: [0,1] x [0,1] + * - DATA_MIN_MAX: per-axis min..max from data + * - ROBUST_PCT: per-axis [pLow..pHigh] percentiles (robust to outliers) + *

+ * Per-axis overrides (by AxisKey) can supply explicit min/max and/or bins. + * A static registry allows lookup by policy id (e.g., "default"). + *

+ * Note: This class re-derives axis scalars (x_i or y_i) in order to compute bounds. + * It mirrors the scalar extraction used in GridDensity3DEngine. + * + * @author Sean Phillips + */ +public final class CanonicalGridPolicy implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + // ---------- Modes ---------- + + public enum Mode { + FIXED_01, + DATA_MIN_MAX, + ROBUST_PCT // [pLow, pHigh] + } + + // ---------- Axis identity & overrides ---------- + + /** + * AxisKey identifies the semantic axis (ScalarType + metric/component/ref hints). + * This lets you set per-axis canonical overrides and cache computed ranges. + */ + public static final class AxisKey implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public final StatisticEngine.ScalarType type; + public final String metricName; // for METRIC_DISTANCE_TO_MEAN + public final Integer componentIndex;// for COMPONENT_AT_DIMENSION + public final String label; // optional display tag + + public AxisKey(StatisticEngine.ScalarType type, String metricName, Integer componentIndex, String label) { + this.type = Objects.requireNonNull(type, "type"); + this.metricName = metricName; + this.componentIndex = componentIndex; + this.label = label; + } + + public static AxisKey from(AxisParams a) { + return new AxisKey(a.getType(), a.getMetricName(), a.getComponentIndex(), a.getMetricName()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof AxisKey)) return false; + AxisKey that = (AxisKey) o; + return type == that.type && + Objects.equals(metricName, that.metricName) && + Objects.equals(componentIndex, that.componentIndex) && + Objects.equals(label, that.label); + } + + @Override + public int hashCode() { + return Objects.hash(type, metricName, componentIndex, label); + } + + @Override + public String toString() { + return "AxisKey{" + + "type=" + type + + ", metricName='" + metricName + '\'' + + ", componentIndex=" + componentIndex + + ", label='" + label + '\'' + + '}'; + } + } + + /** + * Per-axis explicit override (any field nullable = leave to policy). + */ + public static final class AxisOverride implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + public final Double min; + public final Double max; + public final Integer bins; + + public AxisOverride(Double min, Double max, Integer bins) { + this.min = min; + this.max = max; + this.bins = bins; + } + + public AxisOverride withBins(Integer b) { + return new AxisOverride(min, max, b); + } + } + + /** + * Simple holder for numeric range. + */ + public static final class AxisRange implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + public final double min, max; + + public AxisRange(double min, double max) { + this.min = min; + this.max = max; + } + + @Override + public String toString() { + return "[" + min + ", " + max + "]"; + } + } + + // ---------- Policy fields ---------- + + private final String id; + private final Mode mode; + private final int defaultBinsX; + private final int defaultBinsY; + + // For ROBUST_PCT + private final double pLow; // e.g., 0.01 + private final double pHigh; // e.g., 0.99 + + // Small epsilon to avoid zero-span ranges + private final double epsilon; + + // Optional per-axis overrides + private final Map overrides = new ConcurrentHashMap<>(); + + // Simple cache for computed ranges per dataset signature (optional use via callers) + // Map> + private final Map> datasetRangeCache = new ConcurrentHashMap<>(); + + // ---------- Registry ---------- + + private static final Map REGISTRY = new ConcurrentHashMap<>(); + + /** Default policy: robust percentiles [1%, 99%], 64x64 bins. */ + static { + CanonicalGridPolicy def = new Builder("default", Mode.ROBUST_PCT) + .bins(64, 64) + .percentiles(0.01, 0.99) + .epsilon(1e-8) + .build(); + register(def); + register(new Builder("fixed01", Mode.FIXED_01).bins(64, 64).build()); + register(new Builder("minmax", Mode.DATA_MIN_MAX).bins(64, 64).epsilon(1e-8).build()); + } + + public static void register(CanonicalGridPolicy policy) { + REGISTRY.put(Objects.requireNonNull(policy).id, policy); + } + + public static CanonicalGridPolicy get(String id) { + return REGISTRY.getOrDefault(id, REGISTRY.get("default")); + } + + // ---------- Builder ---------- + + public static final class Builder { + private final String id; + private final Mode mode; + private int defaultBinsX = 64, defaultBinsY = 64; + private double pLow = 0.01, pHigh = 0.99; + private double epsilon = 1e-8; + private final Map overrides = new HashMap<>(); + + public Builder(String id, Mode mode) { + this.id = Objects.requireNonNull(id, "id"); + this.mode = Objects.requireNonNull(mode, "mode"); + } + + public Builder bins(int bx, int by) { + this.defaultBinsX = bx; + this.defaultBinsY = by; + return this; + } + + public Builder percentiles(double low, double high) { + this.pLow = low; + this.pHigh = high; + return this; + } + + public Builder epsilon(double e) { + this.epsilon = e; + return this; + } + + public Builder override(AxisKey key, AxisOverride ov) { + this.overrides.put(key, ov); + return this; + } + + public CanonicalGridPolicy build() { + if (mode == Mode.ROBUST_PCT && !(pLow >= 0 && pLow < pHigh && pHigh <= 1)) + throw new IllegalArgumentException("Invalid percentiles: pLow < pHigh and both in [0,1]."); + return new CanonicalGridPolicy(this); + } + } + + private CanonicalGridPolicy(Builder b) { + this.id = b.id; + this.mode = b.mode; + this.defaultBinsX = b.defaultBinsX; + this.defaultBinsY = b.defaultBinsY; + this.pLow = b.pLow; + this.pHigh = b.pHigh; + this.epsilon = b.epsilon; + this.overrides.putAll(b.overrides); + } + + // ---------- Public API ---------- + + /** + * Compute a GridSpec for the given axes and vectors, using this policy. + * Falls back to per-axis overrides when present. + */ + public GridSpec gridForAxes(List vectors, + AxisParams xAxis, + AxisParams yAxis, + Integer binsX, + Integer binsY, + String datasetCacheKey // optional: stable id for caching (e.g., SHA of data) + ) { + int bx = binsX != null ? binsX : defaultBinsX; + int by = binsY != null ? binsY : defaultBinsY; + + AxisKey kx = AxisKey.from(xAxis); + AxisKey ky = AxisKey.from(yAxis); + + AxisOverride ox = overrides.get(kx); + AxisOverride oy = overrides.get(ky); + + AxisRange rx = (ox != null && ox.min != null && ox.max != null) + ? new AxisRange(ox.min, ox.max) + : computeAxisRange(vectors, xAxis, datasetCacheKey); + + AxisRange ry = (oy != null && oy.min != null && oy.max != null) + ? new AxisRange(oy.min, oy.max) + : computeAxisRange(vectors, yAxis, datasetCacheKey); + + if (ox != null && ox.bins != null) bx = ox.bins; + if (oy != null && oy.bins != null) by = oy.bins; + + // Construct GridSpec with explicit bounds. + GridSpec g = new GridSpec(bx, by); + g.setMinX(rx.min); + g.setMaxX(rx.max); + g.setMinY(ry.min); + g.setMaxY(ry.max); + return g; + } + + /** + * Precompute and cache ranges for a set of axes over a dataset. + * Useful when you will request many pairs repeatedly. + */ + public void warmupRanges(String datasetCacheKey, + List vectors, + Collection axes) { + for (AxisParams ax : axes) { + computeAxisRange(vectors, ax, datasetCacheKey); + } + } + + /** + * Returns the canonical range for a single axis (applies overrides if present). + */ + public AxisRange axisRange(List vectors, AxisParams axis, String datasetCacheKey) { + AxisKey k = AxisKey.from(axis); + AxisOverride ov = overrides.get(k); + if (ov != null && ov.min != null && ov.max != null) { + return new AxisRange(ov.min, ov.max); + } + return computeAxisRange(vectors, axis, datasetCacheKey); + } + + // ---------- Internals ---------- + + private AxisRange computeAxisRange(List vectors, + AxisParams axis, + String datasetCacheKey) { + // Cache path + if (datasetCacheKey != null) { + AxisRange cached = datasetRangeCache + .computeIfAbsent(datasetCacheKey, k -> new ConcurrentHashMap<>()) + .get(AxisKey.from(axis)); + if (cached != null) return cached; + } + + AxisRange r; + switch (mode) { + case FIXED_01 -> r = new AxisRange(0.0, 1.0); + case DATA_MIN_MAX -> { + double[] vals = extractScalars(vectors, axis); + double min = Arrays.stream(vals).min().orElse(0.0); + double max = Arrays.stream(vals).max().orElse(min + epsilon); + if (max - min < epsilon) max = min + epsilon; + r = new AxisRange(min, max); + } + case ROBUST_PCT -> { + double[] vals = extractScalars(vectors, axis); + if (vals.length == 0) { + r = new AxisRange(0.0, 1.0); + } else { + Arrays.sort(vals); + double lo = percentileSorted(vals, pLow); + double hi = percentileSorted(vals, pHigh); + if (hi - lo < epsilon) hi = lo + epsilon; + r = new AxisRange(lo, hi); + } + } + default -> r = new AxisRange(0.0, 1.0); + } + + if (datasetCacheKey != null) { + datasetRangeCache.computeIfAbsent(datasetCacheKey, k -> new ConcurrentHashMap<>()) + .put(AxisKey.from(axis), r); + } + return r; + } + + // Extract scalar values per AxisParams, mirroring GridDensity3DEngine + private double[] extractScalars(List vectors, AxisParams axis) { + if (vectors == null || vectors.isEmpty()) return new double[0]; + + // Precompute mean if needed + List meanVector = null; + Set needMean = Set.of( + StatisticEngine.ScalarType.DIST_TO_MEAN, + StatisticEngine.ScalarType.COSINE_TO_MEAN + ); + if (needMean.contains(axis.getType())) { + meanVector = FeatureVector.getMeanVector(vectors); + } + + // Metric if needed + Metric metric = null; + if (axis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && axis.getMetricName() != null + && axis.getReferenceVec() != null) { + metric = Metric.getMetric(axis.getMetricName()); + } + + double[] out = new double[vectors.size()]; + for (int i = 0; i < vectors.size(); i++) { + FeatureVector fv = vectors.get(i); + out[i] = scalarValue(fv, axis.getType(), meanVector, metric, axis.getReferenceVec(), axis.getComponentIndex()); + } + return out; + } + + private double scalarValue(FeatureVector fv, + StatisticEngine.ScalarType type, + List meanVec, + Metric metric, + List refVec, + Integer componentIndex) { + switch (type) { + case L1_NORM: + return fv.getData().stream().mapToDouble(Math::abs).sum(); + case LINF_NORM: + return fv.getData().stream().mapToDouble(Math::abs).max().orElse(0.0); + case MEAN: + return fv.getData().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + case MAX: + return fv.getMax(); + case MIN: + return fv.getMin(); + case DIST_TO_MEAN: + if (meanVec == null) return 0.0; + return AnalysisUtils.l2Norm(StatisticEngine.diffList(fv.getData(), meanVec)); + case COSINE_TO_MEAN: + if (meanVec == null) return 0.0; + return AnalysisUtils.cosineSimilarity(fv.getData(), meanVec); + case METRIC_DISTANCE_TO_MEAN: + if (metric == null || refVec == null) return 0.0; + double[] a = fv.getData().stream().mapToDouble(Double::doubleValue).toArray(); + double[] b = refVec.stream().mapToDouble(Double::doubleValue).toArray(); + return metric.distance(a, b); + case COMPONENT_AT_DIMENSION: + if (componentIndex == null) return 0.0; + List d = fv.getData(); + if (componentIndex >= 0 && componentIndex < d.size()) return d.get(componentIndex); + return 0.0; + case PC1_PROJECTION: + // Not computed here (expensive). If needed, precompute externally and pass via COMPONENT_AT_DIMENSION. + return 0.0; + default: + return 0.0; + } + } + + // Percentile for sorted array, linear interpolation between nearest ranks + private static double percentileSorted(double[] sorted, double p) { + if (sorted.length == 0) return 0.0; + if (p <= 0) return sorted[0]; + if (p >= 1) return sorted[sorted.length - 1]; + double pos = p * (sorted.length - 1); + int i = (int) Math.floor(pos); + int j = (int) Math.ceil(pos); + if (i == j) return sorted[i]; + double w = pos - i; + return (1 - w) * sorted[i] + w * sorted[j]; + } + + // ---------- Accessors ---------- + + public String id() { + return id; + } + + public Mode mode() { + return mode; + } + + public int defaultBinsX() { + return defaultBinsX; + } + + public int defaultBinsY() { + return defaultBinsY; + } + + public double pLow() { + return pLow; + } + + public double pHigh() { + return pHigh; + } + + public double epsilon() { + return epsilon; + } + + public Map overridesView() { + return Collections.unmodifiableMap(overrides); + } + + @Override + public String toString() { + return "CanonicalGridPolicy{" + + "id='" + id + '\'' + + ", mode=" + mode + + ", defaultBinsX=" + defaultBinsX + + ", defaultBinsY=" + defaultBinsY + + ", pLow=" + pLow + + ", pHigh=" + pHigh + + ", epsilon=" + epsilon + + ", overrides=" + overrides.size() + + '}'; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java new file mode 100644 index 00000000..7baa6642 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java @@ -0,0 +1,413 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * DensityCache + * ------------ + * LRU (+ optional TTL) cache for joint PDF/CDF grids produced by GridDensity3DEngine. + *

+ * Cache key = datasetSignature ⨉ xAxis ⨉ yAxis ⨉ gridSpec (bins + bounds). + * The datasetSignature can be provided by the caller (stable ID) or derived + * from the data (fast fingerprint over a sampled subset). + *

+ * Thread-safe via RW-lock; LRU eviction via access-ordered LinkedHashMap. + *

+ * Typical usage: + * DensityCache cache = new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); + * // build a canonical grid first (recommended) + * GridSpec grid = CanonicalGridPolicy.get("default").gridForAxes(vectors, xAxis, yAxis, null, null, "myDataset"); + * GridDensityResult res = cache.getOrCompute(vectors, xAxis, yAxis, grid, "myDataset"); // provenance optional + *

+ * Notes: + * - GridDensityResult holds both PDF and CDF; one cache entry covers both. + * - To ensure stable keys across runs, pass a true datasetId (e.g., content hash). + * If you can't, the internal fingerprint is deterministic but approximate. + * - Avoid caching empty datasets (we skip and return compute directly). + * + * @author Sean Phillips + */ +public final class DensityCache implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Cache statistics snapshot. + */ + public static final class Stats implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + public final long hits, misses, puts, evictions, expirations, size; + + Stats(long hits, long misses, long puts, long evictions, long expirations, long size) { + this.hits = hits; + this.misses = misses; + this.puts = puts; + this.evictions = evictions; + this.expirations = expirations; + this.size = size; + } + + @Override + public String toString() { + return "Stats{hits=" + hits + ", misses=" + misses + ", puts=" + puts + + ", evictions=" + evictions + ", expirations=" + expirations + + ", size=" + size + '}'; + } + } + + /** + * Builder for DensityCache. + */ + public static final class Builder { + private int maxEntries = 128; + private long ttlMillis = 0; // 0 = no TTL + + public Builder maxEntries(int n) { + this.maxEntries = Math.max(1, n); + return this; + } + + public Builder ttlMillis(long ms) { + this.ttlMillis = Math.max(0, ms); + return this; + } + + public DensityCache build() { + return new DensityCache(maxEntries, ttlMillis); + } + } + + /** + * Internal entry. + */ + private static final class Entry implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + final GridDensityResult result; + final long createdAt; + final JpdfProvenance provenance; // optional; may be null + + Entry(GridDensityResult r, long t, JpdfProvenance p) { + this.result = r; + this.createdAt = t; + this.provenance = p; + } + } + + private final int maxEntries; + private final long ttlMillis; + + /** + * Access-ordered LRU map with bounded size (evicts eldest). + */ + private final LinkedHashMap map; + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + // Stats (best-effort; not atomic across fields) + private long hits = 0, misses = 0, puts = 0, evictions = 0, expirations = 0; + + private DensityCache(int maxEntries, long ttlMillis) { + this.maxEntries = maxEntries; + this.ttlMillis = ttlMillis; + this.map = new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + boolean evict = size() > DensityCache.this.maxEntries; + if (evict) evictions++; + return evict; + } + }; + } + + // ===================================================================================== + // Core cache operations + // ===================================================================================== + + /** + * Get (or compute+insert) a GridDensityResult for the given inputs, storing the provided provenance. + * + * @param vectors dataset rows; if null/empty, computes directly without caching + * @param xAxis axis params for X + * @param yAxis axis params for Y + * @param grid grid spec (bins & bounds should be explicit for canonical comparability) + * @param datasetId optional stable dataset identifier; if null, a fast fingerprint is derived + * @param provenance optional provenance record to store alongside the result + */ + public GridDensityResult getOrCompute( + List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + String datasetId, + JpdfProvenance provenance + ) { + Objects.requireNonNull(xAxis, "xAxis"); + Objects.requireNonNull(yAxis, "yAxis"); + Objects.requireNonNull(grid, "grid"); + + if (vectors == null || vectors.isEmpty()) { + // Nothing worth caching; delegate to engine + return GridDensity3DEngine.computePdfCdf2D(vectors, xAxis, yAxis, grid); + } + + final String key = makeKey(vectors, xAxis, yAxis, grid, datasetId); + final long now = System.currentTimeMillis(); + + // Fast path: read lock then compute if miss + Entry e; + lock.readLock().lock(); + try { + e = map.get(key); + if (e != null && !isExpired(e, now)) { + hits++; + return e.result; + } + } finally { + lock.readLock().unlock(); + } + + // Compute outside lock + GridDensityResult computed = GridDensity3DEngine.computePdfCdf2D(vectors, xAxis, yAxis, grid); + + // Write back if still missing (double-check) + lock.writeLock().lock(); + try { + Entry existing = map.get(key); + if (existing != null && !isExpired(existing, now)) { + hits++; + return existing.result; + } + misses++; + if (existing != null && isExpired(existing, now)) expirations++; + map.put(key, new Entry(computed, now, provenance)); + puts++; + } finally { + lock.writeLock().unlock(); + } + return computed; + } + + /** + * Overload: get (or compute+insert) without providing a provenance. + * The cache will store a {@code null} provenance for this entry. + */ + public GridDensityResult getOrCompute( + List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + String datasetId + ) { + return getOrCompute(vectors, xAxis, yAxis, grid, datasetId, null); + } + + /** + * Manual insert (replaces existing). + */ + public void put(String key, GridDensityResult value, JpdfProvenance provenance) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + lock.writeLock().lock(); + try { + map.put(key, new Entry(value, System.currentTimeMillis(), provenance)); + puts++; + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Overload: manual insert without provenance. + */ + public void put(String key, GridDensityResult value) { + put(key, value, null); + } + + /** + * Direct lookup by precomputed key; returns null if missing/expired. + */ + public GridDensityResult get(String key) { + lock.writeLock().lock(); // write: we may remove expired + try { + Entry e = map.get(key); + if (e == null) return null; + if (isExpired(e, System.currentTimeMillis())) { + map.remove(key); + expirations++; + return null; + } + hits++; + return e.result; + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Return provenance for a cached entry, or null. + */ + public JpdfProvenance getProvenance(String key) { + lock.readLock().lock(); + try { + Entry e = map.get(key); + if (e == null) return null; + return e.provenance; + } finally { + lock.readLock().unlock(); + } + } + + /** + * Remove a specific key. + */ + public void invalidate(String key) { + lock.writeLock().lock(); + try { + map.remove(key); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Clear all entries. + */ + public void clear() { + lock.writeLock().lock(); + try { + map.clear(); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Snapshot of stats. + */ + public Stats stats() { + lock.readLock().lock(); + try { + return new Stats(hits, misses, puts, evictions, expirations, map.size()); + } finally { + lock.readLock().unlock(); + } + } + + /** + * Current number of entries. + */ + public int size() { + lock.readLock().lock(); + try { + return map.size(); + } finally { + lock.readLock().unlock(); + } + } + + // ===================================================================================== + // Keying / fingerprints + // ===================================================================================== + + /** + * Make a stable cache key for the inputs. If datasetId is null, a deterministic + * fingerprint is derived by sampling up to SAMPLE_ROWS rows and a few columns. + */ + public String makeKey(List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + String datasetId) { + String ds = (datasetId != null && !datasetId.isBlank()) + ? datasetId + : fingerprintDataset(vectors, 256, 64); + return "ds=" + ds + + "|x=" + axisKey(xAxis) + + "|y=" + axisKey(yAxis) + + "|g=" + gridKey(grid); + } + + /** + * Fast, deterministic fingerprint of the dataset (not cryptographic). + */ + public static String fingerprintDataset(List vectors, int maxRows, int maxCols) { + if (vectors == null || vectors.isEmpty()) return "empty"; + int n = vectors.size(); + int d = (vectors.get(0).getData() == null) ? 0 : vectors.get(0).getData().size(); + + int rows = Math.min(n, Math.max(1, maxRows)); + int cols = Math.min(d, Math.max(1, maxCols)); + + long h = 1469598103934665603L; // FNV-ish seed + long p = 1099511628211L; + + int stepR = Math.max(1, n / rows); + int stepC = Math.max(1, d / cols); + + for (int r = 0, countR = 0; r < n && countR < rows; r += stepR, countR++) { + List row = vectors.get(r).getData(); + for (int c = 0, countC = 0; c < d && countC < cols; c += stepC, countC++) { + double v = (c < row.size()) ? row.get(c) : 0.0; + long bits = Double.doubleToLongBits(v); + h ^= bits; + h *= p; + } + // include some metadata (size) + h ^= row.size(); + h *= p; + } + h ^= n * 31L + d; + return Long.toUnsignedString(h, 36); // compact base36 + } + + private static String axisKey(AxisParams a) { + StringBuilder sb = new StringBuilder(64); + sb.append(a.getType()); + if (a.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + sb.append(":metric=").append(String.valueOf(a.getMetricName())); + sb.append(":ref=").append(vecHash(a.getReferenceVec())); + } else if (a.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + sb.append(":comp=").append(String.valueOf(a.getComponentIndex())); + } + String lbl = a.getMetricName(); + if (lbl != null && !lbl.isBlank()) sb.append(":lbl=").append(lbl); + return sb.toString(); + } + + private static String gridKey(GridSpec g) { + // Bounds may be null -> treat as 'auto'. Prefer explicit bounds for canonical comparability. + return "bx=" + g.getBinsX() + ",by=" + g.getBinsY() + + ",minx=" + (g.getMinX() == null ? "auto" : g.getMinX()) + + ",maxx=" + (g.getMaxX() == null ? "auto" : g.getMaxX()) + + ",miny=" + (g.getMinY() == null ? "auto" : g.getMinY()) + + ",maxy=" + (g.getMaxY() == null ? "auto" : g.getMaxY()); + } + + private static String vecHash(List v) { + if (v == null || v.isEmpty()) return "null"; + long h = 0xcbf29ce484222325L, p = 0x100000001b3L; + int step = Math.max(1, v.size() / 16); // sample up to 16 entries + for (int i = 0; i < v.size(); i += step) { + long bits = Double.doubleToLongBits(v.get(i)); + h ^= bits; + h *= p; + } + h ^= v.size(); + return Long.toUnsignedString(h, 36); + } + + private boolean isExpired(Entry e, long now) { + if (ttlMillis <= 0) return false; + return (now - e.createdAt) > ttlMillis; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java new file mode 100644 index 00000000..bd5c59f5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java @@ -0,0 +1,285 @@ +package edu.jhuapl.trinity.utils.statistics; + +import javafx.geometry.Insets; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonBar.ButtonData; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.VBox; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +/** + * Simple dialog utilities for entering custom vectors or precomputed scalars. + *

+ * Features: + * - Supports single or multiple vectors (multi-line paste). + * - Pads/truncates vectors to an expected dimension if provided. + * - Parses scalars (score or score+info) for direct PDF/CDF charts. + * - Shows warnings for padding, truncation, or invalid tokens. + * + * @author Sean Phillips + */ +public final class DialogUtils { + + private DialogUtils() { + } + + // ----------------- Vector Input ----------------- + + /** + * Show a dialog that collects a single vector (no enforced dimension). + */ + public static List showCustomVectorDialog() { + List> all = showCustomVectorsDialog(null); + if (all == null || all.isEmpty()) return null; + return all.get(0); + } + + /** + * Show a dialog that collects a single vector with an expected dimension. + * Pads with 0.0 or truncates to match expectedDim. Shows warnings if applied. + */ + public static List showCustomVectorDialog(Integer expectedDim) { + if (expectedDim == null) { + return showCustomVectorDialog(); + } + List> all = showCustomVectorsDialog(expectedDim); + if (all == null || all.isEmpty()) return null; + return all.get(0); + } + + /** + * Show a dialog that collects one or more vectors (multi-row). + * Pads/truncates each row to expectedDim if provided. + */ + public static List> showCustomVectorsDialog(Integer expectedDim) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Enter Custom Vector(s)"); + dialog.setHeaderText("Paste one vector per line. Use commas, semicolons, or spaces."); + + TextArea textArea = new TextArea(); + textArea.setPromptText("Examples:\n" + + "0.12, -1.3, 2.5\n" + + "1.0 2.0 3.0\n" + + "0.5; -1.2; 3"); + textArea.setPrefColumnCount(60); + textArea.setPrefRowCount(10); + + Label hint = new Label(expectedDim == null + ? "No fixed dimension enforced." + : ("Expected dimension: " + expectedDim + " (rows will be padded/truncated)")); + + VBox content = new VBox(8, hint, textArea); + content.setPadding(new Insets(10)); + dialog.getDialogPane().setContent(content); + + ButtonType okType = new ButtonType("OK", ButtonData.OK_DONE); + ButtonType cancelType = new ButtonType("Cancel", ButtonData.CANCEL_CLOSE); + dialog.getDialogPane().getButtonTypes().addAll(okType, cancelType); + + ButtonType result = dialog.showAndWait().orElse(cancelType); + if (result != okType) return null; + + String raw = textArea.getText(); + ParseOutcome parsed = parseMultiRow(raw, expectedDim); + + if (!parsed.warnings.isEmpty()) { + showWarnings(parsed.warnings, "Vector Input Warnings", + "Some rows required padding/truncation or had invalid tokens."); + } + + return parsed.vectors.isEmpty() ? null : parsed.vectors; + } + + // ----------------- Scalars Input ----------------- + + /** + * Result container for scalar inputs (score + optional info fraction). + */ + public static final class ScalarInputResult { + public final List scores = new ArrayList<>(); + public final List infos = new ArrayList<>(); // may be empty + } + + /** + * Show a dialog to paste precomputed scalars. + * Each line: "score" or "score, infoPercent". + * Values are clamped to [0,1]. Warnings shown for issues. + */ + public static ScalarInputResult showScalarSamplesDialog() { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Enter Precomputed Scalars"); + dialog.setHeaderText("One per line: score or score, infoPercent (both in [0,1])."); + + TextArea textArea = new TextArea(); + textArea.setPromptText("Examples:\n" + + "0.73\n" + + "0.91, 0.62\n" + + "0.50 0.25\n" + + "0.33; 1.0"); + textArea.setPrefColumnCount(60); + textArea.setPrefRowCount(12); + + VBox content = new VBox(8, + new Label("Separators: comma, semicolon, or whitespace."), + textArea); + content.setPadding(new Insets(10)); + dialog.getDialogPane().setContent(content); + + ButtonType okType = new ButtonType("OK", ButtonData.OK_DONE); + ButtonType cancelType = new ButtonType("Cancel", ButtonData.CANCEL_CLOSE); + dialog.getDialogPane().getButtonTypes().addAll(okType, cancelType); + + ButtonType result = dialog.showAndWait().orElse(cancelType); + if (result != okType) return null; + + String raw = textArea.getText(); + return parseScalarPairs(raw); + } + + // ----------------- Parsing helpers ----------------- + + private static final class ParseOutcome { + final List> vectors = new ArrayList<>(); + final List warnings = new ArrayList<>(); + } + + private static ParseOutcome parseMultiRow(String raw, Integer expectedDim) { + ParseOutcome out = new ParseOutcome(); + if (raw == null || raw.trim().isEmpty()) return out; + + String[] lines = raw.split("\\R"); + int lineNumber = 0; + + for (String line : lines) { + lineNumber++; + if (line == null) continue; + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + + // Normalize separators + String normalized = trimmed.replace(',', ' ').replace(';', ' '); + String[] tokens = normalized.trim().split("\\s+"); + + List row = new ArrayList<>(); + int skippedTokens = 0; + for (String tok : tokens) { + if (tok.isEmpty()) continue; + try { + row.add(Double.parseDouble(tok)); + } catch (NumberFormatException nfe) { + skippedTokens++; + } + } + + if (row.isEmpty()) { + out.warnings.add("Line " + lineNumber + ": no valid numeric tokens; skipped."); + continue; + } + + // Enforce dimension + if (expectedDim != null && expectedDim > 0) { + if (row.size() < expectedDim) { + int missing = expectedDim - row.size(); + for (int i = 0; i < missing; i++) row.add(0.0); + out.warnings.add("Line " + lineNumber + ": padded with " + missing + " zero(s)."); + } else if (row.size() > expectedDim) { + int extra = row.size() - expectedDim; + while (row.size() > expectedDim) row.remove(row.size() - 1); + out.warnings.add("Line " + lineNumber + ": truncated " + extra + " value(s)."); + } + } + + if (skippedTokens > 0) { + out.warnings.add("Line " + lineNumber + ": skipped " + skippedTokens + " unparsable token(s)."); + } + + out.vectors.add(row); + } + return out; + } + + private static ScalarInputResult parseScalarPairs(String raw) { + ScalarInputResult out = new ScalarInputResult(); + List warnings = new ArrayList<>(); + if (raw == null || raw.trim().isEmpty()) return out; + + String[] lines = raw.split("\\R"); + int lineNo = 0; + + for (String line : lines) { + lineNo++; + if (line == null) continue; + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) continue; + + String normalized = trimmed.replace(',', ' ').replace(';', ' '); + String[] tokens = normalized.trim().split("\\s+"); + + Double score = null, info = null; + try { + if (tokens.length >= 1) score = Double.parseDouble(tokens[0]); + } catch (NumberFormatException ignore) { + } + try { + if (tokens.length >= 2) info = Double.parseDouble(tokens[1]); + } catch (NumberFormatException ignore) { + } + + if (score == null) { + warnings.add("Line " + lineNo + ": invalid score; skipped."); + continue; + } + + // Clamp + if (score < 0.0 || score > 1.0) { + warnings.add("Line " + lineNo + ": score " + score + " clamped to [0,1]."); + score = Math.max(0.0, Math.min(1.0, score)); + } + if (info != null && (info < 0.0 || info > 1.0)) { + warnings.add("Line " + lineNo + ": info " + info + " clamped to [0,1]."); + info = Math.max(0.0, Math.min(1.0, info)); + } + + out.scores.add(score); + if (info != null) out.infos.add(info); + } + + if (!warnings.isEmpty()) { + showWarnings(warnings, "Scalar Input Warnings", + "Some rows had invalid values or were clamped to [0,1]."); + } + return out; + } + + // ----------------- Warning helper ----------------- + + private static void showWarnings(List warnings, String title, String header) { + if (warnings == null || warnings.isEmpty()) return; + + final int MAX_LINES = 12; + StringJoiner sj = new StringJoiner("\n"); + for (int i = 0; i < Math.min(MAX_LINES, warnings.size()); i++) { + sj.add(warnings.get(i)); + } + if (warnings.size() > MAX_LINES) { + sj.add("…and " + (warnings.size() - MAX_LINES) + " more."); + } + + Alert alert = new Alert(AlertType.WARNING); + alert.setTitle(title); + alert.setHeaderText(header); + TextArea area = new TextArea(sj.toString()); + area.setEditable(false); + area.setWrapText(true); + area.setPrefRowCount(Math.min(MAX_LINES, warnings.size())); + alert.getDialogPane().setContent(area); + alert.showAndWait(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java new file mode 100644 index 00000000..c5de2601 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java @@ -0,0 +1,413 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +/** + * DivergenceComputer + * ------------------ + * Builds a feature-by-feature divergence matrix D for two cohorts (A vs B), where + * each entry D[i][j] measures the separation between cohort A and cohort B + * on the joint PDF over (component i, component j). + *

+ * Supported divergences: + * - JS: Jensen–Shannon divergence (base-2 logs). Range: [0, 1]. + * - HELLINGER: Hellinger distance. Range: [0, 1]. + * - TV: Total Variation distance (L1/2). Range: [0, 1]. + *

+ * Inputs: + * - cohortA, cohortB: Feature vectors (rows). Must be non-null; may be empty. + * - component indices: explicit list or recipe-provided range [start..end]. + * - recipe: controls bins and bounds policy for consistent, aligned grids. + * - cache: optional; respected if recipe.isCacheEnabled() is true. + *

+ * Outputs: + * - DivergenceResult: symmetric matrix (F x F), labels, indices, metadata, and a quality matrix. + *

+ * Notes: + * - For each (i,j), we compute aligned GridDensityResult for A and B on the + * SAME GridSpec (union bounds under the chosen policy), then convert PDFs + * to mass per cell (pdf * dx * dy) to form discrete distributions over the grid. + * - We normalize the flattened masses to sum to 1 (guarding numerics) then + * compute the divergence. + * - Diagonal cells compare A vs B on the *joint (i,i)* surface (allowed if desired). + * If you prefer to force diagonal to 0, you can post-process in the UI. + *

+ * This class does not modify any global UI state and is thread-safe per call. + * + * @author Sean Phillips (Trinity) + * @author (helper) ChatGPT + */ +public final class DivergenceComputer { + + // --------------------------------------------------------------------- + // Types + // --------------------------------------------------------------------- + + public enum DivergenceMetric { + JS, // Jensen–Shannon divergence (base-2 logs) in [0,1] + HELLINGER, // Hellinger distance in [0,1] + TV // Total variation distance in [0,1] + } + + /** + * Output bundle for a single divergence computation. + */ + public static final class DivergenceResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * Symmetric divergence matrix (F x F). + */ + public final double[][] matrix; + + /** + * Optional quality matrix (F x F), e.g., min(coverageA, coverageB) fraction in [0,1]. + */ + public final double[][] quality; + + /** + * Chosen divergence metric. + */ + public final DivergenceMetric metric; + + /** + * Selected component indices (length F). + */ + public final int[] componentIndices; + + /** + * Human-friendly labels "Comp i", length F. + */ + public final List labels; + + /** + * Binning used for X and Y (from recipe). + */ + public final int binsX; + public final int binsY; + + /** + * Bounds policy applied (from recipe). + */ + public final JpdfRecipe.BoundsPolicy boundsPolicy; + + /** + * Canonical policy id (when boundsPolicy == CANONICAL_BY_FEATURE). + */ + public final String canonicalPolicyId; + + /** + * Whether cache was allowed (recipe-level flag; not a guarantee of hits). + */ + public final boolean cacheEnabled; + + public DivergenceResult(double[][] matrix, + double[][] quality, + DivergenceMetric metric, + int[] componentIndices, + List labels, + int binsX, + int binsY, + JpdfRecipe.BoundsPolicy boundsPolicy, + String canonicalPolicyId, + boolean cacheEnabled) { + this.matrix = matrix; + this.quality = quality; + this.metric = metric; + this.componentIndices = componentIndices; + this.labels = labels; + this.binsX = binsX; + this.binsY = binsY; + this.boundsPolicy = boundsPolicy; + this.canonicalPolicyId = canonicalPolicyId; + this.cacheEnabled = cacheEnabled; + } + } + + private DivergenceComputer() { + } + + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- + + /** + * Convenience: compute over the component index range specified by the recipe. + */ + public static DivergenceResult computeForComponentRange( + List cohortA, + List cohortB, + JpdfRecipe recipe, + DivergenceMetric metric, + DensityCache cache + ) { + Objects.requireNonNull(recipe, "recipe"); + int start = Math.max(0, recipe.getComponentIndexStart()); + int end = Math.max(start, recipe.getComponentIndexEnd()); + + // Clamp to dimensionality if available + int dimA = (cohortA == null || cohortA.isEmpty() || cohortA.get(0).getData() == null) + ? -1 : cohortA.get(0).getData().size(); + int dimB = (cohortB == null || cohortB.isEmpty() || cohortB.get(0).getData() == null) + ? -1 : cohortB.get(0).getData().size(); + int dim = Math.max(dimA, dimB); + if (dim >= 0) end = Math.min(end, Math.max(0, dim - 1)); + + List comps = new ArrayList<>(); + for (int i = start; i <= end; i++) comps.add(i); + + return computeForComponents(cohortA, cohortB, comps, recipe, metric, cache); + } + + /** + * Compute the divergence matrix for explicit component indices. + * + * @param cohortA cohort A feature vectors + * @param cohortB cohort B feature vectors + * @param componentIndices component indices (0-based) to include + * @param recipe controls bins & bounds alignment policy + * @param metric divergence metric + * @param cache optional; used if recipe.isCacheEnabled() + */ + public static DivergenceResult computeForComponents( + List cohortA, + List cohortB, + List componentIndices, + JpdfRecipe recipe, + DivergenceMetric metric, + DensityCache cache + ) { + Objects.requireNonNull(cohortA, "cohortA"); + Objects.requireNonNull(cohortB, "cohortB"); + Objects.requireNonNull(componentIndices, "componentIndices"); + Objects.requireNonNull(recipe, "recipe"); + Objects.requireNonNull(metric, "metric"); + + // Early out + if (componentIndices.isEmpty()) { + return new DivergenceResult(new double[0][0], new double[0][0], metric, + new int[0], List.of(), + recipe.getBinsX(), recipe.getBinsY(), + recipe.getBoundsPolicy(), recipe.getCanonicalPolicyId(), + recipe.isCacheEnabled()); + } + + // Canonical policy (alignment) + final CanonicalGridPolicy policy = CanonicalGridPolicy.get( + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default" + ); + + final boolean useCache = recipe.isCacheEnabled() && cache != null; + + // Prepare component list/labels + final int F = componentIndices.size(); + final int[] comps = new int[F]; + final List labels = new ArrayList<>(F); + for (int k = 0; k < F; k++) { + comps[k] = componentIndices.get(k); + labels.add("Comp " + comps[k]); + } + + // Prepare outputs + double[][] D = new double[F][F]; + double[][] Q = new double[F][F]; // quality; min fraction of usable samples across cohorts (heuristic) + + // Diagonal init (will be computed like any pair; but we keep symmetry & quality sane) + for (int i = 0; i < F; i++) { + D[i][i] = 0.0; // JS(P,P)=0, H(P,P)=0, TV(P,P)=0 theoretically; we still compute to be robust + Q[i][i] = 1.0; + } + + int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); + ExecutorService pool = Executors.newFixedThreadPool(threads); + List> futures = new ArrayList<>(); + + final String idA = DensityCache.fingerprintDataset(cohortA, 256, 64); + final String idB = DensityCache.fingerprintDataset(cohortB, 256, 64); + + for (int a = 0; a < F; a++) { + final int ia = a; + futures.add(pool.submit(() -> { + for (int b = ia; b < F; b++) { + int ci = comps[ia]; + int cj = comps[b]; + + AxisParams x = new AxisParams(); + x.setType(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + x.setComponentIndex(ci); + + AxisParams y = new AxisParams(); + y.setType(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + y.setComponentIndex(cj); + + // Compute aligned baselines using ABComparisonEngine + ABComparisonEngine.AbResult ab = ABComparisonEngine.compare( + cohortA, cohortB, + x, y, + recipe, + useCache ? cache : null, + idA, idB + ); + + // Convert PDFs to mass distributions (flatten) + MassVec pa = toMass(ab.a); + MassVec pb = toMass(ab.b); + + // Normalize (guard if sums deviate from 1 by numeric noise) + normalizeInPlace(pa.values); + normalizeInPlace(pb.values); + + // Divergence + double div; + switch (metric) { + case JS -> div = jsDivergence(pa.values, pb.values); + case HELLINGER -> div = hellinger(pa.values, pb.values); + case TV -> div = totalVariation(pa.values, pb.values); + default -> div = 0.0; + } + + // Simple quality proxy: min coverage ratio across cohorts (if any NaN rows were filtered upstream, + // this would reflect in counts; GridDensity3DEngine currently bins every row, so set 1.0). + // Keep hook for future enhancements. + double q = 1.0; + + D[ia][b] = div; + D[b][ia] = div; + Q[ia][b] = q; + Q[b][ia] = q; + } + })); + } + + // Wait for all + for (Future f : futures) { + try { + f.get(); + } catch (Exception e) { + pool.shutdownNow(); + throw new RuntimeException("DivergenceComputer: task failed", e); + } + } + pool.shutdown(); + + return new DivergenceResult( + D, Q, metric, + comps, labels, + recipe.getBinsX(), recipe.getBinsY(), + recipe.getBoundsPolicy(), recipe.getCanonicalPolicyId(), + recipe.isCacheEnabled() + ); + } + + // --------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------- + + /** + * Flatten pdf grid to mass vector (z * dx * dy). + */ + private static MassVec toMass(GridDensityResult r) { + double[][] pdf = r.getPdfZ(); + int by = pdf.length; + int bx = (by == 0) ? 0 : pdf[0].length; + double[] out = new double[bx * by]; + double w = r.getDx() * r.getDy(); + int k = 0; + for (int y = 0; y < by; y++) { + double[] row = pdf[y]; + for (int x = 0; x < bx; x++) { + double v = row[x] * w; + out[k++] = (Double.isFinite(v) && v > 0) ? v : 0.0; + } + } + return new MassVec(out); + } + + private static final class MassVec { + final double[] values; + + MassVec(double[] v) { + this.values = v; + } + } + + /** + * Normalize a vector to sum=1 (if sum<=0, make uniform). + */ + private static void normalizeInPlace(double[] p) { + double s = 0.0; + for (double v : p) s += v; + if (!(s > 0)) { + double u = 1.0 / Math.max(1, p.length); + for (int i = 0; i < p.length; i++) p[i] = u; + return; + } + for (int i = 0; i < p.length; i++) p[i] /= s; + } + + // ---------------- Jensen–Shannon divergence (base-2 logs) ---------------- + private static double jsDivergence(double[] p, double[] q) { + int n = Math.min(p.length, q.length); + double d = 0.0; + for (int i = 0; i < n; i++) { + double pi = clampProb(p[i]); + double qi = clampProb(q[i]); + double mi = 0.5 * (pi + qi); + d += klTerm(pi, mi) + klTerm(qi, mi); + } + return 0.5 * d; // already base-2 + } + + /** + * KL term with base-2 logs, guarding for zeros. + */ + private static double klTerm(double a, double b) { + if (a <= 0 || b <= 0) return 0.0; + return a * log2(a / b); + } + + private static double log2(double x) { + return Math.log(x) / Math.log(2.0); + } + + private static double clampProb(double v) { + if (Double.isNaN(v) || v < 0) return 0.0; + if (v > 1) return 1.0; + return v; + } + + // ---------------- Hellinger distance ---------------- + private static double hellinger(double[] p, double[] q) { + int n = Math.min(p.length, q.length); + double s = 0.0; + for (int i = 0; i < n; i++) { + double a = Math.sqrt(clampProb(p[i])); + double b = Math.sqrt(clampProb(q[i])); + double d = a - b; + s += d * d; + } + return Math.min(1.0, Math.sqrt(s) / Math.sqrt(2.0)); + } + + // ---------------- Total variation distance ---------------- + private static double totalVariation(double[] p, double[] q) { + int n = Math.min(p.length, q.length); + double s = 0.0; + for (int i = 0; i < n; i++) { + s += Math.abs(clampProb(p[i]) - clampProb(q[i])); + } + return Math.min(1.0, 0.5 * s); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java new file mode 100644 index 00000000..bef0caf6 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java @@ -0,0 +1,486 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * FeatureSimilarityComputer + * ------------------------- + * Computes an N x N feature similarity matrix for a single cohort (one FeatureCollection), + * where N = (endIndex - startIndex + 1). Each entry S[i,j] encodes dependence between + * component @i and component @j using one of: + *

+ * - PEARSON : |r| + * - KENDALL : |tau_b| (approx; stride-sampled for large N) + * - MI_LITE : Normalized Mutual Information in [0,1] using fixed equal-width binning + * - DIST_CORR : Distance correlation in [0,1] (biased estimator) + *

+ * Data sufficiency guard: + * sufficient <=> (#samples) / (binsX * binsY) >= minAvgCountPerCell + * If insufficient, S[i,j] is set to Double.NaN and mask[i][j] = false. + *

+ * Diagonal: + * S[i,i] = 1.0 when sufficient; else NaN. + *

+ * Output includes labels ("Comp ") for convenience and a metadata block. + * + * @author Sean Phillips + */ +public final class FeatureSimilarityComputer { + + /** + * Similarity metric options (aligned with JpdfRecipe.ScoreMetric names). + */ + public enum SimilarityMetric { + PEARSON, + KENDALL, + MI_LITE, + DIST_CORR + } + + /** + * Result bundle for a single cohort. + */ + public static final class Result implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * Similarity matrix, size N x N. May contain NaN where insufficient. + */ + public final double[][] sim; + + /** + * True where the corresponding sim[i][j] passed the sufficiency guard. + */ + public final boolean[][] sufficient; + + /** + * Display labels for each component (length N), e.g., "Comp 7". + */ + public final List labels; + + /** + * Metadata for auditing and UI badges. + */ + public final Meta meta; + + public Result(double[][] sim, boolean[][] sufficient, List labels, Meta meta) { + this.sim = sim; + this.sufficient = sufficient; + this.labels = labels; + this.meta = meta; + } + } + + /** + * Metadata captured for the matrix computation. + */ + public static final class Meta implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public final int startIndex; + public final int endIndex; + public final long nSamples; + public final SimilarityMetric metric; + public final int miBins; // for MI_LITE + public final int kendallMaxN; // for KENDALL sampling + public final int binsX; // for sufficiency guard + public final int binsY; // for sufficiency guard + public final double minAvgCountPerCell; + + public Meta(int startIndex, + int endIndex, + long nSamples, + SimilarityMetric metric, + int miBins, + int kendallMaxN, + int binsX, + int binsY, + double minAvgCountPerCell) { + this.startIndex = startIndex; + this.endIndex = endIndex; + this.nSamples = nSamples; + this.metric = metric; + this.miBins = miBins; + this.kendallMaxN = kendallMaxN; + this.binsX = binsX; + this.binsY = binsY; + this.minAvgCountPerCell = minAvgCountPerCell; + } + } + + private FeatureSimilarityComputer() { + // utility class + } + + /** + * Compute an N x N similarity matrix across components [startIndex..endIndex] for one cohort. + * + * @param vectors cohort feature vectors (rows); required + * @param startIndex inclusive component start (clamped to [0, D-1]) + * @param endIndex inclusive component end (clamped to [start, D-1]) + * @param metric similarity metric to use + * @param miBins MI-lite bin count (>=4) used only when metric == MI_LITE + * @param kendallMaxN stride-sampling cap for Kendall tau (>=50) + * @param binsX guard param for avg count per cell (>=2) + * @param binsY guard param for avg count per cell (>=2) + * @param minAvgCountPerCell sufficiency threshold; if <=0 no pairs are blocked + */ + public static Result compute(List vectors, + int startIndex, + int endIndex, + SimilarityMetric metric, + int miBins, + int kendallMaxN, + int binsX, + int binsY, + double minAvgCountPerCell) { + Objects.requireNonNull(vectors, "vectors"); + if (vectors.isEmpty()) { + return emptyResult(0, 0, metric, miBins, kendallMaxN, binsX, binsY, minAvgCountPerCell); + } + + // Clamp index range against dimensionality + int dims = (vectors.get(0).getData() == null) ? 0 : vectors.get(0).getData().size(); + int s = Math.max(0, startIndex); + int e = Math.max(s, Math.min(endIndex, Math.max(0, dims - 1))); + int N = (e >= s) ? (e - s + 1) : 0; + if (N == 0) { + return emptyResult(s, e, metric, miBins, kendallMaxN, binsX, binsY, minAvgCountPerCell); + } + + // Extract columns as double[N][nSamples] + double[][] cols = extractComponentColumns(vectors, s, e); + int nSamples = (cols.length == 0) ? 0 : cols[0].length; + + // Prepare output + double[][] S = new double[N][N]; + boolean[][] ok = new boolean[N][N]; + + // Sufficiency (same n for all pairs since same cohort) + boolean sufficient = sufficiency(nSamples, binsX, binsY, minAvgCountPerCell); + + // Fill matrix (symmetric; compute upper triangle and mirror) + for (int i = 0; i < N; i++) { + S[i][i] = sufficient ? 1.0 : Double.NaN; + ok[i][i] = sufficient; + } + + for (int i = 0; i < N; i++) { + double[] xi = cols[i]; + for (int j = i + 1; j < N; j++) { + double[] xj = cols[j]; + double val; + if (!sufficient) { + val = Double.NaN; + } else { + switch (metric) { + case PEARSON -> val = Math.abs(pearson(xi, xj)); + case KENDALL -> val = Math.abs(kendallTauApprox(xi, xj, Math.max(50, kendallMaxN))); + case MI_LITE -> val = nmiLite(xi, xj, Math.max(4, miBins)); + case DIST_CORR -> val = distCorr(xi, xj); + default -> val = Double.NaN; + } + } + S[i][j] = val; + S[j][i] = val; + ok[i][j] = sufficient && !Double.isNaN(val); + ok[j][i] = ok[i][j]; + } + } + + // Labels "Comp " + List labels = new ArrayList<>(N); + for (int k = 0; k < N; k++) labels.add("Comp " + (s + k)); + + Meta meta = new Meta(s, e, nSamples, metric, + Math.max(4, miBins), Math.max(50, kendallMaxN), + Math.max(2, binsX), Math.max(2, binsY), + Math.max(0.0, minAvgCountPerCell)); + + return new Result(S, ok, labels, meta); + } + + // ------------------------------------------------------------------------------------ + // Internals: column extraction, sufficiency, metrics + // ------------------------------------------------------------------------------------ + + private static Result emptyResult(int s, int e, + SimilarityMetric metric, + int miBins, + int kendallMaxN, + int binsX, + int binsY, + double minAvgCountPerCell) { + double[][] S = new double[0][0]; + boolean[][] ok = new boolean[0][0]; + List labels = new ArrayList<>(0); + Meta meta = new Meta(s, e, 0L, metric, + Math.max(4, miBins), Math.max(50, kendallMaxN), + Math.max(2, binsX), Math.max(2, binsY), + Math.max(0.0, minAvgCountPerCell)); + return new Result(S, ok, labels, meta); + } + + private static double[][] extractComponentColumns(List vectors, int start, int end) { + int N = end - start + 1; + int n = vectors.size(); + double[][] cols = new double[N][n]; + for (int row = 0; row < n; row++) { + List data = vectors.get(row).getData(); + for (int d = 0; d < N; d++) { + int idx = start + d; + double val = (data != null && idx >= 0 && idx < data.size()) ? data.get(idx) : 0.0; + cols[d][row] = val; + } + } + return cols; + } + + private static boolean sufficiency(long n, int bx, int by, double minAvgPerCell) { + if (bx <= 0 || by <= 0) return true; + if (minAvgPerCell <= 0.0) return true; + double avg = (bx * (double) by) == 0 ? 0.0 : (n / (bx * (double) by)); + return avg >= minAvgPerCell; + } + + // ----- Metrics (duplicated here to keep this class self-contained) ----- + + private static double pearson(double[] x, double[] y) { + int n = Math.min(x.length, y.length); + if (n < 3) return 0.0; + double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0; + for (int i = 0; i < n; i++) { + double xi = x[i], yi = y[i]; + sx += xi; + sy += yi; + sxx += xi * xi; + syy += yi * yi; + sxy += xi * yi; + } + double num = n * sxy - sx * sy; + double den = Math.sqrt(Math.max(0.0, n * sxx - sx * sx)) * + Math.sqrt(Math.max(0.0, n * syy - sy * sy)); + if (den == 0.0) return 0.0; + double r = num / den; + if (Double.isNaN(r) || Double.isInfinite(r)) return 0.0; + return r; + } + + /** + * Approximate Kendall's tau_b using stride-sampling for large n. + */ + private static double kendallTauApprox(double[] x, double[] y, int maxN) { + int n = Math.min(x.length, y.length); + if (n <= 1) return 0.0; + + int[] idx = sampledIndices(n, maxN); + int m = idx.length; + + long concordant = 0, discordant = 0; + long tiesX = 0, tiesY = 0; + + for (int a = 0; a < m; a++) { + int i = idx[a]; + for (int b = a + 1; b < m; b++) { + int j = idx[b]; + double dx = x[i] - x[j]; + double dy = y[i] - y[j]; + + if (dx == 0.0 && dy == 0.0) continue; // joint tie + if (dx == 0.0) { + tiesX++; + continue; + } + if (dy == 0.0) { + tiesY++; + continue; + } + + double prod = dx * dy; + if (prod > 0) concordant++; + else if (prod < 0) discordant++; + } + } + double denom = Math.sqrt((concordant + discordant + tiesX) * 1.0) * + Math.sqrt((concordant + discordant + tiesY) * 1.0); + if (denom == 0.0) return 0.0; + return (concordant - discordant) / denom; + } + + private static int[] sampledIndices(int n, int maxN) { + if (n <= maxN) { + int[] idx = new int[n]; + for (int i = 0; i < n; i++) idx[i] = i; + return idx; + } + int[] idx = new int[maxN]; + double step = (n - 1.0) / (maxN - 1.0); + for (int k = 0; k < maxN; k++) idx[k] = (int) Math.round(k * step); + return idx; + } + + /** + * NMI in [0,1] using equal-width binning with add-one smoothing. + */ + private static double nmiLite(double[] x, double[] y, int bins) { + int n = Math.min(x.length, y.length); + if (n == 0) return 0.0; + + double minX = Double.POSITIVE_INFINITY, maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < n; i++) { + double xi = x[i], yi = y[i]; + if (xi < minX) minX = xi; + if (xi > maxX) maxX = xi; + if (yi < minY) minY = yi; + if (yi > maxY) maxY = yi; + } + if (!(maxX > minX)) maxX = minX + 1e-8; + if (!(maxY > minY)) maxY = minY + 1e-8; + + double dx = (maxX - minX) / bins; + double dy = (maxY - minY) / bins; + + double[][] joint = new double[bins][bins]; + double[] px = new double[bins]; + double[] py = new double[bins]; + + Arrays.fill(px, 1.0); + Arrays.fill(py, 1.0); + for (int by = 0; by < bins; by++) Arrays.fill(joint[by], 1.0); + + double nEff = n + bins + bins + bins * bins; + + for (int i = 0; i < n; i++) { + int bx = (int) Math.floor((x[i] - minX) / dx); + int by = (int) Math.floor((y[i] - minY) / dy); + if (bx < 0) bx = 0; + if (bx >= bins) bx = bins - 1; + if (by < 0) by = 0; + if (by >= bins) by = bins - 1; + joint[by][bx] += 1.0; + px[bx] += 1.0; + py[by] += 1.0; + } + + for (int b = 0; b < bins; b++) { + px[b] /= nEff; + py[b] /= nEff; + } + for (int by = 0; by < bins; by++) { + for (int bx = 0; bx < bins; bx++) { + joint[by][bx] /= nEff; + } + } + + double hx = entropy(px); + double hy = entropy(py); + double mi = 0.0; + for (int by = 0; by < bins; by++) { + for (int bx = 0; bx < bins; bx++) { + double pxy = joint[by][bx]; + double denom = px[bx] * py[by]; + if (pxy > 0.0 && denom > 0.0) { + mi += pxy * Math.log(pxy / denom); + } + } + } + double denom = Math.sqrt(Math.max(hx, 1e-12) * Math.max(hy, 1e-12)); + double nmi = (denom > 0.0) ? (mi / denom) : 0.0; + if (Double.isNaN(nmi) || Double.isInfinite(nmi)) nmi = 0.0; + if (nmi < 0.0) nmi = 0.0; + else if (nmi > 1.0) nmi = 1.0; + return nmi; + } + + private static double entropy(double[] p) { + double h = 0.0; + for (double v : p) if (v > 0.0) h -= v * Math.log(v); + return h; + } + + /** + * Distance correlation (biased), returned in [0,1]. + */ + private static double distCorr(double[] x, double[] y) { + int n = Math.min(x.length, y.length); + if (n < 3) return 0.0; + + double[][] ax = distanceMatrix(x, n); + double[][] ay = distanceMatrix(y, n); + + double[][] axc = doubleCenter(ax); + double[][] ayc = doubleCenter(ay); + + double dcov2 = meanElementwiseProduct(axc, ayc); + double dvarx = meanElementwiseProduct(axc, axc); + double dvary = meanElementwiseProduct(ayc, ayc); + + if (dvarx <= 0.0 || dvary <= 0.0) return 0.0; + double dcor = Math.sqrt(Math.max(0.0, dcov2)) / Math.sqrt(dvarx * dvary); + if (Double.isNaN(dcor) || Double.isInfinite(dcor)) return 0.0; + if (dcor < 0.0) dcor = 0.0; + else if (dcor > 1.0) dcor = 1.0; + return dcor; + } + + private static double[][] distanceMatrix(double[] v, int n) { + double[][] d = new double[n][n]; + for (int i = 0; i < n; i++) { + d[i][i] = 0.0; + for (int j = i + 1; j < n; j++) { + double dij = Math.abs(v[i] - v[j]); + d[i][j] = dij; + d[j][i] = dij; + } + } + return d; + } + + private static double[][] doubleCenter(double[][] a) { + int n = a.length; + double[] rowMean = new double[n]; + double[] colMean = new double[n]; + double grand = 0.0; + + for (int i = 0; i < n; i++) { + double rs = 0.0; + for (int j = 0; j < n; j++) rs += a[i][j]; + rowMean[i] = rs / n; + grand += rs; + } + grand /= (n * n); + + for (int j = 0; j < n; j++) { + double cs = 0.0; + for (int i = 0; i < n; i++) cs += a[i][j]; + colMean[j] = cs / n; + } + + double[][] out = new double[n][n]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + out[i][j] = a[i][j] - rowMean[i] - colMean[j] + grand; + } + } + return out; + } + + private static double meanElementwiseProduct(double[][] a, double[][] b) { + int n = a.length; + double s = 0.0; + for (int i = 0; i < n; i++) + for (int j = 0; j < n; j++) + s += a[i][j] * b[i][j]; + return s / (n * n); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java new file mode 100644 index 00000000..64ea3e7d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java @@ -0,0 +1,247 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.AnalysisUtils; +import edu.jhuapl.trinity.utils.metric.Metric; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Builds 2D joint PDF/CDF grids (Z surfaces) from a collection of FeatureVectors + * for Trinity's 3D hypersurface renderer. + *

+ * Core: + * - Joint PDF surface z = f(x, y) via 2D histogram (normalized to density). + * - Joint CDF surface z = F(x, y) = P(X ≤ x, Y ≤ y) via 2D prefix sums over per-cell probability mass. + *

+ * Axes are defined by AxisParams (per-axis scalar type + optional metric/reference/componentIndex). + * Grid discretization and optional bounds are defined by GridSpec. + *

+ * Notes: + * - PDF integrates to ~1: sum(pdfZ) * dx * dy ≈ 1. + * - CDF is monotone in +x and +y and ends near 1. + * - If you need PCA/UMAP coordinates as axes, precompute them and pass via COMPONENT_AT_DIMENSION, + * or adapt this engine to accept externally supplied (x,y) arrays. + * + * @author Sean Phillips + */ +public class GridDensity3DEngine { + + private GridDensity3DEngine() { + // Utility class + } + + /** + * Compute 2D histogram-based joint PDF and joint CDF for two scalar features across FeatureVectors. + * If gridSpec bounds are null, they are inferred from data (with a tiny epsilon to avoid degenerate bins). + * + * @param vectors feature vectors (rows) + * @param xAxis axis params for X + * @param yAxis axis params for Y + * @param gridSpec discretization and optional bounds + * @return GridDensityResult containing PDF grid, CDF grid, axis edges/centers, and bin sizes + */ + public static GridDensityResult computePdfCdf2D( + List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec gridSpec + ) { + Objects.requireNonNull(xAxis, "xAxis"); + Objects.requireNonNull(yAxis, "yAxis"); + Objects.requireNonNull(gridSpec, "gridSpec"); + + if (vectors == null || vectors.isEmpty()) { + return emptyResult(gridSpec); + } + + // Prepare auxiliary data only when required by selected scalar types + List meanVector = null; + Set needMean = Set.of( + StatisticEngine.ScalarType.DIST_TO_MEAN, + StatisticEngine.ScalarType.COSINE_TO_MEAN + ); + if (needMean.contains(xAxis.getType()) || needMean.contains(yAxis.getType())) { + meanVector = FeatureVector.getMeanVector(vectors); + } + + Metric metricX = null; + if (xAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && xAxis.getMetricName() != null + && xAxis.getReferenceVec() != null) { + metricX = Metric.getMetric(xAxis.getMetricName()); + } + Metric metricY = null; + if (yAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && yAxis.getMetricName() != null + && yAxis.getReferenceVec() != null) { + metricY = Metric.getMetric(yAxis.getMetricName()); + } + + // Compute scalar pairs (x_i, y_i) + final int n = vectors.size(); + double[] xs = new double[n]; + double[] ys = new double[n]; + + for (int i = 0; i < n; i++) { + FeatureVector fv = vectors.get(i); + xs[i] = scalarValue( + fv, + xAxis.getType(), + meanVector, + metricX, + xAxis.getReferenceVec(), + xAxis.getComponentIndex() + ); + ys[i] = scalarValue( + fv, + yAxis.getType(), + meanVector, + metricY, + yAxis.getReferenceVec(), + yAxis.getComponentIndex() + ); + } + + // Bounds (auto or explicit) + double minX = (gridSpec.getMinX() != null) ? gridSpec.getMinX() : min(xs); + double maxX = (gridSpec.getMaxX() != null) ? gridSpec.getMaxX() : max(xs); + double minY = (gridSpec.getMinY() != null) ? gridSpec.getMinY() : min(ys); + double maxY = (gridSpec.getMaxY() != null) ? gridSpec.getMaxY() : max(ys); + if (minX == maxX) maxX += 1e-8; + if (minY == maxY) maxY += 1e-8; + + final int binsX = gridSpec.getBinsX(); + final int binsY = gridSpec.getBinsY(); + + final double dx = (maxX - minX) / binsX; + final double dy = (maxY - minY) / binsY; + + double[] xEdges = new double[binsX + 1]; + double[] yEdges = new double[binsY + 1]; + double[] xCenters = new double[binsX]; + double[] yCenters = new double[binsY]; + + for (int bx = 0; bx <= binsX; bx++) xEdges[bx] = minX + bx * dx; + for (int by = 0; by <= binsY; by++) yEdges[by] = minY + by * dy; + for (int bx = 0; bx < binsX; bx++) xCenters[bx] = 0.5 * (xEdges[bx] + xEdges[bx + 1]); + for (int by = 0; by < binsY; by++) yCenters[by] = 0.5 * (yEdges[by] + yEdges[by + 1]); + + // 2D histogram (counts) + double[][] counts = new double[binsY][binsX]; + for (int i = 0; i < n; i++) { + int bx = (int) Math.floor((xs[i] - minX) / dx); + int by = (int) Math.floor((ys[i] - minY) / dy); + if (bx < 0) bx = 0; + if (bx >= binsX) bx = binsX - 1; + if (by < 0) by = 0; + if (by >= binsY) by = binsY - 1; + counts[by][bx] += 1.0; + } + + // Normalize to PDF: density = count / (N * dx * dy) + final double invAreaN = 1.0 / (n * dx * dy); + double[][] pdfZ = new double[binsY][binsX]; + for (int by = 0; by < binsY; by++) { + for (int bx = 0; bx < binsX; bx++) { + pdfZ[by][bx] = counts[by][bx] * invAreaN; + } + } + + // Convert to per-cell probability mass, then 2D prefix sum → CDF + double[][] mass = new double[binsY][binsX]; + for (int by = 0; by < binsY; by++) { + for (int bx = 0; bx < binsX; bx++) { + mass[by][bx] = pdfZ[by][bx] * dx * dy; + } + } + double[][] cdfZ = prefixSum2D(mass); + // Numerical guard + cdfZ[binsY - 1][binsX - 1] = Math.min(1.0, Math.max(0.0, cdfZ[binsY - 1][binsX - 1])); + + return new GridDensityResult(pdfZ, cdfZ, xEdges, yEdges, xCenters, yCenters, dx, dy); + } + + // ---------- Helpers ---------- + + private static double scalarValue(FeatureVector fv, + StatisticEngine.ScalarType type, + List meanVec, + Metric metric, + List refVec, + Integer componentIndex) { + switch (type) { + case L1_NORM: + return fv.getData().stream().mapToDouble(Math::abs).sum(); + case LINF_NORM: + return fv.getData().stream().mapToDouble(Math::abs).max().orElse(0.0); + case MEAN: + return fv.getData().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + case MAX: + return fv.getMax(); + case MIN: + return fv.getMin(); + case DIST_TO_MEAN: + if (meanVec == null) return 0.0; + return AnalysisUtils.l2Norm(StatisticEngine.diffList(fv.getData(), meanVec)); + case COSINE_TO_MEAN: + if (meanVec == null) return 0.0; + return AnalysisUtils.cosineSimilarity(fv.getData(), meanVec); + case PC1_PROJECTION: + // If you need PC projections as axes, precompute externally + // and pass via COMPONENT_AT_DIMENSION (or extend this engine). + return 0.0; + case METRIC_DISTANCE_TO_MEAN: + if (metric == null || refVec == null) return 0.0; + double[] a = fv.getData().stream().mapToDouble(Double::doubleValue).toArray(); + double[] b = refVec.stream().mapToDouble(Double::doubleValue).toArray(); + return metric.distance(a, b); + case COMPONENT_AT_DIMENSION: + if (componentIndex == null) return 0.0; + List d = fv.getData(); + if (componentIndex >= 0 && componentIndex < d.size()) return d.get(componentIndex); + return 0.0; + default: + return 0.0; + } + } + + private static double[][] prefixSum2D(double[][] mass) { + int h = mass.length; + int w = mass[0].length; + double[][] c = new double[h][w]; + for (int y = 0; y < h; y++) { + double rowSum = 0.0; + for (int x = 0; x < w; x++) { + rowSum += mass[y][x]; + c[y][x] = rowSum + (y > 0 ? c[y - 1][x] : 0.0); + } + } + return c; + } + + private static double min(double[] v) { + double m = Double.POSITIVE_INFINITY; + for (double x : v) if (x < m) m = x; + return m; + } + + private static double max(double[] v) { + double m = Double.NEGATIVE_INFINITY; + for (double x : v) if (x > m) m = x; + return m; + } + + private static GridDensityResult emptyResult(GridSpec grid) { + int bx = Math.max(5, grid.getBinsX()); + int by = Math.max(5, grid.getBinsY()); + double[][] z0 = new double[by][bx]; + double[] xEdges = new double[bx + 1]; + double[] yEdges = new double[by + 1]; + double[] xCenters = new double[bx]; + double[] yCenters = new double[by]; + return new GridDensityResult(z0, z0, xEdges, yEdges, xCenters, yCenters, 1.0, 1.0); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java new file mode 100644 index 00000000..d04a9fe1 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java @@ -0,0 +1,104 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.ArrayList; +import java.util.List; + +/** + * Result container for 2D joint PDF/CDF surfaces. + *

+ * Holds: + * - PDF grid (normalized density) + * - CDF grid (cumulative probability) + * - Axis edges and centers + * - Bin widths + *

+ * Designed for use in Trinity’s 3D Hypersurface renderer. + * + * @author Sean Phillips + */ +public final class GridDensityResult { + private final double[][] pdfZ; // size binsY x binsX (row-major: y, then x) + private final double[][] cdfZ; // same shape, cumulative in +x and +y + private final double[] xEdges; // length binsX+1 + private final double[] yEdges; // length binsY+1 + private final double[] xCenters; // length binsX + private final double[] yCenters; // length binsY + private final double dx; + private final double dy; + + public GridDensityResult(double[][] pdfZ, + double[][] cdfZ, + double[] xEdges, + double[] yEdges, + double[] xCenters, + double[] yCenters, + double dx, + double dy) { + this.pdfZ = pdfZ; + this.cdfZ = cdfZ; + this.xEdges = xEdges; + this.yEdges = yEdges; + this.xCenters = xCenters; + this.yCenters = yCenters; + this.dx = dx; + this.dy = dy; + } + + public double[][] getPdfZ() { + return pdfZ; + } + + public double[][] getCdfZ() { + return cdfZ; + } + + public double[] getxEdges() { + return xEdges; + } + + public double[] getyEdges() { + return yEdges; + } + + public double[] getxCenters() { + return xCenters; + } + + public double[] getyCenters() { + return yCenters; + } + + public double getDx() { + return dx; + } + + public double getDy() { + return dy; + } + + /** + * Convert PDF grid to List> (row-major). + */ + public List> pdfAsListGrid() { + List> grid = new ArrayList<>(pdfZ.length); + for (double[] row : pdfZ) { + List r = new ArrayList<>(row.length); + for (double v : row) r.add(v); + grid.add(r); + } + return grid; + } + + /** + * Convert CDF grid to List> (row-major). + */ + public List> cdfAsListGrid() { + List> grid = new ArrayList<>(cdfZ.length); + for (double[] row : cdfZ) { + List r = new ArrayList<>(row.length); + for (double v : row) r.add(v); + grid.add(r); + } + return grid; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java new file mode 100644 index 00000000..47845fc5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java @@ -0,0 +1,111 @@ +package edu.jhuapl.trinity.utils.statistics; + +/** + * Discretization spec for the 2D grid used by Density3DEngine. + * Immutable, with sensible minimums. + * + * @author Sean Phillips + */ +public final class GridSpec { + private int binsX; + private int binsY; + private Double minX; + private Double maxX; + private Double minY; + private Double maxY; + + /** + * Create a GridSpec with automatic bounds. + * + * @param binsX number of bins along X (>=5 enforced) + * @param binsY number of bins along Y (>=5 enforced) + */ + public GridSpec(int binsX, int binsY) { + this(binsX, binsY, null, null, null, null); + } + + /** + * Create a GridSpec with optional explicit bounds (any null is auto-computed). + * + * @param binsX number of bins along X (>=5 enforced) + * @param binsY number of bins along Y (>=5 enforced) + * @param minX explicit minimum X (nullable) + * @param maxX explicit maximum X (nullable) + * @param minY explicit minimum Y (nullable) + * @param maxY explicit maximum Y (nullable) + */ + public GridSpec(int binsX, int binsY, Double minX, Double maxX, Double minY, Double maxY) { + this.binsX = Math.max(5, binsX); + this.binsY = Math.max(5, binsY); + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + } + + public int getBinsX() { + return binsX; + } + + public int getBinsY() { + return binsY; + } + + public Double getMinX() { + return minX; + } + + public Double getMaxX() { + return maxX; + } + + public Double getMinY() { + return minY; + } + + public Double getMaxY() { + return maxY; + } + + /** + * @param binsX the binsX to set + */ + public void setBinsX(int binsX) { + this.binsX = binsX; + } + + /** + * @param binsY the binsY to set + */ + public void setBinsY(int binsY) { + this.binsY = binsY; + } + + /** + * @param minX the minX to set + */ + public void setMinX(Double minX) { + this.minX = minX; + } + + /** + * @param maxX the maxX to set + */ + public void setMaxX(Double maxX) { + this.maxX = maxX; + } + + /** + * @param minY the minY to set + */ + public void setMinY(Double minY) { + this.minY = minY; + } + + /** + * @param maxY the maxY to set + */ + public void setMaxY(Double maxY) { + this.maxY = maxY; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java new file mode 100644 index 00000000..03becde7 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java @@ -0,0 +1,486 @@ +package edu.jhuapl.trinity.utils.statistics; + +import javafx.beans.Observable; +import javafx.geometry.Insets; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; + +/** + * HeatmapThumbnailView + * See changelog in header of this file revision: + * - legend placement fixed (managed=false) + * - palette schemes added + * - minAlphaAtVmin allows background to show through low-end values + */ +public final class HeatmapThumbnailView extends StackPane { + + public enum PaletteKind {SEQUENTIAL, DIVERGING} + + /** + * Named color ramps. + */ + public enum PaletteScheme { + VIRIDIS, INFERNO, MAGMA, PLASMA, CIVIDIS, TURBO, BLUE_YELLOW, GREYS + } + + // --- View components --- + private final Canvas canvas = new Canvas(180, 120); // default size; resizable + private final Canvas legend = new Canvas(14, 120); + private boolean showLegend = true; + private Insets contentPadding = new Insets(4, 4, 4, 4); + + // --- Data --- + private List> grid; // z[row][col] + private boolean flipY = false; + + // --- Value range --- + private boolean autoRange = true; + private double vmin = 0.0; + private double vmax = 1.0; + + // --- Palette --- + private PaletteKind palette = PaletteKind.SEQUENTIAL; + private double divergingCenter = 0.0; // used when palette == DIVERGING + private PaletteScheme seqScheme = PaletteScheme.VIRIDIS; + private PaletteScheme divScheme = PaletteScheme.BLUE_YELLOW; // base hues for halves + + // --- Appearance --- + private Color backgroundColor = Color.BLACK; + private boolean imageSmoothing = true; + private double gamma = 1.0; + private double clipLowPct = 0.0; + private double clipHighPct = 0.0; + /** + * Alpha to use at v=vmin after gamma (0..1). 1.0 = opaque low-end; 0.0 = fully transparent. + */ + private double minAlphaAtVmin = 1.0; + + public HeatmapThumbnailView() { + getChildren().add(canvas); + getChildren().add(legend); + setPadding(contentPadding); + + // Important: StackPane ignores relocate() for managed children. Disable layout management. + canvas.setManaged(false); + legend.setManaged(false); + + // Relayout & redraw on size changes + widthProperty().addListener(this::onSize); + heightProperty().addListener(this::onSize); + legend.visibleProperty().set(showLegend); + + // Initial layout + layoutChildren(); + } + + // ===================================================================================== + // Public API + // ===================================================================================== + + public void setGrid(List> grid) { + this.grid = grid; + if (autoRange) recomputeRange(); + redraw(); + } + + public void setFromGridDensity(GridDensityResult res, boolean useCDF, boolean flipY) { + Objects.requireNonNull(res, "GridDensityResult"); + List> g = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); + this.flipY = flipY; + setGrid(g); + } + + public void setFlipY(boolean flip) { + this.flipY = flip; + redraw(); + } + + public void useSequentialPalette() { + this.palette = PaletteKind.SEQUENTIAL; + redraw(); + } + + public void useDivergingPalette(double center) { + this.palette = PaletteKind.DIVERGING; + this.divergingCenter = center; + redraw(); + } + + /** + * Choose the ramp used for sequential mapping. + */ + public void setSequentialScheme(PaletteScheme scheme) { + if (scheme != null) { + this.seqScheme = scheme; + redraw(); + } + } + + /** + * Choose the base ramp used for diverging halves (low and high mirror). + */ + public void setDivergingScheme(PaletteScheme scheme) { + if (scheme != null) { + this.divScheme = scheme; + redraw(); + } + } + + public void setShowLegend(boolean show) { + this.showLegend = show; + legend.setVisible(show); + requestLayout(); + redraw(); + } + + public void setAutoRange(boolean auto) { + this.autoRange = auto; + if (auto && grid != null) recomputeRange(); + redraw(); + } + + public void setFixedRange(double min, double max) { + this.autoRange = false; + this.vmin = min; + this.vmax = max <= min ? min + 1e-12 : max; + redraw(); + } + + public void setContentPadding(Insets insets) { + this.contentPadding = insets == null ? Insets.EMPTY : insets; + setPadding(contentPadding); + requestLayout(); + redraw(); + } + + public void setBackgroundColor(Color c) { + this.backgroundColor = (c == null ? Color.BLACK : c); + redraw(); + } + + public void setImageSmoothing(boolean on) { + this.imageSmoothing = on; + redraw(); + } + + public void setGamma(double gamma) { + double g = Double.isFinite(gamma) ? gamma : 1.0; + this.gamma = Math.max(0.1, Math.min(5.0, g)); + redraw(); + } + + public void setClipPercent(double lowPct, double highPct) { + this.clipLowPct = clamp(lowPct, 0.0, 20.0); + this.clipHighPct = clamp(highPct, 0.0, 20.0); + if (autoRange && grid != null) recomputeRange(); + redraw(); + } + + /** + * Set alpha applied at the low end (vmin) after gamma shaping. + */ + public void setMinAlphaAtVmin(double a) { + this.minAlphaAtVmin = clamp(a, 0.0, 1.0); + redraw(); + } + + public double getVmin() { + return vmin; + } + + public double getVmax() { + return vmax; + } + + public double getDivergingCenter() { + return divergingCenter; + } + + public PaletteKind getPalette() { + return palette; + } + + public boolean isLegendShown() { + return showLegend; + } + + public Color getBackgroundColor() { + return backgroundColor; + } + + public boolean isImageSmoothing() { + return imageSmoothing; + } + + public double getGamma() { + return gamma; + } + + public double getClipLowPct() { + return clipLowPct; + } + + public double getClipHighPct() { + return clipHighPct; + } + + public PaletteScheme getSequentialScheme() { + return seqScheme; + } + + public PaletteScheme getDivergingScheme() { + return divScheme; + } + + public double getMinAlphaAtVmin() { + return minAlphaAtVmin; + } + + // ===================================================================================== + // Layout & rendering + // ===================================================================================== + + @Override + protected void layoutChildren() { + double w = getWidth(); + double h = getHeight(); + double rightLegend = showLegend ? legend.getWidth() + 4 : 0.0; + + double cw = Math.max(1, w - rightLegend - contentPadding.getLeft() - contentPadding.getRight()); + double ch = Math.max(1, h - contentPadding.getTop() - contentPadding.getBottom()); + + canvas.setWidth(cw); + canvas.setHeight(ch); + canvas.relocate(contentPadding.getLeft(), contentPadding.getTop()); + + if (showLegend) { + legend.setHeight(ch); + legend.relocate(contentPadding.getLeft() + cw + 4, contentPadding.getTop()); + } + super.layoutChildren(); + } + + private void onSize(Observable obs) { + layoutChildren(); + redraw(); + } + + private void recomputeRange() { + if (grid == null || grid.isEmpty()) return; + + List vals = new ArrayList<>(); + for (int r = 0; r < grid.size(); r++) { + List row = grid.get(r); + if (row == null) continue; + for (int c = 0; c < row.size(); c++) { + Double v = row.get(c); + if (v != null && Double.isFinite(v)) vals.add(v); + } + } + if (vals.isEmpty()) { + vmin = 0.0; + vmax = 1.0; + return; + } + + Collections.sort(vals); + int k = vals.size(); + int iLo = (int) Math.floor(clamp(clipLowPct, 0, 20) / 100.0 * (k - 1)); + int iHi = (int) Math.ceil((1.0 - clamp(clipHighPct, 0, 20) / 100.0) * (k - 1)); + double lo = vals.get(Math.max(0, Math.min(k - 1, iLo))); + double hi = vals.get(Math.max(0, Math.min(k - 1, iHi))); + if (!Double.isFinite(lo) || !Double.isFinite(hi)) { + lo = 0.0; + hi = 1.0; + } + if (hi - lo < 1e-12) hi = lo + 1e-12; + + vmin = lo; + vmax = hi; + } + + private void redraw() { + GraphicsContext g = canvas.getGraphicsContext2D(); + g.setImageSmoothing(imageSmoothing); + + // background fill + g.setFill(backgroundColor); + g.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); + + if (grid == null || grid.isEmpty()) { + drawEmpty(g); + drawLegend(); + return; + } + int rows = grid.size(); + int cols = grid.get(0).size(); + if (rows <= 0 || cols <= 0) { + drawEmpty(g); + drawLegend(); + return; + } + + WritableImage img = new WritableImage(cols, rows); + PixelWriter pw = img.getPixelWriter(); + + final double min = vmin; + final double max = vmax; + final double span = Math.max(1e-12, max - min); + + for (int r = 0; r < rows; r++) { + int rr = flipY ? (rows - 1 - r) : r; + List row = grid.get(rr); + for (int c = 0; c < cols; c++) { + double v = row.get(c) == null ? Double.NaN : row.get(c); + Color color = mapValueToColor(v, min, max, span); + pw.setColor(c, r, color); + } + } + + g.drawImage(img, 0, 0, cols, rows, 0, 0, canvas.getWidth(), canvas.getHeight()); + drawLegend(); + } + + private void drawEmpty(GraphicsContext g) { + g.setStroke(Color.gray(0.35)); + g.strokeRect(0.5, 0.5, Math.max(0, canvas.getWidth() - 1), Math.max(0, canvas.getHeight() - 1)); + } + + private void drawLegend() { + if (!showLegend) return; + GraphicsContext lg = legend.getGraphicsContext2D(); + lg.setImageSmoothing(true); + + lg.setFill(backgroundColor); + lg.fillRect(0, 0, legend.getWidth(), legend.getHeight()); + + double w = legend.getWidth(); + double h = legend.getHeight(); + + int steps = (int) Math.max(2, h); + for (int i = 0; i < steps; i++) { + double t = 1.0 - (double) i / (steps - 1); // top->bottom maps max->min + double v = vmin + t * (vmax - vmin); + Color c = mapValueToColor(v, vmin, vmax, Math.max(1e-12, vmax - vmin)); + lg.setStroke(c); + lg.strokeLine(0, i + 0.5, w - 1, i + 0.5); + } + + lg.setStroke(Color.gray(0.35)); + lg.strokeRect(0.5, 0.5, w - 1, h - 1); + + if (palette == PaletteKind.DIVERGING) { + double tCenter = (vmax - divergingCenter) / Math.max(1e-12, (vmax - vmin)); + double y = (1.0 - tCenter) * h; + lg.setStroke(Color.gray(0.6)); + lg.strokeLine(0, y, w, y); + } + } + + // ===================================================================================== + // Color mapping + // ===================================================================================== + + private Color mapValueToColor(double v, double min, double max, double span) { + if (!Double.isFinite(v)) { + return Color.color(0, 0, 0, 0.0); + } + double t = (v - min) / span; + t = clamp01(t); + t = Math.pow(t, gamma); + t = clamp01(t); + + if (palette == PaletteKind.SEQUENTIAL) { + Color base = rampColor(seqScheme, t); + // fade low-end if requested + double a = (t <= 0.0) ? minAlphaAtVmin : 1.0; + return new Color(base.getRed(), base.getGreen(), base.getBlue(), a); + } else { + // Diverging: map sides with mirrored ramp from divScheme + if (v <= divergingCenter) { + double lt = (divergingCenter - v) / Math.max(1e-12, divergingCenter - min); // 0..1 + lt = Math.pow(clamp01(lt), gamma); + Color side = rampColor(divScheme, lt); // far low -> ramp end + return lerpColor(Color.web("#ffffff"), side, clamp01(lt)); + } else { + double rt = (v - divergingCenter) / Math.max(1e-12, max - divergingCenter); + rt = Math.pow(clamp01(rt), gamma); + Color side = rampColorOpposite(divScheme, rt); // far high -> opposite end + return lerpColor(Color.web("#ffffff"), side, clamp01(rt)); + } + } + } + + /** + * Produces a color in the given scheme for 0..1 (low->high). + */ + private static Color rampColor(PaletteScheme scheme, double t) { + t = clamp01(t); + switch (scheme) { + case INFERNO: + return multiStop(t, + "#000004", "#1f0c48", "#550f6d", "#88226a", "#b63655", "#e35933", "#f9950a", "#fcffa4"); + case MAGMA: + return multiStop(t, + "#000004", "#1b0c41", "#4f0c6b", "#88226a", "#b73779", "#e56b5d", "#fb9f3a", "#fbe723"); + case PLASMA: + return multiStop(t, + "#0d0887", "#41049d", "#6a00a8", "#8f0da4", "#b12a90", "#cc4778", "#e16462", "#f2844b", "#fca636", "#fcce25", "#f0f921"); + case CIVIDIS: + return multiStop(t, + "#00204c", "#163867", "#2a517a", "#3f6a89", "#578399", "#729ca7", "#90b5b5", "#b0cec2", "#d4e7cf", "#f9f1d3"); + case TURBO: + return multiStop(t, + "#30123b", "#4145ad", "#2ab0e8", "#2be3a0", "#8dfc3c", "#f7f54a", "#f79d1e", "#e7522f", "#cc1a4a", "#7a1a6c"); + case BLUE_YELLOW: + return multiStop(t, + "#0b3d91", "#00bcd4", "#ffeb3b", "#ffffff"); + case GREYS: + return multiStop(t, + "#000000", "#333333", "#777777", "#bbbbbb", "#ffffff"); + case VIRIDIS: + default: + return multiStop(t, + "#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"); + } + } + + /** + * For diverging high side: use a complementary/opposite end of the chosen scheme. + */ + private static Color rampColorOpposite(PaletteScheme scheme, double t) { + // Simple approach: flip t and reuse ramp; for BLUE_YELLOW this becomes Yellow->Blue. + return rampColor(scheme, t); + } + + private static Color multiStop(double t, String... hexStops) { + if (hexStops == null || hexStops.length == 0) return Color.WHITE; + if (hexStops.length == 1) return Color.web(hexStops[0]); + double pos = t * (hexStops.length - 1); + int i = (int) Math.floor(pos); + int j = Math.min(hexStops.length - 1, i + 1); + double k = pos - i; + return lerpColor(Color.web(hexStops[i]), Color.web(hexStops[j]), k); + } + + private static Color lerpColor(Color a, Color b, double t) { + t = clamp01(t); + double r = a.getRed() + (b.getRed() - a.getRed()) * t; + double g = a.getGreen() + (b.getGreen() - a.getGreen()) * t; + double bl = a.getBlue() + (b.getBlue() - a.getBlue()) * t; + double al = a.getOpacity() + (b.getOpacity() - a.getOpacity()) * t; + return new Color(r, g, bl, al); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java new file mode 100644 index 00000000..6d75d01e --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java @@ -0,0 +1,423 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionService; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +/** + * JpdfBatchEngine + * --------------- + * Orchestrates batch generation of joint PDF/CDF surfaces based on a JpdfRecipe. + *

+ * Supports: + * • Component-pairs mode (auto enumerate COMPONENT_AT_DIMENSION pairs). + * • Whitelist mode (explicit AxisParams pairs from the recipe). + *

+ * Honors recipe: + * • PairSelection: ALL / WHITELIST / TOP_K_BY_SCORE / THRESHOLD_BY_SCORE + * • BoundsPolicy: DATA_MIN_MAX / FIXED_01 / CANONICAL_BY_FEATURE + * • ScoreMetric, minAvgCountPerCell, binsX/binsY, cacheEnabled + *

+ * Threading: fixed pool sized to Runtime.availableProcessors(). + *

+ * Note: GridDensityResult contains both PDF and CDF; OutputKind is recorded in provenance + * by the UI later; engine returns both so consumers can choose. + */ +public final class JpdfBatchEngine { + + public JpdfBatchEngine() { + } + + /** + * Per-pair output bundle. + */ + public static final class PairJobResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public final int i; // component index for X (or -1 if not component-mode) + public final int j; // component index for Y (or -1 if not component-mode) + public final AxisParams xAxis; + public final AxisParams yAxis; + public final GridSpec grid; + public final GridDensityResult density; + public final PairScorer.PairScore rank; // may be null in whitelist mode + public final boolean fromCache; + public final long computeMillis; + public final JpdfProvenance provenance; // may be null if cache doesn't store it + + public PairJobResult(int i, + int j, + AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + GridDensityResult density, + PairScorer.PairScore rank, + boolean fromCache, + long computeMillis, + JpdfProvenance provenance) { + this.i = i; + this.j = j; + this.xAxis = xAxis; + this.yAxis = yAxis; + this.grid = grid; + this.density = density; + this.rank = rank; + this.fromCache = fromCache; + this.computeMillis = computeMillis; + this.provenance = provenance; + } + } + + /** + * Batch summary + outputs. + */ + public static final class BatchResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public final String datasetFingerprint; + public final String recipeName; + public final String policyId; + public final List jobs; + public final long wallMillis; + public final int submittedPairs; + public final int computedPairs; + public final int cacheHits; + public final DensityCache.Stats cacheStatsSnapshot; + + public BatchResult(String datasetFingerprint, + String recipeName, + String policyId, + List jobs, + long wallMillis, + int submittedPairs, + int computedPairs, + int cacheHits, + DensityCache.Stats cacheStatsSnapshot) { + this.datasetFingerprint = datasetFingerprint; + this.recipeName = recipeName; + this.policyId = policyId; + this.jobs = jobs; + this.wallMillis = wallMillis; + this.submittedPairs = submittedPairs; + this.computedPairs = computedPairs; + this.cacheHits = cacheHits; + this.cacheStatsSnapshot = cacheStatsSnapshot; + } + } + + // ===================================================================================== + // Component-pairs mode + // ===================================================================================== + + /** + * Backwards-compatible entry (no live append). + */ + public static BatchResult runComponentPairs(List vectors, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + DensityCache cache) { + return runComponentPairs(vectors, recipe, canonicalPolicy, cache, null); + } + + /** + * Live-append overload: calls onResult for each finished pair (may be null). + */ + public static BatchResult runComponentPairs(List vectors, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + DensityCache cache, + Consumer onResult) { + Objects.requireNonNull(vectors); + Objects.requireNonNull(recipe); + Objects.requireNonNull(canonicalPolicy); + Objects.requireNonNull(cache); + + final long t0 = System.currentTimeMillis(); + final String dsFp = DensityCache.fingerprintDataset(vectors, 256, 64); + + if (vectors.isEmpty() || !recipe.isComponentPairsMode()) { + return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), + Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); + } + + PairScorer.Config scorerCfg = PairScorer.Config.defaultFor( + recipe.getScoreMetric(), + recipe.getBinsX(), + recipe.getBinsY(), + recipe.getMinAvgCountPerCell() + ); + + List candidates = PairScorer.scoreComponentPairs( + vectors, + recipe.getComponentIndexStart(), + recipe.getComponentIndexEnd(), + recipe.isIncludeSelfPairs(), + recipe.isOrderedPairs(), + scorerCfg + ); + + List selected = switch (recipe.getPairSelection()) { + case ALL -> candidates; + case TOP_K_BY_SCORE -> { + int k = Math.max(1, recipe.getTopK()); + k = Math.min(k, candidates.size()); + yield new ArrayList<>(candidates.subList(0, k)); + } + case THRESHOLD_BY_SCORE -> { + List out = new ArrayList<>(); + for (PairScorer.PairScore ps : candidates) { + if (ps.score >= recipe.getScoreThreshold() && ps.sufficient) { + out.add(ps); + } + } + yield out; + } + case WHITELIST -> Collections.emptyList(); + }; + + if (recipe.getPairSelection() == JpdfRecipe.PairSelection.WHITELIST) { + return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), + Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); + } + + int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); + ExecutorService pool = Executors.newFixedThreadPool(threads); + CompletionService ecs = new ExecutorCompletionService<>(pool); + + int submitted = 0; + for (PairScorer.PairScore ps : selected) { + AxisParams x = new AxisParams(); + x.setType(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + x.setComponentIndex(ps.i); + + AxisParams y = new AxisParams(); + y.setType(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + y.setComponentIndex(ps.j); + + GridSpec grid = resolveGrid(vectors, x, y, recipe, canonicalPolicy, dsFp); + ecs.submit(new ComputeTask(vectors, x, y, grid, ps, recipe, cache, dsFp)); + submitted++; + } + + List out = new ArrayList<>(submitted); + int cacheHits = 0, computed = 0; + try { + for (int k = 0; k < submitted; k++) { + Future f = ecs.take(); + PairJobResult r = f.get(); + if (r.fromCache) cacheHits++; + else computed++; + out.add(r); + + // Live-append hook (safe-guarded) + if (onResult != null) { + try { + onResult.accept(r); + } catch (Throwable ignore) { /* isolate UI issues */ } + } + } + } catch (Exception ex) { + pool.shutdownNow(); + throw new RuntimeException("JpdfBatchEngine: execution interrupted", ex); + } finally { + pool.shutdown(); + } + + out.sort(Comparator.comparingDouble(o -> o.rank != null ? -o.rank.score : 0.0)); + + long wall = System.currentTimeMillis() - t0; + return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), + out, wall, submitted, computed, cacheHits, cache.stats()); + } + + // ===================================================================================== + // Whitelist mode + // ===================================================================================== + + /** + * Backwards-compatible entry (no live append). + */ + public static BatchResult runWhitelistPairs(List vectors, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + DensityCache cache) { + return runWhitelistPairs(vectors, recipe, canonicalPolicy, cache, null); + } + + /** + * Live-append overload: calls onResult for each finished pair (may be null). + */ + public static BatchResult runWhitelistPairs(List vectors, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + DensityCache cache, + Consumer onResult) { + Objects.requireNonNull(vectors); + Objects.requireNonNull(recipe); + Objects.requireNonNull(canonicalPolicy); + Objects.requireNonNull(cache); + + final long t0 = System.currentTimeMillis(); + final String dsFp = DensityCache.fingerprintDataset(vectors, 256, 64); + + if (vectors.isEmpty() + || recipe.getPairSelection() != JpdfRecipe.PairSelection.WHITELIST + || recipe.getExplicitAxisPairs().isEmpty()) { + return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), + Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); + } + + int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); + ExecutorService pool = Executors.newFixedThreadPool(threads); + CompletionService ecs = new ExecutorCompletionService<>(pool); + + int submitted = 0; + for (JpdfRecipe.AxisPair ap : recipe.getExplicitAxisPairs()) { + AxisParams x = ap.xAxis(); + AxisParams y = ap.yAxis(); + GridSpec grid = resolveGrid(vectors, x, y, recipe, canonicalPolicy, dsFp); + ecs.submit(new ComputeTask(vectors, x, y, grid, null, recipe, cache, dsFp)); + submitted++; + } + + List out = new ArrayList<>(submitted); + int cacheHits = 0, computed = 0; + try { + for (int k = 0; k < submitted; k++) { + Future f = ecs.take(); + PairJobResult r = f.get(); + if (r.fromCache) cacheHits++; + else computed++; + out.add(r); + + // Live-append hook (safe-guarded) + if (onResult != null) { + try { + onResult.accept(r); + } catch (Throwable ignore) { /* isolate UI issues */ } + } + } + } catch (Exception ex) { + pool.shutdownNow(); + throw new RuntimeException("JpdfBatchEngine: execution interrupted", ex); + } finally { + pool.shutdown(); + } + + long wall = System.currentTimeMillis() - t0; + return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), + out, wall, submitted, computed, cacheHits, cache.stats()); + } + + // ===================================================================================== + // Worker + // ===================================================================================== + + private static final class ComputeTask implements Callable { + private final List vectors; + private final AxisParams x; + private final AxisParams y; + private final GridSpec grid; + private final PairScorer.PairScore rank; + private final JpdfRecipe recipe; + private final DensityCache cache; + private final String datasetFp; + + ComputeTask(List vectors, + AxisParams x, + AxisParams y, + GridSpec grid, + PairScorer.PairScore rank, + JpdfRecipe recipe, + DensityCache cache, + String datasetFp) { + this.vectors = vectors; + this.x = x; + this.y = y; + this.grid = grid; + this.rank = rank; + this.recipe = recipe; + this.cache = cache; + this.datasetFp = datasetFp; + } + + @Override + public PairJobResult call() { + final boolean useCache = recipe.isCacheEnabled(); + final String key = useCache ? cache.makeKey(vectors, x, y, grid, datasetFp) : null; + + GridDensityResult cached = (useCache ? cache.get(key) : null); + + long t1 = System.currentTimeMillis(); + GridDensityResult res; + boolean fromCache; + + if (cached != null) { + res = cached; + fromCache = true; + } else if (useCache) { + // Call a version of getOrCompute that does NOT require a JpdfProvenance upfront. + res = cache.getOrCompute(vectors, x, y, grid, datasetFp); + fromCache = false; + } else { + res = GridDensity3DEngine.computePdfCdf2D(vectors, x, y, grid); + fromCache = false; + } + + long t2 = System.currentTimeMillis(); + + int ii = (x.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION && x.getComponentIndex() != null) + ? x.getComponentIndex() : -1; + int jj = (y.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION && y.getComponentIndex() != null) + ? y.getComponentIndex() : -1; + + // Try to retrieve provenance if cache tracks it; otherwise leave null. + JpdfProvenance prov = (useCache && key != null) ? cache.getProvenance(key) : null; + + return new PairJobResult(ii, jj, x, y, grid, + res, rank, fromCache, Math.max(0, t2 - t1), prov); + } + } + + // ===================================================================================== + // Grid resolution + // ===================================================================================== + + private static GridSpec resolveGrid(List vectors, + AxisParams x, + AxisParams y, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + String dsFp) { + int bx = Math.max(2, recipe.getBinsX()); + int by = Math.max(2, recipe.getBinsY()); + + return switch (recipe.getBoundsPolicy()) { + case FIXED_01 -> { + GridSpec g = new GridSpec(bx, by); + g.setMinX(0.0); + g.setMaxX(1.0); + g.setMinY(0.0); + g.setMaxY(1.0); + yield g; + } + case CANONICAL_BY_FEATURE -> canonicalPolicy.gridForAxes(vectors, x, y, bx, by, dsFp); + case DATA_MIN_MAX -> new GridSpec(bx, by); // engine will infer bounds per pair + }; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java new file mode 100644 index 00000000..abe614f3 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java @@ -0,0 +1,571 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * JpdfProvenance + * --------------- + * Metadata describing how a Joint PDF/CDF (or comparison map) was produced: + * - operation & surface kind (baseline PDF/CDF, signed difference, mixture, conditional, time-windowed) + * - canonical grid spec & alignment method (for cross-cohort comparability) + * - axis summaries (scalar types, metric/reference choices, component indices) + * - data summary (N, sufficiency checks), scoring used for preselection, cohort labels + * - recipe & cache keys so results are reproducible and deduplicated + *

+ * This is intentionally lightweight and immutable; attach one instance per produced grid. + * + * @author Sean Phillips + */ +public final class JpdfProvenance implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + // ---------- Enums ---------- + + /** + * High-level operation that produced the surface. + */ + public enum Operation { + BASELINE, // a single cohort/slice + MIXTURE, // weighted combination of baselines + DIFFERENCE, // signed A - B (comparison surface; not a PDF) + CONDITIONAL, // conditioned on a predicate over a third variable or metadata tag + TIME_WINDOW // produced from a time window / rolling window + } + + /** + * What the Z values represent. + */ + public enum SurfaceKind { + PDF, // integrates to ≈1 over domain + CDF, // monotone, ends near 1 + COMPARISON_SIGNED // signed difference map (e.g., A−B), can be negative/positive + } + + /** + * How we aligned input grids to a canonical spec before ops. + */ + public enum Alignment { + NONE, // already on the same spec + REBIN, // mass-conservative bin merging/splitting onto target edges + REMAP // mass-conservative mapping by area overlap to target cells + } + + /** + * How bounds were decided (mirrors JpdfRecipe.BoundsPolicy). + */ + public enum BoundsPolicy { + DATA_MIN_MAX, + FIXED_01, + CANONICAL_BY_FEATURE + } + + /** + * Summary of a single axis' semantic definition. + */ + public static final class AxisSummary implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public enum ReferenceKind {NONE, MEAN, VECTOR_AT_INDEX, CUSTOM} + + private final StatisticEngine.ScalarType scalarType; + private final String metricName; // for METRIC_DISTANCE_TO_MEAN + private final Integer componentIndex; // for COMPONENT_AT_DIMENSION + private final ReferenceKind referenceKind; + private final Integer referenceIndex; // when VECTOR_AT_INDEX + private final String label; // optional display label + + public AxisSummary(StatisticEngine.ScalarType scalarType, + String metricName, + Integer componentIndex, + ReferenceKind referenceKind, + Integer referenceIndex, + String label) { + this.scalarType = Objects.requireNonNull(scalarType, "scalarType"); + this.metricName = metricName; + this.componentIndex = componentIndex; + this.referenceKind = referenceKind == null ? ReferenceKind.NONE : referenceKind; + this.referenceIndex = referenceIndex; + this.label = label; + } + + public StatisticEngine.ScalarType scalarType() { + return scalarType; + } + + public String metricName() { + return metricName; + } + + public Integer componentIndex() { + return componentIndex; + } + + public ReferenceKind referenceKind() { + return referenceKind; + } + + public Integer referenceIndex() { + return referenceIndex; + } + + public String label() { + return label; + } + + @Override + public String toString() { + return "AxisSummary{" + + "scalarType=" + scalarType + + ", metricName='" + metricName + '\'' + + ", componentIndex=" + componentIndex + + ", referenceKind=" + referenceKind + + ", referenceIndex=" + referenceIndex + + ", label='" + label + '\'' + + '}'; + } + } + + /** + * Canonical grid & bounds. + */ + public static final class GridSummary implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final int binsX, binsY; + private final double minX, maxX, minY, maxY; + private final double dx, dy; + private final BoundsPolicy boundsPolicy; + private final String canonicalPolicyId; // when CANONICAL_BY_FEATURE + + public GridSummary(int binsX, int binsY, + double minX, double maxX, double minY, double maxY, + double dx, double dy, + BoundsPolicy boundsPolicy, + String canonicalPolicyId) { + this.binsX = binsX; + this.binsY = binsY; + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + this.dx = dx; + this.dy = dy; + this.boundsPolicy = boundsPolicy == null ? BoundsPolicy.DATA_MIN_MAX : boundsPolicy; + this.canonicalPolicyId = canonicalPolicyId; + } + + public int binsX() { + return binsX; + } + + public int binsY() { + return binsY; + } + + public double minX() { + return minX; + } + + public double maxX() { + return maxX; + } + + public double minY() { + return minY; + } + + public double maxY() { + return maxY; + } + + public double dx() { + return dx; + } + + public double dy() { + return dy; + } + + public BoundsPolicy boundsPolicy() { + return boundsPolicy; + } + + public String canonicalPolicyId() { + return canonicalPolicyId; + } + + @Override + public String toString() { + return "GridSummary{" + + "binsX=" + binsX + + ", binsY=" + binsY + + ", minX=" + minX + + ", maxX=" + maxX + + ", minY=" + minY + + ", maxY=" + maxY + + ", dx=" + dx + + ", dy=" + dy + + ", boundsPolicy=" + boundsPolicy + + ", canonicalPolicyId='" + canonicalPolicyId + '\'' + + '}'; + } + } + + /** + * Data/quality/selection details useful for auditing & UI badges. + */ + public static final class DataSummary implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final long nSamples; // number of (x,y) pairs used + private final double minAvgCountPerCell; // guard from recipe + private final boolean sufficiencyPass; // N/(bx*by) >= minAvgCountPerCell + private final JpdfRecipe.ScoreMetric scoreMetric; + private final Double selectionScore; // preselection score for this pair (nullable) + private final Integer selectionRank; // rank among candidates (nullable) + + public DataSummary(long nSamples, + double minAvgCountPerCell, + boolean sufficiencyPass, + JpdfRecipe.ScoreMetric scoreMetric, + Double selectionScore, + Integer selectionRank) { + this.nSamples = nSamples; + this.minAvgCountPerCell = minAvgCountPerCell; + this.sufficiencyPass = sufficiencyPass; + this.scoreMetric = scoreMetric; + this.selectionScore = selectionScore; + this.selectionRank = selectionRank; + } + + public long nSamples() { + return nSamples; + } + + public double minAvgCountPerCell() { + return minAvgCountPerCell; + } + + public boolean sufficiencyPass() { + return sufficiencyPass; + } + + public JpdfRecipe.ScoreMetric scoreMetric() { + return scoreMetric; + } + + public Double selectionScore() { + return selectionScore; + } + + public Integer selectionRank() { + return selectionRank; + } + + @Override + public String toString() { + return "DataSummary{" + + "nSamples=" + nSamples + + ", minAvgCountPerCell=" + minAvgCountPerCell + + ", sufficiencyPass=" + sufficiencyPass + + ", scoreMetric=" + scoreMetric + + ", selectionScore=" + selectionScore + + ", selectionRank=" + selectionRank + + '}'; + } + } + + /** + * Numeric hygiene checks for the produced surface. + */ + public static final class NumericChecks implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final Double pdfMass; // sum(pdf)*dx*dy (≈1 for PDFs) + private final Double cdfTerminal; // cdf[bY-1][bX-1] (≈1 for CDFs) + private final Boolean cdfMonotoneXY; // true if monotone in +x and +y + private final Double minZ, maxZ; // value range + + public NumericChecks(Double pdfMass, Double cdfTerminal, Boolean cdfMonotoneXY, Double minZ, Double maxZ) { + this.pdfMass = pdfMass; + this.cdfTerminal = cdfTerminal; + this.cdfMonotoneXY = cdfMonotoneXY; + this.minZ = minZ; + this.maxZ = maxZ; + } + + public Double pdfMass() { + return pdfMass; + } + + public Double cdfTerminal() { + return cdfTerminal; + } + + public Boolean cdfMonotoneXY() { + return cdfMonotoneXY; + } + + public Double minZ() { + return minZ; + } + + public Double maxZ() { + return maxZ; + } + + @Override + public String toString() { + return "NumericChecks{" + + "pdfMass=" + pdfMass + + ", cdfTerminal=" + cdfTerminal + + ", cdfMonotoneXY=" + cdfMonotoneXY + + ", minZ=" + minZ + + ", maxZ=" + maxZ + + '}'; + } + } + + // ---------- Immutable fields ---------- + + private final Operation operation; + private final SurfaceKind surfaceKind; + + private final AxisSummary xAxis; + private final AxisSummary yAxis; + private final GridSummary grid; + + private final Alignment alignment; + private final String recipeName; // from JpdfRecipe + private final String canonicalSpecKey; // cache/spec identity (e.g., FeatureName@policy) + private final String cacheKey; // content-hash cache key (if used) + + private final DataSummary data; + private final NumericChecks numericChecks; + + private final String cohortALabel; // provenance labels (A/B) + private final String cohortBLabel; // nullable for single-cohort + private final Instant computedAt; + private final Long computeMillis; // nullable wall clock time + + // ---------- CTOR/Builder ---------- + + private JpdfProvenance(Builder b) { + this.operation = Objects.requireNonNull(b.operation, "operation"); + this.surfaceKind = Objects.requireNonNull(b.surfaceKind, "surfaceKind"); + this.xAxis = Objects.requireNonNull(b.xAxis, "xAxis"); + this.yAxis = Objects.requireNonNull(b.yAxis, "yAxis"); + this.grid = Objects.requireNonNull(b.grid, "grid"); + this.alignment = b.alignment == null ? Alignment.NONE : b.alignment; + this.recipeName = b.recipeName; + this.canonicalSpecKey = b.canonicalSpecKey; + this.cacheKey = b.cacheKey; + this.data = b.data; + this.numericChecks = b.numericChecks; + this.cohortALabel = b.cohortALabel; + this.cohortBLabel = b.cohortBLabel; + this.computedAt = b.computedAt == null ? Instant.now() : b.computedAt; + this.computeMillis = b.computeMillis; + } + + public static Builder newBuilder(Operation op, SurfaceKind kind, AxisSummary x, AxisSummary y, GridSummary grid) { + return new Builder(op, kind, x, y, grid); + } + + public static final class Builder { + private final Operation operation; + private final SurfaceKind surfaceKind; + private final AxisSummary xAxis; + private final AxisSummary yAxis; + private final GridSummary grid; + + private Alignment alignment = Alignment.NONE; + private String recipeName; + private String canonicalSpecKey; + private String cacheKey; + + private DataSummary data; + private NumericChecks numericChecks; + + private String cohortALabel; + private String cohortBLabel; + private Instant computedAt; + private Long computeMillis; + + private Builder(Operation op, SurfaceKind kind, AxisSummary x, AxisSummary y, GridSummary grid) { + this.operation = op; + this.surfaceKind = kind; + this.xAxis = x; + this.yAxis = y; + this.grid = grid; + } + + public Builder alignment(Alignment v) { + this.alignment = v; + return this; + } + + public Builder recipeName(String v) { + this.recipeName = v; + return this; + } + + public Builder canonicalSpecKey(String v) { + this.canonicalSpecKey = v; + return this; + } + + public Builder cacheKey(String v) { + this.cacheKey = v; + return this; + } + + public Builder data(DataSummary v) { + this.data = v; + return this; + } + + public Builder numericChecks(NumericChecks v) { + this.numericChecks = v; + return this; + } + + public Builder cohortLabels(String a, String b) { + this.cohortALabel = a; + this.cohortBLabel = b; + return this; + } + + public Builder computedAt(Instant t) { + this.computedAt = t; + return this; + } + + public Builder computeMillis(Long ms) { + this.computeMillis = ms; + return this; + } + + public JpdfProvenance build() { + return new JpdfProvenance(this); + } + } + + // ---------- Getters ---------- + + public Operation operation() { + return operation; + } + + public SurfaceKind surfaceKind() { + return surfaceKind; + } + + public AxisSummary xAxis() { + return xAxis; + } + + public AxisSummary yAxis() { + return yAxis; + } + + public GridSummary grid() { + return grid; + } + + public Alignment alignment() { + return alignment; + } + + public String recipeName() { + return recipeName; + } + + public String canonicalSpecKey() { + return canonicalSpecKey; + } + + public String cacheKey() { + return cacheKey; + } + + public DataSummary data() { + return data; + } + + public NumericChecks numericChecks() { + return numericChecks; + } + + public String cohortALabel() { + return cohortALabel; + } + + public String cohortBLabel() { + return cohortBLabel; + } + + public Instant computedAt() { + return computedAt; + } + + public Long computeMillis() { + return computeMillis; + } + + // ---------- Convenience factories ---------- + + public static JpdfProvenance baselinePdf(AxisSummary x, AxisSummary y, GridSummary g, + String recipeName, String cohortLabel) { + return JpdfProvenance.newBuilder(Operation.BASELINE, SurfaceKind.PDF, x, y, g) + .recipeName(recipeName) + .cohortLabels(cohortLabel, null) + .build(); + } + + public static JpdfProvenance baselineCdf(AxisSummary x, AxisSummary y, GridSummary g, + String recipeName, String cohortLabel) { + return JpdfProvenance.newBuilder(Operation.BASELINE, SurfaceKind.CDF, x, y, g) + .recipeName(recipeName) + .cohortLabels(cohortLabel, null) + .build(); + } + + public static JpdfProvenance signedDiff(AxisSummary x, AxisSummary y, GridSummary g, + String recipeName, String cohortA, String cohortB) { + return JpdfProvenance.newBuilder(Operation.DIFFERENCE, SurfaceKind.COMPARISON_SIGNED, x, y, g) + .recipeName(recipeName) + .cohortLabels(cohortA, cohortB) + .build(); + } + + @Override + public String toString() { + return "JpdfProvenance{" + + "operation=" + operation + + ", surfaceKind=" + surfaceKind + + ", xAxis=" + xAxis + + ", yAxis=" + yAxis + + ", grid=" + grid + + ", alignment=" + alignment + + ", recipeName='" + recipeName + '\'' + + ", canonicalSpecKey='" + canonicalSpecKey + '\'' + + ", cacheKey='" + cacheKey + '\'' + + ", data=" + data + + ", numericChecks=" + numericChecks + + ", cohortALabel='" + cohortALabel + '\'' + + ", cohortBLabel='" + cohortBLabel + '\'' + + ", computedAt=" + computedAt + + ", computeMillis=" + computeMillis + + '}'; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java new file mode 100644 index 00000000..9f20b402 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java @@ -0,0 +1,486 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * JpdfRecipe + * ----------- + * Serializable configuration for batch generation of pairwise Joint PDF/CDF surfaces. + *

+ * Scope: + * - Generate from COMPONENT_AT_DIMENSION pairs (index-index) OR from an explicit whitelist of Axis pairs. + * - Select pairs via ALL / WHITELIST / TOP_K_BY_SCORE / THRESHOLD_BY_SCORE. + * - Choose scoring metric used during preselection (Pearson / Kendall / MI_LITE / DIST_CORR). + *

+ * Grid / Bounds: + * - binsX/binsY plus a bounds policy (DATA_MIN_MAX, FIXED_01, or CANONICAL_BY_FEATURE via CanonicalGridPolicy id). + * - Data-sufficiency guard (minAvgCountPerCell) to suppress low-sample surfaces. + *

+ * Outputs: + * - PDF, CDF or BOTH. + * - Optional flags: enable cache, save thumbnails, labels for cohorts (for provenance/reporting). + *

+ * Note: This class is a pure configuration object. Execution is handled by JpdfBatchEngine/AbComparisonEngine. + * + * @author Sean Phillips + */ +public final class JpdfRecipe implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + // ---------- Enums ---------- + + /** + * How to choose which pairs are built. + */ + public enum PairSelection { + /** + * Build all valid pairs from COMPONENT_AT_DIMENSION (respecting includeSelf/ordered flags). + */ + ALL, + /** + * Only build the pairs explicitly provided in {@link #explicitAxisPairs}. + */ + WHITELIST, + /** + * Pre-score all candidate pairs, then build the top-K by {@link #scoreMetric}. + */ + TOP_K_BY_SCORE, + /** + * Pre-score all candidate pairs, then build all pairs >= {@link #scoreThreshold}. + */ + THRESHOLD_BY_SCORE + } + + /** + * Which dependence score to use for preselection. + */ + public enum ScoreMetric { + PEARSON, + KENDALL, + MI_LITE, // inexpensive MI approximation + DIST_CORR // distance correlation (lite implementation) + } + + /** + * What to emit for each selected pair. + */ + public enum OutputKind { + PDF_ONLY, + CDF_ONLY, + PDF_AND_CDF + } + + /** + * How X/Y bounds are defined for the 2D grid. + */ + public enum BoundsPolicy { + /** + * Use min/max from the data actually used for this surface (per-axis). + */ + DATA_MIN_MAX, + /** + * Lock to [0,1] x [0,1] (useful for probabilities/scores). + */ + FIXED_01, + /** + * Ask CanonicalGridPolicy (by {@link #canonicalPolicyId}) to provide per-feature canonical bounds. + * Keeps specs consistent across pairs & cohorts. + */ + CANONICAL_BY_FEATURE + } + + // ---------- Axis pair holder (explicit whitelist mode) ---------- + + /** + * AxisPair wraps the axis definitions used by GridDensity3DEngine. + * In whitelist mode, each pair defines (X axis, Y axis) explicitly. + */ + public static final class AxisPair implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private final AxisParams xAxis; + private final AxisParams yAxis; + + public AxisPair(AxisParams xAxis, AxisParams yAxis) { + this.xAxis = Objects.requireNonNull(xAxis, "xAxis"); + this.yAxis = Objects.requireNonNull(yAxis, "yAxis"); + } + + public AxisParams xAxis() { + return xAxis; + } + + public AxisParams yAxis() { + return yAxis; + } + } + + // ---------- Immutable fields ---------- + + private final String name; + private final String description; + + // Pair selection strategy + private final PairSelection pairSelection; + private final ScoreMetric scoreMetric; // used when TOP_K or THRESHOLD + private final int topK; // used when TOP_K + private final double scoreThreshold; // used when THRESHOLD + + // COMPONENT_AT_DIMENSION space (auto generation) + private final boolean componentPairsMode; // if true, enumerate index-index pairs + private final int componentIndexStart; // inclusive + private final int componentIndexEnd; // inclusive + private final boolean includeSelfPairs; // allow (i,i) + private final boolean orderedPairs; // if false, only i explicitAxisPairs; + + // Grid spec policy + private final int binsX; + private final int binsY; + private final BoundsPolicy boundsPolicy; + private final String canonicalPolicyId; // name/id for CanonicalGridPolicy to look up (when CANONICAL_BY_FEATURE) + + // Data sufficiency (guards) + private final double minAvgCountPerCell; // e.g., require N/(binsX*binsY) >= this + + // Outputs & runtime options + private final OutputKind outputKind; + private final boolean cacheEnabled; + private final boolean saveThumbnails; // for PairGrid previews + + // Optional cohort labels for provenance/reporting + private final String cohortALabel; + private final String cohortBLabel; + + // ---------- Builders / CTOR ---------- + + private JpdfRecipe(Builder b) { + this.name = b.name; + this.description = b.description; + + this.pairSelection = b.pairSelection; + this.scoreMetric = b.scoreMetric; + this.topK = b.topK; + this.scoreThreshold = b.scoreThreshold; + + this.componentPairsMode = b.componentPairsMode; + this.componentIndexStart = b.componentIndexStart; + this.componentIndexEnd = b.componentIndexEnd; + this.includeSelfPairs = b.includeSelfPairs; + this.orderedPairs = b.orderedPairs; + + this.explicitAxisPairs = Collections.unmodifiableList(new ArrayList<>(b.explicitAxisPairs)); + + this.binsX = b.binsX; + this.binsY = b.binsY; + this.boundsPolicy = b.boundsPolicy; + this.canonicalPolicyId = b.canonicalPolicyId; + + this.minAvgCountPerCell = b.minAvgCountPerCell; + + this.outputKind = b.outputKind; + this.cacheEnabled = b.cacheEnabled; + this.saveThumbnails = b.saveThumbnails; + + this.cohortALabel = b.cohortALabel; + this.cohortBLabel = b.cohortBLabel; + + validate(); + } + + private void validate() throws IllegalArgumentException { + if (name == null || name.isBlank()) throw new IllegalArgumentException("Recipe name is required."); + if (binsX < 2 || binsY < 2) throw new IllegalArgumentException("binsX and binsY must be >= 2."); + if (pairSelection == PairSelection.TOP_K_BY_SCORE && topK <= 0) + throw new IllegalArgumentException("topK must be > 0 for TOP_K_BY_SCORE."); + if (pairSelection == PairSelection.THRESHOLD_BY_SCORE && !Double.isFinite(scoreThreshold)) + throw new IllegalArgumentException("scoreThreshold must be finite for THRESHOLD_BY_SCORE."); + if (pairSelection == PairSelection.WHITELIST && explicitAxisPairs.isEmpty()) + throw new IllegalArgumentException("explicitAxisPairs must be non-empty for WHITELIST."); + if (boundsPolicy == BoundsPolicy.CANONICAL_BY_FEATURE && (canonicalPolicyId == null || canonicalPolicyId.isBlank())) + throw new IllegalArgumentException("canonicalPolicyId is required for CANONICAL_BY_FEATURE."); + if (componentPairsMode && (componentIndexEnd < componentIndexStart)) + throw new IllegalArgumentException("componentIndexEnd must be >= componentIndexStart."); + if (minAvgCountPerCell < 0.0) + throw new IllegalArgumentException("minAvgCountPerCell cannot be negative."); + } + + public static Builder newBuilder(String name) { + return new Builder(name); + } + + public static final class Builder { + private final String name; + private String description = ""; + + private PairSelection pairSelection = PairSelection.ALL; + private ScoreMetric scoreMetric = ScoreMetric.PEARSON; + private int topK = 20; + private double scoreThreshold = 0.2; + + private boolean componentPairsMode = true; + private int componentIndexStart = 0; + private int componentIndexEnd = 1; + private boolean includeSelfPairs = false; + private boolean orderedPairs = false; + + private final List explicitAxisPairs = new ArrayList<>(); + + private int binsX = 64; + private int binsY = 64; + private BoundsPolicy boundsPolicy = BoundsPolicy.DATA_MIN_MAX; + private String canonicalPolicyId = "default"; + + private double minAvgCountPerCell = 3.0; + + private OutputKind outputKind = OutputKind.PDF_AND_CDF; + private boolean cacheEnabled = true; + private boolean saveThumbnails = true; + + private String cohortALabel = "A"; + private String cohortBLabel = "B"; + + private Builder(String name) { + this.name = name; + } + + public Builder description(String v) { + this.description = v; + return this; + } + + public Builder pairSelection(PairSelection v) { + this.pairSelection = v; + return this; + } + + public Builder scoreMetric(ScoreMetric v) { + this.scoreMetric = v; + return this; + } + + public Builder topK(int v) { + this.topK = v; + return this; + } + + public Builder scoreThreshold(double v) { + this.scoreThreshold = v; + return this; + } + + public Builder componentPairsMode(boolean v) { + this.componentPairsMode = v; + return this; + } + + public Builder componentIndexRange(int startInclusive, int endInclusive) { + this.componentIndexStart = startInclusive; + this.componentIndexEnd = endInclusive; + return this; + } + + public Builder includeSelfPairs(boolean v) { + this.includeSelfPairs = v; + return this; + } + + public Builder orderedPairs(boolean v) { + this.orderedPairs = v; + return this; + } + + public Builder addAxisPair(AxisPair p) { + this.explicitAxisPairs.add(Objects.requireNonNull(p)); + return this; + } + + public Builder addAxisPair(AxisParams x, AxisParams y) { + return addAxisPair(new AxisPair(x, y)); + } + + public Builder clearAxisPairs() { + this.explicitAxisPairs.clear(); + return this; + } + + public Builder bins(int sameForBoth) { + this.binsX = sameForBoth; + this.binsY = sameForBoth; + return this; + } + + public Builder bins(int bx, int by) { + this.binsX = bx; + this.binsY = by; + return this; + } + + public Builder boundsPolicy(BoundsPolicy v) { + this.boundsPolicy = v; + return this; + } + + public Builder canonicalPolicyId(String v) { + this.canonicalPolicyId = v; + return this; + } + + public Builder minAvgCountPerCell(double v) { + this.minAvgCountPerCell = v; + return this; + } + + public Builder outputKind(OutputKind v) { + this.outputKind = v; + return this; + } + + public Builder cacheEnabled(boolean v) { + this.cacheEnabled = v; + return this; + } + + public Builder saveThumbnails(boolean v) { + this.saveThumbnails = v; + return this; + } + + public Builder cohortLabels(String a, String b) { + this.cohortALabel = a; + this.cohortBLabel = b; + return this; + } + + public JpdfRecipe build() { + return new JpdfRecipe(this); + } + } + + // ---------- Getters ---------- + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public PairSelection getPairSelection() { + return pairSelection; + } + + public ScoreMetric getScoreMetric() { + return scoreMetric; + } + + public int getTopK() { + return topK; + } + + public double getScoreThreshold() { + return scoreThreshold; + } + + public boolean isComponentPairsMode() { + return componentPairsMode; + } + + public int getComponentIndexStart() { + return componentIndexStart; + } + + public int getComponentIndexEnd() { + return componentIndexEnd; + } + + public boolean isIncludeSelfPairs() { + return includeSelfPairs; + } + + public boolean isOrderedPairs() { + return orderedPairs; + } + + public List getExplicitAxisPairs() { + return explicitAxisPairs; + } + + public int getBinsX() { + return binsX; + } + + public int getBinsY() { + return binsY; + } + + public BoundsPolicy getBoundsPolicy() { + return boundsPolicy; + } + + public String getCanonicalPolicyId() { + return canonicalPolicyId; + } + + public double getMinAvgCountPerCell() { + return minAvgCountPerCell; + } + + public OutputKind getOutputKind() { + return outputKind; + } + + public boolean isCacheEnabled() { + return cacheEnabled; + } + + public boolean isSaveThumbnails() { + return saveThumbnails; + } + + public String getCohortALabel() { + return cohortALabel; + } + + public String getCohortBLabel() { + return cohortBLabel; + } + + // ---------- Convenience ---------- + + @Override + public String toString() { + return "JpdfRecipe{" + + "name='" + name + '\'' + + ", pairSelection=" + pairSelection + + ", scoreMetric=" + scoreMetric + + ", topK=" + topK + + ", scoreThreshold=" + scoreThreshold + + ", componentPairsMode=" + componentPairsMode + + ", componentIndexRange=[" + componentIndexStart + "," + componentIndexEnd + "]" + + ", includeSelfPairs=" + includeSelfPairs + + ", orderedPairs=" + orderedPairs + + ", explicitAxisPairs=" + explicitAxisPairs.size() + + ", binsX=" + binsX + + ", binsY=" + binsY + + ", boundsPolicy=" + boundsPolicy + + ", canonicalPolicyId='" + canonicalPolicyId + '\'' + + ", minAvgCountPerCell=" + minAvgCountPerCell + + ", outputKind=" + outputKind + + ", cacheEnabled=" + cacheEnabled + + ", saveThumbnails=" + saveThumbnails + + ", cohortALabel='" + cohortALabel + '\'' + + ", cohortBLabel='" + cohortBLabel + '\'' + + '}'; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java new file mode 100644 index 00000000..a84374e4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -0,0 +1,850 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.utils.ResourceUtils; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.SnapshotParameters; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.ImageView; +import javafx.scene.image.WritableImage; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.BorderStroke; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.TilePane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * PairGridPane (TilePane-backed) + * ------------------------------ + * Responsive grid of heatmap thumbnails that wraps on resize without + * forcing the parent to expand. + *

+ * Header slot via {@link #setHeader(Node)} to host controls like Search/Sort. + *

+ * New (appearance & UX): + * - Grid-wide "display state" (palette, legend, background, smoothing, gamma, clip%, range mode) + * - Setters to update existing tiles immediately and apply to future tiles + * - Global range compute across visible items + * - Per-tile context menu (autoscale, pin fixed range from tile, compute global, export PNG) + * - Hover preview: temporarily autoscale a tile while hovered if grid is in Global/Fixed + */ +public final class PairGridPane extends BorderPane { + // --- Streaming support (thread-safe buffer -> periodic FX flush) --- + private final Object streamLock = new Object(); + private final List pendingBuffer = new ArrayList<>(); + private long lastFlushNanos = System.nanoTime(); + private boolean flushScheduled = false; + + // ---------- Public API types ---------- + + public static final class PairItem { + public final String xLabel; + public final String yLabel; + + /** + * Optional component indices for sort-by-i/j convenience; may be null. + */ + public final Integer iIndex; + public final Integer jIndex; + + public final List> grid; + public final GridDensityResult res; + public final boolean useCDF; + public final boolean flipY; + public final HeatmapThumbnailView.PaletteKind palette; + public final Double center; + public final boolean autoRange; + public final Double vmin; + public final Double vmax; + public final boolean showLegend; + public final Double score; + public final Object userData; + + private PairItem(Builder b) { + this.xLabel = b.xLabel; + this.yLabel = b.yLabel; + this.iIndex = b.iIndex; + this.jIndex = b.jIndex; + this.grid = b.grid; + this.res = b.res; + this.useCDF = b.useCDF; + this.flipY = b.flipY; + this.palette = b.palette; + this.center = b.center; + this.autoRange = b.autoRange; + this.vmin = b.vmin; + this.vmax = b.vmax; + this.showLegend = b.showLegend; + this.score = b.score; + this.userData = b.userData; + } + + public static Builder newBuilder(String xLabel, String yLabel) { + return new Builder(xLabel, yLabel); + } + + public static final class Builder { + private final String xLabel; + private final String yLabel; + + private Integer iIndex = null; + private Integer jIndex = null; + + private List> grid; + private GridDensityResult res; + private boolean useCDF = false; + private boolean flipY = true; + + private HeatmapThumbnailView.PaletteKind palette = HeatmapThumbnailView.PaletteKind.SEQUENTIAL; + private Double center = 0.0; + private boolean autoRange = true; + private Double vmin = 0.0; + private Double vmax = 1.0; + private boolean showLegend = false; + + private Double score = null; + private Object userData = null; + + private Builder(String xLabel, String yLabel) { + this.xLabel = Objects.requireNonNull(xLabel, "xLabel"); + this.yLabel = Objects.requireNonNull(yLabel, "yLabel"); + } + + public Builder grid(List> g) { + this.grid = g; + this.res = null; + return this; + } + + public Builder from(GridDensityResult r, boolean useCDF, boolean flipY) { + this.res = r; + this.grid = null; + this.useCDF = useCDF; + this.flipY = flipY; + return this; + } + + public Builder palette(HeatmapThumbnailView.PaletteKind p) { + this.palette = p; + return this; + } + + public Builder divergingCenter(double c) { + this.center = c; + return this; + } + + public Builder autoRange(boolean on) { + this.autoRange = on; + return this; + } + + public Builder fixedRange(double min, double max) { + this.autoRange = false; + this.vmin = min; + this.vmax = max; + return this; + } + + public Builder flipY(boolean flip) { + this.flipY = flip; + return this; + } + + public Builder showLegend(boolean show) { + this.showLegend = show; + return this; + } + + public Builder score(Double s) { + this.score = s; + return this; + } + + public Builder userData(Object d) { + this.userData = d; + return this; + } + + /** + * Optional: attach component indices for downstream sort-by-i/j. + */ + public Builder indices(Integer i, Integer j) { + this.iIndex = i; + this.jIndex = j; + return this; + } + + public PairItem build() { + if (grid == null && res == null) + throw new IllegalArgumentException("Provide grid OR GridDensityResult."); + return new PairItem(this); + } + } + } + + public static final class CellClick { + public final int index; + public final PairItem item; + public final String xLabel; + public final String yLabel; + + public CellClick(int index, PairItem item) { + this.index = index; + this.item = item; + this.xLabel = item.xLabel; + this.yLabel = item.yLabel; + } + } + + /** + * Range struct for global/fixed usage. + */ + public static final class Range { + public final double vmin, vmax; + + public Range(double vmin, double vmax) { + this.vmin = vmin; + this.vmax = vmax; + } + } + + public enum RangeMode {AUTO, GLOBAL, FIXED} + + /** + * Captures grid-wide display preferences to apply to tiles. + */ + private static final class DisplayState { + HeatmapThumbnailView.PaletteKind palette = HeatmapThumbnailView.PaletteKind.SEQUENTIAL; + boolean legend = false; + boolean smoothing = true; + double gamma = 1.0; + double clipLow = 0.0; + double clipHigh = 0.0; + Color background = Color.BLACK; + RangeMode rangeMode = RangeMode.AUTO; + double fixedMin = Double.NaN; + double fixedMax = Double.NaN; + } + + // ---------- Fields & configuration ---------- + + private final TilePane tilePane = new TilePane(); + private final ScrollPane scroller = new ScrollPane(tilePane); + private final StackPane contentStack = new StackPane(); + + /** + * Optional header area shown above the grid (e.g., Search/Sort). + */ + private Node headerNode = null; + + private VBox placeholder; + private final Label placeholderLabel = new Label("No results loaded"); + + private final List allItems = new ArrayList<>(); + private List visibleItems = new ArrayList<>(); + + private Predicate filter = null; + private Comparator sorter = null; + + private int columns = 4; // hint only; wrapping adapts to viewport width + private double cellWidth = 180; + private double cellHeight = 140; + private double cellHGap = 8; + private double cellVGap = 8; + private Insets cellPadding = new Insets(6); + + private boolean showScoresInHeader = true; + private Consumer onCellClick = null; + + //display state applied to tiles + private final DisplayState display = new DisplayState(); + private Range globalRange = null; + + // Optional export hook + public static final class ExportRequest { + public final PairItem item; + public final WritableImage image; + + public ExportRequest(PairItem item, WritableImage image) { + this.item = item; + this.image = image; + } + } + + private Consumer onExportRequest; + + public void setOnExportRequest(Consumer handler) { + this.onExportRequest = handler; + } + + // ---------- Construction ---------- + + public PairGridPane() { + // ScrollPane: cap viewport so it never pushes parent + scroller.setFitToWidth(true); + scroller.setFitToHeight(false); // content height won’t try to expand the parent + scroller.setPannable(true); + scroller.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroller.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroller.setMinHeight(0); + scroller.setPrefHeight(Region.USE_COMPUTED_SIZE); + scroller.setMaxHeight(Region.USE_COMPUTED_SIZE); + // modest defaults; parent can override via setPreferredViewport(...) + scroller.setPrefViewportWidth(720); + scroller.setPrefViewportHeight(420); + + // TilePane: width bound to the viewport; height doesn’t drive parent + tilePane.setPadding(new Insets(6)); + tilePane.setHgap(cellHGap); + tilePane.setVgap(cellVGap); + tilePane.setTileAlignment(Pos.TOP_LEFT); + tilePane.setPrefColumns(columns); + tilePane.setMinWidth(0); + tilePane.setPrefWidth(0); + tilePane.setMaxWidth(Region.USE_PREF_SIZE); + tilePane.setMinHeight(0); + tilePane.setPrefHeight(Region.USE_COMPUTED_SIZE); + tilePane.setMaxHeight(Region.USE_PREF_SIZE); + + // Bind tilePane width to the viewport so wrapping responds to resize + scroller.viewportBoundsProperty().addListener((obs, ov, nb) -> { + double w = Math.max(0, nb.getWidth() - 2); + tilePane.setPrefWidth(w); + }); + + // Placeholder (image + label) establishes an initial footprint + ImageView iv = ResourceUtils.loadIcon("data", 256); + placeholder = new VBox(10, iv, placeholderLabel); + placeholder.setAlignment(Pos.CENTER); + placeholder.setMinSize(0, 0); + placeholder.setPrefSize(560, 360); // gives initial size w/o pushing parent + + contentStack.getChildren().addAll(scroller, placeholder); + placeholder.toFront(); + + // Layout: optional header at TOP, content in CENTER + setCenter(contentStack); + } + + // ---------- Header API ---------- + + /** + * Optional header node above the grid (e.g., search/sort bar). Pass null to clear. + */ + public void setHeader(Node header) { + this.headerNode = header; + if (header == null) { + setTop(null); + } else { + BorderPane.setAlignment(header, Pos.CENTER_LEFT); + BorderPane.setMargin(header, new Insets(2, 8, 2, 8)); + setTop(header); + } + } + + // Allow parent to suggest viewport size (prevents ScrollPane from enlarging parent) + public void setPreferredViewport(double width, double height) { + scroller.setPrefViewportWidth(Math.max(0, width)); + scroller.setPrefViewportHeight(Math.max(0, height)); + } + + // ---------- Public API (items) ---------- + + public void setItems(List items) { + allItems.clear(); + if (items != null) allItems.addAll(items); + applyFilterSortAndRender(); + } + + /** + * Legacy add (rebuilds grid, respects filter/sort). + */ + public void addItem(PairItem item) { + if (item != null) { + allItems.add(item); + applyFilterSortAndRender(); + } + } + + /** + * Streaming add: buffer items and flush them to the UI based on either + * a count threshold (flushN) or a time threshold (flushMs). + * + * @param item the item to append + * @param flushN flush when at least this many pending items (>=1) + * @param flushMs flush if at least this many ms since last flush (>=0; 0 disables) + * @param incrementalSort if true, apply current sorter on each flush; if false, preserve append order + */ + public void addItemStreaming(PairItem item, int flushN, int flushMs, boolean incrementalSort) { + if (item == null) return; + + // sanitize thresholds + if (flushN < 1) flushN = 1; + if (flushMs < 0) flushMs = 0; + + boolean schedule = false; + long now = System.nanoTime(); + + synchronized (streamLock) { + pendingBuffer.add(item); + long elapsedMs = (now - lastFlushNanos) / 1_000_000L; + if (pendingBuffer.size() >= flushN || (flushMs > 0 && elapsedMs >= flushMs)) { + if (!flushScheduled) { + flushScheduled = true; + schedule = true; + } + } + } + + if (schedule) { + final boolean doSort = incrementalSort; + Platform.runLater(() -> { + List toAdd; + synchronized (streamLock) { + toAdd = new ArrayList<>(pendingBuffer); + pendingBuffer.clear(); + lastFlushNanos = System.nanoTime(); + flushScheduled = false; + } + allItems.addAll(toAdd); + applyFilterSortAndRenderInternal(doSort); + }); + } + } + + public void clearItems() { + allItems.clear(); + applyFilterSortAndRender(); + } + + public void setFilter(Predicate filter) { + this.filter = filter; + applyFilterSortAndRender(); + } + + public void setSorter(Comparator sorter) { + this.sorter = sorter; + applyFilterSortAndRender(); + } + + public void sortByScoreDescending() { + setSorter(Comparator.nullsLast((a, b) -> { + double sa = a.score == null ? Double.NEGATIVE_INFINITY : a.score; + double sb = b.score == null ? Double.NEGATIVE_INFINITY : b.score; + return -Double.compare(sa, sb); + })); + } + + /** + * Preferred columns hint; wrapping still adapts to viewport width. + */ + public void setColumns(int cols) { + this.columns = Math.max(1, cols); + tilePane.setPrefColumns(this.columns); + renderTiles(); + } + + /** + * Sets the preferred content size of the heatmap region inside each tile. + */ + public void setCellSize(double width, double height) { + this.cellWidth = Math.max(60, width); + this.cellHeight = Math.max(60, height); + renderTiles(); + } + + public void setCellGaps(double hgap, double vgap) { + this.cellHGap = Math.max(0, hgap); + this.cellVGap = Math.max(0, vgap); + tilePane.setHgap(cellHGap); + tilePane.setVgap(cellVGap); + renderTiles(); + } + + public void setCellPadding(Insets insets) { + this.cellPadding = insets == null ? Insets.EMPTY : insets; + renderTiles(); + } + + public void setShowScoresInHeader(boolean show) { + this.showScoresInHeader = show; + renderTiles(); + } + + public void setOnCellClick(Consumer onClick) { + this.onCellClick = onClick; + } + + public List getVisibleItems() { + return Collections.unmodifiableList(visibleItems); + } + + // ---------- Public API (appearance) ---------- + + public void setPalette(HeatmapThumbnailView.PaletteKind p) { + if (p == null) return; + display.palette = p; + refreshExistingTiles(); + } + + public void setLegendVisible(boolean visible) { + display.legend = visible; + refreshExistingTiles(); + } + + public void setBackgroundColor(Color c) { + display.background = (c == null ? Color.BLACK : c); + refreshExistingTiles(); + } + + public void setImageSmoothing(boolean smoothing) { + display.smoothing = smoothing; + refreshExistingTiles(); + } + + public void setGamma(double gamma) { + display.gamma = clamp(gamma, 0.1, 5.0); + refreshExistingTiles(); + } + + public void setClipLowPct(double pct) { + display.clipLow = clamp(pct, 0.0, 20.0); + recomputeGlobalIfNeeded(); + refreshExistingTiles(); + } + + public void setClipHighPct(double pct) { + display.clipHigh = clamp(pct, 0.0, 20.0); + recomputeGlobalIfNeeded(); + refreshExistingTiles(); + } + + public void setRangeModeAuto() { + display.rangeMode = RangeMode.AUTO; + refreshExistingTiles(); + } + + public void setRangeModeGlobal() { + display.rangeMode = RangeMode.GLOBAL; + this.globalRange = computeGlobalRange(); + refreshExistingTiles(); + } + + public void setRangeModeFixed(double vmin, double vmax) { + if (!(Double.isFinite(vmin) && Double.isFinite(vmax))) return; + double mn = Math.min(vmin, vmax); + double mx = Math.max(vmin, vmax); + if (mx - mn < 1e-12) mx = mn + 1e-12; + display.rangeMode = RangeMode.FIXED; + display.fixedMin = mn; + display.fixedMax = mx; + refreshExistingTiles(); + } + + /** + * Computes min/max across visible items’ data (grids or res). + */ + public Range computeGlobalRange() { + double lo = Double.POSITIVE_INFINITY, hi = Double.NEGATIVE_INFINITY; + for (PairItem it : visibleItems) { + List> g = it.grid; + if (g == null && it.res != null) { + g = (it.useCDF ? it.res.cdfAsListGrid() : it.res.pdfAsListGrid()); + } + if (g == null) continue; + for (int r = 0; r < g.size(); r++) { + List row = g.get(r); + if (row == null) continue; + for (int c = 0; c < row.size(); c++) { + Double vv = row.get(c); + if (vv != null && Double.isFinite(vv)) { + if (vv < lo) lo = vv; + if (vv > hi) hi = vv; + } + } + } + } + if (!Double.isFinite(lo) || !Double.isFinite(hi)) return null; + if (hi - lo < 1e-12) hi = lo + 1e-12; + return new Range(lo, hi); + } + + // ---------- Internal rendering ---------- + + private void applyFilterSortAndRender() { + List tmp = new ArrayList<>(); + for (PairItem it : allItems) { + if (filter == null || filter.test(it)) tmp.add(it); + } + if (sorter != null) tmp.sort(sorter); + visibleItems = tmp; + renderTiles(); + } + + /** + * Recompute visibleItems with optional sort, then render. + */ + private void applyFilterSortAndRenderInternal(boolean doSort) { + List tmp = new ArrayList<>(); + for (PairItem it : allItems) { + if (filter == null || filter.test(it)) tmp.add(it); + } + if (doSort && sorter != null) { + tmp.sort(sorter); + } + visibleItems = tmp; + renderTiles(); + } + + private void renderTiles() { + tilePane.getChildren().clear(); + if (visibleItems.isEmpty()) { + placeholder.setVisible(true); + scroller.setVisible(false); + return; + } + placeholder.setVisible(false); + scroller.setVisible(true); + + for (int i = 0; i < visibleItems.size(); i++) { + PairItem item = visibleItems.get(i); + Node cell = buildCell(i, item); + tilePane.getChildren().add(cell); + } + } + + private Node buildCell(int index, PairItem item) { + String title = item.xLabel + " | " + item.yLabel; + if (showScoresInHeader && item.score != null) { + title += " " + String.format("score=%.4f", item.score); + } + Label header = new Label(title); + header.setPadding(new Insets(2, 4, 2, 4)); + + HeatmapThumbnailView view = new HeatmapThumbnailView(); + view.setPrefSize(cellWidth, cellHeight); + view.setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + view.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + + // Apply item-specific basics first (flip, palette choice) + view.setFlipY(item.flipY); + if (item.palette == HeatmapThumbnailView.PaletteKind.DIVERGING) { + double c = item.center == null ? 0.0 : item.center; + view.useDivergingPalette(c); + } else { + view.useSequentialPalette(); + } + view.setShowLegend(item.showLegend); + + // Data + if (item.grid != null) { + view.setGrid(item.grid); + } else if (item.res != null) { + view.setFromGridDensity(item.res, item.useCDF, item.flipY); + } + + // Apply grid-wide display state (overrides range choice) + applyViewDisplay(view, item); + + BorderPane cell = new BorderPane(); + cell.setTop(header); + BorderPane.setAlignment(header, Pos.CENTER_LEFT); + BorderPane.setMargin(header, new Insets(0, 0, 4, 0)); + + StackPane center = new StackPane(view); + center.setPadding(cellPadding); + cell.setCenter(center); + + // keep programmatic border (no inline CSS classes) + cell.setBorder(new Border(new BorderStroke( + Color.web("#3A3A3A"), + BorderStrokeStyle.SOLID, + new CornerRadii(6), + new BorderWidths(1.0) + ))); + cell.setBackground(null); + + // Click handling + cell.addEventHandler(MouseEvent.MOUSE_ENTERED, e -> cell.setCursor(Cursor.HAND)); + cell.addEventHandler(MouseEvent.MOUSE_EXITED, e -> cell.setCursor(Cursor.DEFAULT)); + cell.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { + if (onCellClick != null) onCellClick.accept(new CellClick(index, item)); + }); + + // Context menu per-tile + ContextMenu cm = buildContextMenuForTile(view, item); + cell.setOnContextMenuRequested(e -> cm.show(cell, e.getScreenX(), e.getScreenY())); + view.setOnContextMenuRequested(e -> cm.show(view, e.getScreenX(), e.getScreenY())); + + // Hover preview: temporarily autoscale this tile if grid is in Global/Fixed + cell.setOnMouseEntered(e -> { + if (display.rangeMode != RangeMode.AUTO) { + // Temporarily show local auto contrast + view.setAutoRange(true); + // respect clip/gamma/background/smoothing + view.setClipPercent(display.clipLow, display.clipHigh); + view.setGamma(display.gamma); + view.setImageSmoothing(display.smoothing); + view.setBackgroundColor(display.background); + } + }); + cell.setOnMouseExited(e -> { + if (display.rangeMode != RangeMode.AUTO) { + // Restore grid-wide settings + applyViewDisplay(view, item); + } + }); + + return cell; + } + + private ContextMenu buildContextMenuForTile(HeatmapThumbnailView view, PairItem item) { + MenuItem autoscaleThis = new MenuItem("Autoscale this tile"); + autoscaleThis.setOnAction(e -> { + view.setAutoRange(true); + view.setClipPercent(display.clipLow, display.clipHigh); + }); + + MenuItem pinFixedFromThis = new MenuItem("Pin fixed range from this tile"); + pinFixedFromThis.setOnAction(e -> { + double mn = view.getVmin(); + double mx = view.getVmax(); + setRangeModeFixed(mn, mx); + }); + + MenuItem computeGlobal = new MenuItem("Compute global range from visible"); + computeGlobal.setOnAction(e -> { + Range r = computeGlobalRange(); + if (r != null) { + this.globalRange = r; + setRangeModeGlobal(); + } + }); + + MenuItem exportPng = new MenuItem("Export PNG…"); + exportPng.setOnAction(e -> { + if (onExportRequest != null) { + SnapshotParameters sp = new SnapshotParameters(); + WritableImage snap = view.snapshot(sp, null); + onExportRequest.accept(new ExportRequest(item, snap)); + } + }); + + ContextMenu cm = new ContextMenu(autoscaleThis, pinFixedFromThis, computeGlobal, exportPng); + + // Optional live summary row (title only) as disabled CustomMenuItem + Label meta = new Label(item.xLabel + " vs " + item.yLabel); + CustomMenuItem info = new CustomMenuItem(meta, false); + info.setDisable(true); + cm.getItems().add(0, info); + + return cm; + } + + /** + * Apply current grid-wide display state to a specific view. + */ + private void applyViewDisplay(HeatmapThumbnailView view, PairItem item) { + // palette base is already set from the item; allow grid palette override if desired + if (display.palette == HeatmapThumbnailView.PaletteKind.DIVERGING) { + double c = (item.center != null) ? item.center : view.getDivergingCenter(); + view.useDivergingPalette(c); + } else { + view.useSequentialPalette(); + } + + view.setShowLegend(display.legend); + view.setBackgroundColor(display.background); + view.setImageSmoothing(display.smoothing); + view.setGamma(display.gamma); + view.setClipPercent(display.clipLow, display.clipHigh); + + // Range precedence: FIXED > GLOBAL > item-setting + if (display.rangeMode == RangeMode.FIXED && + Double.isFinite(display.fixedMin) && Double.isFinite(display.fixedMax)) { + view.setFixedRange(display.fixedMin, display.fixedMax); + } else if (display.rangeMode == RangeMode.GLOBAL && globalRange != null) { + view.setFixedRange(globalRange.vmin, globalRange.vmax); + } else { + // respect item preference + if (!item.autoRange && item.vmin != null && item.vmax != null) { + view.setFixedRange(item.vmin, item.vmax); + } else { + view.setAutoRange(true); + } + } + } + + /** + * Re-apply display state to all existing tiles. + */ + private void refreshExistingTiles() { + for (Node n : tilePane.getChildren()) { + if (!(n instanceof BorderPane bp)) continue; + Node center = bp.getCenter(); + if (!(center instanceof StackPane sp)) continue; + if (sp.getChildren().isEmpty()) continue; + + Node hv = sp.getChildren().get(0); + if (!(hv instanceof HeatmapThumbnailView view)) continue; + + // Find the PairItem associated to this cell (by header label lookup index) + // Safer: rebuild association by index position in tilePane. + int idx = tilePane.getChildren().indexOf(n); + if (idx < 0 || idx >= visibleItems.size()) continue; + PairItem item = visibleItems.get(idx); + + applyViewDisplay(view, item); + } + } + + private void recomputeGlobalIfNeeded() { + if (display.rangeMode == RangeMode.GLOBAL) { + this.globalRange = computeGlobalRange(); + } + } + + // In PairGridPane (public helper) + public interface ViewConsumer { + void accept(HeatmapThumbnailView v); + } + + public void forEachView(ViewConsumer fn) { + for (Node n : tilePane.getChildren()) { + if (!(n instanceof BorderPane bp)) continue; + Node center = bp.getCenter(); + if (!(center instanceof StackPane sp) || sp.getChildren().isEmpty()) continue; + Node hv = sp.getChildren().get(0); + if (hv instanceof HeatmapThumbnailView v) fn.accept(v); + } + } + + // ---------- Utils ---------- + + private static double clamp(double v, double lo, double hi) { + return (v < lo) ? lo : (v > hi ? hi : v); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java new file mode 100644 index 00000000..06ec7050 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java @@ -0,0 +1,260 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * PairGridRecord + * --------------- + * Minimal, serializable container for a single (X,Y) joint surface result intended for UI / IO. + *

+ * Carries: + * - Axes (AxisParams snapshots used during compute) + * - The canonical GridSpec used + * - Optional PDF and/or CDF grids (List>), same shape as GridSpec + * - Provenance objects for PDF/CDF (if present) + *

+ * Notes: + * - This is a data record, not a compute class. + * - It is agnostic to the source (single cohort vs A/B); use the static factories. + * - If you plan to persist, prefer List> over primitive arrays for JSON friendliness. + * + * @author Sean Phillips + */ +public final class PairGridRecord implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final AxisParams xAxis; + private final AxisParams yAxis; + private final GridSpec grid; + + /** + * Optional PDF values; null if not requested. + */ + private final List> pdf; + + /** + * Optional CDF values; null if not requested. + */ + private final List> cdf; + + /** + * Provenance for the PDF; null when pdf == null. + */ + private final JpdfProvenance pdfProvenance; + + /** + * Provenance for the CDF; null when cdf == null. + */ + private final JpdfProvenance cdfProvenance; + + /** + * Optional human label for this record (e.g., cohort name or composite tag). + */ + private final String label; + + // ---------------------------- Constructors ---------------------------- + + private PairGridRecord(Builder b) { + this.xAxis = b.xAxis; + this.yAxis = b.yAxis; + this.grid = b.grid; + this.pdf = b.pdf; + this.cdf = b.cdf; + this.pdfProvenance = b.pdfProvenance; + this.cdfProvenance = b.cdfProvenance; + this.label = b.label; + } + + public static Builder newBuilder(AxisParams xAxis, AxisParams yAxis, GridSpec grid) { + return new Builder(xAxis, yAxis, grid); + } + + public static final class Builder { + private final AxisParams xAxis; + private final AxisParams yAxis; + private final GridSpec grid; + + private List> pdf; + private List> cdf; + private JpdfProvenance pdfProvenance; + private JpdfProvenance cdfProvenance; + private String label; + + private Builder(AxisParams xAxis, AxisParams yAxis, GridSpec grid) { + this.xAxis = Objects.requireNonNull(xAxis, "xAxis"); + this.yAxis = Objects.requireNonNull(yAxis, "yAxis"); + this.grid = Objects.requireNonNull(grid, "grid"); + } + + public Builder pdf(List> v) { + this.pdf = v; + return this; + } + + public Builder cdf(List> v) { + this.cdf = v; + return this; + } + + public Builder pdfProvenance(JpdfProvenance p) { + this.pdfProvenance = p; + return this; + } + + public Builder cdfProvenance(JpdfProvenance p) { + this.cdfProvenance = p; + return this; + } + + public Builder label(String v) { + this.label = v; + return this; + } + + public PairGridRecord build() { + return new PairGridRecord(this); + } + } + + // ---------------------------- Factories ---------------------------- + + /** + * Create a record from a single cohort GridDensityResult. + * Use this from JpdfBatchEngine outputs. + */ + public static PairGridRecord fromSingle(AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + GridDensityResult res, + JpdfRecipe.OutputKind ok, + JpdfProvenance pdfProv, + JpdfProvenance cdfProv, + String label) { + Objects.requireNonNull(res, "res"); + + List> pdf = null, cdf = null; + if (ok == JpdfRecipe.OutputKind.PDF_ONLY || ok == JpdfRecipe.OutputKind.PDF_AND_CDF) { + pdf = safeCopy2D(res.pdfAsListGrid()); + } + if (ok == JpdfRecipe.OutputKind.CDF_ONLY || ok == JpdfRecipe.OutputKind.PDF_AND_CDF) { + cdf = safeCopy2D(res.cdfAsListGrid()); + } + + return PairGridRecord.newBuilder(xAxis, yAxis, grid) + .pdf(pdf) + .cdf(cdf) + .pdfProvenance(pdfProv) + .cdfProvenance(cdfProv) + .label(label) + .build(); + } + + /** + * Create two records (A and B) plus an optional signed-difference record from ABComparisonEngine. + * The diff record is returned as the third element; it may be null if not requested. + */ + public static Triple fromAb(ABComparisonEngine.AbResult ab, + AxisParams xAxis, + AxisParams yAxis, + JpdfRecipe.OutputKind ok, + String labelA, + String labelB, + String labelDiff) { + Objects.requireNonNull(ab, "ab"); + GridSpec grid = ab.grid; + + PairGridRecord a = fromSingle( + xAxis, yAxis, grid, ab.a, ok, + ab.pdfProvA, ab.cdfProvA, labelA + ); + PairGridRecord b = fromSingle( + xAxis, yAxis, grid, ab.b, ok, + ab.pdfProvB, ab.cdfProvB, labelB + ); + + PairGridRecord diff = null; + boolean needPdf = ok == JpdfRecipe.OutputKind.PDF_ONLY || ok == JpdfRecipe.OutputKind.PDF_AND_CDF; + boolean needCdf = ok == JpdfRecipe.OutputKind.CDF_ONLY || ok == JpdfRecipe.OutputKind.PDF_AND_CDF; + + List> pdf = needPdf ? safeCopy2D(ab.pdfDiff) : null; + List> cdf = needCdf ? safeCopy2D(ab.cdfDiff) : null; + + if ((pdf != null) || (cdf != null)) { + diff = PairGridRecord.newBuilder(xAxis, yAxis, grid) + .pdf(pdf) + .cdf(cdf) + .pdfProvenance(ab.pdfProvDiff) + .cdfProvenance(ab.cdfProvDiff) + .label(labelDiff) + .build(); + } + + return new Triple<>(a, b, diff); + } + + // ---------------------------- Getters ---------------------------- + + public AxisParams getxAxis() { + return xAxis; + } + + public AxisParams getyAxis() { + return yAxis; + } + + public GridSpec getGrid() { + return grid; + } + + public List> getPdf() { + return pdf; + } + + public List> getCdf() { + return cdf; + } + + public JpdfProvenance getPdfProvenance() { + return pdfProvenance; + } + + public JpdfProvenance getCdfProvenance() { + return cdfProvenance; + } + + public String getLabel() { + return label; + } + + // ---------------------------- Helpers ---------------------------- + + private static List> safeCopy2D(List> src) { + if (src == null) return null; + List> out = new ArrayList<>(src.size()); + for (List row : src) out.add(new ArrayList<>(row)); + return out; + } + + /** + * Tiny generic triple carrier (kept local to avoid extra dependencies). + */ + public static final class Triple implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + public final A first; + public final B second; + public final C third; + + public Triple(A first, B second, C third) { + this.first = first; + this.second = second; + this.third = third; + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java new file mode 100644 index 00000000..5c282940 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java @@ -0,0 +1,537 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.AnalysisUtils; +import edu.jhuapl.trinity.utils.metric.Metric; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * PairScorer + * ---------- + * Pre-ranks 2D axis pairs for Joint PDF/CDF generation. + *

+ * Primary path: COMPONENT_AT_DIMENSION pairs over an index range (fast, cached columns). + * Also includes a convenience scorer for an arbitrary AxisParams pair (slower; extracts scalars). + *

+ * Score metrics (normalized to ~[0,1] where possible): + * - PEARSON : |r| (abs Pearson correlation), robust to sign + * - KENDALL : |tau_b| (approx; down-samples if very large N) + * - MI_LITE : NMI = I(X;Y) / sqrt(H(X) * H(Y)) using fixed equal-width binning + add-one smoothing + * - DIST_CORR : distance correlation (biased estimator), ∈ [0,1] + *

+ * Data sufficiency flag: sufficient iff N / (binsX*binsY) >= minAvgCountPerCell (when bins params provided). + *

+ * Notes: + * - This class does not mutate input data and is dependency-free. + * - MI-lite assumes finite values; if inputs are near-constant, entropy guards avoid divide-by-zero. + * - Kendall tau computation is O(N^2); for N>MAX_KENDALL_N we stride-sample to MAX_KENDALL_N. + * + * @author Sean Phillips + */ +public final class PairScorer { + + // ----------- Result type ----------- + + public static final class PairScore implements Serializable, Comparable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * For component-mode: i and j are component indices. For axis-mode: both are -1. + */ + public final int i; + public final int j; + public final double score; // normalized score used for ranking (>=0) + public final long nSamples; + public final boolean sufficient; + public final String reason; // optional note (e.g., "low variance", "insufficient N") + + public PairScore(int i, int j, double score, long nSamples, boolean sufficient, String reason) { + this.i = i; + this.j = j; + this.score = score; + this.nSamples = nSamples; + this.sufficient = sufficient; + this.reason = reason; + } + + @Override + public int compareTo(PairScore o) { + return -Double.compare(this.score, o.score); + } // desc + + @Override + public String toString() { + return "PairScore{" + + "i=" + i + ", j=" + j + + ", score=" + score + + ", n=" + nSamples + + ", sufficient=" + sufficient + + (reason != null ? (", reason='" + reason + '\'') : "") + + '}'; + } + } + + // ----------- Config ----------- + + public static final class Config { + public final JpdfRecipe.ScoreMetric metric; + public final int miBins; // for MI-lite (equal-width) + public final int maxKendallN; // downsample limit for Kendall + public final Integer binsX; // for sufficiency flag (nullable) + public final Integer binsY; // for sufficiency flag (nullable) + public final double minAvgCountPerCell; // sufficiency threshold + + public Config(JpdfRecipe.ScoreMetric metric, + int miBins, + int maxKendallN, + Integer binsX, + Integer binsY, + double minAvgCountPerCell) { + this.metric = Objects.requireNonNull(metric); + this.miBins = Math.max(4, miBins); + this.maxKendallN = Math.max(50, maxKendallN); + this.binsX = binsX; + this.binsY = binsY; + this.minAvgCountPerCell = Math.max(0.0, minAvgCountPerCell); + } + + public static Config defaultFor(JpdfRecipe.ScoreMetric metric, Integer binsX, Integer binsY, double minAvgPerCell) { + return new Config(metric, 16, 2000, binsX, binsY, minAvgPerCell); + } + } + + private PairScorer() { + } + + // ==================================================================================== + // COMPONENT MODE: fast scoring over (start..end) component indices with caching + // ==================================================================================== + + /** + * Score all valid component pairs in [start..end], respecting includeSelf/ordered flags. + */ + public static List scoreComponentPairs( + List vectors, + int startInclusive, + int endInclusive, + boolean includeSelfPairs, + boolean orderedPairs, + Config cfg + ) { + Objects.requireNonNull(vectors, "vectors"); + if (vectors.isEmpty()) return Collections.emptyList(); + + int dStart = Math.max(0, startInclusive); + int dEnd = Math.max(dStart, endInclusive); + int nDims = (vectors.get(0).getData() != null) ? vectors.get(0).getData().size() : 0; + dEnd = Math.min(dEnd, Math.max(0, nDims - 1)); + + // Extract columns (dimension -> array over samples) + double[][] cols = extractComponentColumns(vectors, dStart, dEnd); + int nSamples = cols.length == 0 ? 0 : cols[0].length; + + boolean suff = sufficiency(nSamples, cfg.binsX, cfg.binsY, cfg.minAvgCountPerCell); + String insuffReason = suff ? null : "insufficient N per cell"; + + List out = new ArrayList<>(); + for (int i = dStart; i <= dEnd; i++) { + int ii = i - dStart; + for (int j = dStart; j <= dEnd; j++) { + if (!includeSelfPairs && i == j) continue; + if (!orderedPairs && j <= i) continue; + + int jj = j - dStart; + double[] x = cols[ii]; + double[] y = cols[jj]; + + PairScore ps = new PairScore(i, j, + scorePair(x, y, cfg), + nSamples, + suff, + suff ? null : insuffReason); + out.add(ps); + } + } + Collections.sort(out); + return out; + } + + // ==================================================================================== + // AXIS MODE: convenience scoring for an arbitrary AxisParams pair (slower; on demand) + // ==================================================================================== + + public static PairScore scoreAxisPair( + List vectors, + AxisParams xAxis, + AxisParams yAxis, + Config cfg + ) { + Objects.requireNonNull(vectors, "vectors"); + if (vectors.isEmpty()) return new PairScore(-1, -1, 0.0, 0, false, "no data"); + + double[] x = extractAxisScalars(vectors, xAxis); + double[] y = extractAxisScalars(vectors, yAxis); + + boolean suff = sufficiency(x.length, cfg.binsX, cfg.binsY, cfg.minAvgCountPerCell); + String reason = suff ? null : "insufficient N per cell"; + + double score = scorePair(x, y, cfg); + return new PairScore(-1, -1, score, x.length, suff, reason); + } + + // ==================================================================================== + // Core scoring + // ==================================================================================== + + private static double scorePair(double[] x, double[] y, Config cfg) { + if (x.length != y.length || x.length < 3) return 0.0; + + switch (cfg.metric) { + case PEARSON: + return Math.abs(pearson(x, y)); + case KENDALL: + return Math.abs(kendallTauApprox(x, y, cfg.maxKendallN)); + case MI_LITE: + return nmiLite(x, y, cfg.miBins); + case DIST_CORR: + return distCorr(x, y); + default: + return 0.0; + } + } + + // ----------- Pearson r ----------- + + private static double pearson(double[] x, double[] y) { + int n = x.length; + double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0; + for (int i = 0; i < n; i++) { + double xi = x[i], yi = y[i]; + sx += xi; + sy += yi; + sxx += xi * xi; + syy += yi * yi; + sxy += xi * yi; + } + double num = n * sxy - sx * sy; + double den = Math.sqrt(Math.max(0, n * sxx - sx * sx)) * Math.sqrt(Math.max(0, n * syy - sy * sy)); + if (den == 0) return 0.0; + double r = num / den; + if (Double.isNaN(r) || Double.isInfinite(r)) return 0.0; + return r; + } + + // ----------- Kendall's tau-b (approx; stride sampling for large N) ----------- + + private static double kendallTauApprox(double[] x, double[] y, int maxN) { + int n = x.length; + if (n <= 1) return 0.0; + + int[] idx = sampledIndices(n, maxN); + int m = idx.length; + + long concordant = 0, discordant = 0; + long tiesX = 0, tiesY = 0; + + for (int a = 0; a < m; a++) { + int i = idx[a]; + for (int b = a + 1; b < m; b++) { + int j = idx[b]; + double dx = x[i] - x[j]; + double dy = y[i] - y[j]; + + if (dx == 0 && dy == 0) continue; // joint tie: ignore + if (dx == 0) { + tiesX++; + continue; + } + if (dy == 0) { + tiesY++; + continue; + } + + double prod = dx * dy; + if (prod > 0) concordant++; + else if (prod < 0) discordant++; + // prod == 0 cases already handled above + } + } + double denom = Math.sqrt((concordant + discordant + tiesX) * 1.0) * + Math.sqrt((concordant + discordant + tiesY) * 1.0); + if (denom == 0) return 0.0; + return (concordant - discordant) / denom; + } + + private static int[] sampledIndices(int n, int maxN) { + if (n <= maxN) { + int[] idx = new int[n]; + for (int i = 0; i < n; i++) idx[i] = i; + return idx; + } + // stride sampling (deterministic, stable) + int[] idx = new int[maxN]; + double step = (n - 1.0) / (maxN - 1.0); + for (int k = 0; k < maxN; k++) idx[k] = (int) Math.round(k * step); + return idx; + } + + // ----------- MI-lite -> NMI in [0,1] ----------- + + private static double nmiLite(double[] x, double[] y, int bins) { + int n = x.length; + if (n == 0) return 0.0; + + // Bounds: robust to constant signals + double minX = Double.POSITIVE_INFINITY, maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < n; i++) { + double xi = x[i]; + double yi = y[i]; + if (xi < minX) minX = xi; + if (xi > maxX) maxX = xi; + if (yi < minY) minY = yi; + if (yi > maxY) maxY = yi; + } + if (!(maxX > minX)) maxX = minX + 1e-8; + if (!(maxY > minY)) maxY = minY + 1e-8; + + double dx = (maxX - minX) / bins; + double dy = (maxY - minY) / bins; + + // add-one smoothing + double[][] joint = new double[bins][bins]; + double[] px = new double[bins]; + double[] py = new double[bins]; + + Arrays.fill(px, 1.0); + Arrays.fill(py, 1.0); + for (int by = 0; by < bins; by++) Arrays.fill(joint[by], 1.0); + + double nEff = n + bins + bins + bins * bins; // pseudo counts included + + for (int i = 0; i < n; i++) { + int bx = (int) Math.floor((x[i] - minX) / dx); + int by = (int) Math.floor((y[i] - minY) / dy); + if (bx < 0) bx = 0; + if (bx >= bins) bx = bins - 1; + if (by < 0) by = 0; + if (by >= bins) by = bins - 1; + + joint[by][bx] += 1.0; + px[bx] += 1.0; + py[by] += 1.0; + } + + // Normalize to probabilities + for (int bx = 0; bx < bins; bx++) px[bx] /= nEff; + for (int by = 0; by < bins; by++) py[by] /= nEff; + for (int by = 0; by < bins; by++) + for (int bx = 0; bx < bins; bx++) + joint[by][bx] /= nEff; + + // Entropies, MI + double hx = entropy(px); + double hy = entropy(py); + double mi = 0.0; + for (int by = 0; by < bins; by++) { + for (int bx = 0; bx < bins; bx++) { + double pxy = joint[by][bx]; + double denom = px[bx] * py[by]; + if (pxy > 0 && denom > 0) mi += pxy * Math.log(pxy / denom); + } + } + + // Normalized MI: divide by sqrt(Hx*Hy); guard if entropies near zero + double denom = Math.sqrt(Math.max(hx, 1e-12) * Math.max(hy, 1e-12)); + double nmi = (denom > 0) ? (mi / denom) : 0.0; + // Clamp to [0,1] range for numeric stability + if (Double.isNaN(nmi) || Double.isInfinite(nmi)) nmi = 0.0; + if (nmi < 0) nmi = 0; + else if (nmi > 1) nmi = 1; + return nmi; + } + + private static double entropy(double[] p) { + double h = 0.0; + for (double v : p) if (v > 0) h -= v * Math.log(v); + return h; + } + + // ----------- Distance correlation (biased) ----------- + + private static double distCorr(double[] x, double[] y) { + int n = x.length; + if (n < 3) return 0.0; + + double[][] ax = distanceMatrix(x); + double[][] ay = distanceMatrix(y); + + double[][] axc = doubleCenter(ax); + double[][] ayc = doubleCenter(ay); + + double dcov2 = meanElementwiseProduct(axc, ayc); + double dvarx = meanElementwiseProduct(axc, axc); + double dvary = meanElementwiseProduct(ayc, ayc); + + if (dvarx <= 0 || dvary <= 0) return 0.0; + double dcor = Math.sqrt(Math.max(0, dcov2)) / Math.sqrt(dvarx * dvary); + if (Double.isNaN(dcor) || Double.isInfinite(dcor)) return 0.0; + // Clamp + if (dcor < 0) dcor = 0; + else if (dcor > 1) dcor = 1; + return dcor; + } + + private static double[][] distanceMatrix(double[] v) { + int n = v.length; + double[][] d = new double[n][n]; + for (int i = 0; i < n; i++) { + d[i][i] = 0; + for (int j = i + 1; j < n; j++) { + double dij = Math.abs(v[i] - v[j]); + d[i][j] = dij; + d[j][i] = dij; + } + } + return d; + } + + private static double[][] doubleCenter(double[][] a) { + int n = a.length; + double[] rowMean = new double[n]; + double[] colMean = new double[n]; + double grand = 0.0; + + for (int i = 0; i < n; i++) { + double rs = 0; + for (int j = 0; j < n; j++) rs += a[i][j]; + rowMean[i] = rs / n; + grand += rs; + } + grand /= (n * n); + + for (int j = 0; j < n; j++) { + double cs = 0; + for (int i = 0; i < n; i++) cs += a[i][j]; + colMean[j] = cs / n; + } + + double[][] out = new double[n][n]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + out[i][j] = a[i][j] - rowMean[i] - colMean[j] + grand; + } + } + return out; + } + + private static double meanElementwiseProduct(double[][] a, double[][] b) { + int n = a.length; + double s = 0.0; + for (int i = 0; i < n; i++) + for (int j = 0; j < n; j++) + s += a[i][j] * b[i][j]; + return s / (n * n); + } + + // ----------- Sufficiency ----------- + + private static boolean sufficiency(int n, Integer bx, Integer by, double minAvgPerCell) { + if (bx == null || by == null) return true; // unknown bins → don't block + if (bx <= 0 || by <= 0) return true; + double avg = (bx * (double) by) == 0 ? 0 : (n / (bx * (double) by)); + return avg >= minAvgPerCell; + } + + // ----------- Extraction helpers ----------- + + private static double[][] extractComponentColumns(List vectors, int start, int end) { + int nDims = end - start + 1; + int n = vectors.size(); + double[][] cols = new double[nDims][n]; + for (int row = 0; row < n; row++) { + List data = vectors.get(row).getData(); + for (int d = 0; d < nDims; d++) { + int idx = start + d; + double val = (idx >= 0 && idx < data.size()) ? data.get(idx) : 0.0; + cols[d][row] = val; + } + } + return cols; + } + + private static double[] extractAxisScalars(List vectors, AxisParams axis) { + // Precompute mean if needed + List meanVector = null; + Set needMean = Set.of( + StatisticEngine.ScalarType.DIST_TO_MEAN, + StatisticEngine.ScalarType.COSINE_TO_MEAN + ); + if (needMean.contains(axis.getType())) { + meanVector = FeatureVector.getMeanVector(vectors); + } + + Metric metric = null; + if (axis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && axis.getMetricName() != null + && axis.getReferenceVec() != null) { + metric = Metric.getMetric(axis.getMetricName()); + } + + double[] out = new double[vectors.size()]; + for (int i = 0; i < vectors.size(); i++) { + FeatureVector fv = vectors.get(i); + out[i] = scalarValue(fv, axis.getType(), meanVector, metric, axis.getReferenceVec(), axis.getComponentIndex()); + } + return out; + } + + private static double scalarValue(FeatureVector fv, + StatisticEngine.ScalarType type, + List meanVec, + Metric metric, + List refVec, + Integer componentIndex) { + switch (type) { + case L1_NORM: + return fv.getData().stream().mapToDouble(Math::abs).sum(); + case LINF_NORM: + return fv.getData().stream().mapToDouble(Math::abs).max().orElse(0.0); + case MEAN: + return fv.getData().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + case MAX: + return fv.getMax(); + case MIN: + return fv.getMin(); + case DIST_TO_MEAN: + if (meanVec == null) return 0.0; + return AnalysisUtils.l2Norm(StatisticEngine.diffList(fv.getData(), meanVec)); + case COSINE_TO_MEAN: + if (meanVec == null) return 0.0; + return AnalysisUtils.cosineSimilarity(fv.getData(), meanVec); + case METRIC_DISTANCE_TO_MEAN: + if (metric == null || refVec == null) return 0.0; + double[] a = fv.getData().stream().mapToDouble(Double::doubleValue).toArray(); + double[] b = refVec.stream().mapToDouble(Double::doubleValue).toArray(); + return metric.distance(a, b); + case COMPONENT_AT_DIMENSION: + if (componentIndex == null) return 0.0; + List d = fv.getData(); + if (componentIndex >= 0 && componentIndex < d.size()) return d.get(componentIndex); + return 0.0; + case PC1_PROJECTION: + return 0.0; // not computed here + default: + return 0.0; + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java new file mode 100644 index 00000000..9a33f0f0 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java @@ -0,0 +1,436 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.BoundsPolicy; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.OutputKind; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.PairSelection; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.ScoreMetric; +import javafx.geometry.Insets; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import java.util.function.Consumer; + +public final class PairwiseJpdfConfigPanel extends BorderPane { + + // ---- sizing knobs ---- + private static final double PANEL_PREF_WIDTH = 390; + private static final double LABEL_COL_WIDTH = 130; // label column + private static final double FIELD_MIN_W = 180; // generic control minimum + private static final double FIELD_PREF_W = 240; // generic control preferred + private static final double FIELD_WIDE_W = 300; // name field + private static final double SPINNER_MIN_W = 100; + private static final double SPINNER_PREF_W = 120; + + private Consumer onRun; + + // General + private final TextField recipeNameField = new TextField(); + + // Pair selection + private final ComboBox pairSelectionCombo = new ComboBox<>(); + private final ComboBox scoreMetricCombo = new ComboBox<>(); + private final Spinner topKSpinner = new Spinner<>(); + private final Spinner thresholdSpinner = new Spinner<>(); + private final CheckBox componentPairsCheck = new CheckBox("Enumerate Component Pairs (i,j)"); + private final Spinner compStartSpinner = new Spinner<>(); + private final Spinner compEndSpinner = new Spinner<>(); + private final CheckBox includeSelfPairsCheck = new CheckBox("Include (i,i)"); + private final CheckBox orderedPairsCheck = new CheckBox("Treat pairs as ordered (i,j) and (j,i)"); + + // Grid / Bounds + private final Spinner binsXSpinner = new Spinner<>(); + private final Spinner binsYSpinner = new Spinner<>(); + private final ComboBox boundsPolicyCombo = new ComboBox<>(); + private final TextField canonicalPolicyIdField = new TextField("default"); + + // Guard + private final Spinner minAvgCountPerCellSpinner = new Spinner<>(); + + // Output/runtime + private final ComboBox outputKindCombo = new ComboBox<>(); + private final CheckBox cacheEnabledCheck = new CheckBox("Enable Cache"); + private final CheckBox saveThumbsCheck = new CheckBox("Save Thumbnails"); + + // Whitelist (optional placeholder) + private final TextArea whitelistArea = new TextArea(); + + // Actions (exposed to parent top bar) + private final Button runButton = new Button("Run"); + private final Button resetButton = new Button("Reset"); + + public PairwiseJpdfConfigPanel() { + setPadding(new Insets(2)); + setPrefWidth(PANEL_PREF_WIDTH); + setMaxWidth(PANEL_PREF_WIDTH); + + // Defaults & widths + recipeNameField.setPromptText("Recipe name (required)"); + recipeNameField.setPrefWidth(FIELD_WIDE_W); + recipeNameField.setMinWidth(FIELD_MIN_W); + recipeNameField.setMaxWidth(FIELD_WIDE_W); + + pairSelectionCombo.getItems().addAll(PairSelection.values()); + pairSelectionCombo.setValue(PairSelection.ALL); + pairSelectionCombo.setMinWidth(FIELD_MIN_W); + pairSelectionCombo.setPrefWidth(FIELD_PREF_W); + pairSelectionCombo.setMaxWidth(FIELD_WIDE_W); + + scoreMetricCombo.getItems().addAll(ScoreMetric.values()); + scoreMetricCombo.setValue(ScoreMetric.PEARSON); + widenCombo(scoreMetricCombo); + + topKSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 10_000, 20, 1)); + topKSpinner.setEditable(true); + topKSpinner.setMinWidth(SPINNER_MIN_W); + topKSpinner.setPrefWidth(SPINNER_PREF_W); + topKSpinner.setMaxWidth(SPINNER_PREF_W); + + thresholdSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1.0, 0.2, 0.01)); + thresholdSpinner.setEditable(true); + thresholdSpinner.setMinWidth(SPINNER_MIN_W); + thresholdSpinner.setPrefWidth(SPINNER_PREF_W); + thresholdSpinner.setMaxWidth(SPINNER_PREF_W); + + componentPairsCheck.setSelected(true); + includeSelfPairsCheck.setSelected(false); + orderedPairsCheck.setSelected(false); + + compStartSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, 0, 1)); + compStartSpinner.setEditable(true); + compStartSpinner.setMinWidth(SPINNER_MIN_W); + compStartSpinner.setPrefWidth(SPINNER_PREF_W); + compStartSpinner.setMaxWidth(SPINNER_PREF_W); + + compEndSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, 1, 1)); + compEndSpinner.setEditable(true); + compEndSpinner.setMinWidth(SPINNER_MIN_W); + compEndSpinner.setPrefWidth(SPINNER_PREF_W); + compEndSpinner.setMaxWidth(SPINNER_PREF_W); + + binsXSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(2, 4096, 64, 2)); + binsXSpinner.setEditable(true); + binsXSpinner.setMinWidth(SPINNER_MIN_W); + binsXSpinner.setPrefWidth(SPINNER_PREF_W); + binsXSpinner.setMaxWidth(SPINNER_PREF_W); + + binsYSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(2, 4096, 64, 2)); + binsYSpinner.setEditable(true); + binsYSpinner.setMinWidth(SPINNER_MIN_W); + binsYSpinner.setPrefWidth(SPINNER_PREF_W); + binsYSpinner.setMaxWidth(SPINNER_PREF_W); + + boundsPolicyCombo.getItems().addAll(BoundsPolicy.values()); + boundsPolicyCombo.setValue(BoundsPolicy.DATA_MIN_MAX); + widenCombo(boundsPolicyCombo); + + canonicalPolicyIdField.setPrefWidth(FIELD_PREF_W); + canonicalPolicyIdField.setMinWidth(FIELD_MIN_W); + canonicalPolicyIdField.setMaxWidth(FIELD_PREF_W); + + minAvgCountPerCellSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, 3.0, 0.5)); + minAvgCountPerCellSpinner.setEditable(true); + minAvgCountPerCellSpinner.setMinWidth(SPINNER_MIN_W); + minAvgCountPerCellSpinner.setPrefWidth(SPINNER_PREF_W); + minAvgCountPerCellSpinner.setMaxWidth(SPINNER_PREF_W); + + outputKindCombo.getItems().addAll(OutputKind.values()); + outputKindCombo.setValue(OutputKind.PDF_AND_CDF); + widenCombo(outputKindCombo); + + cacheEnabledCheck.setSelected(true); + saveThumbsCheck.setSelected(true); + + whitelistArea.setPromptText("Optional whitelist of axis pairs (placeholder)."); + whitelistArea.setPrefRowCount(2); + whitelistArea.setWrapText(true); + whitelistArea.setPrefWidth(FIELD_PREF_W); + whitelistArea.setMinWidth(FIELD_MIN_W); + whitelistArea.setMaxWidth(FIELD_PREF_W); + whitelistArea.setMaxHeight(Region.USE_PREF_SIZE); + + // Layout + setCenter(buildForm()); + + // Reactive enable/disable + pairSelectionCombo.valueProperty().addListener((obs, ov, nv) -> updateEnablement()); + updateEnablement(); + + // Actions + runButton.setOnAction(e -> onRun()); + resetButton.setOnAction(e -> resetToDefaults()); + } + + /** + * Expose Run button so the parent pane can place it in the top bar. + */ + public Button getRunButton() { + return runButton; + } + + /** + * Expose Reset button so the parent pane can place it in the top bar. + */ + public Button getResetButton() { + return resetButton; + } + + /** + * Set a callback to receive a fully-built JpdfRecipe when the user clicks Run. + */ + public void setOnRun(Consumer onRun) { + this.onRun = onRun; + } + + /** + * Build a JpdfRecipe from current UI state without running. + * + * @throws IllegalArgumentException if configuration is invalid + */ + public JpdfRecipe snapshotRecipe() { + return buildRecipeFromUI(); + } + + /** + * Apply a recipe back into the UI controls. + * Fields not represented in the panel are ignored. + */ + public void applyRecipe(JpdfRecipe r) { + if (r == null) return; + recipeNameField.setText(r.getName()); + pairSelectionCombo.setValue(r.getPairSelection()); + scoreMetricCombo.setValue(r.getScoreMetric()); + topKSpinner.getValueFactory().setValue(r.getTopK()); + thresholdSpinner.getValueFactory().setValue(r.getScoreThreshold()); + componentPairsCheck.setSelected(r.isComponentPairsMode()); + compStartSpinner.getValueFactory().setValue(r.getComponentIndexStart()); + compEndSpinner.getValueFactory().setValue(r.getComponentIndexEnd()); + includeSelfPairsCheck.setSelected(r.isIncludeSelfPairs()); + orderedPairsCheck.setSelected(r.isOrderedPairs()); + binsXSpinner.getValueFactory().setValue(r.getBinsX()); + binsYSpinner.getValueFactory().setValue(r.getBinsY()); + boundsPolicyCombo.setValue(r.getBoundsPolicy()); + canonicalPolicyIdField.setText(r.getCanonicalPolicyId()); + minAvgCountPerCellSpinner.getValueFactory().setValue(r.getMinAvgCountPerCell()); + outputKindCombo.setValue(r.getOutputKind()); + cacheEnabledCheck.setSelected(r.isCacheEnabled()); + saveThumbsCheck.setSelected(r.isSaveThumbnails()); + updateEnablement(); + } + + // --------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------- + + private VBox buildForm() { + GridPane g = new GridPane(); + g.setHgap(8); + g.setVgap(6); + g.setPadding(new Insets(2)); + g.setMaxWidth(Region.USE_PREF_SIZE); + + // Two columns: Label | Control + ColumnConstraints c0 = new ColumnConstraints(); + c0.setMinWidth(LABEL_COL_WIDTH); + c0.setPrefWidth(LABEL_COL_WIDTH); + c0.setMaxWidth(LABEL_COL_WIDTH); + c0.setHgrow(Priority.NEVER); + + ColumnConstraints c1 = new ColumnConstraints(); + c1.setMinWidth(FIELD_MIN_W); + c1.setPrefWidth(FIELD_PREF_W); + c1.setMaxWidth(FIELD_WIDE_W); + c1.setHgrow(Priority.SOMETIMES); + + g.getColumnConstraints().addAll(c0, c1); + + int r = 0; + + // General + g.add(compactLabel("Name"), 0, r); + g.add(recipeNameField, 1, r++); + + // Pair selection + g.add(compactLabel("Pair Selection"), 0, r); + g.add(pairSelectionCombo, 1, r++); + g.add(compactLabel("Score Metric"), 0, r); + g.add(scoreMetricCombo, 1, r++); + g.add(compactLabel("Top-K"), 0, r); + g.add(topKSpinner, 1, r++); + g.add(compactLabel("Threshold"), 0, r); + g.add(thresholdSpinner, 1, r++); + + // Component options + g.add(compactLabel(""), 0, r); + g.add(componentPairsCheck, 1, r++); // checkbox row + g.add(compactLabel("Component Start"), 0, r); + g.add(compStartSpinner, 1, r++); + g.add(compactLabel("Component End"), 0, r); + g.add(compEndSpinner, 1, r++); + g.add(compactLabel(""), 0, r); + g.add(includeSelfPairsCheck, 1, r++); // checkbox row + g.add(compactLabel(""), 0, r); + g.add(orderedPairsCheck, 1, r++); // checkbox row + + // Grid / Bounds + g.add(compactLabel("Bins X"), 0, r); + g.add(binsXSpinner, 1, r++); + g.add(compactLabel("Bins Y"), 0, r); + g.add(binsYSpinner, 1, r++); + g.add(compactLabel("Bounds Policy"), 0, r); + g.add(boundsPolicyCombo, 1, r++); + g.add(compactLabel("Canonical Policy Id"), 0, r); + g.add(canonicalPolicyIdField, 1, r++); + + // Guard + g.add(compactLabel("Min Avg Count/Cell"), 0, r); + g.add(minAvgCountPerCellSpinner, 1, r++); + + // Outputs + g.add(compactLabel("Output"), 0, r); + g.add(outputKindCombo, 1, r++); + g.add(compactLabel(""), 0, r); + g.add(cacheEnabledCheck, 1, r++); // checkbox row + g.add(compactLabel(""), 0, r); + g.add(saveThumbsCheck, 1, r++); // checkbox row + + // Whitelist + g.add(compactLabel("Whitelist (opt)"), 0, r); + g.add(whitelistArea, 1, r++); + + VBox root = new VBox(8, g); + root.setPadding(new Insets(2)); + root.setFillWidth(false); + return root; + } + + private void updateEnablement() { + PairSelection ps = pairSelectionCombo.getValue(); + boolean needTopK = ps == PairSelection.TOP_K_BY_SCORE; + boolean needThreshold = ps == PairSelection.THRESHOLD_BY_SCORE; + boolean needScoring = needTopK || needThreshold; + + scoreMetricCombo.setDisable(!needScoring); + topKSpinner.setDisable(!needTopK); + thresholdSpinner.setDisable(!needThreshold); + } + + private void onRun() { + try { + JpdfRecipe recipe = buildRecipeFromUI(); + if (onRun != null) onRun.accept(recipe); + } catch (IllegalArgumentException ex) { + Alert a = new Alert(Alert.AlertType.ERROR, ex.getMessage()); + a.setHeaderText("Invalid Configuration"); + a.setTitle("JPDF Recipe Error"); + a.showAndWait(); + } + } + + private JpdfRecipe buildRecipeFromUI() { + String name = recipeNameField.getText() == null ? "" : recipeNameField.getText().trim(); + if (name.isBlank()) throw new IllegalArgumentException("Recipe name is required."); + + int bx = binsXSpinner.getValue(); + int by = binsYSpinner.getValue(); + if (bx < 2 || by < 2) throw new IllegalArgumentException("Bins must be ≥ 2."); + + int start = compStartSpinner.getValue(); + int end = compEndSpinner.getValue(); + if (componentPairsCheck.isSelected() && end < start) { + throw new IllegalArgumentException("Component index end must be ≥ start."); + } + + PairSelection ps = pairSelectionCombo.getValue(); + int topK = topKSpinner.getValue(); + double thr = thresholdSpinner.getValue(); + ScoreMetric sm = scoreMetricCombo.getValue(); + + BoundsPolicy bp = boundsPolicyCombo.getValue(); + String canonicalId = canonicalPolicyIdField.getText(); + if (bp == BoundsPolicy.CANONICAL_BY_FEATURE) { + if (canonicalId == null || canonicalId.isBlank()) { + throw new IllegalArgumentException("Canonical policy id is required for CANONICAL_BY_FEATURE."); + } + } + + JpdfRecipe.Builder b = JpdfRecipe.newBuilder(name) + .pairSelection(ps) + .scoreMetric(sm) + .bins(bx, by) + .boundsPolicy(bp) + .canonicalPolicyId(canonicalId == null ? "" : canonicalId.trim()) + .minAvgCountPerCell(minAvgCountPerCellSpinner.getValue()) + .outputKind(outputKindCombo.getValue()) + .cacheEnabled(cacheEnabledCheck.isSelected()) + .saveThumbnails(saveThumbsCheck.isSelected()) + .componentPairsMode(componentPairsCheck.isSelected()) + .componentIndexRange(start, end) + .includeSelfPairs(includeSelfPairsCheck.isSelected()) + .orderedPairs(orderedPairsCheck.isSelected()); + + if (ps == PairSelection.TOP_K_BY_SCORE) { + if (topK <= 0) throw new IllegalArgumentException("Top-K must be > 0."); + b.topK(topK); + } else if (ps == PairSelection.THRESHOLD_BY_SCORE) { + if (!Double.isFinite(thr)) throw new IllegalArgumentException("Threshold must be finite."); + b.scoreThreshold(thr); + } + + return b.build(); + } + + private static Label compactLabel(String text) { + Label l = new Label(text); + l.setMinWidth(LABEL_COL_WIDTH); + l.setPrefWidth(LABEL_COL_WIDTH); + l.setMaxWidth(LABEL_COL_WIDTH); + return l; + } + + private static void widenCombo(ComboBox cb) { + cb.setMinWidth(FIELD_MIN_W); + cb.setPrefWidth(FIELD_PREF_W); + cb.setMaxWidth(FIELD_PREF_W); + } + + public void resetToDefaults() { + recipeNameField.clear(); + + pairSelectionCombo.setValue(PairSelection.ALL); + scoreMetricCombo.setValue(ScoreMetric.PEARSON); + topKSpinner.getValueFactory().setValue(20); + thresholdSpinner.getValueFactory().setValue(0.2); + + componentPairsCheck.setSelected(true); + compStartSpinner.getValueFactory().setValue(0); + compEndSpinner.getValueFactory().setValue(1); + includeSelfPairsCheck.setSelected(false); + orderedPairsCheck.setSelected(false); + + binsXSpinner.getValueFactory().setValue(64); + binsYSpinner.getValueFactory().setValue(64); + boundsPolicyCombo.setValue(BoundsPolicy.DATA_MIN_MAX); + canonicalPolicyIdField.setText("default"); + + minAvgCountPerCellSpinner.getValueFactory().setValue(3.0); + + outputKindCombo.setValue(OutputKind.PDF_AND_CDF); + cacheEnabledCheck.setSelected(true); + saveThumbsCheck.setSelected(true); + + updateEnablement(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java new file mode 100644 index 00000000..7e08dde4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java @@ -0,0 +1,596 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.utils.statistics.DivergenceComputer.DivergenceMetric; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.BoundsPolicy; +import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.ScoreMetric; +import javafx.geometry.Insets; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * PairwiseMatrixConfigPanel + * ------------------------- + * Compact configuration panel for building a request to compute a pairwise matrix: + * - Mode: SIMILARITY (single cohort) or DIVERGENCE (A vs B) + * - Metric per mode (ScoreMetric for similarity, DivergenceMetric for divergence) + * - Component index range (inclusive), diagonal inclusion, ordered pairs + * - Grid bins and bounds policy (for underlying JPDF alignment) + * - Initial visualization hints (palette/range/legend) + *

+ * Emits a {@link Request} via {@link #setOnRun(Consumer)}. + * + * @author Sean Phillips + */ +public final class PairwiseMatrixConfigPanel extends BorderPane { + + /** + * Execution mode. + */ + public enum Mode {SIMILARITY, DIVERGENCE} + + /** + * Immutable request DTO produced by this panel. + * Use {@link Builder} to construct internally; consumers just read fields. + */ + public static final class Request { + public final String name; + public final Mode mode; + + // metrics + public final ScoreMetric similarityMetric; // when mode == SIMILARITY + public final DivergenceMetric divergenceMetric; // when mode == DIVERGENCE + + // component range & pairing + public final int componentStart; + public final int componentEnd; + public final boolean includeDiagonal; + public final boolean orderedPairs; + + // grid / bounds + public final int binsX; + public final int binsY; + public final BoundsPolicy boundsPolicy; + public final String canonicalPolicyId; + + // cohort labels (used by divergence; harmless otherwise) + public final String cohortALabel; + public final String cohortBLabel; + + // initial view hints + public final boolean paletteDiverging; + public final boolean autoRange; + public final Double fixedMin; // when !autoRange + public final Double fixedMax; // when !autoRange + public final Double divergingCenter; // when paletteDiverging + public final boolean showLegend; + + private Request(Builder b) { + this.name = b.name; + this.mode = b.mode; + this.similarityMetric = b.similarityMetric; + this.divergenceMetric = b.divergenceMetric; + this.componentStart = b.componentStart; + this.componentEnd = b.componentEnd; + this.includeDiagonal = b.includeDiagonal; + this.orderedPairs = b.orderedPairs; + this.binsX = b.binsX; + this.binsY = b.binsY; + this.boundsPolicy = b.boundsPolicy; + this.canonicalPolicyId = b.canonicalPolicyId; + this.cohortALabel = b.cohortALabel; + this.cohortBLabel = b.cohortBLabel; + this.paletteDiverging = b.paletteDiverging; + this.autoRange = b.autoRange; + this.fixedMin = b.fixedMin; + this.fixedMax = b.fixedMax; + this.divergingCenter = b.divergingCenter; + this.showLegend = b.showLegend; + } + + public static final class Builder { + private String name; + private Mode mode = Mode.SIMILARITY; + private ScoreMetric similarityMetric = ScoreMetric.PEARSON; + private DivergenceMetric divergenceMetric = DivergenceMetric.JS; + private int componentStart = 0; + private int componentEnd = 1; + private boolean includeDiagonal = false; + private boolean orderedPairs = false; + private int binsX = 64; + private int binsY = 64; + private BoundsPolicy boundsPolicy = BoundsPolicy.DATA_MIN_MAX; + private String canonicalPolicyId = "default"; + private String cohortALabel = "A"; + private String cohortBLabel = "B"; + private boolean paletteDiverging = false; + private boolean autoRange = true; + private Double fixedMin = null; + private Double fixedMax = null; + private Double divergingCenter = 0.0; + private boolean showLegend = true; + + public Builder name(String v) { + this.name = v; + return this; + } + + public Builder mode(Mode v) { + this.mode = v; + return this; + } + + public Builder similarityMetric(ScoreMetric v) { + this.similarityMetric = v; + return this; + } + + public Builder divergenceMetric(DivergenceMetric v) { + this.divergenceMetric = v; + return this; + } + + public Builder componentIndexRange(int s, int e) { + this.componentStart = s; + this.componentEnd = e; + return this; + } + + public Builder includeDiagonal(boolean v) { + this.includeDiagonal = v; + return this; + } + + public Builder orderedPairs(boolean v) { + this.orderedPairs = v; + return this; + } + + public Builder bins(int bx, int by) { + this.binsX = bx; + this.binsY = by; + return this; + } + + public Builder boundsPolicy(BoundsPolicy v) { + this.boundsPolicy = v; + return this; + } + + public Builder canonicalPolicyId(String v) { + this.canonicalPolicyId = v; + return this; + } + + public Builder cohortLabels(String a, String b) { + this.cohortALabel = a; + this.cohortBLabel = b; + return this; + } + + public Builder paletteDiverging(boolean v) { + this.paletteDiverging = v; + return this; + } + + public Builder autoRange(boolean v) { + this.autoRange = v; + return this; + } + + public Builder fixedRange(Double min, Double max) { + this.fixedMin = min; + this.fixedMax = max; + return this; + } + + public Builder divergingCenter(Double v) { + this.divergingCenter = v; + return this; + } + + public Builder showLegend(boolean v) { + this.showLegend = v; + return this; + } + + public Request build() { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(boundsPolicy, "boundsPolicy"); + return new Request(this); + } + } + } + + // ---- sizing knobs ---- + private static final double PANEL_PREF_WIDTH = 390; + private static final double LABEL_COL_WIDTH = 150; + private static final double FIELD_MIN_W = 180; + private static final double FIELD_PREF_W = 240; + + private Consumer onRun; + + // General + private final TextField nameField = new TextField(); + + // Mode & Metric + private final ComboBox modeCombo = new ComboBox<>(); + private final ComboBox simMetricCombo = new ComboBox<>(); + private final ComboBox divMetricCombo = new ComboBox<>(); + + // Components + private final Spinner compStartSpinner = new Spinner<>(); + private final Spinner compEndSpinner = new Spinner<>(); + private final CheckBox includeDiagonalCheck = new CheckBox("Include diagonal (i,i)"); + private final CheckBox orderedPairsCheck = new CheckBox("Ordered pairs (i,j) and (j,i)"); + + // Grid / Bounds + private final Spinner binsXSpinner = new Spinner<>(); + private final Spinner binsYSpinner = new Spinner<>(); + private final ComboBox boundsPolicyCombo = new ComboBox<>(); + private final TextField canonicalPolicyIdField = new TextField("default"); + + // Visual defaults (for MatrixHeatmapView) + private final ComboBox paletteCombo = new ComboBox<>(); // "Sequential" | "Diverging" + private final CheckBox autoRangeCheck = new CheckBox("Auto range"); + private final Spinner fixedMinSpinner = new Spinner<>(); + private final Spinner fixedMaxSpinner = new Spinner<>(); + private final Spinner divergingCenterSpinner = new Spinner<>(); + private final CheckBox showLegendCheck = new CheckBox("Show legend"); + + // Cohort labels (used only in Divergence mode; harmless otherwise) + private final TextField cohortALabelField = new TextField("A"); + private final TextField cohortBLabelField = new TextField("B"); + + // Buttons (exposed) + private final Button runButton = new Button("Run"); + private final Button resetButton = new Button("Reset"); + + public PairwiseMatrixConfigPanel() { + setPadding(new Insets(2)); + setPrefWidth(PANEL_PREF_WIDTH); + setMaxWidth(PANEL_PREF_WIDTH); + + // General + nameField.setPromptText("Matrix name (required)"); + nameField.setMinWidth(FIELD_MIN_W); + nameField.setPrefWidth(FIELD_PREF_W); + nameField.setMaxWidth(FIELD_PREF_W); + + // Mode & metrics + modeCombo.getItems().addAll(Mode.values()); + modeCombo.setValue(Mode.SIMILARITY); + widenCombo(modeCombo); + + simMetricCombo.getItems().addAll(ScoreMetric.values()); + simMetricCombo.setValue(ScoreMetric.PEARSON); + widenCombo(simMetricCombo); + + divMetricCombo.getItems().addAll(DivergenceMetric.values()); + divMetricCombo.setValue(DivergenceMetric.JS); + widenCombo(divMetricCombo); + + // Components + compStartSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, 0, 1)); + compStartSpinner.setEditable(true); + compEndSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Integer.MAX_VALUE, 1, 1)); + compEndSpinner.setEditable(true); + includeDiagonalCheck.setSelected(false); + orderedPairsCheck.setSelected(false); + + // Grid / Bounds + binsXSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(8, 4096, 64, 2)); + binsXSpinner.setEditable(true); + binsYSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(8, 4096, 64, 2)); + binsYSpinner.setEditable(true); + boundsPolicyCombo.getItems().addAll(BoundsPolicy.values()); + boundsPolicyCombo.setValue(BoundsPolicy.DATA_MIN_MAX); + widenCombo(boundsPolicyCombo); + + // Visual defaults + paletteCombo.getItems().addAll("Sequential", "Diverging"); + paletteCombo.setValue("Sequential"); + widenCombo(paletteCombo); + + autoRangeCheck.setSelected(true); + + fixedMinSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(-1_000_000, 1_000_000, 0.0, 0.05)); + fixedMinSpinner.setEditable(true); + fixedMaxSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(-1_000_000, 1_000_000, 1.0, 0.05)); + fixedMaxSpinner.setEditable(true); + + divergingCenterSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(-1_000_000, 1_000_000, 0.0, 0.05)); + divergingCenterSpinner.setEditable(true); + + showLegendCheck.setSelected(true); + + // Cohort labels + cohortALabelField.setPromptText("Cohort A label"); + cohortBLabelField.setPromptText("Cohort B label"); + cohortALabelField.setMinWidth(FIELD_MIN_W); + cohortBLabelField.setMinWidth(FIELD_MIN_W); + + // Layout + setCenter(buildForm()); + + // Reactive enable/disable + modeCombo.valueProperty().addListener((obs, ov, nv) -> updateEnablement()); + autoRangeCheck.selectedProperty().addListener((obs, ov, nv) -> updateEnablement()); + boundsPolicyCombo.valueProperty().addListener((obs, ov, nv) -> updateEnablement()); + updateEnablement(); + + // Actions + runButton.setOnAction(e -> onRun()); + resetButton.setOnAction(e -> resetToDefaults()); + } + + // ---- Public API ---- + + /** + * Expose Run button (for toolbar placement). + */ + public Button getRunButton() { + return runButton; + } + + /** + * Expose Reset button (for toolbar placement). + */ + public Button getResetButton() { + return resetButton; + } + + /** + * Register callback to receive a fully-built Request when the user clicks Run. + */ + public void setOnRun(Consumer onRun) { + this.onRun = onRun; + } + + /** + * Snapshot the current UI into a Request without running. + */ + public Request snapshotRequest() { + return buildRequestFromUI(); + } + + // ---- Internals ---- + + private VBox buildForm() { + GridPane g = new GridPane(); + g.setHgap(8); + g.setVgap(6); + g.setPadding(new Insets(2)); + g.setMaxWidth(Region.USE_PREF_SIZE); + + ColumnConstraints c0 = new ColumnConstraints(); + c0.setMinWidth(LABEL_COL_WIDTH); + c0.setPrefWidth(LABEL_COL_WIDTH); + c0.setMaxWidth(LABEL_COL_WIDTH); + c0.setHgrow(Priority.NEVER); + + ColumnConstraints c1 = new ColumnConstraints(); + c1.setMinWidth(FIELD_MIN_W); + c1.setPrefWidth(FIELD_PREF_W); + c1.setMaxWidth(FIELD_PREF_W); + c1.setHgrow(Priority.SOMETIMES); + + g.getColumnConstraints().addAll(c0, c1); + + int r = 0; + + // General + g.add(compactLabel("Name"), 0, r); + g.add(nameField, 1, r++); + + // Mode & metric + g.add(compactLabel("Mode"), 0, r); + g.add(modeCombo, 1, r++); + g.add(compactLabel("Similarity Metric"), 0, r); + g.add(simMetricCombo, 1, r++); + g.add(compactLabel("Divergence Metric"), 0, r); + g.add(divMetricCombo, 1, r++); + + // Components + g.add(compactLabel("Component Start"), 0, r); + g.add(compStartSpinner, 1, r++); + g.add(compactLabel("Component End"), 0, r); + g.add(compEndSpinner, 1, r++); + g.add(compactLabel(""), 0, r); + g.add(includeDiagonalCheck, 1, r++); + g.add(compactLabel(""), 0, r); + g.add(orderedPairsCheck, 1, r++); + + // Grid / Bounds + g.add(compactLabel("Bins X"), 0, r); + g.add(binsXSpinner, 1, r++); + g.add(compactLabel("Bins Y"), 0, r); + g.add(binsYSpinner, 1, r++); + g.add(compactLabel("Bounds Policy"), 0, r); + g.add(boundsPolicyCombo, 1, r++); + g.add(compactLabel("Canonical Policy Id"), 0, r); + g.add(canonicalPolicyIdField, 1, r++); + + // Visual defaults + g.add(compactLabel("Palette"), 0, r); + g.add(paletteCombo, 1, r++); + g.add(compactLabel(""), 0, r); + g.add(autoRangeCheck, 1, r++); + g.add(compactLabel("Fixed Min"), 0, r); + g.add(fixedMinSpinner, 1, r++); + g.add(compactLabel("Fixed Max"), 0, r); + g.add(fixedMaxSpinner, 1, r++); + g.add(compactLabel("Diverging Center"), 0, r); + g.add(divergingCenterSpinner, 1, r++); + g.add(compactLabel(""), 0, r); + g.add(showLegendCheck, 1, r++); + + // Cohort labels + g.add(compactLabel("Cohort A Label"), 0, r); + g.add(cohortALabelField, 1, r++); + g.add(compactLabel("Cohort B Label"), 0, r); + g.add(cohortBLabelField, 1, r++); + + VBox root = new VBox(8, g); + root.setPadding(new Insets(2)); + root.setFillWidth(false); + return root; + } + + private void updateEnablement() { + Mode mode = modeCombo.getValue(); + boolean isSimilarity = mode == Mode.SIMILARITY; + boolean isDivergence = mode == Mode.DIVERGENCE; + + simMetricCombo.setDisable(!isSimilarity); + divMetricCombo.setDisable(!isDivergence); + + BoundsPolicy bp = boundsPolicyCombo.getValue(); + boolean needCanonical = bp == BoundsPolicy.CANONICAL_BY_FEATURE; + canonicalPolicyIdField.setDisable(!needCanonical); + + boolean auto = autoRangeCheck.isSelected(); + fixedMinSpinner.setDisable(auto); + fixedMaxSpinner.setDisable(auto); + + boolean diverging = "Diverging".equalsIgnoreCase(paletteCombo.getValue()); + divergingCenterSpinner.setDisable(!diverging); + } + + private void onRun() { + try { + Request req = buildRequestFromUI(); + if (onRun != null) onRun.accept(req); + } catch (IllegalArgumentException ex) { + Alert a = new Alert(Alert.AlertType.ERROR, ex.getMessage()); + a.setHeaderText("Invalid Matrix Configuration"); + a.setTitle("Pairwise Matrix Error"); + a.showAndWait(); + } + } + + private Request buildRequestFromUI() { + String name = nameField.getText() == null ? "" : nameField.getText().trim(); + if (name.isBlank()) throw new IllegalArgumentException("Matrix name is required."); + + int start = compStartSpinner.getValue(); + int end = compEndSpinner.getValue(); + if (end < start) throw new IllegalArgumentException("Component index end must be ≥ start."); + + int bx = binsXSpinner.getValue(); + int by = binsYSpinner.getValue(); + if (bx < 2 || by < 2) throw new IllegalArgumentException("Bins must be ≥ 2."); + + BoundsPolicy bp = boundsPolicyCombo.getValue(); + String canonicalId = canonicalPolicyIdField.getText(); + if (bp == BoundsPolicy.CANONICAL_BY_FEATURE) { + if (canonicalId == null || canonicalId.isBlank()) { + throw new IllegalArgumentException("Canonical policy id is required for CANONICAL_BY_FEATURE."); + } + } + + Mode mode = modeCombo.getValue(); + ScoreMetric simMetric = simMetricCombo.getValue(); + DivergenceMetric divMetric = divMetricCombo.getValue(); + + boolean auto = autoRangeCheck.isSelected(); + Double vmin = null, vmax = null, center = null; + if (!auto) { + vmin = fixedMinSpinner.getValue(); + vmax = fixedMaxSpinner.getValue(); + if (!(vmax > vmin)) { + throw new IllegalArgumentException("When Auto range is off, Fixed Max must be > Fixed Min."); + } + } + if ("Diverging".equalsIgnoreCase(paletteCombo.getValue())) { + center = divergingCenterSpinner.getValue(); + } + + return new Request.Builder() + .name(name) + .mode(mode) + .similarityMetric(simMetric == null ? ScoreMetric.PEARSON : simMetric) + .divergenceMetric(divMetric == null ? DivergenceMetric.JS : divMetric) + .componentIndexRange(start, end) + .includeDiagonal(includeDiagonalCheck.isSelected()) + .orderedPairs(orderedPairsCheck.isSelected()) + .bins(bx, by) + .boundsPolicy(bp) + .canonicalPolicyId(canonicalId == null ? "" : canonicalId.trim()) + .cohortLabels( + safeText(cohortALabelField.getText(), "A"), + safeText(cohortBLabelField.getText(), "B") + ) + .paletteDiverging(center != null) + .autoRange(auto) + .fixedRange(vmin, vmax) + .divergingCenter(center) + .showLegend(showLegendCheck.isSelected()) + .build(); + } + + private static String safeText(String s, String def) { + return (s == null || s.isBlank()) ? def : s.trim(); + } + + private static Label compactLabel(String text) { + Label l = new Label(text); + l.setMinWidth(LABEL_COL_WIDTH); + l.setPrefWidth(LABEL_COL_WIDTH); + l.setMaxWidth(LABEL_COL_WIDTH); + return l; + } + + private static void widenCombo(ComboBox cb) { + cb.setMinWidth(FIELD_MIN_W); + cb.setPrefWidth(FIELD_PREF_W); + cb.setMaxWidth(FIELD_PREF_W); + } + + /** + * Reset fields to sensible defaults. + */ + public void resetToDefaults() { + nameField.clear(); + modeCombo.setValue(Mode.SIMILARITY); + simMetricCombo.setValue(ScoreMetric.PEARSON); + divMetricCombo.setValue(DivergenceMetric.JS); + + compStartSpinner.getValueFactory().setValue(0); + compEndSpinner.getValueFactory().setValue(1); + includeDiagonalCheck.setSelected(false); + orderedPairsCheck.setSelected(false); + + binsXSpinner.getValueFactory().setValue(64); + binsYSpinner.getValueFactory().setValue(64); + boundsPolicyCombo.setValue(BoundsPolicy.DATA_MIN_MAX); + canonicalPolicyIdField.setText("default"); + + paletteCombo.setValue("Sequential"); + autoRangeCheck.setSelected(true); + fixedMinSpinner.getValueFactory().setValue(0.0); + fixedMaxSpinner.getValueFactory().setValue(1.0); + divergingCenterSpinner.getValueFactory().setValue(0.0); + showLegendCheck.setSelected(true); + + cohortALabelField.setText("A"); + cohortBLabelField.setText("B"); + + updateEnablement(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java new file mode 100644 index 00000000..5b5f8fb3 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java @@ -0,0 +1,331 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.statistics.DivergenceComputer.DivergenceMetric; +import edu.jhuapl.trinity.utils.statistics.PairScorer.Config; +import edu.jhuapl.trinity.utils.statistics.PairScorer.PairScore; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * PairwiseMatrixEngine + * -------------------- + * High-level orchestration to produce N×N matrices over component indices: + *

+ * 1) Similarity (single cohort) + * - Uses {@link PairScorer} for pairwise scores (PEARSON, KENDALL, MI_LITE, DIST_CORR). + * - Fills a symmetric matrix S[i][j] over a selected component index range from {@link JpdfRecipe}. + * - Also returns a "quality" matrix Q[i][j] based on PairScorer's sufficiency flag (1.0 or 0.0). + *

+ * 2) Divergence (A vs B) + * - Uses {@link DivergenceComputer} (JS / HELLINGER / TV). + * - Reuses canonical grid alignment via {@link ABComparisonEngine} under the hood. + * - Returns D[i][j] and (optionally) a quality matrix carried from DivergenceComputer. + *

+ * Output is a compact {@link MatrixResult} suitable for {@code MatrixHeatmapView}. + *

+ * This class contains no UI code and is thread-safe per call. + * + * @author Sean Phillips + */ +public final class PairwiseMatrixEngine { + + private PairwiseMatrixEngine() { + } + + // ------------------------------------------------------------------------------------ + // Result DTO + // ------------------------------------------------------------------------------------ + + /** + * Immutable result bundle for a computed matrix (similarity or divergence). + */ + public static final class MatrixResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * The primary N×N matrix (scores or divergences). + */ + public final double[][] matrix; + + /** + * Optional N×N quality matrix (same shape), may be null. + */ + public final double[][] quality; + + /** + * Component indices (length N). + */ + public final int[] componentIndices; + + /** + * Human-readable labels for rows/cols (length N). + */ + public final List labels; + + /** + * Legend label for UI (e.g., "Pearson |r|" or "JS divergence"). + */ + public final String legendLabel; + + /** + * Suggested title (e.g., "Similarity: PEARSON" or "Divergence: JS"). + */ + public final String title; + + private MatrixResult(double[][] matrix, + double[][] quality, + int[] componentIndices, + List labels, + String legendLabel, + String title) { + this.matrix = matrix; + this.quality = quality; + this.componentIndices = componentIndices; + this.labels = labels; + this.legendLabel = legendLabel; + this.title = title; + } + + public static MatrixResult of(double[][] matrix, + double[][] quality, + int[] comps, + List labels, + String legend, + String title) { + return new MatrixResult(matrix, quality, comps, labels, legend, title); + } + } + + // ------------------------------------------------------------------------------------ + // Public API: Similarity (single cohort) + // ------------------------------------------------------------------------------------ + + /** + * Compute a component-by-component similarity matrix for a single cohort + * using the score metric specified in the recipe. + *

+ * Diagonal entries are computed as the metric of a component with itself + * when {@code includeDiagonal} is true; otherwise set to 0. + */ + public static MatrixResult computeSimilarityMatrix( + List cohort, + JpdfRecipe recipe, + boolean includeDiagonal + ) { + Objects.requireNonNull(cohort, "cohort"); + Objects.requireNonNull(recipe, "recipe"); + + // Resolve component range from recipe and clamp to dimensionality if available + int start = Math.max(0, recipe.getComponentIndexStart()); + int end = Math.max(start, recipe.getComponentIndexEnd()); + int dim = inferMaxDim(cohort); + if (dim >= 0) end = Math.min(end, Math.max(0, dim - 1)); + + List compsList = new ArrayList<>(); + for (int i = start; i <= end; i++) compsList.add(i); + + return computeSimilarityMatrixForComponents( + cohort, + compsList, + recipe.getScoreMetric(), + recipe.getBinsX(), + recipe.getBinsY(), + recipe.getMinAvgCountPerCell(), + includeDiagonal + ); + } + + /** + * Compute a similarity matrix for an explicit list of component indices using a chosen score metric. + */ + public static MatrixResult computeSimilarityMatrixForComponents( + List cohort, + List componentIndices, + JpdfRecipe.ScoreMetric scoreMetric, + int binsX, + int binsY, + double minAvgCountPerCell, + boolean includeDiagonal + ) { + Objects.requireNonNull(cohort, "cohort"); + Objects.requireNonNull(componentIndices, "componentIndices"); + Objects.requireNonNull(scoreMetric, "scoreMetric"); + + if (componentIndices.isEmpty()) { + return MatrixResult.of(new double[0][0], new double[0][0], new int[0], List.of(), + legendForScore(scoreMetric), "Similarity: " + scoreMetric.name()); + } + + // Build contiguous range if possible, otherwise map indices through a dense temporary space + // For simplicity and performance we will compute by direct column extraction using PairScorer. + int minIdx = Integer.MAX_VALUE, maxIdx = Integer.MIN_VALUE; + for (Integer idx : componentIndices) { + if (idx == null) continue; + if (idx < minIdx) minIdx = idx; + if (idx > maxIdx) maxIdx = idx; + } + if (minIdx == Integer.MAX_VALUE) { + return MatrixResult.of(new double[0][0], new double[0][0], new int[0], List.of(), + legendForScore(scoreMetric), "Similarity: " + scoreMetric.name()); + } + + // Configure PairScorer (used only for scoring and sufficiency flags) + Config cfg = Config.defaultFor(scoreMetric, binsX, binsY, minAvgCountPerCell); + + // We will compute pairwise scores for all pairs in [minIdx..maxIdx], then select the subset we need. + List allScores = PairScorer.scoreComponentPairs( + cohort, + minIdx, + maxIdx, + includeDiagonal, // include (i,i) if requested + false, // unordered pairs → only i labels = new ArrayList<>(N); + for (int k = 0; k < N; k++) labels.add("Comp " + comps[k]); + + return MatrixResult.of( + M, + Q, + comps, + labels, + legendForScore(scoreMetric), + "Similarity: " + scoreMetric.name() + ); + } + + // ------------------------------------------------------------------------------------ + // Public API: Divergence (A vs B) + // ------------------------------------------------------------------------------------ + + /** + * Compute a component-by-component divergence matrix for two cohorts A vs B. + * This defers to {@link DivergenceComputer} to ensure aligned PDF grids and stable metrics. + */ + public static MatrixResult computeDivergenceMatrix( + List cohortA, + List cohortB, + JpdfRecipe recipe, + DivergenceMetric metric, + DensityCache cache + ) { + Objects.requireNonNull(cohortA, "cohortA"); + Objects.requireNonNull(cohortB, "cohortB"); + Objects.requireNonNull(recipe, "recipe"); + Objects.requireNonNull(metric, "metric"); + + DivergenceComputer.DivergenceResult dr = DivergenceComputer.computeForComponentRange( + cohortA, cohortB, recipe, metric, cache + ); + + // Labels/indices already computed by DivergenceComputer + List labels = (dr.labels != null) ? dr.labels : buildDefaultLabels(dr.componentIndices); + + return MatrixResult.of( + dr.matrix, + dr.quality, + dr.componentIndices, + labels, + legendForDivergence(metric), + "Divergence: " + metric.name() + ); + } + + // ------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------ + + private static int inferMaxDim(List cohort) { + if (cohort == null || cohort.isEmpty()) return -1; + List row0 = cohort.get(0).getData(); + if (row0 == null) return -1; + return row0.size(); + } + + private static String legendForScore(JpdfRecipe.ScoreMetric m) { + return switch (m) { + case PEARSON -> "Pearson |r|"; + case KENDALL -> "Kendall |τ|"; + case MI_LITE -> "Normalized MI"; + case DIST_CORR -> "Distance Correlation"; + }; + } + + private static String legendForDivergence(DivergenceMetric m) { + return switch (m) { + case JS -> "Jensen–Shannon (0..1)"; + case HELLINGER -> "Hellinger (0..1)"; + case TV -> "Total Variation (0..1)"; + }; + } + + private static List buildDefaultLabels(int[] comps) { + if (comps == null) return List.of(); + List out = new ArrayList<>(comps.length); + for (int c : comps) out.add("Comp " + c); + return out; + } + + private static int indexOf(int[] arr, int v) { + for (int i = 0; i < arr.length; i++) if (arr[i] == v) return i; + return -1; + } + + private static double defaultDiagonal(JpdfRecipe.ScoreMetric m) { + // For self-vs-self similarity: + // - Pearson(|r|): 1 + // - Kendall(|tau|): 1 + // - NMI-lite: 1 + // - DistCorr: 1 + return 1.0; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java new file mode 100644 index 00000000..716155b1 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java @@ -0,0 +1,148 @@ +package edu.jhuapl.trinity.utils.statistics; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * RecipeIo + * -------- + * JSON save/load helper for JpdfRecipe. Uses a DTO schema (RecipeDto) so + * the JSON layout is explicit, versioned, and stable even if JpdfRecipe evolves. + * + */ +public final class RecipeIo { + private static final ObjectMapper M = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + + private RecipeIo() { + } + + /** + * Write a recipe as JSON to the given stream. + */ + public static void write(OutputStream os, JpdfRecipe r) throws IOException { + if (r == null) throw new IllegalArgumentException("recipe null"); + M.writeValue(os, RecipeDto.from(r)); + } + + /** + * Read a recipe from JSON stream. + */ + public static JpdfRecipe read(InputStream is) throws IOException { + RecipeDto dto = M.readValue(is, RecipeDto.class); + return dto.toRecipe(); + } + + // ----------------------------------------------------------------- + // DTO for stable JSON schema + // ----------------------------------------------------------------- + public static final class RecipeDto { + public String _schema = "trinity.jpdf.recipe.v1"; + + public String name; + public String description; + + public String pairSelection; + public String scoreMetric; + public Integer topK; + public Double scoreThreshold; + + public Boolean componentPairsMode; + public Integer componentIndexStart; + public Integer componentIndexEnd; + public Boolean includeSelfPairs; + public Boolean orderedPairs; + + public Integer binsX; + public Integer binsY; + public String boundsPolicy; + public String canonicalPolicyId; + + public Double minAvgCountPerCell; + + public String outputKind; + public Boolean cacheEnabled; + public Boolean saveThumbnails; + + public String cohortALabel; + public String cohortBLabel; + + public static RecipeDto from(JpdfRecipe r) { + RecipeDto d = new RecipeDto(); + d.name = r.getName(); + d.description = r.getDescription(); + d.pairSelection = r.getPairSelection().name(); + d.scoreMetric = r.getScoreMetric().name(); + d.topK = r.getTopK(); + d.scoreThreshold = r.getScoreThreshold(); + d.componentPairsMode = r.isComponentPairsMode(); + d.componentIndexStart = r.getComponentIndexStart(); + d.componentIndexEnd = r.getComponentIndexEnd(); + d.includeSelfPairs = r.isIncludeSelfPairs(); + d.orderedPairs = r.isOrderedPairs(); + d.binsX = r.getBinsX(); + d.binsY = r.getBinsY(); + d.boundsPolicy = r.getBoundsPolicy().name(); + d.canonicalPolicyId = r.getCanonicalPolicyId(); + d.minAvgCountPerCell = r.getMinAvgCountPerCell(); + d.outputKind = r.getOutputKind().name(); + d.cacheEnabled = r.isCacheEnabled(); + d.saveThumbnails = r.isSaveThumbnails(); + d.cohortALabel = r.getCohortALabel(); + d.cohortBLabel = r.getCohortBLabel(); + return d; + } + + public JpdfRecipe toRecipe() { + JpdfRecipe.Builder b = JpdfRecipe.newBuilder(nonBlank(name, "Unnamed")) + .description(nvl(description, "")) + .pairSelection(JpdfRecipe.PairSelection.valueOf(nvl(pairSelection, "ALL"))) + .scoreMetric(JpdfRecipe.ScoreMetric.valueOf(nvl(scoreMetric, "PEARSON"))) + .bins(nvl(binsX, 64), nvl(binsY, 64)) + .boundsPolicy(JpdfRecipe.BoundsPolicy.valueOf(nvl(boundsPolicy, "DATA_MIN_MAX"))) + .canonicalPolicyId(nvl(canonicalPolicyId, "default")) + .minAvgCountPerCell(nvl(minAvgCountPerCell, 3.0)) + .outputKind(JpdfRecipe.OutputKind.valueOf(nvl(outputKind, "PDF_AND_CDF"))) + .cacheEnabled(nvl(cacheEnabled, true)) + .saveThumbnails(nvl(saveThumbnails, true)) + .componentPairsMode(nvl(componentPairsMode, true)) + .componentIndexRange(nvl(componentIndexStart, 0), nvl(componentIndexEnd, 1)) + .includeSelfPairs(nvl(includeSelfPairs, false)) + .orderedPairs(nvl(orderedPairs, false)); + + if (topK != null) b.topK(topK); + if (scoreThreshold != null) b.scoreThreshold(scoreThreshold); + if (cohortALabel != null || cohortBLabel != null) + b.cohortLabels(nvl(cohortALabel, "A"), nvl(cohortBLabel, "B")); + + return b.build(); + } + + // helpers + private static String nonBlank(String s, String def) { + return (s == null || s.isBlank()) ? def : s; + } + + private static String nvl(String s, String def) { + return s == null ? def : s; + } + + private static int nvl(Integer v, int def) { + return v == null ? def : v; + } + + private static double nvl(Double v, double def) { + return v == null ? def : v; + } + + private static boolean nvl(Boolean v, boolean def) { + return v == null ? def : v; + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java new file mode 100644 index 00000000..e190659a --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java @@ -0,0 +1,501 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + + +/** + * SimilarityComputer + * ------------------ + * Builds a feature-by-feature similarity matrix for a single cohort (FeatureCollection A). + *

+ * Metrics supported: + * - NMI: Normalized Mutual Information in [0,1] via equal-width binning + add-one smoothing + * - PEARSON: |Pearson r| in [0,1] + * - DIST_CORR:Distance correlation (biased estimator) in [0,1] + *

+ * Inputs: + * - Feature vectors (List) assumed to share dimensionality. + * - A JpdfRecipe for binning/guards (minAvgCountPerCell) and to pull component index range. + * Only component-based features are supported here (COMPONENT_AT_DIMENSION). + *

+ * Outputs: + * - SimilarityResult: matrix (double[F][F]), labels, selected component indices, metric, and simple quality stats. + *

+ * Notes: + * - This class does NOT build Joint PDFs and does not require GridSpec; it works on raw columns. + * - Bounds for NMI are per-pair (min..max per each variable). + * - Computation is parallelized over the upper triangle. + * + * @author Sean Phillips + */ +public final class SimilarityComputer { + + public enum Metric { + /** + * Normalized mutual information in [0,1] (symmetric, robust to monotone transforms roughly). + */ + NMI, + /** + * Absolute Pearson correlation |r| in [0,1]. + */ + PEARSON_ABS, + /** + * Distance correlation (biased), in [0,1]. + */ + DIST_CORR + } + + /** + * Output bundle for a single similarity computation. + */ + public static final class SimilarityResult implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * Symmetric matrix (F x F). Diagonal is 1.0 when defined. + */ + public final double[][] matrix; + + /** + * Optional quality matrix (F x F), e.g., fraction of finite pairs used. In [0,1]. + */ + public final double[][] quality; + + /** + * Chosen metric. + */ + public final Metric metric; + + /** + * Selected component indices (length F). + */ + public final int[] componentIndices; + + /** + * Human-readable labels per feature (length F), e.g., "Comp 0", "Comp 1". + */ + public final List labels; + + /** + * Number of sample rows used per pair (ideally equals N unless NaNs encountered). + */ + public final int nSamples; + + /** + * Effective bin count used by NMI (null when metric != NMI). + */ + public final Integer nmiBins; + + /** + * Guard threshold from recipe (for FYI / UI badges). + */ + public final double minAvgCountPerCell; + + public SimilarityResult(double[][] matrix, + double[][] quality, + Metric metric, + int[] componentIndices, + List labels, + int nSamples, + Integer nmiBins, + double minAvgCountPerCell) { + this.matrix = matrix; + this.quality = quality; + this.metric = metric; + this.componentIndices = componentIndices; + this.labels = labels; + this.nSamples = nSamples; + this.nmiBins = nmiBins; + this.minAvgCountPerCell = minAvgCountPerCell; + } + } + + private SimilarityComputer() { + } + + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- + + /** + * Compute a similarity matrix across a component index range defined by the recipe. + * Uses all rows in {@code vectors}. + * + * @param vectors feature vectors (rows). Must be non-null; empty => empty result. + * @param recipe provides component index range and bins (for NMI) + guard knobs. + * @param metric similarity metric to compute. + * @return SimilarityResult containing the matrix and metadata. + */ + public static SimilarityResult computeForComponentRange( + List vectors, + JpdfRecipe recipe, + Metric metric + ) { + Objects.requireNonNull(vectors, "vectors"); + Objects.requireNonNull(recipe, "recipe"); + Objects.requireNonNull(metric, "metric"); + + // Resolve component index range from recipe + int start = Math.max(0, recipe.getComponentIndexStart()); + int end = Math.max(start, recipe.getComponentIndexEnd()); + // Clamp by dimensionality if possible + int dim = (vectors.isEmpty() || vectors.get(0).getData() == null) ? -1 : vectors.get(0).getData().size(); + if (dim >= 0) end = Math.min(end, Math.max(0, dim - 1)); + + List indices = new ArrayList<>(); + for (int i = start; i <= end; i++) indices.add(i); + + return computeForComponents(vectors, indices, recipe, metric); + } + + /** + * Compute a similarity matrix for an explicit list of component indices. + * + * @param vectors feature vectors (rows) + * @param componentIndices list of dimensions to include (0-based) + * @param recipe recipe for bins/guards (NMI), ignored bounds policy here + * @param metric metric to compute + * @return SimilarityResult + */ + public static SimilarityResult computeForComponents( + List vectors, + List componentIndices, + JpdfRecipe recipe, + Metric metric + ) { + Objects.requireNonNull(vectors, "vectors"); + Objects.requireNonNull(componentIndices, "componentIndices"); + Objects.requireNonNull(recipe, "recipe"); + Objects.requireNonNull(metric, "metric"); + + // Early outs + if (vectors.isEmpty() || componentIndices.isEmpty()) { + return new SimilarityResult(new double[0][0], new double[0][0], metric, + new int[0], List.of(), 0, + metric == Metric.NMI ? Math.min(recipe.getBinsX(), recipe.getBinsY()) : null, + recipe.getMinAvgCountPerCell()); + } + + // Extract selected columns into dense arrays: cols[k][row] + int[] comps = componentIndices.stream().mapToInt(Integer::intValue).toArray(); + double[][] cols = extractColumns(vectors, comps); + int F = cols.length; + int N = (F == 0) ? 0 : cols[0].length; + + // Prepare outputs + double[][] M = new double[F][F]; + double[][] Q = new double[F][F]; // quality (fraction finite pairs used) + + // Diagonal + for (int i = 0; i < F; i++) { + M[i][i] = 1.0; + Q[i][i] = 1.0; + } + + // NMI bins: choose something sane. Use recipe bins, but not too huge for 1D discretization. + final int nmiBins = (metric == Metric.NMI) + ? Math.max(4, Math.min(128, Math.min(recipe.getBinsX(), recipe.getBinsY()))) + : -1; + + // Parallel upper-triangle computation + int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); + ExecutorService pool = Executors.newFixedThreadPool(threads); + List> futures = new ArrayList<>(); + + for (int i = 0; i < F; i++) { + final int ii = i; + futures.add(pool.submit(() -> { + double[] x = cols[ii]; + for (int j = ii + 1; j < F; j++) { + double[] y = cols[j]; + + // Filter finite pairs once per edge + PairXY xy = finitePairs(x, y); + double s; + switch (metric) { + case NMI -> s = nmiLite(xy.x, xy.y, nmiBins); + case PEARSON_ABS -> s = Math.abs(pearson(xy.x, xy.y)); + case DIST_CORR -> s = distCorr(xy.x, xy.y); + default -> s = 0.0; + } + double q = (xy.n == 0) ? 0.0 : (xy.n / (double) N); // fraction of usable rows + + M[ii][j] = s; + M[j][ii] = s; + Q[ii][j] = q; + Q[j][ii] = q; + } + })); + } + + // Wait + for (Future f : futures) { + try { + f.get(); + } catch (Exception e) { + pool.shutdownNow(); + throw new RuntimeException("SimilarityComputer: task failed", e); + } + } + pool.shutdown(); + + // Labels + List labels = new ArrayList<>(F); + for (int c : comps) labels.add("Comp " + c); + + // Return + return new SimilarityResult( + M, + Q, + metric, + comps, + labels, + N, + (metric == Metric.NMI ? nmiBins : null), + recipe.getMinAvgCountPerCell() + ); + } + + // --------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------- + + /** + * Extracts an array per selected component index: cols[k][row]. + */ + private static double[][] extractColumns(List vectors, int[] comps) { + int F = comps.length; + int N = vectors.size(); + double[][] cols = new double[F][N]; + for (int r = 0; r < N; r++) { + List row = vectors.get(r).getData(); + for (int k = 0; k < F; k++) { + int idx = comps[k]; + double v = (row != null && idx >= 0 && idx < row.size() && row.get(idx) != null) + ? row.get(idx) + : Double.NaN; + cols[k][r] = v; + } + } + return cols; + } + + /** + * Holder for filtered finite pairs. + */ + private static final class PairXY { + final double[] x, y; + final int n; + + PairXY(double[] x, double[] y) { + this.x = x; + this.y = y; + this.n = x.length; + } + } + + /** + * Remove any rows with non-finite x or y. + */ + private static PairXY finitePairs(double[] x, double[] y) { + int n = Math.min(x.length, y.length); + double[] xx = new double[n]; + double[] yy = new double[n]; + int m = 0; + for (int i = 0; i < n; i++) { + double a = x[i], b = y[i]; + if (Double.isFinite(a) && Double.isFinite(b)) { + xx[m] = a; + yy[m] = b; + m++; + } + } + if (m == n) return new PairXY(xx, yy); + double[] x2 = new double[m], y2 = new double[m]; + System.arraycopy(xx, 0, x2, 0, m); + System.arraycopy(yy, 0, y2, 0, m); + return new PairXY(x2, y2); + } + + // ---------------- Pearson r ---------------- + private static double pearson(double[] x, double[] y) { + int n = Math.min(x.length, y.length); + if (n < 3) return 0.0; + double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0; + for (int i = 0; i < n; i++) { + double xi = x[i], yi = y[i]; + sx += xi; + sy += yi; + sxx += xi * xi; + syy += yi * yi; + sxy += xi * yi; + } + double num = n * sxy - sx * sy; + double den = Math.sqrt(Math.max(0, n * sxx - sx * sx)) * Math.sqrt(Math.max(0, n * syy - sy * sy)); + if (den == 0) return 0.0; + double r = num / den; + if (Double.isNaN(r) || Double.isInfinite(r)) return 0.0; + return r; + } + + // ---------------- Distance correlation (biased) ---------------- + private static double distCorr(double[] x, double[] y) { + int n = Math.min(x.length, y.length); + if (n < 3) return 0.0; + + double[][] ax = distanceMatrix(x); + double[][] ay = distanceMatrix(y); + + double[][] axc = doubleCenter(ax); + double[][] ayc = doubleCenter(ay); + + double dcov2 = meanElementwiseProduct(axc, ayc); + double dvarx = meanElementwiseProduct(axc, axc); + double dvary = meanElementwiseProduct(ayc, ayc); + + if (dvarx <= 0 || dvary <= 0) return 0.0; + double dcor = Math.sqrt(Math.max(0, dcov2)) / Math.sqrt(dvarx * dvary); + if (Double.isNaN(dcor) || Double.isInfinite(dcor)) return 0.0; + if (dcor < 0) dcor = 0; + else if (dcor > 1) dcor = 1; + return dcor; + } + + private static double[][] distanceMatrix(double[] v) { + int n = v.length; + double[][] d = new double[n][n]; + for (int i = 0; i < n; i++) { + d[i][i] = 0; + for (int j = i + 1; j < n; j++) { + double dij = Math.abs(v[i] - v[j]); + d[i][j] = dij; + d[j][i] = dij; + } + } + return d; + } + + private static double[][] doubleCenter(double[][] a) { + int n = a.length; + double[] rowMean = new double[n]; + double[] colMean = new double[n]; + double grand = 0.0; + + for (int i = 0; i < n; i++) { + double rs = 0; + for (int j = 0; j < n; j++) rs += a[i][j]; + rowMean[i] = rs / n; + grand += rs; + } + grand /= (n * n); + + for (int j = 0; j < n; j++) { + double cs = 0; + for (int i = 0; i < n; i++) cs += a[i][j]; + colMean[j] = cs / n; + } + + double[][] out = new double[n][n]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + out[i][j] = a[i][j] - rowMean[i] - colMean[j] + grand; + } + } + return out; + } + + private static double meanElementwiseProduct(double[][] a, double[][] b) { + int n = a.length; + double s = 0.0; + for (int i = 0; i < n; i++) + for (int j = 0; j < n; j++) + s += a[i][j] * b[i][j]; + return s / (n * n); + } + + // ---------------- NMI (equal-width + add-one smoothing) ---------------- + private static double nmiLite(double[] x, double[] y, int bins) { + int n = Math.min(x.length, y.length); + if (n == 0) return 0.0; + + double minX = Double.POSITIVE_INFINITY, maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < n; i++) { + double xi = x[i], yi = y[i]; + if (xi < minX) minX = xi; + if (xi > maxX) maxX = xi; + if (yi < minY) minY = yi; + if (yi > maxY) maxY = yi; + } + if (!(maxX > minX)) maxX = minX + 1e-8; + if (!(maxY > minY)) maxY = minY + 1e-8; + + double dx = (maxX - minX) / bins; + double dy = (maxY - minY) / bins; + + // add-one smoothing + double[][] joint = new double[bins][bins]; + double[] px = new double[bins]; + double[] py = new double[bins]; + for (int bx = 0; bx < bins; bx++) { + px[bx] = 1.0; + } + for (int by = 0; by < bins; by++) { + py[by] = 1.0; + for (int bx = 0; bx < bins; bx++) joint[by][bx] = 1.0; + } + + double nEff = n + bins + bins + bins * bins; + + for (int i = 0; i < n; i++) { + int bx = (int) Math.floor((x[i] - minX) / dx); + int by = (int) Math.floor((y[i] - minY) / dy); + if (bx < 0) bx = 0; + if (bx >= bins) bx = bins - 1; + if (by < 0) by = 0; + if (by >= bins) by = bins - 1; + joint[by][bx] += 1.0; + px[bx] += 1.0; + py[by] += 1.0; + } + + for (int bx = 0; bx < bins; bx++) px[bx] /= nEff; + for (int by = 0; by < bins; by++) py[by] /= nEff; + for (int by = 0; by < bins; by++) + for (int bx = 0; bx < bins; bx++) + joint[by][bx] /= nEff; + + double hx = entropy(px); + double hy = entropy(py); + double mi = 0.0; + for (int by = 0; by < bins; by++) { + for (int bx = 0; bx < bins; bx++) { + double pxy = joint[by][bx]; + double den = px[bx] * py[by]; + if (pxy > 0 && den > 0) mi += pxy * Math.log(pxy / den); + } + } + double denom = Math.sqrt(Math.max(hx, 1e-12) * Math.max(hy, 1e-12)); + double nmi = (denom > 0) ? (mi / denom) : 0.0; + if (Double.isNaN(nmi) || Double.isInfinite(nmi)) nmi = 0.0; + if (nmi < 0) nmi = 0; + else if (nmi > 1) nmi = 1; + return nmi; + } + + private static double entropy(double[] p) { + double h = 0.0; + for (double v : p) if (v > 0) h -= v * Math.log(v); + return h; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java new file mode 100644 index 00000000..5c88ec25 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -0,0 +1,390 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.application.Platform; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +public class StatPdfCdfChart extends LineChart { + + public enum Mode {PDF_ONLY, CDF_ONLY} + + private StatisticEngine.ScalarType scalarType; + private int bins; + private Mode mode; + private List vectors; + + // Metric mode + private String metricNameForGeneric; + private List referenceVectorForGeneric; + + // Component marginal mode + private Integer componentIndex; + + // X-axis lock + private boolean lockXAxis = false; + private double lockLower = 0.0; + private double lockUpper = 1.0; + + // Presentation + private boolean showSymbols = true; + private double seriesStrokeWidth = 2.0; + + // --- raw-sample override for 2D charts --- + private List scalarSamples = null; // if non-null & non-empty, use this instead of vectors + + //retain last stat and expose interactions + private StatisticResult lastStat = null; + + public static final class BinSelection { + public final int bin; + public final double xCenter; + public final double xFrom, xTo; + public final int count; + public final double fraction; + public final int[] sampleIdx; + + public BinSelection(int bin, double xCenter, double xFrom, double xTo, + int count, double fraction, int[] sampleIdx) { + this.bin = bin; + this.xCenter = xCenter; + this.xFrom = xFrom; + this.xTo = xTo; + this.count = count; + this.fraction = fraction; + this.sampleIdx = sampleIdx; + } + } + + private Consumer onBinHover; + private Consumer onBinClick; + + public void setOnBinHover(Consumer h) { + this.onBinHover = h; + } + + public void setOnBinClick(Consumer h) { + this.onBinClick = h; + } + + public StatisticResult getLastStatisticResult() { + return lastStat; + } + + public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mode) { + super(new NumberAxis(), new NumberAxis()); + this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; + this.bins = Math.max(5, bins); + this.mode = (mode != null) ? mode : Mode.PDF_ONLY; + this.vectors = new ArrayList<>(); + + NumberAxis xAxis = (NumberAxis) getXAxis(); + NumberAxis yAxis = (NumberAxis) getYAxis(); + xAxis.setLabel("Scalar Value"); + yAxis.setLabel(this.mode == Mode.PDF_ONLY ? "Density (PDF)" : "Probability (CDF)"); + yAxis.setForceZeroInRange(false); + + setAnimated(false); + setLegendVisible(false); + setCreateSymbols(showSymbols); + setTitle(titleForCurrentState()); + } + + // ---- configuration ---- + + public void setScalarType(StatisticEngine.ScalarType scalarType) { + this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; + setTitle(titleForCurrentState()); + refresh(); + } + + public void setBins(int bins) { + this.bins = Math.max(5, bins); + refresh(); + } + + public void setMode(Mode mode) { + this.mode = (mode != null) ? mode : Mode.PDF_ONLY; + NumberAxis yAxis = (NumberAxis) getYAxis(); + yAxis.setLabel(this.mode == Mode.PDF_ONLY ? "Density (PDF)" : "Probability (CDF)"); + setTitle(titleForCurrentState()); + refresh(); + } + + public void setFeatureVectors(List vectors) { + this.vectors = (vectors != null) ? new ArrayList<>(vectors) : new ArrayList<>(); + refresh(); + } + + public void addFeatureVectors(List newVectors) { + if (newVectors == null || newVectors.isEmpty()) return; + if (this.vectors == null) this.vectors = new ArrayList<>(newVectors); + else this.vectors.addAll(newVectors); + refresh(); + } + + // Metric mode + public void setMetricNameForGeneric(String metricNameForGeneric) { + this.metricNameForGeneric = metricNameForGeneric; + refresh(); + } + + public void setReferenceVectorForGeneric(List referenceVectorForGeneric) { + this.referenceVectorForGeneric = referenceVectorForGeneric; + refresh(); + } + + // Component marginal mode + public void setComponentIndex(Integer componentIndex) { + this.componentIndex = componentIndex; + refresh(); + } + + // Axis lock control + public void setLockXAxis(boolean lock, double lower, double upper) { + this.lockXAxis = lock; + this.lockLower = lower; + this.lockUpper = upper; + applyAxisLock(); + } + + // Presentation controls + public void setShowSymbols(boolean show) { + this.showSymbols = show; + setCreateSymbols(showSymbols); + } + + public void setSeriesStrokeWidth(double px) { + this.seriesStrokeWidth = Math.max(0.5, px); + getData().forEach(s -> { + if (s.getNode() != null) { + s.getNode().setStyle("-fx-stroke-width: " + this.seriesStrokeWidth + "px;"); + } + }); + } + + // ---- raw samples API ---- + + /** + * Use precomputed scalar samples instead of deriving scalars from FeatureVectors. + */ + public void setScalarSamples(List samples) { + if (samples == null || samples.isEmpty()) { + this.scalarSamples = null; + } else { + this.scalarSamples = new ArrayList<>(samples); + } + refresh(); + } + + public void addScalarSamples(List more) { + if (more == null || more.isEmpty()) return; + if (this.scalarSamples == null) this.scalarSamples = new ArrayList<>(more); + else this.scalarSamples.addAll(more); + refresh(); + } + + public void clearScalarSamples() { + this.scalarSamples = null; + refresh(); + } + + // getters + public StatisticEngine.ScalarType getScalarType() { + return scalarType; + } + + public int getBins() { + return bins; + } + + public Mode getMode() { + return mode; + } + + public String getMetricNameForGeneric() { + return metricNameForGeneric; + } + + public List getReferenceVectorForGeneric() { + return referenceVectorForGeneric; + } + + public Integer getComponentIndex() { + return componentIndex; + } + + public boolean isLockXAxis() { + return lockXAxis; + } + + public double getLockLower() { + return lockLower; + } + + public double getLockUpper() { + return lockUpper; + } + + public boolean isShowSymbols() { + return showSymbols; + } + + public double getSeriesStrokeWidth() { + return seriesStrokeWidth; + } + + public boolean isUsingRawSamples() { + return scalarSamples != null && !scalarSamples.isEmpty(); + } + + // internal + private String titleForCurrentState() { + return (this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType; + } + + private void refresh() { + getData().clear(); + lastStat = null; + + if (isUsingRawSamples()) { + // Directly bin the provided samples + StatisticResult stat = StatisticEngine.buildStatResult(scalarSamples, bins); + plotFromStatistic(stat); + return; + } + + if (vectors == null || vectors.isEmpty()) { + applyAxisLock(); + return; + } + + Set types = Set.of(scalarType); + String metricName = (scalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) ? metricNameForGeneric : null; + List refVec = (scalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) ? referenceVectorForGeneric : null; + Integer compIdx = (scalarType == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) ? componentIndex : null; + + Map resultMap = + StatisticEngine.computeStatistics(vectors, types, bins, metricName, refVec, compIdx); + + plotFromStatistic(resultMap.get(scalarType)); + } + + private void plotFromStatistic(StatisticResult stat) { + lastStat = stat; + if (stat == null) { + applyAxisLock(); + return; + } + double[] x = stat.getPdfBins(); + XYChart.Series series = new XYChart.Series<>(); + + if (mode == Mode.PDF_ONLY) { + double[] y = stat.getPdf(); + int n = Math.min(x.length, y.length); + for (int i = 0; i < n; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); + } else { + double[] y = stat.getCdf(); + int n = Math.min(x.length, y.length); + for (int i = 0; i < n; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); + } + getData().add(series); + + if (series.getNode() != null) { + series.getNode().setStyle("-fx-stroke-width: " + seriesStrokeWidth + "px;"); + } + applyAxisLock(); + + attachPlotInteractions(); + } + + private void applyAxisLock() { + NumberAxis xAxis = (NumberAxis) getXAxis(); + if (xAxis == null) return; + + if (lockXAxis) { + xAxis.setAutoRanging(false); + xAxis.setLowerBound(lockLower); + xAxis.setUpperBound(lockUpper); + double span = Math.max(1e-12, lockUpper - lockLower); + double tick = span / 10.0; + xAxis.setTickUnit(tick); + xAxis.setMinorTickCount(2); + } else { + xAxis.setAutoRanging(true); + } + } + + //interaction plumbing + private void attachPlotInteractions() { + Platform.runLater(() -> { + Node plotArea = lookup(".chart-plot-background"); + if (plotArea == null) return; + + plotArea.setOnMouseMoved(evt -> { + if (onBinHover == null || lastStat == null || lastStat.getBinEdges() == null) return; + Double xVal = xValueFromPlotPixel(plotArea, evt.getX()); + if (xVal == null) return; + BinSelection sel = selectionForX(xVal); + if (sel != null) onBinHover.accept(sel); + }); + + plotArea.setOnMouseClicked(evt -> { + if (onBinClick == null || lastStat == null || lastStat.getBinEdges() == null) return; + Double xVal = xValueFromPlotPixel(plotArea, evt.getX()); + if (xVal == null) return; + BinSelection sel = selectionForX(xVal); + if (sel != null) onBinClick.accept(sel); + }); + }); + } + + private Double xValueFromPlotPixel(Node plotArea, double xInPlotLocal) { + try { + Point2D scenePt = plotArea.localToScene(xInPlotLocal, 0); + Point2D axisPt = getXAxis().sceneToLocal(scenePt); + return ((NumberAxis) getXAxis()).getValueForDisplay(axisPt.getX()).doubleValue(); + } catch (Exception ex) { + return null; + } + } + + private BinSelection selectionForX(double xVal) { + double[] edges = (lastStat != null) ? lastStat.getBinEdges() : null; + if (edges == null || edges.length < 2) return null; + + int n = edges.length - 1; + if (xVal <= edges[0]) xVal = edges[0] + 1e-12; + if (xVal >= edges[n]) xVal = edges[n] - 1e-12; + + // binary search for bin + int lo = 0, hi = n - 1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + if (xVal < edges[mid]) { + hi = mid - 1; + } else if (xVal >= edges[mid + 1]) { + lo = mid + 1; + } else { + int b = mid; + double center = lastStat.getPdfBins()[b]; + int count = lastStat.getBinCounts()[b]; + double frac = (lastStat.getTotalSamples() > 0) + ? ((double) count) / lastStat.getTotalSamples() + : 0.0; + int[] idx = lastStat.getBinToSampleIdx()[b]; + return new BinSelection(b, center, edges[b], edges[b + 1], count, frac, idx); + } + } + return null; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java new file mode 100644 index 00000000..c6c386e8 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -0,0 +1,1145 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.metric.Metric; +import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ComboBox; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioButton; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * PDF, CDF, and a time-series chart (vertical stack) with linked interactions, + * label filtering, and Similarity / Contribution / Cumulative modes. + *

+ * No scroll panes, no wildcard imports, and no inline CSS. + *

+ * Top-K contributors live to the right of the time-series in a fixed-width panel. + * + * @author Sean Phillips + */ +public class StatPdfCdfChartPanel extends BorderPane { + + // ===== Charts ===== + private StatPdfCdfChart pdfChart; + private StatPdfCdfChart cdfChart; + private StatTimeSeriesChart tsChart; + + // ===== Top-K contributors UI ===== + private Spinner topKSpinner; + private ListView topKList; + private final ObservableList topKItems = FXCollections.observableArrayList(); + private final List topKIndices = new ArrayList<>(); + private int topKFixedWidth = 220; // fixed width for side panel + private int topKSpinnerWidth = 80; // fixed width for spinner + + // Cap time-series row height (no pref height set) + private double tsMaxHeight = 280.0; + + // ===== Data source mode ===== + private enum DataSource {VECTORS, SCALARS} + + private DataSource dataSource = DataSource.VECTORS; + + // Scalars mode state/UI + private List scalarScores = new ArrayList<>(); + private List scalarInfos = new ArrayList<>(); + private String scalarField = "Score"; // "Score" or "Info%" + + // Axis lock + private boolean lockXToUnit = false; + + // Vectors mode controls/state + private ComboBox xFeatureCombo; + private Spinner binsSpinner; + + private ComboBox metricCombo; + private String referenceMode = "Mean"; // "Mean", "Vector @ Index", "Custom" + private Spinner xIndexSpinner; + + private ComboBox yFeatureCombo; + private Spinner yIndexSpinner; + + private boolean surfaceCDF = false; // false=PDF, true=CDF + + // Data/state + private List currentVectors = new ArrayList<>(); + private StatisticEngine.ScalarType currentXFeature = StatisticEngine.ScalarType.L1_NORM; + private int currentBins = 40; + private List customReferenceVector; + + // Time-series mode & aggregator + private enum TSMode {SIMILARITY, CONTRIBUTION, CUMULATIVE} + + private TSMode tsMode = TSMode.CONTRIBUTION; + private StatisticEngine.SimilarityAggregator agg = StatisticEngine.SimilarityAggregator.MEAN; + private final double contribEps = 1e-6; + private HBox tsRow; + // Selection + private boolean persistSelection = false; + private final Label selectionInfo = new Label("—"); + + // ===== Label filter ===== + private static final String UNLABELED = "(unlabeled)"; + private List allLabels = new ArrayList<>(); + private List selectedLabels = new ArrayList<>(); + private final Map labelChecks = new LinkedHashMap<>(); + private Menu labelsMenu; // built in Options + private CustomMenuItem labelsCustom; + private VBox labelsBox; + // Callback + private Consumer onComputeSurface = null; + + public StatPdfCdfChartPanel() { + this(null, StatisticEngine.ScalarType.L1_NORM, 40); + } + + public StatPdfCdfChartPanel(List initialVectors, + StatisticEngine.ScalarType initialType, + int initialBins) { + if (initialVectors != null) currentVectors = initialVectors; + if (initialType != null) currentXFeature = initialType; + if (initialBins > 0) currentBins = initialBins; + + // --- Visible row: X Feature (2D), Bins, X Dim (always visible), Refresh, Options --- + xFeatureCombo = new ComboBox<>(); + xFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); + xFeatureCombo.setValue(currentXFeature); + + binsSpinner = new Spinner<>(5, 100, currentBins, 5); + binsSpinner.setPrefWidth(100); + binsSpinner.setPrefHeight(34); + + // Always-visible X dimension spinner + xIndexSpinner = new Spinner<>(); + xIndexSpinner.setPrefWidth(100); + xIndexSpinner.setMinWidth(100); + xIndexSpinner.setMaxWidth(100); + xIndexSpinner.valueProperty().addListener((obs, ov, nv) -> { + if (nv == null) return; + if (dataSource == DataSource.VECTORS) refresh2DCharts(); + }); + + Button refresh2DButton = new Button("Refresh"); + refresh2DButton.setOnAction(e -> refresh2DCharts()); + + MenuButton optionsMenu = buildOptionsMenu(); + + HBox topBar = new HBox(12, + new VBox(2, new Label("X Feature"), xFeatureCombo), + new VBox(2, new Label("Bins"), binsSpinner), + new VBox(2, new Label("X Dim"), xIndexSpinner), + refresh2DButton, + optionsMenu + ); + topBar.setAlignment(Pos.CENTER_LEFT); + topBar.setPadding(new Insets(6, 8, 4, 8)); + HBox.setHgrow(optionsMenu, Priority.NEVER); + + // --- Charts: PDF (top), CDF (middle), TS+TopK (bottom) --- + pdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); + cdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); + tsChart = new StatTimeSeriesChart(); + + // Build the Top-K panel and place it to the right of the time-series + VBox topKPanel = buildTopKPanel(); + this.tsRow = new HBox(8, tsChart, topKPanel); // <-- use the FIELD, not a local + HBox.setHgrow(tsChart, Priority.ALWAYS); + this.tsRow.setFillHeight(true); + + // Initial data + if (!currentVectors.isEmpty()) { + rebuildLabelsFromCurrentVectors(); + applyXFeatureOptions(); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); // also refreshes Top-K + } + if (lockXToUnit) { + pdfChart.setLockXAxis(true, 0.0, 1.0); + cdfChart.setLockXAxis(true, 0.0, 1.0); + } + + wireChartInteractions(); + + Button clearBtn = new Button("Clear Highlights"); + clearBtn.setOnAction(e -> { + tsChart.clearHighlights(); + selectionInfo.setText("—"); + }); + + HBox bottomBar = new HBox(12, new Label("Selection:"), selectionInfo, new Region(), clearBtn); + HBox.setHgrow(bottomBar.getChildren().get(2), Priority.ALWAYS); + bottomBar.setAlignment(Pos.CENTER_LEFT); + bottomBar.setPadding(new Insets(2, 8, 8, 8)); + + // Container + VBox chartsBox = new VBox(8, pdfChart, cdfChart, this.tsRow, bottomBar); + chartsBox.setPadding(new Insets(6)); + chartsBox.setFillWidth(true); + + // Make all three main sections share vertical space equally + enforceEqualSectionHeights(); + + setTop(topBar); + setCenter(chartsBox); + + xFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { + currentXFeature = nv; + updateXControlEnablement(); + updateXIndexBoundsAndValue(); + }); + } + + /** + * Force PDF, CDF, and TS rows to share vertical space and not inflate the parent. + */ + private void enforceEqualSectionHeights() { + // Give each section a reasonable minimum so they don't collapse + pdfChart.setMinHeight(120); + cdfChart.setMinHeight(120); + tsChart.setMinHeight(120); + + // Shrink preferred heights so the parent does NOT expand to large computed prefs. + // (Charts default to a large prefHeight; zeroing it avoids vertical oversizing.) + pdfChart.setPrefHeight(0); + cdfChart.setPrefHeight(0); + this.tsRow.setPrefHeight(0); + + // Let VBox distribute extra space equally across all three + VBox.setVgrow(pdfChart, Priority.ALWAYS); + VBox.setVgrow(cdfChart, Priority.ALWAYS); + VBox.setVgrow(this.tsRow, Priority.ALWAYS); + } + + // ===== Top-K panel ===== + private VBox buildTopKPanel() { + Label title = new Label("Top-K Contributors"); + Label kLabel = new Label("K:"); + topKSpinner = new Spinner<>(); + topKSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 1000, 10, 1)); + topKSpinner.setPrefWidth(topKSpinnerWidth); + topKSpinner.setMinWidth(topKSpinnerWidth); + topKSpinner.setMaxWidth(topKSpinnerWidth); + topKSpinner.valueProperty().addListener((obs, ov, nv) -> refreshTopK()); + + HBox header = new HBox(6, title, new Region(), kLabel, topKSpinner); + HBox.setHgrow(header.getChildren().get(1), Priority.ALWAYS); + header.setAlignment(Pos.CENTER_LEFT); + + topKList = new ListView<>(topKItems); + // Fixed width for the whole side panel + VBox panel = new VBox(6, header, topKList); + panel.setPadding(new Insets(0, 0, 0, 0)); + panel.setMinWidth(topKFixedWidth); + panel.setPrefWidth(topKFixedWidth); + panel.setMaxWidth(topKFixedWidth); + VBox.setVgrow(topKList, Priority.ALWAYS); // list consumes vertical space inside panel only + + // Clicking an item highlights that sample + topKList.setOnMouseClicked(e -> { + int idx = topKList.getSelectionModel().getSelectedIndex(); + if (idx >= 0 && idx < topKIndices.size()) { + int sampleIdx = topKIndices.get(idx); + tsChart.clearHighlights(); + tsChart.highlightSamples(new int[]{sampleIdx}); + selectionInfo.setText("Top-K picked sample #" + sampleIdx); + } + }); + + return panel; + } + + private void refreshTopK() { + List series = getCurrentTSValues(); + topKItems.clear(); + topKIndices.clear(); + if (series == null || series.isEmpty()) return; + + int k = Math.max(1, Math.min(topKSpinner.getValue(), series.size())); + List pairs = new ArrayList<>(series.size()); + for (int i = 0; i < series.size(); i++) { + double v = series.get(i) == null ? 0.0 : series.get(i); + pairs.add(new int[]{i, (int) Math.round(Math.abs(v) * 1_000_000)}); // keep stable sort with abs magnitude + } + pairs.sort((a, b) -> Integer.compare(b[1], a[1])); + + for (int i = 0; i < k; i++) { + int idx = pairs.get(i)[0]; + double val = series.get(idx) == null ? 0.0 : series.get(idx); + topKIndices.add(idx); + topKItems.add(String.format("#%d | Δ=%.6f", idx, val)); + } + } + + private List getCurrentTSValues() { + List use = getFilteredVectors(); + if (dataSource == DataSource.SCALARS) { + // For pasted scalars, only Similarity mode is supported + return new ArrayList<>(scalarField.equals("Info%") ? scalarInfos : scalarScores); + } + if (use == null || use.isEmpty()) return new ArrayList<>(); + + if (tsMode == TSMode.CONTRIBUTION) { + StatisticEngine.ContributionSeries cs = + StatisticEngine.computeContributions(use, agg, null, contribEps); + return cs.delta; + } else if (tsMode == TSMode.CUMULATIVE) { + StatisticEngine.ContributionSeries cs = + StatisticEngine.computeContributions(use, agg, null, contribEps); + return StatisticEngine.cumulativeFromDeltas(cs.delta); + } else { + // SIMILARITY: show the same scalar values that fed the PDF/CDF + StatisticResult stat = (pdfChart != null) ? pdfChart.getLastStatisticResult() : null; + List vals = (stat != null) ? stat.getValues() : null; + return (vals != null) ? new ArrayList<>(vals) : new ArrayList<>(); + } + } + + // ===== Options menu ===== + private MenuButton buildOptionsMenu() { + MenuButton mb = new MenuButton("Options"); + + // Data source + CheckMenuItem useScalars = new CheckMenuItem("Use Scalars Mode"); + useScalars.setSelected(false); + useScalars.selectedProperty().addListener((o, oldV, nv) -> { + dataSource = nv ? DataSource.SCALARS : DataSource.VECTORS; + if (nv) { + applyScalarSamplesToCharts(); + } else { + pdfChart.clearScalarSamples(); + cdfChart.clearScalarSamples(); + refresh2DCharts(); + } + }); + + CheckMenuItem lockX = new CheckMenuItem("Lock X to [0,1]"); + lockX.setSelected(lockXToUnit); + lockX.selectedProperty().addListener((o, oldV, nv) -> { + lockXToUnit = nv; + pdfChart.setLockXAxis(nv, 0.0, 1.0); + cdfChart.setLockXAxis(nv, 0.0, 1.0); + }); + + CheckMenuItem persistSel = new CheckMenuItem("Persist Selection"); + persistSel.setSelected(persistSelection); + persistSel.selectedProperty().addListener((o, oldV, nv) -> persistSelection = nv); + + // Scalars submenu + Menu scalarsMenu = new Menu("Scalars"); + ToggleGroup scalarFieldGroup = new ToggleGroup(); + + RadioMenuItem scoreItem = new RadioMenuItem("Field: Score"); + scoreItem.setToggleGroup(scalarFieldGroup); + scoreItem.setSelected("Score".equals(scalarField)); + scoreItem.setOnAction(e -> { + scalarField = "Score"; + applyScalarSamplesToCharts(); + }); + + RadioMenuItem infoItem = new RadioMenuItem("Field: Info%"); + infoItem.setToggleGroup(scalarFieldGroup); + infoItem.setSelected("Info%".equals(scalarField)); + infoItem.setOnAction(e -> { + scalarField = "Info%"; + applyScalarSamplesToCharts(); + }); + + MenuItem pasteScalars = new MenuItem("Paste Scalars…"); + pasteScalars.setOnAction(e -> { + ScalarInputResult res = DialogUtils.showScalarSamplesDialog(); + if (res == null) return; + if (res.scores != null && !res.scores.isEmpty()) scalarScores = res.scores; + if (res.infos != null && !res.infos.isEmpty()) scalarInfos = res.infos; + applyScalarSamplesToCharts(); + }); + + MenuItem clearScalars = new MenuItem("Clear Scalars"); + clearScalars.setOnAction(e -> { + scalarScores.clear(); + scalarInfos.clear(); + pdfChart.clearScalarSamples(); + cdfChart.clearScalarSamples(); + tsChart.clearHighlights(); + tsChart.setSeries(new ArrayList<>()); + selectionInfo.setText("—"); + refreshTopK(); + }); + + scalarsMenu.getItems().addAll(scoreItem, infoItem, new SeparatorMenuItem(), pasteScalars, clearScalars); + + // ----- Labels Filter submenu (custom content that stays open) ----- + labelsMenu = new Menu("Labels (Filter)"); + labelsBox = new VBox(6); + labelsBox.setPadding(new Insets(6, 10, 6, 10)); + labelsCustom = new CustomMenuItem(labelsBox); + labelsCustom.setHideOnClick(false); // keep menu open while clicking checkboxes + labelsMenu.getItems().setAll(labelsCustom); // single custom content node + + // Metric/Reference (X) section (no X Index here anymore) + metricCombo = new ComboBox<>(); + metricCombo.getItems().addAll(Metric.getMetricNames()); + if (metricCombo.getItems().contains("euclidean")) metricCombo.setValue("euclidean"); + else if (!metricCombo.getItems().isEmpty()) metricCombo.setValue(metricCombo.getItems().get(0)); + + ComboBox referenceCombo = new ComboBox<>(); + referenceCombo.getItems().addAll("Mean", "Vector @ Index", "Custom"); + referenceCombo.setValue(referenceMode); + referenceCombo.valueProperty().addListener((obs, ov, nv) -> { + referenceMode = nv; + updateXControlEnablement(); + updateXIndexBoundsAndValue(); + }); + + Button setCustomButton = new Button("Set Custom Vector…"); + setCustomButton.setOnAction(e -> { + Integer expectedDim = (currentVectors != null && !currentVectors.isEmpty()) + ? currentVectors.get(0).getData().size() : null; + List parsed = (expectedDim != null) + ? DialogUtils.showCustomVectorDialog(expectedDim) + : DialogUtils.showCustomVectorDialog(); + if (parsed != null && !parsed.isEmpty()) setCustomReferenceVector(parsed); + }); + + VBox metricBox = new VBox(6, + new HBox(8, new Label("Metric (X)"), metricCombo), + new HBox(8, new Label("Reference"), referenceCombo), + setCustomButton + ); + metricBox.setPadding(new Insets(6, 10, 6, 10)); + CustomMenuItem metricCustom = new CustomMenuItem(metricBox); + metricCustom.setHideOnClick(false); + + MenuItem metricHeader = new MenuItem("X Metric & Reference"); + metricHeader.setDisable(true); + + // 3D Surface section + yFeatureCombo = new ComboBox<>(); + yFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); + yFeatureCombo.setValue(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + yFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { + updateYControlEnablement(); + updateYIndexBoundsAndValue(); + }); + + yIndexSpinner = new Spinner<>(); + yIndexSpinner.setPrefWidth(100); + yIndexSpinner.setMinWidth(100); + yIndexSpinner.setMaxWidth(100); + + ToggleGroup surfaceGroup = new ToggleGroup(); + RadioButton surfacePdf = new RadioButton("PDF"); + surfacePdf.setToggleGroup(surfaceGroup); + surfacePdf.setSelected(!surfaceCDF); + surfacePdf.setOnAction(e -> surfaceCDF = false); + + RadioButton surfaceCdf = new RadioButton("CDF"); + surfaceCdf.setToggleGroup(surfaceGroup); + surfaceCdf.setSelected(surfaceCDF); + surfaceCdf.setOnAction(e -> surfaceCDF = true); + + Button compute3DButton = new Button("Compute 3D Surface"); + compute3DButton.setOnAction(e -> compute3DSurface()); + + VBox surfaceBox = new VBox(6, + new HBox(8, new Label("Y Feature"), yFeatureCombo), + new HBox(8, new Label("Y Index"), yIndexSpinner), + new HBox(8, new Label("Surface"), surfacePdf, surfaceCdf), + compute3DButton + ); + surfaceBox.setPadding(new Insets(6, 10, 6, 10)); + CustomMenuItem surfaceCustom = new CustomMenuItem(surfaceBox); + surfaceCustom.setHideOnClick(false); + + MenuItem surfaceHeader = new MenuItem("3D Surface"); + surfaceHeader.setDisable(true); + + // Time-Series Mode & Aggregator + Menu tsModeMenu = new Menu("Time-Series Mode"); + ToggleGroup tsModeGroup = new ToggleGroup(); + RadioMenuItem tsSimilarity = new RadioMenuItem("Similarity (matches PDF feature)"); + tsSimilarity.setToggleGroup(tsModeGroup); + tsSimilarity.setSelected(tsMode == TSMode.SIMILARITY); + tsSimilarity.setOnAction(e -> { + tsMode = TSMode.SIMILARITY; + updateTimeSeries(); + }); + + RadioMenuItem tsContribution = new RadioMenuItem("Contribution (Δ log-odds)"); + tsContribution.setToggleGroup(tsModeGroup); + tsContribution.setSelected(tsMode == TSMode.CONTRIBUTION); + tsContribution.setOnAction(e -> { + tsMode = TSMode.CONTRIBUTION; + updateTimeSeries(); + }); + + RadioMenuItem tsCumulative = new RadioMenuItem("Cumulative log-odds"); + tsCumulative.setToggleGroup(tsModeGroup); + tsCumulative.setSelected(tsMode == TSMode.CUMULATIVE); + tsCumulative.setOnAction(e -> { + tsMode = TSMode.CUMULATIVE; + updateTimeSeries(); + }); + + tsModeMenu.getItems().addAll(tsSimilarity, tsContribution, tsCumulative); + + Menu aggregatorMenu = new Menu("Aggregator (Contribution)"); + ToggleGroup aggGroup = new ToggleGroup(); + RadioMenuItem aggMean = new RadioMenuItem("Mean"); + RadioMenuItem aggGeo = new RadioMenuItem("Geomean"); + RadioMenuItem aggMin = new RadioMenuItem("Min"); + aggMean.setToggleGroup(aggGroup); + aggGeo.setToggleGroup(aggGroup); + aggMin.setToggleGroup(aggGroup); + aggMean.setSelected(agg == StatisticEngine.SimilarityAggregator.MEAN); + aggGeo.setSelected(agg == StatisticEngine.SimilarityAggregator.GEOMEAN); + aggMin.setSelected(agg == StatisticEngine.SimilarityAggregator.MIN); + aggMean.setOnAction(e -> { + agg = StatisticEngine.SimilarityAggregator.MEAN; + if (tsMode != TSMode.SIMILARITY) updateTimeSeries(); + }); + aggGeo.setOnAction(e -> { + agg = StatisticEngine.SimilarityAggregator.GEOMEAN; + if (tsMode != TSMode.SIMILARITY) updateTimeSeries(); + }); + aggMin.setOnAction(e -> { + agg = StatisticEngine.SimilarityAggregator.MIN; + if (tsMode != TSMode.SIMILARITY) updateTimeSeries(); + }); + aggregatorMenu.getItems().addAll(aggMean, aggGeo, aggMin); + + // Final assembly + mb.getItems().addAll( + useScalars, + lockX, + persistSel, + new SeparatorMenuItem(), + scalarsMenu, + labelsMenu, + new SeparatorMenuItem(), + tsModeMenu, + aggregatorMenu, + new SeparatorMenuItem(), + metricHeader, + metricCustom, + new SeparatorMenuItem(), + surfaceHeader, + surfaceCustom + ); + + // initialize enablement/bounds + updateXControlEnablement(); + updateYControlEnablement(); + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + + // Populate labels panel with current state (if vectors already set) + rebuildLabelsMenu(); + + return mb; + } + + + // ===== Public API ===== + + public boolean isSurfaceCDF() { + return surfaceCDF; + } + + public String getYFeatureTypeForDisplay() { + if (yFeatureCombo == null) return "N/A"; + StatisticEngine.ScalarType type = yFeatureCombo.getValue(); + if (type == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + Integer idx = yIndexSpinner.getValue(); + return "COMPONENT_AT_DIMENSION[" + (idx != null ? idx : "?") + "]"; + } + return (type != null) ? type.name() : "N/A"; + } + + /** + * Consumer invoked when "Compute 3D Surface" finishes successfully. + */ + public void setOnComputeSurface(Consumer handler) { + this.onComputeSurface = handler; + } + + public void setFeatureVectors(List vectors) { + this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); + rebuildLabelsFromCurrentVectors(); + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + if (dataSource == DataSource.VECTORS) { + applyXFeatureOptions(); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); + } + } + + public void addFeatureVectors(List newVectors) { + if (newVectors == null || newVectors.isEmpty()) return; + if (this.currentVectors == null) this.currentVectors = new ArrayList<>(newVectors); + else this.currentVectors.addAll(newVectors); + rebuildLabelsFromCurrentVectors(); + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + if (dataSource == DataSource.VECTORS) { + applyXFeatureOptions(); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); + } + } + + public int getBins() { + return binsSpinner.getValue(); + } + + public StatisticEngine.ScalarType getScalarType() { + return xFeatureCombo.getValue(); + } + + public StatPdfCdfChart getPdfChart() { + return pdfChart; + } + + public StatPdfCdfChart getCdfChart() { + return cdfChart; + } + + public StatTimeSeriesChart getTimeSeriesChart() { + return tsChart; + } + + public void setCustomReferenceVector(List customVector) { + this.customReferenceVector = customVector; + if (dataSource == DataSource.VECTORS + && xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && "Custom".equals(referenceMode) + && !currentVectors.isEmpty()) { + applyXFeatureOptions(); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); + } + } + + // ===== State ===== + + public static final class State { + public StatisticEngine.ScalarType xType; + public int bins; + public boolean lockX; + public boolean surfaceIsCDF; + public String metricName; + public String referenceMode; // Mean / Vector @ Index / Custom + public Integer xIndex; + public StatisticEngine.ScalarType yType; + public Integer yIndex; + public boolean usingScalars; + public String scalarField; // Score / Info% + public List scalarScores; + public List scalarInfos; + public List customRef; // nullable + public boolean persistSelection; + public List selectedLabels; + public String tsMode; // "SIMILARITY" / "CONTRIBUTION" / "CUMULATIVE" + public String aggregator; // "MEAN","GEOMEAN","MIN" + } + + public State exportState() { + State s = new State(); + s.xType = xFeatureCombo.getValue(); + s.bins = binsSpinner.getValue(); + s.lockX = lockXToUnit; + s.surfaceIsCDF = surfaceCDF; + s.metricName = metricCombo != null ? metricCombo.getValue() : null; + s.referenceMode = referenceMode; + s.xIndex = xIndexSpinner != null ? xIndexSpinner.getValue() : null; + s.yType = yFeatureCombo != null ? yFeatureCombo.getValue() : null; + s.yIndex = yIndexSpinner != null ? yIndexSpinner.getValue() : null; + s.usingScalars = (dataSource == DataSource.SCALARS); + s.scalarField = scalarField; + s.scalarScores = new ArrayList<>(scalarScores); + s.scalarInfos = new ArrayList<>(scalarInfos); + s.customRef = (customReferenceVector == null) ? null : new ArrayList<>(customReferenceVector); + s.persistSelection = persistSelection; + s.selectedLabels = new ArrayList<>(selectedLabels); + s.tsMode = this.tsMode.name(); + s.aggregator = this.agg.name(); + return s; + } + + public void applyState(State s) { + if (s == null) return; + + xFeatureCombo.setValue(s.xType != null ? s.xType : xFeatureCombo.getValue()); + binsSpinner.getValueFactory().setValue((s.bins > 0) ? s.bins : binsSpinner.getValue()); + lockXToUnit = s.lockX; + pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + + surfaceCDF = s.surfaceIsCDF; + + if (metricCombo != null && s.metricName != null) metricCombo.setValue(s.metricName); + if (s.referenceMode != null) referenceMode = s.referenceMode; + if (xIndexSpinner != null && s.xIndex != null) xIndexSpinner.getValueFactory().setValue(s.xIndex); + + if (yFeatureCombo != null && s.yType != null) yFeatureCombo.setValue(s.yType); + if (yIndexSpinner != null && s.yIndex != null) yIndexSpinner.getValueFactory().setValue(s.yIndex); + + dataSource = s.usingScalars ? DataSource.SCALARS : DataSource.VECTORS; + scalarField = (s.scalarField != null) ? s.scalarField : scalarField; + scalarScores = (s.scalarScores != null) ? new ArrayList<>(s.scalarScores) : new ArrayList<>(); + scalarInfos = (s.scalarInfos != null) ? new ArrayList<>(s.scalarInfos) : new ArrayList<>(); + customReferenceVector = (s.customRef != null) ? new ArrayList<>(s.customRef) : null; + persistSelection = s.persistSelection; + + if (s.selectedLabels != null) selectedLabels = new ArrayList<>(s.selectedLabels); + if (s.tsMode != null) tsMode = TSMode.valueOf(s.tsMode); + if (s.aggregator != null) agg = StatisticEngine.SimilarityAggregator.valueOf(s.aggregator); + + // Apply visuals + rebuildLabelsFromCurrentVectors(); + updateXControlEnablement(); + updateYControlEnablement(); + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + + if (dataSource == DataSource.SCALARS) { + applyScalarSamplesToCharts(); + } else { + refresh2DCharts(); + } + } + + // ===== Scalars helpers ===== + + private void applyScalarSamplesToCharts() { + if (dataSource != DataSource.SCALARS) return; + List chosen = "Info%".equals(scalarField) ? scalarInfos : scalarScores; + if (chosen == null || chosen.isEmpty()) { + pdfChart.clearScalarSamples(); + cdfChart.clearScalarSamples(); + tsChart.setSeries(new ArrayList<>()); + selectionInfo.setText("—"); + refreshTopK(); + return; + } + pdfChart.setScalarSamples(chosen); + cdfChart.setScalarSamples(chosen); + + // For pasted scalars, show Similarity + tsMode = TSMode.SIMILARITY; + tsChart.setAxisLabels("Sample Index", "Scalar Value"); + tsChart.setSeries(chosen); + + pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + refreshTopK(); + } + + // ===== 2D (Vectors) behavior ===== + + private void refresh2DCharts() { + if (dataSource != DataSource.VECTORS) return; + if (null == pdfChart || null == cdfChart) return; + currentXFeature = xFeatureCombo.getValue(); + currentBins = binsSpinner.getValue(); + + pdfChart.setScalarType(currentXFeature); + pdfChart.setBins(currentBins); + + cdfChart.setScalarType(currentXFeature); + cdfChart.setBins(currentBins); + + applyXFeatureOptions(); + + List use = getFilteredVectors(); + if (use != null && !use.isEmpty()) { + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); + } else { + pdfChart.setFeatureVectors(new ArrayList<>()); + cdfChart.setFeatureVectors(new ArrayList<>()); + tsChart.setSeries(new ArrayList<>()); + selectionInfo.setText("—"); + refreshTopK(); + } + + pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + } + + private void applyXFeatureOptions() { + // Clear residuals + pdfChart.setMetricNameForGeneric(null); + pdfChart.setReferenceVectorForGeneric(null); + pdfChart.setComponentIndex(null); + + cdfChart.setMetricNameForGeneric(null); + cdfChart.setReferenceVectorForGeneric(null); + cdfChart.setComponentIndex(null); + + if (currentXFeature == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + String metricName = (metricCombo != null) ? metricCombo.getValue() : null; + List ref = switch (referenceMode) { + case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); + case "Custom" -> customReferenceVector; + default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); + }; + pdfChart.setMetricNameForGeneric(metricName); + pdfChart.setReferenceVectorForGeneric(ref); + cdfChart.setMetricNameForGeneric(metricName); + cdfChart.setReferenceVectorForGeneric(ref); + } else if (currentXFeature == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + Integer idx = xIndexSpinner.getValue(); + pdfChart.setComponentIndex(idx); + cdfChart.setComponentIndex(idx); + } + } + + private void compute3DSurface() { + if (onComputeSurface == null) return; + List use = getFilteredVectors(); + if (use == null || use.isEmpty()) return; + if (dataSource != DataSource.VECTORS) return; + + AxisParams xAxis = new AxisParams(); + xAxis.setType(xFeatureCombo.getValue()); + if (xAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + xAxis.setMetricName(metricCombo.getValue()); + List ref = switch (referenceMode) { + case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); + case "Custom" -> customReferenceVector; + default -> use.isEmpty() ? null : FeatureVector.getMeanVector(use); + }; + xAxis.setReferenceVec(ref); + } else if (xAxis.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + xAxis.setComponentIndex(xIndexSpinner.getValue()); + } + + AxisParams yAxis = new AxisParams(); + yAxis.setType(yFeatureCombo.getValue()); + if (yAxis.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + yAxis.setComponentIndex(yIndexSpinner.getValue()); + } else if (yAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + yAxis.setMetricName(metricCombo.getValue()); + List ref = switch (referenceMode) { + case "Vector @ Index" -> getVectorAtIndexAsList(yIndexSpinner.getValue()); + case "Custom" -> customReferenceVector; + default -> use.isEmpty() ? null : FeatureVector.getMeanVector(use); + }; + yAxis.setReferenceVec(ref); + } + + int bins = binsSpinner.getValue(); + GridSpec grid = new GridSpec(bins, bins); + GridDensityResult result = GridDensity3DEngine.computePdfCdf2D(use, xAxis, yAxis, grid); + onComputeSurface.accept(result); + } + + // ===== Enablement / bounds ===== + private void updateXControlEnablement() { + boolean isMetric = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; + boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + + if (metricCombo != null) { + metricCombo.setDisable(!isMetric); + } + + // Keep the spinner ALWAYS visible; only disable when it doesn't apply + boolean indexEnabled = (isMetric && "Vector @ Index".equals(referenceMode)) || isComponent; + if (xIndexSpinner != null) { + xIndexSpinner.setDisable(!indexEnabled); + // NOTE: do NOT toggle visible/managed here; we want it shown at all times. + } + } + + private void updateYControlEnablement() { + boolean yNeedsDim = yFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + if (yIndexSpinner != null) yIndexSpinner.setDisable(!yNeedsDim); + } + + private void updateXIndexBoundsAndValue() { + boolean isMetricVectorIdx = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && "Vector @ Index".equals(referenceMode); + boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + + if (xIndexSpinner == null) return; + + if (isMetricVectorIdx) { + int maxIdx = Math.max(0, getMaxVectorIndex()); + setSpinnerBounds(xIndexSpinner, 0, maxIdx, safeSpinnerValue(xIndexSpinner, 0, maxIdx)); + } else if (isComponent) { + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(xIndexSpinner, 0, maxDim, safeSpinnerValue(xIndexSpinner, 0, maxDim)); + } else { + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(xIndexSpinner, 0, Math.max(0, maxDim), 0); + } + } + + private void updateYIndexBoundsAndValue() { + if (yIndexSpinner == null) return; + boolean yIsComponent = yFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + if (yIsComponent) { + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(yIndexSpinner, 0, maxDim, safeSpinnerValue(yIndexSpinner, 0, maxDim)); + } else { + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(yIndexSpinner, 0, Math.max(0, maxDim), 0); + } + } + + // ===== Utilities ===== + + private static void setSpinnerBounds(Spinner spinner, int min, int max, int value) { + spinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(min, Math.max(min, max), value, 1)); + spinner.getValueFactory().setValue(Math.max(min, Math.min(max, value))); + } + + private static int safeSpinnerValue(Spinner spinner, int min, int max) { + Integer v = (spinner.getValue() == null) ? min : spinner.getValue(); + return Math.max(min, Math.min(max, v)); + } + + private int getMaxVectorIndex() { + return Math.max(0, (currentVectors == null ? 0 : currentVectors.size()) - 1); + } + + private int getMaxDimensionIndex() { + if (currentVectors == null || currentVectors.isEmpty()) return 0; + return Math.max(0, currentVectors.get(0).getData().size() - 1); + } + + private List getVectorAtIndexAsList(int idx) { + if (currentVectors == null || currentVectors.isEmpty()) return null; + int safe = Math.max(0, Math.min(idx, currentVectors.size() - 1)); + return currentVectors.get(safe).getData(); + } + + // ===== Label filter logic ===== + + private void rebuildLabelsFromCurrentVectors() { + Set labelSet = new LinkedHashSet<>(); + for (FeatureVector fv : currentVectors) { + String lab = fv.getLabel(); + labelSet.add(lab != null ? lab : UNLABELED); + } + allLabels = new ArrayList<>(labelSet); + + if (selectedLabels.isEmpty() && !allLabels.isEmpty()) { + selectedLabels = new ArrayList<>(allLabels); + } else { + selectedLabels.removeIf(l -> !labelSet.contains(l)); + } + rebuildLabelsMenu(); + } + + private void rebuildLabelsMenu() { + if (labelsMenu == null || labelsBox == null) return; + + labelsBox.getChildren().clear(); + labelChecks.clear(); + + if (allLabels.isEmpty()) { + labelsBox.getChildren().add(new Label("No labels found")); + return; + } + + // Header row with All / None / Apply (doesn't close the menu) + Label lbl = new Label("Visible labels:"); + Button bAll = new Button("All"); + Button bNone = new Button("None"); + Button bApply = new Button("Apply"); + HBox header = new HBox(8, lbl, new Region(), bAll, bNone, bApply); + HBox.setHgrow(header.getChildren().get(1), Priority.ALWAYS); + header.setAlignment(Pos.CENTER_LEFT); + + VBox list = new VBox(4); + for (String lab : allLabels) { + CheckBox cb = new CheckBox(lab); + cb.setSelected(selectedLabels.contains(lab)); + cb.selectedProperty().addListener((obs, ov, nv) -> { + if (nv) { + if (!selectedLabels.contains(lab)) selectedLabels.add(lab); + } else { + selectedLabels.remove(lab); + } + }); + labelChecks.put(lab, cb); + list.getChildren().add(cb); + } + + bAll.setOnAction(e -> { + selectedLabels = new ArrayList<>(allLabels); + for (CheckBox cb : labelChecks.values()) cb.setSelected(true); + }); + bNone.setOnAction(e -> { + selectedLabels.clear(); + for (CheckBox cb : labelChecks.values()) cb.setSelected(false); + }); + bApply.setOnAction(e -> refresh2DCharts()); + + labelsBox.getChildren().addAll(header, list); + } + + + private List getFilteredVectors() { + if (currentVectors == null || currentVectors.isEmpty()) return new ArrayList<>(); + if (allLabels.isEmpty() || selectedLabels.isEmpty() || selectedLabels.size() == allLabels.size()) { + return new ArrayList<>(currentVectors); + } + List out = new ArrayList<>(); + for (FeatureVector fv : currentVectors) { + String lab = fv.getLabel(); + String norm = (lab != null) ? lab : UNLABELED; + if (selectedLabels.contains(norm)) out.add(fv); + } + return out; + } + + // ===== Linking & time-series update ===== +// ===== Linking & time-series update ===== + private void updateTimeSeries() { + if (dataSource == DataSource.SCALARS) { + // handled in applyScalarSamplesToCharts() + refreshTopK(); + return; + } + List use = getFilteredVectors(); + if (use == null || use.isEmpty()) { + tsChart.setSeries(new ArrayList<>()); + selectionInfo.setText("—"); + refreshTopK(); + return; + } + + if (tsMode == TSMode.CONTRIBUTION) { + StatisticEngine.ContributionSeries cs = + StatisticEngine.computeContributions(use, agg, null, contribEps); + tsChart.setAxisLabels("Sample Index", "Δ log-odds"); + tsChart.setSeries(cs.delta); + } else if (tsMode == TSMode.CUMULATIVE) { + StatisticEngine.ContributionSeries cs = + StatisticEngine.computeContributions(use, agg, null, contribEps); + List cum = StatisticEngine.cumulativeFromDeltas(cs.delta); + tsChart.setAxisLabels("Sample Index", "Cumulative log-odds"); + tsChart.setSeries(cum); + } else { // SIMILARITY + StatisticResult stat = (pdfChart != null) ? pdfChart.getLastStatisticResult() : null; + List vals = (stat != null) ? stat.getValues() : new ArrayList<>(); + tsChart.setAxisLabels("Sample Index", "Scalar Value"); + tsChart.setSeries(vals); + } + + // Make sure the Top-K list reflects the current TS mode/series + refreshTopK(); + } + + + private void wireChartInteractions() { + // PDF -> TS + pdfChart.setOnBinHover(sel -> { + if (!persistSelection) tsChart.clearHighlights(); + tsChart.highlightSamples(sel.sampleIdx); + selectionInfo.setText( + String.format("PDF bin %d: [%.4f, %.4f) center≈%.4f • count=%d (%.2f%%)", + sel.bin, sel.xFrom, sel.xTo, sel.xCenter, sel.count, 100.0 * sel.fraction) + ); + }); + pdfChart.setOnBinClick(sel -> { + tsChart.highlightSamples(sel.sampleIdx); + selectionInfo.setText( + String.format("PDF bin %d (clicked): [%.4f, %.4f) center≈%.4f • count=%d (%.2f%%)", + sel.bin, sel.xFrom, sel.xTo, sel.xCenter, sel.count, 100.0 * sel.fraction) + ); + }); + + // CDF -> TS + cdfChart.setOnBinHover(sel -> { + if (!persistSelection) tsChart.clearHighlights(); + tsChart.highlightSamples(sel.sampleIdx); + selectionInfo.setText( + String.format("CDF bin %d: [%.4f, %.4f) center≈%.4f • count=%d (%.2f%%)", + sel.bin, sel.xFrom, sel.xTo, sel.xCenter, sel.count, 100.0 * sel.fraction) + ); + }); + cdfChart.setOnBinClick(sel -> { + tsChart.highlightSamples(sel.sampleIdx); + selectionInfo.setText( + String.format("CDF bin %d (clicked): [%.4f, %.4f) center≈%.4f • count=%d (%.2f%%)", + sel.bin, sel.xFrom, sel.xTo, sel.xCenter, sel.count, 100.0 * sel.fraction) + ); + }); + + // TS -> readout + tsChart.setOnPointHover(p -> { + if (tsMode == TSMode.CONTRIBUTION) { + selectionInfo.setText(String.format("Sample #%d: Δ log-odds = %.6f", p.sampleIdx, p.y)); + } else if (tsMode == TSMode.CUMULATIVE) { + selectionInfo.setText(String.format("Sample #%d: cumulative log-odds = %.6f", p.sampleIdx, p.y)); + } else { + StatisticResult stat = pdfChart.getLastStatisticResult(); + String extra = ""; + if (stat != null && stat.getSampleToBin() != null && p.sampleIdx >= 0 && p.sampleIdx < stat.getSampleToBin().length) { + int b = stat.getSampleToBin()[p.sampleIdx]; + if (b >= 0 && stat.getBinEdges() != null && b + 1 < stat.getBinEdges().length) { + double from = stat.getBinEdges()[b]; + double to = stat.getBinEdges()[b + 1]; + extra = String.format(" • bin=%d [%.4f, %.4f)", b, from, to); + } + } + selectionInfo.setText(String.format("Sample #%d: value=%.6f%s", p.sampleIdx, p.y, extra)); + } + }); + + tsChart.setOnPointClick(p -> { + if (!persistSelection) tsChart.clearHighlights(); + tsChart.highlightSamples(new int[]{p.sampleIdx}); + if (tsMode == TSMode.CONTRIBUTION) { + selectionInfo.setText(String.format("Sample #%d (clicked): Δ log-odds = %.6f", p.sampleIdx, p.y)); + } else if (tsMode == TSMode.CUMULATIVE) { + selectionInfo.setText(String.format("Sample #%d (clicked): cumulative log-odds = %.6f", p.sampleIdx, p.y)); + } else { + selectionInfo.setText(String.format("Sample #%d (clicked): value=%.6f", p.sampleIdx, p.y)); + } + }); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java new file mode 100644 index 00000000..78ad2515 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java @@ -0,0 +1,226 @@ +package edu.jhuapl.trinity.utils.statistics; + +import javafx.application.Platform; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; + +import java.util.ArrayList; +import java.util.BitSet; +import java.util.List; +import java.util.function.Consumer; + +/** + * Simple time-series chart that plots scalar values against sample index (0..N-1) + * and supports highlighting arbitrary sample indices, plus hover/click callbacks + * that report the nearest sample index under the mouse. + *

+ * Now supports "append" highlights for a persist-selection workflow. + */ +public class StatTimeSeriesChart extends LineChart { + + // ===== Public event payload ===== + public static final class PointSelection { + public final int sampleIdx; + public final double x; // index + public final double y; // value + + public PointSelection(int sampleIdx, double x, double y) { + this.sampleIdx = sampleIdx; + this.x = x; + this.y = y; + } + } + + // ===== Series ===== + private final XYChart.Series lineSeries = new XYChart.Series<>(); + private final XYChart.Series highlightSeries = new XYChart.Series<>(); + + // ===== Data ===== + private List currentValues = new ArrayList<>(); + private final BitSet highlighted = new BitSet(); // tracks which indices are currently highlighted + + // ===== Callbacks ===== + private Consumer onHover; + private Consumer onClick; + + public StatTimeSeriesChart() { + super(new NumberAxis(), new NumberAxis()); + setAnimated(false); + setLegendVisible(false); + setTitle("Scalar Time Series"); + + NumberAxis xAxis = (NumberAxis) getXAxis(); + NumberAxis yAxis = (NumberAxis) getYAxis(); + xAxis.setLabel("Sample Index"); + yAxis.setLabel("Scalar Value"); + xAxis.setForceZeroInRange(false); + yAxis.setForceZeroInRange(false); + + // We want a clean line for the main series and point symbols only for highlights. + // createSymbols(true) is chart-wide; we'll hide symbols on the main series manually. + setCreateSymbols(true); + + getData().add(lineSeries); + getData().add(highlightSeries); + + // Make highlight series draw only symbols (no connecting stroke). + Platform.runLater(() -> { + if (highlightSeries.getNode() != null) { + highlightSeries.getNode().setStyle("-fx-stroke: transparent;"); + } + }); + + attachPlotInteractions(); + } + + // ===== Public API ===== + + /** + * Replace the entire series (x = sample index 0..N-1, y = values[i]). + */ + public void setSeries(List values) { + currentValues = (values != null) ? new ArrayList<>(values) : new ArrayList<>(); + lineSeries.getData().clear(); + clearHighlights(); // also resets BitSet + + for (int i = 0; i < currentValues.size(); i++) { + lineSeries.getData().add(new XYChart.Data<>(i, currentValues.get(i))); + } + + // Hide symbols for the main line, but keep them for highlight points. + Platform.runLater(() -> { + hideSymbolsForSeries(lineSeries, true); + if (highlightSeries.getNode() != null) { + highlightSeries.getNode().setStyle("-fx-stroke: transparent;"); + } + styleHighlightDots(); + }); + } + + /** + * Highlight a set of sample indices (draws visible symbols at those indices), + * replacing any existing highlights (backward-compatible behavior). + */ + public void highlightSamples(int[] indices) { + highlightSamples(indices, false); + } + + /** + * Highlight a set of sample indices, optionally appending to any existing highlights. + * + * @param indices indices to (add) highlight + * @param append if false, replaces existing highlights; if true, appends/dedupes + */ + public void highlightSamples(int[] indices, boolean append) { + if (!append) clearHighlights(); + addHighlights(indices); + } + + /** + * Append highlights without removing existing ones. + */ + public void addHighlights(int[] indices) { + if (indices == null || currentValues.isEmpty()) return; + + for (int idx : indices) { + if (idx >= 0 && idx < currentValues.size() && !highlighted.get(idx)) { + highlighted.set(idx); + Double y = currentValues.get(idx); + XYChart.Data d = new XYChart.Data<>(idx, y); + highlightSeries.getData().add(d); + } + } + + Platform.runLater(() -> { + if (highlightSeries.getNode() != null) { + highlightSeries.getNode().setStyle("-fx-stroke: transparent;"); + } + styleHighlightDots(); + }); + } + + /** + * Clear any highlighted samples. + */ + public void clearHighlights() { + highlightSeries.getData().clear(); + highlighted.clear(); + } + + /** + * Optional: customize axis labels. + */ + public void setAxisLabels(String xLabel, String yLabel) { + if (xLabel != null) ((NumberAxis) getXAxis()).setLabel(xLabel); + if (yLabel != null) ((NumberAxis) getYAxis()).setLabel(yLabel); + } + + public void setOnPointHover(Consumer handler) { + this.onHover = handler; + } + + public void setOnPointClick(Consumer handler) { + this.onClick = handler; + } + + // ===== Internals ===== + + private void attachPlotInteractions() { + Platform.runLater(() -> { + Node plotArea = lookup(".chart-plot-background"); + if (plotArea == null) return; + + plotArea.setOnMouseMoved(evt -> { + if (onHover == null || currentValues.isEmpty()) return; + Integer idx = indexFromPlotPixel(plotArea, evt.getX()); + if (idx == null) return; + double y = currentValues.get(idx); + onHover.accept(new PointSelection(idx, idx, y)); + }); + + plotArea.setOnMouseClicked(evt -> { + if (onClick == null || currentValues.isEmpty()) return; + Integer idx = indexFromPlotPixel(plotArea, evt.getX()); + if (idx == null) return; + double y = currentValues.get(idx); + onClick.accept(new PointSelection(idx, idx, y)); + }); + }); + } + + private Integer indexFromPlotPixel(Node plotArea, double xInPlotLocal) { + try { + Point2D scenePt = plotArea.localToScene(xInPlotLocal, 0); + Point2D axisPt = getXAxis().sceneToLocal(scenePt); + double xVal = ((NumberAxis) getXAxis()).getValueForDisplay(axisPt.getX()).doubleValue(); + if (currentValues == null || currentValues.isEmpty()) return null; + int idx = (int) Math.round(xVal); + if (idx < 0) idx = 0; + if (idx >= currentValues.size()) idx = currentValues.size() - 1; + return idx; + } catch (Exception ex) { + return null; + } + } + + private static void hideSymbolsForSeries(XYChart.Series series, boolean hide) { + for (XYChart.Data d : series.getData()) { + Node n = d.getNode(); + if (n != null) n.setVisible(!hide); + } + } + + private void styleHighlightDots() { + for (XYChart.Data d : highlightSeries.getData()) { + if (d.getNode() != null) { + d.getNode().setStyle( + "-fx-background-color: #00FF00AA; " + + "-fx-background-radius: 8px; -fx-padding: 6px;" + ); + } + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java new file mode 100644 index 00000000..90e4ee4a --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -0,0 +1,473 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.AnalysisUtils; +import edu.jhuapl.trinity.utils.metric.Metric; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clip; + +/** + * StatisticEngine for extracting scalar statistics and their distributions + * from FeatureVector collections, plus utilities for stepwise "contribution" + * series derived from time-ordered FeatureVectors. + *

+ * - PDF/CDF: histogram-based density/cumulative distributions for chosen scalars + * - Contribution series: aggregate each FeatureVector's [0,1] similarities to S_t, + * convert to log-odds L_t=log(S_t/(1-S_t)), then Δ_t=L_t-L_{t-1}. + *

+ * NOTE: FeatureVectors are assumed order-aligned with time when computing contributions. + * + * @author Sean Phillips + */ +public class StatisticEngine { + + // ===== Scalar selection for PDF/CDF ===== + public enum ScalarType { + L1_NORM, + LINF_NORM, + MEAN, + MAX, + MIN, + DIST_TO_MEAN, + COSINE_TO_MEAN, + PC1_PROJECTION, + METRIC_DISTANCE_TO_MEAN, + COMPONENT_AT_DIMENSION // marginal of a single component across vectors + } + + // ===== Aggregation for contribution series ===== + public enum SimilarityAggregator { + MEAN, // weighted arithmetic mean on [0,1] + GEOMEAN, // (weighted) geometric mean, penalizes weak dimensions + MIN // strict: weakest dimension dominates + } + + /** + * Holder for contribution computation outputs. + */ + public static final class ContributionSeries { + public final List similarity; // S_t in (0,1) + public final List logit; // L_t + public final List delta; // Δ_t = L_t - L_{t-1}, with Δ_0 = L_0 + + public ContributionSeries(List s, List l, List d) { + this.similarity = s; + this.logit = l; + this.delta = d; + } + } + + /** + * Main entry point to compute selected statistics for a list of FeatureVectors. + * + * @param vectors List of FeatureVector + * @param selectedTypes Set of ScalarTypes to calculate + * @param pdfBins Number of bins for PDF/CDF histograms + * @param metricNameForGeneric (Optional) Metric name for METRIC_DISTANCE_TO_MEAN, e.g. "manhattan" + * @param referenceVectorForGeneric (Optional) Reference vector (mean, centroid, etc.) for METRIC_DISTANCE_TO_MEAN + * @return Map of ScalarType to StatisticResult + */ + public static Map computeStatistics( + List vectors, + Set selectedTypes, + int pdfBins, + String metricNameForGeneric, + List referenceVectorForGeneric + ) { + // Default to no component selection + return computeStatistics(vectors, selectedTypes, pdfBins, metricNameForGeneric, + referenceVectorForGeneric, null); + } + + /** + * Overload supporting COMPONENT_AT_DIMENSION via componentIndex. + * If componentIndex is null or out of range when COMPONENT_AT_DIMENSION is requested, + * the component result will be omitted. + */ + public static Map computeStatistics( + List vectors, + Set selectedTypes, + int pdfBins, + String metricNameForGeneric, + List referenceVectorForGeneric, + Integer componentIndex + ) { + Map results = new HashMap<>(); + if (vectors == null || vectors.isEmpty() || selectedTypes == null || selectedTypes.isEmpty()) { + return results; + } + + List meanVector = null; + if (selectedTypes.contains(ScalarType.DIST_TO_MEAN) || selectedTypes.contains(ScalarType.COSINE_TO_MEAN)) { + meanVector = FeatureVector.getMeanVector(vectors); + } + + // PC1 projections, if needed + if (selectedTypes.contains(ScalarType.PC1_PROJECTION)) { + double[][] dataArr = vectors.stream() + .map(fv -> fv.getData().stream().mapToDouble(Double::doubleValue).toArray()) + .toArray(double[][]::new); + + double[][] pcaProjected = AnalysisUtils.doCommonsPCA(dataArr); + List pc1Projections = new ArrayList<>(); + for (int i = 0; i < pcaProjected.length; i++) { + pc1Projections.add(pcaProjected[i][0]); + } + results.put(ScalarType.PC1_PROJECTION, buildStatResult(pc1Projections, pdfBins)); + } + + // COMPONENT_AT_DIMENSION, if needed + if (selectedTypes.contains(ScalarType.COMPONENT_AT_DIMENSION)) { + if (componentIndex != null && componentIndex >= 0) { + List comps = new ArrayList<>(vectors.size()); + for (FeatureVector fv : vectors) { + List d = fv.getData(); + if (componentIndex < d.size()) { + comps.add(d.get(componentIndex)); + } + } + if (!comps.isEmpty()) { + results.put(ScalarType.COMPONENT_AT_DIMENSION, buildStatResult(comps, pdfBins)); + } + } + } + + // Main per-vector scalar calculations + for (ScalarType type : selectedTypes) { + if (type == ScalarType.PC1_PROJECTION || type == ScalarType.COMPONENT_AT_DIMENSION) continue; + + if (type == ScalarType.METRIC_DISTANCE_TO_MEAN) { + if (metricNameForGeneric != null && referenceVectorForGeneric != null) { + Metric metric = Metric.getMetric(metricNameForGeneric); + double[] refVec = referenceVectorForGeneric.stream().mapToDouble(Double::doubleValue).toArray(); + List metricDists = new ArrayList<>(vectors.size()); + for (FeatureVector fv : vectors) { + double[] fvArr = fv.getData().stream().mapToDouble(Double::doubleValue).toArray(); + double value = metric.distance(fvArr, refVec); + metricDists.add(value); + } + results.put(type, buildStatResult(metricDists, pdfBins)); + } + continue; + } + + List scalars = new ArrayList<>(vectors.size()); + for (FeatureVector fv : vectors) { + double value = switch (type) { + case L1_NORM -> fv.getData().stream().mapToDouble(Math::abs).sum(); + case LINF_NORM -> fv.getData().stream().mapToDouble(Math::abs).max().orElse(0.0); + case MEAN -> fv.getData().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + case MAX -> fv.getMax(); + case MIN -> fv.getMin(); + case DIST_TO_MEAN -> (meanVector != null) + ? AnalysisUtils.l2Norm(diffList(fv.getData(), meanVector)) + : 0.0; + case COSINE_TO_MEAN -> (meanVector != null) + ? AnalysisUtils.cosineSimilarity(fv.getData(), meanVector) + : 0.0; + default -> 0.0; + }; + scalars.add(value); + } + results.put(type, buildStatResult(scalars, pdfBins)); + } + return results; + } + + /** + * Build a cumulative series from a list of delta values. + * Intended for converting per-step Δ log-odds into cumulative log-odds. + *

+ * Rules: + * - Null or NaN entries are treated as 0.0. + * - Empty or null input returns an empty list. + * + * @param deltas List of per-step changes (e.g., Δ log-odds) + * @return cumulative sum series of the same length + */ + public static List cumulativeFromDeltas(List deltas) { + List out = new ArrayList<>(); + if (deltas == null || deltas.isEmpty()) return out; + + double acc = 0.0; + for (Double d : deltas) { + double v = (d == null || d.isNaN()) ? 0.0 : d; + acc += v; + out.add(acc); + } + return out; + } + + // ===== Contribution series (Similarity -> Logit -> Δ) ===== + + /** + * Compute per-step contributions from a sequence of FeatureVectors. + * + * @param vectors sequential FeatureVectors (each element in [0,1]) + * @param agg how to aggregate each vector to a single S_t + * @param weights optional per-dimension weights (will be normalized to sum=1). If null, uniform. + * @param epsilon small value used to clip S_t into (ε, 1-ε) and for GEOMEAN stability (e.g., 1e-6). + */ + public static ContributionSeries computeContributions( + List vectors, + SimilarityAggregator agg, + double[] weights, + double epsilon + ) { + List S = aggregateSimilaritySeries(vectors, agg, weights, epsilon); + List L = logitSeries(S, epsilon); + List D = contributionSeriesFromLogit(L); + return new ContributionSeries(S, L, D); + } + + /** + * Aggregate each FeatureVector to a single similarity S_t in (0,1). + */ + public static List aggregateSimilaritySeries( + List vectors, + SimilarityAggregator agg, + double[] weights, + double epsilon + ) { + List out = new ArrayList<>(); + if (vectors == null || vectors.isEmpty()) return out; + + int dim = Math.max(1, vectors.get(0).getData() != null ? vectors.get(0).getData().size() : 1); + double[] w = normalizedWeights(weights, dim); + + for (FeatureVector fv : vectors) { + List z = fv.getData(); + if (z == null || z.isEmpty()) { + out.add(0.5); + continue; + } + + double s; + switch (agg != null ? agg : SimilarityAggregator.MEAN) { + case GEOMEAN -> { + // weighted geometric mean on [0,1], using exponents w[i] (sum=1) + double prod = 1.0; + int m = Math.min(dim, z.size()); + for (int i = 0; i < m; i++) { + double zi = clamp01(z.get(i)); + prod *= Math.pow(Math.max(zi, epsilon), w[i]); + } + s = prod; + } + case MIN -> { + double mval = 1.0; + int m = Math.min(dim, z.size()); + for (int i = 0; i < m; i++) { + mval = Math.min(mval, clamp01(z.get(i))); + } + s = mval; + } +// case MEAN: //For now the default case is the same as standard mean + default -> { + double sum = 0.0, wsum = 0.0; + int m = Math.min(dim, z.size()); + for (int i = 0; i < m; i++) { + double zi = clamp01(z.get(i)); + sum += w[i] * zi; + wsum += w[i]; + } + s = (wsum > 0 ? sum / wsum : sum); + } + } + out.add(clip(s, epsilon, 1.0 - epsilon)); + } + return out; + } + + /** + * Logit(L) for each S_t with clipping to avoid infinities. + */ + public static List logitSeries(List s, double epsilon) { + List out = new ArrayList<>(); + if (s == null) return out; + for (Double v : s) { + double p = clip(v != null ? v : 0.5, epsilon, 1.0 - epsilon); + out.add(Math.log(p / (1.0 - p))); + } + return out; + } + + /** + * Δ_t = L_t - L_{t-1} with Δ_0 = L_0 (baseline probability 0.5 → log-odds 0). + */ + public static List contributionSeriesFromLogit(List L) { + List out = new ArrayList<>(); + if (L == null || L.isEmpty()) return out; + out.add(L.get(0)); // Δ_0 vs. baseline 0.5 + for (int t = 1; t < L.size(); t++) { + out.add(L.get(t) - L.get(t - 1)); + } + return out; + } + + // ===== Core helpers for PDF/CDF ===== + + public static double[] diffList(List a, List b) { + int m = Math.min(a.size(), b.size()); + double[] out = new double[m]; + for (int i = 0; i < m; i++) out[i] = a.get(i) - b.get(i); + return out; + } + + public static StatisticResult buildStatResult(List scalars, int bins) { + PDFCDFResult pdfcdf = computePDFCDF(scalars, bins); + StatisticResult sr = new StatisticResult(scalars, pdfcdf.bins, pdfcdf.pdf, pdfcdf.cdf); + sr.setBinEdges(pdfcdf.binEdges); + sr.setSampleToBin(pdfcdf.sampleToBin); + sr.setBinCounts(pdfcdf.binCounts); + sr.setBinToSampleIdx(pdfcdf.binToSampleIdx); + sr.setTotalSamples(pdfcdf.totalSamples); + return sr; + } + + /** + * Internal container for histogram outputs. + */ + public static class PDFCDFResult { + double[] bins; // bin centers + double[] pdf; // density values + double[] cdf; // cumulative probability + double[] binEdges; // edges, length = n+1 + int[] sampleToBin; // per-sample bin mapping + int[] binCounts; // counts per bin + int[][] binToSampleIdx; // for each bin, list of sample indices + int totalSamples; // number of valid samples counted + + PDFCDFResult( + double[] bins, double[] pdf, double[] cdf, + double[] binEdges, int[] sampleToBin, + int[] binCounts, int[][] binToSampleIdx, int totalSamples + ) { + this.bins = bins; + this.pdf = pdf; + this.cdf = cdf; + this.binEdges = binEdges; + this.sampleToBin = sampleToBin; + this.binCounts = binCounts; + this.binToSampleIdx = binToSampleIdx; + this.totalSamples = totalSamples; + } + } + + /** + * Compute histogram-based PDF/CDF and sample-to-bin mapping, including counts and + * bin-to-sample index lists for interactive linking. + * Density is normalized so that sum(pdf[i] * binWidth) ≈ 1. + */ + public static PDFCDFResult computePDFCDF(List values, int binsCount) { + int n = Math.max(1, Math.max(5, binsCount)); + if (values == null) values = new ArrayList<>(); + + // min/max over valid values + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + int validCount = 0; + for (Double v : values) { + if (v == null || v.isNaN() || v.isInfinite()) continue; + validCount++; + if (v < min) min = v; + if (v > max) max = v; + } + + // Handle no valid values + if (validCount == 0 || !Double.isFinite(min) || !Double.isFinite(max)) { + double[] zerosN = new double[n]; + double[] zerosN1 = new double[n + 1]; + int[] emptyMap = new int[values.size()]; + int[] zeroCounts = new int[n]; + int[][] emptyIdx = new int[n][0]; + return new PDFCDFResult(zerosN, zerosN, zerosN, zerosN1, emptyMap, zeroCounts, emptyIdx, 0); + } + if (min == max) max = min + 1e-8; + + // build edges & centers + double binWidth = (max - min) / n; + double[] edges = new double[n + 1]; + double[] centers = new double[n]; + for (int i = 0; i <= n; i++) edges[i] = min + i * binWidth; + for (int i = 0; i < n; i++) centers[i] = (edges[i] + edges[i + 1]) * 0.5; + + // counts & mappings + int[] sampleToBin = new int[values.size()]; + int[] binCounts = new int[n]; + List> binLists = new ArrayList<>(n); + for (int i = 0; i < n; i++) binLists.add(new ArrayList<>()); + + int totalSamples = 0; + for (int si = 0; si < values.size(); si++) { + Double vv = values.get(si); + if (vv == null || vv.isNaN() || vv.isInfinite()) { + sampleToBin[si] = -1; + continue; + } + int idx = (int) Math.floor((vv - min) / binWidth); + if (idx < 0) idx = 0; + if (idx >= n) idx = n - 1; // include rightmost edge + binCounts[idx] += 1; + binLists.get(idx).add(si); + sampleToBin[si] = idx; + totalSamples++; + } + + // pdf normalization: count / (N_valid * width) + double[] pdf = new double[n]; + if (totalSamples > 0 && binWidth > 0) { + for (int i = 0; i < n; i++) pdf[i] = binCounts[i] / (totalSamples * binWidth); + } + + // cdf via cumulative sum + double[] cdf = new double[n]; + double acc = 0.0; + for (int i = 0; i < n; i++) { + acc += pdf[i] * binWidth; + cdf[i] = acc; + } + // clamp numerical drift + if (cdf[n - 1] > 1.0) cdf[n - 1] = 1.0; + if (cdf[n - 1] < 0.0) cdf[n - 1] = 0.0; + + // finalize bin-to-sample arrays + int[][] binToSampleIdx = new int[n][]; + for (int i = 0; i < n; i++) { + List lst = binLists.get(i); + binToSampleIdx[i] = new int[lst.size()]; + for (int j = 0; j < lst.size(); j++) binToSampleIdx[i][j] = lst.get(j); + } + + return new PDFCDFResult(centers, pdf, cdf, edges, sampleToBin, binCounts, binToSampleIdx, totalSamples); + } + + // ===== small helpers ===== + + private static double[] normalizedWeights(double[] w, int dim) { + double[] out = new double[dim]; + if (w == null || w.length == 0) { + double u = 1.0 / dim; + for (int i = 0; i < dim; i++) out[i] = u; + return out; + } + double sum = 0.0; + for (int i = 0; i < dim; i++) { + out[i] = (i < w.length ? Math.max(0.0, w[i]) : 0.0); + sum += out[i]; + } + if (sum <= 0) { + double u = 1.0 / dim; + for (int i = 0; i < dim; i++) out[i] = u; + } else { + for (int i = 0; i < dim; i++) out[i] /= sum; + } + return out; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java new file mode 100644 index 00000000..dede1bd1 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java @@ -0,0 +1,162 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.List; + +/** + * Container for scalar-values-derived statistics and histogram metadata. + *

+ * Fields include: + * - values: per-sample scalar value used to build the histogram + * - pdfBins: histogram bin centers (length = N) + * - pdf: density at each bin (normalized so sum(pdf[i] * binWidth) ≈ 1) + * - cdf: cumulative probability at each bin center (in [0,1]) + * - binEdges: histogram bin edges (length = N + 1) + * - sampleToBin: for each sample index, the assigned bin index (or -1) + * - binCounts: count of samples in each bin (length = N) + * - binToSampleIdx: for each bin, the list of sample indices assigned to it + * - totalSamples: number of valid samples considered in the histogram + */ +public class StatisticResult { + + // Per-sample scalar values that were histogrammed (aligned to original order) + private List values; + + // Histogram data + private double[] pdfBins; // bin centers (length = N) + private double[] pdf; // density values at centers (length = N) + private double[] cdf; // cumulative probability at centers (length = N) + + // Additional histogram metadata for interactivity/linking + private double[] binEdges; // bin edges (length = N + 1) + private int[] sampleToBin; // mapping sample index -> bin index (or -1) + private int[] binCounts; // counts per bin (length = N) + private int[][] binToSampleIdx; // for each bin, indices of samples in that bin + private int totalSamples; // number of (valid) samples accumulated + + public StatisticResult(List values, double[] pdfBins, double[] pdf, double[] cdf) { + this.values = values; + this.pdfBins = pdfBins; + this.pdf = pdf; + this.cdf = cdf; + } + + // ----- Getters / Setters ----- + + public List getValues() { + return values; + } + + public void setValues(List values) { + this.values = values; + } + + public double[] getPdfBins() { + return pdfBins; + } + + public void setPdfBins(double[] pdfBins) { + this.pdfBins = pdfBins; + } + + public double[] getPdf() { + return pdf; + } + + public void setPdf(double[] pdf) { + this.pdf = pdf; + } + + public double[] getCdf() { + return cdf; + } + + public void setCdf(double[] cdf) { + this.cdf = cdf; + } + + /** + * @return bin edges array of length N+1 (may be null if not provided) + */ + public double[] getBinEdges() { + return binEdges; + } + + /** + * @param binEdges bin edges array of length N+1 + */ + public void setBinEdges(double[] binEdges) { + this.binEdges = binEdges; + } + + /** + * @return per-sample mapping to bin index (or -1), length equals number of samples; may be null + */ + public int[] getSampleToBin() { + return sampleToBin; + } + + /** + * @param sampleToBin per-sample mapping to bin index (or -1), length equals number of samples + */ + public void setSampleToBin(int[] sampleToBin) { + this.sampleToBin = sampleToBin; + } + + /** + * @return counts per bin (length = number of bins); may be null + */ + public int[] getBinCounts() { + return binCounts; + } + + /** + * @param binCounts counts per bin (length = number of bins) + */ + public void setBinCounts(int[] binCounts) { + this.binCounts = binCounts; + } + + /** + * @return for each bin, the array of sample indices assigned to that bin; may be null + */ + public int[][] getBinToSampleIdx() { + return binToSampleIdx; + } + + /** + * @param binToSampleIdx for each bin, the array of sample indices assigned to that bin + */ + public void setBinToSampleIdx(int[][] binToSampleIdx) { + this.binToSampleIdx = binToSampleIdx; + } + + /** + * @return total number of valid samples used to build the histogram + */ + public int getTotalSamples() { + return totalSamples; + } + + /** + * @param totalSamples total number of valid samples used to build the histogram + */ + public void setTotalSamples(int totalSamples) { + this.totalSamples = totalSamples; + } + + // ----- Convenience ----- + + /** + * @return number of bins (0 if unknown) + */ + public int getBinCount() { + return (pdfBins != null) ? pdfBins.length : 0; + } + + /** + * @return true if binEdges are present and consistent with pdfBins + */ + public boolean hasValidBinEdges() { + return binEdges != null && pdfBins != null && binEdges.length == pdfBins.length + 1; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java new file mode 100644 index 00000000..07b935e0 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java @@ -0,0 +1,448 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.graph.MatrixToGraphAdapter; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; + +/** + * SyntheticMatrixFactory + * ---------------------- + * Utilities for producing small, controllable synthetic matrices and cohorts + * that are visually easy to validate with the 3D graph layouts. + *

+ * Provided generators: + * 1) twoClustersSimilarity / kClustersSimilarity (block-diagonal similarity) + * 2) ringSimilarity (circular 1D manifold) + * 3) threeClustersDivergence (triangular separation) + * 4) gridSimilarity (2D lattice with RBF kernel) + * 5) makeCohorts_GaussianVsUniform (cohorts; useful for divergence workflows) + *

+ * All matrix builders return a SyntheticMatrix bundle containing: + * - double[][] matrix + * - List labels (one per feature) + * - List clusterIds (optional ground-truth: cluster index, -1 if not applicable) + * - MatrixKind (SIMILARITY or DIVERGENCE) + * - title (brief description) + * - OPTIONAL: cohortA / cohortB (if you attach them) + *

+ * Notes: + * - Similarity matrices are normalized to [0,1], symmetric, and have 1.0 on the diagonal. + * - Divergence matrices are normalized to [0,1], symmetric, and have 0.0 on the diagonal. + * - Jitter adds small random noise to off-diagonals to break ties / make layouts less rigid. + */ +public final class SyntheticMatrixFactory { + + private SyntheticMatrixFactory() { + } + + // --------------------------------------------------------------------- + // Result bundle + // --------------------------------------------------------------------- + + public static final class SyntheticMatrix implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public final double[][] matrix; + public final List labels; + public final List clusterIds; // may be null or size N (useful for coloring) + public final MatrixToGraphAdapter.MatrixKind kind; + public final String title; + + /** + * Optional cohorts (may be null). If present, PairwiseMatrixView can fall back to them for JPDF/ΔPDF. + */ + public final List cohortA; + public final List cohortB; + + public SyntheticMatrix(double[][] matrix, + List labels, + List clusterIds, + MatrixToGraphAdapter.MatrixKind kind, + String title) { + this(matrix, labels, clusterIds, kind, title, null, null); + } + + public SyntheticMatrix(double[][] matrix, + List labels, + List clusterIds, + MatrixToGraphAdapter.MatrixKind kind, + String title, + List cohortA, + List cohortB) { + this.matrix = matrix; + this.labels = labels; + this.clusterIds = clusterIds; + this.kind = kind; + this.title = title; + this.cohortA = cohortA; + this.cohortB = cohortB; + } + + /** + * Return a copy of this SyntheticMatrix with cohorts attached (immutably). + */ + public SyntheticMatrix withCohorts(List a, List b) { + return new SyntheticMatrix(matrix, labels, clusterIds, kind, title, + a != null ? new ArrayList<>(a) : null, + b != null ? new ArrayList<>(b) : null); + } + + /** + * Return a copy with a modified title (e.g., to annotate how cohorts were produced). + */ + public SyntheticMatrix withTitle(String extra) { + String t = (extra == null || extra.isBlank()) ? title : (title + " — " + extra); + return new SyntheticMatrix(matrix, labels, clusterIds, kind, t, cohortA, cohortB); + } + } + + // --------------------------------------------------------------------- + // 1) Block-diagonal similarity (2 clusters and K clusters) + // --------------------------------------------------------------------- + + /** + * Two clusters: N = n1 + n2. Within-cluster similarity ≈ withinSim, across ≈ betweenSim. + * + * @param n1 size of cluster 0 + * @param n2 size of cluster 1 + * @param withinSim e.g., 0.85 + * @param betweenSim e.g., 0.10 + * @param jitter small noise added to off-diagonals (e.g., 0.02) + * @param seed RNG seed + */ + public static SyntheticMatrix twoClustersSimilarity(int n1, int n2, + double withinSim, + double betweenSim, + double jitter, + long seed) { + return kClustersSimilarity(new int[]{n1, n2}, withinSim, betweenSim, jitter, seed); + } + + /** + * K clusters with sizes[]; uses a block-diagonal similarity pattern. + */ + public static SyntheticMatrix kClustersSimilarity(int[] sizes, + double withinSim, + double betweenSim, + double jitter, + long seed) { + int n = 0; + for (int s : sizes) n += s; + double[][] S = new double[n][n]; + List clusterIds = new ArrayList<>(n); + List labels = defaultLabels(n); + + // Assign cluster ids per index + int idx = 0; + for (int c = 0; c < sizes.length; c++) { + for (int k = 0; k < sizes[c]; k++) { + clusterIds.add(c); + idx++; + } + } + + Random rng = new Random(seed); + + // Fill similarities + int start = 0; + for (int c = 0; c < sizes.length; c++) { + int end = start + sizes[c] - 1; + + // within cluster block + for (int i = start; i <= end; i++) { + for (int j = start; j <= end; j++) { + if (i == j) S[i][j] = 1.0; + else S[i][j] = clamp01(withinSim + noise(rng, jitter)); + } + } + + // across clusters + int crossStart = end + 1; + for (int i = start; i <= end; i++) { + for (int j = crossStart; j < n; j++) { + double val = clamp01(betweenSim + noise(rng, jitter)); + S[i][j] = val; + S[j][i] = val; + } + } + + start = end + 1; + } + + symmetrize(S, true); // force symmetry + diag=1 + normalize01(S, true); // re-normalize to [0,1], keep diag=1 + String title = "Similarity: " + sizes.length + " clusters (K=" + + sizes.length + ", within≈" + withinSim + ", between≈" + betweenSim + ")"; + return new SyntheticMatrix(S, labels, clusterIds, MatrixToGraphAdapter.MatrixKind.SIMILARITY, title); + } + + // --------------------------------------------------------------------- + // 2) Ring similarity + // --------------------------------------------------------------------- + + /** + * A 1D ring (cycle) whose pairwise similarity decays with circular distance (Gaussian kernel). + * + * @param n number of features on the ring + * @param sigma kernel width in ring steps (e.g., 3.0) + * @param jitter noise on off-diagonal (e.g., 0.01) + * @param seed RNG seed + */ + public static SyntheticMatrix ringSimilarity(int n, double sigma, double jitter, long seed) { + double[][] S = new double[n][n]; + List labels = defaultLabels(n); + Random rng = new Random(seed); + + for (int i = 0; i < n; i++) { + S[i][i] = 1.0; + for (int j = i + 1; j < n; j++) { + int d = ringDistance(i, j, n); + double s = Math.exp(-(d * d) / (sigma * sigma)); + s = clamp01(s + noise(rng, jitter)); + S[i][j] = S[j][i] = s; + } + } + symmetrize(S, true); + normalize01(S, true); + return new SyntheticMatrix(S, labels, null, MatrixToGraphAdapter.MatrixKind.SIMILARITY, + "Similarity: Ring (n=" + n + ", σ=" + sigma + ")"); + } + + // --------------------------------------------------------------------- + // 3) Three clusters divergence + // --------------------------------------------------------------------- + + /** + * Three groups with small within divergence and large across divergence. + * + * @param sizes array of 3 sizes (e.g., {10,10,10}) + * @param withinD e.g., 0.1 + * @param betweenD e.g., 0.9 + * @param jitter noise added to off-diagonals (e.g., 0.02) + * @param seed RNG seed + */ + public static SyntheticMatrix threeClustersDivergence(int[] sizes, + double withinD, + double betweenD, + double jitter, + long seed) { + if (sizes == null || sizes.length != 3) { + throw new IllegalArgumentException("threeClustersDivergence expects sizes length == 3"); + } + int n = sizes[0] + sizes[1] + sizes[2]; + double[][] D = new double[n][n]; + List clusterIds = new ArrayList<>(n); + List labels = defaultLabels(n); + Random rng = new Random(seed); + + // assign ids + int idx = 0; + for (int c = 0; c < 3; c++) { + for (int k = 0; k < sizes[c]; k++) { + clusterIds.add(c); + idx++; + } + } + + // Fill + int[] starts = new int[]{0, sizes[0], sizes[0] + sizes[1]}; + int[] ends = new int[]{sizes[0] - 1, sizes[0] + sizes[1] - 1, n - 1}; + + for (int c = 0; c < 3; c++) { + for (int i = starts[c]; i <= ends[c]; i++) { + D[i][i] = 0.0; + for (int j = i + 1; j <= ends[c]; j++) { + double val = clamp01(withinD + noise(rng, jitter)); + D[i][j] = D[j][i] = val; + } + } + } + // across clusters + for (int c1 = 0; c1 < 3; c1++) { + for (int c2 = c1 + 1; c2 < 3; c2++) { + for (int i = starts[c1]; i <= ends[c1]; i++) { + for (int j = starts[c2]; j <= ends[c2]; j++) { + double val = clamp01(betweenD + noise(rng, jitter)); + D[i][j] = D[j][i] = val; + } + } + } + } + + symmetrize(D, false); // diag=0 + normalize01(D, false); // keep 0 on diag + String title = "Divergence: 3 clusters (within≈" + withinD + ", between≈" + betweenD + ")"; + return new SyntheticMatrix(D, labels, clusterIds, MatrixToGraphAdapter.MatrixKind.DIVERGENCE, title); + } + + // --------------------------------------------------------------------- + // 4) Grid similarity (2D lattice + RBF kernel) + // --------------------------------------------------------------------- + + /** + * Features positioned on a rows×cols lattice; similarity uses an RBF kernel on the 2D grid distance. + * + * @param rows grid rows (e.g., 6) + * @param cols grid cols (e.g., 6) + * @param sigma kernel width in grid steps (e.g., 1.5) + * @param jitter off-diagonal noise + * @param seed RNG seed + */ + public static SyntheticMatrix gridSimilarity(int rows, int cols, double sigma, double jitter, long seed) { + int n = Math.max(1, rows) * Math.max(1, cols); + double[][] S = new double[n][n]; + List labels = defaultLabels(n); + Random rng = new Random(seed); + + // map index -> (r,c) + int[][] coords = new int[n][2]; + int t = 0; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + coords[t][0] = r; + coords[t][1] = c; + t++; + } + } + + for (int i = 0; i < n; i++) { + S[i][i] = 1.0; + for (int j = i + 1; j < n; j++) { + int dr = coords[i][0] - coords[j][0]; + int dc = coords[i][1] - coords[j][1]; + double dist2 = dr * dr + dc * dc; + double s = Math.exp(-(dist2) / (sigma * sigma)); + s = clamp01(s + noise(rng, jitter)); + S[i][j] = S[j][i] = s; + } + } + symmetrize(S, true); + normalize01(S, true); + return new SyntheticMatrix(S, labels, null, MatrixToGraphAdapter.MatrixKind.SIMILARITY, + "Similarity: Grid " + rows + "×" + cols + " (σ=" + sigma + ")"); + } + + // --------------------------------------------------------------------- + // 5) Cohorts for divergence workflows (Gaussian vs Uniform) + // --------------------------------------------------------------------- + + /** + * Build two cohorts of FeatureVectors: + * - A: Gaussian(μ, σ) per component + * - B: Uniform[min,max] per component + * Both with N samples and D dimensions (matching shapes). + */ + public static Cohorts makeCohorts_GaussianVsUniform(int N, int D, + double mu, double sigma, + double uniMin, double uniMax, + long seedA, long seedB) { + Random rngA = new Random(seedA); + Random rngB = new Random(seedB); + + List a = new ArrayList<>(N); + for (int i = 0; i < N; i++) { + ArrayList row = new ArrayList<>(D); + for (int j = 0; j < D; j++) { + row.add(mu + rngA.nextGaussian() * sigma); + } + a.add(new FeatureVector(row)); + } + + double span = uniMax - uniMin; + List b = new ArrayList<>(N); + for (int i = 0; i < N; i++) { + ArrayList row = new ArrayList<>(D); + for (int j = 0; j < D; j++) { + row.add(uniMin + rngB.nextDouble() * span); + } + b.add(new FeatureVector(row)); + } + return new Cohorts(a, b); + } + + public static final class Cohorts { + public final List cohortA; + public final List cohortB; + + public Cohorts(List a, List b) { + this.cohortA = a; + this.cohortB = b; + } + } + + // --------------------------------------------------------------------- + // Utilities + // --------------------------------------------------------------------- + + private static List defaultLabels(int n) { + List L = new ArrayList<>(n); + for (int i = 0; i < n; i++) L.add("F" + i); + return L; + } + + private static int ringDistance(int i, int j, int n) { + int d = Math.abs(i - j); + return Math.min(d, n - d); + // neighbors are ringDistance==1, next-neighbors==2, etc. + } + + private static double noise(Random rng, double amp) { + if (amp <= 0) return 0.0; + // small zero-mean noise in [-amp, +amp] + return (rng.nextDouble() * 2.0 - 1.0) * amp; + } + + /** + * Make symmetric, set diagonal to 1 (similarity) or 0 (divergence). + */ + private static void symmetrize(double[][] M, boolean similarity) { + int n = M.length; + for (int i = 0; i < n; i++) { + M[i][i] = similarity ? 1.0 : 0.0; + for (int j = i + 1; j < n; j++) { + double v = 0.5 * (safe(M[i][j]) + safe(M[j][i])); + M[i][j] = M[j][i] = v; + } + } + } + + /** + * Normalize to [0,1] preserving diagonal convention (1 for similarity, 0 for divergence). + */ + private static void normalize01(double[][] M, boolean similarity) { + int n = M.length; + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + if (i == j) continue; // keep diag convention + double v = safe(M[i][j]); + if (v < min) min = v; + if (v > max) max = v; + } + } + if (!(max > min)) return; // already flat / degenerate + + double span = max - min; + for (int i = 0; i < n; i++) { + M[i][i] = similarity ? 1.0 : 0.0; + for (int j = 0; j < n; j++) { + if (i == j) continue; + double v = (M[i][j] - min) / span; + M[i][j] = clamp01(v); + } + } + } + + private static double safe(double v) { + return (Double.isFinite(v) ? v : 0.0); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/umap/Utils.java b/src/main/java/edu/jhuapl/trinity/utils/umap/Utils.java index 170b8bb2..70711013 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/umap/Utils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/umap/Utils.java @@ -139,94 +139,6 @@ static int[] rejectionSample(final int nSamples, final int poolSize, final Rando return result; } - -// @numba.njit(parallel=true) -// def new_build_candidates( -// current_graph, -// n_vertices, -// n_neighbors, -// max_candidates, -// rng_state, -// rho=0.5, -// ): # pragma: no cover -// """Build a heap of candidate neighbors for nearest neighbor descent. For -// each vertex the candidate neighbors are any current neighbors, and any -// vertices that have the vertex as one of their nearest neighbors. - -// Parameters -// ---------- -// current_graph: heap -// The current state of the graph for nearest neighbor descent. - -// n_vertices: int -// The total number of vertices in the graph. - -// n_neighbors: int -// The number of neighbor edges per node in the current graph. - -// max_candidates: int -// The maximum number of new candidate neighbors. - -// rng_state: array of int64, shape (3,) -// The internal state of the rng - -// Returns -// ------- -// candidate_neighbors: A heap with an array of (randomly sorted) candidate -// neighbors for each vertex in the graph. -// """ -// new_candidate_neighbors = make_heap( -// n_vertices, max_candidates -// ) -// old_candidate_neighbors = make_heap( -// n_vertices, max_candidates -// ) - -// for i in numba.prange(n_vertices): -// for j in range(n_neighbors): -// if current_graph[0, i, j] < 0: -// continue -// idx = current_graph[0, i, j] -// isn = current_graph[2, i, j] -// d = tau_rand(rng_state) -// if tau_rand(rng_state) < rho: -// c = 0 -// if isn: -// c += heap_push( -// new_candidate_neighbors, -// i, -// d, -// idx, -// isn, -// ) -// c += heap_push( -// new_candidate_neighbors, -// idx, -// d, -// i, -// isn, -// ) -// else: -// heap_push( -// old_candidate_neighbors, -// i, -// d, -// idx, -// isn, -// ) -// heap_push( -// old_candidate_neighbors, -// idx, -// d, -// i, -// isn, -// ) - -// if c > 0: -// current_graph[2, i, j] = 0 - -// return new_candidate_neighbors, old_candidate_neighbors - /** * Return a submatrix given an original matrix and the indices to keep. * diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 735f41af..29320693 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -90,6 +90,7 @@ exports edu.jhuapl.trinity.javafx.javafx3d.tasks; exports edu.jhuapl.trinity.javafx.javafx3d; exports edu.jhuapl.trinity.javafx.renderers; + exports edu.jhuapl.trinity.javafx.services; exports edu.jhuapl.trinity.messages; exports edu.jhuapl.trinity.utils.clustering; exports edu.jhuapl.trinity.utils.fun; @@ -97,6 +98,7 @@ exports edu.jhuapl.trinity.utils.metric; exports edu.jhuapl.trinity.utils.umap; exports edu.jhuapl.trinity.utils.volumetric; + exports edu.jhuapl.trinity.utils.statistics; exports edu.jhuapl.trinity.utils; exports edu.jhuapl.trinity; } diff --git a/src/main/resources/edu/jhuapl/trinity/css/styles.css b/src/main/resources/edu/jhuapl/trinity/css/styles.css index 8f52f247..f8415886 100644 --- a/src/main/resources/edu/jhuapl/trinity/css/styles.css +++ b/src/main/resources/edu/jhuapl/trinity/css/styles.css @@ -1407,6 +1407,7 @@ -fx-padding: 0 2.0 0 2.0; } + /******************************************************************************* * * * GridPane * @@ -1611,9 +1612,7 @@ .split-menu-button > .label { -fx-border-color: transparent; -fx-border-width: 2px; - -fx-text-fill: -text_color; - -fx-background-color: -button_background_color; -fx-background-insets: 0; -fx-background-radius: 0; @@ -1622,7 +1621,6 @@ .split-menu-button > .arrow-button { -fx-border-color: transparent; -fx-border-width: 2px; - -fx-background-color: -button_background_color; -fx-background-insets: 0; -fx-background-radius: 0; @@ -1631,7 +1629,6 @@ .split-menu-button > .arrow-button > .arrow { -fx-padding: 0.208em 0.358em 0.208em 0.358em; /* 2.5px 4.29px 2.5px 4.29px */ -fx-shape: "M21.361,12.736l0.527,0.527L16,19.152l-5.889-5.889l0.527-0.527L16,18.098L21.361,12.736z"; - -fx-background-color: -arrow_color; } @@ -1660,16 +1657,94 @@ -fx-border-color: -focus_ring_border_color; -fx-border-width: 1px; -fx-border-insets: -2; - -fx-border-style: segments(1, 2) outside; - -fx-padding: 0px; - -fx-background-color: -button_background_color; -fx-background-insets: 0; -fx-background-radius: 0; } +/*************************************** + * Standalone MenuButton (toolbar, etc.) + * Matches .button look & feel + ***************************************/ +.menu-button { + /* mirror your .button variables/colors */ + -fx-background-color: #333333; /* like -var-background_color */ + -fx-background-insets: 0; + -fx-background-radius: 0; + + -fx-border-color: transparent; + -fx-border-width: 2; + + -fx-padding: 0.25em 1.666666em 0.25em 1.666666em; /* same rhythm as .button */ + + -fx-font-family: "Segoe UI Semilight"; + -fx-font-size: 1.333333em; /* 16 */ + -fx-text-fill: white; +} + +/* hover */ +.menu-button:hover { + -fx-border-color: #858585; /* -var-border_hover_color */ +} + +/* pressed / menu open */ +.menu-button:armed, +.menu-button:showing { + -fx-background-color: #666666; /* -var-background_pressed_color */ + -fx-border-color: #666666; +} + +/* keyboard focus ring (match .button) */ +.menu-button:focused { + -fx-border-color: transparent, white; /* -var-focus_ring_border_color */ + -fx-border-width: 1, 1; + /*noinspection CssInvalidFunction*/ + -fx-border-style: solid, segments(1, 2); + -fx-border-insets: 1 1 1 1, 0; +} + +/* disabled */ +.menu-button:disabled { + -fx-opacity: 0.4; + -fx-background-color: #333333; + -fx-text-fill: white; +} + +/* label text */ +.menu-button > .label { + -fx-text-fill: white; +} + +/* arrow button region follows the same background/border */ +.menu-button > .arrow-button { + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-background-radius: 0; + -fx-border-color: transparent; + -fx-border-width: 0; +} + +/* arrow glyph — ensure visibility on dark bg */ +.menu-button > .arrow-button > .arrow { + -fx-background-color: white; + -fx-padding: 0.2em 0.35em 0.2em 0.35em; /* subtle breathing room */ + /* default triangle is fine; no custom shape required */ +} + +/* hover feedback on the arrow area too */ +.menu-button:hover > .arrow-button { + -fx-border-color: #858585; + -fx-border-width: 0; /* no split look; keep it unified */ +} + +/* pressed/open visual on arrow area */ +.menu-button:armed > .arrow-button, +.menu-button:showing > .arrow-button { + -fx-background-color: #666666; +} + /******************************************************************************* * * * TabPane * diff --git a/src/main/resources/edu/jhuapl/trinity/icons/save.png b/src/main/resources/edu/jhuapl/trinity/icons/save.png index 71a75c52..520b505f 100644 Binary files a/src/main/resources/edu/jhuapl/trinity/icons/save.png and b/src/main/resources/edu/jhuapl/trinity/icons/save.png differ diff --git a/src/main/resources/edu/jhuapl/trinity/icons/snapshot.png b/src/main/resources/edu/jhuapl/trinity/icons/snapshot.png new file mode 100644 index 00000000..037bdbd9 Binary files /dev/null and b/src/main/resources/edu/jhuapl/trinity/icons/snapshot.png differ diff --git a/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java new file mode 100644 index 00000000..b9931bcc --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java @@ -0,0 +1,170 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +public class StatisticsEngineTest { + private static final Logger LOG = LoggerFactory.getLogger(StatisticsEngineTest.class); + + public static void main(String[] args) { + randomGaussianTest(); + bimodalTest(); + } + + public static void randomGaussianTest() { + // === 1. Generate synthetic data === + int numVectors = 1000; + int dim = 10; + Random rand = new Random(42); // fixed seed for reproducibility + + List vectors = new ArrayList<>(); + for (int i = 0; i < numVectors; i++) { + List data = new ArrayList<>(); + for (int j = 0; j < dim; j++) { + data.add(rand.nextGaussian()); // Standard normal distribution + } + FeatureVector fv = new FeatureVector(); + fv.setData(data); + vectors.add(fv); + } + + // === 2. Select statistics to compute === + Set types = Set.of( + StatisticEngine.ScalarType.L1_NORM, + StatisticEngine.ScalarType.MEAN, + StatisticEngine.ScalarType.MAX + ); + + // === 3. Run the statistics engine === + int pdfBins = 40; + Map stats = + StatisticEngine.computeStatistics( + vectors, + types, + pdfBins, + null, // no metric for generic + null // no reference vector + ); + + // === 4. Print results for inspection & theory spot-check === + + // Theory values + double normTheoreticalMean = Math.sqrt(dim); + double meanTheoreticalMean = 0.0; + double meanTheoreticalStd = 1.0 / Math.sqrt(dim); + // Theoretical expected max of dim normals: approx Φ^-1(1-1/dim) ~ inverse CDF + // For dim=10: approx 1.538 (by order stats), but sample max ~ 2.0–2.3 is typical + double approxMaxTheoretical = approxNormalMax(dim); + + + LOG.info("========= Theory vs. Empirical Spot Check ========="); + LOG.info("Dimension: {} Number of vectors: {}", dim, numVectors); + LOG.info("Expected L2 norm: mean ≈ {}", String.format("%.3f", normTheoreticalMean)); + LOG.info("Expected mean of vec: mean = {} std = {}", String.format("%.3f", meanTheoreticalMean), String.format("%.3f", meanTheoreticalStd)); + LOG.info("Expected max per vec: approx ≈ {} (see comments)", String.format("%.3f", approxMaxTheoretical)); + LOG.info(""); + + for (StatisticEngine.ScalarType type : types) { + StatisticResult sr = stats.get(type); + double empiricalMean = sr.getValues().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + double empiricalStd = std(sr.getValues(), empiricalMean); + + LOG.info("---- {} ----", type); + LOG.info("Sample min: {}", String.format("%.4f", Collections.min(sr.getValues()))); + LOG.info("Sample max: {}", String.format("%.4f", Collections.max(sr.getValues()))); + LOG.info("Sample mean: {}", String.format("%.4f", empiricalMean)); + LOG.info("Sample std: {}", String.format("%.4f", empiricalStd)); + + LOG.info("First 5 PDF bins: {}", Arrays.toString(Arrays.copyOf(sr.getPdfBins(), 5))); + LOG.info("First 5 PDF vals: {}", Arrays.toString(Arrays.copyOf(sr.getPdf(), 5))); + LOG.info("First 5 CDF vals: {}", Arrays.toString(Arrays.copyOf(sr.getCdf(), 5))); + LOG.info(""); + } + + // === 5. Spot-check summary === + LOG.info("If these match theory (see above), PDF/CDF logic is likely correct!"); + + } + + public static void bimodalTest() { + int numVectors = 1000; + int dim = 10; + List vectors = new ArrayList<>(); + // First half: all zeros + for (int i = 0; i < numVectors / 2; i++) { + FeatureVector fv = new FeatureVector(); + fv.setData(Collections.nCopies(dim, 0.0)); + vectors.add(fv); + } + // Second half: all ones + for (int i = 0; i < numVectors / 2; i++) { + FeatureVector fv = new FeatureVector(); + fv.setData(Collections.nCopies(dim, 1.0)); + vectors.add(fv); + } + + Set types = Set.of( + StatisticEngine.ScalarType.L1_NORM, + StatisticEngine.ScalarType.MEAN, + StatisticEngine.ScalarType.MAX + ); + int pdfBins = 10; // fewer bins to make PDF more obvious for discrete values + + Map stats = + StatisticEngine.computeStatistics( + vectors, types, pdfBins, null, null + ); + + double normPeak = Math.sqrt(dim); + + LOG.info("========= Bimodal (two-cluster) Test ========="); + LOG.info("Dimension: {} Number of vectors: {}", dim, numVectors); + LOG.info("Expected NORM: two peaks at 0 and {}", normPeak); + LOG.info("Expected MEAN: two peaks at 0 and 1"); + LOG.info("Expected MAX: two peaks at 0 and 1"); + LOG.info(""); + + for (StatisticEngine.ScalarType type : types) { + StatisticResult sr = stats.get(type); + Map counts = sr.getValues().stream() + .collect(Collectors.groupingBy(x -> Math.round(x * 10000.0) / 10000.0, Collectors.counting())); + + LOG.info("---- {} ----", type); + LOG.info("Unique value counts: {}", counts); + LOG.info("Sample min: {}", String.format("%.4f", Collections.min(sr.getValues()))); + LOG.info("Sample max: {}", String.format("%.4f", Collections.max(sr.getValues()))); + LOG.info("Sample mean: {}", String.format("%.4f", sr.getValues().stream().mapToDouble(Double::doubleValue).average().orElse(0.0))); + LOG.info("PDF: {}", Arrays.toString(sr.getPdf())); + LOG.info("CDF: {}", Arrays.toString(sr.getCdf())); + LOG.info(""); + } + + LOG.info("Check that all values are at expected peaks, and that the PDF/CDF reflects a perfect 50/50 split."); + + } + + // Utility: sample stddev + private static double std(List vals, double mean) { + double sum = 0.0; + for (double v : vals) sum += (v - mean) * (v - mean); + return Math.sqrt(sum / vals.size()); + } + + // Approximate the expected maximum of N iid standard normal variables + private static double approxNormalMax(int n) { + // Gumbel approximation: μ + σ * Φ^-1(1 - 1/n), for N(μ, σ^2) + // For N(0,1): use inverse error function + // Approx: sqrt(2*log(n)) + return Math.sqrt(2 * Math.log(n)); + } +}