File size: 9,038 Bytes
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 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | """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 datetime as _dt
from decimal import Decimal
import numpy as np
import pandas as pd
from src.tools.analytics.descriptive import ColumnNotFoundError
# 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)."""
# --------------------------------------------------------------------------- #
# House style preset — fixed by design, not a planner argument.
#
# Colorway = the dataviz reference categorical palette (8 slots, light mode).
# The slot ORDER is the colorblind-safety mechanism (validated: worst adjacent
# CVD deltaE 24.2 vs the >=12 target) — series take slots in this fixed order,
# never cycled or re-picked. Backgrounds are transparent so the chart inherits
# the FE surface; inks/gridlines are the palette's chrome roles.
# --------------------------------------------------------------------------- #
COLORWAY = [
"#2a78d6", # blue
"#1baf7a", # aqua
"#eda100", # yellow
"#008300", # green
"#4a3aa7", # violet
"#e34948", # red
"#e87ba4", # magenta
"#eb6834", # orange
]
_INK_SECONDARY = "#52514e" # body text
_INK_MUTED = "#898781" # axis titles / tick labels (mode-neutral)
_GRIDLINE = "#e1e0d9" # hairline grid
_BASELINE = "#c3c2b7" # axis line
LAYOUT_PRESET: dict[str, object] = {
"colorway": COLORWAY,
"font": {
"family": 'system-ui, -apple-system, "Segoe UI", sans-serif',
"size": 13,
"color": _INK_SECONDARY,
},
"paper_bgcolor": "rgba(0,0,0,0)",
"plot_bgcolor": "rgba(0,0,0,0)",
"margin": {"t": 48, "r": 16, "b": 48, "l": 56},
"bargap": 0.25, # keeps adjacent bars visually separated (mark-spec gap)
}
# Mark specs: thin marks — 2px lines, 8px markers.
_LINE_STYLE = {"width": 2}
_MARKER_STYLE = {"size": 8}
def _axis(label: str) -> dict[str, object]:
"""Recessive axis chrome + the column name as the axis title."""
return {
"title": {"text": label, "font": {"color": _INK_MUTED}},
"tickfont": {"color": _INK_MUTED},
"gridcolor": _GRIDLINE,
"linecolor": _BASELINE,
"automargin": True,
}
def _clean(value: object) -> object:
"""One JSON-safe scalar: numpy -> Python, NaN/NaT -> None, dates -> ISO strings.
Chart specs are persisted as JSONB and shipped to the FE, so every value in
the trace arrays must survive json.dumps. Upstream `_materialize` already
normalizes fully-numeric columns, but mixed columns can still carry Decimal,
and date columns carry Timestamps.
"""
if value is None or value is pd.NaT:
return None
if isinstance(value, float) and np.isnan(value):
return None
if hasattr(value, "item"): # numpy scalar (incl. np.datetime64 via Timestamp below)
value = value.item()
if isinstance(value, float) and np.isnan(value):
return None
if isinstance(value, Decimal):
return float(value)
if isinstance(value, pd.Timestamp):
return value.isoformat()
if isinstance(value, _dt.datetime | _dt.date):
return value.isoformat()
if isinstance(value, bool | int | float | str):
return value
return str(value)
def _values(col: pd.Series) -> list[object]:
return [_clean(v) for v in col.tolist()]
def _series_label(value: object) -> str:
"""Trace name for one series group; null groups get an explicit label."""
cleaned = _clean(value)
return "(missing)" if cleaned is None else str(cleaned)
# 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)
"""
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)}"
)
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},
}
|