diff --git a/package-lock.json b/package-lock.json
index 488dc32e4b43..3b59d041ff90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@gravity-ui/chartkit": "^5.10.1",
"@gravity-ui/components": "^3.10.1",
"@gravity-ui/date-components": "^2.10.1",
+ "@gravity-ui/graph": "^0.0.2",
"@gravity-ui/icons": "^2.11.0",
"@gravity-ui/markdown-editor": "^13.18.0",
"@gravity-ui/navigation": "^2.24.1",
@@ -19,6 +20,7 @@
"@gravity-ui/uikit": "^6.30.1",
"@mdx-js/mdx": "^2.3.0",
"@mdx-js/react": "^2.3.0",
+ "@monaco-editor/react": "^4.6.0",
"@octokit/rest": "^20.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
@@ -3460,6 +3462,26 @@
"eslint": "^8.0.0"
}
},
+ "node_modules/@gravity-ui/graph": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@gravity-ui/graph/-/graph-0.0.2.tgz",
+ "integrity": "sha512-sP0pT2+hjiaMD8fQXQixb9el3Li1UXISwuKEhzz7RheejMOqdQ1gebuvRNr2UjheD4akU5vn+de8YhQxafn/uA==",
+ "dependencies": {
+ "@monaco-editor/react": "^4.6.0",
+ "@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"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/@gravity-ui/i18n": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@gravity-ui/i18n/-/i18n-1.6.0.tgz",
@@ -3965,6 +3987,30 @@
"react": ">=16"
}
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
+ "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.21.0 < 1"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
+ "integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.4.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/@next/env": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz",
@@ -4292,6 +4338,15 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@preact/signals-core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.8.0.tgz",
+ "integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/@react-spring/animated": {
"version": "9.7.3",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.3.tgz",
@@ -10361,6 +10416,11 @@
"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=="
+ },
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -11434,6 +11494,11 @@
"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=="
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -14102,6 +14167,11 @@
"node": ">=8"
}
},
+ "node_modules/quickselect": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
+ },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -14145,6 +14215,14 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
+ "node_modules/rbush": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
+ "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
+ "dependencies": {
+ "quickselect": "^2.0.0"
+ }
+ },
"node_modules/rc-slider": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.6.2.tgz",
@@ -15541,6 +15619,11 @@
"stacktrace-gps": "^3.0.4"
}
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
+ },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
diff --git a/package.json b/package.json
index 48c90c2539c9..ba3ac2ec1814 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"@gravity-ui/chartkit": "^5.10.1",
"@gravity-ui/components": "^3.10.1",
"@gravity-ui/date-components": "^2.10.1",
+ "@gravity-ui/graph": "^0.0.2",
"@gravity-ui/icons": "^2.11.0",
"@gravity-ui/markdown-editor": "^13.18.0",
"@gravity-ui/navigation": "^2.24.1",
@@ -15,6 +16,7 @@
"@gravity-ui/uikit": "^6.30.1",
"@mdx-js/mdx": "^2.3.0",
"@mdx-js/react": "^2.3.0",
+ "@monaco-editor/react": "^4.6.0",
"@octokit/rest": "^20.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
diff --git a/src/components/GraphPlayground/GraphPlayground.scss b/src/components/GraphPlayground/GraphPlayground.scss
index 33a708bb0bf4..955867e07fb4 100644
--- a/src/components/GraphPlayground/GraphPlayground.scss
+++ b/src/components/GraphPlayground/GraphPlayground.scss
@@ -51,6 +51,7 @@ $block: '.#{variables.$ns}graph';
border-radius: 24px;
flex: 1;
min-height: 648px;
+ max-height: 648px;
gap: 24px;
position: relative;
}
diff --git a/src/components/GraphPlayground/GraphPlayground.tsx b/src/components/GraphPlayground/GraphPlayground.tsx
index 9e9d36a55ecf..e46931c36731 100644
--- a/src/components/GraphPlayground/GraphPlayground.tsx
+++ b/src/components/GraphPlayground/GraphPlayground.tsx
@@ -1,20 +1,17 @@
-import {LayoutColumns, LayoutSideContentRight} from '@gravity-ui/icons';
import {Col, Grid, Row} from '@gravity-ui/page-constructor';
-import {Button, Icon, Text} from '@gravity-ui/uikit';
+import {Button, ThemeProvider} from '@gravity-ui/uikit';
import {useTranslation} from 'next-i18next';
-import {useState} from 'react';
import {block, getLocaleLink} from '../../utils';
import './GraphPlayground.scss';
+import {GraphPlayground} from './Playground/GraphPlayground';
const b = block('graph');
export const GraphPlayround = () => {
const {t, i18n} = useTranslation('graph');
- const [JSONVisible, setJSONVisible] = useState(true);
-
return (
@@ -33,23 +30,11 @@ export const GraphPlayround = () => {
-
-
- Graph viewer
-
+
+
+
+
- {JSONVisible && (
-
- JSON Editor
-
-
- )}
);
diff --git a/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.css b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.css
new file mode 100644
index 000000000000..c54af2769e38
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.css
@@ -0,0 +1,21 @@
+.action-block-wrapper {
+ border-radius: 8px;
+ border-width: 4px;
+ padding: var(--g-spacing-3);
+
+ display: flex;
+ flex-direction: column;
+ gap: var(--g-spacing-1);
+}
+
+.action-block-name {
+ font-weight: 500;
+}
+
+.action-block-wrapper:hover {
+ cursor: pointer;
+ border-color: rgba(229, 229, 229, 0.4);
+ background-color: rgba(57, 47, 57, 1);
+}
+
+
diff --git a/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx
new file mode 100644
index 000000000000..77ff80569a33
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx
@@ -0,0 +1,39 @@
+import {Graph, GraphBlock, GraphBlockAnchor} from '@gravity-ui/graph';
+import {Database} from '@gravity-ui/icons';
+import {Button, Flex, Icon, Text} from '@gravity-ui/uikit';
+import React from 'react';
+
+import {TGravityActionBlock} from '../generateLayout';
+
+import './ActionBlock.css';
+
+export function ActionBlockHtml({graph, block}: {graph: Graph; block: TGravityActionBlock}) {
+ return (
+
+ {block.anchors.map((anchor) => {
+ return (
+
+ );
+ })}
+
+
+ {block.name}
+
+
+ {block.meta?.description}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/GraphPlayground/Playground/ActionBlock/index.tsx b/src/components/GraphPlayground/Playground/ActionBlock/index.tsx
new file mode 100644
index 000000000000..cbb0d5b4efdd
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/ActionBlock/index.tsx
@@ -0,0 +1,169 @@
+import {CanvasBlock, EAnchorType, TAnchor, TBlockId, TPoint, layoutText} from '@gravity-ui/graph';
+import {EventedComponent} from '@gravity-ui/graph/build/mixins/withEvents';
+import React from 'react';
+
+import {TGravityActionBlock} from '../generateLayout';
+
+import './ActionBlock.css';
+import {ActionBlockHtml} from './ActionBlockHtml';
+
+export function renderSVG(
+ icon: {
+ path: string;
+ width: number;
+ height: number;
+ iniatialWidth: number;
+ initialHeight: number;
+ },
+ ctx: CanvasRenderingContext2D,
+ rect: {x: number; y: number; width: number; height: number},
+) {
+ ctx.save();
+ const iconPath = new Path2D(icon.path);
+ const coefX = icon.width / icon.iniatialWidth;
+ const coefY = icon.height / icon.initialHeight;
+ // MoveTo position
+ ctx.translate(
+ rect.x + rect.width / 2 - icon.width / 2,
+ rect.y + rect.height / 2 - icon.height / 2,
+ );
+ ctx.scale(coefX, coefY);
+ ctx.fill(iconPath, 'evenodd');
+ ctx.restore();
+}
+
+function getAnchorY(index: number) {
+ let y = 18 * index;
+ if (index >= 1) {
+ y += 8 * index;
+ }
+ return y + 18;
+}
+
+export class ActionBlock extends CanvasBlock {
+ cursor = 'pointer';
+
+ protected hovered = false;
+
+ renderHTML() {
+ return (
+
+ );
+ }
+
+ getAnchorPosition(anchor: TAnchor): TPoint {
+ const a = this.getAnchorsYOffter(anchor.type as EAnchorType);
+ const index = this.connectedState.$anchorIndexs.value?.get(anchor.id) || 0;
+ const y = getAnchorY(index);
+ return {
+ x: anchor.type === EAnchorType.OUT ? this.state.width : 0,
+ y: a + y,
+ };
+ }
+
+ renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void {
+ this.renderBody(ctx);
+ // do not show icon for large scale
+ if (this.context.camera.getCameraScale() < 0.1) {
+ return;
+ }
+
+ ctx.fillStyle = 'rgba(255, 190, 92, 1)';
+ renderSVG(
+ {
+ path: 'M5.75 2.5H10.25C10.7842 2.5 11.2532 2.77929 11.519 3.19983C10.6259 3.58121 10 4.46751 10 5.5C10 6.61941 10.7357 7.56698 11.75 7.88555V12C11.75 12.8284 11.0784 13.5 10.25 13.5H5.75C5.21576 13.5 4.74676 13.2207 4.48102 12.8002C5.3741 12.4188 6 11.5325 6 10.5C6 9.38059 5.26428 8.43302 4.25 8.11445V7.88555C5.26428 7.56698 6 6.61941 6 5.5C6 4.46751 5.3741 3.58121 4.48102 3.19982C4.74676 2.77929 5.21576 2.5 5.75 2.5ZM2.75 8.11445V7.88555C1.73572 7.56698 1 6.61941 1 5.5C1 4.32762 1.80699 3.34373 2.8958 3.0735C3.28617 1.87008 4.41648 1 5.75 1H10.25C11.5835 1 12.7138 1.87008 13.1042 3.07351C14.193 3.34373 15 4.32762 15 5.5C15 6.61941 14.2643 7.56698 13.25 7.88555V12C13.25 13.6569 11.9069 15 10.25 15H5.75C4.41647 15 3.28616 14.1299 2.8958 12.9265C1.80699 12.6563 1 11.6724 1 10.5C1 9.38059 1.73572 8.43302 2.75 8.11445ZM3.5 11.5C4.05228 11.5 4.5 11.0523 4.5 10.5C4.5 9.94771 4.05228 9.5 3.5 9.5C2.94772 9.5 2.5 9.94772 2.5 10.5C2.5 11.0523 2.94772 11.5 3.5 11.5ZM2.5 5.5C2.5 4.94772 2.94772 4.5 3.5 4.5C4.05228 4.5 4.5 4.94772 4.5 5.5C4.5 6.05228 4.05228 6.5 3.5 6.5C2.94772 6.5 2.5 6.05229 2.5 5.5ZM12.5 4.5C11.9477 4.5 11.5 4.94772 11.5 5.5C11.5 6.05229 11.9477 6.5 12.5 6.5C13.0523 6.5 13.5 6.05229 13.5 5.5C13.5 4.94772 13.0523 4.5 12.5 4.5Z',
+ width: 14 * 4,
+ height: 14 * 4,
+ iniatialWidth: 14,
+ initialHeight: 14,
+ },
+ ctx,
+ this.getContentRect(),
+ );
+ }
+
+ renderSchematicView(ctx: CanvasRenderingContext2D) {
+ this.renderBody(ctx);
+
+ const scale = this.context.camera.getCameraScale();
+ const shouldRenderText = scale > this.context.constants.block.SCALES[0];
+
+ if (shouldRenderText) {
+ ctx.fillStyle = this.context.colors.block?.text || '';
+ ctx.textAlign = 'center';
+ this.renderTextAtCenter(this.state.name, ctx);
+ }
+ }
+
+ protected subscribe(id: TBlockId) {
+ const subs = super.subscribe(id);
+ subs.push(
+ // FIXME: Types is broken, parent methods do not passed to child
+ (this as unknown as EventedComponent).addEventListener('mouseenter', () => {
+ this.hovered = true;
+ (this as unknown as EventedComponent).performRender();
+ }),
+ (this as unknown as EventedComponent).addEventListener('mouseleave', () => {
+ this.hovered = false;
+ (this as unknown as EventedComponent).performRender();
+ }),
+ );
+ return subs;
+ }
+
+ protected renderName(ctx: CanvasRenderingContext2D) {
+ const scale = this.context.camera.getCameraScale();
+
+ if (scale > this.context.constants.block.SCALES[0]) {
+ ctx.fillStyle = this.context.colors.block?.text || '';
+ ctx.textAlign = 'center';
+ this.renderText(this.state.name, ctx);
+ }
+ }
+
+ protected getAnchorsYOffter(type: EAnchorType) {
+ const anchors = this.connectedState.$state.value.anchors.filter((a) => a.type === type);
+ const {height} = this.getContentRect();
+ return (height - getAnchorY(anchors.length - 1)) / 2;
+ }
+
+ protected renderTextAtCenter(name: string, ctx: CanvasRenderingContext2D) {
+ const rect = this.getContentRect();
+ const scale = this.context.camera.getCameraScale();
+ ctx.fillStyle = this.context.colors.block?.text || '';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ const {lines, measures} = layoutText(name, ctx, rect, {
+ font: `500 ${9 / scale}px YS Text`,
+ lineHeight: 9 / scale,
+ });
+ const shiftY = rect.height / 2 - measures.height / 2;
+ for (let index = 0; index < lines.length; index++) {
+ const [line, x, y] = lines[index];
+ const rY = Math.floor(y + shiftY);
+ ctx.fillText(line, x, rY);
+ }
+ }
+
+ protected renderBody(ctx: CanvasRenderingContext2D) {
+ const scale = this.context.camera.getCameraScale();
+
+ ctx.lineWidth = Math.min(Math.round(2 / scale), 12);
+ ctx.fillStyle = this.hovered
+ ? 'rgba(57, 47, 57, 1)'
+ : this.context.colors.block?.background || '';
+
+ ctx.beginPath();
+ ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 8);
+ ctx.fill();
+ if (this.state.selected) {
+ ctx.strokeStyle = this.context.colors.block?.selectedBorder || '';
+ } else {
+ ctx.strokeStyle = this.hovered
+ ? 'rgba(229, 229, 229, 0.4)'
+ : this.context.colors.block?.border || '';
+ }
+ ctx.stroke();
+ ctx.closePath();
+ }
+}
diff --git a/src/components/GraphPlayground/Playground/Editor/Editor.css b/src/components/GraphPlayground/Playground/Editor/Editor.css
new file mode 100644
index 000000000000..6a6a8d2e1984
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Editor/Editor.css
@@ -0,0 +1,12 @@
+.editor-wrap {
+ width: 100%;
+}
+
+.actions {
+ padding: var(--g-spacing-3);
+ border-top: 1px solid var(--g-color-base-float-accent-hover)
+}
+
+.actions .hotkey {
+ --g-color-text-hint: var(--g-color-text-dark-hint);
+}
\ No newline at end of file
diff --git a/src/components/GraphPlayground/Playground/Editor/index.tsx b/src/components/GraphPlayground/Playground/Editor/index.tsx
new file mode 100644
index 000000000000..5829c7186f45
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Editor/index.tsx
@@ -0,0 +1,182 @@
+import {TBlock, TBlockId, TConnection} from '@gravity-ui/graph';
+import {Button, Flex, Hotkey, Text} from '@gravity-ui/uikit';
+import {Editor, OnMount, OnValidate, loader} from '@monaco-editor/react';
+import {KeyCode, KeyMod} from 'monaco-editor/esm/vs/editor/editor.api';
+import React, {Ref, useCallback, useImperativeHandle, useRef, useState} from 'react';
+
+import './Editor.css';
+import {defineConigSchema} from './schema';
+import {GravityTheme, defineTheme} from './theme';
+import {findBlockPositionsMonaco} from './utils';
+
+loader.init().then((monaco) => {
+ defineTheme(monaco);
+ defineConigSchema(monaco);
+});
+
+export interface ConfigEditorController {
+ scrollTo: (blockId: TBlockId) => void;
+ updateBlocks: (block: TBlock[]) => void;
+ setContent: (p: {blocks: TBlock[]; connections: TConnection[]}) => void;
+}
+
+type ConfigEditorProps = {
+ onChange?: (config: {blocks: TBlock[]; connections: TConnection[]}) => void;
+ addBlock?: () => void;
+};
+
+type ExtractTypeFromArray = T extends Array ? E : never;
+
+export const ConfigEditor = React.forwardRef(function ConfigEditor(
+ props: ConfigEditorProps,
+ ref: Ref,
+) {
+ const [errorMarker, setErrorMarker] =
+ useState[0]>>();
+
+ const monacoRef = useRef[0]>();
+
+ const valueRef = useRef<{blocks: TBlock[]; connections: TConnection[]}>({
+ blocks: [],
+ connections: [],
+ });
+
+ useImperativeHandle(ref, () => ({
+ scrollTo: (blockId) => {
+ if (!monacoRef.current) {
+ return;
+ }
+ const model = monacoRef.current.getModel();
+ const range = findBlockPositionsMonaco(model!, blockId);
+
+ if (range?.start.column) {
+ monacoRef.current?.revealLinesInCenter(
+ range.start.lineNumber,
+ range.end.lineNumber,
+ 0,
+ );
+ }
+
+ monacoRef.current.setSelection({
+ startColumn: range.start.column,
+ startLineNumber: range.start.lineNumber,
+ endColumn: range.end.column,
+ endLineNumber: range.end.lineNumber,
+ });
+ },
+ updateBlocks: (blocks: TBlock[]) => {
+ const model = monacoRef.current?.getModel();
+ if (!model) {
+ return;
+ }
+ const edits = blocks.map((block) => {
+ const range = findBlockPositionsMonaco(model, block.id);
+ const text = JSON.stringify({block: [block]}, null, 2);
+ return {
+ range: {
+ startColumn: range.start.column,
+ startLineNumber: range.start.lineNumber,
+ endColumn: range.end.column,
+ endLineNumber: range.end.lineNumber,
+ },
+ text: text.slice(19, text.length - 6),
+ };
+ });
+
+ model.applyEdits(edits);
+ },
+ setContent: ({blocks, connections}) => {
+ valueRef.current = {
+ blocks,
+ connections,
+ };
+ if (!monacoRef.current) {
+ return;
+ }
+ monacoRef.current?.setValue(JSON.stringify(valueRef.current, null, 2));
+ },
+ }));
+
+ const applyChanges = useCallback(() => {
+ const model = monacoRef.current?.getModel();
+ if (!model) {
+ return;
+ }
+ try {
+ const data = JSON.parse(model.getValue());
+ props?.onChange?.({blocks: data.blocks, connections: data.conections});
+ } catch (e) {
+ console.error(e);
+ }
+ }, [monacoRef]);
+
+ return (
+
+
+ {
+ monacoRef.current = editor;
+ monacoRef.current?.setValue(JSON.stringify(valueRef.current, null, 2));
+ // eslint-disable-next-line no-bitwise
+ editor.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, applyChanges);
+ }}
+ onValidate={(markers) => {
+ setErrorMarker(markers.filter((m) => m.severity === 8)[0] || null);
+ }}
+ language={'json'}
+ theme={GravityTheme}
+ options={{
+ contextmenu: false,
+ lineNumbersMinChars: 4,
+ glyphMargin: false,
+ fontSize: 18,
+ lineHeight: 20,
+ colorDecorators: true,
+ minimap: {enabled: false},
+ smoothScrolling: true,
+ // @ts-ignore
+ 'bracketPairColorization.editor': true,
+ }}
+ />
+
+
+
+
+ {errorMarker && (
+
+
+ {
+ monacoRef.current?.revealLinesInCenter(
+ errorMarker.startLineNumber,
+ errorMarker.endLineNumber,
+ 0,
+ );
+
+ monacoRef.current?.setSelection({
+ startColumn: errorMarker.startColumn,
+ startLineNumber: errorMarker.startLineNumber,
+ endColumn: errorMarker.endColumn,
+ endLineNumber: errorMarker.endLineNumber,
+ });
+ }}
+ >
+ {errorMarker.message}
+
+
+
+ )}
+
+
+ );
+});
diff --git a/src/components/GraphPlayground/Playground/Editor/schema.ts b/src/components/GraphPlayground/Playground/Editor/schema.ts
new file mode 100644
index 000000000000..0836eafab5d8
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Editor/schema.ts
@@ -0,0 +1,168 @@
+import {Monaco} from '@monaco-editor/react';
+
+export function defineConigSchema(monaco: Monaco) {
+ monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
+ validate: true,
+ schemaValidation: 'error',
+ schemas: [
+ {
+ uri: 'http://gravity/graph-playground/schema.json', // id of the first schema
+ fileMatch: ['*'], // associate with our model
+ schema: {
+ type: 'object',
+ properties: {
+ blocks: {
+ type: 'array',
+ items: {
+ $ref: '#/definitions/TBlock',
+ },
+ description: 'List of blocks (TBlock[])',
+ },
+ connections: {
+ type: 'array',
+ items: {
+ $ref: '#/definitions/TConnection',
+ },
+ description: 'List of connections (TConnection[])',
+ },
+ },
+ required: ['blocks', 'connections'],
+ definitions: {
+ TBlock: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ description: 'Block identifier (TBlockId)',
+ },
+ is: {
+ type: 'string',
+ description: 'String representation of the block type',
+ },
+ x: {
+ type: 'number',
+ description: 'X coordinate',
+ },
+ y: {
+ type: 'number',
+ description: 'Y coordinate',
+ },
+ width: {
+ type: 'number',
+ description: 'Block width',
+ },
+ height: {
+ type: 'number',
+ description: 'Block height',
+ },
+ selected: {
+ type: 'boolean',
+ description: 'Flag indicating if the block is selected',
+ },
+ name: {
+ type: 'string',
+ description: 'Block name',
+ },
+ anchors: {
+ type: 'array',
+ items: {
+ $ref: '#/definitions/TAnchor',
+ },
+ description: 'List of anchors (TAnchor[])',
+ },
+ meta: {
+ type: 'object',
+ description: 'Meta information (optional)',
+ },
+ },
+ required: [
+ 'id',
+ 'is',
+ 'x',
+ 'y',
+ 'width',
+ 'height',
+ 'selected',
+ 'name',
+ 'anchors',
+ ],
+ },
+ TConnection: {
+ type: 'object',
+ properties: {
+ sourceBlockId: {
+ type: 'string',
+ description: 'Identifier of the source block',
+ },
+ targetBlockId: {
+ type: 'string',
+ description: 'Identifier of the target block',
+ },
+ sourceAnchorId: {
+ type: 'string',
+ description: 'Identifier of the source anchor (optional)',
+ },
+ targetAnchorId: {
+ type: 'string',
+ description: 'Identifier of the target anchor (optional)',
+ },
+ label: {
+ type: 'string',
+ description: 'Connection label (optional)',
+ },
+ styles: {
+ type: 'object',
+ properties: {
+ dashes: {
+ type: 'array',
+ items: {
+ type: 'number',
+ },
+ description:
+ 'Array of dash lengths for dashed lines (optional)',
+ },
+ },
+ additionalProperties: true,
+ description: 'Connection styles (optional)',
+ },
+ dashed: {
+ type: 'boolean',
+ description: 'Flag indicating if the line is dashed (optional)',
+ },
+ selected: {
+ type: 'boolean',
+ description:
+ 'Flag indicating if the connection is selected (optional)',
+ },
+ },
+ required: ['sourceBlockId', 'targetBlockId'],
+ },
+ TAnchor: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ description: 'Anchor identifier',
+ },
+ blockId: {
+ type: 'string',
+ description: 'Identifier of the block this anchor belongs to',
+ },
+ type: {
+ type: 'string',
+ enum: ['IN', 'OUT'],
+ description: 'Anchor type, either IN or OUT',
+ },
+ index: {
+ type: 'number',
+ description: 'Anchor index',
+ },
+ },
+ required: ['id', 'blockId', 'type'],
+ },
+ },
+ },
+ },
+ ],
+ });
+}
diff --git a/src/components/GraphPlayground/Playground/Editor/theme.ts b/src/components/GraphPlayground/Playground/Editor/theme.ts
new file mode 100644
index 000000000000..88af951a49ac
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Editor/theme.ts
@@ -0,0 +1,24 @@
+import type {Monaco} from '@monaco-editor/react';
+
+export const GravityTheme = 'gravity';
+
+export function defineTheme(monaco: Monaco) {
+ monaco.editor.defineTheme(GravityTheme, {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ token: 'string.key.json',
+ foreground: '#febe5c',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#ffdb4d4d',
+ 'editor.background': '#251b25',
+ 'editor.lineHighlightBackground': '#ffdb4d4d',
+ 'editorLineNumber.foreground': '#bd5c0a',
+ 'editor.selectionBackground': '#ffdb4d4d',
+ 'editor.inactiveSelectionBackground': '#ffdb4d4d',
+ },
+ });
+}
diff --git a/src/components/GraphPlayground/Playground/Editor/utils.ts b/src/components/GraphPlayground/Playground/Editor/utils.ts
new file mode 100644
index 000000000000..575f9cd3ad5d
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Editor/utils.ts
@@ -0,0 +1,31 @@
+import {TBlockId} from '@gravity-ui/graph';
+import {editor} from 'monaco-editor';
+
+export function findBlockPositionsMonaco(model: editor.ITextModel, blockId: TBlockId) {
+ const configString = model.getValue();
+ const blockSearchStr = `"id": "${String(blockId)}"`;
+ const startIndex = configString.indexOf(blockSearchStr);
+
+ const blockStart = configString.lastIndexOf('{', startIndex);
+ let blockEnd = configString.indexOf('}', startIndex);
+
+ let braceCount = 1;
+ let currentPos = blockEnd + 1;
+ while (braceCount > 0 && currentPos < configString.length) {
+ if (configString[currentPos] === '{') {
+ braceCount++;
+ } else if (configString[currentPos] === '}') {
+ braceCount--;
+ }
+ currentPos++;
+ }
+ blockEnd = currentPos;
+
+ const startPosition = model.getPositionAt(blockStart);
+ const endPosition = model.getPositionAt(blockEnd);
+
+ return {
+ start: startPosition,
+ end: endPosition,
+ };
+}
diff --git a/src/components/GraphPlayground/Playground/GraphPlayground.tsx b/src/components/GraphPlayground/Playground/GraphPlayground.tsx
new file mode 100644
index 000000000000..63cdf36c39c5
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/GraphPlayground.tsx
@@ -0,0 +1,351 @@
+import {
+ EAnchorType,
+ ECanChangeBlockGeometry,
+ Graph,
+ GraphBlock,
+ GraphCanvas,
+ GraphState,
+ HookGraphParams,
+ TBlock,
+ useGraph,
+ useGraphEvent,
+} from '@gravity-ui/graph';
+import {Flex, RadioButton, RadioButtonOption, Text} from '@gravity-ui/uikit';
+import random from 'lodash/random';
+import {useCallback, useEffect, useLayoutEffect, useRef} from 'react';
+
+import {block} from '../../../utils';
+
+import {ActionBlock} from './ActionBlock';
+import {ConfigEditor, ConfigEditorController} from './Editor';
+import './Playground.scss';
+import {GraphSettings} from './Settings';
+import {TextBlock} from './TextBlock';
+import {Toolbox} from './Toolbox';
+import {
+ GravityActionBlockIS,
+ GravityTextBlockIS,
+ createActionBlock,
+ createTextBlock,
+ generatePlaygroundActionBlocks,
+} from './generateLayout';
+
+const b = block('graph-playground');
+const radioB = block('graph-playground-radio-buton');
+
+const generated = generatePlaygroundActionBlocks(0, 5);
+
+const textBlocks = [
+ createTextBlock(
+ -144,
+ 80,
+ 448,
+ 0,
+ 'To create new block, drag and drop new connection from edge',
+ ),
+ createTextBlock(-64, 160, 240, 1, 'Use scroll to zoom in or out'),
+];
+
+const config: HookGraphParams = {
+ viewConfiguration: {
+ colors: {
+ selection: {
+ background: 'rgba(255, 190, 92, 0.1)',
+ border: 'rgba(255, 190, 92, 1)',
+ },
+ connection: {
+ background: 'rgba(255, 255, 255, 0.5)',
+ selectedBackground: 'rgba(234, 201, 74, 1)',
+ },
+ block: {
+ background: 'rgba(37, 27, 37, 1)',
+ border: 'rgba(229, 229, 229, 0.2)',
+ selectedBorder: 'rgba(255, 190, 92, 1)',
+ text: 'rgba(255, 255, 255, 1)',
+ },
+ anchor: {
+ background: 'rgba(255, 190, 92, 1)',
+ },
+ canvas: {
+ layerBackground: 'rgba(22, 13, 27, 1)',
+ belowLayerBackground: 'rgba(22, 13, 27, 1)',
+ dots: 'rgba(255, 255, 255, 0.2)',
+ border: 'rgba(255, 255, 255, 0.3)',
+ },
+ },
+ constants: {
+ block: {
+ SCALES: [0.1, 0.2, 0.5],
+ },
+ },
+ },
+ settings: {
+ canDragCamera: true,
+ canZoomCamera: true,
+ canDuplicateBlocks: false,
+ canChangeBlockGeometry: ECanChangeBlockGeometry.ALL,
+ canCreateNewConnections: false,
+ showConnectionArrows: false,
+ scaleFontSize: 1,
+ useBezierConnections: true,
+ useBlocksAnchors: true,
+ showConnectionLabels: false,
+ blockComponents: {
+ [GravityActionBlockIS]: ActionBlock,
+ [GravityTextBlockIS]: TextBlock,
+ },
+ },
+};
+
+const graphSizeOptions: RadioButtonOption[] = [
+ {value: '1', content: '1'},
+ {value: '100', content: '100'},
+ {value: '1000', content: '1 000'},
+ {value: '10000', content: '10 000'},
+];
+
+function useFn, RT>(handler: (...args: ARG) => RT) {
+ const handlerRef = useRef(handler);
+
+ handlerRef.current = handler;
+
+ return useCallback((...args: ARG) => {
+ return handlerRef.current(...args);
+ }, []);
+}
+
+export function GraphPlayground() {
+ const {graph, setEntities, updateEntities, start} = useGraph(config);
+ const editorRef = useRef(null);
+
+ const updateVisibleConfig = useFn(() => {
+ const config = graph.rootStore.getAsConfig();
+ editorRef.current?.setContent({
+ blocks: config.blocks || [],
+ connections: config.connections || [],
+ });
+ });
+
+ useGraphEvent(graph, 'block-change', ({block}) => {
+ editorRef.current?.updateBlocks([block]);
+ editorRef.current?.scrollTo(block.id);
+ });
+
+ useGraphEvent(graph, 'blocks-selection-change', ({changes}) => {
+ editorRef.current?.updateBlocks([
+ ...changes.add.map((id) => ({
+ ...graph.rootStore.blocksList.getBlock(id)!,
+ selected: true,
+ })),
+ ...changes.removed.map((id) => ({
+ ...graph.rootStore.blocksList.getBlock(id)!,
+ selected: false,
+ })),
+ ]);
+ });
+
+ useGraphEvent(
+ graph,
+ 'connection-created',
+ ({sourceBlockId, sourceAnchorId, targetBlockId, targetAnchorId}, event) => {
+ event.preventDefault();
+ if (!sourceAnchorId) {
+ return;
+ }
+ const pullSourceAnchor = graph.rootStore.blocksList
+ .getBlockState(sourceBlockId)
+ .getAnchorById(sourceAnchorId);
+ if (pullSourceAnchor.state.type === EAnchorType.IN) {
+ graph.api.addConnection({
+ sourceBlockId: targetBlockId,
+ sourceAnchorId: targetAnchorId,
+ targetBlockId: sourceBlockId,
+ targetAnchorId: sourceAnchorId,
+ });
+ } else {
+ graph.api.addConnection({
+ sourceBlockId: sourceBlockId,
+ sourceAnchorId: sourceAnchorId,
+ targetBlockId: targetBlockId,
+ targetAnchorId: targetAnchorId,
+ });
+ }
+ updateVisibleConfig();
+ },
+ );
+
+ useGraphEvent(
+ graph,
+ 'connection-create-drop',
+ ({sourceBlockId, sourceAnchorId, targetBlockId, point}) => {
+ if (targetBlockId) {
+ return;
+ }
+ let block: TBlock;
+ const pullSourceAnchor = graph.rootStore.blocksList
+ .getBlockState(sourceBlockId)
+ .getAnchorById(sourceAnchorId);
+ if (pullSourceAnchor.state.type === EAnchorType.IN) {
+ block = createActionBlock(
+ point.x - 126,
+ point.y - 63,
+ graph.rootStore.blocksList.$blocksMap.value.size + 1,
+ );
+ graph.api.addBlock(block);
+ graph.api.addConnection({
+ sourceBlockId: block.id,
+ sourceAnchorId: block.anchors[1].id,
+ targetBlockId: sourceBlockId,
+ targetAnchorId: sourceAnchorId,
+ });
+ } else {
+ block = createActionBlock(
+ point.x,
+ point.y - 63,
+ graph.rootStore.blocksList.$blocksMap.value.size + 1,
+ );
+ graph.api.addBlock(block);
+ graph.api.addConnection({
+ sourceBlockId: sourceBlockId,
+ sourceAnchorId: sourceAnchorId,
+ targetBlockId: block.id,
+ targetAnchorId: block.anchors[0].id,
+ });
+ }
+ graph.zoomTo([block.id], {transition: 250});
+ updateVisibleConfig();
+ editorRef.current?.scrollTo(block.id);
+ },
+ );
+
+ useLayoutEffect(() => {
+ setEntities({
+ blocks: [...textBlocks, ...generated.blocks],
+ connections: generated.connections,
+ });
+ updateVisibleConfig();
+ }, [setEntities]);
+
+ useGraphEvent(graph, 'state-change', ({state}) => {
+ if (state === GraphState.ATTACHED) {
+ start();
+ graph.zoomTo('center', {padding: 300});
+ }
+ });
+
+ const addNewBlock = useFn(() => {
+ const rect = graph.rootStore.blocksList.getUsableRect();
+ const x = random(rect.x, rect.x + rect.width + 100);
+ const y = random(rect.y, rect.y + rect.height + 100);
+ const block = createActionBlock(x, y, graph.rootStore.blocksList.$blocksMap.value.size + 1);
+ graph.api.addBlock(block);
+ graph.zoomTo([block.id], {transition: 250});
+ updateVisibleConfig();
+ editorRef.current?.scrollTo(block.id);
+ });
+
+ const renderBlockFn = useFn((graph: Graph, block: TBlock) => {
+ const view = graph.rootStore.blocksList.getBlockState(block.id)?.getViewComponent();
+ if (view instanceof ActionBlock) {
+ return view.renderHTML();
+ }
+ if (view instanceof TextBlock) {
+ return view.renderHTML();
+ }
+ return (
+
+ Unknown block <>{block.id}>
+
+ );
+ });
+
+ useGraphEvent(graph, 'blocks-selection-change', ({list}) => {
+ if (list.length === 1) {
+ editorRef.current?.scrollTo(list[0]);
+ }
+ });
+
+ useEffect(() => {
+ const fn = (e: KeyboardEvent) => {
+ if (e.code === 'Backspace') {
+ graph.api.deleteSelected();
+ updateVisibleConfig();
+ }
+ };
+ document.body.addEventListener('keydown', fn);
+ return () => document.body.removeEventListener('keydown', fn);
+ });
+
+ const updateGraphSize = (value: string) => {
+ let config;
+ switch (value) {
+ case graphSizeOptions[0].value: {
+ config = generatePlaygroundActionBlocks(0, 5);
+ break;
+ }
+ case graphSizeOptions[1].value: {
+ config = generatePlaygroundActionBlocks(10, 100);
+ break;
+ }
+ case graphSizeOptions[2].value: {
+ config = generatePlaygroundActionBlocks(23, 150);
+ break;
+ }
+ case graphSizeOptions[3].value: {
+ graph.updateSettings({
+ useBezierConnections: false,
+ });
+ config = generatePlaygroundActionBlocks(50, 150);
+ break;
+ }
+ }
+ if (config) {
+ setEntities({blocks: config?.blocks, connections: config?.connections});
+ graph.zoomTo('center', {transition: 500});
+ updateVisibleConfig();
+ }
+ };
+
+ return (
+
+
+
+
+ Blocks
+
+
+
+
+
+
+
+
+
+
+
+
+
+ JSON Editor
+
+
+ {
+ updateEntities({blocks, connections});
+ }}
+ addBlock={addNewBlock}
+ />
+
+
+
+ );
+}
diff --git a/src/components/GraphPlayground/Playground/Playground.scss b/src/components/GraphPlayground/Playground/Playground.scss
new file mode 100644
index 000000000000..0f79631aca18
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Playground.scss
@@ -0,0 +1,134 @@
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins;
+@use '../../../variables.scss';
+
+$pg: '.#{variables.$ns}graph-playground';
+$radio: '.#{variables.$ns}graph-playground-radio-buton';
+
+#{$radio} {
+ --_--border-radius: 8px;
+ --g-color-base-brand: rgba(255, 190, 92, 1);
+ --g-color-base-background: var(--g-color-base-brand);
+ --g-color-text-primary: var(--g-color-text-dark-primary);
+
+ .g-radio-button__option {
+ &:hover .g-radio-button__option-text {
+ --g-color-text-primary: var(--g-color-text-light-primary);
+ }
+
+ &_checked {
+ &:hover .g-radio-button__option-text {
+ --g-color-text-primary: var(--g-color-text-dark-primary);
+ }
+ }
+ }
+}
+
+#{$pg} {
+ height: 100%;
+
+ &__content {
+ &_graph {
+ min-width: 50%;
+ }
+
+ #{$pg} {
+ &__title {
+ line-height: var(--g-text-display-1-line-height);
+ }
+ }
+ }
+
+ &__view {
+ border: 1px solid transparent;
+ border-radius: 24px;
+ overflow: hidden;
+ position: relative;
+
+ &_config-editor {
+ border-radius: 10px;
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ &_graph-editor {
+ background-color: var(--g-color-base-background);
+ }
+ }
+
+ &__graph-tools {
+ height: 100%;
+ pointer-events: none;
+ position: absolute;
+ left: 20px;
+ z-index: 10;
+
+ #{$pg} {
+ &__zoom {
+ pointer-events: all;
+ box-sizing: border-box;
+ position: absolute;
+ top: 50%;
+ left: 0;
+ z-index: 10;
+ transform: translate(0, -50%);
+ }
+
+ &__graph-settings {
+ pointer-events: all;
+ box-sizing: border-box;
+ position: absolute;
+ bottom: 20px;
+ left: 0;
+ z-index: 10;
+ }
+ }
+ }
+
+ .g-button,
+ .g-radio-button {
+ --_--border-radius: 8px;
+ }
+
+ .button-group {
+ .g-button {
+ border-radius: 0;
+
+ &::before,
+ &::after {
+ border-radius: 0;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--yc-color-base-misc-light);
+ }
+
+ &:first-child {
+ border-top-right-radius: var(--g-button-border-radius, var(--_--border-radius));
+ border-top-left-radius: var(--g-button-border-radius, var(--_--border-radius));
+
+ &::before,
+ &::after {
+ border-top-right-radius: var(--g-button-border-radius, var(--_--border-radius));
+ border-top-left-radius: var(--g-button-border-radius, var(--_--border-radius));
+ }
+ }
+
+ &:last-child {
+ border-bottom-right-radius: var(--g-button-border-radius, var(--_--border-radius));
+ border-bottom-left-radius: var(--g-button-border-radius, var(--_--border-radius));
+
+ &::before,
+ &::after {
+ border-bottom-right-radius: var(
+ --g-button-border-radius,
+ var(--_--border-radius)
+ );
+ border-bottom-left-radius: var(
+ --g-button-border-radius,
+ var(--_--border-radius)
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/GraphPlayground/Playground/Settings.scss b/src/components/GraphPlayground/Playground/Settings.scss
new file mode 100644
index 000000000000..66110e972bab
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Settings.scss
@@ -0,0 +1,11 @@
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins;
+@use '../../../variables.scss';
+
+$settings: '.#{variables.$ns}graph-settings';
+
+#{$settings} {
+ &__popup {
+ padding: 16px;
+ }
+}
diff --git a/src/components/GraphPlayground/Playground/Settings.tsx b/src/components/GraphPlayground/Playground/Settings.tsx
new file mode 100644
index 000000000000..5c02d5e5e5e5
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Settings.tsx
@@ -0,0 +1,101 @@
+import {Graph} from '@gravity-ui/graph';
+import {Gear} from '@gravity-ui/icons';
+import {Button, Flex, Icon, Popup, RadioButton, RadioButtonOption, Text} from '@gravity-ui/uikit';
+import React, {useRef, useState} from 'react';
+
+import {block} from '../../../utils';
+
+import './Settings.scss';
+import {useRerender} from './hooks';
+
+const ConnectionVariants: RadioButtonOption[] = [
+ {value: 'bezier', content: 'Bezier'},
+ {value: 'line', content: 'Line'},
+];
+
+const ConnectionArrowsVariants: RadioButtonOption[] = [
+ {value: 'bezier', content: 'Show'},
+ {value: 'line', content: 'Hide'},
+];
+
+const b = block('graph-settings');
+
+export function GraphSettings({
+ className,
+ radionButtonClass,
+ graph,
+}: {
+ className: string;
+ radionButtonClass: string;
+ graph: Graph;
+}) {
+ const rerender = useRerender();
+ const settingBtnRef = useRef(null);
+ const [settingsOpened, setSettingsOpened] = useState(false);
+ return (
+ <>
+
+ setSettingsOpened(false)}
+ placement={['right-end']}
+ >
+
+ Graph settings
+
+ Connection type
+ {
+ graph.updateSettings({
+ useBezierConnections: value === ConnectionVariants[0].value,
+ });
+ rerender();
+ }}
+ value={
+ ConnectionVariants[
+ graph.rootStore.settings.getConfigFlag('useBezierConnections')
+ ? 0
+ : 1
+ ].value
+ }
+ options={ConnectionVariants}
+ />
+
+
+ Show arrows
+ {
+ graph.updateSettings({
+ showConnectionArrows:
+ value === ConnectionArrowsVariants[0].value,
+ });
+ rerender();
+ }}
+ value={
+ ConnectionArrowsVariants[
+ graph.rootStore.settings.getConfigFlag('showConnectionArrows')
+ ? 0
+ : 1
+ ].value
+ }
+ options={ConnectionArrowsVariants}
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss b/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss
new file mode 100644
index 000000000000..333a21b54072
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss
@@ -0,0 +1,36 @@
+.text-block-wrapper {
+ border-radius: 8px;
+ border-width: 2px;
+ border-style: dashed;
+
+ padding: 0 var(--g-spacing-3);
+
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ color: var(--g-color-line-warning);
+ border-color: var(--g-color-line-warning);
+ background-color: rgba(46, 31, 34, 1);
+
+ .icon {
+ flex-shrink: 0;
+ }
+}
+
+.text-block-text {
+ flex-grow: 1;
+ font-weight: 500;
+ text-overflow: ellipsis;
+}
+
+.text-block-wrapper:hover {
+ cursor: pointer;
+ border-color: rgba(255, 190, 92, 1);
+}
+
+.text-block-wrapper.selected {
+ color: rgba(255, 190, 92, 1);
+ border-color: rgba(255, 190, 92, 1);
+ border-style: solid;
+}
diff --git a/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx b/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx
new file mode 100644
index 000000000000..f163c248ccff
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx
@@ -0,0 +1,22 @@
+import {Graph, GraphBlock} from '@gravity-ui/graph';
+import {CircleInfo} from '@gravity-ui/icons';
+import {Flex, Icon, Text} from '@gravity-ui/uikit';
+import React from 'react';
+
+import {TGravityTextBlock} from '../generateLayout';
+
+import './TextBlock.scss';
+
+export function TextBlockHtml({graph, block}: {graph: Graph; block: TGravityTextBlock}) {
+ return (
+
+
+
+
+ {block.meta?.text}
+
+ {/* */}
+
+
+ );
+}
diff --git a/src/components/GraphPlayground/Playground/TextBlock/index.tsx b/src/components/GraphPlayground/Playground/TextBlock/index.tsx
new file mode 100644
index 000000000000..17af64b026db
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/TextBlock/index.tsx
@@ -0,0 +1,99 @@
+import {CanvasBlock, TBlockId, layoutText} from '@gravity-ui/graph';
+import {EventedComponent} from '@gravity-ui/graph/build/mixins/withEvents';
+import React from 'react';
+
+import {TGravityTextBlock} from '../generateLayout';
+
+import {TextBlockHtml} from './TextBlockHtml';
+
+export class TextBlock extends CanvasBlock {
+ cursor = 'pointer';
+
+ protected hovered = false;
+
+ renderHTML() {
+ return (
+
+ );
+ }
+
+ renderMinimalisticBlock(ctx: CanvasRenderingContext2D): void {
+ this.renderBody(ctx);
+ }
+
+ renderSchematicView(ctx: CanvasRenderingContext2D) {
+ this.renderBody(ctx);
+
+ const scale = this.context.camera.getCameraScale();
+ const shouldRenderText = scale > this.context.constants.block.SCALES[0];
+
+ if (shouldRenderText) {
+ this.renderName(ctx);
+ }
+ }
+
+ protected subscribe(id: TBlockId) {
+ const subs = super.subscribe(id);
+ subs.push(
+ (this as unknown as EventedComponent).addEventListener('mouseenter', () => {
+ this.hovered = true;
+ (this as unknown as EventedComponent).performRender();
+ }),
+ (this as unknown as EventedComponent).addEventListener('mouseleave', () => {
+ this.hovered = false;
+ (this as unknown as EventedComponent).performRender();
+ }),
+ );
+ return subs;
+ }
+
+ protected renderName(ctx: CanvasRenderingContext2D) {
+ ctx.fillStyle = 'rgba(189, 142, 75, 1)';
+ ctx.textAlign = 'center';
+ this.renderText(this.state.meta?.text || '', ctx);
+ }
+
+ protected renderTextAtCenter(name: string, ctx: CanvasRenderingContext2D) {
+ const rect = this.getContentRect();
+ const scale = this.context.camera.getCameraScale();
+ ctx.fillStyle = 'rgba(189, 142, 75, 1)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'bottom';
+ const {lines, measures} = layoutText(name, ctx, rect, {
+ font: `500 ${13 / scale}px YS Text`,
+ lineHeight: 9 / scale,
+ });
+ const shiftY = rect.height / 2 - measures.height / 2;
+ for (let index = 0; index < lines.length; index++) {
+ const [line, x, y] = lines[index];
+ const rY = Math.round(y + shiftY);
+ ctx.fillText(line, x, rY);
+ }
+ }
+
+ protected renderBody(ctx: CanvasRenderingContext2D) {
+ const scale = this.context.camera.getCameraScale();
+
+ ctx.save();
+
+ ctx.lineWidth = Math.min(Math.round(2 / scale), 12);
+ ctx.fillStyle = 'rgba(189, 142, 75, 0.1)';
+
+ ctx.beginPath();
+ ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 8);
+ ctx.fill();
+
+ if (this.state.selected) {
+ ctx.lineWidth = Math.min(Math.round(2 / scale), 12);
+ } else {
+ ctx.lineWidth = 2;
+ ctx.setLineDash([4, 4]);
+ }
+
+ ctx.strokeStyle = 'rgba(189, 142, 75, 1)';
+ ctx.stroke();
+ ctx.closePath();
+
+ ctx.restore();
+ }
+}
diff --git a/src/components/GraphPlayground/Playground/Toolbox.tsx b/src/components/GraphPlayground/Playground/Toolbox.tsx
new file mode 100644
index 000000000000..9cf2df4ec082
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/Toolbox.tsx
@@ -0,0 +1,52 @@
+import {Graph, useGraphEvent} from '@gravity-ui/graph';
+import {MagnifierMinus, MagnifierPlus, SquareDashed} from '@gravity-ui/icons';
+import {Button, Flex, Icon, Tooltip} from '@gravity-ui/uikit';
+import React, {useState} from 'react';
+
+export function Toolbox({className, graph}: {className: string; graph: Graph}) {
+ const [scale, setScale] = useState(1);
+
+ useGraphEvent(graph, 'camera-change', ({scale}) => {
+ setScale(scale);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/GraphPlayground/Playground/generateLayout.tsx b/src/components/GraphPlayground/Playground/generateLayout.tsx
new file mode 100644
index 000000000000..88ce05dc5e9e
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/generateLayout.tsx
@@ -0,0 +1,129 @@
+// import { TBlock } from "../../components/canvas/blocks/Block";
+// import { TGraphConfig } from "../../graph";
+// import { EAnchorType } from "../../store/anchor/Anchor";
+
+import {EAnchorType, type TBlock, TConnection} from '@gravity-ui/graph';
+
+export const GravityActionBlockIS = 'block-action';
+export type TGravityActionBlock = TBlock<{description: string}> & {is: typeof GravityActionBlockIS};
+
+export const GravityTextBlockIS = 'block-text';
+export type TGravityTextBlock = TBlock<{text: string}> & {is: typeof GravityTextBlockIS};
+
+function getActionBlockId(num: number): string {
+ return `action_${num}`;
+}
+
+export function createActionBlock(x: number, y: number, index: number): TGravityActionBlock {
+ const blockId = getActionBlockId(index);
+
+ return {
+ is: GravityActionBlockIS,
+ id: blockId,
+ x,
+ y,
+ width: 63 * 2,
+ height: 63 * 2,
+ selected: false,
+ name: `Block #${index}`,
+ anchors: [
+ {
+ id: `${blockId}_in`,
+ blockId,
+ type: EAnchorType.IN,
+ },
+ {
+ id: `${blockId}_out`,
+ blockId,
+ type: EAnchorType.OUT,
+ },
+ ],
+ meta: {
+ description: 'Description',
+ },
+ };
+}
+
+export function createTextBlock(
+ x: number,
+ y: number,
+ width: number,
+ index: number,
+ text: string,
+): TGravityTextBlock {
+ const blockId = `text_${index}`;
+
+ return {
+ is: GravityTextBlockIS,
+ id: blockId,
+ x,
+ y,
+ width,
+ height: 48,
+ selected: false,
+ name: `Text Block`,
+ anchors: [],
+ meta: {
+ text,
+ },
+ };
+}
+
+function getRandomArbitrary(min: number, max: number) {
+ return Math.round(Math.random() * (max - min) + min);
+}
+
+export function generatePlaygroundActionBlocks(layersCount: number, connectionsPerLayer: number) {
+ const config: {blocks: TBlock[]; connections: TConnection[]} = {
+ blocks: [],
+ connections: [],
+ };
+
+ const gapX = 500;
+ const gapY = 200;
+
+ let prevLayerBlocks: TBlock[] = [];
+ let index = 0;
+ for (let i = 0; i <= layersCount; i++) {
+ let count = i ** 2;
+ if (i >= layersCount / 2) {
+ count = (layersCount - i) ** 2;
+ }
+ const startY = (500 - gapY * count) / 2;
+ const layerX = gapX * i * 2.5;
+ const currentLayerBlocks: TBlock[] = [];
+ for (let j = 0; j <= count; j++) {
+ const y = startY + gapY * j;
+
+ const block = createActionBlock(layerX, y, ++index);
+
+ config.blocks.push(block);
+ currentLayerBlocks.push(block);
+ }
+ if (i > 1) {
+ for (let c = 0; c <= connectionsPerLayer; c++) {
+ const indexSource = getRandomArbitrary(
+ config.blocks.length - currentLayerBlocks.length - prevLayerBlocks.length - 1,
+ config.blocks.length - currentLayerBlocks.length - 1,
+ );
+ const indexTarget = getRandomArbitrary(
+ config.blocks.length - currentLayerBlocks.length - 1,
+ config.blocks.length - 1,
+ );
+ if (indexSource !== indexTarget) {
+ const sourceBlockId = getActionBlockId(indexSource);
+ const targetBlockId = getActionBlockId(indexTarget);
+ config.connections.push({
+ sourceBlockId: sourceBlockId,
+ sourceAnchorId: `${sourceBlockId}_anchor_out`,
+ targetBlockId: targetBlockId,
+ targetAnchorId: `${targetBlockId}_anchor_in`,
+ });
+ }
+ }
+ prevLayerBlocks = [...currentLayerBlocks];
+ }
+ }
+
+ return config;
+}
diff --git a/src/components/GraphPlayground/Playground/hooks.ts b/src/components/GraphPlayground/Playground/hooks.ts
new file mode 100644
index 000000000000..d0529efe1178
--- /dev/null
+++ b/src/components/GraphPlayground/Playground/hooks.ts
@@ -0,0 +1,8 @@
+import {useCallback, useState} from 'react';
+
+export function useRerender() {
+ const [_, setTick] = useState(Date.now());
+ return useCallback(() => {
+ setTick(Date.now());
+ }, []);
+}
diff --git a/src/components/GraphPlayground/consts.ts b/src/components/GraphPlayground/consts.ts
new file mode 100644
index 000000000000..d8804b55f068
--- /dev/null
+++ b/src/components/GraphPlayground/consts.ts
@@ -0,0 +1,8 @@
+import {type RadioButtonOption} from '@gravity-ui/uikit';
+
+export const GraphSizeOptions: RadioButtonOption[] = [
+ {value: '1', content: '1'},
+ {value: '100', content: '100'},
+ {value: '1000', content: '1 000'},
+ {value: '10000', content: '10 000'},
+];