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

View File

@ -6,11 +6,11 @@ from typing import Union, Optional
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):
def __init__(self, chart, color, width, style, func=None):
def __init__(self, chart, func=None):
super().__init__(chart.win)
self.chart = chart
@ -34,13 +34,10 @@ class TwoPointDrawing(Drawing):
end_time: TIME,
end_value: NUM,
round: bool,
color,
width,
style,
options: dict,
func=None
):
super().__init__(chart, color, width, style, func)
super().__init__(chart, func)
def make_js_point(time, price):
formatted_time = self.chart._single_datetime_format(time)
@ -54,14 +51,14 @@ class TwoPointDrawing(Drawing):
"price": {price}
}}'''
options_string = '\n'.join(f'{key}: {val},' for key, val in options.items())
self.run_script(f'''
{self.id} = new {drawing_type}(
{make_js_point(start_time, start_value)},
{make_js_point(end_time, end_value)},
{{
lineColor: '{color}',
lineStyle: {as_enum(style, LINE_STYLE)},
width: {width},
{options_string}
}}
)
{chart.id}.series.attachPrimitive({self.id})
@ -70,7 +67,7 @@ class TwoPointDrawing(Drawing):
class HorizontalLine(Drawing):
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.run_script(f'''
@ -113,7 +110,7 @@ class HorizontalLine(Drawing):
class VerticalLine(Drawing):
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.run_script(f'''
@ -136,7 +133,68 @@ class VerticalLine(Drawing):
def label(self, text: str): # TODO
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):
def __init__(self, series: 'SeriesCommon', start_time: Union[TIME, tuple, list], end_time: Optional[TIME] = None,
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)
self._chart.set(df, render_drawings=_tickers.get(self._chart) == ticker)
self._chart.set(df, keep_drawings=_tickers.get(self._chart) == ticker)
_tickers[self._chart] = ticker
if not live:

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_SCRIPT_MESSAGE_RECEIVED, lambda e: emit_callback(self, e.GetString()))
self.webview.AddScriptMessageHandler('wx_msg')
self.webview.SetPage(abstract.TEMPLATE, '')
self.webview.AddUserScript(abstract.JS['toolbox']) if toolbox else None
self.webview.LoadURL(f'file:///{abstract.INDEX}')
# self.webview.AddUserScript(abstract.JS['toolbox']) if toolbox else None
def get_webview(self): return self.webview

View File

@ -22,7 +22,9 @@ export class ColorPicker {
private _opacityLabel: HTMLDivElement;
private rgba: number[] | undefined;
constructor(saveDrawings: Function) {
constructor(saveDrawings: Function,
private colorOption: string,
) {
this.saveDrawings = saveDrawings
this._div = document.createElement('div');
@ -114,12 +116,12 @@ export class ColorPicker {
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})
Drawing.lastHoveredObject.applyOptions({[this.colorOption]: oColor})
this.saveDrawings()
}
openMenu(rect: DOMRect) {
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._updateOpacitySlider();
this._div.style.top = (rect.top-30)+'px'

View File

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

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()
cases = [loader.loadTestsFromTestCase(module) for module in TEST_CASES]
suite = unittest.TestSuite(cases)
unittest.TextTestRunner().run(suite)
unittest.TextTestRunner(verbosity=2).run(suite)

View File

@ -1,13 +1,10 @@
import unittest
import pandas as pd
from util import BARS
from util import BARS, Tester
from lightweight_charts import Chart
class TestChart(unittest.TestCase):
def setUp(self):
self.chart = Chart()
class TestChart(Tester):
def test_data_is_renamed(self):
uppercase_df = pd.DataFrame(BARS.copy()).rename({'date': 'Date', 'open': 'OPEN', 'high': 'HIgh', 'low': 'Low', 'close': 'close', 'volUME': 'volume'})
result = self.chart._df_datetime_format(uppercase_df)
@ -19,9 +16,6 @@ class TestChart(unittest.TestCase):
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()

View File

@ -15,7 +15,7 @@ class TestReturns(Tester):
self.assertIsNotNone(screenshot_data)
def test_save_drawings(self):
self.chart.exit()
async def main():
asyncio.create_task(self.chart.show_async());
@ -28,7 +28,7 @@ class TestReturns(Tester):
self.assertTrue(len(self.chart.toolbox.drawings) > 0)
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.topbar.textbox('symbol', 'SYM', align='right')
self.chart.toolbox.save_drawings_under(self.chart.topbar['symbol'])

View File

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