Skip to content

Commit

Permalink
Merge branch 'main' into rs/new-data-model-migration
Browse files Browse the repository at this point in the history
  • Loading branch information
RonanMorgan committed Dec 17, 2024
2 parents 21816e7 + 483aa5d commit ca02e30
Show file tree
Hide file tree
Showing 11 changed files with 394 additions and 69 deletions.
Binary file added app/assets/images/no-alert-default-es.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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);
}
};
});
});
87 changes: 79 additions & 8 deletions app/callbacks/data_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,66 @@
[
Output("client_token", "data"),
Output("form_feedback_area", "children"),
Output("username_input", "style"),
Output("password_input", "style"),
Output("send_form_button", "style"),
Output("form_feedback_area", "style"),
Output("loading_spinner", "style"),
],
Input("send_form_button", "n_clicks"),
[
State("username_input", "value"),
State("password_input", "value"),
State("client_token", "data"),
State("user_headers", "data"),
State("language", "data"),
],
)
def login_callback(n_clicks, username, password, client_token):
def login_callback(n_clicks, username, password, client_token, lang):
"""
Callback to handle user login.
Parameters:
n_clicks (int): Number of times the login button has been clicked.
username (str or None): The value entered in the username input field.
password (str or None): The value entered in the password input field.
user_headers (dict or None): Existing user headers, if any, containing authentication details.
This function is triggered when the login button is clicked. It verifies the provided username and password,
attempts to authenticate the user via the API, and updates the user credentials and headers.
If authentication fails or credentials are missing, it provides appropriate feedback.
After login succeeds and while data required to boot the dashboard is being fetched from the API,
the login form is hidden and a spinner is displayed.
Returns:
dash.dependencies.Output: Updated user credentials and headers, and form feedback + styles to hide/show login elements and loading spinners.
"""
input_style_unchanged = {"width": "250px"}
empty_style_unchanged = {"": ""}
hide_element_style = {"display": "none"}
show_spinner_style = {"transform": "scale(4)"}

translate = {
"fr": {
"missing_password_or_user_name": "Il semble qu'il manque votre nom d'utilisateur et/ou votre mot de passe.",
"wrong_credentials": "Nom d'utilisateur et/ou mot de passe erroné.",
},
"es": {
"missing_password_or_user_name": "Parece que falta su nombre de usuario y/o su contraseña.",
"wrong_credentials": "Nombre de usuario y/o contraseña incorrectos.",
},
}

if client_token is not None:
return dash.no_update, dash.no_update, dash.no_update
return (
dash.no_update,
dash.no_update,
dash.no_update,
input_style_unchanged,
input_style_unchanged,
empty_style_unchanged,
empty_style_unchanged,
hide_element_style,
)

if n_clicks:
# We instantiate the form feedback output
Expand All @@ -46,10 +95,19 @@ def login_callback(n_clicks, username, password, client_token):
# If either the username or the password is missing, the condition is verified

# We add the appropriate feedback
form_feedback.append(html.P("Il semble qu'il manque votre nom d'utilisateur et/ou votre mot de passe."))
form_feedback.append(html.P(translate[lang]["missing_password_or_user_name"]))

# The login modal remains open; other outputs are updated with arbitrary values
return dash.no_update, dash.no_update, form_feedback
return (
dash.no_update,
dash.no_update,
form_feedback,
input_style_unchanged,
input_style_unchanged,
empty_style_unchanged,
empty_style_unchanged,
hide_element_style,
)
else:
# This is the route of the API that we are going to use for the credential check
try:
Expand All @@ -58,13 +116,26 @@ def login_callback(n_clicks, username, password, client_token):
return (
client.token,
dash.no_update,
hide_element_style,
hide_element_style,
hide_element_style,
hide_element_style,
show_spinner_style,
)
except Exception:
# This if statement is verified if credentials are invalid
form_feedback.append(html.P("Nom d'utilisateur et/ou mot de passe erroné."))

return dash.no_update, form_feedback
form_feedback.append(html.P(translate[lang]["wrong_credentials"]))

return (
dash.no_update,
dash.no_update,
form_feedback,
input_style_unchanged,
input_style_unchanged,
empty_style_unchanged,
empty_style_unchanged,
hide_element_style,
)
raise PreventUpdate


Expand Down
40 changes: 22 additions & 18 deletions app/callbacks/display_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
import ast
import json
from typing import List

