diff options
author | mat <27899617+mat-1@users.noreply.github.com> | 2022-05-19 02:34:18 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-05-19 02:34:18 +0000 |
commit | 9b714fe39c83794538e0b38a63afd2bf39664c2f (patch) | |
tree | e0b638cad2b38c17f84fe5270f360a21756f6728 | |
parent | 41dc70e3af49fc11383d0dff3bbe8f006272bed9 (diff) | |
parent | 97688e22c8335642b72d200ce0a865353bef5207 (diff) | |
download | skyblock-stats-9b714fe39c83794538e0b38a63afd2bf39664c2f.tar.gz skyblock-stats-9b714fe39c83794538e0b38a63afd2bf39664c2f.tar.bz2 skyblock-stats-9b714fe39c83794538e0b38a63afd2bf39664c2f.zip |
Merge pull request #3 from skyblockstats/auctions
Auction Prices
-rw-r--r-- | package.json | 6 | ||||
-rw-r--r-- | src/lib/APITypes.d.ts | 20 | ||||
-rw-r--r-- | src/lib/AuctionPreviewTooltip.svelte | 56 | ||||
-rw-r--r-- | src/lib/AuctionPriceScatterplot.svelte | 138 | ||||
-rw-r--r-- | src/lib/GlobalTooltip.ts | 2 | ||||
-rw-r--r-- | src/lib/api.ts | 1 | ||||
-rw-r--r-- | src/lib/utils.ts | 10 | ||||
-rw-r--r-- | src/routes/auctionprices.svelte | 210 | ||||
-rw-r--r-- | src/routes/index.svelte | 2 | ||||
-rw-r--r-- | svelte.config.js | 11 | ||||
-rw-r--r-- | yarn.lock | 12 |
11 files changed, 452 insertions, 16 deletions
diff --git a/package.json b/package.json index b2ba8a9..a2b2139 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,7 @@ "eslint-plugin-svelte3": "^4.0.0", "prettier": "^2.5.1", "prettier-plugin-svelte": "^2.5.0", - "svelte": "^3.46.4", "svelte-check": "^2.4.6", - "svelte-preprocess": "^4.10.4", "tslib": "^2.3.1", "typescript": "~4.6.4", "vite": "^2.8.6", @@ -38,10 +36,12 @@ "@sveltejs/adapter-node": "^1.0.0-next.68", "@sveltejs/adapter-static": "^1.0.0-next.28", "@sveltejs/adapter-vercel": "^1.0.0-next.43", - "@sveltejs/kit": "^1.0.0-next.330", + "@sveltejs/kit": "^1.0.0-next.335", "cookie": "^0.5.0", "dotenv": "^16.0.0", "skyblock-assets": "^2.0.8", + "svelte": "^3.48.0", + "svelte-preprocess": "^4.10.6", "typed-hypixel-api": "^1.1.0" }, "packageManager": "yarn@3.1.1" diff --git a/src/lib/APITypes.d.ts b/src/lib/APITypes.d.ts index 6402721..e01a950 100644 --- a/src/lib/APITypes.d.ts +++ b/src/lib/APITypes.d.ts @@ -434,3 +434,23 @@ export interface AccessoryBagUpgrades { list: string[] } } + +export interface SimpleAuctionSchema { + /** The UUID of the auction so we can look it up later. */ + id: string + coins: number + /** + * The timestamp as **seconds** since epoch. It's in seconds instead of ms + * since we don't need to be super exact and so it's shorter. + */ + ts: number + /** Whether the auction was bought or simply expired. */ + success: boolean + bin: boolean +} +export interface ItemAuctionsSchema { + /** The id of the item */ + id: string + sbId: string + auctions: SimpleAuctionSchema[] +} diff --git a/src/lib/AuctionPreviewTooltip.svelte b/src/lib/AuctionPreviewTooltip.svelte new file mode 100644 index 0000000..26b01d9 --- /dev/null +++ b/src/lib/AuctionPreviewTooltip.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import type { PreviewedAuctionData } from './utils' + import { fade } from 'svelte/transition' + + export let preview: PreviewedAuctionData | null + let lastPreview: PreviewedAuctionData | null + + $: { + lastPreview = preview ?? lastPreview + } + + function onMouseMove(e: MouseEvent) { + // commented out because it doesn't work: sometimes e.target is null when we click a point + if (e.target && !(e.target as HTMLElement).closest('.item-auction-history')) { + preview = null + lastPreview = null + } + } +</script> + +<svelte:body on:mousemove={onMouseMove} /> + +{#if lastPreview} + <div + id="auction-preview-tooltip-container" + style={lastPreview ? `left: ${lastPreview.pageX}px; top: ${lastPreview.pageY}px` : undefined} + out:fade={{ duration: 100 }} + in:fade={{ duration: 100 }} + > + <div id="auction-preview-tooltip"> + <p><b>{lastPreview.auction.coins.toLocaleString()}</b> coins</p> + <time>{new Date(lastPreview.auction.ts * 1000).toLocaleString()}</time> + </div> + </div> +{/if} + +<style> + #auction-preview-tooltip-container { + position: absolute; + pointer-events: none; + transition: left 200ms linear, top 200ms linear; + } + #auction-preview-tooltip { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.1); + padding: 0.5em; + } + + p { + margin: 0; + } + + time { + color: var(--theme-darker-text); + } +</style> diff --git a/src/lib/AuctionPriceScatterplot.svelte b/src/lib/AuctionPriceScatterplot.svelte new file mode 100644 index 0000000..6c59e70 --- /dev/null +++ b/src/lib/AuctionPriceScatterplot.svelte @@ -0,0 +1,138 @@ +<script lang="ts"> + import { browser } from '$app/env' + + import type { ItemAuctionsSchema, SimpleAuctionSchema } from './APITypes' + import type { PreviewedAuctionData } from './utils' + + export let item: ItemAuctionsSchema + export let currentlyPreviewedAuction: PreviewedAuctionData | null + + let svgEl: SVGElement + let maxCoins: number = item.auctions.reduce((max, auction) => Math.max(max, auction.coins), 0) + let currentTimestamp = Math.floor(Date.now() / 1000) + let earliestTimestamp = item.auctions.length > 0 ? item.auctions[0].ts : 0 + let hoursBetween = (currentTimestamp - earliestTimestamp) / (60 * 60) + const gridWidth = 100 / hoursBetween + + // this code is bad but it works + let heightCoinInterval = Math.ceil(Math.pow(10, Math.floor(Math.log10(maxCoins / 5)))) + if (heightCoinInterval < maxCoins / 20) { + heightCoinInterval *= 5 + } else if (heightCoinInterval < maxCoins / 10) { + heightCoinInterval *= 2 + } + const gridHeight = 100 / (maxCoins / heightCoinInterval) + + function getAuctionCoordinates(auction: SimpleAuctionSchema) { + const timestampPercentage = + (auction.ts - earliestTimestamp) / (currentTimestamp - earliestTimestamp) + return [timestampPercentage * 100, 100 - (auction.coins / maxCoins) * 100] + } + + function updateNearest(e: MouseEvent) { + const rect = svgEl.getBoundingClientRect() + + const mouseCoords = [e.clientX - rect.left, e.clientY - rect.top] + let nearestDistance = Number.MAX_SAFE_INTEGER + let nearestAuction: SimpleAuctionSchema | null = null + for (const auction of item.auctions) { + const auctionCoordsSvg = getAuctionCoordinates(auction) + const auctionCoords = [ + (auctionCoordsSvg[0] * rect.width) / 100, + (auctionCoordsSvg[1] * rect.height) / 100, + ] + const distance = + Math.pow(mouseCoords[0] - auctionCoords[0], 2) + + Math.pow(mouseCoords[1] - auctionCoords[1], 2) + if (distance < nearestDistance) { + nearestDistance = distance + nearestAuction = auction + } + } + if (nearestAuction) { + const [svgX, svgY] = getAuctionCoordinates(nearestAuction) + const [x, y] = [(svgX * rect.width) / 100, (svgY * rect.height) / 100] + if (currentlyPreviewedAuction?.auction.id === nearestAuction.id) return + currentlyPreviewedAuction = { + pageX: window.scrollX + rect.left + x, + pageY: window.scrollY + rect.top + y, + auction: nearestAuction, + } + for (const el of document.getElementsByClassName('selected-auction')) + el.classList.remove('selected-auction') + document + .getElementsByClassName(`auction-point-${nearestAuction.id}`)[0] + .classList.add('selected-auction') + } else { + currentlyPreviewedAuction = null + } + } + + function shortenBigNumber(n: number) { + if (n < 1000) return n + if (n < 1_000_000) return parseFloat((n / 1000).toPrecision(2)).toLocaleString() + 'k' + if (n < 1_000_000_000) return parseFloat((n / 1_000_000).toPrecision(2)).toLocaleString() + 'M' + if (n < 1_000_000_000_000) + return parseFloat((n / 1_000_000_000).toPrecision(2)).toLocaleString() + 'B' + } +</script> + +<svg viewBox="0 0 100 100" class="item-auction-history"> + <defs> + <pattern + id="grid-{item.id}" + width={gridWidth} + height={gridHeight} + patternUnits="userSpaceOnUse" + x="0%" + y="100%" + > + <path + d="M {gridWidth} {gridHeight} L 0 {gridHeight} 0 0" + fill="none" + stroke="#fff2" + stroke-width="1" + /> + </pattern> + </defs> + {#each new Array(Math.floor(maxCoins / heightCoinInterval) + 1) as _, intervalIndex} + <text + x="-1" + y={Math.min(Math.max(5, 100 - intervalIndex * gridHeight + 2), 100)} + fill="var(--theme-darker-text)" + font-size="6px" + text-anchor="end">{shortenBigNumber(heightCoinInterval * intervalIndex)}</text + > + {/each} + <rect + width="100%" + height="100%" + fill="url(#grid-{item.id})" + on:mousemove={updateNearest} + bind:this={svgEl} + /> + + {#each item.auctions as auction (auction.id)} + {@const [x, y] = getAuctionCoordinates(auction)} + <circle + cx={x} + cy={y} + r="1" + stroke-width="4" + fill={auction.bin ? '#11b' : '#1b1'} + class="auction-point-{auction.id}" + /> + <!-- class:selected-auction={currentlyPreviewedAuction?.auction?.id === auction?.id} --> + {/each} +</svg> + +<style> + .item-auction-history { + height: 10em; + width: 100%; + } + + svg :global(.selected-auction) { + stroke: #06e7; + } +</style> diff --git a/src/lib/GlobalTooltip.ts b/src/lib/GlobalTooltip.ts index d2c1020..f4bc381 100644 --- a/src/lib/GlobalTooltip.ts +++ b/src/lib/GlobalTooltip.ts @@ -52,7 +52,6 @@ export function registerItem(itemEl: HTMLElement) { }) itemEl.addEventListener('click', e => { tooltipLocked = !tooltipLocked - moveTooltipToMouse(e) tooltipEl.style.display = 'block' if (tooltipLocked) { tooltipEl.style.userSelect = 'auto' @@ -64,6 +63,7 @@ export function registerItem(itemEl: HTMLElement) { const loreHtml = itemEl.getElementsByClassName('tooltip-lore')[0].innerHTML const nameHtml = itemEl.getElementsByClassName('tooltip-name')[0].innerHTML tooltipEl.innerHTML = `<p class="item-lore-name">${nameHtml}</p><p class="item-lore-text">${loreHtml}</p>` + moveTooltipToMouse(e) }) document.addEventListener('mousedown', e => { if (tooltipLocked && !tooltipEl.contains(e.target as Node)) { diff --git a/src/lib/api.ts b/src/lib/api.ts index 689a952..e3559e1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,2 +1,3 @@ // the trailing slash is required export const API_URL = 'https://skyblock-api.matdoes.dev/' +// export const API_URL = 'http://localhost:8080/'
\ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e34d573..c094db4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,5 @@ +import type { SimpleAuctionSchema } from "./APITypes" + export const colorCodes: { [key: string]: string } = { '0': '#000000', // black '1': '#0000be', // blue @@ -219,4 +221,10 @@ export function skyblockTime(year: number, month = 1, day = 1) { if (month) time += 37200000 * (month - 1) if (day) time += 1200000 * (day - 1) return time -}
\ No newline at end of file +} + +export interface PreviewedAuctionData { + pageX: number + pageY: number + auction: SimpleAuctionSchema +} diff --git a/src/routes/auctionprices.svelte b/src/routes/auctionprices.svelte new file mode 100644 index 0000000..9e7615c --- /dev/null +++ b/src/routes/auctionprices.svelte @@ -0,0 +1,210 @@ +<script lang="ts" context="module"> + import type { Load } from '@sveltejs/kit' + import { API_URL } from '$lib/api' + + export const load: Load = async ({ params, fetch }) => { + const auctionItemsPromise = fetch(`${API_URL}auctionitems`).then(r => r.json()) + const data = await fetch(`${API_URL}auctionprices`).then(r => r.json()) + const auctionItems = await auctionItemsPromise + + return { + props: { + data, + auctionItems, + }, + } + } +</script> + +<script lang="ts"> + import Header from '$lib/Header.svelte' + import Head from '$lib/Head.svelte' + import { cleanId, removeFormattingCode, toTitleCase, type PreviewedAuctionData } from '$lib/utils' + import type { ItemAuctionsSchema } from '$lib/APITypes' + import AuctionPriceScatterplot from '$lib/AuctionPriceScatterplot.svelte' + import AuctionPreviewTooltip from '$lib/AuctionPreviewTooltip.svelte' + import { browser } from '$app/env' + import Item from '$lib/minecraft/Item.svelte' + import furfskyReborn from 'skyblock-assets/matchers/furfsky_reborn.json' + + export let data: ItemAuctionsSchema[] + export let auctionItems: Record<string, { display: { name: string }; vanillaId?: string }> + + let currentlyPreviewedAuction: PreviewedAuctionData | null = null + + let query: string = '' + + $: queryNormalized = query.toLowerCase() + + let allMatchingItemIds: string[] + $: { + pageNumber = 0 + allMatchingItemIds = Object.entries(auctionItems) + .filter(a => removeFormattingCode(a[1]?.display.name.toLowerCase()).includes(queryNormalized)) + .map(a => a[0]) + } + $: { + if (browser) fetchAndSetItems(allMatchingItemIds.slice(0, 100)) + } + + async function fetchAndSetItems(itemIds: string[]) { + const localQuery = query + const localData = await fetchItems(query.length > 0 ? itemIds : null) + // if the query hasn't changed, update the data + if (query === localQuery) data = localData + } + async function fetchItems(itemIds: null | string[]): Promise<ItemAuctionsSchema[]> { + let url = `${API_URL}auctionprices` + if (itemIds !== null) { + if (itemIds.length === 0) return [] + url += `?items=${itemIds.join(',')}` + } + return await fetch(url).then(r => r.json()) + } + + let pageHeight = 0 + $: { + pageHeight = 0 + } + + // 0 indexed + let pageNumber = 0 + let loadingPage = false + + async function checkScroll() { + if (loadingPage) return + + let pageHeightTemp = window.scrollY + window.innerHeight + if (pageHeightTemp <= pageHeight) return + pageHeight = pageHeightTemp + if (pageHeight >= document.body.scrollHeight - 1000) { + loadingPage = true + pageNumber++ + const itemIds = allMatchingItemIds.slice(pageNumber * 100, (pageNumber + 1) * 100) + if (itemIds.length > 0) { + const shownIds = data.map(d => d.id) + const items = (await fetchItems(itemIds)).filter(i => !shownIds.includes(i.id)) + if (items.length > 0) data = [...data, ...items] + } + loadingPage = false + } + } + + $: { + if (browser && !currentlyPreviewedAuction) { + for (const el of document.getElementsByClassName('selected-auction')) + el.classList.remove('selected-auction') + } + } +</script> + +<Head title="SkyBlock Auction Prices" /> +<Header /> + +<svelte:window on:scroll={checkScroll} /> + +<AuctionPreviewTooltip bind:preview={currentlyPreviewedAuction} /> + +<main> + <h1>SkyBlock Auction Prices</h1> + <div class="filter-items-settings"> + <input type="text" id="filter-items-tier" placeholder="Search..." bind:value={query} /> + </div> + <div class="item-list"> + {#each data as item (item.id)} + {@const binAuctions = item.auctions.filter(i => i.bin)} + {@const normalAuctions = item.auctions.filter(i => !i.bin)} + {@const itemData = auctionItems[item.sbId]} + <div class="item-container"> + {#if itemData && itemData.vanillaId} + <div class="item-slot-container"> + <Item item={{ ...itemData, id: item.id }} pack={furfskyReborn} headSize={50} isslot /> + </div> + {/if} + <h2> + {removeFormattingCode( + auctionItems[item.id]?.display.name ?? toTitleCase(cleanId(item.id.toLowerCase())) + )} + </h2> + <div class="auctions-info-text"> + {#if binAuctions.length > 0} + <p> + Cheapest recent BIN: <b> + {binAuctions.reduce((a, b) => (a.coins < b.coins ? a : b)).coins.toLocaleString()} coins + </b> + </p> + {/if} + {#if normalAuctions.length > 0} + <p> + Cheapest recent auction: <b> + {normalAuctions + .reduce((a, b) => (a.coins < b.coins ? a : b)) + .coins.toLocaleString()} coins + </b> + </p> + {/if} + {#if item.auctions.length >= 2} + <p> + Median: + <b> + {[...item.auctions] + .sort((a, b) => a.coins - b.coins) + [Math.floor(item.auctions.length / 2)].coins.toLocaleString()} coins + </b> + </p> + <p> + Volume: + <b> + {parseFloat( + ( + (24 * 60 * 60) / + ((Date.now() / 1000 - item.auctions[0].ts) / item.auctions.length) + ).toPrecision(2) + ).toLocaleString()}/day + </b> + </p> + {/if} + </div> + <div class="item-scatterplot"> + <AuctionPriceScatterplot {item} bind:currentlyPreviewedAuction /> + </div> + </div> + {/each} + {#if data.length === 0} + No results + {/if} + </div> +</main> + +<style> + p { + margin: 0; + } + .item-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16em, 1fr)); + grid-gap: 1em; + margin-top: 1em; + } + .item-container { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.1); + padding: 0.75em; + border-radius: 1em; + } + + .item-scatterplot { + margin-top: 1em; + } + + .auctions-info-text { + color: var(--theme-darker-text); + } + .auctions-info-text b { + color: var(--theme-main-text); + } + + .item-slot-container { + float: right; + } +</style> diff --git a/src/routes/index.svelte b/src/routes/index.svelte index 4c11a88..d22f854 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -49,7 +49,7 @@ <section> <h2>Other SkyBlock tools</h2> <ul id="other-tools-list"> - <li><a>Auction prices (coming soon)</a></li> + <li><a href="/auctionprices">Auction prices</a></li> <li><a href="/leaderboards">Leaderboards</a></li> <li><a>Bazaar (coming soon)</a></li> <li><a href="/chat">Fake chat generator</a></li> diff --git a/svelte.config.js b/svelte.config.js index b4cc10d..8d97638 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -45,10 +45,13 @@ const config = { server: process.env.REPL_ID || process.env.GITPOD_WORKSPACE_ID ? { - hmr: { - protocol: 'wss', - port: 443, - }, + hmr: process.env.GITPOD_WORKSPACE_URL + ? { + host: process.env.GITPOD_WORKSPACE_URL.replace('https://', '3000-'), + protocol: "wss", + clientPort: 443 + } + : true } : {}, }, @@ -150,10 +150,10 @@ dependencies: esbuild "^0.14.21" -"@sveltejs/kit@^1.0.0-next.330": - version "1.0.0-next.330" - resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-1.0.0-next.330.tgz#1865ca9e38eb34633b0e469e833f4f51ebb5eb69" - integrity sha512-Wb95D5tOF8BViZuikqzZLAcupdS7TpXtadNPgpEOxKowmkrW8xjRrrfVdPIkNOLqAP1V+gKInmQ/gFYmnv5EjA== +"@sveltejs/kit@^1.0.0-next.335": + version "1.0.0-next.335" + resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-1.0.0-next.335.tgz#14bd4016633605b9edc8b7a77cd393ca778449db" + integrity sha512-iZutvIJSSNJJGceZOX2ZWqcyRqp9MIPnNWOgOLXqBG/Z/+KLoN8MRI0U79XIw232SAEXhhkwaJtB3UnXQSu85A== dependencies: "@sveltejs/vite-plugin-svelte" "^1.0.0-next.32" chokidar "^3.5.3" @@ -1608,7 +1608,7 @@ svelte-hmr@^0.14.11: resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.14.11.tgz#63d532dc9c2c849ab708592f034765fa2502e568" integrity sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ== -svelte-preprocess@^4.0.0, svelte-preprocess@^4.10.4: +svelte-preprocess@^4.0.0, svelte-preprocess@^4.10.6: version "4.10.6" resolved "https://registry.yarnpkg.com/svelte-preprocess/-/svelte-preprocess-4.10.6.tgz#5f9a53e7ed3b85fc7e0841120c725b76ac5a1ba8" integrity sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ== @@ -1620,7 +1620,7 @@ svelte-preprocess@^4.0.0, svelte-preprocess@^4.10.4: sorcery "^0.10.0" strip-indent "^3.0.0" -svelte@^3.46.4: +svelte@^3.48.0: version "3.48.0" resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.48.0.tgz#f98c866d45e155bad8e1e88f15f9c03cd28753d3" integrity sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ== |