Merge pull request 'development' (#106) from development into main
Some checks failed
CI / lint-and-test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

Reviewed-on: #106
This commit was merged in pull request #106.
This commit is contained in:
2026-02-02 22:02:31 +00:00
21 changed files with 618 additions and 407 deletions

274
CLAUDE.md
View File

@@ -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. 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 setup # Install deps, create .env, init pre-commit
make docker-up # Start PostgreSQL + PostGIS (auto-detects x86/ARM) make docker-up # Start PostgreSQL + PostGIS (auto-detects x86/ARM)
make docker-down # Stop containers make docker-down # Stop containers
make docker-logs # View container logs
make db-init # Initialize database schema make db-init # Initialize database schema
make db-reset # Drop and recreate database (DESTRUCTIVE) make db-reset # Drop and recreate database (DESTRUCTIVE)
# Data Loading # Data Loading
make load-data # Load all project data (currently: Toronto) make load-data # Load all project data (currently: Toronto)
make load-toronto # Load Toronto data from APIs 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 # Application
make run # Start Dash dev server make run # Start Dash dev server
# Testing & Quality # Testing & Quality
make test # Run pytest make test # Run pytest
make test-cov # Run pytest with coverage
make lint # Run ruff linter make lint # Run ruff linter
make format # Run ruff formatter make format # Run ruff formatter
make typecheck # Run mypy type checker make typecheck # Run mypy type checker
@@ -79,8 +86,7 @@ make dbt-run # Run dbt models
make dbt-test # Run dbt tests make dbt-test # Run dbt tests
make dbt-docs # Generate and serve dbt documentation make dbt-docs # Generate and serve dbt documentation
# Maintenance # Run `make help` for full target list
make clean # Remove build artifacts and caches
``` ```
### Branch Workflow ### Branch Workflow
@@ -104,50 +110,22 @@ make clean # Remove build artifacts and caches
### Module Responsibilities ### Module Responsibilities
| Directory | Contains | Purpose | | Directory | Purpose |
|-----------|----------|---------| |-----------|---------|
| `schemas/` | Pydantic models | Data validation | | `schemas/` | Pydantic models for data validation |
| `models/` | SQLAlchemy ORM | Database persistence | | `models/` | SQLAlchemy ORM for database persistence |
| `parsers/` | API/CSV extraction | Raw data ingestion | | `parsers/` | API/CSV extraction for raw data ingestion |
| `loaders/` | Database operations | Data loading | | `loaders/` | Database operations for data loading |
| `services/` | Query functions | dbt mart queries, business logic | | `services/` | Query functions for dbt mart queries |
| `figures/` | Chart factories | Plotly figure generation | | `figures/` | Chart factories for Plotly figure generation |
| `callbacks/` | Dash callbacks | In `pages/{dashboard}/callbacks/` | | `errors/` | Custom exception classes (see `errors/exceptions.py`) |
| `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."""
```
### Code Standards ### Code Standards
- Python 3.10+ type hints: `list[str]`, `dict[str, int] | None`
- Single responsibility functions with verb naming - Single responsibility functions with verb naming
- Early returns over deep nesting - Early returns over deep nesting
- Google-style docstrings only for non-obvious behavior - 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) **Entry Point:** `portfolio_app/app.py` (Dash app factory with Pages routing)
| Directory | Purpose | Notes | | Directory | Purpose |
|-----------|---------|-------| |-----------|---------|
| `pages/` | Dash Pages (file-based routing) | URLs match file paths | | `pages/` | Dash Pages (file-based routing) |
| `pages/toronto/` | Toronto Dashboard | `tabs/` for layouts, `callbacks/` for interactions | | `pages/toronto/` | Toronto Dashboard (`tabs/` for layouts, `callbacks/` for interactions) |
| `components/` | Shared UI components | metric_card, sidebar, map_controls, time_slider | | `components/` | Shared UI components |
| `figures/toronto/` | Toronto chart factories | choropleth, bar_charts, scatter, radar, time_series | | `figures/toronto/` | Toronto chart factories |
| `toronto/` | Toronto data logic | parsers/, loaders/, schemas/, models/ | | `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 |
**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 ### Multi-Dashboard Architecture
The codebase is structured to support multiple dashboard projects: - **figures/**: Domain-namespaced (`figures/toronto/`, future: `figures/football/`)
- **figures/**: Domain-namespaced figure factories (`figures/toronto/`, future: `figures/football/`)
- **notebooks/**: Domain-namespaced documentation (`notebooks/toronto/`, future: `notebooks/football/`)
- **dbt models**: Domain subdirectories (`staging/toronto/`, `marts/toronto/`) - **dbt models**: Domain subdirectories (`staging/toronto/`, `marts/toronto/`)
- **Database schemas**: Domain-specific raw data (`raw_toronto`, future: `raw_football`) - **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 | | Validation | Pydantic | >=2.0 |
| ORM | SQLAlchemy | >=2.0 (2.0-style API only) | | ORM | SQLAlchemy | >=2.0 (2.0-style API only) |
| Transformation | dbt-postgres | >=1.7 | | Transformation | dbt-postgres | >=1.7 |
| Data Processing | Pandas | >=2.1 | | Visualization | Dash + Plotly + dash-mantine-components | >=2.14 |
| Geospatial | GeoPandas + Shapely | >=0.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 | | Python | 3.11+ | Via pyenv |
**Notes**: **Notes**: SQLAlchemy 2.0 + Pydantic 2.0 only. Docker Compose V2 format (no `version` field).
- 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`)
--- ---
@@ -212,35 +179,8 @@ The codebase is structured to support multiple dashboard projects:
| `int_toronto` | Toronto dbt intermediate views | | `int_toronto` | Toronto dbt intermediate views |
| `mart_toronto` | Toronto dbt mart tables | | `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` ### 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 | | Layer | Naming | Purpose |
|-------|--------|---------| |-------|--------|---------|
| Shared | `stg_dimensions__*` | Cross-domain dimensions | | Shared | `stg_dimensions__*` | Cross-domain dimensions |
@@ -252,7 +192,7 @@ dbt/models/
## Deferred Features ## Deferred Features
**Stop and flag if a task seems to require these**: **Stop and flag if a task requires these**:
| Feature | Reason | | 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 ## Reference Documents
| Document | Location | Use When | | Document | Location | Use When |
|----------|----------|----------| |----------|----------|----------|
| Project reference | `docs/PROJECT_REFERENCE.md` | Architecture decisions, completed work | | Project reference | `docs/PROJECT_REFERENCE.md` | Architecture decisions |
| Developer guide | `docs/CONTRIBUTING.md` | How to add pages, blog posts, tabs | | Developer guide | `docs/CONTRIBUTING.md` | How to add pages, tabs |
| Lessons learned | `docs/project-lessons-learned/INDEX.md` | Past issues and solutions | | Lessons learned | `docs/project-lessons-learned/INDEX.md` | Past issues and solutions |
| Deployment runbook | `docs/runbooks/deployment.md` | Deploying to staging/production | | Deployment runbook | `docs/runbooks/deployment.md` | Deploying to environments |
| Dashboard runbook | `docs/runbooks/adding-dashboard.md` | Adding new data dashboards |
--- ---
## 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 | | Skill | Trigger | Purpose |
|-------|---------|---------| |-------|---------|---------|
| `/projman:sprint-plan` | New sprint or phase implementation | Architecture analysis + Gitea issue creation | | `/projman:sprint-plan` | New sprint/feature | Architecture analysis + Gitea issue creation |
| `/projman:sprint-start` | Beginning implementation work | Load lessons learned (Wiki.js or local), start execution | | `/projman:sprint-start` | Begin implementation | Load lessons learned, start execution |
| `/projman:sprint-status` | Check progress | Review blockers and completion status | | `/projman:sprint-status` | Check progress | Review blockers and completion |
| `/projman:sprint-close` | Sprint completion | Capture lessons learned (Wiki.js or local backup) | | `/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 ### Data Platform: data-platform
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
### Gitea Repository Use for dbt, PostgreSQL, and PostGIS operations.
- **Repo**: `personal-projects/personal-portfolio` | Skill | Purpose |
- **Host**: `gitea.hotserv.cloud` |-------|---------|
- **SSH**: `ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/personal-portfolio.git` | `/data-platform:data-review` | Audit data integrity, schema validity, dbt compliance |
- **Labels**: 18 repository-level labels configured (Type, Priority, Complexity, Effort) | `/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**: **MCP tools available:** `pg_connect`, `pg_query`, `pg_tables`, `pg_columns`, `pg_schemas`, `st_*` (PostGIS), `dbt_*` operations.
- `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment`
- `get_labels`, `suggest_labels`
**Wiki.js**: ### Visualization: viz-platform
- `search_lessons`, `create_lesson`, `search_pages`, `get_page`
### 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:** **When to use:** Dashboard development, new visualizations, component prop lookup.
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
### Code Quality: code-sentinel ### Code Quality: code-sentinel
Use for security scanning and refactoring analysis. Use for security scanning and refactoring analysis.
| Command | Purpose | | Skill | Purpose |
|---------|---------| |-------|---------|
| `/code-sentinel:security-scan` | Full security audit of codebase | | `/code-sentinel:security-scan` | Full security audit of codebase |
| `/code-sentinel:refactor` | Apply refactoring patterns | | `/code-sentinel:refactor` | Apply refactoring patterns |
| `/code-sentinel:refactor-dry` | Preview refactoring without applying | | `/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 ### Documentation: doc-guardian
Use for documentation drift detection and synchronization. Use for documentation drift detection and synchronization.
| Command | Purpose | | Skill | Purpose |
|---------|---------| |-------|---------|
| `/doc-guardian:doc-audit` | Scan project for documentation drift | | `/doc-guardian:doc-audit` | Scan project for documentation drift |
| `/doc-guardian:doc-sync` | Synchronize pending documentation updates | | `/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 ### Pull Requests: pr-review
Use for comprehensive PR review with multiple analysis perspectives. Use for comprehensive PR review with multiple analysis perspectives.
| Command | Purpose | | Skill | Purpose |
|---------|---------| |-------|---------|
| `/pr-review:initial-setup` | Configure PR review for this project | | `/pr-review:initial-setup` | Configure PR review for project |
| `/pr-review:project-init` | Quick project-level setup | | Triggered automatically | Security, performance, maintainability, test analysis |
**When to use:** Before merging significant PRs to `development` or `main`. **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 ### 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*

