File size: 6,961 Bytes
5a60e93 535f173 5a60e93 535f173 5a60e93 f282b15 5a60e93 f282b15 5a60e93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """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 "<col>_<fn>" (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 "<y> by <x>".
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},
}
|