Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions ml_peg/app/build_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@
)
from ml_peg.app.utils.onboarding import (
build_onboarding_modal,
build_tutorial_button,
register_onboarding_callbacks,
)
from ml_peg.app.utils.register_callbacks import (
register_benchmark_to_category_callback,
register_filter_loading_callback,
register_filter_tables_callback,
)
from ml_peg.app.utils.storage import (
build_header_controls,
register_storage_callbacks,
)
from ml_peg.app.utils.utils import (
build_level_of_theory_warnings,
get_framework_config,
Expand Down Expand Up @@ -1048,6 +1051,22 @@ def build_nav(
"color": "#212529",
},
),
# Time-based progress bar: fills continuously over ~10s, easing
# toward ~95% (keyframe in loading.css; same single-element bar
# as the pre-hydration loader in dash_loading.css). It vanishes
# with the mask when ready, so it never reaches a fake 100%.
Div(
style={
"width": "200px",
"height": "6px",
"borderRadius": "3px",
"background": (
"linear-gradient(#119DFF, #119DFF) left center "
"/ 5% 100% no-repeat, #d0ebff"
),
"animation": "ml-peg-bar-fill 10s ease-out forwards",
},
),
],
id="startup-mask",
style={
Expand All @@ -1067,7 +1086,7 @@ def build_nav(
),
Interval(id="startup-mask-poll", interval=250, n_intervals=0),
build_onboarding_modal(),
build_tutorial_button(),
build_header_controls(),
Location(id="app-location", refresh=False),
Store(
id="summary-table-scores-store",
Expand Down Expand Up @@ -1184,6 +1203,7 @@ def build_nav(

# Hide the start-up mask once the page has rendered, or after a timeout as
# a safety net, then stop polling. Clientside, so it adds no server load.
# (The progress bar fills via a CSS animation, not this callback.)
clientside_callback(
"""
function(n) {
Expand All @@ -1200,6 +1220,8 @@ def build_nav(
Input("startup-mask-poll", "n_intervals"),
)

register_storage_callbacks()

@callback(
Output("model-filter-checklist", "value"),
Output("selected-models-store", "data"),
Expand Down
11 changes: 11 additions & 0 deletions ml_peg/app/data/loading.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,14 @@
transform: rotate(360deg);
}
}

/* Start-up progress bar: fill continuously, easing toward ~95% (it vanishes
with the start-up mask when content is ready, so it never reaches 100%). */
@keyframes ml-peg-bar-fill {
from {
background-size: 5% 100%;
}
to {
background-size: 95% 100%;
}
}
15 changes: 10 additions & 5 deletions ml_peg/app/data/utils/dash_loading.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@
animation: ml-peg-spin 0.8s linear infinite;
}

/* Single-element progress bar (gradient fill over a track), so it appears from
the very first paint. Same ml-peg-bar-fill keyframe as the start-up mask. */
._dash-loading::after {
content: "Loading ML-PEG…";
font-size: 16px;
font-weight: 600;
color: #212529;
font-family: sans-serif;
content: "";
width: 200px;
height: 6px;
border-radius: 3px;
background:
linear-gradient(#119dff, #119dff) left center / 5% 100% no-repeat,
#d0ebff;
animation: ml-peg-bar-fill 10s ease-out forwards;
}
13 changes: 10 additions & 3 deletions ml_peg/app/utils/build_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ def build_table_loading_spinner() -> Div:
)


def build_filter_overlay(table_id: str, child) -> Loading:
def build_filter_overlay(table_id: str, child, delay_hide: int = 250) -> Loading:
"""
Wrap a table in a filter-aware loading overlay.

Expand All @@ -515,6 +515,10 @@ def build_filter_overlay(table_id: str, child) -> Loading:
the loading target component.
child
Component tree to render under the overlay.
delay_hide
Milliseconds to keep the spinner up after loading ends. A larger value
bridges the gaps between the several quick updates that a summary table
receives as scores propagate, so the user sees one spinner not several.

Returns
-------
Expand All @@ -528,7 +532,7 @@ def build_filter_overlay(table_id: str, child) -> Loading:
custom_spinner=build_table_loading_spinner(),
target_components={table_id: ["data", "style_data_conditional"]},
show_initially=False,
delay_hide=250,
delay_hide=delay_hide,
overlay_style={"visibility": "visible", "opacity": 1},
parent_style={"position": "relative", "width": "fit-content"},
)
Expand All @@ -549,7 +553,10 @@ def build_loading_summary_table(table: DataTable) -> Loading:
Loading
Loading wrapper scoped to applied filter changes and table updates.
"""
return build_filter_overlay(table.id, Div(table))
# Longer delay_hide than the default: a summary table gets several quick
# updates in a row as category scores propagate, so bridge the gaps into one
# spinner instead of several flashes.
return build_filter_overlay(table.id, Div(table), delay_hide=600)


def build_plot_download_controls(graph_id: str) -> Div:
Expand Down
6 changes: 2 additions & 4 deletions ml_peg/app/utils/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,9 @@ def build_tutorial_button() -> html.Button:
"Tutorial",
id="restart-tutorial-button",
title="Restart the interactive tutorial",
# Positioning is handled by the header-controls container in build_app so
# this button can sit alongside the clear-cache button.
style={
"position": "fixed",
"top": "20px",
"right": "20px",
"padding": "8px 16px",
"borderRadius": "6px",
"border": "1px solid #cbd5e1",
Expand All @@ -179,7 +178,6 @@ def build_tutorial_button() -> html.Button:
"cursor": "pointer",
"fontWeight": 600,
"fontSize": "14px",
"zIndex": "1600", # Above loading overlays (1200/1400).
"boxShadow": "0 2px 8px rgba(0, 0, 0, 0.1)",
"transition": "all 0.2s ease",
},
Expand Down
118 changes: 118 additions & 0 deletions ml_peg/app/utils/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
Buttons and callbacks for clearing the app data saved in the browser.

Kept in its own file so ``build_app`` stays short. ``build_header_controls``
makes the buttons shown in the top-right corner; ``register_storage_callbacks``
makes them work: clearing the saved data when the button is clicked, and
automatically after a new version is released.
"""

from __future__ import annotations

from dash import Input, Output, clientside_callback
from dash.html import Button, Div

from ml_peg import __version__
from ml_peg.app.utils.onboarding import build_tutorial_button

_CLEAR_BUTTON_STYLE = {
"padding": "8px 16px",
"borderRadius": "6px",
"border": "1px solid #cbd5e1",
"background": "white",
"color": "#475569",
"cursor": "pointer",
"fontWeight": 600,
"fontSize": "14px",
"boxShadow": "0 2px 8px rgba(0, 0, 0, 0.1)",
"transition": "all 0.2s ease",
}


def build_header_controls() -> Div:
"""
Build the buttons shown in the top-right corner of the app.

Holds the "Clear cache" button next to the "Tutorial" button. The two hidden
Divs are not shown, they just give the callbacks somewhere to write to.

Returns
-------
Div
Container holding the top-right buttons.
"""
return Div(
[
Button(
"Clear cache",
id="clear-storage-button",
n_clicks=0,
title=(
"Clear browser-stored app state (weights, thresholds, "
"tutorial progress) and reload. Use after an update if "
"the app shows stale data."
),
style=_CLEAR_BUTTON_STYLE,
),
build_tutorial_button(),
Div(id="clear-storage-dummy", style={"display": "none"}),
Div(id="storage-version-dummy", style={"display": "none"}),
],
style={
"position": "fixed",
"top": "20px",
"right": "20px",
"display": "flex",
"alignItems": "center",
"gap": "10px",
"zIndex": "1600", # Above loading overlays (1200/1400).
},
)


def register_storage_callbacks() -> None:
"""Register the clear-cache and version-bump auto-clear clientside callbacks."""
# Clear all browser-persisted dcc.Store data (session + local) and reload, so
# stale cached state after an update can be wiped from the header button.
clientside_callback(
"""
function (n_clicks) {
if (n_clicks && window.confirm(
"Clear cached app data and reload? Saved weights and thresholds"
+ " will be reset."
)) {
window.localStorage.clear();
window.sessionStorage.clear();
window.location.reload();
}
return "";
}
""",
Output("clear-storage-dummy", "children"),
Input("clear-storage-button", "n_clicks"),
prevent_initial_call=True,
)

# Auto-clear browser-persisted stores when the released version changes, so a
# new release drops stale cached state automatically. The version is recorded
# in localStorage.
clientside_callback(
f"""
function (pathname) {{
const current = "{__version__}";
const stored = window.localStorage.getItem("ml-peg-store-version");
if (stored !== current) {{
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.setItem("ml-peg-store-version", current);
if (stored !== null) {{
window.location.reload();
}}
}}
return "";
}}
""",
Output("storage-version-dummy", "children"),
Input("app-location", "pathname"),
prevent_initial_call=False,
)
Loading