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},
    }