View File

@@ -5,11 +5,11 @@ models:
description: "Rental data enriched with time and zone dimensions" description: "Rental data enriched with time and zone dimensions"
columns: columns:
- name: rental_id - name: rental_id
tests: data_tests:
- unique - unique
- not_null - not_null
- name: zone_code - name: zone_code
tests: data_tests:
- not_null - not_null
- name: int_neighbourhood__demographics - name: int_neighbourhood__demographics
@@ -17,11 +17,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: census_year - name: census_year
description: "Census year" description: "Census year"
tests: data_tests:
- not_null - not_null
- name: income_quintile - name: income_quintile
description: "Income quintile (1-5, city-wide)" description: "Income quintile (1-5, city-wide)"
@@ -31,7 +31,7 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: year - name: year
description: "Reference year" description: "Reference year"
@@ -45,11 +45,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: year - name: year
description: "Statistics year" description: "Statistics year"
tests: data_tests:
- not_null - not_null
- name: crime_rate_per_100k - name: crime_rate_per_100k
description: "Total crime rate per 100K population" description: "Total crime rate per 100K population"
@@ -61,7 +61,7 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: year - name: year
description: "Reference year" description: "Reference year"
@@ -75,11 +75,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: year - name: year
description: "Survey year" description: "Survey year"
tests: data_tests:
- not_null - not_null
- name: avg_rent_2bed - name: avg_rent_2bed
description: "Weighted average 2-bedroom rent" description: "Weighted average 2-bedroom rent"

