From cac70ef0301ea8155cd775c1ab17af343565efae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Correnson?= <loic.correnson@cea.fr> Date: Wed, 5 Jun 2024 09:08:17 +0200 Subject: [PATCH] [dome/diagrams] clusters --- ivette/src/dome/renderer/graph/diagram.tsx | 107 ++++++++++++++++++--- ivette/src/sandbox/dotdiagram.tsx | 31 +++--- 2 files changed, 110 insertions(+), 28 deletions(-) diff --git a/ivette/src/dome/renderer/graph/diagram.tsx b/ivette/src/dome/renderer/graph/diagram.tsx index 5f111e7322f..c80ce63f0ac 100644 --- a/ivette/src/dome/renderer/graph/diagram.tsx +++ b/ivette/src/dome/renderer/graph/diagram.tsx @@ -58,6 +58,8 @@ export type Box = Cell | Box[]; export interface Node { /** Node identifier (unique). */ id: string; + /** Cluster identifier */ + cluster?: string; /** Node label */ label?: string; /** Node tooltip */ @@ -102,13 +104,25 @@ export interface Edge { tailLabel?: string, } +export interface Cluster { + /** Identifier */ + id: string; + /** Label (default is none) */ + label?: string; + /** Title (default is none) */ + title?: string; + /** Background color (default is grey) */ + color?: Color; +} + /* -------------------------------------------------------------------------- */ /* --- Graph Component Properties --- */ /* -------------------------------------------------------------------------- */ export interface DiagramProps { - nodes: readonly Node[]; - edges: readonly Edge[]; + nodes?: readonly Node[]; + edges?: readonly Edge[]; + clusters?: readonly Cluster[]; /** Element to focus on. @@ -192,6 +206,8 @@ const DIR = (h: Arrow, t: Arrow): string | undefined => /* --- Dot Model --- */ /* -------------------------------------------------------------------------- */ +type cluster = { props: Cluster; nodes: Node[]; } + class Builder { private selected: string | undefined; @@ -200,6 +216,7 @@ class Builder { private kid = 0; private imap = new Map<string, string>(); private rmap = new Map<string, string>(); + private cmap = new Map<string, cluster>(); index(id: string): string { const n = this.imap.get(id); @@ -210,6 +227,25 @@ class Builder { return m; } + findCluster(id: string): cluster { + const c = this.cmap.get(id); + if (c !== undefined) return c; + const d = { props: { id }, nodes: [] }; + this.cmap.set(id, d); + return d; + } + + addClusterNode(n: Node): void { + if (n.cluster !== undefined) { + this.findCluster(n.cluster).nodes.push(n); + } + } + + setClusterProps(props: Cluster): void { + const c = this.findCluster(props.id); + c.props = props; + } + nodeId(n: string): string { return this.rmap.get(n) ?? n; } @@ -217,6 +253,7 @@ class Builder { init(): Builder { this.spec = 'digraph {\n'; this.selected = undefined; + this.cmap.clear(); // Keep node index to fade in & out return this; } @@ -244,15 +281,15 @@ class Builder { return this.print(a.split('"').join('\\"')); } - value(a: string | number): Builder { + value(a: string | number | boolean): Builder { if (typeof a === 'string') return this.print('"').escaped(a).print('"'); else return this.print(`${a}`); } - attr(a: string, v: string | number | undefined): Builder { - return v ? this.print(' ', a, '=').value(v).print('; ') : this; + attr(a: string, v: string | number | boolean | undefined): Builder { + return v ? this.print(' ', a, '=').value(v).print(';') : this; } // --- Node Table Shape @@ -281,28 +318,54 @@ class Builder { // --- Node node(n: Node): void { - this.print(' ').port(n.id).print(' ['); + this + .print(' ') + .port(n.id) + .print(' [') + .attr('id', n.id); if (typeof n.shape === 'object') { this .attr('shape', 'record') .print(' label="') .record(n.shape) - .print('"; '); + .print('";') + .attr('tooltip', n.title ?? n.id); } else { this .attr('label', n.label ?? n.id) - .attr('shape', n.shape); + .attr('shape', n.shape) + .attr('tooltip', n.title ?? n.label ?? n.id); } const color = n.color ?? (n.id === this.selected ? 'selected' : 'white'); this - .attr('id', n.id) - .attr('tooltip', n.title) .attr('fontcolor', FGCOLOR[color]) .attr('fillcolor', BGCOLOR[color]) - .println('];'); + .println(' ];'); + } + + cluster(c: cluster): void { + const { props: s, nodes } = c; + const { color = 'grey' } = s; + this + .print(' subgraph cluster_', this.index(s.id), ' {\n ') + .attr('style', 'filled') + .attr('label', s.label) + .attr('tooltip', s.title ?? s.id) + .attr('fontcolor', FGCOLOR[color]) + .attr('fillcolor', BGCOLOR[color]) + .print('\n '); + nodes.forEach(n => this.print(' ', this.index(n.id), ';')); + this.println('\n }'); + } + + clusters(cs: readonly Cluster[]): Builder { + cs.forEach(c => this.setClusterProps(c)); + return this; } nodes(ns: readonly Node[]): Builder { + ns.forEach(n => this.addClusterNode(n)); + this.cmap.forEach(c => this.cluster(c)); ns.forEach(n => this.node(n)); return this; } @@ -310,6 +373,7 @@ class Builder { // --- Edge edge(e: Edge): void { const { line = 'solid', head = 'arrow', tail = 'none' } = e; + const tooltip = e.title ?? e.label ?? `${e.source} -> ${e.target}`; this .print(' ') .port(e.source, e.sourcePort) @@ -319,7 +383,10 @@ class Builder { .attr('label', e.label) .attr('headlabel', e.headLabel) .attr('taillabel', e.tailLabel) - .attr('labeltooltip', e.title) + .attr('labeltooltip', e.label ? tooltip : undefined) + .attr('headtooltip', e.headLabel ? tooltip : undefined) + .attr('tailtooltip', e.tailLabel ? tooltip : undefined) + .attr('tooltip', tooltip) .attr('dir', DIR(head, tail)) .attr('color', e.color ? EDCOLOR[e.color] : undefined) .attr('style', line === 'solid' ? undefined : line) @@ -350,19 +417,28 @@ function GraphvizView(props: GraphvizProps): JSX.Element { const builder = React.useMemo(() => new Builder, []); // --- Model Generation - const { direction = 'LR', nodes, edges, selected } = props; + const { + direction = 'LR', + clusters = [], + nodes = [], + edges = [], + selected + } = props; + const model = React.useMemo(() => builder .init() .select(selected) + .print(' ') .attr('rankdir', direction) .attr('bgcolor', 'none') .attr('width', 0.5) .println('node [ style="filled" ];') + .clusters(clusters) .nodes(nodes) .edges(edges) .flush() - , [builder, direction, nodes, edges, selected] + , [builder, direction, clusters, nodes, edges, selected] ); // --- Model Update Callback @@ -442,8 +518,7 @@ export function Diagram(props: DiagramProps): JSX.Element { </div> )} </AutoSizer > - ) - } + )} </> ); } diff --git a/ivette/src/sandbox/dotdiagram.tsx b/ivette/src/sandbox/dotdiagram.tsx index 321036f953c..191617f28bc 100644 --- a/ivette/src/sandbox/dotdiagram.tsx +++ b/ivette/src/sandbox/dotdiagram.tsx @@ -27,7 +27,7 @@ import React from 'react'; import { Scroll } from 'dome/layout/boxes'; import { HSplit } from 'dome/layout/splitters'; -import { Diagram, Node, Edge } from 'dome/graph/diagram'; +import { Diagram, Node, Edge, Cluster } from 'dome/graph/diagram'; import { registerSandbox } from 'ivette'; // -------------------------------------------------------------------------- @@ -45,17 +45,17 @@ const nodes: Node[] = [ { label: 'Dashed "e"', port: 'e' }, ] }, - { id: 'white', color: 'white' }, - { id: 'grey', color: 'grey' }, - { id: 'dark', color: 'dark' }, - { id: 'primary', color: 'primary' }, - { id: 'selected', color: 'selected' }, - { id: 'green', color: 'green' }, - { id: 'orange', color: 'orange' }, - { id: 'red', color: 'red' }, - { id: 'yellow', color: 'yellow' }, - { id: 'blue', color: 'blue' }, - { id: 'pink', color: 'pink' }, + { id: 'white', color: 'white', cluster: 'BG' }, + { id: 'grey', color: 'grey', cluster: 'BG' }, + { id: 'dark', color: 'dark', cluster: 'BG' }, + { id: 'primary', color: 'primary', cluster: 'BG' }, + { id: 'selected', color: 'selected', cluster: 'BG' }, + { id: 'green', color: 'green', cluster: 'BG' }, + { id: 'orange', color: 'orange', cluster: 'BG' }, + { id: 'red', color: 'red', cluster: 'BG' }, + { id: 'yellow', color: 'yellow', cluster: 'BG' }, + { id: 'blue', color: 'blue', cluster: 'BG' }, + { id: 'pink', color: 'pink', cluster: 'BG' }, { id: 'X' }, { id: 'Y' } ]; @@ -87,9 +87,15 @@ const edges: Edge[] = [ }, ]; +function makeCluster(s: string | undefined): Cluster { + const color = nodes.find(n => n.id === s)?.color; + return { id: 'BG', title: 'Background Cluster', color }; +} + function DiagramSample(): JSX.Element { const [model, setModel] = React.useState(''); const [selected, setSelected] = React.useState<string>(); + const clusters = React.useMemo(() => [makeCluster(selected)], [selected]); return ( <HSplit settings='sandbox.diagram.split'> <Scroll> @@ -101,6 +107,7 @@ function DiagramSample(): JSX.Element { <Diagram nodes={nodes} edges={edges} + clusters={clusters} selected={selected} onModelChanged={setModel} onSelection={setSelected} -- GitLab