Para.tsx 13.6 KB
Newer Older
Lijiaoa's avatar
Lijiaoa committed
1
import * as React from 'react';
2
import * as d3 from 'd3';
3
import { Dropdown, IDropdownOption, Stack, DefaultButton } from '@fluentui/react';
4
import ParCoords from 'parcoord-es';
Lijiaoa's avatar
Lijiaoa committed
5
6
import { SearchSpace } from '@model/searchspace';
import { EXPERIMENT, TRIALS } from '@static/datamodel';
7
8
import { SingleAxis, MultipleAxes } from '@static/interface';
import { Trial } from '@model/trial';
Lijiaoa's avatar
Lijiaoa committed
9
import ChangeColumnComponent from '../ChangeColumnComponent';
Lijiaoa's avatar
Lijiaoa committed
10
import { optimizeModeValue } from './optimizeMode';
11
import { getValue } from '@model/localStorage';
Lijiaoa's avatar
Lijiaoa committed
12

13
import 'parcoord-es/dist/parcoords.css';
Lijiaoa's avatar
Lijiaoa committed
14
15
import '@style/button.scss';
import '@style/experiment/trialdetail/para.scss';
16

Deshui Yu's avatar
Deshui Yu committed
17
interface ParaState {
18
    dimName: string[];
19
    selectedPercent: string;
20
    userSelectOptimizeMode: string;
21
22
    primaryMetricKey: string;
    noChart: boolean;
23
24
25
    customizeColumnsDialogVisible: boolean;
    availableDimensions: string[];
    chosenDimensions: string[];
Deshui Yu's avatar
Deshui Yu committed
26
27
}

28
interface ParaProps {
29
    trials: Trial[];
30
    searchSpace: SearchSpace;
Lijiao's avatar
Lijiao committed
31
32
}

33
class Para extends React.Component<ParaProps, ParaState> {
34
35
36
    private paraRef = React.createRef<HTMLDivElement>();
    private pcs: any;

37
38
39
    private chartMulineStyle = {
        width: '100%',
        height: 392,
40
41
42
43
44
45
46
        margin: '0 auto'
    };
    private innerChartMargins = {
        top: 32,
        right: 20,
        bottom: 20,
        left: 28
47
48
49
    };

