diff --git a/app/assets/styles.css b/app/assets/styles.css index 38b13e6..fd77ec1 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -15,18 +15,25 @@ body { .card-title { color: #2D2D32; font-family: "Familjen Grotesk", sans-serif; - font-weight: 700; + font-weight: 600; font-size: 35px; } .indicator_title { + font-family: "Familjen Grotesk", sans-serif; + font-size: 35px; + color: #2D2D32; +} + + +.indicator_value { font-family: "Familjen Grotesk", sans-serif; font-weight: 700; font-size: 50px; color: #2D2D32; } -.indicator_subtitle { +.indicator_delta { font-size: 25px; font-family: "Inter", sans-serif; } diff --git a/app/pages/locations.py b/app/pages/locations.py index 5855dff..a030e24 100644 --- a/app/pages/locations.py +++ b/app/pages/locations.py @@ -83,7 +83,7 @@ def layout(device_id: str = None, **kwargs): # MAP CARD for specific location leaflet_manager.set_locations(data_manager.location_info) - + map = leaflet_manager.get_map( device_id=device_id, style={"height": "300px"}, @@ -92,16 +92,16 @@ def layout(device_id: str = None, **kwargs): ) map_card = location_component_manager.get_card( - title=label, - body=map, - logo="fa-map-location-dot" + title=label, body=map, logo="fa-map-location-dot" ) # NOISE LEVEL card level_card = location_component_manager.get_level_card() # LINE GRAPH card with date picker and download button - line_graphs_card = location_component_manager.get_noise_line_graph_card() + line_graphs_card = ( + location_component_manager.get_noise_line_graph_card() + ) # NAVBAR nav_bar = location_component_manager.get_navbar() diff --git a/app/src/app_components.py b/app/src/app_components.py index 7a34dda..e698284 100644 --- a/app/src/app_components.py +++ b/app/src/app_components.py @@ -38,23 +38,24 @@ class COMPONENT_ID(StrEnum): """ Component IDs for the app. """ + redirect = auto() - + # maps system_map = auto() map_markers = auto() - + # aggregate indicator mean_indicator = auto() mean_indicator_tooltip = auto() - + # noise analyzer hourly_noise_line_graph = auto() raw_noise_line_graph = auto() date_picker = auto() download_button = auto() download_csv = auto() - + # data stores hourly_data_store = auto() last_update_text = auto() @@ -340,11 +341,44 @@ def get_navbar(self) -> dbc.NavbarSimple: """ navbar = dbc.NavbarSimple( children=[ - dbc.NavItem(dbc.NavLink("Get tRacket", href="https://tracket.info/sensor/", target="_blank", className="nav-link")), - dbc.NavItem(dbc.NavLink("Noise Map", href="https://dashboard.tracket.info/locations",target="_blank", className="nav-link")), - dbc.NavItem(dbc.NavLink("About", href="https://tracket.info/",target="_blank", className="nav-link")), - dbc.NavItem(dbc.NavLink("Donate", href="https://opencollective.com/tRacket",target="_blank", className="nav-link")), - dbc.Button("Log In", href="https://manage.tracket.info/", target="_blank", className="button-login"), + dbc.NavItem( + dbc.NavLink( + "Get tRacket", + href="https://tracket.info/sensor/", + target="_blank", + className="nav-link", + ) + ), + dbc.NavItem( + dbc.NavLink( + "Noise Map", + href="https://dashboard.tracket.info/locations", + target="_blank", + className="nav-link", + ) + ), + dbc.NavItem( + dbc.NavLink( + "About", + href="https://tracket.info/", + target="_blank", + className="nav-link", + ) + ), + dbc.NavItem( + dbc.NavLink( + "Donate", + href="https://opencollective.com/tRacket", + target="_blank", + className="nav-link", + ) + ), + dbc.Button( + "Log In", + href="https://manage.tracket.info/", + target="_blank", + className="button-login", + ), ], brand=dbc.Container( [ @@ -420,11 +454,10 @@ def get_indicators(self, indicators: Dict[str, float | int]) -> dbc.Row: row = [] for title, value in indicators.items(): - fig = plotter.plot(value=value, title=title) + indicator = plotter.plot(value=value, title=title) col = dbc.Col( - dcc.Graph( - figure=fig, - config={"displayModeBar": False}, + html.Div( + [indicator], style={"height": "20vh"}, ) ) @@ -442,7 +475,9 @@ def __init__(self, data_manager: AppDataManager) -> None: super().__init__() self.data_manager = data_manager - def get_card(self, title: str, body: object, logo: str, style: dict = dict()): + def get_card( + self, title: str, body: object, logo: str, style: dict = dict() + ): """ Create a dbc.Card() component with the given title, body and fontawesome logo. """ @@ -462,7 +497,7 @@ def get_card(self, title: str, body: object, logo: str, style: dict = dict()): ), ) card = dbc.Card([card_header, dbc.CardBody([body])], style=style) - + return card def _get_noise_line_graph( @@ -473,10 +508,10 @@ def _get_noise_line_graph( Create an empty noise graph component which can be updated using callbacks. """ noise_line_graph = dcc.Graph( - figure=go.Figure(), - id=component_id, - style={"visibility": "hidden"}, - ) + figure=go.Figure(), + id=component_id, + style={"visibility": "hidden"}, + ) return noise_line_graph @@ -484,8 +519,12 @@ def get_noise_line_graph_card(self) -> dbc.Card: """ Create the card component holding the noise line graphs. """ - raw_noise_line_graph = self._get_noise_line_graph(COMPONENT_ID.raw_noise_line_graph) - hourly_noise_line_graph = self._get_noise_line_graph(COMPONENT_ID.hourly_noise_line_graph) + raw_noise_line_graph = self._get_noise_line_graph( + COMPONENT_ID.raw_noise_line_graph + ) + hourly_noise_line_graph = self._get_noise_line_graph( + COMPONENT_ID.hourly_noise_line_graph + ) ### Date Picker ### @@ -496,33 +535,33 @@ def get_noise_line_graph_card(self) -> dbc.Card: download_button = self._get_download_button() noise_line_card_body = dbc.CardBody( + [ + dbc.Row( [ - dbc.Row( - [ - dbc.Col(date_controls, lg=3, md=3), - dbc.Col(download_button, lg=2, md=2), - ] - ), - dbc.Row( - [ - dbc.Col( - dbc.Spinner(hourly_noise_line_graph), - lg=12, - md=12, - ) - ] - ), - dbc.Row( - [ - dbc.Col( - dbc.Spinner(raw_noise_line_graph), - lg=12, - md=12, - ) - ] - ), + dbc.Col(date_controls, lg=3, md=3), + dbc.Col(download_button, lg=2, md=2), ] - ) + ), + dbc.Row( + [ + dbc.Col( + dbc.Spinner(hourly_noise_line_graph), + lg=12, + md=12, + ) + ] + ), + dbc.Row( + [ + dbc.Col( + dbc.Spinner(raw_noise_line_graph), + lg=12, + md=12, + ) + ] + ), + ] + ) line_graphs_card = self.get_card( title="Noise Analyzer", @@ -558,16 +597,17 @@ def get_level_card( ) -> dbc.Card: card = self.get_card( - title="Noise Level & Trend", + title="Current Noise Level", body=dbc.CardBody( - [ - html.Span(id=COMPONENT_ID.last_update_text), - html.Br(), - self._get_mean_indicator(), - ] - ), + [ + html.Span(id=COMPONENT_ID.last_update_text), + html.Br(), + html.Br(), + self._get_mean_indicator(), + ] + ), logo="fa-arrow-trend-up", - style={"height": "395px", "marginBottom": "20px"} + style={"height": "395px", "marginBottom": "20px"}, ) return card @@ -648,7 +688,6 @@ def __init__(self, data_manager: AppDataManager) -> None: self.data_manager = data_manager self.data_formatter = DataFormatter() - def initialize_callbacks(self): def _update_fig_with_layout(relayout_data: dict, figure: dict) -> None: """ @@ -695,7 +734,9 @@ def download_button_callback(n_clicks): Output(COMPONENT_ID.hourly_data_store, "data"), Input(COMPONENT_ID.raw_data_store, "data"), ) - def aggregate_raw_to_hourly(raw_data: List[Dict[str, object]]) -> List[Dict[str, object]]: + def aggregate_raw_to_hourly( + raw_data: List[Dict[str, object]] + ) -> List[Dict[str, object]]: """ Take the raw data and resample to hourly. """ @@ -704,11 +745,7 @@ def aggregate_raw_to_hourly(raw_data: List[Dict[str, object]]) -> List[Dict[str, # aggregate raw_df = raw_df.set_index(COLUMN.TIMESTAMP) hourly_df = raw_df.resample("1H").agg( - { - COLUMN.MEAN: "mean", - COLUMN.MIN: "min", - COLUMN.MAX: "max" - } + {COLUMN.MEAN: "mean", COLUMN.MIN: "min", COLUMN.MAX: "max"} ) hourly_df = hourly_df.reset_index() @@ -717,13 +754,15 @@ def aggregate_raw_to_hourly(raw_data: List[Dict[str, object]]) -> List[Dict[str, hourly_data = self.data_formatter.dataframe_to_store(hourly_df) return hourly_data - + @callback( Output(COMPONENT_ID.raw_data_store, "data"), Input(COMPONENT_ID.date_picker, "start_date"), Input(COMPONENT_ID.date_picker, "end_date"), ) - def load_data(start_date: date, end_date: date) -> List[Dict[str, object]]: + def load_data( + start_date: date, end_date: date + ) -> List[Dict[str, object]]: """ Load data based on date picker into client-side raw data store. """ @@ -732,7 +771,7 @@ def load_data(start_date: date, end_date: date) -> List[Dict[str, object]]: start_date = date.fromisoformat(start_date) end_date = date.fromisoformat(end_date) end_date += timedelta(days=1) - + self.data_manager.load_and_format_location_noise( location_id=device_id, granularity=Granularity.raw, @@ -744,7 +783,7 @@ def load_data(start_date: date, end_date: date) -> List[Dict[str, object]]: raw_data = self.data_formatter.dataframe_to_store(raw_data) return raw_data - + @callback( Output( COMPONENT_ID.hourly_noise_line_graph, @@ -762,7 +801,10 @@ def load_data(start_date: date, end_date: date) -> List[Dict[str, object]]: Input(COMPONENT_ID.raw_data_store, "data"), prevent_initial_call="initial_duplicate", ) - def update_line_charts(hourly_data: List[Dict[str, float]], raw_data: List[Dict[str, float]]): + def update_line_charts( + hourly_data: List[Dict[str, float]], + raw_data: List[Dict[str, float]], + ): """ Main callback responsible for loading data based on the date selector, updating the line charts and storing aggregate noise data. diff --git a/app/src/plotting.py b/app/src/plotting.py index 252bb57..7bfb07a 100644 --- a/app/src/plotting.py +++ b/app/src/plotting.py @@ -487,35 +487,67 @@ class AbstractIndicatorPlotter(BasePlotter): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - def _get_indicator(self, value: float, delta: float) -> html.Div: + def _get_indicator( + self, + value: int | float, + units: Optional[str] = None, + delta: Optional[int | float] = None, + title: Optional[str] = None, + ) -> html.Div: """ - Create an indicator component based on value and delta. + Create an indicator component that shows a + value and delta in percentage. """ - # round values + # round numbers value = round(value, 2) - delta = round(delta, 2) - # check sign and set color & logo - if delta >= 0: - logo = html.I(className=f"fa-solid fa-angles-up") - color = self._config["plot.colors"]["increase_color"] + elements = [] + + if title: + title_line = html.Center( + html.Div(title, className="indicator_title") + ) + elements.append(title_line) + + if units: + value_text = f"{value} {units}" else: - logo = html.I(className=f"fa-solid fa-angles-down") - color = self._config["plot.colors"]["decrease_color"] - - title = html.Center(html.Div(f'{value} dBA', className="indicator_title")) - subtitle = html.Div([ - html.Center([ - logo, - html.Span(style={"display":"inline-block", "width": 15}), - html.Span(f'{abs(delta)} %') - ]), - ], className="indicator_subtitle", style={"color": color}) - - indicator = html.Div([ - title, - subtitle - ]) + value_text = f"{value}" + + value_line = html.Center( + html.Div(value_text, className="indicator_value") + ) + elements.append(value_line) + + if delta: + delta = round(delta, 2) + + # check sign to set color & logo appropriately + if delta >= 0: + logo = html.I(className=f"fa-solid fa-angles-up") + color = self._config["plot.colors"]["increase_color"] + else: + logo = html.I(className=f"fa-solid fa-angles-down") + color = self._config["plot.colors"]["decrease_color"] + + delta_line = html.Div( + [ + html.Center( + [ + logo, + html.Span( + style={"display": "inline-block", "width": 15} + ), + html.Span(f"{delta} %"), + ] + ), + ], + className="indicator_delta", + style={"color": color}, + ) + elements.append(delta_line) + + indicator = html.Div(elements) return indicator @@ -530,19 +562,9 @@ def __init__(self, bootstrap_template: str = None) -> None: def plot(self, value: int | float, title: str = None) -> go.Figure: """ """ - fig = self._get_indicator(value=value, mode="number", title=title) - fig.update_layout( - margin=dict( - l=10, - r=10, - b=10, - t=10, - ), - ) + indicator = self._get_indicator(value=value, title=title) - self.set_formatting(fig) - - return fig + return indicator class MeanIndicatorPlotter(AbstractIndicatorPlotter): @@ -582,340 +604,10 @@ def _get_title(self) -> str: def plot(self) -> html.Div: last_mean = self._get_last_mean() ref_mean = self._get_reference_mean() - delta = round((last_mean - ref_mean)/last_mean*100, 1) - - indicator = self._get_indicator( - value=last_mean, - delta=delta - ) - - return indicator - - -class DeviceCountIndicatorPlotter(AbstractIndicatorPlotter): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - def _validate_data(self, df: pd.DataFrame) -> None: - for col in [COLUMN.DEVICEID, COLUMN.COUNT, COLUMN.COUNT_PRIOR]: - assert col in df.columns - assert df[COLUMN.DEVICEID].nunique() == df.shape[0] - - def _get_device_count(self) -> int: - """Current device count.""" - return (self.df[COLUMN.COUNT] > 0).sum() - - def _get_reference_count(self) -> int: - """Current device count.""" - return (self.df[COLUMN.COUNT_PRIOR] > 0).sum() - - def plot(self) -> go.Figure: - fig = self._get_indicator( - value=self._get_device_count(), - title="Number of Active Devices", - delta={ - "reference": self._get_reference_count(), - "relative": False, - "increasing.color": "green", - "decreasing.color": "red", - }, - ) - - self.set_formatting(fig) - - return fig - - -class MinAverageIndicatorPlotter(AbstractIndicatorPlotter): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - def _validate_data(self, df: pd.DataFrame) -> None: - for col in [ - COLUMN.DEVICEID, - COLUMN.AVGMIN, - COLUMN.COUNT, - COLUMN.AVGMIN_PRIOR, - COLUMN.COUNT_PRIOR, - ]: - assert col in df.columns - - assert df[COLUMN.DEVICEID].nunique() == df.shape[0] - - def _get_min_avg(self, count_col: COLUMN, min_col: COLUMN) -> float: - """ - Find system avg: individual device-level avg multiplied by device count for device total (disaggregate first) then get global average. - """ - total_noise = sum(self.df[min_col] * self.df[count_col]) - total_count = sum(self.df[count_col]) - - if total_count > 0: - avg = total_noise / total_count - avg = round(avg, 2) - else: - avg = None - - return avg - - def _get_system_min_avg(self) -> float: - """Current week.""" - return self._get_min_avg(COLUMN.COUNT, COLUMN.AVGMIN) - - def _get_reference_avg(self) -> float: - """Prior week.""" - return self._get_min_avg(COLUMN.COUNT_PRIOR, COLUMN.AVGMIN_PRIOR) - - def plot(self) -> go.Figure: - fig = self._get_indicator( - value=self._get_system_min_avg(), - title="Average Ambient Noise", - delta={ - "reference": self._get_reference_avg(), - "relative": True, - "valueformat": ".1%", - "increasing.color": "red", - "decreasing.color": "green", - }, - number={"suffix": " dBA"}, - ) - - self.set_formatting(fig) - - return fig - - -class OutlierIndicatorPlotter(AbstractIndicatorPlotter): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - def _validate_data(self, df: pd.DataFrame) -> None: - assert COLUMN.OUTLIERCOUNT in df.columns - assert COLUMN.OUTLIERCOUNT_PRIOR in df.columns - - def _get_total_count(self) -> int: - return self.df[COLUMN.OUTLIERCOUNT].sum() - - def _get_reference_count(self) -> int: - return self.df[COLUMN.OUTLIERCOUNT_PRIOR].sum() - - def plot(self) -> go.Figure: - fig = self._get_indicator( - value=self._get_total_count(), - title="Number of Outliers", - delta={ - "reference": self._get_reference_count(), - "relative": False, - "increasing.color": "red", - "decreasing.color": "green", - }, - ) - - self.set_formatting(fig) - - return fig - - -class TimeOfDay(StrEnum): - DAY = auto() - EVENING = auto() - NIGHT = auto() - - -class TimeOfDayIndicatorPlotter(AbstractIndicatorPlotter): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + delta = round((last_mean - ref_mean) / last_mean * 100, 1) - def _validate_data(self, df: pd.DataFrame) -> None: - """ - Expects the hourly data for the calculation. - """ - assert COLUMN.DATE in df.columns - assert COLUMN.HOUR in df.columns - assert COLUMN.MINNOISE in df.columns - - def _get_time_bounds(self, time_of_day: TimeOfDay) -> tuple[int, int]: - """ - Return the start/end for the time of day. - """ - if time_of_day == TimeOfDay.DAY: - start, end = int(self._config["constants"]["day_start"]), int( - self._config["constants"]["day_end"] - ) - elif time_of_day == TimeOfDay.EVENING: - start, end = int(self._config["constants"]["evening_start"]), int( - self._config["constants"]["evening_end"] - ) - elif time_of_day == TimeOfDay.NIGHT: - start, end = int(self._config["constants"]["night_start"]), int( - self._config["constants"]["night_end"] - ) - - return start, end - - def _get_time_of_day_average( - self, df: pd.DataFrame, time_of_day: TimeOfDay - ) -> float: - """ - Calculate the time of day average for a given day. - """ - start, end = self._get_time_bounds(time_of_day) - - if start < end: - hour_filter = (start <= df[COLUMN.HOUR]) & (df[COLUMN.HOUR] < end) - else: - hour_filter = (start <= df[COLUMN.HOUR]) | (df[COLUMN.HOUR] < end) - - average = df.loc[hour_filter, COLUMN.MINNOISE].mean() - - return average - - def _extract_last_two_days(self) -> tuple[pd.DataFrame, pd.DataFrame]: - """ - Slice out the most current 48 hours of data and return it split into two 24 hour blocks. - """ - self.df[COLUMN.DATE] = pd.to_datetime(self.df[COLUMN.DATE]) - self.df.sort_values( - [COLUMN.DATE, COLUMN.HOUR], ascending=False, inplace=True - ) - - current_df = self.df.iloc[:24, :] - previous_df = self.df.iloc[24:48, :] - - return current_df, previous_df - - def plot(self, time_of_day: TimeOfDay) -> go.Figure: - """ - Create indicator for time of day average with delta comparing to previous day value. - """ - current_df, previous_df = self._extract_last_two_days() - - emoji = { - TimeOfDay.DAY: "🌅", - TimeOfDay.EVENING: "🌇", - TimeOfDay.NIGHT: "🌃", - } - - indicator_text = f"{str(time_of_day).title()} {emoji[time_of_day]}" - - fig = self._get_indicator( - value=self._get_time_of_day_average(current_df, time_of_day), - title=indicator_text, - number={"suffix": " dBA"}, - delta={ - "reference": self._get_time_of_day_average( - previous_df, time_of_day - ), - "relative": True, - "valueformat": ".1%", - "increasing.color": "red", - "decreasing.color": "green", - }, - ) - - self.set_formatting(fig) - - return fig - - -class MapPlotter(BasePlotter): - """ - Class for creating maps. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - def _validate_data(self, df: pd.DataFrame) -> None: - assert COLUMN.DEVICEID in df.columns - assert COLUMN.LAT in df.columns - assert COLUMN.LON in df.columns - assert COLUMN.LABEL in df.columns - - def plot(self) -> go.Figure: - """ - Create marker map of device locations. - """ - fig = go.Figure() - fig.add_trace(self._get_map_layer()) - self._position_map(fig) - self._set_margin(fig) - - return fig - - def _set_margin(self, fig): - fig.update_layout( - margin={"r": 0, "t": 0, "l": 0, "b": 0}, - ) - - def _position_map(self, fig): - """ - Center and zoom map. - """ - lat, lon = self._get_map_center() - - fig.update_layout( - mapbox=dict( - zoom=int(self._config["map"]["zoom"]), - center=dict( - lat=lat, - lon=lon, - ), - style=self._config["map"]["style"], - ), - ) - - def _get_map_layer(self) -> go.Scattermapbox: - """ - Create the map layer and with markers. - """ - - return go.Scattermapbox( - lat=self.df[COLUMN.LAT], - lon=self.df[COLUMN.LON], - mode="markers", - marker=go.scattermapbox.Marker( - size=20, color=self.colors[COLOR_ITEM.MAX] - ), - hoverinfo="text", - hovertemplate="%{hovertext}", - hovertext=list(self.df[COLUMN.LABEL].values), - name="", - ) - - def _get_map_center(self) -> tuple[float, float]: - """ - Read the map center from the data provided, or fall back on the default from config. - Returns lat, lon tuple. - """ - - if self.df.shape[0] > 0: - return self.df[COLUMN.LAT].values[0], self.df[COLUMN.LON].values[0] - - else: - lat = float(self._config["constants"]["map_center_lat"]) - lon = float(self._config["constants"]["map_center_lon"]) - - return lat, lon - - def _get_indicator_trace( - self, - value: float | int, - text: str, - x_pos: tuple[float, float], - y_pos: tuple[float, float], - ) -> go.Indicator: - """ - Count indicator. - """ - indicator = go.Indicator( - mode="number", - value=value, - number={"font_color": self.colors[COLOR_ITEM.MAX]}, - title={ - "text": text, - "font_color": self.colors[COLOR_ITEM.MAX], - }, - domain={"x": x_pos, "y": y_pos}, + indicator = self._get_indicator( + value=last_mean, delta=delta, units="dBA" ) return indicator diff --git a/app/src/utils.py b/app/src/utils.py index bab6e53..657a32b 100644 --- a/app/src/utils.py +++ b/app/src/utils.py @@ -117,10 +117,11 @@ def get_last_time(df: pd.DataFrame) -> str: Get last time stamp from the dataframe. """ recent_timestamp = pd.to_datetime(df[COLUMN.TIMESTAMP].max()) - formatted_timestamp = recent_timestamp.strftime('%d %b %Y, %I:%M %p') + formatted_timestamp = recent_timestamp.strftime("%d %b %Y, %I:%M %p") return formatted_timestamp + def load_config(config_path: str = None) -> configparser.ConfigParser: """ Load a config file from the current dir or a given location. @@ -166,16 +167,18 @@ class DataFormatter(object): def __init__(self) -> None: pass - def store_to_dataframe(self, data: List[Dict[str, object]]) -> pd.DataFrame: + def store_to_dataframe( + self, data: List[Dict[str, object]] + ) -> pd.DataFrame: """ - Take a json style data set from the client-side dcc.Store() and + Take a json style data set from the client-side dcc.Store() and turn into a dataframe with Enum column names and proper data types. """ df = pd.DataFrame(data) df = self._string_col_names_to_enum(df) df = self._set_data_types(df) - + return df def dataframe_to_store(self, df: pd.DataFrame) -> List[Dict[str, Any]]: @@ -184,9 +187,9 @@ def dataframe_to_store(self, df: pd.DataFrame) -> List[Dict[str, Any]]: """ df = self._enum_col_names_to_string(df) data = df.to_dict("records") - + return data - + @staticmethod def _fill_missing_times(df: pd.DataFrame, freq: str) -> pd.DataFrame: """