feat(market): card/chart refinements — 8-cell min width, larger labels, flat fill, range-matched change

Portfolio group cards:
- Always show the secondary DRGX amount, including on 2-cell cards.
- Larger group icon + label (subtitle1); value stays subtitle1 for consistency.
- Minimum group width is now 8 cells (default width 8).

Price chart:
- Area fill is now a flat translucent fill matching the group-card sparklines
  (dropped the vertical gradient + its primitive-API helper).
- Interval buttons sit near the top of the chart area (removed the empty gap
  above them) with a comfortable gap below before the plot.
- The change badge's suffix now matches the SELECTED range (1H->1h, 1D->24h,
  1W->7d, 1M->30d) instead of the raw data span (which read 23h / 6d).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 17:09:40 -05:00
parent d48c6db21c
commit 8c88afe16e
2 changed files with 33 additions and 57 deletions

View File

@@ -96,7 +96,7 @@ public:
// (-1 = auto-place), w/h in cells (span). Set when the user drags/resizes a card.
int gridCol = -1;
int gridRow = -1;
int gridW = 6;
int gridW = 8; // default group width (cells); minimum is 8
int gridH = 3; // default group height (cells); minimum is 2, card design adapts
};

View File

@@ -197,27 +197,6 @@ static std::vector<std::pair<std::time_t, double>> pfChartSeries(const MarketInf
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.
@@ -929,7 +908,7 @@ void RenderMarketTab(App* app)
// this grid and span many cells; they're drawn with an inner inset so adjacent cards don't touch.
float pfCardGap = 0.0f; // gapless cell grid
float pfInset = 3.0f * mktDp; // per-card inset for visual separation
const int pfMinW = 4, pfMinH = 2; // minimum card size in cells (~128x64)
const int pfMinW = 8, pfMinH = 2; // minimum card size in cells (~256x64)
int pfCols = std::max(pfMinW, (int)(availWidth / (32.0f * mktDp)));
float pfCellW = availWidth / (float)pfCols; // square cell edge (>= ~32px)
float pfCellH = pfCellW;
@@ -983,26 +962,18 @@ void RenderMarketTab(App* app)
}
// 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) {
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;
};
// Suffix = the SELECTED range (not the raw data span), so 1D reads "24h", 1W reads "7d", etc.
std::string periodSuffix = "24h";
if (periodIsInterval && chartTimes.size() >= 2)
periodSuffix = pfSpanLabel((long)(chartTimes.back() - chartTimes.front()));
switch (s_chart_interval) {
case 1: periodSuffix = "1h"; break; // 1H
case 2: periodSuffix = "24h"; break; // 1D
case 3: periodSuffix = "7d"; break; // 1W
case 4: periodSuffix = "30d"; break; // 1M
default: periodSuffix = "24h"; break; // Live (uses the market's 24h change)
}
// ================================================================
// PRICE SUMMARY — Combined hero card with price, stats, and exchange
@@ -1140,11 +1111,14 @@ void RenderMarketTab(App* app)
float chartPad = pad;
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
float stripH = pillH + Layout::spacingSm(); // top control/info strip height
// Buttons sit near the top of the chart area (minimal gap above), with a comfortable gap
// below before the plot so they aren't crowded against the chart.
float stripTop = chartMin.y + Layout::spacingXs();
float stripH = pillH + Layout::spacingMd();
// ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ----
{
float rowTop = chartMin.y + chartPad;
float rowTop = stripTop;
float textY = rowTop + (pillH - capFont->LegacySize) * 0.5f;
// Interval buttons (left). Live = in-session buffer; the rest are historical.
@@ -1216,7 +1190,7 @@ void RenderMarketTab(App* app)
float labelPadBottom = Layout::spacingXl() + Layout::spacingSm();
float plotLeft = chartMin.x + labelPadLeft;
float plotRight = chartMax.x - chartPad;
float plotTop = chartMin.y + chartPad + stripH;
float plotTop = stripTop + stripH;
float plotBottom = chartMax.y - labelPadBottom;
float plotW = plotRight - plotLeft;
float plotH = plotBottom - plotTop;
@@ -1264,8 +1238,11 @@ void RenderMarketTab(App* app)
points[i] = ImVec2(x, y);
}
// Gradient area fill under the curve (solid near the line, fading to transparent).
pfFillGradientArea(dl, points, plotBottom, WithAlpha(dirCol, 65), WithAlpha(dirCol, 0));
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
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(WithAlpha(dirCol, 28));
// Line
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
@@ -1623,7 +1600,6 @@ void RenderMarketTab(App* app)
// 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;
bool cardShort = cardHeight < 90.0f * mktDp;
float hpad = cardShort ? Layout::spacingSm() : pad;
float hleft = gcMin.x + hpad, hright = gcMax.x - hpad;
@@ -1635,7 +1611,7 @@ void RenderMarketTab(App* app)
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;
ImFont* vf = sub1;
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;
@@ -1651,19 +1627,19 @@ void RenderMarketTab(App* app)
}
}
// Left column: icon + name (top), secondary DRGX below on taller cards.
// Left column: icon + name (larger, top), secondary DRGX amount below (always shown).
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();
ImVec2(nameX + sub1->LegacySize * 0.5f, topY + sub1->LegacySize * 0.5f),
accent ? accent : OnSurfaceMedium(), Type().iconSmall(), sub1->LegacySize);
nameX += sub1->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(sub1, sub1->LegacySize, ImVec2(nameX, topY), OnSurfaceMedium(),
fit(e.label, sub1, nameMaxW).c_str());
float leftBottom = topY + sub1->LegacySize;
if (!footStr.empty()) {
dl->AddText(capFont, capFont->LegacySize, ImVec2(nameX, leftBottom + 1.0f * mktDp),
OnSurfaceDisabled(), fit(footStr, capFont, nameMaxW).c_str());
leftBottom += capFont->LegacySize + 1.0f * mktDp;
@@ -1753,8 +1729,8 @@ void RenderMarketTab(App* app)
ImVec2 sz = cellSize(nw, nh), mx(mn.x + sz.x, mn.y + sz.y);
drawCard(mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
// Live size readout (e.g. "6\xC3\x972") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (4x2).
// Live size readout (e.g. "8\xC3\x972") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (8x2).
{
char szb[16]; snprintf(szb, sizeof(szb), "%d\xC3\x97%d", nw, nh);
ImVec2 tsz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, szb);