Skip to content

Commit

Permalink
Zoom on alert image (#169)
Browse files Browse the repository at this point in the history
* WIP

#144

* Progress towards triggering custom js + application to zoom reset

* Add doc

* More clean up

* Format/lint

* More formatting/linting

* Remove unused className

* PR comment
  • Loading branch information
vanderlindenma authored Nov 7, 2024
1 parent 20816f6 commit a0e3bbb
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 39 deletions.
107 changes: 107 additions & 0 deletions app/assets/js/zoomable_image.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
});
});
41 changes: 12 additions & 29 deletions app/callbacks/display_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@
# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> 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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")],
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions app/layouts/main_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 37 additions & 9 deletions app/pages/homepage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit a0e3bbb

Please sign in to comment.