View File

@@ -16,12 +16,12 @@ crime_by_year as (
neighbourhood_id, neighbourhood_id,
crime_year as year, crime_year as year,
sum(incident_count) as total_incidents, 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 = '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 = '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 = '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 = '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 = '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 = 'homicide' then incident_count else 0 end) as homicide_count,
avg(rate_per_100k) as avg_rate_per_100k avg(rate_per_100k) as avg_rate_per_100k
from crime from crime
group by neighbourhood_id, crime_year group by neighbourhood_id, crime_year

View File

@@ -42,10 +42,10 @@ pivoted as (
select select
neighbourhood_id, neighbourhood_id,
year, 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 = '2bed' 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 = '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 = '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 = '3bed' then weighted_avg_rent / nullif(total_weight, 0) end) as avg_rent_3bed,
avg(vacancy_rate) as vacancy_rate, avg(vacancy_rate) as vacancy_rate,
sum(rental_units_estimate) as total_rental_units sum(rental_units_estimate) as total_rental_units
from allocated from allocated

View File

@@ -6,7 +6,7 @@ models:
columns: columns:
- name: rental_id - name: rental_id
description: "Unique rental record identifier" description: "Unique rental record identifier"
tests: data_tests:
- unique - unique
- not_null - not_null
@@ -17,11 +17,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: neighbourhood_name - name: neighbourhood_name
description: "Official neighbourhood name" description: "Official neighbourhood name"
tests: data_tests:
- not_null - not_null
- name: geometry - name: geometry
description: "PostGIS geometry for mapping" description: "PostGIS geometry for mapping"
@@ -41,11 +41,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: neighbourhood_name - name: neighbourhood_name
description: "Official neighbourhood name" description: "Official neighbourhood name"
tests: data_tests:
- not_null - not_null
- name: geometry - name: geometry
description: "PostGIS geometry for mapping" description: "PostGIS geometry for mapping"
@@ -63,11 +63,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: neighbourhood_name - name: neighbourhood_name
description: "Official neighbourhood name" description: "Official neighbourhood name"
tests: data_tests:
- not_null - not_null
- name: geometry - name: geometry
description: "PostGIS geometry for mapping" description: "PostGIS geometry for mapping"
@@ -77,7 +77,7 @@ models:
description: "100 = city average crime rate" description: "100 = city average crime rate"
- name: safety_tier - name: safety_tier
description: "Safety tier (1=safest, 5=highest crime)" description: "Safety tier (1=safest, 5=highest crime)"
tests: data_tests:
- accepted_values: - accepted_values:
arguments: arguments:
values: [1, 2, 3, 4, 5] values: [1, 2, 3, 4, 5]
@@ -89,11 +89,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: neighbourhood_name - name: neighbourhood_name
description: "Official neighbourhood name" description: "Official neighbourhood name"
tests: data_tests:
- not_null - not_null
- name: geometry - name: geometry
description: "PostGIS geometry for mapping" description: "PostGIS geometry for mapping"
@@ -103,7 +103,7 @@ models:
description: "100 = city average income" description: "100 = city average income"
- name: income_quintile - name: income_quintile
description: "Income quintile (1-5)" description: "Income quintile (1-5)"
tests: data_tests:
- accepted_values: - accepted_values:
arguments: arguments:
values: [1, 2, 3, 4, 5] values: [1, 2, 3, 4, 5]
@@ -115,11 +115,11 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood identifier" description: "Neighbourhood identifier"
tests: data_tests:
- not_null - not_null
- name: neighbourhood_name - name: neighbourhood_name
description: "Official neighbourhood name" description: "Official neighbourhood name"
tests: data_tests:
- not_null - not_null
- name: geometry - name: geometry
description: "PostGIS geometry for mapping" description: "PostGIS geometry for mapping"
@@ -129,7 +129,7 @@ models:
description: "100 = city average amenities" description: "100 = city average amenities"
- name: amenity_tier - name: amenity_tier
description: "Amenity tier (1=best, 5=lowest)" description: "Amenity tier (1=best, 5=lowest)"
tests: data_tests:
- accepted_values: - accepted_values:
arguments: arguments:
values: [1, 2, 3, 4, 5] values: [1, 2, 3, 4, 5]

View File

@@ -128,7 +128,8 @@ final as (
-- Component scores (0-100) -- Component scores (0-100)
round(safety_score::numeric, 1) as safety_score, round(safety_score::numeric, 1) as safety_score,
round(affordability_score::numeric, 1) as affordability_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, 50.0 as amenity_score,
-- Composite livability score: safety (40%), affordability (40%), amenities (20%) -- Composite livability score: safety (40%), affordability (40%), amenities (20%)

View File

@@ -6,16 +6,16 @@ models:
columns: columns:
- name: rental_id - name: rental_id
description: "Unique identifier for rental record" description: "Unique identifier for rental record"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: date_key - name: date_key
description: "Date dimension key (YYYYMMDD)" description: "Date dimension key (YYYYMMDD)"
tests: data_tests:
- not_null - not_null
- name: zone_key - name: zone_key
description: "CMHC zone dimension key" description: "CMHC zone dimension key"
tests: data_tests:
- not_null - not_null
- name: stg_dimensions__cmhc_zones - name: stg_dimensions__cmhc_zones
@@ -23,12 +23,12 @@ models:
columns: columns:
- name: zone_key - name: zone_key
description: "Zone dimension key" description: "Zone dimension key"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: zone_code - name: zone_code
description: "CMHC zone code" description: "CMHC zone code"
tests: data_tests:
- unique - unique
- not_null - not_null
@@ -37,12 +37,12 @@ models:
columns: columns:
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood primary key" description: "Neighbourhood primary key"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: neighbourhood_name - name: neighbourhood_name
description: "Official neighbourhood name" description: "Official neighbourhood name"
tests: data_tests:
- not_null - not_null
- name: geometry - name: geometry
description: "PostGIS geometry (POLYGON)" description: "PostGIS geometry (POLYGON)"
@@ -52,16 +52,16 @@ models:
columns: columns:
- name: census_id - name: census_id
description: "Census record identifier" description: "Census record identifier"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood foreign key" description: "Neighbourhood foreign key"
tests: data_tests:
- not_null - not_null
- name: census_year - name: census_year
description: "Census year (2016, 2021)" description: "Census year (2016, 2021)"
tests: data_tests:
- not_null - not_null
- name: stg_toronto__crime - name: stg_toronto__crime
@@ -69,16 +69,16 @@ models:
columns: columns:
- name: crime_id - name: crime_id
description: "Crime record identifier" description: "Crime record identifier"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood foreign key" description: "Neighbourhood foreign key"
tests: data_tests:
- not_null - not_null
- name: crime_type - name: crime_type
description: "Type of crime" description: "Type of crime"
tests: data_tests:
- not_null - not_null
- name: stg_toronto__amenities - name: stg_toronto__amenities
@@ -86,16 +86,16 @@ models:
columns: columns:
- name: amenity_id - name: amenity_id
description: "Amenity record identifier" description: "Amenity record identifier"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood foreign key" description: "Neighbourhood foreign key"
tests: data_tests:
- not_null - not_null
- name: amenity_type - name: amenity_type
description: "Type of amenity" description: "Type of amenity"
tests: data_tests:
- not_null - not_null
- name: stg_cmhc__zone_crosswalk - name: stg_cmhc__zone_crosswalk
@@ -103,18 +103,18 @@ models:
columns: columns:
- name: crosswalk_id - name: crosswalk_id
description: "Crosswalk record identifier" description: "Crosswalk record identifier"
tests: data_tests:
- unique - unique
- not_null - not_null
- name: cmhc_zone_code - name: cmhc_zone_code
description: "CMHC zone code" description: "CMHC zone code"
tests: data_tests:
- not_null - not_null
- name: neighbourhood_id - name: neighbourhood_id
description: "Neighbourhood foreign key" description: "Neighbourhood foreign key"
tests: data_tests:
- not_null - not_null
- name: area_weight - name: area_weight
description: "Proportional area weight (0-1)" description: "Proportional area weight (0-1)"
tests: data_tests:
- not_null - not_null

View File

@@ -10,8 +10,9 @@ staged as (
select select
zone_key, zone_key,
zone_code, zone_code,
zone_name, zone_name
geometry -- geometry column excluded: CMHC does not provide zone boundaries
-- Spatial analysis uses dim_neighbourhood geometry instead
from source from source
) )

View File

@@ -28,7 +28,7 @@ def create_metric_selector(
label=label, label=label,
data=options, data=options,
value=default_value or (options[0]["value"] if options else None), 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", id=f"{id_prefix}-layer-toggle",
label="Show Boundaries", label="Show Boundaries",
checked=True, checked=True,
style={"marginTop": "10px"}, mt="sm",
) )
) )

