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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user