refactor(market): extract price chart into mktDrawPriceChart

Move the ~250-line chart block (interval strip + 24h stats + refresh, plotted
curve with grid/area-fill/hi-lo+time labels/hover crosshair+tooltip, empty
state) out of RenderMarketTab into mktDrawPriceChart(MktCtx), recomputing cheap
locals and reading the precomputed series via the ctx. Drop the now-unused
hs/pad locals. Verbatim body. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 00:57:59 -05:00
parent a092097141
commit 71fb81670f

View File

@@ -1542,6 +1542,275 @@ static void mktDrawPriceHero(const MktCtx& cx)
ImGui::Dummy(ImVec2(availWidth, 0));
}
// Price chart, drawn inside the hero's shared glass panel: interval strip + 24h stats + refresh,
// then the plotted curve (grid, area fill, hi/lo + time labels, hover crosshair/tooltip) or an
// empty state. Reads the precomputed series (s_mkt.history / cx.chartTimes).
static void mktDrawPriceChart(const MktCtx& cx)
{
App* app = cx.app;
const auto& market = app->getWalletState().market;
auto& S = schema::UI();
ImDrawList* dl = ImGui::GetWindowDrawList();
float availWidth = ImGui::GetContentRegionAvail().x;
float pad = Layout::cardInnerPadding();
float hs = Layout::hScale(availWidth);
float mktDp = Layout::dpiScale();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
float chartH = cx.chartH;
const auto& chartTimes = *cx.chartTimes;
std::time_t nowSec = cx.nowSec;
bool chartUp = cx.chartUp;
char buf[128];
// The chart series (s_mkt.history) + timestamps (chartTimes) were computed in the
// precompute above. Historical intervals come from CoinGecko market_chart; "Live" is the
// in-session buffer. Until two points exist, the empty-state below is shown.
// 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);
float chartPad = pad;
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
// Buttons sit near the top of the chart area, with a gap below them before the plot.
float stripTop = chartMin.y + Layout::spacingXs();
// ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ----
{
float rowTop = stripTop;
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_mkt.chartInterval == 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_mkt.chartInterval = kIvs[b].iv;
}
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();
}
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 = stripTop + pillH + Layout::spacingMd(); // gap below the buttons before the plot
float plotBottom = chartMax.y - labelPadBottom;
float plotW = plotRight - plotLeft;
float plotH = plotBottom - plotTop;
if (s_mkt.history.size() >= 2) {
// Compute Y range with padding
double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
if (yMax <= yMin) { yMax = yMin + 1e-8; }
double yRange = yMax - yMin;
double yPadding = yRange * 0.12;
yMin -= yPadding;
yMax += yPadding;
// Horizontal grid lines (4 lines)
for (int g = 0; g <= 4; g++) {
float gy = plotTop + plotH * (float)g / 4.0f;
dl->AddLine(ImVec2(plotLeft, gy), ImVec2(plotRight, gy),
IM_COL32(255, 255, 255, 12), 1.0f);
double labelVal = yMax - (yMax - yMin) * (double)g / 4.0;
// Adaptive precision (via the shared price formatter) — cleaner than a fixed 6 decimals.
snprintf(buf, sizeof(buf), "%s", FormatPrice(labelVal).c_str());
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
// Keep the axis label inside the card even on narrow windows (min-padding may be
// smaller than the label width) so it never spills onto the tab background.
float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lblX, gy - labelSz.y * 0.5f),
OnSurfaceDisabled(), buf);
}
// Build points
size_t n = s_mkt.history.size();
std::vector<ImVec2> points(n);
// Colored by the DISPLAYED period's direction (chartUp), not just 24h.
ImU32 dirCol = chartUp ? Success() : Error();
ImU32 lineCol = WithAlpha(dirCol, 220);
ImU32 dotCol = dirCol;
for (size_t i = 0; i < n; i++) {
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
float x = plotLeft + t * plotW;
float y = plotBottom - (float)((s_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
points[i] = ImVec2(x, y);
}
// 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 (no per-point dots — a clean curve).
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
// High/low price labels at the displayed range's extremes (no dot markers).
if (n >= 3) {
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
auto markExtreme = [&](size_t idx, bool high) {
ImVec2 p = points[idx];
std::string lbl = FormatPrice(s_mkt.history[idx]);
ImVec2 ls = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lbl.c_str());
float ly = high ? p.y - ls.y - 5.0f * mktDp : p.y + 5.0f * mktDp;
float lx = std::min(std::max(plotLeft, p.x - ls.x * 0.5f), plotRight - ls.x);
dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, ly), OnSurfaceMedium(), lbl.c_str());
};
markExtreme(hiIdx, true);
markExtreme(loIdx, false);
}
// X-axis time labels. The series is timestamped, so label each tick with the real
// "<time> ago" (rightmost = "now") derived from the sample's timestamp.
{
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);
float secAgo = (idx < (int)chartTimes.size()) ? (float)(nowSec - chartTimes[idx])
: (float)(nPts - 1 - idx) * 60.0f;
char tlbl[32];
if (secAgo < 45.0f) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (secAgo < 5400.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fm", secAgo / 60.0f);
else if (secAgo < 172800.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fh", secAgo / 3600.0f);
else if (secAgo < 1209600.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fd", secAgo / 86400.0f);
else if (secAgo < 7776000.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fw", secAgo / 604800.0f);
else snprintf(tlbl, sizeof(tlbl), "~%.0fmo", secAgo / 2592000.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 + 4), OnSurfaceDisabled(), tlbl);
}
}
// Hover crosshair + tooltip
ImVec2 mousePos = ImGui::GetIO().MousePos;
if (mousePos.x >= plotLeft && mousePos.x <= plotRight &&
mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
{
float mx = mousePos.x - plotLeft;
float closest_t = mx / plotW;
int idx = (int)(closest_t * (n - 1) + 0.5f);
if (idx < 0) idx = 0;
if (idx >= (int)n) idx = (int)n - 1;
float px = points[idx].x;
float py = points[idx].y;
dl->AddLine(ImVec2(px, plotTop), ImVec2(px, plotBottom),
IM_COL32(255, 255, 255, 40), 1.0f);
dl->AddLine(ImVec2(plotLeft, py), ImVec2(plotRight, py),
IM_COL32(255, 255, 255, 40), 1.0f);
float hoverDotR = std::max(S.drawElement("tabs.market", "chart-hover-dot-min-radius").size, S.drawElement("tabs.market", "chart-hover-dot-radius").size * hs);
float hoverRingR = std::max(S.drawElement("tabs.market", "chart-hover-ring-min-radius").size, S.drawElement("tabs.market", "chart-hover-ring-radius").size * hs);
dl->AddCircleFilled(ImVec2(px, py), hoverDotR, dotCol);
dl->AddCircle(ImVec2(px, py), hoverRingR, IM_COL32(255, 255, 255, 80), 0, 1.5f);
// Tooltip: real date/time (from the series timestamps) + price at the hovered point.
// Sub-day intervals (Live/1H) show the time; day+ intervals show the date.
char whenBuf[32] = "";
if (idx < (int)chartTimes.size()) {
std::time_t tt = chartTimes[idx];
std::tm* tmv = std::localtime(&tt);
if (tmv) std::strftime(whenBuf, sizeof(whenBuf),
s_mkt.chartInterval <= 1 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
}
if (whenBuf[0])
snprintf(buf, sizeof(buf), "%s \xC2\xB7 %s", whenBuf, FormatPrice(s_mkt.history[idx]).c_str());
else
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10;
float tipY = py - tipSz.y - tipPad * 2 - 4;
if (tipX + tipSz.x + tipPad * 2 > plotRight)
tipX = px - tipSz.x - tipPad * 2 - 10;
if (tipY < plotTop) tipY = py + 10;
ImVec2 tipMin(tipX, tipY);
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
} else {
// Empty state, centered in the plot area (the interval strip + refresh are already drawn).
const char* msg = TR("market_no_history");
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, (plotTop + plotBottom) * 0.5f - ts.y * 0.5f),
OnSurfaceDisabled(), msg);
}
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
ImGui::Dummy(ImVec2(availWidth, 0));
}
void RenderMarketTab(App* app)
{
auto& S = schema::UI();
@@ -1583,9 +1852,7 @@ void RenderMarketTab(App* app)
// Responsive: scale factors per frame
float availWidth = ImGui::GetContentRegionAvail().x;
float hs = Layout::hScale(availWidth);
float vs = Layout::vScale(marketAvail.y);
float pad = Layout::cardInnerPadding();
float gap = Layout::cardGap();
ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -1681,254 +1948,7 @@ void RenderMarketTab(App* app)
// ================================================================
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
// ================================================================
{
// The chart series (s_mkt.history) + timestamps (chartTimes) were computed in the
// precompute above. Historical intervals come from CoinGecko market_chart; "Live" is the
// in-session buffer. Until two points exist, the empty-state below is shown.
// 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);
float chartPad = pad;
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
// Buttons sit near the top of the chart area, with a gap below them before the plot.
float stripTop = chartMin.y + Layout::spacingXs();
// ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ----
{
float rowTop = stripTop;
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_mkt.chartInterval == 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_mkt.chartInterval = kIvs[b].iv;
}
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();
}
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 = stripTop + pillH + Layout::spacingMd(); // gap below the buttons before the plot
float plotBottom = chartMax.y - labelPadBottom;
float plotW = plotRight - plotLeft;
float plotH = plotBottom - plotTop;
if (s_mkt.history.size() >= 2) {
// Compute Y range with padding
double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
if (yMax <= yMin) { yMax = yMin + 1e-8; }
double yRange = yMax - yMin;
double yPadding = yRange * 0.12;
yMin -= yPadding;
yMax += yPadding;
// Horizontal grid lines (4 lines)
for (int g = 0; g <= 4; g++) {
float gy = plotTop + plotH * (float)g / 4.0f;
dl->AddLine(ImVec2(plotLeft, gy), ImVec2(plotRight, gy),
IM_COL32(255, 255, 255, 12), 1.0f);
double labelVal = yMax - (yMax - yMin) * (double)g / 4.0;
// Adaptive precision (via the shared price formatter) — cleaner than a fixed 6 decimals.
snprintf(buf, sizeof(buf), "%s", FormatPrice(labelVal).c_str());
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
// Keep the axis label inside the card even on narrow windows (min-padding may be
// smaller than the label width) so it never spills onto the tab background.
float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lblX, gy - labelSz.y * 0.5f),
OnSurfaceDisabled(), buf);
}
// Build points
size_t n = s_mkt.history.size();
std::vector<ImVec2> points(n);
// Colored by the DISPLAYED period's direction (chartUp), not just 24h.
ImU32 dirCol = chartUp ? Success() : Error();
ImU32 lineCol = WithAlpha(dirCol, 220);
ImU32 dotCol = dirCol;
for (size_t i = 0; i < n; i++) {
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
float x = plotLeft + t * plotW;
float y = plotBottom - (float)((s_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
points[i] = ImVec2(x, y);
}
// 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 (no per-point dots — a clean curve).
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
// High/low price labels at the displayed range's extremes (no dot markers).
if (n >= 3) {
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
auto markExtreme = [&](size_t idx, bool high) {
ImVec2 p = points[idx];
std::string lbl = FormatPrice(s_mkt.history[idx]);
ImVec2 ls = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lbl.c_str());
float ly = high ? p.y - ls.y - 5.0f * mktDp : p.y + 5.0f * mktDp;
float lx = std::min(std::max(plotLeft, p.x - ls.x * 0.5f), plotRight - ls.x);
dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, ly), OnSurfaceMedium(), lbl.c_str());
};
markExtreme(hiIdx, true);
markExtreme(loIdx, false);
}
// X-axis time labels. The series is timestamped, so label each tick with the real
// "<time> ago" (rightmost = "now") derived from the sample's timestamp.
{
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);
float secAgo = (idx < (int)chartTimes.size()) ? (float)(nowSec - chartTimes[idx])
: (float)(nPts - 1 - idx) * 60.0f;
char tlbl[32];
if (secAgo < 45.0f) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (secAgo < 5400.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fm", secAgo / 60.0f);
else if (secAgo < 172800.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fh", secAgo / 3600.0f);
else if (secAgo < 1209600.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fd", secAgo / 86400.0f);
else if (secAgo < 7776000.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fw", secAgo / 604800.0f);
else snprintf(tlbl, sizeof(tlbl), "~%.0fmo", secAgo / 2592000.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 + 4), OnSurfaceDisabled(), tlbl);
}
}
// Hover crosshair + tooltip
ImVec2 mousePos = ImGui::GetIO().MousePos;
if (mousePos.x >= plotLeft && mousePos.x <= plotRight &&
mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
{
float mx = mousePos.x - plotLeft;
float closest_t = mx / plotW;
int idx = (int)(closest_t * (n - 1) + 0.5f);
if (idx < 0) idx = 0;
if (idx >= (int)n) idx = (int)n - 1;
float px = points[idx].x;
float py = points[idx].y;
dl->AddLine(ImVec2(px, plotTop), ImVec2(px, plotBottom),
IM_COL32(255, 255, 255, 40), 1.0f);
dl->AddLine(ImVec2(plotLeft, py), ImVec2(plotRight, py),
IM_COL32(255, 255, 255, 40), 1.0f);
float hoverDotR = std::max(S.drawElement("tabs.market", "chart-hover-dot-min-radius").size, S.drawElement("tabs.market", "chart-hover-dot-radius").size * hs);
float hoverRingR = std::max(S.drawElement("tabs.market", "chart-hover-ring-min-radius").size, S.drawElement("tabs.market", "chart-hover-ring-radius").size * hs);
dl->AddCircleFilled(ImVec2(px, py), hoverDotR, dotCol);
dl->AddCircle(ImVec2(px, py), hoverRingR, IM_COL32(255, 255, 255, 80), 0, 1.5f);
// Tooltip: real date/time (from the series timestamps) + price at the hovered point.
// Sub-day intervals (Live/1H) show the time; day+ intervals show the date.
char whenBuf[32] = "";
if (idx < (int)chartTimes.size()) {
std::time_t tt = chartTimes[idx];
std::tm* tmv = std::localtime(&tt);
if (tmv) std::strftime(whenBuf, sizeof(whenBuf),
s_mkt.chartInterval <= 1 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
}
if (whenBuf[0])
snprintf(buf, sizeof(buf), "%s \xC2\xB7 %s", whenBuf, FormatPrice(s_mkt.history[idx]).c_str());
else
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10;
float tipY = py - tipSz.y - tipPad * 2 - 4;
if (tipX + tipSz.x + tipPad * 2 > plotRight)
tipX = px - tipSz.x - tipPad * 2 - 10;
if (tipY < plotTop) tipY = py + 10;
ImVec2 tipMin(tipX, tipY);
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
} else {
// Empty state, centered in the plot area (the interval strip + refresh are already drawn).
const char* msg = TR("market_no_history");
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, (plotTop + plotBottom) * 0.5f - ts.y * 0.5f),
OnSurfaceDisabled(), msg);
}
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
ImGui::Dummy(ImVec2(availWidth, 0));
}
mktDrawPriceChart(mktc);
// ================================================================
// PAIR SELECTOR — every trading pair across all exchanges shown as