Skip to content

feat(frontend): /chart/[index] candle page + ECharts swap + dark/light theme - #68

Merged
obchain merged 4 commits into
mainfrom
feat/27-chart-page
May 26, 2026
Merged

obchain merged 4 commits into
mainfrom
feat/27-chart-page

Conversation

@obchain

@obchain obchain commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the public frontend surface of the index. Issue #27 (the /chart/[index] candle chart) is the visible deliverable; the PR also bundles two adjacent scope-creep items the chart work surfaced and demanded:

  1. Chart library swap — lightweight-charts ships a non-removable TradingView attribution under its license. Replaced with ECharts (MIT, tree-shakable, native candlestick + line + bar).
  2. Dark / light theme system — the chart page reuses the design tokens the landing already used, but the user requested a real toggle. Adding the theme switch as a parallel scope kept tokens and ECharts palette in sync from the start instead of bolting one onto the other later.
  3. Landing refresh — the index cards now act as Links into the new chart page (hover state, "chart →" affordance) and the hero/typography were tightened.

Three atomic commits, in dependency order.

Commits

SHA Subject Why
4ce4bdf chore(frontend): swap lightweight-charts for echarts First because every commit after touches the chart bundle
9b374d5 feat(frontend): dark/light theme + landing redesign Theme tokens land before Chart consumes them
75ad6b1 feat(frontend): /chart/[index] candle chart with timeframe toggle The actual #27 deliverable

Routes after this PR

Path Purpose Notes
/ Landing gradient hero, link cards, live ticks, theme toggle
/chart/bvol BVOL candle chart full layout described below
/chart/evol EVOL candle chart same
/chart/foo (404 via notFound()) only bvol/evol allowed
/methodology, /docs, _not-found Placeholders inherit theme

Chart page layout

Inspired by CoinGecko + Deribit DVOL pages, condensed into a 12-col grid:

┌────────────────────────────────────────────────────────────────┐
│ breadcrumb (VolX / bvol — Bitcoin Volatility)         [☾/☀]    │
│ 67.42  +2.31% 1d                       live · 60 s cadence     │
├────────────────────────────────────────────────────────────────┤
│ ┌──── 6-stat strip (open/close/high/low/ticks/avg conf) ────┐  │
│ └────────────────────────────────────────────────────────────┘  │
├────────────────────────────────────────────────────────────────┤
│ [1h] [1d] [7d] [30d] [90d] [all]                  5m bars      │
│ ┌─────────── chart (col 1-9) ──────────┐ ┌── rail (10-12) ──┐  │
│ │   candle + EMA + close area          │ │ About            │  │
│ │   gradient + last-close marker       │ │ Live snapshot    │  │
│ │   volume bars                        │ │ Window           │  │
│ └──────────────────────────────────────┘ └──────────────────┘  │
├────────────────────────────────────────────────────────────────┤
│ data source: deribit · CBOE-style variance integral · updated  │
└────────────────────────────────────────────────────────────────┘

Below xl the rail drops under the chart, the stat strip wraps to 2 / 3 columns, the chart aspect tightens to 16:9.

Chart visuals

  • Candles: glow shadow in up/down hue, 14 px max width, animation 250 ms.
  • Area gradient overlay on closes — purple/indigo (theme-keyed), fills under the close line without hiding candles.
  • EMA line — adaptive period (min(20, max(4, bars/6))), orange overlay.
  • Last-close mark-line — dashed horizontal + pill label at the right edge.
  • Tooltip — pill backdrop-blur card with O/H/L/C/range/Δ% rows.
  • Crosshair axis-pointer labels — bold pills, themed shadow.
  • Axis labels — 12 px / 0.9 opacity (was 10 px / 0.35) — readable as a real price ladder.
  • Grid lines — dashed, theme-keyed.

Live tick updates the open candle in place (high/low/close); periodic 60 s history refresh handles bar rolls so client-side toStartOfInterval mirroring is not required.

Timeframe → REST interval map

