From ce72f71a9dfe4661bb8bfd6bf80470ce0f061efe Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 6 Sep 2026 16:44:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=B0=E5=AF=8C=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=9B=BE=E8=B5=84=E6=BA=90=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DiagramEditorPane.test.mjs | 31 +- apps/web/src/components/DiagramEditorPane.tsx | 377 ++++++++++++++++-- packages/shared/src/diagram-view.ts | 61 ++- packages/shared/src/diagram.test.mjs | 18 + packages/shared/src/diagram.ts | 19 + packages/shared/src/i18n/en-US.ts | 59 +++ packages/shared/src/i18n/zh-CN.ts | 59 +++ 7 files changed, 585 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 33896917..db41885a 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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(""); + expect(source).toContain("category.items.filter"); + expect(source).toContain('className="grid grid-cols-7 gap-1 px-1 pb-2"'); + expect(source).toContain(''); + 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'); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 6a0fa51c..ce412d9e 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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; + +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 ( + { + setOpen(nextOpen); + if (!nextOpen) setQuery(""); + }}> + + + + +
+
+ + setQuery(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + /> +
+
+
+ {categories.length > 0 ? categories.map((category) => ( + + event.preventDefault()}> + + + {t(category.labelKey)} + {category.items.length} + + + +
+ {category.items.map((item) => { + const Icon = item.icon; + const label = t(item.labelKey); + return ( + + + { + onAdd(item); + setOpen(false); + }} + > + + + + + {label} + + + ); + })} +
+
+
+ )) : ( +
{t("diagram.noMatchingComponents")}
+ )} +
+
+
+ ); +}; + 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, ref: null) => { + props: { iconNode: Array<[string, Record]> }; + }; + }).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()?.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 = ({ {t("diagram.architectureShortcuts")} - - - - - - addNode("client")}>{t("diagram.addClient")} - addNode("frontend")}>{t("diagram.addFrontend")} - addNode("database")}>{t("diagram.addDatabase")} - addNode("storage")}>{t("diagram.addStorage")} - addNode("queue")}>{t("diagram.addQueue")} - addNode("security")}>{t("diagram.addSecurity")} - addNode("external")}>{t("diagram.addExternal")} - - addNode("boundary")}>{t("diagram.addBoundary")} - - + addNode(item.shape, { + label: t(item.labelKey), + resourceIcon: architectureResourceIcon(item), + })} + t={t} + /> {t("diagram.architectureConnectHint")} ) : ( diff --git a/packages/shared/src/diagram-view.ts b/packages/shared/src/diagram-view.ts index 6edf1c37..c8db3e96 100644 --- a/packages/shared/src/diagram-view.ts +++ b/packages/shared/src/diagram-view.ts @@ -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> = { 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 = { + 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> = { + 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", + }, + } : {}), }, }; }); diff --git a/packages/shared/src/diagram.test.mjs b/packages/shared/src/diagram.test.mjs index 56ed12e1..e9c37072 100644 --- a/packages/shared/src/diagram.test.mjs +++ b/packages/shared/src/diagram.test.mjs @@ -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"); diff --git a/packages/shared/src/diagram.ts b/packages/shared/src/diagram.ts index c1110f85..a448a468 100644 --- a/packages/shared/src/diagram.ts +++ b/packages/shared/src/diagram.ts @@ -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; @@ -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 } : {}), }; }; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index dee6e6e0..e96aed5b 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index c5c4acd7..b63975da 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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: "新主题",