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

  </chart>
Sascha Herzinger's avatar
Sascha Herzinger committed
110
111
112
113
</template>

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

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

391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
  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-kde
      fill: none
      stroke: black
      stroke-width: 0.2%
Sascha Herzinger's avatar
Sascha Herzinger committed
411
412
413
414
415
</style>


<!--CSS for dynamically created components-->
<style lang="sass">
416
  .fjs-boxplot-axis
Sascha Herzinger's avatar
Sascha Herzinger committed
417
418
419
    shape-rendering: crispEdges
    .tick
      shape-rendering: crispEdges
Sascha Herzinger's avatar
Sascha Herzinger committed
420
      text
421
        font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
422
    line
423
424
425
      stroke: #E2E2E2
    path
      stroke: none
426
427
428
429
    .fjs-x-axis
      .tick
        text
          text-anchor: start
430
          font-size: 0.75em
Sascha Herzinger's avatar
Sascha Herzinger committed
431
</style>