diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff23402 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +ANTHROPIC_API_KEY=sk-ant-xxxxx diff --git a/.gitignore b/.gitignore index b24d71e..3ddd863 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,16 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files -node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode +venv/ +__pycache__/ +*.pyc *.py[cod] - -# Log files -*.log - -# Package files -*.jar - -# Maven -target/ -dist/ - -# JetBrains IDE -.idea/ - -# Unit test reports -TEST*.xml - -# Generated by MacOS +.env +output/*.svg +output/data_* +output/tmp_* .DS_Store - -# Generated by Windows Thumbs.db - -# Applications -*.app -*.exe -*.war - -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv - +*.xlsx +*.xls +*.pdf +.idea/ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..36101d0 --- /dev/null +++ b/README.md @@ -0,0 +1,320 @@ +# PIMCO Chart Generator + +Automated publication-quality chart generation matching PIMCO's InDesign visual style. Upload data, describe what you want in plain English, and get a pixel-perfect SVG ready for any platform. Then iterate with natural language — "make lines thicker", "change the title", "remove Japan" — until it's exactly right. + +## How It Works + +``` + PIMCO CHART GENERATOR + ===================== + + ┌─────────────┐ ┌─────────────┐ + │ Excel/CSV │ │ Plain-text │ + │ Data File │ │ Brief │ + └──────┬──────┘ └──────┬──────┘ + │ │ + ▼ │ + ┌─────────────┐ │ + │ Loader │ │ + │ (pandas + │ │ + │ openpyxl) │ │ + └──────┬──────┘ │ + │ │ + ▼ │ + ┌─────────────┐ │ + │ Analyzer │ │ + │ Summarize │ │ + │ columns, │ │ + │ ranges, │ │ + │ types │ │ + └──────┬──────┘ │ + │ │ + ▼ ▼ + ┌──────────────────────────────────┐ + │ Claude Opus 4.6 │ + │ ┌────────────────────────────┐ │ + │ │ System Prompt: │ │ + │ │ - PIMCO style guide │ │ + │ │ - ChartSpec JSON schema │ │ + │ │ - Few-shot examples │ │ + │ └────────────────────────────┘ │ + │ │ + │ Input: data summary + brief │ + │ Output: structured ChartSpec │ + │ (via tool use) │ + └───────────────┬──────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ Spec Validator │ + │ - Fuzzy column name matching │ + │ - Range validation │ + └───────────────┬──────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ SVG Renderer │ + │ ┌──────────┐ ┌──────────────┐ │ + │ │ Layout │ │ Scale │ │ + │ │ (single/ │ │ (data → │ │ + │ │ dual) │ │ pixels) │ │ + │ └──────────┘ └──────────────┘ │ + │ ┌──────────┐ ┌──────────────┐ │ + │ │ Axes │ │ Series │ │ + │ │ (grid, │ │ (lines, │ │ + │ │ ticks) │ │ bars, fill) │ │ + │ └──────────┘ └──────────────┘ │ + │ ┌──────────┐ ┌──────────────┐ │ + │ │ Legend │ │ Annotations │ │ + │ │ (top- │ │ (ellipses, │ │ + │ │ right) │ │ callouts) │ │ + │ └──────────┘ └──────────────┘ │ + │ ┌─────────────────────────────┐ │ + │ │ Typography + Font Embed │ │ + │ │ (Roboto base64 in SVG) │ │ + │ └─────────────────────────────┘ │ + └───────────────┬──────────────────┘ + │ + ▼ + ┌──────────────┐ + │ SVG Output │ + │ 2560x1440 │ + └──────┬───────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ Iterative Refinement │ + │ │ + │ "Make lines thicker" │ + │ "Change title to ..." │ + │ "Remove the Japan line" │ + │ "Add a subtitle" │ + │ "Use dashed lines for trend" │ + │ │ + │ ┌────────┐ ┌──────────────┐ │ + │ │Current │───▶│ Claude edits │ │ + │ │ Spec │ │ only what │ │ + │ │ JSON │◀───│ you asked │ │ + │ └────────┘ └──────────────┘ │ + │ │ │ + │ ▼ │ + │ Re-render ──▶ Updated SVG │ + └──────────────────────────────────┘ + │ + ┌────────┼────────┐ + ▼ ▼ ▼ + InDesign Web Figma + Illustr. Pages Sketch +``` + +## Architecture + +The AI does NOT generate code or SVG markup. It outputs a **structured JSON specification** (ChartSpec) which is then rendered deterministically by the Python engine. This means: + +- **No visual hallucinations** — the renderer is pure code, not generative AI +- **Fully debuggable** — inspect the ChartSpec JSON to see exactly what the AI decided +- **Consistent style** — every chart follows the same PIMCO style rules regardless of the brief +- **Iterative** — refine with natural language, each edit builds on the last + +## Iterative Refinement + +After generating a chart, a refinement bar appears below the preview. Type natural language edits and the chart updates in place, keeping full conversation history. + +### What you can say + +| Category | Example edits | +|----------------|------------------------------------------------------------------| +| **Lines** | "Make lines thicker" / "Use dashed lines for the trend" | +| **Colors** | "Change the U.S. line to the purple color" / "Swap U.K. and Australia colors" | +| **Series** | "Remove the Japan line" / "Add the Germany data" | +| **Titles** | "Change title to 'Global Yields'" / "Add subtitle: Source: Bloomberg" | +| **Axes** | "Y-axis from 0 to 8%" / "Show years only on X-axis" | +| **Annotations**| "Add an ellipse around 2023-2024" / "Remove the annotation" | +| **Layout** | "Make it a dual-panel chart" / "Switch to single panel" | + +Each edit sends the current spec + your full conversation history to Claude, which modifies only what you asked and returns the updated chart. You can chain as many edits as you like. + +## PIMCO Visual Style + +| Element | Specification | +|------------------|--------------------------------------------------| +| Canvas | 2560 x 1440 px (configurable per chart) | +| Title font | Roboto Condensed, 32px | +| Axis font | Roboto Regular, 22px | +| Legend font | Roboto Regular, 20px | +| Line weight | 3.0px (solid), dashed 14,8, dotted 5,7 | +| Gridlines | Horizontal only, #D4D4D4, 1.0px | +| Background | White (#FFFFFF) | + +### Color Palette + +``` + ██ #003D5C Dark teal-blue (primary / U.S.) + ██ #5A6B28 Olive (secondary / Australia / trends) + ██ #891A6A Purple-magenta (tertiary / U.K.) + ██ #1FBFAA Cyan-teal (Germany) + ██ #8B7D32 Dark gold (Japan) +``` + +### Shaded Fills + +- **Blue** (#003D5C at 20% opacity) — data above reference line +- **Pink** (#891A6A at 20% opacity) — data below reference line + +## Supported Chart Types + +1. **Multi-line time series** — e.g., bond yields across countries +2. **Annotated line charts** — with grey ellipse highlights on regions of interest +3. **Dual-panel side-by-side** — with trend lines, reference levels, and shaded deviation fills +4. **Bar charts** — vertical bars for categorical or time-based data +5. **Stacked bar charts** — multiple categories stacked + +## Quick Start + +### 1. Setup + +```bash +cd PIMCO-CHARTS +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### 2. Configure API Key + +```bash +cp .env.example .env +# Edit .env and add your Anthropic API key: +# ANTHROPIC_API_KEY=sk-ant-xxxxx +``` + +### 3. Run + +```bash +source venv/bin/activate +python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload +``` + +Open **http://localhost:8080** in your browser. + +### 4. Generate a Chart + +1. Upload an Excel (.xlsx) or CSV file +2. Optionally specify a sheet name +3. Write a brief describing the chart you want +4. Set width/height (default 2560x1440) +5. Click **Generate Chart** +6. Preview the SVG inline, then **Download SVG** + +### 5. Refine + +After generating, type edits in the refinement bar below the chart: +- "Make lines thicker" +- "Change title to 'Bond Yields 2020-2025'" +- "Remove the Japan line" +- Each edit re-renders instantly + +## Example Briefs + +**Simple line chart:** +> Create a line chart showing 10-year government bond yields for the US, UK, Australia, Germany, and Japan. Y-axis from -1% to 6%. + +**Annotated chart:** +> Show quarterly tech and non-tech investment contribution to GDP growth from 1994 to 2024. Highlight the recent convergence area with an ellipse. + +**Dual panel with fills:** +> Create a dual-panel chart. Left panel: PIMCO global industrial production indexed to Oct 2024 = 100, with a dashed two-year pre-election trend and dotted election level. Shade blue above trend, pink below. Right panel: same treatment for world exports to U.S. + +## Project Structure + +``` +PIMCO-CHARTS/ +├── app/ +│ ├── main.py # FastAPI web app (upload, generate, refine, download) +│ ├── config.py # Settings, API keys, paths +│ ├── models/ +│ │ ├── chart_spec.py # ChartSpec Pydantic schema +│ │ └── style.py # PIMCO colors, fonts, layout constants +│ ├── data/ +│ │ ├── loader.py # Excel/CSV parsing +│ │ ├── analyzer.py # Data summarization for AI prompt +│ │ └── transformer.py # Date detection, column cleaning +│ ├── ai/ +│ │ ├── brief_interpreter.py # Claude Opus 4.6: brief → ChartSpec + refine +│ │ ├── prompts.py # System + refinement prompts, few-shot examples +│ │ └── spec_validator.py # Fuzzy column matching +│ ├── renderer/ +│ │ ├── engine.py # Main orchestrator: spec + data → SVG +│ │ ├── layout.py # Single/dual-panel positioning +│ │ ├── scale.py # Data domain → pixel coordinates +│ │ ├── axes.py # Gridlines, tick labels +│ │ ├── series.py # Lines, bars, shaded fills +│ │ ├── legend.py # Horizontal top-right legend +│ │ ├── typography.py # Title, subtitle text +│ │ └── annotations.py # Ellipses, callouts +│ ├── templates/ # HTMX HTML templates +│ └── static/ +│ ├── style.css +│ └── fonts/ # Roboto & Roboto Condensed TTFs +├── output/ # Generated SVGs +├── requirements.txt +└── .env # ANTHROPIC_API_KEY (not committed) +``` + +## Technical Details + +### Font Embedding + +SVGs are self-contained — Roboto and Roboto Condensed fonts are base64-encoded directly into the SVG's `") + d.append(style) + + +def _render_panel( + d: draw.Drawing, + panel: PanelSpec, + bounds: PanelBounds, + data: dict[str, pd.DataFrame], +): + """Render a single chart panel.""" + # Resolve the DataFrame to use + df = _resolve_dataframe(data) + if df is None or df.empty: + return + + # Detect date column + date_col = _find_date_column(df) + if date_col is None: + return + + dates = pd.to_datetime(df[date_col]) + + # Collect Y values for all series to compute axis range + all_y_values = [] + series_data = {} + palette = COLORS["palette"] + + for idx, s in enumerate(panel.series): + col = _find_column(df, s.data_column) + if col is None: + continue + y_vals = pd.to_numeric(df[col], errors="coerce") + series_data[s.label] = { + "x": dates.tolist(), + "y": y_vals.tolist(), + "spec": s, + "color": palette[ + (s.color_index if s.color_index is not None else idx) % len(palette) + ], + } + valid = y_vals.dropna() + if not valid.empty: + all_y_values.extend(valid.tolist()) + + if not all_y_values: + return + + # Compute axis ranges + y_min = panel.y_axis.min_val if panel.y_axis.min_val is not None else min(all_y_values) + y_max = panel.y_axis.max_val if panel.y_axis.max_val is not None else max(all_y_values) + + # Add padding + if panel.y_axis.min_val is None or panel.y_axis.max_val is None: + y_range = y_max - y_min + if panel.y_axis.min_val is None: + y_min -= y_range * 0.05 + if panel.y_axis.max_val is None: + y_max += y_range * 0.05 + + # Generate Y ticks + if panel.y_axis.tick_interval and panel.y_axis.tick_interval > 0: + import numpy as np + y_ticks = list(np.arange(y_min, y_max + panel.y_axis.tick_interval * 0.01, panel.y_axis.tick_interval)) + else: + y_ticks = nice_ticks(y_min, y_max) + + if y_ticks: + y_min = min(y_min, y_ticks[0]) + y_max = max(y_max, y_ticks[-1]) + + valid_dates = dates.dropna() + date_min = valid_dates.min().to_pydatetime() + date_max = valid_dates.max().to_pydatetime() + + # Create scales (note: Y is inverted - top of screen is smaller Y pixel value) + y_scale = LinearScale(y_min, y_max, bounds.bottom, bounds.top) + x_scale = DateScale(date_min, date_max, bounds.left, bounds.right) + + # Render axes + render_y_axis( + d, y_scale, + plot_left=bounds.left, + plot_right=bounds.right, + ticks=y_ticks, + suffix=panel.y_axis.suffix or "", + label=panel.y_axis.label, + ) + + date_ticks = nice_date_ticks(date_min, date_max) + render_x_axis( + d, x_scale, + plot_bottom=bounds.bottom, + ticks=date_ticks, + date_format=panel.x_axis.date_format, + ) + + # Render shaded fills first (behind lines) + for label, sd in series_data.items(): + s = sd["spec"] + if s.shaded_fill: + ref_label = s.shaded_fill.reference_series + if ref_label in series_data: + ref_sd = series_data[ref_label] + # Align data lengths + min_len = min(len(sd["x"]), len(sd["y"]), len(ref_sd["y"])) + render_shaded_fill( + d, + sd["x"][:min_len], + sd["y"][:min_len], + ref_sd["y"][:min_len], + x_scale, y_scale, + above_color=s.shaded_fill.above_color, + below_color=s.shaded_fill.below_color, + ) + + # Render annotations (behind lines) + for ann in panel.annotations: + if ann.type == "ellipse": + try: + x_start = pd.to_datetime(ann.x_start).to_pydatetime() if ann.x_start else date_min + x_end = pd.to_datetime(ann.x_end).to_pydatetime() if ann.x_end else date_max + y_start = ann.y_start if ann.y_start is not None else y_min + y_end = ann.y_end if ann.y_end is not None else y_max + render_ellipse(d, x_scale, y_scale, x_start, x_end, y_start, y_end) + except Exception: + pass + + # Render data series + for label, sd in series_data.items(): + s = sd["spec"] + if s.chart_type == "line": + render_line_series( + d, + sd["x"], sd["y"], + x_scale, y_scale, + color=sd["color"], + line_style=s.line_style, + line_weight=s.line_weight, + ) + elif s.chart_type == "bar": + render_bar_series( + d, + sd["x"], sd["y"], + x_scale, y_scale, + color=sd["color"], + ) + + # Title and subtitle + render_title(d, panel.title, bounds.left, bounds.top - 120) + if panel.subtitle: + render_subtitle(d, panel.subtitle, bounds.left, bounds.top - 80) + + # Legend + render_legend(d, panel.series, bounds.right, LAYOUT["legend_top_offset"] + bounds.top - 115) + + +def _resolve_dataframe(data: dict[str, pd.DataFrame]) -> pd.DataFrame | None: + """Get the primary DataFrame from the data dict.""" + if "_default" in data: + return data["_default"] + if data: + return next(iter(data.values())) + return None + + +def _find_date_column(df: pd.DataFrame) -> str | None: + """Auto-detect the date/time column in a DataFrame.""" + for col in df.columns: + if col.lower() in ("date", "dates", "time", "timestamp", "period", "month", "quarter"): + return col + # Check if column contains datetime-like values + if df[col].dtype == "datetime64[ns]": + return col + + # Try parsing first column as dates + first_col = df.columns[0] + try: + pd.to_datetime(df[first_col].head(5)) + return first_col + except Exception: + pass + + return None + + +def _find_column(df: pd.DataFrame, name: str) -> str | None: + """Find a column by exact or fuzzy match.""" + if name in df.columns: + return name + + # Case-insensitive match + lower_map = {c.lower(): c for c in df.columns} + if name.lower() in lower_map: + return lower_map[name.lower()] + + # Fuzzy match + import difflib + matches = difflib.get_close_matches(name.lower(), [c.lower() for c in df.columns], n=1, cutoff=0.6) + if matches: + return lower_map[matches[0]] + + return None diff --git a/app/renderer/layout.py b/app/renderer/layout.py new file mode 100644 index 0000000..fd3809a --- /dev/null +++ b/app/renderer/layout.py @@ -0,0 +1,63 @@ +"""Canvas sizing and panel layout computation.""" + +from __future__ import annotations +from dataclasses import dataclass +from app.models.style import LAYOUT + + +@dataclass +class PanelBounds: + """Pixel boundaries for a chart panel's plot area.""" + left: float + right: float + top: float + bottom: float + + @property + def width(self) -> float: + return self.right - self.left + + @property + def height(self) -> float: + return self.bottom - self.top + + +def compute_layout(layout_type: str) -> tuple[int, int, list[PanelBounds]]: + """Compute canvas dimensions and panel bounds. + + Returns: (canvas_width, canvas_height, list_of_panel_bounds) + """ + margins = LAYOUT["margins"] + + if layout_type == "dual_panel": + dims = LAYOUT["dual_panel"] + w, h = dims["width"], dims["height"] + gap = LAYOUT["panel_gap"] + + usable_width = w - margins["left"] - margins["right"] - gap + panel_width = usable_width / 2 + + left_panel = PanelBounds( + left=margins["left"], + right=margins["left"] + panel_width, + top=margins["top"], + bottom=h - margins["bottom"], + ) + right_panel = PanelBounds( + left=margins["left"] + panel_width + gap, + right=w - margins["right"], + top=margins["top"], + bottom=h - margins["bottom"], + ) + return w, h, [left_panel, right_panel] + + else: # single + dims = LAYOUT["single"] + w, h = dims["width"], dims["height"] + panel = PanelBounds( + left=margins["left"], + right=w - margins["right"], + top=margins["top"], + bottom=h - margins["bottom"], + ) + return w, h, [panel] diff --git a/app/renderer/legend.py b/app/renderer/legend.py new file mode 100644 index 0000000..ef47ec1 --- /dev/null +++ b/app/renderer/legend.py @@ -0,0 +1,61 @@ +"""Legend rendering - horizontal layout, top-right positioning.""" + +from __future__ import annotations +import drawsvg as draw +from app.models.style import COLORS, FONTS, LAYOUT +from app.models.chart_spec import SeriesSpec + + +def render_legend( + d: draw.Drawing, + series_list: list[SeriesSpec], + plot_right: float, + y: float, +): + """Render a horizontal legend at the top-right of the chart.""" + font = FONTS["legend"] + line_len = LAYOUT["legend_line_length"] + item_gap = LAYOUT["legend_item_gap"] + palette = COLORS["palette"] + + # Calculate total width to right-align + items = [] + for s in series_list: + if s.chart_type == "pie": + continue + color = palette[s.color_index % len(palette)] if s.color_index is not None else palette[0] + # Approximate text width: ~7px per character at 11px font + text_width = len(s.label) * 6.5 + items.append((s.label, color, s.line_style, text_width)) + + if not items: + return + + total_width = sum(line_len + 8 + tw for _, _, _, tw in items) + item_gap * (len(items) - 1) + x = plot_right - total_width + + for label, color, line_style, text_width in items: + # Draw the line segment + dash = None + if line_style == "dashed": + dash = LAYOUT["dash_pattern"] + elif line_style == "dotted": + dash = LAYOUT["dot_pattern"] + + d.append(draw.Line( + x, y, x + line_len, y, + stroke=color, + stroke_width=LAYOUT["line_weight"], + stroke_dasharray=dash if dash else "none", + )) + + # Draw the label text + d.append(draw.Text( + label, font["size"], + x + line_len + 8, y, + font_family="Roboto, sans-serif", + fill=COLORS["axis_text"], + dominant_baseline="middle", + )) + + x += line_len + 8 + text_width + item_gap diff --git a/app/renderer/scale.py b/app/renderer/scale.py new file mode 100644 index 0000000..e11a180 --- /dev/null +++ b/app/renderer/scale.py @@ -0,0 +1,122 @@ +"""Data-to-pixel coordinate mapping.""" + +from __future__ import annotations +import math +from datetime import datetime +from typing import Sequence + + +class LinearScale: + """Maps a numeric data domain to a pixel range.""" + + def __init__(self, domain_min: float, domain_max: float, range_min: float, range_max: float): + self.domain_min = domain_min + self.domain_max = domain_max + self.range_min = range_min + self.range_max = range_max + span = domain_max - domain_min + self._scale = (range_max - range_min) / span if span != 0 else 0 + + def __call__(self, value: float) -> float: + return self.range_min + (value - self.domain_min) * self._scale + + def invert(self, pixel: float) -> float: + if self._scale == 0: + return self.domain_min + return self.domain_min + (pixel - self.range_min) / self._scale + + +class DateScale: + """Maps datetime values to pixel positions.""" + + def __init__(self, date_min: datetime, date_max: datetime, range_min: float, range_max: float): + self.date_min = date_min + self.date_max = date_max + self.range_min = range_min + self.range_max = range_max + total_seconds = (date_max - date_min).total_seconds() + self._scale = (range_max - range_min) / total_seconds if total_seconds != 0 else 0 + + def __call__(self, value: datetime) -> float: + seconds = (value - self.date_min).total_seconds() + return self.range_min + seconds * self._scale + + def invert(self, pixel: float) -> datetime: + from datetime import timedelta + if self._scale == 0: + return self.date_min + seconds = (pixel - self.range_min) / self._scale + return self.date_min + timedelta(seconds=seconds) + + +def nice_ticks(data_min: float, data_max: float, target_count: int = 6) -> list[float]: + """Generate aesthetically pleasing tick values for an axis.""" + if data_min == data_max: + return [data_min] + + raw_step = (data_max - data_min) / target_count + magnitude = 10 ** math.floor(math.log10(abs(raw_step))) + residual = raw_step / magnitude + + if residual <= 1.5: + nice_step = 1 * magnitude + elif residual <= 3: + nice_step = 2 * magnitude + elif residual <= 7: + nice_step = 5 * magnitude + else: + nice_step = 10 * magnitude + + tick_min = math.floor(data_min / nice_step) * nice_step + tick_max = math.ceil(data_max / nice_step) * nice_step + + ticks = [] + val = tick_min + while val <= tick_max + nice_step * 0.01: + ticks.append(round(val, 10)) + val += nice_step + + return ticks + + +def nice_date_ticks(date_min: datetime, date_max: datetime, target_count: int = 8) -> list[datetime]: + """Generate evenly spaced date ticks.""" + total_days = (date_max - date_min).days + if total_days <= 0: + return [date_min] + + from datetime import timedelta + from dateutil.relativedelta import relativedelta + + # Choose interval based on span + if total_days <= 365: + # Monthly ticks + interval = relativedelta(months=1) + elif total_days <= 365 * 3: + # Quarterly + interval = relativedelta(months=3) + elif total_days <= 365 * 8: + # Semi-annual + interval = relativedelta(months=6) + elif total_days <= 365 * 20: + # Annual + interval = relativedelta(years=1) + else: + # Every 2 years + interval = relativedelta(years=2) + + # Start from the first day of the month at or after date_min + current = datetime(date_min.year, date_min.month, 1) + if current < date_min: + current += relativedelta(months=1) + + ticks = [] + while current <= date_max: + ticks.append(current) + current += interval + + # Thin out if too many ticks + while len(ticks) > target_count * 1.5: + ticks = ticks[::2] + + return ticks diff --git a/app/renderer/series.py b/app/renderer/series.py new file mode 100644 index 0000000..36bd6bf --- /dev/null +++ b/app/renderer/series.py @@ -0,0 +1,212 @@ +"""Data series rendering: lines, bars, areas, shaded fills.""" + +from __future__ import annotations +from datetime import datetime +import drawsvg as draw +from app.models.style import COLORS, LAYOUT +from app.renderer.scale import LinearScale, DateScale + + +def render_line_series( + d: draw.Drawing, + x_values: list[datetime], + y_values: list[float], + x_scale: DateScale, + y_scale: LinearScale, + color: str, + line_style: str = "solid", + line_weight: float | None = None, +): + """Render a line series as an SVG polyline.""" + if not x_values or not y_values: + return + + weight = line_weight or LAYOUT["line_weight"] + points = [] + for xv, yv in zip(x_values, y_values): + if yv is not None and not (isinstance(yv, float) and yv != yv): # skip NaN + px = x_scale(xv) + py = y_scale(yv) + points.append((px, py)) + + if len(points) < 2: + return + + # Build path string for smoother rendering + path_data = f"M {points[0][0]},{points[0][1]}" + for px, py in points[1:]: + path_data += f" L {px},{py}" + + dash = None + if line_style == "dashed": + dash = LAYOUT["dash_pattern"] + elif line_style == "dotted": + dash = LAYOUT["dot_pattern"] + + d.append(draw.Path( + d=path_data, + stroke=color, + stroke_width=weight, + fill="none", + stroke_dasharray=dash if dash else "none", + stroke_linejoin="round", + stroke_linecap="round", + )) + + +def render_shaded_fill( + d: draw.Drawing, + x_values: list[datetime], + y_data: list[float], + y_ref: list[float], + x_scale: DateScale, + y_scale: LinearScale, + above_color: str = "blue", + below_color: str = "pink", +): + """Render shaded regions between a data series and a reference series. + + Blue fill where data > reference, pink fill where data < reference. + """ + if not x_values or len(x_values) < 2: + return + + color_above = COLORS["shaded_above"] if above_color == "blue" else COLORS["shaded_below"] + color_below = COLORS["shaded_below"] if below_color == "pink" else COLORS["shaded_above"] + + # Build pixel-space points + px_points = [] + for i, xv in enumerate(x_values): + yd = y_data[i] if i < len(y_data) else None + yr = y_ref[i] if i < len(y_ref) else None + if yd is None or yr is None: + continue + if isinstance(yd, float) and yd != yd: + continue + if isinstance(yr, float) and yr != yr: + continue + px = x_scale(xv) + py_d = y_scale(yd) + py_r = y_scale(yr) + px_points.append((px, py_d, py_r)) + + if len(px_points) < 2: + return + + # Split into segments at crossings and fill each + segments_above = [] + segments_below = [] + current_above = [] + current_below = [] + + for i in range(len(px_points)): + px, py_d, py_r = px_points[i] + + if i > 0: + prev_px, prev_py_d, prev_py_r = px_points[i - 1] + prev_diff = prev_py_d - prev_py_r + curr_diff = py_d - py_r + + # Check for crossing (sign change) - note: y-axis is inverted in SVG + if prev_diff * curr_diff < 0: + # Linear interpolation to find crossing point + t = prev_diff / (prev_diff - curr_diff) + cross_px = prev_px + t * (px - prev_px) + cross_py = prev_py_d + t * (py_d - prev_py_d) + + # Close current segment at crossing + if current_above: + current_above.append((cross_px, cross_py, cross_py)) + segments_above.append(current_above) + current_above = [(cross_px, cross_py, cross_py)] + if current_below: + current_below.append((cross_px, cross_py, cross_py)) + segments_below.append(current_below) + current_below = [(cross_px, cross_py, cross_py)] + + # In SVG, smaller y = higher on screen + # py_d < py_r means data is ABOVE reference visually + if py_d <= py_r: # data above or equal + current_above.append((px, py_d, py_r)) + if not current_below or current_below[-1] != (px, py_d, py_r): + pass # only add to above + else: # data below + current_below.append((px, py_d, py_r)) + + if current_above: + segments_above.append(current_above) + if current_below: + segments_below.append(current_below) + + # Render fill paths + for seg in segments_above: + _render_fill_segment(d, seg, color_above) + for seg in segments_below: + _render_fill_segment(d, seg, color_below) + + +def _render_fill_segment(d: draw.Drawing, points: list[tuple], fill_color: str): + """Render a single filled region between data and reference lines.""" + if len(points) < 2: + return + + # Forward path along data line + path_data = f"M {points[0][0]},{points[0][1]}" + for px, py_d, py_r in points[1:]: + path_data += f" L {px},{py_d}" + + # Backward path along reference line + for px, py_d, py_r in reversed(points): + path_data += f" L {px},{py_r}" + + path_data += " Z" + + d.append(draw.Path( + d=path_data, + fill=fill_color, + stroke="none", + )) + + +def render_bar_series( + d: draw.Drawing, + x_values: list[datetime], + y_values: list[float], + x_scale: DateScale, + y_scale: LinearScale, + color: str, + bar_width: float | None = None, + baseline: float = 0, +): + """Render a bar chart series.""" + if not x_values or not y_values: + return + + n = len(x_values) + if n < 2: + total_width = 40 + else: + total_width = abs(x_scale(x_values[1]) - x_scale(x_values[0])) + + bw = bar_width or total_width * 0.7 + baseline_y = y_scale(baseline) + + for xv, yv in zip(x_values, y_values): + if yv is None or (isinstance(yv, float) and yv != yv): + continue + px = x_scale(xv) + py = y_scale(yv) + + x_left = px - bw / 2 + if py < baseline_y: + # Bar goes up + d.append(draw.Rectangle( + x_left, py, bw, baseline_y - py, + fill=color, stroke="none", + )) + else: + # Bar goes down + d.append(draw.Rectangle( + x_left, baseline_y, bw, py - baseline_y, + fill=color, stroke="none", + )) diff --git a/app/renderer/typography.py b/app/renderer/typography.py new file mode 100644 index 0000000..33cf7e4 --- /dev/null +++ b/app/renderer/typography.py @@ -0,0 +1,47 @@ +"""Title, subtitle, and text element rendering.""" + +from __future__ import annotations +import drawsvg as draw +from app.models.style import FONTS, COLORS + + +def render_title(d: draw.Drawing, text: str, x: float, y: float): + """Render chart title (top-left, Roboto Condensed).""" + font = FONTS["title"] + d.append(draw.Text( + text, font["size"], + x, y, + font_family="Roboto Condensed, Roboto, sans-serif", + font_weight=font["weight"], + fill=COLORS["axis_text"], + dominant_baseline="hanging", + )) + + +def render_subtitle(d: draw.Drawing, text: str, x: float, y: float): + """Render chart subtitle below title.""" + font = FONTS["subtitle"] + d.append(draw.Text( + text, font["size"], + x, y, + font_family="Roboto Condensed, Roboto, sans-serif", + font_weight=font["weight"], + fill=COLORS["subtitle_text"], + dominant_baseline="hanging", + )) + + +def render_axis_label(d: draw.Drawing, text: str, x: float, y: float, rotation: float = 0): + """Render a rotated axis label (e.g., Y-axis 'Percentage points').""" + font = FONTS["axis_label"] + t = draw.Text( + text, font["size"], + x, y, + font_family="Roboto, sans-serif", + font_weight=font["weight"], + fill=COLORS["subtitle_text"], + text_anchor="middle", + dominant_baseline="middle", + transform=f"rotate({rotation},{x},{y})" if rotation else "", + ) + d.append(t) diff --git a/app/static/fonts/Roboto-Bold.ttf b/app/static/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..987f2ff --- /dev/null +++ b/app/static/fonts/Roboto-Bold.ttf @@ -0,0 +1,1469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/app/static/fonts/Roboto-Regular.ttf b/app/static/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..f4b6166 --- /dev/null +++ b/app/static/fonts/Roboto-Regular.ttf @@ -0,0 +1,1469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/app/static/fonts/Roboto.ttf b/app/static/fonts/Roboto.ttf new file mode 100644 index 0000000..5522a36 Binary files /dev/null and b/app/static/fonts/Roboto.ttf differ diff --git a/app/static/fonts/RobotoCondensed-Regular.ttf b/app/static/fonts/RobotoCondensed-Regular.ttf new file mode 100644 index 0000000..f1e9729 --- /dev/null +++ b/app/static/fonts/RobotoCondensed-Regular.ttf @@ -0,0 +1,1469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/app/static/fonts/RobotoCondensed.ttf b/app/static/fonts/RobotoCondensed.ttf new file mode 100644 index 0000000..2210555 Binary files /dev/null and b/app/static/fonts/RobotoCondensed.ttf differ diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..cadfdc5 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,306 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Roboto', sans-serif; + background: #f5f5f5; + color: #333; + line-height: 1.6; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 40px 20px; +} + +header { + margin-bottom: 40px; +} + +header h1 { + font-family: 'Roboto Condensed', sans-serif; + font-size: 2rem; + color: #003D5C; + margin-bottom: 4px; +} + +header .subtitle { + color: #666; + font-size: 1rem; +} + +.upload-form { + background: #fff; + border-radius: 8px; + padding: 32px; + box-shadow: 0 1px 4px rgba(0,0,0,0.08); + margin-bottom: 32px; +} + +.form-group { + margin-bottom: 24px; +} + +.form-group.inline { + display: flex; + gap: 24px; +} + +.form-group.inline > div { + flex: 1; +} + +label { + display: block; + font-weight: 500; + margin-bottom: 6px; + font-size: 0.9rem; +} + +.optional { + font-weight: 300; + color: #999; +} + +input[type="file"], +input[type="text"], +input[type="number"], +textarea { + width: 100%; + padding: 10px 14px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: 'Roboto', sans-serif; + font-size: 0.95rem; + transition: border-color 0.2s; +} + +input:focus, +textarea:focus { + outline: none; + border-color: #003D5C; +} + +textarea { + resize: vertical; +} + +.btn { + display: inline-block; + padding: 12px 32px; + background: #003D5C; + color: #fff; + border: none; + border-radius: 4px; + font-family: 'Roboto Condensed', sans-serif; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s; + text-decoration: none; +} + +.btn:hover { + background: #005580; +} + +.btn-download { + background: #5A6B28; +} + +.btn-download:hover { + background: #6d8030; +} + +/* Loading indicator */ +.htmx-indicator { + display: none; + text-align: center; + padding: 40px; + color: #666; +} + +.htmx-indicator.htmx-request { + display: block; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #ddd; + border-top-color: #003D5C; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Preview */ +.preview-container { + background: #fff; + border-radius: 8px; + padding: 24px; + box-shadow: 0 1px 4px rgba(0,0,0,0.08); +} + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.preview-header h2 { + font-family: 'Roboto Condensed', sans-serif; + color: #003D5C; +} + +.svg-preview { + border: 1px solid #eee; + border-radius: 4px; + overflow: auto; + background: #fff; +} + +.svg-preview svg { + width: 100%; + height: auto; +} + +.spec-details { + margin-top: 8px; +} + +.spec-details summary { + cursor: pointer; + color: #003D5C; + font-size: 0.85rem; +} + +.spec-details pre { + margin-top: 8px; + padding: 12px; + background: #f8f8f8; + border-radius: 4px; + font-size: 0.8rem; + max-height: 400px; + overflow: auto; + white-space: pre-wrap; +} + +.error { + background: #fff5f5; + border: 1px solid #ffcccc; + border-radius: 8px; + padding: 20px; + color: #cc0000; +} + +/* Refinement section */ +.refine-section { + margin-top: 24px; + border-top: 1px solid #eee; + padding-top: 20px; +} + +.history { + max-height: 200px; + overflow-y: auto; + margin-bottom: 16px; + padding: 12px; + background: #fafafa; + border-radius: 6px; +} + +.history-entry { + margin-bottom: 8px; + font-size: 0.88rem; + line-height: 1.5; +} + +.history-entry:last-child { + margin-bottom: 0; +} + +.history-role { + font-weight: 500; + margin-right: 6px; +} + +.history-entry.user .history-role { + color: #003D5C; +} + +.history-entry.user .history-role::after { + content: ":"; +} + +.history-entry.assistant .history-role { + color: #5A6B28; +} + +.history-entry.assistant .history-role::after { + content: ":"; +} + +.history-entry.assistant .history-message { + color: #666; + font-style: italic; +} + +.refine-form { + display: flex; + gap: 12px; + align-items: stretch; +} + +.refine-input { + flex: 1; + padding: 12px 16px !important; + border: 2px solid #ddd !important; + border-radius: 6px !important; + font-size: 0.95rem !important; + transition: border-color 0.2s; +} + +.refine-input:focus { + border-color: #003D5C !important; +} + +.refine-input::placeholder { + color: #aaa; +} + +.btn-refine { + padding: 12px 28px; + white-space: nowrap; + background: #003D5C; + border-radius: 6px; +} + +.refine-loading { + padding: 12px !important; + display: none; + align-items: center; + justify-content: center; + gap: 10px; +} + +.refine-loading.htmx-request { + display: flex !important; +} + +.spinner-small { + width: 20px; + height: 20px; + border: 2px solid #ddd; + border-top-color: #003D5C; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d6ea21a --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,21 @@ + + + + + + PIMCO Chart Generator + + + + + + +
+
+

