UNPKG

veritatisquam

Version:
735 lines (662 loc) 19.2 kB
// Internal Imports import { Component } from '../component'; import { AxisPositions, Events, ScaleTypes, Roles, TruncationTypes, } from '../../interfaces'; import { Tools } from '../../tools'; import { ChartModel } from '../../model'; import { DOMUtils } from '../../services'; import { TickRotations } from '../../interfaces/enums'; import * as Configuration from '../../configuration'; import { computeTimeIntervalName, formatTick, isTickPrimary, } from '../../services/time-series'; // D3 Imports import { select } from 'd3-selection'; import { axisBottom, axisLeft, axisRight, axisTop } from 'd3-axis'; export class Axis extends Component { type = 'axes'; margins: any; scale: any; scaleType: ScaleTypes; constructor(model: ChartModel, services: any, configs?: any) { super(model, services, configs); if (configs) { this.configs = configs; } this.margins = this.configs.margins; } render(animate = true) { const { position: axisPosition } = this.configs; const options = this.getOptions(); const isAxisVisible = Tools.getProperty( options, 'axes', axisPosition, 'visible' ); const svg = this.getContainerSVG(); const { width, height } = DOMUtils.getSVGElementSize(this.parent, { useAttrs: true, }); // Add axis into the parent const container = DOMUtils.appendOrSelect( svg, `g.axis.${axisPosition}` ); let startPosition, endPosition; if ( axisPosition === AxisPositions.BOTTOM || axisPosition === AxisPositions.TOP ) { startPosition = this.configs.axes[AxisPositions.LEFT] ? this.margins.left : 0; endPosition = this.configs.axes[AxisPositions.RIGHT] ? width - this.margins.right : width; } else { startPosition = height - this.margins.bottom; endPosition = this.margins.top; } // Grab the scale off of the Scales service const scale = this.services.cartesianScales.getScaleByPosition( axisPosition ); if (this.scaleType === ScaleTypes.LABELS) { scale.rangeRound([startPosition, endPosition]); } else { scale.range([startPosition, endPosition]); } // Identify the corresponding d3 axis function let axisFunction; switch (axisPosition) { case AxisPositions.LEFT: axisFunction = axisLeft; break; case AxisPositions.BOTTOM: axisFunction = axisBottom; break; case AxisPositions.RIGHT: axisFunction = axisRight; break; case AxisPositions.TOP: axisFunction = axisTop; break; } container.attr('aria-label', `${axisPosition} axis`); const axisRefExists = !container.select(`g.ticks`).empty(); let axisRef = DOMUtils.appendOrSelect(container, `g.ticks`); if (!axisRefExists) { axisRef.attr('role', `${Roles.GRAPHICS_OBJECT} ${Roles.GROUP}`); axisRef.attr('aria-label', `${axisPosition} ticks`); } // We draw the invisible axis because of the async nature of d3 transitions // To be able to tell the final width & height of the axis when initiaing the transition // The invisible axis is updated instantly and without a transition const invisibleAxisRef = DOMUtils.appendOrSelect( container, `g.ticks.invisible` ) .style('opacity', '0') .style('pointer-events', 'none') .attr('aria-hidden', true) .attr('aria-label', `invisible ${axisPosition} ticks`); const axisOptions = Tools.getProperty(options, 'axes', axisPosition); const isTimeScaleType = this.scaleType === ScaleTypes.TIME || axisOptions.scaleType === ScaleTypes.TIME; const isVerticalAxis = axisPosition === AxisPositions.LEFT || axisPosition === AxisPositions.RIGHT; // if zoomDomain is available, scale type is time, and axis position isBOTTOM or TOP // update scale domain to zoomDomain. const zoomDomain = this.model.get('zoomDomain'); if (zoomDomain && isTimeScaleType && !isVerticalAxis) { scale.domain(zoomDomain); } if (!isAxisVisible) { axisRef.attr('aria-hidden', true); return; } const axisScaleType = Tools.getProperty(axisOptions, 'scaleType'); const isDataLoading = Tools.getProperty(options, 'data', 'loading'); const numberOfTicksProvided = Tools.getProperty( axisOptions, 'ticks', 'number' ); // user can provide custom ticks to be displayed // ticks need to be in the domain of the axis data const userProvidedTickValues = Tools.getProperty( axisOptions, 'ticks', 'values' ); // get user provided custom values for truncation const truncationType = Tools.getProperty( axisOptions, 'truncation', 'type' ); const truncationThreshold = Tools.getProperty( axisOptions, 'truncation', 'threshold' ); const truncationNumCharacter = Tools.getProperty( axisOptions, 'truncation', 'numCharacter' ); const isNumberOfTicksProvided = numberOfTicksProvided !== null; const timeScaleOptions = Tools.getProperty(options, 'timeScale'); // Append to DOM a fake tick to get the right computed font height const fakeTick = DOMUtils.appendOrSelect(invisibleAxisRef, `g.tick`); const fakeTickText = DOMUtils.appendOrSelect(fakeTick, `text`).text( '0' ); const tickHeight = DOMUtils.getSVGElementSize(fakeTickText.node(), { useBBox: true, }).height; fakeTick.remove(); const scaleType = this.scaleType || axisOptions.scaleType || ScaleTypes.LINEAR; // Initialize axis object const axis = axisFunction(scale).tickSizeOuter(0); if (scale.ticks) { let numberOfTicks; if (isNumberOfTicksProvided) { numberOfTicks = numberOfTicksProvided; } else { numberOfTicks = Configuration.axis.ticks.number; if (isVerticalAxis) { // Set how many ticks based on height numberOfTicks = this.getNumberOfFittingTicks( height, tickHeight, Configuration.axis.ticks.verticalSpaceRatio ); } } // scale continuous // remove 0 ticks for skeleton if (scale.ticks().length === 1 && scale.ticks()[0] === 0) { numberOfTicks = 0; } axis.ticks(numberOfTicks); if (isTimeScaleType) { if (!scale.ticks(numberOfTicks).length) { axis.tickValues([]); } else { const addSpaceOnEdges = Tools.getProperty( options, 'timeScale', 'addSpaceOnEdges' ); const customDomain = Tools.getProperty( options, 'axes', axisPosition, 'domain' ); let tickValues; // scale.nice() will change scale domain which causes extra space near chart edge // so use another scale instance to avoid impacts to original scale const tempScale = scale.copy(); if (addSpaceOnEdges && !customDomain) { tempScale.nice(numberOfTicks); } tickValues = tempScale.ticks(numberOfTicks); // Remove labels on the edges // If there are more than 2 labels to show if ( addSpaceOnEdges && tickValues.length > 2 && !customDomain ) { tickValues.splice(tickValues.length - 1, 1); tickValues.splice(0, 1); } axis.tickValues(tickValues); } } } // create the right ticks formatter let formatter; const userProvidedFormatter = Tools.getProperty( axisOptions, 'ticks', 'formatter' ); if (isTimeScaleType) { const timeInterval = computeTimeIntervalName(axis.tickValues()); if (userProvidedFormatter === null) { formatter = (t: number, i: number) => formatTick(t, i, timeInterval, timeScaleOptions); } else { formatter = (t: number, i: number) => { const defaultFormattedValue = formatTick( t, i, timeInterval, timeScaleOptions ); return userProvidedFormatter(t, i, defaultFormattedValue); }; } } else { if (userProvidedFormatter === null) { if (scaleType === ScaleTypes.LINEAR) { formatter = (t) => t.toLocaleString(); } } else { formatter = userProvidedFormatter; } } // Set ticks formatter axis.tickFormat(formatter); // prioritize using a custom array of values rather than number of ticks // if both are provided. custom tick values need to be within the domain of the scale const [ lowerBound, upperBound, ] = this.services.cartesianScales .getScaleByPosition(axisPosition) .domain(); let validTicks; if (userProvidedTickValues) { if (isTimeScaleType) { // sanitize user-provided tick values userProvidedTickValues.forEach((userProvidedTickValue, i) => { if (userProvidedTickValue.getTime === undefined) { userProvidedTickValues[i] = new Date( userProvidedTickValue ); } }); // check the supplied ticks are within the time domain validTicks = userProvidedTickValues.filter((tick) => { const tickTimestamp = tick.getTime(); return ( tickTimestamp >= new Date(lowerBound).getTime() && tickTimestamp <= new Date(upperBound).getTime() ); }); } else if (axisScaleType === ScaleTypes.LABELS) { const discreteDomain = this.services.cartesianScales .getScaleByPosition(axisPosition) .domain(); validTicks = userProvidedTickValues.filter((tick) => discreteDomain.includes(tick) ); } else { // continuous scales validTicks = userProvidedTickValues.filter( (tick) => tick >= lowerBound && tick <= upperBound ); } axis.tickValues(validTicks); } // Position and transition the axis switch (axisPosition) { case AxisPositions.LEFT: axisRef.attr('transform', `translate(${this.margins.left}, 0)`); break; case AxisPositions.BOTTOM: axisRef.attr( 'transform', `translate(0, ${height - this.margins.bottom})` ); break; case AxisPositions.RIGHT: axisRef.attr( 'transform', `translate(${width - this.margins.right}, 0)` ); break; case AxisPositions.TOP: axisRef.attr('transform', `translate(0, ${this.margins.top})`); break; } // Position the axis title // check that data exists, if they don't, doesn't show the title axis const isDataEmpty = this.model.isDataEmpty(); if (axisOptions.title) { const axisTitleRef = DOMUtils.appendOrSelect( container, `text.axis-title` ).html(isDataEmpty || isDataLoading ? '' : axisOptions.title); switch (axisPosition) { case AxisPositions.LEFT: axisTitleRef .attr('transform', 'rotate(-90)') .attr('y', 0) .attr('x', -(scale.range()[0] / 2)) .attr('dy', '1em') .style('text-anchor', 'middle'); break; case AxisPositions.BOTTOM: axisTitleRef .attr( 'transform', `translate(${ this.margins.left / 2 + scale.range()[1] / 2 }, ${height})` ) .style('text-anchor', 'middle'); break; case AxisPositions.RIGHT: axisTitleRef .attr('transform', 'rotate(90)') .attr('y', -width) .attr('x', scale.range()[0] / 2) .attr('dy', '1em') .style('text-anchor', 'middle'); break; case AxisPositions.TOP: const { height: titleHeight } = DOMUtils.getSVGElementSize( axisTitleRef, { useBBox: true, } ); axisTitleRef .attr( 'transform', `translate(${ this.margins.left / 2 + scale.range()[1] / 2 }, ${titleHeight / 2})` ) .style('text-anchor', 'middle'); break; } } // Apply new axis to the axis element if (isTimeScaleType) { const timeInterval = computeTimeIntervalName(axis.tickValues()); const showDayName = timeScaleOptions.showDayName; const axisRefSelection = axisRef; if (animate) { axisRef = axisRef.transition( this.services.transitions.getTransition( 'axis-update', animate ) ); } axisRef = axisRef.call(axis); // Manipulate tick labels to make bold those that are in long format const ticks = axisRefSelection .selectAll('.tick') .data(axis.tickValues(), scale) .order() .select('text'); ticks.style('font-weight', (tickValue: number, i: number) => { return isTickPrimary(tickValue, i, timeInterval, showDayName) ? 'bold' : 'normal'; }); } else { if (!animate || !axisRefExists) { axisRef = axisRef.call(axis); } else { axisRef = axisRef .transition( this.services.transitions.getTransition('axis-update') ) .call(axis); } } invisibleAxisRef.call(axis); if ( axisPosition === AxisPositions.BOTTOM || axisPosition === AxisPositions.TOP ) { let shouldRotateTicks = false; // user could decide if tick rotation is required during zoom domain changing const tickRotation = Tools.getProperty( axisOptions, 'ticks', 'rotation' ); if (tickRotation === TickRotations.ALWAYS) { shouldRotateTicks = true; } else if (tickRotation === TickRotations.NEVER) { shouldRotateTicks = false; } else if (!tickRotation || tickRotation === TickRotations.AUTO) { // if the option is not set or set to AUTO // depending on if tick rotation is necessary by calculating space // If we're dealing with a discrete scale type // We're able to grab the spacing between the ticks if (scale.step) { const textNodes = invisibleAxisRef .selectAll('g.tick text') .nodes(); // If any ticks are any larger than the scale step size shouldRotateTicks = textNodes.some( (textNode) => DOMUtils.getSVGElementSize(textNode, { useBBox: true, }).width >= scale.step() ); } else { // When dealing with a continuous scale // We need to calculate an estimated size of the ticks const minTickSize = Tools.getProperty( axisOptions, 'ticks', 'rotateIfSmallerThan' ) || Configuration.axis.ticks.rotateIfSmallerThan; const ticksNumber = isTimeScaleType ? axis.tickValues().length : scale.ticks().length; const estimatedTickSize = width / ticksNumber / 2; shouldRotateTicks = isTimeScaleType ? estimatedTickSize < minTickSize * 2 // datetime tick could be very long : estimatedTickSize < minTickSize; } } if (shouldRotateTicks) { if (!isNumberOfTicksProvided) { axis.ticks( this.getNumberOfFittingTicks( width, tickHeight, Configuration.axis.ticks.horizontalSpaceRatio ) ); invisibleAxisRef.call(axis); axisRef.call(axis); } container .selectAll('g.ticks g.tick text') .attr('transform', `rotate(-45)`) .style( 'text-anchor', axisPosition === AxisPositions.TOP ? 'start' : 'end' ); } else { container .selectAll('g.ticks g.tick text') .attr('transform', null) .style('text-anchor', null); } } // we don't need to show axes on empty state and on skeleton state // because the Skeleton component draws them if (isDataLoading) { container.attr('opacity', 0); } else { container.attr('opacity', 1); } axisRef.selectAll('g.tick').attr('aria-label', (d) => d); invisibleAxisRef.selectAll('g.tick').attr('aria-label', (d) => d); // truncate the label if it's too long // only applies to discrete type if ( truncationType !== TruncationTypes.NONE && axisScaleType === ScaleTypes.LABELS && !userProvidedTickValues ) { const axisTickLabels = this.services.cartesianScales.getScaleDomain( axisPosition ); if (axisTickLabels.length > 0) { const tick_html = svg .select(`g.axis.${axisPosition} g.ticks g.tick`) .html(); container.selectAll('g.ticks g.tick').html(tick_html); container .selectAll('g.tick text') .data(axisTickLabels) .text(function (d) { if (d.length > truncationThreshold) { return Tools.truncateLabel( d, truncationType, truncationNumCharacter ); } else { return d; } }); this.getInvisibleAxisRef() .selectAll('g.tick text') .data(axisTickLabels) .text(function (d) { if (d.length > truncationThreshold) { return Tools.truncateLabel( d, truncationType, truncationNumCharacter ); } else { return d; } }); container .selectAll('g.ticks') .html(this.getInvisibleAxisRef().html()); container.selectAll('g.tick text').data(axisTickLabels); } } // Add event listeners to elements drawn this.addEventListeners(); } addEventListeners() { const svg = this.getContainerSVG(); const { position: axisPosition } = this.configs; const container = DOMUtils.appendOrSelect( svg, `g.axis.${axisPosition}` ); const options = this.getOptions(); const axisOptions = Tools.getProperty(options, 'axes', axisPosition); const axisScaleType = Tools.getProperty(axisOptions, 'scaleType'); const truncationThreshold = Tools.getProperty( axisOptions, 'truncation', 'threshold' ); const isTimeScaleType = this.scaleType === ScaleTypes.TIME || axisOptions.scaleType === ScaleTypes.TIME; const self = this; container .selectAll('g.tick text') .on('mouseover', function (datum) { // Dispatch mouse event self.services.events.dispatchEvent( Events.Axis.LABEL_MOUSEOVER, { element: select(this), datum, } ); if ( axisScaleType === ScaleTypes.LABELS && datum.length > truncationThreshold ) { self.services.events.dispatchEvent(Events.Tooltip.SHOW, { hoveredElement: select(this), content: datum, }); } }) .on('mousemove', function (datum) { // Dispatch mouse event self.services.events.dispatchEvent( Events.Axis.LABEL_MOUSEMOVE, { element: select(this), datum, } ); if ( axisScaleType === ScaleTypes.LABELS && datum.length > truncationThreshold ) { self.services.events.dispatchEvent(Events.Tooltip.MOVE); } }) .on('click', function (datum) { // Dispatch mouse event self.services.events.dispatchEvent(Events.Axis.LABEL_CLICK, { element: select(this), datum, }); }) .on('mouseout', function (datum) { // Dispatch mouse event self.services.events.dispatchEvent(Events.Axis.LABEL_MOUSEOUT, { element: select(this), datum, }); if (axisScaleType === ScaleTypes.LABELS) { self.services.events.dispatchEvent(Events.Tooltip.HIDE); } }); } getInvisibleAxisRef() { const { position: axisPosition } = this.configs; return this.getContainerSVG().select( `g.axis.${axisPosition} g.ticks.invisible` ); } getTitleRef() { const { position: axisPosition } = this.configs; return this.getContainerSVG().select( `g.axis.${axisPosition} text.axis-title` ); } getNumberOfFittingTicks(size, tickSize, spaceRatio) { const numberOfTicksFit = Math.floor(size / (tickSize * spaceRatio)); return Tools.clamp( numberOfTicksFit, 2, Configuration.axis.ticks.number ); } destroy() { const svg = this.getContainerSVG(); const { position: axisPosition } = this.configs; const container = DOMUtils.appendOrSelect( svg, `g.axis.${axisPosition}` ); // Remove event listeners container .selectAll('g.tick text') .on('mouseover', null) .on('mousemove', null) .on('mouseout', null); } }