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

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

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

<style lang="sass" scoped>
398
  @import '~assets/base.sass'
399
400
  svg
    .fjs-box
Sascha Herzinger's avatar
Sascha Herzinger committed
401
      cursor: pointer
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
      .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%
420
421
    .fjs-anova-results
      text-anchor: middle
Sascha Herzinger's avatar
Sascha Herzinger committed
422
423
424
425
426
</style>


<!--CSS for dynamically created components-->
<style lang="sass">
427
  .fjs-boxplot-axis
Sascha Herzinger's avatar
Sascha Herzinger committed
428
429
430
    shape-rendering: crispEdges
    .tick
      shape-rendering: crispEdges
Sascha Herzinger's avatar
Sascha Herzinger committed
431
      text
432
        font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
433
    line
434
435
436
      stroke: #E2E2E2
    path
      stroke: none
437
438
439
440
    .fjs-x-axis
      .tick
        text
          text-anchor: start
441
          font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
442
</style>