refactor(ui): organize component source tree
This commit is contained in:
parent
8927f2ab8a
commit
32ad2cebe4
66 changed files with 194 additions and 124 deletions
114
packages/ui/src/components/telemetry/LineChart.tsx
Normal file
114
packages/ui/src/components/telemetry/LineChart.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import "uplot/dist/uPlot.min.css";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UiSeverity } from "../../types";
|
||||
|
||||
export interface LineChartProps {
|
||||
values?: number[];
|
||||
severity?: UiSeverity;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
values = [],
|
||||
severity = "neutral",
|
||||
label = "Telemetry trend",
|
||||
}: LineChartProps) {
|
||||
const chartElementRef = useRef<HTMLDivElement>(null);
|
||||
const valuesKey = values.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
const chartElement = chartElementRef.current;
|
||||
if (!chartElement) return;
|
||||
|
||||
let chart: ChartInstance | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let disposed = false;
|
||||
|
||||
void import("uplot").then((module) => {
|
||||
if (disposed || !chartElementRef.current) return;
|
||||
|
||||
const element = chartElementRef.current;
|
||||
chart = new module.default(chartOptions(element, severity), chartData(values), element);
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
chart?.setSize(chartSize(element));
|
||||
});
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
chart?.destroy();
|
||||
};
|
||||
}, [severity, valuesKey, values]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="line-chart"
|
||||
data-chart-library="uplot"
|
||||
data-severity={severity}
|
||||
data-values={values.join(",")}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<div ref={chartElementRef} className="line-chart__canvas" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartInstance = {
|
||||
destroy(): void;
|
||||
setSize(size: { width: number; height: number }): void;
|
||||
};
|
||||
|
||||
function chartData(values: number[]): import("uplot").AlignedData {
|
||||
const normalized = values.length ? values : [0, 0];
|
||||
return [
|
||||
normalized.map((_, index) => index),
|
||||
normalized.map((value) => Math.max(0, Number(value) || 0)),
|
||||
];
|
||||
}
|
||||
|
||||
function chartOptions(
|
||||
element: HTMLElement,
|
||||
severity: UiSeverity,
|
||||
): import("uplot").Options {
|
||||
return {
|
||||
...chartSize(element),
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
padding: [2, 0, 2, 0],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: { auto: true },
|
||||
},
|
||||
axes: [{ show: false }, { show: false }],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: chartStroke(element, severity),
|
||||
width: 2,
|
||||
points: { show: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function chartSize(element?: HTMLElement): { width: number; height: number } {
|
||||
return {
|
||||
width: Math.max(80, Math.round(element?.clientWidth || 120)),
|
||||
height: Math.max(20, Math.round(element?.clientHeight || 24)),
|
||||
};
|
||||
}
|
||||
|
||||
function chartStroke(element: HTMLElement, severity: UiSeverity): string {
|
||||
const styles = window.getComputedStyle(element.closest(".line-chart") || element);
|
||||
const currentColor = styles.color;
|
||||
if (currentColor) return currentColor;
|
||||
if (severity === "danger") return "#ff1744";
|
||||
if (severity === "warning") return "#ffb020";
|
||||
return "#d7ff00";
|
||||
}
|
||||
26
packages/ui/src/components/telemetry/SignalTrace.tsx
Normal file
26
packages/ui/src/components/telemetry/SignalTrace.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export interface SignalTraceProps {
|
||||
density?: "regular" | "tight";
|
||||
orientation?: "horizontal" | "vertical";
|
||||
tone?: "neutral" | "accent" | "warning";
|
||||
}
|
||||
|
||||
export function SignalTrace({
|
||||
density = "regular",
|
||||
orientation = "horizontal",
|
||||
tone = "accent",
|
||||
}: SignalTraceProps) {
|
||||
return (
|
||||
<div
|
||||
className="signal-trace"
|
||||
data-density={density}
|
||||
data-orientation={orientation}
|
||||
data-tone={tone}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="signal-trace__rail" />
|
||||
<span className="signal-trace__node" data-node="start" />
|
||||
<span className="signal-trace__node" data-node="middle" />
|
||||
<span className="signal-trace__node" data-node="end" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
packages/ui/src/components/telemetry/Sparkline.tsx
Normal file
37
packages/ui/src/components/telemetry/Sparkline.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { UiSeverity } from "../../types";
|
||||
|
||||
export interface SparklineProps {
|
||||
values?: number[];
|
||||
severity?: UiSeverity;
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
values = [],
|
||||
severity = "neutral",
|
||||
}: SparklineProps) {
|
||||
const points = toPoints(values);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="sparkline"
|
||||
data-severity={severity}
|
||||
viewBox="0 0 100 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{points ? <polyline points={points} /> : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function toPoints(values: number[]): string {
|
||||
if (values.length === 0) return "";
|
||||
const max = Math.max(...values, 1);
|
||||
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const x = index * step;
|
||||
const y = 24 - (Math.max(0, value) / max) * 22;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
59
packages/ui/src/components/telemetry/TelemetryCard.tsx
Normal file
59
packages/ui/src/components/telemetry/TelemetryCard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { clampPercent, formatMetricValue } from "../../format";
|
||||
import type { UiTelemetryCard } from "../../types";
|
||||
import { IconGlyph } from "../foundation/IconGlyph";
|
||||
import { LineChart } from "./LineChart";
|
||||
|
||||
export interface TelemetryCardProps {
|
||||
card: UiTelemetryCard;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function TelemetryCard({ card, index }: TelemetryCardProps) {
|
||||
const progress = resolveProgress(card);
|
||||
const value = formatMetricValue(card.value);
|
||||
const metricIndex = index ? String(index).padStart(2, "0") : undefined;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="telemetry-card"
|
||||
data-severity={card.severity}
|
||||
data-model-id={card.id}
|
||||
data-metric-index={metricIndex}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
{metricIndex ? (
|
||||
<span className="telemetry-card__index">{metricIndex}</span>
|
||||
) : null}
|
||||
<h3>{card.label}</h3>
|
||||
</div>
|
||||
{card.icon ? <IconGlyph name={card.icon} size="sm" /> : null}
|
||||
</header>
|
||||
<strong>{value}</strong>
|
||||
{progress !== null ? (
|
||||
<div className="telemetry-card__bar" aria-hidden="true">
|
||||
<span style={{ "--metric-progress": `${progress}%` } as CSSProperties} />
|
||||
</div>
|
||||
) : null}
|
||||
{card.sparkline?.length ? (
|
||||
<LineChart
|
||||
values={card.sparkline}
|
||||
severity={card.severity}
|
||||
label={`${card.label} trend`}
|
||||
/>
|
||||
) : null}
|
||||
{card.detail || card.description ? (
|
||||
<p className="telemetry-card__source">{card.detail || card.description}</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProgress(card: UiTelemetryCard): number | null {
|
||||
if (typeof card.progress === "number") return clampPercent(card.progress);
|
||||
if (card.value.kind !== "percent") return null;
|
||||
|
||||
const progress = Number(card.value.value);
|
||||
return Number.isFinite(progress) ? clampPercent(progress) : null;
|
||||
}
|
||||
29
packages/ui/src/components/telemetry/TelemetryGrid.tsx
Normal file
29
packages/ui/src/components/telemetry/TelemetryGrid.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useId } from "react";
|
||||
import type { UiTelemetryCard } from "../../types";
|
||||
import { TelemetryCard } from "./TelemetryCard";
|
||||
|
||||
export interface TelemetryGridProps {
|
||||
cards: UiTelemetryCard[];
|
||||
}
|
||||
|
||||
export function TelemetryGrid({ cards }: TelemetryGridProps) {
|
||||
const headingId = useId();
|
||||
const metricCount = `${cards.length} ${cards.length === 1 ? "Metric" : "Metrics"}`;
|
||||
|
||||
return (
|
||||
<section className="telemetry-section" aria-labelledby={headingId}>
|
||||
<header className="telemetry-section__header">
|
||||
<div>
|
||||
<p>Signal Matrix</p>
|
||||
<h2 id={headingId}>Telemetry</h2>
|
||||
</div>
|
||||
<span>{metricCount}</span>
|
||||
</header>
|
||||
<div className="telemetry-grid">
|
||||
{cards.map((card, index) => (
|
||||
<TelemetryCard key={card.id} card={card} index={index + 1} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
packages/ui/src/components/telemetry/TelemetryStrip.tsx
Normal file
6
packages/ui/src/components/telemetry/TelemetryStrip.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { UiTelemetryCard } from "../../types";
|
||||
import { TelemetryGrid } from "./TelemetryGrid";
|
||||
|
||||
export function TelemetryStrip({ cards }: { cards: UiTelemetryCard[] }) {
|
||||
return <TelemetryGrid cards={cards} />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue