From c6253d8db4b0f846a5ba45032892b6f0a3d5107d Mon Sep 17 00:00:00 2001 From: anderdc Date: Mon, 1 Jun 2026 11:14:36 -0500 Subject: [PATCH 1/4] fix(dashboard): keep crown rate and EMA on-axis in market chart The TAO->BTC market chart could clip the live crown reference line and EMA dips off-screen when they fell outside the executed-swap band. - robustYRange now takes soft (EMA) and hard (crown) must-show values: EMA widens the padded core band, while the crown is clamped flush to the axis edge with no wasted margin past it. - y-axis labels use adaptive precision so a tight band no longer collapses every tick to the same rounded value. - crown label flips below the line when it sits in the top half so it stays inside the frame. --- src/components/dashboard/MarketRateChart.tsx | 46 ++++++++---- src/components/dashboard/marketRate.ts | 73 +++++++++++++++----- 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/src/components/dashboard/MarketRateChart.tsx b/src/components/dashboard/MarketRateChart.tsx index a7b5680..e32bcac 100644 --- a/src/components/dashboard/MarketRateChart.tsx +++ b/src/components/dashboard/MarketRateChart.tsx @@ -81,9 +81,37 @@ 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); + // 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); const xMin = blocks.length ? Math.min(...blocks) : undefined; @@ -188,12 +216,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' } }, @@ -214,12 +237,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' } }, @@ -264,7 +282,7 @@ const MarketRateChart: React.FC<{ direction: Direction; fill?: boolean }> = ({ opacity: 0.8, }, label: { - position: 'insideStartTop', + position: crownLabelPosition, color: crownColor, fontFamily: FONTS.mono, fontSize: 9, 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 From 29a63299098c15f513434aedb808f3293f8c184b Mon Sep 17 00:00:00 2001 From: anderdc Date: Mon, 1 Jun 2026 11:46:43 -0500 Subject: [PATCH 2/4] feat(miners): add 1h range chip and default leaderboard to it 1h is the validator's live scoring window (SCORING_WINDOW_BLOCKS), so the miners page now opens on it instead of 30d. Chips are now 1h/24h/7d/30d. Requires the matching das-allways change that adds 1h to VALID_RANGES / BLOCKS_PER_RANGE; without it parseRange 400s on range=1h. --- src/api/models/MinersDashboard.ts | 2 +- src/api/models/searchParams.ts | 2 +- src/components/miners/MinerLeaderboard.tsx | 3 ++- src/pages/MinersPage.tsx | 4 +++- 4 files changed, 7 insertions(+), 4 deletions(-) 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/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index 36d5f02..d02efbf 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); diff --git a/src/pages/MinersPage.tsx b/src/pages/MinersPage.tsx index d8677c6..4f232f6 100644 --- a/src/pages/MinersPage.tsx +++ b/src/pages/MinersPage.tsx @@ -29,7 +29,9 @@ const MinersPage: React.FC = () => { const isMobile = useMediaQuery(theme.breakpoints.down('md')); const rangeParam = params.get('range'); - const range: Range = isRange(rangeParam) ? rangeParam : '30d'; + // Default to 1h — the live scoring window — so the page opens on the data + // that reflects current scoring. + const range: Range = isRange(rangeParam) ? rangeParam : '1h'; const pairParam = params.get('pair'); const direction = isDirection(pairParam) ? pairParam : 'BTC-TAO'; const crownRangeParam = params.get('crownRange'); From 37c6505867380910433b30458a3a29b7679361a5 Mon Sep 17 00:00:00 2001 From: anderdc Date: Mon, 1 Jun 2026 11:56:01 -0500 Subject: [PATCH 3/4] fix(miners,mobile): give leaderboard search and time toggles their own rows On mobile the search field and the four range chips shared one row, leaving the search badly cramped. The header's inner stack now goes column on xs: search gets a full-width row and the chips drop to their own row, spread evenly (flex:1). Desktop layout is unchanged. --- src/components/miners/MinerLeaderboard.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/miners/MinerLeaderboard.tsx b/src/components/miners/MinerLeaderboard.tsx index d02efbf..f4d2aab 100644 --- a/src/components/miners/MinerLeaderboard.tsx +++ b/src/components/miners/MinerLeaderboard.tsx @@ -180,7 +180,11 @@ const MinerLeaderboard: React.FC<{ > Miner Leaderboard - + )} - + {RANGES.map((r) => (