feat: Add floating sidebar navigation and dark theme support

- Add floating pill-shaped sidebar with navigation icons
- Implement dark/light theme toggle with localStorage persistence
- Update all figure factories for transparent backgrounds
- Use carto-darkmatter map style for choropleths
- Add methodology link button to Toronto dashboard header
- Add back to dashboard button on methodology page
- Remove social links from home page (now in sidebar)
- Update CLAUDE.md to Sprint 7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 11:53:13 -05:00
parent 1e0ea9cca2
commit b3fb94c7cb
13 changed files with 475 additions and 49 deletions

View File

@@ -6,7 +6,7 @@ Working context for Claude Code on the Analytics Portfolio project.
## Project Status ## Project Status
**Current Sprint**: 1 (Project Bootstrap) **Current Sprint**: 7 (Navigation & Theme Modernization)
**Phase**: 1 - Toronto Housing Dashboard **Phase**: 1 - Toronto Housing Dashboard
**Branch**: `development` (feature branches merge here) **Branch**: `development` (feature branches merge here)
@@ -254,4 +254,4 @@ All scripts in `scripts/`:
--- ---
*Last Updated: Sprint 1* *Last Updated: Sprint 7*

View File

@@ -2,7 +2,9 @@
import dash import dash
import dash_mantine_components as dmc import dash_mantine_components as dmc
from dash import dcc, html
from .components import create_sidebar
from .config import get_settings from .config import get_settings
@@ -17,14 +19,31 @@ def create_app() -> dash.Dash:
) )
app.layout = dmc.MantineProvider( app.layout = dmc.MantineProvider(
id="mantine-provider",
children=[
dcc.Location(id="url", refresh=False),
dcc.Store(id="theme-store", storage_type="local", data="dark"),
dcc.Store(id="theme-init-dummy"), # Dummy store for theme init callback
html.Div(
[
create_sidebar(),
html.Div(
dash.page_container, dash.page_container,
className="page-content-wrapper",
),
],
),
],
theme={ theme={
"primaryColor": "blue", "primaryColor": "blue",
"fontFamily": "'Inter', sans-serif", "fontFamily": "'Inter', sans-serif",
}, },
forceColorScheme="light", defaultColorScheme="dark",
) )
# Import callbacks to register them
from . import callbacks # noqa: F401
return app return app

View File

@@ -0,0 +1,139 @@
/* Floating sidebar navigation styles */
/* Sidebar container */
.floating-sidebar {
position: fixed;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 60px;
padding: 16px 8px;
border-radius: 32px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: background-color 0.2s ease;
}
/* Page content offset to prevent sidebar overlap */
.page-content-wrapper {
margin-left: 92px; /* sidebar width (60px) + left margin (16px) + gap (16px) */
min-height: 100vh;
}
/* Dark theme (default) */
[data-mantine-color-scheme="dark"] .floating-sidebar {
background-color: #141414;
}
[data-mantine-color-scheme="dark"] body {
background-color: #000000;
}
/* Light theme */
[data-mantine-color-scheme="light"] .floating-sidebar {
background-color: #f0f0f0;
}
[data-mantine-color-scheme="light"] body {
background-color: #ffffff;
}
/* Brand initials styling */
.sidebar-brand {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--mantine-color-blue-filled);
margin-bottom: 4px;
transition: transform 0.2s ease;
}
.sidebar-brand:hover {
transform: scale(1.05);
}
.sidebar-brand-link {
font-weight: 700;
font-size: 16px;
color: white;
text-decoration: none;
line-height: 1;
}
/* Divider between sections */
.sidebar-divider {
width: 32px;
height: 1px;
background-color: var(--mantine-color-dimmed);
margin: 4px 0;
opacity: 0.3;
}
/* Active nav icon indicator */
.nav-icon-active {
background-color: var(--mantine-color-blue-filled) !important;
}
/* Navigation icon hover effects */
.floating-sidebar .mantine-ActionIcon-root {
transition: transform 0.15s ease, background-color 0.15s ease;
}
.floating-sidebar .mantine-ActionIcon-root:hover {
transform: scale(1.1);
}
/* Ensure links don't have underlines */
.floating-sidebar a {
text-decoration: none;
}
/* Theme toggle specific styling */
#theme-toggle {
transition: transform 0.3s ease;
}
#theme-toggle:hover {
transform: rotate(15deg) scale(1.1);
}
/* Responsive adjustments for smaller screens */
@media (max-width: 768px) {
.floating-sidebar {
left: 8px;
width: 50px;
padding: 12px 6px;
border-radius: 25px;
}
.page-content-wrapper {
margin-left: 70px;
}
.sidebar-brand {
width: 34px;
height: 34px;
}
.sidebar-brand-link {
font-size: 14px;
}
}
/* Very small screens - hide sidebar, show minimal navigation */
@media (max-width: 480px) {
.floating-sidebar {
display: none;
}
.page-content-wrapper {
margin-left: 0;
}
}

