@watt-ui/react 0.0.0-stage → 0.1.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +43 -0
  3. package/README.md +48 -2
  4. package/dist/chart/data.d.ts +13 -0
  5. package/dist/chart/data.d.ts.map +1 -0
  6. package/dist/chart/data.js +92 -0
  7. package/dist/chart/data.js.map +1 -0
  8. package/dist/chart/engine.d.ts +2 -0
  9. package/dist/chart/engine.d.ts.map +1 -0
  10. package/dist/chart/engine.js +16 -0
  11. package/dist/chart/engine.js.map +1 -0
  12. package/dist/chart/options.d.ts +18 -0
  13. package/dist/chart/options.d.ts.map +1 -0
  14. package/dist/chart/options.js +136 -0
  15. package/dist/chart/options.js.map +1 -0
  16. package/dist/components/alarm-feed.d.ts +18 -0
  17. package/dist/components/alarm-feed.d.ts.map +1 -0
  18. package/dist/components/alarm-feed.js +45 -0
  19. package/dist/components/alarm-feed.js.map +1 -0
  20. package/dist/components/asset-tree.d.ts +33 -0
  21. package/dist/components/asset-tree.d.ts.map +1 -0
  22. package/dist/components/asset-tree.js +149 -0
  23. package/dist/components/asset-tree.js.map +1 -0
  24. package/dist/components/battery-soc.d.ts +11 -0
  25. package/dist/components/battery-soc.d.ts.map +1 -0
  26. package/dist/components/battery-soc.js +34 -0
  27. package/dist/components/battery-soc.js.map +1 -0
  28. package/dist/components/data-freshness.d.ts +26 -0
  29. package/dist/components/data-freshness.d.ts.map +1 -0
  30. package/dist/components/data-freshness.js +45 -0
  31. package/dist/components/data-freshness.js.map +1 -0
  32. package/dist/components/energy-flow.d.ts +39 -0
  33. package/dist/components/energy-flow.d.ts.map +1 -0
  34. package/dist/components/energy-flow.js +176 -0
  35. package/dist/components/energy-flow.js.map +1 -0
  36. package/dist/components/flow-geometry.d.ts +24 -0
  37. package/dist/components/flow-geometry.d.ts.map +1 -0
  38. package/dist/components/flow-geometry.js +57 -0
  39. package/dist/components/flow-geometry.js.map +1 -0
  40. package/dist/components/power-metric.d.ts +14 -0
  41. package/dist/components/power-metric.d.ts.map +1 -0
  42. package/dist/components/power-metric.js +25 -0
  43. package/dist/components/power-metric.js.map +1 -0
  44. package/dist/components/status-indicator.d.ts +10 -0
  45. package/dist/components/status-indicator.d.ts.map +1 -0
  46. package/dist/components/status-indicator.js +15 -0
  47. package/dist/components/status-indicator.js.map +1 -0
  48. package/dist/components/telemetry-chart.d.ts +50 -0
  49. package/dist/components/telemetry-chart.d.ts.map +1 -0
  50. package/dist/components/telemetry-chart.js +256 -0
  51. package/dist/components/telemetry-chart.js.map +1 -0
  52. package/dist/domain.d.ts +6 -0
  53. package/dist/domain.d.ts.map +1 -0
  54. package/dist/domain.js +43 -0
  55. package/dist/domain.js.map +1 -0
  56. package/dist/icons/index.d.ts +7 -0
  57. package/dist/icons/index.d.ts.map +1 -0
  58. package/dist/icons/index.js +38 -0
  59. package/dist/icons/index.js.map +1 -0
  60. package/dist/index.d.ts +19 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +10 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal.d.ts +6 -0
  65. package/dist/internal.d.ts.map +1 -0
  66. package/dist/internal.js +45 -0
  67. package/dist/internal.js.map +1 -0
  68. package/dist/server.d.ts +10 -0
  69. package/dist/server.d.ts.map +1 -0
  70. package/dist/server.js +32 -0
  71. package/dist/server.js.map +1 -0
  72. package/dist/styles.css +130 -0
  73. package/dist/theme/shadcn.css +14 -0
  74. package/dist/types.d.ts +61 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/types.js +2 -0
  77. package/dist/types.js.map +1 -0
  78. package/package.json +118 -4
  79. package/src/chart/data.ts +103 -0
  80. package/src/chart/engine.ts +19 -0
  81. package/src/chart/options.ts +178 -0
  82. package/src/components/alarm-feed.tsx +126 -0
  83. package/src/components/asset-tree.tsx +258 -0
  84. package/src/components/battery-soc.tsx +84 -0
  85. package/src/components/data-freshness.tsx +109 -0
  86. package/src/components/energy-flow.tsx +364 -0
  87. package/src/components/flow-geometry.ts +82 -0
  88. package/src/components/power-metric.tsx +71 -0
  89. package/src/components/status-indicator.tsx +31 -0
  90. package/src/components/telemetry-chart.tsx +476 -0
  91. package/src/domain.ts +64 -0
  92. package/src/icons/index.tsx +49 -0
  93. package/src/index.ts +37 -0
  94. package/src/internal.tsx +57 -0
  95. package/src/server.ts +47 -0
  96. package/src/styles.css +130 -0
  97. package/src/theme/shadcn.css +14 -0
  98. package/src/types.ts +64 -0
