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