Boxplot.vue 15.6 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" name="Boxplot 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
      <hr class="fjs-seperator"/>
      <div class="fjs-parameter-container">
17
        <label>
18
          <input type="checkbox" v-model="params.showData"/>
19
20
          Show Points
        </label>
21
        <br/>
22
        <label>
23
          <input type="checkbox" v-model="params.jitter"/>
24
25
          Jitter Data
        </label>
26
        <br/>
27
        <label>
28
          <input type="checkbox" v-model="params.showKDE"/>
29
30
          Show Density Est.
        </label>
31
32
      </div>
    </control-panel>
Sascha Herzinger's avatar
Sascha Herzinger committed
33

34
    <svg :width="width" :height="height">
35
      <rect x="0" y="0" :height="height" :width="width" style="opacity: 0;" @click="resetFilter"></rect>
36
      <g :transform="`translate(${margin.left}, ${margin.top})`">
37
38
39
        <text :x="this.padded.width / 2"
              class="fjs-anova-results"
              v-if="Object.keys(this.results.anova).length">
40
41
          ANOVA -- F-value: {{ anova.fValue }}
          &nbsp p-value: {{ anova.pValue }}
42
        </text>
43
44
        <g class="fjs-boxplot-axis fjs-x-axis" ref="xAxis" :transform="`translate(0, ${padded.height})`"></g>
        <g class="fjs-boxplot-axis fjs-y-axis" ref="yAxis"></g>
45
46
        <g class="fjs-box"
           :transform="`translate(${scales.x(label)}, 0)`"
47
           v-tooltip="{placement: 'bottom'}"
48
           :title="label"
49
           @click="setIDFilter(label)"
50
51
           @mouseenter="showTooltip(label)"
           @mouseleave="hideTooltip(label)"
52
           v-for="label in labels">
53
          <line class="fjs-upper-whisker"
54
                :ref="`${label}-upper-whisker`"
55
                :title="results.statistics[label].u_wsk"
56
                v-tooltip="{placement: 'right'}"
57
                :x1="- boxplotWidth / 6"
58
                :y1="boxes[label].u_wsk"
59
                :x2="boxplotWidth / 6"
60
                :y2="boxes[label].u_wsk">
61
62
          </line>
          <line class="fjs-lower-whisker"
63
                :ref="`${label}-lower-whisker`"
64
                :title="results.statistics[label].l_wsk"
65
                v-tooltip="{placement: 'right'}"
66
                :x1="- boxplotWidth / 6"
67
                :y1="boxes[label].l_wsk"
68
                :x2="boxplotWidth / 6"
69
                :y2="boxes[label].l_wsk">
70
71
          </line>
          <line class="fjs-upper-quartile"
72
                :ref="`${label}-upper-quartile`"
73
                :title="results.statistics[label].u_qrt"
74
                v-tooltip="{placement: 'left'}"
75
                :x1="- boxplotWidth / 2"
76
                :y1="boxes[label].u_qrt"
77
                :x2="boxplotWidth / 2"
78
                :y2="boxes[label].u_qrt">
79
80
          </line>
          <line class="fjs-lower-quartile"
81
                :ref="`${label}-lower-quartile`"
82
                :title="results.statistics[label].l_qrt"
83
                v-tooltip="{placement: 'left'}"
84
                :x1="- boxplotWidth / 2"
85
                :y1="boxes[label].l_qrt"
86
                :x2="boxplotWidth / 2"
87
                :y2="boxes[label].l_qrt">
88
89
          </line>
          <line class="fjs-median"
90
                :ref="`${label}-median`"
91
                :title="results.statistics[label].median"
92
                v-tooltip="{placement: 'right'}"
93
                :x1="- boxplotWidth / 2"
94
                :y1="boxes[label].median"
95
                :x2="boxplotWidth / 2"
96
                :y2="boxes[label].median">
97
98
99
          </line>
          <line class="fjs-antenna"
                :x1="0"
100
                :y1="boxes[label].u_wsk"
101
                :x2="0"
102
                :y2="boxes[label].l_wsk">
103
104
105
          </line>
          <rect class="fjs-above-median-box"
                :x="- boxplotWidth / 2"
106
                :y="boxes[label].u_qrt"
107
                :width="boxplotWidth"
108
                :height="boxes[label].median - boxes[label].u_qrt">
