fix(charts): fit analysis windows across viewport sizes
Generic Container CI/CD / test-build-publish (push) Successful in 2m34s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m34s

This commit is contained in:
2026-11-07 20:13:43 +08:00
parent e0436217a8
commit 4a7781d5e5
3 changed files with 69 additions and 12 deletions
@@ -172,10 +172,10 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</dl> </dl>
</motion.section> : null} </motion.section> : null}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground"> <p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span> <Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
</p> </p>
</>; </>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-[360px] min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title} return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
onPointerDownCapture={() => { pointer.current = true; }} onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => { onKeyDownCapture={() => {
pointer.current = false; pointer.current = false;
@@ -200,8 +200,8 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</CardHeader> </CardHeader>
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}> <CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null} {migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(220px,1fr)_minmax(0,auto)] gap-x-4 gap-y-2 @min-[640px]:grid-cols-[minmax(0,1fr)_208px] @min-[640px]:grid-rows-[auto_auto_minmax(220px,1fr)]" : undefined}> <Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined}>
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}> <TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs" <motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }} style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }} initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
@@ -210,10 +210,10 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger> <TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger> <TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList> </TabsList>
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-44 min-h-0 space-y-3 overflow-y-auto border-t border-border pt-3 @min-[640px]:col-start-2 @min-[640px]:row-start-1 @min-[640px]:row-span-3 @min-[640px]:max-h-none @min-[640px]:border-t-0 @min-[640px]:border-l @min-[640px]:pt-0 @min-[640px]:pl-3" : undefined}> <aside className={props.expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined}>
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[640px]:flex-col @min-[640px]:flex-nowrap @min-[640px]:items-stretch" : ""}`} aria-label="显示的数据序列"> <div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : ""}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline" {props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto min-h-8 max-w-full gap-1.5 whitespace-normal text-left text-[11px] ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)} className={`h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)} disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index], onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}> table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
@@ -230,7 +230,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</div> : null} </div> : null}
{props.expanded ? metadata : null} {props.expanded ? metadata : null}
</aside> </aside>
<div className={props.expanded ? "col-start-1 row-start-2" : undefined}> <div className={props.expanded ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null} {!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5"> <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -244,10 +244,10 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button> onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent> </CollapsibleContent>
</div> </div>
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}> <motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}> {(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0} <div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${props.expanded ? "h-full min-h-0 overflow-auto" : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`} className={`${props.expanded ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
@@ -312,9 +312,9 @@ export function InvestigationPresentationPanel({
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={item.chart.title} fillContent <FloatingWindow title={item.chart.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}> openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-[420px] min-w-0 flex-col gap-3"> {() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
<AgentChartView chart={item.chart} embedded expanded /> <AgentChartView chart={item.chart} embedded expanded />
<div className="shrink-0">{references(item.evidence_refs)}</div> {item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
</div>} </div>}
</FloatingWindow> </FloatingWindow>
<Button <Button
@@ -116,3 +116,60 @@ test("structured analysis charts open responsive windows in local preview and hi
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
expect(mock.mutations).toEqual([]); expect(mock.mutations).toEqual([]);
}); });
test("live charts fit desktop and narrow windows without outer scrollbars", async ({ page }, testInfo) => {
const task = investigationFixture(1, "实时图表紧凑布局核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, chart(1));
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
const content = popup.locator("[data-floating-window-content]");
await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible();
for (const [width, height] of [[1440, 900], [1280, 720], [800, 600], [375, 540], [320, 540]]) {
await page.setViewportSize({ width, height });
await expect.poll(() => content.evaluate((node) => ({
x: node.scrollWidth - node.clientWidth,
y: node.scrollHeight - node.clientHeight
}))).toEqual({ x: 0, y: 0 });
const plot = popup.getByRole("img", { name: "合成压力分析 1", exact: true });
expect((await plot.boundingBox())!.height).toBeGreaterThanOrEqual(120);
await testInfo.attach(`live-chart-fit-${width}`, {
body: await popup.screenshot({ path: testInfo.outputPath(`live-chart-fit-${width}.png`) }), contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
await popup.getByRole("button", { name: "筛选数据范围", exact: true }).click();
await expect(popup.getByRole("combobox", { name: "区间起点" })).toBeVisible();
await expect.poll(() => content.evaluate((node) => node.scrollHeight - node.clientHeight)).toBe(0);
await popup.getByRole("tab", { name: "数据", exact: true }).click();
await expect(popup.getByRole("table")).toBeVisible();
expect(mock.mutations).toEqual([]);
});
test("SCADA chart axes and legends fit every nested container", async ({ page }, testInfo) => {
const task = investigationFixture(1, "SCADA 压力布局核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, { action: "open_chart", chart_id: "scada-pressure", evidence_refs: ["synthetic:pressure"], chart: {
title: "SCADA 回放压力:异常前后(2026-07-16,UTC+8)", chart_type: "line",
x_axis_name: "时间(UTC+8)", y_axis_name: "压力(m)", x_data: ["07:30", "07:45", "08:00"],
series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] },
{ name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }]
} });
const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16,UTC+8)", exact: true });
await expect(popup).toBeVisible();
await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*"))
.filter((element) => {
const style = getComputedStyle(element);
return (style.overflowX === "auto" || style.overflowX === "scroll" || style.overflowY === "auto" || style.overflowY === "scroll") &&
(element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight);
}).map((element) => ({ class: element.className, x: element.scrollWidth - element.clientWidth, y: element.scrollHeight - element.clientHeight }))
)).toEqual([]);
const plot = popup.getByRole("group", { name: /按回车查看数据点/ });
await expect.poll(() => plot.evaluate((element) => ({
x: element.scrollWidth - element.clientWidth, y: element.scrollHeight - element.clientHeight
}))).toEqual({ x: 0, y: 0 });
await testInfo.attach("scada-chart-layout", {
body: await popup.screenshot({ path: testInfo.outputPath("scada-chart-layout.png") }), contentType: "image/png"
});
});