Initial commit: PIMCO chart generator with iterative refinement

AI-powered tool that generates publication-quality SVG charts matching
PIMCO's InDesign style. Upload Excel/CSV data, write a plain-English
brief, then iterate with natural language edits until the chart is
exactly right.

- Claude Opus 4.6 interprets briefs into structured ChartSpec JSON
- Deterministic SVG renderer via drawsvg (no visual hallucinations)
- Roboto/Roboto Condensed fonts base64-embedded in SVG
- FastAPI + HTMX web frontend with live preview
- Conversational refinement: "make lines thicker", "change title", etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-03-05 16:29:21 -05:00
parent f57c844216
commit a3a38e85d2
38 changed files with 7220 additions and 47 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
ANTHROPIC_API_KEY=sk-ant-xxxxx

60
.gitignore vendored
View file

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

320
README.md Normal file
View file

@ -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 `<defs><style>` block as `@font-face` declarations. This means the SVG renders correctly in any viewer without requiring the fonts to be installed.
### ChartSpec Schema
The ChartSpec is the contract between the AI interpreter and the deterministic renderer:
```
ChartSpec
layout: "single" | "dual_panel"
panels: [PanelSpec]
title, subtitle
x_axis: AxisSpec (label, suffix, date_format, min/max, tick_interval)
y_axis: AxisSpec
series: [SeriesSpec]
label, data_column, chart_type, color_index, line_style, line_weight
shaded_fill: {reference_series, above_color, below_color}
annotations: [AnnotationSpec]
type: "ellipse" | "callout" | "label", x_range, y_range, text
```
### Shaded Fill Algorithm
For charts that show deviation from a trend line (blue above, pink below), the renderer:
1. Walks both polylines point-by-point
2. Detects sign changes (crossings) via linear interpolation
3. Splits fill paths at intersection points
4. Renders separate SVG `<path>` elements for above/below regions
### Iterative Refinement Architecture
The refinement system works by:
1. Keeping an in-memory session with the current ChartSpec, loaded data, and conversation history
2. When you type an edit, sending Claude the current spec JSON + full history + your edit
3. Claude returns a complete updated ChartSpec (modifying only what was asked)
4. The renderer re-renders from the new spec against the same data
5. The cycle repeats — chain as many edits as needed
## Dependencies
| Package | Purpose |
|------------------|--------------------------------------|
| anthropic | Claude Opus 4.6 API (brief → spec) |
| drawsvg | Direct SVG element construction |
| pandas | Data manipulation and date parsing |
| openpyxl | Excel file reading |
| fastapi | Web framework |
| uvicorn | ASGI server |
| jinja2 | HTML templating |
| pydantic | Schema validation |
| python-dotenv | Environment variable loading |
| python-multipart | File upload handling |

0
app/__init__.py Normal file
View file

0
app/ai/__init__.py Normal file
View file

129
app/ai/brief_interpreter.py Normal file
View file

@ -0,0 +1,129 @@
"""Claude API integration: interprets a brief + data summary into a ChartSpec."""
from __future__ import annotations
import json
import anthropic
from app.config import ANTHROPIC_API_KEY, CLAUDE_MODEL
from app.models.chart_spec import ChartSpec
from app.ai.prompts import SYSTEM_PROMPT, REFINE_SYSTEM_PROMPT, FEW_SHOT_EXAMPLES
def interpret_brief(brief: str, data_summary: str) -> ChartSpec:
"""Send brief + data summary to Claude and get back a ChartSpec.
Uses Claude's tool use feature to force structured JSON output.
"""
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
# Build few-shot messages
messages = []
for ex in FEW_SHOT_EXAMPLES:
messages.append({
"role": "user",
"content": f"DATA AVAILABLE:\n{ex['data_summary']}\n\nBRIEF:\n{ex['brief']}",
})
messages.append({
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": f"example_{FEW_SHOT_EXAMPLES.index(ex)}",
"name": "create_chart_spec",
"input": ex["spec"],
}
],
})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": f"example_{FEW_SHOT_EXAMPLES.index(ex)}",
"content": "Chart specification accepted.",
}
],
})
# Add the actual request
messages.append({
"role": "user",
"content": f"DATA AVAILABLE:\n{data_summary}\n\nBRIEF:\n{brief}",
})
tool_schema = ChartSpec.model_json_schema()
response = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=4096,
system=SYSTEM_PROMPT,
messages=messages,
tools=[{
"name": "create_chart_spec",
"description": "Create a PIMCO chart specification from the brief and data",
"input_schema": tool_schema,
}],
tool_choice={"type": "tool", "name": "create_chart_spec"},
)
for block in response.content:
if block.type == "tool_use":
return ChartSpec.model_validate(block.input)
raise ValueError("Claude did not return a tool_use block with chart specification")
def refine_spec(
current_spec: ChartSpec,
edit_instruction: str,
data_summary: str,
history: list[dict],
) -> ChartSpec:
"""Refine an existing ChartSpec based on a natural language edit instruction.
Sends the current spec + conversation history + edit to Claude,
which returns an updated ChartSpec.
"""
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
# Build conversation context from history
history_text = "\n".join(
f"{'User' if h['role'] == 'user' else 'System'}: {h['message']}"
for h in history[:-1] # exclude the latest edit (we send it separately)
)
current_spec_json = json.dumps(current_spec.model_dump(), indent=2, default=str)
messages = [
{
"role": "user",
"content": (
f"DATA AVAILABLE:\n{data_summary}\n\n"
f"CONVERSATION SO FAR:\n{history_text}\n\n"
f"CURRENT CHART SPEC:\n```json\n{current_spec_json}\n```\n\n"
f"EDIT REQUEST:\n{edit_instruction}\n\n"
"Apply the requested changes to the current chart spec. "
"Keep everything else the same — only modify what was asked."
),
},
]
tool_schema = ChartSpec.model_json_schema()
response = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=4096,
system=REFINE_SYSTEM_PROMPT,
messages=messages,
tools=[{
"name": "create_chart_spec",
"description": "Create an updated PIMCO chart specification with the requested changes",
"input_schema": tool_schema,
}],
tool_choice={"type": "tool", "name": "create_chart_spec"},
)
for block in response.content:
if block.type == "tool_use":
return ChartSpec.model_validate(block.input)
raise ValueError("Claude did not return an updated chart specification")

