From 9b929c37dc08381829fa6f0f88c46815d43a6b61 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Thu, 26 Dec 2024 13:17:43 +0300 Subject: [PATCH] feat: elk converter --- .../connections/MultipointConnection.ts | 94 ++++++++++++++++ src/react-component/hooks/useElk.ts | 40 +++++++ src/store/connection/ConnectionState.ts | 14 +++ src/stories/examples/elk/ELKConnection.ts | 82 -------------- .../examples/elk/calculateTextDimensions.ts | 10 ++ src/stories/examples/elk/elk.stories.tsx | 104 ++++++------------ src/stories/examples/elk/getElkConfig.ts | 35 ++++++ src/utils/converters/eklConverter.ts | 30 +++++ src/utils/types/types.ts | 9 ++ 9 files changed, 268 insertions(+), 150 deletions(-) create mode 100644 src/components/canvas/connections/MultipointConnection.ts create mode 100644 src/react-component/hooks/useElk.ts delete mode 100644 src/stories/examples/elk/ELKConnection.ts create mode 100644 src/stories/examples/elk/calculateTextDimensions.ts create mode 100644 src/stories/examples/elk/getElkConfig.ts create mode 100644 src/utils/converters/eklConverter.ts diff --git a/src/components/canvas/connections/MultipointConnection.ts b/src/components/canvas/connections/MultipointConnection.ts new file mode 100644 index 0000000..5fd3ef2 --- /dev/null +++ b/src/components/canvas/connections/MultipointConnection.ts @@ -0,0 +1,94 @@ +import { Path2DRenderStyleResult } from "../../../components/canvas/connections/BatchPath2D"; +import { BlockConnection } from "../../../components/canvas/connections/BlockConnection"; +import { TMultipointConnection } from "../../../store/connection/ConnectionState"; +import { curvePolyline } from "../../../utils/shapes/curvePolyline"; +import { trangleArrowForVector } from "../../../utils/shapes/triangle"; + +const DEFAULT_FONT_SIZE = 14; + +export class MultipointConnection extends BlockConnection { + public createPath() { + const { points } = this.connectedState.$state.value; + if (!points.length) { + return super.createPath(); + } + return curvePolyline(points, 10); + } + + public createArrowPath(): Path2D { + const { points } = this.connectedState.$state.value; + if (!points.length) { + return undefined; + } + + const [start, end] = points.slice(points.length - 2); + return trangleArrowForVector(start, end, 16, 10); + } + + 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" }; + } + + public getPoints() { + return this.connectedState.$state.value.points || []; + } + + public afterRender?(ctx: CanvasRenderingContext2D): void { + this.renderLabelsText(ctx); + } + + public updatePoints(): void { + super.updatePoints(); + return; + } + + public getBBox() { + const { points } = this.connectedState.$state.value; + if (!points.length) { + return super.getBBox(); + } + + const x = []; + const y = []; + points.forEach((point) => { + x.push(point.x); + y.push(point.y); + }); + + return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)] as const; + } + + private renderLabelsText(ctx: CanvasRenderingContext2D) { + const { labels } = this.connectedState.$state.value; + if (!labels || !labels.length) { + return; + } + + labels.forEach(({ x, y, text, height }) => { + if ([x, y, text].some((i) => i === undefined)) { + return; + } + ctx.fillStyle = this.context.colors.connectionLabel.text; + + if (this.state.hovered) ctx.fillStyle = this.context.colors.connectionLabel.hoverText; + if (this.state.selected) ctx.fillStyle = this.context.colors.connectionLabel.selectedText; + + ctx.textBaseline = "top"; + ctx.textAlign = "center"; + ctx.font = `${height || DEFAULT_FONT_SIZE}px sans-serif`; + ctx.fillText(text, x, y); + + ctx.fillStyle = this.context.colors.connectionLabel.background; + + if (this.state.hovered) ctx.fillStyle = this.context.colors.connectionLabel.hoverBackground; + if (this.state.selected) ctx.fillStyle = this.context.colors.connectionLabel.selectedBackground; + }); + + return; + } +} diff --git a/src/react-component/hooks/useElk.ts b/src/react-component/hooks/useElk.ts new file mode 100644 index 0000000..a26de7c --- /dev/null +++ b/src/react-component/hooks/useElk.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import ELK, { ElkLayoutArguments, ElkNode } from "elkjs"; + +import { elkConverter } from "../../utils/converters/eklConverter"; +import { ConverterResult } from "../../utils/types/types"; + +export const useElk = (config: ElkNode, args?: ElkLayoutArguments) => { + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const elk = useMemo(() => new ELK(), []); + + const layout = useCallback(() => { + return elk.layout(config, args); + }, [elk, config, args]); + + useEffect(() => { + let isCancelled = false; + + layout() + .then((data) => { + if (isCancelled) return; + setResult(elkConverter(data)); + setIsLoading(false); + }) + .catch((error) => { + if (!isCancelled) { + console.error("Error during ELK layout:", error); + setIsLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [layout]); + + return { result, isLoading, elk }; +}; diff --git a/src/store/connection/ConnectionState.ts b/src/store/connection/ConnectionState.ts index 698c411..3bd3b25 100644 --- a/src/store/connection/ConnectionState.ts +++ b/src/store/connection/ConnectionState.ts @@ -1,6 +1,7 @@ import { computed, signal } from "@preact/signals-core"; import { TConnectionColors } from "../../graphConfig"; +import { TPoint } from "../../utils/types/shapes"; import { ESelectionStrategy } from "../../utils/types/types"; import { TBlockId } from "../block/Block"; @@ -24,6 +25,19 @@ export type TConnection = { selected?: boolean; }; +export type TLabel = { + height?: number; + width?: number; + x?: number; + y?: number; + text?: string; +}; + +export type TMultipointConnection = TConnection & { + points?: TPoint[]; + labels?: TLabel[]; +}; + export class ConnectionState { public $state = signal(undefined); diff --git a/src/stories/examples/elk/ELKConnection.ts b/src/stories/examples/elk/ELKConnection.ts deleted file mode 100644 index f928322..0000000 --- a/src/stories/examples/elk/ELKConnection.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 { curvePolyline } from "../../../utils/shapes/curvePolyline"; -import { trangleArrowForVector } from "../../../utils/shapes/triangle"; - -export type TElkTConnection = TConnection & { - elk: ElkExtendedEdge; -}; - -export class ELKConnection extends BlockConnection { - protected points: { x: number; y: number }[] = []; - - public createPath() { - const elk = this.connectedState.$state.value.elk; - if (!elk.sections || !this.points?.length) { - return super.createPath(); - } - return curvePolyline(this.points, 10); - } - - 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 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" }; - } - - public getPoints() { - return this.points || []; - } - - public afterRender?(_: CanvasRenderingContext2D): void { - // do not render label; - 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]; - - 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; - } -} diff --git a/src/stories/examples/elk/calculateTextDimensions.ts b/src/stories/examples/elk/calculateTextDimensions.ts new file mode 100644 index 0000000..5ce6f2e --- /dev/null +++ b/src/stories/examples/elk/calculateTextDimensions.ts @@ -0,0 +1,10 @@ +export const calculateTextDimensions = (text: string, fontSize: number, fontFamily: string) => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + context.font = `${fontSize}px ${fontFamily}`; + const metrics = context.measureText(text); + return { + width: metrics.width, + height: fontSize, + }; +}; diff --git a/src/stories/examples/elk/elk.stories.tsx b/src/stories/examples/elk/elk.stories.tsx index 03dcea2..81aa328 100644 --- a/src/stories/examples/elk/elk.stories.tsx +++ b/src/stories/examples/elk/elk.stories.tsx @@ -2,96 +2,64 @@ import React, { useEffect, useMemo, useState } from "react"; import { Select, SelectOption, ThemeProvider } from "@gravity-ui/uikit"; import type { Meta, StoryFn } from "@storybook/react"; -import ELK, { ElkExtendedEdge, ElkNode } from "elkjs"; +import { MultipointConnection } from "../../../components/canvas/connections/MultipointConnection"; import { Graph, GraphCanvas, GraphState, TBlock, useGraph, useGraphEvent } from "../../../index"; +import { useElk } from "../../../react-component/hooks/useElk"; import { useFn } from "../../../utils/hooks/useFn"; import { generatePrettyBlocks } from "../../configurations/generatePretty"; import { BlockStory } from "../../main/Block"; -import { ELKConnection } from "./ELKConnection"; +import { getElkConfig } from "./getElkConfig"; import "@gravity-ui/uikit/styles/styles.css"; -export type TElkBlock = TBlock & { - elk: ElkNode; -}; - const config = generatePrettyBlocks(10, 30, true); const GraphApp = () => { + const [algorithm, setAlgorithm] = useState("layered"); const { graph, setEntities, start } = useGraph({ settings: { - connection: ELKConnection, + connection: MultipointConnection, }, }); - const elk = useMemo(() => new ELK({}), []); + const elkConfig = useMemo(() => { + return getElkConfig(config, algorithm); + }, [algorithm]); - const [algoritm, setAlgortm] = useState("layered"); + const { isLoading, elk, result } = useElk(elkConfig); 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, "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([]); + if (isLoading || !result) return; + + const connections = config.connections.map((connection) => { + return { + ...connection, + ...result.edges[connection.id], + }; + }); + + const blocks = config.blocks.map((block) => { + return { + ...block, + ...result.blocks[block.id], + }; + }); + + setEntities({ + blocks, + connections, + }); + + graph.zoomTo("center", { padding: 300 }); + }, [isLoading, result]); + + const [algorithms, setAlgorithms] = useState([]); useEffect(() => { elk.knownLayoutAlgorithms().then((knownLayoutAlgorithms) => { - setAlgortms( + setAlgorithms( knownLayoutAlgorithms.map((knownLayoutAlgorithm) => { const { id, name } = knownLayoutAlgorithm; const algId = id.split(".").at(-1); @@ -113,7 +81,7 @@ const GraphApp = () => { return ( - + ; ); diff --git a/src/stories/examples/elk/getElkConfig.ts b/src/stories/examples/elk/getElkConfig.ts new file mode 100644 index 0000000..eea19ab --- /dev/null +++ b/src/stories/examples/elk/getElkConfig.ts @@ -0,0 +1,35 @@ +import { ElkExtendedEdge, ElkNode } from "elkjs"; + +import { TGraphConfig } from "../../../graph"; + +import { calculateTextDimensions } from "./calculateTextDimensions"; + +export const getElkConfig = ({ blocks, connections }: TGraphConfig, algorithm: string) => ({ + id: "root", + layoutOptions: { + "elk.algorithm": algorithm, + "elk.spacing.edgeNode": "500.0", + "elk.spacing.nodeNode": "500.0", + + "elk.nodeLabels.placement": "OUTSIDE", + "elk.edgeLabels.placement": "CENTER", + }, + children: blocks.map((b) => { + return { + id: b.id as string, + width: b.width, + height: b.height, + } satisfies ElkNode; + }), + edges: connections.map((c, i) => { + const labelText = `label ${i}`; + const dimensions = calculateTextDimensions(labelText, 14, "sans-serif"); + + return { + id: c.id as string, + sources: [c.sourceBlockId as string], + targets: [c.targetBlockId as string], + labels: [{ text: labelText, ...dimensions }], + } satisfies ElkExtendedEdge; + }), +}); diff --git a/src/utils/converters/eklConverter.ts b/src/utils/converters/eklConverter.ts new file mode 100644 index 0000000..00e4678 --- /dev/null +++ b/src/utils/converters/eklConverter.ts @@ -0,0 +1,30 @@ +import { ElkExtendedEdge, ElkNode } from "elkjs"; + +import { ConverterResult } from "../types/types"; + +const convertElkEdges = (edges?: ElkExtendedEdge[]): ConverterResult["edges"] => { + return edges.reduce((acc, edge) => { + acc[edge.id] = { + points: [edge.sections[0].startPoint, ...(edge.sections[0].bendPoints || []), edge.sections[0].endPoint], + labels: edge.labels, + }; + return acc; + }, {}); +}; + +const convertElkChildren = (childrens: ElkNode[]): ConverterResult["blocks"] => { + return childrens.reduce((acc, children) => { + acc[children.id] = { + x: children.x, + y: children.y, + }; + return acc; + }, {}); +}; + +export const elkConverter = (node: ElkNode): ConverterResult => { + return { + edges: convertElkEdges(node.edges), + blocks: convertElkChildren(node.children), + }; +}; diff --git a/src/utils/types/types.ts b/src/utils/types/types.ts index b774c1d..c479bea 100644 --- a/src/utils/types/types.ts +++ b/src/utils/types/types.ts @@ -1,4 +1,13 @@ +import { TConnectionId, TMultipointConnection } from "../../store/connection/ConnectionState"; + +import { TPoint } from "./shapes"; + export enum ESelectionStrategy { REPLACE, APPEND, } + +export type ConverterResult = { + edges: Record>; + blocks: Record; +};