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:
parent
f57c844216
commit
a3a38e85d2
38 changed files with 7220 additions and 47 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
60
.gitignore
vendored
60
.gitignore
vendored
|
|
@ -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
320
README.md
Normal 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
0
app/__init__.py
Normal file
0
app/ai/__init__.py
Normal file
0
app/ai/__init__.py
Normal file
129
app/ai/brief_interpreter.py
Normal file
129
app/ai/brief_interpreter.py
Normal 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
118
app/ai/prompts.py
Normal 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
45
app/ai/spec_validator.py
Normal 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
17
app/config.py
Normal 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
0
app/data/__init__.py
Normal file
51
app/data/analyzer.py
Normal file
51
app/data/analyzer.py
Normal 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
43
app/data/loader.py
Normal 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
64
app/data/transformer.py
Normal 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
196
app/main.py
Normal 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
0
app/models/__init__.py
Normal file
53
app/models/chart_spec.py
Normal file
53
app/models/chart_spec.py
Normal 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
40
app/models/style.py
Normal 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
0
app/renderer/__init__.py
Normal file
52
app/renderer/annotations.py
Normal file
52
app/renderer/annotations.py
Normal 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
123
app/renderer/axes.py
Normal 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
285
app/renderer/engine.py
Normal 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
63
app/renderer/layout.py
Normal 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
61
app/renderer/legend.py
Normal 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
122
app/renderer/scale.py
Normal 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
212
app/renderer/series.py
Normal 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",
|
||||
))
|
||||
47
app/renderer/typography.py
Normal file
47
app/renderer/typography.py
Normal 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)
|
||||
1469
app/static/fonts/Roboto-Bold.ttf
Normal file
1469
app/static/fonts/Roboto-Bold.ttf
Normal file
File diff suppressed because one or more lines are too long
1469
app/static/fonts/Roboto-Regular.ttf
Normal file
1469
app/static/fonts/Roboto-Regular.ttf
Normal file
File diff suppressed because one or more lines are too long
BIN
app/static/fonts/Roboto.ttf
Normal file
BIN
app/static/fonts/Roboto.ttf
Normal file
Binary file not shown.
1469
app/static/fonts/RobotoCondensed-Regular.ttf
Normal file
1469
app/static/fonts/RobotoCondensed-Regular.ttf
Normal file
File diff suppressed because one or more lines are too long
BIN
app/static/fonts/RobotoCondensed.ttf
Normal file
BIN
app/static/fonts/RobotoCondensed.ttf
Normal file
Binary file not shown.
306
app/static/style.css
Normal file
306
app/static/style.css
Normal 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
21
app/templates/base.html
Normal 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>
|
||||
51
app/templates/preview.html
Normal file
51
app/templates/preview.html
Normal 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
47
app/templates/upload.html
Normal 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: 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
32
requirements.txt
Normal 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
87
test_render.py
Normal 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
214
test_render_all.py
Normal 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/")
|
||||
Loading…
Add table
Reference in a new issue