diff --git a/Dockerfile b/Dockerfile index 5974c29..d3b8aa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/app/ai/brief_interpreter.py b/app/ai/brief_interpreter.py index 095caef..a6b7412 100644 --- a/app/ai/brief_interpreter.py +++ b/app/ai/brief_interpreter.py @@ -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 diff --git a/app/ai/prompts.py b/app/ai/prompts.py index 9b6d518..3e8bdce 100644 --- a/app/ai/prompts.py +++ b/app/ai/prompts.py @@ -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": [], + }], + }, + }, ] diff --git a/app/main.py b/app/main.py index 367ca78..77f4140 100644 --- a/app/main.py +++ b/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 diff --git a/app/models/chart_spec.py b/app/models/chart_spec.py index 4a518b0..d6ed242 100644 --- a/app/models/chart_spec.py +++ b/app/models/chart_spec.py @@ -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] diff --git a/app/models/style.py b/app/models/style.py index ed077aa..48ead91 100644 --- a/app/models/style.py +++ b/app/models/style.py @@ -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, diff --git a/app/renderer/axes.py b/app/renderer/axes.py index 372d23c..598e265 100644 --- a/app/renderer/axes.py +++ b/app/renderer/axes.py @@ -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,78 +36,89 @@ 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=f"{stroke_w}px", + )) - d.append(draw.Line( - plot_left, y, plot_right, y, - stroke=COLORS["gridline"], - stroke_width=stroke_w, - )) - - # 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) - lx = plot_left - 15 - ly = y + + if side == "right": + lx = plot_right + 15 + anchor = "start" + else: + lx = plot_left - 15 + 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 - render_axis_label(d, label, plot_left - 90, mid_y, rotation=-90) + 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) def render_x_axis( @@ -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}" diff --git a/app/renderer/engine.py b/app/renderer/engine.py index a7c100e..06aa4ef 100644 --- a/app/renderer/engine.py +++ b/app/renderer/engine.py @@ -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 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"") - d.append(style) + d.append(draw.Raw(f"")) 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: - return + use_categorical = panel.x_axis.scale_kind == "category" - dates = pd.to_datetime(df[date_col]) + # 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 - # 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: - y_min = min(y_min, y_ticks[0]) - y_max = max(y_max, y_ticks[-1]) + 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, ) - 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, + 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) + + _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'' @@ -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'' + f'' + f'' + )) + 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 diff --git a/app/renderer/export.py b/app/renderer/export.py new file mode 100644 index 0000000..b622001 --- /dev/null +++ b/app/renderer/export.py @@ -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"" + f"" + f"" + f"{svg_str}" + f"" + ) + + +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 diff --git a/app/renderer/layout.py b/app/renderer/layout.py index 48e0c7b..ae150d8 100644 --- a/app/renderer/layout.py +++ b/app/renderer/layout.py @@ -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, diff --git a/app/renderer/pie.py b/app/renderer/pie.py index a58e007..9a8e563 100644 --- a/app/renderer/pie.py +++ b/app/renderer/pie.py @@ -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 - 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"Z" - ) + 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) - p = draw.Path(d=path_d, fill=color, stroke="#FFFFFF", stroke_width=2) - d.append(p) + 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_out:.2f} {y1_out:.2f} " + f"A {radius} {radius} 0 {large_arc} 1 {x2_out:.2f} {y2_out:.2f} " + f"Z" + ) + + 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 diff --git a/app/renderer/scale.py b/app/renderer/scale.py index e11a180..c7aee8f 100644 --- a/app/renderer/scale.py +++ b/app/renderer/scale.py @@ -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: diff --git a/app/renderer/series.py b/app/renderer/series.py index 36bd6bf..59e08fd 100644 --- a/app/renderer/series.py +++ b/app/renderer/series.py @@ -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")) diff --git a/requirements.txt b/requirements.txt index de8bebb..8c6ff6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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