109
110
111
          </rect>
          <rect class="fjs-below-median-box"
                :x="- boxplotWidth / 2"
112
                :y="boxes[label].median"
113
                :width="boxplotWidth"
114
                :height="boxes[label].l_qrt - boxes[label].median">
115
          </rect>
116
117
118
119
120
          <image :href="dataUrls[label]"
                 :data-label="label"
                 :height="padded.height"
                 :width="boxplotWidth / 2">
          </image>
121
122
          <polyline class="fjs-kde"
                    :points="kdePolyPoints[label]"
123
                    v-if="params.showKDE">
124
          </polyline>
Sascha Herzinger's avatar
Sascha Herzinger committed
125
        </g>
126
127
128
129
      </g>
    </svg>

  </chart>
Sascha Herzinger's avatar
Sascha Herzinger committed
130
131
132
133
</template>

<script>
  import DataBox from '../components/DataBox.vue'
Sascha Herzinger's avatar
Sascha Herzinger committed
134
135
  import ControlPanel from '../components/ControlPanel.vue'
  import Chart from '../components/Chart.vue'
Sascha Herzinger's avatar
Sascha Herzinger committed
136
  import store from '../../store/store'
Sascha Herzinger's avatar
Sascha Herzinger committed
137
  import runAnalysis from '../mixins/run-analysis'
Sascha Herzinger's avatar
Sascha Herzinger committed
138
139
  import * as d3 from 'd3'
  import deepFreeze from 'deep-freeze-strict'
Sascha Herzinger's avatar
Sascha Herzinger committed
140
  import { truncateTextUntil } from '../mixins/utils'
141
  import tooltip from '../directives/tooltip'
142
  import StateSaver from '../mixins/state-saver'
143
  import getHDPICanvas from '../mixins/high-dpi-canvas'
