hazo_dataviz 0.3.0 → 0.4.0
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 +15 -1
- package/README.md +30 -4
- package/SETUP_CHECKLIST.md +3 -1
- package/dist/charts/chart_controls.d.ts +31 -0
- package/dist/charts/chart_controls.d.ts.map +1 -0
- package/dist/charts/chart_controls.js +75 -0
- package/dist/charts/time_series_chart.d.ts +37 -0
- package/dist/charts/time_series_chart.d.ts.map +1 -0
- package/dist/charts/time_series_chart.js +123 -0
- package/dist/index.client.d.ts +5 -0
- package/dist/index.client.d.ts.map +1 -1
- package/dist/index.client.js +3 -0
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +22 -2
- package/dist/lib/runners/in_memory.d.ts.map +1 -1
- package/dist/lib/runners/in_memory.js +1 -66
- package/dist/lib/time_bucket.d.ts +16 -0
- package/dist/lib/time_bucket.d.ts.map +1 -0
- package/dist/lib/time_bucket.js +99 -0
- package/package.json +6 -5
package/CHANGE_LOG.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# hazo_dataviz — Change Log
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.4.0 — 2026-06-14
|
|
4
|
+
|
|
5
|
+
Interactive time-series widgets with grain roll-up + recharts moved to peerDependencies.
|
|
6
|
+
|
|
7
|
+
- **`TimeSeriesChart`** (`src/charts/time_series_chart.tsx`) — new interactive chart component supporting single- and multi-series data; renders as recharts `LineChart` or `BarChart` with a built-in segmented `ChartControls` toolbar (uncontrolled by default, externally controlled when `grain`/`chartType`/`onGrainChange`/`onChartTypeChange` props are all supplied); auto-rolls up daily data to weekly / monthly / quarterly / yearly via `aggregateSeries`; optional `referenceDate` marker (day grain only); SSR-safe mount guard
|
|
8
|
+
- **`ChartControls`** (`src/charts/chart_controls.tsx`) — standalone segmented-control toolbar for grain (Daily / Weekly / Monthly / Quarterly / Yearly) and chart-type (Line / Bar) selection; fully themeable via `ChartTheme`; exported types `ChartControlsProps`, `ChartType`, `ChartTheme`
|
|
9
|
+
- **`aggregateSeries` / `bucketDate`** extracted to `src/lib/time_bucket.ts`; both now exported from `./client` for consumer reuse
|
|
10
|
+
- **recharts** moved from `dependencies` to `peerDependencies` (`"^2.15.4 || ^3.0.0"`) + `devDependencies` (`^2.15.4`), so consumers (e.g. seo_monitor on recharts 3) supply the single copy
|
|
11
|
+
- **`hazo_core` peer** floor lowered from `^1.2.0` to `^1.0.0` (allows consumers on any hazo_core 1.x; workspace policy forbids `>=` ranges)
|
|
12
|
+
- **Fix (client-safety)**: `lib/format.ts` no longer imports `format_date` from `hazo_core` — that symbol is server-only (absent from hazo_core's `client` export condition), which broke client bundling for consumers on `hazo_core >=1.0.0`. The `date` format case now uses a self-contained formatter (default `dmy`); only client-safe `formatCurrency`/`formatNumber` remain imported
|
|
13
|
+
- **Tests**: added unit coverage for the new surface — `ChartControls` render/interaction (`src/charts/__tests__/chart_controls.test.tsx`), `TimeSeriesChart` empty-state (`src/charts/__tests__/time_series_chart.test.tsx`; full recharts render stays browser-only per the jest ESM/recharts limitation), and the client-safe date formatter codes in `format.test.ts`. Added `/autotest` scenarios for `aggregateSeries`/`bucketDate` grain roll-up and `formatValue`
|
|
14
|
+
- **Test-app**: new **Widgets** sidebar page (`test-app/app/widgets/page.tsx`) that renders `TimeSeriesChart` + `ChartControls` interactively — uncontrolled built-in toolbar, multi-series with per-series agg, a shared controlled toolbar driving two charts, the `referenceDate` marker, and the empty state — over 120 days of deterministic synthetic data
|
|
15
|
+
- **Fix (test-app)**: bumped the test-app's `hazo_dataviz` dep range `^0.3.0` → `^0.4.0` and removed a nested published-`0.3.0` self-shadow under `node_modules/` that was masking the live workspace source (caret on `0.3.0` excludes `0.4.0`)
|
|
16
|
+
|
|
17
|
+
## 0.3.0 — 2026-06-13
|
|
4
18
|
|
|
5
19
|
"Studio" visual restyle — a warm, editorial light theme applied to every shipped
|
|
6
20
|
visualizer and to the test-app showroom.
|
package/README.md
CHANGED
|
@@ -4,15 +4,15 @@ Headless data-visualization primitives for hazo apps. A descriptor-driven data l
|
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
**v0.
|
|
7
|
+
**v0.4.0** — Data layer + built-in visualizers + auto-selection complete, plus interactive time-series widgets (`TimeSeriesChart` / `ChartControls`) with client-side grain roll-up. The server-SQL runner is deferred (blocked on `hazo_connect` GROUP BY support — see FR-003).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install hazo_dataviz hazo_core
|
|
12
|
+
npm install hazo_dataviz hazo_core recharts
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Peer deps: `hazo_core` (required); `
|
|
15
|
+
Peer deps: `hazo_core` (required); `recharts` (required for the chart components — `^2.15.4 || ^3.0.0`, supply one copy yourself); `react` / `react-dom`; `hazo_connect` / `hazo_ui` / `hazo_api` (optional).
|
|
16
16
|
|
|
17
17
|
## Quick start — data layer
|
|
18
18
|
|
|
@@ -98,11 +98,37 @@ For hand-built charts (not query-driven), `hazo_dataviz/client` exports lightwei
|
|
|
98
98
|
recharts wrappers: `LineChart` (single series) and `MultiLineChart` (multi series),
|
|
99
99
|
both with null-gap handling and responsive sizing.
|
|
100
100
|
|
|
101
|
+
### Interactive time-series widgets
|
|
102
|
+
|
|
103
|
+
`TimeSeriesChart` is a higher-level widget for daily time-series data with built-in
|
|
104
|
+
grain roll-up. It accepts single- (`data`) or multi-series (`series`) input, renders as
|
|
105
|
+
a line or bar chart, and rolls daily points up to weekly / monthly / quarterly / yearly
|
|
106
|
+
via `aggregateSeries` as the grain changes. It ships a segmented `ChartControls` toolbar
|
|
107
|
+
(uncontrolled by default; pass all of `grain` / `chartType` / `onGrainChange` /
|
|
108
|
+
`onChartTypeChange` to control it externally and share one toolbar across charts).
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { TimeSeriesChart } from 'hazo_dataviz/client';
|
|
112
|
+
|
|
113
|
+
<TimeSeriesChart
|
|
114
|
+
dates={dates} // ['2024-01-01', ...]
|
|
115
|
+
series={[
|
|
116
|
+
{ label: 'Revenue', color: '#f5563d', data: revenue, agg: 'sum' },
|
|
117
|
+
{ label: 'Sessions', color: '#14a098', data: sessions, agg: 'avg' },
|
|
118
|
+
]}
|
|
119
|
+
unit="k"
|
|
120
|
+
referenceDate="2024-03-01" // dashed "today" marker (day grain only)
|
|
121
|
+
/>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`ChartControls` (grain + chart-type segmented control) and the pure helpers
|
|
125
|
+
`aggregateSeries` / `bucketDate` are also exported standalone for consumer reuse.
|
|
126
|
+
|
|
101
127
|
## Exports
|
|
102
128
|
|
|
103
129
|
| Entry point | Exports |
|
|
104
130
|
|---|---|
|
|
105
|
-
| `hazo_dataviz` (server) / `hazo_dataviz/client` | `parseDescriptor`, `parseQuery`, `derivePickerOptions`, `createInMemoryRunner`, `useDataQuery`, `formatValue` / `formatPercent` / `formatCompact`, `LineChart`, `MultiLineChart`, `CONTRACT_VERSION`, + types |
|
|
131
|
+
| `hazo_dataviz` (server) / `hazo_dataviz/client` | `parseDescriptor`, `parseQuery`, `derivePickerOptions`, `createInMemoryRunner`, `useDataQuery`, `formatValue` / `formatPercent` / `formatCompact`, `LineChart`, `MultiLineChart`, `TimeSeriesChart`, `ChartControls`, `aggregateSeries`, `bucketDate`, `CONTRACT_VERSION`, + types |
|
|
106
132
|
| `hazo_dataviz/visualizers` | `VisualizerView`, `builtinVisualizers`, `selectVisualizer`, `deriveEncoding`, `classifyShape`, + `Visualizer` / `VisualizerOpts` / `QueryShape` types |
|
|
107
133
|
| `hazo_dataviz/testing` | Fixtures (`prices`, `sector_breakdown`, `sparse`) + `createInMemoryRunner` re-export |
|
|
108
134
|
| `hazo_dataviz/api` | HTTP route handler factories (`hazo_api`-based) |
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -9,7 +9,9 @@ npm install hazo_dataviz hazo_core recharts
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
- `hazo_core` is a required peer dependency.
|
|
12
|
-
- `recharts` is required
|
|
12
|
+
- `recharts` is a required peer dependency (`^2.15.4 || ^3.0.0`) of the built-in
|
|
13
|
+
visualizers and `./client` chart primitives (`LineChart`, `MultiLineChart`,
|
|
14
|
+
`TimeSeriesChart`) — supply a single copy in the consuming app.
|
|
13
15
|
- `react` / `react-dom` are peer deps (^18 or ^19).
|
|
14
16
|
- `hazo_connect` / `hazo_ui` / `hazo_api` are optional peers — only needed if you use
|
|
15
17
|
the (future) server-SQL runner or the `./api` route handlers.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { TimeGrain } from '../schema/descriptor.js';
|
|
3
|
+
export type { TimeGrain };
|
|
4
|
+
export type ChartType = 'line' | 'bar';
|
|
5
|
+
export interface ChartTheme {
|
|
6
|
+
/** primary text / active control text */
|
|
7
|
+
ink: string;
|
|
8
|
+
/** muted text / inactive control text */
|
|
9
|
+
muted: string;
|
|
10
|
+
/** hairline borders */
|
|
11
|
+
hairline: string;
|
|
12
|
+
/** control surface background (inactive) */
|
|
13
|
+
surface: string;
|
|
14
|
+
/** active segment background */
|
|
15
|
+
active: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ChartControlsProps {
|
|
18
|
+
grain: TimeGrain;
|
|
19
|
+
chartType: ChartType;
|
|
20
|
+
onGrainChange: (g: TimeGrain) => void;
|
|
21
|
+
onChartTypeChange: (t: ChartType) => void;
|
|
22
|
+
/** subset of grains to show; default ['day','week','month','year'] */
|
|
23
|
+
grains?: TimeGrain[];
|
|
24
|
+
/** subset of chart types to show; default ['line','bar'] */
|
|
25
|
+
chartTypes?: ChartType[];
|
|
26
|
+
/** partial theme override; sensible light defaults when omitted */
|
|
27
|
+
theme?: Partial<ChartTheme>;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function ChartControls({ grain, chartType, onGrainChange, onChartTypeChange, grains, chartTypes, theme, className, }: ChartControlsProps): React.ReactElement;
|
|
31
|
+
//# sourceMappingURL=chart_controls.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chart_controls.d.ts","sourceRoot":"","sources":["../../src/charts/chart_controls.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAGzD,YAAY,EAAE,SAAS,EAAE,CAAC;AAI1B,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,KAAK,CAAC;AAEvC,MAAM,WAAW,UAAU;IACzB,yCAAyC;IACzC,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,SAAS,CAAC;IACrB,aAAa,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IACtC,iBAAiB,EAAE,CAAC,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;IAC1C,sEAAsE;IACtE,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;IACzB,mEAAmE;IACnE,KAAK,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA+ID,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,MAAuB,EACvB,UAAgC,EAChC,KAAK,EACL,SAAS,GACV,EAAE,kBAAkB,GAAG,KAAK,CAAC,YAAY,CA+BzC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
4
|
+
const DEFAULT_THEME = {
|
|
5
|
+
ink: '#2f2a25',
|
|
6
|
+
muted: '#9a9087',
|
|
7
|
+
hairline: '#e7ddd0',
|
|
8
|
+
surface: 'transparent',
|
|
9
|
+
active: '#f0ebe3',
|
|
10
|
+
};
|
|
11
|
+
const DEFAULT_GRAINS = ['day', 'week', 'month', 'year'];
|
|
12
|
+
const DEFAULT_CHART_TYPES = ['line', 'bar'];
|
|
13
|
+
const GRAIN_LABELS = {
|
|
14
|
+
day: 'Daily',
|
|
15
|
+
week: 'Weekly',
|
|
16
|
+
month: 'Monthly',
|
|
17
|
+
quarter: 'Quarterly',
|
|
18
|
+
year: 'Yearly',
|
|
19
|
+
};
|
|
20
|
+
const CHART_TYPE_LABELS = {
|
|
21
|
+
line: 'Line',
|
|
22
|
+
bar: 'Bar',
|
|
23
|
+
};
|
|
24
|
+
// ── Tiny SVG icons (optional visual hint beside each label) ───────────────────
|
|
25
|
+
function LineIcon() {
|
|
26
|
+
return (_jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", style: { flexShrink: 0 }, children: _jsx("polyline", { points: "1,9 4,5 7,7 11,2" }) }));
|
|
27
|
+
}
|
|
28
|
+
function BarIcon() {
|
|
29
|
+
return (_jsxs("svg", { width: "12", height: "12", viewBox: "0 0 12 12", fill: "currentColor", "aria-hidden": "true", style: { flexShrink: 0 }, children: [_jsx("rect", { x: "1", y: "7", width: "2.5", height: "4", rx: "0.5" }), _jsx("rect", { x: "4.75", y: "4", width: "2.5", height: "7", rx: "0.5" }), _jsx("rect", { x: "8.5", y: "1", width: "2.5", height: "10", rx: "0.5" })] }));
|
|
30
|
+
}
|
|
31
|
+
function SegmentedGroup({ options, value, getLabel, getIcon, getTestId, onChange, theme, }) {
|
|
32
|
+
return (_jsx("div", { style: {
|
|
33
|
+
display: 'inline-flex',
|
|
34
|
+
border: `1px solid ${theme.hairline}`,
|
|
35
|
+
borderRadius: 8,
|
|
36
|
+
overflow: 'hidden',
|
|
37
|
+
}, children: options.map((opt, idx) => {
|
|
38
|
+
const isActive = opt === value;
|
|
39
|
+
const isFirst = idx === 0;
|
|
40
|
+
return (_jsxs("button", { type: "button", "data-testid": getTestId(opt), "aria-pressed": isActive, onClick: () => onChange(opt), style: {
|
|
41
|
+
display: 'inline-flex',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
gap: 5,
|
|
44
|
+
padding: '5px 11px',
|
|
45
|
+
fontSize: 12,
|
|
46
|
+
fontWeight: isActive ? 600 : 400,
|
|
47
|
+
lineHeight: 1,
|
|
48
|
+
color: isActive ? theme.ink : theme.muted,
|
|
49
|
+
background: isActive ? theme.active : theme.surface,
|
|
50
|
+
border: 'none',
|
|
51
|
+
borderLeft: isFirst ? 'none' : `1px solid ${theme.hairline}`,
|
|
52
|
+
cursor: 'pointer',
|
|
53
|
+
whiteSpace: 'nowrap',
|
|
54
|
+
transition: 'background 0.12s, color 0.12s',
|
|
55
|
+
// Reset browser button styles
|
|
56
|
+
outline: 'none',
|
|
57
|
+
appearance: 'none',
|
|
58
|
+
WebkitAppearance: 'none',
|
|
59
|
+
}, children: [getIcon ? getIcon(opt) : null, getLabel(opt)] }, opt));
|
|
60
|
+
}) }));
|
|
61
|
+
}
|
|
62
|
+
// ── ChartControls ─────────────────────────────────────────────────────────────
|
|
63
|
+
function getChartTypeIcon(t) {
|
|
64
|
+
if (t === 'line')
|
|
65
|
+
return _jsx(LineIcon, {});
|
|
66
|
+
return _jsx(BarIcon, {});
|
|
67
|
+
}
|
|
68
|
+
export function ChartControls({ grain, chartType, onGrainChange, onChartTypeChange, grains = DEFAULT_GRAINS, chartTypes = DEFAULT_CHART_TYPES, theme, className, }) {
|
|
69
|
+
const t = { ...DEFAULT_THEME, ...theme };
|
|
70
|
+
return (_jsxs("div", { className: className, style: {
|
|
71
|
+
display: 'inline-flex',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
gap: 8,
|
|
74
|
+
}, children: [_jsx(SegmentedGroup, { options: grains, value: grain, getLabel: (g) => GRAIN_LABELS[g], getTestId: (g) => `grain-${g}`, onChange: onGrainChange, theme: t }), _jsx(SegmentedGroup, { options: chartTypes, value: chartType, getLabel: (ct) => CHART_TYPE_LABELS[ct], getIcon: getChartTypeIcon, getTestId: (ct) => `charttype-${ct}`, onChange: onChartTypeChange, theme: t })] }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { ChartType, ChartTheme } from './chart_controls.js';
|
|
3
|
+
import type { Agg, TimeGrain } from '../schema/descriptor.js';
|
|
4
|
+
import type { ChartDataSeries } from '../types/index.js';
|
|
5
|
+
export interface TimeSeriesSeries {
|
|
6
|
+
label?: string;
|
|
7
|
+
color: string;
|
|
8
|
+
data: ChartDataSeries;
|
|
9
|
+
/** per-series aggregation for the grain roll-up; default 'sum' */
|
|
10
|
+
agg?: Agg;
|
|
11
|
+
}
|
|
12
|
+
export interface TimeSeriesChartProps {
|
|
13
|
+
dates: string[];
|
|
14
|
+
/** multi-series form */
|
|
15
|
+
series?: TimeSeriesSeries[];
|
|
16
|
+
/** single-series convenience form (used when `series` is omitted) */
|
|
17
|
+
data?: ChartDataSeries;
|
|
18
|
+
color?: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
agg?: Agg;
|
|
21
|
+
height?: number;
|
|
22
|
+
unit?: string;
|
|
23
|
+
theme?: Partial<ChartTheme>;
|
|
24
|
+
/** draws a "today" reference marker, ONLY when the active grain === 'day' */
|
|
25
|
+
referenceDate?: string;
|
|
26
|
+
grain?: TimeGrain;
|
|
27
|
+
chartType?: ChartType;
|
|
28
|
+
onGrainChange?: (g: TimeGrain) => void;
|
|
29
|
+
onChartTypeChange?: (t: ChartType) => void;
|
|
30
|
+
/** when uncontrolled, render the ChartControls header (default true uncontrolled, false controlled) */
|
|
31
|
+
showControls?: boolean;
|
|
32
|
+
/** grains to offer in the (uncontrolled) controls; passthrough to ChartControls */
|
|
33
|
+
grains?: TimeGrain[];
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function TimeSeriesChart(props: TimeSeriesChartProps): React.ReactElement;
|
|
37
|
+
//# sourceMappingURL=time_series_chart.d.ts.map
|
|
@@ -0,0 +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;AAoED,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,KAAK,CAAC,YAAY,CAkN/E"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { LineChart as RLineChart, Line, BarChart as RBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from 'recharts';
|
|
5
|
+
import { aggregateSeries } from '../lib/time_bucket.js';
|
|
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
|
+
};
|
|
15
|
+
function CustomTooltip({ active, label, payload, unit, theme }) {
|
|
16
|
+
if (!active || !payload || payload.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
return (_jsxs("div", { style: {
|
|
19
|
+
background: theme.surface,
|
|
20
|
+
border: `1px solid ${theme.hairline}`,
|
|
21
|
+
borderRadius: 6,
|
|
22
|
+
padding: '7px 10px',
|
|
23
|
+
fontSize: 12,
|
|
24
|
+
color: theme.ink,
|
|
25
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
|
26
|
+
}, 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: {
|
|
27
|
+
display: 'inline-block',
|
|
28
|
+
width: 8,
|
|
29
|
+
height: 8,
|
|
30
|
+
borderRadius: '50%',
|
|
31
|
+
background: entry.color ?? theme.ink,
|
|
32
|
+
flexShrink: 0,
|
|
33
|
+
} }), _jsx("span", { style: { color: theme.muted }, children: entry.name ? `${entry.name}: ` : '' }), _jsx("span", { children: entry.value != null ? `${entry.value}${unit ?? ''}` : '—' })] }, i)))] }));
|
|
34
|
+
}
|
|
35
|
+
// ── TimeSeriesChart ────────────────────────────────────────────────────────────
|
|
36
|
+
export function TimeSeriesChart(props) {
|
|
37
|
+
const { dates, series, data, color, label, agg, height = 220, unit, theme, referenceDate, grain: grainProp, chartType: chartTypeProp, onGrainChange, onChartTypeChange, grains, className, } = props;
|
|
38
|
+
// ── Controlled vs uncontrolled ────────────────────────────────────────────
|
|
39
|
+
const isControlled = grainProp !== undefined && chartTypeProp !== undefined;
|
|
40
|
+
const [grainState, setGrainState] = React.useState(grainProp ?? 'day');
|
|
41
|
+
const [typeState, setTypeState] = React.useState(chartTypeProp ?? 'line');
|
|
42
|
+
const activeGrain = isControlled ? grainProp : grainState;
|
|
43
|
+
const activeType = isControlled ? chartTypeProp : typeState;
|
|
44
|
+
const showControls = props.showControls ?? !isControlled;
|
|
45
|
+
const handleGrain = React.useCallback((g) => {
|
|
46
|
+
if (!isControlled)
|
|
47
|
+
setGrainState(g);
|
|
48
|
+
onGrainChange?.(g);
|
|
49
|
+
}, [isControlled, onGrainChange]);
|
|
50
|
+
const handleType = React.useCallback((t) => {
|
|
51
|
+
if (!isControlled)
|
|
52
|
+
setTypeState(t);
|
|
53
|
+
onChartTypeChange?.(t);
|
|
54
|
+
}, [isControlled, onChartTypeChange]);
|
|
55
|
+
// ── SSR mount guard ───────────────────────────────────────────────────────
|
|
56
|
+
const [mounted, setMounted] = React.useState(false);
|
|
57
|
+
React.useEffect(() => { setMounted(true); }, []);
|
|
58
|
+
// ── Resolve merged theme ──────────────────────────────────────────────────
|
|
59
|
+
const t = { ...DEFAULT_THEME, ...theme };
|
|
60
|
+
// ── Normalize series ──────────────────────────────────────────────────────
|
|
61
|
+
let resolvedSeries;
|
|
62
|
+
if (series && series.length > 0) {
|
|
63
|
+
resolvedSeries = series;
|
|
64
|
+
}
|
|
65
|
+
else if (data) {
|
|
66
|
+
resolvedSeries = [{ label, color: color ?? '#38bdf8', data, agg }];
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
resolvedSeries = [];
|
|
70
|
+
}
|
|
71
|
+
// ── Empty state ───────────────────────────────────────────────────────────
|
|
72
|
+
if (resolvedSeries.length === 0) {
|
|
73
|
+
return (_jsx("div", { className: className, style: {
|
|
74
|
+
display: 'flex',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
justifyContent: 'center',
|
|
77
|
+
height,
|
|
78
|
+
color: t.muted,
|
|
79
|
+
fontSize: 14,
|
|
80
|
+
}, children: "No data" }));
|
|
81
|
+
}
|
|
82
|
+
// ── Roll-up ───────────────────────────────────────────────────────────────
|
|
83
|
+
const rolled = resolvedSeries.map((s) => aggregateSeries(dates, s.data, activeGrain, s.agg ?? 'sum'));
|
|
84
|
+
const bucketDates = rolled[0]?.dates ?? [];
|
|
85
|
+
// Build recharts row objects: [{ x: date, s0: val, s1: val, ... }]
|
|
86
|
+
const seriesMeta = resolvedSeries.map((s, i) => ({
|
|
87
|
+
key: `s${i}`,
|
|
88
|
+
color: s.color,
|
|
89
|
+
label: s.label ?? (resolvedSeries.length > 1 ? `Series ${i + 1}` : ''),
|
|
90
|
+
}));
|
|
91
|
+
const chartRows = bucketDates.map((d, i) => {
|
|
92
|
+
const row = { x: d };
|
|
93
|
+
for (let si = 0; si < rolled.length; si++) {
|
|
94
|
+
row[`s${si}`] = rolled[si]?.values[i] ?? null;
|
|
95
|
+
}
|
|
96
|
+
return row;
|
|
97
|
+
});
|
|
98
|
+
// ── Axis formatters ───────────────────────────────────────────────────────
|
|
99
|
+
const yTickFmt = (v) => unit ? `${v}${unit}` : String(v);
|
|
100
|
+
// ── ReferenceLine guard ───────────────────────────────────────────────────
|
|
101
|
+
const showReference = referenceDate !== undefined &&
|
|
102
|
+
activeGrain === 'day' &&
|
|
103
|
+
bucketDates.includes(referenceDate);
|
|
104
|
+
// ── Shared axis / grid props ──────────────────────────────────────────────
|
|
105
|
+
const xAxisProps = {
|
|
106
|
+
dataKey: 'x',
|
|
107
|
+
tick: { fontSize: 11, fill: t.muted },
|
|
108
|
+
axisLine: { stroke: t.hairline },
|
|
109
|
+
tickLine: false,
|
|
110
|
+
minTickGap: 28,
|
|
111
|
+
};
|
|
112
|
+
const yAxisProps = {
|
|
113
|
+
tick: { fontSize: 11, fill: t.muted },
|
|
114
|
+
axisLine: { stroke: t.hairline },
|
|
115
|
+
tickLine: false,
|
|
116
|
+
width: 40,
|
|
117
|
+
tickFormatter: yTickFmt,
|
|
118
|
+
};
|
|
119
|
+
const tooltipEl = (_jsx(Tooltip, { isAnimationActive: false, content: (tooltipProps) => (_jsx(CustomTooltip, { active: tooltipProps.active, label: String(tooltipProps.label ?? ''), payload: tooltipProps.payload, unit: unit, theme: t })) }));
|
|
120
|
+
const referenceEl = showReference ? (_jsx(ReferenceLine, { x: referenceDate, stroke: "#fbbf24", strokeDasharray: "4 3", label: { value: 'today', position: 'insideTopRight', fontSize: 10, fill: '#fbbf24' } })) : null;
|
|
121
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
122
|
+
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
|
+
}
|
package/dist/index.client.d.ts
CHANGED
|
@@ -11,7 +11,12 @@ export type { Result, Column, Encoding, QueryRunner, PickerOptions } from './typ
|
|
|
11
11
|
export type { QueryStatus, UseDataQueryResult } from './hooks/use_data_query.js';
|
|
12
12
|
export { LineChart } from './charts/line_chart.js';
|
|
13
13
|
export { MultiLineChart } from './charts/multi_line_chart.js';
|
|
14
|
+
export { TimeSeriesChart } from './charts/time_series_chart.js';
|
|
15
|
+
export { ChartControls } from './charts/chart_controls.js';
|
|
16
|
+
export { aggregateSeries, bucketDate } from './lib/time_bucket.js';
|
|
14
17
|
export type { LineChartProps } from './charts/line_chart.js';
|
|
15
18
|
export type { MultiLineChartProps } from './charts/multi_line_chart.js';
|
|
19
|
+
export type { TimeSeriesChartProps, TimeSeriesSeries } from './charts/time_series_chart.js';
|
|
20
|
+
export type { ChartControlsProps, ChartType, ChartTheme } from './charts/chart_controls.js';
|
|
16
21
|
export type { ChartDataPoint, ChartDataSeries, MultiSeries } from './types/index.js';
|
|
17
22
|
//# sourceMappingURL=index.client.d.ts.map
|
|
@@ -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,YAAY,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACxE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,kBAAkB,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;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"}
|
package/dist/index.client.js
CHANGED
|
@@ -9,3 +9,6 @@ export { useDataQuery } from './hooks/use_data_query.js';
|
|
|
9
9
|
// Bespoke chart components (recharts-based)
|
|
10
10
|
export { LineChart } from './charts/line_chart.js';
|
|
11
11
|
export { MultiLineChart } from './charts/multi_line_chart.js';
|
|
12
|
+
export { TimeSeriesChart } from './charts/time_series_chart.js';
|
|
13
|
+
export { ChartControls } from './charts/chart_controls.js';
|
|
14
|
+
export { aggregateSeries, bucketDate } from './lib/time_bucket.js';
|
package/dist/lib/format.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/lib/format.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/lib/format.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAwBtD,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAclE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAMjE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKnD"}
|
package/dist/lib/format.js
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
|
-
import { formatCurrency, formatNumber
|
|
1
|
+
import { formatCurrency, formatNumber } from 'hazo_core';
|
|
2
2
|
const LOCALE = 'en-US';
|
|
3
|
+
/**
|
|
4
|
+
* Client-safe date formatter. hazo_core's `format_date` is server-only (it is
|
|
5
|
+
* absent from hazo_core's `client` export condition), and this module is part
|
|
6
|
+
* of the client barrel, so we format dates self-contained here instead.
|
|
7
|
+
* Supported `dateFormat` codes mirror the common hazo_core ones; default `dmy`.
|
|
8
|
+
*/
|
|
9
|
+
function formatDateValue(value, dateFormat) {
|
|
10
|
+
const d = new Date(value);
|
|
11
|
+
if (Number.isNaN(d.getTime()))
|
|
12
|
+
return value;
|
|
13
|
+
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
14
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
15
|
+
const yyyy = String(d.getUTCFullYear());
|
|
16
|
+
switch (dateFormat) {
|
|
17
|
+
case 'mdy': return `${mm}/${dd}/${yyyy}`;
|
|
18
|
+
case 'ymd': return `${yyyy}-${mm}-${dd}`;
|
|
19
|
+
case 'dmy':
|
|
20
|
+
default: return `${dd}/${mm}/${yyyy}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
3
23
|
export function formatValue(value, format) {
|
|
4
24
|
if (value === null || value === undefined)
|
|
5
25
|
return '';
|
|
@@ -13,7 +33,7 @@ export function formatValue(value, format) {
|
|
|
13
33
|
case 'compact':
|
|
14
34
|
return formatCompact(Number(value));
|
|
15
35
|
case 'date':
|
|
16
|
-
return
|
|
36
|
+
return formatDateValue(String(value), format.dateFormat);
|
|
17
37
|
}
|
|
18
38
|
}
|
|
19
39
|
export function formatPercent(value, decimals = 2) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"in_memory.d.ts","sourceRoot":"","sources":["../../../src/lib/runners/in_memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"in_memory.d.ts","sourceRoot":"","sources":["../../../src/lib/runners/in_memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAEpE,OAAO,KAAK,EAAkB,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAgCxE,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,iBAAiB,EAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAC9B,WAAW,CAkMb"}
|
|
@@ -1,70 +1,5 @@
|
|
|
1
1
|
import { HazoError } from 'hazo_core';
|
|
2
|
-
|
|
3
|
-
// Time-grain bucketing
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
function isoWeek(d) {
|
|
6
|
-
// ISO 8601: week starts Monday; week 1 = week containing first Thursday
|
|
7
|
-
const thursday = new Date(d);
|
|
8
|
-
thursday.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7) + 3);
|
|
9
|
-
const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1));
|
|
10
|
-
const weekNum = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
11
|
-
return `${thursday.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
|
12
|
-
}
|
|
13
|
-
function bucketDate(value, grain) {
|
|
14
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
15
|
-
if (isNaN(d.getTime()))
|
|
16
|
-
return String(value);
|
|
17
|
-
const y = d.getUTCFullYear();
|
|
18
|
-
const m = d.getUTCMonth(); // 0-based
|
|
19
|
-
switch (grain) {
|
|
20
|
-
case 'day':
|
|
21
|
-
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
|
22
|
-
case 'week':
|
|
23
|
-
return isoWeek(d);
|
|
24
|
-
case 'month':
|
|
25
|
-
return `${y}-${String(m + 1).padStart(2, '0')}`;
|
|
26
|
-
case 'quarter':
|
|
27
|
-
return `${y}-Q${Math.floor(m / 3) + 1}`;
|
|
28
|
-
case 'year':
|
|
29
|
-
return String(y);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Aggregation helpers (SQL null semantics — nulls are skipped)
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
function numericValues(vals) {
|
|
36
|
-
return vals.filter((v) => v !== null && v !== undefined && typeof v === 'number' && !isNaN(v));
|
|
37
|
-
}
|
|
38
|
-
function aggregate(vals, agg, rawVals) {
|
|
39
|
-
if (agg === 'count')
|
|
40
|
-
return vals.filter((v) => v !== null && v !== undefined).length;
|
|
41
|
-
const nums = numericValues(vals);
|
|
42
|
-
if (nums.length === 0)
|
|
43
|
-
return null;
|
|
44
|
-
switch (agg) {
|
|
45
|
-
case 'sum': return nums.reduce((a, b) => a + b, 0);
|
|
46
|
-
case 'avg': return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
47
|
-
case 'min': return Math.min(...nums);
|
|
48
|
-
case 'max': return Math.max(...nums);
|
|
49
|
-
case 'last': {
|
|
50
|
-
// last = value at max raw value in the group (rawVals = temporal dim values)
|
|
51
|
-
if (!rawVals || rawVals.length === 0)
|
|
52
|
-
return nums[nums.length - 1] ?? null;
|
|
53
|
-
let maxRaw = rawVals[0];
|
|
54
|
-
let lastVal = vals[0];
|
|
55
|
-
for (let i = 1; i < rawVals.length; i++) {
|
|
56
|
-
const rv = rawVals[i];
|
|
57
|
-
const curr = typeof rv === 'string' || typeof rv === 'number' ? rv : String(rv);
|
|
58
|
-
const max = typeof maxRaw === 'string' || typeof maxRaw === 'number' ? maxRaw : String(maxRaw);
|
|
59
|
-
if (curr > max) {
|
|
60
|
-
maxRaw = rv;
|
|
61
|
-
lastVal = vals[i];
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return lastVal ?? null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
2
|
+
import { bucketDate, aggregate } from '../time_bucket.js';
|
|
68
3
|
// ---------------------------------------------------------------------------
|
|
69
4
|
// Filter application
|
|
70
5
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Agg, TimeGrain } from '../schema/descriptor.js';
|
|
2
|
+
import type { ChartDataSeries } from '../types/index.js';
|
|
3
|
+
export declare function isoWeek(d: Date): string;
|
|
4
|
+
export declare function bucketDate(value: unknown, grain: TimeGrain): string;
|
|
5
|
+
export declare function aggregate(vals: unknown[], agg: Agg, rawVals?: unknown[]): unknown;
|
|
6
|
+
/**
|
|
7
|
+
* Buckets parallel `dates`/`values` arrays by `grain` and aggregates each
|
|
8
|
+
* bucket with `agg`. Output order follows first-seen bucket key order
|
|
9
|
+
* (ascending when input dates are ascending). Nulls are skipped per SQL
|
|
10
|
+
* semantics; a bucket with no numeric values returns null.
|
|
11
|
+
*/
|
|
12
|
+
export declare function aggregateSeries(dates: string[], values: ChartDataSeries, grain: TimeGrain, agg: Agg): {
|
|
13
|
+
dates: string[];
|
|
14
|
+
values: ChartDataSeries;
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=time_bucket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time_bucket.d.ts","sourceRoot":"","sources":["../../src/lib/time_bucket.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAMzD,wBAAgB,OAAO,CAAC,CAAC,EAAE,IAAI,GAAG,MAAM,CAOvC;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,CAiBnE;AAUD,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CA0BjF;AAMD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,eAAe,EACvB,KAAK,EAAE,SAAS,EAChB,GAAG,EAAE,GAAG,GACP;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAuB9C"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Time-grain bucketing and series aggregation utilities.
|
|
3
|
+
// Extracted from runners/in_memory.ts so they can be used by chart helpers.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// ISO week / date bucketing
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export function isoWeek(d) {
|
|
9
|
+
// ISO 8601: week starts Monday; week 1 = week containing first Thursday
|
|
10
|
+
const thursday = new Date(d);
|
|
11
|
+
thursday.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7) + 3);
|
|
12
|
+
const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1));
|
|
13
|
+
const weekNum = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
14
|
+
return `${thursday.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
|
15
|
+
}
|
|
16
|
+
export function bucketDate(value, grain) {
|
|
17
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
18
|
+
if (isNaN(d.getTime()))
|
|
19
|
+
return String(value);
|
|
20
|
+
const y = d.getUTCFullYear();
|
|
21
|
+
const m = d.getUTCMonth(); // 0-based
|
|
22
|
+
switch (grain) {
|
|
23
|
+
case 'day':
|
|
24
|
+
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
|
25
|
+
case 'week':
|
|
26
|
+
return isoWeek(d);
|
|
27
|
+
case 'month':
|
|
28
|
+
return `${y}-${String(m + 1).padStart(2, '0')}`;
|
|
29
|
+
case 'quarter':
|
|
30
|
+
return `${y}-Q${Math.floor(m / 3) + 1}`;
|
|
31
|
+
case 'year':
|
|
32
|
+
return String(y);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Aggregation helpers (SQL null semantics — nulls are skipped)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
function numericValues(vals) {
|
|
39
|
+
return vals.filter((v) => v !== null && v !== undefined && typeof v === 'number' && !isNaN(v));
|
|
40
|
+
}
|
|
41
|
+
export function aggregate(vals, agg, rawVals) {
|
|
42
|
+
if (agg === 'count')
|
|
43
|
+
return vals.filter((v) => v !== null && v !== undefined).length;
|
|
44
|
+
const nums = numericValues(vals);
|
|
45
|
+
if (nums.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
switch (agg) {
|
|
48
|
+
case 'sum': return nums.reduce((a, b) => a + b, 0);
|
|
49
|
+
case 'avg': return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
50
|
+
case 'min': return Math.min(...nums);
|
|
51
|
+
case 'max': return Math.max(...nums);
|
|
52
|
+
case 'last': {
|
|
53
|
+
// last = value at max raw value in the group (rawVals = temporal dim values)
|
|
54
|
+
if (!rawVals || rawVals.length === 0)
|
|
55
|
+
return nums[nums.length - 1] ?? null;
|
|
56
|
+
let maxRaw = rawVals[0];
|
|
57
|
+
let lastVal = vals[0];
|
|
58
|
+
for (let i = 1; i < rawVals.length; i++) {
|
|
59
|
+
const rv = rawVals[i];
|
|
60
|
+
const curr = typeof rv === 'string' || typeof rv === 'number' ? rv : String(rv);
|
|
61
|
+
const max = typeof maxRaw === 'string' || typeof maxRaw === 'number' ? maxRaw : String(maxRaw);
|
|
62
|
+
if (curr > max) {
|
|
63
|
+
maxRaw = rv;
|
|
64
|
+
lastVal = vals[i];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return lastVal ?? null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// aggregateSeries — bucket parallel date/value arrays into time-grain buckets
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
/**
|
|
75
|
+
* Buckets parallel `dates`/`values` arrays by `grain` and aggregates each
|
|
76
|
+
* bucket with `agg`. Output order follows first-seen bucket key order
|
|
77
|
+
* (ascending when input dates are ascending). Nulls are skipped per SQL
|
|
78
|
+
* semantics; a bucket with no numeric values returns null.
|
|
79
|
+
*/
|
|
80
|
+
export function aggregateSeries(dates, values, grain, agg) {
|
|
81
|
+
// Use a Map to preserve insertion order (first-seen = ascending for sorted input)
|
|
82
|
+
const buckets = new Map();
|
|
83
|
+
for (let i = 0; i < dates.length; i++) {
|
|
84
|
+
const key = bucketDate(dates[i], grain);
|
|
85
|
+
const val = values[i] !== undefined ? values[i] : null;
|
|
86
|
+
if (!buckets.has(key)) {
|
|
87
|
+
buckets.set(key, []);
|
|
88
|
+
}
|
|
89
|
+
buckets.get(key).push(val);
|
|
90
|
+
}
|
|
91
|
+
const outDates = [];
|
|
92
|
+
const outValues = [];
|
|
93
|
+
for (const [key, vals] of buckets) {
|
|
94
|
+
outDates.push(key);
|
|
95
|
+
const aggregated = aggregate(vals, agg);
|
|
96
|
+
outValues.push(typeof aggregated === 'number' ? aggregated : null);
|
|
97
|
+
}
|
|
98
|
+
return { dates: outDates, values: outValues };
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_dataviz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Headless data visualization primitives for hazo apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -42,16 +42,16 @@
|
|
|
42
42
|
"build:test-app": "npm run build && cd test-app && npm run build"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"recharts": "^2.15.4",
|
|
46
45
|
"zod": "^4.1.12"
|
|
47
46
|
},
|
|
48
47
|
"peerDependencies": {
|
|
49
48
|
"hazo_api": "^2.4.0",
|
|
50
|
-
"hazo_connect": "^3.
|
|
51
|
-
"hazo_core": "^1.
|
|
49
|
+
"hazo_connect": "^3.7.0",
|
|
50
|
+
"hazo_core": "^1.0.0",
|
|
52
51
|
"hazo_ui": "^4.0.0",
|
|
53
52
|
"react": "^18.0.0 || ^19.0.0",
|
|
54
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
53
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
54
|
+
"recharts": "^2.15.4 || ^3.0.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"hazo_connect": {
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@tailwindcss/postcss": "^4.2.4",
|
|
69
|
+
"recharts": "^2.15.4",
|
|
69
70
|
"@testing-library/jest-dom": "^6.0.0",
|
|
70
71
|
"@testing-library/react": "^16.0.0",
|
|
71
72
|
"@types/jest": "^30.0.0",
|