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