@@ -0,0 +1,109 @@
1
+ "use client";
2
+ import { createContext, forwardRef, useContext } from "react";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { cx, part, useNow } from "../internal.js";
5
+ import { parseTimestamp, formatTime } from "../domain.js";
6
+ import type { DataQuality, PartProps, RootProps, Timestamp } from "../types.js";
7
+ interface Freshness {
8
+ timestamp: Timestamp | null;
9
+ quality?: DataQuality;
10
+ }
11
+ const Context = createContext<Freshness | null>(null);
12
+ function useFreshness() {
13
+ const ctx = useContext(Context);
14
+ if (!ctx) throw new Error("DataFreshness parts require DataFreshness.Root");
15
+ return ctx;
16
+ }
17
+ export interface DataFreshnessProps extends RootProps, Freshness {}
18
+ const Root = forwardRef<HTMLElement, DataFreshnessProps>(
19
+ (
20
+ {
21
+ timestamp,
22
+ quality,
23
+ density = "comfortable",
24
+ asChild,
25
+ className,
26
+ ...props
27
+ },
28
+ ref,
29
+ ) => {
30
+ const Component = asChild ? Slot : "span";
31
+ return (
32
+ <Context.Provider value={{ timestamp, quality }}>
33
+ <Component
34
+ {...props}
35
+ ref={ref as never}
36
+ data-slot="data-freshness"
37
+ data-density={density}
38
+ data-quality={quality}
39
+ className={cx(
40
+ "watt-root inline-flex items-center gap-2 text-sm text-[var(--watt-muted)] data-[density=compact]:gap-1 data-[density=compact]:text-xs",
41
+ className,
42
+ )}
43
+ />
44
+ </Context.Provider>
45
+ );
46
+ },
47
+ );
48
+ export interface FreshnessTimeProps extends React.ComponentPropsWithoutRef<"time"> {
49
+ timeZone: string;
50
+ locale?: string;
51
+ formatter?: (timestamp: number) => React.ReactNode;
52
+ fallback?: React.ReactNode;
53
+ }
54
+ const Time = forwardRef<HTMLTimeElement, FreshnessTimeProps>(
55
+ (
56
+ { timeZone, locale, formatter, fallback = "—", children, ...props },
57
+ ref,
58
+ ) => {
59
+ const { timestamp } = useFreshness();
60
+ const time = parseTimestamp(timestamp);
61
+ return (
62
+ <time
63
+ {...props}
64
+ ref={ref}
65
+ data-slot="data-freshness-time"
66
+ dateTime={time === null ? undefined : new Date(time).toISOString()}
67
+ >
68
+ {children ??
69
+ (time === null
70
+ ? fallback
71
+ : formatter
72
+ ? formatter(time)
73
+ : formatTime(time, timeZone, locale))}
74
+ </time>
75
+ );
76
+ },
77
+ );
78
+ export interface FreshnessElapsedProps extends PartProps {
79
+ now?: Timestamp;
80
+ formatter: (elapsedMs: number) => React.ReactNode;
81
+ fallback?: React.ReactNode;
82
+ }
83
+ const Elapsed = forwardRef<HTMLElement, FreshnessElapsedProps>(
84
+ ({ now, formatter, fallback = "—", asChild, children, ...props }, ref) => {
85
+ const { timestamp } = useFreshness();
86
+ const clock = useNow(now === undefined);
87
+ const reference = now === undefined ? clock : parseTimestamp(now);
88
+ const time = parseTimestamp(timestamp);
89
+ const Component = asChild ? Slot : "span";
90
+ return (
91
+ <Component
92
+ {...props}
93
+ ref={ref as never}
94
+ data-slot="data-freshness-elapsed"
95
+ >
96
+ {children ??
97
+ (time === null || reference === null
98
+ ? fallback
99
+ : formatter(reference - time))}
100
+ </Component>
101
+ );
102
+ },
103
+ );
104
+ export const DataFreshness = {
105
+ Root,
106
+ Time,
107
+ Elapsed,
108
+ Quality: part("data-freshness-quality", "", "span"),
109
+ };
@@ -0,0 +1,364 @@
1
+ "use client";
2
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import { Slot } from "@radix-ui/react-slot";
14
+ import { cx } from "../internal.js";
15
+ import {
16
+ connection,
17
+ type Rect,
18
+ type Side,
19
+ type Point,
20
+ } from "./flow-geometry.js";
21
+ import type { EnergyFlowEdge, PartProps, RootProps } from "../types.js";
22
+ export interface FlowAnimation {
23
+ type?: "particles" | "dash" | "pulse" | "none";
24
+ speed?: number;
25
+ particleSize?: number;
26
+ particleCount?: number;
27
+ }
28
+ export interface FlowStyle {
29
+ animation?: FlowAnimation;
30
+ path?: "straight" | "step" | "bezier";
31
+ color?: string;
32
+ strokeWidth?: number;
33
+ }
34
+ export interface EnergyFlowProps extends RootProps, FlowStyle {}
35
+ interface FlowContext extends FlowStyle {
36
+ rects: ReadonlyMap<string, Rect>;
37
+ register: (key: string, element: HTMLElement, side?: Side) => () => void;
38
+ }
39
+ const Context = createContext<FlowContext | null>(null);
40
+ const NodeContext = createContext<string | null>(null);
41
+ const LabelContext = createContext<Point | null>(null);
42
+ const keyFor = (node: string, port?: string) =>
43
+ JSON.stringify([node, port ?? null]);
44
+ function useFlow() {
45
+ const ctx = useContext(Context);
46
+ if (!ctx) throw new Error("EnergyFlow parts require EnergyFlow.Root");
47
+ return ctx;
48
+ }
49
+ const Root = forwardRef<HTMLElement, EnergyFlowProps>(
50
+ (
51
+ {
52
+ asChild,
53
+ density = "comfortable",
54
+ animation,
55
+ path = "bezier",
56
+ color,
57
+ strokeWidth = 2,
58
+ children,
59
+ className,
60
+ ...props
61
+ },
62
+ ref,
63
+ ) => {
64
+ const host = useRef<HTMLElement | null>(null);
65
+ const elements = useRef(
66
+ new Map<string, { element: HTMLElement; side?: Side }>(),
67
+ );
68
+ const observer = useRef<ResizeObserver | null>(null);
69
+ const frame = useRef(0);
70
+ const [rects, setRects] = useState<ReadonlyMap<string, Rect>>(new Map());
71
+ const measure = useCallback(() => {
72
+ cancelAnimationFrame(frame.current);
73
+ frame.current = requestAnimationFrame(() => {
74
+ const root = host.current;
75
+ if (!root) return;
76
+ const rootRect = root.getBoundingClientRect();
77
+ const scaleX = rootRect.width / (root.offsetWidth || 1),
78
+ scaleY = rootRect.height / (root.offsetHeight || 1);
79
+ const next = new Map<string, Rect>();
80
+ elements.current.forEach(({ element, side }, key) => {
81
+ if (!element.getClientRects().length) return;
82
+ const r = element.getBoundingClientRect();
83
+ next.set(key, {
84
+ x:
85
+ (r.left - rootRect.left) / (scaleX || 1) -
86
+ root.clientLeft +
87
+ root.scrollLeft,
88
+ y:
89
+ (r.top - rootRect.top) / (scaleY || 1) -
90
+ root.clientTop +
91
+ root.scrollTop,
92
+ width: r.width / (scaleX || 1),
93
+ height: r.height / (scaleY || 1),
94
+ side,
95
+ });
96
+ });
97
+ setRects((old) =>
98
+ JSON.stringify([...old]) === JSON.stringify([...next]) ? old : next,
99
+ );
100
+ });
101
+ }, []);
102
+ const register = useCallback(
103
+ (key: string, element: HTMLElement, side?: Side) => {
104
+ if (elements.current.has(key))
105
+ throw new Error(`Duplicate EnergyFlow node/port: ${key}`);
106
+ elements.current.set(key, { element, side });
107
+ observer.current?.observe(element);
108
+ measure();
109
+ return () => {
110
+ observer.current?.unobserve(element);
111
+ elements.current.delete(key);
112
+ measure();
113
+ };
114
+ },
115
+ [measure],
116
+ );
117
+ const hostRef = useComposedRefs(host, ref);
118
+ useEffect(() => {
119
+ observer.current = new ResizeObserver(measure);
120
+ if (host.current) observer.current.observe(host.current);
121
+ elements.current.forEach(({ element }) =>
122
+ observer.current!.observe(element),
123
+ );
124
+ const mutation = new MutationObserver(measure);
125
+ if (host.current)
126
+ mutation.observe(host.current, {
127
+ attributes: true,
128
+ subtree: true,
129
+ attributeFilter: ["class", "style"],
130
+ childList: true,
131
+ });
132
+ window.addEventListener("resize", measure);
133
+ document.addEventListener("scroll", measure, true);
134
+ measure();
135
+ return () => {
136
+ observer.current?.disconnect();
137
+ observer.current = null;
138
+ mutation.disconnect();
139
+ window.removeEventListener("resize", measure);
140
+ document.removeEventListener("scroll", measure, true);
141
+ cancelAnimationFrame(frame.current);
142
+ };
143
+ }, [measure]);
144
+ const Component = asChild ? Slot : "div";
145
+ return (
146
+ <Context.Provider
147
+ value={{ register, rects, animation, path, color, strokeWidth }}
148
+ >
149
+ <Component
150
+ {...props}
151
+ ref={hostRef as never}
152
+ data-slot="energy-flow"
153
+ data-density={density}
154
+ className={cx("watt-root relative isolate", className)}
155
+ >
156
+ {children}
157
+ </Component>
158
+ </Context.Provider>
159
+ );
160
+ },
161
+ );
162
+ export interface FlowNodeProps extends PartProps {
163
+ id: string;
164
+ }
165
+ const Node = forwardRef<HTMLElement, FlowNodeProps>(
166
+ ({ id, asChild, className, ...props }, ref) => {
167
+ const { register } = useFlow();
168
+ const host = useRef<HTMLElement | null>(null);
169
+ const hostRef = useComposedRefs(host, ref);
170
+ useEffect(() => {
171
+ if (!id) throw new Error("EnergyFlow.Node requires a nonempty id");
172
+ if (host.current) return register(keyFor(id), host.current);
173
+ }, [id, register]);
174
+ const Component = asChild ? Slot : "div";
175
+ return (
176
+ <NodeContext.Provider value={id}>
177
+ <Component
178
+ {...props}
179
+ ref={hostRef as never}
180
+ data-slot="energy-flow-node"
181
+ data-node-id={id}
182
+ className={cx("relative z-10", className)}
183
+ />
184
+ </NodeContext.Provider>
185
+ );
186
+ },
187
+ );
188
+ export interface FlowPortProps extends PartProps {
189
+ id: string;
190
+ side: Side;
191
+ }
192
+ const Port = forwardRef<HTMLElement, FlowPortProps>(
193
+ ({ id, side, asChild, className, ...props }, ref) => {
194
+ const { register } = useFlow();
195
+ const node = useContext(NodeContext);
196
+ const host = useRef<HTMLElement | null>(null);
197
+ const hostRef = useComposedRefs(host, ref);
198
+ useEffect(() => {
199
+ if (!node || !id)
200
+ throw new Error("A named EnergyFlow.Port requires a Node");
201
+ if (host.current) return register(keyFor(node, id), host.current, side);
202
+ }, [node, id, side, register]);
203
+ const Component = asChild ? Slot : "span";
204
+ return (
205
+ <Component
206
+ {...props}
207
+ ref={hostRef as never}
208
+ data-slot="energy-flow-port"
209
+ data-port-id={id}
210
+ className={cx(
211
+ "absolute h-2 w-2 rounded-full bg-[var(--watt-accent)]",
212
+ className,
213
+ )}
214
+ />
215
+ );
216
+ },
217
+ );
218
+ export interface FlowEdgeProps extends EnergyFlowEdge, FlowStyle {
219
+ children?: React.ReactNode;
220
+ className?: string;
221
+ label?: string;
222
+ style?: React.CSSProperties;
223
+ }
224
+ function Edge({
225
+ source,
226
+ target,
227
+ sourcePort,
228
+ targetPort,
229
+ animation,
230
+ path,
231
+ color,
232
+ strokeWidth,
233
+ children,
234
+ className,
235
+ label,
236
+ style,
237
+ }: FlowEdgeProps) {
238
+ const ctx = useFlow();
239
+ const id = `watt-${useId().replace(/:/g, "")}`;
240
+ const a = ctx.rects.get(keyFor(source, sourcePort)),
241
+ b = ctx.rects.get(keyFor(target, targetPort));
242
+ const settings = {
243
+ type: "particles",
244
+ speed: 1,
245
+ particleSize: 3,
246
+ particleCount: 3,
247
+ ...ctx.animation,
248
+ ...animation,
249
+ };
250
+ if (!Number.isFinite(settings.speed) || settings.speed < 0)
251
+ throw new Error("Flow speed must be finite and nonnegative");
252
+ if (
253
+ !Number.isFinite(settings.particleCount) ||
254
+ settings.particleCount < 0 ||
255
+ settings.particleCount > 100 ||
256
+ !Number.isFinite(settings.particleSize) ||
257
+ settings.particleSize < 0
258
+ )
259
+ throw new Error("Invalid flow particles (count 0–100, size >= 0)");
260
+ const width = strokeWidth ?? ctx.strokeWidth ?? 2;
261
+ if (!Number.isFinite(width) || width <= 0)
262
+ throw new Error("Flow strokeWidth must be positive");
263
+ if (source === target || !a || !b) return null;
264
+ const geometry = connection(a, b, path ?? ctx.path ?? "bezier");
265
+ const ink = color ?? ctx.color ?? "var(--watt-accent)";
266
+ const duration = 3 / (settings.speed || 1);
267
+ const motionStyle = {
268
+ animationDuration: `${duration}s`,
269
+ animationPlayState: settings.speed === 0 ? "paused" : "running",
270
+ } as React.CSSProperties;
271
+ return (
272
+ <LabelContext.Provider value={geometry.middle}>
273
+ <svg
274
+ data-slot="energy-flow-edge"
275
+ data-source={source}
276
+ data-target={target}
277
+ className={cx(
278
+ "pointer-events-none absolute inset-0 h-full w-full overflow-visible",
279
+ className,
280
+ )}
281
+ style={{ color: ink, ...style }}
282
+ role={label ? "img" : undefined}
283
+ aria-label={label}
284
+ aria-hidden={!label || undefined}
285
+ >
286
+ <defs>
287
+ <marker
288
+ id={id}
289
+ markerWidth="8"
290
+ markerHeight="8"
291
+ refX="7"
292
+ refY="4"
293
+ orient="auto"
294
+ markerUnits="userSpaceOnUse"
295
+ >
296
+ <path
297
+ d="M1 1 L7 4 L1 7"
298
+ fill="none"
299
+ stroke="currentColor"
300
+ strokeWidth="1.5"
301
+ strokeLinecap="round"
302
+ strokeLinejoin="round"
303
+ />
304
+ </marker>
305
+ </defs>
306
+ <path
307
+ d={geometry.path}
308
+ fill="none"
309
+ stroke="currentColor"
310
+ strokeWidth={width}
311
+ markerEnd={`url(#${id})`}
312
+ className={
313
+ settings.type === "dash"
314
+ ? "watt-flow-dash"
315
+ : settings.type === "pulse"
316
+ ? "watt-flow-pulse"
317
+ : undefined
318
+ }
319
+ style={motionStyle}
320
+ />
321
+ {settings.type === "particles" &&
322
+ Array.from({ length: Math.floor(settings.particleCount) }, (_, i) => (
323
+ <circle
324
+ key={i}
325
+ r={settings.particleSize}
326
+ fill="currentColor"
327
+ className="watt-flow-particle"
328
+ style={{
329
+ ...motionStyle,
330
+ offsetPath: `path('${geometry.path}')`,
331
+ animationDelay: `${(-duration * i) / Math.max(1, settings.particleCount)}s`,
332
+ }}
333
+ />
334
+ ))}
335
+ </svg>
336
+ {children}
337
+ </LabelContext.Provider>
338
+ );
339
+ }
340
+ const Label = forwardRef<HTMLElement, PartProps>(
341
+ ({ asChild, className, style, ...props }, ref) => {
342
+ const point = useContext(LabelContext);
343
+ if (!point) throw new Error("EnergyFlow.Label requires an Edge");
344
+ const Component = asChild ? Slot : "span";
345
+ return (
346
+ <Component
347
+ {...props}
348
+ ref={ref as never}
349
+ data-slot="energy-flow-label"
350
+ className={cx(
351
+ "absolute z-10 bg-[var(--watt-surface)] px-2 text-sm tabular-nums",
352
+ className,
353
+ )}
354
+ style={{
355
+ left: point.x,
356
+ top: point.y,
357
+ transform: "translate(-50%, -140%)",
358
+ ...style,
359
+ }}
360
+ />
361
+ );
362
+ },
363
+ );
364
+ export const EnergyFlow = { Root, Node, Port, Edge, Label };
@@ -0,0 +1,82 @@
1
+ export interface Rect {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ side?: Side;
7
+ }
8
+ export type Side = "top" | "right" | "bottom" | "left";
9
+ export interface Point {
10
+ x: number;
11
+ y: number;
12
+ }
13
+ export function anchor(rect: Rect, other: Rect): { point: Point; side: Side } {
14
+ const dx = other.x + other.width / 2 - rect.x - rect.width / 2;
15
+ const dy = other.y + other.height / 2 - rect.y - rect.height / 2;
16
+ const side =
17
+ rect.side ??
18
+ (Math.abs(dx) >= Math.abs(dy)
19
+ ? dx >= 0
20
+ ? "right"
21
+ : "left"
22
+ : dy >= 0
23
+ ? "bottom"
24
+ : "top");
25
+ return {
26
+ side,
27
+ point: {
28
+ x:
29
+ rect.x +
30
+ (side === "left" ? 0 : side === "right" ? rect.width : rect.width / 2),
31
+ y:
32
+ rect.y +
33
+ (side === "top"
34
+ ? 0
35
+ : side === "bottom"
36
+ ? rect.height
37
+ : rect.height / 2),
38
+ },
39
+ };
40
+ }
41
+ export function connection(
42
+ source: Rect,
43
+ target: Rect,
44
+ type: "straight" | "step" | "bezier",
45
+ ) {
46
+ const a = anchor(source, target),
47
+ b = anchor(target, source);
48
+ const start = a.point,
49
+ end = b.point;
50
+ const middle = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
51
+ const move = `M ${start.x} ${start.y}`;
52
+ if (type === "straight")
53
+ return { path: `${move} L ${end.x} ${end.y}`, middle };
54
+ if (type === "step") {
55
+ const horizontal = a.side === "left" || a.side === "right";
56
+ return {
57
+ path: horizontal
58
+ ? `${move} H ${middle.x} V ${end.y} H ${end.x}`
59
+ : `${move} V ${middle.y} H ${end.x} V ${end.y}`,
60
+ middle,
61
+ };
62
+ }
63
+ const distance = Math.max(
64
+ 36,
65
+ Math.hypot(end.x - start.x, end.y - start.y) * 0.45,
66
+ );
67
+ const offset = (side: Side) => ({
68
+ x: side === "left" ? -distance : side === "right" ? distance : 0,
69
+ y: side === "top" ? -distance : side === "bottom" ? distance : 0,
70
+ });
71
+ const da = offset(a.side),
72
+ db = offset(b.side);
73
+ const c1 = { x: start.x + da.x, y: start.y + da.y },
74
+ c2 = { x: end.x + db.x, y: end.y + db.y };
75
+ return {
76
+ path: `${move} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${end.x} ${end.y}`,
77
+ middle: {
78
+ x: (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8,
79
+ y: (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8,
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { forwardRef } from "react";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { cx, part } from "../internal.js";
5
+ import { formatNumber } from "../domain.js";
6
+ import type { PartProps, RootProps } from "../types.js";
7
+ const Root = forwardRef<HTMLElement, RootProps>(
8
+ ({ asChild, density = "comfortable", className, ...props }, ref) => {
9
+ const Component = asChild ? Slot : "div";
10
+ return (
11
+ <Component
12
+ {...props}
13
+ ref={ref as never}
14
+ data-slot="power-metric"
15
+ data-density={density}
16
+ className={cx(
17
+ "watt-root flex flex-col gap-2 data-[density=compact]:gap-1",
18
+ className,
19
+ )}
20
+ />
21
+ );
22
+ },
23
+ );
24
+ export interface MetricValueProps extends PartProps {
25
+ value: number | null;
26
+ formatter?: (value: number) => React.ReactNode;
27
+ fallback?: React.ReactNode;
28
+ }
29
+ const Value = forwardRef<HTMLElement, MetricValueProps>(
30
+ (
31
+ {
32
+ asChild,
33
+ value,
34
+ formatter = formatNumber,
35
+ fallback = "—",
36
+ children,
37
+ className,
38
+ ...props
39
+ },
40
+ ref,
41
+ ) => {
42
+ const Component = asChild ? Slot : "span";
43
+ return (
44
+ <Component
45
+ {...props}
46
+ ref={ref as never}
47
+ data-slot="power-metric-value"
48
+ className={cx(
49
+ "text-5xl font-medium tabular-nums tracking-tight",
50
+ className,
51
+ )}
52
+ >
53
+ {children ??
54
+ (value !== null && Number.isFinite(value)
55
+ ? formatter(value)
56
+ : fallback)}
57
+ </Component>
58
+ );
59
+ },
60
+ );
61
+ export const PowerMetric = {
62
+ Root,
63
+ Value,
64
+ Label: part("power-metric-label", "text-sm text-[var(--watt-muted)]", "span"),
65
+ Unit: part("power-metric-unit", "text-lg text-[var(--watt-muted)]", "span"),
66
+ Trend: part(
67
+ "power-metric-trend",
68
+ "inline-flex items-center gap-1 text-sm",
69
+ "span",
70
+ ),
71
+ };
@@ -0,0 +1,31 @@
1
+ "use client";
2
+ import { forwardRef } from "react";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { cx, part } from "../internal.js";
5
+ import type { EquipmentStatus, RootProps } from "../types.js";
6
+ export interface StatusIndicatorProps extends RootProps {
7
+ status: EquipmentStatus;
8
+ }
9
+ const Root = forwardRef<HTMLElement, StatusIndicatorProps>(
10
+ ({ status, density = "comfortable", asChild, className, ...props }, ref) => {
11
+ const Component = asChild ? Slot : "span";
12
+ return (
13
+ <Component
14
+ {...props}
15
+ ref={ref as never}
16
+ data-slot="status-indicator"
17
+ data-status={status}
18
+ data-density={density}
19
+ className={cx(
20
+ "watt-status inline-flex items-center gap-2 text-sm data-[density=compact]:gap-1 data-[density=compact]:text-xs",
21
+ className,
22
+ )}
23
+ />
24
+ );
25
+ },
26
+ );
27
+ export const StatusIndicator = {
28
+ Root,
29
+ Indicator: part("status-indicator-dot", "watt-status-dot", "span"),
30
+ Label: part("status-indicator-label", "", "span"),
31
+ };