From b3fb94c7cba15a8174507ea8e722f1797b4649c0 Mon Sep 17 00:00:00 2001 From: lmiranda Date: Thu, 15 Jan 2026 11:53:13 -0500 Subject: [PATCH] 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 --- CLAUDE.md | 4 +- portfolio_app/app.py | 23 ++- portfolio_app/assets/sidebar.css | 139 ++++++++++++++++ portfolio_app/callbacks/__init__.py | 5 + portfolio_app/callbacks/theme.py | 38 +++++ portfolio_app/components/__init__.py | 2 + portfolio_app/components/sidebar.py | 179 +++++++++++++++++++++ portfolio_app/figures/choropleth.py | 25 ++- portfolio_app/figures/summary_cards.py | 3 +- portfolio_app/figures/time_series.py | 43 ++++- portfolio_app/pages/home.py | 36 ----- portfolio_app/pages/toronto/dashboard.py | 12 ++ portfolio_app/pages/toronto/methodology.py | 15 +- 13 files changed, 475 insertions(+), 49 deletions(-) create mode 100644 portfolio_app/assets/sidebar.css create mode 100644 portfolio_app/callbacks/__init__.py create mode 100644 portfolio_app/callbacks/theme.py create mode 100644 portfolio_app/components/sidebar.py diff --git a/CLAUDE.md b/CLAUDE.md index e6176e7..ebe2267 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Working context for Claude Code on the Analytics Portfolio project. ## Project Status -**Current Sprint**: 1 (Project Bootstrap) +**Current Sprint**: 7 (Navigation & Theme Modernization) **Phase**: 1 - Toronto Housing Dashboard **Branch**: `development` (feature branches merge here) @@ -254,4 +254,4 @@ All scripts in `scripts/`: --- -*Last Updated: Sprint 1* +*Last Updated: Sprint 7* diff --git a/portfolio_app/app.py b/portfolio_app/app.py index ce3e9f2..ddee1c0 100644 --- a/portfolio_app/app.py +++ b/portfolio_app/app.py @@ -2,7 +2,9 @@ import dash import dash_mantine_components as dmc +from dash import dcc, html +from .components import create_sidebar from .config import get_settings @@ -17,14 +19,31 @@ def create_app() -> dash.Dash: ) app.layout = dmc.MantineProvider( - dash.page_container, + 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, + className="page-content-wrapper", + ), + ], + ), + ], theme={ "primaryColor": "blue", "fontFamily": "'Inter', sans-serif", }, - forceColorScheme="light", + defaultColorScheme="dark", ) + # Import callbacks to register them + from . import callbacks # noqa: F401 + return app diff --git a/portfolio_app/assets/sidebar.css b/portfolio_app/assets/sidebar.css new file mode 100644 index 0000000..8956497 --- /dev/null +++ b/portfolio_app/assets/sidebar.css @@ -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; + } +} diff --git a/portfolio_app/callbacks/__init__.py b/portfolio_app/callbacks/__init__.py new file mode 100644 index 0000000..116b20a --- /dev/null +++ b/portfolio_app/callbacks/__init__.py @@ -0,0 +1,5 @@ +"""Application-level callbacks for the portfolio app.""" + +from . import theme + +__all__ = ["theme"] diff --git a/portfolio_app/callbacks/theme.py b/portfolio_app/callbacks/theme.py new file mode 100644 index 0000000..7ca5cbe --- /dev/null +++ b/portfolio_app/callbacks/theme.py @@ -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, +) diff --git a/portfolio_app/components/__init__.py b/portfolio_app/components/__init__.py index b079dd5..264dda3 100644 --- a/portfolio_app/components/__init__.py +++ b/portfolio_app/components/__init__.py @@ -2,11 +2,13 @@ from .map_controls import create_map_controls, create_metric_selector from .metric_card import MetricCard, create_metric_cards_row +from .sidebar import create_sidebar from .time_slider import create_time_slider, create_year_selector __all__ = [ "create_map_controls", "create_metric_selector", + "create_sidebar", "create_time_slider", "create_year_selector", "MetricCard", diff --git a/portfolio_app/components/sidebar.py b/portfolio_app/components/sidebar.py new file mode 100644 index 0000000..05e4565 --- /dev/null +++ b/portfolio_app/components/sidebar.py @@ -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", + ) diff --git a/portfolio_app/figures/choropleth.py b/portfolio_app/figures/choropleth.py index 6e4ec7b..8c7be7d 100644 --- a/portfolio_app/figures/choropleth.py +++ b/portfolio_app/figures/choropleth.py @@ -39,6 +39,10 @@ def create_choropleth_figure( if center is None: 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 geojson is None or not data: fig = go.Figure(go.Scattermapbox()) @@ -51,6 +55,9 @@ def create_choropleth_figure( margin={"l": 0, "r": 0, "t": 40, "b": 0}, title=title or "Toronto Housing Map", height=500, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color="#c9c9c9", ) fig.add_annotation( text="No geometry data available. Complete QGIS digitization to enable map.", @@ -59,7 +66,7 @@ def create_choropleth_figure( x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "gray"}, + font={"size": 14, "color": "#888888"}, ) return fig @@ -68,6 +75,11 @@ def create_choropleth_figure( 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( df, geojson=geojson, @@ -76,7 +88,7 @@ def create_choropleth_figure( color=color_column, color_continuous_scale=color_scale, hover_data=hover_data, - mapbox_style=map_style, + mapbox_style=effective_map_style, center=center, zoom=zoom, opacity=0.7, @@ -86,10 +98,17 @@ def create_choropleth_figure( margin={"l": 0, "r": 0, "t": 40, "b": 0}, title=title, height=500, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color="#c9c9c9", coloraxis_colorbar={ - "title": color_column.replace("_", " ").title(), + "title": { + "text": color_column.replace("_", " ").title(), + "font": {"color": "#c9c9c9"}, + }, "thickness": 15, "len": 0.7, + "tickfont": {"color": "#c9c9c9"}, }, ) diff --git a/portfolio_app/figures/summary_cards.py b/portfolio_app/figures/summary_cards.py index 9a27a1a..fd4cbc3 100644 --- a/portfolio_app/figures/summary_cards.py +++ b/portfolio_app/figures/summary_cards.py @@ -69,7 +69,8 @@ def create_metric_card_figure( height=120, margin={"l": 20, "r": 20, "t": 40, "b": 20}, 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 diff --git a/portfolio_app/figures/time_series.py b/portfolio_app/figures/time_series.py index 5bbf543..fc46cfc 100644 --- a/portfolio_app/figures/time_series.py +++ b/portfolio_app/figures/time_series.py @@ -38,8 +38,15 @@ def create_price_time_series( x=0.5, y=0.5, 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 df = pd.DataFrame(data) @@ -69,6 +76,11 @@ def create_price_time_series( yaxis_tickprefix="$", yaxis_tickformat=",", 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 @@ -106,8 +118,15 @@ def create_volume_time_series( x=0.5, y=0.5, 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 df = pd.DataFrame(data) @@ -153,6 +172,11 @@ def create_volume_time_series( yaxis_title=volume_column.replace("_", " ").title(), yaxis_tickformat=",", 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 @@ -187,8 +211,15 @@ def create_market_comparison_chart( x=0.5, y=0.5, 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 if metrics is None: @@ -221,12 +252,18 @@ def create_market_comparison_chart( height=400, margin={"l": 40, "r": 40, "t": 50, "b": 40}, 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={ "orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1, + "font": {"color": "#c9c9c9"}, }, ) diff --git a/portfolio_app/pages/home.py b/portfolio_app/pages/home.py index 919c9e5..51e3304 100644 --- a/portfolio_app/pages/home.py +++ b/portfolio_app/pages/home.py @@ -2,7 +2,6 @@ import dash import dash_mantine_components as dmc -from dash_iconify import DashIconify 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." @@ -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: """Create the availability statement.""" return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic") @@ -193,7 +158,6 @@ layout = dmc.Container( create_summary_section(), create_tech_stack_section(), create_projects_section(), - create_social_links(), dmc.Divider(my="lg"), create_availability_section(), dmc.Space(h=40), diff --git a/portfolio_app/pages/toronto/dashboard.py b/portfolio_app/pages/toronto/dashboard.py index 2fb8f7d..204db0f 100644 --- a/portfolio_app/pages/toronto/dashboard.py +++ b/portfolio_app/pages/toronto/dashboard.py @@ -3,6 +3,7 @@ import dash import dash_mantine_components as dmc from dash import dcc, html +from dash_iconify import DashIconify from portfolio_app.components import ( create_map_controls, @@ -76,6 +77,17 @@ def create_header() -> 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( id_prefix="toronto", min_year=2020, diff --git a/portfolio_app/pages/toronto/methodology.py b/portfolio_app/pages/toronto/methodology.py index 9bfed4c..e77e869 100644 --- a/portfolio_app/pages/toronto/methodology.py +++ b/portfolio_app/pages/toronto/methodology.py @@ -2,7 +2,8 @@ import dash import dash_mantine_components as dmc -from dash import html +from dash import dcc, html +from dash_iconify import DashIconify dash.register_page( __name__, @@ -18,8 +19,18 @@ def layout() -> dmc.Container: size="md", py="xl", 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 - dmc.Title("Methodology", order=1, mb="lg"), + dmc.Title("Methodology", order=1, mb="lg", mt="md"), dmc.Text( "This page documents the data sources, processing methodology, " "and known limitations of the Toronto Housing Dashboard.",