@rongmz/trading-charts
Version:
This is a d3 based charting library for stocks and finance world. If the question is, why another chart library? - Coz, I find no "open-source" library fits my requirements.
1,075 lines (961 loc) • 46.2 kB
text/typescript
import * as d3 from 'd3';
import { EventEmitter } from 'events';
import {
Annotation, CandlePlotData, CanvasMap, ChartConfig, ChartSettings, D3YScaleMap, DarkThemeChartSettings, EVENT_PAN, EVENT_ZOOM,
GraphData, GraphDataMat, Interpolator, LightThemeChartSettings, MIN_ZOOM_POINTS, MouseDownPosition, MousePosition, PlotLineType, ScaleRowMap,
X_AXIS_HEIGHT_PX, ZoomPanListenerType, ZoomPanType, debug, error, log
} from './types';
import {
clearCanvas, drawArea, drawBar, drawBoxFilledText, drawCandle, drawCenterPivotRotatedText, drawFlagMark, drawLine,
drawRectLimiterMark, drawText, drawXRange, drawXSingle
} from './utils';
const trimLines = (string: string) => string.replace(/\n\s+/g, '');
const toDevicePixel = (number: number) => (number * window.devicePixelRatio);
export class TradingChart {
private dataMat: GraphDataMat = [];
private dataWindowStartIndex?: number;
private dataWindowEndIndex?: number;
private config: ChartConfig;
private settings: ChartSettings;
/** Chart root element provided as container */
private root: d3.Selection<HTMLDivElement, any, any, any> | undefined = undefined;
/** internal maintable */
private table: d3.Selection<HTMLTableElement, any, any, any> | undefined = undefined;
/** Internal Td map */
private scaleRowMap: ScaleRowMap = {};
private mainCanvasMap: CanvasMap = {};
private mainUpdateCanvasMap: CanvasMap = {};
private scaleYCanvasMap: CanvasMap = {};
private scaleYUpdateCanvasMap: CanvasMap = {};
private scaleXCanvas: d3.Selection<HTMLCanvasElement, any, any, any> | undefined = undefined;
private scaleXUpdateCanvas: d3.Selection<HTMLCanvasElement, any, any, any> | undefined = undefined;
private zoomInterpolator: Interpolator<number, number> = d3.interpolateNumber(0, 0);
private panOffset: number = 0;
private panOffsetSaved: number = 0;
private d3xScale = d3.scaleBand<Date>();
private d3yScaleMap: D3YScaleMap = {};
private currentMouseDownStart?: MouseDownPosition;
private mousePosition?: MousePosition;
private zoomEventEmitter = new EventEmitter();
private panEventEmitter = new EventEmitter();
private annotations: Annotation[] = [];
private data: GraphData = {};
/**
* Instantiate a TradingChart
* NO AUTO INITIALIZATION.
* @param root the element under which the chart will be rendered. The styling of the root element won't be manipulated.
* @param config Data based config for the entire chart
* @param settings Cosmetic settings for the chart
*/
constructor(_config: ChartConfig, _settings: Partial<ChartSettings>, theme?: 'light' | 'dark') {
this.config = _config;
const scaleIds = Object.keys(_config);
// save settings
this.settings = Object.assign({},
(theme === 'dark' ? DarkThemeChartSettings : LightThemeChartSettings),
_settings, { scaleSectionRatio: (1 / scaleIds.length) }) as ChartSettings;
debug(this);
}
/** Initializes all dom nodes. This is a heavy loading process. This is called automatically at first initialization. This does not attch the root to DOM. */
public initialize() {
// get scale ids
const scaleIds = Object.keys(this.config);
// d3 root
this.root = d3.select(document.createElement('div')).style('position', 'relative');
// now create tabular view based on given config and scales
const table = this.root.append('table').attr('class', 'chartTable').attr('style', trimLines(`
position: absolute;
width: ${this.settings.width}px;
height: ${this.settings.height}px;
user-select: none;
-webkit-tap-highlight-color: transparent;
border: none;
border-collapse: collapse;
border-spacing: 0;
line-height: 0px;
margin: 0;
padding: 0;
background: ${this.settings.background}
`));
this.table = table;
const chartSectionWidth = this.settings.width * this.settings.plotSectionRatio;
const scaleSectionWidth = this.settings.width - chartSectionWidth;
const chartHeight = this.settings.height - scaleIds.length - X_AXIS_HEIGHT_PX;
scaleIds.map((scaleId, i, _) => {
const sectionHeight = chartHeight * (((this.settings.subGraph || {})[scaleId]?.scaleSectionRatio) || this.settings.scaleSectionRatio) +
((this.settings.subGraph || {})[scaleId]?.deltaHeight || 0);
// append tr and canvas within
const tr = table.append('tr').attr('class', `subChart ${scaleId}`)
const graphTd = tr.append('td').attr('class', `section ${scaleId}`).attr('style', trimLines(`
border: none;
line-height: 0px;
margin: 0;
padding: 0;
text-align: left;
vertical-align: top;
cursor: crosshair;
overflow: hidden;
width: ${chartSectionWidth}px;
height: ${sectionHeight}px;
`))
const graphContainer = graphTd.append('div').attr('class', `canvasContainer ${scaleId}`).attr('style', trimLines(`
width:100%;
height:100%;
position: relative;
overflow: hidden;
`))
this.mainCanvasMap[scaleId] = graphContainer.append('canvas').attr('class', `mainCanvas ${scaleId}`).attr('style', trimLines(`
user-select: none;
-webkit-tap-highlight-color: transparent;
width: ${chartSectionWidth}px;
height: ${sectionHeight}px;
position: absolute;
left: 0px;
top: 0px;
`))
.attr('width', toDevicePixel(chartSectionWidth))
.attr('height', toDevicePixel(sectionHeight))
.attr('scaleId', scaleId).attr('canvasType', 'mainCanvas');
this.mainUpdateCanvasMap[scaleId] = graphContainer.append('canvas').attr('class', `mainUpdateCanvas ${scaleId}`).attr('style', trimLines(`
user-select: none;
-webkit-tap-highlight-color: transparent;
width: ${chartSectionWidth}px;
height: ${sectionHeight}px;
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
`))
.attr('width', toDevicePixel(chartSectionWidth))
.attr('height', toDevicePixel(sectionHeight))
.attr('scaleId', scaleId)
.attr('canvasType', 'mainUpdateCanvas');
const rightScaleTd = tr.append('td').attr('class', `scale ${scaleId}`).attr('style', trimLines(`
border-left: 1px solid ${this.settings.graphSeparatorColor};
line-height: 0px;
margin: 0;
padding: 0;
text-align: left;
vertical-align: top;
width: ${scaleSectionWidth}px;
min-width: ${scaleSectionWidth}px;
height: ${sectionHeight}px;
`))
const rightScaleContainer = rightScaleTd.append('div').attr('class', `scaleContainer ${scaleId}`).attr('style', trimLines(`
width:100%;
height:100%;
position: relative;
overflow: hidden;
`))
this.scaleYCanvasMap[scaleId] = rightScaleContainer.append('canvas').attr('class', `scaleYCanvas ${scaleId}`).attr('style', trimLines(`
user-select: none;
-webkit-tap-highlight-color: transparent;
width: ${scaleSectionWidth}px;
height: ${sectionHeight}px;
position: absolute;
left: 0px;
top: 0px;
`))
.attr('width', toDevicePixel(scaleSectionWidth))
.attr('height', toDevicePixel(sectionHeight))
.attr('scaleId', scaleId).attr('canvasType', 'scaleYCanvas');
this.scaleYUpdateCanvasMap[scaleId] = rightScaleContainer.append('canvas').attr('class', `scaleYUpdateCanvas ${scaleId}`).attr('style', trimLines(`
user-select: none;
-webkit-tap-highlight-color: transparent;
width: ${scaleSectionWidth}px;
height: ${sectionHeight}px;
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
cursor: ns-resize;
`))
.attr('width', toDevicePixel(scaleSectionWidth))
.attr('height', toDevicePixel(sectionHeight))
.attr('scaleId', scaleId)
.attr('canvasType', 'scaleYUpdateCanvas');
// save ref to row map
this.scaleRowMap[scaleId] = [graphTd, rightScaleTd];
if (i < _.length - 1) {
const separatorhandle = table
.append('tr').attr('style', `height: 1px;`)
.append('td').attr('colspan', '2').attr('style', trimLines(`
margin: 0;
padding: 0;
position: relative;
background: ${this.settings.graphSeparatorColor}
`))
.append('div')
.attr('class', 'separatorHandle')
.attr('style', trimLines(`
height: 9px;
left: 0;
position: absolute;
top: -4px;
width: 100%;
z-index: 50;
cursor: row-resize;
`))
.attr('draggable', true)
.attr('scale1', _[i])
.attr('scale2', _[i + 1]);
// attach dragging and resize functionality and listener
separatorhandle.on('drag', (event) => {
if (event.pageY && event.offsetY) { // work around for drag end
const scale1 = _[i], scale2 = _[i + 1];
const scale1tds = this.scaleRowMap[scale1];
const scale2tds = this.scaleRowMap[scale2];
const scale1newH = scale1tds.reduce((_, td) => {
const h = parseFloat(td.style('height'));
const newH = h + event.offsetY;
td.style('height', `${newH}px`);
return newH;
}, 0);
const scale2newH = scale2tds.reduce((_, td) => {
const h = parseFloat(td.style('height'));
const newH = h - event.offsetY;
td.style('height', `${newH}px`);
return newH;
}, 0);
// change canvas h
this.mainCanvasMap[scale1].style('height', `${scale1newH}px`).attr('height', toDevicePixel(scale1newH));
this.mainUpdateCanvasMap[scale1].style('height', `${scale1newH}px`).attr('height', toDevicePixel(scale1newH));
this.scaleYCanvasMap[scale1].style('height', `${scale1newH}px`).attr('height', toDevicePixel(scale1newH));
this.scaleYUpdateCanvasMap[scale1].style('height', `${scale1newH}px`).attr('height', toDevicePixel(scale1newH));
// scale 2
this.mainCanvasMap[scale2].style('height', `${scale2newH}px`).attr('height', toDevicePixel(scale2newH));
this.mainUpdateCanvasMap[scale2].style('height', `${scale2newH}px`).attr('height', toDevicePixel(scale2newH));
this.scaleYCanvasMap[scale2].style('height', `${scale2newH}px`).attr('height', toDevicePixel(scale2newH));
this.scaleYUpdateCanvasMap[scale2].style('height', `${scale2newH}px`).attr('height', toDevicePixel(scale2newH));
// ask to redaw the main canvas
this.redrawMainCanvas();
}
});
}
});
// append x scale
const tr = this.table
.append('tr').attr('style', trimLines(`
border-top: 1px solid ${this.settings.graphSeparatorColor};
`))
const xdiv = tr.append('td').attr('style', trimLines(`
line-height: 0px;
margin: 0;
padding: 0;
text-align: left;
vertical-align: top;
overflow: hidden;
width: ${chartSectionWidth}px;
min-width: ${chartSectionWidth}px;
`))
.append('div')
.attr('class', `xScaleContainer`)
.attr('style', trimLines(`
width:100%;
height:100%;
position: relative;
overflow: hidden;
`))
this.scaleXCanvas = xdiv.append('canvas')
.attr('class', `scaleXCanvas`)
.attr('style', trimLines(`
user-select: none;
-webkit-tap-highlight-color: transparent;
width: ${chartSectionWidth}px;
height: ${X_AXIS_HEIGHT_PX}px;
position: absolute;
left: 0px;
top: 0px;
`))
.attr('width', toDevicePixel(chartSectionWidth))
.attr('height', toDevicePixel(X_AXIS_HEIGHT_PX));
this.scaleXUpdateCanvas = xdiv.append('canvas')
.attr('class', `scaleXUpdateCanvas`)
.attr('style', trimLines(`
user-select: none;
-webkit-tap-highlight-color: transparent;
width: ${chartSectionWidth}px;
height: ${X_AXIS_HEIGHT_PX}px;
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
cursor: ew-resize;
`))
.attr('width', toDevicePixel(chartSectionWidth))
.attr('height', toDevicePixel(X_AXIS_HEIGHT_PX));
// place holder for botttom right corner
tr.append('td').attr('style', trimLines(`
border-left: 1px solid ${this.settings.graphSeparatorColor};
line-height: 0px;
margin: 0;
padding: 0;
`));
// setup listeners for mouse.
scaleIds.map(scaleId => {
const d3Canvas = this.mainUpdateCanvasMap[scaleId];
d3Canvas.on('mousedown', event => {
event.preventDefault();
this.currentMouseDownStart = { scaleId, x: event.x, y: event.y };
this.scaleRowMap[scaleId][0].style('cursor', 'grabbing');
})
d3Canvas.on('mouseup', event => {
event.preventDefault();
this.currentMouseDownStart = undefined;
this.panOffsetSaved = this.panOffset;
this.scaleRowMap[scaleId][0].style('cursor', 'crosshair');
});
d3Canvas.on('mousemove', (event: MouseEvent) => {
event.preventDefault();
this.clearUpdateCanvas(); // clear the update canvas
if (this.currentMouseDownStart) {
this.currentMouseDownStart.dx = event.x - this.currentMouseDownStart.x;
this.currentMouseDownStart.dy = event.y - this.currentMouseDownStart.y;
this.pan(this.currentMouseDownStart.dx || 0, this.currentMouseDownStart.dy || 0)
}
else {
// just normal mouse move update pointer crosshead
const canvas = this.mainUpdateCanvasMap[scaleId].node();
const rect = canvas?.getBoundingClientRect();
const x = rect ? event.x - rect.left : event.x;
const y = rect ? event.y - rect.top : event.y;
if (!this.mousePosition) this.mousePosition = { x, y, scaleId };
else {
this.mousePosition.x = x;
this.mousePosition.y = y;
this.mousePosition.scaleId = scaleId;
}
this.redrawUpdateCanvas();
}
});
d3Canvas.on('mouseout', () => {
this.clearUpdateCanvas();
})
});
// attach zoom to table
this.table.on('wheel', (event: WheelEvent) => {
event.preventDefault();
if (event.deltaY < 0) this.zoom(this.settings.wheelZoomSensitivity);
else this.zoom(-this.settings.wheelZoomSensitivity);
})
// Ask for redraw with whatever data
this.redrawMainCanvas();
// watermark
if (this.settings.watermarkText) {
this.root.append('div')
.attr('class', 'watermark')
.attr('style', trimLines(`
position: absolute;
width: ${this.settings.width}px;
height: ${this.settings.height}px;
text-align: center;
vertical-align: middle;
line-height: ${this.settings.height}px;
font-size: 3rem;
overflow: hidden;
color: #00000012;
`))
.append('span')
.attr('style', 'white-space:break-spaces')
.html(this.settings.watermarkText);
}
}
/**
* Dynamically update config for this Chart.
* NO AUTO INITIALIZATION.
* @param _config
*/
public setConfig(_config: ChartConfig) {
this.config = _config;
// change the subgraph ratio
this.settings.scaleSectionRatio = 1 / Object.keys(_config).length;
if (this.settings.subGraph) {
Object.keys(this.settings.subGraph).map(sid => {
delete this.settings.subGraph[sid].scaleSectionRatio;
})
}
}
/**
* Dynamically update the chart theme.
* NO AUTO INITIALIZATION.
* @param theme
*/
public updateTheme(theme: 'light' | 'dark') {
// save settings
this.settings = Object.assign({}, this.settings,
(theme === 'dark' ? DarkThemeChartSettings : LightThemeChartSettings)) as ChartSettings;
// draw main graph
this.redrawMainCanvas();
}
/** Get a color pallet */
private getColorPallet(scaleId?: string) {
const colorpallet = (scaleId ? ((this.settings.subGraph || {})[scaleId] || {}).colorPallet : undefined) || this.settings.colorPallet || d3.schemePaired;
return colorpallet;
}
/**
* Set the data to the the existing chart.
* This triggers rendering for the entire chart based on changes.
* @param data
*/
public setData(_data: GraphData) {
this.data = _data;
this.recalculateData();
// debug(this.dataMat);
this.zoomInterpolator = d3.interpolateNumber(this.dataMat.length, MIN_ZOOM_POINTS)
this.updateWindowFromZoomPan();
// draw main graph
this.redrawMainCanvas();
}
/** This is a internal function which re-calculates all data points */
private recalculateData() {
if (this.data && Object.keys(this.data).length > 0) {
// extract grouped data
const dtgrouped = Object.keys(this.config).reduce((rv, scaleId) => {
const subgraph = this.config[scaleId];
Object.keys(subgraph).map((plotName, i) => {
const plotConf = subgraph[plotName];
const d = this.data[plotConf.dataId] || [];
const colorpallet = this.getColorPallet(scaleId);
const defaultColor = colorpallet[i % colorpallet.length];
let lastBaseY: number | undefined = undefined;
// loop thourgh d
d.map(d => {
const ts = plotConf.tsValue(d);
const data = plotConf.data(d);
const color = (plotConf.color) ? (typeof (plotConf.color) === 'function' ? plotConf.color(d) : plotConf.color) : defaultColor;
const baseY = (typeof (plotConf.baseY) === 'undefined') ? lastBaseY : (typeof (plotConf.baseY) === 'function' ? plotConf.baseY(d) : plotConf.baseY);
lastBaseY = baseY; // replace last
if (!rv[ts.getTime()]) rv[ts.getTime()] = { [scaleId]: { [plotName]: { d: data, color, baseY } } };
else if (!rv[ts.getTime()][scaleId]) rv[ts.getTime()][scaleId] = { [plotName]: { d: data, color, baseY } };
else if (!rv[ts.getTime()][scaleId][plotName]) rv[ts.getTime()][scaleId][plotName] = { d: data, color, baseY };
})
});
return rv;
}, {} as any);
// save data mat
const xDomainValus = Object.keys(dtgrouped).sort((a, b) => ((+a) - (+b)));
this.dataMat = xDomainValus.map(tsk => {
return { ts: new Date(+tsk), data: dtgrouped[tsk] }
});
}
}
/**
* Set chart annotations.
* @param _annotations
*/
public setAnnotations(_annotations: Partial<Annotation>[]) {
this.annotations = _annotations.map((a, i) => {
if (typeof (a.color) === 'undefined') {
const colorPallet = this.getColorPallet(a.scaleId);
a.color = colorPallet[i];
if (typeof (a.areaColor) === 'undefined') {
a.areaColor = d3.color(colorPallet[i])?.copy({ opacity: 0.2 }).formatHex8() || colorPallet[i];
}
}
if (typeof (a.text) === 'undefined') a.text = `${i + 1}`;
a.x = (a.x || []).map(x => ((Object.prototype.toString.call(x) === '[object Date]') ? x : new Date(x)));
if (typeof (a.textColor) === 'undefined') a.textColor = this.settings.crossHairContrastColor;
return a as Annotation; // return enriched
});
// ask for redraw.
this.redrawMainCanvas();
}
/**
* Funtion to get the windowes data based on zoom. Do not slice the actual data.
*/
public getWindowedData(): GraphDataMat {
return this.dataMat.slice(this.dataWindowStartIndex || 0, this.dataWindowEndIndex || this.dataMat.length);
}
/**
* Update window from zoom and pan
*/
private updateWindowFromZoomPan() {
const windowLength = this.zoomInterpolator(this.settings.zoomLevel);
// x2=L-1-panoffset
// x1=L-1-panoffset - (b-1)
this.dataWindowStartIndex = Math.max(0, this.dataMat.length - 1 - this.panOffset - windowLength);
this.dataWindowEndIndex = Math.min(this.dataMat.length, this.dataMat.length - this.panOffset);
}
/** Do zoom */
public zoom(step: number) {
this.settings.zoomLevel = Math.max(Math.min(this.settings.zoomLevel + step, 1), 0.001);
this.updateWindowFromZoomPan();
this.redrawMainCanvas();
// call any listeners
this.zoomEventEmitter.emit(EVENT_ZOOM);
}
/**
* Set zoom level and pan based on calculated start and end
* @param start
* @param end
*/
public zoomPanToRange(start: Date, end: Date) {
if (this.dataMat && this.dataMat.length > 0) {
const [i1, i2] = this.dataMat.reduce((rv, mat, i) => {
if (mat.ts.getTime() <= start.getTime()) rv[0] = Math.max(i - 1, 0); // update till ts == start or atleast before start.
if (mat.ts.getTime() <= end.getTime()) rv[1] = Math.min(i + 1, this.dataMat.length - 1); // update till ts == end or atleast before end
return rv;
}, [0, this.dataMat.length - 1]);
// got the window [i1, i2]
this.panOffset = i2;
this.dataWindowEndIndex = i2 - 1;
this.dataWindowStartIndex = i1;
const windowLength = this.dataWindowEndIndex - this.dataWindowStartIndex;
const zoomLevel = (windowLength - this.dataMat.length) / (MIN_ZOOM_POINTS - this.dataMat.length);
log(`Zoom level determined: ${zoomLevel}`);
this.settings.zoomLevel = zoomLevel;
this.redrawMainCanvas();
// call any listeners
this.zoomEventEmitter.emit(EVENT_ZOOM);
}
}
/**
* Panning. positive dx for pan to see latest data at right hand side.
* @param dx
* @param dy
*/
public pan(dx: number, dy: number) {
const xBandW = this.d3xScale.step();
const maxBarsToscroll = Math.floor(dx / xBandW);
const windowLength = this.zoomInterpolator(this.settings.zoomLevel);
this.panOffset = Math.min(Math.max(0, this.panOffsetSaved + maxBarsToscroll), this.panOffset + windowLength);
this.updateWindowFromZoomPan();
this.redrawMainCanvas();
// call listeners
this.panEventEmitter.emit(EVENT_PAN);
}
/**
* Dynamically update chart settings.
* @param _settings
*/
public updateSettings(_settings: Partial<ChartSettings>) {
this.recalculateData();
this.settings = Object.assign(this.settings || {}, _settings) as ChartSettings;
// ask for redraw
this.updateWindowFromZoomPan();
this.redrawMainCanvas();
}
/**
* Update all scale domains
* @param windowedData
*/
private updateDomains(windowedData: GraphDataMat) {
// update x scale domain
this.d3xScale.domain(windowedData.map(_ => _.ts));
// update y scale domains
const maxminMap = windowedData.reduce((rv, d) => {
const scaleIds = Object.keys(d.data);
scaleIds.map(scaleId => {
let max = -Infinity, min = Infinity;
Object.keys(d.data[scaleId]).map(plotName => {
const plotd = d.data[scaleId][plotName];
max = Math.max(max, typeof (plotd.d) === 'object' ? plotd.d.l : plotd.d, plotd.baseY || -Infinity);
min = Math.min(min, typeof (plotd.d) === 'object' ? plotd.d.l : plotd.d, plotd.baseY || Infinity);
});
if (!rv[scaleId]) rv[scaleId] = { max, min };
else {
rv[scaleId].max = Math.max(rv[scaleId].max, max);
rv[scaleId].min = Math.min(rv[scaleId].min, min);
}
});
return rv;
}, {} as any);
Object.keys(maxminMap).map(scaleId => {
const { max, min } = maxminMap[scaleId];
const yScaleDomainPaddingLength = (max - min) * (((this.settings.subGraph || {})[scaleId] || {}).yScalePaddingPct || this.settings.yScalePaddingPct);
if (!this.d3yScaleMap[scaleId]) this.d3yScaleMap[scaleId] = d3.scaleLinear();
this.d3yScaleMap[scaleId]
.domain([min - yScaleDomainPaddingLength, max + yScaleDomainPaddingLength])
})
}
/**
* Function which redraws the main canvas and corresponding scales
* Main canvas will be redrawn for zoom, panning, section resize etc.
*/
public redrawMainCanvas() {
// If there is data then only main graph will be drawn
if (this.dataMat && this.dataMat.length && this.scaleXCanvas) {
const windowedData = this.getWindowedData();
// update scale domains
this.updateDomains(windowedData);
const xScaleCanvas = this.scaleXCanvas.node() as HTMLCanvasElement;
const xScaleCanvasWidth = +this.scaleXCanvas.attr('width');
const xScaleCanvasHeight = +this.scaleXCanvas.attr('height');
const xScaleFormat = d3.timeFormat(this.settings.xScaleFormat);
const xScaleCanvasCtx = xScaleCanvas.getContext('2d') as CanvasRenderingContext2D;
// clear canvas
clearCanvas(xScaleCanvasCtx, 0, 0, xScaleCanvasWidth, xScaleCanvasHeight);
const callOnXTicks = (direction: 'forward' | 'backward', fn: (d: Date, i: number, _: Date[]) => void) => {
const domain = this.d3xScale.domain();
const totalDomainLength = domain.length;
const stepsize = Math.floor(totalDomainLength / this.settings.xGridInterval);
for (let i = 0; i < this.settings.xGridInterval; i++) {
const index = i * stepsize;
const j = (direction === 'backward') ? totalDomainLength - 1 - index : index;
fn(domain[j], i, domain);
}
}
Object.keys(this.config).map(scaleId => {
const subgraphConfig = this.config[scaleId];
const canvas = this.mainCanvasMap[scaleId].node() as HTMLCanvasElement;
const canvasWidth = +this.mainCanvasMap[scaleId].attr('width');
const canvasHeight = +this.mainCanvasMap[scaleId].attr('height');
const yScaleCanvas = this.scaleYCanvasMap[scaleId].node() as HTMLCanvasElement;
const yScaleCanvasWidth = +this.scaleYCanvasMap[scaleId].attr('width');
const yScaleCanvasHeight = +this.scaleYCanvasMap[scaleId].attr('height');
// draw scales
this.d3yScaleMap[scaleId].range([canvasHeight, 0]);
const d3yScale = this.d3yScaleMap[scaleId];
this.d3xScale
.range([0, xScaleCanvasWidth])
.padding(this.settings.xScalePadding);
const mainCanvasCtx = canvas.getContext('2d') as CanvasRenderingContext2D;
const yScaleCanvasCtx = yScaleCanvas.getContext('2d') as CanvasRenderingContext2D;
// clear canvas before drawing
clearCanvas(mainCanvasCtx, 0, 0, canvasWidth, canvasHeight);
clearCanvas(yScaleCanvasCtx, 0, 0, yScaleCanvasWidth, yScaleCanvasHeight);
// -----------------------------------END: Draw Axis------------------------------------------------
// -----------------------------------START: Draw Plot------------------------------------------------
Object.keys(subgraphConfig).map(plotName => {
const plotConfig = subgraphConfig[plotName];
const subGraphSettings = (this.settings.subGraph || {})[scaleId] || {};
const bandW = this.d3xScale.bandwidth();
const filteredWindowedData = windowedData.filter(d => (d.data[scaleId] && d.data[scaleId][plotName] && typeof (d.data[scaleId][plotName].d) !== 'undefined'));
switch (plotConfig.type) {
//--------------Candle plot------------
case 'candle':
filteredWindowedData.map(d => {
const _d = d.data[scaleId][plotName];
if (typeof (_d.d) !== 'undefined') {
const _c = _d.d as CandlePlotData;
const x = this.d3xScale(d.ts) as number;
drawCandle(mainCanvasCtx, _d.color, x, d3yScale(_c.o), d3yScale(_c.c), x + bandW / 2, d3yScale(_c.h), d3yScale(_c.l), bandW)
}
});
break;
//--------------line plot------------
case 'dashed-line':
case 'dotted-line':
case 'solid-line':
drawLine(mainCanvasCtx, filteredWindowedData[filteredWindowedData.length - 1].data[scaleId][plotName].color, plotConfig.type as PlotLineType,
(subGraphSettings.lineWidth || this.settings.lineWidth), filteredWindowedData.map(d => {
const _d = d.data[scaleId][plotName];
const x = this.d3xScale(d.ts) as number;
const y = d3yScale(_d.d as number);
return [x + bandW / 2, y];
}));
break;
//--------------bar plot------------
case 'bar':
filteredWindowedData.map(d => {
const _d = d.data[scaleId][plotName];
const x = this.d3xScale(d.ts) as number;
const y = d3yScale(_d.d as number);
drawBar(mainCanvasCtx, _d.color, x, y, bandW, canvasHeight - y);
});
break;
//--------------var bar plot------------
case 'var-bar':
filteredWindowedData.map(d => {
const _d = d.data[scaleId][plotName];
const x = this.d3xScale(d.ts) as number;
const y = d3yScale(_d.d as number);
const baseY = typeof (_d.baseY) !== 'undefined' ? d3yScale(_d.baseY) : canvasHeight;
drawBar(mainCanvasCtx, _d.color, x, y, bandW, baseY - y);
});
break;
//--------------area plot------------
case 'area':
const color = d3.color(filteredWindowedData[filteredWindowedData.length - 1].data[scaleId][plotName].color) as d3.RGBColor | d3.HSLColor;
const areaColor = plotConfig.areaColor ? [plotConfig.areaColor, plotConfig.areaColor] : [color.copy({ opacity: 0.6 }).formatHex8(), color.copy({ opacity: 0.2 }).formatHex8()];
drawArea(mainCanvasCtx, color.formatHex8(), plotConfig.colorBaseY, (subGraphSettings.lineWidth || this.settings.lineWidth),
areaColor, filteredWindowedData.map(d => {
const _d = d.data[scaleId][plotName];
const x = this.d3xScale(d.ts) as number;
const y = d3yScale(_d.d as number);
const baseY = typeof (_d.baseY) !== 'undefined' ? d3yScale(_d.baseY) : canvasHeight;
return [x + bandW / 2, y, baseY];
}));
break;
}
})
// -----------------------------------END: Draw Plot------------------------------------------------
// -----------------------------------START: Draw Grid Lines------------------------------------------------
// if (this.settings.gridLinesType !== 'none') {
// // x grid lines
// callOnXTicks('backward', (d, i, _) => {
// const x = this.d3xScale(d) as number;
// const color = typeof (this.settings.gridLinesColor) === 'string' ? this.settings.gridLinesColor as string : this.settings.gridLinesColor[0];
// drawGridLine(mainCanvasCtx, color, x, 0, 0, canvasHeight, this.settings.gridLinesType as 'vert');
// });
// }
// -----------------------------------END: Draw Grid Lines------------------------------------------------
// -----------------------------------START: Draw y Scale------------------------------------------------
const yScaleTicksCount = ((this.settings.subGraph || {})[scaleId] || {}).yScaleTickCount || this.settings.yScaleTickCount;
const ticks = d3yScale.ticks(yScaleTicksCount);
const yScaleDomainSize = d3yScale.domain().reduce((rv, d, i, _) => {
if (i === 0) rv = d;
else if (i === _.length - 1) {
return Math.abs(rv - d);
}
return rv;
}, 0);
const tickFormat = d3yScale.tickFormat(yScaleTicksCount, ((this.settings.subGraph || {})[scaleId] || {}).yScaleFormat ||
((yScaleDomainSize > 1000) ? '~s' : (yScaleDomainSize < 10) ? '.2~f' : 'd'));
ticks.map((tick) => {
const y = d3yScale(tick);
drawText(yScaleCanvasCtx, tickFormat(tick), 1, y, 0, this.settings.scaleFontColor, this.settings.scaleFontSize, 'left');
})
const yScaleTitle = ((this.settings.subGraph || {})[scaleId] || {}).yScaleTitle || this.settings.yScaleTitle;
if (yScaleTitle)
drawCenterPivotRotatedText(yScaleCanvasCtx, yScaleTitle, yScaleCanvasWidth - parseInt(this.settings.scaleFontSize), yScaleCanvasHeight / 2, 270,
this.settings.scaleFontColor, this.settings.scaleFontSize);
// -----------------------------------END: Draw y Scale------------------------------------------------
// -----------------------------------START: Draw title------------------------------------------------
const title = ((this.settings.subGraph || {})[scaleId] || {}).title;
if (title) {
const titleFontColor = ((this.settings.subGraph || {})[scaleId] || {}).titleFontColor || this.settings.scaleFontColor;
const titleFontSize = ((this.settings.subGraph || {})[scaleId] || {}).titleFontSize || this.settings.scaleFontSize;
const titlePosition = ((this.settings.subGraph || {})[scaleId] || {}).titlePlacement || this.settings.titlePlacement || 'top-right';
const legendMargin = ((this.settings.subGraph || {})[scaleId] || {}).legendMargin || this.settings.legendMargin || [10, 10, 10];
const legendMarginType = typeof (legendMargin);
switch (titlePosition) {
case 'top-center':
drawText(mainCanvasCtx, title, canvasWidth / 2, legendMarginType === 'number' ? legendMargin : (legendMargin as any)[0], undefined, titleFontColor, titleFontSize, 'center', 'top');
break;
case 'top-right':
drawText(mainCanvasCtx, title, canvasWidth - (legendMarginType === 'number' ? legendMargin : (legendMargin as any)[2]), legendMarginType === 'number' ? legendMargin : (legendMargin as any)[0], undefined, titleFontColor, titleFontSize, 'right', 'top');
break;
case 'top-left':
drawText(mainCanvasCtx, title, legendMarginType === 'number' ? legendMargin : (legendMargin as any)[1], legendMarginType === 'number' ? legendMargin : (legendMargin as any)[0], undefined, titleFontColor, titleFontSize, 'left', 'top');
break;
}
}
// -----------------------------------END: Draw title------------------------------------------------
// -----------------------------------START: Try to draw annotations------------------------------------------------
const annotations = this.annotations.filter(a => ((a.scaleId === scaleId) || (typeof (a.scaleId) === 'undefined')));
if (annotations && annotations.length > 0) {
// debug('annotations', scaleId, annotations);
const lineWidth = this.settings.annotationLineWidth;
const annotationFontSize = this.settings.annotationFontSize;
const xBandW = this.d3xScale.bandwidth();
annotations.map(annotation => {
const x = annotation.x.map(_ => this.d3xScale(_) as number);
const y = annotation.y.map(_ => d3yScale(_) as number);
switch (annotation.type) {
case 'xRange':
if (x.length > 1) drawXRange(mainCanvasCtx, x[0], x[1], canvasHeight, annotation.color, lineWidth, annotation.areaColor, annotation.text, annotationFontSize);
break;
case 'xSingle':
if (x.length > 0) drawXSingle(mainCanvasCtx, x[0] + xBandW / 2, canvasHeight, annotation.color, lineWidth, annotation.text, annotationFontSize);
break;
case 'flag':
x.map((x, i) => {
drawFlagMark(mainCanvasCtx, x + xBandW / 2, y[i], annotation.text, annotation.direction, annotation.color, annotation.textColor, annotationFontSize);
})
break;
case 'rect':
drawRectLimiterMark(mainCanvasCtx, x[0], x[1], y[0], y[1], y[2], y[3], annotation.color, lineWidth, annotation.areaColor, annotation.text, annotationFontSize)
break;
}
})
}
// -----------------------------------END: Try to draw annotations------------------------------------------------
});
// ----------------Draw X axis-----------------------
callOnXTicks('forward', (d, i, _) => {
const xscaleY = xScaleCanvasHeight / 2;
const x = this.d3xScale(d) as number;
drawText(xScaleCanvasCtx, xScaleFormat(d), x, xscaleY, 0, this.settings.scaleFontColor, this.settings.scaleFontSize, 'left');
});
// -------------------Draw for only xrange and xSingle annotations to xscale-------------------------------
this.annotations.filter(a => ((a.type === 'xRange' || a.type === 'xSingle') && a.x.length > 0)).map(annotation => {
if (annotation.showXValue) {
try {
const x = annotation.x.map(_ => this.d3xScale(_) as number);
const txt = annotation.x.map(_ => xScaleFormat(_));
const rh = parseInt(this.settings.annotationFontSize);
switch (annotation.type) {
case 'xRange':
drawBoxFilledText(xScaleCanvasCtx, txt[0], annotation.color, annotation.textColor, x[0], 5, x[0], 0, undefined, rh + 10, this.settings.annotationFontSize, 'right', 'top');
drawBoxFilledText(xScaleCanvasCtx, txt[1], annotation.color, annotation.textColor, x[1], 5, x[1], 0, undefined, rh + 10, this.settings.annotationFontSize, 'left', 'top');
break;
case 'xSingle':
drawBoxFilledText(xScaleCanvasCtx, txt[0], annotation.color, annotation.textColor, x[0] + this.d3xScale.bandwidth() / 2, 5, undefined, 0, undefined, rh + 10, this.settings.annotationFontSize, 'center', 'top');
break;
}
} catch (e) {
error('Error while drawing annotation', e);
}
}
})
}
}
/** Function responsible for redrawing update canvas for mouse positions and annotations on scales. */
private redrawUpdateCanvas() {
if (this.scaleXUpdateCanvas)
try {
// calculate
const windowedData = this.getWindowedData();
const xScaleCtx = this.scaleXUpdateCanvas.node()?.getContext('2d');
const xstep = this.d3xScale.step();
const bandW = this.d3xScale.bandwidth();
const domainVal = this.d3xScale.domain()[Math.floor(toDevicePixel(this.mousePosition?.x || 0) / xstep)]
const x = (this.d3xScale(domainVal) || 0) + bandW / 2;
if (xScaleCtx) {
const text = ` ${d3.timeFormat(this.settings.xScaleCrossHairFormat)(domainVal)} `;
drawBoxFilledText(xScaleCtx, text, this.settings.crossHairColor, this.settings.crossHairContrastColor, x, 5, undefined, 0, undefined,
parseInt(this.settings.scaleFontSize) + 10, this.settings.scaleFontSize, 'center', 'top');
// drawText(xScaleCtx, text, x, 0, undefined, this.settings.crossHairContrastColor, this.settings.scaleFontSize, 'center', 'top')
}
Object.keys(this.config).map(scaleId => {
const canvas = this.mainUpdateCanvasMap[scaleId];
const ctx = canvas.node()?.getContext('2d');
const canvasWidth = +canvas.attr('width');
const canvasHeight = +canvas.attr('height');
if (ctx) {
drawLine(ctx, this.settings.crossHairColor, 'dotted-line', this.settings.crossHairWidth, [[x, 0], [x, canvasHeight]]);
// draw only if this is the current scale subgraph
if (this.mousePosition?.scaleId === scaleId) {
const y = toDevicePixel(this.mousePosition?.y || 0);
const ydomainVal = this.d3yScaleMap[scaleId].invert(y);
const yformatter = d3.format(((this.settings.subGraph || {})[scaleId] || {}).crossHairYScaleFormat || this.settings.crossHairYScaleFormat)
const yscalecanvas = this.scaleYUpdateCanvasMap[scaleId];
const yscalectx = yscalecanvas.node()?.getContext('2d');
const yscalecanvasWidth = +yscalecanvas.attr('width');
// draw y line
drawLine(ctx, this.settings.crossHairColor, 'dotted-line', this.settings.crossHairWidth, [[0, y], [canvasWidth, y]]);
if (yscalectx) {
// y scale drawing
drawBoxFilledText(yscalectx, yformatter(ydomainVal), this.settings.crossHairColor, this.settings.crossHairContrastColor, 5, y,
0, y - parseInt(this.settings.scaleFontSize) / 2 - 5, toDevicePixel(yscalecanvasWidth), parseInt(this.settings.scaleFontSize) + 10, this.settings.scaleFontSize, 'left', 'middle');
}
}
// render legends at the current x coordinates
const matData = windowedData.find(v => v.ts.getTime() === domainVal.getTime());
if (matData) {
const plots = Object.keys(this.config[scaleId]);
const plotVals = plots.map(plot => ((matData.data[scaleId] || {})[plot] || {})).map((d, i) => ({ name: plots[i], d }));
if (plotVals.length > 0) {
const legendFontSize = ((this.settings.subGraph || {})[scaleId] || {}).legendFontSize || this.settings.legendFontSize;
const legendPosition = ((this.settings.subGraph || {})[scaleId] || {}).legendPosition || this.settings.legendPosition || 'top-left';
const legendMargin = ((this.settings.subGraph || {})[scaleId] || {}).legendMargin || this.settings.legendMargin || [10, 10, 10];
const legendMarginType = typeof (legendMargin);
const formatter = d3.format(((this.settings.subGraph || {})[scaleId] || {}).legendFormat || this.settings.legendFormat);
plotVals.map((plotLegendVal, i) => {
const y = (i + 1) * (legendMarginType === 'number' ? legendMargin : (legendMargin as any)[0]) + (i * parseInt(legendFontSize));
const legendText = typeof (plotLegendVal.d.d) === 'object' ? `O ${plotLegendVal.d.d.o} H ${plotLegendVal.d.d.h} L ${plotLegendVal.d.d.l} C ${plotLegendVal.d.d.c}` : `${plotLegendVal.name}: ${formatter(plotLegendVal.d.d)} ${(typeof (plotLegendVal.d.baseY) !== 'undefined') ? ` BaseY: ${formatter(plotLegendVal.d.baseY)}` : ''}`;
switch (legendPosition) {
case 'top-right':
drawText(ctx, legendText, canvasWidth - (legendMarginType === 'number' ? legendMargin : (legendMargin as any)[2]), y, undefined, plotLegendVal.d.color, legendFontSize, 'right', 'top');
break;
case 'top-left':
drawText(ctx, legendText, legendMarginType === 'number' ? legendMargin : (legendMargin as any)[1], y, undefined, plotLegendVal.d.color, legendFontSize, 'left', 'top');
break;
}
});
}
}
}
})
} catch (e) { log(e); }
}
/**
* This is to clear the update canvas
*/
private clearUpdateCanvas() {
const scaleIds = Object.keys(this.config);
scaleIds.map(scaleId => {
const canvas = this.mainUpdateCanvasMap[scaleId];
const canvasCtx = canvas.node()?.getContext('2d');
const canvasWidth = +canvas.attr('width');
const canvasHeight = +canvas.attr('height');
if (canvasCtx) clearCanvas(canvasCtx, 0, 0, canvasWidth, canvasHeight);
const yScalecanvas = this.scaleYUpdateCanvasMap[scaleId];
const yScalecanvasCtx = yScalecanvas.node()?.getContext('2d');
const yScalecanvasWidth = +yScalecanvas.attr('width');
const yScalecanvasHeight = +yScalecanvas.attr('height');
if (yScalecanvasCtx) clearCanvas(yScalecanvasCtx, 0, 0, yScalecanvasWidth, yScalecanvasHeight);
});
if (this.scaleXUpdateCanvas) {
const xScalecanvasCtx = this.scaleXUpdateCanvas.node()?.getContext('2d');
const xScalecanvasWidth = +this.scaleXUpdateCanvas.attr('width');
const xScalecanvasHeight = +this.scaleXUpdateCanvas.attr('height');
if (xScalecanvasCtx) clearCanvas(xScalecanvasCtx, 0, 0, xScalecanvasWidth, xScalecanvasHeight);
}
}
/**
* Detach the chart root from DOM.
*/
public detach() {
if (this.root)
this.root.remove();
this.zoomEventEmitter.removeAllListeners();
this.panEventEmitter.removeAllListeners();
}
/**
* Attach to given DOM root node
*/
public attach(domRoot: HTMLElement) {
if (this.root) {
const _root = this.root.node();
if (_root) domRoot.appendChild(_root);
}
}
/**
* Add listener to events
* @param event
* @param listener
* @returns
*/
public on(event: ZoomPanType, listener: ZoomPanListenerType) {
switch (event) {
case 'zoom':
return this.zoomEventEmitter.on(EVENT_ZOOM, listener);
case 'pan':
return this.panEventEmitter.on(EVENT_PAN, listener);
}
}
/**
* Add one off listener to events
* @param event
* @param listener
* @returns
*/
public once(event: ZoomPanType, listener: ZoomPanListenerType) {
switch (event) {
case 'zoom':
return this.zoomEventEmitter.once(EVENT_ZOOM, listener);
case 'pan':
return this.panEventEmitter.once(EVENT_PAN, listener);
}
}
/**
* Remove a listener to events
* @param event
* @param listener
* @returns
*/
public off(event: ZoomPanType, listener: ZoomPanListenerType) {
switch (event) {
case 'zoom':
return this.zoomEventEmitter.off(EVENT_ZOOM, listener);
case 'pan':
return this.panEventEmitter.off(EVENT_PAN, listener);
}
}
/**
* Remove all listeners for events
* @param event
* @param listener
* @returns
*/
public offAll(event: ZoomPanType) {
switch (event) {
case 'zoom':
return this.zoomEventEmitter.removeAllListeners(EVENT_ZOOM);
case 'pan':
return this.panEventEmitter.removeAllListeners(EVENT_PAN);
}
}
}