diff --git a/src/api/models/MinersDashboard.ts b/src/api/models/MinersDashboard.ts index 78b5c39..b3f7b6f 100644 --- a/src/api/models/MinersDashboard.ts +++ b/src/api/models/MinersDashboard.ts @@ -1,5 +1,5 @@ export type Direction = 'BTC-TAO' | 'TAO-BTC'; -export type Range = '24h' | '7d' | '30d' | '90d' | 'all'; +export type Range = '1h' | '24h' | '7d' | '30d' | '90d' | 'all'; export type CurrentCrown = { uid: number | null; diff --git a/src/api/models/searchParams.ts b/src/api/models/searchParams.ts index 04571aa..bf9988c 100644 --- a/src/api/models/searchParams.ts +++ b/src/api/models/searchParams.ts @@ -8,7 +8,7 @@ export type RateRange = '1h' | '4h' | '24h' | '4d'; // 90d/all dropped: the API clamps every range to ~30d (MAX_LOOKBACK_BLOCKS), // so they were redundant with 30d. A stale ?range=90d/all URL now falls back // to the 30d default. The Range type keeps them for API back-compat. -const RANGES: readonly Range[] = ['24h', '7d', '30d']; +const RANGES: readonly Range[] = ['1h', '24h', '7d', '30d']; const CROWN_RANGES: readonly CrownRange[] = ['1h', '2h', '4h']; const RATE_RANGES: readonly RateRange[] = ['1h', '4h', '24h', '4d']; diff --git a/src/components/dashboard/MarketRateChart.tsx b/src/components/dashboard/MarketRateChart.tsx index a7b5680..2ad846a 100644 --- a/src/components/dashboard/MarketRateChart.tsx +++ b/src/components/dashboard/MarketRateChart.tsx @@ -81,8 +81,44 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ const rates = clean.map((p) => p.rate); const emaValues = ema(rates, EMA_PERIOD); - const yRange = robustYRange(rates); + // Keep the EMA line and the live crown rate on-axis. The EMA is "soft" + // (padded like the data); the crown is "hard" — pulled flush to the edge + // when far away rather than padded past, so we don't waste vertical space. + const yRange = robustYRange(rates, { + soft: emaValues, + hard: crownRate != null ? [crownRate] : [], + }); const vol = volumeByBlock(clean); + const maxVol = vol.length ? Math.max(...vol.map((b) => b.vol)) : 0; + // Compact τ-volume label: keep it short for the cramped volume axis. + const fmtVol = (v: number) => + v >= 1000 + ? `${(v / 1000).toFixed(1)}k` + : v >= 10 + ? v.toFixed(0) + : v.toFixed(1); + + // Adaptive y-axis precision: a wide span (e.g. once a far-off crown rate is + // included) reads fine as integers, but a tight band would collapse every + // tick to the same rounded value — so show decimals when the span is small. + const ySpan = yRange ? yRange.max - yRange.min : 0; + const yDecimals = ySpan >= 10 ? 0 : ySpan >= 1 ? 1 : 2; + + // Keep the crown label inside the frame: when the crown line sits in the + // top half (often flush against the top edge), render the label below it; + // otherwise above. Avoids the label clipping off-chart at the extremes. + const crownLabelPosition = + crownRate != null && + yRange && + yRange.max - crownRate < crownRate - yRange.min + ? 'insideStartBottom' + : 'insideStartTop'; + const yAxisLabel = { + color: axisColor, + fontFamily: FONTS.mono, + fontSize: 9, + formatter: (v: number) => v.toFixed(yDecimals), + }; // Shared block x-range so the price and volume grids line up exactly. const blocks = clean.map((p) => p.block); @@ -188,12 +224,7 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ scale: true, gridIndex: 0, ...(yRange ? { min: yRange.min, max: yRange.max } : {}), - axisLabel: { - color: axisColor, - fontFamily: FONTS.mono, - fontSize: 9, - formatter: (v: number) => v.toFixed(0), - }, + axisLabel: yAxisLabel, axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: gridColor, type: 'dashed' } }, @@ -202,10 +233,21 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ type: 'value', gridIndex: 1, min: 0, - axisLabel: { show: false }, + // Headroom above the tallest bar so the max-volume line and its + // label sit clear of the bar top rather than flush at the edge. + max: maxVol > 0 ? maxVol * 1.2 : undefined, + splitNumber: 2, + axisLabel: { + color: axisColor, + fontFamily: FONTS.mono, + fontSize: 8, + formatter: fmtVol, + }, axisLine: { show: false }, axisTick: { show: false }, - splitLine: { show: false }, + splitLine: { + lineStyle: { color: gridColor, type: 'dashed', opacity: 0.4 }, + }, }, ] : [ @@ -214,12 +256,7 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ scale: true, gridIndex: 0, ...(yRange ? { min: yRange.min, max: yRange.max } : {}), - axisLabel: { - color: axisColor, - fontFamily: FONTS.mono, - fontSize: 9, - formatter: (v: number) => v.toFixed(0), - }, + axisLabel: yAxisLabel, axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: gridColor, type: 'dashed' } }, @@ -244,10 +281,21 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ xAxisIndex: 0, yAxisIndex: 0, smooth: true, + // Constrain the spline so it never overshoots the data between + // points — keeps the curve smooth without the bowing/humps that + // plain `smooth` introduces over unevenly-spaced (by block) points. + smoothMonotone: 'x', showSymbol: false, data: clean.map((p, i) => [p.block, emaValues[i]]), lineStyle: { color: accent, width: 2 }, - areaStyle: { color: accent, opacity: 0.07 }, + // Vertical gradient fill (accent → transparent) for the trading- + // terminal look, instead of a flat low-opacity tint. + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: `${accent}40` }, + { offset: 1, color: `${accent}00` }, + ]), + }, z: 3, // Dashed reference line at the live crown rate so the chart shows // where "now" sits versus the recent executed swaps. @@ -264,7 +312,7 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ opacity: 0.8, }, label: { - position: 'insideStartTop', + position: crownLabelPosition, color: crownColor, fontFamily: FONTS.mono, fontSize: 9, @@ -283,6 +331,29 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ data: vol.map((b) => [b.block, b.vol]), itemStyle: { color: accent, opacity: 0.32 }, barWidth: 5, + // Dotted reference at the largest single-block volume so the + // other bars read relative to the peak. + markLine: + maxVol > 0 + ? { + silent: true, + symbol: 'none', + data: [{ yAxis: maxVol }], + lineStyle: { + color: crownColor, + type: 'dotted', + width: 1, + opacity: 0.8, + }, + label: { + position: 'insideEndTop', + color: axisColor, + fontFamily: FONTS.mono, + fontSize: 8, + formatter: `max ${fmtVol(maxVol)}τ`, + }, + } + : undefined, }, ] : []), diff --git a/src/components/dashboard/marketRate.ts b/src/components/dashboard/marketRate.ts index 47436be..f27ab8d 100644 --- a/src/components/dashboard/marketRate.ts +++ b/src/components/dashboard/marketRate.ts @@ -88,28 +88,65 @@ export const volumeByBlock = ( .sort((a, b) => a.block - b.block); }; -// Padded y-axis bounds for the (already outlier-free) series. +// Padded y-axis bounds for the (already outlier-free) scatter series. +// +// `soft` values (the EMA line) widen the padded core band so the line keeps a +// little breathing room from the frame. `hard` values (the live crown rate) +// must stay on-axis but get NO padding beyond them: when the crown is far from +// recent fills it sits flush at the very top/bottom edge rather than pushing a +// wasteful empty margin past the reference line. Both guarantee visibility — +// without this the crown line and EMA dips can clip off-chart. export const robustYRange = ( values: number[], + { soft = [], hard = [] }: { soft?: number[]; hard?: number[] } = {}, ): { min: number; max: number } | null => { - if (values.length < 4) return null; - const s = [...values].sort((a, b) => a - b); - const q = (p: number) => s[Math.floor((s.length - 1) * p)]; - const q1 = q(0.25); - const q3 = q(0.75); - const iqr = q3 - q1; - const lo = q1 - 1.5 * iqr; - const hi = q3 + 1.5 * iqr; - const inBand = s.filter((v) => v >= lo && v <= hi); - if (!inBand.length) return null; - let min = inBand[0]; - let max = inBand[inBand.length - 1]; - if (min === max) { - min -= 1; - max += 1; + const softVals = soft.filter((v) => Number.isFinite(v)); + const hardVals = hard.filter((v) => Number.isFinite(v)); + + // Core band from the scatter values (robust to outliers), widened by the + // soft must-show set (EMA), which can dip just outside the executed band. + let coreMin: number | undefined; + let coreMax: number | undefined; + if (values.length >= 4) { + const s = [...values].sort((a, b) => a - b); + const q = (p: number) => s[Math.floor((s.length - 1) * p)]; + const q1 = q(0.25); + const q3 = q(0.75); + const iqr = q3 - q1; + const lo = q1 - 1.5 * iqr; + const hi = q3 + 1.5 * iqr; + const inBand = s.filter((v) => v >= lo && v <= hi); + if (inBand.length) { + coreMin = inBand[0]; + coreMax = inBand[inBand.length - 1]; + } + } + for (const v of softVals) { + coreMin = coreMin === undefined ? v : Math.min(coreMin, v); + coreMax = coreMax === undefined ? v : Math.max(coreMax, v); + } + // No core series to anchor on — fall back to whatever we must hard-show. + if (coreMin === undefined || coreMax === undefined) { + if (!hardVals.length) return null; + coreMin = Math.min(...hardVals); + coreMax = Math.max(...hardVals); + } + + if (coreMin === coreMax) { + coreMin -= 1; + coreMax += 1; + } + const pad = (coreMax - coreMin) * 0.12 || 1; + let min = coreMin - pad; + let max = coreMax + pad; + + // Clamp the axis to any hard value beyond the padded core — flush, no extra + // margin past it. + for (const v of hardVals) { + if (v < min) min = v; + if (v > max) max = v; } - const pad = (max - min) * 0.12 || 1; - return { min: min - pad, max: max + pad }; + return { min, max }; }; // Latest EMA rate for a direction — the smoothed recent-market rate the ticker diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index 36d5f02..f4d2aab 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -23,10 +23,11 @@ import SortHeader, { type SortDir } from './SortHeader'; import { FONTS } from '../../theme'; import { formatTao, shortHotkey } from '../../utils/format'; +// 1h is the live scoring window (SCORING_WINDOW_BLOCKS) and the default view. // 30d is the deepest window; the API clamps everything to ~30d // (MAX_LOOKBACK_BLOCKS) so crown_holders stays prunable, which made the old // 90d/all chips return identical data to 30d. -const RANGES: Range[] = ['24h', '7d', '30d']; +const RANGES: Range[] = ['1h', '24h', '7d', '30d']; const formatVolume = (raw: string): string => { const v = parseFloat(raw); @@ -179,7 +180,11 @@ const MinerLeaderboard: React.FC<{ > Miner Leaderboard - + )} - + {RANGES.map((r) => (