Skip to content

Commit 4ef10a8

Browse files
Merge pull request cylc#1763 from markgrahamdawson/cycle-subgraphs
added cyclepoint grouping with subgraphs
2 parents bb56c02 + 5195e9f commit 4ef10a8

File tree

7 files changed

+236
-32
lines changed

7 files changed

+236
-32
lines changed

changes.d/1763.feat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added graph view feature to group nodes by cycle point
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!--
2+
Copyright (C) NIWA & British Crown (Met Office) & Contributors.
3+
4+
This program is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU General Public License as published by
6+
the Free Software Foundation, either version 3 of the License, or
7+
(at your option) any later version.
8+
9+
This program is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU General Public License for more details.
13+
14+
You should have received a copy of the GNU General Public License
15+
along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
-->
17+
18+
<!--
19+
Subgraph - Used to render graphviz subgraphs as svg.
20+
-->
21+
<template>
22+
<g class="c-graph-subgraph">
23+
<rect
24+
:width="subgraph.width"
25+
:height="subgraph.height"
26+
:x="subgraph.x"
27+
:y="subgraph.y"
28+
rx="50"
29+
ry="50"
30+
fill="none"
31+
stroke-width="8px"
32+
stroke="grey"
33+
stroke-dasharray="50 50"
34+
/>
35+
<text
36+
:x="labelXPosition"
37+
:y="labelYPosition"
38+
font-family="Roboto"
39+
alignment-baseline="middle" text-anchor="middle"
40+
font-size="60px"
41+
fill="black"
42+
stroke-width=5
43+
paint-order="stroke"
44+
stroke="white"
45+
>
46+
{{ subgraph.label }}
47+
</text>
48+
</g>
49+
</template>
50+
51+
<script>
52+
export default {
53+
name: 'GraphSubgraph',
54+
props: {
55+
subgraph: {
56+
type: Object,
57+
required: true
58+
}
59+
},
60+
computed: {
61+
labelXPosition () {
62+
return (parseInt(this.subgraph.x) + (parseInt(this.subgraph.width) / 2))
63+
},
64+
labelYPosition () {
65+
// Graphviz puts labels inside the subgraph
66+
// SVG rect text is put outside the rect
67+
// Adding 90pt to the y position brings the label inside the rect
68+
return (parseInt(this.subgraph.y) + 90)
69+
},
70+
}
71+
}
72+
</script>

