From dfa5f92d8a4419f40ec2c8d46c1d2de517e07457 Mon Sep 17 00:00:00 2001 From: l3ocho Date: Mon, 2 Feb 2026 17:00:30 -0500 Subject: [PATCH] refactor: update app code for domain-scoped schema migration - Update dbt model references to use new schema naming (stg_toronto, int_toronto, mart_toronto) - Refactor figure factories to use consistent column naming from new schema - Update callbacks to work with refactored data structures - Add centralized design tokens module for consistent styling - Streamline CLAUDE.md documentation Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 274 +++++++----------- .../intermediate/toronto/_intermediate.yml | 20 +- .../int_neighbourhood__crime_summary.sql | 12 +- .../int_rentals__neighbourhood_allocated.sql | 8 +- dbt/models/marts/toronto/_marts.yml | 28 +- .../toronto/mart_neighbourhood_overview.sql | 3 +- dbt/models/staging/toronto/_staging.yml | 40 +-- .../toronto/stg_dimensions__cmhc_zones.sql | 5 +- portfolio_app/components/map_controls.py | 4 +- portfolio_app/components/time_slider.py | 7 +- portfolio_app/design/__init__.py | 48 +++ portfolio_app/design/tokens.py | 162 +++++++++++ portfolio_app/figures/toronto/bar_charts.py | 55 ++-- portfolio_app/figures/toronto/choropleth.py | 25 +- portfolio_app/figures/toronto/demographics.py | 60 ++-- portfolio_app/figures/toronto/radar.py | 45 +-- portfolio_app/figures/toronto/scatter.py | 40 ++- .../figures/toronto/summary_cards.py | 22 +- portfolio_app/figures/toronto/time_series.py | 91 +++--- .../toronto/callbacks/chart_callbacks.py | 56 ++-- .../pages/toronto/callbacks/map_callbacks.py | 20 +- 21 files changed, 618 insertions(+), 407 deletions(-) create mode 100644 portfolio_app/design/__init__.py create mode 100644 portfolio_app/design/tokens.py diff --git a/CLAUDE.md b/CLAUDE.md index 67f3d62..8c1e586 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,17 @@ +## Mandatory Behavior Rules + +**These rules are NON-NEGOTIABLE. Violating them wastes the user's time and money.** + +1. **CHECK EVERYTHING** - Search ALL locations before saying "no" (cache, installed, source directories) +2. **BELIEVE THE USER** - Investigate thoroughly before disagreeing; user instincts are often right +3. **VERIFY BEFORE "DONE"** - Run commands, show output; "done" means verified working +4. **SHOW EXACTLY WHAT'S ASKED** - Do not interpret or summarize unless requested + +--- + Working context for Claude Code on the Analytics Portfolio project. --- @@ -53,22 +64,18 @@ Working context for Claude Code on the Analytics Portfolio project. make setup # Install deps, create .env, init pre-commit make docker-up # Start PostgreSQL + PostGIS (auto-detects x86/ARM) make docker-down # Stop containers -make docker-logs # View container logs make db-init # Initialize database schema make db-reset # Drop and recreate database (DESTRUCTIVE) # Data Loading make load-data # Load all project data (currently: Toronto) make load-toronto # Load Toronto data from APIs -make load-toronto-only # Load Toronto data without dbt or seeding -make seed-data # Seed sample development data # Application make run # Start Dash dev server # Testing & Quality make test # Run pytest -make test-cov # Run pytest with coverage make lint # Run ruff linter make format # Run ruff formatter make typecheck # Run mypy type checker @@ -79,8 +86,7 @@ make dbt-run # Run dbt models make dbt-test # Run dbt tests make dbt-docs # Generate and serve dbt documentation -# Maintenance -make clean # Remove build artifacts and caches +# Run `make help` for full target list ``` ### Branch Workflow @@ -104,50 +110,22 @@ make clean # Remove build artifacts and caches ### Module Responsibilities -| Directory | Contains | Purpose | -|-----------|----------|---------| -| `schemas/` | Pydantic models | Data validation | -| `models/` | SQLAlchemy ORM | Database persistence | -| `parsers/` | API/CSV extraction | Raw data ingestion | -| `loaders/` | Database operations | Data loading | -| `services/` | Query functions | dbt mart queries, business logic | -| `figures/` | Chart factories | Plotly figure generation | -| `callbacks/` | Dash callbacks | In `pages/{dashboard}/callbacks/` | -| `errors/` | Exception classes | Custom exceptions | -| `utils/` | Helper modules | Markdown loading, shared utilities | - -### Type Hints - -Use Python 3.10+ style: -```python -def process(items: list[str], config: dict[str, int] | None = None) -> bool: - ... -``` - -### Error Handling - -```python -# errors/exceptions.py -class PortfolioError(Exception): - """Base exception.""" - -class ParseError(PortfolioError): - """PDF/CSV parsing failed.""" - -class ValidationError(PortfolioError): - """Pydantic or business rule validation failed.""" - -class LoadError(PortfolioError): - """Database load operation failed.""" -``` +| Directory | Purpose | +|-----------|---------| +| `schemas/` | Pydantic models for data validation | +| `models/` | SQLAlchemy ORM for database persistence | +| `parsers/` | API/CSV extraction for raw data ingestion | +| `loaders/` | Database operations for data loading | +| `services/` | Query functions for dbt mart queries | +| `figures/` | Chart factories for Plotly figure generation | +| `errors/` | Custom exception classes (see `errors/exceptions.py`) | ### Code Standards +- Python 3.10+ type hints: `list[str]`, `dict[str, int] | None` - Single responsibility functions with verb naming - Early returns over deep nesting - Google-style docstrings only for non-obvious behavior -- Module-level constants for magic values -- Pydantic BaseSettings for runtime config --- @@ -155,23 +133,19 @@ class LoadError(PortfolioError): **Entry Point:** `portfolio_app/app.py` (Dash app factory with Pages routing) -| Directory | Purpose | Notes | -|-----------|---------|-------| -| `pages/` | Dash Pages (file-based routing) | URLs match file paths | -| `pages/toronto/` | Toronto Dashboard | `tabs/` for layouts, `callbacks/` for interactions | -| `components/` | Shared UI components | metric_card, sidebar, map_controls, time_slider | -| `figures/toronto/` | Toronto chart factories | choropleth, bar_charts, scatter, radar, time_series | -| `toronto/` | Toronto data logic | parsers/, loaders/, schemas/, models/ | -| `content/blog/` | Markdown blog articles | Processed by `utils/markdown_loader.py` | -| `notebooks/toronto/` | Toronto documentation | 5 domains: overview, housing, safety, demographics, amenities | +| Directory | Purpose | +|-----------|---------| +| `pages/` | Dash Pages (file-based routing) | +| `pages/toronto/` | Toronto Dashboard (`tabs/` for layouts, `callbacks/` for interactions) | +| `components/` | Shared UI components | +| `figures/toronto/` | Toronto chart factories | +| `toronto/` | Toronto data logic (parsers, loaders, schemas, models) | -**Key URLs:** `/` (home), `/toronto` (dashboard), `/blog` (listing), `/blog/{slug}` (articles) +**Key URLs:** `/` (home), `/toronto` (dashboard), `/blog` (listing), `/blog/{slug}` (articles), `/health` (status) ### Multi-Dashboard Architecture -The codebase is structured to support multiple dashboard projects: -- **figures/**: Domain-namespaced figure factories (`figures/toronto/`, future: `figures/football/`) -- **notebooks/**: Domain-namespaced documentation (`notebooks/toronto/`, future: `notebooks/football/`) +- **figures/**: Domain-namespaced (`figures/toronto/`, future: `figures/football/`) - **dbt models**: Domain subdirectories (`staging/toronto/`, `marts/toronto/`) - **Database schemas**: Domain-specific raw data (`raw_toronto`, future: `raw_football`) @@ -185,18 +159,11 @@ The codebase is structured to support multiple dashboard projects: | Validation | Pydantic | >=2.0 | | ORM | SQLAlchemy | >=2.0 (2.0-style API only) | | Transformation | dbt-postgres | >=1.7 | -| Data Processing | Pandas | >=2.1 | +| Visualization | Dash + Plotly + dash-mantine-components | >=2.14 | | Geospatial | GeoPandas + Shapely | >=0.14 | -| Visualization | Dash + Plotly | >=2.14 | -| UI Components | dash-mantine-components | Latest stable | -| Testing | pytest | >=7.0 | | Python | 3.11+ | Via pyenv | -**Notes**: -- SQLAlchemy 2.0 + Pydantic 2.0 only (never mix 1.x APIs) -- PostGIS extension required in database -- Docker Compose V2 format (no `version` field) -- **Multi-architecture support**: `make docker-up` auto-detects CPU architecture and uses the appropriate PostGIS image (x86_64: `postgis/postgis`, ARM64: `imresamu/postgis`) +**Notes**: SQLAlchemy 2.0 + Pydantic 2.0 only. Docker Compose V2 format (no `version` field). --- @@ -212,35 +179,8 @@ The codebase is structured to support multiple dashboard projects: | `int_toronto` | Toronto dbt intermediate views | | `mart_toronto` | Toronto dbt mart tables | -### Geographic Reality (Toronto Housing) - -``` -City Neighbourhoods (158) - Primary geographic unit for analysis -CMHC Zones (~20) - Rental data (Census Tract aligned) -``` - -### Star Schema (raw_toronto) - -| Table | Type | Keys | -|-------|------|------| -| `fact_rentals` | Fact | -> dim_time, dim_cmhc_zone | -| `dim_time` | Dimension (public) | date_key (PK) - shared | -| `dim_cmhc_zone` | Dimension | zone_key (PK), geometry | -| `dim_neighbourhood` | Dimension | neighbourhood_id (PK), geometry | -| `dim_policy_event` | Dimension | event_id (PK) | - ### dbt Project: `portfolio` -**Model Structure:** -``` -dbt/models/ -├── shared/ # Cross-domain dimensions -│ └── stg_dimensions__time.sql -├── staging/toronto/ # Toronto staging models -├── intermediate/toronto/ # Toronto intermediate models -└── marts/toronto/ # Toronto mart tables -``` - | Layer | Naming | Purpose | |-------|--------|---------| | Shared | `stg_dimensions__*` | Cross-domain dimensions | @@ -252,7 +192,7 @@ dbt/models/ ## Deferred Features -**Stop and flag if a task seems to require these**: +**Stop and flag if a task requires these**: | Feature | Reason | |---------|--------| @@ -277,139 +217,123 @@ LOG_LEVEL=INFO --- -## Script Standards - -All scripts in `scripts/`: -- Include usage comments at top -- Idempotent where possible -- Exit codes: 0 = success, 1 = error -- Use `set -euo pipefail` for bash -- Log to stdout, errors to stderr - ---- - ## Reference Documents | Document | Location | Use When | |----------|----------|----------| -| Project reference | `docs/PROJECT_REFERENCE.md` | Architecture decisions, completed work | -| Developer guide | `docs/CONTRIBUTING.md` | How to add pages, blog posts, tabs | +| Project reference | `docs/PROJECT_REFERENCE.md` | Architecture decisions | +| Developer guide | `docs/CONTRIBUTING.md` | How to add pages, tabs | | Lessons learned | `docs/project-lessons-learned/INDEX.md` | Past issues and solutions | -| Deployment runbook | `docs/runbooks/deployment.md` | Deploying to staging/production | -| Dashboard runbook | `docs/runbooks/adding-dashboard.md` | Adding new data dashboards | +| Deployment runbook | `docs/runbooks/deployment.md` | Deploying to environments | --- -## Projman Plugin Workflow +## Plugin Reference -**CRITICAL: Always use the projman plugin for sprint and task management.** +### Sprint Management: projman -### When to Use Projman Skills +**CRITICAL: Always use projman for sprint and task management.** | Skill | Trigger | Purpose | |-------|---------|---------| -| `/projman:sprint-plan` | New sprint or phase implementation | Architecture analysis + Gitea issue creation | -| `/projman:sprint-start` | Beginning implementation work | Load lessons learned (Wiki.js or local), start execution | -| `/projman:sprint-status` | Check progress | Review blockers and completion status | -| `/projman:sprint-close` | Sprint completion | Capture lessons learned (Wiki.js or local backup) | +| `/projman:sprint-plan` | New sprint/feature | Architecture analysis + Gitea issue creation | +| `/projman:sprint-start` | Begin implementation | Load lessons learned, start execution | +| `/projman:sprint-status` | Check progress | Review blockers and completion | +| `/projman:sprint-close` | Sprint completion | Capture lessons learned | -### Default Behavior +**Default workflow**: `/projman:sprint-plan` before code -> create issues -> `/projman:sprint-start` -> track via Gitea -> `/projman:sprint-close` -When user requests implementation work: +**Gitea**: `personal-projects/personal-portfolio` at `gitea.hotserv.cloud` -1. **ALWAYS start with `/projman:sprint-plan`** before writing code -2. Create Gitea issues with proper labels and acceptance criteria -3. Use `/projman:sprint-start` to begin execution with lessons learned -4. Track progress via Gitea issue comments -5. Close sprint with `/projman:sprint-close` to document lessons +### Data Platform: data-platform -### Gitea Repository +Use for dbt, PostgreSQL, and PostGIS operations. -- **Repo**: `personal-projects/personal-portfolio` -- **Host**: `gitea.hotserv.cloud` -- **SSH**: `ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/personal-portfolio.git` -- **Labels**: 18 repository-level labels configured (Type, Priority, Complexity, Effort) +| Skill | Purpose | +|-------|---------| +| `/data-platform:data-review` | Audit data integrity, schema validity, dbt compliance | +| `/data-platform:data-gate` | CI/CD data quality gate (pass/fail) | -### MCP Tools Available +**When to use:** Schema changes, dbt model development, data loading, before merging data PRs. -**Gitea**: -- `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment` -- `get_labels`, `suggest_labels` +**MCP tools available:** `pg_connect`, `pg_query`, `pg_tables`, `pg_columns`, `pg_schemas`, `st_*` (PostGIS), `dbt_*` operations. -**Wiki.js**: -- `search_lessons`, `create_lesson`, `search_pages`, `get_page` +### Visualization: viz-platform -### Lessons Learned (Backup Method) +Use for Dash/Mantine component validation and chart creation. -**When Wiki.js is unavailable**, use the local backup in `docs/project-lessons-learned/`: +| Skill | Purpose | +|-------|---------| +| `/viz-platform:component` | Inspect DMC component props and validation | +| `/viz-platform:chart` | Create themed Plotly charts | +| `/viz-platform:theme` | Apply/validate themes | +| `/viz-platform:dashboard` | Create dashboard layouts | -**At Sprint Start:** -1. Review `docs/project-lessons-learned/INDEX.md` for relevant past lessons -2. Search lesson files by tags/keywords before implementation -3. Apply prevention strategies from applicable lessons - -**At Sprint Close:** -1. Try Wiki.js `create_lesson` first -2. If Wiki.js fails, create lesson in `docs/project-lessons-learned/` -3. Use naming convention: `{phase-or-sprint}-{short-description}.md` -4. Update `INDEX.md` with new entry -5. Follow the lesson template in INDEX.md - -**Migration:** Once Wiki.js is configured, lessons will be migrated there for better searchability. - -### Issue Structure - -Every Gitea issue should include: -- **Overview**: Brief description -- **Files to Create/Modify**: Explicit paths -- **Acceptance Criteria**: Checkboxes -- **Technical Notes**: Implementation hints -- **Labels**: Listed in body (workaround for label API issues) - ---- - -## Other Available Plugins +**When to use:** Dashboard development, new visualizations, component prop lookup. ### Code Quality: code-sentinel Use for security scanning and refactoring analysis. -| Command | Purpose | -|---------|---------| +| Skill | Purpose | +|-------|---------| | `/code-sentinel:security-scan` | Full security audit of codebase | | `/code-sentinel:refactor` | Apply refactoring patterns | | `/code-sentinel:refactor-dry` | Preview refactoring without applying | -**When to use:** Before major releases, after adding authentication/data handling code, periodic audits. +**When to use:** Before major releases, after adding auth/data handling code, periodic audits. ### Documentation: doc-guardian Use for documentation drift detection and synchronization. -| Command | Purpose | -|---------|---------| +| Skill | Purpose | +|-------|---------| | `/doc-guardian:doc-audit` | Scan project for documentation drift | | `/doc-guardian:doc-sync` | Synchronize pending documentation updates | -**When to use:** After significant code changes, before releases, when docs feel stale. +**When to use:** After significant code changes, before releases. ### Pull Requests: pr-review Use for comprehensive PR review with multiple analysis perspectives. -| Command | Purpose | -|---------|---------| -| `/pr-review:initial-setup` | Configure PR review for this project | -| `/pr-review:project-init` | Quick project-level setup | +| Skill | Purpose | +|-------|---------| +| `/pr-review:initial-setup` | Configure PR review for project | +| Triggered automatically | Security, performance, maintainability, test analysis | **When to use:** Before merging significant PRs to `development` or `main`. +### Requirement Clarification: clarity-assist + +Use when requirements are ambiguous or need decomposition. + +**When to use:** Unclear specifications, complex feature requests, conflicting requirements. + +### Contract Validation: contract-validator + +Use for plugin interface validation. + +| Skill | Purpose | +|-------|---------| +| `/contract-validator:agent-check` | Quick agent definition validation | +| `/contract-validator:full-validation` | Full plugin contract validation | + +**When to use:** When modifying plugin integrations or agent definitions. + ### Git Workflow: git-flow -Use for git operations assistance. +Use for standardized git operations. -**When to use:** Complex merge scenarios, branch management questions. +| Skill | Purpose | +|-------|---------| +| `/git-flow:commit` | Auto-generated conventional commit | +| `/git-flow:branch-start` | Create feature/fix/chore branch | +| `/git-flow:git-status` | Comprehensive status with recommendations | + +**When to use:** Complex merge scenarios, branch management, standardized commits. --- -*Last Updated: February 2026 (Multi-Dashboard Architecture)* +*Last Updated: February 2026* diff --git a/dbt/models/intermediate/toronto/_intermediate.yml b/dbt/models/intermediate/toronto/_intermediate.yml index 908da4a..b66b5af 100644 --- a/dbt/models/intermediate/toronto/_intermediate.yml +++ b/dbt/models/intermediate/toronto/_intermediate.yml @@ -5,11 +5,11 @@ models: description: "Rental data enriched with time and zone dimensions" columns: - name: rental_id - tests: + data_tests: - unique - not_null - name: zone_code - tests: + data_tests: - not_null - name: int_neighbourhood__demographics @@ -17,11 +17,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: census_year description: "Census year" - tests: + data_tests: - not_null - name: income_quintile description: "Income quintile (1-5, city-wide)" @@ -31,7 +31,7 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: year description: "Reference year" @@ -45,11 +45,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: year description: "Statistics year" - tests: + data_tests: - not_null - name: crime_rate_per_100k description: "Total crime rate per 100K population" @@ -61,7 +61,7 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: year description: "Reference year" @@ -75,11 +75,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: year description: "Survey year" - tests: + data_tests: - not_null - name: avg_rent_2bed description: "Weighted average 2-bedroom rent" diff --git a/dbt/models/intermediate/toronto/int_neighbourhood__crime_summary.sql b/dbt/models/intermediate/toronto/int_neighbourhood__crime_summary.sql index cf5c3c8..e9ce321 100644 --- a/dbt/models/intermediate/toronto/int_neighbourhood__crime_summary.sql +++ b/dbt/models/intermediate/toronto/int_neighbourhood__crime_summary.sql @@ -16,12 +16,12 @@ crime_by_year as ( neighbourhood_id, crime_year as year, sum(incident_count) as total_incidents, - sum(case when crime_type = 'Assault' then incident_count else 0 end) as assault_count, - sum(case when crime_type = 'Auto Theft' then incident_count else 0 end) as auto_theft_count, - sum(case when crime_type = 'Break and Enter' then incident_count else 0 end) as break_enter_count, - sum(case when crime_type = 'Robbery' then incident_count else 0 end) as robbery_count, - sum(case when crime_type = 'Theft Over' then incident_count else 0 end) as theft_over_count, - sum(case when crime_type = 'Homicide' then incident_count else 0 end) as homicide_count, + sum(case when crime_type = 'assault' then incident_count else 0 end) as assault_count, + sum(case when crime_type = 'auto_theft' then incident_count else 0 end) as auto_theft_count, + sum(case when crime_type = 'break_and_enter' then incident_count else 0 end) as break_enter_count, + sum(case when crime_type = 'robbery' then incident_count else 0 end) as robbery_count, + sum(case when crime_type = 'theft_over' then incident_count else 0 end) as theft_over_count, + sum(case when crime_type = 'homicide' then incident_count else 0 end) as homicide_count, avg(rate_per_100k) as avg_rate_per_100k from crime group by neighbourhood_id, crime_year diff --git a/dbt/models/intermediate/toronto/int_rentals__neighbourhood_allocated.sql b/dbt/models/intermediate/toronto/int_rentals__neighbourhood_allocated.sql index aa04534..cc7f840 100644 --- a/dbt/models/intermediate/toronto/int_rentals__neighbourhood_allocated.sql +++ b/dbt/models/intermediate/toronto/int_rentals__neighbourhood_allocated.sql @@ -42,10 +42,10 @@ pivoted as ( select neighbourhood_id, year, - max(case when bedroom_type = 'Two Bedroom' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_2bed, - max(case when bedroom_type = 'One Bedroom' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_1bed, - max(case when bedroom_type = 'Bachelor' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_bachelor, - max(case when bedroom_type = 'Three Bedroom +' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_3bed, + max(case when bedroom_type = '2bed' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_2bed, + max(case when bedroom_type = '1bed' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_1bed, + max(case when bedroom_type = 'bachelor' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_bachelor, + max(case when bedroom_type = '3bed' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_3bed, avg(vacancy_rate) as vacancy_rate, sum(rental_units_estimate) as total_rental_units from allocated diff --git a/dbt/models/marts/toronto/_marts.yml b/dbt/models/marts/toronto/_marts.yml index 37170ca..fba3848 100644 --- a/dbt/models/marts/toronto/_marts.yml +++ b/dbt/models/marts/toronto/_marts.yml @@ -6,7 +6,7 @@ models: columns: - name: rental_id description: "Unique rental record identifier" - tests: + data_tests: - unique - not_null @@ -17,11 +17,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: neighbourhood_name description: "Official neighbourhood name" - tests: + data_tests: - not_null - name: geometry description: "PostGIS geometry for mapping" @@ -41,11 +41,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: neighbourhood_name description: "Official neighbourhood name" - tests: + data_tests: - not_null - name: geometry description: "PostGIS geometry for mapping" @@ -63,11 +63,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: neighbourhood_name description: "Official neighbourhood name" - tests: + data_tests: - not_null - name: geometry description: "PostGIS geometry for mapping" @@ -77,7 +77,7 @@ models: description: "100 = city average crime rate" - name: safety_tier description: "Safety tier (1=safest, 5=highest crime)" - tests: + data_tests: - accepted_values: arguments: values: [1, 2, 3, 4, 5] @@ -89,11 +89,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: neighbourhood_name description: "Official neighbourhood name" - tests: + data_tests: - not_null - name: geometry description: "PostGIS geometry for mapping" @@ -103,7 +103,7 @@ models: description: "100 = city average income" - name: income_quintile description: "Income quintile (1-5)" - tests: + data_tests: - accepted_values: arguments: values: [1, 2, 3, 4, 5] @@ -115,11 +115,11 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood identifier" - tests: + data_tests: - not_null - name: neighbourhood_name description: "Official neighbourhood name" - tests: + data_tests: - not_null - name: geometry description: "PostGIS geometry for mapping" @@ -129,7 +129,7 @@ models: description: "100 = city average amenities" - name: amenity_tier description: "Amenity tier (1=best, 5=lowest)" - tests: + data_tests: - accepted_values: arguments: values: [1, 2, 3, 4, 5] diff --git a/dbt/models/marts/toronto/mart_neighbourhood_overview.sql b/dbt/models/marts/toronto/mart_neighbourhood_overview.sql index 5a9c256..feefc1f 100644 --- a/dbt/models/marts/toronto/mart_neighbourhood_overview.sql +++ b/dbt/models/marts/toronto/mart_neighbourhood_overview.sql @@ -128,7 +128,8 @@ final as ( -- Component scores (0-100) round(safety_score::numeric, 1) as safety_score, round(affordability_score::numeric, 1) as affordability_score, - -- Amenity score not available at this level, use placeholder + -- TODO: Replace with actual amenity score when fact_amenities is populated + -- Currently uses neutral placeholder (50.0) which affects livability_score accuracy 50.0 as amenity_score, -- Composite livability score: safety (40%), affordability (40%), amenities (20%) diff --git a/dbt/models/staging/toronto/_staging.yml b/dbt/models/staging/toronto/_staging.yml index cadd5f5..dc3641c 100644 --- a/dbt/models/staging/toronto/_staging.yml +++ b/dbt/models/staging/toronto/_staging.yml @@ -6,16 +6,16 @@ models: columns: - name: rental_id description: "Unique identifier for rental record" - tests: + data_tests: - unique - not_null - name: date_key description: "Date dimension key (YYYYMMDD)" - tests: + data_tests: - not_null - name: zone_key description: "CMHC zone dimension key" - tests: + data_tests: - not_null - name: stg_dimensions__cmhc_zones @@ -23,12 +23,12 @@ models: columns: - name: zone_key description: "Zone dimension key" - tests: + data_tests: - unique - not_null - name: zone_code description: "CMHC zone code" - tests: + data_tests: - unique - not_null @@ -37,12 +37,12 @@ models: columns: - name: neighbourhood_id description: "Neighbourhood primary key" - tests: + data_tests: - unique - not_null - name: neighbourhood_name description: "Official neighbourhood name" - tests: + data_tests: - not_null - name: geometry description: "PostGIS geometry (POLYGON)" @@ -52,16 +52,16 @@ models: columns: - name: census_id description: "Census record identifier" - tests: + data_tests: - unique - not_null - name: neighbourhood_id description: "Neighbourhood foreign key" - tests: + data_tests: - not_null - name: census_year description: "Census year (2016, 2021)" - tests: + data_tests: - not_null - name: stg_toronto__crime @@ -69,16 +69,16 @@ models: columns: - name: crime_id description: "Crime record identifier" - tests: + data_tests: - unique - not_null - name: neighbourhood_id description: "Neighbourhood foreign key" - tests: + data_tests: - not_null - name: crime_type description: "Type of crime" - tests: + data_tests: - not_null - name: stg_toronto__amenities @@ -86,16 +86,16 @@ models: columns: - name: amenity_id description: "Amenity record identifier" - tests: + data_tests: - unique - not_null - name: neighbourhood_id description: "Neighbourhood foreign key" - tests: + data_tests: - not_null - name: amenity_type description: "Type of amenity" - tests: + data_tests: - not_null - name: stg_cmhc__zone_crosswalk @@ -103,18 +103,18 @@ models: columns: - name: crosswalk_id description: "Crosswalk record identifier" - tests: + data_tests: - unique - not_null - name: cmhc_zone_code description: "CMHC zone code" - tests: + data_tests: - not_null - name: neighbourhood_id description: "Neighbourhood foreign key" - tests: + data_tests: - not_null - name: area_weight description: "Proportional area weight (0-1)" - tests: + data_tests: - not_null diff --git a/dbt/models/staging/toronto/stg_dimensions__cmhc_zones.sql b/dbt/models/staging/toronto/stg_dimensions__cmhc_zones.sql index a4f294c..fe817cd 100644 --- a/dbt/models/staging/toronto/stg_dimensions__cmhc_zones.sql +++ b/dbt/models/staging/toronto/stg_dimensions__cmhc_zones.sql @@ -10,8 +10,9 @@ staged as ( select zone_key, zone_code, - zone_name, - geometry + zone_name + -- geometry column excluded: CMHC does not provide zone boundaries + -- Spatial analysis uses dim_neighbourhood geometry instead from source ) diff --git a/portfolio_app/components/map_controls.py b/portfolio_app/components/map_controls.py index 5fa6784..2e33017 100644 --- a/portfolio_app/components/map_controls.py +++ b/portfolio_app/components/map_controls.py @@ -28,7 +28,7 @@ def create_metric_selector( label=label, data=options, value=default_value or (options[0]["value"] if options else None), - style={"width": "200px"}, + w=200, ) @@ -64,7 +64,7 @@ def create_map_controls( id=f"{id_prefix}-layer-toggle", label="Show Boundaries", checked=True, - style={"marginTop": "10px"}, + mt="sm", ) ) diff --git a/portfolio_app/components/time_slider.py b/portfolio_app/components/time_slider.py index 12752d6..bdf5c77 100644 --- a/portfolio_app/components/time_slider.py +++ b/portfolio_app/components/time_slider.py @@ -38,7 +38,7 @@ def create_year_selector( label=label, data=options, value=str(default_year), - style={"width": "120px"}, + w=120, ) @@ -83,7 +83,8 @@ def create_time_slider( marks=marks, step=1, minRange=1, - style={"marginTop": "20px", "marginBottom": "10px"}, + mt="md", + mb="sm", ), ], p="md", @@ -131,5 +132,5 @@ def create_month_selector( label=label, data=options, value=str(default_month), - style={"width": "140px"}, + w=140, ) diff --git a/portfolio_app/design/__init__.py b/portfolio_app/design/__init__.py new file mode 100644 index 0000000..53a190d --- /dev/null +++ b/portfolio_app/design/__init__.py @@ -0,0 +1,48 @@ +"""Design system tokens and utilities.""" + +from .tokens import ( + CHART_PALETTE, + COLOR_ACCENT, + COLOR_NEGATIVE, + COLOR_POSITIVE, + COLOR_WARNING, + GRID_COLOR, + GRID_COLOR_DARK, + PALETTE_COMPARISON, + PALETTE_GENDER, + PALETTE_TREND, + PAPER_BG, + PLOT_BG, + POLICY_COLORS, + TEXT_MUTED, + TEXT_PRIMARY, + TEXT_SECONDARY, + get_colorbar_defaults, + get_default_layout, +) + +__all__ = [ + # Text colors + "TEXT_PRIMARY", + "TEXT_SECONDARY", + "TEXT_MUTED", + # Chart backgrounds + "GRID_COLOR", + "GRID_COLOR_DARK", + "PAPER_BG", + "PLOT_BG", + # Semantic colors + "COLOR_POSITIVE", + "COLOR_NEGATIVE", + "COLOR_WARNING", + "COLOR_ACCENT", + # Palettes + "CHART_PALETTE", + "PALETTE_COMPARISON", + "PALETTE_GENDER", + "PALETTE_TREND", + "POLICY_COLORS", + # Utility functions + "get_default_layout", + "get_colorbar_defaults", +] diff --git a/portfolio_app/design/tokens.py b/portfolio_app/design/tokens.py new file mode 100644 index 0000000..a90e7c6 --- /dev/null +++ b/portfolio_app/design/tokens.py @@ -0,0 +1,162 @@ +"""Centralized design tokens for consistent styling across the application. + +This module provides a single source of truth for colors, ensuring: +- Consistent styling across all Plotly figures and components +- Accessibility compliance (WCAG color contrast) +- Easy theme updates without hunting through multiple files + +Usage: + from portfolio_app.design import TEXT_PRIMARY, CHART_PALETTE + fig.update_layout(font_color=TEXT_PRIMARY) +""" + +from typing import Any + +# ============================================================================= +# TEXT COLORS (Dark Theme) +# ============================================================================= + +TEXT_PRIMARY = "#c9c9c9" +"""Primary text color for labels, titles, and body text.""" + +TEXT_SECONDARY = "#888888" +"""Secondary text color for subtitles, captions, and muted text.""" + +TEXT_MUTED = "#666666" +"""Muted text color for disabled states and placeholders.""" + + +# ============================================================================= +# CHART BACKGROUND & GRID +# ============================================================================= + +GRID_COLOR = "rgba(128, 128, 128, 0.2)" +"""Standard grid line color with transparency.""" + +GRID_COLOR_DARK = "rgba(128, 128, 128, 0.3)" +"""Darker grid for radar charts and polar plots.""" + +PAPER_BG = "rgba(0, 0, 0, 0)" +"""Transparent paper background for charts.""" + +PLOT_BG = "rgba(0, 0, 0, 0)" +"""Transparent plot background for charts.""" + + +# ============================================================================= +# SEMANTIC COLORS +# ============================================================================= + +COLOR_POSITIVE = "#40c057" +"""Positive/success indicator (Mantine green-6).""" + +COLOR_NEGATIVE = "#fa5252" +"""Negative/error indicator (Mantine red-6).""" + +COLOR_WARNING = "#fab005" +"""Warning indicator (Mantine yellow-6).""" + +COLOR_ACCENT = "#228be6" +"""Primary accent color (Mantine blue-6).""" + + +# ============================================================================= +# ACCESSIBLE CHART PALETTE +# ============================================================================= + +# Okabe-Ito palette - optimized for all color vision deficiencies +# Reference: https://jfly.uni-koeln.de/color/ +CHART_PALETTE = [ + "#0072B2", # Blue (primary data series) + "#E69F00", # Orange + "#56B4E9", # Sky blue + "#009E73", # Teal/green + "#F0E442", # Yellow + "#D55E00", # Vermillion + "#CC79A7", # Pink + "#000000", # Black (use sparingly) +] +""" +Accessible categorical palette (Okabe-Ito). + +Distinguishable for deuteranopia, protanopia, and tritanopia. +Use indices 0-6 for most charts; index 7 (black) for emphasis only. +""" + +# Semantic subsets for specific use cases +PALETTE_COMPARISON = [CHART_PALETTE[0], CHART_PALETTE[1]] +"""Two-color palette for A/B comparisons.""" + +PALETTE_GENDER = { + "male": "#56B4E9", # Sky blue + "female": "#CC79A7", # Pink +} +"""Gender-specific colors (accessible contrast).""" + +PALETTE_TREND = { + "positive": COLOR_POSITIVE, + "negative": COLOR_NEGATIVE, + "neutral": TEXT_SECONDARY, +} +"""Trend indicator colors for sparklines and deltas.""" + + +# ============================================================================= +# POLICY/EVENT MARKERS (Time Series) +# ============================================================================= + +POLICY_COLORS = { + "policy_change": "#E69F00", # Orange - policy changes + "major_event": "#D55E00", # Vermillion - major events + "data_note": "#56B4E9", # Sky blue - data annotations + "forecast": "#009E73", # Teal - forecast periods + "highlight": "#F0E442", # Yellow - highlighted regions +} +"""Colors for policy markers and event annotations on time series.""" + + +# ============================================================================= +# CHART LAYOUT DEFAULTS +# ============================================================================= + + +def get_default_layout() -> dict[str, Any]: + """Return default Plotly layout settings with design tokens. + + Returns: + dict: Layout configuration for fig.update_layout() + + Example: + fig.update_layout(**get_default_layout()) + """ + return { + "paper_bgcolor": PAPER_BG, + "plot_bgcolor": PLOT_BG, + "font": {"color": TEXT_PRIMARY}, + "title": {"font": {"color": TEXT_PRIMARY}}, + "legend": {"font": {"color": TEXT_PRIMARY}}, + "xaxis": { + "gridcolor": GRID_COLOR, + "linecolor": GRID_COLOR, + "tickfont": {"color": TEXT_PRIMARY}, + "title": {"font": {"color": TEXT_PRIMARY}}, + }, + "yaxis": { + "gridcolor": GRID_COLOR, + "linecolor": GRID_COLOR, + "tickfont": {"color": TEXT_PRIMARY}, + "title": {"font": {"color": TEXT_PRIMARY}}, + }, + } + + +def get_colorbar_defaults() -> dict[str, Any]: + """Return default colorbar settings with design tokens. + + Returns: + dict: Colorbar configuration for choropleth/heatmap traces + """ + return { + "tickfont": {"color": TEXT_PRIMARY}, + "title": {"font": {"color": TEXT_PRIMARY}}, + } diff --git a/portfolio_app/figures/toronto/bar_charts.py b/portfolio_app/figures/toronto/bar_charts.py index 692ab45..f1d0cea 100644 --- a/portfolio_app/figures/toronto/bar_charts.py +++ b/portfolio_app/figures/toronto/bar_charts.py @@ -6,6 +6,17 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go +from portfolio_app.design import ( + CHART_PALETTE, + COLOR_NEGATIVE, + COLOR_POSITIVE, + GRID_COLOR, + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) + def create_ranking_bar( data: list[dict[str, Any]], @@ -14,8 +25,8 @@ def create_ranking_bar( title: str | None = None, top_n: int = 10, bottom_n: int = 10, - color_top: str = "#4CAF50", - color_bottom: str = "#F44336", + color_top: str = COLOR_POSITIVE, + color_bottom: str = COLOR_NEGATIVE, value_format: str = ",.0f", ) -> go.Figure: """Create horizontal bar chart showing top and bottom rankings. @@ -87,10 +98,10 @@ def create_ranking_bar( barmode="group", showlegend=True, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", - xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR, "title": None}, yaxis={"autorange": "reversed", "title": None}, margin={"l": 10, "r": 10, "t": 40, "b": 10}, ) @@ -126,10 +137,10 @@ def create_stacked_bar( df = pd.DataFrame(data) - # Default color scheme + # Default color scheme using accessible palette if color_map is None: categories = df[category_column].unique() - colors = px.colors.qualitative.Set2[: len(categories)] + colors = CHART_PALETTE[: len(categories)] color_map = dict(zip(categories, colors, strict=False)) fig = px.bar( @@ -147,11 +158,11 @@ def create_stacked_bar( fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", - xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, - yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR, "title": None}, + yaxis={"gridcolor": GRID_COLOR, "title": None}, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, margin={"l": 10, "r": 10, "t": 60, "b": 10}, ) @@ -164,7 +175,7 @@ def create_horizontal_bar( name_column: str, value_column: str, title: str | None = None, - color: str = "#2196F3", + color: str = CHART_PALETTE[0], value_format: str = ",.0f", sort: bool = True, ) -> go.Figure: @@ -204,10 +215,10 @@ def create_horizontal_bar( fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", - xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR, "title": None}, yaxis={"title": None}, margin={"l": 10, "r": 10, "t": 40, "b": 10}, ) @@ -225,13 +236,13 @@ def _create_empty_figure(title: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={"visible": False}, yaxis={"visible": False}, ) diff --git a/portfolio_app/figures/toronto/choropleth.py b/portfolio_app/figures/toronto/choropleth.py index a8c8d55..dddb007 100644 --- a/portfolio_app/figures/toronto/choropleth.py +++ b/portfolio_app/figures/toronto/choropleth.py @@ -5,6 +5,13 @@ from typing import Any import plotly.express as px import plotly.graph_objects as go +from portfolio_app.design import ( + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) + def create_choropleth_figure( geojson: dict[str, Any] | None, @@ -55,9 +62,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", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, ) fig.add_annotation( text="No geometry data available. Complete QGIS digitization to enable map.", @@ -66,7 +73,7 @@ def create_choropleth_figure( x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) return fig @@ -98,17 +105,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", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, coloraxis_colorbar={ "title": { "text": color_column.replace("_", " ").title(), - "font": {"color": "#c9c9c9"}, + "font": {"color": TEXT_PRIMARY}, }, "thickness": 15, "len": 0.7, - "tickfont": {"color": "#c9c9c9"}, + "tickfont": {"color": TEXT_PRIMARY}, }, ) diff --git a/portfolio_app/figures/toronto/demographics.py b/portfolio_app/figures/toronto/demographics.py index ceced5b..2fa4b6c 100644 --- a/portfolio_app/figures/toronto/demographics.py +++ b/portfolio_app/figures/toronto/demographics.py @@ -5,6 +5,16 @@ from typing import Any import pandas as pd import plotly.graph_objects as go +from portfolio_app.design import ( + CHART_PALETTE, + GRID_COLOR, + PALETTE_GENDER, + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) + def create_age_pyramid( data: list[dict[str, Any]], @@ -52,7 +62,7 @@ def create_age_pyramid( x=male_values_neg, orientation="h", name="Male", - marker_color="#2196F3", + marker_color=PALETTE_GENDER["male"], hovertemplate="%{y}
Male: %{customdata:,}", customdata=male_values, ) @@ -65,7 +75,7 @@ def create_age_pyramid( x=female_values, orientation="h", name="Female", - marker_color="#E91E63", + marker_color=PALETTE_GENDER["female"], hovertemplate="%{y}
Female: %{x:,}", ) ) @@ -77,12 +87,12 @@ def create_age_pyramid( title=title, barmode="overlay", bargap=0.1, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={ "title": "Population", - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, "range": [-max_val * 1.1, max_val * 1.1], "tickvals": [-max_val, -max_val / 2, 0, max_val / 2, max_val], "ticktext": [ @@ -93,7 +103,7 @@ def create_age_pyramid( f"{max_val:,.0f}", ], }, - yaxis={"title": None, "gridcolor": "rgba(128,128,128,0.2)"}, + yaxis={"title": None, "gridcolor": GRID_COLOR}, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, margin={"l": 10, "r": 10, "t": 60, "b": 10}, ) @@ -127,17 +137,9 @@ def create_donut_chart( df = pd.DataFrame(data) + # Use accessible palette by default if colors is None: - colors = [ - "#2196F3", - "#4CAF50", - "#FF9800", - "#E91E63", - "#9C27B0", - "#00BCD4", - "#FFC107", - "#795548", - ] + colors = CHART_PALETTE fig = go.Figure( go.Pie( @@ -153,8 +155,8 @@ def create_donut_chart( fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + font_color=TEXT_PRIMARY, showlegend=False, margin={"l": 10, "r": 10, "t": 60, "b": 10}, ) @@ -167,7 +169,7 @@ def create_income_distribution( bracket_column: str, count_column: str, title: str | None = None, - color: str = "#4CAF50", + color: str = CHART_PALETTE[3], # Teal ) -> go.Figure: """Create histogram-style bar chart for income distribution. @@ -199,17 +201,17 @@ def create_income_distribution( fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={ "title": "Income Bracket", - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, "tickangle": -45, }, yaxis={ "title": "Households", - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, }, margin={"l": 10, "r": 10, "t": 60, "b": 80}, ) @@ -227,13 +229,13 @@ def _create_empty_figure(title: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={"visible": False}, yaxis={"visible": False}, ) diff --git a/portfolio_app/figures/toronto/radar.py b/portfolio_app/figures/toronto/radar.py index 35f8e75..5e6d6c2 100644 --- a/portfolio_app/figures/toronto/radar.py +++ b/portfolio_app/figures/toronto/radar.py @@ -4,6 +4,14 @@ from typing import Any import plotly.graph_objects as go +from portfolio_app.design import ( + CHART_PALETTE, + GRID_COLOR_DARK, + PAPER_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) + def create_radar_figure( data: list[dict[str, Any]], @@ -32,16 +40,9 @@ def create_radar_figure( if not data or not metrics: return _create_empty_figure(title or "Radar Chart") - # Default colors + # Use accessible palette by default if colors is None: - colors = [ - "#2196F3", - "#4CAF50", - "#FF9800", - "#E91E63", - "#9C27B0", - "#00BCD4", - ] + colors = CHART_PALETTE fig = go.Figure() @@ -78,19 +79,19 @@ def create_radar_figure( polar={ "radialaxis": { "visible": True, - "gridcolor": "rgba(128,128,128,0.3)", - "linecolor": "rgba(128,128,128,0.3)", - "tickfont": {"color": "#c9c9c9"}, + "gridcolor": GRID_COLOR_DARK, + "linecolor": GRID_COLOR_DARK, + "tickfont": {"color": TEXT_PRIMARY}, }, "angularaxis": { - "gridcolor": "rgba(128,128,128,0.3)", - "linecolor": "rgba(128,128,128,0.3)", - "tickfont": {"color": "#c9c9c9"}, + "gridcolor": GRID_COLOR_DARK, + "linecolor": GRID_COLOR_DARK, + "tickfont": {"color": TEXT_PRIMARY}, }, - "bgcolor": "rgba(0,0,0,0)", + "bgcolor": PAPER_BG, }, - paper_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + font_color=TEXT_PRIMARY, showlegend=len(data) > 1, legend={"orientation": "h", "yanchor": "bottom", "y": -0.2}, margin={"l": 40, "r": 40, "t": 60, "b": 40}, @@ -133,7 +134,7 @@ def create_comparison_radar( metrics=metrics, name_column="__name__", title=title, - colors=["#4CAF50", "#9E9E9E"], + colors=[CHART_PALETTE[3], TEXT_SECONDARY], # Teal for selected, gray for avg ) @@ -156,11 +157,11 @@ def _create_empty_figure(title: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + font_color=TEXT_PRIMARY, ) return fig diff --git a/portfolio_app/figures/toronto/scatter.py b/portfolio_app/figures/toronto/scatter.py index 1e1c6ef..012be40 100644 --- a/portfolio_app/figures/toronto/scatter.py +++ b/portfolio_app/figures/toronto/scatter.py @@ -6,6 +6,15 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go +from portfolio_app.design import ( + CHART_PALETTE, + GRID_COLOR, + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) + def create_scatter_figure( data: list[dict[str, Any]], @@ -72,21 +81,21 @@ def create_scatter_figure( if trendline: fig.update_traces( selector={"mode": "lines"}, - line={"color": "#FF9800", "dash": "dash", "width": 2}, + line={"color": CHART_PALETTE[1], "dash": "dash", "width": 2}, ) fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={ - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, "title": x_title or x_column.replace("_", " ").title(), "zeroline": False, }, yaxis={ - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, "title": y_title or y_column.replace("_", " ").title(), "zeroline": False, }, @@ -140,19 +149,20 @@ def create_bubble_chart( hover_name=name_column, size_max=size_max, opacity=0.7, + color_discrete_sequence=CHART_PALETTE, ) fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={ - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, "title": x_title or x_column.replace("_", " ").title(), }, yaxis={ - "gridcolor": "rgba(128,128,128,0.2)", + "gridcolor": GRID_COLOR, "title": y_title or y_column.replace("_", " ").title(), }, margin={"l": 10, "r": 10, "t": 40, "b": 10}, @@ -171,13 +181,13 @@ def _create_empty_figure(title: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) fig.update_layout( title=title, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={"visible": False}, yaxis={"visible": False}, ) diff --git a/portfolio_app/figures/toronto/summary_cards.py b/portfolio_app/figures/toronto/summary_cards.py index fd4cbc3..2af7d36 100644 --- a/portfolio_app/figures/toronto/summary_cards.py +++ b/portfolio_app/figures/toronto/summary_cards.py @@ -4,6 +4,14 @@ from typing import Any import plotly.graph_objects as go +from portfolio_app.design import ( + COLOR_NEGATIVE, + COLOR_POSITIVE, + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, +) + def create_metric_card_figure( value: float | int | str, @@ -59,8 +67,12 @@ def create_metric_card_figure( "relative": False, "valueformat": ".1f", "suffix": delta_suffix, - "increasing": {"color": "green" if positive_is_good else "red"}, - "decreasing": {"color": "red" if positive_is_good else "green"}, + "increasing": { + "color": COLOR_POSITIVE if positive_is_good else COLOR_NEGATIVE + }, + "decreasing": { + "color": COLOR_NEGATIVE if positive_is_good else COLOR_POSITIVE + }, } fig.add_trace(go.Indicator(**indicator_config)) @@ -68,9 +80,9 @@ def create_metric_card_figure( fig.update_layout( height=120, margin={"l": 20, "r": 20, "t": 40, "b": 20}, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font={"family": "Inter, sans-serif", "color": "#c9c9c9"}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font={"family": "Inter, sans-serif", "color": TEXT_PRIMARY}, ) return fig diff --git a/portfolio_app/figures/toronto/time_series.py b/portfolio_app/figures/toronto/time_series.py index fc46cfc..ad3a027 100644 --- a/portfolio_app/figures/toronto/time_series.py +++ b/portfolio_app/figures/toronto/time_series.py @@ -5,6 +5,15 @@ from typing import Any import plotly.express as px import plotly.graph_objects as go +from portfolio_app.design import ( + CHART_PALETTE, + GRID_COLOR, + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) + def create_price_time_series( data: list[dict[str, Any]], @@ -38,14 +47,14 @@ def create_price_time_series( x=0.5, y=0.5, showarrow=False, - font={"color": "#888888"}, + font={"color": TEXT_SECONDARY}, ) fig.update_layout( title=title, height=350, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, ) return fig @@ -59,6 +68,7 @@ def create_price_time_series( y=price_column, color=group_column, title=title, + color_discrete_sequence=CHART_PALETTE, ) else: fig = px.line( @@ -67,6 +77,7 @@ def create_price_time_series( y=price_column, title=title, ) + fig.update_traces(line_color=CHART_PALETTE[0]) fig.update_layout( height=350, @@ -76,11 +87,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"}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR}, + yaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR}, ) return fig @@ -118,14 +129,14 @@ def create_volume_time_series( x=0.5, y=0.5, showarrow=False, - font={"color": "#888888"}, + font={"color": TEXT_SECONDARY}, ) fig.update_layout( title=title, height=350, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, ) return fig @@ -140,6 +151,7 @@ def create_volume_time_series( y=volume_column, color=group_column, title=title, + color_discrete_sequence=CHART_PALETTE, ) else: fig = px.bar( @@ -148,6 +160,7 @@ def create_volume_time_series( y=volume_column, title=title, ) + fig.update_traces(marker_color=CHART_PALETTE[0]) else: if group_column and group_column in df.columns: fig = px.line( @@ -156,6 +169,7 @@ def create_volume_time_series( y=volume_column, color=group_column, title=title, + color_discrete_sequence=CHART_PALETTE, ) else: fig = px.line( @@ -164,6 +178,7 @@ def create_volume_time_series( y=volume_column, title=title, ) + fig.update_traces(line_color=CHART_PALETTE[0]) fig.update_layout( height=350, @@ -172,11 +187,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"}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR}, + yaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR}, ) return fig @@ -211,14 +226,14 @@ def create_market_comparison_chart( x=0.5, y=0.5, showarrow=False, - font={"color": "#888888"}, + font={"color": TEXT_SECONDARY}, ) fig.update_layout( title=title, height=400, - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, ) return fig @@ -230,8 +245,6 @@ def create_market_comparison_chart( fig = make_subplots(specs=[[{"secondary_y": True}]]) - colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"] - for i, metric in enumerate(metrics[:4]): if metric not in df.columns: continue @@ -242,7 +255,7 @@ def create_market_comparison_chart( x=df[date_column], y=df[metric], name=metric.replace("_", " ").title(), - line={"color": colors[i % len(colors)]}, + line={"color": CHART_PALETTE[i % len(CHART_PALETTE)]}, ), secondary_y=secondary, ) @@ -252,18 +265,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"}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR}, + yaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR}, legend={ "orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1, - "font": {"color": "#c9c9c9"}, + "font": {"color": TEXT_PRIMARY}, }, ) @@ -290,13 +303,13 @@ def add_policy_markers( if not policy_events: return fig - # Color mapping for policy categories + # Color mapping for policy categories using design tokens category_colors = { - "monetary": "#1f77b4", # Blue - "tax": "#2ca02c", # Green - "regulatory": "#ff7f0e", # Orange - "supply": "#9467bd", # Purple - "economic": "#d62728", # Red + "monetary": CHART_PALETTE[0], # Blue + "tax": CHART_PALETTE[3], # Teal/green + "regulatory": CHART_PALETTE[1], # Orange + "supply": CHART_PALETTE[6], # Pink + "economic": CHART_PALETTE[5], # Vermillion } # Symbol mapping for expected direction @@ -313,7 +326,7 @@ def add_policy_markers( title = event.get("title", "Policy Event") level = event.get("level", "federal") - color = category_colors.get(category, "#666666") + color = category_colors.get(category, TEXT_SECONDARY) symbol = direction_symbols.get(direction, "circle") # Add vertical line for the event @@ -335,7 +348,7 @@ def add_policy_markers( "symbol": symbol, "size": 12, "color": color, - "line": {"width": 1, "color": "white"}, + "line": {"width": 1, "color": TEXT_PRIMARY}, }, name=title, hovertemplate=( diff --git a/portfolio_app/pages/toronto/callbacks/chart_callbacks.py b/portfolio_app/pages/toronto/callbacks/chart_callbacks.py index e0d2e5c..56767cd 100644 --- a/portfolio_app/pages/toronto/callbacks/chart_callbacks.py +++ b/portfolio_app/pages/toronto/callbacks/chart_callbacks.py @@ -5,6 +5,14 @@ import pandas as pd import plotly.graph_objects as go from dash import Input, Output, callback +from portfolio_app.design import ( + CHART_PALETTE, + GRID_COLOR, + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) from portfolio_app.figures.toronto import ( create_donut_chart, create_horizontal_bar, @@ -109,18 +117,18 @@ def update_housing_trend(year: str, neighbourhood_id: int | None) -> go.Figure: x=[d["year"] for d in data], y=[d["avg_rent"] for d in data], mode="lines+markers", - line={"color": "#2196F3", "width": 2}, + line={"color": CHART_PALETTE[0], "width": 2}, marker={"size": 8}, name="City Average", ) ) fig.update_layout( - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", - xaxis={"gridcolor": "rgba(128,128,128,0.2)"}, - yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": "Avg Rent (2BR)"}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR}, + yaxis={"gridcolor": GRID_COLOR, "title": "Avg Rent (2BR)"}, showlegend=False, margin={"l": 40, "r": 10, "t": 10, "b": 30}, ) @@ -153,7 +161,7 @@ def update_housing_types(year: str) -> go.Figure: data=data, name_column="type", value_column="percentage", - colors=["#4CAF50", "#2196F3"], + colors=[CHART_PALETTE[3], CHART_PALETTE[0]], # Teal for owner, blue for renter ) @@ -178,19 +186,19 @@ def update_safety_trend(year: str) -> go.Figure: x=[d["year"] for d in data], y=[d["crime_rate"] for d in data], mode="lines+markers", - line={"color": "#FF5722", "width": 2}, + line={"color": CHART_PALETTE[5], "width": 2}, # Vermillion marker={"size": 8}, fill="tozeroy", - fillcolor="rgba(255,87,34,0.1)", + fillcolor="rgba(213, 94, 0, 0.1)", # Vermillion with opacity ) ) fig.update_layout( - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", - xaxis={"gridcolor": "rgba(128,128,128,0.2)"}, - yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": "Crime Rate per 100K"}, + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, + xaxis={"gridcolor": GRID_COLOR}, + yaxis={"gridcolor": GRID_COLOR, "title": "Crime Rate per 100K"}, showlegend=False, margin={"l": 40, "r": 10, "t": 10, "b": 30}, ) @@ -233,7 +241,7 @@ def update_safety_types(year: str) -> go.Figure: data=data, name_column="category", value_column="count", - color="#FF5722", + color=CHART_PALETTE[5], # Vermillion for crime ) @@ -264,7 +272,11 @@ def update_demographics_age(year: str) -> go.Figure: data=data, name_column="age_group", value_column="percentage", - colors=["#9C27B0", "#673AB7", "#3F51B5"], + colors=[ + CHART_PALETTE[2], + CHART_PALETTE[0], + CHART_PALETTE[4], + ], # Sky, Blue, Yellow ) @@ -301,7 +313,7 @@ def update_demographics_income(year: str) -> go.Figure: data=data, name_column="bracket", value_column="count", - color="#4CAF50", + color=CHART_PALETTE[3], # Teal sort=False, ) @@ -333,7 +345,7 @@ def update_amenities_breakdown(year: str) -> go.Figure: data=data, name_column="type", value_column="count", - color="#4CAF50", + color=CHART_PALETTE[3], # Teal ) @@ -387,9 +399,9 @@ def _empty_chart(message: str) -> go.Figure: """Create an empty chart with a message.""" fig = go.Figure() fig.update_layout( - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={"visible": False}, yaxis={"visible": False}, ) @@ -400,6 +412,6 @@ def _empty_chart(message: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) return fig diff --git a/portfolio_app/pages/toronto/callbacks/map_callbacks.py b/portfolio_app/pages/toronto/callbacks/map_callbacks.py index 2856546..0338021 100644 --- a/portfolio_app/pages/toronto/callbacks/map_callbacks.py +++ b/portfolio_app/pages/toronto/callbacks/map_callbacks.py @@ -4,6 +4,12 @@ import plotly.graph_objects as go from dash import Input, Output, State, callback, no_update +from portfolio_app.design import ( + PAPER_BG, + PLOT_BG, + TEXT_PRIMARY, + TEXT_SECONDARY, +) from portfolio_app.figures.toronto import create_choropleth_figure, create_ranking_bar from portfolio_app.toronto.services import ( get_amenities_data, @@ -267,8 +273,8 @@ def _empty_map(message: str) -> go.Figure: "zoom": 9.5, }, margin={"l": 0, "r": 0, "t": 0, "b": 0}, - paper_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + font_color=TEXT_PRIMARY, ) fig.add_annotation( text=message, @@ -277,7 +283,7 @@ def _empty_map(message: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) return fig @@ -286,9 +292,9 @@ def _empty_chart(message: str) -> go.Figure: """Create an empty chart with a message.""" fig = go.Figure() fig.update_layout( - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - font_color="#c9c9c9", + paper_bgcolor=PAPER_BG, + plot_bgcolor=PLOT_BG, + font_color=TEXT_PRIMARY, xaxis={"visible": False}, yaxis={"visible": False}, ) @@ -299,6 +305,6 @@ def _empty_chart(message: str) -> go.Figure: x=0.5, y=0.5, showarrow=False, - font={"size": 14, "color": "#888888"}, + font={"size": 14, "color": TEXT_SECONDARY}, ) return fig