mat.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import * as Plot from '@observablehq/plot';
  2. import * as d3 from 'd3';
  3. import {cachedFetchJSON} from './cache';
  4. const tickerSelect = document.querySelector('select#ticker') as HTMLSelectElement;
  5. const charts = document.querySelector('#charts')!;
  6. (async function () {
  7. const materials: Material[] = await fetch('https://rest.fnar.net/material/allmaterials').then((r) => r.json());
  8. const selected = document.location.hash.substring(1);
  9. for (const mat of materials.sort((a, b) => a.Ticker.localeCompare(b.Ticker))) {
  10. const option = document.createElement('option');
  11. option.value = mat.Ticker;
  12. option.textContent = `${mat.Ticker} ${mat.Name}`;
  13. if (mat.Ticker === selected)
  14. option.selected = true;
  15. tickerSelect.appendChild(option);
  16. }
  17. if (selected)
  18. render();
  19. })();
  20. tickerSelect.addEventListener('change', async () => {
  21. await render();
  22. document.location.hash = tickerSelect.value;
  23. });
  24. async function render() {
  25. charts.innerHTML = '';
  26. const ticker = tickerSelect.value;
  27. const cxpc = await Promise.all([
  28. getCXPC(ticker, 'NC1'), getCXPC(ticker, 'CI1'),
  29. getCXPC(ticker, 'IC1'), getCXPC(ticker, 'AI1'),
  30. ]);
  31. let minDate = null, maxDate = null;
  32. let maxPrice = 0, maxTraded = 0;
  33. for (const cxPrices of cxpc)
  34. for (const p of cxPrices) {
  35. if (minDate === null || p.DateEpochMs < minDate) minDate = p.DateEpochMs;
  36. if (maxDate === null || p.DateEpochMs > maxDate) maxDate = p.DateEpochMs;
  37. if (p.High > maxPrice) maxPrice = p.High;
  38. if (p.Traded > maxTraded) maxTraded = p.Traded;
  39. }
  40. if (minDate === null || maxDate === null)
  41. throw new Error('no data');
  42. const dateRange: [Date, Date] = [new Date(minDate), new Date(maxDate)];
  43. const cxpcRange = {dateRange, maxPrice, maxTraded};
  44. charts.append(...await Promise.all([
  45. renderExchange('NC1', ticker, cxpcRange, cxpc[0]), renderExchange('CI1', ticker, cxpcRange, cxpc[1]),
  46. renderExchange('IC1', ticker, cxpcRange, cxpc[2]), renderExchange('AI1', ticker, cxpcRange, cxpc[3]),
  47. renderRecipes(ticker),
  48. ]));
  49. }
  50. async function getCXPC(ticker: string, cx: string): Promise<PriceChartPoint[]> {
  51. const cxpc: PriceChartPoint[] = await cachedFetchJSON(`https://rest.fnar.net/exchange/cxpc/${ticker}.${cx}`);
  52. const threshold = Date.now() - 100 * 24 * 60 * 60 * 1000; // work around FIO bug that shows old data
  53. return cxpc.filter((p) => p.Interval === 'HOUR_TWELVE' && p.DateEpochMs > threshold);
  54. }
  55. async function renderExchange(cx: string, ticker: string, cxpcRange: CXPCRange, cxpc: PriceChartPoint[]): Promise<HTMLElement> {
  56. const div = document.createElement('div');
  57. div.append(renderPriceChart(cx, cxpcRange, cxpc));
  58. div.append(renderOrderBook(await cachedFetchJSON(`https://rest.fnar.net/exchange/${ticker}.${cx}`)));
  59. return div;
  60. }
  61. function renderPriceChart(cx: string, {dateRange, maxPrice, maxTraded}: CXPCRange, cxpc: PriceChartPoint[]):
  62. SVGSVGElement | HTMLElement {
  63. const chartsWidth = charts.getBoundingClientRect().width;
  64. const numFormat = Plot.formatNumber();
  65. return Plot.plot({
  66. grid: true,
  67. width: chartsWidth < 600 ? chartsWidth : chartsWidth / 2 - 10,
  68. height: 400,
  69. marginLeft: 26 + 7 * Math.log10(maxPrice),
  70. marginRight: 26 + 7 * Math.log10(maxTraded),
  71. x: {domain: dateRange},
  72. y: {axis: 'left', label: cx, domain: [0, maxPrice * 1.1]},
  73. marks: [
  74. Plot.axisY(d3.ticks(0, maxTraded * 1.5, 10), {
  75. label: 'traded',
  76. anchor: 'right',
  77. y: (d) => (d / maxTraded) * maxPrice / 3,
  78. }),
  79. Plot.rectY(cxpc, {
  80. x: (p) => new Date(p.DateEpochMs),
  81. y: (p) => (p.Traded / maxTraded) * maxPrice / 3, // scale traded to price
  82. interval: 'day',
  83. fill: '#272',
  84. fillOpacity: 0.2,
  85. }),
  86. Plot.ruleX(cxpc, {
  87. x: (p) => new Date(p.DateEpochMs),
  88. y1: 'Low',
  89. y2: 'High',
  90. strokeWidth: 5,
  91. stroke: '#42a',
  92. }),
  93. Plot.dot(cxpc, {
  94. x: (p) => new Date(p.DateEpochMs),
  95. y: (p) => p.Volume / p.Traded,
  96. fill: '#a37',
  97. r: 2,
  98. }),
  99. Plot.crosshairX(cxpc, {
  100. x: (p) => new Date(p.DateEpochMs),
  101. y: (p) => p.Volume / p.Traded,
  102. textStroke: '#111',
  103. }),
  104. Plot.text(cxpc, Plot.pointerX({
  105. px: (p) => new Date(p.DateEpochMs),
  106. py: (p) => p.Volume / p.Traded,
  107. dy: -20,
  108. frameAnchor: "top-right",
  109. fontVariant: "tabular-nums",
  110. text: (p) => `${Plot.formatIsoDate(new Date(p.DateEpochMs))}\n` +
  111. `avg: $${numFormat(p.Volume / p.Traded)}\n` +
  112. `high: ${numFormat(p.High)}\n` +
  113. `low: ${numFormat(p.Low)}\n` +
  114. `traded: ${numFormat(p.Traded)}`
  115. })),
  116. ]
  117. });
  118. }
  119. function renderOrderBook(book: OrderBook): HTMLElement {
  120. const table = document.createElement('table');
  121. book.SellingOrders.sort((a, b) => b.ItemCost - a.ItemCost);
  122. for (const order of book.SellingOrders) {
  123. if (book.MMSell !== null && order.ItemCost >= book.MMSell && order.ItemCount !== null) continue;
  124. const row = renderOrder(order);
  125. row.classList.add('sell');
  126. table.appendChild(row);
  127. }
  128. book.BuyingOrders.sort((a, b) => b.ItemCost - a.ItemCost);
  129. for (const order of book.BuyingOrders) {
  130. if (book.MMBuy !== null && order.ItemCost <= book.MMBuy && order.ItemCount !== null) continue;
  131. const row = renderOrder(order);
  132. row.classList.add('buy');
  133. table.appendChild(row);
  134. }
  135. const div = document.createElement('div');
  136. div.classList.add('cxob');
  137. div.appendChild(table);
  138. // center the first bid
  139. const firstBuyRow: HTMLTableRowElement | null = table.querySelector('tr.buy');
  140. if (firstBuyRow !== null) {
  141. const centerFirstBuyOrder = () => {
  142. if (div.isConnected)
  143. div.scrollTop = Math.max(0, firstBuyRow.offsetTop - div.clientHeight / 2 + 10);
  144. else
  145. requestAnimationFrame(centerFirstBuyOrder);
  146. };
  147. requestAnimationFrame(centerFirstBuyOrder);
  148. }
  149. return div;
  150. }
  151. function renderOrder(order: Order): HTMLTableRowElement {
  152. const row = document.createElement('tr');
  153. row.insertCell().textContent = order.CompanyName;
  154. row.insertCell().textContent = order.CompanyCode;
  155. row.insertCell().textContent = order.ItemCount === null ? '∞' : order.ItemCount.toString();
  156. row.insertCell().textContent = `${formatPrice(order.ItemCost)}`;
  157. return row;
  158. }
  159. const formatPrice = new Intl.NumberFormat(undefined, {minimumSignificantDigits: 3, maximumSignificantDigits: 3}).format;
  160. async function renderRecipes(ticker: string): Promise<HTMLElement> {
  161. const recipes: Recipe[] = await cachedFetchJSON(`https://rest.fnar.net/recipes/${ticker}`);
  162. const section = document.createElement('section');
  163. for (const recipe of recipes)
  164. section.innerHTML += `<div class="recipe">
  165. ${recipe.Inputs.map((i) => `${i.Amount}×${i.CommodityTicker}`).join(' ')}
  166. <span class="building">${recipe.BuildingTicker}</span>
  167. ${recipe.Outputs.map((o) => `${o.Amount}×${o.CommodityTicker}`).join(' ')}
  168. </div>`;
  169. return section;
  170. }
  171. interface Material {
  172. Ticker: string;
  173. Name: string;
  174. }
  175. interface PriceChartPoint {
  176. Interval: 'MINUTE_FIVE' | 'MINUTE_FIFTEEN' | 'MINUTE_THIRTY' | 'HOUR_ONE' | 'HOUR_TWO' | 'HOUR_FOUR' | 'HOUR_SIX' | 'HOUR_TWELVE' | 'DAY_ONE' | 'DAY_THREE';
  177. DateEpochMs: number;
  178. High: number;
  179. Low: number;
  180. Volume: number;
  181. Traded: number;
  182. }
  183. interface CXPCRange {
  184. dateRange: [Date, Date];
  185. maxPrice: number;
  186. maxTraded: number;
  187. }
  188. interface OrderBook {
  189. BuyingOrders: Order[];
  190. SellingOrders: Order[];
  191. MMBuy: number | null;
  192. MMSell: number | null;
  193. }
  194. interface Order {
  195. CompanyName: string;
  196. CompanyCode: string;
  197. ItemCount: number | null;
  198. ItemCost: number;
  199. }
  200. interface Recipe {
  201. BuildingTicker: string;
  202. Inputs: RecipeMat[];
  203. Outputs: RecipeMat[];
  204. }
  205. interface RecipeMat {
  206. CommodityTicker: string;
  207. Amount: number;
  208. }