Merge pull request #385 from louisnw01/2.0

2.0
This commit is contained in:
louisnw01
2024-06-01 13:40:57 +01:00
committed by GitHub
89 changed files with 5821 additions and 2370 deletions

View File

@ -22,12 +22,12 @@ ___
## Features
1. Streamlined for live data, with methods for updating directly from tick data.
2. Multi-pane charts using [Subcharts](https://lightweight-charts-python.readthedocs.io/en/latest/reference/abstract_chart.html#AbstractChart.create_subchart).
3. The [Toolbox](https://lightweight-charts-python.readthedocs.io/en/latest/reference/toolbox.html), allowing for trendlines, rays and horizontal lines to be drawn directly onto charts.
3. The [Toolbox](https://lightweight-charts-python.readthedocs.io/en/latest/reference/toolbox.html), allowing for trendlines, rectangles, rays and horizontal lines to be drawn directly onto charts.
4. [Events](https://lightweight-charts-python.readthedocs.io/en/latest/tutorials/events.html) allowing for timeframe selectors (1min, 5min, 30min etc.), searching, hotkeys, and more.
5. [Tables](https://lightweight-charts-python.readthedocs.io/en/latest/reference/tables.html) for watchlists, order entry, and trade management.
6. Direct integration of market data through [Polygon.io's](https://polygon.io/?utm_source=affiliate&utm_campaign=pythonlwcharts) market data API.
__Supports:__ Jupyter Notebooks, PyQt5, PySide6, wxPython, Streamlit, and asyncio.
__Supports:__ Jupyter Notebooks, PyQt6, PyQt5, PySide6, wxPython, Streamlit, and asyncio.
PartTimeLarry: [Interactive Brokers API and TradingView Charts in Python](https://www.youtube.com/watch?v=TlhDI3PforA)
___

34
build.sh Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
NC='\033[0m'
ERROR="${RED}[ERROR]${NC} "
INFO="${CYAN}[INFO]${NC} "
WARNING="${WARNING}[WARNING]${NC} "
rm -rf dist/bundle.js dist/typings/
if [[ $? -eq 0 ]]; then
echo -e "${INFO}deleted bundle.js and typings.."
else
echo -e "${WARNING}could not delete old dist files, continuing.."
fi
npx rollup -c rollup.config.js
if [[ $? -ne 0 ]]; then
exit 1
fi
cp dist/bundle.js src/general/styles.css lightweight_charts/js
if [[ $? -eq 0 ]]; then
echo -e "${INFO}copied bundle.js, style.css into python package"
else
echo -e "${ERROR}could not copy dist into python package ?"
exit 1
fi
echo -e "\n${GREEN}[BUILD SUCCESS]${NC}"

View File

@ -1,7 +1,7 @@
# Alternative GUI's
## PyQt5 / PySide6
## PyQt6 / PyQt5 / PySide6
```python
import pandas as pd

View File

@ -36,7 +36,7 @@ def on_timeframe_selection(chart):
if new_data.empty:
return
# The symbol has not changed, so we want to re-render the drawings.
chart.set(new_data, render_drawings=True)
chart.set(new_data, keep_drawings=True)
if __name__ == '__main__':

View File

@ -22,7 +22,7 @@ The `websockets` library is required when using live data.
```{important}
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)`).
If `show_async` is used with live data, `block` can be either value.
`show_async` can also be used with live data.
```

View File

@ -8,7 +8,7 @@ ___
```{py:method} set(data: pd.DataFrame, render_drawings: bool = False)
```{py:method} set(data: pd.DataFrame, keep_drawings: bool = False)
Sets the initial data for the chart.
@ -17,9 +17,11 @@ Columns should be named:
Time can be given in the index rather than a column, and volume can be omitted if volume is not used. Column names are not case sensitive.
If `render_drawings` is `True`, any drawings made using the `toolbox` will be redrawn with the new data. This is designed to be used when switching to a different timeframe of the same symbol.
If `keep_drawings` is `True`, any drawings made using the `toolbox` will be redrawn with the new data. This is designed to be used when switching to a different timeframe of the same symbol.
`None` can also be given, which will erase all candle and volume data displayed on the chart.
You can also add columns to color the candles (https://tradingview.github.io/lightweight-charts/tutorials/customization/data-points)
```
@ -27,7 +29,7 @@ ___
```{py:method} update(series: pd.Series, render_drawings: bool = False)
```{py:method} update(series: pd.Series, keep_drawings: bool = False)
Updates the chart data from a bar.
Series labels should be akin to [`set`](#AbstractChart.set).

View File

@ -45,7 +45,7 @@ ___
```{py:method} show_async(block: bool)
```{py:method} show_async()
:async:
Show the chart asynchronously.
@ -85,7 +85,7 @@ ___
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.
Either the `PyQt5` or `PySide6` libraries will work with this chart.
Either the `PyQt5`, `PyQt6` or `PySide6` libraries will work with this chart.
Callbacks can be received through the Qt event loop.
___

View File

@ -22,6 +22,11 @@ Fires when the range (visibleLogicalRange) changes.
```
```{py:method} click -> (chart: Chart, time: NUM, price: NUM)
Fires when the mouse is clicked, returning the time and price of the clicked location.
```
````
Tutorial: [Topbar & Events](../tutorials/events.md)

View File

@ -101,7 +101,7 @@ async def update_clock(chart):
async def main():
chart = Chart()
chart.topbar.textbox('clock')
await asyncio.gather(chart.show_async(block=True), update_clock(chart))
await asyncio.gather(chart.show_async(), update_clock(chart))
if __name__ == '__main__':
@ -130,7 +130,6 @@ async def data_loop(chart):
return
chart.update_from_tick(ticks.iloc[i])
await asyncio.sleep(0.03)
i += 1
def on_new_bar(chart):
@ -150,7 +149,7 @@ async def main():
df = pd.read_csv('ohlc.csv')
chart.set(df)
await asyncio.gather(chart.show_async(block=True), data_loop(chart))
await asyncio.gather(chart.show_async(), data_loop(chart))
if __name__ == '__main__':

7
index.html Normal file
View File

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<!-- redirect to example page -->
<meta http-equiv="refresh" content="0; URL=src/example/" />
</head>
</html>

View File

@ -1,67 +1,40 @@
import asyncio
import json
import os
from base64 import b64decode
from datetime import datetime
from typing import Union, Literal, List, Optional
from typing import Callable, Union, Literal, List, Optional
import pandas as pd
from .table import Table
from .toolbox import ToolBox
from .drawings import Box, HorizontalLine, RayLine, TrendLine, TwoPointDrawing, VerticalLine, VerticalSpan
from .topbar import TopBar
from .util import (
IDGen, jbool, Pane, Events, TIME, NUM, FLOAT,
LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, PRICE_SCALE_MODE,
line_style, marker_position, marker_shape, crosshair_mode, price_scale_mode, js_data,
BulkRunScript, Pane, Events, IDGen, as_enum, jbool, js_json, TIME, NUM, FLOAT,
LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE,
PRICE_SCALE_MODE, marker_position, marker_shape, js_data,
)
JS = {}
current_dir = os.path.dirname(os.path.abspath(__file__))
for file in ('pkg', 'funcs', 'callback', 'toolbox', 'table'):
with open(os.path.join(current_dir, 'js', f'{file}.js'), 'r', encoding='utf-8') as f:
JS[file] = f.read()
TEMPLATE = f"""
<!DOCTYPE html>
<html lang="">
<head>
<title>lightweight-charts-python</title>
<script>{JS['pkg']}</script>
<meta name="viewport" content ="width=device-width, initial-scale=1">
<style>
body {{
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}}
#wrapper {{
width: 100vw;
height: 100vh;
background-color: #000000;
}}
</style>
</head>
<body>
<div id="wrapper"></div>
<script>
{JS['funcs']}
{JS['table']}
</script>
</body>
</html>
"""
INDEX = os.path.join(current_dir, 'js', 'test.html')
class Window:
_id_gen = IDGen()
handlers = {}
def __init__(self, script_func: callable = None, js_api_code: str = None, run_script: callable = None):
def __init__(
self,
script_func: Optional[Callable] = None,
js_api_code: Optional[str] = None,
run_script: Optional[Callable] = None
):
self.loaded = False
self.script_func = script_func
self.scripts = []
self.final_scripts = []
self.bulk_run = BulkRunScript(script_func)
if run_script:
self.run_script = run_script
@ -73,65 +46,101 @@ class Window:
if self.loaded:
return
self.loaded = True
[self.run_script(script) for script in self.scripts]
[self.run_script(script) for script in self.final_scripts]
if hasattr(self, '_return_q'):
while not self.run_script_and_get('document.readyState == "complete"'):
continue # scary, but works
initial_script = ''
self.scripts.extend(self.final_scripts)
for script in self.scripts:
initial_script += f'\n{script}'
self.script_func(initial_script)
def run_script(self, script: str, run_last: bool = False):
"""
For advanced users; evaluates JavaScript within the Webview.
"""
if self.script_func is None:
raise AttributeError("script_func has not been set")
if self.loaded:
if self.bulk_run.enabled:
self.bulk_run.add_script(script)
else:
self.script_func(script)
return
self.scripts.append(script) if not run_last else self.final_scripts.append(script)
elif run_last:
self.final_scripts.append(script)
else:
self.scripts.append(script)
def run_script_and_get(self, script: str):
self.run_script(f'_~_~RETURN~_~_{script}')
return self._return_q.get()
def create_table(
self, width: NUM, height: NUM, headings: tuple, widths: tuple = None,
alignments: tuple = None, position: FLOAT = 'left', draggable: bool = False,
background_color: str = '#121417', border_color: str = 'rgb(70, 70, 70)',
border_width: int = 1, heading_text_colors: tuple = None,
heading_background_colors: tuple = None, return_clicked_cells: bool = False,
func: callable = None
self,
width: NUM,
height: NUM,
headings: tuple,
widths: Optional[tuple] = None,
alignments: Optional[tuple] = None,
position: FLOAT = 'left',
draggable: bool = False,
background_color: str = '#121417',
border_color: str = 'rgb(70, 70, 70)',
border_width: int = 1,
heading_text_colors: Optional[tuple] = None,
heading_background_colors: Optional[tuple] = None,
return_clicked_cells: bool = False,
func: Optional[Callable] = None
) -> 'Table':
return Table(self, width, height, headings, widths, alignments, position, draggable,
background_color, border_color, border_width, heading_text_colors,
heading_background_colors, return_clicked_cells, func)
return Table(*locals().values())
def create_subchart(self, position: FLOAT = 'left', width: float = 0.5, height: float = 0.5,
sync_id: str = None, scale_candles_only: bool = False,
sync_crosshairs_only: bool = False, toolbox: bool = False
def create_subchart(
self,
position: FLOAT = 'left',
width: float = 0.5,
height: float = 0.5,
sync_id: Optional[str] = None,
scale_candles_only: bool = False,
sync_crosshairs_only: bool = False,
toolbox: bool = False
) -> 'AbstractChart':
subchart = AbstractChart(self, width, height, scale_candles_only, toolbox, position=position)
subchart = AbstractChart(
self,
width,
height,
scale_candles_only,
toolbox,
position=position
)
if not sync_id:
return subchart
self.run_script(f'''
syncCharts({subchart.id}, {sync_id}, {jbool(sync_crosshairs_only)})
{subchart.id}.chart.timeScale().setVisibleLogicalRange(
{sync_id}.chart.timeScale().getVisibleLogicalRange()
Lib.Handler.syncCharts(
{subchart.id},
{sync_id},
{jbool(sync_crosshairs_only)}
)
''', run_last=True)
return subchart
def style(self, background_color: str = '#0c0d0f', hover_background_color: str = '#3c434c',
def style(
self,
background_color: str = '#0c0d0f',
hover_background_color: str = '#3c434c',
click_background_color: str = '#50565E',
active_background_color: str = 'rgba(0, 122, 255, 0.7)',
muted_background_color: str = 'rgba(0, 122, 255, 0.3)',
border_color: str = '#3C434C', color: str = '#d8d9db', active_color: str = '#ececed'):
self.run_script(f'''
window.pane = {{
backgroundColor: '{background_color}',
hoverBackgroundColor: '{hover_background_color}',
clickBackgroundColor: '{click_background_color}',
activeBackgroundColor: '{active_background_color}',
mutedBackgroundColor: '{muted_background_color}',
borderColor: '{border_color}',
color: '{color}',
activeColor: '{active_color}',
}}''')
border_color: str = '#3C434C',
color: str = '#d8d9db',
active_color: str = '#ececed'
):
self.run_script(f'Lib.Handler.setRootStyles({js_json(locals())});')
class SeriesCommon(Pane):
def __init__(self, chart: 'AbstractChart', name: str = None):
def __init__(self, chart: 'AbstractChart', name: str = ''):
super().__init__(chart.win)
self._chart = chart
if hasattr(chart, '_interval'):
@ -143,6 +152,7 @@ class SeriesCommon(Pane):
self.num_decimals = 2
self.offset = 0
self.data = pd.DataFrame()
self.markers = {}
def _set_interval(self, df: pd.DataFrame):
if not pd.api.types.is_datetime64_any_dtype(df['time']):
@ -169,16 +179,6 @@ class SeriesCommon(Pane):
self.offset = value
break
self.run_script(
f'if ({self.id}.toolBox) {self.id}.interval = {self._interval}'
)
def _push_to_legend(self):
self.run_script(f'''
{self._chart.id}.lines.push({self.id})
{self._chart.id}.legend.lines.push({self._chart.id}.legend.makeLineRow({self.id}))
''')
@staticmethod
def _format_labels(data, labels, index, exclude_lowercase):
def rename(la, mapper):
@ -209,7 +209,7 @@ class SeriesCommon(Pane):
series['time'] = self._single_datetime_format(series['time'])
return series
def _single_datetime_format(self, arg):
def _single_datetime_format(self, arg) -> float:
if isinstance(arg, (str, int, float)) or not pd.api.types.is_datetime64_any_dtype(arg):
try:
arg = pd.to_datetime(arg, unit='ms')
@ -218,7 +218,7 @@ class SeriesCommon(Pane):
arg = self._interval * (arg.timestamp() // self._interval)+self.offset
return arg
def set(self, df: pd.DataFrame = None, format_cols: bool = True):
def set(self, df: Optional[pd.DataFrame] = None, format_cols: bool = True):
if df is None or df.empty:
self.run_script(f'{self.id}.series.setData([])')
self.data = pd.DataFrame()
@ -231,7 +231,7 @@ class SeriesCommon(Pane):
df = df.rename(columns={self.name: 'value'})
self.data = df.copy()
self._last_bar = df.iloc[-1]
self.run_script(f'{self.id}.data = {js_data(df)}; {self.id}.series.setData({self.id}.data); ')
self.run_script(f'{self.id}.series.setData({js_data(df)}); ')
def update(self, series: pd.Series):
series = self._series_datetime_format(series, exclude_lowercase=self.name)
@ -241,16 +241,11 @@ class SeriesCommon(Pane):
self.data.loc[self.data.index[-1]] = self._last_bar
self.data = pd.concat([self.data, series.to_frame().T], ignore_index=True)
self._last_bar = series
bar = js_data(series)
self.run_script(f'''
if (stampToDate(lastBar({self.id}.data).time).getTime() === stampToDate({series['time']}).getTime()) {{
{self.id}.data[{self.id}.data.length-1] = {bar}
}}
else {self.id}.data.push({bar})
{self.id}.series.update({bar})
''')
self.run_script(f'{self.id}.series.update({js_data(series)})')
def _update_markers(self):
self.run_script(f'{self.id}.series.setMarkers({json.dumps(list(self.markers.values()))})')
def marker_list(self, markers: list):
"""
Creates multiple markers.\n
@ -264,19 +259,20 @@ class SeriesCommon(Pane):
"""
markers = markers.copy()
marker_ids = []
for i, marker in enumerate(markers):
markers[i]['time'] = self._single_datetime_format(markers[i]['time'])
markers[i]['position'] = marker_position(markers[i]['position'])
markers[i]['shape'] = marker_shape(markers[i]['shape'])
markers[i]['id'] = self.win._id_gen.generate()
marker_ids.append(markers[i]['id'])
self.run_script(f"""
{self.id}.markers.push(...{markers})
{self.id}.series.setMarkers({self.id}.markers)
""")
for marker in markers:
marker_id = self.win._id_gen.generate()
self.markers[marker_id] = {
"time": self._single_datetime_format(marker['time']),
"position": marker_position(marker['position']),
"color": marker['color'],
"shape": marker_shape(marker['shape']),
"text": marker['text'],
}
marker_ids.append(marker_id)
self._update_markers()
return marker_ids
def marker(self, time: datetime = None, position: MARKER_POSITION = 'below',
def marker(self, time: Optional[datetime] = None, position: MARKER_POSITION = 'below',
shape: MARKER_SHAPE = 'arrow_up', color: str = '#2196F3', text: str = ''
) -> str:
"""
@ -289,66 +285,93 @@ class SeriesCommon(Pane):
:return: The id of the marker placed.
"""
try:
time = self._last_bar['time'] if not time else self._single_datetime_format(time)
formatted_time = self._last_bar['time'] if not time else self._single_datetime_format(time)
except TypeError:
raise TypeError('Chart marker created before data was set.')
marker_id = self.win._id_gen.generate()
self.run_script(f"""
{self.id}.markers.push({{
time: {time if isinstance(time, float) else f"'{time}'"},
position: '{marker_position(position)}',
color: '{color}',
shape: '{marker_shape(shape)}',
text: '{text}',
id: '{marker_id}'
}});
{self.id}.series.setMarkers({self.id}.markers)""")
self.markers[marker_id] = {
"time": formatted_time,
"position": marker_position(position),
"color": color,
"shape": marker_shape(shape),
"text": text,
}
self._update_markers()
return marker_id
def remove_marker(self, marker_id: str):
"""
Removes the marker with the given id.\n
"""
self.run_script(f'''
{self.id}.markers.forEach(function (marker) {{
if ('{marker_id}' === marker.id) {{
{self.id}.markers.splice({self.id}.markers.indexOf(marker), 1)
{self.id}.series.setMarkers({self.id}.markers)
}}
}});''')
self.markers.pop(marker_id)
self._update_markers()
def horizontal_line(self, price: NUM, color: str = 'rgb(122, 146, 202)', width: int = 2,
style: LINE_STYLE = 'solid', text: str = '', axis_label_visible: bool = True,
func: callable = None
func: Optional[Callable] = None
) -> 'HorizontalLine':
"""
Creates a horizontal line at the given price.
"""
return HorizontalLine(self, price, color, width, style, text, axis_label_visible, func)
def remove_horizontal_line(self, price: NUM = None):
"""
Removes a horizontal line at the given price.
"""
self.run_script(f'''
{self.id}.horizontal_lines.forEach(function (line) {{
if ({price} === line.price) line.deleteLine()
}})''')
def trend_line(
self,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool = False,
line_color: str = '#1E80F0',
width: int = 2,
style: LINE_STYLE = 'solid',
) -> TwoPointDrawing:
return TrendLine(*locals().values())
def box(
self,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool = False,
color: str = '#1E80F0',
fill_color: str = 'rgba(255, 255, 255, 0.2)',
width: int = 2,
style: LINE_STYLE = 'solid',
) -> TwoPointDrawing:
return Box(*locals().values())
def ray_line(
self,
start_time: TIME,
value: NUM,
round: bool = False,
color: str = '#1E80F0',
width: int = 2,
style: LINE_STYLE = 'solid',
text: str = ''
) -> RayLine:
# TODO
return RayLine(*locals().values())
def vertical_line(
self,
time: TIME,
color: str = '#1E80F0',
width: int = 2,
style: LINE_STYLE ='solid',
text: str = ''
) -> VerticalLine:
return VerticalLine(*locals().values())
def clear_markers(self):
"""
Clears the markers displayed on the data.\n
"""
self.run_script(f'''{self.id}.markers = []; {self.id}.series.setMarkers([])''')
def clear_horizontal_lines(self):
"""
Clears the horizontal lines displayed on the data.\n
"""
self.run_script(f'''
{self.id}.horizontal_lines.forEach(function (line) {{{self.id}.series.removePriceLine(line.line);}});
{self.id}.horizontal_lines = [];
''')
self.markers.clear()
self._update_markers()
def price_line(self, label_visible: bool = True, line_visible: bool = True, title: str = ''):
self.run_script(f'''
@ -363,10 +386,10 @@ class SeriesCommon(Pane):
Sets the precision and minMove.\n
:param precision: The number of decimal places.
"""
min_move = 1 / (10**precision)
self.run_script(f'''
{self.id}.precision = {precision}
{self.id}.series.applyOptions({{
priceFormat: {{precision: {precision}, minMove: {1 / (10 ** precision)}}}
priceFormat: {{precision: {precision}, minMove: {min_move}}}
}})''')
self.num_decimals = precision
@ -382,8 +405,13 @@ class SeriesCommon(Pane):
if ('volumeSeries' in {self.id}) {self.id}.volumeSeries.applyOptions({{visible: {jbool(arg)}}})
''')
def vertical_span(self, start_time: Union[TIME, tuple, list], end_time: TIME = None,
color: str = 'rgba(252, 219, 3, 0.2)', round: bool = False):
def vertical_span(
self,
start_time: Union[TIME, tuple, list],
end_time: Optional[TIME] = None,
color: str = 'rgba(252, 219, 3, 0.2)',
round: bool = False
):
"""
Creates a vertical line or span across the chart.\n
Start time and end time can be used together, or end_time can be
@ -395,94 +423,18 @@ class SeriesCommon(Pane):
return VerticalSpan(self, start_time, end_time, color)
class HorizontalLine(Pane):
def __init__(self, chart, price, color, width, style, text, axis_label_visible, func):
super().__init__(chart.win)
self.price = price
self.run_script(f'''
{self.id} = new HorizontalLine(
{chart.id}, '{self.id}', {price}, '{color}', {width},
{line_style(style)}, {jbool(axis_label_visible)}, '{text}'
)''')
if not func:
return
def wrapper(p):
self.price = float(p)
func(chart, self)
async def wrapper_async(p):
self.price = float(p)
await func(chart, self)
self.win.handlers[self.id] = wrapper_async if asyncio.iscoroutinefunction(func) else wrapper
self.run_script(f'if ("toolBox" in {chart.id}) {chart.id}.toolBox.drawings.push({self.id})')
def update(self, price):
"""
Moves the horizontal line to the given price.
"""
self.run_script(f'{self.id}.updatePrice({price})')
self.price = price
def label(self, text: str):
self.run_script(f'{self.id}.updateLabel("{text}")')
def delete(self):
"""
Irreversibly deletes the horizontal line.
"""
self.run_script(f'{self.id}.deleteLine()')
del self
class VerticalSpan(Pane):
def __init__(self, series: 'SeriesCommon', start_time: Union[TIME, tuple, list], end_time: TIME = None,
color: str = 'rgba(252, 219, 3, 0.2)'):
self._chart = series._chart
super().__init__(self._chart.win)
start_time, end_time = pd.to_datetime(start_time), pd.to_datetime(end_time)
self.run_script(f'''
{self.id} = {self._chart.id}.chart.addHistogramSeries({{
color: '{color}',
priceFormat: {{type: 'volume'}},
priceScaleId: 'vertical_line',
lastValueVisible: false,
priceLineVisible: false,
}})
{self.id}.priceScale('').applyOptions({{
scaleMargins: {{top: 0, bottom: 0}}
}})
''')
if end_time is None:
if isinstance(start_time, pd.DatetimeIndex):
data = [{'time': time.timestamp(), 'value': 1} for time in start_time]
else:
data = [{'time': start_time.timestamp(), 'value': 1}]
self.run_script(f'{self.id}.setData({data})')
else:
self.run_script(f'''
{self.id}.setData(calculateTrendLine(
{start_time.timestamp()}, 1, {end_time.timestamp()}, 1, {series.id}))
''')
def delete(self):
"""
Irreversibly deletes the vertical span.
"""
self.run_script(f'{self._chart.id}.chart.removeSeries({self.id})')
class Line(SeriesCommon):
def __init__(self, chart, name, color, style, width, price_line, price_label, crosshair_marker=True):
super().__init__(chart, name)
self.color = color
self.run_script(f'''
{self.id} = {{
type: "line",
series: {chart.id}.chart.addLineSeries({{
{self.id} = {self._chart.id}.createLineSeries(
"{name}",
{{
color: '{color}',
lineStyle: {line_style(style)},
lineStyle: {as_enum(style, LINE_STYLE)},
lineWidth: {width},
lastValueVisible: {jbool(price_label)},
priceLineVisible: {jbool(price_line)},
@ -492,30 +444,26 @@ class Line(SeriesCommon):
minValue: 1_000_000_000,
maxValue: 0,
},
}),""" if chart._scale_candles_only else ''}
}}),
markers: [],
horizontal_lines: [],
name: '{name}',
color: '{color}',
precision: 2,
}),
""" if chart._scale_candles_only else ''}
}}
)
null''')
def _set_trend(self, start_time, start_value, end_time, end_value, ray=False, round=False):
if round:
start_time = self._single_datetime_format(start_time)
end_time = self._single_datetime_format(end_time)
else:
start_time, end_time = pd.to_datetime((start_time, end_time)).astype('int64') // 10 ** 9
# def _set_trend(self, start_time, start_value, end_time, end_value, ray=False, round=False):
# if round:
# start_time = self._single_datetime_format(start_time)
# end_time = self._single_datetime_format(end_time)
# else:
# start_time, end_time = pd.to_datetime((start_time, end_time)).astype('int64') // 10 ** 9
self.run_script(f'''
{self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: false}})
{self.id}.series.setData(
calculateTrendLine({start_time}, {start_value}, {end_time}, {end_value},
{self._chart.id}, {jbool(ray)}))
{self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: true}})
''')
# self.run_script(f'''
# {self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: false}})
# {self.id}.series.setData(
# calculateTrendLine({start_time}, {start_value}, {end_time}, {end_value},
# {self._chart.id}, {jbool(ray)}))
# {self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: true}})
# ''')
def delete(self):
"""
@ -545,8 +493,6 @@ class Histogram(SeriesCommon):
priceScaleId: '{self.id}',
priceFormat: {{type: "volume"}},
}}),
markers: [],
horizontal_lines: [],
name: '{name}',
color: '{color}',
precision: 2,
@ -582,13 +528,13 @@ class Candlestick(SeriesCommon):
self.candle_data = pd.DataFrame()
self.run_script(f'{self.id}.makeCandlestickSeries()')
# self.run_script(f'{self.id}.makeCandlestickSeries()')
def set(self, df: pd.DataFrame = None, render_drawings=False):
def set(self, df: Optional[pd.DataFrame] = None, keep_drawings=False):
"""
Sets the initial data for the chart.\n
:param df: columns: date/time, open, high, low, close, volume (if volume enabled).
:param render_drawings: Re-renders any drawings made through the toolbox. Otherwise, they will be deleted.
:param keep_drawings: keeps any drawings made through the toolbox. Otherwise, they will be deleted.
"""
if df is None or df.empty:
self.run_script(f'{self.id}.series.setData([])')
@ -598,10 +544,8 @@ class Candlestick(SeriesCommon):
df = self._df_datetime_format(df)
self.candle_data = df.copy()
self._last_bar = df.iloc[-1]
self.run_script(f'{self.id}.series.setData({js_data(df)})')
self.run_script(f'{self.id}.data = {js_data(df)}; {self.id}.series.setData({self.id}.data)')
toolbox_action = 'clearDrawings' if not render_drawings else 'renderDrawings'
self.run_script(f"if ('toolBox' in {self._chart.id}) {self._chart.id}.toolBox.{toolbox_action}()")
if 'volume' not in df:
return
volume = df.drop(columns=['open', 'high', 'low', 'close']).rename(columns={'volume': 'value'})
@ -618,8 +562,13 @@ class Candlestick(SeriesCommon):
if (!{self.id}.chart.priceScale("right").options.autoScale)
{self.id}.chart.priceScale("right").applyOptions({{autoScale: true}})
''')
# TODO keep drawings doesn't work consistenly w
if keep_drawings:
self.run_script(f'{self._chart.id}.toolBox?._drawingTool.repositionOnTime()')
else:
self.run_script(f"{self._chart.id}.toolBox?.clearDrawings()")
def update(self, series: pd.Series, render_drawings=False, _from_tick=False):
def update(self, series: pd.Series, _from_tick=False):
"""
Updates the data from a bar;
if series['time'] is the same time as the last bar, the last bar will be overwritten.\n
@ -630,18 +579,9 @@ class Candlestick(SeriesCommon):
self.candle_data.loc[self.candle_data.index[-1]] = self._last_bar
self.candle_data = pd.concat([self.candle_data, series.to_frame().T], ignore_index=True)
self._chart.events.new_bar._emit(self)
self._last_bar = series
bar = js_data(series)
self.run_script(f'''
if (stampToDate(lastBar({self.id}.data).time).getTime() === stampToDate({series['time']}).getTime()) {{
{self.id}.data[{self.id}.data.length-1] = {bar}
}}
else {{
{self.id}.data.push({bar})
{f'{self.id}.toolBox.renderDrawings()' if render_drawings else ''}
}}
{self.id}.series.update({bar})
''')
self.run_script(f'{self.id}.series.update({js_data(series)})')
if 'volume' not in series:
return
volume = series.drop(['open', 'high', 'low', 'close']).rename({'volume': 'value'})
@ -656,10 +596,7 @@ class Candlestick(SeriesCommon):
"""
series = self._series_datetime_format(series)
if series['time'] < self._last_bar['time']:
raise ValueError(
f'Trying to update tick of time "{pd.to_datetime(series["time"])}", '
f'which occurs before the last bar time of '
f'"{pd.to_datetime(self._last_bar["time"])}".')
raise ValueError(f'Trying to update tick of time "{pd.to_datetime(series["time"])}", which occurs before the last bar time of "{pd.to_datetime(self._last_bar["time"])}".')
bar = pd.Series(dtype='float64')
if series['time'] == self._last_bar['time']:
bar = self._last_bar
@ -680,15 +617,25 @@ class Candlestick(SeriesCommon):
self.update(bar, _from_tick=True)
def price_scale(
self, auto_scale: bool = True, mode: PRICE_SCALE_MODE = 'normal', invert_scale: bool = False,
align_labels: bool = True, scale_margin_top: float = 0.2, scale_margin_bottom: float = 0.2,
border_visible: bool = False, border_color: Optional[str] = None, text_color: Optional[str] = None,
entire_text_only: bool = False, visible: bool = True, ticks_visible: bool = False, minimum_width: int = 0
self,
auto_scale: bool = True,
mode: PRICE_SCALE_MODE = 'normal',
invert_scale: bool = False,
align_labels: bool = True,
scale_margin_top: float = 0.2,
scale_margin_bottom: float = 0.2,
border_visible: bool = False,
border_color: Optional[str] = None,
text_color: Optional[str] = None,
entire_text_only: bool = False,
visible: bool = True,
ticks_visible: bool = False,
minimum_width: int = 0
):
self.run_script(f'''
{self.id}.series.priceScale().applyOptions({{
autoScale: {jbool(auto_scale)},
mode: {price_scale_mode(mode)},
mode: {as_enum(mode, PRICE_SCALE_MODE)},
invertScale: {jbool(invert_scale)},
alignLabels: {jbool(align_labels)},
scaleMargins: {{top: {scale_margin_top}, bottom: {scale_margin_bottom}}},
@ -703,29 +650,17 @@ class Candlestick(SeriesCommon):
def candle_style(
self, up_color: str = 'rgba(39, 157, 130, 100)', down_color: str = 'rgba(200, 97, 100, 100)',
wick_enabled: bool = True, border_enabled: bool = True, border_up_color: str = '',
wick_visible: bool = True, border_visible: bool = True, border_up_color: str = '',
border_down_color: str = '', wick_up_color: str = '', wick_down_color: str = ''):
"""
Candle styling for each of its parts.\n
If only `up_color` and `down_color` are passed, they will color all parts of the candle.
"""
if border_enabled:
border_up_color = border_up_color if border_up_color else up_color
border_down_color = border_down_color if border_down_color else down_color
if wick_enabled:
wick_up_color = wick_up_color if wick_up_color else up_color
wick_down_color = wick_down_color if wick_down_color else down_color
self.run_script(f"""
{self.id}.series.applyOptions({{
upColor: "{up_color}",
downColor: "{down_color}",
wickVisible: {jbool(wick_enabled)},
borderVisible: {jbool(border_enabled)},
{f'borderUpColor: "{border_up_color}",' if border_enabled else ''}
{f'borderDownColor: "{border_down_color}",' if border_enabled else ''}
{f'wickUpColor: "{wick_up_color}",' if wick_enabled else ''}
{f'wickDownColor: "{wick_down_color}",' if wick_enabled else ''}
}})""")
self.run_script(f"{self.id}.series.applyOptions({js_json(locals())})")
def volume_config(self, scale_margin_top: float = 0.8, scale_margin_bottom: float = 0.0,
up_color='rgba(83,141,131,0.8)', down_color='rgba(200,127,130,0.8)'):
@ -761,7 +696,7 @@ class AbstractChart(Candlestick, Pane):
self.polygon: PolygonAPI = PolygonAPI(self)
self.run_script(
f'{self.id} = new Chart("{self.id}", {width}, {height}, "{position}", {jbool(autosize)})')
f'{self.id} = new Lib.Handler("{self.id}", {width}, {height}, "{position}", {jbool(autosize)})')
Candlestick.__init__(self, self)
@ -784,7 +719,6 @@ class AbstractChart(Candlestick, Pane):
Creates and returns a Line object.
"""
self._lines.append(Line(self, name, color, style, width, price_line, price_label))
self._lines[-1]._push_to_legend()
return self._lines[-1]
def create_histogram(
@ -795,11 +729,9 @@ class AbstractChart(Candlestick, Pane):
"""
Creates and returns a Histogram object.
"""
histogram = Histogram(
return Histogram(
self, name, color, price_line, price_label,
scale_margin_top, scale_margin_bottom)
histogram._push_to_legend()
return histogram
def lines(self) -> List[Line]:
"""
@ -807,22 +739,6 @@ class AbstractChart(Candlestick, Pane):
"""
return self._lines.copy()
def trend_line(self, start_time: TIME, start_value: NUM, end_time: TIME, end_value: NUM,
round: bool = False, color: str = '#1E80F0', width: int = 2,
style: LINE_STYLE = 'solid',
) -> Line:
line = Line(self, '', color, style, width, False, False, False)
line._set_trend(start_time, start_value, end_time, end_value, round=round)
return line
def ray_line(self, start_time: TIME, value: NUM, round: bool = False,
color: str = '#1E80F0', width: int = 2,
style: LINE_STYLE = 'solid'
) -> Line:
line = Line(self, '', color, style, width, False, False, False)
line._set_trend(start_time, value, start_time, value, ray=True, round=round)
return line
def set_visible_range(self, start_time: TIME, end_time: TIME):
self.run_script(f'''
{self.id}.chart.timeScale().setVisibleRange({{
@ -831,7 +747,7 @@ class AbstractChart(Candlestick, Pane):
}})
''')
def resize(self, width: float = None, height: float = None):
def resize(self, width: Optional[float] = None, height: Optional[float] = None):
"""
Resizes the chart within the window.
Dimensions should be given as a float between 0 and 1.
@ -846,30 +762,19 @@ class AbstractChart(Candlestick, Pane):
def time_scale(self, right_offset: int = 0, min_bar_spacing: float = 0.5,
visible: bool = True, time_visible: bool = True, seconds_visible: bool = False,
border_visible: bool = True, border_color: str = None):
border_visible: bool = True, border_color: Optional[str] = None):
"""
Options for the timescale of the chart.
"""
self.run_script(f'''
{self.id}.chart.applyOptions({{
timeScale: {{
rightOffset: {right_offset},
minBarSpacing: {min_bar_spacing},
visible: {jbool(visible)},
timeVisible: {jbool(time_visible)},
secondsVisible: {jbool(seconds_visible)},
borderVisible: {jbool(border_visible)},
{f'borderColor: "{border_color}",' if border_color else ''}
}}
}})''')
self.run_script(f'''{self.id}.chart.applyOptions({{timeScale: {js_json(locals())}}})''')
def layout(self, background_color: str = '#000000', text_color: str = None,
font_size: int = None, font_family: str = None):
def layout(self, background_color: str = '#000000', text_color: Optional[str] = None,
font_size: Optional[int] = None, font_family: Optional[str] = None):
"""
Global layout options for the chart.
"""
self.run_script(f"""
document.getElementById('wrapper').style.backgroundColor = '{background_color}'
document.getElementById('container').style.backgroundColor = '{background_color}'
{self.id}.chart.applyOptions({{
layout: {{
background: {{color: "{background_color}"}},
@ -889,43 +794,53 @@ class AbstractChart(Candlestick, Pane):
vertLines: {{
visible: {jbool(vert_enabled)},
color: "{color}",
style: {line_style(style)},
style: {as_enum(style, LINE_STYLE)},
}},
horzLines: {{
visible: {jbool(horz_enabled)},
color: "{color}",
style: {line_style(style)},
style: {as_enum(style, LINE_STYLE)},
}},
}}
}})""")
def crosshair(self, mode: CROSSHAIR_MODE = 'normal', vert_visible: bool = True,
vert_width: int = 1, vert_color: str = None, vert_style: LINE_STYLE = 'large_dashed',
vert_label_background_color: str = 'rgb(46, 46, 46)', horz_visible: bool = True,
horz_width: int = 1, horz_color: str = None, horz_style: LINE_STYLE = 'large_dashed',
horz_label_background_color: str = 'rgb(55, 55, 55)'):
def crosshair(
self,
mode: CROSSHAIR_MODE = 'normal',
vert_visible: bool = True,
vert_width: int = 1,
vert_color: Optional[str] = None,
vert_style: LINE_STYLE = 'large_dashed',
vert_label_background_color: str = 'rgb(46, 46, 46)',
horz_visible: bool = True,
horz_width: int = 1,
horz_color: Optional[str] = None,
horz_style: LINE_STYLE = 'large_dashed',
horz_label_background_color: str = 'rgb(55, 55, 55)'
):
"""
Crosshair formatting for its vertical and horizontal axes.
"""
self.run_script(f'''
{self.id}.chart.applyOptions({{
crosshair: {{
mode: {crosshair_mode(mode)},
mode: {as_enum(mode, CROSSHAIR_MODE)},
vertLine: {{
visible: {jbool(vert_visible)},
width: {vert_width},
{f'color: "{vert_color}",' if vert_color else ''}
style: {line_style(vert_style)},
style: {as_enum(vert_style, LINE_STYLE)},
labelBackgroundColor: "{vert_label_background_color}"
}},
horzLine: {{
visible: {jbool(horz_visible)},
width: {horz_width},
{f'color: "{horz_color}",' if horz_color else ''}
style: {line_style(horz_style)},
style: {as_enum(horz_style, LINE_STYLE)},
labelBackgroundColor: "{horz_label_background_color}"
}}
}}}})''')
}}
}})''')
def watermark(self, text: str, font_size: int = 44, color: str = 'rgba(180, 180, 200, 0.5)'):
"""
@ -935,11 +850,9 @@ class AbstractChart(Candlestick, Pane):
{self.id}.chart.applyOptions({{
watermark: {{
visible: true,
fontSize: {font_size},
horzAlign: 'center',
vertAlign: 'center',
color: '{color}',
text: '{text}',
...{js_json(locals())}
}}
}})''')
@ -975,7 +888,7 @@ class AbstractChart(Candlestick, Pane):
self.run_script(f"{self.id}.spinner.style.display = '{'block' if visible else 'none'}'")
def hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta', None],
keys: Union[str, tuple, int], func: callable):
keys: Union[str, tuple, int], func: Callable):
if not isinstance(keys, tuple):
keys = (keys,)
for key in keys:
@ -1000,31 +913,40 @@ class AbstractChart(Candlestick, Pane):
self.win.handlers[f'{modifier_key, keys}'] = func
def create_table(
self, width: NUM, height: NUM, headings: tuple, widths: tuple = None,
alignments: tuple = None, position: FLOAT = 'left', draggable: bool = False,
background_color: str = '#121417', border_color: str = 'rgb(70, 70, 70)',
border_width: int = 1, heading_text_colors: tuple = None,
heading_background_colors: tuple = None, return_clicked_cells: bool = False,
func: callable = None
self,
width: NUM,
height: NUM,
headings: tuple,
widths: Optional[tuple] = None,
alignments: Optional[tuple] = None,
position: FLOAT = 'left',
draggable: bool = False,
background_color: str = '#121417',
border_color: str = 'rgb(70, 70, 70)',
border_width: int = 1,
heading_text_colors: Optional[tuple] = None,
heading_background_colors: Optional[tuple] = None,
return_clicked_cells: bool = False,
func: Optional[Callable] = None
) -> Table:
return self.win.create_table(width, height, headings, widths, alignments, position, draggable,
background_color, border_color, border_width, heading_text_colors,
heading_background_colors, return_clicked_cells, func)
args = locals()
del args['self']
return self.win.create_table(*args.values())
def screenshot(self) -> bytes:
"""
Takes a screenshot. This method can only be used after the chart window is visible.
:return: a bytes object containing a screenshot of the chart.
"""
self.run_script(f'_~_~RETURN~_~_{self.id}.chart.takeScreenshot().toDataURL()')
serial_data = self.win._return_q.get()
serial_data = self.win.run_script_and_get(f'{self.id}.chart.takeScreenshot().toDataURL()')
return b64decode(serial_data.split(',')[1])
def create_subchart(self, position: FLOAT = 'left', width: float = 0.5, height: float = 0.5,
sync: Union[str, bool] = None, scale_candles_only: bool = False,
sync: Optional[Union[str, bool]] = None, scale_candles_only: bool = False,
sync_crosshairs_only: bool = False,
toolbox: bool = False) -> 'AbstractChart':
if sync is True:
sync = self.id
return self.win.create_subchart(position, width, height, sync,
scale_candles_only, sync_crosshairs_only, toolbox)
args = locals()
del args['self']
return self.win.create_subchart(*args.values())

