sid-ui 0.1.3 → 0.2.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.
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
4
+
5
+ import { segmentShares, squarify } from "./chartMath";
6
+ import {
7
+ ChartLegend,
8
+ ChartTable,
9
+ ChartTip,
10
+ defaultFormat,
11
+ toneAt,
12
+ type ChartTone,
13
+ } from "./chartParts";
14
+ import { useElementWidth } from "./Responsive";
15
+
16
+ export type TreemapDatum = {
17
+ label: string;
18
+ value: number;
19
+ /** Tiles in the same group share a tone and a legend entry. */
20
+ group?: string;
21
+ tone?: ChartTone;
22
+ };
23
+
24
+ const DEFAULT_HEIGHT = 320;
25
+ /** The 2px surface gap between tiles. */
26
+ const GAP = 2;
27
+ /** A tile needs this much room before it prints its name. */
28
+ const LABEL_MIN = { width: 64, height: 40 };
29
+
30
+ /**
31
+ * Part of a whole for many items at once — budget lines, storage by folder. Each tile's area is
32
+ * its share, laid out squarified so tiles stay readable. Group tiles to color them by category.
33
+ */
34
+ export function Treemap({
35
+ data,
36
+ label,
37
+ height = DEFAULT_HEIGHT,
38
+ formatValue = defaultFormat,
39
+ }: {
40
+ data: TreemapDatum[];
41
+ /** Accessible name for the chart. */
42
+ label: string;
43
+ height?: number;
44
+ formatValue?: (value: number) => string;
45
+ }) {
46
+ const frame = useRef<HTMLDivElement>(null);
47
+ const width = useElementWidth(frame);
48
+ const [active, setActive] = useState<number | null>(null);
49
+
50
+ const sorted = [...data].sort((a, b) => b.value - a.value);
51
+ const groups = [...new Set(sorted.map((d) => d.group ?? d.label))];
52
+ const grouped = sorted.some((d) => d.group);
53
+ const toneOf = (d: TreemapDatum) =>
54
+ d.tone ?? toneAt(groups.indexOf(d.group ?? d.label) % 4, undefined);
55
+ const tiles = squarify(
56
+ sorted.map((d) => d.value),
57
+ { x: 0, y: 0, width, height },
58
+ );
59
+ const shares = segmentShares(sorted.map((d) => d.value));
60
+ const largest = Math.max(1, ...sorted.map((d) => d.value));
61
+
62
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
63
+ if (sorted.length === 0) return;
64
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
65
+ event.preventDefault();
66
+ const step = event.key === "ArrowRight" ? 1 : -1;
67
+ setActive((current) => ((current ?? -step) + step + sorted.length) % sorted.length);
68
+ } else if (event.key === "Escape") {
69
+ setActive(null);
70
+ }
71
+ };
72
+
73
+ const focusTile = active === null ? null : tiles[active];
74
+
75
+ return (
76
+ <div className="os-chart">
77
+ {grouped ? (
78
+ <ChartLegend
79
+ items={groups.map((g) => ({
80
+ label: g,
81
+ tone: toneOf(sorted.find((d) => (d.group ?? d.label) === g) ?? sorted[0]),
82
+ }))}
83
+ />
84
+ ) : null}
85
+ <div
86
+ ref={frame}
87
+ className="os-chart-frame os-treemap"
88
+ style={{ height }}
89
+ tabIndex={0}
90
+ role="group"
91
+ aria-label={`${label}. Use the arrow keys to read each tile.`}
92
+ onKeyDown={onKey}
93
+ onBlur={() => setActive(null)}
94
+ onPointerLeave={() => setActive(null)}
95
+ >
96
+ {width > 0
97
+ ? sorted.map((d, index) => {
98
+ const tile = tiles[index];
99
+ if (tile.width <= 0 || tile.height <= 0) return null;
100
+ const roomy = tile.width >= LABEL_MIN.width && tile.height >= LABEL_MIN.height;
101
+ // Larger tiles in a group read stronger.
102
+ const strength = 62 + Math.round((d.value / largest) * 38);
103
+ return (
104
+ <div
105
+ key={`${d.label}-${index}`}
106
+ className="os-treemap-tile"
107
+ data-tone={toneOf(d)}
108
+ data-active={active === index || undefined}
109
+ data-dim={active !== null && active !== index ? true : undefined}
110
+ style={
111
+ {
112
+ left: tile.x + GAP / 2,
113
+ top: tile.y + GAP / 2,
114
+ width: Math.max(0, tile.width - GAP),
115
+ height: Math.max(0, tile.height - GAP),
116
+ "--os-treemap-strength": `${strength}%`,
117
+ animationDelay: `${Math.min(500, index * 35)}ms`,
118
+ } as CSSProperties
119
+ }
120
+ onPointerEnter={() => setActive(index)}
121
+ aria-hidden
122
+ >
123
+ {roomy ? (
124
+ <>
125
+ <span className="os-treemap-name">{d.label}</span>
126
+ <span className="os-treemap-value">{formatValue(d.value)}</span>
127
+ </>
128
+ ) : null}
129
+ </div>
130
+ );
131
+ })
132
+ : null}
133
+ {active !== null && focusTile ? (
134
+ <ChartTip
135
+ title={sorted[active].group ?? undefined}
136
+ left={focusTile.x + focusTile.width / 2}
137
+ top={Math.max(0, focusTile.y + 8)}
138
+ align={
139
+ focusTile.x + focusTile.width / 2 < width / 4
140
+ ? "start"
141
+ : focusTile.x + focusTile.width / 2 > (width * 3) / 4
142
+ ? "end"
143
+ : "center"
144
+ }
145
+ rows={[
146
+ {
147
+ label: `${sorted[active].label} · ${Math.round(shares[active])}%`,
148
+ value: formatValue(sorted[active].value),
149
+ tone: toneOf(sorted[active]),
150
+ },
151
+ ]}
152
+ />
153
+ ) : null}
154
+ </div>
155
+ <ChartTable
156
+ caption={label}
157
+ headers={["", ...(grouped ? ["Group"] : []), "Value", "Share"]}
158
+ rows={sorted.map((d, index) => [
159
+ d.label,
160
+ ...(grouped ? [d.group ?? ""] : []),
161
+ formatValue(d.value),
162
+ `${Math.round(shares[index])}%`,
163
+ ])}
164
+ />
165
+ </div>
166
+ );
167
+ }
@@ -1,8 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
3
+ import { useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
4
4
 
5
- import { areaPath, linePath, linearScale, nearestIndex, niceTicks } from "./chartMath";
5
+ import {
6
+ areaPath,
7
+ linePath,
8
+ linearScale,
9
+ nearestIndex,
10
+ niceTicks,
11
+ smoothAreaPath,
12
+ smoothPath,
13
+ } from "./chartMath";
6
14
  import {
7
15
  ChartLegend,
8
16
  ChartTable,
@@ -27,8 +35,9 @@ const X_LABEL_ROOM = 64;
27
35
  const TIP_GAP = 12;
28
36
 
29
37
  /**
30
- * Change over time for up to four series on one axis. Lines are 2px with a 10% wash in
31
- * `area`; a crosshair snaps to the nearest point and reads out every series at once.
38
+ * Change over time for up to four series on one axis. Lines are 2px — smooth by default —
39
+ * with a gradient wash in `area`, and draw themselves in on first paint. A crosshair snaps to
40
+ * the nearest point and reads out every series at once.
32
41
  * Arrow keys move the readout for keyboard users; a hidden table carries every value.
33
42
  */
34
43
  export function TrendChart({
@@ -36,6 +45,7 @@ export function TrendChart({
36
45
  series,
37
46
  label,
38
47
  variant = "area",
48
+ curve = "smooth",
39
49
  height = DEFAULT_HEIGHT,
40
50
  formatValue = defaultFormat,
41
51
  hasLegend,
@@ -46,6 +56,8 @@ export function TrendChart({
46
56
  /** Accessible name for the chart. */
47
57
  label: string;
48
58
  variant?: "area" | "line";
59
+ /** smooth bends through each point without overshooting it; linear joins them straight. */
60
+ curve?: "smooth" | "linear";
49
61
  height?: number;
50
62
  formatValue?: (value: number) => string;
51
63
  /** Defaults to on for two or more series; one series is named by its card title. */
@@ -53,6 +65,10 @@ export function TrendChart({
53
65
  }) {
54
66
  const frame = useRef<HTMLDivElement>(null);
55
67
  const width = useElementWidth(frame);
68
+ // useId can contain colons, which url(#…) references do not tolerate.
69
+ const gradientId = `os-trend-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
70
+ const line = curve === "smooth" ? smoothPath : linePath;
71
+ const area = curve === "smooth" ? smoothAreaPath : areaPath;
56
72
  const [active, setActive] = useState<number | null>(null);
57
73
 
58
74
  const plotW = Math.max(0, width - MARGIN.left - MARGIN.right);
@@ -141,14 +157,32 @@ export function TrendChart({
141
157
  </text>
142
158
  ) : null,
143
159
  )}
144
- {toned.map((item) => {
160
+ {toned.map((item, seriesIndex) => {
145
161
  const points = item.values.map((value, index) => ({ x: xs[index], y: y(value) }));
162
+ const fill = `${gradientId}-${seriesIndex}`;
146
163
  return (
147
164
  <g key={item.label} data-tone={item.tone}>
148
165
  {variant === "area" ? (
149
- <path className="os-chart-area" d={areaPath(points, y(0))} />
166
+ <>
167
+ <defs>
168
+ <linearGradient id={fill} x1="0" x2="0" y1="0" y2="1">
169
+ <stop className="os-chart-stop-top" offset="0%" />
170
+ <stop className="os-chart-stop-bottom" offset="100%" />
171
+ </linearGradient>
172
+ </defs>
173
+ <path
174
+ className="os-chart-area"
175
+ d={area(points, y(0))}
176
+ fill={`url(#${fill})`}
177
+ />
178
+ </>
150
179
  ) : null}
151
- <path className="os-chart-line" d={linePath(points)} />
180
+ <path
181
+ className="os-chart-line"
182
+ d={line(points)}
183
+ pathLength={1}
184
+ style={{ animationDelay: `${seriesIndex * 120}ms` }}
185
+ />
152
186
  </g>
153
187
  );
154
188
  })}
@@ -162,14 +196,20 @@ export function TrendChart({
162
196
  y2={MARGIN.top + plotH}
163
197
  />
164
198
  {toned.map((item) => (
165
- <circle
166
- key={item.label}
167
- className="os-chart-dot"
168
- data-tone={item.tone}
169
- cx={activeX}
170
- cy={y(item.values[active] ?? 0)}
171
- r={4}
172
- />
199
+ <g key={item.label} data-tone={item.tone}>
200
+ <circle
201
+ className="os-chart-halo"
202
+ cx={activeX}
203
+ cy={y(item.values[active] ?? 0)}
204
+ r={9}
205
+ />
206
+ <circle
207
+ className="os-chart-dot"
208
+ cx={activeX}
209
+ cy={y(item.values[active] ?? 0)}
210
+ r={4.5}
211
+ />
212
+ </g>
173
213
  ))}
174
214
  </g>
175
215
  ) : null}
@@ -123,3 +123,154 @@ export function compactNumber(value: number): string {
123
123
  value,
124
124
  );
125
125
  }
126
+
127
+ /**
128
+ * A smooth path through the points that never overshoots them — monotone cubic
129
+ * interpolation (Fritsch–Carlson), the same curve as D3's curveMonotoneX. Points must be
130
+ * sorted by x.
131
+ */
132
+ export function smoothPath(points: Point[]): string {
133
+ const n = points.length;
134
+ if (n < 3) return linePath(points);
135
+ const dx: number[] = [];
136
+ const slope: number[] = [];
137
+ for (let i = 0; i < n - 1; i++) {
138
+ dx.push(points[i + 1].x - points[i].x);
139
+ slope.push(dx[i] === 0 ? 0 : (points[i + 1].y - points[i].y) / dx[i]);
140
+ }
141
+ const tangent: number[] = [slope[0]];
142
+ for (let i = 1; i < n - 1; i++) {
143
+ tangent.push(slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2);
144
+ }
145
+ tangent.push(slope[n - 2]);
146
+ // Fritsch–Carlson: cap the tangents so no segment bulges past its endpoints.
147
+ for (let i = 0; i < n - 1; i++) {
148
+ if (slope[i] === 0) {
149
+ tangent[i] = 0;
150
+ tangent[i + 1] = 0;
151
+ continue;
152
+ }
153
+ const a = tangent[i] / slope[i];
154
+ const b = tangent[i + 1] / slope[i];
155
+ const h = a * a + b * b;
156
+ if (h > 9) {
157
+ const t = 3 / Math.sqrt(h);
158
+ tangent[i] = t * a * slope[i];
159
+ tangent[i + 1] = t * b * slope[i];
160
+ }
161
+ }
162
+ let d = `M${fmt(points[0].x)},${fmt(points[0].y)}`;
163
+ for (let i = 0; i < n - 1; i++) {
164
+ const p0 = points[i];
165
+ const p1 = points[i + 1];
166
+ const third = dx[i] / 3;
167
+ d +=
168
+ `C${fmt(p0.x + third)},${fmt(p0.y + tangent[i] * third)} ` +
169
+ `${fmt(p1.x - third)},${fmt(p1.y - tangent[i + 1] * third)} ` +
170
+ `${fmt(p1.x)},${fmt(p1.y)}`;
171
+ }
172
+ return d;
173
+ }
174
+
175
+ /** The smooth line closed down to a baseline. */
176
+ export function smoothAreaPath(points: Point[], baseline: number): string {
177
+ if (points.length === 0) return "";
178
+ const first = points[0];
179
+ const last = points[points.length - 1];
180
+ return `${smoothPath(points)}L${fmt(last.x)},${fmt(baseline)}L${fmt(first.x)},${fmt(baseline)}Z`;
181
+ }
182
+
183
+ /**
184
+ * Clean ticks spanning [min, max] for axes that need not start at zero, such as a scatter
185
+ * plot's: 20, 40, 60, 80 rather than 17.3 … 83.9.
186
+ */
187
+ export function niceDomain(min: number, max: number, count = 5): number[] {
188
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
189
+ if (min === max) {
190
+ const pad = min === 0 ? 1 : Math.abs(min) * 0.1;
191
+ return niceDomain(min - pad, max + pad, count);
192
+ }
193
+ const raw = (max - min) / Math.max(1, count);
194
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
195
+ const step = (NICE_STEPS.find((nice) => nice * magnitude >= raw) ?? 10) * magnitude;
196
+ const start = Math.floor(min / step) * step;
197
+ const end = Math.ceil(max / step) * step;
198
+ const ticks: number[] = [];
199
+ for (let tick = start; tick <= end + step / 2; tick += step) ticks.push(round(tick));
200
+ return ticks;
201
+ }
202
+
203
+ /** The point at `fraction` of a turn (0 at twelve o'clock, clockwise) on a circle. */
204
+ export function polarPoint(cx: number, cy: number, radius: number, fraction: number): Point {
205
+ const angle = fraction * 2 * Math.PI - Math.PI / 2;
206
+ return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) };
207
+ }
208
+
209
+ /** A closed polygon through the points, for radar shapes and grids. */
210
+ export function polygonPath(points: Point[]): string {
211
+ return points.length ? `${linePath(points)}Z` : "";
212
+ }
213
+
214
+ export type Rect = { x: number; y: number; width: number; height: number };
215
+
216
+ /**
217
+ * Squarified treemap layout (Bruls, Huizing, van Wijk): tiles each value into `rect` with
218
+ * area proportional to it, keeping tiles as close to square as it can. Returns one rect per
219
+ * value, in input order; values should be sorted largest first for the best shapes.
220
+ */
221
+ export function squarify(values: number[], rect: Rect): Rect[] {
222
+ const out: Rect[] = values.map(() => ({ x: rect.x, y: rect.y, width: 0, height: 0 }));
223
+ const total = values.reduce((sum, v) => sum + Math.max(0, v), 0);
224
+ if (total <= 0 || rect.width <= 0 || rect.height <= 0) return out;
225
+ const scale = (rect.width * rect.height) / total;
226
+ const items = values
227
+ .map((value, index) => ({ index, area: Math.max(0, value) * scale }))
228
+ .filter((item) => item.area > 0);
229
+
230
+ let free = { ...rect };
231
+ let row: typeof items = [];
232
+
233
+ const worst = (areas: number[], side: number) => {
234
+ const sum = areas.reduce((a, b) => a + b, 0);
235
+ const max = Math.max(...areas);
236
+ const min = Math.min(...areas);
237
+ return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));
238
+ };
239
+
240
+ const layRow = (cells: typeof items) => {
241
+ const sum = cells.reduce((a, c) => a + c.area, 0);
242
+ const horizontal = free.width >= free.height;
243
+ if (horizontal) {
244
+ const width = sum / free.height;
245
+ let y = free.y;
246
+ for (const cell of cells) {
247
+ const height = cell.area / width;
248
+ out[cell.index] = { x: free.x, y, width, height };
249
+ y += height;
250
+ }
251
+ free = { x: free.x + width, y: free.y, width: free.width - width, height: free.height };
252
+ } else {
253
+ const height = sum / free.width;
254
+ let x = free.x;
255
+ for (const cell of cells) {
256
+ const width = cell.area / height;
257
+ out[cell.index] = { x, y: free.y, width, height };
258
+ x += width;
259
+ }
260
+ free = { x: free.x, y: free.y + height, width: free.width, height: free.height - height };
261
+ }
262
+ };
263
+
264
+ for (const item of items) {
265
+ const side = Math.min(free.width, free.height);
266
+ const current = row.map((c) => c.area);
267
+ if (row.length === 0 || worst([...current, item.area], side) <= worst(current, side)) {
268
+ row.push(item);
269
+ } else {
270
+ layRow(row);
271
+ row = [item];
272
+ }
273
+ }
274
+ if (row.length) layRow(row);
275
+ return out;
276
+ }
@@ -122,12 +122,42 @@ export { useFileDrop, formatBytes, matchesAccept } from "./useFileDrop";
122
122
  export type { UseFileDropOptions, FileDropProps } from "./useFileDrop";
