Backend API
<template>
<div v-if="connectionStarted">
<DxChart
id="chart"
ref="chartRef"
:data-source="dataSource"
:margin="{right: 30}"
:customize-point="customizePoint"
zooming-mode="all"
scrolling-mode="all"
title="Stock Price"
>
<DxSeries
argument-field="date"
type="candlestick"
pane="Price"
>
<DxAggregation
:enabled="true"
:calculate="calculateCandle"
method="custom"
/>
</DxSeries>
<DxSeries
argument-field="date"
value-field="volume"
type="bar"
color="red"
pane="Volume"
name="Volume"
>
<DxAggregation
:enabled="true"
method="sum"
/>
</DxSeries>
<DxPane name="Price"/>
<DxPane
:height="80"
name="Volume"
/>
<DxLegend :visible="false"/>
<DxArgumentAxis
:min-visual-range-length="{minutes: 10}"
:visual-range="{length: 'hour'}"
argument-type="datetime"
/>
<DxZoomAndPan argument-axis="both"/>
<DxValueAxis :placeholder-size="50"/>
<DxScrollBar :visible="true"/>
<DxLoadingIndicator :enabled="true"/>
<DxTooltip
:enabled="true"
:shared="true"
argument-format="shortDateShortTime"
content-template="tooltipTemplate"
/>
<DxCrosshair
:enabled="true"
:horizontal-line="{visible: false}"
/>
<template #tooltipTemplate="{ data }">
<TooltipTemplate :point-info="data"/>
</template>
</DxChart>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
DxChart,
DxArgumentAxis,
DxValueAxis,
DxAggregation,
DxSeries,
DxLegend,
DxScrollBar,
DxZoomAndPan,
DxLoadingIndicator,
DxPane,
DxTooltip,
DxCrosshair,
} from 'devextreme-vue/chart';
import type { chartPointAggregationInfoObject } from "devextreme/viz/chart";
import { CustomStore } from 'devextreme-vue/common/data';
import { HubConnectionBuilder, HttpTransportType } from '@aspnet/signalr';
import TooltipTemplate from './TooltipTemplate.vue';
const chartRef = ref<DxChart>();
const connectionStarted = ref(false);
const dataSource = ref<CustomStore>();
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 }]);
});
dataSource.value = store;
connectionStarted.value = true;
});
function calculateCandle(e: chartPointAggregationInfoObject) {
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 [];
}
function customizePoint(pointInfo: Record<string, any>) {
if (pointInfo.seriesName === 'Volume') {
const point = chartRef.value?.instance
?.getAllSeries()[0]
.getPointsByArg(pointInfo.argument)[0]
.data;
if (point && point.close >= point.open) {
return { color: '#1db2f5' };
}
}
return { color: undefined };
}
</script>
<style>
#chart {
height: 440px;
}
</style>
<template>
<div class="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>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
pointInfo: Record<string, any>
}>(), {
pointInfo: () => ({} as Record<string, any>),
});
const volume = computed<{value : number}>(() => props.pointInfo.points.filter(({ seriesName }: any) => seriesName === 'Volume')[0]);
const prices = computed<Record<string, any>>(() => props.pointInfo.points.filter(({ seriesName }: any) => seriesName !== 'Volume')[0]);
const formatCurrency = new Intl.NumberFormat('en-US',
{ style: 'currency', currency: 'USD', minimumFractionDigits: 0 }).format;
const formatNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
}).format;
</script>
<style>
.tooltip-template span {
font-weight: 500;
}
</style>
window.exports = window.exports || {};
window.config = {
transpiler: 'plugin-babel',
meta: {
'*.vue': {
loader: 'vue-loader',
},
'*.ts': {
loader: 'demo-ts-loader',
},
'*.svg': {
loader: 'svg-loader',
},
'devextreme/time_zone_utils.js': {
'esModule': true,
},
'devextreme/localization.js': {
'esModule': true,
},
'devextreme/viz/palette.js': {
'esModule': true,
},
'openai': {
'esModule': true,
},
},
paths: {
'project:': '../../../../',
'npm:': 'https://cdn.jsdelivr.net/npm/',
'bundles:': '../../../../bundles/',
'externals:': '../../../../bundles/externals/',
},
map: {
'vue': 'npm:vue@3.4.27/dist/vue.esm-browser.js',
'@vue/shared': 'npm:@vue/shared@3.4.27/dist/shared.cjs.prod.js',
'vue-loader': 'npm:dx-systemjs-vue-browser@1.1.2/index.js',
'demo-ts-loader': 'project:utils/demo-ts-loader.js',
'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js',
'svg-loader': 'project:utils/svg-loader.js',
'@aspnet/signalr': 'npm:@aspnet/signalr@1.0.27/dist/cjs',
'tslib': 'npm:tslib/tslib.js',
'mitt': 'npm:mitt/dist/mitt.umd.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-vue': 'npm:devextreme-vue@link:../../packages/devextreme-vue/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',
'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-vue': {
main: 'index.js',
},
'devextreme-vue/common': {
main: 'index.js',
},
'devextreme': {
defaultExtension: 'js',
},
'devextreme/events/utils': {
main: 'index',
},
'devextreme/common/core/events/utils': {
main: 'index',
},
'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,
},
};
System.config(window.config);
// eslint-disable-next-line
const useTgzInCSB = ['openai'];
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#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" />
<script type="module">
import * as vueCompilerSFC from "https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@3.4.27/dist/compiler-sfc.esm-browser.js";
window.vueCompilerSFC = vueCompilerSFC;
</script>
<script src="https://cdn.jsdelivr.net/npm/typescript@5.4.5/lib/typescript.js"></script>
<script type="module">
import * as vueCompilerSFC from "https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@3.4.27/dist/compiler-sfc.esm-browser.js";
window.vueCompilerSFC = vueCompilerSFC;
</script>
<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.ts");
</script>
</head>
<body class="dx-viewport">
<div class="demo-container">
<div id="app"> </div>
</div>
</body>
</html>
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.