2.0 first commit

This commit is contained in:
louisnw
2024-03-30 15:38:34 +00:00
parent a91ea493d7
commit e9f21b3b0e
69 changed files with 4081 additions and 2107 deletions

15
.gitignore vendored
View File

@ -1 +1,16 @@
docs/build/**
venv/
.ipynb_checkpoints/
__pycache__/
build/
dist/
*.egg-info/
node_modules/
.DS_Store
*.sublime-*
.idea/
pyrightconfig.json
working/

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}"

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

@ -2,62 +2,32 @@ import asyncio
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 .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,
IDGen, as_enum, jbool, Pane, Events, TIME, NUM, FLOAT,
LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, PRICE_SCALE_MODE, js_json,
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 = []
@ -80,58 +50,69 @@ class Window:
"""
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:
self.script_func(script)
return
self.scripts.append(script) if not run_last else self.final_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)
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_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)
if not sync_id:
return subchart
self.run_script(f'''
syncCharts({subchart.id}, {sync_id}, {jbool(sync_crosshairs_only)})
Handler.syncCharts({subchart.id}, {sync_id}, {jbool(sync_crosshairs_only)})
{subchart.id}.chart.timeScale().setVisibleLogicalRange(
{sync_id}.chart.timeScale().getVisibleLogicalRange()
)
''', run_last=True)
return subchart
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}',
}}''')
#TODO test func below with polygon and others
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'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'):
@ -169,15 +150,13 @@ 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}))
''')
return
#TODO you dont need this? All series should have a series row?
# self.run_script(f'''
# {self._chart.id}._seriesList.push({self.id})
# {self._chart.id}.legend.lines.push({self._chart.id}.legend.makeSeriesRow({self.id}))
# ''')
@staticmethod
def _format_labels(data, labels, index, exclude_lowercase):
@ -209,7 +188,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 +197,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 +210,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,14 +220,6 @@ 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 marker_list(self, markers: list):
@ -276,7 +247,7 @@ class SeriesCommon(Pane):
""")
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:
"""
@ -319,14 +290,14 @@ class SeriesCommon(Pane):
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):
def remove_horizontal_line(self, price: NUM):
"""
Removes a horizontal line at the given price.
"""
@ -363,10 +334,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 +353,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
@ -402,7 +378,7 @@ class HorizontalLine(Pane):
self.run_script(f'''
{self.id} = new HorizontalLine(
{chart.id}, '{self.id}', {price}, '{color}', {width},
{line_style(style)}, {jbool(axis_label_visible)}, '{text}'
{as_enum(style, LINE_STYLE)}, {jbool(axis_label_visible)}, '{text}'
)''')
if not func:
return
@ -437,7 +413,7 @@ class HorizontalLine(Pane):
class VerticalSpan(Pane):
def __init__(self, series: 'SeriesCommon', start_time: Union[TIME, tuple, list], end_time: TIME = None,
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)
@ -475,31 +451,29 @@ class VerticalSpan(Pane):
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({{
color: '{color}',
lineStyle: {line_style(style)},
lineWidth: {width},
lastValueVisible: {jbool(price_label)},
priceLineVisible: {jbool(price_line)},
crosshairMarkerVisible: {jbool(crosshair_marker)},
{"""autoscaleInfoProvider: () => ({
priceRange: {
minValue: 1_000_000_000,
maxValue: 0,
},
}),""" if chart._scale_candles_only else ''}
}}),
markers: [],
horizontal_lines: [],
name: '{name}',
color: '{color}',
precision: 2,
}}
{self.id} = {self._chart.id}.createLineSeries(
"{name}",
{{
color: '{color}',
lineStyle: {as_enum(style, LINE_STYLE)},
lineWidth: {width},
lastValueVisible: {jbool(price_label)},
priceLineVisible: {jbool(price_line)},
crosshairMarkerVisible: {jbool(crosshair_marker)},
{"""autoscaleInfoProvider: () => ({
priceRange: {
minValue: 1_000_000_000,
maxValue: 0,
},
}),
""" if chart._scale_candles_only else ''}
}}
)
null''')
def _set_trend(self, start_time, start_value, end_time, end_value, ray=False, round=False):
@ -582,9 +556,9 @@ 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, render_drawings=False):
"""
Sets the initial data for the chart.\n
:param df: columns: date/time, open, high, low, close, volume (if volume enabled).
@ -599,9 +573,10 @@ class Candlestick(SeriesCommon):
self.candle_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)')
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}()")
self.run_script(f'{self.id}.series.setData({js_data(df)})')
# TODO are we not using renderdrawings then?
# toolbox_action = 'clearDrawings' if not render_drawings else 'renderDrawings'
# self.run_script(f"if ({self._chart.id}.toolBox) {self._chart.id}.toolBox.{toolbox_action}()")
if 'volume' not in df:
return
volume = df.drop(columns=['open', 'high', 'low', 'close']).rename(columns={'volume': 'value'})
@ -630,18 +605,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 +622,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
@ -688,7 +651,7 @@ class Candlestick(SeriesCommon):
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 +666,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 ''}
}})""")
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
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({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 +712,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 Handler("{self.id}", {width}, {height}, "{position}", {jbool(autosize)})')
Candlestick.__init__(self, self)
@ -831,7 +782,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,37 +797,20 @@ 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}'
{self.id}.chart.applyOptions({{
layout: {{
background: {{color: "{background_color}"}},
{f'textColor: "{text_color}",' if text_color else ''}
{f'fontSize: {font_size},' if font_size else ''}
{f'fontFamily: "{font_family}",' if font_family else ''}
}}}})""")
document.getElementById('container').style.backgroundColor = '{background_color}'
{self.id}.chart.applyOptions({{ layout: {js_json(locals())} }})""")
def grid(self, vert_enabled: bool = True, horz_enabled: bool = True,
color: str = 'rgba(29, 30, 38, 5)', style: LINE_STYLE = 'solid'):
@ -889,20 +823,20 @@ 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_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: str = None, horz_style: LINE_STYLE = 'large_dashed',
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.
@ -910,19 +844,19 @@ class AbstractChart(Candlestick, Pane):
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}"
}}
}}}})''')
@ -935,11 +869,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 +907,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,12 +932,21 @@ 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,
@ -1016,12 +957,11 @@ class AbstractChart(Candlestick, Pane):
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:

View File

@ -1,37 +1,43 @@
import asyncio
import json
import multiprocessing as mp
import typing
import webview
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:
@ -39,64 +45,147 @@ 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')
)
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()
# TODO make sure setup.py requires latest pywebview now
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)
except KeyError:
window.evaluate_js(arg)
except KeyError as e:
return
except JavascriptException as e:
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):
"""
@ -104,34 +193,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
@ -145,12 +231,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

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,228 @@
: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-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: flex;
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,27 @@
<!DOCTYPE html>
<html lang="">
<head>
<title>lightweight-charts-python</title>
<link rel="stylesheet" href="styles.css">
<script type="importmap">
{
"imports": { "lightweight-charts": "./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 type="module" 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

@ -1,5 +1,5 @@
import random
from typing import Union
from typing import Union, Optional, Callable
from .util import jbool, Pane, NUM
@ -10,8 +10,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 ""})
@ -34,7 +34,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))
@ -46,23 +47,42 @@ 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}')")
self._table.pop(self.id)
# # TODO this might be useful in abschart
# def _call(self, method_name: str, *args):
# new_args = []
# for arg in args:
# if isinstance(arg, str):
# arg = f"'{arg}'"
# new_args.append(arg)
# self.run_script(f"{self._table.id}.{method_name}({', '.join(new_args)})")
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)
@ -75,17 +95,20 @@ class Table(Pane, dict):
self.win.handlers[self.id] = lambda rId: func(self[rId])
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}
{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,7 +8,7 @@ ALIGN = Literal['left', 'right']
class Widget(Pane):
def __init__(self, topbar, value, func=None):
def __init__(self, topbar, value, func: callable = None):
super().__init__(topbar.win)
self.value = value
@ -75,10 +75,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):

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
@ -19,7 +20,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)
@ -44,6 +45,21 @@ def js_data(data: Union[pd.DataFrame, pd.Series]):
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 +69,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 +97,7 @@ def marker_position(p: MARKER_POSITION):
'above': 'aboveBar',
'below': 'belowBar',
'inside': 'inBar',
None: None,
}[p]
}.get(p)
class Emitter:
@ -127,12 +137,10 @@ 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})
Handler.makeSpinner({chart.id})
{chart.id}.search = Handler.makeSearchBox({chart.id})
''')
)
self.range_change = JSEmitter(chart, f'range_change{chart.id}',

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"
}
}

17
rollup.config.js Normal file
View File

@ -0,0 +1,17 @@
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
export default [
{
input: 'src/general/handler.ts',
output: {
file: 'dist/bundle.js',
format: 'es',
},
external: ['lightweight-charts'],
plugins: [
typescript(),
terser(),
],
},
];

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 { Drawing, 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 = {
...this._options,
...defaultBoxOptions,
}
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.applyOptions({showCircles: false});
this._unsubscribe("mousedown", this._handleMouseDownInteraction);
break;
case InteractionState.HOVERING:
document.body.style.cursor = "pointer";
this.applyOptions({showCircles: 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) {
Drawing._addDiffToPoint(this._p1, diff.time, diff.logical, diff.price);
}
if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP2) {
Drawing._addDiffToPoint(this._p2, diff.time, diff.logical, diff.price);
}
if (this._state != InteractionState.DRAGGING) {
if (this._state == InteractionState.DRAGGINGP3) {
Drawing._addDiffToPoint(this._p1, diff.time, diff.logical, 0);
Drawing._addDiffToPoint(this._p2, 0, 0, diff.price);
}
if (this._state == InteractionState.DRAGGINGP4) {
Drawing._addDiffToPoint(this._p1, 0, 0, diff.price);
Drawing._addDiffToPoint(this._p2, diff.time, 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;
}
}

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

@ -0,0 +1,42 @@
import { ViewPoint } from "../drawing/pane-view";
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { TwoPointDrawingPaneRenderer } from "../drawing/pane-renderer";
import { BoxOptions } from "./box";
export class BoxPaneRenderer extends TwoPointDrawingPaneRenderer {
declare _options: BoxOptions;
constructor(p1: ViewPoint, p2: ViewPoint, text1: string, text2: string, options: BoxOptions) {
super(p1, p2, text1, text2, options)
}
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;
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._options.showCircles) 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);
});
}
}

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

@ -0,0 +1,19 @@
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._p1.price.toFixed(1),
'' + this._source._p2.price.toFixed(1),
this._source._options as BoxOptions,
);
}
}

View File

@ -0,0 +1,139 @@
import { Drawing } from "../drawing/drawing";
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) {
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({lineColor: oColor})
this.saveDrawings()
}
openMenu(rect: DOMRect) {
if (!Drawing.lastHoveredObject) return;
this.rgba = ColorPicker.extractRGBA(Drawing.lastHoveredObject._options.lineColor)
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,83 @@
import { Drawing } from "../drawing/drawing";
import { GlobalParams } from "../general/global-params";
interface Item {
elem: HTMLSpanElement;
action: Function;
closeAction: Function | null;
}
declare const window: GlobalParams;
export class ContextMenu {
private div: HTMLDivElement
private hoverItem: Item | null;
constructor() {
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;
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))
}
}
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)
}
}

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,23 @@
import {
IChartApi,
ISeriesApi,
Logical,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
import { DrawingOptions } from './options';
export interface Point {
time: Time | null;
logical: Logical;
price: number;
}
export interface DrawingDataSource {
chart: IChartApi;
series: ISeriesApi<keyof SeriesOptionsMap>;
options: DrawingOptions;
p1: Point;
p2: Point;
}

View File

@ -0,0 +1,97 @@
import {
IChartApi,
ISeriesApi,
MouseEventParams,
SeriesType,
} from 'lightweight-charts';
import { Drawing } from './drawing';
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 = [];
}
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);
}
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);
}
}

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

@ -0,0 +1,182 @@
import { ISeriesApi, MouseEventParams, SeriesType, Time, Logical } from 'lightweight-charts';
import { PluginBase } from '../plugin-base';
import { Point } from './data-source';
import { DrawingPaneView } from './pane-view';
import { DrawingOptions, defaultOptions } from './options';
import { convertTime } from '../helpers/time';
export enum InteractionState {
NONE,
HOVERING,
DRAGGING,
DRAGGINGP1,
DRAGGINGP2,
DRAGGINGP3,
DRAGGINGP4,
}
interface DiffPoint {
time: number | null;
logical: number;
price: number;
}
export abstract class Drawing extends PluginBase {
_paneViews: DrawingPaneView[] = [];
_options: DrawingOptions;
abstract _type: string;
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 abstract updatePoints(...points: (Point | null)[]): void;
detach() {
this.series.detachPrimitive(this);
for (const s of this._listeners) {
document.body.removeEventListener(s.name, s.listener)
}
}
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) {
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;
}
return;
}
this._handleDragInteraction(param);
}
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 = {
time: null,
logical: p1.logical-p2.logical,
price: p1.price-p2.price,
}
if (p1.time && p2.time) {
diff.time = convertTime(p1.time)-convertTime(p2.time);
}
return diff;
}
protected static _addDiffToPoint(point: Point, timeDiff: number | null, logicalDiff: number, priceDiff: number) {
if (timeDiff != null && point.time != null) {
point.time = (convertTime(point.time)+timeDiff)/1000 as Time;
}
else {
point.time = null;
}
point.logical = point.logical + logicalDiff as Logical;
point.price = point.price+priceDiff;
}
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: any): void; // TODO any?
protected abstract _moveToState(state: InteractionState): void;
protected abstract _mouseIsOverDrawing(param: MouseEventParams): boolean;
// toJSON() {
// const {series, chart, ...serialized} = this;
// return serialized;
// }
}

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

@ -0,0 +1,18 @@
import { LineStyle } from "lightweight-charts";
export interface DrawingOptions {
lineColor: string;
lineStyle: LineStyle
width: number;
showLabels: boolean;
showCircles: boolean,
}
export const defaultOptions: DrawingOptions = {
lineColor: 'rgb(255, 255, 255)',
lineStyle: LineStyle.Solid,
width: 4,
showLabels: true,
showCircles: false,
};

View File

@ -0,0 +1,67 @@
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;
_text1: string;
_text2: string;
constructor(p1: ViewPoint, p2: ViewPoint, text1: string, text2: string, options: DrawingOptions) {
super(options);
this._p1 = p1;
this._p2 = p2;
this._text1 = text1;
this._text2 = text2;
}
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;
}
}

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

@ -0,0 +1,55 @@
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() {
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();
if (!p.time) {
return timeScale.logicalToCoordinate(p.logical);
}
return timeScale.timeToCoordinate(p.time);
}
}

View File

@ -0,0 +1,39 @@
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 {
_p1: Point;
_p2: Point;
_paneViews: TwoPointDrawingPaneView[] = [];
constructor(
p1: Point,
p2: Point,
options?: Partial<DrawingOptions>
) {
super()
this._p1 = p1;
this._p2 = p2;
this._options = {
...defaultOptions,
...options,
};
}
setFirstPoint(point: Point) {
this.updatePoints(point);
}
setSecondPoint(point: Point) {
this.updatePoints(null, point);
}
public updatePoints(...points: (Point|null)[]) {
this._p1 = points[0] || this._p1;
this._p2 = points[1] || this._p2;
this.requestUpdate();
}
}

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 @@
import { Table } from "./table";
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;
Handler: any;
Table: typeof Table;
}
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';
window.Table = Table;
}
// export interface SeriesHandler {
// type: string;
// series: ISeriesApi<SeriesType>;
// markers: SeriesMarker<"">[],
// horizontal_lines: HorizontalLine[],
// name?: string,
// precision: number,
// }

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

@ -0,0 +1,331 @@
import {
ColorType,
CrosshairMode,
DeepPartial,
IChartApi,
ISeriesApi,
LineStyleOptions,
LogicalRange,
LogicalRangeChangeEventHandler,
MouseEventHandler,
MouseEventParams,
SeriesMarker,
SeriesOptionsCommon,
SeriesType,
Time,
createChart
} from "lightweight-charts";
import { HorizontalLine } from "../horizontal-line/horizontal-line";
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 horizontal_lines: HorizontalLine[] = [];
public markers: SeriesMarker<"">[] = [];
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>[] = [];
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) {
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,
horizontal_lines: [],
markers: [],
}
}
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 setChildRange = (timeRange: LogicalRange | null) => { if(timeRange) childChart.chart.timeScale().setVisibleLogicalRange(timeRange); }
const setParentRange = (timeRange: LogicalRange | null) => { if(timeRange) parentChart.chart.timeScale().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)
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) => {
console.log('1')
if (window.handlerInFocus !== chart.id) return false
console.log(searchWindow.style)
if (searchWindow.style.display === 'none') {
console.log('3')
if (/^[a-zA-Z0-9]$/.test(event.key)) {
console.log('4')
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]);
}
}
}
window.Handler = Handler;

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

@ -0,0 +1,221 @@
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 = true;
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.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}`
})
}
}

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

@ -0,0 +1,228 @@
: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-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: flex;
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'
}
}

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

@ -0,0 +1,203 @@
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 { StylePicker } from "../context-menu/style-picker";
import { ColorPicker } from "../context-menu/color-picker";
import { IChartApi, ISeriesApi, SeriesType } from "lightweight-charts";
import { TwoPointDrawing } from "../drawing/two-point-drawing";
import { HorizontalLine } from "../horizontal-line/horizontal-line";
import { RayLine } from "../horizontal-line/ray-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 = '';
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()
this._makeContextMenu();
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));
for (const button of this.buttons) {
div.appendChild(button);
}
return div
}
private _makeToolBoxElement(DrawingType: new (...args: any[]) => Drawing, keyCmd: string, paths: string) {
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;
})
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()
}
private _makeContextMenu() {
const contextMenu = new ContextMenu()
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)
}
// 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])
// })
// }
clearDrawings() {
this._drawingTool.clearDrawings();
}
saveDrawings() {
const drawingMeta = []
for (const d of this._drawingTool.drawings) {
if (d instanceof TwoPointDrawing) {
drawingMeta.push({
type: d._type,
p1: d._p1,
p2: d._p2,
color: d._options.lineColor,
style: d._options.lineStyle, // TODO should push all options, just dont have showcircles/ non public stuff as actual options
// would also fix the instanceOf in loadDrawings
})
}
// TODO else if d instanceof Drawing
}
const string = JSON.stringify(drawingMeta);
window.callbackFunction(`save_drawings${this._handlerID}_~_${string}`)
}
loadDrawings(drawings: any[]) { // TODO any?
drawings.forEach((d) => {
const options = {
lineColor: d.color,
lineStyle: d.style,
}
switch (d.type) {
case "Box":
this._drawingTool.addNewDrawing(new Box(d.p1, d.p2, options));
break;
case "TrendLine":
this._drawingTool.addNewDrawing(new TrendLine(d.p1, d.p2, options));
break;
// TODO case HorizontalLine
}
})
}
}

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

@ -0,0 +1,171 @@
import { GlobalParams } from "./global-params";
import { Handler } from "./handler";
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') {
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') {
let menu = document.createElement('div')
menu.classList.add('topbar-menu');
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) {
menu.style.display = 'none';
return;
}
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: string, callbackName: string | null, separator: boolean, append=true, align='left') {
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) {
button.addEventListener('click', () => window.callbackFunction(`${widget.callbackName}_~_${button.innerText}`));
}
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,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,74 @@
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";
export class HorizontalLine extends Drawing {
_type = 'HorizontalLine';
_paneViews: HorizontalLinePaneView[];
_point: Point;
protected _startDragPoint: Point | null = null;
constructor(point: Point, options: DeepPartial<DrawingOptions>) {
super(options)
this._point = point;
this._point.time = null; // time is null for horizontal lines
this._paneViews = [new HorizontalLinePaneView(this)];
// TODO ids should be stored in an object dictionary so u can access the lines
// this.handler.horizontal_lines.push(this) TODO fix this in handler ?
}
public updatePoints(...points: (Point | null)[]) {
for (const p of points) if (p) this._point.price = p.price;
this.requestUpdate();
}
_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._handleMouseUpInteraction);
this._subscribe("mousedown", this._handleMouseDownInteraction)
this.chart.applyOptions({handleScroll: true});
break;
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) {
Drawing._addDiffToPoint(this._point, 0, 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);
}
}

View File

@ -0,0 +1,33 @@
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { DrawingOptions } from "../drawing/options";
import { DrawingPaneRenderer } from "../drawing/pane-renderer";
import { ViewPoint } from "../drawing/pane-view";
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;
ctx.beginPath();
ctx.moveTo(scaledX, scaledY);
ctx.lineTo(scope.bitmapSize.width, scaledY);
ctx.stroke();
});
}
}

View File

@ -0,0 +1,29 @@
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;
this._point.x = point.time ? timeScale.timeToCoordinate(point.time) : null;
this._point.y = series.priceToCoordinate(point.price);
}
renderer() {
return new HorizontalLinePaneRenderer(
this._point,
this._source._options
);
}
}

View File

@ -0,0 +1,36 @@
import {
DeepPartial,
MouseEventParams
} from "lightweight-charts";
import { Point } from "../drawing/data-source";
import { Drawing } from "../drawing/drawing";
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: any) {
Drawing._addDiffToPoint(this._point, diff.time, 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);
}
}

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,40 @@
import { ViewPoint } from "./pane-view";
import { CanvasRenderingTarget2D } from "fancy-canvas";
import { TwoPointDrawingPaneRenderer } from "../drawing/pane-renderer";
import { DrawingOptions } from "../drawing/options";
export class TrendLinePaneRenderer extends TwoPointDrawingPaneRenderer {
constructor(p1: ViewPoint, p2: ViewPoint, text1: string, text2: string, options: DrawingOptions) {
super(p1, p2, text1, text2, options);
}
draw(target: CanvasRenderingTarget2D) {
target.useBitmapCoordinateSpace(scope => {
if (
this._p1.x === null ||
this._p1.y === null ||
this._p2.x === null ||
this._p2.y === null
)
return;
const ctx = scope.context;
const scaled = this._getScaledCoordinates(scope);
if (!scaled) return;
ctx.lineWidth = this._options.width;
ctx.strokeStyle = this._options.lineColor;
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._options.showCircles) return;
this._drawEndCircle(scope, scaled.x1, scaled.y1);
this._drawEndCircle(scope, scaled.x2, scaled.y2);
});
}
}

View File

@ -0,0 +1,25 @@
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._p1.price.toFixed(1),
'' + this._source._p2.price.toFixed(1),
this._source._options
);
}
}

View File

@ -0,0 +1,109 @@
import {
MouseEventParams,
} from 'lightweight-charts';
import { TrendLinePaneView } from './pane-view';
import { Point } from '../drawing/data-source';
import { Drawing, 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._options.showCircles = false;
this.requestUpdate();
this._unsubscribe("mousedown", this._handleMouseDownInteraction);
break;
case InteractionState.HOVERING:
document.body.style.cursor = "pointer";
this._options.showCircles = 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) {
Drawing._addDiffToPoint(this._p1, diff.time, diff.logical, diff.price);
}
if (this._state == InteractionState.DRAGGING || this._state == InteractionState.DRAGGINGP2) {
Drawing._addDiffToPoint(this._p2, diff.time, 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
}
}

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,
},
},
});

View File

@ -0,0 +1,62 @@
// import { Handler } from "../general/handler"
// interface Command {
// type: string,
// id: string,
// method: string,
// args: string,
// }
// class Interpreter {
// private _tokens: string[];
// private cwd: string;
// private _i: number;
// private objects = {};
// constructor() {
// }
// private _next() {
// this._i++;
// this.cwd = this._tokens[this._i];
// return this.cwd;
// }
// private _handleCommand(command: string[]) {
// const type = this.cwd;
// switch (this.cwd) {
// case "auth":
// break;
// case "create":
// return this._create();
// case "obj":
// break;
// case "":
// }
// }
// private static readonly createMap = {
// "Handler": Handler,
// }
// // create, HorizontalLine, id
// private _create() {
// const type = this.cwd;
// this._next();
// Interpreter.createMap[type](...this.cwd)
// }
// private _obj() {
// const id = this._next();
// const method = this._next();
// const args = this._next();
// this.objects[id][method](args);
// }
// }

34
test/drawings.json Normal file
View File

@ -0,0 +1,34 @@
{
"SYM": [
{
"type": "Box",
"p1": {
"time": 1675036800,
"logical": 2928,
"price": 130.25483664317744
},
"p2": {
"time": 1676332800,
"logical": 2939,
"price": 145.52389493914157
},
"color": "#FFF",
"style": 0
},
{
"type": "TrendLine",
"p1": {
"time": 1669939200,
"logical": 2890,
"price": 196.12991672005123
},
"p2": {
"time": 1673222400,
"logical": 2914,
"price": 223.17796284433055
},
"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().run(suite)

27
test/test_chart.py Normal file
View File

@ -0,0 +1,27 @@
import unittest
import pandas as pd
from util import BARS
from lightweight_charts import Chart
class TestChart(unittest.TestCase):
def setUp(self):
self.chart = Chart()
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])
def tearDown(self):
self.chart.exit()
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):
self.chart.exit()
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, debug=True)
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()

12
test/test_toolbox.py Normal file
View File

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

23
test/test_topbar.py Normal file
View File

@ -0,0 +1,23 @@
import unittest
import pandas as pd
from lightweight_charts import Chart
class TestTopBar(unittest.TestCase):
def test_switcher_fires_event(self):
chart = Chart()
chart.topbar.switcher('a', ('1', '2'), func=lambda c: (self.assertEqual(c.topbar['a'].value, '2'), c.exit()))
chart.run_script(f'{chart.topbar["a"].id}.intervalElements[1].dispatchEvent(new Event("click"))')
chart.show(block=True)
def test_button_fires_event(self):
chart = Chart()
chart.topbar.button('a', '1', func=lambda c: (self.assertEqual(c.topbar['a'].value, '2'), c.exit()))
chart.topbar['a'].set('2')
chart.run_script(f'{chart.topbar["a"].id}.elem.dispatchEvent(new Event("click"))')
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();
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"]
}