feat(market): compact chart w/ time axis; floating portfolio + redesigned group cards

Follow-up to the Market tab rework, per feedback:

- Chart is more compact: a modest responsive height (~22% of the tab, floored
  at 110px) instead of stretching to fill.
- Chart now has a time axis. The price series is one sample per ~60s market
  refresh (RefreshScheduler::kPrice), so the x-axis is labelled in approximate
  minutes-ago ("~45m … ~15m … now") instead of only "Now" — you can now read
  the interval/span of each plot point.
- Portfolio: removed the top-level card background — the summary (fiat hero +
  24h change + BTC, DRGX balance + Z/T, ratio bar) now floats directly under the
  MY DRGX header, and the "GROUPS" heading is gone.
- Group cards redesigned: a max width (280px, shrinks on narrow tabs) so they
  float rather than stretch; the group name is larger (subtitle font) top-left;
  the DRGX + fiat amounts moved to the card's top-right corner; and more inner
  padding around the content.

Removed the now-unused portfolio_groups i18n key. Full-node + Lite build clean;
ctest 1/1; hygiene clean. Rendering change — needs a screenshot check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:32:19 -05:00
parent 67feaea767
commit 9bcdf51895
2 changed files with 81 additions and 80 deletions

View File

@@ -276,39 +276,12 @@ void RenderMarketTab(App* app)
char buf[128];
// ================================================================
// Section geometry — this tab does NOT scroll, so everything must fit. Precompute the
// portfolio card height (refined summary + a grid of group cards) and the hero header,
// then let the price chart absorb the remaining vertical space (capped at its schema
// height, floored so it stays useful). Fixes the old layout where an oversized chart
// squeezed the portfolio into a cramped strip.
// Section geometry.
// ================================================================
float mktDp = Layout::dpiScale();
// -- Portfolio card geometry --
const auto& pfEntriesGeo = app->settings()->getPortfolioEntries();
float pfInnerW = availWidth - Layout::spacingLg() * 2.0f;
float ratioBarH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size,
S.drawElement("tabs.market", "ratio-bar-height").size * vs);
float pfSummaryH = Layout::spacingLg()
+ sub1->LegacySize + Layout::spacingSm() // fiat hero row
+ body2->LegacySize + Layout::spacingSm() // DRGX balance row
+ ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label
+ Layout::spacingMd(); // bottom pad
int pfN = (int)pfEntriesGeo.size();
float pfCardGap = Layout::spacingSm();
float pfMinCardW = 200.0f * mktDp;
int pfCardsPerRow = std::max(1, (int)((pfInnerW + pfCardGap) / (pfMinCardW + pfCardGap)));
if (pfN > 0 && pfCardsPerRow > pfN) pfCardsPerRow = pfN;
float pfCardW = (pfN > 0) ? (pfInnerW - (pfCardsPerRow - 1) * pfCardGap) / (float)pfCardsPerRow : 0.0f;
float pfCardH = body2->LegacySize + Layout::spacingXs() + body2->LegacySize
+ capFont->LegacySize + Layout::spacingSm() * 2.0f;
int pfRows = (pfN > 0) ? (pfN + pfCardsPerRow - 1) / pfCardsPerRow : 0;
float pfGroupsH = (pfN > 0)
? (ovFont->LegacySize + Layout::spacingSm()
+ pfRows * pfCardH + std::max(0, pfRows - 1) * pfCardGap
+ Layout::spacingMd())
: 0.0f;
float portfolioCardH = pfSummaryH + pfGroupsH;
// -- Compact price chart: a modest responsive height, no longer stretched to fill the tab. --
float chartH = std::max(110.0f * vs, std::min(chartElem.height * vs, marketAvail.y * 0.22f));
// -- Hero header (price + stats, excluding the chart) --
float mktStatsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
@@ -316,12 +289,28 @@ void RenderMarketTab(App* app)
S.drawElement("tabs.market", "hero-card-min-height").size + mktStatsRowH + pad,
(S.drawElement("tabs.market", "hero-card-height").size + mktStatsRowH + pad) * vs);
// -- Chart absorbs the slack: capped at its schema height, floored so it stays useful --
float mktChipH = S.drawElement("tabs.market", "pair-chip-height").height;
float mktPairsReserve = mktChipH * 2.0f + Layout::spacingXl() + capFont->LegacySize;
float chartH = std::min(chartElem.height * vs,
marketAvail.y - heroHeaderH - portfolioCardH - mktPairsReserve - gap * 3.0f);
chartH = std::max(chartH, 130.0f * vs);
// -- Portfolio geometry. No parent card background: the summary floats under the header
// and the custom-group cards float below it (each with its own glass background). --
const auto& pfEntriesGeo = app->settings()->getPortfolioEntries();
float ratioBarH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size,
S.drawElement("tabs.market", "ratio-bar-height").size * vs);
float pfSummaryH = sub1->LegacySize + Layout::spacingSm() // fiat hero row
+ body2->LegacySize + Layout::spacingSm() // DRGX balance row
+ ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label
+ Layout::spacingMd(); // gap before the group cards
int pfN = (int)pfEntriesGeo.size();
float pfCardGap = Layout::spacingSm();
float pfCardPad = Layout::spacingMd(); // inner padding of each group card
float pfCardW = std::min(280.0f * mktDp, availWidth); // max card width (shrinks on a narrow tab)
int pfCardsPerRow = std::max(1, (int)((availWidth + pfCardGap) / (pfCardW + pfCardGap)));
if (pfN > 0 && pfCardsPerRow > pfN) pfCardsPerRow = pfN;
// Card: a name row (sub1) + a fiat row (caption); the DRGX/fiat amounts sit top-right.
float pfCardH = pfCardPad * 2.0f + sub1->LegacySize + Layout::spacingXs() + capFont->LegacySize;
int pfRows = (pfN > 0) ? (pfN + pfCardsPerRow - 1) / pfCardsPerRow : 0;
float pfGroupsH = (pfN > 0)
? (pfRows * pfCardH + std::max(0, pfRows - 1) * pfCardGap)
: 0.0f;
float portfolioH = pfSummaryH + pfGroupsH;
// ================================================================
// PRICE SUMMARY — Combined hero card with price, stats, and exchange
@@ -558,14 +547,29 @@ void RenderMarketTab(App* app)
dl->AddCircleFilled(points[i], dotR, dotCol);
}
// X-axis: samples are per-refresh, not hourly-timestamped, so only the most
// recent point can be labelled truthfully ("Now"). The series simply runs
// older→newer, left→right. (Avoids the old fake "24h…6h" hourly gradations.)
// X-axis time labels. The price series is one sample per market refresh (~60s
// cadence, see RefreshScheduler::kPrice), so label the axis in approximate
// minutes-ago (rightmost = "now"). Approximate — a refresh can be delayed — hence "~".
{
const char* nowText = TR("market_now");
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, nowText);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(plotRight - lblSz.x, plotBottom + 2), OnSurfaceDisabled(), nowText);
const float kSecPerPoint = 60.0f; // RefreshScheduler::kPrice
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);
int minsAgo = (int)(((float)(nPts - 1 - idx) * kSecPerPoint) / 60.0f + 0.5f);
char tlbl[32];
if (minsAgo <= 0) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (minsAgo < 90) snprintf(tlbl, sizeof(tlbl), "~%dm", minsAgo);
else snprintf(tlbl, sizeof(tlbl), "~%.1fh", minsAgo / 60.0f);
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, tlbl);
float lx = (tk == 0) ? plotLeft
: (tk == ticks - 1) ? plotRight - lblSz.x
: xpos - lblSz.x * 0.5f;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lx, plotBottom + 2), OnSurfaceDisabled(), tlbl);
}
}
// Hover crosshair + tooltip
@@ -756,7 +760,7 @@ void RenderMarketTab(App* app)
}
// ================================================================
// PORTFOLIO — Glass card with balance breakdown
// PORTFOLIO — floating summary (no card background) + custom-group cards
// ================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
@@ -775,13 +779,11 @@ void RenderMarketTab(App* app)
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// No parent card background — the summary floats directly under the "MY DRGX" header.
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float cardH = portfolioCardH; // precomputed: summary block + group-card grid
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
float cx = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + Layout::spacingLg();
float rightEdge = cardMin.x + availWidth;
float cx = cardMin.x;
float cy = cardMin.y;
// ---- SUMMARY: fiat value (hero) + 24h change, BTC right-aligned ----
if (market.price_usd > 0) {
@@ -805,7 +807,7 @@ void RenderMarketTab(App* app)
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - btcW - pad, cy + (sub1->LegacySize - capFont->LegacySize)),
ImVec2(rightEdge - btcW, cy + (sub1->LegacySize - capFont->LegacySize)),
OnSurfaceMedium(), buf);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), TR("market_no_price"));
@@ -818,12 +820,12 @@ void RenderMarketTab(App* app)
snprintf(buf, sizeof(buf), "Z %.4f \xC2\xB7 T %.4f", private_balance, transparent_balance);
float brkW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - brkW - pad, cy + 2), OnSurfaceDisabled(), buf);
ImVec2(rightEdge - brkW, cy + 2), OnSurfaceDisabled(), buf);
cy += body2->LegacySize + Layout::spacingSm();
// Full-width shielded/transparent ratio bar + % label.
if (total_balance > 0) {
float barW = pfInnerW;
float barW = availWidth;
float shieldedRatio = (float)(private_balance / total_balance);
if (shieldedRatio > 1.0f) shieldedRatio = 1.0f;
if (shieldedRatio < 0.0f) shieldedRatio = 0.0f;
@@ -849,12 +851,9 @@ void RenderMarketTab(App* app)
ImVec2(cx, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
}
// ---- CUSTOM GROUPS — one glass card per entry, wrapping into a grid ----
// ---- CUSTOM GROUPS — floating glass cards below the summary (no heading) ----
if (pfN > 0) {
float gy = cardMin.y + pfSummaryH;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, gy), OnSurfaceMedium(), TR("portfolio_groups"));
gy += ovFont->LegacySize + Layout::spacingSm();
for (int i = 0; i < pfN; i++) {
const auto& e = pfEntriesGeo[i];
int col = i % pfCardsPerRow;
@@ -863,42 +862,45 @@ void RenderMarketTab(App* app)
ImVec2 gcMax(gcMin.x + pfCardW, gcMin.y + pfCardH);
GlassPanelSpec gcGlass;
gcGlass.rounding = 8.0f;
gcGlass.fillAlpha = 18;
gcGlass.borderAlpha = 30;
gcGlass.rounding = 10.0f;
gcGlass.fillAlpha = 22;
gcGlass.borderAlpha = 40;
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
float tcx = gcMin.x + Layout::spacingSm();
float tcy = gcMin.y + Layout::spacingSm();
float maxTextW = pfCardW - Layout::spacingSm() * 2.0f;
float rowY = gcMin.y + pfCardPad;
float gcRight = gcMax.x - pfCardPad;
// Label (truncated with an ellipsis to the card width).
// Amounts, top-right corner: DRGX on the name row, fiat directly below it.
snprintf(buf, sizeof(buf), "%.4f %s", bal, DRAGONX_TICKER);
float drgxW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(body2, body2->LegacySize,
ImVec2(gcRight - drgxW, rowY + (sub1->LegacySize - body2->LegacySize) * 0.5f),
OnSurface(), buf);
if (market.price_usd > 0) {
char usd[48];
snprintf(usd, sizeof(usd), "$%.2f", bal * market.price_usd);
float usdW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usd).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(gcRight - usdW, rowY + sub1->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), usd);
}
// Group name, top-left, larger — truncated so it never overlaps the amount.
float nameMaxW = (gcRight - drgxW - Layout::spacingMd()) - (gcMin.x + pfCardPad);
std::string label = e.label;
bool truncated = false;
while (label.size() > 1 &&
body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, label.c_str()).x > maxTextW) {
sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, label.c_str()).x > nameMaxW) {
label.pop_back();
truncated = true;
}
if (truncated) label += "\xE2\x80\xA6";
dl->AddText(body2, body2->LegacySize, ImVec2(tcx, tcy), OnSurfaceMedium(), label.c_str());
tcy += body2->LegacySize + Layout::spacingXs();
// DRGX amount.
snprintf(buf, sizeof(buf), "%.4f %s", bal, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(tcx, tcy), OnSurface(), buf);
tcy += body2->LegacySize;
// Fiat value.
if (market.price_usd > 0) {
snprintf(buf, sizeof(buf), "$%.2f", bal * market.price_usd);
dl->AddText(capFont, capFont->LegacySize, ImVec2(tcx, tcy), OnSurfaceDisabled(), buf);
}
dl->AddText(sub1, sub1->LegacySize, ImVec2(gcMin.x + pfCardPad, rowY), OnSurface(), label.c_str());
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + cardH));
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}

View File

@@ -1119,7 +1119,6 @@ void I18n::loadBuiltinEnglish()
strings_["market_pct_shielded"] = "%.0f%% Shielded";
strings_["market_portfolio"] = "MY DRGX";
strings_["portfolio_all_funds"] = "All funds";
strings_["portfolio_groups"] = "GROUPS";
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
strings_["portfolio_manage_title"] = "Manage portfolio";
strings_["portfolio_add_entry"] = "Add entry";