// 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, pointsToTvOhlcStart} from "@/charts/chart-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'}, } export function allocationText(weight, amount, symbol, separator = ' ') { const hasAmount = amount !== null && amount !== undefined && amount > 0 if (hasAmount) amount = Number(amount) const hasWeight = weight !== null && weight !== undefined if (hasWeight) weight = Number(weight) let text = '' if (hasWeight) text += `${(weight * 100).toFixed(1)}%` const hasSymbol = symbol !== null && symbol !== undefined if (hasAmount && hasSymbol) { if (hasWeight) text += separator text += `${amount.toPrecision(3).toLocaleString('fullwide')} ${symbol}` } 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.amountSymbol = null // LEAF SUBCLASSES MUST CALL setModel(model) AFTER ALL CONSTRUCTION. } // // primary interface methods // setModel(model) { if (model.color) this.model.color = model.color if (model.allocation !== null && model.allocation !== undefined) this.model.allocation = model.allocation if (model.maxAllocation !== null && model.maxAllocation !== undefined) this.model.maxAllocation = model.maxAllocation if (model.amount !== null && model.amount !== undefined) this.model.amount = model.amount if (model.amountSymbol) this.model.amountSymbol = model.amountSymbol const newProps = {} // text color const color = this.model.color ? this.model.color : '#0044ff' newProps.textcolor = color // line color if (this.model.allocation && 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.allocation, this.model.amount, this.model.amountSymbol) if (this.debug) text = `${this.id} ` + text if (!text.length) newProps.showLabel = false else { newProps.text = text newProps.showLabel = true } 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; if (lc) o[p+".linecolor"] = lc if (tc) o[p+".textcolor"] = tc 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 this.colorProps() } 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 = 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 = 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) } } } } onPoints(points) {} // the control points of an existing shape were changed 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 onDrag(points) {} 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 { onDrag(points) { const s = this.tvShape(); if (this.debug) { console.log('shape', s.id, s) console.log('currentMovingPoint', s._source.currentMovingPoint()) console.log('startMovingPoint', s._source.startMovingPoint()) console.log('isBeingEdited', s._source.isBeingEdited()) console.log('state', s._source.state()) } } } 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 = false this.model.extendRight = false 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