diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/AppController.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/AppController.java index c7a8fc27..87fbd614 100644 --- a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/AppController.java +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/AppController.java @@ -48,13 +48,14 @@ public class AppController { private final MediaPlayers mediaPlayers; private final Logger log = LoggerFactory.getLogger(getClass()); private final FileChooser fileChooser = new FileChooser(); - private final LocalizationLifecycleController localizationLifecycleController; + // Disabled ZeroMQ for https://github.com/mbari-media-management/vars-annotation/issues/148 +// private final LocalizationLifecycleController localizationLifecycleController; public AppController(UIToolBox toolBox) { this.toolBox = toolBox; alerts = new Alerts(toolBox); mediaPlayers = new MediaPlayers(toolBox); - localizationLifecycleController = new LocalizationLifecycleController(toolBox); +// localizationLifecycleController = new LocalizationLifecycleController(toolBox); initialize(); } diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/cbpanel/ConceptButtonPanesController.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/cbpanel/ConceptButtonPanesController.java index 48a73ae2..39b2be45 100644 --- a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/cbpanel/ConceptButtonPanesController.java +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/cbpanel/ConceptButtonPanesController.java @@ -13,6 +13,7 @@ import javafx.scene.text.Text; import org.mbari.vars.core.util.Preconditions; import org.mbari.vars.ui.UIToolBox; +import org.mbari.vars.ui.javafx.timeline.TimelineController; import org.mbari.vars.ui.messages.*; import org.mbari.vars.ui.javafx.Icons; import org.mbari.vars.services.model.User; @@ -49,12 +50,14 @@ public class ConceptButtonPanesController { private Logger log = LoggerFactory.getLogger(getClass()); private BooleanProperty lockProperty = new SimpleBooleanProperty(false); private final ConceptButtonPanesWithHighlightController overviewController; + private final TimelineController timelineController; public ConceptButtonPanesController(UIToolBox toolBox) { Preconditions.checkNotNull(toolBox, "The UIToolbox arg can not be null"); this.toolBox = toolBox; this.i18n = toolBox.getI18nBundle(); overviewController = new ConceptButtonPanesWithHighlightController(toolBox); + timelineController = new TimelineController(toolBox); // add listener to Data.user. When changed remove all panes and reload toolBox.getData() .userProperty() @@ -136,8 +139,25 @@ private VBox getControlPane() { } }); + Button timelineButton = new JFXButton(); + Text timelineIcon = Icons.TIMELINE.standardSize(); + String timelineLabel = i18n.getString("cppanel.tabpane.timeline.label"); + Tab timelineTab = new Tab(timelineLabel, timelineController.getRoot()); + timelineButton.setGraphic(timelineIcon); + timelineButton.setTooltip(new Tooltip(i18n.getString("cppanel.tabpane.timeline.tooltip"))); + timelineButton.setOnAction(e -> { + ObservableList tabs = getTabPane().getTabs(); + if (tabs.contains(timelineTab)) { + tabs.remove(timelineTab); + } + else { + tabs.add(timelineTab); + getTabPane().getSelectionModel().select(timelineTab); + } + }); + // COntrol Pane - controlPane = new VBox(addButton, removeButton, lockButton, overviewButton); + controlPane = new VBox(addButton, removeButton, lockButton, overviewButton, timelineButton); } return controlPane; } diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/mlstage/MachineLearningStageController.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/mlstage/MachineLearningStageController.java index 4bdae31c..abb939d5 100644 --- a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/mlstage/MachineLearningStageController.java +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/mlstage/MachineLearningStageController.java @@ -66,6 +66,7 @@ private void init() { log.atDebug().log("Created " + locView.size() + " Localization UI objects"); machineLearningStage.setLocalizations(locView); machineLearningStage.show(); + }); } else { machineLearningStage.setLocalizations(Collections.emptyList()); @@ -130,7 +131,7 @@ public void analyze2() throws IOException { var mlService = new OkHttpMegalodonService(mlRemoteUrlOpt.get()); try { var mlImageInference = MLAnalysisService.analyzeCurrentElapsedTime(toolBox, mlService); - inference.set(mlImageInference); + Platform.runLater(() -> inference.set(mlImageInference)); } catch (Exception e) { var i18n = toolBox.getI18nBundle(); diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/roweditor/AnnotationEditorPaneController.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/roweditor/AnnotationEditorPaneController.java index 58d889bd..724932a1 100644 --- a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/roweditor/AnnotationEditorPaneController.java +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/roweditor/AnnotationEditorPaneController.java @@ -175,6 +175,14 @@ void initialize() { } } }); + conceptComboBox.focusedProperty().addListener((obs, oldv, newv) -> { + if (newv) { + conceptComboBox.setStyle("-fx-background-color: #5D3D3D"); + } + else { + conceptComboBox.setStyle(null); + } + }); loadComboBoxData(); // If the cache is cleared reload combobox data diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/DisplayedAnnotation.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/DisplayedAnnotation.java new file mode 100644 index 00000000..1072cfcc --- /dev/null +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/DisplayedAnnotation.java @@ -0,0 +1,114 @@ +package org.mbari.vars.ui.javafx.timeline; + +import javafx.application.Platform; +import javafx.beans.binding.DoubleBinding; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Line; +import org.mbari.vars.core.EventBus; +import org.mbari.vars.services.model.Annotation; +import org.mbari.vars.ui.events.AnnotationsSelectedEvent; +import org.mbari.vars.ui.util.ColorUtil; + +import java.util.List; + +record DisplayedAnnotation(Annotation annotation, Label label, Line line) { + + + public void addTo(Pane parent, + Line horizontalAxis, + DoubleBinding distanceBetweenMinutes, + EventBus eventBus) { + if (annotation.getElapsedTime() == null) { + return; + } + + Platform.runLater(() -> { + var minutes = annotation.getElapsedTime().toMillis() / 1000.0 / 60.0; + var xProp = distanceBetweenMinutes.multiply(minutes).add(TimelineController.OFFSET); + + // --- LABEL + var firstLetter = annotation.getConcept().toUpperCase().charAt(0); + var charCode = (int) firstLetter; + var shortName = firstLetter + ""; + + label.getStylesheets().clear(); + label.setText(shortName); + label.layoutXProperty().bind(xProp.subtract(label.widthProperty().divide(2))); + var incremProp = parent.heightProperty() + .subtract(TimelineController.OFFSET * 2) + .subtract(horizontalAxis.startYProperty()) + .divide(26) + .multiply(charCode - 65) + .add(horizontalAxis.startYProperty()); + label.layoutYProperty().bind(incremProp); + + var fillHex = ColorUtil.stringToHexColor(annotation.getConcept(), 0.7); + var fill = Color.web(fillHex, 0.7); +// label.setTextFill(fill); + + + label.setStyle("-fx-font-weight: bold; -fx-font-size: 16px; -fx-text-fill: " + fillHex + ";"); + + + label.setOnMouseClicked(evt -> { + var e = new AnnotationsSelectedEvent(DisplayedAnnotation.class, List.of(annotation)); + eventBus.send(e); + }); + + if (annotation.getRecordedTimestamp() != null) { + label.setTooltip(new Tooltip(annotation.getConcept() + " at " + annotation.getRecordedTimestamp())); + } + else { + label.setTooltip(new Tooltip(annotation.getConcept() + " at " + minutes + " minutes")); + } + + // -- LINE + line.startXProperty().bind(xProp); + line.endXProperty().bind(xProp); + line.startYProperty().bind(horizontalAxis.startYProperty()); + line.endYProperty().bind(label.layoutYProperty()); + + var lightStroke = new Color(fill.getRed(), fill.getGreen(), fill.getBlue(), 0.15); + var heavyStroke = new Color(fill.getRed(), fill.getGreen(), fill.getBlue(), 1); + var heavyStrokeHex = ColorUtil.toHex(heavyStroke); + line.setStroke(lightStroke); + + label.setOnMouseEntered(evt -> Platform.runLater(() -> { + line.setStroke(heavyStroke); + line.setStrokeWidth(3); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 18px; -fx-text-fill: " + heavyStrokeHex + ";"); + label.setText(annotation.getConcept()); + })); + + label.setOnMouseExited(evt -> Platform.runLater(() -> { + line.setStroke(lightStroke); + line.setStrokeWidth(3); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 16px; -fx-text-fill: " + fillHex + ";"); + label.setText(shortName); + })); + + parent.getChildren().addAll(line, label); + }); + + } + + public void removeFrom(Pane parent) { + Platform.runLater(() -> { + label.layoutXProperty().unbind(); + label.layoutYProperty().unbind(); + line.startXProperty().unbind(); + line.startYProperty().unbind(); + line.endXProperty().unbind(); + line.endYProperty().unbind(); + label.setOnMouseEntered(e -> {}); + label.setOnMouseExited(e -> {}); + label.setOnMouseClicked(e -> {}); + parent.getChildren().removeAll(label, line); + parent.requestLayout(); + }); + + } +} diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/TickMark.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/TickMark.java new file mode 100644 index 00000000..66266253 --- /dev/null +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/TickMark.java @@ -0,0 +1,43 @@ +package org.mbari.vars.ui.javafx.timeline; + +import javafx.application.Platform; +import javafx.beans.binding.DoubleBinding; +import javafx.scene.control.Label; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Line; + +record TickMark(int minute, Line tick, Label label) { + + public void addTo(Pane parent, Line horizontalAxis, DoubleBinding distanceBetweenMinutes) { + Platform.runLater(() -> { + var offset = TimelineController.OFFSET; + tick.startXProperty().bind(distanceBetweenMinutes.multiply(minute).add(offset)); + tick.endXProperty().bind(tick.startXProperty()); + + tick.startYProperty().bind(horizontalAxis.startYProperty().subtract(offset)); + tick.endYProperty().bind(horizontalAxis.startYProperty().add(offset)); + tick.setStyle("-fx-stroke: #B3A9A3; -fx-stroke-width: 2px;"); + + var label = new Label("" + minute); + label.setTextFill(Color.valueOf("#B3A9A3")); + label.layoutXProperty().bind(tick.startXProperty().subtract(label.widthProperty().divide(2))); + label.layoutYProperty().bind(tick.startYProperty().subtract(offset * 2)); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 18px;"); + parent.getChildren().addAll(tick, label); + }); + } + + public void removeFrom(Pane parent) { + Platform.runLater(() -> { + parent.getChildren().removeAll(tick, label); + tick.startXProperty().unbind(); + tick.endXProperty().unbind(); + tick.startYProperty().unbind(); + tick.endYProperty().unbind(); + label.layoutXProperty().unbind(); + label.layoutYProperty().unbind(); + }); + + } +} diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/Timeline.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/Timeline.java new file mode 100644 index 00000000..e95e5056 --- /dev/null +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/Timeline.java @@ -0,0 +1,18 @@ +package org.mbari.vars.ui.javafx.timeline; + +import javafx.beans.binding.DoubleBinding; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Line; +import org.mbari.vars.services.model.Media; + +import java.util.List; + +record Timeline(Media media, List tickMarks) { + public void addTo(Pane parent, Line horizontalAxis, DoubleBinding distanceBetweenMinutes) { + tickMarks.forEach(t -> t.addTo(parent, horizontalAxis, distanceBetweenMinutes)); + } + + public void removeFrom(Pane parent) { + tickMarks.forEach(t -> t.removeFrom(parent)); + } +} diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/TimelineController.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/TimelineController.java new file mode 100644 index 00000000..82af9f3e --- /dev/null +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/javafx/timeline/TimelineController.java @@ -0,0 +1,122 @@ +package org.mbari.vars.ui.javafx.timeline; + +import javafx.application.Platform; +import javafx.beans.binding.DoubleBinding; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.Label; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Line; +import org.mbari.vars.services.model.Annotation; +import org.mbari.vars.services.model.Media; +import org.mbari.vars.ui.UIToolBox; +import org.mbari.vars.ui.events.AnnotationsAddedEvent; +import org.mbari.vars.ui.events.AnnotationsChangedEvent; +import org.mbari.vars.ui.events.AnnotationsRemovedEvent; +import org.mbari.vars.ui.events.MediaChangedEvent; + +import java.util.ArrayList; +import java.util.List; + +public class TimelineController { + + public static final int OFFSET = 10; + + + private final UIToolBox toolBox; + private Pane root; + DoubleProperty endXProperty = new SimpleDoubleProperty(); + DoubleProperty numberOfMinutesProperty = new SimpleDoubleProperty(); + DoubleBinding distanceBetweenMinutesProperty; + Line horizontalAxis; + List displayedAnno = new ArrayList<>(); + private Timeline timeline; + + public TimelineController(UIToolBox toolBox) { + this.toolBox = toolBox; + init(); + var eventBus = toolBox.getEventBus().toObserverable(); + + eventBus.ofType(MediaChangedEvent.class) + .subscribe(e -> setMedia(e.get())); + + eventBus.ofType(AnnotationsAddedEvent.class) + .subscribe(e -> e.get().forEach(this::addAnnotation)); + + eventBus.ofType(AnnotationsRemovedEvent.class) + .subscribe(e -> e.get().forEach(this::removeAnnotation)); + + eventBus.ofType(AnnotationsChangedEvent.class) + .subscribe(e -> e.get().forEach(a -> { + removeAnnotation(a); + addAnnotation(a); + })); + + } + + private void init() { + root = new Pane(); + endXProperty.bind(root.widthProperty().subtract(OFFSET)); + horizontalAxis = new Line(OFFSET, OFFSET * 3, endXProperty.get(), OFFSET * 3); + horizontalAxis.endXProperty().bind(endXProperty); + horizontalAxis.setStyle("-fx-stroke: #B3A9A3; -fx-stroke-width: 2px;"); + root.getChildren().add(horizontalAxis); + distanceBetweenMinutesProperty = horizontalAxis.endXProperty() + .subtract(horizontalAxis.startXProperty()) + .divide(numberOfMinutesProperty); + + } + + public Pane getRoot() { + return root; + } + + private void addAnnotation(Annotation a) { + Platform.runLater(() -> { + var da = new DisplayedAnnotation(a, new Label(), new Line()); + displayedAnno.add(da); + da.addTo(root, horizontalAxis, distanceBetweenMinutesProperty, toolBox.getEventBus()); + }); + } + + private void removeAnnotation(Annotation a) { + Platform.runLater(() -> { + for (int i = 0; i < displayedAnno.size(); i++) { + var d = displayedAnno.get(i); + if (d.annotation().getObservationUuid().equals(a.getObservationUuid())) { + displayedAnno.remove(i); + d.removeFrom(root); + break; + } + } + }); + } + + + + private void setMedia(Media media) { + displayedAnno.forEach(d -> d.removeFrom(root)); + displayedAnno.clear(); + if (media == null || media.getDuration() == null) { + if (timeline != null) { + timeline.removeFrom(root); + } + } + else { + var numberOfMinutes = Math.ceil(media.getDuration().toMillis() / 1000D / 60D); + numberOfMinutesProperty.set(numberOfMinutes); + + var tickMarks = new ArrayList(); + for (int i = 0; i <= numberOfMinutes; i++) { + tickMarks.add(new TickMark(i, new Line(), new Label())); + } + timeline = new Timeline(media, tickMarks); + timeline.addTo(root, horizontalAxis, distanceBetweenMinutesProperty); + } + } + + +} diff --git a/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/util/ColorUtil.java b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/util/ColorUtil.java new file mode 100644 index 00000000..aa3ca57a --- /dev/null +++ b/org.mbari.vars.ui/src/main/java/org/mbari/vars/ui/util/ColorUtil.java @@ -0,0 +1,65 @@ +package org.mbari.vars.ui.util; + +import javafx.scene.paint.Color; + +public class ColorUtil { + + private ColorUtil() { + } + + public static String toHex(int r, int g, int b) { + return String.format("#%02x%02x%02x", r, g, b); + } + + public static String toHex(int r, int g, int b, int a) { + return String.format("#%02x%02x%02x%02x", r, g, b, a); + } + + public static String toHex(Color color) { + return toHex(asInt(color.getRed()), asInt(color.getGreen()), asInt(color.getBlue()), asInt(color.getOpacity())); + } + + public static String toHex(Color color, double opacity) { + return toHex(asInt(color.getRed()), asInt(color.getGreen()), asInt(color.getBlue()), asInt(opacity)); + } + + public static String stringToHexColor(String s) { + var hash = s.hashCode(); + var color = intToRGBA(hash); + return toHex(color); + } + + public static String stringToHexColor(String s, double opacity) { + var hash = s.hashCode(); + var color = intToRGBA(hash); + return toHex(color, opacity); + } + + public static Color stringToColor(String s) { + var hash = s.hashCode(); + return intToRGBA(hash); + } + + private static Color intToRGBA(int i) { + var a = brighten((i >> 24) & 0xff, 128); + var r = brighten((i >> 16) & 0xff, 32); + var g = brighten((i >> 8) & 0xff, 32); + var b = brighten(i & 0xff, 32); + return new Color(r / 255D, g / 255D, b / 255D, a / 255D); + } + + private static int asInt(double color) { + return (int) Math.round(color * 255); + } + + public static int brighten(int color, int min) { + if (min > 255) { + min = 255; + } + else if (min < 0) { + min = 0; + } + return (int) Math.round(((color + min) / (255D + min)) * 255); + } + +} \ No newline at end of file diff --git a/org.mbari.vars.ui/src/main/resources/i18n.properties b/org.mbari.vars.ui/src/main/resources/i18n.properties index 1ca7a2ec..dcf94a20 100644 --- a/org.mbari.vars.ui/src/main/resources/i18n.properties +++ b/org.mbari.vars.ui/src/main/resources/i18n.properties @@ -184,6 +184,8 @@ cppanel.tabpane.remove.preferror.header=Preference error cppanel.tabpane.remove.preferror.title=VARS - Error cppanel.tabpane.remove.title=VARS - Remove a tab cppanel.tabpane.remove.tooltip=Remove current tab +cppanel.tabpane.timeline.label=TIMELINE +cppanel.tabpane.timeline.tooltip=Toggle timeline tab filebrowsing.dialog.title=Select Video File filebrowsing.open.missing.content=The video you are opening does not exist in the video asset manager filebrowsing.open.missing.header=Unable to find matching video by name