View File

@@ -38,7 +38,7 @@ def create_year_selector(
label=label, label=label,
data=options, data=options,
value=str(default_year), value=str(default_year),
style={"width": "120px"}, w=120,
) )
@@ -83,7 +83,8 @@ def create_time_slider(
marks=marks, marks=marks,
step=1, step=1,
minRange=1, minRange=1,
style={"marginTop": "20px", "marginBottom": "10px"}, mt="md",
mb="sm",
), ),
], ],
p="md", p="md",
@@ -131,5 +132,5 @@ def create_month_selector(
label=label, label=label,
data=options, data=options,
value=str(default_month), value=str(default_month),
style={"width": "140px"}, w=140,
) )

View File

@@ -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",
]

View File

@@ -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}},
}

View File

@@ -6,6 +6,17 @@ import pandas as pd
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go 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( def create_ranking_bar(
data: list[dict[str, Any]], data: list[dict[str, Any]],
@@ -14,8 +25,8 @@ def create_ranking_bar(
title: str | None = None, title: str | None = None,
top_n: int = 10, top_n: int = 10,
bottom_n: int = 10, bottom_n: int = 10,
color_top: str = "#4CAF50", color_top: str = COLOR_POSITIVE,
color_bottom: str = "#F44336", color_bottom: str = COLOR_NEGATIVE,
value_format: str = ",.0f", value_format: str = ",.0f",
) -> go.Figure: ) -> go.Figure:
"""Create horizontal bar chart showing top and bottom rankings. """Create horizontal bar chart showing top and bottom rankings.
@@ -87,10 +98,10 @@ def create_ranking_bar(
barmode="group", barmode="group",
showlegend=True, showlegend=True,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02},
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, xaxis={"gridcolor": GRID_COLOR, "title": None},
yaxis={"autorange": "reversed", "title": None}, yaxis={"autorange": "reversed", "title": None},
margin={"l": 10, "r": 10, "t": 40, "b": 10}, margin={"l": 10, "r": 10, "t": 40, "b": 10},
) )
@@ -126,10 +137,10 @@ def create_stacked_bar(
df = pd.DataFrame(data) df = pd.DataFrame(data)
# Default color scheme # Default color scheme using accessible palette
if color_map is None: if color_map is None:
categories = df[category_column].unique() 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)) color_map = dict(zip(categories, colors, strict=False))
fig = px.bar( fig = px.bar(
@@ -147,11 +158,11 @@ def create_stacked_bar(
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, xaxis={"gridcolor": GRID_COLOR, "title": None},
yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, yaxis={"gridcolor": GRID_COLOR, "title": None},
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02},
margin={"l": 10, "r": 10, "t": 60, "b": 10}, margin={"l": 10, "r": 10, "t": 60, "b": 10},
) )
@@ -164,7 +175,7 @@ def create_horizontal_bar(
name_column: str, name_column: str,
value_column: str, value_column: str,
title: str | None = None, title: str | None = None,
color: str = "#2196F3", color: str = CHART_PALETTE[0],
value_format: str = ",.0f", value_format: str = ",.0f",
sort: bool = True, sort: bool = True,
) -> go.Figure: ) -> go.Figure:
@@ -204,10 +215,10 @@ def create_horizontal_bar(
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": None}, xaxis={"gridcolor": GRID_COLOR, "title": None},
yaxis={"title": None}, yaxis={"title": None},
margin={"l": 10, "r": 10, "t": 40, "b": 10}, margin={"l": 10, "r": 10, "t": 40, "b": 10},
) )
@@ -225,13 +236,13 @@ def _create_empty_figure(title: str) -> go.Figure:
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"visible": False}, xaxis={"visible": False},
yaxis={"visible": False}, yaxis={"visible": False},
) )

