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
46 changes: 43 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
anna-follestad-4ss marked this conversation as resolved.
│ ├── home.py
│ ├── introduction.py
│ ├── data_table.py
│ └── analytics.py
├── tests/ # pytest suite
├── notebooks/ # ad-hoc exploration, outside the running app
Expand Down Expand Up @@ -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 `<div>`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`
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
61 changes: 61 additions & 0 deletions src/assets/fullscreen.js
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
anna-follestad-4ss marked this conversation as resolved.

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);
});
6 changes: 3 additions & 3 deletions src/pages/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
14 changes: 7 additions & 7 deletions src/pages/home.py → src/pages/data_table.py
Original file line number Diff line number Diff line change
@@ -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.

"""

Expand All @@ -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"

Expand All @@ -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},
Expand Down
Loading
Loading