567 lines
14 KiB
TypeScript
567 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|