implement fill color option for boxes, start wx integration

This commit is contained in:
louisnw
2024-05-16 11:25:42 +01:00
parent f8c0a5754d
commit 906571e4fb
13 changed files with 175 additions and 137 deletions

View File

@ -7,7 +7,7 @@ import pandas as pd
from .table import Table from .table import Table
from .toolbox import ToolBox from .toolbox import ToolBox
from .drawings import HorizontalLine, TwoPointDrawing, VerticalSpan from .drawings import Box, HorizontalLine, TrendLine, TwoPointDrawing, VerticalSpan
from .topbar import TopBar from .topbar import TopBar
from .util import ( from .util import (
Pane, Events, IDGen, as_enum, jbool, js_json, TIME, NUM, FLOAT, Pane, Events, IDGen, as_enum, jbool, js_json, TIME, NUM, FLOAT,
@ -45,9 +45,9 @@ class Window:
return return
self.loaded = True self.loaded = True
# TODO this wont work for anything which isnt pywebview :( put it in the chart class ? if hasattr(self, '_return_q'):
while not self.run_script_and_get('document.readyState == "complete"'): while not self.run_script_and_get('document.readyState == "complete"'):
continue # scary, but works continue # scary, but works
initial_script = '' initial_script = ''
self.scripts.extend(self.final_scripts) self.scripts.extend(self.final_scripts)
@ -283,13 +283,13 @@ class SeriesCommon(Pane):
:return: The id of the marker placed. :return: The id of the marker placed.
""" """
try: try:
time = self._last_bar['time'] if not time else self._single_datetime_format(time) formatted_time = self._last_bar['time'] if not time else self._single_datetime_format(time)
except TypeError: except TypeError:
raise TypeError('Chart marker created before data was set.') raise TypeError('Chart marker created before data was set.')
marker_id = self.win._id_gen.generate() marker_id = self.win._id_gen.generate()
self.run_script(f""" self.run_script(f"""
{self.id}.markers.push({{ {self.id}.markers.push({{
time: {time if isinstance(time, float) else f"'{time}'"}, time: {time if isinstance(formatted_time, float) else f"'{formatted_time}'"},
position: '{marker_position(position)}', position: '{marker_position(position)}',
color: '{color}', color: '{color}',
shape: '{marker_shape(shape)}', shape: '{marker_shape(shape)}',
@ -719,11 +719,11 @@ class AbstractChart(Candlestick, Pane):
end_time: TIME, end_time: TIME,
end_value: NUM, end_value: NUM,
round: bool = False, round: bool = False,
color: str = '#1E80F0', line_color: str = '#1E80F0',
width: int = 2, width: int = 2,
style: LINE_STYLE = 'solid', style: LINE_STYLE = 'solid',
) -> TwoPointDrawing: ) -> TwoPointDrawing:
return TwoPointDrawing("TrendLine", *locals().values()) return TrendLine(*locals().values())
def box( def box(
self, self,
@ -733,10 +733,11 @@ class AbstractChart(Candlestick, Pane):
end_value: NUM, end_value: NUM,
round: bool = False, round: bool = False,
color: str = '#1E80F0', color: str = '#1E80F0',
fill_color: str = 'rgba(255, 255, 255, 0.2)',
width: int = 2, width: int = 2,
style: LINE_STYLE = 'solid', style: LINE_STYLE = 'solid',
) -> TwoPointDrawing: ) -> TwoPointDrawing:
return TwoPointDrawing("Box", *locals().values()) return Box(*locals().values())
def ray_line( def ray_line(
self, self,

View File

@ -6,11 +6,11 @@ from typing import Union, Optional
from lightweight_charts.util import js_json from lightweight_charts.util import js_json
from .util import NUM, Pane, as_enum, LINE_STYLE, TIME from .util import NUM, Pane, as_enum, LINE_STYLE, TIME, snake_to_camel
class Drawing(Pane): class Drawing(Pane):
def __init__(self, chart, color, width, style, func=None): def __init__(self, chart, func=None):
super().__init__(chart.win) super().__init__(chart.win)
self.chart = chart self.chart = chart
@ -34,13 +34,10 @@ class TwoPointDrawing(Drawing):
end_time: TIME, end_time: TIME,
end_value: NUM, end_value: NUM,
round: bool, round: bool,
color, options: dict,
width,
style,
func=None func=None
): ):
super().__init__(chart, color, width, style, func) super().__init__(chart, func)
def make_js_point(time, price): def make_js_point(time, price):
formatted_time = self.chart._single_datetime_format(time) formatted_time = self.chart._single_datetime_format(time)
@ -54,14 +51,14 @@ class TwoPointDrawing(Drawing):
"price": {price} "price": {price}
}}''' }}'''
options_string = '\n'.join(f'{key}: {val},' for key, val in options.items())
self.run_script(f''' self.run_script(f'''
{self.id} = new {drawing_type}( {self.id} = new {drawing_type}(
{make_js_point(start_time, start_value)}, {make_js_point(start_time, start_value)},
{make_js_point(end_time, end_value)}, {make_js_point(end_time, end_value)},
{{ {{
lineColor: '{color}', {options_string}
lineStyle: {as_enum(style, LINE_STYLE)},
width: {width},
}} }}
) )
{chart.id}.series.attachPrimitive({self.id}) {chart.id}.series.attachPrimitive({self.id})
@ -70,7 +67,7 @@ class TwoPointDrawing(Drawing):
class HorizontalLine(Drawing): class HorizontalLine(Drawing):
def __init__(self, chart, price, color, width, style, text, axis_label_visible, func): def __init__(self, chart, price, color, width, style, text, axis_label_visible, func):
super().__init__(chart, color, width, style, func) super().__init__(chart, func)
self.price = price self.price = price
self.run_script(f''' self.run_script(f'''
@ -113,7 +110,7 @@ class HorizontalLine(Drawing):
class VerticalLine(Drawing): class VerticalLine(Drawing):
def __init__(self, chart, time, color, width, style, text, axis_label_visible, func): def __init__(self, chart, time, color, width, style, text, axis_label_visible, func):
super().__init__(chart, color, width, style, func) super().__init__(chart, func)
self.time = time self.time = time
self.run_script(f''' self.run_script(f'''
@ -137,6 +134,67 @@ class VerticalLine(Drawing):
self.run_script(f'{self.id}.updateLabel("{text}")') self.run_script(f'{self.id}.updateLabel("{text}")')
class Box(TwoPointDrawing):
def __init__(self,
chart,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool,
line_color: str,
fill_color: str,
width: int,
style: LINE_STYLE,
func=None):
super().__init__(
"Box",
chart,
start_time,
start_value,
end_time,
end_value,
round,
{
"lineColor": f'"{line_color}"',
"fillColor": f'"{fill_color}"',
"width": width,
"lineStyle": as_enum(style, LINE_STYLE)
},
func
)
class TrendLine(TwoPointDrawing):
def __init__(self,
chart,
start_time: TIME,
start_value: NUM,
end_time: TIME,
end_value: NUM,
round: bool,
line_color: str,
width: int,
style: LINE_STYLE,
func=None):
super().__init__(
"TrendLine",
chart,
start_time,
start_value,
end_time,
end_value,
round,
{
"lineColor": f'"line_color"',
"width": width,
"lineStyle": as_enum(style, LINE_STYLE)
},
func
)
class VerticalSpan(Pane): class VerticalSpan(Pane):
def __init__(self, series: 'SeriesCommon', start_time: Union[TIME, tuple, list], end_time: Optional[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)'): color: str = 'rgba(252, 219, 3, 0.2)'):

File diff suppressed because one or more lines are too long

View File

@ -247,7 +247,7 @@ class PolygonAPI:
df = await async_get_bar_data(ticker, timeframe, start_date, end_date, limit) df = await async_get_bar_data(ticker, timeframe, start_date, end_date, limit)
self._chart.set(df, render_drawings=_tickers.get(self._chart) == ticker) self._chart.set(df, keep_drawings=_tickers.get(self._chart) == ticker)
_tickers[self._chart] = ticker _tickers[self._chart] = ticker
if not live: if not live:

View File

@ -63,8 +63,8 @@ class WxChart(abstract.AbstractChart):
self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(500, self.win.on_js_load)) self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(500, self.win.on_js_load))
self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: emit_callback(self, e.GetString())) self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: emit_callback(self, e.GetString()))
self.webview.AddScriptMessageHandler('wx_msg') self.webview.AddScriptMessageHandler('wx_msg')
self.webview.SetPage(abstract.TEMPLATE, '') self.webview.LoadURL(f'file:///{abstract.INDEX}')
self.webview.AddUserScript(abstract.JS['toolbox']) if toolbox else None # self.webview.AddUserScript(abstract.JS['toolbox']) if toolbox else None
def get_webview(self): return self.webview def get_webview(self): return self.webview

View File

@ -22,7 +22,9 @@ export class ColorPicker {
private _opacityLabel: HTMLDivElement; private _opacityLabel: HTMLDivElement;
private rgba: number[] | undefined; private rgba: number[] | undefined;
constructor(saveDrawings: Function) { constructor(saveDrawings: Function,
private colorOption: string,
) {
this.saveDrawings = saveDrawings this.saveDrawings = saveDrawings
this._div = document.createElement('div'); this._div = document.createElement('div');
@ -114,12 +116,12 @@ export class ColorPicker {
updateColor() { updateColor() {
if (!Drawing.lastHoveredObject || !this.rgba) return; if (!Drawing.lastHoveredObject || !this.rgba) return;
const oColor = `rgba(${this.rgba[0]}, ${this.rgba[1]}, ${this.rgba[2]}, ${this.opacity})` const oColor = `rgba(${this.rgba[0]}, ${this.rgba[1]}, ${this.rgba[2]}, ${this.opacity})`
Drawing.lastHoveredObject.applyOptions({lineColor: oColor}) Drawing.lastHoveredObject.applyOptions({[this.colorOption]: oColor})
this.saveDrawings() this.saveDrawings()
} }
openMenu(rect: DOMRect) { openMenu(rect: DOMRect) {
if (!Drawing.lastHoveredObject) return; if (!Drawing.lastHoveredObject) return;
this.rgba = ColorPicker.extractRGBA(Drawing.lastHoveredObject._options.lineColor) this.rgba = ColorPicker.extractRGBA(Drawing.lastHoveredObject._options[this.colorOption])
this.opacity = this.rgba[3]; this.opacity = this.rgba[3];
this._updateOpacitySlider(); this._updateOpacitySlider();
this._div.style.top = (rect.top-30)+'px' this._div.style.top = (rect.top-30)+'px'

View File

@ -1,5 +1,21 @@
import { Drawing } from "../drawing/drawing"; import { Drawing } from "../drawing/drawing";
import { DrawingTool } from "../drawing/drawing-tool";
import { GlobalParams } from "../general/global-params"; import { GlobalParams } from "../general/global-params";
import { ColorPicker } from "./color-picker";
import { StylePicker } from "./style-picker";
export function camelToTitle(inputString: string) {
const result = [];
for (const c of inputString) {
if (result.length == 0) {
result.push(c.toUpperCase());
} else if (c == c.toUpperCase()) {
result.push(' '+c);
} else result.push(c);
}
return result.join('');
}
interface Item { interface Item {
elem: HTMLSpanElement; elem: HTMLSpanElement;
@ -13,8 +29,12 @@ declare const window: GlobalParams;
export class ContextMenu { export class ContextMenu {
private div: HTMLDivElement private div: HTMLDivElement
private hoverItem: Item | null; private hoverItem: Item | null;
private items: HTMLElement[] = []
constructor() { constructor(
private saveDrawings: Function,
private drawingTool: DrawingTool,
) {
this._onRightClick = this._onRightClick.bind(this); this._onRightClick = this._onRightClick.bind(this);
this.div = document.createElement('div'); this.div = document.createElement('div');
this.div.classList.add('context-menu'); this.div.classList.add('context-menu');
@ -35,6 +55,50 @@ export class ContextMenu {
private _onRightClick(ev: MouseEvent) { private _onRightClick(ev: MouseEvent) {
if (!Drawing.hoveredObject) return; if (!Drawing.hoveredObject) return;
for (const item of this.items) {
this.div.removeChild(item);
}
this.items = [];
for (const optionName of Object.keys(Drawing.hoveredObject._options)) {
let subMenu;
if (optionName.toLowerCase().includes('color')) {
subMenu = new ColorPicker(this.saveDrawings, optionName);
} else if (optionName === 'lineStyle') {
subMenu = new StylePicker(this.saveDrawings)
} else continue;
let onClick = (rect: DOMRect) => subMenu.openMenu(rect)
this.menuItem(camelToTitle(optionName), onClick, () => {
document.removeEventListener('click', subMenu.closeMenu)
subMenu._div.style.display = 'none'
})
}
let onClickDelete = () => this.drawingTool.delete(Drawing.lastHoveredObject);
this.separator()
this.menuItem('Delete Drawing', onClickDelete)
// const colorPicker = new ColorPicker(this.saveDrawings)
// const stylePicker = new StylePicker(this.saveDrawings)
// let onClickDelete = () => this._drawingTool.delete(Drawing.lastHoveredObject);
// let onClickColor = (rect: DOMRect) => colorPicker.openMenu(rect)
// let onClickStyle = (rect: DOMRect) => stylePicker.openMenu(rect)
// contextMenu.menuItem('Color Picker', onClickColor, () => {
// document.removeEventListener('click', colorPicker.closeMenu)
// colorPicker._div.style.display = 'none'
// })
// contextMenu.menuItem('Style', onClickStyle, () => {
// document.removeEventListener('click', stylePicker.closeMenu)
// stylePicker._div.style.display = 'none'
// })
// contextMenu.separator()
// contextMenu.menuItem('Delete Drawing', onClickDelete)
ev.preventDefault(); ev.preventDefault();
this.div.style.left = ev.clientX + 'px'; this.div.style.left = ev.clientX + 'px';
this.div.style.top = ev.clientY + 'px'; this.div.style.top = ev.clientY + 'px';
@ -70,6 +134,9 @@ export class ContextMenu {
item.addEventListener('mouseover', () => timeout = setTimeout(() => action(item.getBoundingClientRect()), 100)) item.addEventListener('mouseover', () => timeout = setTimeout(() => action(item.getBoundingClientRect()), 100))
item.addEventListener('mouseout', () => clearTimeout(timeout)) item.addEventListener('mouseout', () => clearTimeout(timeout))
} }
this.items.push(item);
} }
public separator() { public separator() {
const separator = document.createElement('div') const separator = document.createElement('div')
@ -78,6 +145,8 @@ export class ContextMenu {
separator.style.margin = '3px 0px' separator.style.margin = '3px 0px'
separator.style.backgroundColor = window.pane.borderColor separator.style.backgroundColor = window.pane.borderColor
this.div.appendChild(separator) this.div.appendChild(separator)
this.items.push(separator);
} }
} }

View File

@ -4,8 +4,6 @@ import { Box } from "../box/box";
import { Drawing } from "../drawing/drawing"; import { Drawing } from "../drawing/drawing";
import { ContextMenu } from "../context-menu/context-menu"; import { ContextMenu } from "../context-menu/context-menu";
import { GlobalParams } from "./global-params"; 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 { IChartApi, ISeriesApi, SeriesType } from "lightweight-charts";
import { HorizontalLine } from "../horizontal-line/horizontal-line"; import { HorizontalLine } from "../horizontal-line/horizontal-line";
import { RayLine } from "../horizontal-line/ray-line"; import { RayLine } from "../horizontal-line/ray-line";
@ -42,7 +40,7 @@ export class ToolBox {
this._commandFunctions = commandFunctions; this._commandFunctions = commandFunctions;
this._drawingTool = new DrawingTool(chart, series, () => this.removeActiveAndSave()); this._drawingTool = new DrawingTool(chart, series, () => this.removeActiveAndSave());
this.div = this._makeToolBox() this.div = this._makeToolBox()
this._makeContextMenu(); new ContextMenu(this.saveDrawings, this._drawingTool);
commandFunctions.push((event: KeyboardEvent) => { commandFunctions.push((event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') { if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') {
@ -130,27 +128,6 @@ export class ToolBox {
this.saveDrawings() 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() { // renderDrawings() {
// if (this.mouseDown) return // if (this.mouseDown) return
// this.drawings.forEach((item) => { // this.drawings.forEach((item) => {

View File

@ -1,62 +0,0 @@
// 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);
// }
// }

View File

@ -19,4 +19,4 @@ if __name__ == '__main__':
loader = unittest.TestLoader() loader = unittest.TestLoader()
cases = [loader.loadTestsFromTestCase(module) for module in TEST_CASES] cases = [loader.loadTestsFromTestCase(module) for module in TEST_CASES]
suite = unittest.TestSuite(cases) suite = unittest.TestSuite(cases)
unittest.TextTestRunner().run(suite) unittest.TextTestRunner(verbosity=2).run(suite)

View File

@ -1,13 +1,10 @@
import unittest import unittest
import pandas as pd import pandas as pd
from util import BARS from util import BARS, Tester
from lightweight_charts import Chart from lightweight_charts import Chart
class TestChart(unittest.TestCase): class TestChart(Tester):
def setUp(self):
self.chart = Chart()
def test_data_is_renamed(self): 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'}) 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) result = self.chart._df_datetime_format(uppercase_df)
@ -19,9 +16,6 @@ class TestChart(unittest.TestCase):
self.assertEqual(result0, self.chart.lines()[0]) self.assertEqual(result0, self.chart.lines()[0])
self.assertEqual(result1, self.chart.lines()[1]) self.assertEqual(result1, self.chart.lines()[1])
def tearDown(self):
self.chart.exit()
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -15,7 +15,7 @@ class TestReturns(Tester):
self.assertIsNotNone(screenshot_data) self.assertIsNotNone(screenshot_data)
def test_save_drawings(self): def test_save_drawings(self):
self.chart.exit()
async def main(): async def main():
asyncio.create_task(self.chart.show_async()); asyncio.create_task(self.chart.show_async());
@ -28,7 +28,7 @@ class TestReturns(Tester):
self.assertTrue(len(self.chart.toolbox.drawings) > 0) self.assertTrue(len(self.chart.toolbox.drawings) > 0)
self.chart.exit() self.chart.exit()
self.chart = Chart(toolbox=True, debug=True) self.chart = Chart(toolbox=True, width=100, height=100)
self.chart.set(BARS) self.chart.set(BARS)
self.chart.topbar.textbox('symbol', 'SYM', align='right') self.chart.topbar.textbox('symbol', 'SYM', align='right')
self.chart.toolbox.save_drawings_under(self.chart.topbar['symbol']) self.chart.toolbox.save_drawings_under(self.chart.topbar['symbol'])

View File

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