feat(market): chart gradient fill, period change %, timestamped hover, high/low markers

Builds on the timestamped historical series (pfChartSeries returns [time,price]):

- Gradient area fill: replace the flat translucent fill with a true vertical
  gradient (solid at the line, fading to transparent) via the draw-list primitive
  API (pfFillGradientArea).
- Period-aware change: the price badge now reports the change over the DISPLAYED
  chart period with a span label derived from real timestamps (e.g. "+42% 1.0y"),
  falling back to 24h for Live. Line/fill/marker colors follow that direction.
- Timestamped hover: the crosshair tooltip shows the real date/time at the hovered
  point (time for Live/1H, date for day+ intervals) alongside the price.
- High/low markers: hollow dot + price label at the range's extremes.

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

View File

@@ -180,6 +180,66 @@ static float pfChartSecPerPoint(int interval) {
}
}
// 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`.
static std::vector<std::pair<std::time_t, double>> pfChartSeries(const MarketInfo& m, int interval,
std::time_t now)
{
const long kDay = 86400;
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; }
default: break;
}
std::vector<std::pair<std::time_t, double>> out;
const auto& h = m.price_history;
for (size_t i = 0; i < h.size(); i++)
out.push_back({ now - (std::time_t)((h.size() - 1 - i) * 60), h[i] });
return out;
}
// Vertical-gradient area fill under a polyline (solid `topCol` at the curve, fading to `botCol` at
// `bottomY`). Drawn as one gradient quad per segment via the draw list's primitive API.
static void pfFillGradientArea(ImDrawList* dl, const std::vector<ImVec2>& pts, float bottomY,
ImU32 topCol, ImU32 botCol)
{
int n = (int)pts.size();
if (n < 2) return;
const ImVec2 uv = dl->_Data->TexUvWhitePixel;
for (int i = 0; i + 1 < n; i++) {
const ImVec2& p0 = pts[i];
const ImVec2& p1 = pts[i + 1];
dl->PrimReserve(6, 4);
unsigned int base = (unsigned int)dl->_VtxCurrentIdx;
dl->PrimWriteVtx(p0, uv, topCol);
dl->PrimWriteVtx(p1, uv, topCol);
dl->PrimWriteVtx(ImVec2(p1.x, bottomY), uv, botCol);
dl->PrimWriteVtx(ImVec2(p0.x, bottomY), uv, botCol);
dl->PrimWriteIdx((ImDrawIdx)base); dl->PrimWriteIdx((ImDrawIdx)(base + 1)); dl->PrimWriteIdx((ImDrawIdx)(base + 2));
dl->PrimWriteIdx((ImDrawIdx)base); dl->PrimWriteIdx((ImDrawIdx)(base + 2)); dl->PrimWriteIdx((ImDrawIdx)(base + 3));
}
}
// A group's value trend tracks the DRGX price, so a group sparkline plots the price history
// (normalized) across a rect. Colored by overall direction. Only meaningful for market bases.
static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
@@ -934,6 +994,37 @@ void RenderMarketTab(App* app)
float pfGroupsH = (pfN > 0) ? ((pfMaxRow + 2) * pfCellH) : 0.0f;
float portfolioH = pfSummaryH + pfGroupsH;
// 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::vector<std::time_t> chartTimes;
s_price_history.clear();
for (const auto& pr : pfChartSeries(market, s_chart_interval, std::time(nullptr))) {
s_price_history.push_back(pr.second);
chartTimes.push_back(pr.first);
}
// Change over the displayed period (Live falls back to the market's 24h figure).
double periodChangePct = market.change_24h;
bool periodIsInterval = false;
if (s_chart_interval != 0 && s_price_history.size() >= 2 && s_price_history.front() > 0.0) {
periodChangePct = (s_price_history.back() - s_price_history.front()) / s_price_history.front() * 100.0;
periodIsInterval = true;
}
bool chartUp = periodChangePct >= 0.0;
// Compact label for the displayed span (from the real timestamps), e.g. "24h", "7d", "1.0y".
auto pfSpanLabel = [](long sec) -> std::string {
char b[32];
if (sec < 5400) snprintf(b, sizeof(b), "%ldm", sec / 60);
else if (sec < 172800) snprintf(b, sizeof(b), "%ldh", sec / 3600);
else if (sec < 1209600) snprintf(b, sizeof(b), "%ldd", sec / 86400);
else if (sec < 7776000) snprintf(b, sizeof(b), "%ldw", sec / 604800);
else if (sec < 63072000) snprintf(b, sizeof(b), "%ldmo", sec / 2592000);
else snprintf(b, sizeof(b), "%.1fy", sec / 31536000.0);
return b;
};
std::string periodSuffix = "24h";
if (periodIsInterval && chartTimes.size() >= 2)
periodSuffix = pfSpanLabel((long)(chartTimes.back() - chartTimes.front()));
// ================================================================
// PRICE SUMMARY — Combined hero card with price, stats, and exchange
// ================================================================
@@ -964,17 +1055,17 @@ void RenderMarketTab(App* app)
ImVec2(cx + priceW + Layout::spacingSm(), cy + (h3->LegacySize - body2->LegacySize)),
OnSurfaceMedium(), DRAGONX_TICKER);
// 24h change badge — to the right of ticker
// Change badge — over the displayed chart period (or 24h for Live) — right of the ticker.
float tickerW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, DRAGONX_TICKER).x;
float badgeX = cx + priceW + Layout::spacingSm() + tickerW + Layout::spacingMd();
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
snprintf(buf, sizeof(buf), "%s%.2f%% 24h", market.change_24h >= 0 ? "+" : "", market.change_24h);
ImU32 chgCol = chartUp ? Success() : Error();
snprintf(buf, sizeof(buf), "%s%.2f%% %s", chartUp ? "+" : "", periodChangePct, periodSuffix.c_str());
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float badgePadH = Layout::spacingSm();
float badgePadV = Layout::spacingXs();
ImVec2 bMin(badgeX, cy + (h3->LegacySize - chgSz.y - badgePadV * 2) * 0.5f);
ImVec2 bMax(badgeX + chgSz.x + badgePadH * 2, bMin.y + chgSz.y + badgePadV * 2);
ImU32 badgeBg = market.change_24h >= 0 ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
ImU32 badgeBg = chartUp ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
@@ -1059,10 +1150,9 @@ void RenderMarketTab(App* app)
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
// ================================================================
{
// Plot the historical price series for the selected interval (Live/1H/1D/1W/1M). The
// historical intervals come from CoinGecko market_chart; "Live" is the in-session buffer.
// Until two points exist, the empty-state below is shown instead of a synthetic curve.
s_price_history = pfSparklineSeries(market, s_chart_interval);
// The chart series (s_price_history) + timestamps (chartTimes) were computed in the
// precompute above. Historical intervals come from CoinGecko market_chart; "Live" is the
// in-session buffer. Until two points exist, the empty-state below is shown.
s_history_initialized = true;
// The chart shares the combined hero+chart glass panel drawn in PRICE SUMMARY above.
@@ -1183,12 +1273,10 @@ void RenderMarketTab(App* app)
size_t n = s_price_history.size();
std::vector<ImVec2> points(n);
ImU32 lineCol = market.change_24h >= 0
? WithAlpha(Success(), 220) : WithAlpha(Error(), 220);
ImU32 fillCol = market.change_24h >= 0
? WithAlpha(Success(), 25) : WithAlpha(Error(), 25);
ImU32 dotCol = market.change_24h >= 0
? Success() : Error();
// Colored by the DISPLAYED period's direction (chartUp), not just 24h.
ImU32 dirCol = chartUp ? Success() : Error();
ImU32 lineCol = WithAlpha(dirCol, 220);
ImU32 dotCol = dirCol;
for (size_t i = 0; i < n; i++) {
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
@@ -1197,14 +1285,8 @@ void RenderMarketTab(App* app)
points[i] = ImVec2(x, y);
}
// Fill under curve (single concave polygon to avoid AA seam artifacts)
if (n >= 2) {
for (size_t i = 0; i < n; i++)
dl->PathLineTo(points[i]);
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
dl->PathFillConcave(fillCol);
}
// Gradient area fill under the curve (solid near the line, fading to transparent).
pfFillGradientArea(dl, points, plotBottom, WithAlpha(dirCol, 65), WithAlpha(dirCol, 0));
// Line
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
@@ -1218,6 +1300,23 @@ void RenderMarketTab(App* app)
dl->AddCircleFilled(points[n - 1], dotR + 1.0f * mktDp, dotCol);
dl->AddCircle(points[n - 1], dotR + 3.5f * mktDp, WithAlpha(dotCol, 130), 0, 1.4f * mktDp);
// High/low markers for the displayed range: a hollow dot + price label at the extremes.
if (n >= 3) {
size_t hiIdx = (size_t)(std::max_element(s_price_history.begin(), s_price_history.end()) - s_price_history.begin());
size_t loIdx = (size_t)(std::min_element(s_price_history.begin(), s_price_history.end()) - s_price_history.begin());
auto markExtreme = [&](size_t idx, bool high) {
ImVec2 p = points[idx];
std::string lbl = FormatPrice(s_price_history[idx]);
ImVec2 ls = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lbl.c_str());
float ly = high ? p.y - ls.y - 5.0f * mktDp : p.y + 5.0f * mktDp;
float lx = std::min(std::max(plotLeft, p.x - ls.x * 0.5f), plotRight - ls.x);
dl->AddCircle(p, 2.6f * mktDp, WithAlpha(OnSurface(), 190), 0, 1.3f * mktDp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, ly), OnSurfaceMedium(), lbl.c_str());
};
markExtreme(hiIdx, true);
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 — "~".
{
@@ -1269,9 +1368,19 @@ void RenderMarketTab(App* app)
dl->AddCircleFilled(ImVec2(px, py), hoverDotR, dotCol);
dl->AddCircle(ImVec2(px, py), hoverRingR, IM_COL32(255, 255, 255, 80), 0, 1.5f);
// Samples are per-refresh, not timestamped, so we don't claim a specific
// "Xh ago" — just show the price at the hovered point.
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_price_history[idx]).c_str());
// Tooltip: real date/time (from the series timestamps) + price at the hovered point.
// Sub-day intervals (Live/1H) show the time; day+ intervals show the date.
char whenBuf[32] = "";
if (idx < (int)chartTimes.size()) {
std::time_t tt = chartTimes[idx];
std::tm* tmv = std::localtime(&tt);
if (tmv) std::strftime(whenBuf, sizeof(whenBuf),
s_chart_interval <= 1 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
}
if (whenBuf[0])
snprintf(buf, sizeof(buf), "%s \xC2\xB7 %s", whenBuf, FormatPrice(s_price_history[idx]).c_str());
else
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_price_history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10;