From 5aa3cc4d5110ea91266a742840802948a87e86a4 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 3 Jul 2026 15:37:31 -0500 Subject: [PATCH] feat(market): historical chart intervals, stats overlay, configurable outline, easier resize Chart: - The price chart now plots the historical CoinGecko series with a Live/1H/1D/1W/1M interval selector (top-left of the chart). Live = in-session buffer; 1H uses the intraday 5-min series, 1D/1W/1M the daily series (via pfSparklineSeries). X-axis labels are interval-aware ("~5h"/"~3d"/"~2w"/"~4mo"). Market-tab open kicks the self-throttled market_chart fetch so history populates promptly. - 24H volume + market cap moved from their own row onto the chart's top strip (right side, next to the refresh button), reclaiming the empty band above the chart; the hero header now holds just the price row + separator. - More bottom padding under the plot so the time labels and the DRGX/USDT pair buttons aren't crowded against the card edges. Portfolio groups: - Accent-outline opacity is now configurable per group (0-100%, default 25%) via a slider in the editor's Appearance column; applied to the card + preview outline. - Resize grip enlarged (14->20px) and a live "W x H" size readout is drawn while resizing, so shrinking a group to the 4x3 minimum is reliable and visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/settings.cpp | 3 + src/config/settings.h | 1 + src/ui/windows/market_tab.cpp | 269 ++++++++++++++++++++-------------- src/util/i18n.cpp | 4 + 4 files changed, 166 insertions(+), 111 deletions(-) diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 1be5efc..0c4483f 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -318,6 +318,8 @@ bool Settings::load(const std::string& path) entry.icon = e["icon"].get(); if (e.contains("color") && e["color"].is_number()) entry.color = e["color"].get(); + if (e.contains("outline_opacity") && e["outline_opacity"].is_number_integer()) + entry.outlineOpacity = e["outline_opacity"].get(); if (e.contains("price_basis") && e["price_basis"].is_number_integer()) entry.priceBasis = e["price_basis"].get(); if (e.contains("manual_price") && e["manual_price"].is_number()) @@ -493,6 +495,7 @@ bool Settings::save(const std::string& path) for (const auto& a : e.addresses) entry["addresses"].push_back(a); entry["icon"] = e.icon; entry["color"] = e.color; + entry["outline_opacity"] = e.outlineOpacity; entry["price_basis"] = e.priceBasis; entry["manual_price"] = e.manualPrice; entry["manual_currency"] = e.manualCurrency; diff --git a/src/config/settings.h b/src/config/settings.h index e702c53..02534b6 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -80,6 +80,7 @@ public: std::vector addresses; std::string icon; // project_icons wallet-icon name; empty = no icon unsigned int color = 0; // packed IM_COL32 accent; 0 = theme default + int outlineOpacity = 25; // accent-outline opacity, percent (0-100) // Per-group price data. priceBasis: 0 = live market (USD), 1 = live market (BTC), // 2 = DRGX only (no fiat value), 3 = manual price. Defaults preserve prior behavior // (show DRGX + USD value). diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 17df053..b99a09d 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -99,6 +99,7 @@ static char s_pf_label[64] = {0}; static std::vector s_pf_addrs; static std::string s_pf_icon; // selected wallet-icon name ("" = none) static unsigned int s_pf_color = 0; // selected accent (packed IM_COL32; 0 = default) +static int s_pf_outline_opacity = 25; // accent-outline opacity (percent) static char s_pf_search[64] = {0}; // address-list filter text static int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent static bool s_pf_funded_only = false; @@ -164,6 +165,21 @@ static std::vector pfSparklineSeries(const MarketInfo& m, int interval) return pfResampleHistory(m.price_history, interval); } +// Main price-chart interval selector (same semantics as pfSparklineSeries): +// 0=Live (in-session minute buffer) 1=hour 2=day 3=week 4=month. Session-scoped. +static int s_chart_interval = 2; // default: Day (historical) + +// Approx seconds represented by one plotted point at the given chart interval, for X-axis labels. +static float pfChartSecPerPoint(int interval) { + switch (interval) { + case 1: return 3600.0f; // hour + case 2: return 86400.0f; // day + case 3: return 604800.0f; // week + case 4: return 2592000.0f; // month (~30d) + default: return 60.0f; // minute (live buffer) + } +} + // 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, @@ -275,6 +291,7 @@ static void PortfolioBeginEdit(App* app, int index) s_pf_addrs = src.addresses; s_pf_icon = src.icon; s_pf_color = src.color; + s_pf_outline_opacity = src.outlineOpacity; s_pf_price_basis = src.priceBasis; s_pf_manual_price = src.manualPrice; snprintf(s_pf_manual_ccy, sizeof(s_pf_manual_ccy), "%s", src.manualCurrency.c_str()); @@ -288,6 +305,7 @@ static void PortfolioBeginEdit(App* app, int index) s_pf_addrs.clear(); s_pf_icon.clear(); s_pf_color = 0; + s_pf_outline_opacity = 25; s_pf_price_basis = 0; s_pf_manual_price = 0.0; snprintf(s_pf_manual_ccy, sizeof(s_pf_manual_ccy), "USD"); @@ -384,7 +402,10 @@ static void RenderPortfolioEditor(App* app) GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; DrawGlassPanel(pdl, pMin, pMax, g); // Colored outline around the whole preview card (matches the live group cards). - if (accent) pdl->AddRect(pMin, pMax, accent, 10.0f, 0, 2.0f); + if (accent) { + int oa = (int)(std::max(0, std::min(100, s_pf_outline_opacity)) * 2.55f + 0.5f); + pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); + } if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { std::vector h = pfSparklineSeries(state.market, s_pf_spark_interval); if (h.size() >= 2) { @@ -502,6 +523,15 @@ static void RenderPortfolioEditor(App* app) ImGui::EndPopup(); } } + + // Accent-outline opacity (only meaningful when an accent color is set). + ImGui::BeginDisabled(s_pf_color == 0u); + ImGui::TextUnformatted(TR("portfolio_outline_opacity")); + ImGui::SetNextItemWidth(-1); + ImGui::SliderInt("##pfOutlineOpacity", &s_pf_outline_opacity, 0, 100, "%d%%"); + if (s_pf_outline_opacity < 0) s_pf_outline_opacity = 0; + if (s_pf_outline_opacity > 100) s_pf_outline_opacity = 100; + ImGui::EndDisabled(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Price data: how this group's value is priced + which fields the card shows. @@ -752,6 +782,7 @@ static void RenderPortfolioEditor(App* app) e.addresses = s_pf_addrs; e.icon = s_pf_icon; e.color = s_pf_color; + e.outlineOpacity = s_pf_outline_opacity; e.priceBasis = s_pf_price_basis; e.manualPrice = s_pf_manual_price; e.manualCurrency = s_pf_manual_ccy; @@ -792,6 +823,9 @@ void RenderMarketTab(App* app) // Kick a once-per-session live exchange-list fetch (self-guarded), then source the // registry from it (falling back to the compiled-in list until it arrives). app->refreshExchanges(); + // Also kick the historical price-chart fetch (self-throttled) so the chart's hour/day/week/ + // month intervals populate promptly when the user opens the Market tab. + app->refreshMarketChart(); const auto& registry = EffectiveRegistry(market); // Load persisted exchange/pair on first frame @@ -836,11 +870,11 @@ void RenderMarketTab(App* app) // -- Hero header: size to the ACTUAL content (price row + separator gap + stats row) so the // chart starts right after "24H VOLUME"/"Market Cap" instead of below a reserved empty band. -- ImFont* mktH3 = Type().h3(); - float heroHeaderH = Layout::spacingLg() // top pad - + mktH3->LegacySize + Layout::spacingMd() // price row -> separator - + Layout::spacingSm() // separator -> stats - + ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize // stats label + value - + Layout::spacingSm(); // bottom pad before the chart (tight) + // Header now holds only the price row + separator; 24H volume + market cap moved onto the + // chart's top strip (with the interval buttons) so they no longer create an empty band. + float heroHeaderH = Layout::spacingLg() // top pad + + mktH3->LegacySize + Layout::spacingMd() // price row -> separator + + Layout::spacingSm(); // gap below separator, before the chart // -- 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). -- @@ -944,41 +978,13 @@ void RenderMarketTab(App* app) dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp); dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf); - // ---- SEPARATOR ---- + // ---- SEPARATOR (below the price row; 24H volume + market cap now overlay the chart) ---- float sepY = cy + h3->LegacySize + Layout::spacingMd(); float rnd = glassSpec.rounding; dl->AddLine(ImVec2(cardMin.x + rnd * 0.5f, sepY), ImVec2(cardMax.x - rnd * 0.5f, sepY), WithAlpha(OnSurface(), 15), 1.0f * dp); - // ---- STATS ROW (24h Volume | Market Cap) ---- - // (BTC-price column + the "updated Ns ago" staleness line were dropped to - // simplify; freshness is still shown by the attribution "Updated" line.) - float statsY = sepY + Layout::spacingSm(); - float colW = (availWidth - Layout::spacingLg() * 2) / 2.0f; - - struct StatItem { const char* label; std::string value; ImU32 valueCol; }; - StatItem stats[2] = { - {TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()}, - {TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()}, - }; - - for (int i = 0; i < 2; i++) { - float sx = cardMin.x + Layout::spacingLg() + i * colW; - float centerX = sx + colW * 0.5f; - - // Label (overline, centered) - ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, stats[i].label); - dl->AddText(ovFont, ovFont->LegacySize, - ImVec2(centerX - lblSz.x * 0.5f, statsY), OnSurfaceMedium(), stats[i].label); - - // Value (subtitle1, centered) - float valY = statsY + ovFont->LegacySize + Layout::spacingXs(); - ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[i].value.c_str()); - dl->AddText(sub1, sub1->LegacySize, - ImVec2(centerX - valSz.x * 0.5f, valY), stats[i].valueCol, stats[i].value.c_str()); - } - // ---- TRADE BUTTON (top-right of card) ---- if (!currentExchange.pairs.empty()) { const char* pairName = currentExchange.pairs[s_pair_idx].displayName.c_str(); @@ -1053,30 +1059,100 @@ void RenderMarketTab(App* app) // PRICE CHART — drawn inside the combined hero card's glass panel (above) // ================================================================ { - // Plot the REAL accumulated price history (one point per refresh, oldest→newest). - // No synthetic data: until at least two real samples exist, the empty-state below - // ("not enough history yet") is shown rather than inventing a price curve. - s_price_history = market.price_history; + // 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); s_history_initialized = true; - // Chart height precomputed above (responsive — the chart absorbs the vertical slack - // left by the hero header + portfolio card). No glass panel here — it shares the - // combined hero+chart card's panel drawn in PRICE SUMMARY above. + // The chart shares the combined hero+chart glass panel drawn in PRICE SUMMARY above. ImVec2 chartMin = ImGui::GetCursorScreenPos(); ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH); - if (!s_price_history.empty() && s_price_history.size() >= 2) { - float chartPad = pad; - float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size, S.drawElement("tabs.market", "chart-y-axis-padding").size * hs); - float labelPadBottom = Layout::spacingXl(); + float chartPad = pad; + float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f; + float stripH = pillH + Layout::spacingSm(); // top control/info strip height - float plotLeft = chartMin.x + labelPadLeft; - float plotRight = chartMax.x - chartPad; - float plotTop = chartMin.y + chartPad; - float plotBottom = chartMax.y - labelPadBottom; - float plotW = plotRight - plotLeft; - float plotH = plotBottom - plotTop; + // ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ---- + { + float rowTop = chartMin.y + chartPad; + float textY = rowTop + (pillH - capFont->LegacySize) * 0.5f; + // Interval buttons (left). Live = in-session buffer; the rest are historical. + const struct { const char* lbl; int iv; } kIvs[] = { + { TR("market_iv_live"), 0 }, { "1H", 1 }, { "1D", 2 }, { "1W", 3 }, { "1M", 4 } + }; + float bx = chartMin.x + chartPad; + for (int b = 0; b < 5; b++) { + float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, kIvs[b].lbl).x; + float bw = tw + Layout::spacingSm() * 2.0f; + ImVec2 bmn(bx, rowTop), bmx(bx + bw, rowTop + pillH); + bool sel = (s_chart_interval == kIvs[b].iv); + bool bhov = material::IsRectHovered(bmn, bmx); + ImU32 bg = sel ? WithAlpha(Primary(), 200) + : (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18)); + dl->AddRectFilled(bmn, bmx, bg, 4.0f); + dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY), + sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl); + ImGui::SetCursorScreenPos(bmn); + ImGui::PushID(9100 + b); + if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) { + s_chart_interval = kIvs[b].iv; + s_history_initialized = false; + } + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::PopID(); + bx += bw + Layout::spacingXs(); + } + + // Refresh button (far right). + float rEdge = chartMax.x - chartPad; + ImFont* iconSmall = material::Typography::instance().iconSmall(); + ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH); + bool refreshHov = material::IsRectHovered(rbMin, rbMax); + if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f); + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } + ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH); + dl->AddText(iconSmall, iconSmall->LegacySize, + ImVec2(rbMin.x + (pillH - icSz.x) * 0.5f, rbMin.y + (pillH - icSz.y) * 0.5f), + refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH); + ImGui::SetCursorScreenPos(rbMin); + if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(pillH, pillH))) { + app->refreshMarketData(); + s_history_initialized = false; + s_last_refresh_time = ImGui::GetTime(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price")); + + // 24H volume + market cap, right-aligned to the left of the refresh button. Skipped + // when there's no room (narrow window) so they never overlap the interval buttons. + float sxr = rbMin.x - Layout::spacingMd(); + auto drawStat = [&](const char* label, const std::string& val) { + char sb[64]; snprintf(sb, sizeof(sb), "%s %s", label, val.c_str()); + float w = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, sb).x; + if (sxr - w < bx + Layout::spacingMd()) return; + sxr -= w; + dl->AddText(capFont, capFont->LegacySize, ImVec2(sxr, textY), OnSurfaceMedium(), sb); + sxr -= Layout::spacingMd(); + }; + if (market.price_usd > 0) { + drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap)); + drawStat(TR("market_vol_short"), FormatCompactUSD(market.volume_24h)); + } + } + + float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size, + S.drawElement("tabs.market", "chart-y-axis-padding").size * hs); + // Extra bottom room so the time labels aren't crowded against the card's bottom edge. + float labelPadBottom = Layout::spacingXl() + Layout::spacingSm(); + float plotLeft = chartMin.x + labelPadLeft; + float plotRight = chartMax.x - chartPad; + float plotTop = chartMin.y + chartPad + stripH; + float plotBottom = chartMax.y - labelPadBottom; + float plotW = plotRight - plotLeft; + float plotH = plotBottom - plotTop; + + if (s_price_history.size() >= 2) { // Compute Y range with padding double yMin = *std::min_element(s_price_history.begin(), s_price_history.end()); double yMax = *std::max_element(s_price_history.begin(), s_price_history.end()); @@ -1138,28 +1214,30 @@ void RenderMarketTab(App* app) dl->AddCircleFilled(points[i], dotR, dotCol); } - // 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 "~". + // X-axis time labels. Each point spans one interval unit (minute/hour/day/week/month); + // convert points-before-now to a "~