diff --git a/portfolio_app/components/__init__.py b/portfolio_app/components/__init__.py new file mode 100644 index 0000000..b079dd5 --- /dev/null +++ b/portfolio_app/components/__init__.py @@ -0,0 +1,14 @@ +"""Shared Dash components for the portfolio application.""" + +from .map_controls import create_map_controls, create_metric_selector +from .metric_card import MetricCard, create_metric_cards_row +from .time_slider import create_time_slider, create_year_selector + +__all__ = [ + "create_map_controls", + "create_metric_selector", + "create_time_slider", + "create_year_selector", + "MetricCard", + "create_metric_cards_row", +] diff --git a/portfolio_app/components/map_controls.py b/portfolio_app/components/map_controls.py new file mode 100644 index 0000000..5fa6784 --- /dev/null +++ b/portfolio_app/components/map_controls.py @@ -0,0 +1,79 @@ +"""Map control components for choropleth visualizations.""" + +from typing import Any + +import dash_mantine_components as dmc +from dash import html + + +def create_metric_selector( + id_prefix: str, + options: list[dict[str, str]], + default_value: str | None = None, + label: str = "Select Metric", +) -> dmc.Select: + """Create a metric selector dropdown. + + Args: + id_prefix: Prefix for component IDs. + options: List of options with 'label' and 'value' keys. + default_value: Initial selected value. + label: Label text for the selector. + + Returns: + Mantine Select component. + """ + return dmc.Select( + id=f"{id_prefix}-metric-selector", + label=label, + data=options, + value=default_value or (options[0]["value"] if options else None), + style={"width": "200px"}, + ) + + +def create_map_controls( + id_prefix: str, + metric_options: list[dict[str, str]], + default_metric: str | None = None, + show_layer_toggle: bool = True, +) -> dmc.Paper: + """Create a control panel for map visualizations. + + Args: + id_prefix: Prefix for component IDs. + metric_options: Options for metric selector. + default_metric: Default selected metric. + show_layer_toggle: Whether to show layer visibility toggle. + + Returns: + Mantine Paper component containing controls. + """ + controls: list[Any] = [ + create_metric_selector( + id_prefix=id_prefix, + options=metric_options, + default_value=default_metric, + label="Display Metric", + ), + ] + + if show_layer_toggle: + controls.append( + dmc.Switch( + id=f"{id_prefix}-layer-toggle", + label="Show Boundaries", + checked=True, + style={"marginTop": "10px"}, + ) + ) + + return dmc.Paper( + children=[ + dmc.Text("Map Controls", fw=500, size="sm", mb="xs"), + html.Div(controls), + ], + p="md", + radius="sm", + withBorder=True, + ) diff --git a/portfolio_app/components/metric_card.py b/portfolio_app/components/metric_card.py new file mode 100644 index 0000000..42b3d24 --- /dev/null +++ b/portfolio_app/components/metric_card.py @@ -0,0 +1,115 @@ +"""Metric card components for KPI display.""" + +from typing import Any + +import dash_mantine_components as dmc +from dash import dcc + +from portfolio_app.figures.summary_cards import create_metric_card_figure + + +class MetricCard: + """A reusable metric card component.""" + + def __init__( + self, + id_prefix: str, + title: str, + value: float | int | str = 0, + delta: float | None = None, + prefix: str = "", + suffix: str = "", + format_spec: str = ",.0f", + positive_is_good: bool = True, + ): + """Initialize a metric card. + + Args: + id_prefix: Prefix for component IDs. + title: Card title. + value: Main metric value. + delta: Change value for delta indicator. + prefix: Value prefix (e.g., '$'). + suffix: Value suffix. + format_spec: Python format specification. + positive_is_good: Whether positive delta is good. + """ + self.id_prefix = id_prefix + self.title = title + self.value = value + self.delta = delta + self.prefix = prefix + self.suffix = suffix + self.format_spec = format_spec + self.positive_is_good = positive_is_good + + def render(self) -> dmc.Paper: + """Render the metric card component. + + Returns: + Mantine Paper component with embedded graph. + """ + fig = create_metric_card_figure( + value=self.value, + title=self.title, + delta=self.delta, + prefix=self.prefix, + suffix=self.suffix, + format_spec=self.format_spec, + positive_is_good=self.positive_is_good, + ) + + return dmc.Paper( + children=[ + dcc.Graph( + id=f"{self.id_prefix}-graph", + figure=fig, + config={"displayModeBar": False}, + style={"height": "120px"}, + ) + ], + p="xs", + radius="sm", + withBorder=True, + ) + + +def create_metric_cards_row( + metrics: list[dict[str, Any]], + id_prefix: str = "metric", +) -> dmc.SimpleGrid: + """Create a row of metric cards. + + Args: + metrics: List of metric configurations with keys: + - title: Card title + - value: Metric value + - delta: Optional change value + - prefix: Optional value prefix + - suffix: Optional value suffix + - format_spec: Optional format specification + - positive_is_good: Optional delta color logic + id_prefix: Prefix for component IDs. + + Returns: + Mantine SimpleGrid component with metric cards. + """ + cards = [] + for i, metric in enumerate(metrics): + card = MetricCard( + id_prefix=f"{id_prefix}-{i}", + title=metric.get("title", ""), + value=metric.get("value", 0), + delta=metric.get("delta"), + prefix=metric.get("prefix", ""), + suffix=metric.get("suffix", ""), + format_spec=metric.get("format_spec", ",.0f"), + positive_is_good=metric.get("positive_is_good", True), + ) + cards.append(card.render()) + + return dmc.SimpleGrid( + cols={"base": 1, "sm": 2, "md": len(cards)}, + spacing="md", + children=cards, + ) diff --git a/portfolio_app/components/time_slider.py b/portfolio_app/components/time_slider.py new file mode 100644 index 0000000..12752d6 --- /dev/null +++ b/portfolio_app/components/time_slider.py @@ -0,0 +1,135 @@ +"""Time selection components for temporal data filtering.""" + +from datetime import date + +import dash_mantine_components as dmc + + +def create_year_selector( + id_prefix: str, + min_year: int = 2020, + max_year: int | None = None, + default_year: int | None = None, + label: str = "Select Year", +) -> dmc.Select: + """Create a year selector dropdown. + + Args: + id_prefix: Prefix for component IDs. + min_year: Minimum year option. + max_year: Maximum year option (defaults to current year). + default_year: Initial selected year. + label: Label text for the selector. + + Returns: + Mantine Select component. + """ + if max_year is None: + max_year = date.today().year + + if default_year is None: + default_year = max_year + + years = list(range(max_year, min_year - 1, -1)) + options = [{"label": str(year), "value": str(year)} for year in years] + + return dmc.Select( + id=f"{id_prefix}-year-selector", + label=label, + data=options, + value=str(default_year), + style={"width": "120px"}, + ) + + +def create_time_slider( + id_prefix: str, + min_year: int = 2020, + max_year: int | None = None, + default_range: tuple[int, int] | None = None, + label: str = "Time Range", +) -> dmc.Paper: + """Create a time range slider component. + + Args: + id_prefix: Prefix for component IDs. + min_year: Minimum year for the slider. + max_year: Maximum year for the slider. + default_range: Default (start, end) year range. + label: Label text for the slider. + + Returns: + Mantine Paper component containing the slider. + """ + if max_year is None: + max_year = date.today().year + + if default_range is None: + default_range = (min_year, max_year) + + # Create marks for every year + marks = [ + {"value": year, "label": str(year)} for year in range(min_year, max_year + 1) + ] + + return dmc.Paper( + children=[ + dmc.Text(label, fw=500, size="sm", mb="xs"), + dmc.RangeSlider( + id=f"{id_prefix}-time-slider", + min=min_year, + max=max_year, + value=list(default_range), + marks=marks, + step=1, + minRange=1, + style={"marginTop": "20px", "marginBottom": "10px"}, + ), + ], + p="md", + radius="sm", + withBorder=True, + ) + + +def create_month_selector( + id_prefix: str, + default_month: int | None = None, + label: str = "Select Month", +) -> dmc.Select: + """Create a month selector dropdown. + + Args: + id_prefix: Prefix for component IDs. + default_month: Initial selected month (1-12). + label: Label text for the selector. + + Returns: + Mantine Select component. + """ + months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + options = [{"label": month, "value": str(i + 1)} for i, month in enumerate(months)] + + if default_month is None: + default_month = date.today().month + + return dmc.Select( + id=f"{id_prefix}-month-selector", + label=label, + data=options, + value=str(default_month), + style={"width": "140px"}, + ) diff --git a/portfolio_app/figures/__init__.py b/portfolio_app/figures/__init__.py new file mode 100644 index 0000000..0c3378c --- /dev/null +++ b/portfolio_app/figures/__init__.py @@ -0,0 +1,12 @@ +"""Plotly figure factories for data visualization.""" + +from .choropleth import create_choropleth_figure +from .summary_cards import create_metric_card_figure +from .time_series import create_price_time_series, create_volume_time_series + +__all__ = [ + "create_choropleth_figure", + "create_price_time_series", + "create_volume_time_series", + "create_metric_card_figure", +] diff --git a/portfolio_app/figures/choropleth.py b/portfolio_app/figures/choropleth.py new file mode 100644 index 0000000..6e4ec7b --- /dev/null +++ b/portfolio_app/figures/choropleth.py @@ -0,0 +1,152 @@ +"""Choropleth map figure factory for Toronto housing data.""" + +from typing import Any + +import plotly.express as px +import plotly.graph_objects as go + + +def create_choropleth_figure( + geojson: dict[str, Any] | None, + data: list[dict[str, Any]], + location_key: str, + color_column: str, + hover_data: list[str] | None = None, + color_scale: str = "Blues", + title: str | None = None, + map_style: str = "carto-positron", + center: dict[str, float] | None = None, + zoom: float = 9.5, +) -> go.Figure: + """Create a choropleth map figure. + + Args: + geojson: GeoJSON FeatureCollection for boundaries. + data: List of data records with location keys and values. + location_key: Column name for location identifier. + color_column: Column name for color values. + hover_data: Additional columns to show on hover. + color_scale: Plotly color scale name. + title: Optional chart title. + map_style: Mapbox style (carto-positron, open-street-map, etc.). + center: Map center coordinates {"lat": float, "lon": float}. + zoom: Initial zoom level. + + Returns: + Plotly Figure object. + """ + # Default center to Toronto + if center is None: + center = {"lat": 43.7, "lon": -79.4} + + # If no geojson provided, create a placeholder map + if geojson is None or not data: + fig = go.Figure(go.Scattermapbox()) + fig.update_layout( + mapbox={ + "style": map_style, + "center": center, + "zoom": zoom, + }, + margin={"l": 0, "r": 0, "t": 40, "b": 0}, + title=title or "Toronto Housing Map", + height=500, + ) + fig.add_annotation( + text="No geometry data available. Complete QGIS digitization to enable map.", + xref="paper", + yref="paper", + x=0.5, + y=0.5, + showarrow=False, + font={"size": 14, "color": "gray"}, + ) + return fig + + # Create choropleth with data + import pandas as pd + + df = pd.DataFrame(data) + + fig = px.choropleth_mapbox( + df, + geojson=geojson, + locations=location_key, + featureidkey=f"properties.{location_key}", + color=color_column, + color_continuous_scale=color_scale, + hover_data=hover_data, + mapbox_style=map_style, + center=center, + zoom=zoom, + opacity=0.7, + ) + + fig.update_layout( + margin={"l": 0, "r": 0, "t": 40, "b": 0}, + title=title, + height=500, + coloraxis_colorbar={ + "title": color_column.replace("_", " ").title(), + "thickness": 15, + "len": 0.7, + }, + ) + + return fig + + +def create_district_map( + districts_geojson: dict[str, Any] | None, + purchase_data: list[dict[str, Any]], + metric: str = "avg_price", +) -> go.Figure: + """Create choropleth map for TRREB districts. + + Args: + districts_geojson: GeoJSON for TRREB district boundaries. + purchase_data: Purchase statistics by district. + metric: Metric to display (avg_price, sales_count, etc.). + + Returns: + Plotly Figure object. + """ + hover_columns = ["district_name", "sales_count", "avg_price", "median_price"] + + return create_choropleth_figure( + geojson=districts_geojson, + data=purchase_data, + location_key="district_code", + color_column=metric, + hover_data=[c for c in hover_columns if c != metric], + color_scale="Blues" if "price" in metric else "Greens", + title="Toronto Purchase Market by District", + ) + + +def create_zone_map( + zones_geojson: dict[str, Any] | None, + rental_data: list[dict[str, Any]], + metric: str = "avg_rent", +) -> go.Figure: + """Create choropleth map for CMHC zones. + + Args: + zones_geojson: GeoJSON for CMHC zone boundaries. + rental_data: Rental statistics by zone. + metric: Metric to display (avg_rent, vacancy_rate, etc.). + + Returns: + Plotly Figure object. + """ + hover_columns = ["zone_name", "avg_rent", "vacancy_rate", "rental_universe"] + + return create_choropleth_figure( + geojson=zones_geojson, + data=rental_data, + location_key="zone_code", + color_column=metric, + hover_data=[c for c in hover_columns if c != metric], + color_scale="Oranges" if "rent" in metric else "Purples", + title="Toronto Rental Market by Zone", + ) diff --git a/portfolio_app/figures/summary_cards.py b/portfolio_app/figures/summary_cards.py new file mode 100644 index 0000000..9a27a1a --- /dev/null +++ b/portfolio_app/figures/summary_cards.py @@ -0,0 +1,106 @@ +"""Summary card figure factories for KPI display.""" + +from typing import Any + +import plotly.graph_objects as go + + +def create_metric_card_figure( + value: float | int | str, + title: str, + delta: float | None = None, + delta_suffix: str = "%", + prefix: str = "", + suffix: str = "", + format_spec: str = ",.0f", + positive_is_good: bool = True, +) -> go.Figure: + """Create a KPI indicator figure. + + Args: + value: The main metric value. + title: Card title. + delta: Optional change value (for delta indicator). + delta_suffix: Suffix for delta value (e.g., '%'). + prefix: Prefix for main value (e.g., '$'). + suffix: Suffix for main value. + format_spec: Python format specification for the value. + positive_is_good: Whether positive delta is good (green) or bad (red). + + Returns: + Plotly Figure object. + """ + # Determine numeric value for indicator + if isinstance(value, int | float): + number_value: float | None = float(value) + else: + number_value = None + + fig = go.Figure() + + # Add indicator trace + indicator_config: dict[str, Any] = { + "mode": "number", + "value": number_value if number_value is not None else 0, + "title": {"text": title, "font": {"size": 14}}, + "number": { + "font": {"size": 32}, + "prefix": prefix, + "suffix": suffix, + "valueformat": format_spec, + }, + } + + # Add delta if provided + if delta is not None: + indicator_config["mode"] = "number+delta" + indicator_config["delta"] = { + "reference": number_value - delta if number_value else 0, + "relative": False, + "valueformat": ".1f", + "suffix": delta_suffix, + "increasing": {"color": "green" if positive_is_good else "red"}, + "decreasing": {"color": "red" if positive_is_good else "green"}, + } + + fig.add_trace(go.Indicator(**indicator_config)) + + fig.update_layout( + height=120, + margin={"l": 20, "r": 20, "t": 40, "b": 20}, + paper_bgcolor="rgba(0,0,0,0)", + font={"family": "Inter, sans-serif"}, + ) + + return fig + + +def create_summary_metrics( + metrics: dict[str, dict[str, Any]], +) -> list[go.Figure]: + """Create multiple metric card figures. + + Args: + metrics: Dictionary of metric configurations. + Key: metric name + Value: dict with 'value', 'title', 'delta' (optional), etc. + + Returns: + List of Plotly Figure objects. + """ + figures = [] + + for metric_config in metrics.values(): + fig = create_metric_card_figure( + value=metric_config.get("value", 0), + title=metric_config.get("title", ""), + delta=metric_config.get("delta"), + delta_suffix=metric_config.get("delta_suffix", "%"), + prefix=metric_config.get("prefix", ""), + suffix=metric_config.get("suffix", ""), + format_spec=metric_config.get("format_spec", ",.0f"), + positive_is_good=metric_config.get("positive_is_good", True), + ) + figures.append(fig) + + return figures diff --git a/portfolio_app/figures/time_series.py b/portfolio_app/figures/time_series.py new file mode 100644 index 0000000..fa70c6c --- /dev/null +++ b/portfolio_app/figures/time_series.py @@ -0,0 +1,233 @@ +"""Time series figure factories for Toronto housing data.""" + +from typing import Any + +import plotly.express as px +import plotly.graph_objects as go + + +def create_price_time_series( + data: list[dict[str, Any]], + date_column: str = "full_date", + price_column: str = "avg_price", + group_column: str | None = None, + title: str = "Average Price Over Time", + show_yoy: bool = True, +) -> go.Figure: + """Create a time series chart for price data. + + Args: + data: List of records with date and price columns. + date_column: Column name for dates. + price_column: Column name for price values. + group_column: Optional column for grouping (e.g., district_code). + title: Chart title. + show_yoy: Whether to show year-over-year change annotations. + + Returns: + Plotly Figure object. + """ + import pandas as pd + + if not data: + fig = go.Figure() + fig.add_annotation( + text="No data available", + xref="paper", + yref="paper", + x=0.5, + y=0.5, + showarrow=False, + ) + fig.update_layout(title=title, height=350) + return fig + + df = pd.DataFrame(data) + df[date_column] = pd.to_datetime(df[date_column]) + + if group_column and group_column in df.columns: + fig = px.line( + df, + x=date_column, + y=price_column, + color=group_column, + title=title, + ) + else: + fig = px.line( + df, + x=date_column, + y=price_column, + title=title, + ) + + fig.update_layout( + height=350, + margin={"l": 40, "r": 20, "t": 50, "b": 40}, + xaxis_title="Date", + yaxis_title=price_column.replace("_", " ").title(), + yaxis_tickprefix="$", + yaxis_tickformat=",", + hovermode="x unified", + ) + + return fig + + +def create_volume_time_series( + data: list[dict[str, Any]], + date_column: str = "full_date", + volume_column: str = "sales_count", + group_column: str | None = None, + title: str = "Sales Volume Over Time", + chart_type: str = "bar", +) -> go.Figure: + """Create a time series chart for volume/count data. + + Args: + data: List of records with date and volume columns. + date_column: Column name for dates. + volume_column: Column name for volume values. + group_column: Optional column for grouping. + title: Chart title. + chart_type: 'bar' or 'line'. + + Returns: + Plotly Figure object. + """ + import pandas as pd + + if not data: + fig = go.Figure() + fig.add_annotation( + text="No data available", + xref="paper", + yref="paper", + x=0.5, + y=0.5, + showarrow=False, + ) + fig.update_layout(title=title, height=350) + return fig + + df = pd.DataFrame(data) + df[date_column] = pd.to_datetime(df[date_column]) + + if chart_type == "bar": + if group_column and group_column in df.columns: + fig = px.bar( + df, + x=date_column, + y=volume_column, + color=group_column, + title=title, + ) + else: + fig = px.bar( + df, + x=date_column, + y=volume_column, + title=title, + ) + else: + if group_column and group_column in df.columns: + fig = px.line( + df, + x=date_column, + y=volume_column, + color=group_column, + title=title, + ) + else: + fig = px.line( + df, + x=date_column, + y=volume_column, + title=title, + ) + + fig.update_layout( + height=350, + margin={"l": 40, "r": 20, "t": 50, "b": 40}, + xaxis_title="Date", + yaxis_title=volume_column.replace("_", " ").title(), + yaxis_tickformat=",", + hovermode="x unified", + ) + + return fig + + +def create_market_comparison_chart( + data: list[dict[str, Any]], + date_column: str = "full_date", + metrics: list[str] | None = None, + title: str = "Market Indicators", +) -> go.Figure: + """Create a multi-metric comparison chart. + + Args: + data: List of records with date and metric columns. + date_column: Column name for dates. + metrics: List of metric columns to display. + title: Chart title. + + Returns: + Plotly Figure object with secondary y-axis. + """ + import pandas as pd + from plotly.subplots import make_subplots + + if not data: + fig = go.Figure() + fig.add_annotation( + text="No data available", + xref="paper", + yref="paper", + x=0.5, + y=0.5, + showarrow=False, + ) + fig.update_layout(title=title, height=400) + return fig + + if metrics is None: + metrics = ["avg_price", "sales_count"] + + df = pd.DataFrame(data) + df[date_column] = pd.to_datetime(df[date_column]) + + fig = make_subplots(specs=[[{"secondary_y": True}]]) + + colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"] + + for i, metric in enumerate(metrics[:4]): + if metric not in df.columns: + continue + + secondary = i > 0 + fig.add_trace( + go.Scatter( + x=df[date_column], + y=df[metric], + name=metric.replace("_", " ").title(), + line={"color": colors[i % len(colors)]}, + ), + secondary_y=secondary, + ) + + fig.update_layout( + title=title, + height=400, + margin={"l": 40, "r": 40, "t": 50, "b": 40}, + hovermode="x unified", + legend={ + "orientation": "h", + "yanchor": "bottom", + "y": 1.02, + "xanchor": "right", + "x": 1, + }, + ) + + return fig diff --git a/portfolio_app/pages/toronto/__init__.py b/portfolio_app/pages/toronto/__init__.py index 03a318b..2fb8f7d 100644 --- a/portfolio_app/pages/toronto/__init__.py +++ b/portfolio_app/pages/toronto/__init__.py @@ -1 +1,282 @@ """Toronto Housing Dashboard page.""" + +import dash +import dash_mantine_components as dmc +from dash import dcc, html + +from portfolio_app.components import ( + create_map_controls, + create_metric_cards_row, + create_time_slider, + create_year_selector, +) + +dash.register_page(__name__, path="/toronto", name="Toronto Housing") + +# Metric options for the purchase market +PURCHASE_METRIC_OPTIONS = [ + {"label": "Average Price", "value": "avg_price"}, + {"label": "Median Price", "value": "median_price"}, + {"label": "Sales Volume", "value": "sales_count"}, + {"label": "Days on Market", "value": "avg_dom"}, +] + +# Metric options for the rental market +RENTAL_METRIC_OPTIONS = [ + {"label": "Average Rent", "value": "avg_rent"}, + {"label": "Vacancy Rate", "value": "vacancy_rate"}, + {"label": "Rental Universe", "value": "rental_universe"}, +] + +# Sample metrics for KPI cards (will be populated by callbacks) +SAMPLE_METRICS = [ + { + "title": "Avg. Price", + "value": 1125000, + "delta": 2.3, + "prefix": "$", + "format_spec": ",.0f", + }, + { + "title": "Sales Volume", + "value": 4850, + "delta": -5.1, + "format_spec": ",", + }, + { + "title": "Avg. DOM", + "value": 18, + "delta": 3, + "suffix": " days", + "positive_is_good": False, + }, + { + "title": "Avg. Rent", + "value": 2450, + "delta": 4.2, + "prefix": "$", + "format_spec": ",.0f", + }, +] + + +def create_header() -> dmc.Group: + """Create the dashboard header with title and controls.""" + return dmc.Group( + [ + dmc.Stack( + [ + dmc.Title("Toronto Housing Dashboard", order=1), + dmc.Text( + "Real estate market analysis for the Greater Toronto Area", + c="dimmed", + ), + ], + gap="xs", + ), + dmc.Group( + [ + create_year_selector( + id_prefix="toronto", + min_year=2020, + default_year=2024, + label="Year", + ), + ], + gap="md", + ), + ], + justify="space-between", + align="flex-start", + ) + + +def create_kpi_section() -> dmc.Box: + """Create the KPI metrics row.""" + return dmc.Box( + children=[ + dmc.Title("Key Metrics", order=3, size="h4", mb="sm"), + html.Div( + id="toronto-kpi-cards", + children=[ + create_metric_cards_row(SAMPLE_METRICS, id_prefix="toronto-kpi") + ], + ), + ], + ) + + +def create_purchase_map_section() -> dmc.Grid: + """Create the purchase market choropleth section.""" + return dmc.Grid( + [ + dmc.GridCol( + create_map_controls( + id_prefix="purchase-map", + metric_options=PURCHASE_METRIC_OPTIONS, + default_metric="avg_price", + ), + span={"base": 12, "md": 3}, + ), + dmc.GridCol( + dmc.Paper( + children=[ + dcc.Graph( + id="purchase-choropleth", + config={"scrollZoom": True}, + style={"height": "500px"}, + ), + ], + p="xs", + radius="sm", + withBorder=True, + ), + span={"base": 12, "md": 9}, + ), + ], + gutter="md", + ) + + +def create_rental_map_section() -> dmc.Grid: + """Create the rental market choropleth section.""" + return dmc.Grid( + [ + dmc.GridCol( + create_map_controls( + id_prefix="rental-map", + metric_options=RENTAL_METRIC_OPTIONS, + default_metric="avg_rent", + ), + span={"base": 12, "md": 3}, + ), + dmc.GridCol( + dmc.Paper( + children=[ + dcc.Graph( + id="rental-choropleth", + config={"scrollZoom": True}, + style={"height": "500px"}, + ), + ], + p="xs", + radius="sm", + withBorder=True, + ), + span={"base": 12, "md": 9}, + ), + ], + gutter="md", + ) + + +def create_time_series_section() -> dmc.Grid: + """Create the time series charts section.""" + return dmc.Grid( + [ + dmc.GridCol( + dmc.Paper( + children=[ + dmc.Title("Price Trends", order=4, size="h5", mb="sm"), + dcc.Graph( + id="price-time-series", + config={"displayModeBar": False}, + style={"height": "350px"}, + ), + ], + p="md", + radius="sm", + withBorder=True, + ), + span={"base": 12, "md": 6}, + ), + dmc.GridCol( + dmc.Paper( + children=[ + dmc.Title("Sales Volume", order=4, size="h5", mb="sm"), + dcc.Graph( + id="volume-time-series", + config={"displayModeBar": False}, + style={"height": "350px"}, + ), + ], + p="md", + radius="sm", + withBorder=True, + ), + span={"base": 12, "md": 6}, + ), + ], + gutter="md", + ) + + +def create_market_comparison_section() -> dmc.Paper: + """Create the market comparison chart section.""" + return dmc.Paper( + children=[ + dmc.Group( + [ + dmc.Title("Market Indicators", order=4, size="h5"), + create_time_slider( + id_prefix="market-comparison", + min_year=2020, + label="", + ), + ], + justify="space-between", + align="center", + mb="md", + ), + dcc.Graph( + id="market-comparison-chart", + config={"displayModeBar": False}, + style={"height": "400px"}, + ), + ], + p="md", + radius="sm", + withBorder=True, + ) + + +def create_data_notice() -> dmc.Alert: + """Create a notice about data availability.""" + return dmc.Alert( + children=[ + dmc.Text( + "This dashboard uses TRREB and CMHC data. " + "Geographic boundaries require QGIS digitization to enable choropleth maps. " + "Sample data is shown below.", + size="sm", + ), + ], + title="Data Notice", + color="blue", + variant="light", + ) + + +# Register callbacks +from portfolio_app.pages.toronto import callbacks # noqa: E402, F401 + +layout = dmc.Container( + dmc.Stack( + [ + create_header(), + create_data_notice(), + create_kpi_section(), + dmc.Divider(my="md", label="Purchase Market", labelPosition="center"), + create_purchase_map_section(), + dmc.Divider(my="md", label="Rental Market", labelPosition="center"), + create_rental_map_section(), + dmc.Divider(my="md", label="Trends", labelPosition="center"), + create_time_series_section(), + create_market_comparison_section(), + dmc.Space(h=40), + ], + gap="lg", + ), + size="xl", + py="xl", +) diff --git a/portfolio_app/pages/toronto/callbacks/__init__.py b/portfolio_app/pages/toronto/callbacks/__init__.py index 8fdb4fe..325371a 100644 --- a/portfolio_app/pages/toronto/callbacks/__init__.py +++ b/portfolio_app/pages/toronto/callbacks/__init__.py @@ -1 +1,172 @@ """Toronto dashboard callbacks.""" + +import plotly.graph_objects as go +from dash import Input, Output, callback + +from portfolio_app.figures.choropleth import create_choropleth_figure +from portfolio_app.figures.time_series import ( + create_market_comparison_chart, + create_price_time_series, + create_volume_time_series, +) + +# Sample data for demonstration (will be replaced with database queries) +SAMPLE_PURCHASE_DATA = [ + { + "district_code": "C01", + "district_name": "Downtown Toronto", + "avg_price": 1250000, + "median_price": 1100000, + "sales_count": 450, + "avg_dom": 15, + }, + { + "district_code": "C02", + "district_name": "Annex/Yorkville", + "avg_price": 1850000, + "median_price": 1650000, + "sales_count": 280, + "avg_dom": 12, + }, + { + "district_code": "E01", + "district_name": "East York", + "avg_price": 980000, + "median_price": 920000, + "sales_count": 320, + "avg_dom": 18, + }, + { + "district_code": "W01", + "district_name": "Etobicoke South", + "avg_price": 875000, + "median_price": 825000, + "sales_count": 410, + "avg_dom": 20, + }, +] + +SAMPLE_RENTAL_DATA = [ + { + "zone_code": "Z01", + "zone_name": "Toronto Core", + "avg_rent": 2650, + "vacancy_rate": 2.1, + "rental_universe": 125000, + }, + { + "zone_code": "Z02", + "zone_name": "North York", + "avg_rent": 2200, + "vacancy_rate": 2.8, + "rental_universe": 85000, + }, + { + "zone_code": "Z03", + "zone_name": "Scarborough", + "avg_rent": 1950, + "vacancy_rate": 3.2, + "rental_universe": 72000, + }, +] + +SAMPLE_TIME_SERIES_DATA = [ + {"full_date": "2023-01-01", "avg_price": 1050000, "sales_count": 3200}, + {"full_date": "2023-02-01", "avg_price": 1080000, "sales_count": 3400}, + {"full_date": "2023-03-01", "avg_price": 1120000, "sales_count": 4100}, + {"full_date": "2023-04-01", "avg_price": 1150000, "sales_count": 4500}, + {"full_date": "2023-05-01", "avg_price": 1180000, "sales_count": 4800}, + {"full_date": "2023-06-01", "avg_price": 1160000, "sales_count": 4600}, + {"full_date": "2023-07-01", "avg_price": 1140000, "sales_count": 4200}, + {"full_date": "2023-08-01", "avg_price": 1130000, "sales_count": 4000}, + {"full_date": "2023-09-01", "avg_price": 1125000, "sales_count": 3800}, + {"full_date": "2023-10-01", "avg_price": 1110000, "sales_count": 3600}, + {"full_date": "2023-11-01", "avg_price": 1100000, "sales_count": 3400}, + {"full_date": "2023-12-01", "avg_price": 1090000, "sales_count": 3000}, + {"full_date": "2024-01-01", "avg_price": 1095000, "sales_count": 3100}, + {"full_date": "2024-02-01", "avg_price": 1105000, "sales_count": 3300}, + {"full_date": "2024-03-01", "avg_price": 1125000, "sales_count": 4000}, + {"full_date": "2024-04-01", "avg_price": 1140000, "sales_count": 4400}, + {"full_date": "2024-05-01", "avg_price": 1155000, "sales_count": 4700}, + {"full_date": "2024-06-01", "avg_price": 1145000, "sales_count": 4500}, +] + + +@callback( # type: ignore[misc] + Output("purchase-choropleth", "figure"), + Input("purchase-map-metric-selector", "value"), + Input("toronto-year-selector", "value"), +) +def update_purchase_choropleth(metric: str, year: str) -> go.Figure: + """Update the purchase market choropleth map.""" + # In production, this would query the database + # For now, return a placeholder map without geojson + return create_choropleth_figure( + geojson=None, # Will be populated when QGIS digitization is complete + data=SAMPLE_PURCHASE_DATA, + location_key="district_code", + color_column=metric or "avg_price", + hover_data=["district_name", "sales_count"], + title=f"Purchase Market - {metric.replace('_', ' ').title() if metric else 'Average Price'}", + ) + + +@callback( # type: ignore[misc] + Output("rental-choropleth", "figure"), + Input("rental-map-metric-selector", "value"), + Input("toronto-year-selector", "value"), +) +def update_rental_choropleth(metric: str, year: str) -> go.Figure: + """Update the rental market choropleth map.""" + return create_choropleth_figure( + geojson=None, # Will be populated when QGIS digitization is complete + data=SAMPLE_RENTAL_DATA, + location_key="zone_code", + color_column=metric or "avg_rent", + hover_data=["zone_name", "vacancy_rate"], + color_scale="Oranges", + title=f"Rental Market - {metric.replace('_', ' ').title() if metric else 'Average Rent'}", + ) + + +@callback( # type: ignore[misc] + Output("price-time-series", "figure"), + Input("toronto-year-selector", "value"), +) +def update_price_time_series(year: str) -> go.Figure: + """Update the price time series chart.""" + return create_price_time_series( + data=SAMPLE_TIME_SERIES_DATA, + date_column="full_date", + price_column="avg_price", + title="Average Price Trend", + ) + + +@callback( # type: ignore[misc] + Output("volume-time-series", "figure"), + Input("toronto-year-selector", "value"), +) +def update_volume_time_series(year: str) -> go.Figure: + """Update the volume time series chart.""" + return create_volume_time_series( + data=SAMPLE_TIME_SERIES_DATA, + date_column="full_date", + volume_column="sales_count", + title="Sales Volume Trend", + chart_type="bar", + ) + + +@callback( # type: ignore[misc] + Output("market-comparison-chart", "figure"), + Input("market-comparison-time-slider", "value"), +) +def update_market_comparison(time_range: list[int]) -> go.Figure: + """Update the market comparison chart.""" + return create_market_comparison_chart( + data=SAMPLE_TIME_SERIES_DATA, + date_column="full_date", + metrics=["avg_price", "sales_count"], + title="Market Indicators Comparison", + )