|
| 1 | +import * as d3 from 'd3'; |
| 2 | +import styles from '../tooltipStyles.module.css'; |
| 3 | +import Renderer from '../Renderer.js'; |
| 4 | + |
| 5 | +class WorkItemAgeRenderer extends Renderer { |
| 6 | + color = '#0ea5e9'; |
| 7 | + xAxisLabel = 'Work item states'; |
| 8 | + yAxisLabel = 'Age(days)'; |
| 9 | + dotRadius = 7; |
| 10 | + timeScale = 'logarithmic'; |
| 11 | + |
| 12 | + constructor( |
| 13 | + data, |
| 14 | + workTicketsURL, |
| 15 | + states = ['analysis_active', 'analysis_done', 'in_progress', 'dev_complete', 'verification_start', 'delivered'] |
| 16 | + ) { |
| 17 | + const filteredData = data.filter((d) => d.currentState !== 'delivered'); |
| 18 | + super(filteredData); |
| 19 | + this.states = states.filter((d) => d !== 'delivered'); |
| 20 | + this.data = this.groupData(filteredData); |
| 21 | + this.workTicketsURL = workTicketsURL; |
| 22 | + } |
| 23 | + |
| 24 | + groupData(data) { |
| 25 | + const groupedData = data.reduce((acc, item) => { |
| 26 | + let group = acc.find((g) => g.currentState === item.currentState && g.age === item.age); |
| 27 | + if (!group) { |
| 28 | + group = { currentState: item.currentState, age: item.age, items: [] }; |
| 29 | + acc.push(group); |
| 30 | + } |
| 31 | + group.items.push(item); |
| 32 | + return acc; |
| 33 | + }, []); |
| 34 | + groupedData.sort((t1, t2) => this.states.indexOf(t1.currentState) - this.states.indexOf(t2.currentState)); |
| 35 | + return groupedData; |
| 36 | + } |
| 37 | + |
| 38 | + renderGraph(graphElementSelector) { |
| 39 | + this.drawSvg(graphElementSelector); |
| 40 | + this.drawAxes(); |
| 41 | + this.drawArea(); |
| 42 | + } |
| 43 | + |
| 44 | + drawSvg(graphElementSelector) { |
| 45 | + this.svg = this.createSvg(graphElementSelector); |
| 46 | + } |
| 47 | + |
| 48 | + drawArea() { |
| 49 | + this.computeDotPositions(); |
| 50 | + |
| 51 | + // Add vertical grid lines to delimit state areas |
| 52 | + this.svg |
| 53 | + .selectAll('.state-delimiter') |
| 54 | + .data(this.states) |
| 55 | + .enter() |
| 56 | + .append('line') |
| 57 | + .attr('class', 'state-delimiter') |
| 58 | + .attr('x1', (d) => this.x(d)) |
| 59 | + .attr('x2', (d) => this.x(d)) |
| 60 | + .attr('y1', 0) |
| 61 | + .attr('y2', this.height) |
| 62 | + .attr('stroke', '#ccc') |
| 63 | + .attr('stroke-dasharray', '4 2'); |
| 64 | + |
| 65 | + // Draw dots |
| 66 | + this.svg |
| 67 | + .selectAll('.dot') |
| 68 | + .data(this.data) |
| 69 | + .enter() |
| 70 | + .append('circle') |
| 71 | + .attr('class', 'dot') |
| 72 | + .style('cursor', 'pointer') |
| 73 | + .attr('id', (d) => `age-${d.ticketId}`) |
| 74 | + .attr('cx', (d) => d.xJitter) |
| 75 | + .attr('cy', (d) => this.y(d.age)) |
| 76 | + .attr('r', this.dotRadius) |
| 77 | + .attr('fill', 'steelblue') |
| 78 | + .on('click', (event, d) => this.handleMouseClickEvent(event, d)); |
| 79 | + |
| 80 | + // Add numbers inside the dots |
| 81 | + this.svg |
| 82 | + .selectAll('.dot-label') |
| 83 | + .data(this.data) |
| 84 | + .enter() |
| 85 | + .append('text') |
| 86 | + .attr('class', 'dot-label') |
| 87 | + .attr('x', (d) => d.xJitter) |
| 88 | + .attr('y', (d) => this.y(d.age)) |
| 89 | + .attr('dy', '0.35em') |
| 90 | + .attr('text-anchor', 'middle') |
| 91 | + .attr('font-size', '10px') |
| 92 | + .style('cursor', 'pointer') |
| 93 | + .style('fill', 'white') |
| 94 | + .text((d) => d.items.length) |
| 95 | + .on('click', (event, d) => this.handleMouseClickEvent(event, d)); |
| 96 | + } |
| 97 | + |
| 98 | + computeDotPositions() { |
| 99 | + const groupedData = d3.group(this.data, (d) => d.currentState); |
| 100 | + |
| 101 | + // Generate x positions for dots within each state |
| 102 | + const stateWidth = this.x.bandwidth(); |
| 103 | + const jitterRange = stateWidth - this.dotRadius * 2; // Adjust range to prevent overlap |
| 104 | + |
| 105 | + groupedData.forEach((group, state) => { |
| 106 | + // Generate evenly spaced positions within the jitter range |
| 107 | + let horizontalPositions = d3.range(group.length).map((i) => i * (this.dotRadius * 2) - jitterRange / 2); |
| 108 | + |
| 109 | + // Shuffle positions for randomness |
| 110 | + horizontalPositions = d3.shuffle(horizontalPositions); |
| 111 | + |
| 112 | + group.forEach((item, index) => { |
| 113 | + // Clamp positions to keep dots inside the band |
| 114 | + const xPosition = horizontalPositions[index]; |
| 115 | + const clampedX = Math.max(-jitterRange / 2 + this.dotRadius, Math.min(jitterRange / 2 - this.dotRadius, xPosition)); |
| 116 | + |
| 117 | + // Assign xJitter, ensuring the dot stays within the band |
| 118 | + item.xJitter = this.x(state) + stateWidth / 2 + clampedX; |
| 119 | + }); |
| 120 | + }); |
| 121 | + } |
| 122 | + |
| 123 | + setTimeScaleListener(timeScaleSelector) { |
| 124 | + this.timeScaleSelectElement = document.querySelector(timeScaleSelector); |
| 125 | + if (this.timeScaleSelectElement) { |
| 126 | + this.timeScaleSelectElement.value = this.timeScale; |
| 127 | + this.timeScaleSelectElement.addEventListener('change', (event) => { |
| 128 | + this.timeScale = event.target.value; |
| 129 | + this.computeYScale(); |
| 130 | + this.updateChartArea(this.selectedTimeRange); |
| 131 | + }); |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + updateChartArea() { |
| 136 | + this.drawYAxis(this.gy, this.y); |
| 137 | + this.computeDotPositions(); |
| 138 | + this.svg |
| 139 | + .selectAll(`.dot`) |
| 140 | + .attr('cx', (d) => d.xJitter) |
| 141 | + .attr('cy', (d) => this.y(d.age)); |
| 142 | + this.svg |
| 143 | + .selectAll(`.dot-label`) |
| 144 | + .attr('x', (d) => d.xJitter) |
| 145 | + .attr('y', (d) => this.y(d.age)); |
| 146 | + } |
| 147 | + |
| 148 | + drawAxes() { |
| 149 | + this.computeXScale(); |
| 150 | + this.computeYScale(); |
| 151 | + this.gx = this.svg.append('g'); |
| 152 | + this.gy = this.svg.append('g'); |
| 153 | + this.drawXAxis(this.gx, this.x); |
| 154 | + this.drawYAxis(this.gy, this.y); |
| 155 | + this.drawAxesLabels(this.svg, this.xAxisLabel, this.yAxisLabel); |
| 156 | + } |
| 157 | + |
| 158 | + computeYScale() { |
| 159 | + if (this.timeScale === 'logarithmic') { |
| 160 | + this.y = d3 |
| 161 | + .scaleLog() |
| 162 | + .domain([1, d3.max(this.data, (d) => d.age)]) |
| 163 | + .range([this.height, 0]); |
| 164 | + } else if (this.timeScale === 'linear') { |
| 165 | + this.y = d3 |
| 166 | + .scaleLinear() |
| 167 | + .domain([0, d3.max(this.data, (d) => d.age)]) |
| 168 | + .range([this.height, 0]); |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + computeXScale() { |
| 173 | + this.x = d3.scaleBand().domain(this.states).range([0, this.width]).padding(0); |
| 174 | + } |
| 175 | + |
| 176 | + drawXAxis(gx, x) { |
| 177 | + gx.attr('transform', `translate(0,${this.height})`) |
| 178 | + .call(d3.axisBottom(x)) |
| 179 | + .selectAll('text') |
| 180 | + .attr('class', 'axis-label') |
| 181 | + .style('text-anchor', 'middle'); |
| 182 | + } |
| 183 | + |
| 184 | + drawYAxis(gy, y) { |
| 185 | + gy.call(d3.axisLeft(y)).selectAll('text').attr('class', 'axis-label'); |
| 186 | + } |
| 187 | + |
| 188 | + showTooltip(event) { |
| 189 | + console.log(event); |
| 190 | + !this.tooltip && this.#createTooltip(); |
| 191 | + this.#clearTooltipContent(); |
| 192 | + this.#positionTooltip(event.tooltipLeft, event.tooltipTop); |
| 193 | + this.populateTooltip(event); |
| 194 | + this.tooltip.on('mouseleave', () => this.setupMouseLeaveHandler()); |
| 195 | + } |
| 196 | + |
| 197 | + /** |
| 198 | + * Hides the tooltip. |
| 199 | + */ |
| 200 | + hideTooltip() { |
| 201 | + this.tooltip?.transition().duration(100).style('opacity', 0).style('pointer-events', 'none'); |
| 202 | + } |
| 203 | + |
| 204 | + /** |
| 205 | + * Creates a tooltip for the chart used for the observation logging. |
| 206 | + * @private |
| 207 | + */ |
| 208 | + #createTooltip() { |
| 209 | + this.tooltip = d3.select('body').append('div').attr('class', styles.chartTooltip).attr('id', 's-tooltip').style('opacity', 0); |
| 210 | + } |
| 211 | + |
| 212 | + /** |
| 213 | + * Populates the tooltip's content with event data: ticket id and observation body |
| 214 | + * @private |
| 215 | + * @param {Object} event - The event data for the tooltip. |
| 216 | + */ |
| 217 | + populateTooltip(event) { |
| 218 | + this.tooltip.style('pointer-events', 'auto').style('opacity', 0.9); |
| 219 | + this.tooltip.append('p').text(`Age: ${event.age}`); |
| 220 | + event.items.forEach((item) => { |
| 221 | + this.tooltip |
| 222 | + .append('div') |
| 223 | + .append('a') |
| 224 | + .style('text-decoration', 'underline') |
| 225 | + .attr('href', `${this.workTicketsURL}/${item.ticketId}`) |
| 226 | + .text(item.ticketId) |
| 227 | + .attr('target', '_blank'); |
| 228 | + }); |
| 229 | + } |
| 230 | + |
| 231 | + /** |
| 232 | + * Positions the tooltip on the page. |
| 233 | + * @private |
| 234 | + * @param {number} left - The left position for the tooltip. |
| 235 | + * @param {number} top - The top position for the tooltip. |
| 236 | + */ |
| 237 | + #positionTooltip(left, top) { |
| 238 | + this.tooltip.transition().duration(100).style('opacity', 0.9).style('pointer-events', 'auto'); |
| 239 | + this.tooltip.style('left', left + 'px').style('top', top + 'px'); |
| 240 | + } |
| 241 | + |
| 242 | + /** |
| 243 | + * Clears the content of the tooltip. |
| 244 | + * @private |
| 245 | + */ |
| 246 | + #clearTooltipContent() { |
| 247 | + this.tooltip.selectAll('*').remove(); |
| 248 | + } |
| 249 | + |
| 250 | + handleMouseClickEvent(event, d) { |
| 251 | + let data = { |
| 252 | + ...d, |
| 253 | + tooltipLeft: event.pageX, |
| 254 | + tooltipTop: event.pageY, |
| 255 | + }; |
| 256 | + |
| 257 | + this.showTooltip(data); |
| 258 | + } |
| 259 | + |
| 260 | + setupMouseLeaveHandler() { |
| 261 | + d3.select(this.svg.node().parentNode).on('mouseleave', (event) => { |
| 262 | + if (event.relatedTarget !== this.tooltip?.node()) { |
| 263 | + this.hideTooltip(); |
| 264 | + } |
| 265 | + }); |
| 266 | + } |
| 267 | +} |
| 268 | + |
| 269 | +export default WorkItemAgeRenderer; |
0 commit comments