View File

@@ -5,6 +5,13 @@ from typing import Any
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go import plotly.graph_objects as go
from portfolio_app.design import (
PAPER_BG,
PLOT_BG,
TEXT_PRIMARY,
TEXT_SECONDARY,
)
def create_choropleth_figure( def create_choropleth_figure(
geojson: dict[str, Any] | None, geojson: dict[str, Any] | None,
@@ -55,9 +62,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)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
) )
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.",
@@ -66,7 +73,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": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
return fig return fig
@@ -98,17 +105,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)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
coloraxis_colorbar={ coloraxis_colorbar={
"title": { "title": {
"text": color_column.replace("_", " ").title(), "text": color_column.replace("_", " ").title(),
"font": {"color": "#c9c9c9"}, "font": {"color": TEXT_PRIMARY},
}, },
"thickness": 15, "thickness": 15,
"len": 0.7, "len": 0.7,
"tickfont": {"color": "#c9c9c9"}, "tickfont": {"color": TEXT_PRIMARY},
}, },
) )

View File

@@ -5,6 +5,16 @@ from typing import Any
import pandas as pd import pandas as pd
import plotly.graph_objects as go 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( def create_age_pyramid(
data: list[dict[str, Any]], data: list[dict[str, Any]],
@@ -52,7 +62,7 @@ def create_age_pyramid(
x=male_values_neg, x=male_values_neg,
orientation="h", orientation="h",
name="Male", name="Male",
marker_color="#2196F3", marker_color=PALETTE_GENDER["male"],
hovertemplate="%{y}<br>Male: %{customdata:,}<extra></extra>", hovertemplate="%{y}<br>Male: %{customdata:,}<extra></extra>",
customdata=male_values, customdata=male_values,
) )
@@ -65,7 +75,7 @@ def create_age_pyramid(
x=female_values, x=female_values,
orientation="h", orientation="h",
name="Female", name="Female",
marker_color="#E91E63", marker_color=PALETTE_GENDER["female"],
hovertemplate="%{y}<br>Female: %{x:,}<extra></extra>", hovertemplate="%{y}<br>Female: %{x:,}<extra></extra>",
) )
) )
@@ -77,12 +87,12 @@ def create_age_pyramid(
title=title, title=title,
barmode="overlay", barmode="overlay",
bargap=0.1, bargap=0.1,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={ xaxis={
"title": "Population", "title": "Population",
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
"range": [-max_val * 1.1, max_val * 1.1], "range": [-max_val * 1.1, max_val * 1.1],
"tickvals": [-max_val, -max_val / 2, 0, max_val / 2, max_val], "tickvals": [-max_val, -max_val / 2, 0, max_val / 2, max_val],
"ticktext": [ "ticktext": [
@@ -93,7 +103,7 @@ def create_age_pyramid(
f"{max_val:,.0f}", 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}, legend={"orientation": "h", "yanchor": "bottom", "y": 1.02},
margin={"l": 10, "r": 10, "t": 60, "b": 10}, margin={"l": 10, "r": 10, "t": 60, "b": 10},
) )
@@ -127,17 +137,9 @@ def create_donut_chart(
df = pd.DataFrame(data) df = pd.DataFrame(data)
# Use accessible palette by default
if colors is None: if colors is None:
colors = [ colors = CHART_PALETTE
"#2196F3",
"#4CAF50",
"#FF9800",
"#E91E63",
"#9C27B0",
"#00BCD4",
"#FFC107",
"#795548",
]
fig = go.Figure( fig = go.Figure(
go.Pie( go.Pie(
@@ -153,8 +155,8 @@ def create_donut_chart(
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
showlegend=False, showlegend=False,
margin={"l": 10, "r": 10, "t": 60, "b": 10}, margin={"l": 10, "r": 10, "t": 60, "b": 10},
) )
@@ -167,7 +169,7 @@ def create_income_distribution(
bracket_column: str, bracket_column: str,
count_column: str, count_column: str,
title: str | None = None, title: str | None = None,
color: str = "#4CAF50", color: str = CHART_PALETTE[3], # Teal
) -> go.Figure: ) -> go.Figure:
"""Create histogram-style bar chart for income distribution. """Create histogram-style bar chart for income distribution.
@@ -199,17 +201,17 @@ def create_income_distribution(
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={ xaxis={
"title": "Income Bracket", "title": "Income Bracket",
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
"tickangle": -45, "tickangle": -45,
}, },
yaxis={ yaxis={
"title": "Households", "title": "Households",
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
}, },
margin={"l": 10, "r": 10, "t": 60, "b": 80}, margin={"l": 10, "r": 10, "t": 60, "b": 80},
) )
@@ -227,13 +229,13 @@ def _create_empty_figure(title: str) -> go.Figure:
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"visible": False}, xaxis={"visible": False},
yaxis={"visible": False}, yaxis={"visible": False},
) )

View File

@@ -4,6 +4,14 @@ from typing import Any
import plotly.graph_objects as go 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( def create_radar_figure(
data: list[dict[str, Any]], data: list[dict[str, Any]],
@@ -32,16 +40,9 @@ def create_radar_figure(
if not data or not metrics: if not data or not metrics:
return _create_empty_figure(title or "Radar Chart") return _create_empty_figure(title or "Radar Chart")
# Default colors # Use accessible palette by default
if colors is None: if colors is None:
colors = [ colors = CHART_PALETTE
"#2196F3",
"#4CAF50",
"#FF9800",
"#E91E63",
"#9C27B0",
"#00BCD4",
]
fig = go.Figure() fig = go.Figure()
@@ -78,19 +79,19 @@ def create_radar_figure(
polar={ polar={
"radialaxis": { "radialaxis": {
"visible": True, "visible": True,
"gridcolor": "rgba(128,128,128,0.3)", "gridcolor": GRID_COLOR_DARK,
"linecolor": "rgba(128,128,128,0.3)", "linecolor": GRID_COLOR_DARK,
"tickfont": {"color": "#c9c9c9"}, "tickfont": {"color": TEXT_PRIMARY},
}, },
"angularaxis": { "angularaxis": {
"gridcolor": "rgba(128,128,128,0.3)", "gridcolor": GRID_COLOR_DARK,
"linecolor": "rgba(128,128,128,0.3)", "linecolor": GRID_COLOR_DARK,
"tickfont": {"color": "#c9c9c9"}, "tickfont": {"color": TEXT_PRIMARY},
}, },
"bgcolor": "rgba(0,0,0,0)", "bgcolor": PAPER_BG,
}, },
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
showlegend=len(data) > 1, showlegend=len(data) > 1,
legend={"orientation": "h", "yanchor": "bottom", "y": -0.2}, legend={"orientation": "h", "yanchor": "bottom", "y": -0.2},
margin={"l": 40, "r": 40, "t": 60, "b": 40}, margin={"l": 40, "r": 40, "t": 60, "b": 40},
@@ -133,7 +134,7 @@ def create_comparison_radar(
metrics=metrics, metrics=metrics,
name_column="__name__", name_column="__name__",
title=title, 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, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
) )
return fig return fig

View File

@@ -6,6 +6,15 @@ import pandas as pd
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go 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( def create_scatter_figure(
data: list[dict[str, Any]], data: list[dict[str, Any]],
@@ -72,21 +81,21 @@ def create_scatter_figure(
if trendline: if trendline:
fig.update_traces( fig.update_traces(
selector={"mode": "lines"}, selector={"mode": "lines"},
line={"color": "#FF9800", "dash": "dash", "width": 2}, line={"color": CHART_PALETTE[1], "dash": "dash", "width": 2},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={ xaxis={
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
"title": x_title or x_column.replace("_", " ").title(), "title": x_title or x_column.replace("_", " ").title(),
"zeroline": False, "zeroline": False,
}, },
yaxis={ yaxis={
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
"title": y_title or y_column.replace("_", " ").title(), "title": y_title or y_column.replace("_", " ").title(),
"zeroline": False, "zeroline": False,
}, },
@@ -140,19 +149,20 @@ def create_bubble_chart(
hover_name=name_column, hover_name=name_column,
size_max=size_max, size_max=size_max,
opacity=0.7, opacity=0.7,
color_discrete_sequence=CHART_PALETTE,
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={ xaxis={
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
"title": x_title or x_column.replace("_", " ").title(), "title": x_title or x_column.replace("_", " ").title(),
}, },
yaxis={ yaxis={
"gridcolor": "rgba(128,128,128,0.2)", "gridcolor": GRID_COLOR,
"title": y_title or y_column.replace("_", " ").title(), "title": y_title or y_column.replace("_", " ").title(),
}, },
margin={"l": 10, "r": 10, "t": 40, "b": 10}, margin={"l": 10, "r": 10, "t": 40, "b": 10},
@@ -171,13 +181,13 @@ def _create_empty_figure(title: str) -> go.Figure:
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"visible": False}, xaxis={"visible": False},
yaxis={"visible": False}, yaxis={"visible": False},
) )

View File

@@ -4,6 +4,14 @@ from typing import Any
import plotly.graph_objects as go 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( def create_metric_card_figure(
value: float | int | str, value: float | int | str,
@@ -59,8 +67,12 @@ def create_metric_card_figure(
"relative": False, "relative": False,
"valueformat": ".1f", "valueformat": ".1f",
"suffix": delta_suffix, "suffix": delta_suffix,
"increasing": {"color": "green" if positive_is_good else "red"}, "increasing": {
"decreasing": {"color": "red" if positive_is_good else "green"}, "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)) fig.add_trace(go.Indicator(**indicator_config))
@@ -68,9 +80,9 @@ def create_metric_card_figure(
fig.update_layout( fig.update_layout(
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=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font={"family": "Inter, sans-serif", "color": "#c9c9c9"}, font={"family": "Inter, sans-serif", "color": TEXT_PRIMARY},
) )
return fig return fig

View File

@@ -5,6 +5,15 @@ from typing import Any
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go 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( def create_price_time_series(
data: list[dict[str, Any]], data: list[dict[str, Any]],
@@ -38,14 +47,14 @@ def create_price_time_series(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"color": "#888888"}, font={"color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
height=350, height=350,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
) )
return fig return fig
@@ -59,6 +68,7 @@ def create_price_time_series(
y=price_column, y=price_column,
color=group_column, color=group_column,
title=title, title=title,
color_discrete_sequence=CHART_PALETTE,
) )
else: else:
fig = px.line( fig = px.line(
@@ -67,6 +77,7 @@ def create_price_time_series(
y=price_column, y=price_column,
title=title, title=title,
) )
fig.update_traces(line_color=CHART_PALETTE[0])
fig.update_layout( fig.update_layout(
height=350, height=350,
@@ -76,11 +87,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)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "#333333", "linecolor": "#444444"}, xaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"}, yaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR},
) )
return fig return fig
@@ -118,14 +129,14 @@ def create_volume_time_series(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"color": "#888888"}, font={"color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
height=350, height=350,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
) )
return fig return fig
@@ -140,6 +151,7 @@ def create_volume_time_series(
y=volume_column, y=volume_column,
color=group_column, color=group_column,
title=title, title=title,
color_discrete_sequence=CHART_PALETTE,
) )
else: else:
fig = px.bar( fig = px.bar(
@@ -148,6 +160,7 @@ def create_volume_time_series(
y=volume_column, y=volume_column,
title=title, title=title,
) )
fig.update_traces(marker_color=CHART_PALETTE[0])
else: else:
if group_column and group_column in df.columns: if group_column and group_column in df.columns:
fig = px.line( fig = px.line(
@@ -156,6 +169,7 @@ def create_volume_time_series(
y=volume_column, y=volume_column,
color=group_column, color=group_column,
title=title, title=title,
color_discrete_sequence=CHART_PALETTE,
) )
else: else:
fig = px.line( fig = px.line(
@@ -164,6 +178,7 @@ def create_volume_time_series(
y=volume_column, y=volume_column,
title=title, title=title,
) )
fig.update_traces(line_color=CHART_PALETTE[0])
fig.update_layout( fig.update_layout(
height=350, height=350,
@@ -172,11 +187,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)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "#333333", "linecolor": "#444444"}, xaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"}, yaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR},
) )
return fig return fig
@@ -211,14 +226,14 @@ def create_market_comparison_chart(
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"color": "#888888"}, font={"color": TEXT_SECONDARY},
) )
fig.update_layout( fig.update_layout(
title=title, title=title,
height=400, height=400,
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
) )
return fig return fig
@@ -230,8 +245,6 @@ def create_market_comparison_chart(
fig = make_subplots(specs=[[{"secondary_y": True}]]) fig = make_subplots(specs=[[{"secondary_y": True}]])
colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
for i, metric in enumerate(metrics[:4]): for i, metric in enumerate(metrics[:4]):
if metric not in df.columns: if metric not in df.columns:
continue continue
@@ -242,7 +255,7 @@ def create_market_comparison_chart(
x=df[date_column], x=df[date_column],
y=df[metric], y=df[metric],
name=metric.replace("_", " ").title(), name=metric.replace("_", " ").title(),
line={"color": colors[i % len(colors)]}, line={"color": CHART_PALETTE[i % len(CHART_PALETTE)]},
), ),
secondary_y=secondary, secondary_y=secondary,
) )
@@ -252,18 +265,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)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "#333333", "linecolor": "#444444"}, xaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR},
yaxis={"gridcolor": "#333333", "linecolor": "#444444"}, yaxis={"gridcolor": GRID_COLOR, "linecolor": GRID_COLOR},
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"}, "font": {"color": TEXT_PRIMARY},
}, },
) )
@@ -290,13 +303,13 @@ def add_policy_markers(
if not policy_events: if not policy_events:
return fig return fig
# Color mapping for policy categories # Color mapping for policy categories using design tokens
category_colors = { category_colors = {
"monetary": "#1f77b4", # Blue "monetary": CHART_PALETTE[0], # Blue
"tax": "#2ca02c", # Green "tax": CHART_PALETTE[3], # Teal/green
"regulatory": "#ff7f0e", # Orange "regulatory": CHART_PALETTE[1], # Orange
"supply": "#9467bd", # Purple "supply": CHART_PALETTE[6], # Pink
"economic": "#d62728", # Red "economic": CHART_PALETTE[5], # Vermillion
} }
# Symbol mapping for expected direction # Symbol mapping for expected direction
@@ -313,7 +326,7 @@ def add_policy_markers(
title = event.get("title", "Policy Event") title = event.get("title", "Policy Event")
level = event.get("level", "federal") level = event.get("level", "federal")
color = category_colors.get(category, "#666666") color = category_colors.get(category, TEXT_SECONDARY)
symbol = direction_symbols.get(direction, "circle") symbol = direction_symbols.get(direction, "circle")
# Add vertical line for the event # Add vertical line for the event
@@ -335,7 +348,7 @@ def add_policy_markers(
"symbol": symbol, "symbol": symbol,
"size": 12, "size": 12,
"color": color, "color": color,
"line": {"width": 1, "color": "white"}, "line": {"width": 1, "color": TEXT_PRIMARY},
}, },
name=title, name=title,
hovertemplate=( hovertemplate=(

View File

@@ -5,6 +5,14 @@ import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
from dash import Input, Output, callback 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 ( from portfolio_app.figures.toronto import (
create_donut_chart, create_donut_chart,
create_horizontal_bar, 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], x=[d["year"] for d in data],
y=[d["avg_rent"] for d in data], y=[d["avg_rent"] for d in data],
mode="lines+markers", mode="lines+markers",
line={"color": "#2196F3", "width": 2}, line={"color": CHART_PALETTE[0], "width": 2},
marker={"size": 8}, marker={"size": 8},
name="City Average", name="City Average",
) )
) )
fig.update_layout( fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "rgba(128,128,128,0.2)"}, xaxis={"gridcolor": GRID_COLOR},
yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": "Avg Rent (2BR)"}, yaxis={"gridcolor": GRID_COLOR, "title": "Avg Rent (2BR)"},
showlegend=False, showlegend=False,
margin={"l": 40, "r": 10, "t": 10, "b": 30}, margin={"l": 40, "r": 10, "t": 10, "b": 30},
) )
@@ -153,7 +161,7 @@ def update_housing_types(year: str) -> go.Figure:
data=data, data=data,
name_column="type", name_column="type",
value_column="percentage", 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], x=[d["year"] for d in data],
y=[d["crime_rate"] for d in data], y=[d["crime_rate"] for d in data],
mode="lines+markers", mode="lines+markers",
line={"color": "#FF5722", "width": 2}, line={"color": CHART_PALETTE[5], "width": 2}, # Vermillion
marker={"size": 8}, marker={"size": 8},
fill="tozeroy", fill="tozeroy",
fillcolor="rgba(255,87,34,0.1)", fillcolor="rgba(213, 94, 0, 0.1)", # Vermillion with opacity
) )
) )
fig.update_layout( fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"gridcolor": "rgba(128,128,128,0.2)"}, xaxis={"gridcolor": GRID_COLOR},
yaxis={"gridcolor": "rgba(128,128,128,0.2)", "title": "Crime Rate per 100K"}, yaxis={"gridcolor": GRID_COLOR, "title": "Crime Rate per 100K"},
showlegend=False, showlegend=False,
margin={"l": 40, "r": 10, "t": 10, "b": 30}, margin={"l": 40, "r": 10, "t": 10, "b": 30},
) )
@@ -233,7 +241,7 @@ def update_safety_types(year: str) -> go.Figure:
data=data, data=data,
name_column="category", name_column="category",
value_column="count", 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, data=data,
name_column="age_group", name_column="age_group",
value_column="percentage", 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, data=data,
name_column="bracket", name_column="bracket",
value_column="count", value_column="count",
color="#4CAF50", color=CHART_PALETTE[3], # Teal
sort=False, sort=False,
) )
@@ -333,7 +345,7 @@ def update_amenities_breakdown(year: str) -> go.Figure:
data=data, data=data,
name_column="type", name_column="type",
value_column="count", 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.""" """Create an empty chart with a message."""
fig = go.Figure() fig = go.Figure()
fig.update_layout( fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"visible": False}, xaxis={"visible": False},
yaxis={"visible": False}, yaxis={"visible": False},
) )
@@ -400,6 +412,6 @@ def _empty_chart(message: str) -> go.Figure:
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
return fig return fig

View File

@@ -4,6 +4,12 @@
import plotly.graph_objects as go import plotly.graph_objects as go
from dash import Input, Output, State, callback, no_update 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.figures.toronto import create_choropleth_figure, create_ranking_bar
from portfolio_app.toronto.services import ( from portfolio_app.toronto.services import (
get_amenities_data, get_amenities_data,
@@ -267,8 +273,8 @@ def _empty_map(message: str) -> go.Figure:
"zoom": 9.5, "zoom": 9.5,
}, },
margin={"l": 0, "r": 0, "t": 0, "b": 0}, margin={"l": 0, "r": 0, "t": 0, "b": 0},
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
) )
fig.add_annotation( fig.add_annotation(
text=message, text=message,
@@ -277,7 +283,7 @@ def _empty_map(message: str) -> go.Figure:
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
return fig return fig
@@ -286,9 +292,9 @@ def _empty_chart(message: str) -> go.Figure:
"""Create an empty chart with a message.""" """Create an empty chart with a message."""
fig = go.Figure() fig = go.Figure()
fig.update_layout( fig.update_layout(
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor=PAPER_BG,
plot_bgcolor="rgba(0,0,0,0)", plot_bgcolor=PLOT_BG,
font_color="#c9c9c9", font_color=TEXT_PRIMARY,
xaxis={"visible": False}, xaxis={"visible": False},
yaxis={"visible": False}, yaxis={"visible": False},
) )
@@ -299,6 +305,6 @@ def _empty_chart(message: str) -> go.Figure:
x=0.5, x=0.5,
y=0.5, y=0.5,
showarrow=False, showarrow=False,
font={"size": 14, "color": "#888888"}, font={"size": 14, "color": TEXT_SECONDARY},
) )
return fig return fig