import * as Plot from '@observablehq/plot'; import * as d3 from 'd3'; import {cachedFetchJSON} from './cache'; const tickerSelect = document.querySelector('select#ticker') as HTMLSelectElement; const charts = document.querySelector('#charts')!; (async function () { const materials: Material[] = await fetch('https://rest.fnar.net/material/allmaterials').then((r) => r.json()); const selected = document.location.hash.substring(1); for (const mat of materials.sort((a, b) => a.Ticker.localeCompare(b.Ticker))) { const option = document.createElement('option'); option.value = mat.Ticker; option.textContent = `${mat.Ticker} ${mat.Name}`; if (mat.Ticker === selected) option.selected = true; tickerSelect.appendChild(option); } if (selected) render(); })(); tickerSelect.addEventListener('change', async () => { await render(); document.location.hash = tickerSelect.value; }); async function render() { charts.innerHTML = ''; const ticker = tickerSelect.value; const cxpc = await Promise.all([ getCXPC(ticker, 'NC1'), getCXPC(ticker, 'CI1'), getCXPC(ticker, 'IC1'), getCXPC(ticker, 'AI1'), ]); let minDate = null, maxDate = null; let maxPrice = 0, maxTraded = 0; for (const cxPrices of cxpc) for (const p of cxPrices) { if (minDate === null || p.DateEpochMs < minDate) minDate = p.DateEpochMs; if (maxDate === null || p.DateEpochMs > maxDate) maxDate = p.DateEpochMs; if (p.High > maxPrice) maxPrice = p.High; if (p.Traded > maxTraded) maxTraded = p.Traded; } if (minDate === null || maxDate === null) throw new Error('no data'); const dateRange: [Date, Date] = [new Date(minDate), new Date(maxDate)]; const cxpcRange = {dateRange, maxPrice, maxTraded}; charts.append(...await Promise.all([ renderExchange('NC1', ticker, cxpcRange, cxpc[0]), renderExchange('CI1', ticker, cxpcRange, cxpc[1]), renderExchange('IC1', ticker, cxpcRange, cxpc[2]), renderExchange('AI1', ticker, cxpcRange, cxpc[3]), renderRecipes(ticker), ])); } async function getCXPC(ticker: string, cx: string): Promise { const cxpc: PriceChartPoint[] = await cachedFetchJSON(`https://rest.fnar.net/exchange/cxpc/${ticker}.${cx}`); const threshold = Date.now() - 100 * 24 * 60 * 60 * 1000; // work around FIO bug that shows old data return cxpc.filter((p) => p.Interval === 'HOUR_TWELVE' && p.DateEpochMs > threshold); } async function renderExchange(cx: string, ticker: string, cxpcRange: CXPCRange, cxpc: PriceChartPoint[]): Promise { const div = document.createElement('div'); div.append(renderPriceChart(cx, cxpcRange, cxpc)); div.append(renderOrderBook(await cachedFetchJSON(`https://rest.fnar.net/exchange/${ticker}.${cx}`))); return div; } function renderPriceChart(cx: string, {dateRange, maxPrice, maxTraded}: CXPCRange, cxpc: PriceChartPoint[]): SVGSVGElement | HTMLElement { const chartsWidth = charts.getBoundingClientRect().width; const numFormat = Plot.formatNumber(); return Plot.plot({ grid: true, width: chartsWidth < 600 ? chartsWidth : chartsWidth / 2 - 10, height: 400, marginLeft: 26 + 7 * Math.log10(maxPrice), marginRight: 26 + 7 * Math.log10(maxTraded), x: {domain: dateRange}, y: {axis: 'left', label: cx, domain: [0, maxPrice * 1.1]}, marks: [ Plot.axisY(d3.ticks(0, maxTraded * 1.5, 10), { label: 'traded', anchor: 'right', y: (d) => (d / maxTraded) * maxPrice / 3, }), Plot.rectY(cxpc, { x: (p) => new Date(p.DateEpochMs), y: (p) => (p.Traded / maxTraded) * maxPrice / 3, // scale traded to price interval: 'day', fill: '#272', fillOpacity: 0.2, }), Plot.ruleX(cxpc, { x: (p) => new Date(p.DateEpochMs), y1: 'Low', y2: 'High', strokeWidth: 5, stroke: '#42a', }), Plot.dot(cxpc, { x: (p) => new Date(p.DateEpochMs), y: (p) => p.Volume / p.Traded, fill: '#a37', r: 2, }), Plot.crosshairX(cxpc, { x: (p) => new Date(p.DateEpochMs), y: (p) => p.Volume / p.Traded, textStroke: '#111', }), Plot.text(cxpc, Plot.pointerX({ px: (p) => new Date(p.DateEpochMs), py: (p) => p.Volume / p.Traded, dy: -20, frameAnchor: "top-right", fontVariant: "tabular-nums", text: (p) => `${Plot.formatIsoDate(new Date(p.DateEpochMs))}\n` + `avg: $${numFormat(p.Volume / p.Traded)}\n` + `high: ${numFormat(p.High)}\n` + `low: ${numFormat(p.Low)}\n` + `traded: ${numFormat(p.Traded)}` })), ] }); } function renderOrderBook(book: OrderBook): HTMLElement { const table = document.createElement('table'); book.SellingOrders.sort((a, b) => b.ItemCost - a.ItemCost); for (const order of book.SellingOrders) { if (book.MMSell !== null && order.ItemCost >= book.MMSell && order.ItemCount !== null) continue; const row = renderOrder(order); row.classList.add('sell'); table.appendChild(row); } book.BuyingOrders.sort((a, b) => b.ItemCost - a.ItemCost); for (const order of book.BuyingOrders) { if (book.MMBuy !== null && order.ItemCost <= book.MMBuy && order.ItemCount !== null) continue; const row = renderOrder(order); row.classList.add('buy'); table.appendChild(row); } const div = document.createElement('div'); div.classList.add('cxob'); div.appendChild(table); // center the first bid const firstBuyRow: HTMLTableRowElement | null = table.querySelector('tr.buy'); if (firstBuyRow !== null) { const centerFirstBuyOrder = () => { if (div.isConnected) div.scrollTop = Math.max(0, firstBuyRow.offsetTop - div.clientHeight / 2 + 10); else requestAnimationFrame(centerFirstBuyOrder); }; requestAnimationFrame(centerFirstBuyOrder); } return div; } function renderOrder(order: Order): HTMLTableRowElement { const row = document.createElement('tr'); row.insertCell().textContent = order.CompanyName; row.insertCell().textContent = order.CompanyCode; row.insertCell().textContent = order.ItemCount === null ? '∞' : order.ItemCount.toString(); row.insertCell().textContent = `${formatPrice(order.ItemCost)}`; return row; } const formatPrice = new Intl.NumberFormat(undefined, {minimumSignificantDigits: 3, maximumSignificantDigits: 3}).format; async function renderRecipes(ticker: string): Promise { const recipes: Recipe[] = await cachedFetchJSON(`https://rest.fnar.net/recipes/${ticker}`); const section = document.createElement('section'); for (const recipe of recipes) section.innerHTML += `
${recipe.Inputs.map((i) => `${i.Amount}×${i.CommodityTicker}`).join(' ')} ${recipe.BuildingTicker} ${recipe.Outputs.map((o) => `${o.Amount}×${o.CommodityTicker}`).join(' ')}
`; return section; } interface Material { Ticker: string; Name: string; } interface PriceChartPoint { Interval: 'MINUTE_FIVE' | 'MINUTE_FIFTEEN' | 'MINUTE_THIRTY' | 'HOUR_ONE' | 'HOUR_TWO' | 'HOUR_FOUR' | 'HOUR_SIX' | 'HOUR_TWELVE' | 'DAY_ONE' | 'DAY_THREE'; DateEpochMs: number; High: number; Low: number; Volume: number; Traded: number; } interface CXPCRange { dateRange: [Date, Date]; maxPrice: number; maxTraded: number; } interface OrderBook { BuyingOrders: Order[]; SellingOrders: Order[]; MMBuy: number | null; MMSell: number | null; } interface Order { CompanyName: string; CompanyCode: string; ItemCount: number | null; ItemCost: number; } interface Recipe { BuildingTicker: string; Inputs: RecipeMat[]; Outputs: RecipeMat[]; } interface RecipeMat { CommodityTicker: string; Amount: number; }