feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled

This commit is contained in:
2026-09-04 15:34:24 +08:00
parent 63d67a645b
commit fa5485049a
60 changed files with 4494 additions and 2073 deletions
+133 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import uPlot from 'uplot';
import type { ScalarSeries } from '../training/types';
@@ -14,8 +15,19 @@ function smooth(values: (number | null)[], factor: number): (number | null)[] {
});
}
export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smoothing: number }) {
export function ScalarChart({
series,
smoothing,
title = '训练与评估 Scalars',
}: {
series: ScalarSeries[];
smoothing: number;
title?: string;
}) {
const host = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
const trackZoom = useRef(false);
const zoomRanges = useRef<Partial<Record<'x' | 'y', { min: number; max: number }>>>({});
const prepared = useMemo(() => {
const steps = Array.from(
new Set(series.flatMap((item) => item.points.map((point) => point.step))),
@@ -36,13 +48,24 @@ export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smo
useEffect(() => {
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
const element = host.current;
const width = Math.max(280, Math.floor(element.getBoundingClientRect().width));
trackZoom.current = false;
const chart = new uPlot(
{
width: Math.max(320, element.clientWidth),
height: 360,
title: '训练与评估 Scalars',
width,
height: 280,
cursor: { drag: { x: true, y: true, setScale: true } },
scales: { x: { time: false } },
hooks: {
setScale: [
(instance, key) => {
if (!trackZoom.current || (key !== 'x' && key !== 'y')) return;
const scale = instance.scales[key];
if (typeof scale.min === 'number' && typeof scale.max === 'number')
zoomRanges.current[key] = { min: scale.min, max: scale.max };
},
],
},
axes: [
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
@@ -60,22 +83,123 @@ export function ScalarChart({ series, smoothing }: { series: ScalarSeries[]; smo
prepared,
element,
);
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width;
if (width) chart.setSize({ width: Math.max(320, Math.floor(width)), height: 360 });
chartRef.current = chart;
trackZoom.current = true;
for (const key of ['x', 'y'] as const) {
const range = zoomRanges.current[key];
if (range) chart.setScale(key, range);
}
const wheelZoom = (event: WheelEvent) => {
if (!event.deltaY) return;
event.preventDefault();
event.stopPropagation();
const bounds = chart.over.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
const xRatio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
const yRatio = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height));
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
const factor = Math.min(2, Math.max(0.5, Math.exp(event.deltaY * unit * 0.002)));
for (const key of ['x', 'y'] as const) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const ratio = key === 'x' ? xRatio : 1 - yRatio;
const anchor = scale.min + (scale.max - scale.min) * ratio;
chart.setScale(key, {
min: anchor - (anchor - scale.min) * factor,
max: anchor + (scale.max - anchor) * factor,
});
}
};
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
let frame = 0;
let lastWidth = width;
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
const nextWidth = Math.max(280, Math.floor(element.getBoundingClientRect().width));
if (nextWidth !== lastWidth) {
lastWidth = nextWidth;
chart.setSize({ width: nextWidth, height: 280 });
}
});
});
observer.observe(element);
return () => {
observer.disconnect();
chart.over.removeEventListener('wheel', wheelZoom);
window.cancelAnimationFrame(frame);
trackZoom.current = false;
chartRef.current = null;
chart.destroy();
};
}, [prepared, series]);
const zoom = (factor: number) => {
const chart = chartRef.current;
if (!chart) return;
for (const key of ['x', 'y']) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const center = (scale.min + scale.max) / 2;
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
chart.setScale(key, { min: center - radius, max: center + radius });
}
};
const resetZoom = () => {
const chart = chartRef.current;
if (!chart) return;
zoomRanges.current = {};
trackZoom.current = false;
chart.setData(chart.data, true);
trackZoom.current = true;
};
if (series.length === 0)
return (
<div className="grid h-[360px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
<div className="grid h-[280px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
当前 trial 尚无 scalar 数据
</div>
);
return <div ref={host} className="min-w-0 overflow-hidden rounded-lg bg-app p-2" />;
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-app">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="min-w-0 truncate text-[10px] font-semibold" title={title}>
{title}
</h3>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label={`${title} 放大`}
title="放大"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(0.7)}
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 缩小`}
title="缩小"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(1.4)}
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 重置缩放`}
title="重置缩放"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={resetZoom}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</header>
<div ref={host} className="min-w-0 w-full overflow-hidden" />
<p className="border-t border-border px-3 py-1.5 text-[9px] text-text-tertiary">
图表区域滚轮以指针位置缩放;也可拖拽框选,或使用右上角按钮缩放和复位。
</p>
</section>
);
}