import type { BoundingRect, Dimension, DrawableElem, Point, XYChartAxisConfig, XYChartAxisThemeConfig, } from '../../interfaces.js'; import type { TextDimensionCalculator } from '../../textDimensionCalculator.js'; import type { Axis, AxisPosition } from './index.js'; const BAR_WIDTH_TO_TICK_WIDTH_RATIO = 0.7; const MAX_OUTER_PADDING_PERCENT_FOR_WRT_LABEL = 0.2; export abstract class BaseAxis implements Axis { protected boundingRect: BoundingRect = { x: 0, y: 0, width: 0, height: 0 }; protected axisPosition: AxisPosition = 'left'; private range: [number, number]; protected showTitle = false; protected showLabel = false; protected showTick = false; protected showAxisLine = false; protected outerPadding = 0; protected titleTextHeight = 0; protected labelTextHeight = 0; constructor( protected axisConfig: XYChartAxisConfig, protected title: string, protected textDimensionCalculator: TextDimensionCalculator, protected axisThemeConfig: XYChartAxisThemeConfig ) { this.range = [0, 10]; this.boundingRect = { x: 0, y: 0, width: 0, height: 0 }; this.axisPosition = 'left'; } setRange(range: [number, number]): void { this.range = range; if (this.axisPosition === 'left' || this.axisPosition === 'right') { this.boundingRect.height = range[1] - range[0]; } else { this.boundingRect.width = range[1] - range[0]; } this.recalculateScale(); } getRange(): [number, number] { return [this.range[0] + this.outerPadding, this.range[1] - this.outerPadding]; } setAxisPosition(axisPosition: AxisPosition): void { this.axisPosition = axisPosition; this.setRange(this.range); } abstract getScaleValue(value: number | string): number; abstract recalculateScale(): void; abstract getTickValues(): (string | number)[]; getTickDistance(): number { const range = this.getRange(); return Math.abs(range[0] - range[1]) / this.getTickValues().length; } getAxisOuterPadding(): number { return this.outerPadding; } private getLabelDimension(): Dimension { return this.textDimensionCalculator.getMaxDimension( this.getTickValues().map((tick) => tick.toString()), this.axisConfig.labelFontSize ); } recalculateOuterPaddingToDrawBar(): void { if (BAR_WIDTH_TO_TICK_WIDTH_RATIO * this.getTickDistance() > this.outerPadding * 2) { this.outerPadding = Math.floor((BAR_WIDTH_TO_TICK_WIDTH_RATIO * this.getTickDistance()) / 2); } this.recalculateScale(); } private calculateSpaceIfDrawnHorizontally(availableSpace: Dimension) { let availableHeight = availableSpace.height; if (this.axisConfig.showAxisLine && availableHeight > this.axisConfig.axisLineWidth) { availableHeight -= this.axisConfig.axisLineWidth; this.showAxisLine = true; } if (this.axisConfig.showLabel) { const spaceRequired = this.getLabelDimension(); const maxPadding = MAX_OUTER_PADDING_PERCENT_FOR_WRT_LABEL * availableSpace.width; this.outerPadding = Math.min(spaceRequired.width / 2, maxPadding); const heightRequired = spaceRequired.height + this.axisConfig.labelPadding * 2; this.labelTextHeight = spaceRequired.height; if (heightRequired <= availableHeight) { availableHeight -= heightRequired; this.showLabel = true; } } if (this.axisConfig.showTick && availableHeight >= this.axisConfig.tickLength) { this.showTick = true; availableHeight -= this.axisConfig.tickLength; } if (this.axisConfig.showTitle && this.title) { const spaceRequired = this.textDimensionCalculator.getMaxDimension( [this.title], this.axisConfig.titleFontSize ); const heightRequired = spaceRequired.height + this.axisConfig.titlePadding * 2; this.titleTextHeight = spaceRequired.height; if (heightRequired <= availableHeight) { availableHeight -= heightRequired; this.showTitle = true; } } this.boundingRect.width = availableSpace.width; this.boundingRect.height = availableSpace.height - availableHeight; } private calculateSpaceIfDrawnVertical(availableSpace: Dimension) { let availableWidth = availableSpace.width; if (this.axisConfig.showAxisLine && availableWidth > this.axisConfig.axisLineWidth) { availableWidth -= this.axisConfig.axisLineWidth; this.showAxisLine = true; } if (this.axisConfig.showLabel) { const spaceRequired = this.getLabelDimension(); const maxPadding = MAX_OUTER_PADDING_PERCENT_FOR_WRT_LABEL * availableSpace.height; this.outerPadding = Math.min(spaceRequired.height / 2, maxPadding); const widthRequired = spaceRequired.width + this.axisConfig.labelPadding * 2; if (widthRequired <= availableWidth) { availableWidth -= widthRequired; this.showLabel = true; } } if (this.axisConfig.showTick && availableWidth >= this.axisConfig.tickLength) { this.showTick = true; availableWidth -= this.axisConfig.tickLength; } if (this.axisConfig.showTitle && this.title) { const spaceRequired = this.textDimensionCalculator.getMaxDimension( [this.title], this.axisConfig.titleFontSize ); const widthRequired = spaceRequired.height + this.axisConfig.titlePadding * 2; this.titleTextHeight = spaceRequired.height; if (widthRequired <= availableWidth) { availableWidth -= widthRequired; this.showTitle = true; } } this.boundingRect.width = availableSpace.width - availableWidth; this.boundingRect.height = availableSpace.height; } calculateSpace(availableSpace: Dimension): Dimension { if (this.axisPosition === 'left' || this.axisPosition === 'right') { this.calculateSpaceIfDrawnVertical(availableSpace); } else { this.calculateSpaceIfDrawnHorizontally(availableSpace); } this.recalculateScale(); return { width: this.boundingRect.width, height: this.boundingRect.height, }; } setBoundingBoxXY(point: Point): void { this.boundingRect.x = point.x; this.boundingRect.y = point.y; } private getDrawableElementsForLeftAxis(): DrawableElem[] { const drawableElement: DrawableElem[] = []; if (this.showAxisLine) { const x = this.boundingRect.x + this.boundingRect.width - this.axisConfig.axisLineWidth / 2; drawableElement.push({ type: 'path', groupTexts: ['left-axis', 'axisl-line'], data: [ { path: `M ${x},${this.boundingRect.y} L ${x},${ this.boundingRect.y + this.boundingRect.height } `, strokeFill: this.axisThemeConfig.axisLineColor, strokeWidth: this.axisConfig.axisLineWidth, }, ], }); } if (this.showLabel) { drawableElement.push({ type: 'text', groupTexts: ['left-axis', 'label'], data: this.getTickValues().map((tick) => ({ text: tick.toString(), x: this.boundingRect.x + this.boundingRect.width - (this.showLabel ? this.axisConfig.labelPadding : 0) - (this.showTick ? this.axisConfig.tickLength : 0) - (this.showAxisLine ? this.axisConfig.axisLineWidth : 0), y: this.getScaleValue(tick), fill: this.axisThemeConfig.labelColor, fontSize: this.axisConfig.labelFontSize, rotation: 0, verticalPos: 'middle', horizontalPos: 'right', })), }); } if (this.showTick) { const x = this.boundingRect.x + this.boundingRect.width - (this.showAxisLine ? this.axisConfig.axisLineWidth : 0); drawableElement.push({ type: 'path', groupTexts: ['left-axis', 'ticks'], data: this.getTickValues().map((tick) => ({ path: `M ${x},${this.getScaleValue(tick)} L ${ x - this.axisConfig.tickLength },${this.getScaleValue(tick)}`, strokeFill: this.axisThemeConfig.tickColor, strokeWidth: this.axisConfig.tickWidth, })), }); } if (this.showTitle) { drawableElement.push({ type: 'text', groupTexts: ['left-axis', 'title'], data: [ { text: this.title, x: this.boundingRect.x + this.axisConfig.titlePadding, y: this.boundingRect.y + this.boundingRect.height / 2, fill: this.axisThemeConfig.titleColor, fontSize: this.axisConfig.titleFontSize, rotation: 270, verticalPos: 'top', horizontalPos: 'center', }, ], }); } return drawableElement; } private getDrawableElementsForBottomAxis(): DrawableElem[] { const drawableElement: DrawableElem[] = []; if (this.showAxisLine) { const y = this.boundingRect.y + this.axisConfig.axisLineWidth / 2; drawableElement.push({ type: 'path', groupTexts: ['bottom-axis', 'axis-line'], data: [ { path: `M ${this.boundingRect.x},${y} L ${ this.boundingRect.x + this.boundingRect.width },${y}`, strokeFill: this.axisThemeConfig.axisLineColor, strokeWidth: this.axisConfig.axisLineWidth, }, ], }); } if (this.showLabel) { drawableElement.push({ type: 'text', groupTexts: ['bottom-axis', 'label'], data: this.getTickValues().map((tick) => ({ text: tick.toString(), x: this.getScaleValue(tick), y: this.boundingRect.y + this.axisConfig.labelPadding + (this.showTick ? this.axisConfig.tickLength : 0) + (this.showAxisLine ? this.axisConfig.axisLineWidth : 0), fill: this.axisThemeConfig.labelColor, fontSize: this.axisConfig.labelFontSize, rotation: 0, verticalPos: 'top', horizontalPos: 'center', })), }); } if (this.showTick) { const y = this.boundingRect.y + (this.showAxisLine ? this.axisConfig.axisLineWidth : 0); drawableElement.push({ type: 'path', groupTexts: ['bottom-axis', 'ticks'], data: this.getTickValues().map((tick) => ({ path: `M ${this.getScaleValue(tick)},${y} L ${this.getScaleValue(tick)},${ y + this.axisConfig.tickLength }`, strokeFill: this.axisThemeConfig.tickColor, strokeWidth: this.axisConfig.tickWidth, })), }); } if (this.showTitle) { drawableElement.push({ type: 'text', groupTexts: ['bottom-axis', 'title'], data: [ { text: this.title, x: this.range[0] + (this.range[1] - this.range[0]) / 2, y: this.boundingRect.y + this.boundingRect.height - this.axisConfig.titlePadding - this.titleTextHeight, fill: this.axisThemeConfig.titleColor, fontSize: this.axisConfig.titleFontSize, rotation: 0, verticalPos: 'top', horizontalPos: 'center', }, ], }); } return drawableElement; } private getDrawableElementsForTopAxis(): DrawableElem[] { const drawableElement: DrawableElem[] = []; if (this.showAxisLine) { const y = this.boundingRect.y + this.boundingRect.height - this.axisConfig.axisLineWidth / 2; drawableElement.push({ type: 'path', groupTexts: ['top-axis', 'axis-line'], data: [ { path: `M ${this.boundingRect.x},${y} L ${ this.boundingRect.x + this.boundingRect.width },${y}`, strokeFill: this.axisThemeConfig.axisLineColor, strokeWidth: this.axisConfig.axisLineWidth, }, ], }); } if (this.showLabel) { drawableElement.push({ type: 'text', groupTexts: ['top-axis', 'label'], data: this.getTickValues().map((tick) => ({ text: tick.toString(), x: this.getScaleValue(tick), y: this.boundingRect.y + (this.showTitle ? this.titleTextHeight + this.axisConfig.titlePadding * 2 : 0) + this.axisConfig.labelPadding, fill: this.axisThemeConfig.labelColor, fontSize: this.axisConfig.labelFontSize, rotation: 0, verticalPos: 'top', horizontalPos: 'center', })), }); } if (this.showTick) { const y = this.boundingRect.y; drawableElement.push({ type: 'path', groupTexts: ['top-axis', 'ticks'], data: this.getTickValues().map((tick) => ({ path: `M ${this.getScaleValue(tick)},${ y + this.boundingRect.height - (this.showAxisLine ? this.axisConfig.axisLineWidth : 0) } L ${this.getScaleValue(tick)},${ y + this.boundingRect.height - this.axisConfig.tickLength - (this.showAxisLine ? this.axisConfig.axisLineWidth : 0) }`, strokeFill: this.axisThemeConfig.tickColor, strokeWidth: this.axisConfig.tickWidth, })), }); } if (this.showTitle) { drawableElement.push({ type: 'text', groupTexts: ['top-axis', 'title'], data: [ { text: this.title, x: this.boundingRect.x + this.boundingRect.width / 2, y: this.boundingRect.y + this.axisConfig.titlePadding, fill: this.axisThemeConfig.titleColor, fontSize: this.axisConfig.titleFontSize, rotation: 0, verticalPos: 'top', horizontalPos: 'center', }, ], }); } return drawableElement; } getDrawableElements(): DrawableElem[] { if (this.axisPosition === 'left') { return this.getDrawableElementsForLeftAxis(); } if (this.axisPosition === 'right') { throw Error('Drawing of right axis is not implemented'); } if (this.axisPosition === 'bottom') { return this.getDrawableElementsForBottomAxis(); } if (this.axisPosition === 'top') { return this.getDrawableElementsForTopAxis(); } return []; } }