From e955bcca03c094b3859f92cfc6847b2cce4623e9 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 8 Sep 2025 20:30:27 -0400 Subject: [PATCH 01/50] CyberVector, CyberReport serializers. CyberReportFile drag and drop support. --- .../trinity/data/files/CyberReportFile.java | 117 ++++++++++++++ .../trinity/data/files/CyberReporterFile.java | 63 ++++++++ .../trinity/data/files/DroppableFile.java | 77 +++++++++ .../data/messages/xai/CyberReport.java | 117 ++++++++++++++ .../data/messages/xai/CyberVector.java | 152 ++++++++++++++++++ .../javafx/events/FeatureVectorEvent.java | 2 + .../jhuapl/trinity/utils/ResourceUtils.java | 5 + 7 files changed, 533 insertions(+) create mode 100644 src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java create mode 100644 src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java create mode 100644 src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java create mode 100644 src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java create mode 100644 src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java diff --git a/src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java b/src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java new file mode 100644 index 00000000..98e35c85 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java @@ -0,0 +1,117 @@ +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 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; +import java.nio.file.Files; + +/** + * @author Sean Phillips + */ +public class CyberReportFile extends File implements Transferable { + private static final Logger LOG = LoggerFactory.getLogger(CyberReportFile.class); + public static final DataFlavor DATA_FLAVOR = new DataFlavor(CyberReportFile.class, "CYBERREPORT"); + public CyberReport cyberReport = null; + + /** + * Constructor that extends File super constructor + * + * @param pathname Full path string to the file + */ + public CyberReportFile(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 CyberReportFile(String pathname, Boolean parseExisting) throws IOException { + super(pathname); + if (parseExisting) + parseContent(); + } + + /** + * Tests whether a given File is a LayerableObject file or not + * + * @param file The File to be tested + * @return True if the file being tested has header line matching FILE_DESC + * @throws java.io.IOException + */ + public static boolean isCyberReportFile(File file) throws IOException { + String extension = file.getAbsolutePath().substring( + file.getAbsolutePath().lastIndexOf(".")); + if (extension.equalsIgnoreCase(".json")) { + String body = Files.readString(file.toPath()); + return CyberReport.isCyberReport(body); + } + return false; + } + + /** + * 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(); + } + + private CyberReport 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()); + cyberReport = mapper.readValue(message, CyberReport.class); + return cyberReport; + } + + /** + * 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 void writeContent() throws IOException { + if (null != cyberReport) { + ObjectMapper mapper = new ObjectMapper(); + //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.writeValue(this, cyberReport); + LOG.info("CyberReport serialized to file."); + } + } + + @Override + public DataFlavor[] getTransferDataFlavors() { + return new DataFlavor[]{DATA_FLAVOR}; + } + + @Override + public boolean isDataFlavorSupported(DataFlavor df) { + return df == DATA_FLAVOR; + } + + @Override + public Object getTransferData(DataFlavor df) throws UnsupportedFlavorException, IOException { + if (df == DATA_FLAVOR) { + return this; + } else { + throw new UnsupportedFlavorException(df); + } + } +} 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..1ac761ef --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java @@ -0,0 +1,63 @@ +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 java.awt.datatransfer.DataFlavor; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +/** + * @author Sean Phillips + */ +public class CyberReporterFile extends DroppableFile { + public CyberReport cyberReport = 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(CyberReportFile.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()); + cyberReport = mapper.readValue(message, CyberReport.class); + } + + @Override + public void writeContent() throws IOException { + if (null != cyberReport) { + ObjectMapper mapper = new ObjectMapper(); + //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.writeValue(this, cyberReport); + LOG.info("CyberReport serialized to file."); + } + } +} \ No newline at end of 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..eb064654 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java @@ -0,0 +1,77 @@ +package edu.jhuapl.trinity.data.files; + +import java.awt.datatransfer.DataFlavor; +import java.awt.datatransfer.Transferable; +import java.awt.datatransfer.UnsupportedFlavorException; +import java.io.File; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * + * @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); + } + } +} \ No newline at end of file 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..72d29344 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java @@ -0,0 +1,117 @@ +package edu.jhuapl.trinity.data.messages.xai; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +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 { + + // ----- simple metadata ----- + @JsonProperty("Ground Truth") + private String groundTruth; + + @JsonProperty("Adjacent Network") + private String adjacentNetwork; + + @JsonProperty("Inferences") + private List inferences = new ArrayList<>(); + + @JsonProperty("Mod") + private List mod = new ArrayList<>(); + + // ----- score vectors (known names) ----- + @JsonProperty("S(GT, A)") + private CyberVector sGtA; + + @JsonProperty("S(intel, GT)") + private CyberVector sIntelGt; + + @JsonProperty("S(intel, A)") + private CyberVector sIntelA; + + @JsonProperty("S(inf, GT)") + private CyberVector sInfGt; + + // delta vector + @JsonProperty("delta") + private CyberVector delta; + + // ----- catch-all for any other S(... ) vectors so you can treat them like a list ----- + @JsonIgnore // avoid double-serializing; we expose via @JsonAnyGetter below + private final Map extraVectors = new LinkedHashMap<>(); + + public static boolean isCyberReport(String body) { + return body.contains("Ground Truth") && body.contains("Adjacent Network") + && body.contains("Inferences"); + } + /** + * Any unrecognized property will land here. Jackson will deserialize the value + * into a CyberVector because of the method signature. + * This is handy if future files add more S(?, ?) blocks without changing this class. + * @param name + * @param vector + */ + @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. + * @return + */ + @JsonAnyGetter + public Map getExtraVectors() { + return extraVectors; + } + + // ----- convenience: expose all vectors (known + extra) as a collection if you want "a list of CyberVector" ----- + @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 ----- + 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/CyberVector.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java new file mode 100644 index 00000000..4423b27f --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java @@ -0,0 +1,152 @@ +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 edu.jhuapl.trinity.data.messages.MessageData; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.UUID; + +/** + * @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 mapToStateArray = (state) -> { +// double[] states = new double[state.data.size()]; +// for (int i = 0; i < states.length; i++) { +// states[i] = state.data.get(i); +// } +// return states; +// }; + + // + + 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/javafx/events/FeatureVectorEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/FeatureVectorEvent.java index f42ac97e..2ea7f671 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,8 @@ 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 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/utils/ResourceUtils.java b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java index 4df03670..8ce706d4 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.cyberReport))); } } catch (IOException ex) { LOG.error(null, ex); From 573cb264c3c6e3bf8f5257287256b64bd1454148 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 9 Sep 2025 07:07:23 -0400 Subject: [PATCH 02/50] FeatureVectorEventHandler now supports CyberReport/CyberVector processing for Hyperspace. --- .../edu/jhuapl/trinity/AppAsyncManager.java | 1 + .../data/messages/xai/CyberReport.java | 27 +++-- .../data/messages/xai/CyberVector.java | 46 ++++++-- .../handlers/FeatureVectorEventHandler.java | 105 +++++++++++++----- 4 files changed, 133 insertions(+), 46 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 672dfeb4..d066a093 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -863,6 +863,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..."); 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 index 72d29344..1c6e5682 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java @@ -12,35 +12,44 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class CyberReport { + public final static String GROUNDTRUTH = "Ground Truth"; + public final static String INFERENCES = "Inferences"; + public final static String ADJACENTNETWORK = "Adjacent Network"; + public final static String MOD = "Mod"; + public final static String SGTA = "S(GT, A)"; + public final static String SINTELGT = "S(intel, GT)"; + public final static String SINTELA = "S(intel, A)"; + public final static String SINFGT = "S(inf, GT)"; + public final static String DELTA = "delta"; // ----- simple metadata ----- - @JsonProperty("Ground Truth") + @JsonProperty(GROUNDTRUTH) private String groundTruth; - @JsonProperty("Adjacent Network") + @JsonProperty(ADJACENTNETWORK) private String adjacentNetwork; - @JsonProperty("Inferences") + @JsonProperty(INFERENCES) private List inferences = new ArrayList<>(); - @JsonProperty("Mod") + @JsonProperty(MOD) private List mod = new ArrayList<>(); // ----- score vectors (known names) ----- - @JsonProperty("S(GT, A)") + @JsonProperty(SGTA) private CyberVector sGtA; - @JsonProperty("S(intel, GT)") + @JsonProperty(SINTELGT) private CyberVector sIntelGt; - @JsonProperty("S(intel, A)") + @JsonProperty(SINTELA) private CyberVector sIntelA; - @JsonProperty("S(inf, GT)") + @JsonProperty(SINFGT) private CyberVector sInfGt; // delta vector - @JsonProperty("delta") + @JsonProperty(DELTA) private CyberVector delta; // ----- catch-all for any other S(... ) vectors so you can treat them like a list ----- 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 index 4423b27f..20a68b42 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java @@ -6,7 +6,9 @@ import edu.jhuapl.trinity.data.messages.MessageData; import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.UUID; +import java.util.function.Function; /** * @author Sean Phillips @@ -92,14 +94,42 @@ public CyberVector() { // metaData.put("uuid", entityId); } -// -// public static Function mapToStateArray = (state) -> { -// double[] states = new double[state.data.size()]; -// for (int i = 0; i < states.length; i++) { -// states[i] = state.data.get(i); -// } -// return states; -// }; + 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; + }; // 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..55d760a8 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java @@ -4,6 +4,8 @@ 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 +22,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; /** @@ -48,7 +51,77 @@ public FeatureVectorEventHandler() { public void addFeatureVectorRenderer(FeatureVectorRenderer renderer) { renderers.add(renderer); } + public static FeatureVector cyberToFeatureVector(CyberVector cyberVector) { + FeatureVector fv = new FeatureVector(); + fv.setData(CyberVector.mapToVectorList.apply(cyberVector)); + 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) { + CyberReport cyberReport = (CyberReport) event.object; + 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.getsGtA()); + sgtaFV.setLabel(CyberReport.SGTA); + sgtaFV.getMetaData().putAll(metaData); + addNewFeatureVector(sgtaFV); + + FeatureVector sinfgtFV = cyberToFeatureVector(cyberReport.getsInfGt()); + sinfgtFV.setLabel(CyberReport.SINFGT); + sinfgtFV.getMetaData().putAll(metaData); + addNewFeatureVector(sinfgtFV); + + FeatureVector sintelaFV = cyberToFeatureVector(cyberReport.getsIntelA()); + sintelaFV.setLabel(CyberReport.SINTELA); + sintelaFV.getMetaData().putAll(metaData); + addNewFeatureVector(sintelaFV); + + FeatureVector sintelgtFV = cyberToFeatureVector(cyberReport.getsIntelGt()); + sintelgtFV.setLabel(CyberReport.SINTELGT); + sintelgtFV.getMetaData().putAll(metaData); + addNewFeatureVector(sintelgtFV); + + FeatureVector deltaFV = cyberToFeatureVector(cyberReport.getDelta()); + deltaFV.setLabel(CyberReport.DELTA); + deltaFV.getMetaData().putAll(metaData); + addNewFeatureVector(deltaFV); + + } public void handleFeatureVectorEvent(FeatureVectorEvent event) { FeatureVector featureVector = (FeatureVector) event.object; if (event.getEventType().equals(FeatureVectorEvent.LOCATE_FEATURE_VECTOR)) { @@ -57,35 +130,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); } } @@ -276,6 +321,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); } } } From 80b4be11e0134c45f806d66983991a90d2da5262 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 9 Sep 2025 21:50:34 -0400 Subject: [PATCH 03/50] New PDF and CDF computation engine --- .../data/messages/xai/FeatureVector.java | 3 +- .../jhuapl/trinity/utils/AnalysisUtils.java | 10 ++ .../utils/statistics/StatisticEngine.java | 153 +++++++++++++++++ .../utils/statistics/StatisticResult.java | 49 ++++++ .../edu/jhuapl/trinity/utils/umap/Utils.java | 88 ---------- src/main/java/module-info.java | 1 + .../statistics/StatisticsEngineTest.java | 159 ++++++++++++++++++ 7 files changed, 373 insertions(+), 90 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java create mode 100644 src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java 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..7d4b7e31 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 @@ -224,7 +224,6 @@ public String metadataAsString(String delimiter) { return sb.toString(); } // - /** * @return the entityId */ @@ -378,7 +377,6 @@ public HashMap getMetaData() { public void setMetaData(HashMap metaData) { this.metaData = metaData; } - // /** * @return the text @@ -407,4 +405,5 @@ public String getMediaURL() { public void setMediaURL(String mediaURL) { this.mediaURL = mediaURL; } + // } diff --git a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java index 266773c7..7d15de38 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java @@ -449,5 +449,15 @@ public static double[][] transformUMAP(FeatureCollection featureCollection, Umap Utils.printTotalTime(start); 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/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java new file mode 100644 index 00000000..254691a3 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -0,0 +1,153 @@ +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.*; + +/** + * StatisticEngine for extracting scalar statistics and their distributions + * from FeatureVector collections. + * + * @author Sean + */ +public class StatisticEngine { + + public enum ScalarType { + NORM, MEAN, MAX, MIN, + DIST_TO_MEAN, + COSINE_TO_MEAN, + PC1_PROJECTION, + METRIC_DISTANCE_TO_MEAN // requires metricName argument + } + + /** + * 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 + ) { + Map results = new HashMap<>(); + List meanVector = null; + if (selectedTypes.contains(ScalarType.DIST_TO_MEAN) || selectedTypes.contains(ScalarType.COSINE_TO_MEAN)) { + meanVector = FeatureVector.getMeanVector(vectors); + } + + // PC1 projections, if needed + List pc1Projections = null; + 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); + pc1Projections = new ArrayList<>(); + for (int i = 0; i < pcaProjected.length; i++) { + pc1Projections.add(pcaProjected[i][0]); + } + results.put(ScalarType.PC1_PROJECTION, buildStatResult(pc1Projections, pdfBins)); + } + + // Main per-vector scalar calculations + for (ScalarType type : selectedTypes) { + if (type == ScalarType.PC1_PROJECTION) continue; // already handled + if (type == ScalarType.METRIC_DISTANCE_TO_MEAN) { + // Metric distance (user-selected) + if (metricNameForGeneric != null && referenceVectorForGeneric != null) { + Metric metric = Metric.getMetric(metricNameForGeneric); + double[] refVec = referenceVectorForGeneric.stream().mapToDouble(Double::doubleValue).toArray(); + List metricDists = new ArrayList<>(); + 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<>(); + for (FeatureVector fv : vectors) { + double value = switch (type) { + case NORM -> AnalysisUtils.l2Norm(fv.getData().stream().mapToDouble(Double::doubleValue).toArray()); + 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; + } + + // Utility: vector difference as double[] + public static double[] diffList(List a, List b) { + double[] out = new double[a.size()]; + for (int i = 0; i < a.size(); i++) out[i] = a.get(i) - b.get(i); + return out; + } + + // Utility: generate StatisticResult from scalars + public static StatisticResult buildStatResult(List scalars, int bins) { + PDFCDFResult pdfcdf = computePDFCDF(scalars, bins); + return new StatisticResult(scalars, pdfcdf.bins, pdfcdf.pdf, pdfcdf.cdf); + } + + // PDF/CDF calculation using histogram + public static class PDFCDFResult { + double[] bins; + double[] pdf; + double[] cdf; + PDFCDFResult(double[] bins, double[] pdf, double[] cdf) { + this.bins = bins; this.pdf = pdf; this.cdf = cdf; + } + } + + public static PDFCDFResult computePDFCDF(List values, int binsCount) { + double min = values.stream().min(Double::compare).get(); + double max = values.stream().max(Double::compare).get(); + if (min == max) max += 1e-8; // Avoid divide by zero for constant values + double binWidth = (max - min) / binsCount; + double[] bins = new double[binsCount]; + double[] pdf = new double[binsCount]; + double[] cdf = new double[binsCount]; + + for (int i = 0; i < binsCount; i++) { + bins[i] = min + (i + 0.5) * binWidth; + } + for (double v : values) { + int idx = (int) ((v - min) / binWidth); + if (idx < 0) idx = 0; + if (idx >= binsCount) idx = binsCount - 1; + pdf[idx] += 1; + } + for (int i = 0; i < binsCount; i++) { + pdf[i] /= (values.size() * binWidth); + } + cdf[0] = pdf[0] * binWidth; + for (int i = 1; i < binsCount; i++) { + cdf[i] = cdf[i - 1] + pdf[i] * binWidth; + } + return new PDFCDFResult(bins, pdf, cdf); + } +} 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..66707937 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java @@ -0,0 +1,49 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.List; + +public class StatisticResult { + private List values; // Scalar values for each FeatureVector + private double[] pdfBins; // Bin centers for PDF + private double[] pdf; // PDF values + private double[] cdf; // CDF values + + public StatisticResult(List values, double[] pdfBins, double[] pdf, double[] cdf) { + this.values = values; + this.pdfBins = pdfBins; + this.pdf = pdf; + this.cdf = cdf; + } + + 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; + } +} 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..6c2b2134 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -97,6 +97,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/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..64171772 --- /dev/null +++ b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java @@ -0,0 +1,159 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.AnalysisUtils; +import edu.jhuapl.trinity.utils.metric.Metric; + +import java.util.*; +import java.util.stream.Collectors; + +public class StatisticsEngineTest { + 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.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); + + System.out.println("========= Theory vs. Empirical Spot Check ========="); + System.out.println("Dimension: " + dim + " Number of vectors: " + numVectors); + System.out.printf("Expected L2 norm: mean ≈ %.3f\n", normTheoreticalMean); + System.out.printf("Expected mean of vec: mean = %.3f std = %.3f\n", meanTheoreticalMean, meanTheoreticalStd); + System.out.printf("Expected max per vec: approx ≈ %.3f (see comments)\n", approxMaxTheoretical); + System.out.println(); + + 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); + + System.out.println("---- " + type + " ----"); + System.out.printf("Sample min: %.4f\n", Collections.min(sr.getValues())); + System.out.printf("Sample max: %.4f\n", Collections.max(sr.getValues())); + System.out.printf("Sample mean: %.4f\n", empiricalMean); + System.out.printf("Sample std: %.4f\n", empiricalStd); + + System.out.println("First 5 PDF bins: " + Arrays.toString(Arrays.copyOf(sr.getPdfBins(), 5))); + System.out.println("First 5 PDF vals: " + Arrays.toString(Arrays.copyOf(sr.getPdf(), 5))); + System.out.println("First 5 CDF vals: " + Arrays.toString(Arrays.copyOf(sr.getCdf(), 5))); + System.out.println(); + } + + // === 5. Spot-check summary === + System.out.println("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.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); + + System.out.println("========= Bimodal (two-cluster) Test ========="); + System.out.println("Dimension: " + dim + " Number of vectors: " + numVectors); + System.out.println("Expected NORM: two peaks at 0 and " + normPeak); + System.out.println("Expected MEAN: two peaks at 0 and 1"); + System.out.println("Expected MAX: two peaks at 0 and 1\n"); + + 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())); + + System.out.println("---- " + type + " ----"); + System.out.printf("Unique value counts: %s\n", counts); + System.out.printf("Sample min: %.4f\n", Collections.min(sr.getValues())); + System.out.printf("Sample max: %.4f\n", Collections.max(sr.getValues())); + System.out.printf("Sample mean: %.4f\n", sr.getValues().stream().mapToDouble(Double::doubleValue).average().orElse(0.0)); + System.out.println("PDF: " + Arrays.toString(sr.getPdf())); + System.out.println("CDF: " + Arrays.toString(sr.getCdf())); + System.out.println(); + } + + System.out.println("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)); + } +} \ No newline at end of file From 0491b869f64ca198e2fe5c0726f250651e8fce95 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 10 Sep 2025 09:17:08 -0400 Subject: [PATCH 04/50] first cut nat PdfCdf chart. --- .../utils/statistics/StatPdfCdfChart.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java 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..184622b6 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -0,0 +1,73 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; + +import java.util.List; +import java.util.Set; + +public class StatPdfCdfChart extends LineChart { + + private StatisticEngine.ScalarType scalarType; + private int pdfBins; + + public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int pdfBins) { + super(new NumberAxis(), new NumberAxis()); + this.scalarType = scalarType; + this.pdfBins = pdfBins; + + setTitle(scalarType + " (PDF and CDF)"); + getXAxis().setLabel("Scalar Value"); + getYAxis().setLabel("Density / Probability"); + setLegendVisible(true); + } + + /** + * Set the input vectors, recompute, and update the plot. + */ + public void setFeatureVectors(List vectors) { + updateChart(vectors); + } + + private void updateChart(List vectors) { + getData().clear(); + + Set types = Set.of(scalarType); + StatisticResult stat = StatisticEngine.computeStatistics( + vectors, types, pdfBins, null, null + ).get(scalarType); + + // PDF line + XYChart.Series pdfSeries = new XYChart.Series<>(); + pdfSeries.setName("PDF"); + double[] bins = stat.getPdfBins(); + double[] pdf = stat.getPdf(); + for (int i = 0; i < bins.length; i++) { + pdfSeries.getData().add(new XYChart.Data<>(bins[i], pdf[i])); + } + + // CDF line + XYChart.Series cdfSeries = new XYChart.Series<>(); + cdfSeries.setName("CDF"); + double[] cdf = stat.getCdf(); + for (int i = 0; i < bins.length; i++) { + cdfSeries.getData().add(new XYChart.Data<>(bins[i], cdf[i])); + } + + getData().addAll(pdfSeries, cdfSeries); + + // Optional: distinct styling for CDF line + cdfSeries.getNode().setStyle("-fx-stroke: #ff7f0e; -fx-stroke-dash-array: 8 4;"); // orange dashed + pdfSeries.getNode().setStyle("-fx-stroke: #1f77b4;"); // blue solid + } + + /** + * Optional: Support adding vectors (appending) instead of replacing. + */ + public void addFeatureVectors(List vectors) { + // Combine current and new vectors, then call setFeatureVectors(...) + // (implement as needed) + } +} From 2424e9fdf302f173f81a00f73995f70db86a4fe2 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 10 Sep 2025 14:20:55 -0400 Subject: [PATCH 05/50] End to end first cut for sending feature vectors to PDF/CDF chart --- src/main/java/edu/jhuapl/trinity/App.java | 5 + .../edu/jhuapl/trinity/AppAsyncManager.java | 18 +++ .../components/panes/StatPdfCdfPane.java | 94 +++++++++++++++ .../components/radial/HyperspaceMenu.java | 9 ++ .../javafx/events/ApplicationEvent.java | 1 + .../statistics/StatPdfCdfChartPanel.java | 109 ++++++++++++++++++ .../utils/statistics/StatisticEngine.java | 7 +- 7 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index ad5b8559..1c98da47 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -336,6 +336,11 @@ 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(ApplicationEvent.SHOW_STATISTICS_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 d066a093..8a9071b8 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -92,6 +92,7 @@ import java.util.Map; import static edu.jhuapl.trinity.App.theConfig; +import edu.jhuapl.trinity.javafx.components.panes.StatPdfCdfPane; /** @@ -118,6 +119,7 @@ public class AppAsyncManager extends Task { JukeBoxPane jukeBoxPane; VideoPane videoPane; SpecialEffectsPane specialEffectsPane; + StatPdfCdfPane statPdfCdfPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; WaveformPane waveformPane; @@ -535,6 +537,22 @@ protected Void call() throws Exception { }); } }); + 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.AUTO_PROJECTION_MODE, e -> { boolean enabled = (boolean) e.object; 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..7ee983a9 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java @@ -0,0 +1,94 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +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.control.Label; +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. + * + * @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 + 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); + borderPane.setCenter(chartPanel); + } + + 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/radial/HyperspaceMenu.java b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java index f2249590..ceaa10f7 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/events/ApplicationEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java index 76a8408f..e38f17ea 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,7 @@ 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 ApplicationEvent(EventType eventType, Object object) { this(eventType); 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..f2f285b5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -0,0 +1,109 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.geometry.Insets; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; + +import java.util.ArrayList; +import java.util.List; +import javafx.geometry.Pos; +import javafx.scene.layout.VBox; + +/** + * Chart panel for visualizing PDF and CDF statistics for Trinity analytics. + * Can be instantiated empty or with initial data. + * + * @author Sean Phillips + */ +public class StatPdfCdfChartPanel extends BorderPane { + private StatPdfCdfChart chart; + private ComboBox scalarTypeCombo; + private Spinner binsSpinner; + private List currentVectors; + private StatisticEngine.ScalarType currentScalarType; + private int currentBins; + + private Runnable onPopOut = null; + + /** + * Create an empty chart panel (no data, uses default scalar and bins). + */ + public StatPdfCdfChartPanel() { + this(null, StatisticEngine.ScalarType.NORM, 40); + } + + /** + * Create a chart panel with initial data and options. + */ + public StatPdfCdfChartPanel(List initialVectors, StatisticEngine.ScalarType initialType, int initialBins) { + this.currentVectors = (initialVectors != null) ? initialVectors : new ArrayList<>(); + this.currentScalarType = (initialType != null) ? initialType : StatisticEngine.ScalarType.NORM; + this.currentBins = (initialBins > 0) ? initialBins : 40; + + scalarTypeCombo = new ComboBox<>(); + scalarTypeCombo.getItems().addAll(StatisticEngine.ScalarType.values()); + scalarTypeCombo.setValue(currentScalarType); + + binsSpinner = new Spinner<>(5, 100, currentBins, 5); + binsSpinner.setPrefWidth(100); + binsSpinner.setPrefHeight(40); + + Button refreshButton = new Button("Refresh"); + Button popOutButton = new Button("Pop Out"); + + HBox controlBar = new HBox(20, + new VBox(5, new Label("Feature:"), scalarTypeCombo), + new VBox(5, new Label("Bins:"), binsSpinner), + refreshButton, popOutButton); + controlBar.setPadding(new Insets(5)); + controlBar.setAlignment(Pos.CENTER); + + // Chart + chart = new StatPdfCdfChart(currentScalarType, currentBins); + if (!currentVectors.isEmpty()) { + chart.setFeatureVectors(currentVectors); + } + + setTop(controlBar); + setCenter(chart); + + refreshButton.setOnAction(e -> refreshChart()); + popOutButton.setOnAction(e -> { if (onPopOut != null) onPopOut.run(); }); + } + + private void refreshChart() { + currentScalarType = scalarTypeCombo.getValue(); + currentBins = binsSpinner.getValue(); + chart = new StatPdfCdfChart(currentScalarType, currentBins); + if (currentVectors != null && !currentVectors.isEmpty()) { + chart.setFeatureVectors(currentVectors); + } + setCenter(chart); + } + + public void setOnPopOut(Runnable handler) { + this.onPopOut = handler; + } + + public void setFeatureVectors(List vectors) { + this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); + chart.setFeatureVectors(currentVectors); + } + + public int getBins() { + return binsSpinner.getValue(); + } + + public StatisticEngine.ScalarType getScalarType() { + return scalarTypeCombo.getValue(); + } + + public StatPdfCdfChart getChart() { + return chart; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java index 254691a3..239208dd 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -3,8 +3,11 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.AnalysisUtils; import edu.jhuapl.trinity.utils.metric.Metric; - -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; /** * StatisticEngine for extracting scalar statistics and their distributions From 06a3d8c9e9cc367a0049e3fb32d29e9077069a97 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Thu, 11 Sep 2025 07:20:52 -0400 Subject: [PATCH 06/50] StatisticEngine and chart/panel classes fixed to support L1Norm, LinfNorm and Trinity Vector Metrics system. --- .../utils/statistics/StatPdfCdfChart.java | 150 +++++++++++++----- .../statistics/StatPdfCdfChartPanel.java | 141 ++++++++++++---- .../utils/statistics/StatisticEngine.java | 27 ++-- 3 files changed, 237 insertions(+), 81 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index 184622b6..76085c3f 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -5,69 +5,133 @@ 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; +/** + * LineChart that renders EITHER PDF-only or CDF-only for a chosen scalar type. + * Use two instances if you want separate axes (one for PDF, one for CDF). + * + * @author Sean Phillips + */ public class StatPdfCdfChart extends LineChart { + public enum Mode { PDF_ONLY, CDF_ONLY } + private StatisticEngine.ScalarType scalarType; - private int pdfBins; + private int bins; + private Mode mode; + private List vectors; + + //optional metric & reference vector for METRIC_DISTANCE_TO_MEAN + private String metricNameForGeneric; // e.g., "euclidean" + private List referenceVectorForGeneric; // e.g., mean vector - public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int pdfBins) { + public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mode) { super(new NumberAxis(), new NumberAxis()); - this.scalarType = scalarType; - this.pdfBins = pdfBins; + 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(false); + setTitle((this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType); + } + + public void setScalarType(StatisticEngine.ScalarType scalarType) { + this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; + setTitle((this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType); + refresh(); + } + + public void setBins(int bins) { + this.bins = Math.max(5, bins); + refresh(); + } - setTitle(scalarType + " (PDF and CDF)"); - getXAxis().setLabel("Scalar Value"); - getYAxis().setLabel("Density / Probability"); - setLegendVisible(true); + 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((this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType); + refresh(); } - /** - * Set the input vectors, recompute, and update the plot. - */ public void setFeatureVectors(List vectors) { - updateChart(vectors); + this.vectors = (vectors != null) ? new ArrayList<>(vectors) : new ArrayList<>(); + refresh(); + } + + /** Append more vectors and redraw. */ + 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(); } - private void updateChart(List vectors) { + // NEW: metric options setters + public void setMetricNameForGeneric(String metricNameForGeneric) { + this.metricNameForGeneric = metricNameForGeneric; + refresh(); + } + + public void setReferenceVectorForGeneric(List referenceVectorForGeneric) { + this.referenceVectorForGeneric = referenceVectorForGeneric; + refresh(); + } + + private void refresh() { getData().clear(); + if (vectors == null || vectors.isEmpty()) return; Set types = Set.of(scalarType); - StatisticResult stat = StatisticEngine.computeStatistics( - vectors, types, pdfBins, null, null - ).get(scalarType); - - // PDF line - XYChart.Series pdfSeries = new XYChart.Series<>(); - pdfSeries.setName("PDF"); - double[] bins = stat.getPdfBins(); - double[] pdf = stat.getPdf(); - for (int i = 0; i < bins.length; i++) { - pdfSeries.getData().add(new XYChart.Data<>(bins[i], pdf[i])); - } - // CDF line - XYChart.Series cdfSeries = new XYChart.Series<>(); - cdfSeries.setName("CDF"); - double[] cdf = stat.getCdf(); - for (int i = 0; i < bins.length; i++) { - cdfSeries.getData().add(new XYChart.Data<>(bins[i], cdf[i])); - } + // Pass through metric options when applicable + String metricName = (scalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) + ? metricNameForGeneric + : null; + List refVec = (scalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) + ? referenceVectorForGeneric + : null; - getData().addAll(pdfSeries, cdfSeries); + Map resultMap = + StatisticEngine.computeStatistics(vectors, types, bins, metricName, refVec); - // Optional: distinct styling for CDF line - cdfSeries.getNode().setStyle("-fx-stroke: #ff7f0e; -fx-stroke-dash-array: 8 4;"); // orange dashed - pdfSeries.getNode().setStyle("-fx-stroke: #1f77b4;"); // blue solid - } + StatisticResult stat = resultMap.get(scalarType); + if (stat == null) return; + + double[] x = stat.getPdfBins(); + XYChart.Series series = new XYChart.Series<>(); - /** - * Optional: Support adding vectors (appending) instead of replacing. - */ - public void addFeatureVectors(List vectors) { - // Combine current and new vectors, then call setFeatureVectors(...) - // (implement as needed) + if (mode == Mode.PDF_ONLY) { + double[] y = stat.getPdf(); + for (int i = 0; i < x.length; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); + } else { + double[] y = stat.getCdf(); + for (int i = 0; i < x.length; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); + } + getData().add(series); } + + public StatisticEngine.ScalarType getScalarType() { return scalarType; } + public int getBins() { return bins; } + public Mode getMode() { return mode; } + + // Expose for panel if needed + public String getMetricNameForGeneric() { return metricNameForGeneric; } + public List getReferenceVectorForGeneric() { return referenceVectorForGeneric; } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index f2f285b5..d0be9fbc 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -1,50 +1,56 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.utils.metric.Metric; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.Spinner; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; import java.util.ArrayList; import java.util.List; -import javafx.geometry.Pos; -import javafx.scene.layout.VBox; /** - * Chart panel for visualizing PDF and CDF statistics for Trinity analytics. + * Chart panel with GUI controls on top and TWO independent charts stacked vertically: + * - Top chart renders PDF-only + * - Bottom chart renders CDF-only + * Controls (Feature type and Bins) update both charts in sync. + * + * Supports METRIC_DISTANCE_TO_MEAN by letting user choose a metric and using the mean vector as reference. + * * Can be instantiated empty or with initial data. * * @author Sean Phillips */ public class StatPdfCdfChartPanel extends BorderPane { - private StatPdfCdfChart chart; + private StatPdfCdfChart pdfChart; + private StatPdfCdfChart cdfChart; + private ComboBox scalarTypeCombo; private Spinner binsSpinner; + private ComboBox metricCombo; // NEW + private List currentVectors; private StatisticEngine.ScalarType currentScalarType; private int currentBins; private Runnable onPopOut = null; - /** - * Create an empty chart panel (no data, uses default scalar and bins). - */ public StatPdfCdfChartPanel() { - this(null, StatisticEngine.ScalarType.NORM, 40); + this(null, StatisticEngine.ScalarType.L1_NORM, 40); } - /** - * Create a chart panel with initial data and options. - */ public StatPdfCdfChartPanel(List initialVectors, StatisticEngine.ScalarType initialType, int initialBins) { this.currentVectors = (initialVectors != null) ? initialVectors : new ArrayList<>(); - this.currentScalarType = (initialType != null) ? initialType : StatisticEngine.ScalarType.NORM; + this.currentScalarType = (initialType != null) ? initialType : StatisticEngine.ScalarType.L1_NORM; this.currentBins = (initialBins > 0) ? initialBins : 40; + // Controls scalarTypeCombo = new ComboBox<>(); scalarTypeCombo.getItems().addAll(StatisticEngine.ScalarType.values()); scalarTypeCombo.setValue(currentScalarType); @@ -52,38 +58,99 @@ public StatPdfCdfChartPanel(List initialVectors, StatisticEngine. binsSpinner = new Spinner<>(5, 100, currentBins, 5); binsSpinner.setPrefWidth(100); binsSpinner.setPrefHeight(40); - + + // NEW: Metric selector (disabled unless METRIC_DISTANCE_TO_MEAN) + metricCombo = new ComboBox<>(); + metricCombo.getItems().addAll(Metric.getMetricNames()); + // try to default to euclidean if present + if (metricCombo.getItems().contains("euclidean")) { + metricCombo.setValue("euclidean"); + } else if (!metricCombo.getItems().isEmpty()) { + metricCombo.setValue(metricCombo.getItems().get(0)); + } + metricCombo.setDisable(currentScalarType != StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN); + Button refreshButton = new Button("Refresh"); Button popOutButton = new Button("Pop Out"); - HBox controlBar = new HBox(20, + HBox controlBar = new HBox( + 20, new VBox(5, new Label("Feature:"), scalarTypeCombo), - new VBox(5, new Label("Bins:"), binsSpinner), - refreshButton, popOutButton); + new VBox(5, new Label("Bins:"), binsSpinner), + new VBox(5, new Label("Metric:"), metricCombo), + refreshButton, + popOutButton + ); controlBar.setPadding(new Insets(5)); controlBar.setAlignment(Pos.CENTER); - // Chart - chart = new StatPdfCdfChart(currentScalarType, currentBins); + // Charts: PDF (top), CDF (bottom) + pdfChart = new StatPdfCdfChart(currentScalarType, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); + cdfChart = new StatPdfCdfChart(currentScalarType, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); + if (!currentVectors.isEmpty()) { - chart.setFeatureVectors(currentVectors); + // If metric feature selected, set metric & reference (mean) + applyMetricOptionsIfNeeded(); + pdfChart.setFeatureVectors(currentVectors); + cdfChart.setFeatureVectors(currentVectors); } + VBox chartsBox = new VBox(8, pdfChart, cdfChart); + chartsBox.setPadding(new Insets(6)); + VBox.setVgrow(pdfChart, javafx.scene.layout.Priority.ALWAYS); + VBox.setVgrow(cdfChart, javafx.scene.layout.Priority.ALWAYS); + setTop(controlBar); - setCenter(chart); + setCenter(chartsBox); - refreshButton.setOnAction(e -> refreshChart()); + // Actions + refreshButton.setOnAction(e -> refreshCharts()); popOutButton.setOnAction(e -> { if (onPopOut != null) onPopOut.run(); }); + + // Keep metric control enabled state in sync with feature selection + scalarTypeCombo.valueProperty().addListener((obs, oldVal, newVal) -> { + boolean isMetric = newVal == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; + metricCombo.setDisable(!isMetric); + }); } - private void refreshChart() { + private void refreshCharts() { currentScalarType = scalarTypeCombo.getValue(); currentBins = binsSpinner.getValue(); - chart = new StatPdfCdfChart(currentScalarType, currentBins); + + pdfChart.setScalarType(currentScalarType); + pdfChart.setBins(currentBins); + + cdfChart.setScalarType(currentScalarType); + cdfChart.setBins(currentBins); + + // NEW: set metric + reference vector if needed + applyMetricOptionsIfNeeded(); + if (currentVectors != null && !currentVectors.isEmpty()) { - chart.setFeatureVectors(currentVectors); + pdfChart.setFeatureVectors(currentVectors); + cdfChart.setFeatureVectors(currentVectors); + } + } + + private void applyMetricOptionsIfNeeded() { + if (currentScalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + // Reference = mean vector of current data + List meanVector = FeatureVector.getMeanVector(currentVectors); + String metricName = metricCombo.getValue(); + + pdfChart.setMetricNameForGeneric(metricName); + pdfChart.setReferenceVectorForGeneric(meanVector); + + cdfChart.setMetricNameForGeneric(metricName); + cdfChart.setReferenceVectorForGeneric(meanVector); + } else { + // Clear any prior settings so other features don't accidentally use them + pdfChart.setMetricNameForGeneric(null); + pdfChart.setReferenceVectorForGeneric(null); + cdfChart.setMetricNameForGeneric(null); + cdfChart.setReferenceVectorForGeneric(null); } - setCenter(chart); } public void setOnPopOut(Runnable handler) { @@ -92,7 +159,21 @@ public void setOnPopOut(Runnable handler) { public void setFeatureVectors(List vectors) { this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); - chart.setFeatureVectors(currentVectors); + // NEW: keep metric options correct when data change + applyMetricOptionsIfNeeded(); + pdfChart.setFeatureVectors(this.currentVectors); + cdfChart.setFeatureVectors(this.currentVectors); + } + + 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); + + // Keep metric options correct when appending data + applyMetricOptionsIfNeeded(); + pdfChart.addFeatureVectors(newVectors); + cdfChart.addFeatureVectors(newVectors); } public int getBins() { @@ -103,7 +184,11 @@ public StatisticEngine.ScalarType getScalarType() { return scalarTypeCombo.getValue(); } - public StatPdfCdfChart getChart() { - return chart; + public StatPdfCdfChart getPdfChart() { + return pdfChart; + } + + public StatPdfCdfChart getCdfChart() { + return cdfChart; } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java index 239208dd..83918084 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -13,18 +13,21 @@ * StatisticEngine for extracting scalar statistics and their distributions * from FeatureVector collections. * - * @author Sean + * @author Sean Phillips */ public class StatisticEngine { - public enum ScalarType { - NORM, MEAN, MAX, MIN, - DIST_TO_MEAN, - COSINE_TO_MEAN, - PC1_PROJECTION, - METRIC_DISTANCE_TO_MEAN // requires metricName argument - } - +public enum ScalarType { + L1_NORM, + LINF_NORM, + MEAN, + MAX, + MIN, + DIST_TO_MEAN, + COSINE_TO_MEAN, + PC1_PROJECTION, + METRIC_DISTANCE_TO_MEAN +} /** * Main entry point to compute selected statistics for a list of FeatureVectors. * @param vectors List of FeatureVector @@ -84,7 +87,11 @@ public static Map computeStatistics( List scalars = new ArrayList<>(); for (FeatureVector fv : vectors) { double value = switch (type) { - case NORM -> AnalysisUtils.l2Norm(fv.getData().stream().mapToDouble(Double::doubleValue).toArray()); + 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(); From d6e98d4e3ec754ccaf6663e44245d38285fab8fc Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Thu, 11 Sep 2025 08:17:59 -0400 Subject: [PATCH 07/50] Dimension based computations for PDF and CDF --- .../utils/statistics/StatPdfCdfChart.java | 46 +-- .../statistics/StatPdfCdfChartPanel.java | 307 ++++++++++++++---- .../utils/statistics/StatisticEngine.java | 130 +++++--- 3 files changed, 346 insertions(+), 137 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index 76085c3f..bee21d9c 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -14,6 +14,10 @@ * LineChart that renders EITHER PDF-only or CDF-only for a chosen scalar type. * Use two instances if you want separate axes (one for PDF, one for CDF). * + * Supports: + * - METRIC_DISTANCE_TO_MEAN via metric name and reference vector + * - COMPONENT_AT_DIMENSION via componentIndex + * * @author Sean Phillips */ public class StatPdfCdfChart extends LineChart { @@ -25,9 +29,12 @@ public enum Mode { PDF_ONLY, CDF_ONLY } private Mode mode; private List vectors; - //optional metric & reference vector for METRIC_DISTANCE_TO_MEAN - private String metricNameForGeneric; // e.g., "euclidean" - private List referenceVectorForGeneric; // e.g., mean vector + // Metric mode + private String metricNameForGeneric; + private List referenceVectorForGeneric; + + // Component marginal mode + private Integer componentIndex; public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mode) { super(new NumberAxis(), new NumberAxis()); @@ -72,18 +79,14 @@ public void setFeatureVectors(List vectors) { refresh(); } - /** Append more vectors and redraw. */ 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); - } + if (this.vectors == null) this.vectors = new ArrayList<>(newVectors); + else this.vectors.addAll(newVectors); refresh(); } - // NEW: metric options setters + // Metric mode public void setMetricNameForGeneric(String metricNameForGeneric) { this.metricNameForGeneric = metricNameForGeneric; refresh(); @@ -94,29 +97,31 @@ public void setReferenceVectorForGeneric(List referenceVectorForGeneric) refresh(); } + // Component marginal mode + public void setComponentIndex(Integer componentIndex) { + this.componentIndex = componentIndex; + refresh(); + } + private void refresh() { getData().clear(); if (vectors == null || vectors.isEmpty()) return; Set types = Set.of(scalarType); - // Pass through metric options when applicable - String metricName = (scalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) - ? metricNameForGeneric - : null; - List refVec = (scalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) - ? referenceVectorForGeneric - : null; + 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); + StatisticEngine.computeStatistics(vectors, types, bins, metricName, refVec, compIdx); StatisticResult stat = resultMap.get(scalarType); if (stat == null) return; double[] x = stat.getPdfBins(); XYChart.Series series = new XYChart.Series<>(); - if (mode == Mode.PDF_ONLY) { double[] y = stat.getPdf(); for (int i = 0; i < x.length; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); @@ -130,8 +135,7 @@ private void refresh() { public StatisticEngine.ScalarType getScalarType() { return scalarType; } public int getBins() { return bins; } public Mode getMode() { return mode; } - - // Expose for panel if needed public String getMetricNameForGeneric() { return metricNameForGeneric; } public List getReferenceVectorForGeneric() { return referenceVectorForGeneric; } + public Integer getComponentIndex() { return componentIndex; } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index d0be9fbc..22fecaa7 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -6,10 +6,14 @@ import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; +import javafx.scene.control.Dialog; import javafx.scene.control.Label; import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TextArea; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import java.util.ArrayList; @@ -17,11 +21,16 @@ /** * Chart panel with GUI controls on top and TWO independent charts stacked vertically: - * - Top chart renders PDF-only - * - Bottom chart renders CDF-only - * Controls (Feature type and Bins) update both charts in sync. + * - Top chart renders PDF-only + * - Bottom chart renders CDF-only * - * Supports METRIC_DISTANCE_TO_MEAN by letting user choose a metric and using the mean vector as reference. + * Controls split across two rows: + * Row 1: Feature, Bins, Refresh, Pop Out + * Row 2: Metric, Reference, Index (shared), Set Custom Vector... + * + * Features: + * - METRIC_DISTANCE_TO_MEAN: choose metric; reference = Mean / Vector@Index / Custom + * - COMPONENT_AT_DIMENSION: choose a component index for the marginal using the same Index spinner * * Can be instantiated empty or with initial data. * @@ -33,7 +42,19 @@ public class StatPdfCdfChartPanel extends BorderPane { private ComboBox scalarTypeCombo; private Spinner binsSpinner; - private ComboBox metricCombo; // NEW + + // Metric/reference controls (row 2) + private ComboBox metricCombo; + private ComboBox referenceCombo; // "Mean", "Vector @ Index", "Custom" + + // Shared index spinner for both: + // - Vector @ Index (reference selection) + // - COMPONENT_AT_DIMENSION (component index) + private Spinner indexSpinner; + private Label indexLabel; + + // Custom vector storage + private List customReferenceVector; private List currentVectors; private StatisticEngine.ScalarType currentScalarType; @@ -50,7 +71,7 @@ public StatPdfCdfChartPanel(List initialVectors, StatisticEngine. this.currentScalarType = (initialType != null) ? initialType : StatisticEngine.ScalarType.L1_NORM; this.currentBins = (initialBins > 0) ? initialBins : 40; - // Controls + // ===== Row 1 controls ===== scalarTypeCombo = new ComboBox<>(); scalarTypeCombo.getItems().addAll(StatisticEngine.ScalarType.values()); scalarTypeCombo.setValue(currentScalarType); @@ -59,61 +80,144 @@ public StatPdfCdfChartPanel(List initialVectors, StatisticEngine. binsSpinner.setPrefWidth(100); binsSpinner.setPrefHeight(40); - // NEW: Metric selector (disabled unless METRIC_DISTANCE_TO_MEAN) - metricCombo = new ComboBox<>(); - metricCombo.getItems().addAll(Metric.getMetricNames()); - // try to default to euclidean if present - if (metricCombo.getItems().contains("euclidean")) { - metricCombo.setValue("euclidean"); - } else if (!metricCombo.getItems().isEmpty()) { - metricCombo.setValue(metricCombo.getItems().get(0)); - } - metricCombo.setDisable(currentScalarType != StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN); - Button refreshButton = new Button("Refresh"); Button popOutButton = new Button("Pop Out"); - HBox controlBar = new HBox( - 20, - new VBox(5, new Label("Feature:"), scalarTypeCombo), - new VBox(5, new Label("Bins:"), binsSpinner), - new VBox(5, new Label("Metric:"), metricCombo), + HBox row1 = new HBox( + 16, + new VBox(5, new Label("Feature"), scalarTypeCombo), + new VBox(5, new Label("Bins"), binsSpinner), refreshButton, popOutButton ); - controlBar.setPadding(new Insets(5)); - controlBar.setAlignment(Pos.CENTER); + row1.setAlignment(Pos.CENTER_LEFT); + row1.setPadding(new Insets(6, 6, 0, 6)); + + // ===== Row 2 controls ===== + 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)); + + referenceCombo = new ComboBox<>(); + referenceCombo.getItems().addAll("Mean", "Vector @ Index", "Custom"); + referenceCombo.setValue("Mean"); + + indexLabel = new Label("Index"); + indexSpinner = new Spinner<>(); + indexSpinner.setPrefWidth(100); + + Button setCustomButton = new Button("Set Custom Vector…"); + + HBox row2 = new HBox( + 16, + new VBox(5, new Label("Metric"), metricCombo), + new VBox(5, new Label("Reference"), referenceCombo), + new VBox(5, indexLabel, indexSpinner), + setCustomButton + ); + row2.setAlignment(Pos.CENTER_LEFT); + row2.setPadding(new Insets(4, 6, 6, 6)); + + // Make rows stretch nicely if needed + HBox.setHgrow(row1, Priority.ALWAYS); + HBox.setHgrow(row2, Priority.ALWAYS); + + VBox controlBar = new VBox(row1, row2); + controlBar.setPadding(new Insets(0)); - // Charts: PDF (top), CDF (bottom) + // ===== Charts: PDF (top), CDF (bottom) ===== pdfChart = new StatPdfCdfChart(currentScalarType, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); cdfChart = new StatPdfCdfChart(currentScalarType, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); if (!currentVectors.isEmpty()) { - // If metric feature selected, set metric & reference (mean) - applyMetricOptionsIfNeeded(); + applyFeatureSpecificOptions(); // sets metric/ref or component index pdfChart.setFeatureVectors(currentVectors); cdfChart.setFeatureVectors(currentVectors); } VBox chartsBox = new VBox(8, pdfChart, cdfChart); - chartsBox.setPadding(new Insets(6)); + chartsBox.setPadding(new Insets(2)); VBox.setVgrow(pdfChart, javafx.scene.layout.Priority.ALWAYS); VBox.setVgrow(cdfChart, javafx.scene.layout.Priority.ALWAYS); setTop(controlBar); setCenter(chartsBox); - // Actions + // ===== Actions ===== refreshButton.setOnAction(e -> refreshCharts()); popOutButton.setOnAction(e -> { if (onPopOut != null) onPopOut.run(); }); - // Keep metric control enabled state in sync with feature selection + // Paste/import custom vector via dialog + setCustomButton.setOnAction(e -> { + List parsed = showCustomVectorDialog(); + if (parsed != null && !parsed.isEmpty()) { + setCustomReferenceVector(parsed); + } + }); + + // Inter-control enable/disable + index label/bounds management scalarTypeCombo.valueProperty().addListener((obs, oldVal, newVal) -> { - boolean isMetric = newVal == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; - metricCombo.setDisable(!isMetric); + currentScalarType = newVal; + updateControlsEnablement(); + updateIndexSpinnerBoundsAndLabel(); // reacts to feature change }); + + referenceCombo.valueProperty().addListener((obs, o, nv) -> { + updateControlsEnablement(); + updateIndexSpinnerBoundsAndLabel(); // reacts to reference change + }); + + // Initialize UI states + updateControlsEnablement(); + updateIndexSpinnerBoundsAndLabel(); + } + + // ===== Public API ===== + + public void setOnPopOut(Runnable handler) { + this.onPopOut = handler; } + public void setFeatureVectors(List vectors) { + this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); + updateIndexSpinnerBoundsAndLabel(); + applyFeatureSpecificOptions(); + pdfChart.setFeatureVectors(this.currentVectors); + cdfChart.setFeatureVectors(this.currentVectors); + } + + 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); + + updateIndexSpinnerBoundsAndLabel(); + applyFeatureSpecificOptions(); + pdfChart.addFeatureVectors(newVectors); + cdfChart.addFeatureVectors(newVectors); + } + + /** Provide a custom reference vector for metric distance mode. */ + public void setCustomReferenceVector(List customVector) { + this.customReferenceVector = customVector; + if (scalarTypeCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && "Custom".equals(referenceCombo.getValue())) { + applyFeatureSpecificOptions(); + if (!currentVectors.isEmpty()) { + pdfChart.setFeatureVectors(currentVectors); + cdfChart.setFeatureVectors(currentVectors); + } + } + } + + public int getBins() { return binsSpinner.getValue(); } + public StatisticEngine.ScalarType getScalarType() { return scalarTypeCombo.getValue(); } + public StatPdfCdfChart getPdfChart() { return pdfChart; } + public StatPdfCdfChart getCdfChart() { return cdfChart; } + + // ===== Internal: behavior ===== + private void refreshCharts() { currentScalarType = scalarTypeCombo.getValue(); currentBins = binsSpinner.getValue(); @@ -124,8 +228,7 @@ private void refreshCharts() { cdfChart.setScalarType(currentScalarType); cdfChart.setBins(currentBins); - // NEW: set metric + reference vector if needed - applyMetricOptionsIfNeeded(); + applyFeatureSpecificOptions(); if (currentVectors != null && !currentVectors.isEmpty()) { pdfChart.setFeatureVectors(currentVectors); @@ -133,62 +236,128 @@ private void refreshCharts() { } } - private void applyMetricOptionsIfNeeded() { + private void applyFeatureSpecificOptions() { + // Clear residuals first + pdfChart.setMetricNameForGeneric(null); + pdfChart.setReferenceVectorForGeneric(null); + pdfChart.setComponentIndex(null); + + cdfChart.setMetricNameForGeneric(null); + cdfChart.setReferenceVectorForGeneric(null); + cdfChart.setComponentIndex(null); + if (currentScalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { - // Reference = mean vector of current data - List meanVector = FeatureVector.getMeanVector(currentVectors); String metricName = metricCombo.getValue(); + List ref = switch (referenceCombo.getValue()) { + case "Vector @ Index" -> getVectorAtIndexAsList(indexSpinner.getValue()); + case "Custom" -> customReferenceVector; + default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); + }; pdfChart.setMetricNameForGeneric(metricName); - pdfChart.setReferenceVectorForGeneric(meanVector); + pdfChart.setReferenceVectorForGeneric(ref); cdfChart.setMetricNameForGeneric(metricName); - cdfChart.setReferenceVectorForGeneric(meanVector); - } else { - // Clear any prior settings so other features don't accidentally use them - pdfChart.setMetricNameForGeneric(null); - pdfChart.setReferenceVectorForGeneric(null); - cdfChart.setMetricNameForGeneric(null); - cdfChart.setReferenceVectorForGeneric(null); + cdfChart.setReferenceVectorForGeneric(ref); + } else if (currentScalarType == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + pdfChart.setComponentIndex(indexSpinner.getValue()); + cdfChart.setComponentIndex(indexSpinner.getValue()); } } - public void setOnPopOut(Runnable handler) { - this.onPopOut = handler; + private void updateControlsEnablement() { + boolean isMetric = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; + boolean isComponent = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + + metricCombo.setDisable(!isMetric); + referenceCombo.setDisable(!isMetric); + + // The single Index spinner is enabled if: + // - metric + "Vector @ Index", OR + // - component feature is selected + boolean indexEnabled = (isMetric && "Vector @ Index".equals(referenceCombo.getValue())) || isComponent; + indexSpinner.setDisable(!indexEnabled); } - public void setFeatureVectors(List vectors) { - this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); - // NEW: keep metric options correct when data change - applyMetricOptionsIfNeeded(); - pdfChart.setFeatureVectors(this.currentVectors); - cdfChart.setFeatureVectors(this.currentVectors); + private void updateIndexSpinnerBoundsAndLabel() { + // Decide what the index means right now & set bounds accordingly + boolean isMetricVectorIdx = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && "Vector @ Index".equals(referenceCombo.getValue()); + boolean isComponent = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + + if (isMetricVectorIdx) { + // bounds: 0..(N-1) + int maxIdx = Math.max(0, getMaxVectorIndex()); + setSpinnerBounds(indexSpinner, 0, maxIdx, Math.min(indexSpinner.getValue() == null ? 0 : indexSpinner.getValue(), maxIdx)); + indexLabel.setText("Index (Vector)"); + } else if (isComponent) { + // bounds: 0..(D-1) + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(indexSpinner, 0, maxDim, Math.min(indexSpinner.getValue() == null ? 0 : indexSpinner.getValue(), maxDim)); + indexLabel.setText("Index (Dimension)"); + } else { + // Not applicable right now; keep bounds reasonable but disabled by enablement logic + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(indexSpinner, 0, Math.max(0, maxDim), 0); + indexLabel.setText("Index"); + } } - 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); + 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))); + } - // Keep metric options correct when appending data - applyMetricOptionsIfNeeded(); - pdfChart.addFeatureVectors(newVectors); - cdfChart.addFeatureVectors(newVectors); + private int getMaxVectorIndex() { + return Math.max(0, (currentVectors == null ? 0 : currentVectors.size()) - 1); } - public int getBins() { - return binsSpinner.getValue(); + private int getMaxDimensionIndex() { + if (currentVectors == null || currentVectors.isEmpty()) return 0; + return Math.max(0, currentVectors.get(0).getData().size() - 1); } - public StatisticEngine.ScalarType getScalarType() { - return scalarTypeCombo.getValue(); + 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(); } - public StatPdfCdfChart getPdfChart() { - return pdfChart; + /** Simple paste dialog for a custom reference vector (comma/space separated doubles). */ + private List showCustomVectorDialog() { + Dialog> dialog = new Dialog<>(); + dialog.setTitle("Set Custom Reference Vector"); + dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL); + + TextArea ta = new TextArea(); + ta.setPromptText("Paste numbers separated by commas or spaces, e.g.\n0.12, -1.3, 2.5, 0.0"); + ta.setPrefRowCount(6); + dialog.getDialogPane().setContent(ta); + + dialog.setResultConverter(bt -> { + if (bt == javafx.scene.control.ButtonType.OK) { + String text = ta.getText(); + List parsed = parseDoubles(text); + return (parsed == null || parsed.isEmpty()) ? null : parsed; + } + return null; + }); + + return dialog.showAndWait().orElse(null); } - public StatPdfCdfChart getCdfChart() { - return cdfChart; + private static List parseDoubles(String s) { + if (s == null || s.isBlank()) return null; + String norm = s.replaceAll("[\\n\\t]", " ").replaceAll(",", " "); + String[] parts = norm.trim().split("\\s+"); + List out = new ArrayList<>(parts.length); + for (String p : parts) { + try { + out.add(Double.valueOf(p)); + } catch (NumberFormatException ignore) { + // skip tokens that are not numbers + } + } + return out; } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java index 83918084..adb1ae76 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -3,6 +3,7 @@ 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; @@ -17,17 +18,19 @@ */ public class StatisticEngine { -public enum ScalarType { - L1_NORM, - LINF_NORM, - MEAN, - MAX, - MIN, - DIST_TO_MEAN, - COSINE_TO_MEAN, - PC1_PROJECTION, - METRIC_DISTANCE_TO_MEAN -} + 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 // NEW: marginal of a single component across vectors + } + /** * Main entry point to compute selected statistics for a list of FeatureVectors. * @param vectors List of FeatureVector @@ -43,37 +46,74 @@ public static Map computeStatistics( 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 - List pc1Projections = null; if (selectedTypes.contains(ScalarType.PC1_PROJECTION)) { double[][] dataArr = vectors.stream() - .map(fv -> fv.getData().stream().mapToDouble(Double::doubleValue).toArray()) - .toArray(double[][]::new); + .map(fv -> fv.getData().stream().mapToDouble(Double::doubleValue).toArray()) + .toArray(double[][]::new); double[][] pcaProjected = AnalysisUtils.doCommonsPCA(dataArr); - pc1Projections = new ArrayList<>(); + 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) continue; // already handled + if (type == ScalarType.PC1_PROJECTION || type == ScalarType.COMPONENT_AT_DIMENSION) continue; + if (type == ScalarType.METRIC_DISTANCE_TO_MEAN) { - // Metric distance (user-selected) if (metricNameForGeneric != null && referenceVectorForGeneric != null) { Metric metric = Metric.getMetric(metricNameForGeneric); double[] refVec = referenceVectorForGeneric.stream().mapToDouble(Double::doubleValue).toArray(); - List metricDists = new ArrayList<>(); + 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); @@ -84,23 +124,20 @@ public static Map computeStatistics( continue; } - List scalars = new ArrayList<>(); + 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 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; + ? AnalysisUtils.l2Norm(diffList(fv.getData(), meanVector)) + : 0.0; case COSINE_TO_MEAN -> meanVector != null - ? AnalysisUtils.cosineSimilarity(fv.getData(), meanVector) - : 0.0; + ? AnalysisUtils.cosineSimilarity(fv.getData(), meanVector) + : 0.0; default -> 0.0; }; scalars.add(value); @@ -110,20 +147,17 @@ public static Map computeStatistics( return results; } - // Utility: vector difference as double[] public static double[] diffList(List a, List b) { double[] out = new double[a.size()]; for (int i = 0; i < a.size(); i++) out[i] = a.get(i) - b.get(i); return out; } - // Utility: generate StatisticResult from scalars public static StatisticResult buildStatResult(List scalars, int bins) { PDFCDFResult pdfcdf = computePDFCDF(scalars, bins); return new StatisticResult(scalars, pdfcdf.bins, pdfcdf.pdf, pdfcdf.cdf); } - // PDF/CDF calculation using histogram public static class PDFCDFResult { double[] bins; double[] pdf; @@ -134,30 +168,32 @@ public static class PDFCDFResult { } public static PDFCDFResult computePDFCDF(List values, int binsCount) { + if (values == null || values.isEmpty()) { + int n = Math.max(5, binsCount); + return new PDFCDFResult(new double[n], new double[n], new double[n]); + } + int n = Math.max(1, binsCount); + double min = values.stream().min(Double::compare).get(); double max = values.stream().max(Double::compare).get(); - if (min == max) max += 1e-8; // Avoid divide by zero for constant values - double binWidth = (max - min) / binsCount; - double[] bins = new double[binsCount]; - double[] pdf = new double[binsCount]; - double[] cdf = new double[binsCount]; - - for (int i = 0; i < binsCount; i++) { - bins[i] = min + (i + 0.5) * binWidth; - } + if (min == max) max += 1e-8; + + double binWidth = (max - min) / n; + double[] bins = new double[n]; + double[] pdf = new double[n]; + double[] cdf = new double[n]; + + for (int i = 0; i < n; i++) bins[i] = min + (i + 0.5) * binWidth; for (double v : values) { int idx = (int) ((v - min) / binWidth); if (idx < 0) idx = 0; - if (idx >= binsCount) idx = binsCount - 1; - pdf[idx] += 1; - } - for (int i = 0; i < binsCount; i++) { - pdf[i] /= (values.size() * binWidth); + if (idx >= n) idx = n - 1; + pdf[idx] += 1.0; } + for (int i = 0; i < n; i++) pdf[i] /= (values.size() * binWidth); cdf[0] = pdf[0] * binWidth; - for (int i = 1; i < binsCount; i++) { - cdf[i] = cdf[i - 1] + pdf[i] * binWidth; - } + for (int i = 1; i < n; i++) cdf[i] = cdf[i - 1] + pdf[i] * binWidth; + cdf[n - 1] = Math.min(1.0, Math.max(0.0, cdf[n - 1])); return new PDFCDFResult(bins, pdf, cdf); } } From 1d69e17477fdddb3dc4b16696295604df5e968ee Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Thu, 11 Sep 2025 15:41:37 -0400 Subject: [PATCH 08/50] First cut support for 3D Joint PDF/CDF using Hypersurface --- .../components/panes/StatPdfCdfPane.java | 28 ++ .../javafx/events/HypersurfaceGridEvent.java | 65 +++ .../javafx/javafx3d/Hypersurface3DPane.java | 68 ++- .../edu/jhuapl/trinity/utils/DataUtils.java | 74 ++++ .../trinity/utils/statistics/AxisParams.java | 83 ++++ .../trinity/utils/statistics/DialogUtils.java | 62 +++ .../utils/statistics/GridDensity3DEngine.java | 247 +++++++++++ .../utils/statistics/GridDensityResult.java | 77 ++++ .../trinity/utils/statistics/GridSpec.java | 52 +++ .../statistics/StatPdfCdfChartPanel.java | 412 +++++++++++------- .../statistics/StatisticsEngineTest.java | 4 +- 11 files changed, 1004 insertions(+), 168 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java 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 index 7ee983a9..87b5e637 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java @@ -1,6 +1,7 @@ package edu.jhuapl.trinity.javafx.components.panes; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +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; @@ -45,6 +46,7 @@ public StatPdfCdfPane(Scene scene, Pane parent) { borderPane.setPadding(new Insets(8)); chartPanel = new StatPdfCdfChartPanel(); // empty state + chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); borderPane.setCenter(chartPanel); } @@ -73,9 +75,35 @@ public StatPdfCdfPane( borderPane.setPadding(new Insets(8)); chartPanel = new StatPdfCdfChartPanel(vectors, scalarType, bins); + chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); borderPane.setCenter(chartPanel); } + 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); } 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..fc56a2c7 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java @@ -0,0 +1,65 @@ +package edu.jhuapl.trinity.javafx.events; + +import java.util.List; +import javafx.event.Event; +import javafx.event.EventTarget; +import javafx.event.EventType; + +/** + * 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/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 26da6ed5..57d26df0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -20,6 +20,7 @@ import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; +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; @@ -35,10 +36,13 @@ import edu.jhuapl.trinity.javafx.renderers.FeatureVectorRenderer; 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.metric.Metric; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; import javafx.animation.AnimationTimer; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; @@ -126,6 +130,7 @@ import java.util.Optional; import java.util.Random; import java.util.function.Function; +import javafx.scene.control.ComboBox; import javafx.scene.control.Menu; /** @@ -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; @@ -253,6 +258,7 @@ enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public List featureLabels = new ArrayList<>(); public Spinner xWidthSpinner, zWidthSpinner; + private ComboBox heightModeCombo; public Scene scene; HashMap shape3DToCalloutMap; public String imageryBasePath = ""; @@ -776,6 +782,16 @@ public Hypersurface3DPane(Scene scene) { 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); scene.addEventHandler(HyperspaceEvent.REFRESH_RATE_GUI, e -> hypersurfaceRefreshRate = (long) e.object); @@ -834,6 +850,46 @@ public void computeCosineDistance(FeatureCollection collection) { cosineDistancesGrid.toArray(Double[]::new))); System.out.println(cosineDistancesGrid.toString()); } + private void applySurfaceGridToHypersurface(List> grid) { + // pick a mode; NORMALIZE_01 is a great default for CDF +// HeightMode mode = HeightMode.NORMALIZE_01; + HeightMode mode = heightModeCombo.getValue(); + double userScale = 1.0; //yHeightScaleSpinner.getValue(); // your existing control (0..100) + + List> scaled = DataUtils.normalizeAndScale(grid, mode, userScale); + + dataGrid.clear(); + dataGrid.addAll(scaled); + + xWidth = dataGrid.get(0).size(); // columns (X) + zWidth = dataGrid.size(); // rows (Y) + + xWidthSpinner.getValueFactory().setValue(xWidth); + zWidthSpinner.getValueFactory().setValue(zWidth); + + updateTheMesh(); + updateView(true); + } + public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean flipY) { + // Choose which surface to render + List> grid = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); + // Optional: flip Y if your renderer expects Y-up instead of row-major Y-down + if (flipY) { + java.util.Collections.reverse(grid); // reverse row order + } + dataGrid.clear(); + dataGrid.addAll(grid); + + xWidth = dataGrid.get(0).size(); // columns (X) + zWidth = dataGrid.size(); // rows (Y) + + // keep your existing spinners/mesh update logic + xWidthSpinner.getValueFactory().setValue(xWidth); + zWidthSpinner.getValueFactory().setValue(zWidth); + updateTheMesh(); + updateView(true); + } + public void computeSurfaceDifference(FeatureCollection collection) { double[][] newRayRay = collection.convertFeaturesToArray(); //@DEBUG SMP @@ -1539,6 +1595,15 @@ else if (anchorIndex > dataGrid.size()) surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); zWidthSpinner.setPrefWidth(125); + // in constructor / initControls() + heightModeCombo = new ComboBox<>(); + heightModeCombo.getItems().addAll(HeightMode.values()); + heightModeCombo.setValue(HeightMode.RAW); // default + + VBox heightControls = new VBox(5, + new Label("Height Mode"), + heightModeCombo + ); ToggleGroup colorationToggle = new ToggleGroup(); RadioButton colorByImageRadioButton = new RadioButton("Color by Image"); @@ -1679,6 +1744,7 @@ else if (anchorIndex > dataGrid.size()) new HBox(10, xWidthLabel, xWidthSpinner), new HBox(10, zWidthLabel, zWidthSpinner), new HBox(10, yScaleLabel, yScaleSpinner), + heightControls, new HBox(10, surfScaleLabel, surfScaleSpinner), new Label("Color Method"), colorationHBox, diff --git a/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java b/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java index fe9bdff7..15718164 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java @@ -39,6 +39,12 @@ */ 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(); @@ -53,6 +59,74 @@ public static int randomSign() { 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()); 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..567dc704 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java @@ -0,0 +1,83 @@ +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/DialogUtils.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java new file mode 100644 index 00000000..76101391 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java @@ -0,0 +1,62 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import javafx.scene.control.TextArea; + +/** + * Small utility dialog helpers used by statistics panels. + * + * @author Sean Phillips + */ +public final class DialogUtils { + + private DialogUtils() { } + + /** + * Shows a simple dialog to collect a custom reference vector. + * Accepts numbers separated by commas, spaces, or newlines. + * + * @return a list of parsed doubles, or null if the user canceled/empty. + */ + public static List showCustomVectorDialog() { + Dialog> dialog = new Dialog<>(); + dialog.setTitle("Set Custom Reference Vector"); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + TextArea ta = new TextArea(); + ta.setPromptText("Paste numbers separated by commas or spaces, e.g.\n0.12, -1.3, 2.5, 0.0"); + ta.setPrefRowCount(6); + dialog.getDialogPane().setContent(ta); + + dialog.setResultConverter(bt -> { + if (bt == ButtonType.OK) { + String text = ta.getText(); + return parseDoubles(text); + } + return null; + }); + + return dialog.showAndWait().orElse(null); + } + + /** + * Parses doubles from a free-form string (commas/spaces/newlines). + */ + public static List parseDoubles(String s) { + if (s == null || s.isBlank()) return null; + String norm = s.replaceAll("[\\n\\t]", " ").replace(",", " "); + String[] parts = norm.trim().split("\\s+"); + List out = new ArrayList<>(parts.length); + for (String p : parts) { + try { + out.add(Double.parseDouble(p)); + } catch (NumberFormatException ignore) { + // skip tokens that aren't valid doubles + } + } + return out.isEmpty() ? null : out; + } +} 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..1dc562c2 --- /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..faa16f43 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java @@ -0,0 +1,77 @@ +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..117c386c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java @@ -0,0 +1,52 @@ +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 final int binsX; + private final int binsY; + private final Double minX; + private final Double maxX; + private final Double minY; + private final 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; } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index 22fecaa7..a6b23cfc 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -2,37 +2,32 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.metric.Metric; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; -import javafx.scene.control.Dialog; import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; import javafx.scene.control.Spinner; import javafx.scene.control.SpinnerValueFactory; -import javafx.scene.control.TextArea; +import javafx.scene.control.ToggleGroup; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import java.util.ArrayList; -import java.util.List; - /** - * Chart panel with GUI controls on top and TWO independent charts stacked vertically: - * - Top chart renders PDF-only - * - Bottom chart renders CDF-only + * Chart panel for visualizing PDF and CDF statistics (2D) and + * triggering a 3D joint PDF/CDF surface computation. * - * Controls split across two rows: - * Row 1: Feature, Bins, Refresh, Pop Out - * Row 2: Metric, Reference, Index (shared), Set Custom Vector... + * Minimal additions: + * - Y feature + Y index + * - "Compute 3D Surface" button * - * Features: - * - METRIC_DISTANCE_TO_MEAN: choose metric; reference = Mean / Vector@Index / Custom - * - COMPONENT_AT_DIMENSION: choose a component index for the marginal using the same Index spinner - * - * Can be instantiated empty or with initial data. + * 2D charts still use X feature controls (existing). * * @author Sean Phillips */ @@ -40,52 +35,61 @@ public class StatPdfCdfChartPanel extends BorderPane { private StatPdfCdfChart pdfChart; private StatPdfCdfChart cdfChart; - private ComboBox scalarTypeCombo; + // Existing X-feature controls + private ComboBox xFeatureCombo; private Spinner binsSpinner; - // Metric/reference controls (row 2) + // Metric/reference shared with X (unchanged) private ComboBox metricCombo; private ComboBox referenceCombo; // "Mean", "Vector @ Index", "Custom" - - // Shared index spinner for both: - // - Vector @ Index (reference selection) - // - COMPONENT_AT_DIMENSION (component index) - private Spinner indexSpinner; - private Label indexLabel; - - // Custom vector storage + private Spinner xIndexSpinner; + private Label xIndexLabel; + + // Y-feature (new, minimal) + private ComboBox yFeatureCombo; + private Spinner yIndexSpinner; + private Label yIndexLabel; + + private RadioButton pdfRadio; + private RadioButton cdfRadio; + private ToggleGroup surfaceToggle; + + // Custom vector storage (used if X uses Custom) private List customReferenceVector; - private List currentVectors; - private StatisticEngine.ScalarType currentScalarType; - private int currentBins; + private List currentVectors = new ArrayList<>(); + private StatisticEngine.ScalarType currentXFeature = StatisticEngine.ScalarType.L1_NORM; + private int currentBins = 40; private Runnable onPopOut = null; + private Consumer onComputeSurface = null; public StatPdfCdfChartPanel() { this(null, StatisticEngine.ScalarType.L1_NORM, 40); } - public StatPdfCdfChartPanel(List initialVectors, StatisticEngine.ScalarType initialType, int initialBins) { - this.currentVectors = (initialVectors != null) ? initialVectors : new ArrayList<>(); - this.currentScalarType = (initialType != null) ? initialType : StatisticEngine.ScalarType.L1_NORM; - this.currentBins = (initialBins > 0) ? initialBins : 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; - // ===== Row 1 controls ===== - scalarTypeCombo = new ComboBox<>(); - scalarTypeCombo.getItems().addAll(StatisticEngine.ScalarType.values()); - scalarTypeCombo.setValue(currentScalarType); + // ===== Row 1: X feature + bins + 2D actions ===== + xFeatureCombo = new ComboBox<>(); + xFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); + xFeatureCombo.setValue(currentXFeature); binsSpinner = new Spinner<>(5, 100, currentBins, 5); binsSpinner.setPrefWidth(100); binsSpinner.setPrefHeight(40); - Button refreshButton = new Button("Refresh"); + Button refreshButton = new Button("Refresh 2D"); Button popOutButton = new Button("Pop Out"); HBox row1 = new HBox( 16, - new VBox(5, new Label("Feature"), scalarTypeCombo), + new VBox(5, new Label("X Feature (2D)"), xFeatureCombo), new VBox(5, new Label("Bins"), binsSpinner), refreshButton, popOutButton @@ -93,7 +97,7 @@ public StatPdfCdfChartPanel(List initialVectors, StatisticEngine. row1.setAlignment(Pos.CENTER_LEFT); row1.setPadding(new Insets(6, 6, 0, 6)); - // ===== Row 2 controls ===== + // ===== Row 2: X metric/ref/index ===== metricCombo = new ComboBox<>(); metricCombo.getItems().addAll(Metric.getMetricNames()); if (metricCombo.getItems().contains("euclidean")) metricCombo.setValue("euclidean"); @@ -103,86 +107,145 @@ public StatPdfCdfChartPanel(List initialVectors, StatisticEngine. referenceCombo.getItems().addAll("Mean", "Vector @ Index", "Custom"); referenceCombo.setValue("Mean"); - indexLabel = new Label("Index"); - indexSpinner = new Spinner<>(); - indexSpinner.setPrefWidth(100); + xIndexLabel = new Label("X Index"); + xIndexSpinner = new Spinner<>(); + xIndexSpinner.setPrefWidth(100); Button setCustomButton = new Button("Set Custom Vector…"); HBox row2 = new HBox( 16, - new VBox(5, new Label("Metric"), metricCombo), - new VBox(5, new Label("Reference"), referenceCombo), - new VBox(5, indexLabel, indexSpinner), + new VBox(5, new Label("Metric (X)"), metricCombo), + new VBox(5, new Label("Reference (X)"), referenceCombo), + new VBox(5, xIndexLabel, xIndexSpinner), setCustomButton ); row2.setAlignment(Pos.CENTER_LEFT); - row2.setPadding(new Insets(4, 6, 6, 6)); + row2.setPadding(new Insets(4, 6, 0, 6)); + + // ===== Row 3: Y feature + Y index + 3D action ===== + yFeatureCombo = new ComboBox<>(); + yFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); + yFeatureCombo.setValue(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); + + yIndexLabel = new Label("Y Index"); + yIndexSpinner = new Spinner<>(); + yIndexSpinner.setPrefWidth(100); + + surfaceToggle = new ToggleGroup(); + + pdfRadio = new RadioButton("PDF"); + pdfRadio.setToggleGroup(surfaceToggle); + pdfRadio.setSelected(true); // default + + cdfRadio = new RadioButton("CDF"); + cdfRadio.setToggleGroup(surfaceToggle); + + HBox surfaceBox = new HBox(8, pdfRadio, cdfRadio); + surfaceBox.setAlignment(Pos.CENTER_LEFT); + + Button compute3DButton = new Button("Compute 3D Surface"); + + HBox row3 = new HBox( + 16, + new VBox(5, new Label("Y Feature (3D)"), yFeatureCombo), + new VBox(5, yIndexLabel, yIndexSpinner), + new VBox(5, new Label("Surface"), surfaceBox), + compute3DButton + ); + + row3.setAlignment(Pos.CENTER_LEFT); + row3.setPadding(new Insets(4, 6, 6, 6)); - // Make rows stretch nicely if needed HBox.setHgrow(row1, Priority.ALWAYS); HBox.setHgrow(row2, Priority.ALWAYS); + HBox.setHgrow(row3, Priority.ALWAYS); - VBox controlBar = new VBox(row1, row2); + VBox controlBar = new VBox(row1, row2, row3); controlBar.setPadding(new Insets(0)); // ===== Charts: PDF (top), CDF (bottom) ===== - pdfChart = new StatPdfCdfChart(currentScalarType, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); - cdfChart = new StatPdfCdfChart(currentScalarType, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); + pdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); + cdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); if (!currentVectors.isEmpty()) { - applyFeatureSpecificOptions(); // sets metric/ref or component index + applyXFeatureOptions(); pdfChart.setFeatureVectors(currentVectors); cdfChart.setFeatureVectors(currentVectors); } VBox chartsBox = new VBox(8, pdfChart, cdfChart); - chartsBox.setPadding(new Insets(2)); + chartsBox.setPadding(new Insets(6)); VBox.setVgrow(pdfChart, javafx.scene.layout.Priority.ALWAYS); VBox.setVgrow(cdfChart, javafx.scene.layout.Priority.ALWAYS); setTop(controlBar); setCenter(chartsBox); - // ===== Actions ===== - refreshButton.setOnAction(e -> refreshCharts()); + // ===== Handlers ===== + refreshButton.setOnAction(e -> refresh2DCharts()); popOutButton.setOnAction(e -> { if (onPopOut != null) onPopOut.run(); }); - // Paste/import custom vector via dialog setCustomButton.setOnAction(e -> { - List parsed = showCustomVectorDialog(); - if (parsed != null && !parsed.isEmpty()) { - setCustomReferenceVector(parsed); - } + List parsed = DialogUtils.showCustomVectorDialog(); + if (parsed != null && !parsed.isEmpty()) setCustomReferenceVector(parsed); }); - // Inter-control enable/disable + index label/bounds management - scalarTypeCombo.valueProperty().addListener((obs, oldVal, newVal) -> { - currentScalarType = newVal; - updateControlsEnablement(); - updateIndexSpinnerBoundsAndLabel(); // reacts to feature change - }); + compute3DButton.setOnAction(e -> compute3DSurface()); - referenceCombo.valueProperty().addListener((obs, o, nv) -> { - updateControlsEnablement(); - updateIndexSpinnerBoundsAndLabel(); // reacts to reference change + xFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { + currentXFeature = nv; + updateXControlEnablement(); + updateXIndexBoundsAndLabel(); + }); + referenceCombo.valueProperty().addListener((obs, ov, nv) -> { + updateXControlEnablement(); + updateXIndexBoundsAndLabel(); + }); + yFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { + updateYControlEnablement(); + updateYIndexBoundsAndLabel(); }); - // Initialize UI states - updateControlsEnablement(); - updateIndexSpinnerBoundsAndLabel(); + // initialize control states + updateXControlEnablement(); + updateYControlEnablement(); + updateXIndexBoundsAndLabel(); + updateYIndexBoundsAndLabel(); + } + + // ----------------- Public API ----------------- + public boolean isSurfaceCDF() { + return cdfRadio.isSelected(); + } + /** + * Get a human-friendly description of the current Y feature type. + * Includes the component index if the Y feature is COMPONENT_AT_DIMENSION. + */ + 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"; } - // ===== Public API ===== + public void setOnPopOut(Runnable handler) { this.onPopOut = handler; } - public void setOnPopOut(Runnable handler) { - this.onPopOut = handler; + /** 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<>(); - updateIndexSpinnerBoundsAndLabel(); - applyFeatureSpecificOptions(); + updateXIndexBoundsAndLabel(); + updateYIndexBoundsAndLabel(); + applyXFeatureOptions(); pdfChart.setFeatureVectors(this.currentVectors); cdfChart.setFeatureVectors(this.currentVectors); } @@ -192,43 +255,42 @@ public void addFeatureVectors(List newVectors) { if (this.currentVectors == null) this.currentVectors = new ArrayList<>(newVectors); else this.currentVectors.addAll(newVectors); - updateIndexSpinnerBoundsAndLabel(); - applyFeatureSpecificOptions(); + updateXIndexBoundsAndLabel(); + updateYIndexBoundsAndLabel(); + applyXFeatureOptions(); pdfChart.addFeatureVectors(newVectors); cdfChart.addFeatureVectors(newVectors); } - /** Provide a custom reference vector for metric distance mode. */ + public int getBins() { return binsSpinner.getValue(); } + public StatisticEngine.ScalarType getScalarType() { return xFeatureCombo.getValue(); } + public StatPdfCdfChart getPdfChart() { return pdfChart; } + public StatPdfCdfChart getCdfChart() { return cdfChart; } + public void setCustomReferenceVector(List customVector) { this.customReferenceVector = customVector; - if (scalarTypeCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN - && "Custom".equals(referenceCombo.getValue())) { - applyFeatureSpecificOptions(); - if (!currentVectors.isEmpty()) { - pdfChart.setFeatureVectors(currentVectors); - cdfChart.setFeatureVectors(currentVectors); - } + if (xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && "Custom".equals(referenceCombo.getValue()) + && !currentVectors.isEmpty()) { + applyXFeatureOptions(); + pdfChart.setFeatureVectors(currentVectors); + cdfChart.setFeatureVectors(currentVectors); } } - public int getBins() { return binsSpinner.getValue(); } - public StatisticEngine.ScalarType getScalarType() { return scalarTypeCombo.getValue(); } - public StatPdfCdfChart getPdfChart() { return pdfChart; } - public StatPdfCdfChart getCdfChart() { return cdfChart; } - - // ===== Internal: behavior ===== + // ----------------- 2D (X feature) behavior ----------------- - private void refreshCharts() { - currentScalarType = scalarTypeCombo.getValue(); + private void refresh2DCharts() { + currentXFeature = xFeatureCombo.getValue(); currentBins = binsSpinner.getValue(); - pdfChart.setScalarType(currentScalarType); + pdfChart.setScalarType(currentXFeature); pdfChart.setBins(currentBins); - cdfChart.setScalarType(currentScalarType); + cdfChart.setScalarType(currentXFeature); cdfChart.setBins(currentBins); - applyFeatureSpecificOptions(); + applyXFeatureOptions(); if (currentVectors != null && !currentVectors.isEmpty()) { pdfChart.setFeatureVectors(currentVectors); @@ -236,8 +298,8 @@ private void refreshCharts() { } } - private void applyFeatureSpecificOptions() { - // Clear residuals first + private void applyXFeatureOptions() { + // Clear residuals pdfChart.setMetricNameForGeneric(null); pdfChart.setReferenceVectorForGeneric(null); pdfChart.setComponentIndex(null); @@ -246,68 +308,126 @@ private void applyFeatureSpecificOptions() { cdfChart.setReferenceVectorForGeneric(null); cdfChart.setComponentIndex(null); - if (currentScalarType == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + if (currentXFeature == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { String metricName = metricCombo.getValue(); List ref = switch (referenceCombo.getValue()) { - case "Vector @ Index" -> getVectorAtIndexAsList(indexSpinner.getValue()); + 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 (currentScalarType == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { - pdfChart.setComponentIndex(indexSpinner.getValue()); - cdfChart.setComponentIndex(indexSpinner.getValue()); + } else if (currentXFeature == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + Integer idx = xIndexSpinner.getValue(); + pdfChart.setComponentIndex(idx); + cdfChart.setComponentIndex(idx); } } - private void updateControlsEnablement() { - boolean isMetric = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; - boolean isComponent = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + private void updateXControlEnablement() { + boolean isMetric = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; + boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; metricCombo.setDisable(!isMetric); referenceCombo.setDisable(!isMetric); - // The single Index spinner is enabled if: - // - metric + "Vector @ Index", OR - // - component feature is selected boolean indexEnabled = (isMetric && "Vector @ Index".equals(referenceCombo.getValue())) || isComponent; - indexSpinner.setDisable(!indexEnabled); + xIndexSpinner.setDisable(!indexEnabled); } - private void updateIndexSpinnerBoundsAndLabel() { - // Decide what the index means right now & set bounds accordingly - boolean isMetricVectorIdx = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + private void updateXIndexBoundsAndLabel() { + boolean isMetricVectorIdx = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN && "Vector @ Index".equals(referenceCombo.getValue()); - boolean isComponent = scalarTypeCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; if (isMetricVectorIdx) { - // bounds: 0..(N-1) int maxIdx = Math.max(0, getMaxVectorIndex()); - setSpinnerBounds(indexSpinner, 0, maxIdx, Math.min(indexSpinner.getValue() == null ? 0 : indexSpinner.getValue(), maxIdx)); - indexLabel.setText("Index (Vector)"); + setSpinnerBounds(xIndexSpinner, 0, maxIdx, safeSpinnerValue(xIndexSpinner, 0, maxIdx)); + xIndexLabel.setText("X Index (Vector)"); } else if (isComponent) { - // bounds: 0..(D-1) int maxDim = getMaxDimensionIndex(); - setSpinnerBounds(indexSpinner, 0, maxDim, Math.min(indexSpinner.getValue() == null ? 0 : indexSpinner.getValue(), maxDim)); - indexLabel.setText("Index (Dimension)"); + setSpinnerBounds(xIndexSpinner, 0, maxDim, safeSpinnerValue(xIndexSpinner, 0, maxDim)); + xIndexLabel.setText("X Index (Dimension)"); + } else { + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(xIndexSpinner, 0, Math.max(0, maxDim), 0); + xIndexLabel.setText("X Index"); + } + } + + // ----------------- 3D compute (new) ----------------- + + private void compute3DSurface() { + if (onComputeSurface == null || currentVectors == null || currentVectors.isEmpty()) return; + + // Build X axis params from existing controls + AxisParams xAxis = new AxisParams(); + xAxis.setType(xFeatureCombo.getValue()); + if (xAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { + xAxis.setMetricName(metricCombo.getValue()); + List ref = switch (referenceCombo.getValue()) { + case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); + case "Custom" -> customReferenceVector; + default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); + }; + xAxis.setReferenceVec(ref); + } else if (xAxis.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { + xAxis.setComponentIndex(xIndexSpinner.getValue()); + } + + // Build Y axis params (minimal: support component index and mirror metric/ref if used) + 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 = (referenceCombo.getValue().equals("Custom")) + ? customReferenceVector + : (currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors)); + if ("Vector @ Index".equals(referenceCombo.getValue())) { + ref = getVectorAtIndexAsList(xIndexSpinner.getValue()); + } + yAxis.setReferenceVec(ref); + } + int bins = binsSpinner.getValue(); + GridSpec grid = new GridSpec(bins, bins); + GridDensityResult result = GridDensity3DEngine.computePdfCdf2D(currentVectors, xAxis, yAxis, grid); + onComputeSurface.accept(result); + } + + private void updateYControlEnablement() { + boolean yNeedsDim = yFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + yIndexSpinner.setDisable(!yNeedsDim); + } + + private void updateYIndexBoundsAndLabel() { + boolean yIsComponent = yFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + if (yIsComponent) { + int maxDim = getMaxDimensionIndex(); + setSpinnerBounds(yIndexSpinner, 0, maxDim, safeSpinnerValue(yIndexSpinner, 0, maxDim)); + yIndexLabel.setText("Y Index (Dimension)"); } else { - // Not applicable right now; keep bounds reasonable but disabled by enablement logic int maxDim = getMaxDimensionIndex(); - setSpinnerBounds(indexSpinner, 0, Math.max(0, maxDim), 0); - indexLabel.setText("Index"); + setSpinnerBounds(yIndexSpinner, 0, Math.max(0, maxDim), 0); + yIndexLabel.setText("Y Index"); } } + // ----------------- 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); } @@ -322,42 +442,4 @@ private List getVectorAtIndexAsList(int idx) { int safe = Math.max(0, Math.min(idx, currentVectors.size() - 1)); return currentVectors.get(safe).getData(); } - - /** Simple paste dialog for a custom reference vector (comma/space separated doubles). */ - private List showCustomVectorDialog() { - Dialog> dialog = new Dialog<>(); - dialog.setTitle("Set Custom Reference Vector"); - dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL); - - TextArea ta = new TextArea(); - ta.setPromptText("Paste numbers separated by commas or spaces, e.g.\n0.12, -1.3, 2.5, 0.0"); - ta.setPrefRowCount(6); - dialog.getDialogPane().setContent(ta); - - dialog.setResultConverter(bt -> { - if (bt == javafx.scene.control.ButtonType.OK) { - String text = ta.getText(); - List parsed = parseDoubles(text); - return (parsed == null || parsed.isEmpty()) ? null : parsed; - } - return null; - }); - - return dialog.showAndWait().orElse(null); - } - - private static List parseDoubles(String s) { - if (s == null || s.isBlank()) return null; - String norm = s.replaceAll("[\\n\\t]", " ").replaceAll(",", " "); - String[] parts = norm.trim().split("\\s+"); - List out = new ArrayList<>(parts.length); - for (String p : parts) { - try { - out.add(Double.valueOf(p)); - } catch (NumberFormatException ignore) { - // skip tokens that are not numbers - } - } - return out; - } } diff --git a/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java index 64171772..7aede765 100644 --- a/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java +++ b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java @@ -34,7 +34,7 @@ public static void randomGaussianTest() { // === 2. Select statistics to compute === Set types = Set.of( - StatisticEngine.ScalarType.NORM, + StatisticEngine.ScalarType.L1_NORM, StatisticEngine.ScalarType.MEAN, StatisticEngine.ScalarType.MAX ); @@ -106,7 +106,7 @@ public static void bimodalTest() { } Set types = Set.of( - StatisticEngine.ScalarType.NORM, + StatisticEngine.ScalarType.L1_NORM, StatisticEngine.ScalarType.MEAN, StatisticEngine.ScalarType.MAX ); From a103e0df8dad2f459a34f62421f7f7a445e08948 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sat, 13 Sep 2025 11:27:58 -0400 Subject: [PATCH 09/50] SurfaceUtils providing Interpolation and Smoothing algorithms for Hypersurface3DPane Added features to allow more manual entry to StatPdfCdfChart GUI components. --- .../trinity/data/files/CyberReportFile.java | 117 -- .../trinity/data/files/CyberReporterFile.java | 2 +- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 6 + .../javafx/javafx3d/Hypersurface3DPane.java | 1316 +++++------------ .../trinity/javafx/javafx3d/SurfaceUtils.java | 400 +++++ .../trinity/utils/statistics/DialogUtils.java | 281 +++- .../utils/statistics/StatPdfCdfChart.java | 161 +- .../statistics/StatPdfCdfChartPanel.java | 292 +++- 8 files changed, 1386 insertions(+), 1189 deletions(-) delete mode 100644 src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java diff --git a/src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java b/src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java deleted file mode 100644 index 98e35c85..00000000 --- a/src/main/java/edu/jhuapl/trinity/data/files/CyberReportFile.java +++ /dev/null @@ -1,117 +0,0 @@ -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 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; -import java.nio.file.Files; - -/** - * @author Sean Phillips - */ -public class CyberReportFile extends File implements Transferable { - private static final Logger LOG = LoggerFactory.getLogger(CyberReportFile.class); - public static final DataFlavor DATA_FLAVOR = new DataFlavor(CyberReportFile.class, "CYBERREPORT"); - public CyberReport cyberReport = null; - - /** - * Constructor that extends File super constructor - * - * @param pathname Full path string to the file - */ - public CyberReportFile(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 CyberReportFile(String pathname, Boolean parseExisting) throws IOException { - super(pathname); - if (parseExisting) - parseContent(); - } - - /** - * Tests whether a given File is a LayerableObject file or not - * - * @param file The File to be tested - * @return True if the file being tested has header line matching FILE_DESC - * @throws java.io.IOException - */ - public static boolean isCyberReportFile(File file) throws IOException { - String extension = file.getAbsolutePath().substring( - file.getAbsolutePath().lastIndexOf(".")); - if (extension.equalsIgnoreCase(".json")) { - String body = Files.readString(file.toPath()); - return CyberReport.isCyberReport(body); - } - return false; - } - - /** - * 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(); - } - - private CyberReport 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()); - cyberReport = mapper.readValue(message, CyberReport.class); - return cyberReport; - } - - /** - * 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 void writeContent() throws IOException { - if (null != cyberReport) { - ObjectMapper mapper = new ObjectMapper(); - //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - mapper.writeValue(this, cyberReport); - LOG.info("CyberReport serialized to file."); - } - } - - @Override - public DataFlavor[] getTransferDataFlavors() { - return new DataFlavor[]{DATA_FLAVOR}; - } - - @Override - public boolean isDataFlavorSupported(DataFlavor df) { - return df == DATA_FLAVOR; - } - - @Override - public Object getTransferData(DataFlavor df) throws UnsupportedFlavorException, IOException { - if (df == DATA_FLAVOR) { - return this; - } else { - throw new UnsupportedFlavorException(df); - } - } -} diff --git a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java index 1ac761ef..91d93a0e 100644 --- a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java +++ b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java @@ -39,7 +39,7 @@ public static boolean isFileType(File file) throws IOException { } @Override public DataFlavor getDataFlavor() { - return new DataFlavor(CyberReportFile.class, "CYBERREPORT"); + return new DataFlavor(CyberReporterFile.class, "CYBERREPORT"); } @Override 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..e6c98d5b 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/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 57d26df0..d049a9ef 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -121,14 +121,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.Random; +import java.util.*; import java.util.function.Function; import javafx.scene.control.ComboBox; import javafx.scene.control.Menu; @@ -136,9 +129,9 @@ /** * @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; @@ -218,16 +211,11 @@ public 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); @@ -246,7 +234,6 @@ public 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); @@ -266,6 +253,22 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public AmbientLight ambientLight; public PointLight pointLight; + // ================= NEW: Processing state & UI ================= + private ComboBox smoothingCombo; + private Spinner smoothingRadiusSpinner; + private Spinner gaussianSigmaSpinner; + private CheckBox enableSmoothingCheck; + + private ComboBox interpCombo; + private SurfaceUtils.Interpolation interpMode = SurfaceUtils.Interpolation.NEAREST; + + private CheckBox enableToneMapCheck; + private ComboBox toneMapCombo; + private Spinner toneParamSpinner; // k (TANH) or gamma (GAMMA) + + private List> originalGrid = new ArrayList<>(); + // ============================================================== + public Hypersurface3DPane(Scene scene) { this.scene = scene; shape3DToCalloutMap = new HashMap<>(); @@ -279,9 +282,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); @@ -292,7 +293,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); @@ -310,17 +311,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); @@ -332,19 +330,16 @@ 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); 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 -> { @@ -357,7 +352,6 @@ public Hypersurface3DPane(Scene scene) { }); subScene.setOnKeyPressed(event -> { - //What key did the user press? KeyCode keycode = event.getCode(); if ((keycode == KeyCode.NUMPAD0 && event.isControlDown()) @@ -368,147 +362,67 @@ public Hypersurface3DPane(Scene scene) { resetView(0, true); } double change = 10.0; - //Add shift modifier to simulate "Running Speed" - 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); - } - - //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 (event.isShiftDown()) change = 100.0; - 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 - cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() - 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); - 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 - cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() - change); + 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.NUMPAD1 || (keycode == KeyCode.DIGIT0)) //roll positive - cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() + change); - if (keycode == KeyCode.NUMPAD3 || (keycode == KeyCode.DIGIT0 && event.isControlDown())) //roll negative - cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() - change); + 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)) 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)) 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; - zFactorIndex -= 1; + xFactorIndex -= 1; yFactorIndex -= 1; zFactorIndex -= 1; 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)); - } - if (redraw) { - updateView(false); - notifyIndexChange(); - } + boolean redraw = true; + if (redraw) { updateView(false); notifyIndexChange(); } updateLabels(); } } 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) { - xFactorIndex += 1; - yFactorIndex += 1; - zFactorIndex += 1; + xFactorIndex += 1; yFactorIndex += 1; zFactorIndex += 1; 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)); - } - if (redraw) { - updateView(false); - notifyIndexChange(); - } + 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; - glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz); - } - if (keycode == KeyCode.K) { - double tz = 5; - if (event.isShiftDown()) - tz = 50; - glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() - tz); - } + if (keycode == KeyCode.I) { double tz = event.isShiftDown()? 50:5; glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz);} + if (keycode == KeyCode.K) { 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(); @@ -516,37 +430,24 @@ 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; - } + double modifier = 50.0; double modifierFactor = 0.1; + 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); @@ -570,11 +471,7 @@ public Hypersurface3DPane(Scene scene) { File file = fileChooser.showSaveDialog(null); if (file != null) { WritableImage image = this.snapshot(new SnapshotParameters(), null); - try { - ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); - } catch (IOException ioe) { - // TODO: handle exception here - } + try { ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); } catch (IOException ioe) { } } }); MenuItem unrollHyperspaceItem = new MenuItem("Unroll Hyperspace Data"); @@ -595,9 +492,7 @@ public Hypersurface3DPane(Scene scene) { try { fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); computeSurfaceDifference(fcf.featureCollection); - } catch (IOException ex) { - LOG.error(null, ex); - } + } catch (IOException ex) { LOG.error(null, ex); } } }); MenuItem cosineSimilarityItem = new MenuItem("Feature Collection Cosine Distance"); @@ -612,25 +507,20 @@ public Hypersurface3DPane(Scene scene) { try { fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); computeCosineDistance(fcf.featureCollection); - } catch (IOException ex) { - LOG.error(null, ex); - } + } catch (IOException ex) { LOG.error(null, ex); } } }); - + 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"); @@ -644,7 +534,6 @@ public Hypersurface3DPane(Scene scene) { } if (!pp.getChildren().contains(surfaceChartPane)) { pp.getChildren().add(surfaceChartPane); - //slideInPane(surfaceChartPane); surfaceChartPane.slideInPane(); } else { surfaceChartPane.show(); @@ -662,6 +551,7 @@ public Hypersurface3DPane(Scene scene) { xWidthSpinner.getValueFactory().setValue(xWidth); zWidthSpinner.getValueFactory().setValue(zWidth); generateRandos(xWidth, zWidth, yScale); + originalGrid = deepCopyGrid(dataGrid); updateTheMesh(); updateView(true); }); @@ -670,14 +560,11 @@ 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)); @@ -685,35 +572,27 @@ public Hypersurface3DPane(Scene scene) { unrollHyperspaceItem, analysisMenu, enableHoverItem, surfaceChartsItem, showDataMarkersItem, enableCrosshairsItem, updateAllItem, clearDataItem, resetViewItem); - cm.setAutoFix(true); - cm.setAutoHide(true); - cm.setHideOnEscape(true); - cm.setOpacity(0.85); + cm.setAutoFix(true); cm.setAutoHide(true); cm.setHideOnEscape(true); cm.setOpacity(0.85); 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 + loadSurf3D(); this.scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { - Color color = (Color) e.object; - subScene.setFill(color); + Color color = (Color) e.object; subScene.setFill(color); }); this.scene.addEventHandler(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, e -> { skybox.setVisible((Boolean) e.object); }); this.scene.addEventHandler(ImageEvent.NEW_TEXTURE_SURFACE, e -> { 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 x1 = 0; int y1 = 0; + 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, @@ -728,16 +607,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; @@ -762,35 +635,15 @@ public Hypersurface3DPane(Scene scene) { if (newFactorMaxIndex < factorMaxIndex) { factorMaxIndex = newFactorMaxIndex; boolean update = false; - if (xFactorIndex > factorMaxIndex) { - xFactorIndex = factorMaxIndex; - update = true; - } - if (yFactorIndex > factorMaxIndex) { - yFactorIndex = factorMaxIndex; - update = true; - } - if (zFactorIndex > factorMaxIndex) { - zFactorIndex = factorMaxIndex; - update = true; - } - if (update) { - updateView(true); - notifyIndexChange(); - } - } else - factorMaxIndex = newFactorMaxIndex; + if (xFactorIndex > factorMaxIndex) { xFactorIndex = factorMaxIndex; update = true; } + if (yFactorIndex > factorMaxIndex) { yFactorIndex = factorMaxIndex; update = true; } + if (zFactorIndex > factorMaxIndex) { zFactorIndex = factorMaxIndex; update = true; } + if (update) { updateView(true); notifyIndexChange(); } + } 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(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); scene.addEventHandler(HyperspaceEvent.REFRESH_RATE_GUI, e -> hypersurfaceRefreshRate = (long) e.object); @@ -799,167 +652,118 @@ 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); updateTheMesh(); }); AnimationTimer surfUpdateAnimationTimer = new AnimationTimer() { - long sleepNs = 0; - long prevTime = 0; - long NANOS_IN_MILLI = 1_000_000; - - @Override - public void handle(long now) { + long sleepNs = 0; long prevTime = 0; long NANOS_IN_MILLI = 1_000_000; + @Override public void handle(long now) { sleepNs = hypersurfaceRefreshRate * NANOS_IN_MILLI; - if ((now - prevTime) < sleepNs) return; - prevTime = now; + if ((now - prevTime) < sleepNs) return; prevTime = now; long startTime; - if (computeRandos) { - generateRandos(xWidth, zWidth, yScale); - } - if (animated || isDirty) { - startTime = System.nanoTime(); - updateTheMesh(); - LOG.info("updateTheMesh(): {}", Utils.totalTimeString(startTime)); - } + if (computeRandos) { generateRandos(xWidth, zWidth, yScale); } + if (animated || isDirty) { startTime = System.nanoTime(); updateTheMesh(); LOG.info("updateTheMesh(): {}", Utils.totalTimeString(startTime)); } } }; surfUpdateAnimationTimer.start(); } - + 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))); + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Feature Collection Cosine Similarity", + cosineDistancesGrid.toArray(Double[]::new))); System.out.println(cosineDistancesGrid.toString()); } + private void applySurfaceGridToHypersurface(List> grid) { - // pick a mode; NORMALIZE_01 is a great default for CDF -// HeightMode mode = HeightMode.NORMALIZE_01; HeightMode mode = heightModeCombo.getValue(); - double userScale = 1.0; //yHeightScaleSpinner.getValue(); // your existing control (0..100) - + double userScale = 1.0; // future: user control List> scaled = DataUtils.normalizeAndScale(grid, mode, userScale); - dataGrid.clear(); dataGrid.addAll(scaled); - - xWidth = dataGrid.get(0).size(); // columns (X) - zWidth = dataGrid.size(); // rows (Y) - + originalGrid = deepCopyGrid(dataGrid); // NEW + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); xWidthSpinner.getValueFactory().setValue(xWidth); zWidthSpinner.getValueFactory().setValue(zWidth); + rebuildProcessedGridAndRefresh(); // NEW: run pipeline + } - updateTheMesh(); - updateView(true); - } public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean flipY) { - // Choose which surface to render List> grid = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); - // Optional: flip Y if your renderer expects Y-up instead of row-major Y-down - if (flipY) { - java.util.Collections.reverse(grid); // reverse row order - } + if (flipY) Collections.reverse(grid); dataGrid.clear(); dataGrid.addAll(grid); - - xWidth = dataGrid.get(0).size(); // columns (X) - zWidth = dataGrid.size(); // rows (Y) - - // keep your existing spinners/mesh update logic + originalGrid = deepCopyGrid(dataGrid); // NEW + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); xWidthSpinner.getValueFactory().setValue(xWidth); zWidthSpinner.getValueFactory().setValue(zWidth); - updateTheMesh(); - updateView(true); + 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); + 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); + 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) { @@ -968,31 +772,18 @@ 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) { ImageView iv = loadImageView(featureVector, featureVector.isBBoxValid()); - iv.setPreserveRatio(true); - iv.setFitWidth(CHIP_FIT_WIDTH); - iv.setFitHeight(CHIP_FIT_WIDTH); - - TitledPane imageTP = new TitledPane(); - imageTP.setContent(iv); - imageTP.setText("Imagery"); - + 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()) { - sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); - } + 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"); - + 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) @@ -1004,19 +795,13 @@ public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubSc infoCallout.setPickOnBounds(false); infoCallout.setManaged(false); addCallout(infoCallout, shape3D); - infoCallout.play().setOnFinished(eh -> { - if (null == featureVector.getImageURL() || featureVector.getImageURL().isBlank()) { - imageTP.setExpanded(false); - } - }); + infoCallout.play().setOnFinished(eh -> { 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); } @@ -1034,31 +819,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); @@ -1073,13 +847,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; @@ -1087,11 +859,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++) { @@ -1100,45 +871,25 @@ public void updatePaintMesh() { texCoords[index] = currX; texCoords[index + 1] = currZ; - // Create faces - p00 = z * numDivX + x; - p01 = p00 + 1; - p10 = p00 + numDivX; - p11 = p10 + 1; - tc00 = z * numDivX + x; - tc01 = tc00 + 1; - tc10 = tc00 + numDivX; - tc11 = tc10 + 1; + p00 = z * numDivX + x; p01 = p00 + 1; p10 = p00 + numDivX; p11 = p10 + 1; + tc00 = z * numDivX + x; tc01 = tc00 + 1; tc10 = tc00 + numDivX; tc11 = tc10 + 1; index = (z * subDivX * faceSize + (x * faceSize)) * 2; - faces[index + 0] = p00; - faces[index + 1] = tc00; - faces[index + 2] = p10; - faces[index + 3] = tc10; - faces[index + 4] = p11; - faces[index + 5] = tc11; - + faces[index + 0] = p00; faces[index + 1] = tc00; faces[index + 2] = p10; faces[index + 3] = tc10; faces[index + 4] = p11; faces[index + 5] = tc11; index += faceSize; - faces[index + 0] = p11; - faces[index + 1] = tc11; - faces[index + 2] = p01; - faces[index + 3] = tc01; - faces[index + 4] = p00; - faces[index + 5] = tc00; + faces[index + 0] = p11; faces[index + 1] = tc11; faces[index + 2] = p01; faces[index + 3] = tc01; faces[index + 4] = p00; faces[index + 5] = tc00; diffusePaintImage.getPixelWriter().setColor(x, z, Color.TRANSPARENT); } } 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); @@ -1147,51 +898,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) { @@ -1209,71 +941,39 @@ public void intro(double milliseconds) { JavaFX3DUtils.zoomTransition(milliseconds, camera, cameraDistance); } - public void outtro(double milliseconds) { - JavaFX3DUtils.zoomTransition(milliseconds, camera, DEFAULT_INTRO_DISTANCE); - } + public void outtro(double milliseconds) { JavaFX3DUtils.zoomTransition(milliseconds, camera, DEFAULT_INTRO_DISTANCE); } - public void updateAll() { - Platform.runLater(() -> { - updateView(true); - }); - } + public void updateAll() { Platform.runLater(() -> updateView(true)); } private void mouseDragCamera(MouseEvent me) { - mouseOldX = mousePosX; - mouseOldY = mousePosY; - mousePosX = me.getSceneX(); - mousePosY = me.getSceneY(); - 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; - } + mouseOldX = mousePosX; mouseOldY = mousePosY; + mousePosX = me.getSceneX(); mousePosY = me.getSceneY(); + mouseDeltaX = (mousePosX - mouseOldX); mouseDeltaY = (mousePosY - mouseOldY); + double modifier = 1.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 x = p2Ditty.getX(); 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 { @@ -1282,103 +982,78 @@ 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")); - } catch (Exception ex) { - iv = new ImageView(ResourceUtils.loadIconFile("noimage")); - } + } else iv = new ImageView(ResourceUtils.loadIconFile("noimage")); + } catch (Exception ex) { iv = new ImageView(ResourceUtils.loadIconFile("noimage")); } return iv; } public void updateView(boolean forcePNodeUpdate) { if (null != surfPlot) { Platform.runLater(() -> { - if (heightChanged) { //if it hasn't changed, don't call expensive height change - 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 + if (heightChanged) { heightChanged = false; } 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) - return lookupPoint(p); - else - return findBlerpHeight(p); - } else - return 0.0; +private Number vertToHeight(Vert3D p) { + 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); } +} 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()); } @@ -1390,14 +1065,13 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, return f12 + (f34 - f12) * yratio; } - int vert; - Point3D vertP3D; + int vert; Point3D vertP3D; 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); @@ -1416,55 +1090,31 @@ 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 (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()); - 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(); - text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); - double maxZ = Arrays.stream(zRay).max(Double::compare).get(); - 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)); + 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(); text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); + double maxZ = Arrays.stream(zRay).max(Double::compare).get(); 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)); } - e.consume(); } }); Glow glow = new Glow(0.8); - double poleHeight = 60; - double radius = 3; + double poleHeight = 60; double radius = 3; glowLineBox = new Box(xWidth * surfScale, poleHeight, radius); glowLineBox.setMaterial(new PhongMaterial(Color.ALICEBLUE.deriveColor(1, 1, 1, 0.2))); glowLineBox.setDrawMode(DrawMode.FILL); @@ -1477,10 +1127,8 @@ private void loadSurf3D() { PhongMaterial eastPoleMaterial = new PhongMaterial(Color.STEELBLUE); PhongMaterial westPoleMaterial = new PhongMaterial(Color.STEELBLUE); PhongMaterial knobMaterial = new PhongMaterial(Color.ALICEBLUE); - eastPole.setMaterial(eastPoleMaterial); - westPole.setMaterial(westPoleMaterial); - eastKnob.setMaterial(knobMaterial); - westKnob.setMaterial(knobMaterial); + eastPole.setMaterial(eastPoleMaterial); westPole.setMaterial(westPoleMaterial); + eastKnob.setMaterial(knobMaterial); westKnob.setMaterial(knobMaterial); eastPole.setTranslateX((xWidth * surfScale) / 2.0); westPole.setTranslateX(-(xWidth * surfScale) / 2.0); eastKnob.setTranslateX((xWidth * surfScale) / 2.0); @@ -1492,54 +1140,36 @@ 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 + 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)); labelGroup.getChildren().addAll(eastLabel, westLabel); - //Add to hashmap so updateLabels() can manage the label position - shape3DToLabel.put(eastKnob, eastLabel); - shape3DToLabel.put(westKnob, westLabel); + 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"); - updateLabels(); - updateCalloutHeadPoints(subScene); + updateLabels(); updateCalloutHeadPoints(subScene); }); 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); - Spinner yScaleSpinner = new Spinner( - new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 500.0, yScale, 1.00)); + 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); updateTheMesh(); }); - //whenever the spinner value is changed... yScaleSpinner.setOnKeyTyped(e -> { if (e.getCode() == KeyCode.ENTER) { yScale = ((Double) yScaleSpinner.getValue()).floatValue(); @@ -1547,12 +1177,10 @@ else if (anchorIndex > dataGrid.size()) updateTheMesh(); } }); - yScaleSpinner.setPrefWidth(125); - Spinner surfScaleSpinner = new Spinner( - new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 100.0, surfScale, 1.0)); + + 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); @@ -1562,21 +1190,9 @@ else if (anchorIndex > dataGrid.size()) 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 = 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()); updateTheMesh(); @@ -1584,10 +1200,9 @@ else if (anchorIndex > dataGrid.size()) surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); xWidthSpinner.setPrefWidth(125); - zWidthSpinner = new Spinner( - new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, 200, 10)); + + 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()); updateTheMesh(); @@ -1595,33 +1210,23 @@ else if (anchorIndex > dataGrid.size()) surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); zWidthSpinner.setPrefWidth(125); - // in constructor / initControls() + heightModeCombo = new ComboBox<>(); heightModeCombo.getItems().addAll(HeightMode.values()); - heightModeCombo.setValue(HeightMode.RAW); // default - - VBox heightControls = new VBox(5, - new Label("Height Mode"), - heightModeCombo - ); + heightModeCombo.setValue(HeightMode.RAW); + VBox heightControls = new VBox(5,new Label("Height Mode"),heightModeCombo); 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); - + 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()); + if (lastImageSource != null) { + 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); @@ -1631,120 +1236,105 @@ else if (anchorIndex > dataGrid.size()) } updateTheMesh(); }); - HBox colorationHBox = new HBox(10, colorByImageRadioButton, - colorByFeatureValueRadioButton, colorByShapleyValueRadioButton); + 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(); - updateTheMesh(); - }); + 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(); 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); - } - }); + 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); }); 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); - } - }); + 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); }); 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()); - ColorPicker specPicker = new ColorPicker(Color.CYAN); - specPicker.setOnAction(e -> { - ((PhongMaterial) surfPlot.getMaterial()).setSpecularColor(specPicker.getValue()); - }); + specPicker.setOnAction(e -> ((PhongMaterial) surfPlot.getMaterial()).setSpecularColor(specPicker.getValue())); 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(); - } + if (enableAmbient.isSelected()) { lightPicker.setDisable(false); ambientLight.getScope().addAll(surfPlot); } + else { lightPicker.setDisable(true); ambientLight.getScope().clear(); } }); 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(); - } + if (enablePoint.isSelected()) { specPicker.setDisable(false); pointLight.getScope().addAll(surfPlot); } + else { specPicker.setDisable(true); pointLight.getScope().clear(); } }); - 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); + // =================== NEW: Processing UI =================== + enableSmoothingCheck = new CheckBox("Enable Smoothing"); enableSmoothingCheck.setSelected(false); + smoothingCombo = new ComboBox<>(); smoothingCombo.getItems().addAll(SurfaceUtils.Smoothing.values()); smoothingCombo.setValue(SurfaceUtils.Smoothing.GAUSSIAN); + smoothingRadiusSpinner = new Spinner<>(1, 25, 2, 1); smoothingRadiusSpinner.setEditable(true); + gaussianSigmaSpinner = new Spinner<>(0.1, 10.0, 1.0, 0.1); gaussianSigmaSpinner.setEditable(true); + + interpCombo = new ComboBox<>(); interpCombo.getItems().addAll(SurfaceUtils.Interpolation.values()); interpCombo.setValue(SurfaceUtils.Interpolation.NEAREST); + + enableToneMapCheck = new CheckBox("Enable Tone Map"); enableToneMapCheck.setSelected(false); + toneMapCombo = new ComboBox<>(); toneMapCombo.getItems().addAll(SurfaceUtils.ToneMap.values()); toneMapCombo.setValue(SurfaceUtils.ToneMap.NONE); + toneParamSpinner = new Spinner<>(0.10, 10.0, 2.0, 0.10); toneParamSpinner.setEditable(true); + + Runnable refreshProc = this::rebuildProcessedGridAndRefresh; + enableSmoothingCheck.setOnAction(e -> refreshProc.run()); + smoothingCombo.setOnAction(e -> refreshProc.run()); + smoothingRadiusSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); + gaussianSigmaSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); + enableToneMapCheck.setOnAction(e -> refreshProc.run()); + toneMapCombo.setOnAction(e -> refreshProc.run()); + toneParamSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); + interpCombo.setOnAction(e -> { interpMode = interpCombo.getValue(); updateTheMesh(); }); + + VBox smoothingBox = new VBox(6, + new Label("Smoothing"), + enableSmoothingCheck, + new HBox(10, new Label("Method"), smoothingCombo), + new HBox(10, new Label("Radius"), smoothingRadiusSpinner), + new HBox(10, new Label("Sigma (Gaussian)"), gaussianSigmaSpinner) + ); + VBox interpBox = new VBox(6, new Label("Interpolation"), new HBox(10, new Label("Mode"), interpCombo)); + VBox toneBox = new VBox(6, + new Label("Tone Mapping"), + enableToneMapCheck, + new HBox(10, new Label("Operator"), toneMapCombo), + new HBox(10, new Label("k / γ"), toneParamSpinner) + ); + // ========================================================== + + 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), - heightControls, + heightControls, + smoothingBox, // NEW + interpBox, // NEW + toneBox, // NEW new HBox(10, surfScaleLabel, surfScaleSpinner), new Label("Color Method"), colorationHBox, @@ -1756,8 +1346,6 @@ else if (anchorIndex > dataGrid.size()) new Label("Ambient Light Color"), enableAmbient, lightPicker, -// new Label("Diffuse Color"), -// diffusePicker, new Label("Specular Color"), enablePoint, specPicker @@ -1767,7 +1355,6 @@ else if (anchorIndex > dataGrid.size()) 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); @@ -1777,20 +1364,15 @@ else if (anchorIndex > dataGrid.size()) } 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()) { - sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); - } + 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()); } @@ -1811,27 +1393,23 @@ private void addDebugPoint(Point3D point3D) { } public void clearAll() { - xFactorIndex = 0; - yFactorIndex = 1; - zFactorIndex = 2; + xFactorIndex = 0; yFactorIndex = 1; zFactorIndex = 2; Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); 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() { - updateView(true); - } + public void showAll() { updateView(true); } public void hideFA3D() { Timeline timeline = new Timeline( @@ -1847,8 +1425,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)), @@ -1857,80 +1434,19 @@ public void showFA3D() { timeline.playFromStart(); } - @Override - public void setFeatureCollection(FeatureCollection fc) { - featureVectors = fc.getFeatures(); - } + @Override public void setFeatureCollection(FeatureCollection fc) { featureVectors = fc.getFeatures(); } 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(); - } - 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(); - } - 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(); - } - 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(); - } - 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(); - } - 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(); - } - break; - } + case DBSCAN -> { DBSCANClusterTask t = new DBSCANClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } + case HDDBSCAN -> { HDDBSCANClusterTask t = new HDDBSCANClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } + case KMEANS -> { KMeansClusterTask t = new KMeansClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } + case KMEDIODS -> { KMediodsClusterTask t = new KMediodsClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } + case EX_MAX -> { ExMaxClusterTask t = new ExMaxClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } + case AFFINITY -> { AffinityClusterTask t = new AffinityClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } } } @@ -1940,15 +1456,12 @@ 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)); @@ -1956,7 +1469,8 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection xWidth = neuralData.get(0).size() / 2; zWidthSpinner.getValueFactory().setValue(zWidth); xWidthSpinner.getValueFactory().setValue(xWidth); - updateTheMesh(); + originalGrid = deepCopyGrid(dataGrid); // NEW + rebuildProcessedGridAndRefresh(); // NEW xSphere.setTranslateX((xWidth * surfScale) / 2.0); zSphere.setTranslateZ((zWidth * surfScale) / 2.0); @@ -1977,33 +1491,14 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection updateLabels(); } - @Override - public void addSemanticMap(SemanticMap semanticMap) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public SemanticMap getSemanticMap(long id) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void locateSemanticMap(SemanticMap semanticMap) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void clearSemanticMaps() { - throw new UnsupportedOperationException("Not supported yet."); - } + @Override public void addSemanticMap(SemanticMap semanticMap) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public SemanticMap getSemanticMap(long id) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void locateSemanticMap(SemanticMap semanticMap) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void clearSemanticMaps() { throw new UnsupportedOperationException("Not supported yet."); } @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()); @@ -2014,106 +1509,44 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl 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()); - featureVectors = featureCollection.getFeatures(); - } - - @Override - public void addFeatureVector(FeatureVector featureVector) { - featureVectors.add(featureVector); - dataGrid.add(featureVector.getData()); - } - - @Override - public void locateFeatureVector(FeatureVector featureVector) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void clearFeatureVectors() { - featureVectors.clear(); - dataGrid.clear(); - } - - @Override - public List getAllFeatureVectors() { - if (null == featureVectors) - return Collections.EMPTY_LIST; - return featureVectors; - } + originalGrid = deepCopyGrid(dataGrid); // NEW + rebuildProcessedGridAndRefresh(); // NEW - @Override - public void setColorByID(String iGotID, Color color) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void setColorByIndex(int i, Color color) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void setVisibleByIndex(int i, boolean b) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void refresh() { - refresh(true); - } - - @Override - public void refresh(boolean forceNodeUpdate) { - updateTheMesh(); - } - - @Override - public void setDimensionLabels(List labelStrings) { - featureLabels = labelStrings; + getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); + featureVectors = featureCollection.getFeatures(); } - @Override - public void setSpheroidAnchor(boolean animate, int index) { - double z = index * surfScale; -// int column = Float.valueOf(vertP3D.getX() / surfScale).intValue(); - } + @Override public void addFeatureVector(FeatureVector featureVector) { featureVectors.add(featureVector); dataGrid.add(featureVector.getData()); originalGrid = deepCopyGrid(dataGrid); rebuildProcessedGridAndRefresh(); } + @Override public void locateFeatureVector(FeatureVector featureVector) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); originalGrid.clear(); } + @Override public List getAllFeatureVectors() { if (null == featureVectors) return Collections.EMPTY_LIST; return featureVectors; } + @Override public void setColorByID(String iGotID, Color color) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void setColorByIndex(int i, Color color) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void setVisibleByIndex(int i, boolean b) { throw new UnsupportedOperationException("Not supported yet."); } + @Override public void refresh() { refresh(true); } + @Override public void refresh(boolean forceNodeUpdate) { updateTheMesh(); } + @Override public void setDimensionLabels(List labelStrings) { featureLabels = labelStrings; } + @Override public void setSpheroidAnchor(boolean animate, int index) { double z = index * surfScale; } 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(); + Color color = null; int rgb, r, g, b = 0; double dataValue = 0; + 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); - r = (rgb >> 16) & 0xFF; - g = (rgb >> 8) & 0xFF; - b = rgb & 0xFF; + 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; dataValue = (((r + g + b) / 3.0) / 255.0); fv.getData().set(2, dataValue); featureVectors.add(fv); @@ -2124,11 +1557,11 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { Utils.printTotalTime(startTime); LOG.info("Injecting Mesh into Hypersurface... "); startTime = System.nanoTime(); - zWidth = rows; - xWidth = columns; + zWidth = rows; xWidth = columns; zWidthSpinner.getValueFactory().setValue(zWidth); xWidthSpinner.getValueFactory().setValue(xWidth); - updateTheMesh(); + originalGrid = deepCopyGrid(dataGrid); // NEW + rebuildProcessedGridAndRefresh(); // NEW xSphere.setTranslateX((xWidth * surfScale) / 2.0); zSphere.setTranslateZ((zWidth * surfScale) / 2.0); Utils.printTotalTime(startTime); @@ -2140,42 +1573,57 @@ 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); default -> surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); } } - } catch (IOException ex) { - LOG.error(null, ex); - } + } catch (IOException ex) { LOG.error(null, ex); } } - @Override - public void addShapleyVector(ShapleyVector shapleyVector) { - shapleyVectors.add(shapleyVector); + @Override public void addShapleyVector(ShapleyVector shapleyVector) { shapleyVectors.add(shapleyVector); } + @Override public void clearShapleyVectors() { shapleyVectors.clear(); } + + // ================= NEW: 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; } - @Override - public void clearShapleyVectors() { - shapleyVectors.clear(); + private void rebuildProcessedGridAndRefresh() { + if (originalGrid == null || originalGrid.isEmpty()) return; + List> g = deepCopyGrid(originalGrid); + if (enableSmoothingCheck != null && enableSmoothingCheck.isSelected()) { + SurfaceUtils.Smoothing sm = smoothingCombo.getValue(); + int radius = smoothingRadiusSpinner.getValue(); + double sigma = gaussianSigmaSpinner.getValue(); + g = SurfaceUtils.smooth(g, sm, sigma, radius); + } + if (enableToneMapCheck != null && enableToneMapCheck.isSelected()) { + SurfaceUtils.ToneMap tm = toneMapCombo.getValue(); + double param = toneParamSpinner.getValue(); + g = SurfaceUtils.toneMapGrid(g, tm, param); + } + dataGrid.clear(); dataGrid.addAll(g); + xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); + xWidthSpinner.getValueFactory().setValue(xWidth); + zWidthSpinner.getValueFactory().setValue(zWidth); + updateTheMesh(); updateView(true); } + // ========================================================================= } 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..e747a4c9 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java @@ -0,0 +1,400 @@ +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/utils/statistics/DialogUtils.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java index 76101391..4678ad19 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java @@ -2,12 +2,25 @@ import java.util.ArrayList; import java.util.List; +import java.util.StringJoiner; +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; /** - * Small utility dialog helpers used by statistics panels. + * 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 */ @@ -15,48 +28,246 @@ 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); + } + /** - * Shows a simple dialog to collect a custom reference vector. - * Accepts numbers separated by commas, spaces, or newlines. - * - * @return a list of parsed doubles, or null if the user canceled/empty. + * 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() { - Dialog> dialog = new Dialog<>(); - dialog.setTitle("Set Custom Reference Vector"); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); - - TextArea ta = new TextArea(); - ta.setPromptText("Paste numbers separated by commas or spaces, e.g.\n0.12, -1.3, 2.5, 0.0"); - ta.setPrefRowCount(6); - dialog.getDialogPane().setContent(ta); - - dialog.setResultConverter(bt -> { - if (bt == ButtonType.OK) { - String text = ta.getText(); - return parseDoubles(text); - } - return null; - }); + 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; - return dialog.showAndWait().orElse(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 } /** - * Parses doubles from a free-form string (commas/spaces/newlines). + * 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 List parseDoubles(String s) { - if (s == null || s.isBlank()) return null; - String norm = s.replaceAll("[\\n\\t]", " ").replace(",", " "); - String[] parts = norm.trim().split("\\s+"); - List out = new ArrayList<>(parts.length); - for (String p : parts) { - try { - out.add(Double.parseDouble(p)); - } catch (NumberFormatException ignore) { - // skip tokens that aren't valid doubles + 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.isEmpty() ? null : out; + 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/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index bee21d9c..067581ca 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -1,25 +1,14 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -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 javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; -/** - * LineChart that renders EITHER PDF-only or CDF-only for a chosen scalar type. - * Use two instances if you want separate axes (one for PDF, one for CDF). - * - * Supports: - * - METRIC_DISTANCE_TO_MEAN via metric name and reference vector - * - COMPONENT_AT_DIMENSION via componentIndex - * - * @author Sean Phillips - */ public class StatPdfCdfChart extends LineChart { public enum Mode { PDF_ONLY, CDF_ONLY } @@ -36,6 +25,18 @@ public enum Mode { PDF_ONLY, CDF_ONLY } // 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; + + // --- NEW: raw-sample override for 2D charts --- + private List scalarSamples = null; // if non-null & non-empty, use this instead of vectors + public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mode) { super(new NumberAxis(), new NumberAxis()); this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; @@ -51,13 +52,14 @@ public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mod setAnimated(false); setLegendVisible(false); - setCreateSymbols(false); - setTitle((this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType); + setCreateSymbols(showSymbols); + setTitle(titleForCurrentState()); } + // ---- configuration (unchanged) ---- public void setScalarType(StatisticEngine.ScalarType scalarType) { this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; - setTitle((this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType); + setTitle(titleForCurrentState()); refresh(); } @@ -70,7 +72,7 @@ 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((this.mode == Mode.PDF_ONLY ? "PDF" : "CDF") + " • " + this.scalarType); + setTitle(titleForCurrentState()); refresh(); } @@ -103,39 +105,136 @@ public void setComponentIndex(Integer 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;"); + } + }); + } + + // ---- NEW: 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(); - if (vectors == null || vectors.isEmpty()) return; - Set types = Set.of(scalarType); + 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); - StatisticResult stat = resultMap.get(scalarType); - if (stat == null) return; + plotFromStatistic(resultMap.get(scalarType)); + } + private void plotFromStatistic(StatisticResult 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(); - for (int i = 0; i < x.length; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); + 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(); - for (int i = 0; i < x.length; i++) series.getData().add(new XYChart.Data<>(x[i], y[i])); + 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(); } - 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; } + 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); + } + } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index a6b23cfc..2a2b1156 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -2,12 +2,14 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.metric.Metric; +import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.RadioButton; @@ -23,44 +25,64 @@ * Chart panel for visualizing PDF and CDF statistics (2D) and * triggering a 3D joint PDF/CDF surface computation. * - * Minimal additions: - * - Y feature + Y index - * - "Compute 3D Surface" button + * Adds "Precomputed Scalars" data source support: + * - Paste rows of "score" or "score, infoPercent" (both in [0,1]) + * - Choose which field (Score or Info%) to visualize + * - Axis lock control to fix X to [0,1] for consistent comparisons * - * 2D charts still use X feature controls (existing). + * Vectors mode remains unchanged; 3D surface is enabled only in Vectors mode. * * @author Sean Phillips */ public class StatPdfCdfChartPanel extends BorderPane { + // Charts private StatPdfCdfChart pdfChart; private StatPdfCdfChart cdfChart; - // Existing X-feature controls + // Data source mode + private enum DataSource { VECTORS, SCALARS } + private ComboBox dataSourceCombo; + + // --- Scalars mode state/UI --- + private List scalarScores = new ArrayList<>(); + private List scalarInfos = new ArrayList<>(); + private ComboBox scalarFieldCombo; // "Score" or "Info%" + private Button pasteScalarsButton; + private Button clearScalarsButton; + + // Axis lock (both modes) + private CheckBox lockXCheck; // lock to [0,1] + + // --- Vectors mode controls/state --- private ComboBox xFeatureCombo; private Spinner binsSpinner; - // Metric/reference shared with X (unchanged) private ComboBox metricCombo; private ComboBox referenceCombo; // "Mean", "Vector @ Index", "Custom" private Spinner xIndexSpinner; private Label xIndexLabel; - // Y-feature (new, minimal) + // Y-feature (for 3D) private ComboBox yFeatureCombo; private Spinner yIndexSpinner; private Label yIndexLabel; - private RadioButton pdfRadio; - private RadioButton cdfRadio; - private ToggleGroup surfaceToggle; - - // Custom vector storage (used if X uses Custom) - private List customReferenceVector; + private RadioButton pdfRadio3D; + private RadioButton cdfRadio3D; + private ToggleGroup surfaceToggle; + private Button refresh2DButton; + private Button popOutButton; + private Button setCustomButton; + private Button compute3DButton; + + // Data/state private List currentVectors = new ArrayList<>(); private StatisticEngine.ScalarType currentXFeature = StatisticEngine.ScalarType.L1_NORM; private int currentBins = 40; + private List customReferenceVector; + // Callbacks private Runnable onPopOut = null; private Consumer onComputeSurface = null; @@ -75,7 +97,48 @@ public StatPdfCdfChartPanel(List initialVectors, if (initialType != null) currentXFeature = initialType; if (initialBins > 0) currentBins = initialBins; - // ===== Row 1: X feature + bins + 2D actions ===== + // ===== Row 0: Data source + Axis Lock ===== + dataSourceCombo = new ComboBox<>(); + dataSourceCombo.getItems().addAll(DataSource.VECTORS, DataSource.SCALARS); + dataSourceCombo.setValue(DataSource.VECTORS); + + lockXCheck = new CheckBox("Lock X to [0,1]"); + lockXCheck.setSelected(false); + lockXCheck.selectedProperty().addListener((obs, ov, nv) -> { + if (nv) { + if (pdfChart != null) pdfChart.setLockXAxis(true, 0.0, 1.0); + if (cdfChart != null) cdfChart.setLockXAxis(true, 0.0, 1.0); + } else { + if (pdfChart != null) pdfChart.setLockXAxis(false, 0.0, 1.0); + if (cdfChart != null) cdfChart.setLockXAxis(false, 0.0, 1.0); + } + }); + + HBox row0 = new HBox(16, + new VBox(5, new Label("Data Source"), dataSourceCombo), + new VBox(18, new Label(""), lockXCheck) + ); + row0.setAlignment(Pos.CENTER_LEFT); + row0.setPadding(new Insets(6, 6, 0, 6)); + + // ===== Row 1: Scalars controls (hidden in Vectors mode) ===== + scalarFieldCombo = new ComboBox<>(); + scalarFieldCombo.getItems().addAll("Score", "Info%"); + scalarFieldCombo.setValue("Score"); + + pasteScalarsButton = new Button("Paste Scalars…"); + clearScalarsButton = new Button("Clear Scalars"); + + HBox rowScalars = new HBox( + 16, + new VBox(5, new Label("Field (2D)"), scalarFieldCombo), + pasteScalarsButton, + clearScalarsButton + ); + rowScalars.setAlignment(Pos.CENTER_LEFT); + rowScalars.setPadding(new Insets(4, 6, 0, 6)); + + // ===== Row 1 (Vectors): X feature + bins + actions ===== xFeatureCombo = new ComboBox<>(); xFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); xFeatureCombo.setValue(currentXFeature); @@ -84,20 +147,20 @@ public StatPdfCdfChartPanel(List initialVectors, binsSpinner.setPrefWidth(100); binsSpinner.setPrefHeight(40); - Button refreshButton = new Button("Refresh 2D"); - Button popOutButton = new Button("Pop Out"); + refresh2DButton = new Button("Refresh 2D"); + popOutButton = new Button("Pop Out"); - HBox row1 = new HBox( + HBox row1Vectors = new HBox( 16, new VBox(5, new Label("X Feature (2D)"), xFeatureCombo), new VBox(5, new Label("Bins"), binsSpinner), - refreshButton, + refresh2DButton, popOutButton ); - row1.setAlignment(Pos.CENTER_LEFT); - row1.setPadding(new Insets(6, 6, 0, 6)); + row1Vectors.setAlignment(Pos.CENTER_LEFT); + row1Vectors.setPadding(new Insets(6, 6, 0, 6)); - // ===== Row 2: X metric/ref/index ===== + // ===== Row 2 (Vectors): X metric/ref/index ===== metricCombo = new ComboBox<>(); metricCombo.getItems().addAll(Metric.getMetricNames()); if (metricCombo.getItems().contains("euclidean")) metricCombo.setValue("euclidean"); @@ -111,19 +174,19 @@ public StatPdfCdfChartPanel(List initialVectors, xIndexSpinner = new Spinner<>(); xIndexSpinner.setPrefWidth(100); - Button setCustomButton = new Button("Set Custom Vector…"); + setCustomButton = new Button("Set Custom Vector…"); - HBox row2 = new HBox( + HBox row2Vectors = new HBox( 16, new VBox(5, new Label("Metric (X)"), metricCombo), new VBox(5, new Label("Reference (X)"), referenceCombo), new VBox(5, xIndexLabel, xIndexSpinner), setCustomButton ); - row2.setAlignment(Pos.CENTER_LEFT); - row2.setPadding(new Insets(4, 6, 0, 6)); + row2Vectors.setAlignment(Pos.CENTER_LEFT); + row2Vectors.setPadding(new Insets(4, 6, 0, 6)); - // ===== Row 3: Y feature + Y index + 3D action ===== + // ===== Row 3 (Vectors): Y feature + Y index + 3D surface ===== yFeatureCombo = new ComboBox<>(); yFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); yFeatureCombo.setValue(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); @@ -133,35 +196,36 @@ public StatPdfCdfChartPanel(List initialVectors, yIndexSpinner.setPrefWidth(100); surfaceToggle = new ToggleGroup(); + pdfRadio3D = new RadioButton("PDF"); + pdfRadio3D.setToggleGroup(surfaceToggle); + pdfRadio3D.setSelected(true); + cdfRadio3D = new RadioButton("CDF"); + cdfRadio3D.setToggleGroup(surfaceToggle); - pdfRadio = new RadioButton("PDF"); - pdfRadio.setToggleGroup(surfaceToggle); - pdfRadio.setSelected(true); // default - - cdfRadio = new RadioButton("CDF"); - cdfRadio.setToggleGroup(surfaceToggle); - - HBox surfaceBox = new HBox(8, pdfRadio, cdfRadio); + HBox surfaceBox = new HBox(8, pdfRadio3D, cdfRadio3D); surfaceBox.setAlignment(Pos.CENTER_LEFT); - Button compute3DButton = new Button("Compute 3D Surface"); - - HBox row3 = new HBox( + compute3DButton = new Button("Compute 3D Surface"); + + HBox row3Vectors = new HBox( 16, new VBox(5, new Label("Y Feature (3D)"), yFeatureCombo), new VBox(5, yIndexLabel, yIndexSpinner), new VBox(5, new Label("Surface"), surfaceBox), compute3DButton ); - - row3.setAlignment(Pos.CENTER_LEFT); - row3.setPadding(new Insets(4, 6, 6, 6)); - - HBox.setHgrow(row1, Priority.ALWAYS); - HBox.setHgrow(row2, Priority.ALWAYS); - HBox.setHgrow(row3, Priority.ALWAYS); - - VBox controlBar = new VBox(row1, row2, row3); + row3Vectors.setAlignment(Pos.CENTER_LEFT); + row3Vectors.setPadding(new Insets(4, 6, 6, 6)); + + // Grow hints + HBox.setHgrow(row0, Priority.ALWAYS); + HBox.setHgrow(rowScalars, Priority.ALWAYS); + HBox.setHgrow(row1Vectors, Priority.ALWAYS); + HBox.setHgrow(row2Vectors, Priority.ALWAYS); + HBox.setHgrow(row3Vectors, Priority.ALWAYS); + + // Control area (we toggle rows depending on data source) + VBox controlBar = new VBox(row0, rowScalars, row1Vectors, row2Vectors, row3Vectors); controlBar.setPadding(new Insets(0)); // ===== Charts: PDF (top), CDF (bottom) ===== @@ -174,20 +238,78 @@ public StatPdfCdfChartPanel(List initialVectors, cdfChart.setFeatureVectors(currentVectors); } + if (lockXCheck.isSelected()) { + pdfChart.setLockXAxis(true, 0.0, 1.0); + cdfChart.setLockXAxis(true, 0.0, 1.0); + } + VBox chartsBox = new VBox(8, pdfChart, cdfChart); chartsBox.setPadding(new Insets(6)); - VBox.setVgrow(pdfChart, javafx.scene.layout.Priority.ALWAYS); - VBox.setVgrow(cdfChart, javafx.scene.layout.Priority.ALWAYS); + VBox.setVgrow(pdfChart, Priority.ALWAYS); + VBox.setVgrow(cdfChart, Priority.ALWAYS); setTop(controlBar); setCenter(chartsBox); // ===== Handlers ===== - refreshButton.setOnAction(e -> refresh2DCharts()); + + // Data source toggle + dataSourceCombo.valueProperty().addListener((obs, ov, nv) -> { + boolean usingScalars = (nv == DataSource.SCALARS); + rowScalars.setManaged(usingScalars); + rowScalars.setVisible(usingScalars); + + row1Vectors.setManaged(!usingScalars); + row1Vectors.setVisible(!usingScalars); + row2Vectors.setManaged(!usingScalars); + row2Vectors.setVisible(!usingScalars); + row3Vectors.setManaged(!usingScalars); + row3Vectors.setVisible(!usingScalars); + + if (usingScalars) { + // Switch charts to raw scalar mode + applyScalarSamplesToCharts(); + // Disable 3D compute in scalars mode + compute3DButton.setDisable(true); + } else { + // Back to Vectors mode + pdfChart.clearScalarSamples(); + cdfChart.clearScalarSamples(); + compute3DButton.setDisable(false); + refresh2DCharts(); + } + }); + // Initialize visibility (Vectors by default) + rowScalars.setManaged(false); + rowScalars.setVisible(false); + + // Scalars handlers + pasteScalarsButton.setOnAction(e -> { + ScalarInputResult res = DialogUtils.showScalarSamplesDialog(); + if (res == null) return; // cancelled + if (res.scores != null && !res.scores.isEmpty()) scalarScores = res.scores; + if (res.infos != null && !res.infos.isEmpty()) scalarInfos = res.infos; + applyScalarSamplesToCharts(); + }); + clearScalarsButton.setOnAction(e -> { + scalarScores.clear(); + scalarInfos.clear(); + pdfChart.clearScalarSamples(); + cdfChart.clearScalarSamples(); + }); + scalarFieldCombo.valueProperty().addListener((obs, ov, nv) -> applyScalarSamplesToCharts()); + + // Vectors handlers + refresh2DButton.setOnAction(e -> refresh2DCharts()); popOutButton.setOnAction(e -> { if (onPopOut != null) onPopOut.run(); }); setCustomButton.setOnAction(e -> { - List parsed = DialogUtils.showCustomVectorDialog(); + // Optional: enforce expected dimension + 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); }); @@ -207,7 +329,7 @@ public StatPdfCdfChartPanel(List initialVectors, updateYIndexBoundsAndLabel(); }); - // initialize control states + // initialize states updateXControlEnablement(); updateYControlEnablement(); updateXIndexBoundsAndLabel(); @@ -215,23 +337,19 @@ public StatPdfCdfChartPanel(List initialVectors, } // ----------------- Public API ----------------- + public boolean isSurfaceCDF() { - return cdfRadio.isSelected(); + return cdfRadio3D.isSelected(); } - /** - * Get a human-friendly description of the current Y feature type. - * Includes the component index if the Y feature is COMPONENT_AT_DIMENSION. - */ + public String getYFeatureTypeForDisplay() { - if (yFeatureCombo == null) { - return "N/A"; - } + 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"; + return (type != null) ? type.name() : "N/A"; } public void setOnPopOut(Runnable handler) { this.onPopOut = handler; } @@ -245,9 +363,11 @@ public void setFeatureVectors(List vectors) { this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); updateXIndexBoundsAndLabel(); updateYIndexBoundsAndLabel(); - applyXFeatureOptions(); - pdfChart.setFeatureVectors(this.currentVectors); - cdfChart.setFeatureVectors(this.currentVectors); + if (dataSourceCombo.getValue() == DataSource.VECTORS) { + applyXFeatureOptions(); + pdfChart.setFeatureVectors(this.currentVectors); + cdfChart.setFeatureVectors(this.currentVectors); + } } public void addFeatureVectors(List newVectors) { @@ -257,9 +377,11 @@ public void addFeatureVectors(List newVectors) { updateXIndexBoundsAndLabel(); updateYIndexBoundsAndLabel(); - applyXFeatureOptions(); - pdfChart.addFeatureVectors(newVectors); - cdfChart.addFeatureVectors(newVectors); + if (dataSourceCombo.getValue() == DataSource.VECTORS) { + applyXFeatureOptions(); + pdfChart.addFeatureVectors(newVectors); + cdfChart.addFeatureVectors(newVectors); + } } public int getBins() { return binsSpinner.getValue(); } @@ -269,7 +391,8 @@ public void addFeatureVectors(List newVectors) { public void setCustomReferenceVector(List customVector) { this.customReferenceVector = customVector; - if (xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + if (dataSourceCombo.getValue() == DataSource.VECTORS + && xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN && "Custom".equals(referenceCombo.getValue()) && !currentVectors.isEmpty()) { applyXFeatureOptions(); @@ -278,9 +401,32 @@ public void setCustomReferenceVector(List customVector) { } } - // ----------------- 2D (X feature) behavior ----------------- + // ----------------- Scalars helpers ----------------- + + private void applyScalarSamplesToCharts() { + if (dataSourceCombo.getValue() != DataSource.SCALARS) return; + + List chosen = "Info%".equals(scalarFieldCombo.getValue()) ? scalarInfos : scalarScores; + + if (chosen == null || chosen.isEmpty()) { + pdfChart.clearScalarSamples(); + cdfChart.clearScalarSamples(); + return; + } + pdfChart.setScalarSamples(chosen); + cdfChart.setScalarSamples(chosen); + + // Respect lock checkbox + boolean lock = lockXCheck.isSelected(); + pdfChart.setLockXAxis(lock, 0.0, 1.0); + cdfChart.setLockXAxis(lock, 0.0, 1.0); + } + + // ----------------- 2D (Vectors) behavior ----------------- private void refresh2DCharts() { + if (dataSourceCombo.getValue() != DataSource.VECTORS) return; + currentXFeature = xFeatureCombo.getValue(); currentBins = binsSpinner.getValue(); @@ -296,6 +442,10 @@ private void refresh2DCharts() { pdfChart.setFeatureVectors(currentVectors); cdfChart.setFeatureVectors(currentVectors); } + + boolean lock = lockXCheck.isSelected(); + pdfChart.setLockXAxis(lock, 0.0, 1.0); + cdfChart.setLockXAxis(lock, 0.0, 1.0); } private void applyXFeatureOptions() { @@ -357,12 +507,12 @@ private void updateXIndexBoundsAndLabel() { } } - // ----------------- 3D compute (new) ----------------- + // ----------------- 3D compute (Vectors only) ----------------- private void compute3DSurface() { if (onComputeSurface == null || currentVectors == null || currentVectors.isEmpty()) return; + if (dataSourceCombo.getValue() != DataSource.VECTORS) return; - // Build X axis params from existing controls AxisParams xAxis = new AxisParams(); xAxis.setType(xFeatureCombo.getValue()); if (xAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { @@ -377,7 +527,6 @@ private void compute3DSurface() { xAxis.setComponentIndex(xIndexSpinner.getValue()); } - // Build Y axis params (minimal: support component index and mirror metric/ref if used) AxisParams yAxis = new AxisParams(); yAxis.setType(yFeatureCombo.getValue()); if (yAxis.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { @@ -392,6 +541,7 @@ private void compute3DSurface() { } yAxis.setReferenceVec(ref); } + int bins = binsSpinner.getValue(); GridSpec grid = new GridSpec(bins, bins); GridDensityResult result = GridDensity3DEngine.computePdfCdf2D(currentVectors, xAxis, yAxis, grid); From 435a1e48da574c9fca432709d37da4c024b89d07 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sat, 13 Sep 2025 16:21:11 -0400 Subject: [PATCH 10/50] CyberReport processing updated to match new dynamic formatting. --- .../trinity/data/files/CyberReporterFile.java | 13 ++- .../data/messages/xai/CyberReport.java | 55 ++++++----- .../data/messages/xai/CyberReportIO.java | 95 +++++++++++++++++++ .../data/messages/xai/CyberVector.java | 14 ++- .../handlers/FeatureVectorEventHandler.java | 64 +++++++------ .../jhuapl/trinity/utils/ResourceUtils.java | 2 +- 6 files changed, 179 insertions(+), 64 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java diff --git a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java index 91d93a0e..8a31ac02 100644 --- a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java +++ b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java @@ -3,16 +3,18 @@ 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 CyberReport cyberReport = null; + public List cyberReports = null; public CyberReporterFile(String pathname) { super(pathname); @@ -48,16 +50,17 @@ public void parseContent() throws IOException { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); String message = Files.readString(this.toPath()); - cyberReport = mapper.readValue(message, CyberReport.class); + // From a string: + cyberReports = CyberReportIO.readReports(message); } @Override public void writeContent() throws IOException { - if (null != cyberReport) { + if (null != cyberReports) { ObjectMapper mapper = new ObjectMapper(); //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - mapper.writeValue(this, cyberReport); - LOG.info("CyberReport serialized to file."); + mapper.writeValue(this, cyberReports); + LOG.info("CyberReports serialized to file."); } } } \ No newline at end of file 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 index 1c6e5682..c12750ec 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java @@ -2,9 +2,11 @@ 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; @@ -12,15 +14,19 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class CyberReport { - public final static String GROUNDTRUTH = "Ground Truth"; - public final static String INFERENCES = "Inferences"; - public final static String ADJACENTNETWORK = "Adjacent Network"; - public final static String MOD = "Mod"; - public final static String SGTA = "S(GT, A)"; - public final static String SINTELGT = "S(intel, GT)"; - public final static String SINTELA = "S(intel, A)"; - public final static String SINFGT = "S(inf, GT)"; - public final static String DELTA = "delta"; + 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(GROUNDTRUTH) @@ -33,7 +39,7 @@ public class CyberReport { private List inferences = new ArrayList<>(); @JsonProperty(MOD) - private List mod = new ArrayList<>(); + private List mod = new ArrayList<>(); // ----- score vectors (known names) ----- @JsonProperty(SGTA) @@ -52,20 +58,20 @@ public class CyberReport { @JsonProperty(DELTA) private CyberVector delta; - // ----- catch-all for any other S(... ) vectors so you can treat them like a list ----- + // ----- catch-all for any other S(... ) vectors ----- @JsonIgnore // avoid double-serializing; we expose via @JsonAnyGetter below private final Map extraVectors = new LinkedHashMap<>(); public static boolean isCyberReport(String body) { - return body.contains("Ground Truth") && body.contains("Adjacent Network") - && body.contains("Inferences"); + 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. - * This is handy if future files add more S(?, ?) blocks without changing this class. - * @param name - * @param vector */ @JsonAnySetter public void putUnknown(String name, CyberVector vector) { @@ -75,23 +81,20 @@ public void putUnknown(String name, CyberVector vector) { } } - /** - * When serializing back to JSON, include the extra S(...) vectors naturally. - * @return - */ + /** 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 if you want "a list of CyberVector" ----- + // ----- convenience: expose all vectors (known + extra) as a collection ----- @JsonIgnore public List getAllVectors() { List all = new ArrayList<>(); - if (sGtA != null) all.add(sGtA); + if (sGtA != null) all.add(sGtA); if (sIntelGt != null) all.add(sIntelGt); - if (sIntelA != null) all.add(sIntelA); - if (sInfGt != null) all.add(sInfGt); + if (sIntelA != null) all.add(sIntelA); + if (sInfGt != null) all.add(sInfGt); all.addAll(extraVectors.values()); return all; } @@ -106,8 +109,8 @@ public List getAllVectors() { 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 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; } 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..d063f7f5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java @@ -0,0 +1,95 @@ +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 index 20a68b42..5f561553 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java @@ -3,11 +3,12 @@ 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.HashMap; import java.util.List; -import java.util.UUID; import java.util.function.Function; /** @@ -130,7 +131,14 @@ public CyberVector() { 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; } 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 55d760a8..eaceabdc 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java @@ -1,5 +1,6 @@ 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; @@ -51,9 +52,18 @@ public FeatureVectorEventHandler() { public void addFeatureVectorRenderer(FeatureVectorRenderer renderer) { renderers.add(renderer); } - public static FeatureVector cyberToFeatureVector(CyberVector cyberVector) { + 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; } @@ -89,38 +99,34 @@ private void addNewFeatureVector(FeatureVector featureVector) { } } public void handleCyberReport(FeatureVectorEvent event) { - CyberReport cyberReport = (CyberReport) event.object; - 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()); + List cyberReports = (List) event.object; + 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.getsGtA()); - sgtaFV.setLabel(CyberReport.SGTA); - sgtaFV.getMetaData().putAll(metaData); - addNewFeatureVector(sgtaFV); - - FeatureVector sinfgtFV = cyberToFeatureVector(cyberReport.getsInfGt()); - sinfgtFV.setLabel(CyberReport.SINFGT); - sinfgtFV.getMetaData().putAll(metaData); - addNewFeatureVector(sinfgtFV); + FeatureVector sgtaFV = cyberToFeatureVector(CyberReport.SGTA, cyberReport.getsGtA()); + sgtaFV.getMetaData().putAll(metaData); + addNewFeatureVector(sgtaFV); - FeatureVector sintelaFV = cyberToFeatureVector(cyberReport.getsIntelA()); - sintelaFV.setLabel(CyberReport.SINTELA); - sintelaFV.getMetaData().putAll(metaData); - addNewFeatureVector(sintelaFV); + FeatureVector sinfgtFV = cyberToFeatureVector(CyberReport.SINFGT, cyberReport.getsInfGt()); + sinfgtFV.getMetaData().putAll(metaData); + addNewFeatureVector(sinfgtFV); - FeatureVector sintelgtFV = cyberToFeatureVector(cyberReport.getsIntelGt()); - sintelgtFV.setLabel(CyberReport.SINTELGT); - sintelgtFV.getMetaData().putAll(metaData); - addNewFeatureVector(sintelgtFV); + FeatureVector sintelaFV = cyberToFeatureVector(CyberReport.SINTELA, cyberReport.getsIntelA()); + sintelaFV.getMetaData().putAll(metaData); + addNewFeatureVector(sintelaFV); - FeatureVector deltaFV = cyberToFeatureVector(cyberReport.getDelta()); - deltaFV.setLabel(CyberReport.DELTA); - deltaFV.getMetaData().putAll(metaData); - addNewFeatureVector(deltaFV); - + FeatureVector sintelgtFV = cyberToFeatureVector(CyberReport.SINTELGT, cyberReport.getsIntelGt()); + sintelgtFV.getMetaData().putAll(metaData); + addNewFeatureVector(sintelgtFV); + + FeatureVector deltaFV = cyberToFeatureVector(CyberReport.DELTA, cyberReport.getDelta()); + deltaFV.getMetaData().putAll(metaData); + addNewFeatureVector(deltaFV); + } } public void handleFeatureVectorEvent(FeatureVectorEvent event) { FeatureVector featureVector = (FeatureVector) event.object; diff --git a/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java index 8ce706d4..075360d6 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java @@ -629,7 +629,7 @@ protected Void call() throws Exception { } else if (CyberReporterFile.isFileType(file)) { CyberReporterFile cyberReportFile = new CyberReporterFile(file.getAbsolutePath(), true); Platform.runLater(() -> scene.getRoot().fireEvent( - new FeatureVectorEvent(FeatureVectorEvent.NEW_CYBER_REPORT, cyberReportFile.cyberReport))); + new FeatureVectorEvent(FeatureVectorEvent.NEW_CYBER_REPORT, cyberReportFile.cyberReports))); } } catch (IOException ex) { LOG.error(null, ex); From a562a66cb38fb1a10570401d3dae859e05ed36af Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 14 Sep 2025 15:06:32 -0400 Subject: [PATCH 11/50] Stubbed new FeatureVectorManager GUI. --- src/main/java/edu/jhuapl/trinity/App.java | 4 + .../edu/jhuapl/trinity/AppAsyncManager.java | 16 +- .../components/FeatureVectorManagerView.java | 328 ++++++++++++++++++ .../panes/FeatureVectorManagerPane.java | 60 ++++ .../javafx/components/panes/LitPathPane.java | 5 +- .../components/panes/StatPdfCdfPane.java | 1 - .../javafx/events/ApplicationEvent.java | 1 + 7 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index 1c98da47..deca2baf 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -341,6 +341,10 @@ private void keyReleased(Stage stage, KeyEvent e) { stage.getScene().getRoot().fireEvent( new ApplicationEvent(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.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 8a9071b8..dc775cf8 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -92,6 +92,7 @@ import java.util.Map; import static edu.jhuapl.trinity.App.theConfig; +import edu.jhuapl.trinity.javafx.components.panes.FeatureVectorManagerPane; import edu.jhuapl.trinity.javafx.components.panes.StatPdfCdfPane; @@ -120,6 +121,7 @@ public class AppAsyncManager extends Task { VideoPane videoPane; SpecialEffectsPane specialEffectsPane; StatPdfCdfPane statPdfCdfPane; + FeatureVectorManagerPane featureVectorManagerPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; WaveformPane waveformPane; @@ -551,9 +553,19 @@ protected Void call() throws Exception { if (null != e.object) { Platform.runLater(() -> statPdfCdfPane.setFeatureVectors((List) e.object)); } - }); - + LOG.info("FeatureVector Manager"); + scene.addEventHandler(ApplicationEvent.SHOW_FEATUREVECTOR_MANAGER, e -> { + if (null == featureVectorManagerPane) { + featureVectorManagerPane = new FeatureVectorManagerPane(scene, desktopPane); + } + if (!desktopPane.getChildren().contains(featureVectorManagerPane)) { + desktopPane.getChildren().add(featureVectorManagerPane); + featureVectorManagerPane.slideInPane(); + } else { + featureVectorManagerPane.show(); + } + }); scene.addEventHandler(ApplicationEvent.AUTO_PROJECTION_MODE, e -> { boolean enabled = (boolean) e.object; if (enabled) { 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..0695541e --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -0,0 +1,328 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.beans.property.*; +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.*; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +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<>(); + + // Table and 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<>("Preview"); + private final TableColumn colScore = new TableColumn<>("Score"); + private final TableColumn colPfa = new TableColumn<>("PFA"); + private final TableColumn colLayer = new TableColumn<>("Layer"); + + private final ObservableList items = FXCollections.observableArrayList(); + + // Bottom details + private final Accordion detailsAccordion = new Accordion(); + private final TitledPane detailsSection = new TitledPane(); + private final TextArea valuesPreviewArea = new TextArea(); + private final GridPane metaGrid = new GridPane(); + + // Status + private final Label statusLabel = new Label("Ready."); + private final ProgressBar progressBar = new ProgressBar(); + + public FeatureVectorManagerView() { + buildHeader(); + buildTable(); + buildDetails(); + + BorderPane.setMargin(table, Insets.EMPTY); + setTop(buildHeaderBar()); + setCenter(table); + + VBox bottomStack = new VBox(4, buildDetailsBlock(), buildStatusBar()); + bottomStack.setPadding(new Insets(2, 2, 2, 2)); + setBottom(bottomStack); + + // Wire properties + samplingChoice.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> { + if (n != null) samplingMode.set(n); + }); + collectionSelector.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> { + if (n != null) selectedCollection.set(n); + }); + detailLevel.addListener((obs, o, n) -> applyDetailLevel(n)); + + detailsSection.setExpanded(false); + progressBar.setVisible(false); + applyDetailLevel(detailLevel.get()); + } + + // --------------------- Builders --------------------- + + private HBox buildHeaderBar() { + Label lblCollection = new Label("Collection:"); + Label lblSampling = new Label("Sampling:"); + + HBox header = new HBox(8, + lblCollection, collectionSelector, + lblSampling, samplingChoice + ); + 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 the inputs expand when there is space + HBox.setHgrow(collectionSelector, Priority.ALWAYS); + HBox.setHgrow(samplingChoice, Priority.ALWAYS); + + return header; + } + + private void buildHeader() { + collectionSelector.setPromptText("No collections loaded"); + collectionSelector.setPrefWidth(250); + collectionSelector.setMinWidth(100); + collectionSelector.setMaxWidth(Double.MAX_VALUE); + + samplingChoice.getItems().addAll("All", "Head (1000)", "Tail (1000)", "Random (1000)"); + samplingChoice.getSelectionModel().selectFirst(); + samplingChoice.setPrefWidth(250); + samplingChoice.setMinWidth(100); + samplingChoice.setMaxWidth(Double.MAX_VALUE); + } + + 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))); + + 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()); + } + + private void buildDetails() { + valuesPreviewArea.setEditable(false); + valuesPreviewArea.setPrefRowCount(8); + + metaGrid.setHgap(6); + metaGrid.setVgap(4); + metaGrid.addRow(0, new Label("Metadata:"), new Label("(shown when a vector is selected)")); + + VBox detailsBox = new VBox(6, valuesPreviewArea, metaGrid); + detailsBox.setPadding(new Insets(6)); + + detailsSection.setText("Details"); + detailsSection.setContent(detailsBox); + detailsSection.setExpanded(false); + + detailsAccordion.getPanes().setAll(detailsSection); + detailsAccordion.setExpandedPane(null); + } + + private Node buildDetailsBlock() { + BorderPane wrapper = new BorderPane(detailsAccordion); + 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; + } + + 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(); + if (metaGrid.getChildren().size() > 1) metaGrid.getChildren().set(1, new Label("(no selection)")); + detailsSection.setExpanded(false); + detailsAccordion.setExpandedPane(null); + 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()); + + Label metaLabel = new Label(fv.getMetaData() == null ? "{}" : fv.getMetaData().toString()); + if (metaGrid.getChildren().size() > 1) metaGrid.getChildren().set(1, metaLabel); + else metaGrid.add(metaLabel, 1, 0); + + if (!detailsSection.isExpanded()) { + detailsAccordion.setExpandedPane(detailsSection); + detailsSection.setExpanded(true); + } + } + + 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); + } + } + + // --------------------- 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."); + } + 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); + } + + public TableView getTable() { return table; } + public ComboBox getCollectionSelector() { return collectionSelector; } + public ChoiceBox getSamplingChoice() { return samplingChoice; } + public Accordion getDetailsAccordion() { return detailsAccordion; } + public TitledPane getDetailsSection() { return detailsSection; } + + 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(); } +} 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..bfcbaa36 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -0,0 +1,60 @@ +// File: src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; + +import java.util.List; + +public class FeatureVectorManagerPane extends LitPathPane { + + private static final int DEFAULT_WIDTH = 980; + private static final int DEFAULT_HEIGHT = 680; + + private final FeatureVectorManagerView view; + + private final ContextMenu ctxMenu = new ContextMenu(); + private final MenuItem miOpenFull = new MenuItem("Open Full View"); + private final MenuItem miPopOut = new MenuItem("Pop-out"); + + private Runnable onOpenFullRequested = () -> {}; + private Runnable onPopOutRequested = () -> {}; + + public FeatureVectorManagerPane(Scene scene, Pane parent) { + super(scene, parent, DEFAULT_WIDTH, DEFAULT_HEIGHT, + new BorderPane(), "Feature Vectors", "Manager", 300.0, 400.0); + + view = new FeatureVectorManagerView(); + + BorderPane root = (BorderPane) this.contentPane; + root.setCenter(view); + root.setPadding(new Insets(2)); // was 8 + + view.setDetailLevel(FeatureVectorManagerView.DetailLevel.COMPACT); + + miOpenFull.setOnAction(e -> onOpenFullRequested.run()); + miPopOut.setOnAction(e -> onPopOutRequested.run()); + ctxMenu.getItems().setAll(miOpenFull, miPopOut); + + this.setOnContextMenuRequested(e -> ctxMenu.show(this, e.getScreenX(), e.getScreenY())); + view.setOnContextMenuRequested(e -> ctxMenu.show(view, e.getScreenX(), e.getScreenY())); + view.getTable().setOnContextMenuRequested(e -> ctxMenu.show(view.getTable(), e.getScreenX(), e.getScreenY())); + } + + public void setOnOpenFullRequested(Runnable r) { this.onOpenFullRequested = (r != null) ? r : () -> {}; } + public void setOnPopOutRequested(Runnable r) { this.onPopOutRequested = (r != null) ? r : () -> {}; } + + public FeatureVectorManagerView getView() { return view; } + + public void setDetailLevel(FeatureVectorManagerView.DetailLevel level) { view.setDetailLevel(level); } + public void setVectors(List vectors) { view.setVectors(vectors); } + public void setCollections(List names) { view.setCollections(names); } + public void setStatus(String message) { view.setStatus(message); } + public void showProgress(boolean show) { view.showProgress(show); } +} 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..20005689 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 @@ -54,6 +54,7 @@ public class LitPathPane extends PathPane { double hoverTopInset = -2; double hoverSideInset = -3; double effectsFitWidth = 40; + public double mainContentBorderFrameBuffer = 64; public Background opaqueBackground; public Background defaultBackground; public Background background; @@ -159,12 +160,12 @@ public LitPathPane(Scene scene, Pane parent, int width, int height, Pane userCon setEffects(); 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( 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 index 87b5e637..2a416dc3 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java @@ -6,7 +6,6 @@ import edu.jhuapl.trinity.utils.statistics.StatisticEngine; import javafx.geometry.Insets; import javafx.scene.Scene; -import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; import javafx.scene.layout.Pane; 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 e38f17ea..64f573b0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -44,6 +44,7 @@ public class ApplicationEvent extends Event { 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 SHOW_FEATUREVECTOR_MANAGER = new EventType(ANY, "SHOW_FEATUREVECTOR_MANAGER"); public ApplicationEvent(EventType eventType, Object object) { this(eventType); From 5fc76bb78454d18d3cd53e0806fcd2593b1fb3f5 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 14 Sep 2025 19:38:02 -0400 Subject: [PATCH 12/50] First cut at FeatureVectorManager service and GUI. Observer only --- .../edu/jhuapl/trinity/AppAsyncManager.java | 33 ++- .../components/FeatureVectorManagerView.java | 38 +-- .../panes/FeatureVectorManagerPane.java | 101 ++++++-- .../javafx/events/FeatureVectorEvent.java | 1 + .../javafx/services/DisplayListSampler.java | 34 +++ .../services/FeatureVectorManagerService.java | 67 ++++++ .../FeatureVectorManagerServiceImpl.java | 216 ++++++++++++++++++ .../services/FeatureVectorRepository.java | 15 ++ .../InMemoryFeatureVectorRepository.java | 37 +++ 9 files changed, 511 insertions(+), 31 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index dc775cf8..3cb03647 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -94,6 +94,9 @@ import static edu.jhuapl.trinity.App.theConfig; import edu.jhuapl.trinity.javafx.components.panes.FeatureVectorManagerPane; import edu.jhuapl.trinity.javafx.components.panes.StatPdfCdfPane; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerServiceImpl; +import edu.jhuapl.trinity.javafx.services.InMemoryFeatureVectorRepository; /** @@ -148,6 +151,8 @@ public class AppAsyncManager extends Task { MissionTimerX missionTimerX; TimelineAnimation timelineAnimation; + + FeatureVectorManagerService fvService; public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, CircleProgressIndicator progress, Map namedParameters) { this.scene = scene; @@ -155,6 +160,12 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir this.progress = progress; this.desktopPane = desktopPane; this.centerStack = centerStack; + // 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()); + } setOnSucceeded(e -> { Platform.runLater(() -> { scene.getRoot().fireEvent( @@ -554,10 +565,28 @@ protected Void call() throws Exception { Platform.runLater(() -> statPdfCdfPane.setFeatureVectors((List) e.object)); } }); - LOG.info("FeatureVector Manager"); + LOG.info("FeatureVector Manager and Services"); + // Mirror NEW_FEATURE_COLLECTION into the manager (ignore PROJECT_FEATURE_COLLECTION by design) + scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, ev -> { + if (ev.object instanceof FeatureCollection fc) { + String collName = FeatureVectorManagerService.deriveCollectionName(ev.object2, fc); + 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) { - featureVectorManagerPane = new FeatureVectorManagerPane(scene, desktopPane); + // Use the shared service so the view reflects mirrored events + featureVectorManagerPane = new FeatureVectorManagerPane(scene, desktopPane, fvService); } if (!desktopPane.getChildren().contains(featureVectorManagerPane)) { desktopPane.getChildren().add(featureVectorManagerPane); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index 0695541e..e7874df7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -29,7 +29,7 @@ public enum DetailLevel { COMPACT, FULL } private final ComboBox collectionSelector = new ComboBox<>(); private final ChoiceBox samplingChoice = new ChoiceBox<>(); - // Table and columns + // Table + columns private final TableView table = new TableView<>(); private final TableColumn colIndex = new TableColumn<>("#"); private final TableColumn colLabel = new TableColumn<>("Label"); @@ -41,7 +41,7 @@ public enum DetailLevel { COMPACT, FULL } private final ObservableList items = FXCollections.observableArrayList(); - // Bottom details + // Details private final Accordion detailsAccordion = new Accordion(); private final TitledPane detailsSection = new TitledPane(); private final TextArea valuesPreviewArea = new TextArea(); @@ -64,30 +64,27 @@ public FeatureVectorManagerView() { bottomStack.setPadding(new Insets(2, 2, 2, 2)); setBottom(bottomStack); - // Wire properties - samplingChoice.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> { - if (n != null) samplingMode.set(n); + // Wiring (internal) + samplingChoice.getSelectionModel().selectedItemProperty().addListener((o, oldV, newV) -> { + if (newV != null) samplingMode.set(newV); }); - collectionSelector.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> { - if (n != null) selectedCollection.set(n); + collectionSelector.getSelectionModel().selectedItemProperty().addListener((o, oldV, newV) -> { + if (newV != null) selectedCollection.set(newV); }); - detailLevel.addListener((obs, o, n) -> applyDetailLevel(n)); + detailLevel.addListener((o, oldV, newV) -> applyDetailLevel(newV)); detailsSection.setExpanded(false); progressBar.setVisible(false); applyDetailLevel(detailLevel.get()); } - // --------------------- Builders --------------------- + // Header ------------------------------------------------- private HBox buildHeaderBar() { Label lblCollection = new Label("Collection:"); Label lblSampling = new Label("Sampling:"); - HBox header = new HBox(8, - lblCollection, collectionSelector, - lblSampling, samplingChoice - ); + HBox header = new HBox(8, lblCollection, collectionSelector, lblSampling, samplingChoice); header.setAlignment(Pos.CENTER_LEFT); header.setPadding(new Insets(4, 4, 4, 4)); header.setBackground(new Background(new BackgroundFill( @@ -97,7 +94,9 @@ private HBox buildHeaderBar() { Color.color(1, 1, 1, 0.18), BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT ))); - // Let the inputs expand when there is space + // Let inputs expand/shrink with space + collectionSelector.setMaxWidth(Double.MAX_VALUE); + samplingChoice.setMaxWidth(Double.MAX_VALUE); HBox.setHgrow(collectionSelector, Priority.ALWAYS); HBox.setHgrow(samplingChoice, Priority.ALWAYS); @@ -117,6 +116,8 @@ private void buildHeader() { samplingChoice.setMaxWidth(Double.MAX_VALUE); } + // Table -------------------------------------------------- + private void buildTable() { table.setItems(items); table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); @@ -158,6 +159,8 @@ private void buildTable() { .addListener((ListChangeListener) change -> updateDetailsPreview()); } + // Details ----------------------------------------------- + private void buildDetails() { valuesPreviewArea.setEditable(false); valuesPreviewArea.setPrefRowCount(8); @@ -188,6 +191,8 @@ private Node buildDetailsBlock() { return wrapper; } + // Status ------------------------------------------------- + private HBox buildStatusBar() { Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); @@ -207,7 +212,7 @@ private HBox buildStatusBar() { return status; } - // --------------------- Helpers --------------------- + // Helpers ----------------------------------------------- private static String opt(String s) { return s == null ? "" : s; } @@ -290,7 +295,7 @@ private void applyDetailLevel(DetailLevel level) { } } - // --------------------- Public API --------------------- + // Public API -------------------------------------------- public void setVectors(List vectors) { items.setAll(vectors == null ? Collections.emptyList() : vectors); @@ -310,6 +315,7 @@ public void showProgress(boolean 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; } 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 index bfcbaa36..98ae4aa8 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -1,8 +1,11 @@ -// File: src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java package edu.jhuapl.trinity.javafx.components.panes; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService.SamplingMode; +import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerServiceImpl; +import edu.jhuapl.trinity.javafx.services.InMemoryFeatureVectorRepository; import javafx.geometry.Insets; import javafx.scene.Scene; import javafx.scene.control.ContextMenu; @@ -18,26 +21,81 @@ public class FeatureVectorManagerPane extends LitPathPane { private static final int DEFAULT_HEIGHT = 680; private final FeatureVectorManagerView view; + private final FeatureVectorManagerService service; + // Windowing context menu (RMB) private final ContextMenu ctxMenu = new ContextMenu(); private final MenuItem miOpenFull = new MenuItem("Open Full View"); private final MenuItem miPopOut = new MenuItem("Pop-out"); + // External hooks private Runnable onOpenFullRequested = () -> {}; - private Runnable onPopOutRequested = () -> {}; + private Runnable onPopOutRequested = () -> {}; public FeatureVectorManagerPane(Scene scene, Pane parent) { + this(scene, parent, new FeatureVectorManagerServiceImpl(new InMemoryFeatureVectorRepository())); + } + + public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerService service) { super(scene, parent, DEFAULT_WIDTH, DEFAULT_HEIGHT, new BorderPane(), "Feature Vectors", "Manager", 300.0, 400.0); - view = new FeatureVectorManagerView(); + this.service = service; - BorderPane root = (BorderPane) this.contentPane; - root.setCenter(view); - root.setPadding(new Insets(2)); // was 8 + // Allow service to fire events to the scene root (flattened list to workspace) + if (service instanceof FeatureVectorManagerServiceImpl impl && scene != null) { + impl.setEventTarget(scene.getRoot()); + } + view = new FeatureVectorManagerView(); view.setDetailLevel(FeatureVectorManagerView.DetailLevel.COMPACT); + BorderPane root = (BorderPane) this.contentPane; + root.setCenter(view); + root.setPadding(new Insets(2)); + + // ---- Wire lists ---- + view.getTable().setItems(service.getDisplayedVectors()); + view.setCollections(service.getCollectionNames()); + + // ---- Active collection (two-way) ---- + 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())) { + view.getCollectionSelector().getSelectionModel().select(n); + } + }); + if (service.activeCollectionNameProperty().get() != null) { + view.getCollectionSelector().getSelectionModel().select(service.activeCollectionNameProperty().get()); + } + + // ---- Sampling mode (string <-> enum) ---- + view.samplingModeProperty().addListener((obs, o, n) -> { + SamplingMode m = fromStringSampling(n); + if (m != service.samplingModeProperty().get()) { + service.samplingModeProperty().set(m); + } + }); + service.samplingModeProperty().addListener((obs, o, n) -> { + String s = toStringSampling(n); + if (!s.equals(view.getSamplingMode())) { + view.samplingModeProperty().set(s); + } + }); + + // ---- Status / progress passthrough ---- + service.statusProperty().addListener((obs, o, n) -> view.setStatus(n)); + service.progressProperty().addListener((obs, o, n) -> { + double v = (n == null) ? -1 : n.doubleValue(); + view.showProgress(v >= 0); + // If you decide to show determinate progress later, extend the view to set value. + }); + + // ---- Context menu (windowing only, as requested) ---- miOpenFull.setOnAction(e -> onOpenFullRequested.run()); miPopOut.setOnAction(e -> onPopOutRequested.run()); ctxMenu.getItems().setAll(miOpenFull, miPopOut); @@ -47,14 +105,31 @@ public FeatureVectorManagerPane(Scene scene, Pane parent) { view.getTable().setOnContextMenuRequested(e -> ctxMenu.show(view.getTable(), e.getScreenX(), e.getScreenY())); } + // --- Sampling mapping helpers --- + private static SamplingMode fromStringSampling(String s) { + if (s == null) return SamplingMode.ALL; + String l = s.toLowerCase(); + if (l.startsWith("head")) return SamplingMode.HEAD_1000; + if (l.startsWith("tail")) return SamplingMode.TAIL_1000; + if (l.startsWith("random")) return SamplingMode.RANDOM_1000; + return SamplingMode.ALL; + } + private static String toStringSampling(SamplingMode m) { + if (m == null) return "All"; + switch (m) { + case HEAD_1000: return "Head (1000)"; + case TAIL_1000: return "Tail (1000)"; + case RANDOM_1000: return "Random (1000)"; + default: return "All"; + } + } + + // External hooks for windowing public void setOnOpenFullRequested(Runnable r) { this.onOpenFullRequested = (r != null) ? r : () -> {}; } - public void setOnPopOutRequested(Runnable r) { this.onPopOutRequested = (r != null) ? r : () -> {}; } + public void setOnPopOutRequested(Runnable r) { this.onPopOutRequested = (r != null) ? r : () -> {}; } + // Convenience public FeatureVectorManagerView getView() { return view; } - public void setDetailLevel(FeatureVectorManagerView.DetailLevel level) { view.setDetailLevel(level); } - public void setVectors(List vectors) { view.setVectors(vectors); } - public void setCollections(List names) { view.setCollections(names); } - public void setStatus(String message) { view.setStatus(message); } - public void showProgress(boolean show) { view.showProgress(show); } -} + public void setVectors(List vectors) { service.replaceActiveVectors(vectors); } +} \ No newline at end of file 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 2ea7f671..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,7 @@ 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"); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java b/src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java new file mode 100644 index 00000000..5d60233a --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java @@ -0,0 +1,34 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +import static edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService.SamplingMode; + +final class DisplayListSampler { + private DisplayListSampler() {} + + static List sample(List src, SamplingMode mode) { + if (src == null) return Collections.emptyList(); + int n = src.size(); + if (n == 0 || mode == null || mode == SamplingMode.ALL) return src; + + int k = 1000; + switch (mode) { + case HEAD_1000: return src.subList(0, Math.min(k, n)); + case TAIL_1000: return src.subList(Math.max(0, n - k), n); + case RANDOM_1000: + if (n <= k) return src; + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + HashSet idx = new HashSet<>(k * 2); + while (idx.size() < k) idx.add(rnd.nextInt(n)); + ArrayList out = new ArrayList<>(k); + for (Integer i : idx) out.add(src.get(i)); + return out; + default: + return src; + } + } +} 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..ef59a6ca --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -0,0 +1,67 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import javafx.beans.property.*; +import javafx.collections.ObservableList; + +import java.nio.file.Path; +import java.util.List; + +public interface FeatureVectorManagerService { + + enum SamplingMode { ALL, HEAD_1000, TAIL_1000, RANDOM_1000 } + + // Collections + ObservableList getCollectionNames(); + StringProperty activeCollectionNameProperty(); + + // Displayed vectors + ObservableList getDisplayedVectors(); + ObjectProperty samplingModeProperty(); + + // Counts + ReadOnlyIntegerProperty totalVectorCountProperty(); + ReadOnlyIntegerProperty displayedCountProperty(); + + // Mutations + void addCollection(String name, List vectors); + void removeCollection(String name); + void renameCollection(String oldName, String newName); + void appendVectorsToActive(List vectors); + void replaceActiveVectors(List vectors); + + // Import / Export (background) + void importJsonAsync(String collectionName, Path jsonPath); + void importCsvAsync(String collectionName, Path csvPath, int expectedDim); + void exportCsvAsync(Path csvPath, List source); + + // Status / progress + ReadOnlyStringProperty statusProperty(); + ReadOnlyDoubleProperty progressProperty(); + + // Integration back to app (fires FeatureVectorEvent subtype) + void applyActiveToWorkspace(); + + // Lifecycle + void dispose(); + /** + * Derive a collection name from an import hint (e.g. filename) or fall back to a generic name. + * @param hint may be a String path, filename, or null + * @param fc the FeatureCollection being imported + * @return a suitable collection name + */ + public static String deriveCollectionName(Object hint, FeatureCollection fc) { + String name = null; + if (hint instanceof String pathOrHint) { + int slash = Math.max(pathOrHint.lastIndexOf('/'), pathOrHint.lastIndexOf('\\')); + name = (slash >= 0) ? pathOrHint.substring(slash + 1) : pathOrHint; + int dot = name.lastIndexOf('.'); + if (dot > 0) name = name.substring(0, dot); + } + if (name == null || name.isBlank()) { + name = "FeatureCollection-" + System.currentTimeMillis(); + } + return name; + } +} 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..0e8c9b7c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -0,0 +1,216 @@ +package edu.jhuapl.trinity.javafx.services; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import javafx.application.Platform; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.event.EventTarget; +import javafx.scene.Node; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class FeatureVectorManagerServiceImpl implements FeatureVectorManagerService { + + private final FeatureVectorRepository repo; + private final ObservableList displayed = FXCollections.observableArrayList(); + + private final StringProperty activeCollection = new SimpleStringProperty(""); + private final ObjectProperty samplingMode = new SimpleObjectProperty<>(SamplingMode.ALL); + + private final ReadOnlyIntegerWrapper totalCount = new ReadOnlyIntegerWrapper(0); + private final ReadOnlyIntegerWrapper displayedCount = new ReadOnlyIntegerWrapper(0); + + private final ReadOnlyStringWrapper status = new ReadOnlyStringWrapper("Ready."); + private final ReadOnlyDoubleWrapper progress = new ReadOnlyDoubleWrapper(-1); + + private final ExecutorService executor = Executors.newFixedThreadPool(Math.max(2, Runtime.getRuntime().availableProcessors()/2)); + + // Where to fire events (typically scene.getRoot()) + private EventTarget eventTarget; + + public FeatureVectorManagerServiceImpl(FeatureVectorRepository repo) { + this.repo = Objects.requireNonNull(repo, "repo"); + activeCollection.addListener((obs,o,n) -> recomputeDisplayed()); + samplingMode.addListener((obs,o,n) -> recomputeDisplayed()); + } + + public void setEventTarget(EventTarget target) { this.eventTarget = target; } + + @Override public ObservableList getCollectionNames() { return repo.getCollectionNames(); } + @Override public StringProperty activeCollectionNameProperty() { return activeCollection; } + @Override public ObservableList getDisplayedVectors() { return displayed; } + @Override public ObjectProperty samplingModeProperty() { return samplingMode; } + + @Override public ReadOnlyIntegerProperty totalVectorCountProperty() { return totalCount.getReadOnlyProperty(); } + @Override public ReadOnlyIntegerProperty displayedCountProperty() { return displayedCount.getReadOnlyProperty(); } + + @Override public ReadOnlyStringProperty statusProperty() { return status.getReadOnlyProperty(); } + @Override public ReadOnlyDoubleProperty progressProperty() { return progress.getReadOnlyProperty(); } + + @Override + public void addCollection(String name, List vectors) { + if (name == null || name.isBlank()) return; + repo.put(name, vectors); + if (activeCollection.get().isBlank()) activeCollection.set(name); + recomputeDisplayed(); + setStatus("Added collection '" + name + "' (" + (vectors==null?0:vectors.size()) + " vectors)."); + } + + @Override + public void removeCollection(String name) { + repo.remove(name); + if (name.equals(activeCollection.get())) { + activeCollection.set(repo.getCollectionNames().isEmpty() ? "" : + repo.getCollectionNames().get(0)); + } + recomputeDisplayed(); + setStatus("Removed collection '" + name + "'."); + } + + @Override + public void renameCollection(String oldName, String newName) { + if (!repo.contains(oldName) || newName == null || newName.isBlank()) return; + List v = new ArrayList<>(repo.get(oldName)); + repo.remove(oldName); + repo.put(newName, v); + if (oldName.equals(activeCollection.get())) activeCollection.set(newName); + setStatus("Renamed collection '" + oldName + "' → '" + newName + "'."); + } + + @Override + public void appendVectorsToActive(List vectors) { + if (vectors == null || vectors.isEmpty()) return; + String name = activeCollection.get(); + if (name == null || name.isBlank()) { + name = "Collection-" + (repo.getCollectionNames().size() + 1); + repo.put(name, new ArrayList<>()); + activeCollection.set(name); + } + repo.get(name).addAll(vectors); + recomputeDisplayed(); + setStatus("Appended " + vectors.size() + " vectors to '" + name + "'."); + } + + @Override + public void replaceActiveVectors(List vectors) { + String name = activeCollection.get(); + if (name == null || name.isBlank()) { + name = "Collection-" + (repo.getCollectionNames().size() + 1); + repo.put(name, new ArrayList<>()); + activeCollection.set(name); + } + List dest = repo.get(name); + dest.clear(); + if (vectors != null) dest.addAll(vectors); + recomputeDisplayed(); + setStatus("Replaced active collection with " + dest.size() + " vectors."); + } + + @Override + public void importJsonAsync(String collectionName, Path jsonPath) { + Task> task = new Task<>() { + @Override protected List call() throws Exception { + updateMessage("Importing JSON: " + jsonPath.getFileName()); + updateProgress(-1, 1); + Thread.sleep(200); + return List.of(); + } + }; + task.messageProperty().addListener((obs,o,n) -> setStatus(n)); + bindProgress(task); + task.setOnSucceeded(e -> { addCollection(collectionName, task.getValue()); clearProgress(); }); + task.setOnFailed(e -> { setStatus("Import failed: " + task.getException()); clearProgress(); }); + executor.submit(task); + } + + @Override + public void importCsvAsync(String collectionName, Path csvPath, int expectedDim) { + Task> task = new Task<>() { + @Override protected List call() throws Exception { + updateMessage("Importing CSV: " + csvPath.getFileName()); + updateProgress(-1, 1); + Thread.sleep(200); + return List.of(); + } + }; + task.messageProperty().addListener((obs,o,n) -> setStatus(n)); + bindProgress(task); + task.setOnSucceeded(e -> { addCollection(collectionName, task.getValue()); clearProgress(); }); + task.setOnFailed(e -> { setStatus("Import failed: " + task.getException()); clearProgress(); }); + executor.submit(task); + } + + @Override + public void exportCsvAsync(Path csvPath, List source) { + if (source == null) source = getActive(); + Task task = new Task<>() { + @Override protected Void call() throws Exception { + updateMessage("Exporting CSV: " + csvPath.getFileName()); + updateProgress(-1, 1); + Thread.sleep(200); + return null; + } + }; + task.messageProperty().addListener((obs,o,n) -> setStatus(n)); + bindProgress(task); + task.setOnSucceeded(e -> { setStatus("Export complete: " + csvPath.getFileName()); clearProgress(); }); + task.setOnFailed(e -> { setStatus("Export failed: " + task.getException()); clearProgress(); }); + executor.submit(task); + } + +@Override +public void applyActiveToWorkspace() { + if (eventTarget == null) { + setStatus("No event target set; cannot apply to workspace."); + return; + } + List flat = new ArrayList<>(getActive()); + Platform.runLater(() -> { + FeatureVectorEvent ev = new FeatureVectorEvent( + FeatureVectorEvent.APPLY_ACTIVE_FEATUREVECTORS, + flat + ); + ev.clearExisting = false; // or true if you want to reset consumers + if (eventTarget instanceof Node node) { + node.fireEvent(ev); + } + }); + setStatus("Applied " + flat.size() + " vectors to workspace."); +} + + private List getActive() { + String name = activeCollection.get(); + return (name == null || name.isBlank()) ? List.of() : repo.get(name); + } + + private void recomputeDisplayed() { + List active = getActive(); + totalCount.set(active.size()); + List sampled = DisplayListSampler.sample(active, samplingMode.get()); + Platform.runLater(() -> { + displayed.setAll(sampled); + displayedCount.set(displayed.size()); + }); + } + + private void setStatus(String s) { Platform.runLater(() -> status.set(s == null ? "" : s)); } + + private void bindProgress(Task t) { + t.progressProperty().addListener((obs,o,n) -> { + double val = (n == null) ? -1 : n.doubleValue(); + Platform.runLater(() -> progress.set(val)); + }); + } + + private void clearProgress() { Platform.runLater(() -> progress.set(-1)); } + + @Override public void dispose() { executor.shutdownNow(); } +} 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..440c600d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java @@ -0,0 +1,15 @@ +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/InMemoryFeatureVectorRepository.java b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java new file mode 100644 index 00000000..044b2f15 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java @@ -0,0 +1,37 @@ +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.*; + +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(); + } +} From 9c74f541a430d118c47c2c27db1195023ee4e05d Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 14 Sep 2025 22:02:36 -0400 Subject: [PATCH 13/50] Fixes so that FeatureVectorManagerView can properly manage and switch between collections. Small fix in Hyperspace3DPane so that random presses of the F key don't trigger niche functionality --- .../components/FeatureVectorManagerView.java | 82 +++-- .../panes/FeatureVectorManagerPane.java | 151 ++++------ .../javafx/javafx3d/Hyperspace3DPane.java | 6 +- .../services/FeatureVectorManagerService.java | 61 ++-- .../FeatureVectorManagerServiceImpl.java | 285 +++++++----------- 5 files changed, 257 insertions(+), 328 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index e7874df7..f1c5b982 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -17,6 +17,16 @@ import java.util.List; import java.util.Locale; +/** + * FeatureVectorManagerView + * - Header with Collection selector + Sampling choice + * - Center TableView of FeatureVectors (compact/full columns) + * - Bottom stack: Details (values preview) and Metadata (formatted key/value), independent TitledPanes + * - Status bar with progress indicator + * + * Note: The collection ComboBox should be bound to a live ObservableList by the container/pane: + * view.getCollectionSelector().setItems(service.getCollectionNames()); + */ public class FeatureVectorManagerView extends BorderPane { public enum DetailLevel { COMPACT, FULL } @@ -41,11 +51,11 @@ public enum DetailLevel { COMPACT, FULL } private final ObservableList items = FXCollections.observableArrayList(); - // Details - private final Accordion detailsAccordion = new Accordion(); + // Details + Metadata (independent panes) private final TitledPane detailsSection = new TitledPane(); + private final TitledPane metadataSection = new TitledPane(); private final TextArea valuesPreviewArea = new TextArea(); - private final GridPane metaGrid = new GridPane(); + private final TextArea metaTextArea = new TextArea(); // Status private final Label statusLabel = new Label("Ready."); @@ -54,13 +64,13 @@ public enum DetailLevel { COMPACT, FULL } public FeatureVectorManagerView() { buildHeader(); buildTable(); - buildDetails(); + buildDetailAndMetadataPanes(); BorderPane.setMargin(table, Insets.EMPTY); setTop(buildHeaderBar()); setCenter(table); - VBox bottomStack = new VBox(4, buildDetailsBlock(), buildStatusBar()); + VBox bottomStack = new VBox(4, buildBottomBlock(), buildStatusBar()); bottomStack.setPadding(new Insets(2, 2, 2, 2)); setBottom(bottomStack); @@ -74,6 +84,7 @@ public FeatureVectorManagerView() { detailLevel.addListener((o, oldV, newV) -> applyDetailLevel(newV)); detailsSection.setExpanded(false); + metadataSection.setExpanded(false); progressBar.setVisible(false); applyDetailLevel(detailLevel.get()); } @@ -159,29 +170,40 @@ private void buildTable() { .addListener((ListChangeListener) change -> updateDetailsPreview()); } - // Details ----------------------------------------------- + // Details & Metadata panes ------------------------------- - private void buildDetails() { + private void buildDetailAndMetadataPanes() { + // Details (values preview) valuesPreviewArea.setEditable(false); valuesPreviewArea.setPrefRowCount(8); + valuesPreviewArea.setWrapText(true); - metaGrid.setHgap(6); - metaGrid.setVgap(4); - metaGrid.addRow(0, new Label("Metadata:"), new Label("(shown when a vector is selected)")); - - VBox detailsBox = new VBox(6, valuesPreviewArea, metaGrid); + VBox detailsBox = new VBox(6, valuesPreviewArea); detailsBox.setPadding(new Insets(6)); detailsSection.setText("Details"); detailsSection.setContent(detailsBox); detailsSection.setExpanded(false); - detailsAccordion.getPanes().setAll(detailsSection); - detailsAccordion.setExpandedPane(null); + // Metadata (formatted key/value pairs) + 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 buildDetailsBlock() { - BorderPane wrapper = new BorderPane(detailsAccordion); + private Node buildBottomBlock() { + // Independent (non-Accordion) so both panes can be open at once + 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 ))); @@ -245,14 +267,14 @@ private void updateDetailsPreview() { var sel = table.getSelectionModel().getSelectedItems(); if (sel == null || sel.isEmpty()) { valuesPreviewArea.clear(); - if (metaGrid.getChildren().size() > 1) metaGrid.getChildren().set(1, new Label("(no selection)")); - detailsSection.setExpanded(false); - detailsAccordion.setExpandedPane(null); + metaTextArea.setText("(no metadata)"); + // do not auto-collapse; let user control the panes independently return; } FeatureVector fv = sel.get(0); var data = fv.getData(); + // Details text StringBuilder sb = new StringBuilder(); sb.append("Label: ").append(opt(fv.getLabel())).append("\n"); sb.append("Dim: ").append(data == null ? 0 : data.size()).append("\n"); @@ -277,13 +299,18 @@ private void updateDetailsPreview() { } valuesPreviewArea.setText(sb.toString()); - Label metaLabel = new Label(fv.getMetaData() == null ? "{}" : fv.getMetaData().toString()); - if (metaGrid.getChildren().size() > 1) metaGrid.getChildren().set(1, metaLabel); - else metaGrid.add(metaLabel, 1, 0); - - if (!detailsSection.isExpanded()) { - detailsAccordion.setExpandedPane(detailsSection); - detailsSection.setExpanded(true); + // Metadata text (key/value per line) + 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()); } } @@ -305,6 +332,7 @@ 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(); @@ -319,8 +347,8 @@ public void showProgress(boolean show) { public TableView getTable() { return table; } public ComboBox getCollectionSelector() { return collectionSelector; } public ChoiceBox getSamplingChoice() { return samplingChoice; } - public Accordion getDetailsAccordion() { return detailsAccordion; } public TitledPane getDetailsSection() { return detailsSection; } + public TitledPane getMetadataSection() { return metadataSection; } public StringProperty samplingModeProperty() { return samplingMode; } public String getSamplingMode() { return samplingMode.get(); } 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 index 98ae4aa8..645029a6 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -1,64 +1,52 @@ package edu.jhuapl.trinity.javafx.components.panes; -import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; -import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService.SamplingMode; -import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerServiceImpl; -import edu.jhuapl.trinity.javafx.services.InMemoryFeatureVectorRepository; -import javafx.geometry.Insets; +import javafx.beans.binding.Bindings; +import javafx.collections.ListChangeListener; import javafx.scene.Scene; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.MenuItem; -import javafx.scene.layout.BorderPane; +import javafx.scene.control.ListCell; import javafx.scene.layout.Pane; -import java.util.List; - public class FeatureVectorManagerPane extends LitPathPane { - private static final int DEFAULT_WIDTH = 980; - private static final int DEFAULT_HEIGHT = 680; - private final FeatureVectorManagerView view; private final FeatureVectorManagerService service; - // Windowing context menu (RMB) - private final ContextMenu ctxMenu = new ContextMenu(); - private final MenuItem miOpenFull = new MenuItem("Open Full View"); - private final MenuItem miPopOut = new MenuItem("Pop-out"); - - // External hooks - private Runnable onOpenFullRequested = () -> {}; - private Runnable onPopOutRequested = () -> {}; - - public FeatureVectorManagerPane(Scene scene, Pane parent) { - this(scene, parent, new FeatureVectorManagerServiceImpl(new InMemoryFeatureVectorRepository())); - } - public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerService service) { - super(scene, parent, DEFAULT_WIDTH, DEFAULT_HEIGHT, - new BorderPane(), "Feature Vectors", "Manager", 300.0, 400.0); + super(scene, parent, 900, 640, new FeatureVectorManagerView(), + "Feature Vectors", "Manager", 300.0, 400.0); + this.view = (FeatureVectorManagerView) this.contentPane; this.service = service; - // Allow service to fire events to the scene root (flattened list to workspace) - if (service instanceof FeatureVectorManagerServiceImpl impl && scene != null) { - impl.setEventTarget(scene.getRoot()); - } + wireViewToService(); + } - view = new FeatureVectorManagerView(); - view.setDetailLevel(FeatureVectorManagerView.DetailLevel.COMPACT); + private void wireViewToService() { + // Live-bind the table to service's displayed vectors + view.getTable().setItems(service.getDisplayedVectors()); - BorderPane root = (BorderPane) this.contentPane; - root.setCenter(view); - root.setPadding(new Insets(2)); + // Live-bind the ComboBox to service's collection names + var combo = view.getCollectionSelector(); + combo.setItems(service.getCollectionNames()); + combo.setVisibleRowCount(15); - // ---- Wire lists ---- - view.getTable().setItems(service.getDisplayedVectors()); - view.setCollections(service.getCollectionNames()); + // 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)); + } + }); - // ---- Active collection (two-way) ---- + // 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); @@ -66,70 +54,41 @@ public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerSe }); service.activeCollectionNameProperty().addListener((obs, o, n) -> { if (n != null && !n.equals(view.getSelectedCollection())) { - view.getCollectionSelector().getSelectionModel().select(n); + combo.getSelectionModel().select(n); } }); - if (service.activeCollectionNameProperty().get() != null) { - view.getCollectionSelector().getSelectionModel().select(service.activeCollectionNameProperty().get()); - } - // ---- Sampling mode (string <-> enum) ---- - view.samplingModeProperty().addListener((obs, o, n) -> { - SamplingMode m = fromStringSampling(n); - if (m != service.samplingModeProperty().get()) { - service.samplingModeProperty().set(m); + // 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)); } }); - service.samplingModeProperty().addListener((obs, o, n) -> { - String s = toStringSampling(n); - if (!s.equals(view.getSamplingMode())) { - view.samplingModeProperty().set(s); + + // 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); } }); - // ---- Status / progress passthrough ---- - service.statusProperty().addListener((obs, o, n) -> view.setStatus(n)); - service.progressProperty().addListener((obs, o, n) -> { - double v = (n == null) ? -1 : n.doubleValue(); - view.showProgress(v >= 0); - // If you decide to show determinate progress later, extend the view to set value. + // 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."); }); - - // ---- Context menu (windowing only, as requested) ---- - miOpenFull.setOnAction(e -> onOpenFullRequested.run()); - miPopOut.setOnAction(e -> onPopOutRequested.run()); - ctxMenu.getItems().setAll(miOpenFull, miPopOut); - - this.setOnContextMenuRequested(e -> ctxMenu.show(this, e.getScreenX(), e.getScreenY())); - view.setOnContextMenuRequested(e -> ctxMenu.show(view, e.getScreenX(), e.getScreenY())); - view.getTable().setOnContextMenuRequested(e -> ctxMenu.show(view.getTable(), e.getScreenX(), e.getScreenY())); + view.getTable().itemsProperty().bind(Bindings.createObjectBinding( + () -> service.getDisplayedVectors(), service.getDisplayedVectors())); } - // --- Sampling mapping helpers --- - private static SamplingMode fromStringSampling(String s) { - if (s == null) return SamplingMode.ALL; - String l = s.toLowerCase(); - if (l.startsWith("head")) return SamplingMode.HEAD_1000; - if (l.startsWith("tail")) return SamplingMode.TAIL_1000; - if (l.startsWith("random")) return SamplingMode.RANDOM_1000; - return SamplingMode.ALL; - } - private static String toStringSampling(SamplingMode m) { - if (m == null) return "All"; - switch (m) { - case HEAD_1000: return "Head (1000)"; - case TAIL_1000: return "Tail (1000)"; - case RANDOM_1000: return "Random (1000)"; - default: return "All"; - } + // Convenience for tests / external triggers + public void applyActiveToWorkspace() { + service.applyActiveToWorkspace(); } - - // External hooks for windowing - public void setOnOpenFullRequested(Runnable r) { this.onOpenFullRequested = (r != null) ? r : () -> {}; } - public void setOnPopOutRequested(Runnable r) { this.onPopOutRequested = (r != null) ? r : () -> {}; } - - // Convenience - public FeatureVectorManagerView getView() { return view; } - public void setDetailLevel(FeatureVectorManagerView.DetailLevel level) { view.setDetailLevel(level); } - public void setVectors(List vectors) { service.replaceActiveVectors(vectors); } -} \ No newline at end of file +} 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..61f69e9d 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) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index ef59a6ca..71b17828 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -2,56 +2,49 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import javafx.beans.property.*; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.StringProperty; import javafx.collections.ObservableList; -import java.nio.file.Path; -import java.util.List; - +/** + * Central manager API for FeatureVectors grouped as named collections. + */ public interface FeatureVectorManagerService { enum SamplingMode { ALL, HEAD_1000, TAIL_1000, RANDOM_1000 } - // Collections + /** Live list of collection names (do not replace the instance; mutate it). */ ObservableList getCollectionNames(); + + /** Name of the currently active collection. */ StringProperty activeCollectionNameProperty(); - // Displayed vectors + /** Live list of vectors to display (sampling applied). */ ObservableList getDisplayedVectors(); - ObjectProperty samplingModeProperty(); - // Counts - ReadOnlyIntegerProperty totalVectorCountProperty(); - ReadOnlyIntegerProperty displayedCountProperty(); + /** Current sampling mode. */ + ObjectProperty samplingModeProperty(); - // Mutations - void addCollection(String name, List vectors); - void removeCollection(String name); - void renameCollection(String oldName, String newName); - void appendVectorsToActive(List vectors); - void replaceActiveVectors(List vectors); + /** Add a new collection (or replace existing if same name), selecting it active. */ + void addCollection(String proposedName, java.util.List vectors); - // Import / Export (background) - void importJsonAsync(String collectionName, Path jsonPath); - void importCsvAsync(String collectionName, Path csvPath, int expectedDim); - void exportCsvAsync(Path csvPath, List source); + /** Append vectors to the currently active collection. */ + void appendVectorsToActive(java.util.List vectors); - // Status / progress - ReadOnlyStringProperty statusProperty(); - ReadOnlyDoubleProperty progressProperty(); + /** Replace the vectors in the currently active collection. */ + void replaceActiveVectors(java.util.List vectors); - // Integration back to app (fires FeatureVectorEvent subtype) + /** Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). */ void applyActiveToWorkspace(); - // Lifecycle - void dispose(); + /** Optional: Where events should be fired (e.g., scene.getRoot()). */ + void setEventTarget(javafx.event.EventTarget target); + + // ---------- Shared naming helper ---------- /** - * Derive a collection name from an import hint (e.g. filename) or fall back to a generic name. - * @param hint may be a String path, filename, or null - * @param fc the FeatureCollection being imported - * @return a suitable collection name + * Derive a collection name from an import hint (e.g., file path) or fall back to a generic name. */ - public static String deriveCollectionName(Object hint, FeatureCollection fc) { + static String deriveCollectionName(Object hint, FeatureCollection fc) { String name = null; if (hint instanceof String pathOrHint) { int slash = Math.max(pathOrHint.lastIndexOf('/'), pathOrHint.lastIndexOf('\\')); @@ -59,9 +52,9 @@ public static String deriveCollectionName(Object hint, FeatureCollection fc) { int dot = name.lastIndexOf('.'); if (dot > 0) name = name.substring(0, dot); } - if (name == null || name.isBlank()) { + if (name == null || name.trim().isEmpty()) { name = "FeatureCollection-" + System.currentTimeMillis(); } - return name; - } + return name.trim(); + } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java index 0e8c9b7c..2e89cf80 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -3,214 +3,163 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import javafx.application.Platform; -import javafx.beans.property.*; +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.concurrent.Task; import javafx.event.EventTarget; -import javafx.scene.Node; -import java.nio.file.Path; -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.*; +import java.util.concurrent.ThreadLocalRandom; +import javafx.scene.Node; +/** + * In-memory implementation; keeps a map of named collections and exposes a sampled "displayed" list. + */ public class FeatureVectorManagerServiceImpl implements FeatureVectorManagerService { - private final FeatureVectorRepository repo; - private final ObservableList displayed = FXCollections.observableArrayList(); - - private final StringProperty activeCollection = new SimpleStringProperty(""); + 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 Node eventTarget; - private final ReadOnlyIntegerWrapper totalCount = new ReadOnlyIntegerWrapper(0); - private final ReadOnlyIntegerWrapper displayedCount = new ReadOnlyIntegerWrapper(0); - - private final ReadOnlyStringWrapper status = new ReadOnlyStringWrapper("Ready."); - private final ReadOnlyDoubleWrapper progress = new ReadOnlyDoubleWrapper(-1); - - private final ExecutorService executor = Executors.newFixedThreadPool(Math.max(2, Runtime.getRuntime().availableProcessors()/2)); - - // Where to fire events (typically scene.getRoot()) - private EventTarget eventTarget; + public FeatureVectorManagerServiceImpl(InMemoryFeatureVectorRepository ignoredRepo) { + // repo placeholder kept for drop-in compatibility; not used in this in-memory impl. - public FeatureVectorManagerServiceImpl(FeatureVectorRepository repo) { - this.repo = Objects.requireNonNull(repo, "repo"); - activeCollection.addListener((obs,o,n) -> recomputeDisplayed()); - samplingMode.addListener((obs,o,n) -> recomputeDisplayed()); + // keep displayedVectors in sync whenever the active name or sampling mode changes + activeCollectionName.addListener((obs, o, n) -> refreshDisplayedFromActive()); + samplingMode.addListener((obs, o, n) -> refreshDisplayedFromActive()); } - public void setEventTarget(EventTarget target) { this.eventTarget = target; } - - @Override public ObservableList getCollectionNames() { return repo.getCollectionNames(); } - @Override public StringProperty activeCollectionNameProperty() { return activeCollection; } - @Override public ObservableList getDisplayedVectors() { return displayed; } + @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 ReadOnlyIntegerProperty totalVectorCountProperty() { return totalCount.getReadOnlyProperty(); } - @Override public ReadOnlyIntegerProperty displayedCountProperty() { return displayedCount.getReadOnlyProperty(); } - - @Override public ReadOnlyStringProperty statusProperty() { return status.getReadOnlyProperty(); } - @Override public ReadOnlyDoubleProperty progressProperty() { return progress.getReadOnlyProperty(); } - @Override - public void addCollection(String name, List vectors) { - if (name == null || name.isBlank()) return; - repo.put(name, vectors); - if (activeCollection.get().isBlank()) activeCollection.set(name); - recomputeDisplayed(); - setStatus("Added collection '" + name + "' (" + (vectors==null?0:vectors.size()) + " vectors)."); - } - - @Override - public void removeCollection(String name) { - repo.remove(name); - if (name.equals(activeCollection.get())) { - activeCollection.set(repo.getCollectionNames().isEmpty() ? "" : - repo.getCollectionNames().get(0)); - } - recomputeDisplayed(); - setStatus("Removed collection '" + name + "'."); - } - - @Override - public void renameCollection(String oldName, String newName) { - if (!repo.contains(oldName) || newName == null || newName.isBlank()) return; - List v = new ArrayList<>(repo.get(oldName)); - repo.remove(oldName); - repo.put(newName, v); - if (oldName.equals(activeCollection.get())) activeCollection.set(newName); - setStatus("Renamed collection '" + oldName + "' → '" + newName + "'."); + public void addCollection(String proposedName, List vectors) { + if (vectors == null) vectors = List.of(); + final String clean = cleanName(proposedName); + final String name = uniquify(clean); + final List payload = List.copyOf(vectors); // defensive + + runFx(() -> { + collections.put(name, new ArrayList<>(payload)); + if (!collectionNames.contains(name)) collectionNames.add(name); + // Select new collection by default + activeCollectionName.set(name); + refreshDisplayedFromActive(); + }); } @Override public void appendVectorsToActive(List vectors) { if (vectors == null || vectors.isEmpty()) return; - String name = activeCollection.get(); - if (name == null || name.isBlank()) { - name = "Collection-" + (repo.getCollectionNames().size() + 1); - repo.put(name, new ArrayList<>()); - activeCollection.set(name); - } - repo.get(name).addAll(vectors); - recomputeDisplayed(); - setStatus("Appended " + vectors.size() + " vectors to '" + name + "'."); - } - - @Override - public void replaceActiveVectors(List vectors) { - String name = activeCollection.get(); - if (name == null || name.isBlank()) { - name = "Collection-" + (repo.getCollectionNames().size() + 1); - repo.put(name, new ArrayList<>()); - activeCollection.set(name); - } - List dest = repo.get(name); - dest.clear(); - if (vectors != null) dest.addAll(vectors); - recomputeDisplayed(); - setStatus("Replaced active collection with " + dest.size() + " vectors."); - } - - @Override - public void importJsonAsync(String collectionName, Path jsonPath) { - Task> task = new Task<>() { - @Override protected List call() throws Exception { - updateMessage("Importing JSON: " + jsonPath.getFileName()); - updateProgress(-1, 1); - Thread.sleep(200); - return List.of(); + runFx(() -> { + String name = activeCollectionName.get(); + if (name == null) { + // No collection yet: create a default one + name = uniquify("Collection"); + collections.put(name, new ArrayList<>()); + collectionNames.add(name); + activeCollectionName.set(name); } - }; - task.messageProperty().addListener((obs,o,n) -> setStatus(n)); - bindProgress(task); - task.setOnSucceeded(e -> { addCollection(collectionName, task.getValue()); clearProgress(); }); - task.setOnFailed(e -> { setStatus("Import failed: " + task.getException()); clearProgress(); }); - executor.submit(task); + collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(vectors); + refreshDisplayedFromActive(); + }); } @Override - public void importCsvAsync(String collectionName, Path csvPath, int expectedDim) { - Task> task = new Task<>() { - @Override protected List call() throws Exception { - updateMessage("Importing CSV: " + csvPath.getFileName()); - updateProgress(-1, 1); - Thread.sleep(200); - return List.of(); + public void replaceActiveVectors(List vectors) { + runFx(() -> { + String name = activeCollectionName.get(); + if (name == null) { + name = uniquify("Collection"); + collectionNames.add(name); + activeCollectionName.set(name); } - }; - task.messageProperty().addListener((obs,o,n) -> setStatus(n)); - bindProgress(task); - task.setOnSucceeded(e -> { addCollection(collectionName, task.getValue()); clearProgress(); }); - task.setOnFailed(e -> { setStatus("Import failed: " + task.getException()); clearProgress(); }); - executor.submit(task); + collections.put(name, new ArrayList<>(vectors == null ? List.of() : vectors)); + refreshDisplayedFromActive(); + }); } @Override - public void exportCsvAsync(Path csvPath, List source) { - if (source == null) source = getActive(); - Task task = new Task<>() { - @Override protected Void call() throws Exception { - updateMessage("Exporting CSV: " + csvPath.getFileName()); - updateProgress(-1, 1); - Thread.sleep(200); - return null; + public void applyActiveToWorkspace() { + runFx(() -> { + String name = activeCollectionName.get(); + List active = (name == null) ? List.of() + : Collections.unmodifiableList(collections.getOrDefault(name, List.of())); + if (eventTarget != null) { + FeatureVectorEvent evt = new FeatureVectorEvent(FeatureVectorEvent.APPLY_ACTIVE_FEATUREVECTORS, active); + eventTarget.fireEvent(evt); } - }; - task.messageProperty().addListener((obs,o,n) -> setStatus(n)); - bindProgress(task); - task.setOnSucceeded(e -> { setStatus("Export complete: " + csvPath.getFileName()); clearProgress(); }); - task.setOnFailed(e -> { setStatus("Export failed: " + task.getException()); clearProgress(); }); - executor.submit(task); + }); } @Override -public void applyActiveToWorkspace() { - if (eventTarget == null) { - setStatus("No event target set; cannot apply to workspace."); - return; +public void setEventTarget(EventTarget target) { + if (target instanceof Node n) { + this.eventTarget = n; + } else { + this.eventTarget = null; } - List flat = new ArrayList<>(getActive()); - Platform.runLater(() -> { - FeatureVectorEvent ev = new FeatureVectorEvent( - FeatureVectorEvent.APPLY_ACTIVE_FEATUREVECTORS, - flat - ); - ev.clearExisting = false; // or true if you want to reset consumers - if (eventTarget instanceof Node node) { - node.fireEvent(ev); - } - }); - setStatus("Applied " + flat.size() + " vectors to workspace."); } - private List getActive() { - String name = activeCollection.get(); - return (name == null || name.isBlank()) ? List.of() : repo.get(name); - } + // ---------- internals ---------- - private void recomputeDisplayed() { - List active = getActive(); - totalCount.set(active.size()); - List sampled = DisplayListSampler.sample(active, samplingMode.get()); - Platform.runLater(() -> { - displayed.setAll(sampled); - displayedCount.set(displayed.size()); - }); + private void refreshDisplayedFromActive() { + String name = activeCollectionName.get(); + List src = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); + List sampled = sample(src, samplingMode.get()); + displayedVectors.setAll(sampled); } - private void setStatus(String s) { Platform.runLater(() -> status.set(s == null ? "" : s)); } + private static List sample(List src, SamplingMode mode) { + if (src == null || src.isEmpty()) return List.of(); + int n = src.size(); + switch (mode) { + case ALL -> { return src; } + case HEAD_1000 -> { return src.subList(0, Math.min(1000, n)); } + case TAIL_1000 -> { + if (n <= 1000) return src; + return src.subList(n - 1000, n); + } + case RANDOM_1000 -> { + if (n <= 1000) return src; + ThreadLocalRandom rng = ThreadLocalRandom.current(); + // reservoir-like quick sample + ArrayList copy = new ArrayList<>(src); + for (int i = 0; i < 1000; i++) { + int j = i + rng.nextInt(n - i); + Collections.swap(copy, i, j); + } + return copy.subList(0, 1000); + } + default -> { return src; } + } + } - private void bindProgress(Task t) { - t.progressProperty().addListener((obs,o,n) -> { - double val = (n == null) ? -1 : n.doubleValue(); - Platform.runLater(() -> progress.set(val)); - }); + private static String cleanName(String s) { + if (s == null) return ""; + return s.trim(); } - private void clearProgress() { Platform.runLater(() -> progress.set(-1)); } + 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; + } - @Override public void dispose() { executor.shutdownNow(); } + private static void runFx(Runnable r) { + if (Platform.isFxApplicationThread()) r.run(); + else Platform.runLater(r); + } } From 3a3221cc45a34ea869709a48758a53f27a5ab5e0 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 15 Sep 2025 06:41:22 -0400 Subject: [PATCH 14/50] applyActiveToWorkspace now connected for Hyperspace --- .../edu/jhuapl/trinity/AppAsyncManager.java | 7 + .../panes/FeatureVectorManagerPane.java | 259 +++++++++++++- .../services/FeatureVectorManagerService.java | 41 ++- .../FeatureVectorManagerServiceImpl.java | 323 ++++++++++++++++-- 4 files changed, 592 insertions(+), 38 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 3cb03647..10cf26e2 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -568,6 +568,13 @@ protected Void call() throws Exception { LOG.info("FeatureVector Manager and Services"); // Mirror NEW_FEATURE_COLLECTION into the manager (ignore PROJECT_FEATURE_COLLECTION by design) scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, ev -> { + // ⬇⬇⬇ ADD THIS GUARD ⬇⬇⬇ + if ("FV_MANAGER_APPLY".equals(ev.object2)) { + // Let FeatureVectorEventHandler handle rendering, + // but do NOT add it back into the Manager service. + return; + } + // existing code if (ev.object instanceof FeatureCollection fc) { String collName = FeatureVectorManagerService.deriveCollectionName(ev.object2, fc); fvService.addCollection(collName, fc.getFeatures()); 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 index 645029a6..303fd0ec 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -1,12 +1,21 @@ package edu.jhuapl.trinity.javafx.components.panes; +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.beans.binding.Bindings; import javafx.collections.ListChangeListener; +import javafx.geometry.Insets; import javafx.scene.Scene; -import javafx.scene.control.ListCell; +import javafx.scene.control.*; +import javafx.scene.input.ContextMenuEvent; import javafx.scene.layout.Pane; +import javafx.stage.FileChooser; + +import java.io.File; +import java.util.*; +import java.util.stream.Collectors; public class FeatureVectorManagerPane extends LitPathPane { @@ -21,6 +30,8 @@ public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerSe this.service = service; wireViewToService(); + installCollectionContextMenu(); + installTableContextMenu(); } private void wireViewToService() { @@ -87,6 +98,252 @@ private void wireViewToService() { () -> service.getDisplayedVectors(), service.getDisplayedVectors())); } + // ---------------- 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 -> { + // quick boolean choice via confirmation + 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); + + MenuItem miApply = new MenuItem("Apply Active to Workspace"); + miApply.setOnAction(e -> service.applyActiveToWorkspace()); + + ctx.getItems().addAll(miRename, miDuplicate, miDelete, new SeparatorMenuItem(), + miMergeInto, export, new SeparatorMenuItem(), miApply); + + 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; + + List options = new ArrayList<>(service.getCollectionNames()); + String current = service.activeCollectionNameProperty().get(); + // allow typing a new name; use TextInputDialog to allow new + 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)) + ); + }); + + MenuItem miApply = new MenuItem("Apply Active to Workspace"); + miApply.setOnAction(e -> service.applyActiveToWorkspace()); + + ctx.getItems().addAll(miRemoveSel, miCopyTo, new SeparatorMenuItem(), + miEditLabel, miEditMeta, new SeparatorMenuItem(), miLocate, miApply); + + table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { + if (!ctx.isShowing()) ctx.show(table, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + } + + // ---------------- 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(); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index 71b17828..af3ce60a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -5,6 +5,11 @@ 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. @@ -12,6 +17,7 @@ public interface FeatureVectorManagerService { 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(); @@ -26,19 +32,46 @@ enum SamplingMode { ALL, HEAD_1000, TAIL_1000, RANDOM_1000 } ObjectProperty samplingModeProperty(); /** Add a new collection (or replace existing if same name), selecting it active. */ - void addCollection(String proposedName, java.util.List vectors); + void addCollection(String proposedName, List vectors); /** Append vectors to the currently active collection. */ - void appendVectorsToActive(java.util.List vectors); + void appendVectorsToActive(List vectors); /** Replace the vectors in the currently active collection. */ - void replaceActiveVectors(java.util.List vectors); + 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(); /** Optional: Where events should be fired (e.g., scene.getRoot()). */ - void setEventTarget(javafx.event.EventTarget target); + void setEventTarget(EventTarget target); // ---------- Shared naming helper ---------- /** diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java index 2e89cf80..67924d8f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -1,5 +1,7 @@ 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; @@ -10,10 +12,15 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.EventTarget; +import javafx.scene.Node; +import javafx.scene.Scene; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; import java.util.*; import java.util.concurrent.ThreadLocalRandom; -import javafx.scene.Node; +import java.util.stream.Collectors; /** * In-memory implementation; keeps a map of named collections and exposes a sampled "displayed" list. @@ -25,11 +32,12 @@ public class FeatureVectorManagerServiceImpl implements FeatureVectorManagerServ private final StringProperty activeCollectionName = new SimpleStringProperty(); private final ObservableList displayedVectors = FXCollections.observableArrayList(); private final ObjectProperty samplingMode = new SimpleObjectProperty<>(SamplingMode.ALL); - private Node eventTarget; - public FeatureVectorManagerServiceImpl(InMemoryFeatureVectorRepository ignoredRepo) { - // repo placeholder kept for drop-in compatibility; not used in this in-memory impl. + // 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 the active name or sampling mode changes activeCollectionName.addListener((obs, o, n) -> refreshDisplayedFromActive()); samplingMode.addListener((obs, o, n) -> refreshDisplayedFromActive()); @@ -45,12 +53,11 @@ public void addCollection(String proposedName, List vectors) { if (vectors == null) vectors = List.of(); final String clean = cleanName(proposedName); final String name = uniquify(clean); - final List payload = List.copyOf(vectors); // defensive + final List payload = copyVectors(vectors); runFx(() -> { - collections.put(name, new ArrayList<>(payload)); + collections.put(name, payload); if (!collectionNames.contains(name)) collectionNames.add(name); - // Select new collection by default activeCollectionName.set(name); refreshDisplayedFromActive(); }); @@ -59,56 +66,232 @@ public void addCollection(String proposedName, List vectors) { @Override public void appendVectorsToActive(List vectors) { if (vectors == null || vectors.isEmpty()) return; + runFx(() -> { + String name = ensureActiveCollection(); + collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(copyVectors(vectors)); + refreshDisplayedFromActive(); + }); + } + + @Override + public void replaceActiveVectors(List vectors) { + runFx(() -> { + String name = ensureActiveCollection(); + collections.put(name, 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(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(cleanName( + (proposedName == null || proposedName.isBlank()) ? ("Copy of " + sourceName) : proposedName)); + final List payload = 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)) { + // pick next or previous if any + 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(cloneVector(fv)); + if (id != null) existingIds.add(id); + } + } + } else { + tgt.addAll(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(copyVectors(src)); + FeatureCollectionFile out = new FeatureCollectionFile(file.getAbsolutePath(), false); + out.featureCollection = fc; + out.writeContent(); + } else { // CSV + writeCsv(file, src); + } + } + + @Override + public void removeFromActive(List toRemove) { + if (toRemove == null || toRemove.isEmpty()) return; runFx(() -> { String name = activeCollectionName.get(); - if (name == null) { - // No collection yet: create a default one - name = uniquify("Collection"); - collections.put(name, new ArrayList<>()); - collectionNames.add(name); - activeCollectionName.set(name); + if (name == null) return; + List list = collections.get(name); + if (list == null) return; + // remove by identity (entityId preferred), fallback equals + 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); } - collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(vectors); refreshDisplayedFromActive(); }); } @Override - public void replaceActiveVectors(List vectors) { + 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(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) { - name = uniquify("Collection"); - collectionNames.add(name); - activeCollectionName.set(name); + 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); + } } - collections.put(name, new ArrayList<>(vectors == null ? List.of() : vectors)); refreshDisplayedFromActive(); }); } @Override - public void applyActiveToWorkspace() { + public void bulkEditMetadataInActive(List targets, Map kv) { + if (targets == null || targets.isEmpty() || kv == null || kv.isEmpty()) return; runFx(() -> { String name = activeCollectionName.get(); - List active = (name == null) ? List.of() - : Collections.unmodifiableList(collections.getOrDefault(name, List.of())); - if (eventTarget != null) { - FeatureVectorEvent evt = new FeatureVectorEvent(FeatureVectorEvent.APPLY_ACTIVE_FEATUREVECTORS, active); - eventTarget.fireEvent(evt); + 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 setEventTarget(EventTarget target) { - if (target instanceof Node n) { - this.eventTarget = n; - } else { - this.eventTarget = null; - } +public void applyActiveToWorkspace() { + 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 edu.jhuapl.trinity.data.messages.xai.FeatureCollection(); + fc.setFeatures(active); + + var evt = new edu.jhuapl.trinity.javafx.events.FeatureVectorEvent( + edu.jhuapl.trinity.javafx.events.FeatureVectorEvent.NEW_FEATURE_COLLECTION, + fc + ); + + // IMPORTANT: tag this so AppAsyncManager won’t re-import it + evt.object2 = "FV_MANAGER_APPLY"; + + 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() { @@ -131,7 +314,6 @@ private static List sample(List src, SamplingMode case RANDOM_1000 -> { if (n <= 1000) return src; ThreadLocalRandom rng = ThreadLocalRandom.current(); - // reservoir-like quick sample ArrayList copy = new ArrayList<>(src); for (int i = 0; i < 1000; i++) { int j = i + rng.nextInt(n - i); @@ -158,8 +340,83 @@ private String uniquify(String base) { 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); } + + private static List copyVectors(List in) { + if (in == null) return List.of(); + return in.stream().map(FeatureVectorManagerServiceImpl::cloneVector).collect(Collectors.toCollection(ArrayList::new)); + } + + private 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 java.util.HashMap<>(src.getMetaData())); + } + return fv; + } + + private static void writeCsv(File file, List src) throws Exception { + int maxDim = 0; + for (FeatureVector fv : src) { + if (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 + for (FeatureVector fv : src) { + 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() && data.get(i) != null) { + row.append(data.get(i)); + } + } + w.write(row.toString()); + w.newLine(); + } + } + } + private static String escapeCsv(String s) { + if (s == null) return ""; + if (s.contains(",") || s.contains("\"") || s.contains("\n")) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + return s; + } } From a65038544707cef71974c0a935fd25b8e06cafbf Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 15 Sep 2025 13:09:27 -0400 Subject: [PATCH 15/50] Append and Replace support for both collections and rows of vectors --- .../edu/jhuapl/trinity/AppAsyncManager.java | 2 +- .../components/FeatureVectorManagerView.java | 58 +++++++++++++++++++ .../panes/FeatureVectorManagerPane.java | 58 +++++++++++++++---- .../services/FeatureVectorManagerService.java | 8 ++- .../FeatureVectorManagerServiceImpl.java | 16 ++--- 5 files changed, 122 insertions(+), 20 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 10cf26e2..eb5258bb 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -569,7 +569,7 @@ protected Void call() throws Exception { // Mirror NEW_FEATURE_COLLECTION into the manager (ignore PROJECT_FEATURE_COLLECTION by design) scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, ev -> { // ⬇⬇⬇ ADD THIS GUARD ⬇⬇⬇ - if ("FV_MANAGER_APPLY".equals(ev.object2)) { + if (FeatureVectorManagerService.MANAGER_APPLY_TAG.equals(ev.object2)) { // Let FeatureVectorEventHandler handle rendering, // but do NOT add it back into the Manager service. return; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index f1c5b982..9ca2d989 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -24,6 +24,10 @@ * - 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: * view.getCollectionSelector().setItems(service.getCollectionNames()); */ @@ -61,6 +65,10 @@ public enum DetailLevel { COMPACT, FULL } private final Label statusLabel = new Label("Ready."); private final ProgressBar progressBar = new ProgressBar(); + // Callbacks (wired by container/pane) + private Runnable onApplyAppend; + private Runnable onApplyReplace; + public FeatureVectorManagerView() { buildHeader(); buildTable(); @@ -87,6 +95,16 @@ public FeatureVectorManagerView() { 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 ------------------------------------------------- @@ -213,6 +231,38 @@ private Node buildBottomBlock() { 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(); }); + + applyMenu.getItems().addAll(applyAppend, applyReplace); + + // (Future: Rename, Duplicate, Export, Remove …) + 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); + + // (Future: Copy, Export rows, Delete rows …) + return new ContextMenu(applyMenu); + } + // Status ------------------------------------------------- private HBox buildStatusBar() { @@ -328,16 +378,20 @@ 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); @@ -359,4 +413,8 @@ public void showProgress(boolean show) { 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; } } 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 index 303fd0ec..d4ac8ca9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -1,5 +1,6 @@ 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.FeatureVectorEvent; @@ -160,7 +161,6 @@ private void installCollectionContextMenu() { chooser.setContentText("Target collection:"); chooser.getDialogPane().setPadding(new Insets(10)); chooser.showAndWait().ifPresent(target -> { - // quick boolean choice via confirmation Alert dedup = new Alert(Alert.AlertType.CONFIRMATION, "De-duplicate by entityId while merging?", ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); @@ -180,11 +180,16 @@ private void installCollectionContextMenu() { miExportCsv.setOnAction(e -> exportCollection(FeatureVectorManagerService.ExportFormat.CSV)); export.getItems().addAll(miExportJson, miExportCsv); - MenuItem miApply = new MenuItem("Apply Active to Workspace"); - miApply.setOnAction(e -> service.applyActiveToWorkspace()); + // 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)); + applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); ctx.getItems().addAll(miRename, miDuplicate, miDelete, new SeparatorMenuItem(), - miMergeInto, export, new SeparatorMenuItem(), miApply); + miMergeInto, export, new SeparatorMenuItem(), applyMenu); combo.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { if (!ctx.isShowing()) ctx.show(combo, e.getScreenX(), e.getScreenY()); @@ -230,9 +235,7 @@ private void installTableContextMenu() { List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); if (sel.isEmpty()) return; - List options = new ArrayList<>(service.getCollectionNames()); String current = service.activeCollectionNameProperty().get(); - // allow typing a new name; use TextInputDialog to allow new TextInputDialog dlg = new TextInputDialog(current == null ? "NewCollection" : (current + "-copy")); dlg.setHeaderText("Copy Selected to Collection"); dlg.setContentText("Target name (existing or new):"); @@ -295,11 +298,20 @@ private void installTableContextMenu() { ); }); - MenuItem miApply = new MenuItem("Apply Active to Workspace"); - miApply.setOnAction(e -> service.applyActiveToWorkspace()); + // 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, miApply); + miEditLabel, miEditMeta, new SeparatorMenuItem(), + miLocate, applyMenu); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { if (!ctx.isShowing()) ctx.show(table, e.getScreenX(), e.getScreenY()); @@ -307,6 +319,32 @@ private void installTableContextMenu() { }); } + /** + * 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()) { + // Apply only the selected vectors by firing NEW_FEATURE_COLLECTION, + // tagged so AppAsyncManager does not mirror it back into the manager. + 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 { + // No selection -> use active collection pathway + service.applyActiveToWorkspace(replace); + } + } + // ---------------- Helpers ---------------- private static String safeFilename(String s) { @@ -346,6 +384,6 @@ private void error(String msg) { // Convenience for tests / external triggers public void applyActiveToWorkspace() { - service.applyActiveToWorkspace(); + service.applyActiveToWorkspace(false); } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index af3ce60a..8189a611 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -16,6 +16,8 @@ */ public interface FeatureVectorManagerService { + public static String MANAGER_APPLY_TAG = "FV_MANAGER_APPLY"; + enum SamplingMode { ALL, HEAD_1000, TAIL_1000, RANDOM_1000 } enum ExportFormat { JSON, CSV } @@ -68,8 +70,12 @@ enum ExportFormat { JSON, CSV } void bulkEditMetadataInActive(List targets, Map kv); /** Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). */ - void applyActiveToWorkspace(); + void applyActiveToWorkspace(boolean replace); + // Convenience default + default void applyActiveToWorkspace() { + applyActiveToWorkspace(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 index 67924d8f..d929c931 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -254,30 +254,30 @@ public void bulkEditMetadataInActive(List targets, Map { String name = activeCollectionName.get(); List active = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); if (active == null || active.isEmpty()) return; - var fc = new edu.jhuapl.trinity.data.messages.xai.FeatureCollection(); + var fc = new FeatureCollection(); fc.setFeatures(active); - var evt = new edu.jhuapl.trinity.javafx.events.FeatureVectorEvent( - edu.jhuapl.trinity.javafx.events.FeatureVectorEvent.NEW_FEATURE_COLLECTION, + var evt = new FeatureVectorEvent( + FeatureVectorEvent.NEW_FEATURE_COLLECTION, fc ); - - // IMPORTANT: tag this so AppAsyncManager won’t re-import it - evt.object2 = "FV_MANAGER_APPLY"; + // keep the “no-dup” tag so AppAsyncManager skips re-ingesting: + evt.object2 = MANAGER_APPLY_TAG; + // replace vs append: + evt.clearExisting = replace; 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) { From 0c35c4744842ad37dd79a607ea49e5fe5735831f Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 15 Sep 2025 14:50:05 -0400 Subject: [PATCH 16/50] Fixed label config dimensionLabels now update Dimensionality ListView --- .../handlers/FeatureVectorEventHandler.java | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) 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 eaceabdc..2b535c4b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java @@ -62,8 +62,7 @@ public static FeatureVector cyberToFeatureVector(String label, CyberVector cyber } catch (JsonProcessingException ex) { } - } - + } return fv; } @@ -185,7 +184,6 @@ public void scanLabelsAndLayers(List featureVectors) { FactorLabel.addAllFactorLabels(newFactorLabels); if (!newFeatureLayers.isEmpty()) FeatureLayer.addAllFeatureLayer(newFeatureLayers); - } public void handleClearAllEvent(FeatureVectorEvent event) { @@ -216,28 +214,31 @@ 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())); - - 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); - } + updateDimensionLabels(featureCollection.getDimensionLabels()); } } + + 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); + } + } public void handleLabelConfigEvent(FeatureVectorEvent event) { LabelConfig labelConfig = (LabelConfig) event.object; @@ -302,11 +303,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()); } } From e2cd94d53ec3f658d67d4d33d2ced843067326af Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 15 Sep 2025 19:44:51 -0400 Subject: [PATCH 17/50] Refactored reusable static methods into new FeatureVectorUtils helper class. --- .../edu/jhuapl/trinity/AppAsyncManager.java | 85 ++---- .../components/FeatureVectorManagerView.java | 68 ++--- .../panes/FeatureVectorManagerPane.java | 30 +- .../services/FeatureVectorManagerService.java | 24 +- .../FeatureVectorManagerServiceImpl.java | 182 +++--------- .../javafx/services/FeatureVectorUtils.java | 279 ++++++++++++++++++ 6 files changed, 378 insertions(+), 290 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index eb5258bb..c0f07f28 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -12,6 +12,7 @@ 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.ImageInspectorPane; import edu.jhuapl.trinity.javafx.components.panes.JukeBoxPane; @@ -19,6 +20,7 @@ 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; @@ -58,6 +60,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; @@ -92,17 +98,11 @@ import java.util.Map; import static edu.jhuapl.trinity.App.theConfig; -import edu.jhuapl.trinity.javafx.components.panes.FeatureVectorManagerPane; -import edu.jhuapl.trinity.javafx.components.panes.StatPdfCdfPane; -import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; -import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerServiceImpl; -import edu.jhuapl.trinity.javafx.services.InMemoryFeatureVectorRepository; - /** * @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; @@ -124,7 +124,7 @@ public class AppAsyncManager extends Task { VideoPane videoPane; SpecialEffectsPane specialEffectsPane; StatPdfCdfPane statPdfCdfPane; - FeatureVectorManagerPane featureVectorManagerPane; + FeatureVectorManagerPane featureVectorManagerPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; WaveformPane waveformPane; @@ -151,7 +151,7 @@ public class AppAsyncManager extends Task { MissionTimerX missionTimerX; TimelineAnimation timelineAnimation; - + FeatureVectorManagerService fvService; public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, CircleProgressIndicator progress, Map namedParameters) { @@ -165,25 +165,13 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir // Allow service to fire APPLY_ACTIVE_FEATUREVECTORS back to the app via scene root if (fvService instanceof FeatureVectorManagerServiceImpl impl) { impl.setEventTarget(scene.getRoot()); - } - 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)); - }); - }); + } + 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 @@ -213,7 +201,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..."); @@ -245,9 +233,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) { @@ -454,8 +440,7 @@ protected Void call() throws Exception { switch (e.object) { case File file -> cocoViewerPane.loadCocoFile(file); case CocoObject cocoObject -> cocoViewerPane.loadCocoObject(cocoObject); - default -> { - } + default -> { } } }); } @@ -545,9 +530,7 @@ 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"); @@ -565,18 +548,17 @@ protected Void call() throws Exception { Platform.runLater(() -> statPdfCdfPane.setFeatureVectors((List) e.object)); } }); + LOG.info("FeatureVector Manager and Services"); - // Mirror NEW_FEATURE_COLLECTION into the manager (ignore PROJECT_FEATURE_COLLECTION by design) + // Mirror NEW_FEATURE_COLLECTION into the manager (ignore when Manager itself applied it) scene.getRoot().addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, ev -> { - // ⬇⬇⬇ ADD THIS GUARD ⬇⬇⬇ + // 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 add it back into the Manager service. + // Let FeatureVectorEventHandler handle rendering, but do NOT re-ingest into Manager return; } - // existing code if (ev.object instanceof FeatureCollection fc) { - String collName = FeatureVectorManagerService.deriveCollectionName(ev.object2, fc); + String collName = FeatureVectorUtils.deriveCollectionName(ev.object2, fc.getFeatures()); fvService.addCollection(collName, fc.getFeatures()); } }); @@ -589,7 +571,7 @@ protected Void call() throws Exception { // 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 @@ -630,8 +612,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(); @@ -648,7 +628,6 @@ protected Void call() throws Exception { if (null != retroWavePane) { retroWavePane.animateShow(); } - //@TODO SMP fadeOutConsole(500); } else { hypersurface3DPane.setVisible(false); if (null != projections3DPane) { @@ -662,7 +641,6 @@ protected Void call() throws Exception { hyperspace3DPane.intro(1000); hyperspaceIntroShown = true; } - //@TODO SMP fadeInConsole(500); } }); scene.addEventHandler(ApplicationEvent.SHOW_SHAPE3D_CONTROLS, e -> { @@ -751,7 +729,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); @@ -831,8 +809,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); }); @@ -1089,11 +1065,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/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index 9ca2d989..4ef2fe0c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -1,6 +1,7 @@ package edu.jhuapl.trinity.javafx.components; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.services.FeatureVectorUtils; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; @@ -15,7 +16,6 @@ import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Locale; /** * FeatureVectorManagerView @@ -162,7 +162,8 @@ private void buildTable() { ); colLabel.setMinWidth(120); - colLabel.setCellValueFactory(cd -> new ReadOnlyStringWrapper(opt(cd.getValue().getLabel()))); + colLabel.setCellValueFactory(cd -> + new ReadOnlyStringWrapper(FeatureVectorUtils.nullToEmpty(cd.getValue().getLabel()))); colDim.setMinWidth(70); colDim.setMaxWidth(90); @@ -172,14 +173,18 @@ private void buildTable() { return new ReadOnlyStringWrapper(String.valueOf(dim)); }); - colPreview.setCellValueFactory(cd -> new ReadOnlyStringWrapper(previewList(cd.getValue().getData(), 8))); + colPreview.setCellValueFactory(cd -> + new ReadOnlyStringWrapper(FeatureVectorUtils.previewList(cd.getValue().getData(), 8))); colScore.setMinWidth(80); - colScore.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getScore()))); + colScore.setCellValueFactory(cd -> + new ReadOnlyStringWrapper(FeatureVectorUtils.trimDouble(cd.getValue().getScore()))); colPfa.setMinWidth(70); - colPfa.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getPfa()))); + colPfa.setCellValueFactory(cd -> + new ReadOnlyStringWrapper(FeatureVectorUtils.trimDouble(cd.getValue().getPfa()))); colLayer.setMinWidth(70); - colLayer.setCellValueFactory(cd -> new ReadOnlyStringWrapper(String.valueOf(cd.getValue().getLayer()))); + colLayer.setCellValueFactory(cd -> + new ReadOnlyStringWrapper(String.valueOf(cd.getValue().getLayer()))); table.getColumns().setAll(colIndex, colLabel, colDim, colPreview); @@ -284,34 +289,7 @@ private HBox buildStatusBar() { 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(); - } + // Details/Metadata rendering ---------------------------- private void updateDetailsPreview() { var sel = table.getSelectionModel().getSelectedItems(); @@ -326,9 +304,10 @@ private void updateDetailsPreview() { // Details text StringBuilder sb = new StringBuilder(); - sb.append("Label: ").append(opt(fv.getLabel())).append("\n"); + sb.append("Label: ").append(FeatureVectorUtils.nullToEmpty(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("Score: ").append(FeatureVectorUtils.trimDouble(fv.getScore())) + .append(" PFA: ").append(FeatureVectorUtils.trimDouble(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"); @@ -340,9 +319,7 @@ private void updateDetailsPreview() { 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); + sb.append(FeatureVectorUtils.trimDouble(v)); } } if (data.size() > n) sb.append(", …"); @@ -350,18 +327,7 @@ private void updateDetailsPreview() { valuesPreviewArea.setText(sb.toString()); // Metadata text (key/value per line) - 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()); - } + metaTextArea.setText(FeatureVectorUtils.prettyMetadata(fv.getMetaData())); } private void applyDetailLevel(DetailLevel level) { 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 index d4ac8ca9..c58ee08a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -5,6 +5,7 @@ import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; +import edu.jhuapl.trinity.javafx.services.FeatureVectorUtils; import javafx.beans.binding.Bindings; import javafx.collections.ListChangeListener; import javafx.geometry.Insets; @@ -201,7 +202,8 @@ 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.setInitialFileName(FeatureVectorUtils.safeFilename(current) + + (fmt == FeatureVectorManagerService.ExportFormat.JSON ? ".json" : ".csv")); fc.getExtensionFilters().setAll( new FileChooser.ExtensionFilter("JSON", "*.json"), new FileChooser.ExtensionFilter("CSV", "*.csv"), @@ -278,7 +280,7 @@ private void installTableContextMenu() { dlg.setResultConverter(btn -> { if (btn == ButtonType.OK) { - return parseKeyValues(ta.getText()); + return FeatureVectorUtils.parseKeyValues(ta.getText()); } return null; }); @@ -347,30 +349,6 @@ private void applySelectionOrActive(boolean 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); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index 8189a611..f33e3922 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -1,6 +1,5 @@ package edu.jhuapl.trinity.javafx.services; -import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import javafx.beans.property.ObjectProperty; import javafx.beans.property.StringProperty; @@ -16,7 +15,7 @@ */ public interface FeatureVectorManagerService { - public static String MANAGER_APPLY_TAG = "FV_MANAGER_APPLY"; + String MANAGER_APPLY_TAG = "FV_MANAGER_APPLY"; enum SamplingMode { ALL, HEAD_1000, TAIL_1000, RANDOM_1000 } enum ExportFormat { JSON, CSV } @@ -72,28 +71,11 @@ enum ExportFormat { JSON, CSV } /** Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). */ void applyActiveToWorkspace(boolean replace); - // Convenience default + // Convenience default (append mode) default void applyActiveToWorkspace() { applyActiveToWorkspace(false); } + /** Optional: Where events should be fired (e.g., scene.getRoot()). */ void setEventTarget(EventTarget target); - - // ---------- Shared naming helper ---------- - /** - * Derive a collection name from an import hint (e.g., file path) or fall back to a generic name. - */ - static String deriveCollectionName(Object hint, FeatureCollection fc) { - String name = null; - if (hint instanceof String pathOrHint) { - int slash = Math.max(pathOrHint.lastIndexOf('/'), pathOrHint.lastIndexOf('\\')); - name = (slash >= 0) ? pathOrHint.substring(slash + 1) : pathOrHint; - int dot = name.lastIndexOf('.'); - if (dot > 0) name = name.substring(0, dot); - } - if (name == null || name.trim().isEmpty()) { - name = "FeatureCollection-" + System.currentTimeMillis(); - } - return name.trim(); - } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java index d929c931..2296026d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -15,11 +15,8 @@ import javafx.scene.Node; import javafx.scene.Scene; -import java.io.BufferedWriter; import java.io.File; -import java.io.FileWriter; import java.util.*; -import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; /** @@ -51,9 +48,9 @@ public FeatureVectorManagerServiceImpl(InMemoryFeatureVectorRepository ignoredRe @Override public void addCollection(String proposedName, List vectors) { if (vectors == null) vectors = List.of(); - final String clean = cleanName(proposedName); - final String name = uniquify(clean); - final List payload = copyVectors(vectors); + final String clean = FeatureVectorUtils.cleanCollectionName(proposedName); + final String name = uniquify(clean); + final List payload = FeatureVectorUtils.copyVectors(vectors); runFx(() -> { collections.put(name, payload); @@ -66,18 +63,20 @@ public void addCollection(String proposedName, List vectors) { @Override public void appendVectorsToActive(List vectors) { if (vectors == null || vectors.isEmpty()) return; + final List payload = FeatureVectorUtils.copyVectors(vectors); runFx(() -> { String name = ensureActiveCollection(); - collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(copyVectors(vectors)); + collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(payload); refreshDisplayedFromActive(); }); } @Override public void replaceActiveVectors(List vectors) { + final List payload = FeatureVectorUtils.copyVectors(vectors == null ? List.of() : vectors); runFx(() -> { String name = ensureActiveCollection(); - collections.put(name, copyVectors(vectors == null ? List.of() : vectors)); + collections.put(name, payload); refreshDisplayedFromActive(); }); } @@ -85,7 +84,7 @@ public void replaceActiveVectors(List vectors) { @Override public void renameCollection(String oldName, String newName) { if (oldName == null || newName == null) return; - final String cleanNew = uniquify(cleanName(newName)); + final String cleanNew = uniquify(FeatureVectorUtils.cleanCollectionName(newName)); runFx(() -> { List existing = collections.remove(oldName); if (existing == null) return; @@ -104,9 +103,9 @@ 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(cleanName( - (proposedName == null || proposedName.isBlank()) ? ("Copy of " + sourceName) : proposedName)); - final List payload = copyVectors(src); + final String base = (proposedName == null || proposedName.isBlank()) ? ("Copy of " + sourceName) : proposedName; + final String newName = uniquify(FeatureVectorUtils.cleanCollectionName(base)); + final List payload = FeatureVectorUtils.copyVectors(src); runFx(() -> { collections.put(newName, payload); collectionNames.add(newName); @@ -152,12 +151,12 @@ public void mergeInto(String targetName, String sourceName, boolean dedupByEntit for (FeatureVector fv : src) { String id = fv.getEntityId(); if (id == null || !existingIds.contains(id)) { - tgt.add(cloneVector(fv)); + tgt.add(FeatureVectorUtils.cloneVector(fv)); if (id != null) existingIds.add(id); } } } else { - tgt.addAll(copyVectors(src)); + tgt.addAll(FeatureVectorUtils.copyVectors(src)); } refreshDisplayedFromActive(); }); @@ -169,12 +168,12 @@ public void exportCollection(String name, File file, ExportFormat format) throws List src = collections.getOrDefault(name, List.of()); if (format == ExportFormat.JSON) { FeatureCollection fc = new FeatureCollection(); - fc.setFeatures(copyVectors(src)); + fc.setFeatures(FeatureVectorUtils.copyVectors(src)); FeatureCollectionFile out = new FeatureCollectionFile(file.getAbsolutePath(), false); out.featureCollection = fc; out.writeContent(); } else { // CSV - writeCsv(file, src); + FeatureVectorUtils.writeCsv(file, src); } } @@ -203,6 +202,7 @@ public void removeFromActive(List toRemove) { @Override public void copyToCollection(List toCopy, String targetCollection) { if (toCopy == null || toCopy.isEmpty() || targetCollection == null) return; + final List payload = FeatureVectorUtils.copyVectors(toCopy); runFx(() -> { String target = (collectionNames.contains(targetCollection)) ? targetCollection @@ -210,7 +210,7 @@ public void copyToCollection(List toCopy, String targetCollection collections.computeIfAbsent(target, k -> { if (!collectionNames.contains(target)) collectionNames.add(target); return new ArrayList<>(); - }).addAll(copyVectors(toCopy)); + }).addAll(payload); refreshDisplayedFromActive(); }); } @@ -253,30 +253,30 @@ public void bulkEditMetadataInActive(List targets, Map { - 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 - ); - // keep the “no-dup” tag so AppAsyncManager skips re-ingesting: - evt.object2 = MANAGER_APPLY_TAG; - // replace vs append: - evt.clearExisting = replace; - - if (eventNode != null) eventNode.fireEvent(evt); - else if (eventScene != null) eventScene.getRoot().fireEvent(evt); - }); -} + @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 + ); + // keep the “no-dup” tag so AppAsyncManager skips re-ingesting: + evt.object2 = MANAGER_APPLY_TAG; + // replace vs append: + evt.clearExisting = replace; + + if (eventNode != null) eventNode.fireEvent(evt); + else if (eventScene != null) eventScene.getRoot().fireEvent(evt); + }); + } @Override public void setEventTarget(EventTarget target) { @@ -297,41 +297,14 @@ public void setEventTarget(EventTarget target) { private void refreshDisplayedFromActive() { String name = activeCollectionName.get(); List src = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); - List sampled = sample(src, samplingMode.get()); + List sampled = FeatureVectorUtils.sample(src, samplingMode.get()); displayedVectors.setAll(sampled); } - private static List sample(List src, SamplingMode mode) { - if (src == null || src.isEmpty()) return List.of(); - int n = src.size(); - switch (mode) { - case ALL -> { return src; } - case HEAD_1000 -> { return src.subList(0, Math.min(1000, n)); } - case TAIL_1000 -> { - if (n <= 1000) return src; - return src.subList(n - 1000, n); - } - case RANDOM_1000 -> { - if (n <= 1000) return src; - ThreadLocalRandom rng = ThreadLocalRandom.current(); - ArrayList copy = new ArrayList<>(src); - for (int i = 0; i < 1000; i++) { - int j = i + rng.nextInt(n - i); - Collections.swap(copy, i, j); - } - return copy.subList(0, 1000); - } - default -> { return src; } - } - } - - private static String cleanName(String s) { - if (s == null) return ""; - return s.trim(); - } - + /** Ensure the name is unique among current collections. */ private String uniquify(String base) { - String b = (base == null || base.isBlank()) ? "Collection" : base.trim(); + String b = FeatureVectorUtils.cleanCollectionName( + (base == null || base.isBlank()) ? "Collection" : base); String name = b; int i = 2; while (collectionNames.contains(name)) { @@ -340,6 +313,7 @@ private String uniquify(String base) { return name; } + /** Make sure there is an active collection and return its name. */ private String ensureActiveCollection() { String name = activeCollectionName.get(); if (name == null) { @@ -355,68 +329,4 @@ private static void runFx(Runnable r) { if (Platform.isFxApplicationThread()) r.run(); else Platform.runLater(r); } - - private static List copyVectors(List in) { - if (in == null) return List.of(); - return in.stream().map(FeatureVectorManagerServiceImpl::cloneVector).collect(Collectors.toCollection(ArrayList::new)); - } - - private 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 java.util.HashMap<>(src.getMetaData())); - } - return fv; - } - - private static void writeCsv(File file, List src) throws Exception { - int maxDim = 0; - for (FeatureVector fv : src) { - if (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 - for (FeatureVector fv : src) { - 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() && data.get(i) != null) { - row.append(data.get(i)); - } - } - w.write(row.toString()); - w.newLine(); - } - } - } - private static String escapeCsv(String s) { - if (s == null) return ""; - if (s.contains(",") || s.contains("\"") || s.contains("\n")) { - return "\"" + s.replace("\"", "\"\"") + "\""; - } - return s; - } } 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..c31b7b14 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java @@ -0,0 +1,279 @@ +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.*; +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(); + } + } + } + } + + private static String escapeCsv(String s) { + if (s == null) return ""; + if (s.contains(",") || s.contains("\"") || s.contains("\n")) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + return s; + } +} From 60a858a00066acba543c3333af720eafb82189df Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 16 Sep 2025 06:34:57 -0400 Subject: [PATCH 18/50] Search and Filter capability for FeatureVectorManager --- .../components/FeatureVectorManagerView.java | 104 +++++++++++------- .../panes/FeatureVectorManagerPane.java | 72 ++++++++++-- .../services/FeatureVectorManagerService.java | 15 ++- .../FeatureVectorManagerServiceImpl.java | 75 +++++++------ .../javafx/services/FeatureVectorUtils.java | 6 +- 5 files changed, 189 insertions(+), 83 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index 4ef2fe0c..249bcd74 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -1,7 +1,6 @@ package edu.jhuapl.trinity.javafx.components; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import edu.jhuapl.trinity.javafx.services.FeatureVectorUtils; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; @@ -16,10 +15,11 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Locale; /** * FeatureVectorManagerView - * - Header with Collection selector + Sampling choice + * - 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 @@ -28,8 +28,7 @@ * - 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: - * view.getCollectionSelector().setItems(service.getCollectionNames()); + * Note: The collection ComboBox should be bound to a live ObservableList by the container/pane. */ public class FeatureVectorManagerView extends BorderPane { @@ -42,6 +41,7 @@ public enum DetailLevel { COMPACT, FULL } // 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<>(); @@ -112,8 +112,9 @@ public FeatureVectorManagerView() { 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); + 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( @@ -126,23 +127,27 @@ private HBox buildHeaderBar() { // Let inputs expand/shrink with space collectionSelector.setMaxWidth(Double.MAX_VALUE); samplingChoice.setMaxWidth(Double.MAX_VALUE); - HBox.setHgrow(collectionSelector, Priority.ALWAYS); - HBox.setHgrow(samplingChoice, Priority.ALWAYS); + 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(250); - collectionSelector.setMinWidth(100); + 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(250); - samplingChoice.setMinWidth(100); - samplingChoice.setMaxWidth(Double.MAX_VALUE); + 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 -------------------------------------------------- @@ -162,8 +167,7 @@ private void buildTable() { ); colLabel.setMinWidth(120); - colLabel.setCellValueFactory(cd -> - new ReadOnlyStringWrapper(FeatureVectorUtils.nullToEmpty(cd.getValue().getLabel()))); + colLabel.setCellValueFactory(cd -> new ReadOnlyStringWrapper(opt(cd.getValue().getLabel()))); colDim.setMinWidth(70); colDim.setMaxWidth(90); @@ -173,18 +177,14 @@ private void buildTable() { return new ReadOnlyStringWrapper(String.valueOf(dim)); }); - colPreview.setCellValueFactory(cd -> - new ReadOnlyStringWrapper(FeatureVectorUtils.previewList(cd.getValue().getData(), 8))); + colPreview.setCellValueFactory(cd -> new ReadOnlyStringWrapper(previewList(cd.getValue().getData(), 8))); colScore.setMinWidth(80); - colScore.setCellValueFactory(cd -> - new ReadOnlyStringWrapper(FeatureVectorUtils.trimDouble(cd.getValue().getScore()))); + colScore.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getScore()))); colPfa.setMinWidth(70); - colPfa.setCellValueFactory(cd -> - new ReadOnlyStringWrapper(FeatureVectorUtils.trimDouble(cd.getValue().getPfa()))); + colPfa.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getPfa()))); colLayer.setMinWidth(70); - colLayer.setCellValueFactory(cd -> - new ReadOnlyStringWrapper(String.valueOf(cd.getValue().getLayer()))); + colLayer.setCellValueFactory(cd -> new ReadOnlyStringWrapper(String.valueOf(cd.getValue().getLayer()))); table.getColumns().setAll(colIndex, colLabel, colDim, colPreview); @@ -196,7 +196,6 @@ private void buildTable() { // Details & Metadata panes ------------------------------- private void buildDetailAndMetadataPanes() { - // Details (values preview) valuesPreviewArea.setEditable(false); valuesPreviewArea.setPrefRowCount(8); valuesPreviewArea.setWrapText(true); @@ -208,7 +207,6 @@ private void buildDetailAndMetadataPanes() { detailsSection.setContent(detailsBox); detailsSection.setExpanded(false); - // Metadata (formatted key/value pairs) metaTextArea.setEditable(false); metaTextArea.setPrefRowCount(8); metaTextArea.setWrapText(true); @@ -222,7 +220,6 @@ private void buildDetailAndMetadataPanes() { } private Node buildBottomBlock() { - // Independent (non-Accordion) so both panes can be open at once VBox stack = new VBox(6, detailsSection, metadataSection); stack.setPadding(new Insets(6)); @@ -248,8 +245,6 @@ private ContextMenu buildCollectionContextMenu() { applyReplace.setOnAction(e -> { if (onApplyReplace != null) onApplyReplace.run(); }); applyMenu.getItems().addAll(applyAppend, applyReplace); - - // (Future: Rename, Duplicate, Export, Remove …) return new ContextMenu(applyMenu); } @@ -263,8 +258,6 @@ private ContextMenu buildTableContextMenu() { applyReplace.setOnAction(e -> { if (onApplyReplace != null) onApplyReplace.run(); }); applyMenu.getItems().addAll(applyAppend, applyReplace); - - // (Future: Copy, Export rows, Delete rows …) return new ContextMenu(applyMenu); } @@ -289,25 +282,49 @@ private HBox buildStatusBar() { return status; } - // Details/Metadata rendering ---------------------------- + // 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)"); - // do not auto-collapse; let user control the panes independently return; } FeatureVector fv = sel.get(0); var data = fv.getData(); - // Details text StringBuilder sb = new StringBuilder(); - sb.append("Label: ").append(FeatureVectorUtils.nullToEmpty(fv.getLabel())).append("\n"); + sb.append("Label: ").append(opt(fv.getLabel())).append("\n"); sb.append("Dim: ").append(data == null ? 0 : data.size()).append("\n"); - sb.append("Score: ").append(FeatureVectorUtils.trimDouble(fv.getScore())) - .append(" PFA: ").append(FeatureVectorUtils.trimDouble(fv.getPfa())).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"); @@ -319,15 +336,25 @@ private void updateDetailsPreview() { Double v = data.get(i); if (v == null) sb.append("NaN"); else { - sb.append(FeatureVectorUtils.trimDouble(v)); + 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()); - // Metadata text (key/value per line) - metaTextArea.setText(FeatureVectorUtils.prettyMetadata(fv.getMetaData())); + 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) { @@ -367,6 +394,7 @@ public void showProgress(boolean show) { 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; } 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 index c58ee08a..44ace5ca 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -5,17 +5,19 @@ import edu.jhuapl.trinity.javafx.components.FeatureVectorManagerView; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; -import edu.jhuapl.trinity.javafx.services.FeatureVectorUtils; import javafx.beans.binding.Bindings; import javafx.collections.ListChangeListener; import javafx.geometry.Insets; import javafx.scene.Scene; import javafx.scene.control.*; 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.time.Duration; import java.util.*; import java.util.stream.Collectors; @@ -24,6 +26,9 @@ 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); @@ -34,6 +39,7 @@ public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerSe wireViewToService(); installCollectionContextMenu(); installTableContextMenu(); + installSearchWiring(); // <- hook the Search field } private void wireViewToService() { @@ -100,6 +106,38 @@ private void wireViewToService() { () -> 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() { @@ -202,8 +240,7 @@ private void exportCollection(FeatureVectorManagerService.ExportFormat fmt) { String current = service.activeCollectionNameProperty().get(); if (current == null) return; FileChooser fc = new FileChooser(); - fc.setInitialFileName(FeatureVectorUtils.safeFilename(current) - + (fmt == FeatureVectorManagerService.ExportFormat.JSON ? ".json" : ".csv")); + fc.setInitialFileName(safeFilename(current) + (fmt == FeatureVectorManagerService.ExportFormat.JSON ? ".json" : ".csv")); fc.getExtensionFilters().setAll( new FileChooser.ExtensionFilter("JSON", "*.json"), new FileChooser.ExtensionFilter("CSV", "*.csv"), @@ -280,7 +317,7 @@ private void installTableContextMenu() { dlg.setResultConverter(btn -> { if (btn == ButtonType.OK) { - return FeatureVectorUtils.parseKeyValues(ta.getText()); + return parseKeyValues(ta.getText()); } return null; }); @@ -331,8 +368,6 @@ private void applySelectionOrActive(boolean replace) { List sel = new ArrayList<>(table.getSelectionModel().getSelectedItems()); if (!sel.isEmpty()) { - // Apply only the selected vectors by firing NEW_FEATURE_COLLECTION, - // tagged so AppAsyncManager does not mirror it back into the manager. FeatureCollection fc = new FeatureCollection(); fc.setFeatures(sel); FeatureVectorEvent evt = @@ -342,13 +377,36 @@ private void applySelectionOrActive(boolean replace) { getScene().getRoot().fireEvent(evt); } else { - // No selection -> use active collection pathway 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); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index f33e3922..a5e5061f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.javafx.services; +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import javafx.beans.property.ObjectProperty; import javafx.beans.property.StringProperty; @@ -26,12 +27,18 @@ enum ExportFormat { JSON, CSV } /** Name of the currently active collection. */ StringProperty activeCollectionNameProperty(); - /** Live list of vectors to display (sampling applied). */ + /** 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); @@ -71,10 +78,8 @@ enum ExportFormat { JSON, CSV } /** Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). */ void applyActiveToWorkspace(boolean replace); - // Convenience default (append mode) - default void applyActiveToWorkspace() { - applyActiveToWorkspace(false); - } + // Convenience default + default void applyActiveToWorkspace() { applyActiveToWorkspace(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 index 2296026d..b881234e 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -14,13 +14,18 @@ import javafx.event.EventTarget; import javafx.scene.Node; import javafx.scene.Scene; - import java.io.File; -import java.util.*; +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 "displayed" list. + * In-memory implementation; keeps a map of named collections and exposes a sampled/filtered "displayed" list. */ public class FeatureVectorManagerServiceImpl implements FeatureVectorManagerService { @@ -29,27 +34,30 @@ public class FeatureVectorManagerServiceImpl implements FeatureVectorManagerServ 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 the active name or sampling mode changes + // 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.cleanCollectionName(proposedName); - final String name = uniquify(clean); + final String clean = FeatureVectorUtils.cleanName(proposedName); + final String name = uniquify(clean); final List payload = FeatureVectorUtils.copyVectors(vectors); runFx(() -> { @@ -63,20 +71,18 @@ public void addCollection(String proposedName, List vectors) { @Override public void appendVectorsToActive(List vectors) { if (vectors == null || vectors.isEmpty()) return; - final List payload = FeatureVectorUtils.copyVectors(vectors); runFx(() -> { String name = ensureActiveCollection(); - collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(payload); + collections.computeIfAbsent(name, k -> new ArrayList<>()).addAll(FeatureVectorUtils.copyVectors(vectors)); refreshDisplayedFromActive(); }); } @Override public void replaceActiveVectors(List vectors) { - final List payload = FeatureVectorUtils.copyVectors(vectors == null ? List.of() : vectors); runFx(() -> { String name = ensureActiveCollection(); - collections.put(name, payload); + collections.put(name, FeatureVectorUtils.copyVectors(vectors == null ? List.of() : vectors)); refreshDisplayedFromActive(); }); } @@ -84,7 +90,7 @@ public void replaceActiveVectors(List vectors) { @Override public void renameCollection(String oldName, String newName) { if (oldName == null || newName == null) return; - final String cleanNew = uniquify(FeatureVectorUtils.cleanCollectionName(newName)); + final String cleanNew = uniquify(FeatureVectorUtils.cleanName(newName)); runFx(() -> { List existing = collections.remove(oldName); if (existing == null) return; @@ -103,8 +109,8 @@ public String duplicateCollection(String sourceName, String proposedName) { if (sourceName == null) return null; List src = collections.get(sourceName); if (src == null) return null; - final String base = (proposedName == null || proposedName.isBlank()) ? ("Copy of " + sourceName) : proposedName; - final String newName = uniquify(FeatureVectorUtils.cleanCollectionName(base)); + 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); @@ -122,7 +128,6 @@ public void deleteCollection(String name) { collections.remove(name); collectionNames.remove(name); if (Objects.equals(activeCollectionName.get(), name)) { - // pick next or previous if any if (!collectionNames.isEmpty()) { activeCollectionName.set(collectionNames.get(0)); } else { @@ -185,7 +190,6 @@ public void removeFromActive(List toRemove) { if (name == null) return; List list = collections.get(name); if (list == null) return; - // remove by identity (entityId preferred), fallback equals Set ids = toRemove.stream() .map(FeatureVector::getEntityId) .filter(Objects::nonNull) @@ -202,7 +206,6 @@ public void removeFromActive(List toRemove) { @Override public void copyToCollection(List toCopy, String targetCollection) { if (toCopy == null || toCopy.isEmpty() || targetCollection == null) return; - final List payload = FeatureVectorUtils.copyVectors(toCopy); runFx(() -> { String target = (collectionNames.contains(targetCollection)) ? targetCollection @@ -210,7 +213,7 @@ public void copyToCollection(List toCopy, String targetCollection collections.computeIfAbsent(target, k -> { if (!collectionNames.contains(target)) collectionNames.add(target); return new ArrayList<>(); - }).addAll(payload); + }).addAll(FeatureVectorUtils.copyVectors(toCopy)); refreshDisplayedFromActive(); }); } @@ -268,10 +271,8 @@ public void applyActiveToWorkspace(boolean replace) { FeatureVectorEvent.NEW_FEATURE_COLLECTION, fc ); - // keep the “no-dup” tag so AppAsyncManager skips re-ingesting: - evt.object2 = MANAGER_APPLY_TAG; - // replace vs append: - evt.clearExisting = replace; + 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); @@ -293,18 +294,29 @@ public void setEventTarget(EventTarget target) { } // ---------- internals ---------- +private void refreshDisplayedFromActive() { + String name = activeCollectionName.get(); + List src = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); - private void refreshDisplayedFromActive() { - String name = activeCollectionName.get(); - List src = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); - List sampled = FeatureVectorUtils.sample(src, samplingMode.get()); - displayedVectors.setAll(sampled); - } + // 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); +} - /** Ensure the name is unique among current collections. */ private String uniquify(String base) { - String b = FeatureVectorUtils.cleanCollectionName( - (base == null || base.isBlank()) ? "Collection" : base); + String b = (base == null || base.isBlank()) ? "Collection" : base.trim(); String name = b; int i = 2; while (collectionNames.contains(name)) { @@ -313,7 +325,6 @@ private String uniquify(String base) { return name; } - /** Make sure there is an active collection and return its name. */ private String ensureActiveCollection() { String name = activeCollectionName.get(); if (name == null) { @@ -329,4 +340,4 @@ private static void runFx(Runnable r) { if (Platform.isFxApplicationThread()) r.run(); else Platform.runLater(r); } -} +} \ No newline at end of file diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java index c31b7b14..d4463c21 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java @@ -269,11 +269,15 @@ public static void writeCsv(File file, List src) throws IOExcepti } } - private static String escapeCsv(String s) { + 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(); + } } From 51547191c6fafa3b3e968f94aca69368337024d2 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 16 Sep 2025 12:27:58 -0400 Subject: [PATCH 19/50] FeatureVectorManager GUI support now allows for popping out to second screen or full screen. --- src/main/java/edu/jhuapl/trinity/App.java | 2 + .../edu/jhuapl/trinity/AppAsyncManager.java | 29 +- .../components/FeatureVectorManagerView.java | 44 +- .../panes/FeatureVectorManagerPane.java | 33 +- .../javafx/components/panes/LitPathPane.java | 7 +- .../FeatureVectorManagerPopoutController.java | 563 ++++++++++++++++++ .../javafx/events/ApplicationEvent.java | 1 + .../services/FeatureVectorManagerService.java | 2 - src/main/java/module-info.java | 1 + 9 files changed, 661 insertions(+), 21 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index deca2baf..d069571e 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -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); diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index c0f07f28..a758b0c9 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -98,6 +98,7 @@ import java.util.Map; import static edu.jhuapl.trinity.App.theConfig; +import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; /** * @author Sean Phillips @@ -151,7 +152,7 @@ public class AppAsyncManager extends Task { MissionTimerX missionTimerX; TimelineAnimation timelineAnimation; - + FeatureVectorManagerPopoutController fvPop; FeatureVectorManagerService fvService; public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, CircleProgressIndicator progress, Map namedParameters) { @@ -166,6 +167,8 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir if (fvService instanceof FeatureVectorManagerServiceImpl impl) { impl.setEventTarget(scene.getRoot()); } + fvPop = new FeatureVectorManagerPopoutController(fvService, scene); + setOnSucceeded(e -> Platform.runLater(() -> scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); setOnFailed(e -> Platform.runLater(() -> @@ -577,12 +580,24 @@ protected Void call() throws Exception { // Use the shared service so the view reflects mirrored events featureVectorManagerPane = new FeatureVectorManagerPane(scene, desktopPane, fvService); } - if (!desktopPane.getChildren().contains(featureVectorManagerPane)) { - desktopPane.getChildren().add(featureVectorManagerPane); - featureVectorManagerPane.slideInPane(); - } else { - featureVectorManagerPane.show(); - } + 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.AUTO_PROJECTION_MODE, e -> { boolean enabled = (boolean) e.object; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index 249bcd74..5bc33f9f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -1,21 +1,48 @@ package edu.jhuapl.trinity.javafx.components; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import javafx.beans.property.*; 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.*; -import javafx.scene.layout.*; import javafx.scene.paint.Color; - import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; +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.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; /** * FeatureVectorManagerView @@ -48,10 +75,12 @@ public enum DetailLevel { COMPACT, FULL } 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<>("Preview"); + 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(); @@ -179,6 +208,8 @@ private void buildTable() { colPreview.setCellValueFactory(cd -> new ReadOnlyStringWrapper(previewList(cd.getValue().getData(), 8))); + colImageUrl.setCellValueFactory(cd -> new ReadOnlyStringWrapper(cd.getValue().getImageURL().trim())); + colText.setCellValueFactory(cd -> new ReadOnlyStringWrapper(cd.getValue().getText().trim())); colScore.setMinWidth(80); colScore.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getScore()))); colPfa.setMinWidth(70); @@ -361,7 +392,8 @@ 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); + table.getColumns().setAll(colIndex, colLabel, colDim, colPreview, + colScore, colPfa, colLayer, colImageUrl, colText); } } 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 index 44ace5ca..e4dd7f0e 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -3,23 +3,40 @@ 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.*; 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.time.Duration; -import java.util.*; +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; +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; public class FeatureVectorManagerPane extends LitPathPane { @@ -39,9 +56,15 @@ public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerSe wireViewToService(); installCollectionContextMenu(); installTableContextMenu(); - installSearchWiring(); // <- hook the Search field + 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()); 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 20005689..33755292 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 @@ -238,7 +238,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(); @@ -263,6 +263,11 @@ CornerRadii.EMPTY, new BorderWidths(1), contentPane.setOpacity(0.8); }); } + @Override + public void close() { + super.close(); + onClose(); + } public void onClose() { parent.getChildren().remove(this); } 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..566d2c51 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java @@ -0,0 +1,563 @@ +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.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +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; +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.layout.Background; +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; + +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)); + applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); + + 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/events/ApplicationEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java index 64f573b0..549cfb25 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -45,6 +45,7 @@ public class ApplicationEvent extends Event { 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 SHOW_FEATUREVECTOR_MANAGER = new EventType(ANY, "SHOW_FEATUREVECTOR_MANAGER"); + public static final EventType POPOUT_FEATUREVECTOR_MANAGER = new EventType(ANY, "POPOUT_FEATUREVECTOR_MANAGER"); public ApplicationEvent(EventType eventType, Object object) { this(eventType); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index a5e5061f..7c0c2771 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -1,12 +1,10 @@ package edu.jhuapl.trinity.javafx.services; -import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; 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; diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 6c2b2134..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; From c9b6e773aa8024f272480935c793eaf0c94bdc42 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 16 Sep 2025 17:09:20 -0400 Subject: [PATCH 20/50] Created new HypersurfaceControlsPane. Not wired in. Initial commit for new joint PDF generator engine. Not wired in. --- .../edu/jhuapl/trinity/AppAsyncManager.java | 19 + .../panes/HypersurfaceControlsPane.java | 478 ++++++++++++++++ .../components/panes/SurfaceControlPane.java | 227 -------- .../javafx/events/ApplicationEvent.java | 1 + .../javafx/events/HypersurfaceEvent.java | 151 ++++++ .../javafx/javafx3d/Hypersurface3DPane.java | 16 +- .../utils/statistics/ABComparisonEngine.java | 297 ++++++++++ .../utils/statistics/CanonicalGridPolicy.java | 421 +++++++++++++++ .../utils/statistics/DensityCache.java | 347 ++++++++++++ .../trinity/utils/statistics/GridSpec.java | 54 +- .../statistics/HeatmapThumbnailView.java | 340 ++++++++++++ .../utils/statistics/JpdfBatchEngine.java | 370 +++++++++++++ .../utils/statistics/JpdfProvenance.java | 381 +++++++++++++ .../trinity/utils/statistics/JpdfRecipe.java | 320 +++++++++++ .../utils/statistics/PairGridRecord.java | 203 +++++++ .../trinity/utils/statistics/PairScorer.java | 508 ++++++++++++++++++ .../statistics/PairwiseJpdfConfigPanel.java | 351 ++++++++++++ .../utils/statistics/StatisticEngine.java | 2 +- 18 files changed, 4246 insertions(+), 240 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java delete mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceControlPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index a758b0c9..faf3edf6 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -98,6 +98,7 @@ import java.util.Map; import static edu.jhuapl.trinity.App.theConfig; +import edu.jhuapl.trinity.javafx.components.panes.HypersurfaceControlsPane; import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; /** @@ -117,6 +118,7 @@ public class AppAsyncManager extends Task { Hyperspace3DPane hyperspace3DPane; Hypersurface3DPane hypersurface3DPane; + HypersurfaceControlsPane hypersurfaceControlsPane; ProjectorPane projectorPane; Projections3DPane projections3DPane; TrajectoryTrackerPane trajectoryTrackerPane; @@ -599,6 +601,23 @@ protected Void call() throws Exception { } else fvPop.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) { 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..643def80 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -0,0 +1,478 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.data.files.FeatureCollectionFile; +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +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 java.io.File; +import java.nio.file.Paths; +import javafx.event.Event; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +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.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; +import javafx.stage.FileChooser; + +public class HypersurfaceControlsPane extends LitPathPane { + + private static final int PANEL_WIDTH = 400; + private static final int PANEL_HEIGHT = 640; + + /** preferred width for all Spinners */ + public static double SPINNER_PREF_WIDTH = 125.0; + /** preferred width for all ComboBoxes */ + public static double COMBO_PREF_WIDTH = 220.0; + /** preferred width for all CheckBoxes */ + public static double CHECKBOX_PREF_WIDTH = 100.0; + /** preferred width for all ColorPickers */ + public static double COLOR_PICKER_PREF_WIDTH = 150.0; + + private final Hypersurface3DPane target; + + // Core + 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; + + // UX / Runtime + private CheckBox hoverCheck; + private CheckBox chartsCheck; + private CheckBox markersCheck; + private CheckBox crosshairsCheck; + private Spinner refreshSpinner; + private Spinner queueSpinner; + + 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()); + } + + private TabPane buildTabs() { + // seed 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, e -> fireOnRoot(HypersurfaceEvent.xWidth(xWidthSpinner.getValue()))); + addRow(dimsGrid, 1, "Z length", zWidthSpinner, e -> fireOnRoot(HypersurfaceEvent.zWidth(zWidthSpinner.getValue()))); + addRow(dimsGrid, 2, "Y scale", yScaleSpinner, e -> fireOnRoot(HypersurfaceEvent.yScale(yScaleSpinner.getValue()))); + addRow(dimsGrid, 3, "Range scale", surfScaleSpinner, e -> fireOnRoot(HypersurfaceEvent.surfScale(surfScaleSpinner.getValue()))); + + // ---- Rendering ---- + GridPane renderGrid = formGrid(); + meshTypeCombo = new ComboBox<>(); + meshTypeCombo.getItems().addAll("Surface", "Cylindrical"); + meshTypeCombo.getSelectionModel().select("Surface"); + styleCombo(meshTypeCombo); + meshTypeCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.surfaceRender("Surface".equals(meshTypeCombo.getValue())))); + + drawModeCombo = new ComboBox<>(); + drawModeCombo.getItems().addAll(DrawMode.LINE, DrawMode.FILL); + drawModeCombo.getSelectionModel().select(DrawMode.LINE); + styleCombo(drawModeCombo); + drawModeCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.drawMode(drawModeCombo.getValue()))); + + cullFaceCombo = new ComboBox<>(); + cullFaceCombo.getItems().addAll(CullFace.FRONT, CullFace.BACK, CullFace.NONE); + cullFaceCombo.getSelectionModel().select(CullFace.NONE); + styleCombo(cullFaceCombo); + cullFaceCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.cullFace(cullFaceCombo.getValue()))); + + 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); + colorationCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.coloration(colorationCombo.getValue()))); + + addRow(renderGrid, 0, "Mesh", meshTypeCombo, null); + addRow(renderGrid, 1, "Draw", drawModeCombo, null); + addRow(renderGrid, 2, "Cull", cullFaceCombo, null); + addRow(renderGrid, 3, "Color", colorationCombo, null); + + // ---- Scene (incl. Lighting) ---- + GridPane sceneGrid = formGrid(); + bgPicker = new ColorPicker(bg0); + styleColorPicker(bgPicker); + skyboxCheck = new CheckBox("Skybox"); + styleCheck(skyboxCheck); + + skyboxCheck.setOnAction(e -> fireOnRoot(new HyperspaceEvent(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, skyboxCheck.isSelected()))); + bgPicker.setOnAction(e -> fireOnRoot(new HyperspaceEvent(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, bgPicker.getValue()))); + + enableAmbientCheck = new CheckBox("Ambient"); + enableAmbientCheck.setSelected(true); + styleCheck(enableAmbientCheck); + ambientColorPicker = new ColorPicker(Color.WHITE); + styleColorPicker(ambientColorPicker); + ambientColorPicker.setOnAction(e -> fireOnRoot(HypersurfaceEvent.ambientColor(ambientColorPicker.getValue()))); + enableAmbientCheck.setOnAction(e -> { + boolean on = enableAmbientCheck.isSelected(); + ambientColorPicker.setDisable(!on); + fireOnRoot(HypersurfaceEvent.ambientEnabled(on)); + }); + + enablePointCheck = new CheckBox("Point light"); + enablePointCheck.setSelected(true); + styleCheck(enablePointCheck); + specularColorPicker = new ColorPicker(Color.CYAN); + styleColorPicker(specularColorPicker); + specularColorPicker.setOnAction(e -> fireOnRoot(HypersurfaceEvent.specularColor(specularColorPicker.getValue()))); + enablePointCheck.setOnAction(e -> { + boolean on = enablePointCheck.isSelected(); + specularColorPicker.setDisable(!on); + fireOnRoot(HypersurfaceEvent.pointEnabled(on)); + }); + + addRow(sceneGrid, 0, "Background", bgPicker, null); + addRow(sceneGrid, 1, "Sky", skyboxCheck, null); + addRow(sceneGrid, 2, "Ambient", new HBox(6, enableAmbientCheck, ambientColorPicker), null); + addRow(sceneGrid, 3, "Point", new HBox(6, enablePointCheck, specularColorPicker), null); + + VBox viewTabContent = new VBox(10, + titledBox("Dimensions", dimsGrid), + titledBox("Rendering", renderGrid), + titledBox("Scene", sceneGrid) + ); + 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); + heightModeCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.heightMode(heightModeCombo.getValue()))); + addRow(heightGrid, 0, "Height mode", heightModeCombo, null); + + 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); + gaussianSigmaSpinner = new Spinner<>(0.10, 10.0, 1.0, 0.10); + styleSpinner(smoothingRadiusSpinner); + styleSpinner(gaussianSigmaSpinner); + + smoothingRadiusSpinner.setEditable(true); + gaussianSigmaSpinner.setEditable(true); + + 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))); + + addRow(smoothingGrid, 0, "Smoothing", enableSmoothingCheck, null); + addRow(smoothingGrid, 1, "Method", smoothingCombo, null); + addRow(smoothingGrid, 2, "Radius", smoothingRadiusSpinner, null); + addRow(smoothingGrid, 3, "Sigma", gaussianSigmaSpinner, null); + + GridPane interpGrid = formGrid(); + interpCombo = new ComboBox<>(); + interpCombo.getItems().addAll(SurfaceUtils.Interpolation.values()); + interpCombo.getSelectionModel().select(SurfaceUtils.Interpolation.NEAREST); + styleCombo(interpCombo); + interpCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.interp(interpCombo.getValue()))); + addRow(interpGrid, 0, "Mode", interpCombo, null); + + 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); + + 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))); + + addRow(toneGrid, 0, "Tone map", enableToneMapCheck, null); + addRow(toneGrid, 1, "Operator", toneMapCombo, null); + addRow(toneGrid, 2, "k / γ", toneParamSpinner, null); + + VBox procTabContent = new VBox(10, + titledBox("Height", heightGrid), + titledBox("Smoothing", smoothingGrid), + titledBox("Interpolation", interpGrid), + titledBox("Tone Mapping", toneGrid) + ); + procTabContent.setPadding(new Insets(6)); + + // ---- Ops tab (UX, Runtime, Actions) ---- + GridPane uxGrid = formGrid(); + hoverCheck = new CheckBox("Hover"); + chartsCheck = new CheckBox("Surface charts"); + markersCheck = new CheckBox("Markers"); + crosshairsCheck = new CheckBox("Crosshairs"); + styleCheck(hoverCheck); + styleCheck(chartsCheck); + styleCheck(markersCheck); + styleCheck(crosshairsCheck); + + hoverCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.hoverEnabled(hoverCheck.isSelected()))); + chartsCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.surfaceChartsEnabled(chartsCheck.isSelected()))); + markersCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.dataMarkersEnabled(markersCheck.isSelected()))); + crosshairsCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.crosshairsEnabled(crosshairsCheck.isSelected()))); + + // Two rows, no extra "UX" label in the grid + addRow(uxGrid, 0, "", new HBox(12, hoverCheck, chartsCheck), null); + addRow(uxGrid, 1, "", new HBox(12, markersCheck, crosshairsCheck), null); + + GridPane runtimeGrid = formGrid(); + refreshSpinner = new Spinner<>( + new SpinnerValueFactory.IntegerSpinnerValueFactory( + 10, 10_000, + (target != null) ? (int) target.hypersurfaceRefreshRate : 500, + 10 + ) + ); + queueSpinner = new Spinner<>( + new SpinnerValueFactory.IntegerSpinnerValueFactory( + 100, 1_000_000, + (target != null) ? target.queueLimit : 20_000, + 100 + ) + ); + styleSpinner(refreshSpinner); + styleSpinner(queueSpinner); + + refreshSpinner.setEditable(true); + queueSpinner.setEditable(true); + + refreshSpinner.valueProperty().addListener((o, ov, nv) -> + fireOnRoot(new HyperspaceEvent(HyperspaceEvent.REFRESH_RATE_GUI, nv.longValue()))); + queueSpinner.valueProperty().addListener((o, ov, nv) -> + fireOnRoot(new HyperspaceEvent(HyperspaceEvent.NODE_QUEUELIMIT_GUI, nv))); + + addRow(runtimeGrid, 0, "Refresh (ms)", refreshSpinner, null); + addRow(runtimeGrid, 1, "Queue limit", queueSpinner, null); + + VBox actionsBox = new VBox(6, + buttonRow("Reset View", e -> fireOnRoot(HypersurfaceEvent.resetView())), + buttonRow("Update Render", e -> fireOnRoot(HypersurfaceEvent.updateRender())), + buttonRow("Clear Data", e -> fireOnRoot(HypersurfaceEvent.clearData())), + buttonRow("Unroll Hyperspace Data", e -> fireOnRoot(HypersurfaceEvent.unroll())), + buttonRow("Show Vector Distances", e -> fireOnRoot(HypersurfaceEvent.computeVectorDistances())), + buttonRow("Feature Collection Difference…", e -> openFCAndFire(true)), + buttonRow("Feature Collection Cosine Distance…", e -> openFCAndFire(false)) + ); + + VBox opsTabContent = new VBox(10, + titledBox("UX", uxGrid), + titledBox("Runtime", runtimeGrid), + titledBox("Actions", actionsBox) + ); + opsTabContent.setPadding(new Insets(6)); + + // ---- TabPane ---- + TabPane tabs = new TabPane(); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabs.setPrefWidth(PANEL_WIDTH - 8); + + Tab t1 = new Tab("View", viewTabContent); + Tab t2 = new Tab("Processing", procTabContent); + Tab t3 = new Tab("Ops", opsTabContent); + + tabs.getTabs().addAll(t1, t2, t3); + return tabs; + } + + private void openFCAndFire(boolean difference) { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(difference ? "Load FeatureCollection to Compare…" : "Load FeatureCollection for Cosine Distance…"); + fileChooser.setInitialDirectory(Paths.get(".").toFile()); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); + File file = fileChooser.showOpenDialog(null); + if (file != null) { + try { + FeatureCollectionFile fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); + FeatureCollection fc = fcf.featureCollection; + if (difference) { + fireOnRoot(HypersurfaceEvent.computeCollectionDiff(fc)); + } else { + fireOnRoot(HypersurfaceEvent.computeCosineDistance(fc)); + } + } catch (Exception ex) { + Alert alert = new Alert(Alert.AlertType.ERROR, "Failed to read FeatureCollection:\n" + ex.getMessage(), ButtonType.OK); + alert.showAndWait(); + } + } + } + + 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, + javafx.event.EventHandler onAction) { + 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) { + ((javafx.scene.control.Control) control).setMaxWidth(Double.MAX_VALUE); + } + } + gp.add(control, 1, row); + + if (onAction != null) { + if (control instanceof ComboBox) { + ((ComboBox) control).setOnAction(onAction); + } else if (control instanceof CheckBox) { + ((CheckBox) control).setOnAction(onAction); + } + } + } + + 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 HBox buttonRow(String text, javafx.event.EventHandler handler) { + Button b = new Button(text); + b.setMaxWidth(Double.MAX_VALUE); + b.setOnAction(handler); + HBox row = new HBox(b); + HBox.setHgrow(b, Priority.ALWAYS); + return row; + } + + 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/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/events/ApplicationEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java index 549cfb25..fb5d2903 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -46,6 +46,7 @@ public class ApplicationEvent extends Event { public static final EventType SHOW_STATISTICS_PANE = new EventType(ANY, "SHOW_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 ApplicationEvent(EventType eventType, Object object) { this(eventType); 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..b9505049 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java @@ -0,0 +1,151 @@ +package edu.jhuapl.trinity.javafx.events; + +import javafx.event.Event; +import javafx.event.EventTarget; +import javafx.event.EventType; + +/** + * Hypersurface-specific UI + render events. + */ +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 + + // --- 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 (optional) --- + public static HypersurfaceEvent of(EventType type) { return new HypersurfaceEvent(type); } + public static HypersurfaceEvent of(EventType type, Object payload) { return new HypersurfaceEvent(type, payload); } + + // Examples: + 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); } +} 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 d049a9ef..fc152f2c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -1,7 +1,6 @@ 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.messages.bci.SemanticMap; @@ -74,7 +73,6 @@ 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; @@ -454,6 +452,13 @@ public Hypersurface3DPane(Scene scene) { 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(); @@ -568,8 +573,8 @@ public Hypersurface3DPane(Scene scene) { 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); cm.setAutoHide(true); cm.setHideOnEscape(true); cm.setOpacity(0.85); @@ -1598,7 +1603,7 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { @Override public void addShapleyVector(ShapleyVector shapleyVector) { shapleyVectors.add(shapleyVector); } @Override public void clearShapleyVectors() { shapleyVectors.clear(); } - // ================= NEW: helpers for processing pipeline ================= + // ================= 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)); @@ -1625,5 +1630,4 @@ private void rebuildProcessedGridAndRefresh() { zWidthSpinner.getValueFactory().setValue(zWidth); updateTheMesh(); updateView(true); } - // ========================================================================= } 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..3b10a581 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java @@ -0,0 +1,297 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +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/CanonicalGridPolicy.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java new file mode 100644 index 00000000..bff34c13 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java @@ -0,0 +1,421 @@ +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..ec77596d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java @@ -0,0 +1,347 @@ +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/GridSpec.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java index 117c386c..5f644e24 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java @@ -7,12 +7,12 @@ * @author Sean Phillips */ public final class GridSpec { - private final int binsX; - private final int binsY; - private final Double minX; - private final Double maxX; - private final Double minY; - private final Double maxY; + private int binsX; + private int binsY; + private Double minX; + private Double maxX; + private Double minY; + private Double maxY; /** * Create a GridSpec with automatic bounds. @@ -49,4 +49,46 @@ public GridSpec(int binsX, int binsY, Double minX, Double maxX, Double minY, Dou 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..4fc65b48 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java @@ -0,0 +1,340 @@ +package edu.jhuapl.trinity.utils.statistics; + +import java.util.List; +import java.util.Objects; + +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.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; + +/** + * HeatmapThumbnailView + * -------------------- + * Lightweight JavaFX view that renders a small 2D grid (e.g., PDF/CDF or diff surface) + * to a Canvas using a fast PixelWriter-based mapper. Designed for thumbnail/overview use. + * + * Features: + * - Accepts List> or GridDensityResult (choose PDF or CDF). + * - Sequential or diverging color palette (diverging supports center value). + * - Auto or fixed value range; optional Y-flip for row-major grids. + * - Optional slim legend bar with min/center/max tick labels. + * + * Notes: + * - This view is not interactive; wrap it if you need selection/hover. + * - For very large grids, downsampling happens naturally by Canvas scaling. + * + * @author Sean Phillips + */ +public final class HeatmapThumbnailView extends StackPane { + + public enum PaletteKind { SEQUENTIAL, DIVERGING } + + // --- 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 + + public HeatmapThumbnailView() { + getChildren().add(canvas); + getChildren().add(legend); + setPadding(contentPadding); + + // Relayout & redraw on size changes + widthProperty().addListener(this::onSize); + heightProperty().addListener(this::onSize); + legend.visibleProperty().set(showLegend); + + // Initial layout + layoutChildren(); + } + + // ===================================================================================== + // Public API + // ===================================================================================== + + /** Set the underlying grid (row-major), and request redraw. */ + public void setGrid(List> grid) { + this.grid = grid; + if (autoRange) recomputeRange(); + redraw(); + } + + /** Convenience to set from a GridDensityResult (choose PDF or CDF). */ + 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); + } + + /** Flip Y axis (top row drawn at bottom when true). */ + public void setFlipY(boolean flip) { + this.flipY = flip; + redraw(); + } + + /** Use sequential palette. */ + public void useSequentialPalette() { + this.palette = PaletteKind.SEQUENTIAL; + redraw(); + } + + /** Use diverging palette with specified center (e.g., 0.0 for signed diffs). */ + public void useDivergingPalette(double center) { + this.palette = PaletteKind.DIVERGING; + this.divergingCenter = center; + redraw(); + } + + /** Enable/disable legend bar. */ + public void setShowLegend(boolean show) { + this.showLegend = show; + legend.setVisible(show); + requestLayout(); + redraw(); + } + + /** Use automatic value range from data. */ + public void setAutoRange(boolean auto) { + this.autoRange = auto; + if (auto && grid != null) recomputeRange(); + redraw(); + } + + /** Set fixed value range. Auto-range is disabled. */ + public void setFixedRange(double min, double max) { + this.autoRange = false; + this.vmin = min; + this.vmax = max <= min ? min + 1e-12 : max; + redraw(); + } + + /** Set content padding inside the view. */ + public void setContentPadding(Insets insets) { + this.contentPadding = insets == null ? Insets.EMPTY : insets; + setPadding(contentPadding); + requestLayout(); + redraw(); + } + + /** Expose current min/max (useful for pairing legends externally). */ + 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; } + + // ===================================================================================== + // 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; + double lo = Double.POSITIVE_INFINITY; + double hi = Double.NEGATIVE_INFINITY; + 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) == null ? Double.NaN : row.get(c); + if (Double.isFinite(v)) { + if (v < lo) lo = v; + if (v > hi) hi = v; + } + } + } + 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; + // For diverging with auto range, keep center inside [vmin, vmax] + if (palette == PaletteKind.DIVERGING && (divergingCenter < vmin || divergingCenter > vmax)) { + // leave as-is; mapping will clamp + } + } + + private void redraw() { + GraphicsContext g = canvas.getGraphicsContext2D(); + g.setFill(Color.TRANSPARENT); + g.clearRect(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; + } + + // Create an image at cell resolution, then scale onto canvas. + WritableImage img = new WritableImage(cols, rows); + PixelWriter pw = img.getPixelWriter(); + + // Precompute mapping + 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); + } + } + + // Draw image scaled to canvas + g.drawImage(img, 0, 0, cols, rows, 0, 0, canvas.getWidth(), canvas.getHeight()); + + // Legend + drawLegend(); + } + + private void drawEmpty(GraphicsContext g) { + g.setFill(Color.gray(0.1)); + g.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); + g.setStroke(Color.gray(0.4)); + 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.setFill(Color.TRANSPARENT); + lg.clearRect(0, 0, legend.getWidth(), legend.getHeight()); + + double w = legend.getWidth(); + double h = legend.getHeight(); + + // vertical gradient + 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 by default + 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); + } + + // tick-ish markers (minimal; text labels would add font deps here) + lg.setStroke(Color.gray(0.15)); + lg.strokeRect(0.5, 0.5, w - 1, h - 1); + + // center tick for diverging + if (palette == PaletteKind.DIVERGING) { + double tCenter = (vmax - divergingCenter) / Math.max(1e-12, (vmax - vmin)); // 1 at top, 0 at bottom + double y = (1.0 - tCenter) * h; + lg.setStroke(Color.gray(0.3)); + 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.gray(0.05, 0.0); // fully transparent for NaN/inf + } + double t = (v - min) / span; + t = clamp01(t); + + if (palette == PaletteKind.SEQUENTIAL) { + // Simple perceptual-ish ramp: deep blue -> cyan -> yellow -> near-white + return seqRamp(t); + } else { + // Diverging around 'divergingCenter': blue (<) -> white (0) -> red (>) + if (v <= divergingCenter) { + double lt = (divergingCenter - v) / Math.max(1e-12, divergingCenter - min); // 0..1 + return lerpColor(Color.web("#ffffff"), Color.web("#2b6cb0"), clamp01(lt)); // white to blue + } else { + double rt = (v - divergingCenter) / Math.max(1e-12, max - divergingCenter); + return lerpColor(Color.web("#ffffff"), Color.web("#c53030"), clamp01(rt)); // white to red + } + } + } + + private static Color seqRamp(double t) { + // Blend: #0b3d91 (navy) -> #00bcd4 (cyan) -> #ffeb3b (yellow) -> #ffffff + if (t < 0.33) { + double k = t / 0.33; + return lerpColor(Color.web("#0b3d91"), Color.web("#00bcd4"), k); + } else if (t < 0.67) { + double k = (t - 0.33) / 0.34; + return lerpColor(Color.web("#00bcd4"), Color.web("#ffeb3b"), k); + } else { + double k = (t - 0.67) / 0.33; + return lerpColor(Color.web("#ffeb3b"), Color.web("#ffffff"), 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); + } + + private static double clamp01(double x) { + if (x < 0) return 0; + if (x > 1) return 1; + return x; + } +} 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..8dc3876e --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java @@ -0,0 +1,370 @@ +package edu.jhuapl.trinity.utils.statistics; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +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; + +/** + * 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 { + + private 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 + // ===================================================================================== + + public static BatchResult runComponentPairs(List vectors, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + DensityCache cache) { + 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); + } + } 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 + // ===================================================================================== + + public static BatchResult runWhitelistPairs(List vectors, + JpdfRecipe recipe, + CanonicalGridPolicy canonicalPolicy, + DensityCache cache) { + 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); + } + } 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..91994c9b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java @@ -0,0 +1,381 @@ +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..0b2178f7 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java @@ -0,0 +1,320 @@ +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/PairGridRecord.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java new file mode 100644 index 00000000..a52c962d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java @@ -0,0 +1,203 @@ +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..690a5a93 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java @@ -0,0 +1,508 @@ +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..e043a19c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java @@ -0,0 +1,351 @@ +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 java.util.function.Consumer; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +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.Separator; +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.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +/** + * PairwiseJpdfConfigPanel + * ----------------------- + * Floating JavaFX pane that collects configuration for a JpdfRecipe and emits it via a callback. + * + * Notes: + * - Pure UI: it does not perform computation. Use setOnRun(...) to receive a built JpdfRecipe. + * - "Whitelist" entry is a free-text placeholder here; integrate with a dedicated AxisPair editor later. + * + * @author Sean Phillips + */ +public final class PairwiseJpdfConfigPanel extends BorderPane { + + // --- Callbacks --- + private Consumer onRun; + + // --- General --- + private final TextField recipeNameField = new TextField(); + private final TextArea descriptionArea = new TextArea(); + + // --- 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"); + + // --- Data sufficiency guard --- + private final Spinner minAvgCountPerCellSpinner = new Spinner<>(); + + // --- Outputs & runtime --- + private final ComboBox outputKindCombo = new ComboBox<>(); + private final CheckBox cacheEnabledCheck = new CheckBox("Enable Cache"); + private final CheckBox saveThumbsCheck = new CheckBox("Save Thumbnails"); + + // --- Cohort labels (for provenance/reporting) --- + private final TextField cohortAField = new TextField("A"); + private final TextField cohortBField = new TextField("B"); + + // --- Whitelist placeholder (optional future editor) --- + private final TextArea whitelistArea = new TextArea(); + + // --- Actions --- + private final Button runButton = new Button("Run"); + private final Button resetButton = new Button("Reset"); + + public PairwiseJpdfConfigPanel() { + setPadding(new Insets(10)); + + // Defaults + recipeNameField.setPromptText("Recipe name (required)"); + descriptionArea.setPromptText("Optional description..."); + descriptionArea.setPrefRowCount(2); + + pairSelectionCombo.getItems().addAll(PairSelection.values()); + pairSelectionCombo.setValue(PairSelection.ALL); + + scoreMetricCombo.getItems().addAll(ScoreMetric.values()); + scoreMetricCombo.setValue(ScoreMetric.PEARSON); + + topKSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 10_000, 20, 1)); + topKSpinner.setEditable(true); + + thresholdSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1.0, 0.2, 0.01)); + thresholdSpinner.setEditable(true); + + componentPairsCheck.setSelected(true); + 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); + + includeSelfPairsCheck.setSelected(false); + orderedPairsCheck.setSelected(false); + + binsXSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(2, 4096, 64, 2)); + binsXSpinner.setEditable(true); + binsYSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(2, 4096, 64, 2)); + binsYSpinner.setEditable(true); + + boundsPolicyCombo.getItems().addAll(BoundsPolicy.values()); + boundsPolicyCombo.setValue(BoundsPolicy.DATA_MIN_MAX); + + minAvgCountPerCellSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, 3.0, 0.5)); + minAvgCountPerCellSpinner.setEditable(true); + + outputKindCombo.getItems().addAll(OutputKind.values()); + outputKindCombo.setValue(OutputKind.PDF_AND_CDF); + + cacheEnabledCheck.setSelected(true); + saveThumbsCheck.setSelected(true); + + whitelistArea.setPromptText("Optional whitelist of axis pairs (placeholder).\nIntegrate with an AxisPair editor later."); + whitelistArea.setPrefRowCount(3); + + // Layout + setCenter(buildForm()); + setBottom(buildButtons()); + + // Reactive enable/disable + pairSelectionCombo.valueProperty().addListener((obs, ov, nv) -> updateEnablement()); + updateEnablement(); + + // Actions + runButton.setOnAction(e -> onRun()); + resetButton.setOnAction(e -> resetToDefaults()); + } + + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- + + /** Set a callback to receive a fully-built JpdfRecipe when the user clicks Run. */ + public void setOnRun(Consumer onRun) { + this.onRun = onRun; + } + + // --------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------- + + private VBox buildForm() { + GridPane g = new GridPane(); + g.setHgap(10); + g.setVgap(8); + g.setPadding(new Insets(8)); + + ColumnConstraints c0 = new ColumnConstraints(); + c0.setPercentWidth(32); + ColumnConstraints c1 = new ColumnConstraints(); + c1.setPercentWidth(68); + g.getColumnConstraints().addAll(c0, c1); + + int r = 0; + g.add(new Label("Name"), 0, r); g.add(recipeNameField, 1, r++); + + g.add(new Label("Description"), 0, r); g.add(descriptionArea, 1, r++); + + g.add(new Label("Pair Selection"), 0, r); g.add(pairSelectionCombo, 1, r++); + + HBox scoreBox = new HBox(10, new Label("Score Metric"), scoreMetricCombo); + HBox topkBox = new HBox(10, new Label("Top-K"), topKSpinner); + HBox thBox = new HBox(10, new Label("Threshold"), thresholdSpinner); + VBox selectBox = new VBox(6, scoreBox, topkBox, thBox); + g.add(new Label("Preselection Options"), 0, r); g.add(selectBox, 1, r++); + + VBox compBox = new VBox(6, + componentPairsCheck, + new HBox(10, new Label("Component Start"), compStartSpinner), + new HBox(10, new Label("Component End"), compEndSpinner), + new HBox(10, includeSelfPairsCheck, orderedPairsCheck) + ); + g.add(new Label("Components"), 0, r); g.add(compBox, 1, r++); + + VBox gridBox = new VBox(6, + new HBox(10, new Label("Bins X"), binsXSpinner, new Label("Bins Y"), binsYSpinner), + new HBox(10, new Label("Bounds Policy"), boundsPolicyCombo), + new HBox(10, new Label("Canonical Policy Id"), canonicalPolicyIdField) + ); + g.add(new Label("Grid / Bounds"), 0, r); g.add(gridBox, 1, r++); + + g.add(new Label("Min Avg Count / Cell"), 0, r); g.add(minAvgCountPerCellSpinner, 1, r++); + + VBox outBox = new VBox(6, + new HBox(10, new Label("Output"), outputKindCombo), + new HBox(10, cacheEnabledCheck, saveThumbsCheck) + ); + g.add(new Label("Outputs"), 0, r); g.add(outBox, 1, r++); + + VBox cohortsBox = new VBox(6, + new HBox(10, new Label("Cohort A"), cohortAField), + new HBox(10, new Label("Cohort B"), cohortBField) + ); + g.add(new Label("Cohorts"), 0, r); g.add(cohortsBox, 1, r++); + + g.add(new Label("Whitelist (optional)"), 0, r); g.add(whitelistArea, 1, r++); + + VBox root = new VBox(8, + g, + new Separator() + ); + root.setPadding(new Insets(4)); + return root; + } + + private HBox buildButtons() { + HBox box = new HBox(10, resetButton, runButton); + box.setAlignment(Pos.CENTER_RIGHT); + BorderPane.setMargin(box, new Insets(8, 8, 8, 8)); + HBox.setHgrow(runButton, Priority.NEVER); + return box; + } + + private void updateEnablement() { + PairSelection ps = pairSelectionCombo.getValue(); + boolean needTopK = ps == PairSelection.TOP_K_BY_SCORE; + boolean needThreshold = ps == PairSelection.THRESHOLD_BY_SCORE; + boolean needScoring = ps == PairSelection.TOP_K_BY_SCORE || ps == PairSelection.THRESHOLD_BY_SCORE; + + 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) + .description(descriptionArea.getText() == null ? "" : descriptionArea.getText()) + .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()) + .cohortLabels( + safe(cohortAField.getText(), "A"), + safe(cohortBField.getText(), "B") + ) + .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); + } + + // NOTE: For WHITELIST mode, integrate an AxisPair editor later and call: + // b.clearAxisPairs(); b.addAxisPair(...); + // For now we just emit the recipe with empty explicit list (builder will validate non-empty if WHITELIST is chosen). + return b.build(); + } + + private static String safe(String s, String fallback) { + String t = s == null ? "" : s.trim(); + return t.isEmpty() ? fallback : t; + } + + private void resetToDefaults() { + recipeNameField.clear(); + descriptionArea.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); + + cohortAField.setText("A"); + cohortBField.setText("B"); + + whitelistArea.clear(); + updateEnablement(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java index adb1ae76..b11aac70 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -28,7 +28,7 @@ public enum ScalarType { COSINE_TO_MEAN, PC1_PROJECTION, METRIC_DISTANCE_TO_MEAN, - COMPONENT_AT_DIMENSION // NEW: marginal of a single component across vectors + COMPONENT_AT_DIMENSION //marginal of a single component across vectors } /** From 39c5824fd10d37f757818bd80fcc9980b7dfd4ea Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 16 Sep 2025 21:51:03 -0400 Subject: [PATCH 21/50] First cut at new Pairwise Joint PDF Pane. Sorta works. --- src/main/java/edu/jhuapl/trinity/App.java | 4 + .../edu/jhuapl/trinity/AppAsyncManager.java | 19 + .../components/panes/PairwiseJpdfPane.java | 305 ++++++++++++++ .../javafx/events/ApplicationEvent.java | 1 + .../utils/statistics/JpdfBatchEngine.java | 2 +- .../utils/statistics/PairGridPane.java | 380 ++++++++++++++++++ 6 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index d069571e..b3fe244e 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -347,6 +347,10 @@ private void keyReleased(Stage stage, KeyEvent e) { 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.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 faf3edf6..5de647b9 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -99,6 +99,7 @@ import static edu.jhuapl.trinity.App.theConfig; import edu.jhuapl.trinity.javafx.components.panes.HypersurfaceControlsPane; +import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; /** @@ -127,6 +128,7 @@ public class AppAsyncManager extends Task { VideoPane videoPane; SpecialEffectsPane specialEffectsPane; StatPdfCdfPane statPdfCdfPane; + PairwiseJpdfPane pairwiseJpdfPane; FeatureVectorManagerPane featureVectorManagerPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; @@ -601,6 +603,23 @@ protected Void call() throws Exception { } 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.SHOW_HYPERSPACE_CONTROLS, e -> { if (null == hypersurfaceControlsPane) { // Use the shared service so the view reflects mirrored events 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..ac9be455 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -0,0 +1,305 @@ +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.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; +import edu.jhuapl.trinity.utils.statistics.CanonicalGridPolicy; +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.PairGridPane; +import edu.jhuapl.trinity.utils.statistics.StatisticEngine; +import edu.jhuapl.trinity.utils.statistics.AxisParams; +import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Separator; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +/** + * PairwiseJpdfPane + * ---------------- + * Floating parent (LitPathPane) hosting: + * - PairwiseJpdfConfigPanel (provided by caller, shown on left) + * - PairGridPane (center thumbnails) + * + * The config panel emits a JpdfRecipe via setOnRun(...). This pane runs a batch with + * JpdfBatchEngine.{runComponentPairs,runWhitelistPairs} depending on the recipe and + * renders results in the PairGridPane. Clicking a thumbnail fires + * HypersurfaceGridEvent.{RENDER_PDF, RENDER_CDF} so Hypersurface3DPane can update. + * + * Provide vectors via setCohortA / setCohortB. Cohort B is optional. + * + * Notes: + * - Thumbnails render PDF by default; the "Open as" control decides what is sent to 3D. + */ +public final class PairwiseJpdfPane extends LitPathPane { + + private enum OpenKind { PDF, CDF } + + private final BorderPane root; + private final PairwiseJpdfConfigPanel config; + private final PairGridPane grid; + + private final JpdfBatchEngine engine; + private final DensityCache cache; + + private List cohortA = new ArrayList<>(); + private List cohortB = new ArrayList<>(); + private String cohortALabel = "A"; + private String cohortBLabel = "B"; + + /** Optional external trigger to provide a recipe; not required if you use the panel's Run. */ + private Supplier recipeSupplier; + + // UI controls local to this pane + private final ComboBox openKindCombo = new ComboBox<>(); + + public PairwiseJpdfPane(Scene scene, + Pane parent, + JpdfBatchEngine engine, + DensityCache cache, + PairwiseJpdfConfigPanel configPanel) { + super(scene, parent, + 1100, 760, + new BorderPane(), + "Pairwise Joint Densities", "Batch", + 420.0, 400.0); + + this.root = (BorderPane) this.contentPane; + this.engine = (engine != null) ? engine : new JpdfBatchEngine(); + this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); + this.config = Objects.requireNonNull(configPanel, "configPanel"); + + this.grid = new PairGridPane(); + + buildLayout(); + wireHandlers(scene); + } + + public PairwiseJpdfPane(Scene scene, Pane parent) { + this(scene, parent, null, null, new PairwiseJpdfConfigPanel()); + } + + // --------------------------------------------------------------------- + // 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 PairwiseJpdfConfigPanel getConfigPanel() { return config; } + public PairGridPane getGridPane() { return grid; } + + /** Optional: Use if you want an external Run button elsewhere. */ + public void setRecipeSupplier(Supplier supplier) { this.recipeSupplier = supplier; } + + /** Entry point used by the config panel's Run callback. */ + 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; + } + + // Resolve canonical policy (even if not used, we pass a valid object) + CanonicalGridPolicy policy = CanonicalGridPolicy.get( + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default" + ); + + toast("Computing pairwise densities…", false); + + JpdfBatchEngine.BatchResult batch; + switch (recipe.getPairSelection()) { + case WHITELIST -> { + batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, cache); + } + default -> { + // ALL / TOP_K_BY_SCORE / THRESHOLD_BY_SCORE + batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, cache); + } + } + + // Convert to PairGridPane items for the UI (thumbnails: PDF by default) + List items = new ArrayList<>(batch.jobs.size()); + for (JpdfBatchEngine.PairJobResult job : batch.jobs) { + String xLab = axisLabel(job.xAxis, job.i); + String yLab = axisLabel(job.yAxis, job.j); + + Double score = (job.rank != null) ? job.rank.score : null; + + PairGridPane.PairItem item = PairGridPane.PairItem + .newBuilder(xLab, yLab) + .from(job.density, /*useCDF=*/false, /*flipY=*/true) + .score(score) + .showLegend(false) + .build(); + + items.add(item); + } + + // Sort by score descending (nulls last) + grid.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); + })); + + grid.setItems(items); + + toast("Batch complete: " + items.size() + " surfaces; cacheHits=" + batch.cacheHits + + "; wall=" + batch.wallMillis + " ms.", false); + } + + @Override + public void maximize() { + // Intentionally left for you to wire to your pop-out event if desired. + // Example: + // scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISE_JPDF, Boolean.TRUE)); + } + + // --------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------- + + private void buildLayout() { + // Controls: Open-as selector + convenience buttons + openKindCombo.getItems().setAll(OpenKind.PDF, OpenKind.CDF); + openKindCombo.getSelectionModel().select(OpenKind.PDF); + + Button runBtn = new Button("Run (external)"); + runBtn.setOnAction(e -> { + if (recipeSupplier != null) { + runWithRecipe(recipeSupplier.get()); + } else { + toast("No recipe supplier set; use the panel’s Run button.", true); + } + }); + + Button clearBtn = new Button("Clear Grid"); + clearBtn.setOnAction(e -> grid.clearItems()); + + HBox bar = new HBox(10, + runBtn, + clearBtn, + new Separator(), + openKindCombo + ); + bar.setAlignment(Pos.CENTER_LEFT); + bar.setPadding(new Insets(6, 8, 6, 8)); + + BorderPane left = new BorderPane(); + left.setTop(bar); + left.setCenter(config); + BorderPane.setMargin(config, new Insets(6, 8, 6, 8)); + + root.setLeft(left); + root.setCenter(grid); + BorderPane.setMargin(grid, new Insets(6)); + root.setPadding(new Insets(6)); + root.setBottom(new Separator()); + } + + private void wireHandlers(Scene scene) { + // Wire the config panel to this pane + config.setOnRun(this::runWithRecipe); + + // Click on a thumbnail -> open in 3D (PDF/CDF decided by openKindCombo) + grid.setOnCellClick(click -> { + if (click == null || click.item == null) return; + openClickedCell(click); + }); + + // Convenience: accept NEW_FEATURE_COLLECTION as Cohort A + scene.addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, e -> { + if (e.object instanceof FeatureCollection fc && fc.getFeatures() != null) { + setCohortA(fc.getFeatures(), "A"); + toast("Loaded " + fc.getFeatures().size() + " vectors into Cohort A.", false); + } + }); + } + + private void openClickedCell(PairGridPane.CellClick click) { + PairGridPane.PairItem item = click.item; + OpenKind kind = openKindCombo.getValue() == null ? OpenKind.PDF : openKindCombo.getValue(); + + List> zGrid; + double[] xCenters = null; + double[] yCenters = null; + + if (item.res != null) { + GridDensityResult res = item.res; + if (kind == OpenKind.CDF) { + zGrid = res.cdfAsListGrid(); + } else { + zGrid = res.pdfAsListGrid(); + } + xCenters = res.getxCenters(); + yCenters = res.getyCenters(); + } else { + // Item was constructed with a raw grid (no centers available) + zGrid = item.grid; + // xCenters / yCenters left null (renderer should handle this case) + } + + String label = (kind == OpenKind.CDF ? "CDF: " : "PDF: ") + item.xLabel + " | " + item.yLabel; + + scene.getRoot().fireEvent(new HypersurfaceGridEvent( + kind == OpenKind.CDF ? HypersurfaceGridEvent.RENDER_CDF : HypersurfaceGridEvent.RENDER_PDF, + zGrid, xCenters, yCenters, label)); + + toast("Opened " + (kind == OpenKind.CDF ? "CDF" : "PDF") + " in 3D: " + item.xLabel + " | " + item.yLabel, false); + } + + private static String axisLabel(AxisParams axis, int componentIndexIfAny) { + if (axis == null || axis.getType() == null) return "Axis"; + StatisticEngine.ScalarType t = axis.getType(); + return switch (t) { + case COMPONENT_AT_DIMENSION -> { + int idx = axis.getComponentIndex() != null ? axis.getComponentIndex() : componentIndexIfAny; + yield (idx >= 0) ? ("Comp[" + idx + "]") : "Component"; + } + case METRIC_DISTANCE_TO_MEAN -> { + String m = axis.getMetricName(); + yield (m != null && !m.isBlank()) ? (m + "→mean") : "metric→mean"; + } + default -> t.name(); + }; + } + + private void toast(String msg, boolean isError) { + Platform.runLater(() -> scene.getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), + isError ? Color.ORANGERED : Color.LIGHTGREEN))); + } +} 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 fb5d2903..2cee28d3 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -47,6 +47,7 @@ public class ApplicationEvent extends Event { 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 ApplicationEvent(EventType eventType, Object object) { this(eventType); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java index 8dc3876e..cc724bf1 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java @@ -38,7 +38,7 @@ */ public final class JpdfBatchEngine { - private JpdfBatchEngine() {} + public JpdfBatchEngine() {} /** Per-pair output bundle. */ public static final class PairJobResult implements Serializable { 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..131cabb0 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -0,0 +1,380 @@ +package edu.jhuapl.trinity.utils.statistics; + +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; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +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.GridPane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; + +/** + * PairGridPane + * ------------ + * Scrollable grid of heatmap thumbnails (pairwise JPDF/CDFs/diffs). + * Each cell shows a {@link HeatmapThumbnailView} with a compact title and optional score. + * + * Features: + * - Provide a list of {@link PairItem} (x/y labels, grid or GridDensityResult, palette, range, etc.) + * - Sorting (e.g., by score) and filtering (predicate) + * - Configurable columns, cell size, spacing, legend visibility + * - Click callback (emits {@link CellClick}) so caller can open a detailed/3D view + * + * This class is presentation-only; it does not compute densities. + * + * @author Sean Phillips + */ +public final class PairGridPane extends BorderPane { + + // ===================================================================================== + // Public API types + // ===================================================================================== + + /** Immutable description of one grid to render as a thumbnail. */ + public static final class PairItem { + public final String xLabel; + public final String yLabel; + + /** Render source: either grid is non-null or res is non-null (choose one). */ + public final List> grid; // z[row][col] + public final GridDensityResult res; // optional + public final boolean useCDF; // when res != null + public final boolean flipY; + + /** Palette & range. If palette == DIVERGING, center is used. */ + public final HeatmapThumbnailView.PaletteKind palette; + public final Double center; // diverging center (nullable) + public final boolean autoRange; + public final Double vmin; // when autoRange == false + public final Double vmax; // when autoRange == false + public final boolean showLegend; + + /** Optional score (for sorting / display) and arbitrary user data. */ + public final Double score; + public final Object userData; + + private PairItem(Builder b) { + this.xLabel = b.xLabel; + this.yLabel = b.yLabel; + 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 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"); + } + + /** Provide raw grid (row-major). */ + public Builder grid(List> g) { + this.grid = g; this.res = null; return this; + } + + /** Provide a GridDensityResult; set useCDF accordingly. */ + 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; } + + public PairItem build() { + if (grid == null && res == null) + throw new IllegalArgumentException("Provide grid OR GridDensityResult."); + return new PairItem(this); + } + } + } + + /** Click info for a cell. */ + public static final class CellClick { + public final int index; // index within current (filtered/sorted) list + 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; + } + } + + // ===================================================================================== + // Fields & configuration + // ===================================================================================== + + private final GridPane gridPane = new GridPane(); + private final ScrollPane scroller = new ScrollPane(gridPane); + + private final List allItems = new ArrayList<>(); + private List visibleItems = new ArrayList<>(); + + private Predicate filter = null; + private Comparator sorter = null; + + private int columns = 4; + 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; + + // ===================================================================================== + // Construction + // ===================================================================================== + + public PairGridPane() { + scroller.setFitToWidth(true); + scroller.setPannable(true); + scroller.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroller.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + + gridPane.setHgap(cellHGap); + gridPane.setVgap(cellVGap); + gridPane.setPadding(new Insets(6)); + + setCenter(scroller); + } + + // ===================================================================================== + // Public API + // ===================================================================================== + + public void setItems(List items) { + allItems.clear(); + if (items != null) allItems.addAll(items); + applyFilterSortAndRender(); + } + + public void addItem(PairItem item) { + if (item != null) { + allItems.add(item); + applyFilterSortAndRender(); + } + } + + public void clearItems() { + allItems.clear(); + applyFilterSortAndRender(); + } + + /** Filter: return true to keep an item visible. */ + public void setFilter(Predicate filter) { + this.filter = filter; + applyFilterSortAndRender(); + } + + /** Sorter comparator (e.g., by descending score). */ + public void setSorter(Comparator sorter) { + this.sorter = sorter; + applyFilterSortAndRender(); + } + + /** Convenience: sort by score descending, nulls last. */ + 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); + })); + } + + public void setColumns(int cols) { + this.columns = Math.max(1, cols); + renderGrid(); + } + + public void setCellSize(double width, double height) { + this.cellWidth = Math.max(60, width); + this.cellHeight = Math.max(60, height); + renderGrid(); + } + + public void setCellGaps(double hgap, double vgap) { + this.cellHGap = Math.max(0, hgap); + this.cellVGap = Math.max(0, vgap); + gridPane.setHgap(cellHGap); + gridPane.setVgap(cellVGap); + renderGrid(); + } + + public void setCellPadding(Insets insets) { + this.cellPadding = insets == null ? Insets.EMPTY : insets; + renderGrid(); + } + + public void setShowScoresInHeader(boolean show) { + this.showScoresInHeader = show; + renderGrid(); + } + + /** Register click callback for cells. */ + public void setOnCellClick(Consumer onClick) { + this.onCellClick = onClick; + } + + /** Current visible (filtered/sorted) items snapshot. */ + public List getVisibleItems() { + return Collections.unmodifiableList(visibleItems); + } + + // ===================================================================================== + // 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; + renderGrid(); + } + + private void renderGrid() { + gridPane.getChildren().clear(); + if (visibleItems.isEmpty()) return; + + int colCount = Math.max(1, columns); + int row = 0, col = 0; + + for (int i = 0; i < visibleItems.size(); i++) { + PairItem item = visibleItems.get(i); + Node cell = buildCell(i, item); + gridPane.add(cell, col, row); + col++; + if (col >= colCount) { col = 0; row++; } + } + } + + private Node buildCell(int index, PairItem item) { + // Title (X | Y [+score]) + 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)); + header.setTextFill(Color.web("#E0E0E0")); + + // Heatmap view + 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); + + 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); + view.setFlipY(item.flipY); + + if (item.autoRange) { + view.setAutoRange(true); + } else { + double mn = item.vmin == null ? 0.0 : item.vmin; + double mx = item.vmax == null ? 1.0 : item.vmax; + view.setFixedRange(mn, mx); + } + + if (item.grid != null) { + view.setGrid(item.grid); + } else if (item.res != null) { + view.setFromGridDensity(item.res, item.useCDF, item.flipY); + } + + // Layout: BorderPane per cell + 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); + + cell.setBorder(new Border(new BorderStroke( + Color.web("#3A3A3A"), + BorderStrokeStyle.SOLID, + new CornerRadii(6), + new BorderWidths(1.0) + ))); + cell.setBackground(null); + + // Click handling (entire cell) + 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)); + }); + + return cell; + } +} From 574cd7afd0343861393ba7d6b0de98f5f1b20e3a Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 17 Sep 2025 13:28:59 -0400 Subject: [PATCH 22/50] all embedded Hypersurface GUI controls now moved to dedicated floating HypersurfaceControlsPane --- .../panes/HypersurfaceControlsPane.java | 275 ++++------ .../javafx/javafx3d/Hypersurface3DPane.java | 509 ++++++++++-------- .../javafx/javafx3d/Projections3DPane.java | 2 +- 3 files changed, 364 insertions(+), 422 deletions(-) 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 index 643def80..274a5fb4 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -1,21 +1,14 @@ package edu.jhuapl.trinity.javafx.components.panes; -import edu.jhuapl.trinity.data.files.FeatureCollectionFile; -import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; 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 java.io.File; -import java.nio.file.Paths; import javafx.event.Event; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.ButtonType; import javafx.scene.control.CheckBox; import javafx.scene.control.ColorPicker; import javafx.scene.control.ComboBox; @@ -36,25 +29,20 @@ import javafx.scene.paint.Color; import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; -import javafx.stage.FileChooser; public class HypersurfaceControlsPane extends LitPathPane { private static final int PANEL_WIDTH = 400; private static final int PANEL_HEIGHT = 640; - /** preferred width for all Spinners */ public static double SPINNER_PREF_WIDTH = 125.0; - /** preferred width for all ComboBoxes */ public static double COMBO_PREF_WIDTH = 220.0; - /** preferred width for all CheckBoxes */ public static double CHECKBOX_PREF_WIDTH = 100.0; - /** preferred width for all ColorPickers */ public static double COLOR_PICKER_PREF_WIDTH = 150.0; private final Hypersurface3DPane target; - // Core + // Core controls private Spinner yScaleSpinner; private Spinner surfScaleSpinner; private Spinner xWidthSpinner; @@ -85,14 +73,6 @@ public class HypersurfaceControlsPane extends LitPathPane { private CheckBox enablePointCheck; private ColorPicker specularColorPicker; - // UX / Runtime - private CheckBox hoverCheck; - private CheckBox chartsCheck; - private CheckBox markersCheck; - private CheckBox crosshairsCheck; - private Spinner refreshSpinner; - private Spinner queueSpinner; - 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; @@ -107,14 +87,14 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar } private TabPane buildTabs() { - // seed values + // 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 ---- + // === Dimensions === GridPane dimsGrid = formGrid(); xWidthSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, xWidth0, 4)); zWidthSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, zWidth0, 10)); @@ -130,83 +110,108 @@ private TabPane buildTabs() { yScaleSpinner.setEditable(true); surfScaleSpinner.setEditable(true); - addRow(dimsGrid, 0, "X width", xWidthSpinner, e -> fireOnRoot(HypersurfaceEvent.xWidth(xWidthSpinner.getValue()))); - addRow(dimsGrid, 1, "Z length", zWidthSpinner, e -> fireOnRoot(HypersurfaceEvent.zWidth(zWidthSpinner.getValue()))); - addRow(dimsGrid, 2, "Y scale", yScaleSpinner, e -> fireOnRoot(HypersurfaceEvent.yScale(yScaleSpinner.getValue()))); - addRow(dimsGrid, 3, "Range scale", surfScaleSpinner, e -> fireOnRoot(HypersurfaceEvent.surfScale(surfScaleSpinner.getValue()))); - - // ---- Rendering ---- + 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); - meshTypeCombo.setOnAction(e -> - fireOnRoot(HypersurfaceEvent.surfaceRender("Surface".equals(meshTypeCombo.getValue())))); + addRow(renderGrid, 0, "Mesh", meshTypeCombo); drawModeCombo = new ComboBox<>(); drawModeCombo.getItems().addAll(DrawMode.LINE, DrawMode.FILL); drawModeCombo.getSelectionModel().select(DrawMode.LINE); styleCombo(drawModeCombo); - drawModeCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.drawMode(drawModeCombo.getValue()))); + addRow(renderGrid, 1, "Draw", drawModeCombo); cullFaceCombo = new ComboBox<>(); cullFaceCombo.getItems().addAll(CullFace.FRONT, CullFace.BACK, CullFace.NONE); cullFaceCombo.getSelectionModel().select(CullFace.NONE); styleCombo(cullFaceCombo); - cullFaceCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.cullFace(cullFaceCombo.getValue()))); + 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.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); - colorationCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.coloration(colorationCombo.getValue()))); - - addRow(renderGrid, 0, "Mesh", meshTypeCombo, null); - addRow(renderGrid, 1, "Draw", drawModeCombo, null); - addRow(renderGrid, 2, "Cull", cullFaceCombo, null); - addRow(renderGrid, 3, "Color", colorationCombo, null); + addRow(renderGrid, 3, "Color", colorationCombo); - // ---- Scene (incl. Lighting) ---- + 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); - skyboxCheck.setOnAction(e -> fireOnRoot(new HyperspaceEvent(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, skyboxCheck.isSelected()))); - bgPicker.setOnAction(e -> fireOnRoot(new HyperspaceEvent(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, bgPicker.getValue()))); + 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); - ambientColorPicker.setOnAction(e -> fireOnRoot(HypersurfaceEvent.ambientColor(ambientColorPicker.getValue()))); + + 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); - specularColorPicker.setOnAction(e -> fireOnRoot(HypersurfaceEvent.specularColor(specularColorPicker.getValue()))); + + 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)); }); - - addRow(sceneGrid, 0, "Background", bgPicker, null); - addRow(sceneGrid, 1, "Sky", skyboxCheck, null); - addRow(sceneGrid, 2, "Ambient", new HBox(6, enableAmbientCheck, ambientColorPicker), null); - addRow(sceneGrid, 3, "Point", new HBox(6, enablePointCheck, specularColorPicker), null); + specularColorPicker.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.specularColor(specularColorPicker.getValue()))); VBox viewTabContent = new VBox(10, titledBox("Dimensions", dimsGrid), @@ -215,14 +220,15 @@ private TabPane buildTabs() { ); viewTabContent.setPadding(new Insets(6)); - // ---- Processing tab ---- + // === Processing tab === GridPane heightGrid = formGrid(); heightModeCombo = new ComboBox<>(); heightModeCombo.getItems().addAll(HeightMode.values()); heightModeCombo.getSelectionModel().select(HeightMode.RAW); styleCombo(heightModeCombo); - heightModeCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.heightMode(heightModeCombo.getValue()))); - addRow(heightGrid, 0, "Height mode", heightModeCombo, null); + addRow(heightGrid, 0, "Height mode", heightModeCombo); + heightModeCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.heightMode(heightModeCombo.getValue()))); GridPane smoothingGrid = formGrid(); enableSmoothingCheck = new CheckBox("Enable"); @@ -232,30 +238,35 @@ private TabPane buildTabs() { smoothingCombo.getSelectionModel().select(SurfaceUtils.Smoothing.GAUSSIAN); styleCombo(smoothingCombo); smoothingRadiusSpinner = new Spinner<>(1, 25, 2, 1); - gaussianSigmaSpinner = new Spinner<>(0.10, 10.0, 1.0, 0.10); styleSpinner(smoothingRadiusSpinner); + gaussianSigmaSpinner = new Spinner<>(0.10, 10.0, 1.0, 0.10); styleSpinner(gaussianSigmaSpinner); smoothingRadiusSpinner.setEditable(true); gaussianSigmaSpinner.setEditable(true); - 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))); + addRow(smoothingGrid, 0, "Smoothing", enableSmoothingCheck); + addRow(smoothingGrid, 1, "Method", smoothingCombo); + addRow(smoothingGrid, 2, "Radius", smoothingRadiusSpinner); + addRow(smoothingGrid, 3, "Sigma", gaussianSigmaSpinner); - addRow(smoothingGrid, 0, "Smoothing", enableSmoothingCheck, null); - addRow(smoothingGrid, 1, "Method", smoothingCombo, null); - addRow(smoothingGrid, 2, "Radius", smoothingRadiusSpinner, null); - addRow(smoothingGrid, 3, "Sigma", gaussianSigmaSpinner, null); + 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); - interpCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.interp(interpCombo.getValue()))); - addRow(interpGrid, 0, "Mode", interpCombo, null); + addRow(interpGrid, 0, "Mode", interpCombo); + interpCombo.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.interp(interpCombo.getValue()))); GridPane toneGrid = formGrid(); enableToneMapCheck = new CheckBox("Enable"); @@ -269,13 +280,16 @@ private TabPane buildTabs() { toneParamSpinner.setEditable(true); - 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))); + addRow(toneGrid, 0, "Tone map", enableToneMapCheck); + addRow(toneGrid, 1, "Operator", toneMapCombo); + addRow(toneGrid, 2, "k / γ", toneParamSpinner); - addRow(toneGrid, 0, "Tone map", enableToneMapCheck, null); - addRow(toneGrid, 1, "Operator", toneMapCombo, null); - addRow(toneGrid, 2, "k / γ", toneParamSpinner, null); + 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), @@ -285,107 +299,18 @@ private TabPane buildTabs() { ); procTabContent.setPadding(new Insets(6)); - // ---- Ops tab (UX, Runtime, Actions) ---- - GridPane uxGrid = formGrid(); - hoverCheck = new CheckBox("Hover"); - chartsCheck = new CheckBox("Surface charts"); - markersCheck = new CheckBox("Markers"); - crosshairsCheck = new CheckBox("Crosshairs"); - styleCheck(hoverCheck); - styleCheck(chartsCheck); - styleCheck(markersCheck); - styleCheck(crosshairsCheck); - - hoverCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.hoverEnabled(hoverCheck.isSelected()))); - chartsCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.surfaceChartsEnabled(chartsCheck.isSelected()))); - markersCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.dataMarkersEnabled(markersCheck.isSelected()))); - crosshairsCheck.setOnAction(e -> fireOnRoot(HypersurfaceEvent.crosshairsEnabled(crosshairsCheck.isSelected()))); - - // Two rows, no extra "UX" label in the grid - addRow(uxGrid, 0, "", new HBox(12, hoverCheck, chartsCheck), null); - addRow(uxGrid, 1, "", new HBox(12, markersCheck, crosshairsCheck), null); - - GridPane runtimeGrid = formGrid(); - refreshSpinner = new Spinner<>( - new SpinnerValueFactory.IntegerSpinnerValueFactory( - 10, 10_000, - (target != null) ? (int) target.hypersurfaceRefreshRate : 500, - 10 - ) - ); - queueSpinner = new Spinner<>( - new SpinnerValueFactory.IntegerSpinnerValueFactory( - 100, 1_000_000, - (target != null) ? target.queueLimit : 20_000, - 100 - ) - ); - styleSpinner(refreshSpinner); - styleSpinner(queueSpinner); - - refreshSpinner.setEditable(true); - queueSpinner.setEditable(true); - - refreshSpinner.valueProperty().addListener((o, ov, nv) -> - fireOnRoot(new HyperspaceEvent(HyperspaceEvent.REFRESH_RATE_GUI, nv.longValue()))); - queueSpinner.valueProperty().addListener((o, ov, nv) -> - fireOnRoot(new HyperspaceEvent(HyperspaceEvent.NODE_QUEUELIMIT_GUI, nv))); - - addRow(runtimeGrid, 0, "Refresh (ms)", refreshSpinner, null); - addRow(runtimeGrid, 1, "Queue limit", queueSpinner, null); - - VBox actionsBox = new VBox(6, - buttonRow("Reset View", e -> fireOnRoot(HypersurfaceEvent.resetView())), - buttonRow("Update Render", e -> fireOnRoot(HypersurfaceEvent.updateRender())), - buttonRow("Clear Data", e -> fireOnRoot(HypersurfaceEvent.clearData())), - buttonRow("Unroll Hyperspace Data", e -> fireOnRoot(HypersurfaceEvent.unroll())), - buttonRow("Show Vector Distances", e -> fireOnRoot(HypersurfaceEvent.computeVectorDistances())), - buttonRow("Feature Collection Difference…", e -> openFCAndFire(true)), - buttonRow("Feature Collection Cosine Distance…", e -> openFCAndFire(false)) - ); - - VBox opsTabContent = new VBox(10, - titledBox("UX", uxGrid), - titledBox("Runtime", runtimeGrid), - titledBox("Actions", actionsBox) - ); - opsTabContent.setPadding(new Insets(6)); - - // ---- TabPane ---- + // === TabPane === TabPane tabs = new TabPane(); tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); tabs.setPrefWidth(PANEL_WIDTH - 8); Tab t1 = new Tab("View", viewTabContent); Tab t2 = new Tab("Processing", procTabContent); - Tab t3 = new Tab("Ops", opsTabContent); - tabs.getTabs().addAll(t1, t2, t3); + tabs.getTabs().addAll(t1, t2); return tabs; } - private void openFCAndFire(boolean difference) { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle(difference ? "Load FeatureCollection to Compare…" : "Load FeatureCollection for Cosine Distance…"); - fileChooser.setInitialDirectory(Paths.get(".").toFile()); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); - File file = fileChooser.showOpenDialog(null); - if (file != null) { - try { - FeatureCollectionFile fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); - FeatureCollection fc = fcf.featureCollection; - if (difference) { - fireOnRoot(HypersurfaceEvent.computeCollectionDiff(fc)); - } else { - fireOnRoot(HypersurfaceEvent.computeCosineDistance(fc)); - } - } catch (Exception ex) { - Alert alert = new Alert(Alert.AlertType.ERROR, "Failed to read FeatureCollection:\n" + ex.getMessage(), ButtonType.OK); - alert.showAndWait(); - } - } - } - private void fireOnRoot(Event evt) { if (scene != null && scene.getRoot() != null) { scene.getRoot().fireEvent(evt); @@ -411,8 +336,7 @@ private static GridPane formGrid() { return gp; } - private static void addRow(GridPane gp, int row, String label, javafx.scene.Node control, - javafx.event.EventHandler onAction) { + private static void addRow(GridPane gp, int row, String label, javafx.scene.Node control) { Label l = new Label(label); gp.add(l, 0, row); @@ -420,19 +344,11 @@ private static void addRow(GridPane gp, int row, String label, javafx.scene.Node GridPane.setHgrow(control, Priority.NEVER); } else { GridPane.setHgrow(control, Priority.ALWAYS); - if (control instanceof javafx.scene.control.Control) { - ((javafx.scene.control.Control) control).setMaxWidth(Double.MAX_VALUE); + if (control instanceof javafx.scene.control.Control control1) { + control1.setMaxWidth(Double.MAX_VALUE); } } gp.add(control, 1, row); - - if (onAction != null) { - if (control instanceof ComboBox) { - ((ComboBox) control).setOnAction(onAction); - } else if (control instanceof CheckBox) { - ((CheckBox) control).setOnAction(onAction); - } - } } private static VBox titledBox(String title, javafx.scene.Node content) { @@ -443,15 +359,6 @@ private static VBox titledBox(String title, javafx.scene.Node content) { return box; } - private static HBox buttonRow(String text, javafx.event.EventHandler handler) { - Button b = new Button(text); - b.setMaxWidth(Double.MAX_VALUE); - b.setOnAction(handler); - HBox row = new HBox(b); - HBox.setHgrow(b, Priority.ALWAYS); - return row; - } - private static void styleSpinner(Spinner spinner) { spinner.setPrefWidth(SPINNER_PREF_WIDTH); spinner.setMaxWidth(SPINNER_PREF_WIDTH); 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 fc152f2c..363a317d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -19,6 +19,7 @@ import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; 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; @@ -50,7 +51,6 @@ import javafx.embed.swing.SwingFXUtils; import javafx.event.ActionEvent; import javafx.geometry.Point2D; -import javafx.geometry.Pos; import javafx.scene.AmbientLight; import javafx.scene.Group; import javafx.scene.Node; @@ -62,18 +62,12 @@ 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.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.ToggleGroup; import javafx.scene.effect.Glow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; @@ -88,7 +82,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; @@ -121,7 +114,6 @@ import java.nio.file.Paths; import java.util.*; import java.util.function.Function; -import javafx.scene.control.ComboBox; import javafx.scene.control.Menu; /** @@ -242,31 +234,25 @@ public 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; - private ComboBox heightModeCombo; public Scene scene; HashMap shape3DToCalloutMap; public String imageryBasePath = ""; SurfaceChartPane surfaceChartPane; public AmbientLight ambientLight; public PointLight pointLight; - - // ================= NEW: Processing state & UI ================= - private ComboBox smoothingCombo; - private Spinner smoothingRadiusSpinner; - private Spinner gaussianSigmaSpinner; - private CheckBox enableSmoothingCheck; - - private ComboBox interpCombo; - private SurfaceUtils.Interpolation interpMode = SurfaceUtils.Interpolation.NEAREST; - - private CheckBox enableToneMapCheck; - private ComboBox toneMapCombo; - private Spinner toneParamSpinner; // k (TANH) or gamma (GAMMA) - 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; + public Hypersurface3DPane(Scene scene) { this.scene = scene; shape3DToCalloutMap = new HashMap<>(); @@ -338,8 +324,8 @@ public Hypersurface3DPane(Scene scene) { pointLight.setTranslateY(camera.getTranslateY()); pointLight.setTranslateZ(camera.getTranslateZ() + 500.0); - subScene.setOnMouseEntered(event -> subScene.requestFocus()); - setOnMouseEntered(event -> subScene.requestFocus()); +// subScene.setOnMouseEntered(event -> subScene.requestFocus()); +// setOnMouseEntered(event -> subScene.requestFocus()); subScene.setOnZoom(event -> { double modifier = 50.0; double modifierFactor = 0.1; @@ -553,8 +539,8 @@ public Hypersurface3DPane(Scene scene) { clearAll(); xWidth = DEFAULT_XWIDTH; zWidth = DEFAULT_ZWIDTH; - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); generateRandos(xWidth, zWidth, yScale); originalGrid = deepCopyGrid(dataGrid); updateTheMesh(); @@ -692,16 +678,15 @@ public void computeCosineDistance(FeatureCollection collection) { } private void applySurfaceGridToHypersurface(List> grid) { - HeightMode mode = heightModeCombo.getValue(); double userScale = 1.0; // future: user control - List> scaled = DataUtils.normalizeAndScale(grid, mode, userScale); + List> scaled = DataUtils.normalizeAndScale(grid, heightMode, userScale); dataGrid.clear(); dataGrid.addAll(scaled); originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); rebuildProcessedGridAndRefresh(); // NEW: run pipeline } @@ -713,8 +698,8 @@ public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); rebuildProcessedGridAndRefresh(); // NEW } @@ -737,8 +722,8 @@ public void computeSurfaceDifference(FeatureCollection collection) { originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); rebuildProcessedGridAndRefresh(); // NEW } @@ -760,8 +745,8 @@ public void computeVectorDistances() { originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); rebuildProcessedGridAndRefresh(); // NEW } @@ -1167,207 +1152,247 @@ private void loadSurf3D() { }); extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); + wireEventHandlers(); + +// yScaleSpinner.getValueFactory().valueProperty().addListener(e -> { +// yScale = ((Double) yScaleSpinner.getValue()).floatValue(); +// surfPlot.setFunctionScale(yScale); +// updateTheMesh(); +// }); +// yScaleSpinner.setOnKeyTyped(e -> { +// if (e.getCode() == KeyCode.ENTER) { +// yScale = ((Double) yScaleSpinner.getValue()).floatValue(); +// surfPlot.setFunctionScale(yScale); +// updateTheMesh(); +// } +// }); +// +// surfScaleSpinner.valueProperty().addListener(e -> { +// surfScale = ((Double) surfScaleSpinner.getValue()).floatValue(); +// surfPlot.setRangeX(xWidth * surfScale); +// surfPlot.setRangeY(zWidth * surfScale); +// updateTheMesh(); +// surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); +// surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); +// }); +// surfScaleSpinner.setPrefWidth(125); +// +// xWidthSpinner.valueProperty().addListener(e -> { +// xWidth = ((int) xWidthSpinner.getValue()); +// updateTheMesh(); +// surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); +// surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); +// }); +// +// zWidthSpinner.valueProperty().addListener(e -> { +// zWidth = ((int) zWidthSpinner.getValue()); +// updateTheMesh(); +// surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); +// surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); +// }); +// +// colorationToggle.selectedToggleProperty().addListener(cl -> { +// if (colorByImageRadioButton.isSelected()) { +// colorationMethod = COLORATION.COLOR_BY_IMAGE; +// if (lastImageSource != null) { +// 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); +// } +// updateTheMesh(); +// }); +// meshTypeToggle.selectedToggleProperty().addListener(cl -> { +// surfaceRender = surfaceRadioButton.isSelected(); +// updateTheMesh(); +// }); +// drawModeToggle.selectedToggleProperty().addListener(cl -> { +// if (drawModeLine.isSelected()) +// surfPlot.setDrawMode(DrawMode.LINE); +// else +// surfPlot.setDrawMode(DrawMode.FILL); }); +// +// cullFaceToggle.selectedToggleProperty().addListener(cl -> { +// if (cullFaceFront.isSelected()) +// surfPlot.setCullFace(CullFace.FRONT); +// else if (cullFaceBack.isSelected()) +// surfPlot.setCullFace(CullFace.BACK); +// else surfPlot.setCullFace(CullFace.NONE); +// }); - Spinner yScaleSpinner = new Spinner(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 500.0, yScale, 1.00)); - yScaleSpinner.setEditable(true); - yScaleSpinner.getValueFactory().valueProperty().addListener(e -> { - yScale = ((Double) yScaleSpinner.getValue()).floatValue(); - surfPlot.setFunctionScale(yScale); - updateTheMesh(); + 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); + +// ambientLight.colorProperty().bind(lightPicker.valueProperty()); +// specPicker.setOnAction(e -> ((PhongMaterial) surfPlot.getMaterial()).setSpecularColor(specPicker.getValue())); +// +// enableAmbient.setSelected(true); +// enableAmbient.setOnAction(e -> { +// if (enableAmbient.isSelected()) { lightPicker.setDisable(false); ambientLight.getScope().addAll(surfPlot); } +// else { lightPicker.setDisable(true); ambientLight.getScope().clear(); } +// }); +// +// enablePoint.setOnAction(e -> { +// if (enablePoint.isSelected()) { specPicker.setDisable(false); pointLight.getScope().addAll(surfPlot); } +// else { specPicker.setDisable(true); pointLight.getScope().clear(); } +// }); +// +// Runnable refreshProc = this::rebuildProcessedGridAndRefresh; +// enableSmoothingCheck.setOnAction(e -> refreshProc.run()); +// smoothingCombo.setOnAction(e -> refreshProc.run()); +// smoothingRadiusSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); +// gaussianSigmaSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); +// enableToneMapCheck.setOnAction(e -> refreshProc.run()); +// toneMapCombo.setOnAction(e -> refreshProc.run()); +// toneParamSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); +// interpCombo.setOnAction(e -> { interpMode = interpCombo.getValue(); updateTheMesh(); }); + + 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)); + }); }); - yScaleSpinner.setOnKeyTyped(e -> { - if (e.getCode() == KeyCode.ENTER) { - yScale = ((Double) yScaleSpinner.getValue()).floatValue(); - surfPlot.setFunctionScale(yScale); - updateTheMesh(); + } + /** + * Sets up event handlers for HypersurfaceEvents sent from HypersurfaceControlsPane. + * Updates all rendering state and triggers updates as needed. + */ + private void wireEventHandlers() { +// Scene scene = getScene(); + 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); } - }); - yScaleSpinner.setPrefWidth(125); - - Spinner surfScaleSpinner = new Spinner(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 100.0, surfScale, 1.0)); - surfScaleSpinner.setEditable(true); - surfScaleSpinner.valueProperty().addListener(e -> { - surfScale = ((Double) surfScaleSpinner.getValue()).floatValue(); - surfPlot.setRangeX(xWidth * surfScale); - surfPlot.setRangeY(zWidth * surfScale); updateTheMesh(); - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); }); - surfScaleSpinner.setPrefWidth(125); - xWidthSpinner = new Spinner(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 4000, 200, 4)); - xWidthSpinner.setEditable(true); - xWidthSpinner.valueProperty().addListener(e -> { - xWidth = ((int) xWidthSpinner.getValue()); + 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(); - 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); - zWidthSpinner.valueProperty().addListener(e -> { - zWidth = ((int) zWidthSpinner.getValue()); + 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); }); - zWidthSpinner.setPrefWidth(125); - - heightModeCombo = new ComboBox<>(); - heightModeCombo.getItems().addAll(HeightMode.values()); - heightModeCombo.setValue(HeightMode.RAW); - VBox heightControls = new VBox(5,new Label("Height Mode"),heightModeCombo); - - 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; - if (lastImageSource != null) { - 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.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(); }); - 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(); 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); }); - 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); }); - HBox cullFaceHBox = new HBox(10, cullFaceFront, cullFaceBack, cullFaceNone); - 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()); - ColorPicker specPicker = new ColorPicker(Color.CYAN); - specPicker.setOnAction(e -> ((PhongMaterial) surfPlot.getMaterial()).setSpecularColor(specPicker.getValue())); - - 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(); } + // Rendering modes + scene.addEventHandler(HypersurfaceEvent.SURFACE_RENDER_CHANGED, e -> { + this.surfaceRender = (boolean) e.object; + updateTheMesh(); + }); + 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(); }); - 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(); } + // 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(); + }); + scene.addEventHandler(HypersurfaceEvent.TONEMAP_ENABLE_CHANGED, e -> { + this.toneEnabled = (boolean) e.object; + rebuildProcessedGridAndRefresh(); + }); + 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(); }); - // =================== NEW: Processing UI =================== - enableSmoothingCheck = new CheckBox("Enable Smoothing"); enableSmoothingCheck.setSelected(false); - smoothingCombo = new ComboBox<>(); smoothingCombo.getItems().addAll(SurfaceUtils.Smoothing.values()); smoothingCombo.setValue(SurfaceUtils.Smoothing.GAUSSIAN); - smoothingRadiusSpinner = new Spinner<>(1, 25, 2, 1); smoothingRadiusSpinner.setEditable(true); - gaussianSigmaSpinner = new Spinner<>(0.1, 10.0, 1.0, 0.1); gaussianSigmaSpinner.setEditable(true); - - interpCombo = new ComboBox<>(); interpCombo.getItems().addAll(SurfaceUtils.Interpolation.values()); interpCombo.setValue(SurfaceUtils.Interpolation.NEAREST); - - enableToneMapCheck = new CheckBox("Enable Tone Map"); enableToneMapCheck.setSelected(false); - toneMapCombo = new ComboBox<>(); toneMapCombo.getItems().addAll(SurfaceUtils.ToneMap.values()); toneMapCombo.setValue(SurfaceUtils.ToneMap.NONE); - toneParamSpinner = new Spinner<>(0.10, 10.0, 2.0, 0.10); toneParamSpinner.setEditable(true); - - Runnable refreshProc = this::rebuildProcessedGridAndRefresh; - enableSmoothingCheck.setOnAction(e -> refreshProc.run()); - smoothingCombo.setOnAction(e -> refreshProc.run()); - smoothingRadiusSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); - gaussianSigmaSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); - enableToneMapCheck.setOnAction(e -> refreshProc.run()); - toneMapCombo.setOnAction(e -> refreshProc.run()); - toneParamSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); - interpCombo.setOnAction(e -> { interpMode = interpCombo.getValue(); updateTheMesh(); }); - - VBox smoothingBox = new VBox(6, - new Label("Smoothing"), - enableSmoothingCheck, - new HBox(10, new Label("Method"), smoothingCombo), - new HBox(10, new Label("Radius"), smoothingRadiusSpinner), - new HBox(10, new Label("Sigma (Gaussian)"), gaussianSigmaSpinner) - ); - VBox interpBox = new VBox(6, new Label("Interpolation"), new HBox(10, new Label("Mode"), interpCombo)); - VBox toneBox = new VBox(6, - new Label("Tone Mapping"), - enableToneMapCheck, - new HBox(10, new Label("Operator"), toneMapCombo), - new HBox(10, new Label("k / γ"), toneParamSpinner) - ); - // ========================================================== - - 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), - heightControls, - smoothingBox, // NEW - interpBox, // NEW - toneBox, // NEW - 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("Specular Color"), - enablePoint, - specPicker - ); - StackPane.setAlignment(vbox, Pos.BOTTOM_LEFT); - vbox.setPickOnBounds(false); - getChildren().add(vbox); - 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)); - }); + // 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); }); - } + // 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)); + } public void updateCalloutByFeatureVector(Callout callout, FeatureVector featureVector) { callout.setMainTitleText(featureVector.getLabel()); callout.mainTitleTextNode.setText(callout.getMainTitleText()); @@ -1472,8 +1497,8 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection 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); +// zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); originalGrid = deepCopyGrid(dataGrid); // NEW rebuildProcessedGridAndRefresh(); // NEW @@ -1512,8 +1537,8 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl } zWidth = dataGrid.size(); xWidth = dataGrid.get(0).size(); - zWidthSpinner.getValueFactory().setValue(zWidth); - xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); originalGrid = deepCopyGrid(dataGrid); // NEW rebuildProcessedGridAndRefresh(); // NEW @@ -1563,8 +1588,8 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { LOG.info("Injecting Mesh into Hypersurface... "); startTime = System.nanoTime(); zWidth = rows; xWidth = columns; - zWidthSpinner.getValueFactory().setValue(zWidth); - xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); originalGrid = deepCopyGrid(dataGrid); // NEW rebuildProcessedGridAndRefresh(); // NEW xSphere.setTranslateX((xWidth * surfScale) / 2.0); @@ -1611,23 +1636,33 @@ private static List> deepCopyGrid(List> src) { } private void rebuildProcessedGridAndRefresh() { +// 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; + if (originalGrid == null || originalGrid.isEmpty()) return; List> g = deepCopyGrid(originalGrid); - if (enableSmoothingCheck != null && enableSmoothingCheck.isSelected()) { - SurfaceUtils.Smoothing sm = smoothingCombo.getValue(); - int radius = smoothingRadiusSpinner.getValue(); - double sigma = gaussianSigmaSpinner.getValue(); - g = SurfaceUtils.smooth(g, sm, sigma, radius); + if (smoothingEnabled) { +// SurfaceUtils.Smoothing sm = smoothingCombo.getValue(); +// int radius = smoothingRadiusSpinner.getValue(); +// double sigma = gaussianSigmaSpinner.getValue(); + g = SurfaceUtils.smooth(g, smoothingMethod, gaussianSigma, smoothingRadius); } - if (enableToneMapCheck != null && enableToneMapCheck.isSelected()) { - SurfaceUtils.ToneMap tm = toneMapCombo.getValue(); - double param = toneParamSpinner.getValue(); - g = SurfaceUtils.toneMapGrid(g, tm, param); + if (toneEnabled) { +// SurfaceUtils.ToneMap tm = toneMapCombo.getValue(); +// double param = toneParamSpinner.getValue(); + g = SurfaceUtils.toneMapGrid(g, toneOperator, toneParam); } dataGrid.clear(); dataGrid.addAll(g); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); - xWidthSpinner.getValueFactory().setValue(xWidth); - zWidthSpinner.getValueFactory().setValue(zWidth); +// xWidthSpinner.getValueFactory().setValue(xWidth); +// zWidthSpinner.getValueFactory().setValue(zWidth); updateTheMesh(); updateView(true); } } 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..8a2ae927 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java @@ -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 -> { From 1e41fd0a36fde02bb7d2f7332b332ecbeb9647a8 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 17 Sep 2025 14:43:27 -0400 Subject: [PATCH 23/50] added sync event handling for the HypersurfaceControlsPane --- .../panes/HypersurfaceControlsPane.java | 27 +++ .../javafx/events/HypersurfaceEvent.java | 24 ++- .../javafx/javafx3d/Hypersurface3DPane.java | 173 ++++-------------- 3 files changed, 87 insertions(+), 137 deletions(-) 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 index 274a5fb4..1f2ba532 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -84,6 +84,33 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar BorderPane bp = (BorderPane) this.contentPane; bp.setPadding(new Insets(6)); bp.setCenter(buildTabs()); + + scene.addEventHandler(HypersurfaceEvent.SET_XWIDTH_GUI, e -> { + // Set spinner value if different, *without* firing another event + 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(); + }); + } private TabPane buildTabs() { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java index b9505049..d319f69a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java @@ -5,7 +5,8 @@ import javafx.event.EventType; /** - * Hypersurface-specific UI + render events. + * Hypersurface-specific UI + render events, including + * two-way sync for GUI control values. */ public class HypersurfaceEvent extends Event { @@ -24,6 +25,17 @@ public class HypersurfaceEvent extends Event { 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 @@ -111,11 +123,11 @@ public HypersurfaceEvent(Object source, EventTarget target, EventType type) { return new HypersurfaceEvent(type); } public static HypersurfaceEvent of(EventType type, Object payload) { return new HypersurfaceEvent(type, payload); } - // Examples: + // 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); } @@ -148,4 +160,10 @@ public HypersurfaceEvent(Object source, EventTarget target, EventType> grid) { originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); -// xWidthSpinner.getValueFactory().setValue(xWidth); -// zWidthSpinner.getValueFactory().setValue(zWidth); + syncGuiControls(); rebuildProcessedGridAndRefresh(); // NEW: run pipeline } @@ -698,8 +697,7 @@ public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); -// xWidthSpinner.getValueFactory().setValue(xWidth); -// zWidthSpinner.getValueFactory().setValue(zWidth); + syncGuiControls(); rebuildProcessedGridAndRefresh(); // NEW } @@ -722,8 +720,7 @@ public void computeSurfaceDifference(FeatureCollection collection) { originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); -// xWidthSpinner.getValueFactory().setValue(xWidth); -// zWidthSpinner.getValueFactory().setValue(zWidth); + syncGuiControls(); rebuildProcessedGridAndRefresh(); // NEW } @@ -745,8 +742,7 @@ public void computeVectorDistances() { originalGrid = deepCopyGrid(dataGrid); // NEW xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); -// xWidthSpinner.getValueFactory().setValue(xWidth); -// zWidthSpinner.getValueFactory().setValue(zWidth); + syncGuiControls(); rebuildProcessedGridAndRefresh(); // NEW } @@ -1153,77 +1149,6 @@ private void loadSurf3D() { extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); wireEventHandlers(); - -// yScaleSpinner.getValueFactory().valueProperty().addListener(e -> { -// yScale = ((Double) yScaleSpinner.getValue()).floatValue(); -// surfPlot.setFunctionScale(yScale); -// updateTheMesh(); -// }); -// yScaleSpinner.setOnKeyTyped(e -> { -// if (e.getCode() == KeyCode.ENTER) { -// yScale = ((Double) yScaleSpinner.getValue()).floatValue(); -// surfPlot.setFunctionScale(yScale); -// updateTheMesh(); -// } -// }); -// -// surfScaleSpinner.valueProperty().addListener(e -> { -// surfScale = ((Double) surfScaleSpinner.getValue()).floatValue(); -// surfPlot.setRangeX(xWidth * surfScale); -// surfPlot.setRangeY(zWidth * surfScale); -// updateTheMesh(); -// surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); -// surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); -// }); -// surfScaleSpinner.setPrefWidth(125); -// -// xWidthSpinner.valueProperty().addListener(e -> { -// xWidth = ((int) xWidthSpinner.getValue()); -// updateTheMesh(); -// surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); -// surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); -// }); -// -// zWidthSpinner.valueProperty().addListener(e -> { -// zWidth = ((int) zWidthSpinner.getValue()); -// updateTheMesh(); -// surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); -// surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); -// }); -// -// colorationToggle.selectedToggleProperty().addListener(cl -> { -// if (colorByImageRadioButton.isSelected()) { -// colorationMethod = COLORATION.COLOR_BY_IMAGE; -// if (lastImageSource != null) { -// 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); -// } -// updateTheMesh(); -// }); -// meshTypeToggle.selectedToggleProperty().addListener(cl -> { -// surfaceRender = surfaceRadioButton.isSelected(); -// updateTheMesh(); -// }); -// drawModeToggle.selectedToggleProperty().addListener(cl -> { -// if (drawModeLine.isSelected()) -// surfPlot.setDrawMode(DrawMode.LINE); -// else -// surfPlot.setDrawMode(DrawMode.FILL); }); -// -// cullFaceToggle.selectedToggleProperty().addListener(cl -> { -// if (cullFaceFront.isSelected()) -// surfPlot.setCullFace(CullFace.FRONT); -// else if (cullFaceBack.isSelected()) -// surfPlot.setCullFace(CullFace.BACK); -// else surfPlot.setCullFace(CullFace.NONE); -// }); pointLight.getScope().addAll(surfPlot); sceneRoot.getChildren().add(pointLight); @@ -1233,30 +1158,6 @@ private void loadSurf3D() { ambientLight.getScope().addAll(surfPlot); sceneRoot.getChildren().add(ambientLight); -// ambientLight.colorProperty().bind(lightPicker.valueProperty()); -// specPicker.setOnAction(e -> ((PhongMaterial) surfPlot.getMaterial()).setSpecularColor(specPicker.getValue())); -// -// enableAmbient.setSelected(true); -// enableAmbient.setOnAction(e -> { -// if (enableAmbient.isSelected()) { lightPicker.setDisable(false); ambientLight.getScope().addAll(surfPlot); } -// else { lightPicker.setDisable(true); ambientLight.getScope().clear(); } -// }); -// -// enablePoint.setOnAction(e -> { -// if (enablePoint.isSelected()) { specPicker.setDisable(false); pointLight.getScope().addAll(surfPlot); } -// else { specPicker.setDisable(true); pointLight.getScope().clear(); } -// }); -// -// Runnable refreshProc = this::rebuildProcessedGridAndRefresh; -// enableSmoothingCheck.setOnAction(e -> refreshProc.run()); -// smoothingCombo.setOnAction(e -> refreshProc.run()); -// smoothingRadiusSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); -// gaussianSigmaSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); -// enableToneMapCheck.setOnAction(e -> refreshProc.run()); -// toneMapCombo.setOnAction(e -> refreshProc.run()); -// toneParamSpinner.valueProperty().addListener((obs,o,n) -> refreshProc.run()); -// interpCombo.setOnAction(e -> { interpMode = interpCombo.getValue(); updateTheMesh(); }); - updateLabels(); subScene.sceneProperty().addListener(c -> { Platform.runLater(() -> { @@ -1266,6 +1167,30 @@ private void loadSurf3D() { }); }); } + /** + * 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); + } + } + /** * Sets up event handlers for HypersurfaceEvents sent from HypersurfaceControlsPane. * Updates all rendering state and triggers updates as needed. @@ -1497,8 +1422,7 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection 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); + syncGuiControls(); originalGrid = deepCopyGrid(dataGrid); // NEW rebuildProcessedGridAndRefresh(); // NEW @@ -1537,11 +1461,9 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl } zWidth = dataGrid.size(); xWidth = dataGrid.get(0).size(); -// zWidthSpinner.getValueFactory().setValue(zWidth); -// xWidthSpinner.getValueFactory().setValue(xWidth); - originalGrid = deepCopyGrid(dataGrid); // NEW - rebuildProcessedGridAndRefresh(); // NEW - + syncGuiControls(); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); featureVectors = featureCollection.getFeatures(); } @@ -1588,10 +1510,9 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { LOG.info("Injecting Mesh into Hypersurface... "); startTime = System.nanoTime(); zWidth = rows; xWidth = columns; -// zWidthSpinner.getValueFactory().setValue(zWidth); -// xWidthSpinner.getValueFactory().setValue(xWidth); - originalGrid = deepCopyGrid(dataGrid); // NEW - rebuildProcessedGridAndRefresh(); // NEW + syncGuiControls(); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); xSphere.setTranslateX((xWidth * surfScale) / 2.0); zSphere.setTranslateZ((zWidth * surfScale) / 2.0); Utils.printTotalTime(startTime); @@ -1636,33 +1557,17 @@ private static List> deepCopyGrid(List> src) { } private void rebuildProcessedGridAndRefresh() { -// 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; - if (originalGrid == null || originalGrid.isEmpty()) return; List> g = deepCopyGrid(originalGrid); if (smoothingEnabled) { -// SurfaceUtils.Smoothing sm = smoothingCombo.getValue(); -// int radius = smoothingRadiusSpinner.getValue(); -// double sigma = gaussianSigmaSpinner.getValue(); g = SurfaceUtils.smooth(g, smoothingMethod, gaussianSigma, smoothingRadius); } if (toneEnabled) { -// SurfaceUtils.ToneMap tm = toneMapCombo.getValue(); -// double param = toneParamSpinner.getValue(); g = SurfaceUtils.toneMapGrid(g, toneOperator, toneParam); } dataGrid.clear(); dataGrid.addAll(g); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); -// xWidthSpinner.getValueFactory().setValue(xWidth); -// zWidthSpinner.getValueFactory().setValue(zWidth); + syncGuiControls(); updateTheMesh(); updateView(true); } -} +} \ No newline at end of file From 3965d65b1f5337c726ce3a91ae6148d76bdd6b79 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Thu, 18 Sep 2025 08:12:58 -0400 Subject: [PATCH 24/50] Tweaks to layout to make it more compact --- .../components/panes/PairwiseJpdfPane.java | 194 +++--------- .../statistics/PairwiseJpdfConfigPanel.java | 287 ++++++++++-------- 2 files changed, 208 insertions(+), 273 deletions(-) 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 index ac9be455..0a11d08f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -8,25 +8,20 @@ import edu.jhuapl.trinity.utils.statistics.CanonicalGridPolicy; import edu.jhuapl.trinity.utils.statistics.DensityCache; import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import edu.jhuapl.trinity.utils.statistics.HeatmapThumbnailView; import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine; import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; import edu.jhuapl.trinity.utils.statistics.PairGridPane; -import edu.jhuapl.trinity.utils.statistics.StatisticEngine; -import edu.jhuapl.trinity.utils.statistics.AxisParams; import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; - import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.function.Supplier; - import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; import javafx.scene.control.Separator; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; @@ -34,27 +29,8 @@ import javafx.scene.paint.Color; import javafx.scene.text.Font; -/** - * PairwiseJpdfPane - * ---------------- - * Floating parent (LitPathPane) hosting: - * - PairwiseJpdfConfigPanel (provided by caller, shown on left) - * - PairGridPane (center thumbnails) - * - * The config panel emits a JpdfRecipe via setOnRun(...). This pane runs a batch with - * JpdfBatchEngine.{runComponentPairs,runWhitelistPairs} depending on the recipe and - * renders results in the PairGridPane. Clicking a thumbnail fires - * HypersurfaceGridEvent.{RENDER_PDF, RENDER_CDF} so Hypersurface3DPane can update. - * - * Provide vectors via setCohortA / setCohortB. Cohort B is optional. - * - * Notes: - * - Thumbnails render PDF by default; the "Open as" control decides what is sent to 3D. - */ public final class PairwiseJpdfPane extends LitPathPane { - private enum OpenKind { PDF, CDF } - private final BorderPane root; private final PairwiseJpdfConfigPanel config; private final PairGridPane grid; @@ -67,12 +43,8 @@ private enum OpenKind { PDF, CDF } private String cohortALabel = "A"; private String cohortBLabel = "B"; - /** Optional external trigger to provide a recipe; not required if you use the panel's Run. */ private Supplier recipeSupplier; - // UI controls local to this pane - private final ComboBox openKindCombo = new ComboBox<>(); - public PairwiseJpdfPane(Scene scene, Pane parent, JpdfBatchEngine engine, @@ -90,6 +62,7 @@ public PairwiseJpdfPane(Scene scene, this.config = Objects.requireNonNull(configPanel, "configPanel"); this.grid = new PairGridPane(); + this.grid.setOnCellClick(click -> openCellIn3D(click.item)); buildLayout(); wireHandlers(scene); @@ -99,10 +72,6 @@ public PairwiseJpdfPane(Scene scene, Pane parent) { this(scene, parent, null, null, new PairwiseJpdfConfigPanel()); } - // --------------------------------------------------------------------- - // 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; @@ -116,21 +85,12 @@ public void setCohortB(List vectors, String label) { public PairwiseJpdfConfigPanel getConfigPanel() { return config; } public PairGridPane getGridPane() { return grid; } - /** Optional: Use if you want an external Run button elsewhere. */ public void setRecipeSupplier(Supplier supplier) { this.recipeSupplier = supplier; } - /** Entry point used by the config panel's Run callback. */ 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; - } + 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; } - // Resolve canonical policy (even if not used, we pass a valid object) CanonicalGridPolicy policy = CanonicalGridPolicy.get( (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) ? recipe.getCanonicalPolicyId() @@ -141,41 +101,27 @@ public void runWithRecipe(JpdfRecipe recipe) { JpdfBatchEngine.BatchResult batch; switch (recipe.getPairSelection()) { - case WHITELIST -> { - batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, cache); - } - default -> { - // ALL / TOP_K_BY_SCORE / THRESHOLD_BY_SCORE - batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, cache); - } + case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, cache); + default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, cache); } - // Convert to PairGridPane items for the UI (thumbnails: PDF by default) List items = new ArrayList<>(batch.jobs.size()); for (JpdfBatchEngine.PairJobResult job : batch.jobs) { - String xLab = axisLabel(job.xAxis, job.i); - String yLab = axisLabel(job.yAxis, job.j); - - Double score = (job.rank != null) ? job.rank.score : null; - - PairGridPane.PairItem item = PairGridPane.PairItem - .newBuilder(xLab, yLab) - .from(job.density, /*useCDF=*/false, /*flipY=*/true) - .score(score) - .showLegend(false) - .build(); - - items.add(item); + 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) + .from(job.density, false, true) // PDF thumbnail by default, flipY=true + .palette(HeatmapThumbnailView.PaletteKind.SEQUENTIAL) + .autoRange(true) + .showLegend(false); + + if (job.rank != null) b.score(job.rank.score); + items.add(b.build()); } - - // Sort by score descending (nulls last) - grid.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); - })); - grid.setItems(items); + grid.sortByScoreDescending(); toast("Batch complete: " + items.size() + " surfaces; cacheHits=" + batch.cacheHits + "; wall=" + batch.wallMillis + " ms.", false); @@ -183,38 +129,24 @@ public void runWithRecipe(JpdfRecipe recipe) { @Override public void maximize() { - // Intentionally left for you to wire to your pop-out event if desired. - // Example: - // scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISE_JPDF, Boolean.TRUE)); + // wire your popout event if desired } - // --------------------------------------------------------------------- - // Internals - // --------------------------------------------------------------------- - private void buildLayout() { - // Controls: Open-as selector + convenience buttons - openKindCombo.getItems().setAll(OpenKind.PDF, OpenKind.CDF); - openKindCombo.getSelectionModel().select(OpenKind.PDF); - - Button runBtn = new Button("Run (external)"); - runBtn.setOnAction(e -> { - if (recipeSupplier != null) { - runWithRecipe(recipeSupplier.get()); - } else { - toast("No recipe supplier set; use the panel’s Run button.", true); - } + // Top bar now includes: Reset, Run (from config), | Run (external), Clear Grid + Button runExternalBtn = new Button("Run (external)"); + runExternalBtn.setOnAction(e -> { + if (recipeSupplier != null) runWithRecipe(recipeSupplier.get()); + else toast("No recipe supplier set; use the panel’s Run button.", true); }); Button clearBtn = new Button("Clear Grid"); clearBtn.setOnAction(e -> grid.clearItems()); - HBox bar = new HBox(10, - runBtn, - clearBtn, - new Separator(), - openKindCombo - ); + Button cfgReset = config.getResetButton(); + Button cfgRun = config.getRunButton(); + + HBox bar = new HBox(10, cfgReset, cfgRun, new Separator(), runExternalBtn, clearBtn); bar.setAlignment(Pos.CENTER_LEFT); bar.setPadding(new Insets(6, 8, 6, 8)); @@ -227,20 +159,14 @@ private void buildLayout() { root.setCenter(grid); BorderPane.setMargin(grid, new Insets(6)); root.setPadding(new Insets(6)); - root.setBottom(new Separator()); + // bottom separator removed—buttons are in the top bar now } private void wireHandlers(Scene scene) { - // Wire the config panel to this pane + // Wire config 'Run' to our execution path config.setOnRun(this::runWithRecipe); - // Click on a thumbnail -> open in 3D (PDF/CDF decided by openKindCombo) - grid.setOnCellClick(click -> { - if (click == null || click.item == null) return; - openClickedCell(click); - }); - - // Convenience: accept NEW_FEATURE_COLLECTION as Cohort A + // Accept NEW_FEATURE_COLLECTION as Cohort A scene.addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, e -> { if (e.object instanceof FeatureCollection fc && fc.getFeatures() != null) { setCohortA(fc.getFeatures(), "A"); @@ -249,52 +175,20 @@ private void wireHandlers(Scene scene) { }); } - private void openClickedCell(PairGridPane.CellClick click) { - PairGridPane.PairItem item = click.item; - OpenKind kind = openKindCombo.getValue() == null ? OpenKind.PDF : openKindCombo.getValue(); - - List> zGrid; - double[] xCenters = null; - double[] yCenters = null; - - if (item.res != null) { - GridDensityResult res = item.res; - if (kind == OpenKind.CDF) { - zGrid = res.cdfAsListGrid(); - } else { - zGrid = res.pdfAsListGrid(); - } - xCenters = res.getxCenters(); - yCenters = res.getyCenters(); - } else { - // Item was constructed with a raw grid (no centers available) - zGrid = item.grid; - // xCenters / yCenters left null (renderer should handle this case) - } - - String label = (kind == OpenKind.CDF ? "CDF: " : "PDF: ") + item.xLabel + " | " + item.yLabel; + private void openCellIn3D(PairGridPane.PairItem item) { + if (item == null || item.res == null) return; + GridDensityResult res = item.res; + // default: open PDF; your UI can add a toggle later if needed + var gridList = res.pdfAsListGrid(); scene.getRoot().fireEvent(new HypersurfaceGridEvent( - kind == OpenKind.CDF ? HypersurfaceGridEvent.RENDER_CDF : HypersurfaceGridEvent.RENDER_PDF, - zGrid, xCenters, yCenters, label)); - - toast("Opened " + (kind == OpenKind.CDF ? "CDF" : "PDF") + " in 3D: " + item.xLabel + " | " + item.yLabel, false); - } - - private static String axisLabel(AxisParams axis, int componentIndexIfAny) { - if (axis == null || axis.getType() == null) return "Axis"; - StatisticEngine.ScalarType t = axis.getType(); - return switch (t) { - case COMPONENT_AT_DIMENSION -> { - int idx = axis.getComponentIndex() != null ? axis.getComponentIndex() : componentIndexIfAny; - yield (idx >= 0) ? ("Comp[" + idx + "]") : "Component"; - } - case METRIC_DISTANCE_TO_MEAN -> { - String m = axis.getMetricName(); - yield (m != null && !m.isBlank()) ? (m + "→mean") : "metric→mean"; - } - default -> t.name(); - }; + HypersurfaceGridEvent.RENDER_PDF, + gridList, + res.getxCenters(), + res.getyCenters(), + item.xLabel + " | " + item.yLabel + " (PDF)" + )); + toast("Opened PDF in 3D.", false); } private void toast(String msg, boolean isError) { diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java index e043a19c..d7557570 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java @@ -6,13 +6,11 @@ import edu.jhuapl.trinity.utils.statistics.JpdfRecipe.ScoreMetric; import java.util.function.Consumer; import javafx.geometry.Insets; -import javafx.geometry.Pos; 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.Separator; import javafx.scene.control.Spinner; import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.TextArea; @@ -22,29 +20,27 @@ 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; -/** - * PairwiseJpdfConfigPanel - * ----------------------- - * Floating JavaFX pane that collects configuration for a JpdfRecipe and emits it via a callback. - * - * Notes: - * - Pure UI: it does not perform computation. Use setOnRun(...) to receive a built JpdfRecipe. - * - "Whitelist" entry is a free-text placeholder here; integrate with a dedicated AxisPair editor later. - * - * @author Sean Phillips - */ public final class PairwiseJpdfConfigPanel extends BorderPane { - // --- Callbacks --- + // ---- sizing knobs ---- + private static final double PANEL_PREF_WIDTH = 390; + private static final double LABEL_COL_WIDTH = 130; // main label col + 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 + private static final double SUBLABEL_COL_W = 90; // sublabel in sub-grids + private static final double SPINNER_MIN_W = 100; + private static final double SPINNER_PREF_W = 120; + private Consumer onRun; - // --- General --- + // General private final TextField recipeNameField = new TextField(); - private final TextArea descriptionArea = new TextArea(); - // --- Pair selection --- + // Pair selection private final ComboBox pairSelectionCombo = new ComboBox<>(); private final ComboBox scoreMetricCombo = new ComboBox<>(); private final Spinner topKSpinner = new Spinner<>(); @@ -55,83 +51,108 @@ public final class PairwiseJpdfConfigPanel extends BorderPane { 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 --- + // 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"); - // --- Data sufficiency guard --- + // Guard private final Spinner minAvgCountPerCellSpinner = new Spinner<>(); - // --- Outputs & runtime --- + // Output/runtime private final ComboBox outputKindCombo = new ComboBox<>(); private final CheckBox cacheEnabledCheck = new CheckBox("Enable Cache"); private final CheckBox saveThumbsCheck = new CheckBox("Save Thumbnails"); - // --- Cohort labels (for provenance/reporting) --- - private final TextField cohortAField = new TextField("A"); - private final TextField cohortBField = new TextField("B"); - - // --- Whitelist placeholder (optional future editor) --- - private final TextArea whitelistArea = new TextArea(); - - // --- Actions --- + // Actions (now exposed to parent so they can be placed in the top bar) private final Button runButton = new Button("Run"); private final Button resetButton = new Button("Reset"); public PairwiseJpdfConfigPanel() { - setPadding(new Insets(10)); + setPadding(new Insets(2)); + setPrefWidth(PANEL_PREF_WIDTH); + setMaxWidth(PANEL_PREF_WIDTH); - // Defaults + // Defaults & widths recipeNameField.setPromptText("Recipe name (required)"); - descriptionArea.setPromptText("Optional description..."); - descriptionArea.setPrefRowCount(2); + 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.setPrefWidth(FIELD_WIDE_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); + 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); includeSelfPairsCheck.setSelected(false); orderedPairsCheck.setSelected(false); 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).\nIntegrate with an AxisPair editor later."); - whitelistArea.setPrefRowCount(3); - - // Layout + // Layout: ONLY center form (no bottom buttons; parent will place buttons in its top bar) setCenter(buildForm()); - setBottom(buildButtons()); // Reactive enable/disable pairSelectionCombo.valueProperty().addListener((obs, ov, nv) -> updateEnablement()); @@ -142,14 +163,13 @@ public PairwiseJpdfConfigPanel() { resetButton.setOnAction(e -> resetToDefaults()); } - // --------------------------------------------------------------------- - // Public API - // --------------------------------------------------------------------- + /** 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; - } + public void setOnRun(Consumer onRun) { this.onRun = onRun; } // --------------------------------------------------------------------- // Internals @@ -157,81 +177,78 @@ public void setOnRun(Consumer onRun) { private VBox buildForm() { GridPane g = new GridPane(); - g.setHgap(10); - g.setVgap(8); - g.setPadding(new Insets(8)); + g.setHgap(8); + g.setVgap(6); + g.setPadding(new Insets(2)); + g.setMaxWidth(Region.USE_PREF_SIZE); ColumnConstraints c0 = new ColumnConstraints(); - c0.setPercentWidth(32); + c0.setMinWidth(LABEL_COL_WIDTH); + c0.setPrefWidth(LABEL_COL_WIDTH); + c0.setMaxWidth(LABEL_COL_WIDTH); + c0.setHgrow(Priority.NEVER); + ColumnConstraints c1 = new ColumnConstraints(); - c1.setPercentWidth(68); + 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; - g.add(new Label("Name"), 0, r); g.add(recipeNameField, 1, r++); - - g.add(new Label("Description"), 0, r); g.add(descriptionArea, 1, r++); - - g.add(new Label("Pair Selection"), 0, r); g.add(pairSelectionCombo, 1, r++); - - HBox scoreBox = new HBox(10, new Label("Score Metric"), scoreMetricCombo); - HBox topkBox = new HBox(10, new Label("Top-K"), topKSpinner); - HBox thBox = new HBox(10, new Label("Threshold"), thresholdSpinner); - VBox selectBox = new VBox(6, scoreBox, topkBox, thBox); - g.add(new Label("Preselection Options"), 0, r); g.add(selectBox, 1, r++); - - VBox compBox = new VBox(6, - componentPairsCheck, - new HBox(10, new Label("Component Start"), compStartSpinner), - new HBox(10, new Label("Component End"), compEndSpinner), - new HBox(10, includeSelfPairsCheck, orderedPairsCheck) - ); - g.add(new Label("Components"), 0, r); g.add(compBox, 1, r++); - - VBox gridBox = new VBox(6, - new HBox(10, new Label("Bins X"), binsXSpinner, new Label("Bins Y"), binsYSpinner), - new HBox(10, new Label("Bounds Policy"), boundsPolicyCombo), - new HBox(10, new Label("Canonical Policy Id"), canonicalPolicyIdField) - ); - g.add(new Label("Grid / Bounds"), 0, r); g.add(gridBox, 1, r++); - - g.add(new Label("Min Avg Count / Cell"), 0, r); g.add(minAvgCountPerCellSpinner, 1, r++); - - VBox outBox = new VBox(6, - new HBox(10, new Label("Output"), outputKindCombo), - new HBox(10, cacheEnabledCheck, saveThumbsCheck) - ); - g.add(new Label("Outputs"), 0, r); g.add(outBox, 1, r++); - - VBox cohortsBox = new VBox(6, - new HBox(10, new Label("Cohort A"), cohortAField), - new HBox(10, new Label("Cohort B"), cohortBField) - ); - g.add(new Label("Cohorts"), 0, r); g.add(cohortsBox, 1, r++); - - g.add(new Label("Whitelist (optional)"), 0, r); g.add(whitelistArea, 1, r++); - - VBox root = new VBox(8, - g, - new Separator() - ); - root.setPadding(new Insets(4)); + g.add(compactLabel("Name"), 0, r); g.add(recipeNameField, 1, r++); + + g.add(compactLabel("Pair Selection"), 0, r); g.add(pairSelectionCombo, 1, r++); + + // --- Preselection sub-grid --- + GridPane pre = subGrid(); + int pr = 0; + pre.add(sublabel("Score Metric"), 0, pr); pre.add(scoreMetricCombo, 1, pr++); + pre.add(sublabel("Top-K"), 0, pr); pre.add(topKSpinner, 1, pr++); + pre.add(sublabel("Threshold"), 0, pr); pre.add(thresholdSpinner, 1, pr++); + g.add(compactLabel("Preselection"), 0, r); g.add(pre, 1, r++); + + // --- Components sub-grid --- + GridPane comp = subGrid(); + int cr = 0; + comp.add(componentPairsCheck, 0, cr, 2, 1); cr++; + comp.add(sublabel("Comp Start"), 0, cr); comp.add(compStartSpinner, 1, cr++); // row + comp.add(sublabel("Comp End"), 0, cr); comp.add(compEndSpinner, 1, cr++); // row + comp.add(includeSelfPairsCheck, 0, cr, 2, 1); cr++; // row + comp.add(orderedPairsCheck, 0, cr, 2, 1); cr++; // moved to next row + g.add(compactLabel("Components"), 0, r); g.add(comp, 1, r++); + + // --- Grid / Bounds sub-grid --- + GridPane gridBox = subGrid(); + int gr = 0; + gridBox.add(sublabel("Bins X"), 0, gr); gridBox.add(binsXSpinner, 1, gr++); // row + gridBox.add(sublabel("Bins Y"), 0, gr); gridBox.add(binsYSpinner, 1, gr++); // moved to next row + gridBox.add(sublabel("Bounds Policy"), 0, gr); gridBox.add(boundsPolicyCombo, 1, gr++); + gridBox.add(sublabel("Canonical Id"), 0, gr); gridBox.add(canonicalPolicyIdField, 1, gr++); + g.add(compactLabel("Grid / Bounds"), 0, r); g.add(gridBox, 1, r++); + + g.add(compactLabel("Min Avg Count/Cell"), 0, r); g.add(minAvgCountPerCellSpinner, 1, r++); + + // Outputs + GridPane out = subGrid(); + int or = 0; + out.add(sublabel("Output"), 0, or); out.add(outputKindCombo, 1, or++); + HBox cacheFlags = new HBox(8, cacheEnabledCheck, saveThumbsCheck); + out.add(cacheFlags, 0, or, 2, 1); + g.add(compactLabel("Outputs"), 0, r); g.add(out, 1, r++); + + VBox root = new VBox(8, g); + root.setPadding(new Insets(2)); + root.setFillWidth(false); return root; } - private HBox buildButtons() { - HBox box = new HBox(10, resetButton, runButton); - box.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setMargin(box, new Insets(8, 8, 8, 8)); - HBox.setHgrow(runButton, Priority.NEVER); - return box; - } - private void updateEnablement() { PairSelection ps = pairSelectionCombo.getValue(); boolean needTopK = ps == PairSelection.TOP_K_BY_SCORE; boolean needThreshold = ps == PairSelection.THRESHOLD_BY_SCORE; - boolean needScoring = ps == PairSelection.TOP_K_BY_SCORE || ps == PairSelection.THRESHOLD_BY_SCORE; + boolean needScoring = needTopK || needThreshold; scoreMetricCombo.setDisable(!needScoring); topKSpinner.setDisable(!needTopK); @@ -278,7 +295,6 @@ private JpdfRecipe buildRecipeFromUI() { } JpdfRecipe.Builder b = JpdfRecipe.newBuilder(name) - .description(descriptionArea.getText() == null ? "" : descriptionArea.getText()) .pairSelection(ps) .scoreMetric(sm) .bins(bx, by) @@ -288,10 +304,6 @@ private JpdfRecipe buildRecipeFromUI() { .outputKind(outputKindCombo.getValue()) .cacheEnabled(cacheEnabledCheck.isSelected()) .saveThumbnails(saveThumbsCheck.isSelected()) - .cohortLabels( - safe(cohortAField.getText(), "A"), - safe(cohortBField.getText(), "B") - ) .componentPairsMode(componentPairsCheck.isSelected()) .componentIndexRange(start, end) .includeSelfPairs(includeSelfPairsCheck.isSelected()) @@ -305,20 +317,54 @@ private JpdfRecipe buildRecipeFromUI() { b.scoreThreshold(thr); } - // NOTE: For WHITELIST mode, integrate an AxisPair editor later and call: - // b.clearAxisPairs(); b.addAxisPair(...); - // For now we just emit the recipe with empty explicit list (builder will validate non-empty if WHITELIST is chosen). return b.build(); } - private static String safe(String s, String fallback) { - String t = s == null ? "" : s.trim(); - return t.isEmpty() ? fallback : t; + 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 void resetToDefaults() { + private static Label sublabel(String text) { + Label l = new Label(text); + l.setMinWidth(SUBLABEL_COL_W); + l.setPrefWidth(SUBLABEL_COL_W); + l.setMaxWidth(SUBLABEL_COL_W); + return l; + } + + private static GridPane subGrid() { + GridPane sg = new GridPane(); + sg.setHgap(6); + sg.setVgap(4); + + ColumnConstraints s0 = new ColumnConstraints(); + s0.setMinWidth(SUBLABEL_COL_W); + s0.setPrefWidth(SUBLABEL_COL_W); + s0.setMaxWidth(SUBLABEL_COL_W); + s0.setHgrow(Priority.NEVER); + + ColumnConstraints s1 = new ColumnConstraints(); + s1.setMinWidth(FIELD_MIN_W); + s1.setPrefWidth(FIELD_PREF_W); + s1.setMaxWidth(FIELD_PREF_W); + s1.setHgrow(Priority.NEVER); + + sg.getColumnConstraints().addAll(s0, s1); + return sg; + } + + 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(); - descriptionArea.clear(); pairSelectionCombo.setValue(PairSelection.ALL); scoreMetricCombo.setValue(ScoreMetric.PEARSON); @@ -341,11 +387,6 @@ private void resetToDefaults() { outputKindCombo.setValue(OutputKind.PDF_AND_CDF); cacheEnabledCheck.setSelected(true); saveThumbsCheck.setSelected(true); - - cohortAField.setText("A"); - cohortBField.setText("B"); - - whitelistArea.clear(); updateEnablement(); } } From 2aab4fb89abc0ccd315a1530bb58a97563dfa3d0 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Thu, 18 Sep 2025 19:02:38 -0400 Subject: [PATCH 25/50] tilePane for pairwise JPDF thumbnails no longer blows out size of parent. --- .../utils/statistics/PairGridPane.java | 184 +++++++++--------- .../statistics/PairwiseJpdfConfigPanel.java | 131 +++++-------- 2 files changed, 146 insertions(+), 169 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java index 131cabb0..2a2e357c 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.utils.statistics; +import edu.jhuapl.trinity.utils.ResourceUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -14,6 +15,7 @@ import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; +import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Border; import javafx.scene.layout.BorderPane; @@ -21,53 +23,35 @@ import javafx.scene.layout.BorderStrokeStyle; import javafx.scene.layout.BorderWidths; import javafx.scene.layout.CornerRadii; -import javafx.scene.layout.GridPane; 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; /** - * PairGridPane - * ------------ - * Scrollable grid of heatmap thumbnails (pairwise JPDF/CDFs/diffs). - * Each cell shows a {@link HeatmapThumbnailView} with a compact title and optional score. - * - * Features: - * - Provide a list of {@link PairItem} (x/y labels, grid or GridDensityResult, palette, range, etc.) - * - Sorting (e.g., by score) and filtering (predicate) - * - Configurable columns, cell size, spacing, legend visibility - * - Click callback (emits {@link CellClick}) so caller can open a detailed/3D view - * - * This class is presentation-only; it does not compute densities. - * - * @author Sean Phillips + * PairGridPane (TilePane-backed) + * ------------------------------ + * Responsive grid of heatmap thumbnails that wraps on resize without + * forcing the parent to expand. */ public final class PairGridPane extends BorderPane { - // ===================================================================================== - // Public API types - // ===================================================================================== + // ---------- Public API types ---------- - /** Immutable description of one grid to render as a thumbnail. */ public static final class PairItem { public final String xLabel; public final String yLabel; - - /** Render source: either grid is non-null or res is non-null (choose one). */ - public final List> grid; // z[row][col] - public final GridDensityResult res; // optional - public final boolean useCDF; // when res != null + public final List> grid; + public final GridDensityResult res; + public final boolean useCDF; public final boolean flipY; - - /** Palette & range. If palette == DIVERGING, center is used. */ public final HeatmapThumbnailView.PaletteKind palette; - public final Double center; // diverging center (nullable) + public final Double center; public final boolean autoRange; - public final Double vmin; // when autoRange == false - public final Double vmax; // when autoRange == false + public final Double vmin; + public final Double vmax; public final boolean showLegend; - - /** Optional score (for sorting / display) and arbitrary user data. */ public final Double score; public final Object userData; @@ -116,23 +100,16 @@ private Builder(String xLabel, String yLabel) { this.yLabel = Objects.requireNonNull(yLabel, "yLabel"); } - /** Provide raw grid (row-major). */ - public Builder grid(List> g) { - this.grid = g; this.res = null; return this; - } - - /** Provide a GridDensityResult; set useCDF accordingly. */ + 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; } @@ -144,27 +121,24 @@ public PairItem build() { } } - /** Click info for a cell. */ public static final class CellClick { - public final int index; // index within current (filtered/sorted) list + 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; + this.index = index; this.item = item; + this.xLabel = item.xLabel; this.yLabel = item.yLabel; } } - // ===================================================================================== - // Fields & configuration - // ===================================================================================== + // ---------- Fields & configuration ---------- - private final GridPane gridPane = new GridPane(); - private final ScrollPane scroller = new ScrollPane(gridPane); + private final TilePane tilePane = new TilePane(); + private final ScrollPane scroller = new ScrollPane(tilePane); + private final StackPane contentStack = new StackPane(); + private VBox placeholder; + private final Label placeholderLabel = new Label("No results loaded"); private final List allItems = new ArrayList<>(); private List visibleItems = new ArrayList<>(); @@ -172,7 +146,7 @@ public CellClick(int index, PairItem item) { private Predicate filter = null; private Comparator sorter = null; - private int columns = 4; + private int columns = 4; // hint only; wrapping adapts to viewport width private double cellWidth = 180; private double cellHeight = 140; private double cellHGap = 8; @@ -182,26 +156,61 @@ public CellClick(int index, PairItem item) { private boolean showScoresInHeader = true; private Consumer onCellClick = null; - // ===================================================================================== - // Construction - // ===================================================================================== + // ---------- Construction ---------- public PairGridPane() { + // ScrollPane: cap viewport so it never pushes parent scroller.setFitToWidth(true); 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 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); + }); - gridPane.setHgap(cellHGap); - gridPane.setVgap(cellVGap); - gridPane.setPadding(new Insets(6)); + // 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 + placeholderLabel.setStyle("-fx-text-fill: derive(-fx-text-base-color, -30%); -fx-font-style: italic;"); - setCenter(scroller); + contentStack.getChildren().addAll(scroller, placeholder); + placeholder.toFront(); + + setCenter(contentStack); } - // ===================================================================================== - // Public API - // ===================================================================================== + // 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 ---------- public void setItems(List items) { allItems.clear(); @@ -221,19 +230,16 @@ public void clearItems() { applyFilterSortAndRender(); } - /** Filter: return true to keep an item visible. */ public void setFilter(Predicate filter) { this.filter = filter; applyFilterSortAndRender(); } - /** Sorter comparator (e.g., by descending score). */ public void setSorter(Comparator sorter) { this.sorter = sorter; applyFilterSortAndRender(); } - /** Convenience: sort by score descending, nulls last. */ public void sortByScoreDescending() { setSorter(Comparator.nullsLast((a, b) -> { double sa = a.score == null ? Double.NEGATIVE_INFINITY : a.score; @@ -242,48 +248,47 @@ public void sortByScoreDescending() { })); } + /** Preferred columns hint; wrapping still adapts to viewport width. */ public void setColumns(int cols) { this.columns = Math.max(1, cols); - renderGrid(); + 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); - renderGrid(); + renderTiles(); } public void setCellGaps(double hgap, double vgap) { this.cellHGap = Math.max(0, hgap); this.cellVGap = Math.max(0, vgap); - gridPane.setHgap(cellHGap); - gridPane.setVgap(cellVGap); - renderGrid(); + tilePane.setHgap(cellHGap); + tilePane.setVgap(cellVGap); + renderTiles(); } public void setCellPadding(Insets insets) { this.cellPadding = insets == null ? Insets.EMPTY : insets; - renderGrid(); + renderTiles(); } public void setShowScoresInHeader(boolean show) { this.showScoresInHeader = show; - renderGrid(); + renderTiles(); } - /** Register click callback for cells. */ public void setOnCellClick(Consumer onClick) { this.onCellClick = onClick; } - /** Current visible (filtered/sorted) items snapshot. */ public List getVisibleItems() { return Collections.unmodifiableList(visibleItems); } - // ===================================================================================== - // Internal rendering - // ===================================================================================== + // ---------- Internal rendering ---------- private void applyFilterSortAndRender() { List tmp = new ArrayList<>(); @@ -292,27 +297,27 @@ private void applyFilterSortAndRender() { } if (sorter != null) tmp.sort(sorter); visibleItems = tmp; - renderGrid(); + renderTiles(); } - private void renderGrid() { - gridPane.getChildren().clear(); - if (visibleItems.isEmpty()) return; - - int colCount = Math.max(1, columns); - int row = 0, col = 0; + 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); - gridPane.add(cell, col, row); - col++; - if (col >= colCount) { col = 0; row++; } + tilePane.getChildren().add(cell); } } private Node buildCell(int index, PairItem item) { - // Title (X | Y [+score]) String title = item.xLabel + " | " + item.yLabel; if (showScoresInHeader && item.score != null) { title += " " + String.format("score=%.4f", item.score); @@ -321,7 +326,6 @@ private Node buildCell(int index, PairItem item) { header.setPadding(new Insets(2, 4, 2, 4)); header.setTextFill(Color.web("#E0E0E0")); - // Heatmap view HeatmapThumbnailView view = new HeatmapThumbnailView(); view.setPrefSize(cellWidth, cellHeight); view.setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); @@ -350,7 +354,6 @@ private Node buildCell(int index, PairItem item) { view.setFromGridDensity(item.res, item.useCDF, item.flipY); } - // Layout: BorderPane per cell BorderPane cell = new BorderPane(); cell.setTop(header); BorderPane.setAlignment(header, Pos.CENTER_LEFT); @@ -368,7 +371,6 @@ private Node buildCell(int index, PairItem item) { ))); cell.setBackground(null); - // Click handling (entire cell) 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 -> { diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java index d7557570..914c98fa 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java @@ -18,7 +18,6 @@ import javafx.scene.layout.BorderPane; 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; @@ -27,11 +26,10 @@ public final class PairwiseJpdfConfigPanel extends BorderPane { // ---- sizing knobs ---- private static final double PANEL_PREF_WIDTH = 390; - private static final double LABEL_COL_WIDTH = 130; // main label col + 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 - private static final double SUBLABEL_COL_W = 90; // sublabel in sub-grids + 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; @@ -65,7 +63,10 @@ public final class PairwiseJpdfConfigPanel extends BorderPane { private final CheckBox cacheEnabledCheck = new CheckBox("Enable Cache"); private final CheckBox saveThumbsCheck = new CheckBox("Save Thumbnails"); - // Actions (now exposed to parent so they can be placed in the top bar) + // 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"); @@ -82,8 +83,9 @@ public PairwiseJpdfConfigPanel() { pairSelectionCombo.getItems().addAll(PairSelection.values()); pairSelectionCombo.setValue(PairSelection.ALL); - pairSelectionCombo.setPrefWidth(FIELD_WIDE_W); - pairSelectionCombo.setMaxWidth(FIELD_WIDE_W); + pairSelectionCombo.setMinWidth(FIELD_MIN_W); + pairSelectionCombo.setPrefWidth(FIELD_PREF_W); + pairSelectionCombo.setMaxWidth(FIELD_WIDE_W); scoreMetricCombo.getItems().addAll(ScoreMetric.values()); scoreMetricCombo.setValue(ScoreMetric.PEARSON); @@ -102,6 +104,8 @@ public PairwiseJpdfConfigPanel() { 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); @@ -115,9 +119,6 @@ public PairwiseJpdfConfigPanel() { compEndSpinner.setPrefWidth(SPINNER_PREF_W); compEndSpinner.setMaxWidth(SPINNER_PREF_W); - includeSelfPairsCheck.setSelected(false); - orderedPairsCheck.setSelected(false); - binsXSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(2, 4096, 64, 2)); binsXSpinner.setEditable(true); binsXSpinner.setMinWidth(SPINNER_MIN_W); @@ -151,7 +152,15 @@ public PairwiseJpdfConfigPanel() { cacheEnabledCheck.setSelected(true); saveThumbsCheck.setSelected(true); - // Layout: ONLY center form (no bottom buttons; parent will place buttons in its top bar) + 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 @@ -182,6 +191,7 @@ private VBox buildForm() { 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); @@ -197,46 +207,39 @@ private VBox buildForm() { g.getColumnConstraints().addAll(c0, c1); int r = 0; - g.add(compactLabel("Name"), 0, r); g.add(recipeNameField, 1, r++); - - g.add(compactLabel("Pair Selection"), 0, r); g.add(pairSelectionCombo, 1, r++); - - // --- Preselection sub-grid --- - GridPane pre = subGrid(); - int pr = 0; - pre.add(sublabel("Score Metric"), 0, pr); pre.add(scoreMetricCombo, 1, pr++); - pre.add(sublabel("Top-K"), 0, pr); pre.add(topKSpinner, 1, pr++); - pre.add(sublabel("Threshold"), 0, pr); pre.add(thresholdSpinner, 1, pr++); - g.add(compactLabel("Preselection"), 0, r); g.add(pre, 1, r++); - - // --- Components sub-grid --- - GridPane comp = subGrid(); - int cr = 0; - comp.add(componentPairsCheck, 0, cr, 2, 1); cr++; - comp.add(sublabel("Comp Start"), 0, cr); comp.add(compStartSpinner, 1, cr++); // row - comp.add(sublabel("Comp End"), 0, cr); comp.add(compEndSpinner, 1, cr++); // row - comp.add(includeSelfPairsCheck, 0, cr, 2, 1); cr++; // row - comp.add(orderedPairsCheck, 0, cr, 2, 1); cr++; // moved to next row - g.add(compactLabel("Components"), 0, r); g.add(comp, 1, r++); - - // --- Grid / Bounds sub-grid --- - GridPane gridBox = subGrid(); - int gr = 0; - gridBox.add(sublabel("Bins X"), 0, gr); gridBox.add(binsXSpinner, 1, gr++); // row - gridBox.add(sublabel("Bins Y"), 0, gr); gridBox.add(binsYSpinner, 1, gr++); // moved to next row - gridBox.add(sublabel("Bounds Policy"), 0, gr); gridBox.add(boundsPolicyCombo, 1, gr++); - gridBox.add(sublabel("Canonical Id"), 0, gr); gridBox.add(canonicalPolicyIdField, 1, gr++); - g.add(compactLabel("Grid / Bounds"), 0, r); g.add(gridBox, 1, r++); - - g.add(compactLabel("Min Avg Count/Cell"), 0, r); g.add(minAvgCountPerCellSpinner, 1, r++); + + // 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 - GridPane out = subGrid(); - int or = 0; - out.add(sublabel("Output"), 0, or); out.add(outputKindCombo, 1, or++); - HBox cacheFlags = new HBox(8, cacheEnabledCheck, saveThumbsCheck); - out.add(cacheFlags, 0, or, 2, 1); - g.add(compactLabel("Outputs"), 0, r); g.add(out, 1, r++); + 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)); @@ -328,35 +331,6 @@ private static Label compactLabel(String text) { return l; } - private static Label sublabel(String text) { - Label l = new Label(text); - l.setMinWidth(SUBLABEL_COL_W); - l.setPrefWidth(SUBLABEL_COL_W); - l.setMaxWidth(SUBLABEL_COL_W); - return l; - } - - private static GridPane subGrid() { - GridPane sg = new GridPane(); - sg.setHgap(6); - sg.setVgap(4); - - ColumnConstraints s0 = new ColumnConstraints(); - s0.setMinWidth(SUBLABEL_COL_W); - s0.setPrefWidth(SUBLABEL_COL_W); - s0.setMaxWidth(SUBLABEL_COL_W); - s0.setHgrow(Priority.NEVER); - - ColumnConstraints s1 = new ColumnConstraints(); - s1.setMinWidth(FIELD_MIN_W); - s1.setPrefWidth(FIELD_PREF_W); - s1.setMaxWidth(FIELD_PREF_W); - s1.setHgrow(Priority.NEVER); - - sg.getColumnConstraints().addAll(s0, s1); - return sg; - } - private static void widenCombo(ComboBox cb) { cb.setMinWidth(FIELD_MIN_W); cb.setPrefWidth(FIELD_PREF_W); @@ -387,6 +361,7 @@ public void resetToDefaults() { outputKindCombo.setValue(OutputKind.PDF_AND_CDF); cacheEnabledCheck.setSelected(true); saveThumbsCheck.setSelected(true); + updateEnablement(); } } From 14e8cde47ef50673560f00eb7ac7bd37e97d0b28 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Fri, 19 Sep 2025 08:05:57 -0400 Subject: [PATCH 26/50] setup for second screen popout for PairwiseJpdfPane --- .../edu/jhuapl/trinity/AppAsyncManager.java | 11 ++ .../components/panes/PairwiseJpdfPane.java | 5 +- .../PairwiseJpdfPanePopoutController.java | 159 ++++++++++++++++++ .../javafx/events/ApplicationEvent.java | 1 + 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 5de647b9..68231f3c 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -101,6 +101,7 @@ import edu.jhuapl.trinity.javafx.components.panes.HypersurfaceControlsPane; import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; +import edu.jhuapl.trinity.javafx.controllers.PairwiseJpdfPanePopoutController; /** * @author Sean Phillips @@ -129,6 +130,7 @@ public class AppAsyncManager extends Task { SpecialEffectsPane specialEffectsPane; StatPdfCdfPane statPdfCdfPane; PairwiseJpdfPane pairwiseJpdfPane; + PairwiseJpdfPanePopoutController pjpPop; FeatureVectorManagerPane featureVectorManagerPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; @@ -172,6 +174,7 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir impl.setEventTarget(scene.getRoot()); } fvPop = new FeatureVectorManagerPopoutController(fvService, scene); + pjpPop = new PairwiseJpdfPanePopoutController(scene); setOnSucceeded(e -> Platform.runLater(() -> scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); @@ -619,6 +622,14 @@ protected Void call() throws Exception { 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_HYPERSPACE_CONTROLS, e -> { if (null == hypersurfaceControlsPane) { 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 index 0a11d08f..56a6dd71 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -2,6 +2,7 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +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; @@ -129,7 +130,9 @@ public void runWithRecipe(JpdfRecipe recipe) { @Override public void maximize() { - // wire your popout event if desired + scene.getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISEJPDF_JPDF, Boolean.TRUE) + ); } private void buildLayout() { 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..57465060 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java @@ -0,0 +1,159 @@ +package edu.jhuapl.trinity.javafx.controllers; + +import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +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.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 PairwiseJpdfPane pane; + + 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() { + // IMPORTANT: Create a *fresh* PairwiseJpdfPane for the new window, + // passing in a new Scene (will be set below), and null for parent. + scene = new Scene(new BorderPane(), Color.BLACK); + pane = new PairwiseJpdfPane(scene, null, engine, cache, configPanel); + + // Optional: if you want to sync state from the main window, expose methods to set cohorts/labels, etc. + + // Simple toolbar for popout controls + ToolBar tb = new ToolBar(); + tb.setPadding(new Insets(5)); + 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(pane); + scene.setRoot(root); + + // Style if desired + // scene.getStylesheets().add(...); + + 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(); + pane = null; + scene.setRoot(new BorderPane()); // help GC + scene = null; + stage = null; + }); + } + + 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("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); + } +} 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 2cee28d3..b653ed74 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -48,6 +48,7 @@ public class ApplicationEvent extends Event { 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 ApplicationEvent(EventType eventType, Object object) { this(eventType); From 5f05e4e54c91eccdc61b10171cf84e7243f08076 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Fri, 19 Sep 2025 12:03:35 -0400 Subject: [PATCH 27/50] Migrated PairwiseJpdfPane child Nodes into reusable component to support second screen popout. --- .../javafx/components/PairwiseJpdfView.java | 171 ++++++++++++++++++ .../components/panes/PairwiseJpdfPane.java | 7 +- .../PairwiseJpdfPanePopoutController.java | 89 ++++++--- 3 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java 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..b7e223bc --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -0,0 +1,171 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +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.JpdfRecipe; +import edu.jhuapl.trinity.utils.statistics.PairGridPane; +import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; +import javafx.geometry.Insets; +import javafx.geometry.Pos; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.scene.control.Button; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; + +/** + * PairwiseJpdfView + * - Top bar with: Reset, Run (from config), Run (external), Clear Grid + * - Left: Config Panel + * - Center: Grid of heatmap thumbnails + * - Public API to set cohorts, supply recipes, and handle run/clear events. + * - Usable in floating pane or in popout/fullscreen windows. + */ +public class PairwiseJpdfView extends BorderPane { + + // Core GUI nodes + private final PairwiseJpdfConfigPanel configPanel; + private final PairGridPane gridPane; + private final HBox topBar; + + // State + private List cohortA = new ArrayList<>(); + private List cohortB = new ArrayList<>(); + private String cohortALabel = "A"; + private String cohortBLabel = "B"; + + private Supplier recipeSupplier; // optional, for run external button + private Consumer toastHandler; // optional, for showing user messages + + public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { + this.configPanel = configPanel != null ? configPanel : new PairwiseJpdfConfigPanel(); + this.gridPane = new PairGridPane(); + this.gridPane.setOnCellClick(click -> { + if (onCellClickHandler != null) onCellClickHandler.accept(click.item); + }); + + // Buttons (configPanel's Run/Reset, plus ours) + Button runExternalBtn = new Button("Run (external)"); + runExternalBtn.setOnAction(e -> { + if (recipeSupplier != null) { + runWithRecipe(recipeSupplier.get()); + } else { + toast("No recipe supplier set; use the panel’s Run button.", true); + } + }); + + Button clearBtn = new Button("Clear Grid"); + clearBtn.setOnAction(e -> gridPane.clearItems()); + + Button cfgReset = configPanel.getResetButton(); + Button cfgRun = configPanel.getRunButton(); + + topBar = new HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); + topBar.setAlignment(Pos.CENTER_LEFT); + topBar.setPadding(new Insets(6, 8, 6, 8)); + + // Left = VBox(topBar, configPanel) + BorderPane left = new BorderPane(); + left.setTop(topBar); + left.setCenter(configPanel); + BorderPane.setMargin(configPanel, new Insets(6, 8, 6, 8)); + + setLeft(left); + setCenter(gridPane); + BorderPane.setMargin(gridPane, new Insets(6)); + setPadding(new Insets(6)); + + // Wire config panel 'Run' to runWithRecipe + configPanel.setOnRun(this::runWithRecipe); + } + + // --- Public API --- + + /** Set the vectors for Cohort A (primary) */ + public void setCohortA(List vectors, String label) { + this.cohortA = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); + if (label != null && !label.isBlank()) this.cohortALabel = label; + } + + /** Set the vectors for Cohort B (not currently used in batch, but included for completeness) */ + public void setCohortB(List vectors, String label) { + this.cohortB = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); + if (label != null && !label.isBlank()) this.cohortBLabel = label; + } + + /** Allow parent to provide a recipe supplier for "Run (external)" button */ + public void setRecipeSupplier(Supplier supplier) { this.recipeSupplier = supplier; } + + /** Set a custom handler for toasts/user messages (optional) */ + public void setToastHandler(Consumer handler) { this.toastHandler = handler; } + + /** Run computation and populate grid, using provided recipe */ + 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; } + + CanonicalGridPolicy policy = CanonicalGridPolicy.get( + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default" + ); + + toast("Computing pairwise densities…", false); + + // For now, only cohortA is used + JpdfBatchEngine.BatchResult batch; + switch (recipe.getPairSelection()) { + case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, null); + default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, null); + } + + List items = new ArrayList<>(batch.jobs.size()); + for (JpdfBatchEngine.PairJobResult job : batch.jobs) { + 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) + .from(job.density, false, true) + .palette(HeatmapThumbnailView.PaletteKind.SEQUENTIAL) + .autoRange(true) + .showLegend(false); + if (job.rank != null) b.score(job.rank.score); + items.add(b.build()); + } + gridPane.setItems(items); + gridPane.sortByScoreDescending(); + toast("Batch complete: " + items.size() + " surfaces; cacheHits=" + batch.cacheHits + + "; wall=" + batch.wallMillis + " ms.", false); + } + + // --- Cell click handler (parent can use to wire up 3D open, etc.) --- + private Consumer onCellClickHandler; + public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } + + // --- Toast/user message --- + public void toast(String msg, boolean isError) { + if (toastHandler != null) { + toastHandler.accept((isError ? "[Error] " : "") + msg); + } else { + // fallback: print to console + System.out.println((isError ? "[Error] " : "[Info] ") + msg); + } + } + + // --- Expose underlying controls for advanced wiring, if needed --- + public PairwiseJpdfConfigPanel getConfigPanel() { return configPanel; } + public PairGridPane getGridPane() { return gridPane; } + public HBox getTopBar() { return topBar; } + + // --- Advanced: get current state (optional) --- + public List getCohortA() { return cohortA; } + public List getCohortB() { return cohortB; } + public String getCohortALabel() { return cohortALabel; } + public String getCohortBLabel() { return cohortBLabel; } +} 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 index 56a6dd71..19640b0d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -149,7 +149,7 @@ private void buildLayout() { Button cfgReset = config.getResetButton(); Button cfgRun = config.getRunButton(); - HBox bar = new HBox(10, cfgReset, cfgRun, new Separator(), runExternalBtn, clearBtn); + HBox bar = new HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); bar.setAlignment(Pos.CENTER_LEFT); bar.setPadding(new Insets(6, 8, 6, 8)); @@ -160,9 +160,8 @@ private void buildLayout() { root.setLeft(left); root.setCenter(grid); - BorderPane.setMargin(grid, new Insets(6)); - root.setPadding(new Insets(6)); - // bottom separator removed—buttons are in the top bar now + BorderPane.setMargin(grid, new Insets(2)); + root.setPadding(new Insets(2)); } private void wireHandlers(Scene scene) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java index 57465060..ffbc4e30 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java @@ -1,7 +1,12 @@ package edu.jhuapl.trinity.javafx.controllers; -import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; +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; @@ -16,6 +21,7 @@ import javafx.stage.Window; import java.util.prefs.Preferences; +import javafx.scene.layout.Background; public class PairwiseJpdfPanePopoutController { private final Scene appScene; @@ -23,7 +29,7 @@ public class PairwiseJpdfPanePopoutController { private Stage stage; private Scene scene; - private PairwiseJpdfPane pane; + private PairwiseJpdfView view; private final JpdfBatchEngine engine; private final DensityCache cache; @@ -32,11 +38,16 @@ public class PairwiseJpdfPanePopoutController { public PairwiseJpdfPanePopoutController(Scene scene) { this(scene, null, null, null); } - public PairwiseJpdfPanePopoutController(Scene appScene, JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { + + 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(); + 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) */ @@ -67,16 +78,39 @@ public void sendToSecondScreen() { } private void buildStageAndWire() { - // IMPORTANT: Create a *fresh* PairwiseJpdfPane for the new window, - // passing in a new Scene (will be set below), and null for parent. - scene = new Scene(new BorderPane(), Color.BLACK); - pane = new PairwiseJpdfPane(scene, null, engine, cache, configPanel); - - // Optional: if you want to sync state from the main window, expose methods to set cohorts/labels, etc. - - // Simple toolbar for popout controls + 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); @@ -86,12 +120,18 @@ private void buildStageAndWire() { BorderPane root = new BorderPane(); root.setTop(tb); - root.setCenter(pane); - scene.setRoot(root); - - // Style if desired - // scene.getStylesheets().add(...); - + 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); @@ -103,7 +143,7 @@ private void buildStageAndWire() { stage.setOnCloseRequest(e -> { saveWindowPrefs(); - pane = null; + view = null; scene.setRoot(new BorderPane()); // help GC scene = null; stage = null; @@ -116,7 +156,7 @@ private void placeOnBestScreen() { } private void moveToScreen(Screen screen) { - var b = screen.getVisualBounds(); + javafx.geometry.Rectangle2D b = screen.getVisualBounds(); stage.setX(b.getMinX() + 40); stage.setY(b.getMinY() + 40); if (!stage.isShowing()) { @@ -156,4 +196,9 @@ private void restoreWindowPrefs() { 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; + } } From 53545a4b9215400fb3ce5a198dfb22e66cd170e9 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 21 Sep 2025 10:33:04 -0400 Subject: [PATCH 28/50] Fixed PairwiseJpdfPane to reuse PairwiseJpdfView component. JpdfBatchEngine allows for consumer of BatchResult objects to support streaming of thumbnails --- .../javafx/components/PairwiseJpdfView.java | 51 +++-- .../components/panes/PairwiseJpdfPane.java | 213 +++++------------- .../utils/statistics/JpdfBatchEngine.java | 31 +++ 3 files changed, 122 insertions(+), 173 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index b7e223bc..284ac9d1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -5,19 +5,20 @@ 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 javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; -import javafx.scene.control.Button; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; /** * PairwiseJpdfView @@ -34,6 +35,9 @@ public class PairwiseJpdfView extends BorderPane { private final PairGridPane gridPane; private final HBox topBar; + // Core engine/cache (now always non-null) + private final DensityCache cache; + // State private List cohortA = new ArrayList<>(); private List cohortB = new ArrayList<>(); @@ -43,8 +47,14 @@ public class PairwiseJpdfView extends BorderPane { private Supplier recipeSupplier; // optional, for run external button private Consumer toastHandler; // optional, for showing user messages + // Cell click handler (e.g., for open in 3D) + private Consumer onCellClickHandler; + public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { - this.configPanel = configPanel != null ? configPanel : new PairwiseJpdfConfigPanel(); + // Defensive: always have a cache (if none supplied, create default) + this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); + this.configPanel = (configPanel != null) ? configPanel : new PairwiseJpdfConfigPanel(); + this.gridPane = new PairGridPane(); this.gridPane.setOnCellClick(click -> { if (onCellClickHandler != null) onCellClickHandler.accept(click.item); @@ -63,8 +73,8 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf Button clearBtn = new Button("Clear Grid"); clearBtn.setOnAction(e -> gridPane.clearItems()); - Button cfgReset = configPanel.getResetButton(); - Button cfgRun = configPanel.getRunButton(); + Button cfgReset = this.configPanel.getResetButton(); + Button cfgRun = this.configPanel.getRunButton(); topBar = new HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); topBar.setAlignment(Pos.CENTER_LEFT); @@ -73,8 +83,8 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf // Left = VBox(topBar, configPanel) BorderPane left = new BorderPane(); left.setTop(topBar); - left.setCenter(configPanel); - BorderPane.setMargin(configPanel, new Insets(6, 8, 6, 8)); + left.setCenter(this.configPanel); + BorderPane.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); setLeft(left); setCenter(gridPane); @@ -82,7 +92,7 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf setPadding(new Insets(6)); // Wire config panel 'Run' to runWithRecipe - configPanel.setOnRun(this::runWithRecipe); + this.configPanel.setOnRun(this::runWithRecipe); } // --- Public API --- @@ -105,6 +115,9 @@ public void setCohortB(List vectors, String label) { /** Set a custom handler for toasts/user messages (optional) */ public void setToastHandler(Consumer handler) { this.toastHandler = handler; } + /** Set cell click handler (e.g., open in 3D) */ + public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } + /** Run computation and populate grid, using provided recipe */ public void runWithRecipe(JpdfRecipe recipe) { if (recipe == null) { toast("No recipe provided.", true); return; } @@ -116,13 +129,23 @@ public void runWithRecipe(JpdfRecipe recipe) { : "default" ); + if (policy == null) { + toast("Failed to determine canonical grid policy for recipe.", true); + return; + } + + if (this.cache == null) { + toast("DensityCache is null! This should never happen.", true); + return; + } + toast("Computing pairwise densities…", false); // For now, only cohortA is used - JpdfBatchEngine.BatchResult batch; + BatchResult batch; switch (recipe.getPairSelection()) { - case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, null); - default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, null); + case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, this.cache); + default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, this.cache); } List items = new ArrayList<>(batch.jobs.size()); @@ -144,10 +167,6 @@ public void runWithRecipe(JpdfRecipe recipe) { "; wall=" + batch.wallMillis + " ms.", false); } - // --- Cell click handler (parent can use to wire up 3D open, etc.) --- - private Consumer onCellClickHandler; - public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } - // --- Toast/user message --- public void toast(String msg, boolean isError) { if (toastHandler != null) { 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 index 19640b0d..3c678c75 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -2,200 +2,99 @@ 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.CanonicalGridPolicy; import edu.jhuapl.trinity.utils.statistics.DensityCache; import edu.jhuapl.trinity.utils.statistics.GridDensityResult; -import edu.jhuapl.trinity.utils.statistics.HeatmapThumbnailView; import edu.jhuapl.trinity.utils.statistics.JpdfBatchEngine; import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; -import edu.jhuapl.trinity.utils.statistics.PairGridPane; import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.function.Supplier; import javafx.application.Platform; -import javafx.geometry.Insets; -import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.Separator; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.text.Font; -public final class PairwiseJpdfPane extends LitPathPane { - - private final BorderPane root; - private final PairwiseJpdfConfigPanel config; - private final PairGridPane grid; - - private final JpdfBatchEngine engine; - private final DensityCache cache; +import java.util.List; +import java.util.function.Supplier; - private List cohortA = new ArrayList<>(); - private List cohortB = new ArrayList<>(); - private String cohortALabel = "A"; - private String cohortBLabel = "B"; +public final class PairwiseJpdfPane extends LitPathPane { - private Supplier recipeSupplier; + private final PairwiseJpdfView view; - public PairwiseJpdfPane(Scene scene, - Pane parent, - JpdfBatchEngine engine, - DensityCache cache, - PairwiseJpdfConfigPanel configPanel) { + public PairwiseJpdfPane( + Scene scene, + Pane parent, + JpdfBatchEngine engine, + DensityCache cache, + PairwiseJpdfConfigPanel configPanel + ) { super(scene, parent, - 1100, 760, - new BorderPane(), - "Pairwise Joint Densities", "Batch", - 420.0, 400.0); + 1100, 760, + new PairwiseJpdfView(engine, cache, configPanel), + "Pairwise Joint Densities", "Batch", + 420.0, 400.0); - this.root = (BorderPane) this.contentPane; - this.engine = (engine != null) ? engine : new JpdfBatchEngine(); - this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); - this.config = Objects.requireNonNull(configPanel, "configPanel"); + this.view = (PairwiseJpdfView) this.contentPane; - this.grid = new PairGridPane(); - this.grid.setOnCellClick(click -> openCellIn3D(click.item)); + // 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); + } + }); - buildLayout(); - wireHandlers(scene); + // 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, new PairwiseJpdfConfigPanel()); + this(scene, parent, null, null, null); } + // --- Forwarders for public API compatibility --- public void setCohortA(List vectors, String label) { - this.cohortA = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); - if (label != null && !label.isBlank()) this.cohortALabel = label; + view.setCohortA(vectors, 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; + view.setCohortB(vectors, label); + } + public void setRecipeSupplier(Supplier supplier) { + view.setRecipeSupplier(supplier); } - - public PairwiseJpdfConfigPanel getConfigPanel() { return config; } - public PairGridPane getGridPane() { return grid; } - - public void setRecipeSupplier(Supplier supplier) { this.recipeSupplier = supplier; } - 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; } - - CanonicalGridPolicy policy = CanonicalGridPolicy.get( - (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) - ? recipe.getCanonicalPolicyId() - : "default" - ); - - toast("Computing pairwise densities…", false); - - JpdfBatchEngine.BatchResult batch; - switch (recipe.getPairSelection()) { - case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, cache); - default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, cache); - } - - List items = new ArrayList<>(batch.jobs.size()); - for (JpdfBatchEngine.PairJobResult job : batch.jobs) { - 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) - .from(job.density, false, true) // PDF thumbnail by default, flipY=true - .palette(HeatmapThumbnailView.PaletteKind.SEQUENTIAL) - .autoRange(true) - .showLegend(false); - - if (job.rank != null) b.score(job.rank.score); - items.add(b.build()); - } - grid.setItems(items); - grid.sortByScoreDescending(); - - toast("Batch complete: " + items.size() + " surfaces; cacheHits=" + batch.cacheHits + - "; wall=" + batch.wallMillis + " ms.", false); + view.runWithRecipe(recipe); + } + public PairwiseJpdfView getView() { + return view; } @Override public void maximize() { scene.getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISEJPDF_JPDF, Boolean.TRUE) + new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISEJPDF_JPDF, Boolean.TRUE) ); } - - private void buildLayout() { - // Top bar now includes: Reset, Run (from config), | Run (external), Clear Grid - Button runExternalBtn = new Button("Run (external)"); - runExternalBtn.setOnAction(e -> { - if (recipeSupplier != null) runWithRecipe(recipeSupplier.get()); - else toast("No recipe supplier set; use the panel’s Run button.", true); - }); - - Button clearBtn = new Button("Clear Grid"); - clearBtn.setOnAction(e -> grid.clearItems()); - - Button cfgReset = config.getResetButton(); - Button cfgRun = config.getRunButton(); - - HBox bar = new HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); - bar.setAlignment(Pos.CENTER_LEFT); - bar.setPadding(new Insets(6, 8, 6, 8)); - - BorderPane left = new BorderPane(); - left.setTop(bar); - left.setCenter(config); - BorderPane.setMargin(config, new Insets(6, 8, 6, 8)); - - root.setLeft(left); - root.setCenter(grid); - BorderPane.setMargin(grid, new Insets(2)); - root.setPadding(new Insets(2)); - } - - private void wireHandlers(Scene scene) { - // Wire config 'Run' to our execution path - config.setOnRun(this::runWithRecipe); - - // Accept NEW_FEATURE_COLLECTION as Cohort A - scene.addEventHandler(FeatureVectorEvent.NEW_FEATURE_COLLECTION, e -> { - if (e.object instanceof FeatureCollection fc && fc.getFeatures() != null) { - setCohortA(fc.getFeatures(), "A"); - toast("Loaded " + fc.getFeatures().size() + " vectors into Cohort A.", false); - } - }); - } - - private void openCellIn3D(PairGridPane.PairItem item) { - if (item == null || item.res == null) return; - GridDensityResult res = item.res; - - // default: open PDF; your UI can add a toggle later if needed - var gridList = res.pdfAsListGrid(); - scene.getRoot().fireEvent(new HypersurfaceGridEvent( - HypersurfaceGridEvent.RENDER_PDF, - gridList, - res.getxCenters(), - res.getyCenters(), - item.xLabel + " | " + item.yLabel + " (PDF)" - )); - toast("Opened PDF in 3D.", false); - } - - private void toast(String msg, boolean isError) { - Platform.runLater(() -> scene.getRoot().fireEvent( - new CommandTerminalEvent(msg, new Font("Consolas", 18), - isError ? Color.ORANGERED : Color.LIGHTGREEN))); - } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java index cc724bf1..e6adc20c 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java @@ -16,6 +16,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.function.Consumer; /** * JpdfBatchEngine @@ -117,10 +118,20 @@ public BatchResult(String datasetFingerprint, // 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); @@ -201,6 +212,11 @@ public static BatchResult runComponentPairs(List vectors, 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(); @@ -220,10 +236,20 @@ public static BatchResult runComponentPairs(List vectors, // 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); @@ -260,6 +286,11 @@ public static BatchResult runWhitelistPairs(List vectors, 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(); From ea205a20f8f2d462bf3222135c784da814bf8ac9 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 21 Sep 2025 15:18:03 -0400 Subject: [PATCH 29/50] View and Pane updated to support streaming output --- .../javafx/components/PairwiseJpdfView.java | 264 ++++++++++++++---- .../utils/statistics/PairGridPane.java | 31 +- 2 files changed, 239 insertions(+), 56 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index 284ac9d1..16d82739 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -9,33 +9,46 @@ import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; import edu.jhuapl.trinity.utils.statistics.PairGridPane; import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.concurrent.Task; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.util.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Supplier; /** * PairwiseJpdfView - * - Top bar with: Reset, Run (from config), Run (external), Clear Grid + * - Top: buttons (left) + progress label (right) * - Left: Config Panel - * - Center: Grid of heatmap thumbnails - * - Public API to set cohorts, supply recipes, and handle run/clear events. - * - Usable in floating pane or in popout/fullscreen windows. + * - Center: Grid of heatmap thumbnails (live-append as results complete) + * + * Streaming UI approach: + * - Engine threads enqueue results into 'pendingQueue' (no runLater per item). + * - A Timeline on the FX thread flushes up to N items every tick to the grid. + * - When engine signals done and queue drains, we finalize (sort, toast, enable UI). */ public class PairwiseJpdfView extends BorderPane { // Core GUI nodes private final PairwiseJpdfConfigPanel configPanel; private final PairGridPane gridPane; - private final HBox topBar; + private final BorderPane topBar; // container for buttons (left) + progress (right) + private final HBox topButtons; // left side + private final Label progressLabel; // right side - // Core engine/cache (now always non-null) + // Cache (always non-null) private final DensityCache cache; // State @@ -44,14 +57,28 @@ public class PairwiseJpdfView extends BorderPane { private String cohortALabel = "A"; private String cohortBLabel = "B"; - private Supplier recipeSupplier; // optional, for run external button - private Consumer toastHandler; // optional, for showing user messages - - // Cell click handler (e.g., for open in 3D) + private Supplier recipeSupplier; // optional for "Run (external)" + private Consumer toastHandler; // optional for user messages private Consumer onCellClickHandler; + // Background worker ref (optional cancellation later) + private volatile Thread workerThread; + + // --- Streaming UI buffers/state --- + private final ConcurrentLinkedQueue pendingQueue = new ConcurrentLinkedQueue<>(); + private Timeline flushTimer; // runs on FX thread, drains pendingQueue + private volatile boolean streamDone = false; // set true when engine finishes + private final AtomicInteger completed = new AtomicInteger(0); + + // final summary to show when flushing finishes + private volatile JpdfBatchEngine.BatchResult finalBatchSummary = null; + private volatile long finalWallMillis = 0L; + + // Tuning: how often & how many items to flush per tick + private static final Duration FLUSH_PERIOD = Duration.millis(50); + private static final int FLUSH_BATCH_SIZE = 12; + public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { - // Defensive: always have a cache (if none supplied, create default) this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); this.configPanel = (configPanel != null) ? configPanel : new PairwiseJpdfConfigPanel(); @@ -60,7 +87,10 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf if (onCellClickHandler != null) onCellClickHandler.accept(click.item); }); - // Buttons (configPanel's Run/Reset, plus ours) + // Buttons (use the config panel's run/reset, plus our extras) + Button cfgReset = this.configPanel.getResetButton(); + Button cfgRun = this.configPanel.getRunButton(); + Button runExternalBtn = new Button("Run (external)"); runExternalBtn.setOnAction(e -> { if (recipeSupplier != null) { @@ -73,14 +103,19 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf Button clearBtn = new Button("Clear Grid"); clearBtn.setOnAction(e -> gridPane.clearItems()); - Button cfgReset = this.configPanel.getResetButton(); - Button cfgRun = this.configPanel.getRunButton(); + topButtons = new HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); + topButtons.setAlignment(Pos.CENTER_LEFT); + topButtons.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 HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); - topBar.setAlignment(Pos.CENTER_LEFT); - topBar.setPadding(new Insets(6, 8, 6, 8)); + topBar = new BorderPane(); + topBar.setLeft(topButtons); + topBar.setRight(progressLabel); - // Left = VBox(topBar, configPanel) + // Left = VBox-like: topBar, then config panel BorderPane left = new BorderPane(); left.setTop(topBar); left.setCenter(this.configPanel); @@ -91,35 +126,33 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf BorderPane.setMargin(gridPane, new Insets(6)); setPadding(new Insets(6)); - // Wire config panel 'Run' to runWithRecipe + // Wire config panel Run to us this.configPanel.setOnRun(this::runWithRecipe); } // --- Public API --- - /** Set the vectors for Cohort A (primary) */ public void setCohortA(List vectors, String label) { this.cohortA = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); if (label != null && !label.isBlank()) this.cohortALabel = label; } - /** Set the vectors for Cohort B (not currently used in batch, but included for completeness) */ public void setCohortB(List vectors, String label) { this.cohortB = (vectors == null) ? new ArrayList<>() : new ArrayList<>(vectors); if (label != null && !label.isBlank()) this.cohortBLabel = label; } - /** Allow parent to provide a recipe supplier for "Run (external)" button */ public void setRecipeSupplier(Supplier supplier) { this.recipeSupplier = supplier; } - /** Set a custom handler for toasts/user messages (optional) */ public void setToastHandler(Consumer handler) { this.toastHandler = handler; } - /** Set cell click handler (e.g., open in 3D) */ public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } - /** Run computation and populate grid, using provided recipe */ + /** Run computation (on a background thread) and populate grid with live-append thumbnails using a buffered FX flush. */ public void runWithRecipe(JpdfRecipe recipe) { + if (recipe == null && recipeSupplier != null) { + recipe = recipeSupplier.get(); + } 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; } @@ -128,61 +161,184 @@ public void runWithRecipe(JpdfRecipe recipe) { ? recipe.getCanonicalPolicyId() : "default" ); - if (policy == null) { toast("Failed to determine canonical grid policy for recipe.", true); return; } - if (this.cache == null) { - toast("DensityCache is null! This should never happen.", true); - return; - } - + // Prepare UI for a fresh run + gridPane.clearItems(); + setTopBarDisabled(true); + setProgressText("Preparing…"); toast("Computing pairwise densities…", false); - // For now, only cohortA is used - BatchResult batch; - switch (recipe.getPairSelection()) { - case WHITELIST -> batch = JpdfBatchEngine.runWhitelistPairs(cohortA, recipe, policy, this.cache); - default -> batch = JpdfBatchEngine.runComponentPairs(cohortA, recipe, policy, this.cache); - } + // Reset streaming state + pendingQueue.clear(); + completed.set(0); + streamDone = false; + finalBatchSummary = null; + finalWallMillis = 0L; + + // Start/Restart the flush timer on FX thread + startFlushTimer(); + + // Streaming callback invoked by engine threads (NO runLater here) + final java.util.function.Consumer onResult = job -> { + if (job == null || job.density == null) return; - List items = new ArrayList<>(batch.jobs.size()); - for (JpdfBatchEngine.PairJobResult job : batch.jobs) { 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) - .from(job.density, false, true) - .palette(HeatmapThumbnailView.PaletteKind.SEQUENTIAL) - .autoRange(true) - .showLegend(false); + .newBuilder(xLbl, yLbl) + .from(job.density, /*useCDF*/ false, /*flipY*/ true) + .palette(HeatmapThumbnailView.PaletteKind.SEQUENTIAL) + .autoRange(true) + .showLegend(false); + if (job.rank != null) b.score(job.rank.score); - items.add(b.build()); + + PairGridPane.PairItem item = b.build(); + + // enqueue for FX flush + pendingQueue.add(item); + completed.incrementAndGet(); + }; + + final JpdfRecipe runRecipe = recipe; // capture final reference + final CanonicalGridPolicy runPolicy = policy; + + 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, runPolicy, cache, onResult); + default -> + batch = JpdfBatchEngine.runComponentPairs(cohortA, runRecipe, runPolicy, cache, onResult); + } + } catch (Throwable t) { + final String msg = "Batch failed: " + t.getClass().getSimpleName() + + " - " + String.valueOf(t.getMessage()); + Platform.runLater(() -> { + streamDone = true; + setTopBarDisabled(false); + setProgressText(""); + toast(msg, true); + stopFlushTimer(); // no more appends + }); + return null; + } + + final BatchResult finalBatch = batch; + final long wall = System.currentTimeMillis() - start; + + // Signal done & stash summary for finalize on FX thread + Platform.runLater(() -> { + finalBatchSummary = finalBatch; + finalWallMillis = wall; + streamDone = true; + }); + return null; + } + }; + + workerThread = new Thread(task, "PairwiseJpdfView-Worker"); + workerThread.setDaemon(true); + workerThread.start(); + } + + // --- Streaming flush machinery (FX thread) --- + + private void startFlushTimer() { + if (flushTimer != null) { + flushTimer.stop(); + } + flushTimer = new Timeline(new KeyFrame(FLUSH_PERIOD, e -> flushPendingOnce())); + flushTimer.setCycleCount(Timeline.INDEFINITE); + flushTimer.playFromStart(); + } + + private void stopFlushTimer() { + if (flushTimer != null) { + flushTimer.stop(); + flushTimer = null; } - gridPane.setItems(items); - gridPane.sortByScoreDescending(); - toast("Batch complete: " + items.size() + " surfaces; cacheHits=" + batch.cacheHits + - "; wall=" + batch.wallMillis + " ms.", false); } - // --- Toast/user message --- + /** Runs on FX thread every tick; drains up to FLUSH_BATCH_SIZE items and appends to the grid. */ + private void flushPendingOnce() { + int n = 0; + PairGridPane.PairItem item; + while (n < FLUSH_BATCH_SIZE && (item = pendingQueue.poll()) != null) { + gridPane.addItemStreaming(item); + n++; + } + if (n > 0) { + setProgressText("Loaded " + completed.get() + "…"); + } + + // If engine is done and queue is empty, finalize once and stop timer + if (streamDone && pendingQueue.isEmpty()) { + // Optional final sort (rebuilds once at the end) + gridPane.sortByScoreDescending(); + + BatchResult fb = finalBatchSummary; + if (fb != null) { + int total = Math.max(fb.submittedPairs, completed.get()); + setProgressText("Loaded " + completed.get() + " / " + total); + setTopBarDisabled(false); + toast("Batch complete: " + completed.get() + + " surfaces; cacheHits=" + fb.cacheHits + + "; wall=" + finalWallMillis + " ms.", false); + } else { + setTopBarDisabled(false); + } + stopFlushTimer(); + } + } + + // --- Helpers --- + + private void setTopBarDisabled(boolean disabled) { + topButtons.setDisable(disabled); + progressLabel.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 { - // fallback: print to console System.out.println((isError ? "[Error] " : "[Info] ") + msg); } } - // --- Expose underlying controls for advanced wiring, if needed --- + // Expose underlying controls if needed public PairwiseJpdfConfigPanel getConfigPanel() { return configPanel; } public PairGridPane getGridPane() { return gridPane; } - public HBox getTopBar() { return topBar; } + public BorderPane getTopBar() { return topBar; } + + // Optional cancel hook (engine would need to check interrupts to honor it) + public void cancelRun() { + Thread t = workerThread; + if (t != null && t.isAlive()) { + t.interrupt(); + } + Platform.runLater(() -> { + streamDone = true; + stopFlushTimer(); + setTopBarDisabled(false); + setProgressText("Cancelled"); + }); + } - // --- Advanced: get current state (optional) --- + // Current state (optional) public List getCohortA() { return cohortA; } public List getCohortB() { return cohortB; } public String getCohortALabel() { return cohortALabel; } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java index 2a2e357c..449b88a4 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -161,6 +161,7 @@ public CellClick(int index, PairItem item) { public PairGridPane() { // ScrollPane: cap viewport so it never pushes parent scroller.setFitToWidth(true); + scroller.setFitToHeight(false); // important: content height won’t try to expand the parent scroller.setPannable(true); scroller.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); scroller.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); @@ -196,7 +197,6 @@ public PairGridPane() { placeholder.setAlignment(Pos.CENTER); placeholder.setMinSize(0, 0); placeholder.setPrefSize(560, 360); // gives initial size w/o pushing parent - placeholderLabel.setStyle("-fx-text-fill: derive(-fx-text-base-color, -30%); -fx-font-style: italic;"); contentStack.getChildren().addAll(scroller, placeholder); placeholder.toFront(); @@ -218,6 +218,7 @@ public void setItems(List items) { applyFilterSortAndRender(); } + /** Legacy add (rebuilds grid, respects filter/sort). */ public void addItem(PairItem item) { if (item != null) { allItems.add(item); @@ -225,6 +226,31 @@ public void addItem(PairItem item) { } } + /** + * Streaming add: efficiently appends one tile without rebuilding the whole grid + * when no filter/sort is active. If filter or sorter is set, falls back to full render. + */ + public void addItemStreaming(PairItem item) { + if (item == null) return; + allItems.add(item); + + // If filtering or sorting is active, we must rebuild to preserve correctness. + if (filter != null || sorter != null) { + applyFilterSortAndRender(); + return; + } + + // No filter/sort: append directly. + if (visibleItems.isEmpty()) { + placeholder.setVisible(false); + scroller.setVisible(true); + } + int index = visibleItems.size(); + visibleItems.add(item); + Node cell = buildCell(index, item); + tilePane.getChildren().add(cell); + } + public void clearItems() { allItems.clear(); applyFilterSortAndRender(); @@ -324,7 +350,7 @@ private Node buildCell(int index, PairItem item) { } Label header = new Label(title); header.setPadding(new Insets(2, 4, 2, 4)); - header.setTextFill(Color.web("#E0E0E0")); + // leave header unstyled (no textFill or CSS), per app-wide styling policy HeatmapThumbnailView view = new HeatmapThumbnailView(); view.setPrefSize(cellWidth, cellHeight); @@ -363,6 +389,7 @@ private Node buildCell(int index, PairItem item) { center.setPadding(cellPadding); cell.setCenter(center); + // keep programmatic border (no CSS classes used) cell.setBorder(new Border(new BorderStroke( Color.web("#3A3A3A"), BorderStrokeStyle.SOLID, From 6f3989c68a8153dfcf48fc9cab17f1a4e2c5bfd1 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 21 Sep 2025 19:13:32 -0400 Subject: [PATCH 30/50] Refactored PairwiseJpdfView so that controls and content are in splitPane --- .../javafx/components/PairwiseJpdfView.java | 288 +++++++++--------- .../utils/statistics/PairGridPane.java | 81 +++-- 2 files changed, 210 insertions(+), 159 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index 16d82739..1da6c962 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -9,44 +9,70 @@ import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; import edu.jhuapl.trinity.utils.statistics.PairGridPane; import edu.jhuapl.trinity.utils.statistics.PairwiseJpdfConfigPanel; -import javafx.animation.KeyFrame; -import javafx.animation.Timeline; import javafx.application.Platform; import javafx.concurrent.Task; 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.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.SplitPane; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; -import javafx.util.Duration; +import javafx.scene.layout.VBox; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Supplier; +import javafx.scene.layout.Background; /** - * PairwiseJpdfView - * - Top: buttons (left) + progress label (right) - * - Left: Config Panel - * - Center: Grid of heatmap thumbnails (live-append as results complete) + * PairwiseJpdfView (SplitPane-based) + * ---------------------------------- + * Layout: + * [SplitPane HORIZONTAL] + * ├─ Left (VBox): + * │ ├─ Top bar (buttons + streaming controls on the left, progress label on the right) + * │ ├─ Separator + * │ └─ PairwiseJpdfConfigPanel (2-col compact) + * └─ Right: + * └─ PairGridPane (scrollable TilePane of thumbnails) * - * Streaming UI approach: - * - Engine threads enqueue results into 'pendingQueue' (no runLater per item). - * - A Timeline on the FX thread flushes up to N items every tick to the grid. - * - When engine signals done and queue drains, we finalize (sort, toast, enable UI). + * Streaming: + * - Results are appended live via the engine’s onResult callback. + * - Flush cadence and incremental sorting can be tuned in the top bar. */ public class PairwiseJpdfView extends BorderPane { // Core GUI nodes private final PairwiseJpdfConfigPanel configPanel; private final PairGridPane gridPane; - private final BorderPane topBar; // container for buttons (left) + progress (right) - private final HBox topButtons; // left side - private final Label progressLabel; // right side + + // Split layout + private final SplitPane splitPane; + private final VBox controlsBox; // left side of split + private final BorderPane topBar; // top of left side + private final HBox topLeft; // buttons + streaming controls (left) + private final Label progressLabel; // progress (right) + private double lastDividerPos = 0.36; // remembered position for collapse/expand + + // Buttons + private final Button cfgResetBtn; + private final Button cfgRunBtn; + private final Button runExternalBtn; + private final Button clearBtn; + private final Button collapseBtn; + + // Streaming controls + private final Spinner flushSizeSpinner; // how many completed items per UI flush + private final Spinner flushIntervalSpinner; // how many ms between forced UI flushes + private final CheckBox incrementalSortCheck; // optional incremental sorting // Cache (always non-null) private final DensityCache cache; @@ -61,37 +87,25 @@ public class PairwiseJpdfView extends BorderPane { private Consumer toastHandler; // optional for user messages private Consumer onCellClickHandler; - // Background worker ref (optional cancellation later) + // Background worker ref (optional cancellation) private volatile Thread workerThread; - // --- Streaming UI buffers/state --- - private final ConcurrentLinkedQueue pendingQueue = new ConcurrentLinkedQueue<>(); - private Timeline flushTimer; // runs on FX thread, drains pendingQueue - private volatile boolean streamDone = false; // set true when engine finishes - private final AtomicInteger completed = new AtomicInteger(0); - - // final summary to show when flushing finishes - private volatile JpdfBatchEngine.BatchResult finalBatchSummary = null; - private volatile long finalWallMillis = 0L; - - // Tuning: how often & how many items to flush per tick - private static final Duration FLUSH_PERIOD = Duration.millis(50); - private static final int FLUSH_BATCH_SIZE = 12; - public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { + // Engine is used indirectly via static calls; keep cache & config this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); this.configPanel = (configPanel != null) ? configPanel : new PairwiseJpdfConfigPanel(); + // Grid (right side) this.gridPane = new PairGridPane(); this.gridPane.setOnCellClick(click -> { if (onCellClickHandler != null) onCellClickHandler.accept(click.item); }); - // Buttons (use the config panel's run/reset, plus our extras) - Button cfgReset = this.configPanel.getResetButton(); - Button cfgRun = this.configPanel.getRunButton(); + // --- Buttons + cfgResetBtn = this.configPanel.getResetButton(); + cfgRunBtn = this.configPanel.getRunButton(); - Button runExternalBtn = new Button("Run (external)"); + runExternalBtn = new Button("Run (external)"); runExternalBtn.setOnAction(e -> { if (recipeSupplier != null) { runWithRecipe(recipeSupplier.get()); @@ -100,31 +114,61 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf } }); - Button clearBtn = new Button("Clear Grid"); + clearBtn = new Button("Clear Grid"); clearBtn.setOnAction(e -> gridPane.clearItems()); - topButtons = new HBox(10, cfgReset, cfgRun, runExternalBtn, clearBtn); - topButtons.setAlignment(Pos.CENTER_LEFT); - topButtons.setPadding(new Insets(6, 8, 6, 8)); + collapseBtn = new Button("Collapse Controls"); + collapseBtn.setOnAction(e -> toggleControlsCollapsed()); + + // --- Streaming controls + flushSizeSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 512, 6, 1)); + flushSizeSpinner.setEditable(true); + + flushIntervalSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 2000, 80, 10)); + flushIntervalSpinner.setEditable(true); + + incrementalSortCheck = new CheckBox("Incremental sort"); + // --- Top left row (buttons + streaming controls) + HBox streamControls = new HBox(8, + new Label("Flush N"), flushSizeSpinner, + new Label("Flush ms"), flushIntervalSpinner, + incrementalSortCheck + ); + streamControls.setAlignment(Pos.CENTER_LEFT); + + topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, runExternalBtn, clearBtn, collapseBtn, streamControls); + topLeft.setAlignment(Pos.CENTER_LEFT); + topLeft.setPadding(new Insets(6, 8, 6, 8)); + + // --- Progress label (right) progressLabel = new Label(""); BorderPane.setAlignment(progressLabel, Pos.CENTER_RIGHT); BorderPane.setMargin(progressLabel, new Insets(6, 8, 6, 8)); + // --- Top bar (left=topLeft, right=progressLabel) topBar = new BorderPane(); - topBar.setLeft(topButtons); + topBar.setLeft(topLeft); topBar.setRight(progressLabel); - - // Left = VBox-like: topBar, then config panel - BorderPane left = new BorderPane(); - left.setTop(topBar); - left.setCenter(this.configPanel); - BorderPane.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); - - setLeft(left); - setCenter(gridPane); - BorderPane.setMargin(gridPane, new Insets(6)); - setPadding(new Insets(6)); + setTop(topBar); + + // --- Left controls area (VBox): topBar, Separator, configPanel + controlsBox = new VBox(); +// controlsBox.getChildren().addAll(topBar, new Separator(), this.configPanel); + controlsBox.getChildren().addAll(this.configPanel); + VBox.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); + + // Give controls a reasonable min/pref so divider behaves well + controlsBox.setMinWidth(220); + controlsBox.setPrefWidth(390); + + // --- SplitPane: controls (left), grid (right) + splitPane = new SplitPane(controlsBox, gridPane); + splitPane.setOrientation(Orientation.HORIZONTAL); + splitPane.setDividerPositions(lastDividerPos); + splitPane.setBackground(Background.EMPTY); + setCenter(splitPane); + BorderPane.setMargin(splitPane, new Insets(6)); // Wire config panel Run to us this.configPanel.setOnRun(this::runWithRecipe); @@ -148,7 +192,7 @@ public void setCohortB(List vectors, String label) { public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } - /** Run computation (on a background thread) and populate grid with live-append thumbnails using a buffered FX flush. */ + /** Run computation (background thread) and live-append thumbnails with tunable cadence. */ public void runWithRecipe(JpdfRecipe recipe) { if (recipe == null && recipeSupplier != null) { recipe = recipeSupplier.get(); @@ -156,33 +200,31 @@ 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; } - CanonicalGridPolicy policy = CanonicalGridPolicy.get( - (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) - ? recipe.getCanonicalPolicyId() - : "default" + 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; } - // Prepare UI for a fresh run + // Prepare UI gridPane.clearItems(); - setTopBarDisabled(true); + setControlsDisabled(true); setProgressText("Preparing…"); toast("Computing pairwise densities…", false); - // Reset streaming state - pendingQueue.clear(); - completed.set(0); - streamDone = false; - finalBatchSummary = null; - finalWallMillis = 0L; + // Progress counter + AtomicInteger completed = new AtomicInteger(0); - // Start/Restart the flush timer on FX thread - startFlushTimer(); + // Streaming cadence + final int flushN = safeSpinnerInt(flushSizeSpinner, 6); + final int flushMs = safeSpinnerInt(flushIntervalSpinner, 80); + final boolean doIncrementalSort = incrementalSortCheck.isSelected(); - // Streaming callback invoked by engine threads (NO runLater here) + // On-result (invoked from engine worker threads) final java.util.function.Consumer onResult = job -> { if (job == null || job.density == null) return; @@ -197,16 +239,17 @@ public void runWithRecipe(JpdfRecipe recipe) { .showLegend(false); if (job.rank != null) b.score(job.rank.score); - PairGridPane.PairItem item = b.build(); - // enqueue for FX flush - pendingQueue.add(item); - completed.incrementAndGet(); + gridPane.addItemStreaming(item, flushN, flushMs, doIncrementalSort); + + int n = completed.incrementAndGet(); + if ((n % flushN) == 0) { + Platform.runLater(() -> setProgressText("Loaded " + n + "…")); + } }; - final JpdfRecipe runRecipe = recipe; // capture final reference - final CanonicalGridPolicy runPolicy = policy; + final JpdfRecipe runRecipe = recipe; Task task = new Task<>() { @Override protected Void call() { @@ -215,31 +258,35 @@ public void runWithRecipe(JpdfRecipe recipe) { try { switch (runRecipe.getPairSelection()) { case WHITELIST -> - batch = JpdfBatchEngine.runWhitelistPairs(cohortA, runRecipe, runPolicy, cache, onResult); + batch = JpdfBatchEngine.runWhitelistPairs(cohortA, runRecipe, policy, cache, onResult); default -> - batch = JpdfBatchEngine.runComponentPairs(cohortA, runRecipe, runPolicy, cache, onResult); + batch = JpdfBatchEngine.runComponentPairs(cohortA, runRecipe, policy, cache, onResult); } } catch (Throwable t) { final String msg = "Batch failed: " + t.getClass().getSimpleName() - + " - " + String.valueOf(t.getMessage()); + + " - " + String.valueOf(t.getMessage()); Platform.runLater(() -> { - streamDone = true; - setTopBarDisabled(false); + setControlsDisabled(false); setProgressText(""); toast(msg, true); - stopFlushTimer(); // no more appends }); return null; } final BatchResult finalBatch = batch; + final int finalCompleted = completed.get(); final long wall = System.currentTimeMillis() - start; - // Signal done & stash summary for finalize on FX thread Platform.runLater(() -> { - finalBatchSummary = finalBatch; - finalWallMillis = wall; - streamDone = true; + // Final sort by score (descending) to present a clean list at the end + 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; } @@ -250,61 +297,34 @@ public void runWithRecipe(JpdfRecipe recipe) { workerThread.start(); } - // --- Streaming flush machinery (FX thread) --- - - private void startFlushTimer() { - if (flushTimer != null) { - flushTimer.stop(); - } - flushTimer = new Timeline(new KeyFrame(FLUSH_PERIOD, e -> flushPendingOnce())); - flushTimer.setCycleCount(Timeline.INDEFINITE); - flushTimer.playFromStart(); - } + // --- Helpers --- - private void stopFlushTimer() { - if (flushTimer != null) { - flushTimer.stop(); - flushTimer = null; + private void toggleControlsCollapsed() { + double pos = splitPane.getDividerPositions()[0]; + // Collapse if not already collapsed + if (pos > 0.02) { + lastDividerPos = pos; + splitPane.setDividerPositions(0.0); + collapseBtn.setText("Expand Controls"); + } else { + splitPane.setDividerPositions(Math.max(0.15, lastDividerPos)); + collapseBtn.setText("Collapse Controls"); } } - /** Runs on FX thread every tick; drains up to FLUSH_BATCH_SIZE items and appends to the grid. */ - private void flushPendingOnce() { - int n = 0; - PairGridPane.PairItem item; - while (n < FLUSH_BATCH_SIZE && (item = pendingQueue.poll()) != null) { - gridPane.addItemStreaming(item); - n++; - } - if (n > 0) { - setProgressText("Loaded " + completed.get() + "…"); - } - - // If engine is done and queue is empty, finalize once and stop timer - if (streamDone && pendingQueue.isEmpty()) { - // Optional final sort (rebuilds once at the end) - gridPane.sortByScoreDescending(); - - BatchResult fb = finalBatchSummary; - if (fb != null) { - int total = Math.max(fb.submittedPairs, completed.get()); - setProgressText("Loaded " + completed.get() + " / " + total); - setTopBarDisabled(false); - toast("Batch complete: " + completed.get() - + " surfaces; cacheHits=" + fb.cacheHits - + "; wall=" + finalWallMillis + " ms.", false); - } else { - setTopBarDisabled(false); - } - stopFlushTimer(); + private int safeSpinnerInt(Spinner sp, int fallback) { + try { + sp.commitValue(); + Integer v = sp.getValue(); + return (v == null) ? fallback : v.intValue(); + } catch (Throwable ignore) { + return fallback; } } - // --- Helpers --- - - private void setTopBarDisabled(boolean disabled) { - topButtons.setDisable(disabled); - progressLabel.setDisable(disabled); + private void setControlsDisabled(boolean disabled) { + topBar.setDisable(disabled); + configPanel.setDisable(disabled); } private void setProgressText(String text) { @@ -322,7 +342,7 @@ public void toast(String msg, boolean isError) { // Expose underlying controls if needed public PairwiseJpdfConfigPanel getConfigPanel() { return configPanel; } public PairGridPane getGridPane() { return gridPane; } - public BorderPane getTopBar() { return topBar; } + public SplitPane getSplitPane() { return splitPane; } // Optional cancel hook (engine would need to check interrupts to honor it) public void cancelRun() { @@ -330,12 +350,6 @@ public void cancelRun() { if (t != null && t.isAlive()) { t.interrupt(); } - Platform.runLater(() -> { - streamDone = true; - stopFlushTimer(); - setTopBarDisabled(false); - setProgressText("Cancelled"); - }); } // Current state (optional) diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java index 449b88a4..c5ebdd07 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -36,7 +36,11 @@ * forcing the parent to expand. */ 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 { @@ -226,30 +230,63 @@ public void addItem(PairItem item) { } } - /** - * Streaming add: efficiently appends one tile without rebuilding the whole grid - * when no filter/sort is active. If filter or sorter is set, falls back to full render. - */ - public void addItemStreaming(PairItem item) { - if (item == null) return; - allItems.add(item); - - // If filtering or sorting is active, we must rebuild to preserve correctness. - if (filter != null || sorter != null) { - applyFilterSortAndRender(); - return; +/** 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(); +} +/** + * 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; + } } + } - // No filter/sort: append directly. - if (visibleItems.isEmpty()) { - placeholder.setVisible(false); - scroller.setVisible(true); - } - int index = visibleItems.size(); - visibleItems.add(item); - Node cell = buildCell(index, item); - tilePane.getChildren().add(cell); + if (schedule) { + final boolean doSort = incrementalSort; + javafx.application.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(); From ed06ee5289d5c69224a46cc574e8e98cb20ad4c6 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 23 Sep 2025 13:45:22 -0400 Subject: [PATCH 31/50] Rearranged gui layout to minimize total horizontal space usage --- .../javafx/components/PairwiseJpdfView.java | 297 ++++++++++++------ .../components/panes/PairwiseJpdfPane.java | 5 - .../utils/statistics/JpdfBatchEngine.java | 2 - .../utils/statistics/PairGridPane.java | 147 +++++---- .../statistics/PairwiseJpdfConfigPanel.java | 35 +++ .../trinity/utils/statistics/RecipeIo.java | 128 ++++++++ 6 files changed, 459 insertions(+), 155 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index 1da6c962..d4d0566b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -9,44 +9,32 @@ 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.geometry.Insets; import javafx.geometry.Orientation; import javafx.geometry.Pos; -import javafx.scene.control.Button; -import javafx.scene.control.CheckBox; -import javafx.scene.control.Label; -import javafx.scene.control.Separator; -import javafx.scene.control.Spinner; -import javafx.scene.control.SpinnerValueFactory; -import javafx.scene.control.SplitPane; +import javafx.scene.control.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.scene.layout.Background; +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; -import java.util.function.Supplier; -import javafx.scene.layout.Background; /** - * PairwiseJpdfView (SplitPane-based) - * ---------------------------------- - * Layout: - * [SplitPane HORIZONTAL] - * ├─ Left (VBox): - * │ ├─ Top bar (buttons + streaming controls on the left, progress label on the right) - * │ ├─ Separator - * │ └─ PairwiseJpdfConfigPanel (2-col compact) - * └─ Right: - * └─ PairGridPane (scrollable TilePane of thumbnails) - * - * Streaming: - * - Results are appended live via the engine’s onResult callback. - * - Flush cadence and incremental sorting can be tuned in the top bar. + * PairwiseJpdfView + * - Search/Sort are hosted in the PairGridPane header. + * - Toolbar is slim: Run, Reset, Collapse, Actions (MenuButton). + * - Actions menu contains Save/Load/Clear and streaming controls (Flush N/ms, Incremental sort). */ public class PairwiseJpdfView extends BorderPane { @@ -56,23 +44,29 @@ public class PairwiseJpdfView extends BorderPane { // Split layout private final SplitPane splitPane; - private final VBox controlsBox; // left side of split - private final BorderPane topBar; // top of left side - private final HBox topLeft; // buttons + streaming controls (left) - private final Label progressLabel; // progress (right) - private double lastDividerPos = 0.36; // remembered position for collapse/expand + private final VBox controlsBox; + private final BorderPane topBar; + private final HBox topLeft; + private final Label progressLabel; + private double lastDividerPos = 0.36; - // Buttons + // Buttons (visible in toolbar) private final Button cfgResetBtn; private final Button cfgRunBtn; - private final Button runExternalBtn; - private final Button clearBtn; private final Button collapseBtn; - // Streaming controls - private final Spinner flushSizeSpinner; // how many completed items per UI flush - private final Spinner flushIntervalSpinner; // how many ms between forced UI flushes - private final CheckBox incrementalSortCheck; // optional incremental sorting + // MenuButton (consolidated actions) + private final MenuButton actionsBtn; + + // Streaming controls (now only inside 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"); // Cache (always non-null) private final DensityCache cache; @@ -83,86 +77,81 @@ public class PairwiseJpdfView extends BorderPane { private String cohortALabel = "A"; private String cohortBLabel = "B"; - private Supplier recipeSupplier; // optional for "Run (external)" - private Consumer toastHandler; // optional for user messages + private Consumer toastHandler; private Consumer onCellClickHandler; - // Background worker ref (optional cancellation) private volatile Thread workerThread; public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdfConfigPanel configPanel) { - // Engine is used indirectly via static calls; keep cache & config this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); this.configPanel = (configPanel != null) ? configPanel : new PairwiseJpdfConfigPanel(); - // Grid (right side) + // Grid (right) this.gridPane = new PairGridPane(); this.gridPane.setOnCellClick(click -> { if (onCellClickHandler != null) onCellClickHandler.accept(click.item); }); - // --- Buttons + // Buttons from config panel cfgResetBtn = this.configPanel.getResetButton(); cfgRunBtn = this.configPanel.getRunButton(); - runExternalBtn = new Button("Run (external)"); - runExternalBtn.setOnAction(e -> { - if (recipeSupplier != null) { - runWithRecipe(recipeSupplier.get()); - } else { - toast("No recipe supplier set; use the panel’s Run button.", true); - } - }); - - clearBtn = new Button("Clear Grid"); - clearBtn.setOnAction(e -> gridPane.clearItems()); - collapseBtn = new Button("Collapse Controls"); collapseBtn.setOnAction(e -> toggleControlsCollapsed()); - // --- Streaming controls + // 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"); - // --- Top left row (buttons + streaming controls) - HBox streamControls = new HBox(8, - new Label("Flush N"), flushSizeSpinner, - new Label("Flush ms"), flushIntervalSpinner, - incrementalSortCheck + // --- 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 gridHeader = new HBox(8, + new Label("Search"), searchField, + new Label("Sort"), sortCombo, sortAscCheck ); - streamControls.setAlignment(Pos.CENTER_LEFT); + gridHeader.setAlignment(Pos.CENTER_LEFT); + gridHeader.setPadding(new Insets(4, 8, 2, 8)); + gridPane.setHeader(gridHeader); - topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, runExternalBtn, clearBtn, collapseBtn, streamControls); + // --- Actions (MenuButton) --- + actionsBtn = buildActionsMenu(); + + // --- Slim toolbar --- + topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn); topLeft.setAlignment(Pos.CENTER_LEFT); topLeft.setPadding(new Insets(6, 8, 6, 8)); - // --- Progress label (right) progressLabel = new Label(""); BorderPane.setAlignment(progressLabel, Pos.CENTER_RIGHT); BorderPane.setMargin(progressLabel, new Insets(6, 8, 6, 8)); - // --- Top bar (left=topLeft, right=progressLabel) topBar = new BorderPane(); topBar.setLeft(topLeft); topBar.setRight(progressLabel); setTop(topBar); - - // --- Left controls area (VBox): topBar, Separator, configPanel + + // Left controls box controlsBox = new VBox(); -// controlsBox.getChildren().addAll(topBar, new Separator(), this.configPanel); controlsBox.getChildren().addAll(this.configPanel); VBox.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); - - // Give controls a reasonable min/pref so divider behaves well - controlsBox.setMinWidth(220); + controlsBox.setMinWidth(0); controlsBox.setPrefWidth(390); - // --- SplitPane: controls (left), grid (right) + // SplitPane splitPane = new SplitPane(controlsBox, gridPane); splitPane.setOrientation(Orientation.HORIZONTAL); splitPane.setDividerPositions(lastDividerPos); @@ -170,8 +159,46 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf setCenter(splitPane); BorderPane.setMargin(splitPane, new Insets(6)); - // Wire config panel Run to us + // 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(); + } + + // 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; } // --- Public API --- @@ -186,17 +213,13 @@ public void setCohortB(List vectors, String label) { if (label != null && !label.isBlank()) this.cohortBLabel = label; } - public void setRecipeSupplier(Supplier supplier) { this.recipeSupplier = supplier; } - public void setToastHandler(Consumer handler) { this.toastHandler = handler; } public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } - /** Run computation (background thread) and live-append thumbnails with tunable cadence. */ + // --- Run --- + public void runWithRecipe(JpdfRecipe recipe) { - if (recipe == null && recipeSupplier != null) { - recipe = recipeSupplier.get(); - } 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; } @@ -210,21 +233,16 @@ public void runWithRecipe(JpdfRecipe recipe) { return; } - // Prepare UI gridPane.clearItems(); setControlsDisabled(true); setProgressText("Preparing…"); toast("Computing pairwise densities…", false); - // Progress counter AtomicInteger completed = new AtomicInteger(0); - - // Streaming cadence final int flushN = safeSpinnerInt(flushSizeSpinner, 6); final int flushMs = safeSpinnerInt(flushIntervalSpinner, 80); final boolean doIncrementalSort = incrementalSortCheck.isSelected(); - // On-result (invoked from engine worker threads) final java.util.function.Consumer onResult = job -> { if (job == null || job.density == null) return; @@ -233,7 +251,8 @@ public void runWithRecipe(JpdfRecipe recipe) { PairGridPane.PairItem.Builder b = PairGridPane.PairItem .newBuilder(xLbl, yLbl) - .from(job.density, /*useCDF*/ false, /*flipY*/ true) + .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); @@ -278,9 +297,7 @@ public void runWithRecipe(JpdfRecipe recipe) { final long wall = System.currentTimeMillis() - start; Platform.runLater(() -> { - // Final sort by score (descending) to present a clean list at the end gridPane.sortByScoreDescending(); - int total = Math.max(finalBatch.submittedPairs, finalCompleted); setProgressText("Loaded " + finalCompleted + " / " + total); setControlsDisabled(false); @@ -301,13 +318,17 @@ public void runWithRecipe(JpdfRecipe recipe) { private void toggleControlsCollapsed() { double pos = splitPane.getDividerPositions()[0]; - // Collapse if not already collapsed - if (pos > 0.02) { + if (pos > 0.02 || controlsBox.isVisible()) { lastDividerPos = pos; + controlsBox.setManaged(false); + controlsBox.setVisible(false); splitPane.setDividerPositions(0.0); collapseBtn.setText("Expand Controls"); } else { - splitPane.setDividerPositions(Math.max(0.15, lastDividerPos)); + controlsBox.setManaged(true); + controlsBox.setVisible(true); + double restore = (lastDividerPos <= 0.02) ? 0.36 : lastDividerPos; + splitPane.setDividerPositions(restore); collapseBtn.setText("Collapse Controls"); } } @@ -316,7 +337,7 @@ private int safeSpinnerInt(Spinner sp, int fallback) { try { sp.commitValue(); Integer v = sp.getValue(); - return (v == null) ? fallback : v.intValue(); + return (v == null) ? fallback : v; } catch (Throwable ignore) { return fallback; } @@ -339,12 +360,10 @@ public void toast(String msg, boolean isError) { } } - // Expose underlying controls if needed public PairwiseJpdfConfigPanel getConfigPanel() { return configPanel; } public PairGridPane getGridPane() { return gridPane; } public SplitPane getSplitPane() { return splitPane; } - // Optional cancel hook (engine would need to check interrupts to honor it) public void cancelRun() { Thread t = workerThread; if (t != null && t.isAlive()) { @@ -352,9 +371,103 @@ public void cancelRun() { } } - // Current state (optional) 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/panes/PairwiseJpdfPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java index 3c678c75..1ae63452 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -17,9 +17,7 @@ import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.text.Font; - import java.util.List; -import java.util.function.Supplier; public final class PairwiseJpdfPane extends LitPathPane { @@ -81,9 +79,6 @@ public void setCohortA(List vectors, String label) { public void setCohortB(List vectors, String label) { view.setCohortB(vectors, label); } - public void setRecipeSupplier(Supplier supplier) { - view.setRecipeSupplier(supplier); - } public void runWithRecipe(JpdfRecipe recipe) { view.runWithRecipe(recipe); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java index e6adc20c..c585db39 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java @@ -1,10 +1,8 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; - import java.io.Serial; import java.io.Serializable; -import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java index c5ebdd07..2efec5c1 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -34,6 +34,8 @@ * ------------------------------ * Responsive grid of heatmap thumbnails that wraps on resize without * forcing the parent to expand. + * + * New: Header slot via {@link #setHeader(Node)} to host controls like Search/Sort. */ public final class PairGridPane extends BorderPane { // --- Streaming support (thread-safe buffer -> periodic FX flush) --- @@ -41,11 +43,17 @@ public final class PairGridPane extends BorderPane { 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; @@ -62,6 +70,8 @@ public static final class PairItem { 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; @@ -84,6 +94,9 @@ 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; @@ -116,6 +129,8 @@ public Builder from(GridDensityResult r, boolean useCDF, boolean flipY) { 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) @@ -141,6 +156,10 @@ public CellClick(int index, PairItem item) { 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"); @@ -165,7 +184,7 @@ public CellClick(int index, PairItem item) { public PairGridPane() { // ScrollPane: cap viewport so it never pushes parent scroller.setFitToWidth(true); - scroller.setFitToHeight(false); // important: content height won’t try to expand the parent + 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); @@ -176,7 +195,7 @@ public PairGridPane() { scroller.setPrefViewportWidth(720); scroller.setPrefViewportHeight(420); - // TilePane: width bound to viewport; height doesn’t drive parent + // TilePane: width bound to the viewport; height doesn’t drive parent tilePane.setPadding(new Insets(6)); tilePane.setHgap(cellHGap); tilePane.setVgap(cellVGap); @@ -205,9 +224,24 @@ public PairGridPane() { 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)); @@ -230,63 +264,51 @@ public void addItem(PairItem item) { } } -/** 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(); -} -/** - * 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; + /** + * 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; - javafx.application.Platform.runLater(() -> { - List toAdd; - synchronized (streamLock) { - toAdd = new ArrayList<>(pendingBuffer); - pendingBuffer.clear(); - lastFlushNanos = System.nanoTime(); - flushScheduled = false; - } - allItems.addAll(toAdd); - applyFilterSortAndRenderInternal(doSort); - }); + if (schedule) { + final boolean doSort = incrementalSort; + javafx.application.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(); @@ -363,6 +385,19 @@ private void applyFilterSortAndRender() { 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()) { @@ -387,7 +422,7 @@ private Node buildCell(int index, PairItem item) { } Label header = new Label(title); header.setPadding(new Insets(2, 4, 2, 4)); - // leave header unstyled (no textFill or CSS), per app-wide styling policy + // leave header unstyled (no CSS classes), per app-wide styling policy HeatmapThumbnailView view = new HeatmapThumbnailView(); view.setPrefSize(cellWidth, cellHeight); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java index 914c98fa..30403fba 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java @@ -180,6 +180,41 @@ public PairwiseJpdfConfigPanel() { /** 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 // --------------------------------------------------------------------- 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..a745c66b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java @@ -0,0 +1,128 @@ +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; } + } +} From 9538d887d8a94f16669b518903737ef38ea9b303 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 24 Sep 2025 06:41:41 -0400 Subject: [PATCH 32/50] added menu-button styles --- .../edu/jhuapl/trinity/css/styles.css | 88 +++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/src/main/resources/edu/jhuapl/trinity/css/styles.css b/src/main/resources/edu/jhuapl/trinity/css/styles.css index 8f52f247..cbd72d09 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,15 +1657,92 @@ -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; +} /******************************************************************************* * * From 03034a856f665d1e987e338756945b9baa9ee1a4 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Fri, 26 Sep 2025 09:35:16 -0400 Subject: [PATCH 33/50] First cut at base components for Pairwise Matrix support. Not wired in nor tested yet. --- .../java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java | 1 + 1 file changed, 1 insertion(+) 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 b653ed74..6a7194b9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -49,6 +49,7 @@ public class ApplicationEvent extends Event { 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 ApplicationEvent(EventType eventType, Object object) { this(eventType); From 55a71d4a53784fae4854a67c461d06aa16bc2c88 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Fri, 26 Sep 2025 11:10:54 -0400 Subject: [PATCH 34/50] New classes to support Pairwise Matrix computations --- .../javafx/components/MatrixHeatmapView.java | 631 ++++++++++++++++++ .../javafx/components/PairwiseMatrixView.java | 394 +++++++++++ .../components/panes/MatrixHeatmapPane.java | 179 +++++ .../components/panes/PairwiseMatrixPane.java | 119 ++++ .../utils/statistics/DivergenceComputer.java | 378 +++++++++++ .../statistics/FeatureSimilarityComputer.java | 455 +++++++++++++ .../statistics/PairwiseMatrixConfigPanel.java | 484 ++++++++++++++ .../statistics/PairwiseMatrixEngine.java | 315 +++++++++ .../utils/statistics/SimilarityComputer.java | 443 ++++++++++++ 9 files changed, 3398 insertions(+) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java 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..3f7a93b1 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java @@ -0,0 +1,631 @@ +package edu.jhuapl.trinity.javafx.components; + +import java.text.DecimalFormat; +import java.util.List; +import java.util.function.Consumer; +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; + +/** + * 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. + * - 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 -------------------- +// --- Compatibility enums (safe defaults) --- +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; + + // 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; + + // -------------------- 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); + } + + /** 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). */ + 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."); + } + this.colLabels = labels.toArray(new String[0]); + } + 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(); + } + + /** 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: + // [top labels] + double topLabelH = estimateTopLabelHeight(g, c); + // [left labels] + double leftLabelW = estimateLeftLabelWidth(g, r); + // [legend] + 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 = 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; + + // Disable pixel smoothing for crisp cell edges + 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 = matrix[i][j]; + + Color col = switch (paletteKind) { + case SEQUENTIAL -> sequentialColor(norm01(v, vmin, vmax)); + case DIVERGING -> divergingColor(v, vmin, vmax, divergingCenter != null ? divergingCenter.doubleValue() : 0.0); + }; + + g.setFill(col); + g.fillRect(x, y, Math.ceil(cellW), Math.ceil(cellH)); + } + } + + // Optional thin grid lines (subtle) + g.setStroke(Color.color(0, 0, 0, 0.15)); + g.setLineWidth(1.0); + // Vertical + for (int j = 0; j <= c; j++) { + double x = contentX + j * cellW + 0.5; + g.strokeLine(x, contentY, x, contentY + contentH); + } + // Horizontal + 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; + + // Vertical gradient + 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 -> sequentialColor(norm01(v, vmin, vmax)); + case DIVERGING -> 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 and labels (min, center (diverging), max) + g.setFill(Color.LIGHTGRAY); + g.setFont(labelFont); + g.setTextAlign(TextAlignment.LEFT); + g.setTextBaseline(VPos.CENTER); + + double tickX = x0 + w + 4; + // max (top) + g.fillText(df.format(vmax), tickX, y0); + // min (bottom) + g.fillText(df.format(vmin), tickX, y0 + h); + + // center (if diverging) + if (paletteKind == PaletteKind.DIVERGING) { + double t = 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), tickX, 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 = 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, 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 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}; + } + + private 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; + } + + // Sequential palette: grayscale → viridis-like simple blend + private static Color sequentialColor(double t) { + // Simple perceptual-ish gradient (dark blue → teal → yellow) + // t in [0,1] + double r = clamp01(-0.5 + 2.2 * t); + double g = clamp01(0.1 + 1.9 * t); + double b = clamp01(1.0 - 0.9 * t); + // Subtle gamma + double gamma = 0.95; + return Color.color(Math.pow(r, gamma), Math.pow(g, gamma), Math.pow(b, gamma)); + } + + // Diverging palette: cool (blue) below center, warm (red) above center; white at center + private static Color divergingColor(double v, double min, double max, double center) { + // Map to [-1, 1] where 0 is 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) { + // 0..1: white → orange/red + 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 { + // -1..0: blue → white + 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; + } + + private double estimateTopLabelHeight(GraphicsContext g, int cols) { + if (cols == 0) return 0; + g.setFont(labelFont); + // Use one sample to estimate height + 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); // cap measurement work + 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) { + // Use a representative sample for consistent line height + measureText.setText("Mg"); + measureText.setFont(g.getFont()); + return measureText.getLayoutBounds().getHeight(); +} + +} 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..e6048aa0 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -0,0 +1,394 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.MatrixHeatmapView.MatrixClick; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.DivergenceComputer.DivergenceMetric; +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 java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +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.CheckMenuItem; +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.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; + +/** + * PairwiseMatrixView + * ------------------ + * Parent composite that hosts: + * - Left: PairwiseMatrixConfigPanel (inputs/controls) + * - Right: MatrixHeatmapView (rendered matrix) + * - Top bar: Run/Reset + Actions (+ quick A/B helpers) + * + * Runs PairwiseMatrixEngine for Similarity or Divergence based on the panel request + * and updates the MatrixHeatmapView with results and initial display settings. + * + * @author Sean Phillips + */ +public final class PairwiseMatrixView extends BorderPane { + + // --- 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; + private double lastDividerPos = 0.36; + + // --- Buttons (visible in toolbar) + private final Button cfgRunBtn; + private final Button cfgResetBtn; + private final Button collapseBtn; + + // Quick cohort helpers + private Button btnBEqualsA; + private Button btnSplitA; + + // --- Actions (extensible) + 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; + + // Divergence preflight option + private boolean autoFillBFromAWhenMissing = true; + + // --- Hooks + private Consumer toastHandler; + private Consumer onCellClickHandler; + + 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(); + + // Hook matrix clicks to external handler if provided + this.heatmap.setOnCellClick(click -> { + if (onCellClickHandler != null) onCellClickHandler.accept(click); + }); + + // Buttons from config panel + this.cfgRunBtn = this.configPanel.getRunButton(); + this.cfgResetBtn = this.configPanel.getResetButton(); + + // Toolbar collapse toggle + this.collapseBtn = new Button("Collapse Controls"); + this.collapseBtn.setOnAction(e -> toggleControlsCollapsed()); + + // Actions menu (+ divergence helpers) + this.actionsBtn = buildActionsMenu(); + + // Quick A/B helper buttons + this.btnBEqualsA = new Button("B = A"); + this.btnBEqualsA.setOnAction(e -> useCohortAForB(null)); + this.btnSplitA = new Button("Split A→B"); + this.btnSplitA.setOnAction(e -> splitACohortIntoAandB()); + + // Slim toolbar (left = buttons; right = progress text) + this.topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn, btnBEqualsA, btnSplitA); + this.topLeft.setAlignment(Pos.CENTER_LEFT); + this.topLeft.setPadding(new Insets(6, 8, 6, 8)); + + this.progressLabel = new Label(""); + BorderPane.setAlignment(progressLabel, Pos.CENTER_RIGHT); + BorderPane.setMargin(progressLabel, new Insets(6, 8, 6, 8)); + + this.topBar = new BorderPane(); + this.topBar.setLeft(topLeft); + this.topBar.setRight(progressLabel); + setTop(topBar); + + // Left controls box + this.controlsBox = new VBox(); + this.controlsBox.getChildren().addAll(this.configPanel); + VBox.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); + this.controlsBox.setMinWidth(0); + this.controlsBox.setPrefWidth(390); + + // SplitPane: left controls, right heatmap + this.splitPane = new SplitPane(controlsBox, heatmap); + this.splitPane.setOrientation(Orientation.HORIZONTAL); + this.splitPane.setDividerPositions(lastDividerPos); + this.splitPane.setBackground(Background.EMPTY); + setCenter(splitPane); + BorderPane.setMargin(splitPane, new Insets(6)); + + // 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; } + + /** Optional: send toast/status to Terminal/Console integration. */ + public void setToastHandler(Consumer handler) { this.toastHandler = handler; } + + /** Optional: 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; } + + // Build minimal recipe from UI request + JpdfRecipe recipe = buildRecipeFromRequest(req); + + // Heatmap display settings + 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 + ); + } + + // Preflight: ensure data availability + if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty. Load or set vectors first.", true); + return; + } + if (req.mode == Mode.DIVERGENCE) { + if (!ensureBForDivergenceIfNeeded()) { + // ensureB already toasts a message + return; + } + } + + setControlsDisabled(true); + setProgressText("Running…"); + + // Run off FX thread (computations can be non-trivial) + new Thread(() -> { + long start = System.currentTimeMillis(); + try { + MatrixResult result; + + if (req.mode == Mode.SIMILARITY) { + result = PairwiseMatrixEngine.computeSimilarityMatrix( + cohortA, recipe, req.includeDiagonal + ); + } else { + // Divergence + 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); + 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(); + } + + private static T nonNull(T v, T def) { return (v != null ? v : def); } + + private JpdfRecipe buildRecipeFromRequest(Request req) { + // Engines use bins/bounds/policy/indices and (for similarity) scoreMetric. + 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(); + } + + // --------------------------------------------------------------------- + // Toolbar + Actions + // --------------------------------------------------------------------- + + private MenuButton buildActionsMenu() { + MenuButton mb = new MenuButton("Actions"); + + // Clear matrix + MenuItem clearItem = new MenuItem("Clear Matrix"); + clearItem.setOnAction(e -> heatmap.setMatrix((double[][]) null)); + + // Divergence helpers + CheckMenuItem autoFillBItem = new CheckMenuItem("Auto-fill B from A when missing"); + autoFillBItem.setSelected(autoFillBFromAWhenMissing); + autoFillBItem.selectedProperty().addListener((obs, ov, nv) -> autoFillBFromAWhenMissing = nv); + + MenuItem useAasBItem = new MenuItem("Set B = A (copy)"); + useAasBItem.setOnAction(e -> useCohortAForB(null)); + + MenuItem splitAItem = new MenuItem("Split A into A/B"); + splitAItem.setOnAction(e -> splitACohortIntoAandB()); + + mb.getItems().addAll( + clearItem, + new SeparatorMenuItem(), + autoFillBItem, + useAasBItem, + splitAItem + ); + return mb; + } + + 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"); + } + } + + // --------------------------------------------------------------------- + // Cohort helpers + divergence preflight + // --------------------------------------------------------------------- + + /** Copy Cohort A into Cohort B (A vs A sanity check). */ + private void useCohortAForB(String label) { + List a = getCohortA(); + if (a == null || a.isEmpty()) return; + String lab = (label != null && !label.isBlank()) ? label : (getCohortALabel() + " (copy)"); + setCohortB(new ArrayList<>(a), lab); + } + + /** Split Cohort A into two halves: first half -> A, second half -> B. */ + private void splitACohortIntoAandB() { + List a = getCohortA(); + if (a == null || a.size() < 2) return; + int mid = a.size() / 2; + setCohortB(new ArrayList<>(a.subList(mid, a.size())), getCohortALabel() + " (late)"); + setCohortA(new ArrayList<>(a.subList(0, mid)), getCohortALabel() + " (early)"); + } + + /** Ensure Cohort B exists for divergence runs. Auto-fill from A if enabled. */ + private boolean ensureBForDivergenceIfNeeded() { + if (cohortB != null && !cohortB.isEmpty()) return true; + if (autoFillBFromAWhenMissing) { + useCohortAForB(getCohortALabel() + " (copy)"); + toast("Cohort B was empty — auto-filled with a copy of A for testing.", false); + return true; + } else { + toast("Cohort B is empty. Set B or enable 'Auto-fill B from A' in Actions.", true); + return false; + } + } + + // --------------------------------------------------------------------- + // 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 { + System.out.println((isError ? "[Error] " : "[Info] ") + msg); + } + } + + private static String safeName(String s) { + String x = (s == null) ? "" : s.trim(); + return x.isEmpty() ? "PairwiseMatrix" : x; + } +} 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..3bfa81be --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java @@ -0,0 +1,179 @@ +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 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 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 { + System.out.println(prefixed); + } + } + + // --------------------------------------------------------------------- + // 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/PairwiseMatrixPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java new file mode 100644 index 00000000..7d055a5f --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java @@ -0,0 +1,119 @@ +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.components.MatrixHeatmapView.MatrixClick; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.utils.statistics.DensityCache; +import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixConfigPanel; +import java.util.List; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +/** + * 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); + } + }); + + // Optional: wire matrix cell clicks (i,j,value). For now, just toast. + view.setOnCellClick((MatrixClick click) -> { + if (click == null) return; + 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/utils/statistics/DivergenceComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java new file mode 100644 index 00000000..1c4ee3fc --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java @@ -0,0 +1,378 @@ +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.*; + +/** + * 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..2f6649ac --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java @@ -0,0 +1,455 @@ +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/PairwiseMatrixConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java new file mode 100644 index 00000000..88b1386e --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java @@ -0,0 +1,484 @@ +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 java.util.Objects; +import java.util.function.Consumer; +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; + +/** + * 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..197be628 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java @@ -0,0 +1,315 @@ +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/SimilarityComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java new file mode 100644 index 00000000..3933b694 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java @@ -0,0 +1,443 @@ +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.*; + +/** + * 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; + } +} From e6d33f3a7f187d2b4fadc909c7194a4ccfb07330 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 28 Sep 2025 08:46:12 -0400 Subject: [PATCH 35/50] Divergence Matrix upgrades with Synthetic and Labelwise cohort generators. --- src/main/java/edu/jhuapl/trinity/App.java | 4 + .../edu/jhuapl/trinity/AppAsyncManager.java | 18 + .../data/messages/xai/FeatureVector.java | 19 + .../javafx/components/MatrixHeatmapView.java | 82 +++-- .../javafx/components/PairwiseMatrixView.java | 344 +++++++++++++----- .../javafx/events/ApplicationEvent.java | 1 + 6 files changed, 333 insertions(+), 135 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index b3fe244e..f223fc55 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -351,6 +351,10 @@ private void keyReleased(Stage stage, KeyEvent e) { 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 68231f3c..a9e983d3 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -100,6 +100,7 @@ import static edu.jhuapl.trinity.App.theConfig; import edu.jhuapl.trinity.javafx.components.panes.HypersurfaceControlsPane; import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; +import edu.jhuapl.trinity.javafx.components.panes.PairwiseMatrixPane; import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; import edu.jhuapl.trinity.javafx.controllers.PairwiseJpdfPanePopoutController; @@ -131,6 +132,7 @@ public class AppAsyncManager extends Task { StatPdfCdfPane statPdfCdfPane; PairwiseJpdfPane pairwiseJpdfPane; PairwiseJpdfPanePopoutController pjpPop; + PairwiseMatrixPane pairwiseMatrixPane; FeatureVectorManagerPane featureVectorManagerPane; NavigatorPane navigatorPane; CocoViewerPane cocoViewerPane; @@ -630,6 +632,22 @@ protected Void call() throws Exception { } 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) { 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 7d4b7e31..8e6b2a2c 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 @@ -80,6 +80,25 @@ public FeatureVector() { entityId = UUID.randomUUID().toString(); 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 diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java index 3f7a93b1..7b6af5ad 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java @@ -36,9 +36,9 @@ public final class MatrixHeatmapView extends BorderPane { // -------------------- Types -------------------- -// --- Compatibility enums (safe defaults) --- -public enum ValueMode { RAW, ABS_VALUE } -public enum ScaleMode { AUTO, FIXED } + // Compatibility enums (simple placeholders for future options) + public enum ValueMode { RAW, ABS_VALUE } + public enum ScaleMode { AUTO, FIXED } /** Color palette styles. */ public enum PaletteKind { @@ -68,8 +68,10 @@ public MatrixClick(int row, int col, double value) { // 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(); + + // 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; @@ -86,6 +88,9 @@ public MatrixClick(int row, int col, double value) { private Double fixedMax = null; private Double divergingCenter = 0.0; // for DIVERGING only + // Column label compaction (e.g., "Comp 12" -> "12") + private boolean compactColumnLabels = false; + // Interaction private Consumer onCellClick = null; private final Tooltip hoverTip = new Tooltip(); @@ -256,6 +261,14 @@ public void setLabelFont(Font f) { } } + /** When true, column labels like "Comp 12" are rendered without the "Comp " prefix. */ + public void setCompactColumnLabels(boolean on) { + if (this.compactColumnLabels != on) { + this.compactColumnLabels = on; + layoutAndDraw(); + } + } + /** Set click handler for cell picks. */ public void setOnCellClick(Consumer handler) { this.onCellClick = handler; @@ -273,13 +286,8 @@ public double[][] getMatrixCopy() { // -------------------- Internals: sizing + render -------------------- - private int rows() { - return matrix.length; - } - - private int cols() { - return (rows() == 0) ? 0 : matrix[0].length; - } + 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()); @@ -401,9 +409,10 @@ private void drawAxisLabels(GraphicsContext g) { // Top (columns) for (int j = 0; j < c; j++) { - String lbl = (j < colLabels.length && colLabels[j] != null) ? colLabels[j] : ("c" + j); + String base = (j < colLabels.length && colLabels[j] != null) ? colLabels[j] : ("c" + j); + String lbl = compactColLabel(base, j); double x = contentX + (j + 0.5) * cellW; - double y = contentY - labelMargin - textHeight(g, lbl); + double y = contentY - labelMargin - textHeight(g); g.fillText(lbl, x, Math.max(0, y)); } @@ -553,33 +562,27 @@ private static double norm01(double v, double min, double max) { return t; } - // Sequential palette: grayscale → viridis-like simple blend + // Sequential palette: simple gradient (dark blue → teal → yellow) private static Color sequentialColor(double t) { - // Simple perceptual-ish gradient (dark blue → teal → yellow) - // t in [0,1] double r = clamp01(-0.5 + 2.2 * t); double g = clamp01(0.1 + 1.9 * t); double b = clamp01(1.0 - 0.9 * t); - // Subtle gamma double gamma = 0.95; return Color.color(Math.pow(r, gamma), Math.pow(g, gamma), Math.pow(b, gamma)); } // Diverging palette: cool (blue) below center, warm (red) above center; white at center private static Color divergingColor(double v, double min, double max, double center) { - // Map to [-1, 1] where 0 is 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) { - // 0..1: white → orange/red 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 { - // -1..0: blue → white double a = -t; double r = 1.0 - 0.9 * a; double g = 1.0 - 0.8 * a; @@ -597,8 +600,7 @@ private static double clamp01(double x) { private double estimateTopLabelHeight(GraphicsContext g, int cols) { if (cols == 0) return 0; g.setFont(labelFont); - // Use one sample to estimate height - return Math.ceil(textHeight(g, "X") + 2); + return Math.ceil(textHeight(g)); } private double estimateLeftLabelWidth(GraphicsContext g, int rows) { @@ -614,18 +616,28 @@ private double estimateLeftLabelWidth(GraphicsContext g, int rows) { } 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) { - // Use a representative sample for consistent line height - measureText.setText("Mg"); - measureText.setFont(g.getFont()); - return measureText.getLayoutBounds().getHeight(); -} + 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) { + measureText.setText("Mg"); // representative sample + measureText.setFont(g.getFont()); + return measureText.getLayoutBounds().getHeight(); + } + + /** If compactColumnLabels=true and label starts with "Comp", strip that prefix. */ + private String compactColLabel(String original, int colIndex) { + if (!compactColumnLabels) return original; + if (original == null || original.isBlank()) return Integer.toString(colIndex); + String s = original.trim(); + if (s.length() >= 4 && s.substring(0, 4).equalsIgnoreCase("comp")) { + s = s.substring(4).trim(); // drop "comp" + any space + } + return s.isEmpty() ? Integer.toString(colIndex) : s; + } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index e6048aa0..dbbaba98 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -18,12 +18,12 @@ import javafx.geometry.Orientation; import javafx.geometry.Pos; import javafx.scene.control.Button; -import javafx.scene.control.CheckMenuItem; 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; @@ -35,11 +35,14 @@ * Parent composite that hosts: * - Left: PairwiseMatrixConfigPanel (inputs/controls) * - Right: MatrixHeatmapView (rendered matrix) - * - Top bar: Run/Reset + Actions (+ quick A/B helpers) + * - Top bar: Run/Reset + Actions * * Runs PairwiseMatrixEngine for Similarity or Divergence based on the panel request * and updates the MatrixHeatmapView with results and initial display settings. * + * This view is suitable to be wrapped by a floating pane (e.g., PairwiseMatrixPane), + * similar to PairwiseJpdfView/Pane. + * * @author Sean Phillips */ public final class PairwiseMatrixView extends BorderPane { @@ -61,11 +64,7 @@ public final class PairwiseMatrixView extends BorderPane { private final Button cfgResetBtn; private final Button collapseBtn; - // Quick cohort helpers - private Button btnBEqualsA; - private Button btnSplitA; - - // --- Actions (extensible) + // --- Actions (synthetic cohorts, derive by label, etc.) private final MenuButton actionsBtn; // --- State (data + cache) @@ -75,9 +74,6 @@ public final class PairwiseMatrixView extends BorderPane { private String cohortBLabel = "B"; private final DensityCache cache; - // Divergence preflight option - private boolean autoFillBFromAWhenMissing = true; - // --- Hooks private Consumer toastHandler; private Consumer onCellClickHandler; @@ -86,6 +82,7 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); this.configPanel = (configPanel != null) ? configPanel : new PairwiseMatrixConfigPanel(); this.heatmap = new MatrixHeatmapView(); + this.heatmap.setCompactColumnLabels(true); // compact column labels by default // Hook matrix clicks to external handler if provided this.heatmap.setOnCellClick(click -> { @@ -100,17 +97,11 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca this.collapseBtn = new Button("Collapse Controls"); this.collapseBtn.setOnAction(e -> toggleControlsCollapsed()); - // Actions menu (+ divergence helpers) + // Actions menu (synthetic cohorts + helpers) this.actionsBtn = buildActionsMenu(); - // Quick A/B helper buttons - this.btnBEqualsA = new Button("B = A"); - this.btnBEqualsA.setOnAction(e -> useCohortAForB(null)); - this.btnSplitA = new Button("Split A→B"); - this.btnSplitA.setOnAction(e -> splitACohortIntoAandB()); - // Slim toolbar (left = buttons; right = progress text) - this.topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn, btnBEqualsA, btnSplitA); + this.topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn); this.topLeft.setAlignment(Pos.CENTER_LEFT); this.topLeft.setPadding(new Insets(6, 8, 6, 8)); @@ -182,34 +173,18 @@ public void setCohortB(List vectors, String label) { private void runWithRequest(Request req) { if (req == null) { toast("No request provided.", true); return; } - // Build minimal recipe from UI request + // Build a minimal JpdfRecipe from the request (bins & bounds & indices & scoring). JpdfRecipe recipe = buildRecipeFromRequest(req); - // Heatmap display settings 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 - ); - } - - // Preflight: ensure data availability - if (cohortA == null || cohortA.isEmpty()) { - toast("Cohort A is empty. Load or set vectors first.", true); - return; - } - if (req.mode == Mode.DIVERGENCE) { - if (!ensureBForDivergenceIfNeeded()) { - // ensureB already toasts a message - return; - } - } + 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…"); @@ -221,15 +196,37 @@ private void runWithRequest(Request req) { 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 + // 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 + cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), cache ); } @@ -243,6 +240,7 @@ private void runWithRequest(Request req) { heatmap.setMatrix(result.matrix); heatmap.setRowLabels(result.labels); heatmap.setColLabels(result.labels); + heatmap.setCompactColumnLabels(true); // ensure compact columns after labels set toast((result.title != null ? result.title + " — " : "") + "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); } @@ -262,59 +260,243 @@ private void runWithRequest(Request req) { private static T nonNull(T v, T def) { return (v != null ? v : def); } private JpdfRecipe buildRecipeFromRequest(Request req) { - // Engines use bins/bounds/policy/indices and (for similarity) scoreMetric. + // We keep this minimal—engines only use bins/bounds/policy/indices/scoreMetric. + // For similarity path we include the requested ScoreMetric; divergence ignores score metric. 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) + .componentPairsMode(true) // not used directly here, but harmless .componentIndexRange(req.componentStart, req.componentEnd) - .includeSelfPairs(req.includeDiagonal) + .includeSelfPairs(req.includeDiagonal) // used by similarity engine as hint .orderedPairs(req.orderedPairs) - .cacheEnabled(true) + .cacheEnabled(true) // let Divergence/Comparison leverage cache if passed .saveThumbnails(false) - .minAvgCountPerCell(3.0); + .minAvgCountPerCell(3.0); // sufficiency default used by PairScorer if (req.mode == Mode.SIMILARITY && req.similarityMetric != null) { b.scoreMetric(req.similarityMetric); } else { + // fallback (unused for divergence) b.scoreMetric(JpdfRecipe.ScoreMetric.PEARSON); } return b.build(); } // --------------------------------------------------------------------- - // Toolbar + Actions + // Actions menu (synthetic cohorts & helpers) // --------------------------------------------------------------------- private MenuButton buildActionsMenu() { MenuButton mb = new MenuButton("Actions"); - // Clear matrix + // Clear heatmap MenuItem clearItem = new MenuItem("Clear Matrix"); clearItem.setOnAction(e -> heatmap.setMatrix((double[][]) null)); - // Divergence helpers - CheckMenuItem autoFillBItem = new CheckMenuItem("Auto-fill B from A when missing"); - autoFillBItem.setSelected(autoFillBFromAWhenMissing); - autoFillBItem.selectedProperty().addListener((obs, ov, nv) -> autoFillBFromAWhenMissing = nv); + // 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 useAasBItem = new MenuItem("Set B = A (copy)"); - useAasBItem.setOnAction(e -> useCohortAForB(null)); + 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 splitAItem = new MenuItem("Split A into A/B"); - splitAItem.setOnAction(e -> splitACohortIntoAandB()); + 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()); mb.getItems().addAll( clearItem, new SeparatorMenuItem(), - autoFillBItem, - useAasBItem, - splitAItem + copyAToB, + splitA, + bRandomGaussian, + bRandomNoise, + bShiftOneComp, + new SeparatorMenuItem(), + cohortsByLabel ); return mb; } + // --------------------------------------------------------------------- + // Synthetic cohort helpers + // --------------------------------------------------------------------- + + 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); + } + // Adjust constructor if your FeatureVector differs + 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); + } + // Adjust constructor if your FeatureVector differs + 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; + } + + // Build B by copying A and adding delta on idx + 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); + } + // Adjust constructor if your FeatureVector differs + 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 { + // Adjust if your FeatureVector exposes labels differently. + String lbl = fv.getLabel(); // e.g., fv.getLabel() or fv.getName() or metadata map + return (lbl == null) ? "" : lbl.trim(); + } catch (Throwable t) { + return ""; + } + } + + // --------------------------------------------------------------------- + // Toolbar helpers + // --------------------------------------------------------------------- + private void toggleControlsCollapsed() { double pos = splitPane.getDividerPositions()[0]; if (pos > 0.02 || controlsBox.isVisible()) { @@ -332,44 +514,6 @@ private void toggleControlsCollapsed() { } } - // --------------------------------------------------------------------- - // Cohort helpers + divergence preflight - // --------------------------------------------------------------------- - - /** Copy Cohort A into Cohort B (A vs A sanity check). */ - private void useCohortAForB(String label) { - List a = getCohortA(); - if (a == null || a.isEmpty()) return; - String lab = (label != null && !label.isBlank()) ? label : (getCohortALabel() + " (copy)"); - setCohortB(new ArrayList<>(a), lab); - } - - /** Split Cohort A into two halves: first half -> A, second half -> B. */ - private void splitACohortIntoAandB() { - List a = getCohortA(); - if (a == null || a.size() < 2) return; - int mid = a.size() / 2; - setCohortB(new ArrayList<>(a.subList(mid, a.size())), getCohortALabel() + " (late)"); - setCohortA(new ArrayList<>(a.subList(0, mid)), getCohortALabel() + " (early)"); - } - - /** Ensure Cohort B exists for divergence runs. Auto-fill from A if enabled. */ - private boolean ensureBForDivergenceIfNeeded() { - if (cohortB != null && !cohortB.isEmpty()) return true; - if (autoFillBFromAWhenMissing) { - useCohortAForB(getCohortALabel() + " (copy)"); - toast("Cohort B was empty — auto-filled with a copy of A for testing.", false); - return true; - } else { - toast("Cohort B is empty. Set B or enable 'Auto-fill B from A' in Actions.", true); - return false; - } - } - - // --------------------------------------------------------------------- - // Misc helpers - // --------------------------------------------------------------------- - private void setControlsDisabled(boolean disabled) { topBar.setDisable(disabled); configPanel.setDisable(disabled); 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 6a7194b9..7b1baef5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -50,6 +50,7 @@ public class ApplicationEvent extends Event { 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); From bc9ec199b7b3a52c26b175606c3dd5bac8acf72f Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 29 Sep 2025 11:09:46 -0400 Subject: [PATCH 36/50] Updates to fix UI usability with collapsible controls and labeling --- .../javafx/components/MatrixHeatmapView.java | 193 ++++------ .../javafx/components/PairwiseMatrixView.java | 330 +++++------------- .../jhuapl/trinity/utils/MatrixViewUtil.java | 96 +++++ 3 files changed, 267 insertions(+), 352 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java index 7b6af5ad..57875fc9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.javafx.components; +import edu.jhuapl.trinity.javafx.util.MatrixViewUtil; import java.text.DecimalFormat; import java.util.List; import java.util.function.Consumer; @@ -24,7 +25,7 @@ * - Renders on Canvas (fast, lightweight). * - Sequential and diverging palettes. * - Auto or fixed range mapping. - * - Optional color legend. + * - Optional color legend (with optional title). * - Axis labels (top / left). * - Hover tooltips with (row, col, value). * - Click callback with cell picking. @@ -36,7 +37,7 @@ public final class MatrixHeatmapView extends BorderPane { // -------------------- Types -------------------- - // Compatibility enums (simple placeholders for future options) + public enum ValueMode { RAW, ABS_VALUE } public enum ScaleMode { AUTO, FIXED } @@ -80,6 +81,7 @@ public MatrixClick(int row, int col, double value) { 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; @@ -88,9 +90,6 @@ public MatrixClick(int row, int col, double value) { private Double fixedMax = null; private Double divergingCenter = 0.0; // for DIVERGING only - // Column label compaction (e.g., "Comp 12" -> "12") - private boolean compactColumnLabels = false; - // Interaction private Consumer onCellClick = null; private final Tooltip hoverTip = new Tooltip(); @@ -102,6 +101,9 @@ public MatrixClick(int row, int col, double value) { private double contentW; private double contentH; + // Label behavior + private boolean compactColumnLabels = true; // default: strip "Comp " → just the index for columns + // -------------------- Construction -------------------- public MatrixHeatmapView() { @@ -193,7 +195,7 @@ public void setRowLabels(List labels) { layoutAndDraw(); } - /** Optional column labels (length must equal cols). */ + /** 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()]; @@ -201,11 +203,34 @@ public void setColLabels(List labels) { if (labels.size() != cols()) { throw new IllegalArgumentException("Column labels length must match matrix cols."); } - this.colLabels = labels.toArray(new String[0]); + 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())) { @@ -217,6 +242,12 @@ public void setDefaultAxisLabels(String rowPrefix, String 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; @@ -261,14 +292,6 @@ public void setLabelFont(Font f) { } } - /** When true, column labels like "Comp 12" are rendered without the "Comp " prefix. */ - public void setCompactColumnLabels(boolean on) { - if (this.compactColumnLabels != on) { - this.compactColumnLabels = on; - layoutAndDraw(); - } - } - /** Set click handler for cell picks. */ public void setOnCellClick(Consumer handler) { this.onCellClick = handler; @@ -314,11 +337,8 @@ private void draw() { } // Compute layout boxes: - // [top labels] double topLabelH = estimateTopLabelHeight(g, c); - // [left labels] double leftLabelW = estimateLeftLabelWidth(g, r); - // [legend] double legendW = showLegend ? (legendWidth + legendGap) : 0.0; // Content box (matrix area) @@ -330,7 +350,7 @@ private void draw() { // Value mapping range double vmin, vmax; if (autoRange) { - double[] mm = minMax(matrix); + double[] mm = MatrixViewUtil.minMax(matrix); vmin = mm[0]; vmax = mm[1]; if (!(vmax > vmin)) vmax = vmin + 1e-9; @@ -360,18 +380,18 @@ private void drawMatrix(GraphicsContext g, double vmin, double vmax) { double cellW = contentW / c; double cellH = contentH / r; - // Disable pixel smoothing for crisp cell edges 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 = matrix[i][j]; + double v = MatrixViewUtil.sanitize(matrix[i][j]); Color col = switch (paletteKind) { - case SEQUENTIAL -> sequentialColor(norm01(v, vmin, vmax)); - case DIVERGING -> divergingColor(v, vmin, vmax, divergingCenter != null ? divergingCenter.doubleValue() : 0.0); + 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); @@ -379,15 +399,13 @@ private void drawMatrix(GraphicsContext g, double vmin, double vmax) { } } - // Optional thin grid lines (subtle) + // Subtle grid lines g.setStroke(Color.color(0, 0, 0, 0.15)); g.setLineWidth(1.0); - // Vertical for (int j = 0; j <= c; j++) { double x = contentX + j * cellW + 0.5; g.strokeLine(x, contentY, x, contentY + contentH); } - // Horizontal for (int i = 0; i <= r; i++) { double y = contentY + i * cellH + 0.5; g.strokeLine(contentX, y, contentX + contentW, y); @@ -409,10 +427,9 @@ private void drawAxisLabels(GraphicsContext g) { // Top (columns) for (int j = 0; j < c; j++) { - String base = (j < colLabels.length && colLabels[j] != null) ? colLabels[j] : ("c" + j); - String lbl = compactColLabel(base, 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); + double y = contentY - labelMargin - textHeight(g, lbl); g.fillText(lbl, x, Math.max(0, y)); } @@ -433,14 +450,28 @@ private void drawLegend(GraphicsContext g, double vmin, double vmax) { double w = legendWidth; double h = contentH; - // Vertical gradient + // 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 -> sequentialColor(norm01(v, vmin, vmax)); - case DIVERGING -> divergingColor(v, vmin, vmax, divergingCenter != null ? divergingCenter.doubleValue() : 0.0); + 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); @@ -452,23 +483,22 @@ private void drawLegend(GraphicsContext g, double vmin, double vmax) { g.setLineWidth(1.0); g.strokeRect(x0 + 0.5, y0 + 0.5, w - 1, h - 1); - // Ticks and labels (min, center (diverging), max) + // Ticks & labels inside the bar + double pad = 2.0; g.setFill(Color.LIGHTGRAY); g.setFont(labelFont); g.setTextAlign(TextAlignment.LEFT); g.setTextBaseline(VPos.CENTER); - double tickX = x0 + w + 4; - // max (top) - g.fillText(df.format(vmax), tickX, y0); - // min (bottom) - g.fillText(df.format(vmin), tickX, y0 + h); + 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); - // center (if diverging) if (paletteKind == PaletteKind.DIVERGING) { - double t = norm01(divergingCenter != null ? divergingCenter.doubleValue() : 0.0, vmin, vmax); + 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), tickX, y); + g.fillText(df.format( + divergingCenter != null ? divergingCenter.doubleValue() : 0.0), x0 + pad, y); } } @@ -492,7 +522,7 @@ private void onMouseMove(MouseEvent e) { return; } int i = rc[0], j = rc[1]; - double v = matrix[i][j]; + 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); @@ -511,7 +541,7 @@ private void onMouseClick(MouseEvent e) { int[] rc = pickCell(e.getX(), e.getY()); if (rc == null) return; int i = rc[0], j = rc[1]; - onCellClick.accept(new MatrixClick(i, j, matrix[i][j])); + onCellClick.accept(new MatrixClick(i, j, MatrixViewUtil.sanitize(matrix[i][j]))); } /** Return {row, col} or null if mouse is outside content box. */ @@ -539,75 +569,17 @@ private static String[] defaultLabels(int n, String prefix) { return out; } - 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.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}; - } - - private 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; - } - - // Sequential palette: simple gradient (dark blue → teal → yellow) - private static Color sequentialColor(double t) { - 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)); - } - - // Diverging palette: cool (blue) below center, warm (red) above center; white at center - private static Color divergingColor(double v, double min, double max, double 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; - } - private double estimateTopLabelHeight(GraphicsContext g, int cols) { if (cols == 0) return 0; g.setFont(labelFont); - return Math.ceil(textHeight(g)); + 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); // cap measurement work + 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); @@ -624,20 +596,9 @@ private double textWidth(GraphicsContext g, String s) { return measureText.getLayoutBounds().getWidth(); } - private double textHeight(GraphicsContext g) { - measureText.setText("Mg"); // representative sample + private double textHeight(GraphicsContext g, String s) { + measureText.setText((s == null || s.isEmpty()) ? "Mg" : s); measureText.setFont(g.getFont()); return measureText.getLayoutBounds().getHeight(); } - - /** If compactColumnLabels=true and label starts with "Comp", strip that prefix. */ - private String compactColLabel(String original, int colIndex) { - if (!compactColumnLabels) return original; - if (original == null || original.isBlank()) return Integer.toString(colIndex); - String s = original.trim(); - if (s.length() >= 4 && s.substring(0, 4).equalsIgnoreCase("comp")) { - s = s.substring(4).trim(); // drop "comp" + any space - } - return s.isEmpty() ? Integer.toString(colIndex) : s; - } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index dbbaba98..6b55725c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -23,10 +23,10 @@ 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; /** @@ -40,8 +40,7 @@ * Runs PairwiseMatrixEngine for Similarity or Divergence based on the panel request * and updates the MatrixHeatmapView with results and initial display settings. * - * This view is suitable to be wrapped by a floating pane (e.g., PairwiseMatrixPane), - * similar to PairwiseJpdfView/Pane. + * Designed to be wrapped by a floating pane (e.g., PairwiseMatrixPane), similar to PairwiseJpdfView/Pane. * * @author Sean Phillips */ @@ -57,14 +56,18 @@ public final class PairwiseMatrixView extends BorderPane { 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 (synthetic cohorts, derive by label, etc.) + // --- Actions (for future extensibility) private final MenuButton actionsBtn; // --- State (data + cache) @@ -82,7 +85,9 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); this.configPanel = (configPanel != null) ? configPanel : new PairwiseMatrixConfigPanel(); this.heatmap = new MatrixHeatmapView(); - this.heatmap.setCompactColumnLabels(true); // compact column labels by default + + // Compact column labels: show indices only (no "Comp" prefix) + this.heatmap.setCompactColumnLabels(true); // Hook matrix clicks to external handler if provided this.heatmap.setOnCellClick(click -> { @@ -94,41 +99,69 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca this.cfgResetBtn = this.configPanel.getResetButton(); // Toolbar collapse toggle - this.collapseBtn = new Button("Collapse Controls"); - this.collapseBtn.setOnAction(e -> toggleControlsCollapsed()); + collapseBtn = new Button("Collapse Controls"); + collapseBtn.setOnAction(e -> toggleControlsCollapsed()); - // Actions menu (synthetic cohorts + helpers) - this.actionsBtn = buildActionsMenu(); + // Actions menu (placeholder for future save/load/export) + actionsBtn = buildActionsMenu(); // Slim toolbar (left = buttons; right = progress text) - this.topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn); - this.topLeft.setAlignment(Pos.CENTER_LEFT); - this.topLeft.setPadding(new Insets(6, 8, 6, 8)); + topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn); + topLeft.setAlignment(Pos.CENTER_LEFT); + topLeft.setPadding(new Insets(6, 8, 6, 8)); - this.progressLabel = new Label(""); + progressLabel = new Label(""); BorderPane.setAlignment(progressLabel, Pos.CENTER_RIGHT); BorderPane.setMargin(progressLabel, new Insets(6, 8, 6, 8)); - this.topBar = new BorderPane(); - this.topBar.setLeft(topLeft); - this.topBar.setRight(progressLabel); + topBar = new BorderPane(); + topBar.setLeft(topLeft); + topBar.setRight(progressLabel); setTop(topBar); // Left controls box - this.controlsBox = new VBox(); - this.controlsBox.getChildren().addAll(this.configPanel); + controlsBox = new VBox(); + controlsBox.getChildren().addAll(this.configPanel); VBox.setMargin(this.configPanel, new Insets(6, 8, 6, 8)); - this.controlsBox.setMinWidth(0); - this.controlsBox.setPrefWidth(390); + + // 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 - this.splitPane = new SplitPane(controlsBox, heatmap); - this.splitPane.setOrientation(Orientation.HORIZONTAL); - this.splitPane.setDividerPositions(lastDividerPos); - this.splitPane.setBackground(Background.EMPTY); + 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) { + // Keep a sensible floor for the left panel width + expandedControlsPrefWidth = Math.max(220, Math.round(p * total)); + controlsBox.setPrefWidth(expandedControlsPrefWidth); + } + } + } + }); + } + // Wiring this.configPanel.setOnRun(this::runWithRequest); } @@ -176,6 +209,7 @@ private void runWithRequest(Request req) { // Build a minimal JpdfRecipe from the request (bins & bounds & indices & scoring). 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(); @@ -240,7 +274,6 @@ cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), ca heatmap.setMatrix(result.matrix); heatmap.setRowLabels(result.labels); heatmap.setColLabels(result.labels); - heatmap.setCompactColumnLabels(true); // ensure compact columns after labels set toast((result.title != null ? result.title + " — " : "") + "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); } @@ -260,260 +293,85 @@ cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), ca private static T nonNull(T v, T def) { return (v != null ? v : def); } private JpdfRecipe buildRecipeFromRequest(Request req) { - // We keep this minimal—engines only use bins/bounds/policy/indices/scoreMetric. - // For similarity path we include the requested ScoreMetric; divergence ignores score metric. + // Engines only use bins/bounds/policy/indices/scoreMetric. 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) // not used directly here, but harmless + .componentPairsMode(true) .componentIndexRange(req.componentStart, req.componentEnd) - .includeSelfPairs(req.includeDiagonal) // used by similarity engine as hint + .includeSelfPairs(req.includeDiagonal) .orderedPairs(req.orderedPairs) - .cacheEnabled(true) // let Divergence/Comparison leverage cache if passed + .cacheEnabled(true) .saveThumbnails(false) - .minAvgCountPerCell(3.0); // sufficiency default used by PairScorer + .minAvgCountPerCell(3.0); if (req.mode == Mode.SIMILARITY && req.similarityMetric != null) { b.scoreMetric(req.similarityMetric); } else { - // fallback (unused for divergence) + // fallback (unused for divergence path) b.scoreMetric(JpdfRecipe.ScoreMetric.PEARSON); } return b.build(); } // --------------------------------------------------------------------- - // Actions menu (synthetic cohorts & helpers) + // Toolbar helpers // --------------------------------------------------------------------- private MenuButton buildActionsMenu() { MenuButton mb = new MenuButton("Actions"); - // Clear heatmap + // Simple "Clear" action for the heatmap MenuItem clearItem = new MenuItem("Clear Matrix"); clearItem.setOnAction(e -> heatmap.setMatrix((double[][]) null)); - // 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); - }); + // Reserved hooks for future: Save/Load config, export image, etc. + MenuItem sep = new SeparatorMenuItem(); - 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()); - - mb.getItems().addAll( - clearItem, - new SeparatorMenuItem(), - copyAToB, - splitA, - bRandomGaussian, - bRandomNoise, - bShiftOneComp, - new SeparatorMenuItem(), - cohortsByLabel - ); + mb.getItems().addAll(clearItem, sep); return mb; } // --------------------------------------------------------------------- - // Synthetic cohort helpers + // Collapse/Expand (no node swapping; width-based) // --------------------------------------------------------------------- - 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); - } - // Adjust constructor if your FeatureVector differs - 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); - } - // Adjust constructor if your FeatureVector differs - 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; - } + private void toggleControlsCollapsed() { + if (!controlsCollapsed) { + // Save current divider position if sensible + double pos = splitPane.getDividerPositions()[0]; + if (pos > 0.02 && pos < 0.98) lastDividerPos = pos; - // Build B by copying A and adding delta on idx - 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); - } - // Adjust constructor if your FeatureVector differs - b.add(new FeatureVector(row)); - } - setCohortB(b, cohortALabel + " (shifted comp " + idx + " by " + delta + ")"); - toast("Cohort B = A with comp " + idx + " shifted by " + delta, false); - } + // Collapse by width (keep node in SplitPane) + controlsCollapsed = true; + collapseBtn.setText("Expand Controls"); - 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); - } + controlsBox.setPrefWidth(0); + controlsBox.setMaxWidth(0); - if (aList.isEmpty() || bList.isEmpty()) { - toast("No matches for one or both labels. A=" + aList.size() + ", B=" + bList.size(), true); - return; - } + splitPane.setDividerPositions(0.0); + splitPane.requestLayout(); + } else { + // Expand: restore widths and divider + controlsCollapsed = false; + collapseBtn.setText("Collapse Controls"); - setCohortA(new ArrayList<>(aList), la); - setCohortB(new ArrayList<>(bList), lb); - toast("Derived cohorts by label: A=" + la + " (" + aList.size() + "), B=" + lb + " (" + bList.size() + ")", false); - } + controlsBox.setMaxWidth(Region.USE_COMPUTED_SIZE); + controlsBox.setPrefWidth(expandedControlsPrefWidth); - private static String safeVectorLabel(FeatureVector fv) { - try { - // Adjust if your FeatureVector exposes labels differently. - String lbl = fv.getLabel(); // e.g., fv.getLabel() or fv.getName() or metadata map - return (lbl == null) ? "" : lbl.trim(); - } catch (Throwable t) { - return ""; + final double target = (lastDividerPos > 0.02 && lastDividerPos < 0.98) ? lastDividerPos : 0.36; + Platform.runLater(() -> { + splitPane.setDividerPositions(target); + splitPane.requestLayout(); + }); } } // --------------------------------------------------------------------- - // Toolbar helpers + // Misc 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 void setControlsDisabled(boolean disabled) { topBar.setDisable(disabled); configPanel.setDisable(disabled); 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..404dd092 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java @@ -0,0 +1,96 @@ +package edu.jhuapl.trinity.javafx.util; + +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; + } +} From d8576402bbd735f1a6cca542e10755626e2dfdc9 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 29 Sep 2025 16:06:26 -0400 Subject: [PATCH 37/50] First cut 3D graph derived from Similarity and Divergence Matrices. --- .../javafx/components/MatrixHeatmapView.java | 5 + .../javafx/components/PairwiseMatrixView.java | 255 +++++++++++++- .../javafx/javafx3d/Hypersurface3DPane.java | 26 +- .../tasks/BuildGraphFromMatrixTask.java | 141 ++++++++ .../javafx/renderers/Graph3DRenderer.java | 80 +++++ .../trinity/utils/graph/ForceFrLayout3D.java | 136 ++++++++ .../utils/graph/GraphLayoutEngine.java | 27 ++ .../utils/graph/GraphLayoutParams.java | 63 ++++ .../utils/graph/MatrixToGraphAdapter.java | 313 ++++++++++++++++++ .../utils/graph/SuperMdsEmbedding3D.java | 46 +++ 10 files changed, 1081 insertions(+), 11 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutEngine.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/graph/SuperMdsEmbedding3D.java diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java index 57875fc9..3d21c3bb 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java @@ -2,6 +2,8 @@ import edu.jhuapl.trinity.javafx.util.MatrixViewUtil; import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.function.Consumer; import javafx.beans.InvalidationListener; @@ -182,6 +184,9 @@ public void setMatrix(List> values) { 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()) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index 6b55725c..0eabb502 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -1,7 +1,11 @@ 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.javafx3d.tasks.BuildGraphFromMatrixTask; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; +import edu.jhuapl.trinity.utils.graph.MatrixToGraphAdapter; import edu.jhuapl.trinity.utils.statistics.DensityCache; import edu.jhuapl.trinity.utils.statistics.DivergenceComputer.DivergenceMetric; import edu.jhuapl.trinity.utils.statistics.JpdfRecipe; @@ -23,6 +27,7 @@ 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; @@ -80,6 +85,7 @@ public final class PairwiseMatrixView extends BorderPane { // --- Hooks private Consumer toastHandler; private Consumer onCellClickHandler; + public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache cache) { this.cache = (cache != null) ? cache : new DensityCache.Builder().maxEntries(128).ttlMillis(0).build(); @@ -318,21 +324,250 @@ private JpdfRecipe buildRecipeFromRequest(Request req) { // --------------------------------------------------------------------- // Toolbar helpers // --------------------------------------------------------------------- +private MenuButton buildActionsMenu() { + MenuButton mb = new MenuButton("Actions"); - private MenuButton buildActionsMenu() { - MenuButton mb = new MenuButton("Actions"); + MenuItem clearItem = new MenuItem("Clear Matrix"); + clearItem.setOnAction(e -> heatmap.setMatrix((double[][]) null)); - // Simple "Clear" action for the heatmap - 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)); - // Reserved hooks for future: Save/Load config, export image, etc. - MenuItem sep = new SeparatorMenuItem(); + MenuItem buildGraphFromDiv = new MenuItem("Build Graph from Divergence"); + buildGraphFromDiv.setOnAction(e -> triggerGraphBuild(MatrixToGraphAdapter.MatrixKind.DIVERGENCE)); - mb.getItems().addAll(clearItem, sep); - return mb; + // 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()); + + mb.getItems().addAll(buildGraphFromSim, buildGraphFromDiv, + new SeparatorMenuItem(), copyAToB, splitA, bShiftOneComp, cohortsByLabel, + new SeparatorMenuItem(), bRandomGaussian, bRandomNoise, + new SeparatorMenuItem(), clearItem); + + return mb; +} +private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { + 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 how to interpret the matrix and how to lay it out + MatrixToGraphAdapter.WeightMode weightMode = + MatrixToGraphAdapter.WeightMode.DIRECT; // use your preferred default + + GraphLayoutParams layout = new GraphLayoutParams(); + layout.kind = GraphLayoutParams.LayoutKind.MDS_3D; // or FORCE_FR, CIRCLE_XZ, etc. + // (If your GraphLayoutParams uses setters/builder, adjust these two lines accordingly.) + + SuperMDS.Params mdsParams = new SuperMDS.Params(); + mdsParams.outputDim = 3; // ensure 3D + + setControlsDisabled(true); + setProgressText("Building graph…"); + + BuildGraphFromMatrixTask task = new BuildGraphFromMatrixTask( + scene, M, labels, kind, weightMode, 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(); +} + // --------------------------------------------------------------------- + // Synthetic cohort helpers + // --------------------------------------------------------------------- + + 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); + } + // Adjust constructor if your FeatureVector differs + 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); + } + // Adjust constructor if your FeatureVector differs + 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; + } + + // Build B by copying A and adding delta on idx + 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); + } + // Adjust constructor if your FeatureVector differs + 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 { + // Adjust if your FeatureVector exposes labels differently. + String lbl = fv.getLabel(); // e.g., fv.getLabel() or fv.getName() or metadata map + return (lbl == null) ? "" : lbl.trim(); + } catch (Throwable t) { + return ""; + } + } // --------------------------------------------------------------------- // Collapse/Expand (no node swapping; width-based) // --------------------------------------------------------------------- @@ -388,6 +623,8 @@ private void toast(String msg, boolean isError) { System.out.println((isError ? "[Error] " : "[Info] ") + 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(); 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 225b8b58..9194990e 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -3,6 +3,7 @@ import edu.jhuapl.trinity.App; 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.messages.bci.SemanticMap; import edu.jhuapl.trinity.data.messages.bci.SemanticMapCollection; import edu.jhuapl.trinity.data.messages.bci.SemanticReconstruction; @@ -18,6 +19,7 @@ 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; @@ -34,6 +36,7 @@ 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; @@ -253,7 +256,14 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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 GraphDirectedCollection currentGraph = null; + private Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() + .withNodeRadius(20.0) + .withEdgeWidth(8.0f) + .withPositionScalar(1.0); + public Hypersurface3DPane(Scene scene) { this.scene = scene; shape3DToCalloutMap = new HashMap<>(); @@ -317,7 +327,8 @@ public Hypersurface3DPane(Scene scene) { labelGroup.setVisible(false); 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); subScene.setCamera(camera); pointLight = new PointLight(Color.WHITE); cameraTransform.getChildren().add(pointLight); @@ -572,6 +583,17 @@ public Hypersurface3DPane(Scene scene) { e.consume(); } }); + + 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)); + 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 -> { 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..b72be140 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java @@ -0,0 +1,141 @@ +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.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 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)); + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_TEXT_CONSOLE, msg, true)); + }); + } + + private void postConsole(String msg) { + Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_TEXT_CONSOLE, msg, true)) + ); + } + + 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/renderers/Graph3DRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java new file mode 100644 index 00000000..3aa4cfc6 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java @@ -0,0 +1,80 @@ +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.javafx3d.animated.AnimatedSphere; +import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; +import edu.jhuapl.trinity.utils.JavaFX3DUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import javafx.scene.Group; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import org.fxyz3d.geometry.Point3D; + +/** + * 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); + 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); + edges.add(t); + } + + root.getChildren().addAll(nodes); + root.getChildren().addAll(edges); + return root; + } +} 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..f9896843 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java @@ -0,0 +1,136 @@ +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..f9486d5b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java @@ -0,0 +1,63 @@ +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). + * + * @author Sean Phillips + */ +public final class GraphLayoutParams implements Serializable { + @Serial private static final long serialVersionUID = 1L; + + 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 --- + /** Keep at most this many heaviest edges (per-node cap applied after global sort). */ + 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; + + 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 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/MatrixToGraphAdapter.java b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java new file mode 100644 index 00000000..d02a6111 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java @@ -0,0 +1,313 @@ +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.List; + +/** + * MatrixToGraphAdapter + * -------------------- + * Converts a similarity or divergence matrix into a GraphDirectedCollection, + * and computes node positions using a chosen layout (Static, MDS-3D, Force-FR). + * + * - STATIC layouts (CIRCLE_XZ, CIRCLE_XY, SPHERE) ignore the matrix for positions. + * - MDS_3D: uses a provided embedding function (plug your existing MDS). + * - FORCE_FR: uses simple 3D Fruchterman–Reingold with edge weights + * derived from the matrix (see WeightMode). + * + * @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 (sparsify by global top-K with per-node degree cap). + List edgeList = selectEdges(weights, layoutParams.maxEdges, layoutParams.maxDegreePerNode, layoutParams.minEdgeWeight); + + // 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); + // Normalize/scale to radius + 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; + } + + /** Pick a sparse edge set: global top-K by weight with per-node degree cap. */ + private static List selectEdges(double[][] weights, int maxEdges, int maxDeg, double minW) { + final int n = weights.length; + List all = new ArrayList<>(); + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + double w = weights[i][j]; + if (w > minW) all.add(new EdgeRec(i, j, w)); + } + } + all.sort(Comparator.comparingDouble((EdgeRec e) -> e.w).reversed()); + + int[] deg = new int[n]; + List out = new ArrayList<>(); + for (EdgeRec e : all) { + 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) {} + + // ---------- 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)(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; + 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) { + // Normalize RMS radius then scale to target radius (keeps aspect, avoids huge spread) + 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..3b876361 --- /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; + } +} From de339f63864e34a7ba263217ee52fae4182524a7 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 30 Sep 2025 08:31:52 -0400 Subject: [PATCH 38/50] fixes to have JPDF from matrix cell clicks --- .../javafx/components/PairwiseMatrixView.java | 470 +++++++++++++----- .../components/panes/PairwiseMatrixPane.java | 8 +- 2 files changed, 351 insertions(+), 127 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index 0eabb502..bd2ed737 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -3,20 +3,28 @@ 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.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 java.util.ArrayList; import java.util.List; import java.util.function.Consumer; + import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Orientation; @@ -37,17 +45,11 @@ /** * PairwiseMatrixView * ------------------ - * Parent composite that hosts: - * - Left: PairwiseMatrixConfigPanel (inputs/controls) - * - Right: MatrixHeatmapView (rendered matrix) - * - Top bar: Run/Reset + Actions - * - * Runs PairwiseMatrixEngine for Similarity or Divergence based on the panel request - * and updates the MatrixHeatmapView with results and initial display settings. + * 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. * - * Designed to be wrapped by a floating pane (e.g., PairwiseMatrixPane), similar to PairwiseJpdfView/Pane. - * - * @author Sean Phillips + * This version reuses your existing Jpdf pipeline (JpdfBatchEngine/JpdfRecipe). */ public final class PairwiseMatrixView extends BorderPane { @@ -85,18 +87,40 @@ public final class PairwiseMatrixView extends BorderPane { // --- 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 (no "Comp" prefix) + // Compact column labels: show indices only this.heatmap.setCompactColumnLabels(true); - // Hook matrix clicks to external handler if provided + // Cell click → render JPDF (Similarity) or ΔPDF (Divergence) via existing Jpdf pipeline 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 if (cohortA == null || cohortA.isEmpty()) { + toast("Cohort A is empty. Load or set vectors first.", true); + } else { + if (effective.mode == Mode.SIMILARITY) { + renderPdfForCellUsingEngine(click.row, click.col, effective); + } else { + 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); }); @@ -159,7 +183,6 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca lastDividerPos = p; double total = splitPane.getWidth() <= 0 ? getWidth() : splitPane.getWidth(); if (total > 0) { - // Keep a sensible floor for the left panel width expandedControlsPrefWidth = Math.max(220, Math.round(p * total)); controlsBox.setPrefWidth(expandedControlsPrefWidth); } @@ -172,9 +195,7 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca this.configPanel.setOnRun(this::runWithRequest); } - public PairwiseMatrixView() { - this(null, null); - } + public PairwiseMatrixView() { this(null, null); } // --------------------------------------------------------------------- // Public API @@ -211,8 +232,9 @@ public void setCohortB(List vectors, String label) { private void runWithRequest(Request req) { if (req == null) { toast("No request provided.", true); return; } + this.lastRequest = req; // cache config used for "Run" - // Build a minimal JpdfRecipe from the request (bins & bounds & indices & scoring). + // 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 @@ -229,7 +251,6 @@ private void runWithRequest(Request req) { setControlsDisabled(true); setProgressText("Running…"); - // Run off FX thread (computations can be non-trivial) new Thread(() -> { long start = System.currentTimeMillis(); try { @@ -281,7 +302,7 @@ cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), ca heatmap.setRowLabels(result.labels); heatmap.setColLabels(result.labels); toast((result.title != null ? result.title + " — " : "") + - "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); + "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); } setProgressText(""); setControlsDisabled(false); @@ -296,45 +317,116 @@ cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), ca }, "PairwiseMatrixView-Worker").start(); } - private static T nonNull(T v, T def) { return (v != null ? v : def); } + public PairwiseMatrixConfigPanel.Request getLastRequestOrBuild() { + if (lastRequest != null) return lastRequest; + return (configPanel != null ? configPanel.snapshotRequest() : null); + } - private JpdfRecipe buildRecipeFromRequest(Request req) { - // Engines only use bins/bounds/policy/indices/scoreMetric. - 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); + // --------------------------------------------------------------------- + // Cell-click → JPDF surface (Similarity) + // --------------------------------------------------------------------- - if (req.mode == Mode.SIMILARITY && req.similarityMetric != null) { - b.scoreMetric(req.similarityMetric); - } else { - // fallback (unused for divergence path) - b.scoreMetric(JpdfRecipe.ScoreMetric.PEARSON); - } - return b.build(); + public void renderPdfForCellUsingEngine(int i, int j, Request req) { + if (req == null) { toast("No configuration available to build JPDF.", true); return; } + 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); } // --------------------------------------------------------------------- - // Toolbar helpers + // 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; } + 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)); + 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 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)); + 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)"); @@ -377,55 +469,54 @@ private MenuButton buildActionsMenu() { // Label-derived cohorts MenuItem cohortsByLabel = new MenuItem("Derive A/B by vector label…"); cohortsByLabel.setOnAction(e -> promptDeriveCohortsByLabel()); - - mb.getItems().addAll(buildGraphFromSim, buildGraphFromDiv, - new SeparatorMenuItem(), copyAToB, splitA, bShiftOneComp, cohortsByLabel, - new SeparatorMenuItem(), bRandomGaussian, bRandomNoise, - new SeparatorMenuItem(), clearItem); - - return mb; -} -private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { - 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 how to interpret the matrix and how to lay it out - MatrixToGraphAdapter.WeightMode weightMode = - MatrixToGraphAdapter.WeightMode.DIRECT; // use your preferred default - - GraphLayoutParams layout = new GraphLayoutParams(); - layout.kind = GraphLayoutParams.LayoutKind.MDS_3D; // or FORCE_FR, CIRCLE_XZ, etc. - // (If your GraphLayoutParams uses setters/builder, adjust these two lines accordingly.) - - SuperMDS.Params mdsParams = new SuperMDS.Params(); - mdsParams.outputDim = 3; // ensure 3D - - setControlsDisabled(true); - setProgressText("Building graph…"); - - BuildGraphFromMatrixTask task = new BuildGraphFromMatrixTask( - scene, M, labels, kind, weightMode, 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(); -} + + mb.getItems().addAll(buildGraphFromSim, buildGraphFromDiv, + new SeparatorMenuItem(), copyAToB, splitA, bShiftOneComp, cohortsByLabel, + new SeparatorMenuItem(), bRandomGaussian, bRandomNoise, + new SeparatorMenuItem(), clearItem); + + return mb; + } + + private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { + 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; } + + MatrixToGraphAdapter.WeightMode weightMode = MatrixToGraphAdapter.WeightMode.DIRECT; + + GraphLayoutParams layout = new GraphLayoutParams(); + layout.kind = GraphLayoutParams.LayoutKind.MDS_3D; // or FORCE_FR, etc. + + SuperMDS.Params mdsParams = new SuperMDS.Params(); + mdsParams.outputDim = 3; // ensure 3D + + setControlsDisabled(true); + setProgressText("Building graph…"); + + BuildGraphFromMatrixTask task = new BuildGraphFromMatrixTask( + scene, M, labels, kind, weightMode, 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(); + } + // --------------------------------------------------------------------- // Synthetic cohort helpers // --------------------------------------------------------------------- @@ -441,7 +532,6 @@ private List synthGaussianLikeA(List likeA, double double z = rng.nextGaussian() * stddev + mean; row.add(z); } - // Adjust constructor if your FeatureVector differs out.add(new FeatureVector(row)); } return out; @@ -459,7 +549,6 @@ private List synthUniformLikeA(List likeA, double double v = min + rng.nextDouble() * span; row.add(v); } - // Adjust constructor if your FeatureVector differs out.add(new FeatureVector(row)); } return out; @@ -478,12 +567,8 @@ private void promptShiftOneComponent() { if (compRes.isEmpty()) return; int idx; - try { - idx = Integer.parseInt(compRes.get().trim()); - } catch (Exception ex) { - toast("Invalid component index.", true); - return; - } + 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"); @@ -494,14 +579,9 @@ private void promptShiftOneComponent() { if (deltaRes.isEmpty()) return; double delta; - try { - delta = Double.parseDouble(deltaRes.get().trim()); - } catch (Exception ex) { - toast("Invalid delta.", true); - return; - } + try { delta = Double.parseDouble(deltaRes.get().trim()); } + catch (Exception ex) { toast("Invalid delta.", true); return; } - // Build B by copying A and adding delta on idx List b = new ArrayList<>(cohortA.size()); for (FeatureVector fv : cohortA) { List src = fv.getData(); @@ -510,7 +590,6 @@ private void promptShiftOneComponent() { double v = src.get(j); row.add(j == idx ? v + delta : v); } - // Adjust constructor if your FeatureVector differs b.add(new FeatureVector(row)); } setCohortB(b, cohortALabel + " (shifted comp " + idx + " by " + delta + ")"); @@ -561,24 +640,20 @@ private void promptDeriveCohortsByLabel() { private static String safeVectorLabel(FeatureVector fv) { try { - // Adjust if your FeatureVector exposes labels differently. - String lbl = fv.getLabel(); // e.g., fv.getLabel() or fv.getName() or metadata map + String lbl = fv.getLabel(); return (lbl == null) ? "" : lbl.trim(); - } catch (Throwable t) { - return ""; - } + } catch (Throwable t) { return ""; } } + // --------------------------------------------------------------------- - // Collapse/Expand (no node swapping; width-based) + // Collapse/Expand // --------------------------------------------------------------------- private void toggleControlsCollapsed() { if (!controlsCollapsed) { - // Save current divider position if sensible double pos = splitPane.getDividerPositions()[0]; if (pos > 0.02 && pos < 0.98) lastDividerPos = pos; - // Collapse by width (keep node in SplitPane) controlsCollapsed = true; collapseBtn.setText("Expand Controls"); @@ -588,7 +663,6 @@ private void toggleControlsCollapsed() { splitPane.setDividerPositions(0.0); splitPane.requestLayout(); } else { - // Expand: restore widths and divider controlsCollapsed = false; collapseBtn.setText("Collapse Controls"); @@ -630,4 +704,152 @@ 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/panes/PairwiseMatrixPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java index 7d055a5f..dfa8c378 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java @@ -51,13 +51,15 @@ public PairwiseMatrixPane( } }); - // Optional: wire matrix cell clicks (i,j,value). For now, just toast. - view.setOnCellClick((MatrixClick click) -> { + // 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( From f4158d745e9a79b95ed2bd1f9499188c01adcb75 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 30 Sep 2025 14:47:33 -0400 Subject: [PATCH 39/50] mvp 3D graph support from Similarity Matrix with GUI controls. Not sure if its valid yet. --- .../javafx/components/GraphControlsView.java | 367 ++++++++++++++++++ .../javafx/components/PairwiseMatrixView.java | 64 +-- .../panes/HypersurfaceControlsPane.java | 8 +- .../components/panes/PairwiseMatrixPane.java | 8 +- .../trinity/javafx/events/GraphEvent.java | 4 + .../tasks/BuildGraphFromMatrixTask.java | 9 +- .../utils/graph/GraphLayoutParams.java | 65 +++- .../utils/graph/MatrixToGraphAdapter.java | 216 +++++++++-- 8 files changed, 682 insertions(+), 59 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java 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..38dbc848 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java @@ -0,0 +1,367 @@ +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.*; +import javafx.scene.layout.*; + +/** + * 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/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index bd2ed737..0ae5acf7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -83,6 +83,7 @@ public final class PairwiseMatrixView extends BorderPane { private String cohortALabel = "A"; private String cohortBLabel = "B"; private final DensityCache cache; + MatrixToGraphAdapter.MatrixKind currentMatrixKind = MatrixToGraphAdapter.MatrixKind.SIMILARITY; // --- Hooks private Consumer toastHandler; @@ -220,10 +221,10 @@ public void setCohortB(List vectors, String label) { public MatrixHeatmapView getHeatmapView() { return heatmap; } public SplitPane getSplitPane() { return splitPane; } - /** Optional: send toast/status to Terminal/Console integration. */ + /**send toast/status to Terminal/Console integration. */ public void setToastHandler(Consumer handler) { this.toastHandler = handler; } - /** Optional: observe cell clicks from the heatmap. */ + /** observe cell clicks from the heatmap. */ public void setOnCellClick(Consumer handler) { this.onCellClickHandler = handler; } // --------------------------------------------------------------------- @@ -477,8 +478,7 @@ private MenuButton buildActionsMenu() { return mb; } - - private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { + public void triggerGraphBuildWithParams(GraphLayoutParams layout) { double[][] M = currentMatrix(); if (M == null || M.length == 0) { toast("No matrix to build a graph from.", true); return; } @@ -486,37 +486,53 @@ private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { var scene = getScene(); if (scene == null) { toast("Scene not ready.", true); return; } - MatrixToGraphAdapter.WeightMode weightMode = MatrixToGraphAdapter.WeightMode.DIRECT; - - GraphLayoutParams layout = new GraphLayoutParams(); - layout.kind = GraphLayoutParams.LayoutKind.MDS_3D; // or FORCE_FR, etc. +// 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); + + // 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; // ensure 3D + mdsParams.outputDim = 3; setControlsDisabled(true); setProgressText("Building graph…"); BuildGraphFromMatrixTask task = new BuildGraphFromMatrixTask( - scene, M, labels, kind, weightMode, layout, mdsParams); + 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); - }); + 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(); + 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 cohort helpers // --------------------------------------------------------------------- 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 index 1f2ba532..02ba691d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.javafx.components.panes; +import edu.jhuapl.trinity.javafx.components.GraphControlsView; import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; @@ -330,11 +331,12 @@ private TabPane buildTabs() { TabPane tabs = new TabPane(); tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); tabs.setPrefWidth(PANEL_WIDTH - 8); - + GraphControlsView gcv = new GraphControlsView(scene); + Tab t1 = new Tab("View", viewTabContent); Tab t2 = new Tab("Processing", procTabContent); - - tabs.getTabs().addAll(t1, t2); + Tab t3 = new Tab("Graph", gcv); + tabs.getTabs().addAll(t1, t2, t3); return tabs; } 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 index dfa8c378..430bb053 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java @@ -6,6 +6,8 @@ import edu.jhuapl.trinity.javafx.components.MatrixHeatmapView.MatrixClick; 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 java.util.List; @@ -50,6 +52,10 @@ public PairwiseMatrixPane( 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 -> { @@ -63,7 +69,7 @@ public PairwiseMatrixPane( // Forward view toasts to the command terminal view.setToastHandler(msg -> { Platform.runLater(() -> scene.getRoot().fireEvent( - new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN))); + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN))); }); } 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..83b2b9c7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java @@ -15,6 +15,10 @@ 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"); + // in edu.jhuapl.trinity.javafx.events.GraphEvent + 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"); public GraphEvent(EventType arg0) { super(arg0); 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 index b72be140..b99c241b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java @@ -5,6 +5,7 @@ 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; @@ -15,11 +16,10 @@ import javafx.scene.paint.Color; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import java.time.Duration; import java.util.List; - import static edu.jhuapl.trinity.utils.Utils.totalTimeString; +import javafx.scene.text.Font; /** * BuildGraphFromMatrixTask @@ -121,13 +121,14 @@ private void showBusy(String msg) { ps.innerStrokeColor = Color.AQUA; ps.outerStrokeColor = Color.DODGERBLUE; scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.UPDATE_BUSY_INDICATOR, ps)); - scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_TEXT_CONSOLE, msg, true)); + postConsole(msg); }); } private void postConsole(String msg) { Platform.runLater(() -> - scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_TEXT_CONSOLE, msg, true)) + scene.getRoot().fireEvent(new CommandTerminalEvent( + msg, new Font("Consolas", 18), Color.LIGHTGREEN)) ); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java index f9486d5b..d9673e9e 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java @@ -6,12 +6,24 @@ /** * 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; - public enum LayoutKind { CIRCLE_XZ, CIRCLE_XY, SPHERE, MDS_3D, FORCE_FR } + // --------------------------------------------------------------------- + // Layout kinds + // --------------------------------------------------------------------- + + public enum LayoutKind { + CIRCLE_XZ, + CIRCLE_XY, + SPHERE, + MDS_3D, + FORCE_FR + } /** Which layout engine to use. */ public LayoutKind kind = LayoutKind.SPHERE; @@ -19,7 +31,10 @@ public enum LayoutKind { CIRCLE_XZ, CIRCLE_XY, SPHERE, MDS_3D, FORCE_FR } /** Radius / scale for static layouts and initial placement for force layouts. */ public double radius = 600.0; - // --- Force-FR knobs --- + // --------------------------------------------------------------------- + // Force-FR knobs + // --------------------------------------------------------------------- + /** Number of force iterations (ticks). */ public int iterations = 500; /** Global step size / temperature start (will cool). */ @@ -33,17 +48,53 @@ public enum LayoutKind { CIRCLE_XZ, CIRCLE_XY, SPHERE, MDS_3D, FORCE_FR } /** Cooling schedule factor (0..1). Smaller → faster cooling. */ public double cooling = 0.96; - // --- Edge building from matrix --- - /** Keep at most this many heaviest edges (per-node cap applied after global sort). */ + // --------------------------------------------------------------------- + // 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; } @@ -56,6 +107,12 @@ public enum LayoutKind { CIRCLE_XZ, CIRCLE_XY, SPHERE, MDS_3D, FORCE_FR } 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; } diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java index d02a6111..bdbddd0e 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java @@ -3,10 +3,12 @@ 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 @@ -14,10 +16,17 @@ * Converts a similarity or divergence matrix into a GraphDirectedCollection, * and computes node positions using a chosen layout (Static, MDS-3D, Force-FR). * - * - STATIC layouts (CIRCLE_XZ, CIRCLE_XY, SPHERE) ignore the matrix for positions. - * - MDS_3D: uses a provided embedding function (plug your existing MDS). - * - FORCE_FR: uses simple 3D Fruchterman–Reingold with edge weights - * derived from the matrix (see WeightMode). + * 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 */ @@ -64,16 +73,16 @@ public static GraphDirectedCollection build(double[][] matrix, // 1) Prepare a weight matrix for edges (symmetric, non-negative). double[][] weights = toEdgeWeights(matrix, kind, weightMode, layoutParams.normalizeWeights01); - // 2) Build edges (sparsify by global top-K with per-node degree cap). - List edgeList = selectEdges(weights, layoutParams.maxEdges, layoutParams.maxDegreePerNode, layoutParams.minEdgeWeight); + // 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 -> { + case SPHERE -> pos = StaticLayouts.sphere(n, layoutParams.radius); + case MDS_3D -> { // Need distances for MDS double[][] distances = toDistances(matrix, kind); if (mds3d == null) { @@ -81,7 +90,6 @@ public static GraphDirectedCollection build(double[][] matrix, pos = StaticLayouts.sphere(n, layoutParams.radius); } else { pos = mds3d.embed(distances, labels); - // Normalize/scale to radius pos = normalizeToRadius(pos, layoutParams.radius); } } @@ -132,7 +140,7 @@ public static GraphDirectedCollection build(double[][] matrix, return gc; } - // ----- Helpers ----- + // ===== Helpers ===== private static GraphDirectedCollection emptyGraph() { GraphDirectedCollection gc = new GraphDirectedCollection(); @@ -234,21 +242,169 @@ private static double[][] toDistances(double[][] m, MatrixKind kind) { return d; } - /** Pick a sparse edge set: global top-K by weight with per-node degree cap. */ - private static List selectEdges(double[][] weights, int maxEdges, int maxDeg, double minW) { - final int n = weights.length; - List all = new ArrayList<>(); + // ===== 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 w = weights[i][j]; - if (w > minW) all.add(new EdgeRec(i, j, w)); + 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); } } - all.sort(Comparator.comparingDouble((EdgeRec e) -> e.w).reversed()); + 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<>(); - for (EdgeRec e : all) { + 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); @@ -258,6 +414,21 @@ private static List selectEdges(double[][] weights, int maxEdges, int m } 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 ---------- @@ -286,8 +457,8 @@ 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)(n - 1)) * 2.0; - double radius = Math.sqrt(1.0 - y*y); + 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; @@ -298,7 +469,6 @@ static double[][] sphere(int n, double r) { } private static double[][] normalizeToRadius(double[][] pos, double radius) { - // Normalize RMS radius then scale to target radius (keeps aspect, avoids huge spread) 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)); From 84aee4af8defdbf567a5c5e6908f1319e2e44e8e Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 1 Oct 2025 15:13:25 -0400 Subject: [PATCH 40/50] Graph style can now be controlled via GUI. SyntheticDataDialog support for Similarity and Divergence Matrices. --- .../components/GraphStyleControlsView.java | 343 +++++++++++++++ .../javafx/components/PairwiseMatrixView.java | 107 ++++- .../dialogs/SyntheticDataDialog.java | 366 ++++++++++++++++ .../panes/HypersurfaceControlsPane.java | 33 +- .../components/panes/PairwiseMatrixPane.java | 1 - .../trinity/javafx/events/GraphEvent.java | 52 ++- .../javafx/javafx3d/Hypersurface3DPane.java | 124 +++++- .../javafx3d/animated/AnimatedSphere.java | 17 +- .../javafx/javafx3d/animated/Tracer.java | 43 +- .../trinity/utils/graph/GraphStyleParams.java | 70 +++ .../statistics/SyntheticMatrixFactory.java | 403 ++++++++++++++++++ 11 files changed, 1500 insertions(+), 59 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java 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..d57bc59b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java @@ -0,0 +1,343 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.utils.graph.GraphStyleParams; +import java.util.Objects; +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; + +/** + * 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/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index 0ae5acf7..33ece8a3 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -3,6 +3,7 @@ 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; @@ -20,11 +21,10 @@ 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 java.util.ArrayList; import java.util.List; import java.util.function.Consumer; - import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Orientation; @@ -471,10 +471,22 @@ private MenuButton buildActionsMenu() { MenuItem cohortsByLabel = new MenuItem("Derive A/B by vector label…"); cohortsByLabel.setOnAction(e -> promptDeriveCohortsByLabel()); + MenuItem syntheticDataDialogItem = new MenuItem("Synthetic Data Controller"); + syntheticDataDialogItem.setOnAction(e -> { + SyntheticDataDialog dlg = new SyntheticDataDialog(); + dlg.initOwner(getScene().getWindow()); + 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(), clearItem); + new SeparatorMenuItem(), syntheticDataDialogItem, clearItem); return mb; } @@ -486,16 +498,6 @@ public void triggerGraphBuildWithParams(GraphLayoutParams layout) { var scene = getScene(); if (scene == null) { toast("Scene not ready.", true); return; } -// 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); - // Choose weight mapping here (not in params) MatrixToGraphAdapter.WeightMode wmode = (currentMatrixKind == MatrixToGraphAdapter.MatrixKind.SIMILARITY) @@ -515,7 +517,6 @@ public void triggerGraphBuildWithParams(GraphLayoutParams layout) { 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) { @@ -536,6 +537,84 @@ private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { // --------------------------------------------------------------------- // Synthetic cohort helpers // --------------------------------------------------------------------- +/** Load a synthetic matrix (similarity or divergence) into the heatmap. */ +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; + } + + // 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(sm.kind, sm.matrix); + + // Nice toast + 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"); +} + +/* ---------- 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(); 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..fdb99d43 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java @@ -0,0 +1,366 @@ +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.util.Callback; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +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; + +/** + * 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 + * + * Returns a Result that indicates which artifact was created. + * + * Usage: + * SyntheticDataDialog dlg = new SyntheticDataDialog(); + * Optional out = dlg.showAndWait(); + * out.ifPresent(r -> { + * switch (r.kind()) { + * case SIMILARITY_MATRIX, DIVERGENCE_MATRIX -> { + * double[][] M = r.matrix().matrix; + * List labels = r.matrix().labels; + * // set heatmap, etc. + * } + * case COHORTS -> { + * List A = r.cohorts().cohortA; + * List B = r.cohorts().cohortB; + * // set cohorts in PairwiseMatrixView, etc. + * } + * } + * }); + */ +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); + + // 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); + + // Cohorts tab controls (Gaussian vs Uniform) + 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(new Callback() { + @Override public Result call(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) { + // Show an inline alert so users know why it failed + 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(); + + // Cluster sizes hint + Label hintSizes = new Label("K Cluster sizes (csv):"); + HBox sizesRow = new HBox(8, new Label("Sizes (csv)"), simSizes); + sizesRow.setAlignment(Pos.CENTER_LEFT); + + 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) + ) + ); + 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); + } + return Result.similarity(sm); + } + + // ------------------------------ + // Divergence tab + // ------------------------------ + private Node buildDivergenceContent() { + 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) + ) + ); + 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); + return Result.divergence(sm); + } + + // ------------------------------ + // Cohorts tab + // ------------------------------ + 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/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 02ba691d..41d19c52 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -1,6 +1,7 @@ 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.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; @@ -85,33 +86,33 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar BorderPane bp = (BorderPane) this.contentPane; bp.setPadding(new Insets(6)); bp.setCenter(buildTabs()); - + scene.addEventHandler(HypersurfaceEvent.SET_XWIDTH_GUI, e -> { // Set spinner value if different, *without* firing another event - if (xWidthSpinner != null && !xWidthSpinner.getValue().equals((Integer)e.object)) { - xWidthSpinner.getValueFactory().setValue((Integer)e.object); + 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); + 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); + 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); + if (surfScaleSpinner != null && !surfScaleSpinner.getValue().equals((Double) e.object)) { + surfScaleSpinner.getValueFactory().setValue((Double) e.object); } e.consume(); }); - + } private TabPane buildTabs() { @@ -331,12 +332,16 @@ private TabPane buildTabs() { TabPane tabs = new TabPane(); tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); tabs.setPrefWidth(PANEL_WIDTH - 8); - GraphControlsView gcv = new GraphControlsView(scene); - + + 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", gcv); - tabs.getTabs().addAll(t1, t2, t3); + Tab t3 = new Tab("Graph Layout", graphLayoutView); + Tab t4 = new Tab("Graph Style", graphStyleView); + + tabs.getTabs().addAll(t1, t2, t3, t4); return tabs; } 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 index 430bb053..26f71c8b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java @@ -3,7 +3,6 @@ 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.components.MatrixHeatmapView.MatrixClick; import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.events.GraphEvent; 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 83b2b9c7..f08d7b5a 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,28 +17,36 @@ 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"); - // in edu.jhuapl.trinity.javafx.events.GraphEvent 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"); - - public GraphEvent(EventType arg0) { - super(arg0); - } - - public GraphEvent(EventType arg0, Object arg1) { - this(arg0); - object = arg1; - } - - public GraphEvent(EventType arg0, Object arg1, Object arg2) { - this(arg0); - object = arg1; - object2 = arg2; - } - - public GraphEvent(Object arg0, EventTarget arg1, EventType arg2) { - super(arg0, arg1, arg2); - object = arg0; - } + // 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"); + + public GraphEvent(EventType arg0) { super(arg0); } + public GraphEvent(EventType arg0, Object arg1) { this(arg0); object = arg1; } + public GraphEvent(EventType arg0, Object arg1, Object arg2) { this(arg0); object = arg1; object2 = arg2; } + 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/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 9194990e..7c7a3971 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -27,7 +27,9 @@ 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; @@ -44,6 +46,7 @@ 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; @@ -118,6 +121,7 @@ import java.util.*; import java.util.function.Function; import javafx.event.Event; +import javafx.scene.Parent; import javafx.scene.control.Menu; /** @@ -263,6 +267,8 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} .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; @@ -336,8 +342,6 @@ public Hypersurface3DPane(Scene scene) { pointLight.setTranslateY(camera.getTranslateY()); pointLight.setTranslateZ(camera.getTranslateZ() + 500.0); -// subScene.setOnMouseEntered(event -> subScene.requestFocus()); -// setOnMouseEntered(event -> subScene.requestFocus()); subScene.setOnZoom(event -> { double modifier = 50.0; double modifierFactor = 0.1; @@ -583,18 +587,60 @@ public Hypersurface3DPane(Scene scene) { e.consume(); } }); +// 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)); + 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; subScene.setFill(color); @@ -683,6 +729,73 @@ public Hypersurface3DPane(Scene scene) { }; 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(); @@ -1172,12 +1285,12 @@ private void loadSurf3D() { extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); wireEventHandlers(); - pointLight.getScope().addAll(surfPlot); + 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); + ambientLight.getScope().addAll(surfPlot, graphLayer); sceneRoot.getChildren().add(ambientLight); updateLabels(); @@ -1218,7 +1331,6 @@ private void fireOnRoot(Event evt) { * Updates all rendering state and triggers updates as needed. */ private void wireEventHandlers() { -// Scene scene = getScene(); if (scene == null) return; // Geometry / scale scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { 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..1bae407b 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 @@ -125,7 +125,22 @@ public void unselect() { // contract(); 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..b9c2dcb2 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,14 +8,14 @@ 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; import org.fxyz3d.shapes.composites.PolyLine3D; - import java.util.ArrayList; import java.util.List; - import static javafx.animation.Animation.INDEFINITE; /** @@ -146,4 +146,43 @@ 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/utils/graph/GraphStyleParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java new file mode 100644 index 00000000..93715c9c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java @@ -0,0 +1,70 @@ +package edu.jhuapl.trinity.utils.graph; + +import java.io.Serial; +import java.io.Serializable; +import javafx.scene.paint.Color; + +/** + * 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/statistics/SyntheticMatrixFactory.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java new file mode 100644 index 00000000..4d81b34d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java @@ -0,0 +1,403 @@ +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; + +/** + * 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) + * + * 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; + + public SyntheticMatrix(double[][] matrix, + List labels, + List clusterIds, + MatrixToGraphAdapter.MatrixKind kind, + String title) { + this.matrix = matrix; + this.labels = labels; + this.clusterIds = clusterIds; + this.kind = kind; + this.title = title; + } + } + + // --------------------------------------------------------------------- + // 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; + } + + private static double clamp01(double v) { + if (v < 0.0) return 0.0; + if (v > 1.0) return 1.0; + return v; + } + + /** + * 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); + } +} From 3774e154af88b7b09670a1162b97b9e32f3b91ec Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sat, 4 Oct 2025 10:45:48 -0400 Subject: [PATCH 41/50] SyntheticDataDialog not fills in Cohort info. Visibility of Graph 3D objects controllable from HypersurfaceControlsPane --- .../javafx/components/PairwiseMatrixView.java | 329 ++++++++++-------- .../dialogs/SyntheticDataDialog.java | 134 +++++-- .../panes/HypersurfaceControlsPane.java | 27 +- .../trinity/javafx/events/GraphEvent.java | 9 +- .../javafx/javafx3d/Hypersurface3DPane.java | 146 +++++++- .../javafx/renderers/Graph3DRenderer.java | 91 ++++- .../statistics/SyntheticMatrixFactory.java | 30 ++ 7 files changed, 585 insertions(+), 181 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index 33ece8a3..f0c5c5c0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -21,6 +21,7 @@ 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; import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory.SyntheticMatrix; import java.util.ArrayList; import java.util.List; @@ -49,7 +50,8 @@ * 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 (JpdfBatchEngine/JpdfRecipe). + * 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 { @@ -83,6 +85,12 @@ public final class PairwiseMatrixView extends BorderPane { 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 @@ -100,7 +108,7 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca // Compact column labels: show indices only this.heatmap.setCompactColumnLabels(true); - // Cell click → render JPDF (Similarity) or ΔPDF (Divergence) via existing Jpdf pipeline + // Cell click → render JPDF (Similarity) or ΔPDF (Divergence) this.heatmap.setOnCellClick(click -> { if (click == null) return; @@ -108,12 +116,21 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca Request effective = getLastRequestOrBuild(); if (effective == null) { toast("No configuration to render (run once or provide defaults).", true); - } else if (cohortA == null || cohortA.isEmpty()) { - toast("Cohort A is empty. Load or set vectors first.", 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); } } @@ -133,7 +150,7 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca collapseBtn = new Button("Collapse Controls"); collapseBtn.setOnAction(e -> toggleControlsCollapsed()); - // Actions menu (placeholder for future save/load/export) + // Actions menu actionsBtn = buildActionsMenu(); // Slim toolbar (left = buttons; right = progress text) @@ -221,7 +238,7 @@ public void setCohortB(List vectors, String label) { public MatrixHeatmapView getHeatmapView() { return heatmap; } public SplitPane getSplitPane() { return splitPane; } - /**send toast/status to Terminal/Console integration. */ + /** send toast/status to Terminal/Console integration. */ public void setToastHandler(Consumer handler) { this.toastHandler = handler; } /** observe cell clicks from the heatmap. */ @@ -302,6 +319,9 @@ cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), ca 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); } @@ -329,6 +349,8 @@ public PairwiseMatrixConfigPanel.Request getLastRequestOrBuild() { 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 @@ -363,55 +385,57 @@ public void renderPdfForCellUsingEngine(int i, int j, Request req) { // --------------------------------------------------------------------- // 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; } - 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); -} + 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 @@ -471,8 +495,9 @@ private MenuButton buildActionsMenu() { 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 -> { + syntheticDataDialogItem.setOnAction(e -> { SyntheticDataDialog dlg = new SyntheticDataDialog(); dlg.initOwner(getScene().getWindow()); dlg.showAndWait().ifPresent(res -> { @@ -490,6 +515,7 @@ private MenuButton buildActionsMenu() { 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; } @@ -519,102 +545,129 @@ public void triggerGraphBuildWithParams(GraphLayoutParams layout) { 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); + 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); -} + triggerGraphBuildWithParams(layout); + } // --------------------------------------------------------------------- - // Synthetic cohort helpers + // Synthetic matrix & cohort helpers // --------------------------------------------------------------------- -/** Load a synthetic matrix (similarity or divergence) into the heatmap. */ -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; - } - - // 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(sm.kind, sm.matrix); - - // Nice toast - 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); -} + /** 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; + } -/** Convenience overload with default labels. */ -public void setCohorts(List a, List b) { - setCohorts(a, "A", b, "B"); -} + // Map factory kind to adapter kind + this.currentMatrixKind = (sm.kind == MatrixToGraphAdapter.MatrixKind.DIVERGENCE) + ? MatrixToGraphAdapter.MatrixKind.DIVERGENCE + : MatrixToGraphAdapter.MatrixKind.SIMILARITY; -/* ---------- helpers ---------- */ + // 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); + } -private void applyHeatmapDefaultsFor(MatrixToGraphAdapter.MatrixKind kind, double[][] M) { - // Legend ON, auto range ON - heatmap.setShowLegend(true); - heatmap.setAutoRange(true); + // Palette + range defaults based on kind + applyHeatmapDefaultsFor(this.currentMatrixKind, sm.matrix); - // 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(); + // 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); } -} -/** 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; - } + /** 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(); } } - if (!(max > min)) { // degenerate - min = 0.0; max = 1.0; + + /** 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}; } - return new double[]{min, max}; -} private List synthGaussianLikeA(List likeA, double mean, double stddev) { int n = likeA.size(); 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 index fdb99d43..5ca30f39 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java @@ -7,12 +7,15 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.util.Callback; + import java.util.ArrayList; import java.util.List; import java.util.Objects; + 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; @@ -32,25 +35,8 @@ * • Divergence synthetic matrices (3 clusters) * • Synthetic cohorts (Gaussian vs Uniform) for divergence workflows * - * Returns a Result that indicates which artifact was created. - * - * Usage: - * SyntheticDataDialog dlg = new SyntheticDataDialog(); - * Optional out = dlg.showAndWait(); - * out.ifPresent(r -> { - * switch (r.kind()) { - * case SIMILARITY_MATRIX, DIVERGENCE_MATRIX -> { - * double[][] M = r.matrix().matrix; - * List labels = r.matrix().labels; - * // set heatmap, etc. - * } - * case COHORTS -> { - * List A = r.cohorts().cohortA; - * List B = r.cohorts().cohortB; - * // set cohorts in PairwiseMatrixView, etc. - * } - * } - * }); + * 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 { @@ -131,6 +117,17 @@ private static long lval(TextField tf, long def) { 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); @@ -138,7 +135,19 @@ private static long lval(TextField tf, long def) { private final TextField divNoise = tf("0.02", 80); private final TextField divSeed = tf("46", 120); - // Cohorts tab controls (Gaussian vs Uniform) + // 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); @@ -182,8 +191,9 @@ public SyntheticDataDialog() { default -> null; }; } catch (Throwable t) { - // Show an inline alert so users know why it failed - Alert a = new Alert(Alert.AlertType.ERROR, "Build failed: " + t.getClass().getSimpleName() + " – " + String.valueOf(t.getMessage()), ButtonType.OK); + Alert a = new Alert(Alert.AlertType.ERROR, + "Build failed: " + t.getClass().getSimpleName() + " – " + String.valueOf(t.getMessage()), + ButtonType.OK); a.showAndWait(); return null; } @@ -198,11 +208,26 @@ private Node buildSimilarityContent() { simKind.getItems().addAll("2 Clusters", "K Clusters", "Ring", "Grid"); simKind.getSelectionModel().selectFirst(); - // Cluster sizes hint - Label hintSizes = new Label("K Cluster sizes (csv):"); 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), @@ -219,7 +244,10 @@ private Node buildSimilarityContent() { ), section("Random", row("Seed", simSeed) - ) + ), + new Separator(), + row("", simAttachCohorts), + simCohSection ); top.setPadding(new Insets(8)); return new ScrollPane(top); @@ -257,6 +285,21 @@ private Result buildSimilarity() { } 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); } @@ -264,6 +307,24 @@ private Result buildSimilarity() { // 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) @@ -275,7 +336,10 @@ private Node buildDivergenceContent() { ), section("Random", row("Seed", divSeed) - ) + ), + new Separator(), + row("", divAttachCohorts), + divCohSection ); top.setPadding(new Insets(8)); return new ScrollPane(top); @@ -287,12 +351,28 @@ private Result buildDivergence() { 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 + // Cohorts tab (standalone) // ------------------------------ private Node buildCohortsContent() { VBox top = new VBox(10, 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 index 41d19c52..60f8ccbf 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -2,6 +2,7 @@ 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; @@ -20,6 +21,7 @@ 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; @@ -75,6 +77,9 @@ public class HypersurfaceControlsPane extends LitPathPane { 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; @@ -87,8 +92,8 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar bp.setPadding(new Insets(6)); bp.setCenter(buildTabs()); + // --- GUI sync from model → controls scene.addEventHandler(HypersurfaceEvent.SET_XWIDTH_GUI, e -> { - // Set spinner value if different, *without* firing another event if (xWidthSpinner != null && !xWidthSpinner.getValue().equals((Integer) e.object)) { xWidthSpinner.getValueFactory().setValue((Integer) e.object); } @@ -113,6 +118,14 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar 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() { @@ -242,10 +255,20 @@ private TabPane buildTabs() { 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("Scene", sceneGrid), + titledBox("Graph Overlay", graphOverlayGrid) ); viewTabContent.setPadding(new Insets(6)); 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 f08d7b5a..355b098c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java @@ -30,9 +30,16 @@ public class GraphEvent extends Event { 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); } public GraphEvent(EventType arg0, Object arg1) { this(arg0); object = arg1; } 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 7c7a3971..4753f245 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -4,6 +4,8 @@ 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; @@ -262,6 +264,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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) @@ -335,6 +338,9 @@ public Hypersurface3DPane(Scene scene) { 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); pointLight = new PointLight(Color.WHITE); cameraTransform.getChildren().add(pointLight); @@ -635,7 +641,7 @@ public Hypersurface3DPane(Scene scene) { // 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)); @@ -1451,6 +1457,66 @@ private void wireEventHandlers() { 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); + }); + + // 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); + }); + + // 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 + )); + }); + + // 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) { callout.setMainTitleText(featureVector.getLabel()); @@ -1689,7 +1755,6 @@ private static List> deepCopyGrid(List> src) { for (List row : src) out.add(new ArrayList<>(row)); return out; } - private void rebuildProcessedGridAndRefresh() { if (originalGrid == null || originalGrid.isEmpty()) return; List> g = deepCopyGrid(originalGrid); @@ -1704,4 +1769,81 @@ private void rebuildProcessedGridAndRefresh() { 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); + }); + } + } \ No newline at end of file diff --git a/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java index 3aa4cfc6..36eefd31 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java @@ -3,6 +3,7 @@ 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; @@ -10,16 +11,19 @@ import java.util.List; import java.util.Optional; import javafx.scene.Group; +import javafx.scene.Scene; import javafx.scene.paint.Color; import javafx.scene.paint.PhongMaterial; import org.fxyz3d.geometry.Point3D; /** - * Turns a GraphDirectedCollection into a 3D Group (nodes = AnimatedSphere, edges = Tracer). + * 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; @@ -27,19 +31,49 @@ public static final class Params { 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; } + 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() {} + private Graph3DRenderer() { + } public static Group buildGraphGroup(GraphDirectedCollection graph, Params params) { Group root = new Group(); - if (graph == null) return root; + if (graph == null) { + return root; + } Params p = (params != null) ? params : new Params(); Color nodeDefault = (graph.getDefaultNodeColor() != null) @@ -54,8 +88,25 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params 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.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); } @@ -64,12 +115,29 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params for (GraphEdge ge : graph.getEdges()) { Optional a = graph.findNodeById(ge.getStartID()); Optional b = graph.findNodeById(ge.getEndID()); - if (a.isEmpty() || b.isEmpty()) continue; + 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); } @@ -77,4 +145,5 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params root.getChildren().addAll(edges); return root; } + } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java index 4d81b34d..392f3d85 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java @@ -28,6 +28,7 @@ * - 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. @@ -51,16 +52,45 @@ public static final class SyntheticMatrix implements Serializable { 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); } } From 6fe4b24d7992aceb702dccd2ced6fa9bf2589d1c Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 6 Oct 2025 09:11:25 -0400 Subject: [PATCH 42/50] small tweaks to adjust to new CyberReport format to ensure proper ingest. Ingest of CyberReport now emits NEW_FEATURE_COLLECTION event so all listeners here it properly. --- .../data/messages/xai/CyberReport.java | 21 +++++++++++++ .../javafx/components/PairwiseMatrixView.java | 1 - .../handlers/FeatureVectorEventHandler.java | 30 ++++++++++++++----- .../statistics/HeatmapThumbnailView.java | 1 - 4 files changed, 43 insertions(+), 10 deletions(-) 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 index c12750ec..1356b51b 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java @@ -14,6 +14,7 @@ @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"; @@ -29,6 +30,10 @@ public class CyberReport { public static record ModEntry(int value, String label) {} // ----- simple metadata ----- + + @JsonProperty(PERCENTAGE) + private Double percentage; + @JsonProperty(GROUNDTRUTH) private String groundTruth; @@ -62,6 +67,8 @@ public static record ModEntry(int value, String label) {} @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) @@ -100,6 +107,20 @@ public List getAllVectors() { } // ----- 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; } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index f0c5c5c0..772d109d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -21,7 +21,6 @@ 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; import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory.SyntheticMatrix; import java.util.ArrayList; import java.util.List; 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 2b535c4b..881962c4 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java @@ -99,6 +99,9 @@ private void addNewFeatureVector(FeatureVector 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()); @@ -108,24 +111,35 @@ public void handleCyberReport(FeatureVectorEvent event) { FeatureVector sgtaFV = cyberToFeatureVector(CyberReport.SGTA, cyberReport.getsGtA()); sgtaFV.getMetaData().putAll(metaData); - addNewFeatureVector(sgtaFV); - + //addNewFeatureVector(sgtaFV); + features.add(sgtaFV); + FeatureVector sinfgtFV = cyberToFeatureVector(CyberReport.SINFGT, cyberReport.getsInfGt()); sinfgtFV.getMetaData().putAll(metaData); - addNewFeatureVector(sinfgtFV); - + //addNewFeatureVector(sinfgtFV); + features.add(sinfgtFV); + FeatureVector sintelaFV = cyberToFeatureVector(CyberReport.SINTELA, cyberReport.getsIntelA()); sintelaFV.getMetaData().putAll(metaData); - addNewFeatureVector(sintelaFV); + //addNewFeatureVector(sintelaFV); + features.add(sintelaFV); FeatureVector sintelgtFV = cyberToFeatureVector(CyberReport.SINTELGT, cyberReport.getsIntelGt()); sintelgtFV.getMetaData().putAll(metaData); - addNewFeatureVector(sintelgtFV); - + //addNewFeatureVector(sintelgtFV); + features.add(sintelgtFV); + FeatureVector deltaFV = cyberToFeatureVector(CyberReport.DELTA, cyberReport.getDelta()); deltaFV.getMetaData().putAll(metaData); - addNewFeatureVector(deltaFV); + //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; diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java index 4fc65b48..07a5b55c 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java @@ -9,7 +9,6 @@ import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; -import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; From 23d8efae9d412c8e5e5ceccc411ad00a3458ffb1 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 6 Oct 2025 16:07:09 -0400 Subject: [PATCH 43/50] Upgrades to appearance management for Joint PDF thumbnail grid. --- .../components/FeatureVectorManagerView.java | 6 +- .../javafx/components/PairwiseJpdfView.java | 240 +++++++++++++- .../javafx/components/PairwiseMatrixView.java | 7 + .../dialogs/SyntheticDataDialog.java | 38 ++- .../statistics/HeatmapThumbnailView.java | 246 ++++++++------ .../utils/statistics/PairGridPane.java | 306 +++++++++++++++++- 6 files changed, 698 insertions(+), 145 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index 5bc33f9f..c0ed46cd 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -208,8 +208,10 @@ private void buildTable() { colPreview.setCellValueFactory(cd -> new ReadOnlyStringWrapper(previewList(cd.getValue().getData(), 8))); - colImageUrl.setCellValueFactory(cd -> new ReadOnlyStringWrapper(cd.getValue().getImageURL().trim())); - colText.setCellValueFactory(cd -> new ReadOnlyStringWrapper(cd.getValue().getText().trim())); + 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); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index d4d0566b..f46e62ba 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -10,19 +10,22 @@ 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.SnapshotParameters; import javafx.scene.control.*; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; import javafx.stage.FileChooser; -import javafx.scene.layout.Background; +import javax.imageio.ImageIO; +import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.ArrayList; @@ -33,8 +36,9 @@ /** * PairwiseJpdfView * - Search/Sort are hosted in the PairGridPane header. - * - Toolbar is slim: Run, Reset, Collapse, Actions (MenuButton). + * - 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 { @@ -55,10 +59,11 @@ public class PairwiseJpdfView extends BorderPane { private final Button cfgRunBtn; private final Button collapseBtn; - // MenuButton (consolidated actions) + // MenuButtons private final MenuButton actionsBtn; + private final MenuButton appearanceBtn; - // Streaming controls (now only inside menu) + // Streaming controls (inside Actions menu) private final Spinner flushSizeSpinner; private final Spinner flushIntervalSpinner; private final CheckBox incrementalSortCheck; @@ -68,6 +73,32 @@ public class PairwiseJpdfView extends BorderPane { 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; @@ -91,6 +122,21 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf 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(); @@ -119,19 +165,21 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf sortCombo.setValue("Score"); sortAscCheck.setSelected(false); - HBox gridHeader = new HBox(8, + HBox searchSort = new HBox(8, new Label("Search"), searchField, new Label("Sort"), sortCombo, sortAscCheck ); - gridHeader.setAlignment(Pos.CENTER_LEFT); - gridHeader.setPadding(new Insets(4, 8, 2, 8)); - gridPane.setHeader(gridHeader); + 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); + topLeft = new HBox(10, cfgResetBtn, cfgRunBtn, collapseBtn, actionsBtn, appearanceBtn); topLeft.setAlignment(Pos.CENTER_LEFT); topLeft.setPadding(new Insets(6, 8, 6, 8)); @@ -165,6 +213,12 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf 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 @@ -201,6 +255,153 @@ private MenuButton buildActionsMenu() { 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) { @@ -277,9 +478,9 @@ public void runWithRecipe(JpdfRecipe recipe) { try { switch (runRecipe.getPairSelection()) { case WHITELIST -> - batch = JpdfBatchEngine.runWhitelistPairs(cohortA, runRecipe, policy, cache, onResult); + batch = JpdfBatchEngine.runWhitelistPairs(cohortA, runRecipe, policy, PairwiseJpdfView.this.cache, onResult); default -> - batch = JpdfBatchEngine.runComponentPairs(cohortA, runRecipe, policy, cache, onResult); + batch = JpdfBatchEngine.runComponentPairs(cohortA, runRecipe, policy, PairwiseJpdfView.this.cache, onResult); } } catch (Throwable t) { final String msg = "Batch failed: " + t.getClass().getSimpleName() @@ -343,6 +544,15 @@ private int safeSpinnerInt(Spinner sp, int 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); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index 772d109d..27ec4a6b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -30,6 +30,7 @@ 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; @@ -41,6 +42,8 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.StageStyle; /** * PairwiseMatrixView @@ -499,6 +502,10 @@ private MenuButton buildActionsMenu() { 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()); 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 index 5ca30f39..5c7ef888 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java @@ -177,26 +177,24 @@ public SyntheticDataDialog() { getDialogPane().setPadding(new Insets(8)); // Result converter - setResultConverter(new Callback() { - @Override public Result call(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; - } + 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; } }); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java index 07a5b55c..6997d8ce 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java @@ -1,5 +1,7 @@ package edu.jhuapl.trinity.utils.statistics; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; @@ -14,26 +16,20 @@ /** * HeatmapThumbnailView - * -------------------- - * Lightweight JavaFX view that renders a small 2D grid (e.g., PDF/CDF or diff surface) - * to a Canvas using a fast PixelWriter-based mapper. Designed for thumbnail/overview use. - * - * Features: - * - Accepts List> or GridDensityResult (choose PDF or CDF). - * - Sequential or diverging color palette (diverging supports center value). - * - Auto or fixed value range; optional Y-flip for row-major grids. - * - Optional slim legend bar with min/center/max tick labels. - * - * Notes: - * - This view is not interactive; wrap it if you need selection/hover. - * - For very large grids, downsampling happens naturally by Canvas scaling. - * - * @author Sean Phillips + * 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); @@ -52,12 +48,27 @@ public enum PaletteKind { SEQUENTIAL, DIVERGING } // --- 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); @@ -71,14 +82,12 @@ public HeatmapThumbnailView() { // Public API // ===================================================================================== - /** Set the underlying grid (row-major), and request redraw. */ public void setGrid(List> grid) { this.grid = grid; if (autoRange) recomputeRange(); redraw(); } - /** Convenience to set from a GridDensityResult (choose PDF or CDF). */ public void setFromGridDensity(GridDensityResult res, boolean useCDF, boolean flipY) { Objects.requireNonNull(res, "GridDensityResult"); List> g = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); @@ -86,26 +95,26 @@ public void setFromGridDensity(GridDensityResult res, boolean useCDF, boolean fl setGrid(g); } - /** Flip Y axis (top row drawn at bottom when true). */ - public void setFlipY(boolean flip) { - this.flipY = flip; - redraw(); - } + public void setFlipY(boolean flip) { this.flipY = flip; redraw(); } - /** Use sequential palette. */ - public void useSequentialPalette() { - this.palette = PaletteKind.SEQUENTIAL; - redraw(); - } + public void useSequentialPalette() { this.palette = PaletteKind.SEQUENTIAL; redraw(); } - /** Use diverging palette with specified center (e.g., 0.0 for signed diffs). */ public void useDivergingPalette(double center) { this.palette = PaletteKind.DIVERGING; this.divergingCenter = center; redraw(); } - /** Enable/disable legend bar. */ + /** 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); @@ -113,14 +122,12 @@ public void setShowLegend(boolean show) { redraw(); } - /** Use automatic value range from data. */ public void setAutoRange(boolean auto) { this.autoRange = auto; if (auto && grid != null) recomputeRange(); redraw(); } - /** Set fixed value range. Auto-range is disabled. */ public void setFixedRange(double min, double max) { this.autoRange = false; this.vmin = min; @@ -128,7 +135,6 @@ public void setFixedRange(double min, double max) { redraw(); } - /** Set content padding inside the view. */ public void setContentPadding(Insets insets) { this.contentPadding = insets == null ? Insets.EMPTY : insets; setPadding(contentPadding); @@ -136,12 +142,42 @@ public void setContentPadding(Insets insets) { redraw(); } - /** Expose current min/max (useful for pairing legends externally). */ + 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 @@ -167,42 +203,41 @@ protected void layoutChildren() { super.layoutChildren(); } - private void onSize(Observable obs) { - layoutChildren(); - redraw(); - } + private void onSize(Observable obs) { layoutChildren(); redraw(); } private void recomputeRange() { if (grid == null || grid.isEmpty()) return; - double lo = Double.POSITIVE_INFINITY; - double hi = Double.NEGATIVE_INFINITY; + + 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) == null ? Double.NaN : row.get(c); - if (Double.isFinite(v)) { - if (v < lo) lo = v; - if (v > hi) hi = v; - } + Double v = row.get(c); + if (v != null && Double.isFinite(v)) vals.add(v); } } - if (!Double.isFinite(lo) || !Double.isFinite(hi)) { - lo = 0.0; hi = 1.0; - } + 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; - // For diverging with auto range, keep center inside [vmin, vmax] - if (palette == PaletteKind.DIVERGING && (divergingCenter < vmin || divergingCenter > vmax)) { - // leave as-is; mapping will clamp - } + + vmin = lo; vmax = hi; } private void redraw() { GraphicsContext g = canvas.getGraphicsContext2D(); - g.setFill(Color.TRANSPARENT); - g.clearRect(0, 0, canvas.getWidth(), canvas.getHeight()); + g.setImageSmoothing(imageSmoothing); + + // background fill + g.setFill(backgroundColor); + g.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); if (grid == null || grid.isEmpty()) { drawEmpty(g); @@ -217,11 +252,9 @@ private void redraw() { return; } - // Create an image at cell resolution, then scale onto canvas. WritableImage img = new WritableImage(cols, rows); PixelWriter pw = img.getPixelWriter(); - // Precompute mapping final double min = vmin; final double max = vmax; final double span = Math.max(1e-12, max - min); @@ -236,48 +269,42 @@ private void redraw() { } } - // Draw image scaled to canvas g.drawImage(img, 0, 0, cols, rows, 0, 0, canvas.getWidth(), canvas.getHeight()); - - // Legend drawLegend(); } private void drawEmpty(GraphicsContext g) { - g.setFill(Color.gray(0.1)); - g.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); - g.setStroke(Color.gray(0.4)); + 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.setFill(Color.TRANSPARENT); - lg.clearRect(0, 0, legend.getWidth(), legend.getHeight()); + lg.setImageSmoothing(true); + + lg.setFill(backgroundColor); + lg.fillRect(0, 0, legend.getWidth(), legend.getHeight()); double w = legend.getWidth(); double h = legend.getHeight(); - // vertical gradient 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 by default + 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); } - // tick-ish markers (minimal; text labels would add font deps here) - lg.setStroke(Color.gray(0.15)); + lg.setStroke(Color.gray(0.35)); lg.strokeRect(0.5, 0.5, w - 1, h - 1); - // center tick for diverging if (palette == PaletteKind.DIVERGING) { - double tCenter = (vmax - divergingCenter) / Math.max(1e-12, (vmax - vmin)); // 1 at top, 0 at bottom + double tCenter = (vmax - divergingCenter) / Math.max(1e-12, (vmax - vmin)); double y = (1.0 - tCenter) * h; - lg.setStroke(Color.gray(0.3)); + lg.setStroke(Color.gray(0.6)); lg.strokeLine(0, y, w, y); } } @@ -288,40 +315,74 @@ private void drawLegend() { private Color mapValueToColor(double v, double min, double max, double span) { if (!Double.isFinite(v)) { - return Color.gray(0.05, 0.0); // fully transparent for NaN/inf + 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) { - // Simple perceptual-ish ramp: deep blue -> cyan -> yellow -> near-white - return seqRamp(t); + 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 around 'divergingCenter': blue (<) -> white (0) -> red (>) + // Diverging: map sides with mirrored ramp from divScheme if (v <= divergingCenter) { double lt = (divergingCenter - v) / Math.max(1e-12, divergingCenter - min); // 0..1 - return lerpColor(Color.web("#ffffff"), Color.web("#2b6cb0"), clamp01(lt)); // white to blue + 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); - return lerpColor(Color.web("#ffffff"), Color.web("#c53030"), clamp01(rt)); // white to red + rt = Math.pow(clamp01(rt), gamma); + Color side = rampColorOpposite(divScheme, rt); // far high -> opposite end + return lerpColor(Color.web("#ffffff"), side, clamp01(rt)); } } } - private static Color seqRamp(double t) { - // Blend: #0b3d91 (navy) -> #00bcd4 (cyan) -> #ffeb3b (yellow) -> #ffffff - if (t < 0.33) { - double k = t / 0.33; - return lerpColor(Color.web("#0b3d91"), Color.web("#00bcd4"), k); - } else if (t < 0.67) { - double k = (t - 0.33) / 0.34; - return lerpColor(Color.web("#00bcd4"), Color.web("#ffeb3b"), k); - } else { - double k = (t - 0.67) / 0.33; - return lerpColor(Color.web("#ffeb3b"), Color.web("#ffffff"), k); + /** 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; @@ -331,9 +392,6 @@ private static Color lerpColor(Color a, Color b, double t) { return new Color(r, g, bl, al); } - private static double clamp01(double x) { - if (x < 0) return 0; - if (x > 1) return 1; - return x; - } + private static double clamp01(double x) { return (x < 0) ? 0 : (x > 1 ? 1 : x); } + private static double clamp(double x, double lo, double hi) { return (x < lo) ? lo : (x > hi ? hi : x); } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java index 2efec5c1..5b291637 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -9,13 +9,19 @@ import java.util.function.Consumer; import java.util.function.Predicate; +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; @@ -35,7 +41,14 @@ * Responsive grid of heatmap thumbnails that wraps on resize without * forcing the parent to expand. * - * New: Header slot via {@link #setHeader(Node)} to host controls like Search/Sort. + * 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) --- @@ -151,6 +164,28 @@ public CellClick(int index, PairItem item) { } } + /** 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(); @@ -179,6 +214,22 @@ public CellClick(int index, PairItem item) { private boolean showScoresInHeader = true; private Consumer onCellClick = null; + // New: 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() { @@ -248,7 +299,7 @@ public void setPreferredViewport(double width, double height) { scroller.setPrefViewportHeight(Math.max(0, height)); } - // ---------- Public API ---------- + // ---------- Public API (items) ---------- public void setItems(List items) { allItems.clear(); @@ -296,7 +347,7 @@ public void addItemStreaming(PairItem item, int flushN, int flushMs, boolean inc if (schedule) { final boolean doSort = incrementalSort; - javafx.application.Platform.runLater(() -> { + Platform.runLater(() -> { List toAdd; synchronized (streamLock) { toAdd = new ArrayList<>(pendingBuffer); @@ -373,6 +424,93 @@ 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 row = g.get(r); + if (row == null) continue; + for (int c=0; c 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() { @@ -422,13 +560,14 @@ private Node buildCell(int index, PairItem item) { } Label header = new Label(title); header.setPadding(new Insets(2, 4, 2, 4)); - // leave header unstyled (no CSS classes), per app-wide styling policy 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); @@ -436,22 +575,17 @@ private Node buildCell(int index, PairItem item) { view.useSequentialPalette(); } view.setShowLegend(item.showLegend); - view.setFlipY(item.flipY); - - if (item.autoRange) { - view.setAutoRange(true); - } else { - double mn = item.vmin == null ? 0.0 : item.vmin; - double mx = item.vmax == null ? 1.0 : item.vmax; - view.setFixedRange(mn, mx); - } + // 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); @@ -461,7 +595,7 @@ private Node buildCell(int index, PairItem item) { center.setPadding(cellPadding); cell.setCenter(center); - // keep programmatic border (no CSS classes used) + // keep programmatic border (no inline CSS classes) cell.setBorder(new Border(new BorderStroke( Color.web("#3A3A3A"), BorderStrokeStyle.SOLID, @@ -470,12 +604,156 @@ private Node buildCell(int index, PairItem item) { ))); 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); + } } From 75139f1668a2a203fe63ef91c86cf1d1ad698862 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 7 Oct 2025 11:11:35 -0400 Subject: [PATCH 44/50] Statistics pane now supports popout. --- src/main/java/edu/jhuapl/trinity/App.java | 4 +- .../edu/jhuapl/trinity/AppAsyncManager.java | 20 +- .../components/panes/StatPdfCdfPane.java | 21 +- .../StatPdfCdfPopoutController.java | 254 ++++++++++++++++++ .../javafx/events/ApplicationEvent.java | 1 + .../statistics/StatPdfCdfChartPanel.java | 205 ++++++++++++-- 6 files changed, 476 insertions(+), 29 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index f223fc55..13f85a64 100644 --- a/src/main/java/edu/jhuapl/trinity/App.java +++ b/src/main/java/edu/jhuapl/trinity/App.java @@ -340,8 +340,8 @@ private void keyReleased(Stage stage, KeyEvent e) { } //J cuz i'm running out of letters if (e.isAltDown() && e.isControlDown() && e.getCode().equals(KeyCode.J)) { - stage.getScene().getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.SHOW_STATISTICS_PANE)); + 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( diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index a9e983d3..9690fbcb 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -103,6 +103,7 @@ import edu.jhuapl.trinity.javafx.components.panes.PairwiseMatrixPane; import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; import edu.jhuapl.trinity.javafx.controllers.PairwiseJpdfPanePopoutController; +import edu.jhuapl.trinity.javafx.controllers.StatPdfCdfPopoutController; /** * @author Sean Phillips @@ -162,6 +163,7 @@ public class AppAsyncManager extends Task { TimelineAnimation timelineAnimation; FeatureVectorManagerPopoutController fvPop; FeatureVectorManagerService fvService; + StatPdfCdfPopoutController statPop; public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, CircleProgressIndicator progress, Map namedParameters) { this.scene = scene; @@ -177,7 +179,8 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir } 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(() -> @@ -560,6 +563,21 @@ protected Void call() throws Exception { 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) 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 index 2a416dc3..4f408164 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java @@ -1,20 +1,23 @@ 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 java.util.List; 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 { @@ -45,7 +48,7 @@ public StatPdfCdfPane(Scene scene, Pane parent) { borderPane.setPadding(new Insets(8)); chartPanel = new StatPdfCdfChartPanel(); // empty state - chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); + chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); borderPane.setCenter(chartPanel); } @@ -74,10 +77,17 @@ public StatPdfCdfPane( borderPane.setPadding(new Insets(8)); chartPanel = new StatPdfCdfChartPanel(vectors, scalarType, bins); - chartPanel.setOnComputeSurface(result -> onComputeSurface(result)); + 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(); @@ -101,8 +111,9 @@ private void onComputeSurface(GridDensityResult result) { label ) ); - } + } } + public void setFeatureVectors(List vectors) { chartPanel.setFeatureVectors(vectors); } 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..e816ed75 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java @@ -0,0 +1,254 @@ +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 java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.prefs.Preferences; +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; + +/** + * 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 7b1baef5..84db70d2 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -44,6 +44,7 @@ public class ApplicationEvent extends Event { 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"); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index 2a2b1156..2dfca617 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -4,7 +4,9 @@ import edu.jhuapl.trinity.utils.metric.Metric; import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -25,12 +27,12 @@ * Chart panel for visualizing PDF and CDF statistics (2D) and * triggering a 3D joint PDF/CDF surface computation. * - * Adds "Precomputed Scalars" data source support: - * - Paste rows of "score" or "score, infoPercent" (both in [0,1]) - * - Choose which field (Score or Info%) to visualize - * - Axis lock control to fix X to [0,1] for consistent comparisons + * Snapshot API: + * - exportState(): capture current UI/data settings + * - applyState(State): apply a previously captured snapshot * - * Vectors mode remains unchanged; 3D surface is enabled only in Vectors mode. + * The snapshot is used to keep the pane and a popout window in sync + * without reparenting JavaFX nodes. * * @author Sean Phillips */ @@ -72,7 +74,6 @@ private enum DataSource { VECTORS, SCALARS } private ToggleGroup surfaceToggle; private Button refresh2DButton; - private Button popOutButton; private Button setCustomButton; private Button compute3DButton; @@ -83,7 +84,6 @@ private enum DataSource { VECTORS, SCALARS } private List customReferenceVector; // Callbacks - private Runnable onPopOut = null; private Consumer onComputeSurface = null; public StatPdfCdfChartPanel() { @@ -148,14 +148,12 @@ public StatPdfCdfChartPanel(List initialVectors, binsSpinner.setPrefHeight(40); refresh2DButton = new Button("Refresh 2D"); - popOutButton = new Button("Pop Out"); HBox row1Vectors = new HBox( 16, new VBox(5, new Label("X Feature (2D)"), xFeatureCombo), new VBox(5, new Label("Bins"), binsSpinner), - refresh2DButton, - popOutButton + refresh2DButton ); row1Vectors.setAlignment(Pos.CENTER_LEFT); row1Vectors.setPadding(new Insets(6, 6, 0, 6)); @@ -163,8 +161,11 @@ public StatPdfCdfChartPanel(List initialVectors, // ===== Row 2 (Vectors): X metric/ref/index ===== 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)); + if (metricCombo.getItems().contains("euclidean")) { + metricCombo.setValue("euclidean"); + } else if (!metricCombo.getItems().isEmpty()) { + metricCombo.setValue(metricCombo.getItems().get(0)); + } referenceCombo = new ComboBox<>(); referenceCombo.getItems().addAll("Mean", "Vector @ Index", "Custom"); @@ -267,12 +268,9 @@ public StatPdfCdfChartPanel(List initialVectors, row3Vectors.setVisible(!usingScalars); if (usingScalars) { - // Switch charts to raw scalar mode applyScalarSamplesToCharts(); - // Disable 3D compute in scalars mode compute3DButton.setDisable(true); } else { - // Back to Vectors mode pdfChart.clearScalarSamples(); cdfChart.clearScalarSamples(); compute3DButton.setDisable(false); @@ -301,10 +299,8 @@ public StatPdfCdfChartPanel(List initialVectors, // Vectors handlers refresh2DButton.setOnAction(e -> refresh2DCharts()); - popOutButton.setOnAction(e -> { if (onPopOut != null) onPopOut.run(); }); setCustomButton.setOnAction(e -> { - // Optional: enforce expected dimension Integer expectedDim = (currentVectors != null && !currentVectors.isEmpty()) ? currentVectors.get(0).getData().size() : null; List parsed = (expectedDim != null) @@ -352,8 +348,6 @@ public String getYFeatureTypeForDisplay() { return (type != null) ? type.name() : "N/A"; } - public void setOnPopOut(Runnable handler) { this.onPopOut = handler; } - /** Consumer invoked when "Compute 3D Surface" finishes successfully. */ public void setOnComputeSurface(Consumer handler) { this.onComputeSurface = handler; @@ -401,6 +395,176 @@ public void setCustomReferenceVector(List customVector) { } } + // ----------------- Snapshot API ----------------- + + /** + * Immutable snapshot of the panel's current state. Contains plain data only (no JavaFX nodes). + */ + public static final class State { + public final boolean usingScalarsMode; + + // Scalars mode + public final String scalarsField; // "Score" or "Info%" + public final List scalarScores; + public final List scalarInfos; + + // Shared + public final boolean lockXAxisToUnit; + + // Vectors mode + public final List vectors; + public final StatisticEngine.ScalarType xFeature; + public final int bins; + public final String metricName; // for METRIC_DISTANCE_TO_MEAN + public final String referenceMode; // "Mean" | "Vector @ Index" | "Custom" + public final Integer xIndex; // for component or vector index + public final StatisticEngine.ScalarType yFeature; + public final Integer yIndex; + public final boolean surfaceCDF; // true = CDF, false = PDF + public final List customReference; // optional + + public State( + boolean usingScalarsMode, + String scalarsField, + List scalarScores, + List scalarInfos, + boolean lockXAxisToUnit, + List vectors, + StatisticEngine.ScalarType xFeature, + int bins, + String metricName, + String referenceMode, + Integer xIndex, + StatisticEngine.ScalarType yFeature, + Integer yIndex, + boolean surfaceCDF, + List customReference) { + + this.usingScalarsMode = usingScalarsMode; + this.scalarsField = scalarsField; + this.scalarScores = scalarScores == null ? Collections.emptyList() : new ArrayList<>(scalarScores); + this.scalarInfos = scalarInfos == null ? Collections.emptyList() : new ArrayList<>(scalarInfos); + this.lockXAxisToUnit = lockXAxisToUnit; + + this.vectors = vectors == null ? Collections.emptyList() : new ArrayList<>(vectors); + this.xFeature = xFeature; + this.bins = bins; + this.metricName = metricName; + this.referenceMode = referenceMode; + this.xIndex = xIndex; + this.yFeature = yFeature; + this.yIndex = yIndex; + this.surfaceCDF = surfaceCDF; + this.customReference = customReference == null ? null : new ArrayList<>(customReference); + } + } + + /** Capture a snapshot of the current settings and data. */ + public State exportState() { + boolean usingScalars = dataSourceCombo.getValue() == DataSource.SCALARS; + + return new State( + usingScalars, + usingScalars ? scalarFieldCombo.getValue() : null, + usingScalars ? scalarScores : null, + usingScalars ? scalarInfos : null, + lockXCheck.isSelected(), + usingScalars ? null : currentVectors, + usingScalars ? null : xFeatureCombo.getValue(), + usingScalars ? 0 : binsSpinner.getValue(), + usingScalars ? null : metricCombo.getValue(), + usingScalars ? null : referenceCombo.getValue(), + usingScalars ? null : xIndexSpinner.getValue(), + usingScalars ? null : yFeatureCombo.getValue(), + usingScalars ? null : yIndexSpinner.getValue(), + cdfRadio3D.isSelected(), + usingScalars ? null : customReferenceVector + ); + } + + /** Apply a previously exported snapshot to this panel. */ + public void applyState(State s) { + Objects.requireNonNull(s, "state"); + + // Lock X axis (applied later to charts too) + lockXCheck.setSelected(s.lockXAxisToUnit); + + if (s.usingScalarsMode) { + // Switch to Scalars mode + dataSourceCombo.setValue(DataSource.SCALARS); + + // Scalars payload + scalarScores = new ArrayList<>(s.scalarScores); + scalarInfos = new ArrayList<>(s.scalarInfos); + scalarFieldCombo.setValue(s.scalarsField != null ? s.scalarsField : "Score"); + + // Apply to charts + applyScalarSamplesToCharts(); + } else { + // Switch to Vectors mode + dataSourceCombo.setValue(DataSource.VECTORS); + + // Vectors payload + currentVectors = s.vectors == null ? new ArrayList<>() : new ArrayList<>(s.vectors); + xFeatureCombo.setValue(s.xFeature != null ? s.xFeature : StatisticEngine.ScalarType.L1_NORM); + + int safeBins = (s.bins > 0) ? s.bins : 40; + if (binsSpinner.getValueFactory() == null) { + binsSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(5, 100, safeBins, 5)); + } else { + binsSpinner.getValueFactory().setValue(safeBins); + } + + if (s.metricName != null && !metricCombo.getItems().isEmpty()) { + if (metricCombo.getItems().contains(s.metricName)) { + metricCombo.setValue(s.metricName); + } else { + metricCombo.setValue(metricCombo.getItems().get(0)); + } + } + + if (s.referenceMode != null) { + referenceCombo.setValue(s.referenceMode); + } + + if (s.xIndex != null) { + if (xIndexSpinner.getValueFactory() == null) { + xIndexSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Math.max(0, getMaxDimensionIndex()), s.xIndex, 1)); + } else { + xIndexSpinner.getValueFactory().setValue(Math.max(0, s.xIndex)); + } + } + + if (s.yFeature != null) { + yFeatureCombo.setValue(s.yFeature); + } + if (s.yIndex != null) { + if (yIndexSpinner.getValueFactory() == null) { + yIndexSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Math.max(0, getMaxDimensionIndex()), s.yIndex, 1)); + } else { + yIndexSpinner.getValueFactory().setValue(Math.max(0, s.yIndex)); + } + } + + customReferenceVector = s.customReference == null ? null : new ArrayList<>(s.customReference); + + // Surface toggle + if (s.surfaceCDF) { + cdfRadio3D.setSelected(true); + } else { + pdfRadio3D.setSelected(true); + } + + // Refresh charts under current settings + refresh2DCharts(); + } + + // Ensure lock applied to both charts after any mode switch + boolean lock = lockXCheck.isSelected(); + pdfChart.setLockXAxis(lock, 0.0, 1.0); + cdfChart.setLockXAxis(lock, 0.0, 1.0); + } + // ----------------- Scalars helpers ----------------- private void applyScalarSamplesToCharts() { @@ -416,7 +580,6 @@ private void applyScalarSamplesToCharts() { pdfChart.setScalarSamples(chosen); cdfChart.setScalarSamples(chosen); - // Respect lock checkbox boolean lock = lockXCheck.isSelected(); pdfChart.setLockXAxis(lock, 0.0, 1.0); cdfChart.setLockXAxis(lock, 0.0, 1.0); @@ -533,7 +696,7 @@ private void compute3DSurface() { yAxis.setComponentIndex(yIndexSpinner.getValue()); } else if (yAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { yAxis.setMetricName(metricCombo.getValue()); - List ref = (referenceCombo.getValue().equals("Custom")) + List ref = ("Custom".equals(referenceCombo.getValue())) ? customReferenceVector : (currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors)); if ("Vector @ Index".equals(referenceCombo.getValue())) { From 177f284ed8fd974d9fb3dc385ad1871dbf2adc31 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 8 Oct 2025 12:03:44 -0400 Subject: [PATCH 45/50] fixed submenu system to be streamlined without causing exceptions. --- .../utils/statistics/StatPdfCdfChart.java | 3 +- .../statistics/StatPdfCdfChartPanel.java | 800 ++++++++---------- 2 files changed, 348 insertions(+), 455 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index 067581ca..d984ce89 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -56,7 +56,8 @@ public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mod setTitle(titleForCurrentState()); } - // ---- configuration (unchanged) ---- + // ---- configuration ---- + public void setScalarType(StatisticEngine.ScalarType scalarType) { this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; setTitle(titleForCurrentState()); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index 2dfca617..de2a376c 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -4,78 +4,81 @@ import edu.jhuapl.trinity.utils.metric.Metric; import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.function.Consumer; 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.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; /** - * Chart panel for visualizing PDF and CDF statistics (2D) and + * Compact chart panel for visualizing PDF and CDF statistics (2D) and * triggering a 3D joint PDF/CDF surface computation. * - * Snapshot API: - * - exportState(): capture current UI/data settings - * - applyState(State): apply a previously captured snapshot + * Top row (always visible): + * - X Feature (2D), Bins, Refresh 2D, Options (MenuButton) * - * The snapshot is used to keep the pane and a popout window in sync - * without reparenting JavaFX nodes. + * Options menu contains: + * - Scalars mode (Score/Info% + Paste/Clear) + * - Lock X to [0,1] + * - Metric/Reference/Index (for X) + * - Y Feature / Y Index + * - Surface type (PDF/CDF) + * - Compute 3D Surface + * + * State is still exportable/apply-able for popout workflows. + * + * NOTE: No "Pop Out" button here — maximize/popout comes from the parent pane. * * @author Sean Phillips */ public class StatPdfCdfChartPanel extends BorderPane { - // Charts + + // ===== Charts ===== private StatPdfCdfChart pdfChart; private StatPdfCdfChart cdfChart; - // Data source mode + // ===== Data source mode ===== private enum DataSource { VECTORS, SCALARS } - private ComboBox dataSourceCombo; + private DataSource dataSource = DataSource.VECTORS; - // --- Scalars mode state/UI --- + // Scalars mode state/UI private List scalarScores = new ArrayList<>(); private List scalarInfos = new ArrayList<>(); - private ComboBox scalarFieldCombo; // "Score" or "Info%" - private Button pasteScalarsButton; - private Button clearScalarsButton; + private String scalarField = "Score"; // "Score" or "Info%" - // Axis lock (both modes) - private CheckBox lockXCheck; // lock to [0,1] + // Axis lock + private boolean lockXToUnit = false; - // --- Vectors mode controls/state --- + // Vectors mode controls/state (kept as fields; embedded into Options menu items) private ComboBox xFeatureCombo; private Spinner binsSpinner; private ComboBox metricCombo; - private ComboBox referenceCombo; // "Mean", "Vector @ Index", "Custom" + private String referenceMode = "Mean"; // "Mean", "Vector @ Index", "Custom" private Spinner xIndexSpinner; - private Label xIndexLabel; - // Y-feature (for 3D) private ComboBox yFeatureCombo; private Spinner yIndexSpinner; - private Label yIndexLabel; - - private RadioButton pdfRadio3D; - private RadioButton cdfRadio3D; - private ToggleGroup surfaceToggle; - private Button refresh2DButton; - private Button setCustomButton; - private Button compute3DButton; + private boolean surfaceCDF = false; // false=PDF, true=CDF // Data/state private List currentVectors = new ArrayList<>(); @@ -86,6 +89,8 @@ private enum DataSource { VECTORS, SCALARS } // Callbacks private Consumer onComputeSurface = null; + // ===== Constructor ===== + public StatPdfCdfChartPanel() { this(null, StatisticEngine.ScalarType.L1_NORM, 40); } @@ -97,149 +102,46 @@ public StatPdfCdfChartPanel(List initialVectors, if (initialType != null) currentXFeature = initialType; if (initialBins > 0) currentBins = initialBins; - // ===== Row 0: Data source + Axis Lock ===== - dataSourceCombo = new ComboBox<>(); - dataSourceCombo.getItems().addAll(DataSource.VECTORS, DataSource.SCALARS); - dataSourceCombo.setValue(DataSource.VECTORS); - - lockXCheck = new CheckBox("Lock X to [0,1]"); - lockXCheck.setSelected(false); - lockXCheck.selectedProperty().addListener((obs, ov, nv) -> { - if (nv) { - if (pdfChart != null) pdfChart.setLockXAxis(true, 0.0, 1.0); - if (cdfChart != null) cdfChart.setLockXAxis(true, 0.0, 1.0); - } else { - if (pdfChart != null) pdfChart.setLockXAxis(false, 0.0, 1.0); - if (cdfChart != null) cdfChart.setLockXAxis(false, 0.0, 1.0); - } - }); - - HBox row0 = new HBox(16, - new VBox(5, new Label("Data Source"), dataSourceCombo), - new VBox(18, new Label(""), lockXCheck) - ); - row0.setAlignment(Pos.CENTER_LEFT); - row0.setPadding(new Insets(6, 6, 0, 6)); - - // ===== Row 1: Scalars controls (hidden in Vectors mode) ===== - scalarFieldCombo = new ComboBox<>(); - scalarFieldCombo.getItems().addAll("Score", "Info%"); - scalarFieldCombo.setValue("Score"); - - pasteScalarsButton = new Button("Paste Scalars…"); - clearScalarsButton = new Button("Clear Scalars"); - - HBox rowScalars = new HBox( - 16, - new VBox(5, new Label("Field (2D)"), scalarFieldCombo), - pasteScalarsButton, - clearScalarsButton - ); - rowScalars.setAlignment(Pos.CENTER_LEFT); - rowScalars.setPadding(new Insets(4, 6, 0, 6)); - - // ===== Row 1 (Vectors): X feature + bins + actions ===== + // --- Visible row: X Feature (2D), Bins, 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(40); - - refresh2DButton = new Button("Refresh 2D"); - - HBox row1Vectors = new HBox( - 16, - new VBox(5, new Label("X Feature (2D)"), xFeatureCombo), - new VBox(5, new Label("Bins"), binsSpinner), - refresh2DButton - ); - row1Vectors.setAlignment(Pos.CENTER_LEFT); - row1Vectors.setPadding(new Insets(6, 6, 0, 6)); - - // ===== Row 2 (Vectors): X metric/ref/index ===== - 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)); - } + binsSpinner.setPrefHeight(34); - referenceCombo = new ComboBox<>(); - referenceCombo.getItems().addAll("Mean", "Vector @ Index", "Custom"); - referenceCombo.setValue("Mean"); - - xIndexLabel = new Label("X Index"); - xIndexSpinner = new Spinner<>(); - xIndexSpinner.setPrefWidth(100); + Button refresh2DButton = new Button("Refresh 2D"); + refresh2DButton.setOnAction(e -> refresh2DCharts()); - setCustomButton = new Button("Set Custom Vector…"); + MenuButton optionsMenu = buildOptionsMenu(); - HBox row2Vectors = new HBox( - 16, - new VBox(5, new Label("Metric (X)"), metricCombo), - new VBox(5, new Label("Reference (X)"), referenceCombo), - new VBox(5, xIndexLabel, xIndexSpinner), - setCustomButton + HBox topBar = new HBox(12, + new VBox(2, new Label("X Feature (2D)"), xFeatureCombo), + new VBox(2, new Label("Bins"), binsSpinner), + refresh2DButton, + optionsMenu ); - row2Vectors.setAlignment(Pos.CENTER_LEFT); - row2Vectors.setPadding(new Insets(4, 6, 0, 6)); - - // ===== Row 3 (Vectors): Y feature + Y index + 3D surface ===== - yFeatureCombo = new ComboBox<>(); - yFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); - yFeatureCombo.setValue(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); - - yIndexLabel = new Label("Y Index"); - yIndexSpinner = new Spinner<>(); - yIndexSpinner.setPrefWidth(100); - - surfaceToggle = new ToggleGroup(); - pdfRadio3D = new RadioButton("PDF"); - pdfRadio3D.setToggleGroup(surfaceToggle); - pdfRadio3D.setSelected(true); - cdfRadio3D = new RadioButton("CDF"); - cdfRadio3D.setToggleGroup(surfaceToggle); - - HBox surfaceBox = new HBox(8, pdfRadio3D, cdfRadio3D); - surfaceBox.setAlignment(Pos.CENTER_LEFT); - - compute3DButton = new Button("Compute 3D Surface"); - - HBox row3Vectors = new HBox( - 16, - new VBox(5, new Label("Y Feature (3D)"), yFeatureCombo), - new VBox(5, yIndexLabel, yIndexSpinner), - new VBox(5, new Label("Surface"), surfaceBox), - compute3DButton - ); - row3Vectors.setAlignment(Pos.CENTER_LEFT); - row3Vectors.setPadding(new Insets(4, 6, 6, 6)); - - // Grow hints - HBox.setHgrow(row0, Priority.ALWAYS); - HBox.setHgrow(rowScalars, Priority.ALWAYS); - HBox.setHgrow(row1Vectors, Priority.ALWAYS); - HBox.setHgrow(row2Vectors, Priority.ALWAYS); - HBox.setHgrow(row3Vectors, Priority.ALWAYS); + topBar.setAlignment(Pos.CENTER_LEFT); + topBar.setPadding(new Insets(6, 8, 4, 8)); + HBox.setHgrow(optionsMenu, Priority.NEVER); - // Control area (we toggle rows depending on data source) - VBox controlBar = new VBox(row0, rowScalars, row1Vectors, row2Vectors, row3Vectors); - controlBar.setPadding(new Insets(0)); - - // ===== Charts: PDF (top), CDF (bottom) ===== + // --- Charts: PDF (top), CDF (bottom) --- pdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); cdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); + // modest sizing so the pane doesn't inflate vertically + pdfChart.setMinHeight(120); + cdfChart.setMinHeight(120); + pdfChart.setPrefHeight(220); + cdfChart.setPrefHeight(220); + if (!currentVectors.isEmpty()) { applyXFeatureOptions(); pdfChart.setFeatureVectors(currentVectors); cdfChart.setFeatureVectors(currentVectors); } - - if (lockXCheck.isSelected()) { + if (lockXToUnit) { pdfChart.setLockXAxis(true, 0.0, 1.0); cdfChart.setLockXAxis(true, 0.0, 1.0); } @@ -248,94 +150,188 @@ public StatPdfCdfChartPanel(List initialVectors, chartsBox.setPadding(new Insets(6)); VBox.setVgrow(pdfChart, Priority.ALWAYS); VBox.setVgrow(cdfChart, Priority.ALWAYS); + chartsBox.setFillWidth(true); + chartsBox.setPrefHeight(Region.USE_COMPUTED_SIZE); - setTop(controlBar); + setTop(topBar); setCenter(chartsBox); - // ===== Handlers ===== - - // Data source toggle - dataSourceCombo.valueProperty().addListener((obs, ov, nv) -> { - boolean usingScalars = (nv == DataSource.SCALARS); - rowScalars.setManaged(usingScalars); - rowScalars.setVisible(usingScalars); - - row1Vectors.setManaged(!usingScalars); - row1Vectors.setVisible(!usingScalars); - row2Vectors.setManaged(!usingScalars); - row2Vectors.setVisible(!usingScalars); - row3Vectors.setManaged(!usingScalars); - row3Vectors.setVisible(!usingScalars); - - if (usingScalars) { - applyScalarSamplesToCharts(); - compute3DButton.setDisable(true); - } else { - pdfChart.clearScalarSamples(); - cdfChart.clearScalarSamples(); - compute3DButton.setDisable(false); - refresh2DCharts(); - } - }); - // Initialize visibility (Vectors by default) - rowScalars.setManaged(false); - rowScalars.setVisible(false); - - // Scalars handlers - pasteScalarsButton.setOnAction(e -> { - ScalarInputResult res = DialogUtils.showScalarSamplesDialog(); - if (res == null) return; // cancelled - if (res.scores != null && !res.scores.isEmpty()) scalarScores = res.scores; - if (res.infos != null && !res.infos.isEmpty()) scalarInfos = res.infos; - applyScalarSamplesToCharts(); - }); - clearScalarsButton.setOnAction(e -> { - scalarScores.clear(); - scalarInfos.clear(); - pdfChart.clearScalarSamples(); - cdfChart.clearScalarSamples(); - }); - scalarFieldCombo.valueProperty().addListener((obs, ov, nv) -> applyScalarSamplesToCharts()); - - // Vectors handlers - refresh2DButton.setOnAction(e -> refresh2DCharts()); - - 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); - }); - - compute3DButton.setOnAction(e -> compute3DSurface()); - + // --- Handlers for the always-visible controls --- xFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { currentXFeature = nv; updateXControlEnablement(); - updateXIndexBoundsAndLabel(); - }); - referenceCombo.valueProperty().addListener((obs, ov, nv) -> { - updateXControlEnablement(); - updateXIndexBoundsAndLabel(); - }); - yFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { - updateYControlEnablement(); - updateYIndexBoundsAndLabel(); + updateXIndexBoundsAndValue(); }); + } - // initialize states - updateXControlEnablement(); - updateYControlEnablement(); - updateXIndexBoundsAndLabel(); - updateYIndexBoundsAndLabel(); + // ===== Options menu (all the advanced/secondary controls) ===== +private MenuButton buildOptionsMenu() { + MenuButton mb = new MenuButton("Options"); + + // --- Scalars / 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); + }); + + // Scalars submenu (no popup children here, so submenu is fine) + 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(); + }); + + scalarsMenu.getItems().addAll(scoreItem, infoItem, new SeparatorMenuItem(), pasteScalars, clearScalars); + + // ---- Metric / Reference (X) custom section (top-level CustomMenuItem) ---- + 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)); } - // ----------------- Public API ----------------- + ComboBox referenceCombo = new ComboBox<>(); + referenceCombo.getItems().addAll("Mean", "Vector @ Index", "Custom"); + referenceCombo.setValue(referenceMode); + referenceCombo.valueProperty().addListener((obs, ov, nv) -> { + referenceMode = nv; + updateXControlEnablement(); + updateXIndexBoundsAndValue(); + }); + + xIndexSpinner = new Spinner<>(); + xIndexSpinner.setPrefWidth(100); + + 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), + new HBox(8, new Label("X Index"), xIndexSpinner), + 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 custom section (top-level CustomMenuItem) ---- + 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); + + 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); + + // ---- Final, single assembly (no duplicates, no later mutations) ---- + mb.getItems().addAll( + useScalars, + lockX, + new SeparatorMenuItem(), + scalarsMenu, + new SeparatorMenuItem(), + metricHeader, + metricCustom, + new SeparatorMenuItem(), + surfaceHeader, + surfaceCustom + ); + + // initialize enablement/bounds now that controls exist + updateXControlEnablement(); + updateYControlEnablement(); + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + + return mb; +} + // ===== Public API ===== public boolean isSurfaceCDF() { - return cdfRadio3D.isSelected(); + return surfaceCDF; } public String getYFeatureTypeForDisplay() { @@ -355,9 +351,9 @@ public void setOnComputeSurface(Consumer handler) { public void setFeatureVectors(List vectors) { this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); - updateXIndexBoundsAndLabel(); - updateYIndexBoundsAndLabel(); - if (dataSourceCombo.getValue() == DataSource.VECTORS) { + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + if (dataSource == DataSource.VECTORS) { applyXFeatureOptions(); pdfChart.setFeatureVectors(this.currentVectors); cdfChart.setFeatureVectors(this.currentVectors); @@ -368,10 +364,9 @@ 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); - - updateXIndexBoundsAndLabel(); - updateYIndexBoundsAndLabel(); - if (dataSourceCombo.getValue() == DataSource.VECTORS) { + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); + if (dataSource == DataSource.VECTORS) { applyXFeatureOptions(); pdfChart.addFeatureVectors(newVectors); cdfChart.addFeatureVectors(newVectors); @@ -385,193 +380,96 @@ public void addFeatureVectors(List newVectors) { public void setCustomReferenceVector(List customVector) { this.customReferenceVector = customVector; - if (dataSourceCombo.getValue() == DataSource.VECTORS - && xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN - && "Custom".equals(referenceCombo.getValue()) - && !currentVectors.isEmpty()) { + if (dataSource == DataSource.VECTORS + && xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN + && "Custom".equals(referenceMode) + && !currentVectors.isEmpty()) { applyXFeatureOptions(); pdfChart.setFeatureVectors(currentVectors); cdfChart.setFeatureVectors(currentVectors); } } - // ----------------- Snapshot API ----------------- + // ===== State (for popout workflows) ===== - /** - * Immutable snapshot of the panel's current state. Contains plain data only (no JavaFX nodes). - */ public static final class State { - public final boolean usingScalarsMode; - - // Scalars mode - public final String scalarsField; // "Score" or "Info%" - public final List scalarScores; - public final List scalarInfos; - - // Shared - public final boolean lockXAxisToUnit; - - // Vectors mode - public final List vectors; - public final StatisticEngine.ScalarType xFeature; - public final int bins; - public final String metricName; // for METRIC_DISTANCE_TO_MEAN - public final String referenceMode; // "Mean" | "Vector @ Index" | "Custom" - public final Integer xIndex; // for component or vector index - public final StatisticEngine.ScalarType yFeature; - public final Integer yIndex; - public final boolean surfaceCDF; // true = CDF, false = PDF - public final List customReference; // optional - - public State( - boolean usingScalarsMode, - String scalarsField, - List scalarScores, - List scalarInfos, - boolean lockXAxisToUnit, - List vectors, - StatisticEngine.ScalarType xFeature, - int bins, - String metricName, - String referenceMode, - Integer xIndex, - StatisticEngine.ScalarType yFeature, - Integer yIndex, - boolean surfaceCDF, - List customReference) { - - this.usingScalarsMode = usingScalarsMode; - this.scalarsField = scalarsField; - this.scalarScores = scalarScores == null ? Collections.emptyList() : new ArrayList<>(scalarScores); - this.scalarInfos = scalarInfos == null ? Collections.emptyList() : new ArrayList<>(scalarInfos); - this.lockXAxisToUnit = lockXAxisToUnit; - - this.vectors = vectors == null ? Collections.emptyList() : new ArrayList<>(vectors); - this.xFeature = xFeature; - this.bins = bins; - this.metricName = metricName; - this.referenceMode = referenceMode; - this.xIndex = xIndex; - this.yFeature = yFeature; - this.yIndex = yIndex; - this.surfaceCDF = surfaceCDF; - this.customReference = customReference == null ? null : new ArrayList<>(customReference); - } + 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 } - /** Capture a snapshot of the current settings and data. */ public State exportState() { - boolean usingScalars = dataSourceCombo.getValue() == DataSource.SCALARS; - - return new State( - usingScalars, - usingScalars ? scalarFieldCombo.getValue() : null, - usingScalars ? scalarScores : null, - usingScalars ? scalarInfos : null, - lockXCheck.isSelected(), - usingScalars ? null : currentVectors, - usingScalars ? null : xFeatureCombo.getValue(), - usingScalars ? 0 : binsSpinner.getValue(), - usingScalars ? null : metricCombo.getValue(), - usingScalars ? null : referenceCombo.getValue(), - usingScalars ? null : xIndexSpinner.getValue(), - usingScalars ? null : yFeatureCombo.getValue(), - usingScalars ? null : yIndexSpinner.getValue(), - cdfRadio3D.isSelected(), - usingScalars ? null : customReferenceVector - ); + 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); + return s; } - /** Apply a previously exported snapshot to this panel. */ public void applyState(State s) { - Objects.requireNonNull(s, "state"); + 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); - // Lock X axis (applied later to charts too) - lockXCheck.setSelected(s.lockXAxisToUnit); + if (yFeatureCombo != null && s.yType != null) yFeatureCombo.setValue(s.yType); + if (yIndexSpinner != null && s.yIndex != null) yIndexSpinner.getValueFactory().setValue(s.yIndex); - if (s.usingScalarsMode) { - // Switch to Scalars mode - dataSourceCombo.setValue(DataSource.SCALARS); + 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; - // Scalars payload - scalarScores = new ArrayList<>(s.scalarScores); - scalarInfos = new ArrayList<>(s.scalarInfos); - scalarFieldCombo.setValue(s.scalarsField != null ? s.scalarsField : "Score"); + // Apply visuals + updateXControlEnablement(); + updateYControlEnablement(); + updateXIndexBoundsAndValue(); + updateYIndexBoundsAndValue(); - // Apply to charts + if (dataSource == DataSource.SCALARS) { applyScalarSamplesToCharts(); } else { - // Switch to Vectors mode - dataSourceCombo.setValue(DataSource.VECTORS); - - // Vectors payload - currentVectors = s.vectors == null ? new ArrayList<>() : new ArrayList<>(s.vectors); - xFeatureCombo.setValue(s.xFeature != null ? s.xFeature : StatisticEngine.ScalarType.L1_NORM); - - int safeBins = (s.bins > 0) ? s.bins : 40; - if (binsSpinner.getValueFactory() == null) { - binsSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(5, 100, safeBins, 5)); - } else { - binsSpinner.getValueFactory().setValue(safeBins); - } - - if (s.metricName != null && !metricCombo.getItems().isEmpty()) { - if (metricCombo.getItems().contains(s.metricName)) { - metricCombo.setValue(s.metricName); - } else { - metricCombo.setValue(metricCombo.getItems().get(0)); - } - } - - if (s.referenceMode != null) { - referenceCombo.setValue(s.referenceMode); - } - - if (s.xIndex != null) { - if (xIndexSpinner.getValueFactory() == null) { - xIndexSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Math.max(0, getMaxDimensionIndex()), s.xIndex, 1)); - } else { - xIndexSpinner.getValueFactory().setValue(Math.max(0, s.xIndex)); - } - } - - if (s.yFeature != null) { - yFeatureCombo.setValue(s.yFeature); - } - if (s.yIndex != null) { - if (yIndexSpinner.getValueFactory() == null) { - yIndexSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, Math.max(0, getMaxDimensionIndex()), s.yIndex, 1)); - } else { - yIndexSpinner.getValueFactory().setValue(Math.max(0, s.yIndex)); - } - } - - customReferenceVector = s.customReference == null ? null : new ArrayList<>(s.customReference); - - // Surface toggle - if (s.surfaceCDF) { - cdfRadio3D.setSelected(true); - } else { - pdfRadio3D.setSelected(true); - } - - // Refresh charts under current settings refresh2DCharts(); } - - // Ensure lock applied to both charts after any mode switch - boolean lock = lockXCheck.isSelected(); - pdfChart.setLockXAxis(lock, 0.0, 1.0); - cdfChart.setLockXAxis(lock, 0.0, 1.0); } - // ----------------- Scalars helpers ----------------- + // ===== Scalars helpers ===== private void applyScalarSamplesToCharts() { - if (dataSourceCombo.getValue() != DataSource.SCALARS) return; - - List chosen = "Info%".equals(scalarFieldCombo.getValue()) ? scalarInfos : scalarScores; - + if (dataSource != DataSource.SCALARS) return; + List chosen = "Info%".equals(scalarField) ? scalarInfos : scalarScores; if (chosen == null || chosen.isEmpty()) { pdfChart.clearScalarSamples(); cdfChart.clearScalarSamples(); @@ -579,16 +477,14 @@ private void applyScalarSamplesToCharts() { } pdfChart.setScalarSamples(chosen); cdfChart.setScalarSamples(chosen); - - boolean lock = lockXCheck.isSelected(); - pdfChart.setLockXAxis(lock, 0.0, 1.0); - cdfChart.setLockXAxis(lock, 0.0, 1.0); + pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); } - // ----------------- 2D (Vectors) behavior ----------------- + // ===== 2D (Vectors) behavior ===== private void refresh2DCharts() { - if (dataSourceCombo.getValue() != DataSource.VECTORS) return; + if (dataSource != DataSource.VECTORS) return; currentXFeature = xFeatureCombo.getValue(); currentBins = binsSpinner.getValue(); @@ -606,9 +502,8 @@ private void refresh2DCharts() { cdfChart.setFeatureVectors(currentVectors); } - boolean lock = lockXCheck.isSelected(); - pdfChart.setLockXAxis(lock, 0.0, 1.0); - cdfChart.setLockXAxis(lock, 0.0, 1.0); + pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); } private void applyXFeatureOptions() { @@ -622,8 +517,8 @@ private void applyXFeatureOptions() { cdfChart.setComponentIndex(null); if (currentXFeature == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { - String metricName = metricCombo.getValue(); - List ref = switch (referenceCombo.getValue()) { + 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); @@ -639,48 +534,17 @@ private void applyXFeatureOptions() { } } - private void updateXControlEnablement() { - boolean isMetric = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; - boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; - - metricCombo.setDisable(!isMetric); - referenceCombo.setDisable(!isMetric); - - boolean indexEnabled = (isMetric && "Vector @ Index".equals(referenceCombo.getValue())) || isComponent; - xIndexSpinner.setDisable(!indexEnabled); - } - - private void updateXIndexBoundsAndLabel() { - boolean isMetricVectorIdx = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN - && "Vector @ Index".equals(referenceCombo.getValue()); - boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; - - if (isMetricVectorIdx) { - int maxIdx = Math.max(0, getMaxVectorIndex()); - setSpinnerBounds(xIndexSpinner, 0, maxIdx, safeSpinnerValue(xIndexSpinner, 0, maxIdx)); - xIndexLabel.setText("X Index (Vector)"); - } else if (isComponent) { - int maxDim = getMaxDimensionIndex(); - setSpinnerBounds(xIndexSpinner, 0, maxDim, safeSpinnerValue(xIndexSpinner, 0, maxDim)); - xIndexLabel.setText("X Index (Dimension)"); - } else { - int maxDim = getMaxDimensionIndex(); - setSpinnerBounds(xIndexSpinner, 0, Math.max(0, maxDim), 0); - xIndexLabel.setText("X Index"); - } - } - - // ----------------- 3D compute (Vectors only) ----------------- + // ===== 3D compute (Vectors only) ===== private void compute3DSurface() { if (onComputeSurface == null || currentVectors == null || currentVectors.isEmpty()) return; - if (dataSourceCombo.getValue() != DataSource.VECTORS) 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 (referenceCombo.getValue()) { + List ref = switch (referenceMode) { case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); case "Custom" -> customReferenceVector; default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); @@ -696,12 +560,11 @@ private void compute3DSurface() { yAxis.setComponentIndex(yIndexSpinner.getValue()); } else if (yAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { yAxis.setMetricName(metricCombo.getValue()); - List ref = ("Custom".equals(referenceCombo.getValue())) - ? customReferenceVector - : (currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors)); - if ("Vector @ Index".equals(referenceCombo.getValue())) { - ref = getVectorAtIndexAsList(xIndexSpinner.getValue()); - } + List ref = switch (referenceMode) { + case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); + case "Custom" -> customReferenceVector; + default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); + }; yAxis.setReferenceVec(ref); } @@ -711,25 +574,54 @@ private void compute3DSurface() { 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); + boolean indexEnabled = (isMetric && "Vector @ Index".equals(referenceMode)) || isComponent; + if (xIndexSpinner != null) xIndexSpinner.setDisable(!indexEnabled); + } + private void updateYControlEnablement() { boolean yNeedsDim = yFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; - yIndexSpinner.setDisable(!yNeedsDim); + 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 updateYIndexBoundsAndLabel() { + 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)); - yIndexLabel.setText("Y Index (Dimension)"); } else { int maxDim = getMaxDimensionIndex(); setSpinnerBounds(yIndexSpinner, 0, Math.max(0, maxDim), 0); - yIndexLabel.setText("Y Index"); } } - // ----------------- Utilities ----------------- + // ===== 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)); From 10120cfe4f7e63341230a304ce1f1a83f54a8dad Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Wed, 8 Oct 2025 20:22:15 -0400 Subject: [PATCH 46/50] Proper Time Series modes for Stats panel --- .../utils/statistics/StatPdfCdfChart.java | 110 ++- .../statistics/StatPdfCdfChartPanel.java | 898 ++++++++++++++---- .../utils/statistics/StatTimeSeriesChart.java | 209 ++++ .../utils/statistics/StatisticEngine.java | 290 +++++- .../utils/statistics/StatisticResult.java | 97 +- 5 files changed, 1358 insertions(+), 246 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index d984ce89..a8abf097 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -5,6 +5,11 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; + +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; @@ -34,9 +39,39 @@ public enum Mode { PDF_ONLY, CDF_ONLY } private boolean showSymbols = true; private double seriesStrokeWidth = 2.0; - // --- NEW: raw-sample override for 2D charts --- + // --- raw-sample override for 2D charts --- private List scalarSamples = null; // if non-null & non-empty, use this instead of vectors + // --- NEW: 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; @@ -57,7 +92,7 @@ public StatPdfCdfChart(StatisticEngine.ScalarType scalarType, int bins, Mode mod } // ---- configuration ---- - + public void setScalarType(StatisticEngine.ScalarType scalarType) { this.scalarType = (scalarType != null) ? scalarType : StatisticEngine.ScalarType.L1_NORM; setTitle(titleForCurrentState()); @@ -129,7 +164,7 @@ public void setSeriesStrokeWidth(double px) { }); } - // ---- NEW: raw samples API ---- + // ---- raw samples API ---- /** Use precomputed scalar samples instead of deriving scalars from FeatureVectors. */ public void setScalarSamples(List samples) { if (samples == null || samples.isEmpty()) { @@ -173,6 +208,7 @@ private String titleForCurrentState() { private void refresh() { getData().clear(); + lastStat = null; if (isUsingRawSamples()) { // Directly bin the provided samples @@ -198,6 +234,7 @@ private void refresh() { } private void plotFromStatistic(StatisticResult stat) { + lastStat = stat; if (stat == null) { applyAxisLock(); return; @@ -220,6 +257,8 @@ private void plotFromStatistic(StatisticResult stat) { series.getNode().setStyle("-fx-stroke-width: " + seriesStrokeWidth + "px;"); } applyAxisLock(); + + attachPlotInteractions(); } private void applyAxisLock() { @@ -238,4 +277,69 @@ private void applyAxisLock() { xAxis.setAutoRanging(true); } } + + // ===== NEW: 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 index de2a376c..66622ace 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -3,9 +3,15 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.metric.Metric; import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; + 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; + import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -18,6 +24,7 @@ import javafx.scene.control.MenuItem; import javafx.scene.control.RadioButton; import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.ScrollPane; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.Spinner; import javafx.scene.control.SpinnerValueFactory; @@ -29,31 +36,24 @@ import javafx.scene.layout.VBox; /** - * Compact chart panel for visualizing PDF and CDF statistics (2D) and - * triggering a 3D joint PDF/CDF surface computation. - * - * Top row (always visible): - * - X Feature (2D), Bins, Refresh 2D, Options (MenuButton) - * - * Options menu contains: - * - Scalars mode (Score/Info% + Paste/Clear) - * - Lock X to [0,1] - * - Metric/Reference/Index (for X) - * - Y Feature / Y Index - * - Surface type (PDF/CDF) - * - Compute 3D Surface - * - * State is still exportable/apply-able for popout workflows. + * Chart panel showing PDF, CDF, and a time-series chart (vertical stack), + * with linked interactions between the distribution charts and the time-series, + * label filtering, Contribution (Δ log-odds), Cumulative log-odds, and a + * Top-K contributors list ranked by |Δ log-odds|. * - * NOTE: No "Pop Out" button here — maximize/popout comes from the parent pane. - * - * @author Sean Phillips + * PDF/CDF hover/click -> highlights contributing samples in the time-series. + * Time-series hover shows sample index/value (and bin info in the readout). */ public class StatPdfCdfChartPanel extends BorderPane { + // ===== Layout constants ===== + private static final double RIGHT_PANE_WIDTH = 260.0; + private static final double TOPK_SPINNER_WIDTH = 72.0; + // ===== Charts ===== private StatPdfCdfChart pdfChart; private StatPdfCdfChart cdfChart; + private StatTimeSeriesChart tsChart; // ===== Data source mode ===== private enum DataSource { VECTORS, SCALARS } @@ -67,7 +67,7 @@ private enum DataSource { VECTORS, SCALARS } // Axis lock private boolean lockXToUnit = false; - // Vectors mode controls/state (kept as fields; embedded into Options menu items) + // Vectors mode controls/state private ComboBox xFeatureCombo; private Spinner binsSpinner; @@ -86,6 +86,38 @@ private enum DataSource { VECTORS, SCALARS } private int currentBins = 40; private List customReferenceVector; + // Time-series state + private List scalarSeries = new ArrayList<>(); + private boolean persistSelection = false; + + // Maintain persistent highlight indices locally (no addHighlights() needed on tsChart) + private final Set persistentHighlights = new LinkedHashSet<>(); + + // ===== Time-series mode: Similarity vs Contribution vs Cumulative ===== + private enum TSMode { SIMILARITY, CONTRIBUTION, CUMULATIVE } + private TSMode tsMode = TSMode.CONTRIBUTION; // default + private StatisticEngine.SimilarityAggregator agg = StatisticEngine.SimilarityAggregator.MEAN; + private final double contribEps = 1e-6; + + // Keep last contributions for Top-K list + private StatisticEngine.ContributionSeries lastContrib = null; + + // Readout + private final Label selectionInfo = new Label("—"); + + // ===== Label filter state & UI ===== + 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 + + // ===== Top-K contributors panel ===== + private VBox topKRoot; + private VBox topKListBox; + private Spinner topKSpinner; + private int topK = 5; + // Callbacks private Consumer onComputeSurface = null; @@ -102,7 +134,7 @@ public StatPdfCdfChartPanel(List initialVectors, if (initialType != null) currentXFeature = initialType; if (initialBins > 0) currentBins = initialBins; - // --- Visible row: X Feature (2D), Bins, Refresh, Options --- + // --- Visible row --- xFeatureCombo = new ComboBox<>(); xFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); xFeatureCombo.setValue(currentXFeature); @@ -126,37 +158,63 @@ public StatPdfCdfChartPanel(List initialVectors, topBar.setPadding(new Insets(6, 8, 4, 8)); HBox.setHgrow(optionsMenu, Priority.NEVER); - // --- Charts: PDF (top), CDF (bottom) --- + // --- Charts: PDF (top), CDF (middle), Time Series (bottom) --- pdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); cdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); + tsChart = new StatTimeSeriesChart(); - // modest sizing so the pane doesn't inflate vertically + // modest sizing pdfChart.setMinHeight(120); cdfChart.setMinHeight(120); + tsChart.setMinHeight(140); pdfChart.setPrefHeight(220); cdfChart.setPrefHeight(220); + tsChart.setPrefHeight(240); if (!currentVectors.isEmpty()) { + rebuildLabelsFromCurrentVectors(); // build labels before first render applyXFeatureOptions(); - pdfChart.setFeatureVectors(currentVectors); - cdfChart.setFeatureVectors(currentVectors); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); // populate TS based on tsMode } if (lockXToUnit) { pdfChart.setLockXAxis(true, 0.0, 1.0); cdfChart.setLockXAxis(true, 0.0, 1.0); } - VBox chartsBox = new VBox(8, pdfChart, cdfChart); + // linked interactions: PDF/CDF -> TS + wireChartInteractions(); + + // Bottom utility bar + Button clearBtn = new Button("Clear Highlights"); + clearBtn.setOnAction(e -> { + persistentHighlights.clear(); + 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)); + + VBox chartsBox = new VBox(8, pdfChart, cdfChart, tsChart, bottomBar); chartsBox.setPadding(new Insets(6)); VBox.setVgrow(pdfChart, Priority.ALWAYS); VBox.setVgrow(cdfChart, Priority.ALWAYS); + VBox.setVgrow(tsChart, Priority.ALWAYS); chartsBox.setFillWidth(true); chartsBox.setPrefHeight(Region.USE_COMPUTED_SIZE); setTop(topBar); setCenter(chartsBox); - // --- Handlers for the always-visible controls --- + // Right-side: Top-K contributors (fixed width) + setRight(buildTopKPane()); + + // handler xFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { currentXFeature = nv; updateXControlEnablement(); @@ -164,176 +222,324 @@ public StatPdfCdfChartPanel(List initialVectors, }); } - // ===== Options menu (all the advanced/secondary controls) ===== -private MenuButton buildOptionsMenu() { - MenuButton mb = new MenuButton("Options"); + // ===== 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(); + } + }); - // --- Scalars / 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) { + 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; + if (!persistSelection) persistentHighlights.clear(); + applyCurrentHighlights(); + }); + + // 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(); - } else { + }); + + MenuItem clearScalars = new MenuItem("Clear Scalars"); + clearScalars.setOnAction(e -> { + scalarScores.clear(); + scalarInfos.clear(); 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); - }); - - // Scalars submenu (no popup children here, so submenu is fine) - 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(); - }); - - scalarsMenu.getItems().addAll(scoreItem, infoItem, new SeparatorMenuItem(), pasteScalars, clearScalars); - - // ---- Metric / Reference (X) custom section (top-level CustomMenuItem) ---- - 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; + tsChart.clearHighlights(); + tsChart.setSeries(new ArrayList<>()); + selectionInfo.setText("—"); + lastContrib = null; + updateTopKList(); // clear list + }); + + scalarsMenu.getItems().addAll(scoreItem, infoItem, new SeparatorMenuItem(), pasteScalars, clearScalars); + + // Labels Filter submenu + labelsMenu = new Menu("Labels (Filter)"); + labelsMenu.getItems().add(new MenuItem("No labels found")); + + // Metric/Reference (X) section + 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(); + }); + + xIndexSpinner = new Spinner<>(); + xIndexSpinner.setPrefWidth(100); + + 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), + new HBox(8, new Label("X Index"), xIndexSpinner), + 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); + + 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/Cumulative)"); + 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(); - updateXIndexBoundsAndValue(); - }); - - xIndexSpinner = new Spinner<>(); - xIndexSpinner.setPrefWidth(100); - - 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), - new HBox(8, new Label("X Index"), xIndexSpinner), - 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 custom section (top-level CustomMenuItem) ---- - yFeatureCombo = new ComboBox<>(); - yFeatureCombo.getItems().addAll(StatisticEngine.ScalarType.values()); - yFeatureCombo.setValue(StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION); - yFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { updateYControlEnablement(); + updateXIndexBoundsAndValue(); updateYIndexBoundsAndValue(); - }); - - yIndexSpinner = new Spinner<>(); - yIndexSpinner.setPrefWidth(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); - - // ---- Final, single assembly (no duplicates, no later mutations) ---- - mb.getItems().addAll( - useScalars, - lockX, - new SeparatorMenuItem(), - scalarsMenu, - new SeparatorMenuItem(), - metricHeader, - metricCustom, - new SeparatorMenuItem(), - surfaceHeader, - surfaceCustom - ); - - // initialize enablement/bounds now that controls exist - updateXControlEnablement(); - updateYControlEnablement(); - updateXIndexBoundsAndValue(); - updateYIndexBoundsAndValue(); - - return mb; -} - // ===== Public API ===== - public boolean isSurfaceCDF() { - return surfaceCDF; + return mb; + } + + // ===== Top-K Pane (fixed width) ===== + private VBox buildTopKPane() { + Label title = new Label("Top-K Contributors"); + + topKSpinner = new Spinner<>(1, 100, topK, 1); + topKSpinner.setEditable(false); + // Fix spinner width so it doesn't stretch the parent + topKSpinner.setMinWidth(TOPK_SPINNER_WIDTH); + topKSpinner.setPrefWidth(TOPK_SPINNER_WIDTH); + topKSpinner.setMaxWidth(TOPK_SPINNER_WIDTH); + // Optional: constrain editor text columns (even though not editable) + if (topKSpinner.getEditor() != null) { + topKSpinner.getEditor().setPrefColumnCount(3); + } + topKSpinner.valueProperty().addListener((obs, ov, nv) -> { + topK = (nv != null) ? nv : topK; + updateTopKList(); + }); + + HBox header = new HBox(8, title, new Region(), new Label("K:"), topKSpinner); + HBox.setHgrow(header.getChildren().get(1), Priority.ALWAYS); + header.setAlignment(Pos.CENTER_LEFT); + + topKListBox = new VBox(4); + + ScrollPane scroll = new ScrollPane(topKListBox); + // Grow vertically with parent; let the viewport width be respected + VBox.setVgrow(scroll, Priority.ALWAYS); + scroll.setFitToWidth(true); + scroll.setFitToHeight(true); + + topKRoot = new VBox(8, header, scroll); + topKRoot.setPadding(new Insets(8)); + topKRoot.setFillWidth(false); + + // Fix the right pane width + topKRoot.setMinWidth(RIGHT_PANE_WIDTH); + topKRoot.setPrefWidth(RIGHT_PANE_WIDTH); + topKRoot.setMaxWidth(RIGHT_PANE_WIDTH); + + return topKRoot; + } + + private void updateTopKList() { + if (topKListBox == null) return; + topKListBox.getChildren().clear(); + + // Only defined in Contribution/Cumulative (uses Δ) + if (lastContrib == null || lastContrib.delta == null || lastContrib.delta.isEmpty()) { + Label none = new Label("No contribution data."); + topKListBox.getChildren().add(none); + return; + } + + // Build indices and sort by |Δ| + List indices = new ArrayList<>(); + int n = lastContrib.delta.size(); + for (int i = 0; i < n; i++) indices.add(i); + + indices.sort((a, b) -> { + double da = Math.abs(lastContrib.delta.get(a)); + double db = Math.abs(lastContrib.delta.get(b)); + return Double.compare(db, da); + }); + + int count = Math.min(topK, indices.size()); + for (int i = 0; i < count; i++) { + int idx = indices.get(i); + double abs = Math.abs(lastContrib.delta.get(idx)); + String text = String.format("#%d |Δ|= %.6f", idx, abs); + + Button row = new Button(text); + // Keep buttons compact; do not stretch to container width + row.setMaxWidth(RIGHT_PANE_WIDTH * 0.9); + row.setMinWidth(RIGHT_PANE_WIDTH * 0.9); + row.setOnAction(e -> { + if (!persistSelection) persistentHighlights.clear(); + persistentHighlights.add(idx); + applyCurrentHighlights(); + selectionInfo.setText(String.format("Top-K • Sample #%d: |Δ| = %.6f", idx, abs)); + }); + topKListBox.getChildren().add(row); + } } + private void applyCurrentHighlights() { + if (persistentHighlights.isEmpty()) { + tsChart.clearHighlights(); + return; + } + int[] arr = persistentHighlights.stream().mapToInt(Integer::intValue).toArray(); + tsChart.highlightSamples(arr); + } + + // ===== Public API ===== + + public boolean isSurfaceCDF() { return surfaceCDF; } + public String getYFeatureTypeForDisplay() { if (yFeatureCombo == null) return "N/A"; StatisticEngine.ScalarType type = yFeatureCombo.getValue(); @@ -351,12 +557,15 @@ public void setOnComputeSurface(Consumer handler) { public void setFeatureVectors(List vectors) { this.currentVectors = (vectors != null) ? vectors : new ArrayList<>(); + rebuildLabelsFromCurrentVectors(); updateXIndexBoundsAndValue(); updateYIndexBoundsAndValue(); if (dataSource == DataSource.VECTORS) { applyXFeatureOptions(); - pdfChart.setFeatureVectors(this.currentVectors); - cdfChart.setFeatureVectors(this.currentVectors); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); } } @@ -364,12 +573,15 @@ 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(); - pdfChart.addFeatureVectors(newVectors); - cdfChart.addFeatureVectors(newVectors); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); } } @@ -377,6 +589,7 @@ public void addFeatureVectors(List newVectors) { 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; @@ -385,12 +598,14 @@ public void setCustomReferenceVector(List customVector) { && "Custom".equals(referenceMode) && !currentVectors.isEmpty()) { applyXFeatureOptions(); - pdfChart.setFeatureVectors(currentVectors); - cdfChart.setFeatureVectors(currentVectors); + List use = getFilteredVectors(); + pdfChart.setFeatureVectors(use); + cdfChart.setFeatureVectors(use); + updateTimeSeries(); } } - // ===== State (for popout workflows) ===== + // ===== State ===== public static final class State { public StatisticEngine.ScalarType xType; @@ -407,6 +622,15 @@ public static final class State { public List scalarScores; public List scalarInfos; public List customRef; // nullable + public boolean persistSelection; + + // Label filter + public List selectedLabels; + + // Time-series mode & aggregator + public String tsMode; // "SIMILARITY" or "CONTRIBUTION" or "CUMULATIVE" + public String aggregator; // "MEAN","GEOMEAN","MIN" + public Integer topK; // optional } public State exportState() { @@ -425,6 +649,11 @@ public State exportState() { 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(); + s.topK = this.topK; return s; } @@ -451,8 +680,22 @@ public void applyState(State s) { 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); + if (s.topK != null) { + topK = Math.max(1, Math.min(100, s.topK)); + if (topKSpinner != null) topKSpinner.getValueFactory().setValue(topK); + } + + // Reset persisted highlights when applying state (safer) + persistentHighlights.clear(); + applyCurrentHighlights(); // Apply visuals + rebuildLabelsFromCurrentVectors(); updateXControlEnablement(); updateYControlEnablement(); updateXIndexBoundsAndValue(); @@ -473,10 +716,25 @@ private void applyScalarSamplesToCharts() { if (chosen == null || chosen.isEmpty()) { pdfChart.clearScalarSamples(); cdfChart.clearScalarSamples(); + tsChart.setSeries(new ArrayList<>()); + selectionInfo.setText("—"); + lastContrib = null; + updateTopKList(); return; } pdfChart.setScalarSamples(chosen); cdfChart.setScalarSamples(chosen); + + // In Scalars mode, treat TS as similarity only. + tsMode = TSMode.SIMILARITY; + tsChart.setAxisLabels("Sample Index", "Scalar Value"); + tsChart.setSeries(chosen); + scalarSeries = new ArrayList<>(chosen); + lastContrib = null; + persistentHighlights.clear(); + applyCurrentHighlights(); + updateTopKList(); + pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); } @@ -497,9 +755,20 @@ private void refresh2DCharts() { applyXFeatureOptions(); - if (currentVectors != null && !currentVectors.isEmpty()) { - pdfChart.setFeatureVectors(currentVectors); - cdfChart.setFeatureVectors(currentVectors); + 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("—"); + lastContrib = null; + persistentHighlights.clear(); + applyCurrentHighlights(); + updateTopKList(); } pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); @@ -518,11 +787,16 @@ private void applyXFeatureOptions() { 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); + default -> { + List use = getFilteredVectors(); + yield use.isEmpty() ? null : FeatureVector.getMeanVector(use); + } }; + pdfChart.setMetricNameForGeneric(metricName); pdfChart.setReferenceVectorForGeneric(ref); cdfChart.setMetricNameForGeneric(metricName); @@ -534,10 +808,10 @@ private void applyXFeatureOptions() { } } - // ===== 3D compute (Vectors only) ===== - private void compute3DSurface() { - if (onComputeSurface == null || currentVectors == null || currentVectors.isEmpty()) return; + if (onComputeSurface == null) return; + List use = getFilteredVectors(); + if (use == null || use.isEmpty()) return; if (dataSource != DataSource.VECTORS) return; AxisParams xAxis = new AxisParams(); @@ -547,7 +821,7 @@ private void compute3DSurface() { List ref = switch (referenceMode) { case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); case "Custom" -> customReferenceVector; - default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); + default -> use.isEmpty() ? null : FeatureVector.getMeanVector(use); }; xAxis.setReferenceVec(ref); } else if (xAxis.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { @@ -561,16 +835,16 @@ private void compute3DSurface() { } else if (yAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { yAxis.setMetricName(metricCombo.getValue()); List ref = switch (referenceMode) { - case "Vector @ Index" -> getVectorAtIndexAsList(xIndexSpinner.getValue()); + case "Vector @ Index" -> getVectorAtIndexAsList(yIndexSpinner.getValue()); case "Custom" -> customReferenceVector; - default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); + default -> use.isEmpty() ? null : FeatureVector.getMeanVector(use); }; yAxis.setReferenceVec(ref); } int bins = binsSpinner.getValue(); GridSpec grid = new GridSpec(bins, bins); - GridDensityResult result = GridDensity3DEngine.computePdfCdf2D(currentVectors, xAxis, yAxis, grid); + GridDensityResult result = GridDensity3DEngine.computePdfCdf2D(use, xAxis, yAxis, grid); onComputeSurface.accept(result); } @@ -598,7 +872,7 @@ private void updateXIndexBoundsAndValue() { if (xIndexSpinner == null) return; if (isMetricVectorIdx) { - int maxIdx = Math.max(0, getMaxVectorIndex()); + int maxIdx = Math.max(0, getFilteredVectors().size() - 1); setSpinnerBounds(xIndexSpinner, 0, maxIdx, safeSpinnerValue(xIndexSpinner, 0, maxIdx)); } else if (isComponent) { int maxDim = getMaxDimensionIndex(); @@ -643,8 +917,216 @@ private int getMaxDimensionIndex() { } 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(); + List use = getFilteredVectors(); + if (use == null || use.isEmpty()) return null; + int safe = Math.max(0, Math.min(idx, use.size() - 1)); + return use.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) return; + labelsMenu.getItems().clear(); + labelChecks.clear(); + + if (allLabels.isEmpty()) { + MenuItem none = new MenuItem("No labels found"); + none.setDisable(true); + labelsMenu.getItems().add(none); + return; + } + + for (String lab : allLabels) { + CheckMenuItem ci = new CheckMenuItem(lab); + ci.setSelected(selectedLabels.contains(lab)); + ci.selectedProperty().addListener((obs, ov, nv) -> { + if (nv) { + if (!selectedLabels.contains(lab)) selectedLabels.add(lab); + } else { + selectedLabels.remove(lab); + } + }); + labelChecks.put(lab, ci); + labelsMenu.getItems().add(ci); + } + + labelsMenu.getItems().add(new SeparatorMenuItem()); + + MenuItem selectAll = new MenuItem("Select All"); + selectAll.setOnAction(e -> { + selectedLabels = new ArrayList<>(allLabels); + labelChecks.values().forEach(c -> c.setSelected(true)); + refresh2DCharts(); + }); + + MenuItem selectNone = new MenuItem("Select None"); + selectNone.setOnAction(e -> { + selectedLabels.clear(); + labelChecks.values().forEach(c -> c.setSelected(false)); + refresh2DCharts(); + }); + + MenuItem apply = new MenuItem("Apply Label Filter"); + apply.setOnAction(e -> refresh2DCharts()); + + labelsMenu.getItems().addAll(selectAll, selectNone, new SeparatorMenuItem(), apply); + } + + 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 ===== + + private void updateTimeSeries() { + if (dataSource == DataSource.SCALARS) { + // handled in applyScalarSamplesToCharts + return; + } + List use = getFilteredVectors(); + if (use == null || use.isEmpty()) { + tsChart.setSeries(new ArrayList<>()); + lastContrib = null; + persistentHighlights.clear(); + applyCurrentHighlights(); + updateTopKList(); + return; + } + + if (tsMode == TSMode.CONTRIBUTION || tsMode == TSMode.CUMULATIVE) { + lastContrib = StatisticEngine.computeContributions(use, agg, null, contribEps); + + if (tsMode == TSMode.CONTRIBUTION) { + tsChart.setAxisLabels("Sample Index", "Δ log-odds"); + tsChart.setSeries(lastContrib.delta); + } else { + // Cumulative: C_t = sum_{k<=t} Δ_k; start at 0 (50-50 prior odds) + List cum = new ArrayList<>(lastContrib.delta.size()); + double running = 0.0; + for (double d : lastContrib.delta) { + running += d; + cum.add(running); + } + tsChart.setAxisLabels("Sample Index", "Cumulative log-odds (ΣΔ)"); + tsChart.setSeries(cum); + } + + persistentHighlights.clear(); + applyCurrentHighlights(); + updateTopKList(); // uses Δ for ranking + } else { + // Similarity: same scalar values that fed the PDF/CDF + StatisticResult stat = (pdfChart != null) ? pdfChart.getLastStatisticResult() : null; + List vals = (stat != null) ? stat.getValues() : null; + if (vals == null) vals = new ArrayList<>(); + tsChart.setAxisLabels("Sample Index", "Scalar Value"); + tsChart.setSeries(vals); + scalarSeries = new ArrayList<>(vals); + lastContrib = null; // not defined in similarity mode + persistentHighlights.clear(); + applyCurrentHighlights(); + updateTopKList(); + } + } + + private void wireChartInteractions() { + // PDF -> TS + pdfChart.setOnBinHover(sel -> { + handleIncomingHighlight(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 -> { + handleIncomingHighlight(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 -> { + handleIncomingHighlight(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 -> { + handleIncomingHighlight(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 -> { + handleIncomingHighlight(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)); + } + }); + } + + private void handleIncomingHighlight(int[] newIdx) { + if (!persistSelection) { + persistentHighlights.clear(); + } + for (int idx : newIdx) persistentHighlights.add(idx); + applyCurrentHighlights(); } } 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..fc227f9c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java @@ -0,0 +1,209 @@ +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: -fx-default-button; " + + "-fx-background-radius: 6px; -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 index b11aac70..0dc05766 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -12,12 +12,20 @@ /** * StatisticEngine for extracting scalar statistics and their distributions - * from FeatureVector collections. + * 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, @@ -28,7 +36,24 @@ public enum ScalarType { COSINE_TO_MEAN, PC1_PROJECTION, METRIC_DISTANCE_TO_MEAN, - COMPONENT_AT_DIMENSION //marginal of a single component across vectors + 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; + } } /** @@ -132,10 +157,10 @@ public static Map computeStatistics( 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 + case DIST_TO_MEAN -> (meanVector != null) ? AnalysisUtils.l2Norm(diffList(fv.getData(), meanVector)) : 0.0; - case COSINE_TO_MEAN -> meanVector != null + case COSINE_TO_MEAN -> (meanVector != null) ? AnalysisUtils.cosineSimilarity(fv.getData(), meanVector) : 0.0; default -> 0.0; @@ -147,53 +172,256 @@ public static Map computeStatistics( return results; } + // ===== 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: + 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) { - double[] out = new double[a.size()]; - for (int i = 0; i < a.size(); i++) out[i] = a.get(i) - b.get(i); + 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); - return new StatisticResult(scalars, pdfcdf.bins, pdfcdf.pdf, pdfcdf.cdf); + 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; - double[] pdf; - double[] cdf; - PDFCDFResult(double[] bins, double[] pdf, double[] cdf) { + 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) { - if (values == null || values.isEmpty()) { - int n = Math.max(5, binsCount); - return new PDFCDFResult(new double[n], new double[n], new double[n]); + 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; } - int n = Math.max(1, binsCount); - double min = values.stream().min(Double::compare).get(); - double max = values.stream().max(Double::compare).get(); - if (min == max) max += 1e-8; + // 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[] bins = new double[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; - for (int i = 0; i < n; i++) bins[i] = min + (i + 0.5) * binWidth; - for (double v : values) { - int idx = (int) ((v - min) / binWidth); - if (idx < 0) idx = 0; - if (idx >= n) idx = n - 1; - pdf[idx] += 1.0; - } - for (int i = 0; i < n; i++) pdf[i] /= (values.size() * binWidth); - cdf[0] = pdf[0] * binWidth; - for (int i = 1; i < n; i++) cdf[i] = cdf[i - 1] + pdf[i] * binWidth; - cdf[n - 1] = Math.min(1.0, Math.max(0.0, cdf[n - 1])); - return new PDFCDFResult(bins, pdf, cdf); + // 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; + } + + private static double clamp01(double v) { return Math.max(0.0, Math.min(1.0, v)); } + private static double clip(double v, double lo, double hi) { return Math.max(lo, Math.min(hi, v)); } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java index 66707937..8e6d3994 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java @@ -2,11 +2,36 @@ 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 { - private List values; // Scalar values for each FeatureVector - private double[] pdfBins; // Bin centers for PDF - private double[] pdf; // PDF values - private double[] cdf; // CDF values + + // 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; @@ -15,6 +40,8 @@ public StatisticResult(List values, double[] pdfBins, double[] pdf, doub this.cdf = cdf; } + // ----- Getters / Setters ----- + public List getValues() { return values; } @@ -46,4 +73,66 @@ public double[] getCdf() { 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; + } } From 2677a5668737e6b5816fcc78c2e3f71ad2c34b3c Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sat, 11 Oct 2025 09:53:47 -0400 Subject: [PATCH 47/50] Fixes to allow "Set All" to active workspace effect for any and all FeatureCollections loaded in the FeatureVectorManager service --- .../components/FeatureVectorManagerView.java | 7 +- .../javafx/components/PairwiseJpdfView.java | 8 +- .../panes/FeatureVectorManagerPane.java | 7 +- .../components/panes/PairwiseJpdfPane.java | 2 +- .../FeatureVectorManagerPopoutController.java | 7 +- .../services/FeatureVectorManagerService.java | 5 + .../FeatureVectorManagerServiceImpl.java | 18 + .../statistics/StatPdfCdfChartPanel.java | 470 ++++++++---------- .../utils/statistics/StatisticEngine.java | 23 + 9 files changed, 281 insertions(+), 266 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index c0ed46cd..210bbcab 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -97,6 +97,7 @@ public enum DetailLevel { COMPACT, FULL } // Callbacks (wired by container/pane) private Runnable onApplyAppend; private Runnable onApplyReplace; + private Runnable onSetAll; public FeatureVectorManagerView() { buildHeader(); @@ -277,7 +278,10 @@ private ContextMenu buildCollectionContextMenu() { MenuItem applyReplace = new MenuItem("Apply (replace)"); applyReplace.setOnAction(e -> { if (onApplyReplace != null) onApplyReplace.run(); }); - applyMenu.getItems().addAll(applyAppend, applyReplace); + MenuItem setAllReplace = new MenuItem("Set All"); + setAllReplace.setOnAction(e -> { if (onSetAll != null) onSetAll.run(); }); + + applyMenu.getItems().addAll(applyAppend, applyReplace, setAllReplace); return new ContextMenu(applyMenu); } @@ -445,4 +449,5 @@ public void showProgress(boolean show) { // 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/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index f46e62ba..88c3b442 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -1,6 +1,7 @@ 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; @@ -32,6 +33,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; +import javafx.scene.text.Font; /** * PairwiseJpdfView @@ -566,7 +568,11 @@ public void toast(String msg, boolean isError) { if (toastHandler != null) { toastHandler.accept((isError ? "[Error] " : "") + msg); } else { - System.out.println((isError ? "[Error] " : "[Info] ") + msg); + Platform.runLater(() -> { + this.getScene().getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), + isError ? Color.RED : Color.LIGHTGREEN)); + }); } } 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 index e4dd7f0e..ba0d00b7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -248,7 +248,12 @@ private void installCollectionContextMenu() { miApplyAppend.setOnAction(e -> service.applyActiveToWorkspace(false)); MenuItem miApplyReplace = new MenuItem("Apply (replace)"); miApplyReplace.setOnAction(e -> service.applyActiveToWorkspace(true)); - applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); + + 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); 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 index 1ae63452..d96301e2 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -64,7 +64,7 @@ public PairwiseJpdfPane( // Wire up toast to send to terminal view.setToastHandler(msg -> { Platform.runLater(() -> scene.getRoot().fireEvent( - new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN))); + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN))); }); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java index 566d2c51..4e083adc 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java @@ -331,8 +331,13 @@ private void installCollectionContextMenu() { miApplyAppend.setOnAction(e -> service.applyActiveToWorkspace(false)); MenuItem miApplyReplace = new MenuItem("Apply (replace)"); miApplyReplace.setOnAction(e -> service.applyActiveToWorkspace(true)); - applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); + 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); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index 7c0c2771..acebb5a8 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -79,6 +79,11 @@ enum ExportFormat { JSON, CSV } // 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 index b881234e..a8380c6a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -278,7 +278,25 @@ public void applyActiveToWorkspace(boolean replace) { 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) { diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index 66622ace..b7249760 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -3,7 +3,6 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.metric.Metric; import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; - import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -11,7 +10,8 @@ import java.util.Map; import java.util.Set; import java.util.function.Consumer; - +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -19,12 +19,12 @@ 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.ScrollPane; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.Spinner; import javafx.scene.control.SpinnerValueFactory; @@ -36,25 +36,36 @@ import javafx.scene.layout.VBox; /** - * Chart panel showing PDF, CDF, and a time-series chart (vertical stack), - * with linked interactions between the distribution charts and the time-series, - * label filtering, Contribution (Δ log-odds), Cumulative log-odds, and a - * Top-K contributors list ranked by |Δ log-odds|. + * 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. * - * PDF/CDF hover/click -> highlights contributing samples in the time-series. - * Time-series hover shows sample index/value (and bin info in the readout). + * Top-K contributors live to the right of the time-series in a fixed-width panel. + * + * @author Sean Phillips */ public class StatPdfCdfChartPanel extends BorderPane { - // ===== Layout constants ===== - private static final double RIGHT_PANE_WIDTH = 260.0; - private static final double TOPK_SPINNER_WIDTH = 72.0; - // ===== Charts ===== private StatPdfCdfChart pdfChart; private StatPdfCdfChart cdfChart; private StatTimeSeriesChart tsChart; + // ===== Time-series row (chart + Top-K) ===== + private HBox tsRow; + + // ===== 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; @@ -86,39 +97,24 @@ private enum DataSource { VECTORS, SCALARS } private int currentBins = 40; private List customReferenceVector; - // Time-series state - private List scalarSeries = new ArrayList<>(); - private boolean persistSelection = false; - - // Maintain persistent highlight indices locally (no addHighlights() needed on tsChart) - private final Set persistentHighlights = new LinkedHashSet<>(); - - // ===== Time-series mode: Similarity vs Contribution vs Cumulative ===== + // Time-series mode & aggregator private enum TSMode { SIMILARITY, CONTRIBUTION, CUMULATIVE } - private TSMode tsMode = TSMode.CONTRIBUTION; // default + private TSMode tsMode = TSMode.CONTRIBUTION; private StatisticEngine.SimilarityAggregator agg = StatisticEngine.SimilarityAggregator.MEAN; private final double contribEps = 1e-6; - // Keep last contributions for Top-K list - private StatisticEngine.ContributionSeries lastContrib = null; - - // Readout + // Selection + private boolean persistSelection = false; private final Label selectionInfo = new Label("—"); - // ===== Label filter state & UI ===== + // ===== 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 - // ===== Top-K contributors panel ===== - private VBox topKRoot; - private VBox topKListBox; - private Spinner topKSpinner; - private int topK = 5; - - // Callbacks + // Callback private Consumer onComputeSurface = null; // ===== Constructor ===== @@ -158,63 +154,73 @@ public StatPdfCdfChartPanel(List initialVectors, topBar.setPadding(new Insets(6, 8, 4, 8)); HBox.setHgrow(optionsMenu, Priority.NEVER); - // --- Charts: PDF (top), CDF (middle), Time Series (bottom) --- + // --- Charts: PDF (top), CDF (middle) --- pdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); cdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); - tsChart = new StatTimeSeriesChart(); - // modest sizing + // baseline sizes (no pref height on the container) pdfChart.setMinHeight(120); cdfChart.setMinHeight(120); - tsChart.setMinHeight(140); pdfChart.setPrefHeight(220); cdfChart.setPrefHeight(220); - tsChart.setPrefHeight(240); - if (!currentVectors.isEmpty()) { - rebuildLabelsFromCurrentVectors(); // build labels before first render - applyXFeatureOptions(); - List use = getFilteredVectors(); - pdfChart.setFeatureVectors(use); - cdfChart.setFeatureVectors(use); - updateTimeSeries(); // populate TS based on tsMode - } - if (lockXToUnit) { - pdfChart.setLockXAxis(true, 0.0, 1.0); - cdfChart.setLockXAxis(true, 0.0, 1.0); - } + // --- Time Series + Top-K row --- + tsChart = new StatTimeSeriesChart(); + tsChart.setMinHeight(140); + tsChart.setPrefHeight(240); - // linked interactions: PDF/CDF -> TS - wireChartInteractions(); + VBox topKPanel = buildTopKPanel(); // fixed-width side panel + tsRow = new HBox(10, tsChart, topKPanel); + tsRow.setAlignment(Pos.CENTER_LEFT); + tsRow.setFillHeight(false); + tsRow.setMaxHeight(tsMaxHeight); + HBox.setHgrow(tsChart, Priority.ALWAYS); + HBox.setHgrow(topKPanel, Priority.NEVER); + VBox.setVgrow(tsRow, Priority.NEVER); // don't let TS row push others - // Bottom utility bar + // --- Bottom utility bar --- Button clearBtn = new Button("Clear Highlights"); - clearBtn.setOnAction(e -> { - persistentHighlights.clear(); - tsChart.clearHighlights(); - selectionInfo.setText("—"); - }); - + 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)); - VBox chartsBox = new VBox(8, pdfChart, cdfChart, tsChart, bottomBar); + // --- Vertical stack without scroll, with spacer to absorb extra height --- + Region spacer = new Region(); + VBox chartsBox = new VBox(8, pdfChart, cdfChart, tsRow, bottomBar, spacer); chartsBox.setPadding(new Insets(6)); - VBox.setVgrow(pdfChart, Priority.ALWAYS); - VBox.setVgrow(cdfChart, Priority.ALWAYS); - VBox.setVgrow(tsChart, Priority.ALWAYS); - chartsBox.setFillWidth(true); - chartsBox.setPrefHeight(Region.USE_COMPUTED_SIZE); + + // Do NOT let charts grow vertically + VBox.setVgrow(pdfChart, Priority.NEVER); + VBox.setVgrow(cdfChart, Priority.NEVER); + VBox.setVgrow(tsRow, Priority.NEVER); + VBox.setVgrow(bottomBar, Priority.NEVER); + + // The spacer eats any leftover space so charts don't stretch + VBox.setVgrow(spacer, Priority.ALWAYS); setTop(topBar); setCenter(chartsBox); - // Right-side: Top-K contributors (fixed width) - setRight(buildTopKPane()); + // Initial data render + if (!currentVectors.isEmpty()) { + rebuildLabelsFromCurrentVectors(); + applyXFeatureOptions(); + List filtered = getFilteredVectors(); + pdfChart.setFeatureVectors(filtered); + cdfChart.setFeatureVectors(filtered); + updateTimeSeries(); + } + if (lockXToUnit) { + pdfChart.setLockXAxis(true, 0.0, 1.0); + cdfChart.setLockXAxis(true, 0.0, 1.0); + } - // handler + // linked interactions: PDF/CDF -> TS + wireChartInteractions(); + + // Handlers xFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { currentXFeature = nv; updateXControlEnablement(); @@ -222,6 +228,91 @@ public StatPdfCdfChartPanel(List initialVectors, }); } + // ===== 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 = Math.abs(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"); @@ -250,11 +341,7 @@ private MenuButton buildOptionsMenu() { CheckMenuItem persistSel = new CheckMenuItem("Persist Selection"); persistSel.setSelected(persistSelection); - persistSel.selectedProperty().addListener((o, oldV, nv) -> { - persistSelection = nv; - if (!persistSelection) persistentHighlights.clear(); - applyCurrentHighlights(); - }); + persistSel.selectedProperty().addListener((o, oldV, nv) -> persistSelection = nv); // Scalars submenu Menu scalarsMenu = new Menu("Scalars"); @@ -288,8 +375,7 @@ private MenuButton buildOptionsMenu() { tsChart.clearHighlights(); tsChart.setSeries(new ArrayList<>()); selectionInfo.setText("—"); - lastContrib = null; - updateTopKList(); // clear list + refreshTopK(); }); scalarsMenu.getItems().addAll(scoreItem, infoItem, new SeparatorMenuItem(), pasteScalars, clearScalars); @@ -315,6 +401,8 @@ private MenuButton buildOptionsMenu() { xIndexSpinner = new Spinner<>(); xIndexSpinner.setPrefWidth(100); + xIndexSpinner.setMinWidth(100); + xIndexSpinner.setMaxWidth(100); Button setCustomButton = new Button("Set Custom Vector…"); setCustomButton.setOnAction(e -> { @@ -350,6 +438,8 @@ private MenuButton buildOptionsMenu() { yIndexSpinner = new Spinner<>(); yIndexSpinner.setPrefWidth(100); + yIndexSpinner.setMinWidth(100); + yIndexSpinner.setMaxWidth(100); ToggleGroup surfaceGroup = new ToggleGroup(); RadioButton surfacePdf = new RadioButton("PDF"); @@ -378,7 +468,7 @@ private MenuButton buildOptionsMenu() { MenuItem surfaceHeader = new MenuItem("3D Surface"); surfaceHeader.setDisable(true); - // ---- Time-Series Mode & Aggregator ---- + // Time-Series Mode & Aggregator Menu tsModeMenu = new Menu("Time-Series Mode"); ToggleGroup tsModeGroup = new ToggleGroup(); RadioMenuItem tsSimilarity = new RadioMenuItem("Similarity (matches PDF feature)"); @@ -391,14 +481,14 @@ private MenuButton buildOptionsMenu() { tsContribution.setSelected(tsMode == TSMode.CONTRIBUTION); tsContribution.setOnAction(e -> { tsMode = TSMode.CONTRIBUTION; updateTimeSeries(); }); - RadioMenuItem tsCumulative = new RadioMenuItem("Cumulative Log-odds (ΣΔ)"); + 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/Cumulative)"); + Menu aggregatorMenu = new Menu("Aggregator (Contribution)"); ToggleGroup aggGroup = new ToggleGroup(); RadioMenuItem aggMean = new RadioMenuItem("Mean"); RadioMenuItem aggGeo = new RadioMenuItem("Geomean"); @@ -413,8 +503,7 @@ private MenuButton buildOptionsMenu() { 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 ---- + // Final assembly mb.getItems().addAll( useScalars, lockX, @@ -442,100 +531,6 @@ private MenuButton buildOptionsMenu() { return mb; } - // ===== Top-K Pane (fixed width) ===== - private VBox buildTopKPane() { - Label title = new Label("Top-K Contributors"); - - topKSpinner = new Spinner<>(1, 100, topK, 1); - topKSpinner.setEditable(false); - // Fix spinner width so it doesn't stretch the parent - topKSpinner.setMinWidth(TOPK_SPINNER_WIDTH); - topKSpinner.setPrefWidth(TOPK_SPINNER_WIDTH); - topKSpinner.setMaxWidth(TOPK_SPINNER_WIDTH); - // Optional: constrain editor text columns (even though not editable) - if (topKSpinner.getEditor() != null) { - topKSpinner.getEditor().setPrefColumnCount(3); - } - topKSpinner.valueProperty().addListener((obs, ov, nv) -> { - topK = (nv != null) ? nv : topK; - updateTopKList(); - }); - - HBox header = new HBox(8, title, new Region(), new Label("K:"), topKSpinner); - HBox.setHgrow(header.getChildren().get(1), Priority.ALWAYS); - header.setAlignment(Pos.CENTER_LEFT); - - topKListBox = new VBox(4); - - ScrollPane scroll = new ScrollPane(topKListBox); - // Grow vertically with parent; let the viewport width be respected - VBox.setVgrow(scroll, Priority.ALWAYS); - scroll.setFitToWidth(true); - scroll.setFitToHeight(true); - - topKRoot = new VBox(8, header, scroll); - topKRoot.setPadding(new Insets(8)); - topKRoot.setFillWidth(false); - - // Fix the right pane width - topKRoot.setMinWidth(RIGHT_PANE_WIDTH); - topKRoot.setPrefWidth(RIGHT_PANE_WIDTH); - topKRoot.setMaxWidth(RIGHT_PANE_WIDTH); - - return topKRoot; - } - - private void updateTopKList() { - if (topKListBox == null) return; - topKListBox.getChildren().clear(); - - // Only defined in Contribution/Cumulative (uses Δ) - if (lastContrib == null || lastContrib.delta == null || lastContrib.delta.isEmpty()) { - Label none = new Label("No contribution data."); - topKListBox.getChildren().add(none); - return; - } - - // Build indices and sort by |Δ| - List indices = new ArrayList<>(); - int n = lastContrib.delta.size(); - for (int i = 0; i < n; i++) indices.add(i); - - indices.sort((a, b) -> { - double da = Math.abs(lastContrib.delta.get(a)); - double db = Math.abs(lastContrib.delta.get(b)); - return Double.compare(db, da); - }); - - int count = Math.min(topK, indices.size()); - for (int i = 0; i < count; i++) { - int idx = indices.get(i); - double abs = Math.abs(lastContrib.delta.get(idx)); - String text = String.format("#%d |Δ|= %.6f", idx, abs); - - Button row = new Button(text); - // Keep buttons compact; do not stretch to container width - row.setMaxWidth(RIGHT_PANE_WIDTH * 0.9); - row.setMinWidth(RIGHT_PANE_WIDTH * 0.9); - row.setOnAction(e -> { - if (!persistSelection) persistentHighlights.clear(); - persistentHighlights.add(idx); - applyCurrentHighlights(); - selectionInfo.setText(String.format("Top-K • Sample #%d: |Δ| = %.6f", idx, abs)); - }); - topKListBox.getChildren().add(row); - } - } - - private void applyCurrentHighlights() { - if (persistentHighlights.isEmpty()) { - tsChart.clearHighlights(); - return; - } - int[] arr = persistentHighlights.stream().mapToInt(Integer::intValue).toArray(); - tsChart.highlightSamples(arr); - } - // ===== Public API ===== public boolean isSurfaceCDF() { return surfaceCDF; } @@ -623,14 +618,9 @@ public static final class State { public List scalarInfos; public List customRef; // nullable public boolean persistSelection; - - // Label filter public List selectedLabels; - - // Time-series mode & aggregator - public String tsMode; // "SIMILARITY" or "CONTRIBUTION" or "CUMULATIVE" + public String tsMode; // "SIMILARITY" / "CONTRIBUTION" / "CUMULATIVE" public String aggregator; // "MEAN","GEOMEAN","MIN" - public Integer topK; // optional } public State exportState() { @@ -653,7 +643,6 @@ public State exportState() { s.selectedLabels = new ArrayList<>(selectedLabels); s.tsMode = this.tsMode.name(); s.aggregator = this.agg.name(); - s.topK = this.topK; return s; } @@ -685,14 +674,6 @@ public void applyState(State s) { 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); - if (s.topK != null) { - topK = Math.max(1, Math.min(100, s.topK)); - if (topKSpinner != null) topKSpinner.getValueFactory().setValue(topK); - } - - // Reset persisted highlights when applying state (safer) - persistentHighlights.clear(); - applyCurrentHighlights(); // Apply visuals rebuildLabelsFromCurrentVectors(); @@ -718,25 +699,20 @@ private void applyScalarSamplesToCharts() { cdfChart.clearScalarSamples(); tsChart.setSeries(new ArrayList<>()); selectionInfo.setText("—"); - lastContrib = null; - updateTopKList(); + refreshTopK(); return; } pdfChart.setScalarSamples(chosen); cdfChart.setScalarSamples(chosen); - // In Scalars mode, treat TS as similarity only. + // For pasted scalars, show Similarity tsMode = TSMode.SIMILARITY; tsChart.setAxisLabels("Sample Index", "Scalar Value"); tsChart.setSeries(chosen); - scalarSeries = new ArrayList<>(chosen); - lastContrib = null; - persistentHighlights.clear(); - applyCurrentHighlights(); - updateTopKList(); pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); cdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); + refreshTopK(); } // ===== 2D (Vectors) behavior ===== @@ -765,10 +741,7 @@ private void refresh2DCharts() { cdfChart.setFeatureVectors(new ArrayList<>()); tsChart.setSeries(new ArrayList<>()); selectionInfo.setText("—"); - lastContrib = null; - persistentHighlights.clear(); - applyCurrentHighlights(); - updateTopKList(); + refreshTopK(); } pdfChart.setLockXAxis(lockXToUnit, 0.0, 1.0); @@ -787,16 +760,11 @@ private void applyXFeatureOptions() { 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 -> { - List use = getFilteredVectors(); - yield use.isEmpty() ? null : FeatureVector.getMeanVector(use); - } + default -> currentVectors.isEmpty() ? null : FeatureVector.getMeanVector(currentVectors); }; - pdfChart.setMetricNameForGeneric(metricName); pdfChart.setReferenceVectorForGeneric(ref); cdfChart.setMetricNameForGeneric(metricName); @@ -872,7 +840,7 @@ private void updateXIndexBoundsAndValue() { if (xIndexSpinner == null) return; if (isMetricVectorIdx) { - int maxIdx = Math.max(0, getFilteredVectors().size() - 1); + int maxIdx = Math.max(0, getMaxVectorIndex()); setSpinnerBounds(xIndexSpinner, 0, maxIdx, safeSpinnerValue(xIndexSpinner, 0, maxIdx)); } else if (isComponent) { int maxDim = getMaxDimensionIndex(); @@ -917,10 +885,9 @@ private int getMaxDimensionIndex() { } private List getVectorAtIndexAsList(int idx) { - List use = getFilteredVectors(); - if (use == null || use.isEmpty()) return null; - int safe = Math.max(0, Math.min(idx, use.size() - 1)); - return use.get(safe).getData(); + 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 ===== @@ -972,14 +939,14 @@ private void rebuildLabelsMenu() { MenuItem selectAll = new MenuItem("Select All"); selectAll.setOnAction(e -> { selectedLabels = new ArrayList<>(allLabels); - labelChecks.values().forEach(c -> c.setSelected(true)); + for (CheckMenuItem c : labelChecks.values()) c.setSelected(true); refresh2DCharts(); }); MenuItem selectNone = new MenuItem("Select None"); selectNone.setOnAction(e -> { selectedLabels.clear(); - labelChecks.values().forEach(c -> c.setSelected(false)); + for (CheckMenuItem c : labelChecks.values()) c.setSelected(false); refresh2DCharts(); }); @@ -1008,65 +975,52 @@ private List getFilteredVectors() { private void updateTimeSeries() { if (dataSource == DataSource.SCALARS) { // handled in applyScalarSamplesToCharts + refreshTopK(); return; } List use = getFilteredVectors(); if (use == null || use.isEmpty()) { tsChart.setSeries(new ArrayList<>()); - lastContrib = null; - persistentHighlights.clear(); - applyCurrentHighlights(); - updateTopKList(); + selectionInfo.setText("—"); + refreshTopK(); return; } - if (tsMode == TSMode.CONTRIBUTION || tsMode == TSMode.CUMULATIVE) { - lastContrib = StatisticEngine.computeContributions(use, agg, null, contribEps); - - if (tsMode == TSMode.CONTRIBUTION) { - tsChart.setAxisLabels("Sample Index", "Δ log-odds"); - tsChart.setSeries(lastContrib.delta); - } else { - // Cumulative: C_t = sum_{k<=t} Δ_k; start at 0 (50-50 prior odds) - List cum = new ArrayList<>(lastContrib.delta.size()); - double running = 0.0; - for (double d : lastContrib.delta) { - running += d; - cum.add(running); - } - tsChart.setAxisLabels("Sample Index", "Cumulative log-odds (ΣΔ)"); - tsChart.setSeries(cum); - } - - persistentHighlights.clear(); - applyCurrentHighlights(); - updateTopKList(); // uses Δ for ranking + 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 cumulative = StatisticEngine.cumulativeFromDeltas(cs.delta); + tsChart.setAxisLabels("Sample Index", "Cumulative log-odds"); + tsChart.setSeries(cumulative); } else { - // Similarity: same scalar values that fed the PDF/CDF + // SIMILARITY StatisticResult stat = (pdfChart != null) ? pdfChart.getLastStatisticResult() : null; List vals = (stat != null) ? stat.getValues() : null; if (vals == null) vals = new ArrayList<>(); tsChart.setAxisLabels("Sample Index", "Scalar Value"); tsChart.setSeries(vals); - scalarSeries = new ArrayList<>(vals); - lastContrib = null; // not defined in similarity mode - persistentHighlights.clear(); - applyCurrentHighlights(); - updateTopKList(); } + + refreshTopK(); } private void wireChartInteractions() { // PDF -> TS pdfChart.setOnBinHover(sel -> { - handleIncomingHighlight(sel.sampleIdx); + 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 -> { - handleIncomingHighlight(sel.sampleIdx); + 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) @@ -1075,14 +1029,15 @@ private void wireChartInteractions() { // CDF -> TS cdfChart.setOnBinHover(sel -> { - handleIncomingHighlight(sel.sampleIdx); + 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 -> { - handleIncomingHighlight(sel.sampleIdx); + 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) @@ -1094,7 +1049,7 @@ private void wireChartInteractions() { 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)); + selectionInfo.setText(String.format("Sample #%d: cumulative log-odds = %.6f", p.sampleIdx, p.y)); } else { StatisticResult stat = pdfChart.getLastStatisticResult(); String extra = ""; @@ -1111,22 +1066,15 @@ private void wireChartInteractions() { }); tsChart.setOnPointClick(p -> { - handleIncomingHighlight(new int[]{p.sampleIdx}); + 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)); + 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)); } }); } - - private void handleIncomingHighlight(int[] newIdx) { - if (!persistSelection) { - persistentHighlights.clear(); - } - for (int idx : newIdx) persistentHighlights.add(idx); - applyCurrentHighlights(); - } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java index 0dc05766..11a73671 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -171,6 +171,29 @@ public static Map computeStatistics( } 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 -> Δ) ===== From 1b1b00576c9004c772ab066b5a79636f4e822c3f Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Sun, 12 Oct 2025 15:04:47 -0400 Subject: [PATCH 48/50] Fast switching between component index. Fixes to options menu. --- .../jhuapl/trinity/utils/AnalysisUtils.java | 24 + .../statistics/HeatmapThumbnailView.java | 5 +- .../utils/statistics/PairGridPane.java | 2 +- .../utils/statistics/StatPdfCdfChart.java | 5 +- .../statistics/StatPdfCdfChartPanel.java | 856 +++++++++--------- .../utils/statistics/StatTimeSeriesChart.java | 4 +- .../utils/statistics/StatisticEngine.java | 7 +- .../statistics/SyntheticMatrixFactory.java | 7 +- 8 files changed, 469 insertions(+), 441 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java index 7d15de38..81eb0f85 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java @@ -65,6 +65,30 @@ public static void writeAnalysisConfig(File file) throws IOException { ObjectMapper mapper = new ObjectMapper(); 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; diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java index 6997d8ce..449aadbd 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java @@ -1,5 +1,7 @@ package edu.jhuapl.trinity.utils.statistics; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -391,7 +393,4 @@ private static Color lerpColor(Color a, Color b, double t) { double al = a.getOpacity() + (b.getOpacity() - a.getOpacity()) * t; return new Color(r, g, bl, al); } - - private static double clamp01(double x) { return (x < 0) ? 0 : (x > 1 ? 1 : x); } - private static double clamp(double x, double lo, double hi) { return (x < lo) ? lo : (x > hi ? hi : x); } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java index 5b291637..83ad7d35 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -214,7 +214,7 @@ private static final class DisplayState { private boolean showScoresInHeader = true; private Consumer onCellClick = null; - // New: display state applied to tiles + //display state applied to tiles private final DisplayState display = new DisplayState(); private Range globalRange = null; diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index a8abf097..2691ee8e 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -42,7 +42,7 @@ public enum Mode { PDF_ONLY, CDF_ONLY } // --- raw-sample override for 2D charts --- private List scalarSamples = null; // if non-null & non-empty, use this instead of vectors - // --- NEW: retain last stat and expose interactions --- + //retain last stat and expose interactions private StatisticResult lastStat = null; public static final class BinSelection { @@ -278,8 +278,7 @@ private void applyAxisLock() { } } - // ===== NEW: interaction plumbing ===== - + //interaction plumbing private void attachPlotInteractions() { Platform.runLater(() -> { Node plotArea = lookup(".chart-plot-background"); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index b7249760..1b87a72c 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -15,6 +15,7 @@ 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; @@ -52,9 +53,6 @@ public class StatPdfCdfChartPanel extends BorderPane { private StatPdfCdfChart cdfChart; private StatTimeSeriesChart tsChart; - // ===== Time-series row (chart + Top-K) ===== - private HBox tsRow; - // ===== Top-K contributors UI ===== private Spinner topKSpinner; private ListView topKList; @@ -102,7 +100,7 @@ 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("—"); @@ -111,125 +109,130 @@ private enum TSMode { SIMILARITY, CONTRIBUTION, CUMULATIVE } private static final String UNLABELED = "(unlabeled)"; private List allLabels = new ArrayList<>(); private List selectedLabels = new ArrayList<>(); - private final Map labelChecks = new LinkedHashMap<>(); + private final Map labelChecks = new LinkedHashMap<>(); private Menu labelsMenu; // built in Options - + private CustomMenuItem labelsCustom; + private VBox labelsBox; // Callback private Consumer onComputeSurface = null; - // ===== Constructor ===== - 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); + } - 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 --- - 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); - - Button refresh2DButton = new Button("Refresh 2D"); - refresh2DButton.setOnAction(e -> refresh2DCharts()); - - MenuButton optionsMenu = buildOptionsMenu(); - - HBox topBar = new HBox(12, - new VBox(2, new Label("X Feature (2D)"), xFeatureCombo), - new VBox(2, new Label("Bins"), binsSpinner), - 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) --- - pdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.PDF_ONLY); - cdfChart = new StatPdfCdfChart(currentXFeature, currentBins, StatPdfCdfChart.Mode.CDF_ONLY); - - // baseline sizes (no pref height on the container) - pdfChart.setMinHeight(120); - cdfChart.setMinHeight(120); - pdfChart.setPrefHeight(220); - cdfChart.setPrefHeight(220); - - // --- Time Series + Top-K row --- - tsChart = new StatTimeSeriesChart(); - tsChart.setMinHeight(140); - tsChart.setPrefHeight(240); - - VBox topKPanel = buildTopKPanel(); // fixed-width side panel - tsRow = new HBox(10, tsChart, topKPanel); - tsRow.setAlignment(Pos.CENTER_LEFT); - tsRow.setFillHeight(false); - tsRow.setMaxHeight(tsMaxHeight); - HBox.setHgrow(tsChart, Priority.ALWAYS); - HBox.setHgrow(topKPanel, Priority.NEVER); - VBox.setVgrow(tsRow, Priority.NEVER); // don't let TS row push others - - // --- Bottom utility bar --- - 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)); - - // --- Vertical stack without scroll, with spacer to absorb extra height --- - Region spacer = new Region(); - VBox chartsBox = new VBox(8, pdfChart, cdfChart, tsRow, bottomBar, spacer); - chartsBox.setPadding(new Insets(6)); - - // Do NOT let charts grow vertically - VBox.setVgrow(pdfChart, Priority.NEVER); - VBox.setVgrow(cdfChart, Priority.NEVER); - VBox.setVgrow(tsRow, Priority.NEVER); - VBox.setVgrow(bottomBar, Priority.NEVER); - - // The spacer eats any leftover space so charts don't stretch - VBox.setVgrow(spacer, Priority.ALWAYS); - - setTop(topBar); - setCenter(chartsBox); - - // Initial data render - if (!currentVectors.isEmpty()) { - rebuildLabelsFromCurrentVectors(); - applyXFeatureOptions(); - List filtered = getFilteredVectors(); - pdfChart.setFeatureVectors(filtered); - cdfChart.setFeatureVectors(filtered); - updateTimeSeries(); - } - if (lockXToUnit) { - pdfChart.setLockXAxis(true, 0.0, 1.0); - cdfChart.setLockXAxis(true, 0.0, 1.0); - } + wireChartInteractions(); - // linked interactions: PDF/CDF -> TS - wireChartInteractions(); + Button clearBtn = new Button("Clear Highlights"); + clearBtn.setOnAction(e -> { tsChart.clearHighlights(); selectionInfo.setText("—"); }); - // Handlers - xFeatureCombo.valueProperty().addListener((obs, ov, nv) -> { - currentXFeature = nv; - updateXControlEnablement(); - updateXIndexBoundsAndValue(); - }); - } + 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)); - // ===== Top-K panel ===== + // 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:"); @@ -283,9 +286,9 @@ private void refreshTopK() { for (int i = 0; i < k; i++) { int idx = pairs.get(i)[0]; - double val = Math.abs(series.get(idx) == null ? 0.0 : series.get(idx)); + double val = series.get(idx) == null ? 0.0 : series.get(idx); topKIndices.add(idx); - topKItems.add(String.format("#%d | |Δ|=%.6f", idx, val)); + topKItems.add(String.format("#%d | Δ=%.6f", idx, val)); } } @@ -314,222 +317,225 @@ private List getCurrentTSValues() { } // ===== 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; +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(); - }); - - MenuItem clearScalars = new MenuItem("Clear Scalars"); - clearScalars.setOnAction(e -> { - scalarScores.clear(); - scalarInfos.clear(); + } else { 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 - labelsMenu = new Menu("Labels (Filter)"); - labelsMenu.getItems().add(new MenuItem("No labels found")); - - // Metric/Reference (X) section - 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(); - }); - - xIndexSpinner = new Spinner<>(); - xIndexSpinner.setPrefWidth(100); - xIndexSpinner.setMinWidth(100); - xIndexSpinner.setMaxWidth(100); - - 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), - new HBox(8, new Label("X Index"), xIndexSpinner), - 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 + 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(); - updateYControlEnablement(); 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; +} - return mb; - } // ===== Public API ===== @@ -719,7 +725,7 @@ private void applyScalarSamplesToCharts() { private void refresh2DCharts() { if (dataSource != DataSource.VECTORS) return; - + if(null == pdfChart || null == cdfChart) return; currentXFeature = xFeatureCombo.getValue(); currentBins = binsSpinner.getValue(); @@ -817,39 +823,44 @@ private void compute3DSurface() { } // ===== Enablement / bounds ===== +private void updateXControlEnablement() { + boolean isMetric = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; + boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; - 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); - boolean indexEnabled = (isMetric && "Vector @ Index".equals(referenceMode)) || isComponent; - if (xIndexSpinner != null) xIndexSpinner.setDisable(!indexEnabled); + 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 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; @@ -908,53 +919,54 @@ private void rebuildLabelsFromCurrentVectors() { rebuildLabelsMenu(); } - private void rebuildLabelsMenu() { - if (labelsMenu == null) return; - labelsMenu.getItems().clear(); - labelChecks.clear(); - - if (allLabels.isEmpty()) { - MenuItem none = new MenuItem("No labels found"); - none.setDisable(true); - labelsMenu.getItems().add(none); - return; - } - - for (String lab : allLabels) { - CheckMenuItem ci = new CheckMenuItem(lab); - ci.setSelected(selectedLabels.contains(lab)); - ci.selectedProperty().addListener((obs, ov, nv) -> { - if (nv) { - if (!selectedLabels.contains(lab)) selectedLabels.add(lab); - } else { - selectedLabels.remove(lab); - } - }); - labelChecks.put(lab, ci); - labelsMenu.getItems().add(ci); - } +private void rebuildLabelsMenu() { + if (labelsMenu == null || labelsBox == null) return; - labelsMenu.getItems().add(new SeparatorMenuItem()); + labelsBox.getChildren().clear(); + labelChecks.clear(); - MenuItem selectAll = new MenuItem("Select All"); - selectAll.setOnAction(e -> { - selectedLabels = new ArrayList<>(allLabels); - for (CheckMenuItem c : labelChecks.values()) c.setSelected(true); - refresh2DCharts(); - }); + if (allLabels.isEmpty()) { + labelsBox.getChildren().add(new Label("No labels found")); + return; + } - MenuItem selectNone = new MenuItem("Select None"); - selectNone.setOnAction(e -> { - selectedLabels.clear(); - for (CheckMenuItem c : labelChecks.values()) c.setSelected(false); - refresh2DCharts(); + // 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); + } - MenuItem apply = new MenuItem("Apply Label Filter"); - apply.setOnAction(e -> refresh2DCharts()); + 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); +} - labelsMenu.getItems().addAll(selectAll, selectNone, new SeparatorMenuItem(), apply); - } private List getFilteredVectors() { if (currentVectors == null || currentVectors.isEmpty()) return new ArrayList<>(); @@ -971,43 +983,43 @@ private List getFilteredVectors() { } // ===== 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; + } - 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); + } - 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 cumulative = StatisticEngine.cumulativeFromDeltas(cs.delta); - tsChart.setAxisLabels("Sample Index", "Cumulative log-odds"); - tsChart.setSeries(cumulative); - } else { - // SIMILARITY - StatisticResult stat = (pdfChart != null) ? pdfChart.getLastStatisticResult() : null; - List vals = (stat != null) ? stat.getValues() : null; - if (vals == null) vals = new ArrayList<>(); - tsChart.setAxisLabels("Sample Index", "Scalar Value"); - tsChart.setSeries(vals); - } + // Make sure the Top-K list reflects the current TS mode/series + refreshTopK(); +} - refreshTopK(); - } private void wireChartInteractions() { // PDF -> TS diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java index fc227f9c..4ece7bb7 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java @@ -200,8 +200,8 @@ private void styleHighlightDots() { for (XYChart.Data d : highlightSeries.getData()) { if (d.getNode() != null) { d.getNode().setStyle( - "-fx-background-color: -fx-default-button; " + - "-fx-background-radius: 6px; -fx-padding: 6px;" + "-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 index 11a73671..1f588162 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -2,6 +2,8 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.AnalysisUtils; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clip; import edu.jhuapl.trinity.utils.metric.Metric; import java.util.ArrayList; @@ -253,7 +255,7 @@ public static List aggregateSimilaritySeries( } s = mval; } -// case MEAN: +// 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()); @@ -444,7 +446,4 @@ private static double[] normalizedWeights(double[] w, int dim) { } return out; } - - private static double clamp01(double v) { return Math.max(0.0, Math.min(1.0, v)); } - private static double clip(double v, double lo, double hi) { return Math.max(lo, Math.min(hi, v)); } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java index 392f3d85..d792c4b7 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java @@ -1,6 +1,7 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; import edu.jhuapl.trinity.utils.graph.MatrixToGraphAdapter; import java.io.Serial; @@ -379,12 +380,6 @@ private static double noise(Random rng, double amp) { return (rng.nextDouble() * 2.0 - 1.0) * amp; } - private static double clamp01(double v) { - if (v < 0.0) return 0.0; - if (v > 1.0) return 1.0; - return v; - } - /** * Make symmetric, set diagonal to 1 (similarity) or 0 (divergence). */ From 984ed082639e3b2486df632231a35bb125c87874 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 13 Oct 2025 07:18:16 -0400 Subject: [PATCH 49/50] New ExportMicroToolbar for LitPathPane. Import wildcard cleanup sweep. --- .../javafx/components/ExportMicroToolbar.java | 558 ++++++++++++++++++ .../javafx/components/GraphControlsView.java | 17 +- .../javafx/components/PairwiseJpdfView.java | 28 +- .../components/panes/HyperdrivePane.java | 7 +- .../javafx/components/panes/LitPathPane.java | 12 + .../javafx/javafx3d/DirectedTexturedMesh.java | 12 +- .../javafx/javafx3d/Hypersurface3DPane.java | 12 +- .../javafx/services/DisplayListSampler.java | 34 -- .../javafx/services/FeatureVectorUtils.java | 9 +- .../InMemoryFeatureVectorRepository.java | 7 +- .../edu/jhuapl/trinity/utils/WebCamUtils.java | 3 +- .../utils/fun/solar/SunPositionControls.java | 4 +- .../utils/statistics/DivergenceComputer.java | 5 +- .../utils/statistics/SimilarityComputer.java | 6 +- .../edu/jhuapl/trinity/icons/save.png | Bin 17381 -> 7883 bytes .../edu/jhuapl/trinity/icons/snapshot.png | Bin 0 -> 7387 bytes 16 files changed, 654 insertions(+), 60 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java delete mode 100644 src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java create mode 100644 src/main/resources/edu/jhuapl/trinity/icons/snapshot.png 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..04ce0d31 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java @@ -0,0 +1,558 @@ +package edu.jhuapl.trinity.javafx.components; + +import edu.jhuapl.trinity.utils.ResourceUtils; +import javafx.animation.FadeTransition; +import javafx.animation.ParallelTransition; +import javafx.animation.PauseTransition; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +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.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; +import javafx.animation.SequentialTransition; +import javafx.stage.Popup; +import javafx.stage.Window; + +/** + * 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/GraphControlsView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java index 38dbc848..37db6fb6 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java @@ -7,8 +7,21 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.*; -import javafx.scene.layout.*; +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 diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index 88c3b442..0c5b15f1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -11,20 +11,15 @@ 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.SnapshotParameters; -import javafx.scene.control.*; import javafx.scene.image.WritableImage; -import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.stage.FileChooser; - import javax.imageio.ImageIO; import java.io.File; import java.io.FileInputStream; @@ -33,6 +28,29 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; +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.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.text.Font; /** 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..273726da 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 @@ -93,13 +93,14 @@ import java.util.List; 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.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; 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 static edu.jhuapl.trinity.messages.RestAccessLayer.currentEmbeddingsModel; +import static edu.jhuapl.trinity.messages.RestAccessLayer.currentChatModel; +import static edu.jhuapl.trinity.messages.RestAccessLayer.stringToChatCaptionResponse; import java.util.Collections; import java.util.Map; import javafx.stage.FileChooser; 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 33755292..c8cf465c 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,6 +54,7 @@ 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; @@ -158,6 +160,16 @@ 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() - mainContentBorderFrameBuffer); 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..8c0c1e88 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java @@ -43,7 +43,15 @@ 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 static org.fxyz3d.scene.paint.Palette.DEFAULT_COLOR_PALETTE; +import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_COLORS; +import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_DENSITY_FUNCTION; +import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_DIFFUSE_COLOR; +import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_PATTERN; +import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_PATTERN_SCALE; +import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_UNIDIM_FUNCTION; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -51,8 +59,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 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 4753f245..6de5225b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -120,7 +120,14 @@ import java.io.File; import java.io.IOException; import java.nio.file.Paths; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; import java.util.function.Function; import javafx.event.Event; import javafx.scene.Parent; @@ -1844,6 +1851,5 @@ private void highlightSurfaceRowIfPossible(GraphNode node) { Point3D center = new Point3D((xWidth * surfScale) / 2.0, 0, clamped * surfScale); illuminateCrosshair(center); }); - } - + } } \ No newline at end of file diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java b/src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java deleted file mode 100644 index 5d60233a..00000000 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/DisplayListSampler.java +++ /dev/null @@ -1,34 +0,0 @@ -package edu.jhuapl.trinity.javafx.services; - -import edu.jhuapl.trinity.data.messages.xai.FeatureVector; - -import java.util.*; -import java.util.concurrent.ThreadLocalRandom; - -import static edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService.SamplingMode; - -final class DisplayListSampler { - private DisplayListSampler() {} - - static List sample(List src, SamplingMode mode) { - if (src == null) return Collections.emptyList(); - int n = src.size(); - if (n == 0 || mode == null || mode == SamplingMode.ALL) return src; - - int k = 1000; - switch (mode) { - case HEAD_1000: return src.subList(0, Math.min(k, n)); - case TAIL_1000: return src.subList(Math.max(0, n - k), n); - case RANDOM_1000: - if (n <= k) return src; - ThreadLocalRandom rnd = ThreadLocalRandom.current(); - HashSet idx = new HashSet<>(k * 2); - while (idx.size() < k) idx.add(rnd.nextInt(n)); - ArrayList out = new ArrayList<>(k); - for (Integer i : idx) out.add(src.get(i)); - return out; - default: - return src; - } - } -} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java index d4463c21..fd81fa52 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java @@ -6,7 +6,14 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; -import java.util.*; +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; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java index 044b2f15..9014949b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java @@ -1,11 +1,14 @@ package edu.jhuapl.trinity.javafx.services; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import javafx.collections.FXCollections; import javafx.collections.ObservableList; -import java.util.*; - public class InMemoryFeatureVectorRepository implements FeatureVectorRepository { private final Map> map = new LinkedHashMap<>(); private final ObservableList collectionNames = FXCollections.observableArrayList(); diff --git a/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java b/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java index 87d3f0db..72cb0c0d 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java @@ -1,13 +1,12 @@ package edu.jhuapl.trinity.utils; import com.github.sarxos.webcam.Webcam; +import java.awt.Dimension; import javafx.embed.swing.SwingFXUtils; import javafx.scene.image.Image; import javafx.scene.image.WritableImage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import java.awt.*; import java.awt.image.BufferedImage; import java.util.concurrent.TimeUnit; 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..be291878 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 @@ -1,6 +1,9 @@ package edu.jhuapl.trinity.utils.fun.solar; import edu.jhuapl.trinity.javafx.events.EffectEvent; +import static edu.jhuapl.trinity.javafx.events.EffectEvent.SUN_POSITION_ARCHEIGHT; +import static edu.jhuapl.trinity.javafx.events.EffectEvent.SUN_POSITION_ARCWIDTH; +import static edu.jhuapl.trinity.javafx.events.EffectEvent.SUN_POSITION_VELOCITY; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; @@ -14,7 +17,6 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; -import static edu.jhuapl.trinity.javafx.events.EffectEvent.*; /** * @author Sean Phillips diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java index 1c4ee3fc..5ea50fac 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java @@ -1,13 +1,14 @@ 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.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; /** * DivergenceComputer diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java index 3933b694..b219e36f 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java @@ -1,13 +1,15 @@ 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.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + /** * SimilarityComputer diff --git a/src/main/resources/edu/jhuapl/trinity/icons/save.png b/src/main/resources/edu/jhuapl/trinity/icons/save.png index 71a75c525631f80bed5d7af24929b3bca129bfc7..520b505fe29c6f19bacc02c3583e1af999e32c1d 100644 GIT binary patch literal 7883 zcmYj$cT^MK^Y$hop-Ge8n+Vbb74Rc1bX1fC2u0~d1f)stp@?))k*W|PLMT$C_aIWF zgEXm;(4_ZX-h6+5z4x3wchBtXoSA24@11>aG~%fi4HX9!001-(wbk_i07SY40TeLO zWcnKSf;3UOX`6Te0J+uwF3`ue+G>)J-BZKZQ{VN4r;pWZJHW@sN7BK?$-~CV%}&zw zwSDHM0tW!_7(Y~3G4TDgG2{1%Y4Nl2_P|j=79^701= zIq9?}=!Y3NukFTvimSV?EFtV`}nh<=}?4S_hXRMuF;m6Fka@QyYz>6 z&FE}uYPPJm9qHj>w5bN~0Y1P;)!kVstNP^1*#@R2ew5il#)lJLFK4!vXm zI8Oxhy7ujs*S(CU-4S-t2EcmY)!7v#p~x@h>u?pw|2Thn^TP0dHYv=2bG#y~&!wSH)fQesqCfn7^M!bi=7NniqW1~e)R%8L)gDFs^gt|Sg>y(r||5<%Q zT>WyJnO;XTy2(K@p?^E_wn9ZaQ3w@{)n-PmRbIuC?}z=B zL2_(jAIM(%1Vy5=$~v23C1Yz!3mX+cu*iy5To;(*)6TMK<#rW%|- zR|pMqT$>E$@WB~m83RAp!yg}>>J*!@?fY>MhMSgdKM>k9E;x6)76J~G@*0>^-5CJ6 zGN7gvU1l2uV_BF`5h6rj+8Ic$@Gm&a5t*evHhug2TV0}Ac(N`72WTexb&cC9?n#zF znG*ujnRu2@7s6)$$-sO9mc+MZe&>(|V#;{_r#t>^_Mp|GorKlfZD)WZNu<&zf74w| zJeYO+WXxEaj$6T%z4JNJ_U~J;PXbxjv2qMty~)v|85j8XGQ!TZXwKhhI!EtctO3Lg zrT?F!P=>Na}-h#GNd>v%W3#>W}7?*^3`{ATZxd~rn8v?ej}^A z!CIG)u+bW$tC`c)!!Mw9qHm8Sd{gC1FeKhOmi_tk;pt+)!D!~0*F~_iR{gBO$&|Sh zDx6_%&40rNgZx)dFlmK{yOh$v*sEqXExvYQC0JJcoyn|GFs~-YF}SVecqTI}Y_n7m zpqcruA9@dR`VOq?in}||QAK62!g+YH9K3&vk3F^KPC?=4xkg*ora-9x!$p~&*{jiA=tAcPk(Iuv?NI3_Sdm7*Pfq6;B|D#c;Tp2 zp3BtZZw&7?X%c11s@JNh9HGNrN5V0|qPPuzntlCO0rCBf)fZ0+6)hlMKw(1@4}j@D zU{9{q<$pah)%Rsf(p!b69ys2ePE>mHLK3)PR<)NI(gWtRILtm9TEyNF-n@?++`mQ4 ze0UXbk|xvsK;SJn6pT6G(mjgrJfMS^fvkdkMy6#hw@xiBEQRV3II6yf)(}iZ zJm8JEK!^!KFTSk=ZtILY zZ5&R*Wp8*I;TKa*#>LF7)hq#{GGnjg z#nipL#$`X~fvRQJzdAEtOF$5WSH_wqK5%&uKF3D3NPj7#i>I-GblEc{SFv(*R&odS zoOQ+;1r>qkK!`c#J%7(L28c1ap8j8=88YHO($Y6@SJiOWGGU{UqPq2E>i2 z=rtBV2rcJ&wvX#Coq-bki5mjDZVYiZ)i%4z(iN(>G0_~qWiWnJG}Wf&7*TauR!hvG zvdV=x8X0^oN@@1pc>0(~D?CRvOp`WH*mL|Rn z{!D&y2S2Jcvz=PXpR4*=>raf-b*U?U9*?&77lC4*h)=(&1Jl<=)%f^bg(# z8mlnRH>`($(1x~!o=O^Ta1mW9T64#L7(&R*DXV-ENZ|d-pe7KLv7C09Xg3nfKSE%dmP(`&xI2|nb-D7Z^@@!j zaU&xK7b({sd23(-HIaCQ==b8AkIkn$g|TcxODvcHh3IZn94XmQzV2|5mYM6$WY?%} z>kOfwFMA~f)Z1SWo2cz+&?1^V^G$npf_zBmbgXobucT%L|;~mYbds_I%g2}@KLF?Br&9ylUL*7hyR)B?i-v{B~ ze|q|PHu1exDVT8Vq^$Uq&jaA17qSr$y-!UDo=9-YN|!m^lVaZ&0h--6zVlLe()v2V z4IZU%e*6&P(}uhH<<(gNCPX-;%wX`n=N%gA8s9;h2?3||?@AZHe*o}ur5Ec=$P|dS<%*6*7d2%{&Ns(ND2F#PU-3)@eZ)!3-ac#HypLR5y^Kzoda{LXA9n*gzNhovYQ$xxa)=39?QWB< zMglwqie|9HIDJWd+DFGK%D~)B9si!?NYT1B)a%)zId#pTQ)NpS6ORxv>*W>uSereAW8UGe_hQ{ zSW`NbByqskIaIVjwb^|o5X;X@#et~n+9TJzMyIQ8rpf|VG-zD1&LPa7B{3M3U6;xe) zneg9D?9ah|S#(@6B@#k` zlf{wH5wZHToNGT^Hx8)hYH&FND%5oJ$erkJ`f=F-L;EH+in6aTKG5FVQ8y(3srO02 z&Ljl5;L4Qe`6O=}BzeLPAZ#+sT5ILcV@nRl^^Mz-jLH^>Q5eFe$?<} zrsjjU4}`at?$ZGzceMEzo1uhmyn6Is6HYg!r7tl!DqnXy%UG%$hm5?eSJQ%b|@u^;;T$ z(uay)>Kdp}4fI8On@_^A$987ZGj|CnOEusdc!_&rZ(5CIPUmFRTfT7X!=JhMD62%_ z8h+c0E2BnC$q2qthAL1|_>6G2j`xOO7?J7O^Pf_JjH7e4)diNfalwj3aZL1tQjkY0 z``zZ+m2scVWAt8{>_s%k_$hb2R1wkZcU5uHOM4^Byph$KJz4ar=V=`3EGXo(`$}r; z>>KXW4N=rgh?a_JOVbTuzP)G)pd<1$)A46UK#OwIiMUbo)$8#tAF^G~$RMMWn^k<{ zTg7Mf*8QvDj!m{Al8Q!q=aE=vkkR{f{3^U8SV*A;-+yi7!Cog7z%E4Xo9*)Ne7zxMuRGl5oYpcBO6QjHpC3B)=&WwILkUkdZINPl5 z=?d9)u4o{$ra@Pyd;Vf=px#H5SxCpepZ)?+9m=7K+67O<$?B;9Cxs&gYbeQHyS8W% ze~F)vFJ0J{|7tvPf)dDx;ZeI|P}JULZ}xpRH_&!$l*ZWZf<>&~`$+->BWZmw*j%#m z-!l6&?hsitp_Uok+!iTOstnrBugI_NWgNQYM=KPtxxD9`Zf{X0I8L!J#4Y?rLzv{i~dNyt_s>8Hykx_@ zVum-jCrvn9Q(>QcX6N5$;8flS#=J|IduX{TIDo!5m%Fo3K`4o2L7ofgh@WVy=(t%T9Hgy5rlxZJXhNwneFKOZMQ%F_C z7}4NI2?VG@Qj+LnevZDd!y?K_={y)-i4)}f%KhMN7jaqF#P5~toZWyd8k0{(Jx<0a z%5Z_|V_Y_?-A!wro%jMs74~zW%V6;5+FeD@Ewi8#rGvNJMBD;p&?On@P(SNkjiN+a zstPR-=K8j!%AzG7_PV~XV|F7UY?RT|b;Se%$4Bt}{l7>z3(Ndi1X2QXb33Zc3d742 zEqtwd7}Dq0k-%~D@;c0*#~hdxwKYN|sjeGsyWlbST_x`Os-K22qPn_S(}BK`A`MMa zpq{X3w?|T5lpZovnHJ5&E~7_-aLlV=_&}PyWG&{TY*YI16$BCTF~C7!5n;Kya2~(^ z?#B|PK69D9mq036tXLl59%4knuzji=iJK7vb#i3)&J))HUPpnu#o_h&*gr&sqigt*ly z8V2UqLb^0d!SGNP`(HxEAjB~T`WZJJA!Lt32mB-*iyE$88muLfRAd39vv&*WQxE|Q z=674FffK3soR%m6mCMGlRh<^WhAPF0^vX2bMoWaPD!JJw(GppY!?xJUGW zXL9P@>wbz#($a~JYl(mLp>R5=73$REZT^e4YON%)o4g0Z;BqLi`x4$kkF0y~s&iz=@Uj}^2UH!Ftn>}>lCJT~$o_pcz;x`BT z(hH$tCD4F)l|yE~u4lWDfLivwf9;P-q++PmwAWCjmPsPvWYumA{W_v&2{atNvs+zZR~z3&L9=u%kg!oL##StxPI z06rjq7A7DIq{R0^0i9T+r6{O%( zITRCETeQm}0eBBoK7_UbjTV?Rk$<~$G!xfW^vJHM2s;2}VrYhw-cd6Mg|8BNpk3wG z1Ku#`SdDVa#!x7A>k!nW?z~~`h?$NxW>pAq#4asM0^lqL5mTPV?Vg*3I5vYpDBbx;q+zY3ed1^S&^38$p*8PfRkJZGP<7(u61{aLX zymz-*;8t18sMubaH;&Rv?M}M0$2P`iuc=U()Qkem`ka%7bNdUJ(NNdn`K);Bat{VN z*8IT``w8!KJeUv}vemMm@j>naiRpYWdxs4_BL#C%cWlE%Xy;IkXMK0iLFuF)_|B}k zcS3+d-zQ{mJGr%TD7T-LC;l;#DBAz#XVNC(J5seOnndxKSna-TMqCX|@hMcDdEw#;Fzf+m+3rt(# z?){mWPp~ZNm>Vet-2A59lQWB{&{G+?2i>Kmr!Ss7X(0zG77VH&_0r;;0r206%LvM; zzDXZWJWEA3_xCZbX8}~0RIoA6>Pv$MOr;VQ=4Xg$D}>eJeq2k3W;qb-exlw~+FW&z zskumo3w{Wd2je7lq6`een9)jNc~|j!tdF#oY7oN_7f)3c7xt=eIzYEGC89-Ximra? zcWJ`0o5m3BEbJJ7`_Yboygcvd2q##{GQG{&`_FIDnh`AroIE7zUu02#e%!TnV~zB} zw;!|*DA(#g$R;uhUfmM4VAWOw!!{P~9bb9ASNGYDkI8|7x0CV1jZn-_MnZV4jR*|Y zXtZwg9R~mJ?z3rc_`^(-?e!z{ZaO3>w6LqVO#ba>+FlMoXm(Ed`Cdq&|1~sg&dYfD zEQK{-n}bjIvh8=(0Mi+PwDoPqH1bgZ=L<&{7Em|OcXZG17(@85pUO#=W545tnPVaf zp`-@Opq13if7&_CRXfd;3r7nwzwTeRnWAmkJ()aGv-{x! z7d09-_mU7$xc5cYTux~AX0@7Iz1*rmbSb$`Cnsn9r)ssnk^+i>Yu?Qva@A_g)cS{3F zhhl@gdR;aZG$ik}P;a#tQ!`)3jatEKyx(ZWoZk7OUoZlmc}K~HGm3y ztsRr(zdZ~Du2=_#7!!KJ^vl_{hk%EqJ&IX(!UaytE6D}@5r`+@!<5zY>xYI)w1_23Z$%`rfHLA#9e_EzJQ ze_wvcf=Sr7dm2uIl7)uuwr!HgfY&Tm(EUdu_1m%mvVFJpcx z6uQX}#3F#Cb>YpvWE;d8?Nk;{6WP!9*l(j+(o^xGcy7kYj>0W7GQu785JN19`1!9P zD6n%1IP9@W$yO^fX3E@6vo}FvSO(2Ne`FH>l-SGhE$TclTKQ!UlDbZnz(Bz_bD62t zGeIgVM#8sR8%(2bCKOoIf$C~H`&!2+t1M8?f)V|i36Gj;&+oRd$5@eA)SMIU%qC$I zQsI3byU~nOVmo4vyQSxUu0{PdT?U}~TCeC86!1}*9j-QAj_40WZE>IlLAP=XsK1)1 zyF9{wjJwroiw?=!DQu)J127aB7Sa_Ug?DJXs`T$V){F5^G8=iFFKEAx*E~uNm!}(t zh3|`j{|Ijy<8-snwrMkW3Ipp8cuASke$gnphAE(=QegEFXHuW7UX9-h-i46zmDMY6 zTr`~ZSrGq~@J7glsb%be$517!ZQEg{F@P|i@rp=bEV`mt`B`gI;ETPYSu=D0=d`P} z{5*!84wcZ5FzDVEWW)JOLO^>$>V2}p-_N%nnYSWzmWC@ zOAYFl^L5F6(JpFz_B(VSPck{7mdG#g{|&71MM!hUO`(ZjS~$`{H1JU4sd|a3W!V1% Dhw{Yy literal 17381 zcmZsCc|4S17xq0f7}c4jCQEhHj)+Ow10Fhde5qQ!2=zL)GVV<}XYWG5zM z-*>~zd{6KD{=R>{@1HS$cX4Zw7>ZY3B?%%uBJVXuNn2MuEwa0^jjBMh%SF~?xqFslzi$nesxHl zL9ISNFNM#_dQ_==6azOmH~%V=1#14JAXI+3=p;+w&))yz^Wp`o=?(iVa<%>E$4V>< ziN`vAKuS@r(me)Fk7MRhW*Pl(4C*wQ*-LOG#l ze>KcGigS+k3_Sy;#biH+I@Nb@JnutJ1Xdz>=L;y89yvP@1Zy;`;C=9owii9ld!8ox z!qf`pK^eRpMLWhwr*|p?(F3sGH%)wXN3 zELUpHMsr%*hEqiNZTTiyB z#-0O`fevFW$Jh+RhQ1&E>orObb370&jNROr&QwZ-85Gs`KzN_1J;AGOiZ zJpTempFU=#`#;}cHeNI`{D~_CH7ko0Hq{`rjw%>#HlUTW#hO9 zjprqTh^#FY6270`9*;kGozRbi5!bn5x!BNhTl3LYWzY3G7^$vo=k|Ak z#r%#fko)avy1j0nrgK>o;LZL{vA#kOw(MR0Ep zx|yRG-L3eSU?_5dl6%Cs+9ay_IFM8Ud#(qt{-~*KNS6*}ZpeB81O-^tT|t1-J|x)BH{bWSqZ20P zizso|0$HEqwI&D;Ua|UcQBWC}#goCVELsr+(=jC<)@)e74%r+>L;BlN{r)my z0CwTcD_@{;!3^Z{c3_-@uv9?Qiq7p|gpWRN)U(cE2aRKvY+PxWCw+8S**yzpP83-i)=7iyIs?m6q0x$-@L;?P#-*Bp|6Nz#+ymUAE=(J=Z2%Lgup2W zA2B9zGz)g#VI?gpWLl!~;EW(BK9HDXa~wFy1S+A;4#8L!#(z$*i^&0EH?JIRdLe}S z#pm*TU}#mAoL{m_#C)*3yBp&=UkQCjq(JHpfLpefdL7{ z=}@mLgqsX@$FCem68VFK@but_0FW`Yxuvf9>2Xe$vE9bsyg9rV^ryKs9 zX9E3@8R2z)_k@V+@gp7(7u?e9}MFw)(^E>?6cJW z^k^Vo&tpp9zncjE$O!@;NArO4d*Y1K7dFTbEK~sc#G!iy1Ja{J5A1Qgkrgd@fkz1Z z7IZUEz#6ArT;cWw;S$WCIEoSeUBXAH*GC=+1)XQ?MI{{HMm1Rnl*JSxLJ+UM6z%1NO5Y+8?XwVXV|4A@UJ6IahwLZ@o+qk zCF~Q-@tFv)s$|!DhDX#K)y7sFoL};nHX($Fd&seN386?njWa$lu?NwE6nqtoyb&`~n6N5K7QK5;*CSzpPEQg2XIN zP#^>jYeMQ5rW&<{5n>5@@XwL7@*4&25j}-AU0paEng1HQ{Uhx2xLs1O=vlN$PU<1T zX`wI){ph(#m`WOt?wkdTl(x738uxUUvj+yWg&|beNSIceYuJSe5d&G-9KZSt6f%Py zeD8@cHb<9GIxO{nyZN@*--*OJrP4|I0arG+aD~Otr)=6c&c@esx|}|ZLl-LB{OL~p zSw7pmn5p)@VK3GngQ3&Q&{})Yswj0~8th$`0EcWORbQ~Es)1dUnnPeGcJZjvcF50d z$B{}e;ph^G@HiO2@5J5=NhJ0XdF+cKI!|{IZx^U_Cp%lIsoN5B7QyrOHIZB)Ex|D+ zww-WU6h;SYAv1CyNY(~`M+~`^vAvMi6Qm;5+~YP4{NL@jm`yNcT2#Lj2G!Rpn8h3~GCc z+vc^fFtxB@_SxRXp`&ACZl}W1Z`bOIXqa6)vKS|*p+PnNaCTZTNvl0h58ep??^;IX zG2o%gfVP=rURz;0jLc~~YF6YXsLtUgXaZ5G4l(MUk;j`^U8RdB}K90pP=}=*79hZ%@O7cZZ1Dl1k{#ff{qtR)WyNkF;M!H>xBTGQwS_&T3=xQu%t)y z&7o_2!Yy{${qJ!Ik~BF?k699qDLVTbZD1=j#V~pMcGBPE5$ho~&+?U#1ANwPd~Mvk z#M+mwrd!MZuwSK#r2}7%!ITztAZpzCD7GhYk+ z4ph^Nd|+6zEHUinY>P)4PztRjr$4hk6V&YZ-!gUf;Bn+F%4-;Y(2^ErUqp!f(-4UJ zzWlT^^zi2$iXl9ym31hfGoog*=k1frUs?OIRqUcR390FVRWnUcZCSN+g=$Nz+h?>W zq}yQbim_uVNBu6hM`(9t0@G9R2Tv~1Zczf;;bPmQ8|@fMh`>mLc6JtVEO1XLRe#5} zSe3n)`#|nf2{`BBeXGrPIg#z0m5cYEkKgaL7&&5J-cz{oEqq57MyTcR6B+5NLb{}Gpbc+-);;)sw6<;*(GDxO5(J4M;CC-c(hE$K zjN!>O%%{P}(W3EK#ly($0kdri$ESrZZ%EMpr*dA%p{<4Jo`CfP{q*i}K422)uU9u8 z>~9LZL%AD%G&tk-KQrxr8g(>`&milH~n!j!sF z9~Ztbn#0JD!X|oy?-S=^#NkZyuZRfKkeMH;MKRGr*@G5H;L;?WDUNi!?FIBez6sn6 zd{Tfk$t_Hfk^+_)`=NkUX=t1ntR|6?R^u!0f;n)_FU>c#bx=2YJtMw|{lQA=Iy=Pb zk-}_}uGhc{yQS=fxKL?t=>%*GIDsv4O&4;~bI3{05!kCA+-kC1uPbvP05YwjM&RXV zvaI>?n2fD~is*B1gogeQnd54q?W`wPRL`+sPeJ}>=Kr1Ka22!3TJgfDnaW%eb1qCt_spAsx;X6t^fjZX$(iOG?AR9gSP8B9)G zE94wreyq7VAY}%g&yTWt9Wx21AH(&I|Mfb}%?G_!Fgxmd+&l0cq8)Dbm|Eq3HU_UC zNV#E00~l*@@2^5R=T+eRYXOGl6+VtY`z4NlXY=(FuK4kG!@pSp52I&EXRZalUY3?E zdUUvWi=yPaVW#=D9QC-~kIEf02Ew5H2u(vA^r2YJ!FuY>!Lc7OS}o5-Al;LZR?+li zu?LENyvp9&4TwLzNKQUHKQ z4@SK&fQuwKtsgF9#{qW<;|NE~?G6832uI#(yKbV-n_MK5Y2kXR{OTp|OhN=GYmD`Q zUt+oFGaMq)GvxKq1sF zn$5jVK1ZIwKgw-gb(#WhoG$6zgaicXyP znDe~PgGNCPxc=}=XZd!Abmq#|eES*xFlBWtcZ#NNppv_4;TEfh77|9}><7e!QSr2g zoOlb|+q^>Z`NSs+RR7W&+}Z;z&bN&*%=RYNNh;nM5MD_;)_BQ3!TA;2oDslHs?`m2 z=b%ZY1-LZDMn>t7z0BbgjGijue5FkIzwZb6c<)}&1?4B{tkXSurs72hY5j$_!vx=^ zTDF?Qg11BXx!0ak*fqfCuA^IaSH^%w!edS^#CjM9k$Xi2D~z)ZD|Z}L9y%PlO)4&R zQ#)d?1o!xk*7rPJ;bwg8l@Y{p9^f?Si)H%3|CCr^ncDfIAj@WGXJWgNU zqrc7A#Dt*vs=F8L!EmQ1UAmM~O{bBE)b=rtuiJb+2n}gd%h^S9$v6B&H^}9S{Mt#|kw~5dCv?$%qqh;*s|Ef z&SH7_LcVk8DgVijhDCHW1TewMh{Nyn$yoW!rkQ?l={?_q7%>A0AVs9Acy&DKMQ zwaS&?*!1dA0@%XshaI(sJ<^>Wm&of&gvdokNdK;VOqdd|?de%z@VINMzyvEE85u!) zXPQso(%c)5)b~8eX=Tk{K1AS!j{k~@>F@^0Fi7y;nG(WQy$d(H=IIkelU20{OU@P@ z+z7`a9Br7_{8n0pJbW+$mpz653r7pMj%N|8K`Gv8)!yoKkXm1=PC@Ar(}|g8_xrD3 zUxxZ3@Des`3EUKzzA7lF&Opuc-Nt47$jQ$y(?N*abzxS_Vf5aGyV}m#* zWnsJX2I3r~pU4EdSyYGsRH(${TpJq?$uvTt75Ad?A_AXA9a7_g$Pi&bgA02`j;G3<=^wnE$7M1x5;b= z7@FsPTkwzglx^d$4?Y_i7ooY`@o&5?U-jZ&t01Upt>thZ& zkgfzKCnpmd_pJ^|_hMlK613+c3ZTarquqHzmYz)e38L$H4vEh0|>u|7IeqEJFBL#!#q&JQFHaNQa z+@nDB^_KTWnB}cr_WO&s<5QA(bQNIyVz+1!cZ}fo2d#(PvaUy|+9a@h?alQ6B`*}T7?>owEXcs5H`<$M4yPp7EeQYW=te5Fz zkz~+~R9USrI&&(e7lV3DzEM>cbx-bLTEda+LdU1!LtUtayO);!xLa>>y%|oqv&V zBz2%POc%M%BPhsRP*CuLYUW1z(TMpGV_Znv3->{Iuax9_oYYiofk9q}hgXa+8bCxZ zJJ82i;WB~kw2^jMr~e0UVZEAR#W{V^+;R5{Io@HHNeRQD&Ms2PT6^_!x3M)x*ZePk zFuhA)DdbY*fRv~Oj0KmNC~#BVgHaRret&N|5ez1Q0rN}y(r_W5Ma2fX;KI>~#Q zcT!>*tEXZ<{f*T7A(6hNl2q*Y;qKFyf?|(o7`xE^c9$rmjhQHoD$=#8iutOE``Bkg zeAvYT1A=o#Mn;z_A+?2&r~mtR^r=ZA2L=#7T3wENlLSv2?O8vxt|#AQYYG^6!;}y* z$-b$IX%nTiv7Yu!Gt~$>XB@O&N-*Y0>EqXYZskz!9hSbxu%=Zl!)Bb|9zIfYOI*K8 zVJa1EZGG&Q?a3L29#!`>Qq!)CE($6TTjR#aIXZB|vr6E>?bz_=)BYE)ME7sz6=b11 za*rjt9f;hdS>K9T_2rbL91cKDhDJ8jp+e(TF}H)=Q&OcWR!%CK`m5~0p*@?zu=K&N zY&fn17Y~EriWfwps!69$C2(wa6j>_Ps71R?p?il_2t>+Z7Bw#!}x)) z=xaDO+G_Yn|Jozk_8lV>Cz3b=D?YiG)D~=*fC=WuH!e@S9ZHzDnB!$4<8{#a5{%Zd z181!23~u%6n!0!L>DuE?@#S7VRwDCO2IMEduwSf(edLlg5FLMYnH=%*k96vmcNSce z{wD2m5^=T;Y`fClCYmza2wyWjo^_&5P5@ro z)Txu&>Ka%aa}-4E5OBYG`!LitIQNpy?J(N-Jpph-0}JgKjacGcdz{fJ8y}K+1YN5Z zYGdHXgFh_}QyAYKJo0FidOLx9x&u|9)#!KzT^`UQ!6+Il12c|y!>M@AHa9)Ox?d$5 z>=CM8s51b5GhoVj^5xcUzE*Ai|4|42C2xSsmc8~NN=-ZXBD zTeRtd?0jU#T1yTgnfOk4Cq{a#4K*+>n&`n)Fm=kA%v{3ff9}tw)8C5n2~DL3zfG#I z15?AK^SOL25)D-m;`6hk{DYZEZZ%RBS>qvvPbrfp*#-|f_o8mtN0ok>SG*(W&MoIp z&Y_y^eV%z6x6J@|tVKX7$ehWBw#!iVl+N2O|BL0TEp8#UF`A}SBePZUL#70bK__;j z0~O4Yd+EcZ7Ao%;@jJc8B1Mm%b4kxwCrK%9lGU94B8z5hhFgXs3sArVd@sHhQ-yxn zV0E@vacGh`mTo%ena@&bngX^-OTEH7+IG)z|nbJ-F&n zb4)Dt@SxOJHrP_#Vk`JLf_QkB4Kl;*bjjg z^?Eqi6>?4xO606*fKzP7Z+>{V$FGUEUtg`=`-|eWbC?2aYEZZ56PNr|p2J1FtBa3+ zdU)j9fh6vwyu)pv)v0-yD**XE$ACkUpy_H3%>$ducQG{^aa z2c<5Li)p`a34pg+P?{MVsQc)GcU9V1-o0h>6&+mc>oRB0yx#d%Pn}#a3|>%L+uqq^ zl-#Mg5^Ae1_IpQhU~BjMUy1U{TiKJ&hXn1e(+pr1+L+OUJJzRlM!bv8^>%lhs!aRH zhE3UOY4-8bS!vu*Xq27UV89d861`GW zJ%J1ly`Oy8cY@M7vCtAa>)_&~Sgx$Q{@QolVq(YrQoDi0bGdbL{Woz!b~4p?ENSiB zzE0D4h?z1QaHq@wNX5e_6@$Lh;3@C-YHzl)?thv1%B``p$!pTm_!U-Iwyo%wehG%|4~DQj<#DOHp-IhNd0v_- z>z;W5_kpXo?X-LIH5xwlV=19=JmhTh_?*N@gQXE6^aIQwnM|S8&sgu3-iyv%bQJ(n zkayPm)T-FV1^db&~SdVb@;URRjDV#d&Q z_31%ROFkX@o#Yv3>M43);r+7$quvaHJg3XZkdYr?R4uZA2j*cajB@A8l_IqbL8XPYC^9>=_SEqGrhk2nP?^16!DwiuMhV4Asm`O zv06k7UX-SsOQyV*qkWv~Qpw_GwYkZDZ3y@srdJUHUfv(G6$!+oArA zhYu&6X^VCYFp@1~&DxoL-9dLm7`isZ2sN%vbIHHD5;XeqGPdXg?enx~NJ81_(a@w; zk!RB6?4ToRZ>W{&R{q6X7Q;x)>p68OAz~l~v~F;luM<^p4b@PE@@eg5Rd&X3JYAG) z7mjrLptfPpw%IQDgbgCBo|-K0$&sPPKp$~a>OSNiI*OTQKJmXR*%2f6aRXnP@x9kRO4*|XPMFyM_d!<5*RGgPKTPV6r{sW?~h_vfG z;qJo>wqPNo5QHCk!xsDI^OHib z5O`qJp#)OT!g>m~AC#3SX)S+hh6MV%7yQO#PhqT}T$QRh*~Hv&w$hJs#%8&(m@wvj z@G(mFE`>V#Ru2{==>52X{n85rktT$O^~j!)EnyT{Y)DGj;udHP7n_fy=_DvC(pAuo z((qKcKKu3>lGPKb&C&E(|N1D3EhP1JoXOIw-2++y*}%#+Abe%lY5w=DARSg6io$yj zcY->y_$i%p!=2t(8i);s=;X~<)ckGC)XJ;AgS+Ex3T(E^?v!>TqG_rRLYv zK1Xb3O%Eo}%1%kQs9RV*T~3#)pM^7l!nL9x1K!V? z9v0V4jU*}ABim<1n3(9w1^W-W?`uszq5IVQ6O;0k!WEXFh+egPhFw-hlFS-%KX8-MyRnPZR1qsPC|4=^?H%T-jRmeCFL%0R2Vdsm;-maKK^05(;XvuncE-P0PeueC=uU*DDaO?_ z6%4eR_K|@JL++qs4%-bM6!`7s_ifItbZ-PH zW&=D^EEM3-%)rpmAz6AOLXYRc^!eQ;7*B@slYl{c4>$dkPWEN;;wg&j`fuXXi8%?I zPAkX1%QDbOvDqYOCJi{$Ld0L7-5J0UGAc67V zmGP%KF_hoYLU^C^Me_Pr7)cYT`Fo&3ZOpnIM_1gK?yO7izm}$74?kDcYA+6x|Gny< z^Fg|Q%W}y$Jkmt__&v(alD$y}NP}(lGgAgzh|^i5Ktf-OPIN*@qj=5wsj7q#dk&$R zM+_ULJ<=d{ZZW9mLhwQ<1W10Q^I@&0qwN|6)lwdG9$G6V z*H4d8MQhc$&;%;VVY}P(tRle@l`36vHcu|O_e!A&+qz?=6x0AxK*OU0m`UtFdiUpz^!tMls=vZd*^mz zP)4aX=8ovBE7R5aY|$GABPXAk2p%;Hi}9iK_a5Wm9u;v~))ZH>tC>Lg32v8>zp|6Z z0uGPnZ?_6!7D*5*K4JmTSj{Ja2DLrLJJM&s=KWjBxZ&$;Gl90k`Lj1eq!q=!jPLz= zz13nu%6XjhXW~5G+;VtZU1JTCL+IdX+1hTKV#Kyfd=6HM`@C@QCCuj5?M}z{?t<^% zK;kHyyi?pjWFT~)+VpQ1{-!7C9DTHYB88G1AJQmA5G1-7v6wVWgtG0~g*3GU#;^`# z&QLJTo#EdLpSFw|4hmwv!<9JzZT8=p=pgpBwf{Zh5&Jdo8P>=qoBHq$)_f!Ktv~#M zBqU}CrgIj4GqRL$`p8Ykm~OsL8)TOKe!8gRma;njlcJ`-Sc*|aZ+0DASU^`u1RQ^Y z5#hLkM@58Zqy;@9+I-;A={k;+6eYaFEAHK%tVbK?$@Kb;%X5%qN2r}Mbk9sg=iQG> z3VnBgGCV^gKz*rn@$Qy9b8jKiS`5TH-F9?|-SO}(^NoA1xBKIh%3}VOh%q1nUbla&3jYz9!kS&}| z`y0!M78ov&eTjx&)nO&cISPlU&ZSb6Ti;`&yO-@B~MQLX8P zAvA-2K=UH+t#Zg^5b0 z;qq1(RZ|bamy7b7Hhj*?*}iFCAQkd@ZwmS0g4SHl2E$(Q+x-igJxRkUL%U}{Md>P79r`z5Z2S{@d<+xIuEa=6U5i23}O zEVtcNtP;|<2>abis35OiW5cSK39|;Iy$DLosw@irCHSo*^r8cdP41Y9esM=Q28Swt zJrb(EYM3>NR%**WYr6qxdd6)Vmdexo{30Pp$1CUons#}sAJb?h;ULDRe4Fm3R}UHl zuL1)w?bCrC2xXZsFn#zs?*muYk}ab#+;-?Fx<^gQ_EDWNak`X~{Z4Hu;kYZU#!y_r zM<6)`0Z6EJiQ%ohj{!;nLxLQm)X(MiGzdh^zb_4NP`)k@9R6kh9Kc>s{1G1GrInD* z^yYkR;Z=ivocn8f)Ime5pPZ0fDO35!61wqRDTCa_(iH9d3%?{9$07r3lpIA5qppZN zFQjxdy6QCl4tm47e}Z;i%;q2}PI;;1*;sgzXc;#A%bVm~huZ?c9vTwu_H($nNM$Je z@L04@h+^T1O*tooGwmWy7a*N~U*pMpv#>}JXD2_#Cbo)jFa{1!XP7&g)CKfBDGvRr zNx;c`6?ql3_9q!p?${a9c~BOhWDM&O*A!{2^WmC*Xv&#S-8!GI-2nSUS7UbHu>b|n zt>fm)6z>zNJj*?uPwuCO`R`B>}WE?DqFqzKl{&KyDOAaTL*LOp3DWD5yISlA6D{Jr!xta`_!DO>D@?IQ)1o(rZAmyfVM zm<*vkFkZrFOK#P*#08NML|Mq=m^m-#6~R?@Ld)SMHnQWW|0%z)dKcm7mSp?@n9-B-2T`4q27cEQ|rjVQ|q8JS3;Q_wp~9;87vGb61gURmLFhY9!MA%ILd(KzX*$i zxnI#u*;36{jfN3L_c$#xLF6-Sgx|ce$Gbx|s2PA++`D04Izjj`MKmaZ&ZlT>>o-+c z8rW-2tqEs5?1mKw4Jm=cQCKz`7EV;^=XW{(ONs)wWxr1QlhVHh71_7 z+fQMneRm-=99t>?ZX20Mh%o^ltvqMg9VxX3?!Tm2Jx&l}>KWb@)4Ok9i@QpdaVHzu3M&7y^}na?e@Q)4 z;QY%7lEuWC6`s9HvYSU>CA;Qp7Y;b^?>p&pa`9L(X96D0w)%W}7M3X=6?X0Oi zW2>~?z{7~*{y zW_nzYF*W&Y(1zVwPC^(s5rV`^N6p_W*5+sNftHMeZ?JQq{fXa6z``j)pa0=b zH5stitC(^e8q7|YB45s z;C%i}y9udKW}2S!K{ZuN1JTjzAhlOJ5hQpi^G^@_;+!70i)))8rr_=uK;njj!vu&_ za=Mnju{&V7wGk>Xjo{71JvPTEqtj&NQ)Yc|$Ds-bEi7~;=qOMPHK;z9He6}yLrHBt znqNWPfUTs2UtIHho=j>R@%k*_CIN`Kq4us2Fd%D=@_<(ZrFQkCqzkKIA-dYl?Vs(H z0x!BpBn1J+dX*kG%9VS9LtI+n9bEUfPTV-b0Ah}SwUbb{hN;B5l1YJZ=rvO@xEPV$ zY9){~%G@QYE})PahwRv)^VzYB*&ZC4mgYH>s-Q3STyAo@bcWpf$gGGSbiqDff^m2@ z9IVu@?M=n1G<9bzMR_vP)p|_zZ91YeN}1AdBASRZ0~55gBlICIf~q<1@5cOmXHlUh z0mejL00FIC=6@spe)lhn>9Wec37pS)NBaBDfsH9xPLIW7o78Bd7+LXj7x{tC_5l`g zQof0Idxv}J&K<)OdTfW!^Vx!Lq#Q+-+a6G3?f*>@*zS%8DazgaC@D$$@Wi&MT^vIy zAar9>8-JVkcW*B(w-u`#z$e0tG1r+HgKzv~#@=Uy^}K)+8SQ-fEy@G8A2m6)aaqUa zrR3Uy&?g=3nPDp1@OraHqvfCg*iqcp23t}K?G_3s4xGT z$7Jh~pXk|eV$1Wdi`|@(Us}Qf*oikK7{JB;Zo_a)s|@nLeq1%M#kfCHB(Yw*|*)Lcb@o^>#7;b6&JrV|i*9JK4>9ac-%RuA(` z--sC{ZCjeID$4maJw==`wg0P3Hj6S|T?cBSXMvT_3*c}4k2pB438R96UcltjcK|)t zzmP-2SrDlBBcPY#S!AeUDzl~loGI|2zc1+4cDi-$bgWtg0#tJV$5ZrJ6CSW(9S*SK zV)u59#IQDujW_q?!qDs-&KpG!*YI2f7#pu@Xpy266CiA)lYhXnK@C-?_ZQgzvfkr8-CxBWx_OfKgDVit5>5=dT; zbnGq_uA|qvak0DUmL?;-TP3G)*d?67J4nM^0CU6Pa-BNj{3+md1g3QPJ}~WYZhgjS zdh=$%$WBoNk>Y2Ej6IwZFPHLlB(GeLVn&eeLBV?ukx(I(a^TG5rRnH+IsnuQyAde9 ziMl~*+1cm1@j~Zi17S+D*O75>62pdirK!xXM$7@}+o>{^OTR^t)ROA)Z*(ZuU>_$? zzNX%J;2z{<{TL2rfgT<_OH^|mp;Niv)u+2xRy%Xdf2|3bdFh?Pc^dzFvlI_DF=(pV z>OW!E#O(g|%R=DtD-Zqa$O}b(a7Yn6&LscJ6E;U}h$*-_Gf<-A0kguBT%wi|D+F4| z+*obJPg)LN<{UkCiW?aR#F!C||5Tz3kI0d^1QWfVLVGpzf1;$*9l4^+$x$#gwJdpe zVkHq81wEmdn%xorDyty-b$7=Q5*J>ok_kP>c`JcHHUj8nLzd@WNntqHUA45GWEa&F zTB%!9U;yTSxEY97=)jOZl<(&>@Neis+FlCZS{bYTv19(natluC_`)UzM{DnSK<(P> zC-R?P|6T3LMErW$-Kc_gVjWp76HHlNd)GT;CPn2O%+ktZ%o{3vf6z@ z#I1f=t!AtD(hI%L8!{|d!6TsjA9r81sdi(9q0P*=#)3CHFS}vCX&9ace41DRF#9U~ zJUTDto6kYX+gt>ay;*{t`r^o;fQ`^)Et;az-36X<>ERc8mH7#s_adH~Q%Xm3-$y(F zH7_Aa@DeFjayF=1|Mpqkt+{v1RYP-uxO?95<5RG6iXa`jAX5Q#lCmz{f)Ov!0sn7e zfJKasxK>=n2xb$YUxiu#ub^3Gr5FI}p+mB^|Lh*>ROVmz2Y8CR-{AGZ>%bA}CK(#= zfRMjBSJ|+!ZcyJYT4A0Q3*BC1{zO||2^moWa*v@|Jq0n{7uf#_udFPt>*te&zfN!e zT;2@^dp9?!^%;#g=m1vM+7N~|?2m?mHAk~*L|g=MzeP5nV>RnqC2%ifFJXqVtD^$B z?En5}e;_QbpKZgvE)Js0{48t1SRUf#Fd^(MFtnf%)1wyfTK90Be?T$_#L#%XaT^@3EnynUsD7c=-VRy4ymIVLGmKaDi9{d z%vcD6uH7c*3{!8YLpCatn;}0v&cq&w z(9_<`NvIDseAbkZ5o3g7`M1Wf>QQpY+;_X$t1fFq=JO6>vW0JDc^+tcoBiOo9r$Us z;_#O@5sTu7>obDGrfznx3gZ1t&CUJo@UF-rI}gH7{^;n!oDQe3-`vXb?_PXUnj*Eq zmR}`BVsXr20BGbinwr}9d#?mG`7p=UmFifmV%n$Z>wogUh%P*FavXk1am6tpzdG$m zNNlJVbFgnruqc2r!=&ako4OeqBUEnEOHuv!a2UBDS>3b{na8LnV?^*?QH0mx(>Ir} zlVrs;@U_t7i8@(Yde%EU)XS@i zSp&{3z2`aK&3dSz`sE<+98IWfs#%Qhi9V4cgf)KiJ*B3kd9TZwHJF;9=L(j)Cs{9rEq`MzhKQo1%Ei}5^F?Q;sT;;gL$Ezo0q}4nZ zSH`++1)(*3oh}6mO>3+T)*P9c=+m*3(VwU8a9m@HK_RGHV`o_Yz5i`3Za-Yy=7b?ai-xRzHhY`BE zkzjD40_T9Zf{V>lW_${4PA~#&B~1hGiLJ=dTj_uIG?zs+2g?qRqPkiEk0O{^lOo@q z)cE}mSR@B~NI~t}ZnMSL!52Q3yj^~8Xk7?5<@8pnNBl9VBK>h`oR7rG@9$sxL5~Uh3B{C zvyrZ}Usf~jHw(|;HBvx-Uwx&)Nc||%e}w&6JwirZYzAHv)p(4N0k8#*-LYEAr~vt! ztF!T5Kma@v1_VQ_;GtKQ12f%LX@&DmB;9C)Gi^rBE&(T7{pP!Vet~lP8|vX*anSx3 zIVQFQf3iSxVgtyKf>3+|$@gv^KnfD+FvP=yxyZ*Ay}ZB6XwQU%Q=S zls&!M_jc`3iSDj3=YzUQ;&cbnp$`>^wJkIbM>+3TKd3`s=W}2JL=AJX_P%JtAa3rB( z%E|l8j%{plW3LoSCPncrg})H)p^tAA1&v%f{KPBMoSCz5FV@In zu?cmoo!YwIB@59$H5u86a5H3PpYAi)Q=f{>jd7BawM4U1XY@~1zZmchUeWr1yxaGB z&H&d{>K*>MVYbUoEOm`L98^n-5zcb>IUyQFk_%b&?ZRpSaS}D%5`nXQ=TH!D|0v!I z=ET9_P3NT4Gb(@5hi%LUY?Q|#o*pZpxE7pyHx5iU4vdRydbDb}#w9+;y~R&4)SHuH z-fWmPb<3~!Lqnyt90D~N;UX+dI?UJ;Yv{P4>!Ex}+g?q@xzhL%SzJ8noi|cai35SP zeWUMYbH;4Zs>Y1Ve603ZN9DWlAGWUeQyq=p->f;|y4n5DYzJ#9U2HNqX(`s9U^(IBfFTJ`Q=lW~j2AFXIR3rd^N;J6WxZ^(Z%vqEj#y$DcF%KLLpWcK(Sx zvELWl4$Gt9H%NgX4##&&0$NG1 zEBXI|%zC*wsRIy#h7u5QrKIE)+Fq&Dz(uLW7u$vu3ToFYaTsF1CHKA5_DZg2NZ?ET z5d=xWn1D+ExVfK|294NvW|?i&KuXCu$ep{?w*2cX2Uk6-08+oJ1pxf*)JW>Lg}zt1 zt<*MJ^8`}gsRfVRc1q73WY&pYL-k_WvFssU2l$ZfA?uJ<~j@6?=!*mae==CUBxbyQ%b5&?tMapcxX z{jn<9PAPbVzSr|NN&l~6>y$`IsV}B*1i#pClWr@wjS`n3_q~#1Dm_oR@8qrtBQ=38 zCfxuaCmBGfJttF`)+;G_)f_`@FsnU(C6a@Sm`E+Z+Zs^!8ePwP=a9h_;HayKRh#YJwL$@-}Ut_i~|7Rax>7@un5NOEFiNje$<~H?7jDRL}5xni7S2>6I{K<;i}`+%)#Pn z;7j&uzQS&$LYLxp+~}l$f@FDXlY!Bc&F_yn^U4x&;MQsxeFjrbVlRiRzucQ zKhzMgSv*@}y+{4Dpd;Sq1Bg z+Dvqy;M?M^Q|K1~nE#=|&cOqd_C+GK*!vZ7_MY{`&A#@CpyAqnW}F|bxXRwGfc!a! zGe_8CH`OD30{)Hz=f8r4;1c4CE^mrli{{CW_wTO9+$_NLC?NSeJ;Q_*$)D8bQC+AN zD8*ESlisF~Gu)?m(^Jv(U=f zQg)~O4a`0kLQgsVCe>J`&R4J|DPQ3u+m6M?-lW(7%dg0WR>(r5z*a)E1Rx!ujAedd zYonOsSy$FYyB<2olz%k9t@xmY7eg)(@q@}WOXcr<41gl|o;fJ)>VUN!K-Gj&d|zJl zwlY=T6Y?ZwO4bifzt-`5P`a1fP-*dRwU7P`zsv;ko9X+7?DwI%Nc28A@2v9GYlyR5 zBzbu3UfxR+Ys(sQYyKnjwIxAREoh_H)w6Dj!5*O5Q3NG~ug{vTH42oA$b<^jcLba_ z4TguE@p-Wljt!uI!nVhg<=kTAMb>bZ50*;iix1Jr#Y3Fn9m0C%#MO75cgMgHBbuWD z4rIG5zcYr;AUK!CS;yL`VM!Pyx;v(mZGK|qT-7TOXTC|kkG)}Nrt=p#xJVNqj5h42d(7xOC2URbJ}hs|d>4_c(ZoR@H9;1Y z8LgRF+n$`Imu83+qgxw{sLkGyF9j@F^bEW<{uB%Sfdh&ir-?lBNg=$RNSGr%mqs!; zDD3CtTczgqd)scp2HlV>&M#iU;>_d7m#U$1i5W9?E-hB71vycSF2Okxpn)zt+E(OV z>oGmIAX`F$B^1gBO0{sR3^*sGNOaFu2NiiQkkxnY?C3Oq0ApP#-VB~l4`r)Y;VvRu;BZ@4}){VK1N)&|ETrr zlK1w>0l84IDRI5?f9FYYQ8sGm?!?TFkijv`K6!!fqx(&q?zX2#oXp}`5x{|Am9iQ2 zGaw+v6^OmC?hjY%kTMnR$j58e8YS?J&8C?G?xgEbxx&MeRn-22(|UJF2BLdt{G_fj z{*Hx)Gzfra%b{LH4T0k~f{2~{pzZniaVknlOS`1o4}hOZtE=rQX3}I#+tD=)FP+L& z1u&NvJ=)K@B7iEwTm)4f2c_a1*7bv>|2-rcG_O9Rq-fL)9g8bQhoiWu2K$eIA7Gti zAMfCW7T#{$f%ftqQ#Plst2`mG=J@3Xto{6y6=g6>w`{n?S8zme{n#qbpPxN~v3?K%JxjJL1N%%zeYKvWmaSSCI zeD~j{&P&8E**lpMMVO4l%qQmcG~(Y%8X;K~>((me^=2v=#Cwv&q=w41{Frk9J_!FWizd>w=${s-00D^dJ3&%-aHL14CjH#>j&1^oX|)DT@oM@vuArTFW?0sFyKP5Rq_5eU`6GiZcuci{| zuKp~3i3cKGK`4S?663C`7=t$RMm}*lh58_?iuA$7eT)fx{#+|1^U3r*UtQ}iGH4;D zVT{>&uHUr<4t*MlfBxq`cr>(u5jbsxNx=^1r9Cq>c&U3`9(scJRF z@6r-9tr_*i;#T+sTQp92hkXx!(|M7G#qI;GcN-w>25rKB<(7gEkF_??HPNsmcNd|b z%njAuN&hFCF#nj=bOS2xfpC4VKt=FWmx#85wxPT>++Kf-?^OMh49^4%k0q%|-?OCn z?(FD7ULYVsy9SrWQFGD#=2A-46UWowA6xiAJ~apeQq%3zIWKH5`@@BGdp8l8`Ea<>pk`a&%;fbMlyr==zwBzzX^Yj)k zF==obcfnrRdFTf3JK6gU1v%z$3@HU*0ac0zVmA{LPV*92QT2plHI#iyiLP*e9ouca zs0(pKibc+Qz0~z}yf^11y931=g{%a@%)nDiz-0M&y6JA`)&2NC6uy9@NqiC1{MZKC zQP3#I=flhOg$=2^Wp(m|lCAQxe_yQT!3OkQk?h!O{wBRg#2M7}E8TDdDrHD+A)5nk ztltaJeg_Dxpn2sU6irzJiQJ6l8a&t7V2gVzf=|MptWr*AMfygN#Z5Yp7tlQ9rKJM% z*JB6Yjkjs_>Q}zw0{(tDX|=4`;c(YthP=S|)XiqWbs$TpNeU7B)eM7RQ$h7NA8^KS zTLhBy$xu)8Ark2eGI89TO4@wF_I_L)E&04V74yK9@h?{sAKT;++YJ6MXJ^&1KrJ$6$>Ey^C3Xs!QHY$682pkBn2T{TKn&hB+7-p zPs;2u1+?8$E9#9XoJdqY*bzjJX6YiWFynp;|Opxboeie^ma1X423R zL;H(6VUN(hTy)aIE4Tws?8}MmRM;2U{ge7}J3PYjQyQ*4c3-u|`!@U#55bq=fA;Nj z3|-_P5A(`s-%@P~0~8myoypi^Y7#Y`ZhXo8WX`XqRsVRs$_e&; zo>if-yu&1~lWzvs9y6vL^UBTP?omm%6ZfaQ=vx3*3YkROTL8V=7UUtd)qA%&2&aWw zXM%>75(OC#4^A21aa}V0Ew)w9EL}Ygf{weT$&Ai+@Y2iN#lv&B8 zJ9};1NX_Q=Ax+onwHiFZyG84)9?MU-3kEvJTO5MalXeY^d32g?ot=*O%^sMq^;HyV zX}btc0aN!hMNJ_~Uf8U6;~^e(0UPRIofjY!NU<$iOd@Lhuzv%}1MmJ(=%nq@Ua5H= zvBAbUN=`7Wh4sQO8%7+IUOZ(%DJ|r3otjR|WlWSdeV}rcw8?x)&(tK;1a9ObyyW zWDN#Q%e!HQOm|CCJvDn{A1oK+sg~Z1nBFyDH~DbIGLbuny=_ZUKKcyKO@1Y472^mE zN?R98c^XE-UM7TmV@Uz*-@Sy`s*5udjlEcsyg*kuop5`pLZixFY#Bo9XrUvan*&KO z^)OAl9C{^*esrl;=u~n)8wl^F!=p8-9^*{Vsj_`Kfj1~d1bk}at4YT-^=1SdMCwD_ zHrU*mB1y`BFhZ|c|27z_{jvkRxvPIh9GEIhBlK8Xij4qQj7rAJf&7EXL@N6zuGdI)8p{{RT<)f;T&TcEenyAZho9=lno1Fy~YUjxlcB=?l#Y<*( zJM}Y0O-z0MMfV&dTim|qBP&hgy<*GcV!N)r184#?$zQZ{`D`Z5R)@6=nO)Wue}y(H zv+o{`@6g*A+WeN5!Mzs$Q*t3w=|kDeDPh@#>E0vO_vrK+E{9|^@5YbHM@wW)h3`kf zl6?hU=ApeOzDG=BH)v2HP0TG+%XJocnl{A`R*#u1kCX;7&N^m(skK* z@G`0+FL-H(k!dw37WqyPOS0Ec-8Yr}aRyWOT1s?iWPX z0^wD-JdIut1*w;~6MArwRx{i(g#d+a&C!xqxrlw_3U(wf;Oauq#QtWO0KqW6{GHBt zX!H?$3vGjv=WF)%0qwLIvE0cJtX!hP4nE(69&NUHW_ z^WBI0#|)SCTgx={J|+W79~pzMFfum!d0lQP`K*_#_BQFQT2Wy%3UpQk;qgdG%};r2 z($ua*2}qxyL7uW)($-^HFKi2n>m_rN?as92su6h&5FTGROLyR(c)pGIP*$M*Z_!4DS=XSan%>N$dxGrJQst^GN3_4Qw~{$1_igd1%ypS9ZxKAUwAXrz_fU zR3dhYbzTVxfYfZ(lf7O)Y00%9j;NAjC(2XLe(L+(ZV`k@#E3hF_VO}X%7Dmct$tlB zTtilo^Y=0xW1UFgKAvEs7uF=u#YxP{bq_4Au5CQzrV=K39tlTE@St1dWI{$O%)1ZT2J1u3uHx#0h!^vI3(-Az;AB~pT z+(J!nJgIF@^t~l+Y{KNQsNmlRoff@s|g)^LId3y@m|E&oG-0P#~+JiKxm3mpdE2K1B3|r>z z85w{NNc6b+2GyM=RRXY@ZikYCZXR;Y&Yx`ps7I#!G8q(KWQBc|GHwxRSlOf9&ny93 z0t`pvASekg*UcEoD2L{ZWc_WnBE@J=*tZG&wzCbfr(X}Yr&~{V$G878KusEnhWmHP zuMK6!-;I9fUK1%FZ|e_`SDg%8Z5mfeabNi>ANUyEY(Rt+oZofkCb-qYhTsd`$f`%+ z$&;LgUXQNi=@>ngZ%$@-kovpKBgy?BlTSPMM34G6g`NK_ah9v}Z(=`x=%{L8rHTIO zn~V+0{a`>m-;|0}Cg=b5>v@A}={pcpR7urB9Qm9RPgfq#VK(~@pU7TaVe-?R-C)5<9SANTTZ|9y4{BlTib!W5q$|5_WlZRR`_apm$^Z`xu7tKDGv3Ap4~)vEpm zqm^@0$#5o`Q0E(B19AAIxV^V-a(+%!m#tjiwP0Bk$|nY676U_mcgz!`2MZi*$!P@% z6(tsvh9h<*JM`hAip*w>NL{vj>EA$Le%AP(!}%iMtt@Jn_R3#0#_C8#1hz?`DUx_20gIQ}xU^E&*`eSeYoT43M`-X}1E{8tcu$C1Co#+1`tFIzS8?5Zh zfwF!&^IA8DQ%QeI|J%;|R2gPyL%s{7j^%K^^O$|)#2a6qs;{`Hp^%`fn5TFcf!Yot z^QP=vL$Gm{q>^8pm}>@xe_ExC%}lV+#B;t2kBiK&|(nO0W5gRTD!g#aa51 zE$0+yUh@_`!SS58b306UHV9ySQ#m4GCPVD`nS1xMF++t#zt()^^OdJ0fBrsi4qzGZ zgWBs%kGUJ`FlO?7qug~!BxZB#^+$g`9o%G^&oo=qEyfWV{VrL-g!e~h*@>N-50lkhsDa{ zNf3Ci^0ntv*c-yMM)#+Q&z20@R!e?8x6lW&@nFXpQ`#TibSKY?PQXFtk2=I8w~eWcZ=tYu%w!y z7%q4lI3+h<{7l@7|(f)Zi@FK%(~&z5IgjGrZDG#(gfv+GPG-Mjy&HOiNj^!&kG4yY&U=}nXzaWZw% z+-z~7_6q672`FKUBb&L+P5W9JJO?pJqa?$%jKFrqokrhe?&#o$>F3S4JGQs|NH$gi z{Rb>#O;18k@t2=geTLh?6Z-*wa6_0mD+3Q&a*FPTR`azc9#V3w>?Ha8Ki%N=M~1qbP_Kn0R}q8+EtoQUi=U8LB6~I literal 0 HcmV?d00001 From a8f24388d32c42ae1a1f8dad384ed2422866d689 Mon Sep 17 00:00:00 2001 From: samypr100 <3933065+samypr100@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:39:11 -0400 Subject: [PATCH 50/50] chore: fmt / fix logging --- src/main/java/edu/jhuapl/trinity/App.java | 6 +- .../edu/jhuapl/trinity/AppAsyncManager.java | 63 +- .../trinity/data/files/CyberReporterFile.java | 10 +- .../trinity/data/files/DroppableFile.java | 22 +- .../data/messages/xai/CyberReport.java | 120 ++- .../data/messages/xai/CyberReportIO.java | 23 +- .../data/messages/xai/CyberVector.java | 141 ++- .../data/messages/xai/FeatureCollection.java | 8 +- .../data/messages/xai/FeatureVector.java | 2 + .../javafx/components/AnalysisVectorBox.java | 6 +- .../javafx/components/ExportMicroToolbar.java | 36 +- .../components/FeatureVectorManagerView.java | 158 ++- .../javafx/components/GraphControlsView.java | 113 ++- .../components/GraphStyleControlsView.java | 99 +- .../trinity/javafx/components/HoverNode.java | 39 +- .../javafx/components/MatrixHeatmapView.java | 113 ++- .../javafx/components/PairwiseJpdfView.java | 408 ++++---- .../javafx/components/PairwiseMatrixView.java | 388 +++++--- .../dialogs/SyntheticDataDialog.java | 112 ++- .../hyperdrive/BatchRequestManager.java | 267 ++--- .../hyperdrive/ChooseCaptionsTask.java | 2 +- .../ImageEmbeddingsBatchLauncher.java | 30 +- .../hyperdrive/RequestCaptionsTask.java | 2 +- .../hyperdrive/RequestEmbeddingsTask.java | 3 +- .../hyperdrive/RequestTextEmbeddingsTask.java | 2 +- .../listviews/EmbeddingsImageListItem.java | 8 +- .../listviews/EmbeddingsTextListItem.java | 24 +- .../panes/FeatureVectorManagerPane.java | 63 +- .../components/panes/HyperdrivePane.java | 210 ++-- .../javafx/components/panes/LitPathPane.java | 5 +- .../components/panes/MatrixHeatmapPane.java | 119 ++- .../components/panes/PairwiseJpdfPane.java | 34 +- .../components/panes/PairwiseMatrixPane.java | 47 +- .../components/panes/StatPdfCdfPane.java | 19 +- .../components/panes/SurfaceChartPane.java | 10 +- .../radial/CircleProgressIndicator.java | 1 + .../components/radial/HyperspaceMenu.java | 4 +- .../FeatureVectorManagerPopoutController.java | 73 +- .../PairwiseJpdfPanePopoutController.java | 30 +- .../StatPdfCdfPopoutController.java | 45 +- .../javafx/events/FactorAnalysisEvent.java | 4 +- .../trinity/javafx/events/GraphEvent.java | 67 +- .../javafx/events/HypersurfaceEvent.java | 265 +++-- .../javafx/events/HypersurfaceGridEvent.java | 48 +- .../handlers/FeatureVectorEventHandler.java | 57 +- .../javafx/javafx3d/DirectedTexturedMesh.java | 24 +- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 4 +- .../javafx/javafx3d/Hyperspace3DPane.java | 2 +- .../javafx/javafx3d/Hypersurface3DPane.java | 778 ++++++++++----- .../javafx/javafx3d/Projections3DPane.java | 6 +- .../trinity/javafx/javafx3d/SurfaceUtils.java | 227 +++-- .../javafx3d/animated/AnimatedSphere.java | 2 + .../javafx/javafx3d/animated/Tracer.java | 5 +- .../tasks/BuildGraphFromMatrixTask.java | 12 +- .../javafx3d/tasks/ManifoldClusterTask.java | 31 +- .../javafx/renderers/Graph3DRenderer.java | 11 +- .../services/FeatureVectorManagerService.java | 102 +- .../FeatureVectorManagerServiceImpl.java | 76 +- .../services/FeatureVectorRepository.java | 5 + .../javafx/services/FeatureVectorUtils.java | 90 +- .../InMemoryFeatureVectorRepository.java | 27 +- .../messages/EmbeddingsImageCallback.java | 3 +- .../jhuapl/trinity/utils/AnalysisUtils.java | 20 +- .../edu/jhuapl/trinity/utils/DataUtils.java | 125 +-- .../trinity/utils/GridComparisonUtils.java | 12 +- .../jhuapl/trinity/utils/JavaFX3DUtils.java | 8 +- .../jhuapl/trinity/utils/MatrixViewUtil.java | 15 +- .../jhuapl/trinity/utils/MessageUtils.java | 1 + .../edu/jhuapl/trinity/utils/WebCamUtils.java | 3 +- .../trinity/utils/fun/solar/FlarePresets.java | 75 +- .../utils/fun/solar/SunPositionControls.java | 5 +- .../trinity/utils/graph/ForceFrLayout3D.java | 26 +- .../utils/graph/GraphLayoutParams.java | 193 +++- .../trinity/utils/graph/GraphStyleParams.java | 11 +- .../utils/graph/MatrixToGraphAdapter.java | 124 ++- .../utils/graph/SuperMdsEmbedding3D.java | 2 +- .../utils/statistics/ABComparisonEngine.java | 113 ++- .../trinity/utils/statistics/AxisParams.java | 4 +- .../utils/statistics/CanonicalGridPolicy.java | 197 ++-- .../utils/statistics/DensityCache.java | 210 ++-- .../trinity/utils/statistics/DialogUtils.java | 58 +- .../utils/statistics/DivergenceComputer.java | 178 ++-- .../statistics/FeatureSimilarityComputer.java | 125 ++- .../utils/statistics/GridDensity3DEngine.java | 62 +- .../utils/statistics/GridDensityResult.java | 55 +- .../trinity/utils/statistics/GridSpec.java | 29 +- .../statistics/HeatmapThumbnailView.java | 210 ++-- .../utils/statistics/JpdfBatchEngine.java | 114 ++- .../utils/statistics/JpdfProvenance.java | 462 ++++++--- .../trinity/utils/statistics/JpdfRecipe.java | 348 +++++-- .../utils/statistics/PairGridPane.java | 207 ++-- .../utils/statistics/PairGridRecord.java | 149 ++- .../trinity/utils/statistics/PairScorer.java | 139 ++- .../statistics/PairwiseJpdfConfigPanel.java | 126 ++- .../statistics/PairwiseMatrixConfigPanel.java | 270 +++-- .../statistics/PairwiseMatrixEngine.java | 142 +-- .../trinity/utils/statistics/RecipeIo.java | 68 +- .../utils/statistics/SimilarityComputer.java | 198 ++-- .../utils/statistics/StatPdfCdfChart.java | 100 +- .../statistics/StatPdfCdfChartPanel.java | 939 +++++++++--------- .../utils/statistics/StatTimeSeriesChart.java | 37 +- .../utils/statistics/StatisticEngine.java | 130 ++- .../utils/statistics/StatisticResult.java | 68 +- .../statistics/SyntheticMatrixFactory.java | 126 ++- .../edu/jhuapl/trinity/css/styles.css | 15 +- .../statistics/StatisticsEngineTest.java | 155 +-- 106 files changed, 6590 insertions(+), 3775 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/App.java b/src/main/java/edu/jhuapl/trinity/App.java index 13f85a64..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); }); @@ -340,7 +340,7 @@ private void keyReleased(Stage stage, KeyEvent e) { } //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() + 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)) { diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 9690fbcb..fac6e7ad 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -14,9 +14,12 @@ 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; @@ -31,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; @@ -98,12 +104,6 @@ import java.util.Map; import static edu.jhuapl.trinity.App.theConfig; -import edu.jhuapl.trinity.javafx.components.panes.HypersurfaceControlsPane; -import edu.jhuapl.trinity.javafx.components.panes.PairwiseJpdfPane; -import edu.jhuapl.trinity.javafx.components.panes.PairwiseMatrixPane; -import edu.jhuapl.trinity.javafx.controllers.FeatureVectorManagerPopoutController; -import edu.jhuapl.trinity.javafx.controllers.PairwiseJpdfPanePopoutController; -import edu.jhuapl.trinity.javafx.controllers.StatPdfCdfPopoutController; /** * @author Sean Phillips @@ -180,7 +180,7 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir 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(() -> @@ -455,7 +455,8 @@ protected Void call() throws Exception { switch (e.object) { case File file -> cocoViewerPane.loadCocoFile(file); case CocoObject cocoObject -> cocoViewerPane.loadCocoObject(cocoObject); - default -> { } + default -> { + } } }); } @@ -564,20 +565,20 @@ protected Void call() throws Exception { } }); 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) { + 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(); + statPdfCdfPane.close(); } statPop.show(); } else { - if (statPdfCdfPane != null) + if (statPdfCdfPane != null) statPop.getCurrentState().ifPresent( - statPdfCdfPane.getChartPanel()::applyState); + statPdfCdfPane.getChartPanel()::applyState); statPop.close(); } - }); + }); LOG.info("FeatureVector Manager and Services"); // Mirror NEW_FEATURE_COLLECTION into the manager (ignore when Manager itself applied it) @@ -607,8 +608,8 @@ protected Void call() throws Exception { // 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) + 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(); @@ -619,9 +620,9 @@ protected Void call() throws Exception { 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(); + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) { + featureVectorManagerPane.close(); fvPop.show(); } else fvPop.close(); @@ -631,8 +632,8 @@ protected Void call() throws Exception { // 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) + 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(); @@ -643,9 +644,9 @@ protected Void call() throws Exception { 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(); + boolean show = (null != e.object && e.object instanceof Boolean) ? (boolean) e.object : true; + if (show) { + pairwiseJpdfPane.close(); pjpPop.show(); } else pjpPop.close(); @@ -655,8 +656,8 @@ protected Void call() throws Exception { // 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) + 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(); @@ -666,14 +667,14 @@ protected Void call() throws Exception { 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) + 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(); @@ -683,7 +684,7 @@ protected Void call() throws Exception { else hypersurfaceControlsPane.close(); }); - + scene.addEventHandler(ApplicationEvent.AUTO_PROJECTION_MODE, e -> { boolean enabled = (boolean) e.object; if (enabled) { diff --git a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java index 8a31ac02..394addb5 100644 --- a/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java +++ b/src/main/java/edu/jhuapl/trinity/data/files/CyberReporterFile.java @@ -4,6 +4,7 @@ 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; @@ -19,18 +20,20 @@ public class CyberReporterFile extends DroppableFile { 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")) { @@ -39,6 +42,7 @@ public static boolean isFileType(File file) throws IOException { } return false; } + @Override public DataFlavor getDataFlavor() { return new DataFlavor(CyberReporterFile.class, "CYBERREPORT"); @@ -63,4 +67,4 @@ public void writeContent() throws IOException { LOG.info("CyberReports serialized to file."); } } -} \ No newline at end of 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 index eb064654..0ae350b9 100644 --- a/src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java +++ b/src/main/java/edu/jhuapl/trinity/data/files/DroppableFile.java @@ -1,12 +1,13 @@ 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; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * @@ -19,10 +20,11 @@ public abstract class DroppableFile extends File implements Transferable { * 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 * @@ -35,7 +37,8 @@ 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. @@ -44,16 +47,19 @@ public DroppableFile(String pathname, Boolean parseExisting) throws 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 @@ -73,5 +79,5 @@ public Object getTransferData(DataFlavor df) throws UnsupportedFlavorException, } else { throw new UnsupportedFlavorException(df); } - } -} \ No newline at end of file + } +} 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 index 1356b51b..60e192e0 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReport.java @@ -14,20 +14,21 @@ @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"; + 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) {} + public static record ModEntry(int value, String label) { + } // ----- simple metadata ----- @@ -67,8 +68,9 @@ public static record ModEntry(int value, String label) {} @JsonIgnore // avoid double-serializing; we expose via @JsonAnyGetter below private final Map extraVectors = new LinkedHashMap<>(); - public CyberReport() {} - + public CyberReport() { + } + public static boolean isCyberReport(String body) { return body != null && body.contains(GROUNDTRUTH) @@ -88,7 +90,9 @@ public void putUnknown(String name, CyberVector vector) { } } - /** When serializing back to JSON, include the extra S(...) vectors naturally. */ + /** + * When serializing back to JSON, include the extra S(...) vectors naturally. + */ @JsonAnyGetter public Map getExtraVectors() { return extraVectors; @@ -98,10 +102,10 @@ public Map getExtraVectors() { @JsonIgnore public List getAllVectors() { List all = new ArrayList<>(); - if (sGtA != null) all.add(sGtA); + if (sGtA != null) all.add(sGtA); if (sIntelGt != null) all.add(sIntelGt); - if (sIntelA != null) all.add(sIntelA); - if (sInfGt != null) all.add(sInfGt); + if (sIntelA != null) all.add(sIntelA); + if (sInfGt != null) all.add(sInfGt); all.addAll(extraVectors.values()); return all; } @@ -121,30 +125,76 @@ public Double getPercentage() { 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 String getGroundTruth() { + return groundTruth; + } - public List getInferences() { return inferences; } - public void setInferences(List inferences) { this.inferences = inferences; } + public void setGroundTruth(String groundTruth) { + this.groundTruth = groundTruth; + } - public List getMod() { return mod; } - public void setMod(List mod) { this.mod = mod; } + public String getAdjacentNetwork() { + return adjacentNetwork; + } - public CyberVector getsGtA() { return sGtA; } - public void setsGtA(CyberVector sGtA) { this.sGtA = sGtA; } + public void setAdjacentNetwork(String adjacentNetwork) { + this.adjacentNetwork = adjacentNetwork; + } - public CyberVector getsIntelGt() { return sIntelGt; } - public void setsIntelGt(CyberVector sIntelGt) { this.sIntelGt = sIntelGt; } + public List getInferences() { + return inferences; + } + + public void setInferences(List inferences) { + this.inferences = inferences; + } + + public List getMod() { + return mod; + } - public CyberVector getsIntelA() { return sIntelA; } - public void setsIntelA(CyberVector sIntelA) { this.sIntelA = sIntelA; } + public void setMod(List mod) { + this.mod = mod; + } - public CyberVector getsInfGt() { return sInfGt; } - public void setsInfGt(CyberVector sInfGt) { this.sInfGt = sInfGt; } + public CyberVector getsGtA() { + return sGtA; + } - public CyberVector getDelta() { return delta; } - public void setDelta(CyberVector delta) { this.delta = delta; } + 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 index d063f7f5..710a461a 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberReportIO.java @@ -3,6 +3,7 @@ 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; @@ -11,31 +12,40 @@ import java.util.List; public final class CyberReportIO { - private CyberReportIO() {} + private CyberReportIO() { + } private static final ObjectMapper MAPPER = new ObjectMapper(); // --- Public convenience overloads --- - /** Read from a File and return a flattened list of reports. */ + /** + * 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. */ + /** + * 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. */ + /** + * 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. */ + /** + * 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); @@ -57,7 +67,8 @@ private static List toReports(JsonNode root) throws IOException { // Fast path: top-level array of objects if (allObjects(root)) { - out.addAll(MAPPER.convertValue(root, new TypeReference>() {})); + out.addAll(MAPPER.convertValue(root, new TypeReference>() { + })); return out; } 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 index 5f561553..ea131224 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/CyberVector.java @@ -7,6 +7,7 @@ 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; @@ -110,8 +111,8 @@ public CyberVector() { // "Role Permissions Intersection" : 0.5, // "Role Resource Distribution" : 0.5, // "Role Permissions Distribution" : 0.5, -// "Namespace Per User Distribution" : 0.5 - +// "Namespace Per User Distribution" : 0.5 + List vector = new ArrayList<>(); vector.add(state.getImageCount()); vector.add(state.getImageDistribution()); @@ -131,6 +132,7 @@ public CyberVector() { return vector; }; + public String asJSON(String objectName) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); //mapper.configure(SerializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -141,50 +143,125 @@ public String asJSON(String objectName) throws JsonProcessingException { } // - public double getImageCount() { return imageCount; } - public void setImageCount(double imageCount) { this.imageCount = imageCount; } + 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 getImageDistribution() { return imageDistribution; } - public void setImageDistribution(double imageDistribution) { this.imageDistribution = imageDistribution; } + public double getRoleNameIntersection() { + return roleNameIntersection; + } - public double getPodCount() { return podCount; } - public void setPodCount(double podCount) { this.podCount = podCount; } + public void setRoleNameIntersection(double roleNameIntersection) { + this.roleNameIntersection = roleNameIntersection; + } - public double getServiceIntersection() { return serviceIntersection; } - public void setServiceIntersection(double serviceIntersection) { this.serviceIntersection = serviceIntersection; } + public double getResourceCount() { + return resourceCount; + } - public double getNamespaceIntersection() { return namespaceIntersection; } - public void setNamespaceIntersection(double namespaceIntersection) { this.namespaceIntersection = namespaceIntersection; } + public void setResourceCount(double resourceCount) { + this.resourceCount = resourceCount; + } - public double getNamespacesPerPodDistribution() { return namespacesPerPodDistribution; } - public void setNamespacesPerPodDistribution(double namespacesPerPodDistribution) { this.namespacesPerPodDistribution = namespacesPerPodDistribution; } + public double getResourceNameIntersection() { + return resourceNameIntersection; + } - public double getNamespaceCount() { return namespaceCount; } - public void setNamespaceCount(double namespaceCount) { this.namespaceCount = namespaceCount; } + public void setResourceNameIntersection(double resourceNameIntersection) { + this.resourceNameIntersection = resourceNameIntersection; + } - public double getRoleCount() { return roleCount; } - public void setRoleCount(double roleCount) { this.roleCount = roleCount; } + public double getRolePermissionsIntersection() { + return rolePermissionsIntersection; + } - public double getRoleNameIntersection() { return roleNameIntersection; } - public void setRoleNameIntersection(double roleNameIntersection) { this.roleNameIntersection = roleNameIntersection; } + public void setRolePermissionsIntersection(double rolePermissionsIntersection) { + this.rolePermissionsIntersection = rolePermissionsIntersection; + } - public double getResourceCount() { return resourceCount; } - public void setResourceCount(double resourceCount) { this.resourceCount = resourceCount; } + public double getRoleResourceDistribution() { + return roleResourceDistribution; + } - public double getResourceNameIntersection() { return resourceNameIntersection; } - public void setResourceNameIntersection(double resourceNameIntersection) { this.resourceNameIntersection = resourceNameIntersection; } + public void setRoleResourceDistribution(double roleResourceDistribution) { + this.roleResourceDistribution = roleResourceDistribution; + } - public double getRolePermissionsIntersection() { return rolePermissionsIntersection; } - public void setRolePermissionsIntersection(double rolePermissionsIntersection) { this.rolePermissionsIntersection = rolePermissionsIntersection; } + public double getRolePermissionsDistribution() { + return rolePermissionsDistribution; + } - public double getRoleResourceDistribution() { return roleResourceDistribution; } - public void setRoleResourceDistribution(double roleResourceDistribution) { this.roleResourceDistribution = roleResourceDistribution; } + public void setRolePermissionsDistribution(double rolePermissionsDistribution) { + this.rolePermissionsDistribution = rolePermissionsDistribution; + } - public double getRolePermissionsDistribution() { return rolePermissionsDistribution; } - public void setRolePermissionsDistribution(double rolePermissionsDistribution) { this.rolePermissionsDistribution = rolePermissionsDistribution; } + public double getNamespacePerUserDistribution() { + return namespacePerUserDistribution; + } - public double getNamespacePerUserDistribution() { return namespacePerUserDistribution; } - public void setNamespacePerUserDistribution(double namespacePerUserDistribution) { this.namespacePerUserDistribution = 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 8e6b2a2c..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 @@ -80,6 +80,7 @@ public FeatureVector() { entityId = UUID.randomUUID().toString(); metaData.put("uuid", entityId); } + /** * Convenience constructor to initialize with data. * The list reference is copied defensively. @@ -243,6 +244,7 @@ public String metadataAsString(String delimiter) { return sb.toString(); } // + /** * @return the entityId */ 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 index 04ce0d31..dd5e8a52 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/ExportMicroToolbar.java @@ -2,10 +2,11 @@ import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.animation.FadeTransition; -import javafx.animation.ParallelTransition; -import javafx.animation.PauseTransition; 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; @@ -46,6 +47,8 @@ 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; @@ -55,17 +58,14 @@ import java.io.File; import java.io.IOException; import java.util.Objects; -import javafx.animation.SequentialTransition; -import javafx.stage.Popup; -import javafx.stage.Window; /** * 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). + * - 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 { @@ -93,7 +93,7 @@ public class ExportMicroToolbar { // Guard and geometry caches for robust animations private ParallelTransition currentAnim; private double collapsedWidth = -1; - private double expandedWidth = -1; + private double expandedWidth = -1; // Options state private boolean includeChrome = false; @@ -111,12 +111,12 @@ public class ExportMicroToolbar { private Border pinnedBorder; public ExportMicroToolbar( - Pane titleBarParent, - Node chromeNode, - Node contentNode, - Node contextTarget, - javafx.scene.Scene scene, - double iconFitWidth + Pane titleBarParent, + Node chromeNode, + Node contentNode, + Node contextTarget, + javafx.scene.Scene scene, + double iconFitWidth ) { this.titleBarParent = Objects.requireNonNull(titleBarParent); this.chromeNode = Objects.requireNonNull(chromeNode); @@ -126,7 +126,9 @@ public ExportMicroToolbar( this.iconFitWidth = (iconFitWidth > 0) ? iconFitWidth : 32.0; } - /** Attach to the right side of the title bar (default right inset = 12, vertical center). */ + /** + * Attach to the right side of the title bar (default right inset = 12, vertical center). + */ public void installInTitleBarRight() { installInTitleBarRight(12.0, 0.0); } @@ -517,7 +519,7 @@ private void toast(String msg) { 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))); + 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 diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java index 210bbcab..3466a619 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FeatureVectorManagerView.java @@ -1,22 +1,17 @@ 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.paint.Color; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -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.scene.control.ChoiceBox; import javafx.scene.control.ComboBox; import javafx.scene.control.ContextMenu; @@ -43,6 +38,12 @@ 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 @@ -50,16 +51,16 @@ * - 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 } + public enum DetailLevel {COMPACT, FULL} private final StringProperty samplingMode = new SimpleStringProperty("All"); private final StringProperty selectedCollection = new SimpleStringProperty(""); @@ -141,8 +142,8 @@ public FeatureVectorManagerView() { private HBox buildHeaderBar() { Label lblCollection = new Label("Collection:"); - Label lblSampling = new Label("Sampling:"); - Label lblSearch = new Label("Search:"); + 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); @@ -159,8 +160,8 @@ private HBox buildHeaderBar() { 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); + HBox.setHgrow(samplingChoice, Priority.NEVER); + HBox.setHgrow(searchField, Priority.ALWAYS); return header; } @@ -210,9 +211,9 @@ private void buildTable() { colPreview.setCellValueFactory(cd -> new ReadOnlyStringWrapper(previewList(cd.getValue().getData(), 8))); colImageUrl.setCellValueFactory(cd -> new ReadOnlyStringWrapper( - cd.getValue().getImageURL() != null ? cd.getValue().getImageURL().trim() : "" )); + cd.getValue().getImageURL() != null ? cd.getValue().getImageURL().trim() : "")); colText.setCellValueFactory(cd -> new ReadOnlyStringWrapper( - cd.getValue().getText() != null ? cd.getValue().getText().trim() : "" )); + cd.getValue().getText() != null ? cd.getValue().getText().trim() : "")); colScore.setMinWidth(80); colScore.setCellValueFactory(cd -> new ReadOnlyStringWrapper(trim(cd.getValue().getScore()))); colPfa.setMinWidth(70); @@ -223,8 +224,8 @@ private void buildTable() { table.getColumns().setAll(colIndex, colLabel, colDim, colPreview); table.getSelectionModel() - .getSelectedItems() - .addListener((ListChangeListener) change -> updateDetailsPreview()); + .getSelectedItems() + .addListener((ListChangeListener) change -> updateDetailsPreview()); } // Details & Metadata panes ------------------------------- @@ -273,13 +274,19 @@ private ContextMenu buildCollectionContextMenu() { Menu applyMenu = new Menu("Apply to workspace"); MenuItem applyAppend = new MenuItem("Apply (append)"); - applyAppend.setOnAction(e -> { if (onApplyAppend != null) onApplyAppend.run(); }); + applyAppend.setOnAction(e -> { + if (onApplyAppend != null) onApplyAppend.run(); + }); MenuItem applyReplace = new MenuItem("Apply (replace)"); - applyReplace.setOnAction(e -> { if (onApplyReplace != null) onApplyReplace.run(); }); + applyReplace.setOnAction(e -> { + if (onApplyReplace != null) onApplyReplace.run(); + }); MenuItem setAllReplace = new MenuItem("Set All"); - setAllReplace.setOnAction(e -> { if (onSetAll != null) onSetAll.run(); }); + setAllReplace.setOnAction(e -> { + if (onSetAll != null) onSetAll.run(); + }); applyMenu.getItems().addAll(applyAppend, applyReplace, setAllReplace); return new ContextMenu(applyMenu); @@ -289,10 +296,14 @@ 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(); }); + applyAppend.setOnAction(e -> { + if (onApplyAppend != null) onApplyAppend.run(); + }); MenuItem applyReplace = new MenuItem("Apply (replace)"); - applyReplace.setOnAction(e -> { if (onApplyReplace != null) onApplyReplace.run(); }); + applyReplace.setOnAction(e -> { + if (onApplyReplace != null) onApplyReplace.run(); + }); applyMenu.getItems().addAll(applyAppend, applyReplace); return new ContextMenu(applyMenu); @@ -321,7 +332,9 @@ private HBox buildStatusBar() { // Helpers ----------------------------------------------- - private static String opt(String s) { return s == null ? "" : s; } + private static String opt(String s) { + return s == null ? "" : s; + } private static String trim(double d) { String s = String.format(Locale.ROOT, "%.6f", d); @@ -387,9 +400,9 @@ private void updateDetailsPreview() { } else { StringBuilder sbMeta = new StringBuilder(); fv.getMetaData().forEach((k, v) -> sbMeta.append(k == null ? "(null)" : k) - .append(": ") - .append(v == null ? "(null)" : v) - .append("\n")); + .append(": ") + .append(v == null ? "(null)" : v) + .append("\n")); metaTextArea.setText(sbMeta.toString().trim()); } } @@ -398,7 +411,7 @@ private void applyDetailLevel(DetailLevel level) { if (level == DetailLevel.COMPACT) { table.getColumns().setAll(colIndex, colLabel, colDim, colPreview); } else { - table.getColumns().setAll(colIndex, colLabel, colDim, colPreview, + table.getColumns().setAll(colIndex, colLabel, colDim, colPreview, colScore, colPfa, colLayer, colImageUrl, colText); } } @@ -415,13 +428,17 @@ public void addVectors(Collection vectors) { setStatus("Loaded " + items.size() + " vectors."); } - /** Snapshot setter (optional). If you’re binding live in the pane, you can ignore this. */ + /** + * 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 setStatus(String message) { + statusLabel.setText(message == null ? "" : message); + } public void showProgress(boolean show) { progressBar.setVisible(show); @@ -429,25 +446,68 @@ public void showProgress(boolean show) { } // 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 TableView getTable() { + return table; + } + + public ComboBox getCollectionSelector() { + return collectionSelector; + } + + public ChoiceBox getSamplingChoice() { + return samplingChoice; + } + + public TextField getSearchField() { + return searchField; + } - public StringProperty samplingModeProperty() { return samplingMode; } - public String getSamplingMode() { return samplingMode.get(); } + public TitledPane getDetailsSection() { + return detailsSection; + } + + public TitledPane getMetadataSection() { + return metadataSection; + } - public StringProperty selectedCollectionProperty() { return selectedCollection; } - public String getSelectedCollection() { return selectedCollection.get(); } + public StringProperty samplingModeProperty() { + return samplingMode; + } - public ObjectProperty detailLevelProperty() { return detailLevel; } - public void setDetailLevel(DetailLevel level) { this.detailLevel.set(level); } - public DetailLevel getDetailLevel() { return detailLevel.get(); } + 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; } + 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 index 37db6fb6..41418f0c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java @@ -24,14 +24,14 @@ import javafx.scene.layout.VBox; /** - * GraphControlsView + * 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" - * + * - 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. */ @@ -77,6 +77,7 @@ public GraphControlsView(Scene scene) { public GraphLayoutParams getParamsCopy() { return copyParams(params); } + public void setParams(GraphLayoutParams p) { if (p == null) return; copyInto(p, params); @@ -176,14 +177,38 @@ private GridPane buildEdgesGrid() { 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(); }); + 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; @@ -222,12 +247,30 @@ private GridPane buildForceGrid() { 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(); }); + 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; @@ -314,12 +357,18 @@ private void fireOnRoot(javafx.event.Event 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(40); - ColumnConstraints c1 = new ColumnConstraints(); c1.setPercentWidth(60); c1.setHgrow(Priority.ALWAYS); + 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); @@ -331,20 +380,31 @@ private static void addRow(GridPane gp, int row, String label, javafx.scene.Node } 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"); + 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); + 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); + 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); + cb.setPrefWidth(100.0); + cb.setMaxWidth(100.0); + cb.setMinWidth(Region.USE_PREF_SIZE); } private static GraphLayoutParams copyParams(GraphLayoutParams in) { @@ -352,6 +412,7 @@ private static GraphLayoutParams copyParams(GraphLayoutParams in) { copyInto(in, p); return p; } + private static void copyInto(GraphLayoutParams src, GraphLayoutParams dst) { // layout dst.kind = src.kind; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java index d57bc59b..bc8d6329 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java @@ -2,7 +2,6 @@ import edu.jhuapl.trinity.javafx.events.GraphEvent; import edu.jhuapl.trinity.utils.graph.GraphStyleParams; -import java.util.Objects; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; @@ -23,24 +22,26 @@ 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" - * + * - 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) - * + * - 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). @@ -111,9 +112,18 @@ private GridPane buildNodesGrid() { 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(); }); + 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; } @@ -137,9 +147,18 @@ private GridPane buildEdgesGrid() { 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(); }); + 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; } @@ -181,57 +200,75 @@ private void wireGuiSyncHandlers() { // Fine-grained scene.addEventHandler(GraphEvent.SET_NODE_COLOR_GUI, e -> { - Color c = (Color) e.object; if (c == null) return; + 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; } + } finally { + isUpdatingFromGuiSync = false; + } }); scene.addEventHandler(GraphEvent.SET_NODE_RADIUS_GUI, e -> { - Double v = (Double) e.object; if (v == null) return; + 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; } + } finally { + isUpdatingFromGuiSync = false; + } }); scene.addEventHandler(GraphEvent.SET_NODE_OPACITY_GUI, e -> { - Double v = (Double) e.object; if (v == null) return; + 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; } + } finally { + isUpdatingFromGuiSync = false; + } }); scene.addEventHandler(GraphEvent.SET_EDGE_COLOR_GUI, e -> { - Color c = (Color) e.object; if (c == null) return; + 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; } + } finally { + isUpdatingFromGuiSync = false; + } }); scene.addEventHandler(GraphEvent.SET_EDGE_WIDTH_GUI, e -> { - Double v = (Double) e.object; if (v == null) return; + 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; } + } finally { + isUpdatingFromGuiSync = false; + } }); scene.addEventHandler(GraphEvent.SET_EDGE_OPACITY_GUI, e -> { - Double v = (Double) e.object; if (v == null) return; + 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; } + } finally { + isUpdatingFromGuiSync = false; + } }); } 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 index 3d21c3bb..cff4f686 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/MatrixHeatmapView.java @@ -1,11 +1,6 @@ package edu.jhuapl.trinity.javafx.components; -import edu.jhuapl.trinity.javafx.util.MatrixViewUtil; -import java.text.DecimalFormat; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.function.Consumer; +import edu.jhuapl.trinity.utils.MatrixViewUtil; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.geometry.Insets; @@ -20,6 +15,11 @@ 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 * ----------------- @@ -31,7 +31,7 @@ * - 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 @@ -40,16 +40,21 @@ public final class MatrixHeatmapView extends BorderPane { // -------------------- Types -------------------- - public enum ValueMode { RAW, ABS_VALUE } - public enum ScaleMode { AUTO, FIXED } + public enum ValueMode {RAW, ABS_VALUE} - /** Color palette styles. */ + 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. */ + /** + * Simple click payload. + */ public static final class MatrixClick { public final int row; public final int col; @@ -136,7 +141,9 @@ public void invalidated(Observable observable) { // -------------------- Public API -------------------- - /** Set a new matrix. Null or empty → clears view. */ + /** + * 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]; @@ -162,7 +169,9 @@ public void setMatrix(double[][] values) { layoutAndDraw(); } - /** Convenience: accept List>. */ + /** + * Convenience: accept List>. + */ public void setMatrix(List> values) { if (values == null || values.isEmpty()) { setMatrix((double[][]) null); @@ -187,7 +196,10 @@ public void setMatrix(List> values) { public List getRowLabels() { return Arrays.asList(rowLabels); } - /** Optional row labels (length must equal rows). */ + + /** + * Optional row labels (length must equal rows). + */ public void setRowLabels(List labels) { if (labels == null || labels.isEmpty()) { this.rowLabels = new String[rows()]; @@ -200,7 +212,9 @@ public void setRowLabels(List labels) { layoutAndDraw(); } - /** Optional column labels (length must equal cols). Applies compacting if enabled. */ + /** + * 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()]; @@ -222,7 +236,9 @@ public void setColLabels(List labels) { layoutAndDraw(); } - /** Control whether columns compact "Comp 7" → "7". Default: true. */ + /** + * Control whether columns compact "Comp 7" → "7". Default: true. + */ public void setCompactColumnLabels(boolean compact) { this.compactColumnLabels = compact; // Re-apply compaction on currently loaded labels @@ -236,7 +252,9 @@ public void setCompactColumnLabels(boolean compact) { } } - /** Set a uniform label prefix if none provided yet (e.g., "F0..F(n-1)"). */ + /** + * 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); @@ -247,32 +265,42 @@ public void setDefaultAxisLabels(String rowPrefix, String colPrefix) { layoutAndDraw(); } - /** Optional legend title (short); rendered above the legend inside the canvas. */ + /** + * 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. */ + /** + * Sequential palette. + */ public void useSequentialPalette() { this.paletteKind = PaletteKind.SEQUENTIAL; layoutAndDraw(); } - /** Diverging palette (values below/above center split colors). */ + /** + * 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). */ + /** + * Auto-range on (min/max derived from current matrix). + */ public void setAutoRange(boolean on) { this.autoRange = on; layoutAndDraw(); } - /** Fixed range mapping. */ + /** + * Fixed range mapping. + */ public void setFixedRange(double vmin, double vmax) { if (!(vmax > vmin)) { throw new IllegalArgumentException("vmax must be greater than vmin."); @@ -283,13 +311,17 @@ public void setFixedRange(double vmin, double vmax) { layoutAndDraw(); } - /** Show or hide the color legend bar. */ + /** + * Show or hide the color legend bar. + */ public void setShowLegend(boolean show) { this.showLegend = show; layoutAndDraw(); } - /** Set axis label font. */ + /** + * Set axis label font. + */ public void setLabelFont(Font f) { if (f != null) { this.labelFont = f; @@ -297,12 +329,16 @@ public void setLabelFont(Font f) { } } - /** Set click handler for cell picks. */ + /** + * Set click handler for cell picks. + */ public void setOnCellClick(Consumer handler) { this.onCellClick = handler; } - /** Return a defensive copy of the matrix (for external reads). */ + /** + * Return a defensive copy of the matrix (for external reads). + */ public double[][] getMatrixCopy() { int r = rows(), c = cols(); double[][] out = new double[r][c]; @@ -314,8 +350,13 @@ public double[][] getMatrixCopy() { // -------------------- Internals: sizing + render -------------------- - private int rows() { return matrix.length; } - private int cols() { return (rows() == 0) ? 0 : matrix[0].length; } + 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()); @@ -395,8 +436,8 @@ private void drawMatrix(GraphicsContext g, double vmin, double vmax) { 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); + case DIVERGING -> MatrixViewUtil.divergingColor(v, vmin, vmax, + divergingCenter != null ? divergingCenter.doubleValue() : 0.0); }; g.setFill(col); @@ -475,8 +516,8 @@ private void drawLegend(GraphicsContext g, double vmin, double vmax) { 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); + case DIVERGING -> MatrixViewUtil.divergingColor(v, vmin, vmax, + divergingCenter != null ? divergingCenter.doubleValue() : 0.0); }; g.setFill(col); double yy = y0 + k * (h / steps); @@ -500,10 +541,10 @@ private void drawLegend(GraphicsContext g, double vmin, double vmax) { if (paletteKind == PaletteKind.DIVERGING) { double t = MatrixViewUtil.norm01( - divergingCenter != null ? divergingCenter.doubleValue() : 0.0, vmin, vmax); + 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); + divergingCenter != null ? divergingCenter.doubleValue() : 0.0), x0 + pad, y); } } @@ -549,7 +590,9 @@ private void onMouseClick(MouseEvent e) { onCellClick.accept(new MatrixClick(i, j, MatrixViewUtil.sanitize(matrix[i][j]))); } - /** Return {row, col} or null if mouse is outside content box. */ + /** + * 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; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java index 0c5b15f1..25bd6fed 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseJpdfView.java @@ -17,17 +17,6 @@ import javafx.geometry.Insets; import javafx.geometry.Orientation; import javafx.geometry.Pos; -import javafx.scene.image.WritableImage; -import javafx.scene.paint.Color; -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; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.CheckMenuItem; @@ -45,13 +34,25 @@ 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 @@ -99,25 +100,25 @@ public class PairwiseJpdfView extends BorderPane { private final ColorPicker backgroundPicker = new ColorPicker(Color.BLACK); private final ToggleGroup rangeModeGroup = new ToggleGroup(); - private final RadioButton rangeAutoBtn = new RadioButton("Auto"); + private final RadioButton rangeAutoBtn = new RadioButton("Auto"); private final RadioButton rangeGlobalBtn = new RadioButton("Global"); - private final RadioButton rangeFixedBtn = new RadioButton("Fixed"); + 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)); + 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)); + 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)); + new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(60, 800, 180, 10)); private final Spinner canvasHSp = - new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(60, 800, 140, 10)); + new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(60, 800, 140, 10)); // Cache (always non-null) private final DensityCache cache; @@ -160,7 +161,7 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf // Buttons from config panel cfgResetBtn = this.configPanel.getResetButton(); - cfgRunBtn = this.configPanel.getRunButton(); + cfgRunBtn = this.configPanel.getRunButton(); collapseBtn = new Button("Collapse Controls"); collapseBtn.setOnAction(e -> toggleControlsCollapsed()); @@ -186,8 +187,8 @@ public PairwiseJpdfView(JpdfBatchEngine engine, DensityCache cache, PairwiseJpdf sortAscCheck.setSelected(false); HBox searchSort = new HBox(8, - new Label("Search"), searchField, - new Label("Sort"), sortCombo, sortAscCheck + new Label("Search"), searchField, + new Label("Sort"), sortCombo, sortAscCheck ); searchSort.setAlignment(Pos.CENTER_LEFT); searchSort.setPadding(new Insets(4, 8, 2, 8)); @@ -268,150 +269,165 @@ private MenuButton buildActionsMenu() { flushMsItem.setHideOnClick(false); mb.getItems().addAll( - saveItem, loadItem, clearItem, - new SeparatorMenuItem(), - incSortItem, flushNItem, flushMsItem + saveItem, loadItem, clearItem, + new SeparatorMenuItem(), + incSortItem, flushNItem, flushMsItem ); return mb; } -private MenuButton buildAppearanceMenu() { - MenuButton mb = new MenuButton("Appearance"); + private MenuButton buildAppearanceMenu() { + MenuButton mb = new MenuButton("Appearance"); - // ---- Palette & Legend & Background ---- - // (paletteCombo, legendCheck, backgroundPicker are class fields) - paletteCombo.getItems().setAll( + // ---- 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); - } + ); + 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) -> + // 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) -> + 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) -> + 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); + 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(); } - } 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()); + 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); + } + }); - canvasWSp.valueProperty().addListener((o,ov,nv) -> gridPane.setCellSize(nv, canvasHSp.getValue())); - canvasHSp.valueProperty().addListener((o,ov,nv) -> gridPane.setCellSize(canvasWSp.getValue(), nv)); + vminField.setOnAction(e -> applyFixedRangeFromFields()); + vmaxField.setOnAction(e -> applyFixedRangeFromFields()); - return mb; -} + 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; @@ -434,20 +450,30 @@ public void setCohortB(List vectors, String label) { if (label != null && !label.isBlank()) this.cohortBLabel = label; } - public void setToastHandler(Consumer handler) { this.toastHandler = handler; } + public void setToastHandler(Consumer handler) { + this.toastHandler = handler; + } - public void setOnCellClick(Consumer handler) { this.onCellClickHandler = 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; } + 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" + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default" ); if (policy == null) { toast("Failed to determine canonical grid policy for recipe.", true); @@ -471,12 +497,12 @@ public void runWithRecipe(JpdfRecipe recipe) { 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); + .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(); @@ -492,19 +518,18 @@ public void runWithRecipe(JpdfRecipe recipe) { final JpdfRecipe runRecipe = recipe; Task task = new Task<>() { - @Override protected Void call() { + @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); + 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()); + + " - " + String.valueOf(t.getMessage()); Platform.runLater(() -> { setControlsDisabled(false); setProgressText(""); @@ -523,8 +548,8 @@ public void runWithRecipe(JpdfRecipe recipe) { setProgressText("Loaded " + finalCompleted + " / " + total); setControlsDisabled(false); toast("Batch complete: " + finalCompleted - + " surfaces; cacheHits=" + finalBatch.cacheHits - + "; wall=" + wall + " ms.", false); + + " surfaces; cacheHits=" + finalBatch.cacheHits + + "; wall=" + wall + " ms.", false); }); return null; } @@ -565,7 +590,11 @@ private int safeSpinnerInt(Spinner sp, int fallback) { } private static double parseDoubleSafe(String s, double def) { - try { return Double.parseDouble(s); } catch (Exception ex) { return def; } + try { + return Double.parseDouble(s); + } catch (Exception ex) { + return def; + } } private static String safeFilename(String s) { @@ -588,15 +617,23 @@ public void toast(String msg, boolean isError) { } else { Platform.runLater(() -> { this.getScene().getRoot().fireEvent( - new CommandTerminalEvent(msg, new Font("Consolas", 18), - isError ? Color.RED : Color.LIGHTGREEN)); + 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 PairwiseJpdfConfigPanel getConfigPanel() { + return configPanel; + } + + public PairGridPane getGridPane() { + return gridPane; + } + + public SplitPane getSplitPane() { + return splitPane; + } public void cancelRun() { Thread t = workerThread; @@ -605,10 +642,21 @@ public void cancelRun() { } } - public List getCohortA() { return cohortA; } - public List getCohortB() { return cohortB; } - public String getCohortALabel() { return cohortALabel; } - public String getCohortBLabel() { return cohortBLabel; } + 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) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java index 27ec4a6b..edc15a91 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/PairwiseMatrixView.java @@ -22,9 +22,6 @@ import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixEngine.MatrixResult; import edu.jhuapl.trinity.utils.statistics.StatisticEngine; import edu.jhuapl.trinity.utils.statistics.SyntheticMatrixFactory.SyntheticMatrix; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Orientation; @@ -44,6 +41,13 @@ 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 @@ -51,11 +55,12 @@ * 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; @@ -215,7 +220,9 @@ public PairwiseMatrixView(PairwiseMatrixConfigPanel configPanel, DensityCache ca this.configPanel.setOnRun(this::runWithRequest); } - public PairwiseMatrixView() { this(null, null); } + public PairwiseMatrixView() { + this(null, null); + } // --------------------------------------------------------------------- // Public API @@ -231,27 +238,57 @@ public void setCohortB(List vectors, String label) { 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 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 PairwiseMatrixConfigPanel getConfigPanel() { return configPanel; } - public MatrixHeatmapView getHeatmapView() { return heatmap; } - public SplitPane getSplitPane() { return splitPane; } + public SplitPane getSplitPane() { + return splitPane; + } - /** send toast/status to Terminal/Console integration. */ - public void setToastHandler(Consumer handler) { this.toastHandler = handler; } + /** + * 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; } + /** + * 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; } + 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) @@ -264,8 +301,8 @@ private void runWithRequest(Request req) { if (req.autoRange) heatmap.setAutoRange(true); else heatmap.setFixedRange( - req.fixedMin != null ? req.fixedMin : 0.0, - req.fixedMax != null ? req.fixedMax : 1.0 + req.fixedMin != null ? req.fixedMin : 0.0, + req.fixedMax != null ? req.fixedMax : 1.0 ); setControlsDisabled(true); @@ -286,7 +323,7 @@ private void runWithRequest(Request req) { return; } result = PairwiseMatrixEngine.computeSimilarityMatrix( - cohortA, recipe, req.includeDiagonal + cohortA, recipe, req.includeDiagonal ); } else { // DIVERGENCE @@ -307,7 +344,7 @@ private void runWithRequest(Request req) { return; } result = PairwiseMatrixEngine.computeDivergenceMatrix( - cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), cache + cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), cache ); } @@ -322,10 +359,10 @@ cohortA, cohortB, recipe, nonNull(req.divergenceMetric, DivergenceMetric.JS), ca heatmap.setRowLabels(result.labels); heatmap.setColLabels(result.labels); currentMatrixKind = (req.mode == Mode.SIMILARITY) - ? MatrixToGraphAdapter.MatrixKind.SIMILARITY - : MatrixToGraphAdapter.MatrixKind.DIVERGENCE; + ? MatrixToGraphAdapter.MatrixKind.SIMILARITY + : MatrixToGraphAdapter.MatrixKind.DIVERGENCE; toast((result.title != null ? result.title + " — " : "") + - "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); + "N=" + result.matrix.length + "; wall=" + wall + " ms.", false); } setProgressText(""); setControlsDisabled(false); @@ -350,10 +387,16 @@ public PairwiseMatrixConfigPanel.Request getLastRequestOrBuild() { // --------------------------------------------------------------------- public void renderPdfForCellUsingEngine(int i, int j, Request req) { - if (req == null) { toast("No configuration available to build JPDF.", true); return; } + 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; } + 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); @@ -364,21 +407,21 @@ public void renderPdfForCellUsingEngine(int i, int j, Request req) { // (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)" - )); - } + 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); @@ -388,11 +431,20 @@ public void renderPdfForCellUsingEngine(int i, int j, Request req) { // 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; } + 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; } + 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); @@ -458,14 +510,20 @@ private MenuButton buildActionsMenu() { // 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; } + 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; } + 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())); @@ -476,7 +534,10 @@ private MenuButton buildActionsMenu() { 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; } + 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); @@ -484,7 +545,10 @@ private MenuButton buildActionsMenu() { 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; } + 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); @@ -505,7 +569,7 @@ private MenuButton buildActionsMenu() { dlg.initStyle(StageStyle.TRANSPARENT); DialogPane dialogPane = dlg.getDialogPane(); dialogPane.setBackground(Background.EMPTY); - dialogPane.getScene().setFill(Color.TRANSPARENT); + dialogPane.getScene().setFill(Color.TRANSPARENT); dlg.showAndWait().ifPresent(res -> { switch (res.kind()) { case SIMILARITY_MATRIX, DIVERGENCE_MATRIX -> loadSyntheticMatrix(res.matrix()); @@ -515,26 +579,32 @@ private MenuButton buildActionsMenu() { }); mb.getItems().addAll(buildGraphFromSim, buildGraphFromDiv, - new SeparatorMenuItem(), copyAToB, splitA, bShiftOneComp, cohortsByLabel, - new SeparatorMenuItem(), bRandomGaussian, bRandomNoise, - new SeparatorMenuItem(), syntheticDataDialogItem, clearItem); + 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; } + 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; } + 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; + (currentMatrixKind == MatrixToGraphAdapter.MatrixKind.SIMILARITY) + ? MatrixToGraphAdapter.WeightMode.DIRECT + : MatrixToGraphAdapter.WeightMode.INVERSE_FOR_DIVERGENCE; SuperMDS.Params mdsParams = new SuperMDS.Params(); mdsParams.outputDim = 3; @@ -543,25 +613,36 @@ public void triggerGraphBuildWithParams(GraphLayoutParams layout) { setProgressText("Building graph…"); BuildGraphFromMatrixTask task = new BuildGraphFromMatrixTask( - scene, M, labels, currentMatrixKind, wmode, layout, mdsParams); + 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); }); + 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(); + 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); + .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); } @@ -570,7 +651,9 @@ private void triggerGraphBuild(MatrixToGraphAdapter.MatrixKind kind) { // Synthetic matrix & cohort helpers // --------------------------------------------------------------------- - /** Load a synthetic matrix (similarity or divergence) into the heatmap and stash fallback cohorts if provided. */ + /** + * 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); @@ -580,8 +663,8 @@ public void loadSyntheticMatrix(SyntheticMatrix sm) { // Map factory kind to adapter kind this.currentMatrixKind = (sm.kind == MatrixToGraphAdapter.MatrixKind.DIVERGENCE) - ? MatrixToGraphAdapter.MatrixKind.DIVERGENCE - : MatrixToGraphAdapter.MatrixKind.SIMILARITY; + ? MatrixToGraphAdapter.MatrixKind.DIVERGENCE + : MatrixToGraphAdapter.MatrixKind.SIMILARITY; // Render the matrix heatmap.setMatrix(sm.matrix); @@ -615,7 +698,9 @@ public void loadSyntheticMatrix(SyntheticMatrix sm) { toast(title + " — N=" + sm.matrix.length + " loaded.", false); } - /** Convenience: set both cohorts at once with labels. */ + /** + * 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); @@ -624,12 +709,16 @@ public void setCohorts(List a, String labelA, List toast("Cohorts set: A=" + labelA + " (" + na + "), B=" + labelB + " (" + nb + ")", false); } - /** Convenience overload with default labels. */ + /** + * 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. */ + /** + * 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); @@ -656,7 +745,9 @@ private void applyHeatmapDefaultsFor(MatrixToGraphAdapter.MatrixKind kind, doubl } } - /** Returns [min,max] over finite entries. */ + /** + * 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++) { @@ -670,7 +761,8 @@ private static double[] minMax(double[][] M) { } } if (!(max > min)) { // degenerate - min = 0.0; max = 1.0; + min = 0.0; + max = 1.0; } return new double[]{min, max}; } @@ -709,9 +801,15 @@ private List synthUniformLikeA(List likeA, double } private void promptShiftOneComponent() { - if (cohortA == null || cohortA.isEmpty()) { toast("Cohort A is empty.", true); return; } + 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; } + if (d == 0) { + toast("Cohort A has zero-dimensional vectors.", true); + return; + } TextInputDialog compDlg = new TextInputDialog("0"); compDlg.setTitle("Shift Component"); @@ -721,9 +819,16 @@ private void promptShiftOneComponent() { 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; } + 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"); @@ -733,8 +838,12 @@ private void promptShiftOneComponent() { if (deltaRes.isEmpty()) return; double delta; - try { delta = Double.parseDouble(deltaRes.get().trim()); } - catch (Exception ex) { toast("Invalid delta.", true); return; } + 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) { @@ -771,7 +880,10 @@ private void promptDeriveCohortsByLabel() { String la = aRes.get().trim(); String lb = bRes.get().trim(); - if (la.isEmpty() || lb.isEmpty()) { toast("Labels cannot be empty.", true); return; } + if (la.isEmpty() || lb.isEmpty()) { + toast("Labels cannot be empty.", true); + return; + } List aList = new ArrayList<>(); List bList = new ArrayList<>(); @@ -796,7 +908,9 @@ private static String safeVectorLabel(FeatureVector fv) { try { String lbl = fv.getLabel(); return (lbl == null) ? "" : lbl.trim(); - } catch (Throwable t) { return ""; } + } catch (Throwable t) { + return ""; + } } // --------------------------------------------------------------------- @@ -848,18 +962,26 @@ private void toast(String msg, boolean isError) { if (toastHandler != null) { toastHandler.accept((isError ? "[Error] " : "") + msg); } else { - System.out.println((isError ? "[Error] " : "[Info] ") + msg); + LOG.atLevel(isError ? Level.ERROR : Level.INFO).log(msg); } } - private double[][] currentMatrix() { return heatmap.getMatrixCopy(); } - private List currentRowLabels() { return heatmap.getRowLabels(); } + + 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); } + private static T nonNull(T v, T def) { + return (v != null ? v : def); + } // --------------------------------------------------------------------- // Jpdf recipe builders & policy/cache resolvers @@ -868,16 +990,16 @@ private static String safeName(String s) { 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); + .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); @@ -887,7 +1009,9 @@ private JpdfRecipe buildRecipeFromRequest(Request req) { return b.build(); } - /** Build a whitelist recipe for a single (i,j) pair. */ + /** + * 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); @@ -902,23 +1026,23 @@ private JpdfRecipe buildSinglePairWhitelistRecipe(Request req, int i, int j) { 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); + .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" + req.cohortALabel != null ? req.cohortALabel : "A", + req.cohortBLabel != null ? req.cohortBLabel : "B" ); } return b.build(); @@ -926,12 +1050,14 @@ private JpdfRecipe buildSinglePairWhitelistRecipe(Request req, int i, int j) { private CanonicalGridPolicy resolveCanonicalPolicy(JpdfRecipe recipe) { return CanonicalGridPolicy.get( - (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) - ? recipe.getCanonicalPolicyId() - : "default"); + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default"); } - private DensityCache resolveDensityCache() { return this.cache; } + private DensityCache resolveDensityCache() { + return this.cache; + } // --------------------------------------------------------------------- // Grid math helpers for ΔPDF @@ -957,7 +1083,9 @@ private static List> subtract(List> A, List> bilinearResample(List> G, double[] srcX, double[] srcY, double[] dstX, double[] dstY) { @@ -991,19 +1119,31 @@ private static List> bilinearResample(List> G, return out; } - /** largest k such that src[k] <= v, or 0 if v <= src[0] */ + /** + * 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; + 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; } + 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 index 5c7ef888..f4d5316d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/dialogs/SyntheticDataDialog.java @@ -6,12 +6,6 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.util.Callback; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - import javafx.scene.control.Alert; import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; @@ -27,22 +21,28 @@ 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 - * + * • 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 } + public enum Kind {SIMILARITY_MATRIX, DIVERGENCE_MATRIX, COHORTS} - /** Discriminated union for dialog output. */ + /** + * 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 @@ -53,13 +53,30 @@ private Result(Kind kind, SyntheticMatrix matrix, Cohorts cohorts) { 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); } + 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); + } } // ------------------------------ @@ -70,6 +87,7 @@ private static TextField tf(String text, int prefWidth) { t.setPrefWidth(prefWidth); return t; } + private static HBox row(String label, Node control) { Label l = new Label(label); l.setPrefWidth(160); @@ -77,6 +95,7 @@ private static HBox row(String label, Node 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"); @@ -86,14 +105,29 @@ private static VBox section(String title, Node... rows) { 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; } + 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; } + 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; } + try { + return Long.parseLong(tf.getText().trim()); + } catch (Exception e) { + return def; + } } // ------------------------------ @@ -169,9 +203,9 @@ public SyntheticDataDialog() { // Content tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); tabs.getTabs().addAll( - new Tab("Similarity", buildSimilarityContent()), - new Tab("Divergence", buildDivergenceContent()), - new Tab("Cohorts", buildCohortsContent()) + new Tab("Similarity", buildSimilarityContent()), + new Tab("Divergence", buildDivergenceContent()), + new Tab("Cohorts", buildCohortsContent()) ); getDialogPane().setContent(tabs); getDialogPane().setPadding(new Insets(8)); @@ -186,13 +220,13 @@ public SyntheticDataDialog() { return switch (name) { case "Similarity" -> buildSimilarity(); case "Divergence" -> buildDivergence(); - case "Cohorts" -> buildCohorts(); + 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); + "Build failed: " + t.getClass().getSimpleName() + " – " + String.valueOf(t.getMessage()), + ButtonType.OK); a.showAndWait(); return null; } @@ -212,10 +246,10 @@ private Node buildSimilarityContent() { // 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)) + 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); @@ -308,11 +342,11 @@ 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)) + 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); @@ -413,7 +447,7 @@ private static int[] parseSizes(String csv, int enforceK) { for (int i = 0; i < enforceK; i++) def[i] = 8; return def; } - return new int[]{10,10,10}; + return new int[]{10, 10, 10}; } String[] toks = csv.split("[,;\\s]+"); List vals = new ArrayList<>(); @@ -429,12 +463,12 @@ private static int[] parseSizes(String csv, int enforceK) { for (int i = 0; i < enforceK; i++) def[i] = 8; return def; } - return new int[]{10,10,10}; + 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); + 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()]; 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 index ba0d00b7..5fa7f4ce 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/FeatureVectorManagerPane.java @@ -10,19 +10,6 @@ import javafx.collections.ListChangeListener; import javafx.geometry.Insets; import javafx.scene.Scene; -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; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.scene.control.ChoiceDialog; @@ -37,6 +24,20 @@ 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 { @@ -56,7 +57,7 @@ public FeatureVectorManagerPane(Scene scene, Pane parent, FeatureVectorManagerSe wireViewToService(); installCollectionContextMenu(); installTableContextMenu(); - installSearchWiring(); + installSearchWiring(); } @Override @@ -64,7 +65,7 @@ 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()); @@ -76,13 +77,15 @@ private void wireViewToService() { // Render "(unnamed)" for empty names, both in popup and button cell combo.setCellFactory(listView -> new ListCell<>() { - @Override protected void updateItem(String item, boolean empty) { + @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) { + @Override + protected void updateItem(String item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : (item == null || item.trim().isEmpty() ? "(unnamed)" : item)); } @@ -129,7 +132,9 @@ private void wireViewToService() { () -> service.getDisplayedVectors(), service.getDisplayedVectors())); } - /** Wire the header Search TextField to the service text filter (with debounce). */ + /** + * Wire the header Search TextField to the service text filter (with debounce). + */ private void installSearchWiring() { TextField tf = view.getSearchField(); @@ -199,8 +204,8 @@ private void installCollectionContextMenu() { 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); + "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); @@ -212,8 +217,8 @@ private void installCollectionContextMenu() { String current = service.activeCollectionNameProperty().get(); if (current == null) return; List options = service.getCollectionNames().stream() - .filter(n -> !Objects.equals(n, current)) - .collect(Collectors.toList()); + .filter(n -> !Objects.equals(n, current)) + .collect(Collectors.toList()); if (options.isEmpty()) { info("No other collections to merge into."); return; @@ -224,8 +229,8 @@ private void installCollectionContextMenu() { 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); + "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; @@ -256,7 +261,7 @@ private void installCollectionContextMenu() { applyMenu.getItems().addAll(miApplyAppend, miApplyReplace, miSetAllAppend, miSetAllReplace); ctx.getItems().addAll(miRename, miDuplicate, miDelete, new SeparatorMenuItem(), - miMergeInto, export, new SeparatorMenuItem(), applyMenu); + miMergeInto, export, new SeparatorMenuItem(), applyMenu); combo.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { if (!ctx.isShowing()) ctx.show(combo, e.getScreenX(), e.getScreenY()); @@ -377,8 +382,8 @@ private void installTableContextMenu() { applyMenu.getItems().addAll(miApplyAppend, miApplyReplace); ctx.getItems().addAll(miRemoveSel, miCopyTo, new SeparatorMenuItem(), - miEditLabel, miEditMeta, new SeparatorMenuItem(), - miLocate, applyMenu); + miEditLabel, miEditMeta, new SeparatorMenuItem(), + miLocate, applyMenu); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { if (!ctx.isShowing()) ctx.show(table, e.getScreenX(), e.getScreenY()); @@ -389,6 +394,7 @@ miEditLabel, miEditMeta, new SeparatorMenuItem(), /** * 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) { @@ -400,7 +406,7 @@ private void applySelectionOrActive(boolean replace) { fc.setFeatures(sel); FeatureVectorEvent evt = new FeatureVectorEvent(FeatureVectorEvent.NEW_FEATURE_COLLECTION, fc, - FeatureVectorManagerService.MANAGER_APPLY_TAG); + FeatureVectorManagerService.MANAGER_APPLY_TAG); evt.clearExisting = replace; getScene().getRoot().fireEvent(evt); @@ -440,6 +446,7 @@ private void info(String msg) { a.setHeaderText(null); a.showAndWait(); } + private void error(String msg) { Alert a = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK); a.setHeaderText("Error"); 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 273726da..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,21 +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 edu.jhuapl.trinity.javafx.components.hyperdrive.BatchRequestManager; -import edu.jhuapl.trinity.javafx.components.hyperdrive.ImageEmbeddingsBatchLauncher; + import static edu.jhuapl.trinity.data.messages.llm.EmbeddingsImageUrl.imageUrlFromImage; 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.currentEmbeddingsModel; -import static edu.jhuapl.trinity.messages.RestAccessLayer.currentChatModel; -import static edu.jhuapl.trinity.messages.RestAccessLayer.stringToChatCaptionResponse; -import java.util.Collections; -import java.util.Map; -import javafx.stage.FileChooser; +import static edu.jhuapl.trinity.messages.RestAccessLayer.*; /** * @author Sean Phillips @@ -124,7 +123,7 @@ public class HyperdrivePane extends LitPathPane { ImageView baseImageView; BorderPane embeddingsBorderPane; StackPane embeddingsCenterStack; - TextArea baseTextArea; + TextArea baseTextArea; TabPane tabPane; Tab imageryEmbeddingsTab; @@ -151,7 +150,7 @@ public class HyperdrivePane extends LitPathPane { ChoiceBox metricChoiceBox; AtomicInteger requestNumber; AtomicInteger batchNumber; - + Map outstandingRequests; ImageEmbeddingsBatchLauncher imageBatchLauncher; BatchRequestManager> imageEmbeddingManager; @@ -192,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); @@ -224,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()) { @@ -371,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<>(); @@ -390,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); @@ -447,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(); @@ -508,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() @@ -521,7 +520,7 @@ public HyperdrivePane(Scene scene, Pane parent) { exportFeatureCollection("Save FeatureCollecdtion as...", fc); } - }); + }); ContextMenu embeddingsContextMenu = new ContextMenu(selectAllMenuItem, setLabelItem, requestCaptionItem, chooseCaptionItem, textLandmarkCaptionItem, imageLandmarkCaptionItem, @@ -741,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); @@ -770,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) @@ -788,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(); @@ -804,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(); @@ -826,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)) ); @@ -860,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); } @@ -872,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() + @@ -1107,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; @@ -1351,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 @@ -1362,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); @@ -1380,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() @@ -1392,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) : "") + ); }); } ); @@ -1408,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/LitPathPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/LitPathPane.java index c8cf465c..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 @@ -164,7 +164,7 @@ public LitPathPane(Scene scene, Pane parent, int width, int height, Pane userCon mainTitleArea, // title bar with free space this, // whole pane (for "include frame") contentPane, // content-only - mainTitleArea, // right-click target + mainTitleArea, // right-click target this.scene, toolbarFitWidth ); @@ -275,14 +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 index 3bfa81be..9c29dce9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/MatrixHeatmapPane.java @@ -8,6 +8,9 @@ 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; @@ -17,20 +20,20 @@ * ----------------- * 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 -> { ... }); + * 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; /** @@ -38,23 +41,23 @@ public final class MatrixHeatmapPane extends LitPathPane { */ 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 + 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) - ))); + scene.getRoot().fireEvent( + new CommandTerminalEvent(msg, new Font("Consolas", 18), Color.LIGHTGREEN) + ))); } /** @@ -62,86 +65,110 @@ public MatrixHeatmapPane(Scene scene, Pane parent) { */ 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 + 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) - ))); + 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). */ + /** + * Replace the matrix (null/empty clears the view). + */ public void setMatrix(double[][] matrix) { view.setMatrix(matrix); } - /** Convenience overload for List> matrices. */ + /** + * Convenience overload for List> matrices. + */ public void setMatrix(List> matrix) { view.setMatrix(matrix); } - /** Apply the same labels to rows and columns (square matrices). */ + /** + * 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. */ + /** + * Set row labels only. + */ public void setRowLabels(List labels) { view.setRowLabels(labels); } - /** Set column labels only. */ + /** + * Set column labels only. + */ public void setColLabels(List labels) { view.setColLabels(labels); } - /** Use a sequential (single-hue) palette. */ + /** + * Use a sequential (single-hue) palette. + */ public void useSequentialPalette() { view.useSequentialPalette(); } - /** Use a diverging palette split around the given center value. */ + /** + * 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. */ + /** + * 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. */ + /** + * 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. */ + /** + * Show or hide the legend bar. + */ public void setShowLegend(boolean show) { view.setShowLegend(show); } - /** Handle cell clicks (row, col, value). */ + /** + * Handle cell clicks (row, col, value). + */ public void setOnCellClick(Consumer handler) { view.setOnCellClick(handler); } - /** Access to the embedded view for advanced customization. */ + /** + * Access to the embedded view for advanced customization. + */ public MatrixHeatmapView getView() { return view; } @@ -162,7 +189,7 @@ public void toast(String msg, boolean isError) { if (h != null) { h.accept(prefixed); } else { - System.out.println(prefixed); + LOG.atLevel(isError ? Level.ERROR : Level.INFO).log(msg); } } 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 index d96301e2..be629b77 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseJpdfPane.java @@ -17,6 +17,7 @@ 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 { @@ -24,17 +25,17 @@ public final class PairwiseJpdfPane extends LitPathPane { private final PairwiseJpdfView view; public PairwiseJpdfPane( - Scene scene, - Pane parent, - JpdfBatchEngine engine, - DensityCache cache, - PairwiseJpdfConfigPanel configPanel + 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); + 1100, 760, + new PairwiseJpdfView(engine, cache, configPanel), + "Pairwise Joint Densities", "Batch", + 420.0, 400.0); this.view = (PairwiseJpdfView) this.contentPane; @@ -52,11 +53,11 @@ public PairwiseJpdfPane( 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)" + HypersurfaceGridEvent.RENDER_PDF, + gridList, + res.getxCenters(), + res.getyCenters(), + item.xLabel + " | " + item.yLabel + " (PDF)" )); view.toast("Opened PDF in 3D.", false); }); @@ -76,12 +77,15 @@ public PairwiseJpdfPane(Scene scene, Pane parent) { 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; } @@ -89,7 +93,7 @@ public PairwiseJpdfView getView() { @Override public void maximize() { scene.getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.POPOUT_PAIRWISEJPDF_JPDF, Boolean.TRUE) + 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 index 26f71c8b..243e483a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/PairwiseMatrixPane.java @@ -9,19 +9,20 @@ import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; import edu.jhuapl.trinity.utils.statistics.DensityCache; import edu.jhuapl.trinity.utils.statistics.PairwiseMatrixConfigPanel; -import java.util.List; 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 @@ -31,16 +32,16 @@ public final class PairwiseMatrixPane extends LitPathPane { private final PairwiseMatrixView view; public PairwiseMatrixPane( - Scene scene, - Pane parent, - DensityCache cache, - PairwiseMatrixConfigPanel configPanel + Scene scene, + Pane parent, + DensityCache cache, + PairwiseMatrixConfigPanel configPanel ) { super(scene, parent, - 1100, 760, - new PairwiseMatrixView(configPanel, cache), - "Pairwise Matrices", "Matrix", - 420.0, 400.0); + 1100, 760, + new PairwiseMatrixView(configPanel, cache), + "Pairwise Matrices", "Matrix", + 420.0, 400.0); this.view = (PairwiseMatrixView) this.contentPane; @@ -52,16 +53,16 @@ public PairwiseMatrixPane( } }); scene.addEventHandler(GraphEvent.GRAPH_REBUILD_PARAMS, e -> { - GraphLayoutParams p = (GraphLayoutParams) e.object; - view.triggerGraphBuildWithParams(p); - }); + GraphLayoutParams p = (GraphLayoutParams) e.object; + view.triggerGraphBuildWithParams(p); + }); - // wire matrix cell clicks (i,j,value). + // 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); + view.renderPdfForCellUsingEngine(click.row, click.col, req); String msg = "Cell (" + click.row + "," + click.col + ") = " + click.value; toast(msg, false); }); @@ -91,16 +92,20 @@ public PairwiseMatrixView getView() { } // --- Helpers to fabricate/prepare Cohort B --- - /** Copy Cohort A into Cohort B (A vs A sanity check). */ + /** + * 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)"); + (label != null && !label.isBlank()) ? label : view.getCohortALabel() + " (copy)"); } - /** Split Cohort A into two halves: first half -> A, second half -> B. */ + /** + * 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; @@ -108,7 +113,7 @@ public void splitACohortIntoAandB() { 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() { @@ -120,7 +125,7 @@ public void 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))); + 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 index 4f408164..78034651 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/StatPdfCdfPane.java @@ -5,16 +5,17 @@ import edu.jhuapl.trinity.utils.statistics.GridDensityResult; import edu.jhuapl.trinity.utils.statistics.StatPdfCdfChartPanel; import edu.jhuapl.trinity.utils.statistics.StatisticEngine; -import java.util.List; 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. * @@ -56,11 +57,11 @@ public StatPdfCdfPane(Scene scene, Pane parent) { * 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 + Scene scene, + Pane parent, + List vectors, + StatisticEngine.ScalarType scalarType, + int bins ) { super( scene, @@ -96,8 +97,8 @@ private void onComputeSurface(GridDensityResult result) { : result.pdfAsListGrid(); String label = (useCDF ? "CDF" : "PDF") + " : " - + chartPanel.getScalarType() + " vs " - + chartPanel.getYFeatureTypeForDisplay(); + + chartPanel.getScalarType() + " vs " + + chartPanel.getYFeatureTypeForDisplay(); if (getScene() != null) { getScene().getRoot().fireEvent( 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/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 ceaa10f7..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 @@ -336,10 +336,10 @@ public void buildMenu() { })); exportSubMenuItem.addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "Compute Stats", stats, e -> { hyperspace3DPane.getScene().getRoot().fireEvent( - new ApplicationEvent(ApplicationEvent.SHOW_STATISTICS_PANE, + 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 index 4e083adc..f32c0b35 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/FeatureVectorManagerPopoutController.java @@ -11,19 +11,6 @@ import javafx.collections.ListChangeListener; import javafx.geometry.Insets; import javafx.scene.Scene; -import javafx.scene.input.ContextMenuEvent; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.VBox; -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; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; @@ -40,7 +27,12 @@ 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; @@ -48,6 +40,15 @@ 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; @@ -64,7 +65,9 @@ public FeatureVectorManagerPopoutController(FeatureVectorManagerService service, this.appScene = Objects.requireNonNull(appScene, "appScene"); } - /** Open (or focus) the pop-out window. */ + /** + * Open (or focus) the pop-out window. + */ public void show() { if (stage != null && stage.isShowing()) { stage.toFront(); @@ -77,7 +80,9 @@ public void show() { stage.show(); } - /** Close the pop-out window (no service teardown). */ + /** + * Close the pop-out window (no service teardown). + */ public void close() { if (stage != null) stage.close(); } @@ -86,7 +91,9 @@ public boolean isOpen() { return stage != null && stage.isShowing(); } - /** Move to a secondary screen if available. */ + /** + * Move to a secondary screen if available. + */ public void sendToSecondScreen() { if (stage == null) return; Screen second = getSecondaryScreen(); @@ -117,7 +124,7 @@ private void buildStageAndWire() { root.setBackground(Background.EMPTY); scene = new Scene(root, Color.BLACK); - + //Make everything pretty String CSS = StyleResourceProvider.getResource("styles.css").toExternalForm(); scene.getStylesheets().add(CSS); @@ -125,7 +132,7 @@ private void buildStageAndWire() { 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); @@ -168,13 +175,15 @@ private void wireViewToService() { combo.setVisibleRowCount(15); combo.setCellFactory(lv -> new ListCell<>() { - @Override protected void updateItem(String item, boolean empty) { + @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) { + @Override + protected void updateItem(String item, boolean empty) { super.updateItem(item, empty); setText(empty ? null : (item == null || item.trim().isEmpty() ? "(unnamed)" : item)); } @@ -211,7 +220,9 @@ private void wireViewToService() { }); } - /** Debounced search → service.setTextFilter; Enter applies; Esc clears. */ + /** + * Debounced search → service.setTextFilter; Enter applies; Esc clears. + */ private void installSearchWiring() { TextField tf = view.getSearchField(); @@ -337,7 +348,7 @@ private void installCollectionContextMenu() { 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); @@ -430,7 +441,9 @@ private void installTableContextMenu() { dlg.initOwner(stage); dlg.setResultConverter(btn -> btn == ButtonType.OK ? parseKeyValues(ta.getText()) : null); - dlg.showAndWait().ifPresent(kv -> { if (!kv.isEmpty()) service.bulkEditMetadataInActive(sel, kv); }); + dlg.showAndWait().ifPresent(kv -> { + if (!kv.isEmpty()) service.bulkEditMetadataInActive(sel, kv); + }); }); MenuItem miLocate = new MenuItem("Locate in 3D"); @@ -459,7 +472,9 @@ miEditLabel, miEditMeta, new SeparatorMenuItem(), }); } - /** Selection-aware apply: if selection present, fire NEW_FEATURE_COLLECTION to main scene; else use service. */ + /** + * 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()); @@ -521,8 +536,14 @@ private void restoreWindowPrefs() { 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); } + 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); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java index ffbc4e30..1fae3689 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/PairwiseJpdfPanePopoutController.java @@ -13,6 +13,7 @@ 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; @@ -21,7 +22,6 @@ import javafx.stage.Window; import java.util.prefs.Preferences; -import javafx.scene.layout.Background; public class PairwiseJpdfPanePopoutController { private final Scene appScene; @@ -40,17 +40,19 @@ public PairwiseJpdfPanePopoutController(Scene scene) { } public PairwiseJpdfPanePopoutController( - Scene appScene, - JpdfBatchEngine engine, - DensityCache cache, - PairwiseJpdfConfigPanel configPanel) { + 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) */ + /** + * Show the popout window (or focus if already open) + */ public void show() { if (stage != null && stage.isShowing()) { stage.toFront(); @@ -105,12 +107,12 @@ private void buildStageAndWire() { 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); @@ -131,7 +133,7 @@ private void buildStageAndWire() { 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); @@ -192,8 +194,14 @@ private void restoreWindowPrefs() { 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); } + 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); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java b/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java index e816ed75..d358d38c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/controllers/StatPdfCdfPopoutController.java @@ -4,10 +4,6 @@ import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; import edu.jhuapl.trinity.utils.statistics.GridDensityResult; import edu.jhuapl.trinity.utils.statistics.StatPdfCdfChartPanel; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.prefs.Preferences; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ToolBar; @@ -20,9 +16,14 @@ 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. @@ -46,7 +47,9 @@ public StatPdfCdfPopoutController(Scene appScene) { this.appScene = Objects.requireNonNull(appScene, "appScene"); } - /** Provide an initial state to apply to the popout chart. */ + /** + * Provide an initial state to apply to the popout chart. + */ public void setInitialState(StatPdfCdfChartPanel.State state) { if (state == null) return; if (isOpen() && chart != null) { @@ -56,7 +59,9 @@ public void setInitialState(StatPdfCdfChartPanel.State state) { } } - /** Retrieve the latest state: live export if open, else the last pending/applied state if available. */ + /** + * 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()); @@ -64,7 +69,9 @@ public Optional getCurrentState() { return Optional.ofNullable(pendingState); } - /** Open (or focus) the popout window. */ + /** + * Open (or focus) the popout window. + */ public void show() { if (stage != null && stage.isShowing()) { stage.toFront(); @@ -77,7 +84,9 @@ public void show() { stage.show(); } - /** Close the window. The latest state remains accessible via getCurrentState(). */ + /** + * Close the window. The latest state remains accessible via getCurrentState(). + */ public void close() { if (stage == null) return; saveWindowPrefs(); @@ -93,7 +102,9 @@ public boolean isOpen() { return stage != null && stage.isShowing(); } - /** Move the window to a secondary screen if available. */ + /** + * Move the window to a secondary screen if available. + */ public void sendToSecondScreen() { if (stage == null) return; Screen second = getSecondaryScreen(); @@ -178,8 +189,8 @@ private void forwardSurfaceToMainScene(GridDensityResult result) { : result.pdfAsListGrid(); String label = (useCDF ? "CDF" : "PDF") + " : " - + chart.getScalarType() + " vs " - + chart.getYFeatureTypeForDisplay(); + + chart.getScalarType() + " vs " + + chart.getYFeatureTypeForDisplay(); appScene.getRoot().fireEvent( new HypersurfaceGridEvent( @@ -247,8 +258,14 @@ private void restoreWindowPrefs() { 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); } + 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/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/GraphEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java index 355b098c..a6ec89b1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/GraphEvent.java @@ -41,19 +41,60 @@ public class GraphEvent extends Event { 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); } - public GraphEvent(EventType arg0, Object arg1) { this(arg0); object = arg1; } - public GraphEvent(EventType arg0, Object arg1, Object arg2) { this(arg0); object = arg1; object2 = arg2; } - public GraphEvent(Object arg0, EventTarget arg1, EventType arg2) { super(arg0, arg1, arg2); object = arg0; } + public GraphEvent(EventType arg0) { + super(arg0); + } + + public GraphEvent(EventType arg0, Object arg1) { + this(arg0); + object = arg1; + } + + public GraphEvent(EventType arg0, Object arg1, Object arg2) { + this(arg0); + object = arg1; + object2 = arg2; + } + + 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)); } + 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 index d319f69a..e049cbd4 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java @@ -13,105 +13,107 @@ public class HypersurfaceEvent extends Event { public Object object; // loose payload, mirrors HyperspaceEvent pattern public static final EventType ANY = - new EventType<>(Event.ANY, "HYPERSURFACE_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 + new EventType<>(ANY, "HYPERSURF_Y_SCALE_CHANGED"); // Double public static final EventType SURF_SCALE_CHANGED = - new EventType<>(ANY, "HYPERSURF_SURF_SCALE_CHANGED"); // Double + new EventType<>(ANY, "HYPERSURF_SURF_SCALE_CHANGED"); // Double public static final EventType XWIDTH_CHANGED = - new EventType<>(ANY, "HYPERSURF_XWIDTH_CHANGED"); // Integer + new EventType<>(ANY, "HYPERSURF_XWIDTH_CHANGED"); // Integer public static final EventType ZWIDTH_CHANGED = - new EventType<>(ANY, "HYPERSURF_ZWIDTH_CHANGED"); // Integer + 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 + new EventType<>(ANY, "HYPERSURF_SET_XWIDTH_GUI"); // Integer public static final EventType SET_ZWIDTH_GUI = - new EventType<>(ANY, "HYPERSURF_SET_ZWIDTH_GUI"); // Integer + new EventType<>(ANY, "HYPERSURF_SET_ZWIDTH_GUI"); // Integer public static final EventType SET_YSCALE_GUI = - new EventType<>(ANY, "HYPERSURF_SET_YSCALE_GUI"); // Double + new EventType<>(ANY, "HYPERSURF_SET_YSCALE_GUI"); // Double public static final EventType SET_SURFSCALE_GUI = - new EventType<>(ANY, "HYPERSURF_SET_SURFSCALE_GUI"); // Double + 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 + new EventType<>(ANY, "HYPERSURF_SURFACE_RENDER_CHANGED"); // Boolean public static final EventType DRAW_MODE_CHANGED = - new EventType<>(ANY, "HYPERSURF_DRAW_MODE_CHANGED"); // DrawMode + new EventType<>(ANY, "HYPERSURF_DRAW_MODE_CHANGED"); // DrawMode public static final EventType CULL_FACE_CHANGED = - new EventType<>(ANY, "HYPERSURF_CULL_FACE_CHANGED"); // CullFace + 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 + 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 + 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 + new EventType<>(ANY, "HYPERSURF_SMOOTHING_ENABLE_CHANGED"); // Boolean public static final EventType SMOOTHING_METHOD_CHANGED = - new EventType<>(ANY, "HYPERSURF_SMOOTHING_METHOD_CHANGED"); // SurfaceUtils.Smoothing + new EventType<>(ANY, "HYPERSURF_SMOOTHING_METHOD_CHANGED"); // SurfaceUtils.Smoothing public static final EventType SMOOTHING_RADIUS_CHANGED = - new EventType<>(ANY, "HYPERSURF_SMOOTHING_RADIUS_CHANGED"); // Integer + new EventType<>(ANY, "HYPERSURF_SMOOTHING_RADIUS_CHANGED"); // Integer public static final EventType GAUSSIAN_SIGMA_CHANGED = - new EventType<>(ANY, "HYPERSURF_GAUSSIAN_SIGMA_CHANGED"); // Double + 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 + new EventType<>(ANY, "HYPERSURF_TONEMAP_ENABLE_CHANGED"); // Boolean public static final EventType TONEMAP_OPERATOR_CHANGED = - new EventType<>(ANY, "HYPERSURF_TONEMAP_OPERATOR_CHANGED"); // SurfaceUtils.ToneMap + new EventType<>(ANY, "HYPERSURF_TONEMAP_OPERATOR_CHANGED"); // SurfaceUtils.ToneMap public static final EventType TONEMAP_PARAM_CHANGED = - new EventType<>(ANY, "HYPERSURF_TONEMAP_PARAM_CHANGED"); // Double + 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 + 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 + new EventType<>(ANY, "HYPERSURF_AMBIENT_ENABLED_CHANGED"); // Boolean public static final EventType AMBIENT_COLOR_CHANGED = - new EventType<>(ANY, "HYPERSURF_AMBIENT_COLOR_CHANGED"); // Color + new EventType<>(ANY, "HYPERSURF_AMBIENT_COLOR_CHANGED"); // Color public static final EventType POINT_ENABLED_CHANGED = - new EventType<>(ANY, "HYPERSURF_POINT_ENABLED_CHANGED"); // Boolean + new EventType<>(ANY, "HYPERSURF_POINT_ENABLED_CHANGED"); // Boolean public static final EventType SPECULAR_COLOR_CHANGED = - new EventType<>(ANY, "HYPERSURF_SPECULAR_COLOR_CHANGED"); // Color + 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 + 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 + 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 + new EventType<>(ANY, "HYPERSURF_DATA_MARKERS_ENABLE_CHANGED"); // Boolean public static final EventType CROSSHAIRS_ENABLE_CHANGED = - new EventType<>(ANY, "HYPERSURF_CROSSHAIRS_ENABLE_CHANGED"); // Boolean + new EventType<>(ANY, "HYPERSURF_CROSSHAIRS_ENABLE_CHANGED"); // Boolean // --- Commands / actions --- public static final EventType RESET_VIEW = - new EventType<>(ANY, "HYPERSURF_RESET_VIEW"); // no payload + new EventType<>(ANY, "HYPERSURF_RESET_VIEW"); // no payload public static final EventType UPDATE_RENDER = - new EventType<>(ANY, "HYPERSURF_UPDATE_RENDER"); // no payload + new EventType<>(ANY, "HYPERSURF_UPDATE_RENDER"); // no payload public static final EventType CLEAR_DATA = - new EventType<>(ANY, "HYPERSURF_CLEAR_DATA"); // no payload + new EventType<>(ANY, "HYPERSURF_CLEAR_DATA"); // no payload public static final EventType UNROLL_REQUESTED = - new EventType<>(ANY, "HYPERSURF_UNROLL_REQUESTED"); // no payload + new EventType<>(ANY, "HYPERSURF_UNROLL_REQUESTED"); // no payload public static final EventType COMPUTE_VECTOR_DISTANCES = - new EventType<>(ANY, "HYPERSURF_COMPUTE_VECTOR_DISTANCES"); // no payload + new EventType<>(ANY, "HYPERSURF_COMPUTE_VECTOR_DISTANCES"); // no payload public static final EventType COMPUTE_COLLECTION_DIFF = - new EventType<>(ANY, "HYPERSURF_COMPUTE_COLLECTION_DIFF"); // FeatureCollection + new EventType<>(ANY, "HYPERSURF_COMPUTE_COLLECTION_DIFF"); // FeatureCollection public static final EventType COMPUTE_COSINE_DISTANCE = - new EventType<>(ANY, "HYPERSURF_COMPUTE_COSINE_DISTANCE"); // FeatureCollection + new EventType<>(ANY, "HYPERSURF_COMPUTE_COSINE_DISTANCE"); // FeatureCollection // --- Constructors (matching your style) --- - public HypersurfaceEvent(EventType type) { super(type); } + public HypersurfaceEvent(EventType type) { + super(type); + } public HypersurfaceEvent(EventType type, Object payload) { this(type); @@ -124,46 +126,157 @@ public HypersurfaceEvent(Object source, EventTarget target, EventType type) { return new HypersurfaceEvent(type); } - public static HypersurfaceEvent of(EventType type, Object payload) { return new HypersurfaceEvent(type, payload); } + 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); } + 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); } + 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 index fc56a2c7..36045c65 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceGridEvent.java @@ -1,13 +1,14 @@ package edu.jhuapl.trinity.javafx.events; -import java.util.List; 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. * @@ -29,11 +30,11 @@ public class HypersurfaceGridEvent extends Event { private final String label; public HypersurfaceGridEvent( - EventType eventType, - List> zGrid, - double[] xCenters, - double[] yCenters, - String label + EventType eventType, + List> zGrid, + double[] xCenters, + double[] yCenters, + String label ) { super(eventType); this.zGrid = zGrid; @@ -43,13 +44,13 @@ public HypersurfaceGridEvent( } public HypersurfaceGridEvent( - Object source, - EventTarget target, - EventType eventType, - List> zGrid, - double[] xCenters, - double[] yCenters, - String label + Object source, + EventTarget target, + EventType eventType, + List> zGrid, + double[] xCenters, + double[] yCenters, + String label ) { super(source, target, eventType); this.zGrid = zGrid; @@ -58,8 +59,19 @@ public HypersurfaceGridEvent( this.label = label; } - public List> getZGrid() { return zGrid; } - public double[] getXCenters() { return xCenters; } - public double[] getYCenters() { return yCenters; } - public String getLabel() { return 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 881962c4..73f62c15 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/handlers/FeatureVectorEventHandler.java @@ -52,17 +52,18 @@ public FeatureVectorEventHandler() { 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) { + if (null != label) { fv.setLabel(label); try { fv.setText(cyberVector.asJSON(label)); } catch (JsonProcessingException ex) { - + } - } + } return fv; } @@ -97,12 +98,13 @@ private void addNewFeatureVector(FeatureVector featureVector) { 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) { + + for (CyberReport cyberReport : cyberReports) { HashMap metaData = new HashMap(); metaData.put(CyberReport.GROUNDTRUTH, cyberReport.getGroundTruth()); metaData.put(CyberReport.ADJACENTNETWORK, cyberReport.getAdjacentNetwork()); @@ -113,12 +115,12 @@ public void handleCyberReport(FeatureVectorEvent event) { 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); @@ -128,7 +130,7 @@ public void handleCyberReport(FeatureVectorEvent event) { sintelgtFV.getMetaData().putAll(metaData); //addNewFeatureVector(sintelgtFV); features.add(sintelgtFV); - + FeatureVector deltaFV = cyberToFeatureVector(CyberReport.DELTA, cyberReport.getDelta()); deltaFV.getMetaData().putAll(metaData); //addNewFeatureVector(deltaFV); @@ -140,7 +142,8 @@ public void handleCyberReport(FeatureVectorEvent event) { 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)) { @@ -231,27 +234,27 @@ public void handleFeatureCollectionEvent(FeatureVectorEvent event) { updateDimensionLabels(featureCollection.getDimensionLabels()); } } - + 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)); + 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); - } + 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); + } } public void handleLabelConfigEvent(FeatureVectorEvent 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 8c0c1e88..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,6 +37,7 @@ 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; @@ -45,13 +46,7 @@ import org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper; import org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.SectionType; import org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.TextureType; -import static org.fxyz3d.scene.paint.Palette.DEFAULT_COLOR_PALETTE; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_COLORS; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_DENSITY_FUNCTION; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_DIFFUSE_COLOR; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_PATTERN; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_PATTERN_SCALE; -import static org.fxyz3d.shapes.primitives.helper.TriangleMeshHelper.DEFAULT_UNIDIM_FUNCTION; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -59,7 +54,6 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; - /** * TexturedMesh is a base class that provides support for different mesh implementations * taking into account four different kind of textures @@ -282,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() { @@ -303,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() { @@ -323,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() { @@ -345,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() { @@ -353,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()); @@ -385,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() { @@ -406,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 e6c98d5b..258087c7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -455,7 +455,7 @@ private TriangleMesh createSmoothMesh(Function vertFunction, int } int[] faceSmoothingGroups = new int[listFaces.size()]; // 0 == hard edges Arrays.fill(faceSmoothingGroups, 1); // 1: soft edges, all the faces in same surface - smoothingGroups = faceSmoothingGroups; + smoothingGroups = faceSmoothingGroups; return createMesh(); } @@ -499,7 +499,7 @@ private TriangleMesh createPlotMesh(Function function2D, double } int[] faceSmoothingGroups = new int[listFaces.size()]; // 0 == hard edges Arrays.fill(faceSmoothingGroups, 1); // 1: soft edges, all the faces in same surface - smoothingGroups = faceSmoothingGroups; + 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 61f69e9d..3d74ca3a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java @@ -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 6de5225b..01992eaf 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -58,10 +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.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; @@ -74,6 +76,7 @@ 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.TitledPane; import javafx.scene.effect.Glow; @@ -129,9 +132,6 @@ import java.util.Optional; import java.util.Random; import java.util.function.Function; -import javafx.event.Event; -import javafx.scene.Parent; -import javafx.scene.control.Menu; /** * @author Sean Phillips @@ -268,15 +268,15 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private SurfaceUtils.Interpolation interpMode = SurfaceUtils.Interpolation.NEAREST; private boolean toneEnabled = false; private SurfaceUtils.ToneMap toneOperator = SurfaceUtils.ToneMap.NONE; - private double toneParam = 2.0; + 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); + .withNodeRadius(20.0) + .withEdgeWidth(8.0f) + .withPositionScalar(1.0); // Graph visual style state (synced with GraphStyleControlsView) private GraphStyleParams styleParams = new GraphStyleParams(); @@ -347,7 +347,7 @@ public Hypersurface3DPane(Scene scene) { sceneRoot.getChildren().add(graphLayer); graphLayer.setVisible(graphVisible); // Sync controls with current visibility on startup - fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); + fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); subScene.setCamera(camera); pointLight = new PointLight(Color.WHITE); cameraTransform.getChildren().add(pointLight); @@ -389,34 +389,47 @@ public Hypersurface3DPane(Scene scene) { 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.NUMPAD9 || (keycode == KeyCode.DIGIT8 && event.isControlDown())) + cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() - change); 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.NUMPAD6 || (keycode == KeyCode.DIGIT9 && event.isControlDown())) + cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() - change); 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); + if (keycode == KeyCode.NUMPAD3 || (keycode == KeyCode.DIGIT0 && event.isControlDown())) + cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() - change); if (keycode == KeyCode.COMMA) { if (xFactorIndex > 0 && yFactorIndex > 0 && zFactorIndex > 0) { - xFactorIndex -= 1; yFactorIndex -= 1; zFactorIndex -= 1; + xFactorIndex -= 1; + yFactorIndex -= 1; + zFactorIndex -= 1; Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); boolean redraw = true; - if (redraw) { updateView(false); notifyIndexChange(); } + if (redraw) { + updateView(false); + notifyIndexChange(); + } updateLabels(); } } if (keycode == KeyCode.PERIOD) { - int featureSize = featureVectors.isEmpty()? factorMaxIndex : 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) { - xFactorIndex += 1; yFactorIndex += 1; zFactorIndex += 1; + xFactorIndex += 1; + yFactorIndex += 1; + zFactorIndex += 1; Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); boolean redraw = true; - if (redraw) { updateView(false); notifyIndexChange(); } + if (redraw) { + updateView(false); + notifyIndexChange(); + } updateLabels(); } else { scene.getRoot().fireEvent(new CommandTerminalEvent("Feature Index Max Reached: (" @@ -427,8 +440,14 @@ public Hypersurface3DPane(Scene scene) { if (keycode == KeyCode.Y) surfPlot.scaleHeight(1.1f); if (keycode == KeyCode.H) surfPlot.scaleHeight(0.9f); - if (keycode == KeyCode.I) { double tz = event.isShiftDown()? 50:5; glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz);} - if (keycode == KeyCode.K) { double tz = event.isShiftDown()? 50:5; glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() - tz);} + if (keycode == KeyCode.I) { + double tz = event.isShiftDown() ? 50 : 5; + glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz); + } + if (keycode == KeyCode.K) { + double tz = event.isShiftDown() ? 50 : 5; + glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() - tz); + } updateLabels(); updateCalloutHeadPoints(subScene); @@ -450,7 +469,8 @@ public Hypersurface3DPane(Scene scene) { e.consume(); }); subScene.setOnScroll((ScrollEvent event) -> { - double modifier = 50.0; double modifierFactor = 0.1; + double modifier = 50.0; + double modifierFactor = 0.1; if (event.isControlDown()) modifier = 1; if (event.isShiftDown()) modifier = 100.0; double z = camera.getTranslateZ(); @@ -467,13 +487,13 @@ public Hypersurface3DPane(Scene scene) { 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(); @@ -491,7 +511,10 @@ public Hypersurface3DPane(Scene scene) { File file = fileChooser.showSaveDialog(null); if (file != null) { WritableImage image = this.snapshot(new SnapshotParameters(), null); - try { ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); } catch (IOException ioe) { } + try { + ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); + } catch (IOException ioe) { + } } }); MenuItem unrollHyperspaceItem = new MenuItem("Unroll Hyperspace Data"); @@ -512,7 +535,9 @@ public Hypersurface3DPane(Scene scene) { try { fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); computeSurfaceDifference(fcf.featureCollection); - } catch (IOException ex) { LOG.error(null, ex); } + } catch (IOException ex) { + LOG.error(null, ex); + } } }); MenuItem cosineSimilarityItem = new MenuItem("Feature Collection Cosine Distance"); @@ -527,7 +552,9 @@ public Hypersurface3DPane(Scene scene) { try { fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); computeCosineDistance(fcf.featureCollection); - } catch (IOException ex) { LOG.error(null, ex); } + } catch (IOException ex) { + LOG.error(null, ex); + } } }); @@ -587,11 +614,14 @@ public Hypersurface3DPane(Scene scene) { MenuItem resetViewItem = new MenuItem("Reset View"); resetViewItem.setOnAction(e -> resetView(1000, false)); - ContextMenu cm = new ContextMenu(showControlsItem, + ContextMenu cm = new ContextMenu(showControlsItem, copyAsImageItem, saveSnapshotItem, unrollHyperspaceItem, analysisMenu, enableHoverItem, surfaceChartsItem, showDataMarkersItem, enableCrosshairsItem, updateAllItem, clearDataItem, resetViewItem); - cm.setAutoFix(true); cm.setAutoHide(true); cm.setHideOnEscape(true); cm.setOpacity(0.85); + cm.setAutoFix(true); + cm.setAutoHide(true); + cm.setHideOnEscape(true); + cm.setOpacity(0.85); subScene.setOnMouseClicked((MouseEvent e) -> { if (e.getButton() == MouseButton.SECONDARY) { @@ -601,42 +631,42 @@ public Hypersurface3DPane(Scene scene) { } }); // 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); -}); + 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); + 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); - // 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)); + }); - // 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; @@ -656,15 +686,18 @@ public Hypersurface3DPane(Scene scene) { loadSurf3D(); this.scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { - Color color = (Color) e.object; subScene.setFill(color); + Color color = (Color) e.object; + subScene.setFill(color); }); this.scene.addEventHandler(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, e -> { skybox.setVisible((Boolean) e.object); }); this.scene.addEventHandler(ImageEvent.NEW_TEXTURE_SURFACE, e -> { Image image = (Image) e.object; - int x1 = 0; int y1 = 0; - int x2 = (int) image.getWidth(); int y2 = (int) image.getHeight(); + int x1 = 0; + int y1 = 0; + 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, @@ -707,15 +740,33 @@ public Hypersurface3DPane(Scene scene) { if (newFactorMaxIndex < factorMaxIndex) { factorMaxIndex = newFactorMaxIndex; boolean update = false; - if (xFactorIndex > factorMaxIndex) { xFactorIndex = factorMaxIndex; update = true; } - if (yFactorIndex > factorMaxIndex) { yFactorIndex = factorMaxIndex; update = true; } - if (zFactorIndex > factorMaxIndex) { zFactorIndex = factorMaxIndex; update = true; } - if (update) { updateView(true); notifyIndexChange(); } + if (xFactorIndex > factorMaxIndex) { + xFactorIndex = factorMaxIndex; + update = true; + } + if (yFactorIndex > factorMaxIndex) { + yFactorIndex = factorMaxIndex; + update = true; + } + if (zFactorIndex > factorMaxIndex) { + zFactorIndex = factorMaxIndex; + update = true; + } + if (update) { + updateView(true); + notifyIndexChange(); + } } 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(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); scene.addEventHandler(HyperspaceEvent.REFRESH_RATE_GUI, e -> hypersurfaceRefreshRate = (long) e.object); @@ -731,84 +782,98 @@ public Hypersurface3DPane(Scene scene) { updateTheMesh(); }); AnimationTimer surfUpdateAnimationTimer = new AnimationTimer() { - long sleepNs = 0; long prevTime = 0; long NANOS_IN_MILLI = 1_000_000; - @Override public void handle(long now) { + long sleepNs = 0; + long prevTime = 0; + long NANOS_IN_MILLI = 1_000_000; + + @Override + public void handle(long now) { sleepNs = hypersurfaceRefreshRate * NANOS_IN_MILLI; - if ((now - prevTime) < sleepNs) return; prevTime = now; + if ((now - prevTime) < sleepNs) return; + prevTime = now; long startTime; - if (computeRandos) { generateRandos(xWidth, zWidth, yScale); } - if (animated || isDirty) { startTime = System.nanoTime(); updateTheMesh(); LOG.info("updateTheMesh(): {}", Utils.totalTimeString(startTime)); } + if (computeRandos) { + generateRandos(xWidth, zWidth, yScale); + } + if (animated || isDirty) { + startTime = System.nanoTime(); + updateTheMesh(); + LOG.info("updateTheMesh(): {}", Utils.totalTimeString(startTime)); + } } }; 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 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); + // 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); + 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); + } 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)); -} + 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(); @@ -821,8 +886,8 @@ public void computeCosineDistance(FeatureCollection collection) { } scene.getRoot().fireEvent(new FactorAnalysisEvent( FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Feature Collection Cosine Similarity", - cosineDistancesGrid.toArray(Double[]::new))); - System.out.println(cosineDistancesGrid.toString()); + cosineDistancesGrid.toArray(Double[]::new))); + LOG.info("{}", cosineDistancesGrid); } private void applySurfaceGridToHypersurface(List> grid) { @@ -911,13 +976,20 @@ public void updateCalloutHeadPoints(SubScene subScene) { public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubScene subScene) { ImageView iv = loadImageView(featureVector, featureVector.isBBoxValid()); - iv.setPreserveRatio(true); iv.setFitWidth(CHIP_FIT_WIDTH); iv.setFitHeight(CHIP_FIT_WIDTH); - TitledPane imageTP = new TitledPane(); imageTP.setContent(iv); imageTP.setText("Imagery"); + 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 (Map.Entry entry : featureVector.getMetaData().entrySet()) sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); + 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"); + 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) @@ -929,7 +1001,9 @@ public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubSc infoCallout.setPickOnBounds(false); infoCallout.setManaged(false); addCallout(infoCallout, shape3D); - infoCallout.play().setOnFinished(eh -> { if (null == featureVector.getImageURL() || featureVector.getImageURL().isBlank()) imageTP.setExpanded(false); }); + infoCallout.play().setOnFinished(eh -> { + if (null == featureVector.getImageURL() || featureVector.getImageURL().isBlank()) imageTP.setExpanded(false); + }); return infoCallout; } @@ -1005,13 +1079,29 @@ public void updatePaintMesh() { texCoords[index] = currX; texCoords[index + 1] = currZ; - p00 = z * numDivX + x; p01 = p00 + 1; p10 = p00 + numDivX; p11 = p10 + 1; - tc00 = z * numDivX + x; tc01 = tc00 + 1; tc10 = tc00 + numDivX; tc11 = tc10 + 1; + p00 = z * numDivX + x; + p01 = p00 + 1; + p10 = p00 + numDivX; + p11 = p10 + 1; + tc00 = z * numDivX + x; + tc01 = tc00 + 1; + tc10 = tc00 + numDivX; + tc11 = tc10 + 1; index = (z * subDivX * faceSize + (x * faceSize)) * 2; - faces[index + 0] = p00; faces[index + 1] = tc00; faces[index + 2] = p10; faces[index + 3] = tc10; faces[index + 4] = p11; faces[index + 5] = tc11; + faces[index + 0] = p00; + faces[index + 1] = tc00; + faces[index + 2] = p10; + faces[index + 3] = tc10; + faces[index + 4] = p11; + faces[index + 5] = tc11; index += faceSize; - faces[index + 0] = p11; faces[index + 1] = tc11; faces[index + 2] = p01; faces[index + 3] = tc01; faces[index + 4] = p00; faces[index + 5] = tc00; + faces[index + 0] = p11; + faces[index + 1] = tc11; + faces[index + 2] = p01; + faces[index + 3] = tc01; + faces[index + 4] = p00; + faces[index + 5] = tc00; diffusePaintImage.getPixelWriter().setColor(x, z, Color.TRANSPARENT); } } @@ -1048,7 +1138,7 @@ private void setupSkyBox() { 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); ambientLight.getScope().addAll(skybox); skybox.setVisible(false); @@ -1075,19 +1165,28 @@ public void intro(double milliseconds) { JavaFX3DUtils.zoomTransition(milliseconds, camera, cameraDistance); } - public void outtro(double milliseconds) { JavaFX3DUtils.zoomTransition(milliseconds, camera, DEFAULT_INTRO_DISTANCE); } + public void outtro(double milliseconds) { + JavaFX3DUtils.zoomTransition(milliseconds, camera, DEFAULT_INTRO_DISTANCE); + } - public void updateAll() { Platform.runLater(() -> updateView(true)); } + public void updateAll() { + Platform.runLater(() -> updateView(true)); + } private void mouseDragCamera(MouseEvent me) { - mouseOldX = mousePosX; mouseOldY = mousePosY; - mousePosX = me.getSceneX(); mousePosY = me.getSceneY(); - mouseDeltaX = (mousePosX - mouseOldX); mouseDeltaY = (mousePosY - mouseOldY); - double modifier = 1.0; double modifierFactor = 0.1; + mouseOldX = mousePosX; + mouseOldY = mousePosY; + mousePosX = me.getSceneX(); + mousePosY = me.getSceneY(); + mouseDeltaX = (mousePosX - mouseOldX); + mouseDeltaY = (mousePosY - mouseOldY); + double modifier = 1.0; + double modifierFactor = 0.1; if (me.isControlDown()) modifier = 0.1; if (me.isShiftDown()) modifier = 25.0; if (me.isPrimaryButtonDown()) { - if (me.isAltDown()) cameraTransform.rz.setAngle(((cameraTransform.rz.getAngle() + mouseDeltaX * 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); @@ -1103,7 +1202,8 @@ private void mouseDragCamera(MouseEvent me) { private void updateLabels() { shape3DToLabel.forEach((shape3D, node) -> { Point2D p2Ditty = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, 5); - double x = p2Ditty.getX(); double y = p2Ditty.getY() - 25; + double x = p2Ditty.getX(); + double y = p2Ditty.getY() - 25; node.getTransforms().setAll(new Translate(x, y)); }); } @@ -1121,21 +1221,26 @@ private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { } else if (null != featureVector.getImageURL() && !featureVector.getImageURL().isBlank()) { iv = new ImageView(ResourceUtils.loadImageFile(imageryBasePath + featureVector.getImageURL())); } else iv = new ImageView(ResourceUtils.loadIconFile("noimage")); - } catch (Exception ex) { iv = new ImageView(ResourceUtils.loadIconFile("noimage")); } + } catch (Exception ex) { + iv = new ImageView(ResourceUtils.loadIconFile("noimage")); + } return iv; } public void updateView(boolean forcePNodeUpdate) { if (null != surfPlot) { Platform.runLater(() -> { - if (heightChanged) { heightChanged = false; } + if (heightChanged) { + heightChanged = false; + } 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); @@ -1144,33 +1249,33 @@ private void generateRandos(int xWidth, int zWidth, float yScale) { } } -private static double frac(double v) { - v = v - Math.floor(v); - return (v < 0) ? v + 1.0 : v; -} + private static double frac(double v) { + v = v - Math.floor(v); + return (v < 0) ? v + 1.0 : v; + } + + private Number vertToHeight(Vert3D p) { + if (dataGrid == null) return 0.0; -private Number vertToHeight(Vert3D p) { - 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); + 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); } - case NEAREST: - default: - return lookupPoint(p); } -} private Number lookupPoint(Vert3D p) { if (p.yIndex >= dataGrid.size() || p.xIndex >= dataGrid.get(0).size()) return 0.0; @@ -1199,7 +1304,8 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, return f12 + (f34 - f12) * yratio; } - int vert; Point3D vertP3D; + int vert; + Point3D vertP3D; private void loadSurf3D() { LOG.info("Rendering Hypersurface Mesh..."); @@ -1227,19 +1333,29 @@ private void loadSurf3D() { if (row < featureVectors.size()) updateCalloutByFeatureVector(anchorCallout, featureVectors.get(row)); setSpheroidAnchor(false, row); } - if (crosshairsEnabled) { paintSingleColor(Color.TRANSPARENT); illuminateCrosshair(vertP3D); } + if (crosshairsEnabled) { + paintSingleColor(Color.TRANSPARENT); + illuminateCrosshair(vertP3D); + } if (surfaceChartsEnabled) { - List xlist = dataGrid.get(Math.max(0, Math.min(row, dataGrid.size()-1))); + List xlist = dataGrid.get(Math.max(0, Math.min(row, dataGrid.size() - 1))); Double[] xRay = xlist.toArray(Double[]::new); Double[] zRay = new Double[dataGrid.size()]; - 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))); + 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(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(); text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); - double maxZ = Arrays.stream(zRay).max(Double::compare).get(); 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); + 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(); + text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); + double maxZ = Arrays.stream(zRay).max(Double::compare).get(); + 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)); } @@ -1248,7 +1364,8 @@ private void loadSurf3D() { }); Glow glow = new Glow(0.8); - double poleHeight = 60; double radius = 3; + double poleHeight = 60; + double radius = 3; glowLineBox = new Box(xWidth * surfScale, poleHeight, radius); glowLineBox.setMaterial(new PhongMaterial(Color.ALICEBLUE.deriveColor(1, 1, 1, 0.2))); glowLineBox.setDrawMode(DrawMode.FILL); @@ -1261,8 +1378,10 @@ private void loadSurf3D() { PhongMaterial eastPoleMaterial = new PhongMaterial(Color.STEELBLUE); PhongMaterial westPoleMaterial = new PhongMaterial(Color.STEELBLUE); PhongMaterial knobMaterial = new PhongMaterial(Color.ALICEBLUE); - eastPole.setMaterial(eastPoleMaterial); westPole.setMaterial(westPoleMaterial); - eastKnob.setMaterial(knobMaterial); westKnob.setMaterial(knobMaterial); + eastPole.setMaterial(eastPoleMaterial); + westPole.setMaterial(westPoleMaterial); + eastKnob.setMaterial(knobMaterial); + westKnob.setMaterial(knobMaterial); eastPole.setTranslateX((xWidth * surfScale) / 2.0); westPole.setTranslateX(-(xWidth * surfScale) / 2.0); eastKnob.setTranslateX((xWidth * surfScale) / 2.0); @@ -1274,19 +1393,26 @@ private void loadSurf3D() { eastKnob.translateZProperty().bind(glowLineBox.translateZProperty()); westKnob.translateZProperty().bind(glowLineBox.translateZProperty()); - 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)); + 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)); labelGroup.getChildren().addAll(eastLabel, westLabel); - shape3DToLabel.put(eastKnob, eastLabel); shape3DToLabel.put(westKnob, westLabel); + 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(); + if (anchorIndex < 0) anchorIndex = 0; + else if (anchorIndex > dataGrid.size()) anchorIndex = dataGrid.size(); glowLineBox.setTranslateZ((anchorIndex * surfScale) - ((zWidth * surfScale) / 2.0)); setSpheroidAnchor(true, anchorIndex); eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + xWidth); westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); - updateLabels(); updateCalloutHeadPoints(subScene); + updateLabels(); + updateCalloutHeadPoints(subScene); }); scene.addEventHandler(FeatureVectorEvent.SELECT_FEATURE_VECTOR, e -> { if (null != anchorCallout) { @@ -1315,8 +1441,9 @@ private void loadSurf3D() { }); }); } - /** - * Fires HypersurfaceEvent GUI sync events for all core geometry controls + + /** + * Fires HypersurfaceEvent GUI sync events for all core geometry controls * (xWidth, zWidth, yScale, surfScale) to synchronize GUI controls with model state. */ public void syncGuiControls() { @@ -1328,8 +1455,8 @@ public void syncGuiControls() { fireOnRoot(HypersurfaceEvent.setSurfScaleGUI(surfScale)); } - /** - * Helper to fire on the JavaFX root, or self as fallback (copy this if not already present) + /** + * 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) { @@ -1338,7 +1465,7 @@ private void fireOnRoot(Event evt) { this.fireEvent(evt); } } - + /** * Sets up event handlers for HypersurfaceEvents sent from HypersurfaceControlsPane. * Updates all rendering state and triggers updates as needed. @@ -1504,7 +1631,7 @@ private void wireEventHandlers() { scene.getRoot().fireEvent(new CommandTerminalEvent( "Edge hover: " + a.map(Object::toString).orElse("?") + " → " + - b.map(Object::toString).orElse("?") + " | weight = " + w, + b.map(Object::toString).orElse("?") + " | weight = " + w, new Font("Consolas", 16), Color.ALICEBLUE )); }); @@ -1516,15 +1643,16 @@ private void wireEventHandlers() { scene.getRoot().fireEvent(new FactorAnalysisEvent( FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Graph Edge Weight (click): " + ge.getStartID() + " → " + ge.getEndID(), - new Double[]{ w } + 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) { callout.setMainTitleText(featureVector.getLabel()); callout.mainTitleTextNode.setText(callout.getMainTitleText()); @@ -1534,7 +1662,8 @@ public void updateCalloutByFeatureVector(Callout callout, FeatureVector featureV Image image = iv.getImage(); ((ImageView) tp0.getContent()).setImage(image); StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : featureVector.getMetaData().entrySet()) sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); + 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()); } @@ -1555,7 +1684,9 @@ private void addDebugPoint(Point3D point3D) { } public void clearAll() { - xFactorIndex = 0; yFactorIndex = 1; zFactorIndex = 2; + xFactorIndex = 0; + yFactorIndex = 1; + zFactorIndex = 2; Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); @@ -1571,7 +1702,9 @@ public void clearAll() { originalGrid.clear(); } - public void showAll() { updateView(true); } + public void showAll() { + updateView(true); + } public void hideFA3D() { Timeline timeline = new Timeline( @@ -1596,19 +1729,64 @@ public void showFA3D() { timeline.playFromStart(); } - @Override public void setFeatureCollection(FeatureCollection fc) { featureVectors = fc.getFeatures(); } + @Override + public void setFeatureCollection(FeatureCollection fc) { + featureVectors = fc.getFeatures(); + } public void findClusters(ManifoldEvent.ProjectionConfig pc) { if (pc.dataSource != ManifoldEvent.ProjectionConfig.DATA_SOURCE.HYPERSURFACE) return; double[][] observations = FeatureCollection.toData(featureVectors); double projectionScalar = 1000.0; switch (pc.clusterMethod) { - case DBSCAN -> { DBSCANClusterTask t = new DBSCANClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } - case HDDBSCAN -> { HDDBSCANClusterTask t = new HDDBSCANClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } - case KMEANS -> { KMeansClusterTask t = new KMeansClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } - case KMEDIODS -> { KMediodsClusterTask t = new KMediodsClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } - case EX_MAX -> { ExMaxClusterTask t = new ExMaxClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } - case AFFINITY -> { AffinityClusterTask t = new AffinityClusterTask(scene, camera, projectionScalar, observations, pc); if (!t.isCancelledByUser()) { Thread th = new Thread(t); th.setDaemon(true); th.start(); } } + case DBSCAN -> { + DBSCANClusterTask t = new DBSCANClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case HDDBSCAN -> { + HDDBSCANClusterTask t = new HDDBSCANClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case KMEANS -> { + KMeansClusterTask t = new KMeansClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case KMEDIODS -> { + KMediodsClusterTask t = new KMediodsClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case EX_MAX -> { + ExMaxClusterTask t = new ExMaxClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case AFFINITY -> { + AffinityClusterTask t = new AffinityClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } } } @@ -1652,14 +1830,30 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection updateLabels(); } - @Override public void addSemanticMap(SemanticMap semanticMap) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public SemanticMap getSemanticMap(long id) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public void locateSemanticMap(SemanticMap semanticMap) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public void clearSemanticMaps() { throw new UnsupportedOperationException("Not supported yet."); } + @Override + public void addSemanticMap(SemanticMap semanticMap) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public SemanticMap getSemanticMap(long id) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void locateSemanticMap(SemanticMap semanticMap) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void clearSemanticMaps() { + throw new UnsupportedOperationException("Not supported yet."); + } @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()); @@ -1669,23 +1863,72 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl zWidth = dataGrid.size(); xWidth = dataGrid.get(0).size(); syncGuiControls(); - originalGrid = deepCopyGrid(dataGrid); - rebuildProcessedGridAndRefresh(); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); featureVectors = featureCollection.getFeatures(); } - @Override public void addFeatureVector(FeatureVector featureVector) { featureVectors.add(featureVector); dataGrid.add(featureVector.getData()); originalGrid = deepCopyGrid(dataGrid); rebuildProcessedGridAndRefresh(); } - @Override public void locateFeatureVector(FeatureVector featureVector) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); originalGrid.clear(); } - @Override public List getAllFeatureVectors() { if (null == featureVectors) return Collections.EMPTY_LIST; return featureVectors; } - @Override public void setColorByID(String iGotID, Color color) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public void setColorByIndex(int i, Color color) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public void setVisibleByIndex(int i, boolean b) { throw new UnsupportedOperationException("Not supported yet."); } - @Override public void refresh() { refresh(true); } - @Override public void refresh(boolean forceNodeUpdate) { updateTheMesh(); } - @Override public void setDimensionLabels(List labelStrings) { featureLabels = labelStrings; } - @Override public void setSpheroidAnchor(boolean animate, int index) { double z = index * surfScale; } + @Override + public void addFeatureVector(FeatureVector featureVector) { + featureVectors.add(featureVector); + dataGrid.add(featureVector.getData()); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); + } + + @Override + public void locateFeatureVector(FeatureVector featureVector) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void clearFeatureVectors() { + featureVectors.clear(); + dataGrid.clear(); + originalGrid.clear(); + } + + @Override + public List getAllFeatureVectors() { + if (null == featureVectors) return Collections.EMPTY_LIST; + return featureVectors; + } + + @Override + public void setColorByID(String iGotID, Color color) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void setColorByIndex(int i, Color color) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void setVisibleByIndex(int i, boolean b) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void refresh() { + refresh(true); + } + + @Override + public void refresh(boolean forceNodeUpdate) { + updateTheMesh(); + } + + @Override + public void setDimensionLabels(List labelStrings) { + featureLabels = labelStrings; + } + + @Override + public void setSpheroidAnchor(boolean animate, int index) { + double z = index * surfScale; + } private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { lastImage = image; @@ -1694,8 +1937,11 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { 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(); + Color color = null; + int rgb, r, g, b = 0; + double dataValue = 0; + if (null == dataGrid) dataGrid = new ArrayList<>(rows); + else dataGrid.clear(); featureVectors.clear(); for (int rowIndex = y1; rowIndex < y2; rowIndex++) { List currentDataRow = new ArrayList<>(); @@ -1705,7 +1951,9 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { FeatureVector fv = FeatureVector.EMPTY_FEATURE_VECTOR(color.toString(), 3); 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; + r = (rgb >> 16) & 0xFF; + g = (rgb >> 8) & 0xFF; + b = rgb & 0xFF; dataValue = (((r + g + b) / 3.0) / 255.0); fv.getData().set(2, dataValue); featureVectors.add(fv); @@ -1716,10 +1964,11 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { Utils.printTotalTime(startTime); LOG.info("Injecting Mesh into Hypersurface... "); startTime = System.nanoTime(); - zWidth = rows; xWidth = columns; + zWidth = rows; + xWidth = columns; syncGuiControls(); - originalGrid = deepCopyGrid(dataGrid); - rebuildProcessedGridAndRefresh(); + originalGrid = deepCopyGrid(dataGrid); + rebuildProcessedGridAndRefresh(); xSphere.setTranslateX((xWidth * surfScale) / 2.0); zSphere.setTranslateZ((zWidth * surfScale) / 2.0); Utils.printTotalTime(startTime); @@ -1733,7 +1982,8 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { try { WritableImage wi = ResourceUtils.loadImageFile(imageryBasePath + shapleyCollection.getSourceInput()); if (null != wi) { - int x2 = (int) wi.getWidth(); int y2 = (int) wi.getHeight(); + int x2 = (int) wi.getWidth(); + int y2 = (int) wi.getHeight(); tessellateImage(wi, 0, 0, x2, y2); lastImage = wi; LOG.info("injecting Shapley function values into Vertices... "); @@ -1750,11 +2000,20 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { default -> surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); } } - } catch (IOException ex) { LOG.error(null, ex); } + } catch (IOException ex) { + LOG.error(null, ex); + } + } + + @Override + public void addShapleyVector(ShapleyVector shapleyVector) { + shapleyVectors.add(shapleyVector); } - @Override public void addShapleyVector(ShapleyVector shapleyVector) { shapleyVectors.add(shapleyVector); } - @Override public void clearShapleyVectors() { shapleyVectors.clear(); } + @Override + public void clearShapleyVectors() { + shapleyVectors.clear(); + } // ================= helpers for processing pipeline ================= private static List> deepCopyGrid(List> src) { @@ -1762,6 +2021,7 @@ private static List> deepCopyGrid(List> src) { for (List row : src) out.add(new ArrayList<>(row)); return out; } + private void rebuildProcessedGridAndRefresh() { if (originalGrid == null || originalGrid.isEmpty()) return; List> g = deepCopyGrid(originalGrid); @@ -1771,12 +2031,18 @@ private void rebuildProcessedGridAndRefresh() { if (toneEnabled) { g = SurfaceUtils.toneMapGrid(g, toneOperator, toneParam); } - dataGrid.clear(); dataGrid.addAll(g); - xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); + dataGrid.clear(); + dataGrid.addAll(g); + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); syncGuiControls(); - updateTheMesh(); updateView(true); + updateTheMesh(); + updateView(true); } - /** Build a dense similarity/divergence row for a node from the current sparse graph. */ + + /** + * 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]; @@ -1815,7 +2081,9 @@ private Double[] buildSimilarityRowFromGraph(GraphNode node) { return out; } - /** Prefer GraphEdge#getWeight(); fallback to 1.0 if unavailable. */ + /** + * Prefer GraphEdge#getWeight(); fallback to 1.0 if unavailable. + */ private double getEdgeWeightSafe(GraphEdge e) { try { // Adjust if your API uses a different accessor @@ -1825,23 +2093,29 @@ private double getEdgeWeightSafe(GraphEdge e) { } } - /** Optional: if a GraphNode encodes a source row index for the hypersurface, try to extract it. */ + /** + * 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) { } + } 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) { } + } catch (Throwable ignored) { + } return Optional.empty(); } - /** Highlight a row on the hypersurface if we can map a node to a data row. */ + /** + * 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 @@ -1851,5 +2125,5 @@ private void highlightSurfaceRowIfPossible(GraphNode node) { Point3D center = new Point3D((xWidth * surfScale) / 2.0, 0, clamped * surfScale); illuminateCrosshair(center); }); - } -} \ No newline at end of file + } +} 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 8a2ae927..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 -> { - if(null != skybox) 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 index e747a4c9..f1f3ea52 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java @@ -9,15 +9,16 @@ * used by Hypersurface rendering. */ public final class SurfaceUtils { - private SurfaceUtils() {} + private SurfaceUtils() { + } // ============================== Enums =============================== - public enum Interpolation { NEAREST, BILINEAR, BICUBIC } + public enum Interpolation {NEAREST, BILINEAR, BICUBIC} - public enum Smoothing { NONE, BOX, GAUSSIAN, MEDIAN } + public enum Smoothing {NONE, BOX, GAUSSIAN, MEDIAN} - public enum ToneMap { NONE, CLAMP_01, NORMALIZE_01, LOG1P, GAMMA, ZSCORE } + public enum ToneMap {NONE, CLAMP_01, NORMALIZE_01, LOG1P, GAMMA, ZSCORE} // ============================ Sampling ============================== @@ -27,9 +28,9 @@ public static double sample(List> grid, double x, double y, Interpo 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 NEAREST -> sampleNearest(grid, x, y); case BILINEAR -> sampleBilinear(grid, x, y); - case BICUBIC -> sampleBicubicSafe(grid, x, y); // edge-safe w/ fallback + case BICUBIC -> sampleBicubicSafe(grid, x, y); // edge-safe w/ fallback }; } @@ -37,7 +38,9 @@ public static double sample(List> grid, double x, double y) { return sample(grid, x, y, Interpolation.BILINEAR); } - /** Kept for compatibility with older call sites. */ + /** + * 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); @@ -53,57 +56,57 @@ private static double sampleNearest(List> g, double x, double y) { 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 sampleBilinear(List> g, double x, double y) { + final int w = width(g), h = height(g); -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); + // 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); } - 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); + + 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); } - 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 ============================== @@ -113,30 +116,34 @@ public static double sampleBicubicSafe(List> g, double x, double y) * One iteration; GAUSSIAN uses {@code sigma}; other methods ignore it. */ public static List> smooth( - List> grid, - Smoothing method, - double sigma, - int radius + 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). */ + /** + * Overload: BOX/MEDIAN/GAUSSIAN with default sigma heuristic (one iteration). + */ public static List> smooth( - List> grid, - Smoothing method, - int radius + List> grid, + Smoothing method, + int radius ) { return smoothCopy(grid, method, radius, 1, -1.0); } - /** Overload with explicit iterations (sigma used only for GAUSSIAN). */ + /** + * Overload with explicit iterations (sigma used only for GAUSSIAN). + */ public static List> smooth( - List> grid, - Smoothing method, - int radius, - int iterations, - double sigma + List> grid, + Smoothing method, + int radius, + int iterations, + double sigma ) { return smoothCopy(grid, method, radius, iterations, sigma); } @@ -144,16 +151,16 @@ public static List> smooth( /** * Create a smoothed copy of the grid. * - * @param radius kernel radius (>=0), size = 2r+1 + * @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 + * @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 + List> grid, + Smoothing method, + int radius, + int iterations, + double sigma ) { if (isEmpty(grid) || method == Smoothing.NONE || radius <= 0 || iterations <= 0) { return deepCopy(grid); @@ -164,20 +171,26 @@ public static List> smoothCopy( case BOX -> { for (int i = 0; i < iterations; i++) { boxBlurSeparable(src, dst, radius); - List> tmp = src; src = dst; dst = tmp; + 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; + 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; + List> tmp = src; + src = dst; + dst = tmp; } } default -> { /* NONE already returned above */ } @@ -185,13 +198,15 @@ public static List> smoothCopy( return src; } - /** In-place smoothing (replaces {@code grid}). */ + /** + * In-place smoothing (replaces {@code grid}). + */ public static void smoothInPlace( - List> grid, - Smoothing method, - int radius, - int iterations, - double sigma + List> grid, + Smoothing method, + int radius, + int iterations, + double sigma ) { List> out = smoothCopy(grid, method, radius, iterations, sigma); replaceContents(grid, out); @@ -203,14 +218,16 @@ public static void smoothInPlace( * ***New:*** Alias to match your call site {@code toneMapGrid(g, tm, param)}. */ public static List> toneMapGrid( - List> grid, - ToneMap mode, - double param + List> grid, + ToneMap mode, + double param ) { return toneMapCopy(grid, mode, param); } - /** Create a tone-mapped copy of the grid. */ + /** + * 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); @@ -218,7 +235,8 @@ public static List> toneMapCopy(List> grid, ToneMap mo List> out = makeZeroGrid(h, w); double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; - double mean = 0.0, m2 = 0.0; int n = 0; + 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++) { @@ -234,7 +252,11 @@ public static List> toneMapCopy(List> grid, ToneMap mo 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; + n++; + double d = v - mean; + mean += d / n; + double d2 = v - mean; + m2 += d * d2; } } } @@ -348,7 +370,8 @@ private static double[] gaussianKernel(int radius, double sigma) { 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; + k[i + radius] = val; + sum += val; } for (int i = 0; i < n; i++) k[i] /= sum; return k; @@ -359,22 +382,30 @@ private static double[] gaussianKernel(int radius, double sigma) { 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); + + (-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 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 width(List> g) { + return g.get(0).size(); + } - private static int height(List> g) { return g.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 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; 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 1bae407b..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 @@ -125,6 +125,7 @@ public void unselect() { // contract(); setMaterial(phongMaterial); } + public void setMaterialOpacity(double alpha) { double a = Math.max(0.0, Math.min(1.0, alpha)); Color base = phongMaterial.getDiffuseColor(); @@ -141,6 +142,7 @@ public void setMaterialOpacity(double alpha) { 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 b9c2dcb2..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 @@ -14,8 +14,10 @@ import javafx.util.Duration; import org.fxyz3d.geometry.Point3D; import org.fxyz3d.shapes.composites.PolyLine3D; + import java.util.ArrayList; import java.util.List; + import static javafx.animation.Animation.INDEFINITE; /** @@ -165,6 +167,7 @@ public void setDiffuseColor(Color color) { } } } + public void setOpacityAlpha(double alpha) { double a = Math.max(0.0, Math.min(1.0, alpha)); if (this.meshView != null) { @@ -184,5 +187,5 @@ public void setOpacityAlpha(double alpha) { 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 index b99c241b..89c275a6 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/tasks/BuildGraphFromMatrixTask.java @@ -14,22 +14,24 @@ 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; -import javafx.scene.text.Font; /** * 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). - * + * - 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 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 index 36eefd31..a1ff9471 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java @@ -7,15 +7,16 @@ import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; import edu.jhuapl.trinity.utils.JavaFX3DUtils; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; 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). @@ -77,9 +78,9 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params Params p = (params != null) ? params : new Params(); Color nodeDefault = (graph.getDefaultNodeColor() != null) - ? Color.valueOf(graph.getDefaultNodeColor()) : p.defaultNodeColor; + ? Color.valueOf(graph.getDefaultNodeColor()) : p.defaultNodeColor; Color edgeDefault = (graph.getDefaultEdgeColor() != null) - ? Color.valueOf(graph.getDefaultEdgeColor()) : p.defaultEdgeColor; + ? Color.valueOf(graph.getDefaultEdgeColor()) : p.defaultEdgeColor; // Nodes List nodes = new ArrayList<>(graph.getNodes().size()); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java index acebb5a8..d4a91ed8 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerService.java @@ -5,6 +5,7 @@ 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; @@ -16,74 +17,123 @@ public interface FeatureVectorManagerService { String MANAGER_APPLY_TAG = "FV_MANAGER_APPLY"; - enum SamplingMode { ALL, HEAD_1000, TAIL_1000, RANDOM_1000 } - enum ExportFormat { JSON, CSV } + 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). */ + /** + * Live list of collection names (do not replace the instance; mutate it). + */ ObservableList getCollectionNames(); - /** Name of the currently active collection. */ + /** + * Name of the currently active collection. + */ StringProperty activeCollectionNameProperty(); - /** Live list of vectors to display (sampling + filters applied). */ + /** + * Live list of vectors to display (sampling + filters applied). + */ ObservableList getDisplayedVectors(); - /** Current sampling mode. */ + /** + * Current sampling mode. + */ ObjectProperty samplingModeProperty(); - /** Free-text filter across label/text/metadata (case-insensitive). */ + /** + * 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); } + /** + * 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. */ + /** + * 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. */ + /** + * Append vectors to the currently active collection. + */ void appendVectorsToActive(List vectors); - /** Replace the vectors in the currently active collection. */ + /** + * Replace the vectors in the currently active collection. + */ void replaceActiveVectors(List vectors); - /** Rename a collection. */ + /** + * Rename a collection. + */ void renameCollection(String oldName, String newName); - /** Duplicate a collection; returns new collection name. */ + /** + * Duplicate a collection; returns new collection name. + */ String duplicateCollection(String sourceName, String proposedName); - /** Delete a collection. */ + /** + * Delete a collection. + */ void deleteCollection(String name); - /** Merge source collection into target. Optionally de-duplicate by entityId. */ + /** + * 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. */ + /** + * 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. */ + /** + * Remove specific vectors from the active collection. + */ void removeFromActive(List toRemove); - /** Copy specific vectors to a target collection (create if missing). */ + /** + * 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. */ + /** + * 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. */ + /** + * 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). */ + /** + * Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). + */ void applyActiveToWorkspace(boolean replace); // Convenience default - default void applyActiveToWorkspace() { applyActiveToWorkspace(false); } + default void applyActiveToWorkspace() { + applyActiveToWorkspace(false); + } - /** Also Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). */ + /** + * Also Fire APPLY_ACTIVE_FEATUREVECTORS back to the app (scene root). + */ void applyAllToWorkspace(boolean replace); - default void applyAllToWorkspace() { applyAllToWorkspace(false); } + default void applyAllToWorkspace() { + applyAllToWorkspace(false); + } - /** Optional: Where events should be fired (e.g., scene.getRoot()). */ + /** + * 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 index a8380c6a..6147bb3b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorManagerServiceImpl.java @@ -14,6 +14,7 @@ import javafx.event.EventTarget; import javafx.scene.Node; import javafx.scene.Scene; + import java.io.File; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -47,11 +48,30 @@ public FeatureVectorManagerServiceImpl(InMemoryFeatureVectorRepository ignoredRe 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 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) { @@ -150,9 +170,9 @@ public void mergeInto(String targetName, String sourceName, boolean dedupByEntit if (dedupByEntityId) { Set existingIds = tgt.stream() - .map(FeatureVector::getEntityId) - .filter(Objects::nonNull) - .collect(Collectors.toCollection(LinkedHashSet::new)); + .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)) { @@ -191,9 +211,9 @@ public void removeFromActive(List toRemove) { List list = collections.get(name); if (list == null) return; Set ids = toRemove.stream() - .map(FeatureVector::getEntityId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); + .map(FeatureVector::getEntityId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); if (!ids.isEmpty()) { list.removeIf(fv -> fv.getEntityId() != null && ids.contains(fv.getEntityId())); } else { @@ -208,8 +228,8 @@ public void copyToCollection(List toCopy, String targetCollection if (toCopy == null || toCopy.isEmpty() || targetCollection == null) return; runFx(() -> { String target = (collectionNames.contains(targetCollection)) - ? targetCollection - : uniquify(targetCollection); + ? targetCollection + : uniquify(targetCollection); collections.computeIfAbsent(target, k -> { if (!collectionNames.contains(target)) collectionNames.add(target); return new ArrayList<>(); @@ -278,6 +298,7 @@ public void applyActiveToWorkspace(boolean replace) { else if (eventScene != null) eventScene.getRoot().fireEvent(evt); }); } + @Override public void applyAllToWorkspace(boolean replace) { runFx(() -> { @@ -297,6 +318,7 @@ public void applyAllToWorkspace(boolean replace) { else if (eventScene != null) eventScene.getRoot().fireEvent(evt); }); } + @Override public void setEventTarget(EventTarget target) { if (target instanceof Node n) { @@ -312,26 +334,26 @@ public void setEventTarget(EventTarget target) { } // ---------- internals ---------- -private void refreshDisplayedFromActive() { - String name = activeCollectionName.get(); - List src = (name == null) ? List.of() : collections.getOrDefault(name, List.of()); + 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()); + // 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()) + // 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()); + .filter(fv -> edu.jhuapl.trinity.javafx.services.FeatureVectorUtils.matchesTextFilter(fv, q)) + .collect(java.util.stream.Collectors.toList()); - // then apply sampling - List sampled = + // then apply sampling + List sampled = edu.jhuapl.trinity.javafx.services.FeatureVectorUtils.applySampling(filtered, samplingMode.get()); - displayedVectors.setAll(sampled); -} + displayedVectors.setAll(sampled); + } private String uniquify(String base) { String b = (base == null || base.isBlank()) ? "Collection" : base.trim(); @@ -358,4 +380,4 @@ private static void runFx(Runnable r) { if (Platform.isFxApplicationThread()) r.run(); else Platform.runLater(r); } -} \ No newline at end of file +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java index 440c600d..1af59713 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorRepository.java @@ -7,9 +7,14 @@ 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 index fd81fa52..d0743a60 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/FeatureVectorUtils.java @@ -17,46 +17,67 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; -/** Grab-bag of common helpers used by FeatureVector Manager UI & service. */ +/** + * Grab-bag of common helpers used by FeatureVector Manager UI & service. + */ public final class FeatureVectorUtils { - private FeatureVectorUtils() {} + private FeatureVectorUtils() { + } /* ---------------- String helpers ---------------- */ - /** null-safe: returns empty string when s==null */ - public static String nullToEmpty(String s) { return s == null ? "" : s; } + /** + * 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(); } + /** + * 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. */ + /** + * 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. */ + /** + * 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.). */ + /** + * 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). */ + /** + * 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). */ + /** + * 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(); @@ -78,7 +99,9 @@ public static FeatureVector cloneVector(FeatureVector src) { return fv; } - /** Copy a list of FeatureVectors via {@link #cloneVector(FeatureVector)}. */ + /** + * 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()); @@ -88,7 +111,9 @@ public static List copyVectors(List in) { /* ---------------- Preview/format helpers ---------------- */ - /** Small numeric preview for a vector’s data list. */ + /** + * 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()); @@ -108,20 +133,24 @@ public static String previewList(List data, int firstN) { return sb.toString(); } - /** Pretty prints metadata as lines of "key: value". */ + /** + * 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")); + .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). */ + /** + * 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; @@ -163,7 +192,9 @@ public static String deriveCollectionName(Object sourceHint, List return "Collection-" + UUID.randomUUID().toString().substring(0, 8); } - /** Merge helper with optional dedupe by entityId (null IDs are always appended). */ + /** + * Merge helper with optional dedupe by entityId (null IDs are always appended). + */ public static List mergeVectors(List target, Collection source, boolean dedupeByEntityId) { @@ -187,7 +218,9 @@ public static List mergeVectors(List target, /* ---------------- Sampling & filtering ---------------- */ - /** Applies sampling policy used by the Manager. */ + /** + * 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) @@ -216,12 +249,16 @@ public static List applySampling(List all, } } - /** Alias used by the service refactor; delegates to {@link #applySampling(List, FeatureVectorManagerService.SamplingMode)}. */ + /** + * 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. */ + /** + * 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; @@ -258,9 +295,9 @@ public static void writeCsv(File file, List src) throws IOExcepti 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()); + .append(fv.getScore()).append(",") + .append(fv.getPfa()).append(",") + .append(fv.getLayer()); List data = fv.getData(); for (int i = 0; i < maxDim; i++) { row.append(","); @@ -283,8 +320,9 @@ public static String escapeCsv(String s) { } 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 index 9014949b..69b75a30 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/services/InMemoryFeatureVectorRepository.java @@ -1,39 +1,50 @@ 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; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; 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 ObservableList getCollectionNames() { + return collectionNames; + } - @Override public void put(String name, List vectors) { + @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) { + @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 boolean contains(String name) { + return map.containsKey(name); + } - @Override public List get(String name) { + @Override + public List get(String name) { return map.getOrDefault(name, Collections.emptyList()); } - @Override public void clear() { + @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 81eb0f85..8e3606f7 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/AnalysisUtils.java @@ -65,6 +65,7 @@ public static void writeAnalysisConfig(File file) throws IOException { ObjectMapper mapper = new ObjectMapper(); mapper.writeValue(file, currentAnalysisConfig); } + public static double[] normalizedWeights(double[] w, int dim) { double[] out = new double[dim]; if (w == null || w.length == 0) { @@ -85,10 +86,18 @@ public static double[] normalizedWeights(double[] w, int dim) { } 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 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; @@ -473,6 +482,7 @@ public static double[][] transformUMAP(FeatureCollection featureCollection, Umap Utils.printTotalTime(start); 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++) { @@ -482,6 +492,6 @@ public static double cosineSimilarity(List v1, List v2) { 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 15718164..5f50b3db 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/DataUtils.java @@ -39,12 +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(); @@ -59,74 +61,77 @@ public static int randomSign() { public static double normalize(double rawValue, double min, double max) { return (rawValue - min) / (max - min); } -public static List> normalizeAndScale( + + 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; + ) { + 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; + } } - } - 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; + // 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 } - dst.add(z * userScale); // ← apply your existing user multiplier LAST + out.add(dst); } - out.add(dst); + return out; } - return out; -} public static List extractReconstructionEvents(ReconstructionAttributes attrs) { List items = new ArrayList<>(attrs.getEvents().size()); 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 index 404dd092..8d940c10 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java +++ b/src/main/java/edu/jhuapl/trinity/utils/MatrixViewUtil.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.util; +package edu.jhuapl.trinity.utils; import javafx.scene.paint.Color; @@ -11,7 +11,8 @@ */ public final class MatrixViewUtil { - private MatrixViewUtil() {} + private MatrixViewUtil() { + } // ---- math helpers ---- public static double[] minMax(double[][] m) { @@ -25,7 +26,10 @@ public static double[] minMax(double[][] m) { if (v > max) max = v; } } - if (min == Double.POSITIVE_INFINITY) { min = 0.0; max = 1.0; } + 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}; } @@ -79,7 +83,10 @@ private static double clamp01(double x) { } // ---- labels ---- - /** Replace "Comp 7" → "7"; "Comp7" → "7"; otherwise returns original. */ + + /** + * Replace "Comp 7" → "7"; "Comp7" → "7"; otherwise returns original. + */ public static String compactCompLabel(String s) { if (s == null) return null; String x = s.trim(); 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/WebCamUtils.java b/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java index 72cb0c0d..87d3f0db 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/WebCamUtils.java @@ -1,12 +1,13 @@ package edu.jhuapl.trinity.utils; import com.github.sarxos.webcam.Webcam; -import java.awt.Dimension; import javafx.embed.swing.SwingFXUtils; import javafx.scene.image.Image; import javafx.scene.image.WritableImage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + +import java.awt.*; import java.awt.image.BufferedImage; import java.util.concurrent.TimeUnit; 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 be291878..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 @@ -1,9 +1,6 @@ package edu.jhuapl.trinity.utils.fun.solar; import edu.jhuapl.trinity.javafx.events.EffectEvent; -import static edu.jhuapl.trinity.javafx.events.EffectEvent.SUN_POSITION_ARCHEIGHT; -import static edu.jhuapl.trinity.javafx.events.EffectEvent.SUN_POSITION_ARCWIDTH; -import static edu.jhuapl.trinity.javafx.events.EffectEvent.SUN_POSITION_VELOCITY; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; @@ -17,6 +14,8 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; +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 index f9896843..ab6a93cd 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/ForceFrLayout3D.java @@ -46,7 +46,7 @@ public double[][] layout(int n, 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 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; @@ -54,8 +54,12 @@ public double[][] layout(int n, 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; + disp[i][0] += fx; + disp[i][1] += fy; + disp[i][2] += fz; + disp[j][0] -= fx; + disp[j][1] -= fy; + disp[j][2] -= fz; } } @@ -68,7 +72,7 @@ public double[][] layout(int n, 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 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); @@ -76,8 +80,12 @@ public double[][] layout(int n, 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; + disp[i][0] -= fx; + disp[i][1] -= fy; + disp[i][2] -= fz; + disp[j][0] += fx; + disp[j][1] += fy; + disp[j][2] += fz; } } } @@ -95,7 +103,7 @@ public double[][] layout(int n, // 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 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); @@ -123,8 +131,8 @@ private static void initOnSphere(double[][] pos, double r) { // 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 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; diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java index d9673e9e..d68be5b3 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java @@ -5,13 +5,14 @@ /** * 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; + @Serial + private static final long serialVersionUID = 1L; // --------------------------------------------------------------------- // Layout kinds @@ -25,96 +26,204 @@ public enum LayoutKind { FORCE_FR } - /** Which layout engine to use. */ + /** + * Which layout engine to use. + */ public LayoutKind kind = LayoutKind.SPHERE; - /** Radius / scale for static layouts and initial placement for force layouts. */ + /** + * Radius / scale for static layouts and initial placement for force layouts. + */ public double radius = 600.0; // --------------------------------------------------------------------- // Force-FR knobs // --------------------------------------------------------------------- - /** Number of force iterations (ticks). */ + /** + * Number of force iterations (ticks). + */ public int iterations = 500; - /** Global step size / temperature start (will cool). */ + /** + * Global step size / temperature start (will cool). + */ public double step = 1.0; - /** Repulsion constant Cr (higher → more spread). */ + /** + * Repulsion constant Cr (higher → more spread). + */ public double repulsion = 900.0; - /** Attraction constant Ca (higher → tighter edges). */ + /** + * Attraction constant Ca (higher → tighter edges). + */ public double attraction = 0.08; - /** Gravity pulls nodes toward origin to avoid drift. */ + /** + * Gravity pulls nodes toward origin to avoid drift. + */ public double gravity = 0.02; - /** Cooling schedule factor (0..1). Smaller → faster cooling. */ + /** + * Cooling schedule factor (0..1). Smaller → faster cooling. + */ public double cooling = 0.96; // --------------------------------------------------------------------- // Edge building from matrix // --------------------------------------------------------------------- - /** Policies for selecting which edges to keep. */ + /** + * Policies for selecting which edges to keep. + */ public enum EdgePolicy { - /** Fully connected graph (all pairs, except diagonal). */ + /** + * Fully connected graph (all pairs, except diagonal). + */ ALL, - /** k nearest neighbors per node. */ + /** + * k nearest neighbors per node. + */ KNN, - /** Threshold by epsilon: keep edges with weight >= epsilon (similarity) or <= epsilon (divergence). */ + /** + * Threshold by epsilon: keep edges with weight >= epsilon (similarity) or <= epsilon (divergence). + */ EPSILON, - /** Minimum spanning tree plus k nearest neighbors. */ + /** + * Minimum spanning tree plus k nearest neighbors. + */ MST_PLUS_K } - /** Edge selection policy. */ + /** + * Edge selection policy. + */ public EdgePolicy edgePolicy = EdgePolicy.KNN; - /** K for KNN and MST_PLUS_K. */ + /** + * 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). */ + /** + * 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). */ + /** + * Threshold epsilon (meaning depends on similarity vs divergence). + */ public double epsilon = 0.75; - /** Whether to build MST first (for MST_PLUS_K). */ + /** + * Whether to build MST first (for MST_PLUS_K). + */ public boolean buildMst = true; - /** Keep at most this many edges total (after sparsification). */ + /** + * Keep at most this many edges total (after sparsification). + */ public int maxEdges = 5000; - /** Optional per-node degree cap; <=0 disables. */ + /** + * Optional per-node degree cap; <=0 disables. + */ public int maxDegreePerNode = 32; - /** Cut edges below this weight (after transform). */ + /** + * Cut edges below this weight (after transform). + */ public double minEdgeWeight = 0.0; - /** When true, normalize input matrix to [0,1] before transforms. */ + /** + * 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; } + 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; } + 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; } + 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 index 93715c9c..1261405d 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java @@ -1,8 +1,9 @@ package edu.jhuapl.trinity.utils.graph; +import javafx.scene.paint.Color; + import java.io.Serial; import java.io.Serializable; -import javafx.scene.paint.Color; /** * Visual styling parameters for 3D graph rendering. @@ -12,17 +13,17 @@ public final class GraphStyleParams implements Serializable { @Serial private static final long serialVersionUID = 1L; -// Nodes + // Nodes public Color nodeColor = Color.CYAN; // default aligned with Graph3DRenderer.Params public double nodeRadius = 20.0; public double nodeOpacity = 1.0; // [0..1] -// Edges + // 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 + // Fluent setters public GraphStyleParams withNodeColor(Color c) { this.nodeColor = c; return this; @@ -53,7 +54,7 @@ public GraphStyleParams withEdgeOpacity(double o) { return this; } -// Shallow copy helpers + // Shallow copy helpers public static GraphStyleParams copyOf(GraphStyleParams src) { GraphStyleParams p = new GraphStyleParams(); if (src == null) { diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java index bdbddd0e..811629fa 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/MatrixToGraphAdapter.java @@ -3,6 +3,7 @@ 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; @@ -15,33 +16,37 @@ * -------------------- * 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 - * + * - 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). + * • 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 } + 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). */ + /** + * Use raw entries (optionally normalized 0..1). + */ DIRECT, - /** For divergence: w = 1 / (eps + d). For similarity: same as DIRECT. */ + /** + * For divergence: w = 1 / (eps + d). For similarity: same as DIRECT. + */ INVERSE_FOR_DIVERGENCE } @@ -81,8 +86,8 @@ public static GraphDirectedCollection build(double[][] matrix, 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 -> { + case SPHERE -> pos = StaticLayouts.sphere(n, layoutParams.radius); + case MDS_3D -> { // Need distances for MDS double[][] distances = toDistances(matrix, kind); if (mds3d == null) { @@ -156,7 +161,9 @@ private static List defaultLabels(int n) { return out; } - /** Build symmetric weight matrix for edges from similarity/divergence. */ + /** + * 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]; @@ -172,7 +179,9 @@ private static double[][] toEdgeWeights(double[][] m, MatrixKind kind, WeightMod } } } - if (!(max > min)) { max = min + 1e-9; } + if (!(max > min)) { + max = min + 1e-9; + } } for (int i = 0; i < n; i++) { @@ -203,7 +212,9 @@ private static double[][] toEdgeWeights(double[][] m, MatrixKind kind, WeightMod return w; } - /** Distances for MDS: for similarity convert to distance; for divergence use as-is. */ + /** + * 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]; @@ -220,7 +231,9 @@ private static double[][] toDistances(double[][] m, MatrixKind kind) { } } } - if (!(max > min)) { max = min + 1e-9; } + if (!(max > min)) { + max = min + 1e-9; + } } for (int i = 0; i < n; i++) { @@ -255,7 +268,9 @@ private static List buildEdgesByPolicy(double[][] weights, }; } - /** ALL: keep all i selectAll(double[][] w, GraphLayoutParams lp) { int n = w.length; List edges = new ArrayList<>(); @@ -271,7 +286,9 @@ private static List selectAll(double[][] w, GraphLayoutParams lp) { // (Note: this still can be dense; caps limit total/degree.) } - /** KNN: per node, take top-k neighbors by weight; union; optional symmetrization; then caps. */ + /** + * 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); @@ -285,12 +302,12 @@ private static List selectKnn(double[][] w, GraphLayoutParams lp) { double wij = w[i][j]; if (wij <= lp.minEdgeWeight) continue; if (pq.size() < k) { - pq.offer(new int[]{j, (int)Double.doubleToLongBits(wij)}); + 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)}); + pq.offer(new int[]{j, (int) Double.doubleToLongBits(wij)}); } } } @@ -314,7 +331,9 @@ private static List selectKnn(double[][] w, GraphLayoutParams lp) { return applyGlobalAndDegreeCaps(edges, n, lp.maxEdges, lp.maxDegreePerNode); } - /** EPSILON: keep all edges with weight >= epsilon (after transforms), then caps. */ + /** + * 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; @@ -330,7 +349,9 @@ private static List selectByEpsilon(double[][] w, GraphLayoutParams lp) 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. */ + /** + * 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) @@ -408,24 +429,42 @@ private static List applyGlobalAndDegreeCaps(List edges, int n 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]++; + 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 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])); } + + 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]++; } + 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; } } @@ -443,6 +482,7 @@ static double[][] circleXZ(int n, double r) { } return p; } + static double[][] circleXY(int n, double r) { double[][] p = new double[n][3]; for (int i = 0; i < n; i++) { @@ -453,29 +493,35 @@ static double[][] circleXY(int n, double r) { } 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 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; + 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 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; + 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 index 3b876361..935dd4f3 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/SuperMdsEmbedding3D.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/SuperMdsEmbedding3D.java @@ -10,7 +10,7 @@ * 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. * diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java index 3b10a581..59edf55d 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/ABComparisonEngine.java @@ -4,7 +4,6 @@ import java.io.Serial; import java.io.Serializable; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -15,27 +14,30 @@ * 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 - * + * - 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. + * - 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. */ + /** + * 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; + @Serial + private static final long serialVersionUID = 1L; public final GridSpec grid; @@ -78,7 +80,8 @@ public AbResult(GridSpec grid, } } - private ABComparisonEngine() { } + private ABComparisonEngine() { + } /** * Compute an A/B comparison for given axes. Uses recipe bins & bounds policy. @@ -111,18 +114,18 @@ public static AbResult compare(List aVectors, // 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); + ? 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); + ? 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; + || recipe.getOutputKind() == JpdfRecipe.OutputKind.PDF_AND_CDF; boolean needCdf = recipe.getOutputKind() == JpdfRecipe.OutputKind.CDF_ONLY - || recipe.getOutputKind() == JpdfRecipe.OutputKind.PDF_AND_CDF; + || recipe.getOutputKind() == JpdfRecipe.OutputKind.PDF_AND_CDF; List> pdfDiff = null; List> cdfDiff = null; @@ -163,17 +166,17 @@ public static AbResult compare(List aVectors, } return new AbResult( - grid, - resA, - resB, - pdfDiff, - cdfDiff, - pdfProvA, - pdfProvB, - pdfProvDiff, - cdfProvA, - cdfProvB, - cdfProvDiff + grid, + resA, + resB, + pdfDiff, + cdfDiff, + pdfProvA, + pdfProvB, + pdfProvDiff, + cdfProvA, + cdfProvB, + cdfProvDiff ); } @@ -181,7 +184,9 @@ public static AbResult compare(List aVectors, // Helpers // ------------------------------------------------------------------------------------- - /** Build one canonical grid for BOTH cohorts using the recipe's bounds policy and bins. */ + /** + * 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, @@ -194,8 +199,10 @@ private static GridSpec buildAlignedGrid(List aVectors, switch (recipe.getBoundsPolicy()) { case FIXED_01 -> { - g.setMinX(0.0); g.setMaxX(1.0); - g.setMinY(0.0); g.setMaxY(1.0); + 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 @@ -225,7 +232,9 @@ private static GridSpec buildAlignedGrid(List aVectors, return g; } - /** Create a provenance AxisSummary from AxisParams (best-effort without extra context). */ + /** + * 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; @@ -233,7 +242,7 @@ private static JpdfProvenance.AxisSummary toAxisSummary(AxisParams a) { if (a.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN) { refKind = (a.getReferenceVec() != null) ? JpdfProvenance.AxisSummary.ReferenceKind.CUSTOM - : JpdfProvenance.AxisSummary.ReferenceKind.NONE; + : JpdfProvenance.AxisSummary.ReferenceKind.NONE; } else if (a.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) { refKind = JpdfProvenance.AxisSummary.ReferenceKind.NONE; } else { @@ -242,16 +251,18 @@ private static JpdfProvenance.AxisSummary toAxisSummary(AxisParams a) { String label = a.getMetricName(); // harmless display hint; may be null return new JpdfProvenance.AxisSummary( - a.getType(), - a.getMetricName(), - a.getComponentIndex(), - refKind, - refIndex, - label + a.getType(), + a.getMetricName(), + a.getComponentIndex(), + refKind, + refIndex, + label ); } - /** Convert GridSpec + recipe bounds policy into a provenance GridSummary. */ + /** + * 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(); @@ -269,17 +280,21 @@ private static JpdfProvenance.GridSummary toGridSummary(GridSpec g, JpdfRecipe r }; return new JpdfProvenance.GridSummary( - bx, by, - minX, maxX, minY, maxY, - dx, dy, - bp, - recipe.getCanonicalPolicyId() + bx, by, + minX, maxX, minY, maxY, + dx, dy, + bp, + recipe.getCanonicalPolicyId() ); } - private static double nz(Double v, double dflt) { return v == null ? dflt : v; } + 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. */ + /** + * 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()); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java index 567dc704..c9b53965 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/AxisParams.java @@ -10,8 +10,9 @@ public class AxisParams { private Integer componentIndex; // for COMPONENT_AT_DIMENSION public AxisParams() { - + } + public AxisParams(StatisticEngine.ScalarType type, String metricName, List referenceVec, @@ -21,6 +22,7 @@ public AxisParams(StatisticEngine.ScalarType type, this.referenceVec = referenceVec; this.componentIndex = componentIndex; } + /** * @return the type */ diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java index bff34c13..67434a04 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/CanonicalGridPolicy.java @@ -21,15 +21,15 @@ * ------------------- * 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) - * + * - 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. * @@ -37,7 +37,8 @@ */ public final class CanonicalGridPolicy implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; // ---------- Modes ---------- @@ -54,7 +55,8 @@ public enum Mode { * 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; + @Serial + private static final long serialVersionUID = 1L; public final StatisticEngine.ScalarType type; public final String metricName; // for METRIC_DISTANCE_TO_MEAN @@ -72,50 +74,71 @@ public static AxisKey from(AxisParams a) { return new AxisKey(a.getType(), a.getMetricName(), a.getComponentIndex(), a.getMetricName()); } - @Override public boolean equals(Object o) { + @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); + Objects.equals(metricName, that.metricName) && + Objects.equals(componentIndex, that.componentIndex) && + Objects.equals(label, that.label); } - @Override public int hashCode() { + @Override + public int hashCode() { return Objects.hash(type, metricName, componentIndex, label); } - @Override public String toString() { + @Override + public String toString() { return "AxisKey{" + - "type=" + type + - ", metricName='" + metricName + '\'' + - ", componentIndex=" + componentIndex + - ", label='" + label + '\'' + - '}'; + "type=" + type + + ", metricName='" + metricName + '\'' + + ", componentIndex=" + componentIndex + + ", label='" + label + '\'' + + '}'; } } - /** Per-axis explicit override (any field nullable = leave to policy). */ + /** + * Per-axis explicit override (any field nullable = leave to policy). + */ public static final class AxisOverride implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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; + this.min = min; + this.max = max; + this.bins = bins; } - public AxisOverride withBins(Integer b) { return new AxisOverride(min, max, b); } + public AxisOverride withBins(Integer b) { + return new AxisOverride(min, max, b); + } } - /** Simple holder for numeric range. */ + /** + * Simple holder for numeric range. + */ public static final class AxisRange implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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 + "]"; } + + public AxisRange(double min, double max) { + this.min = min; + this.max = max; + } + + @Override + public String toString() { + return "[" + min + ", " + max + "]"; + } } // ---------- Policy fields ---------- @@ -146,10 +169,10 @@ public static final class AxisRange implements Serializable { /** 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(); + .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()); @@ -178,10 +201,27 @@ public Builder(String id, Mode mode) { 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 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)) @@ -224,20 +264,22 @@ public GridSpec gridForAxes(List vectors, 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); + ? 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); + ? 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); + g.setMinX(rx.min); + g.setMaxX(rx.max); + g.setMinY(ry.min); + g.setMaxY(ry.max); return g; } @@ -273,8 +315,8 @@ private AxisRange computeAxisRange(List vectors, // Cache path if (datasetCacheKey != null) { AxisRange cached = datasetRangeCache - .computeIfAbsent(datasetCacheKey, k -> new ConcurrentHashMap<>()) - .get(AxisKey.from(axis)); + .computeIfAbsent(datasetCacheKey, k -> new ConcurrentHashMap<>()) + .get(AxisKey.from(axis)); if (cached != null) return cached; } @@ -305,7 +347,7 @@ private AxisRange computeAxisRange(List vectors, if (datasetCacheKey != null) { datasetRangeCache.computeIfAbsent(datasetCacheKey, k -> new ConcurrentHashMap<>()) - .put(AxisKey.from(axis), r); + .put(AxisKey.from(axis), r); } return r; } @@ -317,8 +359,8 @@ private double[] extractScalars(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 + StatisticEngine.ScalarType.DIST_TO_MEAN, + StatisticEngine.ScalarType.COSINE_TO_MEAN ); if (needMean.contains(axis.getType())) { meanVector = FeatureVector.getMeanVector(vectors); @@ -327,8 +369,8 @@ private double[] extractScalars(List vectors, AxisParams axis) { // Metric if needed Metric metric = null; if (axis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN - && axis.getMetricName() != null - && axis.getReferenceVec() != null) { + && axis.getMetricName() != null + && axis.getReferenceVec() != null) { metric = Metric.getMetric(axis.getMetricName()); } @@ -396,26 +438,49 @@ private static double percentileSorted(double[] sorted, double p) { // ---------- 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 String id() { + return id; + } - public Map overridesView() { return Collections.unmodifiableMap(overrides); } + public Mode mode() { + return mode; + } - @Override public String toString() { + 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() + - '}'; + "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 index ec77596d..7baa6642 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DensityCache.java @@ -14,68 +14,102 @@ * 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 - * + * 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). + * - 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; + @Serial + private static final long serialVersionUID = 1L; - /** Cache statistics snapshot. */ + /** + * Cache statistics snapshot. + */ public static final class Stats implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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; + this.hits = hits; + this.misses = misses; + this.puts = puts; + this.evictions = evictions; + this.expirations = expirations; + this.size = size; } - @Override public String toString() { + + @Override + public String toString() { return "Stats{hits=" + hits + ", misses=" + misses + ", puts=" + puts + - ", evictions=" + evictions + ", expirations=" + expirations + - ", size=" + size + '}'; + ", evictions=" + evictions + ", expirations=" + expirations + + ", size=" + size + '}'; } } - /** Builder for DensityCache. */ + /** + * 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); } + + 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. */ + /** + * Internal entry. + */ private static final class Entry implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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; } + + 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). */ + /** + * Access-ordered LRU map with bounded size (evicts eldest). + */ private final LinkedHashMap map; private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); @@ -86,7 +120,8 @@ 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) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { boolean evict = size() > DensityCache.this.maxEntries; if (evict) evictions++; return evict; @@ -101,20 +136,20 @@ private DensityCache(int maxEntries, long ttlMillis) { /** * 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 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 + List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + String datasetId, + JpdfProvenance provenance ) { Objects.requireNonNull(xAxis, "xAxis"); Objects.requireNonNull(yAxis, "yAxis"); @@ -167,16 +202,18 @@ public GridDensityResult getOrCompute( * The cache will store a {@code null} provenance for this entry. */ public GridDensityResult getOrCompute( - List vectors, - AxisParams xAxis, - AxisParams yAxis, - GridSpec grid, - String datasetId + List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec grid, + String datasetId ) { return getOrCompute(vectors, xAxis, yAxis, grid, datasetId, null); } - /** Manual insert (replaces existing). */ + /** + * Manual insert (replaces existing). + */ public void put(String key, GridDensityResult value, JpdfProvenance provenance) { Objects.requireNonNull(key, "key"); Objects.requireNonNull(value, "value"); @@ -189,12 +226,16 @@ public void put(String key, GridDensityResult value, JpdfProvenance provenance) } } - /** Overload: manual insert without provenance. */ + /** + * 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. */ + /** + * Direct lookup by precomputed key; returns null if missing/expired. + */ public GridDensityResult get(String key) { lock.writeLock().lock(); // write: we may remove expired try { @@ -212,7 +253,9 @@ public GridDensityResult get(String key) { } } - /** Return provenance for a cached entry, or null. */ + /** + * Return provenance for a cached entry, or null. + */ public JpdfProvenance getProvenance(String key) { lock.readLock().lock(); try { @@ -224,32 +267,52 @@ public JpdfProvenance getProvenance(String key) { } } - /** Remove a specific key. */ + /** + * Remove a specific key. + */ public void invalidate(String key) { lock.writeLock().lock(); - try { map.remove(key); } - finally { lock.writeLock().unlock(); } + try { + map.remove(key); + } finally { + lock.writeLock().unlock(); + } } - /** Clear all entries. */ + /** + * Clear all entries. + */ public void clear() { lock.writeLock().lock(); - try { map.clear(); } - finally { lock.writeLock().unlock(); } + try { + map.clear(); + } finally { + lock.writeLock().unlock(); + } } - /** Snapshot of stats. */ + /** + * Snapshot of stats. + */ public Stats stats() { lock.readLock().lock(); - try { return new Stats(hits, misses, puts, evictions, expirations, map.size()); } - finally { lock.readLock().unlock(); } + try { + return new Stats(hits, misses, puts, evictions, expirations, map.size()); + } finally { + lock.readLock().unlock(); + } } - /** Current number of entries. */ + /** + * Current number of entries. + */ public int size() { lock.readLock().lock(); - try { return map.size(); } - finally { lock.readLock().unlock(); } + try { + return map.size(); + } finally { + lock.readLock().unlock(); + } } // ===================================================================================== @@ -266,15 +329,17 @@ public String makeKey(List vectors, GridSpec grid, String datasetId) { String ds = (datasetId != null && !datasetId.isBlank()) - ? datasetId - : fingerprintDataset(vectors, 256, 64); + ? datasetId + : fingerprintDataset(vectors, 256, 64); return "ds=" + ds + - "|x=" + axisKey(xAxis) + - "|y=" + axisKey(yAxis) + - "|g=" + gridKey(grid); + "|x=" + axisKey(xAxis) + + "|y=" + axisKey(yAxis) + + "|g=" + gridKey(grid); } - /** Fast, deterministic fingerprint of the dataset (not cryptographic). */ + /** + * 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(); @@ -322,10 +387,10 @@ private static String axisKey(AxisParams a) { 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()); + ",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) { @@ -334,7 +399,8 @@ private static String vecHash(List v) { 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 ^= bits; + h *= p; } h ^= v.size(); return Long.toUnsignedString(h, 36); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java index 4678ad19..bd5c59f5 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DialogUtils.java @@ -1,8 +1,5 @@ package edu.jhuapl.trinity.utils.statistics; -import java.util.ArrayList; -import java.util.List; -import java.util.StringJoiner; import javafx.geometry.Insets; import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; @@ -13,9 +10,13 @@ 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. @@ -26,11 +27,14 @@ */ public final class DialogUtils { - private DialogUtils() { } + private DialogUtils() { + } // ----------------- Vector Input ----------------- - /** Show a dialog that collects a single vector (no enforced dimension). */ + /** + * 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; @@ -61,15 +65,15 @@ public static List> showCustomVectorsDialog(Integer expectedDim) { 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"); + "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)")); + ? "No fixed dimension enforced." + : ("Expected dimension: " + expectedDim + " (rows will be padded/truncated)")); VBox content = new VBox(8, hint, textArea); content.setPadding(new Insets(10)); @@ -87,7 +91,7 @@ public static List> showCustomVectorsDialog(Integer expectedDim) { if (!parsed.warnings.isEmpty()) { showWarnings(parsed.warnings, "Vector Input Warnings", - "Some rows required padding/truncation or had invalid tokens."); + "Some rows required padding/truncation or had invalid tokens."); } return parsed.vectors.isEmpty() ? null : parsed.vectors; @@ -95,10 +99,12 @@ public static List> showCustomVectorsDialog(Integer expectedDim) { // ----------------- Scalars Input ----------------- - /** Result container for scalar inputs (score + optional info fraction). */ + /** + * 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 + public final List infos = new ArrayList<>(); // may be empty } /** @@ -113,16 +119,16 @@ public static ScalarInputResult showScalarSamplesDialog() { 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"); + "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); + new Label("Separators: comma, semicolon, or whitespace."), + textArea); content.setPadding(new Insets(10)); dialog.getDialogPane().setContent(content); @@ -217,8 +223,14 @@ private static ScalarInputResult parseScalarPairs(String raw) { 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) {} + 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."); @@ -241,7 +253,7 @@ private static ScalarInputResult parseScalarPairs(String raw) { if (!warnings.isEmpty()) { showWarnings(warnings, "Scalar Input Warnings", - "Some rows had invalid values or were clamped to [0,1]."); + "Some rows had invalid values or were clamped to [0,1]."); } return out; } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java index 5ea50fac..c5de2601 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java @@ -1,6 +1,7 @@ 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; @@ -16,30 +17,30 @@ * 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]. - * + * - 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. - * + * - 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. - * + * - 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. - * + * - 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) @@ -57,36 +58,57 @@ public enum DivergenceMetric { TV // Total variation distance in [0,1] } - /** Output bundle for a single divergence computation. */ + /** + * Output bundle for a single divergence computation. + */ public static final class DivergenceResult implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; - /** Symmetric divergence matrix (F x F). */ + /** + * 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]. */ + /** + * Optional quality matrix (F x F), e.g., min(coverageA, coverageB) fraction in [0,1]. + */ public final double[][] quality; - /** Chosen divergence metric. */ + /** + * Chosen divergence metric. + */ public final DivergenceMetric metric; - /** Selected component indices (length F). */ + /** + * Selected component indices (length F). + */ public final int[] componentIndices; - /** Human-friendly labels "Comp i", length F. */ + /** + * Human-friendly labels "Comp i", length F. + */ public final List labels; - /** Binning used for X and Y (from recipe). */ + /** + * Binning used for X and Y (from recipe). + */ public final int binsX; public final int binsY; - /** Bounds policy applied (from recipe). */ + /** + * Bounds policy applied (from recipe). + */ public final JpdfRecipe.BoundsPolicy boundsPolicy; - /** Canonical policy id (when boundsPolicy == CANONICAL_BY_FEATURE). */ + /** + * Canonical policy id (when boundsPolicy == CANONICAL_BY_FEATURE). + */ public final String canonicalPolicyId; - /** Whether cache was allowed (recipe-level flag; not a guarantee of hits). */ + /** + * Whether cache was allowed (recipe-level flag; not a guarantee of hits). + */ public final boolean cacheEnabled; public DivergenceResult(double[][] matrix, @@ -112,7 +134,8 @@ public DivergenceResult(double[][] matrix, } } - private DivergenceComputer() {} + private DivergenceComputer() { + } // --------------------------------------------------------------------- // Public API @@ -122,11 +145,11 @@ private DivergenceComputer() {} * 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 + List cohortA, + List cohortB, + JpdfRecipe recipe, + DivergenceMetric metric, + DensityCache cache ) { Objects.requireNonNull(recipe, "recipe"); int start = Math.max(0, recipe.getComponentIndexStart()); @@ -134,9 +157,9 @@ public static DivergenceResult computeForComponentRange( // Clamp to dimensionality if available int dimA = (cohortA == null || cohortA.isEmpty() || cohortA.get(0).getData() == null) - ? -1 : cohortA.get(0).getData().size(); + ? -1 : cohortA.get(0).getData().size(); int dimB = (cohortB == null || cohortB.isEmpty() || cohortB.get(0).getData() == null) - ? -1 : cohortB.get(0).getData().size(); + ? -1 : cohortB.get(0).getData().size(); int dim = Math.max(dimA, dimB); if (dim >= 0) end = Math.min(end, Math.max(0, dim - 1)); @@ -149,20 +172,20 @@ public static DivergenceResult computeForComponentRange( /** * Compute the divergence matrix for explicit component indices. * - * @param cohortA cohort A feature vectors - * @param cohortB cohort B feature vectors + * @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() + * @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 + List cohortA, + List cohortB, + List componentIndices, + JpdfRecipe recipe, + DivergenceMetric metric, + DensityCache cache ) { Objects.requireNonNull(cohortA, "cohortA"); Objects.requireNonNull(cohortB, "cohortB"); @@ -173,17 +196,17 @@ public static DivergenceResult computeForComponents( // 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()); + 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" + (recipe.getBoundsPolicy() == JpdfRecipe.BoundsPolicy.CANONICAL_BY_FEATURE) + ? recipe.getCanonicalPolicyId() + : "default" ); final boolean useCache = recipe.isCacheEnabled() && cache != null; @@ -231,11 +254,11 @@ public static DivergenceResult computeForComponents( // Compute aligned baselines using ABComparisonEngine ABComparisonEngine.AbResult ab = ABComparisonEngine.compare( - cohortA, cohortB, - x, y, - recipe, - useCache ? cache : null, - idA, idB + cohortA, cohortB, + x, y, + recipe, + useCache ? cache : null, + idA, idB ); // Convert PDFs to mass distributions (flatten) @@ -270,7 +293,9 @@ public static DivergenceResult computeForComponents( // Wait for all for (Future f : futures) { - try { f.get(); } catch (Exception e) { + try { + f.get(); + } catch (Exception e) { pool.shutdownNow(); throw new RuntimeException("DivergenceComputer: task failed", e); } @@ -278,11 +303,11 @@ public static DivergenceResult computeForComponents( pool.shutdown(); return new DivergenceResult( - D, Q, metric, - comps, labels, - recipe.getBinsX(), recipe.getBinsY(), - recipe.getBoundsPolicy(), recipe.getCanonicalPolicyId(), - recipe.isCacheEnabled() + D, Q, metric, + comps, labels, + recipe.getBinsX(), recipe.getBinsY(), + recipe.getBoundsPolicy(), recipe.getCanonicalPolicyId(), + recipe.isCacheEnabled() ); } @@ -290,7 +315,9 @@ public static DivergenceResult computeForComponents( // Internals // --------------------------------------------------------------------- - /** Flatten pdf grid to mass vector (z * dx * dy). */ + /** + * Flatten pdf grid to mass vector (z * dx * dy). + */ private static MassVec toMass(GridDensityResult r) { double[][] pdf = r.getPdfZ(); int by = pdf.length; @@ -310,10 +337,15 @@ private static MassVec toMass(GridDensityResult r) { private static final class MassVec { final double[] values; - MassVec(double[] v) { this.values = v; } + + MassVec(double[] v) { + this.values = v; + } } - /** Normalize a vector to sum=1 (if sum<=0, make uniform). */ + /** + * 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; @@ -338,7 +370,9 @@ private static double jsDivergence(double[] p, double[] q) { return 0.5 * d; // already base-2 } - /** KL term with base-2 logs, guarding for zeros. */ + /** + * 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); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java index 2f6649ac..bef0caf6 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/FeatureSimilarityComputer.java @@ -15,26 +15,28 @@ * 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) - * + *

+ * - 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 + * 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. - * + * 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). */ + /** + * Similarity metric options (aligned with JpdfRecipe.ScoreMetric names). + */ public enum SimilarityMetric { PEARSON, KENDALL, @@ -42,20 +44,31 @@ public enum SimilarityMetric { DIST_CORR } - /** Result bundle for a single cohort. */ + /** + * Result bundle for a single cohort. + */ public static final class Result implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; - /** Similarity matrix, size N x N. May contain NaN where insufficient. */ + /** + * 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. */ + /** + * 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". */ + /** + * Display labels for each component (length N), e.g., "Comp 7". + */ public final List labels; - /** Metadata for auditing and UI badges. */ + /** + * Metadata for auditing and UI badges. + */ public final Meta meta; public Result(double[][] sim, boolean[][] sufficient, List labels, Meta meta) { @@ -66,9 +79,12 @@ public Result(double[][] sim, boolean[][] sufficient, List labels, Meta } } - /** Metadata captured for the matrix computation. */ + /** + * Metadata captured for the matrix computation. + */ public static final class Meta implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; public final int startIndex; public final int endIndex; @@ -108,15 +124,15 @@ private FeatureSimilarityComputer() { /** * 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 + * @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, @@ -186,9 +202,9 @@ public static Result compute(List vectors, 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)); + 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); } @@ -208,9 +224,9 @@ private static Result emptyResult(int s, int e, 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)); + 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); } @@ -244,13 +260,15 @@ private static double pearson(double[] x, double[] y) { 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; + 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)); + 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; @@ -278,8 +296,14 @@ private static double kendallTauApprox(double[] x, double[] y, int maxN) { 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; } + if (dx == 0.0) { + tiesX++; + continue; + } + if (dy == 0.0) { + tiesY++; + continue; + } double prod = dx * dy; if (prod > 0) concordant++; @@ -287,7 +311,7 @@ private static double kendallTauApprox(double[] x, double[] y, int maxN) { } } double denom = Math.sqrt((concordant + discordant + tiesX) * 1.0) * - Math.sqrt((concordant + discordant + tiesY) * 1.0); + Math.sqrt((concordant + discordant + tiesY) * 1.0); if (denom == 0.0) return 0.0; return (concordant - discordant) / denom; } @@ -315,8 +339,10 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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 (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; @@ -337,8 +363,10 @@ private static double nmiLite(double[] x, double[] y, int 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; + 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; @@ -369,7 +397,8 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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; + if (nmi < 0.0) nmi = 0.0; + else if (nmi > 1.0) nmi = 1.0; return nmi; } @@ -399,7 +428,8 @@ private static double distCorr(double[] x, double[] y) { 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; + if (dcor < 0.0) dcor = 0.0; + else if (dcor > 1.0) dcor = 1.0; return dcor; } @@ -409,7 +439,8 @@ private static double[][] distanceMatrix(double[] v, int n) { 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; + d[i][j] = dij; + d[j][i] = dij; } } return d; diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java index 1dc562c2..64ea3e7d 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensity3DEngine.java @@ -11,19 +11,19 @@ /** * 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. - * + * - 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. + * - 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 */ @@ -44,10 +44,10 @@ private GridDensity3DEngine() { * @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 + List vectors, + AxisParams xAxis, + AxisParams yAxis, + GridSpec gridSpec ) { Objects.requireNonNull(xAxis, "xAxis"); Objects.requireNonNull(yAxis, "yAxis"); @@ -60,8 +60,8 @@ public static GridDensityResult computePdfCdf2D( // 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 + StatisticEngine.ScalarType.DIST_TO_MEAN, + StatisticEngine.ScalarType.COSINE_TO_MEAN ); if (needMean.contains(xAxis.getType()) || needMean.contains(yAxis.getType())) { meanVector = FeatureVector.getMeanVector(vectors); @@ -69,14 +69,14 @@ public static GridDensityResult computePdfCdf2D( Metric metricX = null; if (xAxis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN - && xAxis.getMetricName() != null - && xAxis.getReferenceVec() != null) { + && 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) { + && yAxis.getMetricName() != null + && yAxis.getReferenceVec() != null) { metricY = Metric.getMetric(yAxis.getMetricName()); } @@ -88,20 +88,20 @@ public static GridDensityResult computePdfCdf2D( for (int i = 0; i < n; i++) { FeatureVector fv = vectors.get(i); xs[i] = scalarValue( - fv, - xAxis.getType(), - meanVector, - metricX, - xAxis.getReferenceVec(), - xAxis.getComponentIndex() + fv, + xAxis.getType(), + meanVector, + metricX, + xAxis.getReferenceVec(), + xAxis.getComponentIndex() ); ys[i] = scalarValue( - fv, - yAxis.getType(), - meanVector, - metricY, - yAxis.getReferenceVec(), - yAxis.getComponentIndex() + fv, + yAxis.getType(), + meanVector, + metricY, + yAxis.getReferenceVec(), + yAxis.getComponentIndex() ); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java index faa16f43..d04a9fe1 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridDensityResult.java @@ -7,10 +7,10 @@ * Result container for 2D joint PDF/CDF surfaces. *

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

* Designed for use in Trinity’s 3D Hypersurface renderer. * @@ -44,16 +44,41 @@ public GridDensityResult(double[][] pdfZ, 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; } + 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). */ + /** + * Convert PDF grid to List> (row-major). + */ public List> pdfAsListGrid() { List> grid = new ArrayList<>(pdfZ.length); for (double[] row : pdfZ) { @@ -64,7 +89,9 @@ public List> pdfAsListGrid() { return grid; } - /** Convert CDF grid to List> (row-major). */ + /** + * Convert CDF grid to List> (row-major). + */ public List> cdfAsListGrid() { List> grid = new ArrayList<>(cdfZ.length); for (double[] row : cdfZ) { diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java index 5f644e24..47845fc5 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/GridSpec.java @@ -43,12 +43,29 @@ public GridSpec(int binsX, int binsY, Double minX, Double maxX, Double minY, Dou 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; } + 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 diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java index 449aadbd..03becde7 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/HeatmapThumbnailView.java @@ -1,12 +1,5 @@ package edu.jhuapl.trinity.utils.statistics; -import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; -import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - import javafx.beans.Observable; import javafx.geometry.Insets; import javafx.scene.canvas.Canvas; @@ -16,18 +9,28 @@ 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 + * - 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 } + public enum PaletteKind {SEQUENTIAL, DIVERGING} - /** Named color ramps. */ + /** + * Named color ramps. + */ public enum PaletteScheme { VIRIDIS, INFERNO, MAGMA, PLASMA, CIVIDIS, TURBO, BLUE_YELLOW, GREYS } @@ -59,7 +62,9 @@ public enum PaletteScheme { 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. */ + /** + * 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() { @@ -97,9 +102,15 @@ public void setFromGridDensity(GridDensityResult res, boolean useCDF, boolean fl setGrid(g); } - public void setFlipY(boolean flip) { this.flipY = flip; redraw(); } + public void setFlipY(boolean flip) { + this.flipY = flip; + redraw(); + } - public void useSequentialPalette() { this.palette = PaletteKind.SEQUENTIAL; redraw(); } + public void useSequentialPalette() { + this.palette = PaletteKind.SEQUENTIAL; + redraw(); + } public void useDivergingPalette(double center) { this.palette = PaletteKind.DIVERGING; @@ -107,14 +118,24 @@ public void useDivergingPalette(double center) { redraw(); } - /** Choose the ramp used for sequential mapping. */ + /** + * Choose the ramp used for sequential mapping. + */ public void setSequentialScheme(PaletteScheme scheme) { - if (scheme != null) { this.seqScheme = scheme; redraw(); } + if (scheme != null) { + this.seqScheme = scheme; + redraw(); + } } - /** Choose the base ramp used for diverging halves (low and high mirror). */ + /** + * Choose the base ramp used for diverging halves (low and high mirror). + */ public void setDivergingScheme(PaletteScheme scheme) { - if (scheme != null) { this.divScheme = scheme; redraw(); } + if (scheme != null) { + this.divScheme = scheme; + redraw(); + } } public void setShowLegend(boolean show) { @@ -144,9 +165,15 @@ public void setContentPadding(Insets insets) { redraw(); } - public void setBackgroundColor(Color c) { this.backgroundColor = (c == null ? Color.BLACK : c); 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 setImageSmoothing(boolean on) { + this.imageSmoothing = on; + redraw(); + } public void setGamma(double gamma) { double g = Double.isFinite(gamma) ? gamma : 1.0; @@ -161,25 +188,65 @@ public void setClipPercent(double lowPct, double highPct) { redraw(); } - /** Set alpha applied at the low end (vmin) after gamma shaping. */ + /** + * 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; } + 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 @@ -205,7 +272,10 @@ protected void layoutChildren() { super.layoutChildren(); } - private void onSize(Observable obs) { layoutChildren(); redraw(); } + private void onSize(Observable obs) { + layoutChildren(); + redraw(); + } private void recomputeRange() { if (grid == null || grid.isEmpty()) return; @@ -219,18 +289,26 @@ private void recomputeRange() { if (v != null && Double.isFinite(v)) vals.add(v); } } - if (vals.isEmpty()) { vmin = 0.0; vmax = 1.0; return; } + 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)); + 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 (!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; + vmin = lo; + vmax = hi; } private void redraw() { @@ -345,31 +423,43 @@ private Color mapValueToColor(double v, double min, double max, double span) { } } - /** Produces a color in the given scheme for 0..1 (low->high). */ + /** + * 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 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"); + default: + return multiStop(t, + "#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"); } } - /** For diverging high side: use a complementary/opposite end of the chosen scheme. */ + /** + * 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); @@ -379,7 +469,7 @@ 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 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); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java index c585db39..6d75d01e 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfBatchEngine.java @@ -1,6 +1,7 @@ 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; @@ -20,28 +21,32 @@ * 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). - * + * • 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 - * + * • 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. + * by the UI later; engine returns both so consumers can choose. */ public final class JpdfBatchEngine { - public JpdfBatchEngine() {} + public JpdfBatchEngine() { + } - /** Per-pair output bundle. */ + /** + * Per-pair output bundle. + */ public static final class PairJobResult implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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) @@ -77,9 +82,12 @@ public PairJobResult(int i, } } - /** Batch summary + outputs. */ + /** + * Batch summary + outputs. + */ public static final class BatchResult implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; public final String datasetFingerprint; public final String recipeName; @@ -116,7 +124,9 @@ public BatchResult(String datasetFingerprint, // Component-pairs mode // ===================================================================================== - /** Backwards-compatible entry (no live append). */ + /** + * Backwards-compatible entry (no live append). + */ public static BatchResult runComponentPairs(List vectors, JpdfRecipe recipe, CanonicalGridPolicy canonicalPolicy, @@ -124,7 +134,9 @@ public static BatchResult runComponentPairs(List vectors, return runComponentPairs(vectors, recipe, canonicalPolicy, cache, null); } - /** Live-append overload: calls onResult for each finished pair (may be null). */ + /** + * Live-append overload: calls onResult for each finished pair (may be null). + */ public static BatchResult runComponentPairs(List vectors, JpdfRecipe recipe, CanonicalGridPolicy canonicalPolicy, @@ -140,23 +152,23 @@ public static BatchResult runComponentPairs(List vectors, if (vectors.isEmpty() || !recipe.isComponentPairsMode()) { return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), - Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); + Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); } PairScorer.Config scorerCfg = PairScorer.Config.defaultFor( - recipe.getScoreMetric(), - recipe.getBinsX(), - recipe.getBinsY(), - recipe.getMinAvgCountPerCell() + recipe.getScoreMetric(), + recipe.getBinsX(), + recipe.getBinsY(), + recipe.getMinAvgCountPerCell() ); List candidates = PairScorer.scoreComponentPairs( - vectors, - recipe.getComponentIndexStart(), - recipe.getComponentIndexEnd(), - recipe.isIncludeSelfPairs(), - recipe.isOrderedPairs(), - scorerCfg + vectors, + recipe.getComponentIndexStart(), + recipe.getComponentIndexEnd(), + recipe.isIncludeSelfPairs(), + recipe.isOrderedPairs(), + scorerCfg ); List selected = switch (recipe.getPairSelection()) { @@ -180,7 +192,7 @@ public static BatchResult runComponentPairs(List vectors, if (recipe.getPairSelection() == JpdfRecipe.PairSelection.WHITELIST) { return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), - Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); + Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); } int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); @@ -208,12 +220,15 @@ public static BatchResult runComponentPairs(List vectors, for (int k = 0; k < submitted; k++) { Future f = ecs.take(); PairJobResult r = f.get(); - if (r.fromCache) cacheHits++; else computed++; + 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 */ } + try { + onResult.accept(r); + } catch (Throwable ignore) { /* isolate UI issues */ } } } } catch (Exception ex) { @@ -227,14 +242,16 @@ public static BatchResult runComponentPairs(List vectors, long wall = System.currentTimeMillis() - t0; return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), - out, wall, submitted, computed, cacheHits, cache.stats()); + out, wall, submitted, computed, cacheHits, cache.stats()); } // ===================================================================================== // Whitelist mode // ===================================================================================== - /** Backwards-compatible entry (no live append). */ + /** + * Backwards-compatible entry (no live append). + */ public static BatchResult runWhitelistPairs(List vectors, JpdfRecipe recipe, CanonicalGridPolicy canonicalPolicy, @@ -242,7 +259,9 @@ public static BatchResult runWhitelistPairs(List vectors, return runWhitelistPairs(vectors, recipe, canonicalPolicy, cache, null); } - /** Live-append overload: calls onResult for each finished pair (may be null). */ + /** + * Live-append overload: calls onResult for each finished pair (may be null). + */ public static BatchResult runWhitelistPairs(List vectors, JpdfRecipe recipe, CanonicalGridPolicy canonicalPolicy, @@ -257,10 +276,10 @@ public static BatchResult runWhitelistPairs(List vectors, final String dsFp = DensityCache.fingerprintDataset(vectors, 256, 64); if (vectors.isEmpty() - || recipe.getPairSelection() != JpdfRecipe.PairSelection.WHITELIST - || recipe.getExplicitAxisPairs().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()); + Collections.emptyList(), 0L, 0, 0, 0, cache.stats()); } int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); @@ -282,12 +301,15 @@ public static BatchResult runWhitelistPairs(List vectors, for (int k = 0; k < submitted; k++) { Future f = ecs.take(); PairJobResult r = f.get(); - if (r.fromCache) cacheHits++; else computed++; + 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 */ } + try { + onResult.accept(r); + } catch (Throwable ignore) { /* isolate UI issues */ } } } } catch (Exception ex) { @@ -299,7 +321,7 @@ public static BatchResult runWhitelistPairs(List vectors, long wall = System.currentTimeMillis() - t0; return new BatchResult(dsFp, recipe.getName(), canonicalPolicy.id(), - out, wall, submitted, computed, cacheHits, cache.stats()); + out, wall, submitted, computed, cacheHits, cache.stats()); } // ===================================================================================== @@ -360,15 +382,15 @@ public PairJobResult call() { long t2 = System.currentTimeMillis(); int ii = (x.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION && x.getComponentIndex() != null) - ? x.getComponentIndex() : -1; + ? x.getComponentIndex() : -1; int jj = (y.getType() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION && y.getComponentIndex() != null) - ? y.getComponentIndex() : -1; + ? 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); + res, rank, fromCache, Math.max(0, t2 - t1), prov); } } @@ -388,8 +410,10 @@ private static GridSpec resolveGrid(List vectors, 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); + 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); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java index 91994c9b..abe614f3 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfProvenance.java @@ -9,23 +9,26 @@ * 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 - * + * - 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; + @Serial + private static final long serialVersionUID = 1L; // ---------- Enums ---------- - /** High-level operation that produced the surface. */ + /** + * High-level operation that produced the surface. + */ public enum Operation { BASELINE, // a single cohort/slice MIXTURE, // weighted combination of baselines @@ -34,32 +37,41 @@ public enum Operation { TIME_WINDOW // produced from a time window / rolling window } - /** What the Z values represent. */ + /** + * 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. */ + /** + * 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). */ + /** + * 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. */ + /** + * Summary of a single axis' semantic definition. + */ public static final class AxisSummary implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; - public enum ReferenceKind { NONE, MEAN, VECTOR_AT_INDEX, CUSTOM } + public enum ReferenceKind {NONE, MEAN, VECTOR_AT_INDEX, CUSTOM} private final StatisticEngine.ScalarType scalarType; private final String metricName; // for METRIC_DISTANCE_TO_MEAN @@ -82,28 +94,49 @@ public AxisSummary(StatisticEngine.ScalarType scalarType, 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; } + public StatisticEngine.ScalarType scalarType() { + return scalarType; + } + + public String metricName() { + return metricName; + } - @Override public String toString() { + 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 + '\'' + - '}'; + "scalarType=" + scalarType + + ", metricName='" + metricName + '\'' + + ", componentIndex=" + componentIndex + + ", referenceKind=" + referenceKind + + ", referenceIndex=" + referenceIndex + + ", label='" + label + '\'' + + '}'; } } - /** Canonical grid & bounds. */ + /** + * Canonical grid & bounds. + */ public static final class GridSummary implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; private final int binsX, binsY; private final double minX, maxX, minY, maxY; @@ -116,43 +149,81 @@ public GridSummary(int binsX, int binsY, 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.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; } + 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; + } - @Override public String toString() { + 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 + '\'' + - '}'; + "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. */ + /** + * Data/quality/selection details useful for auditing & UI badges. + */ public static final class DataSummary implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; private final long nSamples; // number of (x,y) pairs used private final double minAvgCountPerCell; // guard from recipe @@ -175,28 +246,49 @@ public DataSummary(long nSamples, 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; } + 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; + } - @Override public String toString() { + public Integer selectionRank() { + return selectionRank; + } + + @Override + public String toString() { return "DataSummary{" + - "nSamples=" + nSamples + - ", minAvgCountPerCell=" + minAvgCountPerCell + - ", sufficiencyPass=" + sufficiencyPass + - ", scoreMetric=" + scoreMetric + - ", selectionScore=" + selectionScore + - ", selectionRank=" + selectionRank + - '}'; + "nSamples=" + nSamples + + ", minAvgCountPerCell=" + minAvgCountPerCell + + ", sufficiencyPass=" + sufficiencyPass + + ", scoreMetric=" + scoreMetric + + ", selectionScore=" + selectionScore + + ", selectionRank=" + selectionRank + + '}'; } } - /** Numeric hygiene checks for the produced surface. */ + /** + * Numeric hygiene checks for the produced surface. + */ public static final class NumericChecks implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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) @@ -211,20 +303,35 @@ public NumericChecks(Double pdfMass, Double cdfTerminal, Boolean cdfMonotoneXY, 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; } + public Double pdfMass() { + return pdfMass; + } + + public Double cdfTerminal() { + return cdfTerminal; + } + + public Boolean cdfMonotoneXY() { + return cdfMonotoneXY; + } + + public Double minZ() { + return minZ; + } - @Override public String toString() { + public Double maxZ() { + return maxZ; + } + + @Override + public String toString() { return "NumericChecks{" + - "pdfMass=" + pdfMass + - ", cdfTerminal=" + cdfTerminal + - ", cdfMonotoneXY=" + cdfMonotoneXY + - ", minZ=" + minZ + - ", maxZ=" + maxZ + - '}'; + "pdfMass=" + pdfMass + + ", cdfTerminal=" + cdfTerminal + + ", cdfMonotoneXY=" + cdfMonotoneXY + + ", minZ=" + minZ + + ", maxZ=" + maxZ + + '}'; } } @@ -302,80 +409,163 @@ private Builder(Operation op, SurfaceKind kind, AxisSummary x, AxisSummary y, Gr 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 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); } + 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; } + 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(); + .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(); + .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(); + .recipeName(recipeName) + .cohortLabels(cohortA, cohortB) + .build(); } - @Override public String toString() { + @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 + - '}'; + "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 index 0b2178f7..9f20b402 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/JpdfRecipe.java @@ -11,43 +11,56 @@ * 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). - * + * - 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. - * + * - 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). - * + * - 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; + @Serial + private static final long serialVersionUID = 1L; // ---------- Enums ---------- - /** How to choose which pairs are built. */ + /** + * How to choose which pairs are built. + */ public enum PairSelection { - /** Build all valid pairs from COMPONENT_AT_DIMENSION (respecting includeSelf/ordered flags). */ + /** + * Build all valid pairs from COMPONENT_AT_DIMENSION (respecting includeSelf/ordered flags). + */ ALL, - /** Only build the pairs explicitly provided in {@link #explicitAxisPairs}. */ + /** + * Only build the pairs explicitly provided in {@link #explicitAxisPairs}. + */ WHITELIST, - /** Pre-score all candidate pairs, then build the top-K by {@link #scoreMetric}. */ + /** + * 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}. */ + /** + * Pre-score all candidate pairs, then build all pairs >= {@link #scoreThreshold}. + */ THRESHOLD_BY_SCORE } - /** Which dependence score to use for preselection. */ + /** + * Which dependence score to use for preselection. + */ public enum ScoreMetric { PEARSON, KENDALL, @@ -55,18 +68,26 @@ public enum ScoreMetric { DIST_CORR // distance correlation (lite implementation) } - /** What to emit for each selected pair. */ + /** + * 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. */ + /** + * 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). */ + /** + * 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). */ + /** + * Lock to [0,1] x [0,1] (useful for probabilities/scores). + */ FIXED_01, /** * Ask CanonicalGridPolicy (by {@link #canonicalPolicyId}) to provide per-feature canonical bounds. @@ -82,7 +103,8 @@ public enum BoundsPolicy { * 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; + @Serial + private static final long serialVersionUID = 1L; private final AxisParams xAxis; private final AxisParams yAxis; @@ -90,8 +112,14 @@ 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; } + + public AxisParams xAxis() { + return xAxis; + } + + public AxisParams yAxis() { + return yAxis; + } } // ---------- Immutable fields ---------- @@ -221,100 +249,238 @@ public static final class Builder { private String cohortALabel = "A"; private String cohortBLabel = "B"; - private Builder(String name) { this.name = name; } + private Builder(String name) { + this.name = name; + } - public Builder description(String v) { this.description = v; return this; } + public Builder description(String v) { + this.description = v; + return this; + } + + public Builder pairSelection(PairSelection v) { + this.pairSelection = 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 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 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 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 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 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 minAvgCountPerCell(double v) { this.minAvgCountPerCell = v; return this; } + public Builder cacheEnabled(boolean v) { + this.cacheEnabled = 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 saveThumbnails(boolean v) { + this.saveThumbnails = v; + return this; + } - public Builder cohortLabels(String a, String b) { this.cohortALabel = a; this.cohortBLabel = b; return this; } + public Builder cohortLabels(String a, String b) { + this.cohortALabel = a; + this.cohortBLabel = b; + return this; + } - public JpdfRecipe build() { return new JpdfRecipe(this); } + public JpdfRecipe build() { + return new JpdfRecipe(this); + } } // ---------- Getters ---------- - public String getName() { return name; } - public String getDescription() { return description; } + public String getName() { + return name; + } - public PairSelection getPairSelection() { return pairSelection; } - public ScoreMetric getScoreMetric() { return scoreMetric; } - public int getTopK() { return topK; } - public double getScoreThreshold() { return scoreThreshold; } + public String getDescription() { + return description; + } - 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 PairSelection getPairSelection() { + return pairSelection; + } - public List getExplicitAxisPairs() { return explicitAxisPairs; } + public ScoreMetric getScoreMetric() { + return scoreMetric; + } - public int getBinsX() { return binsX; } - public int getBinsY() { return binsY; } - public BoundsPolicy getBoundsPolicy() { return boundsPolicy; } - public String getCanonicalPolicyId() { return canonicalPolicyId; } + public int getTopK() { + return topK; + } - public double getMinAvgCountPerCell() { return minAvgCountPerCell; } + public double getScoreThreshold() { + return scoreThreshold; + } - public OutputKind getOutputKind() { return outputKind; } - public boolean isCacheEnabled() { return cacheEnabled; } - public boolean isSaveThumbnails() { return saveThumbnails; } + public boolean isComponentPairsMode() { + return componentPairsMode; + } - public String getCohortALabel() { return cohortALabel; } - public String getCohortBLabel() { return cohortBLabel; } + 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() { + @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 + '\'' + - '}'; + "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 index 83ad7d35..a84374e4 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridPane.java @@ -1,14 +1,6 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.utils.ResourceUtils; -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; - import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -35,20 +27,28 @@ 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 + * - 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) --- @@ -63,7 +63,9 @@ 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. */ + /** + * Optional component indices for sort-by-i/j convenience; may be null. + */ public final Integer iIndex; public final Integer jIndex; @@ -130,20 +132,70 @@ private Builder(String xLabel, String yLabel) { this.yLabel = Objects.requireNonNull(yLabel, "yLabel"); } - public Builder grid(List> g) { this.grid = g; this.res = null; return this; } + 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; + 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 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) @@ -158,21 +210,32 @@ public static final class CellClick { 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; + this.index = index; + this.item = item; + this.xLabel = item.xLabel; + this.yLabel = item.yLabel; } } - /** Range struct for global/fixed usage. */ + /** + * 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 Range(double vmin, double vmax) { + this.vmin = vmin; + this.vmax = vmax; + } } - public enum RangeMode { AUTO, GLOBAL, FIXED } + public enum RangeMode {AUTO, GLOBAL, FIXED} - /** Captures grid-wide display preferences to apply to tiles. */ + /** + * Captures grid-wide display preferences to apply to tiles. + */ private static final class DisplayState { HeatmapThumbnailView.PaletteKind palette = HeatmapThumbnailView.PaletteKind.SEQUENTIAL; boolean legend = false; @@ -192,7 +255,9 @@ private static final class DisplayState { private final ScrollPane scroller = new ScrollPane(tilePane); private final StackPane contentStack = new StackPane(); - /** Optional header area shown above the grid (e.g., Search/Sort). */ + /** + * Optional header area shown above the grid (e.g., Search/Sort). + */ private Node headerNode = null; private VBox placeholder; @@ -222,8 +287,13 @@ private static final class DisplayState { 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; } + + public ExportRequest(PairItem item, WritableImage image) { + this.item = item; + this.image = image; + } } + private Consumer onExportRequest; public void setOnExportRequest(Consumer handler) { @@ -281,7 +351,9 @@ public PairGridPane() { // ---------- Header API ---------- - /** Optional header node above the grid (e.g., search/sort bar). Pass null to clear. */ + /** + * 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) { @@ -307,7 +379,9 @@ public void setItems(List items) { applyFilterSortAndRender(); } - /** Legacy add (rebuilds grid, respects filter/sort). */ + /** + * Legacy add (rebuilds grid, respects filter/sort). + */ public void addItem(PairItem item) { if (item != null) { allItems.add(item); @@ -319,10 +393,10 @@ public void addItem(PairItem item) { * 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 + * @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; @@ -384,14 +458,18 @@ public void sortByScoreDescending() { })); } - /** Preferred columns hint; wrapping still adapts to viewport width. */ + /** + * 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. */ + /** + * 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); @@ -481,11 +559,14 @@ public void setRangeModeFixed(double vmin, double 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; + display.fixedMin = mn; + display.fixedMax = mx; refreshExistingTiles(); } - /** Computes min/max across visible items’ data (grids or res). */ + /** + * 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) { @@ -494,12 +575,12 @@ public Range computeGlobalRange() { g = (it.useCDF ? it.res.cdfAsListGrid() : it.res.pdfAsListGrid()); } if (g == null) continue; - for (int r=0; r row = g.get(r); if (row == null) continue; - for (int c=0; c hi) hi = vv; } @@ -523,7 +604,9 @@ private void applyFilterSortAndRender() { renderTiles(); } - /** Recompute visibleItems with optional sort, then render. */ + /** + * Recompute visibleItems with optional sort, then render. + */ private void applyFilterSortAndRenderInternal(boolean doSort) { List tmp = new ArrayList<>(); for (PairItem it : allItems) { @@ -597,10 +680,10 @@ private Node buildCell(int index, PairItem item) { // 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) + Color.web("#3A3A3A"), + BorderStrokeStyle.SOLID, + new CornerRadii(6), + new BorderWidths(1.0) ))); cell.setBackground(null); @@ -681,7 +764,9 @@ private ContextMenu buildContextMenuForTile(HeatmapThumbnailView view, PairItem return cm; } - /** Apply current grid-wide display state to a specific view. */ + /** + * 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) { @@ -699,7 +784,7 @@ private void applyViewDisplay(HeatmapThumbnailView view, PairItem item) { // Range precedence: FIXED > GLOBAL > item-setting if (display.rangeMode == RangeMode.FIXED && - Double.isFinite(display.fixedMin) && Double.isFinite(display.fixedMax)) { + 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); @@ -713,7 +798,9 @@ private void applyViewDisplay(HeatmapThumbnailView view, PairItem item) { } } - /** Re-apply display state to all existing tiles. */ + /** + * Re-apply display state to all existing tiles. + */ private void refreshExistingTiles() { for (Node n : tilePane.getChildren()) { if (!(n instanceof BorderPane bp)) continue; @@ -739,8 +826,12 @@ private void recomputeGlobalIfNeeded() { this.globalRange = computeGlobalRange(); } } + // In PairGridPane (public helper) - public interface ViewConsumer { void accept(HeatmapThumbnailView v); } + public interface ViewConsumer { + void accept(HeatmapThumbnailView v); + } + public void forEachView(ViewConsumer fn) { for (Node n : tilePane.getChildren()) { if (!(n instanceof BorderPane bp)) continue; diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java index a52c962d..06ec7050 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairGridRecord.java @@ -10,41 +10,52 @@ * 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) - * + * - 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. + * - 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; + @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. */ + /** + * Optional PDF values; null if not requested. + */ private final List> pdf; - /** Optional CDF values; null if not requested. */ + /** + * Optional CDF values; null if not requested. + */ private final List> cdf; - /** Provenance for the PDF; null when pdf == null. */ + /** + * Provenance for the PDF; null when pdf == null. + */ private final JpdfProvenance pdfProvenance; - /** Provenance for the CDF; null when cdf == null. */ + /** + * 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). */ + /** + * Optional human label for this record (e.g., cohort name or composite tag). + */ private final String label; // ---------------------------- Constructors ---------------------------- @@ -81,11 +92,30 @@ private Builder(AxisParams xAxis, AxisParams yAxis, GridSpec grid) { 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 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); @@ -117,12 +147,12 @@ public static PairGridRecord fromSingle(AxisParams xAxis, } return PairGridRecord.newBuilder(xAxis, yAxis, grid) - .pdf(pdf) - .cdf(cdf) - .pdfProvenance(pdfProv) - .cdfProvenance(cdfProv) - .label(label) - .build(); + .pdf(pdf) + .cdf(cdf) + .pdfProvenance(pdfProv) + .cdfProvenance(cdfProv) + .label(label) + .build(); } /** @@ -140,12 +170,12 @@ public static Triple fromAb(ABCo GridSpec grid = ab.grid; PairGridRecord a = fromSingle( - xAxis, yAxis, grid, ab.a, ok, - ab.pdfProvA, ab.cdfProvA, labelA + 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 + xAxis, yAxis, grid, ab.b, ok, + ab.pdfProvB, ab.cdfProvB, labelB ); PairGridRecord diff = null; @@ -157,12 +187,12 @@ public static Triple fromAb(ABCo if ((pdf != null) || (cdf != null)) { diff = PairGridRecord.newBuilder(xAxis, yAxis, grid) - .pdf(pdf) - .cdf(cdf) - .pdfProvenance(ab.pdfProvDiff) - .cdfProvenance(ab.cdfProvDiff) - .label(labelDiff) - .build(); + .pdf(pdf) + .cdf(cdf) + .pdfProvenance(ab.pdfProvDiff) + .cdfProvenance(ab.cdfProvDiff) + .label(labelDiff) + .build(); } return new Triple<>(a, b, diff); @@ -170,14 +200,37 @@ public static Triple fromAb(ABCo // ---------------------------- 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; } + 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 ---------------------------- @@ -192,12 +245,16 @@ private static List> safeCopy2D(List> src) { * Tiny generic triple carrier (kept local to avoid extra dependencies). */ public static final class Triple implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @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; + 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 index 690a5a93..5c282940 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairScorer.java @@ -17,22 +17,22 @@ * 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] - * + * - 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. + * - 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 */ @@ -41,9 +41,12 @@ public final class PairScorer { // ----------- Result type ----------- public static final class PairScore implements Serializable, Comparable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; - /** For component-mode: i and j are component indices. For axis-mode: both are -1. */ + /** + * 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) @@ -52,19 +55,28 @@ public static final class PairScore implements Serializable, Comparable scoreComponentPairs( - List vectors, - int startInclusive, - int endInclusive, - boolean includeSelfPairs, - boolean orderedPairs, - Config cfg + List vectors, + int startInclusive, + int endInclusive, + boolean includeSelfPairs, + boolean orderedPairs, + Config cfg ) { Objects.requireNonNull(vectors, "vectors"); if (vectors.isEmpty()) return Collections.emptyList(); @@ -141,10 +154,10 @@ public static List scoreComponentPairs( double[] y = cols[jj]; PairScore ps = new PairScore(i, j, - scorePair(x, y, cfg), - nSamples, - suff, - suff ? null : insuffReason); + scorePair(x, y, cfg), + nSamples, + suff, + suff ? null : insuffReason); out.add(ps); } } @@ -157,10 +170,10 @@ public static List scoreComponentPairs( // ==================================================================================== public static PairScore scoreAxisPair( - List vectors, - AxisParams xAxis, - AxisParams yAxis, - Config cfg + 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"); @@ -203,8 +216,10 @@ private static double pearson(double[] x, double[] y) { 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; + sx += xi; + sy += yi; + sxx += xi * xi; + syy += yi * yi; sxy += xi * yi; } double num = n * sxy - sx * sy; @@ -235,8 +250,14 @@ private static double kendallTauApprox(double[] x, double[] y, int maxN) { 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; } + if (dx == 0) { + tiesX++; + continue; + } + if (dy == 0) { + tiesY++; + continue; + } double prod = dx * dy; if (prod > 0) concordant++; @@ -245,7 +266,7 @@ private static double kendallTauApprox(double[] x, double[] y, int maxN) { } } double denom = Math.sqrt((concordant + discordant + tiesX) * 1.0) * - Math.sqrt((concordant + discordant + tiesY) * 1.0); + Math.sqrt((concordant + discordant + tiesY) * 1.0); if (denom == 0) return 0.0; return (concordant - discordant) / denom; } @@ -273,9 +294,12 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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; + 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; @@ -297,8 +321,10 @@ private static double nmiLite(double[] x, double[] y, int 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; + 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; @@ -329,7 +355,8 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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; + if (nmi < 0) nmi = 0; + else if (nmi > 1) nmi = 1; return nmi; } @@ -359,7 +386,8 @@ private static double distCorr(double[] x, double[] y) { 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; + if (dcor < 0) dcor = 0; + else if (dcor > 1) dcor = 1; return dcor; } @@ -370,7 +398,8 @@ private static double[][] distanceMatrix(double[] v) { 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; + d[i][j] = dij; + d[j][i] = dij; } } return d; @@ -444,8 +473,8 @@ private static double[] extractAxisScalars(List vectors, AxisPara // Precompute mean if needed List meanVector = null; Set needMean = Set.of( - StatisticEngine.ScalarType.DIST_TO_MEAN, - StatisticEngine.ScalarType.COSINE_TO_MEAN + StatisticEngine.ScalarType.DIST_TO_MEAN, + StatisticEngine.ScalarType.COSINE_TO_MEAN ); if (needMean.contains(axis.getType())) { meanVector = FeatureVector.getMeanVector(vectors); @@ -453,8 +482,8 @@ private static double[] extractAxisScalars(List vectors, AxisPara Metric metric = null; if (axis.getType() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN - && axis.getMetricName() != null - && axis.getReferenceVec() != null) { + && axis.getMetricName() != null + && axis.getReferenceVec() != null) { metric = Metric.getMetric(axis.getMetricName()); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java index 30403fba..9a33f0f0 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseJpdfConfigPanel.java @@ -4,7 +4,6 @@ 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 java.util.function.Consumer; import javafx.geometry.Insets; import javafx.scene.control.Alert; import javafx.scene.control.Button; @@ -22,16 +21,18 @@ 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 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; @@ -172,16 +173,30 @@ public PairwiseJpdfConfigPanel() { 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; } + /** + * Expose Run button so the parent pane can place it in the top bar. + */ + public Button getRunButton() { + return runButton; + } - /** Set a callback to receive a fully-built JpdfRecipe when the user clicks Run. */ - public void setOnRun(Consumer onRun) { this.onRun = onRun; } + /** + * 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() { @@ -244,37 +259,56 @@ private VBox buildForm() { int r = 0; // General - g.add(compactLabel("Name"), 0, r); g.add(recipeNameField, 1, r++); + 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++); + 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 + 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++); + 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++); + 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 + 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++); + g.add(compactLabel("Whitelist (opt)"), 0, r); + g.add(whitelistArea, 1, r++); VBox root = new VBox(8, g); root.setPadding(new Insets(2)); @@ -333,19 +367,19 @@ private JpdfRecipe buildRecipeFromUI() { } 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()); + .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."); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java index 88b1386e..7e08dde4 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixConfigPanel.java @@ -3,8 +3,6 @@ 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 java.util.Objects; -import java.util.function.Consumer; import javafx.geometry.Insets; import javafx.scene.control.Alert; import javafx.scene.control.Button; @@ -21,24 +19,29 @@ 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) - * + * - 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 } + /** + * Execution mode. + */ + public enum Mode {SIMILARITY, DIVERGENCE} /** * Immutable request DTO produced by this panel. @@ -121,22 +124,90 @@ public static final class Builder { 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 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"); @@ -147,10 +218,10 @@ public Request build() { } // ---- 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 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; @@ -270,14 +341,33 @@ public PairwiseMatrixConfigPanel() { // ---- 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(); } + /** + * 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 ---- @@ -305,36 +395,56 @@ private VBox buildForm() { int r = 0; // General - g.add(compactLabel("Name"), 0, r); g.add(nameField, 1, r++); + 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++); + 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++); + 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++); + 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++); + 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++); + 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)); @@ -412,26 +522,26 @@ private Request buildRequestFromUI() { } 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(); + .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) { @@ -452,7 +562,9 @@ private static void widenCombo(ComboBox cb) { cb.setMaxWidth(FIELD_PREF_W); } - /** Reset fields to sensible defaults. */ + /** + * Reset fields to sensible defaults. + */ public void resetToDefaults() { nameField.clear(); modeCombo.setValue(Mode.SIMILARITY); diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java index 197be628..5b5f8fb3 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/PairwiseMatrixEngine.java @@ -15,51 +15,67 @@ * 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). - * + * - 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. - * + * - 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() { } + private PairwiseMatrixEngine() { + } // ------------------------------------------------------------------------------------ // Result DTO // ------------------------------------------------------------------------------------ - /** Immutable result bundle for a computed matrix (similarity or divergence). */ + /** + * Immutable result bundle for a computed matrix (similarity or divergence). + */ public static final class MatrixResult implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; - /** The primary N×N matrix (scores or divergences). */ + /** + * The primary N×N matrix (scores or divergences). + */ public final double[][] matrix; - /** Optional N×N quality matrix (same shape), may be null. */ + /** + * Optional N×N quality matrix (same shape), may be null. + */ public final double[][] quality; - /** Component indices (length N). */ + /** + * Component indices (length N). + */ public final int[] componentIndices; - /** Human-readable labels for rows/cols (length N). */ + /** + * Human-readable labels for rows/cols (length N). + */ public final List labels; - /** Legend label for UI (e.g., "Pearson |r|" or "JS divergence"). */ + /** + * Legend label for UI (e.g., "Pearson |r|" or "JS divergence"). + */ public final String legendLabel; - /** Suggested title (e.g., "Similarity: PEARSON" or "Divergence: JS"). */ + /** + * Suggested title (e.g., "Similarity: PEARSON" or "Divergence: JS"). + */ public final String title; private MatrixResult(double[][] matrix, @@ -93,14 +109,14 @@ public static MatrixResult of(double[][] matrix, /** * 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 + List cohort, + JpdfRecipe recipe, + boolean includeDiagonal ) { Objects.requireNonNull(cohort, "cohort"); Objects.requireNonNull(recipe, "recipe"); @@ -115,13 +131,13 @@ public static MatrixResult computeSimilarityMatrix( for (int i = start; i <= end; i++) compsList.add(i); return computeSimilarityMatrixForComponents( - cohort, - compsList, - recipe.getScoreMetric(), - recipe.getBinsX(), - recipe.getBinsY(), - recipe.getMinAvgCountPerCell(), - includeDiagonal + cohort, + compsList, + recipe.getScoreMetric(), + recipe.getBinsX(), + recipe.getBinsY(), + recipe.getMinAvgCountPerCell(), + includeDiagonal ); } @@ -129,13 +145,13 @@ public static MatrixResult computeSimilarityMatrix( * 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 + List cohort, + List componentIndices, + JpdfRecipe.ScoreMetric scoreMetric, + int binsX, + int binsY, + double minAvgCountPerCell, + boolean includeDiagonal ) { Objects.requireNonNull(cohort, "cohort"); Objects.requireNonNull(componentIndices, "componentIndices"); @@ -143,7 +159,7 @@ public static MatrixResult computeSimilarityMatrixForComponents( if (componentIndices.isEmpty()) { return MatrixResult.of(new double[0][0], new double[0][0], new int[0], List.of(), - legendForScore(scoreMetric), "Similarity: " + scoreMetric.name()); + legendForScore(scoreMetric), "Similarity: " + scoreMetric.name()); } // Build contiguous range if possible, otherwise map indices through a dense temporary space @@ -156,7 +172,7 @@ public static MatrixResult computeSimilarityMatrixForComponents( } 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()); + legendForScore(scoreMetric), "Similarity: " + scoreMetric.name()); } // Configure PairScorer (used only for scoring and sufficiency flags) @@ -164,12 +180,12 @@ public static MatrixResult computeSimilarityMatrixForComponents( // 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 cohortA, - List cohortB, - JpdfRecipe recipe, - DivergenceMetric metric, - DensityCache cache + List cohortA, + List cohortB, + JpdfRecipe recipe, + DivergenceMetric metric, + DensityCache cache ) { Objects.requireNonNull(cohortA, "cohortA"); Objects.requireNonNull(cohortB, "cohortB"); @@ -248,19 +264,19 @@ public static MatrixResult computeDivergenceMatrix( Objects.requireNonNull(metric, "metric"); DivergenceComputer.DivergenceResult dr = DivergenceComputer.computeForComponentRange( - cohortA, cohortB, recipe, metric, cache + 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() + dr.matrix, + dr.quality, + dr.componentIndices, + labels, + legendForDivergence(metric), + "Divergence: " + metric.name() ); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java index a745c66b..716155b1 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/RecipeIo.java @@ -3,6 +3,7 @@ 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; @@ -16,18 +17,23 @@ */ public final class RecipeIo { private static final ObjectMapper M = new ObjectMapper() - .enable(SerializationFeature.INDENT_OUTPUT) - .setSerializationInclusion(JsonInclude.Include.NON_NULL); + .enable(SerializationFeature.INDENT_OUTPUT) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); - private RecipeIo() {} + private RecipeIo() { + } - /** Write a recipe as JSON to the given stream. */ + /** + * 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. */ + /** + * Read a recipe from JSON stream. + */ public static JpdfRecipe read(InputStream is) throws IOException { RecipeDto dto = M.readValue(is, RecipeDto.class); return dto.toRecipe(); @@ -95,20 +101,20 @@ public static RecipeDto from(JpdfRecipe r) { 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)); + .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); @@ -119,10 +125,24 @@ public JpdfRecipe toRecipe() { } // 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; } + 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 index b219e36f..e190659a 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SimilarityComputer.java @@ -1,6 +1,7 @@ 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; @@ -15,64 +16,89 @@ * 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] - * + * - 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). - * + * - 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. - * + * - 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. + * - 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 + * @author Sean Phillips */ public final class SimilarityComputer { public enum Metric { - /** Normalized mutual information in [0,1] (symmetric, robust to monotone transforms roughly). */ + /** + * Normalized mutual information in [0,1] (symmetric, robust to monotone transforms roughly). + */ NMI, - /** Absolute Pearson correlation |r| in [0,1]. */ + /** + * Absolute Pearson correlation |r| in [0,1]. + */ PEARSON_ABS, - /** Distance correlation (biased), in [0,1]. */ + /** + * Distance correlation (biased), in [0,1]. + */ DIST_CORR } - /** Output bundle for a single similarity computation. */ + /** + * Output bundle for a single similarity computation. + */ public static final class SimilarityResult implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; - /** Symmetric matrix (F x F). Diagonal is 1.0 when defined. */ + /** + * 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]. */ + /** + * Optional quality matrix (F x F), e.g., fraction of finite pairs used. In [0,1]. + */ public final double[][] quality; - /** Chosen metric. */ + /** + * Chosen metric. + */ public final Metric metric; - /** Selected component indices (length F). */ + /** + * Selected component indices (length F). + */ public final int[] componentIndices; - /** Human-readable labels per feature (length F), e.g., "Comp 0", "Comp 1". */ + /** + * 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). */ + /** + * 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). */ + /** + * Effective bin count used by NMI (null when metric != NMI). + */ public final Integer nmiBins; - /** Guard threshold from recipe (for FYI / UI badges). */ + /** + * Guard threshold from recipe (for FYI / UI badges). + */ public final double minAvgCountPerCell; public SimilarityResult(double[][] matrix, @@ -94,7 +120,8 @@ public SimilarityResult(double[][] matrix, } } - private SimilarityComputer() {} + private SimilarityComputer() { + } // --------------------------------------------------------------------- // Public API @@ -110,9 +137,9 @@ private SimilarityComputer() {} * @return SimilarityResult containing the matrix and metadata. */ public static SimilarityResult computeForComponentRange( - List vectors, - JpdfRecipe recipe, - Metric metric + List vectors, + JpdfRecipe recipe, + Metric metric ) { Objects.requireNonNull(vectors, "vectors"); Objects.requireNonNull(recipe, "recipe"); @@ -134,17 +161,17 @@ public static SimilarityResult computeForComponentRange( /** * Compute a similarity matrix for an explicit list of component indices. * - * @param vectors feature vectors (rows) + * @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 + * @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 + List vectors, + List componentIndices, + JpdfRecipe recipe, + Metric metric ) { Objects.requireNonNull(vectors, "vectors"); Objects.requireNonNull(componentIndices, "componentIndices"); @@ -154,9 +181,9 @@ public static SimilarityResult computeForComponents( // 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()); + 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] @@ -177,8 +204,8 @@ public static SimilarityResult computeForComponents( // 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; + ? 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()); @@ -213,7 +240,9 @@ public static SimilarityResult computeForComponents( // Wait for (Future f : futures) { - try { f.get(); } catch (Exception e) { + try { + f.get(); + } catch (Exception e) { pool.shutdownNow(); throw new RuntimeException("SimilarityComputer: task failed", e); } @@ -226,14 +255,14 @@ public static SimilarityResult computeForComponents( // Return return new SimilarityResult( - M, - Q, - metric, - comps, - labels, - N, - (metric == Metric.NMI ? nmiBins : null), - recipe.getMinAvgCountPerCell() + M, + Q, + metric, + comps, + labels, + N, + (metric == Metric.NMI ? nmiBins : null), + recipe.getMinAvgCountPerCell() ); } @@ -241,7 +270,9 @@ public static SimilarityResult computeForComponents( // Internals // --------------------------------------------------------------------- - /** Extracts an array per selected component index: cols[k][row]. */ + /** + * 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(); @@ -251,22 +282,31 @@ private static double[][] extractColumns(List vectors, int[] comp 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; + ? row.get(idx) + : Double.NaN; cols[k][r] = v; } } return cols; } - /** Holder for filtered finite pairs. */ + /** + * 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; } + + PairXY(double[] x, double[] y) { + this.x = x; + this.y = y; + this.n = x.length; + } } - /** Remove any rows with non-finite x or y. */ + /** + * 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]; @@ -275,7 +315,9 @@ private static PairXY finitePairs(double[] x, double[] y) { 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++; + xx[m] = a; + yy[m] = b; + m++; } } if (m == n) return new PairXY(xx, yy); @@ -292,8 +334,10 @@ private static double pearson(double[] x, double[] y) { 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; + sx += xi; + sy += yi; + sxx += xi * xi; + syy += yi * yi; sxy += xi * yi; } double num = n * sxy - sx * sy; @@ -322,7 +366,8 @@ private static double distCorr(double[] x, double[] y) { 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; + if (dcor < 0) dcor = 0; + else if (dcor > 1) dcor = 1; return dcor; } @@ -333,7 +378,8 @@ private static double[][] distanceMatrix(double[] v) { 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; + d[i][j] = dij; + d[j][i] = dij; } } return d; @@ -386,8 +432,10 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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 (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; @@ -399,16 +447,23 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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; } + 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; + 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; @@ -433,7 +488,8 @@ private static double nmiLite(double[] x, double[] y, int bins) { 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; + if (nmi < 0) nmi = 0; + else if (nmi > 1) nmi = 1; return nmi; } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java index 2691ee8e..5c88ec25 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChart.java @@ -1,12 +1,6 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Consumer; - import javafx.application.Platform; import javafx.geometry.Point2D; import javafx.scene.Node; @@ -14,9 +8,15 @@ 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 } + public enum Mode {PDF_ONLY, CDF_ONLY} private StatisticEngine.ScalarType scalarType; private int bins; @@ -68,9 +68,17 @@ public BinSelection(int bin, double xCenter, double xFrom, double xTo, 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 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()); @@ -165,7 +173,10 @@ public void setSeriesStrokeWidth(double px) { } // ---- raw samples API ---- - /** Use precomputed scalar samples instead of deriving scalars from FeatureVectors. */ + + /** + * Use precomputed scalar samples instead of deriving scalars from FeatureVectors. + */ public void setScalarSamples(List samples) { if (samples == null || samples.isEmpty()) { this.scalarSamples = null; @@ -188,18 +199,53 @@ public void clearScalarSamples() { } // 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(); } + 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() { @@ -228,7 +274,7 @@ private void refresh() { Integer compIdx = (scalarType == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION) ? componentIndex : null; Map resultMap = - StatisticEngine.computeStatistics(vectors, types, bins, metricName, refVec, compIdx); + StatisticEngine.computeStatistics(vectors, types, bins, metricName, refVec, compIdx); plotFromStatistic(resultMap.get(scalarType)); } @@ -278,7 +324,7 @@ private void applyAxisLock() { } } - //interaction plumbing + //interaction plumbing private void attachPlotInteractions() { Platform.runLater(() -> { Node plotArea = lookup(".chart-plot-background"); @@ -333,8 +379,8 @@ private BinSelection selectionForX(double xVal) { double center = lastStat.getPdfBins()[b]; int count = lastStat.getBinCounts()[b]; double frac = (lastStat.getTotalSamples() > 0) - ? ((double) count) / lastStat.getTotalSamples() - : 0.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); } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java index 1b87a72c..c6c386e8 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatPdfCdfChartPanel.java @@ -3,13 +3,6 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.metric.Metric; import edu.jhuapl.trinity.utils.statistics.DialogUtils.ScalarInputResult; -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; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; @@ -36,12 +29,20 @@ 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 @@ -65,12 +66,13 @@ public class StatPdfCdfChartPanel extends BorderPane { private double tsMaxHeight = 280.0; // ===== Data source mode ===== - private enum DataSource { VECTORS, SCALARS } + 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 List scalarInfos = new ArrayList<>(); private String scalarField = "Score"; // "Score" or "Info%" // Axis lock @@ -96,7 +98,8 @@ private enum DataSource { VECTORS, SCALARS } private List customReferenceVector; // Time-series mode & aggregator - private enum TSMode { SIMILARITY, CONTRIBUTION, CUMULATIVE } + private enum TSMode {SIMILARITY, CONTRIBUTION, CUMULATIVE} + private TSMode tsMode = TSMode.CONTRIBUTION; private StatisticEngine.SimilarityAggregator agg = StatisticEngine.SimilarityAggregator.MEAN; private final double contribEps = 1e-6; @@ -119,118 +122,125 @@ private enum TSMode { SIMILARITY, CONTRIBUTION, CUMULATIVE } 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(); + 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("—"); }); + 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)); + 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); + // 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(); + // Make all three main sections share vertical space equally + enforceEqualSectionHeights(); - setTop(topBar); - setCenter(chartsBox); + 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); -} + 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() { @@ -317,229 +327,255 @@ private List getCurrentTSValues() { } // ===== 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) { + 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(); - } else { + }); + + 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(); - 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; + 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(); - 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(); + updateXIndexBoundsAndValue(); 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; -} + + // Populate labels panel with current state (if vectors already set) + rebuildLabelsMenu(); + + return mb; + } // ===== Public API ===== - public boolean isSurfaceCDF() { return surfaceCDF; } + public boolean isSurfaceCDF() { + return surfaceCDF; + } public String getYFeatureTypeForDisplay() { if (yFeatureCombo == null) return "N/A"; @@ -551,7 +587,9 @@ public String getYFeatureTypeForDisplay() { return (type != null) ? type.name() : "N/A"; } - /** Consumer invoked when "Compute 3D Surface" finishes successfully. */ + /** + * Consumer invoked when "Compute 3D Surface" finishes successfully. + */ public void setOnComputeSurface(Consumer handler) { this.onComputeSurface = handler; } @@ -586,11 +624,25 @@ public void addFeatureVectors(List newVectors) { } } - 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 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; @@ -643,7 +695,7 @@ public State exportState() { s.usingScalars = (dataSource == DataSource.SCALARS); s.scalarField = scalarField; s.scalarScores = new ArrayList<>(scalarScores); - s.scalarInfos = new ArrayList<>(scalarInfos); + s.scalarInfos = new ArrayList<>(scalarInfos); s.customRef = (customReferenceVector == null) ? null : new ArrayList<>(customReferenceVector); s.persistSelection = persistSelection; s.selectedLabels = new ArrayList<>(selectedLabels); @@ -673,7 +725,7 @@ public void applyState(State s) { 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<>(); + scalarInfos = (s.scalarInfos != null) ? new ArrayList<>(s.scalarInfos) : new ArrayList<>(); customReferenceVector = (s.customRef != null) ? new ArrayList<>(s.customRef) : null; persistSelection = s.persistSelection; @@ -725,7 +777,7 @@ private void applyScalarSamplesToCharts() { private void refresh2DCharts() { if (dataSource != DataSource.VECTORS) return; - if(null == pdfChart || null == cdfChart) return; + if (null == pdfChart || null == cdfChart) return; currentXFeature = xFeatureCombo.getValue(); currentBins = binsSpinner.getValue(); @@ -823,44 +875,45 @@ private void compute3DSurface() { } // ===== Enablement / bounds ===== -private void updateXControlEnablement() { - boolean isMetric = xFeatureCombo.getValue() == StatisticEngine.ScalarType.METRIC_DISTANCE_TO_MEAN; - boolean isComponent = xFeatureCombo.getValue() == StatisticEngine.ScalarType.COMPONENT_AT_DIMENSION; + 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); - } + 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. + // 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 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; @@ -919,53 +972,53 @@ private void rebuildLabelsFromCurrentVectors() { rebuildLabelsMenu(); } -private void rebuildLabelsMenu() { - if (labelsMenu == null || labelsBox == null) return; + private void rebuildLabelsMenu() { + if (labelsMenu == null || labelsBox == null) return; - labelsBox.getChildren().clear(); - labelChecks.clear(); + labelsBox.getChildren().clear(); + labelChecks.clear(); - if (allLabels.isEmpty()) { - labelsBox.getChildren().add(new Label("No labels found")); - return; - } + 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); - } + // 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); }); - labelChecks.put(lab, cb); - list.getChildren().add(cb); - } + bNone.setOnAction(e -> { + selectedLabels.clear(); + for (CheckBox cb : labelChecks.values()) cb.setSelected(false); + }); + bApply.setOnAction(e -> refresh2DCharts()); - 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); -} + labelsBox.getChildren().addAll(header, list); + } private List getFilteredVectors() { @@ -984,41 +1037,41 @@ private List getFilteredVectors() { // ===== 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; - } + 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); - } + 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(); -} + // Make sure the Top-K list reflects the current TS mode/series + refreshTopK(); + } private void wireChartInteractions() { diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java index 4ece7bb7..78ad2515 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatTimeSeriesChart.java @@ -16,7 +16,7 @@ * 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 { @@ -26,8 +26,11 @@ 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; + this.sampleIdx = sampleIdx; + this.x = x; + this.y = y; } } @@ -75,7 +78,9 @@ public StatTimeSeriesChart() { // ===== Public API ===== - /** Replace the entire series (x = sample index 0..N-1, y = values[i]). */ + /** + * 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(); @@ -105,15 +110,18 @@ public void highlightSamples(int[] indices) { /** * 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 + * @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. */ + /** + * Append highlights without removing existing ones. + */ public void addHighlights(int[] indices) { if (indices == null || currentValues.isEmpty()) return; @@ -134,20 +142,29 @@ public void addHighlights(int[] indices) { }); } - /** Clear any highlighted samples. */ + /** + * Clear any highlighted samples. + */ public void clearHighlights() { highlightSeries.getData().clear(); highlighted.clear(); } - /** Optional: customize axis labels. */ + /** + * 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; } + public void setOnPointHover(Consumer handler) { + this.onHover = handler; + } + + public void setOnPointClick(Consumer handler) { + this.onClick = handler; + } // ===== Internals ===== @@ -201,7 +218,7 @@ private void styleHighlightDots() { if (d.getNode() != null) { d.getNode().setStyle( "-fx-background-color: #00FF00AA; " + - "-fx-background-radius: 8px; -fx-padding: 6px;" + "-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 index 1f588162..90e4ee4a 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticEngine.java @@ -2,8 +2,6 @@ import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.utils.AnalysisUtils; -import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; -import static edu.jhuapl.trinity.utils.AnalysisUtils.clip; import edu.jhuapl.trinity.utils.metric.Metric; import java.util.ArrayList; @@ -12,15 +10,18 @@ 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}. - * + * 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 @@ -48,35 +49,41 @@ public enum SimilarityAggregator { MIN // strict: weakest dimension dominates } - /** Holder for contribution computation outputs. */ + /** + * 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; + 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 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 + List vectors, + Set selectedTypes, + int pdfBins, + String metricNameForGeneric, + List referenceVectorForGeneric ) { // Default to no component selection return computeStatistics(vectors, selectedTypes, pdfBins, metricNameForGeneric, - referenceVectorForGeneric, null); + referenceVectorForGeneric, null); } /** @@ -85,12 +92,12 @@ public static Map computeStatistics( * the component result will be omitted. */ public static Map computeStatistics( - List vectors, - Set selectedTypes, - int pdfBins, - String metricNameForGeneric, - List referenceVectorForGeneric, - Integer componentIndex + 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()) { @@ -105,8 +112,8 @@ public static Map computeStatistics( // 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); + .map(fv -> fv.getData().stream().mapToDouble(Double::doubleValue).toArray()) + .toArray(double[][]::new); double[][] pcaProjected = AnalysisUtils.doCommonsPCA(dataArr); List pc1Projections = new ArrayList<>(); @@ -160,11 +167,11 @@ public static Map computeStatistics( case MAX -> fv.getMax(); case MIN -> fv.getMin(); case DIST_TO_MEAN -> (meanVector != null) - ? AnalysisUtils.l2Norm(diffList(fv.getData(), meanVector)) - : 0.0; + ? AnalysisUtils.l2Norm(diffList(fv.getData(), meanVector)) + : 0.0; case COSINE_TO_MEAN -> (meanVector != null) - ? AnalysisUtils.cosineSimilarity(fv.getData(), meanVector) - : 0.0; + ? AnalysisUtils.cosineSimilarity(fv.getData(), meanVector) + : 0.0; default -> 0.0; }; scalars.add(value); @@ -173,10 +180,11 @@ public static Map computeStatistics( } 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. @@ -201,16 +209,17 @@ public static List cumulativeFromDeltas(List deltas) { /** * 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). + * + * @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 vectors, + SimilarityAggregator agg, + double[] weights, + double epsilon ) { List S = aggregateSimilaritySeries(vectors, agg, weights, epsilon); List L = logitSeries(S, epsilon); @@ -218,12 +227,14 @@ public static ContributionSeries computeContributions( return new ContributionSeries(S, L, D); } - /** Aggregate each FeatureVector to a single similarity S_t in (0,1). */ + /** + * 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 vectors, + SimilarityAggregator agg, + double[] weights, + double epsilon ) { List out = new ArrayList<>(); if (vectors == null || vectors.isEmpty()) return out; @@ -233,7 +244,10 @@ public static List aggregateSimilaritySeries( for (FeatureVector fv : vectors) { List z = fv.getData(); - if (z == null || z.isEmpty()) { out.add(0.5); continue; } + if (z == null || z.isEmpty()) { + out.add(0.5); + continue; + } double s; switch (agg != null ? agg : SimilarityAggregator.MEAN) { @@ -272,7 +286,9 @@ public static List aggregateSimilaritySeries( return out; } - /** Logit(L) for each S_t with clipping to avoid infinities. */ + /** + * 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; @@ -283,7 +299,9 @@ public static List logitSeries(List s, double epsilon) { return out; } - /** Δ_t = L_t - L_{t-1} with Δ_0 = L_0 (baseline probability 0.5 → log-odds 0). */ + /** + * Δ_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; @@ -314,7 +332,9 @@ public static StatisticResult buildStatResult(List scalars, int bins) { return sr; } - /** Internal container for histogram outputs. */ + /** + * Internal container for histogram outputs. + */ public static class PDFCDFResult { double[] bins; // bin centers double[] pdf; // density values @@ -326,13 +346,17 @@ public static class PDFCDFResult { int totalSamples; // number of valid samples counted PDFCDFResult( - double[] bins, double[] pdf, double[] cdf, - double[] binEdges, int[] sampleToBin, - int[] binCounts, int[][] binToSampleIdx, int totalSamples + 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.bins = bins; + this.pdf = pdf; + this.cdf = cdf; + this.binEdges = binEdges; + this.sampleToBin = sampleToBin; + this.binCounts = binCounts; + this.binToSampleIdx = binToSampleIdx; this.totalSamples = totalSamples; } } diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java index 8e6d3994..dede1bd1 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/StatisticResult.java @@ -4,17 +4,17 @@ /** * 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 + * - 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 { @@ -74,64 +74,88 @@ public void setCdf(double[] cdf) { this.cdf = cdf; } - /** @return bin edges array of length N+1 (may be null if not provided) */ + /** + * @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 */ + /** + * @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 */ + /** + * @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 */ + /** + * @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 */ + /** + * @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) */ + /** + * @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 */ + /** + * @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 */ + /** + * @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 */ + /** + * @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 */ + /** + * @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) */ + /** + * @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 */ + /** + * @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 index d792c4b7..07b935e0 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/SyntheticMatrixFactory.java @@ -1,7 +1,6 @@ package edu.jhuapl.trinity.utils.statistics; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import static edu.jhuapl.trinity.utils.AnalysisUtils.clamp01; import edu.jhuapl.trinity.utils.graph.MatrixToGraphAdapter; import java.io.Serial; @@ -10,42 +9,46 @@ 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) - * + * 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) - * + * - 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. + * - 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() {} + private SyntheticMatrixFactory() { + } // --------------------------------------------------------------------- // Result bundle // --------------------------------------------------------------------- public static final class SyntheticMatrix implements Serializable { - @Serial private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 1L; public final double[][] matrix; public final List labels; @@ -53,7 +56,9 @@ public static final class SyntheticMatrix implements Serializable { 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. */ + /** + * Optional cohorts (may be null). If present, PairwiseMatrixView can fall back to them for JPDF/ΔPDF. + */ public final List cohortA; public final List cohortB; @@ -81,14 +86,18 @@ public SyntheticMatrix(double[][] matrix, this.cohortB = cohortB; } - /** Return a copy of this SyntheticMatrix with cohorts attached (immutably). */ + /** + * 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); + 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). */ + /** + * 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); @@ -101,12 +110,13 @@ public SyntheticMatrix withTitle(String extra) { /** * 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 + * + * @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, @@ -159,7 +169,8 @@ public static SyntheticMatrix kClustersSimilarity(int[] sizes, 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; + S[i][j] = val; + S[j][i] = val; } } @@ -169,7 +180,7 @@ public static SyntheticMatrix kClustersSimilarity(int[] sizes, 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 + ")"; + + sizes.length + ", within≈" + withinSim + ", between≈" + betweenSim + ")"; return new SyntheticMatrix(S, labels, clusterIds, MatrixToGraphAdapter.MatrixKind.SIMILARITY, title); } @@ -179,10 +190,11 @@ public static SyntheticMatrix kClustersSimilarity(int[] sizes, /** * 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 + * + * @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]; @@ -201,7 +213,7 @@ public static SyntheticMatrix ringSimilarity(int n, double sigma, double jitter, symmetrize(S, true); normalize01(S, true); return new SyntheticMatrix(S, labels, null, MatrixToGraphAdapter.MatrixKind.SIMILARITY, - "Similarity: Ring (n=" + n + ", σ=" + sigma + ")"); + "Similarity: Ring (n=" + n + ", σ=" + sigma + ")"); } // --------------------------------------------------------------------- @@ -210,11 +222,12 @@ public static SyntheticMatrix ringSimilarity(int n, double sigma, double jitter, /** * 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 + * + * @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, @@ -276,11 +289,12 @@ public static SyntheticMatrix threeClustersDivergence(int[] sizes, /** * 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 + * + * @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); @@ -293,7 +307,9 @@ public static SyntheticMatrix gridSimilarity(int rows, int cols, double sigma, d 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++; + coords[t][0] = r; + coords[t][1] = c; + t++; } } @@ -311,7 +327,7 @@ public static SyntheticMatrix gridSimilarity(int rows, int cols, double sigma, d symmetrize(S, true); normalize01(S, true); return new SyntheticMatrix(S, labels, null, MatrixToGraphAdapter.MatrixKind.SIMILARITY, - "Similarity: Grid " + rows + "×" + cols + " (σ=" + sigma + ")"); + "Similarity: Grid " + rows + "×" + cols + " (σ=" + sigma + ")"); } // --------------------------------------------------------------------- @@ -320,8 +336,8 @@ public static SyntheticMatrix gridSimilarity(int rows, int cols, double sigma, d /** * Build two cohorts of FeatureVectors: - * - A: Gaussian(μ, σ) per component - * - B: Uniform[min,max] per component + * - 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, @@ -355,7 +371,11 @@ public static Cohorts makeCohorts_GaussianVsUniform(int N, int D, 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; } + + public Cohorts(List a, List b) { + this.cohortA = a; + this.cohortB = b; + } } // --------------------------------------------------------------------- diff --git a/src/main/resources/edu/jhuapl/trinity/css/styles.css b/src/main/resources/edu/jhuapl/trinity/css/styles.css index cbd72d09..f8415886 100644 --- a/src/main/resources/edu/jhuapl/trinity/css/styles.css +++ b/src/main/resources/edu/jhuapl/trinity/css/styles.css @@ -1663,41 +1663,42 @@ -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-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-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-font-size: 1.333333em; /* 16 */ -fx-text-fill: white; } /* hover */ .menu-button:hover { - -fx-border-color: #858585; /* -var-border_hover_color */ + -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-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-color: transparent, white; /* -var-focus_ring_border_color */ -fx-border-width: 1, 1; /*noinspection CssInvalidFunction*/ -fx-border-style: solid, segments(1, 2); @@ -1728,7 +1729,7 @@ /* 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 */ + -fx-padding: 0.2em 0.35em 0.2em 0.35em; /* subtle breathing room */ /* default triangle is fine; no custom shape required */ } diff --git a/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java index 7aede765..b9931bcc 100644 --- a/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java +++ b/src/test/java/edu/jhuapl/trinity/utils/statistics/StatisticsEngineTest.java @@ -1,21 +1,27 @@ 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 edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import edu.jhuapl.trinity.utils.AnalysisUtils; -import edu.jhuapl.trinity.utils.metric.Metric; - -import java.util.*; +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() { + + public static void randomGaussianTest() { // === 1. Generate synthetic data === int numVectors = 1000; int dim = 10; @@ -60,88 +66,93 @@ public static void randomGaussianTest() { // For dim=10: approx 1.538 (by order stats), but sample max ~ 2.0–2.3 is typical double approxMaxTheoretical = approxNormalMax(dim); - System.out.println("========= Theory vs. Empirical Spot Check ========="); - System.out.println("Dimension: " + dim + " Number of vectors: " + numVectors); - System.out.printf("Expected L2 norm: mean ≈ %.3f\n", normTheoreticalMean); - System.out.printf("Expected mean of vec: mean = %.3f std = %.3f\n", meanTheoreticalMean, meanTheoreticalStd); - System.out.printf("Expected max per vec: approx ≈ %.3f (see comments)\n", approxMaxTheoretical); - System.out.println(); + + 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); - System.out.println("---- " + type + " ----"); - System.out.printf("Sample min: %.4f\n", Collections.min(sr.getValues())); - System.out.printf("Sample max: %.4f\n", Collections.max(sr.getValues())); - System.out.printf("Sample mean: %.4f\n", empiricalMean); - System.out.printf("Sample std: %.4f\n", empiricalStd); + 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)); - System.out.println("First 5 PDF bins: " + Arrays.toString(Arrays.copyOf(sr.getPdfBins(), 5))); - System.out.println("First 5 PDF vals: " + Arrays.toString(Arrays.copyOf(sr.getPdf(), 5))); - System.out.println("First 5 CDF vals: " + Arrays.toString(Arrays.copyOf(sr.getCdf(), 5))); - System.out.println(); + 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 === - System.out.println("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); + LOG.info("If these match theory (see above), PDF/CDF logic is likely correct!"); + } - 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 + 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); + } - Map stats = - StatisticEngine.computeStatistics( - vectors, types, pdfBins, null, null + 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."); - double normPeak = Math.sqrt(dim); - - System.out.println("========= Bimodal (two-cluster) Test ========="); - System.out.println("Dimension: " + dim + " Number of vectors: " + numVectors); - System.out.println("Expected NORM: two peaks at 0 and " + normPeak); - System.out.println("Expected MEAN: two peaks at 0 and 1"); - System.out.println("Expected MAX: two peaks at 0 and 1\n"); - - 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())); - - System.out.println("---- " + type + " ----"); - System.out.printf("Unique value counts: %s\n", counts); - System.out.printf("Sample min: %.4f\n", Collections.min(sr.getValues())); - System.out.printf("Sample max: %.4f\n", Collections.max(sr.getValues())); - System.out.printf("Sample mean: %.4f\n", sr.getValues().stream().mapToDouble(Double::doubleValue).average().orElse(0.0)); - System.out.println("PDF: " + Arrays.toString(sr.getPdf())); - System.out.println("CDF: " + Arrays.toString(sr.getCdf())); - System.out.println(); } - System.out.println("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; @@ -156,4 +167,4 @@ private static double approxNormalMax(int n) { // Approx: sqrt(2*log(n)) return Math.sqrt(2 * Math.log(n)); } -} \ No newline at end of file +}