View File

@ -1,43 +1,43 @@
import asyncio
import json
import multiprocessing as mp
import typing
import webview
# temporary until we fix to pywebview v5
try:
from webview.errors import JavascriptException
except ModuleNotFoundError:
JavascriptException = Exception
from webview.errors import JavascriptException
from lightweight_charts import abstract
from .util import parse_event_message, FLOAT
import os
import threading
class CallbackAPI:
def __init__(self, emit_queue):
self.emit_q = emit_queue
self.emit_queue = emit_queue
def callback(self, message: str):
self.emit_q.put(message)
self.emit_queue.put(message)
class PyWV:
def __init__(self, q, start_ev, exit_ev, loaded, emit_queue, return_queue, html, debug,
width, height, x, y, screen, on_top, maximize, title):
def __init__(self, q, emit_q, return_q, loaded_event):
self.queue = q
self.return_queue = return_queue
self.exit = exit_ev
self.callback_api = CallbackAPI(emit_queue)
self.loaded: list = loaded
self.html = html
self.return_queue = return_q
self.emit_queue = emit_q
self.loaded_event = loaded_event
self.windows = []
self.create_window(width, height, x, y, screen, on_top, maximize, title)
self.is_alive = True
start_ev.wait()
webview.start(debug=debug)
self.exit.set()
self.callback_api = CallbackAPI(emit_q)
self.windows: typing.List[webview.Window] = []
self.loop()
def create_window(self, width, height, x, y, screen=None, on_top=False, maximize=False, title=''):
def create_window(
self, width, height, x, y, screen=None, on_top=False,
maximize=False, title=''
):
screen = webview.screens[screen] if screen is not None else None
if maximize:
if screen is None:
@ -45,68 +45,148 @@ class PyWV:
width, height = active_screen.width, active_screen.height
else:
width, height = screen.width, screen.height
self.windows.append(webview.create_window(
title, html=self.html, js_api=self.callback_api,
width=width, height=height, x=x, y=y, screen=screen,
on_top=on_top, background_color='#000000'))
self.windows[-1].events.loaded += lambda: self.loop(self.loaded[len(self.windows)-1])
def loop(self, loaded):
loaded.set()
while 1:
self.windows.append(webview.create_window(
title,
url=abstract.INDEX,
js_api=self.callback_api,
width=width,
height=height,
x=x,
y=y,
screen=screen,
on_top=on_top,
background_color='#000000')
)
self.windows[-1].events.loaded += lambda: self.loaded_event.set()
def loop(self):
# self.loaded_event.set()
while self.is_alive:
i, arg = self.queue.get()
if i == 'start':
webview.start(debug=arg, func=self.loop)
self.is_alive = False
self.emit_queue.put('exit')
return
if i == 'create_window':
self.create_window(*arg)
elif arg in ('show', 'hide'):
getattr(self.windows[i], arg)()
elif arg == 'exit':
self.exit.set()
continue
window = self.windows[i]
if arg == 'show':
window.show()
elif arg == 'hide':
window.hide()
else:
try:
if '_~_~RETURN~_~_' in arg:
self.return_queue.put(self.windows[i].evaluate_js(arg[14:]))
self.return_queue.put(window.evaluate_js(arg[14:]))
else:
self.windows[i].evaluate_js(arg)
window.evaluate_js(arg)
except KeyError as e:
return
except JavascriptException as e:
pass
# msg = eval(str(e))
# raise JavascriptException(f"\n\nscript -> '{arg}',\nerror -> {msg['name']}[{msg['line']}:{msg['column']}]\n{msg['message']}")
msg = eval(str(e))
raise JavascriptException(f"\n\nscript -> '{arg}',\nerror -> {msg['name']}[{msg['line']}:{msg['column']}]\n{msg['message']}")
class WebviewHandler():
def __init__(self) -> None:
self._reset()
self.debug = False
def _reset(self):
self.loaded_event = mp.Event()
self.return_queue = mp.Queue()
self.function_call_queue = mp.Queue()
self.emit_queue = mp.Queue()
self.wv_process = mp.Process(
target=PyWV, args=(
self.function_call_queue, self.emit_queue,
self.return_queue, self.loaded_event
),
daemon=True
)
self.max_window_num = -1
def create_window(
self, width, height, x, y, screen=None, on_top=False,
maximize=False, title=''
):
self.function_call_queue.put((
'create_window',
(width, height, x, y, screen, on_top, maximize, title)
))
self.max_window_num += 1
return self.max_window_num
def start(self):
self.loaded_event.clear()
self.wv_process.start()
self.function_call_queue.put(('start', self.debug))
self.loaded_event.wait()
def show(self, window_num):
self.function_call_queue.put((window_num, 'show'))
def hide(self, window_num):
self.function_call_queue.put((window_num, 'hide'))
def evaluate_js(self, window_num, script):
self.function_call_queue.put((window_num, script))
def exit(self):
if self.wv_process.is_alive():
self.wv_process.terminate()
self.wv_process.join()
self._reset()
class Chart(abstract.AbstractChart):
MAX_WINDOWS = 10
_window_num = 0
_main_window_handlers = None
_exit, _start = (mp.Event() for _ in range(2))
_q, _emit_q, _return_q = (mp.Queue() for _ in range(3))
_loaded_list = [mp.Event() for _ in range(MAX_WINDOWS)]
WV: WebviewHandler = WebviewHandler()
def __init__(
self,
width: int = 800,
height: int = 600,
x: int = None,
y: int = None,
title: str = '',
screen: int = None,
on_top: bool = False,
maximize: bool = False,
debug: bool = False,
toolbox: bool = False,
inner_width: float = 1.0,
inner_height: float = 1.0,
scale_candles_only: bool = False,
position: FLOAT = 'left'
):
Chart.WV.debug = debug
self._i = Chart.WV.create_window(
width, height, x, y, screen, on_top, maximize, title
)
window = abstract.Window(
script_func=lambda s: Chart.WV.evaluate_js(self._i, s),
js_api_code='pywebview.api.callback'
)
abstract.Window._return_q = Chart.WV.return_queue
def __init__(self, width: int = 800, height: int = 600, x: int = None, y: int = None, title: str = '',
screen: int = None, on_top: bool = False, maximize: bool = False, debug: bool = False,
toolbox: bool = False, inner_width: float = 1.0, inner_height: float = 1.0,
scale_candles_only: bool = False, position: FLOAT = 'left'):
self._i = Chart._window_num
self._loaded = Chart._loaded_list[self._i]
abstract.Window._return_q = Chart._return_q
Chart._window_num += 1
self.is_alive = True
window = abstract.Window(lambda s: self._q.put((self._i, s)), 'pywebview.api.callback')
if self._i == 0:
if Chart._main_window_handlers is None:
super().__init__(window, inner_width, inner_height, scale_candles_only, toolbox, position=position)
Chart._main_window_handlers = self.win.handlers
self._process = mp.Process(target=PyWV, args=(
self._q, self._start, self._exit, Chart._loaded_list,
self._emit_q, self._return_q, abstract.TEMPLATE, debug,
width, height, x, y, screen, on_top, maximize, title
), daemon=True)
self._process.start()
else:
window.handlers = Chart._main_window_handlers
super().__init__(window, inner_width, inner_height, scale_candles_only, toolbox, position=position)
self._q.put(('create_window', (width, height, x, y, screen, on_top, maximize, title)))
def show(self, block: bool = False):
"""
@ -114,34 +194,31 @@ class Chart(abstract.AbstractChart):
:param block: blocks execution until the chart is closed.
"""
if not self.win.loaded:
self._start.set()
self._loaded.wait()
Chart.WV.start()
self.win.on_js_load()
else:
self._q.put((self._i, 'show'))
Chart.WV.show(self._i)
if block:
asyncio.run(self.show_async(block=True))
asyncio.run(self.show_async())
async def show_async(self, block=False):
async def show_async(self):
self.show(block=False)
if not block:
asyncio.create_task(self.show_async(block=True))
return
try:
from lightweight_charts import polygon
[asyncio.create_task(self.polygon.async_set(*args)) for args in polygon._set_on_load]
while 1:
while self._emit_q.empty() and not self._exit.is_set():
while Chart.WV.emit_queue.empty() and self.is_alive:
await asyncio.sleep(0.05)
if self._exit.is_set():
self._exit.clear()
self.is_alive = False
self.exit()
if not self.is_alive:
return
elif not self._emit_q.empty():
func, args = parse_event_message(self.win, self._emit_q.get())
response = Chart.WV.emit_queue.get()
if response == 'exit':
Chart.WV.exit()
self.is_alive = False
return
else:
func, args = parse_event_message(self.win, response)
await func(*args) if asyncio.iscoroutinefunction(func) else func(*args)
continue
except KeyboardInterrupt:
return
@ -155,12 +232,5 @@ class Chart(abstract.AbstractChart):
"""
Exits and destroys the chart window.\n
"""
self._q.put((self._i, 'exit'))
self._exit.wait() if self.win.loaded else None
self._process.terminate()
Chart._main_window_handlers = None
Chart._window_num = 0
Chart._q = mp.Queue()
Chart._exit.clear(), Chart._start.clear()
Chart.WV.exit()
self.is_alive = False

View File

@ -0,0 +1,276 @@
import asyncio
import json
import pandas as pd
from typing import Union, Optional
from lightweight_charts.util import js_json
from .util import NUM, Pane, as_enum, LINE_STYLE, TIME, snake_to_camel
class Drawing(Pane):
def __init__(self, chart, func=None):
super().__init__(chart.win)
self.chart = chart
def update(self, *points):
js_json_string = f'JSON.parse({json.dumps(points)})'
self.run_script(f'{self.id}.updatePoints(...{js_json_string})')
def delete(self):
"""
Irreversibly deletes the drawing.
"""
self.run_script(f'{self.id}.detach()')
def options(self, color='#1E80F0', style='solid', width=4):
self.run_script(f'''{self.id}.applyOptions({{
lineColor: '{color}',
lineStyle: {as_enum(style, LINE_STYLE)},
width: {width},
}})''')
class TwoPointDrawing(Drawing):
def __init__(
self,
drawing_type,
chart,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool,
options: dict,
func=None
):
super().__init__(chart, func)
def make_js_point(time, price):
formatted_time = self.chart._single_datetime_format(time)
return f'''{{
"time": {formatted_time},
"logical": {self.chart.id}.chart.timeScale()
.coordinateToLogical(
{self.chart.id}.chart.timeScale()
.timeToCoordinate({formatted_time})
),
"price": {price}
}}'''
options_string = '\n'.join(f'{key}: {val},' for key, val in options.items())
self.run_script(f'''
{self.id} = new Lib.{drawing_type}(
{make_js_point(start_time, start_value)},
{make_js_point(end_time, end_value)},
{{
{options_string}
}}
)
{chart.id}.series.attachPrimitive({self.id})
''')
class HorizontalLine(Drawing):
def __init__(self, chart, price, color, width, style, text, axis_label_visible, func):
super().__init__(chart, func)
self.price = price
self.run_script(f'''
{self.id} = new Lib.HorizontalLine(
{{price: {price}}},
{{
lineColor: '{color}',
lineStyle: {as_enum(style, LINE_STYLE)},
width: {width},
text: `{text}`,
}},
callbackName={f"'{self.id}'" if func else 'null'}
)
{chart.id}.series.attachPrimitive({self.id})
''')
if not func:
return
def wrapper(p):
self.price = float(p)
func(chart, self)
async def wrapper_async(p):
self.price = float(p)
await func(chart, self)
self.win.handlers[self.id] = wrapper_async if asyncio.iscoroutinefunction(func) else wrapper
self.run_script(f'{chart.id}.toolBox?.addNewDrawing({self.id})')
def update(self, price: float):
"""
Moves the horizontal line to the given price.
"""
self.run_script(f'{self.id}.updatePoints({{price: {price}}})')
# self.run_script(f'{self.id}.updatePrice({price})')
self.price = price
def options(self, color='#1E80F0', style='solid', width=4, text=''):
super().options(color, style, width)
self.run_script(f'{self.id}.applyOptions({{text: `{text}`}})')
class VerticalLine(Drawing):
def __init__(self, chart, time, color, width, style, text, func=None):
super().__init__(chart, func)
self.time = time
self.run_script(f'''
{self.id} = new Lib.VerticalLine(
{{time: {self.chart._single_datetime_format(time)}}},
{{
lineColor: '{color}',
lineStyle: {as_enum(style, LINE_STYLE)},
width: {width},
text: `{text}`,
}},
callbackName={f"'{self.id}'" if func else 'null'}
)
{chart.id}.series.attachPrimitive({self.id})
''')
def update(self, time: TIME):
self.run_script(f'{self.id}.updatePoints({{time: {time}}})')
# self.run_script(f'{self.id}.updatePrice({price})')
self.price = price
def options(self, color='#1E80F0', style='solid', width=4, text=''):
super().options(color, style, width)
self.run_script(f'{self.id}.applyOptions({{text: `{text}`}})')
class RayLine(Drawing):
def __init__(self,
chart,
start_time: TIME,
value: NUM,
round: bool = False,
color: str = '#1E80F0',
width: int = 2,
style: LINE_STYLE = 'solid',
text: str = '',
func = None,
):
super().__init__(chart, func)
self.run_script(f'''
{self.id} = new Lib.RayLine(
{{time: {self.chart._single_datetime_format(start_time)}, price: {value}}},
{{
lineColor: '{color}',
lineStyle: {as_enum(style, LINE_STYLE)},
width: {width},
text: `{text}`,
}},
callbackName={f"'{self.id}'" if func else 'null'}
)
{chart.id}.series.attachPrimitive({self.id})
''')
class Box(TwoPointDrawing):
def __init__(self,
chart,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool,
line_color: str,
fill_color: str,
width: int,
style: LINE_STYLE,
func=None):
super().__init__(
"Box",
chart,
start_time,
start_value,
end_time,
end_value,
round,
{
"lineColor": f'"{line_color}"',
"fillColor": f'"{fill_color}"',
"width": width,
"lineStyle": as_enum(style, LINE_STYLE)
},
func
)
class TrendLine(TwoPointDrawing):
def __init__(self,
chart,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool,
line_color: str,
width: int,
style: LINE_STYLE,
func=None):
super().__init__(
"TrendLine",
chart,
start_time,
start_value,
end_time,
end_value,
round,
{
"lineColor": f'"{line_color}"',
"width": width,
"lineStyle": as_enum(style, LINE_STYLE)
},
func
)
# TODO reimplement/fix
class VerticalSpan(Pane):
def __init__(self, series: 'SeriesCommon', start_time: Union[TIME, tuple, list], end_time: Optional[TIME] = None,
color: str = 'rgba(252, 219, 3, 0.2)'):
self._chart = series._chart
super().__init__(self._chart.win)
start_time, end_time = pd.to_datetime(start_time), pd.to_datetime(end_time)
self.run_script(f'''
{self.id} = {self._chart.id}.chart.addHistogramSeries({{
color: '{color}',
priceFormat: {{type: 'volume'}},
priceScaleId: 'vertical_line',
lastValueVisible: false,
priceLineVisible: false,
}})
{self.id}.priceScale('').applyOptions({{
scaleMargins: {{top: 0, bottom: 0}}
}})
''')
if end_time is None:
if isinstance(start_time, pd.DatetimeIndex):
data = [{'time': time.timestamp(), 'value': 1} for time in start_time]
else:
data = [{'time': start_time.timestamp(), 'value': 1}]
self.run_script(f'{self.id}.setData({data})')
else:
self.run_script(f'''
{self.id}.setData(calculateTrendLine(
{start_time.timestamp()}, 1, {end_time.timestamp()}, 1, {series.id}))
''')
def delete(self):
"""
Irreversibly deletes the vertical span.
"""
self.run_script(f'{self._chart.id}.chart.removeSeries({self.id})')

File diff suppressed because one or more lines are too long

View File

