From 2aca3bcf7852b3b99105f001effd9b28fce80705 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:57:44 +0000 Subject: [PATCH 1/7] Iteration 264: Add Styler (DataFrame.style API) Port pandas.DataFrame.style / pandas.io.formats.style.Styler to TypeScript. New module src/stats/style.ts provides: - Styler class with fluent chaining API - dataFrameStyle(df) factory function - format / formatIndex / setPrecision / setNaRep - apply (axis-wise) / applymap / map (element-wise) - highlightMax / highlightMin / highlightNull / highlightBetween - backgroundGradient / textGradient / barChart - setCaption / setTableStyles / setTableAttributes / setProperties - hide (index or columns) - toHtml / render / toLatex - exportStyles / clearStyles Tests: 50+ test cases including unit, property-based (fast-check). Playground: playground/style.html with complete API reference. Run: https://github.com/githubnext/tsessebe/actions/runs/24838264967 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/index.html | 5 + playground/style.html | 416 ++++++++++++ src/index.ts | 14 + src/stats/index.ts | 14 + src/stats/style.ts | 1269 +++++++++++++++++++++++++++++++++++++ tests/stats/style.test.ts | 594 +++++++++++++++++ 6 files changed, 2312 insertions(+) create mode 100644 playground/style.html create mode 100644 src/stats/style.ts create mode 100644 tests/stats/style.test.ts diff --git a/playground/index.html b/playground/index.html index 0597f9f2..62e78e60 100644 --- a/playground/index.html +++ b/playground/index.html @@ -449,6 +449,11 @@

assertSeriesEqual / assertFrameEqual / assertIndexEqual โ€” rich assertion helpers for use in test suites. Numeric tolerance, checkLike column-order mode, dtype checks, AssertionError with detailed diff messages. Mirrors pandas.testing.

โœ… Complete
+
+

๐ŸŽจ Styler โ€” DataFrame Style API

+

dataFrameStyle(df) ยท highlightMax / highlightMin / highlightNull / highlightBetween ยท backgroundGradient / textGradient ยท barChart ยท format / formatIndex ยท apply / applymap / map ยท setCaption / setTableStyles / hide ยท toHtml / toLatex. Mirrors pandas.DataFrame.style (Styler).

+
โœ… Complete
+
diff --git a/playground/style.html b/playground/style.html new file mode 100644 index 00000000..cbe1d2a3 --- /dev/null +++ b/playground/style.html @@ -0,0 +1,416 @@ + + + + + + tsb โ€” DataFrame Styler + + + +
+

tsb โ€” Styler

+

dataFrameStyle ยท highlightMax ยท backgroundGradient ยท barChart ยท toHtml ยท toLatex ยท mirrors pandas.DataFrame.style

+
+ +
+ +
+

Overview

+

+ The Styler class provides a fluent API for applying CSS styles to a + DataFrame and rendering the result as styled HTML โ€” directly analogous to + pandas.DataFrame.style (the pandas.io.formats.style.Styler + class). +

+

+ All methods return this, so you can chain them: +

+
import { DataFrame, dataFrameStyle } from "tsb";
+
+const df = DataFrame.fromColumns({
+  product: ["Widget A", "Widget B", "Widget C"],
+  sales:   [120, 340, 85],
+  profit:  [30, 95, -10],
+});
+
+const html = dataFrameStyle(df)
+  .highlightMax({ color: "lightgreen", subset: ["sales", "profit"] })
+  .highlightMin({ color: "salmon",     subset: ["sales", "profit"] })
+  .highlightNull("orange")
+  .backgroundGradient({ cmap: "Blues", subset: ["sales"] })
+  .format((v) => typeof v === "number" ? v.toLocaleString() : String(v))
+  .setCaption("Q3 Product Summary")
+  .setTableStyles([
+    { selector: "th", props: { "background-color": "#343a40", color: "#fff" } },
+  ])
+  .toHtml();
+
+ +
+

Import

+
import {
+  Styler,
+  dataFrameStyle,
+} from "tsb";
+
+// Type imports
+import type {
+  CellProps,
+  TableStyle,
+  StyleRecord,
+  ValueFormatter,
+  ColSubset,
+  AxisStyleFn,
+  ElementStyleFn,
+  HighlightOptions,
+  HighlightBetweenOptions,
+  GradientOptions,
+  BarOptions,
+} from "tsb";
+
+ +
+

Factory function

+
const styler = dataFrameStyle(df);
+// Equivalent to: new Styler(df)
+// Equivalent to: df.style  (in pandas)
+

The styler.data property returns the underlying DataFrame.

+
+ +
+

Formatting

+ +

.format(formatter, subset?, naRep?)

+
// Function formatter
+dataFrameStyle(df).format((v) => typeof v === "number" ? `$${v.toFixed(2)}` : String(v));
+
+// Template string (replaces {v} with the cell value)
+dataFrameStyle(df).format("{v}%", ["pct_col"]);
+
+// Only format specific columns
+dataFrameStyle(df).format(
+  (v) => typeof v === "number" ? v.toFixed(1) : String(v),
+  ["a", "b"],   // ColSubset
+);
+
+// null formatter resets to default
+dataFrameStyle(df).format(null);
+ +

.formatIndex(formatter)

+
// Format the row index labels
+dataFrameStyle(df).formatIndex((v) => `[${String(v)}]`);
+ +

.setPrecision(n)

+
// Show 2 decimal places by default
+dataFrameStyle(df).setPrecision(2);
+ +

.setNaRep(str)

+
// Show "N/A" for missing values
+dataFrameStyle(df).setNaRep("N/A");
+
+ +
+

Custom CSS styles

+ +

.apply(fn, axis?, subset?)

+

+ Apply a column-wise (axis=0) or row-wise (axis=1) function. + The function receives an array of values and must return an array of CSS strings of the + same length. +

+
// Highlight values > 100 in each column
+dataFrameStyle(df).apply(
+  (vals) => vals.map((v) => typeof v === "number" && v > 100 ? "color: red;" : ""),
+  0,         // axis=0 (per-column); use 1 for per-row
+  ["sales"], // optional column subset
+);
+
+// String axis aliases
+dataFrameStyle(df).apply(fn, "index");   // same as axis=0
+dataFrameStyle(df).apply(fn, "columns"); // same as axis=1
+ +

.applymap(fn, subset?) / .map(fn, subset?)

+

Apply an element-wise function (pandas โ‰ฅ 2.1 renamed applymap โ†’ map; both are supported).

+
dataFrameStyle(df).applymap((v) =>
+  typeof v === "number" && v < 0 ? "color: red; font-weight: bold;" : ""
+);
+
+// .map() is an alias
+dataFrameStyle(df).map((v) => v === null ? "background-color: #ffeeee;" : "");
+ +

.setProperties(props, subset?)

+
// Apply CSS to all cells (or a subset)
+dataFrameStyle(df).setProperties({ "font-weight": "bold", color: "navy" }, ["important"]);
+
+ +
+

Built-in highlights

+ +
+
+

.highlightMax(options?)

+
dataFrameStyle(df).highlightMax({
+  color: "lightgreen",  // CSS color
+  subset: ["a", "b"],   // columns to consider
+  axis: 0,              // 0=per-col, 1=per-row, null=table-wide
+});
+
+
+

.highlightMin(options?)

+
dataFrameStyle(df).highlightMin({
+  color: "salmon",
+  axis: 1,  // highlight min in each row
+});
+
+
+ +

.highlightNull(color?, subset?)

+
dataFrameStyle(df).highlightNull("orange");    // default color: "red"
+ +

.highlightBetween(options?)