Sascha Herzinger's avatar
Sascha Herzinger committed
144
145
146
147
  export default {
    name: 'boxplot',
    data () {
      return {
148
149
150
151
152
153
        numData: [],
        catData: [],
        params: {
          showData: false,
          jitter: false,
          showKDE: false
154
        },
Sascha Herzinger's avatar
Sascha Herzinger committed
155
156
        width: 0,
        height: 0,
157
        hasSetFilter: false,
158
159
160
        tooltips: {
          boxes: {}
        },
Sascha Herzinger's avatar
Sascha Herzinger committed
161
162
        results: {
          data: [],
163
164
          statistics: {},
          anova: {}
165
166
        },
        dataUrls: {}
Sascha Herzinger's avatar
Sascha Herzinger committed
167
168
169
      }
    },
    computed: {
170
171
172
      idFilter () {
        return store.getters.filter('ids')
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
173
174
      args () {
        return {
175
176
          features: this.numData,
          categories: this.catData,
177
          id_filter: this.idFilter,
Sascha Herzinger's avatar
Sascha Herzinger committed
178
179
180
          subsets: store.getters.subsets
        }
      },
181
182
183
      pointSize () {
        return this.width / 150
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
184
      validArgs () {
185
        return this.numData.length > 0
Sascha Herzinger's avatar
Sascha Herzinger committed
186
187
      },
      margin () {
188
        const left = 10
189
        const top = this.height / 20
190
        const right = this.width / 20
191
        const bottom = this.height / 3
Sascha Herzinger's avatar
Sascha Herzinger committed
192
193
194
195
196
197
198
        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
199
      labels () {
Sascha Herzinger's avatar
Sascha Herzinger committed
200
        return Object.keys(this.results.statistics).sort()
Sascha Herzinger's avatar
Sascha Herzinger committed
201
      },
202
203
204
205
206
207
208
      canvas () {
        const canvas = {}
        this.labels.forEach(label => {
          canvas[label] = getHDPICanvas(this.boxplotWidth / 2, this.padded.height)
        })
        return canvas
      },
209
210
      points () {
        const points = {}
Sascha Herzinger's avatar
Sascha Herzinger committed
211
        this.labels.forEach(label => {
212
          let [feature, category, subset] = label.split('//')
213
          subset = parseInt(subset.substring(1)) - 1 // revert subset string formatting
214
          points[label] = this.results.data
215
216
217
218
            .filter(d => d.subset === subset &&
              d.feature === feature &&
              d.category === category &&
              typeof d.value === 'number')
219
220
221
            .map(d => {
              return {
                id: d.id,
222
                value: d.value,
223
                jitter: Math.max(this.pointSize / 2, (this.params.jitter ? Math.random() * this.boxplotWidth / 2 : this.boxplotWidth / 2) - this.pointSize / 2),
224
                subset: d.subset,
225
                category: d.category
226
227
              }
            })
228
229
230
        })
        return points
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
231
232
233
234
235
236
237
238
239
240
241
242
243
      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
      },
244
      numOfBoxplots () {
Sascha Herzinger's avatar
Sascha Herzinger committed
245
        return this.labels.length
246
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
247
      boxplotWidth () {
248
        const maxBoxplotWidth = this.padded.width / 4
249
        const minBoxplotWidth = 10
250
251
        let boxplotWidth = this.padded.width / this.numOfBoxplots - this.padded.width * 0.05
        boxplotWidth = boxplotWidth > maxBoxplotWidth ? maxBoxplotWidth : boxplotWidth
252
        boxplotWidth = boxplotWidth < minBoxplotWidth ? minBoxplotWidth : boxplotWidth
253
254
255
        return boxplotWidth
      },
      scales () {
256
        const values = this.results.data.map(d => d.value)
257
258
        const flattened = [].concat.apply([], values)
        const extent = d3.extent(flattened)
Sascha Herzinger's avatar
Sascha Herzinger committed
259
        const padding = (extent[1] - extent[0]) / 20
Sascha Herzinger's avatar
Sascha Herzinger committed
260
        const x = d3.scalePoint()
Sascha Herzinger's avatar
Sascha Herzinger committed
261
          .domain(this.labels)
Sascha Herzinger's avatar
Sascha Herzinger committed
262
          .range([0, this.padded.width])
263
          .padding(0.5)
264
        const y = d3.scaleLinear()
Sascha Herzinger's avatar
Sascha Herzinger committed
265
          .domain([extent[0] - padding, extent[1] + padding])
Sascha Herzinger's avatar
Sascha Herzinger committed
266
          .range([this.padded.height, 0])
Sascha Herzinger's avatar
Sascha Herzinger committed
267
268
        return { x, y }
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
      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
292
      axis () {
Sascha Herzinger's avatar
Sascha Herzinger committed
293
294
        const x = d3.axisBottom(this.scales.x).tickFormat(d => {
          // noinspection JSSuspiciousNameCombination
295
          return truncateTextUntil({text: d, font: `0.875em Roboto`, maxWidth: this.margin.bottom})
Sascha Herzinger's avatar
Sascha Herzinger committed
296
        })
297
298
        const y = d3.axisRight(this.scales.y)
          .tickSizeInner(this.padded.width)
299
        return { x, y }
300
301
302
303
304
      },
      anova () {
        let fValue = this.results.anova.f_value == null ? 'NaN' : this.results.anova.f_value.toFixed(4)
        let pValue = this.results.anova.p_value == null ? 'NaN' : this.results.anova.p_value.toFixed(4)
        return {pValue, fValue}
Sascha Herzinger's avatar
Sascha Herzinger committed
305
306
      }
    },
307
308
309
    // 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
310
311
    watch: {
      'args': {
312
        handler: function () {
313
          if (this.validArgs && !this.hasSetFilter) {
Sascha Herzinger's avatar
Sascha Herzinger committed
314
            this.runAnalysisWrapper(this.args)
Sascha Herzinger's avatar
Sascha Herzinger committed
315
          }
316
          this.hasSetFilter = false
Sascha Herzinger's avatar
Sascha Herzinger committed
317
        }
Sascha Herzinger's avatar
Sascha Herzinger committed
318
319
320
      },
      'axis': {
        handler: function (newAxis) {
321
          this.$nextTick(() => {
322
            d3.select(this.$refs.xAxis)
323
324
325
              .call(newAxis.x)
              .selectAll('text')
              .attr('transform', 'rotate(20)')
326
            d3.select(this.$refs.yAxis)
327
328
329
              .call(newAxis.y)
          })
        }
330
      },
331
      'params.showData': {
332
        handler: function () { this.$nextTick(() => this.drawPoints()) }
333
      },
334
      'params.jitter': {
335
336
337
338
        handler: function () { this.$nextTick(() => this.drawPoints()) }
      },
      'points': {
        handler: function () { this.$nextTick(() => this.drawPoints()) }
Sascha Herzinger's avatar
Sascha Herzinger committed
339
340
341
      }
    },
    methods: {
342
      getTippyInstances (label) {
343
        // prepare mouseover event to populare tippy instances (s. tooltip.js)
344
345
        const event = document.createEvent('Event')
        event.initEvent('mouseover', true, true)
346
        return [
347
348
349
350
351
          this.$refs[`${label}-upper-whisker`][0],
          this.$refs[`${label}-lower-whisker`][0],
          this.$refs[`${label}-upper-quartile`][0],
          this.$refs[`${label}-lower-quartile`][0],
          this.$refs[`${label}-median`][0]
352
        ].map(el => {
353
354
          el.dispatchEvent(event)
          return el._tippy
355
356
        })
      },
357
      showTooltip (label) {
358
        this.getTippyInstances(label).forEach(d => d.show())
359
      },
360
      hideTooltip (label) {
361
        this.getTippyInstances(label).forEach(d => d.hide())
362
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
363
      update_numData (ids) {
364
        this.numData = ids
Sascha Herzinger's avatar
Sascha Herzinger committed
365
366
      },
      update_catData (ids) {
367
        this.catData = ids
Sascha Herzinger's avatar
Sascha Herzinger committed
368
      },
369
370
371
372
373
374
375
376
      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
      },
377
      drawPoints () {
378
379
        this.labels.forEach(label => {
          const canvas = this.canvas[label]
380
381
          const ctx = canvas.getContext('2d')
          ctx.clearRect(0, 0, canvas.width, canvas.height)
382
          if (this.params.showData) {
383
384
385
386
387
388
389
390
391
392
393
            this.points[label].forEach(point => {
              ctx.beginPath()
              ctx.fillStyle = 'black'
              ctx.fillRect(
                point.jitter - this.pointSize / 2,
                this.scales.y(point.value) - this.pointSize / 2,
                this.pointSize,
                this.pointSize
              )
            })
          }
394
395
          // we create new properties. We need to tell Vue that they are reactive
          this.$set(this.dataUrls, label, canvas.toDataURL())
396
397
        })
      },
398
      resize (width, height) {
399
        this.width = width
400
        this.height = height
Sascha Herzinger's avatar
Sascha Herzinger committed
401
      },
Sascha Herzinger's avatar
Sascha Herzinger committed
402
      runAnalysisWrapper (args) {
403
        runAnalysis('compute-boxplot', args)
Sascha Herzinger's avatar
Sascha Herzinger committed
404
405
          .then(response => {
            const results = JSON.parse(response)
406
            results.data = JSON.parse(results.data)
Sascha Herzinger's avatar
Sascha Herzinger committed
407
408
409
410
411
412
413
            deepFreeze(results) // massively improve performance by telling Vue that the objects properties won't change
            this.results = results
          })
          .catch(error => console.error(error))
      }
    },
    components: {
414
      ControlPanel,
Sascha Herzinger's avatar
Sascha Herzinger committed
415
416
      DataBox,
      Chart
Sascha Herzinger's avatar
Sascha Herzinger committed
417
    },
418
419
420
    mixins: [
      StateSaver
    ],
421
422
    directives: {
      tooltip
423
424
425
426
427
    },
    mounted () {
      this.registerDataToSave([
        'catData', 'numData', 'params'
      ])
Sascha Herzinger's avatar
Sascha Herzinger committed
428
429
430
431
432
    }
  }
</script>

<style lang="sass" scoped>
433
  @import '~assets/base.sass'
434
435
  svg
    .fjs-box
Sascha Herzinger's avatar
Sascha Herzinger committed
436
      cursor: pointer
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
      .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-kde
      fill: none
      stroke: black
      stroke-width: 0.2%
455
456
    .fjs-anova-results
      text-anchor: middle
Sascha Herzinger's avatar
Sascha Herzinger committed
457
458
459
460
461
</style>


<!--CSS for dynamically created components-->
<style lang="sass">
462
  .fjs-boxplot-axis
Sascha Herzinger's avatar
Sascha Herzinger committed
463
464
465
    shape-rendering: crispEdges
    .tick
      shape-rendering: crispEdges
Sascha Herzinger's avatar
Sascha Herzinger committed
466
      text
467
        font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
468
    line
469
470
471
      stroke: #E2E2E2
    path
      stroke: none
472
473
474
475
    .fjs-x-axis
      .tick
        text
          text-anchor: start
476
          font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
477
</style>