import dash
from dash import html
import logging_config
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 @@ -104,6 +103,7 @@ def update_wildfire_list(store_wildfires_data, to_acknowledge, media_url):
Output({"type": "wildfire-button", "index": ALL}, "style"),
Output("wildfire_id_on_display", "data"),
Output("auto-move-button", "n_clicks"),
Output("custom_js_trigger", "title"),
],
[
Input({"type": "wildfire-button", "index": ALL}, "n_clicks"),
Expand Down Expand Up @@ -139,7 +139,7 @@ def select_wildfire_with_button(

local_detections, detections_data_loaded = read_stored_DataFrame(local_detections)
if len(local_detections) == 0:
return [[], 0, 1]
return [[], 0, 1, "reset_zoom"]

if not detections_data_loaded:
raise PreventUpdate
Expand Down Expand Up @@ -195,7 +195,7 @@ def select_wildfire_with_button(
},
) # Default style

return [styles, button_index, 1]
return [styles, button_index, 1, "reset_zoom"]


# Get wildfire_id data
Expand Down Expand Up @@ -242,18 +242,19 @@ def update_display_data(wildfire_id_on_display, local_detections, store_wildfire

@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("detection_on_display", "data")],
[
State("media_url", "data"),
State("wildfire-list-container", "children"),
State("language", "data"),
],
prevent_initial_call=True,
)
def update_image_and_bbox(slider_value, detection_data, media_url, wildfire_list):
def update_image_and_bbox(slider_value, detection_data, media_url, wildfire_list, lang):
"""
Updates the image and bounding box display based on the slider value.
Expand All @@ -273,9 +274,13 @@ def update_image_and_bbox(slider_value, detection_data, media_url, wildfire_list
if not data_loaded:
raise PreventUpdate

no_alert_image_src = "./assets/images/no-alert-default.png"
if lang == "es":
no_alert_image_src = "./assets/images/no-alert-default-es.png"

if len(wildfire_list) == 0:
img_html = html.Img(
src="./assets/images/no-alert-default.png",
src=no_alert_image_src,
className="common-style",
style={"width": "100%", "height": "auto"},
)
Expand All @@ -290,6 +295,7 @@ def update_image_and_bbox(slider_value, detection_data, media_url, wildfire_list
images.append(media_url[str(detection["id"])])
boxes = detection_data["processed_loc"].tolist()


if slider_value < len(images):
img_src = images[slider_value]
images_bbox_list = boxes[slider_value]
Expand All @@ -309,12 +315,7 @@ def update_image_and_bbox(slider_value, detection_data, media_url, wildfire_list
"zIndex": "10",
}

# 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 Expand Up @@ -462,10 +463,10 @@ def update_download_link(slider_value, wildfire_id_on_display, store_wildfires_d
Output("map", "center"),
Output("vision_polygons-md", "children"),
Output("map-md", "center"),
Output("alert-camera", "children"),
Output("alert-location", "children"),
Output("alert-azimuth", "children"),
Output("alert-date", "children"),
Output("alert-camera-value", "children"),
Output("alert-location-value", "children"),
Output("alert-azimuth-value", "children"),
Output("alert-date-value", "children"),
Output("alert-information", "style"),
Output("slider-container", "style"),
],
Expand Down Expand Up @@ -596,6 +597,9 @@ def acknowledge_wildfire(n_clicks, store_wildfires_data, wildfire_id_on_display,
for detection_id in detection_ids_list:
Client(client_token, cfg.API_URL).label_detection(detection_id=detection_id, is_wildfire=False)

if cfg.SAFE_DEV_MODE == "True":
raise PreventUpdate

return wildfire_id_on_display


Expand Down
17 changes: 9 additions & 8 deletions app/components/navbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@


def Navbar():
buttons_container = html.Div(
# children=[home_button, alert_screen_button],
className="ml-auto",
style={"display": "flex"},
)

navbar = dbc.Navbar(
[
dbc.Row(
Expand All @@ -25,13 +19,20 @@ def Navbar():
],
align="center",
),
dbc.NavbarToggler(id="navbar-toggler"),
dbc.Collapse(buttons_container, id="navbar-collapse", navbar=True),
html.Div(
className="ml-auto",
style={"display": "flex", "flexDirection": "row", "gap": "10px", "marginRight": "10px"},
children=[
dbc.Button(["🇫🇷", " FR"], href="/fr", color="light", className="mr-2"),
dbc.Button(["🇪🇸", " ES"], href="/es", color="light"),
],
),
],
id="main_navbar",
color="#044448",
dark=True,
className="mb-4",
style={"display": "flex", "justify-content": "space-between"},
)

return navbar
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
SENTRY_DSN: Optional[str] = os.getenv("SENTRY_DSN")
SERVER_NAME: Optional[str] = os.getenv("SERVER_NAME")

# Safeguards
SAFE_DEV_MODE: Optional[str] = os.getenv("SAFE_DEV_MODE")

# App config variables
MAX_ALERTS_PER_EVENT = 10
CAM_OPENING_ANGLE = 87
Expand Down
Loading

0 comments on commit ca02e30

Please sign in to comment.