import { bisectLeft } from 'd3-array' ; import { ScaleBand , scaleBand , scaleQuantize } from 'd3-scale' ; import { colorToRgba } from '../../../../common/color_library_wrappers' ; import { fillTextColor } from '../../../../common/fill_text_color' ; import { Pixels } from '../../../../common/geometry' ; import { Box , Font , maximiseFontSize } from '../../../../common/text_utils' ; import { ScaleContinuous } from '../../../../scales' ; import { ScaleType } from '../../../../scales/constants' ; import { LinearScale , OrdinalScale , RasterTimeScale } from '../../../../specs' ; import { TextMeasure , withTextMeasure } from '../../../../utils/bbox/canvas_text_bbox_calculator' ; import { addIntervalToTime } from '../../../../utils/chrono/elasticsearch' ; import { clamp , Datum } from '../../../../utils/common' ; import { Dimensions , horizontalPad , innerPad , pad } from '../../../../utils/dimensions' ; import { Logger } from '../../../../utils/logger' ; import { HeatmapStyle , Theme , Visible } from '../../../../utils/themes/theme' ; import { PrimitiveValue } from '../../../partition_chart/layout/utils/group_by_rollup' ; import { HeatmapSpec } from '../../specs' ; import { ChartElementSizes , HeatmapTable } from '../../state/selectors/compute_chart_dimensions' ; import { ColorScale } from '../../state/selectors/get_color_scale' ; import { Cell , PickDragFunction , PickDragShapeFunction , PickHighlightedArea , ShapeViewModel , TextBox , } from '../types/viewmodel_types' ; import { BaseDatum } from './../../../xy_chart/utils/specs' ; public export interface HeatmapCellDatum { x : NonNullable < PrimitiveValue > ; y : NonNullable < PrimitiveValue > ; value : number ; originalIndex : number ; } function getValuesInRange ( values , startValue , endValue ) { const startIndex = values . indexOf ( startValue ) ; const endIndex = Math . min ( values . indexOf ( endValue ) + 1 , values . length ) ; return values . slice ( startIndex , endIndex ) ; } function estimatedNonOverlappingTickCount ( chartWidth , style , sampleLabel ) { return withTextMeasure ( ( textMeasure ) => { const { width } = textMeasure ( sampleLabel , { fontFamily : style . fontFamily , fontWeight : style . fontWeight , fontVariant : style . fontVariant , fontStyle : style . fontStyle , } , style . fontSize ) ; const maxTicks = chartWidth / ( width + horizontalPad ( style . padding ) ) ; return Math . floor ( maxTicks / 2 ) ; } ) ; } internal export function shapeViewModel < D extends BaseDatum = Datum > ( textMeasure , spec , { heatmap : heatmapTheme , axes : { axisTitle } , background } , elementSizes , heatmapTable , colorScale , bandsToHide ) { const gridStrokeWidth = heatmapTheme . grid . stroke . width ?? 1 ; const { table , yValues , xValues } = heatmapTable ; const boxedYValues = yValues . map < Box & { value : NonNullable < PrimitiveValue > ; } > ( ( value ) => ( { text : spec . yAxisLabelFormatter ( value ) , value , isValue : false , ... heatmapTheme . yAxisLabel , } ) ) ; const yScale = scaleBand < NonNullable < PrimitiveValue > > ( ) . domain ( yValues ) . range ( [ 0 , elementSizes . fullHeatmapHeight ] ) ; const yInvertedScale = scaleQuantize < NonNullable < PrimitiveValue > > ( ) . domain ( [ 0 , elementSizes . fullHeatmapHeight ] ) . range ( yValues ) ; const xScale = scaleBand < NonNullable < PrimitiveValue > > ( ) . domain ( xValues ) . range ( [ 0 , elementSizes . grid . width ] ) ; const xInvertedScale = scaleQuantize < NonNullable < PrimitiveValue > > ( ) . domain ( [ 0 , elementSizes . grid . width ] ) . range ( xValues ) ; const cellWidth = heatmapTheme . cell . maxWidth !== 'fill' && xScale . bandwidth ( ) > heatmapTheme . cell . maxWidth ? heatmapTheme . cell . maxWidth : xScale . bandwidth ( ) ; const cellHeight = yScale . bandwidth ( ) ; const currentGridHeight = elementSizes . grid . height ; const textXValues = getXTicks ( spec , heatmapTheme , elementSizes . grid , xScale , heatmapTable ) ; const { padding } = heatmapTheme . yAxisLabel ; const textYValues = boxedYValues . map < TextBox > ( ( d ) => { return { ... d , x : - pad ( padding , 'right' ) , y : cellHeight / 2 + ( yScale ( d . value ) || 0 ) , } ; } ) ; const cellWidthInner = cellWidth - gridStrokeWidth * 2 ; const cellHeightInner = cellHeight - gridStrokeWidth * 2 ; if ( colorToRgba ( background . color ) [ 3 ] < 1 ) { Logger . expected ( 'Text contrast requires a opaque background color, using fallbackColor' , 'an opaque color' , background . color ) ; } const cellMap = table . reduce < Record < string , Cell > > ( ( acc , d ) => { const x = xScale ( String ( d . x ) ) ; const y = yScale ( String ( d . y ) ) ! + gridStrokeWidth ; const yIndex = yValues . indexOf ( d . y ) ; if ( x === undefined || y === undefined || yIndex === - 1 ) { return acc ; } const cellBackgroundColor = colorScale ( d . value ) ; const cellKey = getCellKey ( d . x , d . y ) ; const formattedValue = spec . valueFormatter ( d . value ) ; const fontSize = maximiseFontSize ( textMeasure , formattedValue , heatmapTheme . cell . label , heatmapTheme . cell . label . minFontSize , heatmapTheme . cell . label . maxFontSize , cellWidthInner - 6 , cellHeightInner - 6 ) ; acc [ cellKey ] = { x : ( heatmapTheme . cell . maxWidth !== 'fill' ? x + xScale . bandwidth ( ) / 2 - heatmapTheme . cell . maxWidth / 2 : x ) + gridStrokeWidth , y , yIndex , width : cellWidthInner , height : cellHeightInner , datum : d , fill : { color : colorToRgba ( cellBackgroundColor ) , } , stroke : { color : colorToRgba ( heatmapTheme . cell . border . stroke ) , width : heatmapTheme . cell . border . strokeWidth , } , value : d . value , visible : ! isValueHidden ( d . value , bandsToHide ) , formatted : formattedValue , fontSize , textColor : fillTextColor ( background . fallbackColor , cellBackgroundColor , background . color ) , } ; return acc ; } , { } ) ; param x param y const pickQuads = ( x , y ) : Array < Cell > | TextBox => { if ( x > 0 && x < elementSizes . grid . left && y > elementSizes . grid . top && y < elementSizes . grid . top + elementSizes . grid . height ) { const yLabelKey = yInvertedScale ( y ) ; const yLabelValue = textYValues . find ( ( v ) => v . value === yLabelKey ) ; if ( yLabelValue ) { return yLabelValue ; } } if ( x < elementSizes . grid . left || y < elementSizes . grid . top ) { return [ ] ; } if ( x > elementSizes . grid . width + elementSizes . grid . left || y > elementSizes . grid . top + elementSizes . grid . height ) { return [ ] ; } const xValue = xInvertedScale ( x - elementSizes . grid . left ) ; const yValue = yInvertedScale ( y ) ; if ( xValue === undefined || yValue === undefined ) { return [ ] ; } const cellKey = getCellKey ( xValue , yValue ) ; const cell = cellMap [ cellKey ] ; if ( cell ) { return [ cell ] ; } return [ ] ; } ; /**
     * Return selected cells and X,Y ranges based on the drag selection.
     */ const pickDragArea = ( bound ) => { const [ start , end ] = bound ; const { left , top , width } = elementSizes . grid ; const topLeft = [ Math . min ( start . x , end . x ) - left , Math . min ( start . y , end . y ) - top ] ; const bottomRight = [ Math . max ( start . x , end . x ) - left , Math . max ( start . y , end . y ) - top ] ; const startX = xInvertedScale ( clamp ( topLeft [ 0 ] , 0 , width ) ) ; const endX = xInvertedScale ( clamp ( bottomRight [ 0 ] , 0 , width ) ) ; const startY = yInvertedScale ( clamp ( topLeft [ 1 ] , 0 , currentGridHeight - 1 ) ) ; const endY = yInvertedScale ( clamp ( bottomRight [ 1 ] , 0 , currentGridHeight - 1 ) ) ; const allXValuesInRange = getValuesInRange ( xValues , startX , endX ) ; const allYValuesInRange = getValuesInRange ( yValues , startY , endY ) ; const invertedXValues = isRasterTimeScale ( spec . xScale ) && typeof endX === 'number' ? [ startX , addIntervalToTime ( endX , spec . xScale . interval , spec . timeZone ) ] : [ ... allXValuesInRange ] ; const cells = [ ] ; allXValuesInRange . forEach ( ( x ) => { allYValuesInRange . forEach ( ( y ) => { const cellKey = getCellKey ( x , y ) ; cells . push ( cellMap [ cellKey ] ) ; } ) ; } ) ; return { cells : cells . filter ( Boolean ) , x : invertedXValues , y : allYValuesInRange , } ; } ; param x param y const pickHighlightedArea = ( x , y ) => { const startValue = x [ 0 ] ; const endValue = x [ x . length - 1 ] ; const leftIndex = typeof startValue === 'number' ? bisectLeft ( xValues as number [ ] , startValue ) : xValues . indexOf ( startValue ) ; const rightIndex = typeof endValue === 'number' ? bisectLeft ( xValues as number [ ] , endValue ) : xValues . indexOf ( endValue ) + 1 ; const isRightOutOfRange = rightIndex > xValues . length - 1 || rightIndex < 0 ; const isLeftOutOfRange = leftIndex > xValues . length - 1 || leftIndex < 0 ; const startFromScale = xScale ( isLeftOutOfRange ? xValues [ 0 ] : xValues [ leftIndex ] ) ; const endFromScale = xScale ( isRightOutOfRange ? xValues [ xValues . length - 1 ] : xValues [ rightIndex ] ) ; if ( startFromScale === undefined || endFromScale === undefined ) { return null ; } const xStart = elementSizes . grid . left + startFromScale ; const width = endFromScale - startFromScale + ( isRightOutOfRange || isLeftOutOfRange ? cellWidth : 0 ) ; const { y : yStart , totalHeight } = y . filter ( ( v ) => yValues . includes ( v ) ) . reduce ( ( acc , current , i ) => { if ( i === 0 ) { acc . y = yScale ( current ) || 0 ; } acc . totalHeight += cellHeight ; return acc ; } , { y : 0 , totalHeight : 0 } ) ; return { x : xStart , y : yStart , width , height : totalHeight , } ; } ; /**
     * Resolves coordinates and metrics of the selected rect area.
     */ const pickDragShape = ( bound ) => { const area = pickDragArea ( bound ) ; return pickHighlightedArea ( area . x , area . y ) ; } ; const xLines = Array . from ( { length : xValues . length + 1 } , ( d , i ) => ( { x1 : elementSizes . grid . left + i * cellWidth , x2 : elementSizes . grid . left + i * cellWidth , y1 : elementSizes . grid . top , y2 : currentGridHeight , } ) ) ; const yLines = Array . from ( { length : elementSizes . visibleNumberOfRows + 1 } , ( d , i ) => ( { x1 : elementSizes . grid . left , x2 : elementSizes . grid . left + elementSizes . grid . width , y1 : i * cellHeight , y2 : i * cellHeight , } ) ) ; const cells = Object . values ( cellMap ) ; const tableMinFontSize = cells . reduce ( ( acc , { fontSize } ) => Math . min ( acc , fontSize ) , Infinity ) ; const axisTitleFont = { visible : axisTitle . visible , fontFamily : axisTitle . fontFamily , fontStyle : axisTitle . fontStyle ?? 'normal' , fontVariant : 'normal' , fontWeight : 'bold' , textColor : axisTitle . fill , fontSize : axisTitle . fontSize , } ; return { theme : heatmapTheme , heatmapViewModel : { gridOrigin : { x : elementSizes . grid . left , y : elementSizes . grid . top , } , gridLines : { x : xLines , y : yLines , stroke : { color : colorToRgba ( heatmapTheme . grid . stroke . color ) , width : gridStrokeWidth , } , } , pageSize : elementSizes . visibleNumberOfRows , cells , cellFontSize : ( cell ) => ( heatmapTheme . cell . label . useGlobalMinFontSize ? tableMinFontSize : cell . fontSize ) , xValues : textXValues , yValues : textYValues , titles : [ { origin : { x : elementSizes . grid . left + elementSizes . grid . width / 2 , y : elementSizes . grid . top + elementSizes . grid . height + elementSizes . xAxis . height + innerPad ( axisTitle . padding ) + axisTitle . fontSize / 2 , } , ... axisTitleFont , text : spec . xAxisTitle , rotation : 0 , } , { origin : { x : elementSizes . yAxis . left - innerPad ( axisTitle . padding ) - axisTitle . fontSize / 2 , y : elementSizes . grid . top + elementSizes . grid . height / 2 , } , ... axisTitleFont , text : spec . yAxisTitle , rotation : - 90 , } , ] , } , pickQuads , pickDragArea , pickDragShape , pickHighlightedArea , } ; } function getCellKey ( x , y ) { return [ String ( x ) , String ( y ) ] . join ( '&_&' ) ; } function isValueHidden ( value , rangesToHide ) { return rangesToHide . some ( ( [ min , max ] ) => min <= value && value < max ) ; } internal export function isRasterTimeScale ( scale ) { return scale . type === ScaleType . Time ; } function getXTicks ( spec , style , grid , xScale , { xValues , xNumericExtent } ) { const getTextValue = ( formatter , scaleCallback ) => ( value ) : TextBox => { return { text : formatter ( value ) , value , isValue : false , ... style . xAxisLabel , x : scaleCallback ( value ) ?? 0 , y : style . xAxisLabel . fontSize / 2 + pad ( style . xAxisLabel . padding , 'top' ) , } ; } ; if ( isRasterTimeScale ( spec . xScale ) ) { const sampleLabel = spec . xAxisLabelFormatter ( xNumericExtent [ 0 ] ) ; const timeScale = new ScaleContinuous ( { type : ScaleType . Time , domain : xNumericExtent , range : [ 0 , grid . width ] , nice : false , } , { desiredTickCount : estimatedNonOverlappingTickCount ( grid . width , style . xAxisLabel , sampleLabel ) , timeZone : spec . timeZone , } ) ; return timeScale . ticks ( ) . map < TextBox > ( getTextValue ( spec . xAxisLabelFormatter , ( x ) => timeScale . scale ( x ) ) ) ; } return xValues . map < TextBox > ( ( textBox ) => { return { ... getTextValue ( spec . xAxisLabelFormatter , xScale ) ( textBox ) , x : ( xScale ( textBox ) || 0 ) + xScale . bandwidth ( ) / 2 , } ; } ) ; }