Skip to content

Commit 7c732f4

Browse files
added cyclepoint grouping with subgraphs
added toggle button and styling added e2e test use initialOptions prop to save view state fix nodes undefined error added towncrier entry review amends review amends fix broken e2e tests
1 parent 600e3ec commit 7c732f4

File tree

4 files changed

+167
-13
lines changed

4 files changed

+167
-13
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<template>
2+
<g class="c-graph-subgraph">
3+
<rect
4+
:width="subgraph.width"
5+
:height="subgraph.height"
6+
:x="subgraph.x"
7+
:y="subgraph.y"
8+
rx="50"
9+
ry="50"
10+
fill="none"
11+
stroke-width="8px"
12+
stroke="grey"
13+
stroke-dasharray="50 50"
14+
/>
15+
<text
16+
:x="labelXPosition"
17+
:y="labelYPosition"
18+
font-family="Roboto"
19+
alignment-baseline="middle" text-anchor="middle"
20+
font-size="70px"
21+
fill="black"
22+
stroke-width=1.5
23+
stroke="white"
24+
>
25+
{{ subgraph.label }}
26+
</text>
27+
</g>
28+
</template>
29+
30+
<script>
31+
export default {
32+
name: 'GraphSubgraph',
33+
props: {
34+
subgraph: {
35+
type: Object,
36+
required: true
37+
}
38+
},
39+
computed: {
40+
labelXPosition () {
41+
return (parseInt(this.subgraph.x) + (parseInt(this.subgraph.width) / 2))
42+
},
43+
labelYPosition () {
44+
return (parseInt(this.subgraph.y) + 90)
45+
},
46+
}
47+
}
48+
</script>

