Boxplot.vue 14 KB
Newer Older
Sascha Herzinger's avatar
Sascha Herzinger committed
1
<template>
2
  <chart v-on:resize="resize">
3

4
    <control-panel class="fjs-control-panel">
Sascha Herzinger's avatar
Sascha Herzinger committed
5
6
7
8
9
10
11
12
13
14
      <data-box class="fjs-data-box"
                header="Numerical Variables"
                dataType="numerical"
                v-on:update="update_numData">
      </data-box>
      <data-box class="fjs-data-box"
                header="Categorical Variables"
                dataType="categorical"
                v-on:update="update_catData">
      </data-box>
15
16
17
18
19
20
21
22
23
24
25
26
      <hr class="fjs-seperator"/>
      <div class="fjs-parameter-container">
        <input id="fjs-show-data-check" type="checkbox" v-model="params.showData"/>
        <label for="fjs-show-data-check">Show Points</label>
        <br/>
        <input id="fjs-jitter-data-check" type="checkbox" v-model="params.jitter"/>
        <label for="fjs-jitter-data-check">Jitter Data</label>
        <br/>
        <input id="fjs-show-kde-check" type="checkbox" v-model="params.showKDE"/>
        <label for="fjs-show-kde-check">Show Density Est.</label>
      </div>
    </control-panel>
Sascha Herzinger's avatar
Sascha Herzinger committed
27

28
29
    <svg :width="width"
         :height="height">
30
      <rect x="0" y="0" :height="height" :width="width" style="opacity: 0;" @click="resetFilter"></rect>
31
32
33
34
35
      <g :transform="`translate(${margin.left}, ${margin.top})`">
        <g class="fjs-boxplot-axis fjs-x-axis" :transform="`translate(0, ${padded.height})`"></g>
        <g class="fjs-boxplot-axis fjs-y-axis"></g>
        <g class="fjs-box"
           :transform="`translate(${scales.x(label)}, 0)`"
36
           v-tooltip="{placement: 'bottom'}"
37
38
           :title="label"
           :data-label="label"
39
           @click="setIDFilter(label)"
40
41
42
43
44
           @mouseenter="showTooltip(label)"
           @mouseleave="hideTooltip(label)"
           v-for="label in labels" >
          <line class="fjs-upper-whisker"
                :title="results.statistics[label].u_wsk"
45
                v-tooltip="{placement: 'right'}"
46
                :x1="- boxplotWidth / 6"
47
                :y1="boxes[label].u_wsk"
48
                :x2="boxplotWidth / 6"
49
                :y2="boxes[label].u_wsk">
50
51
52
          </line>
          <line class="fjs-lower-whisker"
                :title="results.statistics[label].l_wsk"
53
                v-tooltip="{placement: 'right'}"
54
                :x1="- boxplotWidth / 6"
55
                :y1="boxes[label].l_wsk"
56
                :x2="boxplotWidth / 6"
57
                :y2="boxes[label].l_wsk">
58
59
60
          </line>
          <line class="fjs-upper-quartile"
                :title="results.statistics[label].u_qrt"
61
                v-tooltip="{placement: 'left'}"
62
                :x1="- boxplotWidth / 2"
63
                :y1="boxes[label].u_qrt"
64
                :x2="boxplotWidth / 2"
65
                :y2="boxes[label].u_qrt">
66
67
68
          </line>
          <line class="fjs-lower-quartile"
                :title="results.statistics[label].l_qrt"
69
                v-tooltip="{placement: 'left'}"
70
                :x1="- boxplotWidth / 2"
71
                :y1="boxes[label].l_qrt"
72
                :x2="boxplotWidth / 2"
73
                :y2="boxes[label].l_qrt">
74
75
76
          </line>
          <line class="fjs-median"
                :title="results.statistics[label].median"
77
                v-tooltip="{placement: 'right'}"
78
                :x1="- boxplotWidth / 2"
79
                :y1="boxes[label].median"
80
                :x2="boxplotWidth / 2"
81
                :y2="boxes[label].median">
82
83
84
          </line>
          <line class="fjs-antenna"
                :x1="0"
85
                :y1="boxes[label].u_wsk"
86
                :x2="0"
87
                :y2="boxes[label].l_wsk">
88
89
90
          </line>
          <rect class="fjs-above-median-box"
                :x="- boxplotWidth / 2"
91
                :y="boxes[label].u_qrt"
92
                :width="boxplotWidth"
93
                :height="boxes[label].median - boxes[label].u_qrt">
94
95
96
          </rect>
          <rect class="fjs-below-median-box"
                :x="- boxplotWidth / 2"
97
                :y="boxes[label].median"
98
                :width="boxplotWidth"
99
                :height="boxes[label].l_qrt - boxes[label].median">
100
101
102
103
104
105
106
107
108
109
110
111
112
113
          </rect>
          <circle class="fjs-points"
                  :title="point.tooltip"
                  v-tooltip="{arrow: true, theme: 'light'}"
                  :cx="point.jitter"
                  :cy="scales.y(point.value)"
                  r="0.4%"
                  v-for="point in points[label]"
                  v-if="params.showData">
          </circle>
          <polyline class="fjs-kde"
                    :points="kdePolyPoints[label]"
                    v-if="params.showKDE">
          </polyline>
Sascha Herzinger's avatar
Sascha Herzinger committed
114
        </g>
115
116
117
118
      </g>
    </svg>

  </chart>
Sascha Herzinger's avatar
Sascha Herzinger committed
119
120
121
122
</template>

<script>
  import DataBox from '../components/DataBox.vue'
Sascha Herzinger's avatar
Sascha Herzinger committed
123
124
  import ControlPanel from '../components/ControlPanel.vue'
  import Chart from '../components/Chart.vue'
Sascha Herzinger's avatar
Sascha Herzinger committed
125
  import store from '../../store/store'
Sascha Herzinger's avatar
Sascha Herzinger committed
126
  import runAnalysis from '../mixins/run-analysis'
Sascha Herzinger's avatar
Sascha Herzinger committed
127
128
  import * as d3 from 'd3'
  import deepFreeze from 'deep-freeze-strict'
Sascha Herzinger's avatar
Sascha Herzinger committed
129
  import { truncateTextUntil } from '../mixins/utils'
130
  import tooltip from '../directives/tooltip'
Sascha Herzinger's avatar
Sascha Herzinger committed
131
132
133
134
135
136
137
138
  export default {
    name: 'boxplot',
    data () {
      return {
        width: 0,
        height: 0,
        numData: [],
        catData: [],
139
        hasSetFilter: false,
140
141
142
        tooltips: {
          boxes: {}
        },
143
        params: {
Sascha Herzinger's avatar
Sascha Herzinger committed
144
145
146
          showData: false,
          jitter: false,
          showKDE: false
147
        },
Sascha Herzinger's avatar
Sascha Herzinger committed
148
149
150
151
        results: {
          data: [],
          statistics: {}
        }
Sascha Herzinger's avatar
Sascha Herzinger committed
152
153
154
      }
    },
    computed: {
155
156
157
      idFilter () {
        return store.getters.filter('ids')
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
158
159
      args () {
        return {
160
          features: this.numData,
161
          categories: this.catData,
162
          id_filter: this.idFilter,
Sascha Herzinger's avatar
Sascha Herzinger committed
163
164
165
166
167
168
169
          subsets: store.getters.subsets
        }
      },
      validArgs () {
        return this.numData.length > 0
      },
      margin () {
170
        const left = 10
Sascha Herzinger's avatar
Sascha Herzinger committed
171
        const top = 10
172
        const right = this.width / 20
Sascha Herzinger's avatar
Sascha Herzinger committed
173
        const bottom = this.height * 0.3
Sascha Herzinger's avatar
Sascha Herzinger committed
174
175
176
177
178
179
180
        return { left, top, right, bottom }
      },
      padded () {
        const width = this.width - this.margin.left - this.margin.right
        const height = this.height - this.margin.top - this.margin.bottom
        return { width, height }
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
181
      labels () {
Sascha Herzinger's avatar
Sascha Herzinger committed
182
        return Object.keys(this.results.statistics).sort()
Sascha Herzinger's avatar
Sascha Herzinger committed
183
      },
184
185
      points () {
        const points = {}
Sascha Herzinger's avatar
Sascha Herzinger committed
186
        this.labels.forEach(label => {
187
          let [feature, category, subset] = label.split('//')
188
189
          subset = parseInt(subset.substring(1)) - 1  // revert subset string formatting
          points[label] = this.results.data
190
191
192
193
            .filter(d => d.subset === subset &&
              d.feature === feature &&
              d.category === category &&
              typeof d.value === 'number')
194
195
196
            .map(d => {
              return {
                id: d.id,
197
                value: d.value,
198
199
200
201
202
203
                jitter: this.params.jitter ? Math.random() * this.boxplotWidth / 2 : this.boxplotWidth / 2,
                subset: d.subset,
                category: d.category,
                get tooltip () {
                  return `
<div>
204
  <p>${d.feature}: ${this.value}</p>
205
206
207
208
209
210
211
  <p>Category: ${this.category}</p>
  <p>Subset: ${this.subset + 1}</p>
</div>
`
                }
              }
            })
212
213
214
        })
        return points
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
215
216
217
218
219
220
221
222
223
224
225
226
227
      boxes () {
        const boxes = {}
        this.labels.forEach(label => {
          boxes[label] = {
            u_wsk: this.scales.y(this.results.statistics[label].u_wsk),
            l_wsk: this.scales.y(this.results.statistics[label].l_wsk),
            u_qrt: this.scales.y(this.results.statistics[label].u_qrt),
            l_qrt: this.scales.y(this.results.statistics[label].l_qrt),
            median: this.scales.y(this.results.statistics[label].median)
          }
        })
        return boxes
      },
228
      numOfBoxplots () {
Sascha Herzinger's avatar
Sascha Herzinger committed
229
        return this.labels.length
230
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
231
      boxplotWidth () {
232
        const maxBoxplotWidth = this.padded.width / 4
233
        const minBoxplotWidth = 10
234
235
        let boxplotWidth = this.padded.width / this.numOfBoxplots - this.padded.width * 0.05
        boxplotWidth = boxplotWidth > maxBoxplotWidth ? maxBoxplotWidth : boxplotWidth
236
        boxplotWidth = boxplotWidth < minBoxplotWidth ? minBoxplotWidth : boxplotWidth
237
238
239
        return boxplotWidth
      },
      scales () {
240
        const values = this.results.data.map(d => d.value)
241
242
        const flattened = [].concat.apply([], values)
        const extent = d3.extent(flattened)
Sascha Herzinger's avatar
Sascha Herzinger committed
243
        const padding = (extent[1] - extent[0]) / 20
Sascha Herzinger's avatar
Sascha Herzinger committed
244
        const x = d3.scalePoint()
Sascha Herzinger's avatar
Sascha Herzinger committed
245
          .domain(this.labels)
Sascha Herzinger's avatar
Sascha Herzinger committed
246
          .range([0, this.padded.width])
247
          .padding(0.5)
248
        const y = d3.scaleLinear()
Sascha Herzinger's avatar
Sascha Herzinger committed
249
          .domain([extent[0] - padding, extent[1] + padding])
Sascha Herzinger's avatar
Sascha Herzinger committed
250
          .range([this.padded.height, 0])
Sascha Herzinger's avatar
Sascha Herzinger committed
251
252
        return { x, y }
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
      kdePolyPoints () {
        const polyPoints = {}
        this.labels.forEach(label => {
          polyPoints[label] = this.results.statistics[label].kde.map((d, i) => {
            return -this.kdeScales[label].y(d) + ',' + this.kdeScales[label].x(i)
          }).join(' ')
        })
        return polyPoints
      },
      kdeScales () {
        const polyPoints = {}
        this.labels.forEach(label => {
          const x = d3.scaleLinear()
            .domain([0, this.results.statistics[label].kde.length - 1])
            .range([this.scales.y(this.results.statistics[label].l_wsk),
              this.scales.y(this.results.statistics[label].u_wsk)])
          const y = d3.scaleLinear()
            .domain(d3.extent(this.results.statistics[label].kde))
            .range([0, this.boxplotWidth / 2])
          polyPoints[label] = { x, y }
        })
        return polyPoints
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
276
      axis () {
Sascha Herzinger's avatar
Sascha Herzinger committed
277
278
        const x = d3.axisBottom(this.scales.x).tickFormat(d => {
          // noinspection JSSuspiciousNameCombination
279
          return truncateTextUntil({text: d, font: `0.875em Roboto`, maxWidth: this.margin.bottom})
Sascha Herzinger's avatar
Sascha Herzinger committed
280
        })
281
282
        const y = d3.axisRight(this.scales.y)
          .tickSizeInner(this.padded.width)
283
        return { x, y }
Sascha Herzinger's avatar
Sascha Herzinger committed
284
285
      }
    },
286
287
288
    // IMPORTANT: If the code within the watchers does interact with the DOM the code should be wrapped into a $nextTick
    // statement. This helps with the integration into the Vue component lifecycle. E.g.: an animation can't be
    // applied to an element that does not exist yet.
Sascha Herzinger's avatar
Sascha Herzinger committed
289
290
    watch: {
      'args': {
291
        handler: function () {
292
          if (this.validArgs && !this.hasSetFilter) {
Sascha Herzinger's avatar
Sascha Herzinger committed
293
            this.runAnalysisWrapper(this.args)
Sascha Herzinger's avatar
Sascha Herzinger committed
294
          }
295
          this.hasSetFilter = false
Sascha Herzinger's avatar
Sascha Herzinger committed
296
        }
Sascha Herzinger's avatar
Sascha Herzinger committed
297
298
299
      },
      'axis': {
        handler: function (newAxis) {
300
          this.$nextTick(() => {
Sascha Herzinger's avatar
Sascha Herzinger committed
301
            d3.select(this.$el.querySelector('.fjs-x-axis'))
302
303
304
              .call(newAxis.x)
              .selectAll('text')
              .attr('transform', 'rotate(20)')
Sascha Herzinger's avatar
Sascha Herzinger committed
305
            d3.select(this.$el.querySelector('.fjs-y-axis'))
306
307
308
              .call(newAxis.y)
          })
        }
Sascha Herzinger's avatar
Sascha Herzinger committed
309
310
311
      }
    },
    methods: {
312
      getTippyInstances (label) {
313
314
        const event = document.createEvent('Event')
        event.initEvent('mouseover', true, true)
315
        return [
Sascha Herzinger's avatar
Sascha Herzinger committed
316
317
318
319
320
          this.$el.querySelector(`.fjs-box[data-label="${label}"] .fjs-upper-whisker`),
          this.$el.querySelector(`.fjs-box[data-label="${label}"] .fjs-lower-whisker`),
          this.$el.querySelector(`.fjs-box[data-label="${label}"] .fjs-upper-quartile`),
          this.$el.querySelector(`.fjs-box[data-label="${label}"] .fjs-lower-quartile`),
          this.$el.querySelector(`.fjs-box[data-label="${label}"] .fjs-median`)
321
        ].map(el => {
322
          el.dispatchEvent(event)  // populate tooltips
323
324
          const uuid = el.getAttribute('data-uuid')
          return { el, tip: this._tippyInstances[uuid] }
325
326
        })
      },
327
      showTooltip (label) {
328
        this.getTippyInstances(label).forEach(d => d.el._tippy.show())
329
      },
330
      hideTooltip (label) {
331
        this.getTippyInstances(label).forEach(d => d.el._tippy.hide())
332
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
333
334
335
336
337
338
      update_numData (ids) {
        this.numData = ids
      },
      update_catData (ids) {
        this.catData = ids
      },
339
340
341
342
343
344
345
346
      setIDFilter (label) {
        store.dispatch('setFilter', {filter: 'ids', value: this.points[label].map(d => d.id)})
        this.hasSetFilter = true
      },
      resetFilter () {
        store.dispatch('setFilter', {filter: 'ids', value: []})
        this.hasSetFilter = true
      },
347
348
349
      resize ({height, width}) {
        this.height = height
        this.width = width
Sascha Herzinger's avatar
Sascha Herzinger committed
350
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
351
      runAnalysisWrapper (args) {
Sascha Herzinger's avatar
Sascha Herzinger committed
352
        runAnalysis({task_name: 'compute-boxplot', args})
Sascha Herzinger's avatar
Sascha Herzinger committed
353
354
          .then(response => {
            const results = JSON.parse(response)
355
            results.data = JSON.parse(results.data)
Sascha Herzinger's avatar
Sascha Herzinger committed
356
357
358
359
360
361
362
            deepFreeze(results) // massively improve performance by telling Vue that the objects properties won't change
            this.results = results
          })
          .catch(error => console.error(error))
      }
    },
    components: {
363
      ControlPanel,
Sascha Herzinger's avatar
Sascha Herzinger committed
364
365
      DataBox,
      Chart
Sascha Herzinger's avatar
Sascha Herzinger committed
366
    },
367
368
    directives: {
      tooltip
Sascha Herzinger's avatar
Sascha Herzinger committed
369
370
371
372
373
    }
  }
</script>

<style lang="sass" scoped>
374
  @import '~assets/base.sass'
Sascha Herzinger's avatar
Sascha Herzinger committed
375

376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
  svg
    .fjs-box
      .fjs-median, .fjs-lower-quartile, .fjs-upper-quartile
        opacity: 1
      .fjs-lower-whisker, .fjs-upper-whisker, .fjs-antenna
        shape-rendering: crispEdges
        stroke: black
        stroke-width: 1px
      .fjs-below-median-box
        stroke: none
        fill: rgb(205, 232, 254)
        shape-rendering: crispEdges
      .fjs-above-median-box
        stroke: none
        fill: rgb(180, 221, 253)
        shape-rendering: crispEdges
    .fjs-points
      stroke: white
      stroke-width: 1px
    .fjs-points:hover
      opacity: 0.4
    .fjs-kde
      fill: none
      stroke: black
      stroke-width: 0.2%
Sascha Herzinger's avatar
Sascha Herzinger committed
401
402
403
404
405
</style>


<!--CSS for dynamically created components-->
<style lang="sass">
406
  .fjs-boxplot-axis
Sascha Herzinger's avatar
Sascha Herzinger committed
407
408
409
    shape-rendering: crispEdges
    .tick
      shape-rendering: crispEdges
Sascha Herzinger's avatar
Sascha Herzinger committed
410
      text
411
        font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
412
    line
413
414
415
      stroke: #E2E2E2
    path
      stroke: none
416
417
418
419
    .fjs-x-axis
      .tick
        text
          text-anchor: start
420
          font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
421
</style>