hazo_dataviz 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # hazo_dataviz — Change Log
2
2
 
3
+ ## 0.6.0 — 2026-08-02
4
+
5
+ Legends, a themeable `LineChart`, and a modernised default look. Additive: no prop was removed or renamed, and a chart that passes no new props renders the same data with a cleaner frame.
6
+
7
+ - **New `src/charts/chart_common.tsx`**, exported from `./client` — `ChartLegend`, `ChartTooltip`, `DEFAULT_CHART_THEME`, `resolveChartTheme`, plus the `ChartLegendEntry` / `ChartTooltipProps` types. `TimeSeriesChart` and `LineChart` each carried a private copy of this furniture and had already drifted apart; there is now one of each.
8
+ - **`TimeSeriesChart.showLegend?: boolean`** — swatch legend under the plot. Defaults to `true` when every series carries a `label` and there is more than one, so the common "Added vs Done" chart gets a legend without asking. Pass `false` to suppress, `true` to force one on a single labelled series.
9
+ - **`LineChart` gained `theme`, `label` and `showLegend`.** `theme` is the significant one: **`LineChart` previously took no theme at all**, so it rendered recharts' stock light-mode axes, gridlines and tooltip whatever the host app looked like — near-white hairlines on a dark card, while `TimeSeriesChart` on the same page themed correctly. Unset fields fall back to `DEFAULT_CHART_THEME`, so passing nothing preserves the old appearance exactly.
10
+ - **Legends are HTML below the SVG, not recharts' `<Legend>`.** recharts carves legend space out of the `height` the caller passed, so switching one on would silently shrink the plot, and its default text colour is not theme-aware — the same bug being fixed above. Outside the SVG the legend costs no plot height and inherits nothing.
11
+ - **Modernised default marks** (both charts, and both `TimeSeriesChart` types):
12
+ - Horizontal-only gridlines (`vertical={false}`), hairline, widely dashed (`2 6`). A full cross-hatch was the single most dated thing about the old frame, and vertical rules add nothing a time axis doesn't already give.
13
+ - Axis lines dropped — with a horizontal grid the baseline gridline already reads as the axis, and drawing both stacked two near-identical rules. Tick labels gained margin.
14
+ - Lines: `strokeWidth` 1 → 2, round caps and joins, and an `activeDot` ringed in the theme's tooltip surface so a hover marker reads as sitting *on* the line.
15
+ - Bars: 3px top corner radius, `maxBarSize` 28, tighter `barGap`.
16
+ - Themed tooltip cursor. recharts' stock cursor is an opaque light-grey band — a bright slab across the plot on any dark theme. Lines now get a dashed hairline, bars a faint wash of the theme's own `active`.
17
+ - A small plot margin so a peak's stroke and the last point's active dot are no longer clipped by the container edge.
18
+ - **Tests**: `src/charts/__tests__/chart_common.test.tsx` — 11 cases covering theme merge, legend entry/colour rendering and the empty-list null, and the tooltip's unit suffix, null-value dash, single-vs-multi series naming, and opaque-background fallback. These run fully under jsdom; the chart bodies still can't (recharts pre-bundles a React-18 jsx-runtime), which is exactly why the legend and tooltip are plain HTML.
19
+
3
20
  ## 0.5.0 — 2026-07-21
4
21
 