src/services/mock/json/workflows/multi.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
"isHeld": false,
145145
"isQueued": true,
146146
"isRunahead": false,
147+
"name": "foo",
147148
"task": {
148149
"meanElapsedTime": 0,
149150
"__typename": "Task"
@@ -160,6 +161,7 @@
160161
"isHeld": false,
161162
"isQueued": false,
162163
"isRunahead": false,
164+
"name": "foo",
163165
"task": {
164166
"meanElapsedTime": 0,
165167
"__typename": "Task"

src/utils/graph-utils.js

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/**
1+
/*
22
* Copyright (C) NIWA & British Crown (Met Office) & Contributors.
33
*
44
* This program is free software: you can redistribute it and/or modify
@@ -15,25 +15,42 @@
1515
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
*/
1717

18+
/**
19+
* Convert graphviz edge bezier curve in dot format to SVG path .
20+
*
21+
* @param {string} pos - `pos` attribute of a graph edge in dot format.
22+
* @returns {string} The SVG path.
23+
*/
1824
export function posToPath (pos) {
25+
// pos starts with `e,` followed by a list of coordinates
26+
const parts = pos.substring(2).split(' ')
1927
// the last point comes first, followed by the others in order I.E:
2028
// -1, 0, 1, 2, ... -3, -2
21-
const parts = pos.substring(2).split(' ').map(x => x.split(','))
22-
const [last] = parts.splice(0, 1)
23-
let path = null
24-
for (const part of parts) {
25-
if (!path) {
26-
path = `M${part[0]} -${part[1]} C`
27-
} else {
28-
path = path + ` ${part[0]} -${part[1]},`
29-
}
30-
}
31-
path = path + ` L ${last[0]} -${last[1]}`
32-
return path
29+
const [last, first] = parts.splice(0, 2)
30+
const path = parts.reduce(
31+
(acc, part) => `${acc} ${getCoord(part)},`,
32+
`M${getCoord(first)} C`
33+
)
34+
return `${path} L ${getCoord(last)}`
3335
}
3436

35-
/* TODO: everything! */
36-
// eslint-disable-next-line no-extend-native
37+
/**
38+
* Convert dotcode `pos` coordinate to SVG path coordinate.
39+
*
40+
* @param {string} posCoord - A coordinate in dot format.
41+
* @returns {string}
42+
*/
43+
export function getCoord (posCoord) {
44+
const [x, y] = posCoord.split(',').map(parseFloat)
45+
return `${x} ${-y}`
46+
}
47+
48+
/**
49+
* Calculate a non-cryptographic hash value for a given string.
50+
*
51+
* @param {string} string
52+
* @returns {number}
53+
*/
3754
export function nonCryptoHash (string) {
3855
let hash = 0
3956
let i

src/views/Graph.vue

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
8686
/>
8787
</g>
8888
</g>
89+
<g v-if="groupCycle">
90+
<GraphSubgraph
91+
v-for="(subgraph, key) in subgraphs"
92+
:key="key"
93+
:subgraph="subgraph"
94+
/>
95+
</g>
8996
</g>
9097
</svg>
9198
</div>
@@ -105,6 +112,7 @@ import {
105112
import SubscriptionQuery from '@/model/SubscriptionQuery.model'
106113
// import CylcTreeCallback from '@/services/treeCallback'
107114
import GraphNode from '@/components/cylc/GraphNode.vue'
115+
import GraphSubgraph from '@/components/cylc/GraphSubgraph.vue'
108116
import ViewToolbar from '@/components/cylc/ViewToolbar.vue'
109117
import {
110118
posToPath,
@@ -118,7 +126,8 @@ import {
118126
mdiArrowCollapse,
119127
mdiArrowExpand,
120128
mdiRefresh,
121-
mdiFileRotateRight
129+
mdiFileRotateRight,
130+
mdiVectorSelection
122131
} from '@mdi/js'
123132
124133
// NOTE: Use TaskProxies not nodesEdges{nodes} to list nodes as this is what
@@ -154,7 +163,6 @@ fragment EdgeData on Edge {
154163
fragment TaskProxyData on TaskProxy {
155164
id
156165
state
157-
cyclePoint
158166
isHeld
159167
isRunahead
160168
isQueued
@@ -219,6 +227,7 @@ export default {
219227
220228
components: {
221229
GraphNode,
230+
GraphSubgraph,
222231
ViewToolbar
223232
},
224233
@@ -251,11 +260,19 @@ export default {
251260
*/
252261
const spacing = useInitialOptions('spacing', { props, emit }, 1.5)
253262
263+
/**
264+
* The group by cycle point toggle state.
265+
* If true the graph nodes will be grouped by cycle point
266+
* @type {import('vue').Ref<boolean>}
267+
*/
268+
const groupCycle = useInitialOptions('groupCycle', { props, emit }, false)
269+
254270
return {
255271
jobTheme: useJobTheme(),
256272
transpose,
257273
autoRefresh,
258-
spacing
274+
spacing,
275+
groupCycle
259276
}
260277
},
261278
@@ -268,6 +285,7 @@ export default {
268285
// the nodes end edges we render to the graph
269286
graphNodes: [],
270287
graphEdges: [],
288+
subgraphs: {},
271289
// the svg transformations to apply to each node to apply the layout
272290
// generated by graphviz
273291
nodeTransformations: {},
@@ -361,6 +379,13 @@ export default {
361379
icon: mdiArrowCollapse,
362380
action: 'callback',
363381
callback: this.decreaseSpacing
382+
},
383+
{
384+
title: 'Group by cycle point',
385+
icon: mdiVectorSelection,
386+
action: 'toggle',
387+
value: this.groupCycle,
388+
key: 'groupCycle'
364389
}
365390
]
366391
}
@@ -483,7 +508,20 @@ export default {
483508
}
484509
return ret
485510
},
486-
getDotCode (nodeDimensions, nodes, edges) {
511+
/**
512+
* Get the nodes binned by cycle point
513+
*
514+
* @param {Object[]} nodes
515+
* @returns {{ [dateTime: string]: Object[] }=} mapping of cycle points to nodes
516+
*/
517+
getCycles (nodes) {
518+
if (!this.groupCycle) return
519+
return nodes.reduce((x, y) => {
520+
(x[y.tokens.cycle] ||= []).push(y)
521+
return x
522+
}, {})
523+
},
524+
getDotCode (nodeDimensions, nodes, edges, cycles) {
487525
// return GraphViz dot code for the given nodes, edges and dimensions
488526
const ret = ['digraph {']
489527
let spacing = this.spacing
@@ -526,6 +564,27 @@ export default {
526564
]
527565
`)
528566
}
567+
568+
if (this.groupCycle) {
569+
// Loop over the subgraphs
570+
Object.keys(cycles).forEach((key, i) => {
571+
// Loop over the nodes that are included in the subraph
572+
const nodeFormattedArray = cycles[key].map(a => `"${a.id}"`)
573+
ret.push(`
574+
subgraph cluster_margin_${i}
575+
{
576+
margin=100.0
577+
label="margin"
578+
subgraph cluster_${i} {${nodeFormattedArray};\n
579+
label = "${key}";\n
580+
fontsize = "70px"
581+
style=dashed
582+
margin=60.0
583+
}
584+
}`)
585+
})
586+
}
587+
529588
if (this.transpose) {
530589
// left-right orientation
531590
// route edges from anywhere on the node of the source task to anywhere
@@ -602,6 +661,8 @@ export default {
602661
return
603662
}
604663
664+
const cycles = this.getCycles(nodes)
665+
605666
// compute the graph ID
606667
const graphID = this.hashGraph(nodes, edges)
607668
if (this.graphID === graphID) {
@@ -646,7 +707,7 @@ export default {
646707
647708
// layout the graph
648709
try {
649-
await this.layout(nodes, edges, nodeDimensions)
710+
await this.layout(nodes, edges, nodeDimensions, cycles)
650711
} catch (e) {
651712
// something went wrong, allow the layout to retry later
652713
this.graphID = null
@@ -689,26 +750,43 @@ export default {
689750
* @param {Object[]} edges
690751
* @param {{ [id: string]: SVGRect }} nodeDimensions
691752
*/
692-
async layout (nodes, edges, nodeDimensions) {
753+
async layout (nodes, edges, nodeDimensions, cycles) {
693754
// generate the GraphViz dot code
694-
const dotCode = this.getDotCode(nodeDimensions, nodes, edges)
755+
const dotCode = this.getDotCode(nodeDimensions, nodes, edges, cycles)
695756
696757
// run the layout algorithm
697758
const jsonString = (await this.graphviz).layout(dotCode, 'json')
698759
const json = JSON.parse(jsonString)
699760
761+
this.subgraphs = {}
700762
// update graph node positions
701763
for (const obj of json.objects) {
702-
const [x, y] = obj.pos.split(',')
703-
const bbox = nodeDimensions[obj.name]
704-
// translations:
705-
// 1. The graphviz node coordinates
706-
// 2. Centers the node on this coordinate
707-
// TODO convert (2) to maths OR fix it to avoid recomputation?
708-
this.nodeTransformations[obj.name] = `
764+
if (obj.bb) {
765+
// if the object is a subgraph
766+
if (obj.label !== 'margin') {
767+
// ignore the margins in the dot-code which do not need DOM elements
768+
const [left, bottom, right, top] = obj.bb.split(',')
769+
this.subgraphs[obj.name] = {
770+
x: left,
771+
y: -top,
772+
width: right - left,
773+
height: top - bottom,
774+
label: obj.label
775+
}
776+
}
777+
} else {
778+
// else the object is a node
779+
const [x, y] = obj.pos.split(',')
780+
const bbox = nodeDimensions[obj.name]
781+
// translations:
782+
// 1. The graphviz node coordinates
783+
// 2. Centers the node on this coordinate
784+
// TODO convert (2) to maths OR fix it to avoid recomputation?
785+
this.nodeTransformations[obj.name] = `
709786
translate(${x}, -${y})
710787
translate(-${bbox.width / 2}, -${bbox.height / 2})
711788
`
789+
}
712790
}
713791
// update edge paths
714792
this.graphEdges = json.edges?.map(edge => posToPath(edge.pos)) ?? []
@@ -741,6 +819,11 @@ export default {
741819
if (!this.autoRefresh) {
742820
this.updateTimer()
743821
}
822+
},
823+
groupCycle () {
824+
// refresh the graph when group by cycle point option is changed
825+
this.graphID = null
826+
this.refresh()
744827
}
745828
}
746829
}

0 commit comments

Comments
 (0)