Files
zk-data-agent/frontend/app/app/doc/tool-position-map.tsx
T
2026-05-18 19:28:02 +08:00

567 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
type CSSProperties,
type PointerEvent,
useMemo,
useRef,
useState,
} from "react";
type ToolPosition = {
id: string;
name: string;
x: number;
y: number;
z: number;
type: string;
color: string;
summary: string;
fit: string;
limit: string;
};
type Point3D = {
x: number;
y: number;
z: number;
};
type ProjectionMode = "xy" | "xz" | "yz" | "free";
const tools: ToolPosition[] = [
{
id: "manual",
name: "纯人工",
x: 24,
y: 82,
z: 22,
type: "人主导",
color: "#64748b",
summary: "最懂业务,但查、写、跑、改、整理都靠人推进。",
fit: "复杂边界判断、探索性分析、最终质量把关。",
limit: "执行成本高,流程复用依赖个人习惯和文档质量。",
},
{
id: "web-ai",
name: "网页版 AI",
x: 54,
y: 24,
z: 18,
type: "通用 AI",
color: "#38bdf8",
summary: "适合问答、总结、生成草稿,启动快。",
fit: "通用解释、文本生成、局部判断辅助。",
limit: "不天然接入内部日志、标签规则、标准产物和团队流程。",
},
{
id: "copilot",
name: "IDE Copilot",
x: 62,
y: 34,
z: 28,
type: "编码辅助",
color: "#60a5fa",
summary: "在编辑器里提升局部编码效率。",
fit: "补全、局部函数、单文件修改。",
limit: "更偏代码片段,不负责跨系统数据流和业务产物链。",
},
{
id: "cursor",
name: "Cursor",
x: 76,
y: 46,
z: 38,
type: "IDE Agent",
color: "#818cf8",
summary: "围绕代码仓库做理解、修改和多文件协作。",
fit: "工程代码迭代、仓库内上下文开发。",
limit: "团队业务流程、线上数据、标准导出和共享 Skill 需要另行组织。",
},
{
id: "code-agent",
name: "Claude Code / Codex",
x: 84,
y: 58,
z: 45,
type: "工程 Agent",
color: "#a78bfa",
summary: "能读代码、调工具、执行多步工程任务。",
fit: "本地工程闭环、复杂代码修改、命令执行。",
limit: "强在个人工作区;团队级业务流程沉淀和 Web 多用户协作不是默认形态。",
},
{
id: "notebook",
name: "个人自动化脚本",
x: 66,
y: 74,
z: 48,
type: "个人自动化",
color: "#f59e0b",
summary: "贴近业务数据,能把部分流程脚本化。",
fit: "数据清洗、临时统计、可重复实验。",
limit: "入口、权限、review、产物管理和团队复用通常需要人额外维护。",
},
{
id: "prompt-doc",
name: "经验文档",
x: 38,
y: 62,
z: 60,
type: "经验沉淀",
color: "#10b981",
summary: "能记录规则和经验,便于传播。",
fit: "标签边界、流程说明、操作规范。",
limit: "本身不执行任务,仍需要人把文档转成操作。",
},
{
id: "zk",
name: "ZK Data Agent",
x: 80,
y: 88,
z: 86,
type: "业务 Agent 工作台",
color: "#2563eb",
summary:
"把 Agent Loop、工具执行、业务知识、review 和产物管理组织成团队流程。",
fit: "数据生成、线上挖掘、标签判断、外部系统 Skill 接入。",
limit: "不替代人的业务判断;重点是把判断节点放进可复用流程。",
},
];
const presets: Array<{
mode: ProjectionMode;
name: string;
rotateX: number;
rotateY: number;
}> = [
{ mode: "xy", name: "研发 × 业务", rotateX: 0, rotateY: 0 },
{ mode: "xz", name: "研发 × 复用", rotateX: -90, rotateY: 0 },
{ mode: "yz", name: "业务 × 复用", rotateX: 0, rotateY: -90 },
];
const dimensionCards = [
{
title: "研发执行效率",
text: "读代码、调工具、跑脚本、生成产物。",
},
{
title: "业务嵌入深度",
text: "接住日志、标签体系、数据格式和团队规则。",
},
{
title: "流程可复用度",
text: "把一次解决方案沉淀成团队能继续调用的能力。",
},
];
const frictionCases = [
{ text: "标签边界依赖经验", weight: 5 },
{ text: "格式手工补齐", weight: 4 },
{ text: "规则散在文档", weight: 4 },
{ text: "线上数据难取", weight: 4 },
{ text: "经验靠口口相传", weight: 3 },
{ text: "脚本只在个人机器", weight: 3 },
{ text: "Review 断在中间", weight: 3 },
{ text: "样例难复用", weight: 2 },
{ text: "产物路径分散", weight: 2 },
{ text: "路径和权限反复确认", weight: 2 },
];
const cubeSize = 360;
const cubeCenter = cubeSize / 2;
const stageCenter = 280;
const perspective = 950;
const axisOrigin = { x: -cubeCenter, y: cubeCenter, z: -cubeCenter };
const axisDefinitions = [
{
id: "x",
name: "研发执行效率",
color: "#2563eb",
start: axisOrigin,
end: { x: cubeCenter, y: cubeCenter, z: -cubeCenter },
},
{
id: "y",
name: "业务嵌入深度",
color: "#0f766e",
start: axisOrigin,
end: { x: -cubeCenter, y: -cubeCenter, z: -cubeCenter },
},
{
id: "z",
name: "流程可复用度",
color: "#9333ea",
start: axisOrigin,
end: { x: -cubeCenter, y: cubeCenter, z: cubeCenter },
},
];
const visibleAxesByMode: Record<ProjectionMode, string[]> = {
xy: ["x", "y"],
xz: ["x", "z"],
yz: ["z", "y"],
free: ["x", "y", "z"],
};
function score(tool: ToolPosition) {
return Math.round((tool.x + tool.y + tool.z) / 3);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getPointCoordinates(tool: ToolPosition) {
return {
x: (tool.x / 100) * cubeSize - cubeCenter,
y: cubeCenter - (tool.y / 100) * cubeSize,
z: (tool.z / 100) * cubeSize - cubeCenter,
};
}
function projectPoint(
point: Point3D,
rotateX: number,
rotateY: number,
mode: ProjectionMode,
) {
if (mode === "xy") {
return {
x: stageCenter + point.x,
y: stageCenter + point.y,
z: point.z,
scale: 1,
};
}
if (mode === "xz") {
return {
x: stageCenter + point.x,
y: stageCenter - point.z,
z: -point.y,
scale: 1,
};
}
if (mode === "yz") {
return {
x: stageCenter + point.z,
y: stageCenter + point.y,
z: point.x,
scale: 1,
};
}
const angleX = (rotateX * Math.PI) / 180;
const angleY = (rotateY * Math.PI) / 180;
const cosX = Math.cos(angleX);
const sinX = Math.sin(angleX);
const cosY = Math.cos(angleY);
const sinY = Math.sin(angleY);
const afterY = {
x: point.x * cosY + point.z * sinY,
y: point.y,
z: -point.x * sinY + point.z * cosY,
};
const rotated = {
x: afterY.x,
y: afterY.y * cosX - afterY.z * sinX,
z: afterY.y * sinX + afterY.z * cosX,
};
const scale = perspective / (perspective - rotated.z);
return {
x: stageCenter + rotated.x * scale,
y: stageCenter + rotated.y * scale,
z: rotated.z,
scale,
};
}
function toolAnchorStyle(
tool: ToolPosition,
projected: ReturnType<typeof projectPoint>,
) {
const scale = Math.min(1.08, Math.max(0.88, projected.scale));
return {
"--tool-x": `${projected.x}px`,
"--tool-y": `${projected.y}px`,
"--label-scale": `${scale}`,
"--point-color": tool.color,
zIndex: Math.round(500 + projected.z),
} as CSSProperties;
}
function getProjectedAxes(
rotateX: number,
rotateY: number,
mode: ProjectionMode,
) {
const visibleAxes = new Set(visibleAxesByMode[mode]);
return axisDefinitions
.filter((axis) => visibleAxes.has(axis.id))
.map((axis) => {
const start = projectPoint(axis.start, rotateX, rotateY, mode);
const end = projectPoint(axis.end, rotateX, rotateY, mode);
const vectorX = end.x - start.x;
const vectorY = end.y - start.y;
const length = Math.max(1, Math.hypot(vectorX, vectorY));
const labelX = clamp(end.x + (vectorX / length) * 34, 44, 516);
const labelY = clamp(end.y + (vectorY / length) * 34, 32, 528);
return {
...axis,
start,
end,
labelStyle: {
"--axis-label-x": `${labelX}px`,
"--axis-label-y": `${labelY}px`,
"--axis-color": axis.color,
} as CSSProperties,
};
});
}
export function ToolPositionMap() {
const [projectionMode, setProjectionMode] = useState<ProjectionMode>("xy");
const [rotateX, setRotateX] = useState(0);
const [rotateY, setRotateY] = useState(0);
const [selectedId, setSelectedId] = useState("zk");
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<{
x: number;
y: number;
rotateX: number;
rotateY: number;
} | null>(null);
const selected = useMemo(
() => tools.find((tool) => tool.id === selectedId) ?? tools[0],
[selectedId],
);
const projectedTools = useMemo(
() =>
tools
.map((tool) => ({
tool,
projected: projectPoint(
getPointCoordinates(tool),
rotateX,
rotateY,
projectionMode,
),
}))
.sort((a, b) => a.projected.z - b.projected.z),
[rotateX, rotateY, projectionMode],
);
const projectedAxes = useMemo(
() => getProjectedAxes(rotateX, rotateY, projectionMode),
[rotateX, rotateY, projectionMode],
);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
setProjectionMode("free");
dragRef.current = {
x: event.clientX,
y: event.clientY,
rotateX,
rotateY,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const deltaX = event.clientX - dragRef.current.x;
const deltaY = event.clientY - dragRef.current.y;
setRotateX(dragRef.current.rotateX - deltaY * 0.45);
setRotateY(dragRef.current.rotateY + deltaX * 0.45);
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
dragRef.current = null;
setIsDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<section className="project-doc-section project-doc-position" id="position">
<div className="position-framing">
<div className="position-framing-copy">
<span></span>
<h3 className="position-formula">
<span></span>
<b></b>
<span></span>
<b>×</b>
<span></span>
<b>×</b>
<span></span>
</h3>
<p>
AI
</p>
</div>
<div className="position-dimension-grid">
{dimensionCards.map((card) => (
<div key={card.title}>
<strong>{card.title}</strong>
<p>{card.text}</p>
</div>
))}
</div>
<div className="position-friction-cloud">
<strong></strong>
{frictionCases.map((item) => (
<span className={`is-weight-${item.weight}`} key={item.text}>
{item.text}
</span>
))}
<em></em>
</div>
</div>
<div className="position-map-layout">
<div className="position-stage-card">
<div className="position-controls">
<div>
<span>
{projectionMode === "free"
? "拖动坐标系自由旋转"
: "二维投影视图"}
</span>
<strong>
{projectionMode === "free"
? `X ${Math.round(rotateX)} / Y ${Math.round(rotateY)}`
: presets.find((preset) => preset.mode === projectionMode)
?.name}
</strong>
</div>
<div className="position-preset-row">
{presets.map((preset) => (
<button
className={
projectionMode === preset.mode ? "is-selected" : undefined
}
key={preset.name}
onClick={() => {
setProjectionMode(preset.mode);
setRotateX(preset.rotateX);
setRotateY(preset.rotateY);
}}
type="button"
>
{preset.name}
</button>
))}
</div>
</div>
<div
className={
isDragging ? "position-stage is-dragging" : "position-stage"
}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
>
<div className="position-scene">
<div className="position-space-grid" />
<svg
aria-hidden="true"
className="position-axis-overlay"
viewBox="0 0 560 560"
>
{projectedAxes.map((axis) => (
<line
key={axis.id}
stroke={axis.color}
x1={axis.start.x}
x2={axis.end.x}
y1={axis.start.y}
y2={axis.end.y}
/>
))}
</svg>
<div className="position-axis-label-layer">
{projectedAxes.map((axis) => (
<span
className="position-axis-label"
key={axis.id}
style={axis.labelStyle}
>
{axis.name}
</span>
))}
</div>
<div className="position-tool-layer">
{projectedTools.map(({ tool, projected }) => (
<button
className={[
"position-tool-anchor",
tool.id === selectedId ? "is-selected" : "",
tool.id === hoveredId ? "is-hovered" : "",
]
.filter(Boolean)
.join(" ")}
key={tool.id}
onClick={() => setSelectedId(tool.id)}
onBlur={() => setHoveredId(null)}
onFocus={() => setHoveredId(tool.id)}
onMouseEnter={() => setHoveredId(tool.id)}
onMouseLeave={() => setHoveredId(null)}
onPointerDown={(event) => event.stopPropagation()}
style={toolAnchorStyle(tool, projected)}
type="button"
>
<span className="position-ball" />
<span className="position-tool-label">{tool.name}</span>
</button>
))}
</div>
</div>
</div>
</div>
<aside className="position-detail-card">
<div className="position-score">
<span>{selected.type}</span>
<strong>{score(selected)}</strong>
</div>
<h3>{selected.name}</h3>
<p>{selected.summary}</p>
<div className="position-meter-list">
<div>
<span></span>
<i style={{ width: `${selected.x}%` }} />
</div>
<div>
<span></span>
<i style={{ width: `${selected.y}%` }} />
</div>
<div>
<span></span>
<i style={{ width: `${selected.z}%` }} />
</div>
</div>
<dl>
<div>
<dt></dt>
<dd>{selected.fit}</dd>
</div>
<div>
<dt></dt>
<dd>{selected.limit}</dd>
</div>
</dl>
</aside>
</div>
</section>
);
}