5
22
  New pure inline-SVG chart primitive, extracted from a first consumer (stocktools' bespoke `ProgressRing`).
@@ -0,0 +1,48 @@
1
+ import * as React from 'react';
2
+ import type { ChartTheme } from './chart_controls.js';
3
+ /**
4
+ * The package's stock "Studio" look: warm, light, low-contrast. Every chart
5
+ * merges `{ ...DEFAULT_CHART_THEME, ...props.theme }`, so a consumer only
6
+ * names the fields it wants to move.
7
+ *
8
+ * These are deliberately literal colours, not CSS custom properties: theme
9
+ * fields land on recharts' SVG *presentation attributes* (`fill`, `stroke`),
10
+ * not always a React `style` object, and custom-property resolution inside
11
+ * portalled/attribute-driven SVG is not reliable enough to ship blind.
12
+ */
13
+ export declare const DEFAULT_CHART_THEME: ChartTheme;
14
+ /** Merge a partial override over the package default. */
15
+ export declare function resolveChartTheme(theme?: Partial<ChartTheme>): ChartTheme;
16
+ export interface TooltipEntry {
17
+ color?: string;
18
+ name?: string | number;
19
+ value?: number | string | null;
20
+ }
21
+ export interface ChartTooltipProps {
22
+ active?: boolean;
23
+ label?: string;
24
+ payload?: TooltipEntry[];
25
+ unit?: string;
26
+ theme: ChartTheme;
27
+ }
28
+ export declare function ChartTooltip({ active, label, payload, unit, theme, }: ChartTooltipProps): React.ReactElement | null;
29
+ export interface ChartLegendEntry {
30
+ label: string;
31
+ color: string;
32
+ }
33
+ /**
34
+ * A themed swatch row, rendered as plain HTML *below* the chart rather than
35
+ * through recharts' own `<Legend>`.
36
+ *
37
+ * Deliberate: recharts reserves vertical space for `<Legend>` out of the
38
+ * `height` the caller passed, so switching a legend on silently shrinks the
39
+ * plot — and its default text colour is not theme-aware, which is the exact
40
+ * bug this package already has one instance of. Outside the SVG, the legend
41
+ * costs the chart no plot height and inherits nothing from recharts.
42
+ */
43
+ export declare function ChartLegend({ entries, theme, className, }: {
44
+ entries: ChartLegendEntry[];
45
+ theme: ChartTheme;
46
+ className?: string;
47
+ }): React.ReactElement | null;
48
+ //# sourceMappingURL=chart_common.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chart_common.d.ts","sourceRoot":"","sources":["../../src/charts/chart_common.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB,EAAE,UAOjC,CAAC;AAEF,yDAAyD;AACzD,wBAAgB,iBAAiB,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,UAAU,CAEzE;AAID,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,EAC3B,MAAM,EACN,KAAK,EACL,OAAO,EACP,IAAI,EACJ,KAAK,GACN,EAAE,iBAAiB,GAAG,KAAK,CAAC,YAAY,GAAG,IAAI,CAyD/C;AAID,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,KAAK,EACL,SAAS,GACV,EAAE;IACD,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,KAAK,CAAC,YAAY,GAAG,IAAI,CAmC5B"}
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * The package's stock "Studio" look: warm, light, low-contrast. Every chart
5
+ * merges `{ ...DEFAULT_CHART_THEME, ...props.theme }`, so a consumer only
6
+ * names the fields it wants to move.
7
+ *
8
+ * These are deliberately literal colours, not CSS custom properties: theme
9
+ * fields land on recharts' SVG *presentation attributes* (`fill`, `stroke`),
10
+ * not always a React `style` object, and custom-property resolution inside
11
+ * portalled/attribute-driven SVG is not reliable enough to ship blind.
12
+ */
13
+ export const DEFAULT_CHART_THEME = {
14
+ ink: '#2f2a25',
15
+ muted: '#9a9087',
16
+ hairline: '#ece6dd',
17
+ surface: '#fffdf9',
18
+ active: '#f0ebe3',
19
+ tooltipSurface: '#fffdf9',
20
+ };
21
+ /** Merge a partial override over the package default. */
22
+ export function resolveChartTheme(theme) {
23
+ return { ...DEFAULT_CHART_THEME, ...theme };
24
+ }
25
+ export function ChartTooltip({ active, label, payload, unit, theme, }) {
26
+ if (!active || !payload || payload.length === 0)
27
+ return null;
28
+ return (_jsxs("div", { style: {
29
+ // Must be opaque to be readable; `surface` is commonly 'transparent'
30
+ // so the chart area can show the host card through it.
31
+ background: theme.tooltipSurface ??
32
+ (theme.surface === 'transparent' ? '#171717' : theme.surface),
33
+ border: `1px solid ${theme.hairline}`,
34
+ borderRadius: 8,
35
+ padding: '8px 10px',
36
+ fontSize: 12,
37
+ lineHeight: 1.5,
38
+ color: theme.ink,
39
+ boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
40
+ }, children: [_jsx("div", { style: {
41
+ fontWeight: 600,
42
+ marginBottom: payload.length > 1 ? 5 : 0,
43
+ color: theme.ink,
44
+ }, children: label }), payload.map((entry, i) => (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
45
+ display: 'inline-block',
46
+ width: 8,
47
+ height: 8,
48
+ borderRadius: '50%',
49
+ background: entry.color ?? theme.ink,
50
+ flexShrink: 0,
51
+ } }), payload.length > 1 && entry.name ? (_jsx("span", { style: { color: theme.muted }, children: `${entry.name}: ` })) : null, _jsx("span", { style: { fontVariantNumeric: 'tabular-nums' }, children: entry.value != null ? `${entry.value}${unit ?? ''}` : '—' })] }, i)))] }));
52
+ }
53
+ /**
54
+ * A themed swatch row, rendered as plain HTML *below* the chart rather than
55
+ * through recharts' own `<Legend>`.
56
+ *
57
+ * Deliberate: recharts reserves vertical space for `<Legend>` out of the
58
+ * `height` the caller passed, so switching a legend on silently shrinks the
59
+ * plot — and its default text colour is not theme-aware, which is the exact
60
+ * bug this package already has one instance of. Outside the SVG, the legend
61
+ * costs the chart no plot height and inherits nothing from recharts.
62
+ */
63
+ export function ChartLegend({ entries, theme, className, }) {
64
+ if (entries.length === 0)
65
+ return null;
66
+ return (_jsx("div", { className: className, style: {
67
+ display: 'flex',
68
+ flexWrap: 'wrap',
69
+ alignItems: 'center',
70
+ gap: '4px 16px',
71
+ marginTop: 10,
72
+ fontSize: 12,
73
+ color: theme.muted,
74
+ }, children: entries.map((entry) => (_jsxs("span", { style: { display: 'inline-flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
75
+ display: 'inline-block',
76
+ width: 9,
77
+ height: 9,
78
+ borderRadius: '50%',
79
+ background: entry.color,
80
+ flexShrink: 0,
81
+ } }), entry.label] }, entry.label))) }));
82
+ }
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import type { ChartTheme } from './chart_controls.js';
2
3
  import type { ChartDataSeries } from '../types/index.js';
