| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- 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<PriceChartPoint[]> {
- 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<HTMLElement> {
- 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<HTMLElement> {
- const recipes: Recipe[] = await cachedFetchJSON(`https://rest.fnar.net/recipes/${ticker}`);
- const section = document.createElement('section');
- for (const recipe of recipes)
- section.innerHTML += `<div class="recipe">
- ${recipe.Inputs.map((i) => `${i.Amount}×${i.CommodityTicker}`).join(' ')}
- <span class="building">${recipe.BuildingTicker}</span>
- ${recipe.Outputs.map((o) => `${o.Amount}×${o.CommodityTicker}`).join(' ')}
- </div>`;
- 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;
- }
|