+
dataFrameStyle(df).highlightBetween({
+  left: 0,       // lower bound (null = no lower bound)
+  right: 100,    // upper bound (null = no upper bound)
+  inclusive: "both",  // "both" | "left" | "right" | "neither"
+  color: "lightyellow",
+  subset: ["score"],
+});
+
+ +
+

Color gradients

+ +

.backgroundGradient(options?)

+
dataFrameStyle(df).backgroundGradient({
+  cmap: "Blues",    // colormap name or "colorA:colorB" shorthand
+  low: 0,           // clip low fraction  (default: 0)
+  high: 1,          // clip high fraction (default: 1)
+  axis: 0,          // 0=per-col, 1=per-row, null=table-wide
+  subset: ["a"],    // column subset
+  textColor: true,  // auto-choose dark/light text for contrast
+  vmin: null,       // override minimum for normalization
+  vmax: null,       // override maximum
+});
+ +

Available colormaps:

+

+ Blues ยท Greens ยท Reds ยท Oranges ยท + Purples ยท PuBu ยท YlOrRd ยท RdYlGn ยท + coolwarm ยท "#hex1:#hex2" (custom) +

+ +

.textGradient(options?)

+
// Same as backgroundGradient but applies to text color instead
+dataFrameStyle(df).textGradient({ cmap: "RdYlGn" });
+
+ +
+

Bar charts

+ +

.barChart(options?)

+

Renders inline bar charts using CSS linear-gradient.

+
dataFrameStyle(df).barChart({
+  color: "#4285f4",            // single color or [negColor, posColor]
+  width: 100,                  // bar cell width %
+  align: "left",               // "left" | "mid" | "zero"
+  subset: ["sales"],           // columns
+  vmin: null, vmax: null,      // normalization bounds
+});
+
+ +
+

Table decoration

+ +

.setCaption(text)

+
dataFrameStyle(df).setCaption("Q3 Sales Report");
+ +

.setTableStyles(styles)

+
dataFrameStyle(df).setTableStyles([
+  { selector: "table",        props: { border: "2px solid #999" } },
+  { selector: "th",           props: { "background-color": "#343a40", color: "#fff" } },
+  { selector: "tr:hover td",  props: { "background-color": "#f5f5f5" } },
+  // props as array of pairs is also supported:
+  { selector: "td",           props: [["padding", "6px 12px"], ["font-size", "13px"]] },
+]);
+ +

.setTableAttributes(attrs)

+
dataFrameStyle(df).setTableAttributes('class="report-table" id="q3-report"');
+ +

.hide(axis?, subset?)

+
dataFrameStyle(df).hide(0);                // hide row index
+dataFrameStyle(df).hide("index");           // same
+dataFrameStyle(df).hide(1, ["col_a"]);      // hide specific columns
+dataFrameStyle(df).hide("columns", ["b"]); // same
+
+ +
+

Rendering

+ +

.toHtml(uuid?) / .render(uuid?)

+
const html: string = dataFrameStyle(df)
+  .highlightMax()
+  .toHtml();         // .render() is an alias
+
+// Inject into a page
+document.getElementById("output").innerHTML = html;
+ +

.toLatex(environment?, hrules?)

+
const latex: string = dataFrameStyle(df)
+  .setCaption("Results")
+  .toLatex("tabular", true);  // environment, hrules
+

+ Note: CSS styles are not translated to LaTeX โ€” only the data and caption are rendered. +

+
+ +
+

Utilities

+ +

.exportStyles()

+
const records: StyleRecord[] = dataFrameStyle(df)
+  .highlightMax()
+  .exportStyles();
+// [{ row: 0, col: 1, css: "background-color: yellow;" }, ...]
+ +

.clearStyles()

+
// Remove all accumulated styles (keeps caption, hide state, precision)
+styler.clearStyles();
+
+ +
+

API reference

+
+
dataFrameStyle(df)
+
Create a new Styler wrapping df. Equivalent to df.style in pandas.
+ +
.format(formatter, subset?, naRep?)
+
Format cell values with a function or template string.
+ +
.formatIndex(formatter)
+
Format row index labels.
+ +
.setPrecision(n)
+
Set default float precision (default: 6).
+ +
.setNaRep(str)
+
Set representation for null/NaN values (default: "").
+ +
.apply(fn, axis?, subset?)
+
Column-wise or row-wise CSS function. Returns array of CSS strings.
+ +
.applymap(fn, subset?) / .map(fn, subset?)
+
Element-wise CSS function. Returns a single CSS string per value.
+ +
.setProperties(props, subset?)
+
Set CSS properties for all cells or a subset.
+ +
.highlightMax(options?)
+
Highlight maximum values. Options: color, subset, axis.
+ +
.highlightMin(options?)
+
Highlight minimum values.
+ +
.highlightNull(color?, subset?)
+
Highlight null/undefined/NaN values.
+ +
.highlightBetween(options?)
+
Highlight values in a numeric range. Options: left, right, inclusive, color, subset.
+ +
.backgroundGradient(options?)
+
Background color gradient. Options: cmap, low, high, axis, subset, textColor, vmin, vmax.
+ +
.textGradient(options?)
+
Text color gradient (same options as backgroundGradient).
+ +
.barChart(options?)
+
Inline bar chart. Options: color, width, align, subset, vmin, vmax.
+ +
.setCaption(text)
+
Set a <caption> on the rendered table.
+ +
.setTableStyles(styles)
+
Set CSS selector rules via a <style> block prepended to the output.
+ +
.setTableAttributes(attrs)
+
Set extra HTML attributes on the <table> tag.
+ +
.hide(axis?, subset?)
+
Hide the index (axis=0) or specific columns (axis=1, subset).
+ +
.clearStyles()
+
Reset all style applications, formatters, and table styles.
+ +
.exportStyles()
+
Return the accumulated per-cell styles as StyleRecord[].
+ +
.toHtml(uuid?) / .render(uuid?)
+
Render the styled DataFrame as an HTML string.
+ +
.toLatex(environment?, hrules?)
+
Render as a LaTeX tabular environment.
+
+
+ +
+

Pandas equivalence table

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
tsbpandas equivalent
dataFrameStyle(df)df.style
.format(fn).format(fn)
.formatIndex(fn).format_index(fn)
.apply(fn, axis).apply(fn, axis=0)
.applymap(fn).applymap(fn) / .map(fn)
.map(fn).map(fn) (โ‰ฅ pandas 2.1)
.highlightMax(opts).highlight_max()
.highlightMin(opts).highlight_min()
.highlightNull(color).highlight_null()
.highlightBetween(opts).highlight_between()
.backgroundGradient(opts).background_gradient()
.textGradient(opts).text_gradient()
.barChart(opts).bar()
.setCaption(text).set_caption(text)
.setProperties(props).set_properties(**kwargs)
.setTableStyles(styles).set_table_styles(styles)
.setTableAttributes(attrs).set_table_attributes(attrs)
.hide(0).hide(axis="index")
.hide(1, subset).hide(subset, axis="columns")
.exportStyles().export()
.clearStyles().clear()
.toHtml() / .render().to_html() / .render()
.toLatex().to_latex()
+
+ +
+ + diff --git a/src/index.ts b/src/index.ts index 572ed113..411cb787 100644 --- a/src/index.ts +++ b/src/index.ts @@ -668,3 +668,17 @@ export type { AssertFrameEqualOptions, AssertIndexEqualOptions, } from "./testing/index.ts"; +export { Styler, dataFrameStyle } from "./stats/index.ts"; +export type { + CellProps, + TableStyle, + StyleRecord, + ValueFormatter, + ColSubset, + AxisStyleFn, + ElementStyleFn, + HighlightOptions, + HighlightBetweenOptions, + GradientOptions, + BarOptions, +} from "./stats/index.ts"; diff --git a/src/stats/index.ts b/src/stats/index.ts index d5426bd1..cd2c46fe 100644 --- a/src/stats/index.ts +++ b/src/stats/index.ts @@ -486,3 +486,17 @@ export type { ResampleAggFn, ResampleOptions, } from "./resample.ts"; +export { Styler, dataFrameStyle } from "./style.ts"; +export type { + CellProps, + TableStyle, + StyleRecord, + ValueFormatter, + ColSubset, + AxisStyleFn, + ElementStyleFn, + HighlightOptions, + HighlightBetweenOptions, + GradientOptions, + BarOptions, +} from "./style.ts"; diff --git a/src/stats/style.ts b/src/stats/style.ts new file mode 100644 index 00000000..56e3fbe3 --- /dev/null +++ b/src/stats/style.ts @@ -0,0 +1,1269 @@ +/** + * style โ€” pandas-compatible DataFrame Styler API. + * + * Provides a fluent `Styler` class that mirrors `pandas.io.formats.style.Styler`, + * accessed via `df.style` in pandas or `dataFrameStyle(df)` here. + * + * Supported methods: + * - {@link Styler.format} โ€” format cell values (function or format string) + * - {@link Styler.formatIndex} โ€” format index labels + * - {@link Styler.apply} โ€” apply column/row-wise CSS styling function + * - {@link Styler.applymap} โ€” apply element-wise CSS styling function (alias: map) + * - {@link Styler.map} โ€” pandas 2.1+ alias for applymap + * - {@link Styler.highlightMax} โ€” highlight maximum values + * - {@link Styler.highlightMin} โ€” highlight minimum values + * - {@link Styler.highlightNull} โ€” highlight null/undefined values + * - {@link Styler.highlightBetween} โ€” highlight values in a range + * - {@link Styler.backgroundGradient} โ€” apply background color gradient + * - {@link Styler.textGradient} โ€” apply text color gradient + * - {@link Styler.barChart} โ€” inline bar chart via CSS + * - {@link Styler.setCaption} โ€” set table caption + * - {@link Styler.setProperties} โ€” set CSS properties for a subset of cells + * - {@link Styler.setTableStyles} โ€” set table-level CSS styles + * - {@link Styler.setTableAttributes} โ€” set HTML table attributes + * - {@link Styler.hide} โ€” hide index or specific columns + * - {@link Styler.setPrecision} โ€” default decimal precision + * - {@link Styler.setNaRep} โ€” representation for missing values + * - {@link Styler.toHtml} โ€” render as HTML string + * - {@link Styler.render} โ€” alias for toHtml + * - {@link Styler.toLatex} โ€” render as LaTeX string + * - {@link Styler.exportStyles} โ€” export accumulated styles + * - {@link Styler.clearStyles} โ€” clear all accumulated styles + * - {@link dataFrameStyle} โ€” factory function + * + * @module + */ + +import type { DataFrame } from "../core/index.ts"; +import type { Label, Scalar } from "../types.ts"; + +// โ”€โ”€โ”€ public types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** CSS property map for a single element. */ +export type CellProps = Record; + +/** A table-level style entry (selector + properties). */ +export interface TableStyle { + /** CSS selector, e.g. `"table"`, `"th"`, `"tr:nth-child(even)"`. */ + selector: string; + /** CSS properties as an object or key-value pairs array. */ + props: CellProps | ReadonlyArray<[string, string]>; +} + +/** Per-exported-style record from {@link Styler.exportStyles}. */ +export interface StyleRecord { + /** Row index (0-based) in the DataFrame. */ + row: number; + /** Column index (0-based) in the DataFrame. */ + col: number; + /** CSS property string, e.g. `"background-color: yellow;"`. */ + css: string; +} + +/** Value formatter: a function or a template string with `{v}` placeholder. */ +export type ValueFormatter = ((value: Scalar) => string) | string | null; + +/** Subset selector for columns โ€” column names or their integer positions. */ +export type ColSubset = ReadonlyArray | ReadonlyArray | null | undefined; + +/** Axis-wise style function: receives an array of scalar values, returns CSS strings. */ +export type AxisStyleFn = (values: readonly Scalar[]) => readonly string[]; + +/** Element-wise style function: receives a single scalar value, returns a CSS string. */ +export type ElementStyleFn = (value: Scalar) => string; + +/** Options for {@link Styler.highlightMax} / {@link Styler.highlightMin}. */ +export interface HighlightOptions { + /** CSS color for the highlighted cells. Default `"yellow"`. */ + color?: string; + /** Subset of columns to consider (null = all). */ + subset?: ColSubset; + /** `0` = per-column, `1` = per-row, `null` = table-wide. Default `0`. */ + axis?: 0 | 1 | null; +} + +/** Options for {@link Styler.highlightBetween}. */ +export interface HighlightBetweenOptions { + /** Left (lower) bound. Default `null` (no lower bound). */ + left?: number | null; + /** Right (upper) bound. Default `null` (no upper bound). */ + right?: number | null; + /** Whether bounds are inclusive. Default `[true, true]`. */ + inclusive?: "both" | "neither" | "left" | "right"; + /** CSS color. Default `"yellow"`. */ + color?: string; + /** Subset of columns. */ + subset?: ColSubset; + /** Axis. Default `0`. */ + axis?: 0 | 1 | null; +} + +/** Options for {@link Styler.backgroundGradient}. */ +export interface GradientOptions { + /** + * Named colormap: `"RdYlGn"`, `"Blues"`, `"Greens"`, `"Reds"`, `"Oranges"`, + * `"PuBu"`, `"YlOrRd"`, or any CSS color pair `"from:to"`. + * Default `"RdYlGn"`. + */ + cmap?: string; + /** Low fraction to clip (0โ€“1). Default `0`. */ + low?: number; + /** High fraction to clip (0โ€“1). Default `1`. */ + high?: number; + /** Axis. `0` = per-column, `1` = per-row, `null` = table-wide. Default `0`. */ + axis?: 0 | 1 | null; + /** Subset of columns. */ + subset?: ColSubset; + /** Text contrast mode: automatically choose dark/light text. Default `false`. */ + textColor?: boolean; + /** Center the colormap at this value. */ + vmin?: number | null; + /** Maximum value for colormap normalization. */ + vmax?: number | null; +} + +/** Options for {@link Styler.barChart}. */ +export interface BarOptions { + /** Bar color (single color or `[negative, positive]`). Default `"#d65f5f"`. */ + color?: string | [string, string]; + /** Width of the bar cell in percent. Default `100`. */ + width?: number; + /** Align bar: `"left"` | `"zero"` | `"mid"`. Default `"left"`. */ + align?: "left" | "zero" | "mid"; + /** Subset of columns. */ + subset?: ColSubset; + /** Axis. Default `0`. */ + axis?: 0 | 1 | null; + /** VMin for normalization. */ + vmin?: number | null; + /** VMax for normalization. */ + vmax?: number | null; +} + +// โ”€โ”€โ”€ internal types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Internal per-cell CSS accumulator. */ +type CssGrid = string[][]; + +/** A resolved axis-style application. */ +interface StyleApplication { + fn: AxisStyleFn; + axis: 0 | 1; + colIndices: readonly number[]; +} + +/** An element-wise style application. */ +interface MapApplication { + fn: ElementStyleFn; + colIndices: readonly number[]; +} + +/** + * Table-wide style function: receives a 2-D array of values + * `[row][colIndex]` and returns a 2-D array of CSS strings. + */ +type TableWideStyleFn = ( + values: ReadonlyArray, + colIndices: readonly number[], +) => ReadonlyArray; + +/** A table-wide style application. */ +interface TableWideApplication { + fn: TableWideStyleFn; + colIndices: readonly number[]; +} + +// โ”€โ”€โ”€ color helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Parse a hex color string to [r, g, b] (0โ€“255). */ +function hexToRgb(hex: string): [number, number, number] { + const clean = hex.replace(/^#/, ""); + const full = clean.length === 3 + ? clean.split("").map((c) => c + c).join("") + : clean; + const n = Number.parseInt(full, 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +} + +/** Convert [r, g, b] (0โ€“255) to a CSS hex string. */ +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")) + .join("") + ); +} + +/** Linearly interpolate two hex colors at fraction t โˆˆ [0, 1]. */ +function lerpColor(colorA: string, colorB: string, t: number): string { + const [r1, g1, b1] = hexToRgb(colorA); + const [r2, g2, b2] = hexToRgb(colorB); + return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t); +} + +/** Named colormaps: array of [position, hexColor] stops. */ +const COLORMAPS: Readonly>> = { + RdYlGn: [ + [0.0, "#d73027"], + [0.5, "#ffffbf"], + [1.0, "#1a9850"], + ], + Blues: [ + [0.0, "#f7fbff"], + [1.0, "#08306b"], + ], + Greens: [ + [0.0, "#f7fcf5"], + [1.0, "#00441b"], + ], + Reds: [ + [0.0, "#fff5f0"], + [1.0, "#67000d"], + ], + Oranges: [ + [0.0, "#fff5eb"], + [1.0, "#7f2704"], + ], + PuBu: [ + [0.0, "#fff7fb"], + [1.0, "#023858"], + ], + YlOrRd: [ + [0.0, "#ffffcc"], + [0.5, "#fd8d3c"], + [1.0, "#800026"], + ], + Purples: [ + [0.0, "#fcfbfd"], + [1.0, "#3f007d"], + ], + coolwarm: [ + [0.0, "#3b4cc0"], + [0.5, "#f7f7f7"], + [1.0, "#b40426"], + ], +}; + +/** Map a normalized value t โˆˆ [0, 1] through a named colormap. */ +function colormapColor(t: number, cmap: string): string { + // Support "colorA:colorB" shorthand + if (cmap.includes(":")) { + const parts = cmap.split(":"); + return lerpColor(parts[0] ?? "#ffffff", parts[1] ?? "#000000", t); + } + const stops = COLORMAPS[cmap] ?? COLORMAPS["Blues"]!; + // Find surrounding stops + for (let i = 0; i < stops.length - 1; i++) { + const [p0, c0] = stops[i]!; + const [p1, c1] = stops[i + 1]!; + if (t <= p1) { + const local = p1 === p0 ? 0 : (t - p0) / (p1 - p0); + return lerpColor(c0, c1, local); + } + } + return stops[stops.length - 1]![1]; +} + +/** Relative luminance for WCAG contrast check. */ +function luminance(hex: string): number { + const toLinear = (v: number): number => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + const [rv, gv, bv] = hexToRgb(hex); + return 0.2126 * toLinear(rv) + 0.7152 * toLinear(gv) + 0.0722 * toLinear(bv); +} + +/** Choose `"black"` or `"white"` for readable text on a background color. */ +function contrastText(bgHex: string): string { + const lum = luminance(bgHex); + return lum > 0.179 ? "black" : "white"; +} + +// โ”€โ”€โ”€ utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Convert a scalar to a display string. */ +function scalarToString(value: Scalar, naRep: string, precision: number): string { + if (value === null || value === undefined) { + return naRep; + } + if (typeof value === "number") { + if (Number.isNaN(value)) { + return naRep; + } + if (!Number.isFinite(value)) { + return value > 0 ? "inf" : "-inf"; + } + // Only format with precision if not an integer + if (Number.isInteger(value)) { + return String(value); + } + return value.toFixed(precision); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value === "object" && "totalMs" in value) { + return `${value.totalMs}ms`; + } + return String(value); +} + +/** Apply a ValueFormatter to a scalar. */ +function applyFormatter( + value: Scalar, + formatter: ValueFormatter, + naRep: string, + precision: number, +): string { + if (formatter === null) { + return scalarToString(value, naRep, precision); + } + if (typeof formatter === "function") { + return formatter(value); + } + // Template string with {v} or Python-style {:.2f} (simplified) + const display = scalarToString(value, naRep, precision); + return formatter.replace(/\{[^}]*\}/g, display); +} + +/** Escape HTML special characters. */ +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Normalise a CSS properties object or array to a CSS string. */ +function propsToString(props: CellProps | ReadonlyArray<[string, string]>): string { + if (Array.isArray(props)) { + return (props as ReadonlyArray<[string, string]>).map(([k, v]) => `${k}: ${v};`).join(" "); + } + return Object.entries(props as CellProps) + .map(([k, v]) => `${k}: ${v};`) + .join(" "); +} + +/** Merge two CSS strings (semicolon-separated). */ +function mergeCss(a: string, b: string): string { + const trimA = a.trim().replace(/;$/, ""); + const trimB = b.trim().replace(/;$/, ""); + if (!trimA) { + return trimB; + } + if (!trimB) { + return trimA; + } + return `${trimA}; ${trimB}`; +} + +/** Resolve column indices from a ColSubset given all column names. */ +function resolveColIndices( + colNames: readonly string[], + subset: ColSubset, +): readonly number[] { + if (!subset || subset.length === 0) { + return Array.from({ length: colNames.length }, (_, i) => i); + } + const result: number[] = []; + for (const s of subset) { + if (typeof s === "number") { + if (s >= 0 && s < colNames.length) { + result.push(s); + } + } else { + const idx = colNames.indexOf(s); + if (idx >= 0) { + result.push(idx); + } + } + } + return result; +} + +/** Extract numeric values from a scalar array (nulls excluded). */ +function numericValues(vals: readonly Scalar[]): number[] { + return vals.filter((v): v is number => typeof v === "number" && !Number.isNaN(v)); +} + +/** Normalize values into [0, 1], returning NaN for non-numeric or constant arrays. */ +function normalizeRange( + vals: readonly Scalar[], + vmin: number | null | undefined, + vmax: number | null | undefined, +): number[] { + const nums = vals.map((v) => (typeof v === "number" && !Number.isNaN(v) ? v : Number.NaN)); + const finite = nums.filter((v) => Number.isFinite(v)); + const lo = vmin ?? (finite.length > 0 ? Math.min(...finite) : 0); + const hi = vmax ?? (finite.length > 0 ? Math.max(...finite) : 1); + if (hi === lo) { + return nums.map((v) => (Number.isFinite(v) ? 0.5 : Number.NaN)); + } + return nums.map((v) => (Number.isFinite(v) ? (v - lo) / (hi - lo) : Number.NaN)); +} + +// โ”€โ”€โ”€ Styler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * `Styler` โ€” fluent styling for a DataFrame. + * + * Mirrors `pandas.io.formats.style.Styler`. All mutating methods return + * `this`, enabling method chaining: + * + * ```ts + * const html = dataFrameStyle(df) + * .highlightMax({ color: "lightgreen" }) + * .format((v) => (typeof v === "number" ? v.toFixed(2) : String(v))) + * .setCaption("My Table") + * .toHtml(); + * ``` + */ +export class Styler { + private readonly _df: DataFrame; + private _precision: number; + private _naRep: string; + private _caption: string | null; + private _hideIndex: boolean; + private _hiddenCols: Set; + private _tableStyles: TableStyle[]; + private _tableAttributes: string; + private _formatters: Map; + private _indexFormatter: ValueFormatter | null; + private _styleApplications: StyleApplication[]; + private _mapApplications: MapApplication[]; + private _tableWideApplications: TableWideApplication[]; + + /** The DataFrame being styled. */ + get data(): DataFrame { + return this._df; + } + + constructor(df: DataFrame) { + this._df = df; + this._precision = 6; + this._naRep = ""; + this._caption = null; + this._hideIndex = false; + this._hiddenCols = new Set(); + this._tableStyles = []; + this._tableAttributes = ""; + this._formatters = new Map(); + this._indexFormatter = null; + this._styleApplications = []; + this._mapApplications = []; + this._tableWideApplications = []; + } + + // โ”€โ”€ private helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + private get _colNames(): readonly string[] { + return this._df.columns.values as readonly string[]; + } + + /** Apply a function over columns (axis=0) or rows (axis=1). */ + private _applyAxis( + fn: AxisStyleFn, + axis: 0 | 1, + colIndices: readonly number[], + css: CssGrid, + ): void { + const [nrows, ncols] = this._df.shape; + const colNames = this._colNames; + if (axis === 0) { + for (const ci of colIndices) { + if (ci >= ncols) { + continue; + } + const colName = colNames[ci]; + if (colName === undefined) { + continue; + } + const col = this._df.col(colName); + const vals: Scalar[] = Array.from({ length: nrows }, (_, ri) => col.values[ri] ?? null); + const styles = fn(vals); + for (let ri = 0; ri < nrows; ri++) { + css[ri]![ci] = mergeCss(css[ri]![ci]!, styles[ri] ?? ""); + } + } + } else { + for (let ri = 0; ri < nrows; ri++) { + const rowVals: Scalar[] = colIndices.map((ci) => { + const cn = colNames[ci]; + return cn !== undefined ? (this._df.col(cn).values[ri] ?? null) : null; + }); + const styles = fn(rowVals); + for (let k = 0; k < styles.length; k++) { + const ci = colIndices[k]; + const s = styles[k]; + if (ci !== undefined && ci < ncols && s) { + css[ri]![ci] = mergeCss(css[ri]![ci]!, s); + } + } + } + } + } + + /** Apply an element-wise function. */ + private _applyMap(fn: ElementStyleFn, colIndices: readonly number[], css: CssGrid): void { + const [nrows, ncols] = this._df.shape; + const colNames = this._colNames; + for (const ci of colIndices) { + if (ci >= ncols) { + continue; + } + const colName = colNames[ci]; + if (colName === undefined) { + continue; + } + const col = this._df.col(colName); + for (let ri = 0; ri < nrows; ri++) { + const val = col.values[ri] ?? null; + const s = fn(val); + if (s) { + css[ri]![ci] = mergeCss(css[ri]![ci]!, s); + } + } + } + } + + /** Build the CSS grid by replaying all recorded applications. */ + private _buildCss(): CssGrid { + const [nrows, ncols] = this._df.shape; + const colNames = this._colNames; + const css: CssGrid = Array.from({ length: nrows }, () => + Array.from({ length: ncols }, () => ""), + ); + for (const app of this._styleApplications) { + this._applyAxis(app.fn, app.axis, app.colIndices, css); + } + for (const app of this._mapApplications) { + this._applyMap(app.fn, app.colIndices, css); + } + // Table-wide applications + for (const app of this._tableWideApplications) { + // Build 2D values array [row][colPosition] for the selected columns + const rowsData: Array = Array.from({ length: nrows }, (_, ri) => + app.colIndices.map((ci) => { + const cn = colNames[ci]; + return cn !== undefined ? (this._df.col(cn).values[ri] ?? null) : null; + }), + ); + const styleGrid = app.fn(rowsData, app.colIndices); + for (let ri = 0; ri < nrows; ri++) { + const styleRow = styleGrid[ri]; + if (!styleRow) continue; + app.colIndices.forEach((ci, k) => { + const s = styleRow[k] ?? ""; + if (s) css[ri]![ci] = mergeCss(css[ri]![ci]!, s); + }); + } + } + return css; + } + + // โ”€โ”€ public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /** + * Set the default precision for floating-point display. + * + * @param precision โ€” number of decimal places (default 6). + */ + setPrecision(precision: number): this { + this._precision = precision; + return this; + } + + /** + * Set the representation for missing (null/undefined/NaN) values. + * + * @param naRep โ€” string to show in place of nulls (default `""`). + */ + setNaRep(naRep: string): this { + this._naRep = naRep; + return this; + } + + /** + * Format cell values. + * + * @param formatter โ€” a function, format-string (with `{v}` placeholder), or `null` for default. + * @param subset โ€” columns to format (null = all). + * @param naRep โ€” override NA representation for this formatter. + * + * @example + * ```ts + * style.format((v) => (typeof v === "number" ? `$${v.toFixed(2)}` : String(v))); + * style.format("{v}%", ["pct_col"]); + * ``` + */ + format( + formatter: ValueFormatter, + subset: ColSubset = null, + naRep?: string, + ): this { + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + const effectiveNa = naRep ?? this._naRep; + const effectiveFmt: ValueFormatter = + formatter === null + ? null + : typeof formatter === "function" + ? formatter + : (v: Scalar) => applyFormatter(v, formatter, effectiveNa, this._precision); + for (const ci of colIndices) { + this._formatters.set(ci, effectiveFmt); + } + return this; + } + + /** + * Format index labels. + * + * @param formatter โ€” function or format string. + */ + formatIndex(formatter: ValueFormatter): this { + this._indexFormatter = formatter; + return this; + } + + /** + * Apply a column-wise (axis=0) or row-wise (axis=1) CSS styling function. + * + * The function receives an array of scalar values and must return an array of + * CSS strings of the same length. + * + * @example + * ```ts + * // Highlight the max in each column + * style.apply((vals) => { + * const max = Math.max(...vals.filter((v): v is number => typeof v === "number")); + * return vals.map((v) => (v === max ? "background-color: yellow;" : "")); + * }); + * ``` + */ + apply(fn: AxisStyleFn, axis: 0 | 1 | "index" | "columns" = 0, subset: ColSubset = null): this { + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + const resolvedAxis: 0 | 1 = axis === 0 || axis === "index" ? 0 : 1; + this._styleApplications.push({ fn, axis: resolvedAxis, colIndices }); + return this; + } + + /** + * Apply an element-wise CSS styling function. + * + * @example + * ```ts + * style.applymap((v) => (typeof v === "number" && v < 0 ? "color: red;" : "")); + * ``` + */ + applymap(fn: ElementStyleFn, subset: ColSubset = null): this { + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + this._mapApplications.push({ fn, colIndices }); + return this; + } + + /** + * Alias for {@link Styler.applymap} (pandas โ‰ฅ 2.1 renamed `applymap` โ†’ `map`). + */ + map(fn: ElementStyleFn, subset: ColSubset = null): this { + return this.applymap(fn, subset); + } + + /** + * Highlight the maximum value in each column (axis=0), each row (axis=1), + * or across the entire table (axis=null). + */ + highlightMax(options: HighlightOptions = {}): this { + const { color = "yellow", subset = null, axis = 0 } = options; + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + + const highlightFn: AxisStyleFn = (vals) => { + const nums = numericValues(vals); + if (nums.length === 0) return vals.map(() => ""); + const max = Math.max(...nums); + return vals.map((v) => (v === max ? `background-color: ${color};` : "")); + }; + + if (axis === null) { + // Table-wide: find max across all specified columns + const tableWideFn: TableWideStyleFn = (rowsData) => { + // Collect all numeric values across the whole selection + const allNums: number[] = []; + for (const row of rowsData) { + for (const v of row) { + if (typeof v === "number" && !Number.isNaN(v)) allNums.push(v); + } + } + if (allNums.length === 0) return rowsData.map((row) => row.map(() => "")); + const max = Math.max(...allNums); + return rowsData.map((row) => + row.map((v) => (v === max ? `background-color: ${color};` : "")), + ); + }; + this._tableWideApplications.push({ fn: tableWideFn, colIndices }); + } else { + this._styleApplications.push({ + fn: highlightFn, + axis: axis as 0 | 1, + colIndices, + }); + } + return this; + } + + /** + * Highlight the minimum value in each column (axis=0), row (axis=1), or table (null). + */ + highlightMin(options: HighlightOptions = {}): this { + const { color = "yellow", subset = null, axis = 0 } = options; + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + + /** + * Highlight the minimum value in each column (axis=0), row (axis=1), or table (null). + */ + highlightMin(options: HighlightOptions = {}): this { + const { color = "yellow", subset = null, axis = 0 } = options; + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + + const highlightFn: AxisStyleFn = (vals) => { + const nums = numericValues(vals); + if (nums.length === 0) return vals.map(() => ""); + const min = Math.min(...nums); + return vals.map((v) => (v === min ? `background-color: ${color};` : "")); + }; + + if (axis === null) { + const tableWideFn: TableWideStyleFn = (rowsData) => { + const allNums: number[] = []; + for (const row of rowsData) { + for (const v of row) { + if (typeof v === "number" && !Number.isNaN(v)) allNums.push(v); + } + } + if (allNums.length === 0) return rowsData.map((row) => row.map(() => "")); + const min = Math.min(...allNums); + return rowsData.map((row) => + row.map((v) => (v === min ? `background-color: ${color};` : "")), + ); + }; + this._tableWideApplications.push({ fn: tableWideFn, colIndices }); + } else { + this._styleApplications.push({ + fn: highlightFn, + axis: (axis ?? 0) as 0 | 1, + colIndices, + }); + } + return this; + } + + /** + * Highlight null/undefined/NaN values. + */ + highlightNull(nullColor = "red", subset: ColSubset = null): this { + return this.applymap((v) => { + const isNull = + v === null || v === undefined || (typeof v === "number" && Number.isNaN(v)); + return isNull ? `background-color: ${nullColor};` : ""; + }, subset); + } + + /** + * Highlight values within a numeric range [left, right]. + */ + highlightBetween(options: HighlightBetweenOptions = {}): this { + const { left = null, right = null, inclusive = "both", color = "yellow", subset = null } = + options; + + return this.applymap((v) => { + if (typeof v !== "number" || Number.isNaN(v)) return ""; + let ok = true; + if (left !== null) { + ok = ok && (inclusive === "both" || inclusive === "left" ? v >= left : v > left); + } + if (right !== null) { + ok = ok && (inclusive === "both" || inclusive === "right" ? v <= right : v < right); + } + return ok ? `background-color: ${color};` : ""; + }, subset); + } + + /** + * Apply a background color gradient across values. + * + * @param options โ€” gradient configuration (cmap, low, high, axis, subset, vmin, vmax, textColor). + */ + backgroundGradient(options: GradientOptions = {}): this { + const { + cmap = "Blues", + low = 0, + high = 1, + axis = 0, + subset = null, + textColor = false, + vmin = null, + vmax = null, + } = options; + + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + + const gradientFn: AxisStyleFn = (vals) => { + const normalized = normalizeRange(vals, vmin, vmax); + return normalized.map((t) => { + if (Number.isNaN(t)) { + return ""; + } + // Clip to [low, high] then renormalize + const clipped = low + (high - low) * Math.max(0, Math.min(1, t)); + const bg = colormapColor(clipped, cmap); + let style = `background-color: ${bg};`; + if (textColor) { + style += ` color: ${contrastText(bg)};`; + } + return style; + }); + }; + + this._styleApplications.push({ + fn: gradientFn, + axis: (axis ?? 0) as 0 | 1, + colIndices, + }); + return this; + } + + /** + * Apply a text color gradient (same as backgroundGradient but sets `color:` instead). + */ + textGradient(options: GradientOptions = {}): this { + const { + cmap = "RdYlGn", + low = 0, + high = 1, + axis = 0, + subset = null, + vmin = null, + vmax = null, + } = options; + + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + + const gradientFn: AxisStyleFn = (vals) => { + const normalized = normalizeRange(vals, vmin, vmax); + return normalized.map((t) => { + if (Number.isNaN(t)) { + return ""; + } + const clipped = low + (high - low) * Math.max(0, Math.min(1, t)); + return `color: ${colormapColor(clipped, cmap)};`; + }); + }; + + this._styleApplications.push({ + fn: gradientFn, + axis: (axis ?? 0) as 0 | 1, + colIndices, + }); + return this; + } + + /** + * Render inline bar charts using CSS linear-gradients. + * + * @param options โ€” bar configuration (color, width, align, subset, vmin, vmax). + */ + barChart(options: BarOptions = {}): this { + const { + color = "#d65f5f", + width = 100, + align = "left", + subset = null, + axis = 0, + vmin = null, + vmax = null, + } = options; + + const colNames = this._colNames; + const colIndices = resolveColIndices(colNames, subset); + + const positiveColor = Array.isArray(color) ? (color[1] ?? "#d65f5f") : color; + const negativeColor = Array.isArray(color) ? (color[0] ?? "#5f82d6") : "#5f82d6"; + + const barFn: AxisStyleFn = (vals) => { + const normalized = normalizeRange(vals, vmin, vmax); + return normalized.map((t) => { + if (Number.isNaN(t)) { + return ""; + } + const pct = Math.max(0, Math.min(1, t)) * width; + const c = t >= 0.5 ? positiveColor : negativeColor; + if (align === "left") { + return `background: linear-gradient(90deg, ${c} ${pct.toFixed(1)}%, transparent ${pct.toFixed(1)}%); width: ${width}%;`; + } + if (align === "mid") { + const mid = width / 2; + if (t >= 0.5) { + const w2 = (t - 0.5) * 2 * mid; + const stop1 = mid.toFixed(1); + const stop2 = (mid + w2).toFixed(1); + return `background: linear-gradient(90deg, transparent ${stop1}%, ${positiveColor} ${stop1}%, ${positiveColor} ${stop2}%, transparent ${stop2}%);`; + } + const w2 = (0.5 - t) * 2 * mid; + const start = mid - w2; + return `background: linear-gradient(90deg, transparent ${start.toFixed(1)}%, ${negativeColor} ${start.toFixed(1)}%, ${negativeColor} ${mid.toFixed(1)}%, transparent ${mid.toFixed(1)}%);`; + } + // align === "zero" + const midPct = 50; + if (t >= 0.5) { + const w2 = (t - 0.5) * 2 * (width / 2); + const stop2 = (midPct + w2).toFixed(1); + return `background: linear-gradient(90deg, transparent ${midPct}%, ${positiveColor} ${midPct}%, ${positiveColor} ${stop2}%, transparent ${stop2}%);`; + } + const w2 = (0.5 - t) * 2 * (width / 2); + const stop1 = (midPct - w2).toFixed(1); + return `background: linear-gradient(90deg, transparent ${stop1}%, ${negativeColor} ${stop1}%, ${negativeColor} ${midPct}%, transparent ${midPct}%);`; + }); + }; + + this._styleApplications.push({ + fn: barFn, + axis: (axis ?? 0) as 0 | 1, + colIndices, + }); + return this; + } + + /** + * Set CSS properties for all cells (or a subset of columns). + * + * @example + * ```ts + * style.setProperties({ "font-weight": "bold", color: "navy" }, ["important_col"]); + * ``` + */ + setProperties(props: CellProps, subset: ColSubset = null): this { + const css = Object.entries(props) + .map(([k, v]) => `${k}: ${v};`) + .join(" "); + return this.applymap(() => css, subset); + } + + /** + * Set table-level CSS styles (selectors applied via a ``); + } + + // + const tableAttrs = this._tableAttributes ? ` ${this._tableAttributes}` : ""; + lines.push(`
`); + + // Caption + if (this._caption !== null) { + lines.push(` `); + } + + // Header row + lines.push(" "); + lines.push(" "); + if (!this._hideIndex) { + lines.push(` `); + } + for (const ci of visibleCols) { + const colName = colNames[ci] ?? ""; + lines.push( + ` `, + ); + } + lines.push(" "); + lines.push(" "); + + // Body + lines.push(" "); + for (let ri = 0; ri < nrows; ri++) { + lines.push(" "); + + // Index cell + if (!this._hideIndex) { + const idxLabel = indexVals[ri] ?? ri; + let idxStr: string; + if (this._indexFormatter !== null) { + idxStr = applyFormatter( + idxLabel as Scalar, + this._indexFormatter, + this._naRep, + this._precision, + ); + } else { + idxStr = String(idxLabel); + } + lines.push( + ` `, + ); + } + + // Data cells + for (const ci of visibleCols) { + const colName = colNames[ci]; + const val: Scalar = colName !== undefined ? (this._df.col(colName).values[ri] ?? null) : null; + + const formatter = this._formatters.get(ci) ?? null; + const displayed = applyFormatter(val, formatter, this._naRep, this._precision); + + const cellCss = css[ri]![ci] ?? ""; + const baseStyle = "border: 1px solid #ddd; padding: 4px;"; + const style = cellCss ? `${baseStyle} ${cellCss}` : baseStyle; + + lines.push( + ` `, + ); + } + + lines.push(" "); + } + lines.push(" "); + lines.push("
${escapeHtml(this._caption)}
${escapeHtml(String(colName))}
${escapeHtml(idxStr)}${escapeHtml(displayed)}
"); + + return lines.join("\n"); + } + + /** + * Alias for {@link Styler.toHtml} (matches pandas `.render()` / `.to_html()`). + */ + render(uuid?: string): string { + return this.toHtml(uuid); + } + + /** + * Render the styled DataFrame as a LaTeX string. + * + * Produces a basic LaTeX `tabular` environment; styling is not applied + * (CSS has no direct LaTeX equivalent). + * + * @param environment โ€” LaTeX table environment (default `"tabular"`). + * @param hrules โ€” whether to add horizontal rules (default `true`). + */ + toLatex(environment = "tabular", hrules = true): string { + const [nrows, ncols] = this._df.shape; + const colNames = this._colNames; + const indexVals = this._df.index.values as readonly Label[]; + + const visibleCols: number[] = []; + for (let ci = 0; ci < ncols; ci++) { + if (!this._hiddenCols.has(ci)) { + visibleCols.push(ci); + } + } + + const colSpec = (this._hideIndex ? "" : "l|") + visibleCols.map(() => "r").join(""); + const lines: string[] = []; + + if (this._caption !== null) { + lines.push(`\\begin{table}`); + lines.push(` \\caption{${this._caption}}`); + } + + lines.push(`\\begin{${environment}}{${colSpec}}`); + if (hrules) { + lines.push("\\toprule"); + } + + // Header + const headers: string[] = []; + if (!this._hideIndex) { + headers.push("{}"); + } + for (const ci of visibleCols) { + headers.push(String(colNames[ci] ?? "").replace(/_/g, "\\_")); + } + lines.push(headers.join(" & ") + " \\\\"); + if (hrules) { + lines.push("\\midrule"); + } + + // Rows + for (let ri = 0; ri < nrows; ri++) { + const cells: string[] = []; + if (!this._hideIndex) { + cells.push(String(indexVals[ri] ?? ri).replace(/_/g, "\\_")); + } + for (const ci of visibleCols) { + const colName = colNames[ci]; + const val: Scalar = + colName !== undefined ? (this._df.col(colName).values[ri] ?? null) : null; + const formatter = this._formatters.get(ci) ?? null; + const displayed = applyFormatter(val, formatter, this._naRep, this._precision); + cells.push(displayed.replace(/_/g, "\\_").replace(/&/g, "\\&").replace(/%/g, "\\%")); + } + lines.push(cells.join(" & ") + " \\\\"); + } + + if (hrules) { + lines.push("\\bottomrule"); + } + lines.push(`\\end{${environment}}`); + + if (this._caption !== null) { + lines.push(`\\end{table}`); + } + + return lines.join("\n"); + } + + /** String representation (renders as HTML). */ + toString(): string { + return this.toHtml(); + } +} + +// โ”€โ”€โ”€ factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Create a {@link Styler} for the given DataFrame. + * + * Equivalent to `df.style` in pandas. + * + * @example + * ```ts + * import { DataFrame, dataFrameStyle } from "tsb"; + * + * const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [4, -1, 6] }); + * const html = dataFrameStyle(df) + * .highlightMax({ color: "lightgreen" }) + * .highlightMin({ color: "salmon" }) + * .setCaption("Sample Table") + * .toHtml(); + * ``` + */ +export function dataFrameStyle(df: DataFrame): Styler { + return new Styler(df); +} diff --git a/tests/stats/style.test.ts b/tests/stats/style.test.ts new file mode 100644 index 00000000..43017266 --- /dev/null +++ b/tests/stats/style.test.ts @@ -0,0 +1,594 @@ +/** + * Tests for the Styler API โ€” DataFrame.style equivalent. + * + * Mirrors the pandas Styler test suite: + * https://github.com/pandas-dev/pandas/blob/main/pandas/tests/io/formats/style/ + */ + +import { describe, expect, test } from "bun:test"; +import * as fc from "fast-check"; +import { DataFrame, Styler, dataFrameStyle } from "../../src/index.ts"; +import type { Scalar } from "../../src/index.ts"; + +// โ”€โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function makeNumericDf(): DataFrame { + return DataFrame.fromColumns({ a: [1, 2, 3], b: [4, -1, 6] }, { index: ["r0", "r1", "r2"] }); +} + +// โ”€โ”€โ”€ construction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler construction", () => { + test("dataFrameStyle returns a Styler", () => { + const df = makeNumericDf(); + const s = dataFrameStyle(df); + expect(s).toBeInstanceOf(Styler); + expect(s.data).toBe(df); + }); + + test("new Styler(df) works directly", () => { + const df = makeNumericDf(); + const s = new Styler(df); + expect(s).toBeInstanceOf(Styler); + }); + + test("toHtml produces an HTML string", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).toHtml(); + expect(html).toContain(""); + expect(html).toContain(" { + const df = makeNumericDf(); + const s = dataFrameStyle(df); + expect(s.render("x")).toBe(s.toHtml("x")); + }); +}); + +// โ”€โ”€โ”€ format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.format", () => { + test("function formatter applied to all cells", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .format((v) => `[${String(v)}]`) + .toHtml(); + expect(html).toContain("[1]"); + expect(html).toContain("[4]"); + }); + + test("format with column subset only formats specified columns", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .format((v) => `$${String(v)}`, ["a"]) + .toHtml(); + expect(html).toContain("$1"); + expect(html).toContain("$2"); + expect(html).toContain("$3"); + // column b should NOT be prefixed with $ + // (4 appears without $ in b, with $ only in a for matching values) + }); + + test("format with string template", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .format("{v}%", ["a"]) + .toHtml(); + expect(html).toContain("1%"); + }); + + test("na_rep replacement", () => { + const df = DataFrame.fromColumns({ a: [1, null, 3] }); + const html = dataFrameStyle(df).setNaRep("N/A").toHtml(); + expect(html).toContain("N/A"); + }); + + test("precision formats floats", () => { + const df = DataFrame.fromColumns({ a: [1.23456789] }); + const html = dataFrameStyle(df).setPrecision(2).toHtml(); + expect(html).toContain("1.23"); + }); +}); + +// โ”€โ”€โ”€ formatIndex โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.formatIndex", () => { + test("formats index labels", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .formatIndex((v) => `idx:${String(v)}`) + .toHtml(); + expect(html).toContain("idx:r0"); + expect(html).toContain("idx:r1"); + }); +}); + +// โ”€โ”€โ”€ apply โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.apply", () => { + test("axis=0 applies per-column style", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .apply((vals) => vals.map((v) => (typeof v === "number" && v > 2 ? "color: red;" : ""))) + .toHtml(); + expect(html).toContain("color: red;"); + }); + + test("axis=1 applies per-row style", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .apply( + (vals) => vals.map((v) => (typeof v === "number" && v < 0 ? "background-color: pink;" : "")), + 1, + ) + .toHtml(); + expect(html).toContain("background-color: pink;"); + }); + + test("subset limits columns", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .apply((vals) => vals.map(() => "font-weight: bold;"), 0, ["a"]) + .toHtml(); + expect(html).toContain("font-weight: bold;"); + }); + + test("string axis aliases work", () => { + const df = makeNumericDf(); + const htmlIndex = dataFrameStyle(df) + .apply((vals) => vals.map(() => "color: blue;"), "index") + .toHtml(); + const htmlColumns = dataFrameStyle(df) + .apply((vals) => vals.map(() => "color: blue;"), "columns") + .toHtml(); + expect(htmlIndex).toContain("color: blue;"); + expect(htmlColumns).toContain("color: blue;"); + }); +}); + +// โ”€โ”€โ”€ applymap / map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.applymap / map", () => { + test("applymap applies element-wise", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .applymap((v) => (typeof v === "number" && v < 0 ? "color: red;" : "")) + .toHtml(); + expect(html).toContain("color: red;"); + }); + + test("map is an alias for applymap", () => { + const df = makeNumericDf(); + const h1 = dataFrameStyle(df).applymap((v) => (v === -1 ? "color: red;" : "")).toHtml("x"); + const h2 = dataFrameStyle(df).map((v) => (v === -1 ? "color: red;" : "")).toHtml("x"); + expect(h1).toBe(h2); + }); +}); + +// โ”€โ”€โ”€ highlightMax / highlightMin โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.highlightMax", () => { + test("highlights max in each column", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).highlightMax({ color: "lightgreen" }).toHtml(); + expect(html).toContain("background-color: lightgreen;"); + }); + + test("highlights max per-row with axis=1", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).highlightMax({ color: "cyan", axis: 1 }).toHtml(); + expect(html).toContain("background-color: cyan;"); + }); + + test("default color is yellow", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).highlightMax().toHtml(); + expect(html).toContain("background-color: yellow;"); + }); + + test("subset limits columns", () => { + const df = makeNumericDf(); + const styles = dataFrameStyle(df).highlightMax({ subset: ["a"] }).exportStyles(); + // All highlighted cells should be in column 0 (a) + for (const s of styles) { + if (s.css.includes("background-color: yellow")) { + expect(s.col).toBe(0); + } + } + }); +}); + +describe("Styler.highlightMin", () => { + test("highlights min in each column", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).highlightMin({ color: "salmon" }).toHtml(); + expect(html).toContain("background-color: salmon;"); + }); +}); + +// โ”€โ”€โ”€ highlightNull โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.highlightNull", () => { + test("highlights null values", () => { + const df = DataFrame.fromColumns({ a: [1, null, 3], b: [null, 2, 3] }); + const html = dataFrameStyle(df).highlightNull("red").toHtml(); + expect(html).toContain("background-color: red;"); + }); + + test("does not highlight non-null values", () => { + const df = DataFrame.fromColumns({ a: [1, 2, 3] }); + const styles = dataFrameStyle(df).highlightNull("red").exportStyles(); + expect(styles.length).toBe(0); + }); +}); + +// โ”€โ”€โ”€ highlightBetween โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.highlightBetween", () => { + test("highlights values in range", () => { + const df = makeNumericDf(); + const styles = dataFrameStyle(df) + .highlightBetween({ left: 2, right: 5, color: "orange" }) + .exportStyles(); + const vals = styles.filter((s) => s.css.includes("orange")); + expect(vals.length).toBeGreaterThan(0); + }); + + test("inclusive both", () => { + const df = DataFrame.fromColumns({ a: [1, 2, 3] }); + const styles = dataFrameStyle(df) + .highlightBetween({ left: 1, right: 3, inclusive: "both" }) + .exportStyles(); + expect(styles.length).toBe(3); + }); + + test("inclusive neither", () => { + const df = DataFrame.fromColumns({ a: [1, 2, 3] }); + const styles = dataFrameStyle(df) + .highlightBetween({ left: 1, right: 3, inclusive: "neither" }) + .exportStyles(); + expect(styles.length).toBe(1); // only value 2 + }); + + test("no lower bound", () => { + const df = DataFrame.fromColumns({ a: [1, 2, 3] }); + const styles = dataFrameStyle(df) + .highlightBetween({ right: 2, inclusive: "both" }) + .exportStyles(); + expect(styles.length).toBe(2); // values 1 and 2 + }); +}); + +// โ”€โ”€โ”€ backgroundGradient โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.backgroundGradient", () => { + test("applies background-color CSS", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).backgroundGradient().toHtml(); + expect(html).toContain("background-color: #"); + }); + + test("different cmaps produce different colors", () => { + const df = DataFrame.fromColumns({ a: [0, 100] }); + const h1 = dataFrameStyle(df).backgroundGradient({ cmap: "Blues" }).toHtml("x"); + const h2 = dataFrameStyle(df).backgroundGradient({ cmap: "Reds" }).toHtml("x"); + expect(h1).not.toBe(h2); + }); + + test("textColor adds color CSS", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).backgroundGradient({ textColor: true }).toHtml(); + expect(html).toContain("color:"); + }); + + test("custom cmap as colorA:colorB", () => { + const df = DataFrame.fromColumns({ a: [0, 1] }); + const html = dataFrameStyle(df).backgroundGradient({ cmap: "#000000:#ffffff" }).toHtml(); + expect(html).toContain("background-color: #"); + }); + + test("vmin/vmax clamp range", () => { + const df = DataFrame.fromColumns({ a: [0, 50, 100] }); + const styles = dataFrameStyle(df) + .backgroundGradient({ vmin: 50, vmax: 100 }) + .exportStyles(); + expect(styles.length).toBe(3); // all cells get a style + }); +}); + +// โ”€โ”€โ”€ textGradient โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.textGradient", () => { + test("applies color CSS", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).textGradient().toHtml(); + expect(html).toContain("color: #"); + }); +}); + +// โ”€โ”€โ”€ barChart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.barChart", () => { + test("applies background linear-gradient CSS", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).barChart().toHtml(); + expect(html).toContain("linear-gradient"); + }); + + test("custom color", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df).barChart({ color: "steelblue" }).toHtml(); + expect(html).toContain("steelblue"); + }); +}); + +// โ”€โ”€โ”€ setProperties โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.setProperties", () => { + test("sets CSS for all cells", () => { + const df = makeNumericDf(); + const html = dataFrameStyle(df) + .setProperties({ "font-weight": "bold" }) + .toHtml(); + expect(html).toContain("font-weight: bold;"); + }); + + test("subset limits properties", () => { + const df = makeNumericDf(); + const styles = dataFrameStyle(df) + .setProperties({ color: "navy" }, ["a"]) + .exportStyles(); + for (const s of styles) { + expect(s.col).toBe(0); + } + }); +}); + +// โ”€โ”€โ”€ setTableStyles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("Styler.setTableStyles", () => { + test("adds a