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