2.0 first commit

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

View File

@ -0,0 +1,60 @@
import { Table } from "./table";
export interface GlobalParams extends Window {
pane: paneStyle; // TODO shouldnt need this cause of css variables
handlerInFocus: string;
callbackFunction: Function;
containerDiv: HTMLElement;
setCursor: Function;
cursor: string;
Handler: any;
Table: typeof Table;
}
interface paneStyle {
backgroundColor: string;
hoverBackgroundColor: string;
clickBackgroundColor: string;
activeBackgroundColor: string;
mutedBackgroundColor: string;
borderColor: string;
color: string;
activeColor: string;
}
export const paneStyleDefault: paneStyle = {
backgroundColor: '#0c0d0f',
hoverBackgroundColor: '#3c434c',
clickBackgroundColor: '#50565E',
activeBackgroundColor: 'rgba(0, 122, 255, 0.7)',
mutedBackgroundColor: 'rgba(0, 122, 255, 0.3)',
borderColor: '#3C434C',
color: '#d8d9db',
activeColor: '#ececed',
}
declare const window: GlobalParams;
export function globalParamInit() {
window.pane = {
...paneStyleDefault,
}
window.containerDiv = document.getElementById("container") || document.createElement('div');
window.setCursor = (type: string | undefined) => {
if (type) window.cursor = type;
document.body.style.cursor = window.cursor;
}
window.cursor = 'default';
window.Table = Table;
}
// export interface SeriesHandler {
// type: string;
// series: ISeriesApi<SeriesType>;
// markers: SeriesMarker<"">[],
// horizontal_lines: HorizontalLine[],
// name?: string,
// precision: number,
// }

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

