diff --git a/app/ai/prompts.py b/app/ai/prompts.py index 3e8bdce..ba242f5 100644 --- a/app/ai/prompts.py +++ b/app/ai/prompts.py @@ -22,9 +22,9 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use 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, 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) +4. **Bar charts**: Vertical bars for time-based comparisons. Uses scale_kind: "date" (default) on x_axis. Set chart_type: "bar" on each series. +5. **Categorical bar charts**: Bars for non-date categories (credit ratings, sectors, countries, regions). Set x_axis.scale_kind: "category". Use the category column as x, numeric columns as series with chart_type: "bar". +6. **Stacked bar charts**: NOT implemented — always use chart_type: "bar" instead. Never output chart_type: "stacked_bar". 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) @@ -34,7 +34,7 @@ SYSTEM_PROMPT = """You are a PIMCO chart specification generator. You take a use 2. Assign color_index values to differentiate series (0-9) 3. For Y-axis: set suffix="%" if data is percentages, set label for units like "Percentage points" 4. For X-axis: use date_format like "%b %Y" (Aug 2020), "%Y" (2020), or "%b '%y" (Jan '22) -5. Choose tick_interval to produce clean, readable tick marks (e.g., 1 for 0-6 range, 0.5 for -1.0 to 1.5). Aim for 5-8 tick marks. If tick_interval is set, ensure min_val and max_val are multiples of tick_interval. +5. Choose tick_interval to produce clean, readable tick marks (e.g., 1 for 0-6 range, 0.5 for -1.0 to 1.5). Aim for 5-8 tick marks. Verify: (max_val - min_val) / tick_interval must be between 4 and 10. Never set tick_interval so small that it would produce more than 10 ticks. If tick_interval is set, ensure min_val and max_val are multiples of tick_interval. 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 @@ -88,6 +88,14 @@ Your job is to apply ONLY the requested changes to the existing spec and return - "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" +**Bar chart changes:** +- "Change the bar color to green" → set color_index to the green palette index (e.g., 1 for olive, 4 for teal) on the bar series +- "Switch to categorical bars" → set x_axis.scale_kind="category" +- "Switch to date bars" → set x_axis.scale_kind="date" (or remove scale_kind) +- "Add a second bar series" → add another series with chart_type="bar" and a different color_index +- "Make bars for [column]" → set chart_type="bar" and data_column to that column name +- NEVER use chart_type "stacked_bar" — always use "bar" + **Layout changes:** - "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 @@ -144,6 +152,24 @@ FEW_SHOT_EXAMPLES = [ }], }, }, + { + "brief": "Bar chart showing total returns by sector for Q4 2024. Sectors on x-axis, return percentage on y-axis.", + "data_summary": "Sheet 'Sectors': 8 rows x 2 columns\n Columns: Sector, Total_Return\n Sectors: Technology, Financials, Healthcare, Energy, Consumer, Utilities, Materials, Industrials\n Total_Return: min=-3.2, max=8.7", + "spec": { + "layout": "single", + "panels": [{ + "title": "Sector total returns — Q4 2024", + "subtitle": None, + "x_axis": {"scale_kind": "category"}, + "y_axis": {"suffix": "%", "min_val": -5, "max_val": 10, "tick_interval": 5}, + "secondary_y_axis": None, + "series": [ + {"label": "Total Return", "data_column": "Total_Return", "chart_type": "bar", "color_index": 0, "line_style": "solid", "y_axis_side": "primary"}, + ], + "annotations": [], + }], + }, + }, { "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", diff --git a/app/renderer/axes.py b/app/renderer/axes.py index 598e265..7461cea 100644 --- a/app/renderer/axes.py +++ b/app/renderer/axes.py @@ -202,24 +202,48 @@ def render_x_axis_categorical( label_y = plot_bottom + 30 last_rendered_right = None + # Determine if rotation is needed: rotate when labels would collide horizontally + needs_rotation = False + if scale.categories and scale.step > 0: + max_text_w = max(_estimate_text_width(str(c), font["size"]) for c in scale.categories) + if max_text_w > scale.step * 0.9: + needs_rotation = True + 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 needs_rotation: + 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 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", - )) + if needs_rotation: + 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="end", + dominant_baseline="hanging", + transform=f"rotate(-90,{x},{label_y})", + )) + else: + 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 @@ -295,9 +319,9 @@ def _format_tick(value: float, suffix: str) -> str: abs_val = abs(value) if abs_val >= 1: - if round(value * 10) == value * 10: - return f"{value:.1f}{suffix}" - return f"{value:.2f}{suffix}" + # Format to 2dp then strip trailing zeros — avoids float-comparison bugs + formatted = f"{value:.2f}".rstrip("0").rstrip(".") + return f"{formatted}{suffix}" else: magnitude = -math.floor(math.log10(abs_val)) decimals = max(magnitude + 1, 2) diff --git a/app/renderer/engine.py b/app/renderer/engine.py index 06aa4ef..9763e84 100644 --- a/app/renderer/engine.py +++ b/app/renderer/engine.py @@ -290,6 +290,9 @@ def _compute_y_axis(panel, all_y_values): while val <= stop + interval * 0.01: y_ticks.append(round(val, 10)) val += interval + # Guard: if interval is too small (>20 ticks), fall back to nice_ticks + if len(y_ticks) > 20: + y_ticks = nice_ticks(y_min, y_max) else: y_ticks = nice_ticks(y_min, y_max) @@ -454,8 +457,11 @@ def _dispatch_series(d, s: SeriesSpec, sd: dict, x_scale, y_scale): ) 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 + elif s.chart_type == "stacked_bar": + # stacked_bar not yet fully implemented — render as regular bar + render_bar_series(d, sd["x"], sd["y"], x_scale, y_scale, color=sd["color"]) + elif s.chart_type in ("area", "pie", "donut"): + # These are 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." @@ -509,13 +515,14 @@ def _find_date_column(df: pd.DataFrame) -> str | None: "date", "dates", "time", "timestamp", "period", "month", "quarter", "year", "as of", "as_of", "report_date", "reporting_date", "fiscal_year", "observation_date", "obs_date", + "fy", "qtr", "fiscal", "yr", "mo", "week", "monthly", "quarterly", "annual", } for col in df.columns: if str(col).lower() in _DATE_EXACT: return col for col in df.columns: col_lower = str(col).lower() - if "date" in col_lower or "time" in col_lower: + if any(k in col_lower for k in ("date", "time", "period", "quarter", "fiscal", "month", "year")): return col for col in df.columns: if df[col].dtype == "datetime64[ns]": diff --git a/app/renderer/export.py b/app/renderer/export.py index b622001..10ee03e 100644 --- a/app/renderer/export.py +++ b/app/renderer/export.py @@ -2,13 +2,44 @@ 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. + +For PDF: @font-face is duplicated in the HTML (not just SVG ) so +Chromium's PDF engine embeds TrueType subsets (CIDFont) rather than Type 3 fonts, +which prevents InDesign from substituting T3Font. """ from __future__ import annotations +import base64 +from pathlib import Path _playwright = None _browser = None +# Resolve fonts directory relative to this file: app/renderer/ → app/static/fonts/ +_FONTS_DIR = Path(__file__).parent.parent / "static" / "fonts" +_FONT_ENTRIES = [ + ("Roboto Condensed", "RobotoCondensed-Light.ttf", "300"), + ("Roboto Condensed", "RobotoCondensed-Regular.ttf", "normal"), + ("Roboto Condensed", "RobotoCondensed-Bold.ttf", "bold"), +] + + +def _build_font_css() -> str: + """Build @font-face CSS for Roboto Condensed from the bundled TTF files.""" + css = "" + for family, filename, weight in _FONT_ENTRIES: + font_path = _FONTS_DIR / filename + if font_path.exists(): + b64 = base64.b64encode(font_path.read_bytes()).decode("ascii") + css += ( + f"@font-face{{" + f"font-family:'{family}';" + f"src:url('data:font/truetype;base64,{b64}') format('truetype');" + f"font-weight:{weight};font-style:normal;" + f"}}" + ) + return css + async def init_browser() -> None: """Start persistent Playwright/Chromium instance. Call once at app startup.""" @@ -29,11 +60,12 @@ async def close_browser() -> None: _playwright = None -def _html_wrapper(svg_str: str, width: int, height: int) -> str: +def _html_wrapper(svg_str: str, width: int, height: int, font_css: str = "") -> str: return ( f"" f"" f"" f"{svg_str}" @@ -63,10 +95,15 @@ async def svg_to_pdf(svg_str: str, width: int, height: int) -> bytes: 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). + + Font CSS is injected into the HTML (not just SVG defs) so Chromium + embeds TrueType subsets as CIDFont rather than Type 3, preventing InDesign + T3Font substitution. """ + font_css = _build_font_css() 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.set_content(_html_wrapper(svg_str, width, height, font_css), wait_until="load") await page.wait_for_timeout(200) pdf_bytes = await page.pdf( width=f"{width}px", diff --git a/app/renderer/layout.py b/app/renderer/layout.py index ae150d8..3c2e4dc 100644 --- a/app/renderer/layout.py +++ b/app/renderer/layout.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from app.models.style import LAYOUT _PAD_TOP_MIN = 160 -_PAD_BOTTOM = 100 -_PAD_LEFT = 120 +_PAD_BOTTOM = 160 +_PAD_LEFT = 160 _PAD_RIGHT_STANDARD = 30 _PAD_RIGHT_PIE = 320 _PAD_RIGHT_DUAL_Y = 130 # right-side Y-axis labels for dual_y_axis layout diff --git a/app/renderer/legend.py b/app/renderer/legend.py index bac5c0a..a01f61d 100644 --- a/app/renderer/legend.py +++ b/app/renderer/legend.py @@ -44,9 +44,10 @@ def legend_row_count( square = LAYOUT["legend_square_size"] square_to_text_gap = 10 available_width = plot_right - plot_left + swatch_size = max(square, int(font["size"] * 0.45)) item_widths = [ - square + square_to_text_gap + _estimate_text_width(s.label, font["size"]) + swatch_size + square_to_text_gap + _estimate_text_width(s.label, font["size"]) for s in series_list ] @@ -93,7 +94,7 @@ def render_legend( items = [] for s in series_list: color = palette[s.color_index % len(palette)] if s.color_index is not None else palette[0] - items.append((s.label, color)) + items.append((s.label, color, s.chart_type, s.line_style)) if not items: return @@ -105,9 +106,44 @@ def render_legend( _render_vertical(d, items, font, square, plot_right, plot_top) +def _render_swatch( + d: draw.Drawing, + x: float, + y: float, + color: str, + chart_type: str, + line_style: str, + swatch_size: int, +): + """Render a legend swatch: line segment for line series, filled square for bars/pie.""" + if chart_type == "line": + # Horizontal line segment matching the series line style + line_len = max(swatch_size, 24) + dash = None + if line_style == "dashed": + dash = LAYOUT["dash_pattern"] + elif line_style == "dotted": + dash = LAYOUT["dot_pattern"] + d.append(draw.Line( + x, y, x + line_len, y, + stroke=color, + stroke_width=f"{LAYOUT['line_weight']}px", + stroke_dasharray=dash if dash else "none", + stroke_linecap="round", + )) + return line_len + else: + d.append(draw.Rectangle( + x, y - swatch_size / 2, + swatch_size, swatch_size, + fill=color, + )) + return swatch_size + + def _render_horizontal( d: draw.Drawing, - items: list[tuple[str, str]], + items: list[tuple[str, str, str, str]], font: dict, square: int, plot_left: float, @@ -121,11 +157,13 @@ def _render_horizontal( """ square_to_text_gap = 10 available_width = plot_right - plot_left + # Proportional swatch size aligned to font cap height + swatch_size = max(square, int(font["size"] * 0.45)) - # Compute each item's total pixel width: square + gap + text + # Compute each item's total pixel width: swatch + gap + text item_widths = [ - square + square_to_text_gap + _estimate_text_width(label, font["size"]) - for label, _ in items + swatch_size + square_to_text_gap + _estimate_text_width(label, font["size"]) + for label, _, _, _ in items ] # Pack items into rows, each row limited to available_width @@ -156,19 +194,14 @@ def _render_horizontal( x = plot_right - row_total for i in row_indices: - label, color = items[i] + label, color, chart_type, line_style = items[i] iw = item_widths[i] - # Colored square, vertically centred on the text baseline - d.append(draw.Rectangle( - x, y - square / 2, - square, square, - fill=color, - )) + rendered_w = _render_swatch(d, x, y, color, chart_type, line_style, swatch_size) # Label text d.append(draw.Text( label, font["size"], - x + square + square_to_text_gap, y, + x + rendered_w + square_to_text_gap, y, font_family=f"{font['family']}, sans-serif", font_weight=font["weight"], fill=COLORS["legend_text"], @@ -179,7 +212,7 @@ def _render_horizontal( def _render_vertical( d: draw.Drawing, - items: list[tuple[str, str]], + items: list[tuple[str, str, str, str]], font: dict, square: int, plot_right: float, @@ -188,22 +221,17 @@ def _render_vertical( """Stacked column of legend items, placed to the right of the plot area.""" gap = LAYOUT["legend_vertical_gap"] square_to_text_gap = 10 + swatch_size = max(square, int(font["size"] * 0.45)) x = plot_right + 20 y = plot_top item_height = font["size"] + gap - for label, color in items: - # Colored square - d.append(draw.Rectangle( - x, y - square / 2, - square, square, - fill=color, - )) - # Label text + for label, color, chart_type, line_style in items: + rendered_w = _render_swatch(d, x, y, color, chart_type, line_style, swatch_size) d.append(draw.Text( label, font["size"], - x + square + square_to_text_gap, y, + x + rendered_w + square_to_text_gap, y, font_family=f"{font['family']}, sans-serif", font_weight=font["weight"], fill=COLORS["legend_text"], diff --git a/app/renderer/series.py b/app/renderer/series.py index 59e08fd..5092214 100644 --- a/app/renderer/series.py +++ b/app/renderer/series.py @@ -167,10 +167,11 @@ def render_bar_series( # CategoricalScale provides equal-width slots bw = x_scale.step * 0.7 else: - # DateScale: find minimum non-zero gap between consecutive x positions + # DateScale: use 25th-percentile gap to avoid tiny bars from irregular spacing 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 + gaps = sorted(px_list[i + 1] - px_list[i] for i in range(len(px_list) - 1) if px_list[i + 1] - px_list[i] > 0) + ref_gap = gaps[len(gaps) // 4] if gaps else 40 + bw = ref_gap * 0.7 baseline_y = y_scale(baseline)