imbajin commented on code in PR #826:
URL: 
https://github.com/apache/hugegraph-toolchain/pull/826#discussion_r4323557454


##########
hugegraph-hubble/hubble-fe/src/visualization/runtime/G6Runtime.ts:
##########
@@ -0,0 +1,485 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {DagreLayout, Graph, GraphEvent} from '@antv/g6';
+import type {Command, EdgeData as EngineEdge, GraphData as EngineData, 
GraphOptions, History, IPointerEvent,
+    NodeData as EngineNode} from '@antv/g6';
+import type {GraphData, GraphEdge, GraphNode, NodeData, GraphStyle} from 
'../model/types';
+import {immutableCopy, mutableCopy} from '../model/ownership';
+import {labelFontScale, labelVisibility} from '../model/labels';
+import {PinBridge, registerExtensions} from './extensions';
+import type {ControllerOptions, GraphLayout, GraphRuntime, RuntimeEvent} from 
'./types';
+
+const toEngineData = (data: Partial<GraphData>): EngineData => ({
+    ...(data.nodes ? {nodes: data.nodes.map(node => ({...node, data: 
{...immutableCopy(node.data)}, style: mutableCopy(node.style)}))} : {}),
+    ...(data.edges ? {edges: data.edges.map(edge => ({...edge, data: 
{...immutableCopy(edge.data)}, style: mutableCopy(edge.style)}))} : {}),
+});
+const readNodeData = (data: Record<string, unknown> | undefined): NodeData => {
+    if (!data?.raw || typeof data.raw !== 'object' || Array.isArray(data.raw)) 
{
+        throw new Error('Engine element lost its original data record');
+    }
+    return {...data, raw: data.raw as Record<string, unknown>};
+};
+const hasChanges = (command: Command): boolean => 
Object.values(command.current)
+    .some((data: EngineData) => [data.nodes, data.edges, 
data.combos].some(items => Boolean(items?.length)));
+// G6's default-style callback wins over DrawData. Leave automatic lane/loop
+// geometry to the transform so stale model geometry cannot override its 
result.
+const edgePresentation = (style: EngineEdge['style'] = {}) => {
+    const {curveOffset: _curveOffset, loopPlacement: _loopPlacement, loopDist: 
_loopDist, ...presentation} = style;
+    return presentation;
+};
+
+/** Owns one real G6 instance. No application caller receives the Graph. */
+export class G6Runtime implements GraphRuntime {
+    private readonly graph: Graph;
+    private initialized = false;
+    private firstDataDrawPending = true;
+    private readonly historyEnabled: boolean;
+    private readonly fitZoomLimit: number;
+    private readonly semanticZoom: boolean;
+    private labels = {nodeLabels: true, edgeLabels: true};
+    private fontScale = 1;
+    private alive = true;
+    private busy = false;
+    private recording = false;
+    private replaying = false;
+    private handler: (event: RuntimeEvent) => void = () => {};
+    private readonly bindings: Array<[string, (event: IPointerEvent) => void]> 
= [];
+    private dragIdle: Promise<void> | null = null;
+    private finishDrag?: () => void;
+
+    constructor(container: HTMLElement, options: ControllerOptions = {}) {
+        registerExtensions();
+        const preset = options.preset ?? 'interactive';
+        const interactive = preset === 'interactive';
+        this.historyEnabled = interactive;
+        this.semanticZoom = interactive;
+        this.fitZoomLimit = interactive ? 1.35 : 1.5;
+        // G6 5.1.1 forwards this public G Canvas option, omitted from its 
narrowed type.
+        // Pure camera frames still draw in endFrame; skip the redundant 
per-element hooks.
+        const canvas: NonNullable<GraphOptions['canvas']> & 
{disableRenderHooks: boolean} = {disableRenderHooks: true};
+        this.graph = new Graph({
+            container, width: Math.max(container.clientWidth, 1), height: 
Math.max(container.clientHeight, 1),
+            canvas, autoResize: false, animation: false,
+            padding: preset === 'thumbnail' ? 12 : options.minimap ? [36, 36, 
36, 220] : 36,
+            theme: options.theme ?? 'light', zoomRange: [0.03, 8], data: 
{nodes: [], edges: []},
+            node: {
+                type: datum => typeof datum.data?.shape === 'string' ? 
datum.data.shape : 'circle',
+                style: datum => ({size: 36, fill: '#5C73E6', stroke: 
'#5C73E6', lineWidth: 1,
+                    labelPlacement: 'bottom', labelFill: options.theme === 
'dark' ? '#e8e8ec' : '#242938',
+                    labelBackground: true, labelBackgroundFill: options.theme 
=== 'dark' ? '#171923' : '#ffffff',
+                    labelBackgroundOpacity: 0.85, cursor: preset === 
'thumbnail' ? 'pointer' : 'grab',
+                    ...datum.style, ...this.labelTypography(datum.style, 12, 
[2, 4]),
+                    label: datum.style?.label !== false && 
this.labels.nodeLabels}),
+                state: {selected: datum => ({halo: false, lineWidth: 3, 
stroke: '#F59E0B',
+                    ...this.labelTypography(datum.style, 12, [2, 4]), label: 
datum.style?.label !== false}),
+                    active: {halo: false, lineWidth: 3, stroke: '#2563EB'},
+                    focused: datum => ({label: datum.style?.label !== false})},
+            },
+            edge: {type: 'quadratic',
+                style: datum => ({stroke: '#8D97B0', lineWidth: 1, endArrow: 
true,
+                labelFill: options.theme === 'dark' ? '#e8e8ec' : '#49536A',
+                labelBackground: true, labelBackgroundFill: options.theme === 
'dark' ? '#171923' : '#ffffff',
+                labelBackgroundOpacity: 0.85, loop: true, 
...edgePresentation(datum.style),
+                // Dragging fronts nodes; G6 then recomputes incident edge 
model layers on state updates.
+                // Keep edge rendering below nodes so a crossing edge cannot 
steal their pointer events.
+                zIndex: -1,
+                ...this.labelTypography(datum.style, 11, [1, 3]),
+                label: datum.style?.label !== false && 
this.labels.edgeLabels}),
+            state: {selected: datum => ({halo: false, lineWidth: 3, stroke: 
'#F59E0B',
+                ...this.labelTypography(datum.style, 11, [1, 3]), label: 
datum.style?.label !== false}),
+                active: {halo: false, lineWidth: 2, stroke: '#2563EB'},
+                focused: datum => ({label: datum.style?.label !== false})}},
+            transforms: [{type: 'hubble-parallel-edges'}],
+            behaviors: preset === 'thumbnail' ? [] : [
+                'drag-canvas', 'zoom-canvas',
+                {type: 'drag-element', shadow: false, hideEdge: 'none', 
animation: false,
+                    enable: (event: IPointerEvent) => {
+                        if (!this.alive || this.busy || event.targetType !== 
'node') return false;
+                        this.dragIdle = new Promise(resolve => { 
this.finishDrag = resolve; });
+                        this.handler({type: 'leave'});
+                        return true;
+                    },
+                    onFinish: () => {
+                        this.finishDrag?.(); this.finishDrag = undefined; 
this.dragIdle = null;
+                        if (this.alive) this.handler({type: 'dragEnd'});
+                    }},
+            ],
+            plugins: [
+                {type: 'hubble-pin-bridge', key: 'pins'},
+                ...(interactive ? [{type: 'history', key: 'history', 
stackSize: 20,
+                    beforeAddCommand: (command: Command) => this.recording && 
hasChanges(command),
+                    afterAddCommand: () => {
+                        if (!this.replaying) 
this.getHistory()?.redoStack.splice(0);
+                    }}] : []),
+                ...(interactive && options.minimap ? [{type: 'minimap', key: 
'minimap', size: [180, 120], position: 'left-bottom'}] : []),
+            ],
+        });
+        if (preset !== 'thumbnail') this.bindEvents();
+    }
+
+    async idleInteraction(): Promise<void> { if (this.dragIdle) await 
this.dragIdle; }
+    onEvent(handler: (event: RuntimeEvent) => void): void { this.handler = 
handler; }
+
+    private labelTypography(style: EngineNode['style'] | EngineEdge['style'], 
defaultSize: number, defaultPadding: number[]): {
+        labelFontSize: number; labelLineHeight: number; labelPadding: number | 
number[];
+    } {
+        const nonnegative = (value: unknown): value is number =>
+            typeof value === 'number' && Number.isFinite(value) && value >= 0;
+        const size = nonnegative(style?.labelFontSize) ? style.labelFontSize : 
defaultSize;
+        const lineHeight = nonnegative(style?.labelLineHeight) ? 
style.labelLineHeight : size * 4 / 3;
+        const padding = style?.labelPadding;
+        return {labelFontSize: size / this.fontScale, labelLineHeight: 
lineHeight / this.fontScale,
+            labelPadding: nonnegative(padding) ? padding / this.fontScale
+                : (Array.isArray(padding) && padding.every(nonnegative) ? 
padding : defaultPadding)
+                    .map(value => value / this.fontScale)};
+    }
+
+    private bindEvents(): void {
+        for (const kind of ['node', 'edge'] as const) {
+            for (const [name, type] of [['click', 'click'], ['dblclick', 
'doubleClick'],
+                ['pointerenter', 'hover'], ['pointerleave', 'leave'], 
['contextmenu', 'contextMenu']] as const) {
+                const handler = (event: IPointerEvent) => {
+                    if (!this.alive) return;
+                    if (type === 'hover' && this.dragIdle) return;
+                    if (type === 'contextMenu') event.preventDefault();
+                    const id = 'id' in event.target ? String(event.target.id) 
: undefined;
+                    this.handler({type, id, kind, client: {x: event.client.x, 
y: event.client.y},
+                        ...(type === 'click' && (event.shiftKey || 
event.ctrlKey || event.metaKey)
+                            ? {additiveSelection: true} : {})});
+                };
+                this.graph.on(`${kind}:${name}`, handler);
+                this.bindings.push([`${kind}:${name}`, handler]);
+            }
+        }
+        const canvasClick = () => { if (this.alive) this.handler({type: 
'canvasClick'}); };
+        this.graph.on('canvas:click', canvasClick);
+        this.bindings.push(['canvas:click', canvasClick]);
+        const viewport = () => { if (this.alive) this.handler({type: 
'viewport'}); };
+        this.graph.on(GraphEvent.AFTER_TRANSFORM, viewport);
+        this.bindings.push([GraphEvent.AFTER_TRANSFORM, viewport]);
+    }
+
+    private getHistory(): History | undefined {
+        return this.initialized && this.historyEnabled ? 
this.graph.getPluginInstance<History>('history') : undefined;
+    }
+    private getPins(): PinBridge | undefined {
+        return this.initialized ? 
this.graph.getPluginInstance<PinBridge>('pins') : undefined;
+    }
+
+    private async commit(record: boolean, run: () => Promise<void>): 
Promise<void> {
+        if (this.dragIdle) await this.dragIdle;
+        if (!this.alive) return;
+        const history = this.getHistory();
+        // G6 5.1.1 trims a full stack BEFORE consulting beforeAddCommand.
+        // Preserve the plugin's public stacks around excluded system draws.
+        const saved = !record && history ? {undo: [...history.undoStack], 
redo: [...history.redoStack]} : null;
+        this.busy = true;
+        this.recording = record;
+        try { await run(); }
+        finally {
+            if (saved && history) { history.undoStack = saved.undo; 
history.redoStack = saved.redo; }
+            this.recording = true;
+            this.busy = false;
+        }
+    }
+
+    async replace(data: GraphData, layout: GraphLayout | null): Promise<void> {
+        await this.commit(false, async () => {
+            // Compute before the first draw, so large datasets never create a 
full set of labels first.
+            const zoom = this.initialized ? this.graph.getZoom() : 1;
+            this.labels = this.semanticZoom
+                ? labelVisibility(data.nodes.length + data.edges.length, zoom)
+                : {nodeLabels: true, edgeLabels: true};
+            this.fontScale = this.semanticZoom ? labelFontScale(zoom) : 1;
+            const incoming = toEngineData(data);
+            if (layout?.type === 'd3-force' && incoming.edges?.length && 
incoming.nodes?.length
+                && incoming.nodes.length + incoming.edges.length <= 500
+                && incoming.nodes.every(node => 
!Number.isFinite(node.style?.x) && !Number.isFinite(node.style?.y))) {
+                // Start a fresh force simulation from topology rather than 
D3's unrelated
+                // phyllotaxis for common result sizes. Saved coordinates 
(including pins) remain authoritative.
+                const seed = new DagreLayout({rankdir: 'TB', nodeSize: 48, 
nodesep: 90, ranksep: 140});
+                try {
+                    await seed.execute({nodes: incoming.nodes, edges: 
incoming.edges});
+                    const positions = new Map<string, {x: number; y: 
number}>();

Review Comment:
   [P2] Bound the cost of automatic Dagre seeding, not only element count
   
   This runs a native Dagre layout synchronously on the application thread 
before the default force layout. `await` does not yield while Dagre performs 
its computation, and `nodes + edges <= 500` still admits expensive dense 
inputs. An ordinary unpositioned result with 31 nodes and 465 forward edges 
(one edge for each `source < target`, 496 total elements) enters this branch. 
On the pinned `@antv/[email protected]`, using these exact seed options, three 
bounded cloud Node runs spent 1.52–1.77 seconds inside `execute()` before it 
returned; a separate reproduction measured 1.63 seconds. A zero-delay timer 
could not run until that work completed. A 22-node / 462-edge bidirectional 
fixture also took 1.09–1.19 seconds. This is additional work on the default 
result-display path, before the existing force pass, so accepted query results 
can now stall UI input/rendering despite the small node count.
   
   Please restrict this automatic readability optimization to a demonstrably 
inexpensive topology/edge-density envelope and bypass it outside that envelope, 
preserving the existing force fallback. Add dense, still-under-500 regression 
cases and a bounded responsiveness check; merely adding another `await` around 
this synchronous operation will not remove the stall. Moving computation 
off-thread is another option, but is not necessary if a conservative cheap 
eligibility rule is sufficient.
   
   Evidence boundary: exact reviewed version 
`a69e142335477179f30eea64171d11d73127a417`, native layout package 2.0.0, Node 
24.19.0 in an isolated cloud probe, not a measured browser freeze duration or a 
universal latency threshold. G6 5.1.1 re-exports this Dagre class. The existing 
sparse geometry fixtures do not cover this dense cost case.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to