feat: add adaptive agent workspace windows

This commit is contained in:
2026-08-19 12:57:37 +08:00
parent d08cf2abc1
commit f58e0a70f4
18 changed files with 1797 additions and 309 deletions
@@ -0,0 +1,211 @@
import type { AnalysisDocument } from "./workspace-model";
type FixtureFactory = (id: string, generatedAt: string) => AnalysisDocument;
const PRESSURE_DIAGNOSIS: FixtureFactory = (id, generatedAt) => ({
id,
title: "北辰分区压力异常诊断",
subtitle: "Agent 汇总最近 6 小时 SCADA 压力、流量与泵组工况",
generatedAt,
blocks: [
{
id: "pressure-metrics",
kind: "metric-grid",
title: "诊断摘要",
span: 12,
metrics: [
{ label: "异常测点", value: "7", detail: "较上一时段增加 2 个", tone: "danger" },
{ label: "最低压力", value: "0.186 MPa", detail: "BC-P-014 · 10:32", tone: "warning" },
{ label: "供水缺口", value: "3.8%", detail: "预计持续 42 分钟", tone: "info" },
{ label: "诊断置信度", value: "92.6%", detail: "126 个有效样本", tone: "success" }
]
},
{
id: "pressure-trend",
kind: "chart",
title: "关键节点压力曲线",
description: "BC-P-014 在 09:40 后持续低于调度下限",
span: 7,
chartType: "line",
categories: ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00"],
unit: "MPa",
series: [
{ name: "BC-P-014", values: [0.31, 0.304, 0.287, 0.264, 0.213, 0.186, 0.198], color: "#2563eb" },
{ name: "BC-P-021", values: [0.346, 0.351, 0.338, 0.329, 0.315, 0.306, 0.312], color: "#0f9f8f" },
{ name: "调度下限", values: [0.22, 0.22, 0.22, 0.22, 0.22, 0.22, 0.22], color: "#e05252" }
]
},
{
id: "pressure-ranking",
kind: "chart",
title: "异常偏差排名",
description: "按相对调度下限的压力偏差排序",
span: 5,
chartType: "bar",
categories: ["P-014", "P-008", "P-026", "P-031", "P-017"],
unit: "%",
series: [{ name: "偏差", values: [15.5, 11.8, 9.7, 7.2, 5.9], color: "#e05252" }]
},
{
id: "pressure-evidence",
kind: "data-table",
title: "证据链",
description: "对诊断结论贡献最高的观测证据",
span: 12,
columns: [
{ key: "time", label: "时间" },
{ key: "source", label: "数据源" },
{ key: "finding", label: "观测" },
{ key: "impact", label: "影响" },
{ key: "confidence", label: "置信度", numeric: true }
],
rows: [
{ time: "10:32", source: "BC-P-014", finding: "压力降至 0.186 MPa", impact: "低压风险", confidence: "97.2%" },
{ time: "10:28", source: "BC-F-006", finding: "流量较基线下降 8.4%", impact: "供水缺口", confidence: "91.6%" },
{ time: "10:21", source: "二泵站 2#", finding: "出口压力波动 0.041 MPa", impact: "上游扰动", confidence: "88.9%" }
]
}
]
});
const SUPPLY_ZONE_ANALYSIS: FixtureFactory = (id, generatedAt) => ({
id,
title: "供水服务分区平衡分析",
subtitle: "Agent 对比分区供水量、需求预测和关键连通通道",
generatedAt,
blocks: [
{
id: "zone-metrics",
kind: "metric-grid",
title: "分区态势",
span: 12,
metrics: [
{ label: "分析分区", value: "6", detail: "覆盖 94.3% 用水需求", tone: "neutral" },
{ label: "高负荷分区", value: "2", detail: "北辰、东丽", tone: "warning" },
{ label: "可调配余量", value: "1.74 万m³", detail: "主要来自津南分区", tone: "success" },
{ label: "建议联络线", value: "3 条", detail: "预计缓解 6.1% 峰值压力", tone: "info" }
]
},
{
id: "zone-balance",
kind: "chart",
title: "分区供需对比",
description: "北辰与东丽当前需求已接近可用供水能力",
span: 7,
chartType: "bar",
categories: ["北辰", "东丽", "津南", "西青", "武清", "中心"],
unit: "万m³/d",
series: [
{ name: "可用能力", values: [9.82, 8.31, 10.46, 7.92, 6.83, 11.24], color: "#2563eb" },
{ name: "预测需求", values: [9.47, 8.08, 8.72, 6.84, 5.91, 9.86], color: "#f09a42" }
]
},
{
id: "zone-risk",
kind: "data-table",
title: "调配优先级",
description: "按负荷率、连续供水和通道可用性综合排序",
span: 5,
columns: [
{ key: "zone", label: "分区" },
{ key: "load", label: "负荷率", numeric: true },
{ key: "risk", label: "风险" },
{ key: "action", label: "建议" }
],
rows: [
{ zone: "北辰", load: "96.4%", risk: "高", action: "开启西北联络线" },
{ zone: "东丽", load: "97.2%", risk: "高", action: "提升三泵站频率" },
{ zone: "西青", load: "86.3%", risk: "中", action: "保留调节余量" },
{ zone: "中心", load: "87.7%", risk: "中", action: "监测夜峰变化" }
]
},
{
id: "zone-note",
kind: "narrative",
title: "Agent 结论",
span: 12,
paragraphs: [
"优先将津南分区的可调配余量通过南北联络线输送至东丽,再由中心环网承担北辰分区的部分峰值需求。",
"执行前需复核三泵站 2# 泵组可用状态,并确认阀门 V-BC-103、V-DL-047 的远控权限。"
]
}
]
});
const DISPATCH_FLOW: FixtureFactory = (id, generatedAt) => ({
id,
title: "低压事件调度处置流程",
subtitle: "Agent 将诊断、复核、调度和验证步骤编排为受控流程",
generatedAt,
blocks: [
{
id: "flow-metrics",
kind: "metric-grid",
title: "执行状态",
span: 12,
metrics: [
{ label: "当前阶段", value: "现场复核", detail: "步骤 2 / 5", tone: "info" },
{ label: "已用时间", value: "18 分钟", detail: "目标时限 45 分钟", tone: "success" },
{ label: "待确认项", value: "3", detail: "含 1 项远控权限", tone: "warning" },
{ label: "影响用户", value: "1,284 户", detail: "暂无停水用户", tone: "neutral" }
]
},
{
id: "dispatch-process",
kind: "process-flow",
title: "处置路径",
description: "所有调度动作均需在执行前完成权限和现场状态确认",
span: 7,
nodes: [
{ id: "detect", label: "异常识别", detail: "确认压力与流量异常持续 10 分钟", status: "complete" },
{ id: "verify", label: "现场复核", detail: "复核二泵站出口压力与阀门状态", status: "active" },
{ id: "simulate", label: "方案模拟", detail: "比较联络线调配和泵组增压方案", status: "pending" },
{ id: "execute", label: "调度执行", detail: "确认后下发受控阀门与泵组操作", status: "pending" },
{ id: "observe", label: "效果验证", detail: "观察 15 分钟并更新事件结论", status: "pending" }
]
},
{
id: "dispatch-checklist",
kind: "data-table",
title: "执行清单",
description: "当前阶段需要完成的人员与系统确认",
span: 5,
columns: [
{ key: "owner", label: "责任方" },
{ key: "item", label: "确认项" },
{ key: "state", label: "状态" }
],
rows: [
{ owner: "值班调度", item: "核对 SCADA 时间戳", state: "完成" },
{ owner: "二泵站", item: "复核 2# 泵组出口压力", state: "进行中" },
{ owner: "管网运维", item: "确认 V-BC-103 现场状态", state: "待处理" },
{ owner: "系统管理员", item: "确认远控操作权限", state: "待处理" }
]
},
{
id: "dispatch-progress",
kind: "chart",
title: "处置前后压力预测",
description: "模拟结果显示执行后 20 分钟内压力回到调度区间",
span: 12,
chartType: "line",
categories: ["当前", "+5m", "+10m", "+15m", "+20m", "+30m"],
unit: "MPa",
series: [
{ name: "不采取动作", values: [0.186, 0.181, 0.178, 0.176, 0.179, 0.184], color: "#e05252" },
{ name: "执行建议方案", values: [0.186, 0.204, 0.226, 0.251, 0.267, 0.281], color: "#2563eb" }
]
}
]
});
const FIXTURE_FACTORIES = [PRESSURE_DIAGNOSIS, SUPPLY_ZONE_ANALYSIS, DISPATCH_FLOW] as const;
export function createAnalysisDocumentFixture(index: number, instance: number): AnalysisDocument {
const factory = FIXTURE_FACTORIES[index % FIXTURE_FACTORIES.length] ?? PRESSURE_DIAGNOSIS;
const now = new Date();
return factory(
`analysis-${now.getTime().toString(36)}-${instance}`,
now.toLocaleTimeString("zh-CN", { hour12: false })
);
}
@@ -0,0 +1,203 @@
import ReactECharts from "echarts-for-react";
import { Activity, Check, Clock3, FileSearch, Sparkles } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import type {
AnalysisBlock,
AnalysisDocument,
AnalysisMetric
} from "./workspace-model";
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
return (
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
Agent
</div>
<h2 className="mt-1 text-xl font-semibold leading-7 text-slate-950">{document.title}</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">{document.subtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-600">
<Clock3 size={13} aria-hidden="true" />
{document.generatedAt}
</div>
</header>
<div className="grid grid-cols-12 gap-4 p-4 xl:p-5">
{document.blocks.map((block) => (
<AnalysisBlockView key={block.id} block={block} />
))}
</div>
</article>
);
}
function AnalysisBlockView({ block }: { block: AnalysisBlock }) {
const spanClass = block.span === 5 ? "col-span-12 xl:col-span-5" : block.span === 7 ? "col-span-12 xl:col-span-7" : "col-span-12";
if (block.kind === "metric-grid") {
return (
<section className={spanClass} aria-labelledby={`${block.id}-title`}>
<h3 id={`${block.id}-title`} className="sr-only">{block.title}</h3>
<div className="grid grid-cols-2 gap-3 2xl:grid-cols-4">
{block.metrics.map((metric) => (
<MetricTile key={metric.label} metric={metric} />
))}
</div>
</section>
);
}
if (block.kind === "chart") {
return (
<AnalysisSection block={block} className={spanClass}>
<div className="h-[258px] min-h-0">
<ReactECharts
notMerge
lazyUpdate
style={{ width: "100%", height: "100%" }}
option={createChartOption(block)}
/>
</div>
</AnalysisSection>
);
}
if (block.kind === "process-flow") {
return (
<AnalysisSection block={block} className={spanClass}>
<ol className="space-y-0" aria-label={block.title}>
{block.nodes.map((node, index) => (
<li key={node.id} className="relative flex gap-3 pb-4 last:pb-0">
{index < block.nodes.length - 1 ? (
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-slate-200" aria-hidden="true" />
) : null}
<span
className={cn(
"relative z-[1] grid h-8 w-8 shrink-0 place-items-center rounded-full",
node.status === "complete" && "bg-emerald-100 text-emerald-700",
node.status === "active" && "bg-blue-600 text-white shadow-[0_0_0_4px_rgba(37,99,235,0.12)]",
node.status === "pending" && "bg-slate-100 text-slate-500"
)}
>
{node.status === "complete" ? <Check size={15} aria-hidden="true" /> : node.status === "active" ? <Activity size={15} aria-hidden="true" /> : <Clock3 size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 pt-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">{node.label}</p>
{node.status === "active" ? <span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700"></span> : null}
</div>
<p className="mt-1 text-sm leading-5 text-slate-600">{node.detail}</p>
</div>
</li>
))}
</ol>
</AnalysisSection>
);
}
if (block.kind === "data-table") {
return (
<AnalysisSection block={block} className={spanClass}>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-xs font-semibold text-slate-500">
{block.columns.map((column) => (
<th key={column.key} scope="col" className={cn("px-3 py-2.5 text-left", column.numeric && "text-right")}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, index) => (
<tr key={`${block.id}-${index}`} className="border-b border-slate-100 last:border-0">
{block.columns.map((column) => (
<td key={column.key} className={cn("px-3 py-3 text-slate-700", column.numeric && "text-right tabular-nums")}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</AnalysisSection>
);
}
return (
<AnalysisSection block={block} className={spanClass}>
<div className="space-y-3 text-sm leading-7 text-slate-700">
{block.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</AnalysisSection>
);
}
function AnalysisSection({
block,
className,
children
}: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
className: string;
children: React.ReactNode;
}) {
return (
<section className={cn("min-w-0 overflow-hidden rounded-lg bg-white shadow-[0_1px_4px_rgba(15,23,42,0.12)]", className)} aria-labelledby={`${block.id}-title`}>
<header className="flex items-start gap-3 border-b border-slate-100 px-4 py-3.5">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700"><FileSearch size={16} aria-hidden="true" /></span>
<div className="min-w-0">
<h3 id={`${block.id}-title`} className="text-sm font-semibold text-slate-950">{block.title}</h3>
{"description" in block ? <p className="mt-1 text-xs leading-5 text-slate-500">{block.description}</p> : null}
</div>
</header>
<div className="p-4">{children}</div>
</section>
);
}
function MetricTile({ metric }: { metric: AnalysisMetric }) {
return (
<div className="min-w-0 rounded-lg bg-white px-4 py-3.5 shadow-[0_1px_4px_rgba(15,23,42,0.12)]">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", metricToneClass(metric.tone))} aria-hidden="true" />
<p className="truncate text-xs font-medium text-slate-500">{metric.label}</p>
</div>
<p className="mt-2 text-xl font-semibold text-slate-950 tabular-nums">{metric.value}</p>
<p className="mt-1 truncate text-xs text-slate-500" title={metric.detail}>{metric.detail}</p>
</div>
);
}
function createChartOption(block: Extract<AnalysisBlock, { kind: "chart" }>) {
return {
animationDuration: 260,
animationEasing: "cubicOut",
color: block.series.map((series) => series.color),
grid: { top: 40, right: 18, bottom: 34, left: 52, containLabel: false },
legend: { top: 0, right: 0, itemWidth: 12, itemHeight: 7, textStyle: { color: "#475569", fontSize: 11 } },
tooltip: { trigger: "axis", confine: true, borderWidth: 0, backgroundColor: "rgba(15,23,42,0.92)", textStyle: { color: "#f8fafc", fontSize: 12 } },
xAxis: { type: "category", data: block.categories, boundaryGap: block.chartType === "bar", axisLine: { lineStyle: { color: "#cbd5e1" } }, axisTick: { show: false }, axisLabel: { color: "#64748b", fontSize: 11 } },
yAxis: { type: "value", name: block.unit, nameTextStyle: { color: "#64748b", fontSize: 11, padding: [0, 0, 0, -26] }, splitLine: { lineStyle: { color: "rgba(148,163,184,0.18)" } }, axisLabel: { color: "#64748b", fontSize: 11 } },
series: block.series.map((series) => ({
name: series.name,
type: series.type ?? block.chartType,
data: series.values,
smooth: (series.type ?? block.chartType) === "line" ? 0.28 : undefined,
symbol: "circle",
symbolSize: 6,
barMaxWidth: 24,
lineStyle: { width: 2.5 },
itemStyle: { borderRadius: (series.type ?? block.chartType) === "bar" ? [3, 3, 0, 0] : undefined }
}))
};
}
function metricToneClass(tone: AnalysisMetric["tone"]) {
if (tone === "danger") return "bg-rose-500";
if (tone === "warning") return "bg-amber-500";
if (tone === "success") return "bg-emerald-500";
if (tone === "info") return "bg-blue-600";
return "bg-slate-400";
}
@@ -0,0 +1,324 @@
import {
Bot,
CalendarClock,
ChevronsLeft,
ChevronsRight,
FlaskConical,
Layers3,
Map as MapIcon,
PanelLeft,
SlidersHorizontal,
Sparkles
} from "lucide-react";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { AnalysisDocumentView } from "./analysis-document-view";
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
import {
MAX_ANALYSIS_WINDOWS,
closeAnalysisWindow,
createInitialWorkspaceState,
focusWorkspaceWindow,
maximizeWorkspaceWindow,
minimizeWorkspaceWindow,
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
updateWorkspaceWindowRect,
type WorkspaceBounds
} from "./workspace-model";
import { WorkspaceWindow } from "./workspace-window";
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
type WorkbenchMainFrameProps = {
renderAgentPanel: (onCollapse: () => void) => ReactNode;
conditionsPanel: ReactNode;
layersPanel: ReactNode;
toolsPanel: ReactNode;
mapContent: ReactNode;
mapOverlay?: ReactNode;
testEnabled: boolean;
activeSection: WorkbenchNavigationSection;
navigationCollapsed: boolean;
onActiveSectionChange: (section: WorkbenchNavigationSection) => void;
onNavigationCollapsedChange: (collapsed: boolean) => void;
};
const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; icon: typeof Bot }> = [
{ id: "agent", label: "Agent", icon: Bot },
{ id: "conditions", label: "工况任务", icon: CalendarClock },
{ id: "layers", label: "图层", icon: Layers3 },
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
];
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
export function WorkbenchMainFrame({
renderAgentPanel,
conditionsPanel,
layersPanel,
toolsPanel,
mapContent,
mapOverlay,
testEnabled,
activeSection,
navigationCollapsed,
onActiveSectionChange,
onNavigationCollapsedChange
}: WorkbenchMainFrameProps) {
const workspaceRef = useRef<HTMLDivElement | null>(null);
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
const [desktop, setDesktop] = useState(false);
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
useEffect(() => {
const element = workspaceRef.current;
if (!element) return;
const updateBounds = () => {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const nextBounds = { width: rect.width, height: rect.height };
setBounds(nextBounds);
setWorkspace((current) => resizeWorkspace(current, nextBounds));
}
};
updateBounds();
const observer = new ResizeObserver(updateBounds);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setDesktop(mediaQuery.matches);
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const openTestDocument = useCallback(() => {
if (workspace.analyses.length >= MAX_ANALYSIS_WINDOWS) {
showMapNotice({
tone: "warning",
title: "临时窗口已达上限",
message: "请关闭一个 Agent 分析窗口后继续测试。"
});
return;
}
const document = createAnalysisDocumentFixture(fixtureIndexRef.current, fixtureInstanceRef.current);
fixtureIndexRef.current = (fixtureIndexRef.current + 1) % 3;
fixtureInstanceRef.current += 1;
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]);
const navigationWidth = navigationCollapsed ? 48 : 368;
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
data-testid="workbench-main-frame"
style={{ "--workbench-frame-navigation-width": `${navigationWidth}px` } as CSSProperties}
>
<aside
aria-label="工作台导航"
className="pointer-events-auto absolute bottom-0 left-0 top-0 z-30 hidden overflow-hidden border-r border-slate-300 bg-[#edf1f5] shadow-[1px_0_3px_rgba(15,23,42,0.12)] lg:flex"
style={{ width: navigationWidth }}
>
<nav className="flex w-12 shrink-0 flex-col items-center border-r border-slate-300 bg-[#e2e8ef] py-2" aria-label="主工作区">
<span className="mb-2 grid h-9 w-9 place-items-center rounded-md bg-slate-900 text-white"><PanelLeft size={17} aria-hidden="true" /></span>
{NAVIGATION_ITEMS.map((item) => {
const Icon = item.icon;
const active = item.id === activeSection;
return (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-pressed={active}
title={item.label}
className={cn(
"relative grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : "hover:bg-white/70 hover:text-slate-900"
)}
onClick={() => { onActiveSectionChange(item.id); onNavigationCollapsedChange(false); }}
>
<Icon size={19} aria-hidden="true" />
{active ? <span className="absolute bottom-1.5 h-1 w-1 rounded-full bg-blue-600" aria-hidden="true" /> : null}
</button>
);
})}
<div className="mt-auto flex flex-col items-center gap-1">
{testEnabled ? (
<button
type="button"
aria-label="测试打开 Agent 多图表窗口"
title="测试打开 Agent 多图表窗口"
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="grid h-11 w-11 place-items-center rounded-md text-blue-700 transition-[background-color,color,scale] hover:bg-blue-50 active:scale-[0.96] disabled:text-slate-400 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={openTestDocument}
>
<FlaskConical size={19} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-label={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
title={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
className="grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] hover:bg-white active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => onNavigationCollapsedChange(!navigationCollapsed)}
>
{navigationCollapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={18} aria-hidden="true" />}
</button>
</div>
</nav>
<div
aria-hidden={navigationCollapsed}
className={cn(
"flex min-w-0 flex-1 flex-col bg-[#f7f9fb]",
navigationCollapsed && "invisible"
)}
>
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-600" aria-hidden="true" />
<h2 className="truncate text-sm font-semibold text-slate-900">{NAVIGATION_ITEMS.find((item) => item.id === activeSection)?.label}</h2>
</div>
{testEnabled ? (
<button
type="button"
onClick={openTestDocument}
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="flex h-9 items-center gap-1.5 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(37,99,235,0.3)] transition-[background-color,scale] hover:bg-blue-700 active:scale-[0.96] disabled:bg-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<Sparkles size={14} aria-hidden="true" />
</button>
) : null}
</header>
<div className="relative min-h-0 flex-1 overflow-hidden [--workbench-condition-width:320px]">
{desktop ? (
<>
<NavigationPanel active={activeSection === "agent"}>
{renderAgentPanel(() => onNavigationCollapsedChange(true))}
</NavigationPanel>
<NavigationPanel active={activeSection === "conditions"}>{conditionsPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "layers"}>{layersPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "tools"}>{toolsPanel}</NavigationPanel>
</>
) : null}
</div>
</div>
</aside>
<div
ref={workspaceRef}
id="main-workspace"
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
>
<WorkspaceWindow
id={workspace.map.id}
title={workspace.map.title}
mode={desktop ? workspace.map.mode : "docked"}
rect={workspace.map.rect}
zIndex={workspace.map.zIndex}
active={workspace.activeWindowId === workspace.map.id}
kind="map"
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
>
{mapContent}
{mapOverlay}
</WorkspaceWindow>
{workspace.analyses.map((window) => (
<WorkspaceWindow
key={window.id}
id={window.id}
title={window.title}
mode={window.mode}
rect={window.rect}
zIndex={window.zIndex}
active={workspace.activeWindowId === window.id}
kind="analysis"
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))}
>
<AnalysisDocumentView document={window.document} />
</WorkspaceWindow>
))}
</div>
<div
className="pointer-events-auto absolute bottom-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:left-[var(--workbench-frame-navigation-width)] lg:flex"
role="toolbar"
aria-label="工作区窗口任务栏"
>
<TaskbarButton
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
minimized={workspace.map.mode === "minimized"}
icon={<MapIcon size={15} aria-hidden="true" />}
label="供水管网地图"
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
/>
{workspace.analyses.map((window) => (
<TaskbarButton
key={window.id}
active={workspace.activeWindowId === window.id && window.mode !== "minimized"}
minimized={window.mode === "minimized"}
icon={<Sparkles size={14} aria-hidden="true" />}
label={window.title}
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
/>
))}
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} </span>
</div>
{!desktop ? <span className="sr-only"></span> : null}
</div>
);
}
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
aria-label={`${minimized ? "恢复" : "切换到"}${label}`}
title={label}
className={cn(
"flex h-8 max-w-56 items-center gap-2 rounded-md px-3 text-xs font-medium transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : minimized ? "bg-slate-200 text-slate-600 hover:bg-white" : "text-slate-700 hover:bg-white/80"
)}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</button>
);
}
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
return (
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
{children}
</div>
);
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
MAX_ANALYSIS_WINDOWS,
closeAnalysisWindow,
createInitialWorkspaceState,
maximizeWorkspaceWindow,
minimizeWorkspaceWindow,
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
updateWorkspaceWindowRect,
type AnalysisDocument
} from "./workspace-model";
const bounds = { width: 1180, height: 760 };
function documentAt(index: number): AnalysisDocument {
return {
id: `analysis-${index}`,
title: `分析 ${index}`,
subtitle: "测试",
generatedAt: "10:40:00",
blocks: []
};
}
describe("workspace window lifecycle", () => {
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
const initial = createInitialWorkspaceState(bounds);
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
expect(opened.map.mode).toBe("minimized");
expect(opened.analyses).toHaveLength(1);
const closed = closeAnalysisWindow(opened, "analysis-1");
expect(closed.analyses).toHaveLength(0);
expect(closed.map.mode).toBe("docked");
expect(closed.activeWindowId).toBe("workspace-map");
});
it("supports minimize, maximize and bounded restore", () => {
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
const minimized = minimizeWorkspaceWindow(opened, "analysis-1");
expect(minimized.analyses[0]?.mode).toBe("minimized");
const restored = restoreWorkspaceWindow(minimized, "analysis-1", bounds);
expect(restored.analyses[0]?.mode).toBe("floating");
const maximized = maximizeWorkspaceWindow(restored, "analysis-1");
expect(maximized.analyses[0]?.mode).toBe("maximized");
const resized = updateWorkspaceWindowRect(
maximized,
"analysis-1",
{ x: -100, y: -80, width: 4000, height: 3000 },
bounds
);
expect(resized.analyses[0]?.rect).toEqual({ x: 12, y: 12, width: 1156, height: 736 });
});
it("caps transient analysis windows without deleting an existing result", () => {
const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce(
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
createInitialWorkspaceState(bounds)
);
const overflow = openAnalysisDocument(state, documentAt(99), bounds);
expect(overflow).toBe(state);
expect(overflow.analyses).toHaveLength(MAX_ANALYSIS_WINDOWS);
});
it("reclamps current and restore rectangles when the workspace shrinks", () => {
const opened = openAnalysisDocument(createInitialWorkspaceState(bounds), documentAt(1), bounds);
const moved = updateWorkspaceWindowRect(
opened,
"analysis-1",
{ x: 500, y: 260, width: 660, height: 480 },
bounds
);
const resized = resizeWorkspace(moved, { width: 800, height: 600 });
const analysis = resized.analyses[0];
expect(analysis?.rect).toEqual({ x: 128, y: 108, width: 660, height: 480 });
expect(analysis?.restoreRect).toEqual(analysis?.rect);
expect(resized.map.restoreRect.x + resized.map.restoreRect.width).toBeLessThanOrEqual(788);
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
});
});
@@ -0,0 +1,360 @@
export const MAX_ANALYSIS_WINDOWS = 6;
export type WorkspaceRect = {
x: number;
y: number;
width: number;
height: number;
};
export type WorkspaceBounds = {
width: number;
height: number;
};
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
export type AnalysisMetric = {
label: string;
value: string;
detail: string;
tone: "neutral" | "info" | "success" | "warning" | "danger";
};
export type AnalysisChartSeries = {
name: string;
values: number[];
color: string;
type?: "line" | "bar";
};
export type AnalysisBlock =
| {
id: string;
kind: "metric-grid";
title: string;
span: 12;
metrics: AnalysisMetric[];
}
| {
id: string;
kind: "chart";
title: string;
description: string;
span: 5 | 7 | 12;
chartType: "line" | "bar";
categories: string[];
unit: string;
series: AnalysisChartSeries[];
}
| {
id: string;
kind: "process-flow";
title: string;
description: string;
span: 5 | 7 | 12;
nodes: Array<{
id: string;
label: string;
detail: string;
status: "complete" | "active" | "pending";
}>;
}
| {
id: string;
kind: "data-table";
title: string;
description: string;
span: 5 | 7 | 12;
columns: Array<{ key: string; label: string; numeric?: boolean }>;
rows: Array<Record<string, string | number>>;
}
| {
id: string;
kind: "narrative";
title: string;
span: 5 | 7 | 12;
paragraphs: string[];
};
export type AnalysisDocument = {
id: string;
title: string;
subtitle: string;
generatedAt: string;
blocks: AnalysisBlock[];
};
export type AnalysisWorkspaceWindow = {
id: string;
kind: "agent-analysis";
title: string;
mode: Exclude<WorkspaceWindowMode, "docked">;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = {
id: "workspace-map";
kind: "map";
title: string;
mode: WorkspaceWindowMode;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
};
export type WorkspaceState = {
map: MapWorkspaceWindow;
analyses: AnalysisWorkspaceWindow[];
activeWindowId: string;
nextZIndex: number;
};
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
const mapRect = getDefaultMapRect(bounds);
return {
map: {
id: "workspace-map",
kind: "map",
title: "供水管网地图",
mode: "docked",
rect: mapRect,
restoreRect: mapRect,
zIndex: 1
},
analyses: [],
activeWindowId: "workspace-map",
nextZIndex: 2
};
}
export function openAnalysisDocument(
state: WorkspaceState,
document: AnalysisDocument,
bounds: WorkspaceBounds
): WorkspaceState {
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) {
return state;
}
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
const window: AnalysisWorkspaceWindow = {
id: document.id,
kind: "agent-analysis",
title: document.title,
mode: "floating",
rect,
restoreRect: rect,
zIndex: state.nextZIndex,
document
};
return {
map: { ...state.map, mode: "minimized" },
analyses: [...state.analyses, window],
activeWindowId: window.id,
nextZIndex: state.nextZIndex + 1
};
}
export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState {
const analyses = state.analyses.filter((window) => window.id !== windowId);
if (analyses.length === state.analyses.length) {
return state;
}
if (analyses.length === 0) {
return {
...state,
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex },
analyses,
activeWindowId: state.map.id,
nextZIndex: state.nextZIndex + 1
};
}
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0];
return {
...state,
analyses,
activeWindowId: nextActive?.id ?? state.map.id
};
}
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return {
...state,
map: { ...state.map, zIndex: state.nextZIndex },
activeWindowId: windowId,
nextZIndex: state.nextZIndex + 1
};
}
if (!state.analyses.some((window) => window.id === windowId)) {
return state;
}
return {
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, zIndex: state.nextZIndex } : window
),
activeWindowId: windowId,
nextZIndex: state.nextZIndex + 1
};
}
export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return { ...state, map: { ...state.map, mode: "minimized" } };
}
return {
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, mode: "minimized" } : window
)
};
}
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "maximized" } },
windowId
);
}
return focusWorkspaceWindow(
{
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, mode: "maximized" } : window
)
},
windowId
);
}
export function restoreWorkspaceWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
if (windowId === state.map.id) {
const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map");
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "floating", rect } },
windowId
);
}
return focusWorkspaceWindow(
{
...state,
analyses: state.analyses.map((window) =>
window.id === windowId
? {
...window,
mode: "floating",
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}
: window
)
},
windowId
);
}
export function updateWorkspaceWindowRect(
state: WorkspaceState,
windowId: string,
rect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceState {
if (windowId === state.map.id) {
const nextRect = clampWorkspaceRect(rect, bounds, "map");
return {
...state,
map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect }
};
}
return {
...state,
analyses: state.analyses.map((window) => {
if (window.id !== windowId) return window;
const nextRect = clampWorkspaceRect(rect, bounds, "analysis");
return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect };
})
};
}
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
return {
...state,
map: {
...state.map,
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
},
analyses: state.analyses.map((window) => ({
...window,
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}))
};
}
export function clampWorkspaceRect(
rect: WorkspaceRect,
bounds: WorkspaceBounds,
kind: "map" | "analysis"
): WorkspaceRect {
const minimumWidth = kind === "map" ? 360 : 640;
const minimumHeight = kind === "map" ? 280 : 420;
const availableWidth = Math.max(280, bounds.width - 24);
const availableHeight = Math.max(240, bounds.height - 24);
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight);
return {
x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)),
y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)),
width,
height
};
}
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));
const cascade = (index % 5) * 28;
return clampWorkspaceRect(
{ x: 24 + cascade, y: 20 + cascade, width, height },
bounds,
"analysis"
);
}
function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect {
return clampWorkspaceRect(
{
x: Math.max(12, bounds.width * 0.36),
y: Math.max(12, bounds.height * 0.18),
width: bounds.width * 0.6,
height: bounds.height * 0.72
},
bounds,
"map"
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
@@ -0,0 +1,195 @@
import {
Copy,
Maximize2,
Minimize2,
Minus,
PanelTopClose,
X
} from "lucide-react";
import { useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
type WorkspaceWindowProps = {
id: string;
title: string;
mode: WorkspaceWindowMode;
rect: WorkspaceRect;
zIndex: number;
active: boolean;
kind: "map" | "analysis";
children: ReactNode;
onFocus: () => void;
onRectChange: (rect: WorkspaceRect) => void;
onMinimize: () => void;
onMaximize: () => void;
onRestore: () => void;
onClose?: () => void;
};
export function WorkspaceWindow({
id,
title,
mode,
rect,
zIndex,
active,
kind,
children,
onFocus,
onRectChange,
onMinimize,
onMaximize,
onRestore,
onClose
}: WorkspaceWindowProps) {
const rectRef = useRef(rect);
rectRef.current = rect;
const floating = mode === "floating";
const style = mode === "docked"
? { inset: 0, zIndex }
: mode === "maximized"
? { inset: 8, zIndex }
: { left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex };
const beginPointerAction = (
event: ReactPointerEvent,
action: "move" | ResizeDirection
) => {
if (!floating || event.button !== 0) return;
if (event.target instanceof Element && event.target.closest("button")) return;
event.preventDefault();
onFocus();
const startX = event.clientX;
const startY = event.clientY;
const startRect = rectRef.current;
const handleMove = (pointerEvent: PointerEvent) => {
const deltaX = pointerEvent.clientX - startX;
const deltaY = pointerEvent.clientY - startY;
onRectChange(resizeRect(startRect, action, deltaX, deltaY));
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp, { once: true });
};
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (!floating || !event.key.startsWith("Arrow")) return;
event.preventDefault();
const step = event.altKey ? 2 : 12;
const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
onRectChange(
event.shiftKey
? { ...rect, width: rect.width + deltaX, height: rect.height + deltaY }
: { ...rect, x: rect.x + deltaX, y: rect.y + deltaY }
);
};
return (
<section
id={id}
aria-label={title}
className={cn(
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900",
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
mode === "docked" ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
active ? "lg:ring-1 lg:ring-blue-500/40" : "lg:ring-1 lg:ring-slate-300/90"
)}
style={style}
onPointerDownCapture={onFocus}
>
<header
tabIndex={0}
className={cn(
"workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex",
active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]",
floating && "cursor-move"
)}
onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize}
onKeyDown={handleTitleKeyDown}
onPointerDown={(event) => beginPointerAction(event, "move")}
>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-md", kind === "map" ? "bg-blue-600 text-white" : "bg-slate-900 text-white")}>
{kind === "map" ? <PanelTopClose size={15} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{title}</p>
</div>
<div className="flex shrink-0 items-center">
<WindowButton label={`最小化${title}`} onClick={onMinimize}><Minus size={16} aria-hidden="true" /></WindowButton>
{mode === "maximized" || mode === "docked" ? (
<WindowButton label={`浮动${title}`} onClick={onRestore}><Minimize2 size={15} aria-hidden="true" /></WindowButton>
) : (
<WindowButton label={`最大化${title}`} onClick={onMaximize}><Maximize2 size={15} aria-hidden="true" /></WindowButton>
)}
{onClose ? <WindowButton label={`关闭并销毁${title}`} danger onClick={onClose}><X size={16} aria-hidden="true" /></WindowButton> : null}
</div>
</header>
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
{floating ? RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
aria-hidden="true"
className={cn("hidden lg:block", resizeHandleClass(direction))}
onPointerDown={(event) => beginPointerAction(event, direction)}
/>
)) : null}
</section>
);
}
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
function WindowButton({ label, danger, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
className={cn(
"grid h-10 w-10 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
danger ? "hover:bg-rose-100 hover:text-rose-700" : "hover:bg-white hover:text-blue-700"
)}
onClick={(event) => { event.stopPropagation(); onClick(); }}
>
{children}
</button>
);
}
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
const west = direction.includes("w");
const east = direction.includes("e");
const north = direction.includes("n");
const south = direction.includes("s");
return {
x: west ? rect.x + deltaX : rect.x,
y: north ? rect.y + deltaY : rect.y,
width: rect.width + (east ? deltaX : west ? -deltaX : 0),
height: rect.height + (south ? deltaY : north ? -deltaY : 0)
};
}
function resizeHandleClass(direction: ResizeDirection) {
const shared = "absolute z-20 block touch-none";
const classes: Record<ResizeDirection, string> = {
n: "left-2 right-2 top-0 h-2 cursor-n-resize",
ne: "right-0 top-0 h-3 w-3 cursor-ne-resize",
e: "bottom-2 right-0 top-2 w-2 cursor-e-resize",
se: "bottom-0 right-0 h-3 w-3 cursor-se-resize",
s: "bottom-0 left-2 right-2 h-2 cursor-s-resize",
sw: "bottom-0 left-0 h-3 w-3 cursor-sw-resize",
w: "bottom-2 left-0 top-2 w-2 cursor-w-resize",
nw: "left-0 top-0 h-3 w-3 cursor-nw-resize"
};
return `${shared} ${classes[direction]}`;
}