1import { find, isEmpty, isNil, sortBy, uniq } from 'lodash'; 2import $ from 'jquery'; 3import * as d3 from 'd3'; 4import { contextSrv } from 'app/core/core'; 5import { tickStep } from 'app/core/utils/ticks'; 6import { getColorScale, getOpacityScale } from './color_scale'; 7import coreModule from 'app/angular/core_module'; 8import { PanelEvents, getColorForTheme } from '@grafana/data'; 9import { config } from 'app/core/config'; 10 11const LEGEND_HEIGHT_PX = 6; 12const LEGEND_WIDTH_PX = 100; 13const LEGEND_TICK_SIZE = 0; 14const LEGEND_VALUE_MARGIN = 0; 15const LEGEND_PADDING_LEFT = 10; 16const LEGEND_SEGMENT_WIDTH = 10; 17 18/** 19 * Color legend for heatmap editor. 20 */ 21coreModule.directive('colorLegend', () => { 22 return { 23 restrict: 'E', 24 template: '<div class="heatmap-color-legend"><svg width="16.5rem" height="24px"></svg></div>', 25 link: (scope: any, elem, attrs) => { 26 const ctrl = scope.ctrl; 27 const panel = scope.ctrl.panel; 28 29 render(); 30 31 ctrl.events.on(PanelEvents.render, () => { 32 render(); 33 }); 34 35 function render() { 36 const legendElem = $(elem).find('svg'); 37 const legendWidth = Math.floor(legendElem.outerWidth() ?? 10); 38 39 if (panel.color.mode === 'spectrum') { 40 const colorScheme: any = find(ctrl.colorSchemes, { 41 value: panel.color.colorScheme, 42 }); 43 const colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, legendWidth); 44 drawSimpleColorLegend(elem, colorScale); 45 } else if (panel.color.mode === 'opacity') { 46 const colorOptions = panel.color; 47 drawSimpleOpacityLegend(elem, colorOptions); 48 } 49 } 50 }, 51 }; 52}); 53 54/** 55 * Heatmap legend with scale values. 56 */ 57coreModule.directive('heatmapLegend', () => { 58 return { 59 restrict: 'E', 60 template: `<div class="heatmap-color-legend"><svg width="${LEGEND_WIDTH_PX}px" height="${LEGEND_HEIGHT_PX}px"></svg></div>`, 61 link: (scope: any, elem, attrs) => { 62 const ctrl = scope.ctrl; 63 const panel = scope.ctrl.panel; 64 65 render(); 66 ctrl.events.on(PanelEvents.render, () => { 67 render(); 68 }); 69 70 function render() { 71 clearLegend(elem); 72 if (!isEmpty(ctrl.data) && !isEmpty(ctrl.data.cards)) { 73 const cardStats = ctrl.data.cardStats; 74 const rangeFrom = isNil(panel.color.min) ? Math.max(cardStats.min, 0) : panel.color.min; 75 const rangeTo = isNil(panel.color.max) ? cardStats.max : panel.color.max; 76 const maxValue = cardStats.max; 77 const minValue = cardStats.min; 78 79 if (panel.color.mode === 'spectrum') { 80 const colorScheme: any = find(ctrl.colorSchemes, { 81 value: panel.color.colorScheme, 82 }); 83 drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minValue); 84 } else if (panel.color.mode === 'opacity') { 85 const colorOptions = panel.color; 86 drawOpacityLegend(elem, colorOptions, rangeFrom, rangeTo, maxValue, minValue); 87 } 88 } 89 } 90 }, 91 }; 92}); 93 94function drawColorLegend( 95 elem: JQuery, 96 colorScheme: any, 97 rangeFrom: number, 98 rangeTo: number, 99 maxValue: number, 100 minValue: number 101) { 102 const legendElem = $(elem).find('svg'); 103 const legend = d3.select(legendElem.get(0)); 104 clearLegend(elem); 105 106 const legendWidth = Math.floor(legendElem.outerWidth() ?? 10) - 30; 107 const legendHeight = legendElem.attr('height') as any; 108 109 const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH; 110 const widthFactor = legendWidth / (rangeTo - rangeFrom); 111 const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep); 112 113 const colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, rangeTo, rangeFrom); 114 legend 115 .append('g') 116 .attr('class', 'legend-color-bar') 117 .attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)') 118 .selectAll('.heatmap-color-legend-rect') 119 .data(valuesRange) 120 .enter() 121 .append('rect') 122 .attr('x', (d) => Math.round((d - rangeFrom) * widthFactor)) 123 .attr('y', 0) 124 .attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps 125 .attr('height', legendHeight) 126 .attr('stroke-width', 0) 127 .attr('fill', (d) => colorScale(d)); 128 129 drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange); 130} 131 132function drawOpacityLegend( 133 elem: JQuery, 134 options: { cardColor: null }, 135 rangeFrom: number, 136 rangeTo: number, 137 maxValue: any, 138 minValue: number 139) { 140 const legendElem = $(elem).find('svg'); 141 const legend = d3.select(legendElem.get(0)); 142 clearLegend(elem); 143 144 const legendWidth = Math.floor(legendElem.outerWidth() ?? 30) - 30; 145 const legendHeight = legendElem.attr('height') as any; 146 147 const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH; 148 const widthFactor = legendWidth / (rangeTo - rangeFrom); 149 const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep); 150 151 const opacityScale = getOpacityScale(options, rangeTo, rangeFrom); 152 legend 153 .append('g') 154 .attr('class', 'legend-color-bar') 155 .attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)') 156 .selectAll('.heatmap-opacity-legend-rect') 157 .data(valuesRange) 158 .enter() 159 .append('rect') 160 .attr('x', (d) => Math.round((d - rangeFrom) * widthFactor)) 161 .attr('y', 0) 162 .attr('width', Math.round(rangeStep * widthFactor)) 163 .attr('height', legendHeight) 164 .attr('stroke-width', 0) 165 .attr('fill', options.cardColor) 166 .style('opacity', (d) => opacityScale(d)); 167 168 drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange); 169} 170 171function drawLegendValues( 172 elem: JQuery, 173 rangeFrom: number, 174 rangeTo: number, 175 maxValue: any, 176 minValue: any, 177 legendWidth: number, 178 valuesRange: number[] 179) { 180 const legendElem = $(elem).find('svg'); 181 const legend = d3.select(legendElem.get(0)); 182 183 if (legendWidth <= 0 || legendElem.get(0).childNodes.length === 0) { 184 return; 185 } 186 187 const legendValueScale = d3.scaleLinear().domain([rangeFrom, rangeTo]).range([0, legendWidth]); 188 189 const ticks = buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue); 190 const xAxis = d3.axisBottom(legendValueScale).tickValues(ticks).tickSize(LEGEND_TICK_SIZE); 191 192 const colorRect = legendElem.find(':first-child'); 193 const posY = getSvgElemHeight(legendElem) + LEGEND_VALUE_MARGIN; 194 const posX = getSvgElemX(colorRect) + LEGEND_PADDING_LEFT; 195 196 d3.select(legendElem.get(0)) 197 .append('g') 198 .attr('class', 'axis') 199 .attr('transform', 'translate(' + posX + ',' + posY + ')') 200 .call(xAxis); 201 202 legend.select('.axis').select('.domain').remove(); 203} 204 205function drawSimpleColorLegend(elem: JQuery, colorScale: any) { 206 const legendElem = $(elem).find('svg'); 207 clearLegend(elem); 208 209 const legendWidth = Math.floor(legendElem.outerWidth() ?? 30); 210 const legendHeight = legendElem.attr('height') as any; 211 212 if (legendWidth) { 213 const valuesNumber = Math.floor(legendWidth / 2); 214 const rangeStep = Math.floor(legendWidth / valuesNumber); 215 const valuesRange = d3.range(0, legendWidth, rangeStep); 216 217 const legend = d3.select(legendElem.get(0)); 218 const legendRects = legend.selectAll('.heatmap-color-legend-rect').data(valuesRange); 219 220 legendRects 221 .enter() 222 .append('rect') 223 .attr('x', (d) => d) 224 .attr('y', 0) 225 .attr('width', rangeStep + 1) // Overlap rectangles to prevent gaps 226 .attr('height', legendHeight) 227 .attr('stroke-width', 0) 228 .attr('fill', (d) => colorScale(d)); 229 } 230} 231 232function drawSimpleOpacityLegend(elem: JQuery, options: { colorScale: string; exponent: number; cardColor: string }) { 233 const legendElem = $(elem).find('svg'); 234 clearLegend(elem); 235 236 const legend = d3.select(legendElem.get(0)); 237 const legendWidth = Math.floor(legendElem.outerWidth() ?? 30); 238 const legendHeight = legendElem.attr('height') as any; 239 240 if (legendWidth) { 241 let legendOpacityScale: any; 242 if (options.colorScale === 'linear') { 243 legendOpacityScale = d3.scaleLinear().domain([0, legendWidth]).range([0, 1]); 244 } else if (options.colorScale === 'sqrt') { 245 legendOpacityScale = d3.scalePow().exponent(options.exponent).domain([0, legendWidth]).range([0, 1]); 246 } 247 248 const rangeStep = 10; 249 const valuesRange = d3.range(0, legendWidth, rangeStep); 250 const legendRects = legend.selectAll('.heatmap-opacity-legend-rect').data(valuesRange); 251 252 legendRects 253 .enter() 254 .append('rect') 255 .attr('x', (d) => d) 256 .attr('y', 0) 257 .attr('width', rangeStep) 258 .attr('height', legendHeight) 259 .attr('stroke-width', 0) 260 .attr('fill', getColorForTheme(options.cardColor, config.theme)) 261 .style('opacity', (d) => legendOpacityScale(d)); 262 } 263} 264 265function clearLegend(elem: JQuery) { 266 const legendElem = $(elem).find('svg'); 267 legendElem.empty(); 268} 269 270function getSvgElemX(elem: JQuery) { 271 const svgElem: any = elem.get(0) as any; 272 if (svgElem && svgElem.x && svgElem.x.baseVal) { 273 return svgElem.x.baseVal.value; 274 } else { 275 return 0; 276 } 277} 278 279function getSvgElemHeight(elem: JQuery<any>) { 280 const svgElem: any = elem.get(0); 281 if (svgElem && svgElem.height && svgElem.height.baseVal) { 282 return svgElem.height.baseVal.value; 283 } else { 284 return 0; 285 } 286} 287 288function buildLegendTicks(rangeFrom: number, rangeTo: number, maxValue: number, minValue: number) { 289 const range = rangeTo - rangeFrom; 290 const tickStepSize = tickStep(rangeFrom, rangeTo, 3); 291 const ticksNum = Math.ceil(range / tickStepSize); 292 const firstTick = getFirstCloseTick(rangeFrom, tickStepSize); 293 let ticks = []; 294 295 for (let i = 0; i < ticksNum; i++) { 296 const current = firstTick + tickStepSize * i; 297 // Add user-defined min and max if it had been set 298 if (isValueCloseTo(minValue, current, tickStepSize)) { 299 ticks.push(minValue); 300 continue; 301 } else if (minValue < current) { 302 ticks.push(minValue); 303 } 304 if (isValueCloseTo(maxValue, current, tickStepSize)) { 305 ticks.push(maxValue); 306 continue; 307 } else if (maxValue < current) { 308 ticks.push(maxValue); 309 } 310 ticks.push(current); 311 } 312 if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) { 313 ticks.push(maxValue); 314 } 315 ticks.push(rangeTo); 316 ticks = sortBy(uniq(ticks)); 317 return ticks; 318} 319 320function isValueCloseTo(val: number, valueTo: number, step: number) { 321 const diff = Math.abs(val - valueTo); 322 return diff < step * 0.3; 323} 324 325function getFirstCloseTick(minValue: number, step: number) { 326 if (minValue < 0) { 327 return Math.floor(minValue / step) * step; 328 } 329 return 0; 330} 331