    constructor(props: ParaProps) {
Deshui Yu's avatar
Deshui Yu committed
50
51
52
        super(props);
        this.state = {
            dimName: [],
53
54
            primaryMetricKey: 'default',
            selectedPercent: '1',
Lijiaoa's avatar
Lijiaoa committed
55
            userSelectOptimizeMode: optimizeModeValue(EXPERIMENT.optimizeMode),
56
57
58
            noChart: true,
            customizeColumnsDialogVisible: false,
            availableDimensions: [],
59
            chosenDimensions:
60
61
                localStorage.getItem(`${EXPERIMENT.profile.id}_paraColumns`) !== null &&
                getValue(`${EXPERIMENT.profile.id}_paraColumns`) !== null
62
                    ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
63
                      JSON.parse(getValue(`${EXPERIMENT.profile.id}_paraColumns`)!)
64
                    : []
Deshui Yu's avatar
Deshui Yu committed
65
66
        };
    }
67

Deshui Yu's avatar
Deshui Yu committed
68
    // get percent value number
69
70
    percentNum = (event: React.FormEvent<HTMLDivElement>, item?: IDropdownOption): void => {
        if (item !== undefined) {
71
72
            this.setState({ selectedPercent: item.key.toString() }, () => {
                this.renderParallelCoordinates();
73
74
            });
        }
75
    };
Deshui Yu's avatar
Deshui Yu committed
76

77
78
79
80
81
82
83
84
85
    // get user mode number 'max' or 'min'
    updateUserOptimizeMode = (event: React.FormEvent<HTMLDivElement>, item?: IDropdownOption): void => {
        if (item !== undefined) {
            this.setState({ userSelectOptimizeMode: item.key.toString() }, () => {
                this.renderParallelCoordinates();
            });
        }
    };

86
87
88
    // select all final keys
    updateEntries = (event: React.FormEvent<HTMLDivElement>, item: any): void => {
        if (item !== undefined) {
89
90
91
            this.setState({ primaryMetricKey: item.key }, () => {
                this.renderParallelCoordinates();
            });
92
        }
93
    };
94

Lijiao's avatar
Lijiao committed
95
    componentDidMount(): void {
96
        this.renderParallelCoordinates();
97
98
    }

99
    componentDidUpdate(prevProps: ParaProps): void {
100
101
        // FIXME: redundant update
        if (this.props.trials !== prevProps.trials || this.props.searchSpace !== prevProps.searchSpace) {
102
            this.renderParallelCoordinates();
103
104
        }
    }
Deshui Yu's avatar
Deshui Yu committed
105

Lijiao's avatar
Lijiao committed
106
    render(): React.ReactNode {
107
108
109
110
111
112
113
114
        const {
            selectedPercent,
            noChart,
            customizeColumnsDialogVisible,
            availableDimensions,
            chosenDimensions,
            userSelectOptimizeMode
        } = this.state;
115

Deshui Yu's avatar
Deshui Yu committed
116
        return (
117
118
            <div className='parameter'>
                <Stack horizontal className='para-filter' horizontalAlign='end'>
119
120
121
122
123
124
125
                    <DefaultButton
                        text='Add/Remove axes'
                        onClick={(): void => {
                            this.setState({ customizeColumnsDialogVisible: true });
                        }}
                        styles={{ root: { marginRight: 10 } }}
                    />
126
127
128
129
130
131
132
133
134
135
                    <Dropdown
                        selectedKey={userSelectOptimizeMode}
                        onChange={this.updateUserOptimizeMode}
                        options={[
                            { key: 'maximize', text: 'Maximize' },
                            { key: 'minimize', text: 'Minimize' }
                        ]}
                        styles={{ dropdown: { width: 100 } }}
                        className='para-filter-percent'
                    />
136
                    <Dropdown
137
                        selectedKey={selectedPercent}
138
139
                        onChange={this.percentNum}
                        options={[
140
141
142
                            { key: '0.01', text: 'Top 1%' },
                            { key: '0.05', text: 'Top 5%' },
                            { key: '0.2', text: 'Top 20%' },
143
                            { key: '1', text: 'Top 100%' }
144
                        ]}
145
                        styles={{ dropdown: { width: 120 } }}
146
                        className='para-filter-percent'
147
                    />
148
                    {this.finalKeysDropdown()}
149
                </Stack>
150
151
152
153
154
155
156
157
158
159
160
161
162
                {customizeColumnsDialogVisible && availableDimensions.length > 0 && (
                    <ChangeColumnComponent
                        selectedColumns={chosenDimensions}
                        allColumns={availableDimensions.map(dim => ({ key: dim, name: dim }))}
                        onSelectedChange={(selected: string[]): void => {
                            this.setState({ chosenDimensions: selected }, () => {
                                this.renderParallelCoordinates();
                            });
                        }}
                        onHideDialog={(): void => {
                            this.setState({ customizeColumnsDialogVisible: false });
                        }}
                        minSelected={2}
163
                        whichComponent='para'
164
165
                    />
                )}
166
167
                <div className='parcoords' style={this.chartMulineStyle} ref={this.paraRef} />
                {noChart && <div className='nodata'>No data</div>}
168
            </div>
Deshui Yu's avatar
Deshui Yu committed
169
170
        );
    }
171

172
173
    private finalKeysDropdown(): any {
        const { primaryMetricKey } = this.state;
174
175
176
177
178
179
        if (TRIALS.finalKeys().length === 1) {
            return null;
        } else {
            const finalKeysDropdown: any = [];
            TRIALS.finalKeys().forEach(item => {
                finalKeysDropdown.push({
180
181
                    key: item,
                    text: item
182
183
184
185
                });
            });
            return (
                <div>
186
                    <span className='para-filter-text para-filter-middle'>Metrics</span>
187
                    <Dropdown
188
                        selectedKey={primaryMetricKey}
189
190
191
                        options={finalKeysDropdown}
                        onChange={this.updateEntries}
                        styles={{ root: { width: 150, display: 'inline-block' } }}
192
                        className='para-filter-percent'
193
194
195
196
                    />
                </div>
            );
        }
197
    }
198

199
200
201
202
203
204
205
206
207
    /**
     * Render the parallel coordinates. Using trial data as base and leverage
     * information from search space at a best effort basis.
     * @param source Array of trial data
     * @param searchSpace Search space
     */
    private renderParallelCoordinates(): void {
        const { searchSpace } = this.props;
        const percent = parseFloat(this.state.selectedPercent);
208
        const { primaryMetricKey, chosenDimensions, userSelectOptimizeMode } = this.state;
209
210
211
212
213

        const inferredSearchSpace = TRIALS.inferredSearchSpace(searchSpace);
        const inferredMetricSpace = TRIALS.inferredMetricSpace();
        let convertedTrials = this.getTrialsAsObjectList(inferredSearchSpace, inferredMetricSpace);

214
        const dimensions: [string, any][] = [];
215
216
        let colorDim: string | undefined = undefined,
            colorScale: any = undefined;
217
218
        // treat every axis as numeric to fit for brush
        for (const [k, v] of inferredSearchSpace.axes) {
219
220
221
222
223
224
225
            dimensions.push([
                k,
                {
                    type: 'number',
                    yscale: this.convertToD3Scale(v)
                }
            ]);
226
227
228
229
230
        }
        for (const [k, v] of inferredMetricSpace.axes) {
            const scale = this.convertToD3Scale(v);
            if (k === primaryMetricKey && scale !== undefined && scale.interpolate) {
                // set color for primary metrics
231
232
                // `colorScale` is used to produce a color range, while `scale` is to produce a pixel range
                colorScale = this.convertToD3Scale(v, false);
233
                convertedTrials.sort((a, b) => (userSelectOptimizeMode === 'minimize' ? a[k] - b[k] : b[k] - a[k]));
234
235
236
237
238
239
                // filter top trials
                if (percent != 1) {
                    const keptTrialNum = Math.max(Math.ceil(convertedTrials.length * percent), 1);
                    convertedTrials = convertedTrials.slice(0, keptTrialNum);
                    const domain = d3.extent(convertedTrials, item => item[k]);
                    scale.domain([domain[0], domain[1]]);
240
                    colorScale.domain([domain[0], domain[1]]);
241
242
243
244
                    if (colorScale !== undefined) {
                        colorScale.domain(domain);
                    }
                }
245
246
247
                // reverse the converted trials to show the top ones upfront
                convertedTrials.reverse();
                const assignColors = (scale: any): void => {
248
                    scale.range([0, 1]); // fake a range to perform invert
249
250
                    const [scaleMin, scaleMax] = scale.domain();
                    const pivot = scale.invert(0.5);
251
252
                    scale
                        .domain([scaleMin, pivot, scaleMax])
253
254
255
256
257
                        .range(['#90EE90', '#FFC400', '#CA0000'])
                        .interpolate(d3.interpolateHsl);
                };
                assignColors(colorScale);
                colorDim = k;
258
            }
259
260
261
262
263
264
265
            dimensions.push([
                k,
                {
                    type: 'number',
                    yscale: scale
                }
            ]);
266
267
        }

Lijiaoa's avatar
Lijiaoa committed
268
        if (convertedTrials.length === 0 || dimensions.length <= 1) {
269
270
271
272
273
274
275
            return;
        }

        const firstRun = this.pcs === undefined;
        if (firstRun) {
            this.pcs = ParCoords()(this.paraRef.current);
        }
276
277
        this.pcs
            .data(convertedTrials)
278
279
280
281
282
            .dimensions(
                dimensions
                    .filter(([d, _]) => chosenDimensions.length === 0 || chosenDimensions.includes(d))
                    .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {})
            );
283
        if (firstRun) {
284
285
            this.pcs
                .margin(this.innerChartMargins)
286
287
                .alphaOnBrushed(0.2)
                .smoothness(0.1)
288
                .brushMode('1D-axes')
289
290
291
292
293
294
295
296
297
298
                .reorderable()
                .interactive();
        }
        if (colorScale !== undefined) {
            this.pcs.color(d => (colorScale as any)(d[colorDim as any]));
        }
        this.pcs.render();
        if (firstRun) {
            this.setState({ noChart: false });
        }
299
300
301
302
303
304

