feat(market): square grid cells (2x1 min), preserve grid on edit, sparkline interval

- Grid cells are now square (cellH = cellW); column count is responsive with a
  2-column minimum, and a group's minimum size is 2x1 cells. New groups default
  to 2x1; drag/resize/auto-place all enforce the 2-wide minimum.
- Fix: editing a group no longer resets its grid placement/size. The editor's
  Save now starts from the existing entry and only overwrites the form fields,
  so grid_col/row/w/h (and anything else off-form) are preserved.
- Add a per-group sparkline interval (Minute/Hour/Day/Week/Month) next to the
  Sparkline toggle. The price history (~1 sample/min) is resampled by averaging
  each interval's worth of minute-samples into one point.

PortfolioEntry gains sparklineInterval and defaults gridW=2. Persisted in
settings.json. Full-node + Lite build clean; ctest 1/1; hygiene clean.

Note: the app only accumulates in-session minute samples, so Hour/Day/Month
show points only once enough data has been collected; true long-range history
(instant day/month charts) would need a CoinGecko market_chart fetch, which can
be added separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 04:15:38 -05:00
parent 77219c5ce7
commit 76ed09fad0
4 changed files with 66 additions and 25 deletions

View File

@@ -111,6 +111,23 @@ static bool s_pf_show_drgx = true;
static bool s_pf_show_value = true;
static bool s_pf_show_24h = false;
static bool s_pf_show_sparkline = false;
static int s_pf_spark_interval = 0; // 0=min 1=hour 2=day 3=week 4=month
// price_history is ~1 sample/minute; resample it to the group's chosen interval by averaging
// each block of K minute-samples into one point.
static std::vector<double> pfResampleHistory(const std::vector<double>& hist, int interval)
{
static const int kMins[5] = {1, 60, 1440, 10080, 43200};
int k = kMins[(interval >= 0 && interval < 5) ? interval : 0];
if (k <= 1) return hist;
std::vector<double> out;
for (size_t i = 0; i < hist.size(); i += (size_t)k) {
double sum = 0.0; size_t cnt = 0;
for (size_t j = i; j < hist.size() && j < i + (size_t)k; ++j) { sum += hist[j]; ++cnt; }
if (cnt) out.push_back(sum / (double)cnt);
}
return out;
}
// 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.
@@ -208,6 +225,7 @@ static void PortfolioBeginEdit(App* app, int index)
s_pf_show_value = src.showValue;
s_pf_show_24h = src.show24h;
s_pf_show_sparkline = src.showSparkline;
s_pf_spark_interval = src.sparklineInterval;
} else {
s_pf_label[0] = '\0';
s_pf_addrs.clear();
@@ -220,6 +238,7 @@ static void PortfolioBeginEdit(App* app, int index)
s_pf_show_value = true;
s_pf_show_24h = false;
s_pf_show_sparkline = false;
s_pf_spark_interval = 0;
}
s_pf_search[0] = '\0';
s_pf_type_filter = 0;
@@ -312,13 +331,14 @@ static void RenderPortfolioEditor(App* app)
pdl->AddRectFilled(ImVec2(bx, pMin.y + ppad * 0.6f),
ImVec2(bx + 3.0f, pMax.y - ppad * 0.6f), accent, 2.0f);
}
if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1) &&
state.market.price_history.size() >= 2) {
const auto& h = state.market.price_history;
ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
ImVec2 spMin(pMin.x + ppad + (accent ? 6.0f : 0.0f), pMin.y + ph * 0.46f);
ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f);
pfDrawSparkline(pdl, spMin, spMax, h, spCol);
if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) {
std::vector<double> h = pfResampleHistory(state.market.price_history, s_pf_spark_interval);
if (h.size() >= 2) {
ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
ImVec2 spMin(pMin.x + ppad + (accent ? 6.0f : 0.0f), pMin.y + ph * 0.46f);
ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f);
pfDrawSparkline(pdl, spMin, spMax, h, spCol);
}
}
float rowY = pMin.y + ppad, nameX = pMin.x + ppad + (accent ? 6.0f : 0.0f);
if (!s_pf_icon.empty()) {
@@ -458,9 +478,17 @@ static void RenderPortfolioEditor(App* app)
ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); // 24h: live-market only
ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h);
ImGui::EndDisabled();
// Sparkline on its own line (also live-market only).
// Sparkline on its own line (also live-market only) + its interval.
ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1));
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline);
ImGui::SameLine();
ImGui::BeginDisabled(!s_pf_show_sparkline);
const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"),
TR("portfolio_spark_day"), TR("portfolio_spark_week"),
TR("portfolio_spark_month") };
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::Combo("##pfSparkInterval", &s_pf_spark_interval, spItems, 5);
ImGui::EndDisabled();
ImGui::EndDisabled();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -662,7 +690,10 @@ static void RenderPortfolioEditor(App* app)
ImGui::BeginDisabled(!canSave);
if (ImGui::Button(TR("portfolio_save"))) {
auto entries = settings->getPortfolioEntries();
// Start from the existing entry when editing so its grid placement/size (and any
// other fields not on this form) are preserved — editing must not reset the card.
config::Settings::PortfolioEntry e;
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) e = entries[s_pf_editing];
e.label = s_pf_label;
e.addresses = s_pf_addrs;
e.icon = s_pf_icon;
@@ -674,6 +705,7 @@ static void RenderPortfolioEditor(App* app)
e.showValue = s_pf_show_value;
e.show24h = s_pf_show_24h;
e.showSparkline = s_pf_show_sparkline;
e.sparklineInterval = s_pf_spark_interval;
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
else entries.push_back(e);
persist(entries);
@@ -767,12 +799,11 @@ void RenderMarketTab(App* app)
int pfN = (int)pfEntriesGeo.size();
float pfCardGap = Layout::spacingSm();
float pfCardPad = Layout::spacingMd(); // inner padding of each group card
// Dashboard grid: responsive column count; a 1x1 cell is one card. Cards can be dragged to a
// new cell and corner-resized to span multiple cells.
int pfCols = std::max(1, std::min(4, (int)(availWidth / (240.0f * mktDp) + 0.5f)));
// Dashboard grid of SQUARE cells. Column count is responsive; min 2 columns so a card can meet
// its 2x1 minimum size. Cards drag to a new cell and corner-resize to span multiple cells.
int pfCols = std::max(2, std::min(8, (int)(availWidth / (110.0f * mktDp) + 0.5f)));
float pfCellW = (availWidth - (pfCols - 1) * pfCardGap) / (float)pfCols;
// A 1x1 cell: name row (sub1) + a fiat row (caption) + padding; the amounts sit top-right.
float pfCellH = pfCardPad * 2.0f + sub1->LegacySize + Layout::spacingXs() + capFont->LegacySize;
float pfCellH = pfCellW; // square cells; the smallest card is 2x1
// Resolve each group's grid cell: honor a stored placement when it fits, else auto-place
// row-major into the first free cells. Deterministic each frame.
std::vector<PfCell> pfLayout((size_t)std::max(0, pfN));
@@ -790,7 +821,7 @@ void RenderMarketTab(App* app)
std::vector<char> placed((size_t)pfN, 0);
for (int i = 0; i < pfN; i++) {
const auto& e = pfEntriesGeo[i];
int w = std::max(1, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
int w = std::max(2, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
if (e.gridCol >= 0 && e.gridRow >= 0 && fits(e.gridCol, e.gridRow, w, h)) {
pfLayout[i] = {e.gridCol, e.gridRow, w, h}; mark(e.gridCol, e.gridRow, w, h); placed[i] = 1;
}
@@ -798,7 +829,7 @@ void RenderMarketTab(App* app)
for (int i = 0; i < pfN; i++) {
if (placed[i]) continue;
const auto& e = pfEntriesGeo[i];
int w = std::max(1, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
int w = std::max(2, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
int fc = 0, fr = 0; bool found = false;
for (int r = 0; !found && r < 4096; r++)
for (int c = 0; c + w <= pfCols; c++)
@@ -1378,12 +1409,13 @@ void RenderMarketTab(App* app)
dl->AddRectFilled(ImVec2(bx, gcMin.y + pfCardPad * 0.6f),
ImVec2(bx + 3.0f, gcMax.y - pfCardPad * 0.6f), accent, 2.0f);
}
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1) &&
market.price_history.size() >= 2) {
const auto& hh = market.price_history;
ImU32 spCol = WithAlpha(hh.back() >= hh.front() ? Success() : Error(), 80);
pfDrawSparkline(dl, ImVec2(gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f), gcMin.y + ch * 0.46f),
ImVec2(gcMax.x - pfCardPad, gcMax.y - pfCardPad * 0.6f), hh, spCol);
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) {
std::vector<double> hh = pfResampleHistory(market.price_history, e.sparklineInterval);
if (hh.size() >= 2) {
ImU32 spCol = WithAlpha(hh.back() >= hh.front() ? Success() : Error(), 80);
pfDrawSparkline(dl, ImVec2(gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f), gcMin.y + ch * 0.46f),
ImVec2(gcMax.x - pfCardPad, gcMax.y - pfCardPad * 0.6f), hh, spCol);
}
}
float rowY = gcMin.y + pfCardPad, gcRight = gcMax.x - pfCardPad;
PfDisplay disp = pfBuildDisplay(e, bal, market);
@@ -1484,7 +1516,7 @@ void RenderMarketTab(App* app)
if (s_pf_resize >= 0 && s_pf_resize < pfN) {
int i = s_pf_resize;
ImVec2 mn = cellMin(pfLayout[i]);
int nw = std::max(1, std::min(pfCols - pfLayout[i].col,
int nw = std::max(2, std::min(pfCols - pfLayout[i].col,
(int)((io.MousePos.x - mn.x + pfCardGap) / (pfCellW + pfCardGap) + 0.5f)));
int nh = std::max(1, (int)((io.MousePos.y - mn.y + pfCardGap) / (pfCellH + pfCardGap) + 0.5f));
ImVec2 sz = cellSize(nw, nh), mx(mn.x + sz.x, mn.y + sz.y);