@ -0,0 +1,331 @@
import {
ColorType,
CrosshairMode,
DeepPartial,
IChartApi,
ISeriesApi,
LineStyleOptions,
LogicalRange,
LogicalRangeChangeEventHandler,
MouseEventHandler,
MouseEventParams,
SeriesMarker,
SeriesOptionsCommon,
SeriesType,
Time,
createChart
} from "lightweight-charts";
import { HorizontalLine } from "../horizontal-line/horizontal-line";
import { GlobalParams, globalParamInit } from "./global-params";
import { Legend } from "./legend";
import { ToolBox } from "./toolbox";
import { TopBar } from "./topbar";
export interface Scale{
width: number,
height: number,
}
globalParamInit();
declare const window: GlobalParams;
export class Handler {
public id: string;
public commandFunctions: Function[] = [];
public wrapper: HTMLDivElement;
public div: HTMLDivElement;
public chart: IChartApi;
public scale: Scale;
public horizontal_lines: HorizontalLine[] = [];
public markers: SeriesMarker<"">[] = [];
public precision: number = 2;
public series: ISeriesApi<SeriesType>;
public volumeSeries: ISeriesApi<SeriesType>;
public legend: Legend;
private _topBar: TopBar | undefined;
public toolBox: ToolBox | undefined;
public spinner: HTMLDivElement | undefined;
public _seriesList: ISeriesApi<SeriesType>[] = [];
constructor(chartId: string, innerWidth: number, innerHeight: number, position: string, autoSize: boolean) {
this.reSize = this.reSize.bind(this)
this.id = chartId
this.scale = {
width: innerWidth,
height: innerHeight,
}
this.wrapper = document.createElement('div')
this.wrapper.classList.add("handler");
this.wrapper.style.float = position
this.div = document.createElement('div')
this.div.style.position = 'relative'
this.wrapper.appendChild(this.div);
window.containerDiv.append(this.wrapper)
this.chart = this._createChart();
this.series = this.createCandlestickSeries();
this.volumeSeries = this.createVolumeSeries();
this.legend = new Legend(this)
document.addEventListener('keydown', (event) => {
for (let i = 0; i < this.commandFunctions.length; i++) {
if (this.commandFunctions[i](event)) break
}
})
window.handlerInFocus = this.id;
this.wrapper.addEventListener('mouseover', () => window.handlerInFocus = this.id)
this.reSize()
if (!autoSize) return
window.addEventListener('resize', () => this.reSize())
}
reSize() {
let topBarOffset = this.scale.height !== 0 ? this._topBar?._div.offsetHeight || 0 : 0
this.chart.resize(window.innerWidth * this.scale.width, (window.innerHeight * this.scale.height) - topBarOffset)
this.wrapper.style.width = `${100 * this.scale.width}%`
this.wrapper.style.height = `${100 * this.scale.height}%`
// TODO definitely a better way to do this
if (this.scale.height === 0 || this.scale.width === 0) {
this.legend.div.style.display = 'none'
if (this.toolBox) {
this.toolBox.div.style.display = 'none'
}
}
else {
this.legend.div.style.display = 'flex'
if (this.toolBox) {
this.toolBox.div.style.display = 'flex'
}
}
}
private _createChart() {
return createChart(this.div, {
width: window.innerWidth * this.scale.width,
height: window.innerHeight * this.scale.height,
layout:{
textColor: window.pane.color,
background: {
color: '#000000',
type: ColorType.Solid,
},
fontSize: 12
},
rightPriceScale: {
scaleMargins: {top: 0.3, bottom: 0.25},
},
timeScale: {timeVisible: true, secondsVisible: false},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
labelBackgroundColor: 'rgb(46, 46, 46)'
},
horzLine: {
labelBackgroundColor: 'rgb(55, 55, 55)'
}
},
grid: {
vertLines: {color: 'rgba(29, 30, 38, 5)'},
horzLines: {color: 'rgba(29, 30, 58, 5)'},
},
handleScroll: {vertTouchDrag: true},
})
}
createCandlestickSeries() {
const up = 'rgba(39, 157, 130, 100)'
const down = 'rgba(200, 97, 100, 100)'
const candleSeries = this.chart.addCandlestickSeries({
upColor: up, borderUpColor: up, wickUpColor: up,
downColor: down, borderDownColor: down, wickDownColor: down
});
candleSeries.priceScale().applyOptions({
scaleMargins: {top: 0.2, bottom: 0.2},
});
return candleSeries;
}
createVolumeSeries() {
const volumeSeries = this.chart.addHistogramSeries({
color: '#26a69a',
priceFormat: {type: 'volume'},
priceScaleId: 'volume_scale',
})
volumeSeries.priceScale().applyOptions({
scaleMargins: {top: 0.8, bottom: 0},
});
return volumeSeries;
}
createLineSeries(name: string, options: DeepPartial<LineStyleOptions & SeriesOptionsCommon>) {
const line = this.chart.addLineSeries({...options});
this._seriesList.push(line);
this.legend.makeSeriesRow(name, line)
return {
name: name,
series: line,
horizontal_lines: [],
markers: [],
}
}
createToolBox() {
this.toolBox = new ToolBox(this.id, this.chart, this.series, this.commandFunctions);
this.div.appendChild(this.toolBox.div);
}
createTopBar() {
this._topBar = new TopBar(this);
this.wrapper.prepend(this._topBar._div)
return this._topBar;
}
toJSON() {
// Exclude the chart attribute from serialization
const {chart, ...serialized} = this;
return serialized;
}
public static syncCharts(childChart:Handler, parentChart: Handler, crosshairOnly = false) {
function crosshairHandler(chart: Handler, point: any) {//point: BarData | LineData) {
if (!point) {
chart.chart.clearCrosshairPosition()
return
}
// TODO fix any point ?
chart.chart.setCrosshairPosition(point.value || point!.close, point.time, chart.series);
chart.legend.legendHandler(point, true)
}
function getPoint(series: ISeriesApi<SeriesType>, param: MouseEventParams) {
if (!param.time) return null;
return param.seriesData.get(series) || null;
}
const setChildRange = (timeRange: LogicalRange | null) => { if(timeRange) childChart.chart.timeScale().setVisibleLogicalRange(timeRange); }
const setParentRange = (timeRange: LogicalRange | null) => { if(timeRange) parentChart.chart.timeScale().setVisibleLogicalRange(timeRange); }
const setParentCrosshair = (param: MouseEventParams) => {
crosshairHandler(parentChart, getPoint(childChart.series, param))
}
const setChildCrosshair = (param: MouseEventParams) => {
crosshairHandler(childChart, getPoint(parentChart.series, param))
}
let selected = parentChart
function addMouseOverListener(thisChart: Handler, otherChart: Handler, thisCrosshair: MouseEventHandler<Time>, otherCrosshair: MouseEventHandler<Time>, thisRange: LogicalRangeChangeEventHandler, otherRange: LogicalRangeChangeEventHandler) {
thisChart.wrapper.addEventListener('mouseover', () => {
if (selected === thisChart) return
selected = thisChart
otherChart.chart.unsubscribeCrosshairMove(thisCrosshair)
thisChart.chart.subscribeCrosshairMove(otherCrosshair)
if (crosshairOnly) return;
otherChart.chart.timeScale().unsubscribeVisibleLogicalRangeChange(thisRange)
thisChart.chart.timeScale().subscribeVisibleLogicalRangeChange(otherRange)
})
}
addMouseOverListener(parentChart, childChart, setParentCrosshair, setChildCrosshair, setParentRange, setChildRange)
addMouseOverListener(childChart, parentChart, setChildCrosshair, setParentCrosshair, setChildRange, setParentRange)
parentChart.chart.subscribeCrosshairMove(setChildCrosshair)
if (crosshairOnly) return;
parentChart.chart.timeScale().subscribeVisibleLogicalRangeChange(setChildRange)
}
public static makeSearchBox(chart: Handler) {
const searchWindow = document.createElement('div')
searchWindow.classList.add('searchbox');
searchWindow.style.display = 'none';
const magnifyingGlass = document.createElement('div');
magnifyingGlass.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:lightgray;stroke-opacity:1;stroke-miterlimit:4;" d="M 15 15 L 21 21 M 10 17 C 6.132812 17 3 13.867188 3 10 C 3 6.132812 6.132812 3 10 3 C 13.867188 3 17 6.132812 17 10 C 17 13.867188 13.867188 17 10 17 Z M 10 17 "/></svg>`
const sBox = document.createElement('input');
sBox.type = 'text';
searchWindow.appendChild(magnifyingGlass)
searchWindow.appendChild(sBox)
chart.div.appendChild(searchWindow);
chart.commandFunctions.push((event: KeyboardEvent) => {
console.log('1')
if (window.handlerInFocus !== chart.id) return false
console.log(searchWindow.style)
if (searchWindow.style.display === 'none') {
console.log('3')
if (/^[a-zA-Z0-9]$/.test(event.key)) {
console.log('4')
searchWindow.style.display = 'flex';
sBox.focus();
return true
}
else return false
}
else if (event.key === 'Enter' || event.key === 'Escape') {
if (event.key === 'Enter') window.callbackFunction(`search${chart.id}_~_${sBox.value}`)
searchWindow.style.display = 'none'
sBox.value = ''
return true
}
else return false
})
sBox.addEventListener('input', () => sBox.value = sBox.value.toUpperCase())
return {
window: searchWindow,
box: sBox,
}
}
public static makeSpinner(chart: Handler) {
chart.spinner = document.createElement('div');
chart.spinner.classList.add('spinner');
chart.wrapper.appendChild(chart.spinner)
// TODO below can be css (animate)
let rotation = 0;
const speed = 10;
function animateSpinner() {
if (!chart.spinner) return;
rotation += speed
chart.spinner.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`
requestAnimationFrame(animateSpinner)
}
animateSpinner();
}
private static readonly _styleMap = {
'--bg-color': 'backgroundColor',
'--hover-bg-color': 'hoverBackgroundColor',
'--click-bg-color': 'clickBackgroundColor',
'--active-bg-color': 'activeBackgroundColor',
'--muted-bg-color': 'mutedBackgroundColor',
'--border-color': 'borderColor',
'--color': 'color',
'--active-color': 'activeColor',
}
public static setRootStyles(styles: any) {
const rootStyle = document.documentElement.style;
for (const [property, valueKey] of Object.entries(this._styleMap)) {
rootStyle.setProperty(property, styles[valueKey]);
}
}
}
window.Handler = Handler;

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

@ -0,0 +1,221 @@
import { ISeriesApi, LineData, Logical, MouseEventParams, PriceFormatBuiltIn, SeriesType } from "lightweight-charts";
import { Handler } from "./handler";
interface LineElement {
name: string;
div: HTMLDivElement;
row: HTMLDivElement;
toggle: HTMLDivElement,
series: ISeriesApi<SeriesType>,
solid: string;
}
export class Legend {
private handler: Handler;
public div: HTMLDivElement;
private ohlcEnabled: boolean = false;
private percentEnabled: boolean = false;
private linesEnabled: boolean = false;
private colorBasedOnCandle: boolean = false;
private text: HTMLSpanElement;
private candle: HTMLDivElement;
public _lines: LineElement[] = [];
constructor(handler: Handler) {
this.legendHandler = this.legendHandler.bind(this)
this.handler = handler;
this.ohlcEnabled = true;
this.percentEnabled = false
this.linesEnabled = false
this.colorBasedOnCandle = false
this.div = document.createElement('div');
this.div.classList.add('legend');
this.div.style.maxWidth = `${(handler.scale.width * 100) - 8}vw`
this.text = document.createElement('span')
this.text.style.lineHeight = '1.8'
this.candle = document.createElement('div')
this.div.appendChild(this.text)
this.div.appendChild(this.candle)
handler.div.appendChild(this.div)
// this.makeSeriesRows(handler);
handler.chart.subscribeCrosshairMove(this.legendHandler)
}
toJSON() {
// Exclude the chart attribute from serialization
const {_lines, handler, ...serialized} = this;
return serialized;
}
// makeSeriesRows(handler: Handler) {
// if (this.linesEnabled) handler._seriesList.forEach(s => this.makeSeriesRow(s))
// }
makeSeriesRow(name: string, series: ISeriesApi<SeriesType>) {
const strokeColor = '#FFF';
let openEye = `
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${strokeColor};stroke-opacity:1;stroke-miterlimit:4;" d="M 21.998437 12 C 21.998437 12 18.998437 18 12 18 C 5.001562 18 2.001562 12 2.001562 12 C 2.001562 12 5.001562 6 12 6 C 18.998437 6 21.998437 12 21.998437 12 Z M 21.998437 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${strokeColor};stroke-opacity:1;stroke-miterlimit:4;" d="M 15 12 C 15 13.654687 13.654687 15 12 15 C 10.345312 15 9 13.654687 9 12 C 9 10.345312 10.345312 9 12 9 C 13.654687 9 15 10.345312 15 12 Z M 15 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>\`
`
let closedEye = `
<path style="fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke:${strokeColor};stroke-opacity:1;stroke-miterlimit:4;" d="M 20.001562 9 C 20.001562 9 19.678125 9.665625 18.998437 10.514062 M 12 14.001562 C 10.392187 14.001562 9.046875 13.589062 7.95 12.998437 M 12 14.001562 C 13.607812 14.001562 14.953125 13.589062 16.05 12.998437 M 12 14.001562 L 12 17.498437 M 3.998437 9 C 3.998437 9 4.354687 9.735937 5.104687 10.645312 M 7.95 12.998437 L 5.001562 15.998437 M 7.95 12.998437 C 6.689062 12.328125 5.751562 11.423437 5.104687 10.645312 M 16.05 12.998437 L 18.501562 15.998437 M 16.05 12.998437 C 17.38125 12.290625 18.351562 11.320312 18.998437 10.514062 M 5.104687 10.645312 L 2.001562 12 M 18.998437 10.514062 L 21.998437 12 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
`
let row = document.createElement('div')
row.style.display = 'flex'
row.style.alignItems = 'center'
let div = document.createElement('div')
let toggle = document.createElement('div')
toggle.classList.add('legend-toggle-switch');
let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "22");
svg.setAttribute("height", "16");
let group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = openEye
let on = true
toggle.addEventListener('click', () => {
if (on) {
on = false
group.innerHTML = closedEye
series.applyOptions({
visible: false
})
} else {
on = true
series.applyOptions({
visible: true
})
group.innerHTML = openEye
}
})
svg.appendChild(group)
toggle.appendChild(svg);
row.appendChild(div)
row.appendChild(toggle)
this.div.appendChild(row)
const color = series.options().baseLineColor;
this._lines.push({
name: name,
div: div,
row: row,
toggle: toggle,
series: series,
solid: color.startsWith('rgba') ? color.replace(/[^,]+(?=\))/, '1') : color
});
}
legendItemFormat(num: number, decimal: number) { return num.toFixed(decimal).toString().padStart(8, ' ') }
shorthandFormat(num: number) {
const absNum = Math.abs(num)
if (absNum >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (absNum >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString().padStart(8, ' ');
}
legendHandler(param: MouseEventParams, usingPoint= false) {
if (!this.ohlcEnabled && !this.linesEnabled && !this.percentEnabled) return;
const options: any = this.handler.series.options()
if (!param.time) {
this.candle.style.color = 'transparent'
this.candle.innerHTML = this.candle.innerHTML.replace(options['upColor'], '').replace(options['downColor'], '')
return
}
let data: any;
let logical: Logical | null = null;
if (usingPoint) {
const timeScale = this.handler.chart.timeScale();
let coordinate = timeScale.timeToCoordinate(param.time)
if (coordinate)
logical = timeScale.coordinateToLogical(coordinate.valueOf())
if (logical)
data = this.handler.series.dataByIndex(logical.valueOf())
}
else {
data = param.seriesData.get(this.handler.series);
}
this.candle.style.color = ''
let str = '<span style="line-height: 1.8;">'
if (data) {
if (this.ohlcEnabled) {
str += `O ${this.legendItemFormat(data.open, this.handler.precision)} `
str += `| H ${this.legendItemFormat(data.high, this.handler.precision)} `
str += `| L ${this.legendItemFormat(data.low, this.handler.precision)} `
str += `| C ${this.legendItemFormat(data.close, this.handler.precision)} `
}
if (this.percentEnabled) {
let percentMove = ((data.close - data.open) / data.open) * 100
let color = percentMove > 0 ? options['upColor'] : options['downColor']
let percentStr = `${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %`
if (this.colorBasedOnCandle) {
str += `| <span style="color: ${color};">${percentStr}</span>`
} else {
str += '| ' + percentStr
}
}
if (this.handler.volumeSeries) {
let volumeData: any;
if (logical) {
volumeData = this.handler.volumeSeries.dataByIndex(logical)
}
else {
volumeData = param.seriesData.get(this.handler.volumeSeries)
}
if (volumeData) {
str += this.ohlcEnabled ? `<br>V ${this.shorthandFormat(volumeData.value)}` : ''
}
}
}
this.candle.innerHTML = str + '</span>'
this._lines.forEach((e) => {
if (!this.linesEnabled) {
e.row.style.display = 'none'
return
}
e.row.style.display = 'flex'
let data
if (usingPoint && logical) {
data = e.series.dataByIndex(logical) as LineData
}
else {
data = param.seriesData.get(e.series) as LineData
}
let price;
if (e.series.seriesType() == 'Histogram') {
price = this.shorthandFormat(data.value)
} else {
const format = e.series.options().priceFormat as PriceFormatBuiltIn
price = this.legendItemFormat(data.value, format.precision) // couldn't this just be line.options().precision?
}
e.div.innerHTML = `<span style="color: ${e.solid};">▨</span> ${e.name} : ${price}`
})
}
}

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

@ -0,0 +1,228 @@
:root {
--bg-color:#0c0d0f;
--hover-bg-color: #3c434c;
--click-bg-color: #50565E;
--active-bg-color: rgba(0, 122, 255, 0.7);
--muted-bg-color: rgba(0, 122, 255, 0.3);
--border-color: #3C434C;
--color: #d8d9db;
--active-color: #ececed;
}
body {
background-color: rgb(0,0,0);
color: rgba(19, 23, 34, 1);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
.handler {
display: flex;
flex-direction: column;
position: relative;
}
.toolbox {
position: absolute;
z-index: 2000;
display: flex;
align-items: center;
top: 25%;
border: 2px solid var(--border-color);
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background-color: rgba(25, 27, 30, 0.5);
flex-direction: column;
}
.toolbox-button {
margin: 3px;
border-radius: 4px;
display: flex;
background-color: transparent;
}
.toolbox-button:hover {
background-color: rgba(80, 86, 94, 0.7);
}
.toolbox-button:active {
background-color: rgba(90, 106, 104, 0.7);
}
.active-toolbox-button {
background-color: var(--active-bg-color) !important;
}
.active-toolbox-button g {
fill: var(--active-color);
}
.context-menu {
position: absolute;
z-index: 1000;
background: rgb(50, 50, 50);
color: var(--active-color);
display: none;
border-radius: 5px;
padding: 3px 3px;
font-size: 13px;
cursor: default;
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 10px;
margin: 1px 0px;
border-radius: 3px;
}
.context-menu-item:hover {
background-color: var(--muted-bg-color);
}
.color-picker {
max-width: 170px;
background-color: var(--bg-color);
position: absolute;
z-index: 10000;
display: none;
flex-direction: column;
align-items: center;
border: 2px solid var(--border-color);
border-radius: 8px;
cursor: default;
}
/* topbar-related */
.topbar {
background-color: var(--bg-color);
border-bottom: 2px solid var(--border-color);
display: flex;
align-items: center;
}
.topbar-container {
display: flex;
align-items: center;
flex-grow: 1;
}
.topbar-button {
border: none;
padding: 2px 5px;
margin: 4px 10px;
font-size: 13px;
border-radius: 4px;
color: var(--color);
background-color: transparent;
}
.topbar-button:hover {
background-color: var(--hover-bg-color)
}
.topbar-button:active {
background-color: var(--click-bg-color);
color: var(--active-color);
font-weight: 500;
}
.switcher-button:active {
background-color: var(--click-bg-color);
color: var(--color);
font-weight: normal;
}
.active-switcher-button {
background-color: var(--active-bg-color) !important;
color: var(--active-color) !important;
font-weight: 500;
}
.topbar-textbox {
margin: 0px 18px;
font-size: 16px;
color: var(--color);
}
.topbar-menu {
position: absolute;
display: none;
z-index: 10000;
background-color: var(--bg-color);
border-radius: 2px;
border: 2px solid var(--border-color);
border-top: none;
align-items: flex-start;
max-height: 80%;
overflow-y: auto;
}
.topbar-separator {
width: 1px;
height: 20px;
background-color: var(--border-color);
}
.searchbox {
position: absolute;
top: 0;
bottom: 200px;
left: 0;
right: 0;
margin: auto;
width: 150px;
height: 30px;
padding: 5px;
z-index: 1000;
align-items: center;
background-color: rgba(30 ,30, 30, 0.9);
border: 2px solid var(--border-color);
border-radius: 5px;
display: flex;
}
.searchbox input {
text-align: center;
width: 100px;
margin-left: 10px;
background-color: var(--muted-bg-color);
color: var(--active-color);
font-size: 20px;
border: none;
outline: none;
border-radius: 2px;
}
.spinner {
width: 30px;
height: 30px;
border: 4px solid rgba(255, 255, 255, 0.6);
border-top: 4px solid var(--active-bg-color);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
z-index: 1000;
transform: translate(-50%, -50%);
display: none;
}
.legend {
position: absolute;
z-index: 3000;
pointer-events: none;
top: 10px;
left: 10px;
display: flex;
flex-direction: column;
}
.legend-toggle-switch {
border-radius: 4px;
margin-left: 10px;
pointer-events: auto;
}
.legend-toggle-switch:hover {
cursor: pointer;
background-color: rgba(50, 50, 50, 0.5);
}

199
src/general/table.ts Normal file
View File

@ -0,0 +1,199 @@
import { GlobalParams } from "./global-params";
declare const window: GlobalParams
interface RowDictionary {
[key: number]: HTMLTableRowElement;
}
export class Table {
private _div: HTMLDivElement;
private callbackName: string | null;
private borderColor: string;
private borderWidth: number;
private table: HTMLTableElement;
private rows: RowDictionary = {};
private headings: string[];
private widths: string[];
private alignments: string[];
public footer: HTMLDivElement[] | undefined;
public header: HTMLDivElement[] | undefined;
constructor(width: number, height: number, headings: string[], widths: number[], alignments: string[], position: string, draggable = false,
tableBackgroundColor: string, borderColor: string, borderWidth: number, textColors: string[], backgroundColors: string[]) {
this._div = document.createElement('div')
this.callbackName = null
this.borderColor = borderColor
this.borderWidth = borderWidth
if (draggable) {
this._div.style.position = 'absolute'
this._div.style.cursor = 'move'
} else {
this._div.style.position = 'relative'
this._div.style.float = position
}
this._div.style.zIndex = '2000'
this.reSize(width, height)
this._div.style.display = 'flex'
this._div.style.flexDirection = 'column'
// this._div.style.justifyContent = 'space-between'
this._div.style.borderRadius = '5px'
this._div.style.color = 'white'
this._div.style.fontSize = '12px'
this._div.style.fontVariantNumeric = 'tabular-nums'
this.table = document.createElement('table')
this.table.style.width = '100%'
this.table.style.borderCollapse = 'collapse'
this._div.style.overflow = 'hidden';
this.headings = headings
this.widths = widths.map((width) => `${width * 100}%`)
this.alignments = alignments
let head = this.table.createTHead()
let row = head.insertRow()
for (let i = 0; i < this.headings.length; i++) {
let th = document.createElement('th')
th.textContent = this.headings[i]
th.style.width = this.widths[i]
th.style.letterSpacing = '0.03rem'
th.style.padding = '0.2rem 0px'
th.style.fontWeight = '500'
th.style.textAlign = 'center'
if (i !== 0) th.style.borderLeft = borderWidth+'px solid '+borderColor
th.style.position = 'sticky'
th.style.top = '0'
th.style.backgroundColor = backgroundColors.length > 0 ? backgroundColors[i] : tableBackgroundColor
th.style.color = textColors[i]
row.appendChild(th)
}
let overflowWrapper = document.createElement('div')
overflowWrapper.style.overflowY = 'auto'
overflowWrapper.style.overflowX = 'hidden'
overflowWrapper.style.backgroundColor = tableBackgroundColor
overflowWrapper.appendChild(this.table)
this._div.appendChild(overflowWrapper)
window.containerDiv.appendChild(this._div)
if (!draggable) return
let offsetX: number, offsetY: number;
let onMouseDown = (event: MouseEvent) => {
offsetX = event.clientX - this._div.offsetLeft;
offsetY = event.clientY - this._div.offsetTop;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
let onMouseMove = (event: MouseEvent) => {
this._div.style.left = (event.clientX - offsetX) + 'px';
this._div.style.top = (event.clientY - offsetY) + 'px';
}
let onMouseUp = () => {
// Remove the event listeners for dragging
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
this._div.addEventListener('mousedown', onMouseDown);
}
divToButton(div: HTMLDivElement, callbackString: string) {
div.addEventListener('mouseover', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
div.addEventListener('mouseout', () => div.style.backgroundColor = 'transparent')
div.addEventListener('mousedown', () => div.style.backgroundColor = 'rgba(60, 60, 60)')
div.addEventListener('click', () => window.callbackFunction(callbackString))
div.addEventListener('mouseup', () => div.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
}
newRow(id: number, returnClickedCell=false) {
let row = this.table.insertRow()
row.style.cursor = 'default'
for (let i = 0; i < this.headings.length; i++) {
let cell = row.insertCell()
cell.style.width = this.widths[i];
cell.style.textAlign = this.alignments[i];
cell.style.border = this.borderWidth+'px solid '+this.borderColor
if (returnClickedCell) {
this.divToButton(cell, `${this.callbackName}_~_${id};;;${this.headings[i]}`)
}
}
if (!returnClickedCell) {
this.divToButton(row, `${this.callbackName}_~_${id}`)
}
this.rows[id] = row
}
deleteRow(id: number) {
this.table.deleteRow(this.rows[id].rowIndex)
delete this.rows[id]
}
clearRows() {
let numRows = Object.keys(this.rows).length
for (let i = 0; i < numRows; i++)
this.table.deleteRow(-1)
this.rows = {}
}
private _getCell(rowId: number, column: string) {
return this.rows[rowId].cells[this.headings.indexOf(column)];
}
updateCell(rowId: number, column: string, val: string) {
this._getCell(rowId, column).textContent = val;
}
styleCell(rowId: number, column: string, styleAttribute: string, value: string) {
const style = this._getCell(rowId, column).style;
(style as any)[styleAttribute] = value;
}
makeSection(id: string, type: string, numBoxes: number, func=false) {
let section = document.createElement('div')
section.style.display = 'flex'
section.style.width = '100%'
section.style.padding = '3px 0px'
section.style.backgroundColor = 'rgb(30, 30, 30)'
type === 'footer' ? this._div.appendChild(section) : this._div.prepend(section)
const textBoxes = []
for (let i = 0; i < numBoxes; i++) {
let textBox = document.createElement('div')
section.appendChild(textBox)
textBox.style.flex = '1'
textBox.style.textAlign = 'center'
if (func) {
this.divToButton(textBox, `${id}_~_${i}`)
textBox.style.borderRadius = '2px'
}
textBoxes.push(textBox)
}
if (type === 'footer') {
this.footer = textBoxes;
}
else {
this.header = textBoxes;
}
}
reSize(width: number, height: number) {
this._div.style.width = width <= 1 ? width * 100 + '%' : width + 'px'
this._div.style.height = height <= 1 ? height * 100 + '%' : height + 'px'
}
}

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

@ -0,0 +1,203 @@
import { DrawingTool } from "../drawing/drawing-tool";
import { TrendLine } from "../trend-line/trend-line";
import { Box } from "../box/box";
import { Drawing } from "../drawing/drawing";
import { ContextMenu } from "../context-menu/context-menu";
import { GlobalParams } from "./global-params";
import { StylePicker } from "../context-menu/style-picker";
import { ColorPicker } from "../context-menu/color-picker";
import { IChartApi, ISeriesApi, SeriesType } from "lightweight-charts";
import { TwoPointDrawing } from "../drawing/two-point-drawing";
import { HorizontalLine } from "../horizontal-line/horizontal-line";
import { RayLine } from "../horizontal-line/ray-line";
interface Icon {
div: HTMLDivElement,
group: SVGGElement,
type: new (...args: any[]) => Drawing
}
declare const window: GlobalParams
export class ToolBox {
private static readonly TREND_SVG: string = '<rect x="3.84" y="13.67" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -5.9847 14.4482)" width="21.21" height="1.56"/><path d="M23,3.17L20.17,6L23,8.83L25.83,6L23,3.17z M23,7.41L21.59,6L23,4.59L24.41,6L23,7.41z"/><path d="M6,20.17L3.17,23L6,25.83L8.83,23L6,20.17z M6,24.41L4.59,23L6,21.59L7.41,23L6,24.41z"/>';
private static readonly HORZ_SVG: string = '<rect x="4" y="14" width="9" height="1"/><rect x="16" y="14" width="9" height="1"/><path d="M11.67,14.5l2.83,2.83l2.83-2.83l-2.83-2.83L11.67,14.5z M15.91,14.5l-1.41,1.41l-1.41-1.41l1.41-1.41L15.91,14.5z"/>';
private static readonly RAY_SVG: string = '<rect x="8" y="14" width="17" height="1"/><path d="M3.67,14.5l2.83,2.83l2.83-2.83L6.5,11.67L3.67,14.5z M7.91,14.5L6.5,15.91L5.09,14.5l1.41-1.41L7.91,14.5z"/>';
private static readonly BOX_SVG: string = '<rect x="8" y="6" width="12" height="1"/><rect x="9" y="22" width="11" height="1"/><path d="M3.67,6.5L6.5,9.33L9.33,6.5L6.5,3.67L3.67,6.5z M7.91,6.5L6.5,7.91L5.09,6.5L6.5,5.09L7.91,6.5z"/><path d="M19.67,6.5l2.83,2.83l2.83-2.83L22.5,3.67L19.67,6.5z M23.91,6.5L22.5,7.91L21.09,6.5l1.41-1.41L23.91,6.5z"/><path d="M19.67,22.5l2.83,2.83l2.83-2.83l-2.83-2.83L19.67,22.5z M23.91,22.5l-1.41,1.41l-1.41-1.41l1.41-1.41L23.91,22.5z"/><path d="M3.67,22.5l2.83,2.83l2.83-2.83L6.5,19.67L3.67,22.5z M7.91,22.5L6.5,23.91L5.09,22.5l1.41-1.41L7.91,22.5z"/><rect x="22" y="9" width="1" height="11"/><rect x="6" y="9" width="1" height="11"/>';
// private static readonly VERT_SVG: string = '';
div: HTMLDivElement;
private activeIcon: Icon | null = null;
private buttons: HTMLDivElement[] = [];
private _commandFunctions: Function[];
private _handlerID: string;
private _drawingTool: DrawingTool;
constructor(handlerID: string, chart: IChartApi, series: ISeriesApi<SeriesType>, commandFunctions: Function[]) {
this._handlerID = handlerID;
this._commandFunctions = commandFunctions;
this._drawingTool = new DrawingTool(chart, series, () => this.removeActiveAndSave());
this.div = this._makeToolBox()
this._makeContextMenu();
commandFunctions.push((event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') {
const drawingToDelete = this._drawingTool.drawings.pop();
if (drawingToDelete) this._drawingTool.delete(drawingToDelete)
return true;
}
return false;
});
}
toJSON() {
// Exclude the chart attribute from serialization
const { ...serialized} = this;
return serialized;
}
private _makeToolBox() {
let div = document.createElement('div')
div.classList.add('toolbox');
this.buttons.push(this._makeToolBoxElement(TrendLine, 'KeyT', ToolBox.TREND_SVG))
this.buttons.push(this._makeToolBoxElement(HorizontalLine, 'KeyH', ToolBox.HORZ_SVG));
this.buttons.push(this._makeToolBoxElement(RayLine, 'KeyR', ToolBox.RAY_SVG));
this.buttons.push(this._makeToolBoxElement(Box, 'KeyB', ToolBox.BOX_SVG));
for (const button of this.buttons) {
div.appendChild(button);
}
return div
}
private _makeToolBoxElement(DrawingType: new (...args: any[]) => Drawing, keyCmd: string, paths: string) {
const elem = document.createElement('div')
elem.classList.add("toolbox-button");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "29");
svg.setAttribute("height", "29");
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = paths
group.setAttribute("fill", window.pane.color)
svg.appendChild(group)
elem.appendChild(svg);
const icon: Icon = {div: elem, group: group, type: DrawingType}
elem.addEventListener('click', () => this._onIconClick(icon));
this._commandFunctions.push((event: KeyboardEvent) => {
if (this._handlerID !== window.handlerInFocus) return false;
if (event.altKey && event.code === keyCmd) {
event.preventDefault()
this._onIconClick(icon);
return true
}
return false;
})
return elem
}
private _onIconClick(icon: Icon) {
if (this.activeIcon) {
this.activeIcon.div.classList.remove('active-toolbox-button');
window.setCursor('crosshair');
this._drawingTool?.stopDrawing()
if (this.activeIcon === icon) {
this.activeIcon = null
return
}
}
this.activeIcon = icon
this.activeIcon.div.classList.add('active-toolbox-button')
window.setCursor('crosshair');
this._drawingTool?.beginDrawing(this.activeIcon.type);
}
removeActiveAndSave() {
window.setCursor('default');
if (this.activeIcon) this.activeIcon.div.classList.remove('active-toolbox-button')
this.activeIcon = null
this.saveDrawings()
}
private _makeContextMenu() {
const contextMenu = new ContextMenu()
const colorPicker = new ColorPicker(this.saveDrawings)
const stylePicker = new StylePicker(this.saveDrawings)
let onClickDelete = () => this._drawingTool.delete(Drawing.lastHoveredObject);
let onClickColor = (rect: DOMRect) => colorPicker.openMenu(rect)
let onClickStyle = (rect: DOMRect) => stylePicker.openMenu(rect)
contextMenu.menuItem('Color Picker', onClickColor, () => {
document.removeEventListener('click', colorPicker.closeMenu)
colorPicker._div.style.display = 'none'
})
contextMenu.menuItem('Style', onClickStyle, () => {
document.removeEventListener('click', stylePicker.closeMenu)
stylePicker._div.style.display = 'none'
})
contextMenu.separator()
contextMenu.menuItem('Delete Drawing', onClickDelete)
}
// renderDrawings() {
// if (this.mouseDown) return
// this.drawings.forEach((item) => {
// if ('price' in item) return
// let startDate = Math.round(item.from[0]/this.chart.interval)*this.chart.interval
// let endDate = Math.round(item.to[0]/this.chart.interval)*this.chart.interval
// item.calculateAndSet(startDate, item.from[1], endDate, item.to[1])
// })
// }
clearDrawings() {
this._drawingTool.clearDrawings();
}
saveDrawings() {
const drawingMeta = []
for (const d of this._drawingTool.drawings) {
if (d instanceof TwoPointDrawing) {
drawingMeta.push({
type: d._type,
p1: d._p1,
p2: d._p2,
color: d._options.lineColor,
style: d._options.lineStyle, // TODO should push all options, just dont have showcircles/ non public stuff as actual options
// would also fix the instanceOf in loadDrawings
})
}
// TODO else if d instanceof Drawing
}
const string = JSON.stringify(drawingMeta);
window.callbackFunction(`save_drawings${this._handlerID}_~_${string}`)
}
loadDrawings(drawings: any[]) { // TODO any?
drawings.forEach((d) => {
const options = {
lineColor: d.color,
lineStyle: d.style,
}
switch (d.type) {
case "Box":
this._drawingTool.addNewDrawing(new Box(d.p1, d.p2, options));
break;
case "TrendLine":
this._drawingTool.addNewDrawing(new TrendLine(d.p1, d.p2, options));
break;
// TODO case HorizontalLine
}
})
}
}

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

@ -0,0 +1,171 @@
import { GlobalParams } from "./global-params";
import { Handler } from "./handler";
declare const window: GlobalParams
interface Widget {
elem: HTMLDivElement;
callbackName: string;
intervalElements: HTMLButtonElement[];
onItemClicked: Function;
}
export class TopBar {
private _handler: Handler;
public _div: HTMLDivElement;
private left: HTMLDivElement;
private right: HTMLDivElement;
constructor(handler: Handler) {
this._handler = handler;
this._div = document.createElement('div');
this._div.classList.add('topbar');
const createTopBarContainer = (justification: string) => {
const div = document.createElement('div')
div.classList.add('topbar-container')
div.style.justifyContent = justification
this._div.appendChild(div)
return div
}
this.left = createTopBarContainer('flex-start')
this.right = createTopBarContainer('flex-end')
}
makeSwitcher(items: string[], defaultItem: string, callbackName: string, align='left') {
const switcherElement = document.createElement('div');
switcherElement.style.margin = '4px 12px'
let activeItemEl: HTMLButtonElement;
const createAndReturnSwitcherButton = (itemName: string) => {
const button = document.createElement('button');
button.classList.add('topbar-button');
button.classList.add('switcher-button');
button.style.margin = '0px 2px';
button.innerText = itemName;
if (itemName == defaultItem) {
activeItemEl = button;
button.classList.add('active-switcher-button');
}
const buttonWidth = TopBar.getClientWidth(button)
button.style.minWidth = buttonWidth + 1 + 'px'
button.addEventListener('click', () => widget.onItemClicked(button))
switcherElement.appendChild(button);
return button;
}
const widget: Widget = {
elem: switcherElement,
callbackName: callbackName,
intervalElements: items.map(createAndReturnSwitcherButton),
onItemClicked: (item: HTMLButtonElement) => {
if (item == activeItemEl) return
activeItemEl.classList.remove('active-switcher-button');
item.classList.add('active-switcher-button');
activeItemEl = item;
window.callbackFunction(`${widget.callbackName}_~_${item.innerText}`);
}
}
this.appendWidget(switcherElement, align, true)
return widget
}
makeTextBoxWidget(text: string, align='left') {
const textBox = document.createElement('div');
textBox.classList.add('topbar-textbox');
textBox.innerText = text
this.appendWidget(textBox, align, true)
return textBox
}
makeMenu(items: string[], activeItem: string, separator: boolean, callbackName: string, align='right') {
let menu = document.createElement('div')
menu.classList.add('topbar-menu');
let menuOpen = false;
items.forEach(text => {
let button = this.makeButton(text, null, false, false)
button.elem.addEventListener('click', () => {
widget.elem.innerText = button.elem.innerText+' ↓'
window.callbackFunction(`${callbackName}_~_${button.elem.innerText}`)
menu.style.display = 'none'
menuOpen = false
});
button.elem.style.margin = '4px 4px'
button.elem.style.padding = '2px 2px'
menu.appendChild(button.elem)
})
let widget =
this.makeButton(activeItem+' ↓', null, separator, true, align)
widget.elem.addEventListener('click', () => {
menuOpen = !menuOpen
if (!menuOpen) {
menu.style.display = 'none';
return;
}
let rect = widget.elem.getBoundingClientRect()
menu.style.display = 'flex'
menu.style.flexDirection = 'column'
let center = rect.x+(rect.width/2)
menu.style.left = center-(menu.clientWidth/2)+'px'
menu.style.top = rect.y+rect.height+'px'
})
document.body.appendChild(menu)
}
makeButton(defaultText: string, callbackName: string | null, separator: boolean, append=true, align='left') {
let button = document.createElement('button')
button.classList.add('topbar-button');
// button.style.color = window.pane.color
button.innerText = defaultText;
document.body.appendChild(button)
button.style.minWidth = button.clientWidth+1+'px'
document.body.removeChild(button)
let widget = {
elem: button,
callbackName: callbackName
}
if (callbackName) {
button.addEventListener('click', () => window.callbackFunction(`${widget.callbackName}_~_${button.innerText}`));
}
if (append) this.appendWidget(button, align, separator)
return widget
}
makeSeparator(align='left') {
const separator = document.createElement('div')
separator.classList.add('topbar-seperator')
const div = align == 'left' ? this.left : this.right
div.appendChild(separator)
}
appendWidget(widget: HTMLElement, align: string, separator: boolean) {
const div = align == 'left' ? this.left : this.right
if (separator) {
if (align == 'left') div.appendChild(widget)
this.makeSeparator(align)
if (align == 'right') div.appendChild(widget)
} else div.appendChild(widget)
this._handler.reSize();
}
private static getClientWidth(element: HTMLElement) {
document.body.appendChild(element);
const width = element.clientWidth;
document.body.removeChild(element);
return width;
}
}