Skip to content

Chart Properties NPS Graph#251

Open
Psycast wants to merge 3 commits intobetafrom
chart-nps-graph
Open

Chart Properties NPS Graph#251
Psycast wants to merge 3 commits intobetafrom
chart-nps-graph

Conversation

@Psycast
Copy link
Collaborator

@Psycast Psycast commented Jan 17, 2026

image

_

Add a NPS Graph into Chart Properties.
Each bar represents one second, scale by every 5 minutes to keep the overall density low.
Also shows the current song progress as a visual aid.

Fix VCM_END_ROW_CHANGED not usable in Dialog Menus.
notifyChanges calls dialogs first, before Simfile can add a change for VCM_END_ROW_CHANGED, which gets cleared at the end of the function. This causes dialogs to miss the changes due to order of operations.

Only gSimfile->onChanges currently triggers an additional change flag in the processing order, but it might be worth revising to capture new changes during notifyChanges and add it to the next call.

Psycast and others added 2 commits January 17, 2026 05:16
- Add NPS Graph
- Fix VCM_END_ROW_CHANGED not usable in Dialog Menus.
- Fix typedef class macro.
@uvcat7 uvcat7 self-requested a review January 27, 2026 02:35
Comment on lines +266 to +349
class DialogChartProperties::GraphWidget : public GuiWidget {
public:
~GraphWidget() override;
explicit GraphWidget(GuiContext* gui);

void updateGraph();

void onDraw() override;

private:
DialogChartProperties* myDialog;
std::vector<int> data;
double peak = 0.0;
int scale = 1;
double endTime = 0.0;
};

DialogChartProperties::GraphWidget::~GraphWidget() = default;

DialogChartProperties::GraphWidget::GraphWidget(GuiContext* gui)
: GuiWidget(gui) {
width_ = 340;
height_ = 100;
}

void DialogChartProperties::GraphWidget::updateGraph() {
if (gNotes->empty()) {
return;
}

endTime = gTempo->rowToTime(gSimfile->getEndRow());

int buckets = max(0, static_cast<int>(endTime)) + 1;
scale = max(1, static_cast<int>(ceil(buckets / 300)));
if (scale > 1) buckets = static_cast<int>(buckets / scale) + 1;

peak = 0;
data.resize(buckets);
for (int i = 0; i < buckets; i++) data[i] = 0;

for (auto& note : *gNotes) {
if (note.isMine || note.isWarped || note.isFake) continue;
int bucket = static_cast<int>((note.time + 0.5) / scale);
data[bucket]++;
}

for (int i = 0; i < buckets; i++) {
peak = max(peak, static_cast<double>(data[i]));
}
}

void DialogChartProperties::GraphWidget::onDraw() {
if (gNotes->empty()) {
Draw::fill(rect_, Color32(20, 20, 20, 255));
return;
}

int count = data.size();
double barWidth = (static_cast<double>(width_) / count);
int w = barWidth + 1;

auto batch = Renderer::batchC();
Draw::fill(rect_, Color32(20, 20, 20, 255));

for (int i = 0; i < count; ++i) {
int h = data[i] / peak * height_;
int x = rect_.x + static_cast<int>(i * barWidth);
int y = rect_.y + height_ - h;

Draw::fill(&batch, {x, y, w, h}, Color32(80, 80, 80, 255));
}

double time = min(endTime, gView->getCursorTime());
int x = (time / endTime) * width_;
Draw::fill(&batch, {rect_.x + x, rect_.y, 1, height_},
Color32(160, 160, 160, 255));

batch.flush();

TextStyle textStyle;
std::string info = Str::fmt("Peak: %1 NPS").arg(peak / scale, 0, 0).str;
Text::arrange(Text::TL, textStyle, info.c_str());
Text::draw(vec2i{rect_.x + 4, rect_.y + 2});
}
Copy link
Owner

@uvcat7 uvcat7 Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were several issues with a time-based approach (random jots in the graph on consistent stream density, averaging applied to an entire bucket which makes marathon breakdowns incomprehensible) so I tried rewriting it with measure-based calculations, then converting everything back to time.

This keeps SM-style behavior. I'm not sure if sampling every scale measures is wholly congruent with SM, but the NPS graphs appear to be very similar. That could use some more testing--it might be best to pick the highest NPS within a bucket instead. But this should get 95%+ of cases accurate.

