Files
web/src/charts/shape.js
2025-03-19 21:04:24 -04:00

668 lines
24 KiB
JavaScript

// noinspection JSPotentiallyInvalidUsageOfThis
import {invokeCallback, mixin} from "@/common.js";
import {chart, createShape, deleteShapeId, dragging, draggingShapeIds, drawShape, widget} from "@/charts/chart.js";
import Color from "color";
import {dirtyItems, dirtyPoints, nearestOhlcStart} from "@/charts/chart-misc.js";
import {defined, toPrecision} from "@/misc.js";
//
// Usage of Shapes:
// const shape = new Shape(ShapeType.HLine)
// shape.draw()
// shape.points
// > [{time:17228394, price:42638.83}]
//
// shape.model stores the shape-specific control points, as defined by the shape subclass, and may be a vue ref.
// Use the shape.model.* fields in components to get reactive effects with the chart, or pass an onModel callback
// to the constructor
//
export const ShapeType = {
// this "enum" is a record of the TradingView keystrings
/*
The `name` field must match the user-facing name in the TradingView toolbar UI
TradingView drawing tool codes:
https://www.tradingview.com/charting-library-docs/latest/api/modules/Charting_Library/#supportedlinetools
text anchored_text note anchored_note signpost double_curve arc icon emoji sticker arrow_up arrow_down arrow_left arrow_right price_label price_note arrow_marker flag vertical_line horizontal_line cross_line horizontal_ray trend_line info_line trend_angle arrow ray extended parallel_channel disjoint_angle flat_bottom anchored_vwap pitchfork schiff_pitchfork_modified schiff_pitchfork balloon comment inside_pitchfork pitchfan gannbox gannbox_square gannbox_fixed gannbox_fan fib_retracement fib_trend_ext fib_speed_resist_fan fib_timezone fib_trend_time fib_circles fib_spiral fib_speed_resist_arcs fib_channel xabcd_pattern cypher_pattern abcd_pattern callout triangle_pattern 3divers_pattern head_and_shoulders fib_wedge elliott_impulse_wave elliott_triangle_wave elliott_triple_combo elliott_correction elliott_double_combo cyclic_lines time_cycles sine_line long_position short_position forecast date_range price_range date_and_price_range bars_pattern ghost_feed projection rectangle rotated_rectangle circle ellipse triangle polyline path curve cursor dot arrow_cursor eraser measure zoom brush highlighter regression_trend fixed_range_volume_profile
*/
Segment: {name: 'Trend Line', code: 'trend_line'},
Ray: {name: 'Ray', code: 'ray', drawingProp: 'linetoolray'},
Line: {name: 'Extended Line', code: 'extended', drawingProp: 'linetoolray'},
HRay: {name: 'Horizontal Ray', code: 'horizontal_ray'},
HLine: {name: 'Horizontal Line', code: 'horizontal_line', drawingProp: 'linetoolhorzline'},
VLine: {name: 'Vertical Line', code: 'vertical_line', drawingProp: 'linetoolvertline'},
PriceRange: {name: 'Price Range', code: 'price_range'},
DateRange: {name: 'Date Range', code: 'date_range', drawingProp: 'linetooldaterange'},
}
export function allocationText(buy, weight, amount, baseSymbol, amountSymbol = null, parts = 1, separator = ' ') {
const hasAmount = amount !== null && amount !== undefined && amount > 0
if (hasAmount)
amount = Number(amount)
const hasWeight = weight !== null && weight !== undefined && weight !== 1
if (hasWeight)
weight = Number(weight)
let text = buy === undefined ? '' : buy ? 'Buy ' : 'Sell '
if (hasWeight)
text += `${(weight * 100).toFixed(1)}%`
const hasSymbol = baseSymbol !== null && baseSymbol !== undefined
if (hasAmount && hasSymbol) {
if (hasWeight)
text += separator
if (amountSymbol!==null && amountSymbol!==baseSymbol)
text += `${baseSymbol} worth ${toPrecision(amount,3)} ${amountSymbol}`
else
text += `${toPrecision(amount,3)} ${baseSymbol}`
}
if (parts > 1)
text += separator + `in ${Math.round(parts)} parts`
return text
}
export class Shape {
constructor(type, onModel=null, onDelete=null, props=null, readonly=false, overrides={}) {
// the Shape object manages synchronizing internal data with a corresponding TradingView shape
// each shape in the class hierarchy overrides setModel() to cause effects in TradingView
// TV callbacks are returned to the on*() handlers, primarily onPoints() and onProps(). Handlers
// of TV callbacks should call updateModel() with just the kv's they want to change. Any changes
// are then passed onto the onModel() callback.
// the model object has various attributes defined by each shape subclass. each subclass's constructor
// must call setModel(model) after first calling super.constructor() and optionally declaring any model
// defaults on this.model.
// Shape: {
// lineColor, textColor, color, // lineColor and textColor default to color
// }
this.debug = false
this.id = null // TradingView shapeId, or null if no TV shape created yet (drawing mode)
this.type = type // ShapeType
this.model = {} // set to nothing at first
this.tvCallbacks = null
this.ourPoints = null
this.tvPoints = null
this.creationOptions = readonly ?
{disableSave:true, disableUndo:true, disableSelection:true, lock:true} :
{disableSave:true, disableUndo:true, disableSelection:false}
this.creationOptions.overrides = overrides
this.ourProps = {}
if (props !== null)
this.ourProps = mixin(props, this.ourProps)
this.tvProps = {}
if (onModel !== null)
this.onModel = onModel
if (onDelete !== null )
this.onDelete = onDelete
//
// Model values handled by this base class
//
this.model.color = null
// if allocation is set, a percentage is displayed
this.model.allocation = null
// if maxAllocation is set, the line color (but not text color) is shaded down based on allocation
this.model.maxAllocation = null
// both amount and amountSymbol must be set in order to display amount text
this.model.amount = null
this.model.baseSymbol = null
this.model.amountSymbol = null
this.model.extraText = null
this.model.textLocation = null // defaults to 'above' if not set
// LEAF SUBCLASSES MUST CALL setModel(model) AFTER ALL CONSTRUCTION.
}
//
// primary interface methods
//
setModel(model) {
if (defined(model.color))
this.model.color = model.color
if (defined(model.allocation))
this.model.allocation = model.allocation
if (defined(model.maxAllocation))
this.model.maxAllocation = model.maxAllocation
if (defined(model.amount))
this.model.amount = model.amount
if (defined(model.amountSymbol))
this.model.amountSymbol = model.amountSymbol
if (defined(model.baseSymbol))
this.model.baseSymbol = model.baseSymbol
if (defined(model.extraText))
this.model.extraText = model.extraText
if (defined(model.breakout))
this.model.breakout = model.breakout
if (defined(model.textLocation))
this.model.textLocation = model.textLocation
if (defined(model.buy))
this.model.buy = model.buy
const newProps = {}
// text color
const color = this.model.color ? this.model.color : '#0044ff'
newProps.textcolor = color
// line color
if (defined(this.model.allocation) && defined(this.model.maxAllocation)) {
const w = this.model.allocation / this.model.maxAllocation
if (!w)
newProps.linecolor = 'rgba(0,0,0,0)'
else {
// weighted line color
let c = new Color(color).rgb()
// the perceptual color of a weight follows Stevens's Power Law
// https://en.wikipedia.org/wiki/Stevens's_power_law
newProps.linecolor = c.alpha(Math.pow(w,0.67)).string()
}
}
else
newProps.linecolor = color
// text label
let text = allocationText(this.model.buy, this.model.allocation, this.model.amount, this.model.baseSymbol, this.model.amountSymbol)
if (this.model.breakout)
text += ' ' + (this.model.textLocation==='above' ? '▲Breakout▲' : '▼Breakout▼')
if (this.model.extraText)
text += ' '+this.model.extraText
if (this.debug) text = `${this.id} ` + text
if (!text.length)
newProps.showLabel = false
else {
newProps.text = text
newProps.showLabel = true
newProps.textLocation = this.model.textLocation
newProps.vertLabelsAlign =
this.model.textLocation === 'above' ? 'bottom' :
this.model.textLocation === 'below' ? 'top' : null;
}
if (this.debug && this.id)
console.log('newProps', this.id, chart.getShapeById(this.id).getProperties(), newProps)
this.setProps(newProps)
}
onModel(model, oldModel, changedKeys) {} // model was changed by a TradingView user action
updateModel(changes) {
const oldModel = {...this.model}
if (this.debug) console.log('updateModel', this.id, changes)
const changedKeys = []
for (const k of Object.keys(changes)) {
if( changes[k] !== undefined && changes[k] !== this.model[k] ) {
changedKeys.push(k)
this.model[k] = changes[k]
}
}
if (changedKeys.length)
this.onModel(this.model, oldModel, changedKeys)
}
colorProps() {
if (!this.model.color&&!this.model.lineColor&&!this.model.textColor) return null
const o = {}
const p = this.type.drawingProp
const lc = this.model.lineColor ? this.model.lineColor : this.model.color;
const tc = this.model.textColor ? this.model.textColor : this.model.color;
const tl = this.model.textLocation ? this.model.textLocation : 'above';
if (lc)
o[p+".linecolor"] = lc
if (tc)
o[p+".textcolor"] = tc
if (tl)
o[p+".textlocation"] = tl
return o
}
draw() {
// have the user draw this shape
if (this.debug) console.log(`draw ${this.type.name}`, this.model)
if (this.id)
throw Error(`Shape already exists ${this.id}`)
const or = this.drawingOverrides();
// if (this.debug) console.log('drawing overrides', or)
widget.applyOverrides(or)
drawShape(this.type, new ShapeTVCallbacks(this))
}
// return an object with property defaults, e.g. { "linetoolhorzline.linecolor": "#7f11e0" }
// https://www.tradingview.com/charting-library-docs/latest/api/modules/Charting_Library#drawingoverrides
drawingOverrides() {
return mixin(this.colorProps(), this.creationOptions)
}
create() {
if (this.id !== null) return
// programatically create the shape using the current this.points
if( this.ourPoints && this.ourPoints.length ) {
this.doCreate(this.ourPoints, this.ourProps, this.creationOptions)
}
}
doCreate(points, props, options={}) {
// createShape(this.type, this.points, {overrides:this.props}, new ShapeTVCallbacks(this))
options = mixin(options, this.drawingOverrides())
options['overrides'] = props
this.tvPoints = this.pointsToTvOhlcStart(points)
this.tvCallbacks = new ShapeTVCallbacks(this);
const id = createShape(this.type, this.tvPoints, options, this.tvCallbacks)
// todo set id?
if (this.debug) console.log('created', id, this.type.name, this.tvPoints)
}
onCreate(points, props) {
// the user has finished creating all the control points. drawing mode is exited and the initial shape is created.
this.setPoints(points)
this.setProps(props)
}
createOrDraw() {
if(this.id) return
if(this.ourPoints && this.ourPoints.length)
this.create()
else
this.draw()
}
tvShape() {
try {
return this.id === null ? null : chart.getShapeById(this.id);
}
catch (e) {
console.error(`Could not get chart shape ${this.id}`)
return null
}
}
setPoints(points) {
// setting points to null will delete the shape from the chart. setting points to a valid value will cause the
// shape to be drawn.
if (this.debug) console.error('setPoints', this.id, this.ourPoints, points)
this.ourPoints = points
if (points === null || !points.length)
this.delete()
else {
if (this.id === null)
this.create()
else {
points = this.pointsToTvOhlcStart(points)
if (dirtyPoints(this.tvPoints, points)) {
/*
setPoints(e) {
if (this._source.isFixed())
return;
const t = o(this._source);
if (t !== e.length)
throw new Error(`Wrong points count. Required: ${t}, provided: ${e.length}`);
const i = this._pointsConverter.apiPointsToDataSource(e);
this._model.startChangingLinetool(this._source),
this._model.changeLinePoints(this._source, i),
this._model.endChangingLinetool(!0),
this._source.createServerPoints()
}
*/
const s = this.tvShape();
const lbe = s._model._lineBeingEdited
const lpbc = s._model._linePointBeingChanged
const lpbe = s._model._linePointBeingEdited
if (lpbc!==null)
s._model.endChangingLinetool(!0)
// s._source.createServerPoints() // todo necessary?
s.setPoints(points)
if (lpbc!==null)
s._model.startChangingLinetool(lbe, lpbc, lpbe)
}
}
}
}
// diagonals need to override this to adjust their price as well.
pointsToTvOhlcStart(points, periodSeconds = null) {
return points === null ? null : points.map((p) => {
return {time: nearestOhlcStart(p.time, periodSeconds), price: p.price}
})
}
onPoints(points) {} // the control points of an existing shape were changed
onDrag(points) { console.log('shape ondrag'); this.onPoints(points) }
setProps(props) {
if (!props || Object.keys(props).length===0) return
if (this.debug) console.log('setProps', this.id, props)
this.ourProps = mixin(props, this.ourProps)
if(this.id) {
const p = dirtyItems(this.tvProps, props)
this.tvProps = this.tvProps === null ? p : mixin(p, this.tvProps)
if (this.debug) console.log('setting tv props', this.tvProps)
this.tvShape().setProperties(p)
}
}
onProps(props) { // the display properties of an existing shape were changed
if (this.debug) console.log('shape onProps', this.id, this.model, props)
if (props.textcolor && typeof props.textcolor !== 'object' && props.textcolor !== this.tvProps.textcolor)
this.updateModel({color:props.textcolor})
else if (props.linecolor && typeof props.linecolor !== 'object' && props.linecolor !== this.tvProps.linecolor)
this.updateModel({color:props.linecolor})
}
beingDragged() {
return draggingShapeIds.indexOf(this.id) !== -1
}
delete() {
if (this.debug) console.log('shape.delete', this.id)
this.ourPoints = null
this.tvPoints = null
this.tvProps = null
if (this.id === null) return
if (this.tvCallbacks !== null) {
this.tvCallbacks.enabled = false
this.tvCallbacks = null
}
deleteShapeId(this.id)
this.id = null
}
//
// Overridable shape callbacks initiated by TradingView
//
onDraw() {} // start drawing a new shape for the builder
onRedraw() {} // the mouse moved while in drawing mode
onUndraw() {} // drawing was canceled by clicking on a different tool
onAddPoint() {} // the user clicked a point while drawing (that point is added to the points list)
onMove(points) {} // the shape was moved by dragging a drawing element not the control point
onHide(props) {}
onShow(props) {}
onClick() {} // the shape was selected
onDelete() {}
}
class ShapeTVCallbacks {
// These methods are called by TradingView and provide some default handling before invoking our own Shape callbacks
constructor(shape) {
this.shape = shape
this.enabled = true // gets disabled when the shape is deleted, squelching any further callbacks
}
onCreate(shapeId, _tvShape, points, props) {
if (!this.enabled) return // possible when shape is deleted and re-created
this.shape.id = shapeId
invokeCallback(this.shape, 'onCreate', points, props)
invokeCallback(this.shape, 'onPoints', points)
}
onPoints(shapeId, _tvShape, points) {
if (!this.enabled) return // possible when shape is deleted and re-created
if (this.shape.debug) console.log('tvcb onPoints', shapeId, points)
if (!dragging || this.shape.beingDragged())
this.shape.onPoints(points)
this.shape.tvPoints = points
}
onProps(shapeId, _tvShape, props) {
if (!this.enabled) return // possible when shape is deleted and re-created
if (this.shape.debug) console.log('tvOnProps', shapeId, props)
if (!dragging) // do not listen to props during redraw
this.shape.onProps(props)
this.shape.tvProps = props
}
onDraw() {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onDraw')
}
onRedraw() {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onRedraw')
}
onUndraw() {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onUndraw')
}
onAddPoint() {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onAddPoint')
}
onMove(shapeId, _tvShape, points) {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onMove',points)
}
onDrag(shapeId, _tvShape, points) {
if (!this.enabled) return // possible when shape is deleted and re-created
if (this.shape.debug) console.log('onDrag', shapeId)
invokeCallback(this.shape, 'onDrag', points)
}
onHide(shapeId, _tvShape, props) {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onHide',props)
}
onShow(shapeId, _tvShape, props) {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onShow',props)
}
onClick(shapeId, _tvShape) {
if (!this.enabled) return // possible when shape is deleted and re-created
invokeCallback(this.shape, 'onClick')
}
onDelete(shapeId) {
this.enabled = false
this.shape.id = null
invokeCallback(this.shape, 'onDelete', shapeId)
}
}
export class Line extends Shape {
}
export class HLine extends Line {
constructor(model, onModel=null, onDelete=null, props=null, readonly=false) {
super(ShapeType.HLine, onModel, onDelete, props, readonly, {
// todo this isnt working
linestyle: 0,
linewidth: 2,
italic: false,
})
// Model
this.model.price = null
this.setModel(model) // call setModel at the end
}
delete() {
this.model.price = null
super.delete()
}
setModel(model) {
super.setModel(model)
if (model.price === null) {
this.model.price = null
this.delete()
}
else if (model.price !== this.model.price) {
this.model.price = model.price
this.setPoints([{time:0,price:this.model.price}])
}
}
onPoints(points) {
super.onPoints(points);
const price = points[0].price;
this.updateModel({price})
}
}
export class VLine extends Line {
constructor(model, onModel=null, onDelete=null, props=null) {
super(ShapeType.VLine, onModel, onDelete, props)
// Model
this.model.time = null
this.setModel(model) // call setModel at the end
}
delete() {
this.model.time = null
super.delete()
}
setModel(model) {
// if (this.debug) console.error('VLine setModel', this.id, {...this.model}, model)
super.setModel(model)
if (model.time === null)
this.delete()
else if (model.time !== this.model.time) {
this.model.time = model.time
this.setPoints([{time: model.time, price: 0}])
}
}
onPoints(points) {
const time = points[0].time;
if ( time !== this.tvPoints[0].time ) {
super.onPoints(points);
this.updateModel({time: time})
}
}
}
export class DLine extends Line {
constructor(model, onModel=null, onDelete=null, props=null, readonly=false) {
super(ShapeType.Ray, onModel, onDelete, props, readonly,{
// todo style overrides
})
// Model
this.model.pointA = null // {time:..., price:...}
this.model.pointB = null
this.model.extendLeft = true
this.model.extendRight = true
this.setModel(model) // call setModel at the end
}
drawingOverrides() {
const result = super.drawingOverrides();
result.extendLeft = this.model.extendLeft
result.extendRight = this.model.extendRight
return result
}
delete() {
this.model.pointA = null
this.model.pointB = null
super.delete();
}
setModel(model) {
super.setModel(model)
if (model.pointA === null && model.pointB === null)
this.delete()
else {
this.setProps({extendLeft:model.extendLeft, extendRight: model.extendRight})
if ('pointA' in model && 'pointB' in model) {
const newPoints = [model.pointA, model.pointB];
const oldPoints = [this.model.pointA, this.model.pointB];
if (dirtyPoints(oldPoints, newPoints)) {
this.model.pointA = newPoints[0]
this.model.pointB = newPoints[1]
this.setPoints(newPoints)
}
}
}
}
onPoints(points) {
let dirty = this.tvPoints === null
for (let i=0; !dirty && i<points.length; i++) {
if( points[i].time !== this.tvPoints[i].time || points[i].price !== this.tvPoints[i].price )
dirty = true
}
if (dirty) {
super.onPoints(points);
this.updateModel({pointA: points[0], pointB: points[1]})
}
}
onProps(props) {
super.onProps(props);
this.updateModel({extendLeft: props.extendLeft, extendRight: props.extendRight})
}
/* todo tim
pointsToTvOhlcStart(points, periodSeconds = null) {
if (points === null) return null
const [v,w] = points
const [b,m] = computeInterceptSlope(v.time, v.price, w.time, w.price)
points.map((p) => {
let {time, price} = p
const aligned = nearestOhlcStart(time, periodSeconds);
if (time !== aligned) {
time = aligned
price = b + m * aligned
}
return {time, price}
})
}
*/
}
export class DateRange extends Shape {
constructor(model, onModel=null, onDelete=null, props=null) {
super(ShapeType.DateRange, onModel, onDelete, props)
}
setModel(model) {
super.setModel(model);
if (model.startTime !== this.model.startTime || model.endTime !== this.model.endTime)
this.setPoints([{time: model.startTime}, {time: model.endTime}])
}
}