From 3c28b9140e402bc437dce09733bc3c05851898e3 Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 10 Dec 2024 01:34:15 +0300 Subject: [PATCH 1/3] feat: Attempt to use elk to layout graph. Add custom connections --- package-lock.json | 17 +- package.json | 4 +- .../canvas/GraphComponent/index.tsx | 1 - .../canvas/connections/BaseConnection.ts | 7 +- .../canvas/connections/BlockConnection.ts | 5 +- .../canvas/connections/BlockConnections.ts | 24 +- src/graph.ts | 6 +- src/store/connection/ConnectionState.ts | 4 +- src/store/settings.ts | 12 +- src/stories/configurations/generatePretty.ts | 3 +- src/stories/examples/elk/elk.stories.tsx | 237 ++++++++++++++++++ 11 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 src/stories/examples/elk/elk.stories.tsx diff --git a/package-lock.json b/package-lock.json index 57d155d..914e3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@preact/signals-core": "^1.5.1", "@types/lodash": "^4.17.12", + "elkjs": "^0.9.3", "intersects": "^2.7.2", "lodash-es": "^4.17.21", "rbush": "^3.0.1" @@ -69,7 +70,8 @@ "size-limit": "^10.0.1", "storybook": "^8.1.11", "ts-node": "^10.9.2", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "web-worker": "^1.3.0" }, "engines": { "pnpm": "Please use npm instead of pnpm to install dependencies", @@ -8906,6 +8908,12 @@ "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", "dev": true }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -18926,6 +18934,13 @@ "defaults": "^1.0.3" } }, + "node_modules/web-worker": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 2f38ac3..7d2bc43 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@preact/signals-core": "^1.5.1", "@types/lodash": "^4.17.12", + "elkjs": "^0.9.3", "intersects": "^2.7.2", "lodash-es": "^4.17.21", "rbush": "^3.0.1" @@ -103,6 +104,7 @@ "size-limit": "^10.0.1", "storybook": "^8.1.11", "ts-node": "^10.9.2", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "web-worker": "^1.3.0" } } diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 6b10236..fbeedab 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -22,7 +22,6 @@ export class GraphComponent< private unsubscribe: (() => void)[] = []; constructor(props: Props, parent: Component) { - console.log('lol'); super(props, parent); this.hitBox = new HitBox(this, this.context.graph.hitTest); } diff --git a/src/components/canvas/connections/BaseConnection.ts b/src/components/canvas/connections/BaseConnection.ts index 1e04554..a138eae 100644 --- a/src/components/canvas/connections/BaseConnection.ts +++ b/src/components/canvas/connections/BaseConnection.ts @@ -19,7 +19,8 @@ export type TBaseConnectionState = TComponentState & TConnection & { export class BaseConnection< Props extends TBaseConnectionProps = TBaseConnectionProps, State extends TBaseConnectionState = TBaseConnectionState, - Context extends GraphComponentContext = GraphComponentContext + Context extends GraphComponentContext = GraphComponentContext, + Connection extends TConnection = TConnection, > extends GraphComponent { protected get sourceBlock(): Block { @@ -45,14 +46,14 @@ export class BaseConnection< public anchorsPoints: [TPoint, TPoint] | undefined; - protected connectedState: ConnectionState; + protected connectedState: ConnectionState; protected bBox: [minX: number, minY: number, maxX: number, maxY: number]; constructor(props: Props, parent: Component) { super(props, parent); - this.connectedState = selectConnectionById(this.context.graph, this.props.id); + this.connectedState = selectConnectionById(this.context.graph, this.props.id) as ConnectionState; this.setState({ ...this.connectedState.$state.value as TBaseConnectionState, hovered: false }); } diff --git a/src/components/canvas/connections/BlockConnection.ts b/src/components/canvas/connections/BlockConnection.ts index b71fdbe..dd811ae 100644 --- a/src/components/canvas/connections/BlockConnection.ts +++ b/src/components/canvas/connections/BlockConnection.ts @@ -26,10 +26,11 @@ export type TBlockConnection = { removeFromRenderOrder(cmp): void; }; -export class BlockConnection extends BaseConnection< +export class BlockConnection extends BaseConnection< TConnectionProps, TBaseConnectionState, - TGraphConnectionsContext + TGraphConnectionsContext, + T > implements Path2DRenderInstance { public readonly cursor = "pointer"; diff --git a/src/components/canvas/connections/BlockConnections.ts b/src/components/canvas/connections/BlockConnections.ts index 46cdfb3..9a53e5c 100644 --- a/src/components/canvas/connections/BlockConnections.ts +++ b/src/components/canvas/connections/BlockConnections.ts @@ -35,16 +35,17 @@ export class BlockConnections extends Component { - this.scheduleUpdate(); - }); - - const r2 = this.context.graph.rootStore.connectionsList.$connections.subscribe(() => { - this.scheduleUpdate(); - }); - - return [r1, r2]; + return [ + this.context.graph.rootStore.settings.$connectionsSettings.subscribe(() => { + this.scheduleUpdate(); + }), + this.context.graph.rootStore.connectionsList.$connections.subscribe(() => { + this.scheduleUpdate(); + }), + this.context.graph.rootStore.settings.$connection.subscribe(() => { + this.scheduleUpdate(); + }) + ]; } protected unmount() { @@ -56,6 +57,7 @@ export class BlockConnections extends Component { const props: TConnectionProps = { id: connection.id, @@ -64,7 +66,7 @@ export class BlockConnections extends Component = Constructor> = [ ? Omit & { root?: Props["root"] } : never, ]; -export type TGraphConfig = { +export type TGraphConfig = { configurationName?: string; - blocks?: B[]; + blocks?: Block[]; connections?: TConnection[]; rect?: TRect; cameraXY?: TPoint; cameraScale?: number; - settings?: Partial>; + settings?: Partial>; layers?: LayerConfig[]; }; diff --git a/src/store/connection/ConnectionState.ts b/src/store/connection/ConnectionState.ts index f5af7b6..ee4fdc6 100644 --- a/src/store/connection/ConnectionState.ts +++ b/src/store/connection/ConnectionState.ts @@ -24,8 +24,8 @@ export type TConnection = { selected?: boolean; }; -export class ConnectionState { - public $state = signal(undefined); +export class ConnectionState { + public $state = signal(undefined); public get id() { return this.$state.value.id; diff --git a/src/store/settings.ts b/src/store/settings.ts index b0f12d1..655280b 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -3,6 +3,9 @@ import { computed, signal } from "@preact/signals-core"; import type { Block, TBlock } from "../components/canvas/blocks/Block"; import { RootStore } from "./index"; +import { BaseConnection } from "../components/canvas/connections/BaseConnection"; +import { BlockConnection } from "../components/canvas/connections/BlockConnection"; +import { TConnection } from "./connection/ConnectionState"; export enum ECanChangeBlockGeometry { ALL = "all", @@ -10,7 +13,7 @@ export enum ECanChangeBlockGeometry { NONE = "none", } -export type TGraphSettingsConfig = { +export type TGraphSettingsConfig = { canDragCamera: boolean; canZoomCamera: boolean; canDuplicateBlocks: boolean; @@ -23,7 +26,8 @@ export type TGraphSettingsConfig = { useBlocksAnchors: boolean; connectivityComponentOnClickRaise: boolean; showConnectionLabels: boolean; - blockComponents: Record>; + blockComponents: Record>; + connection?: typeof BlockConnection, }; const getInitState: TGraphSettingsConfig = { @@ -49,6 +53,10 @@ export class GraphEditorSettings { return this.$settings.value.blockComponents; }); + public $connection = computed(() => { + return this.$settings.value.connection; + }) + constructor(public rootStore: RootStore) {} public setupSettings(config: Partial) { diff --git a/src/stories/configurations/generatePretty.ts b/src/stories/configurations/generatePretty.ts index c648a87..448c894 100644 --- a/src/stories/configurations/generatePretty.ts +++ b/src/stories/configurations/generatePretty.ts @@ -46,7 +46,6 @@ export function generatePrettyBlocks( settings: { ...storiesSettings, showConnectionLabels: true, - useBezierConnections: true, ...overrideSettings, }, }; @@ -85,10 +84,10 @@ export function generatePrettyBlocks( const sourceBlockId = `block_${startIndex + indexSource}`; const targetBlockId = `block_${startIndex + indexTarget}`; config.connections.push({ + id: `${sourceBlockId}/${targetBlockId}`, sourceBlockId: sourceBlockId, targetBlockId: targetBlockId, label: "Some label", - dashed: dashedLine && Boolean(Math.floor(random(0, 2))), }); } prevLayerBlocks = [...currentLayerBlocks]; diff --git a/src/stories/examples/elk/elk.stories.tsx b/src/stories/examples/elk/elk.stories.tsx new file mode 100644 index 0000000..aace0ad --- /dev/null +++ b/src/stories/examples/elk/elk.stories.tsx @@ -0,0 +1,237 @@ +// const ELK = require('elkjs') + +import React, { useEffect, useMemo, useState } from "react"; + +import { Flex, Select, SelectOption, ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryFn } from "@storybook/react"; +import ELK, { ElkExtendedEdge, ElkNode } from 'elkjs'; + +import { BlockConnection } from "../../../components/canvas/connections/BlockConnection"; +import { Graph, GraphCanvas, GraphState, TBlock, TConnection, useGraph, useGraphEvent } from "../../../index"; +import { useFn } from "../../../utils/hooks/useFn"; +import { generatePrettyBlocks } from "../../configurations/generatePretty"; +import { BlockStory } from "../../main/Block"; + + +import "@gravity-ui/uikit/styles/styles.css"; + + + +export type TElkTConnection = TConnection & { + elk: ElkExtendedEdge +} + +export type TElkBlock = TBlock & { + elk: ElkNode, +} + +function curve(path: Path2D, points: {x: number, y: number}[], radius) { + path.moveTo(points[0].x, points[0].y); // Начинаем с первой точки + + for (let i = 1; i < points.length - 1; i++) { + const prevPoint = points[i - 1]; + const currPoint = points[i]; + const nextPoint = points[i + 1]; + + // Вычисляем векторы направлений + const vectorPrev = { + x: currPoint.x - prevPoint.x, + y: currPoint.y - prevPoint.y + }; + const vectorNext = { + x: nextPoint.x - currPoint.x, + y: nextPoint.y - currPoint.y + }; + + // Нормализуем векторы + const lenPrev = Math.hypot(vectorPrev.x, vectorPrev.y); + const lenNext = Math.hypot(vectorNext.x, vectorNext.y); + + const unitVecPrev = { + x: vectorPrev.x / lenPrev, + y: vectorPrev.y / lenPrev + }; + const unitVecNext = { + x: vectorNext.x / lenNext, + y: vectorNext.y / lenNext + }; + + // Точки начала и конца скругления + const startArcX = currPoint.x - unitVecPrev.x * radius; + const startArcY = currPoint.y - unitVecPrev.y * radius; + + const endArcX = currPoint.x + unitVecNext.x * radius; + const endArcY = currPoint.y + unitVecNext.y * radius; + + // Линия до начала скругления + path.lineTo(startArcX, startArcY); + + // Скругление угла + path.arcTo(currPoint.x, currPoint.y, endArcX, endArcY, radius); + } + + // Последний сегмент линии + path.lineTo(points[points.length - 1].x, points[points.length - 1].y); + +} + +class ELKConnection extends BlockConnection { + public createPath() { + const elk = this.connectedState.$state.value.elk; + if(!elk || !elk.sections) { + return super.createPath(); + } + const path = new Path2D(); + elk.sections.forEach((c) => { + path.moveTo(c.startPoint.x, c.startPoint.y) + const points = [ + {x: c.startPoint.x, y: c.startPoint.y}, + ...c.bendPoints?.map((point) => ({x: point.x, y: point.y})) || [], + {x: c.endPoint.x, y: c.endPoint.y}, + ]; + curve(path, points, 50); + }); + this.path2d = path; + return path; + } + + public style(ctx: CanvasRenderingContext2D): { type: "stroke"; } | { type: "fill"; fillRule?: CanvasFillRule; } | undefined { + ctx.lineCap = "round"; + return super.style(ctx); + } + + public getBBox() { + const elk = this.connectedState.$state.value.elk; + if(!elk || !elk.sections) { + return super.getBBox(); + } + const x = []; + const y = []; + elk.sections.forEach((c) => { + x.push(c.startPoint.x); + y.push(c.startPoint.y); + c.bendPoints?.forEach((point) => { + x.push(point.x); + y.push(point.y); + }); + x.push(c.endPoint.x); + y.push(c.endPoint.y); + }); + return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + } +} + +const config = generatePrettyBlocks(10, 100, true); + +const GraphApp = () => { + + const { graph, setEntities, start } = useGraph({ + settings: { + connection: ELKConnection + }, + }); + + const elk = useMemo(() => new ELK({}), []) + + const [algoritm, setAlgortm] = useState('layered'); + + useEffect(() => { + const {blocks, connections} = config; + const blocksMap = new Map(blocks.map((b) => [b.id, b])); + const conMap = new Map(connections.map((b) => [b.id, b])); + + const graphDefinition = { + id: "root", + layoutOptions: { 'elk.algorithm': algoritm }, + children: blocks.map((b) => { + return { + id: b.id as string, + width: b.width, + height: b.height, + } + }), + edges: connections.map((c) => { + return { + id: c.id as string, + sources: [ c.sourceBlockId as string ], + targets: [ c.targetBlockId as string ] + } + }), + } + + elk.layout(graphDefinition) + .then((result) => { + console.log(result); + + const {children, edges} = result; + + const con = edges.map((edge) => { + const c = conMap.get(edge.id); + return { + ...c, + elk: edge, + } + }); + const layoutedBlocks = children.map((child) => { + const b = blocksMap.get(child.id); + + return { + ...b, + x: child.x, + y: child.y, + elk: child, + } + }); + + setEntities({ + blocks: layoutedBlocks, + connections: con, + }); + + graph.zoomTo("center", { padding: 300 }); + + }) + .catch(console.error) + + }, [algoritm, elk]); + + const [algorithms, setAlgortms] = useState([]); + + useEffect(() => { + elk.knownLayoutAlgorithms().then((knownLayoutAlgorithms) => { + + setAlgortms(knownLayoutAlgorithms.map((knownLayoutAlgorithm) => { + const {id, name} = knownLayoutAlgorithm; + const algId = id.split('.').at(-1); + return {value: algId, content: name} + })); + }); + }, [elk]); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + start(); + + } + }); + + const renderBlockFn = useFn((graphObject: Graph, block: TBlock) => { + return ; + }); + + return ( + + + ; + + ); +}; + +const meta: Meta = { + title: "Examples/ELK Layout", + component: GraphApp, +}; + +export default meta; + +export const Default: StoryFn = () => ; From c678032d939e48f089fc9fc785816ebec13c835e Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 10 Dec 2024 02:47:03 +0300 Subject: [PATCH 2/3] chore: some cleanup --- src/stories/examples/elk/ELKConnection.ts | 78 +++++++++++++++ src/stories/examples/elk/elk.stories.tsx | 113 +--------------------- src/stories/examples/elk/helpers.tsx | 94 ++++++++++++++++++ 3 files changed, 177 insertions(+), 108 deletions(-) create mode 100644 src/stories/examples/elk/ELKConnection.ts create mode 100644 src/stories/examples/elk/helpers.tsx diff --git a/src/stories/examples/elk/ELKConnection.ts b/src/stories/examples/elk/ELKConnection.ts new file mode 100644 index 0000000..ee65650 --- /dev/null +++ b/src/stories/examples/elk/ELKConnection.ts @@ -0,0 +1,78 @@ +import { ElkExtendedEdge } from "elkjs"; + +import { BlockConnection } from "../../../components/canvas/connections/BlockConnection"; +import { TConnection } from "../../../store/connection/ConnectionState"; + +import { curve, getElkArrowCoords } from "./helpers"; + +export type TElkTConnection = TConnection & { + elk: ElkExtendedEdge +} + + + +export class ELKConnection extends BlockConnection { + public createPath() { + const elk = this.connectedState.$state.value.elk; + if(!elk || !elk.sections) { + return super.createPath(); + } + const path = new Path2D(); + path.addPath(curve(this.points, 50)); + const pointA = this.points[this.points.length - 1]; + const pointB = this.points[this.points.length - 2]; + + path.addPath(getElkArrowCoords(pointB.x, pointB.y, pointA.x, pointA.y, 8)); + this.path2d = path; + return path; + } + + public style(ctx: CanvasRenderingContext2D): { type: "stroke"; } | { type: "fill"; fillRule?: CanvasFillRule; } | undefined { + ctx.lineCap = "round"; + return super.style(ctx); + } + + public afterRender?(_: CanvasRenderingContext2D): void { + // noop; + return; + } + + protected points: {x:number, y: number}[] + + public updatePoints(): void { + super.updatePoints(); + const elk = this.connectedState.$state.value.elk; + if(!elk || !elk.sections) { + return; + } + const section = elk.sections[0]; + + this.points = [ + section.startPoint, + ...section.bendPoints?.map((point) => point) || [], + section.endPoint, + ]; + + return; + } + + public getBBox() { + const elk = this.connectedState.$state.value.elk; + if(!elk || !elk.sections) { + return super.getBBox(); + } + const x = []; + const y = []; + elk.sections.forEach((c) => { + x.push(c.startPoint.x); + y.push(c.startPoint.y); + c.bendPoints?.forEach((point) => { + x.push(point.x); + y.push(point.y); + }); + x.push(c.endPoint.x); + y.push(c.endPoint.y); + }); + return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + } +} \ No newline at end of file diff --git a/src/stories/examples/elk/elk.stories.tsx b/src/stories/examples/elk/elk.stories.tsx index aace0ad..130b889 100644 --- a/src/stories/examples/elk/elk.stories.tsx +++ b/src/stories/examples/elk/elk.stories.tsx @@ -1,126 +1,23 @@ -// const ELK = require('elkjs') - import React, { useEffect, useMemo, useState } from "react"; -import { Flex, Select, SelectOption, ThemeProvider } from "@gravity-ui/uikit"; +import { Select, SelectOption, ThemeProvider } from "@gravity-ui/uikit"; import type { Meta, StoryFn } from "@storybook/react"; -import ELK, { ElkExtendedEdge, ElkNode } from 'elkjs'; +import ELK, { ElkNode } from 'elkjs'; -import { BlockConnection } from "../../../components/canvas/connections/BlockConnection"; -import { Graph, GraphCanvas, GraphState, TBlock, TConnection, useGraph, useGraphEvent } from "../../../index"; +import { Graph, GraphCanvas, GraphState, TBlock, useGraph, useGraphEvent } from "../../../index"; import { useFn } from "../../../utils/hooks/useFn"; import { generatePrettyBlocks } from "../../configurations/generatePretty"; import { BlockStory } from "../../main/Block"; -import "@gravity-ui/uikit/styles/styles.css"; - +import { ELKConnection } from "./ELKConnection"; - -export type TElkTConnection = TConnection & { - elk: ElkExtendedEdge -} +import "@gravity-ui/uikit/styles/styles.css"; export type TElkBlock = TBlock & { elk: ElkNode, } -function curve(path: Path2D, points: {x: number, y: number}[], radius) { - path.moveTo(points[0].x, points[0].y); // Начинаем с первой точки - - for (let i = 1; i < points.length - 1; i++) { - const prevPoint = points[i - 1]; - const currPoint = points[i]; - const nextPoint = points[i + 1]; - - // Вычисляем векторы направлений - const vectorPrev = { - x: currPoint.x - prevPoint.x, - y: currPoint.y - prevPoint.y - }; - const vectorNext = { - x: nextPoint.x - currPoint.x, - y: nextPoint.y - currPoint.y - }; - - // Нормализуем векторы - const lenPrev = Math.hypot(vectorPrev.x, vectorPrev.y); - const lenNext = Math.hypot(vectorNext.x, vectorNext.y); - - const unitVecPrev = { - x: vectorPrev.x / lenPrev, - y: vectorPrev.y / lenPrev - }; - const unitVecNext = { - x: vectorNext.x / lenNext, - y: vectorNext.y / lenNext - }; - - // Точки начала и конца скругления - const startArcX = currPoint.x - unitVecPrev.x * radius; - const startArcY = currPoint.y - unitVecPrev.y * radius; - - const endArcX = currPoint.x + unitVecNext.x * radius; - const endArcY = currPoint.y + unitVecNext.y * radius; - - // Линия до начала скругления - path.lineTo(startArcX, startArcY); - - // Скругление угла - path.arcTo(currPoint.x, currPoint.y, endArcX, endArcY, radius); - } - - // Последний сегмент линии - path.lineTo(points[points.length - 1].x, points[points.length - 1].y); - -} - -class ELKConnection extends BlockConnection { - public createPath() { - const elk = this.connectedState.$state.value.elk; - if(!elk || !elk.sections) { - return super.createPath(); - } - const path = new Path2D(); - elk.sections.forEach((c) => { - path.moveTo(c.startPoint.x, c.startPoint.y) - const points = [ - {x: c.startPoint.x, y: c.startPoint.y}, - ...c.bendPoints?.map((point) => ({x: point.x, y: point.y})) || [], - {x: c.endPoint.x, y: c.endPoint.y}, - ]; - curve(path, points, 50); - }); - this.path2d = path; - return path; - } - - public style(ctx: CanvasRenderingContext2D): { type: "stroke"; } | { type: "fill"; fillRule?: CanvasFillRule; } | undefined { - ctx.lineCap = "round"; - return super.style(ctx); - } - - public getBBox() { - const elk = this.connectedState.$state.value.elk; - if(!elk || !elk.sections) { - return super.getBBox(); - } - const x = []; - const y = []; - elk.sections.forEach((c) => { - x.push(c.startPoint.x); - y.push(c.startPoint.y); - c.bendPoints?.forEach((point) => { - x.push(point.x); - y.push(point.y); - }); - x.push(c.endPoint.x); - y.push(c.endPoint.y); - }); - return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; - } -} - const config = generatePrettyBlocks(10, 100, true); const GraphApp = () => { diff --git a/src/stories/examples/elk/helpers.tsx b/src/stories/examples/elk/helpers.tsx new file mode 100644 index 0000000..9875ac0 --- /dev/null +++ b/src/stories/examples/elk/helpers.tsx @@ -0,0 +1,94 @@ +export function polyline(points: {x: number, y: number}[]) { + const path = new Path2D(); + let i = 0; + path.moveTo(points[0].x, points[0].y); + for (i = 1; i < points.length; i++) { + path.lineTo(points[i].x, points[i].y); + } + + return path; +} + +export function curve(points: {x: number, y: number}[], radius: number) { + const path = new Path2D(); + path.moveTo(points[0].x, points[0].y); + + if(points.length === 2) { + path.lineTo(points[1].x, points[1].y); + return path; + } + + for (let i = 1; i < points.length - 1; i++) { + const prevPoint = points[i - 1]; + const currPoint = points[i]; + const nextPoint = points[i + 1]; + + const vectorPrev = { + x: currPoint.x - prevPoint.x, + y: currPoint.y - prevPoint.y + }; + const vectorNext = { + x: nextPoint.x - currPoint.x, + y: nextPoint.y - currPoint.y + }; + + const lenPrev = Math.hypot(vectorPrev.x, vectorPrev.y); + const lenNext = Math.hypot(vectorNext.x, vectorNext.y); + + const unitVecPrev = { + x: vectorPrev.x / lenPrev, + y: vectorPrev.y / lenPrev + }; + const unitVecNext = { + x: vectorNext.x / lenNext, + y: vectorNext.y / lenNext + }; + + const startArcX = currPoint.x - unitVecPrev.x * radius; + const startArcY = currPoint.y - unitVecPrev.y * radius; + + const endArcX = currPoint.x + unitVecNext.x * radius; + const endArcY = currPoint.y + unitVecNext.y * radius; + + path.lineTo(startArcX, startArcY); + + path.arcTo(currPoint.x, currPoint.y, endArcX, endArcY, radius); + } + + // Последний сегмент линии + path.lineTo(points[points.length - 1].x, points[points.length - 1].y); + return path; +} + +export function getElkArrowCoords( + x1: number, + y1: number, + x2: number, + y2: number, + height = 8, + ) { + const angle = Math.PI / 4; + + const x = x2; + const y = y2; + const lineangle = Math.atan2(y2 - y1, x2 - x1); + + // h is the line length of a side of the arrow head + const h = Math.abs(height / Math.cos(angle)); + + const angle1 = lineangle + Math.PI + angle; + const topx = x + Math.cos(angle1) * h; + const topy = y + Math.sin(angle1) * h; + + const angle2 = lineangle + Math.PI - angle; + const botx = x + Math.cos(angle2) * h; + const boty = y + Math.sin(angle2) * h; + + const trianglePath = new Path2D(); + trianglePath.moveTo(topx, topy); // Вершина треугольника + trianglePath.lineTo(x, y); // Левая точка основания + trianglePath.lineTo(botx, boty); // Правая точка основания + trianglePath.closePath(); + + return trianglePath; + } \ No newline at end of file From 1b96be53e29b5808711f05ff49e40b357c602dea Mon Sep 17 00:00:00 2001 From: draedful Date: Wed, 11 Dec 2024 17:58:31 +0300 Subject: [PATCH 3/3] ...more work... --- .eslintrc | 2 +- .../EventedComponent/EventedComponent.ts | 16 +- .../canvas/GraphComponent/index.tsx | 15 +- src/components/canvas/anchors/index.ts | 21 +- src/components/canvas/blocks/Block.ts | 9 +- src/components/canvas/blocks/Blocks.ts | 1 - .../canvas/connections/Arrow/index.ts | 22 + .../canvas/connections/BaseConnection.ts | 61 +- .../canvas/connections/BatchPath2D/index.tsx | 203 +-- .../canvas/connections/BlockConnection.ts | 133 +- .../canvas/connections/BlockConnections.ts | 17 +- .../canvas/layers/belowLayer/BelowLayer.ts | 16 +- .../canvas/layers/graphLayer/GraphLayer.ts | 75 +- .../canvas/layers/overLayer/OverLayer.ts | 14 +- src/graph.ts | 16 +- src/graphConfig.ts | 4 +- src/lib/Component.ts | 4 +- src/lib/CoreComponent.ts | 7 +- src/lib/Tree.spec.ts | 22 +- src/lib/Tree.ts | 4 +- src/lib/utils.ts | 9 +- src/plugins/minimap/layer.ts | 14 +- src/services/HitTest.ts | 9 +- src/services/Layer.ts | 6 +- .../newConnection/ConnectionService.ts | 7 +- src/store/block/BlocksList.ts | 16 +- src/store/connection/ConnectionState.ts | 9 +- src/store/settings.ts | 12 +- src/stories/configurations/generatePretty.ts | 1 + src/stories/examples/elk/ELKConnection.ts | 122 +- src/stories/examples/elk/elk.stories.tsx | 137 +- src/stories/examples/elk/helpers.tsx | 94 -- src/utils/functions/index.ts | 2 +- .../__snapshots__/curvePolyline.test.ts.snap | 1111 +++++++++++++++++ .../__snapshots__/polyline.test.ts.snap | 183 +++ .../__snapshots__/triangle.test.ts.snap | 377 ++++++ src/utils/shapes/curvePolyline.test.ts | 86 ++ src/utils/shapes/curvePolyline.ts | 88 ++ src/utils/shapes/polyline.test.ts | 16 + src/utils/shapes/polyline.tsx | 12 + src/utils/shapes/triangle.test.ts | 23 + src/utils/shapes/triangle.ts | 55 + 42 files changed, 2462 insertions(+), 589 deletions(-) create mode 100644 src/components/canvas/connections/Arrow/index.ts delete mode 100644 src/stories/examples/elk/helpers.tsx create mode 100644 src/utils/shapes/__snapshots__/curvePolyline.test.ts.snap create mode 100644 src/utils/shapes/__snapshots__/polyline.test.ts.snap create mode 100644 src/utils/shapes/__snapshots__/triangle.test.ts.snap create mode 100644 src/utils/shapes/curvePolyline.test.ts create mode 100644 src/utils/shapes/curvePolyline.ts create mode 100644 src/utils/shapes/polyline.test.ts create mode 100644 src/utils/shapes/polyline.tsx create mode 100644 src/utils/shapes/triangle.test.ts create mode 100644 src/utils/shapes/triangle.ts diff --git a/.eslintrc b/.eslintrc index f1a5f7a..6e37845 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "extends": [ "@gravity-ui/eslint-config", "@gravity-ui/eslint-config/import-order", - // "@gravity-ui/eslint-config/prettier", + "@gravity-ui/eslint-config/prettier", "prettier" ], "parserOptions": { diff --git a/src/components/canvas/EventedComponent/EventedComponent.ts b/src/components/canvas/EventedComponent/EventedComponent.ts index 1fcab34..205ce98 100644 --- a/src/components/canvas/EventedComponent/EventedComponent.ts +++ b/src/components/canvas/EventedComponent/EventedComponent.ts @@ -7,7 +7,7 @@ const listeners = new WeakMap extends Component { public readonly cursor?: string; @@ -27,14 +27,11 @@ export class EventedComponent< // noop } - public listenEvents( - events: string[], - cbOrObject: TEventedComponentListener = this, - ) { + public listenEvents(events: string[], cbOrObject: TEventedComponentListener = this) { const unsubs = events.map((eventName) => { return this.addEventListener(eventName, cbOrObject); }); - return unsubs + return unsubs; } public addEventListener(type: string, cbOrObject: TEventedComponentListener) { @@ -47,7 +44,7 @@ export class EventedComponent< public removeEventListener(type: string, cbOrObject: TEventedComponentListener) { const cbs = this.events.get(type); if (cbs) { - cbs.delete(cbOrObject) + cbs.delete(cbOrObject); } } @@ -57,10 +54,10 @@ export class EventedComponent< handlers?.forEach((cb) => { if (typeof cb === "function") { cb(event); - } else if (cb instanceof Component && "handleEvent" in cb && typeof cb.handleEvent === 'function') { + } else if (cb instanceof Component && "handleEvent" in cb && typeof cb.handleEvent === "function") { cb.handleEvent?.(event); } - }) + }); } public dispatchEvent(event: Event): boolean { @@ -96,5 +93,4 @@ export class EventedComponent< public _hasListener(comp: EventedComponent, type: string) { return listeners.get(comp)?.has?.(type); } - } diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index fbeedab..74a8a31 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -7,15 +7,15 @@ import { HitBox, HitBoxData } from "../../../services/HitTest"; import { EventedComponent } from "../EventedComponent/EventedComponent"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; - -export type GraphComponentContext = TComponentContext & TGraphLayerContext & { - graph: Graph; -} +export type GraphComponentContext = TComponentContext & + TGraphLayerContext & { + graph: Graph; + }; export class GraphComponent< Props extends TComponentProps = TComponentProps, State extends TComponentState = TComponentState, - Context extends GraphComponentContext = GraphComponentContext + Context extends GraphComponentContext = GraphComponentContext, > extends EventedComponent { public hitBox: HitBox; @@ -42,10 +42,9 @@ export class GraphComponent< protected willIterate(): void { super.willIterate(); - if(!this.firstIterate) { + if (!this.firstIterate) { this.shouldRender = this.isVisible(); } - } protected isVisible() { @@ -67,4 +66,4 @@ export class GraphComponent< public onHitBox(_: HitBoxData) { return this.isIterated(); } -} \ No newline at end of file +} diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index fdb07d2..3a715c5 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -47,7 +47,16 @@ export class Anchor extends GraphComponent { private hitBoxHash: string; - private debouncedSetHitBox: (...args: any[]) => void; + private debouncedSetHitBox = frameDebouncer.add( + () => { + const { x, y } = this.props.getPosition(this.props); + this.setHitBox(x - this.shift, y - this.shift, x + this.shift, y + this.shift); + }, + { + delay: 4, + lightFrame: true, + } + ); constructor(props: TAnchorProps, parent: GraphLayer) { super(props, parent); @@ -56,11 +65,6 @@ export class Anchor extends GraphComponent { this.connectedState = selectBlockAnchor(this.context.graph, props.blockId, props.id); this.subscribeSignal(this.connectedState.$selected, (selected) => { this.setState({ selected }); - }) - - this.debouncedSetHitBox = frameDebouncer.add(this.bindedSetHitBox.bind(this), { - delay: 4, - lightFrame: true, }); this.addEventListener("click", this); @@ -116,11 +120,6 @@ export class Anchor extends GraphComponent { } } - public bindedSetHitBox() { - const { x, y } = this.props.getPosition(this.props); - this.setHitBox(x - this.shift, y - this.shift, x + this.shift, y + this.shift); - } - private computeRenderSize(size: number, raised: boolean) { if (raised) { this.setState({ size: size * 1.8 }); diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 7bd610b..93137d0 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -78,10 +78,11 @@ export type BlockViewState = { order: number; }; -export class Block< - T extends TBlock = TBlock, - Props extends TBlockProps = TBlockProps -> extends GraphComponent { +export class Block extends GraphComponent< + Props, + T, + TGraphLayerContext +> { // from controller mixin public readonly isBlock = true; diff --git a/src/components/canvas/blocks/Blocks.ts b/src/components/canvas/blocks/Blocks.ts index 1cf49b4..81cd1fe 100644 --- a/src/components/canvas/blocks/Blocks.ts +++ b/src/components/canvas/blocks/Blocks.ts @@ -20,7 +20,6 @@ export class Blocks extends Component { this.unsubscribe = this.subscribe(); this.prepareFont(this.getFontScale()); - } protected getFontScale() { diff --git a/src/components/canvas/connections/Arrow/index.ts b/src/components/canvas/connections/Arrow/index.ts new file mode 100644 index 0000000..6d8352f --- /dev/null +++ b/src/components/canvas/connections/Arrow/index.ts @@ -0,0 +1,22 @@ +import { TConnection } from "../../../../store/connection/ConnectionState"; +import { Path2DRenderInstance, Path2DRenderStyleResult } from "../BatchPath2D"; +import { BlockConnection } from "../BlockConnection"; + +export type ELKArrowDefinition = { + color: string; + selectedColor: string; + height: number; + width: number; +}; + +export class ConnectionArrow implements Path2DRenderInstance { + constructor(protected connection: BlockConnection) {} + + public getPath() { + return this.connection.createArrowPath(); + } + + public style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined { + return this.connection.styleArrow(ctx); + } +} diff --git a/src/components/canvas/connections/BaseConnection.ts b/src/components/canvas/connections/BaseConnection.ts index a138eae..4a47670 100644 --- a/src/components/canvas/connections/BaseConnection.ts +++ b/src/components/canvas/connections/BaseConnection.ts @@ -9,38 +9,33 @@ import { Block } from "../blocks/Block"; export type TBaseConnectionProps = { id: TConnectionId; -} - -export type TBaseConnectionState = TComponentState & TConnection & { - hovered?: boolean; }; +export type TBaseConnectionState = TComponentState & + TConnection & { + hovered?: boolean; + }; export class BaseConnection< Props extends TBaseConnectionProps = TBaseConnectionProps, State extends TBaseConnectionState = TBaseConnectionState, Context extends GraphComponentContext = GraphComponentContext, - Connection extends TConnection = TConnection, + Connection extends TConnection = TConnection, > extends GraphComponent { - protected get sourceBlock(): Block { - return this.connectedState.$sourceBlock.value?.getViewComponent() + return this.connectedState.$sourceBlock.value?.getViewComponent(); } protected get targetBlock(): Block { return this.connectedState.$targetBlock.value?.getViewComponent(); - }; + } protected get sourceAnchor(): TAnchor | undefined { - return this.sourceBlock.connectedState - .getAnchorById(this.connectedState.sourceAnchorId) - ?.asTAnchor() - }; + return this.sourceBlock.connectedState.getAnchorById(this.connectedState.sourceAnchorId)?.asTAnchor(); + } protected get targetAnchor(): TAnchor | undefined { - return this.targetBlock.connectedState - .getAnchorById(this.connectedState.targetAnchorId) - ?.asTAnchor(); - }; + return this.targetBlock.connectedState.getAnchorById(this.connectedState.targetAnchorId)?.asTAnchor(); + } public connectionPoints: [TPoint, TPoint] | undefined; @@ -55,7 +50,7 @@ export class BaseConnection< this.connectedState = selectConnectionById(this.context.graph, this.props.id) as ConnectionState; - this.setState({ ...this.connectedState.$state.value as TBaseConnectionState, hovered: false }); + this.setState({ ...(this.connectedState.$state.value as TBaseConnectionState), hovered: false }); } protected willMount(): void { @@ -67,7 +62,7 @@ export class BaseConnection< this.updatePoints(); }); - this.listenEvents(["mouseenter", 'mouseleave']); + this.listenEvents(["mouseenter", "mouseleave"]); } protected override handleEvent(event) { @@ -87,15 +82,12 @@ export class BaseConnection< protected updatePoints() { if (!this.sourceBlock || !this.targetBlock) return; - this.connectionPoints = [ - this.sourceBlock.getConnectionPoint("out"), - this.targetBlock.getConnectionPoint("in"), - ]; + this.connectionPoints = [this.sourceBlock.getConnectionPoint("out"), this.targetBlock.getConnectionPoint("in")]; if (this.sourceAnchor && this.targetAnchor) { this.anchorsPoints = [ this.sourceBlock.getConnectionAnchorPosition(this.sourceAnchor), - this.targetBlock.getConnectionAnchorPosition(this.targetAnchor) - ] + this.targetBlock.getConnectionAnchorPosition(this.targetAnchor), + ]; } else { this.anchorsPoints = undefined; } @@ -103,13 +95,13 @@ export class BaseConnection< this.connectionPoints[0].x, this.connectionPoints[1].x, this.anchorsPoints?.[0].x || Infinity, - this.anchorsPoints?.[1].x || Infinity + this.anchorsPoints?.[1].x || Infinity, ]; const y = [ this.connectionPoints[0].y, this.connectionPoints[1].y, this.anchorsPoints?.[0].y || Infinity, - this.anchorsPoints?.[1].y || Infinity + this.anchorsPoints?.[1].y || Infinity, ]; this.bBox = [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)]; @@ -121,15 +113,14 @@ export class BaseConnection< return this.bBox; } - private updateHitBox = () => { const [x1, y1, x2, y2] = this.getBBox(); - const threshold = this.context.constants.connection.THRESHOLD_LINE_HIT; - this.setHitBox( - Math.min(x1, x2) - threshold, - Math.min(y1, y2) - threshold, - Math.max(x1, x2) + threshold, - Math.max(y1, y2) + threshold - ); + const threshold = this.context.constants.connection.THRESHOLD_LINE_HIT; + this.setHitBox( + Math.min(x1, x2) - threshold, + Math.min(y1, y2) - threshold, + Math.max(x1, x2) + threshold, + Math.max(y1, y2) + threshold + ); }; -} \ No newline at end of file +} diff --git a/src/components/canvas/connections/BatchPath2D/index.tsx b/src/components/canvas/connections/BatchPath2D/index.tsx index 38b4e77..84a6c8e 100644 --- a/src/components/canvas/connections/BatchPath2D/index.tsx +++ b/src/components/canvas/connections/BatchPath2D/index.tsx @@ -1,118 +1,129 @@ import { cache } from "../../../../lib/utils"; +export type Path2DRenderStyleResult = + | { type: "stroke" } + | { type: "fill"; fillRule?: CanvasFillRule } + | { type: "both"; fillRule?: CanvasFillRule }; + export interface Path2DRenderInstance { - createPath(): Path2D; - style(ctx: CanvasRenderingContext2D): { type: 'stroke' } | { type: 'fill'; fillRule?: CanvasFillRule } | undefined; - afterRender?(ctx: CanvasRenderingContext2D): void; + getPath(): Path2D | undefined | null; + style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined; + afterRender?(ctx: CanvasRenderingContext2D): void; } class Path2DGroup { - - protected items: Set = new Set(); - - protected path = cache(() => { - return Array.from(this.items).reduce((path, item) => { - path.addPath(item.createPath()); - return path; - }, new Path2D()); - }); - - protected applyStyles(ctx) { - const val = Array.from(this.items)[0]; - return val.style(ctx); - } - - - public add(item: Path2DRenderInstance) { - this.items.add(item); - this.path.reset(); - } - - public delete(item) { - this.items.delete(item); - this.path.reset(); - } - - public render(ctx: CanvasRenderingContext2D) { - if (this.items.size) { - ctx.save(); - - const result = this.applyStyles(ctx); - if (result && result.type === 'fill') { - ctx.fill(this.path.get(), result.fillRule); - } else { - ctx.stroke(this.path.get()); - } - ctx.restore(); - for(const item of this.items) { - item.afterRender(ctx); - } + protected items: Set = new Set(); + + protected path = cache(() => { + const path = new Path2D(); + path.moveTo(0, 0); + return Array.from(this.items).reduce((path, item) => { + const subPath = item.getPath(); + if (subPath) { + path.addPath(subPath); + } + return path; + }, path); + }); + + protected applyStyles(ctx) { + const val = Array.from(this.items)[0]; + return val.style(ctx); + } + + public add(item: Path2DRenderInstance) { + this.items.add(item); + this.path.reset(); + } + + public delete(item) { + this.items.delete(item); + this.path.reset(); + } + + public render(ctx: CanvasRenderingContext2D) { + if (this.items.size) { + ctx.save(); + + const result = this.applyStyles(ctx); + if (result) { + switch (result.type) { + case "fill": { + ctx.fill(this.path.get(), result.fillRule); + break; + } + case "stroke": { + ctx.stroke(this.path.get()); + break; + } + case "both": { + ctx.fill(this.path.get(), result.fillRule); + ctx.stroke(this.path.get()); + } } - + } + ctx.restore(); + for (const item of this.items) { + item.afterRender?.(ctx); + } } - + } } export class BatchPath2DRenderer { + constructor(protected onChange: () => void) {} - constructor(protected onChange: () => void) { + protected indexes: Map> = new Map(); - } + protected itemParams: Map = new Map(); - protected indexes: Map> = new Map(); + public orderedPaths = cache(() => { + return Array.from(this.indexes.entries()) + .sort(([indexA], [indexB]) => indexB - indexA) + .reduce((acc, [_, items]) => { + acc.push(...Array.from(items.values())); + return acc; + }, [] satisfies Path2DGroup[]); + }); - protected itemParams: Map = new Map(); - - public orderedPaths = cache(() => { - return Array.from(this.indexes.entries()) - .sort(([indexA], [indexB]) => indexA - indexB) - .reduce((acc, [_, items]) => { - acc.push(...Array.from(items.values())); - return acc; - }, [] as Path2DGroup[]) - }) - - protected getGroup(zIndex: number, group: string) { - if (!this.indexes.has(zIndex)) { - this.indexes.set(zIndex, new Map()); - } - const index = this.indexes.get(zIndex); - - if (!index.has(group)) { - index.set(group, new Path2DGroup()); - } - - return index.get(group); + protected getGroup(zIndex: number, group: string) { + if (!this.indexes.has(zIndex)) { + this.indexes.set(zIndex, new Map()); } + const index = this.indexes.get(zIndex); - public add(item: Path2DRenderInstance, params: { zIndex: number, group: string }) { - if(this.itemParams.has(item)) { - this.update(item, params); - } - const bucket = this.getGroup(params.zIndex, params.group); - bucket.add(item); - this.itemParams.set(item, params); - this.orderedPaths.reset(); - this.onChange?.(); + if (!index.has(group)) { + index.set(group, new Path2DGroup()); } - public update(item: Path2DRenderInstance, params: { zIndex: number, group: string }) { - this.delete(item); - this.add(item, params); - } + return index.get(group); + } - public delete(item: Path2DRenderInstance) { - if(!this.itemParams.has(item)) { - return; - } - const params = this.itemParams.get(item); - const bucket = this.getGroup(params.zIndex, params.group); - bucket.delete(item); - this.itemParams.delete(item); - this.orderedPaths.reset(); - this.onChange?.(); + public add(item: Path2DRenderInstance, params: { zIndex: number; group: string }) { + if (this.itemParams.has(item)) { + this.update(item, params); } - + const bucket = this.getGroup(params.zIndex, params.group); + bucket.add(item); + this.itemParams.set(item, params); + this.orderedPaths.reset(); + this.onChange?.(); + } + + public update(item: Path2DRenderInstance, params: { zIndex: number; group: string }) { + this.delete(item); + this.add(item, params); + } + + public delete(item: Path2DRenderInstance) { + if (!this.itemParams.has(item)) { + return; + } + const params = this.itemParams.get(item); + const bucket = this.getGroup(params.zIndex, params.group); + bucket.delete(item); + this.itemParams.delete(item); + this.orderedPaths.reset(); + this.onChange?.(); + } } - - diff --git a/src/components/canvas/connections/BlockConnection.ts b/src/components/canvas/connections/BlockConnection.ts index dd811ae..5c9c225 100644 --- a/src/components/canvas/connections/BlockConnection.ts +++ b/src/components/canvas/connections/BlockConnection.ts @@ -7,8 +7,9 @@ import { getFontSize } from "../../../utils/functions/text"; import { cachedMeasureText } from "../../../utils/renderers/text"; import { ESelectionStrategy } from "../../../utils/types/types"; +import { ConnectionArrow } from "./Arrow"; import { BaseConnection, TBaseConnectionProps, TBaseConnectionState } from "./BaseConnection"; -import { Path2DRenderInstance } from "./BatchPath2D"; +import { Path2DRenderInstance, Path2DRenderStyleResult } from "./BatchPath2D"; import { BlockConnections, TGraphConnectionsContext } from "./BlockConnections"; import { bezierCurveLine, getArrowCoords, isPointInStroke } from "./bezierHelpers"; import { getLabelCoords } from "./labelHelper"; @@ -26,34 +27,72 @@ export type TBlockConnection = { removeFromRenderOrder(cmp): void; }; -export class BlockConnection extends BaseConnection< - TConnectionProps, - TBaseConnectionState, - TGraphConnectionsContext, - T -> implements Path2DRenderInstance { - +export class BlockConnection + extends BaseConnection + implements Path2DRenderInstance +{ public readonly cursor = "pointer"; protected path2d: Path2D; private labelGeometry = { x: 0, y: 0, width: 0, height: 0 }; - protected geometry: { x1: number, x2: number, y1: number, y2: number } = { x1: 0, x2: 0, y1: 0, y2: 0 }; + protected geometry: { x1: number; x2: number; y1: number; y2: number } = { x1: 0, x2: 0, y1: 0, y2: 0 }; + + protected arrowShape = new ConnectionArrow(this); constructor(props: TConnectionProps, parent: BlockConnections) { super(props, parent); this.addEventListener("click", this); - this.context.batch.add(this, {zIndex: this.zIndex, group: this.getClassName()}); + this.context.batch.add(this, { zIndex: this.zIndex, group: this.getClassName() }); + this.context.batch.add(this.arrowShape, { zIndex: this.zIndex, group: `arrow/${this.getClassName()}` }); + } + + protected applyShape(state: TBaseConnectionState = this.state) { + const zIndex = state.selected || state.hovered ? this.zIndex + 10 : this.zIndex; + this.context.batch.update(this, { zIndex: zIndex, group: this.getClassName(state) }); + this.context.batch.update(this.arrowShape, { zIndex: zIndex, group: `arrow/${this.getClassName(state)}` }); + } + + public getPath(): Path2D { + return this.generatePath(); + } + + public createArrowPath() { + const coords = getArrowCoords( + this.props.useBezier, + this.geometry.x1, + this.geometry.y1, + this.geometry.x2, + this.geometry.y2, + this.props.bezierDirection + ); + const path = new Path2D(); + path.moveTo(coords[0], coords[1]); + path.lineTo(coords[2], coords[3]); + path.lineTo(coords[4], coords[5]); + return path; + } + + public styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined { + ctx.lineWidth = this.state.hovered || this.state.selected ? 4 : 2; + ctx.strokeStyle = this.getStrokeColor(this.state); + return { type: "stroke" }; } - public createPath(): Path2D { - if(!this.geometry) { + protected generatePath() { + /* Setting this.path2D is important, as hotbox checking uses the isPointInStroke method. */ + this.path2d = this.createPath(); + return this.path2d; + } + + protected createPath(): Path2D { + if (!this.geometry) { return new Path2D(); } if (this.props.useBezier) { - this.path2d = bezierCurveLine( + return bezierCurveLine( { x: this.geometry.x1, y: this.geometry.y1, @@ -64,25 +103,24 @@ export class BlockConnection extends BaseConnection< }, this.props.bezierDirection ); - } else { - this.path2d = new Path2D(); - this.path2d.moveTo(this.geometry.x1, this.geometry.y1); - this.path2d.lineTo(this.geometry.x2, this.geometry.y2); } + this.path2d = new Path2D(); + this.path2d.moveTo(this.geometry.x1, this.geometry.y1); + this.path2d.lineTo(this.geometry.x2, this.geometry.y2); return this.path2d; } public getClassName(state = this.state) { - const hovered = state.hovered ? 'hovered' : 'none'; - const selected = state.selected ? 'selected' : 'none'; + const hovered = state.hovered ? "hovered" : "none"; + const selected = state.selected ? "selected" : "none"; const stroke = this.getStrokeColor(state); - const dash = state.dashed ? (state.styles?.dashes || [6, 4]).join(',') : ""; + const dash = state.dashed ? (state.styles?.dashes || [6, 4]).join(",") : ""; return `connection/${hovered}/${selected}/${stroke}/${dash}`; } - - public style(ctx: CanvasRenderingContext2D): { type: "stroke"; } | { type: "fill"; fillRule?: CanvasFillRule; } | undefined { - this.setRenderStyles(ctx, this.state) + + public style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined { + this.setRenderStyles(ctx, this.state); return { type: "stroke" }; } @@ -98,10 +136,6 @@ export class BlockConnection extends BaseConnection< const cameraClose = this.context.camera.getCameraScale() >= this.context.constants.connection.MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL; - if (this.props.showConnectionArrows && cameraClose) { - ctx.stroke(this.renderArrow()); - } - if (this.state.label && this.props.showConnectionLabels && cameraClose) { this.renderLabelText(ctx); } @@ -109,13 +143,12 @@ export class BlockConnection extends BaseConnection< protected override propsChanged(nextProps: TConnectionProps) { super.propsChanged(nextProps); - - this.context.batch.update(this, {zIndex: this.zIndex, group: this.getClassName()}); + this.applyShape(this.state); } protected override stateChanged(nextState: TBaseConnectionState) { super.stateChanged(nextState); - this.context.batch.update(this, {zIndex: this.zIndex, group: this.getClassName(nextState)}); + this.applyShape(nextState); } public get zIndex() { @@ -131,15 +164,15 @@ export class BlockConnection extends BaseConnection< y1: 0, x2: 0, y2: 0, - } + }; return; } const useAnchors = this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors"); - const source = useAnchors ? (this.anchorsPoints?.[0] || this.connectionPoints[0]) : this.connectionPoints[0]; - const target = useAnchors ? (this.anchorsPoints?.[1] || this.connectionPoints[1]) : this.connectionPoints[1]; + const source = useAnchors ? this.anchorsPoints?.[0] || this.connectionPoints[0] : this.connectionPoints[0]; + const target = useAnchors ? this.anchorsPoints?.[1] || this.connectionPoints[1] : this.connectionPoints[1]; - if(!source || !target) { - this.context.batch.update(this, {zIndex: this.zIndex, group: this.getClassName(this.state)}); + if (!source || !target) { + this.applyShape(); return; } this.geometry = { @@ -147,8 +180,8 @@ export class BlockConnection extends BaseConnection< y1: source.y, x2: target.x, y2: target.y, - } - this.context.batch.update(this, {zIndex: this.zIndex, group: this.getClassName(this.state)}); + }; + this.applyShape(); } protected override handleEvent(event) { @@ -193,12 +226,8 @@ export class BlockConnection extends BaseConnection< } private renderLabelText(ctx: CanvasRenderingContext2D) { - const [ - labelInnerTopPadding, - labelInnerRightPadding, - labelInnerBottomPadding, - labelInnerLeftPadding, - ] = this.context.constants.connection.LABEL.INNER_PADDINGS; + const [labelInnerTopPadding, labelInnerRightPadding, labelInnerBottomPadding, labelInnerLeftPadding] = + this.context.constants.connection.LABEL.INNER_PADDINGS; const padding = this.context.constants.system.GRID_SIZE / 8; const fontSize = Math.max(14, getFontSize(9, this.context.camera.getCameraScale())); const font = `${fontSize}px sans-serif`; @@ -240,26 +269,10 @@ export class BlockConnection extends BaseConnection< x - labelInnerLeftPadding, y - labelInnerTopPadding, measure.width + labelInnerLeftPadding + labelInnerRightPadding, - measure.height + labelInnerTopPadding + labelInnerBottomPadding, + measure.height + labelInnerTopPadding + labelInnerBottomPadding ); } - public renderArrow() { - const coords = getArrowCoords( - this.props.useBezier, - this.geometry.x1, - this.geometry.y1, - this.geometry.x2, - this.geometry.y2, - this.props.bezierDirection - ); - const path = new Path2D(); - path.moveTo(coords[0], coords[1]); - path.lineTo(coords[2], coords[3]); - path.lineTo(coords[4], coords[5]); - return path; - } - public getStrokeColor(state: TConnection) { if (state.selected) return state.styles?.selectedBackground || this.context.colors.connection.selectedBackground; diff --git a/src/components/canvas/connections/BlockConnections.ts b/src/components/canvas/connections/BlockConnections.ts index 9a53e5c..5fe53d9 100644 --- a/src/components/canvas/connections/BlockConnections.ts +++ b/src/components/canvas/connections/BlockConnections.ts @@ -9,13 +9,12 @@ import { BlockConnection, TConnectionProps } from "./BlockConnection"; export type TGraphConnectionsContext = TGraphLayerContext & { batch: BatchPath2DRenderer; -} +}; export class BlockConnections extends Component { - public get connections(): ConnectionState[] { return this.context.graph.rootStore.connectionsList.$connections.value; - }; + } protected readonly unsubscribe: (() => void)[]; @@ -25,8 +24,8 @@ export class BlockConnections extends Component { this.scheduleUpdate(); - }), + }), this.context.graph.rootStore.connectionsList.$connections.subscribe(() => { this.scheduleUpdate(); - }), + }), this.context.graph.rootStore.settings.$connection.subscribe(() => { this.scheduleUpdate(); - }) + }), ]; } @@ -72,7 +71,7 @@ export class BlockConnections extends Component { private camera: ICamera; constructor(props: TBelowLayerProps) { - super( - { - canvas: { - zIndex: 1, - classNames: ["no-pointer-events"], - }, - ...props, - } - ); + super({ + canvas: { + zIndex: 1, + classNames: ["no-pointer-events"], + }, + ...props, + }); this.setContext({ canvas: this.getCanvas(), diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts index ff7ad6c..c5dbad4 100644 --- a/src/components/canvas/layers/graphLayer/GraphLayer.ts +++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts @@ -71,38 +71,34 @@ export class GraphLayer extends Layer { private fixedTargetComponent?: EventedComponent | Camera; constructor(props: TGraphLayerProps) { - super( - { - canvas: { - zIndex: 2, - respectPixelRatio: true, - classNames: ["no-user-select"], - }, - html: { - zIndex: 3, - classNames: ["no-user-select"], - transformByCameraPosition: true, - }, - ...props, - } - ); + super({ + canvas: { + zIndex: 2, + respectPixelRatio: true, + classNames: ["no-user-select"], + }, + html: { + zIndex: 3, + classNames: ["no-user-select"], + transformByCameraPosition: true, + }, + ...props, + }); const canvas = this.getCanvas(); const html = this.getHTML(); - this.setContext( - { - canvas: canvas, - ctx: canvas.getContext("2d"), - htmlCtx: html as HTMLDivElement, - root: this.props.root, - camera: this.props.camera, - ownerDocument: html.ownerDocument, - constants: this.props.graph.graphConstants, - colors: this.props.graph.graphColors, - graph: this.props.graph, - } - ); + this.setContext({ + canvas: canvas, + ctx: canvas.getContext("2d"), + htmlCtx: html as HTMLDivElement, + root: this.props.root, + camera: this.props.camera, + ownerDocument: html.ownerDocument, + constants: this.props.graph.graphConstants, + colors: this.props.graph.graphColors, + graph: this.props.graph, + }); if (this.context.root) { this.attachListeners(); @@ -143,21 +139,28 @@ export class GraphLayer extends Layer { } if (e.eventPhase === Event.CAPTURING_PHASE && rootCapturingEventTypes.has(e.type)) { - return this.tryEmulateClick(e); + this.tryEmulateClick(e); + return; } if (e.eventPhase === Event.BUBBLING_PHASE && rootBubblingEventTypes.has(e.type)) { switch (e.type) { case "mousedown": - case "touchstart": + case "touchstart": { this.updateTargetComponent(e, true); - return this.handleMouseDownEvent(e); + this.handleMouseDownEvent(e); + break; + } case "mouseup": - case "touchend": - return this.onRootPointerEnd(e); + case "touchend": { + this.onRootPointerEnd(e); + break; + } case "click": - case "dblclick": - return this.tryEmulateClick(e); + case "dblclick": { + this.tryEmulateClick(e); + break; + } } } } @@ -195,7 +198,7 @@ export class GraphLayer extends Layer { return; } if (this.fixedTargetComponent !== undefined) { - return this.fixedTargetComponent; + return; } this.prevTargetComponent = this.targetComponent; diff --git a/src/components/canvas/layers/overLayer/OverLayer.ts b/src/components/canvas/layers/overLayer/OverLayer.ts index 7983348..52ab802 100644 --- a/src/components/canvas/layers/overLayer/OverLayer.ts +++ b/src/components/canvas/layers/overLayer/OverLayer.ts @@ -26,15 +26,13 @@ export class OverLayer extends Layer { protected newBlocksService = new NewBlocksService(this.props.graph); constructor(props: TOverLayerProps) { - super( - { - canvas: { - zIndex: 4, - classNames: ["no-pointer-events"], - }, - ...props, + super({ + canvas: { + zIndex: 4, + classNames: ["no-pointer-events"], }, - ); + ...props, + }); this.setContext({ canvas: this.getCanvas(), diff --git a/src/graph.ts b/src/graph.ts index 8f07110..9848aea 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -25,8 +25,8 @@ import { IPoint, IRect, Point, TPoint, TRect, isTRect } from "./utils/types/shap export type LayerConfig = Constructor> = [ T, T extends Constructor> - ? Omit & { root?: Props["root"] } - : never, + ? Omit & { root?: Props["root"] } + : never, ]; export type TGraphConfig = { configurationName?: string; @@ -136,7 +136,7 @@ export class Graph { * @param zoomConfig.x if set - zoom to x coordinate, else - zoom to center * @param zoomConfig.y if set - zoom to y coordinate, else - zoom to center * @param zoomConfig.scale camera scale - * + * * @returns {undefined} * */ public zoom(zoomConfig: { x?: number; y?: number; scale: number }) { @@ -156,10 +156,7 @@ export class Graph { this.api.zoomToBlocks(target, config); } - public getElementsOverPoint>( - point: IPoint, - filter?: T[] - ): InstanceType[] { + public getElementsOverPoint>(point: IPoint, filter?: T[]): InstanceType[] { const items = this.hitTest.testPoint(point, this.graphConstants.system.PIXEL_RATIO); if (filter && items.length > 0) { return items.filter((item) => filter.some((Component) => item instanceof Component)) as InstanceType[]; @@ -174,10 +171,7 @@ export class Graph { return this.getElementsOverPoint(point, filter)?.[0] as InstanceType | undefined; } - public getElementsOverRect>( - rect: TRect, - filter?: T[] - ): InstanceType[] { + public getElementsOverRect>(rect: TRect, filter?: T[]): InstanceType[] { const items = this.hitTest.testBox({ minX: rect.x, minY: rect.y, diff --git a/src/graphConfig.ts b/src/graphConfig.ts index 38800e4..1847289 100644 --- a/src/graphConfig.ts +++ b/src/graphConfig.ts @@ -117,7 +117,7 @@ export type TGraphConstants = { MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL: number; LABEL: { INNER_PADDINGS: [number, number, number, number]; - } + }; }; text: { @@ -157,7 +157,7 @@ export const initGraphConstants: TGraphConstants = { MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL: 0.25, LABEL: { INNER_PADDINGS: [0, 0, 0, 0], - } + }, }, text: { BASE_FONT_SIZE: 24, diff --git a/src/lib/Component.ts b/src/lib/Component.ts index 8ae87d3..9fe5bf0 100644 --- a/src/lib/Component.ts +++ b/src/lib/Component.ts @@ -8,7 +8,7 @@ export type TComponentContext = CoreComponentContext; export class Component< Props extends CoreComponentProps = CoreComponentProps, State extends TComponentState = TComponentState, - Context extends CoreComponentContext = CoreComponentContext + Context extends CoreComponentContext = CoreComponentContext, > extends CoreComponent { protected firstIterate = true; protected firstRender = true; @@ -20,7 +20,7 @@ export class Component< public state: State; - protected __data: { nextProps: Props | undefined, nextState: State | undefined } = { + protected __data: { nextProps: Props | undefined; nextState: State | undefined } = { nextProps: undefined, nextState: undefined, }; diff --git a/src/lib/CoreComponent.ts b/src/lib/CoreComponent.ts index 55f2095..0d27f1e 100644 --- a/src/lib/CoreComponent.ts +++ b/src/lib/CoreComponent.ts @@ -33,8 +33,9 @@ function createDefaultPrivateContext() { export class CoreComponent< Props extends CoreComponentProps = CoreComponentProps, - Context extends CoreComponentContext = CoreComponentContext -> implements ITree { + Context extends CoreComponentContext = CoreComponentContext, +> implements ITree +{ public $: object = {}; public context: Context = {} as Context; @@ -57,7 +58,7 @@ export class CoreComponent< } constructor(props: Props, parent?: CoreComponent) { - this.context = parent?.context as Context || {} as Context; + this.context = (parent?.context as Context) || ({} as Context); this.__comp = { parent, diff --git a/src/lib/Tree.spec.ts b/src/lib/Tree.spec.ts index e2e1904..9af8289 100644 --- a/src/lib/Tree.spec.ts +++ b/src/lib/Tree.spec.ts @@ -1,16 +1,16 @@ import { ITree, Tree } from "./Tree"; - -describe('Tree', () => { - const createChildData = () => ({ - iterate: jest.fn(), - } as ITree) - test('Creation of a Tree object', () => { +describe("Tree", () => { + const createChildData = () => + ({ + iterate: jest.fn(), + }) as ITree; + test("Creation of a Tree object", () => { const tree = new Tree(createChildData()); expect(tree).toBeInstanceOf(Tree); }); - test('Adding and removing child elements', () => { + test("Adding and removing child elements", () => { const parent = new Tree(createChildData()); const child1 = new Tree(createChildData()); const child2 = new Tree(createChildData()); @@ -27,7 +27,7 @@ describe('Tree', () => { expect(parent.children.size).toBe(0); }); - test('Changing the rendering order', () => { + test("Changing the rendering order", () => { const parent = new Tree(createChildData()); const child1 = new Tree(createChildData()); const child2 = new Tree(createChildData()); @@ -42,7 +42,7 @@ describe('Tree', () => { expect(child2.renderOrder).toBe(2); }); - test('Changing the z-index', () => { + test("Changing the z-index", () => { const parent = new Tree(createChildData()); const child1 = new Tree(createChildData()); const child2 = new Tree(createChildData()); @@ -57,7 +57,7 @@ describe('Tree', () => { expect(child2.zIndex).toBe(2); }); - test('Traversing the tree', () => { + test("Traversing the tree", () => { const root = new Tree(createChildData()); const child1 = new Tree(createChildData()); const child2 = new Tree(createChildData()); @@ -75,4 +75,4 @@ describe('Tree', () => { expect(count).toBe(3); // Including the root }); -}); \ No newline at end of file +}); diff --git a/src/lib/Tree.ts b/src/lib/Tree.ts index 56c9834..fea16e4 100644 --- a/src/lib/Tree.ts +++ b/src/lib/Tree.ts @@ -11,7 +11,7 @@ export class Tree { public parent: Tree; public children: Set = new Set(); - + private childrenArray: Tree[] = []; protected childrenDirty = false; @@ -85,7 +85,7 @@ export class Tree { } public updateChildZIndex(child: Tree) { - if(!this.children.has(child)) { + if (!this.children.has(child)) { return; } this.removeZIndex(child); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7d9be5d..8cc2fa7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -10,13 +10,12 @@ export function assign(target: T, source: S) return target as T & S; } - export function cache(fn: () => T) { let result: T; let touched = true; return { get: () => { - if(touched) { + if (touched) { result = fn(); touched = false; } @@ -28,6 +27,6 @@ export function cache(fn: () => T) { clear() { touched = true; result = undefined; - } - } -} \ No newline at end of file + }, + }; +} diff --git a/src/plugins/minimap/layer.ts b/src/plugins/minimap/layer.ts index 5470063..9945ba6 100644 --- a/src/plugins/minimap/layer.ts +++ b/src/plugins/minimap/layer.ts @@ -41,15 +41,13 @@ export class MiniMapLayer extends Layer const classNames = Array.isArray(props.classNames) ? props.classNames : []; classNames.push("graph-minimap"); - super( - { - canvas: { - zIndex: 300, - classNames, - }, - ...props, + super({ + canvas: { + zIndex: 300, + classNames, }, - ); + ...props, + }); this.minimapWidth = this.props.width || 200; this.minimapHeight = this.props.height || 200; diff --git a/src/services/HitTest.ts b/src/services/HitTest.ts index cfa26c3..450360c 100644 --- a/src/services/HitTest.ts +++ b/src/services/HitTest.ts @@ -145,16 +145,11 @@ export class HitBox implements IHitBox { this.minY = minY; this.maxX = maxX; this.maxY = maxY; - this.rect = [ - this.minX, - this.minY, - this.maxX - this.minX, - this.maxY - this.minY, - ]; + this.rect = [this.minX, this.minY, this.maxX - this.minX, this.maxY - this.minY]; this.hitTest.add(this, Boolean(force)); }; - public getRect(): [number, number, number, number] { + public getRect(): [number, number, number, number] { return this.rect; } diff --git a/src/services/Layer.ts b/src/services/Layer.ts index c61092e..f005737 100644 --- a/src/services/Layer.ts +++ b/src/services/Layer.ts @@ -31,7 +31,7 @@ export type LayerContext = { export class Layer< Props extends LayerProps = LayerProps, Context extends LayerContext = LayerContext, - > extends Component { +> extends Component { public static id?: string; protected canvas: HTMLCanvasElement; @@ -44,11 +44,11 @@ export class Layer< constructor(props: Props, parent?: CoreComponent) { super(props, parent); - + this.setContext({ camera: props.camera, }); - + this.init(); this.props.graph.on("colors-changed", (event) => { diff --git a/src/services/newConnection/ConnectionService.ts b/src/services/newConnection/ConnectionService.ts index 7f4f0bb..7d3db1b 100644 --- a/src/services/newConnection/ConnectionService.ts +++ b/src/services/newConnection/ConnectionService.ts @@ -77,10 +77,9 @@ export class ConnectionService extends Emitter { return; } if ( - ( - (this.graph.rootStore.settings.getConfigFlag("useBlocksAnchors") && target instanceof Anchor) || - (isShiftKeyEvent(event) && isBlock(target)) - ) && this.graph.rootStore.settings.getConfigFlag("canCreateNewConnections") + ((this.graph.rootStore.settings.getConfigFlag("useBlocksAnchors") && target instanceof Anchor) || + (isShiftKeyEvent(event) && isBlock(target))) && + this.graph.rootStore.settings.getConfigFlag("canCreateNewConnections") ) { nativeEvent.preventDefault(); nativeEvent.stopPropagation(); diff --git a/src/store/block/BlocksList.ts b/src/store/block/BlocksList.ts index 019e362..26cab6f 100644 --- a/src/store/block/BlocksList.ts +++ b/src/store/block/BlocksList.ts @@ -126,13 +126,17 @@ export class BlockListStore { } if (selected !== anchor.$selected.value) { - this.graph.executеDefaultEventAction("block-anchor-selection-change", { anchor: anchor.asTAnchor(), selected }, () => { - const currentSelected = this.$selectedAnchor.value; - if (currentSelected && currentSelected !== anchor) { - currentSelected.setSelection(false, true); + this.graph.executеDefaultEventAction( + "block-anchor-selection-change", + { anchor: anchor.asTAnchor(), selected }, + () => { + const currentSelected = this.$selectedAnchor.value; + if (currentSelected && currentSelected !== anchor) { + currentSelected.setSelection(false, true); + } + anchor.setSelection(selected, true); } - anchor.setSelection(selected, true); - }); + ); } } diff --git a/src/store/connection/ConnectionState.ts b/src/store/connection/ConnectionState.ts index ee4fdc6..698c411 100644 --- a/src/store/connection/ConnectionState.ts +++ b/src/store/connection/ConnectionState.ts @@ -56,11 +56,8 @@ export class ConnectionState { }); public $geometry = computed(() => { - return [ - this.$sourceBlock.value?.$geometry.value, - this.$targetBlock.value?.$geometry.value, - ] - }) + return [this.$sourceBlock.value?.$geometry.value, this.$targetBlock.value?.$geometry.value]; + }); public static getConnectionId(connection: TConnection) { if (connection.id) return connection.id; @@ -72,7 +69,7 @@ export class ConnectionState { constructor( public store: ConnectionsStore, - connectionState: TConnection + connectionState: T ) { const id = ConnectionState.getConnectionId(connectionState); this.$state.value = { ...connectionState, id }; diff --git a/src/store/settings.ts b/src/store/settings.ts index 655280b..2255424 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -1,19 +1,19 @@ import { computed, signal } from "@preact/signals-core"; import type { Block, TBlock } from "../components/canvas/blocks/Block"; - -import { RootStore } from "./index"; -import { BaseConnection } from "../components/canvas/connections/BaseConnection"; import { BlockConnection } from "../components/canvas/connections/BlockConnection"; + import { TConnection } from "./connection/ConnectionState"; +import { RootStore } from "./index"; + export enum ECanChangeBlockGeometry { ALL = "all", ONLY_SELECTED = "onlySelected", NONE = "none", } -export type TGraphSettingsConfig = { +export type TGraphSettingsConfig = { canDragCamera: boolean; canZoomCamera: boolean; canDuplicateBlocks: boolean; @@ -27,7 +27,7 @@ export type TGraphSettingsConfig>; - connection?: typeof BlockConnection, + connection?: typeof BlockConnection; }; const getInitState: TGraphSettingsConfig = { @@ -55,7 +55,7 @@ export class GraphEditorSettings { public $connection = computed(() => { return this.$settings.value.connection; - }) + }); constructor(public rootStore: RootStore) {} diff --git a/src/stories/configurations/generatePretty.ts b/src/stories/configurations/generatePretty.ts index 448c894..4f03331 100644 --- a/src/stories/configurations/generatePretty.ts +++ b/src/stories/configurations/generatePretty.ts @@ -88,6 +88,7 @@ export function generatePrettyBlocks( sourceBlockId: sourceBlockId, targetBlockId: targetBlockId, label: "Some label", + dashed: dashedLine && Boolean(Math.floor(random(0, 2))), }); } prevLayerBlocks = [...currentLayerBlocks]; diff --git a/src/stories/examples/elk/ELKConnection.ts b/src/stories/examples/elk/ELKConnection.ts index ee65650..f928322 100644 --- a/src/stories/examples/elk/ELKConnection.ts +++ b/src/stories/examples/elk/ELKConnection.ts @@ -1,78 +1,82 @@ import { ElkExtendedEdge } from "elkjs"; +import { Path2DRenderStyleResult } from "../../../components/canvas/connections/BatchPath2D"; import { BlockConnection } from "../../../components/canvas/connections/BlockConnection"; import { TConnection } from "../../../store/connection/ConnectionState"; - -import { curve, getElkArrowCoords } from "./helpers"; +import { curvePolyline } from "../../../utils/shapes/curvePolyline"; +import { trangleArrowForVector } from "../../../utils/shapes/triangle"; export type TElkTConnection = TConnection & { - elk: ElkExtendedEdge -} - - + elk: ElkExtendedEdge; +}; export class ELKConnection extends BlockConnection { - public createPath() { - const elk = this.connectedState.$state.value.elk; - if(!elk || !elk.sections) { - return super.createPath(); - } - const path = new Path2D(); - path.addPath(curve(this.points, 50)); - const pointA = this.points[this.points.length - 1]; - const pointB = this.points[this.points.length - 2]; + protected points: { x: number; y: number }[] = []; - path.addPath(getElkArrowCoords(pointB.x, pointB.y, pointA.x, pointA.y, 8)); - this.path2d = path; - return path; + public createPath() { + const elk = this.connectedState.$state.value.elk; + if (!elk.sections || !this.points?.length) { + return super.createPath(); } + return curvePolyline(this.points, 10); + } - public style(ctx: CanvasRenderingContext2D): { type: "stroke"; } | { type: "fill"; fillRule?: CanvasFillRule; } | undefined { - ctx.lineCap = "round"; - return super.style(ctx); + public createArrowPath(): Path2D { + if (!this.points.length) { + return undefined; } + const [start, end] = this.points.slice(this.points.length - 2); + return trangleArrowForVector(start, end, 16, 10); + } - public afterRender?(_: CanvasRenderingContext2D): void { - // noop; - return; - } + public styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult { + ctx.fillStyle = this.state.selected + ? this.context.colors.connection.selectedBackground + : this.context.colors.connection.background; + ctx.strokeStyle = ctx.fillStyle; + ctx.lineWidth = this.state.selected || this.state.hovered ? -1 : 1; + return { type: "both" }; + } - protected points: {x:number, y: number}[] + public getPoints() { + return this.points || []; + } - public updatePoints(): void { - super.updatePoints(); - const elk = this.connectedState.$state.value.elk; - if(!elk || !elk.sections) { - return; - } - const section = elk.sections[0]; + public afterRender?(_: CanvasRenderingContext2D): void { + // do not render label; + return; + } - this.points = [ - section.startPoint, - ...section.bendPoints?.map((point) => point) || [], - section.endPoint, - ]; - - return; + public updatePoints(): void { + super.updatePoints(); + const elk = this.connectedState.$state.value.elk; + if (!elk || !elk.sections) { + return; } + const section = elk.sections[0]; + + this.points = [section.startPoint, ...(section.bendPoints?.map((point) => point) || []), section.endPoint]; - public getBBox() { - const elk = this.connectedState.$state.value.elk; - if(!elk || !elk.sections) { - return super.getBBox(); - } - const x = []; - const y = []; - elk.sections.forEach((c) => { - x.push(c.startPoint.x); - y.push(c.startPoint.y); - c.bendPoints?.forEach((point) => { - x.push(point.x); - y.push(point.y); - }); - x.push(c.endPoint.x); - y.push(c.endPoint.y); - }); - return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + return; + } + + public getBBox() { + const elk = this.connectedState.$state.value.elk; + if (!elk || !elk.sections) { + return super.getBBox(); } -} \ No newline at end of file + const x = []; + const y = []; + elk.sections.forEach((c) => { + x.push(c.startPoint.x); + y.push(c.startPoint.y); + c.bendPoints?.forEach((point) => { + x.push(point.x); + y.push(point.y); + }); + x.push(c.endPoint.x); + y.push(c.endPoint.y); + }); + return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + } +} diff --git a/src/stories/examples/elk/elk.stories.tsx b/src/stories/examples/elk/elk.stories.tsx index 130b889..03dcea2 100644 --- a/src/stories/examples/elk/elk.stories.tsx +++ b/src/stories/examples/elk/elk.stories.tsx @@ -2,113 +2,108 @@ import React, { useEffect, useMemo, useState } from "react"; import { Select, SelectOption, ThemeProvider } from "@gravity-ui/uikit"; import type { Meta, StoryFn } from "@storybook/react"; -import ELK, { ElkNode } from 'elkjs'; +import ELK, { ElkExtendedEdge, ElkNode } from "elkjs"; import { Graph, GraphCanvas, GraphState, TBlock, useGraph, useGraphEvent } from "../../../index"; import { useFn } from "../../../utils/hooks/useFn"; import { generatePrettyBlocks } from "../../configurations/generatePretty"; import { BlockStory } from "../../main/Block"; - import { ELKConnection } from "./ELKConnection"; import "@gravity-ui/uikit/styles/styles.css"; export type TElkBlock = TBlock & { - elk: ElkNode, -} + elk: ElkNode; +}; -const config = generatePrettyBlocks(10, 100, true); +const config = generatePrettyBlocks(10, 30, true); const GraphApp = () => { - const { graph, setEntities, start } = useGraph({ settings: { - connection: ELKConnection + connection: ELKConnection, }, }); - const elk = useMemo(() => new ELK({}), []) + const elk = useMemo(() => new ELK({}), []); - const [algoritm, setAlgortm] = useState('layered'); + const [algoritm, setAlgortm] = useState("layered"); useEffect(() => { - const {blocks, connections} = config; + const { blocks, connections } = config; const blocksMap = new Map(blocks.map((b) => [b.id, b])); const conMap = new Map(connections.map((b) => [b.id, b])); const graphDefinition = { - id: "root", - layoutOptions: { 'elk.algorithm': algoritm }, - children: blocks.map((b) => { - return { - id: b.id as string, - width: b.width, - height: b.height, - } - }), - edges: connections.map((c) => { - return { - id: c.id as string, - sources: [ c.sourceBlockId as string ], - targets: [ c.targetBlockId as string ] - } - }), - } - - elk.layout(graphDefinition) - .then((result) => { - console.log(result); - - const {children, edges} = result; - - const con = edges.map((edge) => { - const c = conMap.get(edge.id); - return { - ...c, - elk: edge, - } - }); - const layoutedBlocks = children.map((child) => { - const b = blocksMap.get(child.id); - - return { - ...b, - x: child.x, - y: child.y, - elk: child, - } - }); - - setEntities({ - blocks: layoutedBlocks, - connections: con, - }); - - graph.zoomTo("center", { padding: 300 }); - - }) - .catch(console.error) - + id: "root", + layoutOptions: { "elk.algorithm": algoritm, "elk.spacing.edgeNode": "500.0", "elk.spacing.nodeNode": "500.0" }, + children: blocks.map((b) => { + return { + id: b.id as string, + width: b.width, + height: b.height, + } satisfies ElkNode; + }), + edges: connections.map((c) => { + return { + id: c.id as string, + sources: [c.sourceBlockId as string], + targets: [c.targetBlockId as string], + } satisfies ElkExtendedEdge; + }), + }; + + elk + .layout(graphDefinition) + .then((result) => { + const { children, edges } = result; + + const con = edges.map((edge) => { + const c = conMap.get(edge.id); + return { + ...c, + elk: edge, + }; + }); + const layoutedBlocks = children.map((child) => { + const b = blocksMap.get(child.id); + + return { + ...b, + x: child.x, + y: child.y, + elk: child, + }; + }); + + setEntities({ + blocks: layoutedBlocks, + connections: con, + }); + + graph.zoomTo("center", { padding: 300 }); + }) + .catch(console.error); }, [algoritm, elk]); const [algorithms, setAlgortms] = useState([]); useEffect(() => { elk.knownLayoutAlgorithms().then((knownLayoutAlgorithms) => { - - setAlgortms(knownLayoutAlgorithms.map((knownLayoutAlgorithm) => { - const {id, name} = knownLayoutAlgorithm; - const algId = id.split('.').at(-1); - return {value: algId, content: name} - })); + setAlgortms( + knownLayoutAlgorithms.map((knownLayoutAlgorithm) => { + const { id, name } = knownLayoutAlgorithm; + const algId = id.split(".").at(-1); + return { value: algId, content: name }; + }) + ); }); }, [elk]); - + useGraphEvent(graph, "state-change", ({ state }) => { if (state === GraphState.ATTACHED) { start(); - } }); @@ -118,7 +113,7 @@ const GraphApp = () => { return ( - + ; ); diff --git a/src/stories/examples/elk/helpers.tsx b/src/stories/examples/elk/helpers.tsx deleted file mode 100644 index 9875ac0..0000000 --- a/src/stories/examples/elk/helpers.tsx +++ /dev/null @@ -1,94 +0,0 @@ -export function polyline(points: {x: number, y: number}[]) { - const path = new Path2D(); - let i = 0; - path.moveTo(points[0].x, points[0].y); - for (i = 1; i < points.length; i++) { - path.lineTo(points[i].x, points[i].y); - } - - return path; -} - -export function curve(points: {x: number, y: number}[], radius: number) { - const path = new Path2D(); - path.moveTo(points[0].x, points[0].y); - - if(points.length === 2) { - path.lineTo(points[1].x, points[1].y); - return path; - } - - for (let i = 1; i < points.length - 1; i++) { - const prevPoint = points[i - 1]; - const currPoint = points[i]; - const nextPoint = points[i + 1]; - - const vectorPrev = { - x: currPoint.x - prevPoint.x, - y: currPoint.y - prevPoint.y - }; - const vectorNext = { - x: nextPoint.x - currPoint.x, - y: nextPoint.y - currPoint.y - }; - - const lenPrev = Math.hypot(vectorPrev.x, vectorPrev.y); - const lenNext = Math.hypot(vectorNext.x, vectorNext.y); - - const unitVecPrev = { - x: vectorPrev.x / lenPrev, - y: vectorPrev.y / lenPrev - }; - const unitVecNext = { - x: vectorNext.x / lenNext, - y: vectorNext.y / lenNext - }; - - const startArcX = currPoint.x - unitVecPrev.x * radius; - const startArcY = currPoint.y - unitVecPrev.y * radius; - - const endArcX = currPoint.x + unitVecNext.x * radius; - const endArcY = currPoint.y + unitVecNext.y * radius; - - path.lineTo(startArcX, startArcY); - - path.arcTo(currPoint.x, currPoint.y, endArcX, endArcY, radius); - } - - // Последний сегмент линии - path.lineTo(points[points.length - 1].x, points[points.length - 1].y); - return path; -} - -export function getElkArrowCoords( - x1: number, - y1: number, - x2: number, - y2: number, - height = 8, - ) { - const angle = Math.PI / 4; - - const x = x2; - const y = y2; - const lineangle = Math.atan2(y2 - y1, x2 - x1); - - // h is the line length of a side of the arrow head - const h = Math.abs(height / Math.cos(angle)); - - const angle1 = lineangle + Math.PI + angle; - const topx = x + Math.cos(angle1) * h; - const topy = y + Math.sin(angle1) * h; - - const angle2 = lineangle + Math.PI - angle; - const botx = x + Math.cos(angle2) * h; - const boty = y + Math.sin(angle2) * h; - - const trianglePath = new Path2D(); - trianglePath.moveTo(topx, topy); // Вершина треугольника - trianglePath.lineTo(x, y); // Левая точка основания - trianglePath.lineTo(botx, boty); // Правая точка основания - trianglePath.closePath(); - - return trianglePath; - } \ No newline at end of file diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index 860a58f..de4ab5a 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -155,7 +155,7 @@ export function isWindows() { function isTrackpadDetector() { let isTrackpadDetected = false; - let cleanStateTimer = setTimeout(() => { }, 0); + let cleanStateTimer = setTimeout(() => {}, 0); return (e: WheelEvent) => { // deltaX in the trackpad scroll usually is not zero. diff --git a/src/utils/shapes/__snapshots__/curvePolyline.test.ts.snap b/src/utils/shapes/__snapshots__/curvePolyline.test.ts.snap new file mode 100644 index 0000000..b901cd4 --- /dev/null +++ b/src/utils/shapes/__snapshots__/curvePolyline.test.ts.snap @@ -0,0 +1,1111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`curve should create smooth arcs for multiple points 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 44.61182407322746, + "y": 89.22364814645492, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 58.51945418114494, + "cpy1": 100, + "cpy2": 91.48054581885506, + "radius": 12.048327646991336, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 91.48054581885506, + "y": 58.51945418114494, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 100, + "cpx2": 105.38817592677255, + "cpy1": 50, + "cpy2": 60.77635185354509, + "radius": 12.048327646991336, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 150, + "y": 150, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_path": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 44.61182407322746, + "y": 89.22364814645492, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 58.51945418114494, + "cpy1": 100, + "cpy2": 91.48054581885506, + "radius": 12.048327646991336, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 91.48054581885506, + "y": 58.51945418114494, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 100, + "cpx2": 105.38817592677255, + "cpy1": 50, + "cpy2": 60.77635185354509, + "radius": 12.048327646991336, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 150, + "y": 150, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction] { + "calls": [ + [ + 50, + 100, + 58.51945418114494, + 91.48054581885506, + 12.048327646991336, + ], + [ + 100, + 50, + 105.38817592677255, + 60.77635185354509, + 12.048327646991336, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 44.61182407322746, + 89.22364814645492, + ], + [ + 91.48054581885506, + 58.51945418114494, + ], + [ + 150, + 150, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; + +exports[`curve should handle a straight line with two points 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 100, + "y": 100, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_path": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 100, + "y": 100, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction], + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 100, + 100, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; + +exports[`curve should handle collinear points without arcs 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 50, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 50, + "cpy1": 0, + "cpy2": 0, + "radius": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 100, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_path": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 50, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 50, + "cpy1": 0, + "cpy2": 0, + "radius": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 100, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction] { + "calls": [ + [ + 50, + 0, + 50, + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 50, + 0, + ], + [ + 100, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; + +exports[`curve should handle sharp angles by reducing the radius 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 35, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 50, + "cpy1": 0, + "cpy2": 15, + "radius": 15, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 50, + "y": 35, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 65, + "cpy1": 50, + "cpy2": 50, + "radius": 15, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 100, + "y": 50, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_path": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 35, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 50, + "cpy1": 0, + "cpy2": 15, + "radius": 15, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 50, + "y": 35, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 50, + "cpx2": 65, + "cpy1": 50, + "cpy2": 50, + "radius": 15, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 100, + "y": 50, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction] { + "calls": [ + [ + 50, + 0, + 50, + 15, + 15, + ], + [ + 50, + 50, + 65, + 50, + 15, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 35, + 0, + ], + [ + 50, + 35, + ], + [ + 100, + 50, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; + +exports[`curve should limit radius to fit between points 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 5, + "y": 5, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 10, + "cpx2": 15, + "cpy1": 10, + "cpy2": 5, + "radius": 7.0710678118654755, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 20, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_path": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 5, + "y": 5, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "cpx1": 10, + "cpx2": 15, + "cpy1": 10, + "cpy2": 5, + "radius": 7.0710678118654755, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arcTo", + }, + { + "props": { + "x": 20, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction] { + "calls": [ + [ + 10, + 10, + 15, + 5, + 7.0710678118654755, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 5, + 5, + ], + [ + 20, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; diff --git a/src/utils/shapes/__snapshots__/polyline.test.ts.snap b/src/utils/shapes/__snapshots__/polyline.test.ts.snap new file mode 100644 index 0000000..7727979 --- /dev/null +++ b/src/utils/shapes/__snapshots__/polyline.test.ts.snap @@ -0,0 +1,183 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`polyline Should render polyline 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 10, + "y": 10, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "x": 20, + "y": 20, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_path": [ + { + "props": { + "x": 0, + "y": 0, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 10, + "y": 10, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "x": 20, + "y": 20, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction], + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 10, + 10, + ], + [ + 20, + 20, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 0, + 0, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; + +exports[`polyline should create an empty shape for empty points 1`] = ` +Path2D { + "_events": [], + "_path": [], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction], + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction], + "ellipse": [MockFunction], + "lineTo": [MockFunction], + "moveTo": [MockFunction], + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; diff --git a/src/utils/shapes/__snapshots__/triangle.test.ts.snap b/src/utils/shapes/__snapshots__/triangle.test.ts.snap new file mode 100644 index 0000000..62d520f --- /dev/null +++ b/src/utils/shapes/__snapshots__/triangle.test.ts.snap @@ -0,0 +1,377 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`trangleArrowForVector should handle a default height if not provided 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 10, + "y": 10, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 1.1611652351681558, + "y": 4.696699141100894, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "x": 4.696699141100893, + "y": 1.1611652351681567, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": {}, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "closePath", + }, + ], + "_path": [ + { + "props": { + "x": 10, + "y": 10, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 1.1611652351681558, + "y": 4.696699141100894, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "x": 4.696699141100893, + "y": 1.1611652351681567, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": {}, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "closePath", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction], + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction] { + "calls": [ + [], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 1.1611652351681558, + 4.696699141100894, + ], + [ + 4.696699141100893, + 1.1611652351681567, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 10, + 10, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; + +exports[`trangleArrowForVector should return a Path2D instance representing an arrow from start to end 1`] = ` +Path2D { + "_events": [ + { + "props": { + "x": 10, + "y": 10, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 4.696699141100893, + "y": 8.232233047033631, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "x": 8.232233047033631, + "y": 4.696699141100893, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": {}, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "closePath", + }, + ], + "_path": [ + { + "props": { + "x": 10, + "y": 10, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "moveTo", + }, + { + "props": { + "x": 4.696699141100893, + "y": 8.232233047033631, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": { + "x": 8.232233047033631, + "y": 4.696699141100893, + }, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "lineTo", + }, + { + "props": {}, + "transform": [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "closePath", + }, + ], + "_stackIndex": 0, + "_transformStack": [ + [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + ], + "addPath": [MockFunction], + "arc": [MockFunction], + "arcTo": [MockFunction], + "bezierCurveTo": [MockFunction], + "closePath": [MockFunction] { + "calls": [ + [], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "ellipse": [MockFunction], + "lineTo": [MockFunction] { + "calls": [ + [ + 4.696699141100893, + 8.232233047033631, + ], + [ + 8.232233047033631, + 4.696699141100893, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + "moveTo": [MockFunction] { + "calls": [ + [ + 10, + 10, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + "quadraticCurveTo": [MockFunction], + "rect": [MockFunction], +} +`; diff --git a/src/utils/shapes/curvePolyline.test.ts b/src/utils/shapes/curvePolyline.test.ts new file mode 100644 index 0000000..20a30ae --- /dev/null +++ b/src/utils/shapes/curvePolyline.test.ts @@ -0,0 +1,86 @@ +import { curvePolyline } from "./curvePolyline"; + +describe("curve", () => { + test("should create a Path2D object", () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + { x: 200, y: 0 }, + ]; + const radius = 20; + + const result = curvePolyline(points, radius); + expect(result).toBeInstanceOf(Path2D); + }); + + test("should handle a straight line with two points", () => { + const points = [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ]; + const radius = 20; + + const result = curvePolyline(points, radius); + expect(result).toBeInstanceOf(Path2D); + // Check: the curve should be a straight line + expect(result).toMatchSnapshot(); + }); + + test("should create smooth arcs for multiple points", () => { + const points = [ + { x: 0, y: 0 }, + { x: 50, y: 100 }, + { x: 100, y: 50 }, + { x: 150, y: 150 }, + ]; + const radius = 20; + + const result = curvePolyline(points, radius); + expect(result).toBeInstanceOf(Path2D); + // Verify that a valid Path2D object is created + expect(result).toMatchSnapshot(); + }); + + test("should limit radius to fit between points", () => { + const points = [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, // Very close points + { x: 20, y: 0 }, + ]; + const baseRadius = 20; + + const result = curvePolyline(points, baseRadius); + expect(result).toBeInstanceOf(Path2D); + // Ensure the function does not create artifacts + expect(result).toMatchSnapshot(); + }); + + test("should handle sharp angles by reducing the radius", () => { + const points = [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 50 }, // Sharp angle + { x: 100, y: 50 }, + ]; + const baseRadius = 30; + + const result = curvePolyline(points, baseRadius); + expect(result).toBeInstanceOf(Path2D); + // Check that the radius is reduced for the sharp angle + expect(result).toMatchSnapshot(); + }); + + test("should handle collinear points without arcs", () => { + const points = [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 100, y: 0 }, // Points are collinear + ]; + const radius = 20; + + const result = curvePolyline(points, radius); + expect(result).toBeInstanceOf(Path2D); + // Verify no arc is created for collinear points + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/utils/shapes/curvePolyline.ts b/src/utils/shapes/curvePolyline.ts new file mode 100644 index 0000000..5a188e8 --- /dev/null +++ b/src/utils/shapes/curvePolyline.ts @@ -0,0 +1,88 @@ +/** + * Generates a smooth curve through a series of points with adjustable corner rounding. + * + * This function creates a `Path2D` object representing a curved line passing through the provided points. + * The curve's corners are rounded based on the specified radius, which is dynamically adjusted + * to fit between points and scaled according to the angle between segments. + * + * @param {Array<{x: number, y: number}>} points - The array of points through which the curve will pass. + * Each point should be an object with `x` and `y` properties. + * + * @param {number} baseRadius - The base radius for rounding corners. The actual radius is adjusted + * dynamically to ensure it fits between points and scales proportionally to the angle between segments. + * + * @returns {Path2D} A `Path2D` object representing the smoothed curve. + * + * @example + * const points = [ + * { x: 0, y: 0 }, + * { x: 50, y: 100 }, + * { x: 100, y: 50 }, + * { x: 150, y: 150 }, + * ]; + * const radius = 20; + * const path = curvePolyline(points, radius); + * ctx.stroke(path); + * + * @remarks + * - If the angle between segments is close to 180°, the rounding will be minimal. + * - The radius is automatically reduced if it cannot fit between points. + * - For two points, the function creates a straight line instead of a curve. + */ +export function curvePolyline(points: { x: number; y: number }[], baseRadius: number): Path2D { + const path = new Path2D(); + path.moveTo(points[0].x, points[0].y); + + for (let i = 1; i < points.length - 1; i++) { + const prevPoint = points[i - 1]; + const currPoint = points[i]; + const nextPoint = points[i + 1]; + + const vectorPrev = { + x: currPoint.x - prevPoint.x, + y: currPoint.y - prevPoint.y, + }; + const vectorNext = { + x: nextPoint.x - currPoint.x, + y: nextPoint.y - currPoint.y, + }; + + const lenPrev = Math.hypot(vectorPrev.x, vectorPrev.y); + const lenNext = Math.hypot(vectorNext.x, vectorNext.y); + + const unitVecPrev = { + x: vectorPrev.x / lenPrev, + y: vectorPrev.y / lenPrev, + }; + const unitVecNext = { + x: vectorNext.x / lenNext, + y: vectorNext.y / lenNext, + }; + + // angle between points + const dotProduct = unitVecPrev.x * unitVecNext.x + unitVecPrev.y * unitVecNext.y; + const angle = Math.acos(Math.max(-1, Math.min(1, dotProduct))); + + // Adjust raduis to prevent this problem + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arcTo#result_of_a_large_radius + let adjustedRadius = Math.min(baseRadius, (angle / Math.PI) * baseRadius); + const maxRadius = Math.min(lenPrev / 2, lenNext / 2); + adjustedRadius = Math.min(adjustedRadius, maxRadius); + + // Координаты для начала и конца дуги + const startArcX = currPoint.x - unitVecPrev.x * adjustedRadius; + const startArcY = currPoint.y - unitVecPrev.y * adjustedRadius; + + const endArcX = currPoint.x + unitVecNext.x * adjustedRadius; + const endArcY = currPoint.y + unitVecNext.y * adjustedRadius; + + path.lineTo(startArcX, startArcY); + + if (angle < Math.PI - 0.01) { + path.arcTo(currPoint.x, currPoint.y, endArcX, endArcY, adjustedRadius); + } + } + + path.lineTo(points[points.length - 1].x, points[points.length - 1].y); + return path; +} diff --git a/src/utils/shapes/polyline.test.ts b/src/utils/shapes/polyline.test.ts new file mode 100644 index 0000000..2003100 --- /dev/null +++ b/src/utils/shapes/polyline.test.ts @@ -0,0 +1,16 @@ +import { polyline } from "./polyline"; + +describe("polyline", () => { + it("Should render polyline", () => { + const points = [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ]; + expect(polyline(points)).toMatchSnapshot(); + }); + + it("should create an empty shape for empty points", () => { + expect(polyline([])).toMatchSnapshot(); + }); +}); diff --git a/src/utils/shapes/polyline.tsx b/src/utils/shapes/polyline.tsx new file mode 100644 index 0000000..3d9ce51 --- /dev/null +++ b/src/utils/shapes/polyline.tsx @@ -0,0 +1,12 @@ +export function polyline(points: { x: number; y: number }[]) { + const path = new Path2D(); + if (!points.length) { + return path; + } + path.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) { + path.lineTo(points[i].x, points[i].y); + } + + return path; +} diff --git a/src/utils/shapes/triangle.test.ts b/src/utils/shapes/triangle.test.ts new file mode 100644 index 0000000..4db2b3c --- /dev/null +++ b/src/utils/shapes/triangle.test.ts @@ -0,0 +1,23 @@ +// Importing the function +import { trangleArrowForVector } from "./triangle"; + +describe("trangleArrowForVector", () => { + it("should return a Path2D instance representing an arrow from start to end", () => { + const start = { x: 0, y: 0 }; + const end = { x: 10, y: 10 }; + const height = 5; + + const trianglePath = trangleArrowForVector(start, end, height); + + expect(trianglePath).toMatchSnapshot(); + }); + + it("should handle a default height if not provided", () => { + const start = { x: 0, y: 0 }; + const end = { x: 10, y: 10 }; + + const trianglePath = trangleArrowForVector(start, end); + + expect(trianglePath).toMatchSnapshot(); + }); +}); diff --git a/src/utils/shapes/triangle.ts b/src/utils/shapes/triangle.ts new file mode 100644 index 0000000..9e3e97d --- /dev/null +++ b/src/utils/shapes/triangle.ts @@ -0,0 +1,55 @@ +import { clamp } from "../functions/clamp"; + +/** + * Creates a Path2D object that represents a triangle pointing towards the direction defined by + * two points, with its tip at a parameterized position along the line and a given height and base width. + * + * @param {Object} start - The starting point of the vector. + * @param {Object} end - The ending point of the vector. + * @param {number} [height=10] - The height of the triangle from the base to the tip. + * @param {number} [baseWidth=5] - The width of the triangle's base. + * @param {number} [t=1] - A parameter [0, 1] that determines where the tip of the triangle is placed along the line. 0 - start point, 1 - end point + * @returns {Path2D} The Path2D object representing the triangle. + */ +export function trangleArrowForVector( + start: { x: number; y: number }, + end: { x: number; y: number }, + height = 10, + baseWidth = 5, + t = 1 +): Path2D { + const normalizedT = clamp(t, 0, 1); + // Calculate the tip of the triangle on the line between start and end + const tipx = (1 - normalizedT) * start.x + normalizedT * end.x; + const tipy = (1 - normalizedT) * start.y + normalizedT * end.y; + + // Calculate the angle direction from start to end + const lineAngle = Math.atan2(end.y - start.y, end.x - start.x); + + // Positions for the midpoint of the base, offset backward by the height from the tip + const baseMidX = tipx - Math.cos(lineAngle) * height; + const baseMidY = tipy - Math.sin(lineAngle) * height; + + // Half of the base width + const baseHalfWidth = baseWidth / 2; + + // Calculate angles perpendicular to the line to formulate the end points of the base + const leftAngle = lineAngle + Math.PI / 2; + const rightAngle = lineAngle - Math.PI / 2; + + // Position of the left base point + const leftx = baseMidX + Math.cos(leftAngle) * baseHalfWidth; + const lefty = baseMidY + Math.sin(leftAngle) * baseHalfWidth; + + // Position of the right base point + const rightx = baseMidX + Math.cos(rightAngle) * baseHalfWidth; + const righty = baseMidY + Math.sin(rightAngle) * baseHalfWidth; + + const trianglePath = new Path2D(); + trianglePath.moveTo(tipx, tipy); // Tip of the triangle + trianglePath.lineTo(leftx, lefty); // Left point of the base + trianglePath.lineTo(rightx, righty); // Right point of the base + trianglePath.closePath(); + + return trianglePath; +}