3
4
  export interface LineChartProps {
4
5
  data: ChartDataSeries;
@@ -8,7 +9,28 @@ export interface LineChartProps {
8
9
  height?: number;
9
10
  unit?: string;
10
11
  showTooltip?: boolean;
12
+ /**
13
+ * Series name. Shown in the legend, and as the tooltip's heading when
14
+ * there is no meaningful x label. Omit and the chart stays anonymous —
15
+ * usually right, since a single-line chart is normally already titled by
16
+ * its host card.
17
+ */
18
+ label?: string;
19
+ /**
20
+ * Show the swatch legend under the plot. Defaults to true when `label` is
21
+ * set. The legend is HTML below the SVG, so it never eats plot height.
22
+ */
23
+ showLegend?: boolean;
24
+ /**
25
+ * Axis / grid / tooltip colours. **Pass this on any non-light host.**
26
+ * Until v0.6.0 this component took no theme at all and rendered recharts'
27
+ * stock light-mode axes and gridlines whatever the surrounding app looked
28
+ * like — near-white hairlines on a dark card. Unset fields fall back to
29
+ * `DEFAULT_CHART_THEME`, the same warm-light default `TimeSeriesChart`
30
+ * uses, so the old appearance is still what you get if you pass nothing.
31
+ */
32
+ theme?: Partial<ChartTheme>;
11
33
  className?: string;
12
34
  }
13
- export declare function LineChart({ data, dates, color, width, height, unit, showTooltip, className, }: LineChartProps): React.ReactElement;
35
+ export declare function LineChart({ data, dates, color, width, height, unit, showTooltip, label, showLegend, theme, className, }: LineChartProps): React.ReactElement;
14
36
  //# sourceMappingURL=line_chart.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"line_chart.d.ts","sourceRoot":"","sources":["../../src/charts/line_chart.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAU/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEzD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,eAAe,CAAC;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,KAAK,EACL,KAAK,EACL,MAAM,EACN,IAAS,EACT,WAAkB,EAClB,SAAS,GACV,EAAE,cAAc,GAAG,KAAK,CAAC,YAAY,CA2DrC"}
1
+ {"version":3,"file":"line_chart.d.ts","sourceRoot":"","sources":["../../src/charts/line_chart.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAU/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAOtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEzD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,eAAe,CAAC;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,KAAK,EACL,KAAK,EACL,MAAM,EACN,IAAS,EACT,WAAkB,EAClB,KAAK,EACL,UAAU,EACV,KAAK,EACL,SAAS,GACV,EAAE,cAAc,GAAG,KAAK,CAAC,YAAY,CA6GrC"}
@@ -1,26 +1,29 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts';
4
- export function LineChart({ data, dates, color, width, height, unit = '', showTooltip = true, className, }) {
4
+ import { ChartLegend, ChartTooltip, resolveChartTheme, } from './chart_common.js';
5
+ /** See `TimeSeriesChart`'s copy — same reasoning, same numbers. */
6
+ const CHART_MARGIN = { top: 8, right: 8, bottom: 0, left: -4 };
7
+ export function LineChart({ data, dates, color, width, height, unit = '', showTooltip = true, label, showLegend, theme, className, }) {
8
+ const t = resolveChartTheme(theme);
9
+ const chartHeight = height ?? 130;
5
10
  // Empty data guard — render a placeholder, never throw
6
11
  if (!data || data.length === 0) {
7
12
  return (_jsx("div", { className: className, style: {
8
13
  display: 'flex',
9
14
  alignItems: 'center',
10
15
  justifyContent: 'center',
11
- height: height ?? 130,
12
- color: '#9ca3af',
16
+ height: chartHeight,
17
+ color: t.muted,
13
18
  fontSize: 14,
14
19
  }, children: "No data" }));
15
20
  }
16
21
  const chartData = dates.map((d, i) => ({ x: d, value: data[i] ?? null }));
17
- const chartHeight = height ?? 130;
18
- const tooltipFormatter = unit
19
- ? (v) => [`${v}${unit}`, '']
20
- : undefined;
21
- const inner = (_jsxs(RechartsLineChart, { data: chartData, width: width !== undefined ? width : undefined, height: width !== undefined ? chartHeight : undefined, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "x" }), _jsx(YAxis, { tickFormatter: (v) => unit ? `${v}${unit}` : String(v) }), showTooltip && _jsx(Tooltip, { formatter: tooltipFormatter }), _jsx(Line, { type: "monotone", dataKey: "value", stroke: color, dot: false, connectNulls: false, isAnimationActive: false })] }));
22
+ const legendVisible = showLegend ?? label != null;
23
+ const inner = (_jsxs(RechartsLineChart, { data: chartData, margin: CHART_MARGIN, width: width !== undefined ? width : undefined, height: width !== undefined ? chartHeight : undefined, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "2 6", stroke: t.hairline, strokeOpacity: 0.9 }), _jsx(XAxis, { dataKey: "x", tick: { fontSize: 11, fill: t.muted }, axisLine: false, tickLine: false, tickMargin: 10, minTickGap: 28 }), _jsx(YAxis, { tick: { fontSize: 11, fill: t.muted }, axisLine: false, tickLine: false, tickMargin: 8, width: 40, tickFormatter: (v) => (unit ? `${v}${unit}` : String(v)) }), showTooltip && (_jsx(Tooltip, { isAnimationActive: false, cursor: { stroke: t.muted, strokeWidth: 1, strokeDasharray: '3 3' }, content: (tooltipProps) => (_jsx(ChartTooltip, { active: tooltipProps.active, label: String(tooltipProps.label ?? ''), payload: tooltipProps.payload, unit: unit, theme: t })) })), _jsx(Line, { type: "monotone", dataKey: "value", name: label, stroke: color, strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", dot: false, activeDot: { r: 4, strokeWidth: 2, stroke: t.tooltipSurface ?? t.surface }, connectNulls: false, isAnimationActive: false })] }));
24
+ const legendEl = legendVisible && label != null ? (_jsx(ChartLegend, { entries: [{ label, color }], theme: t })) : null;
22
25
  if (width !== undefined) {
23
- return (_jsx("div", { style: { width, height: chartHeight, overflow: 'hidden' }, className: className, children: inner }));
26
+ return (_jsxs("div", { className: className, children: [_jsx("div", { style: { width, height: chartHeight, overflow: 'hidden' }, children: inner }), legendEl] }));
24
27
  }
25
- return (_jsx("div", { className: className, children: _jsx(ResponsiveContainer, { width: "100%", height: chartHeight, children: inner }) }));
28
+ return (_jsxs("div", { className: className, children: [_jsx(ResponsiveContainer, { width: "100%", height: chartHeight, children: inner }), legendEl] }));
26
29
  }
@@ -31,6 +31,17 @@ export interface TimeSeriesChartProps {
31
31
  showControls?: boolean;
32
32
  /** grains to offer in the (uncontrolled) controls; passthrough to ChartControls */
33
33
  grains?: TimeGrain[];
34
+ /**
35
+ * Show the swatch legend under the plot. Defaults to true whenever more
36
+ * than one series carries a `label` — with a single unlabelled series
37
+ * there is nothing to disambiguate, and the chart title already says what
38
+ * the line is. Pass `false` to suppress it, or `true` to force one on a
39
+ * single labelled series.
40
+ *
41
+ * The legend renders as HTML below the SVG, so switching it on never eats
42
+ * into the `height` you asked for.
43
+ */
44
+ showLegend?: boolean;
34
45
  className?: string;
35
46
  }
36
47
  export declare function TimeSeriesChart(props: TimeSeriesChartProps): React.ReactElement;
@@ -1 +1 @@
1
- {"version":3,"file":"time_series_chart.d.ts","sourceRoot":"","sources":["../../src/charts/time_series_chart.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAQ/B,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAIzD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,eAAe,CAAC;IACtB,kEAAkE;IAClE,GAAG,CAAC,EAAE,GAAG,CAAC;CACX;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,wBAAwB;IACxB,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,qEAAqE;IACrE,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5B,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IACvC,iBAAiB,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IAC3C,uGAAuG;IACvG,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA0ED,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,KAAK,CAAC,YAAY,CAkN/E"}
1
+ {"version":3,"file":"time_series_chart.d.ts","sourceRoot":"","sources":["../../src/charts/time_series_chart.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAQ/B,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAQjE,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAIzD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,eAAe,CAAC;IACtB,kEAAkE;IAClE,GAAG,CAAC,EAAE,GAAG,CAAC;CACX;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,wBAAwB;IACxB,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,qEAAqE;IACrE,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5B,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IACvC,iBAAiB,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IAC3C,uGAAuG;IACvG,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAYD,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,KAAK,CAAC,YAAY,CAiQ/E"}
@@ -4,39 +4,17 @@ import * as React from 'react';
4
4
  import { LineChart as RLineChart, Line, BarChart as RBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from 'recharts';
5
5
  import { aggregateSeries } from '../lib/time_bucket.js';
6
6
  import { ChartControls } from './chart_controls.js';
7
- // ── Default theme ──────────────────────────────────────────────────────────────
8
- const DEFAULT_THEME = {
9
- ink: '#2f2a25',
10
- muted: '#9a9087',
11
- hairline: '#ece6dd',
12
- surface: '#fffdf9',
13
- active: '#f0ebe3',
14
- tooltipSurface: '#fffdf9',
15
- };
16
- function CustomTooltip({ active, label, payload, unit, theme }) {
17
- if (!active || !payload || payload.length === 0)
18
- return null;
19
- return (_jsxs("div", { style: {
20
- // Tooltip must be opaque to be readable; `surface` is often transparent.
21
- background: theme.tooltipSurface ?? (theme.surface === 'transparent' ? '#171717' : theme.surface),
22
- border: `1px solid ${theme.hairline}`,
23
- borderRadius: 6,
24
- padding: '7px 10px',
25
- fontSize: 12,
26
- color: theme.ink,
27
- boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
28
- }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: payload.length > 1 ? 4 : 0 }, children: label }), payload.map((entry, i) => (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
29
- display: 'inline-block',
30
- width: 8,
31
- height: 8,
32
- borderRadius: '50%',
33
- background: entry.color ?? theme.ink,
34
- flexShrink: 0,
35
- } }), payload.length > 1 && entry.name ? (_jsx("span", { style: { color: theme.muted }, children: `${entry.name}: ` })) : null, _jsx("span", { children: entry.value != null ? `${entry.value}${unit ?? ''}` : '—' })] }, i)))] }));
36
- }
7
+ import { ChartLegend, ChartTooltip, resolveChartTheme, } from './chart_common.js';
37
8
  // ── TimeSeriesChart ────────────────────────────────────────────────────────────
