From c505fd3d3f4bf463ea0889441c4efe61381bc998 Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 22 Oct 2024 19:30:32 +0300 Subject: [PATCH] feat: add new library `@gravity-ui/graph` (#290) --- package-lock.json | 83 ++++ package.json | 2 + public/locales/en/graph.json | 4 + public/locales/en/libraries-info.json | 1 + public/locales/ru/graph.json | 4 + public/locales/ru/libraries-info.json | 1 + .../libraries/[libId]/playground/index.tsx | 12 +- .../GraphPlayground/GraphPlayground.scss | 83 ++++ .../GraphPlayground/GraphPlayground.tsx | 39 ++ .../Playground/ActionBlock/ActionBlock.scss | 26 ++ .../ActionBlock/ActionBlockHtml.tsx | 41 ++ .../Playground/ActionBlock/index.tsx | 169 ++++++++ .../Playground/Editor/Editor.scss | 20 + .../Playground/Editor/index.tsx | 186 +++++++++ .../Playground/Editor/schema.ts | 168 ++++++++ .../Playground/Editor/theme.ts | 24 ++ .../Playground/Editor/utils.ts | 31 ++ .../Playground/GraphPlayground.tsx | 368 ++++++++++++++++++ .../Playground/Playground.scss | 148 +++++++ .../GraphPlayground/Playground/Settings.scss | 11 + .../GraphPlayground/Playground/Settings.tsx | 101 +++++ .../Playground/TextBlock/TextBlock.scss | 42 ++ .../Playground/TextBlock/TextBlockHtml.tsx | 24 ++ .../Playground/TextBlock/index.tsx | 99 +++++ .../GraphPlayground/Playground/Toolbox.tsx | 52 +++ .../Playground/generateLayout.tsx | 125 ++++++ .../GraphPlayground/Playground/hooks.ts | 8 + src/components/GraphPlayground/consts.ts | 8 + src/libs.mjs | 13 + 29 files changed, 1892 insertions(+), 1 deletion(-) create mode 100644 public/locales/en/graph.json create mode 100644 public/locales/ru/graph.json create mode 100644 src/components/GraphPlayground/GraphPlayground.scss create mode 100644 src/components/GraphPlayground/GraphPlayground.tsx create mode 100644 src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss create mode 100644 src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx create mode 100644 src/components/GraphPlayground/Playground/ActionBlock/index.tsx create mode 100644 src/components/GraphPlayground/Playground/Editor/Editor.scss create mode 100644 src/components/GraphPlayground/Playground/Editor/index.tsx create mode 100644 src/components/GraphPlayground/Playground/Editor/schema.ts create mode 100644 src/components/GraphPlayground/Playground/Editor/theme.ts create mode 100644 src/components/GraphPlayground/Playground/Editor/utils.ts create mode 100644 src/components/GraphPlayground/Playground/GraphPlayground.tsx create mode 100644 src/components/GraphPlayground/Playground/Playground.scss create mode 100644 src/components/GraphPlayground/Playground/Settings.scss create mode 100644 src/components/GraphPlayground/Playground/Settings.tsx create mode 100644 src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss create mode 100644 src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx create mode 100644 src/components/GraphPlayground/Playground/TextBlock/index.tsx create mode 100644 src/components/GraphPlayground/Playground/Toolbox.tsx create mode 100644 src/components/GraphPlayground/Playground/generateLayout.tsx create mode 100644 src/components/GraphPlayground/Playground/hooks.ts create mode 100644 src/components/GraphPlayground/consts.ts diff --git a/package-lock.json b/package-lock.json index 488dc32e4b4..3b59d041ff9 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 48c90c2539c..ba3ac2ec181 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/public/locales/en/graph.json b/public/locales/en/graph.json new file mode 100644 index 00000000000..5045cbb0b56 --- /dev/null +++ b/public/locales/en/graph.json @@ -0,0 +1,4 @@ +{ + "goToLibrary": "Go to library", + "title": "Playground" +} diff --git a/public/locales/en/libraries-info.json b/public/locales/en/libraries-info.json index a9597932abc..5bf418ccdbe 100644 --- a/public/locales/en/libraries-info.json +++ b/public/locales/en/libraries-info.json @@ -30,6 +30,7 @@ "description_babel-preset": "Babel configuration preset for Gravity UI projects.", "description_browserslist-config": "Browserslist confugiration preset used in our services.", "description_markdown-editor": "A powerful tool for working with Markdown, which combines WYSIWYG and Markup modes.", + "description_graph": "High-performance graph renderer with dynamic scale-aware detailization", "description_data-source": "A wrapper around data fetching.", "description_webpack-i18n-assets-plugin": "A plugin for Webpack that replaces calls to localization functions (i18n) with target texts.", "description_table": "Library for visualizing data in tabular format." diff --git a/public/locales/ru/graph.json b/public/locales/ru/graph.json new file mode 100644 index 00000000000..87aab8bd04f --- /dev/null +++ b/public/locales/ru/graph.json @@ -0,0 +1,4 @@ +{ + "goToLibrary": "К библиотеке", + "title": "Редактор" +} diff --git a/public/locales/ru/libraries-info.json b/public/locales/ru/libraries-info.json index 9d3c67c4834..e6c0c2ff5f8 100644 --- a/public/locales/ru/libraries-info.json +++ b/public/locales/ru/libraries-info.json @@ -30,6 +30,7 @@ "description_babel-preset": "Пресет настройки Babel для проектов Gravity UI.", "description_browserslist-config": "Пресет настройки Browserslist для проектов Gravity UI.", "description_markdown-editor": "Мощный инструмент для работы с Markdown, который сочетает в себе режимы WYSIWYG и разметки.", + "description_graph": "Библиотека для визуализации больших графов с динамическим уровнем детализации", "description_data-source": "Библиотека-обертка над загрузкой данных.", "description_webpack-i18n-assets-plugin": "Плагин для Webpack, который заменяет вызовы функций локализации (i18n) на целевые тексты.", "description_table": "Библиотека для отображения таблиц." diff --git a/src/[locale]/libraries/[libId]/playground/index.tsx b/src/[locale]/libraries/[libId]/playground/index.tsx index ab0a181ccf4..4e73b72b5bf 100644 --- a/src/[locale]/libraries/[libId]/playground/index.tsx +++ b/src/[locale]/libraries/[libId]/playground/index.tsx @@ -18,6 +18,15 @@ const MarkdownEditor = dynamic( ssr: false, }, ); +const GraphPlayround = dynamic( + () => + import('../../../../components/GraphPlayground/GraphPlayground').then( + (mod) => mod.GraphPlayround, + ), + { + ssr: false, + }, +); export const getStaticPaths: GetStaticPaths = async () => { const paths = getI18nPaths().reduce((acc, localeItem) => { @@ -48,7 +57,7 @@ export const getStaticProps: GetStaticProps = async (context) => { }; }; -export const availablePlaygrounds = ['markdown-editor']; +export const availablePlaygrounds = ['markdown-editor', 'graph']; export const PlaygroundPage = ({libId}: {libId: string}) => { const hasPlayground = availablePlaygrounds.includes(libId); @@ -62,6 +71,7 @@ export const PlaygroundPage = ({libId}: {libId: string}) => { <> {libId === 'markdown-editor' && } + {libId === 'graph' && } )} diff --git a/src/components/GraphPlayground/GraphPlayground.scss b/src/components/GraphPlayground/GraphPlayground.scss new file mode 100644 index 00000000000..6c8df134f6f --- /dev/null +++ b/src/components/GraphPlayground/GraphPlayground.scss @@ -0,0 +1,83 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../variables.scss'; + +$block: '.#{variables.$ns}graph'; + +#{$block} { + min-height: 100%; + height: 100%; + margin-block-start: calc(var(--g-spacing-base) * 8); + + &__container { + min-height: 100%; + display: flex; + flex-direction: column; + } + + &__heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: pcVariables.$indentXS; + + @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md') - 1) { + margin-bottom: pcVariables.$indentXXXS; + } + } + + &__title { + font-size: 48px; + line-height: 56px; + font-weight: 600; + color: #fff; + margin: 0; + + @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md') - 1) { + font-size: 32px; + line-height: 48px; + margin-bottom: pcVariables.$indentXXXS; + } + } + + &__content { + width: 100%; + min-height: 300px; + } + + &__playground { + padding: 24px 32px; + background: rgba(37, 27, 37, 1); + border-radius: 24px; + flex: 1; + flex-direction: column; + min-height: 80vh; + max-height: 80vh; + gap: 24px; + position: relative; + } + + &__graph-viewer { + flex: 1; + min-height: 100%; + } + + &__json-switcher { + position: absolute; + top: 24px; + right: 32px; + z-index: 2; + } + + &__editor-wrap { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__editor { + flex: 1; + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.2); + } +} diff --git a/src/components/GraphPlayground/GraphPlayground.tsx b/src/components/GraphPlayground/GraphPlayground.tsx new file mode 100644 index 00000000000..6cd5b0cee44 --- /dev/null +++ b/src/components/GraphPlayground/GraphPlayground.tsx @@ -0,0 +1,39 @@ +import {Col, Grid, Row} from '@gravity-ui/page-constructor'; +import {Button, ThemeProvider} from '@gravity-ui/uikit'; +import {useTranslation} from 'next-i18next'; + +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'); + + return ( + + + +

{t('title')}

+
+ +
+ +
+ + + + + +
+ ); +}; diff --git a/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss new file mode 100644 index 00000000000..f2fbf763c25 --- /dev/null +++ b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlock.scss @@ -0,0 +1,26 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../../variables.scss'; + +$block: '.#{variables.$ns}block'; + +#{$block} { + border-radius: 8px; + border-width: 2px; + padding: var(--g-spacing-3); + + display: flex; + flex-direction: column; + gap: var(--g-spacing-1); + + &:hover { + &:not(.selected) { + border-color: rgba(229, 229, 229, 0.4); + } + background-color: rgba(57, 47, 57, 1); + } + + &__name { + font-weight: 500; + } +} diff --git a/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx new file mode 100644 index 00000000000..887c4a03dbe --- /dev/null +++ b/src/components/GraphPlayground/Playground/ActionBlock/ActionBlockHtml.tsx @@ -0,0 +1,41 @@ +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 {block as blockBem} from '../../../../utils'; +import {TGravityActionBlock} from '../generateLayout'; + +import './ActionBlock.scss'; + +const b = blockBem('block'); + +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 00000000000..908768eab48 --- /dev/null +++ b/src/components/GraphPlayground/Playground/ActionBlock/index.tsx @@ -0,0 +1,169 @@ +/* eslint-disable no-param-reassign */ +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 {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.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.lineWidth = Math.min(Math.round(2 / scale), 12); + ctx.strokeStyle = this.context.colors.block?.selectedBorder || ''; + } else { + ctx.lineWidth = Math.min(Math.round(1 / scale), 12); + 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.scss b/src/components/GraphPlayground/Playground/Editor/Editor.scss new file mode 100644 index 00000000000..3d9f3693dcc --- /dev/null +++ b/src/components/GraphPlayground/Playground/Editor/Editor.scss @@ -0,0 +1,20 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../../variables.scss'; + +$block: '.#{variables.$ns}editor'; + +#{$block} { + width: 100%; + + &__actions { + padding: var(--g-spacing-3); + border-top: 1px solid var(--g-color-base-float-accent-hover); + + #{$block} { + &__hotkey { + --g-color-text-hint: var(--g-color-text-dark-hint); + } + } + } +} diff --git a/src/components/GraphPlayground/Playground/Editor/index.tsx b/src/components/GraphPlayground/Playground/Editor/index.tsx new file mode 100644 index 00000000000..e9e496b15cc --- /dev/null +++ b/src/components/GraphPlayground/Playground/Editor/index.tsx @@ -0,0 +1,186 @@ +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 {block} from '../../../../utils'; + +import './Editor.scss'; +import {defineConigSchema} from './schema'; +import {GravityTheme, defineTheme} from './theme'; +import {findBlockPositionsMonaco} from './utils'; + +loader.init().then((monaco) => { + defineTheme(monaco); + defineConigSchema(monaco); +}); + +const b = block('editor'); + +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 00000000000..0836eafab5d --- /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 00000000000..88af951a49a --- /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 00000000000..575f9cd3ad5 --- /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 00000000000..7539753639e --- /dev/null +++ b/src/components/GraphPlayground/Playground/GraphPlayground.tsx @@ -0,0 +1,368 @@ +import { + EAnchorType, + ECanChangeBlockGeometry, + Graph, + GraphBlock, + GraphCanvas, + GraphState, + HookGraphParams, + TBlock, + useGraph, + useGraphEvent, +} from '@gravity-ui/graph'; +import {LayoutColumns, LayoutSideContentRight} from '@gravity-ui/icons'; +import {Button, Flex, Icon, RadioButton, RadioButtonOption, Text} from '@gravity-ui/uikit'; +import random from 'lodash/random'; +import {useCallback, useEffect, useLayoutEffect, useRef, useState} 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({className}: {className: string}) { + const {graph, setEntities, updateEntities, start} = useGraph(config); + const editorRef = useRef(null); + + const [editorOpened, setEditorOpened] = useState(true); + + const updateVisibleConfig = useFn(() => { + const currentConfig = graph.rootStore.getAsConfig(); + editorRef.current?.setContent({ + blocks: currentConfig.blocks || [], + connections: currentConfig.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) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...graph.rootStore.blocksList.getBlock(id)!, + selected: true, + })), + ...changes.removed.map((id) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...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 = async (value: string) => { + let nextConfig; + switch (value) { + case graphSizeOptions[0].value: { + nextConfig = generatePlaygroundActionBlocks(0, 5); + break; + } + case graphSizeOptions[1].value: { + nextConfig = generatePlaygroundActionBlocks(10, 100); + break; + } + case graphSizeOptions[2].value: { + nextConfig = generatePlaygroundActionBlocks(23, 150); + setEditorOpened(false); + break; + } + case graphSizeOptions[3].value: { + graph.updateSettings({ + useBezierConnections: false, + }); + setEditorOpened(false); + nextConfig = generatePlaygroundActionBlocks(50, 150); + break; + } + } + if (nextConfig) { + await new Promise((resolve) => setTimeout(resolve, 200)); + setEntities({blocks: nextConfig?.blocks, connections: nextConfig?.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 00000000000..87f3b05aab8 --- /dev/null +++ b/src/components/GraphPlayground/Playground/Playground.scss @@ -0,0 +1,148 @@ +@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%; + position: relative; + + &__layout-button { + position: absolute; + top: 0; + right: 0; + } + + &__content { + &_graph { + min-width: 50%; + } + + #{$pg} { + &__title { + line-height: var(--g-text-display-1-line-height); + } + } + + &_hidden { + width: 0; + /* stylelint-disable declaration-no-important */ + flex: 0 !important; + visibility: hidden; + } + } + + &__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 00000000000..66110e972ba --- /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 00000000000..5c02d5e5e5e --- /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 00000000000..b993236bd2f --- /dev/null +++ b/src/components/GraphPlayground/Playground/TextBlock/TextBlock.scss @@ -0,0 +1,42 @@ +@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables; +@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins; +@use '../../../../variables.scss'; + +$block: '.#{variables.$ns}text-block'; + +#{$block} { + 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); + + &:hover { + cursor: pointer; + border-color: rgba(255, 190, 92, 1); + } + + &.selected { + color: rgba(255, 190, 92, 1); + border-color: rgba(255, 190, 92, 1); + border-style: solid; + } + + &__icon { + flex-shrink: 0; + } + + &__text { + flex-grow: 1; + font-weight: 500; + text-overflow: ellipsis; + } +} diff --git a/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx b/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx new file mode 100644 index 00000000000..31aa50cf88c --- /dev/null +++ b/src/components/GraphPlayground/Playground/TextBlock/TextBlockHtml.tsx @@ -0,0 +1,24 @@ +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 {block} from '../../../../utils'; +import {TGravityTextBlock} from '../generateLayout'; + +import './TextBlock.scss'; + +const b = block('text-block'); + +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 00000000000..17af64b026d --- /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 00000000000..9cf2df4ec08 --- /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 00000000000..8bf9ace56a2 --- /dev/null +++ b/src/components/GraphPlayground/Playground/generateLayout.tsx @@ -0,0 +1,125 @@ +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 00000000000..d0529efe117 --- /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 00000000000..d8804b55f06 --- /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'}, +]; diff --git a/src/libs.mjs b/src/libs.mjs index da6af3b8c86..bbc7d107131 100644 --- a/src/libs.mjs +++ b/src/libs.mjs @@ -26,6 +26,19 @@ export const libs = [ 'https://raw.githubusercontent.com/gravity-ui/markdown-editor/main/CHANGELOG.md', mainBranch: 'main', }, + { + id: 'graph', + githubId: 'gravity-ui/graph', + npmId: '@gravity-ui/graph', + title: 'Graph', + primary: false, + landing: false, + tags: ['ui'], + storybookUrl: 'https://preview.gravity-ui.com/graph/', + readmeUrl: 'https://raw.githubusercontent.com/gravity-ui/graph/main/README.md', + changelogUrl: 'https://raw.githubusercontent.com/gravity-ui/graph/main/CHANGELOG.md', + mainBranch: 'main', + }, { id: 'components', githubId: 'gravity-ui/components',