@ -1,277 +0,0 @@
if (!window.TopBar) {
class TopBar {
constructor(chart) {
this.makeSwitcher = this.makeSwitcher.bind(this)
this.topBar = document.createElement('div')
this.topBar.style.backgroundColor = pane.backgroundColor
this.topBar.style.borderBottom = '2px solid '+pane.borderColor
this.topBar.style.display = 'flex'
this.topBar.style.alignItems = 'center'
let createTopBarContainer = (justification) => {
let div = document.createElement('div')
div.style.display = 'flex'
div.style.alignItems = 'center'
div.style.justifyContent = justification
div.style.flexGrow = '1'
this.topBar.appendChild(div)
return div
}
this.left = createTopBarContainer('flex-start')
this.right = createTopBarContainer('flex-end')
chart.wrapper.prepend(this.topBar)
chart.topBar = this.topBar
this.reSize = () => chart.reSize()
this.reSize()
}
makeSwitcher(items, activeItem, callbackName, align='left') {
let switcherElement = document.createElement('div');
switcherElement.style.margin = '4px 12px'
let widget = {
elem: switcherElement,
callbackName: callbackName,
intervalElements: null,
onItemClicked: null,
}
widget.intervalElements = items.map((item)=> {
let itemEl = document.createElement('button');
itemEl.style.border = 'none'
itemEl.style.padding = '2px 5px'
itemEl.style.margin = '0px 2px'
itemEl.style.fontSize = '13px'
itemEl.style.borderRadius = '4px'
itemEl.style.backgroundColor = item === activeItem ? pane.activeBackgroundColor : 'transparent'
itemEl.style.color = item === activeItem ? pane.activeColor : pane.color
itemEl.innerText = item;
document.body.appendChild(itemEl)
itemEl.style.minWidth = itemEl.clientWidth + 1 + 'px'
document.body.removeChild(itemEl)
itemEl.addEventListener('mouseenter', () => itemEl.style.backgroundColor = item === activeItem ? pane.activeBackgroundColor : pane.hoverBackgroundColor)
itemEl.addEventListener('mouseleave', () => itemEl.style.backgroundColor = item === activeItem ? pane.activeBackgroundColor : 'transparent')
itemEl.addEventListener('mousedown', () => itemEl.style.backgroundColor = item === activeItem ? pane.activeBackgroundColor : pane.clickBackgroundColor)
itemEl.addEventListener('mouseup', () => itemEl.style.backgroundColor = item === activeItem ? pane.activeBackgroundColor : pane.hoverBackgroundColor)
itemEl.addEventListener('click', () => widget.onItemClicked(item))
switcherElement.appendChild(itemEl);
return itemEl;
});
widget.onItemClicked = (item)=> {
if (item === activeItem) return
widget.intervalElements.forEach((element, index) => {
element.style.backgroundColor = items[index] === item ? pane.activeBackgroundColor : 'transparent'
element.style.color = items[index] === item ? pane.activeColor : pane.color
element.style.fontWeight = items[index] === item ? '500' : 'normal'
})
activeItem = item;
window.callbackFunction(`${widget.callbackName}_~_${item}`);
}
this.appendWidget(switcherElement, align, true)
return widget
}
makeTextBoxWidget(text, align='left') {
let textBox = document.createElement('div')
textBox.style.margin = '0px 18px'
textBox.style.fontSize = '16px'
textBox.style.color = pane.color
textBox.innerText = text
this.appendWidget(textBox, align, true)
return textBox
}
makeMenu(items, activeItem, separator, callbackName, align='right') {
let menu = document.createElement('div')
menu.style.position = 'absolute'
menu.style.display = 'none'
menu.style.zIndex = '100000'
menu.style.backgroundColor = pane.backgroundColor
menu.style.borderRadius = '2px'
menu.style.border = '2px solid '+pane.borderColor
menu.style.borderTop = 'none'
menu.style.alignItems = 'flex-start'
menu.style.maxHeight = '80%'
menu.style.overflowY = 'auto'
menu.style.scrollbar
let menuOpen = false
items.forEach(text => {
let button = this.makeButton(text, null, false, false)
button.elem.addEventListener('click', () => {
widget.elem.innerText = button.elem.innerText+' ↓'
window.callbackFunction(`${callbackName}_~_${button.elem.innerText}`)
menu.style.display = 'none'
menuOpen = false
});
button.elem.style.margin = '4px 4px'
button.elem.style.padding = '2px 2px'
menu.appendChild(button.elem)
})
let widget =
this.makeButton(activeItem+' ↓', null, separator, true, align)
widget.elem.addEventListener('click', () => {
menuOpen = !menuOpen
if (!menuOpen) return menu.style.display = 'none'
let rect = widget.elem.getBoundingClientRect()
menu.style.display = 'flex'
menu.style.flexDirection = 'column'
let center = rect.x+(rect.width/2)
menu.style.left = center-(menu.clientWidth/2)+'px'
menu.style.top = rect.y+rect.height+'px'
})
document.body.appendChild(menu)
}
makeButton(defaultText, callbackName, separator, append=true, align='left') {
let button = document.createElement('button')
button.style.border = 'none'
button.style.padding = '2px 5px'
button.style.margin = '4px 10px'
button.style.fontSize = '13px'
button.style.backgroundColor = 'transparent'
button.style.color = pane.color
button.style.borderRadius = '4px'
button.innerText = defaultText;
document.body.appendChild(button)
button.style.minWidth = button.clientWidth+1+'px'
document.body.removeChild(button)
let widget = {
elem: button,
callbackName: callbackName
}
button.addEventListener('mouseenter', () => button.style.backgroundColor = pane.hoverBackgroundColor)
button.addEventListener('mouseleave', () => button.style.backgroundColor = 'transparent')
if (callbackName) {
button.addEventListener('click', () => window.callbackFunction(`${widget.callbackName}_~_${button.innerText}`));
}
button.addEventListener('mousedown', () => {
button.style.backgroundColor = pane.activeBackgroundColor
button.style.color = pane.activeColor
button.style.fontWeight = '500'
})
button.addEventListener('mouseup', () => {
button.style.backgroundColor = pane.hoverBackgroundColor
button.style.color = pane.color
button.style.fontWeight = 'normal'
})
if (append) this.appendWidget(button, align, separator)
return widget
}
makeSeparator(align='left') {
let seperator = document.createElement('div')
seperator.style.width = '1px'
seperator.style.height = '20px'
seperator.style.backgroundColor = pane.borderColor
let div = align === 'left' ? this.left : this.right
div.appendChild(seperator)
}
appendWidget(widget, align, separator) {
let div = align === 'left' ? this.left : this.right
if (separator) {
if (align === 'left') div.appendChild(widget)
this.makeSeparator(align)
if (align === 'right') div.appendChild(widget)
} else div.appendChild(widget)
this.reSize()
}
}
window.TopBar = TopBar
}
function makeSearchBox(chart) {
let searchWindow = document.createElement('div')
searchWindow.style.position = 'absolute'
searchWindow.style.top = '0'
searchWindow.style.bottom = '200px'
searchWindow.style.left = '0'
searchWindow.style.right = '0'
searchWindow.style.margin = 'auto'
searchWindow.style.width = '150px'
searchWindow.style.height = '30px'
searchWindow.style.padding = '5px'
searchWindow.style.zIndex = '1000'
searchWindow.style.alignItems = 'center'
searchWindow.style.backgroundColor = 'rgba(30, 30, 30, 0.9)'
searchWindow.style.border = '2px solid '+pane.borderColor
searchWindow.style.borderRadius = '5px'
searchWindow.style.display = 'none'
let magnifyingGlass = document.createElement('div');
magnifyingGlass.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:lightgray;stroke-opacity:1;stroke-miterlimit:4;" d="M 15 15 L 21 21 M 10 17 C 6.132812 17 3 13.867188 3 10 C 3 6.132812 6.132812 3 10 3 C 13.867188 3 17 6.132812 17 10 C 17 13.867188 13.867188 17 10 17 Z M 10 17 "/></svg>`
let sBox = document.createElement('input');
sBox.type = 'text';
sBox.style.textAlign = 'center'
sBox.style.width = '100px'
sBox.style.marginLeft = '10px'
sBox.style.backgroundColor = pane.mutedBackgroundColor
sBox.style.color = pane.activeColor
sBox.style.fontSize = '20px'
sBox.style.border = 'none'
sBox.style.outline = 'none'
sBox.style.borderRadius = '2px'
searchWindow.appendChild(magnifyingGlass)
searchWindow.appendChild(sBox)
chart.div.appendChild(searchWindow);
let yPrice = null
chart.chart.subscribeCrosshairMove((param) => {
if (param.point) yPrice = param.point.y;
})
chart.commandFunctions.push((event) => {
if (selectedChart !== chart) return false
if (searchWindow.style.display === 'none') {
if (/^[a-zA-Z0-9]$/.test(event.key)) {
searchWindow.style.display = 'flex';
sBox.focus();
return true
}
else return false
}
else if (event.key === 'Enter' || event.key === 'Escape') {
if (event.key === 'Enter') window.callbackFunction(`search${chart.id}_~_${sBox.value}`)
searchWindow.style.display = 'none'
sBox.value = ''
return true
}
else return false
})
sBox.addEventListener('input', () => sBox.value = sBox.value.toUpperCase())
return {
window: searchWindow,
box: sBox,
}
}
function makeSpinner(chart) {
chart.spinner = document.createElement('div')
chart.spinner.style.width = '30px'
chart.spinner.style.height = '30px'
chart.spinner.style.border = '4px solid rgba(255, 255, 255, 0.6)'
chart.spinner.style.borderTop = '4px solid '+pane.activeBackgroundColor
chart.spinner.style.borderRadius = '50%'
chart.spinner.style.position = 'absolute'
chart.spinner.style.top = '50%'
chart.spinner.style.left = '50%'
chart.spinner.style.zIndex = '1000'
chart.spinner.style.transform = 'translate(-50%, -50%)'
chart.spinner.style.display = 'none'
chart.wrapper.appendChild(chart.spinner)
let rotation = 0;
const speed = 10;
function animateSpinner() {
rotation += speed
chart.spinner.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`
requestAnimationFrame(animateSpinner)
}
animateSpinner();
}

View File

@ -1,595 +0,0 @@
if (!window.Chart) {
window.pane = {
backgroundColor: '#0c0d0f',
hoverBackgroundColor: '#3c434c',
clickBackgroundColor: '#50565E',
activeBackgroundColor: 'rgba(0, 122, 255, 0.7)',
mutedBackgroundColor: 'rgba(0, 122, 255, 0.3)',
borderColor: '#3C434C',
color: '#d8d9db',
activeColor: '#ececed',
}
class Chart {
constructor(chartId, innerWidth, innerHeight, position, autoSize) {
this.makeCandlestickSeries = this.makeCandlestickSeries.bind(this)
this.reSize = this.reSize.bind(this)
this.id = chartId
this.lines = []
this.interval = null
this.wrapper = document.createElement('div')
this.div = document.createElement('div')
this.scale = {
width: innerWidth,
height: innerHeight,
}
this.commandFunctions = []
this.chart = LightweightCharts.createChart(this.div, {
width: window.innerWidth * innerWidth,
height: window.innerHeight * innerHeight,
layout: {
textColor: pane.color,
background: {
color: '#000000',
type: LightweightCharts.ColorType.Solid,
},
fontSize: 12
},
rightPriceScale: {
scaleMargins: {top: 0.3, bottom: 0.25},
},
timeScale: {timeVisible: true, secondsVisible: false},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
vertLine: {
labelBackgroundColor: 'rgb(46, 46, 46)'
},
horzLine: {
labelBackgroundColor: 'rgb(55, 55, 55)'
}
},
grid: {
vertLines: {color: 'rgba(29, 30, 38, 5)'},
horzLines: {color: 'rgba(29, 30, 58, 5)'},
},
handleScroll: {vertTouchDrag: true},
})
this.legend = new Legend(this)
this.wrapper.style.display = 'flex'
this.wrapper.style.flexDirection = 'column'
this.wrapper.style.position = 'relative'
this.wrapper.style.float = position
this.div.style.position = 'relative'
// this.div.style.display = 'flex'
this.reSize()
this.wrapper.appendChild(this.div)
document.getElementById('wrapper').append(this.wrapper)
document.addEventListener('keydown', (event) => {
for (let i = 0; i < this.commandFunctions.length; i++) {
if (this.commandFunctions[i](event)) break
}
})
window.selectedChart = this
this.wrapper.addEventListener('mouseover', (e) => window.selectedChart = this)
if (!autoSize) return
window.addEventListener('resize', () => this.reSize())
}
reSize() {
let topBarOffset = 'topBar' in this && this.scale.height !== 0 ? this.topBar.offsetHeight : 0
this.chart.resize(window.innerWidth * this.scale.width, (window.innerHeight * this.scale.height) - topBarOffset)
this.wrapper.style.width = `${100 * this.scale.width}%`
this.wrapper.style.height = `${100 * this.scale.height}%`
if (this.scale.height === 0 || this.scale.width === 0) {
this.legend.div.style.display = 'none'
if (this.toolBox) {
this.toolBox.elem.style.display = 'none'
}
}
else {
this.legend.div.style.display = 'flex'
if (this.toolBox) {
this.toolBox.elem.style.display = 'flex'
}
}
}
makeCandlestickSeries() {
this.markers = []
this.horizontal_lines = []
this.data = []
this.precision = 2
let up = 'rgba(39, 157, 130, 100)'
let down = 'rgba(200, 97, 100, 100)'
this.series = this.chart.addCandlestickSeries({
color: 'rgb(0, 120, 255)', upColor: up, borderUpColor: up, wickUpColor: up,
downColor: down, borderDownColor: down, wickDownColor: down, lineWidth: 2,
})
this.volumeSeries = this.chart.addHistogramSeries({
color: '#26a69a',
priceFormat: {type: 'volume'},
priceScaleId: 'volume_scale',
})
this.series.priceScale().applyOptions({
scaleMargins: {top: 0.2, bottom: 0.2},
});
this.volumeSeries.priceScale().applyOptions({
scaleMargins: {top: 0.8, bottom: 0},
});
}
toJSON() {
// Exclude the chart attribute from serialization
const {chart, ...serialized} = this;
return serialized;
}
}
window.Chart = Chart
class HorizontalLine {
constructor(chart, lineId, price, color, width, style, axisLabelVisible, text) {
this.updatePrice = this.updatePrice.bind(this)
this.deleteLine = this.deleteLine.bind(this)
this.chart = chart
this.price = price
this.color = color
this.id = lineId
this.priceLine = {
price: this.price,
color: this.color,
lineWidth: width,
lineStyle: style,
axisLabelVisible: axisLabelVisible,
title: text,
}
this.line = this.chart.series.createPriceLine(this.priceLine)
this.chart.horizontal_lines.push(this)
}
toJSON() {
// Exclude the chart attribute from serialization
const {chart, line, ...serialized} = this;
return serialized;
}
updatePrice(price) {
this.chart.series.removePriceLine(this.line)
this.price = price
this.priceLine.price = this.price
this.line = this.chart.series.createPriceLine(this.priceLine)
}
updateLabel(text) {
this.chart.series.removePriceLine(this.line)
this.priceLine.title = text
this.line = this.chart.series.createPriceLine(this.priceLine)
}
updateColor(color) {
this.chart.series.removePriceLine(this.line)
this.color = color
this.priceLine.color = this.color
this.line = this.chart.series.createPriceLine(this.priceLine)
}
updateStyle(style) {
this.chart.series.removePriceLine(this.line)
this.priceLine.lineStyle = style
this.line = this.chart.series.createPriceLine(this.priceLine)
}
deleteLine() {
this.chart.series.removePriceLine(this.line)
this.chart.horizontal_lines.splice(this.chart.horizontal_lines.indexOf(this))
delete this
}
}
window.HorizontalLine = HorizontalLine
class Legend {
constructor(chart) {
this.legendHandler = this.legendHandler.bind(this)
this.chart = chart
this.ohlcEnabled = false
this.percentEnabled = false
this.linesEnabled = false
this.colorBasedOnCandle = false
this.div = document.createElement('div')
this.div.style.position = 'absolute'
this.div.style.zIndex = '3000'
this.div.style.pointerEvents = 'none'
this.div.style.top = '10px'
this.div.style.left = '10px'
this.div.style.display = 'flex'
this.div.style.flexDirection = 'column'
this.div.style.maxWidth = `${(chart.scale.width * 100) - 8}vw`
this.text = document.createElement('span')
this.text.style.lineHeight = '1.8'
this.candle = document.createElement('div')
this.div.appendChild(this.text)
this.div.appendChild(this.candle)
chart.div.appendChild(this.div)
this.makeLines(chart)
chart.chart.subscribeCrosshairMove(this.legendHandler)
}
toJSON() {
// Exclude the chart attribute from serialization
const {lines, chart, ...serialized} = this;
return serialized;
}
makeLines(chart) {
this.lines = []
if (this.linesEnabled) chart.lines.forEach(line => this.lines.push(this.makeLineRow(line)))
}
makeLineRow(line) {
let openEye = `
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${this.color};stroke-opacity:1;stroke-miterlimit:4;" d="M 21.998437 12 C 21.998437 12 18.998437 18 12 18 C 5.001562 18 2.001562 12 2.001562 12 C 2.001562 12 5.001562 6 12 6 C 18.998437 6 21.998437 12 21.998437 12 Z M 21.998437 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${this.color};stroke-opacity:1;stroke-miterlimit:4;" d="M 15 12 C 15 13.654687 13.654687 15 12 15 C 10.345312 15 9 13.654687 9 12 C 9 10.345312 10.345312 9 12 9 C 13.654687 9 15 10.345312 15 12 Z M 15 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>\`
`
let closedEye = `
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${this.color};stroke-opacity:1;stroke-miterlimit:4;" d="M 20.001562 9 C 20.001562 9 19.678125 9.665625 18.998437 10.514062 M 12 14.001562 C 10.392187 14.001562 9.046875 13.589062 7.95 12.998437 M 12 14.001562 C 13.607812 14.001562 14.953125 13.589062 16.05 12.998437 M 12 14.001562 L 12 17.498437 M 3.998437 9 C 3.998437 9 4.354687 9.735937 5.104687 10.645312 M 7.95 12.998437 L 5.001562 15.998437 M 7.95 12.998437 C 6.689062 12.328125 5.751562 11.423437 5.104687 10.645312 M 16.05 12.998437 L 18.501562 15.998437 M 16.05 12.998437 C 17.38125 12.290625 18.351562 11.320312 18.998437 10.514062 M 5.104687 10.645312 L 2.001562 12 M 18.998437 10.514062 L 21.998437 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
`
let row = document.createElement('div')
row.style.display = 'flex'
row.style.alignItems = 'center'
let div = document.createElement('div')
let toggle = document.createElement('div')
toggle.style.borderRadius = '4px'
toggle.style.marginLeft = '10px'
toggle.style.pointerEvents = 'auto'
let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "22");
svg.setAttribute("height", "16");
let group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = openEye
let on = true
toggle.addEventListener('click', (event) => {
if (on) {
on = false
group.innerHTML = closedEye
line.series.applyOptions({
visible: false
})
} else {
on = true
line.series.applyOptions({
visible: true
})
group.innerHTML = openEye
}
})
toggle.addEventListener('mouseover', (event) => {
document.body.style.cursor = 'pointer'
toggle.style.backgroundColor = 'rgba(50, 50, 50, 0.5)'
})
toggle.addEventListener('mouseleave', (event) => {
document.body.style.cursor = 'default'
toggle.style.backgroundColor = 'transparent'
})
svg.appendChild(group)
toggle.appendChild(svg);
row.appendChild(div)
row.appendChild(toggle)
this.div.appendChild(row)
return {
div: div,
row: row,
toggle: toggle,
line: line,
solid: line.color.startsWith('rgba') ? line.color.replace(/[^,]+(?=\))/, '1') : line.color
}
}
legendItemFormat(num, decimal) { return num.toFixed(decimal).toString().padStart(8, ' ') }
shorthandFormat(num) {
const absNum = Math.abs(num)
if (absNum >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (absNum >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString().padStart(8, ' ');
}
legendHandler(param, usingPoint= false) {
let options = this.chart.series.options()
if (!param.time) {
this.candle.style.color = 'transparent'
this.candle.innerHTML = this.candle.innerHTML.replace(options['upColor'], '').replace(options['downColor'], '')
return
}
let data, logical
if (usingPoint) {
let coordinate = this.chart.chart.timeScale().timeToCoordinate(param.time)
logical = this.chart.chart.timeScale().coordinateToLogical(coordinate)
data = this.chart.series.dataByIndex(logical)
}
else {
data = param.seriesData.get(this.chart.series);
}
this.candle.style.color = ''
let str = '<span style="line-height: 1.8;">'
if (data) {
if (this.ohlcEnabled) {
str += `O ${this.legendItemFormat(data.open, this.chart.precision)} `
str += `| H ${this.legendItemFormat(data.high, this.chart.precision)} `
str += `| L ${this.legendItemFormat(data.low, this.chart.precision)} `
str += `| C ${this.legendItemFormat(data.close, this.chart.precision)} `
}
if (this.percentEnabled) {
let percentMove = ((data.close - data.open) / data.open) * 100
let color = percentMove > 0 ? options['upColor'] : options['downColor']
let percentStr = `${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %`
if (this.colorBasedOnCandle) {
str += `| <span style="color: ${color};">${percentStr}</span>`
} else {
str += '| ' + percentStr
}
}
let volumeData;
if (usingPoint) {
volumeData = this.chart.volumeSeries.dataByIndex(logical)
}
else {
volumeData = param.seriesData.get(this.chart.volumeSeries)
}
if (volumeData) {
str += this.ohlcEnabled ? `<br>V ${this.shorthandFormat(volumeData.value)}` : ''
}
}
this.candle.innerHTML = str + '</span>'
this.lines.forEach((line) => {
if (!this.linesEnabled) {
line.row.style.display = 'none'
return
}
line.row.style.display = 'flex'
let price
if (usingPoint) {
price = line.line.series.dataByIndex(logical)
}
else {
price = param.seriesData.get(line.line.series)
}
if (!price) return
else price = price.value
if (line.line.type === 'histogram') {
price = this.shorthandFormat(price)
} else {
price = this.legendItemFormat(price, line.line.precision)
}
line.div.innerHTML = `<span style="color: ${line.solid};">▨</span> ${line.line.name} : ${price}`
})
}
}
window.Legend = Legend
}
function syncCharts(childChart, parentChart, crosshairOnly= false) {
function crosshairHandler(chart, point) {
if (!point) {
chart.chart.clearCrosshairPosition()
return
}
chart.chart.setCrosshairPosition(point.value || point.close, point.time, chart.series);
chart.legend.legendHandler(point, true)
}
function getPoint(series, param) {
if (!param.time) return null;
return param.seriesData.get(series) || null;
}
let setChildRange, setParentRange;
if (crosshairOnly) {
setChildRange = (timeRange) => { }
setParentRange = (timeRange) => { }
}
else {
setChildRange = (timeRange) => childChart.chart.timeScale().setVisibleLogicalRange(timeRange)
setParentRange = (timeRange) => parentChart.chart.timeScale().setVisibleLogicalRange(timeRange)
}
let setParentCrosshair = (param) => {
crosshairHandler(parentChart, getPoint(childChart.series, param))
}
let setChildCrosshair = (param) => {
crosshairHandler(childChart, getPoint(parentChart.series, param))
}
let selected = parentChart
function addMouseOverListener(thisChart, otherChart, thisCrosshair, otherCrosshair, thisRange, otherRange) {
thisChart.wrapper.addEventListener('mouseover', (event) => {
if (selected === thisChart) return
selected = thisChart
otherChart.chart.timeScale().unsubscribeVisibleLogicalRangeChange(thisRange)
otherChart.chart.unsubscribeCrosshairMove(thisCrosshair)
thisChart.chart.timeScale().subscribeVisibleLogicalRangeChange(otherRange)
thisChart.chart.subscribeCrosshairMove(otherCrosshair)
})
}
addMouseOverListener(parentChart, childChart, setParentCrosshair, setChildCrosshair, setParentRange, setChildRange)
addMouseOverListener(childChart, parentChart, setChildCrosshair, setParentCrosshair, setChildRange, setParentRange)
parentChart.chart.timeScale().subscribeVisibleLogicalRangeChange(setChildRange)
parentChart.chart.subscribeCrosshairMove(setChildCrosshair)
}
function stampToDate(stampOrBusiness) {
return new Date(stampOrBusiness*1000)
}
function dateToStamp(date) {
return Math.floor(date.getTime()/1000)
}
function lastBar(obj) {
return obj[obj.length-1]
}
function calculateTrendLine(startDate, startValue, endDate, endValue, chart, ray=false) {
let reversed = false
if (stampToDate(endDate).getTime() < stampToDate(startDate).getTime()) {
reversed = true;
[startDate, endDate] = [endDate, startDate];
}
let startIndex
if (stampToDate(startDate).getTime() < stampToDate(chart.data[0].time).getTime()) {
startIndex = 0
}
else {
startIndex = chart.data.findIndex(item => stampToDate(item.time).getTime() === stampToDate(startDate).getTime())
}
if (startIndex === -1) {
throw new Error(`Could not calculate start index from time ${stampToDate(startDate)}.`)
}
let endIndex
if (ray) {
endIndex = chart.data.length+1000
startValue = endValue
}
else {
endIndex = chart.data.findIndex(item => stampToDate(item.time).getTime() === stampToDate(endDate).getTime())
if (endIndex === -1) {
let barsBetween = (endDate-lastBar(chart.data).time)/chart.interval
endIndex = chart.data.length-1+barsBetween
}
}
let numBars = endIndex-startIndex
const rate_of_change = (endValue - startValue) / numBars;
const trendData = [];
let currentDate = null
let iPastData = 0
for (let i = 0; i <= numBars; i++) {
if (chart.data[startIndex+i]) {
currentDate = chart.data[startIndex+i].time
}
else {
iPastData ++
currentDate = lastBar(chart.data).time+(iPastData*chart.interval)
}
const currentValue = reversed ? startValue + rate_of_change * (numBars - i) : startValue + rate_of_change * i;
trendData.push({ time: currentDate, value: currentValue });
}
return trendData;
}
if (!window.ContextMenu) {
class ContextMenu {
constructor() {
this.menu = document.createElement('div')
this.menu.style.position = 'absolute'
this.menu.style.zIndex = '10000'
this.menu.style.background = 'rgb(50, 50, 50)'
this.menu.style.color = pane.activeColor
this.menu.style.display = 'none'
this.menu.style.borderRadius = '5px'
this.menu.style.padding = '3px 3px'
this.menu.style.fontSize = '13px'
this.menu.style.cursor = 'default'
document.body.appendChild(this.menu)
this.hoverItem = null
let closeMenu = (event) => {
if (!this.menu.contains(event.target)) {
this.menu.style.display = 'none';
this.listen(false)
}
}
this.onRightClick = (event) => {
event.preventDefault();
this.menu.style.left = event.clientX + 'px';
this.menu.style.top = event.clientY + 'px';
this.menu.style.display = 'block';
document.removeEventListener('click', closeMenu)
document.addEventListener('click', closeMenu)
}
}
listen(active) {
active ? document.addEventListener('contextmenu', this.onRightClick) : document.removeEventListener('contextmenu', this.onRightClick)
}
menuItem(text, action, hover=false) {
let item = document.createElement('span')
item.style.display = 'flex'
item.style.alignItems = 'center'
item.style.justifyContent = 'space-between'
item.style.padding = '2px 10px'
item.style.margin = '1px 0px'
item.style.borderRadius = '3px'
this.menu.appendChild(item)
let elem = document.createElement('span')
elem.innerText = text
elem.style.pointerEvents = 'none'
item.appendChild(elem)
if (hover) {
let arrow = document.createElement('span')
arrow.innerText = ``
arrow.style.fontSize = '8px'
arrow.style.pointerEvents = 'none'
item.appendChild(arrow)
}
item.addEventListener('mouseover', (event) => {
item.style.backgroundColor = 'rgba(0, 122, 255, 0.3)'
if (this.hoverItem && this.hoverItem.closeAction) this.hoverItem.closeAction()
this.hoverItem = {elem: elem, action: action, closeAction: hover}
})
item.addEventListener('mouseout', (event) => item.style.backgroundColor = 'transparent')
if (!hover) item.addEventListener('click', (event) => {action(event); this.menu.style.display = 'none'})
else {
let timeout
item.addEventListener('mouseover', () => timeout = setTimeout(() => action(item.getBoundingClientRect()), 100))
item.addEventListener('mouseout', () => clearTimeout(timeout))
}
}
separator() {
let separator = document.createElement('div')
separator.style.width = '90%'
separator.style.height = '1px'
separator.style.margin = '3px 0px'
separator.style.backgroundColor = pane.borderColor
this.menu.appendChild(separator)
}
}
window.ContextMenu = ContextMenu
}
window.callbackFunction = () => undefined;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,234 @@
:root {
--bg-color:#0c0d0f;
--hover-bg-color: #3c434c;
--click-bg-color: #50565E;
--active-bg-color: rgba(0, 122, 255, 0.7);
--muted-bg-color: rgba(0, 122, 255, 0.3);
--border-color: #3C434C;
--color: #d8d9db;
--active-color: #ececed;
}
body {
background-color: rgb(0,0,0);
color: rgba(19, 23, 34, 1);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
.handler {
display: flex;
flex-direction: column;
position: relative;
}
.toolbox {
position: absolute;
z-index: 2000;
display: flex;
align-items: center;
top: 25%;
border: 2px solid var(--border-color);
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background-color: rgba(25, 27, 30, 0.5);
flex-direction: column;
}
.toolbox-button {
margin: 3px;
border-radius: 4px;
display: flex;
background-color: transparent;
}
.toolbox-button:hover {
background-color: rgba(80, 86, 94, 0.7);
}
.toolbox-button:active {
background-color: rgba(90, 106, 104, 0.7);
}
.active-toolbox-button {
background-color: var(--active-bg-color) !important;
}
.active-toolbox-button g {
fill: var(--active-color);
}
.context-menu {
position: absolute;
z-index: 1000;
background: rgb(50, 50, 50);
color: var(--active-color);
display: none;
border-radius: 5px;
padding: 3px 3px;
font-size: 13px;
cursor: default;
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 10px;
margin: 1px 0px;
border-radius: 3px;
}
.context-menu-item:hover {
background-color: var(--muted-bg-color);
}
.color-picker {
max-width: 170px;
background-color: var(--bg-color);
position: absolute;
z-index: 10000;
display: none;
flex-direction: column;
align-items: center;
border: 2px solid var(--border-color);
border-radius: 8px;
cursor: default;
}
/* topbar-related */
.topbar {
background-color: var(--bg-color);
border-bottom: 2px solid var(--border-color);
display: flex;
align-items: center;
}
.topbar-container {
display: flex;
align-items: center;
flex-grow: 1;
}
.topbar-button {
border: none;
padding: 2px 5px;
margin: 4px 10px;
font-size: 13px;
border-radius: 4px;
color: var(--color);
background-color: transparent;
}
.topbar-button:hover {
background-color: var(--hover-bg-color)
}
.topbar-button:active {
background-color: var(--click-bg-color);
color: var(--active-color);
font-weight: 500;
}
.switcher-button:active {
background-color: var(--click-bg-color);
color: var(--color);
font-weight: normal;
}
.active-switcher-button {
background-color: var(--active-bg-color) !important;
color: var(--active-color) !important;
font-weight: 500;
}
.topbar-textbox {
margin: 0px 18px;
font-size: 16px;
color: var(--color);
}
.topbar-textbox-input {
background-color: var(--bg-color);
color: var(--color);
border: 1px solid var(--color);
}
.topbar-menu {
position: absolute;
display: none;
z-index: 10000;
background-color: var(--bg-color);
border-radius: 2px;
border: 2px solid var(--border-color);
border-top: none;
align-items: flex-start;
max-height: 80%;
overflow-y: auto;
}
.topbar-separator {
width: 1px;
height: 20px;
background-color: var(--border-color);
}
.searchbox {
position: absolute;
top: 0;
bottom: 200px;
left: 0;
right: 0;
margin: auto;
width: 150px;
height: 30px;
padding: 5px;
z-index: 1000;
align-items: center;
background-color: rgba(30 ,30, 30, 0.9);
border: 2px solid var(--border-color);
border-radius: 5px;
display: flex;
}
.searchbox input {
text-align: center;
width: 100px;
margin-left: 10px;
background-color: var(--muted-bg-color);
color: var(--active-color);
font-size: 20px;
border: none;
outline: none;
border-radius: 2px;
}
.spinner {
width: 30px;
height: 30px;
border: 4px solid rgba(255, 255, 255, 0.6);
border-top: 4px solid var(--active-bg-color);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
z-index: 1000;
transform: translate(-50%, -50%);
display: none;
}
.legend {
position: absolute;
z-index: 3000;
pointer-events: none;
top: 10px;
left: 10px;
display: none;
flex-direction: column;
}
.legend-toggle-switch {
border-radius: 4px;
margin-left: 10px;
pointer-events: auto;
}
.legend-toggle-switch:hover {
cursor: pointer;
background-color: rgba(50, 50, 50, 0.5);
}

View File

@ -1,166 +0,0 @@
if (!window.Table) {
class Table {
constructor(width, height, headings, widths, alignments, position, draggable = false,
tableBackgroundColor, borderColor, borderWidth, textColors, backgroundColors) {
this.container = document.createElement('div')
this.callbackName = null
this.borderColor = borderColor
this.borderWidth = borderWidth
if (draggable) {
this.container.style.position = 'absolute'
this.container.style.cursor = 'move'
} else {
this.container.style.position = 'relative'
this.container.style.float = position
}
this.container.style.zIndex = '2000'
this.reSize(width, height)
this.container.style.display = 'flex'
this.container.style.flexDirection = 'column'
// this.container.style.justifyContent = 'space-between'
this.container.style.borderRadius = '5px'
this.container.style.color = 'white'
this.container.style.fontSize = '12px'
this.container.style.fontVariantNumeric = 'tabular-nums'
this.table = document.createElement('table')
this.table.style.width = '100%'
this.table.style.borderCollapse = 'collapse'
this.container.style.overflow = 'hidden'
this.rows = {}
this.headings = headings
this.widths = widths.map((width) => `${width * 100}%`)
this.alignments = alignments
let head = this.table.createTHead()
let row = head.insertRow()
for (let i = 0; i < this.headings.length; i++) {
let th = document.createElement('th')
th.textContent = this.headings[i]
th.style.width = this.widths[i]
th.style.letterSpacing = '0.03rem'
th.style.padding = '0.2rem 0px'
th.style.fontWeight = '500'
th.style.textAlign = 'center'
if (i !== 0) th.style.borderLeft = borderWidth+'px solid '+borderColor
th.style.position = 'sticky'
th.style.top = '0'
th.style.backgroundColor = backgroundColors.length > 0 ? backgroundColors[i] : tableBackgroundColor
th.style.color = textColors[i]
row.appendChild(th)
}
let overflowWrapper = document.createElement('div')
overflowWrapper.style.overflowY = 'auto'
overflowWrapper.style.overflowX = 'hidden'
overflowWrapper.style.backgroundColor = tableBackgroundColor
overflowWrapper.appendChild(this.table)
this.container.appendChild(overflowWrapper)
document.getElementById('wrapper').appendChild(this.container)
if (!draggable) return
let offsetX, offsetY;
this.onMouseDown = (event) => {
offsetX = event.clientX - this.container.offsetLeft;
offsetY = event.clientY - this.container.offsetTop;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
let onMouseMove = (event) => {
this.container.style.left = (event.clientX - offsetX) + 'px';
this.container.style.top = (event.clientY - offsetY) + 'px';
}
let onMouseUp = () => {
// Remove the event listeners for dragging
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
this.container.addEventListener('mousedown', this.onMouseDown);
}
divToButton(div, callbackString) {
div.addEventListener('mouseover', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
div.addEventListener('mouseout', () => div.style.backgroundColor = 'transparent')
div.addEventListener('mousedown', () => div.style.backgroundColor = 'rgba(60, 60, 60)')
div.addEventListener('click', () => window.callbackFunction(callbackString))
div.addEventListener('mouseup', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
}
newRow(id, returnClickedCell=false) {
let row = this.table.insertRow()
row.style.cursor = 'default'
for (let i = 0; i < this.headings.length; i++) {
let cell = row.insertCell()
cell.style.width = this.widths[i];
cell.style.textAlign = this.alignments[i];
cell.style.border = this.borderWidth+'px solid '+this.borderColor
if (returnClickedCell) {
this.divToButton(cell, `${this.callbackName}_~_${id};;;${this.headings[i]}`)
}
row[this.headings[i]] = cell
}
if (!returnClickedCell) {
this.divToButton(row, `${this.callbackName}_~_${id}`)
}
this.rows[id] = row
}
deleteRow(id) {
this.table.deleteRow(this.rows[id].rowIndex)
delete this.rows[id]
}
clearRows() {
let numRows = Object.keys(this.rows).length
for (let i = 0; i < numRows; i++)
this.table.deleteRow(-1)
this.rows = {}
}
updateCell(rowId, column, val) {
this.rows[rowId][column].textContent = val
}
makeSection(id, type, numBoxes, func=false) {
let section = document.createElement('div')
section.style.display = 'flex'
section.style.width = '100%'
section.style.padding = '3px 0px'
section.style.backgroundColor = 'rgb(30, 30, 30)'
type === 'footer' ? this.container.appendChild(section) : this.container.prepend(section)
this[type] = []
for (let i = 0; i < numBoxes; i++) {
let textBox = document.createElement('div')
section.appendChild(textBox)
textBox.style.flex = '1'
textBox.style.textAlign = 'center'
if (func) {
this.divToButton(textBox, `${id}_~_${i}`)
textBox.style.borderRadius = '2px'
}
this[type].push(textBox)
}
}
reSize(width, height) {
this.container.style.width = width <= 1 ? width * 100 + '%' : width + 'px'
this.container.style.height = height <= 1 ? height * 100 + '%' : height + 'px'
}
}
window.Table = Table
}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="">
<head>
<title>lightweight-charts-python</title>
<link rel="stylesheet" href="styles.css">
<script src="./lightweight-charts.js"></script>
<meta name="viewport" content ="width=device-width, initial-scale=1">
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="./bundle.js"></script>
</body>
</html>

View File

@ -1,736 +0,0 @@
if (!window.ToolBox) {
class ToolBox {
constructor(chart) {
this.onTrendSelect = this.onTrendSelect.bind(this)
this.onHorzSelect = this.onHorzSelect.bind(this)
this.onRaySelect = this.onRaySelect.bind(this)
this.saveDrawings = this.saveDrawings.bind(this)
this.chart = chart
this.drawings = []
this.chart.cursor = 'default'
this.makingDrawing = false
this.mouseDown = false
this.hoverBackgroundColor = 'rgba(80, 86, 94, 0.7)'
this.clickBackgroundColor = 'rgba(90, 106, 104, 0.7)'
this.elem = this.makeToolBox()
this.subscribeHoverMove()
}
toJSON() {
// Exclude the chart attribute from serialization
const {chart, ...serialized} = this;
return serialized;
}
makeToolBox() {
let toolBoxElem = document.createElement('div')
toolBoxElem.style.position = 'absolute'
toolBoxElem.style.zIndex = '2000'
toolBoxElem.style.display = 'flex'
toolBoxElem.style.alignItems = 'center'
toolBoxElem.style.top = '25%'
toolBoxElem.style.border = '2px solid '+pane.borderColor
toolBoxElem.style.borderLeft = 'none'
toolBoxElem.style.borderTopRightRadius = '4px'
toolBoxElem.style.borderBottomRightRadius = '4px'
toolBoxElem.style.backgroundColor = 'rgba(25, 27, 30, 0.5)'
toolBoxElem.style.flexDirection = 'column'
this.chart.activeIcon = null
let trend = this.makeToolBoxElement(this.onTrendSelect, 'KeyT', `<rect x="3.84" y="13.67" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -5.9847 14.4482)" width="21.21" height="1.56"/><path d="M23,3.17L20.17,6L23,8.83L25.83,6L23,3.17z M23,7.41L21.59,6L23,4.59L24.41,6L23,7.41z"/><path d="M6,20.17L3.17,23L6,25.83L8.83,23L6,20.17z M6,24.41L4.59,23L6,21.59L7.41,23L6,24.41z"/>`)
let horz = this.makeToolBoxElement(this.onHorzSelect, 'KeyH', `<rect x="4" y="14" width="9" height="1"/><rect x="16" y="14" width="9" height="1"/><path d="M11.67,14.5l2.83,2.83l2.83-2.83l-2.83-2.83L11.67,14.5z M15.91,14.5l-1.41,1.41l-1.41-1.41l1.41-1.41L15.91,14.5z"/>`)
let ray = this.makeToolBoxElement(this.onRaySelect, 'KeyR', `<rect x="8" y="14" width="17" height="1"/><path d="M3.67,14.5l2.83,2.83l2.83-2.83L6.5,11.67L3.67,14.5z M7.91,14.5L6.5,15.91L5.09,14.5l1.41-1.41L7.91,14.5z"/>`)
// let testB = this.makeToolBoxElement(this.onTrendSelect, 'KeyB', `<rect x="8" y="6" width="12" height="1"/><rect x="9" y="22" width="11" height="1"/><path d="M3.67,6.5L6.5,9.33L9.33,6.5L6.5,3.67L3.67,6.5z M7.91,6.5L6.5,7.91L5.09,6.5L6.5,5.09L7.91,6.5z"/><path d="M19.67,6.5l2.83,2.83l2.83-2.83L22.5,3.67L19.67,6.5z M23.91,6.5L22.5,7.91L21.09,6.5l1.41-1.41L23.91,6.5z"/><path d="M19.67,22.5l2.83,2.83l2.83-2.83l-2.83-2.83L19.67,22.5z M23.91,22.5l-1.41,1.41l-1.41-1.41l1.41-1.41L23.91,22.5z"/><path d="M3.67,22.5l2.83,2.83l2.83-2.83L6.5,19.67L3.67,22.5z M7.91,22.5L6.5,23.91L5.09,22.5l1.41-1.41L7.91,22.5z"/><rect x="22" y="9" width="1" height="11"/><rect x="6" y="9" width="1" height="11"/>`)
toolBoxElem.appendChild(trend)
toolBoxElem.appendChild(horz)
toolBoxElem.appendChild(ray)
// toolBoxElem.appendChild(testB)
this.chart.div.append(toolBoxElem)
let commandZHandler = (toDelete) => {
if (!toDelete) return
if ('price' in toDelete && toDelete.id !== 'toolBox') return commandZHandler(this.drawings[this.drawings.indexOf(toDelete) - 1])
this.deleteDrawing(toDelete)
}
this.chart.commandFunctions.push((event) => {
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') {
commandZHandler(lastBar(this.drawings))
return true
}
});
return toolBoxElem
}
makeToolBoxElement(action, keyCmd, paths) {
let elem = document.createElement('div')
elem.style.margin = '3px'
elem.style.borderRadius = '4px'
elem.style.display = 'flex'
let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "29");
svg.setAttribute("height", "29");
let group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = paths
group.setAttribute("fill", pane.color)
svg.appendChild(group)
elem.appendChild(svg);
let icon = {elem: elem, action: action}
elem.addEventListener('mouseenter', () => {
elem.style.backgroundColor = icon === this.chart.activeIcon ? pane.activeBackgroundColor : this.hoverBackgroundColor
})
elem.addEventListener('mouseleave', () => {
elem.style.backgroundColor = icon === this.chart.activeIcon ? pane.activeBackgroundColor : 'transparent'
})
elem.addEventListener('mousedown', () => {
elem.style.backgroundColor = icon === this.chart.activeIcon ? pane.activeBackgroundColor : this.clickBackgroundColor
})
elem.addEventListener('mouseup', () => {
elem.style.backgroundColor = icon === this.chart.activeIcon ? pane.activeBackgroundColor : 'transparent'
})
elem.addEventListener('click', () => {
if (this.chart.activeIcon) {
this.chart.activeIcon.elem.style.backgroundColor = 'transparent'
group.setAttribute("fill", pane.color)
document.body.style.cursor = 'crosshair'
this.chart.cursor = 'crosshair'
this.chart.activeIcon.action(false)
if (this.chart.activeIcon === icon) {
return this.chart.activeIcon = null
}
}
this.chart.activeIcon = icon
group.setAttribute("fill", pane.activeColor)
elem.style.backgroundColor = pane.activeBackgroundColor
document.body.style.cursor = 'crosshair'
this.chart.cursor = 'crosshair'
this.chart.activeIcon.action(true)
})
this.chart.commandFunctions.push((event) => {
if (this.chart !== window.selectedChart) {
return
}
if (event.altKey && event.code === keyCmd) {
event.preventDefault()
if (this.chart.activeIcon) {
this.chart.activeIcon.elem.style.backgroundColor = 'transparent'
group.setAttribute("fill", pane.color)
document.body.style.cursor = 'crosshair'
this.chart.cursor = 'crosshair'
this.chart.activeIcon.action(false)
}
this.chart.activeIcon = icon
group.setAttribute("fill", pane.activeColor)
elem.style.backgroundColor = pane.activeBackgroundColor
document.body.style.cursor = 'crosshair'
this.chart.cursor = 'crosshair'
this.chart.activeIcon.action(true)
return true
}
})
return elem
}
removeActiveAndSave() {
document.body.style.cursor = 'default'
this.chart.cursor = 'default'
this.chart.activeIcon.elem.style.backgroundColor = 'transparent'
this.chart.activeIcon = null
this.saveDrawings()
}
onTrendSelect(toggle, ray = false) {
let trendLine = null
let firstTime = null
let firstPrice = null
let currentTime = null
if (!toggle) {
return this.chart.chart.unsubscribeClick(this.clickHandler)
}
let crosshairHandlerTrend = (param) => {
this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerTrend)
if (!this.makingDrawing) return
currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x)
if (!currentTime) {
let barsToMove = param.logical - this.chart.data.length-1
currentTime = lastBar(this.chart.data).time+(barsToMove*this.chart.interval)
}
let currentPrice = this.chart.series.coordinateToPrice(param.point.y)
if (!currentTime) return this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
trendLine.calculateAndSet(firstTime, firstPrice, currentTime, currentPrice)
setTimeout(() => {
this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
}, 10);
}
this.clickHandler = (param) => {
if (!this.makingDrawing) {
this.makingDrawing = true
trendLine = new TrendLine(this.chart, 'rgb(15, 139, 237)', ray)
firstPrice = this.chart.series.coordinateToPrice(param.point.y)
firstTime = !ray ? this.chart.chart.timeScale().coordinateToTime(param.point.x) : lastBar(this.chart.data).time
this.chart.chart.applyOptions({handleScroll: false})
this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
}
else {
this.chart.chart.applyOptions({handleScroll: true})
this.makingDrawing = false
trendLine.line.setMarkers([])
this.drawings.push(trendLine)
this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerTrend)
this.chart.chart.unsubscribeClick(this.clickHandler)
this.removeActiveAndSave()
}
}
this.chart.chart.subscribeClick(this.clickHandler)
}
onHorzSelect(toggle) {
let clickHandlerHorz = (param) => {
let price = this.chart.series.coordinateToPrice(param.point.y)
let lineStyle = LightweightCharts.LineStyle.Solid
let line = new HorizontalLine(this.chart, 'toolBox', price,'red', 2, lineStyle, true)
this.drawings.push(line)
this.chart.chart.unsubscribeClick(clickHandlerHorz)
this.removeActiveAndSave()
}
if (toggle) this.chart.chart.subscribeClick(clickHandlerHorz)
else this.chart.chart.unsubscribeClick(clickHandlerHorz)
}
onRaySelect(toggle) {
this.onTrendSelect(toggle, true)
}
subscribeHoverMove() {
let hoveringOver = null
let x, y
let colorPicker = new ColorPicker(this.saveDrawings)
let stylePicker = new StylePicker(this.saveDrawings)
let onClickDelete = () => this.deleteDrawing(contextMenu.drawing)
let onClickColor = (rect) => colorPicker.openMenu(rect, contextMenu.drawing)
let onClickStyle = (rect) => stylePicker.openMenu(rect, contextMenu.drawing)
let contextMenu = new ContextMenu()
contextMenu.menuItem('Color Picker', onClickColor, () =>{
document.removeEventListener('click', colorPicker.closeMenu)
colorPicker.container.style.display = 'none'
})
contextMenu.menuItem('Style', onClickStyle, () => {
document.removeEventListener('click', stylePicker.closeMenu)
stylePicker.container.style.display = 'none'
})
contextMenu.separator()
contextMenu.menuItem('Delete Drawing', onClickDelete)
let hoverOver = (param) => {
if (!param.point || this.makingDrawing) return
this.chart.chart.unsubscribeCrosshairMove(hoverOver)
x = param.point.x
y = param.point.y
this.drawings.forEach((drawing) => {
let boundaryConditional
let horizontal = false
if ('price' in drawing) {
horizontal = true
let priceCoordinate = this.chart.series.priceToCoordinate(drawing.price)
boundaryConditional = Math.abs(priceCoordinate - param.point.y) < 6
} else {
let trendData = param.seriesData.get(drawing.line);
if (!trendData) return
let priceCoordinate = this.chart.series.priceToCoordinate(trendData.value)
let timeCoordinate = this.chart.chart.timeScale().timeToCoordinate(trendData.time)
boundaryConditional = Math.abs(priceCoordinate - param.point.y) < 6 && Math.abs(timeCoordinate - param.point.x) < 6
}
if (boundaryConditional) {
if (hoveringOver === drawing) return
if (!horizontal && !drawing.ray) drawing.line.setMarkers(drawing.markers)
document.body.style.cursor = 'pointer'
document.addEventListener('mousedown', checkForClick)
document.addEventListener('mouseup', checkForRelease)
hoveringOver = drawing
contextMenu.listen(true)
contextMenu.drawing = drawing
} else if (hoveringOver === drawing) {
if (!horizontal && !drawing.ray) drawing.line.setMarkers([])
document.body.style.cursor = this.chart.cursor
hoveringOver = null
contextMenu.listen(false)
if (!this.mouseDown) {
document.removeEventListener('mousedown', checkForClick)
document.removeEventListener('mouseup', checkForRelease)
}
}
})
this.chart.chart.subscribeCrosshairMove(hoverOver)
}
let originalIndex
let originalTime
let originalPrice
this.mouseDown = false
let clickedEnd = false
let labelColor
let checkForClick = (event) => {
this.mouseDown = true
document.body.style.cursor = 'grabbing'
this.chart.chart.applyOptions({handleScroll: false})
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
this.chart.chart.unsubscribeCrosshairMove(hoverOver)
labelColor = this.chart.chart.options().crosshair.horzLine.labelBackgroundColor
this.chart.chart.applyOptions({crosshair: {horzLine: {labelBackgroundColor: hoveringOver.color}}})
if ('price' in hoveringOver) {
originalPrice = hoveringOver.price
this.chart.chart.subscribeCrosshairMove(crosshairHandlerHorz)
} else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.from[0]) - x) < 4 && !hoveringOver.ray) {
clickedEnd = 'first'
this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
} else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.to[0]) - x) < 4 && !hoveringOver.ray) {
clickedEnd = 'last'
this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
} else {
originalPrice = this.chart.series.coordinateToPrice(y)
originalTime = this.chart.chart.timeScale().coordinateToTime(x * this.chart.scale.width)
this.chart.chart.subscribeCrosshairMove(checkForDrag)
}
originalIndex = this.chart.chart.timeScale().coordinateToLogical(x)
document.removeEventListener('mousedown', checkForClick)
}
let checkForRelease = (event) => {
this.mouseDown = false
document.body.style.cursor = this.chart.cursor
this.chart.chart.applyOptions({handleScroll: true})
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
this.chart.chart.applyOptions({crosshair: {horzLine: {labelBackgroundColor: labelColor}}})
if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') {
window.callbackFunction(`${hoveringOver.id}_~_${hoveringOver.price.toFixed(8)}`);
}
hoveringOver = null
document.removeEventListener('mousedown', checkForClick)
document.removeEventListener('mouseup', checkForRelease)
this.chart.chart.subscribeCrosshairMove(hoverOver)
this.saveDrawings()
}
let checkForDrag = (param) => {
if (!param.point) return
this.chart.chart.unsubscribeCrosshairMove(checkForDrag)
if (!this.mouseDown) return
let priceAtCursor = this.chart.series.coordinateToPrice(param.point.y)
let priceDiff = priceAtCursor - originalPrice
let barsToMove = param.logical - originalIndex
let startBarIndex = this.chart.data.findIndex(item => item.time === hoveringOver.from[0])
let endBarIndex = this.chart.data.findIndex(item => item.time === hoveringOver.to[0])
let startDate
let endBar
if (hoveringOver.ray) {
endBar = this.chart.data[startBarIndex + barsToMove]
startDate = hoveringOver.to[0]
} else {
startDate = this.chart.data[startBarIndex + barsToMove].time
endBar = endBarIndex === -1 ? null : this.chart.data[endBarIndex + barsToMove]
}
let endDate = endBar ? endBar.time : hoveringOver.to[0] + (barsToMove * this.chart.interval)
let startValue = hoveringOver.from[1] + priceDiff
let endValue = hoveringOver.to[1] + priceDiff
hoveringOver.calculateAndSet(startDate, startValue, endDate, endValue)
originalIndex = param.logical
originalPrice = priceAtCursor
this.chart.chart.subscribeCrosshairMove(checkForDrag)
}
let crosshairHandlerTrend = (param) => {
if (!param.point) return
this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerTrend)
if (!this.mouseDown) return
let currentPrice = this.chart.series.coordinateToPrice(param.point.y)
let currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x)
let [firstTime, firstPrice] = [null, null]
if (clickedEnd === 'last') {
firstTime = hoveringOver.from[0]
firstPrice = hoveringOver.from[1]
} else if (clickedEnd === 'first') {
firstTime = hoveringOver.to[0]
firstPrice = hoveringOver.to[1]
}
if (!currentTime) {
let barsToMove = param.logical - this.chart.data.length-1
currentTime = lastBar(this.chart.data).time + (barsToMove*this.chart.interval)
}
hoveringOver.calculateAndSet(firstTime, firstPrice, currentTime, currentPrice)
setTimeout(() => {
this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
}, 10);
}
let crosshairHandlerHorz = (param) => {
if (!param.point) return
this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerHorz)
if (!this.mouseDown) return
hoveringOver.updatePrice(this.chart.series.coordinateToPrice(param.point.y))
setTimeout(() => {
this.chart.chart.subscribeCrosshairMove(crosshairHandlerHorz)
}, 10)
}
this.chart.chart.subscribeCrosshairMove(hoverOver)
}
renderDrawings() {
if (this.mouseDown) return
this.drawings.forEach((item) => {
if ('price' in item) return
let startDate = Math.round(item.from[0]/this.chart.interval)*this.chart.interval
let endDate = Math.round(item.to[0]/this.chart.interval)*this.chart.interval
item.calculateAndSet(startDate, item.from[1], endDate, item.to[1])
})
}
deleteDrawing(drawing) {
if ('price' in drawing) {
this.chart.series.removePriceLine(drawing.line)
}
else {
let range = this.chart.chart.timeScale().getVisibleLogicalRange()
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
this.chart.chart.removeSeries(drawing.line);
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
this.chart.chart.timeScale().setVisibleLogicalRange(range)
}
this.drawings.splice(this.drawings.indexOf(drawing), 1)
this.saveDrawings()
}
clearDrawings() {
this.drawings.forEach((item) => {
if ('price' in item) this.chart.series.removePriceLine(item.line)
else this.chart.chart.removeSeries(item.line)
})
this.drawings = []
}
saveDrawings() {
let drawingsString = JSON.stringify(this.drawings, (key, value) => {
if (key === '' && Array.isArray(value)) {
return value.filter(item => !(item && typeof item === 'object' && 'priceLine' in item && item.id !== 'toolBox'));
} else if (key === 'line' || (value && typeof value === 'object' && 'priceLine' in value && value.id !== 'toolBox')) {
return undefined;
}
return value;
});
window.callbackFunction(`save_drawings${this.chart.id}_~_${drawingsString}`)
}
loadDrawings(drawings) {
this.drawings = []
drawings.forEach((item) => {
let drawing = null
if ('price' in item) {
drawing = new HorizontalLine(this.chart, 'toolBox',
item.priceLine.price, item.priceLine.color, 2,
item.priceLine.lineStyle, item.priceLine.axisLabelVisible)
}
else {
let startDate = Math.round((item.from[0]/this.chart.interval)*this.chart.interval)
let endDate = Math.round((item.to[0]/this.chart.interval)*this.chart.interval)
drawing = new TrendLine(this.chart, item.color, item.ray)
drawing.calculateAndSet(startDate, item.from[1], endDate, item.to[1])
}
this.drawings.push(drawing)
})
}
}
window.ToolBox = ToolBox
class TrendLine {
constructor(chart, color, ray) {
this.calculateAndSet = this.calculateAndSet.bind(this)
this.line = chart.chart.addLineSeries({
color: color,
lineWidth: 2,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
autoscaleInfoProvider: () => ({
priceRange: {
minValue: 1_000_000_000,
maxValue: 0,
},
}),
})
this.color = color
this.markers = null
this.data = null
this.from = null
this.to = null
this.ray = ray
this.chart = chart
}
toJSON() {
// Exclude the chart attribute from serialization
const {chart, ...serialized} = this;
return serialized;
}
calculateAndSet(firstTime, firstPrice, currentTime, currentPrice) {
let data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.chart, this.ray)
this.from = [data[0].time, data[0].value]
this.to = [data[data.length - 1].time, data[data.length-1].value]
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
let logical = this.chart.chart.timeScale().getVisibleLogicalRange()
this.line.setData(data)
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
this.chart.chart.timeScale().setVisibleLogicalRange(logical)
if (!this.ray) {
this.markers = [
{time: this.from[0], position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1},
{time: this.to[0], position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}
]
this.line.setMarkers(this.markers)
}
}
}
window.TrendLine = TrendLine
class ColorPicker {
constructor(saveDrawings) {
this.saveDrawings = saveDrawings
this.container = document.createElement('div')
this.container.style.maxWidth = '170px'
this.container.style.backgroundColor = pane.backgroundColor
this.container.style.position = 'absolute'
this.container.style.zIndex = '10000'
this.container.style.display = 'none'
this.container.style.flexDirection = 'column'
this.container.style.alignItems = 'center'
this.container.style.border = '2px solid '+pane.borderColor
this.container.style.borderRadius = '8px'
this.container.style.cursor = 'default'
let colorPicker = document.createElement('div')
colorPicker.style.margin = '10px'
colorPicker.style.display = 'flex'
colorPicker.style.flexWrap = 'wrap'
let colors = [
'#EBB0B0','#E9CEA1','#E5DF80','#ADEB97','#A3C3EA','#D8BDED',
'#E15F5D','#E1B45F','#E2D947','#4BE940','#639AE1','#D7A0E8',
'#E42C2A','#E49D30','#E7D827','#3CFF0A','#3275E4','#B06CE3',
'#F3000D','#EE9A14','#F1DA13','#2DFC0F','#1562EE','#BB00EF',
'#B50911','#E3860E','#D2BD11','#48DE0E','#1455B4','#6E009F',
'#7C1713','#B76B12','#8D7A13','#479C12','#165579','#51007E',
]
colors.forEach((color) => colorPicker.appendChild(this.makeColorBox(color)))
let separator = document.createElement('div')
separator.style.backgroundColor = pane.borderColor
separator.style.height = '1px'
separator.style.width = '130px'
let opacity = document.createElement('div')
opacity.style.margin = '10px'
let opacityText = document.createElement('div')
opacityText.style.color = 'lightgray'
opacityText.style.fontSize = '12px'
opacityText.innerText = 'Opacity'
let opacityValue = document.createElement('div')
opacityValue.style.color = 'lightgray'
opacityValue.style.fontSize = '12px'
let opacitySlider = document.createElement('input')
opacitySlider.type = 'range'
opacitySlider.value = this.opacity*100
opacityValue.innerText = opacitySlider.value+'%'
opacitySlider.oninput = () => {
opacityValue.innerText = opacitySlider.value+'%'
this.opacity = opacitySlider.value/100
this.updateColor()
}
opacity.appendChild(opacityText)
opacity.appendChild(opacitySlider)
opacity.appendChild(opacityValue)
this.container.appendChild(colorPicker)
this.container.appendChild(separator)
this.container.appendChild(opacity)
document.getElementById('wrapper').appendChild(this.container)
}
makeColorBox(color) {
let box = document.createElement('div')
box.style.width = '18px'
box.style.height = '18px'
box.style.borderRadius = '3px'
box.style.margin = '3px'
box.style.boxSizing = 'border-box'
box.style.backgroundColor = color
box.addEventListener('mouseover', (event) => box.style.border = '2px solid lightgray')
box.addEventListener('mouseout', (event) => box.style.border = 'none')
let rgbValues = this.extractRGB(color)
box.addEventListener('click', (event) => {
this.rgbValues = rgbValues
this.updateColor()
})
return box
}
extractRGB = (anyColor) => {
let dummyElem = document.createElement('div');
dummyElem.style.color = anyColor;
document.body.appendChild(dummyElem);
let computedColor = getComputedStyle(dummyElem).color;
document.body.removeChild(dummyElem);
let colorValues = computedColor.match(/\d+/g).map(Number);
let isRgba = computedColor.includes('rgba');
let opacity = isRgba ? parseFloat(computedColor.split(',')[3]) : 1
return [colorValues[0], colorValues[1], colorValues[2], opacity]
}
updateColor() {
let oColor = `rgba(${this.rgbValues[0]}, ${this.rgbValues[1]}, ${this.rgbValues[2]}, ${this.opacity})`
if ('price' in this.drawing) this.drawing.updateColor(oColor)
else {
this.drawing.color = oColor
this.drawing.line.applyOptions({color: oColor})
}
this.saveDrawings()
}
openMenu(rect, drawing) {
this.drawing = drawing
this.rgbValues = this.extractRGB(drawing.color)
this.opacity = parseFloat(this.rgbValues[3])
this.container.style.top = (rect.top-30)+'px'
this.container.style.left = rect.right+'px'
this.container.style.display = 'flex'
setTimeout(() => document.addEventListener('mousedown', (event) => {
if (!this.container.contains(event.target)) {
this.closeMenu()
}
}), 10)
}
closeMenu(event) {
document.removeEventListener('click', this.closeMenu)
this.container.style.display = 'none'
}
}
window.ColorPicker = ColorPicker
class StylePicker {
constructor(saveDrawings) {
this.saveDrawings = saveDrawings
this.container = document.createElement('div')
this.container.style.position = 'absolute'
this.container.style.zIndex = '10000'
this.container.style.background = 'rgb(50, 50, 50)'
this.container.style.color = pane.activeColor
this.container.style.display = 'none'
this.container.style.borderRadius = '5px'
this.container.style.padding = '3px 3px'
this.container.style.fontSize = '13px'
this.container.style.cursor = 'default'
let styles = [
{name: 'Solid', var: LightweightCharts.LineStyle.Solid},
{name: 'Dotted', var: LightweightCharts.LineStyle.Dotted},
{name: 'Dashed', var: LightweightCharts.LineStyle.Dashed},
{name: 'Large Dashed', var: LightweightCharts.LineStyle.LargeDashed},
{name: 'Sparse Dotted', var: LightweightCharts.LineStyle.SparseDotted},
]
styles.forEach((style) => {
this.container.appendChild(this.makeTextBox(style.name, style.var))
})
document.getElementById('wrapper').appendChild(this.container)
}
makeTextBox(text, style) {
let item = document.createElement('span')
item.style.display = 'flex'
item.style.alignItems = 'center'
item.style.justifyContent = 'space-between'
item.style.padding = '2px 10px'
item.style.margin = '1px 0px'
item.style.borderRadius = '3px'
item.innerText = text
item.addEventListener('mouseover', (event) => item.style.backgroundColor = pane.mutedBackgroundColor)
item.addEventListener('mouseout', (event) => item.style.backgroundColor = 'transparent')
item.addEventListener('click', (event) => {
this.style = style
this.updateStyle()
})
return item
}
updateStyle() {
if ('price' in this.drawing) this.drawing.updateStyle(this.style)
else {
this.drawing.line.applyOptions({lineStyle: this.style})
}
this.saveDrawings()
}
openMenu(rect, drawing) {
this.drawing = drawing
this.container.style.top = (rect.top-30)+'px'
this.container.style.left = rect.right+'px'
this.container.style.display = 'block'
setTimeout(() => document.addEventListener('mousedown', (event) => {
if (!this.container.contains(event.target)) {
this.closeMenu()
}
}), 10)
}
closeMenu(event) {
document.removeEventListener('click', this.closeMenu)
this.container.style.display = 'none'
}
}
window.StylePicker = StylePicker
}

View File

@ -247,7 +247,7 @@ class PolygonAPI:
df = await async_get_bar_data(ticker, timeframe, start_date, end_date, limit)
self._chart.set(df, render_drawings=_tickers.get(self._chart) == ticker)
self._chart.set(df, keep_drawings=_tickers.get(self._chart) == ticker)
_tickers[self._chart] = ticker
if not live:
@ -396,7 +396,7 @@ class PolygonChart(Chart):
Tickers, security types and timeframes are to be defined within the chart window.
If using the standard `show` method, the `block` parameter must be set to True.
When using `show_async`, either is acceptable.
`show_async` can also be used.
"""
def __init__(
self, api_key: str, live: bool = False, num_bars: int = 200, end_date: str = 'now', limit: int = 5_000,

View File

@ -1,6 +1,6 @@
import asyncio
import random
from typing import Union
from typing import Union, Optional, Callable
from .util import jbool, Pane, NUM
@ -11,8 +11,8 @@ class Section(Pane):
self._table = table
self.type = section_type
def __call__(self, number_of_text_boxes: int, func: callable = None):
if func:
def __call__(self, number_of_text_boxes: int, func: Optional[Callable] = None):
if func is not None:
self.win.handlers[self.id] = lambda boxId: func(self._table, int(boxId))
self.run_script(f'''
{self._table.id}.makeSection("{self.id}", "{self.type}", {number_of_text_boxes}, {"true" if func else ""})
@ -35,7 +35,8 @@ class Row(dict):
def __setitem__(self, column, value):
if isinstance(column, tuple):
return [self.__setitem__(col, val) for col, val in zip(column, value)]
[self.__setitem__(col, val) for col, val in zip(column, value)]
return
original_value = value
if column in self._table._formatters:
value = self._table._formatters[column].replace(self._table.VALUE, str(value))
@ -47,7 +48,7 @@ class Row(dict):
def text_color(self, column, color): self._style('textColor', column, color)
def _style(self, style, column, arg):
self.run_script(f"{self._table.id}.rows[{self.id}]['{column}'].style.{style} = '{arg}'")
self.run_script(f"{self._table.id}.styleCell({self.id}, '{column}', '{style}', '{arg}')")
def delete(self):
self.run_script(f"{self._table.id}.deleteRow('{self.id}')")
@ -58,12 +59,22 @@ class Table(Pane, dict):
VALUE = 'CELL__~__VALUE__~__PLACEHOLDER'
def __init__(
self, window, width: NUM, height: NUM, headings: tuple, widths: tuple = None,
alignments: tuple = None, position='left', draggable: bool = False,
background_color: str = '#121417', border_color: str = 'rgb(70, 70, 70)',
border_width: int = 1, heading_text_colors: tuple = None,
heading_background_colors: tuple = None, return_clicked_cells: bool = False,
func: callable = None
self,
window,
width: NUM,
height: NUM,
headings: tuple,
widths: Optional[tuple] = None,
alignments: Optional[tuple] = None,
position='left',
draggable: bool = False,
background_color: str = '#121417',
border_color: str = 'rgb(70, 70, 70)',
border_width: int = 1,
heading_text_colors: Optional[tuple] = None,
heading_background_colors: Optional[tuple] = None,
return_clicked_cells: bool = False,
func: Optional[Callable] = None
):
dict.__init__(self)
Pane.__init__(self, window)
@ -85,17 +96,20 @@ class Table(Pane, dict):
self.win.handlers[self.id] = async_wrapper if asyncio.iscoroutinefunction(func) else wrapper
self.return_clicked_cells = return_clicked_cells
headings = list(headings)
widths = list(widths) if widths else []
alignments = list(alignments) if alignments else []
heading_text_colors = list(heading_text_colors) if heading_text_colors else []
heading_background_colors = list(heading_background_colors) if heading_background_colors else []
self.run_script(f'''
{self.id} = new Table(
{width}, {height}, {headings}, {widths}, {alignments}, '{position}', {jbool(draggable)},
'{background_color}', '{border_color}', {border_width}, {heading_text_colors},
{heading_background_colors}
{self.id} = new Lib.Table(
{width},
{height},
{list(headings)},
{list(widths) if widths else []},
{list(alignments) if alignments else []},
'{position}',
{jbool(draggable)},
'{background_color}',
'{border_color}',
{border_width},
{list(heading_text_colors) if heading_text_colors else []},
{list(heading_background_colors) if heading_background_colors else []}
)''')
self.run_script(f'{self.id}.callbackName = "{self.id}"') if func else None
self.footer = Section(self, 'footer')

View File

@ -3,14 +3,12 @@ import json
class ToolBox:
def __init__(self, chart):
from lightweight_charts.abstract import JS
self.run_script = chart.run_script
self.id = chart.id
self._save_under = None
self.drawings = {}
chart.win.handlers[f'save_drawings{self.id}'] = self._save_drawings
self.run_script(JS['toolbox'])
self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})')
self.run_script(f'{self.id}.createToolBox()')
def save_drawings_under(self, widget: 'Widget'):
"""
@ -24,7 +22,7 @@ class ToolBox:
"""
if not self.drawings.get(tag):
return
self.run_script(f'if ("toolBox" in {self.id}) {self.id}.toolBox.loadDrawings({json.dumps(self.drawings[tag])})')
self.run_script(f'if ({self.id}.toolBox) {self.id}.toolBox.loadDrawings({json.dumps(self.drawings[tag])})')
def import_drawings(self, file_path):
"""

View File

@ -8,11 +8,14 @@ ALIGN = Literal['left', 'right']
class Widget(Pane):
def __init__(self, topbar, value, func=None):
def __init__(self, topbar, value, func: callable = None, convert_boolean=False):
super().__init__(topbar.win)
self.value = value
def wrapper(v):
if convert_boolean:
self.value = False if v == 'false' else True
else:
self.value = v
func(topbar._chart)
@ -24,9 +27,12 @@ class Widget(Pane):
class TextWidget(Widget):
def __init__(self, topbar, initial_text, align):
super().__init__(topbar, value=initial_text)
self.run_script(f'{self.id} = {topbar.id}.makeTextBoxWidget("{initial_text}", "{align}")')
def __init__(self, topbar, initial_text, align, func):
super().__init__(topbar, value=initial_text, func=func)
callback_name = f'"{self.id}"' if func else ''
self.run_script(f'{self.id} = {topbar.id}.makeTextBoxWidget("{initial_text}", "{align}", {callback_name})')
def set(self, string):
self.value = string
@ -54,6 +60,7 @@ class MenuWidget(Widget):
{self.id} = {topbar.id}.makeMenu({list(options)}, "{default}", {jbool(separator)}, "{self.id}", "{align}")
''')
# TODO this will probably need to be fixed
def set(self, option):
if option not in self.options:
raise ValueError(f"Option {option} not in menu options ({self.options})")
@ -63,15 +70,19 @@ class MenuWidget(Widget):
''')
self.win.handlers[self.id](option)
def update_items(self, *items: str):
self.options = list(items)
self.run_script(f'{self.id}.updateMenuItems({self.options})')
class ButtonWidget(Widget):
def __init__(self, topbar, button, separator, align, func):
super().__init__(topbar, value=button, func=func)
def __init__(self, topbar, button, separator, align, toggle, func):
super().__init__(topbar, value=False, func=func, convert_boolean=toggle)
self.run_script(
f'{self.id} = {topbar.id}.makeButton("{button}", "{self.id}", {jbool(separator)}, true, "{align}")')
f'{self.id} = {topbar.id}.makeButton("{button}", "{self.id}", {jbool(separator)}, true, "{align}", {jbool(toggle)})')
def set(self, string):
self.value = string
# self.value = string
self.run_script(f'{self.id}.elem.innerText = "{string}"')
@ -85,10 +96,8 @@ class TopBar(Pane):
def _create(self):
if self._created:
return
from lightweight_charts.abstract import JS
self._created = True
self.run_script(JS['callback'])
self.run_script(f'{self.id} = new TopBar({self._chart.id})')
self.run_script(f'{self.id} = {self._chart.id}.createTopBar()')
def __getitem__(self, item):
if widget := self._widgets.get(item):
@ -109,11 +118,11 @@ class TopBar(Pane):
self._widgets[name] = MenuWidget(self, options, default if default else options[0], separator, align, func)
def textbox(self, name: str, initial_text: str = '',
align: ALIGN = 'left'):
self._create()
self._widgets[name] = TextWidget(self, initial_text, align)
def button(self, name, button_text: str, separator: bool = True,
align: ALIGN = 'left', func: callable = None):
self._create()
self._widgets[name] = ButtonWidget(self, button_text, separator, align, func)
self._widgets[name] = TextWidget(self, initial_text, align, func)
def button(self, name, button_text: str, separator: bool = True,
align: ALIGN = 'left', toggle: bool = False, func: callable = None):
self._create()
self._widgets[name] = ButtonWidget(self, button_text, separator, align, toggle, func)

View File

@ -3,6 +3,7 @@ import json
from datetime import datetime
from random import choices
from typing import Literal, Union
from numpy import isin
import pandas as pd
@ -11,6 +12,7 @@ class Pane:
from lightweight_charts import Window
self.win: Window = window
self.run_script = window.run_script
self.bulk_run = window.bulk_run
if hasattr(self, 'id'):
return
self.id = Window._id_gen.generate()
@ -19,7 +21,7 @@ class Pane:
class IDGen(list):
ascii = 'abcdefghijklmnopqrstuvwxyz'
def generate(self):
def generate(self) -> str:
var = ''.join(choices(self.ascii, k=8))
if var not in self:
self.append(var)
@ -37,13 +39,28 @@ def parse_event_message(window, string):
def js_data(data: Union[pd.DataFrame, pd.Series]):
if isinstance(data, pd.DataFrame):
d = data.to_dict(orient='records')
filtered_records = [{k: v for k, v in record.items() if v is not None} for record in d]
filtered_records = [{k: v for k, v in record.items() if v is not None and not pd.isna(v)} for record in d]
else:
d = data.to_dict()
filtered_records = {k: v for k, v in d.items()}
return json.dumps(filtered_records, indent=2)
def snake_to_camel(s: str):
components = s.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
def js_json(d: dict):
filtered_dict = {}
for key, val in d.items():
if key in ('self') or val in (None,):
continue
if '_' in key:
key = snake_to_camel(key)
filtered_dict[key] = val
return f"JSON.parse('{json.dumps(filtered_dict)}')"
def jbool(b: bool): return 'true' if b is True else 'false' if b is False else None
@ -53,32 +70,27 @@ MARKER_POSITION = Literal['above', 'below', 'inside']
MARKER_SHAPE = Literal['arrow_up', 'arrow_down', 'circle', 'square']
CROSSHAIR_MODE = Literal['normal', 'magnet']
CROSSHAIR_MODE = Literal['normal', 'magnet', 'hidden']
PRICE_SCALE_MODE = Literal['normal', 'logarithmic', 'percentage', 'index100']
TIME = Union[datetime, pd.Timestamp, str]
TIME = Union[datetime, pd.Timestamp, str, float]
NUM = Union[float, int]
FLOAT = Literal['left', 'right', 'top', 'bottom']
def line_style(line: LINE_STYLE):
js = 'LightweightCharts.LineStyle.'
return js+line[:line.index('_')].title() + line[line.index('_') + 1:].title() if '_' in line else js+line.title()
def crosshair_mode(mode: CROSSHAIR_MODE):
return f'LightweightCharts.CrosshairMode.{mode.title()}' if mode else None
def price_scale_mode(mode: PRICE_SCALE_MODE):
return f"LightweightCharts.PriceScaleMode.{'IndexedTo100' if mode == 'index100' else mode.title() if mode else None}"
def as_enum(value, string_types):
types = string_types.__args__
return -1 if value not in types else types.index(value)
def marker_shape(shape: MARKER_SHAPE):
return shape[:shape.index('_')]+shape[shape.index('_')+1:].title() if '_' in shape else shape
return {
'arrow_up': 'arrowUp',
'arrow_down': 'arrowDown',
}.get(shape) or shape
def marker_position(p: MARKER_POSITION):
@ -86,8 +98,7 @@ def marker_position(p: MARKER_POSITION):
'above': 'aboveBar',
'below': 'belowBar',
'inside': 'inBar',
None: None,
}[p]
}.get(p)
class Emitter:
@ -127,27 +138,54 @@ class JSEmitter:
class Events:
def __init__(self, chart):
self.new_bar = Emitter()
from lightweight_charts.abstract import JS
self.search = JSEmitter(chart, f'search{chart.id}',
lambda o: chart.run_script(f'''
{JS['callback']}
makeSpinner({chart.id})
{chart.id}.search = makeSearchBox({chart.id})
Lib.Handler.makeSpinner({chart.id})
{chart.id}.search = Lib.Handler.makeSearchBox({chart.id})
''')
)
self.range_change = JSEmitter(chart, f'range_change{chart.id}',
salt = chart.id[chart.id.index('.')+1:]
self.range_change = JSEmitter(chart, f'range_change{salt}',
lambda o: chart.run_script(f'''
let checkLogicalRange = (logical) => {{
{chart.id}.chart.timeScale().unsubscribeVisibleLogicalRangeChange(checkLogicalRange)
let checkLogicalRange{salt} = (logical) => {{
{chart.id}.chart.timeScale().unsubscribeVisibleLogicalRangeChange(checkLogicalRange{salt})
let barsInfo = {chart.id}.series.barsInLogicalRange(logical)
if (barsInfo) window.callbackFunction(`range_change{chart.id}_~_${{barsInfo.barsBefore}};;;${{barsInfo.barsAfter}}`)
if (barsInfo) window.callbackFunction(`range_change{salt}_~_${{barsInfo.barsBefore}};;;${{barsInfo.barsAfter}}`)
setTimeout(() => {chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange), 50)
setTimeout(() => {chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange{salt}), 50)
}}
{chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange)
{chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange{salt})
'''),
wrapper=lambda o, c, *arg: o(c, *[float(a) for a in arg])
)
self.click = JSEmitter(chart, f'subscribe_click{salt}',
lambda o: chart.run_script(f'''
let clickHandler{salt} = (param) => {{
if (!param.point) return;
const time = {chart.id}.chart.timeScale().coordinateToTime(param.point.x)
const price = {chart.id}.series.coordinateToPrice(param.point.y);
window.callbackFunction(`subscribe_click{salt}_~_${{time}};;;${{price}}`)
}}
{chart.id}.chart.subscribeClick(clickHandler{salt})
'''),
wrapper=lambda func, c, *args: func(c, *[float(a) for a in args])
)
class BulkRunScript:
def __init__(self, script_func):
self.enabled = False
self.scripts = []
self.script_func = script_func
def __enter__(self):
self.enabled = True
def __exit__(self, *args):
self.enabled = False
self.script_func('\n'.join(self.scripts))
self.scripts = []
def add_script(self, script):
self.scripts.append(script)

View File

@ -10,19 +10,26 @@ except ImportError:
wx = None
try:
using_py6 = False
using_pyside6 = False
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtCore import QObject, pyqtSlot as Slot
from PyQt5.QtCore import QObject, pyqtSlot as Slot, QUrl, QTimer
except ImportError:
using_py6 = True
using_pyside6 = True
try:
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtCore import Qt, QObject, Slot
from PySide6.QtCore import Qt, QObject, Slot, QUrl, QTimer
except ImportError:
try:
using_pyside6 = False
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtCore import QObject, pyqtSlot as Slot, QUrl, QTimer
except ImportError:
QWebEngineView = None
if QWebEngineView:
class Bridge(QObject):
def __init__(self, chart):
@ -61,12 +68,13 @@ class WxChart(abstract.AbstractChart):
inner_width, inner_height, scale_candles_only, toolbox)
self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(500, self.win.on_js_load))
self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: emit_callback(self, e.GetString()))
self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: emit_callback(self.win, e.GetString()))
self.webview.AddScriptMessageHandler('wx_msg')
self.webview.SetPage(abstract.TEMPLATE, '')
self.webview.AddUserScript(abstract.JS['toolbox']) if toolbox else None
def get_webview(self): return self.webview
self.webview.LoadURL("file://"+abstract.INDEX)
def get_webview(self):
return self.webview
class QtChart(abstract.AbstractChart):
@ -82,21 +90,25 @@ class QtChart(abstract.AbstractChart):
self.bridge = Bridge(self)
self.web_channel.registerObject('bridge', self.bridge)
self.webview.page().setWebChannel(self.web_channel)
self.webview.loadFinished.connect(self.win.on_js_load)
if using_py6:
self.webview.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self._html = f'''
{abstract.TEMPLATE[:85]}
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script>
var bridge = new QWebChannel(qt.webChannelTransport, function(channel) {{
var pythonObject = channel.objects.bridge;
self.webview.loadFinished.connect(lambda: self.webview.page().runJavaScript('''
let scriptElement = document.createElement("script")
scriptElement.src = 'qrc:///qtwebchannel/qwebchannel.js'
scriptElement.onload = function() {
var bridge = new QWebChannel(qt.webChannelTransport, function(channel) {
var pythonObject = channel.objects.bridge
window.pythonObject = pythonObject
}});
</script>
{abstract.TEMPLATE[85:]}
'''
self.webview.page().setHtml(self._html)
})
}
document.head.appendChild(scriptElement)
'''))
self.webview.loadFinished.connect(lambda: QTimer.singleShot(200, self.win.on_js_load))
if using_pyside6:
self.webview.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self.webview.load(QUrl.fromLocalFile(abstract.INDEX))
def get_webview(self): return self.webview
@ -104,7 +116,21 @@ class QtChart(abstract.AbstractChart):
class StaticLWC(abstract.AbstractChart):
def __init__(self, width=None, height=None, inner_width=1, inner_height=1,
scale_candles_only: bool = False, toolbox=False, autosize=True):
self._html = abstract.TEMPLATE.replace('</script>\n</body>\n</html>', '')
with open(abstract.INDEX.replace("test.html", 'styles.css'), 'r') as f:
css = f.read()
with open(abstract.INDEX.replace("test.html", 'bundle.js'), 'r') as f:
js = f.read()
with open(abstract.INDEX.replace("test.html", 'lightweight-charts.js'), 'r') as f:
lwc = f.read()
with open(abstract.INDEX, 'r') as f:
self._html = f.read() \
.replace('<link rel="stylesheet" href="styles.css">', f"<style>{css}</style>") \
.replace(' src="./lightweight-charts.js">', f'>{lwc}') \
.replace(' src="./bundle.js">', f'>{js}') \
.replace('</body>\n</html>', '<script>')
super().__init__(abstract.Window(run_script=self.run_script), inner_width, inner_height,
scale_candles_only, toolbox, autosize)
self.width = width
@ -146,10 +172,10 @@ class JupyterChart(StaticLWC):
var element = document.getElementsByClassName("tv-lightweight-charts")[i];
element.style.overflow = "visible"
}}
document.getElementById('wrapper').style.overflow = 'hidden'
document.getElementById('wrapper').style.borderRadius = '10px'
document.getElementById('wrapper').style.width = '{self.width}px'
document.getElementById('wrapper').style.height = '100%'
document.getElementById('container').style.overflow = 'hidden'
document.getElementById('container').style.borderRadius = '10px'
document.getElementById('container').style.width = '{self.width}px'
document.getElementById('container').style.height = '100%'
''')
self.run_script(f'{self.id}.chart.resize({width}, {height})')

737
package-lock.json generated Normal file
View File

@ -0,0 +1,737 @@
{
"name": "lwc-plugin-trend-line",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lwc-plugin-trend-line",
"dependencies": {
"dts-bundle-generator": "^8.0.1",
"fancy-canvas": "^2.1.0",
"lightweight-charts": "^4.1.0-rc2",
"tslib": "^2.6.2"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"rollup": "^4.13.0",
"typescript": "^5.4.3",
"vite": "^4.3.1"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.18.20",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@rollup/plugin-terser": {
"version": "0.4.4",
"dev": true,
"license": "MIT",
"dependencies": {
"serialize-javascript": "^6.0.1",
"smob": "^1.0.0",
"terser": "^5.17.4"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-typescript": {
"version": "11.1.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.14.0||^3.0.0||^4.0.0",
"tslib": "*",
"typescript": ">=3.7.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
},
"tslib": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.13.0",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@types/estree": {
"version": "1.0.5",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.11.3",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"dev": true,
"license": "MIT"
},
"node_modules/cliui": {
"version": "8.0.1",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"dev": true,
"license": "MIT"
},
"node_modules/dts-bundle-generator": {
"version": "8.1.2",
"license": "MIT",
"dependencies": {
"typescript": ">=5.0.2",
"yargs": "^17.6.0"
},
"bin": {
"dts-bundle-generator": "dist/bin/dts-bundle-generator.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.18.20",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.18.20",
"@esbuild/android-arm64": "0.18.20",
"@esbuild/android-x64": "0.18.20",
"@esbuild/darwin-arm64": "0.18.20",
"@esbuild/darwin-x64": "0.18.20",
"@esbuild/freebsd-arm64": "0.18.20",
"@esbuild/freebsd-x64": "0.18.20",
"@esbuild/linux-arm": "0.18.20",
"@esbuild/linux-arm64": "0.18.20",
"@esbuild/linux-ia32": "0.18.20",
"@esbuild/linux-loong64": "0.18.20",
"@esbuild/linux-mips64el": "0.18.20",
"@esbuild/linux-ppc64": "0.18.20",
"@esbuild/linux-riscv64": "0.18.20",
"@esbuild/linux-s390x": "0.18.20",
"@esbuild/linux-x64": "0.18.20",
"@esbuild/netbsd-x64": "0.18.20",
"@esbuild/openbsd-x64": "0.18.20",
"@esbuild/sunos-x64": "0.18.20",
"@esbuild/win32-arm64": "0.18.20",
"@esbuild/win32-ia32": "0.18.20",
"@esbuild/win32-x64": "0.18.20"
}
},
"node_modules/escalade": {
"version": "3.1.2",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/fancy-canvas": {
"version": "2.1.0",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.13.1",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lightweight-charts": {
"version": "4.1.3",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.0.0",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.4.37",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.8",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": {
"version": "4.13.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.5"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.13.0",
"@rollup/rollup-android-arm64": "4.13.0",
"@rollup/rollup-darwin-arm64": "4.13.0",
"@rollup/rollup-darwin-x64": "4.13.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.13.0",
"@rollup/rollup-linux-arm64-gnu": "4.13.0",
"@rollup/rollup-linux-arm64-musl": "4.13.0",
"@rollup/rollup-linux-riscv64-gnu": "4.13.0",
"@rollup/rollup-linux-x64-gnu": "4.13.0",
"@rollup/rollup-linux-x64-musl": "4.13.0",
"@rollup/rollup-win32-arm64-msvc": "4.13.0",
"@rollup/rollup-win32-ia32-msvc": "4.13.0",
"@rollup/rollup-win32-x64-msvc": "4.13.0",
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/smob": {
"version": "1.4.1",
"dev": true,
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/terser": {
"version": "5.29.2",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.4.3",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "4.5.2",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
"rollup": "^3.27.1"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@types/node": ">= 14",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vite/node_modules/rollup": {
"version": "3.29.4",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=14.18.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "lwc-plugin-trend-line",
"type": "module",
"scripts": {
"dev": "vite --config src/vite.config.js",
"build": "./build.sh"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"rollup": "^4.13.0",
"typescript": "^5.4.3",
"vite": "^4.3.1"
},
"dependencies": {
"dts-bundle-generator": "^8.0.1",
"fancy-canvas": "^2.1.0",
"lightweight-charts": "^4.1.0-rc2",
"tslib": "^2.6.2"
}
}

21
rollup.config.js Normal file
View File

@ -0,0 +1,21 @@
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
export default [
{
input: 'src/index.ts',
output: {
file: 'dist/bundle.js',
format: 'iife',
name: 'Lib',
globals: {
'lightweight-charts': 'LightweightCharts'
},
},
external: ['lightweight-charts'],
plugins: [
typescript(),
terser(),
],
},
];

View File

@ -5,15 +5,15 @@ with open('README.md', 'r', encoding='utf-8') as f:
setup(
name='lightweight_charts',
version='1.0.21',
version='2.0',
packages=find_packages(),
python_requires='>=3.8',
install_requires=[
'pandas',
'pywebview==4.4.1',
'pywebview>=5.0.5',
],
package_data={
'lightweight_charts': ['js/*.js'],
'lightweight_charts': ['js/*'],
},
author='louisnw',
license='MIT',

153
src/box/box.ts Normal file
View File

@ -0,0 +1,153 @@
import {
MouseEventParams,
} from 'lightweight-charts';
import { Point } from '../drawing/data-source';
import { InteractionState } from '../drawing/drawing';
import { DrawingOptions, defaultOptions } from '../drawing/options';
import { BoxPaneView } from './pane-view';
import { TwoPointDrawing } from '../drawing/two-point-drawing';
export interface BoxOptions extends DrawingOptions {
fillEnabled: boolean;
fillColor: string;
}
const defaultBoxOptions = {
fillEnabled: true,
fillColor: 'rgba(255, 255, 255, 0.2)',
...defaultOptions
}
export class Box extends TwoPointDrawing {
_type = "Box";
constructor(
p1: Point,
p2: Point,
options?: Partial<BoxOptions>
) {
super(p1, p2, options);
this._options = {
...defaultBoxOptions,
...options,
}
this._paneViews = [new BoxPaneView(this)];
}
// autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
// const p1Index = this._pointIndex(this._p1);
// const p2Index = this._pointIndex(this._p2);
// if (p1Index === null || p2Index === null) return null;
// if (endTimePoint < p1Index || startTimePoint > p2Index) return null;
// return {
// priceRange: {
// minValue: this._minPrice,
// maxValue: this._maxPrice,
// },
// };
// }
_moveToState(state: InteractionState) {
switch(state) {
case InteractionState.NONE:
document.body.style.cursor = "default";
this._hovered = false;
this._unsubscribe("mousedown", this._handleMouseDownInteraction);
break;
case InteractionState.HOVERING:
document.body.style.cursor = "pointer";
this._hovered = true;
this._unsubscribe("mouseup", this._handleMouseUpInteraction);
this._subscribe("mousedown", this._handleMouseDownInteraction)
this.chart.applyOptions({handleScroll: true});
break;
case InteractionState.DRAGGINGP1:
case InteractionState.DRAGGINGP2:
case InteractionState.DRAGGINGP3:
case InteractionState.DRAGGINGP4:
case InteractionState.DRAGGING:
document.body.style.cursor = "grabbing";
document.body.addEventListener("mouseup", this._handleMouseUpInteraction);
this._subscribe("mouseup", this._handleMouseUpInteraction);
this.chart.applyOptions({handleScroll: false});
break;
}
this._state = state;
}
_onDrag(diff: any) {
if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP1) {
this._addDiffToPoint(this.p1, diff.logical, diff.price);
}
if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP2) {
this._addDiffToPoint(this.p2, diff.logical, diff.price);
}
if (this._state != InteractionState.DRAGGING) {
if (this._state == InteractionState.DRAGGINGP3) {
this._addDiffToPoint(this.p1, diff.logical, 0);
this._addDiffToPoint(this.p2, 0, diff.price);
}
if (this._state == InteractionState.DRAGGINGP4) {
this._addDiffToPoint(this.p1, 0, diff.price);
this._addDiffToPoint(this.p2, diff.logical, 0);
}
}
}
protected _onMouseDown() {
this._startDragPoint = null;
const hoverPoint = this._latestHoverPoint;
const p1 = this._paneViews[0]._p1;
const p2 = this._paneViews[0]._p2;
if (!p1.x || !p2.x || !p1.y || !p2.y) return this._moveToState(InteractionState.DRAGGING);
const tolerance = 10;
if (Math.abs(hoverPoint.x-p1.x) < tolerance && Math.abs(hoverPoint.y-p1.y) < tolerance) {
this._moveToState(InteractionState.DRAGGINGP1)
}
else if (Math.abs(hoverPoint.x-p2.x) < tolerance && Math.abs(hoverPoint.y-p2.y) < tolerance) {
this._moveToState(InteractionState.DRAGGINGP2)
}
else if (Math.abs(hoverPoint.x-p1.x) < tolerance && Math.abs(hoverPoint.y-p2.y) < tolerance) {
this._moveToState(InteractionState.DRAGGINGP3)
}
else if (Math.abs(hoverPoint.x-p2.x) < tolerance && Math.abs(hoverPoint.y-p1.y) < tolerance) {
this._moveToState(InteractionState.DRAGGINGP4)
}
else {
this._moveToState(InteractionState.DRAGGING);
}
}
protected _mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) {
if (!param.point) return false;
const x1 = this._paneViews[0]._p1.x;
const y1 = this._paneViews[0]._p1.y;
const x2 = this._paneViews[0]._p2.x;
const y2 = this._paneViews[0]._p2.y;
if (!x1 || !x2 || !y1 || !y2 ) return false;
const mouseX = param.point.x;
const mouseY = param.point.y;
const mainX = Math.min(x1, x2);
const mainY = Math.min(y1, y2);
const width = Math.abs(x1-x2);
const height = Math.abs(y1-y2);
const halfTolerance = tolerance/2;
return mouseX > mainX-halfTolerance && mouseX < mainX+width+halfTolerance &&
mouseY > mainY-halfTolerance && mouseY < mainY+height+halfTolerance;
}
}

44
src/box/pane-renderer.ts Normal file
View File

@ -0,0 +1,44 @@
import { ViewPoint } from "../drawing/pane-view";
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { TwoPointDrawingPaneRenderer } from "../drawing/pane-renderer";
import { BoxOptions } from "./box";
import { setLineStyle } from "../helpers/canvas-rendering";
export class BoxPaneRenderer extends TwoPointDrawingPaneRenderer {
declare _options: BoxOptions;
constructor(p1: ViewPoint, p2: ViewPoint, options: BoxOptions, showCircles: boolean) {
super(p1, p2, options, showCircles)
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
const ctx = scope.context;
const scaled = this._getScaledCoordinates(scope);
if (!scaled) return;
ctx.lineWidth = this._options.width;
ctx.strokeStyle = this._options.lineColor;
setLineStyle(ctx, this._options.lineStyle)
ctx.fillStyle = this._options.fillColor;
const mainX = Math.min(scaled.x1, scaled.x2);
const mainY = Math.min(scaled.y1, scaled.y2);
const width = Math.abs(scaled.x1-scaled.x2);
const height = Math.abs(scaled.y1-scaled.y2);
ctx.strokeRect(mainX, mainY, width, height);
ctx.fillRect(mainX, mainY, width, height);
if (!this._hovered) return;
this._drawEndCircle(scope, mainX, mainY);
this._drawEndCircle(scope, mainX+width, mainY);
this._drawEndCircle(scope, mainX+width, mainY+height);
this._drawEndCircle(scope, mainX, mainY+height);
});
}
}

18
src/box/pane-view.ts Normal file
View File

@ -0,0 +1,18 @@
import { Box, BoxOptions } from './box';
import { BoxPaneRenderer } from './pane-renderer';
import { TwoPointDrawingPaneView } from '../drawing/pane-view';
export class BoxPaneView extends TwoPointDrawingPaneView {
constructor(source: Box) {
super(source)
}
renderer() {
return new BoxPaneRenderer(
this._p1,
this._p2,
this._source._options as BoxOptions,
this._source.hovered,
);
}
}

View File

@ -0,0 +1,144 @@
import { Drawing } from "../drawing/drawing";
import { DrawingOptions } from "../drawing/options";
import { GlobalParams } from "../general/global-params";
declare const window: GlobalParams;
export class ColorPicker {
private static readonly colors = [
'#EBB0B0','#E9CEA1','#E5DF80','#ADEB97','#A3C3EA','#D8BDED',
'#E15F5D','#E1B45F','#E2D947','#4BE940','#639AE1','#D7A0E8',
'#E42C2A','#E49D30','#E7D827','#3CFF0A','#3275E4','#B06CE3',
'#F3000D','#EE9A14','#F1DA13','#2DFC0F','#1562EE','#BB00EF',
'#B50911','#E3860E','#D2BD11','#48DE0E','#1455B4','#6E009F',
'#7C1713','#B76B12','#8D7A13','#479C12','#165579','#51007E',
]
public _div: HTMLDivElement;
private saveDrawings: Function;
private opacity: number = 0;
private _opacitySlider: HTMLInputElement;
private _opacityLabel: HTMLDivElement;
private rgba: number[] | undefined;
constructor(saveDrawings: Function,
private colorOption: keyof DrawingOptions,
) {
this.saveDrawings = saveDrawings
this._div = document.createElement('div');
this._div.classList.add('color-picker');
let colorPicker = document.createElement('div')
colorPicker.style.margin = '10px'
colorPicker.style.display = 'flex'
colorPicker.style.flexWrap = 'wrap'
ColorPicker.colors.forEach((color) => colorPicker.appendChild(this.makeColorBox(color)))
let separator = document.createElement('div')
separator.style.backgroundColor = window.pane.borderColor
separator.style.height = '1px'
separator.style.width = '130px'
let opacity = document.createElement('div')
opacity.style.margin = '10px'
let opacityText = document.createElement('div')
opacityText.style.color = 'lightgray'
opacityText.style.fontSize = '12px'
opacityText.innerText = 'Opacity'
this._opacityLabel = document.createElement('div')
this._opacityLabel.style.color = 'lightgray'
this._opacityLabel.style.fontSize = '12px'
this._opacitySlider = document.createElement('input')
this._opacitySlider.type = 'range'
this._opacitySlider.value = (this.opacity*100).toString();
this._opacityLabel.innerText = this._opacitySlider.value+'%'
this._opacitySlider.oninput = () => {
this._opacityLabel.innerText = this._opacitySlider.value+'%'
this.opacity = parseInt(this._opacitySlider.value)/100
this.updateColor()
}
opacity.appendChild(opacityText)
opacity.appendChild(this._opacitySlider)
opacity.appendChild(this._opacityLabel)
this._div.appendChild(colorPicker)
this._div.appendChild(separator)
this._div.appendChild(opacity)
window.containerDiv.appendChild(this._div)
}
private _updateOpacitySlider() {
this._opacitySlider.value = (this.opacity*100).toString();
this._opacityLabel.innerText = this._opacitySlider.value+'%';
}
makeColorBox(color: string) {
const box = document.createElement('div')
box.style.width = '18px'
box.style.height = '18px'
box.style.borderRadius = '3px'
box.style.margin = '3px'
box.style.boxSizing = 'border-box'
box.style.backgroundColor = color
box.addEventListener('mouseover', () => box.style.border = '2px solid lightgray')
box.addEventListener('mouseout', () => box.style.border = 'none')
const rgba = ColorPicker.extractRGBA(color)
box.addEventListener('click', () => {
this.rgba = rgba;
this.updateColor();
})
return box
}
private static extractRGBA(anyColor: string) {
const dummyElem = document.createElement('div');
dummyElem.style.color = anyColor;
document.body.appendChild(dummyElem);
const computedColor = getComputedStyle(dummyElem).color;
document.body.removeChild(dummyElem);
const rgb = computedColor.match(/\d+/g)?.map(Number);
if (!rgb) return [];
let isRgba = computedColor.includes('rgba');
let opacity = isRgba ? parseFloat(computedColor.split(',')[3]) : 1
return [rgb[0], rgb[1], rgb[2], opacity]
}
updateColor() {
if (!Drawing.lastHoveredObject || !this.rgba) return;
const oColor = `rgba(${this.rgba[0]}, ${this.rgba[1]}, ${this.rgba[2]}, ${this.opacity})`
Drawing.lastHoveredObject.applyOptions({[this.colorOption]: oColor})
this.saveDrawings()
}
openMenu(rect: DOMRect) {
if (!Drawing.lastHoveredObject) return;
this.rgba = ColorPicker.extractRGBA(
Drawing.lastHoveredObject._options[this.colorOption] as string
)
this.opacity = this.rgba[3];
this._updateOpacitySlider();
this._div.style.top = (rect.top-30)+'px'
this._div.style.left = rect.right+'px'
this._div.style.display = 'flex'
setTimeout(() => document.addEventListener('mousedown', (event: MouseEvent) => {
if (!this._div.contains(event.target as Node)) {
this.closeMenu()
}
}), 10)
}
closeMenu() {
document.body.removeEventListener('click', this.closeMenu)
this._div.style.display = 'none'
}
}

View File

@ -0,0 +1,153 @@
import { Drawing } from "../drawing/drawing";
import { DrawingTool } from "../drawing/drawing-tool";
import { DrawingOptions } from "../drawing/options";
import { GlobalParams } from "../general/global-params";
import { ColorPicker } from "./color-picker";
import { StylePicker } from "./style-picker";
export function camelToTitle(inputString: string) {
const result = [];
for (const c of inputString) {
if (result.length == 0) {
result.push(c.toUpperCase());
} else if (c == c.toUpperCase()) {
result.push(' '+c);
} else result.push(c);
}
return result.join('');
}
interface Item {
elem: HTMLSpanElement;
action: Function;
closeAction: Function | null;
}
declare const window: GlobalParams;
export class ContextMenu {
private div: HTMLDivElement
private hoverItem: Item | null;
private items: HTMLElement[] = []
constructor(
private saveDrawings: Function,
private drawingTool: DrawingTool,
) {
this._onRightClick = this._onRightClick.bind(this);
this.div = document.createElement('div');
this.div.classList.add('context-menu');
document.body.appendChild(this.div);
this.hoverItem = null;
document.body.addEventListener('contextmenu', this._onRightClick);
}
_handleClick = (ev: MouseEvent) => this._onClick(ev);
private _onClick(ev: MouseEvent) {
if (!ev.target) return;
if (!this.div.contains(ev.target as Node)) {
this.div.style.display = 'none';
document.body.removeEventListener('click', this._handleClick);
}
}
private _onRightClick(ev: MouseEvent) {
if (!Drawing.hoveredObject) return;
for (const item of this.items) {
this.div.removeChild(item);
}
this.items = [];
for (const optionName of Object.keys(Drawing.hoveredObject._options)) {
let subMenu;
if (optionName.toLowerCase().includes('color')) {
subMenu = new ColorPicker(this.saveDrawings, optionName as keyof DrawingOptions);
} else if (optionName === 'lineStyle') {
subMenu = new StylePicker(this.saveDrawings)
} else continue;
let onClick = (rect: DOMRect) => subMenu.openMenu(rect)
this.menuItem(camelToTitle(optionName), onClick, () => {
document.removeEventListener('click', subMenu.closeMenu)
subMenu._div.style.display = 'none'
})
}
let onClickDelete = () => this.drawingTool.delete(Drawing.lastHoveredObject);
this.separator()
this.menuItem('Delete Drawing', onClickDelete)
// const colorPicker = new ColorPicker(this.saveDrawings)
// const stylePicker = new StylePicker(this.saveDrawings)
// let onClickDelete = () => this._drawingTool.delete(Drawing.lastHoveredObject);
// let onClickColor = (rect: DOMRect) => colorPicker.openMenu(rect)
// let onClickStyle = (rect: DOMRect) => stylePicker.openMenu(rect)
// contextMenu.menuItem('Color Picker', onClickColor, () => {
// document.removeEventListener('click', colorPicker.closeMenu)
// colorPicker._div.style.display = 'none'
// })
// contextMenu.menuItem('Style', onClickStyle, () => {
// document.removeEventListener('click', stylePicker.closeMenu)
// stylePicker._div.style.display = 'none'
// })
// contextMenu.separator()
// contextMenu.menuItem('Delete Drawing', onClickDelete)
ev.preventDefault();
this.div.style.left = ev.clientX + 'px';
this.div.style.top = ev.clientY + 'px';
this.div.style.display = 'block';
document.body.addEventListener('click', this._handleClick);
}
public menuItem(text: string, action: Function, hover: Function | null = null) {
const item = document.createElement('span');
item.classList.add('context-menu-item');
this.div.appendChild(item);
const elem = document.createElement('span');
elem.innerText = text;
elem.style.pointerEvents = 'none';
item.appendChild(elem);
if (hover) {
let arrow = document.createElement('span')
arrow.innerText = ``
arrow.style.fontSize = '8px'
arrow.style.pointerEvents = 'none'
item.appendChild(arrow)
}
item.addEventListener('mouseover', () => {
if (this.hoverItem && this.hoverItem.closeAction) this.hoverItem.closeAction()
this.hoverItem = {elem: elem, action: action, closeAction: hover}
})
if (!hover) item.addEventListener('click', (event) => {action(event); this.div.style.display = 'none'})
else {
let timeout: number;
item.addEventListener('mouseover', () => timeout = setTimeout(() => action(item.getBoundingClientRect()), 100))
item.addEventListener('mouseout', () => clearTimeout(timeout))
}
this.items.push(item);
}
public separator() {
const separator = document.createElement('div')
separator.style.width = '90%'
separator.style.height = '1px'
separator.style.margin = '3px 0px'
separator.style.backgroundColor = window.pane.borderColor
this.div.appendChild(separator)
this.items.push(separator);
}
}

View File

@ -0,0 +1,57 @@
import { LineStyle } from "lightweight-charts";
import { GlobalParams } from "../general/global-params";
import { Drawing } from "../drawing/drawing";
declare const window: GlobalParams;
export class StylePicker {
private static readonly _styles = [
{name: 'Solid', var: LineStyle.Solid},
{name: 'Dotted', var: LineStyle.Dotted},
{name: 'Dashed', var: LineStyle.Dashed},
{name: 'Large Dashed', var: LineStyle.LargeDashed},
{name: 'Sparse Dotted', var: LineStyle.SparseDotted},
]
public _div: HTMLDivElement;
private _saveDrawings: Function;
constructor(saveDrawings: Function) {
this._saveDrawings = saveDrawings
this._div = document.createElement('div');
this._div.classList.add('context-menu');
StylePicker._styles.forEach((style) => {
this._div.appendChild(this._makeTextBox(style.name, style.var))
})
window.containerDiv.appendChild(this._div);
}
private _makeTextBox(text: string, style: LineStyle) {
const item = document.createElement('span');
item.classList.add('context-menu-item');
item.innerText = text
item.addEventListener('click', () => {
Drawing.lastHoveredObject?.applyOptions({lineStyle: style});
this._saveDrawings();
})
return item
}
openMenu(rect: DOMRect) {
this._div.style.top = (rect.top-30)+'px'
this._div.style.left = rect.right+'px'
this._div.style.display = 'block'
setTimeout(() => document.addEventListener('mousedown', (event: MouseEvent) => {
if (!this._div.contains(event.target as Node)) {
this.closeMenu()
}
}), 10)
}
closeMenu() {
document.removeEventListener('click', this.closeMenu)
this._div.style.display = 'none'
}
}

View File

@ -0,0 +1,15 @@
import {
Logical,
Time,
} from 'lightweight-charts';
export interface Point {
time: Time | null;
logical: Logical;
price: number;
}
export interface DiffPoint {
logical: number;
price: number;
}

122
src/drawing/drawing-tool.ts Normal file
View File

@ -0,0 +1,122 @@
import {
IChartApi,
ISeriesApi,
Logical,
MouseEventParams,
SeriesType,
} from 'lightweight-charts';
import { Drawing } from './drawing';
import { HorizontalLine } from '../horizontal-line/horizontal-line';
export class DrawingTool {
private _chart: IChartApi;
private _series: ISeriesApi<SeriesType>;
private _finishDrawingCallback: Function | null = null;
private _drawings: Drawing[] = [];
private _activeDrawing: Drawing | null = null;
private _isDrawing: boolean = false;
private _drawingType: (new (...args: any[]) => Drawing) | null = null;
constructor(chart: IChartApi, series: ISeriesApi<SeriesType>, finishDrawingCallback: Function | null = null) {
this._chart = chart;
this._series = series;
this._finishDrawingCallback = finishDrawingCallback;
this._chart.subscribeClick(this._clickHandler);
this._chart.subscribeCrosshairMove(this._moveHandler);
}
private _clickHandler = (param: MouseEventParams) => this._onClick(param);
private _moveHandler = (param: MouseEventParams) => this._onMouseMove(param);
beginDrawing(DrawingType: new (...args: any[]) => Drawing) {
this._drawingType = DrawingType;
this._isDrawing = true;
}
stopDrawing() {
this._isDrawing = false;
this._activeDrawing = null;
}
get drawings() {
return this._drawings;
}
addNewDrawing(drawing: Drawing) {
this._series.attachPrimitive(drawing);
this._drawings.push(drawing);
}
delete(d: Drawing | null) {
if (d == null) return;
const idx = this._drawings.indexOf(d);
if (idx == -1) return;
this._drawings.splice(idx, 1)
d.detach();
}
clearDrawings() {
for (const d of this._drawings) d.detach();
this._drawings = [];
}
repositionOnTime() {
for (const drawing of this.drawings) {
const newPoints = []
for (const point of drawing.points) {
if (!point) {
newPoints.push(point);
continue;
}
const logical = point.time ? this._chart.timeScale()
.coordinateToLogical(
this._chart.timeScale().timeToCoordinate(point.time) || 0
) : point.logical;
newPoints.push({
time: point.time,
logical: logical as Logical,
price: point.price,
})
}
drawing.updatePoints(...newPoints);
}
}
private _onClick(param: MouseEventParams) {
if (!this._isDrawing) return;
const point = Drawing._eventToPoint(param, this._series);
if (!point) return;
if (this._activeDrawing == null) {
if (this._drawingType == null) return;
this._activeDrawing = new this._drawingType(point, point);
this._series.attachPrimitive(this._activeDrawing);
if (this._drawingType == HorizontalLine) this._onClick(param);
}
else {
this._drawings.push(this._activeDrawing);
this.stopDrawing();
if (!this._finishDrawingCallback) return;
this._finishDrawingCallback();
}
}
private _onMouseMove(param: MouseEventParams) {
if (!param) return;
for (const t of this._drawings) t._handleHoverInteraction(param);
if (!this._isDrawing || !this._activeDrawing) return;
const point = Drawing._eventToPoint(param, this._series);
if (!point) return;
this._activeDrawing.updatePoints(null, point);
// this._activeDrawing.setSecondPoint(point);
}
}

180
src/drawing/drawing.ts Normal file
View File

@ -0,0 +1,180 @@
import {
ISeriesApi,
Logical,
MouseEventParams,
SeriesType
} from 'lightweight-charts';
import { PluginBase } from '../plugin-base';
import { DiffPoint, Point } from './data-source';
import { DrawingOptions, defaultOptions } from './options';
import { DrawingPaneView } from './pane-view';
export enum InteractionState {
NONE,
HOVERING,
DRAGGING,
DRAGGINGP1,
DRAGGINGP2,
DRAGGINGP3,
DRAGGINGP4,
}
export abstract class Drawing extends PluginBase {
_paneViews: DrawingPaneView[] = [];
_options: DrawingOptions;
abstract _type: string;
protected _points: (Point|null)[] = [];
protected _state: InteractionState = InteractionState.NONE;
protected _startDragPoint: Point | null = null;
protected _latestHoverPoint: any | null = null;
protected static _mouseIsDown: boolean = false;
public static hoveredObject: Drawing | null = null;
public static lastHoveredObject: Drawing | null = null;
protected _listeners: any[] = [];
constructor(
options?: Partial<DrawingOptions>
) {
super()
this._options = {
...defaultOptions,
...options,
};
}
updateAllViews() {
this._paneViews.forEach(pw => pw.update());
}
paneViews() {
return this._paneViews;
}
applyOptions(options: Partial<DrawingOptions>) {
this._options = {
...this._options,
...options,
}
this.requestUpdate();
}
public updatePoints(...points: (Point | null)[]) {
for (let i=0; i<this.points.length; i++) {
if (points[i] == null) continue;
this.points[i] = points[i] as Point;
}
this.requestUpdate();
}
detach() {
this._options.lineColor = 'transparent';
this.requestUpdate();
this.series.detachPrimitive(this);
for (const s of this._listeners) {
document.body.removeEventListener(s.name, s.listener);
}
}
get points() {
return this._points;
}
protected _subscribe(name: keyof DocumentEventMap, listener: any) {
document.body.addEventListener(name, listener);
this._listeners.push({name: name, listener: listener});
}
protected _unsubscribe(name: keyof DocumentEventMap, callback: any) {
document.body.removeEventListener(name, callback);
const toRemove = this._listeners.find((x) => x.name === name && x.listener === callback)
this._listeners.splice(this._listeners.indexOf(toRemove), 1);
}
_handleHoverInteraction(param: MouseEventParams) {
this._latestHoverPoint = param.point;
if (Drawing._mouseIsDown) {
this._handleDragInteraction(param);
} else {
if (this._mouseIsOverDrawing(param)) {
if (this._state != InteractionState.NONE) return;
this._moveToState(InteractionState.HOVERING);
Drawing.hoveredObject = Drawing.lastHoveredObject = this;
} else {
if (this._state == InteractionState.NONE) return;
this._moveToState(InteractionState.NONE);
if (Drawing.hoveredObject === this) Drawing.hoveredObject = null;
}
}
}
public static _eventToPoint(param: MouseEventParams, series: ISeriesApi<SeriesType>) {
if (!series || !param.point || !param.logical) return null;
const barPrice = series.coordinateToPrice(param.point.y);
if (barPrice == null) return null;
return {
time: param.time || null,
logical: param.logical,
price: barPrice.valueOf(),
}
}
protected static _getDiff(p1: Point, p2: Point): DiffPoint {
const diff: DiffPoint = {
logical: p1.logical-p2.logical,
price: p1.price-p2.price,
}
return diff;
}
protected _addDiffToPoint(point: Point | null, logicalDiff: number, priceDiff: number) {
if (!point) return;
point.logical = point.logical + logicalDiff as Logical;
point.price = point.price+priceDiff;
point.time = this.series.dataByIndex(point.logical)?.time || null;
}
protected _handleMouseDownInteraction = () => {
// if (Drawing._mouseIsDown) return;
Drawing._mouseIsDown = true;
this._onMouseDown();
}
protected _handleMouseUpInteraction = () => {
// if (!Drawing._mouseIsDown) return;
Drawing._mouseIsDown = false;
this._moveToState(InteractionState.HOVERING);
}
private _handleDragInteraction(param: MouseEventParams): void {
if (this._state != InteractionState.DRAGGING &&
this._state != InteractionState.DRAGGINGP1 &&
this._state != InteractionState.DRAGGINGP2 &&
this._state != InteractionState.DRAGGINGP3 &&
this._state != InteractionState.DRAGGINGP4) {
return;
}
const mousePoint = Drawing._eventToPoint(param, this.series);
if (!mousePoint) return;
this._startDragPoint = this._startDragPoint || mousePoint;
const diff = Drawing._getDiff(mousePoint, this._startDragPoint);
this._onDrag(diff);
this.requestUpdate();
this._startDragPoint = mousePoint;
}
protected abstract _onMouseDown(): void;
protected abstract _onDrag(diff: DiffPoint): void;
protected abstract _moveToState(state: InteractionState): void;
protected abstract _mouseIsOverDrawing(param: MouseEventParams): boolean;
}

14
src/drawing/options.ts Normal file
View File

@ -0,0 +1,14 @@
import { LineStyle } from "lightweight-charts";
export interface DrawingOptions {
lineColor: string;
lineStyle: LineStyle
width: number;
}
export const defaultOptions: DrawingOptions = {
lineColor: '#1E80F0',
lineStyle: LineStyle.Solid,
width: 4,
};

View File

@ -0,0 +1,65 @@
import { ISeriesPrimitivePaneRenderer } from "lightweight-charts";
import { ViewPoint } from "./pane-view";
import { DrawingOptions } from "./options";
import { BitmapCoordinatesRenderingScope, CanvasRenderingTarget2D } from "fancy-canvas";
export abstract class DrawingPaneRenderer implements ISeriesPrimitivePaneRenderer {
_options: DrawingOptions;
constructor(options: DrawingOptions) {
this._options = options;
}
abstract draw(target: CanvasRenderingTarget2D): void;
}
export abstract class TwoPointDrawingPaneRenderer extends DrawingPaneRenderer {
_p1: ViewPoint;
_p2: ViewPoint;
protected _hovered: boolean;
constructor(p1: ViewPoint, p2: ViewPoint, options: DrawingOptions, hovered: boolean) {
super(options);
this._p1 = p1;
this._p2 = p2;
this._hovered = hovered;
}
abstract draw(target: CanvasRenderingTarget2D): void;
_getScaledCoordinates(scope: BitmapCoordinatesRenderingScope) {
if (this._p1.x === null || this._p1.y === null ||
this._p2.x === null || this._p2.y === null) return null;
return {
x1: Math.round(this._p1.x * scope.horizontalPixelRatio),
y1: Math.round(this._p1.y * scope.verticalPixelRatio),
x2: Math.round(this._p2.x * scope.horizontalPixelRatio),
y2: Math.round(this._p2.y * scope.verticalPixelRatio),
}
}
// _drawTextLabel(scope: BitmapCoordinatesRenderingScope, text: string, x: number, y: number, left: boolean) {
// scope.context.font = '24px Arial';
// scope.context.beginPath();
// const offset = 5 * scope.horizontalPixelRatio;
// const textWidth = scope.context.measureText(text);
// const leftAdjustment = left ? textWidth.width + offset * 4 : 0;
// scope.context.fillStyle = this._options.labelBackgroundColor;
// scope.context.roundRect(x + offset - leftAdjustment, y - 24, textWidth.width + offset * 2, 24 + offset, 5);
// scope.context.fill();
// scope.context.beginPath();
// scope.context.fillStyle = this._options.labelTextColor;
// scope.context.fillText(text, x + offset * 2 - leftAdjustment, y);
// }
_drawEndCircle(scope: BitmapCoordinatesRenderingScope, x: number, y: number) {
const radius = 9
scope.context.fillStyle = '#000';
scope.context.beginPath();
scope.context.arc(x, y, radius, 0, 2 * Math.PI);
scope.context.stroke();
scope.context.fill();
// scope.context.strokeStyle = this._options.lineColor;
}
}

53
src/drawing/pane-view.ts Normal file
View File

@ -0,0 +1,53 @@
import { Coordinate, ISeriesPrimitivePaneView } from 'lightweight-charts';
import { Drawing } from './drawing';
import { Point } from './data-source';
import { DrawingPaneRenderer } from './pane-renderer';
import { TwoPointDrawing } from './two-point-drawing';
export abstract class DrawingPaneView implements ISeriesPrimitivePaneView {
_source: Drawing;
constructor(source: Drawing) {
this._source = source;
}
abstract update(): void;
abstract renderer(): DrawingPaneRenderer;
}
export interface ViewPoint {
x: Coordinate | null;
y: Coordinate | null;
}
export abstract class TwoPointDrawingPaneView extends DrawingPaneView {
_p1: ViewPoint = { x: null, y: null };
_p2: ViewPoint = { x: null, y: null };
_source: TwoPointDrawing;
constructor(source: TwoPointDrawing) {
super(source);
this._source = source;
}
update() {
if (!this._source.p1 || !this._source.p2) return;
const series = this._source.series;
const y1 = series.priceToCoordinate(this._source.p1.price);
const y2 = series.priceToCoordinate(this._source.p2.price);
const x1 = this._getX(this._source.p1);
const x2 = this._getX(this._source.p2);
this._p1 = { x: x1, y: y1 };
this._p2 = { x: x2, y: y2 };
if (!x1 || !x2 || !y1 || !y2) return;
}
abstract renderer(): DrawingPaneRenderer;
_getX(p: Point) {
const timeScale = this._source.chart.timeScale();
return timeScale.logicalToCoordinate(p.logical);
}
}

View File

@ -0,0 +1,38 @@
import { Point } from './data-source';
import { DrawingOptions, defaultOptions } from './options';
import { Drawing } from './drawing';
import { TwoPointDrawingPaneView } from './pane-view';
export abstract class TwoPointDrawing extends Drawing {
_paneViews: TwoPointDrawingPaneView[] = [];
protected _hovered: boolean = false;
constructor(
p1: Point,
p2: Point,
options?: Partial<DrawingOptions>
) {
super()
this.points.push(p1);
this.points.push(p2);
this._options = {
...defaultOptions,
...options,
};
}
setFirstPoint(point: Point) {
this.updatePoints(point);
}
setSecondPoint(point: Point) {
this.updatePoints(null, point);
}
get p1() { return this.points[0]; }
get p2() { return this.points[1]; }
get hovered() { return this._hovered; }
}

15
src/example/example.ts Normal file
View File

@ -0,0 +1,15 @@
import { generateCandleData } from '../sample-data';
import { Handler } from '../general/handler';
const handler = new Handler("sadasdas", 0.556, 0.5182, "left", true);
handler.createToolBox();
const data = generateCandleData();
if (handler.series)
handler.series.setData(data);

27
src/example/index.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Template Drawing Primitive Plugin Example</title>
<link rel="stylesheet" href="../general/styles.css">
<style>
#container {
/*margin-inline: auto;
max-width: 800px;
height: 400px;
background-color: rgba(240, 243, 250, 1);
border-radius: 5px;
overflow: hidden;*/
}
</style>
</head>
<body>
<div id="container"></div>
<script type="module" src="./example.ts"></script>
<script type="module" src="../general/handler.ts"></script>
</body>
</html>

View File

@ -0,0 +1,60 @@
export interface GlobalParams extends Window {
pane: paneStyle; // TODO shouldnt need this cause of css variables
handlerInFocus: string;
callbackFunction: Function;
containerDiv: HTMLElement;
setCursor: Function;
cursor: string;
}
interface paneStyle {
backgroundColor: string;
hoverBackgroundColor: string;
clickBackgroundColor: string;
activeBackgroundColor: string;
mutedBackgroundColor: string;
borderColor: string;
color: string;
activeColor: string;
}
export const paneStyleDefault: paneStyle = {
backgroundColor: '#0c0d0f',
hoverBackgroundColor: '#3c434c',
clickBackgroundColor: '#50565E',
activeBackgroundColor: 'rgba(0, 122, 255, 0.7)',
mutedBackgroundColor: 'rgba(0, 122, 255, 0.3)',
borderColor: '#3C434C',
color: '#d8d9db',
activeColor: '#ececed',
}
declare const window: GlobalParams;
export function globalParamInit() {
window.pane = {
...paneStyleDefault,
}
window.containerDiv = document.getElementById("container") || document.createElement('div');
window.setCursor = (type: string | undefined) => {
if (type) window.cursor = type;
document.body.style.cursor = window.cursor;
}
window.cursor = 'default';
}
export const setCursor = (type: string | undefined) => {
if (type) window.cursor = type;
document.body.style.cursor = window.cursor;
}
// export interface SeriesHandler {
// type: string;
// series: ISeriesApi<SeriesType>;
// markers: SeriesMarker<"">[],
// horizontal_lines: HorizontalLine[],
// name?: string,
// precision: number,
// }

368
src/general/handler.ts Normal file
View File

@ -0,0 +1,368 @@
import {
ColorType,
CrosshairMode,
DeepPartial,
HistogramStyleOptions,
IChartApi,
ISeriesApi,
LineStyleOptions,
LogicalRange,
LogicalRangeChangeEventHandler,
MouseEventHandler,
MouseEventParams,
SeriesOptionsCommon,
SeriesType,
Time,
createChart
} from "lightweight-charts";
import { GlobalParams, globalParamInit } from "./global-params";
import { Legend } from "./legend";
import { ToolBox } from "./toolbox";
import { TopBar } from "./topbar";
export interface Scale{
width: number,
height: number,
}
globalParamInit();
declare const window: GlobalParams;
export class Handler {
public id: string;
public commandFunctions: Function[] = [];
public wrapper: HTMLDivElement;
public div: HTMLDivElement;
public chart: IChartApi;
public scale: Scale;
public precision: number = 2;
public series: ISeriesApi<SeriesType>;
public volumeSeries: ISeriesApi<SeriesType>;
public legend: Legend;
private _topBar: TopBar | undefined;
public toolBox: ToolBox | undefined;
public spinner: HTMLDivElement | undefined;
public _seriesList: ISeriesApi<SeriesType>[] = [];
// TODO find a better solution rather than the 'position' parameter
constructor(
chartId: string,
innerWidth: number,
innerHeight: number,
position: string,
autoSize: boolean
) {
this.reSize = this.reSize.bind(this)
this.id = chartId
this.scale = {
width: innerWidth,
height: innerHeight,
}
this.wrapper = document.createElement('div')
this.wrapper.classList.add("handler");
this.wrapper.style.float = position
this.div = document.createElement('div')
this.div.style.position = 'relative'
this.wrapper.appendChild(this.div);
window.containerDiv.append(this.wrapper)
this.chart = this._createChart();
this.series = this.createCandlestickSeries();
this.volumeSeries = this.createVolumeSeries();
this.legend = new Legend(this)
document.addEventListener('keydown', (event) => {
for (let i = 0; i < this.commandFunctions.length; i++) {
if (this.commandFunctions[i](event)) break
}
})
window.handlerInFocus = this.id;
this.wrapper.addEventListener('mouseover', () => window.handlerInFocus = this.id)
this.reSize()
if (!autoSize) return
window.addEventListener('resize', () => this.reSize())
}
reSize() {
let topBarOffset = this.scale.height !== 0 ? this._topBar?._div.offsetHeight || 0 : 0
this.chart.resize(window.innerWidth * this.scale.width, (window.innerHeight * this.scale.height) - topBarOffset)
this.wrapper.style.width = `${100 * this.scale.width}%`
this.wrapper.style.height = `${100 * this.scale.height}%`
// TODO definitely a better way to do this
if (this.scale.height === 0 || this.scale.width === 0) {
// if (this.legend.div.style.display == 'flex') this.legend.div.style.display = 'none'
if (this.toolBox) {
this.toolBox.div.style.display = 'none'
}
}
else {
// this.legend.div.style.display = 'flex'
if (this.toolBox) {
this.toolBox.div.style.display = 'flex'
}
}
}
private _createChart() {
return createChart(this.div, {
width: window.innerWidth * this.scale.width,
height: window.innerHeight * this.scale.height,
layout:{
textColor: window.pane.color,
background: {
color: '#000000',
type: ColorType.Solid,
},
fontSize: 12
},
rightPriceScale: {
scaleMargins: {top: 0.3, bottom: 0.25},
},
timeScale: {timeVisible: true, secondsVisible: false},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
labelBackgroundColor: 'rgb(46, 46, 46)'
},
horzLine: {
labelBackgroundColor: 'rgb(55, 55, 55)'
}
},
grid: {
vertLines: {color: 'rgba(29, 30, 38, 5)'},
horzLines: {color: 'rgba(29, 30, 58, 5)'},
},
handleScroll: {vertTouchDrag: true},
})
}
createCandlestickSeries() {
const up = 'rgba(39, 157, 130, 100)'
const down = 'rgba(200, 97, 100, 100)'
const candleSeries = this.chart.addCandlestickSeries({
upColor: up, borderUpColor: up, wickUpColor: up,
downColor: down, borderDownColor: down, wickDownColor: down
});
candleSeries.priceScale().applyOptions({
scaleMargins: {top: 0.2, bottom: 0.2},
});
return candleSeries;
}
createVolumeSeries() {
const volumeSeries = this.chart.addHistogramSeries({
color: '#26a69a',
priceFormat: {type: 'volume'},
priceScaleId: 'volume_scale',
})
volumeSeries.priceScale().applyOptions({
scaleMargins: {top: 0.8, bottom: 0},
});
return volumeSeries;
}
createLineSeries(name: string, options: DeepPartial<LineStyleOptions & SeriesOptionsCommon>) {
const line = this.chart.addLineSeries({...options});
this._seriesList.push(line);
this.legend.makeSeriesRow(name, line)
return {
name: name,
series: line,
}
}
createHistogramSeries(name: string, options: DeepPartial<HistogramStyleOptions & SeriesOptionsCommon>) {
const line = this.chart.addHistogramSeries({...options});
this._seriesList.push(line);
this.legend.makeSeriesRow(name, line)
return {
name: name,
series: line,
}
}
createToolBox() {
this.toolBox = new ToolBox(this.id, this.chart, this.series, this.commandFunctions);
this.div.appendChild(this.toolBox.div);
}
createTopBar() {
this._topBar = new TopBar(this);
this.wrapper.prepend(this._topBar._div)
return this._topBar;
}
toJSON() {
// Exclude the chart attribute from serialization
const {chart, ...serialized} = this;
return serialized;
}
public static syncCharts(childChart:Handler, parentChart: Handler, crosshairOnly = false) {
function crosshairHandler(chart: Handler, point: any) {//point: BarData | LineData) {
if (!point) {
chart.chart.clearCrosshairPosition()
return
}
// TODO fix any point ?
chart.chart.setCrosshairPosition(point.value || point!.close, point.time, chart.series);
chart.legend.legendHandler(point, true)
}
function getPoint(series: ISeriesApi<SeriesType>, param: MouseEventParams) {
if (!param.time) return null;
return param.seriesData.get(series) || null;
}
const childTimeScale = childChart.chart.timeScale();
const parentTimeScale = parentChart.chart.timeScale();
const setChildRange = (timeRange: LogicalRange | null) => {
if(timeRange) childTimeScale.setVisibleLogicalRange(timeRange);
}
const setParentRange = (timeRange: LogicalRange | null) => {
if(timeRange) parentTimeScale.setVisibleLogicalRange(timeRange);
}
const setParentCrosshair = (param: MouseEventParams) => {
crosshairHandler(parentChart, getPoint(childChart.series, param))
}
const setChildCrosshair = (param: MouseEventParams) => {
crosshairHandler(childChart, getPoint(parentChart.series, param))
}
let selected = parentChart
function addMouseOverListener(
thisChart: Handler,
otherChart: Handler,
thisCrosshair: MouseEventHandler<Time>,
otherCrosshair: MouseEventHandler<Time>,
thisRange: LogicalRangeChangeEventHandler,
otherRange: LogicalRangeChangeEventHandler)
{
thisChart.wrapper.addEventListener('mouseover', () => {
if (selected === thisChart) return
selected = thisChart
otherChart.chart.unsubscribeCrosshairMove(thisCrosshair)
thisChart.chart.subscribeCrosshairMove(otherCrosshair)
if (crosshairOnly) return;
otherChart.chart.timeScale().unsubscribeVisibleLogicalRangeChange(thisRange)
thisChart.chart.timeScale().subscribeVisibleLogicalRangeChange(otherRange)
})
}
addMouseOverListener(
parentChart,
childChart,
setParentCrosshair,
setChildCrosshair,
setParentRange,
setChildRange
)
addMouseOverListener(
childChart,
parentChart,
setChildCrosshair,
setParentCrosshair,
setChildRange,
setParentRange
)
parentChart.chart.subscribeCrosshairMove(setChildCrosshair)
const parentRange = parentTimeScale.getVisibleLogicalRange()
if (parentRange) childTimeScale.setVisibleLogicalRange(parentRange)
if (crosshairOnly) return;
parentChart.chart.timeScale().subscribeVisibleLogicalRangeChange(setChildRange)
}
public static makeSearchBox(chart: Handler) {
const searchWindow = document.createElement('div')
searchWindow.classList.add('searchbox');
searchWindow.style.display = 'none';
const magnifyingGlass = document.createElement('div');
magnifyingGlass.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:lightgray;stroke-opacity:1;stroke-miterlimit:4;" d="M 15 15 L 21 21 M 10 17 C 6.132812 17 3 13.867188 3 10 C 3 6.132812 6.132812 3 10 3 C 13.867188 3 17 6.132812 17 10 C 17 13.867188 13.867188 17 10 17 Z M 10 17 "/></svg>`
const sBox = document.createElement('input');
sBox.type = 'text';
searchWindow.appendChild(magnifyingGlass)
searchWindow.appendChild(sBox)
chart.div.appendChild(searchWindow);
chart.commandFunctions.push((event: KeyboardEvent) => {
if (window.handlerInFocus !== chart.id) return false
if (searchWindow.style.display === 'none') {
if (/^[a-zA-Z0-9]$/.test(event.key)) {
searchWindow.style.display = 'flex';
sBox.focus();
return true
}
else return false
}
else if (event.key === 'Enter' || event.key === 'Escape') {
if (event.key === 'Enter') window.callbackFunction(`search${chart.id}_~_${sBox.value}`)
searchWindow.style.display = 'none'
sBox.value = ''
return true
}
else return false
})
sBox.addEventListener('input', () => sBox.value = sBox.value.toUpperCase())
return {
window: searchWindow,
box: sBox,
}
}
public static makeSpinner(chart: Handler) {
chart.spinner = document.createElement('div');
chart.spinner.classList.add('spinner');
chart.wrapper.appendChild(chart.spinner)
// TODO below can be css (animate)
let rotation = 0;
const speed = 10;
function animateSpinner() {
if (!chart.spinner) return;
rotation += speed
chart.spinner.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`
requestAnimationFrame(animateSpinner)
}
animateSpinner();
}
private static readonly _styleMap = {
'--bg-color': 'backgroundColor',
'--hover-bg-color': 'hoverBackgroundColor',
'--click-bg-color': 'clickBackgroundColor',
'--active-bg-color': 'activeBackgroundColor',
'--muted-bg-color': 'mutedBackgroundColor',
'--border-color': 'borderColor',
'--color': 'color',
'--active-color': 'activeColor',
}
public static setRootStyles(styles: any) {
const rootStyle = document.documentElement.style;
for (const [property, valueKey] of Object.entries(this._styleMap)) {
rootStyle.setProperty(property, styles[valueKey]);
}
}
}

9
src/general/index.ts Normal file
View File

@ -0,0 +1,9 @@
// TODO this won't be necessary with ws
export * from './handler';
export * from './global-params';
export * from './legend';
export * from './table';
export * from './toolbox';
export * from './topbar';
export * from '../horizontal-line/ray-line';

222
src/general/legend.ts Normal file
View File

@ -0,0 +1,222 @@
import { ISeriesApi, LineData, Logical, MouseEventParams, PriceFormatBuiltIn, SeriesType } from "lightweight-charts";
import { Handler } from "./handler";
interface LineElement {
name: string;
div: HTMLDivElement;
row: HTMLDivElement;
toggle: HTMLDivElement,
series: ISeriesApi<SeriesType>,
solid: string;
}
export class Legend {
private handler: Handler;
public div: HTMLDivElement;
private ohlcEnabled: boolean = false;
private percentEnabled: boolean = false;
private linesEnabled: boolean = false;
private colorBasedOnCandle: boolean = false;
private text: HTMLSpanElement;
private candle: HTMLDivElement;
public _lines: LineElement[] = [];
constructor(handler: Handler) {
this.legendHandler = this.legendHandler.bind(this)
this.handler = handler;
this.ohlcEnabled = false;
this.percentEnabled = false
this.linesEnabled = false
this.colorBasedOnCandle = false
this.div = document.createElement('div');
this.div.classList.add('legend');
this.div.style.maxWidth = `${(handler.scale.width * 100) - 8}vw`
this.div.style.display = 'none';
this.text = document.createElement('span')
this.text.style.lineHeight = '1.8'
this.candle = document.createElement('div')
this.div.appendChild(this.text)
this.div.appendChild(this.candle)
handler.div.appendChild(this.div)
// this.makeSeriesRows(handler);
handler.chart.subscribeCrosshairMove(this.legendHandler)
}
toJSON() {
// Exclude the chart attribute from serialization
const {_lines, handler, ...serialized} = this;
return serialized;
}
// makeSeriesRows(handler: Handler) {
// if (this.linesEnabled) handler._seriesList.forEach(s => this.makeSeriesRow(s))
// }
makeSeriesRow(name: string, series: ISeriesApi<SeriesType>) {
const strokeColor = '#FFF';
let openEye = `
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${strokeColor};stroke-opacity:1;stroke-miterlimit:4;" d="M 21.998437 12 C 21.998437 12 18.998437 18 12 18 C 5.001562 18 2.001562 12 2.001562 12 C 2.001562 12 5.001562 6 12 6 C 18.998437 6 21.998437 12 21.998437 12 Z M 21.998437 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${strokeColor};stroke-opacity:1;stroke-miterlimit:4;" d="M 15 12 C 15 13.654687 13.654687 15 12 15 C 10.345312 15 9 13.654687 9 12 C 9 10.345312 10.345312 9 12 9 C 13.654687 9 15 10.345312 15 12 Z M 15 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>\`
`
let closedEye = `
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${strokeColor};stroke-opacity:1;stroke-miterlimit:4;" d="M 20.001562 9 C 20.001562 9 19.678125 9.665625 18.998437 10.514062 M 12 14.001562 C 10.392187 14.001562 9.046875 13.589062 7.95 12.998437 M 12 14.001562 C 13.607812 14.001562 14.953125 13.589062 16.05 12.998437 M 12 14.001562 L 12 17.498437 M 3.998437 9 C 3.998437 9 4.354687 9.735937 5.104687 10.645312 M 7.95 12.998437 L 5.001562 15.998437 M 7.95 12.998437 C 6.689062 12.328125 5.751562 11.423437 5.104687 10.645312 M 16.05 12.998437 L 18.501562 15.998437 M 16.05 12.998437 C 17.38125 12.290625 18.351562 11.320312 18.998437 10.514062 M 5.104687 10.645312 L 2.001562 12 M 18.998437 10.514062 L 21.998437 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
`
let row = document.createElement('div')
row.style.display = 'flex'
row.style.alignItems = 'center'
let div = document.createElement('div')
let toggle = document.createElement('div')
toggle.classList.add('legend-toggle-switch');
let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "22");
svg.setAttribute("height", "16");
let group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = openEye
let on = true
toggle.addEventListener('click', () => {
if (on) {
on = false
group.innerHTML = closedEye
series.applyOptions({
visible: false
})
} else {
on = true
series.applyOptions({
visible: true
})
group.innerHTML = openEye
}
})
svg.appendChild(group)
toggle.appendChild(svg);
row.appendChild(div)
row.appendChild(toggle)
this.div.appendChild(row)
const color = series.options().baseLineColor;
this._lines.push({
name: name,
div: div,
row: row,
toggle: toggle,
series: series,
solid: color.startsWith('rgba') ? color.replace(/[^,]+(?=\))/, '1') : color
});
}
legendItemFormat(num: number, decimal: number) { return num.toFixed(decimal).toString().padStart(8, ' ') }
shorthandFormat(num: number) {
const absNum = Math.abs(num)
if (absNum >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (absNum >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString().padStart(8, ' ');
}
legendHandler(param: MouseEventParams, usingPoint= false) {
if (!this.ohlcEnabled && !this.linesEnabled && !this.percentEnabled) return;
const options: any = this.handler.series.options()
if (!param.time) {
this.candle.style.color = 'transparent'
this.candle.innerHTML = this.candle.innerHTML.replace(options['upColor'], '').replace(options['downColor'], '')
return
}
let data: any;
let logical: Logical | null = null;
if (usingPoint) {
const timeScale = this.handler.chart.timeScale();
let coordinate = timeScale.timeToCoordinate(param.time)
if (coordinate)
logical = timeScale.coordinateToLogical(coordinate.valueOf())
if (logical)
data = this.handler.series.dataByIndex(logical.valueOf())
}
else {
data = param.seriesData.get(this.handler.series);
}
this.candle.style.color = ''
let str = '<span style="line-height: 1.8;">'
if (data) {
if (this.ohlcEnabled) {
str += `O ${this.legendItemFormat(data.open, this.handler.precision)} `
str += `| H ${this.legendItemFormat(data.high, this.handler.precision)} `
str += `| L ${this.legendItemFormat(data.low, this.handler.precision)} `
str += `| C ${this.legendItemFormat(data.close, this.handler.precision)} `
}
if (this.percentEnabled) {
let percentMove = ((data.close - data.open) / data.open) * 100
let color = percentMove > 0 ? options['upColor'] : options['downColor']
let percentStr = `${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %`
if (this.colorBasedOnCandle) {
str += `| <span style="color: ${color};">${percentStr}</span>`
} else {
str += '| ' + percentStr
}
}
if (this.handler.volumeSeries) {
let volumeData: any;
if (logical) {
volumeData = this.handler.volumeSeries.dataByIndex(logical)
}
else {
volumeData = param.seriesData.get(this.handler.volumeSeries)
}
if (volumeData) {
str += this.ohlcEnabled ? `<br>V ${this.shorthandFormat(volumeData.value)}` : ''
}
}
}
this.candle.innerHTML = str + '</span>'
this._lines.forEach((e) => {
if (!this.linesEnabled) {
e.row.style.display = 'none'
return
}
e.row.style.display = 'flex'
let data
if (usingPoint && logical) {
data = e.series.dataByIndex(logical) as LineData
}
else {
data = param.seriesData.get(e.series) as LineData
}
let price;
if (e.series.seriesType() == 'Histogram') {
price = this.shorthandFormat(data.value)
} else {
const format = e.series.options().priceFormat as PriceFormatBuiltIn
price = this.legendItemFormat(data.value, format.precision) // couldn't this just be line.options().precision?
}
e.div.innerHTML = `<span style="color: ${e.solid};">▨</span> ${e.name} : ${price}`
})
}
}

59
src/general/menu.ts Normal file
View File

@ -0,0 +1,59 @@
import { GlobalParams } from "./global-params";
declare const window: GlobalParams
export class Menu {
private div: HTMLDivElement;
private isOpen: boolean = false;
private widget: any;
constructor(
private makeButton: Function,
private callbackName: string,
items: string[],
activeItem: string,
separator: boolean,
align: 'right'|'left') {
this.div = document.createElement('div')
this.div.classList.add('topbar-menu');
this.widget = this.makeButton(activeItem+' ↓', null, separator, true, align)
this.updateMenuItems(items)
this.widget.elem.addEventListener('click', () => {
this.isOpen = !this.isOpen;
if (!this.isOpen) {
this.div.style.display = 'none';
return;
}
let rect = this.widget.elem.getBoundingClientRect()
this.div.style.display = 'flex'
this.div.style.flexDirection = 'column'
let center = rect.x+(rect.width/2)
this.div.style.left = center-(this.div.clientWidth/2)+'px'
this.div.style.top = rect.y+rect.height+'px'
})
document.body.appendChild(this.div)
}
updateMenuItems(items: string[]) {
this.div.innerHTML = '';
items.forEach(text => {
let button = this.makeButton(text, null, false, false)
button.elem.addEventListener('click', () => {
this.widget.elem.innerText = button.elem.innerText+' ↓'
window.callbackFunction(`${this.callbackName}_~_${button.elem.innerText}`)
this.div.style.display = 'none'
this.isOpen = false
});
button.elem.style.margin = '4px 4px'
button.elem.style.padding = '2px 2px'
this.div.appendChild(button.elem)
})
this.widget.elem.innerText = items[0]+' ↓';
}
}

234
src/general/styles.css Normal file
View File

@ -0,0 +1,234 @@
:root {
--bg-color:#0c0d0f;
--hover-bg-color: #3c434c;
--click-bg-color: #50565E;
--active-bg-color: rgba(0, 122, 255, 0.7);
--muted-bg-color: rgba(0, 122, 255, 0.3);
--border-color: #3C434C;
--color: #d8d9db;
--active-color: #ececed;
}
body {
background-color: rgb(0,0,0);
color: rgba(19, 23, 34, 1);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
.handler {
display: flex;
flex-direction: column;
position: relative;
}
.toolbox {
position: absolute;
z-index: 2000;
display: flex;
align-items: center;
top: 25%;
border: 2px solid var(--border-color);
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background-color: rgba(25, 27, 30, 0.5);
flex-direction: column;
}
.toolbox-button {
margin: 3px;
border-radius: 4px;
display: flex;
background-color: transparent;
}
.toolbox-button:hover {
background-color: rgba(80, 86, 94, 0.7);
}
.toolbox-button:active {
background-color: rgba(90, 106, 104, 0.7);
}
.active-toolbox-button {
background-color: var(--active-bg-color) !important;
}
.active-toolbox-button g {
fill: var(--active-color);
}
.context-menu {
position: absolute;
z-index: 1000;
background: rgb(50, 50, 50);
color: var(--active-color);
display: none;
border-radius: 5px;
padding: 3px 3px;
font-size: 13px;
cursor: default;
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 10px;
margin: 1px 0px;
border-radius: 3px;
}
.context-menu-item:hover {
background-color: var(--muted-bg-color);
}
.color-picker {
max-width: 170px;
background-color: var(--bg-color);
position: absolute;
z-index: 10000;
display: none;
flex-direction: column;
align-items: center;
border: 2px solid var(--border-color);
border-radius: 8px;
cursor: default;
}
/* topbar-related */
.topbar {
background-color: var(--bg-color);
border-bottom: 2px solid var(--border-color);
display: flex;
align-items: center;
}
.topbar-container {
display: flex;
align-items: center;
flex-grow: 1;
}
.topbar-button {
border: none;
padding: 2px 5px;
margin: 4px 10px;
font-size: 13px;
border-radius: 4px;
color: var(--color);
background-color: transparent;
}
.topbar-button:hover {
background-color: var(--hover-bg-color)
}
.topbar-button:active {
background-color: var(--click-bg-color);
color: var(--active-color);
font-weight: 500;
}
.switcher-button:active {
background-color: var(--click-bg-color);
color: var(--color);
font-weight: normal;
}
.active-switcher-button {
background-color: var(--active-bg-color) !important;
color: var(--active-color) !important;
font-weight: 500;
}
.topbar-textbox {
margin: 0px 18px;
font-size: 16px;
color: var(--color);
}
.topbar-textbox-input {
background-color: var(--bg-color);
color: var(--color);
border: 1px solid var(--color);
}
.topbar-menu {
position: absolute;
display: none;
z-index: 10000;
background-color: var(--bg-color);
border-radius: 2px;
border: 2px solid var(--border-color);
border-top: none;
align-items: flex-start;
max-height: 80%;
overflow-y: auto;
}
.topbar-separator {
width: 1px;
height: 20px;
background-color: var(--border-color);
}
.searchbox {
position: absolute;
top: 0;
bottom: 200px;
left: 0;
right: 0;
margin: auto;
width: 150px;
height: 30px;
padding: 5px;
z-index: 1000;
align-items: center;
background-color: rgba(30 ,30, 30, 0.9);
border: 2px solid var(--border-color);
border-radius: 5px;
display: flex;
}
.searchbox input {
text-align: center;
width: 100px;
margin-left: 10px;
background-color: var(--muted-bg-color);
color: var(--active-color);
font-size: 20px;
border: none;
outline: none;
border-radius: 2px;
}
.spinner {
width: 30px;
height: 30px;
border: 4px solid rgba(255, 255, 255, 0.6);
border-top: 4px solid var(--active-bg-color);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
z-index: 1000;
transform: translate(-50%, -50%);
display: none;
}
.legend {
position: absolute;
z-index: 3000;
pointer-events: none;
top: 10px;
left: 10px;
display: none;
flex-direction: column;
}
.legend-toggle-switch {
border-radius: 4px;
margin-left: 10px;
pointer-events: auto;
}
.legend-toggle-switch:hover {
cursor: pointer;
background-color: rgba(50, 50, 50, 0.5);
}

199
src/general/table.ts Normal file
View File

@ -0,0 +1,199 @@
import { GlobalParams } from "./global-params";
declare const window: GlobalParams
interface RowDictionary {
[key: number]: HTMLTableRowElement;
}
export class Table {
private _div: HTMLDivElement;
private callbackName: string | null;
private borderColor: string;
private borderWidth: number;
private table: HTMLTableElement;
private rows: RowDictionary = {};
private headings: string[];
private widths: string[];
private alignments: string[];
public footer: HTMLDivElement[] | undefined;
public header: HTMLDivElement[] | undefined;
constructor(width: number, height: number, headings: string[], widths: number[], alignments: string[], position: string, draggable = false,
tableBackgroundColor: string, borderColor: string, borderWidth: number, textColors: string[], backgroundColors: string[]) {
this._div = document.createElement('div')
this.callbackName = null
this.borderColor = borderColor
this.borderWidth = borderWidth
if (draggable) {
this._div.style.position = 'absolute'
this._div.style.cursor = 'move'
} else {
this._div.style.position = 'relative'
this._div.style.float = position
}
this._div.style.zIndex = '2000'
this.reSize(width, height)
this._div.style.display = 'flex'
this._div.style.flexDirection = 'column'
// this._div.style.justifyContent = 'space-between'
this._div.style.borderRadius = '5px'
this._div.style.color = 'white'
this._div.style.fontSize = '12px'
this._div.style.fontVariantNumeric = 'tabular-nums'
this.table = document.createElement('table')
this.table.style.width = '100%'
this.table.style.borderCollapse = 'collapse'
this._div.style.overflow = 'hidden';
this.headings = headings
this.widths = widths.map((width) => `${width * 100}%`)
this.alignments = alignments
let head = this.table.createTHead()
let row = head.insertRow()
for (let i = 0; i < this.headings.length; i++) {
let th = document.createElement('th')
th.textContent = this.headings[i]
th.style.width = this.widths[i]
th.style.letterSpacing = '0.03rem'
th.style.padding = '0.2rem 0px'
th.style.fontWeight = '500'
th.style.textAlign = 'center'
if (i !== 0) th.style.borderLeft = borderWidth+'px solid '+borderColor
th.style.position = 'sticky'
th.style.top = '0'
th.style.backgroundColor = backgroundColors.length > 0 ? backgroundColors[i] : tableBackgroundColor
th.style.color = textColors[i]
row.appendChild(th)
}
let overflowWrapper = document.createElement('div')
overflowWrapper.style.overflowY = 'auto'
overflowWrapper.style.overflowX = 'hidden'
overflowWrapper.style.backgroundColor = tableBackgroundColor
overflowWrapper.appendChild(this.table)
this._div.appendChild(overflowWrapper)
window.containerDiv.appendChild(this._div)
if (!draggable) return
let offsetX: number, offsetY: number;
let onMouseDown = (event: MouseEvent) => {
offsetX = event.clientX - this._div.offsetLeft;
offsetY = event.clientY - this._div.offsetTop;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
let onMouseMove = (event: MouseEvent) => {
this._div.style.left = (event.clientX - offsetX) + 'px';
this._div.style.top = (event.clientY - offsetY) + 'px';
}
let onMouseUp = () => {
// Remove the event listeners for dragging
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
this._div.addEventListener('mousedown', onMouseDown);
}
divToButton(div: HTMLDivElement, callbackString: string) {
div.addEventListener('mouseover', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
div.addEventListener('mouseout', () => div.style.backgroundColor = 'transparent')
div.addEventListener('mousedown', () => div.style.backgroundColor = 'rgba(60, 60, 60)')
div.addEventListener('click', () => window.callbackFunction(callbackString))
div.addEventListener('mouseup', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
}
newRow(id: number, returnClickedCell=false) {
let row = this.table.insertRow()
row.style.cursor = 'default'
for (let i = 0; i < this.headings.length; i++) {
let cell = row.insertCell()
cell.style.width = this.widths[i];
cell.style.textAlign = this.alignments[i];
cell.style.border = this.borderWidth+'px solid '+this.borderColor
if (returnClickedCell) {
this.divToButton(cell, `${this.callbackName}_~_${id};;;${this.headings[i]}`)
}
}
if (!returnClickedCell) {
this.divToButton(row, `${this.callbackName}_~_${id}`)
}
this.rows[id] = row
}
deleteRow(id: number) {
this.table.deleteRow(this.rows[id].rowIndex)
delete this.rows[id]
}
clearRows() {
let numRows = Object.keys(this.rows).length
for (let i = 0; i < numRows; i++)
this.table.deleteRow(-1)
this.rows = {}
}
private _getCell(rowId: number, column: string) {
return this.rows[rowId].cells[this.headings.indexOf(column)];
}
updateCell(rowId: number, column: string, val: string) {
this._getCell(rowId, column).textContent = val;
}
styleCell(rowId: number, column: string, styleAttribute: string, value: string) {
const style = this._getCell(rowId, column).style;
(style as any)[styleAttribute] = value;
}
makeSection(id: string, type: string, numBoxes: number, func=false) {
let section = document.createElement('div')
section.style.display = 'flex'
section.style.width = '100%'
section.style.padding = '3px 0px'
section.style.backgroundColor = 'rgb(30, 30, 30)'
type === 'footer' ? this._div.appendChild(section) : this._div.prepend(section)
const textBoxes = []
for (let i = 0; i < numBoxes; i++) {
let textBox = document.createElement('div')
section.appendChild(textBox)
textBox.style.flex = '1'
textBox.style.textAlign = 'center'
if (func) {
this.divToButton(textBox, `${id}_~_${i}`)
textBox.style.borderRadius = '2px'
}
textBoxes.push(textBox)
}
if (type === 'footer') {
this.footer = textBoxes;
}
else {
this.header = textBoxes;
}
}
reSize(width: number, height: number) {
this._div.style.width = width <= 1 ? width * 100 + '%' : width + 'px'
this._div.style.height = height <= 1 ? height * 100 + '%' : height + 'px'
}
}

180
src/general/toolbox.ts Normal file
View File

@ -0,0 +1,180 @@
import { DrawingTool } from "../drawing/drawing-tool";
import { TrendLine } from "../trend-line/trend-line";
import { Box } from "../box/box";
import { Drawing } from "../drawing/drawing";
import { ContextMenu } from "../context-menu/context-menu";
import { GlobalParams } from "./global-params";
import { IChartApi, ISeriesApi, SeriesType } from "lightweight-charts";
import { HorizontalLine } from "../horizontal-line/horizontal-line";
import { RayLine } from "../horizontal-line/ray-line";
import { VerticalLine } from "../vertical-line/vertical-line";
interface Icon {
div: HTMLDivElement,
group: SVGGElement,
type: new (...args: any[]) => Drawing
}
declare const window: GlobalParams
export class ToolBox {
private static readonly TREND_SVG: string = '<rect x="3.84" y="13.67" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -5.9847 14.4482)" width="21.21" height="1.56"/><path d="M23,3.17L20.17,6L23,8.83L25.83,6L23,3.17z M23,7.41L21.59,6L23,4.59L24.41,6L23,7.41z"/><path d="M6,20.17L3.17,23L6,25.83L8.83,23L6,20.17z M6,24.41L4.59,23L6,21.59L7.41,23L6,24.41z"/>';
private static readonly HORZ_SVG: string = '<rect x="4" y="14" width="9" height="1"/><rect x="16" y="14" width="9" height="1"/><path d="M11.67,14.5l2.83,2.83l2.83-2.83l-2.83-2.83L11.67,14.5z M15.91,14.5l-1.41,1.41l-1.41-1.41l1.41-1.41L15.91,14.5z"/>';
private static readonly RAY_SVG: string = '<rect x="8" y="14" width="17" height="1"/><path d="M3.67,14.5l2.83,2.83l2.83-2.83L6.5,11.67L3.67,14.5z M7.91,14.5L6.5,15.91L5.09,14.5l1.41-1.41L7.91,14.5z"/>';
private static readonly BOX_SVG: string = '<rect x="8" y="6" width="12" height="1"/><rect x="9" y="22" width="11" height="1"/><path d="M3.67,6.5L6.5,9.33L9.33,6.5L6.5,3.67L3.67,6.5z M7.91,6.5L6.5,7.91L5.09,6.5L6.5,5.09L7.91,6.5z"/><path d="M19.67,6.5l2.83,2.83l2.83-2.83L22.5,3.67L19.67,6.5z M23.91,6.5L22.5,7.91L21.09,6.5l1.41-1.41L23.91,6.5z"/><path d="M19.67,22.5l2.83,2.83l2.83-2.83l-2.83-2.83L19.67,22.5z M23.91,22.5l-1.41,1.41l-1.41-1.41l1.41-1.41L23.91,22.5z"/><path d="M3.67,22.5l2.83,2.83l2.83-2.83L6.5,19.67L3.67,22.5z M7.91,22.5L6.5,23.91L5.09,22.5l1.41-1.41L7.91,22.5z"/><rect x="22" y="9" width="1" height="11"/><rect x="6" y="9" width="1" height="11"/>';
private static readonly VERT_SVG: string = ToolBox.RAY_SVG;
div: HTMLDivElement;
private activeIcon: Icon | null = null;
private buttons: HTMLDivElement[] = [];
private _commandFunctions: Function[];
private _handlerID: string;
private _drawingTool: DrawingTool;
constructor(handlerID: string, chart: IChartApi, series: ISeriesApi<SeriesType>, commandFunctions: Function[]) {
this._handlerID = handlerID;
this._commandFunctions = commandFunctions;
this._drawingTool = new DrawingTool(chart, series, () => this.removeActiveAndSave());
this.div = this._makeToolBox()
new ContextMenu(this.saveDrawings, this._drawingTool);
commandFunctions.push((event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') {
const drawingToDelete = this._drawingTool.drawings.pop();
if (drawingToDelete) this._drawingTool.delete(drawingToDelete)
return true;
}
return false;
});
}
toJSON() {
// Exclude the chart attribute from serialization
const { ...serialized} = this;
return serialized;
}
private _makeToolBox() {
let div = document.createElement('div')
div.classList.add('toolbox');
this.buttons.push(this._makeToolBoxElement(TrendLine, 'KeyT', ToolBox.TREND_SVG))
this.buttons.push(this._makeToolBoxElement(HorizontalLine, 'KeyH', ToolBox.HORZ_SVG));
this.buttons.push(this._makeToolBoxElement(RayLine, 'KeyR', ToolBox.RAY_SVG));
this.buttons.push(this._makeToolBoxElement(Box, 'KeyB', ToolBox.BOX_SVG));
this.buttons.push(this._makeToolBoxElement(VerticalLine, 'KeyV', ToolBox.VERT_SVG, true));
for (const button of this.buttons) {
div.appendChild(button);
}
return div
}
private _makeToolBoxElement(DrawingType: new (...args: any[]) => Drawing, keyCmd: string, paths: string, rotate=false) {
const elem = document.createElement('div')
elem.classList.add("toolbox-button");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "29");
svg.setAttribute("height", "29");
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = paths
group.setAttribute("fill", window.pane.color)
svg.appendChild(group)
elem.appendChild(svg);
const icon: Icon = {div: elem, group: group, type: DrawingType}
elem.addEventListener('click', () => this._onIconClick(icon));
this._commandFunctions.push((event: KeyboardEvent) => {
if (this._handlerID !== window.handlerInFocus) return false;
if (event.altKey && event.code === keyCmd) {
event.preventDefault()
this._onIconClick(icon);
return true
}
return false;
})
if (rotate == true) {
svg.style.transform = 'rotate(90deg)';
svg.style.transformBox = 'fill-box';
svg.style.transformOrigin = 'center';
}
return elem
}
private _onIconClick(icon: Icon) {
if (this.activeIcon) {
this.activeIcon.div.classList.remove('active-toolbox-button');
window.setCursor('crosshair');
this._drawingTool?.stopDrawing()
if (this.activeIcon === icon) {
this.activeIcon = null
return
}
}
this.activeIcon = icon
this.activeIcon.div.classList.add('active-toolbox-button')
window.setCursor('crosshair');
this._drawingTool?.beginDrawing(this.activeIcon.type);
}
removeActiveAndSave = () => {
window.setCursor('default');
if (this.activeIcon) this.activeIcon.div.classList.remove('active-toolbox-button')
this.activeIcon = null
this.saveDrawings()
}
addNewDrawing(d: Drawing) {
this._drawingTool.addNewDrawing(d);
}
clearDrawings() {
this._drawingTool.clearDrawings();
}
saveDrawings = () => {
const drawingMeta = []
for (const d of this._drawingTool.drawings) {
drawingMeta.push({
type: d._type,
points: d.points,
options: d._options
});
}
const string = JSON.stringify(drawingMeta);
window.callbackFunction(`save_drawings${this._handlerID}_~_${string}`)
}
loadDrawings(drawings: any[]) { // TODO any
drawings.forEach((d) => {
switch (d.type) {
case "Box":
this._drawingTool.addNewDrawing(new Box(d.points[0], d.points[1], d.options));
break;
case "TrendLine":
this._drawingTool.addNewDrawing(new TrendLine(d.points[0], d.points[1], d.options));
break;
case "HorizontalLine":
this._drawingTool.addNewDrawing(new HorizontalLine(d.points[0], d.options));
break;
case "RayLine":
this._drawingTool.addNewDrawing(new RayLine(d.points[0], d.options));
break;
case "VerticalLine":
this._drawingTool.addNewDrawing(new VerticalLine(d.points[0], d.options));
break;
}
})
}
}

172
src/general/topbar.ts Normal file
View File

@ -0,0 +1,172 @@
import { GlobalParams } from "./global-params";
import { Handler } from "./handler";
import { Menu } from "./menu";
declare const window: GlobalParams
interface Widget {
elem: HTMLDivElement;
callbackName: string;
intervalElements: HTMLButtonElement[];
onItemClicked: Function;
}
export class TopBar {
private _handler: Handler;
public _div: HTMLDivElement;
private left: HTMLDivElement;
private right: HTMLDivElement;
constructor(handler: Handler) {
this._handler = handler;
this._div = document.createElement('div');
this._div.classList.add('topbar');
const createTopBarContainer = (justification: string) => {
const div = document.createElement('div')
div.classList.add('topbar-container')
div.style.justifyContent = justification
this._div.appendChild(div)
return div
}
this.left = createTopBarContainer('flex-start')
this.right = createTopBarContainer('flex-end')
}
makeSwitcher(items: string[], defaultItem: string, callbackName: string, align='left') {
const switcherElement = document.createElement('div');
switcherElement.style.margin = '4px 12px'
let activeItemEl: HTMLButtonElement;
const createAndReturnSwitcherButton = (itemName: string) => {
const button = document.createElement('button');
button.classList.add('topbar-button');
button.classList.add('switcher-button');
button.style.margin = '0px 2px';
button.innerText = itemName;
if (itemName == defaultItem) {
activeItemEl = button;
button.classList.add('active-switcher-button');
}
const buttonWidth = TopBar.getClientWidth(button)
button.style.minWidth = buttonWidth + 1 + 'px'
button.addEventListener('click', () => widget.onItemClicked(button))
switcherElement.appendChild(button);
return button;
}
const widget: Widget = {
elem: switcherElement,
callbackName: callbackName,
intervalElements: items.map(createAndReturnSwitcherButton),
onItemClicked: (item: HTMLButtonElement) => {
if (item == activeItemEl) return
activeItemEl.classList.remove('active-switcher-button');
item.classList.add('active-switcher-button');
activeItemEl = item;
window.callbackFunction(`${widget.callbackName}_~_${item.innerText}`);
}
}
this.appendWidget(switcherElement, align, true)
return widget
}
makeTextBoxWidget(text: string, align='left', callbackName=null) {
if (callbackName) {
const textBox = document.createElement('input');
textBox.classList.add('topbar-textbox-input');
textBox.value = text
textBox.style.width = `${(textBox.value.length+2)}ch`
textBox.addEventListener('input', (e) => {
textBox.style.width = `${(textBox.value.length+2)}ch`;
});
textBox.addEventListener('keydown', (e) => {
if (e.key == 'Enter') {
e.preventDefault();
textBox.blur();
}
});
textBox.addEventListener('blur', () => {
window.callbackFunction(`${callbackName}_~_${textBox.value}`)
});
this.appendWidget(textBox, align, true)
return textBox
} else {
const textBox = document.createElement('div');
textBox.classList.add('topbar-textbox');
textBox.innerText = text
this.appendWidget(textBox, align, true)
return textBox
}
}
makeMenu(items: string[], activeItem: string, separator: boolean, callbackName: string, align: 'right'|'left') {
return new Menu(this.makeButton.bind(this), callbackName, items, activeItem, separator, align)
}
makeButton(defaultText: string, callbackName: string | null, separator: boolean, append=true, align='left', toggle=false) {
let button = document.createElement('button')
button.classList.add('topbar-button');
// button.style.color = window.pane.color
button.innerText = defaultText;
document.body.appendChild(button)
button.style.minWidth = button.clientWidth+1+'px'
document.body.removeChild(button)
let widget = {
elem: button,
callbackName: callbackName
}
if (callbackName) {
let handler;
if (toggle) {
let state = false;
handler = () => {
state = !state
window.callbackFunction(`${widget.callbackName}_~_${state}`)
button.style.backgroundColor = state ? 'var(--active-bg-color)' : '';
button.style.color = state ? 'var(--active-color)' : '';
}
} else {
handler = () => window.callbackFunction(`${widget.callbackName}_~_${button.innerText}`)
}
button.addEventListener('click', handler);
}
if (append) this.appendWidget(button, align, separator)
return widget
}
makeSeparator(align='left') {
const separator = document.createElement('div')
separator.classList.add('topbar-seperator')
const div = align == 'left' ? this.left : this.right
div.appendChild(separator)
}
appendWidget(widget: HTMLElement, align: string, separator: boolean) {
const div = align == 'left' ? this.left : this.right
if (separator) {
if (align == 'left') div.appendChild(widget)
this.makeSeparator(align)
if (align == 'right') div.appendChild(widget)
} else div.appendChild(widget)
this._handler.reSize();
}
private static getClientWidth(element: HTMLElement) {
document.body.appendChild(element);
const width = element.clientWidth;
document.body.removeChild(element);
return width;
}
}

33
src/helpers/assertions.ts Normal file
View File

@ -0,0 +1,33 @@
/**
* Ensures that value is defined.
* Throws if the value is undefined, returns the original value otherwise.
*
* @param value - The value, or undefined.
* @returns The passed value, if it is not undefined
*/
export function ensureDefined(value: undefined): never;
export function ensureDefined<T>(value: T | undefined): T;
export function ensureDefined<T>(value: T | undefined): T {
if (value === undefined) {
throw new Error('Value is undefined');
}
return value;
}
/**
* Ensures that value is not null.
* Throws if the value is null, returns the original value otherwise.
*
* @param value - The value, or null.
* @returns The passed value, if it is not null
*/
export function ensureNotNull(value: null): never;
export function ensureNotNull<T>(value: T | null): T;
export function ensureNotNull<T>(value: T | null): T {
if (value === null) {
throw new Error('Value is null');
}
return value;
}

View File

@ -0,0 +1,14 @@
import { LineStyle } from "lightweight-charts";
export function setLineStyle(ctx: CanvasRenderingContext2D, style: LineStyle): void {
const dashPatterns = {
[LineStyle.Solid]: [],
[LineStyle.Dotted]: [ctx.lineWidth, ctx.lineWidth],
[LineStyle.Dashed]: [2 * ctx.lineWidth, 2 * ctx.lineWidth],
[LineStyle.LargeDashed]: [6 * ctx.lineWidth, 6 * ctx.lineWidth],
[LineStyle.SparseDotted]: [ctx.lineWidth, 4 * ctx.lineWidth],
};
const dashPattern = dashPatterns[style];
ctx.setLineDash(dashPattern);
}

View File

@ -0,0 +1,6 @@
export interface BitmapPositionLength {
/** coordinate for use with a bitmap rendering scope */
position: number;
/** length for use with a bitmap rendering scope */
length: number;
}

View File

@ -0,0 +1,23 @@
/**
* Default grid / crosshair line width in Bitmap sizing
* @param horizontalPixelRatio - horizontal pixel ratio
* @returns default grid / crosshair line width in Bitmap sizing
*/
export function gridAndCrosshairBitmapWidth(
horizontalPixelRatio: number
): number {
return Math.max(1, Math.floor(horizontalPixelRatio));
}
/**
* Default grid / crosshair line width in Media sizing
* @param horizontalPixelRatio - horizontal pixel ratio
* @returns default grid / crosshair line width in Media sizing
*/
export function gridAndCrosshairMediaWidth(
horizontalPixelRatio: number
): number {
return (
gridAndCrosshairBitmapWidth(horizontalPixelRatio) / horizontalPixelRatio
);
}

View File

@ -0,0 +1,29 @@
import { BitmapPositionLength } from './common';
/**
* Calculates the position and width which will completely full the space for the bar.
* Useful if you want to draw something that will not have any gaps between surrounding bars.
* @param xMedia - x coordinate of the bar defined in media sizing
* @param halfBarSpacingMedia - half the width of the current barSpacing (un-rounded)
* @param horizontalPixelRatio - horizontal pixel ratio
* @returns position and width which will completely full the space for the bar
*/
export function fullBarWidth(
xMedia: number,
halfBarSpacingMedia: number,
horizontalPixelRatio: number
): BitmapPositionLength {
const fullWidthLeftMedia = xMedia - halfBarSpacingMedia;
const fullWidthRightMedia = xMedia + halfBarSpacingMedia;
const fullWidthLeftBitmap = Math.round(
fullWidthLeftMedia * horizontalPixelRatio
);
const fullWidthRightBitmap = Math.round(
fullWidthRightMedia * horizontalPixelRatio
);
const fullWidthBitmap = fullWidthRightBitmap - fullWidthLeftBitmap;
return {
position: fullWidthLeftBitmap,
length: fullWidthBitmap,
};
}

View File

@ -0,0 +1,48 @@
import { BitmapPositionLength } from './common';
function centreOffset(lineBitmapWidth: number): number {
return Math.floor(lineBitmapWidth * 0.5);
}
/**
* Calculates the bitmap position for an item with a desired length (height or width), and centred according to
* an position coordinate defined in media sizing.
* @param positionMedia - position coordinate for the bar (in media coordinates)
* @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions
* @param desiredWidthMedia - desired width (in media coordinates)
* @returns Position of of the start point and length dimension.
*/
export function positionsLine(
positionMedia: number,
pixelRatio: number,
desiredWidthMedia: number = 1,
widthIsBitmap?: boolean
): BitmapPositionLength {
const scaledPosition = Math.round(pixelRatio * positionMedia);
const lineBitmapWidth = widthIsBitmap
? desiredWidthMedia
: Math.round(desiredWidthMedia * pixelRatio);
const offset = centreOffset(lineBitmapWidth);
const position = scaledPosition - offset;
return { position, length: lineBitmapWidth };
}
/**
* Determines the bitmap position and length for a dimension of a shape to be drawn.
* @param position1Media - media coordinate for the first point
* @param position2Media - media coordinate for the second point
* @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal)
* @returns Position of of the start point and length dimension.
*/
export function positionsBox(
position1Media: number,
position2Media: number,
pixelRatio: number
): BitmapPositionLength {
const scaledPosition1 = Math.round(pixelRatio * position1Media);
const scaledPosition2 = Math.round(pixelRatio * position2Media);
return {
position: Math.min(scaledPosition1, scaledPosition2),
length: Math.abs(scaledPosition2 - scaledPosition1) + 1,
};
}

34
src/helpers/time.ts Normal file
View File

@ -0,0 +1,34 @@
import { Time, isUTCTimestamp, isBusinessDay } from 'lightweight-charts';
export function convertTime(t: Time): number {
if (isUTCTimestamp(t)) return t * 1000;
if (isBusinessDay(t)) return new Date(t.year, t.month, t.day).valueOf();
const [year, month, day] = t.split('-').map(parseInt);
return new Date(year, month, day).valueOf();
}
export function displayTime(time: Time): string {
if (typeof time == 'string') return time;
const date = isBusinessDay(time)
? new Date(time.year, time.month, time.day)
: new Date(time * 1000);
return date.toLocaleDateString();
}
export function formattedDateAndTime(timestamp: number | undefined): [string, string] {
if (!timestamp) return ['', ''];
const dateObj = new Date(timestamp);
// Format date string
const year = dateObj.getFullYear();
const month = dateObj.toLocaleString('default', { month: 'short' });
const date = dateObj.getDate().toString().padStart(2, '0');
const formattedDate = `${date} ${month} ${year}`;
// Format time string
const hours = dateObj.getHours().toString().padStart(2, '0');
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
const formattedTime = `${hours}:${minutes}`;
return [formattedDate, formattedTime];
}

View File

@ -0,0 +1,37 @@
import { Coordinate, ISeriesPrimitiveAxisView, PriceFormatBuiltIn } from 'lightweight-charts';
import { HorizontalLine } from './horizontal-line';
export class HorizontalLineAxisView implements ISeriesPrimitiveAxisView {
_source: HorizontalLine;
_y: Coordinate | null = null;
_price: string | null = null;
constructor(source: HorizontalLine) {
this._source = source;
}
update() {
if (!this._source.series || !this._source._point) return;
this._y = this._source.series.priceToCoordinate(this._source._point.price);
const priceFormat = this._source.series.options().priceFormat as PriceFormatBuiltIn;
const precision = priceFormat.precision;
this._price = this._source._point.price.toFixed(precision).toString();
}
visible() {
return true;
}
tickVisible() {
return true;
}
coordinate() {
return this._y ?? 0;
}
text() {
return this._source._options.text || this._price || '';
}
textColor() {
return 'white';
}
backColor() {
return this._source._options.lineColor;
}
}

View File

@ -0,0 +1,99 @@
import {
DeepPartial,
MouseEventParams
} from "lightweight-charts";
import { Point } from "../drawing/data-source";
import { Drawing, InteractionState } from "../drawing/drawing";
import { DrawingOptions } from "../drawing/options";
import { HorizontalLinePaneView } from "./pane-view";
import { GlobalParams } from "../general/global-params";
import { HorizontalLineAxisView } from "./axis-view";
declare const window: GlobalParams;
export class HorizontalLine extends Drawing {
_type = 'HorizontalLine';
_paneViews: HorizontalLinePaneView[];
_point: Point;
private _callbackName: string | null;
_priceAxisViews: HorizontalLineAxisView[];
protected _startDragPoint: Point | null = null;
constructor(point: Point, options: DeepPartial<DrawingOptions>, callbackName=null) {
super(options)
this._point = point;
this._point.time = null; // time is null for horizontal lines
this._paneViews = [new HorizontalLinePaneView(this)];
this._priceAxisViews = [new HorizontalLineAxisView(this)];
this._callbackName = callbackName;
}
public get points() {
return [this._point];
}
public updatePoints(...points: (Point | null)[]) {
for (const p of points) if (p) this._point.price = p.price;
this.requestUpdate();
}
updateAllViews() {
this._paneViews.forEach((pw) => pw.update());
this._priceAxisViews.forEach((tw) => tw.update());
}
priceAxisViews() {
return this._priceAxisViews;
}
_moveToState(state: InteractionState) {
switch(state) {
case InteractionState.NONE:
document.body.style.cursor = "default";
this._unsubscribe("mousedown", this._handleMouseDownInteraction);
break;
case InteractionState.HOVERING:
document.body.style.cursor = "pointer";
this._unsubscribe("mouseup", this._childHandleMouseUpInteraction);
this._subscribe("mousedown", this._handleMouseDownInteraction)
this.chart.applyOptions({handleScroll: true});
break;
case InteractionState.DRAGGING:
document.body.style.cursor = "grabbing";
this._subscribe("mouseup", this._childHandleMouseUpInteraction);
this.chart.applyOptions({handleScroll: false});
break;
}
this._state = state;
}
_onDrag(diff: any) {
this._addDiffToPoint(this._point, 0, diff.price);
this.requestUpdate();
}
_mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) {
if (!param.point) return false;
const y = this.series.priceToCoordinate(this._point.price);
if (!y) return false;
return (Math.abs(y-param.point.y) < tolerance);
}
protected _onMouseDown() {
this._startDragPoint = null;
const hoverPoint = this._latestHoverPoint;
if (!hoverPoint) return;
return this._moveToState(InteractionState.DRAGGING);
}
protected _childHandleMouseUpInteraction = () => {
this._handleMouseUpInteraction();
if (!this._callbackName) return;
window.callbackFunction(`${this._callbackName}_~_${this._point.price.toFixed(8)}`);
}
}

View File

@ -0,0 +1,35 @@
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { DrawingOptions } from "../drawing/options";
import { DrawingPaneRenderer } from "../drawing/pane-renderer";
import { ViewPoint } from "../drawing/pane-view";
import { setLineStyle } from "../helpers/canvas-rendering";
export class HorizontalLinePaneRenderer extends DrawingPaneRenderer {
_point: ViewPoint = {x: null, y: null};
constructor(point: ViewPoint, options: DrawingOptions) {
super(options);
this._point = point;
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
if (this._point.y == null) return;
const ctx = scope.context;
const scaledY = Math.round(this._point.y * scope.verticalPixelRatio);
const scaledX = this._point.x ? this._point.x * scope.horizontalPixelRatio : 0;
ctx.lineWidth = this._options.width;
ctx.strokeStyle = this._options.lineColor;
setLineStyle(ctx, this._options.lineStyle);
ctx.beginPath();
ctx.moveTo(scaledX, scaledY);
ctx.lineTo(scope.bitmapSize.width, scaledY);
ctx.stroke();
});
}
}

View File

@ -0,0 +1,31 @@
import { HorizontalLinePaneRenderer } from './pane-renderer';
import { HorizontalLine } from './horizontal-line';
import { DrawingPaneView, ViewPoint } from '../drawing/pane-view';
export class HorizontalLinePaneView extends DrawingPaneView {
_source: HorizontalLine;
_point: ViewPoint = {x: null, y: null};
constructor(source: HorizontalLine) {
super(source);
this._source = source;
}
update() {
const point = this._source._point;
const timeScale = this._source.chart.timeScale()
const series = this._source.series;
if (this._source._type == "RayLine") {
this._point.x = point.time ? timeScale.timeToCoordinate(point.time) : timeScale.logicalToCoordinate(point.logical);
}
this._point.y = series.priceToCoordinate(point.price);
}
renderer() {
return new HorizontalLinePaneRenderer(
this._point,
this._source._options
);
}
}

View File

@ -0,0 +1,35 @@
import {
DeepPartial,
MouseEventParams
} from "lightweight-charts";
import { DiffPoint, Point } from "../drawing/data-source";
import { DrawingOptions } from "../drawing/options";
import { HorizontalLine } from "./horizontal-line";
export class RayLine extends HorizontalLine {
_type = 'RayLine';
constructor(point: Point, options: DeepPartial<DrawingOptions>) {
super({...point}, options);
this._point.time = point.time;
}
public updatePoints(...points: (Point | null)[]) {
for (const p of points) if (p) this._point = p;
this.requestUpdate();
}
_onDrag(diff: DiffPoint) {
this._addDiffToPoint(this._point, diff.logical, diff.price);
this.requestUpdate();
}
_mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) {
if (!param.point) return false;
const y = this.series.priceToCoordinate(this._point.price);
const x = this._point.time ? this.chart.timeScale().timeToCoordinate(this._point.time) : null;
if (!y || !x) return false;
return (Math.abs(y-param.point.y) < tolerance && param.point.x > x - tolerance);
}
}

6
src/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from './general';
export * from './horizontal-line/horizontal-line';
export * from './vertical-line/vertical-line';
export * from './box/box';
export * from './trend-line/trend-line';
export * from './vertical-line/vertical-line';

56
src/plugin-base.ts Normal file
View File

@ -0,0 +1,56 @@
import {
DataChangedScope,
IChartApi,
ISeriesApi,
ISeriesPrimitive,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
import { ensureDefined } from './helpers/assertions';
//* PluginBase is a useful base to build a plugin upon which
//* already handles creating getters for the chart and series,
//* and provides a requestUpdate method.
export abstract class PluginBase implements ISeriesPrimitive<Time> {
private _chart: IChartApi | undefined = undefined;
private _series: ISeriesApi<keyof SeriesOptionsMap> | undefined = undefined;
protected dataUpdated?(scope: DataChangedScope): void;
protected requestUpdate(): void {
if (this._requestUpdate) this._requestUpdate();
}
private _requestUpdate?: () => void;
public attached({
chart,
series,
requestUpdate,
}: SeriesAttachedParameter<Time>) {
this._chart = chart;
this._series = series;
this._series.subscribeDataChanged(this._fireDataUpdated);
this._requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached() {
this._chart = undefined;
this._series = undefined;
this._requestUpdate = undefined;
}
public get chart(): IChartApi {
return ensureDefined(this._chart);
}
public get series(): ISeriesApi<keyof SeriesOptionsMap> {
return ensureDefined(this._series);
}
private _fireDataUpdated(scope: DataChangedScope) {
if (this.dataUpdated) {
this.dataUpdated(scope);
}
}
}

59
src/sample-data.ts Normal file
View File

@ -0,0 +1,59 @@
import type { Time, } from 'lightweight-charts';
type LineData = {
time: Time;
value: number;
};
export type CandleData = {
time: Time;
high: number;
low: number;
close: number;
open: number;
};
let randomFactor = 25 + Math.random() * 25;
const samplePoint = (i: number) =>
i *
(0.5 +
Math.sin(i / 10) * 0.2 +
Math.sin(i / 20) * 0.4 +
Math.sin(i / randomFactor) * 0.8 +
Math.sin(i / 500) * 0.5) +
200;
export function generateLineData(numberOfPoints: number = 500): LineData[] {
randomFactor = 25 + Math.random() * 25;
const res = [];
const date = new Date(Date.UTC(2018, 0, 1, 12, 0, 0, 0));
for (let i = 0; i < numberOfPoints; ++i) {
const time = (date.getTime() / 1000) as Time;
const value = samplePoint(i);
res.push({
time,
value,
});
date.setUTCDate(date.getUTCDate() + 1);
}
return res;
}
export function generateCandleData(numberOfPoints: number = 250): CandleData[] {
const lineData = generateLineData(numberOfPoints);
return lineData.map((d, i) => {
const randomRanges = [-1 * Math.random(), Math.random(), Math.random()].map(
j => j * 10
);
const sign = Math.sin(Math.random() - 0.5);
return {
time: d.time,
low: d.value + randomRanges[0],
high: d.value + randomRanges[1],
open: d.value + sign * randomRanges[2],
close: samplePoint(i + 1),
};
});
}

View File

@ -0,0 +1,42 @@
import { ViewPoint } from "./pane-view";
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { TwoPointDrawingPaneRenderer } from "../drawing/pane-renderer";
import { DrawingOptions } from "../drawing/options";
import { setLineStyle } from "../helpers/canvas-rendering";
export class TrendLinePaneRenderer extends TwoPointDrawingPaneRenderer {
constructor(p1: ViewPoint, p2: ViewPoint, options: DrawingOptions, hovered: boolean) {
super(p1, p2, options, hovered);
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
if (
this._p1.x === null ||
this._p1.y === null ||
this._p2.x === null ||
this._p2.y === null
)
return;
const ctx = scope.context;
const scaled = this._getScaledCoordinates(scope);
if (!scaled) return;
ctx.lineWidth = this._options.width;
ctx.strokeStyle = this._options.lineColor;
setLineStyle(ctx, this._options.lineStyle);
ctx.beginPath();
ctx.moveTo(scaled.x1, scaled.y1);
ctx.lineTo(scaled.x2, scaled.y2);
ctx.stroke();
// this._drawTextLabel(scope, this._text1, x1Scaled, y1Scaled, true);
// this._drawTextLabel(scope, this._text2, x2Scaled, y2Scaled, false);
if (!this._hovered) return;
this._drawEndCircle(scope, scaled.x1, scaled.y1);
this._drawEndCircle(scope, scaled.x2, scaled.y2);
});
}
}

View File

@ -0,0 +1,24 @@
import { Coordinate, } from 'lightweight-charts';
import { TrendLine } from './trend-line';
import { TrendLinePaneRenderer } from './pane-renderer';
import { TwoPointDrawingPaneView } from '../drawing/pane-view';
export interface ViewPoint {
x: Coordinate | null;
y: Coordinate | null;
}
export class TrendLinePaneView extends TwoPointDrawingPaneView {
constructor(source: TrendLine) {
super(source)
}
renderer() {
return new TrendLinePaneRenderer(
this._p1,
this._p2,
this._source._options,
this._source.hovered,
);
}
}

View File

@ -0,0 +1,109 @@
import {
MouseEventParams,
} from 'lightweight-charts';
import { TrendLinePaneView } from './pane-view';
import { Point } from '../drawing/data-source';
import { InteractionState } from '../drawing/drawing';
import { DrawingOptions } from '../drawing/options';
import { TwoPointDrawing } from '../drawing/two-point-drawing';
export class TrendLine extends TwoPointDrawing {
_type = "TrendLine"
constructor(
p1: Point,
p2: Point,
options?: Partial<DrawingOptions>
) {
super(p1, p2, options)
this._paneViews = [new TrendLinePaneView(this)];
}
_moveToState(state: InteractionState) {
switch(state) {
case InteractionState.NONE:
document.body.style.cursor = "default";
this._hovered = false;
this.requestUpdate();
this._unsubscribe("mousedown", this._handleMouseDownInteraction);
break;
case InteractionState.HOVERING:
document.body.style.cursor = "pointer";
this._hovered = true;
this.requestUpdate();
this._subscribe("mousedown", this._handleMouseDownInteraction);
this._unsubscribe("mouseup", this._handleMouseDownInteraction);
this.chart.applyOptions({handleScroll: true});
break;
case InteractionState.DRAGGINGP1:
case InteractionState.DRAGGINGP2:
case InteractionState.DRAGGING:
document.body.style.cursor = "grabbing";
this._subscribe("mouseup", this._handleMouseUpInteraction);
this.chart.applyOptions({handleScroll: false});
break;
}
this._state = state;
}
_onDrag(diff: any) {
if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP1) {
this._addDiffToPoint(this.p1, diff.logical, diff.price);
}
if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP2) {
this._addDiffToPoint(this.p2, diff.logical, diff.price);
}
}
protected _onMouseDown() {
this._startDragPoint = null;
const hoverPoint = this._latestHoverPoint;
if (!hoverPoint) return;
const p1 = this._paneViews[0]._p1;
const p2 = this._paneViews[0]._p2;
if (!p1.x || !p2.x || !p1.y || !p2.y) return this._moveToState(InteractionState.DRAGGING);
const tolerance = 10;
if (Math.abs(hoverPoint.x-p1.x) < tolerance && Math.abs(hoverPoint.y-p1.y) < tolerance) {
this._moveToState(InteractionState.DRAGGINGP1)
}
else if (Math.abs(hoverPoint.x-p2.x) < tolerance && Math.abs(hoverPoint.y-p2.y) < tolerance) {
this._moveToState(InteractionState.DRAGGINGP2)
}
else {
this._moveToState(InteractionState.DRAGGING);
}
}
protected _mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) {
if (!param.point) return false;;
const x1 = this._paneViews[0]._p1.x;
const y1 = this._paneViews[0]._p1.y;
const x2 = this._paneViews[0]._p2.x;
const y2 = this._paneViews[0]._p2.y;
if (!x1 || !x2 || !y1 || !y2 ) return false;
const mouseX = param.point.x;
const mouseY = param.point.y;
if (mouseX <= Math.min(x1, x2) - tolerance ||
mouseX >= Math.max(x1, x2) + tolerance) {
return false;
}
const distance = Math.abs((y2 - y1) * mouseX - (x2 - x1) * mouseY + x2 * y1 - y2 * x1
) / Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2);
return distance <= tolerance
}
}

View File

@ -0,0 +1,35 @@
import { Coordinate, ISeriesPrimitiveAxisView } from "lightweight-charts";
import { VerticalLine } from "./vertical-line";
export class VerticalLineTimeAxisView implements ISeriesPrimitiveAxisView {
_source: VerticalLine;
_x: Coordinate | null = null;
constructor(source: VerticalLine) {
this._source = source;
}
update() {
if (!this._source.chart|| !this._source._point) return;
const point = this._source._point;
const timeScale = this._source.chart.timeScale();
this._x = point.time ? timeScale.timeToCoordinate(point.time) : timeScale.logicalToCoordinate(point.logical);
}
visible() {
return !!this._source._options.text;
}
tickVisible() {
return true;
}
coordinate() {
return this._x ?? 0;
}
text() {
return this._source._options.text || '';
}
textColor() {
return "white";
}
backColor() {
return this._source._options.lineColor;
}
}

View File

@ -0,0 +1,32 @@
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { DrawingOptions } from "../drawing/options";
import { DrawingPaneRenderer } from "../drawing/pane-renderer";
import { ViewPoint } from "../drawing/pane-view";
import { setLineStyle } from "../helpers/canvas-rendering";
export class VerticalLinePaneRenderer extends DrawingPaneRenderer {
_point: ViewPoint = {x: null, y: null};
constructor(point: ViewPoint, options: DrawingOptions) {
super(options);
this._point = point;
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
if (this._point.x == null) return;
const ctx = scope.context;
const scaledX = this._point.x * scope.horizontalPixelRatio;
ctx.lineWidth = this._options.width;
ctx.strokeStyle = this._options.lineColor;
setLineStyle(ctx, this._options.lineStyle);
ctx.beginPath();
ctx.moveTo(scaledX, 0);
ctx.lineTo(scaledX, scope.bitmapSize.height);
ctx.stroke();
});
}
}

View File

@ -0,0 +1,29 @@
import { VerticalLinePaneRenderer } from './pane-renderer';
import { VerticalLine } from './vertical-line';
import { DrawingPaneView, ViewPoint } from '../drawing/pane-view';
export class VerticalLinePaneView extends DrawingPaneView {
_source: VerticalLine;
_point: ViewPoint = {x: null, y: null};
constructor(source: VerticalLine) {
super(source);
this._source = source;
}
update() {
const point = this._source._point;
const timeScale = this._source.chart.timeScale()
const series = this._source.series;
this._point.x = point.time ? timeScale.timeToCoordinate(point.time) : timeScale.logicalToCoordinate(point.logical)
this._point.y = series.priceToCoordinate(point.price);
}
renderer() {
return new VerticalLinePaneRenderer(
this._point,
this._source._options
);
}
}

View File

@ -0,0 +1,111 @@
import {
DeepPartial,
MouseEventParams
} from "lightweight-charts";
import { Point } from "../drawing/data-source";
import { Drawing, InteractionState } from "../drawing/drawing";
import { DrawingOptions } from "../drawing/options";
import { VerticalLinePaneView } from "./pane-view";
import { GlobalParams } from "../general/global-params";
import { VerticalLineTimeAxisView } from "./axis-view";
declare const window: GlobalParams;
export class VerticalLine extends Drawing {
_type = 'VerticalLine';
_paneViews: VerticalLinePaneView[];
_timeAxisViews: VerticalLineTimeAxisView[];
_point: Point;
private _callbackName: string | null;
protected _startDragPoint: Point | null = null;
constructor(point: Point, options: DeepPartial<DrawingOptions>, callbackName=null) {
super(options)
this._point = point;
this._paneViews = [new VerticalLinePaneView(this)];
this._callbackName = callbackName;
this._timeAxisViews = [new VerticalLineTimeAxisView(this)]
}
updateAllViews() {
this._paneViews.forEach(pw => pw.update());
this._timeAxisViews.forEach(tw => tw.update());
}
timeAxisViews() {
return this._timeAxisViews;
}
public updatePoints(...points: (Point | null)[]) {
for (const p of points) {
if (!p) continue;
if (!p.time && p.logical) {
p.time = this.series.dataByIndex(p.logical)?.time || null
}
this._point = p;
}
this.requestUpdate();
}
get points() {
return [this._point];
}
_moveToState(state: InteractionState) {
switch(state) {
case InteractionState.NONE:
document.body.style.cursor = "default";
this._unsubscribe("mousedown", this._handleMouseDownInteraction);
break;
case InteractionState.HOVERING:
document.body.style.cursor = "pointer";
this._unsubscribe("mouseup", this._childHandleMouseUpInteraction);
this._subscribe("mousedown", this._handleMouseDownInteraction)
this.chart.applyOptions({handleScroll: true});
break;
case InteractionState.DRAGGING:
document.body.style.cursor = "grabbing";
this._subscribe("mouseup", this._childHandleMouseUpInteraction);
this.chart.applyOptions({handleScroll: false});
break;
}
this._state = state;
}
_onDrag(diff: any) {
this._addDiffToPoint(this._point, diff.logical, 0);
this.requestUpdate();
}
_mouseIsOverDrawing(param: MouseEventParams, tolerance = 4) {
if (!param.point) return false;
const timeScale = this.chart.timeScale()
let x;
if (this._point.time) {
x = timeScale.timeToCoordinate(this._point.time);
}
else {
x = timeScale.logicalToCoordinate(this._point.logical);
}
if (!x) return false;
return (Math.abs(x-param.point.x) < tolerance);
}
protected _onMouseDown() {
this._startDragPoint = null;
const hoverPoint = this._latestHoverPoint;
if (!hoverPoint) return;
return this._moveToState(InteractionState.DRAGGING);
}
protected _childHandleMouseUpInteraction = () => {
this._handleMouseUpInteraction();
if (!this._callbackName) return;
window.callbackFunction(`${this._callbackName}_~_${this._point.price.toFixed(8)}`);
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
src/vite.config.js Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
const input = {
main: './src/example/index.html',
};
export default defineConfig({
build: {
rollupOptions: {
input,
},
},
});

43
test/drawings.json Normal file
View File

@ -0,0 +1,43 @@
{
"SYM": [
{
"type": "Box",
"points":
[
{
"time": 1675036800,
"logical": 2928,
"price": 130.25483664317744
},
{
"time": 1676332800,
"logical": 2939,
"price": 145.52389493914157
}
],
"options": {
"color": "#FFF",
"style": 0
}
},
{
"type": "TrendLine",
"points": [
{
"time": 1669939200,
"logical": 2890,
"price": 196.12991672005123
},
{
"time": 1673222400,
"logical": 2914,
"price": 223.17796284433055
}
],
"options": {
"color": "#FFF",
"style": 0
}
}
]
}

22
test/run_tests.py Normal file
View File

@ -0,0 +1,22 @@
import unittest
from test_returns import TestReturns
from test_table import TestTable
from test_toolbox import TestToolBox
from test_topbar import TestTopBar
from test_chart import TestChart
TEST_CASES = [
TestReturns,
TestTable,
TestToolBox,
TestTopBar,
TestChart,
]
if __name__ == '__main__':
loader = unittest.TestLoader()
cases = [loader.loadTestsFromTestCase(module) for module in TEST_CASES]
suite = unittest.TestSuite(cases)
unittest.TextTestRunner(verbosity=2).run(suite)

21
test/test_chart.py Normal file
View File

@ -0,0 +1,21 @@
import unittest
import pandas as pd
from util import BARS, Tester
from lightweight_charts import Chart
class TestChart(Tester):
def test_data_is_renamed(self):
uppercase_df = pd.DataFrame(BARS.copy()).rename({'date': 'Date', 'open': 'OPEN', 'high': 'HIgh', 'low': 'Low', 'close': 'close', 'volUME': 'volume'})
result = self.chart._df_datetime_format(uppercase_df)
self.assertEqual(list(result.columns), list(BARS.rename(columns={'date': 'time'}).columns))
def test_line_in_list(self):
result0 = self.chart.create_line()
result1 = self.chart.create_line()
self.assertEqual(result0, self.chart.lines()[0])
self.assertEqual(result1, self.chart.lines()[1])
if __name__ == '__main__':
unittest.main()

41
test/test_returns.py Normal file
View File

@ -0,0 +1,41 @@
import unittest
import pandas as pd
from lightweight_charts import Chart
import asyncio
from util import BARS, Tester
class TestReturns(Tester):
def test_screenshot_returns_value(self):
self.chart.set(BARS)
self.chart.show()
screenshot_data = self.chart.screenshot()
self.assertIsNotNone(screenshot_data)
def test_save_drawings(self):
async def main():
asyncio.create_task(self.chart.show_async());
await asyncio.sleep(2)
self.chart.toolbox.drawings.clear() # clear drawings in python
self.assertTrue(len(self.chart.toolbox.drawings) == 0)
self.chart.run_script(f'{self.chart.id}.toolBox.saveDrawings();')
await asyncio.sleep(1) # resave them, and assert they exist
self.assertTrue(len(self.chart.toolbox.drawings) > 0)
self.chart.exit()
self.chart = Chart(toolbox=True, width=100, height=100)
self.chart.set(BARS)
self.chart.topbar.textbox('symbol', 'SYM', align='right')
self.chart.toolbox.save_drawings_under(self.chart.topbar['symbol'])
self.chart.toolbox.import_drawings("drawings.json")
self.chart.toolbox.load_drawings("SYM")
asyncio.run(main())
if __name__ == '__main__':
unittest.main()

12
test/test_table.py Normal file
View File

@ -0,0 +1,12 @@
import unittest
import pandas as pd
from lightweight_charts import Chart
class TestTable(unittest.TestCase):
...
if __name__ == '__main__':
unittest.main()

43
test/test_toolbox.py Normal file
View File

@ -0,0 +1,43 @@
import unittest
import pandas as pd
from lightweight_charts import Chart
from util import BARS, Tester
from time import sleep
class TestToolBox(Tester):
def test_create_horizontal_line(self):
self.chart.set(BARS)
horz_line = self.chart.horizontal_line(200, width=4)
self.chart.show()
result = self.chart.win.run_script_and_get(f"{horz_line.id}._options");
self.assertTrue(result)
self.chart.exit()
def test_create_trend_line(self):
self.chart.set(BARS)
horz_line = self.chart.trend_line(BARS.iloc[-10]['date'], 180, BARS.iloc[-3]['date'], 190)
self.chart.show()
result = self.chart.win.run_script_and_get(f"{horz_line.id}._options");
self.assertTrue(result)
self.chart.exit()
def test_create_box(self):
self.chart.set(BARS)
horz_line = self.chart.box(BARS.iloc[-10]['date'], 180, BARS.iloc[-3]['date'], 190)
self.chart.show()
result = self.chart.win.run_script_and_get(f"{horz_line.id}._options");
self.assertTrue(result)
self.chart.exit()
def test_create_vertical_line(self):
...
def test_create_vertical_span(self):
...
if __name__ == '__main__':
unittest.main()

22
test/test_topbar.py Normal file
View File

@ -0,0 +1,22 @@
import unittest
import pandas as pd
from lightweight_charts import Chart
from util import Tester
class TestTopBar(Tester):
def test_switcher_fires_event(self):
self.chart.topbar.switcher('a', ('1', '2'), func=lambda c: (self.assertEqual(c.topbar['a'].value, '2'), c.exit()))
self.chart.run_script(f'{self.chart.topbar["a"].id}.intervalElements[1].dispatchEvent(new Event("click"))')
self.chart.show(block=True)
def test_button_fires_event(self):
self.chart.topbar.button('a', '1', func=lambda c: (self.assertEqual(c.topbar['a'].value, '2'), c.exit()))
self.chart.topbar['a'].set('2')
self.chart.run_script(f'{self.chart.topbar["a"].id}.elem.dispatchEvent(new Event("click"))')
self.chart.show(block=True)
if __name__ == '__main__':
unittest.main()

20
test/util.py Normal file
View File

@ -0,0 +1,20 @@
import unittest
import pandas as pd
from lightweight_charts import Chart
BARS = pd.read_csv('../examples/1_setting_data/ohlcv.csv')
class Tester(unittest.TestCase):
def setUp(self):
self.chart: Chart = Chart(100, 100, 800, 100);
def tearDown(self) -> None:
self.chart.exit()

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"declaration": true,
"declarationDir": "typings",
"emitDeclarationOnly": true,
// "outDir": "./dist"
},
"include": ["src"]
}