feat: 丰富架构图资源组件

This commit is contained in:
tianma-if committed 2026-09-06 16:44:10 +08:00
1 parent 7e419b6464
commit ce72f71a9d
7 files changed
+585 -39

No files matched your search

@@ -99,8 +99,8 @@ describe("diagram editor canvas surface", () => {
test("provides architecture components, boundaries, semantic edges, and editable labels", () => {
expect(source).toContain('document.kind === "architecture"');
expect(source).toContain('addNode("service")');
expect(source).toContain('addNode("database")');
expect(source).toContain('addNode("boundary")');
expect(source).toContain('{ shape: "database", icon: Database');
expect(source).toContain('{ shape: "boundary", icon: Box');
expect(source).toContain('t("diagram.architectureConnectHint")');
expect(source).toContain("fitArchitectureBoundaries(graph)");
expect(source).toContain("parent.addChild(node)");
@@ -112,6 +112,33 @@ describe("diagram editor canvas surface", () => {
expect(globalStyles).toContain('[data-diagram-kind="architecture"] .x6-port-body');
});
test("organizes architecture resources in a searchable category library", () => {
expect(source).toContain("ARCHITECTURE_LIBRARY_CATEGORIES");
expect(source).toContain('t("diagram.componentLibrary")');
expect(source).toContain('t("diagram.componentSearch")');
expect(source).toContain('labelKey: "diagram.componentCategoryExperience"');
expect(source).toContain('labelKey: "diagram.componentCategoryServices"');
expect(source).toContain('labelKey: "diagram.componentCategoryDatabases"');
expect(source).toContain('labelKey: "diagram.componentCategoryStorage"');
expect(source).toContain('labelKey: "diagram.componentCategoryMiddleware"');
expect(source).toContain('labelKey: "diagram.componentCategoryNetwork"');
expect(source).toContain('labelKey: "diagram.componentCategorySecurity"');
expect(source).toContain('labelKey: "diagram.componentCategoryObservability"');
expect(source).toContain('labelKey: "diagram.componentCategoryExternal"');
expect(source).toContain("<Collapsible key={category.id} defaultOpen>");
expect(source).toContain("category.items.filter");
expect(source).toContain('className="grid grid-cols-7 gap-1 px-1 pb-2"');
expect(source).toContain('<TooltipContent side="top">');
expect(source).toContain('aria-label={label}');
expect(source).toContain('options.label ??');
expect(source).toContain('resourceIcon: architectureResourceIcon(item)');
expect(source).toContain('...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {})');
expect(source).toContain('architectureNodeVisuals(node.shape, size, appearance, node.resourceIcon)');
expect(source).toContain('inferArchitectureResourceIcon(node.label, t)');
expect(source).toContain('.render({}, null).props.iconNode');
expect(source).not.toContain('className="line-clamp-2"');
});
test("shows connection handles only on selected flowchart nodes", () => {
expect(globalStyles).toContain('.x6-node.edgeever-flow-node-active .edgeever-flow-port-dot');
expect(globalStyles).toContain('.x6-port-body:hover .edgeever-flow-port-dot');
+341 -36
View File
@@ -1,40 +1,74 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
import {
Activity,
AppWindow,
Blocks,
Box,
Boxes,
BrickWall,
Cable,
Check,
ChartNoAxesCombined,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
CircleAlert,
Cloud,
CloudCog,
CloudUpload,
Code2,
Container,
Copy,
Cpu,
Diamond,
Database,
DatabaseZap,
Download,
EthernetPort,
FileCode2,
FileClock,
FileImage,
FileStack,
FolderArchive,
Gauge,
GitBranch,
Globe2,
HardDrive,
History as HistoryIcon,
LayoutDashboard,
KeyRound,
Layers3,
ListTree,
Link2,
LockKeyhole,
LoaderCircle,
Maximize2,
Minimize2,
MoreHorizontal,
MonitorSmartphone,
Network,
Pencil,
RadioTower,
Redo2,
RefreshCw,
RotateCcw,
Router,
Search,
Server,
ShieldCheck,
ShieldEllipsis,
Smartphone,
SquareFunction,
Trash2,
Undo2,
Webhook,
Workflow,
Zap,
ZoomIn,
ZoomOut,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -44,6 +78,7 @@ import {
markdownToDoc,
parseDiagramDocument,
serializeDiagramDocument,
type ArchitectureResourceIcon,
type DiagramDocument,
type DiagramEdgeKind,
type DiagramNodeShape,
@@ -57,6 +92,7 @@ import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { RevisionHistoryDialog } from "@/components/dialogs/RevisionHistoryDialog";
import { ShareMemoDialog } from "@/components/dialogs/ShareMemoDialog";
import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -90,7 +126,7 @@ type DiagramEditorPaneProps = {
onToggleDesktopFocusMode: () => void;
};
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string };
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string; resourceIcon?: ArchitectureResourceIcon };
type EdgeData = { kind?: DiagramEdgeKind; bidirectional?: boolean };
type MindMapInsertRelation = "child" | "sibling";
type FlowPort = "top" | "right" | "bottom" | "left";
@@ -125,6 +161,231 @@ type NodeEditorState = {
borderColor: string;
};
type ArchitectureLibraryItem = {
icon: LucideIcon;
labelKey: `diagram.architectureResources.${ArchitectureResourceIcon}`;
shape: DiagramNodeShape;
};
const ARCHITECTURE_LIBRARY_CATEGORIES: Array<{
id: string;
labelKey: string;
tone: string;
items: ArchitectureLibraryItem[];
}> = [
{
id: "applications",
labelKey: "diagram.componentCategoryExperience",
tone: "text-cyan-600",
items: [
{ shape: "client", icon: MonitorSmartphone, labelKey: "diagram.architectureResources.client" },
{ shape: "frontend", icon: AppWindow, labelKey: "diagram.architectureResources.webApp" },
{ shape: "client", icon: Smartphone, labelKey: "diagram.architectureResources.mobileApp" },
{ shape: "frontend", icon: Globe2, labelKey: "diagram.architectureResources.website" },
{ shape: "client", icon: Code2, labelKey: "diagram.architectureResources.apiClient" },
],
},
{
id: "compute",
labelKey: "diagram.componentCategoryServices",
tone: "text-emerald-600",
items: [
{ shape: "service", icon: Server, labelKey: "diagram.architectureResources.service" },
{ shape: "service", icon: Cpu, labelKey: "diagram.architectureResources.virtualMachine" },
{ shape: "service", icon: Container, labelKey: "diagram.architectureResources.container" },
{ shape: "service", icon: Blocks, labelKey: "diagram.architectureResources.kubernetes" },
{ shape: "service", icon: SquareFunction, labelKey: "diagram.architectureResources.serverless" },
],
},
{
id: "data",
labelKey: "diagram.componentCategoryDatabases",
tone: "text-violet-600",
items: [
{ shape: "database", icon: Database, labelKey: "diagram.architectureResources.relationalDatabase" },
{ shape: "database", icon: DatabaseZap, labelKey: "diagram.architectureResources.noSqlDatabase" },
{ shape: "database", icon: Layers3, labelKey: "diagram.architectureResources.cache" },
{ shape: "database", icon: ChartNoAxesCombined, labelKey: "diagram.architectureResources.dataWarehouse" },
{ shape: "database", icon: Search, labelKey: "diagram.architectureResources.searchEngine" },
],
},
{
id: "storage",
labelKey: "diagram.componentCategoryStorage",
tone: "text-lime-600",
items: [
{ shape: "storage", icon: Cloud, labelKey: "diagram.architectureResources.objectStorage" },
{ shape: "storage", icon: FileStack, labelKey: "diagram.architectureResources.fileStorage" },
{ shape: "storage", icon: HardDrive, labelKey: "diagram.architectureResources.blockStorage" },
{ shape: "storage", icon: FolderArchive, labelKey: "diagram.architectureResources.backup" },
{ shape: "storage", icon: CloudUpload, labelKey: "diagram.architectureResources.cdn" },
],
},
{
id: "middleware",
labelKey: "diagram.componentCategoryMiddleware",
tone: "text-orange-600",
items: [
{ shape: "queue", icon: GitBranch, labelKey: "diagram.architectureResources.messageQueue" },
{ shape: "queue", icon: Workflow, labelKey: "diagram.architectureResources.eventBus" },
{ shape: "queue", icon: RadioTower, labelKey: "diagram.architectureResources.streamProcessing" },
{ shape: "service", icon: Webhook, labelKey: "diagram.architectureResources.webhook" },
{ shape: "service", icon: ListTree, labelKey: "diagram.architectureResources.serviceMesh" },
],
},
{
id: "network",
labelKey: "diagram.componentCategoryNetwork",
tone: "text-blue-600",
items: [
{ shape: "service", icon: Router, labelKey: "diagram.architectureResources.apiGateway" },
{ shape: "service", icon: Activity, labelKey: "diagram.architectureResources.loadBalancer" },
{ shape: "external", icon: Globe2, labelKey: "diagram.architectureResources.dns" },
{ shape: "boundary", icon: Network, labelKey: "diagram.architectureResources.vpc" },
{ shape: "boundary", icon: Cable, labelKey: "diagram.architectureResources.subnet" },
{ shape: "security", icon: EthernetPort, labelKey: "diagram.architectureResources.vpn" },
],
},
{
id: "security",
labelKey: "diagram.componentCategorySecurity",
tone: "text-rose-600",
items: [
{ shape: "security", icon: KeyRound, labelKey: "diagram.architectureResources.identity" },
{ shape: "security", icon: BrickWall, labelKey: "diagram.architectureResources.firewall" },
{ shape: "security", icon: ShieldCheck, labelKey: "diagram.architectureResources.waf" },
{ shape: "security", icon: LockKeyhole, labelKey: "diagram.architectureResources.secretManager" },
{ shape: "security", icon: ShieldEllipsis, labelKey: "diagram.architectureResources.certificate" },
{ shape: "boundary", icon: Box, labelKey: "diagram.architectureResources.systemBoundary" },
],
},
{
id: "observability",
labelKey: "diagram.componentCategoryObservability",
tone: "text-teal-600",
items: [
{ shape: "service", icon: Gauge, labelKey: "diagram.architectureResources.monitoring" },
{ shape: "service", icon: FileClock, labelKey: "diagram.architectureResources.logging" },
{ shape: "service", icon: ChartNoAxesCombined, labelKey: "diagram.architectureResources.metrics" },
{ shape: "service", icon: Activity, labelKey: "diagram.architectureResources.tracing" },
{ shape: "service", icon: CircleAlert, labelKey: "diagram.architectureResources.alerting" },
],
},
{
id: "external",
labelKey: "diagram.componentCategoryExternal",
tone: "text-slate-600",
items: [
{ shape: "external", icon: CloudCog, labelKey: "diagram.architectureResources.saas" },
{ shape: "external", icon: Webhook, labelKey: "diagram.architectureResources.externalApi" },
{ shape: "external", icon: Zap, labelKey: "diagram.architectureResources.thirdPartyService" },
],
},
];
const architectureResourceIcon = (item: ArchitectureLibraryItem) =>
item.labelKey.slice("diagram.architectureResources.".length) as ArchitectureResourceIcon;
const ARCHITECTURE_RESOURCE_ICON_COMPONENTS = Object.fromEntries(
ARCHITECTURE_LIBRARY_CATEGORIES.flatMap((category) => category.items)
.map((item) => [architectureResourceIcon(item), item.icon]),
) as Record<ArchitectureResourceIcon, LucideIcon>;
const ARCHITECTURE_LIBRARY_ITEMS = ARCHITECTURE_LIBRARY_CATEGORIES.flatMap((category) => category.items);
const inferArchitectureResourceIcon = (
label: string,
t: (key: string) => string,
) => {
const item = ARCHITECTURE_LIBRARY_ITEMS.find((candidate) => t(candidate.labelKey) === label);
return item ? architectureResourceIcon(item) : undefined;
};
const ArchitectureComponentLibrary = ({
onAdd,
t,
}: {
onAdd: (item: ArchitectureLibraryItem) => void;
t: (key: string) => string;
}) => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const categories = ARCHITECTURE_LIBRARY_CATEGORIES.map((category) => ({
...category,
items: category.items.filter((item) => t(item.labelKey).toLocaleLowerCase().includes(normalizedQuery)),
})).filter((category) => category.items.length > 0);
return (
<DropdownMenu open={open} onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline"><Boxes className="h-4 w-4" />{t("diagram.componentLibrary")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[min(36rem,calc(100vh-8rem))] w-[min(30rem,calc(100vw-2rem))] overflow-y-auto p-0">
<div className="sticky top-0 z-10 border-b border-slate-200 bg-white p-2.5">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
autoFocus
className="h-9 pl-9"
value={query}
placeholder={t("diagram.componentSearch")}
aria-label={t("diagram.componentSearch")}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
/>
</div>
</div>
<div className="p-1.5">
{categories.length > 0 ? categories.map((category) => (
<Collapsible key={category.id} defaultOpen>
<DropdownMenuItem asChild onSelect={(event) => event.preventDefault()}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm font-semibold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-green)]">
<ChevronDown className="h-4 w-4 text-slate-400 transition-transform group-data-[state=closed]:-rotate-90" />
{t(category.labelKey)}
<span className="ml-auto text-xs font-normal text-slate-400">{category.items.length}</span>
</CollapsibleTrigger>
</DropdownMenuItem>
<CollapsibleContent>
<div className="grid grid-cols-7 gap-1 px-1 pb-2">
{category.items.map((item) => {
const Icon = item.icon;
const label = t(item.labelKey);
return (
<Tooltip key={item.labelKey}>
<TooltipTrigger asChild>
<DropdownMenuItem
aria-label={label}
className={cn("flex h-10 w-10 cursor-pointer justify-center rounded-lg p-0 hover:bg-current/10 focus:bg-current/10", category.tone)}
onSelect={() => {
onAdd(item);
setOpen(false);
}}
>
<Icon className="h-5 w-5" />
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="top">
{label}
</TooltipContent>
</Tooltip>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
)) : (
<div className="px-3 py-8 text-center text-sm text-slate-500">{t("diagram.noMatchingComponents")}</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
const FLOW_ACTIVE_NODE_CLASS = "edgeever-flow-node-active";
const FLOW_PORT_HIT_RADIUS = 14;
const FLOW_PORT_DOT_RADIUS = 7;
@@ -317,14 +578,53 @@ const architectureNodeVisuals = (
shape: DiagramNodeShape,
size: { width: number; height: number },
appearance: DiagramAppearance,
resourceIcon?: ArchitectureResourceIcon,
) => {
const accent = ARCHITECTURE_NODE_ACCENTS[shape] ?? "#64748B";
const iconY = Math.round((size.height - 34) / 2);
const iconComponent = resourceIcon ? ARCHITECTURE_RESOURCE_ICON_COMPONENTS[resourceIcon] : undefined;
const iconNodes = iconComponent
? (iconComponent as unknown as {
render: (props: Record<string, never>, ref: null) => {
props: { iconNode: Array<[string, Record<string, string>]> };
};
}).render({}, null).props.iconNode
: null;
const iconMarkup = iconNodes?.map(([tagName], index) => ({
tagName,
selector: `architectureIcon${index}`,
})) ?? [{ tagName: "path", selector: "architectureIcon" }];
const iconAttrs = iconNodes
? Object.fromEntries(iconNodes.map(([, sourceAttrs], index) => {
const { key: _key, ...geometry } = sourceAttrs;
return [`architectureIcon${index}`, {
...geometry,
transform: `translate(15 ${iconY + 5})`,
fill: "none",
stroke: accent,
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
pointerEvents: "none",
}];
}))
: {
architectureIcon: {
d: ARCHITECTURE_NODE_ICONS[shape],
transform: `translate(15 ${iconY + 5})`,
fill: "none",
stroke: accent,
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
pointerEvents: "none",
},
};
return {
markup: [
{ tagName: "rect", selector: "body" },
{ tagName: "rect", selector: "iconFrame" },
{ tagName: "path", selector: "architectureIcon" },
...iconMarkup,
{ tagName: "text", selector: "label" },
],
attrs: {
@@ -339,16 +639,7 @@ const architectureNodeVisuals = (
stroke: "none",
pointerEvents: "none",
},
architectureIcon: {
d: ARCHITECTURE_NODE_ICONS[shape],
transform: `translate(15 ${iconY + 5})`,
fill: "none",
stroke: accent,
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
pointerEvents: "none",
},
...iconAttrs,
},
};
};
@@ -402,7 +693,7 @@ const nodeMetadata = (
const size = diagramNodeSize(node, kind);
const hasPorts = isConnectableDiagram(kind) && node.shape !== "boundary";
const architectureVisuals = kind === "architecture" && node.shape !== "boundary"
? architectureNodeVisuals(node.shape, size, appearance)
? architectureNodeVisuals(node.shape, size, appearance, node.resourceIcon)
: null;
return {
id: node.id,
@@ -412,7 +703,12 @@ const nodeMetadata = (
width: size.width,
height: size.height,
zIndex: node.shape === "boundary" ? 0 : 2,
data: { label: node.label, shape: node.shape, ...(node.parentId ? { parentId: node.parentId } : {}) } satisfies NodeData,
data: {
label: node.label,
shape: node.shape,
...(node.parentId ? { parentId: node.parentId } : {}),
...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}),
} satisfies NodeData,
...(architectureVisuals ? { markup: architectureVisuals.markup } : {}),
attrs: {
body: visualAttrs.body,
@@ -488,6 +784,7 @@ const graphToDocument = (graph: Graph, kind: DiagramDocument["kind"], theme: Dia
height: Math.round(size.height),
shape: data?.shape ?? "process",
...(data?.parentId && graph.getCellById(data.parentId)?.isNode() ? { parentId: data.parentId } : {}),
...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {}),
};
}),
edges: graph.getEdges().flatMap((edge) => {
@@ -593,9 +890,10 @@ const applyGraphPalette = (
node.attr("body", attrs.body);
node.attr("label", { ...attrs.label, text: data?.label ?? "" });
if (kind === "architecture" && shape !== "boundary") {
const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance);
node.attr("iconFrame", architectureVisuals.attrs.iconFrame);
node.attr("architectureIcon", architectureVisuals.attrs.architectureIcon);
const architectureVisuals = architectureNodeVisuals(shape, node.getSize(), appearance, data?.resourceIcon);
for (const [selector, selectorAttrs] of Object.entries(architectureVisuals.attrs)) {
node.attr(selector, selectorAttrs);
}
}
for (const port of node.getPorts()) {
if (!port.id) continue;
@@ -843,7 +1141,15 @@ export const DiagramEditorPane = ({
},
}));
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: !readOnly, showNodeSelectionBox: true, showEdgeSelectionBox: true }));
graph.addNodes(document.nodes.map((node) => nodeMetadata(node, documentTheme, document.kind, appearance)));
graph.addNodes(document.nodes.map((node) => {
const inferredResourceIcon = document.kind === "architecture" && !node.resourceIcon
? inferArchitectureResourceIcon(node.label, t)
: undefined;
return nodeMetadata({
...node,
...(inferredResourceIcon ? { resourceIcon: inferredResourceIcon } : {}),
}, documentTheme, document.kind, appearance);
}));
if (document.kind === "architecture") {
for (const node of graph.getNodes()) {
const parentId = node.getData<NodeData>()?.parentId;
@@ -1117,6 +1423,7 @@ export const DiagramEditorPane = ({
height: node.getSize().height,
shape: data?.shape ?? "process",
...(data?.parentId ? { parentId: data.parentId } : {}),
...(data?.resourceIcon ? { resourceIcon: data.resourceIcon } : {}),
}, themeRef.current, document.kind, appearanceRef.current));
const parent = data?.parentId ? graph.getCellById(data.parentId) : null;
if (parent?.isNode()) parent.addChild(duplicate);
@@ -1226,7 +1533,13 @@ export const DiagramEditorPane = ({
const addNode = useCallback((
shape: DiagramNodeShape = "process",
options: { relation?: MindMapInsertRelation; baseNodeId?: string; beginEditing?: boolean } = {},
options: {
relation?: MindMapInsertRelation;
baseNodeId?: string;
beginEditing?: boolean;
label?: string;
resourceIcon?: ArchitectureResourceIcon;
} = {},
) => {
const graph = graphRef.current;
if (!graph || !document || readOnly) return;
@@ -1272,7 +1585,7 @@ export const DiagramEditorPane = ({
graph.startBatch("add");
const node = graph.addNode(nodeMetadata({
id,
label: isMindMap ? t("diagram.newTopic") : isArchitecture ? architectureNodeLabel(shape, t) : t("diagram.newStep"),
label: options.label ?? (isMindMap ? t("diagram.newTopic") : isArchitecture ? architectureNodeLabel(shape, t) : t("diagram.newStep")),
x: nextPosition.x,
y: nextPosition.y,
width: authoredSize.width,
@@ -1280,6 +1593,7 @@ export const DiagramEditorPane = ({
shape: isMindMap ? "topic" : shape,
...(isMindMap && parent?.isNode() ? { parentId: parent.id } : {}),
...(architectureParentId ? { parentId: architectureParentId } : {}),
...(isArchitecture && options.resourceIcon ? { resourceIcon: options.resourceIcon } : {}),
}, themeRef.current, document.kind, appearanceRef.current));
if (architectureParentId) {
const architectureParent = graph.getCellById(architectureParentId);
@@ -1775,22 +2089,13 @@ export const DiagramEditorPane = ({
</TooltipTrigger>
<TooltipContent>{t("diagram.architectureShortcuts")}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline"><Boxes className="h-4 w-4" />{t("diagram.components")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={() => addNode("client")}><Network className="h-4 w-4" />{t("diagram.addClient")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addNode("frontend")}><Globe2 className="h-4 w-4" />{t("diagram.addFrontend")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addNode("database")}><Database className="h-4 w-4" />{t("diagram.addDatabase")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addNode("storage")}><HardDrive className="h-4 w-4" />{t("diagram.addStorage")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addNode("queue")}><GitBranch className="h-4 w-4" />{t("diagram.addQueue")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addNode("security")}><ShieldCheck className="h-4 w-4" />{t("diagram.addSecurity")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addNode("external")}><Globe2 className="h-4 w-4" />{t("diagram.addExternal")}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => addNode("boundary")}><Box className="h-4 w-4" />{t("diagram.addBoundary")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ArchitectureComponentLibrary
onAdd={(item) => addNode(item.shape, {
label: t(item.labelKey),
resourceIcon: architectureResourceIcon(item),
})}
t={t}
/>
<span className="hidden items-center gap-1.5 px-2 text-xs text-slate-500 xl:flex"><Link2 className="h-3.5 w-3.5" />{t("diagram.architectureConnectHint")}</span>
</>
) : (
+60 -1
View File
@@ -1,4 +1,4 @@
import type { DiagramDocument, DiagramNodeShape, DiagramTheme } from "./diagram";
import type { ArchitectureResourceIcon, DiagramDocument, DiagramNodeShape, DiagramTheme } from "./diagram";
export type DiagramAppearance = "light" | "dark";
@@ -47,6 +47,32 @@ const architectureAccent: Partial<Record<DiagramNodeShape, string>> = {
external: "#64748B",
};
// Native X6 viewers do not bundle React icon components. These compact,
// monochrome glyphs preserve each resource's visual identity in that portable
// projection while the Web editor renders the matching Lucide pictogram.
const architectureResourceGlyphs: Record<ArchitectureResourceIcon, string> = {
client: "▣", webApp: "▤", mobileApp: "▯", website: "◎", apiClient: "</>",
service: "▤", virtualMachine: "⚙", container: "⬡", kubernetes: "⌘", serverless: "ƒ",
relationalDatabase: "◉", noSqlDatabase: "ϟ", cache: "▱", dataWarehouse: "▥", searchEngine: "⌕",
objectStorage: "☁", fileStorage: "▧", blockStorage: "▰", backup: "↶", cdn: "⇧",
messageQueue: "≡", eventBus: "⑂", streamProcessing: "≋", webhook: "⌁", serviceMesh: "⋮",
apiGateway: "⇄", loadBalancer: "↔", dns: "◎", vpc: "◇", subnet: "⊞", vpn: "⌁",
identity: "◆", firewall: "▦", waf: "✓", secretManager: "⌑", certificate: "◈", systemBoundary: "□",
monitoring: "◴", logging: "▤", metrics: "↗", tracing: "∿", alerting: "!",
saas: "☁", externalApi: "⌁", thirdPartyService: "ϟ",
};
const architectureShapeGlyphs: Partial<Record<DiagramNodeShape, string>> = {
client: "▣",
frontend: "▤",
service: "▥",
database: "◉",
storage: "▰",
queue: "≡",
security: "✓",
external: "☁",
};
/** Plain X6 metadata shared by native WebView viewers. */
export const diagramDocumentToX6Cells = (
document: DiagramDocument,
@@ -65,6 +91,10 @@ export const diagramDocumentToX6Cells = (
? (appearance === "dark" ? palette.nodeFill : `${accent}12`)
: emphasized ? palette.topicFill : palette.nodeFill;
const stroke = isBoundary ? palette.nodeStroke : accent ?? (emphasized ? palette.topicStroke : palette.nodeStroke);
const usesArchitectureIcon = document.kind === "architecture" && !isBoundary;
const iconGlyph = node.resourceIcon
? architectureResourceGlyphs[node.resourceIcon]
: architectureShapeGlyphs[node.shape];
return {
id: node.id,
shape: node.shape === "decision" ? "polygon" : "rect",
@@ -73,6 +103,12 @@ export const diagramDocumentToX6Cells = (
width: node.width,
height: node.height,
zIndex: isBoundary ? 0 : 2,
...(usesArchitectureIcon ? { markup: [
{ tagName: "rect", selector: "body" },
{ tagName: "rect", selector: "iconFrame" },
{ tagName: "text", selector: "resourceIcon" },
{ tagName: "text", selector: "label" },
] } : {}),
attrs: {
body: {
fill,
@@ -89,7 +125,30 @@ export const diagramDocumentToX6Cells = (
fontSize: node.shape === "topic" ? 14 : isBoundary ? 12 : 13,
fontWeight: emphasized || isBoundary || accent ? 650 : 500,
...(isBoundary ? { refX: 18, refY: 22, textAnchor: "start", textVerticalAnchor: "middle" } : {}),
...(usesArchitectureIcon ? { refX: 54, refY: "50%", textAnchor: "start", textVerticalAnchor: "middle" } : {}),
},
...(usesArchitectureIcon ? {
iconFrame: {
x: 10,
y: Math.round((node.height - 34) / 2),
width: 34,
height: 34,
rx: node.shape === "database" ? 17 : node.shape === "security" ? 12 : 8,
ry: node.shape === "database" ? 17 : node.shape === "security" ? 12 : 8,
fill: appearance === "dark" ? `${accent}30` : `${accent}18`,
stroke: "none",
},
resourceIcon: {
text: iconGlyph,
x: 27,
y: node.height / 2,
fill: accent,
fontSize: iconGlyph === "</>" ? 10 : 17,
fontWeight: 700,
textAnchor: "middle",
textVerticalAnchor: "middle",
},
} : {}),
},
};
});
+18
View File
@@ -82,6 +82,7 @@ describe("diagram document", () => {
test("round-trips architecture components, boundaries, and semantic connections", () => {
const document = createDefaultDiagramDocument("architecture");
document.nodes.find((node) => node.id === "api").resourceIcon = "container";
const parsed = parseDiagramDocument(serializeDiagramDocument(document));
expect(parsed).toEqual(document);
expect(parsed.schemaVersion).toBe(2);
@@ -97,6 +98,23 @@ describe("diagram document", () => {
expect(fallback).toContain("classDef archDatabase");
});
test("keeps legacy architecture nodes valid and projects resource-specific icons", () => {
const legacy = createDefaultDiagramDocument("architecture");
expect(legacy.nodes.every((node) => node.resourceIcon === undefined)).toBe(true);
expect(parseDiagramDocument(serializeDiagramDocument(legacy))).toEqual(legacy);
const container = legacy.nodes.find((node) => node.id === "api");
const database = legacy.nodes.find((node) => node.id === "database");
container.resourceIcon = "container";
database.resourceIcon = "noSqlDatabase";
const projected = diagramDocumentToX6Cells(legacy, "light");
const containerCell = projected.nodes.find((node) => node.id === "api");
const databaseCell = projected.nodes.find((node) => node.id === "database");
expect(containerCell.attrs.resourceIcon.text).toBe("⬡");
expect(databaseCell.attrs.resourceIcon.text).toBe("ϟ");
expect(containerCell.attrs.resourceIcon.text).not.toBe(databaseCell.attrs.resourceIcon.text);
});
test("rejects malformed and dangling graph data", () => {
expect(parseDiagramDocument("ordinary note")).toBeNull();
const document = createDefaultDiagramDocument("flowchart");
+19
View File
@@ -21,6 +21,20 @@ export type DiagramNodeShape =
export type DiagramEdgeKind = "dependency" | "request" | "async" | "data";
export type DiagramTheme = "brand" | "ocean" | "ink";
export const ARCHITECTURE_RESOURCE_ICONS = [
"client", "webApp", "mobileApp", "website", "apiClient",
"service", "virtualMachine", "container", "kubernetes", "serverless",
"relationalDatabase", "noSqlDatabase", "cache", "dataWarehouse", "searchEngine",
"objectStorage", "fileStorage", "blockStorage", "backup", "cdn",
"messageQueue", "eventBus", "streamProcessing", "webhook", "serviceMesh",
"apiGateway", "loadBalancer", "dns", "vpc", "subnet", "vpn",
"identity", "firewall", "waf", "secretManager", "certificate", "systemBoundary",
"monitoring", "logging", "metrics", "tracing", "alerting",
"saas", "externalApi", "thirdPartyService",
] as const;
export type ArchitectureResourceIcon = typeof ARCHITECTURE_RESOURCE_ICONS[number];
export type DiagramNode = {
id: string;
label: string;
@@ -30,6 +44,7 @@ export type DiagramNode = {
height: number;
shape: DiagramNodeShape;
parentId?: string;
resourceIcon?: ArchitectureResourceIcon;
};
export type DiagramEdge = {
@@ -80,6 +95,9 @@ const DIAGRAM_NODE_SHAPES: DiagramNodeShape[] = [
];
const DIAGRAM_EDGE_KINDS: DiagramEdgeKind[] = ["dependency", "request", "async", "data"];
const isArchitectureResourceIcon = (value: unknown): value is ArchitectureResourceIcon =>
typeof value === "string" && ARCHITECTURE_RESOURCE_ICONS.includes(value as ArchitectureResourceIcon);
const parseNode = (value: unknown): DiagramNode | null => {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const node = value as Record<string, unknown>;
@@ -98,6 +116,7 @@ const parseNode = (value: unknown): DiagramNode | null => {
height: node.height,
shape: node.shape as DiagramNodeShape,
...(typeof node.parentId === "string" && node.parentId ? { parentId: node.parentId } : {}),
...(isArchitectureResourceIcon(node.resourceIcon) ? { resourceIcon: node.resourceIcon } : {}),
};
};
+59
View File
@@ -266,6 +266,65 @@ export const enUS = {
addExternal: "Add external system",
addBoundary: "Add system boundary",
components: "Components",
componentLibrary: "Shape library",
componentSearch: "Search architecture components",
componentCategoryExperience: "Apps & clients",
componentCategoryServices: "Compute & services",
componentCategoryDatabases: "Databases",
componentCategoryStorage: "Storage & CDN",
componentCategoryMiddleware: "Internet middleware",
componentCategoryNetwork: "Network",
componentCategorySecurity: "Security & boundaries",
componentCategoryObservability: "Management & monitoring",
componentCategoryExternal: "External services",
noMatchingComponents: "No matching architecture components",
architectureResources: {
client: "Client",
webApp: "Web app",
mobileApp: "Mobile app",
website: "Website",
apiClient: "API client",
service: "Application service",
virtualMachine: "Virtual machine",
container: "Container",
kubernetes: "Kubernetes",
serverless: "Serverless function",
relationalDatabase: "Relational database",
noSqlDatabase: "NoSQL database",
cache: "Cache",
dataWarehouse: "Data warehouse",
searchEngine: "Search engine",
objectStorage: "Object storage",
fileStorage: "File storage",
blockStorage: "Block storage",
backup: "Backup",
cdn: "CDN",
messageQueue: "Message queue",
eventBus: "Event bus",
streamProcessing: "Stream processing",
webhook: "Webhook",
serviceMesh: "Service mesh",
apiGateway: "API gateway",
loadBalancer: "Load balancer",
dns: "DNS",
vpc: "Virtual private cloud",
subnet: "Subnet",
vpn: "VPN",
identity: "Identity and access",
firewall: "Firewall",
waf: "Web application firewall",
secretManager: "Secret manager",
certificate: "Certificate",
systemBoundary: "System boundary",
monitoring: "Monitoring",
logging: "Logging",
metrics: "Metrics",
tracing: "Distributed tracing",
alerting: "Alerting",
saas: "SaaS service",
externalApi: "External API",
thirdPartyService: "Third-party service",
},
architectureConnectHint: "Drag a handle to connect architecture components",
connectHint: "Drag a handle to connect, or drop on canvas to create the next node",
newTopic: "New topic",
+59
View File
@@ -266,6 +266,65 @@ export const zhCN = {
addExternal: "添加外部系统",
addBoundary: "添加系统边界",
components: "组件",
componentLibrary: "图形库",
componentSearch: "搜索架构组件",
componentCategoryExperience: "应用与客户端",
componentCategoryServices: "计算与服务",
componentCategoryDatabases: "数据库",
componentCategoryStorage: "存储与 CDN",
componentCategoryMiddleware: "互联网中间件",
componentCategoryNetwork: "网络",
componentCategorySecurity: "安全与边界",
componentCategoryObservability: "管理与监控",
componentCategoryExternal: "外部服务",
noMatchingComponents: "没有匹配的架构组件",
architectureResources: {
client: "客户端",
webApp: "Web 应用",
mobileApp: "移动应用",
website: "网站",
apiClient: "API 客户端",
service: "应用服务",
virtualMachine: "虚拟机",
container: "容器",
kubernetes: "Kubernetes",
serverless: "无服务器函数",
relationalDatabase: "关系型数据库",
noSqlDatabase: "NoSQL 数据库",
cache: "缓存",
dataWarehouse: "数据仓库",
searchEngine: "搜索引擎",
objectStorage: "对象存储",
fileStorage: "文件存储",
blockStorage: "块存储",
backup: "备份",
cdn: "CDN",
messageQueue: "消息队列",
eventBus: "事件总线",
streamProcessing: "流处理",
webhook: "Webhook",
serviceMesh: "服务网格",
apiGateway: "API 网关",
loadBalancer: "负载均衡",
dns: "DNS",
vpc: "虚拟私有云",
subnet: "子网",
vpn: "VPN",
identity: "身份与访问管理",
firewall: "防火墙",
waf: "Web 应用防火墙",
secretManager: "密钥管理",
certificate: "证书",
systemBoundary: "系统边界",
monitoring: "监控",
logging: "日志",
metrics: "指标",
tracing: "链路追踪",
alerting: "告警",
saas: "SaaS 服务",
externalApi: "外部 API",
thirdPartyService: "第三方服务",
},
architectureConnectHint: "拖动端点连接架构组件",
connectHint: "拖动端点连线;松开在空白处可创建后续节点",
newTopic: "新主题",