Skip to content

Commit

Permalink
Subsplits are formatted better on the splits page (#617)
Browse files Browse the repository at this point in the history
* Initial pass at adding support for subsplits

* Moved duplicated segment duration cards to a partial

* Don't throw exceptions if there is only 1 attempt and it has subsplits.

* Moved subsplit code out of Segment and into Run to greatly reduce the nubmer of SQL queries

* Fixed display_name for SegmentGroup

* Implemented gold? for SegmentGroup

* Don't try to sort an array (it's also already sorted correctly).

* Request subsplits for comparison charts

* If the user has segment groups and the comparison user doesn't, segment_duration charts would throw exceptions. This filters out undefined segment_groups

* Updated the API documentation

* Removed a now superfluous compact

* segment_duration_card is no longer used twice, so put the code back where I got it

* Fixing the specs due to the changed v4 API response

* Removed the Subsplit concern and moved its code into Segment

* Fixed some indentation

* Get rid of the old subsplit vocabulary where it makes no sense anymore and replace it with segment_group

* Added some comments to SegmentGroup.durations_by_attempt to try to clear up what it's doing

* Removed API documentation for now regarding segment_groups until things are more stable

* Fixed a mistake in the chart_builder for segment_groups

* Fixed an issue where the if last subsplit in a group was defined by having no subsplit characters in the name, it wouldn't be detected.

* Added some view specs surrounding segment groups

* Added run to SegmentGroups as it was needed in the _split_table view.

* Use display_name for the new Run Progress chart
  • Loading branch information
moorecp authored and glacials committed Oct 7, 2019
1 parent 9ee414a commit e89d7b2
Show file tree
Hide file tree
Showing 31 changed files with 4,983 additions and 24 deletions.
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.sass
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
@import navigation
@import paywall
@import rivalries
@import runs
@import search
@import spinner
@import tabs
Expand Down
6 changes: 6 additions & 0 deletions app/assets/stylesheets/runs.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.segment-group
font-size: 1.1em
font-weight: bold

i
font-size: 0.91em
2 changes: 2 additions & 0 deletions app/blueprints/api/v4/run_blueprint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class Api::V4::RunBlueprint < Blueprinter::Base
field(:realtime_duration_ms) { |run, _| run.realtime_duration_ms || 0 }
field(:gametime_duration_ms) { |run, _| run.gametime_duration_ms || 0 }

field :segment_groups, if: -> (_, _, options) { options[:segment_groups] }

view :convert do
field :id do |_, _|
nil
Expand Down
3 changes: 2 additions & 1 deletion app/blueprints/api/v4/segment_blueprint.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
class Api::V4::SegmentBlueprint < Blueprinter::Base
fields :id, :segment_number, :name,
:realtime_shortest_duration_ms, :realtime_gold, :realtime_reduced, :realtime_skipped,
:gametime_shortest_duration_ms, :gametime_gold, :gametime_reduced, :gametime_skipped
:gametime_shortest_duration_ms, :gametime_gold, :gametime_reduced, :gametime_skipped,
:display_name

association :histories, blueprint: Api::V4::SegmentHistoryBlueprint, if: ->(_, _, options) { options[:historic] }

