From 6b22ba532f387564740d86f18ddba1bb3587ea65 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Wed, 20 Aug 2025 14:38:30 +0300 Subject: [PATCH 01/16] feat: migrate to gravity-ui/graph --- package-lock.json | 68 +++++++++ package.json | 3 + src/components/Graph/Graph.tsx | 2 + src/components/Graph/GravityGraph.tsx | 140 ++++++++++++++++++ .../Query/QueryResult/QueryResultViewer.tsx | 2 + .../QueryResult/components/Graph/Graph.tsx | 5 +- src/store/reducers/query/prepareQueryData.ts | 2 + 7 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/components/Graph/GravityGraph.tsx diff --git a/package-lock.json b/package-lock.json index 6c093e2d04..d864b34f93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@gravity-ui/components": "^4.4.0", "@gravity-ui/date-components": "^3.2.3", "@gravity-ui/date-utils": "^2.5.6", + "@gravity-ui/graph": "^1.1.4", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.13.0", "@gravity-ui/illustrations": "^2.1.0", @@ -35,6 +36,7 @@ "colord": "^2.9.3", "copy-to-clipboard": "^3.3.3", "crc-32": "^1.2.2", + "elkjs": "^0.10.0", "history": "^4.10.1", "hotkeys-js": "^3.13.9", "lodash": "^4.17.21", @@ -57,6 +59,7 @@ "use-query-params": "^2.2.1", "uuid": "^10.0.0", "web-vitals": "^1.1.2", + "web-worker": "^1.5.0", "ydb-ui-components": "^5.0.0", "zod": "^3.24.1" }, @@ -3423,6 +3426,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@gravity-ui/graph": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@gravity-ui/graph/-/graph-1.1.4.tgz", + "integrity": "sha512-gZsFFouYo371UZ88/ui9jsx25hBpoILxNDE0MGVZLgGh9WjVWFOnjT3T6YwrYwq+mFrGUo6W86lEQgdpI4NTCw==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.5.1", + "intersects": "^2.7.2", + "lodash-es": "^4.17.21", + "rbush": "^3.0.1" + }, + "engines": { + "pnpm": "Please use npm instead of pnpm to install dependencies", + "yarn": "Please use npm instead of yarn to install dependencies" + } + }, "node_modules/@gravity-ui/i18n": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@gravity-ui/i18n/-/i18n-1.8.0.tgz", @@ -5088,6 +5107,16 @@ "node": ">= 8" } }, + "node_modules/@preact/signals-core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.0.tgz", + "integrity": "sha512-etWpENXm469RHMWIZGblgWrapbIGcRcbccEGGaLkFez3PjlI3XkBrUtSiNFsIfV/DN16PxMOxbWAZUIaLFyJDg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", @@ -10952,6 +10981,12 @@ "integrity": "sha512-SFsAz1hoR+u1eAWjofSPQnx0InE1QHGUAQ92pqYJPT8GARzmyP1zcEBDBxFFC6okJk2E94Ryfmib4DB8Sc6LBw==", "dev": true }, + "node_modules/elkjs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.0.tgz", + "integrity": "sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==", + "license": "EPL-2.0" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -14120,6 +14155,12 @@ "node": ">=12" } }, + "node_modules/intersects": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/intersects/-/intersects-2.7.2.tgz", + "integrity": "sha512-/LtLDq40iFtvnjhouev9p2R+jP+raVONPiD1t8Mcj879pkrLiav99BTRPBkfMPwSYr5vTNws3USGoW+8usS45A==", + "license": "MIT" + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -18170,6 +18211,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -22032,6 +22079,12 @@ } ] }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -22091,6 +22144,15 @@ "node": ">=0.10.0" } }, + "node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "license": "MIT", + "dependencies": { + "quickselect": "^2.0.0" + } + }, "node_modules/rc-slider": { "version": "11.1.8", "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.8.tgz", @@ -28953,6 +29015,12 @@ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.2.tgz", "integrity": "sha512-PFMKIY+bRSXlMxVAQ+m2aw9c/ioUYfDgrYot0YUa+/xa0sakubWhSDyxAKwzymvXVdF4CZI71g06W+mqhzu6ig==" }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 528b1c1905..aa6e74dc03 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@gravity-ui/components": "^4.4.0", "@gravity-ui/date-components": "^3.2.3", "@gravity-ui/date-utils": "^2.5.6", + "@gravity-ui/graph": "^1.1.4", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.13.0", "@gravity-ui/illustrations": "^2.1.0", @@ -37,6 +38,7 @@ "colord": "^2.9.3", "copy-to-clipboard": "^3.3.3", "crc-32": "^1.2.2", + "elkjs": "^0.10.0", "history": "^4.10.1", "hotkeys-js": "^3.13.9", "lodash": "^4.17.21", @@ -59,6 +61,7 @@ "use-query-params": "^2.2.1", "uuid": "^10.0.0", "web-vitals": "^1.1.2", + "web-worker": "^1.5.0", "ydb-ui-components": "^5.0.0", "zod": "^3.24.1" }, diff --git a/src/components/Graph/Graph.tsx b/src/components/Graph/Graph.tsx index 33ba32d68b..c1e6f6ba74 100644 --- a/src/components/Graph/Graph.tsx +++ b/src/components/Graph/Graph.tsx @@ -10,6 +10,8 @@ interface GraphProps { } export function Graph(props: GraphProps) { + console.log(props); + const containerRef = React.useRef(null); const containerId = React.useId(); diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx new file mode 100644 index 0000000000..0a8ac182a4 --- /dev/null +++ b/src/components/Graph/GravityGraph.tsx @@ -0,0 +1,140 @@ +import React, { useEffect } from 'react'; +import { Graph, TGraphConfig, GraphState } from "@gravity-ui/graph"; +import { GraphCanvas, GraphBlock, useGraph, useElk, TBlock, TConnection, useGraphEvent, MultipointConnection } from "@gravity-ui/graph/react"; +import ELK, { ElkNode, ElkExtendedEdge } from 'elkjs'; + +import type { Data, GraphNode, Options, Shapes } from '@gravity-ui/paranoid'; + +interface Props { + data: Data; +} + +const config = { + settings: { + connection: MultipointConnection + } +}; +const elk = new ELK(); + +const renderBlockFn = (graph, block) => { + return {block.id}; +}; + +const prepareChildren = (blocks: TGraphConfig["blocks"]) => { + return blocks.map((b) => { + return { + id: b.id as string, + width: b.width, + height: b.height, + } satisfies ElkNode; + }); +}; + +const prepareEdges = (connections: TGraphConfig["connections"], skipLabels?: boolean) => { + return connections.map((c, i) => { + const labelText = `label ${i}`; + + return { + id: c.id as string, + sources: [c.sourceBlockId as string], + targets: [c.targetBlockId as string], + // labels: skipLabels + // ? [] + // : [{ text: labelText, width: measureText(labelText, `${FONT_SIZE}px sans-serif`), height: FONT_SIZE }], + } satisfies ElkExtendedEdge; + }); +}; + + + +const _blocks = [ + { + width: 200, + height: 160, + id: "Left", + is: "Block", + selected: false, + name: "Left block", + anchors: [], + }, + { + width: 200, + height: 160, + id: "Right", + is: "Block", + selected: false, + name: "Right block", + anchors: [], + } +] + +const _connections = [ + { + id: "c1", + sourceBlockId: "Left", + targetBlockId: "Right", + } +] + +const elkConfig = { + id: "root", + children: prepareChildren(_blocks), + edges: prepareEdges(_connections), + layoutOptions: { + 'elk.algorithm': 'mrtree', + 'elk.direction': 'DOWN', + 'elk.spacing.edgeNode': '50', + 'elk.spacing.nodeNode': '50' + } +}; + +export function GravityGraph({ data }: Props) { + console.log(data); + + const { graph, start } = useGraph(config); + const { isLoading, result } = useElk(elkConfig, elk); + + React.useEffect(() => { + + if (isLoading || !result) { + return; + } + + console.log('result', result); + + + const blocks = _blocks.map((block) => { + return { + ...block, + ...result.blocks[block.id], + }; + }); + + const connections = _connections.reduce((acc, connection) => { + if (connection.id in result.edges) { + acc.push({ + ...connection, + ...result.edges[connection.id], + }); + } + return acc; + }, []); + + console.log('connections', connections); + + + graph.setEntities({ + blocks, + connections, + }); + }, [isLoading, result, graph]); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + console.log('start') + start(); + } + }); + + return +} diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx index 33e14be764..3d74249c12 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx @@ -272,6 +272,8 @@ export function QueryResultViewer({ if (!preparedPlan?.nodes?.length) { return renderStubMessage(); } + console.log(preparedPlan); + return ; } if (activeSection === RESULT_OPTIONS_IDS.json) { diff --git a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx index a4d7750e52..76298cc6ed 100644 --- a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type {Data} from '@gravity-ui/paranoid'; import {YDBGraph} from '../../../../../../components/Graph/Graph'; +import {GravityGraph} from '../../../../../../components/Graph/GravityGraph'; import type {PreparedPlan} from '../../../../../../store/reducers/query/types'; import {cn} from '../../../../../../utils/cn'; import i18n from '../../i18n'; @@ -22,6 +23,8 @@ function isValidGraphData(data: Partial): data is Data { } export function Graph({explain = {}, theme}: GraphProps) { + console.log('explain', explain); + const {links, nodes} = explain; const data = React.useMemo(() => ({links, nodes}), [links, nodes]); @@ -32,7 +35,7 @@ export function Graph({explain = {}, theme}: GraphProps) { return (
- + {(true) ? : }
); } diff --git a/src/store/reducers/query/prepareQueryData.ts b/src/store/reducers/query/prepareQueryData.ts index 3c7d6ec39c..a7d562f3e0 100644 --- a/src/store/reducers/query/prepareQueryData.ts +++ b/src/store/reducers/query/prepareQueryData.ts @@ -10,7 +10,9 @@ export function prepareQueryData( const result = parseQueryAPIResponse(response); const {plan: rawPlan, stats} = result; + const {simplifiedPlan, ...planData} = preparePlanData(rawPlan, stats); + console.log('prepareQueryData', rawPlan, planData); return { ...result, preparedPlan: Object.keys(planData).length > 0 ? planData : undefined, From 598aed43ac8bd7646ad6beede4b775fb998db9ef Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Wed, 20 Aug 2025 18:33:18 +0300 Subject: [PATCH 02/16] wip --- src/components/Graph/GravityGraph.tsx | 170 +++++++++++++------------- 1 file changed, 84 insertions(+), 86 deletions(-) diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index 0a8ac182a4..8509a7e872 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -1,9 +1,21 @@ -import React, { useEffect } from 'react'; -import { Graph, TGraphConfig, GraphState } from "@gravity-ui/graph"; -import { GraphCanvas, GraphBlock, useGraph, useElk, TBlock, TConnection, useGraphEvent, MultipointConnection } from "@gravity-ui/graph/react"; -import ELK, { ElkNode, ElkExtendedEdge } from 'elkjs'; - -import type { Data, GraphNode, Options, Shapes } from '@gravity-ui/paranoid'; +import React, {useEffect, useMemo} from 'react'; + +import type {TBlock, TGraphConfig} from '@gravity-ui/graph'; +import {Graph, GraphState} from '@gravity-ui/graph'; +import { + GraphBlock, + GraphCanvas, + MultipointConnection, + TConnection, + useElk, + useGraph, + useGraphEvent, +} from '@gravity-ui/graph/react'; +import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; +import type {ElkExtendedEdge, ElkNode} from 'elkjs'; +import ELK from 'elkjs'; + +import {prepareBlocks, prepareChildren, prepareConnections, prepareEdges} from './utils'; interface Props { data: Data; @@ -11,97 +23,84 @@ interface Props { const config = { settings: { - connection: MultipointConnection - } + connection: MultipointConnection, + }, }; const elk = new ELK(); const renderBlockFn = (graph, block) => { - return {block.id}; -}; - -const prepareChildren = (blocks: TGraphConfig["blocks"]) => { - return blocks.map((b) => { - return { - id: b.id as string, - width: b.width, - height: b.height, - } satisfies ElkNode; - }); -}; - -const prepareEdges = (connections: TGraphConfig["connections"], skipLabels?: boolean) => { - return connections.map((c, i) => { - const labelText = `label ${i}`; - - return { - id: c.id as string, - sources: [c.sourceBlockId as string], - targets: [c.targetBlockId as string], - // labels: skipLabels - // ? [] - // : [{ text: labelText, width: measureText(labelText, `${FONT_SIZE}px sans-serif`), height: FONT_SIZE }], - } satisfies ElkExtendedEdge; - }); + return ( + + {block.id} + + ); }; - - -const _blocks = [ - { - width: 200, - height: 160, - id: "Left", - is: "Block", - selected: false, - name: "Left block", - anchors: [], - }, - { - width: 200, - height: 160, - id: "Right", - is: "Block", - selected: false, - name: "Right block", - anchors: [], - } -] - -const _connections = [ - { - id: "c1", - sourceBlockId: "Left", - targetBlockId: "Right", - } -] - -const elkConfig = { - id: "root", - children: prepareChildren(_blocks), - edges: prepareEdges(_connections), +// const _blocks: TBlock[] = [ +// { +// width: 200, +// height: 160, +// id: 'Left', +// is: 'block-action', +// selected: false, +// name: 'Left block', +// anchors: [], +// }, +// { +// width: 200, +// height: 160, +// id: 'Right', +// is: 'block-action', +// selected: false, +// name: 'Right block', +// anchors: [], +// }, +// ]; + +// const _connections = [ +// { +// id: 'c1', +// sourceBlockId: 'Left', +// targetBlockId: 'Right', +// }, +// ]; + +const baseElkConfig = { + id: 'root', layoutOptions: { - 'elk.algorithm': 'mrtree', + 'elk.algorithm': 'layered', 'elk.direction': 'DOWN', - 'elk.spacing.edgeNode': '50', - 'elk.spacing.nodeNode': '50' - } + // 'elk.spacing.edgeNode': '50', + 'elk.layered.spacing.nodeNodeBetweenLayers': '50', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', + 'elk.layered.nodePlacement.bk.ordering': 'INTERACTIVE', + 'elk.debugMode': true + // 'elk.alignment': 'CENTER' + }, }; -export function GravityGraph({ data }: Props) { - console.log(data); - - const { graph, start } = useGraph(config); - const { isLoading, result } = useElk(elkConfig, elk); +export function GravityGraph({data}: Props) { + // console.log('997', data); + + const _blocks = useMemo(() => prepareBlocks(data.nodes), [data.nodes]); + const _connections = useMemo(() => prepareConnections(data.links), [data.links]); + const elkConfig = useMemo( + () => ({ + ...baseElkConfig, + children: prepareChildren(_blocks), + edges: prepareEdges(_connections), + }), + [_blocks, _connections], + ); + const {graph, start} = useGraph(config); + const {isLoading, result} = useElk(elkConfig, elk); React.useEffect(() => { - if (isLoading || !result) { return; } - console.log('result', result); - + // console.log('result', result); const blocks = _blocks.map((block) => { return { @@ -120,8 +119,7 @@ export function GravityGraph({ data }: Props) { return acc; }, []); - console.log('connections', connections); - + // console.log('connections', connections); graph.setEntities({ blocks, @@ -129,12 +127,12 @@ export function GravityGraph({ data }: Props) { }); }, [isLoading, result, graph]); - useGraphEvent(graph, "state-change", ({ state }) => { + useGraphEvent(graph, 'state-change', ({state}) => { if (state === GraphState.ATTACHED) { - console.log('start') + console.log('start'); start(); } }); - return + return ; } From 522b7a06be6494571afb65cf439791963c704312 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Wed, 20 Aug 2025 18:34:46 +0300 Subject: [PATCH 03/16] wip --- src/components/Graph/utils.ts | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/components/Graph/utils.ts diff --git a/src/components/Graph/utils.ts b/src/components/Graph/utils.ts new file mode 100644 index 0000000000..f0e9b0376a --- /dev/null +++ b/src/components/Graph/utils.ts @@ -0,0 +1,54 @@ +import type {TBlock, TConnection, TGraphConfig} from '@gravity-ui/graph'; +import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; +import type {ElkExtendedEdge, ElkNode} from 'elkjs'; + +export const prepareChildren = (blocks: TGraphConfig['blocks']) => { + return blocks?.map((b) => { + return { + id: b.id as string, + width: b.width, + height: b.height, + ports: [ + { + id: `port_${b.id as string}`, + }, + ], + // properties: { + // 'elk.portConstraints': 'FIXED_ORDER', + // // 'elk.spacing.portPort': '0', + // }, + } satisfies ElkNode; + }); +}; + +export const prepareEdges = (connections: TGraphConfig['connections'], skipLabels?: boolean) => { + return connections?.map((c, i) => { + const labelText = `label ${i}`; + + return { + id: c.id as string, + sources: [`port_${c.sourceBlockId as string}`], + // sources: [c.sourceBlockId as string], + targets: [c.targetBlockId as string], + // labels: skipLabels ? [] : [{text: labelText, width: 50, height: 14}], + } satisfies ElkExtendedEdge; + }); +}; + +export const prepareBlocks = (nodes: Data['nodes']): TBlock[] => { + return nodes?.map(({data: {id, name, ...rest}}) => ({ + id: String(id), + name, + width: 200, + height: 100, + ...rest, + })); +}; + +export const prepareConnections = (links: Data['links']): TConnection[] => { + return links?.map(({from, to}) => ({ + id: `${from}:${to}`, + sourceBlockId: from, + targetBlockId: to, + })); +}; From 2c4cf395d73c6d24ae900beea1560aad607ee7c2 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Tue, 26 Aug 2025 14:06:40 +0300 Subject: [PATCH 04/16] tmp --- .../Graph/BlockComponents/QueryBlockView.ts | 29 +++++ src/components/Graph/GravityGraph.tsx | 30 ++++- src/components/Graph/colorsConfig.ts | 121 ++++++++++++++++++ src/components/Graph/utils.ts | 43 +++++++ .../QueryResult/components/Graph/Graph.tsx | 6 +- 5 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/components/Graph/BlockComponents/QueryBlockView.ts create mode 100644 src/components/Graph/colorsConfig.ts diff --git a/src/components/Graph/BlockComponents/QueryBlockView.ts b/src/components/Graph/BlockComponents/QueryBlockView.ts new file mode 100644 index 0000000000..e03b5cf62d --- /dev/null +++ b/src/components/Graph/BlockComponents/QueryBlockView.ts @@ -0,0 +1,29 @@ +import {Graph, GraphState, CanvasBlock} from '@gravity-ui/graph'; + +export class QueryBlockView extends CanvasBlock { + protected renderStroke(color: string) { + this.context.ctx.lineWidth = Math.round(3 / this.context.camera.getCameraScale()); + this.context.ctx.strokeStyle = color; + this.context.ctx.stroke(); + } + + public override renderSchematicView(ctx: CanvasRenderingContext2D) { + // Draw circle with shadow + this.context.ctx.save(); + this.context.ctx.shadowOffsetX = 1; + this.context.ctx.shadowOffsetY = 1; + this.context.ctx.shadowBlur = 5; + this.context.ctx.shadowColor = 'rgba(0,0,0,0.15)'; + this.context.ctx.fillStyle = this.context.colors.block?.background; + this.context.ctx.beginPath(); + const centerX = this.state.x + this.state.width / 2; + const centerY = this.state.y + this.state.height / 2; + this.context.ctx.arc(centerX, centerY, 20, 0, Math.PI * 2); + this.context.ctx.fill(); + this.context.ctx.restore(); + + this.context.ctx.restore(); + + this.context.ctx.globalAlpha = 1; + } +} diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index 8509a7e872..b92cd2ec3d 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useMemo} from 'react'; import type {TBlock, TGraphConfig} from '@gravity-ui/graph'; -import {Graph, GraphState} from '@gravity-ui/graph'; +import {Graph, GraphState, CanvasBlock} from '@gravity-ui/graph'; import { GraphBlock, GraphCanvas, @@ -15,15 +15,32 @@ import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; import type {ElkExtendedEdge, ElkNode} from 'elkjs'; import ELK from 'elkjs'; -import {prepareBlocks, prepareChildren, prepareConnections, prepareEdges} from './utils'; +import { + prepareBlocks, + prepareChildren, + prepareConnections, + prepareEdges, + parseCustomPropertyValue, +} from './utils'; + +import {QueryBlockView} from './BlockComponents/QueryBlockView'; +import {graphColorsConfig} from './colorsConfig'; interface Props { data: Data; + theme?: string; } const config = { settings: { connection: MultipointConnection, + blockComponents: { + Query: QueryBlockView, + }, + // canDragCamera: true, + // canZoomCamera: false, + // useBezierConnections: false, + showConnectionArrows: false, }, }; const elk = new ELK(); @@ -74,12 +91,12 @@ const baseElkConfig = { 'elk.layered.spacing.nodeNodeBetweenLayers': '50', 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', 'elk.layered.nodePlacement.bk.ordering': 'INTERACTIVE', - 'elk.debugMode': true + 'elk.debugMode': true, // 'elk.alignment': 'CENTER' }, }; -export function GravityGraph({data}: Props) { +export function GravityGraph({data, theme}: Props) { // console.log('997', data); const _blocks = useMemo(() => prepareBlocks(data.nodes), [data.nodes]); @@ -127,10 +144,15 @@ export function GravityGraph({data}: Props) { }); }, [isLoading, result, graph]); + React.useEffect(() => { + graph.setColors(parseCustomPropertyValue(graphColorsConfig, graph.getGraphCanvas())); + }, [graph, theme]); + useGraphEvent(graph, 'state-change', ({state}) => { if (state === GraphState.ATTACHED) { console.log('start'); start(); + // graph.zoomTo("center", { padding: 300 }); } }); diff --git a/src/components/Graph/colorsConfig.ts b/src/components/Graph/colorsConfig.ts new file mode 100644 index 0000000000..8513c84973 --- /dev/null +++ b/src/components/Graph/colorsConfig.ts @@ -0,0 +1,121 @@ +export type AbstractGraphColorsConfig = Partial>>>; + +export const graphColorsConfig = { + // Default @gravity-ui/graph colors + + canvas: { + belowLayerBackground: '#0000', + border: '#0000', + dots: 'var(--g-color-line-generic)', + layerBackground: 'var(--g-color-base-background)', + }, + block: { + text: 'var(--g-color-text-primary)', + background: 'var(--g-color-base-float)', + border: '#dfdfdf', + }, + connection: { + background: 'var(--g-color-line-generic-solid)', + selectedBackground: 'var(--g-color-line-positive)', + }, + + // Gravity-UI Colors + + textsMain: { + primary: 'var(--g-color-text-primary)', + complementary: 'var(--g-color-text-complementary)', + secondary: 'var(--g-color-text-secondary)', + hint: 'var(--g-color-text-hint)', + }, + textsSemantic: { + info: 'var(--g-color-text-info)', + infoHeavy: 'var(--g-color-text-info-heavy)', + positive: 'var(--g-color-text-positive)', + positiveHeavy: 'var(--g-color-text-positive-heavy)', + warning: 'var(--g-color-text-warning)', + warningHeavy: 'var(--g-color-text-warning-heavy)', + danger: 'var(--g-color-text-danger)', + dangerHeavy: 'var(--g-color-text-danger-heavy)', + utility: 'var(--g-color-text-utility)', + utilityHeavy: 'var(--g-color-text-utility-heavy)', + misc: 'var(--g-color-text-misc)', + miscHeavy: 'var(--g-color-text-misc-heavy)', + }, + backgroundsBasic: { + background: 'var(--g-color-base-background)', + generic: 'var(--g-color-base-generic)', + genericHover: 'var(--g-color-base-generic-hover)', + medium: 'var(--g-color-base-medium)', + mediumHover: 'var(--g-color-base-medium-hover)', + simple: 'var(--g-color-base-simple)', + simpleHover: 'var(--g-color-base-simple-hover)', + }, + backgroundsFloats: { + float: 'var(--g-color-base-float)', + floatHover: 'var(--g-color-base-float-hover)', + floatMedium: 'var(--g-color-base-float-medium)', + floatHeavy: 'var(--g-color-base-float-heavy)', + }, + backgroundsSemantic: { + infoLight: 'var(--g-color-base-info-light)', + infoLightHover: 'var(--g-color-base-info-light-hover)', + positiveLight: 'var(--g-color-base-positive-light)', + positiveLightHover: 'var(--g-color-base-positive-light-hover)', + warningLight: 'var(--g-color-base-warning-light)', + warningLightHover: 'var(--g-color-base-warning-light-hover)', + dangerLight: 'var(--g-color-base-danger-light)', + dangerLightHover: 'var(--g-color-base-danger-light-hover)', + utilityLight: 'var(--g-color-base-utility-light)', + utilityLightHover: 'var(--g-color-base-utility-light-hover)', + miscLight: 'var(--g-color-base-misc-light)', + miscLightHover: 'var(--g-color-base-misc-light-hover)', + neutralLight: 'var(--g-color-base-neutral-light)', + neutralLightHover: 'var(--g-color-base-neutral-light-hover)', + + infoMedium: 'var(--g-color-base-info-medium)', + infoMediumHover: 'var(--g-color-base-info-medium-hover)', + positiveMedium: 'var(--g-color-base-positive-medium)', + positiveMediumHover: 'var(--g-color-base-positive-medium-hover)', + warningMedium: 'var(--g-color-base-warning-medium)', + warningMediumHover: 'var(--g-color-base-warning-medium-hover)', + dangerMedium: 'var(--g-color-base-danger-medium)', + dangerMediumHover: 'var(--g-color-base-danger-medium-hover)', + utilityMedium: 'var(--g-color-base-utility-medium)', + utilityMediumHover: 'var(--g-color-base-utility-medium-hover)', + miscMedium: 'var(--g-color-base-misc-medium)', + miscMediumHover: 'var(--g-color-base-misc-medium-hover)', + neutralMedium: 'var(--g-color-base-neutral-medium)', + neutralMediumHover: 'var(--g-color-base-neutral-medium-hover)', + + infoHeavy: 'var(--g-color-base-info-heavy)', + infoHeavyHover: 'var(--g-color-base-info-heavy-hover)', + positiveHeavy: 'var(--g-color-base-positive-heavy)', + positiveHeavyHover: 'var(--g-color-base-positive-heavy-hover)', + warningHeavy: 'var(--g-color-base-warning-heavy)', + warningHeavyHover: 'var(--g-color-base-warning-heavy-hover)', + dangerHeavy: 'var(--g-color-base-danger-heavy)', + dangerHeavyHover: 'var(--g-color-base-danger-heavy-hover)', + utilityHeavy: 'var(--g-color-base-utility-heavy)', + utilityHeavyHover: 'var(--g-color-base-utility-heavy-hover)', + miscHeavy: 'var(--g-color-base-misc-heavy)', + miscHeavyHover: 'var(--g-color-base-misc-heavy-hover)', + neutralHeavy: 'var(--g-color-base-neutral-heavy)', + neutralHeavyHover: 'var(--g-color-base-neutral-heavy-hover)', + }, + linesGeneral: { + generic: 'var(--g-color-line-generic)', + genericHover: 'var(--g-color-line-generic-hover)', + genericActive: 'var(--g-color-line-generic-active)', + genericAccent: 'var(--g-color-line-generic-accent)', + genericAccentHover: 'var(--g-color-line-generic-accent-hover)', + solid: 'var(--g-color-line-generic-solid)', + }, + linesSemantic: { + info: 'var(--g-color-line-info)', + positive: 'var(--g-color-line-positive)', + warning: 'var(--g-color-line-warning)', + danger: 'var(--g-color-line-danger)', + utility: 'var(--g-color-line-utility)', + misc: 'var(--g-color-line-misc)', + }, +} as const satisfies AbstractGraphColorsConfig; diff --git a/src/components/Graph/utils.ts b/src/components/Graph/utils.ts index f0e9b0376a..c3660e59db 100644 --- a/src/components/Graph/utils.ts +++ b/src/components/Graph/utils.ts @@ -1,6 +1,7 @@ import type {TBlock, TConnection, TGraphConfig} from '@gravity-ui/graph'; import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; import type {ElkExtendedEdge, ElkNode} from 'elkjs'; +import type {AbstractGraphColorsConfig} from './colorsConfig'; export const prepareChildren = (blocks: TGraphConfig['blocks']) => { return blocks?.map((b) => { @@ -38,6 +39,7 @@ export const prepareEdges = (connections: TGraphConfig['connections'], skipLabel export const prepareBlocks = (nodes: Data['nodes']): TBlock[] => { return nodes?.map(({data: {id, name, ...rest}}) => ({ id: String(id), + is: name, name, width: 200, height: 100, @@ -52,3 +54,44 @@ export const prepareConnections = (links: Data['links']): TConnection[] => { targetBlockId: to, })); }; + +function calculateCurrentCustomPropertyValue( + colorSettings: Partial>, + computedStyle: CSSStyleDeclaration, +): Partial> { + const result: Partial> = {}; + + for (const nestedKey in colorSettings) { + if (Object.prototype.hasOwnProperty.call(colorSettings, nestedKey)) { + const value = colorSettings[nestedKey]; + + if (value !== undefined) { + result[nestedKey] = value?.startsWith('var(') + ? computedStyle.getPropertyValue(value.substring(4, value.length - 1)).trim() + : value; + } + } + } + + return result; +} + +export function parseCustomPropertyValue( + colors: T, + block: HTMLElement = globalThis.document.body, +): T { + const parsed: AbstractGraphColorsConfig = {}; + const computedStyle = window.getComputedStyle(block); + + for (const topKey in colors) { + if (Object.prototype.hasOwnProperty.call(colors, topKey)) { + const nestedObj = colors[topKey]; + + if (nestedObj) { + parsed[topKey] = calculateCurrentCustomPropertyValue(nestedObj, computedStyle); + } + } + } + + return parsed as T; +} diff --git a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx index 76298cc6ed..c59ab58a07 100644 --- a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx @@ -35,7 +35,11 @@ export function Graph({explain = {}, theme}: GraphProps) { return (
- {(true) ? : } + {true ? ( + + ) : ( + + )}
); } From 70da0b82bea8f9a2f4462a2de124811160ae1c73 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Tue, 26 Aug 2025 17:01:16 +0300 Subject: [PATCH 05/16] wip: custom blocks --- .../ConnectionBlockComponent.tsx | 14 +++++++ .../BlockComponents/QueryBlockComponent.tsx | 7 ++++ .../BlockComponents/ResultBlockComponent.tsx | 10 +++++ .../BlockComponents/StageBlockComponent.tsx | 14 +++++++ src/components/Graph/GravityGraph.scss | 37 +++++++++++++++++++ src/components/Graph/GravityGraph.tsx | 37 +++++++++++++++++-- src/components/Graph/sizesConfig.ts | 31 ++++++++++++++++ src/components/Graph/utils.ts | 9 +++-- 8 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx create mode 100644 src/components/Graph/BlockComponents/QueryBlockComponent.tsx create mode 100644 src/components/Graph/BlockComponents/ResultBlockComponent.tsx create mode 100644 src/components/Graph/BlockComponents/StageBlockComponent.tsx create mode 100644 src/components/Graph/GravityGraph.scss create mode 100644 src/components/Graph/sizesConfig.ts diff --git a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx new file mode 100644 index 0000000000..ff6d961bf4 --- /dev/null +++ b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx @@ -0,0 +1,14 @@ +import type {TBlock} from '@gravity-ui/graph'; + +type Props = { + block: TBlock; + className: string; +}; + +export const ConnectionBlockComponent = ({className, block}: Props) => { + return ( +
+ {block.operators ? block.operators.join('') : block.name} #{block.id} +
+ ); +}; diff --git a/src/components/Graph/BlockComponents/QueryBlockComponent.tsx b/src/components/Graph/BlockComponents/QueryBlockComponent.tsx new file mode 100644 index 0000000000..730eda79a7 --- /dev/null +++ b/src/components/Graph/BlockComponents/QueryBlockComponent.tsx @@ -0,0 +1,7 @@ +type Props = { + className: string; +}; + +export const QueryBlockComponent = ({className}: Props) => { + return
; +}; diff --git a/src/components/Graph/BlockComponents/ResultBlockComponent.tsx b/src/components/Graph/BlockComponents/ResultBlockComponent.tsx new file mode 100644 index 0000000000..8d0ac4f111 --- /dev/null +++ b/src/components/Graph/BlockComponents/ResultBlockComponent.tsx @@ -0,0 +1,10 @@ +import type {TBlock} from '@gravity-ui/graph'; + +type Props = { + block: TBlock; + className: string; +}; + +export const ResultBlockComponent = ({className, block}: Props) => { + return
{block.name}
; +}; diff --git a/src/components/Graph/BlockComponents/StageBlockComponent.tsx b/src/components/Graph/BlockComponents/StageBlockComponent.tsx new file mode 100644 index 0000000000..de0368d599 --- /dev/null +++ b/src/components/Graph/BlockComponents/StageBlockComponent.tsx @@ -0,0 +1,14 @@ +import type {TBlock} from '@gravity-ui/graph'; + +type Props = { + block: TBlock; + className: string; +}; + +export const StageBlockComponent = ({className, block}: Props) => { + return ( +
+ {block.operators ? block.operators.join('') : block.name} #{block.id} +
+ ); +}; diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss new file mode 100644 index 0000000000..449d1cdc10 --- /dev/null +++ b/src/components/Graph/GravityGraph.scss @@ -0,0 +1,37 @@ +.ydb-gravity-graph { + &__block { + background: none; + border: none; + cursor: auto; + } + + &__block-content { + width: 100%; + height: 100%; + padding: 8px 12px; + background: var(--g-color-base-float); + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); + border: 1px solid var(--g-color-line-generic); + } + + &__block-content.query { + border-radius: 50%; + } + + &__block-content.result { + display: flex; + align-items: center; + justify-content: center; + } + + &__block-content.stage { + border-radius: 6px; + } + + &__block-content.connection { + border-radius: 6px; + box-shadow: none; + background: var(--g-color-base-info-light); + border: 1px solid var(--g-color-line-info); + } +} diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index b92cd2ec3d..43025c2ecc 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -23,7 +23,17 @@ import { parseCustomPropertyValue, } from './utils'; +import {cn} from '../../utils/cn'; + +import './GravityGraph.scss'; + +const b = cn('ydb-gravity-graph'); + import {QueryBlockView} from './BlockComponents/QueryBlockView'; +import {QueryBlockComponent} from './BlockComponents/QueryBlockComponent'; +import {ResultBlockComponent} from './BlockComponents/ResultBlockComponent'; +import {StageBlockComponent} from './BlockComponents/StageBlockComponent'; +import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockComponent'; import {graphColorsConfig} from './colorsConfig'; interface Props { @@ -35,7 +45,7 @@ const config = { settings: { connection: MultipointConnection, blockComponents: { - Query: QueryBlockView, + query: QueryBlockView, }, // canDragCamera: true, // canZoomCamera: false, @@ -46,9 +56,24 @@ const config = { const elk = new ELK(); const renderBlockFn = (graph, block) => { + console.log('===', block); + + const map = { + query: QueryBlockComponent, + result: ResultBlockComponent, + stage: StageBlockComponent, + connection: ConnectionBlockComponent, + }; + + const Component = map[block.is]; + return ( - - {block.id} + + {Component ? ( + + ) : ( + block.id + )} ); }; @@ -88,7 +113,7 @@ const baseElkConfig = { 'elk.algorithm': 'layered', 'elk.direction': 'DOWN', // 'elk.spacing.edgeNode': '50', - 'elk.layered.spacing.nodeNodeBetweenLayers': '50', + 'elk.layered.spacing.nodeNodeBetweenLayers': '20', 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', 'elk.layered.nodePlacement.bk.ordering': 'INTERACTIVE', 'elk.debugMode': true, @@ -151,6 +176,10 @@ export function GravityGraph({data, theme}: Props) { useGraphEvent(graph, 'state-change', ({state}) => { if (state === GraphState.ATTACHED) { console.log('start'); + graph.cameraService.set({ + scale: 1, + scaleMax: 1.5, + }); start(); // graph.zoomTo("center", { padding: 300 }); } diff --git a/src/components/Graph/sizesConfig.ts b/src/components/Graph/sizesConfig.ts new file mode 100644 index 0000000000..ae02c20576 --- /dev/null +++ b/src/components/Graph/sizesConfig.ts @@ -0,0 +1,31 @@ +import type {ExplainPlanNodeData} from '@gravity-ui/paranoid'; + +type Props = { + width: number; + height: number; +}; + +type SizeConfig = Record; + +export const graphSizesConfig: SizeConfig = { + query: { + width: 40, + height: 40, + }, + result: { + width: 112, + height: 40, + }, + stage: { + width: 248, + height: 40, + }, + connection: { + width: 112, + height: 40, + }, + materialize: { + width: 190, + height: 40, + }, +}; diff --git a/src/components/Graph/utils.ts b/src/components/Graph/utils.ts index c3660e59db..0d96e50a5c 100644 --- a/src/components/Graph/utils.ts +++ b/src/components/Graph/utils.ts @@ -2,6 +2,7 @@ import type {TBlock, TConnection, TGraphConfig} from '@gravity-ui/graph'; import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; import type {ElkExtendedEdge, ElkNode} from 'elkjs'; import type {AbstractGraphColorsConfig} from './colorsConfig'; +import {graphSizesConfig} from './sizesConfig'; export const prepareChildren = (blocks: TGraphConfig['blocks']) => { return blocks?.map((b) => { @@ -37,12 +38,12 @@ export const prepareEdges = (connections: TGraphConfig['connections'], skipLabel }; export const prepareBlocks = (nodes: Data['nodes']): TBlock[] => { - return nodes?.map(({data: {id, name, ...rest}}) => ({ + return nodes?.map(({data: {id, name, type, ...rest}}) => ({ id: String(id), - is: name, + is: type, name, - width: 200, - height: 100, + width: graphSizesConfig[type]?.width || 100, + height: graphSizesConfig[type]?.height || 40, ...rest, })); }; From 0b1eac5c378fbc1219cf2c7971b5b90db21eb3b4 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Mon, 1 Sep 2025 15:05:06 +0300 Subject: [PATCH 06/16] wip: styles --- .../ConnectionBlockComponent.tsx | 20 ++++++- .../BlockComponents/StageBlockComponent.tsx | 4 +- src/components/Graph/GravityGraph.scss | 15 +++++- src/components/Graph/GravityGraph.tsx | 7 ++- src/components/Graph/sizesConfig.ts | 31 ----------- src/components/Graph/utils.ts | 52 +++++++++++++++++-- 6 files changed, 89 insertions(+), 40 deletions(-) delete mode 100644 src/components/Graph/sizesConfig.ts diff --git a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx index ff6d961bf4..02831668cd 100644 --- a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx @@ -1,14 +1,32 @@ import type {TBlock} from '@gravity-ui/graph'; +import { Icon } from '@gravity-ui/uikit'; +import {CodeMerge, Shuffle, VectorCircle, MapPin, BroadcastSignal} from '@gravity-ui/icons'; type Props = { block: TBlock; className: string; }; +const getIcon = (name: string) => { + switch (name) { + case 'Merge': + return CodeMerge; + case 'UnionAll': + return VectorCircle; + case 'HashShuffle': + return Shuffle; + case 'Map': + return MapPin; + case 'Broadcast': + return BroadcastSignal; + } +} + export const ConnectionBlockComponent = ({className, block}: Props) => { + const icon = getIcon(block.name); return (
- {block.operators ? block.operators.join('') : block.name} #{block.id} + {icon && } {block.name}
); }; diff --git a/src/components/Graph/BlockComponents/StageBlockComponent.tsx b/src/components/Graph/BlockComponents/StageBlockComponent.tsx index de0368d599..796971beb8 100644 --- a/src/components/Graph/BlockComponents/StageBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/StageBlockComponent.tsx @@ -1,4 +1,5 @@ import type {TBlock} from '@gravity-ui/graph'; +import { Text } from '@gravity-ui/uikit'; type Props = { block: TBlock; @@ -8,7 +9,8 @@ type Props = { export const StageBlockComponent = ({className, block}: Props) => { return (
- {block.operators ? block.operators.join('') : block.name} #{block.id} + {block.operators ? block.operators.map((item) =>
{item}
) : block.name} + {block.tables ?
Tables: {block.tables.join(', ')}
: null}
); }; diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss index 449d1cdc10..62b74cd581 100644 --- a/src/components/Graph/GravityGraph.scss +++ b/src/components/Graph/GravityGraph.scss @@ -7,15 +7,27 @@ &__block-content { width: 100%; - height: 100%; + // height: 100%; padding: 8px 12px; background: var(--g-color-base-float); box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); border: 1px solid var(--g-color-line-generic); + font-size: var(--g-text-body-short-font-size); + line-height: var(--g-text-body-short-line-height); } + &__block-id { + position: absolute; + top: 4px; + right: 4px; + color: var(--g-color-text-secondary); + font-size: 10px; + line-height: 1; + } + &__block-content.query { border-radius: 50%; + height: 100%; } &__block-content.result { @@ -33,5 +45,6 @@ box-shadow: none; background: var(--g-color-base-info-light); border: 1px solid var(--g-color-line-info); + color: var(--g-color-text-info-heavy); } } diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index 43025c2ecc..e288f00974 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -70,7 +70,12 @@ const renderBlockFn = (graph, block) => { return ( {Component ? ( - + <> + + {block.id !== 'undefined' && block.is !== 'result' &&
+ #{block.id} +
} + ) : ( block.id )} diff --git a/src/components/Graph/sizesConfig.ts b/src/components/Graph/sizesConfig.ts deleted file mode 100644 index ae02c20576..0000000000 --- a/src/components/Graph/sizesConfig.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {ExplainPlanNodeData} from '@gravity-ui/paranoid'; - -type Props = { - width: number; - height: number; -}; - -type SizeConfig = Record; - -export const graphSizesConfig: SizeConfig = { - query: { - width: 40, - height: 40, - }, - result: { - width: 112, - height: 40, - }, - stage: { - width: 248, - height: 40, - }, - connection: { - width: 112, - height: 40, - }, - materialize: { - width: 190, - height: 40, - }, -}; diff --git a/src/components/Graph/utils.ts b/src/components/Graph/utils.ts index 0d96e50a5c..7846a3d361 100644 --- a/src/components/Graph/utils.ts +++ b/src/components/Graph/utils.ts @@ -1,8 +1,7 @@ import type {TBlock, TConnection, TGraphConfig} from '@gravity-ui/graph'; -import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; +import type {Data, GraphNode, Options, Shapes, ExplainPlanNodeData} from '@gravity-ui/paranoid'; import type {ElkExtendedEdge, ElkNode} from 'elkjs'; import type {AbstractGraphColorsConfig} from './colorsConfig'; -import {graphSizesConfig} from './sizesConfig'; export const prepareChildren = (blocks: TGraphConfig['blocks']) => { return blocks?.map((b) => { @@ -37,13 +36,56 @@ export const prepareEdges = (connections: TGraphConfig['connections'], skipLabel }); }; +const BLOCK_TOP_PADDING = 8; +const BLOCK_LINE_HEIGHT = 16; +const BORDER_HEIGHT = 2; + +const getBlockSize = (block: ExplainPlanNodeData) => { + const ONE_LINE_HEIGHT = BLOCK_TOP_PADDING * 2 + BLOCK_LINE_HEIGHT + BORDER_HEIGHT; + + switch (block.type) { + case 'query': + return { + width: 40, + height: 40, + }; + case 'result': + return { + width: 112, + height: ONE_LINE_HEIGHT, + }; + case 'stage': + const operatorsLength = block.operators?.length ?? 1; + const tablesLength = block.tables?.length ?? 0; + + return { + width: 248, + height: BORDER_HEIGHT + BLOCK_TOP_PADDING * 2 + (operatorsLength + tablesLength) * BLOCK_LINE_HEIGHT, + }; + case 'connection': + return { + width: 122, + height: ONE_LINE_HEIGHT, + }; + case 'materialize': + return { + width: 190, + height: ONE_LINE_HEIGHT, + }; + default: + return { + width: 100, + height: ONE_LINE_HEIGHT, + }; + } +} + export const prepareBlocks = (nodes: Data['nodes']): TBlock[] => { - return nodes?.map(({data: {id, name, type, ...rest}}) => ({ + return nodes?.map(({data: {id, name, type, ...rest}, data}) => ({ id: String(id), is: type, name, - width: graphSizesConfig[type]?.width || 100, - height: graphSizesConfig[type]?.height || 40, + ...getBlockSize(data), ...rest, })); }; From 904ea566153980cfb9e16d6c89025332cdb22fc6 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Tue, 2 Sep 2025 11:16:11 +0300 Subject: [PATCH 07/16] wip: tooltip --- .../ConnectionBlockComponent.tsx | 13 ++++- .../BlockComponents/StageBlockComponent.tsx | 21 ++++++--- src/components/Graph/GravityGraph.scss | 11 +++++ src/components/Graph/GravityGraph.tsx | 2 +- src/components/Graph/TooltipComponent.tsx | 47 +++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 src/components/Graph/TooltipComponent.tsx diff --git a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx index 02831668cd..d82d69afee 100644 --- a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx @@ -2,6 +2,8 @@ import type {TBlock} from '@gravity-ui/graph'; import { Icon } from '@gravity-ui/uikit'; import {CodeMerge, Shuffle, VectorCircle, MapPin, BroadcastSignal} from '@gravity-ui/icons'; +import { TooltipComponent } from '../TooltipComponent'; + type Props = { block: TBlock; className: string; @@ -24,9 +26,18 @@ const getIcon = (name: string) => { export const ConnectionBlockComponent = ({className, block}: Props) => { const icon = getIcon(block.name); - return ( + const content = (
{icon && } {block.name}
); + + if (!block.stats?.length) { + return content; + } + + return ( + {content} + + ); }; diff --git a/src/components/Graph/BlockComponents/StageBlockComponent.tsx b/src/components/Graph/BlockComponents/StageBlockComponent.tsx index 796971beb8..024352f117 100644 --- a/src/components/Graph/BlockComponents/StageBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/StageBlockComponent.tsx @@ -1,16 +1,25 @@ -import type {TBlock} from '@gravity-ui/graph'; +import type { TBlock } from '@gravity-ui/graph'; import { Text } from '@gravity-ui/uikit'; +import { TooltipComponent } from '../TooltipComponent'; + type Props = { block: TBlock; className: string; }; -export const StageBlockComponent = ({className, block}: Props) => { +export const StageBlockComponent = ({ className, block }: Props) => { + const content =
+ {block.operators ? block.operators.map((item) =>
{item}
) : block.name} + {block.tables ?
Tables: {block.tables.join(', ')}
: null} +
; + + if (!block.stats?.length) { + return content; + } + return ( -
- {block.operators ? block.operators.map((item) =>
{item}
) : block.name} - {block.tables ?
Tables: {block.tables.join(', ')}
: null} -
+ {content} + ); }; diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss index 62b74cd581..5323331a9f 100644 --- a/src/components/Graph/GravityGraph.scss +++ b/src/components/Graph/GravityGraph.scss @@ -41,10 +41,21 @@ } &__block-content.connection { + display: flex; + align-items: center; + gap: 4px; border-radius: 6px; box-shadow: none; background: var(--g-color-base-info-light); border: 1px solid var(--g-color-line-info); color: var(--g-color-text-info-heavy); } + + &__tooltip-content { + padding: 8px; + width: 300px; + font-size: var(--g-text-body-short-font-size); + line-height: var(--g-text-body-short-line-height); + + } } diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index e288f00974..efbc6f5a97 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -56,7 +56,7 @@ const config = { const elk = new ELK(); const renderBlockFn = (graph, block) => { - console.log('===', block); + // console.log('===', block); const map = { query: QueryBlockComponent, diff --git a/src/components/Graph/TooltipComponent.tsx b/src/components/Graph/TooltipComponent.tsx new file mode 100644 index 0000000000..a076a5ed31 --- /dev/null +++ b/src/components/Graph/TooltipComponent.tsx @@ -0,0 +1,47 @@ +import React, { useState, useMemo } from 'react'; +import type { TBlock } from '@gravity-ui/graph'; +import { Text, Popover, TabProvider, TabList, Tab, TabPanel } from '@gravity-ui/uikit'; + +type Props = { + block: TBlock; + children: React.ReactNode; +}; + +const getTooltipContent = (block: TBlock) => { + const [activeTab, setActiveTab] = useState(block?.stats[0]?.group); + + return ( + + + {block?.stats?.map((item) => {item.group})} + + {block?.stats?.map((item) => + {item.stats?.map((stat) =>
+ {Boolean(stat.items) && + <> + {stat.name} + {stat.items?.map(({ name, value }) =>
{name}: {value}
)} + + } + {!stat.items &&
{stat.name}: {stat.value}
} + + +
)} +
)} +
+ ); +} + +export const TooltipComponent = ({ block, children }: Props) => { + return ( + + {children} + + ); +}; From 5576c55b7fa99e230f565d0e80d7ea781da37168 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Tue, 2 Sep 2025 12:40:43 +0300 Subject: [PATCH 08/16] wip: tooltip --- src/components/Graph/GravityGraph.scss | 34 ++++++++- src/components/Graph/GravityGraph.tsx | 90 +++++++---------------- src/components/Graph/TooltipComponent.tsx | 29 +++++--- 3 files changed, 75 insertions(+), 78 deletions(-) diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss index 5323331a9f..9f2d11a6f7 100644 --- a/src/components/Graph/GravityGraph.scss +++ b/src/components/Graph/GravityGraph.scss @@ -13,7 +13,12 @@ box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); border: 1px solid var(--g-color-line-generic); font-size: var(--g-text-body-short-font-size); + font-family: var(--g-font-family); line-height: var(--g-text-body-short-line-height); + + &[aria-haspopup='dialog'] { + cursor: pointer; + } } &__block-id { @@ -52,10 +57,37 @@ } &__tooltip-content { - padding: 8px; + padding: 0 8px 8px; width: 300px; font-size: var(--g-text-body-short-font-size); + font-family: var(--g-font-family); line-height: var(--g-text-body-short-line-height); + } + + &__tooltip-tabs { + margin-bottom: 8px; + } + + &__tooltip-stat-row { + display: grid; + grid-template-columns: 100px auto; + gap: 8px; + margin: 4px 0 0; + + span { + overflow: hidden; + text-overflow: ellipsis; + } + span:nth-child(2) { + word-wrap: break-word; + } + } + + &__tooltip-stat-group { + margin-top: 8px; + } + &__tooltip-stat-group-name { + font-weight: bold; } } diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index efbc6f5a97..a62ed6609d 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -53,10 +53,26 @@ const config = { showConnectionArrows: false, }, }; + +const baseElkConfig = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'DOWN', + // 'elk.spacing.edgeNode': '50', + 'elk.layered.spacing.nodeNodeBetweenLayers': '20', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', + 'elk.layered.nodePlacement.bk.ordering': 'INTERACTIVE', + // 'elk.debugMode': true, + // 'elk.alignment': 'CENTER' + }, +}; + + const elk = new ELK(); const renderBlockFn = (graph, block) => { - // console.log('===', block); + console.log('===', block); const map = { query: QueryBlockComponent, @@ -83,52 +99,7 @@ const renderBlockFn = (graph, block) => { ); }; -// const _blocks: TBlock[] = [ -// { -// width: 200, -// height: 160, -// id: 'Left', -// is: 'block-action', -// selected: false, -// name: 'Left block', -// anchors: [], -// }, -// { -// width: 200, -// height: 160, -// id: 'Right', -// is: 'block-action', -// selected: false, -// name: 'Right block', -// anchors: [], -// }, -// ]; - -// const _connections = [ -// { -// id: 'c1', -// sourceBlockId: 'Left', -// targetBlockId: 'Right', -// }, -// ]; - -const baseElkConfig = { - id: 'root', - layoutOptions: { - 'elk.algorithm': 'layered', - 'elk.direction': 'DOWN', - // 'elk.spacing.edgeNode': '50', - 'elk.layered.spacing.nodeNodeBetweenLayers': '20', - 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', - 'elk.layered.nodePlacement.bk.ordering': 'INTERACTIVE', - 'elk.debugMode': true, - // 'elk.alignment': 'CENTER' - }, -}; - export function GravityGraph({data, theme}: Props) { - // console.log('997', data); - const _blocks = useMemo(() => prepareBlocks(data.nodes), [data.nodes]); const _connections = useMemo(() => prepareConnections(data.links), [data.links]); const elkConfig = useMemo( @@ -147,26 +118,15 @@ export function GravityGraph({data, theme}: Props) { return; } - // console.log('result', result); - - const blocks = _blocks.map((block) => { - return { - ...block, - ...result.blocks[block.id], - }; - }); + const blocks = _blocks.map((block) => ({ + ...block, + ...result.blocks[block.id], + })); - const connections = _connections.reduce((acc, connection) => { - if (connection.id in result.edges) { - acc.push({ - ...connection, - ...result.edges[connection.id], - }); - } - return acc; - }, []); - - // console.log('connections', connections); + const connections = _connections.map((connection) => ({ + ...connection, + ...(connection.id? result.edges[connection.id] : {}), + })); graph.setEntities({ blocks, diff --git a/src/components/Graph/TooltipComponent.tsx b/src/components/Graph/TooltipComponent.tsx index a076a5ed31..442bf06653 100644 --- a/src/components/Graph/TooltipComponent.tsx +++ b/src/components/Graph/TooltipComponent.tsx @@ -2,31 +2,36 @@ import React, { useState, useMemo } from 'react'; import type { TBlock } from '@gravity-ui/graph'; import { Text, Popover, TabProvider, TabList, Tab, TabPanel } from '@gravity-ui/uikit'; +import {cn} from '../../utils/cn'; +const b = cn('ydb-gravity-graph'); type Props = { block: TBlock; children: React.ReactNode; }; +const getStatsContent = (stat) => { + if (!stat.items) { + return

{stat.name}:{stat.value}

; + } + + return ( +
+
{stat.name}:
+ {stat.items?.map(({ name, value }) =>

{name}:{value}

)} +
+ ); +} + const getTooltipContent = (block: TBlock) => { const [activeTab, setActiveTab] = useState(block?.stats[0]?.group); return ( - + {block?.stats?.map((item) => {item.group})} {block?.stats?.map((item) => - {item.stats?.map((stat) =>
- {Boolean(stat.items) && - <> - {stat.name} - {stat.items?.map(({ name, value }) =>
{name}: {value}
)} - - } - {!stat.items &&
{stat.name}: {stat.value}
} - - -
)} + {item.stats?.map(getStatsContent)}
)}
); From eec1fe74417b3407c28dcc59ae0a6846147c350e Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Thu, 4 Sep 2025 12:51:24 +0300 Subject: [PATCH 09/16] wip: icons --- .../Graph/BlockComponents/ConnectionBlockComponent.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx index d82d69afee..1bc8db37d2 100644 --- a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx @@ -1,6 +1,6 @@ import type {TBlock} from '@gravity-ui/graph'; import { Icon } from '@gravity-ui/uikit'; -import {CodeMerge, Shuffle, VectorCircle, MapPin, BroadcastSignal} from '@gravity-ui/icons'; +import {DatabaseFill, Shuffle, GripHorizontal, MapPin, ArrowsExpandHorizontal} from '@gravity-ui/icons'; import { TooltipComponent } from '../TooltipComponent'; @@ -12,15 +12,15 @@ type Props = { const getIcon = (name: string) => { switch (name) { case 'Merge': - return CodeMerge; + return DatabaseFill; case 'UnionAll': - return VectorCircle; + return GripHorizontal; case 'HashShuffle': return Shuffle; case 'Map': return MapPin; case 'Broadcast': - return BroadcastSignal; + return ArrowsExpandHorizontal; } } From c0506531ae3c00e335c06809e997ef47cc5c7762 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Thu, 4 Sep 2025 13:49:53 +0300 Subject: [PATCH 10/16] wip: controls --- src/components/Graph/GraphControls.tsx | 53 +++++ src/components/Graph/GravityGraph.scss | 17 ++ src/components/Graph/GravityGraph.tsx | 29 ++- .../Graph/NonSelectableConnection.tsx | 24 ++ src/components/Graph/colorsConfig.ts | 206 +++++++++--------- .../QueryResult/components/Graph/Graph.scss | 1 + 6 files changed, 220 insertions(+), 110 deletions(-) create mode 100644 src/components/Graph/GraphControls.tsx create mode 100644 src/components/Graph/NonSelectableConnection.tsx diff --git a/src/components/Graph/GraphControls.tsx b/src/components/Graph/GraphControls.tsx new file mode 100644 index 0000000000..d1bd3d4904 --- /dev/null +++ b/src/components/Graph/GraphControls.tsx @@ -0,0 +1,53 @@ +import type {Graph} from '@gravity-ui/graph'; +import MagnifierMinusIcon from '@gravity-ui/icons/svgs/magnifier-minus.svg'; +import MagnifierPlusIcon from '@gravity-ui/icons/svgs/magnifier-plus.svg'; +import {Button, Icon} from '@gravity-ui/uikit'; + +import {cn} from '../../utils/cn'; +const b = cn('ydb-gravity-graph'); + +const ZOOM_STEP = 1.25; + +interface Props { + graph: Graph; +} + +export const GraphControls = ({graph}: Props) => { + const onZoomInClick = () => { + const cameraScale = graph.cameraService.getCameraScale(); + graph.zoom({scale: cameraScale * ZOOM_STEP}); + }; + + const onZoomOutClick = () => { + const cameraScale = graph.cameraService.getCameraScale(); + graph.zoom({scale: cameraScale / ZOOM_STEP}); + }; + + const onResetZoomClick = () => { + graph.zoom({scale: 1}); + }; + + return ( +
+ + + +
+ ); +}; + diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss index 9f2d11a6f7..07272efe89 100644 --- a/src/components/Graph/GravityGraph.scss +++ b/src/components/Graph/GravityGraph.scss @@ -78,6 +78,9 @@ overflow: hidden; text-overflow: ellipsis; } + span:nth-child(1) { + color: var(--g-color-text-secondary); + } span:nth-child(2) { word-wrap: break-word; } @@ -86,8 +89,22 @@ &__tooltip-stat-group { margin-top: 8px; } + + &__tooltip-stat-group + &__tooltip-stat-group { + padding-top: 8px; + border-top: 1px dotted var(--g-color-line-generic); + } &__tooltip-stat-group-name { font-weight: bold; } + + &__zoom-controls { + position: absolute; + top: 8px; + right: 50px; + z-index: 999; + display: flex; + gap: 2px; + } } diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index a62ed6609d..a6599a79b1 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -29,24 +29,25 @@ import './GravityGraph.scss'; const b = cn('ydb-gravity-graph'); -import {QueryBlockView} from './BlockComponents/QueryBlockView'; +// import {QueryBlockView} from './BlockComponents/QueryBlockView'; import {QueryBlockComponent} from './BlockComponents/QueryBlockComponent'; import {ResultBlockComponent} from './BlockComponents/ResultBlockComponent'; import {StageBlockComponent} from './BlockComponents/StageBlockComponent'; import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockComponent'; import {graphColorsConfig} from './colorsConfig'; +import { GraphControls } from './GraphControls'; interface Props { data: Data; theme?: string; } -const config = { +const config: TGraphConfig = { settings: { connection: MultipointConnection, - blockComponents: { - query: QueryBlockView, - }, + // blockComponents: { + // query: QueryBlockView, + // }, // canDragCamera: true, // canZoomCamera: false, // useBezierConnections: false, @@ -72,7 +73,7 @@ const baseElkConfig = { const elk = new ELK(); const renderBlockFn = (graph, block) => { - console.log('===', block); + // console.log('===', block); const map = { query: QueryBlockComponent, @@ -140,15 +141,27 @@ export function GravityGraph({data, theme}: Props) { useGraphEvent(graph, 'state-change', ({state}) => { if (state === GraphState.ATTACHED) { - console.log('start'); graph.cameraService.set({ scale: 1, scaleMax: 1.5, + scaleMin: 0.5, + }); + graph.setConstants({ + block: { + SCALES: [0.125, 0.225, 0.5] // Detailed view stays until zoom = 0.5 + } }); start(); // graph.zoomTo("center", { padding: 300 }); } }); - return ; + // useGraphEvent(graph, 'connection-selection-change', (event) => { + // console.log('connection-click', event); + // }); + + return <> + + + ; } diff --git a/src/components/Graph/NonSelectableConnection.tsx b/src/components/Graph/NonSelectableConnection.tsx new file mode 100644 index 0000000000..10ec21785b --- /dev/null +++ b/src/components/Graph/NonSelectableConnection.tsx @@ -0,0 +1,24 @@ +import {MultipointConnection} from '@gravity-ui/graph/react'; + +/** + * Кастомный класс соединения, который отключает визуальное выделение + * Наследуется от MultipointConnection и переопределяет поведение + */ +export class NonSelectableConnection extends MultipointConnection { + // Переопределяем метод для предотвращения выделения при клике + public override onClick() { + // Ничего не делаем при клике - блокируем выделение + return; + } + + // Переопределяем метод для отключения hover эффектов + public override onPointerEnter() { + // Ничего не делаем при наведении + return; + } + + public override onPointerLeave() { + // Ничего не делаем при уходе курсора + return; + } +} diff --git a/src/components/Graph/colorsConfig.ts b/src/components/Graph/colorsConfig.ts index 8513c84973..4137167627 100644 --- a/src/components/Graph/colorsConfig.ts +++ b/src/components/Graph/colorsConfig.ts @@ -6,116 +6,118 @@ export const graphColorsConfig = { canvas: { belowLayerBackground: '#0000', border: '#0000', - dots: 'var(--g-color-line-generic)', + dots: 'var(--g-color-base-background)', layerBackground: 'var(--g-color-base-background)', }, - block: { - text: 'var(--g-color-text-primary)', - background: 'var(--g-color-base-float)', - border: '#dfdfdf', - }, + // block: { + // text: 'var(--g-color-text-primary)', + // background: 'var(--g-color-base-float)', + // border: '#dfdfdf', + // }, connection: { background: 'var(--g-color-line-generic-solid)', - selectedBackground: 'var(--g-color-line-positive)', + hoverBackground: 'var(--g-color-line-generic-solid)', + selectedBackground: 'var(--g-color-line-generic-solid)', + // selectedBackground: 'var(--g-color-line-positive)', }, // Gravity-UI Colors - textsMain: { - primary: 'var(--g-color-text-primary)', - complementary: 'var(--g-color-text-complementary)', - secondary: 'var(--g-color-text-secondary)', - hint: 'var(--g-color-text-hint)', - }, - textsSemantic: { - info: 'var(--g-color-text-info)', - infoHeavy: 'var(--g-color-text-info-heavy)', - positive: 'var(--g-color-text-positive)', - positiveHeavy: 'var(--g-color-text-positive-heavy)', - warning: 'var(--g-color-text-warning)', - warningHeavy: 'var(--g-color-text-warning-heavy)', - danger: 'var(--g-color-text-danger)', - dangerHeavy: 'var(--g-color-text-danger-heavy)', - utility: 'var(--g-color-text-utility)', - utilityHeavy: 'var(--g-color-text-utility-heavy)', - misc: 'var(--g-color-text-misc)', - miscHeavy: 'var(--g-color-text-misc-heavy)', - }, - backgroundsBasic: { - background: 'var(--g-color-base-background)', - generic: 'var(--g-color-base-generic)', - genericHover: 'var(--g-color-base-generic-hover)', - medium: 'var(--g-color-base-medium)', - mediumHover: 'var(--g-color-base-medium-hover)', - simple: 'var(--g-color-base-simple)', - simpleHover: 'var(--g-color-base-simple-hover)', - }, - backgroundsFloats: { - float: 'var(--g-color-base-float)', - floatHover: 'var(--g-color-base-float-hover)', - floatMedium: 'var(--g-color-base-float-medium)', - floatHeavy: 'var(--g-color-base-float-heavy)', - }, - backgroundsSemantic: { - infoLight: 'var(--g-color-base-info-light)', - infoLightHover: 'var(--g-color-base-info-light-hover)', - positiveLight: 'var(--g-color-base-positive-light)', - positiveLightHover: 'var(--g-color-base-positive-light-hover)', - warningLight: 'var(--g-color-base-warning-light)', - warningLightHover: 'var(--g-color-base-warning-light-hover)', - dangerLight: 'var(--g-color-base-danger-light)', - dangerLightHover: 'var(--g-color-base-danger-light-hover)', - utilityLight: 'var(--g-color-base-utility-light)', - utilityLightHover: 'var(--g-color-base-utility-light-hover)', - miscLight: 'var(--g-color-base-misc-light)', - miscLightHover: 'var(--g-color-base-misc-light-hover)', - neutralLight: 'var(--g-color-base-neutral-light)', - neutralLightHover: 'var(--g-color-base-neutral-light-hover)', + // textsMain: { + // primary: 'var(--g-color-text-primary)', + // complementary: 'var(--g-color-text-complementary)', + // secondary: 'var(--g-color-text-secondary)', + // hint: 'var(--g-color-text-hint)', + // }, + // textsSemantic: { + // info: 'var(--g-color-text-info)', + // infoHeavy: 'var(--g-color-text-info-heavy)', + // positive: 'var(--g-color-text-positive)', + // positiveHeavy: 'var(--g-color-text-positive-heavy)', + // warning: 'var(--g-color-text-warning)', + // warningHeavy: 'var(--g-color-text-warning-heavy)', + // danger: 'var(--g-color-text-danger)', + // dangerHeavy: 'var(--g-color-text-danger-heavy)', + // utility: 'var(--g-color-text-utility)', + // utilityHeavy: 'var(--g-color-text-utility-heavy)', + // misc: 'var(--g-color-text-misc)', + // miscHeavy: 'var(--g-color-text-misc-heavy)', + // }, + // backgroundsBasic: { + // background: 'var(--g-color-base-background)', + // generic: 'var(--g-color-base-generic)', + // genericHover: 'var(--g-color-base-generic-hover)', + // medium: 'var(--g-color-base-medium)', + // mediumHover: 'var(--g-color-base-medium-hover)', + // simple: 'var(--g-color-base-simple)', + // simpleHover: 'var(--g-color-base-simple-hover)', + // }, + // backgroundsFloats: { + // float: 'var(--g-color-base-float)', + // floatHover: 'var(--g-color-base-float-hover)', + // floatMedium: 'var(--g-color-base-float-medium)', + // floatHeavy: 'var(--g-color-base-float-heavy)', + // }, + // backgroundsSemantic: { + // infoLight: 'var(--g-color-base-info-light)', + // infoLightHover: 'var(--g-color-base-info-light-hover)', + // positiveLight: 'var(--g-color-base-positive-light)', + // positiveLightHover: 'var(--g-color-base-positive-light-hover)', + // warningLight: 'var(--g-color-base-warning-light)', + // warningLightHover: 'var(--g-color-base-warning-light-hover)', + // dangerLight: 'var(--g-color-base-danger-light)', + // dangerLightHover: 'var(--g-color-base-danger-light-hover)', + // utilityLight: 'var(--g-color-base-utility-light)', + // utilityLightHover: 'var(--g-color-base-utility-light-hover)', + // miscLight: 'var(--g-color-base-misc-light)', + // miscLightHover: 'var(--g-color-base-misc-light-hover)', + // neutralLight: 'var(--g-color-base-neutral-light)', + // neutralLightHover: 'var(--g-color-base-neutral-light-hover)', - infoMedium: 'var(--g-color-base-info-medium)', - infoMediumHover: 'var(--g-color-base-info-medium-hover)', - positiveMedium: 'var(--g-color-base-positive-medium)', - positiveMediumHover: 'var(--g-color-base-positive-medium-hover)', - warningMedium: 'var(--g-color-base-warning-medium)', - warningMediumHover: 'var(--g-color-base-warning-medium-hover)', - dangerMedium: 'var(--g-color-base-danger-medium)', - dangerMediumHover: 'var(--g-color-base-danger-medium-hover)', - utilityMedium: 'var(--g-color-base-utility-medium)', - utilityMediumHover: 'var(--g-color-base-utility-medium-hover)', - miscMedium: 'var(--g-color-base-misc-medium)', - miscMediumHover: 'var(--g-color-base-misc-medium-hover)', - neutralMedium: 'var(--g-color-base-neutral-medium)', - neutralMediumHover: 'var(--g-color-base-neutral-medium-hover)', + // infoMedium: 'var(--g-color-base-info-medium)', + // infoMediumHover: 'var(--g-color-base-info-medium-hover)', + // positiveMedium: 'var(--g-color-base-positive-medium)', + // positiveMediumHover: 'var(--g-color-base-positive-medium-hover)', + // warningMedium: 'var(--g-color-base-warning-medium)', + // warningMediumHover: 'var(--g-color-base-warning-medium-hover)', + // dangerMedium: 'var(--g-color-base-danger-medium)', + // dangerMediumHover: 'var(--g-color-base-danger-medium-hover)', + // utilityMedium: 'var(--g-color-base-utility-medium)', + // utilityMediumHover: 'var(--g-color-base-utility-medium-hover)', + // miscMedium: 'var(--g-color-base-misc-medium)', + // miscMediumHover: 'var(--g-color-base-misc-medium-hover)', + // neutralMedium: 'var(--g-color-base-neutral-medium)', + // neutralMediumHover: 'var(--g-color-base-neutral-medium-hover)', - infoHeavy: 'var(--g-color-base-info-heavy)', - infoHeavyHover: 'var(--g-color-base-info-heavy-hover)', - positiveHeavy: 'var(--g-color-base-positive-heavy)', - positiveHeavyHover: 'var(--g-color-base-positive-heavy-hover)', - warningHeavy: 'var(--g-color-base-warning-heavy)', - warningHeavyHover: 'var(--g-color-base-warning-heavy-hover)', - dangerHeavy: 'var(--g-color-base-danger-heavy)', - dangerHeavyHover: 'var(--g-color-base-danger-heavy-hover)', - utilityHeavy: 'var(--g-color-base-utility-heavy)', - utilityHeavyHover: 'var(--g-color-base-utility-heavy-hover)', - miscHeavy: 'var(--g-color-base-misc-heavy)', - miscHeavyHover: 'var(--g-color-base-misc-heavy-hover)', - neutralHeavy: 'var(--g-color-base-neutral-heavy)', - neutralHeavyHover: 'var(--g-color-base-neutral-heavy-hover)', - }, - linesGeneral: { - generic: 'var(--g-color-line-generic)', - genericHover: 'var(--g-color-line-generic-hover)', - genericActive: 'var(--g-color-line-generic-active)', - genericAccent: 'var(--g-color-line-generic-accent)', - genericAccentHover: 'var(--g-color-line-generic-accent-hover)', - solid: 'var(--g-color-line-generic-solid)', - }, - linesSemantic: { - info: 'var(--g-color-line-info)', - positive: 'var(--g-color-line-positive)', - warning: 'var(--g-color-line-warning)', - danger: 'var(--g-color-line-danger)', - utility: 'var(--g-color-line-utility)', - misc: 'var(--g-color-line-misc)', - }, + // infoHeavy: 'var(--g-color-base-info-heavy)', + // infoHeavyHover: 'var(--g-color-base-info-heavy-hover)', + // positiveHeavy: 'var(--g-color-base-positive-heavy)', + // positiveHeavyHover: 'var(--g-color-base-positive-heavy-hover)', + // warningHeavy: 'var(--g-color-base-warning-heavy)', + // warningHeavyHover: 'var(--g-color-base-warning-heavy-hover)', + // dangerHeavy: 'var(--g-color-base-danger-heavy)', + // dangerHeavyHover: 'var(--g-color-base-danger-heavy-hover)', + // utilityHeavy: 'var(--g-color-base-utility-heavy)', + // utilityHeavyHover: 'var(--g-color-base-utility-heavy-hover)', + // miscHeavy: 'var(--g-color-base-misc-heavy)', + // miscHeavyHover: 'var(--g-color-base-misc-heavy-hover)', + // neutralHeavy: 'var(--g-color-base-neutral-heavy)', + // neutralHeavyHover: 'var(--g-color-base-neutral-heavy-hover)', + // }, + // linesGeneral: { + // generic: 'var(--g-color-line-generic)', + // genericHover: 'var(--g-color-line-generic-hover)', + // genericActive: 'var(--g-color-line-generic-active)', + // genericAccent: 'var(--g-color-line-generic-accent)', + // genericAccentHover: 'var(--g-color-line-generic-accent-hover)', + // solid: 'var(--g-color-line-generic-solid)', + // }, + // linesSemantic: { + // info: 'var(--g-color-line-info)', + // positive: 'var(--g-color-line-positive)', + // warning: 'var(--g-color-line-warning)', + // danger: 'var(--g-color-line-danger)', + // utility: 'var(--g-color-line-utility)', + // misc: 'var(--g-color-line-misc)', + // }, } as const satisfies AbstractGraphColorsConfig; diff --git a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss index 565424c0e3..547640e301 100644 --- a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss +++ b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss @@ -4,5 +4,6 @@ width: 100%; height: 100%; + position: relative; } } From 4c7c7bca7766b112e9400604b3a16e44a5fc7296 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Fri, 5 Sep 2025 12:06:37 +0300 Subject: [PATCH 11/16] wip: layout --- package-lock.json | 14 - package.json | 2 - src/components/Graph/GravityGraph.tsx | 98 ++----- src/components/Graph/TooltipComponent.tsx | 52 ++-- src/components/Graph/colorsConfig.ts | 2 +- src/components/Graph/treeLayout.ts | 316 ++++++++++++++++++++++ src/components/Graph/utils.ts | 43 +-- 7 files changed, 384 insertions(+), 143 deletions(-) create mode 100644 src/components/Graph/treeLayout.ts diff --git a/package-lock.json b/package-lock.json index d864b34f93..4160efc97d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "colord": "^2.9.3", "copy-to-clipboard": "^3.3.3", "crc-32": "^1.2.2", - "elkjs": "^0.10.0", "history": "^4.10.1", "hotkeys-js": "^3.13.9", "lodash": "^4.17.21", @@ -59,7 +58,6 @@ "use-query-params": "^2.2.1", "uuid": "^10.0.0", "web-vitals": "^1.1.2", - "web-worker": "^1.5.0", "ydb-ui-components": "^5.0.0", "zod": "^3.24.1" }, @@ -10981,12 +10979,6 @@ "integrity": "sha512-SFsAz1hoR+u1eAWjofSPQnx0InE1QHGUAQ92pqYJPT8GARzmyP1zcEBDBxFFC6okJk2E94Ryfmib4DB8Sc6LBw==", "dev": true }, - "node_modules/elkjs": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.0.tgz", - "integrity": "sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==", - "license": "EPL-2.0" - }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -29015,12 +29007,6 @@ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.2.tgz", "integrity": "sha512-PFMKIY+bRSXlMxVAQ+m2aw9c/ioUYfDgrYot0YUa+/xa0sakubWhSDyxAKwzymvXVdF4CZI71g06W+mqhzu6ig==" }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index aa6e74dc03..45274951dc 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "colord": "^2.9.3", "copy-to-clipboard": "^3.3.3", "crc-32": "^1.2.2", - "elkjs": "^0.10.0", "history": "^4.10.1", "hotkeys-js": "^3.13.9", "lodash": "^4.17.21", @@ -61,7 +60,6 @@ "use-query-params": "^2.2.1", "uuid": "^10.0.0", "web-vitals": "^1.1.2", - "web-worker": "^1.5.0", "ydb-ui-components": "^5.0.0", "zod": "^3.24.1" }, diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index a6599a79b1..ff33e8f166 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -7,21 +7,12 @@ import { GraphCanvas, MultipointConnection, TConnection, - useElk, useGraph, useGraphEvent, } from '@gravity-ui/graph/react'; import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; -import type {ElkExtendedEdge, ElkNode} from 'elkjs'; -import ELK from 'elkjs'; -import { - prepareBlocks, - prepareChildren, - prepareConnections, - prepareEdges, - parseCustomPropertyValue, -} from './utils'; +import {prepareBlocks, prepareConnections, parseCustomPropertyValue} from './utils'; import {cn} from '../../utils/cn'; @@ -29,13 +20,13 @@ import './GravityGraph.scss'; const b = cn('ydb-gravity-graph'); -// import {QueryBlockView} from './BlockComponents/QueryBlockView'; import {QueryBlockComponent} from './BlockComponents/QueryBlockComponent'; import {ResultBlockComponent} from './BlockComponents/ResultBlockComponent'; import {StageBlockComponent} from './BlockComponents/StageBlockComponent'; import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockComponent'; import {graphColorsConfig} from './colorsConfig'; -import { GraphControls } from './GraphControls'; +import {GraphControls} from './GraphControls'; +import {calculateTreeLayout, calculateConnectionPaths} from './treeLayout'; interface Props { data: Data; @@ -55,26 +46,7 @@ const config: TGraphConfig = { }, }; -const baseElkConfig = { - id: 'root', - layoutOptions: { - 'elk.algorithm': 'layered', - 'elk.direction': 'DOWN', - // 'elk.spacing.edgeNode': '50', - 'elk.layered.spacing.nodeNodeBetweenLayers': '20', - 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', - 'elk.layered.nodePlacement.bk.ordering': 'INTERACTIVE', - // 'elk.debugMode': true, - // 'elk.alignment': 'CENTER' - }, -}; - - -const elk = new ELK(); - const renderBlockFn = (graph, block) => { - // console.log('===', block); - const map = { query: QueryBlockComponent, result: ResultBlockComponent, @@ -88,10 +60,14 @@ const renderBlockFn = (graph, block) => { {Component ? ( <> - - {block.id !== 'undefined' && block.is !== 'result' &&
- #{block.id} -
} + + {block.id !== 'undefined' && block.is !== 'result' && ( +
#{block.id}
+ )} ) : ( block.id @@ -101,39 +77,19 @@ const renderBlockFn = (graph, block) => { }; export function GravityGraph({data, theme}: Props) { - const _blocks = useMemo(() => prepareBlocks(data.nodes), [data.nodes]); - const _connections = useMemo(() => prepareConnections(data.links), [data.links]); - const elkConfig = useMemo( - () => ({ - ...baseElkConfig, - children: prepareChildren(_blocks), - edges: prepareEdges(_connections), - }), - [_blocks, _connections], - ); const {graph, start} = useGraph(config); - const {isLoading, result} = useElk(elkConfig, elk); React.useEffect(() => { - if (isLoading || !result) { - return; - } - - const blocks = _blocks.map((block) => ({ - ...block, - ...result.blocks[block.id], - })); - - const connections = _connections.map((connection) => ({ - ...connection, - ...(connection.id? result.edges[connection.id] : {}), - })); + const blocks = prepareBlocks(data.nodes); + const connections = prepareConnections(data.links); + const layouted = calculateTreeLayout(blocks, connections); + const edges = calculateConnectionPaths(layouted, connections); graph.setEntities({ - blocks, - connections, + blocks: layouted, + connections: edges, }); - }, [isLoading, result, graph]); + }, [data.nodes, data.links, graph]); React.useEffect(() => { graph.setColors(parseCustomPropertyValue(graphColorsConfig, graph.getGraphCanvas())); @@ -148,20 +104,18 @@ export function GravityGraph({data, theme}: Props) { }); graph.setConstants({ block: { - SCALES: [0.125, 0.225, 0.5] // Detailed view stays until zoom = 0.5 - } + SCALES: [0.125, 0.225, 0.5], // Detailed view stays until zoom = 0.5 + }, }); start(); // graph.zoomTo("center", { padding: 300 }); } }); - // useGraphEvent(graph, 'connection-selection-change', (event) => { - // console.log('connection-click', event); - // }); - - return <> - - - ; + return ( + <> + + + + ); } diff --git a/src/components/Graph/TooltipComponent.tsx b/src/components/Graph/TooltipComponent.tsx index 442bf06653..35c546fafe 100644 --- a/src/components/Graph/TooltipComponent.tsx +++ b/src/components/Graph/TooltipComponent.tsx @@ -1,6 +1,6 @@ -import React, { useState, useMemo } from 'react'; -import type { TBlock } from '@gravity-ui/graph'; -import { Text, Popover, TabProvider, TabList, Tab, TabPanel } from '@gravity-ui/uikit'; +import React, {useState, useMemo} from 'react'; +import type {TBlock} from '@gravity-ui/graph'; +import {Text, Popover, TabProvider, TabList, Tab, TabPanel} from '@gravity-ui/uikit'; import {cn} from '../../utils/cn'; const b = cn('ydb-gravity-graph'); @@ -11,16 +11,26 @@ type Props = { const getStatsContent = (stat) => { if (!stat.items) { - return

{stat.name}:{stat.value}

; + return ( +

+ {stat.name}: + {stat.value} +

+ ); } return (
{stat.name}:
- {stat.items?.map(({ name, value }) =>

{name}:{value}

)} + {stat.items?.map(({name, value}) => ( +

+ {name}: + {value} +

+ ))}
); -} +}; const getTooltipContent = (block: TBlock) => { const [activeTab, setActiveTab] = useState(block?.stats[0]?.group); @@ -28,24 +38,32 @@ const getTooltipContent = (block: TBlock) => { return ( - {block?.stats?.map((item) => {item.group})} + {block?.stats?.map((item) => ( + + {item.group} + + ))} - {block?.stats?.map((item) => - {item.stats?.map(getStatsContent)} - )} + {block?.stats?.map((item) => ( + + {item.stats?.map(getStatsContent)} + + ))} ); -} +}; -export const TooltipComponent = ({ block, children }: Props) => { +export const TooltipComponent = ({block, children}: Props) => { return ( - + strategy="fixed" + > {children} ); diff --git a/src/components/Graph/colorsConfig.ts b/src/components/Graph/colorsConfig.ts index 4137167627..e13aa0b3f8 100644 --- a/src/components/Graph/colorsConfig.ts +++ b/src/components/Graph/colorsConfig.ts @@ -6,7 +6,7 @@ export const graphColorsConfig = { canvas: { belowLayerBackground: '#0000', border: '#0000', - dots: 'var(--g-color-base-background)', + dots: 'var(--g-color-line-generic)', layerBackground: 'var(--g-color-base-background)', }, // block: { diff --git a/src/components/Graph/treeLayout.ts b/src/components/Graph/treeLayout.ts new file mode 100644 index 0000000000..ee7668b483 --- /dev/null +++ b/src/components/Graph/treeLayout.ts @@ -0,0 +1,316 @@ +class TreeLayoutEngine { + constructor(blocks, connections, options = {}) { + this.blocks = new Map(blocks.map((block) => [block.id, {...block}])); + this.connections = connections; + + // Настройки отступов + this.options = { + horizontalSpacing: options.horizontalSpacing || 40, // расстояние между блоками по горизонтали + verticalSpacing: options.verticalSpacing || 20, // расстояние между уровнями + ...options, + }; + + this.tree = null; + this.levels = []; + } + + // Построение структуры дерева + buildTree() { + // Создаем карты родителей и детей + const childrenMap = new Map(); + const parentMap = new Map(); + + // Инициализируем карты + for (const block of this.blocks.values()) { + childrenMap.set(block.id, []); + } + + // Заполняем связи + for (const connection of this.connections) { + const parent = connection.sourceBlockId; + const child = connection.targetBlockId; + + childrenMap.get(parent).push(child); + parentMap.set(child, parent); + } + + // Находим корневой узел (узел без родителя) + const rootId = Array.from(this.blocks.keys()).find((id) => !parentMap.has(id)); + + if (!rootId) { + throw new Error('Root node not found'); + } + + // Рекурсивно строим дерево + const buildNode = (nodeId, level = 0) => { + const block = this.blocks.get(nodeId); + const children = childrenMap + .get(nodeId) + .map((childId) => buildNode(childId, level + 1)); + + return { + id: nodeId, + block: block, + children: children, + level: level, + x: 0, + y: 0, + subtreeWidth: 0, + }; + }; + + this.tree = buildNode(rootId); + return this.tree; + } + + // Группировка узлов по уровням + groupByLevels(node = this.tree, levels = []) { + if (!levels[node.level]) { + levels[node.level] = []; + } + levels[node.level].push(node); + + for (const child of node.children) { + this.groupByLevels(child, levels); + } + + this.levels = levels; + return levels; + } + + // Вычисление ширины поддерева для каждого узла + calculateSubtreeWidths(node = this.tree) { + if (node.children.length === 0) { + // Листовой узел - ширина равна ширине блока + node.subtreeWidth = node.block.width; + } else { + // Рекурсивно вычисляем ширину для детей + for (const child of node.children) { + this.calculateSubtreeWidths(child); + } + + // Ширина поддерева = сумма ширин поддеревьев детей + отступы между ними + const childrenWidth = node.children.reduce((sum, child) => sum + child.subtreeWidth, 0); + const spacingWidth = (node.children.length - 1) * this.options.horizontalSpacing; + const totalChildrenWidth = childrenWidth + spacingWidth; + + // Ширина поддерева = максимум из ширины самого узла и суммарной ширины детей + node.subtreeWidth = Math.max(node.block.width, totalChildrenWidth); + } + + return node.subtreeWidth; + } + + // Размещение узлов по позициям + positionNodes() { + // Вычисляем Y координаты для каждого уровня + let currentY = 0; + const levelY = []; + + for (let level = 0; level < this.levels.length; level++) { + levelY[level] = currentY; + + // Находим максимальную высоту блоков на этом уровне + const maxHeight = Math.max(...this.levels[level].map((node) => node.block.height)); + currentY += maxHeight + this.options.verticalSpacing; + } + + // Рекурсивно размещаем узлы + const positionNode = (node, leftX) => { + // Устанавливаем Y координату + node.y = levelY[node.level]; + + if (node.children.length === 0) { + // Листовой узел - размещаем в текущей позиции + node.x = leftX; + } else { + // Размещаем детей + let childX = leftX; + + // Если ширина узла больше суммарной ширины детей, добавляем отступ + const childrenWidth = node.children.reduce( + (sum, child) => sum + child.subtreeWidth, + 0, + ); + const spacingWidth = (node.children.length - 1) * this.options.horizontalSpacing; + const totalChildrenWidth = childrenWidth + spacingWidth; + + if (node.block.width > totalChildrenWidth) { + const extraSpace = (node.block.width - totalChildrenWidth) / 2; + childX += extraSpace; + } + + // Размещаем каждого ребенка + for (const child of node.children) { + positionNode(child, childX); + childX += child.subtreeWidth + this.options.horizontalSpacing; + } + + // Центрируем родительский узел относительно детей + const firstChild = node.children[0]; + const lastChild = node.children[node.children.length - 1]; + const childrenCenter = (firstChild.x + lastChild.x + lastChild.block.width) / 2; + node.x = childrenCenter - node.block.width / 2; + } + }; + + positionNode(this.tree, 0); + } + + // Нормализация координат (чтобы минимальные координаты были >= 0) + normalizeCoordinates() { + const allNodes = []; + + const collectNodes = (node) => { + allNodes.push(node); + for (const child of node.children) { + collectNodes(child); + } + }; + + collectNodes(this.tree); + + const minX = Math.min(...allNodes.map((node) => node.x)); + const minY = Math.min(...allNodes.map((node) => node.y)); + + // Сдвигаем все координаты так, чтобы минимальные были равны 0 + const offsetX = minX < 0 ? -minX : 0; + const offsetY = minY < 0 ? -minY : 0; + + for (const node of allNodes) { + node.x += offsetX; + node.y += offsetY; + } + } + + // Основной метод компоновки + layout() { + this.buildTree(); + this.groupByLevels(); + this.calculateSubtreeWidths(); + this.positionNodes(); + this.normalizeCoordinates(); + + return this.getLayoutResult(); + } + + // Получение результата компоновки + getLayoutResult() { + const result = []; + + const collectResults = (node) => { + result.push({ + id: node.id, + x: node.x, + y: node.y, + width: node.block.width, + height: node.block.height, + level: node.level, + ...node.block, + }); + + for (const child of node.children) { + collectResults(child); + } + }; + + collectResults(this.tree); + + return result; + } +} + +// Функция для использования алгоритма +export function calculateTreeLayout(blocks, connections, options = {}) { + const engine = new TreeLayoutEngine(blocks, connections, options); + return engine.layout(); +} + +export function calculateConnectionPaths(layoutResult, connections) { + // Создаем карту позиций для удобства поиска + const positionMap = new Map(layoutResult.map((item) => [item.id, item])); + + // Группируем связи по родительскому блоку + const connectionsByParent = new Map(); + + for (const connection of connections) { + const parentId = connection.sourceBlockId; + if (!connectionsByParent.has(parentId)) { + connectionsByParent.set(parentId, []); + } + connectionsByParent.get(parentId).push(connection); + } + + const connectionPaths = []; + + // Для каждого родительского блока рассчитываем пути к детям + for (const [parentId, parentConnections] of connectionsByParent) { + const parent = positionMap.get(parentId); + if (!parent) continue; + + // Координаты начальной точки (центр нижней части родителя) + const startX = parent.x + parent.width / 2; + const startY = parent.y + parent.height; + + if (parentConnections.length === 1) { + // Один дочерний блок - простая прямая линия + const connection = parentConnections[0]; + const child = positionMap.get(connection.targetBlockId); + + if (child) { + const endX = child.x + child.width / 2; + const endY = child.y; + + connectionPaths.push({ + connectionId: connection.id, + sourceBlockId: connection.sourceBlockId, + targetBlockId: connection.targetBlockId, + points: [ + {x: startX, y: startY}, + {x: endX, y: endY}, + ], + }); + } + } else { + // Несколько дочерних блоков - ломаные линии + + // Находим вертикальное расстояние между родителем и ближайшим ребенком + const children = parentConnections + .map((conn) => positionMap.get(conn.targetBlockId)) + .filter((child) => child !== undefined); + + if (children.length === 0) continue; + + // Находим минимальное расстояние до детей по Y + const minChildY = Math.min(...children.map((child) => child.y)); + + // Точка разветвления - посередине между родителем и детьми + const branchY = startY + (minChildY - startY) / 2; + + // Для каждого дочернего блока создаем ломаную линию + for (const connection of parentConnections) { + const child = positionMap.get(connection.targetBlockId); + if (!child) continue; + + const endX = child.x + child.width / 2; + const endY = child.y; + + const points = [ + {x: startX, y: startY}, // Начало - центр нижней части родителя + {x: startX, y: branchY}, // Вертикально вниз до точки разветвления + {x: endX, y: branchY}, // Горизонтально до центра дочернего блока + {x: endX, y: endY}, // Вертикально вниз до центра верхней части дочернего блока + ]; + + connectionPaths.push({ + connectionId: connection.id, + sourceBlockId: connection.sourceBlockId, + targetBlockId: connection.targetBlockId, + points: points, + }); + } + } + } + + return connectionPaths; +} diff --git a/src/components/Graph/utils.ts b/src/components/Graph/utils.ts index 7846a3d361..85bf673cfd 100644 --- a/src/components/Graph/utils.ts +++ b/src/components/Graph/utils.ts @@ -1,41 +1,7 @@ import type {TBlock, TConnection, TGraphConfig} from '@gravity-ui/graph'; import type {Data, GraphNode, Options, Shapes, ExplainPlanNodeData} from '@gravity-ui/paranoid'; -import type {ElkExtendedEdge, ElkNode} from 'elkjs'; import type {AbstractGraphColorsConfig} from './colorsConfig'; -export const prepareChildren = (blocks: TGraphConfig['blocks']) => { - return blocks?.map((b) => { - return { - id: b.id as string, - width: b.width, - height: b.height, - ports: [ - { - id: `port_${b.id as string}`, - }, - ], - // properties: { - // 'elk.portConstraints': 'FIXED_ORDER', - // // 'elk.spacing.portPort': '0', - // }, - } satisfies ElkNode; - }); -}; - -export const prepareEdges = (connections: TGraphConfig['connections'], skipLabels?: boolean) => { - return connections?.map((c, i) => { - const labelText = `label ${i}`; - - return { - id: c.id as string, - sources: [`port_${c.sourceBlockId as string}`], - // sources: [c.sourceBlockId as string], - targets: [c.targetBlockId as string], - // labels: skipLabels ? [] : [{text: labelText, width: 50, height: 14}], - } satisfies ElkExtendedEdge; - }); -}; - const BLOCK_TOP_PADDING = 8; const BLOCK_LINE_HEIGHT = 16; const BORDER_HEIGHT = 2; @@ -57,10 +23,13 @@ const getBlockSize = (block: ExplainPlanNodeData) => { case 'stage': const operatorsLength = block.operators?.length ?? 1; const tablesLength = block.tables?.length ?? 0; - + return { width: 248, - height: BORDER_HEIGHT + BLOCK_TOP_PADDING * 2 + (operatorsLength + tablesLength) * BLOCK_LINE_HEIGHT, + height: + BORDER_HEIGHT + + BLOCK_TOP_PADDING * 2 + + (operatorsLength + tablesLength) * BLOCK_LINE_HEIGHT, }; case 'connection': return { @@ -78,7 +47,7 @@ const getBlockSize = (block: ExplainPlanNodeData) => { height: ONE_LINE_HEIGHT, }; } -} +}; export const prepareBlocks = (nodes: Data['nodes']): TBlock[] => { return nodes?.map(({data: {id, name, type, ...rest}, data}) => ({ From 8fd8ac549648225b36543872850c6041733bede2 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Fri, 5 Sep 2025 12:23:19 +0300 Subject: [PATCH 12/16] wip: custom connection --- .../ConnectionBlockComponent.tsx | 21 ++-- .../Graph/BlockComponents/QueryBlockView.ts | 29 ----- .../BlockComponents/StageBlockComponent.tsx | 30 +++-- src/components/Graph/Graph.tsx | 2 - src/components/Graph/GraphControls.tsx | 16 +-- src/components/Graph/GravityGraph.scss | 5 +- src/components/Graph/GravityGraph.tsx | 18 +-- .../Graph/NonSelectableConnection.tsx | 18 +-- src/components/Graph/colorsConfig.ts | 107 ------------------ 9 files changed, 42 insertions(+), 204 deletions(-) delete mode 100644 src/components/Graph/BlockComponents/QueryBlockView.ts diff --git a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx index 1bc8db37d2..328a6e1451 100644 --- a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx @@ -1,8 +1,14 @@ import type {TBlock} from '@gravity-ui/graph'; -import { Icon } from '@gravity-ui/uikit'; -import {DatabaseFill, Shuffle, GripHorizontal, MapPin, ArrowsExpandHorizontal} from '@gravity-ui/icons'; +import {Icon} from '@gravity-ui/uikit'; +import { + DatabaseFill, + Shuffle, + GripHorizontal, + MapPin, + ArrowsExpandHorizontal, +} from '@gravity-ui/icons'; -import { TooltipComponent } from '../TooltipComponent'; +import {TooltipComponent} from '../TooltipComponent'; type Props = { block: TBlock; @@ -22,13 +28,13 @@ const getIcon = (name: string) => { case 'Broadcast': return ArrowsExpandHorizontal; } -} +}; export const ConnectionBlockComponent = ({className, block}: Props) => { const icon = getIcon(block.name); const content = (
- {icon && } {block.name} + {icon && } {block.name}
); @@ -36,8 +42,5 @@ export const ConnectionBlockComponent = ({className, block}: Props) => { return content; } - return ( - {content} - - ); + return {content}; }; diff --git a/src/components/Graph/BlockComponents/QueryBlockView.ts b/src/components/Graph/BlockComponents/QueryBlockView.ts deleted file mode 100644 index e03b5cf62d..0000000000 --- a/src/components/Graph/BlockComponents/QueryBlockView.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {Graph, GraphState, CanvasBlock} from '@gravity-ui/graph'; - -export class QueryBlockView extends CanvasBlock { - protected renderStroke(color: string) { - this.context.ctx.lineWidth = Math.round(3 / this.context.camera.getCameraScale()); - this.context.ctx.strokeStyle = color; - this.context.ctx.stroke(); - } - - public override renderSchematicView(ctx: CanvasRenderingContext2D) { - // Draw circle with shadow - this.context.ctx.save(); - this.context.ctx.shadowOffsetX = 1; - this.context.ctx.shadowOffsetY = 1; - this.context.ctx.shadowBlur = 5; - this.context.ctx.shadowColor = 'rgba(0,0,0,0.15)'; - this.context.ctx.fillStyle = this.context.colors.block?.background; - this.context.ctx.beginPath(); - const centerX = this.state.x + this.state.width / 2; - const centerY = this.state.y + this.state.height / 2; - this.context.ctx.arc(centerX, centerY, 20, 0, Math.PI * 2); - this.context.ctx.fill(); - this.context.ctx.restore(); - - this.context.ctx.restore(); - - this.context.ctx.globalAlpha = 1; - } -} diff --git a/src/components/Graph/BlockComponents/StageBlockComponent.tsx b/src/components/Graph/BlockComponents/StageBlockComponent.tsx index 024352f117..11dda61c81 100644 --- a/src/components/Graph/BlockComponents/StageBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/StageBlockComponent.tsx @@ -1,25 +1,31 @@ -import type { TBlock } from '@gravity-ui/graph'; -import { Text } from '@gravity-ui/uikit'; +import type {TBlock} from '@gravity-ui/graph'; +import {Text} from '@gravity-ui/uikit'; -import { TooltipComponent } from '../TooltipComponent'; +import {TooltipComponent} from '../TooltipComponent'; type Props = { block: TBlock; className: string; }; -export const StageBlockComponent = ({ className, block }: Props) => { - const content =
- {block.operators ? block.operators.map((item) =>
{item}
) : block.name} - {block.tables ?
Tables: {block.tables.join(', ')}
: null} -
; +export const StageBlockComponent = ({className, block}: Props) => { + const content = ( +
+ {block.operators + ? block.operators.map((item) =>
{item}
) + : block.name} + {block.tables ? ( +
+ Tables: + {block.tables.join(', ')} +
+ ) : null} +
+ ); if (!block.stats?.length) { return content; } - return ( - {content} - - ); + return {content}; }; diff --git a/src/components/Graph/Graph.tsx b/src/components/Graph/Graph.tsx index c1e6f6ba74..33ba32d68b 100644 --- a/src/components/Graph/Graph.tsx +++ b/src/components/Graph/Graph.tsx @@ -10,8 +10,6 @@ interface GraphProps { } export function Graph(props: GraphProps) { - console.log(props); - const containerRef = React.useRef(null); const containerId = React.useId(); diff --git a/src/components/Graph/GraphControls.tsx b/src/components/Graph/GraphControls.tsx index d1bd3d4904..8d6a563a36 100644 --- a/src/components/Graph/GraphControls.tsx +++ b/src/components/Graph/GraphControls.tsx @@ -29,25 +29,15 @@ export const GraphControls = ({graph}: Props) => { return (
- - -
); }; - diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss index 07272efe89..90a9abd937 100644 --- a/src/components/Graph/GravityGraph.scss +++ b/src/components/Graph/GravityGraph.scss @@ -7,7 +7,6 @@ &__block-content { width: 100%; - // height: 100%; padding: 8px 12px; background: var(--g-color-base-float); box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); @@ -28,7 +27,7 @@ color: var(--g-color-text-secondary); font-size: 10px; line-height: 1; - } + } &__block-content.query { border-radius: 50%; @@ -89,7 +88,7 @@ &__tooltip-stat-group { margin-top: 8px; } - + &__tooltip-stat-group + &__tooltip-stat-group { padding-top: 8px; border-top: 1px dotted var(--g-color-line-generic); diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index ff33e8f166..0113c0969f 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -2,14 +2,7 @@ import React, {useEffect, useMemo} from 'react'; import type {TBlock, TGraphConfig} from '@gravity-ui/graph'; import {Graph, GraphState, CanvasBlock} from '@gravity-ui/graph'; -import { - GraphBlock, - GraphCanvas, - MultipointConnection, - TConnection, - useGraph, - useGraphEvent, -} from '@gravity-ui/graph/react'; +import {GraphBlock, GraphCanvas, useGraph, useGraphEvent} from '@gravity-ui/graph/react'; import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; import {prepareBlocks, prepareConnections, parseCustomPropertyValue} from './utils'; @@ -27,6 +20,7 @@ import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockCompone import {graphColorsConfig} from './colorsConfig'; import {GraphControls} from './GraphControls'; import {calculateTreeLayout, calculateConnectionPaths} from './treeLayout'; +import {NonSelectableConnection} from './NonSelectableConnection'; interface Props { data: Data; @@ -35,13 +29,7 @@ interface Props { const config: TGraphConfig = { settings: { - connection: MultipointConnection, - // blockComponents: { - // query: QueryBlockView, - // }, - // canDragCamera: true, - // canZoomCamera: false, - // useBezierConnections: false, + connection: NonSelectableConnection, showConnectionArrows: false, }, }; diff --git a/src/components/Graph/NonSelectableConnection.tsx b/src/components/Graph/NonSelectableConnection.tsx index 10ec21785b..9b33840659 100644 --- a/src/components/Graph/NonSelectableConnection.tsx +++ b/src/components/Graph/NonSelectableConnection.tsx @@ -5,20 +5,10 @@ import {MultipointConnection} from '@gravity-ui/graph/react'; * Наследуется от MultipointConnection и переопределяет поведение */ export class NonSelectableConnection extends MultipointConnection { - // Переопределяем метод для предотвращения выделения при клике - public override onClick() { - // Ничего не делаем при клике - блокируем выделение - return; - } + public override cursor = 'default'; - // Переопределяем метод для отключения hover эффектов - public override onPointerEnter() { - // Ничего не делаем при наведении - return; - } - - public override onPointerLeave() { - // Ничего не делаем при уходе курсора - return; + // Переопределяем метод для предотвращения выделения при клике + protected override handleEvent(event) { + event.stopPropagation(); } } diff --git a/src/components/Graph/colorsConfig.ts b/src/components/Graph/colorsConfig.ts index e13aa0b3f8..99000dd6fe 100644 --- a/src/components/Graph/colorsConfig.ts +++ b/src/components/Graph/colorsConfig.ts @@ -2,122 +2,15 @@ export type AbstractGraphColorsConfig = Partial Date: Fri, 5 Sep 2025 16:27:50 +0300 Subject: [PATCH 13/16] wip: worker --- src/components/Graph/GravityGraph.tsx | 32 +++++++++++++++++++-------- src/components/Graph/treeLayout.ts | 19 ++++++++++++++-- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index 0113c0969f..7ffeae983c 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -19,7 +19,7 @@ import {StageBlockComponent} from './BlockComponents/StageBlockComponent'; import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockComponent'; import {graphColorsConfig} from './colorsConfig'; import {GraphControls} from './GraphControls'; -import {calculateTreeLayout, calculateConnectionPaths} from './treeLayout'; +// import {calculateTreeLayout, calculateConnectionPaths} from './treeLayout'; import {NonSelectableConnection} from './NonSelectableConnection'; interface Props { @@ -68,15 +68,29 @@ export function GravityGraph({data, theme}: Props) { const {graph, start} = useGraph(config); React.useEffect(() => { - const blocks = prepareBlocks(data.nodes); - const connections = prepareConnections(data.links); - const layouted = calculateTreeLayout(blocks, connections); - const edges = calculateConnectionPaths(layouted, connections); - - graph.setEntities({ - blocks: layouted, - connections: edges, + // на всякий случай, хотя маунт больше времени занимает, чем расчёт + const worker = new Worker(new URL('./treeLayout', import.meta.url)); + worker.postMessage({ + nodes: data.nodes, + links: data.links, }); + + worker.onmessage = function (e) { + const {layout, edges} = e.data; + + graph.setEntities({ + blocks: layout, + connections: edges, + }); + }; + + worker.onerror = (err) => { + console.error(err); + }; + + return () => { + worker.terminate(); + }; }, [data.nodes, data.links, graph]); React.useEffect(() => { diff --git a/src/components/Graph/treeLayout.ts b/src/components/Graph/treeLayout.ts index ee7668b483..09aec446a2 100644 --- a/src/components/Graph/treeLayout.ts +++ b/src/components/Graph/treeLayout.ts @@ -1,3 +1,5 @@ +import {prepareBlocks, prepareConnections} from './utils'; + class TreeLayoutEngine { constructor(blocks, connections, options = {}) { this.blocks = new Map(blocks.map((block) => [block.id, {...block}])); @@ -221,12 +223,12 @@ class TreeLayoutEngine { } // Функция для использования алгоритма -export function calculateTreeLayout(blocks, connections, options = {}) { +function calculateTreeLayout(blocks, connections, options = {}) { const engine = new TreeLayoutEngine(blocks, connections, options); return engine.layout(); } -export function calculateConnectionPaths(layoutResult, connections) { +function calculateTreeEdges(layoutResult, connections) { // Создаем карту позиций для удобства поиска const positionMap = new Map(layoutResult.map((item) => [item.id, item])); @@ -314,3 +316,16 @@ export function calculateConnectionPaths(layoutResult, connections) { return connectionPaths; } + +onmessage = function (e) { + const {nodes, links} = e.data; + const blocks = prepareBlocks(nodes); + const connections = prepareConnections(links); + const layout = calculateTreeLayout(blocks, connections); + const edges = calculateTreeEdges(layout, connections); + + postMessage({ + layout, + edges, + }); +}; From 2c54e7015e4664e5f7f4a5e54c592f8aa38bf3c1 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Fri, 5 Sep 2025 17:15:18 +0300 Subject: [PATCH 14/16] wip: fixes --- .../ConnectionBlockComponent.tsx | 15 ++-- .../BlockComponents/StageBlockComponent.tsx | 4 +- src/components/Graph/GraphControls.tsx | 6 +- src/components/Graph/GravityGraph.tsx | 26 +++---- .../Graph/NonSelectableConnection.tsx | 4 +- src/components/Graph/TooltipComponent.tsx | 32 +++++--- src/components/Graph/treeLayout.ts | 52 +++++++++---- src/components/Graph/types.ts | 74 +++++++++++++++++++ src/components/Graph/utils.ts | 10 +-- 9 files changed, 165 insertions(+), 58 deletions(-) create mode 100644 src/components/Graph/types.ts diff --git a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx index 328a6e1451..9a4140a2a5 100644 --- a/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/ConnectionBlockComponent.tsx @@ -1,21 +1,22 @@ -import type {TBlock} from '@gravity-ui/graph'; -import {Icon} from '@gravity-ui/uikit'; import { + ArrowsExpandHorizontal, DatabaseFill, - Shuffle, GripHorizontal, MapPin, - ArrowsExpandHorizontal, + Shuffle, } from '@gravity-ui/icons'; +import {Icon} from '@gravity-ui/uikit'; +import type {IconData} from '@gravity-ui/uikit'; import {TooltipComponent} from '../TooltipComponent'; +import type {ExtendedTBlock} from '../types'; type Props = { - block: TBlock; + block: ExtendedTBlock; className: string; }; -const getIcon = (name: string) => { +const getIcon = (name: string): IconData | undefined => { switch (name) { case 'Merge': return DatabaseFill; @@ -27,6 +28,8 @@ const getIcon = (name: string) => { return MapPin; case 'Broadcast': return ArrowsExpandHorizontal; + default: + return undefined; } }; diff --git a/src/components/Graph/BlockComponents/StageBlockComponent.tsx b/src/components/Graph/BlockComponents/StageBlockComponent.tsx index 11dda61c81..33eb5e1688 100644 --- a/src/components/Graph/BlockComponents/StageBlockComponent.tsx +++ b/src/components/Graph/BlockComponents/StageBlockComponent.tsx @@ -1,10 +1,10 @@ -import type {TBlock} from '@gravity-ui/graph'; import {Text} from '@gravity-ui/uikit'; import {TooltipComponent} from '../TooltipComponent'; +import type {ExtendedTBlock} from '../types'; type Props = { - block: TBlock; + block: ExtendedTBlock; className: string; }; diff --git a/src/components/Graph/GraphControls.tsx b/src/components/Graph/GraphControls.tsx index 8d6a563a36..aee4087d13 100644 --- a/src/components/Graph/GraphControls.tsx +++ b/src/components/Graph/GraphControls.tsx @@ -1,9 +1,11 @@ import type {Graph} from '@gravity-ui/graph'; -import MagnifierMinusIcon from '@gravity-ui/icons/svgs/magnifier-minus.svg'; -import MagnifierPlusIcon from '@gravity-ui/icons/svgs/magnifier-plus.svg'; import {Button, Icon} from '@gravity-ui/uikit'; import {cn} from '../../utils/cn'; + +import MagnifierMinusIcon from '@gravity-ui/icons/svgs/magnifier-minus.svg'; +import MagnifierPlusIcon from '@gravity-ui/icons/svgs/magnifier-plus.svg'; + const b = cn('ydb-gravity-graph'); const ZOOM_STEP = 1.25; diff --git a/src/components/Graph/GravityGraph.tsx b/src/components/Graph/GravityGraph.tsx index 7ffeae983c..b6b90a56aa 100644 --- a/src/components/Graph/GravityGraph.tsx +++ b/src/components/Graph/GravityGraph.tsx @@ -1,26 +1,24 @@ -import React, {useEffect, useMemo} from 'react'; +import React from 'react'; -import type {TBlock, TGraphConfig} from '@gravity-ui/graph'; -import {Graph, GraphState, CanvasBlock} from '@gravity-ui/graph'; +import type {TGraphConfig} from '@gravity-ui/graph'; +import {GraphState} from '@gravity-ui/graph'; import {GraphBlock, GraphCanvas, useGraph, useGraphEvent} from '@gravity-ui/graph/react'; -import type {Data, GraphNode, Options, Shapes} from '@gravity-ui/paranoid'; - -import {prepareBlocks, prepareConnections, parseCustomPropertyValue} from './utils'; import {cn} from '../../utils/cn'; -import './GravityGraph.scss'; - const b = cn('ydb-gravity-graph'); +import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockComponent'; import {QueryBlockComponent} from './BlockComponents/QueryBlockComponent'; import {ResultBlockComponent} from './BlockComponents/ResultBlockComponent'; import {StageBlockComponent} from './BlockComponents/StageBlockComponent'; -import {ConnectionBlockComponent} from './BlockComponents/ConnectionBlockComponent'; -import {graphColorsConfig} from './colorsConfig'; import {GraphControls} from './GraphControls'; -// import {calculateTreeLayout, calculateConnectionPaths} from './treeLayout'; import {NonSelectableConnection} from './NonSelectableConnection'; +import {graphColorsConfig} from './colorsConfig'; +import type {Data} from './types'; +import {parseCustomPropertyValue} from './utils'; + +import './GravityGraph.scss'; interface Props { data: Data; @@ -34,15 +32,15 @@ const config: TGraphConfig = { }, }; -const renderBlockFn = (graph, block) => { - const map = { +const renderBlockFn = (graph: any, block: any) => { + const map: Record> = { query: QueryBlockComponent, result: ResultBlockComponent, stage: StageBlockComponent, connection: ConnectionBlockComponent, }; - const Component = map[block.is]; + const Component = map[block.is as keyof typeof map]; return ( diff --git a/src/components/Graph/NonSelectableConnection.tsx b/src/components/Graph/NonSelectableConnection.tsx index 9b33840659..717841e4d1 100644 --- a/src/components/Graph/NonSelectableConnection.tsx +++ b/src/components/Graph/NonSelectableConnection.tsx @@ -5,10 +5,10 @@ import {MultipointConnection} from '@gravity-ui/graph/react'; * Наследуется от MultipointConnection и переопределяет поведение */ export class NonSelectableConnection extends MultipointConnection { - public override cursor = 'default'; + public override cursor: 'pointer' = 'pointer'; // Переопределяем метод для предотвращения выделения при клике - protected override handleEvent(event) { + protected override handleEvent(event: Event) { event.stopPropagation(); } } diff --git a/src/components/Graph/TooltipComponent.tsx b/src/components/Graph/TooltipComponent.tsx index 35c546fafe..dedbbc2047 100644 --- a/src/components/Graph/TooltipComponent.tsx +++ b/src/components/Graph/TooltipComponent.tsx @@ -1,16 +1,23 @@ -import React, {useState, useMemo} from 'react'; -import type {TBlock} from '@gravity-ui/graph'; -import {Text, Popover, TabProvider, TabList, Tab, TabPanel} from '@gravity-ui/uikit'; +import React, {useState} from 'react'; + +import {Popover, Tab, TabList, TabPanel, TabProvider} from '@gravity-ui/uikit'; import {cn} from '../../utils/cn'; + +import type { + ExtendedTBlock, + TopologyNodeDataStatsItem, + TopologyNodeDataStatsSection, +} from './types'; + const b = cn('ydb-gravity-graph'); type Props = { - block: TBlock; + block: ExtendedTBlock; children: React.ReactNode; }; -const getStatsContent = (stat) => { - if (!stat.items) { +const getStatsContent = (stat: TopologyNodeDataStatsItem | TopologyNodeDataStatsSection) => { + if ('value' in stat) { return (

{stat.name}: @@ -22,7 +29,7 @@ const getStatsContent = (stat) => { return (

{stat.name}:
- {stat.items?.map(({name, value}) => ( + {stat.items?.map(({name, value}: TopologyNodeDataStatsItem) => (

{name}: {value} @@ -32,8 +39,9 @@ const getStatsContent = (stat) => { ); }; -const getTooltipContent = (block: TBlock) => { - const [activeTab, setActiveTab] = useState(block?.stats[0]?.group); +const useTooltipContent = (block: ExtendedTBlock) => { + const firstTab = block?.stats?.[0]?.group || ''; + const [activeTab, setActiveTab] = useState(firstTab); return ( @@ -54,9 +62,11 @@ const getTooltipContent = (block: TBlock) => { }; export const TooltipComponent = ({block, children}: Props) => { + const content = useTooltipContent(block); + return ( { disablePortal strategy="fixed" > - {children} + {children as React.ReactElement} ); }; diff --git a/src/components/Graph/treeLayout.ts b/src/components/Graph/treeLayout.ts index 09aec446a2..85315e97f9 100644 --- a/src/components/Graph/treeLayout.ts +++ b/src/components/Graph/treeLayout.ts @@ -1,8 +1,17 @@ +import type {TBlock, TConnection} from '@gravity-ui/graph'; + +import type {ExtendedTBlock, LayoutOptions, TreeNode} from './types'; import {prepareBlocks, prepareConnections} from './utils'; class TreeLayoutEngine { - constructor(blocks, connections, options = {}) { - this.blocks = new Map(blocks.map((block) => [block.id, {...block}])); + private blocks: Map; + private connections: TConnection[]; + private options: Required; + private tree: TreeNode | null; + private levels: TreeNode[][]; + + constructor(blocks: any[], connections: TConnection[], options: LayoutOptions = {}) { + this.blocks = new Map(blocks.map((block: any) => [block.id, {...block}])); this.connections = connections; // Настройки отступов @@ -44,11 +53,11 @@ class TreeLayoutEngine { } // Рекурсивно строим дерево - const buildNode = (nodeId, level = 0) => { + const buildNode = (nodeId: string, level = 0): TreeNode => { const block = this.blocks.get(nodeId); const children = childrenMap .get(nodeId) - .map((childId) => buildNode(childId, level + 1)); + .map((childId: string) => buildNode(childId, level + 1)); return { id: nodeId, @@ -81,7 +90,7 @@ class TreeLayoutEngine { } // Вычисление ширины поддерева для каждого узла - calculateSubtreeWidths(node = this.tree) { + calculateSubtreeWidths(node: TreeNode = this.tree!): number { if (node.children.length === 0) { // Листовой узел - ширина равна ширине блока node.subtreeWidth = node.block.width; @@ -92,7 +101,10 @@ class TreeLayoutEngine { } // Ширина поддерева = сумма ширин поддеревьев детей + отступы между ними - const childrenWidth = node.children.reduce((sum, child) => sum + child.subtreeWidth, 0); + const childrenWidth = node.children.reduce( + (sum: number, child: TreeNode) => sum + child.subtreeWidth, + 0, + ); const spacingWidth = (node.children.length - 1) * this.options.horizontalSpacing; const totalChildrenWidth = childrenWidth + spacingWidth; @@ -107,18 +119,20 @@ class TreeLayoutEngine { positionNodes() { // Вычисляем Y координаты для каждого уровня let currentY = 0; - const levelY = []; + const levelY: number[] = []; for (let level = 0; level < this.levels.length; level++) { levelY[level] = currentY; // Находим максимальную высоту блоков на этом уровне - const maxHeight = Math.max(...this.levels[level].map((node) => node.block.height)); + const maxHeight = Math.max( + ...this.levels[level].map((node: TreeNode) => node.block.height), + ); currentY += maxHeight + this.options.verticalSpacing; } // Рекурсивно размещаем узлы - const positionNode = (node, leftX) => { + const positionNode = (node: TreeNode, leftX: number): void => { // Устанавливаем Y координату node.y = levelY[node.level]; @@ -131,7 +145,7 @@ class TreeLayoutEngine { // Если ширина узла больше суммарной ширины детей, добавляем отступ const childrenWidth = node.children.reduce( - (sum, child) => sum + child.subtreeWidth, + (sum: number, child: TreeNode) => sum + child.subtreeWidth, 0, ); const spacingWidth = (node.children.length - 1) * this.options.horizontalSpacing; @@ -198,7 +212,7 @@ class TreeLayoutEngine { // Получение результата компоновки getLayoutResult() { - const result = []; + const result: ExtendedTBlock[] = []; const collectResults = (node) => { result.push({ @@ -223,12 +237,12 @@ class TreeLayoutEngine { } // Функция для использования алгоритма -function calculateTreeLayout(blocks, connections, options = {}) { +function calculateTreeLayout(blocks: TBlock[], connections: TConnection[], options = {}) { const engine = new TreeLayoutEngine(blocks, connections, options); return engine.layout(); } -function calculateTreeEdges(layoutResult, connections) { +function calculateTreeEdges(layoutResult: ExtendedTBlock[], connections: TConnection[]) { // Создаем карту позиций для удобства поиска const positionMap = new Map(layoutResult.map((item) => [item.id, item])); @@ -248,7 +262,9 @@ function calculateTreeEdges(layoutResult, connections) { // Для каждого родительского блока рассчитываем пути к детям for (const [parentId, parentConnections] of connectionsByParent) { const parent = positionMap.get(parentId); - if (!parent) continue; + if (!parent) { + continue; + } // Координаты начальной точки (центр нижней части родителя) const startX = parent.x + parent.width / 2; @@ -281,7 +297,9 @@ function calculateTreeEdges(layoutResult, connections) { .map((conn) => positionMap.get(conn.targetBlockId)) .filter((child) => child !== undefined); - if (children.length === 0) continue; + if (children.length === 0) { + continue; + } // Находим минимальное расстояние до детей по Y const minChildY = Math.min(...children.map((child) => child.y)); @@ -292,7 +310,9 @@ function calculateTreeEdges(layoutResult, connections) { // Для каждого дочернего блока создаем ломаную линию for (const connection of parentConnections) { const child = positionMap.get(connection.targetBlockId); - if (!child) continue; + if (!child) { + continue; + } const endX = child.x + child.width / 2; const endY = child.y; diff --git a/src/components/Graph/types.ts b/src/components/Graph/types.ts new file mode 100644 index 0000000000..068bc61472 --- /dev/null +++ b/src/components/Graph/types.ts @@ -0,0 +1,74 @@ +import type {TBlock} from '@gravity-ui/graph'; + +export interface GraphNode { + name: string; + status?: string; + meta?: string; + group?: string; + data?: TData; + metrics?: Metric[]; +} + +// Extended block interface with additional properties +export interface ExtendedTBlock extends TBlock { + stats?: TopologyNodeDataStats[]; + operators?: string[]; + tables?: string[]; +} +export type LinkType = 'arrow' | 'line'; +export interface Link { + from: string; + to: string; +} +export interface Data { + links: Link[]; + nodes: GraphNode[]; +} +export interface Metric { + name: string; + value: string; + theme?: 'warning' | 'danger'; +} +export interface ExplainPlanNodeData { + id?: number; + type: 'query' | 'result' | 'stage' | 'connection' | 'materialize'; + name?: string; + operators?: string[]; + tables?: string[]; + cte?: string; + stats?: TopologyNodeDataStats[]; +} +export interface TopologyNodeDataStatsItem { + name: string; + value: string | number; +} +export interface TopologyNodeDataStatsSection { + name: string; + items: TopologyNodeDataStatsItem[]; +} +export interface TopologyNodeDataStats { + group: string; + stats: TopologyNodeDataStatsSection[] | TopologyNodeDataStatsItem[]; +} + +// TreeLayout related types +export interface LayoutOptions { + horizontalSpacing?: number; + verticalSpacing?: number; +} + +export interface TreeNode { + id: string; + level: number; + block: any; + children: TreeNode[]; + subtreeWidth: number; + x: number; + y: number; +} + +export interface EdgeResult { + points: Array<{x: number; y: number}>; + sourceBlockId: string; + targetBlockId: string; +} diff --git a/src/components/Graph/utils.ts b/src/components/Graph/utils.ts index 85bf673cfd..dd4b9efde2 100644 --- a/src/components/Graph/utils.ts +++ b/src/components/Graph/utils.ts @@ -1,6 +1,7 @@ -import type {TBlock, TConnection, TGraphConfig} from '@gravity-ui/graph'; -import type {Data, GraphNode, Options, Shapes, ExplainPlanNodeData} from '@gravity-ui/paranoid'; +import type {TBlock, TConnection} from '@gravity-ui/graph'; + import type {AbstractGraphColorsConfig} from './colorsConfig'; +import type {Data, ExplainPlanNodeData} from './types'; const BLOCK_TOP_PADDING = 8; const BLOCK_LINE_HEIGHT = 16; @@ -8,6 +9,8 @@ const BORDER_HEIGHT = 2; const getBlockSize = (block: ExplainPlanNodeData) => { const ONE_LINE_HEIGHT = BLOCK_TOP_PADDING * 2 + BLOCK_LINE_HEIGHT + BORDER_HEIGHT; + const operatorsLength = block.operators?.length ?? 1; + const tablesLength = block.tables?.length ?? 0; switch (block.type) { case 'query': @@ -21,9 +24,6 @@ const getBlockSize = (block: ExplainPlanNodeData) => { height: ONE_LINE_HEIGHT, }; case 'stage': - const operatorsLength = block.operators?.length ?? 1; - const tablesLength = block.tables?.length ?? 0; - return { width: 248, height: From f79c0221fe8b87ea0121fe39f91d11a7655519ee Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Mon, 8 Sep 2025 13:39:26 +0300 Subject: [PATCH 15/16] wip: lint fixes --- src/components/Graph/GravityGraph.scss | 42 ++++++++++++------- src/components/Graph/types.ts | 2 +- .../Query/QueryResult/QueryResultViewer.tsx | 1 - .../QueryResult/components/Graph/Graph.scss | 3 +- .../QueryResult/components/Graph/Graph.tsx | 2 - src/store/reducers/query/prepareQueryData.ts | 2 - 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/components/Graph/GravityGraph.scss b/src/components/Graph/GravityGraph.scss index 90a9abd937..b57f31f0d7 100644 --- a/src/components/Graph/GravityGraph.scss +++ b/src/components/Graph/GravityGraph.scss @@ -1,20 +1,23 @@ .ydb-gravity-graph { &__block { - background: none; - border: none; cursor: auto; + + border: none; + background: none; } &__block-content { width: 100%; padding: 8px 12px; - background: var(--g-color-base-float); - box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); - border: 1px solid var(--g-color-line-generic); - font-size: var(--g-text-body-short-font-size); + font-family: var(--g-font-family); + font-size: var(--g-text-body-short-font-size); line-height: var(--g-text-body-short-line-height); + border: 1px solid var(--g-color-line-generic); + background: var(--g-color-base-float); + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.3); + &[aria-haspopup='dialog'] { cursor: pointer; } @@ -24,20 +27,23 @@ position: absolute; top: 4px; right: 4px; - color: var(--g-color-text-secondary); + font-size: 10px; line-height: 1; + + color: var(--g-color-text-secondary); } &__block-content.query { - border-radius: 50%; height: 100%; + + border-radius: 50%; } &__block-content.result { display: flex; - align-items: center; justify-content: center; + align-items: center; } &__block-content.stage { @@ -48,18 +54,20 @@ display: flex; align-items: center; gap: 4px; + + color: var(--g-color-text-info-heavy); + border: 1px solid var(--g-color-line-info); border-radius: 6px; - box-shadow: none; background: var(--g-color-base-info-light); - border: 1px solid var(--g-color-line-info); - color: var(--g-color-text-info-heavy); + box-shadow: none; } &__tooltip-content { - padding: 0 8px 8px; width: 300px; - font-size: var(--g-text-body-short-font-size); + padding: 0 8px 8px; + font-family: var(--g-font-family); + font-size: var(--g-text-body-short-font-size); line-height: var(--g-text-body-short-line-height); } @@ -71,10 +79,12 @@ display: grid; grid-template-columns: 100px auto; gap: 8px; + margin: 4px 0 0; span { overflow: hidden; + text-overflow: ellipsis; } span:nth-child(1) { @@ -91,6 +101,7 @@ &__tooltip-stat-group + &__tooltip-stat-group { padding-top: 8px; + border-top: 1px dotted var(--g-color-line-generic); } @@ -100,9 +111,10 @@ &__zoom-controls { position: absolute; + z-index: 999; top: 8px; right: 50px; - z-index: 999; + display: flex; gap: 2px; } diff --git a/src/components/Graph/types.ts b/src/components/Graph/types.ts index 068bc61472..09289d3880 100644 --- a/src/components/Graph/types.ts +++ b/src/components/Graph/types.ts @@ -21,7 +21,7 @@ export interface Link { to: string; } export interface Data { - links: Link[]; + links?: Link[]; nodes: GraphNode[]; } export interface Metric { diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx index 3d74249c12..1ea7835b8a 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx @@ -272,7 +272,6 @@ export function QueryResultViewer({ if (!preparedPlan?.nodes?.length) { return renderStubMessage(); } - console.log(preparedPlan); return ; } diff --git a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss index 547640e301..5586c013fe 100644 --- a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss +++ b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.scss @@ -1,9 +1,10 @@ .ydb-query-explain-graph { &__canvas-container { + position: relative; + overflow-y: auto; width: 100%; height: 100%; - position: relative; } } diff --git a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx index c59ab58a07..deee3373fb 100644 --- a/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/Graph/Graph.tsx @@ -23,8 +23,6 @@ function isValidGraphData(data: Partial): data is Data { } export function Graph({explain = {}, theme}: GraphProps) { - console.log('explain', explain); - const {links, nodes} = explain; const data = React.useMemo(() => ({links, nodes}), [links, nodes]); diff --git a/src/store/reducers/query/prepareQueryData.ts b/src/store/reducers/query/prepareQueryData.ts index a7d562f3e0..3c7d6ec39c 100644 --- a/src/store/reducers/query/prepareQueryData.ts +++ b/src/store/reducers/query/prepareQueryData.ts @@ -10,9 +10,7 @@ export function prepareQueryData( const result = parseQueryAPIResponse(response); const {plan: rawPlan, stats} = result; - const {simplifiedPlan, ...planData} = preparePlanData(rawPlan, stats); - console.log('prepareQueryData', rawPlan, planData); return { ...result, preparedPlan: Object.keys(planData).length > 0 ? planData : undefined, From d39d2687d766c54a7d0571a34bbd8f93abdaf6d6 Mon Sep 17 00:00:00 2001 From: Yury Popov Date: Mon, 8 Sep 2025 13:56:16 +0300 Subject: [PATCH 16/16] fix types --- src/components/Graph/treeLayout.ts | 42 +++++++++++++++---- src/components/Graph/types.ts | 2 +- .../Query/QueryResult/QueryResultViewer.tsx | 1 - 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/components/Graph/treeLayout.ts b/src/components/Graph/treeLayout.ts index 85315e97f9..5dc4537999 100644 --- a/src/components/Graph/treeLayout.ts +++ b/src/components/Graph/treeLayout.ts @@ -75,7 +75,11 @@ class TreeLayoutEngine { } // Группировка узлов по уровням - groupByLevels(node = this.tree, levels = []) { + groupByLevels(node: TreeNode | null = this.tree, levels: TreeNode[][] = []): TreeNode[][] { + if (!node) { + return levels; + } + if (!levels[node.level]) { levels[node.level] = []; } @@ -117,6 +121,10 @@ class TreeLayoutEngine { // Размещение узлов по позициям positionNodes() { + if (!this.tree) { + return; + } + // Вычисляем Y координаты для каждого уровня let currentY = 0; const levelY: number[] = []; @@ -175,9 +183,13 @@ class TreeLayoutEngine { // Нормализация координат (чтобы минимальные координаты были >= 0) normalizeCoordinates() { - const allNodes = []; + if (!this.tree) { + return; + } - const collectNodes = (node) => { + const allNodes: TreeNode[] = []; + + const collectNodes = (node: TreeNode) => { allNodes.push(node); for (const child of node.children) { collectNodes(child); @@ -211,10 +223,14 @@ class TreeLayoutEngine { } // Получение результата компоновки - getLayoutResult() { + getLayoutResult(): ExtendedTBlock[] { + if (!this.tree) { + return []; + } + const result: ExtendedTBlock[] = []; - const collectResults = (node) => { + const collectResults = (node: TreeNode) => { result.push({ id: node.id, x: node.x, @@ -257,7 +273,12 @@ function calculateTreeEdges(layoutResult: ExtendedTBlock[], connections: TConnec connectionsByParent.get(parentId).push(connection); } - const connectionPaths = []; + const connectionPaths: { + connectionId: string | undefined; + sourceBlockId: string | number; + targetBlockId: string | number; + points: {x: number; y: number}[]; + }[] = []; // Для каждого родительского блока рассчитываем пути к детям for (const [parentId, parentConnections] of connectionsByParent) { @@ -294,15 +315,18 @@ function calculateTreeEdges(layoutResult: ExtendedTBlock[], connections: TConnec // Находим вертикальное расстояние между родителем и ближайшим ребенком const children = parentConnections - .map((conn) => positionMap.get(conn.targetBlockId)) - .filter((child) => child !== undefined); + .map((conn: TConnection) => positionMap.get(conn.targetBlockId)) + .filter( + (child: ExtendedTBlock | undefined): child is ExtendedTBlock => + child !== undefined, + ); if (children.length === 0) { continue; } // Находим минимальное расстояние до детей по Y - const minChildY = Math.min(...children.map((child) => child.y)); + const minChildY = Math.min(...children.map((child: ExtendedTBlock) => child.y)); // Точка разветвления - посередине между родителем и детьми const branchY = startY + (minChildY - startY) / 2; diff --git a/src/components/Graph/types.ts b/src/components/Graph/types.ts index 09289d3880..068bc61472 100644 --- a/src/components/Graph/types.ts +++ b/src/components/Graph/types.ts @@ -21,7 +21,7 @@ export interface Link { to: string; } export interface Data { - links?: Link[]; + links: Link[]; nodes: GraphNode[]; } export interface Metric { diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx index 1ea7835b8a..33e14be764 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx @@ -272,7 +272,6 @@ export function QueryResultViewer({ if (!preparedPlan?.nodes?.length) { return renderStubMessage(); } - return ; } if (activeSection === RESULT_OPTIONS_IDS.json) {