483 lines
14 KiB
Vue
483 lines
14 KiB
Vue
<template>
|
|
<builder-panel :order="order" :builder="builder" :build-tranches="buildTranches"
|
|
:adjust-shapes="adjustShapes" :delete-shapes="deleteShapes" :name="name">
|
|
<div style="min-width: 4em; font-size: larger" :style="colorStyle"
|
|
class="d-flex flex-column">
|
|
<!--
|
|
<div class="flex-row align-items-center">
|
|
<v-btn variant="outlined" style="width: 8em"
|
|
@click="()=>{if (props.builder.breakout!==undefined) props.builder.breakout=!props.builder.breakout}">{{ name }}</v-btn>
|
|
<div class="description w-100 text-center">{{description}}</div>
|
|
</div>
|
|
-->
|
|
<v-text-field type="number" v-model="rungs"
|
|
density="compact" hide-details class="mx-1 my-2" variant="outlined"
|
|
label="Rungs"
|
|
:color="color"
|
|
min="1" :max="MAX_RUNGS"
|
|
:disabled="rungsDisabled"
|
|
style="width: 6.6em; max-height: 2.5em; height: 2.5em"
|
|
id="rungs"
|
|
/>
|
|
<one-time-hint name="rungs" activator="#rungs" after="choose-builder"
|
|
text="↓ Try increasing rungs!" location="top"
|
|
:when="rungs===1&&endpoints[0]!==null"
|
|
:on-complete="()=>track('rungs')"
|
|
/>
|
|
<v-tooltip v-if="builder.breakout!==undefined"
|
|
:text="order.buy?'Breakout orders buy above the breakout line':'Breakdown orders sell below the breakdown line'">
|
|
<template #activator="{ props }">
|
|
<div v-bind="props">
|
|
<v-switch v-model="breakout" :label="order.buy?'Breakout':'Breakdown'"
|
|
persistent-hint :color="switchColor" :base-color="switchColor" hide-details direction="vertical"
|
|
density="compact"/>
|
|
<div class="mx-auto">
|
|
<span style="font-size: .7em; vertical-align: top"
|
|
:style="builder.breakout?{color:new Color(color).lighten(0.5).string()}:null">
|
|
{{ description }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</v-tooltip>
|
|
</div>
|
|
|
|
<slot/>
|
|
|
|
<div v-if="co.drawing" class="d-flex align-center pl-3">
|
|
<v-icon icon="mdi-chat-alert-outline" color="grey" class="mr-1"/>
|
|
Click the chart!
|
|
</div>
|
|
<div v-if="rungs>1" class="mx-2 d-flex justify-start">
|
|
<div class="d-flex align-center mt-2">
|
|
<div id="balance-slider">
|
|
<v-slider v-if="rungs>1" :direction="orientation?'vertical':'horizontal'" min="-100" max="100" v-model="balance100"
|
|
class="no-slider-bg ml-2 mr-4" hide-details/>
|
|
</div>
|
|
<one-time-hint name="balance-slider" activator="#balance-slider" after="rungs"
|
|
text="↓ Slide the amount balance ↓" location="top"
|
|
:when="balance100===0"
|
|
:on-complete="()=>track('balance-slider')"
|
|
/>
|
|
<v-text-field type="number" v-model="balance100" min="-100" max="100"
|
|
density="compact" hide-details variant="outlined" label="Balance" step="5"
|
|
class="balance">
|
|
<template v-slot:prepend>
|
|
<v-btn icon="mdi-scale-balance" variant="plain" @click="builder.balance=0"/>
|
|
</template>
|
|
</v-text-field>
|
|
</div>
|
|
</div>
|
|
</builder-panel>
|
|
</template>
|
|
|
|
<script setup>
|
|
import BuilderPanel from "@/components/chart/BuilderPanel.vue";
|
|
import {deleteBuilder, linearWeights, useChartOrderStore} from "@/orderbuild.js";
|
|
import {linspace, sideColor} from "@/misc.js";
|
|
import {computed, watchEffect} from "vue";
|
|
import Color from "color";
|
|
import {cancelDrawing} from "@/charts/chart.js";
|
|
import {
|
|
devectorize,
|
|
vectorAdd,
|
|
vectorDiv,
|
|
vectorEquals,
|
|
vectorIsNull,
|
|
vectorIsZero,
|
|
vectorize,
|
|
vectorMul,
|
|
vectorNeg,
|
|
vectorSub
|
|
} from "@/vector.js";
|
|
import {logicalXOR} from "@/common.js";
|
|
import OneTimeHint from "@/components/OneTimeHint.vue";
|
|
import {track} from "@/track.js";
|
|
|
|
const co = useChartOrderStore()
|
|
const endpoints = defineModel('modelValue') // 2-item list of points/values
|
|
const props = defineProps({
|
|
name: String,
|
|
description: String,
|
|
order: Object,
|
|
builder: Object,
|
|
buildTranches: Function,
|
|
stdWidth: [Number, Array],
|
|
shape: Function, // shape() -> Shape
|
|
mode: { type: Number, default: 0 }, // rung addition mode: 0 = split, 1 = extend
|
|
flip: { type: Boolean, default: false }, // if true, the balance slider is flipped upside-down
|
|
orientation: { type: Number, default: 1 }, // 0 = horizontal slider, 1 = vertical
|
|
// values may be scalars or vector arrays
|
|
getModelValue: Function, // getModelValue(model) -> value
|
|
setModelValue: Function, // setModelValue(model,value) -> void
|
|
setValues: Function, // setValues(values:Array) -> void
|
|
setWeights: Function, // setWeights(values:Array) -> void
|
|
setShapes: { type: Function, default: null }, // setShapes([shapeA, innerShape, ..., shapeB]) -> void
|
|
})
|
|
|
|
const flippedSign = computed(()=>props.flip?-1:1)
|
|
|
|
const balance100 = computed( {
|
|
get() {return flippedSign.value*props.builder.balance*100},
|
|
set(v) {props.builder.balance = flippedSign.value*v/100; }
|
|
} )
|
|
|
|
// validity checks
|
|
watchEffect(()=>{
|
|
const rungs = props.builder.rungs
|
|
// const prev = props.builder.valid
|
|
props.builder.valid = rungs >= 1 && endpoints.value[0] > 0 && (rungs < 2 || endpoints.value[1])
|
|
// console.log('valid?', prev, props.builder.valid, rungs, valueA.value, valueB.value)
|
|
})
|
|
|
|
let lastBuy = null
|
|
watchEffect(()=>{
|
|
if (props.order.buy!==lastBuy) {
|
|
lastBuy = props.order.buy
|
|
props.builder.color=computeDefaultColor()
|
|
}
|
|
})
|
|
|
|
const breakout = computed({
|
|
get() {return !logicalXOR(props.builder.breakout, props.order.buy)},
|
|
set(v) {props.builder.breakout = !logicalXOR(v, props.order.buy)},
|
|
})
|
|
|
|
function setEndpoints(a, b) {
|
|
endpoints.value = [devectorize(a), devectorize(b)]
|
|
}
|
|
|
|
const MAX_RUNGS = 10 // todo remove arbitrary limit
|
|
|
|
const rungs = computed({
|
|
get() {
|
|
return props.builder.rungs
|
|
},
|
|
set(r) {
|
|
if (!r) {
|
|
props.builder.rungs = 1
|
|
return
|
|
}
|
|
if (r>MAX_RUNGS)
|
|
r = MAX_RUNGS
|
|
let [a,b] = endpoints.value
|
|
a = vectorize(a)
|
|
b = vectorize(b)
|
|
r = Number(r)
|
|
const prevR = Number(props.builder.rungs)
|
|
props.builder.rungs = r
|
|
if ( prevR === 1 && r > 1 ) {
|
|
// convert single shape to a range
|
|
if (props.mode===0) {
|
|
const width = vectorize(props.stdWidth)
|
|
const mid = vectorize(a)
|
|
// console.log('stdWidth', width)
|
|
const halfWidth = vectorDiv(width,2);
|
|
a = vectorAdd(mid, vectorNeg(halfWidth) )
|
|
b = vectorAdd(mid, halfWidth)
|
|
setEndpoints(a,b)
|
|
}
|
|
else if (props.mode===1 ) {
|
|
setEndpoints(a, vectorAdd(a,props.stdWidth))
|
|
}
|
|
else
|
|
throw Error(`Unknown rung mode ${props.mode}`)
|
|
}
|
|
else if ( prevR > 1 && r === 1 ) {
|
|
// convert from a range to a single shape
|
|
if (props.mode===0)
|
|
a = vectorDiv(vectorAdd(a,b), 2)
|
|
b = null
|
|
setEndpoints(a,b)
|
|
}
|
|
else {
|
|
// from multi to multi
|
|
if (props.mode===1) {
|
|
const width = vectorDiv(vectorSub(b, a), (prevR-1))
|
|
b = vectorAdd(a, vectorMul(width, (r-1)))
|
|
setEndpoints(a,b)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const rungsDisabled = computed(()=>endpoints.value[0]===null)
|
|
|
|
const values = computed(()=>{
|
|
let [a, b] = endpoints.value
|
|
// console.log('generating values for endpoints', a, b)
|
|
a = vectorize(a)
|
|
b = vectorize(b)
|
|
const r = props.builder.rungs
|
|
let result
|
|
if ( !r || vectorIsNull(a) )
|
|
result = [] // no data
|
|
else if (r===1)
|
|
result = [devectorize(a)] // single shape
|
|
else {
|
|
const columns = []
|
|
for (let i=0; i<a.length; i++)
|
|
columns.push(linspace(a[i], b[i], r)) // linear spacing for each dimension
|
|
// transpose
|
|
result = []
|
|
for (let i=0; i<r; i++) {
|
|
const vector = []
|
|
for (let j=0; j<a.length; j++)
|
|
vector.push(columns[j][i])
|
|
result.push(devectorize(vector))
|
|
}
|
|
}
|
|
props.setValues(result)
|
|
// console.log('values', result)
|
|
return result;
|
|
})
|
|
|
|
|
|
const weights = computed(() => {
|
|
// const balance = props.flip ? -props.builder.balance : props.builder.balance
|
|
const most = 0.998
|
|
let balance = -props.builder.balance
|
|
if (balance <= -1)
|
|
balance = -most
|
|
else if (balance >= 1)
|
|
balance = most
|
|
const ws = linearWeights(props.builder.rungs, balance)
|
|
if (props.setWeights)
|
|
props.setWeights(ws)
|
|
return ws
|
|
})
|
|
|
|
|
|
const amountSymbol = computed(()=>props.order.amountIsTokenA ? co.selectedSymbol.base.s : co.selectedSymbol.quote.s )
|
|
|
|
|
|
// colors
|
|
function computeDefaultColor() {
|
|
const index = props.order.builders.indexOf(props.builder)
|
|
return sideColor(props.order.buy, index)
|
|
}
|
|
const defaultColor = computeDefaultColor()
|
|
const color = computed({
|
|
get() {return props.builder.color},
|
|
set(v) {
|
|
console.error('set color', v)
|
|
const maxLightness = 60
|
|
// noinspection JSUnresolvedReference
|
|
const c = new Color(v).hsl()
|
|
props.builder.color = c.saturation <= maxLightness ? v : c.lightness(maxLightness).rgb().string()
|
|
}
|
|
})
|
|
const switchColor = computed(()=>props.builder.breakout ? color.value : null)
|
|
const colorStyle = computed(() => {
|
|
// return {'color': color.value}
|
|
return {}
|
|
})
|
|
|
|
|
|
//
|
|
// SHAPE MANAGEMENT
|
|
//
|
|
|
|
// we keep two special control shapes as the edges of the range, with deletable shapes in-between
|
|
|
|
function createShape(value, model, onModel, onDelete) {
|
|
setModelValue(model, value)
|
|
return new props.shape(model, onModel, onDelete) // props.shape is the constructor function
|
|
}
|
|
|
|
|
|
function getModelValue(model) {
|
|
return vectorize(props.getModelValue(model));
|
|
}
|
|
|
|
|
|
function setModelValue(model, value) {
|
|
props.setModelValue(model, devectorize(value))
|
|
}
|
|
|
|
|
|
function translateOnModel(shape) {
|
|
const oldOnModel = shape.onModel
|
|
shape.onModel = function (model, oldModel) {
|
|
oldOnModel.call(this, ...arguments)
|
|
if (!this.beingDragged())
|
|
return
|
|
const prev = getModelValue(oldModel)
|
|
const cur = vectorize(getModelValue(model))
|
|
const delta = vectorSub(cur, prev)
|
|
// console.log('translateOnModel delta', shape.id, prev, cur, delta)
|
|
let [a, b] = endpoints.value
|
|
a = vectorize(a)
|
|
if (!vectorIsZero(delta)) {
|
|
a = vectorAdd(a, delta)
|
|
if (rungs.value > 1)
|
|
b = vectorAdd(vectorize(b), delta)
|
|
setEndpoints(a,b)
|
|
}
|
|
}
|
|
}
|
|
|
|
function setModelColor(model) {
|
|
if (model.color !== undefined && model.color !== color.value)
|
|
color.value = model.color
|
|
}
|
|
|
|
const shapeA = createShape(endpoints.value[0], {color: defaultColor},
|
|
function (model) {
|
|
const value = getModelValue(model);
|
|
if ( !vectorEquals(value, endpoints.value[0]) )
|
|
setEndpoints(value, endpoints.value[1])
|
|
setModelColor(model)
|
|
},
|
|
deleteSelf)
|
|
|
|
if (props.mode===1)
|
|
translateOnModel(shapeA)
|
|
|
|
const shapeB = createShape(endpoints.value[1], {color:defaultColor},
|
|
function (model) {
|
|
const value = getModelValue(model);
|
|
if ( !vectorEquals(value, endpoints.value[1]) )
|
|
setEndpoints(endpoints.value[0], value)
|
|
setModelColor(model)
|
|
},
|
|
deleteSelf)
|
|
|
|
function interiorOnModel(model) {
|
|
setModelColor(model)
|
|
}
|
|
|
|
let interiorShapes = []
|
|
|
|
function createInteriorShape(index) {
|
|
const shape = new props.shape(makeModel(index), interiorOnModel, deleteSelf)
|
|
translateOnModel(shape)
|
|
interiorShapes.push(shape)
|
|
}
|
|
|
|
function removeInteriorShape() {
|
|
if (interiorShapes.length) {
|
|
const shape = interiorShapes.pop()
|
|
shape.delete()
|
|
}
|
|
}
|
|
|
|
function makeModel(index) {
|
|
const ws = weights.value
|
|
const alloc = props.builder.allocation * ws[index];
|
|
const buy = props.order.buy
|
|
const above = buy === props.builder.breakout
|
|
const result = {
|
|
color: color.value,
|
|
allocation: alloc,
|
|
maxAllocation: Math.max(...weights.value),
|
|
amount: props.order.amount * alloc,
|
|
baseSymbol: co.selectedSymbol.base.s,
|
|
amountSymbol: amountSymbol.value,
|
|
textLocation: above ? 'above' : 'below',
|
|
breakout: props.builder.breakout,
|
|
extraText: null,
|
|
buy,
|
|
}
|
|
setModelValue(result, values.value[index])
|
|
return result
|
|
}
|
|
|
|
|
|
function adjustShapes() {
|
|
// this is where all the shapes are created or adjusted
|
|
// console.error('adjustShapes()', ...endpoints.value)
|
|
const vs = values.value
|
|
if (vs.length)
|
|
cancelDrawing()
|
|
// shape properties
|
|
if( vs.length === 0 ) {
|
|
shapeA.delete()
|
|
shapeB.delete()
|
|
for( const shape of interiorShapes )
|
|
shape.delete()
|
|
interiorShapes = []
|
|
if (props.setShapes)
|
|
props.setShapes([])
|
|
}
|
|
else if (vs.length === 1) {
|
|
//
|
|
// SINGLE SHAPE
|
|
//
|
|
shapeA.setModel(makeModel(0))
|
|
shapeB.delete()
|
|
if (interiorShapes.length) {
|
|
for( const shape of interiorShapes )
|
|
shape.delete()
|
|
interiorShapes = []
|
|
}
|
|
if (props.setShapes)
|
|
props.setShapes([shapeA])
|
|
}
|
|
else {
|
|
//
|
|
// VALUE RANGE
|
|
//
|
|
shapeA.setModel(makeModel(0))
|
|
shapeB.setModel(makeModel(vs.length-1))
|
|
const numInterior = Math.max(0,vs.length-2);
|
|
// trim excess interior shapes
|
|
while( interiorShapes.length > numInterior )
|
|
removeInteriorShape()
|
|
// adjust the interior shape values and/or add shapes
|
|
for( let i=1; i<vs.length-1; i++ ) {
|
|
if (i-1 === interiorShapes.length)
|
|
createInteriorShape(i)
|
|
else
|
|
interiorShapes[i-1].setModel(makeModel(i))
|
|
}
|
|
if (props.setShapes)
|
|
props.setShapes([shapeA, ...interiorShapes, shapeB])
|
|
}
|
|
}
|
|
|
|
|
|
function deleteSelf() {
|
|
if (endpoints.value[0]===null)
|
|
cancelDrawing()
|
|
deleteBuilder(props.order, props.builder);
|
|
}
|
|
|
|
function deleteShapes() {
|
|
cancelDrawing()
|
|
shapeA.delete()
|
|
shapeB.delete()
|
|
for (const shape of interiorShapes)
|
|
shape.delete()
|
|
interiorShapes = []
|
|
if (props.setShapes)
|
|
props.setShapes([])
|
|
}
|
|
|
|
|
|
if (!endpoints.value[0])
|
|
shapeA.createOrDraw(); // initiate drawing mode
|
|
else
|
|
adjustShapes()
|
|
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
:deep(.v-slider.v-input--vertical > .v-input__control) {
|
|
min-height: 5em !important;
|
|
}
|
|
:deep(.v-slider.v-input--horizontal > .v-input__control) {
|
|
min-width: 5em !important;
|
|
}
|
|
:deep(.v-slider.no-slider-bg .v-slider-track__fill) {
|
|
background-color: inherit !important;
|
|
}
|
|
.balance {
|
|
min-width: 9em;
|
|
max-width: 12em;
|
|
}
|
|
.description {
|
|
font-size: 0.6em;
|
|
}
|
|
</style>
|