View File

@@ -0,0 +1,5 @@
"""Application-level callbacks for the portfolio app."""
from . import theme
__all__ = ["theme"]

View File

@@ -0,0 +1,38 @@
"""Theme toggle callbacks using clientside JavaScript."""
from dash import Input, Output, State, clientside_callback
# Toggle theme on button click
# Stores new theme value and updates the DOM attribute
clientside_callback(
"""
function(n_clicks, currentTheme) {
if (n_clicks === undefined || n_clicks === null) {
return window.dash_clientside.no_update;
}
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-mantine-color-scheme', newTheme);
return newTheme;
}
""",
Output("theme-store", "data"),
Input("theme-toggle", "n_clicks"),
State("theme-store", "data"),
prevent_initial_call=True,
)
# Initialize theme from localStorage on page load
# Uses a dummy output since we only need the side effect of setting the DOM attribute
clientside_callback(
"""
function(theme) {
if (theme) {
document.documentElement.setAttribute('data-mantine-color-scheme', theme);
}
return theme;
}
""",
Output("theme-init-dummy", "data"),
Input("theme-store", "data"),
prevent_initial_call=False,
)

View File

@@ -2,11 +2,13 @@
from .map_controls import create_map_controls, create_metric_selector from .map_controls import create_map_controls, create_metric_selector
from .metric_card import MetricCard, create_metric_cards_row from .metric_card import MetricCard, create_metric_cards_row
from .sidebar import create_sidebar
from .time_slider import create_time_slider, create_year_selector from .time_slider import create_time_slider, create_year_selector
__all__ = [ __all__ = [
"create_map_controls", "create_map_controls",
"create_metric_selector", "create_metric_selector",
"create_sidebar",
"create_time_slider", "create_time_slider",
"create_year_selector", "create_year_selector",
"MetricCard", "MetricCard",

View File

@@ -0,0 +1,179 @@
"""Floating sidebar navigation component."""
import dash_mantine_components as dmc
from dash import dcc, html
from dash_iconify import DashIconify
# Navigation items configuration
NAV_ITEMS = [
{"path": "/", "icon": "tabler:home", "label": "Home"},
{"path": "/toronto", "icon": "tabler:map-2", "label": "Toronto Housing"},
]
# External links configuration
EXTERNAL_LINKS = [
{
"url": "https://github.com/leomiranda",
"icon": "tabler:brand-github",
"label": "GitHub",
},
{
"url": "https://linkedin.com/in/leobmiranda",
"icon": "tabler:brand-linkedin",
"label": "LinkedIn",
},
]
def create_brand_logo() -> html.Div:
"""Create the brand initials logo."""
return html.Div(
dcc.Link(
"LM",
href="/",
className="sidebar-brand-link",
),
className="sidebar-brand",
)
def create_nav_icon(
icon: str,
label: str,
path: str,
current_path: str,
) -> dmc.Tooltip:
"""Create a navigation icon with tooltip.
Args:
icon: Iconify icon string.
label: Tooltip label.
path: Navigation path.
current_path: Current page path for active state.
Returns:
Tooltip-wrapped navigation icon.
"""
is_active = current_path == path or (path != "/" and current_path.startswith(path))
return dmc.Tooltip(
dcc.Link(
dmc.ActionIcon(
DashIconify(icon=icon, width=20),
variant="subtle" if not is_active else "filled",
size="lg",
radius="xl",
color="blue" if is_active else "gray",
className="nav-icon-active" if is_active else "",
),
href=path,
),
label=label,
position="right",
withArrow=True,
)
def create_theme_toggle(current_theme: str = "dark") -> dmc.Tooltip:
"""Create the theme toggle button.
Args:
current_theme: Current theme ('dark' or 'light').
Returns:
Tooltip-wrapped theme toggle icon.
"""
icon = "tabler:sun" if current_theme == "dark" else "tabler:moon"
label = "Switch to light mode" if current_theme == "dark" else "Switch to dark mode"
return dmc.Tooltip(
dmc.ActionIcon(
DashIconify(icon=icon, width=20, id="theme-toggle-icon"),
id="theme-toggle",
variant="subtle",
size="lg",
radius="xl",
color="gray",
),
label=label,
position="right",
withArrow=True,
)
def create_external_link(url: str, icon: str, label: str) -> dmc.Tooltip:
"""Create an external link icon with tooltip.
Args:
url: External URL.
icon: Iconify icon string.
label: Tooltip label.
Returns:
Tooltip-wrapped external link icon.
"""
return dmc.Tooltip(
dmc.Anchor(
dmc.ActionIcon(
DashIconify(icon=icon, width=20),
variant="subtle",
size="lg",
radius="xl",
color="gray",
),
href=url,
target="_blank",
),
label=label,
position="right",
withArrow=True,
)
def create_sidebar_divider() -> html.Div:
"""Create a horizontal divider for the sidebar."""
return html.Div(className="sidebar-divider")
def create_sidebar(current_path: str = "/", current_theme: str = "dark") -> html.Div:
"""Create the floating sidebar navigation.
Args:
current_path: Current page path for active state highlighting.
current_theme: Current theme for toggle icon state.
Returns:
Complete sidebar component.
"""
return html.Div(
[
# Brand logo
create_brand_logo(),
create_sidebar_divider(),
# Navigation icons
*[
create_nav_icon(
icon=item["icon"],
label=item["label"],
path=item["path"],
current_path=current_path,
)
for item in NAV_ITEMS
],
create_sidebar_divider(),
# Theme toggle
create_theme_toggle(current_theme),
create_sidebar_divider(),
# External links
*[
create_external_link(
url=link["url"],
icon=link["icon"],
label=link["label"],
)
for link in EXTERNAL_LINKS
],
],
className="floating-sidebar",
id="floating-sidebar",
)

View File

@@ -39,6 +39,10 @@ def create_choropleth_figure(
if center is None: if center is None:
center = {"lat": 43.7, "lon": -79.4} center = {"lat": 43.7, "lon": -79.4}
# Use dark-mode friendly map style by default
if map_style == "carto-positron":
map_style = "carto-darkmatter"
# If no geojson provided, create a placeholder map # If no geojson provided, create a placeholder map
if geojson is None or not data: if geojson is None or not data:
fig = go.Figure(go.Scattermapbox()) fig = go.Figure(go.Scattermapbox())
@@ -51,6 +55,9 @@ def create_choropleth_figure(
margin={"l": 0, "r": 0, "t": 40, "b": 0}, margin={"l": 0, "r": 0, "t": 40, "b": 0},
title=title or "Toronto Housing Map", title=title or "Toronto Housing Map",
height=500, height=500,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
) )
fig.add_annotation( fig.add_annotation(
text="No geometry data available. Complete QGIS digitization to enable map.", text="No geometry data available. Complete QGIS digitization to enable map.",
@@ -59,7 +66,7 @@ def create_choropleth_figure(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "gray"}, font={"size": 14, "color": "#888888"},
) )
return fig return fig
@@ -68,6 +75,11 @@ def create_choropleth_figure(
df = pd.DataFrame(data) df = pd.DataFrame(data)
# Use dark-mode friendly map style
effective_map_style = (
"carto-darkmatter" if map_style == "carto-positron" else map_style
)
fig = px.choropleth_mapbox( fig = px.choropleth_mapbox(
df, df,
geojson=geojson, geojson=geojson,
@@ -76,7 +88,7 @@ def create_choropleth_figure(
color=color_column, color=color_column,
color_continuous_scale=color_scale, color_continuous_scale=color_scale,
hover_data=hover_data, hover_data=hover_data,
mapbox_style=map_style, mapbox_style=effective_map_style,
center=center, center=center,
zoom=zoom, zoom=zoom,
opacity=0.7, opacity=0.7,
@@ -86,10 +98,17 @@ def create_choropleth_figure(
margin={"l": 0, "r": 0, "t": 40, "b": 0}, margin={"l": 0, "r": 0, "t": 40, "b": 0},
title=title, title=title,
height=500, height=500,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
coloraxis_colorbar={ coloraxis_colorbar={
"title": color_column.replace("_", " ").title(), "title": {
"text": color_column.replace("_", " ").title(),
"font": {"color": "#c9c9c9"},
},
"thickness": 15, "thickness": 15,
"len": 0.7, "len": 0.7,
"tickfont": {"color": "#c9c9c9"},
}, },
) )

View File

@@ -69,7 +69,8 @@ def create_metric_card_figure(
height=120, height=120,
margin={"l": 20, "r": 20, "t": 40, "b": 20}, margin={"l": 20, "r": 20, "t": 40, "b": 20},
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
font={"family": "Inter, sans-serif"}, plot_bgcolor="rgba(0,0,0,0)",
font={"family": "Inter, sans-serif", "color": "#c9c9c9"},
) )
return fig return fig

View File

@@ -38,8 +38,15 @@ def create_price_time_series(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"color": "#888888"},
)
fig.update_layout(
title=title,
height=350,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
) )
fig.update_layout(title=title, height=350)
return fig return fig
df = pd.DataFrame(data) df = pd.DataFrame(data)
@@ -69,6 +76,11 @@ def create_price_time_series(
yaxis_tickprefix="$", yaxis_tickprefix="$",
yaxis_tickformat=",", yaxis_tickformat=",",
hovermode="x unified", hovermode="x unified",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "#333333", "linecolor": "#444444"},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"},
) )
return fig return fig
@@ -106,8 +118,15 @@ def create_volume_time_series(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"color": "#888888"},
)
fig.update_layout(
title=title,
height=350,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
) )
fig.update_layout(title=title, height=350)
return fig return fig
df = pd.DataFrame(data) df = pd.DataFrame(data)
@@ -153,6 +172,11 @@ def create_volume_time_series(
yaxis_title=volume_column.replace("_", " ").title(), yaxis_title=volume_column.replace("_", " ").title(),
yaxis_tickformat=",", yaxis_tickformat=",",
hovermode="x unified", hovermode="x unified",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "#333333", "linecolor": "#444444"},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"},
) )
return fig return fig
@@ -187,8 +211,15 @@ def create_market_comparison_chart(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"color": "#888888"},
)
fig.update_layout(
title=title,
height=400,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
) )
fig.update_layout(title=title, height=400)
return fig return fig
if metrics is None: if metrics is None:
@@ -221,12 +252,18 @@ def create_market_comparison_chart(
height=400, height=400,
margin={"l": 40, "r": 40, "t": 50, "b": 40}, margin={"l": 40, "r": 40, "t": 50, "b": 40},
hovermode="x unified", hovermode="x unified",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font_color="#c9c9c9",
xaxis={"gridcolor": "#333333", "linecolor": "#444444"},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"},
legend={ legend={
"orientation": "h", "orientation": "h",
"yanchor": "bottom", "yanchor": "bottom",
"y": 1.02, "y": 1.02,
"xanchor": "right", "xanchor": "right",
"x": 1, "x": 1,
"font": {"color": "#c9c9c9"},
}, },
) )

