Boxplot.vue 15.4 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
42
          ANOVA -- F-value: {{ this.results.anova.f_value.toFixed(4) }}
          &nbsp p-value: {{ this.results.anova.p_value.toFixed(4) }}
        </text>
43
44
45
46
        <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)`"
47
           v-tooltip="{placement: 'bottom'}"
48
49
           :title="label"
           :data-label="label"
50
           @click="setIDFilter(label)"
51
52
53
54
55
           @mouseenter="showTooltip(label)"
           @mouseleave="hideTooltip(label)"
           v-for="label in labels" >
          <line class="fjs-upper-whisker"
                :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
63
          </line>
          <line class="fjs-lower-whisker"
                :title="results.statistics[label].l_wsk"
64
                v-tooltip="{placement: 'right'}"
65
                :x1="- boxplotWidth / 6"
66
                :y1="boxes[label].l_wsk"
67
                :x2="boxplotWidth / 6"
68
                :y2="boxes[label].l_wsk">
69
70
71
          </line>
          <line class="fjs-upper-quartile"
                :title="results.statistics[label].u_qrt"
72
                v-tooltip="{placement: 'left'}"
73
                :x1="- boxplotWidth / 2"
74
                :y1="boxes[label].u_qrt"
75
                :x2="boxplotWidth / 2"
76
                :y2="boxes[label].u_qrt">
77
78
79
          </line>
          <line class="fjs-lower-quartile"
                :title="results.statistics[label].l_qrt"
80
                v-tooltip="{placement: 'left'}"
81
                :x1="- boxplotWidth / 2"
82
                :y1="boxes[label].l_qrt"
83
                :x2="boxplotWidth / 2"
84
                :y2="boxes[label].l_qrt">
85
86
87
          </line>
          <line class="fjs-median"
                :title="results.statistics[label].median"
88
                v-tooltip="{placement: 'right'}"
89
                :x1="- boxplotWidth / 2"
90
                :y1="boxes[label].median"
91
                :x2="boxplotWidth / 2"
92
                :y2="boxes[label].median">
93
94
95
          </line>
          <line class="fjs-antenna"
                :x1="0"
96
                :y1="boxes[label].u_wsk"
97
                :x2="0"
98
                :y2="boxes[label].l_wsk">
99
100
101
          </line>
          <rect class="fjs-above-median-box"
                :x="- boxplotWidth / 2"
102
                :y="boxes[label].u_qrt"
103
                :width="boxplotWidth"
104
                :height="boxes[label].median - boxes[label].u_qrt">
105
106
107
          </rect>
          <rect class="fjs-below-median-box"
                :x="- boxplotWidth / 2"
108
                :y="boxes[label].median"
109
                :width="boxplotWidth"
110
                :height="boxes[label].l_qrt - boxes[label].median">
111
          </rect>
112
113
114
115
116
          <image :href="dataUrls[label]"
                 :data-label="label"
                 :height="padded.height"
                 :width="boxplotWidth / 2">
          </image>
117
118
          <polyline class="fjs-kde"
                    :points="kdePolyPoints[label]"
119
                    v-if="params.showKDE">
120
          </polyline>
Sascha Herzinger's avatar
Sascha Herzinger committed
121
        </g>
122
123
124
125
      </g>
    </svg>

  </chart>
Sascha Herzinger's avatar
Sascha Herzinger committed
126
127
128
129
</template>

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

<style lang="sass" scoped>
424
  @import '~assets/base.sass'
425
426
  svg
    .fjs-box
Sascha Herzinger's avatar
Sascha Herzinger committed
427
      cursor: pointer
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
      .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%
446
447
    .fjs-anova-results
      text-anchor: middle
Sascha Herzinger's avatar
Sascha Herzinger committed
448
449
450
451
452
</style>


<!--CSS for dynamically created components-->
<style lang="sass">
453
  .fjs-boxplot-axis
Sascha Herzinger's avatar
Sascha Herzinger committed
454
455
456
    shape-rendering: crispEdges
    .tick
      shape-rendering: crispEdges
Sascha Herzinger's avatar
Sascha Herzinger committed
457
      text
458
        font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
459
    line
460
461
462
      stroke: #E2E2E2
    path
      stroke: none
463
464
465
466
    .fjs-x-axis
      .tick
        text
          text-anchor: start
467
          font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
468
</style>