mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-18 21:04:02 +08:00
feat: add topology view mode with router icons and link-style edges
Canvas-global nodeViewMode (bubble/card/topology) can be set at create or via prefs; topology draws NE routers with straight blue center links and exposes router icon to agents. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
200c8bd450
commit
74f0d8e78a
15 changed files with 322 additions and 103 deletions
|
|
@ -64,9 +64,10 @@ export function createChatHandler(ctx: ToolContext) {
|
||||||
8. 固定坐标用 set_pinned 或 create_node 带 pos;不确定时 mindmap_get_schema。
|
8. 固定坐标用 set_pinned 或 create_node 带 pos;不确定时 mindmap_get_schema。
|
||||||
9. 节点属性:标题用 update_text;描述/多标签/多链接/图片URL/时间用 update_node_meta(patch);边用 update_edge_meta 或 set_edge_label。
|
9. 节点属性:标题用 update_text;描述/多标签/多链接/图片URL/时间用 update_node_meta(patch);边用 update_edge_meta 或 set_edge_label。
|
||||||
10. 节点类型 stylePreset:default|card|title|decision|risk|note|muted(title 仅画布锚点,勿新建;card=新闻快照卡片,配图用 imageUrl、摘要用 note、来源用 links)。
|
10. 节点类型 stylePreset:default|card|title|decision|risk|note|muted(title 仅画布锚点,勿新建;card=新闻快照卡片,配图用 imageUrl、摘要用 note、来源用 links)。
|
||||||
11. 同级排序用 reorder:优先 {"type":"reorder","nodeId":"...","order":10};也可用 afterSiblingId(null=置顶)。
|
11. 节点图标 icon(create_node / update_node_meta.patch):server|database|cloud|person|folder|doc|link|warning|check|star|gear|globe|router。拓扑排障图:建画布时可用 mindmap_create_canvas({ title, nodeViewMode:"topology" }),节点 icon:"router";nodeViewMode 是画布全局偏好,也可事后 set_prefs 修改。
|
||||||
12. 折叠 set_collapsed 会隐藏该节点主父链下的全部子孙并重新布局;展开后恢复。
|
12. 同级排序用 reorder:优先 {"type":"reorder","nodeId":"...","order":10};也可用 afterSiblingId(null=置顶)。
|
||||||
13. 问图:用 mindmap_query(neighbors|path|stats|orphans|missing_meta);答完可用 mindmap_highlight 高亮路径;细节用 mindmap_get_node / mindmap_get_edge。禁止编造图中没有的事实。
|
13. 折叠 set_collapsed 会隐藏该节点主父链下的全部子孙并重新布局;展开后恢复。
|
||||||
|
14. 问图:用 mindmap_query(neighbors|path|stats|orphans|missing_meta);答完可用 mindmap_highlight 高亮路径;细节用 mindmap_get_node / mindmap_get_edge。禁止编造图中没有的事实。
|
||||||
|
|
||||||
当前空间上下文(摘要):
|
当前空间上下文(摘要):
|
||||||
${JSON.stringify(spatial, null, 0).slice(0, 6000)}
|
${JSON.stringify(spatial, null, 0).slice(0, 6000)}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||||
import type { Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
import { WebSocketServer, type WebSocket } from "ws";
|
import { WebSocketServer, type WebSocket } from "ws";
|
||||||
import { SqliteGraphStore } from "@mind-map/graph-store";
|
import { SqliteGraphStore } from "@mind-map/graph-store";
|
||||||
import { coerceOpInput, OpSchema, type Op } from "@mind-map/shared";
|
import { coerceOpInput, OpSchema, NodeViewModeSchema, type Op } from "@mind-map/shared";
|
||||||
import { listSkins } from "@mind-map/dream-skin";
|
import { listSkins } from "@mind-map/dream-skin";
|
||||||
import { GraphError } from "@mind-map/graph-core";
|
import { GraphError } from "@mind-map/graph-core";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -163,8 +163,16 @@ export function startMindMapServer(
|
||||||
const body = (await c.req.json().catch(() => ({}))) as {
|
const body = (await c.req.json().catch(() => ({}))) as {
|
||||||
title?: string;
|
title?: string;
|
||||||
rootText?: string;
|
rootText?: string;
|
||||||
|
nodeViewMode?: string;
|
||||||
|
prefs?: { nodeViewMode?: string };
|
||||||
};
|
};
|
||||||
const g = store.createCanvas(body.title, body.rootText);
|
const viewRaw = body.nodeViewMode ?? body.prefs?.nodeViewMode;
|
||||||
|
const parsed = NodeViewModeSchema.safeParse(viewRaw);
|
||||||
|
const g = store.createCanvas(
|
||||||
|
body.title,
|
||||||
|
body.rootText,
|
||||||
|
parsed.success ? { nodeViewMode: parsed.data } : undefined,
|
||||||
|
);
|
||||||
broadcast({ type: "canvas/created", canvasId: g.canvas.id });
|
broadcast({ type: "canvas/created", canvasId: g.canvas.id });
|
||||||
return c.json(clientCanvasPayload(store, g.canvas.id));
|
return c.json(clientCanvasPayload(store, g.canvas.id));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
nodeFunction,
|
nodeFunction,
|
||||||
OP_SCHEMA_DOC,
|
OP_SCHEMA_DOC,
|
||||||
OP_TYPES,
|
OP_TYPES,
|
||||||
|
NodeIconSchema,
|
||||||
|
NodeViewModeSchema,
|
||||||
} from "@mind-map/shared";
|
} from "@mind-map/shared";
|
||||||
import { hierarchyFingerprint, layout } from "@mind-map/layout-engine";
|
import { hierarchyFingerprint, layout } from "@mind-map/layout-engine";
|
||||||
|
|
||||||
|
|
@ -481,8 +483,10 @@ function estimateOverlaps(
|
||||||
nodeViewMode: string,
|
nodeViewMode: string,
|
||||||
anchorId: string | null,
|
anchorId: string | null,
|
||||||
) {
|
) {
|
||||||
const w = nodeViewMode === "card" ? 180 : 120;
|
const w =
|
||||||
const h = nodeViewMode === "card" ? 72 : 36;
|
nodeViewMode === "card" ? 180 : nodeViewMode === "topology" ? 88 : 120;
|
||||||
|
const h =
|
||||||
|
nodeViewMode === "card" ? 72 : nodeViewMode === "topology" ? 64 : 36;
|
||||||
const ids = nodes
|
const ids = nodes
|
||||||
.filter((n) => !n.deletedAt && n.id !== anchorId && positions[n.id])
|
.filter((n) => !n.deletedAt && n.id !== anchorId && positions[n.id])
|
||||||
.map((n) => n.id);
|
.map((n) => n.id);
|
||||||
|
|
@ -770,11 +774,30 @@ export async function runTool(
|
||||||
rawArgs.rootText != null && String(rawArgs.rootText).trim()
|
rawArgs.rootText != null && String(rawArgs.rootText).trim()
|
||||||
? String(rawArgs.rootText)
|
? String(rawArgs.rootText)
|
||||||
: undefined;
|
: undefined;
|
||||||
const g = ctx.store.createCanvas(title, rootText);
|
const prefsArg =
|
||||||
|
rawArgs.prefs && typeof rawArgs.prefs === "object"
|
||||||
|
? (rawArgs.prefs as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const viewParsed = NodeViewModeSchema.safeParse(
|
||||||
|
rawArgs.nodeViewMode ?? prefsArg?.nodeViewMode,
|
||||||
|
);
|
||||||
|
const g = ctx.store.createCanvas(
|
||||||
|
title,
|
||||||
|
rootText,
|
||||||
|
viewParsed.success ? { nodeViewMode: viewParsed.data } : undefined,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
canvas: { id: g.canvas.id, title: g.canvas.title, rev: g.canvas.rev },
|
canvas: {
|
||||||
hint: "Canvas is empty visually. Create top-level nodes with {type:'create_node', text:'...'} (omit parentId). Use parentId only when nesting under an existing visible node.",
|
id: g.canvas.id,
|
||||||
|
title: g.canvas.title,
|
||||||
|
rev: g.canvas.rev,
|
||||||
|
prefs: { nodeViewMode: g.canvas.prefs.nodeViewMode },
|
||||||
|
},
|
||||||
|
hint:
|
||||||
|
g.canvas.prefs.nodeViewMode === "topology"
|
||||||
|
? "Topology canvas: create NE nodes with icon:'router'. Omit parentId for top-level. Prefer straight hierarchy/relation links."
|
||||||
|
: "Canvas is empty visually. Create top-level nodes with {type:'create_node', text:'...'} (omit parentId). Use parentId only when nesting under an existing visible node.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1506,20 +1529,12 @@ export async function runTool(
|
||||||
"edge_note",
|
"edge_note",
|
||||||
"all",
|
"all",
|
||||||
],
|
],
|
||||||
nodeIcons: [
|
nodeIcons: NodeIconSchema.options,
|
||||||
"server",
|
nodeViewModes: NodeViewModeSchema.options,
|
||||||
"database",
|
createCanvas: {
|
||||||
"cloud",
|
nodeViewMode:
|
||||||
"person",
|
"Optional on mindmap_create_canvas (canvas-global). Also changeable later via set_prefs.",
|
||||||
"folder",
|
},
|
||||||
"doc",
|
|
||||||
"link",
|
|
||||||
"warning",
|
|
||||||
"check",
|
|
||||||
"star",
|
|
||||||
"gear",
|
|
||||||
"globe",
|
|
||||||
],
|
|
||||||
edgeLineStyles: ["solid", "dashed", "dotted"],
|
edgeLineStyles: ["solid", "dashed", "dotted"],
|
||||||
edgeDirections: ["forward", "both", "none"],
|
edgeDirections: ["forward", "both", "none"],
|
||||||
getSubgraph: {
|
getSubgraph: {
|
||||||
|
|
|
||||||
BIN
apps/web/public/topo/ne-router.png
Normal file
BIN
apps/web/public/topo/ne-router.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
|
|
@ -238,14 +238,23 @@ export function App() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
void setPrefs({
|
const cur = canvas?.prefs.nodeViewMode ?? "card";
|
||||||
nodeViewMode:
|
const next =
|
||||||
canvas?.prefs.nodeViewMode !== "bubble" ? "bubble" : "card",
|
cur === "bubble"
|
||||||
})
|
? "card"
|
||||||
}
|
: cur === "card"
|
||||||
|
? "topology"
|
||||||
|
: "bubble";
|
||||||
|
void setPrefs({ nodeViewMode: next });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
模式: {canvas?.prefs.nodeViewMode !== "bubble" ? "卡片" : "气泡"}
|
模式:{" "}
|
||||||
|
{canvas?.prefs.nodeViewMode === "bubble"
|
||||||
|
? "气泡"
|
||||||
|
: canvas?.prefs.nodeViewMode === "topology"
|
||||||
|
? "拓扑"
|
||||||
|
: "卡片"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
drawNodeShape,
|
drawNodeShape,
|
||||||
nodeBadges,
|
nodeBadges,
|
||||||
nodeBoxSize,
|
nodeBoxSize,
|
||||||
|
ROUTER_ICON_URL,
|
||||||
|
TOPO_ICON_PX,
|
||||||
wrapTextLines,
|
wrapTextLines,
|
||||||
} from "./node-geometry";
|
} from "./node-geometry";
|
||||||
import { drawPropSheet, NodePropOverlay } from "./node-props";
|
import { drawPropSheet, NodePropOverlay } from "./node-props";
|
||||||
|
|
@ -179,13 +181,26 @@ export function TopologyCanvas() {
|
||||||
return () => mq.removeEventListener?.("change", onScheme);
|
return () => mq.removeEventListener?.("change", onScheme);
|
||||||
}, []);
|
}, []);
|
||||||
const dens = canvas?.prefs.density === "compact";
|
const dens = canvas?.prefs.density === "compact";
|
||||||
const cardView = canvas?.prefs.nodeViewMode !== "bubble";
|
const viewMode = canvas?.prefs.nodeViewMode ?? "card";
|
||||||
|
const cardView = viewMode === "card";
|
||||||
|
const topoView = viewMode === "topology";
|
||||||
const boxOf = useCallback(
|
const boxOf = useCallback(
|
||||||
(n: (typeof nodes)[number]) => nodeBoxSize(n, dens, { asCard: cardView }),
|
(n: (typeof nodes)[number]) =>
|
||||||
[dens, cardView],
|
nodeBoxSize(n, dens, { asCard: cardView, asTopology: topoView }),
|
||||||
|
[dens, cardView, topoView],
|
||||||
);
|
);
|
||||||
const nw = dens ? 160 : NODE_W;
|
const nw = dens ? 160 : NODE_W;
|
||||||
|
|
||||||
|
const withRouterIcon = useCallback(
|
||||||
|
<T extends { type: string; icon?: string | null }>(op: T): T => {
|
||||||
|
if (!topoView) return op;
|
||||||
|
if (op.type !== "create_node") return op;
|
||||||
|
if (op.icon != null) return op;
|
||||||
|
return { ...op, icon: "router" };
|
||||||
|
},
|
||||||
|
[topoView],
|
||||||
|
);
|
||||||
|
|
||||||
const branchColoring = canvas?.prefs.branchColoring !== false;
|
const branchColoring = canvas?.prefs.branchColoring !== false;
|
||||||
const branchColor = useMemo(() => {
|
const branchColor = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
|
|
@ -372,16 +387,38 @@ export function TopologyCanvas() {
|
||||||
const toN = nodes.find((n) => n.id === e.to);
|
const toN = nodes.find((n) => n.id === e.to);
|
||||||
const aw = fromN ? boxOf(fromN).w : nw;
|
const aw = fromN ? boxOf(fromN).w : nw;
|
||||||
const bw = toN ? boxOf(toN).w : nw;
|
const bw = toN ? boxOf(toN).w : nw;
|
||||||
|
const ah = fromN ? boxOf(fromN).h : 40;
|
||||||
|
const bh = toN ? boxOf(toN).h : 40;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
const x1 = a.x + (b.x >= a.x ? aw / 2 : -aw / 2);
|
let x1: number;
|
||||||
const y1 = a.y;
|
let y1: number;
|
||||||
const x2 = b.x + (b.x >= a.x ? -bw / 2 : bw / 2);
|
let x2: number;
|
||||||
const y2 = b.y;
|
let y2: number;
|
||||||
const cx = (x1 + x2) / 2;
|
let cx: number;
|
||||||
ctx.moveTo(x1, y1);
|
if (topoView) {
|
||||||
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
|
// Straight links into router icon centers (edges draw under nodes)
|
||||||
|
const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable;
|
||||||
|
const pad = dens ? 4 : 6;
|
||||||
|
x1 = a.x;
|
||||||
|
y1 = a.y - ah / 2 + pad + icon / 2;
|
||||||
|
x2 = b.x;
|
||||||
|
y2 = b.y - bh / 2 + pad + icon / 2;
|
||||||
|
cx = (x1 + x2) / 2;
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
} else {
|
||||||
|
x1 = a.x + (b.x >= a.x ? aw / 2 : -aw / 2);
|
||||||
|
y1 = a.y;
|
||||||
|
x2 = b.x + (b.x >= a.x ? -bw / 2 : bw / 2);
|
||||||
|
y2 = b.y;
|
||||||
|
cx = (x1 + x2) / 2;
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
|
||||||
|
}
|
||||||
const weight = Math.max(1, Math.min(5, e.weight ?? 1));
|
const weight = Math.max(1, Math.min(5, e.weight ?? 1));
|
||||||
const style = e.lineStyle ?? (e.kind === "relation" ? "dashed" : "solid");
|
const style = topoView
|
||||||
|
? (e.lineStyle ?? "solid")
|
||||||
|
: (e.lineStyle ?? (e.kind === "relation" ? "dashed" : "solid"));
|
||||||
if (style === "dotted") {
|
if (style === "dotted") {
|
||||||
ctx.setLineDash([2 / viewport.zoom, 4 / viewport.zoom]);
|
ctx.setLineDash([2 / viewport.zoom, 4 / viewport.zoom]);
|
||||||
} else if (style === "dashed") {
|
} else if (style === "dashed") {
|
||||||
|
|
@ -392,30 +429,35 @@ export function TopologyCanvas() {
|
||||||
const isEdgeFlash = highlightEdgeIds.includes(e.id);
|
const isEdgeFlash = highlightEdgeIds.includes(e.id);
|
||||||
const flashEdgeColor = highlightStyle?.edgeColor ?? theme.brand;
|
const flashEdgeColor = highlightStyle?.edgeColor ?? theme.brand;
|
||||||
const flashEdgeWidth = highlightStyle?.edgeWidth ?? 3.6;
|
const flashEdgeWidth = highlightStyle?.edgeWidth ?? 3.6;
|
||||||
ctx.strokeStyle = isEdgeFlash
|
// Topology: uniform link blue (ignore branch palette)
|
||||||
|
const topoLinkColor = "#2563eb";
|
||||||
|
const edgeColor = isEdgeFlash
|
||||||
? flashEdgeColor
|
? flashEdgeColor
|
||||||
: e.kind === "relation"
|
: topoView
|
||||||
? theme.labelSecondary
|
? topoLinkColor
|
||||||
: branchColor.get(e.to) ?? theme.brand;
|
: e.kind === "relation"
|
||||||
|
? theme.labelSecondary
|
||||||
|
: branchColor.get(e.to) ?? theme.brand;
|
||||||
|
ctx.strokeStyle = edgeColor;
|
||||||
ctx.lineWidth =
|
ctx.lineWidth =
|
||||||
(isEdgeFlash
|
(isEdgeFlash
|
||||||
? flashEdgeWidth
|
? flashEdgeWidth
|
||||||
: 1.2 + (weight - 1) * 0.7) / viewport.zoom;
|
: topoView
|
||||||
|
? 1.8 + (weight - 1) * 0.5
|
||||||
|
: 1.2 + (weight - 1) * 0.7) / viewport.zoom;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
// Arrowheads: direction forward|both|none (relation default forward)
|
// Arrowheads: direction forward|both|none (relation default forward)
|
||||||
const dir =
|
const dir =
|
||||||
e.direction ?? (e.kind === "relation" ? "forward" : "none");
|
e.direction ??
|
||||||
|
(topoView ? "none" : e.kind === "relation" ? "forward" : "none");
|
||||||
if (dir === "forward" || dir === "both") {
|
if (dir === "forward" || dir === "both") {
|
||||||
const arrowAng = Math.atan2(0, x2 - cx || (x2 >= a.x ? 1 : -1));
|
const arrowAng = topoView
|
||||||
|
? Math.atan2(y2 - y1, x2 - x1)
|
||||||
|
: Math.atan2(0, x2 - cx || (x2 >= a.x ? 1 : -1));
|
||||||
const size = 8 / viewport.zoom;
|
const size = 8 / viewport.zoom;
|
||||||
const fill = isEdgeFlash
|
|
||||||
? flashEdgeColor
|
|
||||||
: e.kind === "relation"
|
|
||||||
? theme.labelSecondary
|
|
||||||
: branchColor.get(e.to) ?? theme.brand;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.fillStyle = fill;
|
ctx.fillStyle = edgeColor;
|
||||||
ctx.moveTo(x2, y2);
|
ctx.moveTo(x2, y2);
|
||||||
ctx.lineTo(
|
ctx.lineTo(
|
||||||
x2 - size * Math.cos(arrowAng - 0.45),
|
x2 - size * Math.cos(arrowAng - 0.45),
|
||||||
|
|
@ -429,15 +471,12 @@ export function TopologyCanvas() {
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
if (dir === "both") {
|
if (dir === "both") {
|
||||||
const arrowAng = Math.atan2(0, x1 - cx || (a.x >= b.x ? 1 : -1));
|
const arrowAng = topoView
|
||||||
|
? Math.atan2(y1 - y2, x1 - x2)
|
||||||
|
: Math.atan2(0, x1 - cx || (a.x >= b.x ? 1 : -1));
|
||||||
const size = 8 / viewport.zoom;
|
const size = 8 / viewport.zoom;
|
||||||
const fill = isEdgeFlash
|
|
||||||
? flashEdgeColor
|
|
||||||
: e.kind === "relation"
|
|
||||||
? theme.labelSecondary
|
|
||||||
: branchColor.get(e.to) ?? theme.brand;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.fillStyle = fill;
|
ctx.fillStyle = edgeColor;
|
||||||
ctx.moveTo(x1, y1);
|
ctx.moveTo(x1, y1);
|
||||||
ctx.lineTo(
|
ctx.lineTo(
|
||||||
x1 - size * Math.cos(arrowAng - 0.45),
|
x1 - size * Math.cos(arrowAng - 0.45),
|
||||||
|
|
@ -460,10 +499,19 @@ export function TopologyCanvas() {
|
||||||
if (linkPreview) {
|
if (linkPreview) {
|
||||||
const a = posOf(linkPreview.fromId);
|
const a = posOf(linkPreview.fromId);
|
||||||
if (a) {
|
if (a) {
|
||||||
|
const fromN = nodes.find((n) => n.id === linkPreview.fromId);
|
||||||
|
const ah = fromN ? boxOf(fromN).h : 40;
|
||||||
|
let sx = a.x;
|
||||||
|
let sy = a.y;
|
||||||
|
if (topoView) {
|
||||||
|
const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable;
|
||||||
|
const pad = dens ? 4 : 6;
|
||||||
|
sy = a.y - ah / 2 + pad + icon / 2;
|
||||||
|
}
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.setLineDash([4 / viewport.zoom, 4 / viewport.zoom]);
|
ctx.setLineDash([4 / viewport.zoom, 4 / viewport.zoom]);
|
||||||
ctx.strokeStyle = theme.brand;
|
ctx.strokeStyle = topoView ? "#2563eb" : theme.brand;
|
||||||
ctx.moveTo(a.x, a.y);
|
ctx.moveTo(sx, sy);
|
||||||
ctx.lineTo(linkPreview.x, linkPreview.y);
|
ctx.lineTo(linkPreview.x, linkPreview.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
@ -489,6 +537,71 @@ export function TopologyCanvas() {
|
||||||
const isForbidden = dropForbidden && isDrop;
|
const isForbidden = dropForbidden && isDrop;
|
||||||
|
|
||||||
if (inGhost) ctx.globalAlpha = 0.7;
|
if (inGhost) ctx.globalAlpha = 0.7;
|
||||||
|
|
||||||
|
if (topoView) {
|
||||||
|
const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable;
|
||||||
|
const pad = dens ? 4 : 6;
|
||||||
|
const iconX = x + (boxW - icon) / 2;
|
||||||
|
const iconY = y + pad;
|
||||||
|
if (isFlash && highlightStyle?.nodeGlow !== false) {
|
||||||
|
ctx.shadowColor = flashNodeColor;
|
||||||
|
ctx.shadowBlur = 14 / viewport.zoom;
|
||||||
|
} else if (isSel || isHover) {
|
||||||
|
ctx.shadowColor = fill;
|
||||||
|
ctx.shadowBlur = 10 / viewport.zoom;
|
||||||
|
}
|
||||||
|
const img = getCachedImage(ROUTER_ICON_URL, () =>
|
||||||
|
setImageTick((t) => t + 1),
|
||||||
|
);
|
||||||
|
if (img && img.naturalWidth > 0) {
|
||||||
|
ctx.drawImage(img, iconX, iconY, icon, icon);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect?.(iconX, iconY, icon, icon, 6);
|
||||||
|
if (!ctx.roundRect) {
|
||||||
|
ctx.rect(iconX, iconY, icon, icon);
|
||||||
|
}
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
clearShadow(ctx);
|
||||||
|
// Soft accent tint plate behind icon when selected/hover
|
||||||
|
if (isSel || isHover || isDrop || isFlash) {
|
||||||
|
ctx.strokeStyle = isForbidden
|
||||||
|
? theme.warn
|
||||||
|
: isDrop || isFlash
|
||||||
|
? theme.brand
|
||||||
|
: fill;
|
||||||
|
ctx.lineWidth = 1.6 / viewport.zoom;
|
||||||
|
ctx.beginPath();
|
||||||
|
const rr = 6;
|
||||||
|
ctx.moveTo(iconX + rr, iconY);
|
||||||
|
ctx.arcTo(iconX + icon, iconY, iconX + icon, iconY + icon, rr);
|
||||||
|
ctx.arcTo(iconX + icon, iconY + icon, iconX, iconY + icon, rr);
|
||||||
|
ctx.arcTo(iconX, iconY + icon, iconX, iconY, rr);
|
||||||
|
ctx.arcTo(iconX, iconY, iconX + icon, iconY, rr);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = theme.labelPrimary;
|
||||||
|
ctx.font = canvasFont(dens ? 11 : 12, 600);
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
const captionY = iconY + icon + 4;
|
||||||
|
const lines = wrapTextLines(
|
||||||
|
ctx,
|
||||||
|
n.text || U.unnamed,
|
||||||
|
boxW - 4,
|
||||||
|
dens ? 1 : 2,
|
||||||
|
);
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
ctx.fillText(line, x + boxW / 2, captionY + i * (dens ? 13 : 14));
|
||||||
|
});
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.globalAlpha = inGhost ? 0.7 : 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isFlash && highlightStyle?.nodeGlow !== false) {
|
if (isFlash && highlightStyle?.nodeGlow !== false) {
|
||||||
ctx.shadowColor = flashNodeColor;
|
ctx.shadowColor = flashNodeColor;
|
||||||
ctx.shadowBlur = 14 / viewport.zoom;
|
ctx.shadowBlur = 14 / viewport.zoom;
|
||||||
|
|
@ -725,6 +838,7 @@ export function TopologyCanvas() {
|
||||||
nw,
|
nw,
|
||||||
dens,
|
dens,
|
||||||
cardView,
|
cardView,
|
||||||
|
topoView,
|
||||||
boxOf,
|
boxOf,
|
||||||
canvas?.prefs.showRelationEdges,
|
canvas?.prefs.showRelationEdges,
|
||||||
canvas?.prefs.nodeBadges,
|
canvas?.prefs.nodeBadges,
|
||||||
|
|
@ -869,31 +983,33 @@ export function TopologyCanvas() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const parent = selectedIds[0];
|
const parent = selectedIds[0];
|
||||||
void applyOps([
|
void applyOps([
|
||||||
parent
|
withRouterIcon(
|
||||||
? { type: "create_node", text: U.newNode, parentId: parent }
|
parent
|
||||||
: { type: "create_node", text: U.newNode },
|
? { type: "create_node", text: U.newNode, parentId: parent }
|
||||||
|
: { type: "create_node", text: U.newNode },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowLeft" && selectedIds[0] && !editing) {
|
if (e.key === "ArrowLeft" && selectedIds[0] && !editing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void applyOps([
|
void applyOps([
|
||||||
{
|
withRouterIcon({
|
||||||
type: "create_node",
|
type: "create_node",
|
||||||
text: U.leftChild,
|
text: U.leftChild,
|
||||||
parentId: selectedIds[0],
|
parentId: selectedIds[0],
|
||||||
sidePref: -1,
|
sidePref: -1,
|
||||||
},
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowRight" && selectedIds[0] && !editing) {
|
if (e.key === "ArrowRight" && selectedIds[0] && !editing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void applyOps([
|
void applyOps([
|
||||||
{
|
withRouterIcon({
|
||||||
type: "create_node",
|
type: "create_node",
|
||||||
text: U.rightChild,
|
text: U.rightChild,
|
||||||
parentId: selectedIds[0],
|
parentId: selectedIds[0],
|
||||||
sidePref: 1,
|
sidePref: 1,
|
||||||
},
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if ((e.key === "Delete" || e.key === "Backspace") && !editing) {
|
if ((e.key === "Delete" || e.key === "Backspace") && !editing) {
|
||||||
|
|
@ -912,13 +1028,15 @@ export function TopologyCanvas() {
|
||||||
x.to === selectedIds[0],
|
x.to === selectedIds[0],
|
||||||
)?.from ?? null;
|
)?.from ?? null;
|
||||||
void applyOps([
|
void applyOps([
|
||||||
parentOfSel
|
withRouterIcon(
|
||||||
? {
|
parentOfSel
|
||||||
type: "create_node",
|
? {
|
||||||
text: U.sibling,
|
type: "create_node",
|
||||||
parentId: parentOfSel,
|
text: U.sibling,
|
||||||
}
|
parentId: parentOfSel,
|
||||||
: { type: "create_node", text: U.newNode },
|
}
|
||||||
|
: { type: "create_node", text: U.newNode },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -943,6 +1061,7 @@ export function TopologyCanvas() {
|
||||||
setZoomAroundCenter,
|
setZoomAroundCenter,
|
||||||
viewport.zoom,
|
viewport.zoom,
|
||||||
U,
|
U,
|
||||||
|
withRouterIcon,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cursor =
|
const cursor =
|
||||||
|
|
@ -1219,12 +1338,12 @@ export function TopologyCanvas() {
|
||||||
});
|
});
|
||||||
} else if (toolMode === "select") {
|
} else if (toolMode === "select") {
|
||||||
void applyOps([
|
void applyOps([
|
||||||
{
|
withRouterIcon({
|
||||||
type: "create_node",
|
type: "create_node",
|
||||||
text: U.newNode,
|
text: U.newNode,
|
||||||
...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),
|
...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),
|
||||||
sidePref: world.x < 0 ? -1 : 1,
|
sidePref: world.x < 0 ? -1 : 1,
|
||||||
},
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -1297,12 +1416,12 @@ export function TopologyCanvas() {
|
||||||
<div className="canvas-toolbar__group">
|
<div className="canvas-toolbar__group">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`canvas-tool-btn${cardView ? "" : " is-active"}`}
|
className={`canvas-tool-btn${viewMode === "bubble" ? " is-active" : ""}`}
|
||||||
title={U.bubbleTitle}
|
title={U.bubbleTitle}
|
||||||
aria-pressed={!cardView}
|
aria-pressed={viewMode === "bubble"}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!cardView) return;
|
if (viewMode === "bubble") return;
|
||||||
void setPrefs({ nodeViewMode: "bubble" });
|
void setPrefs({ nodeViewMode: "bubble" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1310,17 +1429,30 @@ export function TopologyCanvas() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`canvas-tool-btn${cardView ? " is-active" : ""}`}
|
className={`canvas-tool-btn${viewMode === "card" ? " is-active" : ""}`}
|
||||||
title={U.cardTitle}
|
title={U.cardTitle}
|
||||||
aria-pressed={cardView}
|
aria-pressed={viewMode === "card"}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (cardView) return;
|
if (viewMode === "card") return;
|
||||||
void setPrefs({ nodeViewMode: "card" });
|
void setPrefs({ nodeViewMode: "card" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{U.card}
|
{U.card}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`canvas-tool-btn${viewMode === "topology" ? " is-active" : ""}`}
|
||||||
|
title={U.topologyTitle}
|
||||||
|
aria-pressed={viewMode === "topology"}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => {
|
||||||
|
if (viewMode === "topology") return;
|
||||||
|
void setPrefs({ nodeViewMode: "topology" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{U.topology}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="canvas-tool-btn"
|
className="canvas-tool-btn"
|
||||||
|
|
@ -1472,7 +1604,7 @@ export function TopologyCanvas() {
|
||||||
const text = side < 0 ? U.leftChild : U.rightChild;
|
const text = side < 0 ? U.leftChild : U.rightChild;
|
||||||
const childW = dens ? 160 : 200;
|
const childW = dens ? 160 : 200;
|
||||||
void applyOps([
|
void applyOps([
|
||||||
{
|
withRouterIcon({
|
||||||
type: "create_node",
|
type: "create_node",
|
||||||
text,
|
text,
|
||||||
parentId: id,
|
parentId: id,
|
||||||
|
|
@ -1481,7 +1613,7 @@ export function TopologyCanvas() {
|
||||||
x: p.x + side * (boxW / 2 + childW / 2 + edgeGap),
|
x: p.x + side * (boxW / 2 + childW / 2 + edgeGap),
|
||||||
y: p.y,
|
y: p.y,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ export type CanvasUi = {
|
||||||
dragBranchTitle: string;
|
dragBranchTitle: string;
|
||||||
bubble: string;
|
bubble: string;
|
||||||
card: string;
|
card: string;
|
||||||
|
topology: string;
|
||||||
bubbleTitle: string;
|
bubbleTitle: string;
|
||||||
cardTitle: string;
|
cardTitle: string;
|
||||||
|
topologyTitle: string;
|
||||||
unpin: string;
|
unpin: string;
|
||||||
unpinTitle: string;
|
unpinTitle: string;
|
||||||
undo: string;
|
undo: string;
|
||||||
|
|
@ -69,8 +71,10 @@ const zh: CanvasUi = {
|
||||||
dragBranchTitle: "整枝拖拽:节点与子孙一起移动 (B)",
|
dragBranchTitle: "整枝拖拽:节点与子孙一起移动 (B)",
|
||||||
bubble: "气泡",
|
bubble: "气泡",
|
||||||
card: "卡片",
|
card: "卡片",
|
||||||
|
topology: "拓扑",
|
||||||
bubbleTitle: "气泡视图:精简标题",
|
bubbleTitle: "气泡视图:精简标题",
|
||||||
cardTitle: "卡片视图:展开属性面板",
|
cardTitle: "卡片视图:展开属性面板",
|
||||||
|
topologyTitle: "拓扑视图:路由器图标 + 链路",
|
||||||
unpin: "解钉",
|
unpin: "解钉",
|
||||||
unpinTitle: "取消钉住:让选中节点重新参与自动布局",
|
unpinTitle: "取消钉住:让选中节点重新参与自动布局",
|
||||||
undo: "撤销",
|
undo: "撤销",
|
||||||
|
|
@ -113,8 +117,10 @@ const en: CanvasUi = {
|
||||||
dragBranchTitle: "Move node with descendants (B)",
|
dragBranchTitle: "Move node with descendants (B)",
|
||||||
bubble: "Bubble",
|
bubble: "Bubble",
|
||||||
card: "Card",
|
card: "Card",
|
||||||
|
topology: "Topology",
|
||||||
bubbleTitle: "Bubble view: title only",
|
bubbleTitle: "Bubble view: title only",
|
||||||
cardTitle: "Card view: property sheet",
|
cardTitle: "Card view: property sheet",
|
||||||
|
topologyTitle: "Topology view: router icons + links",
|
||||||
unpin: "Unpin",
|
unpin: "Unpin",
|
||||||
unpinTitle: "Unpin selected node for auto layout",
|
unpinTitle: "Unpin selected node for auto layout",
|
||||||
undo: "Undo",
|
undo: "Undo",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,25 @@
|
||||||
import type { MindNode, StylePreset } from "@mind-map/shared";
|
import type { MindNode, StylePreset } from "@mind-map/shared";
|
||||||
import { estimateCardSize } from "./node-props/sizing";
|
import { estimateCardSize } from "./node-props/sizing";
|
||||||
|
|
||||||
|
export const TOPO_ICON_PX = { comfortable: 36, compact: 30 } as const;
|
||||||
|
export const ROUTER_ICON_URL = "/topo/ne-router.png";
|
||||||
|
|
||||||
/** Keep roughly in sync with layout-engine nodeLayoutSize (comfortable / compact). */
|
/** Keep roughly in sync with layout-engine nodeLayoutSize (comfortable / compact). */
|
||||||
export function nodeBoxSize(
|
export function nodeBoxSize(
|
||||||
n: MindNode,
|
n: MindNode,
|
||||||
dens: boolean,
|
dens: boolean,
|
||||||
opts?: { asCard?: boolean },
|
opts?: { asCard?: boolean; asTopology?: boolean },
|
||||||
): { w: number; h: number } {
|
): { w: number; h: number } {
|
||||||
|
if (opts?.asTopology) {
|
||||||
|
const icon = dens ? TOPO_ICON_PX.compact : TOPO_ICON_PX.comfortable;
|
||||||
|
const caption = dens ? 18 : 20;
|
||||||
|
const gap = 6;
|
||||||
|
const pad = dens ? 6 : 8;
|
||||||
|
return {
|
||||||
|
w: Math.max(dens ? 72 : 88, icon + pad * 2),
|
||||||
|
h: icon + gap + caption + pad,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (opts?.asCard || n.stylePreset === "card") {
|
if (opts?.asCard || n.stylePreset === "card") {
|
||||||
return estimateCardSize(n, dens);
|
return estimateCardSize(n, dens);
|
||||||
}
|
}
|
||||||
|
|
@ -177,4 +190,5 @@ const ICON_GLYPH: Record<string, string> = {
|
||||||
star: "[★]",
|
star: "[★]",
|
||||||
gear: "[设]",
|
gear: "[设]",
|
||||||
globe: "[球]",
|
globe: "[球]",
|
||||||
|
router: "[路]",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,7 @@ export function NodePropEditor({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">无</option>
|
<option value="">无</option>
|
||||||
|
<option value="router">路由器</option>
|
||||||
<option value="server">🖥 服务器</option>
|
<option value="server">🖥 服务器</option>
|
||||||
<option value="database">🗄 数据库</option>
|
<option value="database">🗄 数据库</option>
|
||||||
<option value="cloud">☁ 云</option>
|
<option value="cloud">☁ 云</option>
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActive
|
||||||
{
|
{
|
||||||
name: 'mindmap_create_canvas',
|
name: 'mindmap_create_canvas',
|
||||||
description:
|
description:
|
||||||
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId.',
|
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId. Optional nodeViewMode (bubble|card|topology) is a canvas-global preference (can also be changed later via set_prefs).',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -150,6 +150,12 @@ export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActive
|
||||||
description:
|
description:
|
||||||
'Optional internal label / title sync only — there is no visible root node. Prefer title alone.',
|
'Optional internal label / title sync only — there is no visible root node. Prefer title alone.',
|
||||||
},
|
},
|
||||||
|
nodeViewMode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['bubble', 'card', 'topology'],
|
||||||
|
description:
|
||||||
|
'Canvas-global chrome (bubble|card|topology). Prefer setting when creating a topology map; default card. topology = router icons + straight blue links.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||||
|
|
@ -636,6 +642,7 @@ There is NO visible root node. Canvas title is metadata only. Internally top-lev
|
||||||
|
|
||||||
When the user asks to build a map (e.g. 「输出红楼梦人物关系图」):
|
When the user asks to build a map (e.g. 「输出红楼梦人物关系图」):
|
||||||
1. mindmap_create_canvas({ title: "红楼梦人物关系" }) — ALWAYS creates a NEW canvas id (never overwrites an old one). Opens the right tab on that new canvas.
|
1. mindmap_create_canvas({ title: "红楼梦人物关系" }) — ALWAYS creates a NEW canvas id (never overwrites an old one). Opens the right tab on that new canvas.
|
||||||
|
Topology diagrams: mindmap_create_canvas({ title: "…拓扑", nodeViewMode: "topology" }) then nodes with icon:"router".
|
||||||
2. mindmap_get_spatial_context — orient on the NEW empty canvas
|
2. mindmap_get_spatial_context — orient on the NEW empty canvas
|
||||||
3. mindmap_apply_direct({ ops: [ {type:"batch_create", nodes:[...]}, {type:"auto_fit"} ] })
|
3. mindmap_apply_direct({ ops: [ {type:"batch_create", nodes:[...]}, {type:"auto_fit"} ] })
|
||||||
4. Keep adding in small batches; never dump a full tree JSON overwrite
|
4. Keep adding in small batches; never dump a full tree JSON overwrite
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ function promoteChildrenOnDelete(g: MutableGraph, nodeId: string): Op[] {
|
||||||
export function createEmptyGraph(
|
export function createEmptyGraph(
|
||||||
title = "未命名画布",
|
title = "未命名画布",
|
||||||
rootText?: string,
|
rootText?: string,
|
||||||
|
prefsOverride?: Partial<CanvasPrefs>,
|
||||||
): MutableGraph {
|
): MutableGraph {
|
||||||
const canvasId = newId("c");
|
const canvasId = newId("c");
|
||||||
const nodeId = newId("n");
|
const nodeId = newId("n");
|
||||||
|
|
@ -137,6 +138,7 @@ export function createEmptyGraph(
|
||||||
showHoverCard: true,
|
showHoverCard: true,
|
||||||
inspectorMode: "selection",
|
inspectorMode: "selection",
|
||||||
nodeViewMode: "card",
|
nodeViewMode: "card",
|
||||||
|
...prefsOverride,
|
||||||
};
|
};
|
||||||
const canvas: CanvasMeta = {
|
const canvas: CanvasMeta = {
|
||||||
id: canvasId,
|
id: canvasId,
|
||||||
|
|
|
||||||
|
|
@ -276,8 +276,12 @@ CREATE INDEX IF NOT EXISTS idx_snapshots_canvas ON snapshots(canvas_id);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
createCanvas(title?: string, rootText?: string): MutableGraph {
|
createCanvas(
|
||||||
const g = createEmptyGraph(title ?? "未命名画布", rootText);
|
title?: string,
|
||||||
|
rootText?: string,
|
||||||
|
prefsOverride?: Partial<CanvasMeta["prefs"]>,
|
||||||
|
): MutableGraph {
|
||||||
|
const g = createEmptyGraph(title ?? "未命名画布", rootText, prefsOverride);
|
||||||
this.persistGraph(g, true);
|
this.persistGraph(g, true);
|
||||||
this.cache.set(g.canvas.id, g);
|
this.cache.set(g.canvas.id, g);
|
||||||
return g;
|
return g;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export type LayoutInput = {
|
||||||
edges: MindEdge[];
|
edges: MindEdge[];
|
||||||
focusNodeId: string;
|
focusNodeId: string;
|
||||||
density?: Density;
|
density?: Density;
|
||||||
/** Canvas-wide bubble vs card chrome (affects box sizes). */
|
/** Canvas-wide bubble vs card vs topology chrome (affects box sizes). */
|
||||||
nodeViewMode?: NodeViewMode;
|
nodeViewMode?: NodeViewMode;
|
||||||
/** Previous frame positions — reused when hierarchy structure is unchanged. */
|
/** Previous frame positions — reused when hierarchy structure is unchanged. */
|
||||||
prevPositions?: PositionMap;
|
prevPositions?: PositionMap;
|
||||||
|
|
@ -52,8 +52,18 @@ export function nodeLayoutSize(
|
||||||
| "pinned"
|
| "pinned"
|
||||||
>,
|
>,
|
||||||
dens: Dens,
|
dens: Dens,
|
||||||
opts?: { asCard?: boolean },
|
opts?: { asCard?: boolean; asTopology?: boolean },
|
||||||
): { w: number; h: number } {
|
): { w: number; h: number } {
|
||||||
|
if (opts?.asTopology) {
|
||||||
|
const icon = dens.nodeW >= 200 ? 36 : 30;
|
||||||
|
const caption = dens.nodeW >= 200 ? 20 : 18;
|
||||||
|
const gap = 6;
|
||||||
|
const pad = dens.nodeW >= 200 ? 8 : 6;
|
||||||
|
return {
|
||||||
|
w: Math.max(dens.nodeW >= 200 ? 88 : 72, icon + pad * 2),
|
||||||
|
h: icon + gap + caption + pad,
|
||||||
|
};
|
||||||
|
}
|
||||||
let w = dens.nodeW;
|
let w = dens.nodeW;
|
||||||
let h = dens.nodeH;
|
let h = dens.nodeH;
|
||||||
// meta strip (badges / tags / due)
|
// meta strip (badges / tags / due)
|
||||||
|
|
@ -248,7 +258,7 @@ function buildSubtree(
|
||||||
children: Map<string, string[]>,
|
children: Map<string, string[]>,
|
||||||
dens: Dens,
|
dens: Dens,
|
||||||
path: Set<string>,
|
path: Set<string>,
|
||||||
asCard: boolean,
|
sizeOpts: { asCard?: boolean; asTopology?: boolean },
|
||||||
childFilter?: (childId: string) => boolean,
|
childFilter?: (childId: string) => boolean,
|
||||||
): FlexDatum | null {
|
): FlexDatum | null {
|
||||||
if (path.has(id)) return null;
|
if (path.has(id)) return null;
|
||||||
|
|
@ -266,12 +276,12 @@ function buildSubtree(
|
||||||
children,
|
children,
|
||||||
dens,
|
dens,
|
||||||
nextPath,
|
nextPath,
|
||||||
asCard,
|
sizeOpts,
|
||||||
);
|
);
|
||||||
if (child) kids.push(child);
|
if (child) kids.push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const box = nodeLayoutSize(node, dens, { asCard });
|
const box = nodeLayoutSize(node, dens, sizeOpts);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
// flextree size = full box + gutters (claim real space, not a rigid grid cell)
|
// flextree size = full box + gutters (claim real space, not a rigid grid cell)
|
||||||
|
|
@ -397,6 +407,7 @@ export function hierarchyFingerprint(edges: MindEdge[]): string {
|
||||||
export function layout(input: LayoutInput): PositionMap {
|
export function layout(input: LayoutInput): PositionMap {
|
||||||
const dens = DENSITY[input.density ?? "comfortable"];
|
const dens = DENSITY[input.density ?? "comfortable"];
|
||||||
const asCard = input.nodeViewMode === "card";
|
const asCard = input.nodeViewMode === "card";
|
||||||
|
const asTopology = input.nodeViewMode === "topology";
|
||||||
const nodesById = new Map(input.nodes.map((n) => [n.id, n]));
|
const nodesById = new Map(input.nodes.map((n) => [n.id, n]));
|
||||||
if (!nodesById.has(input.focusNodeId)) return {};
|
if (!nodesById.has(input.focusNodeId)) return {};
|
||||||
|
|
||||||
|
|
@ -407,7 +418,7 @@ export function layout(input: LayoutInput): PositionMap {
|
||||||
const sizes = new Map<string, { w: number; h: number }>();
|
const sizes = new Map<string, { w: number; h: number }>();
|
||||||
for (const n of input.nodes) {
|
for (const n of input.nodes) {
|
||||||
if (n.deletedAt || hidden.has(n.id)) continue;
|
if (n.deletedAt || hidden.has(n.id)) continue;
|
||||||
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard }));
|
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard, asTopology }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = input.prevPositions;
|
const prev = input.prevPositions;
|
||||||
|
|
@ -501,7 +512,7 @@ export function layout(input: LayoutInput): PositionMap {
|
||||||
children,
|
children,
|
||||||
dens,
|
dens,
|
||||||
new Set(),
|
new Set(),
|
||||||
asCard,
|
{ asCard, asTopology },
|
||||||
(childId) => sideOf.get(childId) === side,
|
(childId) => sideOf.get(childId) === side,
|
||||||
);
|
);
|
||||||
if (!tree?.children?.length) continue;
|
if (!tree?.children?.length) continue;
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,12 @@ export const OP_SCHEMA_DOC = {
|
||||||
],
|
],
|
||||||
_comment: "Bulk relation/hierarchy links (max 100). Prefer after nodes exist.",
|
_comment: "Bulk relation/hierarchy links (max 100). Prefer after nodes exist.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "create_node",
|
||||||
|
text: "核心交换机",
|
||||||
|
alias: "core_sw",
|
||||||
|
icon: "router",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "create_node",
|
type: "create_node",
|
||||||
text: "AIOps诊断",
|
text: "AIOps诊断",
|
||||||
|
|
@ -494,9 +500,11 @@ export const OP_SCHEMA_DOC = {
|
||||||
restore_node:
|
restore_node:
|
||||||
"Soft-deleted nodes keep their id. Pass that id (or deleted @alias / unique title). Restores compatible incident edges; children already promoted stay under the new parent.",
|
"Soft-deleted nodes keep their id. Pass that id (or deleted @alias / unique title). Restores compatible incident edges; children already promoted stay under the new parent.",
|
||||||
create_node:
|
create_node:
|
||||||
"Omit parentId for top-level. Optional pos pins immediately. Optional alias. Optional relations[] for outgoing graph edges (not URL links). Optional description object.",
|
"Omit parentId for top-level. Optional pos pins immediately. Optional alias. Optional icon (server|database|cloud|person|folder|doc|link|warning|check|star|gear|globe|router). Optional relations[] for outgoing graph edges (not URL links). Optional description object.",
|
||||||
update_node_meta:
|
update_node_meta:
|
||||||
"Patch note/description/tags/links/imageUrl/dueAt/startAt/accentColor/icon/alias. description is a JSON object (knowledge fields). Null clears scalar fields. links = URL bookmarks.",
|
"Patch note/description/tags/links/imageUrl/dueAt/startAt/accentColor/icon/alias. icon enum same as create_node. description is a JSON object (knowledge fields). Null clears scalar fields. links = URL bookmarks.",
|
||||||
|
set_prefs:
|
||||||
|
"Partial canvas prefs: density, branchColoring, nodeViewMode (bubble|card|topology; canvas-global), showHoverCard, nodeBadges, showRelationEdges, riskProfile, themeId, inspectorMode.",
|
||||||
update_edge_meta:
|
update_edge_meta:
|
||||||
"Patch label/tags/note/weight(1-5)/lineStyle(solid|dashed|dotted)/direction(forward|both|none) on an edge.",
|
"Patch label/tags/note/weight(1-5)/lineStyle(solid|dashed|dotted)/direction(forward|both|none) on an edge.",
|
||||||
set_edge_label: "Need edgeId from spatial_context.edges or get_subgraph.",
|
set_edge_label: "Need edgeId from spatial_context.edges or get_subgraph.",
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ export type NodeBadges = z.infer<typeof NodeBadgesSchema>;
|
||||||
export const InspectorModeSchema = z.enum(["selection", "always"]);
|
export const InspectorModeSchema = z.enum(["selection", "always"]);
|
||||||
export type InspectorMode = z.infer<typeof InspectorModeSchema>;
|
export type InspectorMode = z.infer<typeof InspectorModeSchema>;
|
||||||
|
|
||||||
/** Whole-canvas node chrome: bubble titles vs property cards. */
|
/** Whole-canvas node chrome: bubble titles vs property cards vs topology icons. */
|
||||||
export const NodeViewModeSchema = z.enum(["bubble", "card"]);
|
export const NodeViewModeSchema = z.enum(["bubble", "card", "topology"]);
|
||||||
export type NodeViewMode = z.infer<typeof NodeViewModeSchema>;
|
export type NodeViewMode = z.infer<typeof NodeViewModeSchema>;
|
||||||
|
|
||||||
/** Controlled node icon set (agent/UI). */
|
/** Controlled node icon set (agent/UI). */
|
||||||
|
|
@ -47,6 +47,7 @@ export const NodeIconSchema = z.enum([
|
||||||
"star",
|
"star",
|
||||||
"gear",
|
"gear",
|
||||||
"globe",
|
"globe",
|
||||||
|
"router",
|
||||||
]);
|
]);
|
||||||
export type NodeIcon = z.infer<typeof NodeIconSchema>;
|
export type NodeIcon = z.infer<typeof NodeIconSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue