order sharing

This commit is contained in:
tim
2025-04-22 16:15:14 -04:00
parent 14b8b50812
commit 38fb66c694
17 changed files with 239 additions and 33 deletions

View File

@@ -1,2 +1,3 @@
VITE_WS_URL=ws://localhost:3001
VITE_SNAPSHOT_URL=http://localhost:3001/snapshot
REQUIRE_AUTH=NOAUTH

View File

@@ -21,6 +21,7 @@
"flexsearch": "^0.7.43",
"lru-cache": "^11.0.2",
"luxon": "^3.4.4",
"lz-string": "^1.5.0",
"pinia": "2.1.6",
"pinia-plugin-persistedstate": "^4.1.3",
"roboto-fontface": "*",

28
public/snapshot.js Normal file
View File

@@ -0,0 +1,28 @@
console.log('snapshot.js')
async function getSnapCode() {
const formData = new FormData;
formData.append('language', 'en');
formData.append('timezone', 'Etc/UTC');
formData.append('symbol', 'BTC/USD');
formData.append('preparedImage', window.snapshotImage, 'blob');
try {
const response = await fetch('https://tradingview.com/snapshot/', {
method: 'POST',
body: formData,
credentials: "same-origin",
});
if (!response.ok) {
console.error('Failed to upload snapshot:', response.status, response);
return null;
}
return await response.text();
} catch (error) {
console.error('Error uploading snapshot:', error);
return null
}
}
getSnapCode().then((code)=>console.log('snapshot code', code)).catch((e)=>console.error('snapshot error', e))

View File

@@ -1,7 +1,7 @@
import {useChartOrderStore} from "@/orderbuild.js";
import {invokeCallbacks, prototype} from "@/common.js";
import {DataFeed, defaultSymbol, feelessTickerKey, getAllSymbols, lookupSymbol} from "@/charts/datafeed.js";
import {intervalToSeconds, SingletonCoroutine, toHuman, toPrecision} from "@/misc.js";
import {intervalToSeconds, secondsToInterval, SingletonCoroutine, toHuman, toPrecision} from "@/misc.js";
import {usePrefStore, useStore} from "@/store/store.js";
import {tvCustomThemes} from "../../theme.js";
@@ -58,12 +58,20 @@ export async function setSymbolTicker(ticker) {
function changeInterval(interval) {
co.intervalSecs = intervalToSeconds(interval)
const secs = intervalToSeconds(interval)
co.intervalSecs = secs
prefs.selectedTimeframe = interval
DataFeed.intervalChanged(co.intervalSecs)
DataFeed.intervalChanged(secs)
}
export function changeIntervalSecs(secs) {
const interval = secondsToInterval(secs);
co.intervalSecs = secs
prefs.selectedTimeframe = interval
DataFeed.intervalChanged(secs)
}
function dataLoaded() {
const range = chartMeanRange()
console.log('new mean range', range,)
@@ -164,8 +172,8 @@ export function initWidget(el) {
container: el,
datafeed: DataFeed, // use this for ohlc
locale: "en",
disabled_features: ['main_series_scale_menu',],
enabled_features: ['saveload_separate_drawings_storage',],
disabled_features: ['main_series_scale_menu','display_market_status',],
enabled_features: ['saveload_separate_drawings_storage','snapshot_trading_drawings','show_exchange_logos','show_symbol_logos',],
// drawings_access: {type: 'white', tools: [],}, // show no tools
custom_themes: tvCustomThemes,
theme: useStore().theme,
@@ -193,6 +201,16 @@ export function initWidget(el) {
}
export function onChartReady(f) {
if (co.chartReady)
f(widget, chart)
else
chartInitCbs.push(f)
}
let chartInitCbs = []
function initChart() {
console.log('init chart')
chart = widget.activeChart()
@@ -217,6 +235,11 @@ function initChart() {
}
changeInterval(widget.symbolInterval().interval)
co.chartReady = true
setTimeout(()=>{
for (const cb of chartInitCbs)
cb(widget, chart)
chartInitCbs = []
}, 1)
console.log('chart ready')
}

View File

@@ -64,7 +64,7 @@ const configurationData = {
value: 'UNIv3',
name: 'Uniswap v3',
desc: 'Uniswap v3',
logo: 'https://upload.wikimedia.org/wikipedia/commons/e/e7/Uniswap_Logo.svg',
logo: '/uniswap-logo.svg',
},
],
// The `symbols_types` arguments are used for the `searchSymbols` method if a user selects this symbol type
@@ -111,8 +111,10 @@ export function feelessTickerKey(ticker) {
function addSymbol(chainId, p, base, quote, inverted) {
const symbol = base.s + '/' + quote.s
const fee = `${(p.f/10000).toFixed(2)}%`
const exchange = ['Uniswap v2', 'Uniswap v3'][p.e] + ' ' + fee
// const fee = `${(p.f/10000).toFixed(2)}%`
// const exchange = ['Uniswap v2', 'Uniswap v3'][p.e] + ' ' + fee
const exchange = ['Uniswap v2', 'Uniswap v3'][p.e]
const exchange_logo = '/uniswap-logo.svg'
const full_name = exchange + ':' + symbol // + '%' + formatFee(fee)
const ticker = tickerKey(chainId, p.e, base.a, quote.a, p.f)
// add the search index only if this is the natural, noninverted base/quote pair
@@ -123,7 +125,7 @@ function addSymbol(chainId, p, base, quote, inverted) {
const symbolInfo = {
key: ticker, ticker,
chainId, address: p.a, exchangeId: p.e,
full_name, symbol, description, exchange, type, inverted, base, quote, decimals, x:p.x, fee:p.f,
full_name, symbol, description, exchange, exchange_logo, type, inverted, base, quote, decimals, x:p.x, fee:p.f,
};
_symbols[ticker] = symbolInfo
const feelessKey = feelessTickerKey(ticker)

View File

@@ -77,6 +77,7 @@ const co = useChartOrderStore()
const marketBuilder = newBuilder('MarketBuilder')
console.log('chart order', props.order)
const builders = computed(()=>props.order.builders.length > 0 ? props.order.builders : [marketBuilder])
const tokenIn = computed(()=>props.order.buy ? co.quoteToken : co.baseToken)
const tokenOut = computed(()=>props.order.buy ? co.baseToken : co.quoteToken)

View File

@@ -8,6 +8,12 @@
</v-btn>
<v-btn variant="text" prepend-icon="mdi-delete" v-if="co.orders.length>0"
:disabled="!orderChanged" @click="resetOrder">Reset</v-btn>
<v-btn id="share-btn" variant="text" prepend-icon="mdi-share" v-if="co.orders.length>0"
:disabled="sharing"
@click="shareOrder">{{sharing?'Preparing...':'Share'}}</v-btn>
<v-tooltip activator="#share-btn" text="Copied share link!" v-model="showSharedTooltip"
:open-on-hover="false" :open-on-click="false"
/>
</template>
<div class="overflow-y-auto">
<needs-chart>
@@ -66,6 +72,7 @@ import NeedsChart from "@/components/NeedsChart.vue";
import {PlaceOrderTransaction} from "@/blockchain/transaction.js";
import {errorSuggestsMissingVault} from "@/misc.js";
import {track} from "@/track.js";
import {getShareUrl} from "@/share.js";
const s = useStore()
const co = useChartOrderStore()
@@ -196,6 +203,34 @@ async function doPlaceOrder() {
}
}
const sharing = ref(false)
const showSharedTooltip = ref(false)
const showShareDialog = ref(false)
const shareUrl = ref(null)
function shareOrder() {
sharing.value = true
getShareUrl().then(url => {
shareUrl.value = url
sharing.value = false
navigator.permissions.query({name: "clipboard-write"}).then((permission) => {
const permitted = permission.state === "granted" || permission.state === "prompt"
if (!permitted) {
showShareDialog.value = true
} else {
navigator.clipboard.writeText(url)
.then(() => {
showSharedTooltip.value = true
setTimeout(() => showSharedTooltip.value = false, 3000)
})
.catch(() => {
showShareDialog.value = true
})
}
}).catch(() => null)
})
}
</script>
<style lang="scss"> // NOT scoped

View File

@@ -177,10 +177,8 @@ function update(a, b, updateA, updateB) {
a = maxA
}
_timeEndpoints.value = [a, b]
const newBuilder = {...props.builder}
newBuilder.timeA = a
newBuilder.timeB = b
emit('update:builder', newBuilder)
props.builder.timeA = a
props.builder.timeB = b
}
const flipped = computed(()=>{

View File

@@ -98,7 +98,7 @@
<script setup>
import {applyLinePoints, builderDefaults, useChartOrderStore} from "@/orderbuild.js";
import {sideColor} from "@/misc.js";
import {sideColor, toPrecisionOrNull} from "@/misc.js";
import {MAX_FRACTION, newTranche} from "@/blockchain/orderlib.js";
import RungBuilder from "@/components/chart/RungBuilder.vue";
import {computed, ref} from "vue";
@@ -210,7 +210,7 @@ const time1A = computed({
})
const price1A = computed({
get() { return _endpoints.value[0] === null ? null : _endpoints.value[0][1] },
get() { return toPrecisionOrNull(_endpoints.value[0] === null ? null : _endpoints.value[0][1], 6) },
set(v) {
const flatline0 = _endpoints.value[0];
update(
@@ -232,7 +232,7 @@ const time1B = computed({
})
const price1B = computed({
get() { return _endpoints.value[0] === null ? null : _endpoints.value[0][3] },
get() { return toPrecisionOrNull(_endpoints.value[0] === null ? null : _endpoints.value[0][3], 6) },
set(v) {
const flatline0 = _endpoints.value[0];
update(
@@ -254,7 +254,7 @@ const time2A = computed({
})
const price2A = computed({
get() { return _endpoints.value[1] === null ? null : _endpoints.value[1][1] },
get() { return toPrecisionOrNull(_endpoints.value[1] === null ? null : _endpoints.value[1][1], 6) },
set(v) {
const flatline = _endpoints.value[1];
update(
@@ -276,7 +276,7 @@ const time2B = computed({
})
const price2B = computed({
get() { return _endpoints.value[1] === null ? null : _endpoints.value[1][3] },
get() { return toPrecisionOrNull(_endpoints.value[1] === null ? null : _endpoints.value[1][3], 6) },
set(v) {
const flatline = _endpoints.value[1];
update(
@@ -289,10 +289,8 @@ const price2B = computed({
function update(a, b) { // a and b are lines of two points
if (!vectorEquals(props.builder.lineA, a) || !vectorEquals(props.builder.lineB, b)) {
_endpoints.value = [flattenLine(a), flattenLine(b)]
const newBuilder = {...props.builder}
newBuilder.lineA = a
newBuilder.lineB = b
emit('update:builder', newBuilder)
props.builder.lineA = a
props.builder.lineB = b
}
}

View File

@@ -21,7 +21,7 @@
<td class="weight" style="vertical-align: bottom">{{ allocationTexts[higherIndex] }}</td>
</tr>
<tr v-for="i in innerIndexes" class="ml-5">
<td class="pl-5">{{ prices[i] }}</td>
<td class="pl-5">{{ toPrecision(prices[i],6) }}</td>
<td class="weight">{{ allocationTexts[i] }}</td>
</tr>
</template>
@@ -54,6 +54,7 @@ import {computed, ref} from "vue";
import {allocationText, HLine} from "@/charts/shape.js";
import OneTimeHint from "@/components/OneTimeHint.vue";
import {track} from "@/track.js";
import {toPrecision, toPrecisionOrNull} from "@/misc.js";
const s = useStore()
const os = useOrderStore()
@@ -138,10 +139,8 @@ const priceEndpoints = computed({
function update(a, b) {
_priceEndpoints.value = [a, b]
const newBuilder = {...props.builder}
newBuilder.priceA = a
newBuilder.priceB = b
emit('update:builder', newBuilder)
props.builder.priceA = a
props.builder.priceB = b
}
const flipped = computed(()=>{
@@ -151,7 +150,7 @@ const flipped = computed(()=>{
})
const higherPrice = computed({
get() { return flipped.value ? priceA.value : priceB.value },
get() { return toPrecisionOrNull(flipped.value ? priceA.value : priceB.value, 6) },
set(v) {
if (flipped.value)
priceA.value = v
@@ -172,9 +171,7 @@ const innerIndexes = computed(()=>{
})
const lowerPrice = computed({
get() {
return !flipped.value ? priceA.value : priceB.value
},
get() {return toPrecisionOrNull(!flipped.value ? priceA.value : priceB.value, 6)},
set(v) {
if (!flipped.value)
priceA.value = v

View File

@@ -126,7 +126,7 @@ const balance100 = computed( {
watchEffect(()=>{
const rungs = props.builder.rungs
// const prev = props.builder.valid
props.builder.valid = rungs >= 1 && endpoints.value[0] && (rungs < 2 || endpoints.value[1])
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)
})
@@ -457,6 +457,8 @@ function deleteShapes() {
if (!endpoints.value[0])
shapeA.createOrDraw(); // initiate drawing mode
else
adjustShapes()
</script>

View File

@@ -0,0 +1,14 @@
<template>
</template>
<script setup>
import {loadShareUrl} from "@/share.js";
import router from "@/router/index.js";
loadShareUrl()
router.replace('/order')
</script>
<style scoped lang="scss">
</style>

View File

@@ -230,6 +230,21 @@ export function intervalToSeconds(interval) {
}
export function secondsToInterval(seconds) {
const units = [
[30 * 24 * 60 * 60, 'M'],
[7 * 24 * 60 * 60, 'W'],
[24 * 60 * 60, 'D'],
[60, ''],
[1, 'S'],
]
for( const [unit, suffix] of units)
if (seconds % unit === 0)
return `${seconds / unit}${suffix}`
throw Error(`invalid secondsToInterval ${seconds}`)
}
export function interpolate(a, b, zeroToOne) {
const d = (b-a)
return a + d * zeroToOne
@@ -260,6 +275,12 @@ export function toPrecision(value, significantDigits = 3) {
return value.toFixed(decimalsNeeded); // Use toFixed to completely avoid scientific notation
}
export function toPrecisionOrNull(value, significantDigits = 3) {
if (value===null) return null
if (value===undefined) return undefined
return toPrecision(value, significantDigits)
}
export function toHuman(value, significantDigits = 2) {
if (!isFinite(value)) return value.toString(); // Handle Infinity and NaN
let suffix = ''

View File

@@ -199,8 +199,9 @@ export function timesliceTranches() {
export function builderDefaults(builder, defaults) {
for (const k in defaults)
if (builder[k] === undefined)
if (!Object.prototype.hasOwnProperty.call(builder, k)) {
builder[k] = defaults[k] instanceof Function ? defaults[k]() : defaults[k]
}
}
export function linearWeights(num, skew) {

View File

@@ -14,6 +14,11 @@ const routes = [
// which is lazy-loaded when the route is visited.
component: () => import('@/components/chart/ChartPlaceOrder.vue'),
},
{
name: 'Shared',
path: '/shared',
component: ()=> import('@/components/chart/Shared.vue')
},
{
name: 'Order',
path: '/order',

74
src/share.js Normal file
View File

@@ -0,0 +1,74 @@
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
import {useChartOrderStore} from "@/orderbuild.js";
import {changeIntervalSecs, onChartReady, setSymbol, widget} from "@/charts/chart.js";
import {usePrefStore, useStore} from "@/store/store.js";
import {lookupSymbol} from "@/charts/datafeed.js";
export async function getShareUrl() {
const co = useChartOrderStore();
const s = useStore()
const sym = co.selectedSymbol
console.log('symbol', sym)
const data = {
version: 1,
chainId: s.chainId,
orders: co.orders,
symbol: {
base: {a: sym.base.a, s: sym.base.s},
quote: {a: sym.quote.a, s: sym.quote.s},
route: {
fee: sym.fee,
exchange: sym.exchangeId,
}
},
period: co.intervalSecs,
}
const json = JSON.stringify(data)
console.log('sharing data', json, data)
const compressed = compressToEncodedURIComponent(json);
const baseUrl = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
const imageFile = await takeSnapshot()
return `${baseUrl}/shared?i=${imageFile}&d=${compressed}`;
}
export function loadShareUrl() {
const urlParams = new URLSearchParams(window.location.search);
const dataStr = urlParams.get('d');
if (!dataStr) return
const json = decompressFromEncodedURIComponent(dataStr);
const data = JSON.parse(json);
console.log('loaded shared orders data', data)
const co = useChartOrderStore();
const s = useStore()
const ticker = `${data.chainId}|${data.symbol.route.exchange}|${data.symbol.base.a}|${data.symbol.quote.a}|${data.symbol.route.fee}`;
const symbol = lookupSymbol(ticker)
if (symbol===null) {
console.error('could not find symbol for ticker', ticker)
return
}
s.chainId = data.chainId
const prefs = usePrefStore()
prefs.selectedSymbol = ticker
for (const order of data.orders) {
order.amount = 0
order.valid = false
}
co.orders = data.orders
changeIntervalSecs(data.period)
onChartReady(()=>{
setSymbol(symbol)
.catch((e)=>console.error('could not set symbol', e))
})
console.log('loaded orders', s.chainId, co.orders)
}
export async function takeSnapshot() {
const screenshotCanvas = await widget.takeClientScreenshot();
const image = await new Promise((resolve) => screenshotCanvas.toBlob(resolve));
const response = await fetch(import.meta.env.VITE_SNAPSHOT_URL, {
method: 'PUT',
body: image,
credentials: 'same-origin',
});
return response.text()
}

View File

@@ -1489,6 +1489,11 @@ luxon@^3.4.4:
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20"
integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==
lz-string@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-string@^0.30.11, magic-string@^0.30.17:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"