tradingraph
Version:
Based on rpiontik chart https://github.com/rpiontik/crypto-chart
492 lines (482 loc) • 16.8 kB
JavaScript
const MIN_WIDTH_CANDLE = 3;
const VOLUME_ZONE = 0.3; // Volume area
class BinaryDataWorker {
constructor () {
this.data = {
treeReady: false,
candlesBinary: [],
candlesParsed: [],
averageBinary: [],
averageParsed: [],
firstEntry: 0,
lastResolution: 0,
start: null,
end: null,
width: null,
last: {
offset: 0,
end: 0,
resolution: 0
},
tree: []
};
this.params = {
empty: false,
candleWidths: [],
defaultExposition: 86400 * 30,
fileSizes: {},
firstPoints: {},
resolutions: [],
firstTimestamps: {},
packetSize: 0,
dataRequestPending: false,
isInitialLoading: true,
needDropData: false
};
this.requestInitialParams();
}
resetData () {
Object.assign(this.data, {
treeReady: false,
candlesBinary: [],
candlesParsed: [],
averageBinary: [],
averageParsed: [],
firstEntry: 0,
lastResolution: 0,
start: null,
end: null,
width: null,
last: {
offset: 0,
end: 0,
resolution: 0
},
tree: []
});
Object.assign(this.params, {
dataRequestPending: false,
isInitialLoading: true,
fileSizes: {},
firstPoints: {},
firstTimestamps: {},
resolutions: [],
empty: false
});
}
requestInitialParams () {
this.sendMessage('REQUEST_PARAMS', {
inner: ['candleWidths'],
outer: ['fileSizes', 'firstPoints', 'resolutions', 'packetSize']
});
}
initialLoading (resolution) {
let end = this.params.fileSizes[resolution];
let offset = 0;
this.params.isInitialLoading = true;
this.requestData(offset, end - 1, resolution);
}
rebaseOffset (offset, resolution) {
let dataLength = this.params.fileSizes[resolution];
if (offset < 0 || isNaN(offset)) {
return 0
} else if (offset > (dataLength - 1)) {
return dataLength - 1;
}
return offset;
}
rebaseEnd (end, resolution) {
if (end > this.params.fileSizes[resolution]) {
return this.params.fileSizes[resolution];
}
return end;
}
append (data) {
this.data.treeReady = false;
this.params.dataRequestPending = false;
this.appendedData = ['candleData'];
this.data.candlesBinary = data.slice(0);
this.data.candlesParsed.splice(0);
if (this.params.needDropData) {
// this.data.candlesParsed.splice(0);
this.params.needDropData = false;
}
this.data.candlesParsed = this.data.candlesParsed.concat(this.parseChartData(this.data.candlesBinary)).sort((a, b) => {
return a.timestamp - b.timestamp;
});
if (this.params.isInitialLoading === true) {
this.appendedData.push('averageData');
this.data.averageBinary = data.slice(0);
this.data.averageParsed = this.data.candlesParsed.slice(0);
this.params.isInitialLoading = false;
}
this.makeTree();
this.sendMessage('APPENDED', { type: this.appendedData });
}
parseEntity (entity) {
return {
timestamp: (new Uint32Array(entity, 0, 1))[0],
volume: (new Float32Array(entity, 4, 1))[0],
open: (new Float32Array(entity, 8, 1))[0],
high: (new Float32Array(entity, 12, 1))[0],
low: (new Float32Array(entity, 16, 1))[0],
close: (new Float32Array(entity, 20, 1))[0],
average: (new Float32Array(entity, 24, 1))[0]
}
}
parseChartData (rawData) {
let dataArray = [];
for (let i = 0, j = 0; i < rawData.byteLength; i += this.params.packetSize, j++) {
dataArray[j] = this.parseEntity(rawData.slice(i, i + this.params.packetSize));
}
return dataArray;
}
/**
* @description Make specific tree by raw data
* @return none
*/
makeTree () {
if (this.data.candlesParsed.length > 0) {
this.data.start = this.data.candlesParsed[0].timestamp;
this.data.end = this.data.candlesParsed[this.data.candlesParsed.length - 1].timestamp;
this.params.candleWidths.map((case_) => {
this.data.tree[case_] = [];
let lastCandle = null;
this.data.candlesParsed.map((candle) => {
let id = candle.timestamp - (candle.timestamp % case_);
if (lastCandle && (id === lastCandle.id)) {
lastCandle.low = candle.low < lastCandle.low ? candle.low : lastCandle.low;
lastCandle.high = candle.high > lastCandle.high ? candle.high : lastCandle.high;
lastCandle.close = candle.close;
lastCandle.volume += candle.volume;
} else {
if (lastCandle) {
this.data.tree[case_].push(lastCandle);
}
lastCandle = {
id: id,
timestamp: candle.timestamp,
open: candle.open,
low: candle.low,
high: candle.high,
close: candle.close,
volume: candle.volume
};
}
});
if (lastCandle) {
this.data.tree[case_].push(lastCandle);
}
});
this.data.treeReady = true;
}
}
/**
* @description Looking for satisfying width of candle
* @param {Number} exposition - exposition width
* @param {Number} viewWidth - view box width
* @return true/false
*/
findCandleWidthForUse (exposition, viewWidth) {
let targetCandleNumber = viewWidth / MIN_WIDTH_CANDLE;
let caseCandidate = null;
let prevCandleDiff = 0;
this.params.candleWidths.map((case_) => {
if (caseCandidate) {
let candleDiff = Math.abs(Math.round(targetCandleNumber - exposition / case_));
if (candleDiff < prevCandleDiff) {
prevCandleDiff = candleDiff;
caseCandidate = case_;
}
} else {
caseCandidate = case_;
prevCandleDiff = Math.abs(Math.round(targetCandleNumber - exposition / case_));
}
});
return caseCandidate;
}
findAvailableResolution (resolution = 86400) {
let resolutionQty = this.params.resolutions.length;
if (resolution > this.params.resolutions[resolutionQty - 1]) {
return this.params.resolutions[resolutionQty - 1];
}
for (let i = 0, len = resolutionQty; i < len; i++) {
if (resolution === this.params.resolutions[i] || (resolution < this.params.resolutions[i] && i === 0)) {
return this.params.resolutions[i];
} else if (resolution < this.params.resolutions[i] && i > 0) {
return this.params.resolutions[i - 1];
}
}
}
convertTimestampToPackage (timestamp, resolution) {
return Math.ceil(timestamp / resolution) * this.params.packetSize;
}
convertOffsetToPackage (offset, resolution) {
let firstTimestamp = this.params.firstTimestamps[resolution] || ((new Date()).getTime() / 1e3 - this.params.fileSizes[resolution] / this.params.packetSize * resolution);
let diff = offset - firstTimestamp;
let convertedOffset = this.convertTimestampToPackage(diff, resolution);
return this.rebaseOffset(convertedOffset, resolution);
}
convertEndToPackage (end, resolution) {
let fileSize = this.params.fileSizes[resolution];
let lastPointTimestamp = (this.params.firstTimestamps[resolution] + (fileSize - this.params.packetSize) / this.params.packetSize * resolution) || (new Date()).getTime() / 1e3;
let convertedEnd = this.convertTimestampToPackage(lastPointTimestamp - end, resolution);
return this.rebaseEnd(fileSize - convertedEnd, resolution);
}
/**
* @description Render candles objects
* @param {Number} offset - exposition offset
* @param {Number} exposition - exposition width
* @param {Number} viewWidth - view box width
*/
renderCandles (offset, exposition, viewWidth, viewHeight) {
if (!this.data.treeReady) {
this.makeTree();
}
let result = {
low: null,
high: null,
maxVolume: null,
width: null,
candles: [],
candlesPositivePath: [],
candlesNegativePath: [],
volumePath: []
};
let theCase = this.findCandleWidthForUse(exposition, viewWidth);
let koofX = viewWidth / exposition;
result.width = theCase * koofX;
let dataByCase = this.data.tree[theCase];
let start = 0;
if (dataByCase && this.data.lastResolution === theCase) {
let stop = dataByCase.length;
if (offset > this.data.start) {
start = -Math.floor((offset - this.data.start) / theCase);
}
for (let index = -start; index < stop; index++) {
let candle = dataByCase[index];
if (candle.timestamp <= offset) {
continue;
} else if (candle.timestamp > offset + exposition) {
stop = index;
break;
} else if (start < 0) {
start = index;
}
if ((result.low == null) || (result.low > candle.low)) {
result.low = candle.low;
}
if ((result.high == null) || (result.high < candle.high)) {
result.high = candle.high;
}
if ((result.maxVolume == null) || (result.maxVolume < candle.volume)) {
result.maxVolume = candle.volume;
}
}
start = Math.abs(start);
if (stop == null) {
stop = dataByCase.length;
}
let yFactor = 0;
if (result.high !== result.low) {
yFactor = viewHeight / (result.high - result.low);
} else {
yFactor = viewHeight / (result.high * 1.1 - result.low);
}
let yVolumeFactor = 0;
if (result.maxVolume > 0) {
yVolumeFactor = viewHeight * VOLUME_ZONE / result.maxVolume;
}
let barHalf = theCase * koofX * 0.25;
for (let index = start; index < stop; index++) {
let candle = dataByCase[index];
let x = (candle.timestamp - offset) * koofX;
let pathMainLine = `M${x} ${(result.high - candle.low) * yFactor} L${x} ${(result.high - candle.high) * yFactor} `;
let pathCandleBody =
`M${x - barHalf} ${(result.high - candle.close) * yFactor}
L${x + barHalf} ${(result.high - candle.close) * yFactor}
L${x + barHalf} ${(result.high - candle.open) * yFactor}
L${x - barHalf} ${(result.high - candle.open) * yFactor}`;
let rCandle = Object.assign({}, candle);
if (candle.open <= candle.close) {
rCandle.class = 'positive';
rCandle.candlePathIndex = result.candlesPositivePath.push(pathMainLine + pathCandleBody) - 1;
} else {
rCandle.class = 'negative';
rCandle.candlePathIndex = result.candlesNegativePath.push(pathMainLine + pathCandleBody) - 1;
}
rCandle.volumePathIndex = result.volumePath.push(
`M${x - barHalf} ${viewHeight - candle.volume * yVolumeFactor}
L${x + barHalf} ${viewHeight - candle.volume * yVolumeFactor}
L${x + barHalf} ${viewHeight}
L${x - barHalf} ${viewHeight}`
) - 1;
rCandle.x = x;
result.candles.push(rCandle);
}
}
if (!this.params.isInitialLoading && offset > 1 && (this.data.start > offset || this.data.end < (offset + exposition) || !dataByCase || this.data.lastResolution !== theCase)) {
let resolution = this.findAvailableResolution(theCase);
// let correctOffset = offset < this.data.end ? offset : this.data.end;
// let correctEnd = (offset + exposition) > this.data.start ? (offset + exposition) : this.data.start;
if (resolution) {
this.requestData(
this.convertOffsetToPackage(offset, resolution),
this.convertEndToPackage(offset + exposition, resolution) - 1,
resolution
);
}
}
if (this.data.lastResolution !== theCase) {
this.params.needDropData = true;
}
this.data.lastResolution = theCase;
this.sendMessage('RENDERED', { type: 'candles', data: result });
}
returnEmptyData () {
this.sendMessage('RENDERED', {
type: 'candles',
data: {
candles: [],
candlesPositivePath: [],
candlesNegativePath: [],
volumePath: []
}
});
this.sendMessage('RENDERED', {
type: 'average',
data: {
minTimestamp: 0,
path: []
}
});
}
renderAverage (viewWidth, viewHeight) {
let dataLength = this.data.averageParsed.length;
if (dataLength) {
let step = (viewWidth) / dataLength;
let result = {
minTimestamp: this.data.averageParsed[0].timestamp - 86400,
maxTimestamp: this.data.averageParsed[dataLength - 1].timestamp,
path: []
};
let sortedByAverage = this.data.averageParsed.slice(0).sort((a, b) => {return a.average - b.average;});
let highest = sortedByAverage[dataLength - 1].average;
let lowest = sortedByAverage[0].average;
let yMultiplyer = 0;
if (highest !== lowest) {
yMultiplyer = viewHeight / (highest - lowest);
}
result.path.push(`M0 ${yMultiplyer * (highest - this.data.averageParsed[0].average)}`);
for (let i = 1; i < dataLength; i++) {
result.path.push(`L${step * i} ${yMultiplyer * (highest - this.data.averageParsed[i].average)}`);
}
this.sendMessage('RENDERED', { type: 'average', data: result});
}
}
requestData(offset = this.data.last.offset, end = this.data.last.end, resolution = this.data.last.resolution) {
if (!this.params.dataRequestPending && end > 0 && (this.data.last.offset !== offset || this.data.last.end !== end || this.data.last.resolution !== resolution)) {
this.params.dataRequestPending = true;
Object.assign(this.data.last, {offset, end, resolution});
this.sendMessage('REQUEST_DATA', {offset, end, resolution});
}
}
/**
* @description Apply params for render
* @param {Object} params - params
* @return none
*/
setParams (freshParams) {
if (freshParams.candleWidths && !this.isCandleWidthsTheSame(freshParams.candleWidths)) {
Object.assign(this.data, {
treeReady: false,
candlesBinary: [],
candlesParsed: [],
firstEntry: 0,
lastResolution: 0,
start: null,
width: null,
tree: [],
last: {
offset: 0,
end: 0,
resolution: 0
},
});
}
Object.assign(this.params, freshParams);
}
isCandleWidthsTheSame (newCandleWidths) {
for (let i = 0, len = newCandleWidths.length; i < len; i++) {
if (this.params.candleWidths.indexOf(newCandleWidths[i]) === -1) {
return false;
}
}
return true;
}
messageHandler (message) {
switch (message.data.task) {
case 'SET_PARAMS': {
this.setParams(message.data.params);
if (
message.data.params.fileSizes && Object.keys(this.params.fileSizes).length > 0 &&
this.params.resolutions.length > 0 &&
this.params.fileSizes[this.params.resolutions[this.params.resolutions.length - 1]] > 0 &&
this.params.packetSize && !this.params.dataRequestPending
) {
if (this.params.firstPoints && Object.keys(this.params.firstPoints).length) {
for(let interval in this.params.firstPoints) {
this.params.firstTimestamps[interval] = this.parseEntity(this.params.firstPoints[interval]).timestamp;
}
}
this.initialLoading(this.params.resolutions[this.params.resolutions.length - 1]);
} else if (
!this.data.averageParsed.length && Object.keys(this.params.fileSizes).length > 0 && this.params.resolutions.length > 0 &&
this.params.fileSizes[this.params.resolutions[this.params.resolutions.length - 1]] === 0)
{
this.sendMessage('EMPTY');
}
break;
}
case 'APPEND': {
this.append(message.data.data);
break;
}
case 'RENDER': {
let params = message.data.params;
switch (params.type) {
case 'average': {
this.renderAverage(params.viewWidth, params.viewHeight);
break;
}
case 'candles': {
this.renderCandles(params.offset, params.exposition, params.viewWidth, params.viewHeight);
break;
}
default: break;
}
break;
}
case 'RELOAD': {
this.returnEmptyData();
this.params.empty = false;
this.resetData();
this.requestInitialParams();
break;
}
default: break;
}
}
/**
* @description Send message to parrent
* @param {String} type - string based command for parrent
* @param {Object} body - data for message depends on command
*/
sendMessage (type, body = null) {
postMessage({type, body});
}
}
let worker = new BinaryDataWorker();
onmessage = (data) => {
worker.messageHandler(data);
};