fix: axis labels, bar charts, legend, PDF fonts per Nina's feedback
- layout: _PAD_BOTTOM 100→160, _PAD_LEFT 120→160 to prevent label clipping - axes: fix _format_tick float-comparison bug (1.1→"1.1" not "1.10"); add auto-rotation for categorical x-axis labels when they overflow slot width - engine: tick guard (>20 ticks falls back to nice_ticks); stacked_bar falls back to bar instead of crashing; extend _find_date_column keywords - series: bar width uses 25th-percentile gap instead of min to avoid invisible bars - legend: line series use line swatch with dash pattern; proportional square size - export: @font-face injected in HTML <head> for PDF to prevent T3Font in InDesign - prompts: add categorical bar chart few-shot example; ban stacked_bar; improve tick_interval guidance; add bar chart refine examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d52f088243
commit
d8461076b8
7 changed files with 175 additions and 52 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -202,15 +202,39 @@ 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"])
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]":
|
||||
|
|
|
|||
|
|
@ -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 <head> (not just SVG <defs>) 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"<!DOCTYPE html><html><head>"
|
||||
f"<style>*{{margin:0;padding:0;overflow:hidden}}"
|
||||
f"@page{{size:{width}px {height}px;margin:0}}"
|
||||
f"{font_css}"
|
||||
f"</style></head>"
|
||||
f"<body style='width:{width}px;height:{height}px'>"
|
||||
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 <head> (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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue