diff --git a/app/assets/js/zoomable_image.js b/app/assets/js/zoomable_image.js new file mode 100644 index 0000000..e5ccab7 --- /dev/null +++ b/app/assets/js/zoomable_image.js @@ -0,0 +1,107 @@ +/** + * @fileoverview This script handles zooming and panning functionality for an image within a container. + * It can be used as an -- admittedly hacky -- workaround to run custom JavaScript logic in Dash apps, + * including script that needs to run in response to an app.callback update. + * + * The `custom_js_trigger` element is used for communicating to the script from Dash updates that custom JS logic has to be run. + * When running an app.callback, the `title` attribute of `custom_js_trigger` can be updated (`Output("custom_js_trigger", "title")`) to trigger specific JS code. + * The mutation on the elements `custom_js_trigger` are observed to detect changes in the `data-dash-is-loading` attribute, which is set by Dash when an element is loading. + * The specific code that needs to run is determined by the value of the `title` attribute of the `custom_js_trigger` element. + * + * Because Dash is slow to add elements to the page as a result of API calls, + * the script s `waitForElement()` to wait for specific elements to be available in the DOM before attaching event listeners or running other logic on the element. + * (The DOMContentLoaded event is not sufficient because some elements load faster than others). + * + * JS packages can be loaded from CDN in main.py using the `external_scripts` argument of the app object. + */ + +/** + * Custom JS logic + */ + +// Script-wide variables used to communicate between scripts that await different elements to load. +let switching_to_other_alert = false; +let panzoomInstance = null; + +// Utility function to waits for an element to be available in the DOM before running JS code that depends on the element. +function waitForElement(selector) { + return new Promise((resolve) => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver((mutations) => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); +} + +// Observe mutations of the `custom_js_trigger` element to detect changes in the `data-dash-is-loading` and trigger custon JS as a response. +waitForElement("#custom_js_trigger").then((trigger) => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "data-dash-is-loading" + ) { + if (trigger.title == "reset_zoom") { + switching_to_other_alert = true; + } + } + }); + }); + + observer.observe(trigger, { + attributes: true, + }); +}); + +/** + * Custom JS scripts + */ + +// Initializes the Panzoom instance on the container and adjusts the bounding box styling on transform. +waitForElement("#image-container-with-bbox").then((container) => { + panzoomInstance = panzoom(container, { + bounds: true, + boundsPadding: 1, + minZoom: 1, + initialX: 0, + initialY: 0, + initialZoom: 1, + }); + + panzoomInstance.on("transform", (e) => { + const transform = panzoomInstance.getTransform(); + + const bbox = document.querySelector("#bbox-styling"); + if (bbox) { + const newThickness = 2 / transform.scale; + bbox.style.border = `${newThickness}px solid red`; + } + + if (transform.scale === 1) { + container.style.transform = ""; + } + }); + + // Resets the zoom level when the main image is loaded. + waitForElement("#main-image").then((image) => { + image.onload = () => { + // Waiting for image to load avoids flicker whereby old image is zoomed out before new image is loaded + if (switching_to_other_alert) { + switching_to_other_alert = false; + panzoomInstance.moveTo(0, 0); + panzoomInstance.zoomAbs(0, 0, 1); + } + }; + }); +}); diff --git a/app/callbacks/display_callbacks.py b/app/callbacks/display_callbacks.py index 35db6d4..c12ddda 100644 --- a/app/callbacks/display_callbacks.py +++ b/app/callbacks/display_callbacks.py @@ -3,16 +3,13 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. - import ast import json -from typing import List import dash import logging_config import numpy as np import pandas as pd -from dash import html from dash.dependencies import ALL, Input, Output, State from dash.exceptions import PreventUpdate from main import app @@ -70,6 +67,7 @@ def update_event_list(api_alerts, to_acknowledge): Output({"type": "event-button", "index": ALL}, "style"), Output("event_id_on_display", "data"), Output("auto-move-button", "n_clicks"), + Output("custom_js_trigger", "title"), ], [ Input({"type": "event-button", "index": ALL}, "n_clicks"), @@ -100,7 +98,7 @@ def select_event_with_button(n_clicks, button_ids, local_alerts, event_id_on_dis local_alerts, alerts_data_loaded = read_stored_DataFrame(local_alerts) if len(local_alerts) == 0: - return [[], 0, 1] + return [[], 0, 1, "reset_zoom"] if not alerts_data_loaded: raise PreventUpdate @@ -140,7 +138,7 @@ def select_event_with_button(n_clicks, button_ids, local_alerts, event_id_on_dis }, ) # Default style - return [styles, button_index, 1] + return [styles, button_index, 1, "reset_zoom"] # Get event_id data @@ -184,8 +182,8 @@ def update_display_data(event_id_on_display, local_alerts): @app.callback( [ - Output("image-container", "children"), # Output for the image - Output("bbox-container", "children"), # Output for the bounding box + Output("main-image", "src"), # Output for the image + Output("bbox-positioning", "style"), Output("image-slider", "max"), ], [Input("image-slider", "value"), Input("alert_on_display", "data")], @@ -209,19 +207,14 @@ def update_image_and_bbox(slider_value, alert_data, alert_list): - int: Maximum value for the image slider. """ img_src = "" - bbox_style = {} - bbox_divs: List[html.Div] = [] # This will contain the bounding box as an html.Div + bbox_style = {"display": "none"} # Default style for the bounding box alert_data, data_loaded = read_stored_DataFrame(alert_data) if not data_loaded: raise PreventUpdate if len(alert_list) == 0: - img_html = html.Img( - src="./assets/images/no-alert-default.png", - className="common-style", - style={"width": "100%", "height": "auto"}, - ) - return img_html, bbox_divs, 0 + img_src = "./assets/images/no-alert-default.png" + return img_src, bbox_style, 0 # Filter images with non-empty URLs images, boxes = zip( @@ -233,12 +226,8 @@ def update_image_and_bbox(slider_value, alert_data, alert_list): ) if not images: - img_html = html.Img( - src="./assets/images/no-alert-default.png", - className="common-style", - style={"width": "100%", "height": "auto"}, - ) - return img_html, bbox_divs, 0 + img_src = "./assets/images/no-alert-default.png" + return img_src, bbox_style, 0 # Ensure slider_value is within the range of available images slider_value = slider_value % len(images) @@ -259,16 +248,10 @@ def update_image_and_bbox(slider_value, alert_data, alert_list): "top": f"{y0}%", # Top position based on image height "width": f"{width}%", # Width based on image width "height": f"{height}%", # Height based on image height - "border": "2px solid red", - "zIndex": "10", + "display": "block", } - # Create a div that represents the bounding box - bbox_div = html.Div(style=bbox_style) - bbox_divs.append(bbox_div) - - img_html = html.Img(src=img_src, className="common-style", style={"width": "100%", "height": "auto"}) - return img_html, bbox_divs, len(images) - 1 + return img_src, bbox_style, len(images) - 1 @app.callback( diff --git a/app/layouts/main_layout.py b/app/layouts/main_layout.py index 7940ca2..d583cb1 100644 --- a/app/layouts/main_layout.py +++ b/app/layouts/main_layout.py @@ -29,6 +29,12 @@ def get_main_layout(): return html.Div( [ dcc.Location(id="url", refresh=False), + html.Div( + id="custom_js_trigger", + className="custom_js_trigger", + title="none", + style={"display": "none"}, + ), html.Div( [ Navbar(), # This includes the navbar at the top of the page diff --git a/app/main.py b/app/main.py index 1376ee6..ccbe27d 100644 --- a/app/main.py +++ b/app/main.py @@ -30,7 +30,11 @@ logger.info(f"Sentry middleware enabled on server {cfg.SERVER_NAME}") # We start by instantiating the app -app = dash.Dash(__name__, external_stylesheets=[dbc.themes.UNITED]) +app = dash.Dash( + __name__, + external_stylesheets=[dbc.themes.UNITED], + external_scripts=["https://unpkg.com/panzoom@9.4.0/dist/panzoom.min.js"], +) # We define a few attributes of the app object app.title = "Pyronear - Monitoring platform" diff --git a/app/pages/homepage.py b/app/pages/homepage.py index 6a767bb..2286409 100644 --- a/app/pages/homepage.py +++ b/app/pages/homepage.py @@ -23,20 +23,48 @@ def homepage_layout(user_headers, user_credentials): dbc.Col( [ html.Div( - id="image-container-with-bbox", - style={"position": "relative"}, + id="zoom-containement-container", + className="common-style", + style={"overflow": "hidden"}, children=[ html.Div( - id="image-container", + id="image-container-with-bbox", + style={"position": "relative"}, children=[ - html.Img( - src="./assets/images/no-alert-default.png", - className="common-style", - style={"max-width": "100%", "height": "auto"}, - ) + html.Div( + id="image-container", + children=[ + html.Img( + id="main-image", + src="./assets/images/no-alert-default.png", + className="zoomable-image", + style={"maxWidth": "100%", "height": "auto"}, + ) + ], + ), + html.Div( + id="bbox-container", + style={"display": "block"}, + children=[ + html.Div( + id="bbox-positioning", + style={"display": "none"}, + children=[ + html.Div( + id="bbox-styling", + style={ + "border": "2px solid red", + "height": "100%", + "width": "100%", + "zIndex": "10", + }, + ), + ], + ) + ], + ), ], ), - html.Div(id="bbox-container", style={"display": "block"}), ], ), html.Div(