Expand Down
9 changes: 4 additions & 5 deletions app/controllers/api/v4/runs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@ class Api::V4::RunsController < Api::V4::ApplicationController
def show
timer = Run.program_from_attribute(:content_type, @accept_header)
if timer.nil?
if params[:historic] == '1'
render json: Api::V4::RunBlueprint.render(@run, root: :run, historic: true)
else
render json: Api::V4::RunBlueprint.render(@run, root: :run)
end
options = { root: :run }
options[:historic] = true if params[:historic] == '1'
options[:segment_groups] = true if params[:segment_groups] == '1'
render json: Api::V4::RunBlueprint.render(@run, options)
else
rendered_run = render_run_to_string(timer)
send_data(
Expand Down
13 changes: 11 additions & 2 deletions app/javascript/chart_builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ document.addEventListener('turbolinks:load', function() {

const runs = []

runs.push(fetch(`/api/v4/runs/${gon.run.id}?historic=1`, {
runs.push(fetch(`/api/v4/runs/${gon.run.id}?historic=1&segment_groups=1`, {
headers: {accept: 'application/json'}
}))

if (gon.compare_runs !== undefined) {
gon.compare_runs.forEach(run => runs.push(fetch(`/api/v4/runs/${run.id}?historic=1`, {
gon.compare_runs.forEach(run => runs.push(fetch(`/api/v4/runs/${run.id}?historic=1&segment_groups=1`, {
headers: {accept: 'application/json'}
})))
}
Expand Down Expand Up @@ -82,6 +82,15 @@ document.addEventListener('turbolinks:load', function() {
)
})

runs[0].segment_groups.forEach((segmentGroup, i) => {
buildSegmentDurationChart(
timing,
runs,
runs.map(run => run.segment_groups[i]),
chartOptions
)
})

Array.from(document.getElementsByClassName('segment-spinner')).forEach(spinner => spinner.hidden = true)
document.getElementById('chart-spinner').hidden = true
}
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/charts/box_plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const buildBoxPlot = (runs, options = {}) => {
title: {text: 'Segment Box Plot'},
xAxis: {
title: {text: 'Segment'},
categories: runs[0].segments.map(segment => segment.name)
categories: runs[0].segments.map(segment => segment.display_name)
},
yAxis: {
gridLineColor: 'rgba(255, 255, 255, 0.2)',
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/charts/reset.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const buildResetChart = function(runs, options = {}) {
const attemptsReachedSegment = attemptsSoFar
attemptsSoFar = segment.histories.length

return [segment.name, attemptsReachedSegment - segment.histories.length]
return [segment.display_name, attemptsReachedSegment - segment.histories.length]
}).filter(datum => datum[1] > 0),
innerSize: '50%',
linkedTo: ':previous',
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/charts/run_progress.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const buildRunProgressChart = function(runs, options = {}) {
series: runs.map((run, i) => (
{
data: run.segments.map((segment) => (
[segment.name, parseFloat((segment.histories.length / run.attempts * 100).toFixed(1)), 20]
[segment.dispaly_name, parseFloat((segment.histories.length / run.attempts * 100).toFixed(1)), 20]
)),
name: (run.runners[0] || {name: '???'}).name,
colorByPoint: runs.length == 1,
Expand Down
8 changes: 4 additions & 4 deletions app/javascript/charts/segment.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const buildSegmentChart = function(runs, options) {
if (time < 0) {
time = 0
}
pbData.push([segment.name, time])
pbData.push([segment.display_name, time])

// Start mean info collection
time = 0
Expand All @@ -40,14 +40,14 @@ const buildSegmentChart = function(runs, options) {

time = ((time / counter) - segment[shortest]) / 1000
}
meanData.push([segment.name, time])
meanData.push([segment.display_name, time])

// Start reset info collection
if (resetCounter - segment.histories.length > 0) {
resetData.push([segment.name, (1 - (segment.histories.length / resetCounter)) * 100])
resetData.push([segment.display_name, (1 - (segment.histories.length / resetCounter)) * 100])
resetCounter = segment.histories.length
} else {
resetData.push([segment.name, 0])
resetData.push([segment.display_name, 0])
}
})

Expand Down
2 changes: 1 addition & 1 deletion app/javascript/charts/segment_duration.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const buildSegmentDurationChart = function(timing, runs, segments, options = {})
marker: {enabled: false}
}
},
series: segments.map((segment, i) => ({
series: segments.filter(Boolean).map((segment, i) => ({
name: `${(runs[i].runners[0] || {name: '???'}).name}'s ${segment.name}`,
data: (segment.filteredHistories || segment.histories).map(attempt => {
return [`Attempt #${attempt.attempt_number}`, attempt[duration]]
Expand Down
33 changes: 33 additions & 0 deletions app/models/run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ def previous_pb(timing)
end
end

def segment_groups
segments_with_groups.select(&:segment_group_parent?).map do |segment|
{
id: segment.id,
name: segment.display_name,
segment_number: segment.segment_number,
histories: segment.segment_group_durations
}
end
end

# Calculate the various statistical information about each segments history once in the database for the whole run
# instead of individually for each segment (N queries)
def segment_history_stats(timing)
Expand Down Expand Up @@ -224,6 +235,28 @@ def possible_timesave(timing)
duration(timing) - sum_of_best(timing)
end

def segments_with_groups
return @segments_with_groups if @segments_with_groups
@segments_with_groups = []
segment_group_start_index = nil
segment_group_end_index = nil
segment_array = segments.includes(:histories).order(segment_number: :asc).to_a
segment_array.each_with_index do |segment, i|
if !segment_group_start_index && segment.subsplit?
segment_group_start_index = i
segment_group_end_index = segment_array[i..-1].index { |seg| seg.last_subsplit? } + i
@segments_with_groups << SegmentGroup.new(self, segment_array[segment_group_start_index..segment_group_end_index])
end
if segment_group_end_index == i
segment_group_start_index = nil
segment_group_end_index = nil
end

@segments_with_groups << segment
end
@segments_with_groups
end

private

def stats_select_query(timing)
Expand Down
25 changes: 24 additions & 1 deletion app/models/segment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,33 @@ def skipped?(timing)
end

def to_s
name
display_name
end

def history_stats(timing)
run.segment_history_stats(timing)[id]
end

# segment_group_parent? returns that this segment is not the parent of a segment group
def segment_group_parent?
false
end

# subsplit? returns something truthy representing if this segment is a subsplit of a segment group.
# This won't return true for the last subsplit in a group if it has no indication it is a subsplit
def subsplit?
return false unless name.present?
/^[-\{]/.match?(name)
end

# last_subsplit? returns something truthy representing if this segment is the last subsplit of a segment group
def last_subsplit?
/^[^-]/.match?(name)
end

# display_name returns the name of a normal segment and just the actual segment name of a subsplit
def display_name
return name unless subsplit?
/^(?:-|\{.*?}\s*)(.+)/.match(name)[1]
end
end
128 changes: 128 additions & 0 deletions app/models/segment_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
class SegmentGroup
attr_accessor :run, :segments

def initialize(run, segments)
self.run = run
self.segments = segments
end

def id
"#{segments.first.id}-segment_group"
end

def display_name
match = /\{(.+?)}/.match(segments.last.name) || [nil, segments.last.name]
match[1]
end

def duration(timing)
segments.map do |segment|
segment.duration(timing)
end.sum
end

def end(timing)
segments.last.end(timing)
end

def gold?(timing)
shortest_duration(timing) == duration(timing)
end

def reduced?(timing)
false
end

def segment_group_parent?
true
end

def segment_number
segments.first.segment_number
end

def skipped?(timing)
false
end

def shortest_duration(timing)
Duration.new(durations_by_attempt[timing].values.min)
end

def history_stats(timing)
values = durations_by_attempt[timing].values.sort
mean = values.sum / durations_by_attempt[timing].keys.length.to_f
variance_sum = values.inject(0) { |accum, i| accum + (i-mean)**2 }
sample_variance = values.length == 1 ? 0 : variance_sum / (durations_by_attempt[timing].keys.length - 1).to_f
{
standard_deviation: Math.sqrt(sample_variance),
mean: mean,
median: values.length == 1 ? values[0] : values[values.length / 2 + 1], # Not actual median, but matches the DB query
percentiles: {
10 => percentile(values, 0.1),
90 => percentile(values, 0.9),
99 => percentile(values, 0.99)
}
}
end

def segment_group_durations
durations = {}
durations_by_attempt.keys.each do |timing|
durations_by_attempt[timing].keys.each do |attempt_number|
durations[attempt_number] = {
attempt_number: attempt_number,
'realtime_duration_ms' => 0,
'gametime_duration_ms' => 0
} unless durations[attempt_number]
durations[attempt_number]["#{timing}time_duration_ms"] = durations_by_attempt[timing][attempt_number]
end
end
durations.values.to_a
end

private

# durations_by_attempt returns a hash of realtime and gametime hashes each with a key of the attempt number and a
# value of the real/game time duration of the entire segment_group
def durations_by_attempt
return @durations_by_attempt if @durations_by_attempt
@durations_by_attempt = {
Run::REAL => Hash.new { |h, k| h[k] = [] },
Run::GAME => Hash.new { |h, k| h[k] = [] }
}

# First, collect all the durations for each segment in the group for each attempt
segments.each do |segment|
segment.histories.each do |history|
previous_segment = segments[segment.segment_number - 1]&.histories&.find { |attempt| attempt.attempt_number == history.attempt_number }
# Don't store a segment's duration if it is 0 or if the previous segment's duration was 0 (and thus skipped)
@durations_by_attempt[Run::REAL][history.attempt_number] << history.realtime_duration_ms unless history.realtime_duration_ms == 0 || previous_segment&.realtime_duration_ms == 0
@durations_by_attempt[Run::GAME][history.attempt_number] << history.gametime_duration_ms unless history.gametime_duration_ms == 0 || previous_segment&.gametime_duration_ms == 0
end
end

# Only keep attempts who have times for all segments
@durations_by_attempt.keys.each do |timing|
max_length = @durations_by_attempt[timing].values.map(&:length).max
@durations_by_attempt[timing].delete_if { |key, value| value.length < max_length}
end

# Sum the segment times for each attempt and store those as opposed to the individual segment durations
@durations_by_attempt.keys.each do |timing|
@durations_by_attempt[timing].keys.each do |attempt_number|
@durations_by_attempt[timing][attempt_number] = @durations_by_attempt[timing][attempt_number].sum
end
end

@durations_by_attempt
end

def percentile(values_sorted, percent)
return values_sorted[0] if values_sorted.length == 1
k = (percent * (values_sorted.length - 1) + 1).floor - 1
f = (percent * (values_sorted.length - 1) + 1).modulo(1)

values_sorted[k] + (f * (values_sorted[k+1] - values_sorted[k]))
end
end
10 changes: 5 additions & 5 deletions app/views/runs/_split_table.slim
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
th.align-right Finished At
th.align-right Stats
tbody
- compare_segments = compare_runs.map { |r| r.segments.order(segment_number: :asc) }
- run.segments.zip(*compare_segments).each do |segment, *compare_segments|
- compare_segments = compare_runs.map { |r| r.segments_with_groups }
- run.segments_with_groups.zip(*compare_segments).each do |segment, *compare_segments|
- compare_segment = compare_segments.compact.min_by { |s| s.duration(timing) }
tr
td.align-right.align-middle = segment.segment_number + 1
td.align-left.align-middle = segment.name.presence || '-'
tr class="#{segment.segment_group_parent? ? 'segment-group' : ''}"
td.align-right.align-middle = segment.segment_group_parent? ? '' : segment.segment_number + 1
td.align-left.align-middle = segment.display_name.presence || '-'
td.align-right.align-middle
- if segment.skipped?(timing) || segment.reduced?(timing)
span.time -
Expand Down
2 changes: 1 addition & 1 deletion app/views/runs/_timeline.slim
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
style="width: #{segment.proportion(timing) * 100.0}%; z-index: #{index}; #{'cursor: pointer;' if run.video}"
)
.p-2
.text-light = segment.name.presence || '-'
.text-light = segment.display_name.presence || '-'
.text-light-50
- if segment.duration(timing).present?
= segment.duration(timing).format(precise: run.short?(timing))
Expand Down
2 changes: 1 addition & 1 deletion app/views/runs/_timeline_inspector.slim
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
.bar
p
div class="#{'mx-2' if segment == first || segment == last} #{'second-half' if segment.second_half?(timing)}"
div = segment.name
div = segment.display_name
.text-light-50 #{segment.duration(timing).format(precise: run.short?(timing))} duration
.text-light-50 #{segment.end(timing).format(precise: run.short?(timing))} finished
- if run.has_golds?(timing)
Expand Down
1 change: 1 addition & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ Segment objects have the following format:
|:--------------------------------|:--------|:---------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | string | never | Internal ID of the segment. |
| `name` | string | never | Name of the segment. This value is an exact copy of timers' fields. |
| `display_name` | string | never | Name of the segment without any subsplit-related naming tools. |
| `segment_number` | number | never | The segment number of the run. (This value starts at 0) |
| `realtime_start_ms` | number | never | The total elapsed time of the run at the moment when this segment was started in realtime. Provided in milliseconds. |
| `realtime_duration_ms` | number | never | Realtime duration in milliseconds of the segment. |
Expand Down

0 comments on commit e89d7b2

Please sign in to comment.