diff --git a/.eslintignore b/.eslintignore
index 517b6298d6..5db9c3a96d 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -13,3 +13,5 @@
/website/docs/api/**
/website/versioned_docs/**/api/**
/website/build/**
+
+/plugin-examples
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 0584405a63..87c6a17013 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -36,6 +36,15 @@ jobs:
- name: Build documentation website
working-directory: ./website
run: npm run build
+ - name: Install plugin example dependencies
+ working-directory: ./plugin-examples
+ run: npm install
+ - name: Build plugin examples website
+ working-directory: ./plugin-examples
+ run: npm run build:examples:site
+ - name: Build plugin examples website
+ working-directory: ./plugin-examples
+ run: npm run build:examples:site
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
diff --git a/plugin-examples/.gitignore b/plugin-examples/.gitignore
new file mode 100644
index 0000000000..e8076a4b1d
--- /dev/null
+++ b/plugin-examples/.gitignore
@@ -0,0 +1,17 @@
+node_modules
+dist
+compiled
+typings
+website
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/plugin-examples/README.md b/plugin-examples/README.md
new file mode 100644
index 0000000000..b7d530c76e
--- /dev/null
+++ b/plugin-examples/README.md
@@ -0,0 +1,96 @@
+# Lightweight Charts™ Plugin Examples
+
+This folder contains a collection of example plugins designed to extend the
+functionality of Lightweight Charts™ and inspire the development of your own
+plugins.
+
+**Disclaimer:** These plugins are provided as-is, and are primarily intended as
+proof-of-concept examples and starting points. They have not been fully
+optimized for production and may not receive updates or improvements over time.
+
+We believe in the power of community collaboration, and we warmly welcome any
+pull requests (PRs) aimed at enhancing and fixing the existing examples.
+Additionally, we encourage you to create your own plugins and share them with
+the community. We would be delighted to showcase the best plugins our users
+create in this readme document.
+
+✨ If you have something cool to share or if you need assistance, please don't
+hesitate to get in touch.
+
+🚀 Need a starting point for your plugin idea? Check out
+[create-lwc-plugin](https://github.com/tradingview/create-lwc-plugin) package.
+
+📊 You can view a demo page of the plugins within this repo at his link:
+[Plugin Examples](https://tradingview.github.io/lightweight-charts/plugin-examples)
+
+## Learning More
+
+- [Documentation for Plugins](https://tradingview.github.io/lightweight-charts/docs/next/plugins/intro)
+- [Learn more about Lightweight Charts™](https://www.tradingview.com/lightweight-charts/)
+
+## Running Locally
+
+To run this repo locally, follow these steps:
+
+1. Clone the repo to your local machine
+2. First build the library
+
+ ```shell
+ npm install
+ npm run build:prod
+ ```
+
+3. Switch to the Plugin Examples Folder, install and start the development server
+
+ ```shell
+ cd plugin-examples
+ npm install
+ npm run dev
+ ```
+
+4. Visit `localhost:5173` in the browser.
+
+## Compiling the Examples
+
+```shell
+npm run compile
+```
+
+Check the output in the `compiled` folder.
+
+## Using an Example
+
+Once you have compiled the examples then simply copy that folder into your
+project and import the JS module in your code.
+
+1. Copy the compiled plugin folder into your project, example:
+ `plugins/background-shade-series` (from `compiled/background-shade-series`)
+2. Within your project, you can import the class as follows:
+
+ ```js
+ import { BackgroundShadeSeries } from '../plugins/background-shade-series/background-shade-series';
+
+ // ...
+
+ const backgroundShadeSeriesPlugin = new BackgroundShadeSeries();
+ const myCustomSeries = chart.addCustomSeries(backgroundShadeSeriesPlugin, {
+ lowValue: 0,
+ highValue: 1000,
+ });
+ ```
+
+## Creating your own Plugin
+
+[create-lwc-plugin](https://github.com/tradingview/create-lwc-plugin) is an npm
+package designed to simplify the process of creating a new plugin for
+Lightweight Charts™. With this generator, you can quickly scaffold a project
+from a template for either
+
+- a Drawing primitive plugin, or
+- a Custom series plugin.
+
+You can get started with this simple command:
+
+```shell
+npm create lwc-plugin@latest
+```
diff --git a/plugin-examples/build-website.mjs b/plugin-examples/build-website.mjs
new file mode 100644
index 0000000000..b76a161d22
--- /dev/null
+++ b/plugin-examples/build-website.mjs
@@ -0,0 +1,72 @@
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ statSync,
+ readFileSync,
+ writeFileSync,
+ rmSync,
+} from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const currentDir = dirname(__filename);
+
+const distFolder = resolve(currentDir, 'dist');
+const distSrcFolder = resolve(currentDir, 'dist', 'src');
+const websiteFolder = resolve(currentDir, 'website');
+const docsWebsiteFolder = resolve(currentDir, '..', 'website', 'build', 'plugin-examples');
+
+function emptyDir(dir) {
+ if (!existsSync(dir)) {
+ return;
+ }
+ for (const file of readdirSync(dir)) {
+ rmSync(resolve(dir, file), { recursive: true, force: true });
+ }
+}
+
+function copy(src, dest, contentReplacer) {
+ const stat = statSync(src);
+ if (stat.isDirectory()) {
+ copyDir(src, dest, contentReplacer);
+ } else {
+ const content = readFileSync(src).toString();
+ writeFileSync(dest, contentReplacer ? contentReplacer(content) : content);
+ }
+}
+
+function copyDir(srcDir, destDir, contentReplacer) {
+ mkdirSync(destDir, { recursive: true });
+ for (const file of readdirSync(srcDir)) {
+ const srcFile = resolve(srcDir, file);
+ const destFile = resolve(destDir, file);
+ if (file !== 'src') {
+ copy(srcFile, destFile, contentReplacer);
+ }
+ }
+}
+
+function contentReplacer(content) {
+ return content
+ .replace(/("|')\.\.\/assets/g, '$1./assets')
+ .replace(/\/\.\.\/assets/g, '/assets');
+}
+
+emptyDir(websiteFolder);
+copyDir(distFolder, websiteFolder, contentReplacer, 'src');
+copyDir(distSrcFolder, websiteFolder, contentReplacer);
+copy(
+ resolve(websiteFolder, 'index.html'),
+ resolve(websiteFolder, 'index.html'),
+ content => {
+ return content.replace(
+ '
',
+ '\n '
+ );
+ }
+);
+
+// Copy into the documentation site build
+copyDir(websiteFolder, docsWebsiteFolder, content => content);
diff --git a/plugin-examples/compile.mjs b/plugin-examples/compile.mjs
new file mode 100644
index 0000000000..8e5dd5ecd6
--- /dev/null
+++ b/plugin-examples/compile.mjs
@@ -0,0 +1,165 @@
+/* eslint-disable no-console */
+import { dirname, resolve, basename, join, extname } from 'node:path';
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import { build, defineConfig } from 'vite';
+import { fileURLToPath } from 'url';
+import { generateDtsBundle } from 'dts-bundle-generator';
+
+function findPluginFiles(folderPath, recursive) {
+ const pathNames = readdirSync(folderPath);
+ const matchingFiles = [];
+
+ pathNames.forEach(pathName => {
+ const fullPath = join(folderPath, pathName);
+ const stats = statSync(fullPath);
+
+ if (recursive && stats.isDirectory() && pathName === basename(fullPath)) {
+ const innerFiles = findPluginFiles(fullPath, false);
+ matchingFiles.push(...innerFiles);
+ } else if (
+ stats.isFile() &&
+ pathName === `${basename(folderPath)}${extname(pathName)}`
+ ) {
+ matchingFiles.push([fullPath, basename(folderPath)]);
+ }
+ });
+
+ return matchingFiles;
+}
+
+function convertKebabToCamel(kebabCaseString) {
+ const words = kebabCaseString.split('-');
+ const camelCaseWords = words.map((word, index) => {
+ if (index === 0) {
+ return word.charAt(0).toUpperCase() + word.slice(1);
+ }
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+ });
+
+ return camelCaseWords.join('');
+}
+
+const __filename = fileURLToPath(import.meta.url);
+const currentDir = dirname(__filename);
+
+const pluginsFolder = resolve(currentDir, 'src', 'plugins');
+const pluginFiles = findPluginFiles(pluginsFolder, true);
+
+const filesToBuild = pluginFiles.map(([filepath, exportName]) => {
+ return {
+ filepath,
+ exportName,
+ name: convertKebabToCamel(exportName),
+ };
+});
+
+const compiledFolder = resolve(currentDir, 'compiled');
+if (!existsSync(compiledFolder)) {
+ mkdirSync(compiledFolder);
+}
+
+const buildConfig = ({
+ filepath,
+ name,
+ exportName,
+ formats = ['es', 'umd'],
+}) => {
+ return defineConfig({
+ publicDir: false,
+ build: {
+ outDir: `compiled/${exportName}`,
+ emptyOutDir: true,
+ copyPublicDir: false,
+ lib: {
+ entry: filepath,
+ name,
+ formats,
+ fileName: exportName,
+ },
+ rollupOptions: {
+ external: ['lightweight-charts', 'fancy-canvas'],
+ output: {
+ globals: {
+ 'lightweight-charts': 'LightweightCharts',
+ },
+ },
+ },
+ },
+ });
+};
+
+function buildPackageJson(exportName) {
+ return {
+ name: exportName,
+ type: 'module',
+ main: `./${exportName}.umd.cjs`,
+ module: `./${exportName}.js`,
+ exports: {
+ '.': {
+ import: `./${exportName}.js`,
+ require: `./${exportName}.umd.cjs`,
+ types: `./${exportName}.d.ts`,
+ },
+ },
+ };
+}
+
+const compile = async () => {
+ const startTime = Date.now().valueOf();
+ console.log('⚡️ Starting');
+ console.log('Bundling the plugins...');
+ const promises = filesToBuild.map(file => {
+ return build(buildConfig(file));
+ });
+ await Promise.all(promises);
+ console.log('Generating the package.json files...');
+ filesToBuild.forEach(file => {
+ const packagePath = resolve(
+ compiledFolder,
+ file.exportName,
+ 'package.json'
+ );
+ const content = JSON.stringify(
+ buildPackageJson(file.exportName),
+ undefined,
+ 4
+ );
+ writeFileSync(packagePath, content, { encoding: 'utf-8' });
+ });
+ console.log('Generating the typings files...');
+ filesToBuild.forEach(file => {
+ try {
+ const esModuleTyping = generateDtsBundle([
+ {
+ filePath: `./typings/plugins/${file.exportName}/${file.exportName}.d.ts`,
+ // output: {
+ // umdModuleName: file.name,
+ // },
+ },
+ ]);
+ const typingFilePath = resolve(
+ compiledFolder,
+ file.exportName,
+ `${file.exportName}.d.ts`
+ );
+ writeFileSync(typingFilePath, esModuleTyping.join('\n'), {
+ encoding: 'utf-8',
+ });
+ } catch (e) {
+ console.error('Error generating typings for: ', file.exportName);
+ }
+ });
+ const endTime = Date.now().valueOf();
+ console.log(`🎉 Done (${endTime - startTime}ms)`);
+};
+
+(async () => {
+ await compile();
+ process.exit(0);
+})();
diff --git a/plugin-examples/index.html b/plugin-examples/index.html
new file mode 100644
index 0000000000..343797abfa
--- /dev/null
+++ b/plugin-examples/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+ List of example previews are here
+
+
diff --git a/plugin-examples/package.json b/plugin-examples/package.json
new file mode 100644
index 0000000000..06e3494809
--- /dev/null
+++ b/plugin-examples/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "lightweight-charts-plugin-examples",
+ "type": "module",
+ "scripts": {
+ "build": "tsc",
+ "compile": "tsc && node compile.mjs",
+ "dev": "vite --config src/vite.config.js",
+ "build:examples:site": "vite build --config src/vite.config.js && node build-website.mjs"
+ },
+ "devDependencies": {
+ "typescript": "^5.0.4",
+ "vite": "^4.3.1"
+ },
+ "dependencies": {
+ "dts-bundle-generator": "^8.0.1",
+ "fancy-canvas": "^2.1.0",
+ "globby": "^13.1.4",
+ "lightweight-charts": "file:.."
+ }
+}
diff --git a/plugin-examples/src/combined-examples/delta-brushable/example.ts b/plugin-examples/src/combined-examples/delta-brushable/example.ts
new file mode 100644
index 0000000000..256195d050
--- /dev/null
+++ b/plugin-examples/src/combined-examples/delta-brushable/example.ts
@@ -0,0 +1,98 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { BrushableAreaSeries } from '../../plugins/brushable-area-series/brushable-area-series';
+import { BrushableAreaData } from '../../plugins/brushable-area-series/data';
+import { BrushableAreaStyle } from '../../plugins/brushable-area-series/options';
+import { DeltaTooltipPrimitive } from '../../plugins/delta-tooltip/delta-tooltip';
+import { generateLineData } from '../../sample-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ grid: {
+ vertLines: {
+ visible: false,
+ },
+ horzLines: {
+ visible: false,
+ },
+ },
+ timeScale: {
+ borderVisible: false,
+ },
+ rightPriceScale: {
+ borderVisible: false,
+ },
+ handleScale: false,
+ handleScroll: false,
+}));
+
+const greenStyle: Partial = {
+ lineColor: 'rgb(4,153,129)',
+ topColor: 'rgba(4,153,129, 0.4)',
+ bottomColor: 'rgba(4,153,129, 0)',
+ lineWidth: 3,
+};
+
+const redStyle: Partial = {
+ lineColor: 'rgb(239,83,80)',
+ topColor: 'rgba(239,83,80, 0.4)',
+ bottomColor: 'rgba(239,83,80, 0)',
+ lineWidth: 3,
+};
+
+const fadeStyle: Partial = {
+ lineColor: 'rgb(40,98,255, 0.2)',
+ topColor: 'rgba(40,98,255, 0.05)',
+ bottomColor: 'rgba(40,98,255, 0)',
+};
+
+const baseStyle: Partial = {
+ lineColor: 'rgb(40,98,255)',
+ topColor: 'rgba(40,98,255, 0.4)',
+ bottomColor: 'rgba(40,98,255, 0)',
+};
+
+const customSeriesView = new BrushableAreaSeries();
+const brushAreaSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ ...baseStyle,
+ priceLineVisible: false,
+});
+
+const data: (BrushableAreaData | WhitespaceData)[] = generateLineData();
+brushAreaSeries.setData(data);
+
+const tooltipPrimitive = new DeltaTooltipPrimitive({
+ lineColor: 'rgba(0, 0, 0, 0.2)',
+});
+
+brushAreaSeries.attachPrimitive(tooltipPrimitive);
+
+chart.timeScale().fitContent();
+
+tooltipPrimitive.activeRange().subscribe(activeRange => {
+ if (activeRange === null) {
+ brushAreaSeries.applyOptions({
+ brushRanges: [],
+ ...baseStyle,
+ });
+ return;
+ }
+ brushAreaSeries.applyOptions({
+ brushRanges: [
+ {
+ range: {
+ from: activeRange.from,
+ to: activeRange.to,
+ },
+ style: activeRange.positive ? greenStyle : redStyle,
+ },
+ ],
+ ...fadeStyle,
+ });
+});
+
+window.addEventListener('resize', () => {
+ requestAnimationFrame(() => {
+ chart.timeScale().fitContent();
+ });
+});
diff --git a/plugin-examples/src/combined-examples/delta-brushable/index.html b/plugin-examples/src/combined-examples/delta-brushable/index.html
new file mode 100644
index 0000000000..2766601e9f
--- /dev/null
+++ b/plugin-examples/src/combined-examples/delta-brushable/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ Lightweight Charts - Delta Tooltip Plugin & Brushable Area Series Example
+
+
+
+
+
+
+
+
Delta Tooltip and Brushable Area Series
+
HINT: Use multi-touch, or click and drag
+
+ An example showcasing two plugins being used together. The delta tooltip
+ is used to show the difference between two points (defined by
+ multi-touch or click and drag), while the brushable area series plugin
+ will adjust the colours of the area plot to accentuate the effect.
+
+
+
+
+
diff --git a/plugin-examples/src/examples-base.css b/plugin-examples/src/examples-base.css
new file mode 100644
index 0000000000..0bd0d6b76d
--- /dev/null
+++ b/plugin-examples/src/examples-base.css
@@ -0,0 +1,69 @@
+:root {
+ font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu,
+ sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+body {
+ background-color: rgba(248, 249, 253, 1);
+ color: rgba(19, 23, 34, 1);
+}
+#chart {
+ height: 300px;
+ background-color: rgba(240, 243, 250, 1);
+ border-radius: 5px;
+ overflow: hidden;
+}
+.column,
+#chart,
+#description {
+ margin-inline: auto;
+ max-width: 600px;
+}
+
+.hint-message {
+ color: rgba(120, 123, 134, 1);
+}
+
+button {
+ all: initial;
+ font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu,
+ sans-serif;
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 510;
+ line-height: 24px; /* 150% */
+ letter-spacing: -0.32px;
+ padding: 8px 24px;
+ color: #fff;
+ background-color: rgba(41, 98, 255, 1);
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+button:hover {
+ background-color: rgba(30, 83, 229, 1);
+}
+
+button:active {
+ background-color: rgba(24, 72, 204, 1);
+}
+
+button.grey {
+ color: rgba(19, 23, 34, 1);
+ background-color: rgba(240, 243, 250, 1);
+}
+
+button.grey:hover {
+ background-color: rgba(224, 227, 235, 1);
+}
+
+button.grey:active {
+ background-color: rgba(209, 212, 220, 1);
+}
diff --git a/plugin-examples/src/helpers/assertions.ts b/plugin-examples/src/helpers/assertions.ts
new file mode 100644
index 0000000000..e68742f3cf
--- /dev/null
+++ b/plugin-examples/src/helpers/assertions.ts
@@ -0,0 +1,33 @@
+/**
+ * Ensures that value is defined.
+ * Throws if the value is undefined, returns the original value otherwise.
+ *
+ * @param value - The value, or undefined.
+ * @returns The passed value, if it is not undefined
+ */
+export function ensureDefined(value: undefined): never;
+export function ensureDefined(value: T | undefined): T;
+export function ensureDefined(value: T | undefined): T {
+ if (value === undefined) {
+ throw new Error('Value is undefined');
+ }
+
+ return value;
+}
+
+/**
+ * Ensures that value is not null.
+ * Throws if the value is null, returns the original value otherwise.
+ *
+ * @param value - The value, or null.
+ * @returns The passed value, if it is not null
+ */
+export function ensureNotNull(value: null): never;
+export function ensureNotNull(value: T | null): T;
+export function ensureNotNull(value: T | null): T {
+ if (value === null) {
+ throw new Error('Value is null');
+ }
+
+ return value;
+}
diff --git a/plugin-examples/src/helpers/closest-index.ts b/plugin-examples/src/helpers/closest-index.ts
new file mode 100644
index 0000000000..2bd72d2b1c
--- /dev/null
+++ b/plugin-examples/src/helpers/closest-index.ts
@@ -0,0 +1,44 @@
+export type SearchDirection = 'left' | 'right';
+export class ClosestTimeIndexFinder {
+ private numbers: T[];
+ private cache: Map;
+
+ constructor(sortedNumbers: T[]) {
+ this.numbers = sortedNumbers;
+ this.cache = new Map();
+ }
+
+ public findClosestIndex(target: number, direction: SearchDirection): number {
+ const cacheKey = `${target}:${direction}`;
+ if (this.cache.has(cacheKey)) {
+ return this.cache.get(cacheKey) as number;
+ }
+
+ const closestIndex = this._performSearch(target, direction);
+
+ this.cache.set(cacheKey, closestIndex);
+ return closestIndex;
+ }
+
+ private _performSearch(target: number, direction: SearchDirection): number {
+ let low = 0;
+ let high = this.numbers.length - 1;
+
+ if (target <= this.numbers[0].time) return 0;
+ if (target >= this.numbers[high].time) return high;
+
+ while (low <= high) {
+ const mid = Math.floor((low + high) / 2);
+ const num = this.numbers[mid].time;
+
+ if (num === target) {
+ return mid;
+ } else if (num > target) {
+ high = mid - 1;
+ } else {
+ low = mid + 1;
+ }
+ }
+ return direction === 'left' ? low : high;
+ }
+}
diff --git a/plugin-examples/src/helpers/delegate.ts b/plugin-examples/src/helpers/delegate.ts
new file mode 100644
index 0000000000..d169449d3e
--- /dev/null
+++ b/plugin-examples/src/helpers/delegate.ts
@@ -0,0 +1,67 @@
+export type Callback = (param1: T1) => void;
+
+export interface ISubscription {
+ subscribe(
+ callback: Callback,
+ linkedObject?: unknown,
+ singleshot?: boolean
+ ): void;
+ unsubscribe(callback: Callback): void;
+ unsubscribeAll(linkedObject: unknown): void;
+}
+
+interface Listener {
+ callback: Callback;
+ linkedObject?: unknown;
+ singleshot: boolean;
+}
+
+export class Delegate implements ISubscription {
+ private _listeners: Listener[] = [];
+
+ public subscribe(
+ callback: Callback,
+ linkedObject?: unknown,
+ singleshot?: boolean
+ ): void {
+ const listener: Listener = {
+ callback,
+ linkedObject,
+ singleshot: singleshot === true,
+ };
+ this._listeners.push(listener);
+ }
+
+ public unsubscribe(callback: Callback): void {
+ const index = this._listeners.findIndex(
+ (listener: Listener) => callback === listener.callback
+ );
+ if (index > -1) {
+ this._listeners.splice(index, 1);
+ }
+ }
+
+ public unsubscribeAll(linkedObject: unknown): void {
+ this._listeners = this._listeners.filter(
+ (listener: Listener) => listener.linkedObject !== linkedObject
+ );
+ }
+
+ public fire(param1: T1): void {
+ const listenersSnapshot = [...this._listeners];
+ this._listeners = this._listeners.filter(
+ (listener: Listener) => !listener.singleshot
+ );
+ listenersSnapshot.forEach((listener: Listener) =>
+ listener.callback(param1)
+ );
+ }
+
+ public hasListeners(): boolean {
+ return this._listeners.length > 0;
+ }
+
+ public destroy(): void {
+ this._listeners = [];
+ }
+}
diff --git a/plugin-examples/src/helpers/dimensions/candles.ts b/plugin-examples/src/helpers/dimensions/candles.ts
new file mode 100644
index 0000000000..4a22b0797a
--- /dev/null
+++ b/plugin-examples/src/helpers/dimensions/candles.ts
@@ -0,0 +1,48 @@
+function optimalCandlestickWidth(
+ barSpacing: number,
+ pixelRatio: number
+): number {
+ const barSpacingSpecialCaseFrom = 2.5;
+ const barSpacingSpecialCaseTo = 4;
+ const barSpacingSpecialCaseCoeff = 3;
+ if (
+ barSpacing >= barSpacingSpecialCaseFrom &&
+ barSpacing <= barSpacingSpecialCaseTo
+ ) {
+ return Math.floor(barSpacingSpecialCaseCoeff * pixelRatio);
+ }
+ // coeff should be 1 on small barspacing and go to 0.8 while groing bar spacing
+ const barSpacingReducingCoeff = 0.2;
+ const coeff =
+ 1 -
+ (barSpacingReducingCoeff *
+ Math.atan(
+ Math.max(barSpacingSpecialCaseTo, barSpacing) - barSpacingSpecialCaseTo
+ )) /
+ (Math.PI * 0.5);
+ const res = Math.floor(barSpacing * coeff * pixelRatio);
+ const scaledBarSpacing = Math.floor(barSpacing * pixelRatio);
+ const optimal = Math.min(res, scaledBarSpacing);
+ return Math.max(Math.floor(pixelRatio), optimal);
+}
+
+/**
+ * Calculates the candlestick width that the library would use for the current
+ * bar spacing.
+ * @param barSpacing bar spacing in media coordinates
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns The width (in bitmap coordinates) that the chart would use to draw a candle body
+ */
+export function candlestickWidth(
+ barSpacing: number,
+ horizontalPixelRatio: number
+): number {
+ let width = optimalCandlestickWidth(barSpacing, horizontalPixelRatio);
+ if (width >= 2) {
+ const wickWidth = Math.floor(horizontalPixelRatio);
+ if (wickWidth % 2 !== width % 2) {
+ width--;
+ }
+ }
+ return width;
+}
diff --git a/plugin-examples/src/helpers/dimensions/columns.ts b/plugin-examples/src/helpers/dimensions/columns.ts
new file mode 100644
index 0000000000..de3d12859c
--- /dev/null
+++ b/plugin-examples/src/helpers/dimensions/columns.ts
@@ -0,0 +1,237 @@
+const alignToMinimalWidthLimit = 4;
+const showSpacingMinimalBarWidth = 1;
+
+/**
+ * Spacing gap between columns.
+ * @param barSpacingMedia - spacing between bars (media coordinate)
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns Spacing gap between columns (in Bitmap coordinates)
+ */
+function columnSpacing(barSpacingMedia: number, horizontalPixelRatio: number) {
+ return Math.ceil(barSpacingMedia * horizontalPixelRatio) <=
+ showSpacingMinimalBarWidth
+ ? 0
+ : Math.max(1, Math.floor(horizontalPixelRatio));
+}
+
+/**
+ * Desired width for columns. This may not be the final width because
+ * it may be adjusted later to ensure all columns on screen have a
+ * consistent width and gap.
+ * @param barSpacingMedia - spacing between bars (media coordinate)
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @param spacing - Spacing gap between columns (in Bitmap coordinates). (optional, provide if you have already calculated it)
+ * @returns Desired width for column bars (in Bitmap coordinates)
+ */
+function desiredColumnWidth(
+ barSpacingMedia: number,
+ horizontalPixelRatio: number,
+ spacing?: number
+) {
+ return (
+ Math.round(barSpacingMedia * horizontalPixelRatio) -
+ (spacing ?? columnSpacing(barSpacingMedia, horizontalPixelRatio))
+ );
+}
+
+interface ColumnCommon {
+ /** Spacing gap between columns */
+ spacing: number;
+ /** Shift columns left by one pixel */
+ shiftLeft: boolean;
+ /** Half width of a column */
+ columnHalfWidthBitmap: number;
+ /** horizontal pixel ratio */
+ horizontalPixelRatio: number;
+}
+
+/**
+ * Calculated values which are common to all the columns on the screen, and
+ * are required to calculate the individual positions.
+ * @param barSpacingMedia - spacing between bars (media coordinate)
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns calculated values for subsequent column calculations
+ */
+function columnCommon(
+ barSpacingMedia: number,
+ horizontalPixelRatio: number
+): ColumnCommon {
+ const spacing = columnSpacing(barSpacingMedia, horizontalPixelRatio);
+ const columnWidthBitmap = desiredColumnWidth(
+ barSpacingMedia,
+ horizontalPixelRatio,
+ spacing
+ );
+ const shiftLeft = columnWidthBitmap % 2 === 0;
+ const columnHalfWidthBitmap = (columnWidthBitmap - (shiftLeft ? 0 : 1)) / 2;
+ return {
+ spacing,
+ shiftLeft,
+ columnHalfWidthBitmap,
+ horizontalPixelRatio,
+ };
+}
+
+export interface ColumnPosition {
+ left: number;
+ right: number;
+ shiftLeft: boolean;
+}
+
+/**
+ * Calculate the position for a column. These values can be later adjusted
+ * by a second pass which corrects widths, and shifts columns.
+ * @param xMedia - column x position (center) in media coordinates
+ * @param columnData - precalculated common values (returned by `columnCommon`)
+ * @param previousPosition - result from this function for the previous bar.
+ * @returns initial column position
+ */
+function calculateColumnPosition(
+ xMedia: number,
+ columnData: ColumnCommon,
+ previousPosition: ColumnPosition | undefined
+): ColumnPosition {
+ const xBitmapUnRounded = xMedia * columnData.horizontalPixelRatio;
+ const xBitmap = Math.round(xBitmapUnRounded);
+ const xPositions: ColumnPosition = {
+ left: xBitmap - columnData.columnHalfWidthBitmap,
+ right:
+ xBitmap +
+ columnData.columnHalfWidthBitmap -
+ (columnData.shiftLeft ? 1 : 0),
+ shiftLeft: xBitmap > xBitmapUnRounded,
+ };
+ const expectedAlignmentShift = columnData.spacing + 1;
+ if (previousPosition) {
+ if (xPositions.left - previousPosition.right !== expectedAlignmentShift) {
+ // need to adjust alignment
+ if (previousPosition.shiftLeft) {
+ previousPosition.right = xPositions.left - expectedAlignmentShift;
+ } else {
+ xPositions.left = previousPosition.right + expectedAlignmentShift;
+ }
+ }
+ }
+ return xPositions;
+}
+
+function fixPositionsAndReturnSmallestWidth(
+ positions: ColumnPosition[],
+ initialMinWidth: number
+): number {
+ return positions.reduce((smallest: number, position: ColumnPosition) => {
+ if (position.right < position.left) {
+ position.right = position.left;
+ }
+ const width = position.right - position.left + 1;
+ return Math.min(smallest, width);
+ }, initialMinWidth);
+}
+
+function fixAlignmentForNarrowColumns(
+ positions: ColumnPosition[],
+ minColumnWidth: number
+) {
+ return positions.map((position: ColumnPosition) => {
+ const width = position.right - position.left + 1;
+ if (width <= minColumnWidth) return position;
+ if (position.shiftLeft) {
+ position.right -= 1;
+ } else {
+ position.left += 1;
+ }
+ return position;
+ });
+}
+
+/**
+ * Calculates the column positions and widths for the x positions.
+ * This function creates a new array. You may get faster performance using the
+ * `calculateColumnPositionsInPlace` function instead
+ * @param xMediaPositions - x positions for the bars in media coordinates
+ * @param barSpacingMedia - spacing between bars in media coordinates
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns Positions for the columns
+ */
+export function calculateColumnPositions(
+ xMediaPositions: number[],
+ barSpacingMedia: number,
+ horizontalPixelRatio: number
+): ColumnPosition[] {
+ const common = columnCommon(barSpacingMedia, horizontalPixelRatio);
+ const positions = new Array(xMediaPositions.length);
+ let previous: ColumnPosition | undefined = undefined;
+ for (let i = 0; i < xMediaPositions.length; i++) {
+ positions[i] = calculateColumnPosition(
+ xMediaPositions[i],
+ common,
+ previous
+ );
+ previous = positions[i];
+ }
+ const initialMinWidth = Math.ceil(barSpacingMedia * horizontalPixelRatio);
+ const minColumnWidth = fixPositionsAndReturnSmallestWidth(
+ positions,
+ initialMinWidth
+ );
+ if (common.spacing > 0 && minColumnWidth < alignToMinimalWidthLimit) {
+ return fixAlignmentForNarrowColumns(positions, minColumnWidth);
+ }
+ return positions;
+}
+
+export interface ColumnPositionItem {
+ x: number;
+ column?: ColumnPosition;
+}
+
+/**
+ * Calculates the column positions and widths for bars using the existing the
+ * array of items.
+ * @param items - bar items which include an `x` property, and will be mutated to contain a column property
+ * @param barSpacingMedia - bar spacing in media coordinates
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @param startIndex - start index for visible bars within the items array
+ * @param endIndex - end index for visible bars within the items array
+ */
+export function calculateColumnPositionsInPlace(
+ items: ColumnPositionItem[],
+ barSpacingMedia: number,
+ horizontalPixelRatio: number,
+ startIndex: number,
+ endIndex: number
+): void {
+ const common = columnCommon(barSpacingMedia, horizontalPixelRatio);
+ let previous: ColumnPosition | undefined = undefined;
+ for (let i = startIndex; i < Math.min(endIndex, items.length); i++) {
+ items[i].column = calculateColumnPosition(items[i].x, common, previous);
+ previous = items[i].column;
+ }
+ const minColumnWidth = (items as ColumnPositionItem[]).reduce(
+ (smallest: number, item: ColumnPositionItem, index: number) => {
+ if (!item.column || index < startIndex || index > endIndex)
+ return smallest;
+ if (item.column.right < item.column.left) {
+ item.column.right = item.column.left;
+ }
+ const width = item.column.right - item.column.left + 1;
+ return Math.min(smallest, width);
+ },
+ Math.ceil(barSpacingMedia * horizontalPixelRatio)
+ );
+ if (common.spacing > 0 && minColumnWidth < alignToMinimalWidthLimit) {
+ (items as ColumnPositionItem[]).forEach(
+ (item: ColumnPositionItem, index: number) => {
+ if (!item.column || index < startIndex || index > endIndex) return;
+ const width = item.column.right - item.column.left + 1;
+ if (width <= minColumnWidth) return item;
+ if (item.column.shiftLeft) {
+ item.column.right -= 1;
+ } else {
+ item.column.left += 1;
+ }
+ return item.column;
+ }
+ );
+ }
+}
diff --git a/plugin-examples/src/helpers/dimensions/common.ts b/plugin-examples/src/helpers/dimensions/common.ts
new file mode 100644
index 0000000000..394c6bb9ad
--- /dev/null
+++ b/plugin-examples/src/helpers/dimensions/common.ts
@@ -0,0 +1,6 @@
+export interface BitmapPositionLength {
+ /** coordinate for use with a bitmap rendering scope */
+ position: number;
+ /** length for use with a bitmap rendering scope */
+ length: number;
+}
diff --git a/plugin-examples/src/helpers/dimensions/crosshair-width.ts b/plugin-examples/src/helpers/dimensions/crosshair-width.ts
new file mode 100644
index 0000000000..9d14991aaf
--- /dev/null
+++ b/plugin-examples/src/helpers/dimensions/crosshair-width.ts
@@ -0,0 +1,23 @@
+/**
+ * Default grid / crosshair line width in Bitmap sizing
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns default grid / crosshair line width in Bitmap sizing
+ */
+export function gridAndCrosshairBitmapWidth(
+ horizontalPixelRatio: number
+): number {
+ return Math.max(1, Math.floor(horizontalPixelRatio));
+}
+
+/**
+ * Default grid / crosshair line width in Media sizing
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns default grid / crosshair line width in Media sizing
+ */
+export function gridAndCrosshairMediaWidth(
+ horizontalPixelRatio: number
+): number {
+ return (
+ gridAndCrosshairBitmapWidth(horizontalPixelRatio) / horizontalPixelRatio
+ );
+}
diff --git a/plugin-examples/src/helpers/dimensions/full-width.ts b/plugin-examples/src/helpers/dimensions/full-width.ts
new file mode 100644
index 0000000000..6f4d40dd41
--- /dev/null
+++ b/plugin-examples/src/helpers/dimensions/full-width.ts
@@ -0,0 +1,29 @@
+import { BitmapPositionLength } from './common';
+
+/**
+ * Calculates the position and width which will completely full the space for the bar.
+ * Useful if you want to draw something that will not have any gaps between surrounding bars.
+ * @param xMedia - x coordinate of the bar defined in media sizing
+ * @param halfBarSpacingMedia - half the width of the current barSpacing (un-rounded)
+ * @param horizontalPixelRatio - horizontal pixel ratio
+ * @returns position and width which will completely full the space for the bar
+ */
+export function fullBarWidth(
+ xMedia: number,
+ halfBarSpacingMedia: number,
+ horizontalPixelRatio: number
+): BitmapPositionLength {
+ const fullWidthLeftMedia = xMedia - halfBarSpacingMedia;
+ const fullWidthRightMedia = xMedia + halfBarSpacingMedia;
+ const fullWidthLeftBitmap = Math.round(
+ fullWidthLeftMedia * horizontalPixelRatio
+ );
+ const fullWidthRightBitmap = Math.round(
+ fullWidthRightMedia * horizontalPixelRatio
+ );
+ const fullWidthBitmap = fullWidthRightBitmap - fullWidthLeftBitmap;
+ return {
+ position: fullWidthLeftBitmap,
+ length: fullWidthBitmap,
+ };
+}
diff --git a/plugin-examples/src/helpers/dimensions/positions.ts b/plugin-examples/src/helpers/dimensions/positions.ts
new file mode 100644
index 0000000000..a021b072b0
--- /dev/null
+++ b/plugin-examples/src/helpers/dimensions/positions.ts
@@ -0,0 +1,48 @@
+import { BitmapPositionLength } from './common';
+
+function centreOffset(lineBitmapWidth: number): number {
+ return Math.floor(lineBitmapWidth * 0.5);
+}
+
+/**
+ * Calculates the bitmap position for an item with a desired length (height or width), and centred according to
+ * an position coordinate defined in media sizing.
+ * @param positionMedia - position coordinate for the bar (in media coordinates)
+ * @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions
+ * @param desiredWidthMedia - desired width (in media coordinates)
+ * @returns Position of of the start point and length dimension.
+ */
+export function positionsLine(
+ positionMedia: number,
+ pixelRatio: number,
+ desiredWidthMedia: number = 1,
+ widthIsBitmap?: boolean
+): BitmapPositionLength {
+ const scaledPosition = Math.round(pixelRatio * positionMedia);
+ const lineBitmapWidth = widthIsBitmap
+ ? desiredWidthMedia
+ : Math.round(desiredWidthMedia * pixelRatio);
+ const offset = centreOffset(lineBitmapWidth);
+ const position = scaledPosition - offset;
+ return { position, length: lineBitmapWidth };
+}
+
+/**
+ * Determines the bitmap position and length for a dimension of a shape to be drawn.
+ * @param position1Media - media coordinate for the first point
+ * @param position2Media - media coordinate for the second point
+ * @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal)
+ * @returns Position of of the start point and length dimension.
+ */
+export function positionsBox(
+ position1Media: number,
+ position2Media: number,
+ pixelRatio: number
+): BitmapPositionLength {
+ const scaledPosition1 = Math.round(pixelRatio * position1Media);
+ const scaledPosition2 = Math.round(pixelRatio * position2Media);
+ return {
+ position: Math.min(scaledPosition1, scaledPosition2),
+ length: Math.abs(scaledPosition2 - scaledPosition1) + 1,
+ };
+}
diff --git a/plugin-examples/src/helpers/min-max-in-range.ts b/plugin-examples/src/helpers/min-max-in-range.ts
new file mode 100644
index 0000000000..3400075845
--- /dev/null
+++ b/plugin-examples/src/helpers/min-max-in-range.ts
@@ -0,0 +1,69 @@
+interface UpperLowerData {
+ upper: number;
+ lower: number;
+}
+export class UpperLowerInRange {
+ private _arr: T[];
+ private _chunkSize: number;
+ private _cache: Map;
+
+ constructor(arr: T[], chunkSize = 10) {
+ this._arr = arr;
+ this._chunkSize = chunkSize;
+ this._cache = new Map();
+ }
+
+ public getMinMax(startIndex: number, endIndex: number): UpperLowerData {
+ const cacheKey = `${startIndex}:${endIndex}`;
+ if (cacheKey in this._cache) {
+ return this._cache.get(cacheKey) as T;
+ }
+
+ const result: UpperLowerData = {
+ lower: Infinity,
+ upper: -Infinity,
+ };
+ // Check if we have precalculated min and max values for any of the chunks.
+ const startChunkIndex = Math.floor(startIndex / this._chunkSize);
+ const endChunkIndex = Math.floor(endIndex / this._chunkSize);
+ for (
+ let chunkIndex = startChunkIndex;
+ chunkIndex <= endChunkIndex;
+ chunkIndex++
+ ) {
+ const chunkStart = chunkIndex * this._chunkSize;
+ const chunkEnd = Math.min(
+ (chunkIndex + 1) * this._chunkSize - 1,
+ this._arr.length - 1
+ );
+ const chunkCacheKey = `${chunkStart}:${chunkEnd}`;
+
+ if (chunkCacheKey in this._cache.keys()) {
+ const item = this._cache.get(cacheKey) as T;
+ this._check(item, result);
+ } else {
+ const chunkResult: UpperLowerData = {
+ lower: Infinity,
+ upper: -Infinity,
+ };
+ for (let i = chunkStart; i <= chunkEnd; i++) {
+ this._check(this._arr[i], chunkResult);
+ }
+ this._cache.set(chunkCacheKey, chunkResult);
+ this._check(chunkResult, result);
+ }
+ }
+
+ this._cache.set(cacheKey, result);
+ return result;
+ }
+
+ private _check(item: UpperLowerData, state: UpperLowerData) {
+ if (item.lower < state.lower) {
+ state.lower = item.lower;
+ }
+ if (item.upper > state.upper) {
+ state.upper = item.upper;
+ }
+ }
+}
diff --git a/plugin-examples/src/helpers/simple-clone.ts b/plugin-examples/src/helpers/simple-clone.ts
new file mode 100644
index 0000000000..8f9a77e5ee
--- /dev/null
+++ b/plugin-examples/src/helpers/simple-clone.ts
@@ -0,0 +1,7 @@
+type Mutable = {
+ -readonly [K in keyof T]: T[K]
+}
+
+export function cloneReadonly(obj: T): Mutable {
+ return JSON.parse(JSON.stringify(obj));
+}
diff --git a/plugin-examples/src/helpers/time.ts b/plugin-examples/src/helpers/time.ts
new file mode 100644
index 0000000000..ed654758a1
--- /dev/null
+++ b/plugin-examples/src/helpers/time.ts
@@ -0,0 +1,34 @@
+import { Time, isUTCTimestamp, isBusinessDay } from 'lightweight-charts';
+
+export function convertTime(t: Time): number {
+ if (isUTCTimestamp(t)) return t * 1000;
+ if (isBusinessDay(t)) return new Date(t.year, t.month, t.day).valueOf();
+ const [year, month, day] = t.split('-').map(parseInt);
+ return new Date(year, month, day).valueOf();
+}
+
+export function displayTime(time: Time): string {
+ if (typeof time == 'string') return time;
+ const date = isBusinessDay(time)
+ ? new Date(time.year, time.month, time.day)
+ : new Date(time * 1000);
+ return date.toLocaleDateString();
+}
+
+export function formattedDateAndTime(timestamp: number | undefined): [string, string] {
+ if (!timestamp) return ['', ''];
+ const dateObj = new Date(timestamp);
+
+ // Format date string
+ const year = dateObj.getFullYear();
+ const month = dateObj.toLocaleString('default', { month: 'short' });
+ const date = dateObj.getDate().toString().padStart(2, '0');
+ const formattedDate = `${date} ${month} ${year}`;
+
+ // Format time string
+ const hours = dateObj.getHours().toString().padStart(2, '0');
+ const minutes = dateObj.getMinutes().toString().padStart(2, '0');
+ const formattedTime = `${hours}:${minutes}`;
+
+ return [formattedDate, formattedTime];
+}
diff --git a/plugin-examples/src/index.html b/plugin-examples/src/index.html
new file mode 100644
index 0000000000..fe66c61b1e
--- /dev/null
+++ b/plugin-examples/src/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Lightweight Charts - Plugin Examples
+
+
+
+
+
+ Lightweight Charts™
+ Plugin Examples
+
+
+
+
+ Combined Examples
+
+ Custom Series
+
+ Primitives
+
+
+
diff --git a/plugin-examples/src/plugins/anchored-text/anchored-text.ts b/plugin-examples/src/plugins/anchored-text/anchored-text.ts
new file mode 100644
index 0000000000..65b22c3591
--- /dev/null
+++ b/plugin-examples/src/plugins/anchored-text/anchored-text.ts
@@ -0,0 +1,107 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesAttachedParameter,
+ Time,
+} from 'lightweight-charts';
+
+interface AnchoredTextOptions {
+ vertAlign: 'top' | 'middle' | 'bottom';
+ horzAlign: 'left' | 'middle' | 'right';
+ text: string;
+ lineHeight: number;
+ font: string;
+ color: string;
+}
+
+class AnchoredTextRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: AnchoredTextOptions;
+
+ constructor(options: AnchoredTextOptions) {
+ this._data = options;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useMediaCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ ctx.font = this._data.font;
+ const textWidth = ctx.measureText(this._data.text).width;
+ const horzMargin = 20;
+ let x = horzMargin;
+ const width = scope.mediaSize.width;
+ const height = scope.mediaSize.height;
+ switch (this._data.horzAlign) {
+ case 'right': {
+ x = width - horzMargin - textWidth;
+ break;
+ }
+ case 'middle': {
+ x = width / 2 - textWidth / 2;
+ break;
+ }
+ }
+ const vertMargin = 10;
+ const lineHeight = this._data.lineHeight;
+ let y = vertMargin + lineHeight;
+ switch (this._data.vertAlign) {
+ case 'middle': {
+ y = height / 2 + lineHeight / 2;
+ break;
+ }
+ case 'bottom': {
+ y = height - vertMargin;
+ break;
+ }
+ }
+ ctx.fillStyle = this._data.color;
+ ctx.fillText(this._data.text, x, y);
+ ctx.restore();
+ });
+ }
+}
+
+class AnchoredTextPaneView implements ISeriesPrimitivePaneView {
+ private _source: AnchoredText;
+ constructor(source: AnchoredText) {
+ this._source = source;
+ }
+ update() {}
+ renderer() {
+ return new AnchoredTextRenderer(this._source._data);
+ }
+}
+
+export class AnchoredText implements ISeriesPrimitive {
+ _paneViews: AnchoredTextPaneView[];
+ _data: AnchoredTextOptions;
+
+ constructor(options: AnchoredTextOptions) {
+ this._data = options;
+ this._paneViews = [new AnchoredTextPaneView(this)];
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ requestUpdate?: () => void;
+ attached({ requestUpdate }: SeriesAttachedParameter) {
+ this.requestUpdate = requestUpdate;
+ }
+
+ detached() {
+ this.requestUpdate = undefined;
+ }
+
+ applyOptions(options: Partial) {
+ this._data = { ...this._data, ...options };
+ if (this.requestUpdate) this.requestUpdate();
+ }
+}
diff --git a/plugin-examples/src/plugins/anchored-text/example/example.ts b/plugin-examples/src/plugins/anchored-text/example/example.ts
new file mode 100644
index 0000000000..0ac3a9686b
--- /dev/null
+++ b/plugin-examples/src/plugins/anchored-text/example/example.ts
@@ -0,0 +1,28 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { AnchoredText } from '../anchored-text';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true
+}));
+
+const lineSeries = chart.addLineSeries();
+
+lineSeries.setData(generateLineData());
+
+const anchoredText = new AnchoredText({
+ vertAlign: 'middle',
+ horzAlign: 'middle',
+ text: 'Anchored Text',
+ lineHeight: 54,
+ font: 'italic bold 54px Arial',
+ color: 'red',
+});
+lineSeries.attachPrimitive(anchoredText);
+
+// testing the requestUpdate method
+setTimeout(() => {
+ anchoredText.applyOptions({
+ text: 'New Text',
+ });
+}, 2000);
diff --git a/plugin-examples/src/plugins/anchored-text/example/index.html b/plugin-examples/src/plugins/anchored-text/example/index.html
new file mode 100644
index 0000000000..71f5a155b9
--- /dev/null
+++ b/plugin-examples/src/plugins/anchored-text/example/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Lightweight Charts - Anchored Text Plugin Example
+
+
+
+
+
+
Anchored Text
+
+ Draws text anchored to the center of the chart pane. The text will change after 2 seconds.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/anchored-text/package.json b/plugin-examples/src/plugins/anchored-text/package.json
new file mode 100644
index 0000000000..578e852299
--- /dev/null
+++ b/plugin-examples/src/plugins/anchored-text/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "main": "anchored-text",
+ "types": "anchored-text.d.ts"
+}
diff --git a/plugin-examples/src/plugins/background-shade-series/background-shade-series.ts b/plugin-examples/src/plugins/background-shade-series/background-shade-series.ts
new file mode 100644
index 0000000000..3225a017dd
--- /dev/null
+++ b/plugin-examples/src/plugins/background-shade-series/background-shade-series.ts
@@ -0,0 +1,45 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ LineData,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { BackgroundShadeSeriesOptions, defaultOptions } from './options';
+import { BackgroundShadeSeriesRenderer } from './renderer';
+
+export class BackgroundShadeSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: BackgroundShadeSeriesRenderer;
+
+ constructor() {
+ this._renderer = new BackgroundShadeSeriesRenderer();
+ }
+
+ priceValueBuilder(_plotRow: TData): CustomSeriesPricePlotValues {
+ // using NaN here prevents this series from affecting the price scale scaling,
+ // and showing a crosshair or price line
+ return [NaN];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).value === undefined;
+ }
+
+ renderer(): BackgroundShadeSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: BackgroundShadeSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/background-shade-series/example/example.ts b/plugin-examples/src/plugins/background-shade-series/example/example.ts
new file mode 100644
index 0000000000..9bc9f2c422
--- /dev/null
+++ b/plugin-examples/src/plugins/background-shade-series/example/example.ts
@@ -0,0 +1,20 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { BackgroundShadeSeries } from '../background-shade-series';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const data = generateLineData();
+
+const customSeriesView = new BackgroundShadeSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ lowValue: 0,
+ highValue: 1000,
+});
+
+myCustomSeries.setData(data);
+
+const lineSeries = chart.addLineSeries({ color: 'black' });
+lineSeries.setData(data);
diff --git a/plugin-examples/src/plugins/background-shade-series/example/index.html b/plugin-examples/src/plugins/background-shade-series/example/index.html
new file mode 100644
index 0000000000..68f055f7d5
--- /dev/null
+++ b/plugin-examples/src/plugins/background-shade-series/example/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Lightweight Charts - Background Shade Series Example
+
+
+
+
+
+
Background Shade Series
+
+ Shades the background of the chart based on the series value. A separate
+ line series is plotted on top to shows the values of the series.
+ Hint:
+ zoom in and out to see the full effect.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/background-shade-series/options.ts b/plugin-examples/src/plugins/background-shade-series/options.ts
new file mode 100644
index 0000000000..5775b363a4
--- /dev/null
+++ b/plugin-examples/src/plugins/background-shade-series/options.ts
@@ -0,0 +1,21 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface BackgroundShadeSeriesOptions extends CustomSeriesOptions {
+ lowColor: string;
+ highColor: string;
+ opacity: number;
+ lowValue: number;
+ highValue: number;
+}
+
+export const defaultOptions: BackgroundShadeSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ lowColor: 'rgb(50, 50, 255)',
+ highColor: 'rgb(255, 50, 50)',
+ lowValue: 0,
+ highValue: 100,
+ opacity: 0.8,
+} as const;
diff --git a/plugin-examples/src/plugins/background-shade-series/package.json b/plugin-examples/src/plugins/background-shade-series/package.json
new file mode 100644
index 0000000000..f02a71b870
--- /dev/null
+++ b/plugin-examples/src/plugins/background-shade-series/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "main": "background-shade-series",
+ "types": "background-shade-series.d.ts"
+}
diff --git a/plugin-examples/src/plugins/background-shade-series/renderer.ts b/plugin-examples/src/plugins/background-shade-series/renderer.ts
new file mode 100644
index 0000000000..f8da7e61cc
--- /dev/null
+++ b/plugin-examples/src/plugins/background-shade-series/renderer.ts
@@ -0,0 +1,111 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ LineData,
+ PaneRendererCustomData,
+ Time,
+} from 'lightweight-charts';
+import { BackgroundShadeSeriesOptions } from './options';
+import { fullBarWidth } from '../../helpers/dimensions/full-width';
+
+type RGBColor = [number, number, number];
+
+function parseRGB(rgbString: string): RGBColor {
+ const match = rgbString.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
+ if (!match) {
+ throw new Error('Invalid RGB string');
+ }
+ return [
+ parseInt(match[1], 10),
+ parseInt(match[2], 10),
+ parseInt(match[3], 10),
+ ];
+}
+
+class SimpleColorInterpolator {
+ color1: RGBColor;
+ color2: RGBColor;
+ constructor(color1: string, color2: string) {
+ this.color1 = parseRGB(color1);
+ this.color2 = parseRGB(color2);
+ }
+
+ createInterpolator(low: number, high: number) {
+ const range = high - low;
+ const colorDiff = [
+ this.color2[0] - this.color1[0],
+ this.color2[1] - this.color1[1],
+ this.color2[2] - this.color1[2],
+ ];
+
+ return (value: number) => {
+ const ratio = (value - low) / range;
+ const mixedColor = [
+ Math.round(this.color1[0] + colorDiff[0] * ratio),
+ Math.round(this.color1[1] + colorDiff[1] * ratio),
+ Math.round(this.color1[2] + colorDiff[2] * ratio),
+ ];
+ return `rgb(${mixedColor.join(',')})`;
+ };
+ }
+}
+
+export class BackgroundShadeSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: BackgroundShadeSeriesOptions | null = null;
+
+ draw(target: CanvasRenderingTarget2D): void {
+ target.useBitmapCoordinateSpace(scope => this._drawImpl(scope));
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: BackgroundShadeSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(renderingScope: BitmapCoordinatesRenderingScope): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const colorMixer = new SimpleColorInterpolator(
+ options.lowColor,
+ options.highColor
+ ).createInterpolator(options.lowValue, options.highValue);
+ const bars = this._data.bars.map(bar => {
+ return {
+ color: colorMixer(bar.originalData.value),
+ x: bar.x,
+ };
+ });
+
+ renderingScope.context.save();
+ const halfWidth = this._data.barSpacing / 2;
+ for (
+ let i = this._data.visibleRange.from;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ const fullWidth = fullBarWidth(bar.x, halfWidth, renderingScope.horizontalPixelRatio);
+ const yTop = 0;
+ const height = renderingScope.bitmapSize.height;
+ renderingScope.context.fillStyle = bar.color || 'rgba(0, 0, 0, 0)';
+ renderingScope.context.fillRect(fullWidth.position, yTop, fullWidth.length, height);
+ }
+ renderingScope.context.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/bands-indicator/band-indicator.ts b/plugin-examples/src/plugins/bands-indicator/band-indicator.ts
new file mode 100644
index 0000000000..f9574451be
--- /dev/null
+++ b/plugin-examples/src/plugins/bands-indicator/band-indicator.ts
@@ -0,0 +1,214 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ AutoscaleInfo,
+ BarData,
+ Coordinate,
+ DataChangedScope,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ LineData,
+ Logical,
+ SeriesAttachedParameter,
+ SeriesDataItemTypeMap,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+import { PluginBase } from '../plugin-base';
+import { cloneReadonly } from '../../helpers/simple-clone';
+import { ClosestTimeIndexFinder } from '../../helpers/closest-index';
+import { UpperLowerInRange } from '../../helpers/min-max-in-range';
+
+interface BandRendererData {
+ x: Coordinate | number;
+ upper: Coordinate | number;
+ lower: Coordinate | number;
+}
+
+class BandsIndicatorPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _viewData: BandViewData;
+ constructor(data: BandViewData) {
+ this._viewData = data;
+ }
+ draw() {}
+ drawBackground(target: CanvasRenderingTarget2D) {
+ const points: BandRendererData[] = this._viewData.data;
+ target.useBitmapCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ ctx.scale(scope.horizontalPixelRatio, scope.verticalPixelRatio);
+
+ ctx.strokeStyle = this._viewData.options.lineColor;
+ ctx.lineWidth = this._viewData.options.lineWidth;
+ ctx.beginPath();
+ const region = new Path2D();
+ const lines = new Path2D();
+ region.moveTo(points[0].x, points[0].upper);
+ lines.moveTo(points[0].x, points[0].upper);
+ for (const point of points) {
+ region.lineTo(point.x, point.upper);
+ lines.lineTo(point.x, point.upper);
+ }
+ const end = points.length - 1;
+ region.lineTo(points[end].x, points[end].lower);
+ lines.moveTo(points[end].x, points[end].lower);
+ for (let i = points.length - 2; i >= 0; i--) {
+ region.lineTo(points[i].x, points[i].lower);
+ lines.lineTo(points[i].x, points[i].lower);
+ }
+ region.lineTo(points[0].x, points[0].upper);
+ region.closePath();
+ ctx.stroke(lines);
+ ctx.fillStyle = this._viewData.options.fillColor;
+ ctx.fill(region);
+
+ ctx.restore();
+ });
+ }
+}
+
+interface BandViewData {
+ data: BandRendererData[];
+ options: Required;
+}
+
+class BandsIndicatorPaneView implements ISeriesPrimitivePaneView {
+ _source: BandsIndicator;
+ _data: BandViewData;
+
+ constructor(source: BandsIndicator) {
+ this._source = source;
+ this._data = {
+ data: [],
+ options: this._source._options,
+ };
+ }
+
+ update() {
+ const series = this._source.series;
+ const timeScale = this._source.chart.timeScale();
+ this._data.data = this._source._bandsData.map(d => {
+ return {
+ x: timeScale.timeToCoordinate(d.time) ?? -100,
+ upper: series.priceToCoordinate(d.upper) ?? -100,
+ lower: series.priceToCoordinate(d.lower) ?? -100,
+ };
+ });
+ }
+
+ renderer() {
+ return new BandsIndicatorPaneRenderer(this._data);
+ }
+}
+
+interface BandData {
+ time: Time;
+ upper: number;
+ lower: number;
+}
+
+function extractPrice(
+ dataPoint: SeriesDataItemTypeMap[SeriesType]
+): number | undefined {
+ if ((dataPoint as BarData).close) return (dataPoint as BarData).close;
+ if ((dataPoint as LineData).value) return (dataPoint as LineData).value;
+ return undefined;
+}
+
+export interface BandsIndicatorOptions {
+ lineColor?: string;
+ fillColor?: string;
+ lineWidth?: number;
+}
+
+const defaults: Required = {
+ lineColor: 'rgb(25, 200, 100)',
+ fillColor: 'rgba(25, 200, 100, 0.25)',
+ lineWidth: 1,
+};
+
+export class BandsIndicator extends PluginBase implements ISeriesPrimitive {
+ _paneViews: BandsIndicatorPaneView[];
+ _seriesData: SeriesDataItemTypeMap[SeriesType][] = [];
+ _bandsData: BandData[] = [];
+ _options: Required;
+ _timeIndices: ClosestTimeIndexFinder<{ time: number }>;
+ _upperLower: UpperLowerInRange;
+
+ constructor(options: BandsIndicatorOptions = {}) {
+ super();
+ this._options = { ...defaults, ...options };
+ this._paneViews = [new BandsIndicatorPaneView(this)];
+ this._timeIndices = new ClosestTimeIndexFinder([]);
+ this._upperLower = new UpperLowerInRange([]);
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ attached(p: SeriesAttachedParameter): void {
+ super.attached(p);
+ this.dataUpdated('full');
+ }
+
+ dataUpdated(scope: DataChangedScope) {
+ // plugin base has fired a data changed event
+ this._seriesData = cloneReadonly(this.series.data());
+ this.calculateBands();
+ if (scope === 'full') {
+ this._timeIndices = new ClosestTimeIndexFinder(
+ this._seriesData as { time: number }[]
+ );
+ }
+ }
+
+ _minValue: number = Number.POSITIVE_INFINITY;
+ _maxValue: number = Number.NEGATIVE_INFINITY;
+ calculateBands() {
+ const bandData: BandData[] = new Array(this._seriesData.length);
+ let index = 0;
+ this._minValue = Number.POSITIVE_INFINITY;
+ this._maxValue = Number.NEGATIVE_INFINITY;
+ this._seriesData.forEach(d => {
+ const price = extractPrice(d);
+ if (price === undefined) return;
+ const upper = price * 1.1;
+ const lower = price * 0.9;
+ if (upper > this._maxValue) this._maxValue = upper;
+ if (lower < this._minValue) this._minValue = lower;
+ bandData[index] = {
+ upper,
+ lower,
+ time: d.time,
+ };
+ index += 1;
+ });
+ bandData.length = index;
+ this._bandsData = bandData;
+ this._upperLower = new UpperLowerInRange(this._bandsData, 4);
+ }
+
+ autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo {
+ const ts = this.chart.timeScale();
+ const startTime = (ts.coordinateToTime(
+ ts.logicalToCoordinate(startTimePoint) ?? 0
+ ) ?? 0) as number;
+ const endTime = (ts.coordinateToTime(
+ ts.logicalToCoordinate(endTimePoint) ?? 5000000000
+ ) ?? 5000000000) as number;
+ const startIndex = this._timeIndices.findClosestIndex(startTime, 'left');
+ const endIndex = this._timeIndices.findClosestIndex(endTime, 'right');
+ const range = this._upperLower.getMinMax(startIndex, endIndex);
+ return {
+ priceRange: {
+ minValue: range.lower,
+ maxValue: range.upper,
+ },
+ };
+ }
+}
diff --git a/plugin-examples/src/plugins/bands-indicator/example/example.ts b/plugin-examples/src/plugins/bands-indicator/example/example.ts
new file mode 100644
index 0000000000..a3fee7f2fc
--- /dev/null
+++ b/plugin-examples/src/plugins/bands-indicator/example/example.ts
@@ -0,0 +1,14 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { BandsIndicator } from '../band-indicator';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+const bandIndicator = new BandsIndicator();
+lineSeries.attachPrimitive(bandIndicator);
diff --git a/plugin-examples/src/plugins/bands-indicator/example/index.html b/plugin-examples/src/plugins/bands-indicator/example/index.html
new file mode 100644
index 0000000000..99af6c4dbc
--- /dev/null
+++ b/plugin-examples/src/plugins/bands-indicator/example/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Lightweight Charts - Bands Indicator Plugin Example
+
+
+
+
+
+
Bands Indicator
+
+ Draws a filled area band surrounding the series line, which is rendered
+ beneath the line. Note: this example is randomly
+ generated, so refresh the page to see different data.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/box-whisker-series/box-whisker-series.ts b/plugin-examples/src/plugins/box-whisker-series/box-whisker-series.ts
new file mode 100644
index 0000000000..2e826ce07d
--- /dev/null
+++ b/plugin-examples/src/plugins/box-whisker-series/box-whisker-series.ts
@@ -0,0 +1,44 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { WhiskerBoxSeriesOptions, defaultOptions } from './options';
+import { WhiskerBoxSeriesRenderer } from './renderer';
+import { WhiskerData } from './sample-data';
+
+export class WhiskerBoxSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: WhiskerBoxSeriesRenderer;
+
+ constructor() {
+ this._renderer = new WhiskerBoxSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ // we don't consider outliers here
+ return [plotRow.quartiles[4], plotRow.quartiles[0], plotRow.quartiles[2]];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).quartiles === undefined;
+ }
+
+ renderer(): WhiskerBoxSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: WhiskerBoxSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/box-whisker-series/example/example.ts b/plugin-examples/src/plugins/box-whisker-series/example/example.ts
new file mode 100644
index 0000000000..8c04124fdf
--- /dev/null
+++ b/plugin-examples/src/plugins/box-whisker-series/example/example.ts
@@ -0,0 +1,20 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { WhiskerBoxSeries } from '../box-whisker-series';
+import { WhiskerData, sampleWhiskerData } from '../sample-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const customSeriesView = new WhiskerBoxSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ baseLineColor: '',
+ priceLineVisible: false,
+ lastValueVisible: false,
+});
+
+const data: (WhiskerData | WhitespaceData)[] = sampleWhiskerData();
+// data[data.length -2] = { time: data[data.length -2].time }; // test whitespace data
+myCustomSeries.setData(data);
+
+chart.timeScale().fitContent();
diff --git a/plugin-examples/src/plugins/box-whisker-series/example/index.html b/plugin-examples/src/plugins/box-whisker-series/example/index.html
new file mode 100644
index 0000000000..63735b0bf2
--- /dev/null
+++ b/plugin-examples/src/plugins/box-whisker-series/example/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ Lightweight Charts - Box Whisker Series Plugin Example
+
+
+
+
+
+
Box Whisker Plot
+
+ A chart style often used in statistics. A box and whisker plot is a
+ visual representation of a data set that shows the distribution and
+ spread of the data. It consists of a box that represents the
+ interquartile range, with a line inside the box indicating the median,
+ and "whiskers" extending from the box to show the minimum and maximum
+ values. Outliers are shown as dots above and below the whiskers.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/box-whisker-series/options.ts b/plugin-examples/src/plugins/box-whisker-series/options.ts
new file mode 100644
index 0000000000..f4298d8e20
--- /dev/null
+++ b/plugin-examples/src/plugins/box-whisker-series/options.ts
@@ -0,0 +1,19 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface WhiskerBoxSeriesOptions extends CustomSeriesOptions {
+ whiskerColor: string;
+ lowerQuartileFill: string;
+ upperQuartileFill: string;
+ outlierColor: string;
+}
+
+export const defaultOptions: WhiskerBoxSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ whiskerColor: 'rgba(106, 27, 154, 1)',
+ lowerQuartileFill: 'rgba(103, 58, 183, 1)',
+ upperQuartileFill: 'rgba(233, 30, 99, 1)',
+ outlierColor: 'rgba(149, 152, 161, 1)',
+} as const;
diff --git a/plugin-examples/src/plugins/box-whisker-series/renderer.ts b/plugin-examples/src/plugins/box-whisker-series/renderer.ts
new file mode 100644
index 0000000000..81444ee4b3
--- /dev/null
+++ b/plugin-examples/src/plugins/box-whisker-series/renderer.ts
@@ -0,0 +1,304 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { WhiskerData } from './sample-data';
+import { WhiskerBoxSeriesOptions } from './options';
+import {
+ positionsBox,
+ positionsLine,
+} from '../../helpers/dimensions/positions';
+import { gridAndCrosshairMediaWidth } from '../../helpers/dimensions/crosshair-width';
+import { candlestickWidth } from '../../helpers/dimensions/candles';
+
+interface DesiredWidths {
+ body: number;
+ medianLine: number;
+ extremeLines: number;
+ outlierRadius: number;
+}
+
+function desiredWidths(barSpacing: number): DesiredWidths {
+ // we want these in media coordinates so set pixelRatio to 1
+ const bodyWidth = candlestickWidth(barSpacing, 1);
+ const medianWidth = Math.floor(barSpacing);
+ const lineWidth = candlestickWidth(barSpacing / 2, 1);
+
+ return {
+ body: bodyWidth,
+ medianLine: Math.max(medianWidth, bodyWidth),
+ extremeLines: lineWidth,
+ outlierRadius: Math.min(bodyWidth, 4),
+ };
+}
+
+type QuartileTuple = [number, number, number, number, number];
+
+interface WhiskerBarItem {
+ quartilesY: QuartileTuple;
+ outliers: number[];
+ x: number;
+}
+
+export class WhiskerBoxSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: WhiskerBoxSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: WhiskerBoxSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const bars: WhiskerBarItem[] = this._data.bars.map(bar => {
+ return {
+ quartilesY: bar.originalData.quartiles.map(price => {
+ return (priceToCoordinate(price) ?? 0) as number;
+ }) as QuartileTuple,
+ outliers: (bar.originalData.outliers || []).map(price => {
+ return (priceToCoordinate(price) ?? 0) as number;
+ }),
+ x: bar.x,
+ };
+ });
+
+ const widths = desiredWidths(this._data.barSpacing);
+ const verticalLineWidth = gridAndCrosshairMediaWidth(
+ renderingScope.horizontalPixelRatio
+ );
+ const horizontalLineWidth = gridAndCrosshairMediaWidth(
+ renderingScope.verticalPixelRatio
+ );
+
+ renderingScope.context.save();
+ for (
+ let i = this._data.visibleRange.from;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ if (widths.outlierRadius > 2) {
+ this._drawOutliers(
+ renderingScope.context,
+ bar,
+ widths.outlierRadius,
+ options,
+ renderingScope.horizontalPixelRatio,
+ renderingScope.verticalPixelRatio
+ );
+ }
+ this._drawWhisker(
+ renderingScope.context,
+ bar,
+ widths.extremeLines,
+ options,
+ renderingScope.horizontalPixelRatio,
+ renderingScope.verticalPixelRatio,
+ verticalLineWidth,
+ horizontalLineWidth
+ );
+ this._drawBox(
+ renderingScope.context,
+ bar,
+ widths.body,
+ options,
+ renderingScope.horizontalPixelRatio,
+ renderingScope.verticalPixelRatio
+ );
+ this._drawMedianLine(
+ renderingScope.context,
+ bar,
+ widths.medianLine,
+ options,
+ renderingScope.horizontalPixelRatio,
+ renderingScope.verticalPixelRatio,
+ horizontalLineWidth
+ );
+ }
+ renderingScope.context.restore();
+ }
+
+ _drawWhisker(
+ ctx: CanvasRenderingContext2D,
+ bar: WhiskerBarItem,
+ extremeLineWidth: number,
+ options: WhiskerBoxSeriesOptions,
+ horizontalPixelRatio: number,
+ verticalPixelRatio: number,
+ wickWidth: number,
+ horizontalWickWidth: number
+ ) {
+ ctx.save();
+ ctx.fillStyle = options.whiskerColor;
+ const verticalLinePosition = positionsLine(
+ bar.x,
+ horizontalPixelRatio,
+ wickWidth
+ );
+ const topWhiskerYPositions = positionsBox(
+ bar.quartilesY[0],
+ bar.quartilesY[1],
+ verticalPixelRatio
+ );
+ ctx.fillRect(
+ verticalLinePosition.position,
+ topWhiskerYPositions.position,
+ verticalLinePosition.length,
+ topWhiskerYPositions.length
+ );
+
+ const bottomWhiskerYPositions = positionsBox(
+ bar.quartilesY[3],
+ bar.quartilesY[4],
+ verticalPixelRatio
+ );
+ ctx.fillRect(
+ verticalLinePosition.position,
+ bottomWhiskerYPositions.position,
+ verticalLinePosition.length,
+ bottomWhiskerYPositions.length
+ );
+
+ const horizontalLinePosition = positionsLine(
+ bar.x,
+ horizontalPixelRatio,
+ extremeLineWidth
+ );
+ const topWhiskerHorizontalYPosition = positionsLine(
+ bar.quartilesY[4],
+ verticalPixelRatio,
+ horizontalWickWidth
+ );
+ ctx.fillRect(
+ horizontalLinePosition.position,
+ topWhiskerHorizontalYPosition.position,
+ horizontalLinePosition.length,
+ topWhiskerHorizontalYPosition.length
+ );
+
+ const bottomWhiskerHorizontalYPosition = positionsLine(
+ bar.quartilesY[0],
+ verticalPixelRatio,
+ horizontalWickWidth
+ );
+ ctx.fillRect(
+ horizontalLinePosition.position,
+ bottomWhiskerHorizontalYPosition.position,
+ horizontalLinePosition.length,
+ bottomWhiskerHorizontalYPosition.length
+ );
+ ctx.restore();
+ }
+
+ _drawBox(
+ ctx: CanvasRenderingContext2D,
+ bar: WhiskerBarItem,
+ bodyWidth: number,
+ options: WhiskerBoxSeriesOptions,
+ horizontalPixelRatio: number,
+ verticalPixelRatio: number
+ ) {
+ ctx.save();
+ const upperQuartileYPositions = positionsBox(
+ bar.quartilesY[2],
+ bar.quartilesY[3],
+ verticalPixelRatio
+ );
+ const lowerQuartileYPositions = positionsBox(
+ bar.quartilesY[1],
+ bar.quartilesY[2],
+ verticalPixelRatio
+ );
+ const xPositions = positionsLine(bar.x, horizontalPixelRatio, bodyWidth);
+ ctx.fillStyle = options.lowerQuartileFill;
+ ctx.fillRect(
+ xPositions.position,
+ lowerQuartileYPositions.position,
+ xPositions.length,
+ lowerQuartileYPositions.length
+ );
+ ctx.fillStyle = options.upperQuartileFill;
+ ctx.fillRect(
+ xPositions.position,
+ upperQuartileYPositions.position,
+ xPositions.length,
+ upperQuartileYPositions.length
+ );
+ ctx.restore();
+ }
+
+ _drawMedianLine(
+ ctx: CanvasRenderingContext2D,
+ bar: WhiskerBarItem,
+ medianLineWidth: number,
+ options: WhiskerBoxSeriesOptions,
+ horizontalPixelRatio: number,
+ verticalPixelRatio: number,
+ horizontalLineWidth: number
+ ) {
+ const xPos = positionsLine(bar.x, horizontalPixelRatio, medianLineWidth);
+ const yPos = positionsLine(
+ bar.quartilesY[2],
+ verticalPixelRatio,
+ horizontalLineWidth
+ );
+ ctx.save();
+ ctx.fillStyle = options.whiskerColor;
+ ctx.fillRect(xPos.position, yPos.position, xPos.length, yPos.length);
+ ctx.restore();
+ }
+
+ _drawOutliers(
+ ctx: CanvasRenderingContext2D,
+ bar: WhiskerBarItem,
+ extremeLineWidth: number,
+ options: WhiskerBoxSeriesOptions,
+ horizontalPixelRatio: number,
+ verticalPixelRatio: number
+ ) {
+ ctx.save();
+ const xPos = positionsLine(bar.x, horizontalPixelRatio, 1, true);
+ ctx.fillStyle = options.outlierColor;
+ ctx.lineWidth = 0;
+ bar.outliers.forEach(outlier => {
+ ctx.beginPath();
+ const yPos = positionsLine(outlier, verticalPixelRatio, 1, true);
+ ctx.arc(xPos.position, yPos.position, extremeLineWidth, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.closePath();
+ });
+ ctx.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/box-whisker-series/sample-data.ts b/plugin-examples/src/plugins/box-whisker-series/sample-data.ts
new file mode 100644
index 0000000000..f669c922e4
--- /dev/null
+++ b/plugin-examples/src/plugins/box-whisker-series/sample-data.ts
@@ -0,0 +1,86 @@
+import { CustomData, UTCTimestamp } from 'lightweight-charts';
+
+/**
+ * Construction of a box plot is based around a dataset’s quartiles,
+ * or the values that divide the dataset into equal fourths.
+ * The first quartile (Q1) is greater than 25% of the data and less
+ * than the other 75%. The second quartile (Q2) sits in the middle,
+ * dividing the data in half. Q2 is also known as the median. The third
+ * quartile (Q3) is larger than 75% of the data, and smaller than the remaining 25%.
+ */
+
+/**
+ * Whisker Series Data
+ */
+export interface WhiskerData extends CustomData {
+ quartiles: [
+ number, // q0 (0%)
+ number, // q1 (25%)
+ number, // q2 (50%)
+ number, // q3 (75%)
+ number // q4 (100%)
+ ];
+ outliers?: number[];
+}
+
+const dayLength = 24 * 60 * 60;
+
+function quartileDataPoint(
+ q0: number,
+ q1: number,
+ q2: number,
+ q3: number,
+ q4: number,
+ basePoint: number
+): WhiskerData['quartiles'] {
+ return [
+ basePoint + q0,
+ basePoint + q1,
+ basePoint + q2,
+ basePoint + q3,
+ basePoint + q4,
+ ];
+}
+
+function whiskerDataSection(
+ startDate: number,
+ basePoint: number
+): WhiskerData[] {
+ return [
+ { quartiles: quartileDataPoint(55, 70, 80, 85, 95, basePoint) },
+ { quartiles: quartileDataPoint(50, 70, 78, 83, 90, basePoint) },
+ {
+ quartiles: quartileDataPoint(58, 68, 75, 85, 90, basePoint),
+ outliers: [45 + basePoint, 50 + basePoint],
+ },
+ { quartiles: quartileDataPoint(55, 65, 70, 80, 88, basePoint) },
+ { quartiles: quartileDataPoint(52, 63, 68, 77, 85, basePoint) },
+ {
+ quartiles: quartileDataPoint(50, 65, 72, 76, 88, basePoint),
+ outliers: [45 + basePoint, 95 + basePoint, 100 + basePoint],
+ },
+ { quartiles: quartileDataPoint(40, 60, 78, 85, 90, basePoint) },
+ { quartiles: quartileDataPoint(45, 72, 80, 88, 95, basePoint) },
+ { quartiles: quartileDataPoint(47, 70, 82, 86, 97, basePoint) },
+ {
+ quartiles: quartileDataPoint(53, 68, 83, 87, 92, basePoint),
+ outliers: [45 + basePoint, 100 + basePoint],
+ },
+ ].map((d, index) => {
+ return {
+ ...d,
+ time: (startDate + index * dayLength) as UTCTimestamp,
+ };
+ });
+}
+
+export function sampleWhiskerData(): (WhiskerData)[] {
+ return [
+ ...whiskerDataSection(1677628800, 0),
+ ...whiskerDataSection(1677628800 + 1 * 10 * dayLength, 20),
+ ...whiskerDataSection(1677628800 + 2 * 10 * dayLength, 40),
+ // whitespace
+ // { time: 1677628800 + 3 * 10 * dayLength as Time},
+ ...whiskerDataSection(1677628800 + (3 * 10 + 1) * dayLength, 30),
+ ];
+}
diff --git a/plugin-examples/src/plugins/brushable-area-series/brushable-area-series.ts b/plugin-examples/src/plugins/brushable-area-series/brushable-area-series.ts
new file mode 100644
index 0000000000..171a14b743
--- /dev/null
+++ b/plugin-examples/src/plugins/brushable-area-series/brushable-area-series.ts
@@ -0,0 +1,43 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { BrushableAreaSeriesOptions, defaultOptions } from './options';
+import { BrushableAreaSeriesRenderer } from './renderer';
+import { BrushableAreaData } from './data';
+
+export class BrushableAreaSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: BrushableAreaSeriesRenderer;
+
+ constructor() {
+ this._renderer = new BrushableAreaSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ return [plotRow.value];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).value === undefined;
+ }
+
+ renderer(): BrushableAreaSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: BrushableAreaSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/brushable-area-series/data.ts b/plugin-examples/src/plugins/brushable-area-series/data.ts
new file mode 100644
index 0000000000..ca98df5e4b
--- /dev/null
+++ b/plugin-examples/src/plugins/brushable-area-series/data.ts
@@ -0,0 +1,8 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * BrushableArea Series Data
+ */
+export interface BrushableAreaData extends CustomData {
+ value: number;
+}
diff --git a/plugin-examples/src/plugins/brushable-area-series/example/example.ts b/plugin-examples/src/plugins/brushable-area-series/example/example.ts
new file mode 100644
index 0000000000..4d37268657
--- /dev/null
+++ b/plugin-examples/src/plugins/brushable-area-series/example/example.ts
@@ -0,0 +1,123 @@
+import { Logical, WhitespaceData, createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { BrushableAreaSeries } from '../brushable-area-series';
+import { BrushableAreaData } from '../data';
+import { BrushableAreaStyle } from '../options';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ grid: {
+ vertLines: {
+ visible: false,
+ },
+ horzLines: {
+ visible: false,
+ },
+ },
+ timeScale: {
+ borderVisible: false,
+ },
+ rightPriceScale: {
+ borderVisible: false,
+ },
+ handleScale: false,
+ handleScroll: false,
+}));
+
+const greenStyle: Partial = {
+ lineColor: 'rgb(4,153,129)',
+ topColor: 'rgba(4,153,129, 0.4)',
+ bottomColor: 'rgba(4,153,129, 0)',
+ lineWidth: 3,
+};
+
+const fadeStyle: Partial = {
+ lineColor: 'rgb(40,98,255, 0.2)',
+ topColor: 'rgba(40,98,255, 0.05)',
+ bottomColor: 'rgba(40,98,255, 0)',
+};
+
+const baseStyle: Partial = {
+ lineColor: 'rgb(40,98,255)',
+ topColor: 'rgba(40,98,255, 0.4)',
+ bottomColor: 'rgba(40,98,255, 0)',
+};
+
+const customSeriesView = new BrushableAreaSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ ...baseStyle,
+ priceLineVisible: false,
+});
+
+const data: (BrushableAreaData | WhitespaceData)[] = generateLineData();
+myCustomSeries.setData(data);
+
+chart.timeScale().fitContent();
+
+interface MouseState {
+ drawing: boolean;
+ startLogical: number | null;
+ activeRange: boolean;
+}
+
+const mouseState: MouseState = {
+ drawing: false,
+ startLogical: null,
+ activeRange: false,
+};
+
+const chartElement = chart.chartElement();
+
+function determinePaneXLogical(mouseX: number): Logical | null {
+ const chartBox = chartElement.getBoundingClientRect();
+ const x = mouseX - chartBox.left - chart.priceScale('left').width();
+ if (x < 0 || x > chart.timeScale().width()) return null;
+ return chart.timeScale().coordinateToLogical(x);
+}
+
+chartElement.addEventListener('mousedown', (event: MouseEvent) => {
+ myCustomSeries.applyOptions({
+ brushRanges: [],
+ ...baseStyle,
+ });
+ mouseState.startLogical = determinePaneXLogical(event.clientX);
+ mouseState.drawing = mouseState.startLogical !== null;
+ mouseState.activeRange = false;
+});
+chartElement.addEventListener('mousemove', (event: MouseEvent) => {
+ if (!mouseState.drawing) return;
+ const endLogical = determinePaneXLogical(event.clientX);
+ if (endLogical !== null && mouseState.startLogical !== null) {
+ const first = Math.min(mouseState.startLogical, endLogical);
+ const end = Math.max(mouseState.startLogical, endLogical);
+ if (first === end) return;
+ mouseState.activeRange = true;
+ myCustomSeries.applyOptions({
+ brushRanges: [
+ {
+ range: {
+ from: first,
+ to: end,
+ },
+ style: greenStyle,
+ },
+ ],
+ ...fadeStyle,
+ });
+ }
+});
+
+chartElement.addEventListener('mouseup', () => {
+ mouseState.drawing = false;
+ if (!mouseState.activeRange) {
+ myCustomSeries.applyOptions({
+ brushRanges: [],
+ ...baseStyle,
+ });
+ }
+});
+
+chartElement.addEventListener('mouseleave', () => {
+ mouseState.drawing = false;
+});
diff --git a/plugin-examples/src/plugins/brushable-area-series/example/index.html b/plugin-examples/src/plugins/brushable-area-series/example/index.html
new file mode 100644
index 0000000000..1bd51bfd12
--- /dev/null
+++ b/plugin-examples/src/plugins/brushable-area-series/example/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Lightweight Charts - Brushable Area Series Plugin Example
+
+
+
+
+
+
Brushable Area Series
+
+ HINT: Click and drag across the chart. Click once to clear.
+
+
+ An area series plot where the styling of individual points can be
+ changed dynamically without needing to set new data.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/brushable-area-series/options.ts b/plugin-examples/src/plugins/brushable-area-series/options.ts
new file mode 100644
index 0000000000..b7f12d5a0c
--- /dev/null
+++ b/plugin-examples/src/plugins/brushable-area-series/options.ts
@@ -0,0 +1,39 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+ Range,
+ Logical,
+} from 'lightweight-charts';
+
+export interface BrushRange {
+ range: Range;
+ style: BrushableAreaStyle;
+}
+
+export interface BrushableAreaStyle {
+ lineColor: string;
+ topColor: string;
+ bottomColor: string;
+ lineWidth: number;
+}
+
+export interface BrushableAreaSeriesOptions
+ extends CustomSeriesOptions,
+ BrushableAreaStyle {
+ basePrice: number;
+ /**
+ * If you need to remove the brush ranges then set to null instead of an
+ * empty array.
+ */
+ brushRanges: readonly BrushRange[];
+}
+
+export const defaultOptions: BrushableAreaSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ lineColor: 'rgb(40,98,255)',
+ topColor: 'rgba(40,98,255, 0.4)',
+ bottomColor: 'rgba(40,98,255, 0)',
+ lineWidth: 2,
+ basePrice: 0,
+ brushRanges: [],
+} as const;
diff --git a/plugin-examples/src/plugins/brushable-area-series/renderer.ts b/plugin-examples/src/plugins/brushable-area-series/renderer.ts
new file mode 100644
index 0000000000..2ad542d5c7
--- /dev/null
+++ b/plugin-examples/src/plugins/brushable-area-series/renderer.ts
@@ -0,0 +1,162 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { BrushableAreaData } from './data';
+import {
+ BrushRange,
+ BrushableAreaSeriesOptions,
+ BrushableAreaStyle,
+} from './options';
+
+interface BrushableAreaBarItem {
+ x: number;
+ y: number;
+}
+
+export class BrushableAreaSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: BrushableAreaSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: BrushableAreaSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+
+ const bars: BrushableAreaBarItem[] = this._data.bars.map(bar => {
+ return {
+ x: Math.round(bar.x * renderingScope.horizontalPixelRatio),
+ y:
+ priceToCoordinate(bar.originalData.value)! *
+ renderingScope.verticalPixelRatio,
+ };
+ });
+
+ const ctx = renderingScope.context;
+ const bottomChartY = renderingScope.bitmapSize.height;
+ ctx.save();
+
+ const getRangeStyle = (index: number): BrushableAreaStyle => {
+ if (typeof options.brushRanges === 'string') return options;
+ const foundRange = options.brushRanges.findIndex(
+ (brushRange: BrushRange) => {
+ return index >= brushRange.range.from && index < brushRange.range.to;
+ }
+ );
+ if (foundRange >= 0) {
+ return options.brushRanges[foundRange].style;
+ }
+ return options;
+ };
+
+ const rangeStyles: BrushableAreaStyle[] = new Array(
+ this._data.visibleRange.to
+ );
+ const firstBar = bars[this._data.visibleRange.from];
+ let minY = firstBar.y;
+ for (
+ let i = this._data.visibleRange.from + 1;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ rangeStyles[i] = getRangeStyle(i);
+ const bar = bars[i];
+ if (bar.y < minY) minY = bar.y;
+ }
+
+ const gradientMap: Map = new Map();
+ function getGradient(bottom: string, top: string): CanvasGradient {
+ const hash = bottom + top;
+ if (gradientMap.has(hash)) return gradientMap.get(hash)!;
+ const gradient = ctx.createLinearGradient(0, bottomChartY, 0, minY);
+ gradient.addColorStop(0, bottom);
+ gradient.addColorStop(1, top);
+ gradientMap.set(hash, gradient);
+ return gradient;
+ }
+
+ // DRAW AREAS
+ let previousPosition: [number, number] = [firstBar.x, firstBar.y];
+ for (
+ let i = this._data.visibleRange.from + 1;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ const rangeStyle = rangeStyles[i];
+ ctx.beginPath();
+ ctx.moveTo(previousPosition[0], previousPosition[1]);
+ ctx.lineTo(bar.x, bar.y);
+ ctx.lineTo(bar.x, bottomChartY);
+ ctx.lineTo(previousPosition[0], bottomChartY);
+ ctx.closePath();
+ ctx.fillStyle = getGradient(rangeStyle.bottomColor, rangeStyle.topColor);
+ ctx.fill();
+ previousPosition = [bar.x, bar.y];
+ }
+
+ // DRAW LINE
+ previousPosition = [firstBar.x, firstBar.y];
+ for (
+ let i = this._data.visibleRange.from + 1;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ const rangeStyle = rangeStyles[i];
+ const rangeStyleChanged =
+ i > 0 ? rangeStyles[i - 1] !== rangeStyle : false;
+ const rangeStyleWillChange =
+ i === this._data.visibleRange.to - 1
+ ? true
+ : rangeStyles[i + 1] !== rangeStyle;
+ if (rangeStyleChanged) {
+ ctx.beginPath();
+ ctx.moveTo(previousPosition[0], previousPosition[1]);
+ }
+ ctx.lineTo(bar.x, bar.y);
+ if (rangeStyleWillChange) {
+ ctx.strokeStyle = rangeStyle.lineColor;
+ ctx.lineWidth =
+ rangeStyle.lineWidth * renderingScope.verticalPixelRatio;
+ ctx.stroke();
+ }
+ previousPosition = [bar.x, bar.y];
+ }
+ ctx.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/delta-tooltip/crosshair-line-pane.ts b/plugin-examples/src/plugins/delta-tooltip/crosshair-line-pane.ts
new file mode 100644
index 0000000000..d0573198fe
--- /dev/null
+++ b/plugin-examples/src/plugins/delta-tooltip/crosshair-line-pane.ts
@@ -0,0 +1,94 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesPrimitivePaneViewZOrder,
+} from 'lightweight-charts';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+class TooltipCrosshairLinePaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: TooltipCrosshairLineData[];
+
+ constructor(data: TooltipCrosshairLineData[]) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ if (!this._data.length) return;
+ target.useBitmapCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ this._data.forEach(data => {
+ const crosshairPos = positionsLine(
+ data.x,
+ scope.horizontalPixelRatio,
+ 1
+ );
+ ctx.fillStyle = data.color;
+ ctx.fillRect(
+ crosshairPos.position,
+ data.topMargin * scope.verticalPixelRatio,
+ crosshairPos.length,
+ scope.bitmapSize.height
+ );
+ if (data.priceY) {
+ ctx.beginPath();
+ ctx.ellipse(
+ data.x * scope.horizontalPixelRatio,
+ data.priceY * scope.verticalPixelRatio,
+ 6 * scope.horizontalPixelRatio,
+ 6 * scope.verticalPixelRatio,
+ 0,
+ 0,
+ Math.PI * 2
+ );
+ ctx.fillStyle = data.markerBorderColor;
+ ctx.fill();
+ ctx.beginPath();
+ ctx.ellipse(
+ data.x * scope.horizontalPixelRatio,
+ data.priceY * scope.verticalPixelRatio,
+ 4 * scope.horizontalPixelRatio,
+ 4 * scope.verticalPixelRatio,
+ 0,
+ 0,
+ Math.PI * 2
+ );
+ ctx.fillStyle = data.markerColor;
+ ctx.fill();
+ }
+ });
+
+ ctx.restore();
+ });
+ }
+}
+
+export class MultiTouchCrosshairPaneView implements ISeriesPrimitivePaneView {
+ _data: TooltipCrosshairLineData[];
+ constructor(data: TooltipCrosshairLineData[]) {
+ this._data = data;
+ }
+
+ update(data: TooltipCrosshairLineData[]): void {
+ this._data = data;
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer | null {
+ return new TooltipCrosshairLinePaneRenderer(this._data);
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'top';
+ }
+}
+
+export interface TooltipCrosshairLineData {
+ x: number;
+ visible: boolean;
+ color: string;
+ topMargin: number;
+ priceY: number;
+ markerColor: string;
+ markerBorderColor: string;
+}
diff --git a/plugin-examples/src/plugins/delta-tooltip/delta-tooltip-pane.ts b/plugin-examples/src/plugins/delta-tooltip/delta-tooltip-pane.ts
new file mode 100644
index 0000000000..c03f9e4476
--- /dev/null
+++ b/plugin-examples/src/plugins/delta-tooltip/delta-tooltip-pane.ts
@@ -0,0 +1,400 @@
+import { CanvasRenderingTarget2D, Size } from 'fancy-canvas';
+import {
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesPrimitivePaneViewZOrder,
+} from 'lightweight-charts';
+
+const styles = {
+ background: '#ffffff',
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif",
+ borderRadius: 5,
+ shadowColor: 'rgba(0, 0, 0, 0.2)',
+ shadowBlur: 4,
+ shadowOffsetX: 0,
+ shadowOffsetY: 2,
+
+ itemBlockPadding: 5,
+ itemInlinePadding: 10,
+
+ tooltipLineFontWeights: [590, 400, 400] as number[],
+ tooltipLineFontSizes: [14, 12, 12] as number[],
+ tooltipLineLineHeights: [18, 16, 16] as number[],
+ tooltipLineColors: ['#131722', '#787B86', '#787B86'],
+
+ deltaFontWeights: [590, 400] as number[],
+ deltaFontSizes: [14, 12] as number[],
+ deltaLineHeights: [18, 16] as number[],
+} as const;
+
+function determineSectionWidth(
+ ctx: CanvasRenderingContext2D,
+ lines: string[],
+ fontSizes: number[],
+ fontWeights: number[]
+) {
+ let maxTextWidth = 0;
+ ctx.save();
+ lines.forEach((line, index) => {
+ ctx.font = `${fontWeights[index]} ${fontSizes[index]}px ${styles.fontFamily}`;
+ const measurement = ctx.measureText(line);
+ if (measurement.width > maxTextWidth) maxTextWidth = measurement.width;
+ });
+ ctx.restore();
+ return maxTextWidth + styles.itemInlinePadding * 2;
+}
+
+function determineSectionHeight(lines: string[], lineHeights: number[]) {
+ let height = styles.itemBlockPadding * 1.5; // TODO: the height spacing is inconsistent across different devices...
+ lines.forEach((_line, index) => {
+ height += lineHeights[index];
+ });
+ return height;
+}
+
+interface CalculatedVerticalDrawingPositions {
+ mainY: number;
+ mainHeight: number;
+ leftTooltipTextY: number;
+ rightTooltipTextY: number;
+ deltaTextY: number;
+}
+
+interface CalculatedHorizontalDrawingPositions {
+ mainX: number;
+ mainWidth: number;
+ leftTooltipCentreX: number;
+ rightTooltipCentreX: number;
+ deltaCentreX: number;
+ deltaWidth: number;
+}
+
+type CalculatedDrawingPositions = CalculatedVerticalDrawingPositions &
+ CalculatedHorizontalDrawingPositions;
+
+function calculateVerticalDrawingPositions(
+ data: DeltaTooltipData
+): CalculatedVerticalDrawingPositions {
+ const mainY = data.topSpacing;
+
+ const leftTooltipHeight =
+ data.tooltips.length < 1
+ ? 0
+ : determineSectionHeight(
+ data.tooltips[0].lineContent,
+ styles.tooltipLineLineHeights
+ );
+ const rightTooltipHeight =
+ data.tooltips.length < 2
+ ? 0
+ : determineSectionHeight(
+ data.tooltips[1].lineContent,
+ styles.tooltipLineLineHeights
+ );
+ const deltaHeight = determineSectionHeight(
+ [data.deltaTopLine, data.deltaBottomLine].filter(Boolean),
+ styles.deltaLineHeights
+ );
+
+ const mainHeight = Math.max(
+ leftTooltipHeight,
+ rightTooltipHeight,
+ deltaHeight
+ );
+ const leftTooltipTextY = Math.round(
+ styles.itemBlockPadding + (mainHeight - leftTooltipHeight) / 2
+ );
+ const rightTooltipTextY = Math.round(
+ styles.itemBlockPadding + (mainHeight - rightTooltipHeight) / 2
+ );
+ const deltaTextY = Math.round(
+ styles.itemBlockPadding + (mainHeight - deltaHeight) / 2
+ );
+
+ return {
+ mainY,
+ mainHeight,
+ leftTooltipTextY,
+ rightTooltipTextY,
+ deltaTextY,
+ };
+}
+
+function calculateInitialTooltipPosition(
+ data: DeltaTooltipData,
+ index: number,
+ ctx: CanvasRenderingContext2D,
+ mediaSize: Size
+) {
+ const lines = data.tooltips[index].lineContent;
+ const tooltipWidth = determineSectionWidth(
+ ctx,
+ lines,
+ styles.tooltipLineFontSizes,
+ styles.tooltipLineFontWeights
+ );
+ const halfWidth = tooltipWidth / 2;
+ const idealX = Math.min(
+ Math.max(0, data.tooltips[index].x - halfWidth),
+ mediaSize.width - tooltipWidth
+ );
+ const leftSpace = idealX;
+ const rightSpace = mediaSize.width - tooltipWidth - leftSpace;
+ return {
+ x: idealX,
+ leftSpace,
+ rightSpace,
+ width: tooltipWidth,
+ };
+}
+
+function calculateDrawingHorizontalPositions(
+ data: DeltaTooltipData,
+ ctx: CanvasRenderingContext2D,
+ mediaSize: Size
+): CalculatedHorizontalDrawingPositions {
+ const leftPosition = calculateInitialTooltipPosition(data, 0, ctx, mediaSize);
+ if (data.tooltips.length < 2) {
+ return {
+ mainX: Math.round(leftPosition.x),
+ mainWidth: Math.round(leftPosition.width),
+ leftTooltipCentreX: Math.round(leftPosition.x + leftPosition.width / 2),
+ rightTooltipCentreX: 0,
+ deltaCentreX: 0,
+ deltaWidth: 0,
+ };
+ }
+ const rightPosition = calculateInitialTooltipPosition(
+ data,
+ 1,
+ ctx,
+ mediaSize
+ );
+ const minDeltaWidth =
+ data.tooltips.length < 2
+ ? 0
+ : determineSectionWidth(
+ ctx,
+ [data.deltaTopLine, data.deltaBottomLine].filter(Boolean),
+ styles.deltaFontSizes,
+ styles.deltaFontWeights
+ );
+
+ const overlapWidth =
+ minDeltaWidth + leftPosition.x + leftPosition.width - rightPosition.x;
+ // if positive then we need to adjust positions
+ if (overlapWidth > 0) {
+ const halfOverlap = overlapWidth / 2;
+ if (
+ leftPosition.leftSpace >= halfOverlap &&
+ rightPosition.rightSpace >= halfOverlap
+ ) {
+ leftPosition.x -= halfOverlap;
+ rightPosition.x += halfOverlap;
+ } else {
+ const leftSmaller = leftPosition.leftSpace < rightPosition.rightSpace;
+ if (leftSmaller) {
+ const remainingOverlap = overlapWidth - leftPosition.leftSpace;
+ leftPosition.x -= leftPosition.leftSpace;
+ rightPosition.x += remainingOverlap;
+ } else {
+ const remainingOverlap = overlapWidth - rightPosition.rightSpace;
+ leftPosition.x = Math.max(0, leftPosition.x - remainingOverlap);
+ rightPosition.x += rightPosition.rightSpace;
+ }
+ }
+ }
+
+ const deltaWidth = Math.round(
+ rightPosition.x - leftPosition.x - leftPosition.width
+ );
+ const deltaCentreX = Math.round(rightPosition.x - deltaWidth / 2);
+ return {
+ mainX: Math.round(leftPosition.x),
+ mainWidth: Math.round(
+ leftPosition.width + deltaWidth + rightPosition.width
+ ),
+ leftTooltipCentreX: Math.round(leftPosition.x + leftPosition.width / 2),
+ rightTooltipCentreX: Math.round(rightPosition.x + rightPosition.width / 2),
+ deltaCentreX,
+ deltaWidth,
+ };
+}
+
+function calculateDrawingPositions(
+ data: DeltaTooltipData,
+ ctx: CanvasRenderingContext2D,
+ mediaSize: Size
+): CalculatedDrawingPositions {
+ return {
+ ...calculateVerticalDrawingPositions(data),
+ ...calculateDrawingHorizontalPositions(data, ctx, mediaSize),
+ };
+}
+
+class DeltaTooltipPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: DeltaTooltipData;
+
+ constructor(data: DeltaTooltipData) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ if (this._data.tooltips.length < 1) return;
+ target.useMediaCoordinateSpace(scope => {
+ const ctx = scope.context;
+ const drawingPositions = calculateDrawingPositions(
+ this._data,
+ ctx,
+ scope.mediaSize
+ );
+ ctx.save();
+ this._drawMainTooltip(ctx, drawingPositions);
+ this._drawDeltaArea(ctx, drawingPositions);
+ this._drawTooltipsText(ctx, drawingPositions);
+ this._drawDeltaText(ctx, drawingPositions);
+ ctx.restore();
+ });
+ }
+
+ _drawMainTooltip(
+ ctx: CanvasRenderingContext2D,
+ positions: CalculatedDrawingPositions
+ ) {
+ ctx.save();
+ ctx.fillStyle = styles.background;
+ ctx.shadowBlur = styles.shadowBlur;
+ ctx.shadowOffsetX = styles.shadowOffsetX;
+ ctx.shadowOffsetY = styles.shadowOffsetY;
+ ctx.shadowColor = styles.shadowColor;
+ ctx.beginPath();
+ ctx.roundRect(
+ positions.mainX,
+ positions.mainY,
+ positions.mainWidth,
+ positions.mainHeight,
+ styles.borderRadius
+ );
+ ctx.fill();
+ ctx.restore();
+ }
+
+ _drawDeltaArea(
+ ctx: CanvasRenderingContext2D,
+ positions: CalculatedDrawingPositions
+ ) {
+ ctx.save();
+ ctx.fillStyle = this._data.deltaBackgroundColor;
+ ctx.beginPath();
+ const halfWidth = Math.round(positions.deltaWidth / 2);
+ ctx.fillRect(
+ positions.deltaCentreX - halfWidth,
+ positions.mainY,
+ positions.deltaWidth,
+ positions.mainHeight
+ );
+ ctx.restore();
+ }
+
+ _drawTooltipsText(
+ ctx: CanvasRenderingContext2D,
+ positions: CalculatedDrawingPositions
+ ) {
+ ctx.save();
+ this._data.tooltips.forEach(
+ (tooltip: DeltaSingleTooltipData, tooltipIndex: number) => {
+ const x =
+ tooltipIndex === 0
+ ? positions.leftTooltipCentreX
+ : positions.rightTooltipCentreX;
+ let y =
+ positions.mainY +
+ (tooltipIndex === 0
+ ? positions.leftTooltipTextY
+ : positions.rightTooltipTextY);
+
+ tooltip.lineContent.forEach((line: string, lineIndex: number) => {
+ ctx.font = `${styles.tooltipLineFontWeights[lineIndex]} ${styles.tooltipLineFontSizes[lineIndex]}px ${styles.fontFamily}`;
+ ctx.fillStyle = styles.tooltipLineColors[lineIndex];
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText(line, x, y);
+ y += styles.tooltipLineLineHeights[lineIndex];
+ });
+ }
+ );
+ ctx.restore();
+ }
+
+ _drawDeltaText(
+ ctx: CanvasRenderingContext2D,
+ positions: CalculatedDrawingPositions
+ ) {
+ ctx.save();
+ const x = positions.deltaCentreX;
+ let y = positions.mainY + positions.deltaTextY;
+
+ const lines = [this._data.deltaTopLine, this._data.deltaBottomLine];
+
+ lines.forEach((line: string, lineIndex: number) => {
+ ctx.font = `${styles.deltaFontWeights[lineIndex]} ${styles.deltaFontSizes[lineIndex]}px ${styles.fontFamily}`;
+ ctx.fillStyle = this._data.deltaTextColor;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText(line, x, y);
+ y += styles.deltaLineHeights[lineIndex];
+ });
+ ctx.restore();
+ }
+}
+
+export class DeltaTooltipPaneView implements ISeriesPrimitivePaneView {
+ _data: DeltaTooltipData;
+ constructor(data: Partial) {
+ this._data = {
+ ...defaultOptions,
+ ...data,
+ };
+ }
+
+ update(data: Partial): void {
+ this._data = {
+ ...this._data,
+ ...data,
+ };
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer | null {
+ return new DeltaTooltipPaneRenderer(this._data);
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'top';
+ }
+}
+
+export interface DeltaSingleTooltipData {
+ x: number;
+ lineContent: string[];
+}
+
+export interface DeltaTooltipData {
+ deltaTopLine: string;
+ deltaBottomLine: string;
+ deltaBackgroundColor: string;
+ deltaTextColor: string;
+
+ topSpacing: number;
+
+ tooltips: DeltaSingleTooltipData[];
+}
+
+const defaultOptions: DeltaTooltipData = {
+ deltaTopLine: '',
+ deltaBottomLine: '',
+ deltaBackgroundColor: '#ffffff',
+ deltaTextColor: '#',
+ topSpacing: 20,
+ tooltips: [],
+};
diff --git a/plugin-examples/src/plugins/delta-tooltip/delta-tooltip.ts b/plugin-examples/src/plugins/delta-tooltip/delta-tooltip.ts
new file mode 100644
index 0000000000..677360b0d5
--- /dev/null
+++ b/plugin-examples/src/plugins/delta-tooltip/delta-tooltip.ts
@@ -0,0 +1,284 @@
+import {
+ CrosshairMode,
+ ISeriesPrimitive,
+ SeriesAttachedParameter,
+ LineData,
+ WhitespaceData,
+ CandlestickData,
+ ColorType,
+ LineStyleOptions,
+ AreaStyleOptions,
+ ISeriesPrimitivePaneView,
+ Time,
+} from 'lightweight-charts';
+import { Delegate, ISubscription } from '../../helpers/delegate';
+import { convertTime, formattedDateAndTime } from '../../helpers/time';
+import {
+ MultiTouchCrosshairPaneView,
+ TooltipCrosshairLineData,
+} from './crosshair-line-pane';
+import { DeltaSingleTooltipData, DeltaTooltipData, DeltaTooltipPaneView } from './delta-tooltip-pane';
+import {
+ MultiTouchChartEvents,
+ MultiTouchInteraction,
+} from './multi-touch-chart-events';
+
+const defaultOptions: TooltipPrimitiveOptions = {
+ lineColor: 'rgba(0, 0, 0, 0.2)',
+ priceExtractor: (data: LineData | CandlestickData | WhitespaceData) => {
+ if ((data as LineData).value !== undefined) {
+ return [(data as LineData).value, (data as LineData).value.toFixed(2)];
+ }
+ if ((data as CandlestickData).close !== undefined) {
+ return [
+ (data as CandlestickData).close,
+ (data as CandlestickData).close.toFixed(2),
+ ];
+ }
+ return [0, ''];
+ },
+ showTime: false,
+ topOffset: 20,
+};
+
+export interface TooltipPrimitiveOptions {
+ lineColor: string;
+ priceExtractor: (dataPoint: T) => [number, string];
+ showTime: boolean;
+ topOffset: number;
+}
+
+export interface ActiveRange {
+ from: number;
+ to: number;
+ positive: boolean;
+}
+
+export class DeltaTooltipPrimitive implements ISeriesPrimitive {
+ private _options: TooltipPrimitiveOptions;
+ _crosshairPaneView: MultiTouchCrosshairPaneView;
+ _deltaTooltipPaneView: DeltaTooltipPaneView;
+ _paneViews: ISeriesPrimitivePaneView[];
+ _crosshairData: TooltipCrosshairLineData[] = [];
+ _tooltipData: Partial;
+ _attachedParams: SeriesAttachedParameter | undefined;
+ _touchChartEvents: MultiTouchChartEvents | null = null;
+
+ private _activeRange: Delegate = new Delegate();
+
+ constructor(options: Partial) {
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._tooltipData = {
+ topSpacing: this._options.topOffset,
+ };
+ this._crosshairPaneView = new MultiTouchCrosshairPaneView(this._crosshairData);
+ this._deltaTooltipPaneView = new DeltaTooltipPaneView(this._tooltipData);
+ this._paneViews = [this._crosshairPaneView, this._deltaTooltipPaneView];
+ }
+
+ attached(param: SeriesAttachedParameter): void {
+ this._attachedParams = param;
+ this._setCrosshairMode();
+ this._touchChartEvents = new MultiTouchChartEvents(param.chart, {
+ simulateMultiTouchUsingMouseDrag: true,
+ });
+ this._touchChartEvents.leave().subscribe(() => {
+ this._activeRange.fire(null);
+ this._hideCrosshair();
+ }, this);
+ this._touchChartEvents
+ .move()
+ .subscribe((interactions: MultiTouchInteraction) => {
+ this._showTooltip(interactions);
+ }, this);
+ }
+
+ detached(): void {
+ if (this._touchChartEvents) {
+ this._touchChartEvents.leave().unsubscribeAll(this);
+ this._touchChartEvents.move().unsubscribeAll(this);
+ this._touchChartEvents.destroy();
+ }
+ this._activeRange.destroy();
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ updateAllViews() {
+ this._crosshairPaneView.update(this._crosshairData);
+ this._deltaTooltipPaneView.update(this._tooltipData);
+ }
+
+ setData(crosshairData: TooltipCrosshairLineData[], tooltipData: Partial) {
+ this._crosshairData = crosshairData;
+ this._tooltipData = tooltipData;
+ this.updateAllViews();
+ this._attachedParams?.requestUpdate();
+ }
+
+ currentColor() {
+ return this._options.lineColor;
+ }
+
+ chart() {
+ return this._attachedParams?.chart;
+ }
+
+ series() {
+ return this._attachedParams?.series;
+ }
+
+ applyOptions(options: Partial) {
+ this._options = {
+ ...this._options,
+ ...options,
+ };
+ this._tooltipData.topSpacing = this._options.topOffset;
+ }
+
+ public activeRange(): ISubscription {
+ return this._activeRange;
+ }
+
+ private _setCrosshairMode() {
+ const chart = this.chart();
+ if (!chart) {
+ throw new Error(
+ 'Unable to change crosshair mode because the chart instance is undefined'
+ );
+ }
+ chart.applyOptions({
+ crosshair: {
+ mode: CrosshairMode.Magnet,
+ vertLine: {
+ visible: false,
+ labelVisible: false,
+ },
+ horzLine: {
+ visible: false,
+ labelVisible: false,
+ },
+ },
+ });
+ const series = this.series();
+ if (series) {
+ // We need to draw the crosshair markers ourselves since there can be multiple points now.
+ series.applyOptions({ crosshairMarkerVisible: false });
+ }
+ }
+
+ private _hideTooltip() {
+ this.setData([], {
+ tooltips: [],
+ });
+ }
+
+ private _hideCrosshair() {
+ this._hideTooltip();
+ }
+
+ private _chartBackgroundColor(): string {
+ const chart = this.chart();
+ if (!chart) {
+ return '#FFFFFF';
+ }
+ const backgroundOptions = chart.options().layout.background;
+ if (backgroundOptions.type === ColorType.Solid) {
+ return backgroundOptions.color;
+ }
+ return backgroundOptions.topColor;
+ }
+
+ private _seriesLineColor(): string {
+ const series = this.series();
+ if (!series) {
+ return '#888';
+ }
+ const seriesOptions = series.options();
+ return (
+ (seriesOptions as LineStyleOptions).color ||
+ (seriesOptions as AreaStyleOptions).lineColor ||
+ '#888'
+ );
+ }
+
+ private _showTooltip(interactions: MultiTouchInteraction) {
+ const series = this.series();
+ if (interactions.points.length < 1 || !series) {
+ this._hideCrosshair();
+ return;
+ }
+ const topMargin = this._tooltipData.topSpacing ?? 20;
+ const markerBorderColor = this._chartBackgroundColor();
+ const markerColor = this._seriesLineColor();
+ const tooltips: DeltaSingleTooltipData[] = [];
+ const crosshairData: TooltipCrosshairLineData[] = [];
+ const priceValues: [number, number][] = [];
+ let firstPointIndex = interactions.points[0].index;
+ for (let i = 0; i < Math.min(2, interactions.points.length); i++) {
+ const point = interactions.points[i];
+ const data = series.dataByIndex(point.index);
+ if (data) {
+ const [priceValue, priceString] = this._options.priceExtractor(data);
+ priceValues.push([priceValue, point.index]);
+ const priceY = series.priceToCoordinate(priceValue) ?? -1000;
+ const [date, time] = formattedDateAndTime(
+ data.time ? convertTime(data.time) : undefined
+ );
+ const state: DeltaSingleTooltipData = {
+ x: point.x,
+ lineContent: [priceString, date],
+ };
+ if (this._options.showTime) {
+ state.lineContent.push(time);
+ }
+ if (point.index >= firstPointIndex) {
+ tooltips.push(state);
+ } else {
+ tooltips.unshift(state); // place at front so order is correct.
+ }
+
+ crosshairData.push({
+ x: point.x,
+ priceY,
+ visible: true,
+ color: this.currentColor(),
+ topMargin,
+ markerColor,
+ markerBorderColor,
+ });
+ }
+ }
+ const deltaContent: Partial = {
+ tooltips,
+ };
+ if (priceValues.length > 1) {
+ const correctOrder = priceValues[1][1] > priceValues[0][1];
+ const firstPrice = correctOrder ? priceValues[0][0] : priceValues[1][0];
+ const secondPrice = correctOrder ? priceValues[1][0] : priceValues[0][0];
+ const priceChange = secondPrice - firstPrice;
+ const pctChange = (100 * priceChange) / firstPrice;
+ const positive = priceChange >= 0;
+ deltaContent.deltaTopLine = (positive ? '+' : '') + priceChange.toFixed(2);
+ deltaContent.deltaBottomLine = (positive ? '+' : '') + pctChange.toFixed(2) + '%';
+ deltaContent.deltaBackgroundColor = positive ? 'rgb(4,153,129, 0.2)' : 'rgb(239,83,80, 0.2)';
+ deltaContent.deltaTextColor = positive ? 'rgb(4,153,129)' : 'rgb(239,83,80)';
+ this._activeRange.fire({
+ from: priceValues[correctOrder ? 0 : 1][1] + 1,
+ to: priceValues[correctOrder ? 1 : 0][1] + 1,
+ positive,
+ });
+ } else {
+ deltaContent.deltaTopLine = '';
+ deltaContent.deltaBottomLine = '';
+ this._activeRange.fire(null);
+ }
+ this.setData(crosshairData, deltaContent);
+
+ }
+}
diff --git a/plugin-examples/src/plugins/delta-tooltip/example/example.ts b/plugin-examples/src/plugins/delta-tooltip/example/example.ts
new file mode 100644
index 0000000000..2e838bf634
--- /dev/null
+++ b/plugin-examples/src/plugins/delta-tooltip/example/example.ts
@@ -0,0 +1,39 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { DeltaTooltipPrimitive } from '../delta-tooltip';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ grid: {
+ vertLines: {
+ visible: false,
+ },
+ horzLines: {
+ visible: false,
+ },
+ },
+ timeScale: {
+ borderVisible: false,
+ },
+ rightPriceScale: {
+ borderVisible: false,
+ },
+ handleScale: false,
+ handleScroll: false,
+}));
+
+const areaSeries = chart.addAreaSeries({
+ lineColor: 'rgb(40,98,255)',
+ topColor: 'rgba(40,98,255, 0.4)',
+ bottomColor: 'rgba(40,98,255, 0)',
+ priceLineVisible: false,
+});
+areaSeries.setData(generateLineData());
+
+const tooltipPrimitive = new DeltaTooltipPrimitive({
+ lineColor: 'rgba(0, 0, 0, 0.2)',
+});
+
+areaSeries.attachPrimitive(tooltipPrimitive);
+
+chart.timeScale().fitContent();
diff --git a/plugin-examples/src/plugins/delta-tooltip/example/index.html b/plugin-examples/src/plugins/delta-tooltip/example/index.html
new file mode 100644
index 0000000000..9dbe02ebd7
--- /dev/null
+++ b/plugin-examples/src/plugins/delta-tooltip/example/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Lightweight Charts - Delta Tooltip Plugin Example
+
+
+
+
+
+
+
Delta Tooltip
+
Hint: Use multi-touch, or click and drag.
+
+ The Delta tooltip can be used to show the differences between two points
+ on the chart. Functioning as a normal crosshair tooltip, until the user
+ either uses a multi-touch gesture or clicks and drags the mouse across
+ the chart.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/delta-tooltip/multi-touch-chart-events.ts b/plugin-examples/src/plugins/delta-tooltip/multi-touch-chart-events.ts
new file mode 100644
index 0000000000..cfa11b96a6
--- /dev/null
+++ b/plugin-examples/src/plugins/delta-tooltip/multi-touch-chart-events.ts
@@ -0,0 +1,257 @@
+import { Coordinate, IChartApi, Logical } from 'lightweight-charts';
+import { Delegate, ISubscription } from '../../helpers/delegate';
+
+export interface TouchPoint {
+ x: number;
+ index: Logical;
+ y: Coordinate;
+}
+
+export interface MultiTouchInteraction {
+ points: TouchPoint[];
+}
+
+interface MouseState {
+ drawing: boolean;
+ startLogical: number | null;
+ startCoordinate: number | null;
+ startX: number | null;
+}
+
+function determineChartX(
+ chartElement: HTMLDivElement,
+ chart: IChartApi,
+ mouseX: number
+): number | null {
+ const chartBox = chartElement.getBoundingClientRect();
+ const x = mouseX - chartBox.left - chart.priceScale('left').width();
+ if (x < 0 || x > chart.timeScale().width()) return null;
+ return x;
+}
+
+function determinePaneXLogical(
+ chart: IChartApi,
+ x: number | null
+): Logical | null {
+ if (x === null) return null;
+ return chart.timeScale().coordinateToLogical(x);
+}
+
+function determineYPosition(
+ chartElement: HTMLDivElement,
+ clientY: number
+): Coordinate {
+ const chartContainerBox = chartElement.getBoundingClientRect();
+ return (clientY - chartContainerBox.y) as Coordinate;
+}
+
+interface MultiTouchChartOptions {
+ simulateMultiTouchUsingMouseDrag: boolean;
+}
+
+type UnSubscriber = () => void;
+
+export class MultiTouchChartEvents {
+ _chartElement: HTMLDivElement;
+ _chart: IChartApi;
+ _options: MultiTouchChartOptions;
+
+ _mouseState: MouseState = {
+ drawing: false,
+ startLogical: null,
+ startCoordinate: null,
+ startX: null,
+ };
+
+ private _touchLeave: Delegate = new Delegate();
+ private _touchInteraction: Delegate = new Delegate();
+
+ _unSubscribers: UnSubscriber[] = [];
+
+ constructor(chart: IChartApi, options: MultiTouchChartOptions) {
+ this._options = options;
+ this._chart = chart;
+ this._chartElement = chart.chartElement();
+ this._addMouseEventListener(
+ this._chartElement,
+ 'mouseleave',
+ this._mouseLeave
+ );
+ this._addMouseEventListener(
+ this._chartElement,
+ 'mousemove',
+ this._mouseMove
+ );
+ this._addMouseEventListener(
+ this._chartElement,
+ 'mousedown',
+ this._mouseDown
+ );
+ this._addMouseEventListener(this._chartElement, 'mouseup', this._mouseUp);
+ this._addTouchEventListener(
+ this._chartElement,
+ 'touchstart',
+ this._touchOther
+ );
+ this._addTouchEventListener(
+ this._chartElement,
+ 'touchmove',
+ this._touchMove
+ );
+ this._addTouchEventListener(
+ this._chartElement,
+ 'touchcancel',
+ this._touchFinish
+ );
+ this._addTouchEventListener(
+ this._chartElement,
+ 'touchend',
+ this._touchFinish
+ );
+ }
+
+ destroy() {
+ this._touchLeave.destroy();
+ this._touchInteraction.destroy();
+ this._unSubscribers.forEach(unSub => {
+ unSub();
+ });
+ this._unSubscribers = [];
+ }
+
+ public leave(): ISubscription {
+ return this._touchLeave;
+ }
+
+ public move(): ISubscription {
+ return this._touchInteraction;
+ }
+
+ _addMouseEventListener(
+ target: HTMLDivElement,
+ eventType: 'mouseleave' | 'mousemove' | 'mousedown' | 'mouseup',
+ handler: (event: MouseEvent) => void
+ ): void {
+ const boundMouseMoveHandler = handler.bind(this);
+ target.addEventListener(eventType, boundMouseMoveHandler);
+ const unSubscriber = () => {
+ target.removeEventListener(eventType, boundMouseMoveHandler);
+ };
+ this._unSubscribers.push(unSubscriber);
+ }
+
+ _addTouchEventListener(
+ target: HTMLDivElement,
+ eventType: 'touchstart' | 'touchend' | 'touchmove' | 'touchcancel',
+ handler: (event: TouchEvent) => void
+ ): void {
+ const boundMouseMoveHandler = handler.bind(this);
+ target.addEventListener(eventType, boundMouseMoveHandler);
+ const unSubscriber = () => {
+ target.removeEventListener(eventType, boundMouseMoveHandler);
+ };
+ this._unSubscribers.push(unSubscriber);
+ }
+
+ _mouseLeave() {
+ this._mouseState.drawing = false;
+ this._touchLeave.fire();
+ }
+ _mouseMove(event: MouseEvent) {
+ const chartX = determineChartX(
+ this._chartElement,
+ this._chart,
+ event.clientX
+ );
+ const logical = determinePaneXLogical(this._chart, chartX);
+ const coordinate = determineYPosition(this._chartElement, event.clientY);
+
+ const points: TouchPoint[] = [];
+ if (
+ this._options.simulateMultiTouchUsingMouseDrag &&
+ this._mouseState.drawing &&
+ this._mouseState.startLogical !== null &&
+ this._mouseState.startCoordinate !== null &&
+ this._mouseState.startX !== null
+ ) {
+ points.push({
+ x: this._mouseState.startX,
+ index: this._mouseState.startLogical as Logical,
+ y: this._mouseState.startCoordinate as Coordinate,
+ });
+ }
+
+ if (logical !== null && coordinate !== null && chartX !== null) {
+ points.push({
+ x: chartX,
+ index: logical,
+ y: coordinate,
+ });
+ }
+
+ const interaction: MultiTouchInteraction = {
+ points,
+ };
+ this._touchInteraction.fire(interaction);
+ }
+ _mouseDown(event: MouseEvent) {
+ this._mouseState.startX = determineChartX(
+ this._chartElement,
+ this._chart,
+ event.clientX
+ );
+ this._mouseState.startLogical = determinePaneXLogical(
+ this._chart,
+ this._mouseState.startX
+ );
+ this._mouseState.startCoordinate = determineYPosition(
+ this._chartElement,
+ event.clientY
+ );
+ this._mouseState.drawing =
+ this._mouseState.startLogical !== null &&
+ this._mouseState.startCoordinate !== null;
+ }
+ _mouseUp() {
+ this._mouseState.drawing = false;
+ }
+
+ _touchMove(event: TouchEvent) {
+ event.preventDefault();
+ const points: TouchPoint[] = [];
+ for (let i = 0; i < event.targetTouches.length; i++) {
+ const touch = event.targetTouches.item(i);
+ if (touch !== null) {
+ const chartX = determineChartX(
+ this._chartElement,
+ this._chart,
+ touch.clientX
+ );
+ const logical = determinePaneXLogical(this._chart, chartX);
+ const y = determineYPosition(this._chartElement, touch.clientY);
+ if (chartX !== null && y !== null && logical !== null) {
+ points.push({
+ x: chartX,
+ index: logical,
+ y,
+ });
+ }
+ }
+ }
+ const interaction: MultiTouchInteraction = {
+ points,
+ };
+ this._touchInteraction.fire(interaction);
+ }
+ _touchFinish(event: TouchEvent) {
+ event.preventDefault();
+ // might be fired while some touch points are still active (eg. two fingers to one finger)
+ if (event.targetTouches.length < 1) {
+ this._touchLeave.fire();
+ return;
+ }
+ }
+ _touchOther(event: TouchEvent) {
+ event.preventDefault();
+ }
+}
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/example/example.ts b/plugin-examples/src/plugins/expiring-price-alerts/example/example.ts
new file mode 100644
index 0000000000..5b7d6ec031
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/example/example.ts
@@ -0,0 +1,113 @@
+import {
+ LastPriceAnimationMode,
+ LineData,
+ Time,
+ createChart,
+} from 'lightweight-charts';
+import { ExpiringPriceAlerts } from '../expiring-price-alerts';
+import { sampleAlertLineData } from '../sample-data';
+
+const chart = createChart('chart', {
+ autoSize: true,
+ timeScale: {
+ secondsVisible: true,
+ timeVisible: true,
+ },
+});
+
+const lineSeries = chart.addLineSeries({
+ lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
+});
+const data = sampleAlertLineData(); // 622 items
+const [initialData, realtimeUpdates] = [data.slice(0, 400), data.slice(400)];
+lineSeries.setData(initialData);
+
+const priceAlerts = new ExpiringPriceAlerts(lineSeries, { interval: 60 });
+
+const pos = chart.timeScale().scrollPosition();
+chart.timeScale().scrollToPosition(pos + 20, false);
+
+
+// The rest simulates updates and the user adding price alerts.
+
+const simulateUserPriceAlerts = (time: Time) => {
+ if (time === realtimeUpdates[0].time) {
+ priceAlerts.addExpiringAlert(
+ 19.5,
+ realtimeUpdates[0].time as number,
+ realtimeUpdates[25].time as number,
+ {
+ crossingDirection: 'down',
+ title: '$19.50',
+ }
+ );
+ }
+ if (time === realtimeUpdates[30].time) {
+ priceAlerts.addExpiringAlert(
+ 19.75,
+ realtimeUpdates[30].time as number,
+ realtimeUpdates[45].time as number,
+ {
+ crossingDirection: 'up',
+ title: '$19.75',
+ }
+ );
+ }
+ if (time === realtimeUpdates[45].time) {
+ priceAlerts.addExpiringAlert(
+ 19.0,
+ realtimeUpdates[45].time as number,
+ realtimeUpdates[65].time as number,
+ {
+ crossingDirection: 'down',
+ title: '$19.00',
+ }
+ );
+ }
+
+ if (time === realtimeUpdates[55].time) {
+ priceAlerts.addExpiringAlert(
+ 20.0,
+ realtimeUpdates[55].time as number,
+ realtimeUpdates[65].time as number,
+ {
+ crossingDirection: 'up',
+ title: '$20.00',
+ }
+ );
+ }
+
+ if (time === realtimeUpdates[75].time) {
+ priceAlerts.addExpiringAlert(
+ 21.25,
+ realtimeUpdates[80].time as number,
+ realtimeUpdates[220].time as number,
+ {
+ crossingDirection: 'up',
+ title: 'wishful',
+ }
+ );
+ }
+}
+
+simulateUserPriceAlerts(realtimeUpdates[0].time);
+
+// simulate real-time data
+function* getNextRealtimeUpdate(realtimeData: LineData[]) {
+ for (const dataPoint of realtimeData) {
+ yield dataPoint;
+ }
+ return null;
+}
+const streamingDataProvider = getNextRealtimeUpdate(realtimeUpdates);
+
+const intervalID = window.setInterval(() => {
+ const update = streamingDataProvider.next();
+ if (update.done) {
+ window.clearInterval(intervalID);
+ return;
+ }
+ lineSeries.update(update.value);
+ simulateUserPriceAlerts(update.value.time);
+}, 200);
+
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/example/index.html b/plugin-examples/src/plugins/expiring-price-alerts/example/index.html
new file mode 100644
index 0000000000..8422fa9b7a
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/example/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Lightweight Charts - Rectangle Plugin Example
+
+
+
+
+
+
Expiring Price Alerts
+
+ Price alerts with defined start and end times (expiration dates).
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/expiring-price-alerts.ts b/plugin-examples/src/plugins/expiring-price-alerts/expiring-price-alerts.ts
new file mode 100644
index 0000000000..e1d3830c47
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/expiring-price-alerts.ts
@@ -0,0 +1,210 @@
+import {
+ DataChangedScope,
+ IChartApi,
+ ISeriesApi,
+ LineData,
+ MismatchDirection,
+ SeriesOptionsMap,
+ UTCTimestamp,
+ WhitespaceData,
+} from 'lightweight-charts';
+import {
+ ExpiringPriceAlert,
+ IExpiringPriceAlerts,
+} from './iexpiring-price-alerts';
+import {
+ ExpiringPriceAlertParameters,
+ ExpiringPriceAlertsOptions,
+ defaultOptions,
+} from './options';
+import { ExpiringAlertPrimitive } from './primitive';
+
+/**
+ * This Plugin will work best with a chart which has a linear time scale.
+ */
+
+function hasValue(data: LineData | WhitespaceData): data is LineData {
+ return (data as LineData).value !== undefined;
+}
+
+export class ExpiringPriceAlerts implements IExpiringPriceAlerts {
+ _options: ExpiringPriceAlertsOptions;
+ _chart: IChartApi | null = null;
+ _series: ISeriesApi;
+ _primitive: ExpiringAlertPrimitive;
+
+ _whitespaceSeriesStart: number | null = null;
+ _whitespaceSeriesEnd: number | null = null;
+ _whitespaceSeries: ISeriesApi<'Line'>;
+
+ _alerts: Map = new Map();
+ _dataChangedHandler: (scope: DataChangedScope) => void;
+
+ constructor(
+ series: ISeriesApi,
+ options: Partial
+ ) {
+ this._series = series;
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._primitive = new ExpiringAlertPrimitive(this);
+ this._series.attachPrimitive(this._primitive);
+ this._dataChangedHandler = this._dataChanged.bind(this);
+ this._series.subscribeDataChanged(this._dataChangedHandler);
+
+ const currentLastPoint = this._series.dataByIndex(
+ 10000,
+ MismatchDirection.NearestLeft
+ );
+ if (currentLastPoint) this.checkedCrossed(currentLastPoint);
+
+ this._chart = this._primitive.chart;
+ this._whitespaceSeries = this._chart.addLineSeries();
+ }
+
+ destroy() {
+ this._series.unsubscribeDataChanged(this._dataChangedHandler);
+ this._series.detachPrimitive(this._primitive);
+ }
+
+ alerts() {
+ return this._alerts;
+ }
+ chart() {
+ return this._chart;
+ }
+ series() {
+ return this._series;
+ }
+
+ addExpiringAlert(
+ price: number,
+ startDate: number,
+ endDate: number,
+ parameters: ExpiringPriceAlertParameters
+ ): string {
+ let id = (Math.random() * 100000).toFixed();
+ while (this._alerts.has(id)) {
+ id = (Math.random() * 100000).toFixed();
+ }
+ this._alerts.set(id, {
+ price,
+ start: startDate,
+ end: endDate,
+ parameters,
+ crossed: false,
+ expired: false,
+ });
+ this._update();
+ return id;
+ }
+
+ removeExpiringAlert(id: string) {
+ this._alerts.delete(id);
+ this._update();
+ }
+
+ toggleCrossed(id: string) {
+ const alert = this._alerts.get(id);
+ if (!alert) return;
+ alert.crossed = true;
+ setTimeout(() => {
+ this.removeExpiringAlert(id);
+ }, this._options.clearTimeout);
+ this._update();
+ }
+
+ checkExpired(time: number) {
+ for (const [id, data] of this._alerts.entries()) {
+ if (data.end <= time) {
+ data.expired = true;
+ setTimeout(() => {
+ this.removeExpiringAlert(id);
+ }, this._options.clearTimeout);
+ }
+ }
+ this._update();
+ }
+
+ _lastValue: number | undefined = undefined;
+ checkedCrossed(point: LineData | WhitespaceData) {
+ if (!hasValue(point)) return;
+ if (this._lastValue !== undefined) {
+ for (const [id, data] of this._alerts.entries()) {
+ let crossed = false;
+ if (data.parameters.crossingDirection === 'up') {
+ if (this._lastValue <= data.price && point.value > data.price) {
+ crossed = true;
+ }
+ } else if (data.parameters.crossingDirection === 'down') {
+ if (this._lastValue >= data.price && point.value < data.price) {
+ crossed = true;
+ }
+ }
+ if (crossed) {
+ this.toggleCrossed(id);
+ }
+ }
+ }
+ this._lastValue = point.value;
+ }
+
+ _update() {
+ let start: number | null = Infinity;
+ let end: number | null = 0;
+ const hasAlerts = this._alerts.size > 0;
+ for (const [_id, data] of this._alerts.entries()) {
+ if (data.end > end) end = data.end;
+ if (data.start < start) start = data.start;
+ }
+ if (!hasAlerts) {
+ start = null;
+ end = null;
+ }
+ if (start) {
+ const lastPlotDate =
+ (this._series.dataByIndex(1000000, MismatchDirection.NearestLeft)
+ ?.time as number | undefined) ?? start;
+ if (lastPlotDate < start) start = lastPlotDate;
+ }
+ if (
+ this._whitespaceSeriesStart !== start ||
+ this._whitespaceSeriesEnd !== end
+ ) {
+ this._whitespaceSeriesStart = start;
+ this._whitespaceSeriesEnd = end;
+ if (!this._whitespaceSeriesStart || !this._whitespaceSeriesEnd) {
+ this._whitespaceSeries.setData([]);
+ } else {
+ this._whitespaceSeries.setData(
+ this._buildWhitespace(
+ this._whitespaceSeriesStart,
+ this._whitespaceSeriesEnd
+ )
+ );
+ }
+ }
+
+ this._primitive.requestUpdate();
+ }
+
+ _buildWhitespace(start: number, end: number): WhitespaceData[] {
+ const data: WhitespaceData[] = [];
+ for (let time = start; time <= end; time += this._options.interval) {
+ data.push({ time: time as UTCTimestamp });
+ }
+ return data;
+ }
+
+ _dataChanged() {
+ const lastPoint = this._series.dataByIndex(
+ 100000,
+ MismatchDirection.NearestLeft
+ );
+ if (!lastPoint) return;
+ this.checkedCrossed(lastPoint);
+ this.checkExpired(lastPoint.time as number);
+ }
+}
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/icons.ts b/plugin-examples/src/plugins/expiring-price-alerts/icons.ts
new file mode 100644
index 0000000000..c81199c8ea
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/icons.ts
@@ -0,0 +1,13 @@
+export const upArrowIcon = new Path2D(
+ 'M5.22 14.78a.75.75 0 001.06 0l7.22-7.22v5.69a.75.75 0 001.5 0v-7.5a.75.75 0 00-.75-.75h-7.5a.75.75 0 000 1.5h5.69l-7.22 7.22a.75.75 0 000 1.06z'
+);
+export const downArrowIcon = new Path2D(
+ 'M6.28 5.22a.75.75 0 00-1.06 1.06l7.22 7.22H6.75a.75.75 0 000 1.5h7.5a.747.747 0 00.75-.75v-7.5a.75.75 0 00-1.5 0v5.69L6.28 5.22z'
+);
+export const tickIcon = new Path2D(
+ 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z'
+);
+export const cancelIcon = new Path2D(
+ 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z'
+);
+export const iconDimensions = 20;
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/iexpiring-price-alerts.ts b/plugin-examples/src/plugins/expiring-price-alerts/iexpiring-price-alerts.ts
new file mode 100644
index 0000000000..580aada818
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/iexpiring-price-alerts.ts
@@ -0,0 +1,17 @@
+import { IChartApi, ISeriesApi, SeriesOptionsMap } from 'lightweight-charts';
+import { ExpiringPriceAlertParameters } from "./options";
+
+export interface ExpiringPriceAlert {
+ price: number;
+ start: number;
+ end: number;
+ parameters: ExpiringPriceAlertParameters;
+ crossed: boolean;
+ expired: boolean;
+}
+
+export interface IExpiringPriceAlerts {
+ alerts(): Map;
+ chart(): IChartApi | null;
+ series(): ISeriesApi;
+}
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/options.ts b/plugin-examples/src/plugins/expiring-price-alerts/options.ts
new file mode 100644
index 0000000000..99a1d3f694
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/options.ts
@@ -0,0 +1,17 @@
+export interface ExpiringPriceAlertsOptions {
+ /** Interval between bars (in seconds) */
+ interval: number;
+ /** Delay when removing an alert */
+ clearTimeout: number;
+}
+
+export const defaultOptions: ExpiringPriceAlertsOptions = {
+ interval: 60 * 60 * 24,
+ clearTimeout: 3000,
+};
+
+export interface ExpiringPriceAlertParameters {
+ // color: string;
+ title: string;
+ crossingDirection: 'up' | 'down';
+}
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/primitive.ts b/plugin-examples/src/plugins/expiring-price-alerts/primitive.ts
new file mode 100644
index 0000000000..b677059edd
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/primitive.ts
@@ -0,0 +1,118 @@
+import {
+ ISeriesPrimitivePaneView,
+ ISeriesPrimitivePaneRenderer,
+ Time,
+ AutoscaleInfo,
+} from 'lightweight-charts';
+import { PluginBase } from '../plugin-base';
+import { ExpiringPriceAlerts } from './expiring-price-alerts';
+import { upArrowIcon, tickIcon, cancelIcon, downArrowIcon } from './icons';
+import { ExpiringPriceAlertsPaneRenderer, RendererDataItem } from './renderer';
+
+class ExpiringPriceAlertsPaneView implements ISeriesPrimitivePaneView {
+ _source: ExpiringPriceAlerts;
+ _renderer: ExpiringPriceAlertsPaneRenderer;
+
+ constructor(source: ExpiringPriceAlerts) {
+ this._source = source;
+ this._renderer = new ExpiringPriceAlertsPaneRenderer();
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer {
+ return this._renderer;
+ }
+
+ update() {
+ const data: RendererDataItem[] = [];
+ const ts = this._source._chart?.timeScale();
+ if (ts) {
+ for (const alert of this._source._alerts.values()) {
+ const priceY = this._source._series.priceToCoordinate(alert.price);
+ if (priceY === null) continue;
+ let startX: number | null = ts.timeToCoordinate(alert.start as Time) as
+ | number
+ | null;
+ let endX: number | null = ts.timeToCoordinate(alert.end as Time) as
+ | number
+ | null;
+ if (startX === null && endX === null) continue;
+ if (!startX) startX = 0;
+ if (!endX) endX = ts.width();
+ let color = '#000000';
+ let icon = upArrowIcon;
+ if (alert.parameters.crossingDirection === 'up') {
+ color = alert.crossed
+ ? '#386D2E'
+ : alert.expired
+ ? '#30472C'
+ : '#64C750';
+ icon = alert.crossed
+ ? tickIcon
+ : alert.expired
+ ? cancelIcon
+ : upArrowIcon;
+ } else if (alert.parameters.crossingDirection === 'down') {
+ color = alert.crossed
+ ? '#7C1F3E'
+ : alert.expired
+ ? '#4A2D37'
+ : '#C83264';
+ icon = alert.crossed
+ ? tickIcon
+ : alert.expired
+ ? cancelIcon
+ : downArrowIcon;
+ }
+ data.push({
+ priceY,
+ startX,
+ endX,
+ icon,
+ color,
+ text: alert.parameters.title,
+ fade: alert.expired,
+ });
+ }
+ }
+ this._renderer.update(data);
+ }
+}
+
+export class ExpiringAlertPrimitive extends PluginBase {
+ _source: ExpiringPriceAlerts;
+ _views: ExpiringPriceAlertsPaneView[];
+
+ constructor(source: ExpiringPriceAlerts) {
+ super();
+ this._source = source;
+ this._views = [new ExpiringPriceAlertsPaneView(this._source)];
+ }
+
+ requestUpdate() {
+ super.requestUpdate();
+ }
+
+ updateAllViews() {
+ this._views.forEach(view => view.update());
+ }
+
+ paneViews(): readonly ISeriesPrimitivePaneView[] {
+ return this._views;
+ }
+
+ autoscaleInfo(): AutoscaleInfo | null {
+ let smallest = Infinity;
+ let largest = -Infinity;
+ for (const alert of this._source._alerts.values()) {
+ if (alert.price < smallest) smallest = alert.price;
+ if (alert.price > largest) largest = alert.price;
+ }
+ if (smallest > largest) return null;
+ return {
+ priceRange: {
+ maxValue: largest,
+ minValue: smallest,
+ },
+ };
+ }
+}
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/renderer.ts b/plugin-examples/src/plugins/expiring-price-alerts/renderer.ts
new file mode 100644
index 0000000000..1eef3ef836
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/renderer.ts
@@ -0,0 +1,81 @@
+import { CanvasRenderingTarget2D } from "fancy-canvas";
+import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts';
+import { iconDimensions } from "./icons";
+import { positionsLine } from "../../helpers/dimensions/positions";
+
+export interface RendererDataItem {
+ priceY: number;
+ startX: number;
+ endX: number;
+ color: string;
+ text: string;
+ icon: Path2D;
+ fade: boolean;
+}
+
+export class ExpiringPriceAlertsPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: RendererDataItem[] = [];
+
+ draw(target: CanvasRenderingTarget2D) {
+ let pixelRatio = 1;
+ target.useBitmapCoordinateSpace(scope => {
+ pixelRatio = scope.verticalPixelRatio
+ });
+
+ target.useMediaCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ ctx.lineWidth = 2;
+
+ this._data.forEach(d => {
+ const priceLineY = positionsLine(d.priceY, pixelRatio, ctx.lineWidth);
+ const priceY = (priceLineY.position + priceLineY.length / 2) / pixelRatio;
+
+ ctx.fillStyle = d.color;
+ ctx.strokeStyle = d.color;
+ ctx.lineDashOffset = 0;
+ ctx.globalAlpha = d.fade ? 0.5 : 1;
+ ctx.beginPath();
+ ctx.moveTo(d.startX + 4, priceY);
+ ctx.lineTo(d.endX - 4, priceY);
+ ctx.stroke();
+
+ // dotted lines
+ ctx.beginPath();
+ ctx.setLineDash([3, 6]);
+ ctx.lineCap = 'round';
+ ctx.moveTo(d.startX - 30, priceY);
+ ctx.lineTo(scope.mediaSize.width, priceY);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ ctx.beginPath();
+ ctx.arc(d.startX, priceY, 4, 0, 2 * Math.PI);
+ ctx.arc(d.endX, priceY, 4, 0, 2 * Math.PI);
+ ctx.fill();
+
+ ctx.font = '10px sans-serif';
+ const textMeasurement = ctx.measureText(d.text);
+ ctx.beginPath();
+ ctx.roundRect(d.startX - 30 - 20 - textMeasurement.width, priceY - 7, textMeasurement.width + 20, 14, 4);
+ ctx.fill();
+
+ ctx.fillStyle= '#FFFFFF';
+ ctx.fillText(d.text, d.startX - 30 - 15 - textMeasurement.width, priceY + 3);
+
+ ctx.save();
+ ctx.translate(d.startX - 30 - 14, priceY - 6);
+ const scale = 12 / iconDimensions;
+ ctx.scale(scale, scale);
+ ctx.fill(d.icon, 'evenodd');
+ ctx.restore();
+ });
+
+ ctx.restore();
+ });
+ }
+
+ update(data: RendererDataItem[]) {
+ this._data = data;
+ }
+}
diff --git a/plugin-examples/src/plugins/expiring-price-alerts/sample-data.ts b/plugin-examples/src/plugins/expiring-price-alerts/sample-data.ts
new file mode 100644
index 0000000000..bca1e94bc6
--- /dev/null
+++ b/plugin-examples/src/plugins/expiring-price-alerts/sample-data.ts
@@ -0,0 +1,94 @@
+import { LineData, UTCTimestamp } from 'lightweight-charts';
+
+const values = [
+ 22.75105, 22.7028, 22.91657, 22.89733, 22.58714, 22.79207, 22.4285, 22.12538,
+ 22.12127, 22.29012, 22.47304, 22.124, 22.62265, 22.27654, 22.42059, 22.21117,
+ 22.0404, 22.00526, 21.98259, 21.59455, 21.60589, 21.3791, 21.37931, 21.13736,
+ 21.09146, 20.95892, 21.061, 21.48838, 21.3507, 21.7702, 21.59947, 21.8053,
+ 21.88469, 21.74813, 21.60216, 21.37173, 21.3791, 21.45597, 21.50142, 21.44207,
+ 21.4204, 21.4408, 21.55359, 21.26584, 21.4968, 21.45966, 21.3538, 21.35801,
+ 21.34126, 21.43016, 21.19969, 21.0912, 21.07892, 21.05726, 21.22916, 21.17957,
+ 21.0097, 20.93522, 20.92252, 20.59067, 20.62412, 20.49867, 20.23011, 20.71467,
+ 20.66167, 20.50267, 20.42967, 20.20874, 20.39063, 20.49467, 20.40828,
+ 20.44799, 20.50617, 20.2971, 20.23607, 20.29511, 20.29441, 20.58167, 20.44466,
+ 20.43449, 20.38262, 20.40917, 20.24517, 20.00318, 20.03567, 19.95968, 20.0185,
+ 20.03165, 20.01767, 20.009, 19.88399, 19.62125, 19.64218, 19.58718, 19.68618,
+ 19.76699, 19.7022, 19.66419, 19.86817, 19.89925, 20.07668, 20.0164, 19.97018,
+ 20.40667, 20.46167, 20.93717, 20.73963, 20.97367, 20.80217, 20.78125,
+ 20.65367, 20.66471, 20.58367, 20.40367, 20.34212, 20.51013, 20.72114,
+ 20.83417, 20.6876, 20.88618, 20.86166, 20.76212, 20.57017, 20.45612, 20.38862,
+ 20.50012, 20.36022, 20.42267, 20.34317, 20.40212, 20.20812, 20.12014,
+ 20.08661, 20.37017, 20.27117, 20.38267, 20.57112, 20.63462, 20.51712,
+ 20.49662, 21.00767, 21.02463, 20.8812, 20.8221, 21.03767, 21.25467, 21.24567,
+ 21.45216, 21.23017, 20.99112, 20.74162, 20.76225, 20.64774, 20.64169,
+ 20.41452, 20.4991, 20.369, 20.3724, 20.42232, 20.42278, 20.64722, 20.659,
+ 20.487, 20.443, 20.31207, 20.2099, 20.253, 20.2158, 20.05311, 19.99909,
+ 19.93543, 19.995, 20.02418, 19.93575, 19.84162, 19.51315, 19.7631, 19.87511,
+ 19.90737, 19.81483, 19.77618, 19.7819, 19.82768, 19.97561, 19.7944, 19.93861,
+ 19.9987, 20.02961, 20.08661, 19.9376, 19.70901, 19.6249, 19.78662, 19.7815,
+ 19.8271, 19.84815, 19.8998, 19.94024, 19.84582, 19.89418, 19.80646, 19.7286,
+ 19.67346, 19.59882, 19.3981, 19.52426, 19.5284, 19.50768, 19.45412, 19.133,
+ 19.2128, 18.9867, 19.13962, 19.17998, 19.3486, 19.2539, 19.2824, 19.42142,
+ 19.38443, 19.59812, 19.6082, 19.8457, 19.8301, 19.89561, 19.8315, 19.74523,
+ 19.5896, 19.7819, 19.806, 19.7194, 19.8403, 19.6886, 19.71648, 19.80061,
+ 19.7486, 19.7085, 19.6782, 19.99051, 20.3173, 20.04726, 20.12367, 19.8798,
+ 19.8887, 19.85975, 19.9346, 20.23566, 20.3803, 20.42912, 20.4911, 20.48312,
+ 20.3258, 20.2319, 20.06612, 19.8821, 19.90975, 20.2144, 20.2576, 20.42986,
+ 20.46866, 20.28111, 20.3781, 20.3959, 20.53012, 20.4399, 20.5466, 20.69761,
+ 20.8134, 20.7169, 20.56397, 20.56436, 20.413, 20.2267, 20.12912, 19.9352,
+ 19.8157, 19.9533, 19.8117, 19.68147, 19.71811, 19.5188, 19.65362, 19.6144,
+ 19.56761, 19.712, 19.94311, 20.11861, 20.2737, 20.1426, 20.25974, 20.1544,
+ 20.23462, 20.4116, 20.4735, 20.402, 20.3606, 20.2686, 20.1415, 20.43947,
+ 20.3957, 20.34712, 20.32312, 20.3014, 20.48524, 20.3139, 20.19211, 20.2086,
+ 20.0495, 20.13312, 19.9941, 19.90511, 20.23912, 20.3622, 20.22447, 20.4146,
+ 20.677, 20.8501, 20.9158, 21.05812, 20.98412, 20.88212, 20.5114, 20.2944,
+ 20.22912, 20.34551, 20.64401, 20.41499, 20.56167, 20.4168, 20.83267, 20.87124,
+ 21.07966, 21.11825, 21.23516, 21.17262, 21.17117, 21.26562, 21.68819,
+ 21.49866, 21.12875, 21.2776, 21.1911, 21.26817, 21.04075, 20.98861, 20.71917,
+ 21.054, 21.17097, 21.16817, 21.27475, 21.1621, 21.23617, 20.99471, 20.79351,
+ 20.97217, 20.9955, 20.98875, 20.75619, 20.8541, 21.12849, 21.44499, 21.46674,
+ 21.52774, 21.38549, 21.60966, 21.49149, 21.24575, 21.14967, 21.28462,
+ 21.16367, 20.95416, 21.10482, 20.99775, 20.97867, 21.08117, 20.8621, 20.68235,
+ 20.43553, 20.57967, 20.53117, 20.64162, 20.65199, 20.88266, 20.66024,
+ 20.60367, 20.81067, 20.74746, 20.90295, 20.96825, 20.7816, 20.58375, 20.55561,
+ 20.58396, 20.45875, 20.44812, 20.35825, 20.35342, 20.5258, 20.56024, 20.4671,
+ 20.4961, 20.51725, 20.2908, 20.60257, 20.51649, 20.52325, 20.28075, 20.2275,
+ 20.10024, 19.97799, 19.76576, 19.67076, 19.59337, 19.65561, 19.63576,
+ 19.60676, 19.60862, 19.7086, 19.61876, 19.65261, 19.4346, 19.13526, 19.1797,
+ 19.19825, 19.02462, 19.01076, 19.19062, 19.1031, 19.13262, 19.19912, 19.1524,
+ 19.2408, 19.0645, 18.9816, 18.87626, 19.05776, 19.15226, 19.0539, 19.08175,
+ 19.4081, 19.62287, 20.03562, 20.0334, 19.98025, 19.87342, 19.90411, 19.86439,
+ 19.854, 20.08262, 19.70626, 19.51662, 19.74962, 19.7329, 19.98975, 19.84857,
+ 19.68925, 19.61076, 19.7883, 19.89333, 19.86425, 19.81625, 19.71683, 19.7418,
+ 19.80781, 19.60226, 19.76626, 19.76265, 19.658, 19.58296, 19.67433, 19.4153,
+ 19.43279, 19.3895, 19.30975, 19.34562, 19.15076, 19.36976, 19.4901, 19.53098,
+ 19.24061, 19.17408, 19.76511, 19.5626, 19.59857, 19.5196, 19.51463, 19.56075,
+ 19.3641, 19.41942, 19.58826, 19.66676, 19.81326, 19.7829, 19.76211, 19.76311,
+ 19.97766, 20.10525, 20.2722, 20.2716, 20.17135, 20.07311, 20.3157, 20.2264,
+ 20.39676, 20.4625, 20.50312, 20.3677, 20.1765, 20.2096, 20.35267, 20.2996,
+ 20.1336, 20.2238, 20.17076, 20.4563, 20.3733, 20.19123, 20.2265, 20.088,
+ 20.00712, 19.79225, 19.8315, 19.6296, 19.81115, 19.8027, 20.04111, 20.12554,
+ 20.0872, 19.97929, 20.06232, 19.96875, 19.8285, 19.7951, 19.7092, 19.80896,
+ 19.87468, 19.93682, 19.8848, 19.77461, 19.91834, 19.91344, 20.11499, 20.0363,
+ 19.98339, 20.04612, 20.15542, 20.14014, 20.0757, 20.20916, 20.15754, 19.9329,
+ 19.77726, 19.4439, 19.2639, 19.33584, 19.38604, 19.9823, 20.1534, 20.1505,
+ 20.2411, 20.0882, 20.06004, 20.0718, 19.9922, 19.9429, 20.29474, 20.64345,
+ 20.4973, 20.4995, 20.4925, 20.5518, 20.56334, 20.4018, 20.76615, 20.89018,
+ 20.8361, 20.7883, 21.0405, 21.0438, 20.91084, 20.75973, 20.53584, 20.3443,
+ 20.4036, 20.4276, 20.2046, 20.33574, 20.3885, 20.31212, 20.54012, 20.55962,
+ 20.59762, 20.4891, 20.4921, 20.47799, 20.43309, 20.5816, 20.6245, 20.49362,
+ 20.28501, 20.73262, 21.58076, 21.5319, 21.19974, 21.0166, 20.9161, 20.9851,
+ 21.2885, 21.54462, 21.30551, 21.30862, 21.28351, 21.387, 21.0151, 21.0401,
+ 20.7551, 20.6306, 20.46024, 20.5156, 20.62751, 20.54362, 20.42851, 20.48999,
+];
+
+export function sampleAlertLineData(): LineData[] {
+ const startDate = 1672617600;
+ const interval = 60;
+ return values.map((v, i) => {
+ return {
+ time: (startDate + i * interval) as UTCTimestamp,
+ value: v,
+ };
+ });
+}
diff --git a/plugin-examples/src/plugins/grouped-bars-series/data.ts b/plugin-examples/src/plugins/grouped-bars-series/data.ts
new file mode 100644
index 0000000000..5a88ec485c
--- /dev/null
+++ b/plugin-examples/src/plugins/grouped-bars-series/data.ts
@@ -0,0 +1,8 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * GroupedBars Series Data
+ */
+export interface GroupedBarsData extends CustomData {
+ values: number[];
+}
diff --git a/plugin-examples/src/plugins/grouped-bars-series/example/example.ts b/plugin-examples/src/plugins/grouped-bars-series/example/example.ts
new file mode 100644
index 0000000000..eba0d6c3a9
--- /dev/null
+++ b/plugin-examples/src/plugins/grouped-bars-series/example/example.ts
@@ -0,0 +1,25 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { GroupedBarsSeries } from '../grouped-bars-series';
+import { GroupedBarsData } from '../data';
+import { multipleBarData } from '../../../sample-data';
+import { CrosshairHighlightPrimitive } from '../../highlight-bar-crosshair/highlight-bar-crosshair';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ timeScale: {
+ barSpacing: 16,
+ minBarSpacing: 8,
+ },
+}));
+
+const customSeriesView = new GroupedBarsSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ color: 'black', // for the price line
+});
+
+const data: (GroupedBarsData | WhitespaceData)[] = multipleBarData(3, 200, 20);
+myCustomSeries.setData(data);
+myCustomSeries.attachPrimitive(
+ new CrosshairHighlightPrimitive({ color: 'rgba(0, 100, 200, 0.2)' })
+);
diff --git a/plugin-examples/src/plugins/grouped-bars-series/example/index.html b/plugin-examples/src/plugins/grouped-bars-series/example/index.html
new file mode 100644
index 0000000000..9e0922b48f
--- /dev/null
+++ b/plugin-examples/src/plugins/grouped-bars-series/example/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Lightweight Charts - Grouped Bars Series Plugin Example
+
+
+
+
+
+
Grouped Bars Series
+
+ A series containing multiple columns for each data point. The Highlight
+ Bar Crosshair primitive plugin is also applied to this example to help
+ define the bars for each data point.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/grouped-bars-series/grouped-bars-series.ts b/plugin-examples/src/plugins/grouped-bars-series/grouped-bars-series.ts
new file mode 100644
index 0000000000..8b8df7e23a
--- /dev/null
+++ b/plugin-examples/src/plugins/grouped-bars-series/grouped-bars-series.ts
@@ -0,0 +1,46 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { GroupedBarsSeriesOptions, defaultOptions } from './options';
+import { GroupedBarsSeriesRenderer } from './renderer';
+import { GroupedBarsData } from './data';
+
+export class GroupedBarsSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: GroupedBarsSeriesRenderer;
+
+ constructor() {
+ this._renderer = new GroupedBarsSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ return [
+ 0,
+ ...plotRow.values,
+ ];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return !Boolean((data as Partial).values?.length);
+ }
+
+ renderer(): GroupedBarsSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: GroupedBarsSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/grouped-bars-series/options.ts b/plugin-examples/src/plugins/grouped-bars-series/options.ts
new file mode 100644
index 0000000000..52e1121f1d
--- /dev/null
+++ b/plugin-examples/src/plugins/grouped-bars-series/options.ts
@@ -0,0 +1,19 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface GroupedBarsSeriesOptions extends CustomSeriesOptions {
+ colors: readonly string[];
+}
+
+export const defaultOptions: GroupedBarsSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ colors: [
+ '#2962FF',
+ '#E1575A',
+ '#F28E2C',
+ 'rgb(164, 89, 209)',
+ 'rgb(27, 156, 133)',
+ ],
+} as const;
diff --git a/plugin-examples/src/plugins/grouped-bars-series/renderer.ts b/plugin-examples/src/plugins/grouped-bars-series/renderer.ts
new file mode 100644
index 0000000000..d59636451c
--- /dev/null
+++ b/plugin-examples/src/plugins/grouped-bars-series/renderer.ts
@@ -0,0 +1,121 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { GroupedBarsData } from './data';
+import { GroupedBarsSeriesOptions } from './options';
+import {
+ positionsBox,
+ positionsLine,
+} from '../../helpers/dimensions/positions';
+
+/**
+ * Proof of Concept WIP.
+ * If we actually release this then we should use the tricks within
+ * the histogram renderer to get this pixel perfect.
+ */
+
+interface SingleBar {
+ x: number;
+ y: number;
+ color: string;
+}
+
+interface GroupedBarsBarItem {
+ singleBars: SingleBar[];
+ singleBarWidth: number;
+}
+
+export class GroupedBarsSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: GroupedBarsSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: GroupedBarsSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const barWidth = this._data.barSpacing;
+ const groups: GroupedBarsBarItem[] = this._data.bars.map(bar => {
+ const count = bar.originalData.values.length;
+ const singleBarWidth = barWidth / (count + 1);
+ const padding = singleBarWidth / 2;
+ const startX = padding + bar.x - barWidth / 2 + singleBarWidth / 2;
+ return {
+ singleBarWidth,
+ singleBars: bar.originalData.values.map((value, index) => ({
+ y: priceToCoordinate(value) ?? 0,
+ color: options.colors[index % options.colors.length],
+ x: startX + index * singleBarWidth,
+ })),
+ };
+ });
+
+ const zeroY = priceToCoordinate(0) ?? 0;
+ renderingScope.context.save();
+ for (
+ let i = this._data.visibleRange.from;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const group = groups[i];
+ let lastX: number;
+ group.singleBars.forEach(bar => {
+ const yPos = positionsBox(
+ zeroY,
+ bar.y,
+ renderingScope.verticalPixelRatio
+ );
+ const xPos = positionsLine(
+ bar.x,
+ renderingScope.horizontalPixelRatio,
+ group.singleBarWidth
+ );
+ renderingScope.context.beginPath();
+ renderingScope.context.fillStyle = bar.color;
+ const offset = lastX ? xPos.position - lastX : 0;
+ renderingScope.context.fillRect(
+ xPos.position - offset,
+ yPos.position,
+ xPos.length + offset,
+ yPos.length
+ );
+ lastX = xPos.position + xPos.length;
+ });
+ }
+ renderingScope.context.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/heatmap-series/bell-curve-data.ts b/plugin-examples/src/plugins/heatmap-series/bell-curve-data.ts
new file mode 100644
index 0000000000..959c27f81f
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/bell-curve-data.ts
@@ -0,0 +1,54 @@
+import { LineData } from 'lightweight-charts';
+import { HeatMapData } from './data';
+
+export function generateBellCurve(
+ center: number,
+ spread: number,
+ binSize: number
+) {
+ // Generate random data following a bell curve
+ const data: number[] = [];
+ for (let i = 0; i < 10000; i++) {
+ const value =
+ center +
+ spread *
+ (Math.sqrt(-2 * Math.log(Math.random())) *
+ Math.cos(2 * Math.PI * Math.random()));
+ data.push(value);
+ }
+
+ // Create histogram using the generated data
+ const histogram: Record = {};
+ data.forEach(value => {
+ const bin = Math.floor(value / binSize) * binSize;
+ histogram[bin] = (histogram[bin] || 0) + 1;
+ });
+
+ // Convert histogram object to arrays
+ const histogramData = Object.entries(histogram).map(([bin, frequency]) => ({
+ bin: parseFloat(bin),
+ frequency: frequency * (1 + (Math.random() - 0.5) * 0.5),
+ }));
+
+ return histogramData;
+}
+
+export function generateBellCurveHeatMapData(
+ lineData: LineData[],
+ spread: number,
+ binSize: number
+): HeatMapData[] {
+ return lineData.map(ldata => {
+ const curveData = generateBellCurve(ldata.value, spread, binSize);
+ return {
+ cells: curveData.map(curve => {
+ return {
+ amount: curve.frequency,
+ low: curve.bin,
+ high: curve.bin + binSize,
+ };
+ }),
+ time: ldata.time,
+ };
+ });
+}
diff --git a/plugin-examples/src/plugins/heatmap-series/data.ts b/plugin-examples/src/plugins/heatmap-series/data.ts
new file mode 100644
index 0000000000..f2df23a583
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/data.ts
@@ -0,0 +1,17 @@
+import { CustomData } from 'lightweight-charts';
+
+export interface HeatmapCell {
+ // Price for the lower edge of the heatmap cell
+ low: number;
+ // Price for the upper edge of the heatmap cell
+ high: number;
+ // Amount for the cell
+ amount: number;
+}
+
+/**
+ * HeatMap Series Data
+ */
+export interface HeatMapData extends CustomData {
+ cells: HeatmapCell[];
+}
diff --git a/plugin-examples/src/plugins/heatmap-series/example/example.ts b/plugin-examples/src/plugins/heatmap-series/example/example.ts
new file mode 100644
index 0000000000..c18adef7d9
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/example/example.ts
@@ -0,0 +1,41 @@
+import { createChart } from 'lightweight-charts';
+import { HeatMapSeries } from '../heatmap-series';
+import { HeatMapData } from '../data';
+import { generateHeatmapData } from '../sample-heatmap-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ timeScale: {
+ barSpacing: 24,
+ },
+ rightPriceScale: {
+ scaleMargins: {
+ top: 0.025,
+ bottom: 0.025,
+ },
+ },
+}));
+
+const heatmapData: HeatMapData[] = generateHeatmapData();
+const maxAmount = 36;
+
+function turboColor(t: number): string {
+ t = Math.max(0, Math.min(1, t));
+ const r = Math.max(0, Math.min(255, Math.round(34.61 + t * (1172.33 - t * (10793.56 - t * (33300.12 - t * (38394.49 - t * 14825.05)))))));
+ const g = Math.max(0, Math.min(255, Math.round(23.31 + t * (557.33 + t * (1225.33 - t * (3574.96 - t * (1073.77 + t * 707.56)))))));
+ const b = Math.max(0, Math.min(255, Math.round(27.2 + t * (3211.1 - t * (15327.97 - t * (27814 - t * (22569.18 - t * 6838.66)))))));
+ return `rgb(${r}, ${g}, ${b})`;
+}
+
+const cellShader = (amount: number) => {
+ return turboColor(amount / maxAmount);
+};
+
+const customSeriesView = new HeatMapSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ cellShader,
+ // cellBorderColor: 'white',
+});
+
+myCustomSeries.setData(heatmapData);
diff --git a/plugin-examples/src/plugins/heatmap-series/example/example2.html b/plugin-examples/src/plugins/heatmap-series/example/example2.html
new file mode 100644
index 0000000000..2da6900003
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/example/example2.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Lightweight Charts - HeatMap Series Plugin Example
+
+
+
+
+
+
Heat Map Series
+
+ Heat map series where each data point (time on the price scale) can have
+ multiple heat map cells defined for price ranges. This example is
+ showing heat map cells surrounding a line plot. This could be used to
+ represent orderbook or trading activity at various price points.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/heatmap-series/example/example2.ts b/plugin-examples/src/plugins/heatmap-series/example/example2.ts
new file mode 100644
index 0000000000..5181e08120
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/example/example2.ts
@@ -0,0 +1,63 @@
+import { createChart } from 'lightweight-charts';
+import { HeatMapSeries } from '../heatmap-series';
+import { HeatMapData, HeatmapCell } from '../data';
+import { generateLineData } from '../../../sample-data';
+import { generateBellCurveHeatMapData } from '../bell-curve-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineData = generateLineData(250);
+const heatmapData: (HeatMapData)[] = generateBellCurveHeatMapData(
+ lineData,
+ 20,
+ 5
+);
+const maxAmount = heatmapData.reduce(
+ (currentMax: number, dataPoint: HeatMapData) => {
+ const maxCellAmount = dataPoint.cells.reduce(
+ (cellsMax: number, cell: HeatmapCell) => {
+ if (cell.amount > cellsMax) return cell.amount;
+ return cellsMax;
+ },
+ 0
+ );
+ if (maxCellAmount > currentMax) return maxCellAmount;
+ return currentMax;
+ },
+ 0
+);
+
+// function turboColor(t: number): string {
+// t = Math.max(0, Math.min(1, t));
+// const r = Math.max(0, Math.min(255, Math.round(34.61 + t * (1172.33 - t * (10793.56 - t * (33300.12 - t * (38394.49 - t * 14825.05)))))));
+// const g = Math.max(0, Math.min(255, Math.round(23.31 + t * (557.33 + t * (1225.33 - t * (3574.96 - t * (1073.77 + t * 707.56)))))));
+// const b = Math.max(50, Math.min(255, Math.round(27.2 + t * (3211.1 - t * (15327.97 - t * (27814 - t * (22569.18 - t * 6838.66)))))));
+// return `rgba(${r}, ${g}, ${b}, ${t * 5})`;
+// }
+
+// const cellShader = (amount: number) => {
+// return turboColor(amount / maxAmount);
+// };
+const cellShader = (amount: number) => {
+ const amt = 100 * (amount / maxAmount);
+ const r = 155 - amt;
+ const g = 0;
+ const b = 155 + amt;
+ return `rgba(${r}, ${g}, ${b}, ${0.05 + amt * 0.010})`;
+}
+
+const customSeriesView = new HeatMapSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ cellShader,
+ cellBorderWidth: 0,
+});
+
+myCustomSeries.setData(heatmapData);
+
+const lineSeries = chart.addLineSeries({
+ color: 'black',
+});
+lineSeries.setData(lineData);
diff --git a/plugin-examples/src/plugins/heatmap-series/example/index.html b/plugin-examples/src/plugins/heatmap-series/example/index.html
new file mode 100644
index 0000000000..6ac0792f81
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - HeatMap Series Plugin Example
+
+
+
+
+
+
Heat Map Series
+
+ Heat map series where each data point (time on the price scale) can have
+ multiple heat map cells defined for price ranges.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/heatmap-series/heatmap-series.ts b/plugin-examples/src/plugins/heatmap-series/heatmap-series.ts
new file mode 100644
index 0000000000..b1ab754ead
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/heatmap-series.ts
@@ -0,0 +1,53 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { HeatMapSeriesOptions, defaultOptions } from './options';
+import { HeatMapSeriesRenderer } from './renderer';
+import { HeatMapData } from './data';
+
+export class HeatMapSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: HeatMapSeriesRenderer;
+
+ constructor() {
+ this._renderer = new HeatMapSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ if (plotRow.cells.length < 1) {
+ return [NaN];
+ }
+ let low = Infinity;
+ let high = - Infinity;
+ plotRow.cells.forEach(cell => {
+ if (cell.low < low) low = cell.low;
+ if (cell.high > high) high = cell.high;
+ });
+ const mid = low + (high - low) / 2;
+ return [low, high, mid];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).cells === undefined || (data as Partial).cells!.length < 1;
+ }
+
+ renderer(): HeatMapSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: HeatMapSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/heatmap-series/options.ts b/plugin-examples/src/plugins/heatmap-series/options.ts
new file mode 100644
index 0000000000..024f18c338
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/options.ts
@@ -0,0 +1,26 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export type HeatMapCellShader = (amount: number) => string;
+
+export interface HeatMapSeriesOptions extends CustomSeriesOptions {
+ lastValueVisible: false;
+ priceLineVisible: false;
+ cellShader: HeatMapCellShader;
+ cellBorderWidth: number;
+ cellBorderColor: string;
+}
+
+export const defaultOptions: HeatMapSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ lastValueVisible: false,
+ priceLineVisible: false,
+ cellShader: (amount: number) => {
+ const amt = Math.min(Math.max(0, amount), 100);
+ return `rgba(0, ${100 + amt * 1.55}, ${0 + amt}, ${0.2 + amt * 0.8})`;
+ },
+ cellBorderWidth: 1,
+ cellBorderColor: 'transparent',
+} as const;
diff --git a/plugin-examples/src/plugins/heatmap-series/renderer.ts b/plugin-examples/src/plugins/heatmap-series/renderer.ts
new file mode 100644
index 0000000000..260928062d
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/renderer.ts
@@ -0,0 +1,122 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { HeatMapData } from './data';
+import { HeatMapSeriesOptions } from './options';
+import { fullBarWidth } from '../../helpers/dimensions/full-width';
+import { positionsBox } from '../../helpers/dimensions/positions';
+
+interface HeatMapBarItemCell {
+ low: number;
+ high: number;
+ amount: number;
+}
+
+interface HeatMapBarItem {
+ x: number;
+ cells: HeatMapBarItemCell[];
+}
+
+export class HeatMapSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: HeatMapSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: HeatMapSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const bars: HeatMapBarItem[] = this._data.bars.map(bar => {
+ return {
+ x: bar.x,
+ cells: bar.originalData.cells.map(cell => {
+ return {
+ amount: cell.amount,
+ low:
+ priceToCoordinate(cell.low)!,
+ high:
+ priceToCoordinate(cell.high)!,
+ };
+ }),
+ };
+ });
+ const drawBorder = this._data.barSpacing > options.cellBorderWidth * 3;
+
+ renderingScope.context.save();
+ for (
+ let i = this._data.visibleRange.from;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ const fullWidth = fullBarWidth(
+ bar.x,
+ this._data.barSpacing / 2,
+ renderingScope.horizontalPixelRatio
+ );
+ const borderWidthHorizontal = drawBorder ? options.cellBorderWidth * renderingScope.horizontalPixelRatio : 0;
+ const borderWidthVertical = drawBorder ? options.cellBorderWidth * renderingScope.verticalPixelRatio : 0;
+ for (const cell of bar.cells) {
+ const verticalDimension = positionsBox(
+ cell.low,
+ cell.high,
+ renderingScope.verticalPixelRatio
+ );
+ renderingScope.context.fillStyle = options.cellShader(cell.amount);
+ renderingScope.context.fillRect(
+ fullWidth.position + borderWidthHorizontal,
+ verticalDimension.position + borderWidthVertical,
+ fullWidth.length - borderWidthHorizontal * 2,
+ verticalDimension.length - 1 - borderWidthVertical * 2
+ );
+ if (drawBorder && options.cellBorderWidth && options.cellBorderColor !== 'transparent') {
+ renderingScope.context.beginPath();
+ renderingScope.context.rect(
+ fullWidth.position + borderWidthHorizontal / 2,
+ verticalDimension.position + borderWidthVertical / 2,
+ fullWidth.length - borderWidthHorizontal,
+ verticalDimension.length - 1 - borderWidthVertical
+ );
+ renderingScope.context.strokeStyle = options.cellBorderColor;
+ renderingScope.context.lineWidth = borderWidthHorizontal;
+ renderingScope.context.stroke();
+ }
+ }
+ }
+ renderingScope.context.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/heatmap-series/sample-heatmap-data.ts b/plugin-examples/src/plugins/heatmap-series/sample-heatmap-data.ts
new file mode 100644
index 0000000000..3412d501c8
--- /dev/null
+++ b/plugin-examples/src/plugins/heatmap-series/sample-heatmap-data.ts
@@ -0,0 +1,169 @@
+import { Time } from 'lightweight-charts';
+import { HeatMapData } from './data';
+
+const rawData = [
+ [12.8, 10.6, 11.7, 12.2, 8.9, 4.4, 7.2, 10, 9.4, 6.1],
+ [6.1, 6.1, 5, 4.4, 1.1, 1.7, 3.3, 0, -1.1, 7.2],
+ [8.3, 6.7, 8.3, 10, 8.9, 8.9, 6.7, 6.7, 9.4, 8.3],
+ [9.4, 8.9, 8.3, 14.4, 15.6, 13.9, 16.1, 15.6, 10, 11.1],
+ [12.8, 8.9, 8.3, 7.2, 6.7, 7.2, 7.2, 10, 6.7, 6.7],
+ [7.8, 10, 10, 8.3, 6.7, 7.2, 5, 6.7, 6.7, 5],
+ [6.1, 6.7, 12.2, 10.6, 7.8, 6.7, 8.9, 15.6, 9.4, 7.2],
+ [6.7, 8.3, 5.6, 7.8, 11.1, 8.9, 10, 5, 7.2, 7.8],
+ [8.9, 10, 12.2, 15, 13.3, 12.8, 14.4, 10.6, 10, 9.4],
+ [10, 8.9, 16.7, 11.7, 10.6, 9.4, 11.1, 16.1, 21.1, 20],
+ [17.8, 11.1, 13.9, 15, 15.6, 16.1, 13.3, 10, 13.3, 13.9],
+ [13.3, 20, 23.3, 21.7, 13.9, 16.7, 13.9, 13.3, 16.1, 15.6],
+ [12.8, 11.7, 13.3, 11.1, 12.2, 13.3, 17.8, 23.9, 18.3, 13.3],
+ [14.4, 18.3, 24.4, 25.6, 26.7, 24.4, 19.4, 17.8, 15.6, 19.4],
+ [14.4, 16.7, 12.8, 14.4, 17.2, 22.2, 22.2, 17.2, 16.7, 16.1],
+ [18.9, 17.8, 20, 18.9, 17.2, 12.8, 13.3, 16.1, 16.1, 15],
+ [17.2, 18.9, 23.3, 18.3, 16.1, 17.2, 22.2, 21.1, 18.9, 17.2],
+ [19.4, 24.4, 23.9, 13.9, 15.6, 19.4, 19.4, 18.3, 22.8, 22.2],
+ [21.7, 20, 20, 18.9, 18.3, 20.6, 24.4, 25, 26.7, 28.3],
+ [25, 23.9, 27.8, 25.6, 23.3, 25, 18.9, 26.1, 21.7, 21.1],
+ [25, 19.4, 23.9, 20.6, 18.9, 23.3, 26.7, 25.6, 18.9, 22.2],
+ [22.8, 19.4, 22.8, 23.9, 23.3, 27.2, 33.9, 33.9, 28.3, 21.1],
+ [22.2, 24.4, 25.6, 28.3, 30.6, 30.6, 28.9, 31.1, 34.4, 32.8],
+ [21.7, 23.3, 25.6, 23.3, 22.2, 21.1, 22.2, 26.1, 21.1, 23.9],
+ [22.8, 22.8, 22.8, 22.2, 21.7, 21.1, 22.8, 24.4, 26.1, 28.3],
+ [32.2, 25, 18.9, 20, 20, 22.2, 27.8, 26.1, 22.2, 24.4],
+ [27.8, 27.8, 23.9, 19.4, 16.1, 19.4, 19.4, 21.1, 19.4, 19.4],
+ [22.8, 25, 20.6, 21.1, 23.3, 17.8, 18.9, 18.9, 21.7, 23.9],
+ [23.9, 21.1, 16.1, 12.2, 13.9, 13.9, 15.6, 17.8, 17.2, 16.1],
+ [14.4, 17.8, 15, 11.1, 11.7, 7.8, 11.1, 11.7, 11.7, 11.1],
+ [14.4, 14.4, 15.6, 15, 15.6, 15, 15, 15.6, 17.8, 15],
+ [12.8, 12.2, 10, 8.9, 7.8, 8.9, 12.8, 11.1, 11.1, 9.4],
+ [9.4, 12.2, 10, 13.3, 11.1, 8.3, 8.9, 9.4, 8.9, 8.3],
+ [9.4, 10, 9.4, 12.8, 15, 13.3, 8.3, 9.4, 11.7, 8.9],
+ [7.2, 7.8, 6.7, 6.7, 7.2, 7.8, 6.7, 7.2, 6.1, 4.4],
+ [6.7, 8.3, 3.9, 8.3, 7.2, 8.3, 8.3, 7.2, 5.6, 5.6],
+ [6.7, 7.8, 8.3, 5, 4.4, 3.3, 5, 6.1, 6.7, 10],
+ [6.7, 7.2, 10, 11.7, 10, 3.3, 2.8, 2.8, 2.2, 3.3],
+ [6.7, 6.1, 3.9, 3.3, 1.1, 3.3, 2.2, 3.3, 7.2, 7.2],
+ [10.6, 8.3, 5.6, 6.1, 8.3, 8.9, 9.4, 11.7, 6.1, 8.9],
+ [10.6, 10, 10.6, 9.4, 7.8, 8.3, 8.9, 8.3, 11.1, 9.4],
+ [9.4, 13.3, 11.1, 9.4, 7.8, 10.6, 7.8, 6.7, 7.8, 10],
+ [8.9, 10.6, 8.9, 10, 11.7, 15, 13.9, 11.1, 13.3, 9.4],
+ [7.2, 12.2, 11.7, 12.8, 7.8, 10.6, 12.8, 11.7, 11.7, 14.4],
+ [10.6, 8.9, 11.7, 12.8, 11.1, 10, 9.4, 10, 12.2, 16.7],
+ [16.7, 13.3, 16.1, 18.3, 20, 20.6, 17.2, 13.9, 16.7, 14.4],
+ [13.9, 12.2, 8.3, 13.3, 12.2, 15, 12.2, 7.8, 10.6, 12.8],
+ [13.9, 13.9, 15, 11.7, 13.3, 13.9, 12.2, 16.1, 17.8, 21.1],
+ [21.7, 20.6, 13.9, 15, 13.9, 12.8, 18.3, 20.6, 21.7, 25],
+ [28.9, 30.6, 20.6, 19.4, 22.8, 26.1, 27.2, 21.7, 18.9, 18.3],
+ [17.2, 21.7, 17.2, 16.7, 18.3, 19.4, 15.6, 11.1, 12.2, 16.7],
+ [17.8, 18.3, 16.7, 17.2, 16.1, 16.7, 19.4, 22.8, 20.6, 22.2],
+ [26.1, 26.7, 26.7, 21.7, 20.6, 20.6, 21.7, 20, 20.6, 21.1],
+ [20, 25.6, 23.9, 25.6, 23.3, 20, 17.2, 20.6, 25.6, 22.2],
+ [21.1, 23.3, 22.2, 21.1, 30.6, 30, 33.9, 31.7, 28.3, 26.1],
+ [21.7, 23.3, 26.1, 23.9, 26.7, 30, 22.2, 22.8, 19.4, 26.1],
+ [27.8, 27.8, 31.1, 22.2, 26.1, 27.8, 25, 23.9, 26.1, 31.1],
+ [31.1, 31.1, 31.1, 25.6, 21.1, 25, 25, 21.7, 20.6, 17.2],
+ [25, 28.9, 30, 30.6, 31.1, 28.3, 28.3, 25.6, 25, 25.6],
+ [27.8, 27.2, 21.1, 28.9, 25.6, 26.1, 26.7, 25.6, 27.8, 28.9],
+ [25, 25, 22.2, 24.4, 26.7, 26.7, 23.9, 26.1, 27.8, 27.8],
+ [27.8, 25, 22.8, 20, 21.7, 23.3, 26.7, 26.1, 26.7, 33.9],
+ [25.6, 18.9, 21.7, 18.9, 21.7, 17.8, 21.1, 25.6, 23.3, 21.1],
+ [17.2, 16.1, 17.8, 16.1, 17.2, 13.9, 16.7, 14.4, 13.9, 14.4],
+ [12.8, 14.4, 17.8, 20, 22.8, 16.1, 13.9, 15, 14.4, 13.9],
+ [14.4, 15, 15.6, 15.6, 12.8, 14.4, 12.8, 10.6, 10.6, 11.7],
+ [14.4, 12.8, 10, 12.2, 11.7, 13.9, 14.4, 13.3, 15, 14.4],
+ [17.8, 14.4, 12.2, 10.6, 13.3, 12.8, 11.1, 13.3, 11.1, 11.1],
+ [16.1, 15.6, 13.9, 11.1, 10.6, 10, 11.7, 12.8, 13.3, 7.8],
+ [7.8, 9.4, 11.1, 11.7, 12.2, 12.2, 14.4, 11.7, 9.4, 11.1],
+ [13.3, 7.8, 5, 4.4, 1.1, 1.1, 0, 2.2, 1.1, 5.6],
+ [5, 5.6, 9.4, 9.4, 11.7, 10, 8.3, 7.8, 5, 8.3],
+ [8.9, 10.6, 11.7, 8.3, 6.7, 6.7, 8.9, 9.4, 7.2, 8.9],
+ [8.3, 7.2, 10.6, 8.9, 7.8, 8.3, 7.8, 8.3, 10, 9.4],
+ [12.8, 14.4, 11.1, 10.6, 11.1, 11.1, 6.7, 5.6, 9.4, 6.1],
+ [10, 10, 9.4, 10, 12.8, 12.2, 8.3, 9.4, 11.1, 11.1],
+ [8.3, 7.8, 7.8, 8.9, 5, 2.8, -0.5, -1.6, 3.3, 5.6],
+ [3.9, 10, 12.2, 12.2, 12.8, 11.7, 11.1, 9.4, 8.3, 8.9],
+ [8.3, 10, 6.7, 5.6, 7.2, 6.7, 12.2, 13.9, 12.8, 14.4],
+ [7.2, 11.1, 14.4, 13.9, 15.6, 13.3, 15.6, 12.8, 15, 12.2],
+ [14.4, 16.1, 13.9, 14.4, 16.7, 10.6, 10, 10, 11.1, 11.1],
+ [10.6, 11.1, 12.8, 18.9, 13.9, 11.1, 12.2, 11.7, 11.7, 11.1],
+ [15.6, 14.4, 14.4, 13.3, 12.8, 11.7, 13.9, 21.1, 15.6, 14.4],
+ [15, 17.2, 16.1, 20.6, 20, 14.4, 11.1, 11.7, 14.4, 11.7],
+ [15.6, 17.2, 12.2, 11.7, 13.9, 14.4, 15, 11.1, 16.1, 25],
+ [27.8, 29.4, 18.3, 15, 14.4, 15.6, 16.7, 18.3, 13.9, 13.3],
+ [15.6, 18.9, 24.4, 26.7, 27.8, 26.7, 20, 20, 20, 21.1],
+ [22.2, 20, 24.4, 20, 18.3, 15, 18.3, 20, 18.9, 18.9],
+ [20.6, 23.3, 22.2, 23.3, 18.3, 19.4, 22.2, 25, 24.4, 23.3],
+ [21.1, 20, 23.9, 21.7, 15.6, 17.8, 18.3, 17.8, 17.8, 18.9],
+ [25.6, 20, 22.2, 25, 25, 24.4, 26.1, 21.1, 21.1, 20],
+ [20.6, 25.6, 34.4, 27.2, 21.7, 23.9, 24.4, 28.9, 27.2, 30],
+ [26.7, 28.9, 31.1, 32.2, 29.4, 27.8, 31.1, 31.1, 26.7, 23.9],
+ [25.6, 19.4, 23.9, 21.1, 18.9, 20.6, 22.8, 26.1, 28.3, 30.6],
+ [30, 29.4, 30.6, 28.9, 29.4, 31.7, 32.8, 25, 26.1, 25.6],
+ [25.6, 27.2, 30.6, 35.6, 27.2, 23.3, 21.1, 24.4, 25.6, 27.8],
+ [29.4, 27.2, 21.7, 21.1, 23.9, 27.8, 25, 28.9, 31.1, 28.9],
+ [23.3, 22.8, 17.8, 21.1, 23.3, 20, 20.6, 23.9, 27.8, 32.2],
+ [28.3, 21.1, 21.7, 22.2, 24.4, 24.4, 28.3, 30, 30.6, 22.2],
+ [22.8, 19.4, 23.9, 24.4, 26.1, 22.2, 18.9, 18.9, 21.7, 20],
+ [20.6, 18.9, 16.7, 19.4, 18.3, 19.4, 22.2, 21.7, 23.9, 25.6],
+ [18.9, 20.6, 17.2, 18.3, 18.3, 17.8, 21.1, 16.7, 16.1, 20.6],
+ [16.7, 19.4, 22.2, 16.1, 16.1, 15.6, 14.4, 14.4, 16.7, 12.8],
+ [15.6, 15, 16.7, 15.6, 12.8, 11.1, 13.3, 13.9, 14.4, 15],
+ [16.7, 14.4, 12.8, 13.3, 11.1, 7.8, 6.7, 7.2, 7.2, 8.3],
+ [9.4, 10.6, 7.2, 11.1, 11.1, 11.1, 9.4, 12.8, 11.7, 13.9],
+ [15, 14.4, 12.8, 4.4, 2.8, 4.4, 5.6, 10, 8.3, 12.8],
+ [11.7, 14.4, 14.4, 16.1, 18.9, 14.4, 11.1, 10, 12.8, 12.2],
+ [10, 8.9, 9.4, 11.1, 12.8, 12.8, 10.6, 12.2, 7.2, 7.8],
+ [5.6, 9.4, 6.7, 6.1, 3.3, 3.3, 5.6, 5.6, 5, 10.6],
+ [12.2, 12.2, 7.8, 7.8, 10, 7.8, 9.4, 11.1, 9.4, 6.1],
+ [7.8, 11.7, 13.3, 13.9, 10, 10, 7.2, 9.4, 12.2, 14.4],
+ [17.2, 16.1, 11.1, 12.2, 12.2, 8.3, 7.2, 9.4, 11.1, 10],
+ [10.6, 13.3, 14.4, 12.2, 15, 13.3, 12.8, 12.8, 16.7, 15.6],
+ [14.4, 12.2, 15, 16.1, 12.2, 10.6, 11.1, 12.2, 11.7, 12.8],
+ [11.1, 10, 11.7, 10, 12.2, 11.1, 11.1, 10.6, 12.8, 13.3],
+ [15, 16.7, 17.2, 14.4, 13.3, 14.4, 17.8, 17.2, 13.9, 10.6],
+ [13.9, 13.3, 15.6, 15.6, 13.9, 13.3, 11.7, 11.1, 12.8, 14.4],
+ [20.6, 18.3, 15.6, 15.6, 17.8, 12.8, 12.8, 13.3, 11.1, 12.8],
+ [16.7, 13.9, 14.4, 17.2, 17.2, 13.9, 11.7, 13.3, 11.7, 11.7],
+ [13.9, 17.8, 18.9, 18.9, 21.1, 22.8, 17.2, 15.6, 12.2, 12.2],
+ [13.3, 15.6, 25, 15.6, 16.1, 17.2, 18.3, 18.3, 20.6, 17.2],
+ [14.4, 16.7, 20.6, 23.9, 26.7, 19.4, 13.9, 15.6, 12.2, 17.8],
+ [20, 15.6, 19.4, 25.6, 21.7, 23.3, 25.6, 16.7, 16.1, 17.8],
+ [15.6, 21.7, 24.4, 27.8, 26.1, 22.8, 25, 16.1, 17.8, 20],
+ [22.8, 26.7, 29.4, 31.1, 30.6, 28.9, 25.6, 24.4, 20, 23.9],
+ [27.8, 30, 22.8, 25, 24.4, 23.9, 25, 25.6, 25, 26.1],
+ [25.6, 30.6, 31.7, 33.3, 28.3, 28.9, 30.6, 32.2, 33.9, 33.3],
+ [33.3, 32.8, 29.4, 27.2, 30, 28.9, 21.1, 22.2, 26.1, 25.6],
+ [27.8, 26.1, 26.1, 27.8, 33.3, 35, 26.7, 23.9, 23.9, 26.1],
+ [22.8, 21.1, 22.2, 23.3, 27.8, 32.2, 34.4, 34.4, 33.3, 30.6],
+ [28.3, 26.1, 23.3, 25, 28.3, 25, 28.3, 28.9, 30, 28.3],
+ [28.3, 18.3, 21.7, 25, 27.2, 30, 31.7, 22.8, 22.2, 26.7],
+ [27.8, 23.9, 25.6, 28.3, 29.4, 23.3, 22.2, 20, 18.9, 19.4],
+ [19.4, 18.3, 18.3, 20.6, 16.1, 21.1, 22.8, 24.4, 25, 27.2],
+ [26.7, 20.6, 16.7, 17.8, 20, 18.3, 19.4, 21.1, 22.8, 18.3],
+ [18.9, 20.6, 22.2, 15.6, 18.3, 17.8, 21.1, 21.7, 18.3, 21.1],
+ [15.6, 19.4, 22.8, 23.3, 18.3, 16.1, 18.9, 19.4, 21.1, 17.8],
+ [18.3, 16.7, 15, 21.1, 20, 19.4, 15, 17.2, 17.8, 16.1],
+ [16.1, 12.8, 15, 19.4, 12.2, 16.1, 13.9, 15, 17.2, 15.6],
+ [12.2, 11.1, 10.6, 10, 11.7, 15.6, 12.2, 11.1, 10, 11.1],
+ [11.1, 11.1, 13.3, 9.4, 8.9, 8.9, 13.3, 8.9, 8.9, 8.3],
+ [8.9, 10, 6.7, 6.7, 7.2, 9.4, 9.4, 7.2, 1.7, 5.6],
+ [10, 10.6, 15.6, 10.6, 10, 12.8, 11.1, 15.6, 12.2, 11.7],
+ [9.4, 8.9, 7.8, 7.8, 6.7, 6.1, 6.7, 8.9, 8.3, 7.8],
+ [5.6, 7.8, 5, 5.6, 5, 4.4, 4.4, 5, 7.2, 5.6],
+];
+
+export function generateHeatmapData(): HeatMapData[] {
+ const date = new Date(Date.UTC(2018, 0, 1, 12, 0, 0, 0));
+ return rawData.map(data => {
+ const time = (date.getTime() / 1000) as Time;
+ date.setUTCDate(date.getUTCDate() + 1);
+ return {
+ time,
+ cells: data.map((cellAmount: number, index: number) => {
+ return {
+ high: 10 + index * 10,
+ low: 0 + index * 10,
+ amount: cellAmount,
+ };
+ }),
+ };
+ });
+}
diff --git a/plugin-examples/src/plugins/highlight-bar-crosshair/example/example.ts b/plugin-examples/src/plugins/highlight-bar-crosshair/example/example.ts
new file mode 100644
index 0000000000..405f12620d
--- /dev/null
+++ b/plugin-examples/src/plugins/highlight-bar-crosshair/example/example.ts
@@ -0,0 +1,16 @@
+import { createChart } from 'lightweight-charts';
+import { generateAlternativeCandleData } from '../../../sample-data';
+import { CrosshairHighlightPrimitive } from '../highlight-bar-crosshair';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const candleSeries = chart.addCandlestickSeries();
+candleSeries.setData(generateAlternativeCandleData());
+
+const highlightPrimitive = new CrosshairHighlightPrimitive({
+ color: 'rgba(0, 50, 100, 0.2)',
+});
+
+candleSeries.attachPrimitive(highlightPrimitive);
diff --git a/plugin-examples/src/plugins/highlight-bar-crosshair/example/index.html b/plugin-examples/src/plugins/highlight-bar-crosshair/example/index.html
new file mode 100644
index 0000000000..b734d565f3
--- /dev/null
+++ b/plugin-examples/src/plugins/highlight-bar-crosshair/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - Highlight Bar Crosshair Plugin Example
+
+
+
+
+
+
Highlight Bar Crosshair
+
+ Shades the background of the data point below the mouse cursor. The
+ Highlight bar width matches the bar spacing.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/highlight-bar-crosshair/highlight-bar-crosshair.ts b/plugin-examples/src/plugins/highlight-bar-crosshair/highlight-bar-crosshair.ts
new file mode 100644
index 0000000000..2e17b7bf8d
--- /dev/null
+++ b/plugin-examples/src/plugins/highlight-bar-crosshair/highlight-bar-crosshair.ts
@@ -0,0 +1,180 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ CrosshairMode,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ MouseEventParams,
+ SeriesPrimitivePaneViewZOrder,
+ ISeriesPrimitive,
+ SeriesAttachedParameter,
+ Time,
+} from 'lightweight-charts';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+class CrosshairHighlightPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: CrosshairHighlightData;
+
+ constructor(data: CrosshairHighlightData) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ if (!this._data.visible) return;
+ target.useBitmapCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ const crosshairPos = positionsLine(
+ this._data.x,
+ scope.horizontalPixelRatio,
+ Math.max(1, this._data.barSpacing)
+ );
+ ctx.fillStyle = this._data.color;
+ ctx.fillRect(
+ crosshairPos.position,
+ 0,
+ crosshairPos.length,
+ scope.bitmapSize.height
+ );
+ ctx.restore();
+ });
+ }
+}
+
+class CrosshairHighlightPaneView implements ISeriesPrimitivePaneView {
+ _data: CrosshairHighlightData;
+ constructor(data: CrosshairHighlightData) {
+ this._data = data;
+ }
+
+ update(data: CrosshairHighlightData): void {
+ this._data = data;
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer | null {
+ return new CrosshairHighlightPaneRenderer(this._data);
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'bottom';
+ }
+}
+
+interface CrosshairHighlightData {
+ x: number;
+ visible: boolean;
+ color: string;
+ barSpacing: number;
+}
+
+const defaultOptions: HighlightBarCrosshairOptions = {
+ color: 'rgba(0, 0, 0, 0.2)',
+};
+
+export interface HighlightBarCrosshairOptions {
+ color: string;
+}
+
+export class CrosshairHighlightPrimitive implements ISeriesPrimitive {
+ private _options: HighlightBarCrosshairOptions;
+ _paneViews: CrosshairHighlightPaneView[];
+ _data: CrosshairHighlightData = {
+ x: 0,
+ visible: false,
+ color: 'rgba(0, 0, 0, 0.2)',
+ barSpacing: 6,
+ };
+ _attachedParams: SeriesAttachedParameter | undefined;
+
+ constructor(options: Partial) {
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._paneViews = [new CrosshairHighlightPaneView(this._data)];
+ }
+
+ attached(param: SeriesAttachedParameter): void {
+ this._attachedParams = param;
+ this._setCrosshairMode();
+ param.chart.subscribeCrosshairMove(this._moveHandler);
+ }
+
+ detached(): void {
+ const chart = this.chart();
+ if (chart) {
+ chart.unsubscribeCrosshairMove(this._moveHandler);
+ }
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update(this._data));
+ }
+
+ setData(data: CrosshairHighlightData) {
+ this._data = data;
+ this.updateAllViews();
+ this._attachedParams?.requestUpdate();
+ }
+
+ currentColor() {
+ return this._options.color;
+ }
+
+ chart() {
+ return this._attachedParams?.chart;
+ }
+
+ // We need to disable magnet mode for this to work nicely
+ _setCrosshairMode() {
+ const chart = this.chart();
+ if (!chart) {
+ throw new Error(
+ 'Unable to change crosshair mode because the chart instance is undefined'
+ );
+ }
+ chart.applyOptions({
+ crosshair: {
+ mode: CrosshairMode.Normal,
+ vertLine: {
+ visible: false,
+ },
+ },
+ });
+ }
+
+ private _moveHandler = (param: MouseEventParams) => this._onMouseMove(param);
+
+ private _barSpacing(): number {
+ const chart = this.chart();
+ if (!chart) return 6;
+ const ts = chart.timeScale();
+ const visibleLogicalRange = ts.getVisibleLogicalRange();
+ if (!visibleLogicalRange) return 6;
+ return ts.width() / (visibleLogicalRange.to - visibleLogicalRange.from);
+ }
+
+ private _onMouseMove(param: MouseEventParams) {
+ const chart = this.chart();
+ const logical = param.logical;
+ if (!logical || !chart) {
+ this.setData({
+ x: 0,
+ visible: false,
+ color: this.currentColor(),
+ barSpacing: this._barSpacing(),
+ });
+ return;
+ }
+ const coordinate = chart.timeScale().logicalToCoordinate(logical);
+ this.setData({
+ x: coordinate ?? 0,
+ visible: coordinate !== null,
+ color: this.currentColor(),
+ barSpacing: this._barSpacing(),
+ });
+ }
+}
diff --git a/plugin-examples/src/plugins/hlc-area-series/data.ts b/plugin-examples/src/plugins/hlc-area-series/data.ts
new file mode 100644
index 0000000000..83eed53f4f
--- /dev/null
+++ b/plugin-examples/src/plugins/hlc-area-series/data.ts
@@ -0,0 +1,10 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * HLCArea Series Data
+ */
+export interface HLCAreaData extends CustomData {
+ high: number;
+ low: number;
+ close: number;
+}
diff --git a/plugin-examples/src/plugins/hlc-area-series/example/example.ts b/plugin-examples/src/plugins/hlc-area-series/example/example.ts
new file mode 100644
index 0000000000..fee816378b
--- /dev/null
+++ b/plugin-examples/src/plugins/hlc-area-series/example/example.ts
@@ -0,0 +1,16 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { HLCAreaSeries } from '../hlc-area-series';
+import { HLCAreaData } from '../data';
+import { generateAlternativeCandleData } from '../../../sample-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const customSeriesView = new HLCAreaSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+});
+
+const data: (HLCAreaData | WhitespaceData)[] = generateAlternativeCandleData(100);
+myCustomSeries.setData(data);
diff --git a/plugin-examples/src/plugins/hlc-area-series/example/index.html b/plugin-examples/src/plugins/hlc-area-series/example/index.html
new file mode 100644
index 0000000000..03f80292d8
--- /dev/null
+++ b/plugin-examples/src/plugins/hlc-area-series/example/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Lightweight Charts - HLCArea Series Plugin Example
+
+
+
+
+
+
HLC Area Series
+
+ HLC (High Low Close) Area Series. A line for each of the high, low, and
+ close values is plotted whilst the areas are filled between the close
+ value and the high and low values.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/hlc-area-series/hlc-area-series.ts b/plugin-examples/src/plugins/hlc-area-series/hlc-area-series.ts
new file mode 100644
index 0000000000..8349667c75
--- /dev/null
+++ b/plugin-examples/src/plugins/hlc-area-series/hlc-area-series.ts
@@ -0,0 +1,43 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { HLCAreaSeriesOptions, defaultOptions } from './options';
+import { HLCAreaSeriesRenderer } from './renderer';
+import { HLCAreaData } from './data';
+
+export class HLCAreaSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: HLCAreaSeriesRenderer;
+
+ constructor() {
+ this._renderer = new HLCAreaSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ return [plotRow.low, plotRow.high, plotRow.close];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).close === undefined;
+ }
+
+ renderer(): HLCAreaSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: HLCAreaSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/hlc-area-series/options.ts b/plugin-examples/src/plugins/hlc-area-series/options.ts
new file mode 100644
index 0000000000..09836d55d7
--- /dev/null
+++ b/plugin-examples/src/plugins/hlc-area-series/options.ts
@@ -0,0 +1,27 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface HLCAreaSeriesOptions extends CustomSeriesOptions {
+ highLineColor: string;
+ lowLineColor: string;
+ closeLineColor: string;
+ areaBottomColor: string;
+ areaTopColor: string;
+ highLineWidth: number;
+ lowLineWidth: number;
+ closeLineWidth: number;
+}
+
+export const defaultOptions: HLCAreaSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ highLineColor: '#049981',
+ lowLineColor: '#F23645',
+ closeLineColor: '#878993',
+ areaBottomColor: 'rgba(242, 54, 69, 0.2)',
+ areaTopColor: 'rgba(4, 153, 129, 0.2)',
+ highLineWidth: 2,
+ lowLineWidth: 2,
+ closeLineWidth: 2,
+} as const;
diff --git a/plugin-examples/src/plugins/hlc-area-series/renderer.ts b/plugin-examples/src/plugins/hlc-area-series/renderer.ts
new file mode 100644
index 0000000000..d7590ffb97
--- /dev/null
+++ b/plugin-examples/src/plugins/hlc-area-series/renderer.ts
@@ -0,0 +1,127 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { HLCAreaData } from './data';
+import { HLCAreaSeriesOptions } from './options';
+
+interface HLCAreaBarItem {
+ x: number;
+ high: number;
+ low: number;
+ close: number;
+}
+
+export class HLCAreaSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: HLCAreaSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: HLCAreaSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const bars: HLCAreaBarItem[] = this._data.bars.map(bar => {
+ return {
+ x: bar.x * renderingScope.horizontalPixelRatio,
+ high: priceToCoordinate(bar.originalData.high)! * renderingScope.verticalPixelRatio,
+ low: priceToCoordinate(bar.originalData.low)! * renderingScope.verticalPixelRatio,
+ close: priceToCoordinate(bar.originalData.close)! * renderingScope.verticalPixelRatio,
+ };
+ });
+
+ const ctx = renderingScope.context;
+ ctx.save();
+ ctx.beginPath();
+ const lowLine = new Path2D();
+ const highLine = new Path2D();
+ const closeLine = new Path2D();
+ const firstBar = bars[this._data.visibleRange.from];
+ lowLine.moveTo(firstBar.x, firstBar.low);
+ highLine.moveTo(firstBar.x, firstBar.high);
+ for (
+ let i = this._data.visibleRange.from + 1;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ lowLine.lineTo(bar.x, bar.low);
+ highLine.lineTo(bar.x, bar.high);
+ }
+
+ // We draw the close line in reverse so that it is
+ // to reuse the Path2D to create the filled areas.
+ const lastBar = bars[this._data.visibleRange.to - 1];
+ closeLine.moveTo(lastBar.x, lastBar.close);
+ for (
+ let i = this._data.visibleRange.to - 2;
+ i >= this._data.visibleRange.from;
+ i--
+ ) {
+ const bar = bars[i];
+ closeLine.lineTo(bar.x, bar.close);
+ }
+
+ const topArea = new Path2D(highLine);
+ topArea.lineTo(lastBar.x, lastBar.close);
+ topArea.addPath(closeLine);
+ topArea.lineTo(firstBar.x, firstBar.high);
+ topArea.closePath();
+ ctx.fillStyle = options.areaTopColor;
+ ctx.fill(topArea);
+
+ const bottomArea = new Path2D(lowLine);
+ bottomArea.lineTo(lastBar.x, lastBar.close);
+ bottomArea.addPath(closeLine);
+ bottomArea.lineTo(firstBar.x, firstBar.low);
+ bottomArea.closePath();
+ ctx.fillStyle = options.areaBottomColor;
+ ctx.fill(bottomArea);
+
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = options.lowLineColor;
+ ctx.lineWidth = options.lowLineWidth * renderingScope.verticalPixelRatio;
+ ctx.stroke(lowLine);
+ ctx.strokeStyle = options.highLineColor;
+ ctx.lineWidth = options.highLineWidth * renderingScope.verticalPixelRatio;
+ ctx.stroke(highLine);
+ ctx.strokeStyle = options.closeLineColor;
+ ctx.lineWidth = options.closeLineWidth * renderingScope.verticalPixelRatio;
+ ctx.stroke(closeLine);
+
+ ctx.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/image-watermark/example/example.ts b/plugin-examples/src/plugins/image-watermark/example/example.ts
new file mode 100644
index 0000000000..2d2e26d82f
--- /dev/null
+++ b/plugin-examples/src/plugins/image-watermark/example/example.ts
@@ -0,0 +1,37 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+
+import imgUrl from './image.svg';
+import { ImageWatermark } from '../image-watermark';
+
+const container = document.querySelector('#chart');
+if (!container) throw new Error('Unable to located container div element');
+const chart = ((window as unknown as any).chart = createChart(container, {
+ autoSize: true,
+}));
+
+const watermark = new ImageWatermark(imgUrl, {
+ maxHeight: 400,
+ maxWidth: 400,
+ padding: 50,
+ alpha: 0.4,
+});
+
+/**
+ * Example of creating a fake 'chart' series.
+ * this is a work-around to not having chart primitives.
+ *
+ * So instead of having chart.attachPrimitive(...) we can recommend this
+ * instead if the developer has a reason why a primitive shouldn't be added
+ * to a specific series. Not sure why yet, since every chart should have a
+ * series to be useful. Maybe if they are dynamically adding and removing series
+ * but would like some primitives to always be visible (i.e. a 'chart primitive').
+ */
+// const chartSeries = chart.addLineSeries();
+// chartSeries.attachPrimitive(watermark);
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+lineSeries.attachPrimitive(watermark);
diff --git a/plugin-examples/src/plugins/image-watermark/example/image.svg b/plugin-examples/src/plugins/image-watermark/example/image.svg
new file mode 100644
index 0000000000..bddf383047
--- /dev/null
+++ b/plugin-examples/src/plugins/image-watermark/example/image.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/image-watermark/example/index.html b/plugin-examples/src/plugins/image-watermark/example/index.html
new file mode 100644
index 0000000000..ff140b722e
--- /dev/null
+++ b/plugin-examples/src/plugins/image-watermark/example/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Lightweight Charts - Image Watermark Plugin Example
+
+
+
+
+
+
+
Image Watermark
+
+ Image watermark which is responsive to the size of the chart pane.
+ Padding, maximum width, maximum height values can be defined to control
+ the behaviour of the watermark.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/image-watermark/image-watermark.ts b/plugin-examples/src/plugins/image-watermark/image-watermark.ts
new file mode 100644
index 0000000000..8a25a171ff
--- /dev/null
+++ b/plugin-examples/src/plugins/image-watermark/image-watermark.ts
@@ -0,0 +1,163 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ IChartApi,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesAttachedParameter,
+ SeriesPrimitivePaneViewZOrder,
+ Time,
+} from 'lightweight-charts';
+
+export interface ImageWatermarkOptions {
+ maxWidth?: number;
+ maxHeight?: number;
+ padding?: number;
+ alpha?: number;
+}
+
+class ImageWatermarkPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _source: ImageWatermark;
+ _view: ImageWatermarkPaneView;
+
+ constructor(source: ImageWatermark, view: ImageWatermarkPaneView) {
+ this._source = source;
+ this._view = view;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useMediaCoordinateSpace(scope => {
+ const ctx = scope.context;
+ const pos = this._view._placement;
+ if (!pos) return;
+ if (!this._source._imgElement) throw new Error(`Image element missing.`);
+ ctx.save();
+ ctx.globalAlpha = this._source._options.alpha ?? 1;
+ ctx.drawImage(
+ this._source._imgElement,
+ pos.x,
+ pos.y,
+ pos.width,
+ pos.height
+ );
+ ctx.restore();
+ });
+ }
+}
+
+interface Placement {
+ x: number;
+ y: number;
+ height: number;
+ width: number;
+}
+
+class ImageWatermarkPaneView implements ISeriesPrimitivePaneView {
+ _source: ImageWatermark;
+ _placement: Placement | null = null;
+
+ constructor(source: ImageWatermark) {
+ this._source = source;
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'bottom';
+ }
+
+ update() {
+ this._placement = this._determinePlacement();
+ }
+
+ renderer() {
+ return new ImageWatermarkPaneRenderer(this._source, this);
+ }
+
+ private _determinePlacement(): Placement | null {
+ if (!this._source._chart) return null;
+ const leftPriceScaleWidth = this._source._chart.priceScale('left').width();
+ const plotAreaWidth = this._source._chart.timeScale().width();
+ const startX = leftPriceScaleWidth;
+ const plotAreaHeight =
+ this._source._chart.chartElement().clientHeight -
+ this._source._chart.timeScale().height();
+
+ const plotCentreX = Math.round(plotAreaWidth / 2) + startX;
+ const plotCentreY = Math.round(plotAreaHeight / 2) + 0;
+
+ const padding = this._source._options.padding ?? 0;
+ let availableWidth = plotAreaWidth - 2 * padding;
+ let availableHeight = plotAreaHeight - 2 * padding;
+
+ if (this._source._options.maxHeight)
+ availableHeight = Math.min(
+ availableHeight,
+ this._source._options.maxHeight
+ );
+ if (this._source._options.maxWidth)
+ availableWidth = Math.min(availableWidth, this._source._options.maxWidth);
+
+ const scaleX = availableWidth / this._source._imageWidth;
+ const scaleY = availableHeight / this._source._imageHeight;
+ const scaleToUse = Math.min(scaleX, scaleY);
+
+ const drawWidth = this._source._imageWidth * scaleToUse;
+ const drawHeight = this._source._imageHeight * scaleToUse;
+
+ const x = plotCentreX - 0.5 * drawWidth;
+ const y = plotCentreY - 0.5 * drawHeight;
+
+ return {
+ x,
+ y,
+ height: drawHeight,
+ width: drawWidth,
+ };
+ }
+}
+
+export class ImageWatermark implements ISeriesPrimitive {
+ _paneViews: ImageWatermarkPaneView[];
+ _imgElement: HTMLImageElement | null = null;
+ _imageUrl: string;
+ _options: ImageWatermarkOptions;
+ _imageHeight = 0; // don't draw until loaded fully
+ _imageWidth = 0;
+ _chart: IChartApi | null = null;
+ _containerElement: HTMLElement | null = null;
+ _requestUpdate?: () => void;
+
+ constructor(imageUrl: string, options: ImageWatermarkOptions) {
+ this._imageUrl = imageUrl;
+ this._options = options;
+ this._paneViews = [new ImageWatermarkPaneView(this)];
+ }
+
+ attached({ chart, requestUpdate }: SeriesAttachedParameter) {
+ this._chart = chart;
+ this._requestUpdate = requestUpdate;
+ this._containerElement = chart.chartElement();
+ this._imgElement = new Image();
+ this._imgElement.onload = () => {
+ this._imageHeight = this._imgElement?.naturalHeight ?? 1;
+ this._imageWidth = this._imgElement?.naturalWidth ?? 1;
+ this._paneViews.forEach(pv => pv.update());
+ this.requestUpdate();
+ };
+ this._imgElement.src = this._imageUrl;
+ }
+
+ detached() {
+ this._imgElement = null;
+ }
+
+ requestUpdate(): void {
+ if (this._requestUpdate) this._requestUpdate();
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pv => pv.update());
+ }
+ paneViews() {
+ return this._paneViews;
+ }
+}
diff --git a/plugin-examples/src/plugins/lollipop-series/data.ts b/plugin-examples/src/plugins/lollipop-series/data.ts
new file mode 100644
index 0000000000..729dd69922
--- /dev/null
+++ b/plugin-examples/src/plugins/lollipop-series/data.ts
@@ -0,0 +1,8 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * Lollipop Series Data
+ */
+export interface LollipopData extends CustomData {
+ value: number;
+}
diff --git a/plugin-examples/src/plugins/lollipop-series/example/example.ts b/plugin-examples/src/plugins/lollipop-series/example/example.ts
new file mode 100644
index 0000000000..83aee57823
--- /dev/null
+++ b/plugin-examples/src/plugins/lollipop-series/example/example.ts
@@ -0,0 +1,17 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { generateLineData, shuffleValuesWithLimit } from '../../../sample-data';
+import { LollipopSeries } from '../lollipop-series';
+import { LollipopData } from '../data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const customSeriesView = new LollipopSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ lineWidth: 2,
+});
+
+const data: (LollipopData | WhitespaceData)[] = shuffleValuesWithLimit(generateLineData(100), 10);
+myCustomSeries.setData(data);
diff --git a/plugin-examples/src/plugins/lollipop-series/example/index.html b/plugin-examples/src/plugins/lollipop-series/example/index.html
new file mode 100644
index 0000000000..de1cf9fc56
--- /dev/null
+++ b/plugin-examples/src/plugins/lollipop-series/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - Lollipop Series Plugin Example
+
+
+
+
+
+
Lollipop Series
+
+ Series where the price values are plotted as circular marks with a
+ column towards the baseline value (typically zero).
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/lollipop-series/lollipop-series.ts b/plugin-examples/src/plugins/lollipop-series/lollipop-series.ts
new file mode 100644
index 0000000000..6e26099d01
--- /dev/null
+++ b/plugin-examples/src/plugins/lollipop-series/lollipop-series.ts
@@ -0,0 +1,44 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { LollipopSeriesOptions, defaultOptions } from './options';
+import { LollipopSeriesRenderer } from './renderer';
+import { LollipopData } from './data';
+
+export class LollipopSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: LollipopSeriesRenderer;
+
+ constructor() {
+ this._renderer = new LollipopSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ // zero at the start because it should draw from zero (like a column)
+ return [0, plotRow.value];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).value === undefined;
+ }
+
+ renderer(): LollipopSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: LollipopSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/lollipop-series/options.ts b/plugin-examples/src/plugins/lollipop-series/options.ts
new file mode 100644
index 0000000000..591174edfd
--- /dev/null
+++ b/plugin-examples/src/plugins/lollipop-series/options.ts
@@ -0,0 +1,13 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface LollipopSeriesOptions extends CustomSeriesOptions {
+ lineWidth: number;
+}
+
+export const defaultOptions: LollipopSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ lineWidth: 2,
+} as const;
diff --git a/plugin-examples/src/plugins/lollipop-series/renderer.ts b/plugin-examples/src/plugins/lollipop-series/renderer.ts
new file mode 100644
index 0000000000..ecbea8f0c6
--- /dev/null
+++ b/plugin-examples/src/plugins/lollipop-series/renderer.ts
@@ -0,0 +1,108 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ Coordinate,
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { LollipopData } from './data';
+import { LollipopSeriesOptions } from './options';
+import {
+ positionsBox,
+ positionsLine,
+} from '../../helpers/dimensions/positions';
+
+interface LollipopBarItem {
+ x: number;
+ y: Coordinate | number;
+}
+
+export class LollipopSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: LollipopSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: LollipopSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const bars: LollipopBarItem[] = this._data.bars.map(bar => {
+ return {
+ x: bar.x,
+ y: priceToCoordinate(bar.originalData.value) ?? 0,
+ };
+ });
+
+ const lineWidth = Math.min(this._options.lineWidth, this._data.barSpacing);
+
+ renderingScope.context.save();
+ const barWidth = this._data.barSpacing;
+ const radius = Math.floor(barWidth / 2);
+ const zeroY = priceToCoordinate(0);
+ for (
+ let i = this._data.visibleRange.from;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ const xPosition = positionsLine(
+ bar.x,
+ renderingScope.horizontalPixelRatio,
+ lineWidth
+ );
+ const yPositionBox = positionsBox(
+ zeroY ?? 0,
+ bar.y,
+ renderingScope.verticalPixelRatio
+ );
+ renderingScope.context.beginPath();
+ renderingScope.context.fillStyle = options.color;
+ renderingScope.context.fillRect(
+ xPosition.position,
+ yPositionBox.position,
+ xPosition.length,
+ yPositionBox.length
+ );
+ renderingScope.context.arc(
+ bar.x * renderingScope.horizontalPixelRatio,
+ bar.y * renderingScope.verticalPixelRatio,
+ radius * renderingScope.horizontalPixelRatio,
+ 0,
+ Math.PI * 2
+ );
+ renderingScope.context.fill();
+ }
+ renderingScope.context.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/overlay-price-scale/example/example.ts b/plugin-examples/src/plugins/overlay-price-scale/example/example.ts
new file mode 100644
index 0000000000..13a6fd9020
--- /dev/null
+++ b/plugin-examples/src/plugins/overlay-price-scale/example/example.ts
@@ -0,0 +1,24 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { OverlayPriceScale } from '../overlay-price-scale';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ rightPriceScale: {
+ visible: false,
+ },
+ grid: {
+ horzLines: {
+ visible: false,
+ },
+ },
+}));
+
+const lineSeries = chart.addAreaSeries({
+ priceScaleId: 'overlay',
+});
+
+const data = generateLineData();
+lineSeries.setData(data);
+
+lineSeries.attachPrimitive(new OverlayPriceScale({}));
diff --git a/plugin-examples/src/plugins/overlay-price-scale/example/index.html b/plugin-examples/src/plugins/overlay-price-scale/example/index.html
new file mode 100644
index 0000000000..7669502791
--- /dev/null
+++ b/plugin-examples/src/plugins/overlay-price-scale/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - Overlay Price Scale Plugin Example
+
+
+
+
+
+
Overlay Price Scale
+
+ A price scale which appears on the main chart pane area. For use with
+ series assigned to an overlay price scale.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/overlay-price-scale/overlay-price-scale.ts b/plugin-examples/src/plugins/overlay-price-scale/overlay-price-scale.ts
new file mode 100644
index 0000000000..a6637ebe9d
--- /dev/null
+++ b/plugin-examples/src/plugins/overlay-price-scale/overlay-price-scale.ts
@@ -0,0 +1,194 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ BarPrice,
+ Coordinate,
+ IChartApi,
+ IPriceFormatter,
+ ISeriesApi,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesAttachedParameter,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+
+/*
+ TODO: This is just a simple price scale which
+ doesn't consider the actual minTick amount for
+ series, and doesn't position the labels dynamically
+ to line up to 'rounded' values.
+*/
+
+interface RendererData {
+ priceFormatter: IPriceFormatter;
+ coordinateToPrice: (coordinate: number) => BarPrice | null;
+ priceToCoordinate: (price: number) => Coordinate | null;
+ options: OverlayPriceScaleOptions;
+}
+
+interface Label {
+ label: string;
+ y: number;
+}
+
+const tickSpacing = 40;
+const horizontalPadding = 3;
+const verticalPadding = 2;
+const sideMargin = 10;
+const fontSize = 12;
+const radius = 4;
+
+class OverlayPriceScaleRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: RendererData | null = null;
+ update(data: RendererData) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useMediaCoordinateSpace(scope => {
+ if (!this._data) return;
+ const labels = this._calculatePriceScale(
+ scope.mediaSize.height,
+ this._data
+ );
+ const maxLabelLength = labels.reduce((answer: number, label: Label) => {
+ return Math.max(answer, label.label.length);
+ }, 0);
+ const testLabelForWidth = ''.padEnd(maxLabelLength, '0');
+ const ctx = scope.context;
+ ctx.save();
+ const isLeft = this._data.options.side === 'left';
+ ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ const testDimensions = ctx.measureText(testLabelForWidth);
+ const width = testDimensions.width;
+ const x = isLeft
+ ? sideMargin
+ : scope.mediaSize.width - sideMargin - width;
+ const textX = x + horizontalPadding + Math.round(width / 2);
+ labels.forEach(label => {
+ ctx.beginPath();
+ const topY = label.y - fontSize / 2;
+ ctx.roundRect(
+ x,
+ topY,
+ width + horizontalPadding * 2,
+ fontSize + 2 * verticalPadding,
+ radius
+ );
+ ctx.fillStyle = this._data!.options.backgroundColor;
+ ctx.fill();
+ ctx.beginPath();
+ ctx.fillStyle = this._data!.options.textColor;
+ ctx.fillText(label.label, textX, topY + verticalPadding, width);
+ });
+ ctx.restore();
+ });
+ }
+
+ _calculatePriceScale(height: number, data: RendererData) {
+ const yPositions: number[] = [];
+ const halfTick = Math.round(tickSpacing / 4);
+ let pos = halfTick;
+ while (pos <= height - halfTick) {
+ yPositions.push(pos);
+ pos += tickSpacing;
+ }
+ const labels = yPositions
+ .map(y => {
+ const price = data.coordinateToPrice(y);
+ if (price === null) return null;
+ const priceLabel = data.priceFormatter.format(price);
+ return {
+ label: priceLabel,
+ y: y,
+ };
+ })
+ .filter((item: Label | null): item is Label => Boolean(item));
+ return labels;
+ }
+}
+
+class OverlayPriceScaleView implements ISeriesPrimitivePaneView {
+ _renderer: OverlayPriceScaleRenderer;
+ constructor() {
+ this._renderer = new OverlayPriceScaleRenderer();
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer {
+ return this._renderer;
+ }
+
+ update(data: RendererData) {
+ this._renderer.update(data);
+ }
+}
+
+export interface OverlayPriceScaleOptions {
+ textColor: string;
+ backgroundColor: string;
+ side: 'left' | 'right';
+}
+
+const defaultOptions: OverlayPriceScaleOptions = {
+ textColor: 'rgb(0, 0, 0)',
+ backgroundColor: 'rgba(255, 255, 255, 0.6)',
+ side: 'left',
+} as const;
+
+export class OverlayPriceScale implements ISeriesPrimitive {
+ _paneViews: OverlayPriceScaleView[];
+ _chart: IChartApi | null = null;
+ _series: ISeriesApi | null = null;
+ _requestUpdate?: () => void;
+ _options: OverlayPriceScaleOptions;
+
+ constructor(options: Partial) {
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._paneViews = [new OverlayPriceScaleView()];
+ }
+
+ applyOptions(options: Partial) {
+ this._options = {
+ ...this._options,
+ ...options,
+ };
+ if (this._requestUpdate) this._requestUpdate();
+ }
+
+ attached({ chart, series, requestUpdate }: SeriesAttachedParameter) {
+ this._chart = chart;
+ this._series = series;
+ this._requestUpdate = requestUpdate;
+ }
+
+ detached() {
+ this._chart = null;
+ this._series = null;
+ }
+
+ updateAllViews() {
+ if (!this._series || !this._chart) return;
+ const coordinateToPrice = (coordinate: number): BarPrice | null =>
+ this._series!.coordinateToPrice(coordinate);
+ const priceToCoordinate = (price: number): Coordinate | null =>
+ this._series!.priceToCoordinate(price);
+ const priceFormatter = this._series.priceFormatter();
+ const options = this._options;
+ const data: RendererData = {
+ coordinateToPrice,
+ priceToCoordinate,
+ priceFormatter,
+ options,
+ };
+ this._paneViews.forEach(pw => pw.update(data));
+ }
+ paneViews() {
+ return this._paneViews;
+ }
+}
diff --git a/plugin-examples/src/plugins/partial-price-line/example/example.ts b/plugin-examples/src/plugins/partial-price-line/example/example.ts
new file mode 100644
index 0000000000..fcad58fc64
--- /dev/null
+++ b/plugin-examples/src/plugins/partial-price-line/example/example.ts
@@ -0,0 +1,38 @@
+import { LastPriceAnimationMode, LineData, createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { PartialPriceLine } from '../partial-price-line';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries({
+ lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
+});
+
+const data = generateLineData();
+const [initialData, realtimeUpdates] = [data.slice(0, -100), data.slice(-100)];
+lineSeries.setData(initialData);
+
+lineSeries.attachPrimitive(new PartialPriceLine());
+
+const pos = chart.timeScale().scrollPosition();
+chart.timeScale().scrollToPosition(pos + 20, false);
+
+// simulate real-time data
+function* getNextRealtimeUpdate(realtimeData: LineData[]) {
+ for (const dataPoint of realtimeData) {
+ yield dataPoint;
+ }
+ return null;
+}
+const streamingDataProvider = getNextRealtimeUpdate(realtimeUpdates);
+
+const intervalID = window.setInterval(() => {
+ const update = streamingDataProvider.next();
+ if (update.done) {
+ window.clearInterval(intervalID);
+ return;
+ }
+ lineSeries.update(update.value);
+}, 200);
diff --git a/plugin-examples/src/plugins/partial-price-line/example/index.html b/plugin-examples/src/plugins/partial-price-line/example/index.html
new file mode 100644
index 0000000000..e489fa9fb4
--- /dev/null
+++ b/plugin-examples/src/plugins/partial-price-line/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - Partial Price Line Plugin Example
+
+
+
+
+
+
Partial Price Line
+
+ A Price line which is only drawn from the latest price to the right
+ price scale (instead of across the entire width of the chart).
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/partial-price-line/partial-price-line.ts b/plugin-examples/src/plugins/partial-price-line/partial-price-line.ts
new file mode 100644
index 0000000000..26cd2b1069
--- /dev/null
+++ b/plugin-examples/src/plugins/partial-price-line/partial-price-line.ts
@@ -0,0 +1,121 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ BarData,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ LineData,
+ LineStyleOptions,
+ MismatchDirection,
+ SeriesAttachedParameter,
+ SeriesType,
+ Time,
+ WhitespaceData,
+} from 'lightweight-charts';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+class PartialPriceLineRenderer implements ISeriesPrimitivePaneRenderer {
+ _price: number | null = null;
+ _x: number | null = null;
+ _color: string = '#000000';
+ update(priceY: number | null, color: string, x: number | null) {
+ this._price = priceY;
+ this._color = color;
+ this._x = x;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useBitmapCoordinateSpace(scope => {
+ if (this._price === null || this._x === null) return;
+ const xPosition = Math.round(this._x * scope.horizontalPixelRatio);
+ const yPosition = positionsLine(this._price, scope.verticalPixelRatio, scope.verticalPixelRatio);
+ const yCentre = yPosition.position + yPosition.length / 2;
+ const ctx = scope.context;
+ ctx.save();
+ ctx.beginPath();
+ ctx.setLineDash([
+ 4 * scope.verticalPixelRatio,
+ 2 * scope.verticalPixelRatio,
+ ]);
+ ctx.moveTo(xPosition, yCentre);
+ ctx.lineTo(scope.bitmapSize.width, yCentre);
+ ctx.strokeStyle = this._color;
+ ctx.lineWidth = scope.verticalPixelRatio;
+ ctx.stroke();
+ ctx.restore();
+ });
+ }
+}
+
+class PartialPriceLineView implements ISeriesPrimitivePaneView {
+ _renderer: PartialPriceLineRenderer;
+ constructor() {
+ this._renderer = new PartialPriceLineRenderer();
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer {
+ return this._renderer;
+ }
+
+ update(priceY: number | null, color: string, x: number | null) {
+ this._renderer.update(priceY, color, x);
+ }
+}
+
+export class PartialPriceLine implements ISeriesPrimitive {
+ _paneViews: PartialPriceLineView[];
+ _chart: IChartApi | null = null;
+ _series: ISeriesApi | null = null;
+
+ constructor() {
+ this._paneViews = [new PartialPriceLineView()];
+ }
+
+ attached({ chart, series } : SeriesAttachedParameter) {
+ this._chart = chart;
+ this._series = series;
+ this._series.applyOptions({
+ priceLineVisible: false,
+ });
+ }
+ detached() {
+ this._chart = null;
+ this._series = null;
+ }
+
+ updateAllViews() {
+ if (!this._series || !this._chart) return;
+ const seriesOptions = this._series.options();
+ let color =
+ seriesOptions.priceLineColor ||
+ (seriesOptions as LineStyleOptions).color ||
+ '#000000';
+ const lastValue = this._series.dataByIndex(
+ 100000,
+ MismatchDirection.NearestLeft
+ );
+ let price: number | null = null;
+ let x: number | null = null;
+ if (lastValue) {
+ if ((lastValue as BarData).color !== undefined) {
+ color = (lastValue as BarData).color!;
+ }
+ price = getValue(lastValue);
+ x = this._chart.timeScale().timeToCoordinate(lastValue.time);
+ }
+ const priceY =
+ price !== null ? (this._series.priceToCoordinate(price) as number) : null;
+ this._paneViews.forEach(pw => pw.update(priceY, color, x));
+ }
+ paneViews() {
+ return this._paneViews;
+ }
+}
+
+function getValue(data: LineData | BarData | WhitespaceData): number | null {
+ if ((data as LineData).value !== undefined) return (data as LineData).value;
+ if ((data as BarData).close !== undefined) return (data as BarData).close;
+ return null;
+}
diff --git a/plugin-examples/src/plugins/plugin-base.ts b/plugin-examples/src/plugins/plugin-base.ts
new file mode 100644
index 0000000000..12a5598ad2
--- /dev/null
+++ b/plugin-examples/src/plugins/plugin-base.ts
@@ -0,0 +1,49 @@
+import {
+ DataChangedScope,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ SeriesAttachedParameter,
+ SeriesOptionsMap,
+ Time,
+} from 'lightweight-charts';
+import { ensureDefined } from '../helpers/assertions';
+
+export abstract class PluginBase implements ISeriesPrimitive {
+ private _chart: IChartApi | undefined = undefined;
+ private _series: ISeriesApi | undefined = undefined;
+
+ protected dataUpdated?(scope: DataChangedScope): void;
+ protected requestUpdate(): void {
+ if (this._requestUpdate) this._requestUpdate();
+ }
+ private _requestUpdate?: () => void;
+
+ public attached({ chart, series, requestUpdate }: SeriesAttachedParameter) {
+ this._chart = chart;
+ this._series = series;
+ this._series.subscribeDataChanged(this._fireDataUpdated);
+ this._requestUpdate = requestUpdate;
+ this.requestUpdate();
+ }
+
+ public detached() {
+ this._chart = undefined;
+ this._series = undefined;
+ this._requestUpdate = undefined;
+ }
+
+ public get chart(): IChartApi {
+ return ensureDefined(this._chart);
+ }
+
+ public get series(): ISeriesApi {
+ return ensureDefined(this._series);
+ }
+
+ private _fireDataUpdated(scope: DataChangedScope) {
+ if (this.dataUpdated) {
+ this.dataUpdated(scope);
+ }
+ }
+}
diff --git a/plugin-examples/src/plugins/rectangle-drawing-tool/example/example.ts b/plugin-examples/src/plugins/rectangle-drawing-tool/example/example.ts
new file mode 100644
index 0000000000..cbc76220be
--- /dev/null
+++ b/plugin-examples/src/plugins/rectangle-drawing-tool/example/example.ts
@@ -0,0 +1,20 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { RectangleDrawingTool } from '../rectangle-drawing-tool';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+new RectangleDrawingTool(
+ chart,
+ lineSeries,
+ document.querySelector('#toolbar')!,
+ {
+ showLabels: false,
+ }
+);
diff --git a/plugin-examples/src/plugins/rectangle-drawing-tool/example/index.html b/plugin-examples/src/plugins/rectangle-drawing-tool/example/index.html
new file mode 100644
index 0000000000..671c0d8be2
--- /dev/null
+++ b/plugin-examples/src/plugins/rectangle-drawing-tool/example/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Lightweight Charts - Rectangle Drawing Tool Plugin Example
+
+
+
+
+
+
+
+
Rectangle Drawing Tool
+
+ A simple drawing tool for rectangles. Click on the icon in the top left
+ corner to activate drawing mode, and then click on the chart to define
+ the top left and bottom right corners of a rectangle. The color can be
+ changed using the color picker in the top toolbar.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/rectangle-drawing-tool/rectangle-drawing-tool.ts b/plugin-examples/src/plugins/rectangle-drawing-tool/rectangle-drawing-tool.ts
new file mode 100644
index 0000000000..cd0371804b
--- /dev/null
+++ b/plugin-examples/src/plugins/rectangle-drawing-tool/rectangle-drawing-tool.ts
@@ -0,0 +1,517 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ Coordinate,
+ IChartApi,
+ isBusinessDay,
+ ISeriesApi,
+ ISeriesPrimitiveAxisView,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ MouseEventParams,
+ SeriesPrimitivePaneViewZOrder,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+import { ensureDefined } from '../../helpers/assertions';
+import { PluginBase } from '../plugin-base';
+import { positionsBox } from '../../helpers/dimensions/positions';
+
+class RectanglePaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _p1: ViewPoint;
+ _p2: ViewPoint;
+ _fillColor: string;
+
+ constructor(p1: ViewPoint, p2: ViewPoint, fillColor: string) {
+ this._p1 = p1;
+ this._p2 = p2;
+ this._fillColor = fillColor;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useBitmapCoordinateSpace(scope => {
+ if (
+ this._p1.x === null ||
+ this._p1.y === null ||
+ this._p2.x === null ||
+ this._p2.y === null
+ )
+ return;
+ const ctx = scope.context;
+ const horizontalPositions = positionsBox(
+ this._p1.x,
+ this._p2.x,
+ scope.horizontalPixelRatio
+ );
+ const verticalPositions = positionsBox(
+ this._p1.y,
+ this._p2.y,
+ scope.verticalPixelRatio
+ );
+ ctx.fillStyle = this._fillColor;
+ ctx.fillRect(
+ horizontalPositions.position,
+ verticalPositions.position,
+ horizontalPositions.length,
+ verticalPositions.length
+ );
+ });
+ }
+}
+
+interface ViewPoint {
+ x: Coordinate | null;
+ y: Coordinate | null;
+}
+
+class RectanglePaneView implements ISeriesPrimitivePaneView {
+ _source: Rectangle;
+ _p1: ViewPoint = { x: null, y: null };
+ _p2: ViewPoint = { x: null, y: null };
+
+ constructor(source: Rectangle) {
+ this._source = source;
+ }
+
+ update() {
+ const series = this._source.series;
+ const y1 = series.priceToCoordinate(this._source._p1.price);
+ const y2 = series.priceToCoordinate(this._source._p2.price);
+ const timeScale = this._source.chart.timeScale();
+ const x1 = timeScale.timeToCoordinate(this._source._p1.time);
+ const x2 = timeScale.timeToCoordinate(this._source._p2.time);
+ this._p1 = { x: x1, y: y1 };
+ this._p2 = { x: x2, y: y2 };
+ }
+
+ renderer() {
+ return new RectanglePaneRenderer(
+ this._p1,
+ this._p2,
+ this._source._options.fillColor
+ );
+ }
+}
+
+class RectangleAxisPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _p1: number | null;
+ _p2: number | null;
+ _fillColor: string;
+ _vertical: boolean = false;
+
+ constructor(
+ p1: number | null,
+ p2: number | null,
+ fillColor: string,
+ vertical: boolean
+ ) {
+ this._p1 = p1;
+ this._p2 = p2;
+ this._fillColor = fillColor;
+ this._vertical = vertical;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useBitmapCoordinateSpace(scope => {
+ if (this._p1 === null || this._p2 === null) return;
+ const ctx = scope.context;
+ ctx.save();
+ ctx.globalAlpha = 0.5;
+ const positions = positionsBox(
+ this._p1,
+ this._p2,
+ this._vertical ? scope.verticalPixelRatio : scope.horizontalPixelRatio
+ );
+ ctx.fillStyle = this._fillColor;
+ if (this._vertical) {
+ ctx.fillRect(0, positions.position, 15, positions.length);
+ } else {
+ ctx.fillRect(positions.position, 0, positions.length, 15);
+ }
+
+ ctx.restore();
+ });
+ }
+}
+
+abstract class RectangleAxisPaneView implements ISeriesPrimitivePaneView {
+ _source: Rectangle;
+ _p1: number | null = null;
+ _p2: number | null = null;
+ _vertical: boolean = false;
+
+ constructor(source: Rectangle, vertical: boolean) {
+ this._source = source;
+ this._vertical = vertical;
+ }
+
+ abstract getPoints(): [Coordinate | null, Coordinate | null];
+
+ update() {
+ [this._p1, this._p2] = this.getPoints();
+ }
+
+ renderer() {
+ return new RectangleAxisPaneRenderer(
+ this._p1,
+ this._p2,
+ this._source._options.fillColor,
+ this._vertical
+ );
+ }
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'bottom';
+ }
+}
+
+class RectanglePriceAxisPaneView extends RectangleAxisPaneView {
+ getPoints(): [Coordinate | null, Coordinate | null] {
+ const series = this._source.series;
+ const y1 = series.priceToCoordinate(this._source._p1.price);
+ const y2 = series.priceToCoordinate(this._source._p2.price);
+ return [y1, y2];
+ }
+}
+
+class RectangleTimeAxisPaneView extends RectangleAxisPaneView {
+ getPoints(): [Coordinate | null, Coordinate | null] {
+ const timeScale = this._source.chart.timeScale();
+ const x1 = timeScale.timeToCoordinate(this._source._p1.time);
+ const x2 = timeScale.timeToCoordinate(this._source._p2.time);
+ return [x1, x2];
+ }
+}
+
+abstract class RectangleAxisView implements ISeriesPrimitiveAxisView {
+ _source: Rectangle;
+ _p: Point;
+ _pos: Coordinate | null = null;
+ constructor(source: Rectangle, p: Point) {
+ this._source = source;
+ this._p = p;
+ }
+ abstract update(): void;
+ abstract text(): string;
+
+ coordinate() {
+ return this._pos ?? -1;
+ }
+
+ visible(): boolean {
+ return this._source._options.showLabels;
+ }
+
+ tickVisible(): boolean {
+ return this._source._options.showLabels;
+ }
+
+ textColor() {
+ return this._source._options.labelTextColor;
+ }
+ backColor() {
+ return this._source._options.labelColor;
+ }
+ movePoint(p: Point) {
+ this._p = p;
+ this.update();
+ }
+}
+
+class RectangleTimeAxisView extends RectangleAxisView {
+ update() {
+ const timeScale = this._source.chart.timeScale();
+ this._pos = timeScale.timeToCoordinate(this._p.time);
+ }
+ text() {
+ return this._source._options.timeLabelFormatter(this._p.time);
+ }
+}
+
+class RectanglePriceAxisView extends RectangleAxisView {
+ update() {
+ const series = this._source.series;
+ this._pos = series.priceToCoordinate(this._p.price);
+ }
+ text() {
+ return this._source._options.priceLabelFormatter(this._p.price);
+ }
+}
+
+interface Point {
+ time: Time;
+ price: number;
+}
+
+export interface RectangleDrawingToolOptions {
+ fillColor: string;
+ previewFillColor: string;
+ labelColor: string;
+ labelTextColor: string;
+ showLabels: boolean;
+ priceLabelFormatter: (price: number) => string;
+ timeLabelFormatter: (time: Time) => string;
+}
+
+const defaultOptions: RectangleDrawingToolOptions = {
+ fillColor: 'rgba(200, 50, 100, 0.75)',
+ previewFillColor: 'rgba(200, 50, 100, 0.25)',
+ labelColor: 'rgba(200, 50, 100, 1)',
+ labelTextColor: 'white',
+ showLabels: true,
+ priceLabelFormatter: (price: number) => price.toFixed(2),
+ timeLabelFormatter: (time: Time) => {
+ if (typeof time == 'string') return time;
+ const date = isBusinessDay(time)
+ ? new Date(time.year, time.month, time.day)
+ : new Date(time * 1000);
+ return date.toLocaleDateString();
+ },
+};
+
+class Rectangle extends PluginBase {
+ _options: RectangleDrawingToolOptions;
+ _p1: Point;
+ _p2: Point;
+ _paneViews: RectanglePaneView[];
+ _timeAxisViews: RectangleTimeAxisView[];
+ _priceAxisViews: RectanglePriceAxisView[];
+ _priceAxisPaneViews: RectanglePriceAxisPaneView[];
+ _timeAxisPaneViews: RectangleTimeAxisPaneView[];
+
+ constructor(
+ p1: Point,
+ p2: Point,
+ options: Partial = {}
+ ) {
+ super();
+ this._p1 = p1;
+ this._p2 = p2;
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._paneViews = [new RectanglePaneView(this)];
+ this._timeAxisViews = [
+ new RectangleTimeAxisView(this, p1),
+ new RectangleTimeAxisView(this, p2),
+ ];
+ this._priceAxisViews = [
+ new RectanglePriceAxisView(this, p1),
+ new RectanglePriceAxisView(this, p2),
+ ];
+ this._priceAxisPaneViews = [new RectanglePriceAxisPaneView(this, true)];
+ this._timeAxisPaneViews = [new RectangleTimeAxisPaneView(this, false)];
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ this._timeAxisViews.forEach(pw => pw.update());
+ this._priceAxisViews.forEach(pw => pw.update());
+ this._priceAxisPaneViews.forEach(pw => pw.update());
+ this._timeAxisPaneViews.forEach(pw => pw.update());
+ }
+
+ priceAxisViews() {
+ return this._priceAxisViews;
+ }
+
+ timeAxisViews() {
+ return this._timeAxisViews;
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ priceAxisPaneViews() {
+ return this._priceAxisPaneViews;
+ }
+
+ timeAxisPaneViews() {
+ return this._timeAxisPaneViews;
+ }
+
+ applyOptions(options: Partial) {
+ this._options = { ...this._options, ...options };
+ this.requestUpdate();
+ }
+}
+
+class PreviewRectangle extends Rectangle {
+ constructor(
+ p1: Point,
+ p2: Point,
+ options: Partial = {}
+ ) {
+ super(p1, p2, options);
+ this._options.fillColor = this._options.previewFillColor;
+ }
+
+ public updateEndPoint(p: Point) {
+ this._p2 = p;
+ this._paneViews[0].update();
+ this._timeAxisViews[1].movePoint(p);
+ this._priceAxisViews[1].movePoint(p);
+ this.requestUpdate();
+ }
+}
+
+export class RectangleDrawingTool {
+ private _chart: IChartApi | undefined;
+ private _series: ISeriesApi | undefined;
+ private _drawingsToolbarContainer: HTMLDivElement | undefined;
+ private _defaultOptions: Partial;
+ private _rectangles: Rectangle[];
+ private _previewRectangle: PreviewRectangle | undefined = undefined;
+ private _points: Point[] = [];
+ private _drawing: boolean = false;
+ private _toolbarButton: HTMLDivElement | undefined;
+
+ constructor(
+ chart: IChartApi,
+ series: ISeriesApi,
+ drawingsToolbarContainer: HTMLDivElement,
+ options: Partial
+ ) {
+ this._chart = chart;
+ this._series = series;
+ this._drawingsToolbarContainer = drawingsToolbarContainer;
+ this._addToolbarButton();
+ this._defaultOptions = options;
+ this._rectangles = [];
+ this._chart.subscribeClick(this._clickHandler);
+ this._chart.subscribeCrosshairMove(this._moveHandler);
+ }
+
+ private _clickHandler = (param: MouseEventParams) => this._onClick(param);
+ private _moveHandler = (param: MouseEventParams) => this._onMouseMove(param);
+
+ remove() {
+ this.stopDrawing();
+ if (this._chart) {
+ this._chart.unsubscribeClick(this._clickHandler);
+ this._chart.unsubscribeCrosshairMove(this._moveHandler);
+ }
+ this._rectangles.forEach(rectangle => {
+ this._removeRectangle(rectangle);
+ });
+ this._rectangles = [];
+ this._removePreviewRectangle();
+ this._chart = undefined;
+ this._series = undefined;
+ this._drawingsToolbarContainer = undefined;
+ }
+
+ startDrawing(): void {
+ this._drawing = true;
+ this._points = [];
+ if (this._toolbarButton) {
+ this._toolbarButton.style.fill = 'rgb(100, 150, 250)';
+ }
+ }
+
+ stopDrawing(): void {
+ this._drawing = false;
+ this._points = [];
+ if (this._toolbarButton) {
+ this._toolbarButton.style.fill = 'rgb(0, 0, 0)';
+ }
+ }
+
+ isDrawing(): boolean {
+ return this._drawing;
+ }
+
+ private _onClick(param: MouseEventParams) {
+ if (!this._drawing || !param.point || !param.time || !this._series) return;
+ const price = this._series.coordinateToPrice(param.point.y);
+ if (price === null) {
+ return;
+ }
+ this._addPoint({
+ time: param.time,
+ price,
+ });
+ }
+
+ private _onMouseMove(param: MouseEventParams) {
+ if (!this._drawing || !param.point || !param.time || !this._series) return;
+ const price = this._series.coordinateToPrice(param.point.y);
+ if (price === null) {
+ return;
+ }
+ if (this._previewRectangle) {
+ this._previewRectangle.updateEndPoint({
+ time: param.time,
+ price,
+ });
+ }
+ }
+
+ private _addPoint(p: Point) {
+ this._points.push(p);
+ if (this._points.length >= 2) {
+ this._addNewRectangle(this._points[0], this._points[1]);
+ this.stopDrawing();
+ this._removePreviewRectangle();
+ }
+ if (this._points.length === 1) {
+ this._addPreviewRectangle(this._points[0]);
+ }
+ }
+
+ private _addNewRectangle(p1: Point, p2: Point) {
+ const rectangle = new Rectangle(p1, p2, { ...this._defaultOptions });
+ this._rectangles.push(rectangle);
+ ensureDefined(this._series).attachPrimitive(rectangle);
+ }
+
+ private _removeRectangle(rectangle: Rectangle) {
+ ensureDefined(this._series).detachPrimitive(rectangle);
+ }
+
+ private _addPreviewRectangle(p: Point) {
+ this._previewRectangle = new PreviewRectangle(p, p, {
+ ...this._defaultOptions,
+ });
+ ensureDefined(this._series).attachPrimitive(this._previewRectangle);
+ }
+
+ private _removePreviewRectangle() {
+ if (this._previewRectangle) {
+ ensureDefined(this._series).detachPrimitive(this._previewRectangle);
+ this._previewRectangle = undefined;
+ }
+ }
+
+ private _addToolbarButton() {
+ if (!this._drawingsToolbarContainer) return;
+ const button = document.createElement('div');
+ button.style.width = '20px';
+ button.style.height = '20px';
+ button.innerHTML = ` `;
+ button.addEventListener('click', () => {
+ if (this.isDrawing()) {
+ this.stopDrawing();
+ } else {
+ this.startDrawing();
+ }
+ });
+ this._drawingsToolbarContainer.appendChild(button);
+ this._toolbarButton = button;
+ const colorPicker = document.createElement('input');
+ colorPicker.type = 'color';
+ colorPicker.value = '#C83264';
+ colorPicker.style.width = '24px';
+ colorPicker.style.height = '20px';
+ colorPicker.style.border = 'none';
+ colorPicker.style.padding = '0px';
+ colorPicker.style.backgroundColor = 'transparent';
+ colorPicker.addEventListener('change', () => {
+ const newColor = colorPicker.value;
+ this._defaultOptions.fillColor = newColor + 'CC';
+ this._defaultOptions.previewFillColor = newColor + '77';
+ this._defaultOptions.labelColor = newColor;
+ });
+ this._drawingsToolbarContainer.appendChild(colorPicker);
+ }
+}
diff --git a/plugin-examples/src/plugins/rounded-candles-series/data.ts b/plugin-examples/src/plugins/rounded-candles-series/data.ts
new file mode 100644
index 0000000000..acff83e4ea
--- /dev/null
+++ b/plugin-examples/src/plugins/rounded-candles-series/data.ts
@@ -0,0 +1,10 @@
+import {
+ CandlestickData,
+ CustomData,
+} from 'lightweight-charts';
+
+export interface RoundedCandleSeriesData
+ extends CandlestickData,
+ CustomData {
+ rounded?: boolean;
+}
diff --git a/plugin-examples/src/plugins/rounded-candles-series/example/example.ts b/plugin-examples/src/plugins/rounded-candles-series/example/example.ts
new file mode 100644
index 0000000000..97ebfe8d47
--- /dev/null
+++ b/plugin-examples/src/plugins/rounded-candles-series/example/example.ts
@@ -0,0 +1,32 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { CandleData, generateAlternativeCandleData } from '../../../sample-data';
+import { RoundedCandleSeries } from '../rounded-candles-series';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const customSeriesView = new RoundedCandleSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ color: '#FF00FF', // TESTING: shouldn't see this because we are coloring each bar later
+});
+
+const { upColor, downColor } = myCustomSeries.options();
+
+let lastValue = -Infinity;
+const data: (CandleData | WhitespaceData)[] = generateAlternativeCandleData().map(d => {
+ // we add the item colors here instead of providing an
+ // API to do it internally.
+ const color = d.close >= lastValue ? upColor : downColor;
+ lastValue = d.close;
+ return { ...d, color };
+});
+data[data.length -2] = { time: data[data.length -2].time }; // test whitespace data
+myCustomSeries.setData(data);
+
+// Should be an error...
+// myCustomSeries.update({
+// time: 123456 as Time,
+// close: 1234,
+// open: 1234,
+// });
diff --git a/plugin-examples/src/plugins/rounded-candles-series/example/index.html b/plugin-examples/src/plugins/rounded-candles-series/example/index.html
new file mode 100644
index 0000000000..ca07130d91
--- /dev/null
+++ b/plugin-examples/src/plugins/rounded-candles-series/example/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Lightweight Charts - Rounded Candles Series Example
+
+
+
+
+
+
Rounded Candles Series
+
+ Candle series where at larger sizes the corners of the candle bodies are rounded.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/rounded-candles-series/helpers.ts b/plugin-examples/src/plugins/rounded-candles-series/helpers.ts
new file mode 100644
index 0000000000..4b6d59a543
--- /dev/null
+++ b/plugin-examples/src/plugins/rounded-candles-series/helpers.ts
@@ -0,0 +1,27 @@
+export function optimalCandlestickWidth(
+ barSpacing: number,
+ pixelRatio: number
+): number {
+ const barSpacingSpecialCaseFrom = 2.5;
+ const barSpacingSpecialCaseTo = 4;
+ const barSpacingSpecialCaseCoeff = 3;
+ if (
+ barSpacing >= barSpacingSpecialCaseFrom &&
+ barSpacing <= barSpacingSpecialCaseTo
+ ) {
+ return Math.floor(barSpacingSpecialCaseCoeff * pixelRatio);
+ }
+ // coeff should be 1 on small barspacing and go to 0.8 while groing bar spacing
+ const barSpacingReducingCoeff = 0.2;
+ const coeff =
+ 1 -
+ (barSpacingReducingCoeff *
+ Math.atan(
+ Math.max(barSpacingSpecialCaseTo, barSpacing) - barSpacingSpecialCaseTo
+ )) /
+ (Math.PI * 0.5);
+ const res = Math.floor(barSpacing * coeff * pixelRatio);
+ const scaledBarSpacing = Math.floor(barSpacing * pixelRatio);
+ const optimal = Math.min(res, scaledBarSpacing);
+ return Math.max(Math.floor(pixelRatio), optimal);
+}
diff --git a/plugin-examples/src/plugins/rounded-candles-series/renderer.ts b/plugin-examples/src/plugins/rounded-candles-series/renderer.ts
new file mode 100644
index 0000000000..324c55131a
--- /dev/null
+++ b/plugin-examples/src/plugins/rounded-candles-series/renderer.ts
@@ -0,0 +1,157 @@
+import {
+ CanvasRenderingTarget2D,
+ BitmapCoordinatesRenderingScope,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Range,
+ Time,
+} from 'lightweight-charts';
+import {
+ RoundedCandleSeriesOptions,
+} from './rounded-candles-series';
+import { RoundedCandleSeriesData } from './data';
+import { candlestickWidth } from '../../helpers/dimensions/candles';
+import { gridAndCrosshairMediaWidth } from '../../helpers/dimensions/crosshair-width';
+import { positionsBox, positionsLine } from '../../helpers/dimensions/positions';
+
+interface BarItem {
+ openY: number;
+ highY: number;
+ lowY: number;
+ closeY: number;
+ x: number;
+ isUp: boolean;
+}
+
+export class RoundedCandleSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: RoundedCandleSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: RoundedCandleSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+
+ let lastClose = -Infinity;
+ const bars: BarItem[] = this._data.bars.map(bar => {
+ const isUp = bar.originalData.close >= lastClose;
+ lastClose = bar.originalData.close ?? lastClose;
+ const openY = priceToCoordinate(bar.originalData.open as number) ?? 0;
+ const highY = priceToCoordinate(bar.originalData.high as number) ?? 0;
+ const lowY = priceToCoordinate(bar.originalData.low as number) ?? 0;
+ const closeY = priceToCoordinate(bar.originalData.close as number) ?? 0;
+ return {
+ openY,
+ highY,
+ lowY,
+ closeY,
+ x: bar.x,
+ isUp,
+ };
+ });
+
+ const radius = this._options.radius(this._data.barSpacing);
+ this._drawWicks(renderingScope, bars, this._data.visibleRange);
+ this._drawCandles(renderingScope, bars, this._data.visibleRange, radius);
+ }
+
+ private _drawWicks(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ bars: readonly BarItem[],
+ visibleRange: Range
+ ): void {
+ if (this._data === null || this._options === null) {
+ return;
+ }
+
+ const {
+ context: ctx,
+ horizontalPixelRatio,
+ verticalPixelRatio,
+ } = renderingScope;
+
+ const wickWidth = gridAndCrosshairMediaWidth(horizontalPixelRatio);
+
+ for (let i = visibleRange.from; i < visibleRange.to; i++) {
+ const bar = bars[i];
+ ctx.fillStyle = bar.isUp
+ ? this._options.wickUpColor
+ : this._options.wickDownColor;
+
+ const verticalPositions = positionsBox(bar.lowY, bar.highY, verticalPixelRatio);
+ const linePositions = positionsLine(bar.x, horizontalPixelRatio, wickWidth);
+ ctx.fillRect(linePositions.position, verticalPositions.position, linePositions.length, verticalPositions.length);
+ }
+ }
+
+ private _drawCandles(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ bars: readonly BarItem[],
+ visibleRange: Range,
+ radius: number
+ ): void {
+ if (this._data === null || this._options === null) {
+ return;
+ }
+
+ const {
+ context: ctx,
+ horizontalPixelRatio,
+ verticalPixelRatio,
+ } = renderingScope;
+
+ // we want this in media width therefore using 1
+ // positionsLine will adjust for pixelRatio
+ const candleBodyWidth = candlestickWidth(this._data.barSpacing, 1);
+
+ for (let i = visibleRange.from; i < visibleRange.to; i++) {
+ const bar = bars[i];
+
+ const verticalPositions = positionsBox(Math.min(bar.openY, bar.closeY), Math.max(bar.openY, bar.closeY), verticalPixelRatio);
+ const linePositions = positionsLine(bar.x, horizontalPixelRatio, candleBodyWidth);
+
+ ctx.fillStyle = bar.isUp
+ ? this._options.upColor
+ : this._options.downColor;
+
+ // roundRect might need to polyfilled for older browsers
+ if (ctx.roundRect) {
+ ctx.beginPath();
+ ctx.roundRect(linePositions.position, verticalPositions.position, linePositions.length, verticalPositions.length, radius);
+ ctx.fill();
+ } else {
+ ctx.fillRect(linePositions.position, verticalPositions.position, linePositions.length, verticalPositions.length);
+ }
+ }
+ }
+}
diff --git a/plugin-examples/src/plugins/rounded-candles-series/rounded-candles-series.ts b/plugin-examples/src/plugins/rounded-candles-series/rounded-candles-series.ts
new file mode 100644
index 0000000000..c5195e4dc2
--- /dev/null
+++ b/plugin-examples/src/plugins/rounded-candles-series/rounded-candles-series.ts
@@ -0,0 +1,72 @@
+import {
+ CustomSeriesOptions,
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ customSeriesDefaultOptions,
+ CandlestickSeriesOptions,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { RoundedCandleSeriesData, /* isRoundedCandleData */ } from './data';
+import { RoundedCandleSeriesRenderer } from './renderer';
+
+export interface RoundedCandleSeriesOptions
+ extends CustomSeriesOptions,
+ Exclude<
+ CandlestickSeriesOptions,
+ 'borderVisible' | 'borderColor' | 'borderUpColor' | 'borderDownColor'
+ > {
+ radius: (barSpacing: number) => number;
+}
+
+const defaultOptions: RoundedCandleSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ upColor: '#26a69a',
+ downColor: '#ef5350',
+ wickVisible: true,
+ borderVisible: true,
+ borderColor: '#378658',
+ borderUpColor: '#26a69a',
+ borderDownColor: '#ef5350',
+ wickColor: '#737375',
+ wickUpColor: '#26a69a',
+ wickDownColor: '#ef5350',
+ radius: function (bs: number) {
+ if (bs < 4) return 0;
+ return bs / 3;
+ },
+} as const;
+
+export class RoundedCandleSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: RoundedCandleSeriesRenderer;
+
+ constructor() {
+ this._renderer = new RoundedCandleSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ return [plotRow.high, plotRow.low, plotRow.close];
+ }
+
+ renderer(): RoundedCandleSeriesRenderer {
+ return this._renderer;
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return (data as Partial).close === undefined;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: RoundedCandleSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/session-highlighting/example/example.ts b/plugin-examples/src/plugins/session-highlighting/example/example.ts
new file mode 100644
index 0000000000..5630ab7977
--- /dev/null
+++ b/plugin-examples/src/plugins/session-highlighting/example/example.ts
@@ -0,0 +1,34 @@
+import { Time, createChart, isBusinessDay, isUTCTimestamp } from 'lightweight-charts';
+import { generateAlternativeCandleData } from '../../../sample-data';
+import { SessionHighlighting } from '../session-highlighting';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const candleSeries = chart.addCandlestickSeries();
+const data = generateAlternativeCandleData();
+candleSeries.setData(data);
+
+function getDate(time: Time): Date {
+ if (isUTCTimestamp(time)) {
+ return new Date(time * 1000);
+ } else if (isBusinessDay(time)) {
+ return new Date(time.year, time.month, time.day);
+ } else {
+ return new Date(time);
+ }
+}
+
+const sessionHighlighter = (time: Time) => {
+ const date = getDate(time);
+ const dayOfWeek = date.getDay();
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
+ // Weekend 🏖️
+ return 'rgba(255, 152, 1, 0.08)'
+ }
+ return 'rgba(41, 98, 255, 0.08)';
+};
+
+const sessionHighlighting = new SessionHighlighting(sessionHighlighter);
+candleSeries.attachPrimitive(sessionHighlighting);
diff --git a/plugin-examples/src/plugins/session-highlighting/example/index.html b/plugin-examples/src/plugins/session-highlighting/example/index.html
new file mode 100644
index 0000000000..08c2a0a88f
--- /dev/null
+++ b/plugin-examples/src/plugins/session-highlighting/example/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Lightweight Charts - Session Highlighting Plugin Example
+
+
+
+
+
+
Session Highlighting
+
+ A plugin for shading the background behind the bars on the chart
+ according to a provided function. This could be used to show pre and
+ post market sessions, or any other case where you would like to adjust
+ the background color behind specific data points.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/session-highlighting/session-highlighting.ts b/plugin-examples/src/plugins/session-highlighting/session-highlighting.ts
new file mode 100644
index 0000000000..174cef672a
--- /dev/null
+++ b/plugin-examples/src/plugins/session-highlighting/session-highlighting.ts
@@ -0,0 +1,151 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ Coordinate,
+ DataChangedScope,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesAttachedParameter,
+ SeriesDataItemTypeMap,
+ SeriesPrimitivePaneViewZOrder,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+import { PluginBase } from '../plugin-base';
+
+interface SessionHighlightingRendererData {
+ x: Coordinate | number;
+ color: string;
+}
+
+class SessionHighlightingPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _viewData: SessionHighlightingViewData;
+ constructor(data: SessionHighlightingViewData) {
+ this._viewData = data;
+ }
+ draw(target: CanvasRenderingTarget2D) {
+ const points: SessionHighlightingRendererData[] = this._viewData.data;
+ target.useBitmapCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ try {
+ const yTop = 0;
+ const height = scope.bitmapSize.height;
+ const halfWidth =
+ (scope.horizontalPixelRatio * this._viewData.barWidth) / 2;
+ const cutOff = -1 * (halfWidth + 1);
+ const maxX = scope.bitmapSize.width;
+ points.forEach(point => {
+ const xScaled = point.x * scope.horizontalPixelRatio;
+ if (xScaled < cutOff) return;
+ ctx.fillStyle = point.color || 'rgba(0, 0, 0, 0)';
+ const x1 = Math.max(0, Math.round(xScaled - halfWidth));
+ const x2 = Math.min(maxX, Math.round(xScaled + halfWidth));
+ ctx.fillRect(x1, yTop, x2 - x1, height);
+ });
+ } finally {
+ ctx.restore();
+ }
+ });
+ }
+}
+
+interface SessionHighlightingViewData {
+ data: SessionHighlightingRendererData[];
+ options: Required;
+ barWidth: number;
+}
+
+class SessionHighlightingPaneView implements ISeriesPrimitivePaneView {
+ _source: SessionHighlighting;
+ _data: SessionHighlightingViewData;
+
+ constructor(source: SessionHighlighting) {
+ this._source = source;
+ this._data = {
+ data: [],
+ barWidth: 6,
+ options: this._source._options,
+ };
+ }
+
+ update() {
+ const timeScale = this._source.chart.timeScale();
+ this._data.data = this._source._backgroundColors.map(d => {
+ return {
+ x: timeScale.timeToCoordinate(d.time) ?? -100,
+ color: d.color,
+ };
+ });
+ if (this._data.data.length > 1) {
+ this._data.barWidth = this._data.data[1].x - this._data.data[0].x;
+ } else {
+ this._data.barWidth = 6;
+ }
+ }
+
+ renderer() {
+ return new SessionHighlightingPaneRenderer(this._data);
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'bottom';
+ }
+}
+
+export interface SessionHighlightingOptions {}
+
+const defaults: Required = {};
+
+interface BackgroundData {
+ time: Time;
+ color: string;
+}
+
+export type SessionHighlighter = (date: Time) => string;
+
+export class SessionHighlighting
+ extends PluginBase
+ implements ISeriesPrimitive
+{
+ _paneViews: SessionHighlightingPaneView[];
+ _seriesData: SeriesDataItemTypeMap[SeriesType][] = [];
+ _backgroundColors: BackgroundData[] = [];
+ _options: Required;
+ _highlighter: SessionHighlighter;
+
+ constructor(
+ highlighter: SessionHighlighter,
+ options: SessionHighlightingOptions = {}
+ ) {
+ super();
+ this._highlighter = highlighter;
+ this._options = { ...defaults, ...options };
+ this._paneViews = [new SessionHighlightingPaneView(this)];
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ attached(p: SeriesAttachedParameter): void {
+ super.attached(p);
+ this.dataUpdated('full');
+ }
+
+ dataUpdated(_scope: DataChangedScope) {
+ // plugin base has fired a data changed event
+ // TODO: only update the last value if the scope is 'update'
+ this._backgroundColors = this.series.data().map(dataPoint => {
+ return {
+ time: dataPoint.time,
+ color: this._highlighter(dataPoint.time),
+ };
+ });
+ this.requestUpdate();
+ }
+}
diff --git a/plugin-examples/src/plugins/stacked-area-series/data.ts b/plugin-examples/src/plugins/stacked-area-series/data.ts
new file mode 100644
index 0000000000..9bba1fc672
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-area-series/data.ts
@@ -0,0 +1,8 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * StackedArea Series Data
+ */
+export interface StackedAreaData extends CustomData {
+ values: number[];
+}
diff --git a/plugin-examples/src/plugins/stacked-area-series/example/example.ts b/plugin-examples/src/plugins/stacked-area-series/example/example.ts
new file mode 100644
index 0000000000..efd31004db
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-area-series/example/example.ts
@@ -0,0 +1,24 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { StackedAreaData } from '../data';
+import { multipleBarData } from '../../../sample-data';
+import { StackedAreaSeries } from '../stacked-area-series';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ rightPriceScale: {
+ scaleMargins: {
+ top: 0.05,
+ bottom: 0.05,
+ }
+ }
+}));
+
+const customSeriesView = new StackedAreaSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+});
+
+const data: (StackedAreaData | WhitespaceData)[] = multipleBarData(5, 200, 2);
+myCustomSeries.setData(data);
+
+chart.timeScale().fitContent();
diff --git a/plugin-examples/src/plugins/stacked-area-series/example/index.html b/plugin-examples/src/plugins/stacked-area-series/example/index.html
new file mode 100644
index 0000000000..2e7e32a58e
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-area-series/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - StackedArea Series Plugin Example
+
+
+
+
+
+
Stacked Area Series
+
+ Multiple filled areas stacked on top of each other, representing the
+ proportion of each variable at different points along the axis.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/stacked-area-series/options.ts b/plugin-examples/src/plugins/stacked-area-series/options.ts
new file mode 100644
index 0000000000..13da7f3044
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-area-series/options.ts
@@ -0,0 +1,26 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface StackedAreaColor {
+ line: string;
+ area: string;
+}
+
+export interface StackedAreaSeriesOptions extends CustomSeriesOptions {
+ colors: readonly StackedAreaColor[];
+ lineWidth: number;
+}
+
+export const defaultOptions: StackedAreaSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ colors: [
+ { line: 'rgb(41, 98, 255)', area: 'rgba(41, 98, 255, 0.2)' },
+ { line: 'rgb(225, 87, 90)', area: 'rgba(225, 87, 90, 0.2)' },
+ { line: 'rgb(242, 142, 44)', area: 'rgba(242, 142, 44, 0.2)' },
+ { line: 'rgb(164, 89, 209)', area: 'rgba(164, 89, 209, 0.2)' },
+ { line: 'rgb(27, 156, 133)', area: 'rgba(27, 156, 133, 0.2)' },
+ ],
+ lineWidth: 2,
+} as const;
diff --git a/plugin-examples/src/plugins/stacked-area-series/renderer.ts b/plugin-examples/src/plugins/stacked-area-series/renderer.ts
new file mode 100644
index 0000000000..d5b26b4b29
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-area-series/renderer.ts
@@ -0,0 +1,205 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Range,
+ Time,
+} from 'lightweight-charts';
+import { StackedAreaData } from './data';
+import { StackedAreaSeriesOptions } from './options';
+
+interface Position {
+ x: number;
+ y: number;
+}
+
+interface LinePathData {
+ path: Path2D;
+ first: Position;
+ last: Position;
+}
+
+interface StackedAreaBarItem {
+ x: number;
+ ys: number[];
+}
+
+function cumulativeBuildUp(arr: number[]): number[] {
+ let sum = 0;
+ return arr.map(value => {
+ const newValue = sum + value;
+ sum = newValue;
+ return newValue;
+ });
+}
+
+export class StackedAreaSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: StackedAreaSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: StackedAreaSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+
+ const options = this._options;
+ const bars: StackedAreaBarItem[] = this._data.bars.map(bar => {
+ return {
+ x: bar.x,
+ ys: cumulativeBuildUp(bar.originalData.values).map(
+ value => priceToCoordinate(value) ?? 0
+ ),
+ };
+ });
+ const zeroY = priceToCoordinate(0) ?? 0;
+ renderingScope.context.save();
+ const linesMeshed = this._createLinePaths(
+ bars,
+ this._data.visibleRange,
+ renderingScope,
+ zeroY * renderingScope.verticalPixelRatio
+ );
+ const areaPaths = this._createAreas(linesMeshed);
+ const colorsCount = options.colors.length;
+ areaPaths.forEach((areaPath, index) => {
+ renderingScope.context.fillStyle =
+ options.colors[index % colorsCount].area;
+ renderingScope.context.fill(areaPath);
+ });
+ renderingScope.context.lineWidth =
+ options.lineWidth * renderingScope.verticalPixelRatio;
+ renderingScope.context.lineJoin = 'round';
+ linesMeshed.forEach((linePath, index) => {
+ if (index == 0) return;
+ renderingScope.context.beginPath();
+ renderingScope.context.strokeStyle =
+ options.colors[(index - 1) % colorsCount].line;
+ renderingScope.context.stroke(linePath.path);
+ });
+ renderingScope.context.restore();
+ }
+
+ _createLinePaths(
+ bars: StackedAreaBarItem[],
+ visibleRange: Range,
+ renderingScope: BitmapCoordinatesRenderingScope,
+ zeroY: number
+ ) {
+ const { horizontalPixelRatio, verticalPixelRatio } = renderingScope;
+ const oddLines: LinePathData[] = [];
+ const evenLines: LinePathData[] = [];
+ let firstBar = true;
+ for (let i = visibleRange.from; i < visibleRange.to; i++) {
+ const stack = bars[i];
+ let lineIndex = 0;
+ stack.ys.forEach((yMedia, index) => {
+ if (index % 2 !== 0) {
+ return; // only doing odd at the moment
+ }
+ const x = stack.x * horizontalPixelRatio;
+ const y = yMedia * verticalPixelRatio;
+ if (firstBar) {
+ oddLines[lineIndex] = {
+ path: new Path2D(),
+ first: { x, y },
+ last: { x, y },
+ };
+ oddLines[lineIndex].path.moveTo(x, y);
+ } else {
+ oddLines[lineIndex].path.lineTo(x, y);
+ oddLines[lineIndex].last.x = x;
+ oddLines[lineIndex].last.y = y;
+ }
+ lineIndex += 1;
+ });
+ firstBar = false;
+ }
+ firstBar = true;
+ for (let i = visibleRange.to - 1; i >= visibleRange.from; i--) {
+ const stack = bars[i];
+ let lineIndex = 0;
+ stack.ys.forEach((yMedia, index) => {
+ if (index % 2 === 0) {
+ return; // only doing even at the moment
+ }
+ const x = stack.x * horizontalPixelRatio;
+ const y = yMedia * verticalPixelRatio;
+ if (firstBar) {
+ evenLines[lineIndex] = {
+ path: new Path2D(),
+ first: { x, y },
+ last: { x, y },
+ };
+ evenLines[lineIndex].path.moveTo(x, y);
+ } else {
+ evenLines[lineIndex].path.lineTo(x, y);
+ evenLines[lineIndex].last.x = x;
+ evenLines[lineIndex].last.y = y;
+ }
+ lineIndex += 1;
+ });
+ firstBar = false;
+ }
+
+ const baseLine = {
+ path: new Path2D(),
+ first: { x: oddLines[0].last.x, y: zeroY },
+ last: { x: oddLines[0].first.x, y: zeroY },
+ };
+ baseLine.path.moveTo(oddLines[0].last.x, zeroY);
+ baseLine.path.lineTo(oddLines[0].first.x, zeroY);
+ const linesMeshed: LinePathData[] = [baseLine];
+ for (let i = 0; i < oddLines.length; i++) {
+ linesMeshed.push(oddLines[i]);
+ if (i < evenLines.length) {
+ linesMeshed.push(evenLines[i]);
+ }
+ }
+
+ return linesMeshed;
+ }
+
+ _createAreas(linesMeshed: LinePathData[]): Path2D[] {
+ const areas: Path2D[] = [];
+ for (let i = 1; i < linesMeshed.length; i++) {
+ const areaPath = new Path2D(linesMeshed[i - 1].path);
+ areaPath.lineTo(linesMeshed[i].first.x, linesMeshed[i].first.y);
+ areaPath.addPath(linesMeshed[i].path);
+ areaPath.lineTo(linesMeshed[i - 1].first.x, linesMeshed[i - 1].first.y);
+ areaPath.closePath();
+ areas.push(areaPath);
+ }
+ return areas;
+ }
+}
diff --git a/plugin-examples/src/plugins/stacked-area-series/stacked-area-series.ts b/plugin-examples/src/plugins/stacked-area-series/stacked-area-series.ts
new file mode 100644
index 0000000000..25ccbef538
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-area-series/stacked-area-series.ts
@@ -0,0 +1,49 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { StackedAreaSeriesOptions, defaultOptions } from './options';
+import { StackedAreaSeriesRenderer } from './renderer';
+import { StackedAreaData } from './data';
+
+export class StackedAreaSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: StackedAreaSeriesRenderer;
+
+ constructor() {
+ this._renderer = new StackedAreaSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ return [
+ 0,
+ plotRow.values.reduce(
+ (previousValue, currentValue) => previousValue + currentValue,
+ 0
+ ),
+ ];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return !Boolean((data as Partial).values?.length);
+ }
+
+ renderer(): StackedAreaSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: StackedAreaSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/stacked-bars-series/data.ts b/plugin-examples/src/plugins/stacked-bars-series/data.ts
new file mode 100644
index 0000000000..b29a38e4f9
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-bars-series/data.ts
@@ -0,0 +1,8 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * StackedBars Series Data
+ */
+export interface StackedBarsData extends CustomData {
+ values: number[];
+}
diff --git a/plugin-examples/src/plugins/stacked-bars-series/example/example.ts b/plugin-examples/src/plugins/stacked-bars-series/example/example.ts
new file mode 100644
index 0000000000..b871394d37
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-bars-series/example/example.ts
@@ -0,0 +1,20 @@
+import { WhitespaceData, createChart } from 'lightweight-charts';
+import { StackedBarsSeries } from '../stacked-bars-series';
+import { StackedBarsData } from '../data';
+import { multipleBarData } from '../../../sample-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ timeScale: {
+ minBarSpacing: 3,
+ }
+}));
+
+const customSeriesView = new StackedBarsSeries();
+const myCustomSeries = chart.addCustomSeries(customSeriesView, {
+ /* Options */
+ color: 'black', // for the price line
+});
+
+const data: (StackedBarsData | WhitespaceData)[] = multipleBarData(3, 200, 20);
+myCustomSeries.setData(data);
diff --git a/plugin-examples/src/plugins/stacked-bars-series/example/index.html b/plugin-examples/src/plugins/stacked-bars-series/example/index.html
new file mode 100644
index 0000000000..1c97edadd5
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-bars-series/example/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Lightweight Charts - StackedBars Series Plugin Example
+
+
+
+
+
+
Stacked Bars Series
+
+ A stacked bar plot is a graphical representation that displays
+ categories as segments of a rectangular bar, stacked on top of each
+ other. Each segment represents the contribution of a different variable
+ to the total length of the bar, allowing for visual comparison of both
+ the individual and cumulative values.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/stacked-bars-series/options.ts b/plugin-examples/src/plugins/stacked-bars-series/options.ts
new file mode 100644
index 0000000000..8b30b9740c
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-bars-series/options.ts
@@ -0,0 +1,19 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface StackedBarsSeriesOptions extends CustomSeriesOptions {
+ colors: readonly string[];
+}
+
+export const defaultOptions: StackedBarsSeriesOptions = {
+ ...customSeriesDefaultOptions,
+ colors: [
+ '#2962FF',
+ '#E1575A',
+ '#F28E2C',
+ 'rgb(164, 89, 209)',
+ 'rgb(27, 156, 133)',
+ ],
+} as const;
diff --git a/plugin-examples/src/plugins/stacked-bars-series/renderer.ts b/plugin-examples/src/plugins/stacked-bars-series/renderer.ts
new file mode 100644
index 0000000000..e58e1c8e27
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-bars-series/renderer.ts
@@ -0,0 +1,112 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { StackedBarsData } from './data';
+import { StackedBarsSeriesOptions } from './options';
+import {
+ ColumnPosition,
+ calculateColumnPositionsInPlace,
+} from '../../helpers/dimensions/columns';
+import { positionsBox } from '../../helpers/dimensions/positions';
+
+interface StackedBarsBarItem {
+ x: number;
+ ys: number[];
+ column?: ColumnPosition;
+}
+
+function cumulativeBuildUp(arr: number[]): number[] {
+ let sum = 0;
+ return arr.map(value => {
+ const newValue = sum + value;
+ sum = newValue;
+ return newValue;
+ });
+}
+
+export class StackedBarsSeriesRenderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: StackedBarsSeriesOptions | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: StackedBarsSeriesOptions
+ ): void {
+ this._data = data;
+ this._options = options;
+ }
+
+ _drawImpl(
+ renderingScope: BitmapCoordinatesRenderingScope,
+ priceToCoordinate: PriceToCoordinateConverter
+ ): void {
+ if (
+ this._data === null ||
+ this._data.bars.length === 0 ||
+ this._data.visibleRange === null ||
+ this._options === null
+ ) {
+ return;
+ }
+ const options = this._options;
+ const bars: StackedBarsBarItem[] = this._data.bars.map(bar => {
+ return {
+ x: bar.x,
+ ys: cumulativeBuildUp(bar.originalData.values).map(
+ value => priceToCoordinate(value) ?? 0
+ ),
+ };
+ });
+ calculateColumnPositionsInPlace(
+ bars,
+ this._data.barSpacing,
+ renderingScope.horizontalPixelRatio,
+ this._data.visibleRange.from,
+ this._data.visibleRange.to
+ );
+ const zeroY = priceToCoordinate(0) ?? 0;
+ renderingScope.context.save();
+ for (
+ let i = this._data.visibleRange.from;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const stack = bars[i];
+ const column = stack.column;
+ if (!column) return;
+ let previousY = zeroY;
+ const width = Math.min(Math.max(renderingScope.horizontalPixelRatio, column.right - column.left), this._data.barSpacing * renderingScope.horizontalPixelRatio);
+ stack.ys.forEach((y, index) => {
+ const color = options.colors[index % options.colors.length];
+ const stackBoxPositions = positionsBox(previousY, y, renderingScope.verticalPixelRatio);
+ renderingScope.context.fillStyle = color;
+ renderingScope.context.fillRect(
+ column.left,
+ stackBoxPositions.position,
+ width,
+ stackBoxPositions.length
+ );
+ previousY = y;
+ });
+ }
+ renderingScope.context.restore();
+ }
+}
diff --git a/plugin-examples/src/plugins/stacked-bars-series/stacked-bars-series.ts b/plugin-examples/src/plugins/stacked-bars-series/stacked-bars-series.ts
new file mode 100644
index 0000000000..bdd2e22797
--- /dev/null
+++ b/plugin-examples/src/plugins/stacked-bars-series/stacked-bars-series.ts
@@ -0,0 +1,49 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { StackedBarsSeriesOptions, defaultOptions } from './options';
+import { StackedBarsSeriesRenderer } from './renderer';
+import { StackedBarsData } from './data';
+
+export class StackedBarsSeries
+ implements ICustomSeriesPaneView
+{
+ _renderer: StackedBarsSeriesRenderer;
+
+ constructor() {
+ this._renderer = new StackedBarsSeriesRenderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ return [
+ 0,
+ plotRow.values.reduce(
+ (previousValue, currentValue) => previousValue + currentValue,
+ 0
+ ),
+ ];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ return !Boolean((data as Partial).values?.length);
+ }
+
+ renderer(): StackedBarsSeriesRenderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: StackedBarsSeriesOptions
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/plugin-examples/src/plugins/tooltip/example/example.ts b/plugin-examples/src/plugins/tooltip/example/example.ts
new file mode 100644
index 0000000000..a07c22ea3b
--- /dev/null
+++ b/plugin-examples/src/plugins/tooltip/example/example.ts
@@ -0,0 +1,65 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { TooltipPrimitive } from '../tooltip';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+ grid: {
+ vertLines: {
+ visible: false,
+ },
+ horzLines: {
+ visible: false,
+ },
+ },
+ timeScale: {
+ borderVisible: false,
+ },
+ rightPriceScale: {
+ borderVisible: false,
+ },
+}));
+
+const areaSeries = chart.addAreaSeries({
+ lineColor: 'rgb(4,153,129)',
+ topColor: 'rgba(4,153,129, 0.4)',
+ bottomColor: 'rgba(4,153,129, 0)',
+ priceLineVisible: false,
+});
+areaSeries.setData(generateLineData());
+
+const tooltipPrimitive = new TooltipPrimitive({
+ lineColor: 'rgba(0, 0, 0, 0.2)',
+ tooltip: {
+ followMode: 'top',
+ },
+});
+
+areaSeries.attachPrimitive(tooltipPrimitive);
+
+const trackingButtonEl = document.querySelector('#tracking-button');
+if (trackingButtonEl) trackingButtonEl.classList.add('grey');
+const topButtonEl = document.querySelector('#top-button');
+if (trackingButtonEl) {
+ trackingButtonEl.addEventListener('click', function () {
+ trackingButtonEl.classList.remove('grey');
+ if (topButtonEl) topButtonEl.classList.add('grey');
+ tooltipPrimitive.applyOptions({
+ tooltip: {
+ followMode: 'tracking',
+ },
+ });
+ });
+}
+
+if (topButtonEl) {
+ topButtonEl.addEventListener('click', function () {
+ topButtonEl.classList.remove('grey');
+ if (trackingButtonEl) trackingButtonEl.classList.add('grey');
+ tooltipPrimitive.applyOptions({
+ tooltip: {
+ followMode: 'top',
+ },
+ });
+ });
+}
diff --git a/plugin-examples/src/plugins/tooltip/example/index.html b/plugin-examples/src/plugins/tooltip/example/index.html
new file mode 100644
index 0000000000..18d511937a
--- /dev/null
+++ b/plugin-examples/src/plugins/tooltip/example/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Lightweight Charts - Tooltip Plugin Example
+
+
+
+
+
+ Stick to Top
+ Tracking
+
+
+
+
Tooltip
+
+ A tooltip created using an HTML DOM Element which can either track the
+ cursor vertical, or remain fixed to a specific vertical position. The
+ Delta Tooltip is an example of using the Canvas instead of HTML elements
+ to achieve the tooltip.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/tooltip/tooltip-element.ts b/plugin-examples/src/plugins/tooltip/tooltip-element.ts
new file mode 100644
index 0000000000..3f57089718
--- /dev/null
+++ b/plugin-examples/src/plugins/tooltip/tooltip-element.ts
@@ -0,0 +1,209 @@
+import { IChartApi } from 'lightweight-charts';
+
+export interface TooltipOptions {
+ title: string;
+ followMode: 'top' | 'tracking';
+ /** fallback horizontal deadzone width */
+ horizontalDeadzoneWidth: number;
+ verticalDeadzoneHeight: number;
+ verticalSpacing: number;
+ /** topOffset is the vertical spacing when followMode is 'top' */
+ topOffset: number;
+}
+
+const defaultOptions: TooltipOptions = {
+ title: '',
+ followMode: 'tracking',
+ horizontalDeadzoneWidth: 45,
+ verticalDeadzoneHeight: 100,
+ verticalSpacing: 20,
+ topOffset: 20,
+};
+
+export interface TooltipContentData {
+ title?: string;
+ price: string;
+ date: string;
+ time: string;
+}
+
+export interface TooltipPosition {
+ visible: boolean;
+ paneX: number;
+ paneY: number;
+}
+
+export class TooltipElement {
+ private _chart: IChartApi | null;
+
+ private _element: HTMLDivElement | null;
+ private _titleElement: HTMLDivElement | null;
+ private _priceElement: HTMLDivElement | null;
+ private _dateElement: HTMLDivElement | null;
+ private _timeElement: HTMLDivElement | null;
+
+ private _options: TooltipOptions;
+
+ private _lastTooltipWidth: number | null = null;
+
+ public constructor(chart: IChartApi, options: Partial) {
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._chart = chart;
+
+ const element = document.createElement('div');
+ applyStyle(element, {
+ display: 'flex',
+ 'flex-direction': 'column',
+ 'align-items': 'center',
+ position: 'absolute',
+ transform: 'translate(calc(0px - 50%), 0px)',
+ opacity: '0',
+ left: '0%',
+ top: '0',
+ 'z-index': '100',
+ 'background-color': 'white',
+ 'border-radius': '4px',
+ padding: '5px 10px',
+ 'font-family':
+ "-apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif",
+ 'font-size': '12px',
+ 'font-weight': '400',
+ 'box-shadow': '0px 2px 4px rgba(0, 0, 0, 0.2)',
+ 'line-height': '16px',
+ 'pointer-events': 'none',
+ color: '#131722',
+ });
+
+ const titleElement = document.createElement('div');
+ applyStyle(titleElement, {
+ 'font-size': '16px',
+ 'line-height': '24px',
+ 'font-weight': '590',
+ });
+ setElementText(titleElement, this._options.title);
+ element.appendChild(titleElement);
+
+ const priceElement = document.createElement('div');
+ applyStyle(priceElement, {
+ 'font-size': '14px',
+ 'line-height': '18px',
+ 'font-weight': '590',
+ });
+ setElementText(priceElement, '');
+ element.appendChild(priceElement);
+
+ const dateElement = document.createElement('div');
+ applyStyle(dateElement, {
+ color: '#787B86',
+ });
+ setElementText(dateElement, '');
+ element.appendChild(dateElement);
+
+ const timeElement = document.createElement('div');
+ applyStyle(timeElement, {
+ color: '#787B86',
+ });
+ setElementText(timeElement, '');
+ element.appendChild(timeElement);
+
+ this._element = element;
+ this._titleElement = titleElement;
+ this._priceElement = priceElement;
+ this._dateElement = dateElement;
+ this._timeElement = timeElement;
+
+ const chartElement = this._chart.chartElement();
+ chartElement.appendChild(this._element);
+
+ const chartElementParent = chartElement.parentElement;
+ if (!chartElementParent) {
+ console.error('Chart Element is not attached to the page.');
+ return;
+ }
+ const position = getComputedStyle(chartElementParent).position;
+ if (position !== 'relative' && position !== 'absolute') {
+ console.error('Chart Element position is expected be `relative` or `absolute`.');
+ }
+ }
+
+ public destroy() {
+ if (this._chart && this._element)
+ this._chart.chartElement().removeChild(this._element);
+ }
+
+ public applyOptions(options: Partial) {
+ this._options = {
+ ...this._options,
+ ...options,
+ };
+ }
+
+ public options(): TooltipOptions {
+ return this._options;
+ }
+
+ public updateTooltipContent(tooltipContentData: TooltipContentData) {
+ if (!this._element) return;
+ const tooltipMeasurement = this._element.getBoundingClientRect();
+ this._lastTooltipWidth = tooltipMeasurement.width;
+ if (tooltipContentData.title !== undefined && this._titleElement) {
+ setElementText(this._titleElement, tooltipContentData.title);
+ }
+ setElementText(this._priceElement, tooltipContentData.price);
+ setElementText(this._dateElement, tooltipContentData.date);
+ setElementText(this._timeElement, tooltipContentData.time);
+ }
+
+ public updatePosition(positionData: TooltipPosition) {
+ if (!this._chart || !this._element) return;
+ this._element.style.opacity = positionData.visible ? '1' : '0';
+ if (!positionData.visible) {
+ return;
+ }
+ const x = this._calculateXPosition(positionData, this._chart);
+ const y = this._calculateYPosition(positionData);
+ this._element.style.transform = `translate(${x}, ${y})`;
+ }
+
+ private _calculateXPosition(
+ positionData: TooltipPosition,
+ chart: IChartApi
+ ): string {
+ const x = positionData.paneX + chart.priceScale('left').width();
+ const deadzoneWidth = this._lastTooltipWidth
+ ? Math.ceil(this._lastTooltipWidth / 2)
+ : this._options.horizontalDeadzoneWidth;
+ const xAdjusted = Math.min(
+ Math.max(deadzoneWidth, x),
+ chart.timeScale().width() - deadzoneWidth
+ );
+ return `calc(${xAdjusted}px - 50%)`;
+ }
+
+ private _calculateYPosition(positionData: TooltipPosition): string {
+ if (this._options.followMode == 'top') {
+ return `${this._options.topOffset}px`;
+ }
+ const y = positionData.paneY;
+ const flip =
+ y <= this._options.verticalSpacing + this._options.verticalDeadzoneHeight;
+ const yPx = y + (flip ? 1 : -1) * this._options.verticalSpacing;
+ const yPct = flip ? '' : ' - 100%';
+ return `calc(${yPx}px${yPct})`;
+ }
+}
+
+function setElementText(element: HTMLDivElement | null, text: string) {
+ if (!element || text === element.innerText) return;
+ element.innerText = text;
+ element.style.display = text ? 'block' : 'none';
+}
+
+function applyStyle(element: HTMLElement, styles: Record) {
+ for (const [key, value] of Object.entries(styles)) {
+ element.style.setProperty(key, value);
+ }
+}
diff --git a/plugin-examples/src/plugins/tooltip/tooltip.ts b/plugin-examples/src/plugins/tooltip/tooltip.ts
new file mode 100644
index 0000000000..3b3868d76e
--- /dev/null
+++ b/plugin-examples/src/plugins/tooltip/tooltip.ts
@@ -0,0 +1,258 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ CrosshairMode,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ MouseEventParams,
+ SeriesPrimitivePaneViewZOrder,
+ ISeriesPrimitive,
+ SeriesAttachedParameter,
+ LineData,
+ WhitespaceData,
+ CandlestickData,
+ Time,
+} from 'lightweight-charts';
+import { TooltipElement, TooltipOptions } from './tooltip-element';
+import { convertTime, formattedDateAndTime } from '../../helpers/time';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+class TooltipCrosshairLinePaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: TooltipCrosshairLineData;
+
+ constructor(data: TooltipCrosshairLineData) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ if (!this._data.visible) return;
+ target.useBitmapCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+ const crosshairPos = positionsLine(
+ this._data.x,
+ scope.horizontalPixelRatio,
+ 1
+ );
+ ctx.fillStyle = this._data.color;
+ ctx.fillRect(
+ crosshairPos.position,
+ this._data.topMargin * scope.verticalPixelRatio,
+ crosshairPos.length,
+ scope.bitmapSize.height
+ );
+ ctx.restore();
+ });
+ }
+}
+
+class MultiTouchCrosshairPaneView implements ISeriesPrimitivePaneView {
+ _data: TooltipCrosshairLineData;
+ constructor(data: TooltipCrosshairLineData) {
+ this._data = data;
+ }
+
+ update(data: TooltipCrosshairLineData): void {
+ this._data = data;
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer | null {
+ return new TooltipCrosshairLinePaneRenderer(this._data);
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'bottom';
+ }
+}
+
+interface TooltipCrosshairLineData {
+ x: number;
+ visible: boolean;
+ color: string;
+ topMargin: number;
+}
+
+const defaultOptions: TooltipPrimitiveOptions = {
+ lineColor: 'rgba(0, 0, 0, 0.2)',
+ priceExtractor: (data: LineData | CandlestickData | WhitespaceData) => {
+ if ((data as LineData).value !== undefined) {
+ return (data as LineData).value.toFixed(2);
+ }
+ if ((data as CandlestickData).close !== undefined) {
+ return (data as CandlestickData).close.toFixed(2);
+ }
+ return '';
+ }
+};
+
+export interface TooltipPrimitiveOptions {
+ lineColor: string;
+ tooltip?: Partial;
+ priceExtractor: (dataPoint: T) => string;
+}
+
+export class TooltipPrimitive implements ISeriesPrimitive {
+ private _options: TooltipPrimitiveOptions;
+ private _tooltip: TooltipElement | undefined = undefined;
+ _paneViews: MultiTouchCrosshairPaneView[];
+ _data: TooltipCrosshairLineData = {
+ x: 0,
+ visible: false,
+ color: 'rgba(0, 0, 0, 0.2)',
+ topMargin: 0,
+ };
+ _attachedParams: SeriesAttachedParameter | undefined;
+
+ constructor(options: Partial) {
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._paneViews = [new MultiTouchCrosshairPaneView(this._data)];
+ }
+
+ attached(param: SeriesAttachedParameter): void {
+ this._attachedParams = param;
+ this._setCrosshairMode();
+ param.chart.subscribeCrosshairMove(this._moveHandler);
+ this._createTooltipElement();
+ }
+
+ detached(): void {
+ const chart = this.chart();
+ if (chart) {
+ chart.unsubscribeCrosshairMove(this._moveHandler);
+ }
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update(this._data));
+ }
+
+ setData(data: TooltipCrosshairLineData) {
+ this._data = data;
+ this.updateAllViews();
+ this._attachedParams?.requestUpdate();
+ }
+
+ currentColor() {
+ return this._options.lineColor;
+ }
+
+ chart() {
+ return this._attachedParams?.chart;
+ }
+
+ series() {
+ return this._attachedParams?.series;
+ }
+
+ applyOptions(options: Partial) {
+ this._options = {
+ ...this._options,
+ ...options,
+ };
+ if (this._tooltip) {
+ this._tooltip.applyOptions({ ...this._options.tooltip });
+ }
+ }
+
+ private _setCrosshairMode() {
+ const chart = this.chart();
+ if (!chart) {
+ throw new Error(
+ 'Unable to change crosshair mode because the chart instance is undefined'
+ );
+ }
+ chart.applyOptions({
+ crosshair: {
+ mode: CrosshairMode.Magnet,
+ vertLine: {
+ visible: false,
+ labelVisible: false,
+ },
+ horzLine: {
+ visible: false,
+ labelVisible: false,
+ }
+ },
+ });
+ }
+
+ private _moveHandler = (param: MouseEventParams) => this._onMouseMove(param);
+
+ private _hideTooltip() {
+ if (!this._tooltip) return;
+ this._tooltip.updateTooltipContent({
+ title: '',
+ price: '',
+ date: '',
+ time: '',
+ });
+ this._tooltip.updatePosition({
+ paneX: 0,
+ paneY: 0,
+ visible: false,
+ });
+ }
+
+ private _hideCrosshair() {
+ this._hideTooltip();
+ this.setData({
+ x: 0,
+ visible: false,
+ color: this.currentColor(),
+ topMargin: 0,
+ });
+ }
+
+ private _onMouseMove(param: MouseEventParams) {
+ const chart = this.chart();
+ const series = this.series();
+ const logical = param.logical;
+ if (!logical || !chart || !series) {
+ this._hideCrosshair();
+ return;
+ }
+ const data = param.seriesData.get(series);
+ if (!data) {
+ this._hideCrosshair();
+ return;
+ }
+ const price = this._options.priceExtractor(data);
+ const coordinate = chart.timeScale().logicalToCoordinate(logical);
+ const [date, time] = formattedDateAndTime(param.time ? convertTime(param.time) : undefined);
+ if (this._tooltip) {
+ const tooltipOptions = this._tooltip.options();
+ const topMargin = tooltipOptions.followMode == 'top' ? tooltipOptions.topOffset + 10 : 0;
+ this.setData({
+ x: coordinate ?? 0,
+ visible: coordinate !== null,
+ color: this.currentColor(),
+ topMargin,
+ });
+ this._tooltip.updateTooltipContent({
+ price,
+ date,
+ time,
+ });
+ this._tooltip.updatePosition({
+ paneX: param.point?.x ?? 0,
+ paneY: param.point?.y ?? 0,
+ visible: true,
+ });
+ }
+ }
+
+ private _createTooltipElement() {
+ const chart = this.chart();
+ if (!chart)
+ throw new Error('Unable to create Tooltip element. Chart not attached');
+ this._tooltip = new TooltipElement(chart, {
+ ...this._options.tooltip,
+ });
+ }
+}
diff --git a/plugin-examples/src/plugins/trend-line/example/example.ts b/plugin-examples/src/plugins/trend-line/example/example.ts
new file mode 100644
index 0000000000..7b07dc29e9
--- /dev/null
+++ b/plugin-examples/src/plugins/trend-line/example/example.ts
@@ -0,0 +1,23 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { TrendLine } from '../trend-line';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+const dataLength = data.length;
+const point1 = {
+ time: data[dataLength - 50].time,
+ price: data[dataLength - 50].value * 0.9,
+};
+const point2 = {
+ time: data[dataLength - 5].time,
+ price: data[dataLength - 5].value * 1.10,
+};
+const trend = new TrendLine(chart, lineSeries, point1, point2);
+lineSeries.attachPrimitive(trend);
diff --git a/plugin-examples/src/plugins/trend-line/example/index.html b/plugin-examples/src/plugins/trend-line/example/index.html
new file mode 100644
index 0000000000..25fd500f0d
--- /dev/null
+++ b/plugin-examples/src/plugins/trend-line/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - Trend Line Plugin Example
+
+
+
+
+
+
Trend Line
+
+ A trend line drawn between two points (defined by price and time values)
+ on the chart. Note: This example is randomly generated.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/trend-line/trend-line.ts b/plugin-examples/src/plugins/trend-line/trend-line.ts
new file mode 100644
index 0000000000..01cd17666d
--- /dev/null
+++ b/plugin-examples/src/plugins/trend-line/trend-line.ts
@@ -0,0 +1,187 @@
+import { BitmapCoordinatesRenderingScope, CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ AutoscaleInfo,
+ Coordinate,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ Logical,
+ SeriesOptionsMap,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+
+class TrendLinePaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _p1: ViewPoint;
+ _p2: ViewPoint;
+ _text1: string;
+ _text2: string;
+ _options: TrendLineOptions;
+
+ constructor(p1: ViewPoint, p2: ViewPoint, text1: string, text2: string, options: TrendLineOptions) {
+ this._p1 = p1;
+ this._p2 = p2;
+ this._text1 = text1;
+ this._text2 = text2;
+ this._options = options;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useBitmapCoordinateSpace(scope => {
+ if (
+ this._p1.x === null ||
+ this._p1.y === null ||
+ this._p2.x === null ||
+ this._p2.y === null
+ )
+ return;
+ const ctx = scope.context;
+ const x1Scaled = Math.round(this._p1.x * scope.horizontalPixelRatio);
+ const y1Scaled = Math.round(this._p1.y * scope.verticalPixelRatio);
+ const x2Scaled = Math.round(this._p2.x * scope.horizontalPixelRatio);
+ const y2Scaled = Math.round(this._p2.y * scope.verticalPixelRatio);
+ ctx.lineWidth = this._options.width;
+ ctx.strokeStyle = this._options.lineColor;
+ ctx.beginPath();
+ ctx.moveTo(x1Scaled, y1Scaled);
+ ctx.lineTo(x2Scaled, y2Scaled);
+ ctx.stroke();
+ this._drawTextLabel(scope, this._text1, x1Scaled, y1Scaled, true);
+ this._drawTextLabel(scope, this._text2, x2Scaled, y2Scaled, false);
+ });
+ }
+
+ _drawTextLabel(scope: BitmapCoordinatesRenderingScope, text: string, x: number, y: number, left: boolean) {
+ scope.context.font = '24px Arial';
+ scope.context.beginPath();
+ const offset = 5 * scope.horizontalPixelRatio;
+ const textWidth = scope.context.measureText(text);
+ const leftAdjustment = left ? textWidth.width + offset * 4 : 0;
+ scope.context.fillStyle = this._options.labelBackgroundColor;
+ scope.context.roundRect(x + offset - leftAdjustment, y - 24, textWidth.width + offset * 2, 24 + offset, 5);
+ scope.context.fill();
+ scope.context.beginPath();
+ scope.context.fillStyle = this._options.labelTextColor;
+ scope.context.fillText(text, x + offset * 2 - leftAdjustment, y);
+ }
+}
+
+interface ViewPoint {
+ x: Coordinate | null;
+ y: Coordinate | null;
+}
+
+class TrendLinePaneView implements ISeriesPrimitivePaneView {
+ _source: TrendLine;
+ _p1: ViewPoint = { x: null, y: null };
+ _p2: ViewPoint = { x: null, y: null };
+
+ constructor(source: TrendLine) {
+ this._source = source;
+ }
+
+ update() {
+ const series = this._source._series;
+ const y1 = series.priceToCoordinate(this._source._p1.price);
+ const y2 = series.priceToCoordinate(this._source._p2.price);
+ const timeScale = this._source._chart.timeScale();
+ const x1 = timeScale.timeToCoordinate(this._source._p1.time);
+ const x2 = timeScale.timeToCoordinate(this._source._p2.time);
+ this._p1 = { x: x1, y: y1 };
+ this._p2 = { x: x2, y: y2 };
+ }
+
+ renderer() {
+ return new TrendLinePaneRenderer(
+ this._p1,
+ this._p2,
+ '' + this._source._p1.price.toFixed(1),
+ '' + this._source._p2.price.toFixed(1),
+ this._source._options
+ );
+ }
+}
+
+interface Point {
+ time: Time;
+ price: number;
+}
+
+export interface TrendLineOptions {
+ lineColor: string;
+ width: number;
+ showLabels: boolean;
+ labelBackgroundColor: string;
+ labelTextColor: string;
+}
+
+const defaultOptions: TrendLineOptions = {
+ lineColor: 'rgb(0, 0, 0)',
+ width: 6,
+ showLabels: true,
+ labelBackgroundColor: 'rgba(255, 255, 255, 0.85)',
+ labelTextColor: 'rgb(0, 0, 0)',
+};
+
+export class TrendLine implements ISeriesPrimitive {
+ _chart: IChartApi;
+ _series: ISeriesApi;
+ _p1: Point;
+ _p2: Point;
+ _paneViews: TrendLinePaneView[];
+ _options: TrendLineOptions;
+ _minPrice: number;
+ _maxPrice: number;
+
+ constructor(
+ chart: IChartApi,
+ series: ISeriesApi,
+ p1: Point,
+ p2: Point,
+ options?: Partial
+ ) {
+ this._chart = chart;
+ this._series = series;
+ this._p1 = p1;
+ this._p2 = p2;
+ this._minPrice = Math.min(this._p1.price, this._p2.price);
+ this._maxPrice = Math.max(this._p1.price, this._p2.price);
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._paneViews = [new TrendLinePaneView(this)];
+ }
+
+ autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
+ const p1Index = this._pointIndex(this._p1);
+ const p2Index = this._pointIndex(this._p2);
+ if (p1Index === null || p2Index === null) return null;
+ if (endTimePoint < p1Index || startTimePoint > p2Index) return null;
+ return {
+ priceRange: {
+ minValue: this._minPrice,
+ maxValue: this._maxPrice,
+ },
+ };
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ _pointIndex(p: Point): number | null {
+ const coordinate = this._chart
+ .timeScale()
+ .timeToCoordinate(p.time);
+ if (coordinate === null) return null;
+ const index = this._chart.timeScale().coordinateToLogical(coordinate);
+ return index;
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/constants.ts b/plugin-examples/src/plugins/user-price-alerts/constants.ts
new file mode 100644
index 0000000000..646b8d7a78
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/constants.ts
@@ -0,0 +1,43 @@
+export const buttonWidth = 21;
+export const buttonHeight = 21;
+export const showButtonDistance = 50;
+export const labelHeight = 17;
+export const borderRadius = 2;
+export const iconPadding = 4;
+export const iconPaddingAlertTop = 2;
+export const clockIconViewBoxSize = 13; // Width
+export const iconSize = 13;
+
+export const showCentreLabelDistance = 50;
+export const averageWidthPerCharacter = 5.81; // doesn't need to be exact, just roughly correct. 12px sans-serif
+export const removeButtonWidth = 26;
+export const centreLabelHeight = 20;
+export const centreLabelInlinePadding = 9;
+
+export const clockPlusIconPaths: Path2D[] = [
+ new Path2D(
+ 'M5.34004 1.12254C4.7902 0.438104 3.94626 0 3 0C1.34315 0 0 1.34315 0 3C0 3.94626 0.438104 4.7902 1.12254 5.34004C1.04226 5.714 1 6.10206 1 6.5C1 9.36902 3.19675 11.725 6 11.9776V10.9725C3.75002 10.7238 2 8.81628 2 6.5C2 4.01472 4.01472 2 6.5 2C8.81628 2 10.7238 3.75002 10.9725 6H11.9776C11.9574 5.77589 11.9237 5.55565 11.8775 5.34011C12.562 4.79026 13.0001 3.9463 13.0001 3C13.0001 1.34315 11.6569 0 10.0001 0C9.05382 0 8.20988 0.438111 7.66004 1.12256C7.28606 1.04227 6.89797 1 6.5 1C6.10206 1 5.714 1.04226 5.34004 1.12254ZM4.28255 1.46531C3.93534 1.17484 3.48809 1 3 1C1.89543 1 1 1.89543 1 3C1 3.48809 1.17484 3.93534 1.46531 4.28255C2.0188 3.02768 3.02768 2.0188 4.28255 1.46531ZM8.71751 1.46534C9.97237 2.01885 10.9812 3.02774 11.5347 4.28262C11.8252 3.93541 12.0001 3.48812 12.0001 3C12.0001 1.89543 11.1047 1 10.0001 1C9.51199 1 9.06472 1.17485 8.71751 1.46534Z'
+ ),
+ new Path2D('M7 7V4H8V8H5V7H7Z'),
+ new Path2D('M10 8V10H8V11H10V13H11V11H13V10H11V8H10Z'),
+];
+
+export const clockIconPaths: Path2D[] = [
+ new Path2D(
+ 'M5.11068 1.65894C3.38969 2.08227 1.98731 3.31569 1.33103 4.93171C0.938579 4.49019 0.700195 3.90868 0.700195 3.27148C0.700195 1.89077 1.81948 0.771484 3.2002 0.771484C3.9664 0.771484 4.65209 1.11617 5.11068 1.65894Z'
+ ),
+ new Path2D(
+ 'M12.5 3.37148C12.5 4.12192 12.1694 4.79514 11.6458 5.25338C11.0902 3.59304 9.76409 2.2857 8.09208 1.7559C8.55066 1.21488 9.23523 0.871484 10 0.871484C11.3807 0.871484 12.5 1.99077 12.5 3.37148Z'
+ ),
+ new Path2D(
+ 'M6.42896 11.4999C8.91424 11.4999 10.929 9.48522 10.929 6.99994C10.929 4.51466 8.91424 2.49994 6.42896 2.49994C3.94367 2.49994 1.92896 4.51466 1.92896 6.99994C1.92896 9.48522 3.94367 11.4999 6.42896 11.4999ZM6.00024 3.99994V6.99994H4.00024V7.99994H7.00024V3.99994H6.00024Z'
+ ),
+ new Path2D(
+ 'M4.08902 0.934101C4.4888 1.08621 4.83946 1.33793 5.11068 1.65894C5.06565 1.67001 5.02084 1.68164 4.97625 1.69382C4.65623 1.78123 4.34783 1.89682 4.0539 2.03776C3.16224 2.4653 2.40369 3.12609 1.8573 3.94108C1.64985 4.2505 1.47298 4.58216 1.33103 4.93171C1.05414 4.6202 0.853937 4.23899 0.760047 3.81771C0.720863 3.6419 0.700195 3.45911 0.700195 3.27148C0.700195 1.89077 1.81948 0.771484 3.2002 0.771484C3.51324 0.771484 3.81285 0.829023 4.08902 0.934101ZM12.3317 4.27515C12.4404 3.99488 12.5 3.69015 12.5 3.37148C12.5 1.99077 11.3807 0.871484 10 0.871484C9.66727 0.871484 9.34974 0.936485 9.05938 1.05448C8.68236 1.20769 8.35115 1.45027 8.09208 1.7559C8.43923 1.8659 8.77146 2.00942 9.08499 2.18265C9.96762 2.67034 10.702 3.39356 11.2032 4.26753C11.3815 4.57835 11.5303 4.90824 11.6458 5.25338C11.947 4.98973 12.1844 4.65488 12.3317 4.27515ZM9.18112 3.43939C8.42029 2.85044 7.46556 2.49994 6.42896 2.49994C3.94367 2.49994 1.92896 4.51466 1.92896 6.99994C1.92896 9.48522 3.94367 11.4999 6.42896 11.4999C8.91424 11.4999 10.929 9.48522 10.929 6.99994C10.929 5.55126 10.2444 4.26246 9.18112 3.43939ZM6.00024 3.99994H7.00024V7.99994H4.00024V6.99994H6.00024V3.99994Z'
+ ),
+];
+
+export const crossViewBoxSize = 10;
+export const crossPath = new Path2D(
+ 'M9.35359 1.35359C9.11789 1.11789 8.88219 0.882187 8.64648 0.646484L5.00004 4.29293L1.35359 0.646484C1.11791 0.882212 0.882212 1.11791 0.646484 1.35359L4.29293 5.00004L0.646484 8.64648C0.882336 8.88204 1.11804 9.11774 1.35359 9.35359L5.00004 5.70714L8.64648 9.35359C8.88217 9.11788 9.11788 8.88217 9.35359 8.64649L5.70714 5.00004L9.35359 1.35359Z'
+);
diff --git a/plugin-examples/src/plugins/user-price-alerts/example/example.ts b/plugin-examples/src/plugins/user-price-alerts/example/example.ts
new file mode 100644
index 0000000000..9f0c95a80e
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/example/example.ts
@@ -0,0 +1,62 @@
+import { LineStyle, createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { UserAlertInfo } from '../state';
+import { UserPriceAlerts } from '../user-price-alerts';
+
+const chartContainer = document.querySelector('#chart');
+if (!chartContainer) throw new Error('Chart Container Missing!');
+const chart = createChart('chart', {
+ autoSize: true,
+ grid: {
+ vertLines: {
+ visible: false,
+ },
+ horzLines: {
+ visible: false,
+ },
+ },
+ timeScale: {
+ borderVisible: false,
+ },
+ rightPriceScale: {
+ borderVisible: false,
+ },
+ crosshair: {
+ horzLine: {
+ visible: false,
+ labelVisible: false,
+ },
+ vertLine: {
+ labelVisible: false,
+ style: LineStyle.Solid,
+ width: 1,
+ },
+ },
+ handleScale: false,
+ handleScroll: false,
+});
+
+const areaSeries = chart.addAreaSeries({
+ lineColor: 'rgb(4,153,129)',
+ topColor: 'rgba(4,153,129, 0.4)',
+ bottomColor: 'rgba(4,153,129, 0)',
+ priceLineVisible: false,
+});
+const data = generateLineData();
+areaSeries.setData(data);
+
+const userPriceAlertsPrimitive = new UserPriceAlerts();
+userPriceAlertsPrimitive.setSymbolName('AAPL');
+areaSeries.attachPrimitive(userPriceAlertsPrimitive);
+
+chart.timeScale().fitContent();
+
+userPriceAlertsPrimitive.alertAdded().subscribe((alertInfo: UserAlertInfo) => {
+ console.log(
+ `➕ Alert added @ ${alertInfo.price} with the id: ${alertInfo.id}`
+ );
+});
+
+userPriceAlertsPrimitive.alertRemoved().subscribe((id: string) => {
+ console.log(`❌ Alert removed with the id: ${id}`);
+});
diff --git a/plugin-examples/src/plugins/user-price-alerts/example/index.html b/plugin-examples/src/plugins/user-price-alerts/example/index.html
new file mode 100644
index 0000000000..bd64dba75c
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/example/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Lightweight Charts - User Price Alerts Plugin Example
+
+
+
+
+
+
+
User Price Alerts
+
+ Price alerts which can be added and removed via user interaction on the
+ chart. Click the button near the crosshair label on the price scale to
+ create a new Price Alert Line.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/user-price-alerts/irenderer-data.ts b/plugin-examples/src/plugins/user-price-alerts/irenderer-data.ts
new file mode 100644
index 0000000000..03e27c48aa
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/irenderer-data.ts
@@ -0,0 +1,37 @@
+interface CrosshairRendererData {
+ y: number;
+ text: string;
+}
+
+interface ShowHoverData {
+ /** Text is used for the hover box */
+ text: string;
+ showHover: true;
+ hoverRemove: boolean;
+}
+
+interface NoHoverData {
+ showHover: false;
+}
+
+interface AlertRendererDataBase {
+ y: number;
+ showHover: boolean;
+ text?: string;
+}
+
+interface CrosshairButtonData {
+ hoverColor: string;
+ crosshairLabelIcon: Path2D[];
+ hovering: boolean;
+}
+
+export type AlertRendererData = AlertRendererDataBase & (ShowHoverData | NoHoverData);
+
+export interface IRendererData {
+ alertIcon: Path2D[];
+ alerts: AlertRendererData[];
+ button: CrosshairButtonData | null;
+ color: string;
+ crosshair: CrosshairRendererData | null;
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/mouse.ts b/plugin-examples/src/plugins/user-price-alerts/mouse.ts
new file mode 100644
index 0000000000..8607ed46cf
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/mouse.ts
@@ -0,0 +1,110 @@
+import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
+import { Delegate, ISubscription } from '../../helpers/delegate';
+
+export interface MousePosition {
+ x: number;
+ y: number;
+ xPositionRelativeToPriceScale: number;
+ overPriceScale: boolean;
+ overTimeScale: boolean;
+}
+
+type UnSubscriber = () => void;
+
+/**
+ * We are using our own mouse listeners on the container because
+ * we need to know the mouse position when over the price scale
+ * (in addition to the chart pane)
+ */
+
+export class MouseHandlers {
+ _chart: IChartApi | undefined = undefined;
+ _series: ISeriesApi | undefined = undefined;
+ _unSubscribers: UnSubscriber[] = [];
+
+ private _clicked: Delegate = new Delegate();
+ private _mouseMoved: Delegate = new Delegate();
+
+ attached(chart: IChartApi, series: ISeriesApi) {
+ this._chart = chart;
+ this._series = series;
+ const container = this._chart.chartElement();
+ this._addMouseEventListener(
+ container,
+ 'mouseleave',
+ this._mouseLeave
+ );
+ this._addMouseEventListener(
+ container,
+ 'mousemove',
+ this._mouseMove
+ );
+ this._addMouseEventListener(
+ container,
+ 'click',
+ this._mouseClick
+ );
+ }
+
+ detached() {
+ this._series = undefined;
+ this._clicked.destroy();
+ this._mouseMoved.destroy();
+ this._unSubscribers.forEach(unSub => {
+ unSub();
+ });
+ this._unSubscribers = [];
+ }
+
+ public clicked(): ISubscription {
+ return this._clicked;
+ }
+
+ public mouseMoved(): ISubscription {
+ return this._mouseMoved;
+ }
+
+ _addMouseEventListener(
+ target: HTMLDivElement,
+ eventType: 'mouseleave' | 'mousemove' | 'click',
+ handler: (event: MouseEvent) => void
+ ): void {
+ const boundMouseMoveHandler = handler.bind(this);
+ target.addEventListener(eventType, boundMouseMoveHandler);
+ const unSubscriber = () => {
+ target.removeEventListener(eventType, boundMouseMoveHandler);
+ };
+ this._unSubscribers.push(unSubscriber);
+ }
+
+ _mouseLeave() {
+ this._mouseMoved.fire(null);
+ }
+ _mouseMove(event: MouseEvent) {
+ this._mouseMoved.fire(this._determineMousePosition(event));
+ }
+ _mouseClick(event: MouseEvent) {
+ this._clicked.fire(this._determineMousePosition(event));
+ }
+
+ _determineMousePosition(event: MouseEvent): MousePosition | null {
+ if (!this._chart || !this._series) return null;
+ const element = this._chart.chartElement();
+ const chartContainerBox = element.getBoundingClientRect();
+ const priceScaleWidth = this._series.priceScale().width();
+ const timeScaleHeight = this._chart.timeScale().height();
+ const x = event.clientX - chartContainerBox.x;
+ const y = event.clientY - chartContainerBox.y;
+ const overTimeScale = y > element.clientHeight - timeScaleHeight;
+ const xPositionRelativeToPriceScale =
+ element.clientWidth - priceScaleWidth - x;
+ const overPriceScale = xPositionRelativeToPriceScale < 0;
+ return {
+ x,
+ y,
+ xPositionRelativeToPriceScale,
+ overPriceScale,
+ overTimeScale,
+ };
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/pane-renderer.ts b/plugin-examples/src/plugins/user-price-alerts/pane-renderer.ts
new file mode 100644
index 0000000000..d6c114e003
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/pane-renderer.ts
@@ -0,0 +1,320 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import { PaneRendererBase } from './renderer-base';
+import {
+ averageWidthPerCharacter,
+ buttonHeight,
+ buttonWidth,
+ centreLabelHeight,
+ centreLabelInlinePadding,
+ clockIconViewBoxSize,
+ crossPath,
+ crossViewBoxSize,
+ iconPadding,
+ iconPaddingAlertTop,
+ iconSize,
+ labelHeight,
+ removeButtonWidth,
+} from './constants';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+export class PaneRenderer extends PaneRendererBase {
+ draw(target: CanvasRenderingTarget2D): void {
+ target.useBitmapCoordinateSpace(scope => {
+ if (!this._data) return;
+ this._drawAlertLines(scope);
+ this._drawAlertIcons(scope);
+
+ const hasRemoveHover = this._data.alerts.some(
+ alert => alert.showHover && alert.hoverRemove
+ );
+
+ if (!hasRemoveHover) {
+ this._drawCrosshairLine(scope);
+ this._drawCrosshairLabelButton(scope);
+ }
+ this._drawAlertLabel(scope);
+ });
+ }
+
+ _drawHorizontalLine(
+ scope: BitmapCoordinatesRenderingScope,
+ data: {
+ width: number;
+ lineWidth: number;
+ color: string;
+ y: number;
+ }
+ ) {
+ const ctx = scope.context;
+ try {
+ const yPos = positionsLine(
+ data.y,
+ scope.verticalPixelRatio,
+ data.lineWidth
+ );
+ const yCentre = yPos.position + yPos.length / 2;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.lineWidth = data.lineWidth;
+ ctx.strokeStyle = data.color;
+ const dash = 4 * scope.horizontalPixelRatio;
+ ctx.setLineDash([dash, dash]);
+ ctx.moveTo(0, yCentre);
+ ctx.lineTo(
+ (data.width - buttonWidth) * scope.horizontalPixelRatio,
+ yCentre
+ );
+ ctx.stroke();
+ } finally {
+ ctx.restore();
+ }
+ }
+
+ _drawAlertLines(scope: BitmapCoordinatesRenderingScope) {
+ if (!this._data?.alerts) return;
+ const color = this._data.color;
+ this._data.alerts.forEach(alertData => {
+ this._drawHorizontalLine(scope, {
+ width: scope.mediaSize.width,
+ lineWidth: 1,
+ color,
+ y: alertData.y,
+ });
+ });
+ }
+
+ _drawAlertIcons(scope: BitmapCoordinatesRenderingScope) {
+ if (!this._data?.alerts) return;
+ const color = this._data.color;
+ const icon = this._data.alertIcon;
+ this._data.alerts.forEach(alert => {
+ this._drawLabel(scope, {
+ width: scope.mediaSize.width,
+ labelHeight,
+ y: alert.y,
+ roundedCorners: 2,
+ icon,
+ iconScaling: iconSize / clockIconViewBoxSize,
+ padding: {
+ left: iconPadding,
+ top: iconPaddingAlertTop,
+ },
+ color,
+ });
+ });
+ }
+
+ _calculateLabelWidth(textLength: number) {
+ return (
+ centreLabelInlinePadding * 2 +
+ removeButtonWidth +
+ textLength * averageWidthPerCharacter
+ );
+ }
+
+ _drawAlertLabel(scope: BitmapCoordinatesRenderingScope) {
+ if (!this._data?.alerts) return;
+ const ctx = scope.context;
+ const activeLabel = this._data.alerts.find(alert => alert.showHover);
+ if (!activeLabel || !activeLabel.showHover) return;
+ const labelWidth = this._calculateLabelWidth(activeLabel.text.length);
+ const labelXDimensions = positionsLine(
+ scope.mediaSize.width / 2,
+ scope.horizontalPixelRatio,
+ labelWidth
+ );
+ const yDimensions = positionsLine(
+ activeLabel.y,
+ scope.verticalPixelRatio,
+ centreLabelHeight
+ );
+
+ ctx.save();
+ try {
+ const radius = 4 * scope.horizontalPixelRatio;
+ // draw main body background of label
+ ctx.beginPath();
+ ctx.roundRect(
+ labelXDimensions.position,
+ yDimensions.position,
+ labelXDimensions.length,
+ yDimensions.length,
+ radius
+ );
+ ctx.fillStyle = '#FFFFFF';
+ ctx.fill();
+
+ const removeButtonStartX =
+ labelXDimensions.position +
+ labelXDimensions.length -
+ removeButtonWidth * scope.horizontalPixelRatio;
+
+ if (activeLabel.hoverRemove) {
+ // draw hover background for remove button
+ ctx.beginPath();
+ ctx.roundRect(
+ removeButtonStartX,
+ yDimensions.position,
+ removeButtonWidth * scope.horizontalPixelRatio,
+ yDimensions.length,
+ [0, radius, radius, 0]
+ );
+ ctx.fillStyle = '#F0F3FA';
+ ctx.fill();
+ }
+
+ // draw button divider
+ ctx.beginPath();
+ const dividerDimensions = positionsLine(
+ removeButtonStartX / scope.horizontalPixelRatio,
+ scope.horizontalPixelRatio,
+ 1
+ );
+ ctx.fillStyle = '#F1F3FB';
+ ctx.fillRect(
+ dividerDimensions.position,
+ yDimensions.position,
+ dividerDimensions.length,
+ yDimensions.length
+ );
+
+ // draw stroke for main body
+ ctx.beginPath();
+ ctx.roundRect(
+ labelXDimensions.position,
+ yDimensions.position,
+ labelXDimensions.length,
+ yDimensions.length,
+ radius
+ );
+ ctx.strokeStyle = '#131722';
+ ctx.lineWidth = 1 * scope.horizontalPixelRatio;
+ ctx.stroke();
+
+ // write text
+ ctx.beginPath();
+ ctx.fillStyle = '#131722';
+ ctx.textBaseline = 'middle';
+ ctx.font = `${Math.round(12 * scope.verticalPixelRatio)}px sans-serif`;
+ ctx.fillText(
+ activeLabel.text,
+ labelXDimensions.position +
+ centreLabelInlinePadding * scope.horizontalPixelRatio,
+ activeLabel.y * scope.verticalPixelRatio
+ );
+
+ // draw button icon
+ ctx.beginPath();
+ const iconSize = 9;
+ ctx.translate(
+ removeButtonStartX +
+ (scope.horizontalPixelRatio * (removeButtonWidth - iconSize)) / 2,
+ (activeLabel.y - 5) * scope.verticalPixelRatio
+ );
+ const scaling =
+ (iconSize / crossViewBoxSize) * scope.horizontalPixelRatio;
+ ctx.scale(scaling, scaling);
+ ctx.fillStyle = '#131722';
+ ctx.fill(crossPath, 'evenodd');
+ } finally {
+ ctx.restore();
+ }
+ }
+
+ _drawCrosshairLine(scope: BitmapCoordinatesRenderingScope) {
+ if (!this._data?.crosshair) return;
+ this._drawHorizontalLine(scope, {
+ width: scope.mediaSize.width,
+ lineWidth: 1,
+ color: this._data.color,
+ y: this._data.crosshair.y,
+ });
+ }
+
+ _drawCrosshairLabelButton(scope: BitmapCoordinatesRenderingScope) {
+ if (!this._data?.button || !this._data?.crosshair) return;
+ this._drawLabel(scope, {
+ width: scope.mediaSize.width,
+ labelHeight: buttonHeight,
+ y: this._data.crosshair.y,
+ roundedCorners: [2, 0, 0, 2],
+ icon: this._data.button.crosshairLabelIcon,
+ iconScaling: iconSize / clockIconViewBoxSize,
+ padding: {
+ left: iconPadding,
+ top: iconPadding,
+ },
+ color: this._data.button.hovering
+ ? this._data.button.hoverColor
+ : this._data.color,
+ });
+ }
+
+ _drawLabel(
+ scope: BitmapCoordinatesRenderingScope,
+ data: {
+ width: number;
+ labelHeight: number;
+ y: number;
+ roundedCorners: number | number[];
+ icon: Path2D[];
+ color: string;
+ padding: {
+ top: number;
+ left: number;
+ };
+ iconScaling: number;
+ }
+ ) {
+ const ctx = scope.context;
+ try {
+ ctx.save();
+ ctx.beginPath();
+ const yDimension = positionsLine(
+ data.y,
+ scope.verticalPixelRatio,
+ data.labelHeight
+ );
+ const x = (data.width - (buttonWidth + 1)) * scope.horizontalPixelRatio;
+ ctx.roundRect(
+ x,
+ yDimension.position,
+ buttonWidth * scope.horizontalPixelRatio,
+ yDimension.length,
+ adjustRadius(data.roundedCorners, scope.horizontalPixelRatio)
+ );
+ ctx.fillStyle = data.color;
+ ctx.fill();
+ ctx.beginPath();
+ ctx.translate(
+ x + data.padding.left * scope.horizontalPixelRatio,
+ yDimension.position + data.padding.top * scope.verticalPixelRatio
+ );
+ ctx.scale(
+ data.iconScaling * scope.horizontalPixelRatio,
+ data.iconScaling * scope.verticalPixelRatio
+ );
+ ctx.fillStyle = '#FFFFFF';
+ data.icon.forEach(path => {
+ ctx.beginPath();
+ ctx.fill(path, 'evenodd');
+ });
+ } finally {
+ ctx.restore();
+ }
+ }
+}
+
+function adjustRadius(
+ radius: T,
+ pixelRatio: number
+): T {
+ if (typeof radius === 'number') {
+ return (radius * pixelRatio) as T;
+ }
+ return radius.map(i => i * pixelRatio) as T;
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/pane-view.ts b/plugin-examples/src/plugins/user-price-alerts/pane-view.ts
new file mode 100644
index 0000000000..7d3a2db716
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/pane-view.ts
@@ -0,0 +1,29 @@
+import {
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesPrimitivePaneViewZOrder,
+} from 'lightweight-charts';
+import { IRendererData } from './irenderer-data';
+import { PaneRenderer } from './pane-renderer';
+import { PriceScalePaneRenderer } from './price-scale-pane-renderer';
+
+export class UserAlertPricePaneView implements ISeriesPrimitivePaneView {
+ _renderer: PaneRenderer | PriceScalePaneRenderer;
+ constructor(isPriceScale: boolean) {
+ this._renderer = isPriceScale
+ ? new PriceScalePaneRenderer()
+ : new PaneRenderer();
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'top';
+ }
+
+ renderer(): ISeriesPrimitivePaneRenderer {
+ return this._renderer;
+ }
+
+ update(data: IRendererData | null) {
+ this._renderer.update(data);
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/price-scale-pane-renderer.ts b/plugin-examples/src/plugins/user-price-alerts/price-scale-pane-renderer.ts
new file mode 100644
index 0000000000..eb5700b872
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/price-scale-pane-renderer.ts
@@ -0,0 +1,48 @@
+import { BitmapCoordinatesRenderingScope, CanvasRenderingTarget2D } from 'fancy-canvas';
+import { PaneRendererBase } from './renderer-base';
+import { buttonHeight } from './constants';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+export class PriceScalePaneRenderer extends PaneRendererBase {
+ draw(target: CanvasRenderingTarget2D): void {
+ target.useBitmapCoordinateSpace(scope => {
+ if (!this._data) return;
+ this._drawCrosshairLabel(scope);
+ });
+ }
+
+ _drawCrosshairLabel(scope: BitmapCoordinatesRenderingScope) {
+ if (!this._data?.crosshair) return;
+ const ctx = scope.context;
+ try {
+ const width = scope.bitmapSize.width;
+ const labelWidth = width - 8 * scope.horizontalPixelRatio;
+ ctx.save();
+ ctx.beginPath();
+ ctx.fillStyle = this._data.color;
+ const labelDimensions = positionsLine(this._data.crosshair.y, scope.verticalPixelRatio, buttonHeight);
+ const radius = 2 * scope.horizontalPixelRatio;
+ ctx.roundRect(
+ 0,
+ labelDimensions.position,
+ labelWidth,
+ labelDimensions.length,
+ [0, radius, radius, 0]
+ );
+ ctx.fill();
+ ctx.beginPath();
+ ctx.fillStyle = '#FFFFFF';
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'right';
+ ctx.font = `${Math.round(12 *scope.verticalPixelRatio)}px sans-serif`;
+ const textMeasurements = ctx.measureText(this._data.crosshair.text);
+ ctx.fillText(
+ this._data.crosshair.text,
+ textMeasurements.width + 10 * scope.horizontalPixelRatio,
+ this._data.crosshair.y * scope.verticalPixelRatio
+ );
+ } finally {
+ ctx.restore();
+ }
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/renderer-base.ts b/plugin-examples/src/plugins/user-price-alerts/renderer-base.ts
new file mode 100644
index 0000000000..dbb712da07
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/renderer-base.ts
@@ -0,0 +1,11 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts';
+import { IRendererData } from './irenderer-data';
+
+export abstract class PaneRendererBase implements ISeriesPrimitivePaneRenderer {
+ _data: IRendererData | null = null;
+ abstract draw(target: CanvasRenderingTarget2D): void;
+ update(data: IRendererData | null) {
+ this._data = data;
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/state.ts b/plugin-examples/src/plugins/user-price-alerts/state.ts
new file mode 100644
index 0000000000..bfe4af0279
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/state.ts
@@ -0,0 +1,80 @@
+import { Delegate } from '../../helpers/delegate';
+
+export interface UserAlertInfo {
+ id: string;
+ price: number;
+}
+
+export class UserAlertsState {
+ private _alertAdded: Delegate = new Delegate();
+ private _alertRemoved: Delegate = new Delegate();
+ private _alertChanged: Delegate = new Delegate();
+ private _alertsChanged: Delegate = new Delegate();
+ private _alerts: Map;
+
+ constructor() {
+ this._alerts = new Map();
+ this._alertsChanged.subscribe(() => {
+ this._updateAlertsArray();
+ }, this);
+ }
+
+ destroy() {
+ // TODO: add more destroying 💥
+ this._alertsChanged.unsubscribeAll(this);
+ }
+
+ alertAdded(): Delegate {
+ return this._alertAdded;
+ }
+
+ alertRemoved(): Delegate {
+ return this._alertRemoved;
+ }
+
+ alertChanged(): Delegate {
+ return this._alertChanged;
+ }
+
+ alertsChanged(): Delegate {
+ return this._alertsChanged;
+ }
+
+ addAlert(price: number): string {
+ const id = this._getNewId();
+ const userAlert: UserAlertInfo = {
+ price,
+ id,
+ };
+ this._alerts.set(id, userAlert);
+ this._alertAdded.fire(userAlert);
+ this._alertsChanged.fire();
+ return id;
+ }
+
+ removeAlert(id: string) {
+ if (!this._alerts.has(id)) return;
+ this._alerts.delete(id);
+ this._alertRemoved.fire(id);
+ this._alertsChanged.fire();
+ }
+
+ alerts() {
+ return this._alertsArray;
+ }
+
+ _alertsArray: UserAlertInfo[] = [];
+ _updateAlertsArray() {
+ this._alertsArray = Array.from(this._alerts.values()).sort((a, b) => {
+ return b.price - a.price;
+ });
+ }
+
+ private _getNewId(): string {
+ let id = Math.round(Math.random() * 1000000).toString(16);
+ while (this._alerts.has(id)) {
+ id = Math.round(Math.random() * 1000000).toString(16);
+ }
+ return id;
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-alerts/user-price-alerts.ts b/plugin-examples/src/plugins/user-price-alerts/user-price-alerts.ts
new file mode 100644
index 0000000000..eb70a6e023
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-alerts/user-price-alerts.ts
@@ -0,0 +1,226 @@
+import {
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneView,
+ PrimitiveHoveredItem,
+ SeriesAttachedParameter,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+import {
+ averageWidthPerCharacter,
+ buttonWidth,
+ centreLabelHeight,
+ centreLabelInlinePadding,
+ clockIconPaths,
+ clockPlusIconPaths,
+ removeButtonWidth,
+ showCentreLabelDistance,
+} from './constants';
+import { AlertRendererData, IRendererData } from './irenderer-data';
+import { MouseHandlers, MousePosition } from './mouse';
+import { UserAlertPricePaneView } from './pane-view';
+import { UserAlertInfo, UserAlertsState } from './state';
+
+export class UserPriceAlerts
+ extends UserAlertsState
+ implements ISeriesPrimitive
+{
+ private _chart: IChartApi | undefined = undefined;
+ private _series: ISeriesApi | undefined = undefined;
+ private _mouseHandlers: MouseHandlers;
+
+ private _paneViews: UserAlertPricePaneView[] = [];
+ private _pricePaneViews: UserAlertPricePaneView[] = [];
+
+ private _lastMouseUpdate: MousePosition | null = null;
+ private _currentCursor: string | null = null;
+
+ private _symbolName: string = '';
+
+ constructor() {
+ super();
+ this._mouseHandlers = new MouseHandlers();
+ }
+
+ attached({ chart, series, requestUpdate }: SeriesAttachedParameter) {
+ this._chart = chart;
+ this._series = series;
+ this._paneViews = [new UserAlertPricePaneView(false)];
+ this._pricePaneViews = [new UserAlertPricePaneView(true)];
+ this._mouseHandlers.attached(chart, series);
+ this._mouseHandlers.mouseMoved().subscribe(mouseUpdate => {
+ this._lastMouseUpdate = mouseUpdate;
+ requestUpdate();
+ }, this);
+ this._mouseHandlers.clicked().subscribe(mousePosition => {
+ if (mousePosition && this._series) {
+ if (this._isHovering(mousePosition)) {
+ const price = this._series.coordinateToPrice(mousePosition.y);
+ if (price) {
+ this.addAlert(price);
+ requestUpdate();
+ }
+ }
+ if (this._hoveringID) {
+ this.removeAlert(this._hoveringID);
+ requestUpdate();
+ }
+ }
+ }, this);
+ }
+
+ detached() {
+ this._mouseHandlers.mouseMoved().unsubscribeAll(this);
+ this._mouseHandlers.clicked().unsubscribeAll(this);
+ this._mouseHandlers.detached();
+ this._series = undefined;
+ }
+
+ paneViews(): readonly ISeriesPrimitivePaneView[] {
+ return this._paneViews;
+ }
+
+ priceAxisPaneViews(): readonly ISeriesPrimitivePaneView[] {
+ return this._pricePaneViews;
+ }
+
+ updateAllViews(): void {
+ const alerts = this.alerts();
+ const rendererData = this._calculateRendererData(
+ alerts,
+ this._lastMouseUpdate
+ );
+ this._currentCursor = null;
+ if (
+ rendererData?.button?.hovering ||
+ rendererData?.alerts.some(alert => alert.showHover && alert.hoverRemove)
+ ) {
+ this._currentCursor = 'pointer';
+ }
+ this._paneViews.forEach(pv => pv.update(rendererData));
+ this._pricePaneViews.forEach(pv => pv.update(rendererData));
+ }
+
+ hitTest(): PrimitiveHoveredItem | null {
+ if (!this._currentCursor) return null;
+ return {
+ cursorStyle: this._currentCursor,
+ externalId: 'user-alerts-primitive',
+ zOrder: 'top',
+ };
+ }
+
+ setSymbolName(name: string) {
+ this._symbolName = name;
+ }
+
+ _isHovering(mousePosition: MousePosition | null): boolean {
+ return Boolean(
+ mousePosition &&
+ mousePosition.xPositionRelativeToPriceScale >= 1 &&
+ mousePosition.xPositionRelativeToPriceScale < buttonWidth
+ );
+ }
+
+ _isHoveringRemoveButton(
+ mousePosition: MousePosition | null,
+ timescaleWidth: number,
+ alertY: number,
+ textLength: number
+ ): boolean {
+ if (!mousePosition || !timescaleWidth) return false;
+ const distanceY = Math.abs(mousePosition.y - alertY);
+ if (distanceY > centreLabelHeight / 2) return false;
+ const labelWidth =
+ centreLabelInlinePadding * 2 +
+ removeButtonWidth +
+ textLength * averageWidthPerCharacter;
+ const buttonCentreX =
+ (timescaleWidth + labelWidth - removeButtonWidth) * 0.5;
+ const distanceX = Math.abs(mousePosition.x - buttonCentreX);
+ return distanceX <= removeButtonWidth / 2;
+ }
+
+ private _hoveringID: string = '';
+
+ /**
+ * We are calculating this here instead of within a view
+ * because the data is identical for both Renderers so lets
+ * rather calculate it once here.
+ */
+ _calculateRendererData(
+ alertsInfo: UserAlertInfo[],
+ mousePosition: MousePosition | null
+ ): IRendererData | null {
+ if (!this._series) return null;
+ const priceFormatter = this._series.priceFormatter();
+
+ const showCrosshair = mousePosition && !mousePosition.overTimeScale;
+ const showButton = showCrosshair;
+ const crosshairPrice =
+ mousePosition && this._series.coordinateToPrice(mousePosition.y);
+ const crosshairPriceText = priceFormatter.format(crosshairPrice ?? -100);
+
+ let closestDistance = Infinity;
+ let closestIndex: number = -1;
+
+ const alerts: (AlertRendererData & { price: number; id: string })[] =
+ alertsInfo.map((alertInfo, index) => {
+ const y = this._series!.priceToCoordinate(alertInfo.price) ?? -100;
+ if (mousePosition?.y && y >= 0) {
+ const distance = Math.abs(mousePosition.y - y);
+ if (distance < closestDistance) {
+ closestIndex = index;
+ closestDistance = distance;
+ }
+ }
+ return {
+ y,
+ showHover: false,
+ price: alertInfo.price,
+ id: alertInfo.id,
+ };
+ });
+ this._hoveringID = '';
+ if (closestIndex >= 0 && closestDistance < showCentreLabelDistance) {
+ const timescaleWidth = this._chart?.timeScale().width() ?? 0;
+ const a = alerts[closestIndex];
+ const text = `${this._symbolName} crossing ${this._series
+ .priceFormatter()
+ .format(a.price)}`;
+ const hoverRemove = this._isHoveringRemoveButton(
+ mousePosition,
+ timescaleWidth,
+ a.y,
+ text.length
+ );
+ alerts[closestIndex] = {
+ ...alerts[closestIndex],
+ showHover: true,
+ text,
+ hoverRemove,
+ };
+ if (hoverRemove) this._hoveringID = a.id;
+ }
+ return {
+ alertIcon: clockIconPaths,
+ alerts,
+ button: showButton
+ ? {
+ hovering: this._isHovering(mousePosition),
+ hoverColor: '#50535E',
+ crosshairLabelIcon: clockPlusIconPaths,
+ }
+ : null,
+ color: '#131722',
+ crosshair: showCrosshair
+ ? {
+ y: mousePosition.y,
+ text: crosshairPriceText,
+ }
+ : null,
+ };
+ }
+}
diff --git a/plugin-examples/src/plugins/user-price-lines/example/example.ts b/plugin-examples/src/plugins/user-price-lines/example/example.ts
new file mode 100644
index 0000000000..ab39e8bb5e
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-lines/example/example.ts
@@ -0,0 +1,13 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { UserPriceLines } from '../user-price-lines';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+new UserPriceLines(chart, lineSeries, { color: 'hotpink' });
diff --git a/plugin-examples/src/plugins/user-price-lines/example/index.html b/plugin-examples/src/plugins/user-price-lines/example/index.html
new file mode 100644
index 0000000000..196bcbe436
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-lines/example/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Lightweight Charts - User Price Lines Plugin Example
+
+
+
+
+
+
User Price Line
+
+ Price lines created via the crosshair button (next to the price scale).
+ The crosshair button only appears when the cursor is near the price
+ scale.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/user-price-lines/user-price-lines.ts b/plugin-examples/src/plugins/user-price-lines/user-price-lines.ts
new file mode 100644
index 0000000000..ca88821985
--- /dev/null
+++ b/plugin-examples/src/plugins/user-price-lines/user-price-lines.ts
@@ -0,0 +1,293 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ CrosshairMode,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ MouseEventParams,
+ SeriesType,
+ SeriesPrimitivePaneViewZOrder,
+ LineStyle
+} from 'lightweight-charts';
+import { PluginBase } from '../plugin-base';
+import { positionsBox, positionsLine } from '../../helpers/dimensions/positions';
+
+const LABEL_HEIGHT = 21;
+const plusIcon = `M7.5,7.5 m -7,0 a 7,7 0 1,0 14,0 a 7,7 0 1,0 -14,0 M4 7.5H11 M7.5 4V11`;
+const plusIconPath = new Path2D(plusIcon);
+const plusIconSize = 15; // Icon is 15x15
+
+class UserPriceLineDataBase {
+ _y: number = 0;
+ _data: UserPriceLinesData;
+ constructor(data: UserPriceLinesData) {
+ this._data = data;
+ }
+
+ update(data: UserPriceLinesData, series: ISeriesApi): void {
+ this._data = data;
+ if (!this._data.price) {
+ this._y = -10000;
+ return;
+ }
+ this._y = series.priceToCoordinate(this._data.price) ?? -10000;
+ }
+}
+
+interface UserPriceLinesRendererData {
+ visible: boolean;
+ textColor: string;
+ color: string;
+ y: number;
+ rightX: number;
+ hoverColor: string;
+ hovered: boolean;
+}
+
+class UserPriceLinesPaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: UserPriceLinesRendererData;
+
+ constructor(data: UserPriceLinesRendererData) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ if (!this._data.visible) return;
+ target.useBitmapCoordinateSpace(scope => {
+ const ctx = scope.context;
+ ctx.save();
+
+ const height = LABEL_HEIGHT;
+ const width = height + 1;
+
+ const xPos = positionsBox(this._data.rightX - width, this._data.rightX - 1, scope.horizontalPixelRatio);
+ const yPos = positionsLine(this._data.y, scope.verticalPixelRatio, height);
+
+ ctx.fillStyle = this._data.color;
+ const roundedArray = [5, 0, 0, 5].map(i => i * scope.horizontalPixelRatio);
+ ctx.beginPath();
+ ctx.roundRect(xPos.position, yPos.position, xPos.length, yPos.length, roundedArray);
+ ctx.fill();
+
+ if (this._data.hovered) {
+ ctx.fillStyle = this._data.hoverColor;
+ ctx.beginPath();
+ ctx.roundRect(xPos.position, yPos.position, xPos.length, yPos.length, roundedArray);
+ ctx.fill();
+ }
+
+ ctx.save();
+ ctx.translate(xPos.position + 3 * scope.horizontalPixelRatio, yPos.position + 3 * scope.verticalPixelRatio);
+ ctx.scale(scope.horizontalPixelRatio, scope.verticalPixelRatio);
+ const iconScaling = 15 / plusIconSize;
+ ctx.scale(iconScaling, iconScaling);
+ ctx.strokeStyle = this._data.textColor;
+ ctx.lineWidth = 1;
+ ctx.stroke(plusIconPath);
+ ctx.restore();
+ ctx.restore();
+ });
+ }
+}
+
+class UserPriceLinesPaneView
+ extends UserPriceLineDataBase
+ implements ISeriesPrimitivePaneView
+{
+ renderer(): ISeriesPrimitivePaneRenderer | null {
+ const color = this._data.crosshairColor;
+ return new UserPriceLinesPaneRenderer({
+ visible: this._data.visible,
+ y: this._y,
+ color,
+ textColor: this._data.crosshairLabelColor,
+ rightX: this._data.timeScaleWidth,
+ hoverColor: this._data.hoverColor,
+ hovered: this._data.hovered ?? false,
+ });
+ }
+
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'top';
+ }
+}
+
+interface UserPriceLinesData {
+ visible: boolean;
+ hovered?: boolean;
+ price?: number;
+ timeScaleWidth: number;
+ crosshairLabelColor: string;
+ crosshairColor: string;
+ lineColor: string;
+ hoverColor: string;
+}
+
+class UserPriceLinesLabelButton extends PluginBase {
+ _paneViews: UserPriceLinesPaneView[];
+ _data: UserPriceLinesData = {
+ visible: false,
+ hovered: false,
+ timeScaleWidth: 0,
+ crosshairLabelColor: '#000000',
+ crosshairColor: '#ffffff',
+ lineColor: '#000000',
+ hoverColor: '#777777',
+ };
+ _source: UserPriceLines;
+
+ constructor(source: UserPriceLines) {
+ super();
+ this._paneViews = [new UserPriceLinesPaneView(this._data)];
+ this._source = source;
+ }
+
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update(this._data, this.series));
+ }
+
+ priceAxisViews() {
+ return [];
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+
+ showAddLabel(price: number, hovered: boolean) {
+ const crosshairColor =
+ this.chart.options().crosshair.horzLine.labelBackgroundColor;
+ this._data = {
+ visible: true,
+ price,
+ hovered,
+ timeScaleWidth: this.chart.timeScale().width(),
+ crosshairColor,
+ crosshairLabelColor: '#FFFFFF',
+ lineColor: this._source.currentLineColor(),
+ hoverColor: this._source.currentHoverColor(),
+ };
+ this.updateAllViews();
+ this.requestUpdate();
+ }
+
+ hideAddLabel() {
+ this._data.visible = false;
+ this.updateAllViews();
+ this.requestUpdate();
+ }
+}
+
+const defaultOptions: UserPriceLinesOptions = {
+ color: '#000000',
+ hoverColor: '#777777',
+ limitToOne: true,
+};
+
+export interface UserPriceLinesOptions {
+ color: string;
+ hoverColor: string
+ limitToOne: boolean;
+}
+
+export class UserPriceLines {
+ private _chart: IChartApi | undefined;
+ private _series: ISeriesApi | undefined;
+ private _options: UserPriceLinesOptions;
+ private _labelButtonPrimitive: UserPriceLinesLabelButton;
+
+ constructor(
+ chart: IChartApi,
+ series: ISeriesApi,
+ options: Partial
+ ) {
+ this._chart = chart;
+ this._series = series;
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._chart.subscribeClick(this._clickHandler);
+ this._chart.subscribeCrosshairMove(this._moveHandler);
+ this._labelButtonPrimitive = new UserPriceLinesLabelButton(this);
+ series.attachPrimitive(this._labelButtonPrimitive);
+ this._setCrosshairMode();
+ }
+
+ currentLineColor() {
+ return this._options.color;
+ }
+
+ currentHoverColor() {
+ return this._options.hoverColor;
+ }
+
+ // We need to disable magnet mode for this to work nicely
+ _setCrosshairMode() {
+ if (!this._chart) {
+ throw new Error(
+ 'Unable to change crosshair mode because the chart instance is undefined'
+ );
+ }
+ this._chart.applyOptions({
+ crosshair: {
+ mode: CrosshairMode.Normal,
+ },
+ });
+ }
+
+ private _clickHandler = (param: MouseEventParams) => this._onClick(param);
+ private _moveHandler = (param: MouseEventParams) => this._onMouseMove(param);
+
+ remove() {
+ if (this._chart) {
+ this._chart.unsubscribeClick(this._clickHandler);
+ this._chart.unsubscribeCrosshairMove(this._moveHandler);
+ }
+ if (this._series && this._labelButtonPrimitive) {
+ this._series.detachPrimitive(this._labelButtonPrimitive);
+ }
+ this._chart = undefined;
+ this._series = undefined;
+ }
+
+ private _onClick(param: MouseEventParams) {
+ const price = this._getMousePrice(param);
+ const xDistance = this._distanceFromRightScale(param);
+ if (
+ price === null ||
+ xDistance === null ||
+ xDistance > LABEL_HEIGHT ||
+ !this._series
+ )
+ return;
+ this._series.createPriceLine({
+ price,
+ color: this._options.color,
+ lineStyle: LineStyle.Dashed,
+ });
+ }
+
+ private _onMouseMove(param: MouseEventParams) {
+ const price = this._getMousePrice(param);
+ const xDistance = this._distanceFromRightScale(param);
+ if (price === null || xDistance === null || xDistance > LABEL_HEIGHT * 2) {
+ this._labelButtonPrimitive.hideAddLabel();
+ return;
+ }
+ this._labelButtonPrimitive.showAddLabel(price, xDistance < LABEL_HEIGHT);
+ }
+
+ private _getMousePrice(param: MouseEventParams) {
+ if (!param.point || !this._series) return null;
+ const price = this._series.coordinateToPrice(param.point.y);
+ return price;
+ }
+
+ private _distanceFromRightScale(param: MouseEventParams) {
+ if (!param.point || !this._chart) return null;
+ const timeScaleWidth = this._chart.timeScale().width();
+ return Math.abs(timeScaleWidth - param.point.x);
+ }
+}
diff --git a/plugin-examples/src/plugins/vertical-line/example/example.ts b/plugin-examples/src/plugins/vertical-line/example/example.ts
new file mode 100644
index 0000000000..59402e33d9
--- /dev/null
+++ b/plugin-examples/src/plugins/vertical-line/example/example.ts
@@ -0,0 +1,24 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { VertLine } from '../vertical-line';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+const vertLine = new VertLine(chart, lineSeries, data[data.length - 50].time, {
+ showLabel: true,
+ labelText: 'Hello',
+});
+lineSeries.attachPrimitive(vertLine);
+
+const vertLine2 = new VertLine(chart, lineSeries, data[data.length - 25].time, {
+ showLabel: false,
+ color: 'red',
+ width: 2,
+});
+lineSeries.attachPrimitive(vertLine2);
diff --git a/plugin-examples/src/plugins/vertical-line/example/index.html b/plugin-examples/src/plugins/vertical-line/example/index.html
new file mode 100644
index 0000000000..144dd24241
--- /dev/null
+++ b/plugin-examples/src/plugins/vertical-line/example/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Lightweight Charts - Vertical Line Plugin Example
+
+
+
+
+
+
Vertical Line
+
+ Vertical Line draw at specified time values with a corresponding label
+ on the time scale.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/vertical-line/vertical-line.ts b/plugin-examples/src/plugins/vertical-line/vertical-line.ts
new file mode 100644
index 0000000000..be94e71dd8
--- /dev/null
+++ b/plugin-examples/src/plugins/vertical-line/vertical-line.ts
@@ -0,0 +1,145 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ Coordinate,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ ISeriesPrimitiveAxisView,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ SeriesOptionsMap,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+import { positionsLine } from '../../helpers/dimensions/positions';
+
+class VertLinePaneRenderer implements ISeriesPrimitivePaneRenderer {
+ _x: Coordinate | null = null;
+ _options: VertLineOptions;
+ constructor(x: Coordinate | null, options: VertLineOptions) {
+ this._x = x;
+ this._options = options;
+ }
+ draw(target: CanvasRenderingTarget2D) {
+ target.useBitmapCoordinateSpace(scope => {
+ if (this._x === null) return;
+ const ctx = scope.context;
+ const position = positionsLine(
+ this._x,
+ scope.horizontalPixelRatio,
+ this._options.width
+ );
+ ctx.fillStyle = this._options.color;
+ ctx.fillRect(
+ position.position,
+ 0,
+ position.length,
+ scope.bitmapSize.height
+ );
+ });
+ }
+}
+
+class VertLinePaneView implements ISeriesPrimitivePaneView {
+ _source: VertLine;
+ _x: Coordinate | null = null;
+ _options: VertLineOptions;
+
+ constructor(source: VertLine, options: VertLineOptions) {
+ this._source = source;
+ this._options = options;
+ }
+ update() {
+ const timeScale = this._source._chart.timeScale();
+ this._x = timeScale.timeToCoordinate(this._source._time);
+ }
+ renderer() {
+ return new VertLinePaneRenderer(this._x, this._options);
+ }
+}
+
+class VertLineTimeAxisView implements ISeriesPrimitiveAxisView {
+ _source: VertLine;
+ _x: Coordinate | null = null;
+ _options: VertLineOptions;
+
+ constructor(source: VertLine, options: VertLineOptions) {
+ this._source = source;
+ this._options = options;
+ }
+ update() {
+ const timeScale = this._source._chart.timeScale();
+ this._x = timeScale.timeToCoordinate(this._source._time);
+ }
+ visible() {
+ return this._options.showLabel;
+ }
+ tickVisible() {
+ return this._options.showLabel;
+ }
+ coordinate() {
+ return this._x ?? 0;
+ }
+ text() {
+ return this._options.labelText;
+ }
+ textColor() {
+ return this._options.labelTextColor;
+ }
+ backColor() {
+ return this._options.labelBackgroundColor;
+ }
+}
+
+export interface VertLineOptions {
+ color: string;
+ labelText: string;
+ width: number;
+ labelBackgroundColor: string;
+ labelTextColor: string;
+ showLabel: boolean;
+}
+
+const defaultOptions: VertLineOptions = {
+ color: 'green',
+ labelText: '',
+ width: 3,
+ labelBackgroundColor: 'green',
+ labelTextColor: 'white',
+ showLabel: false,
+};
+
+export class VertLine implements ISeriesPrimitive {
+ _chart: IChartApi;
+ _series: ISeriesApi;
+ _time: Time;
+ _paneViews: VertLinePaneView[];
+ _timeAxisViews: VertLineTimeAxisView[];
+
+ constructor(
+ chart: IChartApi,
+ series: ISeriesApi,
+ time: Time,
+ options?: Partial
+ ) {
+ const vertLineOptions: VertLineOptions = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._chart = chart;
+ this._series = series;
+ this._time = time;
+ this._paneViews = [new VertLinePaneView(this, vertLineOptions)];
+ this._timeAxisViews = [new VertLineTimeAxisView(this, vertLineOptions)];
+ }
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ this._timeAxisViews.forEach(tw => tw.update());
+ }
+ timeAxisViews() {
+ return this._timeAxisViews;
+ }
+ paneViews() {
+ return this._paneViews;
+ }
+}
diff --git a/plugin-examples/src/plugins/volume-profile/example/example.ts b/plugin-examples/src/plugins/volume-profile/example/example.ts
new file mode 100644
index 0000000000..7a7b6fea29
--- /dev/null
+++ b/plugin-examples/src/plugins/volume-profile/example/example.ts
@@ -0,0 +1,28 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../../../sample-data';
+import { VolumeProfile } from '../volume-profile';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries();
+const data = generateLineData();
+lineSeries.setData(data);
+
+const basePrice = data[data.length - 50].value;
+const priceStep = Math.round(basePrice * 0.1);
+const profile = [];
+for (let i = 0; i < 15; i++) {
+ profile.push({
+ price: basePrice + i * priceStep,
+ vol: Math.round(Math.random() * 20),
+ });
+}
+const vpData = {
+ time: data[data.length - 50].time,
+ profile,
+ width: 10, // number of bars width
+};
+const vp = new VolumeProfile(chart, lineSeries, vpData);
+lineSeries.attachPrimitive(vp);
diff --git a/plugin-examples/src/plugins/volume-profile/example/index.html b/plugin-examples/src/plugins/volume-profile/example/index.html
new file mode 100644
index 0000000000..9a04840ab0
--- /dev/null
+++ b/plugin-examples/src/plugins/volume-profile/example/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Lightweight Charts - Volume Profile Plugin Example
+
+
+
+
+
+
Volume Profile
+
+ A Volume Profile anchored to a specified point (defined by price and
+ time values) on the chart.Note: that the example
+ is randomly generated so be sure to refresh the chart a few times.
+
+
+
+
+
diff --git a/plugin-examples/src/plugins/volume-profile/volume-profile.ts b/plugin-examples/src/plugins/volume-profile/volume-profile.ts
new file mode 100644
index 0000000000..44a544f86b
--- /dev/null
+++ b/plugin-examples/src/plugins/volume-profile/volume-profile.ts
@@ -0,0 +1,198 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import {
+ AutoscaleInfo,
+ Coordinate,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ ISeriesPrimitivePaneRenderer,
+ ISeriesPrimitivePaneView,
+ Logical,
+ SeriesOptionsMap,
+ SeriesType,
+ Time,
+} from 'lightweight-charts';
+import { positionsBox } from '../../helpers/dimensions/positions';
+
+interface VolumeProfileItem {
+ y: Coordinate | null;
+ width: number;
+}
+
+interface VolumeProfileRendererData {
+ x: Coordinate | null;
+ top: Coordinate | null;
+ columnHeight: number;
+ width: number;
+ items: VolumeProfileItem[];
+}
+
+interface VolumeProfileDataPoint {
+ price: number;
+ vol: number;
+}
+
+export interface VolumeProfileData {
+ time: Time;
+ profile: VolumeProfileDataPoint[];
+ width: number;
+}
+
+class VolumeProfileRenderer implements ISeriesPrimitivePaneRenderer {
+ _data: VolumeProfileRendererData;
+ constructor(data: VolumeProfileRendererData) {
+ this._data = data;
+ }
+
+ draw(target: CanvasRenderingTarget2D) {
+ target.useBitmapCoordinateSpace(scope => {
+ if (this._data.x === null || this._data.top === null) return;
+ const ctx = scope.context;
+ const horizontalPositions = positionsBox(
+ this._data.x,
+ this._data.x + this._data.width,
+ scope.horizontalPixelRatio
+ );
+ const verticalPositions = positionsBox(
+ this._data.top,
+ this._data.top - this._data.columnHeight * this._data.items.length,
+ scope.verticalPixelRatio
+ );
+
+ ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
+ ctx.fillRect(
+ horizontalPositions.position,
+ verticalPositions.position,
+ horizontalPositions.length,
+ verticalPositions.length
+ );
+
+ ctx.fillStyle = 'rgba(80, 80, 255, 0.8)';
+ this._data.items.forEach(row => {
+ if (row.y === null) return;
+ const itemVerticalPos = positionsBox(
+ row.y,
+ row.y - this._data.columnHeight,
+ scope.verticalPixelRatio
+ );
+ const itemHorizontalPos = positionsBox(
+ this._data.x!,
+ this._data.x! + row.width,
+ scope.horizontalPixelRatio
+ );
+ ctx.fillRect(
+ itemHorizontalPos.position,
+ itemVerticalPos.position,
+ itemHorizontalPos.length,
+ itemVerticalPos.length - 2 // 1 to close gaps
+ );
+ });
+ });
+ }
+}
+
+class VolumeProfilePaneView implements ISeriesPrimitivePaneView {
+ _source: VolumeProfile;
+ _x: Coordinate | null = null;
+ _width: number = 6;
+ _columnHeight: number = 0;
+ _top: Coordinate | null = null;
+ _items: VolumeProfileItem[] = [];
+ constructor(source: VolumeProfile) {
+ this._source = source;
+ }
+
+ update() {
+ const data = this._source._vpData;
+ const series = this._source._series;
+ const timeScale = this._source._chart.timeScale();
+ this._x = timeScale.timeToCoordinate(data.time);
+ this._width = timeScale.options().barSpacing * data.width;
+
+ const y1 =
+ series.priceToCoordinate(data.profile[0].price) ?? (0 as Coordinate);
+ const y2 =
+ series.priceToCoordinate(data.profile[1].price) ??
+ (timeScale.height() as Coordinate);
+ this._columnHeight = Math.max(1, y1 - y2);
+ const maxVolume = data.profile.reduce(
+ (acc, item) => Math.max(acc, item.vol),
+ 0
+ );
+
+ this._top = y1;
+
+ this._items = data.profile.map(row => ({
+ y: series.priceToCoordinate(row.price),
+ width: (this._width * row.vol) / maxVolume,
+ }));
+ }
+
+ renderer() {
+ return new VolumeProfileRenderer({
+ x: this._x,
+ top: this._top,
+ columnHeight: this._columnHeight,
+ width: this._width,
+ items: this._items,
+ });
+ }
+}
+
+export class VolumeProfile implements ISeriesPrimitive {
+ _chart: IChartApi;
+ _series: ISeriesApi;
+ _vpData: VolumeProfileData;
+ _minPrice: number;
+ _maxPrice: number;
+ _paneViews: VolumeProfilePaneView[];
+
+ _vpIndex: number | null = null;
+
+ constructor(
+ chart: IChartApi,
+ series: ISeriesApi,
+ vpData: VolumeProfileData
+ ) {
+ this._chart = chart;
+ this._series = series;
+ this._vpData = vpData;
+ this._minPrice = Infinity;
+ this._maxPrice = -Infinity;
+ this._vpData.profile.forEach(vpData => {
+ if (vpData.price < this._minPrice) this._minPrice = vpData.price;
+ if (vpData.price > this._maxPrice) this._maxPrice = vpData.price;
+ });
+ this._paneViews = [new VolumeProfilePaneView(this)];
+ }
+ updateAllViews() {
+ this._paneViews.forEach(pw => pw.update());
+ }
+
+ // Ensures that the VP is within autoScale
+ autoscaleInfo(
+ startTimePoint: Logical,
+ endTimePoint: Logical
+ ): AutoscaleInfo | null {
+ // calculation of vpIndex could be remembered to reduce CPU usage
+ // and only recheck if the data is changed ('full' update).
+ const vpCoordinate = this._chart
+ .timeScale()
+ .timeToCoordinate(this._vpData.time);
+ if (vpCoordinate === null) return null;
+ const vpIndex = this._chart.timeScale().coordinateToLogical(vpCoordinate);
+ if (vpIndex === null) return null;
+ if (endTimePoint < vpIndex || startTimePoint > vpIndex + this._vpData.width)
+ return null;
+ return {
+ priceRange: {
+ minValue: this._minPrice,
+ maxValue: this._maxPrice,
+ },
+ };
+ }
+
+ paneViews() {
+ return this._paneViews;
+ }
+}
diff --git a/plugin-examples/src/sample-data.ts b/plugin-examples/src/sample-data.ts
new file mode 100644
index 0000000000..c61a1d17c9
--- /dev/null
+++ b/plugin-examples/src/sample-data.ts
@@ -0,0 +1,157 @@
+import type { Time, WhitespaceData } from 'lightweight-charts';
+
+type LineData = {
+ time: Time;
+ value: number;
+};
+
+export type CandleData = {
+ time: Time;
+ high: number;
+ low: number;
+ close: number;
+ open: number;
+};
+
+let randomFactor = 25 + Math.random() * 25;
+const samplePoint = (i: number) =>
+ i *
+ (0.5 +
+ Math.sin(i / 10) * 0.2 +
+ Math.sin(i / 20) * 0.4 +
+ Math.sin(i / randomFactor) * 0.8 +
+ Math.sin(i / 500) * 0.5) +
+ 200;
+
+export function generateLineData(numberOfPoints: number = 500): LineData[] {
+ randomFactor = 25 + Math.random() * 25;
+ const res = [];
+ const date = new Date(Date.UTC(2018, 0, 1, 12, 0, 0, 0));
+ for (let i = 0; i < numberOfPoints; ++i) {
+ const time = (date.getTime() / 1000) as Time;
+ const value = samplePoint(i);
+ res.push({
+ time,
+ value,
+ });
+
+ date.setUTCDate(date.getUTCDate() + 1);
+ }
+
+ return res;
+}
+
+export function generateCandleData(numberOfPoints: number = 250): CandleData[] {
+ const lineData = generateLineData(numberOfPoints);
+ return lineData.map((d, i) => {
+ const randomRanges = [-1 * Math.random(), Math.random(), Math.random()].map(
+ j => j * 10
+ );
+ const sign = Math.sin(Math.random() - 0.5);
+ return {
+ time: d.time,
+ low: d.value + randomRanges[0],
+ high: d.value + randomRanges[1],
+ open: d.value + sign * randomRanges[2],
+ close: samplePoint(i + 1),
+ };
+ });
+}
+
+function randomNumber(min: number, max: number) {
+ return Math.random() * (max - min) + min;
+}
+
+function randomBar(lastClose: number) {
+ const open = +randomNumber(lastClose * 0.95, lastClose * 1.05).toFixed(2);
+ const close = +randomNumber(open * 0.95, open * 1.05).toFixed(2);
+ const high = +randomNumber(
+ Math.max(open, close),
+ Math.max(open, close) * 1.1
+ ).toFixed(2);
+ const low = +randomNumber(
+ Math.min(open, close) * 0.9,
+ Math.min(open, close)
+ ).toFixed(2);
+ return {
+ open,
+ high,
+ low,
+ close,
+ };
+}
+
+export function generateAlternativeCandleData(
+ numberOfPoints: number = 250
+): CandleData[] {
+ const lineData = generateLineData(numberOfPoints);
+ let lastClose = lineData[0].value;
+ return lineData.map(d => {
+ const candle = randomBar(lastClose);
+ lastClose = candle.close;
+ return {
+ time: d.time,
+ low: candle.low,
+ high: candle.high,
+ open: candle.open,
+ close: candle.close,
+ };
+ });
+}
+
+export function shuffleValuesWithLimit(
+ arr: T,
+ limit: number
+): T {
+ const n = arr.length;
+ const originalTimes = arr.map(item => item.time);
+ for (let i = 0; i < n; i++) {
+ // Generate a random index within the limit
+ const j =
+ Math.floor(Math.random() * (Math.min(n - 1, i + limit) - i + 1)) + i;
+ // Swap the current element with the randomly selected element
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ arr.forEach((item, index) => {
+ item.time = originalTimes[index];
+ });
+ return arr;
+}
+
+function splitArrayIntoParts(arr: T[], size: number): T[][] {
+ const result = [];
+ const length = arr.length;
+ let start = 0;
+ while (start < length) {
+ result.push(arr.slice(start, start + size));
+ start += size;
+ }
+ return result;
+}
+
+interface MultibarData extends WhitespaceData {
+ values: number[];
+}
+
+export function multipleBarData(
+ groups: number,
+ numberPoints: number,
+ shuffleLimit = 0
+): MultibarData[] {
+ const basePoints = generateLineData(groups * numberPoints).map(d => {
+ return {
+ ...d,
+ value: Math.max(d.value, 0), // prevent negative numbers
+ };
+ });
+ let sets: LineData[][] = splitArrayIntoParts(basePoints, numberPoints);
+ if (shuffleLimit > 0) {
+ sets = sets.map(set => shuffleValuesWithLimit(set, shuffleLimit));
+ }
+ return sets[0].map((dataPoint, index) => {
+ return {
+ time: dataPoint.time,
+ values: sets.map(set => set[index].value),
+ };
+ });
+}
diff --git a/plugin-examples/src/style.css b/plugin-examples/src/style.css
new file mode 100644
index 0000000000..bbef04ff9e
--- /dev/null
+++ b/plugin-examples/src/style.css
@@ -0,0 +1,62 @@
+:root {
+ font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+a {
+ font-weight: 500;
+ color: rgba(41, 98, 255, 1);
+ text-decoration: inherit;
+}
+a:hover {
+ color: rgba(30, 83, 229, 1);
+}
+a:active {
+ color: rgba(24, 72, 204, 1);
+}
+
+body {
+ margin: 20px;
+ background: white;
+}
+
+h1 {
+ /* font-display: swap; */
+ font-family: 'Euclid Circular B', system-ui, -apple-system, BlinkMacSystemFont,
+ 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
+ sans-serif;
+ font-style: normal;
+ font-weight: 600;
+ font-size: 56px;
+ line-height: 68px;
+ font-feature-settings: 'tnum' on, 'lnum' on;
+ color: black;
+}
+
+h1 {
+ display: flex;
+ flex-direction: column;
+}
+
+.line1, .line2 {
+ display: block;
+}
+
+.line2 {
+ background: linear-gradient(90deg, #006FDE 0%, #3A99CE 25%, #00E7E7 54.17%, #3A99CE 80%, #1238FF 100%);
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.top-links {
+ padding-inline-start: 0px;
+ list-style-type: none;
+}
diff --git a/plugin-examples/src/vite-env.d.ts b/plugin-examples/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/plugin-examples/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/plugin-examples/src/vite.config.js b/plugin-examples/src/vite.config.js
new file mode 100644
index 0000000000..32675baff7
--- /dev/null
+++ b/plugin-examples/src/vite.config.js
@@ -0,0 +1,25 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import { globby } from 'globby';
+
+const paths = (await globby(['./src/**/*.html'])).map(path =>
+ path.replace('./src/', '')
+);
+
+const input = {
+ main: resolve(__dirname, '../index.html'),
+};
+
+let count = 0;
+paths.forEach(p => {
+ input[count++] = resolve(__dirname, p);
+});
+
+export default defineConfig({
+ base: './',
+ build: {
+ rollupOptions: {
+ input,
+ },
+ },
+});
diff --git a/plugin-examples/tsconfig.json b/plugin-examples/tsconfig.json
new file mode 100644
index 0000000000..99e948c965
--- /dev/null
+++ b/plugin-examples/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "Node",
+ "strict": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationDir": "typings",
+ "emitDeclarationOnly": true
+ },
+ "include": ["src"]
+}
diff --git a/website/docs/plugins/intro.md b/website/docs/plugins/intro.md
index 0b9295ec33..45f51def21 100644
--- a/website/docs/plugins/intro.md
+++ b/website/docs/plugins/intro.md
@@ -136,3 +136,9 @@ const chartSeries = chart.addLineSeries();
chartSeries.attachPrimitive(myCustomPrimitive);
```
+
+## Examples
+
+We have a few example plugins within the `plugin-examples` folder of the Lightweight Charts™️ repo: [plugin-examples](https://github.com/tradingview/lightweight-charts/tree/master/plugin-examples).
+
+You can view a demo site for these plugin examples here: [Plugin Examples Demos](https://tradingview.github.io/lightweight-charts/plugin-examples).