From f7a44890924d3af380d74c523b203e51e6bf683c Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 17 Mar 2026 21:44:08 +0300 Subject: [PATCH 1/2] ... --- e2e/tests/related-entities.spec.ts | 152 +++++++++++++++++ .../GraphComponent/GraphComponent.test.ts | 4 + .../canvas/GraphComponent/index.tsx | 4 + src/components/canvas/anchors/index.ts | 4 + src/components/canvas/blocks/Block.ts | 15 ++ .../canvas/connections/BaseConnection.ts | 9 + src/components/canvas/groups/Group.ts | 4 + src/graph.ts | 9 + src/index.ts | 2 + src/utils/graph/getRelatedEntitiesByPorts.ts | 160 ++++++++++++++++++ 10 files changed, 363 insertions(+) create mode 100644 e2e/tests/related-entities.spec.ts create mode 100644 src/utils/graph/getRelatedEntitiesByPorts.ts diff --git a/e2e/tests/related-entities.spec.ts b/e2e/tests/related-entities.spec.ts new file mode 100644 index 00000000..d68d2f2a --- /dev/null +++ b/e2e/tests/related-entities.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from "@playwright/test"; + +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +const BLOCKS = [ + { + id: "block-a", + is: "Block" as const, + x: 100, + y: 100, + width: 180, + height: 90, + name: "Block A", + anchors: [], + selected: false, + }, + { + id: "block-b", + is: "Block" as const, + x: 380, + y: 100, + width: 180, + height: 90, + name: "Block B", + anchors: [], + selected: false, + }, + { + id: "block-c", + is: "Block" as const, + x: 660, + y: 100, + width: 180, + height: 90, + name: "Block C", + anchors: [], + selected: false, + }, +]; + +const CONNECTIONS = [ + { + id: "connection-a-b", + sourceBlockId: "block-a", + targetBlockId: "block-b", + }, + { + id: "connection-b-c", + sourceBlockId: "block-b", + targetBlockId: "block-c", + }, +]; + +async function getRelatedBlocksByDepth( + graphPO: GraphPageObject, + sourceBlockId: string, + depth: number, +): Promise> { + return graphPO.page.evaluate( + ({ sourceId, depthLevel }) => { + const { GraphComponent } = window.GraphModule; + const graphComponents = window.graph.getElementsOverRect( + { + x: -100000, + y: -100000, + width: 200000, + height: 200000, + }, + [GraphComponent], + ) as Array<{ + getEntityType(): string; + getEntityId(): string | number; + }>; + + const source = graphComponents.find((component) => { + return component.getEntityType() === "block" && component.getEntityId() === sourceId; + }); + + if (!source) { + throw new Error(`Source block component not found: ${sourceId}`); + } + + const related = window.graph.getRelatedEntitiesByPorts(source, { depth: depthLevel }) as Record< + string, + Array + >; + + return (related.block ?? []).slice().sort(); + }, + { sourceId: sourceBlockId, depthLevel: depth }, + ); +} + +test.describe("Related entities by ports", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ + blocks: BLOCKS, + connections: CONNECTIONS, + }); + + await graphPO.waitForFrames(5); + }); + + test("depth=1 returns only directly connected blocks", async () => { + const related = await getRelatedBlocksByDepth(graphPO, "block-a", 1); + + expect(related).toEqual(["block-b"]); + }); + + test("depth=2 traverses through connection to next block", async () => { + const related = await getRelatedBlocksByDepth(graphPO, "block-a", 2); + + expect(related).toEqual(["block-b", "block-c"]); + }); + + test("connection type is transit-only and not included in result", async () => { + const related = await graphPO.page.evaluate(() => { + const { GraphComponent } = window.GraphModule; + const graphComponents = window.graph.getElementsOverRect( + { + x: -100000, + y: -100000, + width: 200000, + height: 200000, + }, + [GraphComponent], + ) as Array<{ + getEntityType(): string; + getEntityId(): string | number; + }>; + + const source = graphComponents.find((component) => { + return component.getEntityType() === "block" && component.getEntityId() === "block-a"; + }); + + if (!source) { + throw new Error("Source block component not found: block-a"); + } + + return window.graph.getRelatedEntitiesByPorts(source, { depth: 2 }) as Record< + string, + Array + >; + }); + + expect(related.connection).toBeUndefined(); + expect(related.block?.slice().sort()).toEqual(["block-b", "block-c"]); + }); +}); diff --git a/src/components/canvas/GraphComponent/GraphComponent.test.ts b/src/components/canvas/GraphComponent/GraphComponent.test.ts index e22f183c..772457cb 100644 --- a/src/components/canvas/GraphComponent/GraphComponent.test.ts +++ b/src/components/canvas/GraphComponent/GraphComponent.test.ts @@ -10,6 +10,10 @@ class TestGraphComponent extends GraphComponent { return "test-id"; } + public getEntityType(): string { + return "test"; + } + public subscribeGraphEvent( eventName: EventName, handler: GraphEventsDefinitions[EventName], diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index c33921bc..8761c3c4 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -38,6 +38,10 @@ export class GraphComponent< throw new Error("GraphComponent.getEntityId() is not implemented"); } + public getEntityType(): string { + throw new Error("GraphComponent.getEntityType() is not implemented"); + } + /** * Returns whether this component can be dragged. * Override in subclasses to enable drag behavior. diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index d7b1bb27..8787b70c 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -39,6 +39,10 @@ export class Anchor extends GraphComponen return this.props.id; } + public getEntityType(): string { + return "anchor"; + } + public get zIndex() { // @ts-ignore this.__comp.parent instanceOf Block return this.__comp.parent.zIndex + 1; diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 9f5026ca..046abbf6 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -136,6 +136,10 @@ export class Block this.getAnchorPort(anchor.id)), + ...super.getPorts(), + ]; + + return Array.from(new Set(ports)); + } + /** * Check if block can be dragged based on canDrag setting */ diff --git a/src/components/canvas/connections/BaseConnection.ts b/src/components/canvas/connections/BaseConnection.ts index aa728e7c..e2489a77 100644 --- a/src/components/canvas/connections/BaseConnection.ts +++ b/src/components/canvas/connections/BaseConnection.ts @@ -1,6 +1,7 @@ import { Component, ESchedulerPriority } from "../../../lib"; import { TComponentState } from "../../../lib/Component"; import { ConnectionState, TConnection, TConnectionId } from "../../../store/connection/ConnectionState"; +import { PortState } from "../../../store/connection/port/Port"; import { selectConnectionById } from "../../../store/connection/selectors"; import { debounce } from "../../../utils/functions"; import { TPoint } from "../../../utils/types/shapes"; @@ -145,6 +146,14 @@ export class BaseConnection< return this.props.id; } + public getEntityType(): string { + return "connection"; + } + + public override getPorts(): PortState[] { + return [this.connectedState.$sourcePortState.value, this.connectedState.$targetPortState.value]; + } + protected willMount(): void { // Subscribe to connection state changes for automatic updates this.subscribeSignal(this.connectedState.$selected, (selected) => { diff --git a/src/components/canvas/groups/Group.ts b/src/components/canvas/groups/Group.ts index 2a28c19d..76eda483 100644 --- a/src/components/canvas/groups/Group.ts +++ b/src/components/canvas/groups/Group.ts @@ -170,6 +170,10 @@ export class Group extends GraphComponent[]; } + public getRelatedEntitiesByPorts( + component: GraphComponent, + options?: TRelatedEntitiesOptions + ): TRelatedEntitiesByType { + return getRelatedEntitiesByPorts(this, component, options); + } + public getPointInCameraSpace(event: MouseEvent) { const xy = getXY(this.graphLayer.getCanvas(), event); diff --git a/src/index.ts b/src/index.ts index d94979f0..4854820e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,8 @@ export { type TPoint, type TRect } from "./utils/types/shapes"; export { ESelectionStrategy } from "./services/selection/types"; export * from "./utils/shapes"; export { applyAlpha, clearColorCache } from "./utils/functions/color"; +export { getRelatedEntitiesByPorts } from "./utils/graph/getRelatedEntitiesByPorts"; +export type { TRelatedEntitiesByType, TRelatedEntitiesOptions } from "./utils/graph/getRelatedEntitiesByPorts"; export * from "./components/canvas/groups"; diff --git a/src/utils/graph/getRelatedEntitiesByPorts.ts b/src/utils/graph/getRelatedEntitiesByPorts.ts new file mode 100644 index 00000000..b478cbe6 --- /dev/null +++ b/src/utils/graph/getRelatedEntitiesByPorts.ts @@ -0,0 +1,160 @@ +import { GraphComponent } from "../../components/canvas/GraphComponent"; +import type { Graph } from "../../graph"; + +export type TRelatedEntitiesByType = Record>; + +export type TRelatedEntitiesOptions = { + depth?: number; +}; + +type TRelatedEntitiesMap = Map>; +type TObserverWithViewComponent = { + getViewComponent: () => unknown; +}; + +function normalizeDepth(depth?: number): number { + if (!Number.isFinite(depth) || depth === undefined) { + return 1; + } + + return Math.max(1, Math.floor(depth)); +} + +function getEntityKey(component: GraphComponent): string { + return `${component.getEntityType()}:${String(component.getEntityId())}`; +} + +function resolveObserverComponent(observer: unknown): GraphComponent | undefined { + if (observer instanceof GraphComponent) { + return observer; + } + + if ( + observer && + typeof observer === "object" && + "getViewComponent" in observer && + typeof (observer as TObserverWithViewComponent).getViewComponent === "function" + ) { + const viewComponent = (observer as TObserverWithViewComponent).getViewComponent(); + if (viewComponent instanceof GraphComponent) { + return viewComponent; + } + } + + return undefined; +} + +function collectImmediateRelatedEntities(start: GraphComponent, sourceKey: string): GraphComponent[] { + const queue: GraphComponent[] = [start]; + const visited = new Set([getEntityKey(start)]); + const related: GraphComponent[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + const ports = current.getPorts(); + for (const port of ports) { + if (port.owner instanceof GraphComponent) { + const ownerKey = getEntityKey(port.owner); + if (!visited.has(ownerKey)) { + visited.add(ownerKey); + + if (port.owner.getEntityType() === "connection") { + queue.push(port.owner); + } else if (ownerKey !== sourceKey) { + related.push(port.owner); + } + } + } + + for (const observer of port.observers) { + const observerComponent = resolveObserverComponent(observer); + if (!observerComponent) { + continue; + } + + const observerKey = getEntityKey(observerComponent); + if (visited.has(observerKey)) { + continue; + } + + visited.add(observerKey); + + if (observerComponent.getEntityType() === "connection") { + queue.push(observerComponent); + continue; + } + + if (observerKey !== sourceKey) { + related.push(observerComponent); + } + } + } + } + + return related; +} + +function addToResult(map: TRelatedEntitiesMap, component: GraphComponent): void { + const type = component.getEntityType(); + const id = component.getEntityId(); + + if (!map.has(type)) { + map.set(type, new Set()); + } + + map.get(type)?.add(id); +} + +function toResult(map: TRelatedEntitiesMap): TRelatedEntitiesByType { + const result: TRelatedEntitiesByType = {}; + + for (const [type, ids] of map.entries()) { + result[type] = Array.from(ids); + } + + return result; +} + +export function getRelatedEntitiesByPorts( + _graph: Graph, + component: GraphComponent, + options?: TRelatedEntitiesOptions +): TRelatedEntitiesByType { + const depth = normalizeDepth(options?.depth); + const sourceKey = getEntityKey(component); + + const resultMap: TRelatedEntitiesMap = new Map(); + const expanded = new Set([sourceKey]); + + let frontier: GraphComponent[] = [component]; + + for (let level = 0; level < depth; level += 1) { + if (frontier.length === 0) { + break; + } + + const nextFrontier: GraphComponent[] = []; + + for (const current of frontier) { + const directRelated = collectImmediateRelatedEntities(current, sourceKey); + + for (const relatedComponent of directRelated) { + addToResult(resultMap, relatedComponent); + + const relatedKey = getEntityKey(relatedComponent); + if (!expanded.has(relatedKey)) { + expanded.add(relatedKey); + nextFrontier.push(relatedComponent); + } + } + } + + frontier = nextFrontier; + } + + return toResult(resultMap); +} From 9899bb828eea9d5f320202daa1e101447d0e2973 Mon Sep 17 00:00:00 2001 From: draedful Date: Tue, 17 Mar 2026 23:22:50 +0300 Subject: [PATCH 2/2] ... --- e2e/tests/highlight-service.spec.ts | 151 +++++++++++++ e2e/tests/related-entities.spec.ts | 75 ++++--- .../GraphComponent/GraphComponent.test.ts | 6 + .../canvas/GraphComponent/index.tsx | 51 +++++ src/components/canvas/anchors/index.ts | 12 +- src/components/canvas/blocks/Block.ts | 21 +- .../canvas/connections/BlockConnection.ts | 28 ++- .../connections/MultipointConnection.ts | 24 ++- src/components/canvas/groups/Group.ts | 20 +- src/graph.ts | 17 ++ src/graphEvents.ts | 2 + src/index.ts | 1 + src/react-components/Block.css | 17 ++ src/react-components/Block.tsx | 43 +++- .../highlight/HighlightService.test.ts | 94 ++++++++ src/services/highlight/HighlightService.ts | 143 ++++++++++++ src/services/highlight/index.ts | 2 + src/services/highlight/types.ts | 29 +++ src/store/index.ts | 5 + .../highlightService.stories.tsx | 204 ++++++++++++++++++ src/utils/graph/getRelatedEntitiesByPorts.ts | 104 ++++----- 21 files changed, 929 insertions(+), 120 deletions(-) create mode 100644 e2e/tests/highlight-service.spec.ts create mode 100644 src/services/highlight/HighlightService.test.ts create mode 100644 src/services/highlight/HighlightService.ts create mode 100644 src/services/highlight/index.ts create mode 100644 src/services/highlight/types.ts create mode 100644 src/stories/api/highlightService/highlightService.stories.tsx diff --git a/e2e/tests/highlight-service.spec.ts b/e2e/tests/highlight-service.spec.ts new file mode 100644 index 00000000..049d9feb --- /dev/null +++ b/e2e/tests/highlight-service.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; + +import { GraphPageObject } from "../page-objects/GraphPageObject"; + +const BLOCKS = [ + { + id: "block-a", + is: "Block" as const, + x: 100, + y: 100, + width: 180, + height: 90, + name: "Block A", + anchors: [], + selected: false, + }, + { + id: "block-b", + is: "Block" as const, + x: 380, + y: 100, + width: 180, + height: 90, + name: "Block B", + anchors: [], + selected: false, + }, +]; + +const CONNECTIONS = [ + { + id: "connection-a-b", + sourceBlockId: "block-a", + targetBlockId: "block-b", + }, +]; + +type THighlightModesByEntity = Record; + +async function getModes(graphPO: GraphPageObject): Promise { + return graphPO.page.evaluate(() => { + const { GraphComponent } = window.GraphModule; + const components = window.graph.getElementsOverRect( + { + x: -100000, + y: -100000, + width: 200000, + height: 200000, + }, + [GraphComponent], + ) as Array<{ + getEntityType(): string; + getEntityId(): string | number; + getHighlightVisualMode(): number | undefined; + }>; + + const result: THighlightModesByEntity = {}; + for (const component of components) { + result[`${component.getEntityType()}:${String(component.getEntityId())}`] = component.getHighlightVisualMode(); + } + + return result; + }); +} + +test.describe("HighlightService API", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ + blocks: BLOCKS, + connections: CONNECTIONS, + }); + await graphPO.waitForFrames(4); + }); + + test("highlight() highlights only targets", async () => { + await graphPO.page.evaluate(() => { + window.graph.highlight({ + block: ["block-a"], + }); + }); + await graphPO.waitForFrames(2); + + const modes = await getModes(graphPO); + + expect(modes["block:block-a"]).toBe(20); + expect(modes["block:block-b"]).toBeUndefined(); + expect(modes["connection:connection-a-b"]).toBeUndefined(); + }); + + test("focus() lowlights non-target entities", async () => { + await graphPO.page.evaluate(() => { + window.graph.focus({ + block: ["block-a"], + }); + }); + await graphPO.waitForFrames(2); + + const modes = await getModes(graphPO); + + expect(modes["block:block-a"]).toBe(20); + expect(modes["block:block-b"]).toBe(10); + expect(modes["connection:connection-a-b"]).toBe(10); + }); + + test("clearHighlight() resets all highlight modes", async () => { + await graphPO.page.evaluate(() => { + window.graph.focus({ + block: ["block-a"], + }); + window.graph.clearHighlight(); + }); + await graphPO.waitForFrames(2); + + const modes = await getModes(graphPO); + + expect(modes["block:block-a"]).toBeUndefined(); + expect(modes["block:block-b"]).toBeUndefined(); + expect(modes["connection:connection-a-b"]).toBeUndefined(); + }); + + test("emits highlight-changed for highlight, focus and clear", async () => { + const events = await graphPO.page.evaluate(() => { + const collected: Array<{ mode?: string; previousMode?: string }> = []; + + const handler = (event: CustomEvent<{ mode?: string; previous?: { mode?: string } }>) => { + collected.push({ + mode: event.detail.mode, + previousMode: event.detail.previous?.mode, + }); + }; + + window.graph.on("highlight-changed", handler); + + window.graph.highlight({ block: ["block-a"] }); + window.graph.focus({ block: ["block-b"] }); + window.graph.clearHighlight(); + + window.graph.off("highlight-changed", handler); + + return collected; + }); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ mode: "highlight", previousMode: undefined }); + expect(events[1]).toEqual({ mode: "focus", previousMode: "highlight" }); + expect(events[2]).toEqual({ mode: undefined, previousMode: "focus" }); + }); +}); diff --git a/e2e/tests/related-entities.spec.ts b/e2e/tests/related-entities.spec.ts index d68d2f2a..97424a04 100644 --- a/e2e/tests/related-entities.spec.ts +++ b/e2e/tests/related-entities.spec.ts @@ -91,6 +91,44 @@ async function getRelatedBlocksByDepth( ); } +async function getRelatedByDepth( + graphPO: GraphPageObject, + sourceBlockId: string, + depth: number, +): Promise>> { + return graphPO.page.evaluate( + ({ sourceId, depthLevel }) => { + const { GraphComponent } = window.GraphModule; + const graphComponents = window.graph.getElementsOverRect( + { + x: -100000, + y: -100000, + width: 200000, + height: 200000, + }, + [GraphComponent], + ) as Array<{ + getEntityType(): string; + getEntityId(): string | number; + }>; + + const source = graphComponents.find((component) => { + return component.getEntityType() === "block" && component.getEntityId() === sourceId; + }); + + if (!source) { + throw new Error(`Source block component not found: ${sourceId}`); + } + + return window.graph.getRelatedEntitiesByPorts(source, { depth: depthLevel }) as Record< + string, + Array + >; + }, + { sourceId: sourceBlockId, depthLevel: depth }, + ); +} + test.describe("Related entities by ports", () => { let graphPO: GraphPageObject; @@ -116,37 +154,14 @@ test.describe("Related entities by ports", () => { expect(related).toEqual(["block-b", "block-c"]); }); - test("connection type is transit-only and not included in result", async () => { - const related = await graphPO.page.evaluate(() => { - const { GraphComponent } = window.GraphModule; - const graphComponents = window.graph.getElementsOverRect( - { - x: -100000, - y: -100000, - width: 200000, - height: 200000, - }, - [GraphComponent], - ) as Array<{ - getEntityType(): string; - getEntityId(): string | number; - }>; - - const source = graphComponents.find((component) => { - return component.getEntityType() === "block" && component.getEntityId() === "block-a"; - }); + test("connections are included but do not increment depth", async () => { + const depth1 = await getRelatedByDepth(graphPO, "block-a", 1); + const depth2 = await getRelatedByDepth(graphPO, "block-a", 2); - if (!source) { - throw new Error("Source block component not found: block-a"); - } - - return window.graph.getRelatedEntitiesByPorts(source, { depth: 2 }) as Record< - string, - Array - >; - }); + expect(depth1.block?.slice().sort()).toEqual(["block-b"]); + expect(depth1.connection?.slice().sort()).toEqual(["connection-a-b"]); - expect(related.connection).toBeUndefined(); - expect(related.block?.slice().sort()).toEqual(["block-b", "block-c"]); + expect(depth2.block?.slice().sort()).toEqual(["block-b", "block-c"]); + expect(depth2.connection?.slice().sort()).toEqual(["connection-a-b", "connection-b-c"]); }); }); diff --git a/src/components/canvas/GraphComponent/GraphComponent.test.ts b/src/components/canvas/GraphComponent/GraphComponent.test.ts index 772457cb..bd8e77a7 100644 --- a/src/components/canvas/GraphComponent/GraphComponent.test.ts +++ b/src/components/canvas/GraphComponent/GraphComponent.test.ts @@ -46,6 +46,12 @@ function createTestComponent(root?: HTMLDivElement): TestSetup { const hitTestRemove = jest.fn(); const fakeGraph = { on: graphOn, + rootStore: { + highlightService: { + registerComponent: jest.fn(), + unregisterComponent: jest.fn(), + }, + }, hitTest: { remove: hitTestRemove, update: jest.fn(), diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 8761c3c4..78352e5a 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -6,6 +6,7 @@ import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; import { DragContext, DragDiff } from "../../../services/drag"; +import { HighlightVisualMode } from "../../../services/highlight"; import { PortState, TPort, TPortId } from "../../../store/connection/port/Port"; import { applyAlpha, getXY } from "../../../utils/functions"; import { EventedComponent } from "../EventedComponent/EventedComponent"; @@ -28,8 +29,13 @@ export class GraphComponent< State extends TComponentState = TComponentState, Context extends GraphComponentContext = GraphComponentContext, > extends EventedComponent { + public static LOWLIGHT_ALPHA = 0.35; + public static HIGHLIGHT_BORDER_COLOR = "rgba(140, 82, 255, 1)"; + public hitBox: HitBox; + private highlightMode: HighlightVisualMode | undefined; + private unsubscribe: (() => void)[] = []; protected ports: Map = new Map(); @@ -42,6 +48,49 @@ export class GraphComponent< throw new Error("GraphComponent.getEntityType() is not implemented"); } + public getHighlightVisualMode(): HighlightVisualMode | undefined { + return this.highlightMode; + } + + public setHighlight(mode: HighlightVisualMode | undefined, value = true): boolean { + const nextMode = value ? mode : undefined; + // Set via setState + if (this.highlightMode === nextMode) { + return false; + } + + this.highlightMode = nextMode; + if (this.isMounted()) { + this.performRender(); + } + return true; + } + + protected getHighlightAlpha(): number { + return this.getHighlightVisualMode() === HighlightVisualMode.Lowlight ? GraphComponent.LOWLIGHT_ALPHA : 1; + } + + protected getHighlightAwareColor(color: string | undefined): string | undefined { + if (!color) { + return color; + } + + const alpha = this.getHighlightAlpha(); + if (alpha >= 1) { + return color; + } + + return this.adoptColor(color, { alpha }); + } + + protected getHighlightBorderColor(color: string | undefined): string | undefined { + if (this.getHighlightVisualMode() === HighlightVisualMode.Highlight) { + return GraphComponent.HIGHLIGHT_BORDER_COLOR; + } + + return this.getHighlightAwareColor(color); + } + /** * Returns whether this component can be dragged. * Override in subclasses to enable drag behavior. @@ -98,6 +147,7 @@ export class GraphComponent< const affectsUsableRect = props.affectsUsableRect ?? this.context.affectsUsableRect ?? true; this.setProps({ affectsUsableRect }); this.setContext({ affectsUsableRect }); + this.context.graph.rootStore.highlightService.registerComponent(this); } /* Adopt color to the component alpha */ @@ -325,6 +375,7 @@ export class GraphComponent< } protected unmount() { + this.context.graph.rootStore.highlightService.unregisterComponent(this); super.unmount(); this.unsubscribe.forEach((cb) => cb()); this.ports.forEach((port) => { diff --git a/src/components/canvas/anchors/index.ts b/src/components/canvas/anchors/index.ts index 8787b70c..ba88cddf 100644 --- a/src/components/canvas/anchors/index.ts +++ b/src/components/canvas/anchors/index.ts @@ -1,5 +1,6 @@ import { ECameraScaleLevel } from "../../../services/camera/CameraService"; import { DragContext, DragDiff } from "../../../services/drag"; +import { HighlightVisualMode } from "../../../services/highlight"; import { AnchorState, EAnchorType } from "../../../store/anchor/Anchor"; import { TBlockId } from "../../../store/block/Block"; import { selectBlockAnchor } from "../../../store/block/selectors"; @@ -213,14 +214,17 @@ export class Anchor extends GraphComponen } const { x, y } = this.getPosition(); const ctx = this.context.ctx; - ctx.fillStyle = this.context.colors.anchor.background; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.anchor.background); ctx.beginPath(); ctx.arc(x, y, this.state.size * 0.5, 0, 2 * Math.PI); ctx.fill(); - if (this.state.selected) { - ctx.strokeStyle = this.context.colors.anchor.selectedBorder; - ctx.lineWidth = this.props.lineWidth + 3; + const isHighlightMode = this.getHighlightVisualMode() === HighlightVisualMode.Highlight; + if (this.state.selected || isHighlightMode) { + ctx.strokeStyle = isHighlightMode + ? this.getHighlightBorderColor(this.context.colors.anchor.selectedBorder) + : this.getHighlightAwareColor(this.context.colors.anchor.selectedBorder); + ctx.lineWidth = this.props.lineWidth + (isHighlightMode ? 4 : 3); ctx.stroke(); } ctx.closePath(); diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 046abbf6..f991a1b0 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -5,6 +5,7 @@ import isObject from "lodash/isObject"; import { Component } from "../../../lib/Component"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; import { DragContext, DragDiff } from "../../../services/drag"; +import { HighlightVisualMode } from "../../../services/highlight"; import { ESelectionStrategy } from "../../../services/selection"; import { TGraphSettingsConfig } from "../../../store"; import { EAnchorType } from "../../../store/anchor/Anchor"; @@ -483,13 +484,15 @@ export class Block return this.generatePath(); } + public override setHighlight(mode: HighlightVisualMode | undefined, value?: boolean): boolean { + const changed = super.setHighlight(mode, value); + if (changed) { + this.applyShape(); + } + return changed; + } + /** * Creates the Path2D object for the arrow in the middle of the connection. * This is used by the ConnectionArrow component to render the arrow. @@ -116,7 +125,7 @@ export class BlockConnection ctx.lineWidth = this.state.hovered || this.state.selected ? 4 : 2; const strokeColor = this.getStrokeColor(this.state); if (strokeColor) { - ctx.strokeStyle = strokeColor; + ctx.strokeStyle = this.getHighlightBorderColor(strokeColor); } return { type: "stroke" }; } @@ -156,7 +165,8 @@ export class BlockConnection const selected = state.selected ? "selected" : "none"; const stroke = this.getStrokeColor(state); const dash = state.dashed ? (state.styles?.dashes || [6, 4]).join(",") : ""; - return `connection/${hovered}/${selected}/${stroke}/${dash}`; + const highlightMode = this.getHighlightVisualMode() ?? "none"; + return `connection/${hovered}/${selected}/${stroke}/${dash}/${highlightMode}`; } public style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined { @@ -170,7 +180,7 @@ export class BlockConnection const strokeColor = this.getStrokeColor(state); if (strokeColor) { - ctx.strokeStyle = strokeColor; + ctx.strokeStyle = this.getHighlightBorderColor(strokeColor); } if (withDashed && state.dashed) { @@ -299,15 +309,15 @@ export class BlockConnection ); if (this.context.colors.connectionLabel?.background) { - ctx.fillStyle = this.context.colors.connectionLabel.background; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.background); } if (this.state.hovered && this.context.colors.connectionLabel?.hoverBackground) { - ctx.fillStyle = this.context.colors.connectionLabel.hoverBackground; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.hoverBackground); } if (this.state.selected && this.context.colors.connectionLabel?.selectedBackground) { - ctx.fillStyle = this.context.colors.connectionLabel.selectedBackground; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.selectedBackground); } const rectX = x; @@ -325,15 +335,15 @@ export class BlockConnection ctx.fillRect(rectX, rectY, rectWidth, rectHeight); if (this.context.colors.connectionLabel?.text) { - ctx.fillStyle = this.context.colors.connectionLabel.text; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.text); } if (this.state.hovered && this.context.colors.connectionLabel?.hoverText) { - ctx.fillStyle = this.context.colors.connectionLabel.hoverText; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.hoverText); } if (this.state.selected && this.context.colors.connectionLabel?.selectedText) { - ctx.fillStyle = this.context.colors.connectionLabel.selectedText; + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.selectedText); } ctx.textBaseline = "top"; diff --git a/src/components/canvas/connections/MultipointConnection.ts b/src/components/canvas/connections/MultipointConnection.ts index 32db670a..cc2fb207 100644 --- a/src/components/canvas/connections/MultipointConnection.ts +++ b/src/components/canvas/connections/MultipointConnection.ts @@ -33,9 +33,11 @@ export class MultipointConnection extends BlockConnection } public styleArrow(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult { - ctx.fillStyle = this.state.selected - ? this.context.colors.connection.selectedBackground - : this.context.colors.connection.background; + ctx.fillStyle = this.getHighlightBorderColor( + 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" }; @@ -120,19 +122,23 @@ export class MultipointConnection extends BlockConnection height, }); - ctx.fillStyle = this.context.colors.connectionLabel.text; + ctx.fillStyle = this.getHighlightAwareColor(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; + if (this.state.hovered) + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.hoverText); + if (this.state.selected) + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.selectedText); ctx.textAlign = "left"; ctx.font = `${DEFAULT_FONT_SIZE}px sans-serif`; ctx.fillText(text, x, y, width); - ctx.fillStyle = this.context.colors.connectionLabel.background; + ctx.fillStyle = this.getHighlightAwareColor(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; + if (this.state.hovered) + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.hoverBackground); + if (this.state.selected) + ctx.fillStyle = this.getHighlightAwareColor(this.context.colors.connectionLabel.selectedBackground); ctx.fillRect( x - labelInnerLeftPadding, diff --git a/src/components/canvas/groups/Group.ts b/src/components/canvas/groups/Group.ts index 76eda483..594b0312 100644 --- a/src/components/canvas/groups/Group.ts +++ b/src/components/canvas/groups/Group.ts @@ -1,5 +1,6 @@ import { TComponentState } from "../../../lib/Component"; import { DragContext, DragDiff } from "../../../services/drag"; +import { HighlightVisualMode } from "../../../services/highlight"; import { ESelectionStrategy } from "../../../services/selection/types"; import { BlockState } from "../../../store/block/Block"; import { GroupState, TGroup, TGroupId } from "../../../store/group/Group"; @@ -257,16 +258,21 @@ export class Group extends GraphComponent selected > default if (this.highlighted) { - ctx.strokeStyle = this.style.highlightedBorder; - ctx.fillStyle = this.style.highlightedBackground; + ctx.strokeStyle = this.getHighlightAwareColor(this.style.highlightedBorder); + ctx.fillStyle = this.getHighlightAwareColor(this.style.highlightedBackground); } else if (this.state.selected) { - ctx.strokeStyle = this.style.selectedBorder; - ctx.fillStyle = this.style.selectedBackground; + ctx.strokeStyle = this.getHighlightAwareColor(this.style.selectedBorder); + ctx.fillStyle = this.getHighlightAwareColor(this.style.selectedBackground); } else { - ctx.strokeStyle = this.style.border; - ctx.fillStyle = this.style.background; + ctx.strokeStyle = this.getHighlightAwareColor(this.style.border); + ctx.fillStyle = this.getHighlightAwareColor(this.style.background); + } + const isHighlightedMode = this.getHighlightVisualMode() === HighlightVisualMode.Highlight; + ctx.lineWidth = this.highlighted || isHighlightedMode ? this.style.borderWidth + 1 : this.style.borderWidth; + + if (isHighlightedMode) { + ctx.strokeStyle = this.getHighlightBorderColor(ctx.strokeStyle as string); } - ctx.lineWidth = this.highlighted ? this.style.borderWidth + 1 : this.style.borderWidth; // Draw group rectangle ctx.beginPath(); diff --git a/src/graph.ts b/src/graph.ts index 46d71542..5d94679e 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -17,6 +17,7 @@ import { Layer, LayerPublicProps } from "./services/Layer"; import { Layers } from "./services/LayersService"; import { CameraService } from "./services/camera/CameraService"; import { DragService } from "./services/drag"; +import { THighlightSelection } from "./services/highlight"; import { RootStore } from "./store"; import { TBlockId } from "./store/block/Block"; import { TConnection } from "./store/connection/ConnectionState"; @@ -122,6 +123,10 @@ export class Graph { return this.rootStore.selectionService; } + public get highlightService() { + return this.rootStore.highlightService; + } + constructor( config: TGraphConfig, rootEl?: HTMLDivElement, @@ -282,6 +287,18 @@ export class Graph { return getRelatedEntitiesByPorts(this, component, options); } + public highlight(selection: THighlightSelection): void { + this.rootStore.highlightService.highlight(selection); + } + + public focus(selection: THighlightSelection): void { + this.rootStore.highlightService.focus(selection); + } + + public clearHighlight(): void { + this.rootStore.highlightService.clearHighlight(); + } + public getPointInCameraSpace(event: MouseEvent) { const xy = getXY(this.graphLayer.getCanvas(), event); diff --git a/src/graphEvents.ts b/src/graphEvents.ts index c68b641f..58a54336 100644 --- a/src/graphEvents.ts +++ b/src/graphEvents.ts @@ -2,6 +2,7 @@ import { EventedComponent } from "./components/canvas/EventedComponent/EventedCo import { GraphState } from "./graph"; import { TGraphColors, TGraphConstants } from "./graphConfig"; import { TCameraState } from "./services/camera/CameraService"; +import { THighlightChangedEvent } from "./services/highlight"; import { TSelectionDiff, TSelectionEntityId } from "./services/selection/types"; export type GraphMouseEvent = CustomEvent<{ @@ -42,6 +43,7 @@ export interface GraphEventsDefinitions extends BaseGraphEventDefinition { "constants-changed": (event: CustomEvent<{ constants: TGraphConstants }>) => void; "colors-changed": (event: CustomEvent<{ colors: TGraphColors }>) => void; "state-change": (event: CustomEvent<{ state: GraphState }>) => void; + "highlight-changed": (event: CustomEvent) => void; } const graphMouseEvents = ["mousedown", "click", "dblclick", "mouseenter", "mousemove", "mouseleave"]; diff --git a/src/index.ts b/src/index.ts index 4854820e..42d8afd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export * from "./utils/renderers/text"; export { EVENTS } from "./utils/types/events"; export { type TPoint, type TRect } from "./utils/types/shapes"; export { ESelectionStrategy } from "./services/selection/types"; +export { HighlightVisualMode, type THighlightSelection, type THighlightServiceMode } from "./services/highlight"; export * from "./utils/shapes"; export { applyAlpha, clearColorCache } from "./utils/functions/color"; export { getRelatedEntitiesByPorts } from "./utils/graph/getRelatedEntitiesByPorts"; diff --git a/src/react-components/Block.css b/src/react-components/Block.css index eacb7ce4..9e2a42ac 100644 --- a/src/react-components/Block.css +++ b/src/react-components/Block.css @@ -41,3 +41,20 @@ .graph-block-wrapper.selected:hover { border-color: var(--graph-block-border-selected); } + +.graph-block-wrapper.graph-block-wrapper-highlighted { + border-color: rgba(140, 82, 255, 1); + box-shadow: inset 0 0 0 2px rgba(140, 82, 255, 0.95); +} + +.graph-block-wrapper.graph-block-wrapper-mode-highlight { + border-color: rgba(140, 82, 255, 1); +} + +.graph-block-wrapper.graph-block-wrapper-mode-focus { + border-color: rgba(140, 82, 255, 1); +} + +.graph-block-wrapper.graph-block-wrapper-lowlight { + opacity: 0.35; +} diff --git a/src/react-components/Block.tsx b/src/react-components/Block.tsx index b3a5b5ba..a3e1d3e6 100644 --- a/src/react-components/Block.tsx +++ b/src/react-components/Block.tsx @@ -3,6 +3,7 @@ import React, { ForwardedRef, forwardRef, useEffect, useImperativeHandle, useMem import { TBlock } from "../components/canvas/blocks/Block"; import { Graph } from "../graph"; import { ESchedulerPriority } from "../lib/Scheduler"; +import { HighlightVisualMode, THighlightServiceMode } from "../services/highlight"; import { useComputedSignal, useSchedulerDebounce, useSignalEffect } from "./hooks"; import { useBlockState } from "./hooks/useBlockState"; @@ -101,6 +102,10 @@ function GraphBlockInner( const viewState = useComputedSignal(() => state?.$viewComponent.value, [state]); const [interactive, setInteractive] = useState(viewState?.isInteractive() ?? false); + const [highlightMode, setHighlightMode] = useState( + viewState?.getHighlightVisualMode() + ); + const [serviceTargetMode, setServiceTargetMode] = useState(undefined); /** * By reason of scheduler, canvas layer get the camera state before react will initialize the block @@ -166,20 +171,52 @@ function GraphBlockInner( * So to handle update this props we use onChange callback from view state. */ useEffect(() => { - if (!viewState) return; + if (!viewState) { + return undefined; + } setInteractive(viewState.isInteractive()); return viewState.onChange(() => { setInteractive(viewState.isInteractive()); }); }, [viewState]); + useEffect(() => { + // Read via component.onChange callback + if (!viewState) { + setHighlightMode(undefined); + setServiceTargetMode(undefined); + return undefined; + } + + const updateHighlightMode = (): void => { + setHighlightMode(viewState.getHighlightVisualMode()); + + const state = graph.highlightService.$state.value; + const selectedBlocks = state.selection.block ?? []; + const isTargeted = selectedBlocks.includes(block.id); + + setServiceTargetMode(isTargeted ? state.mode : undefined); + }; + + updateHighlightMode(); + + return graph.on("highlight-changed", () => { + updateHighlightMode(); + }); + }, [block.id, graph, viewState]); + const containerClassNames = useMemo(() => { return cn("graph-block-container", containerClassName, !interactive ? "graph-block-container-non-interactive" : ""); }, [containerClassName, interactive]); const wrapperClassNames = useMemo(() => { - return cn("graph-block-wrapper", className, state?.$selected.value ? "selected" : ""); - }, [className, state?.$selected.value]); + return cn("graph-block-wrapper", className, state?.$selected.value ? "selected" : "", { + "graph-block-wrapper-highlighted": highlightMode === HighlightVisualMode.Highlight, + "graph-block-wrapper-lowlight": highlightMode === HighlightVisualMode.Lowlight, + "graph-block-wrapper-mode-highlight": serviceTargetMode === "highlight", + "graph-block-wrapper-mode-focus": serviceTargetMode === "focus", + }); + }, [className, highlightMode, serviceTargetMode, state?.$selected.value]); if (!viewState || !state) { return null; diff --git a/src/services/highlight/HighlightService.test.ts b/src/services/highlight/HighlightService.test.ts new file mode 100644 index 00000000..3c3531e4 --- /dev/null +++ b/src/services/highlight/HighlightService.test.ts @@ -0,0 +1,94 @@ +import type { Graph } from "../../graph"; + +import { HighlightService } from "./HighlightService"; +import { HighlightVisualMode } from "./types"; + +describe("HighlightService", () => { + function createService() { + const executеDefaultEventAction = jest.fn( + (_eventName: TEventName, _detail: TPayload, defaultCb: () => void) => { + defaultCb(); + } + ); + + const graph = { + executеDefaultEventAction, + } as unknown as Graph; + + return { + service: new HighlightService(graph), + executеDefaultEventAction, + }; + } + + it("highlight mode highlights only target entities", () => { + const { service } = createService(); + + service.highlight({ + block: ["a"], + }); + + expect(service.getEntityHighlightMode("block", "a")).toBe(HighlightVisualMode.Highlight); + expect(service.getEntityHighlightMode("block", "b")).toBeUndefined(); + expect(service.getEntityHighlightMode("connection", "x")).toBeUndefined(); + }); + + it("focus mode lowlights non-target entities", () => { + const { service } = createService(); + + service.focus({ + block: ["a"], + }); + + expect(service.getEntityHighlightMode("block", "a")).toBe(HighlightVisualMode.Highlight); + expect(service.getEntityHighlightMode("block", "b")).toBe(HighlightVisualMode.Lowlight); + expect(service.getEntityHighlightMode("connection", "x")).toBe(HighlightVisualMode.Lowlight); + }); + + it("clearHighlight resets state and mode", () => { + const { service } = createService(); + + service.focus({ + block: ["a"], + }); + service.clearHighlight(); + + expect(service.getEntityHighlightMode("block", "a")).toBeUndefined(); + expect(service.$state.value.active).toBe(false); + expect(service.$state.value.mode).toBeUndefined(); + }); + + it("emits highlight-changed through graph default action API", () => { + const { service, executеDefaultEventAction } = createService(); + + service.highlight({ block: ["a"] }); + service.focus({ block: ["b"] }); + service.clearHighlight(); + + expect(executеDefaultEventAction).toHaveBeenCalledTimes(3); + expect(executеDefaultEventAction.mock.calls[0]?.[0]).toBe("highlight-changed"); + expect(executеDefaultEventAction.mock.calls[1]?.[0]).toBe("highlight-changed"); + expect(executеDefaultEventAction.mock.calls[2]?.[0]).toBe("highlight-changed"); + }); + + it("updates registered component via setHighlight without signal subscription", () => { + const { service } = createService(); + const setHighlight = jest.fn(() => true); + + const component = { + getEntityType: () => "block", + getEntityId: () => "a", + getHighlightVisualMode: () => undefined, + setHighlight, + }; + + service.registerComponent(component); + service.focus({ block: ["a"] }); + service.clearHighlight(); + service.unregisterComponent(component); + + expect(setHighlight).toHaveBeenCalledWith(undefined); + expect(setHighlight).toHaveBeenCalledWith(HighlightVisualMode.Highlight); + expect(setHighlight).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/src/services/highlight/HighlightService.ts b/src/services/highlight/HighlightService.ts new file mode 100644 index 00000000..bd7fd50a --- /dev/null +++ b/src/services/highlight/HighlightService.ts @@ -0,0 +1,143 @@ +import { signal } from "@preact/signals-core"; + +import type { Graph } from "../../graph"; +import type { TSelectionEntityId } from "../selection/types"; + +import { + HighlightVisualMode, + THighlightChangedEvent, + THighlightSelection, + THighlightServiceMode, + THighlightServiceState, + THighlightableEntity, +} from "./types"; + +function cloneSelection(selection: THighlightSelection): THighlightSelection { + const result: THighlightSelection = {}; + + for (const [entityType, ids] of Object.entries(selection)) { + result[entityType] = Array.from(new Set(ids)); + } + + return result; +} + +export class HighlightService { + public $state = signal({ + active: false, + mode: undefined, + selection: {}, + }); + + private components = new Set(); + + constructor(private graph: Graph) {} + + public registerComponent(component: THighlightableEntity): void { + this.components.add(component); + const mode = this.resolveModeForState(component.getEntityType(), component.getEntityId(), this.$state.value); + component.setHighlight(mode); + } + + public unregisterComponent(component: THighlightableEntity): void { + this.components.delete(component); + } + + public highlight(selection: THighlightSelection): void { + this.applyMode("highlight", selection); + } + + public focus(selection: THighlightSelection): void { + this.applyMode("focus", selection); + } + + public clearHighlight(): void { + const previous = this.$state.value; + const next: THighlightServiceState = { + active: false, + mode: undefined, + selection: {}, + }; + const payload: THighlightChangedEvent = { + mode: undefined, + selection: {}, + previous, + }; + + this.graph.executеDefaultEventAction("highlight-changed", payload, () => { + this.$state.value = next; + this.applyDiff(previous, next); + }); + } + + public getEntityHighlightMode(entityType: string, entityId: TSelectionEntityId): HighlightVisualMode | undefined { + return this.resolveModeForState(entityType, entityId, this.$state.value); + } + + public reset(): void { + const previous = this.$state.value; + const next: THighlightServiceState = { + active: false, + mode: undefined, + selection: {}, + }; + this.$state.value = next; + this.applyDiff(previous, next); + } + + private applyMode(mode: THighlightServiceMode, selection: THighlightSelection): void { + const normalizedSelection = cloneSelection(selection); + const previous = this.$state.value; + const next: THighlightServiceState = { + active: true, + mode, + selection: normalizedSelection, + }; + + const payload: THighlightChangedEvent = { + mode, + selection: normalizedSelection, + previous, + }; + + this.graph.executеDefaultEventAction("highlight-changed", payload, () => { + this.$state.value = next; + this.applyDiff(previous, next); + }); + } + + private applyDiff(previous: THighlightServiceState, next: THighlightServiceState): void { + for (const component of this.components) { + const entityType = component.getEntityType(); + const entityId = component.getEntityId(); + + const prevMode = this.resolveModeForState(entityType, entityId, previous); + const nextMode = this.resolveModeForState(entityType, entityId, next); + + if (prevMode !== nextMode) { + component.setHighlight(nextMode); + } + } + } + + private resolveModeForState( + entityType: string, + entityId: TSelectionEntityId, + state: THighlightServiceState + ): HighlightVisualMode | undefined { + if (!state.active || !state.mode) { + return undefined; + } + + const isTargeted = Boolean(state.selection[entityType]?.includes(entityId)); + if (isTargeted) { + return HighlightVisualMode.Highlight; + } + + if (state.mode === "focus") { + return HighlightVisualMode.Lowlight; + } + + return undefined; + } +} diff --git a/src/services/highlight/index.ts b/src/services/highlight/index.ts new file mode 100644 index 00000000..07fed071 --- /dev/null +++ b/src/services/highlight/index.ts @@ -0,0 +1,2 @@ +export * from "./HighlightService"; +export * from "./types"; diff --git a/src/services/highlight/types.ts b/src/services/highlight/types.ts new file mode 100644 index 00000000..eb4f3a69 --- /dev/null +++ b/src/services/highlight/types.ts @@ -0,0 +1,29 @@ +import type { TSelectionEntityId } from "../selection/types"; + +export enum HighlightVisualMode { + Lowlight = 10, + Highlight = 20, +} + +export type THighlightServiceMode = "highlight" | "focus"; + +export type THighlightSelection = Record; + +export type THighlightServiceState = { + active: boolean; + mode: THighlightServiceMode | undefined; + selection: THighlightSelection; +}; + +export type THighlightChangedEvent = { + mode: THighlightServiceMode | undefined; + selection: THighlightSelection; + previous: THighlightServiceState; +}; + +export type THighlightableEntity = { + getEntityType(): string; + getEntityId(): TSelectionEntityId; + getHighlightVisualMode(): HighlightVisualMode | undefined; + setHighlight(mode: HighlightVisualMode | undefined, value?: boolean): boolean; +}; diff --git a/src/store/index.ts b/src/store/index.ts index 03422330..24a26a83 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,6 +2,7 @@ import { batch } from "@preact/signals-core"; import cloneDeep from "lodash/cloneDeep"; import { Graph, TGraphConfig } from "../graph"; +import { HighlightService } from "../services/highlight"; import { SelectionService } from "../services/selection/SelectionService"; import { BlockListStore } from "./block/BlocksList"; @@ -22,8 +23,11 @@ export class RootStore { public selectionService: SelectionService; + public highlightService: HighlightService; + constructor(graph: Graph) { this.selectionService = new SelectionService(); + this.highlightService = new HighlightService(graph); this.blocksList = new BlockListStore(this, graph); this.connectionsList = new ConnectionsStore(this, graph); this.settings = new GraphEditorSettings(this); @@ -45,6 +49,7 @@ export class RootStore { this.connectionsList.reset(); this.settings.reset(); this.groupsList.reset(); + this.highlightService.reset(); }); } } diff --git a/src/stories/api/highlightService/highlightService.stories.tsx b/src/stories/api/highlightService/highlightService.stories.tsx new file mode 100644 index 00000000..75e4d00d --- /dev/null +++ b/src/stories/api/highlightService/highlightService.stories.tsx @@ -0,0 +1,204 @@ +import React, { useMemo, useState } from "react"; + +import { Button, Flex, Text, TextInput, ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryFn } from "@storybook/react-webpack5"; + +import { Graph, GraphComponent, GraphState, TBlock, THighlightSelection } from "../../../index"; +import { GraphCanvas, useGraph, useGraphEvent } from "../../../react-components"; +import { useFn } from "../../../react-components/utils/hooks/useFn"; +import { generatePrettyBlocks } from "../../configurations/generatePretty"; +import { BlockStory } from "../../main/Block"; + +import "@gravity-ui/uikit/styles/styles.css"; + +const prettyConfig = generatePrettyBlocks({ + layersCount: 7, + connectionsPerLayer: 12, + dashedLine: true, +}); + +const blocks = (prettyConfig.blocks ?? []) as TBlock[]; +const connections = prettyConfig.connections ?? []; +const blockList = blocks.slice(0, 40); + +const HighlightServiceStoryApp = () => { + const { graph, setEntities, start } = useGraph({}); + const [currentMode, setCurrentMode] = useState("none"); + const [depth, setDepth] = useState("1"); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + setEntities({ blocks, connections }); + start(); + graph.zoomTo("center", { padding: 180 }); + } + }); + + useGraphEvent(graph, "highlight-changed", ({ mode }) => { + setCurrentMode(mode ?? "none"); + }); + + useGraphEvent(graph, "click", ({ target }) => { + if (!(target instanceof GraphComponent)) { + return; + } + + const parsedDepth = Math.max(1, Number.parseInt(depth, 10) || 1); + const relatedSelection = graph.getRelatedEntitiesByPorts(target, { depth: parsedDepth }); + + const sourceType = target.getEntityType(); + const sourceId = target.getEntityId(); + const sourceIds = relatedSelection[sourceType] || []; + + const selection: THighlightSelection = { + ...relatedSelection, + [sourceType]: Array.from(new Set([...sourceIds, sourceId])), + }; + console.log("selection", selection); + + graph.focus(selection); + }); + + const renderBlockFn = useFn((graphObject: Graph, block: TBlock) => { + return ; + }); + + const actions = useMemo( + () => [ + { + title: "clearHighlight()", + run: () => graph.clearHighlight(), + }, + ], + [graph] + ); + + return ( + +
+
+ +
+
+
+ HighlightService live controls + Current mode: {currentMode} + + Click graph entity to run highlight (target-only accent). +
+
+ Focus by block list hover + Showing first {blockList.length} blocks from generated layout. +
+ {blockList.map((block) => { + return ( + + ); + })} +
+ + {actions.map((item) => { + return ( + + ); + })} + +
+
+
+
+ ); +}; + +const meta: Meta = { + title: "Api/highlightService", + component: HighlightServiceStoryApp, +}; + +export default meta; + +export const Default: StoryFn = () => ; diff --git a/src/utils/graph/getRelatedEntitiesByPorts.ts b/src/utils/graph/getRelatedEntitiesByPorts.ts index b478cbe6..21f28e77 100644 --- a/src/utils/graph/getRelatedEntitiesByPorts.ts +++ b/src/utils/graph/getRelatedEntitiesByPorts.ts @@ -44,58 +44,53 @@ function resolveObserverComponent(observer: unknown): GraphComponent | undefined return undefined; } -function collectImmediateRelatedEntities(start: GraphComponent, sourceKey: string): GraphComponent[] { - const queue: GraphComponent[] = [start]; - const visited = new Set([getEntityKey(start)]); - const related: GraphComponent[] = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current) { - continue; +function collectPortLinkedComponents(component: GraphComponent): GraphComponent[] { + const linkedComponents: GraphComponent[] = []; + const visited = new Set(); + + for (const port of component.getPorts()) { + if (port.owner instanceof GraphComponent) { + const ownerKey = getEntityKey(port.owner); + if (!visited.has(ownerKey)) { + visited.add(ownerKey); + linkedComponents.push(port.owner); + } } - const ports = current.getPorts(); - for (const port of ports) { - if (port.owner instanceof GraphComponent) { - const ownerKey = getEntityKey(port.owner); - if (!visited.has(ownerKey)) { - visited.add(ownerKey); - - if (port.owner.getEntityType() === "connection") { - queue.push(port.owner); - } else if (ownerKey !== sourceKey) { - related.push(port.owner); - } - } + for (const observer of port.observers) { + const observerComponent = resolveObserverComponent(observer); + if (!observerComponent) { + continue; } - for (const observer of port.observers) { - const observerComponent = resolveObserverComponent(observer); - if (!observerComponent) { - continue; - } - - const observerKey = getEntityKey(observerComponent); - if (visited.has(observerKey)) { - continue; - } - + const observerKey = getEntityKey(observerComponent); + if (!visited.has(observerKey)) { visited.add(observerKey); + linkedComponents.push(observerComponent); + } + } + } - if (observerComponent.getEntityType() === "connection") { - queue.push(observerComponent); - continue; - } + return linkedComponents; +} - if (observerKey !== sourceKey) { - related.push(observerComponent); - } - } +function getConnectionsByEntity(component: GraphComponent): GraphComponent[] { + const linkedComponents = collectPortLinkedComponents(component); + const connections = linkedComponents.filter((linked) => linked.getEntityType() === "connection"); + + if (component.getEntityType() === "connection") { + const componentKey = getEntityKey(component); + const alreadyIncluded = connections.some((item) => getEntityKey(item) === componentKey); + if (!alreadyIncluded) { + connections.push(component); } } - return related; + return connections; +} + +function getEntitiesByConnection(connection: GraphComponent): GraphComponent[] { + return collectPortLinkedComponents(connection).filter((linked) => linked.getEntityType() !== "connection"); } function addToResult(map: TRelatedEntitiesMap, component: GraphComponent): void { @@ -140,15 +135,26 @@ export function getRelatedEntitiesByPorts( const nextFrontier: GraphComponent[] = []; for (const current of frontier) { - const directRelated = collectImmediateRelatedEntities(current, sourceKey); + const connections = getConnectionsByEntity(current); - for (const relatedComponent of directRelated) { - addToResult(resultMap, relatedComponent); + for (const connection of connections) { + const connectionKey = getEntityKey(connection); + if (connectionKey !== sourceKey) { + addToResult(resultMap, connection); + } + + const boundEntities = getEntitiesByConnection(connection); + for (const entity of boundEntities) { + const entityKey = getEntityKey(entity); - const relatedKey = getEntityKey(relatedComponent); - if (!expanded.has(relatedKey)) { - expanded.add(relatedKey); - nextFrontier.push(relatedComponent); + if (entityKey !== sourceKey) { + addToResult(resultMap, entity); + } + + if (!expanded.has(entityKey)) { + expanded.add(entityKey); + nextFrontier.push(entity); + } } } }