github.com/hernad/nomad@v1.6.112/ui/app/components/distribution-bar.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 /* eslint-disable ember/no-observers */ 7 import Component from '@ember/component'; 8 import { computed, set } from '@ember/object'; 9 import { observes } from '@ember-decorators/object'; 10 import { run, once } from '@ember/runloop'; 11 import { assign } from '@ember/polyfills'; 12 import { guidFor } from '@ember/object/internals'; 13 import { copy } from 'ember-copy'; 14 import { computed as overridable } from 'ember-overridable-computed'; 15 import d3 from 'd3-selection'; 16 import 'd3-transition'; 17 import WindowResizable from '../mixins/window-resizable'; 18 import styleStringProperty from '../utils/properties/style-string'; 19 import { classNames, classNameBindings } from '@ember-decorators/component'; 20 import classic from 'ember-classic-decorator'; 21 22 const sumAggregate = (total, val) => total + val; 23 24 @classic 25 @classNames('chart', 'distribution-bar') 26 @classNameBindings('isNarrow:is-narrow') 27 export default class DistributionBar extends Component.extend(WindowResizable) { 28 chart = null; 29 @overridable(() => null) data; 30 onSliceClick = null; 31 activeDatum = null; 32 isNarrow = false; 33 34 @styleStringProperty('tooltipPosition') tooltipStyle; 35 maskId = null; 36 37 @computed('data') 38 get _data() { 39 const data = copy(this.data, true); 40 const sum = data.mapBy('value').reduce(sumAggregate, 0); 41 42 return data.map( 43 ({ label, value, className, layers, legendLink, help }, index) => ({ 44 label, 45 value, 46 className, 47 layers, 48 legendLink, 49 help, 50 index, 51 percent: value / sum, 52 offset: 53 data.slice(0, index).mapBy('value').reduce(sumAggregate, 0) / sum, 54 }) 55 ); 56 } 57 58 didInsertElement() { 59 super.didInsertElement(...arguments); 60 const svg = this.element.querySelector('svg'); 61 const chart = d3.select(svg); 62 const maskId = `dist-mask-${guidFor(this)}`; 63 this.setProperties({ chart, maskId }); 64 65 svg.querySelector('clipPath').setAttribute('id', maskId); 66 67 chart.on('mouseleave', () => { 68 run(() => { 69 this.set('isActive', false); 70 this.set('activeDatum', null); 71 chart 72 .selectAll('g') 73 .classed('active', false) 74 .classed('inactive', false); 75 }); 76 }); 77 78 this.renderChart(); 79 } 80 81 didUpdateAttrs() { 82 super.didUpdateAttrs(); 83 this.renderChart(); 84 } 85 86 @observes('_data.@each.{value,label,className}') 87 updateChart() { 88 this.renderChart(); 89 } 90 91 // prettier-ignore 92 /* eslint-disable */ 93 renderChart() { 94 const { chart, _data, isNarrow } = this; 95 const width = this.element.querySelector('svg').clientWidth; 96 const filteredData = _data.filter(d => d.value > 0); 97 filteredData.forEach((d, index) => { 98 set(d, 'index', index); 99 }); 100 101 let slices = chart.select('.bars').selectAll('g').data(filteredData, d => d.label); 102 let sliceCount = filteredData.length; 103 104 slices.exit().remove(); 105 106 let slicesEnter = slices.enter() 107 .append('g') 108 .on('mouseenter', (ev, d) => { 109 run(() => { 110 const slices = this.slices; 111 const slice = slices.filter(datum => datum.label === d.label); 112 slices.classed('active', false).classed('inactive', true); 113 slice.classed('active', true).classed('inactive', false); 114 this.set('activeDatum', d); 115 116 const box = slice.node().getBBox(); 117 const pos = box.x + box.width / 2; 118 119 // Ensure that the position is set before the tooltip is visible 120 run.schedule('afterRender', this, () => this.set('isActive', true)); 121 this.set('tooltipPosition', { 122 left: pos, 123 }); 124 }); 125 }); 126 127 slices = slices.merge(slicesEnter); 128 slices.attr('class', d => { 129 const className = d.className || `slice-${_data.indexOf(d)}` 130 const activeDatum = this.activeDatum; 131 const isActive = activeDatum && activeDatum.label === d.label; 132 const isInactive = activeDatum && activeDatum.label !== d.label; 133 const isClickable = !!this.onSliceClick; 134 return [ 135 className, 136 isActive && 'active', 137 isInactive && 'inactive', 138 isClickable && 'clickable' 139 ].compact().join(' '); 140 }).attr('data-test-slice-label', d => d.className); 141 142 this.set('slices', slices); 143 144 const setWidth = d => { 145 // Remove a pixel from either side of the slice 146 let modifier = 2; 147 if (d.index === 0) modifier--; // But not the left side 148 if (d.index === sliceCount - 1) modifier--; // But not the right side 149 150 return `${width * d.percent - modifier}px`; 151 }; 152 const setOffset = d => `${width * d.offset + (d.index === 0 ? 0 : 1)}px`; 153 154 let hoverTargets = slices.selectAll('.target').data(d => [d]); 155 hoverTargets.enter() 156 .append('rect') 157 .attr('class', 'target') 158 .attr('width', setWidth) 159 .attr('height', '100%') 160 .attr('x', setOffset) 161 .merge(hoverTargets) 162 .transition() 163 .duration(200) 164 .attr('width', setWidth) 165 .attr('x', setOffset) 166 167 let layers = slices.selectAll('.bar').data((d, i) => { 168 return new Array(d.layers || 1).fill(assign({ index: i }, d)); 169 }); 170 layers.enter() 171 .append('rect') 172 .attr('width', setWidth) 173 .attr('x', setOffset) 174 .attr('y', () => isNarrow ? '50%' : 0) 175 .attr('clip-path', `url(#${this.maskId})`) 176 .attr('height', () => isNarrow ? '6px' : '100%') 177 .attr('transform', () => isNarrow ? 'translate(0, -3)' : '') 178 .merge(layers) 179 .attr('class', (d, i) => `bar layer-${i}`) 180 .transition() 181 .duration(200) 182 .attr('width', setWidth) 183 .attr('x', setOffset) 184 185 if (isNarrow) { 186 d3.select(this.element).select('.mask') 187 .attr('height', '6px') 188 .attr('y', '50%'); 189 } 190 191 if (this.onSliceClick) { 192 slices.on('click', this.onSliceClick); 193 } 194 } 195 /* eslint-enable */ 196 197 windowResizeHandler() { 198 once(this, this.renderChart); 199 } 200 }