Fix bar charts, fonts, axis controls, donut support, and Playwright export
Issue 1 – Bar charts blank/lines only: - Silent fall-through on unsupported chart_types (donut, stacked_bar, area) now raises ValueError instead of producing axes-only output - Zero-width bars on duplicate/single dates fixed via sorted-gap calculation - Donut chart type added (ring with percentage labels) - Pie/donut routing now triggers on any() instead of all() Issue 2 – Axis controls not applying: - AxisSpec gains date_min/date_max (x-axis clamping via prompts) - y-bounds no longer silently widened when user sets min_val/max_val - Tick clamping: ticks outside user range are dropped not widened - New dual_y_axis layout with independent left/right Y-axes and y_axis_side per series - Endpoint Y-axis labels (min/max) always render even when spacing is tight Issue 3+4 – Font fallback & InDesign compatibility: - Replace CairoSVG with Playwright/headless Chromium for PNG and PDF export - Chromium honours @font-face base64 data URIs → Roboto Condensed in all exports - PDF output contains embedded TTF subsets and real text operators (selectable in InDesign/Illustrator, no path-outlining, consistent across regions) - FastAPI lifespan manages persistent Playwright browser instance Issue 5 – Stroke weight drift: - All stroke_width values now carry explicit "px" unit suffix - SVG root gets width="…px" height="…px" so 1 SVG px = 0.75 PDF pt exactly AI improvements: - Prompts document date_min/date_max, scale_kind, dual_y_axis, donut - Rule 9 softened: user-specified ranges are honoured even if they crop data - Refinement uses deep-merge so tick_interval/min_val/date_min are never accidentally reset to None when Claude modifies unrelated fields - New donut few-shot example added Library upgrades: anthropic 0.84→0.97, fastapi 0.135→0.136, pandas 3.0.1→3.0.2, pydantic 2.12→2.13, uvicorn 0.41→0.46; cairosvg removed, playwright 1.58.0 added. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
db853cea9e
commit
d52f088243
14 changed files with 676 additions and 366 deletions
|
|
@ -1,10 +1,12 @@
|
|||
FROM python:3.11-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 curl fontconfig \
|
||||
curl fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install Playwright Chromium and its OS dependencies
|
||||
RUN playwright install --with-deps chromium
|
||||
COPY . .
|
||||
RUN mkdir -p /app/output && \
|
||||
mkdir -p /usr/share/fonts/truetype/roboto && \
|
||||
|
|
|
|||
|
|
@ -9,13 +9,9 @@ from app.ai.prompts import SYSTEM_PROMPT, REFINE_SYSTEM_PROMPT, FEW_SHOT_EXAMPLE
|
|||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Send brief + data summary to Claude and get back a ChartSpec."""
|
||||
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
|
||||
# Build few-shot messages
|
||||
messages = []
|
||||
for ex in FEW_SHOT_EXAMPLES:
|
||||
messages.append({
|
||||
|
|
@ -44,7 +40,6 @@ def interpret_brief(brief: str, data_summary: str) -> ChartSpec:
|
|||
],
|
||||
})
|
||||
|
||||
# Add the actual request
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"DATA AVAILABLE:\n{data_summary}\n\nBRIEF:\n{brief}",
|
||||
|
|
@ -80,15 +75,15 @@ def refine_spec(
|
|||
) -> 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.
|
||||
After Claude returns an updated spec, the result is deep-merged against the
|
||||
current spec so that any field Claude omitted keeps its prior value rather
|
||||
than reverting to the Pydantic default.
|
||||
"""
|
||||
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)
|
||||
for h in history[:-1]
|
||||
)
|
||||
|
||||
current_spec_json = json.dumps(current_spec.model_dump(), indent=2, default=str)
|
||||
|
|
@ -124,6 +119,39 @@ def refine_spec(
|
|||
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
return ChartSpec.model_validate(block.input)
|
||||
new_spec = ChartSpec.model_validate(block.input)
|
||||
return _deep_merge_spec(current_spec, new_spec)
|
||||
|
||||
raise ValueError("Claude did not return an updated chart specification")
|
||||
|
||||
|
||||
def _deep_merge_spec(base: ChartSpec, updated: ChartSpec) -> ChartSpec:
|
||||
"""Merge `updated` onto `base`, preserving non-None base values wherever
|
||||
`updated` has reverted a field to its Pydantic default (None / empty).
|
||||
|
||||
This prevents Claude from accidentally resetting tick_interval, min_val,
|
||||
date_min, etc. when it wasn't asked to touch those fields.
|
||||
"""
|
||||
base_dict = base.model_dump()
|
||||
updated_dict = updated.model_dump()
|
||||
|
||||
merged = _merge_dicts(base_dict, updated_dict)
|
||||
return ChartSpec.model_validate(merged)
|
||||
|
||||
|
||||
def _merge_dicts(base: dict, updated: dict) -> dict:
|
||||
result = dict(base)
|
||||
for key, updated_val in updated.items():
|
||||
base_val = base.get(key)
|
||||
if isinstance(updated_val, dict) and isinstance(base_val, dict):
|
||||
result[key] = _merge_dicts(base_val, updated_val)
|
||||
elif isinstance(updated_val, list) and isinstance(base_val, list):
|
||||
# For lists (panels, series, annotations): if updated is non-empty, use it
|
||||
# If updated returned empty but base had content, keep base
|
||||
result[key] = updated_val if updated_val else base_val
|
||||
elif updated_val is None and base_val is not None:
|
||||
# Claude reset a previously-set field to None — keep the base value
|
||||
result[key] = base_val
|
||||
else:
|
||||
result[key] = updated_val
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -21,9 +21,13 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use
|
|||
## 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
|
||||
3. **Dual-panel side-by-side** (layout: "dual_panel"): Two related charts shown together, each with its own independent axes, often with trend lines and shaded fill areas
|
||||
4. **Bar charts**: Vertical bars for time-based comparisons. Uses scale_kind: "date" (default) on x_axis
|
||||
5. **Categorical bar charts**: Bars for non-date categories (credit ratings, sectors, countries). Set x_axis.scale_kind: "category"
|
||||
6. **Stacked bar charts**: Multiple categories stacked (use chart_type: "bar" for now)
|
||||
7. **Donut charts** (chart_type: "donut"): Ring chart with percentage labels. All series in the panel must be donut type
|
||||
8. **Pie charts** (chart_type: "pie"): All series in the panel must be pie type
|
||||
9. **Dual Y-axis** (layout: "dual_y_axis"): One panel, two independent Y-axes (primary on left, secondary on right), shared X-axis. Use when two metrics share an X-axis but have incompatible scales (e.g. price % left, spread bps right)
|
||||
|
||||
## Rules for ChartSpec Output
|
||||
1. Always use the exact column names from the data summary
|
||||
|
|
@ -34,15 +38,25 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use
|
|||
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
|
||||
9. Set min_val/max_val to encompass all data with a small buffer. Never set ranges tighter than the actual data.
|
||||
9. Set min_val/max_val when the user specifies a range. Honor user-specified ranges even if they crop some data points — only add a small buffer when the brief does NOT specify bounds.
|
||||
10. Keep series labels concise (under 30 characters) to avoid legend overflow.
|
||||
11. If numeric data has very small values (e.g. min=-0.0042, max=0.012) but the brief or column name suggests percentages, the data is likely in decimal form (Excel stores -0.42% as -0.0042). Inform the user and set the Y-axis accordingly — do NOT set suffix="%" unless values are already in display form (e.g. -0.42 for -0.42%).
|
||||
|
||||
## Axis range controls
|
||||
- x_axis.date_min / x_axis.date_max: ISO date string ("2010-01-01") or year ("2015") to restrict the x-axis range. Use this when the user says "start at 2010", "show only 2015 onwards", etc.
|
||||
- y_axis.min_val / y_axis.max_val: Numeric bounds for the y-axis. Claude should set these exactly as the user requests.
|
||||
|
||||
## Dual Y-axis setup
|
||||
- Set layout: "dual_y_axis" and provide one panel
|
||||
- Assign y_axis_side: "primary" or "secondary" on each SeriesSpec
|
||||
- Set panel.y_axis for the left (primary) axis and panel.secondary_y_axis for the right axis
|
||||
- Example: price index (left, 0–200) + spread in bps (right, 0–500) on same time axis
|
||||
|
||||
## 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
|
||||
- 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
|
||||
- For donut/pie: use data_column pointing to a column whose last non-null value is the slice value
|
||||
"""
|
||||
|
||||
REFINE_SYSTEM_PROMPT = """You are a PIMCO chart specification editor. You receive:
|
||||
|
|
@ -50,7 +64,7 @@ REFINE_SYSTEM_PROMPT = """You are a PIMCO chart specification editor. You receiv
|
|||
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.
|
||||
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 — including all axis fields, tick_interval, line_weight, annotations, etc.
|
||||
|
||||
## Common edit requests and how to handle them:
|
||||
|
||||
|
|
@ -67,18 +81,23 @@ Your job is to apply ONLY the requested changes to the existing spec and return
|
|||
- "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="%"
|
||||
- "Y-axis from 0 to 10" → set min_val=0, max_val=10 on y_axis
|
||||
- "Show years only on X-axis" → set date_format="%Y" on x_axis
|
||||
- "Add % to Y-axis" → set suffix="%" on y_axis
|
||||
- "Start the x-axis at 2010" → set x_axis.date_min="2010-01-01"
|
||||
- "End the x-axis at 2024" → set x_axis.date_max="2024-12-31"
|
||||
- "X-axis from 2015 to 2023" → set x_axis.date_min="2015-01-01", x_axis.date_max="2023-12-31"
|
||||
|
||||
**Layout changes:**
|
||||
- "Make it dual panel" → change layout to "dual_panel" and split series
|
||||
- "Make it dual panel" → change layout to "dual_panel" and split series across two panels
|
||||
- "Add a secondary right axis for [series]" → change layout to "dual_y_axis", set y_axis_side="secondary" on that series, add secondary_y_axis spec
|
||||
|
||||
## 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
|
||||
- NEVER reset tick_interval, min_val, max_val, or date_min/date_max to null unless explicitly asked to
|
||||
"""
|
||||
|
||||
FEW_SHOT_EXAMPLES = [
|
||||
|
|
@ -92,12 +111,13 @@ FEW_SHOT_EXAMPLES = [
|
|||
"subtitle": None,
|
||||
"x_axis": {"date_format": "%b %Y"},
|
||||
"y_axis": {"suffix": "%", "min_val": -1, "max_val": 6, "tick_interval": 1},
|
||||
"secondary_y_axis": None,
|
||||
"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"},
|
||||
{"label": "U.S.", "data_column": "US_10Y", "chart_type": "line", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Australia", "data_column": "AU_10Y", "chart_type": "line", "color_index": 1, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "U.K.", "data_column": "UK_10Y", "chart_type": "line", "color_index": 2, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Germany", "data_column": "DE_10Y", "chart_type": "line", "color_index": 3, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Japan", "data_column": "JP_10Y", "chart_type": "line", "color_index": 4, "line_style": "solid", "y_axis_side": "primary"},
|
||||
],
|
||||
"annotations": [],
|
||||
}],
|
||||
|
|
@ -113,9 +133,10 @@ FEW_SHOT_EXAMPLES = [
|
|||
"subtitle": None,
|
||||
"x_axis": {"date_format": "%Y"},
|
||||
"y_axis": {"label": "Percentage points", "min_val": -1.0, "max_val": 1.5, "tick_interval": 0.5},
|
||||
"secondary_y_axis": None,
|
||||
"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"},
|
||||
{"label": "Tech investment", "data_column": "Tech_Investment", "chart_type": "line", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Non-tech investment", "data_column": "Nontech_Investment", "chart_type": "line", "color_index": 2, "line_style": "solid", "y_axis_side": "primary"},
|
||||
],
|
||||
"annotations": [
|
||||
{"type": "ellipse", "x_start": "2023-06-01", "x_end": "2024-12-31", "y_start": -0.3, "y_end": 1.2},
|
||||
|
|
@ -123,4 +144,25 @@ FEW_SHOT_EXAMPLES = [
|
|||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"brief": "Donut chart showing regional allocation: North America 45%, Europe 30%, Asia Pacific 20%, Other 5%.",
|
||||
"data_summary": "Sheet 'Allocation': 1 row x 5 columns\n Columns: Region, NorthAmerica, Europe, AsiaPacific, Other\n NorthAmerica: 45.0, Europe: 30.0, AsiaPacific: 20.0, Other: 5.0",
|
||||
"spec": {
|
||||
"layout": "single",
|
||||
"panels": [{
|
||||
"title": "Regional Allocation",
|
||||
"subtitle": None,
|
||||
"x_axis": {"scale_kind": "category"},
|
||||
"y_axis": {},
|
||||
"secondary_y_axis": None,
|
||||
"series": [
|
||||
{"label": "North America", "data_column": "NorthAmerica", "chart_type": "donut", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Europe", "data_column": "Europe", "chart_type": "donut", "color_index": 3, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Asia Pacific", "data_column": "AsiaPacific", "chart_type": "donut", "color_index": 4, "line_style": "solid", "y_axis_side": "primary"},
|
||||
{"label": "Other", "data_column": "Other", "chart_type": "donut", "color_index": 6, "line_style": "solid", "y_axis_side": "primary"},
|
||||
],
|
||||
"annotations": [],
|
||||
}],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
48
app/main.py
48
app/main.py
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import uuid
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, Form, Request
|
||||
|
|
@ -27,9 +28,17 @@ 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
|
||||
import cairosvg
|
||||
from app.renderer import export as svg_export
|
||||
|
||||
app = FastAPI(title="PIMCO Chart Generator", root_path="/Pimco-charts")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await svg_export.init_browser()
|
||||
yield
|
||||
await svg_export.close_browser()
|
||||
|
||||
|
||||
app = FastAPI(title="PIMCO Chart Generator", root_path="/Pimco-charts", lifespan=lifespan)
|
||||
app.add_middleware(AuthMiddleware)
|
||||
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY, https_only=True, same_site="lax")
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1")
|
||||
|
|
@ -39,7 +48,6 @@ 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] = {}
|
||||
|
||||
|
||||
|
|
@ -70,14 +78,12 @@ async def generate(
|
|||
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]}
|
||||
|
|
@ -88,11 +94,9 @@ async def generate(
|
|||
|
||||
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),
|
||||
|
|
@ -106,8 +110,7 @@ async def generate(
|
|||
],
|
||||
}
|
||||
|
||||
# Render
|
||||
svg, base_name, spec_json = _render_and_save(spec, prepared, width, height)
|
||||
svg, base_name, spec_json = await _render_and_save(spec, prepared, width, height)
|
||||
|
||||
return templates.TemplateResponse("preview.html", {
|
||||
"request": request,
|
||||
|
|
@ -146,19 +149,15 @@ async def refine(
|
|||
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, base_name, spec_json = _render_and_save(
|
||||
svg, base_name, spec_json = await _render_and_save(
|
||||
new_spec, session["prepared"], session["width"], session["height"]
|
||||
)
|
||||
|
||||
|
|
@ -195,17 +194,16 @@ async def download(filename: str):
|
|||
return FileResponse(filepath, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
def _render_and_save(
|
||||
async def _render_and_save(
|
||||
spec: ChartSpec,
|
||||
prepared: dict,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Render a ChartSpec and save SVG, PNG, and PDF. Returns (svg_str, base_name, spec_json)."""
|
||||
LAYOUT["single"]["width"] = width
|
||||
LAYOUT["single"]["height"] = height
|
||||
LAYOUT["dual_panel"]["width"] = width
|
||||
LAYOUT["dual_panel"]["height"] = height
|
||||
for layout_key in ("single", "dual_panel", "dual_y_axis"):
|
||||
LAYOUT[layout_key]["width"] = width
|
||||
LAYOUT[layout_key]["height"] = height
|
||||
|
||||
data_dict = {}
|
||||
if len(prepared) == 1:
|
||||
|
|
@ -219,16 +217,16 @@ def _render_and_save(
|
|||
base_name = f"chart_{uuid.uuid4().hex[:8]}"
|
||||
svg_bytes = svg.encode("utf-8")
|
||||
|
||||
# Save SVG
|
||||
with open(OUTPUT_DIR / f"{base_name}.svg", "w") as f:
|
||||
f.write(svg)
|
||||
|
||||
# Save PNG (scale=1 preserves the native SVG pixel dimensions; CairoSVG
|
||||
# defaults to 96 DPI which at 2560×1440 already produces a crisp raster)
|
||||
cairosvg.svg2png(bytestring=svg_bytes, write_to=str(OUTPUT_DIR / f"{base_name}.png"), dpi=150)
|
||||
png_bytes = await svg_export.svg_to_png(svg, width, height)
|
||||
with open(OUTPUT_DIR / f"{base_name}.png", "wb") as f:
|
||||
f.write(png_bytes)
|
||||
|
||||
# Save PDF
|
||||
cairosvg.svg2pdf(bytestring=svg_bytes, write_to=str(OUTPUT_DIR / f"{base_name}.pdf"), dpi=150)
|
||||
pdf_bytes = await svg_export.svg_to_pdf(svg, width, height)
|
||||
with open(OUTPUT_DIR / f"{base_name}.pdf", "wb") as f:
|
||||
f.write(pdf_bytes)
|
||||
|
||||
spec_json = json.dumps(spec.model_dump(), indent=2, default=str)
|
||||
return svg, base_name, spec_json
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ class AxisSpec(BaseModel):
|
|||
min_val: float | None = None
|
||||
max_val: float | None = None
|
||||
tick_interval: float | None = None
|
||||
date_min: str | None = None # ISO date string or year "2015" — clamps x-axis range
|
||||
date_max: str | None = None
|
||||
scale_kind: Literal["date", "category"] = "date"
|
||||
|
||||
|
||||
class ShadedFillSpec(BaseModel):
|
||||
|
|
@ -23,11 +26,12 @@ class ShadedFillSpec(BaseModel):
|
|||
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"
|
||||
chart_type: Literal["line", "bar", "stacked_bar", "area", "pie", "donut"] = "line"
|
||||
color_index: int | None = None
|
||||
line_style: Literal["solid", "dashed", "dotted"] = "solid"
|
||||
line_weight: float | None = None
|
||||
shaded_fill: ShadedFillSpec | None = None
|
||||
y_axis_side: Literal["primary", "secondary"] = "primary" # for dual_y_axis layout
|
||||
|
||||
|
||||
class AnnotationSpec(BaseModel):
|
||||
|
|
@ -44,10 +48,11 @@ class PanelSpec(BaseModel):
|
|||
subtitle: str | None = None
|
||||
x_axis: AxisSpec
|
||||
y_axis: AxisSpec
|
||||
secondary_y_axis: AxisSpec | None = None # right-side axis for dual_y_axis layout
|
||||
series: list[SeriesSpec]
|
||||
annotations: list[AnnotationSpec] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChartSpec(BaseModel):
|
||||
layout: Literal["single", "dual_panel"] = "single"
|
||||
layout: Literal["single", "dual_panel", "dual_y_axis"] = "single"
|
||||
panels: list[PanelSpec]
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ FONTS = {
|
|||
LAYOUT = {
|
||||
"single": {"width": 2560, "height": 1440},
|
||||
"dual_panel": {"width": 2560, "height": 1440},
|
||||
"dual_y_axis": {"width": 2560, "height": 1440},
|
||||
"margins": {"top": 15, "right": 15, "bottom": 15, "left": 15},
|
||||
"panel_gap": 100,
|
||||
"line_weight": 15.0,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ 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
|
||||
from app.renderer.scale import LinearScale, DateScale, CategoricalScale, nice_ticks, nice_date_ticks
|
||||
|
||||
# Pixel spacing threshold below which tick labels are rotated
|
||||
_ROTATION_THRESHOLD_PX = 96
|
||||
|
||||
|
||||
|
|
@ -37,77 +36,88 @@ def render_y_axis(
|
|||
ticks: list[float] | None = None,
|
||||
suffix: str = "",
|
||||
label: str | None = None,
|
||||
side: str = "left",
|
||||
):
|
||||
"""Render Y-axis with horizontal gridlines and left-aligned tick labels."""
|
||||
"""Render Y-axis with horizontal gridlines and tick labels.
|
||||
|
||||
Args:
|
||||
side: "left" (default) or "right" — determines label placement and gridline drawing.
|
||||
Right-side axis does NOT redraw gridlines (they were drawn by the left axis).
|
||||
"""
|
||||
if ticks is None:
|
||||
ticks = nice_ticks(scale.domain_min, scale.domain_max)
|
||||
|
||||
font = FONTS["axis_label"]
|
||||
|
||||
# Compute pixel positions for all ticks to determine label spacing
|
||||
tick_ys = [scale(t) for t in ticks]
|
||||
|
||||
# Determine if labels need rotation (too close together)
|
||||
needs_rotation = False
|
||||
if len(tick_ys) >= 2:
|
||||
spacings = [abs(tick_ys[i] - tick_ys[i - 1]) for i in range(1, len(tick_ys))]
|
||||
min_spacing = min(spacings)
|
||||
if min_spacing < _ROTATION_THRESHOLD_PX:
|
||||
if min(spacings) < _ROTATION_THRESHOLD_PX:
|
||||
needs_rotation = True
|
||||
|
||||
label_half_h = font["size"] / 2
|
||||
last_rendered_bottom: float | None = None
|
||||
endpoint_ys = {tick_ys[0], tick_ys[-1]} if tick_ys else set()
|
||||
|
||||
for tick_val, y in zip(ticks, tick_ys):
|
||||
# Horizontal gridline — always drawn regardless of label overlap
|
||||
if tick_val == 0:
|
||||
stroke_w = LAYOUT["zero_axis_weight"]
|
||||
else:
|
||||
stroke_w = LAYOUT["gridline_weight"]
|
||||
|
||||
# Gridlines — only drawn by left/primary axis to avoid duplicates
|
||||
if side == "left":
|
||||
stroke_w = LAYOUT["zero_axis_weight"] if tick_val == 0 else LAYOUT["gridline_weight"]
|
||||
d.append(draw.Line(
|
||||
plot_left, y, plot_right, y,
|
||||
stroke=COLORS["gridline"],
|
||||
stroke_width=stroke_w,
|
||||
stroke_width=f"{stroke_w}px",
|
||||
))
|
||||
|
||||
# Skip label if it would overlap the previous rendered label
|
||||
# Endpoint labels (min/max) always render; interior labels skip when too close
|
||||
label_top = y - label_half_h
|
||||
if last_rendered_bottom is not None and label_top < last_rendered_bottom + 5:
|
||||
is_endpoint = y in endpoint_ys
|
||||
if (
|
||||
not is_endpoint
|
||||
and last_rendered_bottom is not None
|
||||
and label_top < last_rendered_bottom + 5
|
||||
):
|
||||
continue
|
||||
|
||||
# Tick label
|
||||
label_text = _format_tick(tick_val, suffix)
|
||||
|
||||
if side == "right":
|
||||
lx = plot_right + 15
|
||||
anchor = "start"
|
||||
else:
|
||||
lx = plot_left - 15
|
||||
ly = y
|
||||
anchor = "end"
|
||||
|
||||
if needs_rotation:
|
||||
d.append(draw.Text(
|
||||
label_text, font["size"],
|
||||
lx, ly,
|
||||
lx, y,
|
||||
font_family=f"{font['family']}, sans-serif",
|
||||
font_weight=font["weight"],
|
||||
fill=COLORS["axis_text"],
|
||||
text_anchor="middle",
|
||||
dominant_baseline="middle",
|
||||
transform=f"rotate(-90,{lx},{ly})",
|
||||
transform=f"rotate(-90,{lx},{y})",
|
||||
))
|
||||
else:
|
||||
d.append(draw.Text(
|
||||
label_text, font["size"],
|
||||
lx, ly,
|
||||
lx, y,
|
||||
font_family=f"{font['family']}, sans-serif",
|
||||
font_weight=font["weight"],
|
||||
fill=COLORS["axis_text"],
|
||||
text_anchor="end",
|
||||
text_anchor=anchor,
|
||||
dominant_baseline="middle",
|
||||
))
|
||||
last_rendered_bottom = y + label_half_h
|
||||
|
||||
# Y-axis title (rotated) if provided
|
||||
if label:
|
||||
from app.renderer.typography import render_axis_label
|
||||
mid_y = (scale.range_min + scale.range_max) / 2
|
||||
if side == "right":
|
||||
render_axis_label(d, label, plot_right + 90, mid_y, rotation=90)
|
||||
else:
|
||||
render_axis_label(d, label, plot_left - 90, mid_y, rotation=-90)
|
||||
|
||||
|
||||
|
|
@ -132,34 +142,28 @@ def render_x_axis(
|
|||
date_format = "%b '%y"
|
||||
|
||||
font = FONTS["axis_label"]
|
||||
|
||||
tick_xs = [scale(t) for t in ticks]
|
||||
|
||||
# Determine if labels need rotation (too close together)
|
||||
needs_rotation = False
|
||||
if len(tick_xs) >= 2:
|
||||
spacings = [abs(tick_xs[i] - tick_xs[i - 1]) for i in range(1, len(tick_xs))]
|
||||
min_spacing = min(spacings)
|
||||
if min_spacing < _ROTATION_THRESHOLD_PX:
|
||||
if min(spacings) < _ROTATION_THRESHOLD_PX:
|
||||
needs_rotation = True
|
||||
|
||||
label_y = plot_bottom + 30
|
||||
last_rendered_right = None # Track rightmost edge of last rendered label
|
||||
last_rendered_right = None
|
||||
|
||||
for tick_date, x in zip(ticks, tick_xs):
|
||||
label_text = tick_date.strftime(date_format)
|
||||
text_width = _estimate_text_width(label_text, font["size"])
|
||||
|
||||
if needs_rotation:
|
||||
# Rotated labels are anchored at the right, so their extent goes upward
|
||||
# Overlap check: the label's horizontal "footprint" is approximately font_size wide
|
||||
label_left = x - font["size"] / 2
|
||||
label_right = x + font["size"] / 2
|
||||
else:
|
||||
label_left = x - text_width / 2
|
||||
label_right = x + text_width / 2
|
||||
|
||||
# Skip if overlapping with previous label (with 5px clearance)
|
||||
if last_rendered_right is not None and label_left < last_rendered_right + 5:
|
||||
continue
|
||||
|
||||
|
|
@ -188,6 +192,37 @@ def render_x_axis(
|
|||
last_rendered_right = label_right
|
||||
|
||||
|
||||
def render_x_axis_categorical(
|
||||
d: draw.Drawing,
|
||||
scale: CategoricalScale,
|
||||
plot_bottom: float,
|
||||
):
|
||||
"""Render X-axis with categorical string labels below the plot area."""
|
||||
font = FONTS["axis_label"]
|
||||
label_y = plot_bottom + 30
|
||||
last_rendered_right = None
|
||||
|
||||
for cat in scale.categories:
|
||||
x = scale(cat)
|
||||
text_width = _estimate_text_width(str(cat), font["size"])
|
||||
label_left = x - text_width / 2
|
||||
label_right = x + text_width / 2
|
||||
|
||||
if last_rendered_right is not None and label_left < last_rendered_right + 5:
|
||||
continue
|
||||
|
||||
d.append(draw.Text(
|
||||
str(cat), font["size"],
|
||||
x, label_y,
|
||||
font_family=f"{font['family']}, sans-serif",
|
||||
font_weight=font["weight"],
|
||||
fill=COLORS["axis_text"],
|
||||
text_anchor="middle",
|
||||
dominant_baseline="hanging",
|
||||
))
|
||||
last_rendered_right = label_right
|
||||
|
||||
|
||||
def render_x_axis_numeric(
|
||||
d: draw.Drawing,
|
||||
scale: LinearScale,
|
||||
|
|
@ -200,15 +235,12 @@ def render_x_axis_numeric(
|
|||
ticks = nice_ticks(scale.domain_min, scale.domain_max)
|
||||
|
||||
font = FONTS["axis_label"]
|
||||
|
||||
tick_xs = [scale(t) for t in ticks]
|
||||
|
||||
# Determine if labels need rotation (too close together)
|
||||
needs_rotation = False
|
||||
if len(tick_xs) >= 2:
|
||||
spacings = [abs(tick_xs[i] - tick_xs[i - 1]) for i in range(1, len(tick_xs))]
|
||||
min_spacing = min(spacings)
|
||||
if min_spacing < _ROTATION_THRESHOLD_PX:
|
||||
if min(spacings) < _ROTATION_THRESHOLD_PX:
|
||||
needs_rotation = True
|
||||
|
||||
label_y = plot_bottom + 30
|
||||
|
|
@ -254,11 +286,7 @@ def render_x_axis_numeric(
|
|||
|
||||
|
||||
def _format_tick(value: float, suffix: str) -> str:
|
||||
"""Format a tick value with adaptive precision.
|
||||
|
||||
Shows only as many decimal places as needed to represent the value
|
||||
clearly — avoids truncating small values like -0.0042 to -0.00.
|
||||
"""
|
||||
"""Format a tick value with adaptive precision."""
|
||||
import math
|
||||
if value == 0:
|
||||
return f"0{suffix}"
|
||||
|
|
@ -267,15 +295,12 @@ def _format_tick(value: float, suffix: str) -> str:
|
|||
|
||||
abs_val = abs(value)
|
||||
if abs_val >= 1:
|
||||
# For values >= 1, one or two decimals is sufficient
|
||||
if round(value * 10) == value * 10:
|
||||
return f"{value:.1f}{suffix}"
|
||||
return f"{value:.2f}{suffix}"
|
||||
else:
|
||||
# For values < 1, show enough significant digits (at least 2)
|
||||
magnitude = -math.floor(math.log10(abs_val))
|
||||
decimals = max(magnitude + 1, 2)
|
||||
formatted = f"{value:.{decimals}f}"
|
||||
# Strip trailing zeros after the decimal point
|
||||
formatted = formatted.rstrip("0").rstrip(".")
|
||||
return f"{formatted}{suffix}"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ 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.scale import LinearScale, DateScale, CategoricalScale, nice_ticks, nice_date_ticks
|
||||
from app.renderer.axes import render_y_axis, render_x_axis, render_x_axis_categorical
|
||||
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
|
||||
|
|
@ -22,29 +22,19 @@ from app.renderer.pie import render_pie_series
|
|||
|
||||
|
||||
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
|
||||
"""
|
||||
"""Render a ChartSpec into an SVG string."""
|
||||
has_pie = bool(spec.panels) and all(
|
||||
s.chart_type == "pie"
|
||||
s.chart_type in ("pie", "donut")
|
||||
for panel in spec.panels
|
||||
for s in panel.series
|
||||
)
|
||||
|
||||
# Compute dynamic top padding to prevent title/subtitle/legend overlap.
|
||||
# Panel width is independent of top padding, so estimate it from canvas dims.
|
||||
from app.renderer.legend import legend_row_count
|
||||
from app.models.style import FONTS as _FONTS
|
||||
_margin = LAYOUT["margins"]["top"]
|
||||
_canvas_w = LAYOUT[spec.layout if spec.layout in LAYOUT else "single"]["width"]
|
||||
_est_panel_w = _canvas_w - 2 * _margin - 120 - 30 # approx left+right content padding
|
||||
_layout_key = spec.layout if spec.layout in LAYOUT else "single"
|
||||
_canvas_w = LAYOUT[_layout_key]["width"]
|
||||
_est_panel_w = _canvas_w - 2 * _margin - 120 - 30
|
||||
if spec.layout == "dual_panel":
|
||||
_est_panel_w = (_est_panel_w - LAYOUT["panel_gap"]) / 2
|
||||
|
||||
|
|
@ -53,12 +43,10 @@ def render_chart(spec: ChartSpec, data: dict[str, pd.DataFrame]) -> str:
|
|||
default=1,
|
||||
)
|
||||
_has_subtitle = any(bool(p.subtitle) for p in spec.panels)
|
||||
_TITLE_H = _FONTS["title"]["size"] # 46
|
||||
_SUBTITLE_H = _FONTS["subtitle"]["size"] # 24
|
||||
_LEGEND_ROW_H = _FONTS["legend"]["size"] + 12 # 54
|
||||
_TITLE_H = _FONTS["title"]["size"]
|
||||
_SUBTITLE_H = _FONTS["subtitle"]["size"]
|
||||
_LEGEND_ROW_H = _FONTS["legend"]["size"] + 12
|
||||
_GAP = 10
|
||||
# Space needed above the plot area:
|
||||
# gap from edge + title + gap + [subtitle + gap] + legend rows + gap to plot
|
||||
_pad_top = (
|
||||
15 + _TITLE_H + _GAP
|
||||
+ (_SUBTITLE_H + _GAP if _has_subtitle else 0)
|
||||
|
|
@ -70,63 +58,53 @@ def render_chart(spec: ChartSpec, data: dict[str, pd.DataFrame]) -> str:
|
|||
spec.layout, vertical_legend=has_pie, pad_top=_pad_top
|
||||
)
|
||||
|
||||
d = draw.Drawing(canvas_w, canvas_h)
|
||||
# Explicit pixel units so Chromium's SVG→PDF conversion uses 1px = 0.75pt exactly
|
||||
d = draw.Drawing(canvas_w, canvas_h, id_prefix="c")
|
||||
d.set_pixel_scale(1)
|
||||
|
||||
# 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)
|
||||
panel_data = {panel_key: data[panel_key]} if panel_key in data else data
|
||||
_render_panel(d, panel_spec, bounds, panel_data, layout=spec.layout)
|
||||
|
||||
return d.as_svg()
|
||||
svg = d.as_svg()
|
||||
|
||||
# Inject explicit width/height units into <svg> root so Chromium treats 1 unit = 1px
|
||||
svg = svg.replace(
|
||||
f'width="{canvas_w}" height="{canvas_h}"',
|
||||
f'width="{canvas_w}px" height="{canvas_h}px"',
|
||||
1,
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def _embed_fonts(d: draw.Drawing):
|
||||
"""Embed Roboto Condensed fonts as base64 @font-face in SVG.
|
||||
|
||||
Embeds Light (300), Regular (normal), and Bold (bold) weights so browsers
|
||||
render all chart text in the correct Roboto Condensed variant.
|
||||
CairoSVG/Pango resolves fonts via system fontconfig — see Dockerfile for
|
||||
system font installation that ensures export fidelity.
|
||||
"""
|
||||
"""Embed Roboto Condensed fonts as base64 @font-face — honored by Chromium."""
|
||||
font_css = ""
|
||||
|
||||
# (family_name, path, weight) tuples
|
||||
font_entries = [
|
||||
("Roboto Condensed", FONTS_DIR / "RobotoCondensed-Light.ttf", "300"),
|
||||
("Roboto Condensed", FONTS_DIR / "RobotoCondensed-Regular.ttf", "normal"),
|
||||
("Roboto Condensed", FONTS_DIR / "RobotoCondensed-Bold.ttf", "bold"),
|
||||
]
|
||||
|
||||
for family_name, font_path, weight in font_entries:
|
||||
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: {weight};
|
||||
font-style: normal;
|
||||
}}
|
||||
"""
|
||||
|
||||
font_css += (
|
||||
f"@font-face {{"
|
||||
f"font-family:'{family_name}';"
|
||||
f"src:url('data:font/truetype;base64,{b64}') format('truetype');"
|
||||
f"font-weight:{weight};font-style:normal;"
|
||||
f"}}"
|
||||
)
|
||||
if font_css:
|
||||
style = draw.Raw(f"<defs><style type='text/css'>{font_css}</style></defs>")
|
||||
d.append(style)
|
||||
d.append(draw.Raw(f"<defs><style type='text/css'>{font_css}</style></defs>"))
|
||||
|
||||
|
||||
def _render_pie_panel(
|
||||
|
|
@ -135,21 +113,19 @@ def _render_pie_panel(
|
|||
bounds: PanelBounds,
|
||||
data: dict[str, pd.DataFrame],
|
||||
):
|
||||
"""Render a pie-chart panel (skips axis/scale setup)."""
|
||||
"""Render a pie/donut panel."""
|
||||
df = _resolve_dataframe(data)
|
||||
if df is None or df.empty:
|
||||
return
|
||||
|
||||
palette = COLORS["palette"]
|
||||
labels = []
|
||||
values = []
|
||||
colors = []
|
||||
labels, values, colors = [], [], []
|
||||
is_donut = any(s.chart_type == "donut" for s in panel.series)
|
||||
|
||||
for idx, s in enumerate(panel.series):
|
||||
col = _find_column(df, s.data_column)
|
||||
if col is None:
|
||||
continue
|
||||
# Use last non-null row value as the slice value
|
||||
numeric = pd.to_numeric(df[col], errors="coerce").dropna()
|
||||
if numeric.empty:
|
||||
continue
|
||||
|
|
@ -164,7 +140,8 @@ def _render_pie_panel(
|
|||
cy = (bounds.top + bounds.bottom) / 2
|
||||
radius = min(bounds.right - bounds.left, bounds.bottom - bounds.top) / 2 * 0.85
|
||||
|
||||
render_pie_series(d, labels, values, colors, cx, cy, radius)
|
||||
render_pie_series(d, labels, values, colors, cx, cy, radius,
|
||||
inner_radius_ratio=0.6 if is_donut else 0.0)
|
||||
|
||||
render_title(d, panel.title, bounds.left, bounds.top - 130)
|
||||
if panel.subtitle:
|
||||
|
|
@ -178,42 +155,53 @@ def _render_panel(
|
|||
panel: PanelSpec,
|
||||
bounds: PanelBounds,
|
||||
data: dict[str, pd.DataFrame],
|
||||
layout: str = "single",
|
||||
):
|
||||
"""Render a single chart panel."""
|
||||
# Route pie charts to dedicated renderer
|
||||
if panel.series and all(s.chart_type == "pie" for s in panel.series):
|
||||
# Route pie/donut to dedicated renderer
|
||||
if panel.series and all(s.chart_type in ("pie", "donut") for s in panel.series):
|
||||
_render_pie_panel(d, panel, bounds, data)
|
||||
return
|
||||
|
||||
# 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:
|
||||
use_categorical = panel.x_axis.scale_kind == "category"
|
||||
|
||||
# Build x-axis scale
|
||||
if use_categorical:
|
||||
x_scale, x_categories, date_min, date_max = _build_categorical_scale(df, panel, bounds)
|
||||
if x_scale is None:
|
||||
return
|
||||
else:
|
||||
x_scale, date_min, date_max = _build_date_scale(df, panel, bounds)
|
||||
if x_scale is None:
|
||||
return
|
||||
x_categories = None
|
||||
|
||||
dates = pd.to_datetime(df[date_col])
|
||||
|
||||
# Collect Y values for all series to compute axis range
|
||||
all_y_values = []
|
||||
series_data = {}
|
||||
# Collect Y values
|
||||
all_y_values: list[float] = []
|
||||
series_data: dict[str, dict] = {}
|
||||
palette = COLORS["palette"]
|
||||
|
||||
if not use_categorical:
|
||||
date_col = _find_date_column(df)
|
||||
x_vals_shared = pd.to_datetime(df[date_col]).tolist() if date_col else []
|
||||
else:
|
||||
x_vals_shared = x_categories
|
||||
|
||||
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")
|
||||
x_vals = x_vals_shared
|
||||
series_data[s.label] = {
|
||||
"x": dates.tolist(),
|
||||
"x": x_vals,
|
||||
"y": y_vals.tolist(),
|
||||
"spec": s,
|
||||
"color": palette[
|
||||
(s.color_index if s.color_index is not None else idx) % len(palette)
|
||||
],
|
||||
"color": palette[(s.color_index if s.color_index is not None else idx) % len(palette)],
|
||||
}
|
||||
valid = y_vals.dropna()
|
||||
if not valid.empty:
|
||||
|
|
@ -222,24 +210,81 @@ def _render_panel(
|
|||
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)
|
||||
if layout == "dual_y_axis":
|
||||
_render_dual_y_panel_body(d, panel, bounds, series_data, x_scale, date_min, date_max,
|
||||
use_categorical, x_categories)
|
||||
else:
|
||||
_render_standard_panel_body(d, panel, bounds, series_data, all_y_values, x_scale,
|
||||
date_min, date_max, use_categorical)
|
||||
|
||||
# Add padding
|
||||
if panel.y_axis.min_val is None or panel.y_axis.max_val is None:
|
||||
|
||||
def _build_date_scale(df, panel, bounds):
|
||||
"""Build DateScale honouring spec date_min/date_max. Returns (scale, date_min, date_max) or (None,…)."""
|
||||
date_col = _find_date_column(df)
|
||||
if date_col is None:
|
||||
return None, None, None
|
||||
|
||||
dates = pd.to_datetime(df[date_col])
|
||||
valid_dates = dates.dropna()
|
||||
|
||||
date_min = valid_dates.min().to_pydatetime()
|
||||
date_max = valid_dates.max().to_pydatetime()
|
||||
|
||||
if panel.x_axis.date_min:
|
||||
try:
|
||||
date_min = pd.to_datetime(panel.x_axis.date_min).to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
if panel.x_axis.date_max:
|
||||
try:
|
||||
date_max = pd.to_datetime(panel.x_axis.date_max).to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
x_scale = DateScale(date_min, date_max, bounds.left, bounds.right)
|
||||
return x_scale, date_min, date_max
|
||||
|
||||
|
||||
def _build_categorical_scale(df, panel, bounds):
|
||||
"""Build CategoricalScale from first non-date column. Returns (scale, categories, None, None)."""
|
||||
date_col = _find_date_column(df)
|
||||
cat_col = None
|
||||
for col in df.columns:
|
||||
if col != date_col:
|
||||
cat_col = col
|
||||
break
|
||||
if cat_col is None:
|
||||
cat_col = df.columns[0]
|
||||
|
||||
categories = [str(v) for v in df[cat_col].dropna().tolist()]
|
||||
if not categories:
|
||||
return None, None, None, None
|
||||
|
||||
x_scale = CategoricalScale(categories, bounds.left, bounds.right)
|
||||
return x_scale, categories, None, None
|
||||
|
||||
|
||||
def _compute_y_axis(panel, all_y_values):
|
||||
"""Compute y_min, y_max, y_ticks respecting user-set bounds."""
|
||||
user_min = panel.y_axis.min_val
|
||||
user_max = panel.y_axis.max_val
|
||||
|
||||
y_min = user_min if user_min is not None else min(all_y_values)
|
||||
y_max = user_max if user_max is not None else max(all_y_values)
|
||||
|
||||
if user_min is None or user_max is None:
|
||||
y_range = y_max - y_min
|
||||
if panel.y_axis.min_val is None:
|
||||
if y_range == 0:
|
||||
y_range = abs(y_min) or 1
|
||||
if user_min is None:
|
||||
y_min -= y_range * 0.05
|
||||
if panel.y_axis.max_val is None:
|
||||
if user_max is None:
|
||||
y_max += y_range * 0.05
|
||||
|
||||
# Generate Y ticks
|
||||
if panel.y_axis.tick_interval and panel.y_axis.tick_interval > 0:
|
||||
# Manual loop instead of np.arange to avoid floating-point accumulation
|
||||
interval = panel.y_axis.tick_interval
|
||||
start = y_min if panel.y_axis.min_val is None else panel.y_axis.min_val
|
||||
stop = y_max if panel.y_axis.max_val is None else panel.y_axis.max_val
|
||||
start = y_min
|
||||
stop = y_max
|
||||
y_ticks = []
|
||||
val = start
|
||||
while val <= stop + interval * 0.01:
|
||||
|
|
@ -248,37 +293,96 @@ def _render_panel(
|
|||
else:
|
||||
y_ticks = nice_ticks(y_min, y_max)
|
||||
|
||||
# Only widen auto bounds, never override user-set bounds
|
||||
if y_ticks:
|
||||
if user_min is None:
|
||||
y_min = min(y_min, y_ticks[0])
|
||||
if user_max is None:
|
||||
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()
|
||||
# Clamp ticks to [y_min, y_max] so no tick plots outside the user range
|
||||
y_ticks = [t for t in y_ticks if y_min - 1e-9 <= t <= y_max + 1e-9]
|
||||
|
||||
# Create scales (note: Y is inverted - top of screen is smaller Y pixel value)
|
||||
return y_min, y_max, y_ticks
|
||||
|
||||
|
||||
def _render_standard_panel_body(
|
||||
d, panel, bounds, series_data, all_y_values, x_scale, date_min, date_max, use_categorical
|
||||
):
|
||||
y_min, y_max, y_ticks = _compute_y_axis(panel, all_y_values)
|
||||
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,
|
||||
plot_left=bounds.left, plot_right=bounds.right,
|
||||
ticks=y_ticks, suffix=panel.y_axis.suffix or "", label=panel.y_axis.label,
|
||||
)
|
||||
|
||||
if use_categorical:
|
||||
render_x_axis_categorical(d, x_scale, bounds.bottom)
|
||||
else:
|
||||
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_x_axis(d, x_scale, bounds.bottom, ticks=date_ticks, date_format=panel.x_axis.date_format)
|
||||
|
||||
_render_clip_and_series(d, panel, bounds, series_data, x_scale, y_scale, date_min, date_max, y_min, y_max)
|
||||
_render_labels(d, panel, bounds)
|
||||
|
||||
|
||||
def _render_dual_y_panel_body(
|
||||
d, panel, bounds, series_data, x_scale, date_min, date_max, use_categorical, x_categories
|
||||
):
|
||||
"""Render a panel with two independent Y-axes (left primary, right secondary)."""
|
||||
sec_spec = panel.secondary_y_axis or panel.y_axis
|
||||
|
||||
primary_values = [
|
||||
v
|
||||
for label, sd in series_data.items()
|
||||
if sd["spec"].y_axis_side == "primary"
|
||||
for v in sd["y"]
|
||||
if v is not None and not (isinstance(v, float) and v != v)
|
||||
]
|
||||
secondary_values = [
|
||||
v
|
||||
for label, sd in series_data.items()
|
||||
if sd["spec"].y_axis_side == "secondary"
|
||||
for v in sd["y"]
|
||||
if v is not None and not (isinstance(v, float) and v != v)
|
||||
]
|
||||
|
||||
if not primary_values:
|
||||
primary_values = [v for sd in series_data.values() for v in sd["y"] if v is not None]
|
||||
if not secondary_values:
|
||||
secondary_values = primary_values
|
||||
|
||||
primary_panel = panel.model_copy(update={"y_axis": panel.y_axis})
|
||||
secondary_panel = panel.model_copy(update={"y_axis": sec_spec})
|
||||
|
||||
y_min_p, y_max_p, y_ticks_p = _compute_y_axis(primary_panel, primary_values)
|
||||
y_min_s, y_max_s, y_ticks_s = _compute_y_axis(secondary_panel, secondary_values)
|
||||
|
||||
y_scale_primary = LinearScale(y_min_p, y_max_p, bounds.bottom, bounds.top)
|
||||
y_scale_secondary = LinearScale(y_min_s, y_max_s, bounds.bottom, bounds.top)
|
||||
|
||||
# Primary (left) axis draws gridlines; secondary (right) does not
|
||||
render_y_axis(
|
||||
d, y_scale_primary,
|
||||
plot_left=bounds.left, plot_right=bounds.right,
|
||||
ticks=y_ticks_p, suffix=panel.y_axis.suffix or "", label=panel.y_axis.label,
|
||||
side="left",
|
||||
)
|
||||
render_y_axis(
|
||||
d, y_scale_secondary,
|
||||
plot_left=bounds.left, plot_right=bounds.right,
|
||||
ticks=y_ticks_s, suffix=sec_spec.suffix or "", label=sec_spec.label,
|
||||
side="right",
|
||||
)
|
||||
|
||||
# Define a clipPath so data series cannot render outside the plot area
|
||||
if use_categorical:
|
||||
render_x_axis_categorical(d, x_scale, bounds.bottom)
|
||||
else:
|
||||
date_ticks = nice_date_ticks(date_min, date_max)
|
||||
render_x_axis(d, x_scale, bounds.bottom, ticks=date_ticks, date_format=panel.x_axis.date_format)
|
||||
|
||||
clip_id = f"plot-clip-{int(bounds.left)}-{int(bounds.top)}"
|
||||
d.append(draw.Raw(
|
||||
f'<defs><clipPath id="{clip_id}">'
|
||||
|
|
@ -288,28 +392,43 @@ def _render_panel(
|
|||
))
|
||||
clipped = draw.Group(clip_path=f"url(#{clip_id})")
|
||||
|
||||
# Render shaded fills first (behind lines)
|
||||
for label, sd in series_data.items():
|
||||
s = sd["spec"]
|
||||
y_scale = y_scale_secondary if s.y_axis_side == "secondary" else y_scale_primary
|
||||
_dispatch_series(clipped, s, sd, x_scale, y_scale)
|
||||
|
||||
d.append(clipped)
|
||||
_render_labels(d, panel, bounds)
|
||||
|
||||
|
||||
def _render_clip_and_series(d, panel, bounds, series_data, x_scale, y_scale, date_min, date_max, y_min, y_max):
|
||||
clip_id = f"plot-clip-{int(bounds.left)}-{int(bounds.top)}"
|
||||
d.append(draw.Raw(
|
||||
f'<defs><clipPath id="{clip_id}">'
|
||||
f'<rect x="{bounds.left}" y="{bounds.top}" '
|
||||
f'width="{bounds.width}" height="{bounds.height}"/>'
|
||||
f'</clipPath></defs>'
|
||||
))
|
||||
clipped = draw.Group(clip_path=f"url(#{clip_id})")
|
||||
|
||||
# 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(
|
||||
clipped,
|
||||
sd["x"][:min_len],
|
||||
sd["y"][:min_len],
|
||||
ref_sd["y"][:min_len],
|
||||
clipped, 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)
|
||||
# Annotations (behind series)
|
||||
for ann in panel.annotations:
|
||||
if ann.type == "ellipse":
|
||||
if ann.type == "ellipse" and date_min is not None:
|
||||
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
|
||||
|
|
@ -319,46 +438,47 @@ def _render_panel(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Render data series
|
||||
# Series
|
||||
for label, sd in series_data.items():
|
||||
s = sd["spec"]
|
||||
if s.chart_type == "line":
|
||||
render_line_series(
|
||||
clipped,
|
||||
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(
|
||||
clipped,
|
||||
sd["x"], sd["y"],
|
||||
x_scale, y_scale,
|
||||
color=sd["color"],
|
||||
)
|
||||
_dispatch_series(clipped, sd["spec"], sd, x_scale, y_scale)
|
||||
|
||||
d.append(clipped)
|
||||
|
||||
# Compute vertical positions bottom-up from bounds.top so nothing overlaps.
|
||||
|
||||
def _dispatch_series(d, s: SeriesSpec, sd: dict, x_scale, y_scale):
|
||||
"""Dispatch a single series to the correct renderer. Raises on unsupported types."""
|
||||
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"])
|
||||
elif s.chart_type in ("stacked_bar", "area", "pie", "donut"):
|
||||
# These are either handled upstream (pie/donut) or not yet implemented
|
||||
raise ValueError(
|
||||
f"chart_type='{s.chart_type}' for series '{s.label}' cannot be rendered in a standard panel. "
|
||||
f"Use a dedicated panel with all series of the same type."
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported chart_type '{s.chart_type}' for series '{s.label}'.")
|
||||
|
||||
|
||||
def _render_labels(d, panel, bounds):
|
||||
"""Render title, subtitle, and horizontal legend above the plot area."""
|
||||
from app.renderer.legend import legend_row_count
|
||||
from app.models.style import FONTS as _FONTS_LOCAL
|
||||
_TITLE_H = _FONTS_LOCAL["title"]["size"] # 46
|
||||
_SUBTITLE_H = _FONTS_LOCAL["subtitle"]["size"] # 24
|
||||
_LEGEND_ROW_H = _FONTS_LOCAL["legend"]["size"] + 12 # 54
|
||||
from app.models.style import FONTS as _FL
|
||||
_TITLE_H = _FL["title"]["size"]
|
||||
_SUBTITLE_H = _FL["subtitle"]["size"]
|
||||
_LEGEND_ROW_H = _FL["legend"]["size"] + 12
|
||||
_GAP = 10
|
||||
|
||||
n_legend_rows = legend_row_count(panel.series, bounds.left, bounds.right)
|
||||
_TEXT_HALF = _FL["legend"]["size"] // 2
|
||||
|
||||
_TEXT_HALF = _FONTS_LOCAL["legend"]["size"] // 2 # 21px: half text cap-height
|
||||
|
||||
# Bottom of legend block sits _GAP px above the plot area
|
||||
legend_bottom_row_center_y = bounds.top - _GAP - _TEXT_HALF
|
||||
# Top edge of the top legend row's text
|
||||
legend_block_top_y = legend_bottom_row_center_y - (n_legend_rows - 1) * _LEGEND_ROW_H - _TEXT_HALF
|
||||
|
||||
# Subtitle sits _GAP px above the legend block (if present)
|
||||
if panel.subtitle:
|
||||
subtitle_y = legend_block_top_y - _GAP - _SUBTITLE_H
|
||||
title_y = subtitle_y - _GAP - _TITLE_H
|
||||
|
|
@ -370,8 +490,6 @@ def _render_panel(
|
|||
if panel.subtitle:
|
||||
render_subtitle(d, panel.subtitle, bounds.left, subtitle_y)
|
||||
|
||||
# Legend (horizontal, multi-line if needed, right-aligned above plot)
|
||||
# Pass the computed bottom-row center Y so rows stack correctly
|
||||
render_legend(
|
||||
d, panel.series, bounds.left, bounds.right, bounds.top, bounds.bottom,
|
||||
mode="horizontal", legend_bottom_y=legend_bottom_row_center_y,
|
||||
|
|
@ -379,7 +497,6 @@ def _render_panel(
|
|||
|
||||
|
||||
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:
|
||||
|
|
@ -388,32 +505,21 @@ def _resolve_dataframe(data: dict[str, pd.DataFrame]) -> pd.DataFrame | None:
|
|||
|
||||
|
||||
def _find_date_column(df: pd.DataFrame) -> str | None:
|
||||
"""Auto-detect the date/time column in a DataFrame.
|
||||
|
||||
Mirrors _detect_date_column() in app/data/transformer.py — keep in sync.
|
||||
"""
|
||||
_DATE_EXACT = {
|
||||
"date", "dates", "time", "timestamp", "period", "month", "quarter",
|
||||
"year", "as of", "as_of", "report_date", "reporting_date",
|
||||
"fiscal_year", "observation_date", "obs_date",
|
||||
}
|
||||
# Exact name match
|
||||
for col in df.columns:
|
||||
if str(col).lower() in _DATE_EXACT:
|
||||
return col
|
||||
|
||||
# Substring match
|
||||
for col in df.columns:
|
||||
col_lower = str(col).lower()
|
||||
if "date" in col_lower or "time" in col_lower:
|
||||
return col
|
||||
|
||||
# Dtype check
|
||||
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")
|
||||
|
|
@ -421,24 +527,17 @@ def _find_date_column(df: pd.DataFrame) -> str | None:
|
|||
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
|
||||
|
|
|
|||
78
app/renderer/export.py
Normal file
78
app/renderer/export.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Playwright-based SVG export to PNG and PDF.
|
||||
|
||||
Headless Chromium honours @font-face with base64 data URIs, so Roboto Condensed
|
||||
renders correctly in all exports — no CairoFont-0-0 fallback, no outlined glyphs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_playwright = None
|
||||
_browser = None
|
||||
|
||||
|
||||
async def init_browser() -> None:
|
||||
"""Start persistent Playwright/Chromium instance. Call once at app startup."""
|
||||
global _playwright, _browser
|
||||
from playwright.async_api import async_playwright
|
||||
_playwright = await async_playwright().start()
|
||||
_browser = await _playwright.chromium.launch(headless=True)
|
||||
|
||||
|
||||
async def close_browser() -> None:
|
||||
"""Shut down browser gracefully. Call at app shutdown."""
|
||||
global _playwright, _browser
|
||||
if _browser:
|
||||
await _browser.close()
|
||||
_browser = None
|
||||
if _playwright:
|
||||
await _playwright.stop()
|
||||
_playwright = None
|
||||
|
||||
|
||||
def _html_wrapper(svg_str: str, width: int, height: int) -> str:
|
||||
return (
|
||||
f"<!DOCTYPE html><html><head>"
|
||||
f"<style>*{{margin:0;padding:0;overflow:hidden}}"
|
||||
f"@page{{size:{width}px {height}px;margin:0}}"
|
||||
f"</style></head>"
|
||||
f"<body style='width:{width}px;height:{height}px'>"
|
||||
f"{svg_str}"
|
||||
f"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
async def svg_to_png(svg_str: str, width: int, height: int) -> bytes:
|
||||
"""Render SVG to PNG via headless Chromium. Returns raw PNG bytes."""
|
||||
page = await _browser.new_page(viewport={"width": width, "height": height})
|
||||
try:
|
||||
await page.set_content(_html_wrapper(svg_str, width, height), wait_until="load")
|
||||
# Brief wait for @font-face to finish loading
|
||||
await page.wait_for_timeout(200)
|
||||
png_bytes = await page.screenshot(
|
||||
type="png",
|
||||
clip={"x": 0, "y": 0, "width": width, "height": height},
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
return png_bytes
|
||||
|
||||
|
||||
async def svg_to_pdf(svg_str: str, width: int, height: int) -> bytes:
|
||||
"""Render SVG to PDF via headless Chromium.
|
||||
|
||||
The resulting PDF contains embedded TTF subsets and real text operators,
|
||||
making text fully selectable and editable in InDesign and Illustrator.
|
||||
Stroke weights match the SVG preview exactly (1px SVG = 0.75pt PDF).
|
||||
"""
|
||||
page = await _browser.new_page(viewport={"width": width, "height": height})
|
||||
try:
|
||||
await page.set_content(_html_wrapper(svg_str, width, height), wait_until="load")
|
||||
await page.wait_for_timeout(200)
|
||||
pdf_bytes = await page.pdf(
|
||||
width=f"{width}px",
|
||||
height=f"{height}px",
|
||||
print_background=True,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
return pdf_bytes
|
||||
|
|
@ -4,20 +4,16 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass
|
||||
from app.models.style import LAYOUT
|
||||
|
||||
# Internal content padding added on top of the 15px outer whitespace margin.
|
||||
# These reserve space for title/subtitle, axis labels, and legends.
|
||||
# _PAD_TOP is computed dynamically by render_chart() based on series count;
|
||||
# this value is the fallback minimum.
|
||||
_PAD_TOP_MIN = 160
|
||||
_PAD_BOTTOM = 100 # X-axis labels (42pt) + gap
|
||||
_PAD_LEFT = 120 # Y-axis tick labels + rotated axis title
|
||||
_PAD_RIGHT_STANDARD = 30 # Line/bar: minimal right space (legend is at top)
|
||||
_PAD_RIGHT_PIE = 320 # Pie: space for vertical legend on the right
|
||||
_PAD_BOTTOM = 100
|
||||
_PAD_LEFT = 120
|
||||
_PAD_RIGHT_STANDARD = 30
|
||||
_PAD_RIGHT_PIE = 320
|
||||
_PAD_RIGHT_DUAL_Y = 130 # right-side Y-axis labels for dual_y_axis layout
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelBounds:
|
||||
"""Pixel boundaries for a chart panel's plot area."""
|
||||
left: float
|
||||
right: float
|
||||
top: float
|
||||
|
|
@ -37,22 +33,15 @@ def compute_layout(
|
|||
vertical_legend: bool = False,
|
||||
pad_top: int | None = None,
|
||||
) -> tuple[int, int, list[PanelBounds]]:
|
||||
"""Compute canvas dimensions and panel bounds.
|
||||
|
||||
Args:
|
||||
layout_type: "single" or "dual_panel"
|
||||
vertical_legend: True for pie charts that use a right-side vertical legend
|
||||
|
||||
Returns: (canvas_width, canvas_height, list_of_panel_bounds)
|
||||
"""
|
||||
outer = LAYOUT["margins"]["top"] # All sides are equal (15px)
|
||||
pad_right = _PAD_RIGHT_PIE if vertical_legend else _PAD_RIGHT_STANDARD
|
||||
"""Compute canvas dimensions and panel bounds."""
|
||||
outer = LAYOUT["margins"]["top"]
|
||||
_pad_top = max(pad_top, _PAD_TOP_MIN) if pad_top is not None else _PAD_TOP_MIN
|
||||
|
||||
if layout_type == "dual_panel":
|
||||
dims = LAYOUT["dual_panel"]
|
||||
w, h = dims["width"], dims["height"]
|
||||
gap = LAYOUT["panel_gap"]
|
||||
pad_right = _PAD_RIGHT_PIE if vertical_legend else _PAD_RIGHT_STANDARD
|
||||
|
||||
plot_left = outer + _PAD_LEFT
|
||||
plot_top = outer + _pad_top
|
||||
|
|
@ -74,9 +63,21 @@ def compute_layout(
|
|||
)
|
||||
return w, h, [left_panel, right_panel]
|
||||
|
||||
elif layout_type == "dual_y_axis":
|
||||
dims = LAYOUT["dual_y_axis"]
|
||||
w, h = dims["width"], dims["height"]
|
||||
panel = PanelBounds(
|
||||
left=outer + _PAD_LEFT,
|
||||
right=w - outer - _PAD_RIGHT_DUAL_Y,
|
||||
top=outer + _pad_top,
|
||||
bottom=h - outer - _PAD_BOTTOM,
|
||||
)
|
||||
return w, h, [panel]
|
||||
|
||||
else: # single
|
||||
dims = LAYOUT["single"]
|
||||
w, h = dims["width"], dims["height"]
|
||||
pad_right = _PAD_RIGHT_PIE if vertical_legend else _PAD_RIGHT_STANDARD
|
||||
panel = PanelBounds(
|
||||
left=outer + _PAD_LEFT,
|
||||
right=w - outer - pad_right,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""Pie chart renderer."""
|
||||
"""Pie and donut chart renderer."""
|
||||
|
||||
from __future__ import annotations
|
||||
import math
|
||||
import drawsvg as draw
|
||||
from app.models.style import COLORS
|
||||
from app.models.style import COLORS, FONTS
|
||||
|
||||
|
||||
def render_pie_series(
|
||||
|
|
@ -14,46 +14,78 @@ def render_pie_series(
|
|||
cx: float,
|
||||
cy: float,
|
||||
radius: float,
|
||||
inner_radius_ratio: float = 0.0,
|
||||
):
|
||||
"""Draw a pie chart as SVG arc paths.
|
||||
"""Draw a pie or donut chart.
|
||||
|
||||
Args:
|
||||
d: The SVG drawing.
|
||||
labels: Slice labels.
|
||||
values: Numeric values for each slice (need not sum to any fixed total).
|
||||
colors: Fill color per slice.
|
||||
cx, cy: Centre of the pie.
|
||||
radius: Outer radius in pixels.
|
||||
inner_radius_ratio: 0.0 = solid pie; 0.6 = standard donut ring.
|
||||
"""
|
||||
total = sum(v for v in values if v and v > 0)
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
start_angle = -math.pi / 2 # Start at 12 o'clock
|
||||
inner_r = radius * inner_radius_ratio
|
||||
start_angle = -math.pi / 2 # 12 o'clock
|
||||
|
||||
for i, (label, value, color) in enumerate(zip(labels, values, colors)):
|
||||
font = FONTS["axis_label"]
|
||||
|
||||
for label, value, color in zip(labels, values, colors):
|
||||
if not value or value <= 0:
|
||||
continue
|
||||
|
||||
sweep = 2 * math.pi * (value / total)
|
||||
end_angle = start_angle + sweep
|
||||
|
||||
# Arc path
|
||||
x1 = cx + radius * math.cos(start_angle)
|
||||
y1 = cy + radius * math.sin(start_angle)
|
||||
x2 = cx + radius * math.cos(end_angle)
|
||||
y2 = cy + radius * math.sin(end_angle)
|
||||
x1_out = cx + radius * math.cos(start_angle)
|
||||
y1_out = cy + radius * math.sin(start_angle)
|
||||
x2_out = cx + radius * math.cos(end_angle)
|
||||
y2_out = cy + radius * math.sin(end_angle)
|
||||
|
||||
large_arc = 1 if sweep > math.pi else 0
|
||||
|
||||
if inner_radius_ratio > 0:
|
||||
# Donut: outer arc + inner arc (reversed)
|
||||
x1_in = cx + inner_r * math.cos(end_angle)
|
||||
y1_in = cy + inner_r * math.sin(end_angle)
|
||||
x2_in = cx + inner_r * math.cos(start_angle)
|
||||
y2_in = cy + inner_r * math.sin(start_angle)
|
||||
|
||||
path_d = (
|
||||
f"M {x1_out:.2f} {y1_out:.2f} "
|
||||
f"A {radius} {radius} 0 {large_arc} 1 {x2_out:.2f} {y2_out:.2f} "
|
||||
f"L {x1_in:.2f} {y1_in:.2f} "
|
||||
f"A {inner_r} {inner_r} 0 {large_arc} 0 {x2_in:.2f} {y2_in:.2f} "
|
||||
f"Z"
|
||||
)
|
||||
else:
|
||||
# Solid pie
|
||||
path_d = (
|
||||
f"M {cx} {cy} "
|
||||
f"L {x1:.2f} {y1:.2f} "
|
||||
f"A {radius} {radius} 0 {large_arc} 1 {x2:.2f} {y2:.2f} "
|
||||
f"L {x1_out:.2f} {y1_out:.2f} "
|
||||
f"A {radius} {radius} 0 {large_arc} 1 {x2_out:.2f} {y2_out:.2f} "
|
||||
f"Z"
|
||||
)
|
||||
|
||||
p = draw.Path(d=path_d, fill=color, stroke="#FFFFFF", stroke_width=2)
|
||||
d.append(p)
|
||||
d.append(draw.Path(d=path_d, fill=color, stroke="#FFFFFF", stroke_width="2px"))
|
||||
|
||||
# Percentage label at slice midpoint
|
||||
mid_angle = start_angle + sweep / 2
|
||||
label_r = (radius + inner_r) / 2 if inner_radius_ratio > 0 else radius * 0.65
|
||||
lx = cx + label_r * math.cos(mid_angle)
|
||||
ly = cy + label_r * math.sin(mid_angle)
|
||||
pct = value / total * 100
|
||||
|
||||
if pct >= 4: # skip tiny slice labels
|
||||
d.append(draw.Text(
|
||||
f"{pct:.0f}%",
|
||||
font["size"] * 0.75,
|
||||
lx, ly,
|
||||
font_family=f"{font['family']}, sans-serif",
|
||||
font_weight="bold",
|
||||
fill="#FFFFFF",
|
||||
text_anchor="middle",
|
||||
dominant_baseline="middle",
|
||||
))
|
||||
|
||||
start_angle = end_angle
|
||||
|
|
|
|||
|
|
@ -49,6 +49,28 @@ class DateScale:
|
|||
return self.date_min + timedelta(seconds=seconds)
|
||||
|
||||
|
||||
class CategoricalScale:
|
||||
"""Maps category labels (strings) to evenly spaced pixel centre positions."""
|
||||
|
||||
def __init__(self, categories: list[str], range_min: float, range_max: float):
|
||||
self.categories = list(categories)
|
||||
self.range_min = range_min
|
||||
self.range_max = range_max
|
||||
n = len(self.categories)
|
||||
self._step = (range_max - range_min) / n if n > 0 else 0
|
||||
self._positions = {
|
||||
cat: range_min + self._step * (i + 0.5)
|
||||
for i, cat in enumerate(self.categories)
|
||||
}
|
||||
|
||||
def __call__(self, value: str) -> float:
|
||||
return self._positions.get(str(value), self.range_min)
|
||||
|
||||
@property
|
||||
def step(self) -> float:
|
||||
return self._step
|
||||
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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
|
||||
from app.renderer.scale import LinearScale, DateScale, CategoricalScale
|
||||
|
||||
|
||||
def render_line_series(
|
||||
|
|
@ -32,7 +32,6 @@ def render_line_series(
|
|||
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}"
|
||||
|
|
@ -46,7 +45,7 @@ def render_line_series(
|
|||
d.append(draw.Path(
|
||||
d=path_data,
|
||||
stroke=color,
|
||||
stroke_width=weight,
|
||||
stroke_width=f"{weight}px",
|
||||
fill="none",
|
||||
stroke_dasharray=dash if dash else "none",
|
||||
stroke_linejoin="round",
|
||||
|
|
@ -64,17 +63,13 @@ def render_shaded_fill(
|
|||
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.
|
||||
"""
|
||||
"""Render shaded regions between a data series and a reference series."""
|
||||
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
|
||||
|
|
@ -93,7 +88,6 @@ def render_shaded_fill(
|
|||
if len(px_points) < 2:
|
||||
return
|
||||
|
||||
# Split into segments at crossings and fill each
|
||||
segments_above = []
|
||||
segments_below = []
|
||||
current_above = []
|
||||
|
|
@ -107,14 +101,11 @@ def render_shaded_fill(
|
|||
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)
|
||||
|
|
@ -124,13 +115,9 @@ def render_shaded_fill(
|
|||
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
|
||||
if py_d <= py_r:
|
||||
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
|
||||
else:
|
||||
current_below.append((px, py_d, py_r))
|
||||
|
||||
if current_above:
|
||||
|
|
@ -138,7 +125,6 @@ def render_shaded_fill(
|
|||
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:
|
||||
|
|
@ -146,49 +132,46 @@ def render_shaded_fill(
|
|||
|
||||
|
||||
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",
|
||||
))
|
||||
d.append(draw.Path(d=path_data, fill=fill_color, stroke="none"))
|
||||
|
||||
|
||||
def render_bar_series(
|
||||
d: draw.Drawing,
|
||||
x_values: list[datetime],
|
||||
x_values: list,
|
||||
y_values: list[float],
|
||||
x_scale: DateScale,
|
||||
x_scale, # DateScale | CategoricalScale
|
||||
y_scale: LinearScale,
|
||||
color: str,
|
||||
bar_width: float | None = None,
|
||||
baseline: float = 0,
|
||||
):
|
||||
"""Render a bar chart series."""
|
||||
"""Render a bar chart series. Supports date and categorical x-axes."""
|
||||
if not x_values or not y_values:
|
||||
return
|
||||
|
||||
n = len(x_values)
|
||||
if n < 2:
|
||||
total_width = 40
|
||||
if bar_width is not None:
|
||||
bw = bar_width
|
||||
elif hasattr(x_scale, "step"):
|
||||
# CategoricalScale provides equal-width slots
|
||||
bw = x_scale.step * 0.7
|
||||
else:
|
||||
total_width = abs(x_scale(x_values[1]) - x_scale(x_values[0]))
|
||||
# DateScale: find minimum non-zero gap between consecutive x positions
|
||||
px_list = sorted({x_scale(xv) for xv in x_values})
|
||||
gaps = [px_list[i + 1] - px_list[i] for i in range(len(px_list) - 1) if px_list[i + 1] - px_list[i] > 0]
|
||||
bw = (min(gaps) if gaps else 40) * 0.7
|
||||
|
||||
bw = bar_width or total_width * 0.7
|
||||
baseline_y = y_scale(baseline)
|
||||
|
||||
for xv, yv in zip(x_values, y_values):
|
||||
|
|
@ -197,16 +180,11 @@ def render_bar_series(
|
|||
px = x_scale(xv)
|
||||
py = y_scale(yv)
|
||||
|
||||
if py == baseline_y: # zero-height bar — skip
|
||||
continue
|
||||
|
||||
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",
|
||||
))
|
||||
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",
|
||||
))
|
||||
d.append(draw.Rectangle(x_left, baseline_y, bw, py - baseline_y, fill=color, stroke="none"))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ annotated-doc==0.0.4
|
|||
itsdangerous==2.2.0
|
||||
PyJWT[crypto]==2.8.0
|
||||
annotated-types==0.7.0
|
||||
anthropic==0.84.0
|
||||
anthropic==0.97.0
|
||||
anyio==4.12.1
|
||||
certifi==2026.2.25
|
||||
click==8.3.1
|
||||
|
|
@ -10,7 +10,7 @@ distro==1.9.0
|
|||
docstring_parser==0.17.0
|
||||
drawsvg==2.4.1
|
||||
et_xmlfile==2.0.0
|
||||
fastapi==0.135.1
|
||||
fastapi==0.136.1
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
|
|
@ -20,9 +20,9 @@ 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
|
||||
pandas==3.0.2
|
||||
playwright==1.58.0
|
||||
pydantic==2.13.3
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.22
|
||||
|
|
@ -31,5 +31,4 @@ sniffio==1.3.1
|
|||
starlette==0.52.1
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
uvicorn==0.41.0
|
||||
cairosvg==2.7.1
|
||||
uvicorn==0.46.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue