SLOPSHOPPER

vn-stockmarket-heatmap

Live Vietnam stock-market heatmap and price board (SSI iBoard) in a Claude Code pane

newpanecommandstatusnetworktimer
★ 1v0.2.0MITupdated 2026-10-05NgoTuong12345/claude-mod-vn-stock-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vn-stockmarket-heatmap
│ ┃ VN Stock Watch ✕ › fix the failing auth test and add an audit log call │ ┃ HCMC 12:28:01 │ ┃ error: JSON Parse error: Unexpected EOF · ⏺ Read(src/auth.ts) │ ┃ Lunch break · Source: SSI API ⎿ Read 6 lines │ ┃ [ Sector ][ Exch ]All ▾[ Reset ] ⏺ Update(src/auth.ts) │ ┃ Loading… ⎿ Added 2 lines, removed 1 line │ ┃ [ VN30 ] ⏺ Bash(bun test) │ ┃ Sym Price Chg % Vol Buy ⎿ 3 pass, 1 fail │ ┃ Sell F.Buy F.Sell Room │ ┃ Loading VN30… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ My Watchlist ] │ ┃ Search : ticker, Enter adds to watchlist ⏎ a ✻ Worked for 42s · done 4:20 PM │ │ › /vn-stock-watch │ ⎿ vn-stockmarket-heatmap: VN Stock Watch opened (polls SSI iBoard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · VN Stock Watch
HCMC 12:28:01 error: JSON Parse error: Unexpected EOF · Lunch break · Source: SSI API [ Sector ][ Exch ]All ▾[ Reset ] Loading… [ VN30 ] Sym Price Chg % Vol Buy Sell F.Buy F.Sell Room Loading VN30… [ My Watchlist ] Search : ticker, Enter adds to watchlist ⏎ add
README

VN Stock Watch — Claude Code mod

<img src="docs/desktop_preview.png" width="560" alt="VN Stock Watch in the Claude Code desktop app">

Live Vietnam stock-market heatmap and price board (SSI iBoard data) in a Claude Code pane, with a VN30 table, watchlist and portfolio. Works in the terminal (claude) and the Claude Code desktop app. No API key, no dependencies, no build step.

Install

Via /plugin (marketplace)

In Claude Code (terminal or desktop):

/plugin marketplace add NgoTuong12345/claude-mod-vn-stock-watch
/plugin install vn-stockmarket-heatmap@vn-stock-watch
/reload-plugins

Then run /vn-stock-watch. State is still saved to ~/.claude/state/. Update with /plugin marketplace update vn-stock-watch.

Manual clone

Manual clone: the mod must live at ~/.claude/mods/vn-stockmarket-heatmap (on Windows: %USERPROFILE%\.claude\mods\vn-stockmarket-heatmap). The folder name matters: state is saved to ~/.claude/state/, two levels up from the mod.

git clone https://github.com/NgoTuong12345/claude-mod-vn-stock-watch.git ~/.claude/mods/vn-stockmarket-heatmap

Install via a coding agent

Paste this into Claude Code (terminal or desktop), or any coding agent with shell access:

Clone https://github.com/NgoTuong12345/claude-mod-vn-stock-watch.git into ~/.claude/mods/vn-stockmarket-heatmap (create ~/.claude/mods if missing; on Windows use %USERPROFILE%\.claude\mods). Keep that exact folder name. Do not run a build, there is none. Then run claude plugin validate ~/.claude/mods/vn-stockmarket-heatmap and report the result. Finally tell me to run /reload-plugins and then /vn-stock-watch.

To update later: git -C ~/.claude/mods/vn-stockmarket-heatmap pull, then /reload-plugins.

Enable /vn-stock-watch

A mod in ~/.claude/mods/ is picked up by Claude Code, but a running session only loads it on request:

  1. In the Claude Code prompt (terminal or desktop) run /reload-plugins. A new session picks it up by itself.
  2. Run /vn-stock-watch. The pane opens and starts polling SSI iBoard.

Re-run /reload-plugins after every code change or git pull. If /vn-stock-watch is not found, run /reload-plugins again and check that claude plugin validate ~/.claude/mods/vn-stockmarket-heatmap passes and the folder name is exact.

To try it without installing into mods/: claude --plugin-dir /path/to/vn-stockmarket-heatmap.

Use

Both surfaces: sector/exchange views (desktop: "Group by" and multi-select "Filter" dropdowns: each pick toggles a sector, chips remove them), sector filter, index strip (VN-Index, HNX-Index, UPCOM-Index), VN30 table, watchlist table with 1M/3M/YTD returns, foreign buy/sell volume and remaining room (F.Buy, F.Sell, Room), and a Remove row with one button per watchlist ticker. Price changes flash the whole row until the next poll.

  • Add a ticker: type FPT in the search box and press Enter (a partial code like FP takes the top suggestion). Remove: -FPT, or press its button in the Remove row.
  • Portfolio (terminal): open the My Watchlist tab and type FPT 1000 62.4 (quantity, cost in thousand VND). It shows value, P&L and a total row.
  • Watchlist and portfolio persist in ~/.claude/state/vn-heatmap.json.

Terminal: hotkeys s/e (sector/exchange), f (cycle sector filter), d (reset). Mouse wheel over the treemap zooms it.

Desktop: the treemap is an SVG sized to the pane.

  • Map size: click a bar in the "Map size" slider; ⟲ resets to auto. (The desktop host gives plugins no pointer drag, so it is click-to-set, not draggable.)
  • Text size: click a bar in the "Text size" slider (12–32px, 1px steps; ⟲ resets). Zoom in far enough and the VN30 table splits into two side-by-side tables.

Layout

.claude-plugin/plugin.json   plugin manifest
hooks/                       mod source (index.tsx, watch.ts, treemap.ts, svgmap.ts, types.d.ts, hooks.json)
tests/                       self-checks (watch.check.ts, watch.test.tsx)
docs/ssi-iboard-api.md       data-source API notes

Test

bun tests/watch.check.ts                       # parse/apply/suggest logic; also hits live SSI
claude plugin validate ~/.claude/mods/vn-stockmarket-heatmap

(.claude-plugin/types/ is generated by Claude Code for type-checking and is not committed.)

Troubleshooting

  • Pane says "Nothing to show yet": the render was refused or failed. Check ~/.claude/state/vn-heatmap.err.log and the dim ui.render (Pane) refused: line in the transcript.
  • No data / SSI error: SSI iBoard rate-limits (HTTP 429). It retries on the next poll.
  • Prices outside Mon–Fri 09:00–15:00 (UTC+7) are the last close; polling slows down while the market is closed.

License

MIT

Source 5 files
hooks/index.tsx 738 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Idx, Lists, Meta, Quote, Tab, View, WRow } from './types'
5import { buildGrid, runs, ZOOMS } from './treemap'
6import { buildSvg, tableSvg, TABLE_FS, TABLE_FS_DEF } from './svgmap'
7import type { TRow } from './svgmap'
8import type { Group } from './treemap'
9import { apply, EXCHANGE, HEADERS, MAX, parse, rowsOf, sectorsOf, SSI, STATS, suggest, toQuote, toRow, toTicker } from './watch'
10import type { Ticker } from './watch'
11
12const PANE = 'vn-stock-watch'
13const OPEN_MS = 15_000 // 3 exchange snapshots (~2 MB) per poll; don't go much lower
14const CLOSED_EVERY = 8 // ticks per real fetch while the market is closed (~2 min)
15
16const quotes = atom({ plugin: 'vn-stockmarket-heatmap', key: 'quotes' } as const, [] as Quote[])
17const meta = atom({ plugin: 'vn-stockmarket-heatmap', key: 'meta' } as const, { at: 0, status: 'idle', n: 0 } as Meta)
18const only = atom({ plugin: 'vn-stockmarket-heatmap', key: 'only' } as const, '')
19const idx = atom({ plugin: 'vn-stockmarket-heatmap', key: 'idx' } as const, [] as Idx[])
20const view = atom({ plugin: 'vn-stockmarket-heatmap', key: 'view' } as const, 'sector' as View)
21const tab = atom({ plugin: 'vn-stockmarket-heatmap', key: 'tab' } as const, 'vn30' as Tab)
22const lists = atom({ plugin: 'vn-stockmarket-heatmap', key: 'lists' } as const, { watch: [], port: {} } as Lists)
23const wrows = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wrows' } as const, [] as WRow[])
24const wlrows = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wlrows' } as const, [] as WRow[])
25const wmeta = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wmeta' } as const, { at: 0, status: 'idle', n: 0 } as Meta)
26const zoom = atom({ plugin: 'vn-stockmarket-heatmap', key: 'zoom' } as const, 0)
27const wq = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wq' } as const, '')
28const mapadj = atom({ plugin: 'vn-stockmarket-heatmap', key: 'mapadj' } as const, 0)
29const tzoom = atom({ plugin: 'vn-stockmarket-heatmap', key: 'tzoom' } as const, -1)
30const wmsg = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wmsg' } as const, '')
31
32const WATCH_MS = 3_000
33const SL_N = 28 // map-size slider: segments, the auto-size one, px per segment
34const SL_AUTO = 8
35const SL_STEP = 40
36// Outside the mod dir so a save never hot-reloads the module: ~/.claude/state/vn-heatmap.json
37// ~/.claude/state, whether loaded from ~/.claude/mods/<name> or installed under ~/.claude/plugins/cache/...
38const stateDir = ($: any) => {
39  const r = String($.plugin.root).split(String.fromCharCode(92)).join('/')
40  const i = r.indexOf('/plugins/')
41  return i >= 0 ? `${r.slice(0, i)}/state` : `${r}/../../state`
42}
43const listsFile = ($: any) => `${stateDir($)}/vn-heatmap.json`
44let universe: Ticker[] = [] // ~2k rows; module-level, not an atom
45let wtick = 0
46
47const ssi = async ($: any, path: string, init?: any) => {
48  const r = await $.http.fetch(`${SSI}${path}`, { ...init, headers: { ...HEADERS, ...init?.headers } })
49  return rowsOf(r.status, r.ok, r.text)
50}
51const fetchGroup = async ($: any, g: string) => (await ssi($, `/stock/group/${g}`)).map(toRow)
52const fetchMany = async ($: any, syms: string[]) =>
53  syms.length
54    ? (await ssi($, '/stock/multiple', {
55        method: 'POST',
56        headers: { 'content-type': 'application/json' },
57        body: JSON.stringify({ stocks: syms.slice(0, MAX) }),
58      })).map(toRow)
59    : []
60// 1M / 3M / YTD return vs daily closes (iboard-api chart history, close in thousand VND). Cached 30 min per symbol.
61const retCache = new Map<string, { at: number; v: WRow['rt'] }>()
62const getRets = async ($: any, sym: string, price: number): Promise<WRow['rt']> => {
63  let e = retCache.get(sym)
64  if (!e || Date.now() - e.at > 1_800_000) {
65    const y = new Date().getUTCFullYear()
66    const from = Math.floor(Date.UTC(y - 1, 11, 1) / 1000)
67    const to = Math.floor(Date.now() / 1000) + 86_400
68    const r = await $.http.fetch(`https://iboard-api.ssi.com.vn/statistics/charts/history?resolution=1D&symbol=${sym}&from=${from}&to=${to}`, { headers: HEADERS })
69    const d = JSON.parse(r.text).data as { t: number[]; c: number[] }
70    const at = (ts: number) => { let b: number | undefined; for (let i = 0; i < d.t.length; i++) if (d.t[i]! <= ts) b = d.c[i]; return b } // last close on/before ts
71    const m = (n: number) => { const o = new Date(); o.setUTCMonth(o.getUTCMonth() - n); return Math.floor(o.getTime() / 1000) }
72    e = { at: Date.now(), v: { m1: at(m(1)) ?? 0, m3: at(m(3)) ?? 0, y: at(Math.floor(Date.UTC(y, 0, 1) / 1000) - 1) ?? 0 } }
73    retCache.set(sym, e)
74  }
75  const b = e.v!
76  const f = (base: number) => (base ? (price / 1000 / base - 1) * 100 : null)
77  return { m1: f(b.m1!), m3: f(b.m3!), y: f(b.y!) }
78}
79const fetchUniverse = async ($: any) => (await ssi($, '/stock/stock-info')).map(toTicker)
80
81const refreshWatch = async ($: any) => {
82  try {
83    const t = await read($, tab)
84    const l = await read($, lists)
85    const r = t === 'vn30' ? await fetchGroup($, 'VN30') : await fetchMany($, t === 'watch' ? l.watch : Object.keys(l.port))
86    if (t === 'port') await Promise.all(r.map(async x => { x.rt = await getRets($, x.s, x.p).catch(() => undefined) }))
87    const order = t === 'vn30' ? [] : t === 'watch' ? l.watch : Object.keys(l.port)
88    if (order.length) r.sort((a, b) => order.indexOf(a.s) - order.indexOf(b.s))
89    else r.sort((a, b) => a.s.localeCompare(b.s))
90    if ((await read($, tab)) !== t) return // tab switched mid-flight
91    const prev = new Map((await read($, wrows)).map(x => [x.s, x.p]))
92    for (const x of r) { const o = prev.get(x.s); x.fl = o && o !== x.p ? (x.p > o ? 1 : -1) : 0 } // flash until next poll
93    await update($, wrows, () => r)
94    const wl = l.watch.length ? await fetchMany($, l.watch) : []
95    wl.sort((a, b) => l.watch.indexOf(a.s) - l.watch.indexOf(b.s))
96    await Promise.all(wl.map(async x => { x.rt = await getRets($, x.s, x.p).catch(() => undefined) }))
97    const pw = new Map((await read($, wlrows)).map(x => [x.s, x.p]))
98    for (const x of wl) { const o = pw.get(x.s); x.fl = o && o !== x.p ? (x.p > o ? 1 : -1) : 0 }
99    await update($, wlrows, () => wl)
100    await update($, wmeta, () => ({ at: Date.now(), status: 'live', n: r.length }))
101  } catch (err) {
102    await update($, wmeta, m => ({ ...m, status: `SSI error: ${String((err as Error).message ?? err).slice(0, 50)}` }))
103  }
104}
105
106const setTab = async ($: any, t: Tab) => {
107  await update($, tab, () => t)
108  await update($, wrows, () => [])
109  void refreshWatch($)
110}
111
112const submit = async ($: any, text: string) => {
113  const t = await read($, tab)
114  const port = t === 'port'
115  if (!universe.length) universe = await fetchUniverse($).catch(() => [])
116  const known = new Set(universe.map(x => x.code))
117  const first = suggest(universe, text)[0]?.code
118  const typed = text.trim().toUpperCase()
119  // Enter on a partial code takes the top suggestion ("FP" -> FPT), keeping any qty/cost after it.
120  const head = typed.split(/\s+/)[0] ?? ''
121  const fixed = !head.startsWith('-') && !known.has(head) && first ? typed.replace(head, first) : typed
122  const cmd = parse(fixed, s => known.has(s), port)
123  if (cmd.op === 'bad') return update($, wmsg, () => cmd.why)
124  const next = apply(await read($, lists), cmd, port)
125  await update($, lists, () => next)
126  const saved = await $.fs.write(listsFile($), JSON.stringify(next, null, 1)).then(() => '', (err: Error) => ` (save failed: ${String(err?.message ?? err).slice(0, 40)})`)
127  await update($, wq, () => '')
128  await update($, wmsg, () => `${cmd.op === 'del' ? 'removed' : 'added'} ${cmd.s}${port ? ' (portfolio)' : t === 'vn30' ? ' to watchlist' : ''}${saved}`)
129  void refreshWatch($)
130}
131
132const k1 = (v: number) => (v / 1000).toFixed(2)
133const sgn = (v: number, d = 2) => `${v > 0 ? '+' : ''}${v.toFixed(d)}`
134const vol = (v: number) => (v >= 1e6 ? `${(v / 1e6).toFixed(2)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}K` : String(v))
135const money = (v: number) => (Math.abs(v) >= 1e9 ? `${(v / 1e9).toFixed(2)}B` : `${(v / 1e6).toFixed(1)}M`)
136const tone = (r: WRow) => (r.k === 'c' ? '#9B59D0' : r.k === 'f' ? '#17A2B8' : r.ch > 0 ? '#2FA55A' : r.ch < 0 ? '#C0392B' : '#C9A227')
137
138const IDX: [string, string][] = [['VNINDEX', 'VN-Index'], ['HNXIndex', 'HNX-Index'], ['HNXUpcomIndex', 'UPCOM-Index']]
139const DAY = 86_400_000
140// Outside the mod dir (no hot reload on save): listed shares per symbol, refreshed weekly.
141const capsFile = ($: any) => `${stateDir($)}/vn-heatmap-shares.json`
142let sector = new Map<string, string>()
143let sectorAt = 0
144let shares: Record<string, number> = {}
145let sharesAt = -1 // -1 = cache file not read yet
146let filling = false
147let capsHave = 0
148let capsOf = 0
149let isOpen = false
150let tick = 0
151let gridTop = 0 // treemap rows in the pane tree, set by the last render; the wheel zooms over them
152let gridLen = 0
153let scrollOff = 0
154const refetchAll = () => sharesAt > 0 && Date.now() - sharesAt > 7 * DAY
155
156const ssiGet = async ($: any, url: string) => {
157  const r = await $.http.fetch(url, { headers: HEADERS })
158  if (r.status === 429) throw new Error('SSI 429')
159  if (!r.ok) throw new Error(`SSI ${r.status}`)
160  return JSON.parse(r.text).data
161}
162
163const pullIdx = async ($: any): Promise<Idx[]> =>
164  Promise.all(IDX.map(async ([id, name]) => {
165    const d = await ssiGet($, `${SSI}/exchange-index/${id}`)
166    return { name, price: d.indexValue, change: d.change, pct: d.changePercent }
167  }))
168
169// No bulk share-count endpoint. /stock/{sym}.listedShare is 0 for VCB/HPG/VIC/ACB, so use SSMI
170// finance-indicator sharesOutstanding (per symbol; SSI 429s past ~2 req/s, so one at a time).
171// ponytail: ~1.5k symbols x ~0.75s = ~20 min first fill; tiles appear as caps land (biggest traders first).
172const fillShares = async ($: any, syms: string[]) => {
173  if (filling) return
174  filling = true
175  try {
176    const all = refetchAll()
177    const todo = all ? syms : syms.filter(s => !(s in shares))
178    for (let i = 0; i < todo.length; i++) {
179      try {
180        const d = await ssiGet($, `${STATS}/company/ssmi/finance-indicator?symbol=${todo[i]}`)
181        if (!Array.isArray(d)) throw new Error('SSI bad body') // retry, don't cache
182        const n = Number(d[0]?.sharesOutstanding) // newest report first
183        shares[todo[i]!] = n > 0 ? n : 0 // 0 = no report (no tile, not retried until weekly refresh)
184      } catch {
185        await $.clock.sleep(30_000) // 429, throttle body or network: back off, retry same symbol
186        i--
187        continue
188      }
189      await $.clock.sleep(400)
190      if (i % 50 === 49) await $.fs.write(capsFile($), JSON.stringify({ at: sharesAt, shares })).catch(() => {})
191    }
192    if (all || !sharesAt) sharesAt = Date.now()
193    await $.fs.write(capsFile($), JSON.stringify({ at: sharesAt, shares })).catch(() => {})
194  } finally {
195    filling = false
196  }
197}
198
199const pull = async ($: any): Promise<Quote[]> => {
200  if (sharesAt < 0) {
201    sharesAt = 0
202    try { ({ at: sharesAt, shares } = JSON.parse(await $.fs.read(capsFile($)))) } catch {} // first run: no file yet
203  }
204  if (Date.now() - sectorAt > DAY) {
205    sector = sectorsOf(await $.http.fetch(`${STATS}/company/sectors-data`, { headers: HEADERS }).then((r: any) => JSON.parse(r.text).data))
206    sectorAt = Date.now()
207  }
208  const raw = (await Promise.all(['hose', 'hnx', 'upcom'].map(e => ssiGet($, `${SSI}/stock/type/s/${e}`)))).flat() as any[]
209  const syms = [...raw].sort((a, b) => (b.nmTotalTradedValue ?? 0) - (a.nmTotalTradedValue ?? 0)).map(x => x.stockSymbol as string)
210  if (refetchAll() || syms.some(s => !(s in shares))) void fillShares($, syms)
211  capsHave = syms.filter(s => s in shares).length
212  capsOf = syms.length
213  return raw.map(x => toQuote(x, sector, shares)).filter((x): x is Quote => !!x).sort((a, b) => b.m - a.m)
214}
215
216// VN market hours: Mon-Fri 09:00-15:00 (UTC+7)
217const isMarketOpen = () => {
218  const t = new Date(Date.now() + 7 * 3600_000)
219  const d = t.getUTCDay()
220  const h = t.getUTCHours() + t.getUTCMinutes() / 60
221  return d >= 1 && d <= 5 && h >= 9 && h < 15.1
222}
223// Status label from the exchange schedule (HOSE: ATO 9:00-9:15, ATC 14:30-14:45, put-through to 15:00; UPCOM trades to 15:00)
224const session = (): { t: string; c: string } => {
225  const x = new Date(Date.now() + 7 * 3600_000)
226  const d = x.getUTCDay()
227  const h = x.getUTCHours() + x.getUTCMinutes() / 60
228  const G = '#2ECC71', A = '#F5B041', R = '#FF5A4D'
229  if (d < 1 || d > 5) return { t: 'Closed', c: R }
230  if (h >= 9 && h < 9.25) return { t: 'ATO', c: A }
231  if (h >= 9.25 && h < 11.5) return { t: 'Open', c: G }
232  if (h >= 11.5 && h < 13) return { t: 'Lunch break', c: R }
233  if (h >= 13 && h < 14.5) return { t: 'Open', c: G }
234  if (h >= 14.5 && h < 14.75) return { t: 'ATC', c: A }
235  if (h >= 14.75 && h < 15) return { t: 'Post-close', c: A }
236  return { t: 'Closed', c: R }
237}
238
239const refresh = async ($: any) => {
240  try {
241    const q = await pull($)
242    await update($, quotes, () => q)
243    await pullIdx($).then(ix => update($, idx, () => ix), () => {}) // indices are best-effort
244    await update($, meta, () => ({ at: Date.now(), status: 'live', n: q.length }))
245    const up = q.filter(x => x.p > 0).length
246    const dn = q.filter(x => x.p < 0).length
247    $.ui.status(`VN ▲${up} ■${q.length - up - dn} ▼${dn}`)
248  } catch (err) {
249    void $.fs.write(`${stateDir($)}/vn-heatmap.err.log`, `${new Date().toISOString()} ${String((err as Error)?.stack ?? err)}
250`).catch(() => {})
251    await update($, meta, m => ({ ...m, status: `error: ${String((err as Error).message ?? err).slice(0, 60)}` }))
252  }
253}
254
255const COLOR = (x: Quote) => {
256  if (x.k === 'c') return '#9B59D0'
257  if (x.k === 'f') return '#17A2B8'
258  if (x.p >= 3) return '#0E8A3E'
259  if (x.p >= 1) return '#2FA55A'
260  if (x.p > 0) return '#5BBE7F'
261  if (x.p === 0) return '#C9A227'
262  if (x.p > -1) return '#D9706B'
263  if (x.p > -3) return '#C0392B'
264  return '#8E1B12'
265}
266const fmt = (p: number) => `${p > 0 ? '+' : ''}${p.toFixed(1)}`
267const wavg = (xs: Quote[]) => xs.reduce((a, x) => a + x.p * x.m, 0) / (xs.reduce((a, x) => a + x.m, 0) || 1)
268
269let timersOn = false // module-level: session.start does not re-run after a hot reload, so start lazily
270const startTimers = ($: any) => {
271  if (timersOn) return
272  timersOn = true
273  $.clock.every(OPEN_MS, async () => {
274    if (!isOpen) return
275    tick++
276    if (!isMarketOpen() && tick % CLOSED_EVERY !== 1) return
277    await refresh($)
278  })
279  $.clock.every(WATCH_MS, async () => {
280    if (!isOpen) return
281    wtick++
282    if (!isMarketOpen() && wtick % 40 !== 1) return // ~2 min while closed
283    await refreshWatch($)
284  })
285}
286
287export const register: Register = on => {
288  on('session.start', async ($, e, next) => {
289    await $.command.register({ name: 'vn-stock-watch', description: 'Live Vietnam stock-market heatmap and price board pane' })
290    try {
291      const saved = JSON.parse(await $.fs.read(listsFile($))) as Lists
292      if (Array.isArray(saved.watch) && saved.port) await update($, lists, () => saved)
293    } catch {} // first run: no file yet
294    return next(e)
295  })
296
297  on('command.run', { command: 'vn-stock-watch' }, async $ => {
298    isOpen = true
299    startTimers($)
300    tick = 0
301    wtick = 0
302    await $.ui.open({ id: PANE, title: 'VN Stock Watch' })
303    void refresh($)
304    void refreshWatch($)
305    if (!universe.length) void fetchUniverse($).then(u => { universe = u }, () => {})
306    return { text: 'VN Stock Watch opened (polls SSI iBoard every 15s while the market is open).' }
307  })
308
309  // Wheel over the treemap zooms (up = in: more, tighter labels; down = out: fewer, roomier); elsewhere it scrolls as usual.
310  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
311    const r = e.pointer ? e.pointer.row + scrollOff - gridTop : -1
312    if (e.origin.kind !== 'person' || !e.pointer || r < 0 || r >= gridLen) return next(e)
313    await update($, zoom, z => Math.max(0, Math.min(ZOOMS.length - 1, z - Math.sign(e.by))))
314    return {}
315  })
316
317  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
318    if (!isOpen) { // pane survived a /reload-plugins: module state reset, command.run not re-run
319      isOpen = true
320      startTimers($)
321      void refresh($)
322      void refreshWatch($)
323    }
324    try {
325    const { Box, Text, Button, Input, Select, Svg } = $.ui.resolve(e) as any
326    const desktop = e.surface === 'desktop'
327    const q = await read($, quotes)
328    const m = await read($, meta)
329    const v = await read($, view)
330    const sel = await read($, only)
331    const z = await read($, zoom)
332    const ix = await read($, idx)
333    const cols = Math.max(30, (e.props as any)?.bodyColumns ?? e.viewport?.columns ?? 80)
334    const rows = (e.viewport?.rows ?? 30) - 6
335
336    const age = m.at ? Math.round((Date.now() - m.at) / 1000) : 0
337
338    const sectors = [...new Set(q.map(x => x.g))].sort()
339    const picked = sel ? sel.split('|') : []
340    const pool = picked.length ? q.filter(y => picked.includes(y.g)) : q
341    const by = new Map<string, Quote[]>()
342    for (const x of pool) {
343      const key = v === 'sector' ? x.g : EXCHANGE[x.x] || '?'
344      by.set(key, [...(by.get(key) ?? []), x])
345    }
346    const groups: Group[] = [...by.entries()].map(([name, xs]) => {
347      const avg = wavg(xs)
348      const s = SHORT[name] ?? name.split(' ')[0] ?? name
349      return {
350        heads: [`${name} ${fmt(avg)}`, `${s} ${fmt(avg)}`, s, ...[TINY[name]].filter((x): x is string => !!x)],
351        tone: avg >= 0 ? '#7CE0A0' : '#FF8A80',
352        v: xs.reduce((s, x) => s + x.m, 0),
353        tiles: xs.map(x => ({ label: x.s, sub: fmt(x.p), bg: COLOR(x), v: x.m })),
354      }
355    }).sort((a, b) => b.v - a.v)
356    const n2 = (v: number) => v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
357    const ixs = ix.map(x => ({
358      key: x.name,
359      head: `${x.name} ${n2(x.price)} `,
360      chg: `${x.change > 0 ? '▲' : x.change < 0 ? '▼' : '■'}${n2(x.change)} (${n2(x.pct)}%)`,
361      color: x.change > 0 ? '#2FA55A' : x.change < 0 ? '#C0392B' : '#C9A227',
362    }))
363    const ixLen = ixs.reduce((n, x) => n + x.head.length + x.chg.length + 3, 0)
364    const ixRows = (ixs.length ? Math.ceil(ixLen / cols) : 0) + (sel && !desktop ? 1 : 0) // + chips row
365    const W = Math.max(30, cols - 1)
366    const narrow = W < 62 // short labels so no control row wraps
367    const bodyRows = (e.props as any)?.scroll?.bodyRows ?? (e.viewport?.rows ?? 30) - 3
368    const t = await read($, tab)
369    const l = await read($, lists)
370    const wr = await read($, wrows)
371    const wm = await read($, wmeta)
372    const wl = (await read($, wlrows)).slice(0, 8)
373    const q0 = await read($, wq)
374    const msg = await read($, wmsg)
375    const cap = Math.max(1, Math.floor(bodyRows * 0.3) - 4)
376    const kind = t === 'port' ? 'port' : 'vol'
377    // Two side-by-side tables only when the list overflows one AND each half still fits (dropping at most Buy/Sell);
378    // otherwise one table, dropping low-priority columns until it fits. Never wraps.
379    const dualCols = t !== 'port' && wr.length > cap ? fit(kind, Math.floor((W - 2) / 2), kind === 'vol' ? 0 : 2) : undefined
380    const dual = !!dualCols
381    const cs = dualCols ?? fit(kind, W, kind === 'vol' ? 0 : 99) ?? (kind === 'vol' ? COLS[kind] : COLS[kind].filter(c => !DROP[kind].includes(c.h)))
382    const shown = wr // pane scrolls: show every row instead of truncating
383    const half = dual ? Math.ceil(shown.length / 2) : shown.length
384    const H = Math.max(8, Math.floor(bodyRows * 0.55) - 3 - ixRows) // fixed share; tables below scroll
385    const grid = q.length && !desktop ? buildGrid(groups, W, H, z) : []
386    gridTop = 3 + ixRows // clock, status, buttons, index row(s)
387    gridLen = grid.length
388    scrollOff = (e.props as any)?.scroll?.offset ?? 0
389    const cut = (s: string) => (s.length > W ? s.slice(0, W) : s)
390    const head = cs.map(c => (c.left ? c.h.padEnd(c.w) : c.h.padStart(c.w))).join('')
391
392    const rowCell = (r: WRow) => {
393      const pos = l.port[r.s]
394      const bg = r.fl > 0 ? UP_BG : r.fl < 0 ? DN_BG : undefined
395      return (
396        <Text>
397          {cs.map(c => {
398            const v = c.cell(r, pos)
399            return <Text key={c.h} bold={v.b} dimColor={v.dim} color={v.c} backgroundColor={bg}>{c.left ? v.s.padEnd(c.w) : v.s.padStart(c.w)}</Text>
400          })}
401        </Text>
402      )
403    }
404
405    if (desktop) {
406      // Fill the pane: ~8px per column. Table zoom (A-/A+) scales the SVG text; once two tables fit side by side, it shows two.
407      const mapW = Math.max(480, Math.round(cols * 8))
408      const tz0 = await read($, tzoom)
409      const adj = await read($, mapadj)
410      const slIdx = Math.max(0, Math.min(SL_N - 1, Math.round(adj / SL_STEP) + SL_AUTO))
411      const table = (rs: WRow[], k: 'vol' | 'port' | 'wl', fs: number, pl = false) => {
412        const lh = Math.round(fs * 1.65)
413        const pxCells = (px: number) => Math.floor(px / (fs * 0.62))
414        const two = rs.length > 1 ? fit(k, pxCells((mapW - 24) / 2), k === 'vol' ? 3 : 2) : undefined // VN30 (vol): drops Room/F.Sell/F.Buy to fit two tables; Vol/Buy/Sell stay
415        const cs = two ?? fit(k, pxCells(mapW), k === 'vol' ? 0 : 99) ?? (k === 'vol' ? COLS[k] : COLS[k].filter(c => !DROP[k].includes(c.h)))
416        const h = ((two ? Math.ceil(rs.length / 2) : rs.length) + 1) * lh + 4
417        const svg = () => tableSvg(cs, rs.map(r => ({ cells: cs.map(c => c.cell(r, pl ? l.port[r.s] : undefined)), bg: r.fl > 0 ? '#2ECC71' : r.fl < 0 ? '#FF5A4D' : undefined } as TRow)), mapW, fs, !!two)
418        return { svg, h }
419      }
420      const tblH = (fs: number) => (wr.length ? table(wr, kind, fs, t === 'port').h : 40) + (wl.length ? table(wl, 'wl', fs).h : 0)
421      // ~19px per row; fixed chrome (header, selects, index row, 2 sliders, tab, total, search, hint, watchlist title + remove row) ≈ 400px.
422      const total = (e.viewport?.rows ?? 60) * 19 - 400
423      const clampMap = (h: number) => Math.max(200, Math.min(1100, Math.round(h)))
424      // Auto text size (tz0 < 0): the map keeps the space it gets at the default size (+ the map-size slider), and the tables take whatever height is left, so the biggest text that still fits wins. Smaller map => bigger text; bigger map => smaller text.
425      const refFs = TABLE_FS[tz0 >= 0 ? Math.min(TABLE_FS.length - 1, tz0) : TABLE_FS_DEF]!
426      const mapH = Math.max(160, Math.min(2400, clampMap(total - tblH(refFs)) + adj))
427      const autoIdx = TABLE_FS.map((_, i) => i).reverse().find(i => tblH(TABLE_FS[i]!) <= total - mapH) ?? 0
428      const tz = tz0 >= 0 ? Math.min(TABLE_FS.length - 1, tz0) : autoIdx
429      const fs = TABLE_FS[tz]!
430      const mainT = wr.length ? table(wr, kind, fs, t === 'port') : undefined
431      const wlT = wl.length ? table(wl, 'wl', fs) : undefined
432      return (
433        <Box flexDirection="column" backgroundColor="#202226" flexGrow={1}>
434          <Text bold>HCMC {new Date(Date.now() + 7 * 3600_000).toISOString().slice(11, 19)} <Text dimColor>{m.status === 'live' ? `${age}s ago` : m.status}<Text color={session().c}> · {session().t}</Text>{capsHave < capsOf ? ` · caps ${capsHave}/${capsOf}` : ''} · Source: SSI API</Text></Text>
435          <Box flexWrap="wrap">
436            <Select
437              key="sel-view"
438              label="Group by "
439              value={v}
440              options={[{ value: 'sector', label: 'Sector' }, { value: 'exchange', label: 'Exchange' }]}
441              onSelect={(x: string) => { void update($, view, () => x as View) }}
442            />
443            <Select
444              key="sel-filter"
445              label="Filter "
446              value="__sum"
447              options={[
448                { value: '__sum', label: picked.length ? picked.join(', ').slice(0, 40) : 'All sectors' },
449                { value: '__all', label: 'Clear filter (all sectors)' },
450                ...sectors.map(x => ({ value: x, label: `${picked.includes(x) ? '✓ ' : '   '}${x}` })),
451              ]}
452              onSelect={(x: string) => {
453                if (x === '__sum') return
454                void update($, only, cur => {
455                  if (x === '__all') return ''
456                  const c = cur ? cur.split('|') : []
457                  return (c.includes(x) ? c.filter(y => y !== x) : [...c, x]).join('|')
458                })
459              }}
460            />
461          </Box>
462          {picked.length > 0 && (
463            <Box flexWrap="wrap">
464              <Text dimColor>Showing </Text>
465              {picked.map(x => <Button key={`chip-${x}`} label={`${x} ✕`} onPress={() => update($, only, cur => cur.split('|').filter(y => y !== x).join('|'))} />)}
466            </Box>
467          )}
468          {ixs.length > 0 && <Box flexWrap="wrap">{ixs.map(x => <Text key={x.key}><Text bold>{x.head}</Text><Text color={x.color}>{x.chg}{'   '}</Text></Text>)}</Box>}
469          {q.length === 0 ? <Text dimColor>Loading…</Text> : <Svg source={buildSvg(groups, mapW, mapH)} alt="Vietnam stock-market treemap by market cap, coloured by daily change" />}
470          <Box>
471            <Text dimColor>Map size </Text>
472            {Array.from({ length: SL_N }, (_, k) => (
473              <Button key={`ms${k}`} plain label={k <= slIdx ? '█' : '░'} onPress={() => update($, mapadj, () => (k - SL_AUTO) * SL_STEP)} />
474            ))}
475            <Button key="map-auto" label="⟲" onPress={() => update($, mapadj, () => 0)} />
476          </Box>
477          <Box>
478            <Text dimColor>Text size </Text>
479            {TABLE_FS.map((_, k) => (
480              <Button key={`ts${k}`} plain label={k <= tz ? '█' : '░'} onPress={() => update($, tzoom, () => k)} />
481            ))}
482            <Button key="ts-auto" label="⟲" onPress={() => update($, tzoom, () => -1)} />
483          </Box>
484          <Box>
485            <Button key="tab-vn30" label="VN30" variant={t === 'vn30' ? 'primary' : undefined} onPress={() => setTab($, 'vn30')} />
486          </Box>
487          {!mainT ? <Text dimColor>{t === 'vn30' ? 'Loading VN30…' : 'Empty. Add a ticker below.'}</Text> : <Svg source={mainT.svg()} alt="Stock table" />}
488          {t === 'port' && wr.length > 0 && (() => {
489            const cost = wr.reduce((a, r) => a + (l.port[r.s] ? l.port[r.s].c * 1000 * l.port[r.s].q : 0), 0)
490            const val = wr.reduce((a, r) => a + (l.port[r.s] ? r.p * l.port[r.s].q : 0), 0)
491            const pl = val - cost
492            return <Text bold>Total  Value {money(val)}  <Text color={plc(pl)}>P&L {money(pl)} ({sgn(cost ? (pl / cost) * 100 : 0)}%)</Text></Text>
493          })()}
494          <Input
495            key="ticker"
496            label="Search "
497            placeholder={t === 'port' ? 'FPT 1000 62.4 (sym qty cost-in-k) · -FPT removes' : 'ticker, Enter adds · -FPT removes'}
498            value={q0}
499            submitLabel="add"
500            onInput={(v: string) => { void update($, wq, () => v) }}
501            onSubmit={(v: string) => { void submit($, v) }}
502          />
503          <Text dimColor>{q0.trim() ? suggest(universe, q0).map(x => `${x.code} (${x.name}.${x.ex === 'HOSE' ? 'HSX' : x.ex})`).join(' · ') || (universe.length ? 'no match' : 'loading tickers…') : msg || ' '}</Text>
504          {wlT && (
505            <Box flexDirection="column">
506              <Text bold>My Watchlist</Text>
507              <Svg source={wlT.svg()} alt="Watchlist table" />
508              <Box flexWrap="wrap">
509                <Text dimColor>Remove </Text>
510                {l.watch.map(sym => <Button key={`rm-${sym}`} label={`${sym} −`} onPress={() => { void submit($, `-${sym}`) }} />)}
511              </Box>
512            </Box>
513          )}
514        </Box>
515      )
516    }
517
518    return (
519      <Box flexDirection="column">
520        <Text bold>HCMC {new Date(Date.now() + 7 * 3600_000).toISOString().slice(11, 19)}</Text>
521        <Text dimColor>
522          {m.status === 'live' ? `${age}s ago` : m.status}
523          {` · ${session().t}`}
524          {capsHave < capsOf ? ` · caps ${capsHave}/${capsOf}` : ''}
525          {' · Source: SSI API'}
526        </Text>
527        <Box>
528          <Button key="by-sector" label="Sector" hotkey="s" variant={v === 'sector' ? 'primary' : undefined} onPress={() => update($, view, () => 'sector' as View)} />
529          <Button key="by-exchange" label={narrow ? 'Exch' : 'Exchange'} hotkey="e" variant={v === 'exchange' ? 'primary' : undefined} onPress={() => update($, view, () => 'exchange' as View)} />
530          <Select
531            key="sel-filter"
532            label={narrow ? '' : 'Filter '}
533            value="__sum"
534            options={[
535              { value: '__sum', label: picked.length ? (picked.length > 1 ? `${picked.length} sectors` : picked[0]!).slice(0, narrow ? 10 : 24) : narrow ? 'All' : 'All sectors' },
536              { value: '__all', label: 'Clear filter (all sectors)' },
537              ...sectors.map(x => ({ value: x, label: `${picked.includes(x) ? '✓ ' : '   '}${x}` })),
538            ]}
539            onSelect={(x: string) => {
540              if (x === '__sum') return
541              void update($, only, cur => {
542                if (x === '__all') return ''
543                const c = cur ? cur.split('|') : []
544                return (c.includes(x) ? c.filter(y => y !== x) : [...c, x]).join('|')
545              })
546            }}
547          />
548          <Button key="filter-default" label={narrow ? 'Reset' : 'Default'} hotkey="d" variant={sel ? undefined : 'primary'} onPress={() => update($, only, () => '')} />
549        </Box>
550        {picked.length > 0 && (
551          <Box flexWrap="wrap">
552            <Text dimColor>Showing </Text>
553            {picked.map(x => <Button key={`chip-${x}`} label={`${x} ✕`} onPress={() => update($, only, cur => cur.split('|').filter(y => y !== x).join('|'))} />)}
554          </Box>
555        )}
556        {ixs.length > 0 && (
557          <Box flexWrap="wrap">
558            {ixs.map(x => (
559              <Text key={x.key}>
560                <Text bold>{x.head}</Text>
561                <Text color={x.color}>{x.chg}{'   '}</Text>
562              </Text>
563            ))}
564          </Box>
565        )}
566        {q.length === 0 && <Text dimColor>Loading…</Text>}
567        {grid.map((row, r) => (
568          <Box key={r}>
569            {runs(row).map((s, i) => (
570              <Text key={i} backgroundColor={s.bg} color={s.fg} bold={s.b}>{s.t}</Text>
571            ))}
572          </Box>
573        ))}
574        <Box>
575          <Button key="tab-vn30" label="VN30" variant={t === 'vn30' ? 'primary' : undefined} onPress={() => setTab($, 'vn30')} />
576        </Box>
577        <Text bold>{head}{dual && shown.length > half ? `  ${head}` : ''}</Text>
578        {shown.length === 0 && (
579          <Text dimColor>{t === 'vn30' ? 'Loading VN30…' : 'Empty. Add a ticker below.'}</Text>
580        )}
581        {Array.from({ length: half }, (_, i) => (
582          <Box key={i}>
583            {rowCell(shown[i]!)}
584            {dual && shown[i + half] && <Text>{'  '}</Text>}
585            {dual && shown[i + half] && rowCell(shown[i + half]!)}
586          </Box>
587        ))}
588        {t === 'port' && wr.length > 0 && (() => {
589          const cost = wr.reduce((a, r) => a + (l.port[r.s] ? l.port[r.s].c * 1000 * l.port[r.s].q : 0), 0)
590          const val = wr.reduce((a, r) => a + (l.port[r.s] ? r.p * l.port[r.s].q : 0), 0)
591          const pl = val - cost
592          const tot: Record<string, string> = { Sym: 'Total', Value: money(val), 'P&L': money(pl), 'P&L%': `${sgn(cost ? (pl / cost) * 100 : 0)}%` }
593          return (
594            <Text bold>
595              {cs.map(c => {
596                const s = tot[c.h] ?? ''
597                return <Text key={c.h} color={c.h.startsWith('P&L') ? plc(pl) : undefined}>{c.left ? s.padEnd(c.w) : s.padStart(c.w)}</Text>
598              })}
599            </Text>
600          )
601        })()}
602        <Box>
603          <Button key="tab-port" label="My Watchlist" variant="primary" onPress={() => setTab($, 'port')} />
604        </Box>
605        <Box>
606          <Input
607            key="ticker"
608            label="Search "
609            placeholder={(t === 'port' ? 'FPT 1000 62.4 (sym qty cost-in-k) · -FPT removes' : t === 'vn30' ? 'ticker, Enter adds to watchlist' : 'ticker, Enter adds · -FPT removes').slice(0, Math.max(10, W - 12))}
610            value={q0}
611            submitLabel="add"
612            onInput={(v: string) => { void update($, wq, () => v) }}
613            onSubmit={(v: string) => { void submit($, v) }}
614          />
615          <Text dimColor>{' '}
616            {cut(q0.trim()
617              ? suggest(universe, q0).map(x => `${x.code} (${x.name}.${x.ex === 'HOSE' ? 'HSX' : x.ex})`).join(' · ') || (universe.length ? 'no match' : 'loading tickers…')
618              : msg || ' ').slice(0, Math.max(10, W - 24))}
619          </Text>
620        </Box>
621        {wl.length > 0 && (() => {
622          const wc = fit('wl', W, 99) ?? COLS.wl.filter(c => !DROP.wl.includes(c.h))
623          return (
624            <Box flexDirection="column">
625              <Text bold>My Watchlist</Text>
626              <Text bold>{wc.map(c => (c.left ? c.h.padEnd(c.w) : c.h.padStart(c.w))).join('')}</Text>
627              {wl.map(r => (
628                <Text key={r.s}>
629                  {wc.map(c => {
630                    const v = c.cell(r, undefined)
631                    return <Text key={c.h} bold={v.b} dimColor={v.dim} color={v.c} backgroundColor={r.fl > 0 ? UP_BG : r.fl < 0 ? DN_BG : undefined}>{c.left ? v.s.padEnd(c.w) : v.s.padStart(c.w)}</Text>
632                  })}
633                </Text>
634              ))}
635              <Box flexWrap="wrap">
636                <Text dimColor>Remove </Text>
637                {l.watch.map(sym => <Button key={`rm-${sym}`} label={`${sym} -`} onPress={() => { void submit($, `-${sym}`) }} />)}
638              </Box>
639            </Box>
640          )
641        })()}
642      </Box>
643    )
644    } catch (err) {
645      const msg = String((err as Error)?.stack ?? err)
646      void $.fs.write(`${stateDir($)}/vn-heatmap.err.log`, `${new Date().toISOString()} render ${msg}
647`).catch(() => {})
648      const { Box, Text } = $.ui.resolve(e) as any
649      return <Box flexDirection="column"><Text color="#FF8A80">render error: {msg.slice(0, 300)}</Text></Box>
650    }
651  })
652}
653
654const UP_BG = '#244D38'
655const DN_BG = '#583030'
656const plc = (v: number) => (v > 0 ? '#2FA55A' : v < 0 ? '#C0392B' : '#C9A227')
657
658// Sector header fallbacks (SSI ICB sectors), tried after the full name when a treemap group is narrow.
659const SHORT: Record<string, string> = {
660  'Oil & Gas': 'Oil&Gas', Chemicals: 'Chem', 'Basic Resources': 'Resourc', 'Construction & Materials': 'Constr',
661  'Industrial Goods & Services': 'Indust', 'Automobiles & Parts': 'Auto', 'Food & Beverage': 'Food',
662  'Personal & Household Goods': 'Househ', 'Health Care': 'Health', Retail: 'Retail', Media: 'Media',
663  'Travel & Leisure': 'Travel', Telecommunications: 'Telecom', Utilities: 'Util', Banks: 'Banks',
664  Insurance: 'Insur', 'Real Estate': 'Real Est', 'Financial Services': 'FinSvc', Technology: 'Tech',
665}
666const TINY: Record<string, string> = {
667  'Oil & Gas': 'O&G', Chemicals: 'Chm', 'Basic Resources': 'Res', 'Construction & Materials': 'Con',
668  'Industrial Goods & Services': 'Ind', 'Automobiles & Parts': 'Aut', 'Food & Beverage': 'F&B',
669  'Personal & Household Goods': 'HH', 'Health Care': 'HC', Retail: 'Ret', Media: 'Med', 'Travel & Leisure': 'Trv',
670  Telecommunications: 'Tel', Utilities: 'Utl', Banks: 'Bnk', Insurance: 'Ins', 'Real Estate': 'RE',
671  'Financial Services': 'FS', Technology: 'IT',
672}
673
674// Watch-table columns at minimum width (each value + 1 space); `fit` stretches them to the pane.
675type Cellv = { s: string; c?: string; dim?: boolean; b?: boolean }
676type Col = { h: string; w: number; left?: boolean; cell: (r: WRow, pos?: { q: number; c: number }) => Cellv }
677const LEAD: Col[] = [
678  { h: 'Sym', w: 5, left: true, cell: r => ({ s: r.s, c: tone(r), b: true }) },
679  { h: 'Price', w: 8, cell: r => ({ s: k1(r.p), c: tone(r) }) },
680  { h: 'Chg', w: 7, cell: r => ({ s: sgn(r.ch / 1000), c: tone(r) }) },
681  { h: '%', w: 8, cell: r => ({ s: `${sgn(r.pct)}%`, c: tone(r) }) },
682]
683const rc = (v?: number | null) => (v == null ? { s: '' } : { s: `${sgn(v)}%`, c: plc(v) })
684const COLS: Record<'vol' | 'port' | 'wl', Col[]> = {
685  vol: [
686    ...LEAD,
687    { h: 'Vol', w: 8, cell: r => ({ s: vol(r.vol), dim: true }) },
688    { h: 'Buy', w: 8, cell: r => ({ s: vol(r.bu ?? 0), c: '#2FA55A' }) },
689    { h: 'Sell', w: 8, cell: r => ({ s: vol(r.sd ?? 0), c: '#C0392B' }) },
690    { h: 'F.Buy', w: 8, cell: r => ({ s: vol(r.fb ?? 0), c: '#2FA55A' }) },
691    { h: 'F.Sell', w: 8, cell: r => ({ s: vol(r.fs ?? 0), c: '#C0392B' }) },
692    { h: 'Room', w: 10, cell: r => { const v = r.fr ?? 0; return v < 0 ? { s: `-${vol(-v)}`, c: '#C0392B' } : { s: vol(v), dim: true } } },
693  ],
694  wl: [
695    ...LEAD,
696    { h: 'Vol', w: 8, cell: r => ({ s: vol(r.vol), dim: true }) },
697    { h: 'Buy', w: 8, cell: r => ({ s: vol(r.bu ?? 0), c: '#2FA55A' }) },
698    { h: 'Sell', w: 8, cell: r => ({ s: vol(r.sd ?? 0), c: '#C0392B' }) },
699    { h: 'F.Buy', w: 8, cell: r => ({ s: vol(r.fb ?? 0), c: '#2FA55A' }) },
700    { h: 'F.Sell', w: 8, cell: r => ({ s: vol(r.fs ?? 0), c: '#C0392B' }) },
701    { h: 'Room', w: 10, cell: r => { const v = r.fr ?? 0; return v < 0 ? { s: `-${vol(-v)}`, c: '#C0392B' } : { s: vol(v), dim: true } } },
702    { h: '1M', w: 8, cell: r => rc(r.rt?.m1) },
703    { h: '3M', w: 8, cell: r => rc(r.rt?.m3) },
704    { h: 'YTD', w: 8, cell: r => rc(r.rt?.y) },
705  ],
706  port: [
707    ...LEAD,
708    { h: 'Vol', w: 8, cell: r => ({ s: vol(r.vol), dim: true }) },
709    { h: 'Buy', w: 8, cell: r => ({ s: vol(r.bu ?? 0), c: '#2FA55A' }) },
710    { h: 'Sell', w: 8, cell: r => ({ s: vol(r.sd ?? 0), c: '#C0392B' }) },
711    { h: 'F.Buy', w: 8, cell: r => ({ s: vol(r.fb ?? 0), c: '#2FA55A' }) },
712    { h: 'F.Sell', w: 8, cell: r => ({ s: vol(r.fs ?? 0), c: '#C0392B' }) },
713    { h: 'Room', w: 10, cell: r => { const v = r.fr ?? 0; return v < 0 ? { s: `-${vol(-v)}`, c: '#C0392B' } : { s: vol(v), dim: true } } },
714    { h: '1M', w: 8, cell: r => rc(r.rt?.m1) },
715    { h: '3M', w: 8, cell: r => rc(r.rt?.m3) },
716    { h: 'YTD', w: 8, cell: r => rc(r.rt?.y) },
717    { h: 'Qty', w: 8, cell: (r, p) => ({ s: p ? String(p.q) : '', c: tone(r) }) },
718    { h: 'Cost', w: 8, cell: (r, p) => ({ s: p ? p.c.toFixed(2) : '', c: tone(r) }) },
719    { h: 'Value', w: 9, cell: (r, p) => ({ s: p ? money(r.p * p.q) : '', c: tone(r) }) },
720    { h: 'P&L', w: 9, cell: (r, p) => { const v = p ? (r.p - p.c * 1000) * p.q : 0; return { s: p ? money(v) : '', c: plc(v) } } },
721    { h: 'P&L%', w: 9, cell: (r, p) => { const v = p ? (r.p / (p.c * 1000) - 1) * 100 : 0; return { s: p ? `${sgn(v)}%` : '', c: plc(v) } } },
722  ],
723}
724// least useful first: dropped one by one until the table fits
725const DROP: Record<'vol' | 'port' | 'wl', string[]> = { vol: ['Room', 'F.Sell', 'F.Buy', 'Sell', 'Buy', 'Chg', 'Vol'], wl: ['Room', 'F.Sell', 'F.Buy', 'Sell', 'Buy', 'Vol', 'Chg'], port: ['Room', 'F.Sell', 'F.Buy', 'Sell', 'Buy', 'Vol', 'Chg', 'Cost', 'Qty', '%', 'Value', 'P&L%'] }
726const fit = (k: 'vol' | 'port' | 'wl', room: number, maxDrop: number): Col[] | undefined => {
727  let cs = COLS[k]
728  for (let i = 0; ; i++) {
729    const need = cs.reduce((a, c) => a + c.w, 0)
730    if (need <= room) {
731      const ex = Math.floor((room - need) / cs.length)
732      return cs.map(c => ({ ...c, w: c.w + ex }))
733    }
734    if (i >= Math.min(maxDrop, DROP[k].length)) return undefined
735    cs = cs.filter(c => c.h !== DROP[k][i])
736  }
737}
738
hooks/treemap.ts 142 lines
1// Squarified treemap rasterised onto a character grid (cells are ~2x taller than wide).
2export type Tile = { label: string; sub: string; bg: string; v: number }
3export type Group = { heads: string[]; tone: string; v: number; tiles: Tile[] } // heads: longest first, first that fits wins
4export type Cell = { ch: string; bg: string; fg: string; b: boolean }
5
6const GAP = '#1e1e1e'
7const HEAD = '#2b2b2b'
8const MIN = 12 // subcells (cols x half-rows) a tile needs to be drawn on its own
9const BLANK: Cell = { ch: ' ', bg: GAP, fg: '#FFFFFF', b: false }
10
11const sum = (a: number[]) => a.reduce((s, x) => s + x, 0)
12
13export const squarify = (vals: number[], x: number, y: number, w: number, h: number): number[][] => {
14  const total = sum(vals)
15  if (!vals.length || total <= 0 || w <= 0 || h <= 0) return vals.map(() => [x, y, 0, 0])
16  const k = (w * h) / total
17  const areas = vals.map(v => v * k)
18  const worst = (r: number[], s: number) => {
19    const m = sum(r)
20    return Math.max(...r.map(a => Math.max((s * s * a) / (m * m), (m * m) / (s * s * a))))
21  }
22  const out: number[][] = []
23  let i = 0
24  while (i < areas.length) {
25    const s = Math.min(w, h)
26    const row = [areas[i]]
27    let j = i + 1
28    while (j < areas.length && worst([...row, areas[j]], s) <= worst(row, s)) row.push(areas[j++])
29    const m = sum(row)
30    if (w >= h) {
31      const cw = m / h
32      let cy = y
33      for (const a of row) { out.push([x, cy, cw, a / cw]); cy += a / cw }
34      x += cw; w -= cw
35    } else {
36      const rh = m / w
37      let cx = x
38      for (const a of row) { out.push([cx, y, a / rh, rh]); cx += a / rh }
39      y += rh; h -= rh
40    }
41    i = j
42  }
43  return out
44}
45
46// Half-block raster: each cell is two square "subrows" drawn as ▀ (fg = top half, bg = bottom half), so tile
47// edges land on half rows and thin tiles still show. Text needs a whole cell, so labels sit on full rows only.
48// Terminal text can't shrink, so a zoom level is "how much room a label needs": pad = blank cols around the
49// ticker, minH = text rows a tile must have before it is labelled. Every tile is drawn; small ones stay colour only.
50export const ZOOMS = [
51  { pad: 2, minH: 2 }, // big: only roomy tiles labelled, ticker over change
52  { pad: 0, minH: 2 },
53  { pad: 1, minH: 1 },
54  { pad: 0, minH: 1 }, // small: every tile that fits a bare ticker
55]
56export const buildGrid = (groups: Group[], W: number, H: number, zoom = 0): Cell[][] => {
57  const { pad, minH } = ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, zoom))]!
58  const S = H * 2
59  const px: string[][] = Array.from({ length: S }, () => Array.from({ length: W }, () => GAP))
60  const tx: (Cell | undefined)[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => undefined))
61  const fill = (x0: number, y0: number, x1: number, y1: number, bg: string) => {
62    for (let r = Math.max(0, y0); r < Math.min(S, y1); r++) for (let c = Math.max(0, x0); c < Math.min(W, x1); c++) px[r][c] = bg
63  }
64  const text = (r: number, c: number, s: string, fg: string, bg: string, b: boolean) => {
65    for (let i = 0; i < s.length; i++) if (r >= 0 && r < H && c + i >= 0 && c + i < W) tx[r][c + i] = { ch: s[i], bg, fg, b }
66  }
67  const box = (q: number[]) => {
68    const x0 = Math.round(q[0]), y0 = Math.round(q[1])
69    return [x0, y0, Math.round(q[0] + q[2]), Math.round(q[1] + q[3])]
70  }
71
72  // a cell is ~2x taller than wide, so a subrow is roughly square: lay out directly in (col, subrow) units
73  const gr = squarify(groups.map(x => x.v), 0, 0, W, S)
74  groups.forEach((grp, i) => {
75    const [x0, y0, x1, y1] = box(gr[i])
76    const w = x1 - x0, h = y1 - y0
77    if (w < 3 || h < 2) return
78    let ty0 = y0
79    const head = h >= 6
80    if (head) ty0 = Math.ceil(y0 / 2) * 2 + 2
81    // drop the small-cap tail (tiles under ~3x2 cells read as confetti) and let the rest fill the group;
82    // then drop trailing slivers (thinner than 2 cols or 2 subrows) until the layout is clean
83    const area = w * (y1 - ty0), tot = grp.tiles.reduce((s, t) => s + t.v, 0)
84    let tiles = [...grp.tiles].sort((p, q) => q.v - p.v).filter((t, n) => n === 0 || (t.v / tot) * area >= MIN)
85    let tr = squarify(tiles.map(t => t.v), x0, ty0, w, y1 - ty0)
86    const thin = (q: number[]) => { const [a0, b0, a1, b1] = box(q); return a1 - a0 < 3 || b1 - b0 < 3 }
87    while (tiles.length > 1 && tr.some(thin)) {
88      tiles = tiles.slice(0, -1)
89      tr = squarify(tiles.map(t => t.v), x0, ty0, w, y1 - ty0)
90    }
91    if (head) {
92      const hr = ty0 / 2 - 1 // first whole row inside the group carries the header text
93      fill(x0, y0, x1 - 1, ty0, HEAD) // last col stays GAP so adjacent headers don't merge
94      const hid = grp.tiles.length - tiles.length
95      const heads = hid ? [...grp.heads.map(x => `${x} · +${hid}`), ...grp.heads] : grp.heads
96      const label = heads.find(x => x.length <= w - 2) // never cut mid-word: shorter form or nothing
97      if (label) text(hr, x0 + 1, label, grp.tone, HEAD, true)
98    }
99    tiles.forEach((t, j) => {
100      const [a0, b0, a1, b1] = box(tr[j])
101      const tw = a1 - a0, th = b1 - b0
102      if (tw < 1 || th < 1) return
103      // gutters: 1 col right, 1 subrow (half a row) bottom, so gaps look equal in both axes
104      const iw = tw >= 2 ? tw - 1 : tw
105      const ib1 = th >= 2 ? b1 - 1 : b1
106      fill(a0, b0, a0 + iw, ib1, t.bg)
107      const r0 = Math.ceil(b0 / 2), r1 = Math.floor(ib1 / 2) // whole rows [r0, r1) fully inside the tile
108      const nr = r1 - r0
109      const ok = (s: string) => s.length <= iw - pad
110      const label = t.label // never truncated: "T1" for T11 would name the wrong ticker
111      if (nr < minH || !ok(label)) return // too small to name: colour only
112      const mid = (s: string) => a0 + Math.floor((iw - s.length) / 2)
113      const sub = t.sub && ok(t.sub) ? t.sub : undefined
114      if (nr >= 2) {
115        const top = r0 + Math.floor((nr - (sub ? 2 : 1)) / 2)
116        text(top, mid(label), label, '#FFFFFF', t.bg, true)
117        if (sub) text(top + 1, mid(sub), sub, '#FFFFFF', t.bg, false)
118      } else {
119        const s = sub && ok(`${label} ${sub}`) ? `${label} ${sub}` : label
120        text(r0, mid(s), s, '#FFFFFF', t.bg, true)
121      }
122    })
123  })
124  return Array.from({ length: H }, (_, r) =>
125    Array.from({ length: W }, (_, c) => {
126      const t = tx[r][c]
127      if (t) return t
128      const top = px[r * 2][c], bot = px[r * 2 + 1][c]
129      return top === bot ? { ...BLANK, bg: top } : { ch: '▀', bg: bot, fg: top, b: false }
130    }))
131}
132
133export const runs = (row: Cell[]) => {
134  const o: { t: string; bg: string; fg: string; b: boolean }[] = []
135  for (const c of row) {
136    const l = o[o.length - 1]
137    if (l && l.bg === c.bg && l.fg === c.fg && l.b === c.b) l.t += c.ch
138    else o.push({ t: c.ch, bg: c.bg, fg: c.fg, b: c.b })
139  }
140  return o
141}
142
hooks/svgmap.ts 89 lines
1// Pixel treemap as an SVG string for the desktop surface (terminal uses the cell grid in treemap.ts).
2import { squarify } from './treemap'
3import type { Group } from './treemap'
4
5const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
6
7const r = (n: number) => Math.round(n * 10) / 10
8
9const build = (groups: Group[], W: number, H: number, minTile: number): string => {
10  const out: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui,sans-serif"><rect width="${W}" height="${H}" fill="#202226"/>`]
11  // sector headers scale with the map: ~11px on a small pane up to 20px on a wide one
12  const hf = Math.round(Math.max(11, Math.min(20, W / 70, H / 28)))
13  const HEAD_H = hf + 6
14  const gr = squarify(groups.map(g => g.v), 0, 0, W, H)
15  groups.forEach((g, i) => {
16    const [gx, gy, gw, gh] = gr[i]!
17    if (gw < 4 || gh < 4) return
18    const head = gh > HEAD_H * 2.5 && gw > 40
19    out.push(`<rect x="${gx + 1}" y="${gy + 1}" width="${gw - 2}" height="${gh - 2}" fill="#2a2c31"/>`)
20    if (head) {
21      const label = g.heads.find(s => s.length * hf * 0.6 < gw - 8)
22      if (label) out.push(`<text x="${gx + 5}" y="${r(gy + hf + 1)}" font-size="${hf}" font-weight="700" fill="${g.tone}">${esc(label)}</text>`)
23    }
24    const ty = gy + (head ? HEAD_H : 1)
25    const th = gh - (head ? HEAD_H : 1) - 1
26    if (th < 4) return
27    const tr = squarify(g.tiles.map(t => t.v), gx + 1, ty, gw - 2, th)
28    g.tiles.forEach((t, j) => {
29      const [x, y, w, h] = tr[j]!
30      if (w < minTile || h < minTile) return
31      out.push(`<rect x="${r(x + 0.5)}" y="${r(y + 0.5)}" width="${r(Math.max(0, w - 1))}" height="${r(Math.max(0, h - 1))}" fill="${t.bg}"/>`)
32      const fs = Math.max(8, Math.min(18, w / (t.label.length * 0.75), h / 2.4))
33      if (w < t.label.length * fs * 0.62 || h < fs + 2) return // can't name it: colour only
34      const cx = x + w / 2
35      const two = h >= fs * 2.2
36      out.push(`<text x="${r(cx)}" y="${r(y + h / 2 + (two ? -1 : fs * 0.35))}" font-size="${fs.toFixed(1)}" font-weight="700" fill="#fff" text-anchor="middle">${esc(t.label)}</text>`)
37      if (two) out.push(`<text x="${r(cx)}" y="${r(y + h / 2 + fs * 0.95)}" font-size="${(fs * 0.75).toFixed(1)}" fill="#fff" text-anchor="middle">${esc(t.sub)}</text>`)
38    })
39  })
40  out.push('</svg>')
41  return out.join('')
42}
43
44// Svg source is capped at 131072 chars: raise the smallest drawn tile until it fits.
45export const buildSvg = (groups: Group[], W: number, H: number): string => {
46  let out = ''
47  for (const min of [3, 5, 8, 12, 20, 40]) {
48    out = build(groups, W, H, min)
49    if (out.length < 120_000) break
50  }
51  return out
52}
53
54// Table as SVG so zoom can scale the text (desktop Text has no size). `dual` = two side-by-side halves.
55export type TCell = { s: string; c?: string; dim?: boolean; b?: boolean }
56export type THead = { h: string; w: number; left?: boolean }
57export type TRow = { cells: TCell[]; bg?: string }
58export const TABLE_FS = Array.from({ length: 16 }, (_, i) => 7 + i) // px, 1px steps 7-22 (text-size slider)
59export const TABLE_FS_DEF = 6 // 13px
60
61export const tableSvg = (heads: THead[], rows: TRow[], W: number, fs: number, dual: boolean): string => {
62  const lh = Math.round(fs * 1.65)
63  const gap = dual ? 24 : 0
64  const bw = dual ? (W - gap) / 2 : W
65  const per = dual ? Math.ceil(rows.length / 2) : rows.length
66  const H = (per + 1) * lh + 4
67  const total = heads.reduce((a, c) => a + c.w, 0)
68  const out: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui,sans-serif" font-size="${fs}">`]
69  const block = (x0: number, rs: TRow[]) => {
70    let cx = x0
71    const cols = heads.map(c => { const w = (bw * c.w) / total; const o = { c, x: cx, w }; cx += w; return o })
72    const put = (o: { c: THead; x: number; w: number }, y: number, t: TCell | string) => {
73      const cell = typeof t === 'string' ? { s: t, b: true } : t
74      const left = !!o.c.left
75      out.push(`<text x="${r(left ? o.x + 4 : o.x + o.w - 4)}" y="${r(y + lh / 2 + fs * 0.35)}" text-anchor="${left ? 'start' : 'end'}" fill="${cell.c ?? '#e6e6e6'}"${cell.b ? ' font-weight="700"' : ''}${cell.dim ? ' opacity=".6"' : ''}>${esc(cell.s)}</text>`)
76    }
77    cols.forEach(o => put(o, 0, o.c.h))
78    rs.forEach((row, i) => {
79      const y = (i + 1) * lh
80      if (row.bg) out.push(`<rect x="${r(x0)}" y="${y}" width="${r(bw)}" height="${lh}" fill="${row.bg}" fill-opacity=".25"/>`)
81      cols.forEach((o, k) => put(o, y, row.cells[k]!))
82    })
83  }
84  block(0, rows.slice(0, per))
85  if (dual) block(bw + gap, rows.slice(per))
86  out.push('</svg>')
87  return out.join('')
88}
89
hooks/watch.ts 81 lines
1// SSI iBoard quotes for the watchlist strip (public REST, see docs/ssi-iboard-api.md).
2// ponytail: 3s REST polling; the real push feed is MQTT+protobuf at
3// wss://price-streaming.ssi.com.vn/mqtt, needs a spawned helper since mods have no WebSocket.
4import type { Lists, Quote, WRow } from './types'
5
6export const SSI = 'https://iboard-query.ssi.com.vn'
7export const MAX = 200 // CONFIG_LIMIT_STOCK_MULTIPLE_QUERY in the iBoard bundle
8export const HEADERS = { 'user-agent': 'Mozilla/5.0', referer: 'https://iboard.ssi.com.vn/', accept: 'application/json' }
9
10// matchedPrice is 0 before the first match; during ATO/ATC the board shows the expected price.
11export const toRow = (x: any): WRow => {
12  const live = x.matchedPrice > 0
13  const p = live ? x.matchedPrice : x.expectedMatchedPrice > 0 ? x.expectedMatchedPrice : x.refPrice
14  const ch = live ? x.priceChange : x.expectedMatchedPrice > 0 ? x.expectedPriceChange : 0
15  const pct = live ? x.priceChangePercent : x.expectedMatchedPrice > 0 ? x.expectedPriceChangePercent : 0
16  return {
17    s: x.stockSymbol, ex: String(x.exchange ?? '').toUpperCase(), p, ref: x.refPrice, ch: ch ?? 0, pct: pct ?? 0,
18    vol: x.nmTotalTradedQty ?? 0, hi: x.highest ?? 0, lo: x.lowest ?? 0,
19    bu: x.stockBUVol ?? 0, sd: x.stockSDVol ?? 0, fb: x.buyForeignQtty ?? 0, fs: x.sellForeignQtty ?? 0, fr: x.remainForeignQtty ?? 0, fl: 0, // active buy/sell volume; fb/fs/fr = foreign buy/sell qty and remaining room; fl = price flash vs last poll (set by caller)
20    k: p > 0 && p >= x.ceiling ? 'c' : p > 0 && p <= x.floor ? 'f' : '',
21  }
22}
23
24export const STATS = 'https://iboard-api.ssi.com.vn/statistics'
25export const EXCHANGE = ['', 'HOSE', 'HNX', 'UPCOM']
26
27// Heatmap tile from an SSI quote: size = listed shares x price (market cap). No shares yet = no tile.
28export const toQuote = (x: any, sector: Map<string, string>, shares: Record<string, number>): Quote | undefined => {
29  const r = toRow(x)
30  const n = shares[r.s]
31  if (!n || !(r.p > 0)) return
32  return { s: r.s, p: r.pct, m: n * r.p, g: sector.get(r.s) ?? 'Other', x: EXCHANGE.indexOf(r.ex), k: r.k }
33}
34
35// /company/sectors-data -> symbol => ICB sector (English)
36export const sectorsOf = (data: any[]) =>
37  new Map<string, string>(data.flatMap(i => (i.listCompany ?? []).map((c: any) => [c.symbol, i.industryName?.en ?? 'Other'])))
38
39export type Ticker = { code: string; name: string; ex: string }
40export const toTicker = (x: any): Ticker => ({ code: x.code, name: x.clientNameEn || x.clientName || '', ex: x.exchange })
41
42// Unwraps SSI's { code, message, data } envelope.
43export const rowsOf = (status: number, ok: boolean, text: string) => {
44  if (!ok) throw new Error(`SSI ${status}`)
45  const d = JSON.parse(text)
46  if (!Array.isArray(d.data)) throw new Error(`SSI ${d.message ?? 'bad shape'}`)
47  return d.data as any[]
48}
49
50export const suggest = (all: Ticker[], q: string, n = 6) => {
51  const t = q.trim().toUpperCase().split(/\s+/)[0] ?? ''
52  if (!t) return []
53  const pre = all.filter(x => x.code.startsWith(t))
54  return pre.sort((a, b) => a.code.length - b.code.length || a.code.localeCompare(b.code)).slice(0, n)
55}
56
57// Input grammar: "FPT" adds, "-FPT" removes; on the portfolio tab "FPT 1000 62.4" sets qty and
58// cost (cost in thousand VND, as the board shows prices).
59export type Cmd = { op: 'add'; s: string; q?: number; c?: number } | { op: 'del'; s: string } | { op: 'bad'; why: string }
60export const parse = (text: string, known: (s: string) => boolean, port: boolean): Cmd => {
61  const [a = '', q, c] = text.trim().toUpperCase().split(/\s+/)
62  if (a.startsWith('-')) return { op: 'del', s: a.slice(1) }
63  if (!known(a)) return { op: 'bad', why: `unknown ticker ${a || '(empty)'}` }
64  if (!port) return { op: 'add', s: a }
65  const qty = Number(q), cost = Number(c)
66  if (!(qty > 0) || !(cost > 0)) return { op: 'bad', why: 'portfolio: SYM QTY COST, e.g. FPT 1000 62.4' }
67  return { op: 'add', s: a, q: qty, c: cost }
68}
69
70export const apply = (l: Lists, cmd: Cmd, port: boolean): Lists => {
71  if (cmd.op === 'bad') return l
72  if (port) {
73    const p = { ...l.port }
74    if (cmd.op === 'del') delete p[cmd.s]
75    else p[cmd.s] = { q: cmd.q!, c: cmd.c! }
76    return { ...l, port: p }
77  }
78  const w = l.watch.filter(s => s !== cmd.s)
79  return { ...l, watch: cmd.op === 'add' ? [...w, cmd.s].slice(-MAX) : w }
80}
81
hooks/types.d.ts 17 lines
1export type Quote = { s: string; p: number; m: number; g: string; x: number; k: 'c' | 'f' | '' }
2export type Meta = { at: number; status: string; n: number }
3export type Idx = { name: string; price: number; change: number; pct: number }
4export type View = 'sector' | 'exchange'
5export type WRow = { s: string; ex: string; p: number; ref: number; ch: number; pct: number; vol: number; hi: number; lo: number; bu: number; sd: number; fb: number; fs: number; fr: number; fl: number; rt?: { m1: number | null; m3: number | null; y: number | null }; k: 'c' | 'f' | '' }
6export type Tab = 'vn30' | 'watch' | 'port'
7export type Lists = { watch: string[]; port: Record<string, { q: number; c: number }> }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'vn-stockmarket-heatmap': {
12      quotes: Quote[]; meta: Meta; view: View; only: string; idx: Idx[]
13      tab: Tab; lists: Lists; wrows: WRow[]; wlrows: WRow[]; wmeta: Meta; wq: string; wmsg: string; zoom: number; tzoom: number; mapadj: number
14    }
15  }
16}
17