Skip to content

Commit c5ab5f7

Browse files
committed
UI: Introduce graph's component (#1879)
* Create a new component that uses Echarts Parallel Graph. Signed-off-by: Elena Zioga <[email protected]>
1 parent 6e3afa5 commit c5ab5f7

File tree

5 files changed

+392
-0
lines changed

5 files changed

+392
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div class="graph-wrapper">
2+
<div echarts [initOpts]="initOpts" [options]="options" [merge]="options" class="graph"></div>
3+
</div>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.graph-wrapper {
2+
position: relative;
3+
display: flex;
4+
justify-content: center;
5+
align-items: center;
6+
flex-direction: column;
7+
}
8+
9+
.graph {
10+
width: 400px;
11+
12+
@media (min-width: 768px) {
13+
width: 700px;
14+
}
15+
16+
@media (min-width: 1024px) {
17+
width: 1000px;
18+
}
19+
20+
@media (min-width: 1400px) {
21+
width: 1300px;
22+
}
23+
24+
@media (min-width: 1650px) {
25+
width: 1600px;
26+
}
27+
28+
@media (min-width: 2000px) {
29+
width: 1900px;
30+
}
31+
32+
height: 600px;
33+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
2+
3+
import { TrialsGraphEchartsComponent } from './trials-graph-echarts.component';
4+
5+
describe('TrialsGraphEchartsComponent', () => {
6+
let component: TrialsGraphEchartsComponent;
7+
let fixture: ComponentFixture<TrialsGraphEchartsComponent>;
8+
9+
beforeEach(
10+
waitForAsync(() => {
11+
TestBed.configureTestingModule({
12+
declarations: [TrialsGraphEchartsComponent],
13+
}).compileComponents();
14+
}),
15+
);
16+
17+
beforeEach(() => {
18+
fixture = TestBed.createComponent(TrialsGraphEchartsComponent);
19+
component = fixture.componentInstance;
20+
fixture.detectChanges();
21+
});
22+
23+
it('should create', () => {
24+
expect(component).toBeTruthy();
25+
});
26+
});
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { Component, Input, SimpleChanges } from '@angular/core';
2+
import lowerCase from 'lodash-es/lowerCase';
3+
import capitalize from 'lodash-es/capitalize';
4+
import { ExperimentK8s } from 'src/app/models/experiment.k8s.model';
5+
6+
@Component({
7+
selector: 'app-trials-graph-echarts',
8+
templateUrl: './trials-graph-echarts.component.html',
9+
styleUrls: ['./trials-graph-echarts.component.scss'],
10+
})
11+
export class TrialsGraphEchartsComponent {
12+
initOpts = {
13+
renderer: 'svg',
14+
};
15+
16+
options: any;
17+
dataArray = [];
18+
dataToDisplay = [];
19+
dataAllInfo = [];
20+
tooltipHeaders = [];
21+
tooltipDataToDisplay = [];
22+
parallelAxis = [];
23+
maxAxisValue = [];
24+
color = [];
25+
numberOfmetricStrategies: number;
26+
27+
@Input()
28+
experimentTrialsCsv: string;
29+
30+
@Input()
31+
experiment: ExperimentK8s;
32+
33+
constructor() {}
34+
35+
ngOnChanges(changes: SimpleChanges): void {
36+
// Re-render the graph only when we detect changes to the Trials data, received from the backend
37+
if (!changes.experimentTrialsCsv || !this.experimentTrialsCsv) {
38+
return;
39+
}
40+
41+
this.initializeData();
42+
43+
this.numberOfmetricStrategies =
44+
this.experiment.spec.objective.metricStrategies.length; // the number of the output metrics
45+
let lines = this.experimentTrialsCsv.split('\n');
46+
let axes = lines[0].split(',');
47+
let excludeFromGraph = ['trialName', 'Status', 'KFP Run'];
48+
let excludeFromTooltipHeaders = ['KFP Run'];
49+
let axesToDisplay = axes.filter(axis => !excludeFromGraph.includes(axis));
50+
51+
// In case of having additional metrics, move these at the end
52+
if (this.numberOfmetricStrategies > 1) {
53+
for (let i = 1; i < this.numberOfmetricStrategies; i++) {
54+
axesToDisplay.push(axesToDisplay.splice(1, 1)[0]);
55+
}
56+
}
57+
// Move the target metric at the end and duplicate it
58+
axesToDisplay.push(axesToDisplay.shift());
59+
axesToDisplay.push(axesToDisplay[axesToDisplay.length - 1]);
60+
61+
// Set tooltip headers that includes both trial name and status values
62+
this.tooltipHeaders = axes.filter(
63+
axis => !excludeFromTooltipHeaders.includes(axis),
64+
);
65+
66+
this.dataArray = this.convertCsvToArray(lines, axes);
67+
this.color = this.createColorHeatmap(this.tooltipHeaders);
68+
this.parallelAxis = this.createParallelAxis(axesToDisplay, this.dataArray);
69+
this.prepareGraphData();
70+
71+
this.options = this.createGraphOptions(
72+
this.dataToDisplay,
73+
this.parallelAxis,
74+
this.color,
75+
this.dataAllInfo,
76+
this.tooltipDataToDisplay,
77+
this.tooltipHeaders,
78+
);
79+
}
80+
81+
// Reset the lists
82+
initializeData() {
83+
this.dataArray = [];
84+
this.dataToDisplay = [];
85+
this.dataAllInfo = [];
86+
this.tooltipHeaders = [];
87+
this.tooltipDataToDisplay = [];
88+
this.parallelAxis = [];
89+
this.maxAxisValue = [];
90+
this.color = [];
91+
}
92+
93+
convertCsvToArray(lines, axes) {
94+
let array = [];
95+
for (let i = 1; i < lines.length; i++) {
96+
let obj = {};
97+
let currentline = lines[i].split(',');
98+
for (let j = 0; j < axes.length; j++) {
99+
obj[axes[j]] = currentline[j];
100+
}
101+
array.push(obj);
102+
}
103+
return array;
104+
}
105+
106+
// Set the heatmap color based on the output metric
107+
createColorHeatmap(tooltipHeaders) {
108+
let heatmapColor = ['#1a2a6c', '#b21f1f', '#fdbb2d'];
109+
if (tooltipHeaders[2].includes('loss')) {
110+
heatmapColor.reverse();
111+
}
112+
return heatmapColor;
113+
}
114+
115+
createParallelAxis(axesToDisplay, data) {
116+
// Set the maximum value of each axis
117+
for (let i = 0; i < axesToDisplay.length; i++) {
118+
const max =
119+
Math.max(...data.map(item => item[axesToDisplay[i]])) +
120+
0.1 * Math.max(...data.map(item => item[axesToDisplay[i]]));
121+
this.maxAxisValue.push(max);
122+
}
123+
124+
// Set the parallel axes of the graph
125+
let parallelAxisArray = [];
126+
let parallelAxisObj = {};
127+
for (let i = 0; i < this.experiment.spec.parameters.length; i++) {
128+
// In case of having a metric of type categorical, we have to be explicit and set the type of
129+
// this parallel axis to category since it appears in its own unique way
130+
if (this.experiment.spec.parameters[i].parameterType === 'categorical') {
131+
parallelAxisObj = {
132+
dim: i,
133+
name: lowerCase(axesToDisplay[i]),
134+
type: 'category',
135+
};
136+
} else {
137+
parallelAxisObj = {
138+
dim: i,
139+
name: lowerCase(axesToDisplay[i]),
140+
max: this.maxAxisValue[i],
141+
axisLabel: {
142+
showMaxLabel: false,
143+
},
144+
};
145+
}
146+
parallelAxisArray.push(parallelAxisObj);
147+
}
148+
149+
for (
150+
let j = this.experiment.spec.parameters.length;
151+
j < axesToDisplay.length;
152+
j++
153+
) {
154+
parallelAxisObj = {
155+
dim: j,
156+
name: lowerCase(axesToDisplay[j]),
157+
max: this.maxAxisValue[j],
158+
axisLabel: {
159+
showMaxLabel: false,
160+
},
161+
};
162+
if (j === axesToDisplay.length - 1) {
163+
parallelAxisObj = {
164+
dim: j,
165+
max: this.maxAxisValue[j],
166+
axisTick: {
167+
show: false,
168+
},
169+
axisLine: {
170+
show: false,
171+
},
172+
axisLabel: {
173+
// show: false // doesn't work
174+
align: 'right',
175+
margin: 1000000,
176+
},
177+
};
178+
}
179+
parallelAxisArray.push(parallelAxisObj);
180+
}
181+
return parallelAxisArray;
182+
}
183+
184+
// Set data needed for creating the graph
185+
prepareGraphData() {
186+
let trialToDisplay = [];
187+
let dataAll = [];
188+
let trialNameStatus = [];
189+
let trialMetrics = [];
190+
let tooltipData = [];
191+
this.dataArray.forEach(trial => {
192+
delete trial['KFP Run'];
193+
194+
Object.keys(trial).forEach(axis => {
195+
if (axis === 'trialName' || axis === 'Status') {
196+
trialNameStatus.push(trial[axis]);
197+
} else {
198+
trialToDisplay.push(trial[axis]);
199+
trialMetrics.push(trial[axis]);
200+
}
201+
});
202+
203+
// In case of having additional metrics, move these at the end
204+
if (this.numberOfmetricStrategies > 1) {
205+
for (let i = 1; i < this.numberOfmetricStrategies; i++) {
206+
trialToDisplay.push(trialToDisplay.splice(1, 1)[0]);
207+
}
208+
}
209+
// Move the target metric at the end and duplicate it
210+
trialToDisplay.push(trialToDisplay.shift());
211+
trialToDisplay.push(trialToDisplay[trialToDisplay.length - 1]);
212+
if (trialToDisplay[this.parallelAxis.length - 1] !== '') {
213+
dataAll = trialNameStatus.concat(trialToDisplay);
214+
tooltipData = trialNameStatus.concat(trialMetrics);
215+
this.dataAllInfo.push(dataAll);
216+
this.tooltipDataToDisplay.push(tooltipData);
217+
this.dataToDisplay.push(trialToDisplay);
218+
}
219+
trialNameStatus = [];
220+
trialToDisplay = [];
221+
trialMetrics = [];
222+
});
223+
}
224+
225+
createGraphOptions(
226+
dataToDisplay,
227+
parallelAxis,
228+
color,
229+
dataAllInfo,
230+
tooltipDataToDisplay,
231+
tooltipHeaders,
232+
) {
233+
// Set the options value that echarts need to create the graph
234+
let graphOptions = {
235+
tooltip: {
236+
// O(n^2)
237+
formatter: function (params) {
238+
return createTooltipText(
239+
params,
240+
dataAllInfo,
241+
tooltipDataToDisplay,
242+
tooltipHeaders,
243+
);
244+
},
245+
padding: 10,
246+
borderWidth: 1,
247+
},
248+
parallelAxis: parallelAxis,
249+
visualMap: {
250+
min: 0,
251+
max:
252+
this.maxAxisValue[parallelAxis.length - 1] === 0
253+
? 1
254+
: this.maxAxisValue[parallelAxis.length - 1],
255+
precision: 2,
256+
dimension: parallelAxis.length - 1,
257+
inRange: {
258+
color: color,
259+
},
260+
itemHeight: 482,
261+
itemWidth: 40,
262+
right: 40,
263+
bottom: 50,
264+
align: 'left',
265+
},
266+
series: {
267+
type: 'parallel',
268+
lineStyle: {
269+
width: 2,
270+
opacity: 0.5,
271+
},
272+
smooth: true,
273+
emphasis: {
274+
focus: 'self',
275+
lineStyle: {
276+
width: 3,
277+
opacity: 1,
278+
},
279+
},
280+
data: dataToDisplay,
281+
},
282+
};
283+
return graphOptions;
284+
}
285+
}
286+
287+
function createTooltipText(
288+
params,
289+
dataAllInfo,
290+
tooltipDataToDisplay,
291+
tooltipHeaders,
292+
): string {
293+
for (let i = 0; i < dataAllInfo.length; i++) {
294+
const included = dataAllInfo[i].filter(value =>
295+
params.data.includes(value),
296+
);
297+
if (included.length === params.data.length) {
298+
params.data = tooltipDataToDisplay[i];
299+
let tooltip = '';
300+
for (let i = 0; i < tooltipHeaders.length; i++) {
301+
tooltip +=
302+
'<b>' +
303+
capitalize(lowerCase(tooltipHeaders[i])) +
304+
': ' +
305+
'</b>' +
306+
params.data[i] +
307+
'<br/>';
308+
}
309+
return tooltip;
310+
}
311+
}
312+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
4+
import { TrialsGraphEchartsComponent } from './trials-graph-echarts.component';
5+
import { NgxEchartsModule } from 'ngx-echarts';
6+
7+
@NgModule({
8+
declarations: [TrialsGraphEchartsComponent],
9+
imports: [
10+
CommonModule,
11+
MatProgressSpinnerModule,
12+
NgxEchartsModule.forRoot({
13+
echarts: () => import('echarts'),
14+
}),
15+
],
16+
exports: [TrialsGraphEchartsComponent],
17+
})
18+
export class TrialsGraphEchartsModule {}

0 commit comments

Comments
 (0)