src/views/Graph.vue

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
8686
/>
8787
</g>
8888
</g>
89+
<g v-if="groupCycle" class="graph-subgraph-container">
90+
<g v-for="(subgraph, key) in subgraphs"
91+
:key="key">
92+
<GraphSubgraph
93+
v-if="subgraph.label!='margin'"
94+
:subgraph="subgraph" />
95+
</g>
96+
</g>
8997
</g>
9098
</svg>
9199
</div>
@@ -105,6 +113,7 @@ import {
105113
import SubscriptionQuery from '@/model/SubscriptionQuery.model'
106114
// import CylcTreeCallback from '@/services/treeCallback'
107115
import GraphNode from '@/components/cylc/GraphNode.vue'
116+
import GraphSubgraph from '@/components/cylc/GraphSubgraph.vue'
108117
import ViewToolbar from '@/components/cylc/ViewToolbar.vue'
109118
import {
110119
posToPath,
@@ -118,7 +127,8 @@ import {
118127
mdiArrowCollapse,
119128
mdiArrowExpand,
120129
mdiRefresh,
121-
mdiFileRotateRight
130+
mdiFileRotateRight,
131+
mdiVectorSelection
122132
} from '@mdi/js'
123133
124134
// NOTE: Use TaskProxies not nodesEdges{nodes} to list nodes as this is what
@@ -219,6 +229,7 @@ export default {
219229
220230
components: {
221231
GraphNode,
232+
GraphSubgraph,
222233
ViewToolbar
223234
},
224235
@@ -251,11 +262,19 @@ export default {
251262
*/
252263
const spacing = useInitialOptions('spacing', { props, emit }, 1.5)
253264
265+
/**
266+
* The group by cycle point toggle state.
267+
* If true the graph nodes will be grouped by cycle point
268+
* @type {import('vue').Ref<boolean>}
269+
*/
270+
const groupCycle = useInitialOptions('groupCycle', { props, emit }, false)
271+
254272
return {
255273
jobTheme: useJobTheme(),
256274
transpose,
257275
autoRefresh,
258-
spacing
276+
spacing,
277+
groupCycle
259278
}
260279
},
261280
@@ -268,6 +287,7 @@ export default {
268287
// the nodes end edges we render to the graph
269288
graphNodes: [],
270289
graphEdges: [],
290+
subgraphs: {},
271291
// the svg transformations to apply to each node to apply the layout
272292
// generated by graphviz
273293
nodeTransformations: {},
@@ -361,6 +381,13 @@ export default {
361381
icon: mdiArrowCollapse,
362382
action: 'callback',
363383
callback: this.decreaseSpacing
384+
},
385+
{
386+
title: 'Group by cycle point',
387+
icon: mdiVectorSelection,
388+
action: 'toggle',
389+
value: this.groupCycle,
390+
key: 'groupCycle'
364391
}
365392
]
366393
}
@@ -483,7 +510,20 @@ export default {
483510
}
484511
return ret
485512
},
486-
getDotCode (nodeDimensions, nodes, edges) {
513+
/**
514+
* Get the nodes binned by cycle point
515+
*
516+
* @param {Object[]} nodes
517+
* @returns {{ [dateTime: string]: Object[] nodes }} mapping of node to their cycle point.
518+
*/
519+
getCycles (nodes) {
520+
if (!this.groupCycle) return
521+
return nodes.reduce((x, y) => {
522+
(x[y.node.cyclePoint] ||= []).push(y)
523+
return x
524+
}, {})
525+
},
526+
getDotCode (nodeDimensions, nodes, edges, cycles) {
487527
// return GraphViz dot code for the given nodes, edges and dimensions
488528
const ret = ['digraph {']
489529
let spacing = this.spacing
@@ -526,6 +566,27 @@ export default {
526566
]
527567
`)
528568
}
569+
570+
if (this.groupCycle) {
571+
// Loop over the subgraphs
572+
Object.keys(cycles).forEach((key, i) => {
573+
// Loop over the nodes that are included in the subraph
574+
const nodeFormattedArray = cycles[key].map(a => `"${a.id}"`)
575+
ret.push(`
576+
subgraph cluster_margin_${i}
577+
{
578+
margin=100.0
579+
label="margin"
580+
subgraph cluster_${i} {${nodeFormattedArray};\n
581+
label = "${key}";\n
582+
fontsize = "70px"
583+
style=dashed
584+
margin=60.0
585+
}
586+
}`)
587+
})
588+
}
589+
529590
if (this.transpose) {
530591
// left-right orientation
531592
// route edges from anywhere on the node of the source task to anywhere
@@ -542,6 +603,8 @@ export default {
542603
}
543604
}
544605
ret.push('}')
606+
console.log("ret.join('\n')")
607+
console.log(ret.join('\n'))
545608
return ret.join('\n')
546609
},
547610
hashGraph (nodes, edges) {
@@ -602,6 +665,8 @@ export default {
602665
return
603666
}
604667
668+
const cycles = this.getCycles(nodes)
669+
605670
// compute the graph ID
606671
const graphID = this.hashGraph(nodes, edges)
607672
if (this.graphID === graphID) {
@@ -646,7 +711,7 @@ export default {
646711
647712
// layout the graph
648713
try {
649-
await this.layout(nodes, edges, nodeDimensions)
714+
await this.layout(nodes, edges, nodeDimensions, cycles)
650715
} catch (e) {
651716
// something went wrong, allow the layout to retry later
652717
this.graphID = null
@@ -689,26 +754,42 @@ export default {
689754
* @param {Object[]} edges
690755
* @param {{ [id: string]: SVGRect }} nodeDimensions
691756
*/
692-
async layout (nodes, edges, nodeDimensions) {
757+
async layout (nodes, edges, nodeDimensions, cycles) {
693758
// generate the GraphViz dot code
694-
const dotCode = this.getDotCode(nodeDimensions, nodes, edges)
759+
const dotCode = this.getDotCode(nodeDimensions, nodes, edges, cycles)
695760
696761
// run the layout algorithm
697762
const jsonString = (await this.graphviz).layout(dotCode, 'json')
698763
const json = JSON.parse(jsonString)
699764
765+
this.subgraphs = {}
700766
// update graph node positions
701767
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] = `
768+
if (obj.bb) {
769+
// if the object is a subgraph
770+
const [left, bottom, right, top] = obj.bb.split(',')
771+
this.subgraphs[obj.name] = {
772+
x: left,
773+
y: -top,
774+
width: right - left,
775+
height: top - bottom,
776+
label: obj.label
777+
}
778+
} else {
779+
console.log("obj.name")
780+
console.log(obj.name)
781+
// else the object is a node
782+
const [x, y] = obj.pos.split(',')
783+
const bbox = nodeDimensions[obj.name]
784+
// translations:
785+
// 1. The graphviz node coordinates
786+
// 2. Centers the node on this coordinate
787+
// TODO convert (2) to maths OR fix it to avoid recomputation?
788+
this.nodeTransformations[obj.name] = `
709789
translate(${x}, -${y})
710790
translate(-${bbox.width / 2}, -${bbox.height / 2})
711791
`
792+
}
712793
}
713794
// update edge paths
714795
this.graphEdges = json.edges?.map(edge => posToPath(edge.pos)) ?? []
@@ -741,6 +822,11 @@ export default {
741822
if (!this.autoRefresh) {
742823
this.updateTimer()
743824
}
825+
},
826+
groupCycle () {
827+
// refresh the graph when group by cycle point option is changed
828+
this.graphID = null
829+
this.refresh()
744830
}
745831
}
746832
}

tests/e2e/specs/graph.cy.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ describe('Graph View', () => {
117117
.should('have.length', 7)
118118
})
119119

120+
it('should group by cycle point', () => {
121+
cy.visit('/#/graph/one')
122+
waitForGraphLayout()
123+
cy
124+
.get('[data-cy="control-groupCycle"] > .v-btn')
125+
.click()
126+
cy
127+
.get('.c-graph:first')
128+
.find('.c-graph-subgraph > rect')
129+
.should('be.visible')
130+
})
131+
120132
it('remembers autorefresh setting when switching between workflows', () => {
121133
cy.visit('/#/workspace/one')
122134
addView('Graph')
@@ -130,4 +142,11 @@ describe('Graph View', () => {
130142
waitForGraphLayout()
131143
checkRememberToolbarSettings('[data-cy=control-transpose]', 'not.have.class', 'have.class')
132144
})
145+
146+
it('remembers cycle point grouping setting when switching between workflows', () => {
147+
cy.visit('/#/workspace/one')
148+
addView('Graph')
149+
waitForGraphLayout()
150+
checkRememberToolbarSettings('[data-cy="control-groupCycle"]', 'not.have.class', 'have.class')
151+
})
133152
})

0 commit comments

Comments
 (0)