@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
JavaScript
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