9
+ /**
10
+ * Breathing room round the plot. `top: 8` stops a peak's stroke being clipped
11
+ * by the container edge; `right: 8` does the same for the last point's active
12
+ * dot; `left: -4` claws back the gap recharts leaves beside a `width: 40`
13
+ * Y axis whose labels are short.
14
+ */
15
+ const CHART_MARGIN = { top: 8, right: 8, bottom: 0, left: -4 };
38
16
  export function TimeSeriesChart(props) {
39
- const { dates, series, data, color, label, agg, height = 220, unit, theme, referenceDate, grain: grainProp, chartType: chartTypeProp, onGrainChange, onChartTypeChange, grains, className, } = props;
17
+ const { dates, series, data, color, label, agg, height = 220, unit, theme, referenceDate, grain: grainProp, chartType: chartTypeProp, onGrainChange, onChartTypeChange, grains, showLegend, className, } = props;
40
18
  // ── Controlled vs uncontrolled ────────────────────────────────────────────
41
19
  const isControlled = grainProp !== undefined && chartTypeProp !== undefined;
42
20
  const [grainState, setGrainState] = React.useState(grainProp ?? 'day');
@@ -58,7 +36,7 @@ export function TimeSeriesChart(props) {
58
36
  const [mounted, setMounted] = React.useState(false);
59
37
  React.useEffect(() => { setMounted(true); }, []);
60
38
  // ── Resolve merged theme ──────────────────────────────────────────────────
61
- const t = { ...DEFAULT_THEME, ...theme };
39
+ const t = resolveChartTheme(theme);
62
40
  // ── Normalize series ──────────────────────────────────────────────────────
63
41
  let resolvedSeries;
64
42
  if (series && series.length > 0) {
@@ -104,22 +82,50 @@ export function TimeSeriesChart(props) {
104
82
  activeGrain === 'day' &&
105
83
  bucketDates.includes(referenceDate);
106
84
  // ── Shared axis / grid props ──────────────────────────────────────────────
85
+ //
86
+ // No `axisLine`: with a horizontal-only grid the bottom gridline already
87
+ // reads as the baseline, and drawing both puts two near-identical rules on
88
+ // top of each other.
107
89
  const xAxisProps = {
108
90
  dataKey: 'x',
109
91
  tick: { fontSize: 11, fill: t.muted },
110
- axisLine: { stroke: t.hairline },
92
+ axisLine: false,
111
93
  tickLine: false,
94
+ tickMargin: 10,
112
95
  minTickGap: 28,
113
96
  };
114
97
  const yAxisProps = {
115
98
  tick: { fontSize: 11, fill: t.muted },
116
- axisLine: { stroke: t.hairline },
99
+ axisLine: false,
117
100
  tickLine: false,
101
+ tickMargin: 8,
118
102
  width: 40,
119
103
  tickFormatter: yTickFmt,
120
104
  };
121
- const tooltipEl = (_jsx(Tooltip, { isAnimationActive: false, content: (tooltipProps) => (_jsx(CustomTooltip, { active: tooltipProps.active, label: String(tooltipProps.label ?? ''), payload: tooltipProps.payload, unit: unit, theme: t })) }));
105
+ // Vertical gridlines carry no information a time axis doesn't already
106
+ // give, and a full cross-hatch is the single biggest thing that dates a
107
+ // chart. Horizontal only, hairline, widely dashed.
108
+ const gridEl = (_jsx(CartesianGrid, { vertical: false, strokeDasharray: "2 6", stroke: t.hairline, strokeOpacity: 0.9 }));
109
+ const tooltipEl = (_jsx(Tooltip, { isAnimationActive: false,
110
+ // recharts' stock cursor is an opaque light-grey band, which on a dark
111
+ // theme is a bright slab across the plot. Line charts get a hairline;
112
+ // bar charts get a faint wash of the theme's own `active`.
113
+ cursor: activeType === 'bar'
114
+ ? { fill: t.active, fillOpacity: 0.45 }
115
+ : { stroke: t.muted, strokeWidth: 1, strokeDasharray: '3 3' }, content: (tooltipProps) => (_jsx(ChartTooltip, { active: tooltipProps.active, label: String(tooltipProps.label ?? ''), payload: tooltipProps.payload, unit: unit, theme: t })) }));
116
+ // ── Legend ────────────────────────────────────────────────────────────────
117
+ const legendEntries = seriesMeta
118
+ .filter((s) => s.label !== '')
119
+ .map((s) => ({ label: s.label, color: s.color }));
120
+ const legendVisible = showLegend ?? (legendEntries.length > 1 && legendEntries.length === seriesMeta.length);
122
121
  const referenceEl = showReference ? (_jsx(ReferenceLine, { x: referenceDate, stroke: "#fbbf24", strokeDasharray: "4 3", label: { value: 'today', position: 'insideTopRight', fontSize: 10, fill: '#fbbf24' } })) : null;
123
122
  // ── Render ────────────────────────────────────────────────────────────────
124
- return (_jsxs("div", { className: className, children: [showControls && (_jsx("div", { style: { marginBottom: 8 }, children: _jsx(ChartControls, { grain: activeGrain, chartType: activeType, onGrainChange: handleGrain, onChartTypeChange: handleType, grains: grains, theme: theme }) })), _jsx("div", { style: { height, width: '100%' }, children: mounted && activeType === 'bar' ? (_jsx(ResponsiveContainer, { width: "100%", height: height, children: _jsxs(RBarChart, { data: chartRows, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: t.hairline }), _jsx(XAxis, { ...xAxisProps }), _jsx(YAxis, { ...yAxisProps }), tooltipEl, referenceEl, seriesMeta.map((s) => (_jsx(Bar, { dataKey: s.key, name: s.label, fill: s.color, isAnimationActive: false }, s.key)))] }) })) : mounted ? (_jsx(ResponsiveContainer, { width: "100%", height: height, children: _jsxs(RLineChart, { data: chartRows, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: t.hairline }), _jsx(XAxis, { ...xAxisProps }), _jsx(YAxis, { ...yAxisProps }), tooltipEl, referenceEl, seriesMeta.map((s) => (_jsx(Line, { type: "monotone", dataKey: s.key, name: s.label, stroke: s.color, dot: false, connectNulls: false, isAnimationActive: false }, s.key)))] }) })) : null })] }));
123
+ return (_jsxs("div", { className: className, children: [showControls && (_jsx("div", { style: { marginBottom: 8 }, children: _jsx(ChartControls, { grain: activeGrain, chartType: activeType, onGrainChange: handleGrain, onChartTypeChange: handleType, grains: grains, theme: theme }) })), _jsx("div", { style: { height, width: '100%' }, children: mounted && activeType === 'bar' ? (_jsx(ResponsiveContainer, { width: "100%", height: height, children: _jsxs(RBarChart, { data: chartRows, margin: CHART_MARGIN, barGap: 2, children: [gridEl, _jsx(XAxis, { ...xAxisProps }), _jsx(YAxis, { ...yAxisProps }), tooltipEl, referenceEl, seriesMeta.map((s) => (_jsx(Bar, { dataKey: s.key, name: s.label, fill: s.color, radius: [3, 3, 0, 0], maxBarSize: 28, isAnimationActive: false }, s.key)))] }) })) : mounted ? (_jsx(ResponsiveContainer, { width: "100%", height: height, children: _jsxs(RLineChart, { data: chartRows, margin: CHART_MARGIN, children: [gridEl, _jsx(XAxis, { ...xAxisProps }), _jsx(YAxis, { ...yAxisProps }), tooltipEl, referenceEl, seriesMeta.map((s) => (_jsx(Line, { type: "monotone", dataKey: s.key, name: s.label, stroke: s.color, strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", dot: false,
124
+ // A hover dot ringed in the chart surface reads as a marker
125
+ // sitting on the line rather than a blob merged into it.
126
+ activeDot: {
127
+ r: 4,
128
+ strokeWidth: 2,
129
+ stroke: t.tooltipSurface ?? t.surface,
130
+ }, connectNulls: false, isAnimationActive: false }, s.key)))] }) })) : null }), legendVisible ? _jsx(ChartLegend, { entries: legendEntries, theme: t }) : null] }));
125
131
  }
