Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/api/models/MinersDashboard.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/api/models/searchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
105 changes: 88 additions & 17 deletions src/components/dashboard/MarketRateChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' } },
Expand All @@ -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 },
},
},
]
: [
Expand All @@ -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' } },
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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,
},
]
: []),
Expand Down
73 changes: 55 additions & 18 deletions src/components/dashboard/marketRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions src/components/miners/MinerLeaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -179,7 +180,11 @@ const MinerLeaderboard: React.FC<{
>
Miner Leaderboard
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<TextField
size="small"
placeholder="search uid or hotkey…"
Expand Down Expand Up @@ -211,14 +216,20 @@ const MinerLeaderboard: React.FC<{
{filteredRows.length} of {baseRows.length} shown
</Typography>
)}
<Stack direction="row" spacing={0.5}>
<Stack
direction="row"
spacing={0.5}
sx={{ width: { xs: '100%', sm: 'auto' } }}
>
{RANGES.map((r) => (
<Button
key={r}
size="small"
variant={r === range ? 'contained' : 'outlined'}
onClick={() => onRangeChange(r)}
sx={{
// Fill the row evenly on mobile (own line); natural width on desktop.
flex: { xs: 1, sm: 'none' },
minWidth: 0,
px: 1.25,
py: 0.5,
Expand Down
4 changes: 3 additions & 1 deletion src/pages/MinersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading