Spaces:
Sleeping
Sleeping
| # Shiny Express app for exploring SCB employment by occupation. | |
| # | |
| # This file defines the UI controls (sidebar) and the reactive filters that | |
| # drive the Plotly output. Data is loaded once at startup via `load_payload()` | |
| # and then filtered client-side. | |
| from pathlib import Path | |
| from shiny import reactive | |
| from shiny.express import input, ui | |
| from shinywidgets import output_widget, render_plotly | |
| from src.config import ( | |
| DEFAULT_LEVEL, | |
| DEFAULT_YEAR_RANGE, | |
| LEVEL_OPTIONS, | |
| GLOBAL_YEAR_MIN, | |
| GLOBAL_YEAR_MAX, | |
| ) | |
| from src.data_manager import load_payload | |
| from src.plot_helper import employment_multi_plot | |
| # Helpers for UI mapping | |
| LEVEL_CHOICES = {value: label for label, value in LEVEL_OPTIONS} | |
| YEAR_RANGE_DEFAULT = list(range(DEFAULT_YEAR_RANGE[0], DEFAULT_YEAR_RANGE[1] + 1)) | |
| # ====================================================== | |
| # UI LAYOUT | |
| # ====================================================== | |
| css_file = Path(__file__).parent / "css" / "theme.css" | |
| ui.include_css(css_file) | |
| ui.tags.head( | |
| ui.tags.link( | |
| rel="stylesheet", | |
| href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css", | |
| ) | |
| ) | |
| ui.page_opts( | |
| fillable=False, | |
| fillable_mobile=True, | |
| full_width=True, | |
| id="page", | |
| lang="en", | |
| ) | |
| with ui.sidebar(open="desktop", position="right"): | |
| ui.input_select( | |
| "level", "Select Occupation level", LEVEL_CHOICES, selected=DEFAULT_LEVEL | |
| ) | |
| ui.input_selectize( | |
| "selectize", | |
| "Select Occupation title(s)", | |
| {}, | |
| multiple=True, | |
| options=( | |
| { | |
| "placeholder": "Statisticians...", | |
| "create": False, | |
| "plugins": ["clear_button"], | |
| } | |
| ), | |
| ) | |
| # ui.input_radio_buttons( | |
| # "count_mode", | |
| # "Employed persons display", | |
| # {"raw": "Raw counts", "index": "Index to base year"}, | |
| # selected="raw", | |
| # ) | |
| # with ui.panel_conditional("input.count_mode == 'index'"): | |
| # ui.input_select( | |
| # "base_year", | |
| # "Base year", | |
| # YEAR_RANGE_DEFAULT, | |
| # selected=2022, | |
| # ) | |
| ui.input_slider( | |
| "year_range", | |
| "Year range", | |
| min=GLOBAL_YEAR_MIN, | |
| max=GLOBAL_YEAR_MAX, | |
| value=DEFAULT_YEAR_RANGE, | |
| step=1, | |
| sep="", | |
| ) | |
| ui.input_action_button( | |
| "reset_filters", | |
| "Reset filters", | |
| icon=ui.tags.i(class_="fas fa-rotate-left"), | |
| class_="btn-primary mt-3 w-100", | |
| ) | |
| # ====================================================== | |
| # REACTIVE STATE | |
| # ====================================================== | |
| # Load the (cached) pipeline output once at startup; filters operate on this in-memory DataFrame. | |
| payload = load_payload() | |
| def _reset_filters(): | |
| # Reset UI inputs back to defaults (this does not trigger a data reload). | |
| ui.update_select("level", selected=DEFAULT_LEVEL) | |
| ui.update_slider("year_range", value=DEFAULT_YEAR_RANGE) | |
| ui.update_selectize("selectize", selected=[]) | |
| # Build Selectize choices per selected level | |
| def level_label_choices(): | |
| # Shiny choices are `{value: label}`; we use the plain label as the value returned by the input, | |
| # while displaying `code - label` in the dropdown. | |
| df = payload | |
| lvl = int(input.level()) | |
| subset = df[df["level"] == lvl][["code", "label"]].dropna().drop_duplicates() | |
| choices_list = [] | |
| for _, row in subset.iterrows(): | |
| key = row["label"] | |
| value = f"{row['code']} - {row['label']}" | |
| choices_list.append((key, value)) | |
| # Sort by the code (extract code from display value) | |
| choices_list.sort(key=lambda x: x[1].split(" - ")[0]) | |
| # Convert to dictionary while maintaining order | |
| return {key: value for key, value in choices_list} | |
| # keep selectize choices in sync with level selection | |
| def _sync_selectize_choices(): | |
| choices = level_label_choices() | |
| current = input.selectize() or [] | |
| # Prune selections that no longer exist after switching levels. | |
| valid_selected = [s for s in current if s in choices] | |
| # # apply a default when nothing valid remains | |
| # if not valid_selected and choices: | |
| # # pick the first option (or slice for multiple defaults) | |
| # valid_selected = [next(iter(choices))] | |
| ui.update_selectize("selectize", choices=choices, selected=valid_selected) | |
| # Filtered data based on UI inputs | |
| def filtered_data(): | |
| df = payload | |
| level = int(input.level()) | |
| year_min, year_max = input.year_range() | |
| selected_titles = input.selectize() | |
| idx_level = df["level"] == level | |
| idx_year = df["year"].between(year_min, year_max) | |
| # If no titles selected, return empty dataframe | |
| if not selected_titles: | |
| # Returning an empty frame allows the plot helper to render a friendly placeholder. | |
| return df[idx_level & idx_year & (df["label"] == "")].copy() | |
| idx_title = df["label"].isin(selected_titles) | |
| filtered_df = df[idx_level & idx_year & idx_title] | |
| return filtered_df | |
| # # Warning message for no selections | |
| # with ui.div(style="margin: 20px;"): | |
| # @render.ui | |
| # def selection_status(): | |
| # if not input.selectize(): | |
| # return ui.div( | |
| # ui.tags.div( | |
| # "⚠️ Please select at least one occupation title to view data.", | |
| # style="background-color: #fff3cd; color: #856404; padding: 15px; border: 1px solid #ffeaa7; border-radius: 5px; text-align: center; font-weight: bold;", | |
| # ) | |
| # ) | |
| # else: | |
| # return ui.div() # Return empty div when selections exist | |
| # @render_plotly | |
| # def data_table(): | |
| # df = filtered_data() | |
| # # Show message if no data available | |
| # if df.empty: | |
| # fig = go.Figure() | |
| # fig.add_annotation( | |
| # text="No data available. Please select occupation titles.", | |
| # xref="paper", | |
| # yref="paper", | |
| # x=0.5, | |
| # y=0.5, | |
| # showarrow=False, | |
| # font=dict(size=16), | |
| # ) | |
| # fig.update_layout( | |
| # xaxis=dict(visible=False), yaxis=dict(visible=False), plot_bgcolor="white" | |
| # ) | |
| # return fig | |
| # fig = go.Figure( | |
| # data=go.Table( | |
| # header=dict( | |
| # values=list(df.columns), fill_color="paleturquoise", align="left" | |
| # ), | |
| # cells=dict( | |
| # values=[df[col] for col in df.columns], | |
| # fill_color="lavender", | |
| # align="left", | |
| # ), | |
| # ) | |
| # ) | |
| # return fig | |
| with ui.div(style="display:flex; justify-content:center;"): | |
| output_widget("employment_plot") | |
| def employment_plot2(): | |
| return employment_multi_plot(filtered_data(), level=input.level()) | |