import { GOLDEN_RATIO , TAU } from '../../../../common/constants' ; import { PointObject , Radian , Rectangle } from '../../../../common/geometry' ; import { cssFontShorthand , Font } from '../../../../common/text_utils' ; import { CanvasRenderer } from '../../../../renderers/canvas' ; import { measureText } from '../../../../utils/bbox/canvas_text_bbox_calculator' ; import { Dimensions } from '../../../../utils/dimensions' ; import { Theme } from '../../../../utils/themes/theme' ; import { GoalSubtype } from '../../specs/constants' ; import { BulletViewModel } from '../types/viewmodel_types' ; import { getSagitta , getMinSagitta } from './utils' ; const referenceCircularSizeCap = 360 ; const referenceBulletSizeCap = 500 ; const barThicknessMinSizeRatio = 1 / 10 ; const baselineArcThickness = 32 ; const baselineBarThickness = 32 ; const marginRatio = 0.05 ; const maxTickFontSize = 24 ; const maxLabelFontSize = 32 ; const maxCentralFontSize = 38 ; const arcBoxSamplePitch = ( 5 / 360 ) * TAU ; const capturePad = 16 ; internal export interface Mark { boundingBoxes : ( ctx : CanvasRenderingContext2D ) => Rectangle [ ] ; render : CanvasRenderer ; } internal export class Section implements Mark { protected readonly x : number ; protected readonly y : number ; protected readonly xTo : number ; protected readonly yTo : number ; protected readonly lineWidth : number ; protected readonly strokeStyle : string ; constructor ( x , y , xTo , yTo , lineWidth , strokeStyle ) { this . x = x ; this . y = y ; this . xTo = xTo ; this . yTo = yTo ; this . lineWidth = lineWidth ; this . strokeStyle = strokeStyle ; } boundingBoxes ( ) { return this . lineWidth === 0 ? [ ] : [ { x0 : Math . min ( this . x , this . xTo ) - this . lineWidth / 2 - capturePad , y0 : Math . min ( this . y , this . yTo ) - this . lineWidth / 2 - capturePad , x1 : Math . max ( this . x , this . xTo ) + this . lineWidth / 2 + capturePad , y1 : Math . max ( this . y , this . yTo ) + this . lineWidth / 2 + capturePad , } , ] ; } render ( ctx ) { ctx . beginPath ( ) ; ctx . lineWidth = this . lineWidth ; ctx . strokeStyle = this . strokeStyle ; ctx . moveTo ( this . x , this . y ) ; ctx . lineTo ( this . xTo , this . yTo ) ; ctx . stroke ( ) ; } } internal export const initialBoundingBox = ( ) : Rectangle => ( { x0 : Infinity , y0 : Infinity , x1 : - Infinity , y1 : - Infinity } ) ; internal export class Arc implements Mark { protected readonly x : number ; protected readonly y : number ; protected readonly radius : number ; protected readonly startAngle : Radian ; protected readonly endAngle : Radian ; protected readonly anticlockwise : boolean ; protected readonly lineWidth : number ; protected readonly strokeStyle : string ; constructor ( x , y , radius , startAngle , endAngle , anticlockwise , lineWidth , strokeStyle ) { this . x = x ; this . y = y ; this . radius = radius ; this . startAngle = startAngle ; this . endAngle = endAngle ; this . anticlockwise = anticlockwise ; this . lineWidth = lineWidth ; this . strokeStyle = strokeStyle ; } boundingBoxes ( ) { if ( this . lineWidth === 0 ) return [ ] ; const box = initialBoundingBox ( ) ; const rotationCount = Math . ceil ( Math . max ( 0 , - this . startAngle , - this . endAngle ) / TAU ) ; const startAngle = this . startAngle + rotationCount * TAU ; const endAngle = this . endAngle + rotationCount * TAU ; const angleFrom = Math . round ( startAngle / arcBoxSamplePitch ) * arcBoxSamplePitch ; const angleTo = Math . round ( endAngle / arcBoxSamplePitch ) * arcBoxSamplePitch ; const signedIncrement = arcBoxSamplePitch * Math . sign ( angleTo - angleFrom ) ; for ( let angle = angleFrom ; angle <= angleTo ; angle += signedIncrement ) { const vx = Math . cos ( angle ) ; const vy = Math . sin ( angle ) ; const innerRadius = this . radius - this . lineWidth / 2 ; const outerRadius = this . radius + this . lineWidth / 2 ; const innerX = this . x + vx * innerRadius ; const innerY = this . y + vy * innerRadius ; const outerX = this . x + vx * outerRadius ; const outerY = this . y + vy * outerRadius ; box . x0 = Math . min ( box . x0 , innerX - capturePad , outerX - capturePad ) ; box . y0 = Math . min ( box . y0 , innerY - capturePad , outerY - capturePad ) ; box . x1 = Math . max ( box . x1 , innerX + capturePad , outerX + capturePad ) ; box . y1 = Math . max ( box . y1 , innerY + capturePad , outerY + capturePad ) ; if ( signedIncrement === 0 ) break ; } return Number . isFinite ( box . x0 ) ? [ box ] : [ ] ; } render ( ctx ) { ctx . beginPath ( ) ; ctx . lineWidth = this . lineWidth ; ctx . strokeStyle = this . strokeStyle ; ctx . arc ( this . x , this . y , this . radius , this . startAngle , this . endAngle , this . anticlockwise ) ; ctx . stroke ( ) ; } } internal export class Text implements Mark { protected readonly x : number ; protected readonly y : number ; protected readonly text : string ; protected readonly textAlign : CanvasTextAlign ; protected readonly textBaseline : CanvasTextBaseline ; protected readonly fontShape : Font ; protected readonly fontSize : number ; protected readonly fillStyle : string ; constructor ( x , y , text , textAlign , textBaseline , fontShape , fontSize , fillStyle ) { this . x = x ; this . y = y ; this . text = text ; this . textAlign = textAlign ; this . textBaseline = textBaseline ; this . fontShape = fontShape ; this . fontSize = fontSize ; this . fillStyle = fillStyle ; } setCanvasTextState ( ctx ) { ctx . textAlign = this . textAlign ; ctx . textBaseline = this . textBaseline ; ctx . font = cssFontShorthand ( this . fontShape , this . fontSize ) ; } boundingBoxes ( ctx ) { if ( this . text . length === 0 ) return [ ] ; const box = measureText ( ctx ) ( this . text , this . fontShape , this . fontSize ) ; return [ { x0 : - box . width / 2 + this . x - capturePad , y0 : - box . height / 2 + this . y - capturePad , x1 : box . width / 2 + this . x + capturePad , y1 : box . height / 2 + this . y + capturePad , } , ] ; } render ( ctx ) { this . setCanvasTextState ( ctx ) ; ctx . beginPath ( ) ; ctx . fillStyle = this . fillStyle ; ctx . fillText ( this . text , this . x , this . y ) ; } } function get < T > ( o , name , dflt ) { return name in o ? o [ name ] || dflt : dflt ; } internal export function geoms ( bulletViewModel , theme , parentDimensions , chartCenter ) { const { subtype , lowestValue , highestValue , base , target , actual , bands , ticks , labelMajor , labelMinor , centralMajor , centralMinor , angleStart , angleEnd , } = bulletViewModel ; const circular = subtype === GoalSubtype . Goal ; const vertical = subtype === GoalSubtype . VerticalBullet ; const domain = [ lowestValue , highestValue ] ; const data = { base : { value : base } , ... Object . fromEntries ( bands . map ( ( { value } , index ) => [ `qualitative_${ index }` , { value } ] ) ) , target : { value : target } , actual : { value : actual } , yOffset : { value : 0 } , labelMajor : { value : domain [ circular || ! vertical ? 0 : 1 ] , text : labelMajor } , labelMinor : { value : domain [ circular || ! vertical ? 0 : 1 ] , text : labelMinor } , ... Object . assign ( { } , ... ticks . map ( ( { value , text } , i ) => ( { [ `tick_${ i }` ] : { value , text } } ) ) ) , ... ( circular ? { centralMajor : { value : 0 , text : centralMajor } , centralMinor : { value : 0 , text : centralMinor } , } : { } ) , } ; const minSize = Math . min ( parentDimensions . width , parentDimensions . height ) ; const referenceSize = Math . min ( circular ? referenceCircularSizeCap : referenceBulletSizeCap , circular ? minSize : vertical ? parentDimensions . height : parentDimensions . width ) * ( 1 - 2 * marginRatio ) ; const barThickness = Math . min ( circular ? baselineArcThickness : baselineBarThickness , referenceSize * barThicknessMinSizeRatio ) ; const tickLength = barThickness * Math . pow ( 1 / GOLDEN_RATIO , 3 ) ; const tickOffset = - tickLength / 2 - barThickness / 2 ; const tickFontSize = Math . min ( maxTickFontSize , referenceSize / 25 ) ; const labelFontSize = Math . min ( maxLabelFontSize , referenceSize / 18 ) ; const centralFontSize = Math . min ( maxCentralFontSize , referenceSize / 14 ) ; const shape = circular ? 'arc' : 'line' ; const abstractGeoms = [ ... bulletViewModel . bands . map ( ( b , i ) => ( { order : 0 , landmarks : { from : i ? `qualitative_${ i - 1 }` : 'base' , to : `qualitative_${ i }` , yOffset : 'yOffset' , } , aes : { shape , fillColor : b . fillColor , lineWidth : barThickness } , } ) ) , { order : 1 , landmarks : { from : 'base' , to : 'actual' , yOffset : 'yOffset' } , aes : { shape , fillColor : theme . progressLine . stroke , lineWidth : tickLength } , } , ... ( target ? [ { order : 2 , landmarks : { at : 'target' , yOffset : 'yOffset' } , aes : { shape , fillColor : theme . targetLine . stroke , lineWidth : barThickness / GOLDEN_RATIO } , } , ] : [ ] ) , ... bulletViewModel . ticks . map ( ( b , i ) => ( { order : 3 , landmarks : { at : `tick_${ i }` , yOffset : 'yOffset' } , aes : { shape , fillColor : theme . tickLine . stroke , lineWidth : tickLength , axisNormalOffset : tickOffset } , } ) ) , ... bulletViewModel . ticks . map ( ( b , i ) => ( { order : 4 , landmarks : { at : `tick_${ i }` , yOffset : 'yOffset' } , aes : { shape : 'text' , textAlign : vertical ? 'right' : 'center' , textBaseline : vertical ? 'middle' : 'top' , fillColor : theme . tickLabel . fill , fontShape : { ... theme . tickLabel , fontVariant : 'normal' , fontWeight : '500' } , axisNormalOffset : - barThickness , } , } ) ) , { order : 5 , landmarks : { at : 'labelMajor' } , aes : { shape : 'text' , axisNormalOffset : 0 , axisTangentOffset : circular || ! vertical ? 0 : 2 * labelFontSize , textAlign : vertical ? 'center' : 'right' , textBaseline : 'bottom' , fillColor : theme . majorLabel . fill , fontShape : { ... theme . majorLabel , fontVariant : 'normal' , fontWeight : '900' } , } , } , { order : 5 , landmarks : { at : 'labelMinor' } , aes : { shape : 'text' , axisNormalOffset : 0 , axisTangentOffset : circular || ! vertical ? 0 : 2 * labelFontSize , textAlign : vertical ? 'center' : 'right' , textBaseline : 'top' , fillColor : theme . minorLabel . fill , fontShape : { ... theme . minorLabel , fontVariant : 'normal' , fontWeight : '300' } , } , } , ... ( circular ? [ { order : 6 , landmarks : { at : 'centralMajor' , yOffset : 'yOffset' } , aes : { shape : 'text' , textAlign : 'center' , textBaseline : 'bottom' , fillColor : theme . majorCenterLabel . fill , fontShape : { ... theme . majorCenterLabel , fontVariant : 'normal' , fontWeight : '900' } , } , } , { order : 6 , landmarks : { at : 'centralMinor' , yOffset : 'yOffset' } , aes : { shape : 'text' , textAlign : 'center' , textBaseline : 'top' , fillColor : theme . minorCenterLabel . fill , fontShape : { ... theme . minorCenterLabel , fontVariant : 'normal' , fontWeight : '300' } , } , } , ] : [ ] ) , ] ; const maxWidth = abstractGeoms . reduce ( ( p , g ) => Math . max ( p , get < number > ( g . aes , 'lineWidth' , 0 ) ) , 0 ) ; const r = 0.5 * referenceSize - maxWidth / 2 ; if ( circular ) { const sagitta = getMinSagitta ( angleStart , angleEnd , r ) ; const maxSagitta = getSagitta ( ( 3 / 2 ) * Math . PI , r ) ; data . yOffset . value = sagitta >= maxSagitta ? 0 : ( maxSagitta - sagitta ) / 2 ; } const fullSize = referenceSize ; const labelSize = fullSize / 2 ; const pxRangeFrom = - fullSize / 2 + ( circular || vertical ? 0 : labelSize ) ; const pxRangeTo = fullSize / 2 + ( ! circular && vertical ? - 2 * labelFontSize : 0 ) ; const pxRangeMid = ( pxRangeFrom + pxRangeTo ) / 2 ; const pxRange = pxRangeTo - pxRangeFrom ; const domainExtent = domain [ 1 ] - domain [ 0 ] ; const linearScale = ( x ) => pxRangeFrom + ( pxRange * ( x - domain [ 0 ] ) ) / domainExtent ; const angleRange = angleEnd - angleStart ; const angleScale = ( x ) => angleStart + ( angleRange * ( x - domain [ 0 ] ) ) / domainExtent ; const clockwise = angleStart > angleEnd ; return [ ... abstractGeoms ] . sort ( ( a , b ) => a . order - b . order ) . map ( ( { landmarks , aes } ) => { const at = get ( landmarks , 'at' , '' ) ; const from = get ( landmarks , 'from' , '' ) ; const to = get ( landmarks , 'to' , '' ) ; const yOffset = get ( landmarks , 'yOffset' , '' ) ; const textAlign = circular ? 'center' : get ( aes , 'textAlign' , '' ) ; const fontShape = get ( aes , 'fontShape' , '' ) ; const axisNormalOffset = get ( aes , 'axisNormalOffset' , 0 ) ; const axisTangentOffset = get ( aes , 'axisTangentOffset' , 0 ) ; const lineWidth = get ( aes , 'lineWidth' , 0 ) ; const yOffsetValue = data [ yOffset ] ?. value ?? 0 ; const strokeStyle = get ( aes , 'fillColor' , '' ) ; if ( aes . shape === 'text' ) { const { text } = data [ at ] ; const label = at . slice ( 0 , 5 ) === 'label' ; const central = at . slice ( 0 , 7 ) === 'central' ; const textBaseline = label || central || ! circular ? get ( aes , 'textBaseline' , '' ) : 'middle' ; const fontSize = circular && label ? labelFontSize : circular && central ? centralFontSize : tickFontSize ; const scaledValue = circular ? angleScale ( data [ at ] . value ) : data [ at ] && linearScale ( data [ at ] . value ) ; const x = circular ? ( label || central ? 0 : ( r - GOLDEN_RATIO * barThickness ) * Math . cos ( scaledValue ) ) : ( vertical ? axisNormalOffset : axisTangentOffset + scaledValue ) ; const y = circular ? ( label ? r : central ? 0 : - ( r - GOLDEN_RATIO * barThickness ) * Math . sin ( scaledValue ) ) : ( vertical ? - axisTangentOffset - scaledValue : - axisNormalOffset ) ; return new Text ( x + chartCenter . x , y + chartCenter . y + yOffsetValue , text , textAlign , textBaseline , fontShape , fontSize , strokeStyle ) ; } else if ( aes . shape === 'arc' ) { const cx = chartCenter . x + pxRangeMid ; const cy = chartCenter . y + yOffsetValue ; const radius = at ? r + axisNormalOffset : r ; const startAngle = at ? angleScale ( data [ at ] . value ) + Math . PI / 360 : angleScale ( data [ from ] . value ) ; const endAngle = at ? angleScale ( data [ at ] . value ) - Math . PI / 360 : angleScale ( data [ to ] . value ) ; const anticlockwise = at || clockwise === ( data [ from ] . value < data [ to ] . value ) ; return new Arc ( cx , cy , radius , - startAngle , - endAngle , ! anticlockwise , lineWidth , strokeStyle ) ; } else { const translateX = chartCenter . x + ( vertical ? axisNormalOffset : axisTangentOffset ) ; const translateY = chartCenter . y - ( vertical ? axisTangentOffset : axisNormalOffset ) + yOffsetValue ; const atPx = data [ at ] && linearScale ( data [ at ] . value ) ; const fromPx = at ? atPx - 1 : linearScale ( data [ from ] . value ) ; const toPx = at ? atPx + 1 : linearScale ( data [ to ] . value ) ; const x0 = vertical ? translateX : translateX + fromPx ; const y0 = vertical ? translateY - fromPx : translateY ; const x1 = vertical ? translateX : translateX + toPx ; const y1 = vertical ? translateY - toPx : translateY ; return new Section ( x0 , y0 , x1 , y1 , lineWidth , strokeStyle ) ; } } ) ; }