fix(charts): preserve comparison types and draggable window headings
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s

Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap.
This commit is contained in:
2026-11-08 03:04:14 +08:00
parent 4e3ed5098e
commit e0fe4a5a10
7 changed files with 73 additions and 16 deletions
@@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
+2 -2
View File
@@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, {
props: z
.object({
title: shortText,
chart_type: z.enum(["line", "bar"]),
chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(shortText).min(1).max(100),
@@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
})
.strict(),
description:
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
"展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
},
Evidence: {
props: z
@@ -16,7 +16,7 @@ import { AnswerInteractionContext } from "./answer-ui-interaction-context";
export type AnswerChartProps = {
title: string;
chart_type: "line" | "bar";
chart_type: "line" | "bar" | "time_comparison";
x_data: string[];
series: SafeChartSeries[];
x_axis_name?: string;
@@ -100,7 +100,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const numberLabel = chartValueLabel;
const option = (type: "line" | "bar") => {
const horizontal = type === "bar" && horizontalBars;
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type;
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
const includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
@@ -15,7 +15,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
const chartSchema = z
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100),
+20 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react";
@@ -48,16 +48,31 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
};
useEffect(() => () => removeFloatingWindow(id), [id]);
useEffect(() => {
useLayoutEffect(() => {
if (!open) return;
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24;
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset }));
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
const step = headingHeight + 8;
const minHeight = Math.min(260, window.innerHeight - 16);
const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1);
const slot = others.length % slots;
const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8));
// Keep the preceding headings exposed. Shorter viewports reduce content
// height instead of clamping several windows onto the same title bar.
// Absolute placement also makes StrictMode's repeated setup idempotent.
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y,
width: 800, height: Math.min(560, window.innerHeight - y - 8) }));
raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false;
const resize = () => {
setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds((current) => fitWindow(current));
setBounds((current) => {
const minimumHeight = Math.min(260, window.innerHeight - 16);
const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8));
return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) });
});
};
window.addEventListener("resize", resize);
return () => {
@@ -75,9 +90,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
onClick={() => {
if (!open) {
const offset = (order.length % 5) * 24;
setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
setMaximized(false);
focusOnOpen.current = true;
setManuallyOpen(true);