@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.
Files changed (47) hide show
  1. package/agents/examples/change-grid-money.tsx +36 -0
  2. package/agents/examples/change-grid.tsx +42 -0
  3. package/agents/examples/delta.tsx +11 -6
  4. package/agents/examples/drawdown-chart.tsx +35 -0
  5. package/agents/examples/heatmap-calendar-signed.tsx +67 -0
  6. package/agents/examples/heatmap-status.tsx +6 -4
  7. package/agents/examples/histogram-chart.tsx +28 -0
  8. package/agents/examples/scatter-chart-quadrants.tsx +63 -0
  9. package/agents/examples/scatter-chart.tsx +75 -0
  10. package/agents/examples/slope-chart-market.tsx +33 -0
  11. package/agents/examples/slope-chart.tsx +26 -0
  12. package/agents/examples/waterfall-chart-stores.tsx +27 -0
  13. package/agents/examples/waterfall-chart.tsx +37 -0
  14. package/agents/manifest.json +160 -0
  15. package/dist/components/registry/heatmap/calendar-heatmap.d.ts +8 -2
  16. package/dist/components/registry/heatmap/calendar-heatmap.js +96 -18
  17. package/dist/components/registry/heatmap/status-heatmap.js +86 -47
  18. package/dist/components/siyahi/change-grid.d.ts +31 -0
  19. package/dist/components/siyahi/change-grid.js +74 -0
  20. package/dist/components/siyahi/delta.js +2 -2
  21. package/dist/components/siyahi/drawdown-chart.d.ts +34 -0
  22. package/dist/components/siyahi/drawdown-chart.js +102 -0
  23. package/dist/components/siyahi/histogram-chart.d.ts +57 -0
  24. package/dist/components/siyahi/histogram-chart.js +134 -0
  25. package/dist/components/siyahi/scatter-chart.d.ts +59 -0
  26. package/dist/components/siyahi/scatter-chart.js +367 -0
  27. package/dist/components/siyahi/slope-chart.d.ts +35 -0
  28. package/dist/components/siyahi/slope-chart.js +159 -0
  29. package/dist/components/siyahi/treemap.js +3 -7
  30. package/dist/components/siyahi/waterfall-chart.d.ts +51 -0
  31. package/dist/components/siyahi/waterfall-chart.js +147 -0
  32. package/dist/index.d.ts +9 -2
  33. package/dist/index.js +7 -1
  34. package/dist/lib/chart-frame.d.ts +7 -2
  35. package/dist/lib/chart-frame.js +44 -10
  36. package/dist/lib/chart-series.d.ts +2 -3
  37. package/dist/lib/fit-text.d.ts +6 -0
  38. package/dist/lib/fit-text.js +11 -0
  39. package/dist/lib/heat.d.ts +7 -0
  40. package/dist/lib/heat.js +14 -0
  41. package/dist/lib/scatter.d.ts +98 -0
  42. package/dist/lib/scatter.js +218 -0
  43. package/dist/lib/treemap.d.ts +0 -5
  44. package/dist/lib/treemap.js +1 -16
  45. package/package.json +1 -1
  46. package/report.css +9 -0
  47. 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) 40%, transparent)"
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 getStatusFill = (value, colors, highlighted = false) => {
51
- const base = resolveColor(value, colors);
52
- if (!highlighted) return base;
53
- return `color-mix(in oklch, ${base} 60%, transparent)`;
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("motion-safe:transition-opacity motion-safe:hover:opacity-70", onCellClick && "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className),
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
- fill: getStatusFill(activity.value, colors, highlighted),
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: [!hideDateLabels && /* @__PURE__ */ jsx("g", {
269
- className: cn("fill-current font-mono", labelClassName),
270
- children: dates.map((date, dayIndex) => {
271
- const isFirst = dayIndex === 0;
272
- const isLast = dayIndex === dates.length - 1;
273
- if (dayIndex % labelInterval !== 0 && !isLast) return null;
274
- const blockLeft = (blockWidth + blockMargin) * dayIndex;
275
- const xPosition = isFirst ? blockLeft : isLast ? blockLeft + blockWidth : blockLeft + blockWidth / 2;
276
- const anchor = isFirst ? "start" : isLast ? "end" : "middle";
277
- return /* @__PURE__ */ jsx("text", {
278
- x: xPosition,
279
- y: height + labelFontSize,
280
- dominantBaseline: "hanging",
281
- textAnchor: anchor,
282
- style: { fontSize: `${labelFontSize}px` },
283
- children: format(new Date(date), dateFormat, { locale })
284
- }, `date-${date}`);
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
- }), allActivities.map((activity, dayIndex) => {
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__ */ jsx("svg", {
358
+ children: [/* @__PURE__ */ jsxs("svg", {
333
359
  "aria-hidden": "true",
334
360
  height: LEGEND_SWATCH,
335
361
  width: LEGEND_SWATCH,
336
- children: /* @__PURE__ */ jsx("rect", {
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
- height: LEGEND_SWATCH,
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
- width: LEGEND_SWATCH,
342
- style: { fill: getStatusFill(status.value, colors) }
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-center gap-1 whitespace-nowrap", className),
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 {};