diff --git a/ivette/src/dome/renderer/errors.tsx b/ivette/src/dome/renderer/errors.tsx index abf7e91bc6d2732d8975538d86db9877e4600ad5..9635e2625d646e286b94d603edcd3b63d6055826 100644 --- a/ivette/src/dome/renderer/errors.tsx +++ b/ivette/src/dome/renderer/errors.tsx @@ -30,7 +30,7 @@ */ import React, { ReactNode } from 'react'; -import { Debug } from 'dome'; +import { DEVEL, Debug } from 'dome'; import { Label } from 'dome/controls/labels'; import { Button } from 'dome/controls/buttons'; @@ -53,7 +53,6 @@ export interface CatchProps { label?: string; /** Alternative renderer callback in case of errors. */ onError?: JSX.Element | ErrorRenderer; - children: ReactNode; } @@ -62,25 +61,34 @@ interface CatchState { info?: unknown; } +/* eslint-disable react/prop-types */ + /** React Error Boundaries. */ export class Catch extends React.Component<CatchProps, CatchState, unknown> { - constructor(private p: CatchProps) { - super(p); + constructor(props: CatchProps) { + super(props); this.state = {}; this.logerr = this.logerr.bind(this); this.reload = this.reload.bind(this); } componentDidCatch(error: unknown, info: unknown): void { - this.setState({ error, info }); + if (DEVEL) { + const { label='Error' } = this.props; + D.error(label, ': ', error, info); + } + } + + static getDerivedStateFromError(error: unknown, info: unknown): CatchState { + return { error, info }; } logerr(): void { const { error, info } = this.state; - D.error('catched error:', error, info); + D.error('Catched error:', error, info); } reload(): void { @@ -89,7 +97,7 @@ export class Catch extends React.Component<CatchProps, CatchState, unknown> { render(): JSX.Element { const { error, info } = this.state; - const { onError, label = 'Error' } = this.p; + const { onError, label = 'Error' } = this.props; if (error) { if (typeof onError === 'function') return onError(error, info, this.reload); @@ -106,7 +114,7 @@ export class Catch extends React.Component<CatchProps, CatchState, unknown> { </div> ); } - return (<>{this.p.children}</>); + return (<>{this.props.children}</>); } } diff --git a/ivette/src/dome/renderer/graph/diagram.tsx b/ivette/src/dome/renderer/graph/diagram.tsx index 5f111e7322f7893d5d168f5a3e5dd7ff3ceb388f..f2c2c732aff6a8b1d98bca85ed4c2dcc589fc3b3 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. @@ -137,6 +151,7 @@ export interface DiagramProps { /* --- Color Model --- */ /* -------------------------------------------------------------------------- */ +// node background colors const BGCOLOR = { 'white': '#fff', 'grey': '#ccc', @@ -151,6 +166,22 @@ const BGCOLOR = { 'pink': 'hotpink', }; +// cluster background colors +const SGCOLOR = { + 'white': '#eee', + 'grey': '#ccc', + 'dark': '#aaa', + 'primary': '#4fc3f7', + 'selected': '#90caf9', + 'green': '#AED581', + 'orange': '#FFCC80', + 'red': '#ff6e6e', + 'yellow': '#fff59d', + 'blue': '#bbdefb', + 'pink': '#f8bbd0', +}; + +// foreground colors const FGCOLOR = { 'white': 'black', 'grey': 'black', @@ -165,6 +196,7 @@ const FGCOLOR = { 'pink': 'white', }; +// edge colors const EDCOLOR = { 'white': '#ccc', 'grey': '#888', @@ -192,6 +224,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 +234,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 +245,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 +271,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 +299,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 +336,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', SGCOLOR[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 +391,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 +401,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 +435,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 +536,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 321036f953cf574a519ada6b2d1ef62671ffc63c..191617f28bc1a6299abcf24c7ad53caf922ebd39 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}