@@ -13,6 +13,8 @@ export { LineChart } from './charts/line_chart.js';
13
13
  export { MultiLineChart } from './charts/multi_line_chart.js';
14
14
  export { TimeSeriesChart } from './charts/time_series_chart.js';
15
15
  export { ChartControls } from './charts/chart_controls.js';
16
+ export { ChartLegend, ChartTooltip, DEFAULT_CHART_THEME, resolveChartTheme, } from './charts/chart_common.js';
17
+ export type { ChartLegendEntry, ChartTooltipProps } from './charts/chart_common.js';
16
18
  export { aggregateSeries, bucketDate } from './lib/time_bucket.js';
17
19
  export type { LineChartProps } from './charts/line_chart.js';
18
20
  export type { MultiLineChartProps } from './charts/multi_line_chart.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,YAAY,EACV,iBAAiB,EACjB,KAAK,EACL,GAAG,EACH,SAAS,EACT,MAAM,EACN,QAAQ,GACT,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AACnF,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC7F,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAGjF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACnE,YAAY,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACxE,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAC5F,YAAY,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAC5F,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGrF,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,YAAY,EACV,iBAAiB,EACjB,KAAK,EACL,GAAG,EACH,SAAS,EACT,MAAM,EACN,QAAQ,GACT,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AACnF,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC7F,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAGjF,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAK3D,OAAO,EACL,WAAW,EACX,YAAY,EACZ,mBAAmB,EACnB,iBAAiB,GAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACnE,YAAY,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACxE,YAAY,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAC5F,YAAY,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAC5F,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGrF,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC"}
