@siyahi/ui 0.1.9-next.23 → 0.1.9-next.25
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/agents/examples/change-grid-money.tsx +36 -0
- package/agents/examples/change-grid.tsx +42 -0
- package/agents/examples/delta.tsx +11 -6
- package/agents/examples/drawdown-chart.tsx +35 -0
- package/agents/examples/heatmap-calendar-signed.tsx +67 -0
- package/agents/examples/heatmap-status.tsx +6 -4
- package/agents/examples/histogram-chart.tsx +28 -0
- package/agents/examples/scatter-chart-quadrants.tsx +63 -0
- package/agents/examples/scatter-chart.tsx +75 -0
- package/agents/examples/slope-chart-market.tsx +33 -0
- package/agents/examples/slope-chart.tsx +26 -0
- package/agents/examples/waterfall-chart-stores.tsx +27 -0
- package/agents/examples/waterfall-chart.tsx +37 -0
- package/agents/manifest.json +160 -0
- package/dist/components/registry/heatmap/calendar-heatmap.d.ts +8 -2
- package/dist/components/registry/heatmap/calendar-heatmap.js +96 -18
- package/dist/components/registry/heatmap/status-heatmap.js +86 -47
- package/dist/components/siyahi/change-grid.d.ts +31 -0
- package/dist/components/siyahi/change-grid.js +74 -0
- package/dist/components/siyahi/delta.js +2 -2
- package/dist/components/siyahi/drawdown-chart.d.ts +34 -0
- package/dist/components/siyahi/drawdown-chart.js +102 -0
- package/dist/components/siyahi/histogram-chart.d.ts +57 -0
- package/dist/components/siyahi/histogram-chart.js +134 -0
- package/dist/components/siyahi/scatter-chart.d.ts +59 -0
- package/dist/components/siyahi/scatter-chart.js +367 -0
- package/dist/components/siyahi/slope-chart.d.ts +35 -0
- package/dist/components/siyahi/slope-chart.js +159 -0
- package/dist/components/siyahi/treemap.js +3 -7
- package/dist/components/siyahi/waterfall-chart.d.ts +51 -0
- package/dist/components/siyahi/waterfall-chart.js +147 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +7 -1
- package/dist/lib/chart-frame.d.ts +7 -2
- package/dist/lib/chart-frame.js +44 -10
- package/dist/lib/chart-series.d.ts +2 -3
- package/dist/lib/fit-text.d.ts +6 -0
- package/dist/lib/fit-text.js +11 -0
- package/dist/lib/heat.d.ts +7 -0
- package/dist/lib/heat.js +14 -0
- package/dist/lib/scatter.d.ts +98 -0
- package/dist/lib/scatter.js +218 -0
- package/dist/lib/treemap.d.ts +0 -5
- package/dist/lib/treemap.js +1 -16
- package/package.json +1 -1
- package/report.css +9 -0
- package/styles/siyahi.css +23 -5
|
@@ -2,14 +2,14 @@ import { cn } from "../../../lib/utils.js";
|
|
|
2
2
|
import { useHeatmapNavigation } from "../../../lib/heatmap-navigation.js";
|
|
3
3
|
import { useElementWidth } from "../../../lib/use-element-width.js";
|
|
4
4
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
-
import { Fragment as Fragment$1, createContext, use, useMemo } from "react";
|
|
5
|
+
import { Fragment as Fragment$1, createContext, use, useId, useMemo } from "react";
|
|
6
6
|
import { format } from "date-fns";
|
|
7
7
|
//#region src/components/registry/heatmap/status-heatmap.tsx
|
|
8
8
|
const DEFAULT_COLORS = {
|
|
9
9
|
0: "var(--color-secondary)",
|
|
10
10
|
1: "var(--destructive)",
|
|
11
11
|
2: "var(--warning)",
|
|
12
|
-
3: "color-mix(in oklab, var(--muted-foreground)
|
|
12
|
+
3: "color-mix(in oklab, var(--muted-foreground) 30%, transparent)"
|
|
13
13
|
};
|
|
14
14
|
const SEMANTIC_KEYS = {
|
|
15
15
|
0: "noData",
|
|
@@ -47,11 +47,28 @@ const resolveStatusLabel = (value, labels) => {
|
|
|
47
47
|
default: return `Status ${value}`;
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const DEGRADED = 2;
|
|
51
|
+
/** A degraded day's paint: amber in the lower half of the bar, the healthy grey above it. */
|
|
52
|
+
const DegradedGradient = ({ id, colors, healthyValue }) => /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsxs("linearGradient", {
|
|
53
|
+
id,
|
|
54
|
+
x1: "0",
|
|
55
|
+
y1: "0",
|
|
56
|
+
x2: "0",
|
|
57
|
+
y2: "1",
|
|
58
|
+
children: [/* @__PURE__ */ jsx("stop", {
|
|
59
|
+
offset: "0.5",
|
|
60
|
+
style: { stopColor: resolveColor(healthyValue, colors) }
|
|
61
|
+
}), /* @__PURE__ */ jsx("stop", {
|
|
62
|
+
offset: "0.5",
|
|
63
|
+
style: { stopColor: resolveColor(DEGRADED, colors) }
|
|
64
|
+
})]
|
|
65
|
+
}) });
|
|
66
|
+
const NO_DATA = 0;
|
|
67
|
+
/** A no-data day is drawn hollow unless the page gave that state a colour of its own. */
|
|
68
|
+
const isHollow = (value, colors) => value === NO_DATA && colors?.[NO_DATA] === void 0 && colors?.noData === void 0;
|
|
69
|
+
/** The outline a day carries in the field-edge colour: solid round a healthy day, dashed round a day with no data. */
|
|
70
|
+
const edgeOf = (value, healthyValue, colors) => isHollow(value, colors) ? "dashed" : value === healthyValue ? "solid" : null;
|
|
71
|
+
const blockPaint = (value, degradedId, colors) => value === DEGRADED ? { fill: `url(#${degradedId})` } : { fill: isHollow(value, colors) ? "transparent" : resolveColor(value, colors) };
|
|
55
72
|
const EMPTY_STYLE = {};
|
|
56
73
|
const LEGEND_SWATCH = 10;
|
|
57
74
|
const MIN_FITTED_BAR = 2;
|
|
@@ -99,6 +116,7 @@ const StatusHeatmap = ({ data, dateFormat = "MMM d", blockSize = 40, blockMargin
|
|
|
99
116
|
3
|
|
100
117
|
], healthyValue = 3, style = EMPTY_STYLE, className, children, ...props }) => {
|
|
101
118
|
const [rootRef, rootWidth] = useElementWidth();
|
|
119
|
+
const degradedId = `status-degraded-${useId().replace(/[^\w-]/g, "")}`;
|
|
102
120
|
const labels = useMemo(() => ({
|
|
103
121
|
statuses: {
|
|
104
122
|
noData: "No Data",
|
|
@@ -125,6 +143,7 @@ const StatusHeatmap = ({ data, dateFormat = "MMM d", blockSize = 40, blockMargin
|
|
|
125
143
|
const width = dates.length * (blockWidth + blockMargin) - blockMargin;
|
|
126
144
|
const height = blockSize;
|
|
127
145
|
const contextValue = useMemo(() => ({
|
|
146
|
+
degradedId,
|
|
128
147
|
data,
|
|
129
148
|
dates,
|
|
130
149
|
blockMargin,
|
|
@@ -142,6 +161,7 @@ const StatusHeatmap = ({ data, dateFormat = "MMM d", blockSize = 40, blockMargin
|
|
|
142
161
|
locale,
|
|
143
162
|
colors
|
|
144
163
|
}), [
|
|
164
|
+
degradedId,
|
|
145
165
|
data,
|
|
146
166
|
dates,
|
|
147
167
|
blockMargin,
|
|
@@ -176,7 +196,8 @@ const StatusHeatmap = ({ data, dateFormat = "MMM d", blockSize = 40, blockMargin
|
|
|
176
196
|
});
|
|
177
197
|
};
|
|
178
198
|
const StatusHeatmapBlock = ({ ref, activity, dayIndex, highlighted = false, selected = false, onCellClick, onCellHover, onClick, onKeyDown, onMouseEnter, onMouseLeave, className, style: styleProp, ...props }) => {
|
|
179
|
-
const { blockSize, blockWidth, blockMargin, blockRadius, labels, colors } = useStatusHeatmap();
|
|
199
|
+
const { blockSize, blockWidth, blockMargin, blockRadius, labels, colors, degradedId, healthyValue } = useStatusHeatmap();
|
|
200
|
+
const edge = edgeOf(activity.value, healthyValue, colors);
|
|
180
201
|
const statusText = resolveStatusLabel(activity.value, labels.statuses);
|
|
181
202
|
const ariaLabel = (labels.cellLabel ?? "{{date}}: {{status}}").replace("{{date}}", activity.date).replace("{{status}}", statusText);
|
|
182
203
|
const xPosition = (blockWidth + blockMargin) * dayIndex;
|
|
@@ -187,7 +208,7 @@ const StatusHeatmapBlock = ({ ref, activity, dayIndex, highlighted = false, sele
|
|
|
187
208
|
"aria-pressed": onCellClick ? selected : void 0,
|
|
188
209
|
tabIndex: onCellClick ? 0 : -1,
|
|
189
210
|
"aria-label": ariaLabel,
|
|
190
|
-
className: cn("
|
|
211
|
+
className: cn(edge && "stroke-input stroke-1", "hover:stroke-foreground hover:stroke-1", highlighted && "stroke-foreground stroke-1", selected && "stroke-foreground stroke-2 hover:stroke-2", onCellClick && "cursor-pointer focus-visible:outline-none focus-visible:stroke-ring focus-visible:stroke-2", className),
|
|
191
212
|
"data-value": activity.value,
|
|
192
213
|
"data-date": activity.date,
|
|
193
214
|
"data-highlighted": highlighted || void 0,
|
|
@@ -196,14 +217,11 @@ const StatusHeatmapBlock = ({ ref, activity, dayIndex, highlighted = false, sele
|
|
|
196
217
|
rx: blockRadius,
|
|
197
218
|
ry: blockRadius,
|
|
198
219
|
width: blockWidth,
|
|
220
|
+
strokeDasharray: edge === "dashed" ? "2 2" : void 0,
|
|
199
221
|
x: xPosition,
|
|
200
222
|
y: 0,
|
|
201
223
|
style: {
|
|
202
|
-
|
|
203
|
-
...selected ? {
|
|
204
|
-
stroke: "var(--foreground)",
|
|
205
|
-
strokeWidth: 2
|
|
206
|
-
} : {},
|
|
224
|
+
...blockPaint(activity.value, degradedId, colors),
|
|
207
225
|
...styleProp
|
|
208
226
|
},
|
|
209
227
|
onClick: (event) => {
|
|
@@ -231,7 +249,7 @@ const StatusHeatmapBlock = ({ ref, activity, dayIndex, highlighted = false, sele
|
|
|
231
249
|
StatusHeatmapBlock.displayName = "StatusHeatmapBlock";
|
|
232
250
|
const StatusHeatmapBody = ({ hideDateLabels = false, labelInterval = 30, className, labelClassName, children, ...props }) => {
|
|
233
251
|
const navigation = useHeatmapNavigation();
|
|
234
|
-
const { data, dates, width, height, blockWidth, blockMargin, fontSize, dateFormat, locale, labels } = useStatusHeatmap();
|
|
252
|
+
const { data, dates, width, height, blockWidth, blockMargin, fontSize, dateFormat, locale, labels, colors, degradedId, healthyValue } = useStatusHeatmap();
|
|
235
253
|
const activityMap = useMemo(() => {
|
|
236
254
|
const map = /* @__PURE__ */ new Map();
|
|
237
255
|
data.forEach((activity) => {
|
|
@@ -265,31 +283,39 @@ const StatusHeatmapBody = ({ hideDateLabels = false, labelInterval = 30, classNa
|
|
|
265
283
|
height: height + labelHeight + 6,
|
|
266
284
|
viewBox: `-3 -3 ${width + 6} ${height + labelHeight + 6}`,
|
|
267
285
|
width: width + 6,
|
|
268
|
-
children: [
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
286
|
+
children: [
|
|
287
|
+
/* @__PURE__ */ jsx(DegradedGradient, {
|
|
288
|
+
id: degradedId,
|
|
289
|
+
colors,
|
|
290
|
+
healthyValue
|
|
291
|
+
}),
|
|
292
|
+
!hideDateLabels && /* @__PURE__ */ jsx("g", {
|
|
293
|
+
className: cn("fill-current font-mono", labelClassName),
|
|
294
|
+
children: dates.map((date, dayIndex) => {
|
|
295
|
+
const isFirst = dayIndex === 0;
|
|
296
|
+
const isLast = dayIndex === dates.length - 1;
|
|
297
|
+
if (dayIndex % labelInterval !== 0 && !isLast) return null;
|
|
298
|
+
const blockLeft = (blockWidth + blockMargin) * dayIndex;
|
|
299
|
+
const xPosition = isFirst ? blockLeft : isLast ? blockLeft + blockWidth : blockLeft + blockWidth / 2;
|
|
300
|
+
const anchor = isFirst ? "start" : isLast ? "end" : "middle";
|
|
301
|
+
return /* @__PURE__ */ jsx("text", {
|
|
302
|
+
x: xPosition,
|
|
303
|
+
y: height + labelFontSize,
|
|
304
|
+
dominantBaseline: "hanging",
|
|
305
|
+
textAnchor: anchor,
|
|
306
|
+
style: { fontSize: `${labelFontSize}px` },
|
|
307
|
+
children: format(new Date(date), dateFormat, { locale })
|
|
308
|
+
}, `date-${date}`);
|
|
309
|
+
})
|
|
310
|
+
}),
|
|
311
|
+
allActivities.map((activity, dayIndex) => {
|
|
312
|
+
const key = activity.date;
|
|
313
|
+
return /* @__PURE__ */ jsx(Fragment$1, { children: children({
|
|
314
|
+
activity,
|
|
315
|
+
dayIndex
|
|
316
|
+
}) }, key);
|
|
285
317
|
})
|
|
286
|
-
|
|
287
|
-
const key = activity.date;
|
|
288
|
-
return /* @__PURE__ */ jsx(Fragment$1, { children: children({
|
|
289
|
-
activity,
|
|
290
|
-
dayIndex
|
|
291
|
-
}) }, key);
|
|
292
|
-
})]
|
|
318
|
+
]
|
|
293
319
|
})
|
|
294
320
|
});
|
|
295
321
|
};
|
|
@@ -314,7 +340,7 @@ const StatusHeatmapStat = ({ compute, label, className, children, ...props }) =>
|
|
|
314
340
|
});
|
|
315
341
|
};
|
|
316
342
|
const StatusHeatmapLegend = ({ labels: labelsProp, className, children, ...props }) => {
|
|
317
|
-
const { labels, statusValues, blockRadius, colors } = useStatusHeatmap();
|
|
343
|
+
const { labels, statusValues, blockRadius, colors, degradedId, healthyValue } = useStatusHeatmap();
|
|
318
344
|
const statuses = statusValues.map((value) => ({
|
|
319
345
|
value,
|
|
320
346
|
label: resolveStatusLabel(value, labelsProp ?? labels.statuses)
|
|
@@ -322,25 +348,38 @@ const StatusHeatmapLegend = ({ labels: labelsProp, className, children, ...props
|
|
|
322
348
|
return /* @__PURE__ */ jsx("fieldset", {
|
|
323
349
|
"data-slot": "status-heatmap-legend",
|
|
324
350
|
"aria-label": labels.legendLabel ?? "Status legend",
|
|
325
|
-
className: cn("ml-auto flex items-center gap-3", className),
|
|
351
|
+
className: cn("ml-auto flex flex-wrap items-center gap-x-3 gap-y-1", className),
|
|
326
352
|
...props,
|
|
327
353
|
children: statuses.map((status) => children ? /* @__PURE__ */ jsx(Fragment$1, { children: children({
|
|
328
354
|
value: status.value,
|
|
329
355
|
label: status.label
|
|
330
356
|
}) }, `status-${status.value}`) : /* @__PURE__ */ jsxs("div", {
|
|
331
357
|
className: "flex items-center gap-1",
|
|
332
|
-
children: [/* @__PURE__ */
|
|
358
|
+
children: [/* @__PURE__ */ jsxs("svg", {
|
|
333
359
|
"aria-hidden": "true",
|
|
334
360
|
height: LEGEND_SWATCH,
|
|
335
361
|
width: LEGEND_SWATCH,
|
|
336
|
-
children: /* @__PURE__ */ jsx(
|
|
362
|
+
children: [status.value === DEGRADED && /* @__PURE__ */ jsx(DegradedGradient, {
|
|
363
|
+
id: `${degradedId}-legend`,
|
|
364
|
+
colors,
|
|
365
|
+
healthyValue
|
|
366
|
+
}), /* @__PURE__ */ jsx("rect", {
|
|
337
367
|
"data-value": status.value,
|
|
338
|
-
|
|
368
|
+
...edgeOf(status.value, healthyValue, colors) ? {
|
|
369
|
+
x: .5,
|
|
370
|
+
y: .5,
|
|
371
|
+
height: 9,
|
|
372
|
+
width: 9,
|
|
373
|
+
className: "stroke-input",
|
|
374
|
+
strokeDasharray: edgeOf(status.value, healthyValue, colors) === "dashed" ? "2 1" : void 0
|
|
375
|
+
} : {
|
|
376
|
+
height: LEGEND_SWATCH,
|
|
377
|
+
width: LEGEND_SWATCH
|
|
378
|
+
},
|
|
339
379
|
rx: blockRadius,
|
|
340
380
|
ry: blockRadius,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
})
|
|
381
|
+
style: blockPaint(status.value, `${degradedId}-legend`, colors)
|
|
382
|
+
})]
|
|
344
383
|
}), /* @__PURE__ */ jsx("span", {
|
|
345
384
|
className: "font-medium text-muted-foreground text-xs",
|
|
346
385
|
children: status.label
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/components/siyahi/change-grid.d.ts
|
|
2
|
+
export interface ChangeGridRow {
|
|
3
|
+
label: string;
|
|
4
|
+
/** One figure per column, in column order; null where there is none, such as a month still to come. NaN counts as none. */
|
|
5
|
+
values: readonly (number | null)[];
|
|
6
|
+
/** The row's total as the page works it out: monthly returns compound rather than add. Leave it out for no total. */
|
|
7
|
+
total?: number | null;
|
|
8
|
+
/** Unique key when labels repeat. Defaults to `label`. */
|
|
9
|
+
key?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ChangeGridProps {
|
|
12
|
+
/** The column heads, such as the months of a financial year. */
|
|
13
|
+
columns: readonly string[];
|
|
14
|
+
rows: readonly ChangeGridRow[];
|
|
15
|
+
/** "pct" takes fractions, 0.025 for 2.5%; "money" takes amounts, written short in one unit. */
|
|
16
|
+
format?: "pct" | "money";
|
|
17
|
+
/** The change that takes the deepest tint, as a size. Defaults to the grid's largest, totals aside. */
|
|
18
|
+
range?: number;
|
|
19
|
+
/** Heads the total column, which draws when any row has a total. */
|
|
20
|
+
totalLabel?: string;
|
|
21
|
+
/** Names the table for a screen reader, such as "Monthly sales growth by year". */
|
|
22
|
+
label: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Signed changes laid out rows by columns, such as each month of each year, every cell tinted by its sign and size
|
|
27
|
+
* as a Treemap tile is and written with its sign, with an optional total per row. The figure is always written, so
|
|
28
|
+
* the tint only helps the eye find the large months.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ChangeGrid({ columns, rows, format, range, totalLabel, label, className }: ChangeGridProps): import("react").JSX.Element;
|
|
31
|
+
//#endregion
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table.js";
|
|
2
|
+
import { useFormat } from "../../lib/format.js";
|
|
3
|
+
import { heat } from "../../lib/heat.js";
|
|
4
|
+
import { cn } from "cn";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/components/siyahi/change-grid.tsx
|
|
7
|
+
const MINUS = "−";
|
|
8
|
+
/** A figure the grid can draw: anything that is not a finite number, NaN included, is no figure at all. */
|
|
9
|
+
const known = (value) => value != null && Number.isFinite(value) ? value : null;
|
|
10
|
+
/**
|
|
11
|
+
* Signed changes laid out rows by columns, such as each month of each year, every cell tinted by its sign and size
|
|
12
|
+
* as a Treemap tile is and written with its sign, with an optional total per row. The figure is always written, so
|
|
13
|
+
* the tint only helps the eye find the large months.
|
|
14
|
+
*/
|
|
15
|
+
function ChangeGrid({ columns, rows, format = "pct", range, totalLabel = "Total", label, className }) {
|
|
16
|
+
const f = useFormat();
|
|
17
|
+
const sizes = rows.flatMap(({ values }) => values.flatMap((value) => known(value) ?? []).map(Math.abs));
|
|
18
|
+
const deepest = known(range) ?? Math.max(0, ...sizes);
|
|
19
|
+
const write = (value) => {
|
|
20
|
+
if (value === null) return "—";
|
|
21
|
+
const size = format === "money" ? f.moneyCompact(Math.abs(value), deepest) : f.pct(Math.abs(value));
|
|
22
|
+
return `${value > 0 ? "+" : value < 0 ? MINUS : ""}${size}`;
|
|
23
|
+
};
|
|
24
|
+
const totals = rows.some((row) => row.total !== void 0);
|
|
25
|
+
return /* @__PURE__ */ jsxs(Table, {
|
|
26
|
+
"aria-label": label,
|
|
27
|
+
"data-slot": "change-grid",
|
|
28
|
+
className: cn("border-separate border-spacing-0.5", className),
|
|
29
|
+
children: [/* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, {
|
|
30
|
+
className: "hover:bg-transparent",
|
|
31
|
+
children: [
|
|
32
|
+
/* @__PURE__ */ jsx(TableHead, {
|
|
33
|
+
className: "sticky left-0 bg-card",
|
|
34
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
35
|
+
className: "sr-only",
|
|
36
|
+
children: "Row"
|
|
37
|
+
})
|
|
38
|
+
}),
|
|
39
|
+
columns.map((column) => /* @__PURE__ */ jsx(TableHead, {
|
|
40
|
+
className: "text-right",
|
|
41
|
+
children: column
|
|
42
|
+
}, column)),
|
|
43
|
+
totals && /* @__PURE__ */ jsx(TableHead, {
|
|
44
|
+
className: "text-right",
|
|
45
|
+
children: totalLabel
|
|
46
|
+
})
|
|
47
|
+
]
|
|
48
|
+
}) }), /* @__PURE__ */ jsx(TableBody, { children: rows.map((row) => /* @__PURE__ */ jsxs(TableRow, {
|
|
49
|
+
className: "border-0 hover:bg-transparent",
|
|
50
|
+
children: [
|
|
51
|
+
/* @__PURE__ */ jsx(TableHead, {
|
|
52
|
+
scope: "row",
|
|
53
|
+
className: "sticky left-0 bg-card font-medium",
|
|
54
|
+
children: row.label
|
|
55
|
+
}),
|
|
56
|
+
columns.map((column, index) => {
|
|
57
|
+
const value = known(row.values[index]);
|
|
58
|
+
return /* @__PURE__ */ jsx(TableCell, {
|
|
59
|
+
"data-slot": "change-grid-cell",
|
|
60
|
+
className: cn("rounded-sm text-right font-mono tabular-nums", value === null && "text-muted-foreground"),
|
|
61
|
+
style: { backgroundColor: heat(value, deepest) },
|
|
62
|
+
children: write(value)
|
|
63
|
+
}, column);
|
|
64
|
+
}),
|
|
65
|
+
totals && /* @__PURE__ */ jsx(TableCell, {
|
|
66
|
+
className: "text-right font-mono font-medium tabular-nums",
|
|
67
|
+
children: write(known(row.total))
|
|
68
|
+
})
|
|
69
|
+
]
|
|
70
|
+
}, row.key ?? row.label)) })]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { ChangeGrid };
|
|
@@ -17,11 +17,11 @@ function Delta({ value, format = "pct", className }) {
|
|
|
17
17
|
return /* @__PURE__ */ jsxs("span", {
|
|
18
18
|
"data-slot": "delta",
|
|
19
19
|
"data-direction": direction,
|
|
20
|
-
className: cn("inline-flex items-
|
|
20
|
+
className: cn("inline-flex items-baseline gap-1 whitespace-nowrap", className),
|
|
21
21
|
children: [
|
|
22
22
|
/* @__PURE__ */ jsx(Icon, {
|
|
23
23
|
"aria-hidden": "true",
|
|
24
|
-
className: cn("size-3.5 shrink-0", direction === "up" && "text-positive", direction === "down" && "text-negative", direction === "none" && "text-muted-foreground")
|
|
24
|
+
className: cn("size-3.5 shrink-0 self-center", direction === "up" && "text-positive", direction === "down" && "text-negative", direction === "none" && "text-muted-foreground")
|
|
25
25
|
}),
|
|
26
26
|
direction === "up" ? "+" : direction === "down" ? MINUS : "",
|
|
27
27
|
size
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NumericKey, SeriesChartProps } from "../../lib/chart-series.js";
|
|
2
|
+
//#region src/components/siyahi/drawdown-chart.d.ts
|
|
3
|
+
/** One point as drawn: how far the level sits below the highest it had reached, and that high. */
|
|
4
|
+
export interface DrawdownPoint<X> {
|
|
5
|
+
x: X;
|
|
6
|
+
/** The fall from the peak as a fraction, 0 at a new high and −0.25 a quarter below it; null when the level is unknown. */
|
|
7
|
+
drawdown: number | null;
|
|
8
|
+
/** The highest level so far. */
|
|
9
|
+
peak: number;
|
|
10
|
+
level: number | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Walks the levels in order, keeping the highest so far. A level that is not a finite number has no drawdown and
|
|
14
|
+
* leaves the peak as it was; the first level is the first peak.
|
|
15
|
+
*/
|
|
16
|
+
export declare function drawdowns<X>(levels: readonly {
|
|
17
|
+
x: X;
|
|
18
|
+
level: number | null;
|
|
19
|
+
}[]): DrawdownPoint<X>[];
|
|
20
|
+
export interface DrawdownChartCardProps<T extends object, K extends keyof T> extends Pick<SeriesChartProps<T, K>, "title" | "description" | "action" | "data" | "xKey" | "height" | "xFormatter" | "xTooltipFormatter" | "xTicks" | "placeholder" | "footer"> {
|
|
21
|
+
/** The field holding the level, such as the day's cash balance or a fund's value. */
|
|
22
|
+
levelKey: NumericKey<T>;
|
|
23
|
+
/** Writes the level and the peak in the tooltip. Defaults to useFormat().money. */
|
|
24
|
+
levelFormatter?: (value: number) => string;
|
|
25
|
+
/** Names the fall in the legend and tooltip. */
|
|
26
|
+
label?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* How far a level sits below the highest it had reached, over time: a fund's or a cash balance's fall from its
|
|
30
|
+
* peak. The area hangs from zero in the loss colour, so the deepest and longest falls read at once, and a dashed
|
|
31
|
+
* line marks the deepest one. Levels must be above zero: a fall is a share of the peak.
|
|
32
|
+
*/
|
|
33
|
+
export declare function DrawdownChartCard<T extends object, K extends keyof T>({ data, xKey, levelKey, levelFormatter, label, xFormatter, xTooltipFormatter, xTicks, ...props }: DrawdownChartCardProps<T, K>): import("react").JSX.Element;
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ChartFrame } from "../../lib/chart-frame.js";
|
|
2
|
+
import { useFormat } from "../../lib/format.js";
|
|
3
|
+
import { linearTicks } from "../../lib/scatter.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Area, AreaChart } from "recharts";
|
|
7
|
+
//#region src/components/siyahi/drawdown-chart.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Walks the levels in order, keeping the highest so far. A level that is not a finite number has no drawdown and
|
|
10
|
+
* leaves the peak as it was; the first level is the first peak.
|
|
11
|
+
*/
|
|
12
|
+
function drawdowns(levels) {
|
|
13
|
+
let peak = Number.NEGATIVE_INFINITY;
|
|
14
|
+
return levels.map(({ x, level }) => {
|
|
15
|
+
if (level === null || !Number.isFinite(level)) return {
|
|
16
|
+
x,
|
|
17
|
+
level: null,
|
|
18
|
+
drawdown: null,
|
|
19
|
+
peak
|
|
20
|
+
};
|
|
21
|
+
peak = Math.max(peak, level);
|
|
22
|
+
return {
|
|
23
|
+
x,
|
|
24
|
+
level,
|
|
25
|
+
peak,
|
|
26
|
+
drawdown: peak > 0 ? level / peak - 1 : 0
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* How far a level sits below the highest it had reached, over time: a fund's or a cash balance's fall from its
|
|
32
|
+
* peak. The area hangs from zero in the loss colour, so the deepest and longest falls read at once, and a dashed
|
|
33
|
+
* line marks the deepest one. Levels must be above zero: a fall is a share of the peak.
|
|
34
|
+
*/
|
|
35
|
+
function DrawdownChartCard({ data, xKey, levelKey, levelFormatter, label = "Below peak", xFormatter = String, xTooltipFormatter, xTicks, ...props }) {
|
|
36
|
+
const f = useFormat();
|
|
37
|
+
const formatLevel = levelFormatter ?? ((value) => f.money(value));
|
|
38
|
+
const points = useMemo(() => drawdowns(data.map((row) => ({
|
|
39
|
+
x: row[xKey],
|
|
40
|
+
level: row[levelKey]
|
|
41
|
+
}))), [
|
|
42
|
+
data,
|
|
43
|
+
xKey,
|
|
44
|
+
levelKey
|
|
45
|
+
]);
|
|
46
|
+
const fall = (value) => value < 0 ? `−${f.pct(-value)}` : f.pct(0);
|
|
47
|
+
const deepest = Math.min(0, ...points.flatMap(({ drawdown }) => drawdown ?? []));
|
|
48
|
+
const ticks = linearTicks(Math.min(deepest, -.01), 0);
|
|
49
|
+
return /* @__PURE__ */ jsx(ChartFrame, {
|
|
50
|
+
chart: AreaChart,
|
|
51
|
+
...props,
|
|
52
|
+
data: points,
|
|
53
|
+
xKey: "x",
|
|
54
|
+
xFormatter: (x) => xFormatter(x),
|
|
55
|
+
xTooltipFormatter: (x) => (xTooltipFormatter ?? xFormatter)(x),
|
|
56
|
+
xTicks,
|
|
57
|
+
series: [{
|
|
58
|
+
key: "drawdown",
|
|
59
|
+
label,
|
|
60
|
+
color: "var(--negative)"
|
|
61
|
+
}],
|
|
62
|
+
yDomain: [Math.min(deepest, ticks[0] ?? deepest), 0],
|
|
63
|
+
yTicks: ticks,
|
|
64
|
+
yTickFormatter: (value) => fall(value),
|
|
65
|
+
referenceLines: deepest < 0 ? [{
|
|
66
|
+
y: deepest,
|
|
67
|
+
label: `Deepest ${fall(deepest)}`,
|
|
68
|
+
labelPosition: "insideBottomRight"
|
|
69
|
+
}] : [],
|
|
70
|
+
tooltipRow: (_value, _name, item) => {
|
|
71
|
+
const point = item.payload;
|
|
72
|
+
return (point.drawdown === null ? [[label, "—"]] : [
|
|
73
|
+
[label, fall(point.drawdown)],
|
|
74
|
+
["Level", formatLevel(point.level ?? 0)],
|
|
75
|
+
["Peak so far", formatLevel(point.peak)]
|
|
76
|
+
]).map(([name, figure]) => /* @__PURE__ */ jsxs("div", {
|
|
77
|
+
className: "flex w-full items-center gap-2",
|
|
78
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
79
|
+
className: "text-muted-foreground",
|
|
80
|
+
children: name
|
|
81
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
82
|
+
className: "ml-auto font-mono font-medium text-foreground tabular-nums",
|
|
83
|
+
children: figure
|
|
84
|
+
})]
|
|
85
|
+
}, name));
|
|
86
|
+
},
|
|
87
|
+
children: /* @__PURE__ */ jsx(Area, {
|
|
88
|
+
dataKey: "drawdown",
|
|
89
|
+
name: label,
|
|
90
|
+
type: "linear",
|
|
91
|
+
baseValue: 0,
|
|
92
|
+
stroke: "var(--negative)",
|
|
93
|
+
strokeWidth: 2,
|
|
94
|
+
fill: "color-mix(in oklab, var(--negative) 25%, var(--card))",
|
|
95
|
+
fillOpacity: 1,
|
|
96
|
+
dot: false,
|
|
97
|
+
connectNulls: false
|
|
98
|
+
})
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { DrawdownChartCard, drawdowns };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ChartReferenceLine, SeriesChartProps } from "../../lib/chart-series.js";
|
|
2
|
+
//#region src/components/siyahi/histogram-chart.d.ts
|
|
3
|
+
/** One bucket: the values from `from` up to, not including, `to`; the last bucket takes its upper edge too. */
|
|
4
|
+
export interface HistogramBin {
|
|
5
|
+
key: string;
|
|
6
|
+
from: number;
|
|
7
|
+
to: number;
|
|
8
|
+
count: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Buckets of one round width (1, 2, 2.5 or 5 × 10ⁿ) covering the values, about `target` of them. Values that are not
|
|
12
|
+
* finite numbers are left out.
|
|
13
|
+
*/
|
|
14
|
+
export declare function histogram(values: readonly number[], target?: number): HistogramBin[];
|
|
15
|
+
/**
|
|
16
|
+
* The bucket a value falls in, read against the buckets' own edges: dividing by the width instead put 0.3 in the
|
|
17
|
+
* 0.2–0.3 bucket, as 0.3 / 0.1 is 2.9999999999999996. A value past the last edge belongs to the last bucket.
|
|
18
|
+
*/
|
|
19
|
+
export declare function bucketOf(bins: readonly HistogramBin[], value: number): number;
|
|
20
|
+
/** The value below which `share` of the values fall, interpolating between the two nearest, as spreadsheets do. */
|
|
21
|
+
export declare function percentile(values: readonly number[], share: number): number | null;
|
|
22
|
+
declare const MARKS: {
|
|
23
|
+
readonly median: 0.5;
|
|
24
|
+
readonly p90: 0.9;
|
|
25
|
+
readonly p95: 0.95;
|
|
26
|
+
readonly p99: 0.99;
|
|
27
|
+
};
|
|
28
|
+
export type HistogramMark = keyof typeof MARKS;
|
|
29
|
+
/**
|
|
30
|
+
* One line per bucket that holds a mark, named by its marks: marks that share a bucket share a line and a name,
|
|
31
|
+
* "median, p90", rather than drawing over each other.
|
|
32
|
+
*/
|
|
33
|
+
export declare function markLines(bins: readonly HistogramBin[], marked: readonly {
|
|
34
|
+
mark: HistogramMark;
|
|
35
|
+
value: number;
|
|
36
|
+
}[]): ChartReferenceLine[];
|
|
37
|
+
export interface HistogramChartCardProps extends Pick<SeriesChartProps<HistogramBin, "key">, "title" | "description" | "action" | "height" | "xLabel" | "yLabel" | "placeholder" | "footer"> {
|
|
38
|
+
/** The raw values, one per item, such as each request's response time. */
|
|
39
|
+
values: readonly number[];
|
|
40
|
+
/** About how many buckets; the width is rounded, so the count varies a little. */
|
|
41
|
+
bins?: number;
|
|
42
|
+
/** Writes the bucket edges and the marks, such as (v) => `${v} ms`. Defaults to the locale's numbers. */
|
|
43
|
+
valueFormatter?: (value: number) => string;
|
|
44
|
+
/** The bucket edges under the axis, when they should be shorter than valueFormatter: 1,000 against 1,000 ms. */
|
|
45
|
+
tickFormatter?: (value: number) => string;
|
|
46
|
+
/** What is counted, in the legend and tooltip, such as "Requests". */
|
|
47
|
+
countLabel?: string;
|
|
48
|
+
/** Percentiles to mark with a line at their bucket; their exact values are written under the chart. */
|
|
49
|
+
marks?: readonly HistogramMark[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* How values spread: counts in buckets of one round width, such as response times by 50 ms, with the median or a
|
|
53
|
+
* high percentile marked. The page gives the raw values; the card buckets them.
|
|
54
|
+
*/
|
|
55
|
+
export declare function HistogramChartCard({ values, bins: target, valueFormatter, tickFormatter, countLabel, marks, ...props }: HistogramChartCardProps): import("react").JSX.Element;
|
|
56
|
+
//#endregion
|
|
57
|
+
export {};
|