Skip to content

Commit b5db597

Browse files
authored
Merge pull request #1934 from cll-355_overlay-histogram
Add Dual Range Histogram Series plugin example
2 parents 204e0c6 + 5b9b401 commit b5db597

File tree

10 files changed

+462
-3
lines changed

10 files changed

+462
-3
lines changed

plugin-examples/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugin-examples/src/index.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,17 @@ <h2>Combined Examples</h2>
2626
<h2>Custom Series</h2>
2727
<ul>
2828
<li><a href="./plugins/brushable-area-series/example/">Brushable Area Series</a></li>
29+
<li><a href="./plugins/dual-range-histogram-series/example/">Dual Range Histogram Series</a></li>
2930
<li><a href="./plugins/grouped-bars-series/example/">GroupedBars Series</a></li>
3031
<li>Heatmap Series: <a href="./plugins/heatmap-series/example/">Example 1</a> / <a href="./plugins/heatmap-series/example/example2.html">Example 2</a></li>
3132
<li><a href="./plugins/hlc-area-series/example/">HLC Area Series</a></li>
3233
<li><a href="./plugins/pretty-histogram/example/">Pretty Histogram</a></li>
3334
<li><a href="./plugins/lollipop-series/example/">Lollipop Series</a></li>
3435
<li><a href="./plugins/rounded-candles-series/example/">Rounded Candle Series</a></li>
35-
<li><a href="./plugins/background-shade-series/example/">Shaded Background Series</a></li>
36+
<li><a href="./plugins/background-shade-series/example/">Shaded Background Series</a></li>
3637
<li><a href="./plugins/stacked-area-series/example/">Stacked Area Series</a></li>
3738
<li><a href="./plugins/stacked-bars-series/example/">Stacked Bars Series</a></li>
38-
<li><a href="./plugins/box-whisker-series/example/">Whisker Box Series</a></li>
39+
<li><a href="./plugins/box-whisker-series/example/">Whisker Box Series</a></li>
3940
</ul>
4041
<h2>Primitives</h2>
4142
<ul>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
export type LeftTopRightTopRightBottomLeftBottomRadii = [
2+
number,
3+
number,
4+
number,
5+
number
6+
];
7+
8+
function changeBorderRadius(
9+
borderRadius: LeftTopRightTopRightBottomLeftBottomRadii,
10+
offset: number
11+
): typeof borderRadius {
12+
return borderRadius.map((x: number) =>
13+
x === 0 ? x : x + offset
14+
) as typeof borderRadius;
15+
}
16+
17+
export function drawRoundRect(
18+
// eslint-disable-next-line max-params
19+
ctx: CanvasRenderingContext2D,
20+
x: number,
21+
y: number,
22+
w: number,
23+
h: number,
24+
radii: LeftTopRightTopRightBottomLeftBottomRadii
25+
): void {
26+
/**
27+
* As of May 2023, all of the major browsers now support ctx.roundRect() so we should
28+
* be able to switch to the native version soon.
29+
*/
30+
ctx.beginPath();
31+
if (ctx.roundRect) {
32+
ctx.roundRect(x, y, w, h, radii);
33+
return;
34+
}
35+
/*
36+
* Deprecate the rest in v5.
37+
*/
38+
ctx.lineTo(x + w - radii[1], y);
39+
if (radii[1] !== 0) {
40+
ctx.arcTo(x + w, y, x + w, y + radii[1], radii[1]);
41+
}
42+
43+
ctx.lineTo(x + w, y + h - radii[2]);
44+
if (radii[2] !== 0) {
45+
ctx.arcTo(x + w, y + h, x + w - radii[2], y + h, radii[2]);
46+
}
47+
48+
ctx.lineTo(x + radii[3], y + h);
49+
if (radii[3] !== 0) {
50+
ctx.arcTo(x, y + h, x, y + h - radii[3], radii[3]);
51+
}
52+
53+
ctx.lineTo(x, y + radii[0]);
54+
if (radii[0] !== 0) {
55+
ctx.arcTo(x, y, x + radii[0], y, radii[0]);
56+
}
57+
}
58+
59+
/**
60+
* Draws a rounded rect with a border.
61+
*
62+
* This function assumes that the colors will be solid, without
63+
* any alpha. (This allows us to fix a rendering artefact.)
64+
*
65+
* @param outerBorderRadius - The radius of the border (outer edge)
66+
*/
67+
// eslint-disable-next-line max-params
68+
export function drawRoundRectWithBorder(
69+
ctx: CanvasRenderingContext2D,
70+
left: number,
71+
top: number,
72+
width: number,
73+
height: number,
74+
backgroundColor: string,
75+
borderWidth: number = 0,
76+
outerBorderRadius: LeftTopRightTopRightBottomLeftBottomRadii = [0, 0, 0, 0],
77+
borderColor: string = ''
78+
): void {
79+
ctx.save();
80+
81+
if (!borderWidth || !borderColor || borderColor === backgroundColor) {
82+
drawRoundRect(ctx, left, top, width, height, outerBorderRadius);
83+
ctx.fillStyle = backgroundColor;
84+
ctx.fill();
85+
ctx.restore();
86+
return;
87+
}
88+
89+
const halfBorderWidth = borderWidth / 2;
90+
const radii = changeBorderRadius(outerBorderRadius, -halfBorderWidth);
91+
92+
drawRoundRect(
93+
ctx,
94+
left + halfBorderWidth,
95+
top + halfBorderWidth,
96+
width - borderWidth,
97+
height - borderWidth,
98+
radii
99+
);
100+
101+
if (backgroundColor !== 'transparent') {
102+
ctx.fillStyle = backgroundColor;
103+
ctx.fill();
104+
}
105+
106+
if (borderColor !== 'transparent') {
107+
ctx.lineWidth = borderWidth;
108+
ctx.strokeStyle = borderColor;
109+
ctx.closePath();
110+
ctx.stroke();
111+
}
112+
113+
ctx.restore();
114+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { CustomData } from 'lightweight-charts';
2+
3+
/**
4+
* DualRangeHistogram Series Data
5+
*/
6+
export interface DualRangeHistogramData extends CustomData {
7+
values: number[];
8+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
CustomSeriesPricePlotValues,
3+
ICustomSeriesPaneView,
4+
PaneRendererCustomData,
5+
WhitespaceData,
6+
Time,
7+
} from 'lightweight-charts';
8+
import { DualRangeHistogramSeriesOptions, defaultOptions } from './options';
9+
import { DualRangeHistogramSeriesRenderer } from './renderer';
10+
import { DualRangeHistogramData } from './data';
11+
12+
export class DualRangeHistogramSeries<TData extends DualRangeHistogramData>
13+
implements ICustomSeriesPaneView<Time, TData, DualRangeHistogramSeriesOptions>
14+
{
15+
_renderer: DualRangeHistogramSeriesRenderer<TData>;
16+
17+
constructor() {
18+
this._renderer = new DualRangeHistogramSeriesRenderer();
19+
}
20+
21+
priceValueBuilder(): CustomSeriesPricePlotValues {
22+
return [0]; // keep zero line in view with autoscaling
23+
}
24+
25+
isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
26+
return !Boolean((data as Partial<TData>).values?.length);
27+
}
28+
29+
renderer(): DualRangeHistogramSeriesRenderer<TData> {
30+
return this._renderer;
31+
}
32+
33+
update(
34+
data: PaneRendererCustomData<Time, TData>,
35+
options: DualRangeHistogramSeriesOptions
36+
): void {
37+
this._renderer.update(data, options);
38+
}
39+
40+
defaultOptions() {
41+
return defaultOptions;
42+
}
43+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { BaselineSeries, createChart } from 'lightweight-charts';
2+
import { DualRangeHistogramSeries } from '../dual-range-histogram-series';
3+
import { generateLineData, shuffleValuesWithLimit } from '../../../sample-data';
4+
import { centerLineData, generateDualRangeHistogramData } from './sample-data';
5+
6+
const numberPoints = 200;
7+
8+
const chart = ((window as unknown as any).chart = createChart('chart', {
9+
autoSize: true,
10+
timeScale: {
11+
minBarSpacing: 4,
12+
barSpacing: 21,
13+
},
14+
}));
15+
16+
const dualRangeHistogramSeriesView = new DualRangeHistogramSeries();
17+
const dualRangeHistogramSeries = chart.addCustomSeries(
18+
dualRangeHistogramSeriesView,
19+
{
20+
/* Options */
21+
color: 'black', // for the price line,
22+
priceLineVisible: false,
23+
lastValueVisible: false,
24+
}
25+
);
26+
27+
const data = generateDualRangeHistogramData(numberPoints);
28+
dualRangeHistogramSeries.setData(data);
29+
30+
const baselineSeries = chart.addSeries(BaselineSeries, {
31+
baseValue: { type: 'price', price: 0 },
32+
});
33+
const lineData = centerLineData(
34+
shuffleValuesWithLimit(generateLineData(numberPoints), 3)
35+
);
36+
baselineSeries.setData(lineData);
37+
38+
// The following code is for ensuring that the histogram remains in view
39+
// because we are purposely not telling the library the priceValues so
40+
// it doesn't adjust the price scale normally. This is so we can keep the
41+
// series on the zero line of the main price scale but not affect it's scaling
42+
function setPriceScaleMargins(): void {
43+
const { height } = chart.paneSize();
44+
const seriesHeight = dualRangeHistogramSeries.options().maxHeight;
45+
const margin = Math.min(0.3, seriesHeight / 2 / height);
46+
dualRangeHistogramSeries.priceScale().applyOptions({
47+
scaleMargins: {
48+
top: margin,
49+
bottom: margin,
50+
},
51+
});
52+
}
53+
const resizeObserver = new ResizeObserver(_ => {
54+
setPriceScaleMargins();
55+
});
56+
resizeObserver.observe(chart.chartElement());
57+
setPriceScaleMargins();
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Lightweight Charts - DualRangeHistogram Series Plugin Example</title>
7+
<link href="../../../examples-base.css" rel="stylesheet" />
8+
</head>
9+
<body>
10+
<div id="chart"></div>
11+
<div id="description">
12+
<h1>Dual Range Histogram Series</h1>
13+
<p>
14+
The Dual Range Histogram is a versatile column-based visualisation
15+
designed to represent paired positive and negative value ranges for each
16+
time point. Each group of columns consists of two positive (upward)
17+
bars—typically differentiated by light and dark shades—and two negative
18+
(downward) bars, also in contrasting shades. The lighter columns
19+
represent the total extent of a metric (such as run-up or drawdown),
20+
while the darker columns indicate a subset or related component that
21+
must always be less than or equal to the corresponding lighter column.
22+
This structure allows for clear comparison between total and partial
23+
values within both positive and negative domains. The Dual Range
24+
Histogram is well-suited for financial analysis, performance
25+
backtesting, or any scenario where it is useful to visualise both the
26+
magnitude and composition of upward and downward movements over time.
27+
</p>
28+
</div>
29+
<script type="module" src="./example.ts"></script>
30+
</body>
31+
</html>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { LineData, WhitespaceData } from 'lightweight-charts';
2+
import { multipleBarData } from '../../../sample-data';
3+
import { DualRangeHistogramData } from '../data';
4+
5+
export function centerLineData(lineData: LineData[]): LineData[] {
6+
const lineDataValues = lineData.map(i => i.value);
7+
const min = Math.min(...lineDataValues);
8+
const max = Math.max(...lineDataValues);
9+
const mid = (max - min) / 2 + min;
10+
const adjustedLineData = lineData.map(i => {
11+
return {
12+
...i,
13+
value: i.value - mid,
14+
};
15+
});
16+
return adjustedLineData;
17+
}
18+
19+
export function generateDualRangeHistogramData(
20+
numberPoints: number
21+
): (DualRangeHistogramData | WhitespaceData)[] {
22+
return multipleBarData(4, numberPoints, 20).map(datum => {
23+
const positiveValues = datum.values.slice(0, 2).sort().reverse();
24+
positiveValues[1] *= 0.5 + Math.random() * 0.5;
25+
const negativeValues = datum.values
26+
.slice(2)
27+
.sort()
28+
.map(i => -1 * i);
29+
negativeValues[1] *= 0.5 + Math.random() * 0.5;
30+
return {
31+
...datum,
32+
values: [...positiveValues, ...negativeValues],
33+
};
34+
});
35+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {
2+
CustomSeriesOptions,
3+
customSeriesDefaultOptions,
4+
} from 'lightweight-charts';
5+
6+
export interface DualRangeHistogramSeriesOptions extends CustomSeriesOptions {
7+
colors: readonly string[];
8+
borderRadius: readonly number[];
9+
maxHeight: number;
10+
}
11+
12+
export const defaultOptions: DualRangeHistogramSeriesOptions = {
13+
...customSeriesDefaultOptions,
14+
colors: ['#ACE5DC', '#42BDA8', '#FCCACD', '#F77C80'],
15+
borderRadius: [2, 0, 2, 0],
16+
maxHeight: 130,
17+
} as const;

0 commit comments

Comments
 (0)