development #106
274
CLAUDE.md
274
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.
|
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*
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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%)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
48
portfolio_app/design/__init__.py
Normal file
48
portfolio_app/design/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
162
portfolio_app/design/tokens.py
Normal file
162
portfolio_app/design/tokens.py
Normal 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}},
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user