Suggested change
class DialogChartProperties::GraphWidget : public GuiWidget {
public:
~GraphWidget() override;
explicit GraphWidget(GuiContext* gui);
void updateGraph();
void onDraw() override;
private:
DialogChartProperties* myDialog;
std::vector<int> data;
double peak = 0.0;
int scale = 1;
double endTime = 0.0;
};
DialogChartProperties::GraphWidget::~GraphWidget() = default;
DialogChartProperties::GraphWidget::GraphWidget(GuiContext* gui)
: GuiWidget(gui) {
width_ = 340;
height_ = 100;
}
void DialogChartProperties::GraphWidget::updateGraph() {
if (gNotes->empty()) {
return;
}
endTime = gTempo->rowToTime(gSimfile->getEndRow());
int buckets = max(0, static_cast<int>(endTime)) + 1;
scale = max(1, static_cast<int>(ceil(buckets / 300)));
if (scale > 1) buckets = static_cast<int>(buckets / scale) + 1;
peak = 0;
data.resize(buckets);
for (int i = 0; i < buckets; i++) data[i] = 0;
for (auto& note : *gNotes) {
if (note.isMine || note.isWarped || note.isFake) continue;
int bucket = static_cast<int>((note.time + 0.5) / scale);
data[bucket]++;
}
for (int i = 0; i < buckets; i++) {
peak = max(peak, static_cast<double>(data[i]));
}
}
void DialogChartProperties::GraphWidget::onDraw() {
if (gNotes->empty()) {
Draw::fill(rect_, Color32(20, 20, 20, 255));
return;
}
int count = data.size();
double barWidth = (static_cast<double>(width_) / count);
int w = barWidth + 1;
auto batch = Renderer::batchC();
Draw::fill(rect_, Color32(20, 20, 20, 255));
for (int i = 0; i < count; ++i) {
int h = data[i] / peak * height_;
int x = rect_.x + static_cast<int>(i * barWidth);
int y = rect_.y + height_ - h;
Draw::fill(&batch, {x, y, w, h}, Color32(80, 80, 80, 255));
}
double time = min(endTime, gView->getCursorTime());
int x = (time / endTime) * width_;
Draw::fill(&batch, {rect_.x + x, rect_.y, 1, height_},
Color32(160, 160, 160, 255));
batch.flush();
TextStyle textStyle;
std::string info = Str::fmt("Peak: %1 NPS").arg(peak / scale, 0, 0).str;
Text::arrange(Text::TL, textStyle, info.c_str());
Text::draw(vec2i{rect_.x + 4, rect_.y + 2});
}
class DialogChartProperties::GraphWidget : public GuiWidget {
public:
~GraphWidget() override;
explicit GraphWidget(GuiContext* gui);
void updateGraph();
void onDraw() override;
private:
DialogChartProperties* myDialog;
std::vector<int> data;
double peak = 0.0;
int scale = 1;
int endMeasure = 0;
double endTime = 0.0;
};
DialogChartProperties::GraphWidget::~GraphWidget() = default;
DialogChartProperties::GraphWidget::GraphWidget(GuiContext* gui)
: GuiWidget(gui) {
width_ = 340;
height_ = 100;
}
void DialogChartProperties::GraphWidget::updateGraph() {
if (gNotes->empty()) {
return;
}
endMeasure = (gSimfile->getEndRow() - 1) / (ROWS_PER_BEAT * 4) + 1;
endTime = gTempo->rowToTime(gSimfile->getEndRow());
scale = endMeasure / width_;
int buckets = endMeasure;
// Just calculate everything for charts with <680 measures.
if (scale > 2)
buckets = buckets / scale + 1;
else
scale = 1;
peak = 0;
data.resize(buckets);
for (int i = 0; i < buckets; i++) data[i] = 0;
for (auto& note : *gNotes) {
int measure = note.row / (ROWS_PER_BEAT * 4);
if (note.isMine || note.isWarped || note.isFake ||
(scale > 1 && measure % scale != 0))
continue;
data[measure / scale]++;
}
// The peak calculation isn't always accurate if there is scaling,
// but it should be good enough.
for (int i = 0; i < buckets; i++) {
peak =
max(peak, static_cast<double>(
data[i] / (gTempo->rowToTime((i * scale + 1) * 192) -
gTempo->rowToTime(i * scale * 192))));
}
}
void DialogChartProperties::GraphWidget::onDraw() {
if (gNotes->empty()) {
Draw::fill(rect_, Color32(20, 20, 20, 255));
return;
}
int count = data.size();
double barWidth = (static_cast<double>(width_) / count);
int w = barWidth + 1;
auto batch = Renderer::batchC();
Draw::fill(rect_, Color32(20, 20, 20, 255));
for (int i = 0; i < count; ++i) {
int h =
static_cast<int>(round(data[i] /
(gTempo->rowToTime((i * scale + 1) * 192) -
gTempo->rowToTime(i * scale * 192)) /
peak * height_));
int x = rect_.x + static_cast<int>(gTempo->rowToTime(i * scale * 192) /
endTime * width_);
int y = rect_.y + height_ - h;
Draw::fill(&batch, {x, y, w, h}, Color32(80, 80, 80, 255));
}
double time = min(endTime, gView->getCursorTime());
int x = (time / endTime) * width_;
Draw::fill(&batch, {rect_.x + x, rect_.y, 1, height_},
Color32(160, 160, 160, 255));
batch.flush();
TextStyle textStyle;
std::string info = Str::fmt("Peak: %1 NPS").arg(peak, 1, 1).str;
Text::arrange(Text::TL, textStyle, info.c_str());
Text::draw(vec2i{rect_.x + 4, rect_.y + 2});
}
}

Copy link
Owner

@uvcat7 uvcat7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggested approach to fix the NPS sampling is not necessarily the best solution--would appreciate you giving that a look-over.

Also, the Peak NPS text can cover up the graph, so it should be moved outside of the graph.

@Psycast
Copy link
Collaborator Author

Psycast commented Jan 27, 2026

I wasn't really sure how best to approach it either, but at the time the goal was to get a overview of the NPS to show noticeable spikes and other outliers.

The scaling is also not ideal, but without the it, it can cause render issues with a bar being smaller then 1px on much longer files.

The text for peak was more of an after thought, as peak value was initially just used for scaling the bar heights themselves, I'll see if I can move it above or below and make it not feel out of place.

@uvcat7
Copy link
Owner

uvcat7 commented Jan 27, 2026

The scaling is also not ideal, but without the it, it can cause render issues with a bar being smaller then 1px on much longer files.

Oh, I thought it was for performance reasons. In that case we could just sample everything but only render the highest NPS in the number of measures in each pixel, which should handle every possible case.

I do like having each measure always be one "bar" on the graph, since for shorter files it makes the measure density more intuitive. Not sure if you like it though.

I'll check the ITGMania solution out this week sometime since it might be informative.

@Psycast
Copy link
Collaborator Author

Psycast commented Jan 27, 2026

Ya, it also helped cut down excessive drawn rectangles on much longer marathon files.
Ultimately if it was rendered like the minimap using just a texture, then it wouldn't need to be a concern, but that was a little beyond my ability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants