From 86da4820bc2d81126122496ae6c7bca1b98b216a Mon Sep 17 00:00:00 2001
From: Mark Silverwood
Date: Tue, 29 Aug 2023 23:44:59 +0100
Subject: [PATCH 1/3] =?UTF-8?q?add=20plugin=20examples=20=F0=9F=9A=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.eslintignore | 2 +
.github/workflows/deploy.yml | 9 +
plugin-examples/.gitignore | 17 +
plugin-examples/README.md | 96 ++++
plugin-examples/build-website.mjs | 72 +++
plugin-examples/compile.mjs | 165 ++++++
plugin-examples/index.html | 9 +
plugin-examples/package.json | 20 +
.../delta-brushable/example.ts | 98 ++++
.../delta-brushable/index.html | 31 ++
plugin-examples/src/examples-base.css | 69 +++
plugin-examples/src/helpers/assertions.ts | 33 ++
plugin-examples/src/helpers/closest-index.ts | 44 ++
plugin-examples/src/helpers/delegate.ts | 67 +++
.../src/helpers/dimensions/candles.ts | 48 ++
.../src/helpers/dimensions/columns.ts | 237 ++++++++
.../src/helpers/dimensions/common.ts | 6 +
.../src/helpers/dimensions/crosshair-width.ts | 23 +
.../src/helpers/dimensions/full-width.ts | 29 +
.../src/helpers/dimensions/positions.ts | 48 ++
.../src/helpers/min-max-in-range.ts | 69 +++
plugin-examples/src/helpers/simple-clone.ts | 7 +
plugin-examples/src/helpers/time.ts | 34 ++
plugin-examples/src/index.html | 59 ++
.../plugins/anchored-text/anchored-text.ts | 107 ++++
.../plugins/anchored-text/example/example.ts | 28 +
.../plugins/anchored-text/example/index.html | 19 +
.../src/plugins/anchored-text/package.json | 5 +
.../background-shade-series.ts | 45 ++
.../example/example.ts | 20 +
.../example/index.html | 22 +
.../background-shade-series/options.ts | 21 +
.../background-shade-series/package.json | 5 +
.../background-shade-series/renderer.ts | 111 ++++
.../plugins/bands-indicator/band-indicator.ts | 214 ++++++++
.../bands-indicator/example/example.ts | 14 +
.../bands-indicator/example/index.html | 21 +
.../box-whisker-series/box-whisker-series.ts | 44 ++
.../box-whisker-series/example/example.ts | 20 +
.../box-whisker-series/example/index.html | 24 +
.../src/plugins/box-whisker-series/options.ts | 19 +
.../plugins/box-whisker-series/renderer.ts | 304 ++++++++++
.../plugins/box-whisker-series/sample-data.ts | 86 +++
.../brushable-area-series.ts | 43 ++
.../src/plugins/brushable-area-series/data.ts | 8 +
.../brushable-area-series/example/example.ts | 123 +++++
.../brushable-area-series/example/index.html | 23 +
.../plugins/brushable-area-series/options.ts | 39 ++
.../plugins/brushable-area-series/renderer.ts | 162 ++++++
.../delta-tooltip/crosshair-line-pane.ts | 94 ++++
.../delta-tooltip/delta-tooltip-pane.ts | 400 ++++++++++++++
.../plugins/delta-tooltip/delta-tooltip.ts | 284 ++++++++++
.../plugins/delta-tooltip/example/example.ts | 39 ++
.../plugins/delta-tooltip/example/index.html | 28 +
.../delta-tooltip/multi-touch-chart-events.ts | 257 +++++++++
.../expiring-price-alerts/example/example.ts | 113 ++++
.../expiring-price-alerts/example/index.html | 19 +
.../expiring-price-alerts.ts | 210 +++++++
.../plugins/expiring-price-alerts/icons.ts | 13 +
.../iexpiring-price-alerts.ts | 17 +
.../plugins/expiring-price-alerts/options.ts | 17 +
.../expiring-price-alerts/primitive.ts | 118 ++++
.../plugins/expiring-price-alerts/renderer.ts | 81 +++
.../expiring-price-alerts/sample-data.ts | 94 ++++
.../src/plugins/grouped-bars-series/data.ts | 8 +
.../grouped-bars-series/example/example.ts | 25 +
.../grouped-bars-series/example/index.html | 21 +
.../grouped-bars-series.ts | 46 ++
.../plugins/grouped-bars-series/options.ts | 19 +
.../plugins/grouped-bars-series/renderer.ts | 121 ++++
.../plugins/heatmap-series/bell-curve-data.ts | 54 ++
.../src/plugins/heatmap-series/data.ts | 17 +
.../plugins/heatmap-series/example/example.ts | 41 ++
.../heatmap-series/example/example2.html | 22 +
.../heatmap-series/example/example2.ts | 63 +++
.../plugins/heatmap-series/example/index.html | 20 +
.../plugins/heatmap-series/heatmap-series.ts | 53 ++
.../src/plugins/heatmap-series/options.ts | 26 +
.../src/plugins/heatmap-series/renderer.ts | 122 +++++
.../heatmap-series/sample-heatmap-data.ts | 169 ++++++
.../example/example.ts | 16 +
.../example/index.html | 20 +
.../highlight-bar-crosshair.ts | 180 ++++++
.../src/plugins/hlc-area-series/data.ts | 10 +
.../hlc-area-series/example/example.ts | 16 +
.../hlc-area-series/example/index.html | 21 +
.../hlc-area-series/hlc-area-series.ts | 43 ++
.../src/plugins/hlc-area-series/options.ts | 27 +
.../src/plugins/hlc-area-series/renderer.ts | 127 +++++
.../image-watermark/example/example.ts | 37 ++
.../plugins/image-watermark/example/image.svg | 39 ++
.../image-watermark/example/index.html | 26 +
.../image-watermark/image-watermark.ts | 163 ++++++
.../src/plugins/lollipop-series/data.ts | 8 +
.../lollipop-series/example/example.ts | 17 +
.../lollipop-series/example/index.html | 20 +
.../lollipop-series/lollipop-series.ts | 44 ++
.../src/plugins/lollipop-series/options.ts | 13 +
.../src/plugins/lollipop-series/renderer.ts | 108 ++++
.../overlay-price-scale/example/example.ts | 24 +
.../overlay-price-scale/example/index.html | 20 +
.../overlay-price-scale.ts | 194 +++++++
.../partial-price-line/example/example.ts | 38 ++
.../partial-price-line/example/index.html | 20 +
.../partial-price-line/partial-price-line.ts | 121 ++++
plugin-examples/src/plugins/plugin-base.ts | 49 ++
.../rectangle-drawing-tool/example/example.ts | 20 +
.../rectangle-drawing-tool/example/index.html | 40 ++
.../rectangle-drawing-tool.ts | 517 ++++++++++++++++++
.../plugins/rounded-candles-series/data.ts | 10 +
.../rounded-candles-series/example/example.ts | 32 ++
.../rounded-candles-series/example/index.html | 19 +
.../plugins/rounded-candles-series/helpers.ts | 27 +
.../rounded-candles-series/renderer.ts | 157 ++++++
.../rounded-candles-series.ts | 72 +++
.../session-highlighting/example/example.ts | 34 ++
.../session-highlighting/example/index.html | 22 +
.../session-highlighting.ts | 151 +++++
.../src/plugins/stacked-area-series/data.ts | 8 +
.../stacked-area-series/example/example.ts | 24 +
.../stacked-area-series/example/index.html | 20 +
.../plugins/stacked-area-series/options.ts | 26 +
.../plugins/stacked-area-series/renderer.ts | 205 +++++++
.../stacked-area-series.ts | 49 ++
.../src/plugins/stacked-bars-series/data.ts | 8 +
.../stacked-bars-series/example/example.ts | 20 +
.../stacked-bars-series/example/index.html | 23 +
.../plugins/stacked-bars-series/options.ts | 19 +
.../plugins/stacked-bars-series/renderer.ts | 112 ++++
.../stacked-bars-series.ts | 49 ++
.../src/plugins/tooltip/example/example.ts | 65 +++
.../src/plugins/tooltip/example/index.html | 38 ++
.../src/plugins/tooltip/tooltip-element.ts | 209 +++++++
.../src/plugins/tooltip/tooltip.ts | 258 +++++++++
.../src/plugins/trend-line/example/example.ts | 23 +
.../src/plugins/trend-line/example/index.html | 20 +
.../src/plugins/trend-line/trend-line.ts | 187 +++++++
.../plugins/user-price-alerts/constants.ts | 43 ++
.../user-price-alerts/example/example.ts | 62 +++
.../user-price-alerts/example/index.html | 29 +
.../user-price-alerts/irenderer-data.ts | 37 ++
.../src/plugins/user-price-alerts/mouse.ts | 110 ++++
.../user-price-alerts/pane-renderer.ts | 320 +++++++++++
.../plugins/user-price-alerts/pane-view.ts | 29 +
.../price-scale-pane-renderer.ts | 48 ++
.../user-price-alerts/renderer-base.ts | 11 +
.../src/plugins/user-price-alerts/state.ts | 80 +++
.../user-price-alerts/user-price-alerts.ts | 226 ++++++++
.../user-price-lines/example/example.ts | 13 +
.../user-price-lines/example/index.html | 21 +
.../user-price-lines/user-price-lines.ts | 293 ++++++++++
.../plugins/vertical-line/example/example.ts | 24 +
.../plugins/vertical-line/example/index.html | 20 +
.../plugins/vertical-line/vertical-line.ts | 145 +++++
.../plugins/volume-profile/example/example.ts | 28 +
.../plugins/volume-profile/example/index.html | 21 +
.../plugins/volume-profile/volume-profile.ts | 198 +++++++
plugin-examples/src/sample-data.ts | 157 ++++++
plugin-examples/src/style.css | 62 +++
plugin-examples/src/vite-env.d.ts | 1 +
plugin-examples/src/vite.config.js | 25 +
plugin-examples/tsconfig.json | 21 +
website/docs/plugins/intro.md | 6 +
163 files changed, 11325 insertions(+)
create mode 100644 plugin-examples/.gitignore
create mode 100644 plugin-examples/README.md
create mode 100644 plugin-examples/build-website.mjs
create mode 100644 plugin-examples/compile.mjs
create mode 100644 plugin-examples/index.html
create mode 100644 plugin-examples/package.json
create mode 100644 plugin-examples/src/combined-examples/delta-brushable/example.ts
create mode 100644 plugin-examples/src/combined-examples/delta-brushable/index.html
create mode 100644 plugin-examples/src/examples-base.css
create mode 100644 plugin-examples/src/helpers/assertions.ts
create mode 100644 plugin-examples/src/helpers/closest-index.ts
create mode 100644 plugin-examples/src/helpers/delegate.ts
create mode 100644 plugin-examples/src/helpers/dimensions/candles.ts
create mode 100644 plugin-examples/src/helpers/dimensions/columns.ts
create mode 100644 plugin-examples/src/helpers/dimensions/common.ts
create mode 100644 plugin-examples/src/helpers/dimensions/crosshair-width.ts
create mode 100644 plugin-examples/src/helpers/dimensions/full-width.ts
create mode 100644 plugin-examples/src/helpers/dimensions/positions.ts
create mode 100644 plugin-examples/src/helpers/min-max-in-range.ts
create mode 100644 plugin-examples/src/helpers/simple-clone.ts
create mode 100644 plugin-examples/src/helpers/time.ts
create mode 100644 plugin-examples/src/index.html
create mode 100644 plugin-examples/src/plugins/anchored-text/anchored-text.ts
create mode 100644 plugin-examples/src/plugins/anchored-text/example/example.ts
create mode 100644 plugin-examples/src/plugins/anchored-text/example/index.html
create mode 100644 plugin-examples/src/plugins/anchored-text/package.json
create mode 100644 plugin-examples/src/plugins/background-shade-series/background-shade-series.ts
create mode 100644 plugin-examples/src/plugins/background-shade-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/background-shade-series/example/index.html
create mode 100644 plugin-examples/src/plugins/background-shade-series/options.ts
create mode 100644 plugin-examples/src/plugins/background-shade-series/package.json
create mode 100644 plugin-examples/src/plugins/background-shade-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/bands-indicator/band-indicator.ts
create mode 100644 plugin-examples/src/plugins/bands-indicator/example/example.ts
create mode 100644 plugin-examples/src/plugins/bands-indicator/example/index.html
create mode 100644 plugin-examples/src/plugins/box-whisker-series/box-whisker-series.ts
create mode 100644 plugin-examples/src/plugins/box-whisker-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/box-whisker-series/example/index.html
create mode 100644 plugin-examples/src/plugins/box-whisker-series/options.ts
create mode 100644 plugin-examples/src/plugins/box-whisker-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/box-whisker-series/sample-data.ts
create mode 100644 plugin-examples/src/plugins/brushable-area-series/brushable-area-series.ts
create mode 100644 plugin-examples/src/plugins/brushable-area-series/data.ts
create mode 100644 plugin-examples/src/plugins/brushable-area-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/brushable-area-series/example/index.html
create mode 100644 plugin-examples/src/plugins/brushable-area-series/options.ts
create mode 100644 plugin-examples/src/plugins/brushable-area-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/delta-tooltip/crosshair-line-pane.ts
create mode 100644 plugin-examples/src/plugins/delta-tooltip/delta-tooltip-pane.ts
create mode 100644 plugin-examples/src/plugins/delta-tooltip/delta-tooltip.ts
create mode 100644 plugin-examples/src/plugins/delta-tooltip/example/example.ts
create mode 100644 plugin-examples/src/plugins/delta-tooltip/example/index.html
create mode 100644 plugin-examples/src/plugins/delta-tooltip/multi-touch-chart-events.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/example/example.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/example/index.html
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/expiring-price-alerts.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/icons.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/iexpiring-price-alerts.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/options.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/primitive.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/renderer.ts
create mode 100644 plugin-examples/src/plugins/expiring-price-alerts/sample-data.ts
create mode 100644 plugin-examples/src/plugins/grouped-bars-series/data.ts
create mode 100644 plugin-examples/src/plugins/grouped-bars-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/grouped-bars-series/example/index.html
create mode 100644 plugin-examples/src/plugins/grouped-bars-series/grouped-bars-series.ts
create mode 100644 plugin-examples/src/plugins/grouped-bars-series/options.ts
create mode 100644 plugin-examples/src/plugins/grouped-bars-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/bell-curve-data.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/data.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/example/example2.html
create mode 100644 plugin-examples/src/plugins/heatmap-series/example/example2.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/example/index.html
create mode 100644 plugin-examples/src/plugins/heatmap-series/heatmap-series.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/options.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/heatmap-series/sample-heatmap-data.ts
create mode 100644 plugin-examples/src/plugins/highlight-bar-crosshair/example/example.ts
create mode 100644 plugin-examples/src/plugins/highlight-bar-crosshair/example/index.html
create mode 100644 plugin-examples/src/plugins/highlight-bar-crosshair/highlight-bar-crosshair.ts
create mode 100644 plugin-examples/src/plugins/hlc-area-series/data.ts
create mode 100644 plugin-examples/src/plugins/hlc-area-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/hlc-area-series/example/index.html
create mode 100644 plugin-examples/src/plugins/hlc-area-series/hlc-area-series.ts
create mode 100644 plugin-examples/src/plugins/hlc-area-series/options.ts
create mode 100644 plugin-examples/src/plugins/hlc-area-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/image-watermark/example/example.ts
create mode 100644 plugin-examples/src/plugins/image-watermark/example/image.svg
create mode 100644 plugin-examples/src/plugins/image-watermark/example/index.html
create mode 100644 plugin-examples/src/plugins/image-watermark/image-watermark.ts
create mode 100644 plugin-examples/src/plugins/lollipop-series/data.ts
create mode 100644 plugin-examples/src/plugins/lollipop-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/lollipop-series/example/index.html
create mode 100644 plugin-examples/src/plugins/lollipop-series/lollipop-series.ts
create mode 100644 plugin-examples/src/plugins/lollipop-series/options.ts
create mode 100644 plugin-examples/src/plugins/lollipop-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/overlay-price-scale/example/example.ts
create mode 100644 plugin-examples/src/plugins/overlay-price-scale/example/index.html
create mode 100644 plugin-examples/src/plugins/overlay-price-scale/overlay-price-scale.ts
create mode 100644 plugin-examples/src/plugins/partial-price-line/example/example.ts
create mode 100644 plugin-examples/src/plugins/partial-price-line/example/index.html
create mode 100644 plugin-examples/src/plugins/partial-price-line/partial-price-line.ts
create mode 100644 plugin-examples/src/plugins/plugin-base.ts
create mode 100644 plugin-examples/src/plugins/rectangle-drawing-tool/example/example.ts
create mode 100644 plugin-examples/src/plugins/rectangle-drawing-tool/example/index.html
create mode 100644 plugin-examples/src/plugins/rectangle-drawing-tool/rectangle-drawing-tool.ts
create mode 100644 plugin-examples/src/plugins/rounded-candles-series/data.ts
create mode 100644 plugin-examples/src/plugins/rounded-candles-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/rounded-candles-series/example/index.html
create mode 100644 plugin-examples/src/plugins/rounded-candles-series/helpers.ts
create mode 100644 plugin-examples/src/plugins/rounded-candles-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/rounded-candles-series/rounded-candles-series.ts
create mode 100644 plugin-examples/src/plugins/session-highlighting/example/example.ts
create mode 100644 plugin-examples/src/plugins/session-highlighting/example/index.html
create mode 100644 plugin-examples/src/plugins/session-highlighting/session-highlighting.ts
create mode 100644 plugin-examples/src/plugins/stacked-area-series/data.ts
create mode 100644 plugin-examples/src/plugins/stacked-area-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/stacked-area-series/example/index.html
create mode 100644 plugin-examples/src/plugins/stacked-area-series/options.ts
create mode 100644 plugin-examples/src/plugins/stacked-area-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/stacked-area-series/stacked-area-series.ts
create mode 100644 plugin-examples/src/plugins/stacked-bars-series/data.ts
create mode 100644 plugin-examples/src/plugins/stacked-bars-series/example/example.ts
create mode 100644 plugin-examples/src/plugins/stacked-bars-series/example/index.html
create mode 100644 plugin-examples/src/plugins/stacked-bars-series/options.ts
create mode 100644 plugin-examples/src/plugins/stacked-bars-series/renderer.ts
create mode 100644 plugin-examples/src/plugins/stacked-bars-series/stacked-bars-series.ts
create mode 100644 plugin-examples/src/plugins/tooltip/example/example.ts
create mode 100644 plugin-examples/src/plugins/tooltip/example/index.html
create mode 100644 plugin-examples/src/plugins/tooltip/tooltip-element.ts
create mode 100644 plugin-examples/src/plugins/tooltip/tooltip.ts
create mode 100644 plugin-examples/src/plugins/trend-line/example/example.ts
create mode 100644 plugin-examples/src/plugins/trend-line/example/index.html
create mode 100644 plugin-examples/src/plugins/trend-line/trend-line.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/constants.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/example/example.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/example/index.html
create mode 100644 plugin-examples/src/plugins/user-price-alerts/irenderer-data.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/mouse.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/pane-renderer.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/pane-view.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/price-scale-pane-renderer.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/renderer-base.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/state.ts
create mode 100644 plugin-examples/src/plugins/user-price-alerts/user-price-alerts.ts
create mode 100644 plugin-examples/src/plugins/user-price-lines/example/example.ts
create mode 100644 plugin-examples/src/plugins/user-price-lines/example/index.html
create mode 100644 plugin-examples/src/plugins/user-price-lines/user-price-lines.ts
create mode 100644 plugin-examples/src/plugins/vertical-line/example/example.ts
create mode 100644 plugin-examples/src/plugins/vertical-line/example/index.html
create mode 100644 plugin-examples/src/plugins/vertical-line/vertical-line.ts
create mode 100644 plugin-examples/src/plugins/volume-profile/example/example.ts
create mode 100644 plugin-examples/src/plugins/volume-profile/example/index.html
create mode 100644 plugin-examples/src/plugins/volume-profile/volume-profile.ts
create mode 100644 plugin-examples/src/sample-data.ts
create mode 100644 plugin-examples/src/style.css
create mode 100644 plugin-examples/src/vite-env.d.ts
create mode 100644 plugin-examples/src/vite.config.js
create mode 100644 plugin-examples/tsconfig.json
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).
From 0c5984c7f2a328395b677734f916f48df225a89c Mon Sep 17 00:00:00 2001
From: Mark Silverwood
Date: Wed, 30 Aug 2023 00:06:14 +0100
Subject: [PATCH 2/3] add create-lwc-plugin package
---
.eslintignore | 1 +
packages/create-lwc-plugin/.gitignore | 2 +
packages/create-lwc-plugin/BUILDING.md | 46 +++
packages/create-lwc-plugin/LICENSE | 304 ++++++++++++++++++
packages/create-lwc-plugin/README.md | 82 +++++
packages/create-lwc-plugin/build.config.ts | 13 +
packages/create-lwc-plugin/index.js | 3 +
packages/create-lwc-plugin/package.json | 41 +++
packages/create-lwc-plugin/src/helpers/io.ts | 54 ++++
.../create-lwc-plugin/src/helpers/package.ts | 14 +
.../src/helpers/validation.ts | 14 +
packages/create-lwc-plugin/src/index.ts | 101 ++++++
packages/create-lwc-plugin/src/questions.ts | 126 ++++++++
.../template-common/README.md | 38 +++
.../template-common/_gitignore | 24 ++
.../template-common/compile.mjs | 116 +++++++
.../template-common/index.html | 7 +
.../template-common/package.json | 17 +
.../src/helpers/dimensions/common.ts | 6 +
.../src/helpers/dimensions/crosshair-width.ts | 23 ++
.../src/helpers/dimensions/full-width.ts | 29 ++
.../src/helpers/dimensions/positions.ts | 48 +++
.../template-common/src/helpers/time.ts | 34 ++
.../template-common/src/vite-env.d.ts | 1 +
.../template-common/src/vite.config.js | 13 +
.../template-common/tsconfig.json | 21 ++
.../src/axis-pane-renderer.ts | 44 +++
.../template-primitive/src/axis-pane-view.ts | 55 ++++
.../template-primitive/src/axis-view.ts | 57 ++++
.../template-primitive/src/data-source.ts | 20 ++
.../template-primitive/src/example/example.ts | 23 ++
.../template-primitive/src/example/index.html | 26 ++
.../src/helpers/assertions.ts | 33 ++
.../template-primitive/src/options.ts | 27 ++
.../template-primitive/src/pane-renderer.ts | 46 +++
.../template-primitive/src/pane-view.ts | 37 +++
.../template-primitive/src/plugin-base.ts | 56 ++++
.../template-primitive/src/sample-data.ts | 35 ++
.../template-primitive/src/template-entry.ts | 139 ++++++++
.../template-series/src/data.ts | 11 +
.../template-series/src/example/example.ts | 15 +
.../template-series/src/example/index.html | 26 ++
.../src/helpers/dimensions/candles.ts | 48 +++
.../src/helpers/dimensions/columns.ts | 237 ++++++++++++++
.../template-series/src/options.ts | 23 ++
.../template-series/src/renderer.ts | 109 +++++++
.../template-series/src/sample-data.ts | 50 +++
.../template-series/src/template-entry.ts | 49 +++
packages/create-lwc-plugin/tsconfig.json | 15 +
plugin-examples/README.md | 2 +-
50 files changed, 2360 insertions(+), 1 deletion(-)
create mode 100644 packages/create-lwc-plugin/.gitignore
create mode 100644 packages/create-lwc-plugin/BUILDING.md
create mode 100644 packages/create-lwc-plugin/LICENSE
create mode 100644 packages/create-lwc-plugin/README.md
create mode 100644 packages/create-lwc-plugin/build.config.ts
create mode 100755 packages/create-lwc-plugin/index.js
create mode 100644 packages/create-lwc-plugin/package.json
create mode 100644 packages/create-lwc-plugin/src/helpers/io.ts
create mode 100644 packages/create-lwc-plugin/src/helpers/package.ts
create mode 100644 packages/create-lwc-plugin/src/helpers/validation.ts
create mode 100755 packages/create-lwc-plugin/src/index.ts
create mode 100644 packages/create-lwc-plugin/src/questions.ts
create mode 100644 packages/create-lwc-plugin/template-common/README.md
create mode 100644 packages/create-lwc-plugin/template-common/_gitignore
create mode 100644 packages/create-lwc-plugin/template-common/compile.mjs
create mode 100644 packages/create-lwc-plugin/template-common/index.html
create mode 100644 packages/create-lwc-plugin/template-common/package.json
create mode 100644 packages/create-lwc-plugin/template-common/src/helpers/dimensions/common.ts
create mode 100644 packages/create-lwc-plugin/template-common/src/helpers/dimensions/crosshair-width.ts
create mode 100644 packages/create-lwc-plugin/template-common/src/helpers/dimensions/full-width.ts
create mode 100644 packages/create-lwc-plugin/template-common/src/helpers/dimensions/positions.ts
create mode 100644 packages/create-lwc-plugin/template-common/src/helpers/time.ts
create mode 100644 packages/create-lwc-plugin/template-common/src/vite-env.d.ts
create mode 100644 packages/create-lwc-plugin/template-common/src/vite.config.js
create mode 100644 packages/create-lwc-plugin/template-common/tsconfig.json
create mode 100644 packages/create-lwc-plugin/template-primitive/src/axis-pane-renderer.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/axis-pane-view.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/axis-view.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/data-source.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/example/example.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/example/index.html
create mode 100644 packages/create-lwc-plugin/template-primitive/src/helpers/assertions.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/options.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/pane-renderer.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/pane-view.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/plugin-base.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/sample-data.ts
create mode 100644 packages/create-lwc-plugin/template-primitive/src/template-entry.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/data.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/example/example.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/example/index.html
create mode 100644 packages/create-lwc-plugin/template-series/src/helpers/dimensions/candles.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/helpers/dimensions/columns.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/options.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/renderer.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/sample-data.ts
create mode 100644 packages/create-lwc-plugin/template-series/src/template-entry.ts
create mode 100644 packages/create-lwc-plugin/tsconfig.json
diff --git a/.eslintignore b/.eslintignore
index 5db9c3a96d..570bf43335 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -15,3 +15,4 @@
/website/build/**
/plugin-examples
+/packages/create-lwc-plugin
diff --git a/packages/create-lwc-plugin/.gitignore b/packages/create-lwc-plugin/.gitignore
new file mode 100644
index 0000000000..76add878f8
--- /dev/null
+++ b/packages/create-lwc-plugin/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
\ No newline at end of file
diff --git a/packages/create-lwc-plugin/BUILDING.md b/packages/create-lwc-plugin/BUILDING.md
new file mode 100644
index 0000000000..cdb0d7209a
--- /dev/null
+++ b/packages/create-lwc-plugin/BUILDING.md
@@ -0,0 +1,46 @@
+# Local Development of the create-lwc-plugin
+
+The minimal supported version of [NodeJS](https://nodejs.org/) for development is 18.
+
+1. Install the dependencies
+
+ ```shell
+ npm install
+ ```
+
+2. Create a development stub
+
+ ```shell
+ npm run dev
+ ```
+
+3. Running the CLI locally
+
+ ```shell
+ node index.js
+ ```
+
+## Publishing new version
+
+1. Install the dependencies
+
+ ```shell
+ npm install
+ ```
+
+2. Bump the version number in the `package.json`
+3. Build the package
+
+ ```shell
+ npm run prepublishOnly
+ ```
+
+4. Run `npx publint@latest` and ensure that there aren't any issues with the generated `package.json`.
+5. Publish the package
+
+ ```shell
+ npm publish
+ ```
+
+Hint: append `--dry-run` to the end of the publish command to see the results of
+the publish command without actually uploading the package to NPM.
diff --git a/packages/create-lwc-plugin/LICENSE b/packages/create-lwc-plugin/LICENSE
new file mode 100644
index 0000000000..4011352503
--- /dev/null
+++ b/packages/create-lwc-plugin/LICENSE
@@ -0,0 +1,304 @@
+# create-lwc-plugin license
+
+create-lwc-plugin is released under the Apache license:
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2023 TradingView, Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+## Licenses of bundled dependencies
+
+The published create-lwc-plugin artifact additionally contains code with the following licenses:
+ISC, MIT
+
+## Bundled dependencies
+
+### @clack/prompts
+
+MIT License
+
+Copyright (c) Nate Moore
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+---
+
+`ansi-regex` is adapted from https://github.com/chalk/ansi-regex
+
+MIT License
+
+Copyright (c) Sindre Sorhus (https://sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+### picocolors
+
+ISC License
+
+Copyright (c) 2021 Alexey Raspopov, Kostiantyn Denysov, Anton Verinov
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+## unbuild
+
+MIT License
+
+Copyright (c) Pooya Parsa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+### Vite
+
+MIT License
+
+Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/create-lwc-plugin/README.md b/packages/create-lwc-plugin/README.md
new file mode 100644
index 0000000000..534d5f8b6d
--- /dev/null
+++ b/packages/create-lwc-plugin/README.md
@@ -0,0 +1,82 @@
+# create-lwc-plugin
+
+**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.
+
+By using this wizard-like tool, you can customize the initial setup of their
+plugin project by answering a few questions. This allows for a seamless and
+efficient starting point, saving valuable time and effort.
+
+Whether you are developing a new Drawing primitive plugin or a Custom series
+plugin for Lightweight Charts, this generator provides a structured and
+organized foundation. It ensures that your plugin adheres to the best practices
+and conventions of Lightweight Charts, making it easier to develop, maintain,
+and contribute to the community.
+
+Getting started with your Lightweight Charts plugin development has never been
+easier. Let the Lightweight Charts™ Plugin Scaffold Generator
+(`create-lwc-plugin`) handle the initial setup, so you can focus on creating
+outstanding plugins for Lightweight Charts™.
+
+✨ Need some examples for inspiration? Check out the
+[plugin-examples](https://github.com/tradingview/lightweight-charts/tree/master/plugin-examples)
+folder in the Lightweight Charts repo.
+
+## Scaffolding Your First Lightweight Charts™ Plugin
+
+With NPM:
+
+```bash
+npm create lwc-plugin@latest
+```
+
+With Yarn:
+
+```bash
+yarn create lwc-plugin
+```
+
+With PNPM:
+
+```bash
+pnpm create lwc-plugin
+```
+
+## Using the generated project
+
+### Running Locally (during development)
+
+```shell
+npm install
+npm run dev
+```
+
+Visit `localhost:5173` in the browser.
+
+### Compiling the Plugin
+
+```shell
+npm run compile
+```
+
+Check the output in the `dist` folder.
+
+### Publishing To NPM
+
+You can configure the contents of the package's `package.json` within the
+`compile.mjs` script.
+
+Once you have compiled the plugin (see above section) then you can publish the
+package to NPM with these commands:
+
+```shell
+cd dist
+npm publish
+```
+
+Hint: append `--dry-run` to the end of the publish command to see the results of
+the publish command without actually uploading the package to NPM.
diff --git a/packages/create-lwc-plugin/build.config.ts b/packages/create-lwc-plugin/build.config.ts
new file mode 100644
index 0000000000..b426440ae2
--- /dev/null
+++ b/packages/create-lwc-plugin/build.config.ts
@@ -0,0 +1,13 @@
+import { defineBuildConfig } from 'unbuild';
+
+export default defineBuildConfig({
+ entries: ['src/index'],
+ clean: true,
+ rollup: {
+ inlineDependencies: true,
+ esbuild: {
+ target: 'node18',
+ minify: true,
+ },
+ },
+});
diff --git a/packages/create-lwc-plugin/index.js b/packages/create-lwc-plugin/index.js
new file mode 100755
index 0000000000..b5585bdb14
--- /dev/null
+++ b/packages/create-lwc-plugin/index.js
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+
+import './dist/index.mjs';
diff --git a/packages/create-lwc-plugin/package.json b/packages/create-lwc-plugin/package.json
new file mode 100644
index 0000000000..c3f5c70166
--- /dev/null
+++ b/packages/create-lwc-plugin/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "create-lwc-plugin",
+ "version": "1.0.0",
+ "type": "module",
+ "license": "MIT",
+ "author": "TradingView",
+ "description": "Wizard-like CLI tool for scaffolding a new plugin for Lightweight Charts™",
+ "keywords": ["lightweight-charts", "lwc-plugin", "plugins"],
+ "bin": {
+ "create-lwc-plugin": "index.js"
+ },
+ "files": [
+ "index.js",
+ "template-*",
+ "dist"
+ ],
+ "scripts": {
+ "dev": "unbuild --stub && echo 'now run `node index.js`'",
+ "build": "unbuild",
+ "typecheck": "tsc --noEmit",
+ "prepublishOnly": "npm run build"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/tradingview/lightweight-charts.git",
+ "directory": "packages/create-lwc-plugin"
+ },
+ "bugs": {
+ "url": "https://github.com/slicedsilver/create-lwc-plugin/issues"
+ },
+ "homepage": "https://tradingview.github.io/lightweight-charts/",
+ "devDependencies": {
+ "@clack/prompts": "^0.7.0",
+ "@types/node": "^20.5.1",
+ "picocolors": "^1.0.0",
+ "unbuild": "^1.2.1"
+ }
+}
diff --git a/packages/create-lwc-plugin/src/helpers/io.ts b/packages/create-lwc-plugin/src/helpers/io.ts
new file mode 100644
index 0000000000..da2fcd1dfc
--- /dev/null
+++ b/packages/create-lwc-plugin/src/helpers/io.ts
@@ -0,0 +1,54 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+export function formatTargetDir(targetDir: string | undefined) {
+ return targetDir?.trim().replace(/\/+$/g, '');
+}
+
+export function copy(src: string, dest: string, contentReplacer?: (content: string) => string) {
+ const stat = fs.statSync(src);
+ if (stat.isDirectory()) {
+ copyDir(src, dest, contentReplacer);
+ } else {
+ const content = fs.readFileSync(src).toString();
+ fs.writeFileSync(dest, contentReplacer ? contentReplacer(content) : content);
+ }
+}
+
+export function copyDir(srcDir: string, destDir: string, contentReplacer?: (content: string) => string) {
+ fs.mkdirSync(destDir, { recursive: true });
+ for (const file of fs.readdirSync(srcDir)) {
+ const srcFile = path.resolve(srcDir, file);
+ const destFile = path.resolve(destDir, file);
+ copy(srcFile, destFile, contentReplacer);
+ }
+}
+
+export function dirExists(dir: string) {
+ return fs.existsSync(dir);
+}
+
+export function isEmpty(path: string) {
+ const files = fs.readdirSync(path);
+ return files.length === 0 || (files.length === 1 && files[0] === '.git');
+}
+
+export function emptyDir(dir: string) {
+ if (!fs.existsSync(dir)) {
+ return;
+ }
+ for (const file of fs.readdirSync(dir)) {
+ if (file === '.git') {
+ continue;
+ }
+ fs.rmSync(path.resolve(dir, file), { recursive: true, force: true });
+ }
+}
+
+// editFile(path.resolve(root, `vite.config.${isTs ? 'ts' : 'js'}`), content => {
+// return content.replace('@vitejs/plugin-react', '@vitejs/plugin-react-swc');
+// });
+export function editFile(file: string, callback: (content: string) => string) {
+ const content = fs.readFileSync(file, 'utf-8');
+ fs.writeFileSync(file, callback(content), 'utf-8');
+}
diff --git a/packages/create-lwc-plugin/src/helpers/package.ts b/packages/create-lwc-plugin/src/helpers/package.ts
new file mode 100644
index 0000000000..ab8713548e
--- /dev/null
+++ b/packages/create-lwc-plugin/src/helpers/package.ts
@@ -0,0 +1,14 @@
+function pkgFromUserAgent(userAgent: string | undefined) {
+ if (!userAgent) return undefined;
+ const pkgSpec = userAgent.split(' ')[0];
+ const pkgSpecArr = pkgSpec.split('/');
+ return {
+ name: pkgSpecArr[0],
+ version: pkgSpecArr[1],
+ };
+}
+
+export function getPkgManagerName() {
+ const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent);
+ return pkgInfo ? pkgInfo.name : 'npm';
+}
diff --git a/packages/create-lwc-plugin/src/helpers/validation.ts b/packages/create-lwc-plugin/src/helpers/validation.ts
new file mode 100644
index 0000000000..b0e639c442
--- /dev/null
+++ b/packages/create-lwc-plugin/src/helpers/validation.ts
@@ -0,0 +1,14 @@
+export function isValidPackageName(projectName: string) {
+ return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(
+ projectName
+ );
+}
+
+export function toValidPackageName(projectName: string) {
+ return projectName
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/^[._]/, '')
+ .replace(/[^a-z\d\-~]+/g, '-');
+}
diff --git a/packages/create-lwc-plugin/src/index.ts b/packages/create-lwc-plugin/src/index.ts
new file mode 100755
index 0000000000..80a565f024
--- /dev/null
+++ b/packages/create-lwc-plugin/src/index.ts
@@ -0,0 +1,101 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { intro, outro, spinner, cancel } from '@clack/prompts';
+import color from 'picocolors';
+import { Answers, askQuestions } from './questions';
+import { copy } from './helpers/io';
+
+const cwd = process.cwd();
+
+const renameFiles: Record = {
+ _gitignore: '.gitignore',
+};
+
+async function init() {
+ console.log();
+ intro(color.inverse(' create-lwc-plugin '));
+
+ let answers: Answers;
+ try {
+ answers = await askQuestions();
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ cancel(e.message);
+ }
+ return process.exit(0);
+ }
+
+ const s = spinner();
+
+ s.start('Building your new plugin project');
+ const root = path.join(cwd, answers.targetFolderPath);
+ if (answers.targetFolderPath) {
+ fs.mkdirSync(root, { recursive: true });
+ }
+ const templateDir = path.resolve(
+ fileURLToPath(import.meta.url),
+ '../..',
+ `template-${answers.projectType}`
+ );
+ const commonTemplateDir = path.resolve(
+ fileURLToPath(import.meta.url),
+ '../..',
+ `template-common`
+ );
+
+ const entryName = 'template-entry';
+ const newEntryName = answers.packageName.replace(/lwc-plugin-/, '');
+ const entryFileName = `${entryName}.ts`;
+ const newEntryFileName = `${newEntryName}.ts`;
+
+ const contentsReplacer = (content: string): string => {
+ const result = content
+ .replaceAll(entryName, newEntryName)
+ .replace(/_PLUGINNAME_/g, answers.name)
+ .replace(/_CLASSNAME_/g, answers.typeName)
+ .replace(/_PACKAGENAME_/g, answers.packageName);
+ if (answers.includeHints) {
+ return result;
+ }
+ // Comments starting with '//*' are considered 'hints'
+ return result.replace(/.*\/\/\*.*\r?\n/g, '');
+ };
+
+ const write = (dir: string, file: string, content?: string) => {
+ const targetPath = path.join(root, renameFiles[file] ?? file);
+ if (content) {
+ fs.writeFileSync(targetPath, contentsReplacer(content));
+ } else {
+ copy(path.join(dir, file), targetPath, contentsReplacer);
+ }
+ };
+
+ const files = fs.readdirSync(templateDir);
+ for (const file of files) {
+ write(templateDir, file);
+ }
+
+ const commonFiles = fs.readdirSync(commonTemplateDir);
+ for (const file of commonFiles.filter(f => f !== 'package.json')) {
+ write(commonTemplateDir, file);
+ }
+
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(commonTemplateDir, `package.json`), 'utf-8')
+ );
+
+ pkg.name = answers.packageName;
+
+ write(root, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
+
+ fs.renameSync(path.join(root, 'src', entryFileName), path.join(root, 'src', newEntryFileName));
+
+ s.stop('Built your new plugin project');
+
+ outro("You're all set!");
+}
+
+init().catch(e => {
+ console.error(e);
+});
diff --git a/packages/create-lwc-plugin/src/questions.ts b/packages/create-lwc-plugin/src/questions.ts
new file mode 100644
index 0000000000..18cab0797e
--- /dev/null
+++ b/packages/create-lwc-plugin/src/questions.ts
@@ -0,0 +1,126 @@
+import { confirm, select, isCancel, text, log } from '@clack/prompts';
+import { isValidPackageName, toValidPackageName } from './helpers/validation';
+import color from 'picocolors';
+import { dirExists, isEmpty } from './helpers/io';
+import { resolve } from 'node:path';
+
+export interface Answers {
+ projectType: 'series' | 'primitive';
+ name: string;
+ packageName: string;
+ typeName: string;
+ targetFolderPath: string;
+ includeHints: boolean;
+}
+
+export async function askQuestions(): Promise {
+ let projectType: string | symbol = '';
+ while (['primitive', 'series'].includes(projectType) === false) {
+ const options = [
+ { value: 'primitive', label: 'Drawing Primitive' },
+ { value: 'series', label: 'Custom Series' },
+ ];
+ if (projectType !== 'help') {
+ options.push({ value: 'help', label: 'Help me decide' });
+ }
+ projectType = await select({
+ message: 'Pick a plugin type.',
+ options,
+ });
+ if (isCancel(projectType)) throw new Error('Operation cancelled');
+ if (projectType === 'help') {
+ log.message(`Plugins come in two types: custom series and drawing primitives.
+Custom series allow developers to define new types of series,
+while drawing primitives enable the creation of custom visualizations,
+drawing tools, and chart annotations (and more) which can be attached
+to an existing series.`);
+ log.info(`In the majority of cases you will most likely be better served
+by using a Drawing Primitive plugin unless you are specifically
+looking to create a new type of series.`);
+ }
+ }
+
+ const name = await text({
+ message: `What would you like to name the plugin?`,
+ placeholder:
+ projectType === 'series' ? 'My Custom Series' : 'My Drawing Primitive',
+ validate(value) {
+ if (value.length === 0) return 'A name is required!';
+ },
+ });
+ if (isCancel(name)) throw new Error('Operation cancelled');
+
+ const suggestedPackageName = toValidPackageName('lwc-plugin-' + name);
+ const packageName = await text({
+ message: 'Package Name for the Plugin?',
+ placeholder: suggestedPackageName,
+ initialValue: suggestedPackageName,
+ validate(value) {
+ if (value.length === 0) return 'A class name is required!';
+ if (!value.startsWith('lwc-plugin-'))
+ return 'A package name should start with `lwc-plugin-`';
+ if (!isValidPackageName(value))
+ return 'The name is not a valid npm package name!';
+ },
+ });
+ if (isCancel(packageName)) throw new Error('Operation cancelled');
+
+ let suggestedTypeName = name.replace(/[^a-zA-Z0-9]/g, '');
+ suggestedTypeName =
+ suggestedTypeName[0].toUpperCase() + suggestedTypeName.slice(1);
+
+ const typeName = await text({
+ message: 'Class Name for the Plugin?',
+ placeholder: suggestedTypeName,
+ initialValue: suggestedTypeName,
+ validate(value) {
+ if (value.length === 0) return 'A class name is required!';
+ if (/\s/.test(value)) return 'A class name may not contain any spaces!';
+ if (/\-/.test(value)) return 'A class name may not contain any hyphens!';
+ if (/^\d/.test(value)) return 'A class name may not start with a digit!';
+ if (/^[a-zA-Z0-9]+$/.test(value) === false)
+ return 'A class name may only contain letters and numbers!';
+ if (/^[a-z]/.test(value))
+ return 'A class name should start with a capital letter!';
+ },
+ });
+ if (isCancel(typeName)) throw new Error('Operation cancelled');
+
+ const includeHints = await confirm({
+ message: 'Include hint comments?',
+ });
+ if (isCancel(includeHints)) throw new Error('Operation cancelled');
+
+ const targetFolderPath = await text({
+ message: `Relative Folder Path for Created Plugin? ${color.dim(
+ '(leave blank to use current directory)'
+ )}`,
+ placeholder: packageName,
+ initialValue: packageName,
+ validate(value) {
+ const cwd = process.cwd();
+ const path = resolve(cwd, value);
+ if (value && dirExists(path) && !isEmpty(path)) return 'folder already exists, and it is not empty!';
+ if (!value && !isEmpty(path)) return 'current directory is not empty!';
+ },
+ });
+ if (isCancel(targetFolderPath)) throw new Error('Operation cancelled');
+
+ const shouldContinue = await confirm({
+ message: `Scaffold a new plugin project into the ${color.bold(
+ targetFolderPath || 'current'
+ )} folder?`,
+ active: 'Start',
+ inactive: 'Cancel',
+ });
+ if (isCancel(shouldContinue) || !shouldContinue)
+ throw new Error('Operation cancelled');
+ return {
+ projectType: projectType as 'series' | 'primitive',
+ name,
+ packageName,
+ typeName,
+ targetFolderPath,
+ includeHints,
+ };
+}
diff --git a/packages/create-lwc-plugin/template-common/README.md b/packages/create-lwc-plugin/template-common/README.md
new file mode 100644
index 0000000000..b0673eb5fd
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/README.md
@@ -0,0 +1,38 @@
+# _PLUGINNAME_ - Lightweight Charts™ Plugin
+
+Description of the Plugin.
+
+- Developed for Lightweight Charts version: `v4.1.0`
+
+## Running Locally
+
+```shell
+npm install
+npm run dev
+```
+
+Visit `localhost:5173` in the browser.
+
+## Compiling
+
+```shell
+npm run compile
+```
+
+Check the output in the `dist` folder.
+
+## Publishing To NPM
+
+You can configure the contents of the package's `package.json` within the
+`compile.mjs` script.
+
+Once you have compiled the plugin (see above section) then you can publish the
+package to NPM with these commands:
+
+```shell
+cd dist
+npm publish
+```
+
+Hint: append `--dry-run` to the end of the publish command to see the results of
+the publish command without actually uploading the package to NPM.
diff --git a/packages/create-lwc-plugin/template-common/_gitignore b/packages/create-lwc-plugin/template-common/_gitignore
new file mode 100644
index 0000000000..a90cc1392a
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/_gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+typings
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/create-lwc-plugin/template-common/compile.mjs b/packages/create-lwc-plugin/template-common/compile.mjs
new file mode 100644
index 0000000000..9ecd494b8f
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/compile.mjs
@@ -0,0 +1,116 @@
+import { dirname, resolve } from 'node:path';
+import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
+import { build, defineConfig } from 'vite';
+import { fileURLToPath } from 'url';
+import { generateDtsBundle } from 'dts-bundle-generator';
+
+function buildPackageJson(packageName) {
+ /*
+ Define the contents of the package's package.json here.
+ */
+ return {
+ name: packageName,
+ version: '1.0.0',
+ keywords: ['lwc-plugin', 'lightweight-charts'],
+ type: 'module',
+ main: `./${packageName}.umd.cjs`,
+ module: `./${packageName}.js`,
+ types: `./${packageName}.d.ts`,
+ exports: {
+ import: {
+ types: `./${packageName}.d.ts`,
+ default: `./${packageName}.js`,
+ },
+ require: {
+ types: `./${packageName}.d.cts`,
+ default: `./${packageName}.umd.cjs`,
+ },
+ },
+ };
+}
+
+const __filename = fileURLToPath(import.meta.url);
+const currentDir = dirname(__filename);
+
+const pluginFileName = 'template-entry';
+const pluginFile = resolve(currentDir, 'src', `${pluginFileName}.ts`);
+
+const pluginsToBuild = [
+ {
+ filepath: pluginFile,
+ exportName: '_PACKAGENAME_',
+ name: '_CLASSNAME_',
+ },
+];
+
+const compiledFolder = resolve(currentDir, 'dist');
+if (!existsSync(compiledFolder)) {
+ mkdirSync(compiledFolder);
+}
+
+const buildConfig = ({
+ filepath,
+ name,
+ exportName,
+ formats = ['es', 'umd'],
+}) => {
+ return defineConfig({
+ publicDir: false,
+ build: {
+ outDir: `dist`,
+ emptyOutDir: true,
+ copyPublicDir: false,
+ lib: {
+ entry: filepath,
+ name,
+ formats,
+ fileName: exportName,
+ },
+ rollupOptions: {
+ external: ['lightweight-charts', 'fancy-canvas'],
+ output: {
+ globals: {
+ 'lightweight-charts': 'LightweightCharts',
+ },
+ },
+ },
+ },
+ });
+};
+
+const startTime = Date.now().valueOf();
+console.log('⚡️ Starting');
+console.log('Bundling the plugin...');
+const promises = pluginsToBuild.map(file => {
+ return build(buildConfig(file));
+});
+await Promise.all(promises);
+console.log('Generating the package.json file...');
+pluginsToBuild.forEach(file => {
+ const packagePath = resolve(compiledFolder, 'package.json');
+ const content = JSON.stringify(
+ buildPackageJson(file.exportName),
+ undefined,
+ 4
+ );
+ writeFileSync(packagePath, content, { encoding: 'utf-8' });
+});
+console.log('Generating the typings files...');
+pluginsToBuild.forEach(file => {
+ try {
+ const esModuleTyping = generateDtsBundle([
+ {
+ filePath: `./typings/${pluginFileName}.d.ts`,
+ },
+ ]);
+ const typingFilePath = resolve(compiledFolder, `${file.exportName}.d.ts`);
+ writeFileSync(typingFilePath, esModuleTyping.join('\n'), {
+ encoding: 'utf-8',
+ });
+ copyFileSync(typingFilePath, resolve(compiledFolder, `${file.exportName}.d.cts`));
+ } catch (e) {
+ console.error('Error generating typings for: ', file.exportName);
+ }
+});
+const endTime = Date.now().valueOf();
+console.log(`🎉 Done (${endTime - startTime}ms)`);
diff --git a/packages/create-lwc-plugin/template-common/index.html b/packages/create-lwc-plugin/template-common/index.html
new file mode 100644
index 0000000000..3f1b3a1b19
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/index.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/packages/create-lwc-plugin/template-common/package.json b/packages/create-lwc-plugin/template-common/package.json
new file mode 100644
index 0000000000..eaa476ba3d
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "to-be-replaced",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --config src/vite.config.js",
+ "compile": "tsc && node compile.mjs"
+ },
+ "devDependencies": {
+ "typescript": "^5.0.4",
+ "vite": "^4.3.1"
+ },
+ "dependencies": {
+ "dts-bundle-generator": "^8.0.1",
+ "fancy-canvas": "^2.1.0",
+ "lightweight-charts": "^4.1.0-rc2"
+ }
+}
diff --git a/packages/create-lwc-plugin/template-common/src/helpers/dimensions/common.ts b/packages/create-lwc-plugin/template-common/src/helpers/dimensions/common.ts
new file mode 100644
index 0000000000..394c6bb9ad
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/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/packages/create-lwc-plugin/template-common/src/helpers/dimensions/crosshair-width.ts b/packages/create-lwc-plugin/template-common/src/helpers/dimensions/crosshair-width.ts
new file mode 100644
index 0000000000..9d14991aaf
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/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/packages/create-lwc-plugin/template-common/src/helpers/dimensions/full-width.ts b/packages/create-lwc-plugin/template-common/src/helpers/dimensions/full-width.ts
new file mode 100644
index 0000000000..6f4d40dd41
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/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/packages/create-lwc-plugin/template-common/src/helpers/dimensions/positions.ts b/packages/create-lwc-plugin/template-common/src/helpers/dimensions/positions.ts
new file mode 100644
index 0000000000..a021b072b0
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/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/packages/create-lwc-plugin/template-common/src/helpers/time.ts b/packages/create-lwc-plugin/template-common/src/helpers/time.ts
new file mode 100644
index 0000000000..ed654758a1
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/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/packages/create-lwc-plugin/template-common/src/vite-env.d.ts b/packages/create-lwc-plugin/template-common/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/create-lwc-plugin/template-common/src/vite.config.js b/packages/create-lwc-plugin/template-common/src/vite.config.js
new file mode 100644
index 0000000000..a7ae019b73
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/src/vite.config.js
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite';
+
+const input = {
+ main: './src/example/index.html',
+};
+
+export default defineConfig({
+ build: {
+ rollupOptions: {
+ input,
+ },
+ },
+});
diff --git a/packages/create-lwc-plugin/template-common/tsconfig.json b/packages/create-lwc-plugin/template-common/tsconfig.json
new file mode 100644
index 0000000000..99e948c965
--- /dev/null
+++ b/packages/create-lwc-plugin/template-common/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/packages/create-lwc-plugin/template-primitive/src/axis-pane-renderer.ts b/packages/create-lwc-plugin/template-primitive/src/axis-pane-renderer.ts
new file mode 100644
index 0000000000..1125c37f6b
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/axis-pane-renderer.ts
@@ -0,0 +1,44 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts';
+import { positionsBox } from './helpers/dimensions/positions';
+
+export class _CLASSNAME_AxisPaneRenderer 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();
+ });
+ }
+}
diff --git a/packages/create-lwc-plugin/template-primitive/src/axis-pane-view.ts b/packages/create-lwc-plugin/template-primitive/src/axis-pane-view.ts
new file mode 100644
index 0000000000..0b2caac371
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/axis-pane-view.ts
@@ -0,0 +1,55 @@
+import {
+ Coordinate,
+ ISeriesPrimitivePaneView,
+ SeriesPrimitivePaneViewZOrder,
+} from 'lightweight-charts';
+import { _CLASSNAME_AxisPaneRenderer } from './axis-pane-renderer';
+import { _CLASSNAME_DataSource } from './data-source';
+
+abstract class _CLASSNAME_AxisPaneView implements ISeriesPrimitivePaneView {
+ _source: _CLASSNAME_DataSource;
+ _p1: number | null = null;
+ _p2: number | null = null;
+ _vertical: boolean = false;
+
+ constructor(source: _CLASSNAME_DataSource, vertical: boolean) {
+ this._source = source;
+ this._vertical = vertical;
+ }
+
+ abstract getPoints(): [Coordinate | null, Coordinate | null];
+
+ update() {
+ [this._p1, this._p2] = this.getPoints();
+ }
+
+ renderer() {
+ return new _CLASSNAME_AxisPaneRenderer(
+ this._p1,
+ this._p2,
+ this._source.options.fillColor,
+ this._vertical
+ );
+ }
+ zOrder(): SeriesPrimitivePaneViewZOrder {
+ return 'bottom';
+ }
+}
+
+export class _CLASSNAME_PriceAxisPaneView extends _CLASSNAME_AxisPaneView {
+ 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];
+ }
+}
+
+export class _CLASSNAME_TimeAxisPaneView extends _CLASSNAME_AxisPaneView {
+ 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];
+ }
+}
diff --git a/packages/create-lwc-plugin/template-primitive/src/axis-view.ts b/packages/create-lwc-plugin/template-primitive/src/axis-view.ts
new file mode 100644
index 0000000000..2f3f2f0570
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/axis-view.ts
@@ -0,0 +1,57 @@
+import { Coordinate, ISeriesPrimitiveAxisView } from 'lightweight-charts';
+import { Point, _CLASSNAME_DataSource } from './data-source';
+
+abstract class _CLASSNAME_AxisView implements ISeriesPrimitiveAxisView {
+ _source: _CLASSNAME_DataSource;
+ _p: Point;
+ _pos: Coordinate | null = null;
+ constructor(source: _CLASSNAME_DataSource, 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();
+ }
+}
+
+export class _CLASSNAME_TimeAxisView extends _CLASSNAME_AxisView {
+ update() {
+ const timeScale = this._source.chart.timeScale();
+ this._pos = timeScale.timeToCoordinate(this._p.time);
+ }
+ text() {
+ return this._source.options.timeLabelFormatter(this._p.time);
+ }
+}
+
+export class _CLASSNAME_PriceAxisView extends _CLASSNAME_AxisView {
+ update() {
+ const series = this._source.series;
+ this._pos = series.priceToCoordinate(this._p.price);
+ }
+ text() {
+ return this._source.options.priceLabelFormatter(this._p.price);
+ }
+}
diff --git a/packages/create-lwc-plugin/template-primitive/src/data-source.ts b/packages/create-lwc-plugin/template-primitive/src/data-source.ts
new file mode 100644
index 0000000000..f56ffef4c3
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/data-source.ts
@@ -0,0 +1,20 @@
+import {
+ IChartApi,
+ ISeriesApi,
+ SeriesOptionsMap,
+ Time,
+} from 'lightweight-charts';
+import { _CLASSNAME_Options } from './options';
+
+export interface Point {
+ time: Time;
+ price: number;
+}
+
+export interface _CLASSNAME_DataSource {
+ chart: IChartApi;
+ series: ISeriesApi;
+ options: _CLASSNAME_Options;
+ p1: Point;
+ p2: Point;
+}
diff --git a/packages/create-lwc-plugin/template-primitive/src/example/example.ts b/packages/create-lwc-plugin/template-primitive/src/example/example.ts
new file mode 100644
index 0000000000..2b1a151c17
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/example/example.ts
@@ -0,0 +1,23 @@
+import { createChart } from 'lightweight-charts';
+import { generateLineData } from '../sample-data';
+import { _CLASSNAME_ } from '../template-entry';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const lineSeries = chart.addLineSeries({
+ color: '#000000',
+});
+const data = generateLineData();
+lineSeries.setData(data);
+
+const time1 = data[data.length - 50].time;
+const time2 = data[data.length - 10].time;
+
+const primitive = new _CLASSNAME_(
+ { price: 100, time: time1 },
+ { price: 500, time: time2 }
+);
+
+lineSeries.attachPrimitive(primitive);
diff --git a/packages/create-lwc-plugin/template-primitive/src/example/index.html b/packages/create-lwc-plugin/template-primitive/src/example/index.html
new file mode 100644
index 0000000000..ed1334b874
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/example/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Template Drawing Primitive Plugin Example
+
+
+
+
+
+
+
diff --git a/packages/create-lwc-plugin/template-primitive/src/helpers/assertions.ts b/packages/create-lwc-plugin/template-primitive/src/helpers/assertions.ts
new file mode 100644
index 0000000000..e68742f3cf
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/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/packages/create-lwc-plugin/template-primitive/src/options.ts b/packages/create-lwc-plugin/template-primitive/src/options.ts
new file mode 100644
index 0000000000..91f34ea865
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/options.ts
@@ -0,0 +1,27 @@
+import { Time, isBusinessDay } from 'lightweight-charts';
+
+export interface _CLASSNAME_Options {
+ //* Define the options for the primitive.
+ fillColor: string;
+ labelColor: string;
+ labelTextColor: string;
+ showLabels: boolean;
+ priceLabelFormatter: (price: number) => string;
+ timeLabelFormatter: (time: Time) => string;
+}
+
+export const defaultOptions: _CLASSNAME_Options = {
+ //* Define the default values for all the primitive options.
+ fillColor: 'rgba(200, 50, 100, 0.75)',
+ 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();
+ },
+} as const;
diff --git a/packages/create-lwc-plugin/template-primitive/src/pane-renderer.ts b/packages/create-lwc-plugin/template-primitive/src/pane-renderer.ts
new file mode 100644
index 0000000000..663cda036c
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/pane-renderer.ts
@@ -0,0 +1,46 @@
+import { CanvasRenderingTarget2D } from 'fancy-canvas';
+import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts';
+import { ViewPoint } from './pane-view';
+import { positionsBox } from './helpers/dimensions/positions';
+
+export class _CLASSNAME_PaneRenderer 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
+ );
+ });
+ }
+}
diff --git a/packages/create-lwc-plugin/template-primitive/src/pane-view.ts b/packages/create-lwc-plugin/template-primitive/src/pane-view.ts
new file mode 100644
index 0000000000..0ddf569262
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/pane-view.ts
@@ -0,0 +1,37 @@
+import { Coordinate, ISeriesPrimitivePaneView } from 'lightweight-charts';
+import { _CLASSNAME_PaneRenderer } from './pane-renderer';
+import { _CLASSNAME_DataSource } from './data-source';
+
+export interface ViewPoint {
+ x: Coordinate | null;
+ y: Coordinate | null;
+}
+
+export class _CLASSNAME_PaneView implements ISeriesPrimitivePaneView {
+ _source: _CLASSNAME_DataSource;
+ _p1: ViewPoint = { x: null, y: null };
+ _p2: ViewPoint = { x: null, y: null };
+
+ constructor(source: _CLASSNAME_DataSource) {
+ 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 _CLASSNAME_PaneRenderer(
+ this._p1,
+ this._p2,
+ this._source.options.fillColor
+ );
+ }
+}
diff --git a/packages/create-lwc-plugin/template-primitive/src/plugin-base.ts b/packages/create-lwc-plugin/template-primitive/src/plugin-base.ts
new file mode 100644
index 0000000000..def9684a9b
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/plugin-base.ts
@@ -0,0 +1,56 @@
+import {
+ DataChangedScope,
+ IChartApi,
+ ISeriesApi,
+ ISeriesPrimitive,
+ SeriesAttachedParameter,
+ SeriesOptionsMap,
+ Time,
+} from 'lightweight-charts';
+import { ensureDefined } from './helpers/assertions';
+
+//* PluginBase is a useful base to build a plugin upon which
+//* already handles creating getters for the chart and series,
+//* and provides a requestUpdate method.
+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/packages/create-lwc-plugin/template-primitive/src/sample-data.ts b/packages/create-lwc-plugin/template-primitive/src/sample-data.ts
new file mode 100644
index 0000000000..34d798cb55
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/sample-data.ts
@@ -0,0 +1,35 @@
+import type { Time } from 'lightweight-charts';
+
+type LineData = {
+ time: Time;
+ value: 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(2023, 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;
+}
+
diff --git a/packages/create-lwc-plugin/template-primitive/src/template-entry.ts b/packages/create-lwc-plugin/template-primitive/src/template-entry.ts
new file mode 100644
index 0000000000..aaac20b89e
--- /dev/null
+++ b/packages/create-lwc-plugin/template-primitive/src/template-entry.ts
@@ -0,0 +1,139 @@
+import { AutoscaleInfo, Logical, Time, DataChangedScope } from 'lightweight-charts';
+import {
+ _CLASSNAME_PriceAxisPaneView,
+ _CLASSNAME_TimeAxisPaneView,
+} from './axis-pane-view';
+import { _CLASSNAME_PriceAxisView, _CLASSNAME_TimeAxisView } from './axis-view';
+import { Point, _CLASSNAME_DataSource } from './data-source';
+import { _CLASSNAME_Options, defaultOptions } from './options';
+import { _CLASSNAME_PaneView } from './pane-view';
+import { PluginBase } from './plugin-base';
+
+export class _CLASSNAME_
+ extends PluginBase
+ implements _CLASSNAME_DataSource
+{
+ _options: _CLASSNAME_Options;
+ _p1: Point;
+ _p2: Point;
+ _paneViews: _CLASSNAME_PaneView[];
+ _timeAxisViews: _CLASSNAME_TimeAxisView[];
+ _priceAxisViews: _CLASSNAME_PriceAxisView[];
+ _priceAxisPaneViews: _CLASSNAME_PriceAxisPaneView[];
+ _timeAxisPaneViews: _CLASSNAME_TimeAxisPaneView[];
+
+ constructor(
+ p1: Point,
+ p2: Point,
+ options: Partial<_CLASSNAME_Options> = {}
+ ) {
+ super();
+ this._p1 = p1;
+ this._p2 = p2;
+ this._options = {
+ ...defaultOptions,
+ ...options,
+ };
+ this._paneViews = [new _CLASSNAME_PaneView(this)];
+ this._timeAxisViews = [
+ new _CLASSNAME_TimeAxisView(this, p1),
+ new _CLASSNAME_TimeAxisView(this, p2),
+ ];
+ this._priceAxisViews = [
+ new _CLASSNAME_PriceAxisView(this, p1),
+ new _CLASSNAME_PriceAxisView(this, p2),
+ ];
+ this._priceAxisPaneViews = [new _CLASSNAME_PriceAxisPaneView(this, true)];
+ this._timeAxisPaneViews = [new _CLASSNAME_TimeAxisPaneView(this, false)];
+ }
+
+ updateAllViews() {
+ //* Use this method to update any data required by the
+ //* views to draw.
+ 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() {
+ //* Labels rendered on the price scale
+ return this._priceAxisViews;
+ }
+
+ timeAxisViews() {
+ //* labels rendered on the time scale
+ return this._timeAxisViews;
+ }
+
+ paneViews() {
+ //* rendering on the main chart pane
+ return this._paneViews;
+ }
+
+ priceAxisPaneViews() {
+ //* rendering on the price scale
+ return this._priceAxisPaneViews;
+ }
+
+ timeAxisPaneViews() {
+ //* rendering on the time scale
+ return this._timeAxisPaneViews;
+ }
+
+ autoscaleInfo(
+ startTimePoint: Logical,
+ endTimePoint: Logical
+ ): AutoscaleInfo | null {
+ //* Use this method to provide autoscale information if your primitive
+ //* should have the ability to remain in view automatically.
+ if (
+ this._timeCurrentlyVisible(this.p1.time, startTimePoint, endTimePoint) ||
+ this._timeCurrentlyVisible(this.p2.time, startTimePoint, endTimePoint)
+ ) {
+ return {
+ priceRange: {
+ minValue: Math.min(this.p1.price, this.p2.price),
+ maxValue: Math.max(this.p1.price, this.p2.price),
+ },
+ };
+ }
+ return null;
+ }
+
+ dataUpdated(scope: DataChangedScope): void {
+ //* This method will be called by PluginBase when the data on the
+ //* series has changed.
+ }
+
+ _timeCurrentlyVisible(
+ time: Time,
+ startTimePoint: Logical,
+ endTimePoint: Logical
+ ): boolean {
+ const ts = this.chart.timeScale();
+ const coordinate = ts.timeToCoordinate(time);
+ if (coordinate === null) return false;
+ const logical = ts.coordinateToLogical(coordinate);
+ if (logical === null) return false;
+ return logical <= endTimePoint && logical >= startTimePoint;
+ }
+
+ public get options(): _CLASSNAME_Options {
+ return this._options;
+ }
+
+ applyOptions(options: Partial<_CLASSNAME_Options>) {
+ this._options = { ...this._options, ...options };
+ this.requestUpdate();
+ }
+
+ public get p1(): Point {
+ return this._p1;
+ }
+
+ public get p2(): Point {
+ return this._p2;
+ }
+}
diff --git a/packages/create-lwc-plugin/template-series/src/data.ts b/packages/create-lwc-plugin/template-series/src/data.ts
new file mode 100644
index 0000000000..a3311584e5
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/data.ts
@@ -0,0 +1,11 @@
+import { CustomData } from 'lightweight-charts';
+
+/**
+ * _CLASSNAME_ Data
+ */
+export interface _CLASSNAME_Data extends CustomData {
+ //* Define the structure of the data required for the series.
+ //* You could also 'extend' an existing Lightweight Charts Data type like LineData or CandlestickData
+ high: number;
+ low: number;
+}
diff --git a/packages/create-lwc-plugin/template-series/src/example/example.ts b/packages/create-lwc-plugin/template-series/src/example/example.ts
new file mode 100644
index 0000000000..2d17095bda
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/example/example.ts
@@ -0,0 +1,15 @@
+import { createChart } from 'lightweight-charts';
+import { _CLASSNAME_ } from '../template-entry';
+import { _CLASSNAME_Data } from '../data';
+import { generateSampleData } from '../sample-data';
+
+const chart = ((window as unknown as any).chart = createChart('chart', {
+ autoSize: true,
+}));
+
+const series = chart.addCustomSeries(new _CLASSNAME_(), {
+ /* Options */
+});
+
+const data: _CLASSNAME_Data[] = generateSampleData(500, 50);
+series.setData(data);
diff --git a/packages/create-lwc-plugin/template-series/src/example/index.html b/packages/create-lwc-plugin/template-series/src/example/index.html
new file mode 100644
index 0000000000..b335383c25
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/example/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ _PLUGINNAME_ Plugin Example
+
+
+
+
+
+
+
diff --git a/packages/create-lwc-plugin/template-series/src/helpers/dimensions/candles.ts b/packages/create-lwc-plugin/template-series/src/helpers/dimensions/candles.ts
new file mode 100644
index 0000000000..4a22b0797a
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/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/packages/create-lwc-plugin/template-series/src/helpers/dimensions/columns.ts b/packages/create-lwc-plugin/template-series/src/helpers/dimensions/columns.ts
new file mode 100644
index 0000000000..de3d12859c
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/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/packages/create-lwc-plugin/template-series/src/options.ts b/packages/create-lwc-plugin/template-series/src/options.ts
new file mode 100644
index 0000000000..ab657c43cc
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/options.ts
@@ -0,0 +1,23 @@
+import {
+ CustomSeriesOptions,
+ customSeriesDefaultOptions,
+} from 'lightweight-charts';
+
+export interface _CLASSNAME_Options extends CustomSeriesOptions {
+ //* Define the options for the series.
+ highLineColor: string;
+ lowLineColor: string;
+ areaColor: string;
+ highLineWidth: number;
+ lowLineWidth: number;
+}
+
+export const defaultOptions: _CLASSNAME_Options = {
+ //* Define the default values for all the series options.
+ ...customSeriesDefaultOptions,
+ highLineColor: '#049981',
+ lowLineColor: '#F23645',
+ areaColor: 'rgba(41, 98, 255, 0.2)',
+ highLineWidth: 2,
+ lowLineWidth: 2,
+} as const;
diff --git a/packages/create-lwc-plugin/template-series/src/renderer.ts b/packages/create-lwc-plugin/template-series/src/renderer.ts
new file mode 100644
index 0000000000..27ea539f01
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/renderer.ts
@@ -0,0 +1,109 @@
+import {
+ BitmapCoordinatesRenderingScope,
+ CanvasRenderingTarget2D,
+} from 'fancy-canvas';
+import {
+ ICustomSeriesPaneRenderer,
+ PaneRendererCustomData,
+ PriceToCoordinateConverter,
+ Time,
+} from 'lightweight-charts';
+import { _CLASSNAME_Data } from './data';
+import { _CLASSNAME_Options } from './options';
+
+interface _CLASSNAME_Item {
+ x: number;
+ high: number;
+ low: number;
+}
+
+export class _CLASSNAME_Renderer
+ implements ICustomSeriesPaneRenderer
+{
+ _data: PaneRendererCustomData | null = null;
+ _options: _CLASSNAME_Options | null = null;
+
+ draw(
+ target: CanvasRenderingTarget2D,
+ priceConverter: PriceToCoordinateConverter
+ ): void {
+ target.useBitmapCoordinateSpace(scope =>
+ this._drawImpl(scope, priceConverter)
+ );
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: _CLASSNAME_Options
+ ): 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: _CLASSNAME_Item[] = 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,
+ };
+ });
+
+ const ctx = renderingScope.context;
+ ctx.save();
+ ctx.beginPath();
+ const lowLine = new Path2D();
+ const highLine = new Path2D();
+ const firstBar = bars[this._data.visibleRange.from];
+ lowLine.moveTo(firstBar.x, firstBar.low);
+ for (
+ let i = this._data.visibleRange.from + 1;
+ i < this._data.visibleRange.to;
+ i++
+ ) {
+ const bar = bars[i];
+ lowLine.lineTo(bar.x, bar.low);
+ }
+
+ const lastBar = bars[this._data.visibleRange.to - 1];
+ highLine.moveTo(lastBar.x, lastBar.high);
+ for (
+ let i = this._data.visibleRange.to - 2;
+ i >= this._data.visibleRange.from;
+ i--
+ ) {
+ const bar = bars[i];
+ highLine.lineTo(bar.x, bar.high);
+ }
+
+ const area = new Path2D(lowLine);
+ area.lineTo(lastBar.x, lastBar.high);
+ area.addPath(highLine);
+ area.lineTo(firstBar.x, firstBar.low);
+ area.closePath();
+ ctx.fillStyle = options.areaColor;
+ ctx.fill(area);
+
+ 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.restore();
+ }
+}
diff --git a/packages/create-lwc-plugin/template-series/src/sample-data.ts b/packages/create-lwc-plugin/template-series/src/sample-data.ts
new file mode 100644
index 0000000000..2e0ea7afd9
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/sample-data.ts
@@ -0,0 +1,50 @@
+import type { Time } from 'lightweight-charts';
+import { _CLASSNAME_Data } from './data';
+
+type LineData = {
+ time: Time;
+ value: 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;
+
+function generateLineData(numberOfPoints: number = 500): LineData[] {
+ randomFactor = 25 + Math.random() * 25;
+ const res = [];
+ const date = new Date(Date.UTC(2023, 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 generateSampleData(
+ numberOfPoints: number = 500,
+ averageWidth: number = 50
+): _CLASSNAME_Data[] {
+ return generateLineData(numberOfPoints).map(lineDataPoint => {
+ const high = lineDataPoint.value + Math.random() * averageWidth;
+ const low = lineDataPoint.value - Math.random() * averageWidth;
+ return {
+ time: lineDataPoint.time,
+ high,
+ low,
+ };
+ });
+}
diff --git a/packages/create-lwc-plugin/template-series/src/template-entry.ts b/packages/create-lwc-plugin/template-series/src/template-entry.ts
new file mode 100644
index 0000000000..dca4ea58cc
--- /dev/null
+++ b/packages/create-lwc-plugin/template-series/src/template-entry.ts
@@ -0,0 +1,49 @@
+import {
+ CustomSeriesPricePlotValues,
+ ICustomSeriesPaneView,
+ PaneRendererCustomData,
+ WhitespaceData,
+ Time,
+} from 'lightweight-charts';
+import { _CLASSNAME_Options, defaultOptions } from './options';
+import { _CLASSNAME_Renderer } from './renderer';
+import { _CLASSNAME_Data } from './data';
+
+export class _CLASSNAME_
+ implements ICustomSeriesPaneView
+{
+ _renderer: _CLASSNAME_Renderer;
+
+ constructor() {
+ this._renderer = new _CLASSNAME_Renderer();
+ }
+
+ priceValueBuilder(plotRow: TData): CustomSeriesPricePlotValues {
+ const midPoint = (plotRow.low + plotRow.high) / 2;
+ //* The values returned here are used for the autoscaling behaviour on the chart,
+ //* and the last value is also used as the price value for the crosshair and price label.
+ return [plotRow.low, plotRow.high, midPoint];
+ }
+
+ isWhitespace(data: TData | WhitespaceData): data is WhitespaceData {
+ //* Method for checking if a specific datapoint should be considered whitespace
+ //* Use this to filter out the data points which should be whitespace on the chart (and
+ //* not get provided to the renderer).
+ return (data as Partial).low === undefined || (data as Partial).high === undefined;
+ }
+
+ renderer(): _CLASSNAME_Renderer {
+ return this._renderer;
+ }
+
+ update(
+ data: PaneRendererCustomData,
+ options: _CLASSNAME_Options
+ ): void {
+ this._renderer.update(data, options);
+ }
+
+ defaultOptions() {
+ return defaultOptions;
+ }
+}
diff --git a/packages/create-lwc-plugin/tsconfig.json b/packages/create-lwc-plugin/tsconfig.json
new file mode 100644
index 0000000000..9fc23ebe67
--- /dev/null
+++ b/packages/create-lwc-plugin/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "include": ["build.config.ts", "src", "__tests__"],
+ "compilerOptions": {
+ "outDir": "dist",
+ "target": "ES2022",
+ "module": "ES2020",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "declaration": false,
+ "sourceMap": false,
+ "noUnusedLocals": true,
+ "esModuleInterop": true
+ }
+}
diff --git a/plugin-examples/README.md b/plugin-examples/README.md
index b7d530c76e..709cb4a302 100644
--- a/plugin-examples/README.md
+++ b/plugin-examples/README.md
@@ -81,7 +81,7 @@ project and import the JS module in your code.
## Creating your own Plugin
-[create-lwc-plugin](https://github.com/tradingview/create-lwc-plugin) is an npm
+[create-lwc-plugin](https://github.com/tradingview/lightweight-charts/tree/master/packages/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
From e21ac0ec13bd348e9a7e5bddc8189e015bd409db Mon Sep 17 00:00:00 2001
From: Mark Silverwood
Date: Wed, 6 Sep 2023 23:15:14 +0100
Subject: [PATCH 3/3] update create-lwc-plugin package.json for beta release
---
packages/create-lwc-plugin/package.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/create-lwc-plugin/package.json b/packages/create-lwc-plugin/package.json
index c3f5c70166..08cdaf9fcf 100644
--- a/packages/create-lwc-plugin/package.json
+++ b/packages/create-lwc-plugin/package.json
@@ -1,9 +1,9 @@
{
"name": "create-lwc-plugin",
- "version": "1.0.0",
+ "version": "0.1.0",
"type": "module",
"license": "MIT",
- "author": "TradingView",
+ "author": "TradingView, Inc.",
"description": "Wizard-like CLI tool for scaffolding a new plugin for Lightweight Charts™",
"keywords": ["lightweight-charts", "lwc-plugin", "plugins"],
"bin": {