Skip to content

Commit 527130e

Browse files
committed
Ability to save drawings
- Added `toolbox` to the common methods. - `toolbox.save_drawings_under` can save drawings under a specific `topbar` widget. eg `chart.toolbox.save_drawings_under(chart.topbar[’symbol’]`) - `toolbox.load_drawings` will load and display drawings stored under the tag/string given. - `toolbox.export_drawings` will export all currently saved drawings to the given file path. - `toolbox.import_drawings` will import the drawings stored at the given file path. Fixes/Enhancements: - `update` methods are no longer case sensitive. - HorizontalLines no longer throw cyclic structure errors in the web console. - `API` methods can now be normal methods or coroutines.
1 parent b2ceae5 commit 527130e

File tree

10 files changed

+166
-55
lines changed

10 files changed

+166
-55
lines changed

.github/FUNDING.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
1-
# These are supported funding model platforms
2-
31
github: louisnw01
4-
custom: https://www.buymeacoffee.com/7wzcr2p9vxM
2+
custom: https://www.buymeacoffee.com/7wzcr2p9vxM/

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ ___
3030
7. Direct integration of market data through [Polygon.io's](https://polygon.io/?utm_source=affiliate&utm_campaign=pythonlwcharts) market data API.
3131

3232
__Supports:__ Jupyter Notebooks, PyQt, wxPython, Streamlit, and asyncio.
33+
34+
PartTimeLarry: [Interactive Brokers API and TradingView Charts in Python](https://www.youtube.com/watch?v=TlhDI3PforA)
3335
___
3436

3537
### 1. Display data from a csv:

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
project = 'lightweight-charts-python'
22
copyright = '2023, louisnw'
33
author = 'louisnw'
4-
release = '1.0.14.1'
4+
release = '1.0.14.2'
55

66
extensions = ["myst_parser"]
77

docs/source/docs.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ ___
325325

326326
Sets the data for the line.
327327

328-
When not using the `name` parameter, the columns should be named: `time | value`.
328+
When not using the `name` parameter, the columns should be named: `time | value` (Not case sensitive).
329329

330330
Otherwise, the method will use the column named after the string given in `name`. This name will also be used within the legend of the chart. For example:
331331
```python
@@ -469,6 +469,7 @@ The ID shown above will change depending upon which pane was used to search, due
469469

470470
```{important}
471471
* Search callbacks will always be emitted to a method named `on_search`
472+
* `API` class methods can be either coroutines or normal methods.
472473
```
473474
___
474475

@@ -568,7 +569,33 @@ The following hotkeys can also be used when the Toolbox is enabled:
568569
* Alt+H: Horizontal Line
569570
* Alt+R: Ray Line
570571
* Meta+Z or Ctrl+Z: Undo
572+
___
573+
574+
### `save_drawings_under`
575+
`widget: Widget`
576+
577+
Saves drawings under a specific `topbar` text widget. For example:
578+
579+
```python
580+
chart.toolbox.save_drawings_under(chart.topbar['symbol'])
581+
```
582+
___
583+
584+
### `load_drawings`
585+
`tag: str`
586+
587+
Loads and displays drawings stored under the tag given.
588+
___
589+
### `import_drawings`
590+
`file_path: str`
591+
592+
Imports the drawings stored at the JSON file given in `file_path`.
593+
594+
___
595+
### `export_drawings`
596+
`file_path: str`
571597

598+
Exports all currently saved drawings to the JSON file given in `file_path`.
572599
___
573600

574601
## QtChart

lightweight_charts/abstract.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
from datetime import timedelta, datetime
34
from base64 import b64decode
@@ -59,23 +60,35 @@ def _set_interval(self, df: pd.DataFrame):
5960
}}
6061
''')
6162

63+
@staticmethod
64+
def _rename(data, mapper, is_dataframe):
65+
if is_dataframe:
66+
data.columns = [mapper[key] if key in mapper else key for key in data.columns]
67+
else:
68+
data.index = [mapper[key] if key in mapper else key for key in data.index]
69+
6270
def _df_datetime_format(self, df: pd.DataFrame, exclude_lowercase=None):
6371
df = df.copy()
64-
df.columns = df.columns.str.lower()
65-
if exclude_lowercase:
66-
df[exclude_lowercase] = df[exclude_lowercase.lower()]
72+
if 'date' not in df.columns and 'time' not in df.columns:
73+
df.columns = df.columns.str.lower()
74+
if exclude_lowercase:
75+
df[exclude_lowercase] = df[exclude_lowercase.lower()]
6776
if 'date' in df.columns:
68-
df = df.rename(columns={'date': 'time'})
77+
self._rename(df, {'date': 'time'}, True)
6978
elif 'time' not in df.columns:
7079
df['time'] = df.index
7180
self._set_interval(df)
7281
df['time'] = self._datetime_format(df['time'])
7382
return df
7483

75-
def _series_datetime_format(self, series):
84+
def _series_datetime_format(self, series: pd.Series, exclude_lowercase=None):
7685
series = series.copy()
77-
if 'date' in series.keys():
78-
series = series.rename({'date': 'time'})
86+
if 'date' not in series.index and 'time' not in series.index:
87+
series.index = series.index.str.lower()
88+
if exclude_lowercase:
89+
self._rename(series, {exclude_lowercase.lower(): exclude_lowercase}, False)
90+
if 'date' in series.index:
91+
self._rename(series, {'date': 'time'}, False)
7992
series['time'] = self._datetime_format(series['time'])
8093
return series
8194

@@ -353,6 +366,47 @@ def _widget_with_method(self, method_name):
353366
return widget
354367

355368

369+
class ToolBox:
370+
def __init__(self, chart):
371+
self.run_script = chart.run_script
372+
self.id = chart.id
373+
self._return_q = chart._return_q
374+
375+
self._saved_drawings = {}
376+
377+
def save_drawings_under(self, widget: Widget):
378+
"""
379+
Drawings made on charts will be saved under the widget given. eg `chart.toolbox.save_drawings_under(chart.topbar['symbol'])`.
380+
"""
381+
self._save_under = widget
382+
383+
def load_drawings(self, tag: str):
384+
"""
385+
Loads and displays the drawings on the chart stored under the tag given.
386+
"""
387+
if not self._saved_drawings.get(tag):
388+
return
389+
self.run_script(f'if ("toolBox" in {self.id}) {self.id}.toolBox.loadDrawings({json.dumps(self._saved_drawings[tag])})')
390+
391+
def import_drawings(self, file_path):
392+
"""
393+
Imports a list of drawings stored at the given file path.
394+
"""
395+
with open(file_path, 'r') as f:
396+
json_data = json.load(f)
397+
self._saved_drawings = json_data
398+
399+
def export_drawings(self, file_path):
400+
"""
401+
Exports the current list of drawings to the given file path.
402+
"""
403+
with open(file_path, 'w+') as f:
404+
json.dump(self._saved_drawings, f)
405+
406+
def _save_drawings(self, drawings):
407+
self._saved_drawings[self._save_under.value] = json.loads(drawings)
408+
409+
356410
class LWC(SeriesCommon):
357411
def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False,
358412
scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False,
@@ -394,6 +448,7 @@ def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_
394448
if toolbox:
395449
self.run_script(JS['toolbox'])
396450
self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})')
451+
self.toolbox: ToolBox = ToolBox(self)
397452
if not topbar and not searchbox:
398453
return
399454
self.run_script(JS['callback'])

lightweight_charts/chart.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,17 @@ async def show_async(self, block=False):
8787
self._exit.clear()
8888
return
8989
elif not self._emit_q.empty():
90-
key, chart_id, arg = self._emit_q.get()
90+
name, chart_id, arg = self._emit_q.get()
9191
self._api.chart = self._charts[chart_id]
92-
if widget := self._api.chart.topbar._widget_with_method(key):
92+
if name == 'save_drawings':
93+
self._api.chart.toolbox._save_drawings(arg)
94+
continue
95+
method = getattr(self._api, name)
96+
if hasattr(self._api.chart, 'topbar') and (widget := self._api.chart.topbar._widget_with_method(name)):
9397
widget.value = arg
94-
await getattr(self._api, key)()
98+
await method() if asyncio.iscoroutinefunction(method) else method()
9599
else:
96-
await getattr(self._api, key)(*arg.split(';;;'))
100+
await method(*arg.split(';;;')) if asyncio.iscoroutinefunction(method) else method(arg)
97101
continue
98102
value = self.polygon._q.get()
99103
func, args = value[0], value[1:]

lightweight_charts/js/callback.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ function makeSearchBox(chart) {
4242
yPrice = param.point.y;
4343
}
4444
});
45-
let selectedChart = true
45+
let selectedChart = false
4646
chart.wrapper.addEventListener('mouseover', (event) => {
4747
selectedChart = true
4848
})
4949
chart.wrapper.addEventListener('mouseout', (event) => {
5050
selectedChart = false
5151
})
5252
chart.commandFunctions.push((event) => {
53+
if (!selectedChart) return
5354
if (searchWindow.style.display === 'none') {
5455
if (/^[a-zA-Z0-9]$/.test(event.key)) {
5556
searchWindow.style.display = 'flex';

lightweight_charts/js/funcs.js

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ if (!window.HorizontalLine) {
106106
this.chart.horizontal_lines.push(this)
107107
}
108108

109+
toJSON() {
110+
// Exclude the chart attribute from serialization
111+
const {chart, line, ...serialized} = this;
112+
return serialized;
113+
}
114+
109115
updatePrice(price) {
110116
this.chart.series.removePriceLine(this.line)
111117
this.price = price
@@ -361,36 +367,3 @@ function calculateTrendLine(startDate, startValue, endDate, endValue, interval,
361367
}
362368
return trendData;
363369
}
364-
365-
366-
/*
367-
let customMenu = document.createElement('div')
368-
customMenu.style.position = 'absolute'
369-
customMenu.style.zIndex = '10000'
370-
customMenu.style.background = 'rgba(25, 25, 25, 0.7)'
371-
customMenu.style.color = 'lightgrey'
372-
customMenu.style.display = 'none'
373-
customMenu.style.borderRadius = '5px'
374-
customMenu.style.padding = '5px 10px'
375-
document.body.appendChild(customMenu)
376-
377-
function menuItem(text) {
378-
let elem = document.createElement('div')
379-
elem.innerText = text
380-
customMenu.appendChild(elem)
381-
}
382-
menuItem('Delete drawings')
383-
menuItem('Hide all indicators')
384-
menuItem('Save Chart State')
385-
386-
let closeMenu = (event) => {if (!customMenu.contains(event.target)) customMenu.style.display = 'none';}
387-
document.addEventListener('contextmenu', function (event) {
388-
event.preventDefault(); // Prevent default right-click menu
389-
customMenu.style.left = event.clientX + 'px';
390-
customMenu.style.top = event.clientY + 'px';
391-
customMenu.style.display = 'block';
392-
document.removeEventListener('click', closeMenu)
393-
document.addEventListener('click', closeMenu)
394-
});
395-
396-
*/

lightweight_charts/js/toolbox.js

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ if (!window.ToolBox) {
6666
this.chart.chart.removeSeries(toDelete.line);
6767
if (toDelete.ray) this.chart.chart.timeScale().setVisibleLogicalRange(logical)
6868
}
69-
this.drawings.splice(this.drawings.length - 1)
69+
this.drawings.splice(this.drawings.indexOf(toDelete))
70+
this.saveDrawings()
7071
}
7172
this.chart.commandFunctions.push((event) => {
7273
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') {
@@ -248,6 +249,7 @@ if (!window.ToolBox) {
248249
this.chart.cursor = 'default'
249250
this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor
250251
this.chart.activeIcon = null
252+
this.saveDrawings()
251253
}
252254
}
253255
this.chart.chart.subscribeClick(this.clickHandler)
@@ -263,6 +265,7 @@ if (!window.ToolBox) {
263265
this.chart.cursor = 'default'
264266
this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor
265267
this.chart.activeIcon = null
268+
this.saveDrawings()
266269
}
267270
onHorzSelect(toggle) {
268271
!toggle ? this.chart.chart.unsubscribeClick(this.clickHandlerHorz) : this.chart.chart.subscribeClick(this.clickHandlerHorz)
@@ -319,7 +322,6 @@ if (!window.ToolBox) {
319322
let mouseDown = false
320323
let clickedEnd = false
321324
let checkForClick = (event) => {
322-
//if (!hoveringOver) return
323325
mouseDown = true
324326
document.body.style.cursor = 'grabbing'
325327
this.chart.chart.applyOptions({
@@ -345,11 +347,11 @@ if (!window.ToolBox) {
345347
this.chart.chart.subscribeCrosshairMove(checkForDrag)
346348
}
347349
originalIndex = this.chart.chart.timeScale().coordinateToLogical(x)
348-
this.chart.chart.unsubscribeClick(checkForClick)
350+
document.removeEventListener('mousedown', checkForClick)
349351
}
350352
let checkForRelease = (event) => {
351353
mouseDown = false
352-
document.body.style.cursor = 'pointer'
354+
document.body.style.cursor = this.chart.cursor
353355

354356
this.chart.chart.applyOptions({handleScroll: true})
355357
if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') {
@@ -359,6 +361,7 @@ if (!window.ToolBox) {
359361
document.removeEventListener('mousedown', checkForClick)
360362
document.removeEventListener('mouseup', checkForRelease)
361363
this.chart.chart.subscribeCrosshairMove(hoverOver)
364+
this.saveDrawings()
362365
}
363366
let checkForDrag = (param) => {
364367
if (!param.point) return
@@ -486,6 +489,54 @@ if (!window.ToolBox) {
486489
})
487490
this.drawings = []
488491
}
492+
493+
saveDrawings() {
494+
let drawingsString = JSON.stringify(this.drawings, (key, value) => {
495+
if (key === '' && Array.isArray(value)) {
496+
return value.filter(item => !(item && typeof item === 'object' && 'priceLine' in item && item.id !== 'toolBox'));
497+
} else if (key === 'line' || (value && typeof value === 'object' && 'priceLine' in value && value.id !== 'toolBox')) {
498+
return undefined;
499+
}
500+
return value;
501+
});
502+
this.chart.callbackFunction(`save_drawings__${this.chart.id}__${drawingsString}`)
503+
}
504+
505+
loadDrawings(drawings) {
506+
this.drawings = drawings
507+
this.chart.chart.applyOptions({
508+
handleScroll: false
509+
})
510+
let logical = this.chart.chart.timeScale().getVisibleLogicalRange()
511+
this.drawings.forEach((item) => {
512+
if ('price' in item) {
513+
this.drawings[this.drawings.indexOf(item)] = new HorizontalLine(this.chart, 'toolBox', item.priceLine.price, item.priceLine.color, 2, item.priceLine.lineStyle, item.priceLine.axisLabelVisible)
514+
}
515+
else {
516+
this.drawings[this.drawings.indexOf(item)].line = this.chart.chart.addLineSeries({
517+
lineWidth: 2,
518+
lastValueVisible: false,
519+
priceLineVisible: false,
520+
crosshairMarkerVisible: false,
521+
autoscaleInfoProvider: () => ({
522+
priceRange: {
523+
minValue: 1_000_000_000,
524+
maxValue: 0,
525+
},
526+
}),
527+
})
528+
let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from).getTime() / this.interval) * this.interval), this.interval)
529+
let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to).getTime() / this.interval) * this.interval), this.interval)
530+
let data = calculateTrendLine(startDate, item.data[0].value, endDate, item.data[item.data.length - 1].value, this.interval, this.chart, item.ray)
531+
if (data.length !== 0) item.data = data
532+
item.line.setData(data)
533+
}
534+
})
535+
this.chart.chart.applyOptions({
536+
handleScroll: true
537+
})
538+
this.chart.chart.timeScale().setVisibleLogicalRange(logical)
539+
}
489540
}
490541

491542
window.ToolBox = ToolBox

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name='lightweight_charts',
8-
version='1.0.14.1',
8+
version='1.0.14.2',
99
packages=find_packages(),
1010
python_requires='>=3.8',
1111
install_requires=[

0 commit comments

Comments
 (0)