2.0 first commit
This commit is contained in:
153
src/box/box.ts
Normal file
153
src/box/box.ts
Normal 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
42
src/box/pane-renderer.ts
Normal 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
19
src/box/pane-view.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
139
src/context-menu/color-picker.ts
Normal file
139
src/context-menu/color-picker.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
83
src/context-menu/context-menu.ts
Normal file
83
src/context-menu/context-menu.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
57
src/context-menu/style-picker.ts
Normal file
57
src/context-menu/style-picker.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
23
src/drawing/data-source.ts
Normal file
23
src/drawing/data-source.ts
Normal 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;
|
||||
}
|
||||
97
src/drawing/drawing-tool.ts
Normal file
97
src/drawing/drawing-tool.ts
Normal 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
182
src/drawing/drawing.ts
Normal 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
18
src/drawing/options.ts
Normal 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,
|
||||
};
|
||||
67
src/drawing/pane-renderer.ts
Normal file
67
src/drawing/pane-renderer.ts
Normal 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
55
src/drawing/pane-view.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
src/drawing/two-point-drawing.ts
Normal file
39
src/drawing/two-point-drawing.ts
Normal 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
15
src/example/example.ts
Normal 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
27
src/example/index.html
Normal 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>
|
||||
60
src/general/global-params.ts
Normal file
60
src/general/global-params.ts
Normal 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
331
src/general/handler.ts
Normal 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
221
src/general/legend.ts
Normal 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
228
src/general/styles.css
Normal 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
199
src/general/table.ts
Normal 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
203
src/general/toolbox.ts
Normal 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
171
src/general/topbar.ts
Normal 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
33
src/helpers/assertions.ts
Normal 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;
|
||||
}
|
||||
6
src/helpers/dimensions/common.ts
Normal file
6
src/helpers/dimensions/common.ts
Normal 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;
|
||||
}
|
||||
23
src/helpers/dimensions/crosshair-width.ts
Normal file
23
src/helpers/dimensions/crosshair-width.ts
Normal 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
|
||||
);
|
||||
}
|
||||
29
src/helpers/dimensions/full-width.ts
Normal file
29
src/helpers/dimensions/full-width.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
src/helpers/dimensions/positions.ts
Normal file
48
src/helpers/dimensions/positions.ts
Normal 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
34
src/helpers/time.ts
Normal 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];
|
||||
}
|
||||
74
src/horizontal-line/horizontal-line.ts
Normal file
74
src/horizontal-line/horizontal-line.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/horizontal-line/pane-renderer.ts
Normal file
33
src/horizontal-line/pane-renderer.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
29
src/horizontal-line/pane-view.ts
Normal file
29
src/horizontal-line/pane-view.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/horizontal-line/ray-line.ts
Normal file
36
src/horizontal-line/ray-line.ts
Normal 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
56
src/plugin-base.ts
Normal 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
59
src/sample-data.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
40
src/trend-line/pane-renderer.ts
Normal file
40
src/trend-line/pane-renderer.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
25
src/trend-line/pane-view.ts
Normal file
25
src/trend-line/pane-view.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
109
src/trend-line/trend-line.ts
Normal file
109
src/trend-line/trend-line.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
src/vite.config.js
Normal file
13
src/vite.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const input = {
|
||||
main: './src/example/index.html',
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input,
|
||||
},
|
||||
},
|
||||
});
|
||||
62
src/websocket-test/server.ts
Normal file
62
src/websocket-test/server.ts
Normal 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);
|
||||
// }
|
||||
// }
|
||||
Reference in New Issue
Block a user