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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can’t perform that action at this time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can’t perform that action at this time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can’t perform that action at this time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+ {% 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 @@
+
+
+
+
+ {{ svg_content | safe }}
+
+
+ {% if session_id %}
+
+
+ {% for entry in history %}
+
+ {{ "You" if entry.role == "user" else "Chart Generator" }}
+ {{ entry.message }}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% 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/")