├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── enhancement.yaml │ └── question.yaml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── build.sh ├── cover.png ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── _ext │ └── styles.py │ ├── _static │ ├── fonts │ │ ├── MaisonNeue-Demi.otf │ │ ├── MaisonNeue-Medium.otf │ │ └── MaisonNeue-MediumItalic.otf │ ├── funcs.js │ ├── ohlcv.json │ ├── pkg.js │ ├── splash.css │ └── toolbox.js │ ├── _templates │ └── landing.html │ ├── conf.py │ ├── examples │ ├── events.md │ ├── gui_examples.md │ ├── screenshot.md │ ├── subchart.md │ ├── table.md │ ├── toolbox.md │ └── yfinance.md │ ├── index.md │ ├── polygon.md │ ├── polygonchart.png │ ├── reference │ ├── abstract_chart.md │ ├── charts.md │ ├── events.md │ ├── histogram.md │ ├── horizontal_line.md │ ├── index.md │ ├── line.md │ ├── tables.md │ ├── toolbox.md │ ├── topbar.md │ └── typing.md │ └── tutorials │ ├── events.md │ └── getting_started.md ├── examples ├── 1_setting_data │ ├── ohlcv.csv │ ├── setting_data.png │ └── setting_data.py ├── 2_live_data │ ├── live_data.gif │ ├── live_data.py │ ├── next_ohlcv.csv │ └── ohlcv.csv ├── 3_tick_data │ ├── ohlc.csv │ ├── tick_data.gif │ ├── tick_data.py │ └── ticks.csv ├── 4_line_indicators │ ├── line_indicators.png │ ├── line_indicators.py │ └── ohlcv.csv ├── 5_styling │ ├── ohlcv.csv │ ├── styling.png │ └── styling.py └── 6_callbacks │ ├── bar_data │ ├── AAPL_1min.csv │ ├── AAPL_30min.csv │ ├── AAPL_5min.csv │ ├── GOOGL_1min.csv │ ├── GOOGL_30min.csv │ ├── GOOGL_5min.csv │ ├── TSLA_1min.csv │ ├── TSLA_30min.csv │ └── TSLA_5min.csv │ ├── callbacks.gif │ └── callbacks.py ├── index.html ├── lightweight_charts ├── __init__.py ├── abstract.py ├── chart.py ├── drawings.py ├── js │ ├── bundle.js │ ├── index.html │ ├── lightweight-charts.js │ └── styles.css ├── polygon.py ├── table.py ├── toolbox.py ├── topbar.py ├── util.py └── widgets.py ├── package-lock.json ├── package.json ├── rollup.config.js ├── setup.py ├── src ├── box │ ├── box.ts │ ├── pane-renderer.ts │ └── pane-view.ts ├── context-menu │ ├── color-picker.ts │ ├── context-menu.ts │ └── style-picker.ts ├── drawing │ ├── data-source.ts │ ├── drawing-tool.ts │ ├── drawing.ts │ ├── options.ts │ ├── pane-renderer.ts │ ├── pane-view.ts │ └── two-point-drawing.ts ├── example │ ├── example.ts │ └── index.html ├── general │ ├── global-params.ts │ ├── handler.ts │ ├── index.ts │ ├── legend.ts │ ├── menu.ts │ ├── styles.css │ ├── table.ts │ ├── toolbox.ts │ └── topbar.ts ├── helpers │ ├── assertions.ts │ ├── canvas-rendering.ts │ ├── dimensions │ │ ├── common.ts │ │ ├── crosshair-width.ts │ │ ├── full-width.ts │ │ └── positions.ts │ └── time.ts ├── horizontal-line │ ├── axis-view.ts │ ├── horizontal-line.ts │ ├── pane-renderer.ts │ ├── pane-view.ts │ └── ray-line.ts ├── index.ts ├── plugin-base.ts ├── sample-data.ts ├── trend-line │ ├── pane-renderer.ts │ ├── pane-view.ts │ └── trend-line.ts ├── vertical-line │ ├── axis-view.ts │ ├── pane-renderer.ts │ ├── pane-view.ts │ └── vertical-line.ts ├── vite-env.d.ts └── vite.config.js ├── test ├── drawings.json ├── run_tests.py ├── test_chart.py ├── test_returns.py ├── test_table.py ├── test_toolbox.py ├── test_topbar.py └── util.py └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: louisnw01 2 | custom: https://www.buymeacoffee.com/7wzcr2p9vxM/ 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug/problem with the library 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | 6 | body: 7 | - type: textarea 8 | id: expected-behavior 9 | attributes: 10 | label: Expected Behavior 11 | description: > 12 | Please describe or show a code example of the expected behavior. 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | id: current-behavior 18 | attributes: 19 | label: Current Behaviour 20 | description: > 21 | Please provide a description of the current behaviour. If this is a bug which produces no errors in Python, but causes unexpected output within the webview, please provide a screenshot of the web console using `Chart(debug=True)`. 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | id: example 27 | attributes: 28 | label: Reproducible Example 29 | description: > 30 | Please provide a minimal reproducible example, using generic (`ohlcv.csv`) data where applicable which demonstrates the current behaviour. The code should be complete and not require any further adjustments. DO NOT provide: Incomplete code, unnecessarily long code, code which requires access to a broker/API, or code which uses additional functions/classes/libraries. In almost ALL cases the example should not exceed 50 lines. 31 | placeholder: > 32 | from lightweight_charts import Chart... 33 | render: python 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | attributes: 39 | label: Environment 40 | description: | 41 | examples: 42 | - **OS**: macOS 13.2 43 | - **Library**: 1.0.16.2 44 | value: | 45 | - OS: 46 | - Library: 47 | render: markdown 48 | validations: 49 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yaml: -------------------------------------------------------------------------------- 1 | name: New Feature 2 | description: Request a new feature for the library 3 | labels: ["enhancement"] 4 | 5 | body: 6 | - type: textarea 7 | id: feature-description 8 | attributes: 9 | label: Description 10 | description: > 11 | Please provide a detailed description of the requested feature. What would it do? What problem would it solve? 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: code-behaviour 17 | attributes: 18 | label: Code example 19 | description: > 20 | Please provide a code example of how the new feature should behave (where applicable). 21 | placeholder: > 22 | chart.rotate(degrees=180) # flip the chart upside down 23 | render: python 24 | validations: 25 | required: false 26 | 27 | - type: textarea 28 | id: method-of-implementation 29 | attributes: 30 | label: Method of implementation 31 | description: > 32 | Please provide a description of the logic of this feature/how this feature could be implemented. python/psudocode is welcome!. 33 | placeholder: > 34 | Use the `transform` CSS style to the chart, given the number of degrees provided to `rotate`. 35 | validations: 36 | required: false 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yaml: -------------------------------------------------------------------------------- 1 | name: General Question 2 | description: Ask a question/get help with the library 3 | 4 | body: 5 | - type: textarea 6 | id: question 7 | attributes: 8 | label: Question 9 | description: > 10 | Please check previous issues before submitting. Please use screenshots if they help! 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: code-behaviour 16 | attributes: 17 | label: Code example 18 | description: > 19 | Please provide any code relevant to your question. 20 | placeholder: > 21 | from lightweight_charts import Chart... 22 | render: python 23 | validations: 24 | required: false 25 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/build/** 2 | venv/ 3 | .ipynb_checkpoints/ 4 | __pycache__/ 5 | build/ 6 | dist/ 7 | *.egg-info/ 8 | 9 | node_modules/ 10 | 11 | .DS_Store 12 | *.sublime-* 13 | .idea/ 14 | pyrightconfig.json 15 | 16 | working/ 17 | .zed/ 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/source/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 louisnw01 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | GREEN='\033[0;32m' 4 | RED='\033[0;31m' 5 | YELLOW='\033[0;33m' 6 | CYAN='\033[0;36m' 7 | NC='\033[0m' 8 | 9 | ERROR="${RED}[ERROR]${NC} " 10 | INFO="${CYAN}[INFO]${NC} " 11 | WARNING="${WARNING}[WARNING]${NC} " 12 | 13 | rm -rf dist/bundle.js dist/typings/ 14 | 15 | if [[ $? -eq 0 ]]; then 16 | echo -e "${INFO}deleted bundle.js and typings.." 17 | else 18 | echo -e "${WARNING}could not delete old dist files, continuing.." 19 | fi 20 | 21 | npx rollup -c rollup.config.js 22 | if [[ $? -ne 0 ]]; then 23 | exit 1 24 | fi 25 | 26 | cp dist/bundle.js src/general/styles.css lightweight_charts/js 27 | if [[ $? -eq 0 ]]; then 28 | echo -e "${INFO}copied bundle.js, style.css into python package" 29 | else 30 | echo -e "${ERROR}could not copy dist into python package ?" 31 | exit 1 32 | fi 33 | echo -e "\n${GREEN}[BUILD SUCCESS]${NC}" 34 | 35 | -------------------------------------------------------------------------------- /cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/cover.png -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | myst-parser 3 | furo 4 | sphinx_tippy 5 | sphinx-copybutton -------------------------------------------------------------------------------- /docs/source/_ext/styles.py: -------------------------------------------------------------------------------- 1 | import pygments.styles 2 | 3 | 4 | bulb = pygments.styles.get_style_by_name('lightbulb') 5 | sas = pygments.styles.get_style_by_name('sas') 6 | 7 | 8 | class DarkStyle(bulb): 9 | background_color = '#1e2124ff' 10 | 11 | 12 | class LightStyle(sas): 13 | background_color = '#efeff4ff' 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/source/_static/fonts/MaisonNeue-Demi.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/docs/source/_static/fonts/MaisonNeue-Demi.otf -------------------------------------------------------------------------------- /docs/source/_static/fonts/MaisonNeue-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/docs/source/_static/fonts/MaisonNeue-Medium.otf -------------------------------------------------------------------------------- /docs/source/_static/fonts/MaisonNeue-MediumItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/docs/source/_static/fonts/MaisonNeue-MediumItalic.otf -------------------------------------------------------------------------------- /docs/source/_static/splash.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Maison Neue'; 3 | src: url('fonts/MaisonNeue-Demi.otf') format('opentype'); 4 | 5 | } 6 | @font-face { 7 | font-family: 'Maison Neue Italic'; 8 | src: url('fonts/MaisonNeue-MediumItalic.otf') format('opentype'); 9 | } 10 | 11 | 12 | 13 | .splash-head { 14 | position: relative; 15 | display: flex; 16 | flex-direction: column; 17 | text-align: center; 18 | align-self: center; 19 | margin-bottom: 20px; 20 | } 21 | 22 | .splash-page-container { 23 | display: flex; 24 | flex-direction: column; 25 | } 26 | 27 | .theme-button { 28 | position: absolute; 29 | top: 70px; 30 | right: -50px; 31 | } 32 | 33 | .top-nav { 34 | margin: 0; 35 | padding: 0; 36 | align-self: center; 37 | } 38 | 39 | .top-nav ul { 40 | list-style-type: none; 41 | padding: 0; 42 | display: flex; 43 | justify-content: space-evenly; 44 | background-color: var(--color-background-hover); 45 | border-radius: 5px; 46 | margin: 2rem 0 0; 47 | } 48 | 49 | .top-nav li { 50 | display: inline; 51 | } 52 | 53 | .top-nav li a { 54 | text-decoration: none; 55 | border-radius: 3px; 56 | color: var(--color-foreground-primary); 57 | padding: 0.3rem 0.6rem; 58 | display: flex; 59 | align-items: center; 60 | font-weight: 500; 61 | 62 | } 63 | 64 | .top-nav li a:hover { 65 | background-color: var(--color-background-item) 66 | } 67 | 68 | 69 | 70 | #wrapper { 71 | width: 500px; 72 | 73 | height: 350px; 74 | display: flex; 75 | overflow: hidden; 76 | border-radius: 10px; 77 | border: 2px solid var(--color-foreground-muted); 78 | box-sizing: border-box; 79 | 80 | position: relative; 81 | z-index: 1000; 82 | } 83 | 84 | #main-content { 85 | margin: 30px 0; 86 | display: flex; 87 | justify-content: center; 88 | align-items: center; 89 | flex-wrap: wrap; 90 | } 91 | 92 | #curved-arrow { 93 | transition: transform 0.1s; 94 | transform: rotate(-42deg); 95 | padding: 0 3vw; 96 | width: 100px; 97 | height: 100px; 98 | } 99 | 100 | 101 | 102 | 103 | 104 | @media (max-width: 968px) { 105 | #curved-arrow { 106 | transform: rotate(-70deg) scalex(-1); 107 | } 108 | } 109 | 110 | @media (max-width: 550px) { 111 | .splash-head h1 { 112 | font-size: 30px; 113 | } 114 | #main-content { 115 | flex-direction: column; 116 | } 117 | #curved-arrow { 118 | padding: 4vw 0; 119 | width: 60px; 120 | height: 60px 121 | } 122 | } 123 | @media (max-width: 450px) { 124 | .splash-head h1 { 125 | font-size: 22px; 126 | } 127 | .splash-head i { 128 | font-size: 13px; 129 | } 130 | .top-nav a { 131 | font-size: 12px; 132 | } 133 | .theme-button { 134 | right: -20px; 135 | } 136 | #wrapper { 137 | width: 300px; 138 | height: 250px; 139 | } 140 | } -------------------------------------------------------------------------------- /docs/source/examples/events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | ## Hotkey Example 4 | 5 | ```python 6 | from lightweight_charts import Chart 7 | 8 | def place_buy_order(key): 9 | print(f'Buy {key} shares.') 10 | 11 | 12 | def place_sell_order(key): 13 | print(f'Sell all shares, because I pressed {key}.') 14 | 15 | 16 | if __name__ == '__main__': 17 | chart = Chart() 18 | chart.hotkey('shift', (1, 2, 3), place_buy_order) 19 | chart.hotkey('shift', 'X', place_sell_order) 20 | chart.show(block=True) 21 | ``` 22 | ___ 23 | 24 | 25 | ## Topbar Example 26 | 27 | ```python 28 | import pandas as pd 29 | from lightweight_charts import Chart 30 | 31 | 32 | def get_bar_data(symbol, timeframe): 33 | if symbol not in ('AAPL', 'GOOGL', 'TSLA'): 34 | print(f'No data for "{symbol}"') 35 | return pd.DataFrame() 36 | return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') 37 | 38 | 39 | def on_search(chart, searched_string): 40 | new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) 41 | if new_data.empty: 42 | return 43 | chart.topbar['symbol'].set(searched_string) 44 | chart.set(new_data) 45 | 46 | 47 | def on_timeframe_selection(chart): 48 | new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) 49 | if new_data.empty: 50 | return 51 | chart.set(new_data, True) 52 | 53 | 54 | def on_horizontal_line_move(chart, line): 55 | print(f'Horizontal line moved to: {line.price}') 56 | 57 | 58 | if __name__ == '__main__': 59 | chart = Chart(toolbox=True) 60 | chart.legend(True) 61 | 62 | chart.events.search += on_search 63 | 64 | chart.topbar.textbox('symbol', 'TSLA') 65 | chart.topbar.switcher('timeframe', ('1min', '5min', '30min'), default='5min', 66 | func=on_timeframe_selection) 67 | 68 | df = get_bar_data('TSLA', '5min') 69 | chart.set(df) 70 | 71 | chart.horizontal_line(200, func=on_horizontal_line_move) 72 | 73 | chart.show(block=True) 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/source/examples/gui_examples.md: -------------------------------------------------------------------------------- 1 | # Alternative GUI's 2 | 3 | 4 | ## PyQt6 / PyQt5 / PySide6 5 | 6 | ```python 7 | import pandas as pd 8 | from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget 9 | 10 | from lightweight_charts.widgets import QtChart 11 | 12 | app = QApplication([]) 13 | window = QMainWindow() 14 | layout = QVBoxLayout() 15 | widget = QWidget() 16 | widget.setLayout(layout) 17 | 18 | window.resize(800, 500) 19 | layout.setContentsMargins(0, 0, 0, 0) 20 | 21 | chart = QtChart(widget) 22 | 23 | df = pd.read_csv('ohlcv.csv') 24 | chart.set(df) 25 | 26 | layout.addWidget(chart.get_webview()) 27 | 28 | window.setCentralWidget(widget) 29 | window.show() 30 | 31 | app.exec_() 32 | ``` 33 | ___ 34 | 35 | 36 | 37 | ## WxPython 38 | 39 | ```python 40 | import wx 41 | import pandas as pd 42 | 43 | from lightweight_charts.widgets import WxChart 44 | 45 | 46 | class MyFrame(wx.Frame): 47 | def __init__(self): 48 | super().__init__(None) 49 | self.SetSize(1000, 500) 50 | 51 | panel = wx.Panel(self) 52 | sizer = wx.BoxSizer(wx.VERTICAL) 53 | panel.SetSizer(sizer) 54 | 55 | chart = WxChart(panel) 56 | 57 | df = pd.read_csv('ohlcv.csv') 58 | chart.set(df) 59 | 60 | sizer.Add(chart.get_webview(), 1, wx.EXPAND | wx.ALL) 61 | sizer.Layout() 62 | self.Show() 63 | 64 | 65 | if __name__ == '__main__': 66 | app = wx.App() 67 | frame = MyFrame() 68 | app.MainLoop() 69 | ``` 70 | ___ 71 | ## Jupyter 72 | 73 | ```python 74 | import pandas as pd 75 | from lightweight_charts import JupyterChart 76 | 77 | chart = JupyterChart() 78 | 79 | df = pd.read_csv('ohlcv.csv') 80 | chart.set(df) 81 | 82 | chart.load() 83 | ``` 84 | ___ 85 | 86 | ## Streamlit 87 | 88 | ```python 89 | import pandas as pd 90 | from lightweight_charts.widgets import StreamlitChart 91 | 92 | chart = StreamlitChart(width=900, height=600) 93 | 94 | df = pd.read_csv('ohlcv.csv') 95 | chart.set(df) 96 | 97 | chart.load() 98 | ``` -------------------------------------------------------------------------------- /docs/source/examples/screenshot.md: -------------------------------------------------------------------------------- 1 | # Screenshot & Save 2 | 3 | 4 | ```python 5 | import pandas as pd 6 | from lightweight_charts import Chart 7 | 8 | 9 | if __name__ == '__main__': 10 | chart = Chart() 11 | df = pd.read_csv('ohlcv.csv') 12 | chart.set(df) 13 | chart.show() 14 | 15 | img = chart.screenshot() 16 | with open('screenshot.png', 'wb') as f: 17 | f.write(img) 18 | ``` 19 | 20 | ```{important} 21 | The `screenshot` command can only be executed after the chart window is open. Therefore, either `block` must equal `False`, the screenshot should be triggered with a callback, or `async_show` should be used. 22 | ``` 23 | 24 | ```{important} 25 | This example can only be used with the standard `Chart` object. 26 | ``` 27 | 28 | -------------------------------------------------------------------------------- /docs/source/examples/subchart.md: -------------------------------------------------------------------------------- 1 | # Subcharts 2 | 3 | ## Grid of 4 4 | 5 | ```python 6 | import pandas as pd 7 | from lightweight_charts import Chart 8 | 9 | if __name__ == '__main__': 10 | chart = Chart(inner_width=0.5, inner_height=0.5) 11 | chart2 = chart.create_subchart(position='right', width=0.5, height=0.5) 12 | chart3 = chart.create_subchart(position='left', width=0.5, height=0.5) 13 | chart4 = chart.create_subchart(position='right', width=0.5, height=0.5) 14 | 15 | chart.watermark('1') 16 | chart2.watermark('2') 17 | chart3.watermark('3') 18 | chart4.watermark('4') 19 | 20 | df = pd.read_csv('ohlcv.csv') 21 | chart.set(df) 22 | chart2.set(df) 23 | chart3.set(df) 24 | chart4.set(df) 25 | 26 | chart.show(block=True) 27 | 28 | ``` 29 | ___ 30 | 31 | ## Synced Line Chart 32 | 33 | ```python 34 | import pandas as pd 35 | from lightweight_charts import Chart 36 | 37 | if __name__ == '__main__': 38 | chart = Chart(inner_width=1, inner_height=0.8) 39 | chart.time_scale(visible=False) 40 | 41 | chart2 = chart.create_subchart(width=1, height=0.2, sync=True) 42 | line = chart2.create_line() 43 | 44 | df = pd.read_csv('ohlcv.csv') 45 | df2 = pd.read_csv('rsi.csv') 46 | 47 | chart.set(df) 48 | line.set(df2) 49 | 50 | chart.show(block=True) 51 | ``` 52 | ___ 53 | 54 | ## Grid of 4 with maximize buttons 55 | 56 | ```python 57 | import pandas as pd 58 | from lightweight_charts import Chart 59 | 60 | # ascii symbols 61 | FULLSCREEN = '■' 62 | CLOSE = '×' 63 | 64 | 65 | def on_max(target_chart): 66 | button = target_chart.topbar['max'] 67 | if button.value == CLOSE: 68 | [c.resize(0.5, 0.5) for c in charts] 69 | button.set(FULLSCREEN) 70 | else: 71 | for chart in charts: 72 | width, height = (1, 1) if chart == target_chart else (0, 0) 73 | chart.resize(width, height) 74 | button.set(CLOSE) 75 | 76 | 77 | if __name__ == '__main__': 78 | main_chart = Chart(inner_width=0.5, inner_height=0.5) 79 | charts = [ 80 | main_chart, 81 | main_chart.create_subchart(position='top', width=0.5, height=0.5), 82 | main_chart.create_subchart(position='left', width=0.5, height=0.5), 83 | main_chart.create_subchart(position='right', width=0.5, height=0.5), 84 | ] 85 | 86 | df = pd.read_csv('examples/1_setting_data/ohlcv.csv') 87 | for i, c in enumerate(charts): 88 | chart_number = str(i+1) 89 | c.watermark(chart_number) 90 | c.topbar.textbox('number', chart_number) 91 | c.topbar.button('max', FULLSCREEN, False, align='right', func=on_max) 92 | c.set(df) 93 | 94 | charts[0].show(block=True) 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/source/examples/table.md: -------------------------------------------------------------------------------- 1 | # Table 2 | 3 | ```python 4 | import pandas as pd 5 | from lightweight_charts import Chart 6 | 7 | def on_row_click(row): 8 | row['PL'] = round(row['PL']+1, 2) 9 | row.background_color('PL', 'green' if row['PL'] > 0 else 'red') 10 | 11 | table.footer[1] = row['Ticker'] 12 | 13 | if __name__ == '__main__': 14 | chart = Chart(width=1000, inner_width=0.7, inner_height=1) 15 | subchart = chart.create_subchart(width=0.3, height=0.5) 16 | df = pd.read_csv('ohlcv.csv') 17 | chart.set(df) 18 | subchart.set(df) 19 | 20 | table = chart.create_table(width=0.3, height=0.2, 21 | headings=('Ticker', 'Quantity', 'Status', '%', 'PL'), 22 | widths=(0.2, 0.1, 0.2, 0.2, 0.3), 23 | alignments=('center', 'center', 'right', 'right', 'right'), 24 | position='left', func=on_row_click) 25 | 26 | table.format('PL', f'£ {table.VALUE}') 27 | table.format('%', f'{table.VALUE} %') 28 | 29 | table.new_row('SPY', 3, 'Submitted', 0, 0) 30 | table.new_row('AMD', 1, 'Filled', 25.5, 105.24) 31 | table.new_row('NVDA', 2, 'Filled', -0.5, -8.24) 32 | 33 | table.footer(2) 34 | table.footer[0] = 'Selected:' 35 | 36 | chart.show(block=True) 37 | 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/source/examples/toolbox.md: -------------------------------------------------------------------------------- 1 | # Toolbox with persistent drawings 2 | 3 | To get started, create a file called `drawings.json`, which should contain: 4 | ``` 5 | {} 6 | ``` 7 | ___ 8 | 9 | 10 | 11 | ```python 12 | import pandas as pd 13 | from lightweight_charts import Chart 14 | 15 | 16 | def get_bar_data(symbol, timeframe): 17 | if symbol not in ('AAPL', 'GOOGL', 'TSLA'): 18 | print(f'No data for "{symbol}"') 19 | return pd.DataFrame() 20 | return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') 21 | 22 | 23 | def on_search(chart, searched_string): 24 | new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) 25 | if new_data.empty: 26 | return 27 | chart.topbar['symbol'].set(searched_string) 28 | chart.set(new_data) 29 | 30 | # Load the drawings saved under the symbol. 31 | chart.toolbox.load_drawings(searched_string) 32 | 33 | 34 | def on_timeframe_selection(chart): 35 | new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) 36 | if new_data.empty: 37 | return 38 | # The symbol has not changed, so we want to re-render the drawings. 39 | chart.set(new_data, keep_drawings=True) 40 | 41 | 42 | if __name__ == '__main__': 43 | chart = Chart(toolbox=True) 44 | chart.legend(True) 45 | 46 | chart.events.search += on_search 47 | chart.topbar.textbox('symbol', 'TSLA') 48 | chart.topbar.switcher( 49 | 'timeframe', 50 | ('1min', '5min', '30min'), 51 | default='5min', 52 | func=on_timeframe_selection 53 | ) 54 | 55 | df = get_bar_data('TSLA', '5min') 56 | 57 | chart.set(df) 58 | 59 | # Imports the drawings saved in the JSON file. 60 | chart.toolbox.import_drawings('drawings.json') 61 | 62 | # Loads the drawings under the default symbol. 63 | chart.toolbox.load_drawings(chart.topbar['symbol'].value) 64 | 65 | # Saves drawings based on the symbol. 66 | chart.toolbox.save_drawings_under(chart.topbar['symbol']) 67 | 68 | chart.show(block=True) 69 | 70 | # Exports the drawings to the JSON file upon close. 71 | chart.toolbox.export_drawings('drawings.json') 72 | 73 | ``` -------------------------------------------------------------------------------- /docs/source/examples/yfinance.md: -------------------------------------------------------------------------------- 1 | # YFinance 2 | 3 | ```python 4 | import datetime as dt 5 | import yfinance as yf 6 | from lightweight_charts import Chart 7 | 8 | 9 | def get_bar_data(symbol, timeframe): 10 | if timeframe in ('1m', '5m', '30m'): 11 | days = 7 if timeframe == '1m' else 60 12 | start_date = dt.datetime.now()-dt.timedelta(days=days) 13 | else: 14 | start_date = None 15 | 16 | chart.spinner(True) 17 | data = yf.download(symbol, start_date, interval=timeframe) 18 | chart.spinner(False) 19 | 20 | if data.empty: 21 | return False 22 | chart.set(data) 23 | return True 24 | 25 | 26 | def on_search(chart, searched_string): 27 | if get_bar_data(searched_string, chart.topbar['timeframe'].value): 28 | chart.topbar['symbol'].set(searched_string) 29 | 30 | 31 | def on_timeframe_selection(chart): 32 | get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) 33 | 34 | 35 | if __name__ == '__main__': 36 | chart = Chart(toolbox=True, debug=True) 37 | chart.legend(True) 38 | chart.events.search += on_search 39 | chart.topbar.textbox('symbol', 'n/a') 40 | chart.topbar.switcher( 41 | 'timeframe', 42 | ('1m', '5m', '30m', '1d', '1wk'), 43 | default='5m', 44 | func=on_timeframe_selection 45 | ) 46 | 47 | chart.show(block=True) 48 | ``` -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | ```{toctree} 2 | :hidden: 3 | :caption: TUTORIALS 4 | tutorials/getting_started 5 | tutorials/events 6 | ``` 7 | 8 | 9 | 10 | 11 | 12 | ```{toctree} 13 | :hidden: 14 | :caption: EXAMPLES 15 | examples/table 16 | examples/toolbox 17 | examples/subchart 18 | examples/yfinance 19 | examples/events 20 | examples/screenshot 21 | examples/gui_examples 22 | ``` 23 | 24 | 25 | ```{toctree} 26 | :hidden: 27 | :caption: DOCS 28 | reference/index 29 | polygon 30 | 31 | Github Repository 32 | ``` 33 | 34 | 35 | ```{include} ../../README.md 36 | ``` -------------------------------------------------------------------------------- /docs/source/polygon.md: -------------------------------------------------------------------------------- 1 | # Polygon.io 2 | 3 | [Polygon.io's](https://polygon.io/?utm_source=affiliate&utm_campaign=pythonlwcharts) market data API is directly integrated within lightweight-charts-python, and is easy to use within the library. 4 | 5 | ___ 6 | 7 | ````{py:class} PolygonAPI 8 | This class should be accessed from the `polygon` attribute, which is contained within all chart types. 9 | 10 | `chart.polygon.` 11 | 12 | The `stock`, `option`, `index`, `forex`, and `crypto` methods of `chart.polygon` have common parameters: 13 | 14 | * `timeframe`: The timeframe to be used (`'1min'`, `'5min'`, `'H'`, `'2D'`, `'5W'` etc.) 15 | * `start_date`: The start date given in the format `YYYY-MM-DD`. 16 | * `end_date`: The end date given in the same format. By default this is `'now'`, which uses the time now. 17 | * `limit`: The maximum number of base aggregates to be queried to create the aggregate results. 18 | * `live`: When set to `True`, a websocket connection will be used to update the chart or subchart in real-time. 19 | * These methods will also return a boolean representing whether the request was successful. 20 | 21 | The `websockets` library is required when using live data. 22 | 23 | ```{important} 24 | When using live data and the standard `show` method, the `block` parameter __must__ be set to `True` in order for the data to congregate on the chart (`chart.show(block=True)`). 25 | `show_async` can also be used with live data. 26 | 27 | ``` 28 | 29 | For Example: 30 | 31 | ```python 32 | from lightweight_charts import Chart 33 | 34 | if __name__ == '__main__': 35 | chart = Chart() 36 | chart.polygon.api_key('') 37 | chart.polygon.stock( 38 | symbol='AAPL', 39 | timeframe='5min', 40 | start_date='2023-06-09' 41 | ) 42 | chart.show(block=True) 43 | ``` 44 | 45 | ___ 46 | 47 | 48 | ```{py:method} api_key(key: str) 49 | Sets the API key for the chart. Subsequent `SubChart` objects will inherit the API key given to the parent chart. 50 | 51 | ``` 52 | ___ 53 | 54 | 55 | 56 | ```{py:method} stock(symbol: str, timeframe: str, start_date: str, end_date: str, limit: int, live: bool) -> bool 57 | 58 | Requests and displays stock data pulled from Polygon.io. 59 | 60 | `async_stock` can also be used. 61 | ``` 62 | ___ 63 | 64 | 65 | 66 | ```{py:method} option(symbol: str, timeframe: str, expiration: str, right: 'C' | 'P', strike: NUM, end_date: str, limit: int, live: bool) -> bool 67 | 68 | Requests and displays option data pulled from Polygon.io. 69 | 70 | A formatted option ticker (SPY251219C00650000) can also be given to the `symbol` parameter, allowing for `expiration`, `right`, and `strike` to be left blank. 71 | 72 | `async_option` can also be used. 73 | 74 | ``` 75 | ___ 76 | 77 | 78 | 79 | ```{py:method} index(symbol: str, timeframe: str, start_date: str, end_date: str, limit: int, live: bool) -> bool 80 | 81 | Requests and displays index data pulled from Polygon.io. 82 | 83 | `async_index` can also be used. 84 | ``` 85 | ___ 86 | 87 | 88 | 89 | ```{py:method} forex(fiat_pair: str, timeframe: str, start_date: str, end_date: str, limit: int, live: bool) -> bool 90 | 91 | Requests and displays a forex pair pulled from Polygon.io. 92 | 93 | The two currencies should be separated by a '-' (`USD-CAD`, `GBP-JPY`, etc.). 94 | 95 | `async_forex` can also be used. 96 | ``` 97 | ___ 98 | 99 | 100 | 101 | ```{py:method} crypto(crypto_pair: str, timeframe: str, start_date: str, end_date: str, limit: int, live: bool) -> bool 102 | 103 | Requests and displays a crypto pair pulled from Polygon.io. 104 | 105 | The two currencies should be separated by a '-' (`BTC-USD`, `ETH-BTC`, etc.). 106 | 107 | `async_crypto` can also be used. 108 | ``` 109 | ___ 110 | 111 | 112 | 113 | ```{py:method} log(info: bool) 114 | 115 | If `True`, informational log messages (connection, subscriptions etc.) will be displayed in the console. 116 | 117 | Data errors will always be shown in the console. 118 | ``` 119 | 120 | ```` 121 | 122 | ___ 123 | 124 | ````{py:class} PolygonChart(api_key: str, num_bars: int, limit: int, end_date: str, timeframe_options: tuple, security_options: tuple, live: bool) 125 | The `PolygonChart` provides an easy and complete way to use the Polygon.io API within lightweight-charts-python. 126 | 127 | This object requires the `websockets` library for live data. 128 | 129 | All data is requested within the chart window through searching and selectors. 130 | 131 | As well as the parameters from the [Chart](https://lightweight-charts-python.readthedocs.io/en/latest/charts.html#chart) object, PolygonChart also has the parameters: 132 | 133 | * `api_key`: The user's Polygon.io API key. 134 | * `num_bars`: The target number of bars to be displayed on the chart 135 | * `limit`: The maximum number of base aggregates to be queried to create the aggregate results. 136 | * `end_date`: The end date of the time window. 137 | * `timeframe_options`: The selectors to be included within the timeframe selector. 138 | * `security_options`: The selectors to be included within the security selector. 139 | * `live`: If True, the chart will update in real-time. 140 | ___ 141 | 142 | For Example: 143 | 144 | ```python 145 | from lightweight_charts import PolygonChart 146 | 147 | if __name__ == '__main__': 148 | chart = PolygonChart( 149 | api_key='', 150 | num_bars=200, 151 | limit=5000, 152 | live=True 153 | ) 154 | chart.show(block=True) 155 | ``` 156 | 157 | ![PolygonChart png](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/docs/source/polygonchart.png) 158 | 159 | ```` 160 | 161 | ```{py:function} polygon.get_bar_data(ticker: str, timeframe: str, start_date: str, end_date: str, limit: int = 5_000) -> pd.DataFrame 162 | Module function which returns a formatted Dataframe of the requested aggregate data. 163 | 164 | `ticker` should be prefixed for the appropriate security type (eg. `I:NDX`) 165 | 166 | ``` 167 | 168 | ```{py:function} polygon.subscribe(ticker: str, sec_type: SEC_TYPE, func: callable, args: tuple, precision=2) 169 | :async: 170 | 171 | Subscribes the given callable to live data from polygon. emitting a dictionary (and any given arguments) to the function. 172 | 173 | ``` 174 | 175 | ```{py:function} polygon.unsubscribe(func: callable) 176 | :async: 177 | 178 | Unsubscribes the given function from live data. 179 | 180 | The ticker will only be unsubscribed if there are no additional functions that are currently subscribed. 181 | 182 | ``` 183 | 184 | -------------------------------------------------------------------------------- /docs/source/polygonchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/docs/source/polygonchart.png -------------------------------------------------------------------------------- /docs/source/reference/charts.md: -------------------------------------------------------------------------------- 1 | # Charts 2 | 3 | This page contains a reference to all chart objects that can be used within the library. 4 | 5 | They inherit from [AbstractChart](#AbstractChart). 6 | 7 | ___ 8 | 9 | `````{py:class} Chart(width: int, height: int, x: int, y: int, title: str, screen: int, on_top: bool, maximize: bool, debug: bool, toolbox: bool, inner_width: float, inner_height: float, scale_candles_only: bool) 10 | 11 | The main object used for the normal functionality of lightweight-charts-python, built on the pywebview library. 12 | 13 | The `screen` parameter defines which monitor the chart window will open on, given as an index (primary monitor = 0). 14 | 15 | ```{important} 16 | The `Chart` object should be defined within an `if __name__ == '__main__'` block. 17 | ``` 18 | ___ 19 | 20 | 21 | 22 | ```{py:method} show(block: bool) 23 | 24 | Shows the chart window, blocking until the chart has loaded. If `block` is enabled, the method will block code execution until the window is closed. 25 | 26 | ``` 27 | ___ 28 | 29 | 30 | 31 | ```{py:method} hide() 32 | 33 | Hides the chart window, which can be later shown by calling `chart.show()`. 34 | ``` 35 | ___ 36 | 37 | 38 | 39 | ```{py:method} exit() 40 | 41 | Exits and destroys the chart window. 42 | 43 | ``` 44 | ___ 45 | 46 | 47 | 48 | ```{py:method} show_async() 49 | :async: 50 | 51 | Show the chart asynchronously. 52 | 53 | ``` 54 | ___ 55 | 56 | 57 | 58 | ````{py:method} screenshot(block: bool) -> bytes 59 | 60 | Takes a screenshot of the chart, and returns a bytes object containing the image. For example: 61 | 62 | ```python 63 | if __name__ == '__main__': 64 | chart = Chart() 65 | df = pd.read_csv('ohlcv.csv') 66 | chart.set(df) 67 | chart.show() 68 | 69 | img = chart.screenshot() 70 | with open('screenshot.png', 'wb') as f: 71 | f.write(img) 72 | ``` 73 | 74 | ```{important} 75 | This method should be called after the chart window has loaded. 76 | ``` 77 | ```` 78 | 79 | ````` 80 | ___ 81 | 82 | 83 | 84 | ````{py:class} QtChart(widget: QWidget) 85 | 86 | The `QtChart` object allows the use of charts within a `QMainWindow` object, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. 87 | 88 | Either the `PyQt5`, `PyQt6` or `PySide6` libraries will work with this chart. 89 | 90 | Callbacks can be received through the Qt event loop. 91 | ___ 92 | 93 | 94 | 95 | ```{py:method} get_webview() -> QWebEngineView 96 | 97 | Returns the `QWebEngineView` object. 98 | 99 | ``` 100 | ```` 101 | ___ 102 | 103 | 104 | 105 | ````{py:class} WxChart(parent: WxPanel) 106 | The WxChart object allows the use of charts within a `wx.Frame` object, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. 107 | 108 | Callbacks can be received through the Wx event loop. 109 | ___ 110 | 111 | 112 | 113 | ```{py:method} get_webview() -> wx.html2.WebView 114 | 115 | Returns a `wx.html2.WebView` object which can be used to for positioning and styling within wxPython. 116 | 117 | 118 | ``` 119 | ```` 120 | ___ 121 | 122 | 123 | 124 | ````{py:class} StreamlitChart 125 | The `StreamlitChart` object allows the use of charts within a Streamlit app, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. 126 | 127 | This object only supports the displaying of **static** data, and should not be used with the `update_from_tick` or `update` methods. Every call to the chart object must occur **before** calling `load`. 128 | 129 | ___ 130 | 131 | 132 | 133 | ```{py:method} load() 134 | 135 | Loads the chart into the Streamlit app. This should be called after setting, styling, and configuring the chart, as no further calls to the `StreamlitChart` will be acknowledged. 136 | 137 | ``` 138 | ```` 139 | ___ 140 | 141 | 142 | 143 | ````{py:class} JupyterChart 144 | 145 | The `JupyterChart` object allows the use of charts within a notebook, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. 146 | 147 | This object only supports the displaying of **static** data, and should not be used with the `update_from_tick` or `update` methods. Every call to the chart object must occur **before** calling `load`. 148 | ___ 149 | 150 | 151 | 152 | ```{py:method} load() 153 | 154 | Renders the chart. This should be called after setting, styling, and configuring the chart, as no further calls to the `JupyterChart` will be acknowledged. 155 | 156 | ``` 157 | ```` 158 | -------------------------------------------------------------------------------- /docs/source/reference/events.md: -------------------------------------------------------------------------------- 1 | # `Events` 2 | 3 | ````{py:class} AbstractChart.Events 4 | The chart events class, accessed through `chart.events` 5 | 6 | Events allow asynchronous and synchronous callbacks to be passed back into python. 7 | 8 | Chart events can be subscribed to using: `chart.events. += ` 9 | 10 | ```{py:method} search -> (chart: Chart, string: str) 11 | Fires upon searching. Searchbox will be automatically created. 12 | 13 | ``` 14 | 15 | ```{py:method} new_bar -> (chart: Chart) 16 | Fires when a new candlestick is added to the chart. 17 | 18 | ``` 19 | 20 | ```{py:method} range_change -> (chart: Chart, bars_before: NUM, bars_after: NUM) 21 | Fires when the range (visibleLogicalRange) changes. 22 | 23 | ``` 24 | 25 | ```{py:method} click -> (chart: Chart, time: NUM, price: NUM) 26 | Fires when the mouse is clicked, returning the time and price of the clicked location. 27 | 28 | ``` 29 | 30 | ```` 31 | 32 | Tutorial: [Topbar & Events](../tutorials/events.md) 33 | 34 | -------------------------------------------------------------------------------- /docs/source/reference/histogram.md: -------------------------------------------------------------------------------- 1 | # `Histogram` 2 | 3 | 4 | ````{py:class} Histogram(name: str, color: COLOR, style: LINE_STYLE, width: int, price_line: bool, price_label: bool) 5 | 6 | The `Histogram` object represents a `HistogramSeries` object in Lightweight Charts and can be used to create indicators. As well as the methods described below, the `Line` object also has access to: 7 | 8 | [`horizontal_line`](#AbstractChart.horizontal_line), [`hide_data`](#hide_data), [`show_data`](#show_data) and [`price_line`](#price_line). 9 | 10 | Its instance should only be accessed from [`create_histogram`](#AbstractChart.create_histogram). 11 | ___ 12 | 13 | 14 | 15 | ```{py:method} set(data: pd.DataFrame) 16 | 17 | Sets the data for the histogram. 18 | 19 | When a name has not been set upon declaration, the columns should be named: `time | value` (Not case sensitive). 20 | 21 | The column containing the data should be named after the string given in the `name`. 22 | 23 | A `color` column can be used within the dataframe to specify the color of individual bars. 24 | 25 | ``` 26 | ___ 27 | 28 | 29 | 30 | ```{py:method} update(series: pd.Series) 31 | 32 | Updates the data for the histogram. 33 | 34 | This should be given as a Series object, with labels akin to the `histogram.set` method. 35 | ``` 36 | ___ 37 | 38 | 39 | ```{py:method} scale(scale_margin_top: float, scale_margin_bottom: float) 40 | Scales the margins of the histogram, as used within [`volume_config`](#AbstractChart.volume_config). 41 | ``` 42 | 43 | 44 | ___ 45 | 46 | ```{py:method} delete() 47 | 48 | Irreversibly deletes the histogram. 49 | 50 | ``` 51 | ```` -------------------------------------------------------------------------------- /docs/source/reference/horizontal_line.md: -------------------------------------------------------------------------------- 1 | # `HorizontalLine` 2 | 3 | 4 | ````{py:class} HorizontalLine(price: NUM, color: COLOR, width: int, style: LINE_STYLE, text: str, axis_label_visible: bool, func: callable= None) 5 | 6 | The `HorizontalLine` object represents a `PriceLine` in Lightweight Charts. 7 | 8 | Its instance should be accessed from the `horizontal_line` method. 9 | 10 | 11 | 12 | ```{py:method} update(price: NUM) 13 | 14 | Updates the price of the horizontal line. 15 | ``` 16 | 17 | 18 | ```{py:method} label(text: str) 19 | 20 | Updates the label of the horizontal line. 21 | ``` 22 | 23 | 24 | ```{py:method} delete() 25 | 26 | Irreversibly deletes the horizontal line. 27 | ``` 28 | ```` -------------------------------------------------------------------------------- /docs/source/reference/index.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | ```{toctree} 4 | :hidden: 5 | abstract_chart 6 | line 7 | histogram 8 | horizontal_line 9 | charts 10 | events 11 | topbar 12 | toolbox 13 | tables 14 | 15 | ``` 16 | 17 | 18 | 1. [`AbstractChart`](#AbstractChart) 19 | 2. [`Line`](#Line) 20 | 3. [`Histogram`](#Histogram) 21 | 3. [`HorizontalLine`](#HorizontalLine) 22 | 4. [Charts](#charts) 23 | 5. [`Events`](./events.md) 24 | 6. [`Toolbox`](#ToolBox) 25 | 7. [`Table`](#Table) 26 | -------------------------------------------------------------------------------- /docs/source/reference/line.md: -------------------------------------------------------------------------------- 1 | # `Line` 2 | 3 | 4 | ````{py:class} Line(name: str, color: COLOR, style: LINE_STYLE, width: int, price_line: bool, price_label: bool, price_scale_id: str) 5 | 6 | The `Line` object represents a `LineSeries` object in Lightweight Charts and can be used to create indicators. As well as the methods described below, the `Line` object also has access to: 7 | 8 | [`marker`](#marker), [`horizontal_line`](#AbstractChart.horizontal_line), [`hide_data`](#hide_data), [`show_data`](#show_data) and [`price_line`](#price_line). 9 | 10 | Its instance should only be accessed from [`create_line`](#AbstractChart.create_line). 11 | ___ 12 | 13 | 14 | 15 | ```{py:method} set(data: pd.DataFrame) 16 | 17 | Sets the data for the line. 18 | 19 | When a name has not been set upon declaration, the columns should be named: `time | value` (Not case sensitive). 20 | 21 | Otherwise, the method will use the column named after the string given in `name`. This name will also be used within the legend of the chart. 22 | 23 | ``` 24 | ___ 25 | 26 | 27 | 28 | ```{py:method} update(series: pd.Series) 29 | 30 | Updates the data for the line. 31 | 32 | This should be given as a Series object, with labels akin to the `line.set()` function. 33 | ``` 34 | 35 | 36 | 37 | ___ 38 | 39 | ```{py:method} delete() 40 | 41 | Irreversibly deletes the line. 42 | 43 | ``` 44 | ```` 45 | -------------------------------------------------------------------------------- /docs/source/reference/tables.md: -------------------------------------------------------------------------------- 1 | # `Table` 2 | 3 | `````{py:class} Table(width: NUM, height: NUM, headings: Tuple[str], widths: Tuple[float], alignments: Tuple[str], position: FLOAT, draggable: bool, return_clicked_cells: bool, func: callable) 4 | 5 | Tables are panes that can be used to gain further functionality from charts. They are intended to be used for watchlists, order management, or position management. It should be accessed from the `create_table` common method. 6 | 7 | The `Table` and `Row` objects act as dictionaries, and can be manipulated as such. 8 | 9 | `width`/`height` 10 | : Either given as a percentage (a `float` between 0 and 1) or as an integer representing pixel size. 11 | 12 | `widths` 13 | : Given as a `float` between 0 and 1. 14 | 15 | `position` 16 | : Used as you would with [`create_subchart`](#AbstractChart.create_subchart), representing how the table will float within the window. 17 | 18 | `draggable` 19 | : If `True`, then the window can be dragged to any position within the window. 20 | 21 | `return_clicked_cells` 22 | : If `True`, an additional parameter will be emitted to the `func` given, containing the heading name of the clicked cell. 23 | 24 | `func` 25 | : If given, this will be called when a row is clicked, returning the `Row` object in question. 26 | ___ 27 | 28 | 29 | 30 | ````{py:method} new_row(*values, id: int) -> Row 31 | 32 | Creates a new row within the table, and returns a `Row` object. 33 | 34 | if `id` is passed it should be unique to all other rows. Otherwise, the `id` will be randomly generated. 35 | 36 | Rows can be passed a string (header) item or a tuple to set multiple headings: 37 | 38 | ```python 39 | row['Symbol'] = 'AAPL' 40 | row['Symbol', 'Action'] = 'AAPL', 'BUY' 41 | ``` 42 | 43 | ```` 44 | ___ 45 | 46 | 47 | 48 | ```{py:method} clear() 49 | 50 | Clears and deletes all table rows. 51 | ``` 52 | ___ 53 | 54 | 55 | 56 | ````{py:method} format(column: str, format_str: str) 57 | 58 | Sets the format to be used for the given column. `Table.VALUE` should be used as a placeholder for the cell value. For example: 59 | 60 | ```python 61 | table.format('Daily %', f'{table.VALUE} %') 62 | table.format('PL', f'$ {table.VALUE}') 63 | ``` 64 | 65 | ```` 66 | ___ 67 | 68 | 69 | 70 | ```{py:method} visible(visible: bool) 71 | 72 | Sets the visibility of the Table. 73 | 74 | ``` 75 | ````` 76 | ___ 77 | 78 | 79 | 80 | ````{py:class} Row() 81 | 82 | ```{py:method} background_color(column: str, color: COLOR) 83 | 84 | Sets the background color of the row cell. 85 | ``` 86 | ___ 87 | 88 | 89 | 90 | ```{py:method} text_color(column: str, color: COLOR) 91 | 92 | Sets the foreground color of the row cell. 93 | ``` 94 | ___ 95 | 96 | 97 | 98 | ```{py:method} delete() 99 | 100 | Deletes the row. 101 | ``` 102 | ```` 103 | ___ 104 | 105 | 106 | ````{py:class} Footer 107 | 108 | ```{tip} 109 | All of these methods can be applied to the `header` parameter. 110 | ``` 111 | 112 | Tables can also have a footer containing a number of text boxes. To initialize this, call the `footer` attribute with the number of textboxes to be used: 113 | 114 | ```python 115 | table.footer(3) # Footer will be displayed, with 3 text boxes. 116 | ``` 117 | To edit the textboxes, treat `footer` as a list: 118 | 119 | ```python 120 | table.footer[0] = 'Text Box 1' 121 | table.footer[1] = 'Text Box 2' 122 | table.footer[2] = 'Text Box 3' 123 | ``` 124 | 125 | When calling footer, the `func` parameter can also be used to convert each textbox into a button: 126 | 127 | ```python 128 | def on_footer_click(table, box_index): 129 | print(f'Box number {box_index+1} was pressed.') 130 | 131 | table.footer(3, func=on_footer_click) 132 | ``` 133 | 134 | ```` 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /docs/source/reference/toolbox.md: -------------------------------------------------------------------------------- 1 | # `ToolBox` 2 | 3 | `````{py:class} ToolBox 4 | 5 | The Toolbox allows for trendlines, ray lines and horizontal lines to be drawn and edited directly on the chart. 6 | 7 | It can be used within any Chart object, and is enabled by setting the `toolbox` parameter to `True` upon Chart declaration. 8 | 9 | The following hotkeys can also be used when the Toolbox is enabled: 10 | 11 | | Key Cmd | Action | 12 | |--- |--- | 13 | | `alt T` | Trendline | 14 | | `alt H` | Horizontal Line | 15 | | `alt R` | Ray Line | 16 | | `⌘ Z` or `ctrl Z` | Undo | 17 | 18 | Right-clicking on a drawing will open a context menu, allowing for color selection, style selection and deletion. 19 | ___ 20 | 21 | 22 | 23 | ````{py:method} save_drawings_under(widget: Widget) 24 | 25 | Saves drawings under a specific `topbar` text widget. For example: 26 | 27 | ```python 28 | chart.toolbox.save_drawings_under(chart.topbar['symbol']) 29 | ``` 30 | 31 | ```` 32 | ___ 33 | 34 | 35 | 36 | ```{py:method} load_drawings(tag: str) 37 | 38 | Loads and displays drawings stored under the tag given. 39 | ``` 40 | ___ 41 | 42 | 43 | 44 | ```{py:method} import_drawings(file_path: str) 45 | 46 | Imports the drawings stored at the JSON file given in `file_path`. 47 | 48 | ``` 49 | ___ 50 | 51 | 52 | 53 | ```{py:method} export_drawings(file_path: str) 54 | 55 | Exports all currently saved drawings to the JSON file given in `file_path`. 56 | 57 | ``` 58 | 59 | ````` 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /docs/source/reference/topbar.md: -------------------------------------------------------------------------------- 1 | # `TopBar` 2 | 3 | 4 | ````{py:class} TopBar 5 | The `TopBar` class represents the top bar shown on the chart: 6 | 7 | ![topbar](https://i.imgur.com/Qu2FW9Y.png) 8 | 9 | This object is accessed from the `topbar` attribute of the chart object (`chart.topbar.`). 10 | 11 | Switchers, text boxes and buttons can be added to the top bar, and their instances can be accessed through the `topbar` dictionary. For example: 12 | 13 | ```python 14 | chart.topbar.textbox('symbol', 'AAPL') # Declares a textbox displaying 'AAPL'. 15 | print(chart.topbar['symbol'].value) # Prints the value within 'symbol' -> 'AAPL' 16 | 17 | chart.topbar['symbol'].set('MSFT') # Sets the 'symbol' textbox to 'MSFT' 18 | print(chart.topbar['symbol'].value) # Prints the value again -> 'MSFT' 19 | ``` 20 | 21 | Topbar widgets share common parameters: 22 | * `name`: The name of the widget which can be used to access it from the `topbar` dictionary. 23 | * `align`: The alignment of the widget (either `'left'` or `'right'` which determines which side of the topbar the widget will be placed upon. 24 | 25 | ___ 26 | 27 | 28 | 29 | ```{py:method} switcher(name: str, options: tuple: default: str, align: ALIGN, func: callable) 30 | 31 | * `options`: The options for each switcher item. 32 | * `default`: The initial switcher option set. 33 | 34 | ``` 35 | ___ 36 | 37 | 38 | 39 | ```{py:method} menu(name: str, options: tuple: default: str, separator: bool, align: ALIGN, func: callable) 40 | 41 | * `options`: The options for each menu item. 42 | * `default`: The initial menu option set. 43 | * `separator`: places a separator line to the right of the menu. 44 | 45 | ``` 46 | ___ 47 | 48 | 49 | 50 | ```{py:method} textbox(name: str, initial_text: str, align: ALIGN) 51 | 52 | * `initial_text`: The text to show within the text box. 53 | 54 | ``` 55 | ___ 56 | 57 | 58 | 59 | ```{py:method} button(name: str, button_text: str, separator: bool, align: ALIGN, func: callable) 60 | 61 | * `button_text`: Text to show within the button. 62 | * `separator`: places a separator line to the right of the button. 63 | * `func`: The event handler which will be executed upon a button click. 64 | 65 | ``` 66 | 67 | ```` -------------------------------------------------------------------------------- /docs/source/reference/typing.md: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | # `Typing` 4 | 5 | These classes serve as placeholders for type requirements. 6 | 7 | 8 | ```{py:class} NUM(Literal[float, int]) 9 | ``` 10 | 11 | ```{py:class} FLOAT(Literal['left', 'right', 'top', 'bottom']) 12 | ``` 13 | 14 | ```{py:class} TIME(Union[datetime, pd.Timestamp, str]) 15 | ``` 16 | 17 | ```{py:class} COLOR(str) 18 | Throughout the library, colors should be given as either rgb (`rgb(100, 100, 100)`), rgba(`rgba(100, 100, 100, 0.7)`), hex(`#32a852`) or a html literal(`blue`, `red` etc). 19 | ``` 20 | 21 | ```{py:class} LINE_STYLE(Literal['solid', 'dotted', 'dashed', 'large_dashed', 'sparse_dotted']) 22 | ``` 23 | 24 | ```{py:class} MARKER_POSITION(Literal['above', 'below', 'inside']) 25 | ``` 26 | 27 | ```{py:class} MARKER_SHAPE(Literal['arrow_up', 'arrow_down', 'circle', 'square']) 28 | ``` 29 | 30 | ```{py:class} CROSSHAIR_MODE(Literal['normal', 'magnet']) 31 | ``` 32 | 33 | ```{py:class} PRICE_SCALE_MODE(Literal['normal', 'logarithmic', 'percentage', 'index100']) 34 | ``` 35 | 36 | ```{py:class} ALIGN(Literal['left', 'right']) 37 | ``` 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /docs/source/tutorials/events.md: -------------------------------------------------------------------------------- 1 | # Topbar & Events 2 | 3 | This section gives an overview of how events are handled across the library. 4 | 5 | ## How to use events 6 | 7 | 8 | Take a look at this minimal example, which uses the [`search`](#AbstractChart.Events) event: 9 | 10 | ```python 11 | from lightweight_charts import Chart 12 | 13 | 14 | def on_search(chart, string): 15 | print(f'Search Text: "{string}" | Chart/SubChart ID: "{chart.id}"') 16 | 17 | 18 | if __name__ == '__main__': 19 | chart = Chart() 20 | 21 | # Subscribe the function above to search event 22 | chart.events.search += on_search 23 | 24 | chart.show(block=True) 25 | 26 | ``` 27 | Upon searching in a pane, the expected output would be akin to: 28 | ``` 29 | Search Text: "AAPL" | Chart/SubChart ID: "window.blyjagcr" 30 | ``` 31 | The ID shown above will change depending upon which pane was used to search, allowing for access to the object in question. 32 | 33 | ```{important} 34 | * When using `show` rather than `show_async`, block should be set to `True` (`chart.show(block=True)`). 35 | * Event callables can be either coroutines, methods, or functions. 36 | ``` 37 | 38 | ___ 39 | 40 | ## Topbar events 41 | 42 | 43 | Events can also be emitted from the topbar: 44 | 45 | ```python 46 | from lightweight_charts import Chart 47 | 48 | def on_button_press(chart): 49 | new_button_value = 'On' if chart.topbar['my_button'].value == 'Off' else 'Off' 50 | chart.topbar['my_button'].set(new_button_value) 51 | print(f'Turned something {new_button_value.lower()}.') 52 | 53 | 54 | if __name__ == '__main__': 55 | chart = Chart() 56 | chart.topbar.button('my_button', 'Off', func=on_button_press) 57 | chart.show(block=True) 58 | 59 | ``` 60 | In this example, we are passing `on_button_press` to the `func` parameter. 61 | 62 | When the button is pressed, the function will be emitted the `chart` object as with the previous example, allowing access to the topbar dictionary. 63 | 64 | 65 | The `switcher` is typically used for timeframe selection: 66 | 67 | ```python 68 | from lightweight_charts import Chart 69 | 70 | def on_timeframe_selection(chart): 71 | print(f'Getting data with a {chart.topbar["my_switcher"].value} timeframe.') 72 | 73 | 74 | if __name__ == '__main__': 75 | chart = Chart() 76 | chart.topbar.switcher( 77 | name='my_switcher', 78 | options=('1min', '5min', '30min'), 79 | default='5min', 80 | func=on_timeframe_selection) 81 | chart.show(block=True) 82 | ``` 83 | ___ 84 | 85 | ## Async clock 86 | 87 | There are many use cases where we will need to run our own code whilst the GUI loop continues to listen for events. Let's demonstrate this by using the `textbox` widget to display a clock: 88 | 89 | ```python 90 | import asyncio 91 | from datetime import datetime 92 | from lightweight_charts import Chart 93 | 94 | 95 | async def update_clock(chart): 96 | while chart.is_alive: 97 | await asyncio.sleep(1-(datetime.now().microsecond/1_000_000)) 98 | chart.topbar['clock'].set(datetime.now().strftime('%H:%M:%S')) 99 | 100 | 101 | async def main(): 102 | chart = Chart() 103 | chart.topbar.textbox('clock') 104 | await asyncio.gather(chart.show_async(), update_clock(chart)) 105 | 106 | 107 | if __name__ == '__main__': 108 | asyncio.run(main()) 109 | ``` 110 | 111 | This is how the library is intended to be used with live data (option #2 [described here]()). 112 | ___ 113 | 114 | ## Live data, topbar & events 115 | 116 | 117 | Now we can create an asyncio program which updates chart data whilst allowing the GUI loop to continue processing events, based the [Live data](live_chart.md) example: 118 | 119 | ```python 120 | import asyncio 121 | import pandas as pd 122 | from lightweight_charts import Chart 123 | 124 | 125 | async def data_loop(chart): 126 | ticks = pd.read_csv('ticks.csv') 127 | 128 | for i, tick in ticks.iterrows(): 129 | if not chart.is_alive: 130 | return 131 | chart.update_from_tick(ticks.iloc[i]) 132 | await asyncio.sleep(0.03) 133 | 134 | 135 | def on_new_bar(chart): 136 | print('New bar event!') 137 | 138 | 139 | def on_timeframe_selection(chart): 140 | print(f'Selected timeframe of {chart.topbar["timeframe"].value}') 141 | 142 | 143 | async def main(): 144 | chart = Chart() 145 | chart.events.new_bar += on_new_bar 146 | 147 | chart.topbar.switcher('timeframe', ('1min', '5min'), func=on_timeframe_selection) 148 | 149 | df = pd.read_csv('ohlc.csv') 150 | 151 | chart.set(df) 152 | await asyncio.gather(chart.show_async(), data_loop(chart)) 153 | 154 | 155 | if __name__ == '__main__': 156 | asyncio.run(main()) 157 | 158 | ``` 159 | 160 | -------------------------------------------------------------------------------- /docs/source/tutorials/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | To install the library, use pip: 6 | 7 | ```text 8 | pip install lightweight-charts 9 | ``` 10 | 11 | Pywebview's installation can differ depending on OS. Please refer to their [documentation](https://pywebview.flowrl.com/guide/installation.html#installation). 12 | 13 | When using Docker or WSL, you may need to update your language tags; see [this](https://github.com/louisnw01/lightweight-charts-python/issues/63#issuecomment-1670473651) issue. 14 | 15 | ___ 16 | 17 | ## A simple static chart 18 | 19 | ```python 20 | import pandas as pd 21 | from lightweight_charts import Chart 22 | ``` 23 | 24 | Download this 25 | [`ohlcv.csv`](../../../examples/1_setting_data/ohlcv.csv) 26 | file for this tutorial. 27 | 28 | In this example, we are reading a csv file using pandas: 29 | ```text 30 | date open high low close volume 31 | 0 2010-06-29 1.2667 1.6667 1.1693 1.5927 277519500.0 32 | 1 2010-06-30 1.6713 2.0280 1.5533 1.5887 253039500.0 33 | 2 2010-07-01 1.6627 1.7280 1.3513 1.4640 121461000.0 34 | 3 2010-07-02 1.4700 1.5500 1.2473 1.2800 75871500.0 35 | 4.. 36 | ``` 37 | ..which can be used as data for the `Chart` object: 38 | 39 | 40 | ```python 41 | if __name__ == '__main__': 42 | chart = Chart() 43 | 44 | df = pd.read_csv('ohlcv.csv') 45 | chart.set(df) 46 | 47 | chart.show(block=True) 48 | ``` 49 | 50 | The `block` parameter is set to `True` in this case, as we do not want the program to exit. 51 | 52 | ```{warning} 53 | Due to the library's use of multiprocessing, instantiations of `Chart` should be encapsulated within an `if __name__ == '__main__'` block. 54 | ``` 55 | 56 | 57 | ## Adding a line 58 | 59 | Now lets add a moving average to the chart using the following function: 60 | ```python 61 | def calculate_sma(df, period: int = 50): 62 | return pd.DataFrame({ 63 | 'time': df['date'], 64 | f'SMA {period}': df['close'].rolling(window=period).mean() 65 | }).dropna() 66 | ``` 67 | 68 | `calculate_sma` derives the data column from `f'SMA {period}'`, which we will use as the name of our line: 69 | 70 | ```python 71 | if __name__ == '__main__': 72 | chart = Chart() 73 | line = chart.create_line(name='SMA 50') 74 | 75 | df = pd.read_csv('ohlcv.csv') 76 | sma_df = calculate_sma(df, period=50) 77 | 78 | chart.set(df) 79 | line.set(sma_df) 80 | 81 | chart.show(block=True) 82 | ``` 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /examples/1_setting_data/setting_data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/examples/1_setting_data/setting_data.png -------------------------------------------------------------------------------- /examples/1_setting_data/setting_data.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from lightweight_charts import Chart 3 | 4 | if __name__ == '__main__': 5 | chart = Chart() 6 | 7 | # Columns: time | open | high | low | close | volume 8 | df = pd.read_csv('ohlcv.csv') 9 | chart.set(df) 10 | 11 | chart.show(block=True) 12 | -------------------------------------------------------------------------------- /examples/2_live_data/live_data.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/examples/2_live_data/live_data.gif -------------------------------------------------------------------------------- /examples/2_live_data/live_data.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from time import sleep 3 | from lightweight_charts import Chart 4 | 5 | if __name__ == '__main__': 6 | 7 | chart = Chart() 8 | 9 | df1 = pd.read_csv('ohlcv.csv') 10 | df2 = pd.read_csv('next_ohlcv.csv') 11 | 12 | chart.set(df1) 13 | 14 | chart.show() 15 | 16 | last_close = df1.iloc[-1]['close'] 17 | 18 | for i, series in df2.iterrows(): 19 | chart.update(series) 20 | 21 | if series['close'] > 20 and last_close < 20: 22 | chart.marker(text='The price crossed $20!') 23 | 24 | last_close = series['close'] 25 | sleep(0.1) 26 | -------------------------------------------------------------------------------- /examples/3_tick_data/tick_data.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/examples/3_tick_data/tick_data.gif -------------------------------------------------------------------------------- /examples/3_tick_data/tick_data.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from time import sleep 3 | from lightweight_charts import Chart 4 | 5 | if __name__ == '__main__': 6 | 7 | df1 = pd.read_csv('ohlc.csv') 8 | 9 | # Columns: time | price 10 | df2 = pd.read_csv('ticks.csv') 11 | 12 | chart = Chart() 13 | 14 | chart.set(df1) 15 | 16 | chart.show() 17 | 18 | for i, tick in df2.iterrows(): 19 | chart.update_from_tick(tick) 20 | sleep(0.03) 21 | -------------------------------------------------------------------------------- /examples/4_line_indicators/line_indicators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/examples/4_line_indicators/line_indicators.png -------------------------------------------------------------------------------- /examples/4_line_indicators/line_indicators.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from lightweight_charts import Chart 3 | 4 | 5 | def calculate_sma(df, period: int = 50): 6 | return pd.DataFrame({ 7 | 'time': df['date'], 8 | f'SMA {period}': df['close'].rolling(window=period).mean() 9 | }).dropna() 10 | 11 | 12 | if __name__ == '__main__': 13 | chart = Chart() 14 | chart.legend(visible=True) 15 | 16 | df = pd.read_csv('ohlcv.csv') 17 | chart.set(df) 18 | 19 | line = chart.create_line('SMA 50') 20 | sma_data = calculate_sma(df, period=50) 21 | line.set(sma_data) 22 | 23 | chart.show(block=True) 24 | -------------------------------------------------------------------------------- /examples/5_styling/styling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/examples/5_styling/styling.png -------------------------------------------------------------------------------- /examples/5_styling/styling.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from lightweight_charts import Chart 3 | 4 | 5 | if __name__ == '__main__': 6 | 7 | chart = Chart() 8 | 9 | df = pd.read_csv('ohlcv.csv') 10 | 11 | chart.layout(background_color='#090008', text_color='#FFFFFF', font_size=16, font_family='Helvetica') 12 | 13 | chart.candle_style(up_color='#00ff55', down_color='#ed4807', border_up_color='#FFFFFF', border_down_color='#FFFFFF', 14 | wick_up_color='#FFFFFF', wick_down_color='#FFFFFF') 15 | 16 | chart.volume_config(up_color='#00ff55', down_color='#ed4807') 17 | 18 | chart.watermark('1D', color='rgba(180, 180, 240, 0.7)') 19 | 20 | chart.crosshair(mode='normal', vert_color='#FFFFFF', vert_style='dotted', horz_color='#FFFFFF', horz_style='dotted') 21 | 22 | chart.legend(visible=True, font_size=14) 23 | 24 | chart.set(df) 25 | 26 | chart.show(block=True) 27 | -------------------------------------------------------------------------------- /examples/6_callbacks/callbacks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/052d778beda66f569175cbe6774aba5d3e3b1dea/examples/6_callbacks/callbacks.gif -------------------------------------------------------------------------------- /examples/6_callbacks/callbacks.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from lightweight_charts import Chart 3 | 4 | 5 | def get_bar_data(symbol, timeframe): 6 | if symbol not in ('AAPL', 'GOOGL', 'TSLA'): 7 | print(f'No data for "{symbol}"') 8 | return pd.DataFrame() 9 | return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') 10 | 11 | 12 | def on_search(chart, searched_string): # Called when the user searches. 13 | new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) 14 | if new_data.empty: 15 | return 16 | chart.topbar['symbol'].set(searched_string) 17 | chart.set(new_data) 18 | 19 | 20 | def on_timeframe_selection(chart): # Called when the user changes the timeframe. 21 | new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) 22 | if new_data.empty: 23 | return 24 | chart.set(new_data, True) 25 | 26 | 27 | def on_horizontal_line_move(chart, line): 28 | print(f'Horizontal line moved to: {line.price}') 29 | 30 | 31 | if __name__ == '__main__': 32 | chart = Chart(toolbox=True) 33 | chart.legend(True) 34 | 35 | chart.events.search += on_search 36 | 37 | chart.topbar.textbox('symbol', 'TSLA') 38 | chart.topbar.switcher('timeframe', ('1min', '5min', '30min'), default='5min', 39 | func=on_timeframe_selection) 40 | 41 | df = get_bar_data('TSLA', '5min') 42 | chart.set(df) 43 | 44 | chart.horizontal_line(200, func=on_horizontal_line_move) 45 | 46 | chart.show(block=True) 47 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lightweight_charts/__init__.py: -------------------------------------------------------------------------------- 1 | from .abstract import AbstractChart, Window 2 | from .chart import Chart 3 | from .widgets import JupyterChart 4 | from .polygon import PolygonChart 5 | -------------------------------------------------------------------------------- /lightweight_charts/chart.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import multiprocessing as mp 4 | import typing 5 | import webview 6 | from webview.errors import JavascriptException 7 | 8 | from lightweight_charts import abstract 9 | from .util import parse_event_message, FLOAT 10 | 11 | import os 12 | import threading 13 | 14 | 15 | class CallbackAPI: 16 | def __init__(self, emit_queue): 17 | self.emit_queue = emit_queue 18 | 19 | def callback(self, message: str): 20 | self.emit_queue.put(message) 21 | 22 | 23 | class PyWV: 24 | def __init__(self, q, emit_q, return_q, loaded_event): 25 | self.queue = q 26 | self.return_queue = return_q 27 | self.emit_queue = emit_q 28 | self.loaded_event = loaded_event 29 | 30 | self.is_alive = True 31 | 32 | self.callback_api = CallbackAPI(emit_q) 33 | self.windows: typing.List[webview.Window] = [] 34 | self.loop() 35 | 36 | 37 | def create_window( 38 | self, width, height, x, y, screen=None, on_top=False, 39 | maximize=False, title='' 40 | ): 41 | screen = webview.screens[screen] if screen is not None else None 42 | if maximize: 43 | if screen is None: 44 | active_screen = webview.screens[0] 45 | width, height = active_screen.width, active_screen.height 46 | else: 47 | width, height = screen.width, screen.height 48 | 49 | self.windows.append(webview.create_window( 50 | title, 51 | url=abstract.INDEX, 52 | js_api=self.callback_api, 53 | width=width, 54 | height=height, 55 | x=x, 56 | y=y, 57 | screen=screen, 58 | on_top=on_top, 59 | background_color='#000000') 60 | ) 61 | 62 | self.windows[-1].events.loaded += lambda: self.loaded_event.set() 63 | 64 | 65 | def loop(self): 66 | # self.loaded_event.set() 67 | while self.is_alive: 68 | i, arg = self.queue.get() 69 | 70 | if i == 'start': 71 | webview.start(debug=arg, func=self.loop) 72 | self.is_alive = False 73 | self.emit_queue.put('exit') 74 | return 75 | if i == 'create_window': 76 | self.create_window(*arg) 77 | continue 78 | 79 | window = self.windows[i] 80 | if arg == 'show': 81 | window.show() 82 | elif arg == 'hide': 83 | window.hide() 84 | else: 85 | try: 86 | if '_~_~RETURN~_~_' in arg: 87 | self.return_queue.put(window.evaluate_js(arg[14:])) 88 | else: 89 | window.evaluate_js(arg) 90 | except KeyError as e: 91 | return 92 | except JavascriptException as e: 93 | msg = eval(str(e)) 94 | raise JavascriptException(f"\n\nscript -> '{arg}',\nerror -> {msg['name']}[{msg['line']}:{msg['column']}]\n{msg['message']}") 95 | 96 | 97 | class WebviewHandler(): 98 | def __init__(self) -> None: 99 | self._reset() 100 | self.debug = False 101 | 102 | def _reset(self): 103 | self.loaded_event = mp.Event() 104 | self.return_queue = mp.Queue() 105 | self.function_call_queue = mp.Queue() 106 | self.emit_queue = mp.Queue() 107 | self.wv_process = mp.Process( 108 | target=PyWV, args=( 109 | self.function_call_queue, self.emit_queue, 110 | self.return_queue, self.loaded_event 111 | ), 112 | daemon=True 113 | ) 114 | self.max_window_num = -1 115 | 116 | def create_window( 117 | self, width, height, x, y, screen=None, on_top=False, 118 | maximize=False, title='' 119 | ): 120 | self.function_call_queue.put(( 121 | 'create_window', 122 | (width, height, x, y, screen, on_top, maximize, title) 123 | )) 124 | self.max_window_num += 1 125 | return self.max_window_num 126 | 127 | def start(self): 128 | self.loaded_event.clear() 129 | self.wv_process.start() 130 | self.function_call_queue.put(('start', self.debug)) 131 | self.loaded_event.wait() 132 | 133 | def show(self, window_num): 134 | self.function_call_queue.put((window_num, 'show')) 135 | 136 | def hide(self, window_num): 137 | self.function_call_queue.put((window_num, 'hide')) 138 | 139 | def evaluate_js(self, window_num, script): 140 | self.function_call_queue.put((window_num, script)) 141 | 142 | def exit(self): 143 | if self.wv_process.is_alive(): 144 | self.wv_process.terminate() 145 | self.wv_process.join() 146 | self._reset() 147 | 148 | 149 | class Chart(abstract.AbstractChart): 150 | _main_window_handlers = None 151 | WV: WebviewHandler = WebviewHandler() 152 | 153 | def __init__( 154 | self, 155 | width: int = 800, 156 | height: int = 600, 157 | x: int = None, 158 | y: int = None, 159 | title: str = '', 160 | screen: int = None, 161 | on_top: bool = False, 162 | maximize: bool = False, 163 | debug: bool = False, 164 | toolbox: bool = False, 165 | inner_width: float = 1.0, 166 | inner_height: float = 1.0, 167 | scale_candles_only: bool = False, 168 | position: FLOAT = 'left' 169 | ): 170 | Chart.WV.debug = debug 171 | self._i = Chart.WV.create_window( 172 | width, height, x, y, screen, on_top, maximize, title 173 | ) 174 | 175 | window = abstract.Window( 176 | script_func=lambda s: Chart.WV.evaluate_js(self._i, s), 177 | js_api_code='pywebview.api.callback' 178 | ) 179 | 180 | abstract.Window._return_q = Chart.WV.return_queue 181 | 182 | self.is_alive = True 183 | 184 | if Chart._main_window_handlers is None: 185 | super().__init__(window, inner_width, inner_height, scale_candles_only, toolbox, position=position) 186 | Chart._main_window_handlers = self.win.handlers 187 | else: 188 | window.handlers = Chart._main_window_handlers 189 | super().__init__(window, inner_width, inner_height, scale_candles_only, toolbox, position=position) 190 | 191 | def show(self, block: bool = False): 192 | """ 193 | Shows the chart window.\n 194 | :param block: blocks execution until the chart is closed. 195 | """ 196 | if not self.win.loaded: 197 | Chart.WV.start() 198 | self.win.on_js_load() 199 | else: 200 | Chart.WV.show(self._i) 201 | if block: 202 | asyncio.run(self.show_async()) 203 | 204 | async def show_async(self): 205 | self.show(block=False) 206 | try: 207 | from lightweight_charts import polygon 208 | [asyncio.create_task(self.polygon.async_set(*args)) for args in polygon._set_on_load] 209 | while 1: 210 | while Chart.WV.emit_queue.empty() and self.is_alive: 211 | await asyncio.sleep(0.05) 212 | if not self.is_alive: 213 | return 214 | response = Chart.WV.emit_queue.get() 215 | if response == 'exit': 216 | Chart.WV.exit() 217 | self.is_alive = False 218 | return 219 | else: 220 | func, args = parse_event_message(self.win, response) 221 | await func(*args) if asyncio.iscoroutinefunction(func) else func(*args) 222 | except KeyboardInterrupt: 223 | return 224 | 225 | def hide(self): 226 | """ 227 | Hides the chart window.\n 228 | """ 229 | self._q.put((self._i, 'hide')) 230 | 231 | def exit(self): 232 | """ 233 | Exits and destroys the chart window.\n 234 | """ 235 | Chart.WV.exit() 236 | self.is_alive = False 237 | -------------------------------------------------------------------------------- /lightweight_charts/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | lightweight-charts-python 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /lightweight_charts/js/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color:#0c0d0f; 3 | --hover-bg-color: #3c434c; 4 | --click-bg-color: #50565E; 5 | --active-bg-color: rgba(0, 122, 255, 0.7); 6 | --muted-bg-color: rgba(0, 122, 255, 0.3); 7 | --border-color: #3C434C; 8 | --color: #d8d9db; 9 | --active-color: #ececed; 10 | } 11 | 12 | body { 13 | background-color: rgb(0,0,0); 14 | color: rgba(19, 23, 34, 1); 15 | overflow: hidden; 16 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, 17 | Cantarell, "Helvetica Neue", sans-serif; 18 | } 19 | 20 | .handler { 21 | display: flex; 22 | flex-direction: column; 23 | position: relative; 24 | } 25 | 26 | .toolbox { 27 | position: absolute; 28 | z-index: 2000; 29 | display: flex; 30 | align-items: center; 31 | top: 25%; 32 | border: 2px solid var(--border-color); 33 | border-left: none; 34 | border-top-right-radius: 4px; 35 | border-bottom-right-radius: 4px; 36 | background-color: rgba(25, 27, 30, 0.5); 37 | flex-direction: column; 38 | } 39 | 40 | .toolbox-button { 41 | margin: 3px; 42 | border-radius: 4px; 43 | display: flex; 44 | background-color: transparent; 45 | } 46 | .toolbox-button:hover { 47 | background-color: rgba(80, 86, 94, 0.7); 48 | } 49 | .toolbox-button:active { 50 | background-color: rgba(90, 106, 104, 0.7); 51 | } 52 | 53 | .active-toolbox-button { 54 | background-color: var(--active-bg-color) !important; 55 | } 56 | .active-toolbox-button g { 57 | fill: var(--active-color); 58 | } 59 | 60 | .context-menu { 61 | position: absolute; 62 | z-index: 1000; 63 | background: rgb(50, 50, 50); 64 | color: var(--active-color); 65 | display: none; 66 | border-radius: 5px; 67 | padding: 3px 3px; 68 | font-size: 13px; 69 | cursor: default; 70 | } 71 | .context-menu-item { 72 | display: flex; 73 | align-items: center; 74 | justify-content: space-between; 75 | padding: 2px 10px; 76 | margin: 1px 0px; 77 | border-radius: 3px; 78 | } 79 | .context-menu-item:hover { 80 | background-color: var(--muted-bg-color); 81 | } 82 | 83 | .color-picker { 84 | max-width: 170px; 85 | background-color: var(--bg-color); 86 | position: absolute; 87 | z-index: 10000; 88 | display: none; 89 | flex-direction: column; 90 | align-items: center; 91 | border: 2px solid var(--border-color); 92 | border-radius: 8px; 93 | cursor: default; 94 | } 95 | 96 | 97 | /* topbar-related */ 98 | .topbar { 99 | background-color: var(--bg-color); 100 | border-bottom: 2px solid var(--border-color); 101 | display: flex; 102 | align-items: center; 103 | } 104 | 105 | .topbar-container { 106 | display: flex; 107 | align-items: center; 108 | flex-grow: 1; 109 | } 110 | 111 | .topbar-button { 112 | border: none; 113 | padding: 2px 5px; 114 | margin: 4px 10px; 115 | font-size: 13px; 116 | border-radius: 4px; 117 | color: var(--color); 118 | background-color: transparent; 119 | } 120 | .topbar-button:hover { 121 | background-color: var(--hover-bg-color) 122 | } 123 | 124 | .topbar-button:active { 125 | background-color: var(--click-bg-color); 126 | color: var(--active-color); 127 | font-weight: 500; 128 | } 129 | 130 | .switcher-button:active { 131 | background-color: var(--click-bg-color); 132 | color: var(--color); 133 | font-weight: normal; 134 | } 135 | 136 | .active-switcher-button { 137 | background-color: var(--active-bg-color) !important; 138 | color: var(--active-color) !important; 139 | font-weight: 500; 140 | } 141 | 142 | .topbar-textbox { 143 | margin: 0px 18px; 144 | font-size: 16px; 145 | color: var(--color); 146 | } 147 | 148 | .topbar-textbox-input { 149 | background-color: var(--bg-color); 150 | color: var(--color); 151 | border: 1px solid var(--color); 152 | } 153 | 154 | .topbar-menu { 155 | position: absolute; 156 | display: none; 157 | z-index: 10000; 158 | background-color: var(--bg-color); 159 | border-radius: 2px; 160 | border: 2px solid var(--border-color); 161 | border-top: none; 162 | align-items: flex-start; 163 | max-height: 80%; 164 | overflow-y: auto; 165 | } 166 | 167 | .topbar-separator { 168 | width: 1px; 169 | height: 20px; 170 | background-color: var(--border-color); 171 | } 172 | 173 | .searchbox { 174 | position: absolute; 175 | top: 0; 176 | bottom: 200px; 177 | left: 0; 178 | right: 0; 179 | margin: auto; 180 | width: 150px; 181 | height: 30px; 182 | padding: 5px; 183 | z-index: 1000; 184 | align-items: center; 185 | background-color: rgba(30 ,30, 30, 0.9); 186 | border: 2px solid var(--border-color); 187 | border-radius: 5px; 188 | display: flex; 189 | 190 | } 191 | .searchbox input { 192 | text-align: center; 193 | width: 100px; 194 | margin-left: 10px; 195 | background-color: var(--muted-bg-color); 196 | color: var(--active-color); 197 | font-size: 20px; 198 | border: none; 199 | outline: none; 200 | border-radius: 2px; 201 | } 202 | 203 | .spinner { 204 | width: 30px; 205 | height: 30px; 206 | border: 4px solid rgba(255, 255, 255, 0.6); 207 | border-top: 4px solid var(--active-bg-color); 208 | border-radius: 50%; 209 | position: absolute; 210 | top: 50%; 211 | left: 50%; 212 | z-index: 1000; 213 | transform: translate(-50%, -50%); 214 | display: none; 215 | } 216 | 217 | .legend { 218 | position: absolute; 219 | z-index: 3000; 220 | pointer-events: none; 221 | top: 10px; 222 | left: 10px; 223 | display: none; 224 | flex-direction: column; 225 | } 226 | .series-container { 227 | display: flex; 228 | flex-direction: column; 229 | pointer-events: auto; 230 | overflow-y: auto; 231 | max-height: 80vh; 232 | } 233 | .series-container::-webkit-scrollbar { 234 | width: 0px; 235 | } 236 | .legend-toggle-switch { 237 | border-radius: 4px; 238 | margin-left: 10px; 239 | pointer-events: auto; 240 | } 241 | .legend-toggle-switch:hover { 242 | cursor: pointer; 243 | background-color: rgba(50, 50, 50, 0.5); 244 | } -------------------------------------------------------------------------------- /lightweight_charts/table.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from typing import Union, Optional, Callable 4 | 5 | from .util import jbool, Pane, NUM 6 | 7 | 8 | class Section(Pane): 9 | def __init__(self, table, section_type): 10 | super().__init__(table.win) 11 | self._table = table 12 | self.type = section_type 13 | 14 | def __call__(self, number_of_text_boxes: int, func: Optional[Callable] = None): 15 | if func is not None: 16 | self.win.handlers[self.id] = lambda boxId: func(self._table, int(boxId)) 17 | self.run_script(f''' 18 | {self._table.id}.makeSection("{self.id}", "{self.type}", {number_of_text_boxes}, {"true" if func else ""}) 19 | ''') 20 | 21 | def __setitem__(self, key, value): 22 | self.run_script(f'{self._table.id}.{self.type}[{key}].innerText = "{value}"') 23 | 24 | 25 | class Row(dict): 26 | def __init__(self, table, id, items): 27 | super().__init__() 28 | self.run_script = table.run_script 29 | self._table = table 30 | self.id = id 31 | self.meta = {} 32 | self.run_script(f'{self._table.id}.newRow("{self.id}", {jbool(table.return_clicked_cells)})') 33 | for key, val in items.items(): 34 | self[key] = val 35 | 36 | def __setitem__(self, column, value): 37 | if isinstance(column, tuple): 38 | [self.__setitem__(col, val) for col, val in zip(column, value)] 39 | return 40 | original_value = value 41 | if column in self._table._formatters: 42 | value = self._table._formatters[column].replace(self._table.VALUE, str(value)) 43 | self.run_script(f'{self._table.id}.updateCell("{self.id}", "{column}", "{value}")') 44 | return super().__setitem__(column, original_value) 45 | 46 | def background_color(self, column, color): self._style('backgroundColor', column, color) 47 | 48 | def text_color(self, column, color): self._style('textColor', column, color) 49 | 50 | def _style(self, style, column, arg): 51 | self.run_script(f"{self._table.id}.styleCell({self.id}, '{column}', '{style}', '{arg}')") 52 | 53 | def delete(self): 54 | self.run_script(f"{self._table.id}.deleteRow('{self.id}')") 55 | self._table.pop(self.id) 56 | 57 | 58 | class Table(Pane, dict): 59 | VALUE = 'CELL__~__VALUE__~__PLACEHOLDER' 60 | 61 | def __init__( 62 | self, 63 | window, 64 | width: NUM, 65 | height: NUM, 66 | headings: tuple, 67 | widths: Optional[tuple] = None, 68 | alignments: Optional[tuple] = None, 69 | position='left', 70 | draggable: bool = False, 71 | background_color: str = '#121417', 72 | border_color: str = 'rgb(70, 70, 70)', 73 | border_width: int = 1, 74 | heading_text_colors: Optional[tuple] = None, 75 | heading_background_colors: Optional[tuple] = None, 76 | return_clicked_cells: bool = False, 77 | func: Optional[Callable] = None 78 | ): 79 | dict.__init__(self) 80 | Pane.__init__(self, window) 81 | self._formatters = {} 82 | self.headings = headings 83 | self.is_shown = True 84 | def wrapper(rId, cId=None): 85 | if return_clicked_cells: 86 | func(self[rId], cId) 87 | else: 88 | func(self[rId]) 89 | 90 | async def async_wrapper(rId, cId=None): 91 | if return_clicked_cells: 92 | await func(self[rId], cId) 93 | else: 94 | await func(self[rId]) 95 | 96 | self.win.handlers[self.id] = async_wrapper if asyncio.iscoroutinefunction(func) else wrapper 97 | self.return_clicked_cells = return_clicked_cells 98 | 99 | self.run_script(f''' 100 | {self.id} = new Lib.Table( 101 | {width}, 102 | {height}, 103 | {list(headings)}, 104 | {list(widths) if widths else []}, 105 | {list(alignments) if alignments else []}, 106 | '{position}', 107 | {jbool(draggable)}, 108 | '{background_color}', 109 | '{border_color}', 110 | {border_width}, 111 | {list(heading_text_colors) if heading_text_colors else []}, 112 | {list(heading_background_colors) if heading_background_colors else []} 113 | )''') 114 | self.run_script(f'{self.id}.callbackName = "{self.id}"') if func else None 115 | self.footer = Section(self, 'footer') 116 | self.header = Section(self, 'header') 117 | 118 | def new_row(self, *values, id=None) -> Row: 119 | row_id = random.randint(0, 99_999_999) if not id else id 120 | self[row_id] = Row(self, row_id, {heading: item for heading, item in zip(self.headings, values)}) 121 | return self[row_id] 122 | 123 | def clear(self): self.run_script(f"{self.id}.clearRows()"), super().clear() 124 | 125 | def get(self, __key: Union[int, str]) -> Row: return super().get(int(__key)) 126 | 127 | def __getitem__(self, item): return super().__getitem__(int(item)) 128 | 129 | def format(self, column: str, format_str: str): self._formatters[column] = format_str 130 | 131 | def resize(self, width: NUM, height: NUM): self.run_script(f'{self.id}.reSize({width}, {height})') 132 | 133 | def visible(self, visible: bool): 134 | self.is_shown = visible 135 | self.run_script(f""" 136 | {self.id}._div.style.display = '{'flex' if visible else 'none'}' 137 | {self.id}._div.{'add' if visible else 'remove'}EventListener('mousedown', {self.id}.onMouseDown) 138 | """) 139 | -------------------------------------------------------------------------------- /lightweight_charts/toolbox.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class ToolBox: 5 | def __init__(self, chart): 6 | self.run_script = chart.run_script 7 | self.id = chart.id 8 | self._save_under = None 9 | self.drawings = {} 10 | chart.win.handlers[f'save_drawings{self.id}'] = self._save_drawings 11 | self.run_script(f'{self.id}.createToolBox()') 12 | 13 | def save_drawings_under(self, widget: 'Widget'): 14 | """ 15 | Drawings made on charts will be saved under the widget given. eg `chart.toolbox.save_drawings_under(chart.topbar['symbol'])`. 16 | """ 17 | self._save_under = widget 18 | 19 | def load_drawings(self, tag: str): 20 | """ 21 | Loads and displays the drawings on the chart stored under the tag given. 22 | """ 23 | if not self.drawings.get(tag): 24 | return 25 | self.run_script(f'if ({self.id}.toolBox) {self.id}.toolBox.loadDrawings({json.dumps(self.drawings[tag])})') 26 | 27 | def import_drawings(self, file_path): 28 | """ 29 | Imports a list of drawings stored at the given file path. 30 | """ 31 | with open(file_path, 'r') as f: 32 | json_data = json.load(f) 33 | self.drawings = json_data 34 | 35 | def export_drawings(self, file_path): 36 | """ 37 | Exports the current list of drawings to the given file path. 38 | """ 39 | with open(file_path, 'w+') as f: 40 | json.dump(self.drawings, f, indent=4) 41 | 42 | def _save_drawings(self, drawings): 43 | if not self._save_under: 44 | return 45 | self.drawings[self._save_under.value] = json.loads(drawings) 46 | -------------------------------------------------------------------------------- /lightweight_charts/topbar.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Dict, Literal 3 | 4 | from .util import jbool, Pane 5 | 6 | 7 | ALIGN = Literal['left', 'right'] 8 | 9 | 10 | class Widget(Pane): 11 | def __init__(self, topbar, value, func: callable = None, convert_boolean=False): 12 | super().__init__(topbar.win) 13 | self.value = value 14 | 15 | def wrapper(v): 16 | if convert_boolean: 17 | self.value = False if v == 'false' else True 18 | else: 19 | self.value = v 20 | func(topbar._chart) 21 | 22 | async def async_wrapper(v): 23 | self.value = v 24 | await func(topbar._chart) 25 | 26 | self.win.handlers[self.id] = async_wrapper if asyncio.iscoroutinefunction(func) else wrapper 27 | 28 | 29 | class TextWidget(Widget): 30 | def __init__(self, topbar, initial_text, align, func): 31 | super().__init__(topbar, value=initial_text, func=func) 32 | 33 | callback_name = f'"{self.id}"' if func else '' 34 | 35 | self.run_script(f'{self.id} = {topbar.id}.makeTextBoxWidget("{initial_text}", "{align}", {callback_name})') 36 | 37 | def set(self, string): 38 | self.value = string 39 | self.run_script(f'{self.id}.innerText = "{string}"') 40 | 41 | 42 | class SwitcherWidget(Widget): 43 | def __init__(self, topbar, options, default, align, func): 44 | super().__init__(topbar, value=default, func=func) 45 | self.options = list(options) 46 | self.run_script(f'{self.id} = {topbar.id}.makeSwitcher({self.options}, "{default}", "{self.id}", "{align}")') 47 | 48 | def set(self, option): 49 | if option not in self.options: 50 | raise ValueError(f"option '{option}' does not exist within {self.options}.") 51 | self.run_script(f'{self.id}.onItemClicked("{option}")') 52 | self.value = option 53 | 54 | 55 | class MenuWidget(Widget): 56 | def __init__(self, topbar, options, default, separator, align, func): 57 | super().__init__(topbar, value=default, func=func) 58 | self.options = list(options) 59 | self.run_script(f''' 60 | {self.id} = {topbar.id}.makeMenu({list(options)}, "{default}", {jbool(separator)}, "{self.id}", "{align}") 61 | ''') 62 | 63 | # TODO this will probably need to be fixed 64 | def set(self, option): 65 | if option not in self.options: 66 | raise ValueError(f"Option {option} not in menu options ({self.options})") 67 | self.value = option 68 | self.run_script(f''' 69 | {self.id}._clickHandler("{option}") 70 | ''') 71 | # self.win.handlers[self.id](option) 72 | 73 | def update_items(self, *items: str): 74 | self.options = list(items) 75 | self.run_script(f'{self.id}.updateMenuItems({self.options})') 76 | 77 | 78 | class ButtonWidget(Widget): 79 | def __init__(self, topbar, button, separator, align, toggle, func): 80 | super().__init__(topbar, value=False, func=func, convert_boolean=toggle) 81 | self.run_script( 82 | f'{self.id} = {topbar.id}.makeButton("{button}", "{self.id}", {jbool(separator)}, true, "{align}", {jbool(toggle)})') 83 | 84 | def set(self, string): 85 | # self.value = string 86 | self.run_script(f'{self.id}.elem.innerText = "{string}"') 87 | 88 | 89 | class TopBar(Pane): 90 | def __init__(self, chart): 91 | super().__init__(chart.win) 92 | self._chart = chart 93 | self._widgets: Dict[str, Widget] = {} 94 | self._created = False 95 | 96 | def _create(self): 97 | if self._created: 98 | return 99 | self._created = True 100 | self.run_script(f'{self.id} = {self._chart.id}.createTopBar()') 101 | 102 | def __getitem__(self, item): 103 | if widget := self._widgets.get(item): 104 | return widget 105 | raise KeyError(f'Topbar widget "{item}" not found.') 106 | 107 | def get(self, widget_name): 108 | return self._widgets.get(widget_name) 109 | 110 | def switcher(self, name, options: tuple, default: str = None, 111 | align: ALIGN = 'left', func: callable = None): 112 | self._create() 113 | self._widgets[name] = SwitcherWidget(self, options, default if default else options[0], align, func) 114 | 115 | def menu(self, name, options: tuple, default: str = None, separator: bool = True, 116 | align: ALIGN = 'left', func: callable = None): 117 | self._create() 118 | self._widgets[name] = MenuWidget(self, options, default if default else options[0], separator, align, func) 119 | 120 | def textbox(self, name: str, initial_text: str = '', 121 | align: ALIGN = 'left', func: callable = None): 122 | self._create() 123 | self._widgets[name] = TextWidget(self, initial_text, align, func) 124 | 125 | def button(self, name, button_text: str, separator: bool = True, 126 | align: ALIGN = 'left', toggle: bool = False, func: callable = None): 127 | self._create() 128 | self._widgets[name] = ButtonWidget(self, button_text, separator, align, toggle, func) 129 | -------------------------------------------------------------------------------- /lightweight_charts/util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from datetime import datetime 4 | from random import choices 5 | from typing import Literal, Union 6 | from numpy import isin 7 | import pandas as pd 8 | 9 | 10 | class Pane: 11 | def __init__(self, window): 12 | from lightweight_charts import Window 13 | self.win: Window = window 14 | self.run_script = window.run_script 15 | self.bulk_run = window.bulk_run 16 | if hasattr(self, 'id'): 17 | return 18 | self.id = Window._id_gen.generate() 19 | 20 | 21 | class IDGen(list): 22 | ascii = 'abcdefghijklmnopqrstuvwxyz' 23 | 24 | def generate(self) -> str: 25 | var = ''.join(choices(self.ascii, k=8)) 26 | if var not in self: 27 | self.append(var) 28 | return f'window.{var}' 29 | self.generate() 30 | 31 | 32 | def parse_event_message(window, string): 33 | name, args = string.split('_~_') 34 | args = args.split(';;;') 35 | func = window.handlers[name] 36 | return func, args 37 | 38 | 39 | def js_data(data: Union[pd.DataFrame, pd.Series]): 40 | if isinstance(data, pd.DataFrame): 41 | d = data.to_dict(orient='records') 42 | filtered_records = [{k: v for k, v in record.items() if v is not None and not pd.isna(v)} for record in d] 43 | else: 44 | d = data.to_dict() 45 | filtered_records = {k: v for k, v in d.items()} 46 | return json.dumps(filtered_records, indent=2) 47 | 48 | 49 | def snake_to_camel(s: str): 50 | components = s.split('_') 51 | return components[0] + ''.join(x.title() for x in components[1:]) 52 | 53 | def js_json(d: dict): 54 | filtered_dict = {} 55 | for key, val in d.items(): 56 | if key in ('self') or val in (None,): 57 | continue 58 | if '_' in key: 59 | key = snake_to_camel(key) 60 | filtered_dict[key] = val 61 | return f"JSON.parse('{json.dumps(filtered_dict)}')" 62 | 63 | 64 | def jbool(b: bool): return 'true' if b is True else 'false' if b is False else None 65 | 66 | 67 | LINE_STYLE = Literal['solid', 'dotted', 'dashed', 'large_dashed', 'sparse_dotted'] 68 | 69 | MARKER_POSITION = Literal['above', 'below', 'inside'] 70 | 71 | MARKER_SHAPE = Literal['arrow_up', 'arrow_down', 'circle', 'square'] 72 | 73 | CROSSHAIR_MODE = Literal['normal', 'magnet', 'hidden'] 74 | 75 | PRICE_SCALE_MODE = Literal['normal', 'logarithmic', 'percentage', 'index100'] 76 | 77 | TIME = Union[datetime, pd.Timestamp, str, float] 78 | 79 | NUM = Union[float, int] 80 | 81 | FLOAT = Literal['left', 'right', 'top', 'bottom'] 82 | 83 | 84 | def as_enum(value, string_types): 85 | types = string_types.__args__ 86 | return -1 if value not in types else types.index(value) 87 | 88 | 89 | def marker_shape(shape: MARKER_SHAPE): 90 | return { 91 | 'arrow_up': 'arrowUp', 92 | 'arrow_down': 'arrowDown', 93 | }.get(shape) or shape 94 | 95 | 96 | def marker_position(p: MARKER_POSITION): 97 | return { 98 | 'above': 'aboveBar', 99 | 'below': 'belowBar', 100 | 'inside': 'inBar', 101 | }.get(p) 102 | 103 | 104 | class Emitter: 105 | def __init__(self): 106 | self._callable = None 107 | 108 | def __iadd__(self, other): 109 | self._callable = other 110 | return self 111 | 112 | def _emit(self, *args): 113 | if self._callable: 114 | if asyncio.iscoroutinefunction(self._callable): 115 | asyncio.create_task(self._callable(*args)) 116 | else: 117 | self._callable(*args) 118 | 119 | 120 | class JSEmitter: 121 | def __init__(self, chart, name, on_iadd, wrapper=None): 122 | self._on_iadd = on_iadd 123 | self._chart = chart 124 | self._name = name 125 | self._wrapper = wrapper 126 | 127 | def __iadd__(self, other): 128 | def final_wrapper(*arg): 129 | other(self._chart, *arg) if not self._wrapper else self._wrapper(other, self._chart, *arg) 130 | async def final_async_wrapper(*arg): 131 | await other(self._chart, *arg) if not self._wrapper else await self._wrapper(other, self._chart, *arg) 132 | 133 | self._chart.win.handlers[self._name] = final_async_wrapper if asyncio.iscoroutinefunction(other) else final_wrapper 134 | self._on_iadd(other) 135 | return self 136 | 137 | 138 | class Events: 139 | def __init__(self, chart): 140 | self.new_bar = Emitter() 141 | self.search = JSEmitter(chart, f'search{chart.id}', 142 | lambda o: chart.run_script(f''' 143 | Lib.Handler.makeSpinner({chart.id}) 144 | {chart.id}.search = Lib.Handler.makeSearchBox({chart.id}) 145 | ''') 146 | ) 147 | salt = chart.id[chart.id.index('.')+1:] 148 | self.range_change = JSEmitter(chart, f'range_change{salt}', 149 | lambda o: chart.run_script(f''' 150 | let checkLogicalRange{salt} = (logical) => {{ 151 | {chart.id}.chart.timeScale().unsubscribeVisibleLogicalRangeChange(checkLogicalRange{salt}) 152 | 153 | let barsInfo = {chart.id}.series.barsInLogicalRange(logical) 154 | if (barsInfo) window.callbackFunction(`range_change{salt}_~_${{barsInfo.barsBefore}};;;${{barsInfo.barsAfter}}`) 155 | 156 | setTimeout(() => {chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange{salt}), 50) 157 | }} 158 | {chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange{salt}) 159 | '''), 160 | wrapper=lambda o, c, *arg: o(c, *[float(a) for a in arg]) 161 | ) 162 | 163 | self.click = JSEmitter(chart, f'subscribe_click{salt}', 164 | lambda o: chart.run_script(f''' 165 | let clickHandler{salt} = (param) => {{ 166 | if (!param.point) return; 167 | const time = {chart.id}.chart.timeScale().coordinateToTime(param.point.x) 168 | const price = {chart.id}.series.coordinateToPrice(param.point.y); 169 | window.callbackFunction(`subscribe_click{salt}_~_${{time}};;;${{price}}`) 170 | }} 171 | {chart.id}.chart.subscribeClick(clickHandler{salt}) 172 | '''), 173 | wrapper=lambda func, c, *args: func(c, *[float(a) if a != 'null' else None for a in args]) 174 | ) 175 | 176 | class BulkRunScript: 177 | def __init__(self, script_func): 178 | self.enabled = False 179 | self.scripts = [] 180 | self.script_func = script_func 181 | 182 | def __enter__(self): 183 | self.enabled = True 184 | 185 | def __exit__(self, *args): 186 | self.enabled = False 187 | self.script_func('\n'.join(self.scripts)) 188 | self.scripts = [] 189 | 190 | def add_script(self, script): 191 | self.scripts.append(script) 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lwc-plugin-trend-line", 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite --config src/vite.config.js", 6 | "build": "./build.sh" 7 | }, 8 | "devDependencies": { 9 | "@rollup/plugin-terser": "^0.4.4", 10 | "@rollup/plugin-typescript": "^11.1.6", 11 | "rollup": "^4.13.0", 12 | "typescript": "^5.4.3", 13 | "vite": "^4.3.1" 14 | }, 15 | "dependencies": { 16 | "dts-bundle-generator": "^8.0.1", 17 | "fancy-canvas": "^2.1.0", 18 | "lightweight-charts": "^4.1.0-rc2", 19 | "tslib": "^2.6.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | export default [ 5 | { 6 | input: 'src/index.ts', 7 | output: { 8 | file: 'dist/bundle.js', 9 | format: 'iife', 10 | name: 'Lib', 11 | globals: { 12 | 'lightweight-charts': 'LightweightCharts' 13 | }, 14 | }, 15 | external: ['lightweight-charts'], 16 | plugins: [ 17 | typescript(), 18 | terser(), 19 | ], 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open('README.md', 'r', encoding='utf-8') as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name='lightweight_charts', 8 | version='2.1', 9 | packages=find_packages(), 10 | python_requires='>=3.8', 11 | install_requires=[ 12 | 'pandas', 13 | 'pywebview>=5.0.5', 14 | ], 15 | package_data={ 16 | 'lightweight_charts': ['js/*'], 17 | }, 18 | author='louisnw', 19 | license='MIT', 20 | description="Python framework for TradingView's Lightweight Charts JavaScript library.", 21 | long_description=long_description, 22 | long_description_content_type='text/markdown', 23 | url='https://github.com/louisnw01/lightweight-charts-python', 24 | ) 25 | -------------------------------------------------------------------------------- /src/box/box.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MouseEventParams, 3 | } from 'lightweight-charts'; 4 | 5 | import { Point } from '../drawing/data-source'; 6 | import { InteractionState } from '../drawing/drawing'; 7 | import { DrawingOptions, defaultOptions } from '../drawing/options'; 8 | import { BoxPaneView } from './pane-view'; 9 | import { TwoPointDrawing } from '../drawing/two-point-drawing'; 10 | 11 | 12 | export interface BoxOptions extends DrawingOptions { 13 | fillEnabled: boolean; 14 | fillColor: string; 15 | } 16 | 17 | const defaultBoxOptions = { 18 | fillEnabled: true, 19 | fillColor: 'rgba(255, 255, 255, 0.2)', 20 | ...defaultOptions 21 | } 22 | 23 | 24 | export class Box extends TwoPointDrawing { 25 | _type = "Box"; 26 | 27 | constructor( 28 | p1: Point, 29 | p2: Point, 30 | options?: Partial 31 | ) { 32 | super(p1, p2, options); 33 | this._options = { 34 | ...defaultBoxOptions, 35 | ...options, 36 | } 37 | this._paneViews = [new BoxPaneView(this)]; 38 | } 39 | 40 | // autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null { 41 | // const p1Index = this._pointIndex(this._p1); 42 | // const p2Index = this._pointIndex(this._p2); 43 | // if (p1Index === null || p2Index === null) return null; 44 | // if (endTimePoint < p1Index || startTimePoint > p2Index) return null; 45 | // return { 46 | // priceRange: { 47 | // minValue: this._minPrice, 48 | // maxValue: this._maxPrice, 49 | // }, 50 | // }; 51 | // } 52 | 53 | _moveToState(state: InteractionState) { 54 | switch(state) { 55 | case InteractionState.NONE: 56 | document.body.style.cursor = "default"; 57 | this._hovered = false; 58 | this._unsubscribe("mousedown", this._handleMouseDownInteraction); 59 | break; 60 | 61 | case InteractionState.HOVERING: 62 | document.body.style.cursor = "pointer"; 63 | this._hovered = true; 64 | this._unsubscribe("mouseup", this._handleMouseUpInteraction); 65 | this._subscribe("mousedown", this._handleMouseDownInteraction) 66 | this.chart.applyOptions({handleScroll: true}); 67 | break; 68 | 69 | case InteractionState.DRAGGINGP1: 70 | case InteractionState.DRAGGINGP2: 71 | case InteractionState.DRAGGINGP3: 72 | case InteractionState.DRAGGINGP4: 73 | case InteractionState.DRAGGING: 74 | document.body.style.cursor = "grabbing"; 75 | document.body.addEventListener("mouseup", this._handleMouseUpInteraction); 76 | this._subscribe("mouseup", this._handleMouseUpInteraction); 77 | this.chart.applyOptions({handleScroll: false}); 78 | break; 79 | } 80 | this._state = state; 81 | } 82 | 83 | _onDrag(diff: any) { 84 | if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP1) { 85 | this._addDiffToPoint(this.p1, diff.logical, diff.price); 86 | } 87 | if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP2) { 88 | this._addDiffToPoint(this.p2, diff.logical, diff.price); 89 | } 90 | if (this._state != InteractionState.DRAGGING) { 91 | if (this._state == InteractionState.DRAGGINGP3) { 92 | this._addDiffToPoint(this.p1, diff.logical, 0); 93 | this._addDiffToPoint(this.p2, 0, diff.price); 94 | } 95 | if (this._state == InteractionState.DRAGGINGP4) { 96 | this._addDiffToPoint(this.p1, 0, diff.price); 97 | this._addDiffToPoint(this.p2, diff.logical, 0); 98 | } 99 | } 100 | } 101 | 102 | protected _onMouseDown() { 103 | this._startDragPoint = null; 104 | const hoverPoint = this._latestHoverPoint; 105 | const p1 = this._paneViews[0]._p1; 106 | const p2 = this._paneViews[0]._p2; 107 | 108 | if (!p1.x || !p2.x || !p1.y || !p2.y) return this._moveToState(InteractionState.DRAGGING); 109 | 110 | const tolerance = 10; 111 | if (Math.abs(hoverPoint.x-p1.x) < tolerance && Math.abs(hoverPoint.y-p1.y) < tolerance) { 112 | this._moveToState(InteractionState.DRAGGINGP1) 113 | } 114 | else if (Math.abs(hoverPoint.x-p2.x) < tolerance && Math.abs(hoverPoint.y-p2.y) < tolerance) { 115 | this._moveToState(InteractionState.DRAGGINGP2) 116 | } 117 | else if (Math.abs(hoverPoint.x-p1.x) < tolerance && Math.abs(hoverPoint.y-p2.y) < tolerance) { 118 | this._moveToState(InteractionState.DRAGGINGP3) 119 | } 120 | else if (Math.abs(hoverPoint.x-p2.x) < tolerance && Math.abs(hoverPoint.y-p1.y) < tolerance) { 121 | this._moveToState(InteractionState.DRAGGINGP4) 122 | } 123 | else { 124 | this._moveToState(InteractionState.DRAGGING); 125 | } 126 | } 127 | 128 | protected _mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) { 129 | if (!param.point) return false; 130 | 131 | const x1 = this._paneViews[0]._p1.x; 132 | const y1 = this._paneViews[0]._p1.y; 133 | const x2 = this._paneViews[0]._p2.x; 134 | const y2 = this._paneViews[0]._p2.y; 135 | if (!x1 || !x2 || !y1 || !y2 ) return false; 136 | 137 | const mouseX = param.point.x; 138 | const mouseY = param.point.y; 139 | 140 | const mainX = Math.min(x1, x2); 141 | const mainY = Math.min(y1, y2); 142 | 143 | const width = Math.abs(x1-x2); 144 | const height = Math.abs(y1-y2); 145 | 146 | const halfTolerance = tolerance/2; 147 | 148 | return mouseX > mainX-halfTolerance && mouseX < mainX+width+halfTolerance && 149 | mouseY > mainY-halfTolerance && mouseY < mainY+height+halfTolerance; 150 | } 151 | } 152 | 153 | 154 | -------------------------------------------------------------------------------- /src/box/pane-renderer.ts: -------------------------------------------------------------------------------- 1 | import { ViewPoint } from "../drawing/pane-view"; 2 | import { CanvasRenderingTarget2D } from "fancy-canvas"; 3 | import { TwoPointDrawingPaneRenderer } from "../drawing/pane-renderer"; 4 | import { BoxOptions } from "./box"; 5 | import { setLineStyle } from "../helpers/canvas-rendering"; 6 | 7 | export class BoxPaneRenderer extends TwoPointDrawingPaneRenderer { 8 | declare _options: BoxOptions; 9 | 10 | constructor(p1: ViewPoint, p2: ViewPoint, options: BoxOptions, showCircles: boolean) { 11 | super(p1, p2, options, showCircles) 12 | } 13 | 14 | draw(target: CanvasRenderingTarget2D) { 15 | target.useBitmapCoordinateSpace(scope => { 16 | 17 | const ctx = scope.context; 18 | 19 | const scaled = this._getScaledCoordinates(scope); 20 | 21 | if (!scaled) return; 22 | 23 | ctx.lineWidth = this._options.width; 24 | ctx.strokeStyle = this._options.lineColor; 25 | setLineStyle(ctx, this._options.lineStyle) 26 | ctx.fillStyle = this._options.fillColor; 27 | 28 | const mainX = Math.min(scaled.x1, scaled.x2); 29 | const mainY = Math.min(scaled.y1, scaled.y2); 30 | const width = Math.abs(scaled.x1-scaled.x2); 31 | const height = Math.abs(scaled.y1-scaled.y2); 32 | 33 | ctx.strokeRect(mainX, mainY, width, height); 34 | ctx.fillRect(mainX, mainY, width, height); 35 | 36 | if (!this._hovered) return; 37 | this._drawEndCircle(scope, mainX, mainY); 38 | this._drawEndCircle(scope, mainX+width, mainY); 39 | this._drawEndCircle(scope, mainX+width, mainY+height); 40 | this._drawEndCircle(scope, mainX, mainY+height); 41 | 42 | }); 43 | } 44 | } -------------------------------------------------------------------------------- /src/box/pane-view.ts: -------------------------------------------------------------------------------- 1 | import { Box, BoxOptions } from './box'; 2 | import { BoxPaneRenderer } from './pane-renderer'; 3 | import { TwoPointDrawingPaneView } from '../drawing/pane-view'; 4 | 5 | export class BoxPaneView extends TwoPointDrawingPaneView { 6 | constructor(source: Box) { 7 | super(source) 8 | } 9 | 10 | renderer() { 11 | return new BoxPaneRenderer( 12 | this._p1, 13 | this._p2, 14 | this._source._options as BoxOptions, 15 | this._source.hovered, 16 | ); 17 | } 18 | } -------------------------------------------------------------------------------- /src/context-menu/color-picker.ts: -------------------------------------------------------------------------------- 1 | import { Drawing } from "../drawing/drawing"; 2 | import { DrawingOptions } from "../drawing/options"; 3 | import { GlobalParams } from "../general/global-params"; 4 | 5 | declare const window: GlobalParams; 6 | 7 | 8 | export class ColorPicker { 9 | private static readonly colors = [ 10 | '#EBB0B0','#E9CEA1','#E5DF80','#ADEB97','#A3C3EA','#D8BDED', 11 | '#E15F5D','#E1B45F','#E2D947','#4BE940','#639AE1','#D7A0E8', 12 | '#E42C2A','#E49D30','#E7D827','#3CFF0A','#3275E4','#B06CE3', 13 | '#F3000D','#EE9A14','#F1DA13','#2DFC0F','#1562EE','#BB00EF', 14 | '#B50911','#E3860E','#D2BD11','#48DE0E','#1455B4','#6E009F', 15 | '#7C1713','#B76B12','#8D7A13','#479C12','#165579','#51007E', 16 | ] 17 | 18 | public _div: HTMLDivElement; 19 | private saveDrawings: Function; 20 | 21 | private opacity: number = 0; 22 | private _opacitySlider: HTMLInputElement; 23 | private _opacityLabel: HTMLDivElement; 24 | private rgba: number[] | undefined; 25 | 26 | constructor(saveDrawings: Function, 27 | private colorOption: keyof DrawingOptions, 28 | ) { 29 | this.saveDrawings = saveDrawings 30 | 31 | this._div = document.createElement('div'); 32 | this._div.classList.add('color-picker'); 33 | 34 | let colorPicker = document.createElement('div') 35 | colorPicker.style.margin = '10px' 36 | colorPicker.style.display = 'flex' 37 | colorPicker.style.flexWrap = 'wrap' 38 | 39 | ColorPicker.colors.forEach((color) => colorPicker.appendChild(this.makeColorBox(color))) 40 | 41 | let separator = document.createElement('div') 42 | separator.style.backgroundColor = window.pane.borderColor 43 | separator.style.height = '1px' 44 | separator.style.width = '130px' 45 | 46 | let opacity = document.createElement('div') 47 | opacity.style.margin = '10px' 48 | 49 | let opacityText = document.createElement('div') 50 | opacityText.style.color = 'lightgray' 51 | opacityText.style.fontSize = '12px' 52 | opacityText.innerText = 'Opacity' 53 | 54 | this._opacityLabel = document.createElement('div') 55 | this._opacityLabel.style.color = 'lightgray' 56 | this._opacityLabel.style.fontSize = '12px' 57 | 58 | this._opacitySlider = document.createElement('input') 59 | this._opacitySlider.type = 'range' 60 | this._opacitySlider.value = (this.opacity*100).toString(); 61 | this._opacityLabel.innerText = this._opacitySlider.value+'%' 62 | this._opacitySlider.oninput = () => { 63 | this._opacityLabel.innerText = this._opacitySlider.value+'%' 64 | this.opacity = parseInt(this._opacitySlider.value)/100 65 | this.updateColor() 66 | } 67 | 68 | opacity.appendChild(opacityText) 69 | opacity.appendChild(this._opacitySlider) 70 | opacity.appendChild(this._opacityLabel) 71 | 72 | this._div.appendChild(colorPicker) 73 | this._div.appendChild(separator) 74 | this._div.appendChild(opacity) 75 | window.containerDiv.appendChild(this._div) 76 | 77 | } 78 | 79 | private _updateOpacitySlider() { 80 | this._opacitySlider.value = (this.opacity*100).toString(); 81 | this._opacityLabel.innerText = this._opacitySlider.value+'%'; 82 | } 83 | 84 | makeColorBox(color: string) { 85 | const box = document.createElement('div') 86 | box.style.width = '18px' 87 | box.style.height = '18px' 88 | box.style.borderRadius = '3px' 89 | box.style.margin = '3px' 90 | box.style.boxSizing = 'border-box' 91 | box.style.backgroundColor = color 92 | 93 | box.addEventListener('mouseover', () => box.style.border = '2px solid lightgray') 94 | box.addEventListener('mouseout', () => box.style.border = 'none') 95 | 96 | const rgba = ColorPicker.extractRGBA(color) 97 | box.addEventListener('click', () => { 98 | this.rgba = rgba; 99 | this.updateColor(); 100 | }) 101 | return box 102 | } 103 | 104 | private static extractRGBA(anyColor: string) { 105 | const dummyElem = document.createElement('div'); 106 | dummyElem.style.color = anyColor; 107 | document.body.appendChild(dummyElem); 108 | const computedColor = getComputedStyle(dummyElem).color; 109 | document.body.removeChild(dummyElem); 110 | const rgb = computedColor.match(/\d+/g)?.map(Number); 111 | if (!rgb) return []; 112 | let isRgba = computedColor.includes('rgba'); 113 | let opacity = isRgba ? parseFloat(computedColor.split(',')[3]) : 1 114 | return [rgb[0], rgb[1], rgb[2], opacity] 115 | } 116 | 117 | updateColor() { 118 | if (!Drawing.lastHoveredObject || !this.rgba) return; 119 | const oColor = `rgba(${this.rgba[0]}, ${this.rgba[1]}, ${this.rgba[2]}, ${this.opacity})` 120 | Drawing.lastHoveredObject.applyOptions({[this.colorOption]: oColor}) 121 | this.saveDrawings() 122 | } 123 | openMenu(rect: DOMRect) { 124 | if (!Drawing.lastHoveredObject) return; 125 | this.rgba = ColorPicker.extractRGBA( 126 | Drawing.lastHoveredObject._options[this.colorOption] as string 127 | ) 128 | this.opacity = this.rgba[3]; 129 | this._updateOpacitySlider(); 130 | this._div.style.top = (rect.top-30)+'px' 131 | this._div.style.left = rect.right+'px' 132 | this._div.style.display = 'flex' 133 | 134 | setTimeout(() => document.addEventListener('mousedown', (event: MouseEvent) => { 135 | if (!this._div.contains(event.target as Node)) { 136 | this.closeMenu() 137 | } 138 | }), 10) 139 | } 140 | closeMenu() { 141 | document.body.removeEventListener('click', this.closeMenu) 142 | this._div.style.display = 'none' 143 | } 144 | } -------------------------------------------------------------------------------- /src/context-menu/context-menu.ts: -------------------------------------------------------------------------------- 1 | import { Drawing } from "../drawing/drawing"; 2 | import { DrawingTool } from "../drawing/drawing-tool"; 3 | import { DrawingOptions } from "../drawing/options"; 4 | import { GlobalParams } from "../general/global-params"; 5 | import { ColorPicker } from "./color-picker"; 6 | import { StylePicker } from "./style-picker"; 7 | 8 | 9 | export function camelToTitle(inputString: string) { 10 | const result = []; 11 | for (const c of inputString) { 12 | if (result.length == 0) { 13 | result.push(c.toUpperCase()); 14 | } else if (c == c.toUpperCase()) { 15 | result.push(' '+c); 16 | } else result.push(c); 17 | } 18 | return result.join(''); 19 | } 20 | 21 | interface Item { 22 | elem: HTMLSpanElement; 23 | action: Function; 24 | closeAction: Function | null; 25 | } 26 | 27 | declare const window: GlobalParams; 28 | 29 | 30 | export class ContextMenu { 31 | private div: HTMLDivElement 32 | private hoverItem: Item | null; 33 | private items: HTMLElement[] = [] 34 | 35 | constructor( 36 | private saveDrawings: Function, 37 | private drawingTool: DrawingTool, 38 | ) { 39 | this._onRightClick = this._onRightClick.bind(this); 40 | this.div = document.createElement('div'); 41 | this.div.classList.add('context-menu'); 42 | document.body.appendChild(this.div); 43 | this.hoverItem = null; 44 | document.body.addEventListener('contextmenu', this._onRightClick); 45 | } 46 | 47 | _handleClick = (ev: MouseEvent) => this._onClick(ev); 48 | 49 | private _onClick(ev: MouseEvent) { 50 | if (!ev.target) return; 51 | if (!this.div.contains(ev.target as Node)) { 52 | this.div.style.display = 'none'; 53 | document.body.removeEventListener('click', this._handleClick); 54 | } 55 | } 56 | 57 | private _onRightClick(ev: MouseEvent) { 58 | if (!Drawing.hoveredObject) return; 59 | 60 | for (const item of this.items) { 61 | this.div.removeChild(item); 62 | } 63 | this.items = []; 64 | 65 | for (const optionName of Object.keys(Drawing.hoveredObject._options)) { 66 | let subMenu; 67 | if (optionName.toLowerCase().includes('color')) { 68 | subMenu = new ColorPicker(this.saveDrawings, optionName as keyof DrawingOptions); 69 | } else if (optionName === 'lineStyle') { 70 | subMenu = new StylePicker(this.saveDrawings) 71 | } else continue; 72 | 73 | let onClick = (rect: DOMRect) => subMenu.openMenu(rect) 74 | this.menuItem(camelToTitle(optionName), onClick, () => { 75 | document.removeEventListener('click', subMenu.closeMenu) 76 | subMenu._div.style.display = 'none' 77 | }) 78 | } 79 | 80 | let onClickDelete = () => this.drawingTool.delete(Drawing.lastHoveredObject); 81 | this.separator() 82 | this.menuItem('Delete Drawing', onClickDelete) 83 | 84 | // const colorPicker = new ColorPicker(this.saveDrawings) 85 | // const stylePicker = new StylePicker(this.saveDrawings) 86 | 87 | // let onClickDelete = () => this._drawingTool.delete(Drawing.lastHoveredObject); 88 | // let onClickColor = (rect: DOMRect) => colorPicker.openMenu(rect) 89 | // let onClickStyle = (rect: DOMRect) => stylePicker.openMenu(rect) 90 | 91 | // contextMenu.menuItem('Color Picker', onClickColor, () => { 92 | // document.removeEventListener('click', colorPicker.closeMenu) 93 | // colorPicker._div.style.display = 'none' 94 | // }) 95 | // contextMenu.menuItem('Style', onClickStyle, () => { 96 | // document.removeEventListener('click', stylePicker.closeMenu) 97 | // stylePicker._div.style.display = 'none' 98 | // }) 99 | // contextMenu.separator() 100 | // contextMenu.menuItem('Delete Drawing', onClickDelete) 101 | 102 | 103 | ev.preventDefault(); 104 | this.div.style.left = ev.clientX + 'px'; 105 | this.div.style.top = ev.clientY + 'px'; 106 | this.div.style.display = 'block'; 107 | document.body.addEventListener('click', this._handleClick); 108 | } 109 | 110 | public menuItem(text: string, action: Function, hover: Function | null = null) { 111 | const item = document.createElement('span'); 112 | item.classList.add('context-menu-item'); 113 | this.div.appendChild(item); 114 | 115 | const elem = document.createElement('span'); 116 | elem.innerText = text; 117 | elem.style.pointerEvents = 'none'; 118 | item.appendChild(elem); 119 | 120 | if (hover) { 121 | let arrow = document.createElement('span') 122 | arrow.innerText = `►` 123 | arrow.style.fontSize = '8px' 124 | arrow.style.pointerEvents = 'none' 125 | item.appendChild(arrow) 126 | } 127 | 128 | item.addEventListener('mouseover', () => { 129 | if (this.hoverItem && this.hoverItem.closeAction) this.hoverItem.closeAction() 130 | this.hoverItem = {elem: elem, action: action, closeAction: hover} 131 | }) 132 | if (!hover) item.addEventListener('click', (event) => {action(event); this.div.style.display = 'none'}) 133 | else { 134 | let timeout: number; 135 | item.addEventListener('mouseover', () => timeout = setTimeout(() => action(item.getBoundingClientRect()), 100)) 136 | item.addEventListener('mouseout', () => clearTimeout(timeout)) 137 | } 138 | 139 | this.items.push(item); 140 | 141 | } 142 | public separator() { 143 | const separator = document.createElement('div') 144 | separator.style.width = '90%' 145 | separator.style.height = '1px' 146 | separator.style.margin = '3px 0px' 147 | separator.style.backgroundColor = window.pane.borderColor 148 | this.div.appendChild(separator) 149 | 150 | this.items.push(separator); 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /src/context-menu/style-picker.ts: -------------------------------------------------------------------------------- 1 | import { LineStyle } from "lightweight-charts"; 2 | import { GlobalParams } from "../general/global-params"; 3 | import { Drawing } from "../drawing/drawing"; 4 | 5 | declare const window: GlobalParams; 6 | 7 | 8 | export class StylePicker { 9 | private static readonly _styles = [ 10 | {name: 'Solid', var: LineStyle.Solid}, 11 | {name: 'Dotted', var: LineStyle.Dotted}, 12 | {name: 'Dashed', var: LineStyle.Dashed}, 13 | {name: 'Large Dashed', var: LineStyle.LargeDashed}, 14 | {name: 'Sparse Dotted', var: LineStyle.SparseDotted}, 15 | ] 16 | 17 | public _div: HTMLDivElement; 18 | private _saveDrawings: Function; 19 | 20 | constructor(saveDrawings: Function) { 21 | this._saveDrawings = saveDrawings 22 | 23 | this._div = document.createElement('div'); 24 | this._div.classList.add('context-menu'); 25 | StylePicker._styles.forEach((style) => { 26 | this._div.appendChild(this._makeTextBox(style.name, style.var)) 27 | }) 28 | window.containerDiv.appendChild(this._div); 29 | } 30 | 31 | private _makeTextBox(text: string, style: LineStyle) { 32 | const item = document.createElement('span'); 33 | item.classList.add('context-menu-item'); 34 | item.innerText = text 35 | item.addEventListener('click', () => { 36 | Drawing.lastHoveredObject?.applyOptions({lineStyle: style}); 37 | this._saveDrawings(); 38 | }) 39 | return item 40 | } 41 | 42 | openMenu(rect: DOMRect) { 43 | this._div.style.top = (rect.top-30)+'px' 44 | this._div.style.left = rect.right+'px' 45 | this._div.style.display = 'block' 46 | 47 | setTimeout(() => document.addEventListener('mousedown', (event: MouseEvent) => { 48 | if (!this._div.contains(event.target as Node)) { 49 | this.closeMenu() 50 | } 51 | }), 10) 52 | } 53 | closeMenu() { 54 | document.removeEventListener('click', this.closeMenu) 55 | this._div.style.display = 'none' 56 | } 57 | } -------------------------------------------------------------------------------- /src/drawing/data-source.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Logical, 3 | Time, 4 | } from 'lightweight-charts'; 5 | 6 | export interface Point { 7 | time: Time | null; 8 | logical: Logical; 9 | price: number; 10 | } 11 | 12 | export interface DiffPoint { 13 | logical: number; 14 | price: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/drawing/drawing-tool.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IChartApi, 3 | ISeriesApi, 4 | Logical, 5 | MouseEventParams, 6 | SeriesType, 7 | } from 'lightweight-charts'; 8 | import { Drawing } from './drawing'; 9 | import { HorizontalLine } from '../horizontal-line/horizontal-line'; 10 | 11 | 12 | export class DrawingTool { 13 | private _chart: IChartApi; 14 | private _series: ISeriesApi; 15 | private _finishDrawingCallback: Function | null = null; 16 | 17 | private _drawings: Drawing[] = []; 18 | private _activeDrawing: Drawing | null = null; 19 | private _isDrawing: boolean = false; 20 | private _drawingType: (new (...args: any[]) => Drawing) | null = null; 21 | 22 | constructor(chart: IChartApi, series: ISeriesApi, finishDrawingCallback: Function | null = null) { 23 | this._chart = chart; 24 | this._series = series; 25 | this._finishDrawingCallback = finishDrawingCallback; 26 | 27 | this._chart.subscribeClick(this._clickHandler); 28 | this._chart.subscribeCrosshairMove(this._moveHandler); 29 | } 30 | 31 | private _clickHandler = (param: MouseEventParams) => this._onClick(param); 32 | private _moveHandler = (param: MouseEventParams) => this._onMouseMove(param); 33 | 34 | beginDrawing(DrawingType: new (...args: any[]) => Drawing) { 35 | this._drawingType = DrawingType; 36 | this._isDrawing = true; 37 | } 38 | 39 | stopDrawing() { 40 | this._isDrawing = false; 41 | this._activeDrawing = null; 42 | } 43 | 44 | get drawings() { 45 | return this._drawings; 46 | } 47 | 48 | addNewDrawing(drawing: Drawing) { 49 | this._series.attachPrimitive(drawing); 50 | this._drawings.push(drawing); 51 | } 52 | 53 | delete(d: Drawing | null) { 54 | if (d == null) return; 55 | const idx = this._drawings.indexOf(d); 56 | if (idx == -1) return; 57 | this._drawings.splice(idx, 1) 58 | d.detach(); 59 | } 60 | 61 | clearDrawings() { 62 | for (const d of this._drawings) d.detach(); 63 | this._drawings = []; 64 | } 65 | 66 | repositionOnTime() { 67 | for (const drawing of this.drawings) { 68 | const newPoints = [] 69 | for (const point of drawing.points) { 70 | if (!point) { 71 | newPoints.push(point); 72 | continue; 73 | } 74 | const logical = point.time ? this._chart.timeScale() 75 | .coordinateToLogical( 76 | this._chart.timeScale().timeToCoordinate(point.time) || 0 77 | ) : point.logical; 78 | newPoints.push({ 79 | time: point.time, 80 | logical: logical as Logical, 81 | price: point.price, 82 | }) 83 | } 84 | drawing.updatePoints(...newPoints); 85 | } 86 | } 87 | 88 | private _onClick(param: MouseEventParams) { 89 | if (!this._isDrawing) return; 90 | 91 | const point = Drawing._eventToPoint(param, this._series); 92 | if (!point) return; 93 | 94 | if (this._activeDrawing == null) { 95 | if (this._drawingType == null) return; 96 | 97 | this._activeDrawing = new this._drawingType(point, point); 98 | this._series.attachPrimitive(this._activeDrawing); 99 | if (this._drawingType == HorizontalLine) this._onClick(param); 100 | } 101 | else { 102 | this._drawings.push(this._activeDrawing); 103 | this.stopDrawing(); 104 | 105 | if (!this._finishDrawingCallback) return; 106 | this._finishDrawingCallback(); 107 | } 108 | } 109 | 110 | private _onMouseMove(param: MouseEventParams) { 111 | if (!param) return; 112 | 113 | for (const t of this._drawings) t._handleHoverInteraction(param); 114 | 115 | if (!this._isDrawing || !this._activeDrawing) return; 116 | 117 | const point = Drawing._eventToPoint(param, this._series); 118 | if (!point) return; 119 | this._activeDrawing.updatePoints(null, point); 120 | // this._activeDrawing.setSecondPoint(point); 121 | } 122 | } -------------------------------------------------------------------------------- /src/drawing/drawing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ISeriesApi, 3 | Logical, 4 | MouseEventParams, 5 | SeriesType 6 | } from 'lightweight-charts'; 7 | 8 | import { PluginBase } from '../plugin-base'; 9 | import { DiffPoint, Point } from './data-source'; 10 | import { DrawingOptions, defaultOptions } from './options'; 11 | import { DrawingPaneView } from './pane-view'; 12 | 13 | export enum InteractionState { 14 | NONE, 15 | HOVERING, 16 | DRAGGING, 17 | DRAGGINGP1, 18 | DRAGGINGP2, 19 | DRAGGINGP3, 20 | DRAGGINGP4, 21 | } 22 | 23 | export abstract class Drawing extends PluginBase { 24 | _paneViews: DrawingPaneView[] = []; 25 | _options: DrawingOptions; 26 | 27 | abstract _type: string; 28 | protected _points: (Point|null)[] = []; 29 | 30 | protected _state: InteractionState = InteractionState.NONE; 31 | 32 | protected _startDragPoint: Point | null = null; 33 | protected _latestHoverPoint: any | null = null; 34 | 35 | protected static _mouseIsDown: boolean = false; 36 | 37 | public static hoveredObject: Drawing | null = null; 38 | public static lastHoveredObject: Drawing | null = null; 39 | 40 | protected _listeners: any[] = []; 41 | 42 | constructor( 43 | options?: Partial 44 | ) { 45 | super() 46 | this._options = { 47 | ...defaultOptions, 48 | ...options, 49 | }; 50 | } 51 | 52 | updateAllViews() { 53 | this._paneViews.forEach(pw => pw.update()); 54 | } 55 | 56 | paneViews() { 57 | return this._paneViews; 58 | } 59 | 60 | applyOptions(options: Partial) { 61 | this._options = { 62 | ...this._options, 63 | ...options, 64 | } 65 | this.requestUpdate(); 66 | } 67 | 68 | public updatePoints(...points: (Point | null)[]) { 69 | for (let i=0; i x.name === name && x.listener === callback) 99 | this._listeners.splice(this._listeners.indexOf(toRemove), 1); 100 | } 101 | 102 | _handleHoverInteraction(param: MouseEventParams) { 103 | this._latestHoverPoint = param.point; 104 | if (Drawing._mouseIsDown) { 105 | this._handleDragInteraction(param); 106 | } else { 107 | if (this._mouseIsOverDrawing(param)) { 108 | if (this._state != InteractionState.NONE) return; 109 | this._moveToState(InteractionState.HOVERING); 110 | Drawing.hoveredObject = Drawing.lastHoveredObject = this; 111 | } else { 112 | if (this._state == InteractionState.NONE) return; 113 | this._moveToState(InteractionState.NONE); 114 | if (Drawing.hoveredObject === this) Drawing.hoveredObject = null; 115 | } 116 | } 117 | } 118 | 119 | public static _eventToPoint(param: MouseEventParams, series: ISeriesApi) { 120 | if (!series || !param.point || !param.logical) return null; 121 | const barPrice = series.coordinateToPrice(param.point.y); 122 | if (barPrice == null) return null; 123 | return { 124 | time: param.time || null, 125 | logical: param.logical, 126 | price: barPrice.valueOf(), 127 | } 128 | } 129 | 130 | protected static _getDiff(p1: Point, p2: Point): DiffPoint { 131 | const diff: DiffPoint = { 132 | logical: p1.logical-p2.logical, 133 | price: p1.price-p2.price, 134 | } 135 | return diff; 136 | } 137 | 138 | protected _addDiffToPoint(point: Point | null, logicalDiff: number, priceDiff: number) { 139 | if (!point) return; 140 | point.logical = point.logical + logicalDiff as Logical; 141 | point.price = point.price+priceDiff; 142 | point.time = this.series.dataByIndex(point.logical)?.time || null; 143 | } 144 | 145 | protected _handleMouseDownInteraction = () => { 146 | // if (Drawing._mouseIsDown) return; 147 | Drawing._mouseIsDown = true; 148 | this._onMouseDown(); 149 | } 150 | 151 | protected _handleMouseUpInteraction = () => { 152 | // if (!Drawing._mouseIsDown) return; 153 | Drawing._mouseIsDown = false; 154 | this._moveToState(InteractionState.HOVERING); 155 | } 156 | 157 | private _handleDragInteraction(param: MouseEventParams): void { 158 | if (this._state != InteractionState.DRAGGING && 159 | this._state != InteractionState.DRAGGINGP1 && 160 | this._state != InteractionState.DRAGGINGP2 && 161 | this._state != InteractionState.DRAGGINGP3 && 162 | this._state != InteractionState.DRAGGINGP4) { 163 | return; 164 | } 165 | const mousePoint = Drawing._eventToPoint(param, this.series); 166 | if (!mousePoint) return; 167 | this._startDragPoint = this._startDragPoint || mousePoint; 168 | 169 | const diff = Drawing._getDiff(mousePoint, this._startDragPoint); 170 | this._onDrag(diff); 171 | this.requestUpdate(); 172 | 173 | this._startDragPoint = mousePoint; 174 | } 175 | 176 | protected abstract _onMouseDown(): void; 177 | protected abstract _onDrag(diff: DiffPoint): void; 178 | protected abstract _moveToState(state: InteractionState): void; 179 | protected abstract _mouseIsOverDrawing(param: MouseEventParams): boolean; 180 | } 181 | -------------------------------------------------------------------------------- /src/drawing/options.ts: -------------------------------------------------------------------------------- 1 | import { LineStyle } from "lightweight-charts"; 2 | 3 | 4 | export interface DrawingOptions { 5 | lineColor: string; 6 | lineStyle: LineStyle 7 | width: number; 8 | } 9 | 10 | export const defaultOptions: DrawingOptions = { 11 | lineColor: '#1E80F0', 12 | lineStyle: LineStyle.Solid, 13 | width: 4, 14 | }; 15 | -------------------------------------------------------------------------------- /src/drawing/pane-renderer.ts: -------------------------------------------------------------------------------- 1 | import { ISeriesPrimitivePaneRenderer } from "lightweight-charts"; 2 | import { ViewPoint } from "./pane-view"; 3 | import { DrawingOptions } from "./options"; 4 | import { BitmapCoordinatesRenderingScope, CanvasRenderingTarget2D } from "fancy-canvas"; 5 | 6 | export abstract class DrawingPaneRenderer implements ISeriesPrimitivePaneRenderer { 7 | _options: DrawingOptions; 8 | 9 | constructor(options: DrawingOptions) { 10 | this._options = options; 11 | } 12 | 13 | abstract draw(target: CanvasRenderingTarget2D): void; 14 | 15 | } 16 | 17 | export abstract class TwoPointDrawingPaneRenderer extends DrawingPaneRenderer { 18 | _p1: ViewPoint; 19 | _p2: ViewPoint; 20 | protected _hovered: boolean; 21 | 22 | constructor(p1: ViewPoint, p2: ViewPoint, options: DrawingOptions, hovered: boolean) { 23 | super(options); 24 | this._p1 = p1; 25 | this._p2 = p2; 26 | this._hovered = hovered; 27 | } 28 | 29 | abstract draw(target: CanvasRenderingTarget2D): void; 30 | 31 | _getScaledCoordinates(scope: BitmapCoordinatesRenderingScope) { 32 | if (this._p1.x === null || this._p1.y === null || 33 | this._p2.x === null || this._p2.y === null) return null; 34 | return { 35 | x1: Math.round(this._p1.x * scope.horizontalPixelRatio), 36 | y1: Math.round(this._p1.y * scope.verticalPixelRatio), 37 | x2: Math.round(this._p2.x * scope.horizontalPixelRatio), 38 | y2: Math.round(this._p2.y * scope.verticalPixelRatio), 39 | } 40 | } 41 | 42 | // _drawTextLabel(scope: BitmapCoordinatesRenderingScope, text: string, x: number, y: number, left: boolean) { 43 | // scope.context.font = '24px Arial'; 44 | // scope.context.beginPath(); 45 | // const offset = 5 * scope.horizontalPixelRatio; 46 | // const textWidth = scope.context.measureText(text); 47 | // const leftAdjustment = left ? textWidth.width + offset * 4 : 0; 48 | // scope.context.fillStyle = this._options.labelBackgroundColor; 49 | // scope.context.roundRect(x + offset - leftAdjustment, y - 24, textWidth.width + offset * 2, 24 + offset, 5); 50 | // scope.context.fill(); 51 | // scope.context.beginPath(); 52 | // scope.context.fillStyle = this._options.labelTextColor; 53 | // scope.context.fillText(text, x + offset * 2 - leftAdjustment, y); 54 | // } 55 | 56 | _drawEndCircle(scope: BitmapCoordinatesRenderingScope, x: number, y: number) { 57 | const radius = 9 58 | scope.context.fillStyle = '#000'; 59 | scope.context.beginPath(); 60 | scope.context.arc(x, y, radius, 0, 2 * Math.PI); 61 | scope.context.stroke(); 62 | scope.context.fill(); 63 | // scope.context.strokeStyle = this._options.lineColor; 64 | } 65 | } -------------------------------------------------------------------------------- /src/drawing/pane-view.ts: -------------------------------------------------------------------------------- 1 | import { Coordinate, ISeriesPrimitivePaneView } from 'lightweight-charts'; 2 | import { Drawing } from './drawing'; 3 | import { Point } from './data-source'; 4 | import { DrawingPaneRenderer } from './pane-renderer'; 5 | import { TwoPointDrawing } from './two-point-drawing'; 6 | 7 | 8 | export abstract class DrawingPaneView implements ISeriesPrimitivePaneView { 9 | _source: Drawing; 10 | 11 | constructor(source: Drawing) { 12 | this._source = source; 13 | } 14 | 15 | abstract update(): void; 16 | abstract renderer(): DrawingPaneRenderer; 17 | } 18 | 19 | export interface ViewPoint { 20 | x: Coordinate | null; 21 | y: Coordinate | null; 22 | } 23 | 24 | export abstract class TwoPointDrawingPaneView extends DrawingPaneView { 25 | _p1: ViewPoint = { x: null, y: null }; 26 | _p2: ViewPoint = { x: null, y: null }; 27 | 28 | _source: TwoPointDrawing; 29 | 30 | constructor(source: TwoPointDrawing) { 31 | super(source); 32 | this._source = source; 33 | } 34 | 35 | update() { 36 | if (!this._source.p1 || !this._source.p2) return; 37 | const series = this._source.series; 38 | const y1 = series.priceToCoordinate(this._source.p1.price); 39 | const y2 = series.priceToCoordinate(this._source.p2.price); 40 | const x1 = this._getX(this._source.p1); 41 | const x2 = this._getX(this._source.p2); 42 | this._p1 = { x: x1, y: y1 }; 43 | this._p2 = { x: x2, y: y2 }; 44 | if (!x1 || !x2 || !y1 || !y2) return; 45 | } 46 | 47 | abstract renderer(): DrawingPaneRenderer; 48 | 49 | _getX(p: Point) { 50 | const timeScale = this._source.chart.timeScale(); 51 | return timeScale.logicalToCoordinate(p.logical); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/drawing/two-point-drawing.ts: -------------------------------------------------------------------------------- 1 | import { Point } from './data-source'; 2 | import { DrawingOptions, defaultOptions } from './options'; 3 | import { Drawing } from './drawing'; 4 | import { TwoPointDrawingPaneView } from './pane-view'; 5 | 6 | 7 | export abstract class TwoPointDrawing extends Drawing { 8 | _paneViews: TwoPointDrawingPaneView[] = []; 9 | 10 | protected _hovered: boolean = false; 11 | 12 | constructor( 13 | p1: Point, 14 | p2: Point, 15 | options?: Partial 16 | ) { 17 | super() 18 | this.points.push(p1); 19 | this.points.push(p2); 20 | this._options = { 21 | ...defaultOptions, 22 | ...options, 23 | }; 24 | } 25 | 26 | setFirstPoint(point: Point) { 27 | this.updatePoints(point); 28 | } 29 | 30 | setSecondPoint(point: Point) { 31 | this.updatePoints(null, point); 32 | } 33 | 34 | get p1() { return this.points[0]; } 35 | get p2() { return this.points[1]; } 36 | 37 | get hovered() { return this._hovered; } 38 | } 39 | -------------------------------------------------------------------------------- /src/example/example.ts: -------------------------------------------------------------------------------- 1 | import { generateCandleData } from '../sample-data'; 2 | import { Handler } from '../general/handler'; 3 | 4 | const handler = new Handler("sadasdas", 0.556, 0.5182, "left", true); 5 | 6 | handler.createToolBox(); 7 | 8 | const data = generateCandleData(); 9 | if (handler.series) 10 | handler.series.setData(data); 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Template Drawing Primitive Plugin Example 7 | 8 | 9 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/general/global-params.ts: -------------------------------------------------------------------------------- 1 | export interface GlobalParams extends Window { 2 | pane: paneStyle; // TODO shouldnt need this cause of css variables 3 | handlerInFocus: string; 4 | textBoxFocused: boolean; 5 | callbackFunction: Function; 6 | containerDiv: HTMLElement; 7 | setCursor: Function; 8 | cursor: string; 9 | } 10 | 11 | interface paneStyle { 12 | backgroundColor: string; 13 | hoverBackgroundColor: string; 14 | clickBackgroundColor: string; 15 | activeBackgroundColor: string; 16 | mutedBackgroundColor: string; 17 | borderColor: string; 18 | color: string; 19 | activeColor: string; 20 | } 21 | 22 | export const paneStyleDefault: paneStyle = { 23 | backgroundColor: '#0c0d0f', 24 | hoverBackgroundColor: '#3c434c', 25 | clickBackgroundColor: '#50565E', 26 | activeBackgroundColor: 'rgba(0, 122, 255, 0.7)', 27 | mutedBackgroundColor: 'rgba(0, 122, 255, 0.3)', 28 | borderColor: '#3C434C', 29 | color: '#d8d9db', 30 | activeColor: '#ececed', 31 | } 32 | 33 | declare const window: GlobalParams; 34 | 35 | export function globalParamInit() { 36 | window.pane = { 37 | ...paneStyleDefault, 38 | } 39 | window.containerDiv = document.getElementById("container") || document.createElement('div'); 40 | window.setCursor = (type: string | undefined) => { 41 | if (type) window.cursor = type; 42 | document.body.style.cursor = window.cursor; 43 | } 44 | window.cursor = 'default'; 45 | window.textBoxFocused = false; 46 | } 47 | 48 | export const setCursor = (type: string | undefined) => { 49 | if (type) window.cursor = type; 50 | document.body.style.cursor = window.cursor; 51 | } 52 | 53 | 54 | // export interface SeriesHandler { 55 | // type: string; 56 | // series: ISeriesApi; 57 | // markers: SeriesMarker<"">[], 58 | // horizontal_lines: HorizontalLine[], 59 | // name?: string, 60 | // precision: number, 61 | // } 62 | 63 | -------------------------------------------------------------------------------- /src/general/index.ts: -------------------------------------------------------------------------------- 1 | // TODO this won't be necessary with ws 2 | 3 | export * from './handler'; 4 | export * from './global-params'; 5 | export * from './legend'; 6 | export * from './table'; 7 | export * from './toolbox'; 8 | export * from './topbar'; 9 | export * from '../horizontal-line/ray-line'; -------------------------------------------------------------------------------- /src/general/menu.ts: -------------------------------------------------------------------------------- 1 | import { GlobalParams } from "./global-params"; 2 | 3 | declare const window: GlobalParams 4 | 5 | export class Menu { 6 | private div: HTMLDivElement; 7 | private isOpen: boolean = false; 8 | private widget: any; 9 | 10 | constructor( 11 | private makeButton: Function, 12 | private callbackName: string, 13 | items: string[], 14 | activeItem: string, 15 | separator: boolean, 16 | align: 'right'|'left') { 17 | 18 | this.div = document.createElement('div') 19 | this.div.classList.add('topbar-menu'); 20 | 21 | this.widget = this.makeButton(activeItem+' ↓', null, separator, true, align) 22 | 23 | this.updateMenuItems(items) 24 | 25 | this.widget.elem.addEventListener('click', () => { 26 | this.isOpen = !this.isOpen; 27 | if (!this.isOpen) { 28 | this.div.style.display = 'none'; 29 | return; 30 | } 31 | let rect = this.widget.elem.getBoundingClientRect() 32 | this.div.style.display = 'flex' 33 | this.div.style.flexDirection = 'column' 34 | 35 | let center = rect.x+(rect.width/2) 36 | this.div.style.left = center-(this.div.clientWidth/2)+'px' 37 | this.div.style.top = rect.y+rect.height+'px' 38 | }) 39 | document.body.appendChild(this.div) 40 | } 41 | 42 | updateMenuItems(items: string[]) { 43 | this.div.innerHTML = ''; 44 | 45 | items.forEach(text => { 46 | let button = this.makeButton(text, null, false, false) 47 | button.elem.addEventListener('click', () => { 48 | this._clickHandler(button.elem.innerText); 49 | }); 50 | button.elem.style.margin = '4px 4px' 51 | button.elem.style.padding = '2px 2px' 52 | this.div.appendChild(button.elem) 53 | }) 54 | this.widget.elem.innerText = items[0]+' ↓'; 55 | } 56 | 57 | private _clickHandler(name: string) { 58 | this.widget.elem.innerText = name+' ↓' 59 | window.callbackFunction(`${this.callbackName}_~_${name}`) 60 | this.div.style.display = 'none' 61 | this.isOpen = false 62 | } 63 | } -------------------------------------------------------------------------------- /src/general/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color:#0c0d0f; 3 | --hover-bg-color: #3c434c; 4 | --click-bg-color: #50565E; 5 | --active-bg-color: rgba(0, 122, 255, 0.7); 6 | --muted-bg-color: rgba(0, 122, 255, 0.3); 7 | --border-color: #3C434C; 8 | --color: #d8d9db; 9 | --active-color: #ececed; 10 | } 11 | 12 | body { 13 | background-color: rgb(0,0,0); 14 | color: rgba(19, 23, 34, 1); 15 | overflow: hidden; 16 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, 17 | Cantarell, "Helvetica Neue", sans-serif; 18 | } 19 | 20 | .handler { 21 | display: flex; 22 | flex-direction: column; 23 | position: relative; 24 | } 25 | 26 | .toolbox { 27 | position: absolute; 28 | z-index: 2000; 29 | display: flex; 30 | align-items: center; 31 | top: 25%; 32 | border: 2px solid var(--border-color); 33 | border-left: none; 34 | border-top-right-radius: 4px; 35 | border-bottom-right-radius: 4px; 36 | background-color: rgba(25, 27, 30, 0.5); 37 | flex-direction: column; 38 | } 39 | 40 | .toolbox-button { 41 | margin: 3px; 42 | border-radius: 4px; 43 | display: flex; 44 | background-color: transparent; 45 | } 46 | .toolbox-button:hover { 47 | background-color: rgba(80, 86, 94, 0.7); 48 | } 49 | .toolbox-button:active { 50 | background-color: rgba(90, 106, 104, 0.7); 51 | } 52 | 53 | .active-toolbox-button { 54 | background-color: var(--active-bg-color) !important; 55 | } 56 | .active-toolbox-button g { 57 | fill: var(--active-color); 58 | } 59 | 60 | .context-menu { 61 | position: absolute; 62 | z-index: 1000; 63 | background: rgb(50, 50, 50); 64 | color: var(--active-color); 65 | display: none; 66 | border-radius: 5px; 67 | padding: 3px 3px; 68 | font-size: 13px; 69 | cursor: default; 70 | } 71 | .context-menu-item { 72 | display: flex; 73 | align-items: center; 74 | justify-content: space-between; 75 | padding: 2px 10px; 76 | margin: 1px 0px; 77 | border-radius: 3px; 78 | } 79 | .context-menu-item:hover { 80 | background-color: var(--muted-bg-color); 81 | } 82 | 83 | .color-picker { 84 | max-width: 170px; 85 | background-color: var(--bg-color); 86 | position: absolute; 87 | z-index: 10000; 88 | display: none; 89 | flex-direction: column; 90 | align-items: center; 91 | border: 2px solid var(--border-color); 92 | border-radius: 8px; 93 | cursor: default; 94 | } 95 | 96 | 97 | /* topbar-related */ 98 | .topbar { 99 | background-color: var(--bg-color); 100 | border-bottom: 2px solid var(--border-color); 101 | display: flex; 102 | align-items: center; 103 | } 104 | 105 | .topbar-container { 106 | display: flex; 107 | align-items: center; 108 | flex-grow: 1; 109 | } 110 | 111 | .topbar-button { 112 | border: none; 113 | padding: 2px 5px; 114 | margin: 4px 10px; 115 | font-size: 13px; 116 | border-radius: 4px; 117 | color: var(--color); 118 | background-color: transparent; 119 | } 120 | .topbar-button:hover { 121 | background-color: var(--hover-bg-color) 122 | } 123 | 124 | .topbar-button:active { 125 | background-color: var(--click-bg-color); 126 | color: var(--active-color); 127 | font-weight: 500; 128 | } 129 | 130 | .switcher-button:active { 131 | background-color: var(--click-bg-color); 132 | color: var(--color); 133 | font-weight: normal; 134 | } 135 | 136 | .active-switcher-button { 137 | background-color: var(--active-bg-color) !important; 138 | color: var(--active-color) !important; 139 | font-weight: 500; 140 | } 141 | 142 | .topbar-textbox { 143 | margin: 0px 18px; 144 | font-size: 16px; 145 | color: var(--color); 146 | } 147 | 148 | .topbar-textbox-input { 149 | background-color: var(--bg-color); 150 | color: var(--color); 151 | border: 1px solid var(--color); 152 | } 153 | 154 | .topbar-menu { 155 | position: absolute; 156 | display: none; 157 | z-index: 10000; 158 | background-color: var(--bg-color); 159 | border-radius: 2px; 160 | border: 2px solid var(--border-color); 161 | border-top: none; 162 | align-items: flex-start; 163 | max-height: 80%; 164 | overflow-y: auto; 165 | } 166 | 167 | .topbar-separator { 168 | width: 1px; 169 | height: 20px; 170 | background-color: var(--border-color); 171 | } 172 | 173 | .searchbox { 174 | position: absolute; 175 | top: 0; 176 | bottom: 200px; 177 | left: 0; 178 | right: 0; 179 | margin: auto; 180 | width: 150px; 181 | height: 30px; 182 | padding: 5px; 183 | z-index: 1000; 184 | align-items: center; 185 | background-color: rgba(30 ,30, 30, 0.9); 186 | border: 2px solid var(--border-color); 187 | border-radius: 5px; 188 | display: flex; 189 | 190 | } 191 | .searchbox input { 192 | text-align: center; 193 | width: 100px; 194 | margin-left: 10px; 195 | background-color: var(--muted-bg-color); 196 | color: var(--active-color); 197 | font-size: 20px; 198 | border: none; 199 | outline: none; 200 | border-radius: 2px; 201 | } 202 | 203 | .spinner { 204 | width: 30px; 205 | height: 30px; 206 | border: 4px solid rgba(255, 255, 255, 0.6); 207 | border-top: 4px solid var(--active-bg-color); 208 | border-radius: 50%; 209 | position: absolute; 210 | top: 50%; 211 | left: 50%; 212 | z-index: 1000; 213 | transform: translate(-50%, -50%); 214 | display: none; 215 | } 216 | 217 | .legend { 218 | position: absolute; 219 | z-index: 3000; 220 | pointer-events: none; 221 | top: 10px; 222 | left: 10px; 223 | display: none; 224 | flex-direction: column; 225 | } 226 | .series-container { 227 | display: flex; 228 | flex-direction: column; 229 | pointer-events: auto; 230 | overflow-y: auto; 231 | max-height: 80vh; 232 | } 233 | .series-container::-webkit-scrollbar { 234 | width: 0px; 235 | } 236 | .legend-toggle-switch { 237 | border-radius: 4px; 238 | margin-left: 10px; 239 | pointer-events: auto; 240 | } 241 | .legend-toggle-switch:hover { 242 | cursor: pointer; 243 | background-color: rgba(50, 50, 50, 0.5); 244 | } -------------------------------------------------------------------------------- /src/general/table.ts: -------------------------------------------------------------------------------- 1 | import { GlobalParams } from "./global-params"; 2 | 3 | declare const window: GlobalParams 4 | 5 | 6 | interface RowDictionary { 7 | [key: number]: HTMLTableRowElement; 8 | } 9 | 10 | 11 | export class Table { 12 | private _div: HTMLDivElement; 13 | private callbackName: string | null; 14 | 15 | private borderColor: string; 16 | private borderWidth: number; 17 | private table: HTMLTableElement; 18 | private rows: RowDictionary = {}; 19 | private headings: string[]; 20 | private widths: string[]; 21 | private alignments: string[]; 22 | 23 | public footer: HTMLDivElement[] | undefined; 24 | public header: HTMLDivElement[] | undefined; 25 | 26 | constructor(width: number, height: number, headings: string[], widths: number[], alignments: string[], position: string, draggable = false, 27 | tableBackgroundColor: string, borderColor: string, borderWidth: number, textColors: string[], backgroundColors: string[]) { 28 | this._div = document.createElement('div') 29 | this.callbackName = null 30 | this.borderColor = borderColor 31 | this.borderWidth = borderWidth 32 | 33 | if (draggable) { 34 | this._div.style.position = 'absolute' 35 | this._div.style.cursor = 'move' 36 | } else { 37 | this._div.style.position = 'relative' 38 | this._div.style.float = position 39 | } 40 | this._div.style.zIndex = '2000' 41 | this.reSize(width, height) 42 | this._div.style.display = 'flex' 43 | this._div.style.flexDirection = 'column' 44 | // this._div.style.justifyContent = 'space-between' 45 | 46 | this._div.style.borderRadius = '5px' 47 | this._div.style.color = 'white' 48 | this._div.style.fontSize = '12px' 49 | this._div.style.fontVariantNumeric = 'tabular-nums' 50 | 51 | this.table = document.createElement('table') 52 | this.table.style.width = '100%' 53 | this.table.style.borderCollapse = 'collapse' 54 | this._div.style.overflow = 'hidden'; 55 | 56 | this.headings = headings 57 | this.widths = widths.map((width) => `${width * 100}%`) 58 | this.alignments = alignments 59 | 60 | let head = this.table.createTHead() 61 | let row = head.insertRow() 62 | 63 | for (let i = 0; i < this.headings.length; i++) { 64 | let th = document.createElement('th') 65 | th.textContent = this.headings[i] 66 | th.style.width = this.widths[i] 67 | th.style.letterSpacing = '0.03rem' 68 | th.style.padding = '0.2rem 0px' 69 | th.style.fontWeight = '500' 70 | th.style.textAlign = 'center' 71 | if (i !== 0) th.style.borderLeft = borderWidth+'px solid '+borderColor 72 | th.style.position = 'sticky' 73 | th.style.top = '0' 74 | th.style.backgroundColor = backgroundColors.length > 0 ? backgroundColors[i] : tableBackgroundColor 75 | th.style.color = textColors[i] 76 | row.appendChild(th) 77 | } 78 | 79 | let overflowWrapper = document.createElement('div') 80 | overflowWrapper.style.overflowY = 'auto' 81 | overflowWrapper.style.overflowX = 'hidden' 82 | overflowWrapper.style.backgroundColor = tableBackgroundColor 83 | overflowWrapper.appendChild(this.table) 84 | this._div.appendChild(overflowWrapper) 85 | window.containerDiv.appendChild(this._div) 86 | 87 | if (!draggable) return 88 | 89 | let offsetX: number, offsetY: number; 90 | 91 | let onMouseDown = (event: MouseEvent) => { 92 | offsetX = event.clientX - this._div.offsetLeft; 93 | offsetY = event.clientY - this._div.offsetTop; 94 | 95 | document.addEventListener('mousemove', onMouseMove); 96 | document.addEventListener('mouseup', onMouseUp); 97 | } 98 | 99 | let onMouseMove = (event: MouseEvent) => { 100 | this._div.style.left = (event.clientX - offsetX) + 'px'; 101 | this._div.style.top = (event.clientY - offsetY) + 'px'; 102 | } 103 | 104 | let onMouseUp = () => { 105 | // Remove the event listeners for dragging 106 | document.removeEventListener('mousemove', onMouseMove); 107 | document.removeEventListener('mouseup', onMouseUp); 108 | } 109 | 110 | this._div.addEventListener('mousedown', onMouseDown); 111 | } 112 | 113 | divToButton(div: HTMLDivElement, callbackString: string) { 114 | div.addEventListener('mouseover', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)') 115 | div.addEventListener('mouseout', () => div.style.backgroundColor = 'transparent') 116 | div.addEventListener('mousedown', () => div.style.backgroundColor = 'rgba(60, 60, 60)') 117 | div.addEventListener('click', () => window.callbackFunction(callbackString)) 118 | div.addEventListener('mouseup', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)') 119 | } 120 | 121 | newRow(id: number, returnClickedCell=false) { 122 | let row = this.table.insertRow() 123 | row.style.cursor = 'default' 124 | 125 | for (let i = 0; i < this.headings.length; i++) { 126 | let cell = row.insertCell() 127 | cell.style.width = this.widths[i]; 128 | cell.style.textAlign = this.alignments[i]; 129 | cell.style.border = this.borderWidth+'px solid '+this.borderColor 130 | if (returnClickedCell) { 131 | this.divToButton(cell, `${this.callbackName}_~_${id};;;${this.headings[i]}`) 132 | } 133 | } 134 | if (!returnClickedCell) { 135 | this.divToButton(row, `${this.callbackName}_~_${id}`) 136 | } 137 | this.rows[id] = row 138 | } 139 | 140 | deleteRow(id: number) { 141 | this.table.deleteRow(this.rows[id].rowIndex) 142 | delete this.rows[id] 143 | } 144 | 145 | clearRows() { 146 | let numRows = Object.keys(this.rows).length 147 | for (let i = 0; i < numRows; i++) 148 | this.table.deleteRow(-1) 149 | this.rows = {} 150 | } 151 | 152 | private _getCell(rowId: number, column: string) { 153 | return this.rows[rowId].cells[this.headings.indexOf(column)]; 154 | } 155 | 156 | updateCell(rowId: number, column: string, val: string) { 157 | this._getCell(rowId, column).textContent = val; 158 | } 159 | 160 | styleCell(rowId: number, column: string, styleAttribute: string, value: string) { 161 | const style = this._getCell(rowId, column).style; 162 | (style as any)[styleAttribute] = value; 163 | } 164 | 165 | makeSection(id: string, type: string, numBoxes: number, func=false) { 166 | let section = document.createElement('div') 167 | section.style.display = 'flex' 168 | section.style.width = '100%' 169 | section.style.padding = '3px 0px' 170 | section.style.backgroundColor = 'rgb(30, 30, 30)' 171 | type === 'footer' ? this._div.appendChild(section) : this._div.prepend(section) 172 | 173 | const textBoxes = [] 174 | for (let i = 0; i < numBoxes; i++) { 175 | let textBox = document.createElement('div') 176 | section.appendChild(textBox) 177 | textBox.style.flex = '1' 178 | textBox.style.textAlign = 'center' 179 | if (func) { 180 | this.divToButton(textBox, `${id}_~_${i}`) 181 | textBox.style.borderRadius = '2px' 182 | } 183 | textBoxes.push(textBox) 184 | } 185 | 186 | if (type === 'footer') { 187 | this.footer = textBoxes; 188 | } 189 | else { 190 | this.header = textBoxes; 191 | } 192 | 193 | } 194 | 195 | reSize(width: number, height: number) { 196 | this._div.style.width = width <= 1 ? width * 100 + '%' : width + 'px' 197 | this._div.style.height = height <= 1 ? height * 100 + '%' : height + 'px' 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/general/toolbox.ts: -------------------------------------------------------------------------------- 1 | import { DrawingTool } from "../drawing/drawing-tool"; 2 | import { TrendLine } from "../trend-line/trend-line"; 3 | import { Box } from "../box/box"; 4 | import { Drawing } from "../drawing/drawing"; 5 | import { ContextMenu } from "../context-menu/context-menu"; 6 | import { GlobalParams } from "./global-params"; 7 | import { IChartApi, ISeriesApi, SeriesType } from "lightweight-charts"; 8 | import { HorizontalLine } from "../horizontal-line/horizontal-line"; 9 | import { RayLine } from "../horizontal-line/ray-line"; 10 | import { VerticalLine } from "../vertical-line/vertical-line"; 11 | 12 | 13 | interface Icon { 14 | div: HTMLDivElement, 15 | group: SVGGElement, 16 | type: new (...args: any[]) => Drawing 17 | } 18 | 19 | declare const window: GlobalParams 20 | 21 | export class ToolBox { 22 | private static readonly TREND_SVG: string = ''; 23 | private static readonly HORZ_SVG: string = ''; 24 | private static readonly RAY_SVG: string = ''; 25 | private static readonly BOX_SVG: string = ''; 26 | private static readonly VERT_SVG: string = ToolBox.RAY_SVG; 27 | 28 | div: HTMLDivElement; 29 | private activeIcon: Icon | null = null; 30 | 31 | private buttons: HTMLDivElement[] = []; 32 | 33 | private _commandFunctions: Function[]; 34 | private _handlerID: string; 35 | 36 | private _drawingTool: DrawingTool; 37 | 38 | constructor(handlerID: string, chart: IChartApi, series: ISeriesApi, commandFunctions: Function[]) { 39 | this._handlerID = handlerID; 40 | this._commandFunctions = commandFunctions; 41 | this._drawingTool = new DrawingTool(chart, series, () => this.removeActiveAndSave()); 42 | this.div = this._makeToolBox() 43 | new ContextMenu(this.saveDrawings, this._drawingTool); 44 | 45 | commandFunctions.push((event: KeyboardEvent) => { 46 | if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') { 47 | const drawingToDelete = this._drawingTool.drawings.pop(); 48 | if (drawingToDelete) this._drawingTool.delete(drawingToDelete) 49 | return true; 50 | } 51 | return false; 52 | }); 53 | } 54 | 55 | toJSON() { 56 | // Exclude the chart attribute from serialization 57 | const { ...serialized} = this; 58 | return serialized; 59 | } 60 | 61 | private _makeToolBox() { 62 | let div = document.createElement('div') 63 | div.classList.add('toolbox'); 64 | this.buttons.push(this._makeToolBoxElement(TrendLine, 'KeyT', ToolBox.TREND_SVG)) 65 | this.buttons.push(this._makeToolBoxElement(HorizontalLine, 'KeyH', ToolBox.HORZ_SVG)); 66 | this.buttons.push(this._makeToolBoxElement(RayLine, 'KeyR', ToolBox.RAY_SVG)); 67 | this.buttons.push(this._makeToolBoxElement(Box, 'KeyB', ToolBox.BOX_SVG)); 68 | this.buttons.push(this._makeToolBoxElement(VerticalLine, 'KeyV', ToolBox.VERT_SVG, true)); 69 | for (const button of this.buttons) { 70 | div.appendChild(button); 71 | } 72 | return div 73 | } 74 | 75 | private _makeToolBoxElement(DrawingType: new (...args: any[]) => Drawing, keyCmd: string, paths: string, rotate=false) { 76 | const elem = document.createElement('div') 77 | elem.classList.add("toolbox-button"); 78 | 79 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 80 | svg.setAttribute("width", "29"); 81 | svg.setAttribute("height", "29"); 82 | 83 | const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); 84 | group.innerHTML = paths 85 | group.setAttribute("fill", window.pane.color) 86 | 87 | svg.appendChild(group) 88 | elem.appendChild(svg); 89 | 90 | const icon: Icon = {div: elem, group: group, type: DrawingType} 91 | 92 | elem.addEventListener('click', () => this._onIconClick(icon)); 93 | 94 | this._commandFunctions.push((event: KeyboardEvent) => { 95 | if (this._handlerID !== window.handlerInFocus) return false; 96 | 97 | if (event.altKey && event.code === keyCmd) { 98 | event.preventDefault() 99 | this._onIconClick(icon); 100 | return true 101 | } 102 | return false; 103 | }) 104 | 105 | if (rotate == true) { 106 | svg.style.transform = 'rotate(90deg)'; 107 | svg.style.transformBox = 'fill-box'; 108 | svg.style.transformOrigin = 'center'; 109 | } 110 | 111 | return elem 112 | } 113 | 114 | private _onIconClick(icon: Icon) { 115 | if (this.activeIcon) { 116 | 117 | this.activeIcon.div.classList.remove('active-toolbox-button'); 118 | window.setCursor('crosshair'); 119 | this._drawingTool?.stopDrawing() 120 | if (this.activeIcon === icon) { 121 | this.activeIcon = null 122 | return 123 | } 124 | } 125 | this.activeIcon = icon 126 | this.activeIcon.div.classList.add('active-toolbox-button') 127 | window.setCursor('crosshair'); 128 | this._drawingTool?.beginDrawing(this.activeIcon.type); 129 | } 130 | 131 | removeActiveAndSave = () => { 132 | window.setCursor('default'); 133 | if (this.activeIcon) this.activeIcon.div.classList.remove('active-toolbox-button') 134 | this.activeIcon = null 135 | this.saveDrawings() 136 | } 137 | 138 | addNewDrawing(d: Drawing) { 139 | this._drawingTool.addNewDrawing(d); 140 | } 141 | 142 | clearDrawings() { 143 | this._drawingTool.clearDrawings(); 144 | } 145 | 146 | saveDrawings = () => { 147 | const drawingMeta = [] 148 | for (const d of this._drawingTool.drawings) { 149 | drawingMeta.push({ 150 | type: d._type, 151 | points: d.points, 152 | options: d._options 153 | }); 154 | } 155 | const string = JSON.stringify(drawingMeta); 156 | window.callbackFunction(`save_drawings${this._handlerID}_~_${string}`) 157 | } 158 | 159 | loadDrawings(drawings: any[]) { // TODO any 160 | drawings.forEach((d) => { 161 | switch (d.type) { 162 | case "Box": 163 | this._drawingTool.addNewDrawing(new Box(d.points[0], d.points[1], d.options)); 164 | break; 165 | case "TrendLine": 166 | this._drawingTool.addNewDrawing(new TrendLine(d.points[0], d.points[1], d.options)); 167 | break; 168 | case "HorizontalLine": 169 | this._drawingTool.addNewDrawing(new HorizontalLine(d.points[0], d.options)); 170 | break; 171 | case "RayLine": 172 | this._drawingTool.addNewDrawing(new RayLine(d.points[0], d.options)); 173 | break; 174 | case "VerticalLine": 175 | this._drawingTool.addNewDrawing(new VerticalLine(d.points[0], d.options)); 176 | break; 177 | } 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/general/topbar.ts: -------------------------------------------------------------------------------- 1 | import { GlobalParams } from "./global-params"; 2 | import { Handler } from "./handler"; 3 | import { Menu } from "./menu"; 4 | 5 | declare const window: GlobalParams 6 | 7 | interface Widget { 8 | elem: HTMLDivElement; 9 | callbackName: string; 10 | intervalElements: HTMLButtonElement[]; 11 | onItemClicked: Function; 12 | } 13 | 14 | export class TopBar { 15 | private _handler: Handler; 16 | public _div: HTMLDivElement; 17 | 18 | private left: HTMLDivElement; 19 | private right: HTMLDivElement; 20 | 21 | constructor(handler: Handler) { 22 | this._handler = handler; 23 | 24 | this._div = document.createElement('div'); 25 | this._div.classList.add('topbar'); 26 | 27 | const createTopBarContainer = (justification: string) => { 28 | const div = document.createElement('div') 29 | div.classList.add('topbar-container') 30 | div.style.justifyContent = justification 31 | this._div.appendChild(div) 32 | return div 33 | } 34 | this.left = createTopBarContainer('flex-start') 35 | this.right = createTopBarContainer('flex-end') 36 | } 37 | 38 | makeSwitcher(items: string[], defaultItem: string, callbackName: string, align='left') { 39 | const switcherElement = document.createElement('div'); 40 | switcherElement.style.margin = '4px 12px' 41 | 42 | let activeItemEl: HTMLButtonElement; 43 | 44 | const createAndReturnSwitcherButton = (itemName: string) => { 45 | const button = document.createElement('button'); 46 | button.classList.add('topbar-button'); 47 | button.classList.add('switcher-button'); 48 | button.style.margin = '0px 2px'; 49 | button.innerText = itemName; 50 | 51 | if (itemName == defaultItem) { 52 | activeItemEl = button; 53 | button.classList.add('active-switcher-button'); 54 | } 55 | 56 | const buttonWidth = TopBar.getClientWidth(button) 57 | button.style.minWidth = buttonWidth + 1 + 'px' 58 | button.addEventListener('click', () => widget.onItemClicked(button)) 59 | 60 | switcherElement.appendChild(button); 61 | return button; 62 | } 63 | 64 | const widget: Widget = { 65 | elem: switcherElement, 66 | callbackName: callbackName, 67 | intervalElements: items.map(createAndReturnSwitcherButton), 68 | onItemClicked: (item: HTMLButtonElement) => { 69 | if (item == activeItemEl) return 70 | activeItemEl.classList.remove('active-switcher-button'); 71 | item.classList.add('active-switcher-button'); 72 | activeItemEl = item; 73 | window.callbackFunction(`${widget.callbackName}_~_${item.innerText}`); 74 | } 75 | } 76 | 77 | this.appendWidget(switcherElement, align, true) 78 | return widget 79 | } 80 | 81 | makeTextBoxWidget(text: string, align='left', callbackName=null) { 82 | if (callbackName) { 83 | const textBox = document.createElement('input'); 84 | textBox.classList.add('topbar-textbox-input'); 85 | textBox.value = text 86 | textBox.style.width = `${(textBox.value.length+2)}ch` 87 | textBox.addEventListener('focus', () => { 88 | window.textBoxFocused = true; 89 | }) 90 | textBox.addEventListener('input', (e) => { 91 | e.preventDefault(); 92 | textBox.style.width = `${(textBox.value.length+2)}ch`; 93 | }); 94 | textBox.addEventListener('keydown', (e) => { 95 | if (e.key == 'Enter') { 96 | e.preventDefault(); 97 | textBox.blur(); 98 | } 99 | }); 100 | textBox.addEventListener('blur', () => { 101 | window.callbackFunction(`${callbackName}_~_${textBox.value}`) 102 | window.textBoxFocused = false; 103 | }); 104 | this.appendWidget(textBox, align, true) 105 | return textBox 106 | } else { 107 | const textBox = document.createElement('div'); 108 | textBox.classList.add('topbar-textbox'); 109 | textBox.innerText = text 110 | this.appendWidget(textBox, align, true) 111 | return textBox 112 | } 113 | } 114 | 115 | makeMenu(items: string[], activeItem: string, separator: boolean, callbackName: string, align: 'right'|'left') { 116 | return new Menu(this.makeButton.bind(this), callbackName, items, activeItem, separator, align) 117 | } 118 | 119 | makeButton(defaultText: string, callbackName: string | null, separator: boolean, append=true, align='left', toggle=false) { 120 | let button = document.createElement('button') 121 | button.classList.add('topbar-button'); 122 | // button.style.color = window.pane.color 123 | button.innerText = defaultText; 124 | document.body.appendChild(button) 125 | button.style.minWidth = button.clientWidth+1+'px' 126 | document.body.removeChild(button) 127 | 128 | let widget = { 129 | elem: button, 130 | callbackName: callbackName 131 | } 132 | 133 | if (callbackName) { 134 | let handler; 135 | if (toggle) { 136 | let state = false; 137 | handler = () => { 138 | state = !state 139 | window.callbackFunction(`${widget.callbackName}_~_${state}`) 140 | button.style.backgroundColor = state ? 'var(--active-bg-color)' : ''; 141 | button.style.color = state ? 'var(--active-color)' : ''; 142 | } 143 | } else { 144 | handler = () => window.callbackFunction(`${widget.callbackName}_~_${button.innerText}`) 145 | } 146 | button.addEventListener('click', handler); 147 | } 148 | if (append) this.appendWidget(button, align, separator) 149 | return widget 150 | } 151 | 152 | makeSeparator(align='left') { 153 | const separator = document.createElement('div') 154 | separator.classList.add('topbar-seperator') 155 | const div = align == 'left' ? this.left : this.right 156 | div.appendChild(separator) 157 | } 158 | 159 | appendWidget(widget: HTMLElement, align: string, separator: boolean) { 160 | const div = align == 'left' ? this.left : this.right 161 | if (separator) { 162 | if (align == 'left') div.appendChild(widget) 163 | this.makeSeparator(align) 164 | if (align == 'right') div.appendChild(widget) 165 | } else div.appendChild(widget) 166 | this._handler.reSize(); 167 | } 168 | 169 | private static getClientWidth(element: HTMLElement) { 170 | document.body.appendChild(element); 171 | const width = element.clientWidth; 172 | document.body.removeChild(element); 173 | return width; 174 | } 175 | } 176 | 177 | 178 | -------------------------------------------------------------------------------- /src/helpers/assertions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensures that value is defined. 3 | * Throws if the value is undefined, returns the original value otherwise. 4 | * 5 | * @param value - The value, or undefined. 6 | * @returns The passed value, if it is not undefined 7 | */ 8 | export function ensureDefined(value: undefined): never; 9 | export function ensureDefined(value: T | undefined): T; 10 | export function ensureDefined(value: T | undefined): T { 11 | if (value === undefined) { 12 | throw new Error('Value is undefined'); 13 | } 14 | 15 | return value; 16 | } 17 | 18 | /** 19 | * Ensures that value is not null. 20 | * Throws if the value is null, returns the original value otherwise. 21 | * 22 | * @param value - The value, or null. 23 | * @returns The passed value, if it is not null 24 | */ 25 | export function ensureNotNull(value: null): never; 26 | export function ensureNotNull(value: T | null): T; 27 | export function ensureNotNull(value: T | null): T { 28 | if (value === null) { 29 | throw new Error('Value is null'); 30 | } 31 | 32 | return value; 33 | } 34 | -------------------------------------------------------------------------------- /src/helpers/canvas-rendering.ts: -------------------------------------------------------------------------------- 1 | import { LineStyle } from "lightweight-charts"; 2 | 3 | export function setLineStyle(ctx: CanvasRenderingContext2D, style: LineStyle): void { 4 | const dashPatterns = { 5 | [LineStyle.Solid]: [], 6 | [LineStyle.Dotted]: [ctx.lineWidth, ctx.lineWidth], 7 | [LineStyle.Dashed]: [2 * ctx.lineWidth, 2 * ctx.lineWidth], 8 | [LineStyle.LargeDashed]: [6 * ctx.lineWidth, 6 * ctx.lineWidth], 9 | [LineStyle.SparseDotted]: [ctx.lineWidth, 4 * ctx.lineWidth], 10 | }; 11 | 12 | const dashPattern = dashPatterns[style]; 13 | ctx.setLineDash(dashPattern); 14 | } -------------------------------------------------------------------------------- /src/helpers/dimensions/common.ts: -------------------------------------------------------------------------------- 1 | export interface BitmapPositionLength { 2 | /** coordinate for use with a bitmap rendering scope */ 3 | position: number; 4 | /** length for use with a bitmap rendering scope */ 5 | length: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/dimensions/crosshair-width.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default grid / crosshair line width in Bitmap sizing 3 | * @param horizontalPixelRatio - horizontal pixel ratio 4 | * @returns default grid / crosshair line width in Bitmap sizing 5 | */ 6 | export function gridAndCrosshairBitmapWidth( 7 | horizontalPixelRatio: number 8 | ): number { 9 | return Math.max(1, Math.floor(horizontalPixelRatio)); 10 | } 11 | 12 | /** 13 | * Default grid / crosshair line width in Media sizing 14 | * @param horizontalPixelRatio - horizontal pixel ratio 15 | * @returns default grid / crosshair line width in Media sizing 16 | */ 17 | export function gridAndCrosshairMediaWidth( 18 | horizontalPixelRatio: number 19 | ): number { 20 | return ( 21 | gridAndCrosshairBitmapWidth(horizontalPixelRatio) / horizontalPixelRatio 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/helpers/dimensions/full-width.ts: -------------------------------------------------------------------------------- 1 | import { BitmapPositionLength } from './common'; 2 | 3 | /** 4 | * Calculates the position and width which will completely full the space for the bar. 5 | * Useful if you want to draw something that will not have any gaps between surrounding bars. 6 | * @param xMedia - x coordinate of the bar defined in media sizing 7 | * @param halfBarSpacingMedia - half the width of the current barSpacing (un-rounded) 8 | * @param horizontalPixelRatio - horizontal pixel ratio 9 | * @returns position and width which will completely full the space for the bar 10 | */ 11 | export function fullBarWidth( 12 | xMedia: number, 13 | halfBarSpacingMedia: number, 14 | horizontalPixelRatio: number 15 | ): BitmapPositionLength { 16 | const fullWidthLeftMedia = xMedia - halfBarSpacingMedia; 17 | const fullWidthRightMedia = xMedia + halfBarSpacingMedia; 18 | const fullWidthLeftBitmap = Math.round( 19 | fullWidthLeftMedia * horizontalPixelRatio 20 | ); 21 | const fullWidthRightBitmap = Math.round( 22 | fullWidthRightMedia * horizontalPixelRatio 23 | ); 24 | const fullWidthBitmap = fullWidthRightBitmap - fullWidthLeftBitmap; 25 | return { 26 | position: fullWidthLeftBitmap, 27 | length: fullWidthBitmap, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/helpers/dimensions/positions.ts: -------------------------------------------------------------------------------- 1 | import { BitmapPositionLength } from './common'; 2 | 3 | function centreOffset(lineBitmapWidth: number): number { 4 | return Math.floor(lineBitmapWidth * 0.5); 5 | } 6 | 7 | /** 8 | * Calculates the bitmap position for an item with a desired length (height or width), and centred according to 9 | * an position coordinate defined in media sizing. 10 | * @param positionMedia - position coordinate for the bar (in media coordinates) 11 | * @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions 12 | * @param desiredWidthMedia - desired width (in media coordinates) 13 | * @returns Position of of the start point and length dimension. 14 | */ 15 | export function positionsLine( 16 | positionMedia: number, 17 | pixelRatio: number, 18 | desiredWidthMedia: number = 1, 19 | widthIsBitmap?: boolean 20 | ): BitmapPositionLength { 21 | const scaledPosition = Math.round(pixelRatio * positionMedia); 22 | const lineBitmapWidth = widthIsBitmap 23 | ? desiredWidthMedia 24 | : Math.round(desiredWidthMedia * pixelRatio); 25 | const offset = centreOffset(lineBitmapWidth); 26 | const position = scaledPosition - offset; 27 | return { position, length: lineBitmapWidth }; 28 | } 29 | 30 | /** 31 | * Determines the bitmap position and length for a dimension of a shape to be drawn. 32 | * @param position1Media - media coordinate for the first point 33 | * @param position2Media - media coordinate for the second point 34 | * @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal) 35 | * @returns Position of of the start point and length dimension. 36 | */ 37 | export function positionsBox( 38 | position1Media: number, 39 | position2Media: number, 40 | pixelRatio: number 41 | ): BitmapPositionLength { 42 | const scaledPosition1 = Math.round(pixelRatio * position1Media); 43 | const scaledPosition2 = Math.round(pixelRatio * position2Media); 44 | return { 45 | position: Math.min(scaledPosition1, scaledPosition2), 46 | length: Math.abs(scaledPosition2 - scaledPosition1) + 1, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/helpers/time.ts: -------------------------------------------------------------------------------- 1 | import { Time, isUTCTimestamp, isBusinessDay } from 'lightweight-charts'; 2 | 3 | export function convertTime(t: Time): number { 4 | if (isUTCTimestamp(t)) return t * 1000; 5 | if (isBusinessDay(t)) return new Date(t.year, t.month, t.day).valueOf(); 6 | const [year, month, day] = t.split('-').map(parseInt); 7 | return new Date(year, month, day).valueOf(); 8 | } 9 | 10 | export function displayTime(time: Time): string { 11 | if (typeof time == 'string') return time; 12 | const date = isBusinessDay(time) 13 | ? new Date(time.year, time.month, time.day) 14 | : new Date(time * 1000); 15 | return date.toLocaleDateString(); 16 | } 17 | 18 | export function formattedDateAndTime(timestamp: number | undefined): [string, string] { 19 | if (!timestamp) return ['', '']; 20 | const dateObj = new Date(timestamp); 21 | 22 | // Format date string 23 | const year = dateObj.getFullYear(); 24 | const month = dateObj.toLocaleString('default', { month: 'short' }); 25 | const date = dateObj.getDate().toString().padStart(2, '0'); 26 | const formattedDate = `${date} ${month} ${year}`; 27 | 28 | // Format time string 29 | const hours = dateObj.getHours().toString().padStart(2, '0'); 30 | const minutes = dateObj.getMinutes().toString().padStart(2, '0'); 31 | const formattedTime = `${hours}:${minutes}`; 32 | 33 | return [formattedDate, formattedTime]; 34 | } 35 | -------------------------------------------------------------------------------- /src/horizontal-line/axis-view.ts: -------------------------------------------------------------------------------- 1 | import { Coordinate, ISeriesPrimitiveAxisView, PriceFormatBuiltIn } from 'lightweight-charts'; 2 | import { HorizontalLine } from './horizontal-line'; 3 | 4 | export class HorizontalLineAxisView implements ISeriesPrimitiveAxisView { 5 | _source: HorizontalLine; 6 | _y: Coordinate | null = null; 7 | _price: string | null = null; 8 | 9 | constructor(source: HorizontalLine) { 10 | this._source = source; 11 | } 12 | update() { 13 | if (!this._source.series || !this._source._point) return; 14 | this._y = this._source.series.priceToCoordinate(this._source._point.price); 15 | const priceFormat = this._source.series.options().priceFormat as PriceFormatBuiltIn; 16 | const precision = priceFormat.precision; 17 | this._price = this._source._point.price.toFixed(precision).toString(); 18 | } 19 | visible() { 20 | return true; 21 | } 22 | tickVisible() { 23 | return true; 24 | } 25 | coordinate() { 26 | return this._y ?? 0; 27 | } 28 | text() { 29 | return this._source._options.text || this._price || ''; 30 | } 31 | textColor() { 32 | return 'white'; 33 | } 34 | backColor() { 35 | return this._source._options.lineColor; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/horizontal-line/horizontal-line.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeepPartial, 3 | MouseEventParams 4 | } from "lightweight-charts"; 5 | import { Point } from "../drawing/data-source"; 6 | import { Drawing, InteractionState } from "../drawing/drawing"; 7 | import { DrawingOptions } from "../drawing/options"; 8 | import { HorizontalLinePaneView } from "./pane-view"; 9 | import { GlobalParams } from "../general/global-params"; 10 | import { HorizontalLineAxisView } from "./axis-view"; 11 | 12 | 13 | declare const window: GlobalParams; 14 | 15 | export class HorizontalLine extends Drawing { 16 | _type = 'HorizontalLine'; 17 | _paneViews: HorizontalLinePaneView[]; 18 | _point: Point; 19 | private _callbackName: string | null; 20 | _priceAxisViews: HorizontalLineAxisView[]; 21 | 22 | protected _startDragPoint: Point | null = null; 23 | 24 | constructor(point: Point, options: DeepPartial, callbackName=null) { 25 | super(options) 26 | this._point = point; 27 | this._point.time = null; // time is null for horizontal lines 28 | this._paneViews = [new HorizontalLinePaneView(this)]; 29 | this._priceAxisViews = [new HorizontalLineAxisView(this)]; 30 | 31 | this._callbackName = callbackName; 32 | } 33 | 34 | public get points() { 35 | return [this._point]; 36 | } 37 | 38 | public updatePoints(...points: (Point | null)[]) { 39 | for (const p of points) if (p) this._point.price = p.price; 40 | this.requestUpdate(); 41 | } 42 | 43 | updateAllViews() { 44 | this._paneViews.forEach((pw) => pw.update()); 45 | this._priceAxisViews.forEach((tw) => tw.update()); 46 | } 47 | 48 | priceAxisViews() { 49 | return this._priceAxisViews; 50 | } 51 | 52 | _moveToState(state: InteractionState) { 53 | switch(state) { 54 | case InteractionState.NONE: 55 | document.body.style.cursor = "default"; 56 | this._unsubscribe("mousedown", this._handleMouseDownInteraction); 57 | break; 58 | 59 | case InteractionState.HOVERING: 60 | document.body.style.cursor = "pointer"; 61 | this._unsubscribe("mouseup", this._childHandleMouseUpInteraction); 62 | this._subscribe("mousedown", this._handleMouseDownInteraction) 63 | this.chart.applyOptions({handleScroll: true}); 64 | break; 65 | 66 | case InteractionState.DRAGGING: 67 | document.body.style.cursor = "grabbing"; 68 | this._subscribe("mouseup", this._childHandleMouseUpInteraction); 69 | this.chart.applyOptions({handleScroll: false}); 70 | break; 71 | } 72 | this._state = state; 73 | } 74 | 75 | _onDrag(diff: any) { 76 | this._addDiffToPoint(this._point, 0, diff.price); 77 | this.requestUpdate(); 78 | } 79 | 80 | _mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) { 81 | if (!param.point) return false; 82 | const y = this.series.priceToCoordinate(this._point.price); 83 | if (!y) return false; 84 | return (Math.abs(y-param.point.y) < tolerance); 85 | } 86 | 87 | protected _onMouseDown() { 88 | this._startDragPoint = null; 89 | const hoverPoint = this._latestHoverPoint; 90 | if (!hoverPoint) return; 91 | return this._moveToState(InteractionState.DRAGGING); 92 | } 93 | 94 | protected _childHandleMouseUpInteraction = () => { 95 | this._handleMouseUpInteraction(); 96 | if (!this._callbackName) return; 97 | window.callbackFunction(`${this._callbackName}_~_${this._point.price.toFixed(8)}`); 98 | } 99 | } -------------------------------------------------------------------------------- /src/horizontal-line/pane-renderer.ts: -------------------------------------------------------------------------------- 1 | import { CanvasRenderingTarget2D } from "fancy-canvas"; 2 | import { DrawingOptions } from "../drawing/options"; 3 | import { DrawingPaneRenderer } from "../drawing/pane-renderer"; 4 | import { ViewPoint } from "../drawing/pane-view"; 5 | import { setLineStyle } from "../helpers/canvas-rendering"; 6 | 7 | export class HorizontalLinePaneRenderer extends DrawingPaneRenderer { 8 | _point: ViewPoint = {x: null, y: null}; 9 | 10 | constructor(point: ViewPoint, options: DrawingOptions) { 11 | super(options); 12 | this._point = point; 13 | } 14 | 15 | draw(target: CanvasRenderingTarget2D) { 16 | target.useBitmapCoordinateSpace(scope => { 17 | if (this._point.y == null) return; 18 | const ctx = scope.context; 19 | 20 | const scaledY = Math.round(this._point.y * scope.verticalPixelRatio); 21 | const scaledX = this._point.x ? this._point.x * scope.horizontalPixelRatio : 0; 22 | 23 | ctx.lineWidth = this._options.width; 24 | ctx.strokeStyle = this._options.lineColor; 25 | setLineStyle(ctx, this._options.lineStyle); 26 | ctx.beginPath(); 27 | 28 | ctx.moveTo(scaledX, scaledY); 29 | ctx.lineTo(scope.bitmapSize.width, scaledY); 30 | 31 | ctx.stroke(); 32 | }); 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/horizontal-line/pane-view.ts: -------------------------------------------------------------------------------- 1 | import { HorizontalLinePaneRenderer } from './pane-renderer'; 2 | import { HorizontalLine } from './horizontal-line'; 3 | import { DrawingPaneView, ViewPoint } from '../drawing/pane-view'; 4 | 5 | 6 | export class HorizontalLinePaneView extends DrawingPaneView { 7 | _source: HorizontalLine; 8 | _point: ViewPoint = {x: null, y: null}; 9 | 10 | constructor(source: HorizontalLine) { 11 | super(source); 12 | this._source = source; 13 | } 14 | 15 | update() { 16 | const point = this._source._point; 17 | const timeScale = this._source.chart.timeScale() 18 | const series = this._source.series; 19 | if (this._source._type == "RayLine") { 20 | this._point.x = point.time ? timeScale.timeToCoordinate(point.time) : timeScale.logicalToCoordinate(point.logical); 21 | } 22 | this._point.y = series.priceToCoordinate(point.price); 23 | } 24 | 25 | renderer() { 26 | return new HorizontalLinePaneRenderer( 27 | this._point, 28 | this._source._options 29 | ); 30 | } 31 | } -------------------------------------------------------------------------------- /src/horizontal-line/ray-line.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeepPartial, 3 | MouseEventParams 4 | } from "lightweight-charts"; 5 | import { DiffPoint, Point } from "../drawing/data-source"; 6 | import { DrawingOptions } from "../drawing/options"; 7 | import { HorizontalLine } from "./horizontal-line"; 8 | 9 | export class RayLine extends HorizontalLine { 10 | _type = 'RayLine'; 11 | 12 | constructor(point: Point, options: DeepPartial) { 13 | super({...point}, options); 14 | this._point.time = point.time; 15 | } 16 | 17 | public updatePoints(...points: (Point | null)[]) { 18 | for (const p of points) if (p) this._point = p; 19 | this.requestUpdate(); 20 | } 21 | 22 | _onDrag(diff: DiffPoint) { 23 | this._addDiffToPoint(this._point, diff.logical, diff.price); 24 | this.requestUpdate(); 25 | } 26 | 27 | _mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) { 28 | if (!param.point) return false; 29 | const y = this.series.priceToCoordinate(this._point.price); 30 | 31 | const x = this._point.time ? this.chart.timeScale().timeToCoordinate(this._point.time) : null; 32 | if (!y || !x) return false; 33 | return (Math.abs(y-param.point.y) < tolerance && param.point.x > x - tolerance); 34 | } 35 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './general'; 2 | export * from './horizontal-line/horizontal-line'; 3 | export * from './vertical-line/vertical-line'; 4 | export * from './box/box'; 5 | export * from './trend-line/trend-line'; 6 | export * from './vertical-line/vertical-line'; -------------------------------------------------------------------------------- /src/plugin-base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataChangedScope, 3 | IChartApi, 4 | ISeriesApi, 5 | ISeriesPrimitive, 6 | SeriesAttachedParameter, 7 | SeriesOptionsMap, 8 | Time, 9 | } from 'lightweight-charts'; 10 | import { ensureDefined } from './helpers/assertions'; 11 | 12 | //* PluginBase is a useful base to build a plugin upon which 13 | //* already handles creating getters for the chart and series, 14 | //* and provides a requestUpdate method. 15 | export abstract class PluginBase implements ISeriesPrimitive