Backend API
import React, {
useCallback, useEffect, useRef, useState,
} from 'react';
import Chart, {
ArgumentAxis,
ValueAxis,
Aggregation,
Legend,
Series,
ScrollBar,
ZoomAndPan,
LoadingIndicator,
Pane,
Tooltip,
Crosshair,
Margin,
HorizontalLine,
IAggregationProps,
} from 'devextreme-react/chart';
import { VisualRange } from 'devextreme-react/common/charts';
import { CustomStore } from 'devextreme-react/common/data';
import { HubConnectionBuilder, HttpTransportType } from '@aspnet/signalr';
import TooltipTemplate from './TooltipTemplate.tsx';
const minVisualRangeLength = { minutes: 10 };
const defaultVisualRange: VisualRange = { length: 'hour' };
function App() {
const [dataSource, setDataSource] = useState(null);
const chartRef = useRef(null);
const customizePoint = useCallback((arg) => {
if (arg.seriesName === 'Volume') {
const point = chartRef.current.instance().getAllSeries()[0]
.getPointsByArg(arg.argument)[0].data;
if (point && point.close >= point.open) {
return { color: '#1db2f5' };
}
}
return null;
}, []);
const calculateCandle = useCallback<IAggregationProps['calculate']>((e) => {
const prices = e.data.map((d) => d.price);
if (prices.length) {
return {
date: new Date((e.intervalStart.valueOf() + e.intervalEnd.valueOf()) / 2),
open: prices[0],
high: Math.max.apply(null, prices),
low: Math.min.apply(null, prices),
close: prices[prices.length - 1],
};
}
return null;
}, []);
useEffect(() => {
const hubConnection = new HubConnectionBuilder()
.withUrl('https://js.devexpress.com/Demos/NetCore/stockTickDataHub', {
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
})
.build();
const store = new CustomStore({
load: () => hubConnection.invoke('getAllData'),
key: 'date',
});
hubConnection
.start()
.then(() => {
hubConnection.on('updateStockPrice', (data) => {
store.push([{ type: 'insert', key: data.date, data }]);
});
setDataSource(store);
});
}, []);
return (
<div>
<Chart
id="chart"
ref={chartRef}
dataSource={dataSource}
title="Stock Price"
customizePoint={customizePoint}>
<Margin right={30} />
<Series
pane="Price"
argumentField="date"
type="candlestick">
<Aggregation
enabled={true}
method="custom"
calculate={calculateCandle} />
</Series>
<Series
pane="Volume"
name="Volume"
argumentField="date"
valueField="volume"
color="red"
type="bar">
<Aggregation
enabled={true}
method="sum" />
</Series>
<Pane name="Price"></Pane>
<Pane name="Volume" height={80}></Pane>
<Legend visible={false} />
<ArgumentAxis
argumentType="datetime"
minVisualRangeLength={minVisualRangeLength}
defaultVisualRange={defaultVisualRange} />
<ValueAxis placeholderSize={50} />
<ZoomAndPan argumentAxis="both" />
<ScrollBar visible={true} />
<LoadingIndicator enabled={true} />
<Tooltip
enabled={true}
shared={true}
argumentFormat="shortDateShortTime"
contentRender={TooltipTemplate}
/>
<Crosshair
enabled={true}>
<HorizontalLine visible={false} />
</Crosshair>
</Chart>
</div>
);
}
export default App;
import React, {
useCallback, useEffect, useRef, useState,
} from 'react';
import Chart, {
ArgumentAxis,
ValueAxis,
Aggregation,
Legend,
Series,
ScrollBar,
ZoomAndPan,
LoadingIndicator,
Pane,
Tooltip,
Crosshair,
Margin,
HorizontalLine,
} from 'devextreme-react/chart';
import { CustomStore } from 'devextreme-react/common/data';
import { HubConnectionBuilder, HttpTransportType } from '@aspnet/signalr';
import TooltipTemplate from './TooltipTemplate.js';
const minVisualRangeLength = { minutes: 10 };
const defaultVisualRange = { length: 'hour' };
function App() {
const [dataSource, setDataSource] = useState(null);
const chartRef = useRef(null);
const customizePoint = useCallback((arg) => {
if (arg.seriesName === 'Volume') {
const point = chartRef.current
.instance()
.getAllSeries()[0]
.getPointsByArg(arg.argument)[0].data;
if (point && point.close >= point.open) {
return { color: '#1db2f5' };
}
}
return null;
}, []);
const calculateCandle = useCallback((e) => {
const prices = e.data.map((d) => d.price);
if (prices.length) {
return {
date: new Date((e.intervalStart.valueOf() + e.intervalEnd.valueOf()) / 2),
open: prices[0],
high: Math.max.apply(null, prices),
low: Math.min.apply(null, prices),
close: prices[prices.length - 1],
};
}
return null;
}, []);
useEffect(() => {
const hubConnection = new HubConnectionBuilder()
.withUrl('https://js.devexpress.com/Demos/NetCore/stockTickDataHub', {
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
})
.build();
const store = new CustomStore({
load: () => hubConnection.invoke('getAllData'),
key: 'date',
});
hubConnection.start().then(() => {
hubConnection.on('updateStockPrice', (data) => {
store.push([{ type: 'insert', key: data.date, data }]);
});
setDataSource(store);
});
}, []);
return (
<div>
<Chart
id="chart"
ref={chartRef}
dataSource={dataSource}
title="Stock Price"
customizePoint={customizePoint}
>
<Margin right={30} />
<Series
pane="Price"
argumentField="date"
type="candlestick"
>
<Aggregation
enabled={true}
method="custom"
calculate={calculateCandle}
/>
</Series>
<Series
pane="Volume"
name="Volume"
argumentField="date"
valueField="volume"
color="red"
type="bar"
>
<Aggregation
enabled={true}
method="sum"
/>
</Series>
<Pane name="Price"></Pane>
<Pane
name="Volume"
height={80}
></Pane>
<Legend visible={false} />
<ArgumentAxis
argumentType="datetime"
minVisualRangeLength={minVisualRangeLength}
defaultVisualRange={defaultVisualRange}
/>
<ValueAxis placeholderSize={50} />
<ZoomAndPan argumentAxis="both" />
<ScrollBar visible={true} />
<LoadingIndicator enabled={true} />
<Tooltip
enabled={true}
shared={true}
argumentFormat="shortDateShortTime"
contentRender={TooltipTemplate}
/>
<Crosshair enabled={true}>
<HorizontalLine visible={false} />
</Crosshair>
</Chart>
</div>
);
}
export default App;
import React from 'react';
const formatCurrency = new Intl.NumberFormat('en-US',
{ style: 'currency', currency: 'USD', minimumFractionDigits: 0 }).format;
const formatNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
}).format;
export default function TooltipTemplate(pointInfo) {
const volume = pointInfo.points.filter((point: { seriesName: string; }) => point.seriesName === 'Volume')[0];
const prices = pointInfo.points.filter((point: { seriesName: string; }) => point.seriesName !== 'Volume')[0];
return (
<div className="tooltip-template">
<div>{ pointInfo.argumentText }</div>
<div><span>Open: </span>
{ formatCurrency(prices.openValue) }
</div>
<div><span>High: </span>
{ formatCurrency(prices.highValue) }
</div>
<div><span>Low: </span>
{ formatCurrency(prices.lowValue) }
</div>
<div><span>Close: </span>
{ formatCurrency(prices.closeValue) }
</div>
<div><span>Volume: </span>
{ formatNumber(volume.value) }
</div>
</div>
);
}
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.tsx';
ReactDOM.render(
<App />,
document.getElementById('app'),
);
window.exports = window.exports || {};
window.config = {
transpiler: 'ts',
typescriptOptions: {
module: 'system',
emitDecoratorMetadata: true,
experimentalDecorators: true,
jsx: 'react',
},
meta: {
'react': {
'esModule': true,
},
'typescript': {
'exports': 'ts',
},
'devextreme/time_zone_utils.js': {
'esModule': true,
},
'devextreme/localization.js': {
'esModule': true,
},
'devextreme/viz/palette.js': {
'esModule': true,
},
'openai': {
'esModule': true,
},
},
paths: {
'npm:': 'https://cdn.jsdelivr.net/npm/',
'bundles:': '../../../../bundles/',
'externals:': '../../../../bundles/externals/',
},
defaultExtension: 'js',
map: {
'ts': 'npm:plugin-typescript@8.0.0/lib/plugin.js',
'typescript': 'npm:typescript@4.2.4/lib/typescript.js',
'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js',
'react': 'npm:react@17.0.2/umd/react.development.js',
'react-dom': 'npm:react-dom@17.0.2/umd/react-dom.development.js',
'prop-types': 'npm:prop-types/prop-types.js',
'@aspnet/signalr': 'npm:@aspnet/signalr@1.0.27/dist/cjs',
'tslib': 'npm:tslib/tslib.js',
'rrule': 'npm:rrule@2.6.4/dist/es5/rrule.js',
'luxon': 'npm:luxon@3.4.4/build/global/luxon.min.js',
'es6-object-assign': 'npm:es6-object-assign',
'devextreme': 'npm:devextreme@link:../../packages/devextreme/artifacts/npm/devextreme/cjs',
'devextreme-react': 'npm:devextreme-react@link:../../packages/devextreme-react/npm/cjs',
'devextreme-quill': 'npm:devextreme-quill@1.7.6/dist/dx-quill.min.js',
'devexpress-diagram': 'npm:devexpress-diagram@2.2.24/dist/dx-diagram.js',
'devexpress-gantt': 'npm:devexpress-gantt@4.1.64/dist/dx-gantt.js',
'inferno': 'npm:inferno@8.2.3/dist/inferno.min.js',
'inferno-compat': 'npm:inferno-compat/dist/inferno-compat.min.js',
'inferno-create-element': 'npm:inferno-create-element@8.2.3/dist/inferno-create-element.min.js',
'inferno-dom': 'npm:inferno-dom/dist/inferno-dom.min.js',
'inferno-hydrate': 'npm:inferno-hydrate/dist/inferno-hydrate.min.js',
'inferno-clone-vnode': 'npm:inferno-clone-vnode/dist/inferno-clone-vnode.min.js',
'inferno-create-class': 'npm:inferno-create-class/dist/inferno-create-class.min.js',
'inferno-extras': 'npm:inferno-extras/dist/inferno-extras.min.js',
'@preact/signals-core': 'npm:@preact/signals-core@1.8.0/dist/signals-core.min.js',
'devextreme-cldr-data': 'npm:devextreme-cldr-data@1.0.3',
// SystemJS plugins
'plugin-babel': 'npm:systemjs-plugin-babel@0.0.25/plugin-babel.js',
'systemjs-babel-build': 'npm:systemjs-plugin-babel@0.0.25/systemjs-babel-browser.js',
// Prettier
'prettier/standalone': 'npm:prettier@2.8.8/standalone.js',
'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js',
},
packages: {
'devextreme': {
defaultExtension: 'js',
},
'devextreme-react': {
main: 'index.js',
},
'devextreme-react/common': {
main: 'index.js',
},
'devextreme/events/utils': {
main: 'index',
},
'devextreme/common/core/events/utils': {
main: 'index',
},
'devextreme/localization/messages': {
format: 'json',
defaultExtension: 'json',
},
'devextreme/events': {
main: 'index',
},
'@aspnet/signalr': {
main: 'index.js',
defaultExtension: 'js',
},
'es6-object-assign': {
main: './index.js',
defaultExtension: 'js',
},
},
packageConfigPaths: [
'npm:@devextreme/*/package.json',
],
babelOptions: {
sourceMaps: false,
stage0: true,
react: true,
},
};
System.config(window.config);
// eslint-disable-next-line
const useTgzInCSB = ['openai'];
import React from 'react';
const formatCurrency = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
}).format;
const formatNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
}).format;
export default function TooltipTemplate(pointInfo) {
const volume = pointInfo.points.filter((point) => point.seriesName === 'Volume')[0];
const prices = pointInfo.points.filter((point) => point.seriesName !== 'Volume')[0];
return (
<div className="tooltip-template">
<div>{pointInfo.argumentText}</div>
<div>
<span>Open: </span>
{formatCurrency(prices.openValue)}
</div>
<div>
<span>High: </span>
{formatCurrency(prices.highValue)}
</div>
<div>
<span>Low: </span>
{formatCurrency(prices.lowValue)}
</div>
<div>
<span>Close: </span>
{formatCurrency(prices.closeValue)}
</div>
<div>
<span>Volume: </span>
{formatNumber(volume.value)}
</div>
</div>
);
}
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
ReactDOM.render(<App />, document.getElementById('app'));
<!DOCTYPE html>
<html lang="en">
<head>
<title>DevExtreme Demo</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/25.1.6/css/dx.light.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="https://cdn.jsdelivr.net/npm/core-js@2.6.12/client/shim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@0.21.3/dist/system.js"></script>
<script type="text/javascript" src="config.js"></script>
<script type="text/javascript">
System.import("./index.tsx");
</script>
</head>
<body class="dx-viewport">
<div class="demo-container">
<div id="app"></div>
</div>
</body>
</html>
#chart {
height: 440px;
}
.tooltip-template span {
font-weight: 500;
}
using System.Collections.Generic;
using DevExtreme.MVC.Demos.Models.SignalRTickData;
using Microsoft.AspNet.SignalR;
namespace DevExtreme.MVC.Demos.Hubs {
public class StockTickDataHub : Hub {
private readonly TickDataService _service;
public StockTickDataHub() {
_service = TickDataService.Instance;
}
public IEnumerable<TickItem> GetAllData() {
return _service.GetAllData();
}
}
}
using DevExtreme.MVC.Demos.Hubs;
using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hubs;
using System;
using System.Collections.Generic;
using System.Threading;
using DevExpress.Data.Utils;
namespace DevExtreme.MVC.Demos.Models.SignalRTickData {
public class TickDataService {
public readonly static TickDataService Instance = new TickDataService(GlobalHost.ConnectionManager.GetHubContext<StockTickDataHub>().Clients);
private IHubConnectionContext<dynamic> Clients { get; set; }
private readonly object updateStockPricesLock = new object();
private TickItem[] tickData;
private int lastTickIndex;
private Timer timer;
private TickDataService(IHubConnectionContext<dynamic> clients) {
Clients = clients;
tickData = GenerateTestData();
lastTickIndex = tickData.Length - 1;
timer = new Timer(Update, null, 1000, 1000);
}
public IEnumerable<TickItem> GetAllData() {
var data = new List<TickItem>();
lock(updateStockPricesLock) {
for(var i = lastTickIndex; data.Count < 10000; i--) {
data.Add(tickData[i]);
if(i == 0) {
i = tickData.Length - 1;
}
}
}
return data;
}
private void Update(object state) {
lock(updateStockPricesLock) {
lastTickIndex = (lastTickIndex + 1) % tickData.Length;
var tick = tickData[lastTickIndex];
tick.Date = DateTime.Now;
BroadcastStockPrice(tick);
}
}
private void BroadcastStockPrice(TickItem item) {
Clients.All.updateStockPrice(item);
}
private TickItem[] GenerateTestData() {
var lastPrice = 140m;
var random = NonCryptographicRandom.System;
var slowRandomValue = random.NextDouble();
var data = new TickItem[60 * 60 * 20];
var curTime = DateTime.Now;
for(var i = 0; i < data.Length / 2; i++) {
lastPrice = Math.Round(lastPrice * (decimal)(1 + 0.002 * (-1 + 2 * random.NextDouble())), 2);
if(i % 50 == 0) {
slowRandomValue = random.NextDouble();
if(slowRandomValue > 0.3 && slowRandomValue <= 0.5) slowRandomValue -= 0.2;
if(slowRandomValue > 0.5 && slowRandomValue <= 0.7) slowRandomValue += 0.2;
}
var volume = (int)(100 + 1900 * random.NextDouble() * slowRandomValue);
data[data.Length - 1 - i] = new TickItem(lastPrice, volume, curTime.AddSeconds(-1 * i));
data[i] = new TickItem(lastPrice, volume, curTime.AddSeconds(-1 * (data.Length - 1 - i)));
}
return data;
}
}
}
To display updated data in real time, use the aggregation configuration object. In this object, set the enabled property to true, the method property to custom, and then implement the calculate function to process the incoming data. In this demo, the calculate function aggregates data into a one point for each interval.
You can also use the contentTemplate function to update the tooltip content with incoming data.