@@ -11,6 +11,11 @@ export { LineChart } from './charts/line_chart.js';
11
11
  export { MultiLineChart } from './charts/multi_line_chart.js';
12
12
  export { TimeSeriesChart } from './charts/time_series_chart.js';
13
13
  export { ChartControls } from './charts/chart_controls.js';
14
+ // Shared chart furniture — exported so a consumer building a chart out of
15
+ // another library (or hazo_ui's dependency-free Sparkline/StackedBars) can
16
+ // still put the same themed legend under it rather than hand-rolling a
17
+ // second, slightly-different swatch row.
18
+ export { ChartLegend, ChartTooltip, DEFAULT_CHART_THEME, resolveChartTheme, } from './charts/chart_common.js';
14
19
  export { aggregateSeries, bucketDate } from './lib/time_bucket.js';
15
20
  // Bespoke chart components (pure inline-SVG, no recharts dependency)
16
21
  export { RadialGauge } from './charts/radial_gauge.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_dataviz",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Headless data visualization primitives for hazo apps",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "peerDependencies": {
48
48
  "hazo_api": "^2.6.0",
49
- "hazo_connect": "^3.9.2",
50
- "hazo_core": "^1.2.1",
51
- "hazo_ui": "^6.1.0",
49
+ "hazo_connect": "^4.0.0",
50
+ "hazo_core": "^2.0.0",
51
+ "hazo_ui": "^6.8.0",
52
52
  "react": "^18.0.0 || ^19.0.0",
53
53
  "react-dom": "^18.0.0 || ^19.0.0",
54
54
  "recharts": "^2.15.4 || ^3.0.0"
@@ -66,7 +66,6 @@
66
66
  },
67
67
  "devDependencies": {
68
68
  "@tailwindcss/postcss": "^4.2.4",
69
- "recharts": "^2.15.4",
70
69
  "@testing-library/jest-dom": "^6.0.0",
71
70
  "@testing-library/react": "^16.0.0",
72
71
  "@types/jest": "^30.0.0",
@@ -79,6 +78,7 @@
79
78
  "postcss": "^8.4.49",
80
79
  "react": "^19.0.0",
81
80
  "react-dom": "^19.0.0",
81
+ "recharts": "^2.15.4",
82
82
  "tailwindcss": "^4.2.4",
83
83
  "ts-jest": "^29.4.5",
84
84
  "typescript": "^5.7.2"