UNPKG

@nativescript-community/ui-chart

Version:

A powerful chart / graph plugin, supporting line, bar, pie, radar, bubble, and candlestick charts as well as scaling, panning and animations.

796 lines (795 loc) 27.4 kB
import { Align, Canvas, CanvasView } from '@nativescript-community/ui-canvas'; import { Utils as NUtils, Trace } from '@nativescript/core'; import { ChartAnimator } from '../animation/ChartAnimator'; import { Description } from '../components/Description'; import { Legend } from '../components/Legend'; import { XAxis } from '../components/XAxis'; import { DefaultValueFormatter } from '../formatter/DefaultValueFormatter'; import { LegendRenderer } from '../renderer/LegendRenderer'; import { CLog, CLogTypes, Utils } from '../utils/Utils'; import { ViewPortHandler } from '../utils/ViewPortHandler'; const LOG_TAG = 'NSChart'; /** * Baseclass of all Chart-Views. * */ export class Chart extends CanvasView { /** * default constructor for initialization in code */ constructor() { super(); /** * object that holds all data that was originally set for the chart, before * it was modified or any filtering algorithms had been applied */ this.mData = null; /** * If set to true, chart continues to scroll after touch up */ this.dragDecelerationEnabled = true; /** * Deceleration friction coefficient in [0 ; 1] interval, higher values * indicate that speed will decrease slowly, for example if it set to 0, it * will stop immediately. value must be < 1.0 */ this.dragDecelerationFrictionCoef = 0.9; /** * the default IValueFormatter that has been determined by the chart * considering the provided minimum and maximum values, number of digits depends on provided chart-data */ this.defaultValueFormatter = new DefaultValueFormatter(0); /** * text that is displayed when the chart is empty */ this.noDataText = null; /** * object that manages the bounds and drawing constraints of the chart */ this.viewPortHandler = new ViewPortHandler(); /** * Extra offsets to be appended to the viewport */ this.extraTopOffset = 0; this.extraRightOffset = 0; this.extraBottomOffset = 0; this.extraLeftOffset = 0; /** * let the chart know it does not need to compute autoScale * (it can used the cached ones) */ this.noComputeAutoScaleOnNextDraw = false; /** * let the chart know it does not need to compute axis and legends * (it can used the cached ones) */ this.noComputeAxisOnNextDraw = false; /** * The maximum distance in dp away from an entry causing it to highlight. */ this.maxHighlightDistance = 500; /** * Wether to filter highlights by axis. Default is true */ this.highlightsFilterByAxis = true; /** * Wether to disable scroll while touching the chart. Default to true */ this.disableScrollEnabled = true; /** * tasks to be done after the view is setup */ this.jobs = []; // for performance tracking this.totalTime = 0; this.drawCycles = 0; // /** // * Set a new (e.g. custom) ChartTouchListener NOTE: make sure to // * setTouchEnabled(true); if you need touch gestures on the chart // * // * @param l // */ // public setOnTouchListener(ChartTouchListener l) { // this.chartTouchListener = l; // } // /** // * Returns an instance of the currently active touch listener. // * // * @return // */ // public ChartTouchListener getOnTouchListener() { // return this.chartTouchListener; // } /** * ################ ################ ################ ################ */ /** BELOW CODE IS FOR THE MARKER VIEW */ /** * if set to true, the marker view is drawn when a value is clicked */ this.drawMarkersEnabled = true; this.mDisallowInterceptTouchEvent = true; this.init(); } initNativeView() { super.initNativeView(); this.chartTouchListener && this.chartTouchListener.init(); } disposeNativeView() { super.disposeNativeView(); this.chartTouchListener && this.chartTouchListener.dispose(); } /** * initialize all paints and stuff */ init() { this.animator = new ChartAnimator((state) => { // during animations we dont need to compute autoScale nor axis things this.noComputeAutoScaleOnNextDraw = true; this.noComputeAxisOnNextDraw = true; this.invalidate(); }); this.xAxis = new XAxis(new WeakRef(this)); if (Trace.isEnabled()) { CLog(CLogTypes.log, this.constructor.name, 'init()'); } } get infoPaint() { if (!this.mInfoPaint) { this.mInfoPaint = Utils.getTemplatePaint('black-fill'); this.mInfoPaint.setTextAlign(Align.CENTER); this.mInfoPaint.setTextSize(12); } return this.mInfoPaint; } /** * Sets a new data object for the chart. The data object contains all values * and information needed for displaying. * * @param data */ set data(data) { this.mData = data; this.offsetsCalculated = false; if (!data) { return; } // Calculate how many digits are needed this.setupDefaultFormatter(data.yMin, data.yMax); for (const set of this.mData.dataSets) { if (set.needsFormatter || set.valueFormatter === this.defaultValueFormatter) { set.valueFormatter = this.defaultValueFormatter; } } // let the chart know there is new data this.notifyDataSetChanged(); } /** * Returns the ChartData object that has been set for the chart. */ get data() { return this.mData; } get viewPortScaleX() { return this.viewPortHandler.scaleX; } get viewPortScaleY() { return this.viewPortHandler.scaleY; } /** * Clears the chart from all data (sets it to null) and refreshes it (by * calling invalidate()). */ clear() { this.mData = null; this.offsetsCalculated = false; this.indicesToHighlight = null; if (this.chartTouchListener) { this.chartTouchListener.lastHighlighted = null; } this.invalidate(); } /** * Removes all DataSets (and thereby Entries) from the chart. Does not set the data object to null. Also refreshes the * chart by calling invalidate(). */ clearValues() { this.mData.clearValues(); this.invalidate(); } /** * Returns true if the chart is empty (meaning it's data object is either * null or contains no entries). */ get length() { if (!this.mData) return true; else { if (this.mData.entryCount <= 0) return true; else return false; } } /** * Calculates the required number of digits for the values that might be * drawn in the chart (if enabled), and creates the default-value-formatter */ setupDefaultFormatter(min, max) { let reference = 0; if (!this.mData || this.mData.entryCount < 2) { reference = Math.max(Math.abs(min), Math.abs(max)); } else { reference = Math.abs(max - min); } const digits = Utils.getDecimals(reference); // setup the formatter with a new number of digits this.defaultValueFormatter.setup(digits); } draw(canvas) { if (!this.mData) { const hasText = this.noDataText && this.noDataText.length > 0; if (hasText) { const c = this.center; canvas.drawText(this.noDataText, c.x, c.y, this.infoPaint); } } } onDraw(canvas) { const startTime = Date.now(); super.onDraw(canvas); this.draw(canvas); if (Trace.isEnabled()) { const drawtime = Date.now() - startTime; this.totalTime += drawtime; this.drawCycles += 1; const average = this.totalTime / this.drawCycles; CLog(CLogTypes.log, this.constructor.name, 'Drawtime: ' + drawtime + ' ms, average: ' + average + ' ms, cycles: ' + this.drawCycles); } this.notify({ eventName: 'postDraw', canvas, object: this }); } /** * RESET PERFORMANCE TRACKING FIELDS */ resetTracking() { this.totalTime = 0; this.drawCycles = 0; } /** * Draws the description text in the bottom right corner of the chart (per default) */ drawDescription(c) { // check if description should be drawn if (this.mDescription?.enabled) { const position = this.mDescription.position; const paint = Utils.getTempPaint(); paint.setFont(this.mDescription.typeface); paint.setColor(this.mDescription.textColor); paint.setTextAlign(this.mDescription.textAlign); let x, y; const vph = this.viewPortHandler; // if no position specified, draw on default position if (!position) { x = vph.chartWidth - vph.offsetRight - this.mDescription.xOffset; y = vph.chartHeight - vph.offsetBottom - this.mDescription.yOffset; } else { x = position.x; y = position.y; } c.drawText(this.mDescription.text, x, y, paint); } } /** * Returns the array of currently highlighted values. This might a null or * empty array if nothing is highlighted. */ get highlighted() { return this.indicesToHighlight; } /** * Returns true if there are values to highlight, false if there are no * values to highlight. Checks if the highlight array is null, has a length * of zero or if the first object is null. * */ get hasValuesToHighlight() { return this.indicesToHighlight?.[0] !== undefined; } /** * Sets the last highlighted value for the touchlistener. * * @param highs */ set lastHighlighted(highs) { if (!this.chartTouchListener) { return; } if (highs?.[0] === undefined) { this.chartTouchListener.lastHighlighted = null; } else { this.chartTouchListener.lastHighlighted = highs[0]; } } /** * Highlights the values at the given indices in the given DataSets. Provide * null or an empty array to undo all highlighting. This should be used to * programmatically highlight values. * This method *will not* call the listener. * * @param highs */ highlightValues(highs) { // set the indices to highlight this.indicesToHighlight = highs; this.lastHighlighted = highs; // redraw the chart this.noComputeAutoScaleOnNextDraw = true; this.noComputeAxisOnNextDraw = true; this.invalidate(); } // /** // * Highlights any y-value at the given x-value in the given DataSet. // * Provide -1 as the dataSetIndex to undo all highlighting. // * This method will call the listener. // * @param x The x-value to highlight // * @param dataSetIndex The dataset index to search in // */ // public highlightValue( x, dataSetIndex) { // this.highlightValue(x, dataSetIndex, true); // } // /** // * Highlights the value at the given x-value and y-value in the given DataSet. // * Provide -1 as the dataSetIndex to undo all highlighting. // * This method will call the listener. // * @param x The x-value to highlight // * @param y The y-value to highlight. Supply `NaN` for "any" // * @param dataSetIndex The dataset index to search in // */ // public highlightValue(x, y, dataSetIndex) { // this.highlightValue(x, y, dataSetIndex, true); // } // /** // * Highlights any y-value at the given x-value in the given DataSet. // * Provide -1 as the dataSetIndex to undo all highlighting. // * @param x The x-value to highlight // * @param dataSetIndex The dataset index to search in // * @param callListener Should the listener be called for this change // */ // public highlightValue(x, dataSetIndex, callListener = true) { // this.highlightValue(x, NaN, dataSetIndex, callListener); // } /** * Highlights any y-value at the given x-value in the given DataSet. * Provide -1 as the dataSetIndex to undo all highlighting. * @param x The x-value to highlight * @param y The y-value to highlight. Supply `NaN` for "any" * @param dataSetIndex The dataset index to search in * @param callListener Should the listener be called for this change */ highlightValue(x, y, dataSetIndex, callListener = false) { if (dataSetIndex < 0 || dataSetIndex >= this.mData.dataSetCount) { this.highlight(null, callListener); } else { this.highlight({ x, y, dataSetIndex }, callListener); } } /** * Highlights the value selected by touch gesture. Unlike * highlightValues(...), this generates a callback to the * OnChartValueSelectedListener. * * @param high - the highlight object * @param callListener - call the listener */ highlight(high, callListener = false) { let e = null; let highlight = Array.isArray(high) ? high[0] : high; if (!high) { this.indicesToHighlight = null; } else { e = this.mData.getEntryForHighlight(highlight); if (!e) { this.indicesToHighlight = null; highlight = null; } else { highlight.entry = e; // set the indices to highlight this.indicesToHighlight = [highlight]; } } this.lastHighlighted = this.indicesToHighlight; if (callListener) { this.notify({ eventName: 'highlight', object: this, entry: e, highlight, highlights: Array.isArray(high) ? high : [highlight] }); } // redraw the chart this.noComputeAutoScaleOnNextDraw = true; this.noComputeAxisOnNextDraw = true; this.invalidate(); } /** * Highlights the value selected by touch gesture. Unlike * highlightValues(...), this generates a callback to the * OnChartValueSelectedListener. * * @param high - the highlight object * @param callListener - call the listener */ highlights(highs, callListener = false) { const e = null; if (highs.length === 0) { this.indicesToHighlight = null; } else { highs = highs .map((h) => { if (!h.entry) { h.entry = this.mData.getEntryForHighlight(h); } return h; }) .filter((h) => !!h.entry); this.indicesToHighlight = highs.length ? highs : null; } this.lastHighlighted = this.indicesToHighlight; if (callListener) { this.notify({ eventName: 'highlight', object: this, entry: highs?.[0]?.entry, highlight: highs?.[0], highlights: highs }); } // redraw the chart this.noComputeAutoScaleOnNextDraw = true; this.noComputeAxisOnNextDraw = true; this.invalidate(); } /** * Returns the Highlights (contains x-index and DataSet index) of the * selected value at the given touch polet inside the Line-, Scatter-, or * CandleStick-Chart. * * @param x * @param y * @return */ getHighlightsByTouchPoint(x, y) { return this.highlighter.getHighlight(x, y); } /** * Returns the Highlight object (contains x-index and DataSet index) of the * selected value at the given touch polet inside the Line-, Scatter-, or * CandleStick-Chart. * * @param x * @param y * @return */ getHighlightByTouchPoint(x, y) { return this.getHighlightsByTouchPoint(x, y)?.[0]; } /** * Returns the Highlight object (contains x-index and DataSet index) of the * selected value at the given touch polet inside the Line-, Scatter-, or * CandleStick-Chart. * * @param x * @param y * @return */ getHighlightByXValue(xValue) { if (!this.mData) { return null; } else { return this.highlighter.getHighlightsAtXValue(xValue); } } /** * draws all MarkerViews on the highlighted positions */ drawMarkers(canvas) { // if there is no marker view or drawing marker is disabled if (!this.marker || !this.drawMarkersEnabled || !this.hasValuesToHighlight) return; for (let i = 0; i < this.indicesToHighlight.length; i++) { const highlight = this.indicesToHighlight[i]; const set = this.mData.getDataSetByIndex(highlight.dataSetIndex); const e = highlight.entry || this.mData.getEntryForHighlight(highlight); const entryIndex = set.getEntryIndex(e); // make sure entry not null if (!e || entryIndex > set.entryCount * this.animator.phaseX) continue; const pos = this.getMarkerPosition(highlight); // check bounds if (!this.viewPortHandler.isInBounds(pos[0], pos[1])) continue; // callbacks to update the content this.marker.refreshContent(e, highlight); // draw the marker this.marker.draw(canvas, pos[0], pos[1]); } } /** * Returns the actual position in pixels of the MarkerView for the given * Highlight object. * * @param high * @return */ getMarkerPosition(high) { return [high.drawX, high.drawY]; } /** * ################ ################ ################ ################ * ANIMATIONS ONLY WORK FOR API LEVEL 11 (Android 3.0.x) AND HIGHER. */ /** CODE BELOW THIS RELATED TO ANIMATION */ /** * Returns the animator responsible for animating chart values. */ get description() { if (!this.mDescription) { this.mDescription = new Description(); } return this.mDescription; } /** * ################ ################ ################ ################ * ANIMATIONS ONLY WORK FOR API LEVEL 11 (Android 3.0.x) AND HIGHER. */ /** CODE BELOW FOR PROVIDING EASING FUNCTIONS */ /** * Animates the drawing / rendering of the chart on both x- and y-axis with * the specified animation time. If animate(...) is called, no further * calling of invalidate() is necessary to refresh the chart. ANIMATIONS * ONLY WORK FOR API LEVEL 11 (Android 3.0.x) AND HIGHER. * * @param durationMillisX * @param durationMillisY * @param easingX a custom easing function to be used on the animation phase * @param easingY a custom easing function to be used on the animation phase */ animateXY(durationMillisX, durationMillisY, easingX, easingY) { this.animator.animateXY(durationMillisX, durationMillisY, easingX, easingY); } /** * Animates the rendering of the chart on the x-axis with the specified * animation time. If animate(...) is called, no further calling of * invalidate() is necessary to refresh the chart. ANIMATIONS ONLY WORK FOR * API LEVEL 11 (Android 3.0.x) AND HIGHER. * * @param durationMillis * @param easing a custom easing function to be used on the animation phase */ animateX(durationMillis, easing) { this.animator.animateX(durationMillis, easing); } /** * Animates the rendering of the chart on the y-axis with the specified * animation time. If animate(...) is called, no further calling of * invalidate() is necessary to refresh the chart. ANIMATIONS ONLY WORK FOR * API LEVEL 11 (Android 3.0.x) AND HIGHER. * * @param durationMillis * @param easing a custom easing function to be used on the animation phase */ animateY(durationMillis, easing) { this.animator.animateY(durationMillis, easing); } /** * ################ ################ ################ ################ */ /** BELOW THIS ONLY GETTERS AND SETTERS */ /** * returns the current y-max value across all DataSets */ get yMax() { return this.mData.yMax; } /** * returns the current y-min value across all DataSets */ get yMin() { return this.mData.yMin; } get xChartMax() { return this.xAxis.axisMaximum; } get xChartMin() { return this.xAxis.axisMinimum; } get xRange() { return this.xAxis.axisRange; } /** * Returns a recyclable MPPointF instance. * Returns the center polet of the chart (the whole View) in pixels. */ get center() { return { x: this.viewPortHandler.chartWidth / 2, y: this.viewPortHandler.chartHeight / 2 }; } /** * Returns a recyclable MPPointF instance. * Returns the center of the chart taking offsets under consideration. * (returns the center of the content rectangle) */ get centerOffsets() { return this.viewPortHandler.contentCenter; } /** * Sets extra offsets (around the chart view) to be appended to the * auto-calculated offsets. * * @param left * @param top * @param right * @param bottom */ setExtraOffsets(left, top, right, bottom) { this.extraLeftOffset = left; this.extraTopOffset = top; this.extraRightOffset = right; this.extraBottomOffset = bottom; } /** * Sets extra offsets (around the chart view) to be appended to the * auto-calculated offsets. * * @param left * @param top * @param right * @param bottom */ set extraOffsets(value) { this.extraLeftOffset = value[0]; this.extraTopOffset = value[1]; this.extraRightOffset = value[2]; this.extraBottomOffset = value[3]; } /** * Sets the color of the no data text. * * @param color */ set noDataTextColor(color) { this.infoPaint.setColor(color); } /** * Sets the typeface to be used for the no data text. */ set noDataTextTypeface(tf) { this.infoPaint.setTypeface(tf); } /** * Returns the Legend object of the chart. This method can be used to get an * instance of the legend in order to customize the automatically generated * Legend. */ get legend() { if (!this.mLegend) { this.mLegend = new Legend(); this.legendRenderer = new LegendRenderer(this.viewPortHandler, this.mLegend); } return this.mLegend; } /** * Returns the rectangle that defines the borders of the chart-value surface * (into which the actual values are drawn). */ get contentRect() { return this.viewPortHandler.contentRect; } /** * Returns a recyclable MPPointF instance. */ get centerOfView() { return this.center; } /** * Returns the bitmap that represents the chart. */ get chartBitmap() { //Define a bitmap with the same size as the view const canvas = new Canvas(this.getMeasuredWidth(), this.getMeasuredHeight()); canvas.drawColor(this.backgroundColor); // draw the view on the canvas this.onDraw(canvas); // return the bitmap return canvas.getImage(); } removeViewportJob(job) { const index = this.jobs.indexOf(job); if (index >= 0) { this.jobs.splice(index, 1); } } clearAllViewportJobs() { this.jobs = []; } /** * Either posts a job immediately if the chart has already setup it's * dimensions or adds the job to the execution queue. * * @param job */ addViewportJob(job) { if (this.viewPortHandler.hasChartDimens) { setTimeout(() => { job.run(); }, 0); } else { this.jobs.push(job); } } /** * Returns all jobs that are scheduled to be executed after * onSizeChanged(...). */ getJobs() { return this.jobs; } onSetWidthHeight(w, h) { const needsDataSetChanged = !this.viewPortHandler.hasChartDimens; if (Trace.isEnabled()) { CLog(CLogTypes.info, LOG_TAG, 'onSetWidthHeight', w, h, needsDataSetChanged); } if (w > 0 && h > 0) { if (Trace.isEnabled()) { CLog(CLogTypes.info, LOG_TAG, 'Setting chart dimens, width: ' + w + ', height: ' + h); } this.viewPortHandler.setChartDimens(w, h); } // This may cause the chart view to mutate properties affecting the view port -- // lets do this before we try to run any pending jobs on the view port itself // this.notifyDataSetChanged(); if (needsDataSetChanged) { this.notifyDataSetChanged(); } else { this.offsetsCalculated = false; this.invalidate(); // needs chart size } for (const r of this.jobs) { setTimeout(() => { r.run(); }, 0); } this.jobs = []; this.notify({ eventName: 'sizeChanged', object: this }); } onLayout(left, top, right, bottom) { super.onLayout(left, top, right, bottom); if (__IOS__) { this.onSetWidthHeight(Math.round(NUtils.layout.toDeviceIndependentPixels(right - left)), Math.round(NUtils.layout.toDeviceIndependentPixels(bottom - top))); } } onSizeChanged(w, h, oldw, oldh) { super.onSizeChanged(w, h, oldw, oldh); if (__ANDROID__) { this.onSetWidthHeight(Math.round(w), Math.round(h)); } } /** * disables intercept touchevents */ disableScroll() { if (__ANDROID__ && this.disableScrollEnabled) { const parent = this.nativeViewProtected?.getParent(); this.mDisallowInterceptTouchEvent = true; parent?.requestDisallowInterceptTouchEvent(true); } } /** * enables intercept touchevents */ enableScroll() { if (__ANDROID__ && (this.disableScrollEnabled || this.mDisallowInterceptTouchEvent)) { const parent = this.nativeViewProtected?.getParent(); parent?.requestDisallowInterceptTouchEvent(false); } } } //# sourceMappingURL=Chart.js.map