diff --git a/CLAUDE.md b/CLAUDE.md index bf00de3..499d3de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,10 +3,11 @@ python-dashboard-template/ ├── src/ │ ├── app.py # Main application file │ ├── theme.py # Colours, type scale and the Plotly template -│ ├── memory_log.py # Dev aid: prints RSS memory usage, see LOG_MEMORY +│ ├── memory_log.py # Opt-in dev aid: prints RSS memory to the terminal, see LOG_MEMORY │ ├── assets/ # Static files (CSS, images, sample data) │ └── pages/ # One module per page, each with dash.register_page -│ ├── home.py +│ ├── introduction.py +│ ├── data_table.py │ └── analytics.py ├── tests/ # pytest suite ├── notebooks/ # ad-hoc exploration, outside the running app @@ -50,9 +51,48 @@ python-dashboard-template/ - **Component Libraries**: Prioritize component libraries in this order: Dash Core Components combined with Dash HTML Components, then Dash Mantine Components, then Dash Bootstrap Components if required. Try to minimize the number of libraries required. - **Data Tables**: Do not use `dash.datatable`; use `dash.AgGrid` instead. - **AgGrid Configs**: When instantiating `dag.AgGrid`, always set the following properties: - - `dashGridOptions={"theme": "themeBalham", "animateRows": True, "pagination": True, "paginationPageSize": 10}` - `columnSize="responsiveSizeToFit"` - `defaultColDef={"filter": True, "sortable": True}` + - `dashGridOptions={"theme": "themeBalham", "animateRows": True, ...}`, choosing pagination settings based on row count: + - 15 rows or fewer: `{"pagination": False, "domLayout": "autoHeight"}` — the grid sizes to its content instead of drawing a tall empty box with a pager underneath a handful of rows. + - more than 15 rows: `{"pagination": True, "paginationPageSize": 10}` + +## Fullscreen Toggle Pattern +A reusable "expand to fullscreen" button for any chart inside a `.visual` box. No Dash callback is needed — it's pure CSS + one small JS file in `assets/`, so it automatically applies to any current or future chart that follows the markup pattern below. + +**Why it's built this way (read this before changing it):** the naive version — toggle a `position: fixed` class and call `Plotly.Plots.resize(gd)` — breaks in two ways that are easy to reintroduce by accident: +1. Outside fullscreen, a plot's container normally has no explicit height (it's sized *by* the plot, not the other way round). Asking Plotly to "resize to fit its container" on exit is therefore circular — the container has no size to resize to, and the chart doesn't shrink back. +2. `dcc.Loading` wraps the graph in one or two extra `
`s whose class names aren't part of the public Dash API. Trying to cascade a height down through them with CSS percentages (`height: 100%` chained through unknown wrapper divs) silently breaks and leaves the chart stuck at a stale pixel size — which, in a flex row with the default `align-items: stretch`, then drags the *other* column's box height along with it. + +The fix: give the chart's own wrapper (`.graph-wrap`, not the Plotly div itself) an explicit, always-defined height in both states (fixed px normally, flex-filled in fullscreen), then measure that wrapper directly in JS and set the chart's exact pixel size via `Plotly.relayout(gd, {width, height, autosize: false})`. This never depends on the unknown internal `dcc.Loading` DOM structure. + +**Markup** — wrap every chart to make fullscreen-able like this: +```python +html.Div( + [ + html.Button( + "⛶", + className="fullscreen-toggle-btn", + title="Toggle full screen", + **{"aria-label": "Toggle full screen"}, + ), + html.Div("Chart Title", className="visual-title"), + html.Div( + dcc.Loading(dcc.Graph(id="my-chart")), + className="graph-wrap", + ), + ], + className="visual", +) +``` +If several charts sit side by side in a flex row, add `"alignItems": "flex-start"` to that row's `style` dict — a safety net so one chart's sizing hiccup can never stretch its neighbor. + +The CSS lives in `assets/css/main.css` (the `.visual`, `.fullscreen-toggle-btn`, `.visual--fullscreen`, `.graph-wrap` and `body.fullscreen-active` rules) and the JS lives in `assets/fullscreen.js`. Both are already in the template and apply automatically — no per-page wiring needed beyond the markup above. + +**Rules when reusing this:** +- Always wrap the chart in `.graph-wrap` — never put `fullscreen-toggle-btn` next to a bare `dcc.Graph`/`dcc.Loading` without it; the JS measures `.graph-wrap`, not the Plotly div, so skipping it breaks the resize. +- Don't try to make the chart's height cascade through CSS percentages past `.graph-wrap` — that's the exact thing that broke before. Let the JS set the Plotly size explicitly. +- Only one visual can be fullscreen at a time by design (`enterFullscreen` clears any other `.visual--fullscreen` first); don't remove that if adding more charts. ## Avoid Hallucinations - Never use `app.run_server`; only use `app.run` diff --git a/README.md b/README.md index 2840f59..c2e2114 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Python Dashboard Template -A starting point for Plotly Dash dashboards: one theme, one working example -page and one filterable-chart page, a mock-up of 4insight's header for local -layout work, and the process scaffolding (CI, PR template, `CLAUDE.md` +A starting point for Plotly Dash dashboards: one theme, a landing page with a +revision log and notes box, a data-table example page and a +filterable-chart page, a mock-up of 4insight's header for local layout +work, and the process scaffolding (CI, PR template, `CLAUDE.md` conventions) set up. To start a new project from this template: clone or copy it, replace -`src/assets/sample_data.csv` and the two pages in `src/pages/` with your own, +`src/assets/sample_data.csv` and the pages in `src/pages/` with your own, and update this README and the browser-tab title in `src/app.py`. ## Running it @@ -50,8 +51,9 @@ src/ ├── theme.py colours, type scale and the Plotly template ├── memory_log.py dev aid: prints RSS memory usage, see LOG_MEMORY ├── pages/ -│ ├── home.py example: an AgGrid over the sample data -│ └── analytics.py example: a slicer driving a filtered Plotly chart +│ ├── introduction.py landing page: revision log AgGrid and a free-text notes box +│ ├── data_table.py example: an AgGrid over the sample data +│ └── analytics.py example: a slicer driving a filtered Plotly chart └── assets/ ├── css/main.css page styling, mirrors theme.py as CSS variables ├── 4insight_logo.png diff --git a/src/assets/css/main.css b/src/assets/css/main.css index 7aedc2d..cdbe314 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -257,6 +257,12 @@ body, -------------------------------------------------------------------------- */ .visual { + position: relative; + /* so .fullscreen-toggle-btn can be pinned to a corner. No overflow: hidden + here - .graph-wrap already clips chart overflow during resize (see + fullscreen.js), and hiding overflow on every .visual would also clip + unrelated content that legitimately grows past the box, such as a + resizable .notes-textarea. */ border: 1px solid var(--border-grey-1); border-radius: var(--radius); padding: 12px; @@ -287,6 +293,18 @@ body, margin-bottom: var(--gap); } +.notes-textarea { + width: 100%; + min-height: 120px; + padding: 0; + border: none; + font-family: var(--font-main); + font-size: var(--size-body); + color: var(--body-text); + box-sizing: border-box; + resize: vertical; +} + /* -------------------------------------------------------------------------- Tables (AG Grid) top border 2px dark grey · inner 1px grey · headers size 9 dark grey @@ -391,4 +409,71 @@ input[disabled] { border: 1px solid var(--border-grey-2); background-color: var(--border-grey-1); color: var(--grey); +} + +/* -------------------------------------------------------------------------- + Fullscreen toggle for chart visuals — see assets/fullscreen.js and the + "Fullscreen Toggle Pattern" section in CLAUDE.md for the markup and the + reasoning behind this approach. + -------------------------------------------------------------------------- */ + +.fullscreen-toggle-btn { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.visual--fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + z-index: 2000; + margin: 0; + border-radius: 0; + padding: 24px; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: auto; +} + +.visual--fullscreen .fullscreen-toggle-btn { + top: 16px; + right: 16px; +} + +.visual--fullscreen .visual-title { + flex: 0 0 auto; +} + +/* .graph-wrap's own box size is always deterministic - a fixed height + normally, or flex-filled the remaining fullscreen space. We do NOT rely + on that cascading further down into dcc.Loading's wrapper divs; the JS + in fullscreen.js measures this box directly instead. */ +.graph-wrap { + height: 460px; + overflow: hidden; +} + +.visual--fullscreen .graph-wrap { + flex: 1 1 auto; + min-height: 0; + height: auto; +} + +body.fullscreen-active { + overflow: hidden; } \ No newline at end of file diff --git a/src/assets/fullscreen.js b/src/assets/fullscreen.js new file mode 100644 index 0000000..131b000 --- /dev/null +++ b/src/assets/fullscreen.js @@ -0,0 +1,61 @@ +// Fullscreen toggle for chart visuals. See the "Fullscreen Toggle Pattern" +// section in CLAUDE.md for the required markup and the reasoning behind +// this approach. +// +// Event delegation (rather than per-chart wiring) so this survives +// dcc.Loading re-rendering the DOM, and applies automatically to any +// current or future chart that follows the markup pattern. + +function resizeGraphsIn(container) { + if (!window.Plotly) return; + container.querySelectorAll(".graph-wrap").forEach(function (wrap) { + const gd = wrap.querySelector(".js-plotly-plot"); + if (!gd) return; + const rect = wrap.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (width > 0 && height > 0) { + window.Plotly.relayout(gd, { width: width, height: height, autosize: false }); + } + }); +} + +function settleResize(visual) { + requestAnimationFrame(function () { + resizeGraphsIn(visual); + setTimeout(function () { resizeGraphsIn(visual); }, 100); + }); +} + +function exitFullscreen(visual) { + visual.classList.remove("visual--fullscreen"); + document.body.classList.remove("fullscreen-active"); + settleResize(visual); +} + +function enterFullscreen(visual) { + document.querySelectorAll(".visual--fullscreen").forEach(function (el) { + el.classList.remove("visual--fullscreen"); + }); + visual.classList.add("visual--fullscreen"); + document.body.classList.add("fullscreen-active"); + settleResize(visual); +} + +document.addEventListener("click", function (event) { + const btn = event.target.closest(".fullscreen-toggle-btn"); + if (!btn) return; + const visual = btn.closest(".visual"); + if (!visual) return; + if (visual.classList.contains("visual--fullscreen")) { + exitFullscreen(visual); + } else { + enterFullscreen(visual); + } +}); + +document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") return; + const visual = document.querySelector(".visual--fullscreen"); + if (visual) exitFullscreen(visual); +}); diff --git a/src/pages/analytics.py b/src/pages/analytics.py index 5e9b3b1..d1857fb 100644 --- a/src/pages/analytics.py +++ b/src/pages/analytics.py @@ -12,15 +12,15 @@ import plotly.express as px from dash import Input, Output, callback, dcc, html -dash.register_page(__name__, path="/analytics", name="Analytics", order=1) +dash.register_page(__name__, path="/analytics", name="Analytics", order=2) SAMPLE_DATA_PATH = pathlib.Path(__file__).resolve().parents[1] / "assets" / "sample_data.csv" def load_sample_data(): """Not shared with app.py: importing from app here would re-trigger Dash's - own page auto-discovery when the app is run as a script. See home.py for - the same function - duplicated rather than imported, on purpose.""" + own page auto-discovery when the app is run as a script. See data_table.py + for the same function - duplicated rather than imported, on purpose.""" return pd.read_csv(SAMPLE_DATA_PATH) diff --git a/src/pages/home.py b/src/pages/data_table.py similarity index 79% rename from src/pages/home.py rename to src/pages/data_table.py index 6ed07c8..d50119f 100644 --- a/src/pages/home.py +++ b/src/pages/data_table.py @@ -1,10 +1,10 @@ """ -Landing page ------------- +Data table page +---------------- -This is the "Home" page, which is the first page users see when they open the app. -It contains a table of sample data fetched from the "assets/sample_data.csv" file. -The table is implemented using the Dash AG Grid component, which allows for filtering, sorting, and pagination. +Example page showing a table of sample data fetched from the +"assets/sample_data.csv" file. The table is implemented using the Dash AG +Grid component, which allows for filtering, sorting, and pagination. """ @@ -15,7 +15,7 @@ import pandas as pd from dash import dcc, html -dash.register_page(__name__, path="/", name="Home", order=0) +dash.register_page(__name__, path="/data-table", name="Data table", order=1) SAMPLE_DATA_PATH = pathlib.Path(__file__).resolve().parents[1] / "assets" / "sample_data.csv" @@ -30,7 +30,7 @@ def load_sample_data(): def layout(): df = load_sample_data() grid = dag.AgGrid( - id="home-sample-grid", + id="data-table-sample-grid", rowData=df.to_dict("records"), columnDefs=[{"field": col, "headerName": col} for col in df.columns], defaultColDef={"filter": True, "sortable": True}, diff --git a/src/pages/introduction.py b/src/pages/introduction.py new file mode 100644 index 0000000..b37d668 --- /dev/null +++ b/src/pages/introduction.py @@ -0,0 +1,86 @@ +"""Introduction page: revision log and a free-text notes box. + +Landing page. The revision log records who issued, checked and approved +each version of the app's content - add a row to issue a new revision +rather than editing the last one, since the point of the log is the +history. +""" + +import dash +import dash_ag_grid as dag +from dash import dcc, html + +dash.register_page(__name__, path="/", name="Introduction", order=0) + +# Empty Checked, Approved, etc. mean exactly that: not yet checked, not yet +# approved. Fill them in when you fill in a real revision. + +REVISION_LOG = [ + { + "Revision No.": "1.0", + "Date": "2026-01-01", + "Author": "ABC", + "Checked": "DEF", + "Approved": "GHI", + "Comment": " ", + }, +] + + +def layout(): + grid = dag.AgGrid( + id="introduction-revision-log-grid", + rowData=REVISION_LOG, + # headerName explicitly, or AG Grid title-cases the field: "Comment" + # would still be fine, but "Revision No." would render as "Revision No .". + # flex: the first 5 columns are 20% narrower than an even split, and + # Comment absorbs that freed width (0.8 * 5 = 4, so Comment's flex of + # 2 keeps the same total of 6 that six equal columns would have had). + columnDefs=( + [{"field": col, "headerName": col, "flex": 0.8} for col in list(REVISION_LOG[0])[:5]] + + [{"field": "Comment", "headerName": "Comment", "flex": 2}] + ), + defaultColDef={"filter": True, "sortable": True}, + # No columnSize: AG Grid's sizeColumnsToFit (what "responsiveSizeToFit" + # calls) recalculates widths on its own and overrides colDef.flex in + # the process - the two are alternative sizing mechanisms, not + # composable. Flex-sized columns already resize responsively without it. + dashGridOptions={ + "theme": "themeBalham", + "animateRows": True, + # No pagination: the log only ever holds a handful of rows, and + # autoHeight sizes the grid to its content instead of drawing a + # tall empty box with a pager underneath it. + "pagination": False, + "domLayout": "autoHeight", + # A dot in a field name is a nested-property path to AG Grid, so + # "Revision No." would look up row["Revision No"][""] and render + # blank. The field names here are human labels, never paths. + "suppressFieldDotNotation": True, + }, + ) + return html.Div( + [ + html.Div( + [ + html.Div("Project info", className="visual-title"), + dcc.Textarea( + id="introduction-notes-textarea", + value="Here you can write free text about the project, the dashboard or other relevant information.", + className="notes-textarea", + ), + ], + className="visual", + ), + html.Div( + [ + html.Div("Revision log", className="visual-title"), + dcc.Loading(grid), + ], + className="visual row-gap", + ), + ], + # A small table and a text box read as mostly whitespace spread + # across the full 1800px content width. + className="narrow-page", + ) diff --git a/tests/test_app.py b/tests/test_app.py index 8dd30a8..9e9d505 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,7 +10,8 @@ # sharing an `order` - this dict is the spec these tests check reality # against, so add your new page's path/name here too. EXPECTED_PAGES = { - "/": "Home", + "/": "Introduction", + "/data-table": "Data table", "/analytics": "Analytics", } @@ -62,7 +63,8 @@ def component_ids(node, found=None): # Same deal as EXPECTED_PAGES: add your new page's callback-bound component # ids here, or they simply aren't checked (not a failure, just a silent gap). CALLBACK_IDS = { - "/": {"home-sample-grid"}, + "/": {"introduction-revision-log-grid"}, + "/data-table": {"data-table-sample-grid"}, "/analytics": {"analytics-category-filter", "analytics-chart"}, }