1h  → 1m × 60      (1-min bars × 60 = 1 h)
1d  → 5m × 288     (5-min bars × 288 = 24 h)
7d  → 1h × 168
30d → 1h × 720
90d → 1d × 90
all → 1d × 5000    (≈14 y, effectively all under the API's 10 k cap)

fetchHistory widened to accept "1m" as an interval literal (was "5m" | "1h" | "1d").

Theme system

  • globals.css declares two palettes via :root, [data-theme="dark"] and [data-theme="light"] — background, surface, surface-2, border-subtle, border, border-strong, muted, soft, soft-2, up/down, up-soft/down-soft, grid, tooltip-bg, bg-radial-1/2.
  • @theme inline exports these as Tailwind color tokens (bg-background, text-foreground, bg-surface, text-muted, text-up, etc.).
  • @custom-variant dark and @custom-variant light (Tailwind v4) target the [data-theme] attribute so explicit dark: / light: prefixes are available if needed.
  • lib/theme.tsx provides <ThemeProvider> (context + localStorage sync) and themeInitScript (inline pre-paint script that runs before React hydrates, killing the "flash of wrong theme" effect).
  • <ThemeToggle> placed top-right on landing + chart page.
  • Chart ECharts palette is keyed off the React state theme, not CSS-var reads — this sidesteps a race with the ThemeProvider useEffect that writes data-theme on <html>.

CORS / proxy refresher

REST stays same-origin via the Next rewrite (/v1/:path* → API_PROXY_TARGET); WS connects direct via NEXT_PUBLIC_API_BASE. Both default to http://localhost:8080. No backend change here.

Verification

Step Result
pnpm install Clean (echarts added, lightweight-charts removed)
pnpm typecheck Clean
pnpm lint "No ESLint warnings or errors"
pnpm build Clean. / 7.02 kB / 112 kB FL; /chart/[index] 200 kB / 305 kB FL
Landing / renders both cards, sparkline, link to chart
/chart/bvol, /chart/evol Full layout, candles + EMA + area + last-close pill, tooltip, dataZoom
/chart/foo 404 via notFound()
REST proxy curl localhost:3000/v1/index/bvol/history?interval=1m&limit=60 → 200
WS Opens against ws://localhost:8080/v1/stream, subscribes to single channel, redraws last candle on tick
Theme toggle Flips both pages dark ↔ light; survives reload; respects prefers-color-scheme on first visit; chart redraws automatically

Out of scope

Notes

  • experimental.typedRoutes: true validates the <Link href="/chart/bvol"> etc. paths at build time — caught one wrong path during the refactor.
  • The fancy chart only loads on the /chart/[index] route (use client). Landing stays at 7 kB.
  • EChart LineChart and MarkLineComponent are imported explicitly (tree-shake friendly).
  • useTheme() must be inside <ThemeProvider> — Chart, Dashboard, and IndexCard all are.

Test plan

  • pnpm typecheck clean
  • pnpm lint clean
  • pnpm build clean
  • Browser smoke on /, /chart/bvol, /chart/evol, /chart/foo (404)
  • REST proxy returns 200 for all four interval literals
  • WS connects + last candle updates on tick
  • Theme toggle round-trips through reload + localStorage
  • ECharts palette flips on toggle without a manual refresh

Fixes #27

obchain added 3 commits May 26, 2026 18:05
lightweight-charts ships a mandatory TradingView attribution that the
license forbids removing. Replace with echarts (MIT, tree-shakable,
native candlestick + line + bar). Bundle weight grows from ~52 kB to
~200 kB on the chart route only — the trade for richer chart chrome
and no third-party branding sitting on every screenshot.
- CSS variable palette per [data-theme="dark|light"] (background,
  surface, border, up/down, soft/muted text). Tailwind v4 @theme inline
  binds them so utilities like bg-surface, text-muted, text-up resolve
  via the active theme.
- ThemeProvider holds the React state, syncs to localStorage and writes
  data-theme on <html>; an inline pre-paint script in <head> reads the
  stored / system value before hydration so the page never flashes the
  wrong palette.
- ThemeToggle button sits in the corner of both landing and chart pages.
- Landing: gradient hero, badge above title, link-card per index that
  routes to /chart/{id} on click with a "chart →" hover affordance.
- Sparkline strokes via var(--up) / var(--down) so it flips with the
  theme automatically.
Full-screen detail page per issue #27. Renders a candle + volume chart
with a CoinGecko-style hero (6xl value, delta pill, live indicator),
6-stat strip (open/close/high/low/ticks/avg conf), and a 12-col grid
with chart in col-span-9 and a right rail (about / live snapshot /
window) that collapses below the chart at <xl.

Chart chrome:
- timeframe pill (1h / 1d / 7d / 30d / 90d / all) maps to (interval,
  limit) and refetches history; auto-refresh every 60 s matches the
  engine cadence so bar rolls catch up without a manual switch
- area-gradient under closes for a "filled" feel without hiding candles
- adaptive EMA overlay (period scales with bar count)
- mark-line at last close with a pill label
- crosshair + tooltip pills with backdrop blur and OHLC + range + Δ%
- axis labels rendered at 12 px / 0.9 opacity (was 10 px / 0.35) so
  the price ladder is actually readable
- ECharts palette keyed off the React-state theme rather than CSS-var
  reads, sidestepping the race with ThemeProvider's data-theme write
  on toggle

Page validates {bvol|evol} via notFound() so /chart/foo 404s.
REST history extended to interval="1m" for the 1 h timeframe.
@obchain obchain added area:frontend Next.js dashboard lang:ts TypeScript priority:p1 High, plan for this phase type:feature New functionality labels May 26, 2026
- HIGH-1: live tick redraws via the full draw() path so the candle,
  area-gradient line, EMA overlay, and mark-line all stay in sync.
  The prior partial setOption([{ data: ohlc }]) targeted series[0]
  by position, which is the close-area line — the candle (series[2])
  silently kept stale data and the line series was corrupted with
  OHLC arrays.
- HIGH-2: introduce latestTickRef and apply it to the last bar after
  every history refetch. Previously the 60 s refetch race condition
  overwrote tick patches with the server snapshot (which lacked the
  in-flight tick), erasing the live candle for the next minute.
- MED-1: hoist draw into drawRef so the history-effect deps shrink
  to [id, timeframe]. A theme flip no longer cancels and restarts the
  history interval, eliminating the redundant API round-trip on every
  toggle. Theme-redraw effect reads drawRef.current directly so it
  still picks up the fresh palette.
@obchain
obchain merged commit 3df6b07 into main May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:frontend Next.js dashboard lang:ts TypeScript priority:p1 High, plan for this phase type:feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend: /chart/[index] candle chart + live WS

1 participant