feat(market): range-based chart intervals; redesign group cards (value top-right + body sparkline)

- Chart interval buttons are now time RANGES, not bucket sizes: 1H = last hour,
  1D = last 24h (5-min intraday), 1W = last 7 days, 1M = last 30 days (daily).
  pfChartSeries filters the timestamped series to the window; X-axis "ago" labels
  and the period-change span now derive from the real timestamps.
- Portfolio group cards redesigned: name (+ secondary DRGX) on the left; the value
  with its % change stacked in the top-right corner; the sparkline fills the card
  body. The sparkline now renders on 2-cell cards too (down to ~8px of body), with
  tighter padding on short cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 16:52:09 -05:00
parent ced1773e70
commit d48c6db21c

View File

@@ -169,46 +169,25 @@ static std::vector<double> pfSparklineSeries(const MarketInfo& m, int interval)
// 0=Live (in-session minute buffer) 1=hour 2=day 3=week 4=month. Session-scoped.
static int s_chart_interval = 2; // default: Day (historical)
// Approx seconds represented by one plotted point at the given chart interval, for X-axis labels.
static float pfChartSecPerPoint(int interval) {
switch (interval) {
case 1: return 3600.0f; // hour
case 2: return 86400.0f; // day
case 3: return 604800.0f; // week
case 4: return 2592000.0f; // month (~30d)
default: return 60.0f; // minute (live buffer)
}
}
// Timestamped variant of pfBucketBySeconds: each bucket keeps its last sample's timestamp so the
// chart can label the X axis / hover with real dates. Input oldest->newest; output preserves order.
static std::vector<std::pair<std::time_t, double>> pfBucketBySecondsTimed(
const std::vector<std::pair<std::time_t, double>>& series, long windowSec)
{
std::vector<std::pair<std::time_t, double>> out;
if (series.empty() || windowSec <= 0) return out;
long curBucket = 0; double sum = 0.0; int cnt = 0; std::time_t lastTs = 0;
for (const auto& s : series) {
long b = (long)(s.first / windowSec);
if (cnt > 0 && b != curBucket) { out.push_back({ lastTs, sum / cnt }); sum = 0.0; cnt = 0; }
curBucket = b; sum += s.second; ++cnt; lastTs = s.first;
}
if (cnt > 0) out.push_back({ lastTs, sum / cnt });
return out;
}
// Timestamped price series backing the main chart for the selected interval (Live/1H/1D/1W/1M).
// Historical intervals bucket the CoinGecko series; Live (or an un-fetched historical interval)
// synthesizes minute timestamps for the in-session buffer, ending at `now`.
// Timestamped price series backing the main chart for the selected RANGE (Live/1H/1D/1W/1M).
// The interval buttons select a time window ending at `now`: 1H = last hour, 1D = last 24h,
// 1W = last 7 days, 1M = last 30 days. 1H/1D come from the 5-minute intraday series; 1W/1M from
// the daily series. Live (or an un-fetched/empty range) uses the in-session minute buffer.
static std::vector<std::pair<std::time_t, double>> pfChartSeries(const MarketInfo& m, int interval,
std::time_t now)
{
const long kDay = 86400;
auto lastWindow = [now](const std::vector<std::pair<std::time_t, double>>& src, long rangeSec) {
std::vector<std::pair<std::time_t, double>> out;
std::time_t cutoff = now - (std::time_t)rangeSec;
for (const auto& s : src) if (s.first >= cutoff) out.push_back(s);
return out;
};
switch (interval) {
case 1: { auto v = pfBucketBySecondsTimed(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; }
case 2: { auto v = pfBucketBySecondsTimed(m.price_chart_daily, kDay); if (v.size() >= 2) return v; break; }
case 3: { auto v = pfBucketBySecondsTimed(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; }
case 4: { auto v = pfBucketBySecondsTimed(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; }
case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
default: break;
}
std::vector<std::pair<std::time_t, double>> out;
@@ -914,7 +893,6 @@ void RenderMarketTab(App* app)
ImFont* ovFont = Type().overline();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* h4 = Type().h4();
ImFont* body2 = Type().body2();
char buf[128];
@@ -996,9 +974,10 @@ void RenderMarketTab(App* app)
// Chart series for the selected interval (timestamped), shared by the hero change badge and
// the chart block below. Computed once here so the badge can report the displayed period's change.
std::time_t nowSec = std::time(nullptr);
std::vector<std::time_t> chartTimes;
s_price_history.clear();
for (const auto& pr : pfChartSeries(market, s_chart_interval, std::time(nullptr))) {
for (const auto& pr : pfChartSeries(market, s_chart_interval, nowSec)) {
s_price_history.push_back(pr.second);
chartTimes.push_back(pr.first);
}
@@ -1317,17 +1296,17 @@ void RenderMarketTab(App* app)
markExtreme(loIdx, false);
}
// X-axis time labels. Each point spans one interval unit (minute/hour/day/week/month);
// convert points-before-now to a "~<time> ago" label (rightmost = "now"). Approximate — "~".
// X-axis time labels. The series is timestamped, so label each tick with the real
// "<time> ago" (rightmost = "now") derived from the sample's timestamp.
{
float secPerPoint = pfChartSecPerPoint(s_chart_interval);
int nPts = (int)n;
int ticks = std::min(4, nPts);
for (int tk = 0; tk < ticks; tk++) {
float t = (ticks > 1) ? (float)tk / (float)(ticks - 1) : 1.0f;
float xpos = plotLeft + t * plotW;
int idx = (int)(t * (nPts - 1) + 0.5f);
float secAgo = (float)(nPts - 1 - idx) * secPerPoint;
float secAgo = (idx < (int)chartTimes.size()) ? (float)(nowSec - chartTimes[idx])
: (float)(nPts - 1 - idx) * 60.0f;
char tlbl[32];
if (secAgo < 45.0f) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (secAgo < 5400.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fm", secAgo / 60.0f);
@@ -1623,9 +1602,6 @@ void RenderMarketTab(App* app)
dl->AddRect(gcMin, gcMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.5f : 2.0f);
} else if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
float left = gcMin.x + pad;
float right = gcMax.x - pad;
float availW = right - left;
auto fit = [&](std::string s, ImFont* f, float maxW) {
bool t = false;
while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) { s.pop_back(); t = true; }
@@ -1643,63 +1619,66 @@ void RenderMarketTab(App* app)
std::string heroStr = heroIsValue ? valueStr : (e.showDrgx ? std::string(db) : (hasValue ? valueStr : std::string(db)));
std::string footStr = (heroIsValue && e.showDrgx) ? std::string(db) : std::string();
// Header: icon + name (left), 24h badge (right).
float hy = gcMin.y + pad, nameX = left, nameMaxW = availW;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(),
ImVec2(nameX + body2->LegacySize * 0.5f, hy + body2->LegacySize * 0.5f),
accent ? accent : OnSurfaceMedium(), Type().iconSmall(), body2->LegacySize);
nameX += body2->LegacySize + Layout::spacingSm();
nameMaxW = right - nameX;
}
if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) {
char cb[32]; snprintf(cb, sizeof(cb), "%+.2f%%", market.change_24h);
float cw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, cb).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(right - cw, hy + (body2->LegacySize - capFont->LegacySize) * 0.5f),
market.change_24h >= 0 ? Success() : Error(), cb);
nameMaxW -= cw + Layout::spacingSm();
}
dl->AddText(body2, body2->LegacySize, ImVec2(nameX, hy), OnSurfaceMedium(),
fit(e.label, body2, nameMaxW).c_str());
// Card layout adapts to height (cells are ~32px): tall cards get the full stat tile;
// shorter cards drop the redundant DRGX footer; the shortest (2-cell) also drop the
// sparkline so the header + hero value stay legible.
// Compact stat card: name (+ secondary DRGX) on the left; value with % change stacked
// below it in the top-right corner; the sparkline fills the body — so it renders even
// on a 2-cell card. Tighter padding on short cards.
float cardHeight = gcMax.y - gcMin.y;
bool cardTall = cardHeight > 112.0f * mktDp; // footer + large hero
bool showFooter = !footStr.empty() && cardTall;
bool showSpark = e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)
&& cardHeight > 76.0f * mktDp;
bool cardTall = cardHeight > 112.0f * mktDp;
bool cardShort = cardHeight < 90.0f * mktDp;
float hpad = cardShort ? Layout::spacingSm() : pad;
float hleft = gcMin.x + hpad, hright = gcMax.x - hpad;
float hAvailW = hright - hleft;
float topY = gcMin.y + hpad;
// Sparkline strip along the bottom.
float sparkH = 0.0f;
if (showSpark) {
std::vector<double> hh = pfSparklineSeries(market, e.sparklineInterval);
if (hh.size() >= 2) {
sparkH = std::max(18.0f * mktDp, (gcMax.y - gcMin.y) * 0.24f);
pfDrawSparklineFilled(dl, ImVec2(left, gcMax.y - pad - sparkH), ImVec2(right, gcMax.y - pad),
hh, hh.back() >= hh.front() ? Success() : Error());
// Right column: value (top-right), % change just below it.
float rightBottom = topY;
float valLeft = hright;
bool showChange = e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0;
if (!heroStr.empty()) {
ImFont* vf = cardTall ? sub1 : body2;
std::string vs = fit(heroStr, vf, hAvailW * 0.66f);
float vw = vf->CalcTextSizeA(vf->LegacySize, FLT_MAX, 0, vs.c_str()).x;
valLeft = hright - vw;
dl->AddText(vf, vf->LegacySize, ImVec2(valLeft, topY), OnSurface(), vs.c_str());
rightBottom = topY + vf->LegacySize;
if (showChange) {
char cb[32]; snprintf(cb, sizeof(cb), "%+.2f%%", market.change_24h);
float cw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, cb).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(hright - cw, rightBottom + 1.0f * mktDp),
market.change_24h >= 0 ? Success() : Error(), cb);
valLeft = std::min(valLeft, hright - cw);
rightBottom += capFont->LegacySize + 1.0f * mktDp;
}
}
// Footer: DRGX amount just above the sparkline.
float footBottom = gcMax.y - pad - (sparkH > 0 ? sparkH + Layout::spacingXs() : 0.0f);
float footTop = footBottom;
if (showFooter) {
footTop = footBottom - capFont->LegacySize;
dl->AddText(capFont, capFont->LegacySize, ImVec2(left, footTop),
OnSurfaceDisabled(), fit(footStr, capFont, availW).c_str());
// Left column: icon + name (top), secondary DRGX below on taller cards.
float nameX = hleft;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(),
ImVec2(nameX + body2->LegacySize * 0.5f, topY + body2->LegacySize * 0.5f),
accent ? accent : OnSurfaceMedium(), Type().iconSmall(), body2->LegacySize);
nameX += body2->LegacySize + Layout::spacingSm();
}
float nameMaxW = std::max(24.0f * mktDp, valLeft - Layout::spacingSm() - nameX);
dl->AddText(body2, body2->LegacySize, ImVec2(nameX, topY), OnSurfaceMedium(),
fit(e.label, body2, nameMaxW).c_str());
float leftBottom = topY + body2->LegacySize;
if (!footStr.empty() && cardHeight > 88.0f * mktDp) {
dl->AddText(capFont, capFont->LegacySize, ImVec2(nameX, leftBottom + 1.0f * mktDp),
OnSurfaceDisabled(), fit(footStr, capFont, nameMaxW).c_str());
leftBottom += capFont->LegacySize + 1.0f * mktDp;
}
// Hero value: large, centered in the band between the header and footer.
if (!heroStr.empty()) {
ImFont* heroFont = cardTall ? h4 : sub1;
float bandTop = hy + body2->LegacySize + Layout::spacingSm();
float bandBot = (showFooter ? footTop : footBottom) - Layout::spacingXs();
float heroY = std::max(bandTop, (bandTop + bandBot) * 0.5f - heroFont->LegacySize * 0.5f);
dl->AddText(heroFont, heroFont->LegacySize, ImVec2(left, heroY),
OnSurface(), fit(heroStr, heroFont, availW).c_str());
// Body: sparkline fills from below the header to the card bottom.
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) {
std::vector<double> hh = pfSparklineSeries(market, e.sparklineInterval);
if (hh.size() >= 2) {
float sparkTop = std::max(leftBottom, rightBottom) + Layout::spacingXs();
float sparkBot = gcMax.y - hpad;
if (sparkBot - sparkTop >= 8.0f * mktDp)
pfDrawSparklineFilled(dl, ImVec2(hleft, sparkTop), ImVec2(hright, sparkBot),
hh, hh.back() >= hh.front() ? Success() : Error());
}
}
};