Line data Source code
1 : import type * as arrow from 'apache-arrow';
2 : import * as React from 'react';
3 :
4 : import * as styles from './live_board.module.css';
5 :
6 : /// A row of the live board, see live_board.sql
7 : export interface BoardRow {
8 : symbol: string;
9 : last: number;
10 : open: number;
11 : change: number;
12 : changeRatio: number | null;
13 : bid: number | null;
14 : ask: number | null;
15 : volume: number;
16 : trades: number;
17 : updatedMs: number;
18 : }
19 :
20 : /// A number of a row, null for null
21 1 : function numberOf(row: any, field: string): number | null {
22 1 : const value = row[field];
23 1 : if (value == null) return null;
24 1 : return Number(value);
25 1 : }
26 :
27 : /// The rows of the result of live_board.sql
28 1 : export function readBoardRows(table: arrow.Table): BoardRow[] {
29 1 : const rows: BoardRow[] = [];
30 1 : for (const row of table) {
31 1 : if (row == null) continue;
32 1 : rows.push({
33 1 : symbol: String(row['symbol']),
34 1 : last: numberOf(row, 'last') ?? 0,
35 1 : open: numberOf(row, 'open') ?? 0,
36 1 : change: numberOf(row, 'change') ?? 0,
37 1 : changeRatio: numberOf(row, 'change_ratio'),
38 1 : bid: numberOf(row, 'bid'),
39 1 : ask: numberOf(row, 'ask'),
40 1 : volume: numberOf(row, 'volume') ?? 0,
41 1 : trades: numberOf(row, 'trades') ?? 0,
42 1 : updatedMs: numberOf(row, 'updated_ms') ?? 0,
43 1 : });
44 1 : }
45 1 : return rows;
46 1 : }
47 :
48 : /// The decimals of a price: cents for prices above a dollar, 4 significant digits below
49 1 : export function formatPrice(value: number | null): string {
50 1 : if (value == null) return '–';
51 1 : if (Math.abs(value) >= 1 || value === 0) {
52 1 : return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
53 : }
54 1 : return value.toLocaleString('en-US', { maximumSignificantDigits: 4 });
55 1 : }
56 :
57 : /// A change with its sign, the sign carries the direction next to the color
58 1 : export function formatChange(value: number): string {
59 1 : const text = formatPrice(Math.abs(value));
60 1 : return value > 0 ? `+${text}` : value < 0 ? `−${text}` : text;
61 1 : }
62 :
63 1 : export function formatRatio(value: number | null): string {
64 1 : if (value == null) return '–';
65 1 : const text = `${(Math.abs(value) * 100).toFixed(2)}%`;
66 1 : return value > 0 ? `+${text}` : value < 0 ? `−${text}` : text;
67 1 : }
68 :
69 1 : const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
70 :
71 : /// The time since an update, e.g. 120ms, 4.2s, 3m
72 1 : function formatAge(ms: number): string {
73 1 : if (ms < 0) ms = 0;
74 1 : if (ms < 1000) return `${Math.round(ms)}ms`;
75 1 : if (ms < 60 * 1000) return `${(ms / 1000).toFixed(1)}s`;
76 1 : if (ms < 60 * 60 * 1000) return `${Math.floor(ms / 60000)}m`;
77 0 : return `${Math.floor(ms / 3600000)}h`;
78 1 : }
79 :
80 : /// The direction class of a change
81 1 : function directionClass(value: number | null): string | undefined {
82 1 : if (value == null || value === 0) return undefined;
83 1 : return value > 0 ? styles.up : styles.down;
84 1 : }
85 :
86 : interface Props {
87 : /// The rows of the board
88 : rows: readonly BoardRow[];
89 : /// The selected symbol
90 : selected: string | null;
91 : /// Select a symbol
92 : onSelect: (symbol: string) => void;
93 : /// The current time, for the age of the updates
94 : now: number;
95 : }
96 :
97 : /// The latest trade of every symbol. A price that changed flashes in the direction of the change.
98 1 : export const LiveBoard: React.FC<Props> = (props: Props) => {
99 : // The previous price per symbol, for the direction of the flash
100 1 : const previous = React.useRef(new Map<string, number>());
101 : // The flashes belong to a result, the renders of the clock in between keep them
102 1 : const flashes = React.useMemo(() => {
103 1 : const flashes = new Map<string, number>();
104 1 : for (const row of props.rows) {
105 1 : const before = previous.current.get(row.symbol);
106 1 : if (before != null && before !== row.last) flashes.set(row.symbol, row.last - before);
107 1 : }
108 1 : return flashes;
109 1 : }, [props.rows]);
110 1 : React.useEffect(() => {
111 1 : previous.current = new Map(props.rows.map(row => [row.symbol, row.last]));
112 1 : }, [props.rows]);
113 :
114 1 : return (
115 1 : <table className={styles.board} data-testid="live-board">
116 1 : <thead>
117 1 : <tr>
118 1 : <th className={styles.symbol_col}>Symbol</th>
119 1 : <th>Last</th>
120 1 : <th>Change</th>
121 1 : <th>Change %</th>
122 1 : <th>Bid</th>
123 1 : <th>Ask</th>
124 1 : <th>Volume</th>
125 1 : <th>Trades</th>
126 1 : <th>Updated</th>
127 1 : </tr>
128 1 : </thead>
129 1 : <tbody>
130 1 : {props.rows.map(row => {
131 1 : const flash = flashes.get(row.symbol);
132 1 : return (
133 1 : <tr
134 1 : key={row.symbol}
135 1 : className={row.symbol === props.selected ? styles.selected : undefined}
136 1 : onClick={() => props.onSelect(row.symbol)}
137 1 : aria-selected={row.symbol === props.selected}
138 : >
139 1 : <td className={styles.symbol_col}>{row.symbol}</td>
140 1 : <td>
141 : {/* A new key restarts the animation of the flash */}
142 1 : <span key={row.last} className={flash == null ? undefined : flash > 0 ? styles.flash_up : styles.flash_down}>
143 1 : {formatPrice(row.last)}
144 1 : </span>
145 1 : </td>
146 1 : <td className={directionClass(row.change)}>{formatChange(row.change)}</td>
147 1 : <td className={directionClass(row.changeRatio)}>{formatRatio(row.changeRatio)}</td>
148 1 : <td>{formatPrice(row.bid)}</td>
149 1 : <td>{formatPrice(row.ask)}</td>
150 1 : <td>{COMPACT.format(row.volume)}</td>
151 1 : <td>{COMPACT.format(row.trades)}</td>
152 1 : <td className={styles.age}>{formatAge(props.now - row.updatedMs)}</td>
153 1 : </tr>
154 : );
155 1 : })}
156 1 : </tbody>
157 1 : </table>
158 : );
159 1 : };
|