This guide covers the new public dashboard page authoring API.
Use it when the summary table you need already exists and you want to add or refactor a page under dashboard/pages/.
If the summary does not exist yet, start with adding-summaries.md.
A dashboard page now has one source of truth for page-local interactivity:
- register selectors once
- register sections once
- render section content from pure-ish render functions
The framework takes care of:
- widget watchers
- stable section containers
- rerendering only the affected sections when a selector changes
- rerendering all sections when the global dashboard state changes
- deriving export selector and export region metadata from those same registrations
Each page module still exports a module-level PAGE = DashboardPageDefinition(...).
DashboardPageDefinition is intentionally narrow:
page_idtitlepage_clsordergroup_iddefault_enabledprepared_data_moderequired_summary_idsrequired_prepared_tables
Grouped navigation is declared in a sibling GROUP = DashboardGroupDefinition(...) inside the package __init__.py.
DashboardGroupDefinition now uses default_page_id, not default_child_id.
There is no child_id.
Page authors subclass DashboardPage.
The public lifecycle hooks are:
build_page(self) -> pn.viewable.Viewablesync_controls(self) -> Noneon_global_state_changed(self) -> None
build_page() is required. It should:
- create widgets
- register selectors
- register sections
- return one stable root view
- avoid heavy data reshaping or cross-run filtering work
sync_controls() is optional. It runs before every refresh pass and is the right place to:
- populate selector options from current data availability
- reset invalid widget values to safe defaults
- keep selector value/bootstrap logic out of
render_*()methods
on_global_state_changed() is optional. Use it for page-local cache invalidation when weighting mode, value mode, or available runs change.
When adding or refactoring a page, aim for this shape:
-
build_page()- declare widgets
- register selectors
- register sections
- return the stable root layout
-
sync_controls()- compute selector options from current dashboard state
- keep selector defaults valid
-
render_*()section methods- one logical section per render method
- narrow control flow
- prefer pure helper functions for chart-ready reshaping
-
shared helpers
- use
dashboard/helpers/for logic that appears in multiple pages - keep page-local helpers only for truly page-specific business rules
- use
As a rule of thumb:
build_page()should read like layout assemblysync_controls()should read like selector synchronizationrender_*()should read like "load data, handle missing state, render views"
Use the current pages below as implementation references when possible:
dashboard/pages/overview.py- simplest summary-only page
- good reference for straightforward section rendering
dashboard/pages/raw_trip_demo.py- smallest prepared-data page
- good reference for
resolve_prepared_visualization(...)
dashboard/pages/trip_summaries/trip_mode.py- good reference for one-selector, one-section chart pages
dashboard/pages/long_term_choices/mandatory_location_choice.py- good reference for multi-section geography-driven pages
- also the main complexity target when refactoring shared geography helpers
dashboard/pages/skim_summaries/trip_skims.py- strongest current example of selector sync plus independently refreshed sections
dashboard/pages/skim_summaries/tour_skims.py- strongest current example of sectioned live distributions with non-exportable controls
The core authoring helpers are:
self.selector(
selector_id,
widget=...,
label="...",
exportable=True,
)
self.section(
section_id,
selectors=("selector_a", "selector_b"),
export=True,
render=self.render_section_name,
)
self.section_view("section_id")
self.mark_section_stale("section_id")The most commonly used data helpers remain available on DashboardPage:
resolve_summary_visualization(...)resolve_prepared_visualization(...)require_summary(...)require_summaries(...)optional_summary(...)unavailable_visualization(...)data_not_available_card(...)get_filtered_view(...)clear_filtered_view_cache(...)as_percentweighting_key
When page logic starts repeating, prefer one of the existing helper modules before adding page-local utility functions:
dashboard/helpers/category_helpers.py- selector option building
- config-ordered category values
- config-driven label columns
- category completion for charts and tables
dashboard/helpers/geography_helpers.py- geography column normalization
- geography level and geography id selector domains
- geography-level and geography-id filtering
- all-geographies and all-within-level handling
dashboard/helpers/person_type_helpers.py- person-type selector domains
- person-type filtering
- total-person-type rollups and weights
dashboard/helpers/time_distance_helpers.py- time-bin labels and durations
- distance-bin sorting
dashboard/helpers/comparison_helpers.py- percent-error formatting
- base-run percent-difference tables
- weighted average lookups for comparison tables
dashboard/pages/skim_summaries/_shared.py- skim-page-specific shared logic that should stay local to skim pages
If a transform is clearly reusable across unrelated pages, move it into
dashboard/helpers/. If it only makes sense for one page family, keep it close
to that family, as with the skim shared module or a page-local support module.
from __future__ import annotations
import panel as pn
from dashboard.page_base import DashboardPage, SectionContent
from dashboard.page_definitions import DashboardPageDefinition
class MyNewPage(DashboardPage):
def build_page(self) -> pn.viewable.Viewable:
self.purpose_sel = self.selector(
"purpose",
widget=pn.widgets.Select(name="Purpose", options=["Total"], value="Total"),
label="Purpose",
)
summary_section = self.section(
"summary",
selectors=("purpose",),
render=self.render_summary,
)
return pn.Column(
pn.pane.Markdown("## My New Page"),
pn.Row(pn.pane.Markdown("**Purpose:**"), self.purpose_sel),
summary_section,
sizing_mode="stretch_width",
)
def sync_controls(self) -> None:
options = self._purpose_options()
self.purpose_sel.options = options
if self.purpose_sel.value not in options:
self.purpose_sel.value = options[0]
def render_summary(self) -> SectionContent:
summaries = self.require_summaries(*self.required_summary_ids)
if summaries is None:
return [
self.data_not_available_card(
detail="This page depends on precomputed summaries.",
missing_items=list(self.required_summary_ids),
)
]
return [pn.pane.Markdown(f"Current purpose: {self.purpose_sel.value}")]
PAGE = DashboardPageDefinition(
page_id="my_new_page",
title="My New Page",
order=120,
page_cls=MyNewPage,
required_summary_ids=("my_summary_table",),
)
MyNewPage.definition = PAGEDo:
- create widgets in
build_page() - register every page-local interactive control with
selector(...) - register every refreshable content area with
section(...) - return content from section render functions
- keep expensive reshaping work behind
get_filtered_view(...) - prefer
require_summaries(...),optional_summary(...),resolve_summary_visualization(...), andresolve_prepared_visualization(...)over repeated ad hoc state lookups in render code - add a concise module docstring that explains what the page shows
- add short docstrings to helper functions when they encode a business rule that is not obvious from the function name
Do not:
- call
_watch_widget(...)on newly authored pages - assign
section.objects = ...directly from page code - declare page-local selector metadata in
PAGE - declare export regions in
PAGE - use
child_id - put large data-transformation blocks inline in
build_page() - let
render_*()methods become catch-all implementations for the entire page
The runtime now refreshes at section granularity.
Selector change:
- only sections that depend on that selector rerender
Global state change:
- all sections rerender
Sections declared with selectors=() only rerender on global refresh unless you explicitly call mark_section_stale(...).
Grouped pages are identified only by leaf page_id.
Live config uses leaf page ids inside a group:
dashboard:
live:
pages:
- overview
- tours:
- tour_summary
- tour_modeThe group package defines:
GROUP = DashboardGroupDefinition(
group_id="tours",
title="Tours",
order=30,
default_page_id="tour_summary",
)Export metadata is derived from runtime selector and section registration.
That means:
- registered selectors become export selector metadata
- registered exportable sections become export regions
- section selector dependencies define which selector combinations need pre-rendered variants
Grouped export config uses leaf page ids:
dashboard:
export:
pages:
trip_summaries:
children:
trip_mode:
tour_purpose: all- Add or update the page module under
dashboard/pages/. - Add or update the module-level
PAGE. - If the page belongs to a group, set
group_idonPAGEand keep the packageGROUPaligned withdefault_page_id. - Implement
build_page(). - Register selectors and sections.
- Keep selector option logic in
sync_controls(). - Keep section renderers short and move reusable transforms into shared helpers.
- Declare the summary/prepared-data contract in
PAGE. - Add or update tests covering selector refresh and missing-data behavior.
- If the page should export interactively, add an export-focused test slice too.
For most new pages, the fastest safe workflow is:
- Start from a nearby reference page with similar selectors and data shape.
- Keep
build_page()limited to widget creation, selector registration, section registration, and layout. - Move selector domain logic into
sync_controls(). - Keep each
render_*()method focused on one section. - Extract chart-ready reshaping into a small helper or shared helper module.
- Use
get_filtered_view(...)around any repeated cross-run filtering or aggregation. - Add one live refresh test and, if the page exports, one export-oriented test slice.