        // set new available dims
        this.setState({
            availableDimensions: dimensions.map(e => e[0]),
            chosenDimensions: chosenDimensions.length === 0 ? dimensions.map(e => e[0]) : chosenDimensions
        });
305
306
307
308
309
    }

    private getTrialsAsObjectList(inferredSearchSpace: MultipleAxes, inferredMetricSpace: MultipleAxes): {}[] {
        const { trials } = this.props;

310
        return trials.map(s => {
311
            const entries = Array.from(s.parameters(inferredSearchSpace).entries());
312
            entries.push(...Array.from(s.metrics(inferredMetricSpace).entries()));
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
            const ret = {};
            for (const [k, v] of entries) {
                ret[k.fullName] = v;
            }
            return ret;
        });
    }

    private getRange(): [number, number] {
        // Documentation is lacking.
        // Reference: https://github.com/syntagmatic/parallel-coordinates/issues/308
        // const range = this.pcs.height() - this.pcs.margin().top - this.pcs.margin().bottom;
        const range = this.chartMulineStyle.height - this.innerChartMargins.top - this.innerChartMargins.bottom;
        return [range, 1];
    }
328

329
330
    private convertToD3Scale(axis: SingleAxis, initRange: boolean = true): any {
        const padLinear = ([x0, x1], k = 0.1): [number, number] => {
331
            const dx = ((x1 - x0) * k) / 2;
332
333
334
335
336
            return [x0 - dx, x1 + dx];
        };
        const padLog = ([x0, x1], k = 0.1): [number, number] => {
            const [y0, y1] = padLinear([Math.log(x0), Math.log(x1)], k);
            return [Math.exp(y0), Math.exp(y1)];
337
        };
338
339
340
341
        let scaleInst: any = undefined;
        if (axis.scale === 'ordinal') {
            if (axis.nested) {
                // TODO: handle nested entries
342
                scaleInst = d3.scalePoint().domain(Array.from(axis.domain.keys())).padding(0.2);
343
            } else {
344
                scaleInst = d3.scalePoint().domain(axis.domain).padding(0.2);
345
346
347
348
349
350
351
352
353
354
355
            }
        } else if (axis.scale === 'log') {
            scaleInst = d3.scaleLog().domain(padLog(axis.domain));
        } else if (axis.scale === 'linear') {
            scaleInst = d3.scaleLinear().domain(padLinear(axis.domain));
        }
        if (initRange) {
            scaleInst = scaleInst.range(this.getRange());
        }
        return scaleInst;
    }
Deshui Yu's avatar
Deshui Yu committed
356
357
}

358
export default Para;