PIMCO Chart Generator

+

Upload data and describe your chart to generate publication-quality SVGs

+
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/preview.html b/app/templates/preview.html new file mode 100644 index 0000000..c726ba4 --- /dev/null +++ b/app/templates/preview.html @@ -0,0 +1,51 @@ +
+
+

Generated Chart

+
+ Download SVG + {% if spec_json %} +
+ View Chart Spec +
{{ spec_json }}
+
+ {% endif %} +
+
+ +
+ {{ svg_content | safe }} +
+ + {% if session_id %} +
+
+ {% for entry in history %} +
+ {{ "You" if entry.role == "user" else "Chart Generator" }} + {{ entry.message }} +
+ {% endfor %} +
+ +
+ + + +
+ +
+
+ Updating chart... +
+
+ {% endif %} +
diff --git a/app/templates/upload.html b/app/templates/upload.html new file mode 100644 index 0000000..3b03269 --- /dev/null +++ b/app/templates/upload.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+

Interpreting brief and rendering chart...

+
+ +
+{% endblock %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9ddce6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,32 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anthropic==0.84.0 +anyio==4.12.1 +certifi==2026.2.25 +click==8.3.1 +distro==1.9.0 +docstring_parser==0.17.0 +drawsvg==2.4.1 +et_xmlfile==2.0.0 +fastapi==0.135.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +Jinja2==3.1.6 +jiter==0.13.0 +MarkupSafe==3.0.3 +numpy==2.4.2 +openpyxl==3.1.5 +pandas==3.0.1 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +python-multipart==0.0.22 +six==1.17.0 +sniffio==1.3.1 +starlette==0.52.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.41.0 diff --git a/test_render.py b/test_render.py new file mode 100644 index 0000000..9e1a5f7 --- /dev/null +++ b/test_render.py @@ -0,0 +1,87 @@ +"""Test script: render a multi-line chart matching PIMCO Chart 1 (bond yields) using synthetic data.""" + +import sys +sys.path.insert(0, ".") + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from app.models.chart_spec import ChartSpec, PanelSpec, AxisSpec, SeriesSpec +from app.renderer.engine import render_chart + + +def generate_bond_yield_data() -> pd.DataFrame: + """Generate synthetic 10-year government bond yield data resembling the PDF chart.""" + np.random.seed(42) + dates = pd.date_range("2020-08-01", "2025-08-31", freq="B") # Business days + + def random_walk(start, drift, vol, n): + returns = np.random.normal(drift, vol, n) + values = start + np.cumsum(returns) + # Smooth with rolling average + series = pd.Series(values) + return series.rolling(window=10, min_periods=1).mean().values + + n = len(dates) + + data = pd.DataFrame({ + "Date": dates, + "U.S.": random_walk(0.7, 0.0025, 0.03, n), + "Australia": random_walk(0.9, 0.0025, 0.03, n), + "U.K.": random_walk(0.3, 0.0028, 0.035, n), + "Germany": random_walk(-0.4, 0.0020, 0.03, n), + "Japan": random_walk(0.0, 0.0008, 0.015, n), + }) + + # Clamp values to reasonable ranges + for col in ["U.S.", "Australia", "U.K.", "Germany", "Japan"]: + data[col] = data[col].clip(-1, 6) + + return data + + +def main(): + # Generate data + df = generate_bond_yield_data() + + # Create chart spec matching the PDF Chart 1 + spec = ChartSpec( + layout="single", + panels=[ + PanelSpec( + title="10-year government bond yields", + subtitle=None, + x_axis=AxisSpec( + date_format="%b %Y", + ), + y_axis=AxisSpec( + suffix="%", + min_val=-1, + max_val=6, + tick_interval=1, + ), + series=[ + SeriesSpec(label="U.S.", data_column="U.S.", color_index=0), + SeriesSpec(label="Australia", data_column="Australia", color_index=1), + SeriesSpec(label="U.K.", data_column="U.K.", color_index=2), + SeriesSpec(label="Germany", data_column="Germany", color_index=3), + SeriesSpec(label="Japan", data_column="Japan", color_index=4), + ], + ) + ], + ) + + # Render + svg = render_chart(spec, {"_default": df}) + + # Save + output_path = "output/test_bond_yields.svg" + with open(output_path, "w") as f: + f.write(svg) + + print(f"SVG saved to {output_path}") + print(f"SVG size: {len(svg):,} bytes") + + +if __name__ == "__main__": + main() diff --git a/test_render_all.py b/test_render_all.py new file mode 100644 index 0000000..644da98 --- /dev/null +++ b/test_render_all.py @@ -0,0 +1,214 @@ +"""Test all three chart types from the PIMCO reference PDF.""" + +import sys +sys.path.insert(0, ".") + +import pandas as pd +import numpy as np +from app.models.chart_spec import ( + ChartSpec, PanelSpec, AxisSpec, SeriesSpec, ShadedFillSpec, AnnotationSpec, +) +from app.renderer.engine import render_chart + + +def test_chart1_bond_yields(): + """Chart 1: Multi-line time series (bond yields across 5 countries).""" + np.random.seed(42) + dates = pd.date_range("2020-08-01", "2025-08-31", freq="B") + n = len(dates) + + def walk(start, drift, vol): + r = np.random.normal(drift, vol, n) + v = start + np.cumsum(r) + return pd.Series(v).rolling(10, min_periods=1).mean().values + + df = pd.DataFrame({ + "Date": dates, + "U.S.": walk(0.7, 0.0025, 0.03), + "Australia": walk(0.9, 0.0025, 0.03), + "U.K.": walk(0.3, 0.0028, 0.035), + "Germany": walk(-0.4, 0.0020, 0.03), + "Japan": walk(0.0, 0.0008, 0.015), + }) + + spec = ChartSpec( + layout="single", + panels=[PanelSpec( + title="10-year government bond yields", + x_axis=AxisSpec(date_format="%b %Y"), + y_axis=AxisSpec(suffix="%", min_val=-1, max_val=6, tick_interval=1), + series=[ + SeriesSpec(label="U.S.", data_column="U.S.", color_index=0), + SeriesSpec(label="Australia", data_column="Australia", color_index=1), + SeriesSpec(label="U.K.", data_column="U.K.", color_index=2), + SeriesSpec(label="Germany", data_column="Germany", color_index=3), + SeriesSpec(label="Japan", data_column="Japan", color_index=4), + ], + )], + ) + + svg = render_chart(spec, {"_default": df}) + with open("output/chart1_bond_yields.svg", "w") as f: + f.write(svg) + print("Chart 1 (Bond Yields) saved.") + + +def test_chart2_gdp_investment(): + """Chart 2: Two-line chart with grey ellipse annotation.""" + np.random.seed(99) + dates = pd.date_range("1994-01-01", "2024-12-31", freq="QS") + n = len(dates) + + tech = np.random.normal(0.5, 0.35, n) + tech = pd.Series(tech).rolling(2, min_periods=1).mean().values.copy() + nontech = np.random.normal(0.4, 0.4, n) + nontech = pd.Series(nontech).rolling(2, min_periods=1).mean().values.copy() + + # Add recession dips + for start_idx in [24, 32, 56]: # ~2000, 2002, 2008 + tech[start_idx:start_idx+4] -= 0.7 + nontech[start_idx:start_idx+4] -= 0.8 + + df = pd.DataFrame({"Date": dates, "Tech investment": tech, "Non-tech investment": nontech}) + + spec = ChartSpec( + layout="single", + panels=[PanelSpec( + title="Quarterly contribution to real GDP growth", + x_axis=AxisSpec(date_format="%Y"), + y_axis=AxisSpec(label="Percentage points", min_val=-1.0, max_val=1.5, tick_interval=0.5), + series=[ + SeriesSpec(label="Tech investment", data_column="Tech investment", color_index=0), + SeriesSpec(label="Non-tech investment", data_column="Non-tech investment", color_index=2), + ], + annotations=[ + AnnotationSpec( + type="ellipse", + x_start="2023-06-01", + x_end="2024-12-31", + y_start=-0.3, + y_end=1.2, + ), + ], + )], + ) + + svg = render_chart(spec, {"_default": df}) + with open("output/chart2_gdp_investment.svg", "w") as f: + f.write(svg) + print("Chart 2 (GDP Investment) saved.") + + +def test_chart3_dual_panel(): + """Chart 3: Dual-panel with trend lines and shaded fills.""" + np.random.seed(77) + dates = pd.date_range("2022-01-01", "2026-01-31", freq="MS") + n = len(dates) + + # Left panel: PIMCO Global IP + trend_left = np.linspace(93, 101, n) + ip_data = trend_left + np.random.normal(0, 1.0, n) + ip_data = pd.Series(ip_data).rolling(3, min_periods=1).mean().values.copy() + # Post-election dip + election_idx = 34 # ~Oct 2024 + ip_data[election_idx+2:election_idx+5] -= 2.5 + election_level_left = [100.0] * n + + # Right panel: World Exports + trend_right = np.linspace(93, 106, n) + exports = trend_right + np.random.normal(0, 2.0, n) + exports = pd.Series(exports).rolling(3, min_periods=1).mean().values.copy() + # Tariff spike + exports[election_idx+3:election_idx+6] += 15 + exports[election_idx+6:election_idx+8] -= 8 + election_level_right = [100.0] * n + + df_left = pd.DataFrame({ + "Date": dates, + "PIMCO Global IP": ip_data, + "Trend": trend_left, + "Election Level": election_level_left, + }) + + df_right = pd.DataFrame({ + "Date": dates, + "World Exports to U.S.": exports, + "Trend": trend_right, + "Election Level": election_level_right, + }) + + spec = ChartSpec( + layout="dual_panel", + panels=[ + PanelSpec( + title="PIMCO global industrial production", + x_axis=AxisSpec(date_format="%b '%y"), + y_axis=AxisSpec(min_val=90, max_val=106, tick_interval=2), + series=[ + SeriesSpec( + label="PIMCO global IP (Oct. 2024 = 100)", + data_column="PIMCO Global IP", + color_index=0, + shaded_fill=ShadedFillSpec( + reference_series="Two-year pre-U.S. election trend", + above_color="blue", + below_color="pink", + ), + ), + SeriesSpec( + label="Two-year pre-U.S. election trend", + data_column="Trend", + color_index=1, + line_style="dashed", + ), + SeriesSpec( + label="Pre-U.S. election level", + data_column="Election Level", + color_index=2, + line_style="dotted", + ), + ], + ), + PanelSpec( + title="World exports to U.S.", + x_axis=AxisSpec(date_format="%b '%y"), + y_axis=AxisSpec(min_val=90, max_val=130, tick_interval=5), + series=[ + SeriesSpec( + label="World exports to U.S. (Oct. 2024 = 100)", + data_column="World Exports to U.S.", + color_index=0, + shaded_fill=ShadedFillSpec( + reference_series="Two-year pre-U.S. election trend", + above_color="blue", + below_color="pink", + ), + ), + SeriesSpec( + label="Two-year pre-U.S. election trend", + data_column="Trend", + color_index=1, + line_style="dashed", + ), + SeriesSpec( + label="Pre-U.S. election level", + data_column="Election Level", + color_index=2, + line_style="dotted", + ), + ], + ), + ], + ) + + svg = render_chart(spec, {"_panel_0": df_left, "_panel_1": df_right}) + with open("output/chart3_dual_panel.svg", "w") as f: + f.write(svg) + print("Chart 3 (Dual Panel) saved.") + + +if __name__ == "__main__": + test_chart1_bond_yields() + test_chart2_gdp_investment() + test_chart3_dual_panel() + print("\nAll test charts saved to output/")