From ced1773e7008783628b02c99d1bb5906b16c0ccf Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 3 Jul 2026 15:53:41 -0500 Subject: [PATCH] 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) --- src/ui/windows/market_tab.cpp | 159 ++++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 25 deletions(-) diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 6e5c3c0..f5b0e3f 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -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> pfBucketBySecondsTimed( + const std::vector>& series, long windowSec) +{ + std::vector> 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> 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> 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& 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 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 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 "~