diff --git a/.env-mock b/.env-mock index 65e1b4d..f402083 100644 --- a/.env-mock +++ b/.env-mock @@ -1,2 +1,3 @@ VITE_WS_URL=ws://localhost:3001 +VITE_SNAPSHOT_URL=http://localhost:3001/snapshot REQUIRE_AUTH=NOAUTH diff --git a/package.json b/package.json index 54c755a..ad5a789 100644 --- a/package.json +++ b/package.json @@ -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": "*", diff --git a/public/snapshot.js b/public/snapshot.js new file mode 100644 index 0000000..cbd28d6 --- /dev/null +++ b/public/snapshot.js @@ -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)) diff --git a/src/charts/chart.js b/src/charts/chart.js index c9c86e7..e651804 100644 --- a/src/charts/chart.js +++ b/src/charts/chart.js @@ -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') } diff --git a/src/charts/datafeed.js b/src/charts/datafeed.js index f056f71..e964fe6 100644 --- a/src/charts/datafeed.js +++ b/src/charts/datafeed.js @@ -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) diff --git a/src/components/chart/ChartOrder.vue b/src/components/chart/ChartOrder.vue index 51b3f41..6e53fde 100644 --- a/src/components/chart/ChartOrder.vue +++ b/src/components/chart/ChartOrder.vue @@ -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) diff --git a/src/components/chart/ChartPlaceOrder.vue b/src/components/chart/ChartPlaceOrder.vue index 2952a9c..1b3c527 100644 --- a/src/components/chart/ChartPlaceOrder.vue +++ b/src/components/chart/ChartPlaceOrder.vue @@ -8,6 +8,12 @@ Reset + {{sharing?'Preparing...':'Share'}} +
@@ -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) + }) +} + \ No newline at end of file diff --git a/src/misc.js b/src/misc.js index beac44f..f73d34d 100644 --- a/src/misc.js +++ b/src/misc.js @@ -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 = '' diff --git a/src/orderbuild.js b/src/orderbuild.js index edec32a..c860045 100644 --- a/src/orderbuild.js +++ b/src/orderbuild.js @@ -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) { diff --git a/src/router/index.js b/src/router/index.js index 1a39b51..48424ef 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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', diff --git a/src/share.js b/src/share.js new file mode 100644 index 0000000..aef97d8 --- /dev/null +++ b/src/share.js @@ -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() +} diff --git a/yarn.lock b/yarn.lock index 1abb29a..2970bef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"