@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.
- package/LICENSE +21 -0
- package/NOTICE +43 -0
- package/README.md +48 -2
- package/dist/chart/data.d.ts +13 -0
- package/dist/chart/data.d.ts.map +1 -0
- package/dist/chart/data.js +92 -0
- package/dist/chart/data.js.map +1 -0
- package/dist/chart/engine.d.ts +2 -0
- package/dist/chart/engine.d.ts.map +1 -0
- package/dist/chart/engine.js +16 -0
- package/dist/chart/engine.js.map +1 -0
- package/dist/chart/options.d.ts +18 -0
- package/dist/chart/options.d.ts.map +1 -0
- package/dist/chart/options.js +136 -0
- package/dist/chart/options.js.map +1 -0
- package/dist/components/alarm-feed.d.ts +18 -0
- package/dist/components/alarm-feed.d.ts.map +1 -0
- package/dist/components/alarm-feed.js +45 -0
- package/dist/components/alarm-feed.js.map +1 -0
- package/dist/components/asset-tree.d.ts +33 -0
- package/dist/components/asset-tree.d.ts.map +1 -0
- package/dist/components/asset-tree.js +149 -0
- package/dist/components/asset-tree.js.map +1 -0
- package/dist/components/battery-soc.d.ts +11 -0
- package/dist/components/battery-soc.d.ts.map +1 -0
- package/dist/components/battery-soc.js +34 -0
- package/dist/components/battery-soc.js.map +1 -0
- package/dist/components/data-freshness.d.ts +26 -0
- package/dist/components/data-freshness.d.ts.map +1 -0
- package/dist/components/data-freshness.js +45 -0
- package/dist/components/data-freshness.js.map +1 -0
- package/dist/components/energy-flow.d.ts +39 -0
- package/dist/components/energy-flow.d.ts.map +1 -0
- package/dist/components/energy-flow.js +176 -0
- package/dist/components/energy-flow.js.map +1 -0
- package/dist/components/flow-geometry.d.ts +24 -0
- package/dist/components/flow-geometry.d.ts.map +1 -0
- package/dist/components/flow-geometry.js +57 -0
- package/dist/components/flow-geometry.js.map +1 -0
- package/dist/components/power-metric.d.ts +14 -0
- package/dist/components/power-metric.d.ts.map +1 -0
- package/dist/components/power-metric.js +25 -0
- package/dist/components/power-metric.js.map +1 -0
- package/dist/components/status-indicator.d.ts +10 -0
- package/dist/components/status-indicator.d.ts.map +1 -0
- package/dist/components/status-indicator.js +15 -0
- package/dist/components/status-indicator.js.map +1 -0
- package/dist/components/telemetry-chart.d.ts +50 -0
- package/dist/components/telemetry-chart.d.ts.map +1 -0
- package/dist/components/telemetry-chart.js +256 -0
- package/dist/components/telemetry-chart.js.map +1 -0
- package/dist/domain.d.ts +6 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +43 -0
- package/dist/domain.js.map +1 -0
- package/dist/icons/index.d.ts +7 -0
- package/dist/icons/index.d.ts.map +1 -0
- package/dist/icons/index.js +38 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +45 -0
- package/dist/internal.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +32 -0
- package/dist/server.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/theme/shadcn.css +14 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +118 -4
- package/src/chart/data.ts +103 -0
- package/src/chart/engine.ts +19 -0
- package/src/chart/options.ts +178 -0
- package/src/components/alarm-feed.tsx +126 -0
- package/src/components/asset-tree.tsx +258 -0
- package/src/components/battery-soc.tsx +84 -0
- package/src/components/data-freshness.tsx +109 -0
- package/src/components/energy-flow.tsx +364 -0
- package/src/components/flow-geometry.ts +82 -0
- package/src/components/power-metric.tsx +71 -0
- package/src/components/status-indicator.tsx +31 -0
- package/src/components/telemetry-chart.tsx +476 -0
- package/src/domain.ts +64 -0
- package/src/icons/index.tsx +49 -0
- package/src/index.ts +37 -0
- package/src/internal.tsx +57 -0
- package/src/server.ts +47 -0
- package/src/styles.css +130 -0
- package/src/theme/shadcn.css +14 -0
- 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
|
+
};
|