View File

@@ -2,7 +2,6 @@
import dash import dash
import dash_mantine_components as dmc import dash_mantine_components as dmc
from dash_iconify import DashIconify
dash.register_page(__name__, path="/", name="Home") dash.register_page(__name__, path="/", name="Home")
@@ -52,19 +51,6 @@ PROJECTS = [
}, },
] ]
SOCIAL_LINKS = [
{
"platform": "LinkedIn",
"url": "https://linkedin.com/in/leobmiranda",
"icon": "mdi:linkedin",
},
{
"platform": "GitHub",
"url": "https://github.com/leomiranda",
"icon": "mdi:github",
},
]
AVAILABILITY = "Open to Senior Data Analyst, Analytics Engineer, and BI Developer opportunities in Toronto or remote." AVAILABILITY = "Open to Senior Data Analyst, Analytics Engineer, and BI Developer opportunities in Toronto or remote."
@@ -160,27 +146,6 @@ def create_projects_section() -> dmc.Paper:
) )
def create_social_links() -> dmc.Group:
"""Create social media links."""
return dmc.Group(
[
dmc.Anchor(
dmc.Button(
link["platform"],
leftSection=DashIconify(icon=link["icon"], width=20),
variant="outline",
size="md",
),
href=link["url"],
target="_blank",
)
for link in SOCIAL_LINKS
],
justify="center",
gap="md",
)
def create_availability_section() -> dmc.Text: def create_availability_section() -> dmc.Text:
"""Create the availability statement.""" """Create the availability statement."""
return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic") return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic")
@@ -193,7 +158,6 @@ layout = dmc.Container(
create_summary_section(), create_summary_section(),
create_tech_stack_section(), create_tech_stack_section(),
create_projects_section(), create_projects_section(),
create_social_links(),
dmc.Divider(my="lg"), dmc.Divider(my="lg"),
create_availability_section(), create_availability_section(),
dmc.Space(h=40), dmc.Space(h=40),

View File

@@ -3,6 +3,7 @@
import dash import dash
import dash_mantine_components as dmc import dash_mantine_components as dmc
from dash import dcc, html from dash import dcc, html
from dash_iconify import DashIconify
from portfolio_app.components import ( from portfolio_app.components import (
create_map_controls, create_map_controls,
@@ -76,6 +77,17 @@ def create_header() -> dmc.Group:
), ),
dmc.Group( dmc.Group(
[ [
dcc.Link(
dmc.Button(
"Methodology",
leftSection=DashIconify(
icon="tabler:info-circle", width=18
),
variant="subtle",
color="gray",
),
href="/toronto/methodology",
),
create_year_selector( create_year_selector(
id_prefix="toronto", id_prefix="toronto",
min_year=2020, min_year=2020,

View File

@@ -2,7 +2,8 @@
import dash import dash
import dash_mantine_components as dmc import dash_mantine_components as dmc
from dash import html from dash import dcc, html
from dash_iconify import DashIconify
dash.register_page( dash.register_page(
__name__, __name__,
@@ -18,8 +19,18 @@ def layout() -> dmc.Container:
size="md", size="md",
py="xl", py="xl",
children=[ children=[
# Back to Dashboard button
dcc.Link(
dmc.Button(
"Back to Dashboard",
leftSection=DashIconify(icon="tabler:arrow-left", width=18),
variant="subtle",
color="gray",
),
href="/toronto",
),
# Header # Header
dmc.Title("Methodology", order=1, mb="lg"), dmc.Title("Methodology", order=1, mb="lg", mt="md"),
dmc.Text( dmc.Text(
"This page documents the data sources, processing methodology, " "This page documents the data sources, processing methodology, "
"and known limitations of the Toronto Housing Dashboard.", "and known limitations of the Toronto Housing Dashboard.",