diff --git a/.gitignore b/.gitignore index 34c23ff..87cf194 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,9 @@ venv.bak/ .spyderproject .spyproject +# Pycharm project settings +.idea/ + # Rope project settings .ropeproject diff --git a/app/alert_screen.py b/app/alert_screen.py index 7150de5..e2684a7 100644 --- a/app/alert_screen.py +++ b/app/alert_screen.py @@ -4,8 +4,8 @@ # See LICENSE or go to for full license details. """ -The following Python file is dedicated to the big screen of the web application. -The big screen corresponds to a web page which will be displayed on a big screen in the CODIS room. There will be no +The following Python file is dedicated to the alert screen of the web application. +The alert screen corresponds to a web page which will be displayed on a big screen in the CODIS room. There will be no interaction with the user. The main use of this page is to display a "sober" screen when there are no alerts. When an alert pops out, the screen will automatically change to display various information. @@ -20,34 +20,225 @@ import dash_core_components as dcc import dash_html_components as html -# From navbar.py to add the navigation bar at the top of the page -from navbar import Navbar -# Importing alerts map builder from alerts.py -from alerts import build_alerts_map +# ---------------------------------------------------------------------------------------------------------------------- +# CONTENT +def build_no_alert_detected_screen(): + """ + The following function builds the no alert screen. + """ + # background image as style + style = { + "backgroundImage": 'url("/assets/pyro_alert_off.png")', + "backgroundRepeat": "no-repeat", + "backgroundPosition": "center", + "backgroundSize": "cover", + "position": "fixed", + "height": "100%", + "width": "100%", + } -# Importing risks map and opacity slider builders from risks.py -from risks import build_risks_map, build_opacity_slider + return style -# Importing plotly fig objects from graphs.py -from graphs import generate_meteo_fig -# Importing layer style button builder and fetched API data from utils.py -from utils import build_layer_style_button, build_live_alerts_metadata +def build_alert_detected_screen(img_url, alert_metadata, last_alert): + """ + This function is used in the main.py file to create the alert screen when its on, i.e. when there is an alert + ongoing. + It takes as arguments: -# ---------------------------------------------------------------------------------------------------------------------- -# CONTENT + - 'img_url': the URL address of the alert frame to be displayed on the left of the map; + - 'alert_metadata': a dictionary containing metadata about the ongoing alert + - 'last_alert': pd.DataFrame of the last alert + All these inputs are instantiated in the main.py file via a call to the API. + """ + # Get lat and lon from last_alert + lat, lon = last_alert["lat"], last_alert["lon"] + + # Get device id for last_alert + device_id = last_alert["device_id"] + + # Background image + background_image = html.Img( + id="alert_background", + src="/assets/pyro_alert_on.png", + style={ + "position": "fixed", + "width": "100vw", + "height": "100vh", + }, + ) + + # Fire icon + fire_icon = html.Img( + id="fire_icon", + src="/assets/pyro_fire_logo.png", + className="blink-image", + style={"height": "100%"}, + ) + + # Detection frames + alert_frame = html.Img( + id="alert_frame", + src=img_url, + style={ + "position": "relative", + "width": "100%", + "height": "100%", + "object-fit": "contain", + }, + ) + + # Fire alert image div (right of the screen) + # Multiple frames are rendered here (to make it look like a GIF) + fire_images_div = html.Div( + id="fire_alert_div", + children=[ + alert_frame, + dcc.Interval(id="interval-component-img-refresh", interval=3 * 1000), + ], + style={"display": "flex", "height": "100%", "width": "100%"}, + ) + + # Alert metadata div + alert_metadata_div = html.Div( + children=[ + html.P("Tour: {}".format(alert_metadata["site_name"])), + html.P("Coordonnées de la tour: {}, {}".format(lat, lon)), + html.P("Id de caméra: {}".format(device_id)), + html.P("Azimuth: {}".format(alert_metadata["azimuth"])), + ] + ) + + # Fire text div (left part of screen) + fire_text_div = html.Div( + id="fire_text_div", + children=[ + html.Div( + html.P( + "DFCI: KD62D6.5", + ), + style={ + "font-size": "2vw", + "color": "#054546", + "font-weight": "bold", + }, + ), + html.Div( + alert_metadata_div, + style={ + "font-size": "1.75vw", + "color": "#054546", + }, + ), + ], + style={ + "margin-top": "5%", + }, + ) + + # Final layout: one row containing 2 columns, and each column contains two rows + layout_div = [ + background_image, + dbc.Row( + children=[ + dbc.Col( + id="col_fire_text", + children=[ + dbc.Row( + id="fire_icon_rwo", + children=fire_icon, + style={ + "display": "flex", + "justify-content": "center", + "height": "30%", + }, + ), + dbc.Row( + id="fire_text_row", + children=fire_text_div, + style={ + "display": "flex", + "justify-content": "center", + "margin-right": "2.5%", + }, + ), + ], + style={ + "width": "50%", + "margin": "2.5%", + }, + ), + dbc.Col( + id="col_image_fire", + children=[ + dbc.Row( + html.Div( + html.Div( + html.P("DÉPART DE FEU"), + style={ + "font-size": "4vw", + "color": "#fd4848", + "font-weight": "bold", + }, + className="blink-image", + ), + ), + style={ + "display": "flex", + "justify-content": "center", + "padding-bottom": "7%", + }, + ), + dbc.Row( + id="fire_images_row", + children=fire_images_div, + style={ + "display": "flex", + "justify-content": "center", + "margin-right": "2.5%", + }, + ), + ], + style={ + "width": "50%", + "margin": "2.5%", + }, + ), + ], + style={ + "height": "100%", + }, + ), + ] + + style = { + "height": "100%", + "width": "100%", + "position": "fixed", + } + + return layout_div, style # ---------------------------------------------------------------------------------------------------------------------- # App layout -# The following block gathers elements defined above and returns them via the BigScreen function - - -def Bigscreen(): +# The following block gathers elements defined above and returns them via the alert_screen function +def alert_screen(): """ The following function is used in the main.py file to build the layout of the big screen page. """ - raise NotImplementedError + layout = html.Div( + children=[ + dcc.Interval(id="interval-component-alert-screen", interval=3 * 1000), + html.Div(id="core_layout_alert_screen", children=[]), + ], + style={ + "height": "100%", + "width": "100%", + "position": "fixed", + }, + ) + return layout diff --git a/app/assets/pyro_alert_on.png b/app/assets/pyro_alert_on.png new file mode 100644 index 0000000..466787a Binary files /dev/null and b/app/assets/pyro_alert_on.png differ diff --git a/app/assets/pyro_fire_logo.png b/app/assets/pyro_fire_logo.png new file mode 100644 index 0000000..ce792ae Binary files /dev/null and b/app/assets/pyro_fire_logo.png differ diff --git a/app/assets/styles.css b/app/assets/styles.css new file mode 100644 index 0000000..c02860a --- /dev/null +++ b/app/assets/styles.css @@ -0,0 +1,67 @@ +/* Firefox old */ +@-moz-keyframes blink { + 0% { + opacity: 1; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@-webkit-keyframes blink { + 0% { + opacity: 1; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +/* IE */ +/* csslint ignore:start */ +@-ms-keyframes blink { + 0% { + opacity: 1; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} +/* csslint ignore:end */ + +/* Opera and prob css3 final iteration */ +@keyframes blink { + 0% { + opacity: 1; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.blink-image { + -moz-animation: blink normal 1s infinite ease-in-out; /* Firefox */ + -webkit-animation: blink normal 1s infinite ease-in-out; /* Webkit */ + -ms-animation: blink normal 1s infinite ease-in-out; /* IE */ + animation: blink normal 1s infinite ease-in-out; /* Opera and prob css3 final iteration */ +} diff --git a/app/homepage.py b/app/homepage.py index 2b96cd9..8ec6648 100644 --- a/app/homepage.py +++ b/app/homepage.py @@ -320,12 +320,6 @@ def Homepage(): # Map object added here html.Div(build_alerts_map(), id='hp_map'), - # Interval object triggering api calls every 10 seconds - dcc.Interval( - id='interval-component', - interval=10 * 1000, # Timestep in milliseconds - n_intervals=0), - # Two placeholders updated by callbacks in main.py to trigger a change in map style html.Div(id='map_style_btn_switch_view'), # Associated with the main map style button html.Div(id='alert_btn_switch_view'), # Associated with the alert banner in risks mode diff --git a/app/main.py b/app/main.py index f440483..0817185 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ "python app/main.py" -It is built around 4 main sections: +It is built around 5 main sections: - Imports @@ -21,45 +21,41 @@ - The "Alerts and Infrastructure" view - The "Risk Score" view - The homepage + - The alert screen page - Running the web-app server, which allows to launch the app via the Terminal command. """ - # ---------------------------------------------------------------------------------------------------------------------- # IMPORTS # General imports -import pandas as pd -from flask_caching import Cache -import config as cfg # Cf. config.py file - -# Importing the pyro-API client -from services import api_client # Main Dash imports, used to instantiate the web-app and create callbacks (ie. to generate interactivity) import dash -from dash.dependencies import Input, Output, State -from dash.exceptions import PreventUpdate - +import dash_bootstrap_components as dbc # Various modules provided by Dash to build the page layout import dash_core_components as dcc import dash_html_components as html -import dash_bootstrap_components as dbc import dash_leaflet as dl +import pandas as pd +from dash.dependencies import Input, Output, State +from dash.exceptions import PreventUpdate +from flask_caching import Cache +import config as cfg # Cf. config.py file +from alert_screen import alert_screen, build_no_alert_detected_screen, build_alert_detected_screen +from alerts import define_map_zoom_center, build_alerts_elements # From homepage.py, we import the main layout instantiation function from homepage import Homepage - # From other Python files, we import some functions needed for interactivity from homepage import choose_map_style, display_alerts_frames -from alerts import define_map_zoom_center, build_alerts_elements from risks import build_risks_geojson_and_colorbar -from alert_screen import alert_screen -from utils import choose_layer_style, build_info_box, build_info_object,\ +# Importing the pyro-API client +from services import api_client +from utils import choose_layer_style, build_info_box, build_info_object, \ build_live_alerts_metadata, build_historic_markers, build_legend_box - # ---------------------------------------------------------------------------------------------------------------------- # APP INSTANTIATION & OVERALL LAYOUT @@ -69,11 +65,24 @@ # We define a few attributes of the app object app.title = 'Pyronear - Monitoring platform' app.config.suppress_callback_exceptions = True -server = app.server # Gunicorn will be looking for the server attribute of this module - -# We create a rough layout, filled with the content of the homepage -app.layout = html.Div([dcc.Location(id='url', refresh=False), - html.Div(id='page-content')]) +server = app.server # Gunicorn will be looking for the server attribute of this module + +# We create a rough layout, filled with the content of the homepage/alert page +app.layout = html.Div( + [ + dcc.Location(id="url", refresh=False), + html.Div(id="page-content", style={"height": "100%"}), + # Interval component to generate call to API every 10 seconds + dcc.Interval( + id="interval-component-homepage", + interval=10 * 1000 + ), + # Hidden div to keep a record of live alerts data + dcc.Store(id="store_live_alerts_data", storage_type="memory"), + dcc.Store(id="last_displayed_event_id", storage_type="memory"), + dcc.Store(id="images_url_current_alert", storage_type="session", data={}), + ] +) # Cache configuration cache = Cache(app.server, config={ @@ -95,7 +104,7 @@ @app.callback( Output("page-content", "children"), - Input("url", "pathname"), + Input("url", "pathname") ) def display_page(pathname): """ @@ -103,8 +112,7 @@ def display_page(pathname): thanks to the instantiation functions built in the various .py files. """ if pathname == "/alert_screen": - return alert_screen(alert_metadata) - + return alert_screen() else: return Homepage() @@ -149,6 +157,22 @@ def change_layer_style(n_clicks=None): return choose_layer_style(n_clicks) +@app.callback( + Output("store_live_alerts_data", "data"), + Input('interval-component-homepage', 'n_intervals') +) +def update_alert_data(interval): + """ + The following function is used to update the div containing live alert data from API. + """ + # Fetching live alerts where is_acknowledged is False + response = api_client.get_ongoing_alerts().json() + all_alerts = pd.DataFrame(response) + live_alerts = all_alerts.loc[~all_alerts["is_acknowledged"]] + + return live_alerts.to_json() + + # ---------------------------------------------------------------------------------------------------------------------- # Callbacks related to the "Alerts and Infrastructure" view @@ -444,45 +468,49 @@ def change_map_style_main(map_style_button_input, alert_button_input, map_style_ @app.callback( - [Output('img_url', 'children'), - Output('live_alert_header_btn', 'children'), - Output('live_alerts_marker', 'children')], - Input('interval-component', 'n_intervals'), - State('map_style_button', 'children') + [ + Output("img_url", "children"), + Output("live_alert_header_btn", "children"), + Output("live_alerts_marker", "children"), + ], + Input("store_live_alerts_data", "data"), + State("map_style_button", "children") ) -def fetch_alert_status_metadata(n_intervals, map_style_button_label): +def update_style_components_with_alert_metadata( + live_alerts, map_style_button_label +): """ - -- Fetching and refreshing alerts data -- + -- Updating style components with corresponding alerts data -- - This callback takes as input the 'n_intervals' attribute of the interval component, - which acts as a timer with the number of intervals increasing by 1 every 10 seconds. + This callback takes as input "live_alerts" which is the json object containing all live alerts. This json is + retrieved thanks to every 10s call to the API. It also takes as input the label of the button that allows users to change the style of the map (but as a 'State' mode, so that the callback is not triggered by a change in this label), in order to deduce the style of the map that the user is currently looking at. - Each time it is triggered, the callback makes a call to the API to get all ongoing alerts, - filters out those which have been already acknowledged and returns several elements: + Each time it is triggered, the callback uses the data of all ongoing alerts which are stored in + "store_live_alerts_data" and returns several elements: - it stores the URL address of the frame associated with the last alert; - - it creates the elements that signall the alert around the map (banner); + - it creates the elements that signal the alert around the map (banner); - and instantiates the alert markers on the map. To build these elements, it relies on the build_alerts_elements imported from alerts. - scheduling API metadata fetches and defining alert status """ # Deducing the style of the map in place from the map style button label - if 'risques' in map_style_button_label.lower(): - map_style = 'alerts' + if "risques" in map_style_button_label.lower(): + map_style = "alerts" - elif 'alertes' in map_style_button_label.lower(): - map_style = 'risks' + elif "alertes" in map_style_button_label.lower(): + map_style = "risks" - # Fetching live alerts where is_acknowledged is False - response = api_client.get_ongoing_alerts().json() - all_alerts = pd.DataFrame(response) - live_alerts = all_alerts.loc[~all_alerts['is_acknowledged']] + # Get live alerts data + if live_alerts is None: + raise PreventUpdate + + live_alerts = pd.read_json(live_alerts) # Defining the alert status if live_alerts.empty: @@ -496,10 +524,10 @@ def fetch_alert_status_metadata(n_intervals, map_style_button_label): alert_status = 1 # Fetching the last alert - last_alert = live_alerts.loc[live_alerts['id'].idxmax()] + last_alert = live_alerts.loc[live_alerts["id"].idxmax()] # Fetching the URL address of the frame associated with the last alert - img_url = api_client.get_media_url(last_alert['media_id']).json()["url"] + img_url = api_client.get_media_url(last_alert["media_id"]).json()["url"] return build_alerts_elements(img_url, alert_status, alert_metadata, map_style) @@ -529,7 +557,7 @@ def display_alert_frame_metadata(n_clicks_marker, img_url): @app.callback( - Output('interval-component', 'disabled'), + Output('interval-component-homepage', 'disabled'), Input("alert_marker_{}".format(alert_id), 'n_clicks') ) def callback_func_start_stop_interval(n_clicks): @@ -563,11 +591,121 @@ def callback_func_start_stop_interval(n_clicks): # return build_alerts_elements(alert_status, alert_metadata) +# ---------------------------------------------------------------------------------------------------------------------- +# Callbacks related to alert_screen page +@app.callback( + [ + Output("core_layout_alert_screen", "children"), + Output("core_layout_alert_screen", "style"), + Output("last_displayed_event_id", "data"), + ], + Input("interval-component-alert-screen", "n_intervals"), + [ + State("store_live_alerts_data", "data"), + State("last_displayed_event_id", "data"), + ], +) +def update_alert_screen(n_intervals, live_alerts, last_displayed_event_id): + """ + -- Update elements related to the Alert Screen page when the interval component "alert-screen" is triggered -- + """ + if live_alerts is None: + style_to_display = build_no_alert_detected_screen() + return ( + [{}], + style_to_display, + last_displayed_event_id + ) + + else: + # Fetching the last alert + live_alerts = pd.read_json(live_alerts) + last_alert = live_alerts.loc[live_alerts["id"].idxmax()] + last_event_id = str(last_alert["event_id"]) + + # Fetching the URL address of the frame associated with the last alert + img_url = api_client.get_media_url(last_alert["media_id"]).json()["url"] + + if last_event_id == last_displayed_event_id: + # the alert is related to an event id which has already been displayed + # need to send the img_url to the GIF + raise PreventUpdate + else: + # new event, not been displayed yet + layout_div, style_to_display = build_alert_detected_screen( + img_url, alert_metadata, last_alert + ) + return layout_div, style_to_display, last_event_id + + +@app.callback( + Output("images_url_current_alert", "data"), + Input("interval-component-homepage", "n_intervals"), + [ + State("store_live_alerts_data", "data"), + State("images_url_current_alert", "data") + ], +) +def update_dict_of_images(n_intervals, live_alerts, dict_images_url_current_alert): + """ + -- Update the dictionary of images of ongoing events -- + + Dict where keys are event id and value is a list of all urls related to the same event. + These url come from the API calls, triggered by "interval-component-homepage". + """ + if live_alerts is None: + raise PreventUpdate + + else: + # Fetching the last alert + live_alerts = pd.read_json(live_alerts) + last_alert = live_alerts.loc[live_alerts["id"].idxmax()] + last_event_id = str(last_alert["event_id"]) + + # Fetching the URL address of the frame associated with the last alert + img_url = api_client.get_media_url(last_alert["media_id"]).json()["url"] + + if last_event_id not in dict_images_url_current_alert.keys(): + dict_images_url_current_alert[last_event_id] = [] + dict_images_url_current_alert[last_event_id].append(img_url) + + return dict_images_url_current_alert + + +@app.callback( + Output("alert_frame", "src"), + Input("interval-component-img-refresh", "n_intervals"), + [ + State("last_displayed_event_id", "data"), + State("images_url_current_alert", "data") + ] +) +def update_images_for_doubt_removal(n_intervals, last_displayed_event_id, dict_images_url_current_alert): + """ + -- Create a pseudo GIF -- + + Created from the x frames we received each time there is an alert related to the same event. + The urls of these images are stored in a dictionary "images_url_current_alert". + """ + if last_displayed_event_id not in dict_images_url_current_alert.keys(): + raise PreventUpdate + + list_url_images = dict_images_url_current_alert[last_displayed_event_id] + # Only for demo purposes: will be removed afterwards + list_url_images = [ + "http://placeimg.com/625/225/nature", + "http://placeimg.com/625/225/animals", + "http://placeimg.com/625/225/nature" + ] + return list_url_images[n_intervals % len(list_url_images)] + + # ---------------------------------------------------------------------------------------------------------------------- # RUNNING THE WEB-APP SERVER if __name__ == '__main__': import argparse + parser = argparse.ArgumentParser(description='Pyronear web-app', formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/app/navbar.py b/app/navbar.py index b06a741..664a1af 100644 --- a/app/navbar.py +++ b/app/navbar.py @@ -58,6 +58,26 @@ def Navbar(dropdown=False): className="mx-auto order-0", style={'color': 'white', 'align': 'center', 'justify': 'center'}) + # Alert monitoring screen, to be displayed in CODIS + alert_screen_button = dbc.NavLink( + children=[ + html.Div( + children=[ + html.I( + className="mx-auto order-0", + ), + html.Span("Ecran de monitoring"), + ] + ) + ], + href="alert_screen", + style={ + "font-size": "15px", + "color": "white", + }, + className="btn btn-warning" + ) + # Navbar navbar = dbc.Navbar( [ @@ -73,6 +93,7 @@ def Navbar(dropdown=False): ), dbc.NavbarToggler(id="navbar-toggler"), dbc.Collapse([user_item, dropdown], id="navbar-collapse", navbar=True), + html.Div(alert_screen_button) ], color="black", dark=True, diff --git a/app/utils.py b/app/utils.py index ff2561f..52d31b1 100644 --- a/app/utils.py +++ b/app/utils.py @@ -271,12 +271,12 @@ def build_live_alerts_metadata(): """ alert_metadata = { - "id": 0, + "id": 112, "created_at": "2020-11-25T15:22:21.690Z", "media_url": "https://photos.lci.fr/images/613/344/photo-incendie-generac-gard-e8f2d9-0@1x.jpeg", "lat": 44.765181, "lon": 4.51488, - "event_id": 0, + "event_id": 79, "azimuth": "49.2°", "site_name": "Serre de pied de Boeuf", "type": "start",