Files
web/src/components/chart/RungBuilder.vue
2024-10-31 16:54:57 -04:00

428 lines
12 KiB
Vue

<template>
<builder-panel :order="order" :builder="builder" :build-tranches="buildTranches"
:adjust-shapes="adjustShapes" :delete-shapes="deleteShapes">
<div style="min-width: 4em; font-size: larger" :style="colorStyle"
class="d-flex flex-column align-self-start ml-2">
<div class="flex-row align-items-center">
<v-btn variant="outlined" @click="props.builder.breakout=!props.builder.breakout">{{ name }}</v-btn>
<div class="description">{{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" :base-color="color" min="1" :max="MAX_RUNGS"
:disabled="rungsDisabled"
style="width: 4.5em;"
/>
</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 align-center">
<v-slider v-if="rungs>1" :direction="orientation?'vertical':'horizontal'" min="-100" max="100" v-model="skew100"
class="no-slider-bg ml-2 mr-4" hide-details/>
<v-text-field type="number" v-model="skew100" min="-100" max="100"
density="compact" hide-details variant="outlined" label="Skew" step="5"
:color="color" :base-color="color" class="skew">
<template v-slot:prepend>
<v-btn icon="mdi-scale-balance" variant="plain" @click="builder.skew=0" :color="color"/>
</template>
</v-text-field>
</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";
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 skew 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 skew100 = computed( {
get() {return flippedSign.value*props.builder.skew*100},
set(v) {props.builder.skew = 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] && (rungs < 2 || endpoints.value[1])
// console.log('valid?', prev, props.builder.valid, rungs, valueA.value, valueB.value)
})
function setEndpoints(a, b) {
// console.log('rb setting endpoints', devectorize(a), devectorize(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
// console.log('set rungs', prevR, r, a, b)
if ( r > 0 && vectorIsNull(b) ) {
// 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 ( r === 1 && !vectorIsNull(b) ) {
// 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 skew = props.flip ? -props.builder.skew : props.builder.skew
const most = 0.998
let skew = -props.builder.skew
if (skew <= -1)
skew = -most
else if (skew >= 1)
skew = most
const ws = linearWeights(props.builder.rungs, skew)
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 colorStyle = computed(() => {
return {'color': color.value}
})
//
// 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(this.model))
const delta = vectorSub(cur, prev)
// console.log('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,
amountSymbol: amountSymbol.value,
textLocation: above ? 'above' : 'below',
extraText: !props.builder.breakout ? ' ' :
// (above ? '↑ Breakout ↑' : '↓ Breakout ↓')
(above ? '▲ Breakout ▲' : '▼ Breakout ▼')
}
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
</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;
}
.skew {
min-width: 9em;
max-width: 12em;
}
.description {
font-size: 0.6em;
}
</style>