123
123
 
124
124
  export { Rating, RATING_MAX } from "./Rating";
125
- export type { RatingProps } from "./Rating";
125
+ export type { RatingProps, RatingIcon } from "./Rating";
126
126
 
127
- export { Notification, NotificationList } from "./Notification";
128
- export type { NotificationProps, NotificationListProps, NotificationTone } from "./Notification";
127
+ export { MotionCard, FlipCard, ExpandableCard } from "./CardEffects";
128
+ export type {
129
+ MotionCardProps,
130
+ MotionCardEffect,
131
+ FlipCardProps,
132
+ ExpandableCardProps,
133
+ } from "./CardEffects";
134
+
135
+ export { Chip, ChipGroup } from "./Chip";
136
+ export type { ChipProps, ChipGroupProps, ChipColor, ChipVariant, ChipSize } from "./Chip";
137
+
138
+ export { StatusIndicator } from "./StatusIndicator";
139
+ export type {
140
+ StatusIndicatorProps,
141
+ StatusIndicatorSize,
142
+ StatusIndicatorAppearance,
143
+ } from "./StatusIndicator";
144
+
145
+ export {
146
+ Notification,
147
+ NotificationGroup,
148
+ NotificationIcon,
149
+ NotificationList,
150
+ } from "./Notification";
151
+ export type {
152
+ NotificationProps,
153
+ NotificationGroupProps,
154
+ NotificationIconProps,
155
+ NotificationListProps,
156
+ NotificationTone,
157
+ } from "./Notification";
129
158
  export { NotificationViewport, useNotification } from "./NotificationTrigger";
130
159
  export type {
160
+ NotificationAction,
131
161
  NotificationPosition,
132
162
  ShowNotificationOptions,
133
163
  NotificationDismiss,
@@ -179,6 +209,14 @@ export { BarChart } from "./BarChart";
179
209
  export type { BarDatum } from "./BarChart";
180
210
  export { DonutChart } from "./DonutChart";
181
211
  export type { DonutSlice } from "./DonutChart";
212
+ export { RadarChart } from "./RadarChart";
213
+ export type { RadarSeries } from "./RadarChart";
214
+ export { ScatterChart } from "./ScatterChart";
215
+ export type { ScatterPoint, ScatterSeries } from "./ScatterChart";
216
+ export { RadialBarChart } from "./RadialBarChart";
217
+ export type { RadialBarDatum } from "./RadialBarChart";
218
+ export { Treemap } from "./Treemap";
219
+ export type { TreemapDatum } from "./Treemap";
182
220
  export { Sparkline } from "./Sparkline";
183
221
  export { FunnelChart } from "./FunnelChart";
184
222
  export type { FunnelStage } from "./FunnelChart";