118
app/ai/prompts.py Normal file
View file

@ -0,0 +1,118 @@
"""System prompt and few-shot examples for Claude chart specification generation."""
SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a user's brief (describing a desired chart) along with a summary of available data, and output a structured ChartSpec JSON that will be rendered into a publication-quality SVG.
## PIMCO Chart Style
- Clean, minimal financial charts with horizontal gridlines only (no vertical gridlines, no box border)
- Color palette (use color_index to reference):
0: Dark teal-blue (primary data, U.S.)
1: Olive/dark green (secondary, Australia, trend lines)
2: Purple/magenta (tertiary, U.K.)
3: Cyan/teal (Germany)
4: Dark gold/ochre (Japan)
- Line styles: "solid" for data, "dashed" for trend/reference lines, "dotted" for level markers
- Fonts: Roboto Condensed for titles, Roboto for axis labels
## Chart Types Available
1. **Multi-line time series** (layout: "single"): Multiple data columns as separate line series
2. **Line chart with annotations**: Single or dual series with ellipse highlights on interesting regions
3. **Dual-panel side-by-side** (layout: "dual_panel"): Two related charts shown together, often with trend lines and shaded fill areas
4. **Bar charts**: Vertical bars for categorical or time-based comparisons
5. **Stacked bar charts**: Multiple categories stacked
## Rules for ChartSpec Output
1. Always use the exact column names from the data summary
2. Assign color_index values to differentiate series (0-4)
3. For Y-axis: set suffix="%" if data is percentages, set label for units like "Percentage points"
4. For X-axis: use date_format like "%b %Y" (Aug 2020), "%Y" (2020), or "%b '%y" (Jan '22)
5. Choose tick_interval to produce clean, readable tick marks (e.g., 1 for 0-6 range, 0.5 for -1.0 to 1.5)
6. For dual panels, ensure each panel's series reference columns that exist in the same data sheet
7. When a brief mentions trend lines or reference levels, create separate series with dashed/dotted line_style
8. Use shaded_fill when the brief asks for deviation/gap highlighting between two series
## Important
- Only reference column names that appear in the DATA SUMMARY
- The date column is auto-detected - you don't need to include it as a series
- Keep titles concise and professional
- Choose appropriate axis ranges: set min_val/max_val if the brief suggests specific ranges, otherwise leave as null for auto-scaling
"""
REFINE_SYSTEM_PROMPT = """You are a PIMCO chart specification editor. You receive:
1. The current ChartSpec JSON (the chart as it exists now)
2. A conversation history showing how the chart was built
3. A new edit request from the user
Your job is to apply ONLY the requested changes to the existing spec and return the full updated ChartSpec. Preserve everything that wasn't explicitly asked to change.
## Common edit requests and how to handle them:
**Visual changes:**
- "Make lines thicker" increase line_weight on all series (e.g., 4.0, 5.0)
- "Make the U.S. line thicker" increase line_weight on just that series
- "Change X to red" set a custom color by changing color_index. Available: 0=teal-blue, 1=olive, 2=purple, 3=cyan, 4=gold
- "Use dashed lines for X" change line_style to "dashed"
**Content changes:**
- "Remove the Japan line" remove that series from the panel
- "Add a subtitle" set subtitle field
- "Change title to X" update title
- "Add an ellipse around 2023-2024" add an annotation
**Axis changes:**
- "Y-axis from 0 to 10" set min_val=0, max_val=10
- "Show years only on X-axis" set date_format="%Y"
- "Add % to Y-axis" set suffix="%"
**Layout changes:**
- "Make it dual panel" change layout to "dual_panel" and split series
## Rules
- Return the COMPLETE updated ChartSpec (not just the diff)
- Only modify what was asked keep all other fields identical
- If the edit is ambiguous, make the most reasonable interpretation
- Maintain valid column references from the data summary
"""
FEW_SHOT_EXAMPLES = [
{
"brief": "Create a line chart showing 10-year government bond yields for the US, UK, Australia, Germany, and Japan from August 2020 to present.",
"data_summary": "Sheet 'Yields': 1300 rows x 6 columns\n Columns: Date, US_10Y, UK_10Y, AU_10Y, DE_10Y, JP_10Y\n Date range: 2020-08-01 to 2025-08-31\n US_10Y: min=0.51, max=5.02",
"spec": {
"layout": "single",
"panels": [{
"title": "10-year government bond yields",
"subtitle": None,
"x_axis": {"date_format": "%b %Y"},
"y_axis": {"suffix": "%", "min_val": -1, "max_val": 6, "tick_interval": 1},
"series": [
{"label": "U.S.", "data_column": "US_10Y", "chart_type": "line", "color_index": 0, "line_style": "solid"},
{"label": "Australia", "data_column": "AU_10Y", "chart_type": "line", "color_index": 1, "line_style": "solid"},
{"label": "U.K.", "data_column": "UK_10Y", "chart_type": "line", "color_index": 2, "line_style": "solid"},
{"label": "Germany", "data_column": "DE_10Y", "chart_type": "line", "color_index": 3, "line_style": "solid"},
{"label": "Japan", "data_column": "JP_10Y", "chart_type": "line", "color_index": 4, "line_style": "solid"},
],
"annotations": [],
}],
},
},
{
"brief": "Show quarterly tech and non-tech investment contribution to GDP growth from 1994 to 2024. Highlight the recent convergence area with an ellipse.",
"data_summary": "Sheet 'GDP': 124 rows x 3 columns\n Columns: Date, Tech_Investment, Nontech_Investment\n Date range: 1994-01-01 to 2024-12-31\n Tech_Investment: min=-0.65, max=1.21",
"spec": {
"layout": "single",
"panels": [{
"title": "Quarterly contribution to real GDP growth",
"subtitle": None,
"x_axis": {"date_format": "%Y"},
"y_axis": {"label": "Percentage points", "min_val": -1.0, "max_val": 1.5, "tick_interval": 0.5},
"series": [
{"label": "Tech investment", "data_column": "Tech_Investment", "chart_type": "line", "color_index": 0, "line_style": "solid"},
{"label": "Non-tech investment", "data_column": "Nontech_Investment", "chart_type": "line", "color_index": 2, "line_style": "solid"},
],
"annotations": [
{"type": "ellipse", "x_start": "2023-06-01", "x_end": "2024-12-31", "y_start": -0.3, "y_end": 1.2},
],
}],
},
},
]

45
app/ai/spec_validator.py Normal file
View file

@ -0,0 +1,45 @@
"""Validate a ChartSpec against actual loaded data."""
from __future__ import annotations
import difflib
import pandas as pd
from app.models.chart_spec import ChartSpec
def validate_and_fix_spec(spec: ChartSpec, sheets: dict[str, pd.DataFrame]) -> ChartSpec:
"""Validate column references in spec against actual data, fixing near-misses.
Returns a corrected ChartSpec.
"""
# Get all available column names across all sheets
all_columns = set()
for df in sheets.values():
all_columns.update(str(c) for c in df.columns)
for panel in spec.panels:
for series in panel.series:
if series.data_column not in all_columns:
# Try fuzzy match
match = _fuzzy_match(series.data_column, all_columns)
if match:
series.data_column = match
else:
print(f"Warning: Column '{series.data_column}' not found in data. "
f"Available: {sorted(all_columns)[:10]}")
return spec
def _fuzzy_match(name: str, candidates: set[str], cutoff: float = 0.5) -> str | None:
"""Find the best fuzzy match for a column name."""
matches = difflib.get_close_matches(
name.lower(),
[c.lower() for c in candidates],
n=1,
cutoff=cutoff,
)
if matches:
# Map back to original case
lower_to_original = {c.lower(): c for c in candidates}
return lower_to_original[matches[0]]
return None

17
app/config.py Normal file
View file

@ -0,0 +1,17 @@
"""Application configuration."""
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
FONTS_DIR = STATIC_DIR / "fonts"
TEMPLATES_DIR = APP_DIR / "templates"
OUTPUT_DIR = BASE_DIR / "output"
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
CLAUDE_MODEL = "claude-opus-4-6"

0
app/data/__init__.py Normal file
View file

51
app/data/analyzer.py Normal file
View file

@ -0,0 +1,51 @@
"""Data summarization for AI prompt context."""
from __future__ import annotations
import pandas as pd
def summarize_data(sheets: dict[str, pd.DataFrame], max_tokens: int = 1500) -> str:
"""Produce a concise text summary of loaded data for the AI prompt.
Includes sheet names, column names, data types, date ranges, and value ranges.
"""
parts = []
for sheet_name, df in sheets.items():
cols = df.columns.tolist()
n_rows = len(df)
n_cols = len(cols)
part = f"Sheet '{sheet_name}': {n_rows} rows x {n_cols} columns\n"
part += f" Columns: {', '.join(str(c) for c in cols[:20])}"
if len(cols) > 20:
part += f" ... (+{len(cols) - 20} more)"
part += "\n"
# Identify date columns
for col in cols[:20]:
try:
if df[col].dtype == "datetime64[ns]" or col.lower() in ("date", "dates", "time", "period"):
dates = pd.to_datetime(df[col], errors="coerce").dropna()
if not dates.empty:
part += f" Date range ({col}): {dates.min().strftime('%Y-%m-%d')} to {dates.max().strftime('%Y-%m-%d')}\n"
continue
except Exception:
pass
# Numeric summary
if pd.api.types.is_numeric_dtype(df[col]):
valid = df[col].dropna()
if not valid.empty:
part += f" {col}: min={valid.min():.4g}, max={valid.max():.4g}\n"
parts.append(part)
summary = "DATA SUMMARY:\n" + "\n".join(parts)
# Truncate if too long (rough estimate: ~4 chars per token)
max_chars = max_tokens * 4
if len(summary) > max_chars:
summary = summary[:max_chars] + "\n... (truncated)"
return summary

43
app/data/loader.py Normal file
View file

@ -0,0 +1,43 @@
"""Excel/CSV data loading and parsing."""
from __future__ import annotations
from pathlib import Path
import pandas as pd
def load_file(filepath: str | Path) -> dict[str, pd.DataFrame]:
"""Load an Excel or CSV file into a dict of DataFrames.
Returns:
Dict mapping sheet names (or "_default" for CSV) to DataFrames.
"""
filepath = Path(filepath)
if filepath.suffix.lower() in (".xlsx", ".xls"):
return _load_excel(filepath)
elif filepath.suffix.lower() == ".csv":
df = pd.read_csv(filepath)
return {"_default": df}
else:
raise ValueError(f"Unsupported file type: {filepath.suffix}")
def _load_excel(filepath: Path) -> dict[str, pd.DataFrame]:
"""Load all sheets from an Excel file."""
xls = pd.ExcelFile(filepath)
result = {}
for sheet_name in xls.sheet_names:
try:
df = pd.read_excel(xls, sheet_name=sheet_name)
# Skip empty or tiny sheets
if df.empty or (df.shape[0] < 2 and df.shape[1] < 2):
continue
# Clean up: drop fully empty rows/columns
df = df.dropna(how="all").dropna(axis=1, how="all")
if not df.empty:
result[sheet_name] = df
except Exception:
continue
return result

64
app/data/transformer.py Normal file
View file

@ -0,0 +1,64 @@
"""Data transformation: date parsing, column selection, resampling."""
from __future__ import annotations
import pandas as pd
def prepare_dataframe(df: pd.DataFrame, date_column: str | None = None) -> pd.DataFrame:
"""Prepare a DataFrame for charting: parse dates, ensure numeric columns.
Args:
df: Raw DataFrame
date_column: Optional name of the date column. Auto-detected if None.
Returns:
Cleaned DataFrame with parsed dates and numeric columns.
"""
df = df.copy()
# Auto-detect date column
if date_column is None:
date_column = _detect_date_column(df)
if date_column and date_column in df.columns:
df[date_column] = pd.to_datetime(df[date_column], errors="coerce")
# Drop rows where date is NaT
df = df.dropna(subset=[date_column])
# Sort by date
df = df.sort_values(date_column).reset_index(drop=True)
# Convert numeric-looking columns
for col in df.columns:
if col == date_column:
continue
if df[col].dtype == object:
try:
df[col] = pd.to_numeric(df[col], errors="coerce")
except Exception:
pass
return df
def _detect_date_column(df: pd.DataFrame) -> str | None:
"""Auto-detect the date column in a DataFrame."""
# Check by name
for col in df.columns:
if str(col).lower() in ("date", "dates", "time", "timestamp", "period", "month"):
return col
# Check by dtype
for col in df.columns:
if df[col].dtype == "datetime64[ns]":
return col
# Try parsing first column
first_col = df.columns[0]
try:
parsed = pd.to_datetime(df[first_col].head(10), errors="coerce")
if parsed.notna().sum() >= 5:
return first_col
except Exception:
pass
return None

196
app/main.py Normal file
View file

@ -0,0 +1,196 @@
"""FastAPI application: upload data, interpret brief, render SVG, iterate."""
from __future__ import annotations
import json
import uuid
import traceback
from pathlib import Path
from fastapi import FastAPI, UploadFile, File, Form, Request
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import OUTPUT_DIR, STATIC_DIR, TEMPLATES_DIR
from app.data.loader import load_file
from app.data.analyzer import summarize_data
from app.data.transformer import prepare_dataframe
from app.ai.brief_interpreter import interpret_brief, refine_spec
from app.ai.spec_validator import validate_and_fix_spec
from app.models.chart_spec import ChartSpec
from app.models.style import LAYOUT
from app.renderer.engine import render_chart
app = FastAPI(title="PIMCO Chart Generator")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
OUTPUT_DIR.mkdir(exist_ok=True)
# Simple in-memory session store: session_id -> {spec, data_path, sheets, summary, history}
_sessions: dict[str, dict] = {}
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("upload.html", {"request": request})
@app.post("/generate", response_class=HTMLResponse)
async def generate(
request: Request,
file: UploadFile = File(...),
brief: str = Form(...),
sheet: str = Form(""),
width: int = Form(2560),
height: int = Form(1440),
):
try:
# Save uploaded file
session_id = uuid.uuid4().hex[:12]
data_path = OUTPUT_DIR / f"data_{session_id}_{file.filename}"
with open(data_path, "wb") as f:
content = await file.read()
f.write(content)
# Load and prepare data
sheets = load_file(data_path)
if sheet and sheet in sheets:
sheets = {sheet: sheets[sheet]}
prepared = {}
for name, df in sheets.items():
prepared[name] = prepare_dataframe(df)
summary = summarize_data(prepared)
# Interpret brief with Claude
spec = interpret_brief(brief, summary)
spec = validate_and_fix_spec(spec, prepared)
# Store session
_sessions[session_id] = {
"spec": spec,
"data_path": str(data_path),
"prepared": prepared,
"summary": summary,
"width": width,
"height": height,
"history": [
{"role": "user", "message": brief},
{"role": "assistant", "message": "Chart generated."},
],
}
# Render
svg, filename, spec_json = _render_and_save(spec, prepared, width, height)
return templates.TemplateResponse("preview.html", {
"request": request,
"svg_content": svg,
"filename": filename,
"spec_json": spec_json,
"session_id": session_id,
"history": _sessions[session_id]["history"],
})
except Exception as e:
traceback.print_exc()
return HTMLResponse(
f'<div class="error"><h3>Error</h3><p>{str(e)}</p></div>',
status_code=200,
)
@app.post("/refine", response_class=HTMLResponse)
async def refine(
request: Request,
session_id: str = Form(...),
edit: str = Form(...),
):
try:
session = _sessions.get(session_id)
if not session:
return HTMLResponse(
'<div class="error"><h3>Session expired</h3>'
'<p>Please re-upload your data and generate a new chart.</p></div>',
status_code=200,
)
old_spec = session["spec"]
summary = session["summary"]
history = session["history"]
# Add the edit to history
history.append({"role": "user", "message": edit})
# Ask Claude to refine the spec
new_spec = refine_spec(old_spec, edit, summary, history)
new_spec = validate_and_fix_spec(new_spec, session["prepared"])
# Update session
session["spec"] = new_spec
history.append({"role": "assistant", "message": "Chart updated."})
# Render
svg, filename, spec_json = _render_and_save(
new_spec, session["prepared"], session["width"], session["height"]
)
return templates.TemplateResponse("preview.html", {
"request": request,
"svg_content": svg,
"filename": filename,
"spec_json": spec_json,
"session_id": session_id,
"history": history,
})
except Exception as e:
traceback.print_exc()
return HTMLResponse(
f'<div class="error"><h3>Error</h3><p>{str(e)}</p></div>',
status_code=200,
)
@app.get("/download/{filename}")
async def download(filename: str):
filepath = OUTPUT_DIR / filename
if not filepath.exists():
return HTMLResponse("<p>File not found</p>", status_code=404)
return FileResponse(
filepath,
media_type="image/svg+xml",
filename=filename,
)
def _render_and_save(
spec: ChartSpec,
prepared: dict,
width: int,
height: int,
) -> tuple[str, str, str]:
"""Render a ChartSpec and save the SVG. Returns (svg_str, filename, spec_json)."""
LAYOUT["single"]["width"] = width
LAYOUT["single"]["height"] = height
LAYOUT["dual_panel"]["width"] = width
LAYOUT["dual_panel"]["height"] = height
data_dict = {}
if len(prepared) == 1:
data_dict["_default"] = next(iter(prepared.values()))
else:
data_dict = dict(prepared)
data_dict["_default"] = next(iter(prepared.values()))
svg = render_chart(spec, data_dict)
filename = f"chart_{uuid.uuid4().hex[:8]}.svg"
svg_path = OUTPUT_DIR / filename
with open(svg_path, "w") as f:
f.write(svg)
spec_json = json.dumps(spec.model_dump(), indent=2, default=str)
return svg, filename, spec_json

0
app/models/__init__.py Normal file
View file

53
app/models/chart_spec.py Normal file
View file

@ -0,0 +1,53 @@
"""Pydantic models for the chart specification - the contract between AI and renderer."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class AxisSpec(BaseModel):
label: str | None = None
suffix: str | None = None
date_format: str | None = None
min_val: float | None = None
max_val: float | None = None
tick_interval: float | None = None
class ShadedFillSpec(BaseModel):
reference_series: str = Field(description="Label of the reference series to shade against")
above_color: Literal["blue", "pink"] = "blue"
below_color: Literal["blue", "pink"] = "pink"
class SeriesSpec(BaseModel):
label: str
data_column: str = Field(description="Column name from uploaded data")
chart_type: Literal["line", "bar", "stacked_bar", "area", "pie"] = "line"
color_index: int | None = None
line_style: Literal["solid", "dashed", "dotted"] = "solid"
line_weight: float | None = None
shaded_fill: ShadedFillSpec | None = None
class AnnotationSpec(BaseModel):
type: Literal["ellipse", "callout", "label"]
x_start: str | None = None
x_end: str | None = None
y_start: float | None = None
y_end: float | None = None
text: str | None = None
class PanelSpec(BaseModel):
title: str
subtitle: str | None = None
x_axis: AxisSpec
y_axis: AxisSpec
series: list[SeriesSpec]
annotations: list[AnnotationSpec] = Field(default_factory=list)
class ChartSpec(BaseModel):
layout: Literal["single", "dual_panel"] = "single"
panels: list[PanelSpec]

40
app/models/style.py Normal file
View file

@ -0,0 +1,40 @@
"""PIMCO chart style constants extracted from reference PDF."""
COLORS = {
"palette": [
"#003D5C", # Dark teal-blue (primary: U.S., Tech investment)
"#5A6B28", # Olive/dark green (Australia, trend lines)
"#891A6A", # Purple/magenta (U.K., Non-tech investment)
"#1FBFAA", # Cyan/teal (Germany)
"#8B7D32", # Dark gold/ochre (Japan)
],
"shaded_above": "#003D5C33", # Blue fill above trend (20% opacity)
"shaded_below": "#891A6A33", # Pink fill below trend (20% opacity)
"annotation_ellipse": "#C8C8C880", # Grey ellipse (50% opacity)
"gridline": "#D4D4D4",
"axis_text": "#333333",
"subtitle_text": "#666666",
"background": "#FFFFFF",
}
FONTS = {
"title": {"family": "RobotoCondensed-Regular", "size": 32, "weight": "normal"},
"subtitle": {"family": "RobotoCondensed-Regular", "size": 24, "weight": "normal"},
"axis_label": {"family": "Roboto-Regular", "size": 20, "weight": "normal"},
"axis_value": {"family": "Roboto-Regular", "size": 22, "weight": "normal"},
"legend": {"family": "Roboto-Regular", "size": 20, "weight": "normal"},
}
LAYOUT = {
"single": {"width": 2560, "height": 1440},
"dual_panel": {"width": 2560, "height": 1440},
"margins": {"top": 140, "right": 80, "bottom": 110, "left": 120},
"panel_gap": 100,
"line_weight": 3.0,
"gridline_weight": 1.0,
"dash_pattern": "14,8",
"dot_pattern": "5,7",
"legend_line_length": 45,
"legend_item_gap": 45,
"legend_top_offset": 20,
}

0
app/renderer/__init__.py Normal file
View file

View file

@ -0,0 +1,52 @@
"""Annotation rendering: ellipses, callouts, labels."""
from __future__ import annotations
from datetime import datetime
import drawsvg as draw
from app.models.style import COLORS
from app.renderer.scale import LinearScale, DateScale
def render_ellipse(
d: draw.Drawing,
x_scale: DateScale,
y_scale: LinearScale,
x_start: datetime,
x_end: datetime,
y_start: float,
y_end: float,
):
"""Render a semi-transparent grey ellipse annotation."""
px_start = x_scale(x_start)
px_end = x_scale(x_end)
py_start = y_scale(y_start)
py_end = y_scale(y_end)
cx = (px_start + px_end) / 2
cy = (py_start + py_end) / 2
rx = abs(px_end - px_start) / 2
ry = abs(py_end - py_start) / 2
d.append(draw.Ellipse(
cx, cy, rx, ry,
fill=COLORS["annotation_ellipse"],
stroke="none",
))
def render_text_label(
d: draw.Drawing,
text: str,
x: float,
y: float,
font_size: float = 11,
color: str | None = None,
):
"""Render a text annotation at a specific position."""
d.append(draw.Text(
text, font_size,
x, y,
font_family="Roboto, sans-serif",
fill=color or COLORS["axis_text"],
dominant_baseline="middle",
))

123
app/renderer/axes.py Normal file
View file

@ -0,0 +1,123 @@
"""Axis rendering: gridlines, tick labels, axis lines."""
from __future__ import annotations
from datetime import datetime
import drawsvg as draw
from app.models.style import COLORS, FONTS, LAYOUT
from app.renderer.scale import LinearScale, DateScale, nice_ticks, nice_date_ticks
def render_y_axis(
d: draw.Drawing,
scale: LinearScale,
plot_left: float,
plot_right: float,
ticks: list[float] | None = None,
suffix: str = "",
label: str | None = None,
):
"""Render Y-axis with horizontal gridlines and left-aligned tick labels."""
if ticks is None:
ticks = nice_ticks(scale.domain_min, scale.domain_max)
font = FONTS["axis_value"]
for tick_val in ticks:
y = scale(tick_val)
# Horizontal gridline spanning the full plot width
d.append(draw.Line(
plot_left, y, plot_right, y,
stroke=COLORS["gridline"],
stroke_width=LAYOUT["gridline_weight"],
))
# Tick label
label_text = _format_tick(tick_val, suffix)
d.append(draw.Text(
label_text, font["size"],
plot_left - 15, y,
font_family="Roboto, sans-serif",
fill=COLORS["axis_text"],
text_anchor="end",
dominant_baseline="middle",
))
# Y-axis label (rotated) if provided
if label:
from app.renderer.typography import render_axis_label
mid_y = (scale.range_min + scale.range_max) / 2
render_axis_label(d, label, plot_left - 90, mid_y, rotation=-90)
def render_x_axis(
d: draw.Drawing,
scale: DateScale,
plot_bottom: float,
ticks: list[datetime] | None = None,
date_format: str | None = None,
):
"""Render X-axis with date tick labels below the plot area."""
if ticks is None:
ticks = nice_date_ticks(scale.date_min, scale.date_max)
if date_format is None:
total_days = (scale.date_max - scale.date_min).days
if total_days > 365 * 10:
date_format = "%Y"
elif total_days > 365 * 5:
date_format = "%b %Y"
else:
date_format = "%b '%y"
font = FONTS["axis_value"]
for tick_date in ticks:
x = scale(tick_date)
label_text = tick_date.strftime(date_format)
d.append(draw.Text(
label_text, font["size"],
x, plot_bottom + 30,
font_family="Roboto, sans-serif",
fill=COLORS["axis_text"],
text_anchor="middle",
dominant_baseline="hanging",
))
def render_x_axis_numeric(
d: draw.Drawing,
scale: LinearScale,
plot_bottom: float,
ticks: list[float] | None = None,
suffix: str = "",
):
"""Render X-axis with numeric tick labels."""
if ticks is None:
ticks = nice_ticks(scale.domain_min, scale.domain_max)
font = FONTS["axis_value"]
for tick_val in ticks:
x = scale(tick_val)
label_text = _format_tick(tick_val, suffix)
d.append(draw.Text(
label_text, font["size"],
x, plot_bottom + 30,
font_family="Roboto, sans-serif",
fill=COLORS["axis_text"],
text_anchor="middle",
dominant_baseline="hanging",
))
def _format_tick(value: float, suffix: str) -> str:
"""Format a tick value, removing unnecessary decimal places."""
if value == int(value):
return f"{int(value)}{suffix}"
# Show one decimal for values like 0.5, -0.5
if value * 10 == int(value * 10):
return f"{value:.1f}{suffix}"
return f"{value:.2f}{suffix}"

285
app/renderer/engine.py Normal file
View file

@ -0,0 +1,285 @@
"""Main rendering engine: ChartSpec + DataFrames -> SVG string."""
from __future__ import annotations
import base64
from datetime import datetime
from pathlib import Path
import drawsvg as draw
import pandas as pd
from app.config import FONTS_DIR
from app.models.chart_spec import ChartSpec, PanelSpec, SeriesSpec
from app.models.style import COLORS, LAYOUT
from app.renderer.layout import compute_layout, PanelBounds
from app.renderer.scale import LinearScale, DateScale, nice_ticks, nice_date_ticks
from app.renderer.axes import render_y_axis, render_x_axis
from app.renderer.series import render_line_series, render_shaded_fill, render_bar_series
from app.renderer.legend import render_legend
from app.renderer.typography import render_title, render_subtitle
from app.renderer.annotations import render_ellipse
def render_chart(spec: ChartSpec, data: dict[str, pd.DataFrame]) -> str:
"""Render a ChartSpec into an SVG string.
Args:
spec: The chart specification
data: Dict mapping sheet/table names to DataFrames.
A key of "_default" is used when there's only one data source.
Returns:
SVG string
"""
canvas_w, canvas_h, panel_bounds = compute_layout(spec.layout)
d = draw.Drawing(canvas_w, canvas_h)
# Embed fonts
_embed_fonts(d)
# Background
d.append(draw.Rectangle(0, 0, canvas_w, canvas_h, fill=COLORS["background"]))
# Render each panel
for i, panel_spec in enumerate(spec.panels):
if i >= len(panel_bounds):
break
bounds = panel_bounds[i]
# Support per-panel data: check for _panel_0, _panel_1 keys
panel_key = f"_panel_{i}"
if panel_key in data:
panel_data = {panel_key: data[panel_key]}
else:
panel_data = data
_render_panel(d, panel_spec, bounds, panel_data)
return d.as_svg()
def _embed_fonts(d: draw.Drawing):
"""Embed Roboto fonts as base64 @font-face in SVG."""
font_css = ""
font_files = {
"Roboto": FONTS_DIR / "Roboto-Regular.ttf",
"Roboto Condensed": FONTS_DIR / "RobotoCondensed-Regular.ttf",
}
for family_name, font_path in font_files.items():
if font_path.exists():
with open(font_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
font_css += f"""
@font-face {{
font-family: '{family_name}';
src: url('data:font/truetype;base64,{b64}') format('truetype');
font-weight: normal;
font-style: normal;
}}
"""
if font_css:
style = draw.Raw(f"<defs><style type='text/css'>{font_css}</style></defs>")
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

63
app/renderer/layout.py Normal file
View file

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

61
app/renderer/legend.py Normal file
View file

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

122
app/renderer/scale.py Normal file
View file

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

212
app/renderer/series.py Normal file
View file

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

View file

@ -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)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
app/static/fonts/Roboto.ttf Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

306
app/static/style.css Normal file
View file

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

21
app/templates/base.html Normal file
View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PIMCO Chart Generator</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&family=Roboto+Condensed:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>PIMCO Chart Generator</h1>
<p class="subtitle">Upload data and describe your chart to generate publication-quality SVGs</p>
</header>
{% block content %}{% endblock %}
</div>
</body>
</html>

View file

@ -0,0 +1,51 @@
<div class="preview-container">
<div class="preview-header">
<h2>Generated Chart</h2>
<div class="actions">
<a href="/download/{{ filename }}" class="btn btn-download" download>Download SVG</a>
{% if spec_json %}
<details class="spec-details">
<summary>View Chart Spec</summary>
<pre>{{ spec_json }}</pre>
</details>
{% endif %}
</div>
</div>
<div class="svg-preview">
{{ svg_content | safe }}
</div>
{% if session_id %}
<div class="refine-section">
<div class="history">
{% for entry in history %}
<div class="history-entry {{ entry.role }}">
<span class="history-role">{{ "You" if entry.role == "user" else "Chart Generator" }}</span>
<span class="history-message">{{ entry.message }}</span>
</div>
{% endfor %}
</div>
<form hx-post="/refine"
hx-target="#preview"
hx-swap="innerHTML"
hx-indicator="#refine-loading"
class="refine-form">
<input type="hidden" name="session_id" value="{{ session_id }}">
<input type="text"
name="edit"
class="refine-input"
placeholder="Refine your chart... e.g. 'Make lines thicker', 'Change title to...', 'Remove the Japan line'"
required
autofocus>
<button type="submit" class="btn btn-refine">Update</button>
</form>
<div id="refine-loading" class="htmx-indicator refine-loading">
<div class="spinner-small"></div>
<span>Updating chart...</span>
</div>
</div>
{% endif %}
</div>

47
app/templates/upload.html Normal file
View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<form hx-post="/generate"
hx-target="#preview"
hx-swap="innerHTML"
hx-indicator="#loading"
hx-encoding="multipart/form-data"
class="upload-form">
<div class="form-group">
<label for="file">Data File (Excel or CSV)</label>
<input type="file" id="file" name="file" accept=".xlsx,.xls,.csv" required>
</div>
<div class="form-group">
<label for="sheet">Sheet Name <span class="optional">(optional, auto-detected)</span></label>
<input type="text" id="sheet" name="sheet" placeholder="e.g., Sheet1, Manuf, Global Yields">
</div>
<div class="form-group">
<label for="brief">Chart Brief</label>
<textarea id="brief" name="brief" rows="6" required
placeholder="Describe the chart you want. For example:&#10;&#10;Create a line chart showing 10-year government bond yields for the US, UK, Australia, Germany, and Japan. Use the columns Date, US_10Y, UK_10Y, AU_10Y, DE_10Y, JP_10Y. Y-axis should show percentages from -1% to 6%."></textarea>
</div>
<div class="form-group inline">
<div>
<label for="width">Width (px)</label>
<input type="number" id="width" name="width" value="2560" min="400" max="5000">
</div>
<div>
<label for="height">Height (px)</label>
<input type="number" id="height" name="height" value="1440" min="300" max="3000">
</div>
</div>
<button type="submit" class="btn">Generate Chart</button>
</form>
<div id="loading" class="htmx-indicator">
<div class="spinner"></div>
<p>Interpreting brief and rendering chart...</p>
</div>
<div id="preview"></div>
{% endblock %}

32
requirements.txt Normal file
View file

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

87
test_render.py Normal file
View file

@ -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()

214
test_render_all.py Normal file
View file

@ -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/")