"""render_chart — declarative chart-spec builder (SPINE_V2_PLAN §4.1, S2).
First tool of the `render_*` family: turns an already-materialized DataFrame into
a Plotly-conformant JSON spec wrapped in the `dataeyond.chart.v1` envelope
(SPINE_V2_PLAN §4.2). The FE renders it with plotly.js
(`Plotly.newPlot(el, spec.plotly.data, spec.plotly.layout)`).
Deliberately NOT a code generator and NOT a plotly import (locked decision,
DEV_PLAN deferred row #26): the spec is a hand-built dict, so no generated code
ever executes and no new dependency lands. Style (colors, axis chrome) is the
fixed module preset below — never a planner/LLM decision (CoDA's "Design
Explorer" phase collapsed to a constant).
Pattern A, same as the `analyze_*` family: `data` is resolved to a DataFrame by
the invoker from an upstream table-kind output; this function never fetches.
"""
from __future__ import annotations
import pandas as pd
from src.tools.analytics.charts import (
_LINE_STYLE,
_MARKER_STYLE,
LAYOUT_PRESET,
_axis,
_series_label,
_values,
)
from src.tools.analytics.descriptive import ColumnNotFoundError
# The shared house-style kit (colorway, layout preset, JSON-safe coercion, axis
# chrome) now lives in `charts.py` so `render_chart` and the analytical tools'
# auto-charts stay visually identical. render_chart-specific bits stay here.
# v1 chart types (SPINE_V2_PLAN §4.1). `pie` maps x -> labels, y -> values.
CHART_TYPES = ("bar", "line", "pie", "scatter")
class UnsupportedChartTypeError(ValueError):
"""The requested chart_type is not in CHART_TYPES (error_code UNSUPPORTED_CHART_TYPE)."""
# Prompt-style description read by the Planner to decide WHEN to pick this tool.
DESCRIPTION = """\
Summary: Build a chart specification (bar, line, pie, scatter) from an \
already-retrieved table, for the app to render. Maps one x column and one y \
column (pie: x -> slice labels, y -> slice values); an optional `series` column \
splits bar/line/scatter into one trace per category. Produces a spec only — it \
computes no numbers.
USE WHEN the user EXPLICITLY asks to see a chart — "plot", "chart", "graph", \
"visualize", "diagram" (ID: "grafik", "diagram", "plot", "visualisasikan", \
"buatkan grafik/diagram"). ALWAYS a tail step: `data` consumes the output of an \
upstream task that yields a table (retrieve_data or a table-kind analyze_*).
DON'T USE WHEN:
- the user did not explicitly ask for a chart -> answer with tables/stats only \
(never add a speculative chart)
- the numbers still need computing -> run the analyze_*/retrieve step first, \
then chart its table output
- the upstream output is stats- or series-kind -> feed it a table-kind task \
(e.g. a grouped retrieve or analyze_aggregate)
Example questions:
- "plot revenue by region as a bar chart"
- "buatkan grafik penjualan per kategori"
- "show a pie chart of orders by sales channel"
- "visualize monthly revenue" (chain a month-grouped table first, then line)
"""
# analyze_aggregate renames a measure column to "
_" (e.g. pa_percent ->
# pa_percent_mean). A chart planned before that rename references the original
# name, so resolve it to the aggregated column when the exact name is absent.
_AGG_SUFFIXES = ("mean", "sum", "count", "min", "max", "median", "nunique", "avg")
def _resolve_col(name: str, columns) -> str:
if name in columns:
return name
hits = [c for c in columns if c in {f"{name}_{s}" for s in _AGG_SUFFIXES}]
return hits[0] if len(hits) == 1 else name
def render_chart(
df: pd.DataFrame,
chart_type: str,
x: str,
y: str,
series: str | None = None,
title: str | None = None,
) -> dict[str, object]:
"""Build a `dataeyond.chart.v1` envelope from a table (SPINE_V2_PLAN §4.2).
Args:
df: already-materialized data (the invoker resolves `data` upstream).
x: column for the x axis (pie: slice labels).
y: column for the y axis (pie: slice values).
series: optional column splitting bar/line/scatter into one trace per
distinct value (fixed-order colorway slots). Ignored for pie.
title: chart title; defaults to " by ".
Returns:
The envelope dict: {schema, chart_type, title, plotly: {data, layout}}.
Raises:
UnsupportedChartTypeError: chart_type not in CHART_TYPES.
ColumnNotFoundError: x, y, or series absent from the data.
"""
if chart_type not in CHART_TYPES:
raise UnsupportedChartTypeError(
f"unsupported chart_type '{chart_type}'; supported: {list(CHART_TYPES)}"
)
# Resolve chart columns to their aggregated names (pa_percent -> pa_percent_mean)
# so a retrieve_data -> analyze_aggregate -> render_chart chain works.
x = _resolve_col(x, df.columns)
y = _resolve_col(y, df.columns)
if series is not None:
series = _resolve_col(series, df.columns)
needed = [x, y] if chart_type == "pie" or series is None else [x, y, series]
missing = [c for c in needed if c not in df.columns]
if missing:
raise ColumnNotFoundError(f"columns not found: {missing}")
chart_title = title or f"{y} by {x}"
if chart_type == "pie":
traces: list[dict[str, object]] = [
{"type": "pie", "labels": _values(df[x]), "values": _values(df[y])}
]
else:
plotly_type = "bar" if chart_type == "bar" else "scatter"
mode = {"line": "lines+markers", "scatter": "markers"}.get(chart_type)
def _trace(frame: pd.DataFrame, name: str) -> dict[str, object]:
t: dict[str, object] = {
"type": plotly_type,
"x": _values(frame[x]),
"y": _values(frame[y]),
"name": name,
}
if mode is not None:
t["mode"] = mode
t["line"] = dict(_LINE_STYLE)
t["marker"] = dict(_MARKER_STYLE)
return t
if series is None:
traces = [_trace(df, y)]
else:
# sort=True keeps trace order (and therefore colorway slot
# assignment) deterministic across runs of the same data.
traces = [
_trace(group, _series_label(value))
for value, group in df.groupby(series, dropna=False, sort=True)
]
layout: dict[str, object] = {
**LAYOUT_PRESET,
"title": {"text": chart_title},
# Legend only when there is more than one thing to identify: multi-series
# traces, or pie slices (a single named series is titled, not legended).
"showlegend": len(traces) > 1 or chart_type == "pie",
}
if chart_type != "pie":
layout["xaxis"] = _axis(x)
layout["yaxis"] = _axis(y)
return {
"schema": "dataeyond.chart.v1",
"chart_type": chart_type,
"title": chart_title,
"plotly": {"data": traces, "layout": layout},
}