/*!
* SplitsBrowser - Orienteering results analysis.
*
* Copyright (C) 2000-2013 Dave Ryder, Reinhard Balling, Andris Strazdins,
* Ed Nash, Luke Woodward
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
// Tell JSHint not to complain that this isn't used anywhere.
/* exported SplitsBrowser */
var SplitsBrowser = { Version: "3.1.1", Model: {}, Input: {}, Controls: {} };
(function () {
"use strict";
// Whether a warning about missing messages has been given. We don't
// really want to irritate the user with many alert boxes if there's a
// problem with the messages.
var warnedAboutMessages = false;
// Default alerter function, just calls window.alert.
var alertFunc = function (message) { window.alert(message); };
/**
* Issue a warning about the messages, if a warning hasn't already been
* issued.
* @param {String} warning - The warning message to issue.
*/
function warn(warning) {
if (!warnedAboutMessages) {
alertFunc(warning);
warnedAboutMessages = true;
}
}
/**
* Sets the alerter to use when a warning message should be shown.
*
* This function is intended only for testing purposes.
* @param {Function} alerter - The function to be called when a warning is
* to be shown.
*/
SplitsBrowser.setMessageAlerter = function (alerter) {
alertFunc = alerter;
};
/**
* Returns the message with the given key.
* @param {String} key - The key of the message.
* @return {String} The message with the given key, or a placeholder string
* if the message could not be looked up.
*/
SplitsBrowser.getMessage = function (key) {
if (SplitsBrowser.hasOwnProperty("Messages")) {
if (SplitsBrowser.Messages.hasOwnProperty(key)) {
return SplitsBrowser.Messages[key];
} else {
warn("Message not found for key '" + key + "'");
return "?????";
}
} else {
warn("No messages found. Has a language file been loaded?");
return "?????";
}
};
/**
* Returns the message with the given key, with some string formatting
* applied to the result.
*
* The object 'params' should map search strings to their replacements.
*
* @param {String} key - The key of the message.
* @param {Object} params - Object mapping parameter names to values.
*/
SplitsBrowser.getMessageWithFormatting = function (key, params) {
var message = SplitsBrowser.getMessage(key);
for (var paramName in params) {
if (params.hasOwnProperty(paramName)) {
// Irritatingly there isn't a way of doing global replace
// without using regexps. So we must escape any magic regex
// metacharacters first, so that we have a regexp that will
// match a single static string.
var paramNameRegexEscaped = paramName.replace(/([.+*?|{}()^$\[\]\\])/g, "\\$1");
message = message.replace(new RegExp(paramNameRegexEscaped, "g"), params[paramName]);
}
}
return message;
};
})();
(function () {
"use strict";
// Minimum length of a course that is considered to be given in metres as
// opposed to kilometres.
var MIN_COURSE_LENGTH_METRES = 500;
/**
* Utility function used with filters that simply returns the object given.
* @param x - Any input value
* @returns The input value.
*/
SplitsBrowser.isTrue = function (x) { return x; };
/**
* Utility function that returns whether a value is not null.
* @param x - Any input value.
* @returns True if the value is not null, false otherwise.
*/
SplitsBrowser.isNotNull = function (x) { return x !== null; };
/**
* Exception object raised if invalid data is passed.
* @constructor.
* @param {string} message - The exception detail message.
*/
var InvalidData = function (message) {
this.name = "InvalidData";
this.message = message;
};
/**
* Returns a string representation of this exception.
* @returns {String} String representation.
*/
InvalidData.prototype.toString = function () {
return this.name + ": " + this.message;
};
/**
* Utility function to throw an 'InvalidData' exception object.
* @param {string} message - The exception message.
* @throws {InvalidData} if invoked.
*/
SplitsBrowser.throwInvalidData = function (message) {
throw new InvalidData(message);
};
/**
* Exception object raised if a data parser for a format deems that the data
* given is not of that format.
* @constructor
* @param {String} message - The exception message.
*/
var WrongFileFormat = function (message) {
this.name = "WrongFileFormat";
this.message = message;
};
/**
* Returns a string representation of this exception.
* @returns {String} String representation.
*/
WrongFileFormat.prototype.toString = function () {
return this.name + ": " + this.message;
};
/**
* Utility funciton to throw a 'WrongFileFormat' exception object.
* @param {string} message - The exception message.
* @throws {WrongFileFormat} if invoked.
*/
SplitsBrowser.throwWrongFileFormat = function (message) {
throw new WrongFileFormat(message);
};
/**
* Parses a course length.
*
* This can be specified as a decimal number of kilometres or metres, with
* either a full stop or a comma as the decimal separator.
*
* @param {String} stringValue - The course length to parse, as a string.
* @return {Number} The parsed course length.
*/
SplitsBrowser.parseCourseLength = function (stringValue) {
var courseLength = parseFloat(stringValue.replace(",", "."));
if (courseLength >= MIN_COURSE_LENGTH_METRES) {
courseLength /= 1000;
}
return courseLength;
};
})();
(function () {
"use strict";
SplitsBrowser.NULL_TIME_PLACEHOLDER = "-----";
/**
* Formats a time period given as a number of seconds as a string in the form
* [-][h:]mm:ss.
* @param {Number} seconds - The number of seconds.
* @returns {string} The string formatting of the time.
*/
SplitsBrowser.formatTime = function (seconds) {
if (seconds === null) {
return SplitsBrowser.NULL_TIME_PLACEHOLDER;
}
var result = "";
if (seconds < 0) {
result = "-";
seconds = -seconds;
}
var hours = Math.floor(seconds / (60 * 60));
var mins = Math.floor(seconds / 60) % 60;
var secs = seconds % 60;
if (hours > 0) {
result += hours.toString() + ":";
}
if (mins < 10) {
result += "0";
}
result += mins + ":";
if (secs < 10) {
result += "0";
}
result += Math.round(secs);
return result;
};
/**
* Parse a time of the form MM:SS or H:MM:SS into a number of seconds.
* @param {string} time - The time of the form MM:SS.
* @return {Number} The number of seconds.
*/
SplitsBrowser.parseTime = function (time) {
if (time.match(/^\d+:\d\d$/)) {
return parseInt(time.substring(0, time.length - 3), 10) * 60 + parseInt(time.substring(time.length - 2), 10);
} else if (time.match(/^\d+:\d\d:\d\d$/)) {
return parseInt(time.substring(0, time.length - 6), 10) * 3600 + parseInt(time.substring(time.length - 5, time.length - 3), 10) * 60 + parseInt(time.substring(time.length - 2), 10);
} else {
// Assume anything unrecognised is a missed split.
return null;
}
};
})();
(function () {
"use strict";
var NUMBER_TYPE = typeof 0;
var isNotNull = SplitsBrowser.isNotNull;
var throwInvalidData = SplitsBrowser.throwInvalidData;
var getMessage = SplitsBrowser.getMessage;
/**
* Function used with the JavaScript sort method to sort competitors in order
* by finishing time.
*
* Competitors that mispunch are sorted to the end of the list.
*
* The return value of this method will be:
* (1) a negative number if competitor a comes before competitor b,
* (2) a positive number if competitor a comes after competitor a,
* (3) zero if the order of a and b makes no difference (i.e. they have the
* same total time, or both mispunched.)
*
* @param {SplitsBrowser.Model.Competitor} a - One competitor to compare.
* @param {SplitsBrowser.Model.Competitor} b - The other competitor to compare.
* @returns {Number} Result of comparing two competitors. TH
*/
SplitsBrowser.Model.compareCompetitors = function (a, b) {
if (a.totalTime === b.totalTime) {
return a.order - b.order;
} else if (a.totalTime === null) {
return (b.totalTime === null) ? 0 : 1;
} else {
return (b.totalTime === null) ? -1 : a.totalTime - b.totalTime;
}
};
/**
* Returns the sum of two numbers, or null if either is null.
* @param {Number|null} a - One number, or null, to add.
* @param {Number|null} b - The other number, or null, to add.
* @return {Number|null} null if at least one of a or b is null,
* otherwise a + b.
*/
function addIfNotNull(a, b) {
return (a === null || b === null) ? null : (a + b);
}
/**
* Returns the difference of two numbers, or null if either is null.
* @param {Number|null} a - One number, or null, to add.
* @param {Number|null} b - The other number, or null, to add.
* @return {Number|null} null if at least one of a or b is null,
* otherwise a - b.
*/
function subtractIfNotNull(a, b) {
return (a === null || b === null) ? null : (a - b);
}
/**
* Convert an array of split times into an array of cumulative times.
* If any null splits are given, all cumulative splits from that time
* onwards are null also.
*
* The returned array of cumulative split times includes a zero value for
* cumulative time at the start.
* @param {Array} splitTimes - Array of split times.
* @return {Array} Corresponding array of cumulative split times.
*/
function cumTimesFromSplitTimes(splitTimes) {
if (!$.isArray(splitTimes)) {
throw new TypeError("Split times must be an array - got " + typeof splitTimes + " instead");
} else if (splitTimes.length === 0) {
throwInvalidData("Array of split times must not be empty");
}
var cumTimes = [0];
for (var i = 0; i < splitTimes.length; i += 1) {
cumTimes.push(addIfNotNull(cumTimes[i], splitTimes[i]));
}
return cumTimes;
}
/**
* Convert an array of cumulative times into an array of split times.
* If any null cumulative splits are given, the split times to and from that
* control are null also.
*
* The input array should begin with a zero, for the cumulative time to the
* start.
* @param {Array} cumTimes - Array of cumulative split times.
* @return {Array} Corresponding array of split times.
*/
function splitTimesFromCumTimes(cumTimes) {
if (!$.isArray(cumTimes)) {
throw new TypeError("Cumulative times must be an array - got " + typeof cumTimes + " instead");
} else if (cumTimes.length === 0) {
throwInvalidData("Array of cumulative times must not be empty");
} else if (cumTimes[0] !== 0) {
throwInvalidData("Array of cumulative times must have zero as its first item");
} else if (cumTimes.length === 1) {
throwInvalidData("Array of cumulative times must contain more than just a single zero");
}
var splitTimes = [];
for (var i = 0; i + 1 < cumTimes.length; i += 1) {
splitTimes.push(subtractIfNotNull(cumTimes[i + 1], cumTimes[i]));
}
return splitTimes;
}
/**
* Object that represents the data for a single competitor.
*
* The first parameter (order) merely stores the order in which the competitor
* appears in the given list of results. Its sole use is to stabilise sorts of
* competitors, as JavaScript's sort() method is not guaranteed to be a stable
* sort. However, it is not strictly the finishing order of the competitors,
* as it has been known for them to be given not in the correct order.
*
* It is not recommended to use this constructor directly. Instead, use one of
* the factory methods fromSplitTimes or fromCumTimes to pass in either the
* split or cumulative times and have the other calculated.
*
* @constructor
* @param {Number} order - The position of the competitor within the list of results.
* @param {String} name - The name of the competitor.
* @param {String} club - The name of the competitor's club.
* @param {String} startTime - The competitor's start time.
* @param {Array} splitTimes - Array of split times, as numbers, with nulls for missed controls.
* @param {Array} cumTimes - Array of cumulative split times, as numbers, with nulls for missed controls.
*/
var Competitor = function (order, name, club, startTime, splitTimes, cumTimes) {
if (typeof order !== NUMBER_TYPE) {
throwInvalidData("Competitor order must be a number, got " + typeof order + " '" + order + "' instead");
}
this.order = order;
this.name = name;
this.club = club;
this.startTime = startTime;
this.isNonCompetitive = false;
this.className = null;
this.splitTimes = splitTimes;
this.cumTimes = cumTimes;
this.splitRanks = null;
this.cumRanks = null;
this.timeLosses = null;
this.totalTime = (this.cumTimes.indexOf(null) > -1) ? null : this.cumTimes[this.cumTimes.length - 1];
};
/**
* Marks this competitor as being non-competitive.
*/
Competitor.prototype.setNonCompetitive = function () {
this.isNonCompetitive = true;
};
/**
* Sets the name of the class that the competitor belongs to.
* @param {String} className - The name of the class.
*/
Competitor.prototype.setClassName = function (className) {
this.className = className;
};
/**
* Create and return a Competitor object where the competitor's times are given
* as a list of split times.
*
* The first parameter (order) merely stores the order in which the competitor
* appears in the given list of results. Its sole use is to stabilise sorts of
* competitors, as JavaScript's sort() method is not guaranteed to be a stable
* sort. However, it is not strictly the finishing order of the competitors,
* as it has been known for them to be given not in the correct order.
*
* @param {Number} order - The position of the competitor within the list of results.
* @param {String} name - The name of the competitor.
* @param {String} club - The name of the competitor's club.
* @param {Number} startTime - The competitor's start time, as seconds past midnight.
* @param {Array} splitTimes - Array of split times, as numbers, with nulls for missed controls.
*/
Competitor.fromSplitTimes = function (order, name, club, startTime, splitTimes) {
var cumTimes = cumTimesFromSplitTimes(splitTimes);
return new Competitor(order, name, club, startTime, splitTimes, cumTimes);
};
/**
* Create and return a Competitor object where the competitor's times are given
* as a list of cumulative split times.
*
* The first parameter (order) merely stores the order in which the competitor
* appears in the given list of results. Its sole use is to stabilise sorts of
* competitors, as JavaScript's sort() method is not guaranteed to be a stable
* sort. However, it is not strictly the finishing order of the competitors,
* as it has been known for them to be given not in the correct order.
*
* @param {Number} order - The position of the competitor within the list of results.
* @param {String} name - The name of the competitor.
* @param {String} club - The name of the competitor's club.
* @param {Number} startTime - The competitor's start time, as seconds past midnight.
* @param {Array} cumTimes - Array of cumulative split times, as numbers, with nulls for missed controls.
*/
Competitor.fromCumTimes = function (order, name, club, startTime, cumTimes) {
var splitTimes = splitTimesFromCumTimes(cumTimes);
return new Competitor(order, name, club, startTime, splitTimes, cumTimes);
};
/**
* Returns whether this competitor completed the course.
* @return {boolean} Whether the competitor completed the course.
*/
Competitor.prototype.completed = function () {
return this.totalTime !== null;
};
/**
* Returns the 'suffix' to use with a competitor.
* The suffix indicates whether they are non-competitive or a mispuncher. If
* they are neither, an empty string is returned.
* @return Suffix.
*/
Competitor.prototype.getSuffix = function () {
if (this.completed()) {
return (this.isNonCompetitive) ? getMessage("NonCompetitiveShort") : "";
} else {
return getMessage("MispunchedShort");
}
};
/**
* Returns the competitor's split to the given control. If the control
* index given is zero (i.e. the start), zero is returned. If the
* competitor has no time recorded for that control, null is returned.
* @param {Number} controlIndex - Index of the control (0 = start).
* @return {Number} The split time in seconds for the competitor to the
* given control.
*/
Competitor.prototype.getSplitTimeTo = function (controlIndex) {
return (controlIndex === 0) ? 0 : this.splitTimes[controlIndex - 1];
};
/**
* Returns the competitor's cumulative split to the given control. If the
* control index given is zero (i.e. the start), zero is returned. If the
* competitor has no cumulative time recorded for that control, null is
* returned.
* @param {Number} controlIndex - Index of the control (0 = start).
* @return {Number} The cumulative split time in seconds for the competitor
* to the given control.
*/
Competitor.prototype.getCumulativeTimeTo = function (controlIndex) {
return this.cumTimes[controlIndex];
};
/**
* Returns the rank of the competitor's split to the given control. If the
* control index given is zero (i.e. the start), or if the competitor has no
* time recorded for that control, null is returned.
* @param {Number} controlIndex - Index of the control (0 = start).
* @return {Number} The split time in seconds for the competitor to the
* given control.
*/
Competitor.prototype.getSplitRankTo = function (controlIndex) {
return (controlIndex === 0) ? null : this.splitRanks[controlIndex - 1];
};
/**
* Returns the rank of the competitor's cumulative split to the given
* control. If the control index given is zero (i.e. the start), or if the
* competitor has no time recorded for that control, null is returned.
* @param {Number} controlIndex - Index of the control (0 = start).
* @return {Number} The split time in seconds for the competitor to the
* given control.
*/
Competitor.prototype.getCumulativeRankTo = function (controlIndex) {
return (controlIndex === 0) ? null : this.cumRanks[controlIndex - 1];
};
/**
* Returns the time loss of the competitor at the given control, or null if
* time losses cannot be calculated for the competitor or have not yet been
* calculated.
* @param {Number} controlIndex - Index of the control.
* @return {Number|null} Time loss in seconds, or null.
*/
Competitor.prototype.getTimeLossAt = function (controlIndex) {
return (controlIndex === 0 || this.timeLosses === null) ? null : this.timeLosses[controlIndex - 1];
};
/**
* Returns all of the competitor's cumulative time splits.
* @return {Array} The cumulative split times in seconds for the competitor.
*/
Competitor.prototype.getAllCumulativeTimes = function () {
return this.cumTimes;
};
/**
* Returns whether this competitor is missing a start time.
*
* The competitor is missing its start time if it doesn't have a start time
* and it also has at least one split. (A competitor that has no start time
* and no splits either didn't start the race.)
*
* @return {boolean} True if the competitor doesn't have a start time, false
* if they do, or if they have no other splits.
*/
Competitor.prototype.lacksStartTime = function () {
return this.startTime === null && this.splitTimes.some(isNotNull);
};
/**
* Sets the split and cumulative-split ranks for this competitor.
* @param {Array} splitRanks - Array of split ranks for this competitor.
* @param {Array} cumRanks - Array of cumulative-split ranks for this competitor.
*/
Competitor.prototype.setSplitAndCumulativeRanks = function (splitRanks, cumRanks) {
this.splitRanks = splitRanks;
this.cumRanks = cumRanks;
};
/**
* Return this competitor's cumulative times after being adjusted by a 'reference' competitor.
* @param {Array} referenceCumTimes - The reference cumulative-split-time data to adjust by.
* @return {Array} The array of adjusted data.
*/
Competitor.prototype.getCumTimesAdjustedToReference = function (referenceCumTimes) {
if (referenceCumTimes.length !== this.cumTimes.length) {
throwInvalidData("Cannot adjust competitor times because the numbers of times are different (" + this.cumTimes.length + " and " + referenceCumTimes.length + ")");
} else if (referenceCumTimes.indexOf(null) > -1) {
throwInvalidData("Cannot adjust competitor times because a null value is in the reference data");
}
var adjustedTimes = this.cumTimes.map(function (time, idx) { return subtractIfNotNull(time, referenceCumTimes[idx]); });
return adjustedTimes;
};
/**
* Returns the cumulative times of this competitor with the start time added on.
* @param {Array} referenceCumTimes - The reference cumulative-split-time data to adjust by.
* @return {Array} The array of adjusted data.
*/
Competitor.prototype.getCumTimesAdjustedToReferenceWithStartAdded = function (referenceCumTimes) {
var adjustedTimes = this.getCumTimesAdjustedToReference(referenceCumTimes);
var startTime = this.startTime;
return adjustedTimes.map(function (adjTime) { return addIfNotNull(adjTime, startTime); });
};
/**
* Returns an array of percentages that this competitor's splits were behind
* those of a reference competitor.
* @param {Array} referenceCumTimes - The reference cumulative split times
* @return {Array} The array of percentages.
*/
Competitor.prototype.getSplitPercentsBehindReferenceCumTimes = function (referenceCumTimes) {
if (referenceCumTimes.length !== this.cumTimes.length) {
throwInvalidData("Cannot determine percentages-behind because the numbers of times are different (" + this.cumTimes.length + " and " + referenceCumTimes.length + ")");
} else if (referenceCumTimes.indexOf(null) > -1) {
throwInvalidData("Cannot determine percentages-behind because a null value is in the reference data");
}
var percentsBehind = [0];
this.splitTimes.forEach(function (splitTime, index) {
if (splitTime === null) {
percentsBehind.push(null);
} else {
var referenceSplit = referenceCumTimes[index + 1] - referenceCumTimes[index];
percentsBehind.push(100 * (splitTime - referenceSplit) / referenceSplit);
}
});
return percentsBehind;
};
/**
* Determines the time losses for this competitor.
* @param {Array} fastestSplitTimes - Array of fastest split times.
*/
Competitor.prototype.determineTimeLosses = function (fastestSplitTimes) {
if (this.completed()) {
if (fastestSplitTimes.length !== this.splitTimes.length) {
throwInvalidData("Cannot determine time loss of competitor with " + this.splitTimes.length + " split times using " + fastestSplitTimes.length + " fastest splits");
} else if (fastestSplitTimes.indexOf(null) >= 0) {
throwInvalidData("Cannot determine time loss of competitor when there is a null value in the fastest splits");
}
// We use the same algorithm for calculating time loss as the
// original, with a simplification: we calculate split ratios
// (split[i] / fastest[i]) rather than time loss rates
// (split[i] - fastest[i])/fastest[i]. A control's split ratio
// is its time loss rate plus 1. Not subtracting one at the start
// means that we then don't have to add it back on at the end.
var splitRatios = this.splitTimes.map(function (splitTime, index) {
return splitTime / fastestSplitTimes[index];
});
splitRatios.sort(d3.ascending);
var medianSplitRatio;
if (splitRatios.length % 2 === 1) {
medianSplitRatio = splitRatios[(splitRatios.length - 1) / 2];
} else {
var midpt = splitRatios.length / 2;
medianSplitRatio = (splitRatios[midpt - 1] + splitRatios[midpt]) / 2;
}
this.timeLosses = this.splitTimes.map(function (splitTime, index) {
return Math.round(splitTime - fastestSplitTimes[index] * medianSplitRatio);
});
}
};
/**
* Returns whether this competitor 'crosses' another. Two competitors are
* considered to have crossed if their chart lines on the Race Graph cross.
* @param {Competitor} other - The competitor to compare against.
* @return {Boolean} true if the competitors cross, false if they don't.
*/
Competitor.prototype.crosses = function (other) {
if (other.cumTimes.length !== this.cumTimes.length) {
throwInvalidData("Two competitors with different numbers of controls cannot cross");
}
// We determine whether two competitors cross by keeping track of
// whether this competitor is ahead of other at any point, and whether
// this competitor is behind the other one. If both, the competitors
// cross.
var beforeOther = false;
var afterOther = false;
for (var controlIdx = 0; controlIdx < this.cumTimes.length; controlIdx += 1) {
if (this.cumTimes[controlIdx] !== null && other.cumTimes[controlIdx] !== null) {
var thisTotalTime = this.startTime + this.cumTimes[controlIdx];
var otherTotalTime = other.startTime + other.cumTimes[controlIdx];
if (thisTotalTime < otherTotalTime) {
beforeOther = true;
} else if (thisTotalTime > otherTotalTime) {
afterOther = true;
}
}
}
return beforeOther && afterOther;
};
SplitsBrowser.Model.Competitor = Competitor;
})();
(function (){
"use strict";
var throwInvalidData = SplitsBrowser.throwInvalidData;
/**
* Object that represents a collection of competitor data for a class.
* @constructor.
* @param {string} name - Name of the age class.
* @param {Number} numControls - Number of controls.
* @param {Array} competitors - Array of Competitor objects.
*/
var AgeClass = function (name, numControls, competitors) {
this.name = name;
this.numControls = numControls;
this.competitors = competitors;
this.course = null;
var fastestSplitTimes = d3.range(1, numControls + 2).map(function (controlIdx) {
var splitRec = this.getFastestSplitTo(controlIdx);
return (splitRec === null) ? null : splitRec.split;
}, this);
this.competitors.forEach(function (comp) {
comp.setClassName(this.name);
comp.determineTimeLosses(fastestSplitTimes);
}, this);
};
/**
* Returns whether this age-class is empty, i.e. has no competitors.
* @return {boolean} True if this age class has no competitors, false if it
* has at least one competitor.
*/
AgeClass.prototype.isEmpty = function () {
return (this.competitors.length === 0);
};
/**
* Sets the course that this age class belongs to.
* @param {SplitsBrowser.Model.Course} course - The course this class belongs to.
*/
AgeClass.prototype.setCourse = function (course) {
this.course = course;
};
/**
* Returns the controls that all competitors in this class failed to punch.
*
* @return {Array} Array of numbers of controls that all competitors in this
* class failed to punch.
*/
AgeClass.prototype.getControlsWithNoSplits = function () {
return d3.range(1, this.numControls + 1).filter(function (controlNum) {
return this.competitors.every(function (competitor) { return competitor.getSplitTimeTo(controlNum) === null; });
}, this);
};
/**
* Returns the fastest split time recorded by competitors in this class. If
* no fastest split time is recorded (e.g. because all competitors
* mispunched that control, or the class is empty), null is returned.
* @param {Number} controlIdx - The index of the control to return the
* fastest split to.
* @return {Object|null} Object containing the name and fastest split, or
* null if no split times for that control were recorded.
*/
AgeClass.prototype.getFastestSplitTo = function (controlIdx) {
if (typeof controlIdx !== "number" || controlIdx < 1 || controlIdx > this.numControls + 1) {
throwInvalidData("Cannot return splits to leg '" + controlIdx + "' in a course with " + this.numControls + " control(s)");
}
var fastestSplit = null;
var fastestCompetitor = null;
this.competitors.forEach(function (comp) {
var compSplit = comp.getSplitTimeTo(controlIdx);
if (compSplit !== null) {
if (fastestSplit === null || compSplit < fastestSplit) {
fastestSplit = compSplit;
fastestCompetitor = comp;
}
}
});
return (fastestSplit === null) ? null : {split: fastestSplit, name: fastestCompetitor.name};
};
/**
* Returns all competitors that visited the control in the given time
* interval.
* @param {Number} controlNum - The number of the control, with 0 being the
* start, and this.numControls + 1 being the finish.
* @param {Number} intervalStart - The start time of the interval, as
* seconds past midnight.
* @param {Number} intervalEnd - The end time of the interval, as seconds
* past midnight.
* @return {Array} Array of objects listing the name and start time of each
* competitor visiting the control within the given time interval.
*/
AgeClass.prototype.getCompetitorsAtControlInTimeRange = function (controlNum, intervalStart, intervalEnd) {
if (typeof controlNum !== "number" || isNaN(controlNum) || controlNum < 0 || controlNum > this.numControls + 1) {
throwInvalidData("Control number must be a number between 0 and " + this.numControls + " inclusive");
}
var matchingCompetitors = [];
this.competitors.forEach(function (comp) {
var cumTime = comp.getCumulativeTimeTo(controlNum);
if (cumTime !== null && comp.startTime !== null) {
var actualTimeAtControl = cumTime + comp.startTime;
if (intervalStart <= actualTimeAtControl && actualTimeAtControl <= intervalEnd) {
matchingCompetitors.push({name: comp.name, time: actualTimeAtControl});
}
}
});
return matchingCompetitors;
};
SplitsBrowser.Model.AgeClass = AgeClass;
})();
(function () {
"use strict";
var throwInvalidData = SplitsBrowser.throwInvalidData;
var compareCompetitors = SplitsBrowser.Model.compareCompetitors;
/**
* Utility function to merge the lists of all competitors in a number of age
* classes. All age classes must contain the same number of controls.
* @param {Array} ageClasses - Array of AgeClass objects.
*/
function mergeCompetitors(ageClasses) {
if (ageClasses.length === 0) {
throwInvalidData("Cannot create an AgeClassSet from an empty set of competitors");
}
var allCompetitors = [];
var expectedControlCount = ageClasses[0].numControls;
ageClasses.forEach(function (ageClass) {
if (ageClass.numControls !== expectedControlCount) {
throwInvalidData("Cannot merge age classes with " + expectedControlCount + " and " + ageClass.numControls + " controls");
}
ageClass.competitors.forEach(function (comp) { allCompetitors.push(comp); });
});
allCompetitors.sort(compareCompetitors);
return allCompetitors;
}
/**
* Given an array of numbers, return a list of the corresponding ranks of those
* numbers.
* @param {Array} sourceData - Array of number values.
* @returns Array of corresponding ranks.
*/
function getRanks(sourceData) {
// First, sort the source data, removing nulls.
var sortedData = sourceData.filter(function (x) { return x !== null; });
sortedData.sort(d3.ascending);
// Now construct a map that maps from source value to rank.
var rankMap = new d3.map();
sortedData.forEach(function(value, index) {
if (!rankMap.has(value)) {
rankMap.set(value, index + 1);
}
});
// Finally, build and return the list of ranks.
var ranks = sourceData.map(function(value) {
return (value === null) ? null : rankMap.get(value);
});
return ranks;
}
/**
* An object that represents the currently-selected age classes.
* @constructor
* @param {Array} ageClasses - Array of currently-selected age classes.
*/
var AgeClassSet = function (ageClasses) {
this.allCompetitors = mergeCompetitors(ageClasses);
this.ageClasses = ageClasses;
this.numControls = ageClasses[0].numControls;
this.computeRanks();
};
/**
* Returns whether this age-class set is empty, i.e. whether it has no
* competitors at all.
*/
AgeClassSet.prototype.isEmpty = function () {
return this.allCompetitors.length === 0;
};
/**
* Returns the course used by all of the age classes that make up this set.
* @return {SplitsBrowser.Model.Course} The course used by all age-classes.
*/
AgeClassSet.prototype.getCourse = function () {
return this.ageClasses[0].course;
};
/**
* Returns the name of the 'primary' age class, i.e. that that has been
* chosen in the drop-down list.
* @return {String} Name of the primary age class.
*/
AgeClassSet.prototype.getPrimaryClassName = function () {
return this.ageClasses[0].name;
};
/**
* Returns the number of age classes that this age-class set is made up of.
* @return {Number} The number of age classes that this age-class set is
* made up of.
*/
AgeClassSet.prototype.getNumClasses = function () {
return this.ageClasses.length;
};
/**
* Returns an array of the cumulative times of the winner of the set of age
* classes.
* @return {Array} Array of the winner's cumulative times.
*/
AgeClassSet.prototype.getWinnerCumTimes = function () {
if (this.allCompetitors.length === 0) {
return null;
}
var firstCompetitor = this.allCompetitors[0];
return (firstCompetitor.completed()) ? firstCompetitor.cumTimes : null;
};
/**
* Return the imaginary competitor who recorded the fastest time on each leg
* of the class.
* If at least one control has no competitors recording a time for it, null
* is returned.
* @returns {Array|null} Cumulative splits of the imaginary competitor with
* fastest time, if any.
*/
AgeClassSet.prototype.getFastestCumTimes = function () {
return this.getFastestCumTimesPlusPercentage(0);
};
/**
* Returns an array of controls that no competitor in any of the age-classes
* in this set punched.
* @return {Array} Array of control numbers of controls that no competitor
* punched.
*/
AgeClassSet.prototype.getControlsWithNoSplits = function () {
var controlsWithNoSplits = this.ageClasses[0].getControlsWithNoSplits();
for (var classIndex = 1; classIndex < this.ageClasses.length && controlsWithNoSplits.length > 0; classIndex += 1) {
var thisClassControlsWithNoSplits = this.ageClasses[classIndex].getControlsWithNoSplits();
var controlIdx = 0;
while (controlIdx < controlsWithNoSplits.length) {
if (thisClassControlsWithNoSplits.indexOf(controlsWithNoSplits[controlIdx]) >= 0) {
controlIdx += 1;
} else {
controlsWithNoSplits.splice(controlIdx, 1);
}
}
}
return controlsWithNoSplits;
};
/**
* Return the imaginary competitor who recorded the fastest time on each leg
* of the given classes, with a given percentage of their time added.
* If at least one control has no competitors recording a time for it, null
* is returned.
* @param {Number} percent - The percentage of time to add.
* @returns {Array|null} Cumulative splits of the imaginary competitor with
* fastest time, if any, after adding a percentage.
*/
AgeClassSet.prototype.getFastestCumTimesPlusPercentage = function (percent) {
var ratio = 1 + percent / 100;
var fastestCumTimes = new Array(this.numControls + 1);
fastestCumTimes[0] = 0;
for (var controlIdx = 1; controlIdx <= this.numControls + 1; controlIdx += 1) {
var fastestForThisControl = null;
for (var competitorIdx = 0; competitorIdx < this.allCompetitors.length; competitorIdx += 1) {
var thisTime = this.allCompetitors[competitorIdx].getSplitTimeTo(controlIdx);
if (thisTime !== null && (fastestForThisControl === null || thisTime < fastestForThisControl)) {
fastestForThisControl = thisTime;
}
}
if (fastestForThisControl === null) {
// No fastest time recorded for this control.
return null;
} else {
fastestCumTimes[controlIdx] = fastestCumTimes[controlIdx - 1] + fastestForThisControl * ratio;
}
}
return fastestCumTimes;
};
/**
* Compute the ranks of each competitor within their class.
*/
AgeClassSet.prototype.computeRanks = function () {
var splitRanksByCompetitor = [];
var cumRanksByCompetitor = [];
this.allCompetitors.forEach(function () {
splitRanksByCompetitor.push([]);
cumRanksByCompetitor.push([]);
});
d3.range(1, this.numControls + 2).forEach(function (control) {
var splitsByCompetitor = this.allCompetitors.map(function(comp) { return comp.getSplitTimeTo(control); });
var splitRanksForThisControl = getRanks(splitsByCompetitor);
this.allCompetitors.forEach(function (_comp, idx) { splitRanksByCompetitor[idx].push(splitRanksForThisControl[idx]); });
}, this);
d3.range(1, this.numControls + 2).forEach(function (control) {
// We want to null out all subsequent cumulative ranks after a
// competitor mispunches.
var cumSplitsByCompetitor = this.allCompetitors.map(function (comp, idx) {
// -1 for previous control, another -1 because the cumulative
// time to control N is cumRanksByCompetitor[idx][N - 1].
if (control > 1 && cumRanksByCompetitor[idx][control - 1 - 1] === null) {
// This competitor has no cumulative rank for the previous
// control, so either they mispunched it or mispunched a
// previous one. Give them a null time here, so that they
// end up with another null cumulative rank.
return null;
} else {
return comp.getCumulativeTimeTo(control);
}
});
var cumRanksForThisControl = getRanks(cumSplitsByCompetitor);
this.allCompetitors.forEach(function (_comp, idx) { cumRanksByCompetitor[idx].push(cumRanksForThisControl[idx]); });
}, this);
this.allCompetitors.forEach(function (comp, idx) {
comp.setSplitAndCumulativeRanks(splitRanksByCompetitor[idx], cumRanksByCompetitor[idx]);
});
};
/**
* Returns the best few splits to a given control.
*
* The number of splits returned may actually be fewer than that asked for,
* if there are fewer than that number of people on the class or who punch
* the control.
*
* The results are returned in an array of 2-element arrays, with each child
* array containing the split time and the name. The array is returned in
* ascending order of split time.
*
* @param {Number} numSplits - Maximum number of split times to return.
* @param {Number} controlIdx - Index of the control.
* @return {Array} Array of the fastest splits to the given control.
*/
AgeClassSet.prototype.getFastestSplitsTo = function (numSplits, controlIdx) {
if (typeof numSplits !== "number" || numSplits <= 0) {
throwInvalidData("The number of splits must be a positive integer");
} else if (typeof controlIdx !== "number" || controlIdx <= 0 || controlIdx > this.numControls + 1) {
throwInvalidData("Control " + controlIdx + " out of range");
} else {
// Compare competitors by split time at this control, and, if those
// are equal, total time.
var comparator = function (compA, compB) {
var compASplit = compA.getSplitTimeTo(controlIdx);
var compBSplit = compB.getSplitTimeTo(controlIdx);
return (compASplit === compBSplit) ? d3.ascending(compA.totalTime, compB.totalTime) : d3.ascending(compASplit, compBSplit);
};
var competitors = this.allCompetitors.filter(function (comp) { return comp.completed(); });
competitors.sort(comparator);
var results = [];
for (var i = 0; i < competitors.length && i < numSplits; i += 1) {
results.push({name: competitors[i].name, split: competitors[i].getSplitTimeTo(controlIdx)});
}
return results;
}
};
/**
* Return data from the current classes in a form suitable for plotting in a chart.
* @param {Array} referenceCumTimes - 'Reference' cumulative time data, such
* as that of the winner, or the fastest time.
* @param {Array} currentIndexes - Array of indexes that indicate which
* competitors from the overall list are plotted.
* @param {Object} chartType - The type of chart to draw.
* @returns {Array} Array of data.
*/
AgeClassSet.prototype.getChartData = function (referenceCumTimes, currentIndexes, chartType) {
if (this.isEmpty()) {
throwInvalidData("Cannot return chart data when there is no data");
} else if (typeof referenceCumTimes === "undefined") {
throw new TypeError("referenceCumTimes undefined or missing");
} else if (typeof currentIndexes === "undefined") {
throw new TypeError("currentIndexes undefined or missing");
} else if (typeof chartType === "undefined") {
throw new TypeError("chartType undefined or missing");
}
var competitorData = this.allCompetitors.map(function (comp) { return chartType.dataSelector(comp, referenceCumTimes); });
var selectedCompetitorData = currentIndexes.map(function (index) { return competitorData[index]; });
var xMax = referenceCumTimes[referenceCumTimes.length - 1];
var yMin;
var yMax;
if (currentIndexes.length === 0) {
// No competitors selected. Set yMin and yMax to the boundary
// values of the first competitor.
var firstCompetitorTimes = competitorData[0];
yMin = d3.min(firstCompetitorTimes);
yMax = d3.max(firstCompetitorTimes);
} else {
yMin = d3.min(selectedCompetitorData.map(function (values) { return d3.min(values); }));
yMax = d3.max(selectedCompetitorData.map(function (values) { return d3.max(values); }));
}
if (yMax === yMin) {
// yMin and yMax will be used to scale a y-axis, so we'd better
// make sure that they're not equal.
yMax = yMin + 1;
}
var cumulativeTimesByControl = d3.transpose(selectedCompetitorData);
var xData = (chartType.skipStart) ? referenceCumTimes.slice(1) : referenceCumTimes;
var zippedData = d3.zip(xData, cumulativeTimesByControl);
var competitorNames = currentIndexes.map(function (index) { return this.allCompetitors[index].name; }, this);
return {
dataColumns: zippedData.map(function (data) { return { x: data[0], ys: data[1] }; }),
competitorNames: competitorNames,
numControls: this.numControls,
xExtent: [0, xMax],
yExtent: [yMin, yMax]
};
};
SplitsBrowser.Model.AgeClassSet = AgeClassSet;
})();
(function () {
"use strict";
var throwInvalidData = SplitsBrowser.throwInvalidData;
/**
* A collection of 'classes', all runners within which ran the same physical
* course.
*
* Course length and climb are both optional and can both be null.
* @constructor
* @param {String} name - The name of the course.
* @param {Array} classes - Array of AgeClass objects comprising the course.
* @param {Number|null} length - Length of the course, in kilometres.
* @param {Number|null} climb - The course climb, in metres.
* @param {Array|null} controls - Array of codes of the controls that make
* up this course. This may be null if no such information is provided.
*/
var Course = function (name, classes, length, climb, controls) {
this.name = name;
this.classes = classes;
this.length = length;
this.climb = climb;
this.controls = controls;
};
/** 'Magic' control code that represents the start. */
Course.START = "__START__";
/** 'Magic' control code that represents the finish. */
Course.FINISH = "__FINISH__";
var START = Course.START;
var FINISH = Course.FINISH;
/**
* Returns an array of the 'other' classes on this course.
* @param {SplitsBrowser.Model.AgeClass} ageClass - An age class that should
* be on this course,
* @return {Array} Array of other age classes.
*/
Course.prototype.getOtherClasses = function (ageClass) {
var otherClasses = this.classes.filter(function (cls) { return cls !== ageClass; });
if (otherClasses.length === this.classes.length) {
// Given class not found.
throwInvalidData("Course.getOtherClasses: given class is not in this course");
} else {
return otherClasses;
}
};
/**
* Returns the number of age classes that use this course.
* @return {Number} Number of age classes that use this course.
*/
Course.prototype.getNumClasses = function () {
return this.classes.length;
};
/**
* Returns whether this course has control code data.
* @return {boolean} true if this course has control codes, false if it does
* not.
*/
Course.prototype.hasControls = function () {
return (this.controls !== null);
};
/**
* Returns the code of the control at the given number.
*
* The start is control number 0 and the finish has number one more than the
* number of controls. Numbers outside this range are invalid and cause an
* exception to be thrown.
*
* The codes for the start and finish are given by the constants
* SplitsBrowser.Model.Course.START and SplitsBrowser.Model.Course.FINISH.
*
* @param {Number} controlNum - The number of the control.
* @return {String|null} The code of the control, or one of the
* aforementioned constants for the start or finish.
*/
Course.prototype.getControlCode = function (controlNum) {
if (controlNum === 0) {
// The start.
return START;
} else if (1 <= controlNum && controlNum <= this.controls.length) {
return this.controls[controlNum - 1];
} else if (controlNum === this.controls.length + 1) {
// The finish.
return FINISH;
} else {
throwInvalidData("Cannot get control code of control " + controlNum + " because it is out of range");
}
};
/**
* Returns whether this course uses the given leg.
*
* If this course lacks leg information, it is assumed not to contain any
* legs and so will return false for every leg.
*
* @param {String} startCode - Code for the control at the start of the leg,
* or null for the start.
* @param {String} endCode - Code for the control at the end of the leg, or
* null for the finish.
* @return {boolean} Whether this course uses the given leg.
*/
Course.prototype.usesLeg = function (startCode, endCode) {
return this.getLegNumber(startCode, endCode) >= 0;
};
/**
* Returns the number of a leg in this course, given the start and end
* control codes.
*
* The number of a leg is the number of the end control (so the leg from
* control 3 to control 4 is leg number 4.) The number of the finish
* control is one more than the number of controls.
*
* A negative number is returned if this course does not contain this leg.
*
* @param {String} startCode - Code for the control at the start of the leg,
* or null for the start.
* @param {String} endCode - Code for the control at the end of the leg, or
* null for the finish.
* @return {Number} The control number of the leg in this course, or a
* negative number if the leg is not part of this course.
*/
Course.prototype.getLegNumber = function (startCode, endCode) {
if (this.controls === null) {
// No controls, so no, it doesn't contain the leg specified.
return -1;
}
if (startCode === null && endCode === null) {
// No controls - straight from the start to the finish.
// This leg is only present, and is leg 1, if there are no
// controls.
return (this.controls.length === 0) ? 1 : -1;
} else if (startCode === START) {
// From the start to control 1.
return (this.controls.length > 0 && this.controls[0] === endCode) ? 1 : -1;
} else if (endCode === FINISH) {
return (this.controls.length > 0 && this.controls[this.controls.length - 1] === startCode) ? (this.controls.length + 1) : -1;
} else {
for (var controlIdx = 1; controlIdx < this.controls.length; controlIdx += 1) {
if (this.controls[controlIdx - 1] === startCode && this.controls[controlIdx] === endCode) {
return controlIdx + 1;
}
}
// If we get here, the given leg is not part of this course.
return -1;
}
};
/**
* Returns the fastest splits recorded for a given leg of the course.
*
* Note that this method should only be called if the course is known to use
* the given leg.
*
* @param {String} startCode - Code for the control at the start of the leg,
* or SplitsBrowser.Model.Course.START for the start.
* @param {String} endCode - Code for the control at the end of the leg, or
* SplitsBrowser.Model.Course.FINISH for the finish.
* @return {Array} Array of fastest splits for each age class using this
* course.
*/
Course.prototype.getFastestSplitsForLeg = function (startCode, endCode) {
if (this.legs === null) {
throwInvalidData("Cannot determine fastest splits for a leg because leg information is not available");
}
var legNumber = this.getLegNumber(startCode, endCode);
if (legNumber < 0) {
var legStr = ((startCode === START) ? "start" : startCode) + " to " + ((endCode === FINISH) ? "end" : endCode);
throwInvalidData("Leg from " + legStr + " not found in course " + this.name);
}
var controlNum = legNumber;
var fastestSplits = [];
this.classes.forEach(function (ageClass) {
var classFastest = ageClass.getFastestSplitTo(controlNum);
if (classFastest !== null) {
fastestSplits.push({name: classFastest.name, className: ageClass.name, split: classFastest.split});
}
});
return fastestSplits;
};
/**
* Returns a list of all competitors on this course that visit the control
* with the given code in the time interval given.
*
* Specify SplitsBrowser.Model.Course.START for the start and
* SplitsBrowser.Model.Course.FINISH for the finish.
*
* If the given control is not on this course, an empty list is returned.
*
* @param {String} controlCode - Control code of the required control.
* @param {Number} intervalStart - The start of the interval, as seconds
* past midnight.
* @param {Number} intervalEnd - The end of the interval, as seconds past
* midnight.
* @return {Array} Array of all competitors visiting the given control
* within the given time interval.
*/
Course.prototype.getCompetitorsAtControlInTimeRange = function (controlCode, intervalStart, intervalEnd) {
if (this.controls === null) {
// No controls means don't return any competitors.
return [];
} else if (controlCode === START) {
return this.getCompetitorsAtControlNumInTimeRange(0, intervalStart, intervalEnd);
} else if (controlCode === FINISH) {
return this.getCompetitorsAtControlNumInTimeRange(this.controls.length + 1, intervalStart, intervalEnd);
} else {
var controlIdx = this.controls.indexOf(controlCode);
if (controlIdx >= 0) {
return this.getCompetitorsAtControlNumInTimeRange(controlIdx + 1, intervalStart, intervalEnd);
} else {
// Control not in this course.
return [];
}
}
};
/**
* Returns a list of all competitors on this course that visit the control
* with the given number in the time interval given.
*
* @param {Number} controlNum - The number of the control (0 = start).
* @param {Number} intervalStart - The start of the interval, as seconds
* past midnight.
* @param {Number} intervalEnd - The end of the interval, as seconds past
* midnight.
* @return {Array} Array of all competitors visiting the given control
* within the given time interval.
*/
Course.prototype.getCompetitorsAtControlNumInTimeRange = function (controlNum, intervalStart, intervalEnd) {
var matchingCompetitors = [];
this.classes.forEach(function (ageClass) {
ageClass.getCompetitorsAtControlInTimeRange(controlNum, intervalStart, intervalEnd).forEach(function (comp) {
matchingCompetitors.push({name: comp.name, time: comp.time, className: ageClass.name});
});
});
return matchingCompetitors;
};
/**
* Returns whether the course has the given control.
* @param {String} controlCode - The code of the control.
* @return {boolean} True if the course has the control, false if the
* course doesn't, or doesn't have any controls at all.
*/
Course.prototype.hasControl = function (controlCode) {
return this.controls !== null && this.controls.indexOf(controlCode) > -1;
};
/**
* Returns the control code(s) of the control(s) after the one with the
* given code.
*
* Controls can appear multiple times in a course. If a control appears
* multiple times, there will be multiple next controls. As a result
* @param {String} controlCode - The code of the control.
* @return {Array} The code of the next control
*/
Course.prototype.getNextControls = function (controlCode) {
if (this.controls === null) {
throwInvalidData("Course has no controls");
} else if (controlCode === FINISH) {
throwInvalidData("Cannot fetch next control after the finish");
} else if (controlCode === START) {
return [this.controls[0]];
} else {
var lastControlIdx = -1;
var nextControls = [];
do {
var controlIdx = this.controls.indexOf(controlCode, lastControlIdx + 1);
if (controlIdx === -1) {
break;
} else if (controlIdx === this.controls.length - 1) {
nextControls.push(FINISH);
} else {
nextControls.push(this.controls[controlIdx + 1]);
}
lastControlIdx = controlIdx;
} while (true); // Loop exits when broken.
if (nextControls.length === 0) {
throwInvalidData("Control '" + controlCode + "' not found on course " + this.name);
} else {
return nextControls;
}
}
};
SplitsBrowser.Model.Course = Course;
})();
(function () {
"use strict";
var Course = SplitsBrowser.Model.Course;
/**
* Contains all of the data for an event.
* @param {Array} classes - Array of AgeClass objects representing all of
* the classes of competitors.
* @param {Array} courses - Array of Course objects representing all of the
* courses of the event.
*/
var Event = function (classes, courses) {
this.classes = classes;
this.courses = courses;
};
/**
* Returns the fastest splits for each class on a given leg.
*
* The fastest splits are returned as an array of objects, where each object
* lists the competitors name, the class, and the split time in seconds.
*
* @param {String} startCode - Code for the control at the start of the leg,
* or null for the start.
* @param {String} endCode - Code for the control at the end of the leg, or
* null for the finish.
* @return {Array} Array of objects containing fastest splits for that leg.
*/
Event.prototype.getFastestSplitsForLeg = function (startCode, endCode) {
var fastestSplits = [];
this.courses.forEach(function (course) {
if (course.usesLeg(startCode, endCode)) {
fastestSplits = fastestSplits.concat(course.getFastestSplitsForLeg(startCode, endCode));
}
});
fastestSplits.sort(function (a, b) { return d3.ascending(a.split, b.split); });
return fastestSplits;
};
/**
* Returns a list of competitors that visit the control with the given code
* within the given time interval.
*
* The fastest splits are returned as an array of objects, where each object
* lists the competitors name, the class, and the split time in seconds.
*
* @param {String} startCode - Code for the control at the start of the leg,
* or null for the start.
* @param {String} endCode - Code for the control at the end of the leg, or
* null for the finish.
* @return {Array} Array of objects containing fastest splits for that leg.
*/
Event.prototype.getCompetitorsAtControlInTimeRange = function (controlCode, intervalStart, intervalEnd) {
var competitors = [];
this.courses.forEach(function (course) {
course.getCompetitorsAtControlInTimeRange(controlCode, intervalStart, intervalEnd).forEach(function (comp) {
competitors.push(comp);
});
});
competitors.sort(function (a, b) { return d3.ascending(a.time, b.time); });
return competitors;
};
/**
* Returns the list of controls that follow after a given control.
* @param {String} controlCode - The code for the control.
* @return {Array} Array of objects for each course using that control,
* with each object listing course name and next control.
*/
Event.prototype.getNextControlsAfter = function (controlCode) {
var courses = this.courses;
if (controlCode !== Course.START) {
courses = courses.filter(function (course) { return course.hasControl(controlCode); });
}
return courses.map(function (course) { return {course: course, nextControls: course.getNextControls(controlCode)}; });
};
SplitsBrowser.Model.Event = Event;
})();
(function () {
/**
* Converts a number of seconds into the corresponding number of minutes.
* This conversion is as simple as dividing by 60.
* @param {Number} seconds - The number of seconds to convert.
* @return {Number} The corresponding number of minutes.
*/
function secondsToMinutes(seconds) {
return (seconds === null) ? null : seconds / 60;
}
SplitsBrowser.Model.ChartTypes = {
SplitsGraph: {
nameKey: "SplitsGraphChartType",
dataSelector: function (comp, referenceCumTimes) { return comp.getCumTimesAdjustedToReference(referenceCumTimes).map(secondsToMinutes); },
skipStart: false,
yAxisLabelKey: "SplitsGraphYAxisLabel",
isRaceGraph: false,
isResultsTable: false,
minViewableControl: 1
},
RaceGraph: {
nameKey: "RaceGraphChartType",
dataSelector: function (comp, referenceCumTimes) { return comp.getCumTimesAdjustedToReferenceWithStartAdded(referenceCumTimes).map(secondsToMinutes); },
skipStart: false,
yAxisLabelKey: "RaceGraphYAxisLabel",
isRaceGraph: true,
isResultsTable: false,
minViewableControl: 0
},
PositionAfterLeg: {
nameKey: "PositionAfterLegChartType",
dataSelector: function (comp) { return comp.cumRanks; },
skipStart: true,
yAxisLabelKey: "PositionYAxisLabel",
isRaceGraph: false,
isResultsTable: false,
minViewableControl: 1
},
SplitPosition: {
nameKey: "SplitPositionChartType",
dataSelector: function (comp) { return comp.splitRanks; },
skipStart: true,
yAxisLabelKey: "PositionYAxisLabel",
isRaceGraph: false,
isResultsTable: false,
minViewableControl: 1
},
PercentBehind: {
nameKey: "PercentBehindChartType",
dataSelector: function (comp, referenceCumTimes) { return comp.getSplitPercentsBehindReferenceCumTimes(referenceCumTimes); },
skipStart: false,
yAxisLabelKey: "PercentBehindYAxisLabel",
isRaceGraph: false,
isResultsTable: false,
minViewableControl: 1
},
ResultsTable: {
nameKey: "ResultsTableChartType",
dataSelector: null,
skipStart: false,
yAxisLabelKey: null,
isRaceGraph: false,
isResultsTable: true,
minViewableControl: 1
}
};
})();
(function (){
"use strict";
var NUMBER_TYPE = typeof 0;
var throwInvalidData = SplitsBrowser.throwInvalidData;
/**
* Represents the currently-selected competitors, and offers a callback
* mechanism for when the selection changes.
* @constructor
* @param {Number} count - The number of competitors that can be chosen.
*/
var CompetitorSelection = function (count) {
if (typeof count !== NUMBER_TYPE) {
throwInvalidData("Competitor count must be a number");
} else if (count < 0) {
throwInvalidData("Competitor count must be a non-negative number");
}
this.count = count;
this.currentIndexes = [];
this.changeHandlers = [];
};
/**
* Returns whether the competitor at the given index is selected.
* @param {Number} index - The index of the competitor.
* @returns {boolean} True if the competitor is selected, false if not.
*/
CompetitorSelection.prototype.isSelected = function (index) {
return this.currentIndexes.indexOf(index) > -1;
};
/**
* Returns whether the selection consists of exactly one competitor.
* @returns {boolean} True if precisely one competitor is selected, false if
* either no competitors, or two or more competitors, are selected.
*/
CompetitorSelection.prototype.isSingleRunnerSelected = function () {
return this.currentIndexes.length === 1;
};
/**
* Given that a single runner is selected, select also all of the runners
* that 'cross' this runner.
* @param {Array} competitors - All competitors in the same class.
*/
CompetitorSelection.prototype.selectCrossingRunners = function (competitors) {
if (this.isSingleRunnerSelected()) {
var refCompetitor = competitors[this.currentIndexes[0]];
competitors.forEach(function (comp, idx) {
if (comp.crosses(refCompetitor)) {
this.currentIndexes.push(idx);
}
}, this);
this.currentIndexes.sort(d3.ascending);
this.fireChangeHandlers();
}
};
/**
* Fires all of the change handlers currently registered.
*/
CompetitorSelection.prototype.fireChangeHandlers = function () {
// Call slice(0) to return a copy of the list.
this.changeHandlers.forEach(function (handler) { handler(this.currentIndexes.slice(0)); }, this);
};
/**
* Select all of the competitors.
*/
CompetitorSelection.prototype.selectAll = function () {
this.currentIndexes = d3.range(this.count);
this.fireChangeHandlers();
};
/**
* Select none of the competitors.
*/
CompetitorSelection.prototype.selectNone = function () {
this.currentIndexes = [];
this.fireChangeHandlers();
};
/**
* Register a handler to be called whenever the list of indexes changes.
*
* When a change is made, this function will be called, with the array of
* indexes being the only argument. The array of indexes passed will be a
* copy of that stored internally, so the handler is free to store this
* array and/or modify it.
*
* If the handler has already been registered, nothing happens.
*
* @param {function} handler - The handler to register.
*/
CompetitorSelection.prototype.registerChangeHandler = function (handler) {
if (this.changeHandlers.indexOf(handler) === -1) {
this.changeHandlers.push(handler);
}
};
/**
* Unregister a handler from being called when the list of indexes changes.
*
* If the handler given was never registered, nothing happens.
*
* @param {function} handler - The handler to register.
*/
CompetitorSelection.prototype.deregisterChangeHandler = function (handler) {
var index = this.changeHandlers.indexOf(handler);
if (index > -1) {
this.changeHandlers.splice(index, 1);
}
};
/**
* Toggles whether the competitor at the given index is selected.
* @param {Number} index - The index of the competitor.
*/
CompetitorSelection.prototype.toggle = function (index) {
if (typeof index === NUMBER_TYPE) {
if (0 <= index && index < this.count) {
var position = this.currentIndexes.indexOf(index);
if (position === -1) {
this.currentIndexes.push(index);
this.currentIndexes.sort(d3.ascending);
} else {
this.currentIndexes.splice(position, 1);
}
this.fireChangeHandlers();
} else {
throwInvalidData("Index '" + index + "' is out of range");
}
} else {
throwInvalidData("Index is not a number");
}
};
/**
* Migrates the selected competitors from one list to another.
*
* After the migration, any competitors in the old list that were selected
* and are also in the new competitors list remain selected.
*
* @param {Array} oldCompetitors - Array of Competitor objects for the old
* selection. The length of this must match the current count of
* competitors.
* @param {Array} newCompetitors - Array of Competitor objects for the new
* selection. This array must not be empty.
*/
CompetitorSelection.prototype.migrate = function (oldCompetitors, newCompetitors) {
if (!$.isArray(oldCompetitors)) {
throwInvalidData("CompetitorSelection.migrate: oldCompetitors not an array");
} else if (!$.isArray(newCompetitors)) {
throwInvalidData("CompetitorSelection.migrate: newCompetitors not an array");
} else if (oldCompetitors.length !== this.count) {
throwInvalidData("CompetitorSelection.migrate: oldCompetitors list must have the same length as the current count");
} else if (newCompetitors.length === 0) {
throwInvalidData("CompetitorSelection.migrate: newCompetitors list must not be empty");
}
var selectedCompetitors = this.currentIndexes.map(function (index) { return oldCompetitors[index]; });
this.count = newCompetitors.length;
this.currentIndexes = [];
newCompetitors.forEach(function (comp, idx) {
if (selectedCompetitors.indexOf(comp) >= 0) {
this.currentIndexes.push(idx);
}
}, this);
this.fireChangeHandlers();
};
SplitsBrowser.Model.CompetitorSelection = CompetitorSelection;
})();
(function () {
"use strict";
var isTrue = SplitsBrowser.isTrue;
var throwInvalidData = SplitsBrowser.throwInvalidData;
var throwWrongFileFormat = SplitsBrowser.throwWrongFileFormat;
var parseTime = SplitsBrowser.parseTime;
var Competitor = SplitsBrowser.Model.Competitor;
var compareCompetitors = SplitsBrowser.Model.compareCompetitors;
var AgeClass = SplitsBrowser.Model.AgeClass;
var Course = SplitsBrowser.Model.Course;
var Event = SplitsBrowser.Model.Event;
/**
* Parse a row of competitor data.
* @param {Number} index - Index of the competitor line.
* @param {string} line - The line of competitor data read from a CSV file.
* @param {Number} controlCount - The number of controls (not including the finish).
* @return {Object} Competitor object representing the competitor data read in.
*/
function parseCompetitors(index, line, controlCount) {
// Expect forename, surname, club, start time then (controlCount + 1) split times in the form MM:SS.
var parts = line.split(",");
if (parts.length === controlCount + 5) {
var forename = parts.shift();
var surname = parts.shift();
var club = parts.shift();
var startTime = parts.shift()
if(startTime.match(/^\d+:\d\d:\d\d$/)){ // support for seconds is start times for CSV format
startTime = parseTime(startTime);
}else{
startTime = parseTime(startTime) * 60;
}
var splitTimes = parts.map(parseTime);
if (splitTimes.indexOf(0) >= 0) {
throwInvalidData("Zero split times are not permitted - found one or more zero splits for competitor '" + forename + " " + surname + "'");
}
return Competitor.fromSplitTimes(index + 1, forename + " " + surname, club, startTime, splitTimes);
} else {
throwInvalidData("Expected " + (controlCount + 5) + " items in row for competitor in class with " + controlCount + " controls, got " + (parts.length) + " instead.");
}
}
/**
* Parse CSV data for a class.
* @param {string} class - The string containing data for that class.
* @return {SplitsBrowser.Model.AgeClass} Parsed class data.
*/
function parseAgeClass (ageClass) {
var lines = ageClass.split(/\r?\n/).filter(isTrue);
if (lines.length === 0) {
throwInvalidData("parseAgeClass got an empty list of lines");
}
var firstLineParts = lines.shift().split(",");
if (firstLineParts.length === 2) {
var className = firstLineParts.shift();
var controlCountStr = firstLineParts.shift();
var controlCount = parseInt(controlCountStr, 10);
if (isNaN(controlCount)) {
throwInvalidData("Could not read control count: '" + controlCountStr + "'");
} else if (controlCount < 0) {
throwInvalidData("Expected a positive control count, got " + controlCount + " instead");
} else {
var competitors = lines.map(function (line, index) { return parseCompetitors(index, line, controlCount); });
competitors.sort(compareCompetitors);
return new AgeClass(className, controlCount, competitors);
}
} else {
throwWrongFileFormat("Expected first line to have two parts (class name and number of controls), got " + firstLineParts.length + " part(s) instead");
}
}
/**
* Parse CSV data for an entire event.
* @param {string} eventData - String containing the entire event data.
* @return {SplitsBrowser.Model.Event} All event data read in.
*/
function parseEventData (eventData) {
var classSections = eventData.split(/\r?\n\r?\n/).map($.trim).filter(isTrue);
var classes = classSections.map(parseAgeClass);
classes = classes.filter(function (ageClass) { return !ageClass.isEmpty(); });
if (classes.length === 0) {
throwInvalidData("No competitor data was found");
}
// Nulls are for the course length, climb and controls, which aren't in
// the source data files, so we can't do anything about them.
var courses = classes.map(function (cls) { return new Course(cls.name, [cls], null, null, null); });
for (var i = 0; i < classes.length; i += 1) {
classes[i].setCourse(courses[i]);
}
return new Event(classes, courses);
}
SplitsBrowser.Input.CSV = { parseEventData: parseEventData };
})();
(function () {
"use strict";
var throwInvalidData = SplitsBrowser.throwInvalidData;
var throwWrongFileFormat = SplitsBrowser.throwWrongFileFormat;
var parseCourseLength = SplitsBrowser.parseCourseLength;
var formatTime = SplitsBrowser.formatTime;
var parseTime = SplitsBrowser.parseTime;
var Competitor = SplitsBrowser.Model.Competitor;
var AgeClass = SplitsBrowser.Model.AgeClass;
var Course = SplitsBrowser.Model.Course;
var Event = SplitsBrowser.Model.Event;
// Indexes of the various columns relative to the column for control-1.
var COLUMN_OFFSETS = {
TIME: -35,
CLUB: -31,
AGE_CLASS: -28,
COURSE: -7,
DISTANCE: -6,
CLIMB: -5,
CONTROL_COUNT: -4,
PLACING: -3,
START: -2
};
// Minimum control offset.
var MIN_CONTROLS_OFFSET = 37;
/**
* Checks that two consecutive cumulative times are in strictly ascending
* order, and throws an exception if not. The previous time should not be
* null, but the next time may, and no exception will be thrown in this
* case.
* @param {Number} prevTime - The previous cumulative time, in seconds.
* @param {Number} nextTime - The next cumulative time, in seconds.
*/
function verifyCumulativeTimesInOrder(prevTime, nextTime) {
if (nextTime !== null && nextTime <= prevTime) {
throwInvalidData("Cumulative times must be strictly ascending: read " +
formatTime(prevTime) + " and " + formatTime(nextTime) +
" in that order");
}
}
/**
* Constructs an SI-format data reader.
*
* NOTE: The reader constructed can only be used to read data in once.
* @constructor
* @param {String} data - The SI data to read in.
*/
var Reader = function (data) {
this.data = data;
// Map that associates classes to all of the competitors running on
// that age class.
this.ageClasses = d3.map();
// Map that associates course names to length and climb values.
this.courseDetails = d3.map();
// Set of all pairs of classes and courses.
// (While it is common that one course may have multiple classes, it
// seems also that one class can be made up of multiple courses, e.g.
// M21E at BOC 2013.)
this.classCoursePairs = [];
// Whether any competitors have been read in at all. Blank lines are
// ignored, as are competitors that have no times at all.
this.anyCompetitors = false;
// The column index that contains the control numbers for control 1.
// This is used to determine where various columns are.
this.control1Index = null;
};
/**
* Checks that the data read in contains a header that suggests it is
* SI-format data.
*/
Reader.prototype.checkHeader = function() {
if (this.lines.length <= 1) {
throwWrongFileFormat("No data found to read");
}
var headers = this.lines[0].split(";");
if (headers.length <= 1) {
throwWrongFileFormat("Data appears not to be in the SI CSV format");
}
var firstLine = this.lines[1].split(";");
var endPos = firstLine.length - 1;
while (endPos > 0 && $.trim(firstLine[endPos]) === "") {
endPos -= 1;
}
// The last empty item should be the time.
var controlCodeColumn = endPos - 1;
var digitsOnly = /^\d+$/;
while (controlCodeColumn >= 2 && digitsOnly.test(firstLine[controlCodeColumn - 2])) {
// There's another control code before this one.
controlCodeColumn -= 2;
}
this.control1Index = controlCodeColumn;
var supportedControl1Indexes = [44, 46];
if (this.control1Index === null) {
throwInvalidData("Unable to find index of control 1 in SI CSV data");
} else if (supportedControl1Indexes.indexOf(this.control1Index) < 0) {
throwInvalidData("Unsupported index of control 1: " + this.control1Index);
}
};
/**
* Returns the number of controls to expect on the given line.
* @param {Array} row - Array of row data items.
* @param {Number} lineNumber - The line number of the line.
* @return {Number} Number of controls read.
*/
Reader.prototype.getNumControls = function (row, lineNumber) {
var className = row[this.control1Index + COLUMN_OFFSETS.AGE_CLASS];
if ($.trim(className) === "") {
throwInvalidData("Line " + lineNumber + " does not contain a class for the competitor");
} else if (this.ageClasses.has(className)) {
return this.ageClasses.get(className).numControls;
} else {
return parseInt(row[this.control1Index + COLUMN_OFFSETS.CONTROL_COUNT], 10);
}
};
/**
* Reads the split times out of a row of competitor data.
* @param {Array} row - Array of row data items.
* @param {Number} lineNumber - Line number of the row within the source data.
* @param {Number} numControls - The number of controls to read.
*/
Reader.prototype.readCumulativeTimes = function (row, lineNumber, numControls) {
var cumTimes = [0];
var lastCumTime = 0;
for (var controlIdx = 0; controlIdx < numControls; controlIdx += 1) {
var cellIndex = this.control1Index + 1 + 2 * controlIdx;
var cumTime = (cellIndex < row.length) ? parseTime(row[cellIndex]) : null;
verifyCumulativeTimesInOrder(lastCumTime, cumTime);
cumTimes.push(cumTime);
if (cumTime !== null) {
lastCumTime = cumTime;
}
}
var totalTime = parseTime(row[this.control1Index + COLUMN_OFFSETS.TIME]);
verifyCumulativeTimesInOrder(lastCumTime, totalTime);
cumTimes.push(totalTime);
return cumTimes;
};
/**
* Checks to see whether the given row contains a new age-class, and if so,
* creates it.
* @param {Array} row - Array of row data items.
* @param {Number} numControls - The number of controls to read.
*/
Reader.prototype.createAgeClassIfNecessary = function (row, numControls) {
var className = row[this.control1Index + COLUMN_OFFSETS.AGE_CLASS];
if (!this.ageClasses.has(className)) {
this.ageClasses.set(className, { numControls: numControls, competitors: [] });
}
};
/**
* Checks to see whether the given row contains a new course, and if so,
* creates it.
* @param {Array} row - Array of row data items.
* @param {Number} numControls - The number of controls to read.
*/
Reader.prototype.createCourseIfNecessary = function (row, numControls) {
var courseName = row[this.control1Index + COLUMN_OFFSETS.COURSE];
if (!this.courseDetails.has(courseName)) {
var controlNums = d3.range(0, numControls).map(function (controlIdx) { return row[this.control1Index + 2 * controlIdx]; }, this);
this.courseDetails.set(courseName, {
length: parseCourseLength(row[this.control1Index + COLUMN_OFFSETS.DISTANCE]) || null,
climb: parseInt(row[this.control1Index + COLUMN_OFFSETS.CLIMB], 10) || null,
controls: controlNums
});
}
};
/**
* Checks to see whether the given row contains a class-course pairing that
* we haven't seen so far, and adds one if not.
* @param {Array} row - Array of row data items.
*/
Reader.prototype.createClassCoursePairIfNecessary = function (row) {
var className = row[this.control1Index + COLUMN_OFFSETS.AGE_CLASS];
var courseName = row[this.control1Index + COLUMN_OFFSETS.COURSE];
if (!this.classCoursePairs.some(function (pair) { return pair[0] === className && pair[1] === courseName; })) {
this.classCoursePairs.push([className, courseName]);
}
};
/**
* Reads in the competitor-specific data from the given row and adds it to
* the event data read so far.
* @param {Array} row - Row of items read from a line of the input data.
* @param {Array} cumTimes - Array of cumulative times for the competitor.
*/
Reader.prototype.addCompetitor = function (row, cumTimes) {
var className = row[this.control1Index + COLUMN_OFFSETS.AGE_CLASS];
var placing = row[this.control1Index + COLUMN_OFFSETS.PLACING];
var club = row[this.control1Index + COLUMN_OFFSETS.CLUB];
var startTime = parseTime(row[this.control1Index + COLUMN_OFFSETS.START]);
var isPlacingNonNumeric = (placing !== "" && isNaN(parseInt(placing, 10)));
var name;
if (this.control1Index === 46) {
var forename = row[4];
var surname = row[3];
// Some surnames have their placing appended to them, if their placing
// isn't a number (e.g. mp, n/c). If so, remove this.
if (isPlacingNonNumeric && surname.substring(surname.length - placing.length) === placing) {
surname = $.trim(surname.substring(0, surname.length - placing.length));
}
name = forename + " " + surname;
} else if (this.control1Index === 44) {
name = row[3];
} else {
// Reader should have thrown an error elsewhere if this has happened.
throw new Error("Unrecognised control-1 index: " + this.control1Index);
}
var order = this.ageClasses.get(className).competitors.length + 1;
var competitor = Competitor.fromCumTimes(order, name, club, startTime, cumTimes);
if (isPlacingNonNumeric && competitor.completed()) {
// Competitor has completed the course but has no placing.
// Assume that they are non-competitive.
competitor.setNonCompetitive();
}
this.ageClasses.get(className).competitors.push(competitor);
};
/**
* Parses the given line and adds it to the event data accumulated so far.
* @param {String} line - The line to parse.
* @param {Number} lineNumber - The number of the line (used in error
* messages).
*/
Reader.prototype.readLine = function (line, lineNumber) {
if ($.trim(line) === "") {
// Skip this blank line.
return;
}
var row = line.split(";");
// Check the row is long enough to have all the data besides the
// controls data.
if (row.length < MIN_CONTROLS_OFFSET) {
throwInvalidData("Too few items on line " + lineNumber + " of the input file: expected at least " + MIN_CONTROLS_OFFSET + ", got " + row.length);
}
var numControls = this.getNumControls(row, lineNumber);
var cumTimes = this.readCumulativeTimes(row, lineNumber, numControls);
this.anyCompetitors = true;
this.createAgeClassIfNecessary(row, numControls);
this.createCourseIfNecessary(row, numControls);
this.createClassCoursePairIfNecessary(row);
this.addCompetitor(row, cumTimes);
};
/**
* Creates maps that describe the many-to-many join between the class names
* and course names.
* @return {Object} Object that contains two maps describing the
* many-to-many join.
*/
Reader.prototype.getMapsBetweenClassesAndCourses = function () {
var classesToCourses = d3.map();
var coursesToClasses = d3.map();
this.classCoursePairs.forEach(function (pair) {
var className = pair[0];
var courseName = pair[1];
if (classesToCourses.has(className)) {
classesToCourses.get(className).push(courseName);
} else {
classesToCourses.set(className, [courseName]);
}
if (coursesToClasses.has(courseName)) {
coursesToClasses.get(courseName).push(className);
} else {
coursesToClasses.set(courseName, [className]);
}
});
return {classesToCourses: classesToCourses, coursesToClasses: coursesToClasses};
};
/**
* Creates and return a list of AgeClass objects from all of the data read.
* @return {Array} Array of AgeClass objects.
*/
Reader.prototype.createAgeClasses = function () {
var classNames = this.ageClasses.keys();
classNames.sort();
return classNames.map(function (className) {
var ageClass = this.ageClasses.get(className);
return new AgeClass(className, ageClass.numControls, ageClass.competitors);
}, this);
};
/**
* Find all of the courses and classes that are related to the given course.
*
* It's not always as simple as one course having multiple classes, as there
* can be multiple courses for one single class, and even multiple courses
* among multiple classes (e.g. M20E, M18E on courses 3, 3B at BOC 2013.)
* Essentially, we have a many-to-many join, and we want to pull out of that
* all of the classes and courses linked to the one course with the given
* name.
*
* (For the graph theorists among you, imagine the bipartite graph with
* classes on one side and courses on the other. We want to find the
* connected subgraph that this course belongs to.)
*
* @param {String} initCourseName - The name of the initial course.
* @param {Object} manyToManyMaps - Object that contains the two maps that
* map between class names and course names.
* @param {d3.set} doneCourseNames - Set of all course names that have been
* 'done', i.e. included in a Course object that has been returned from
* a call to this method.
* @param {d3.map} classesMap - Map that maps age-class names to AgeClass
* objects.
* @return {SplitsBrowser.Model.Course} - The created Course object.
*/
Reader.prototype.createCourseFromLinkedClassesAndCourses = function (initCourseName, manyToManyMaps, doneCourseNames, classesMap) {
var courseNamesToDo = [initCourseName];
var classNamesToDo = [];
var relatedCourseNames = [];
var relatedClassNames = [];
var courseName;
var className;
while (courseNamesToDo.length > 0 || classNamesToDo.length > 0) {
while (courseNamesToDo.length > 0) {
courseName = courseNamesToDo.shift();
var classNames = manyToManyMaps.coursesToClasses.get(courseName);
for (var clsIdx = 0; clsIdx < classNames.length; clsIdx += 1) {
className = classNames[clsIdx];
if (classNamesToDo.indexOf(className) < 0 && relatedClassNames.indexOf(className) < 0) {
classNamesToDo.push(className);
}
}
relatedCourseNames.push(courseName);
}
while (classNamesToDo.length > 0) {
className = classNamesToDo.shift();
var courseNames = manyToManyMaps.classesToCourses.get(className);
for (var crsIdx = 0; crsIdx < courseNames.length; crsIdx += 1) {
courseName = courseNames[crsIdx];
if (courseNamesToDo.indexOf(courseName) < 0 && relatedCourseNames.indexOf(courseName) < 0) {
courseNamesToDo.push(courseName);
}
}
relatedClassNames.push(className);
}
}
// Mark all of the courses that we handled here as done.
relatedCourseNames.forEach(function (courseName) {
doneCourseNames.add(courseName);
});
var courseClasses = relatedClassNames.map(function (className) { return classesMap.get(className); });
var details = this.courseDetails.get(initCourseName);
var course = new Course(initCourseName, courseClasses, details.length, details.climb, details.controls);
courseClasses.forEach(function (ageClass) {
ageClass.setCourse(course);
});
return course;
};
/**
* Sort through the data read in and create Course objects representing each
* course in the event.
* @param {Array} classes - Array of AgeClass objects read.
* @return {Array} Array of course objects.
*/
Reader.prototype.determineCourses = function (classes) {
var manyToManyMaps = this.getMapsBetweenClassesAndCourses();
// As we work our way through the courses and classes, we may find one
// class made up from multiple courses (e.g. in BOC2013, class M21E
// uses course 1A and 1B). In this set we collect up all of the
// courses that we have now processed, so that if we later come across
// one we've already dealt with, we can ignore it.
var doneCourseNames = d3.set();
var classesMap = d3.map();
classes.forEach(function (ageClass) {
classesMap.set(ageClass.name, ageClass);
});
// List of all Course objects created so far.
var courses = [];
manyToManyMaps.coursesToClasses.keys().forEach(function (courseName) {
if (!doneCourseNames.has(courseName)) {
var course = this.createCourseFromLinkedClassesAndCourses(courseName, manyToManyMaps, doneCourseNames, classesMap);
courses.push(course);
}
}, this);
return courses;
};
/**
* Parses the read-in data and returns it.
* @return {SplitsBrowser.Model.Event} Event-data read.
*/
Reader.prototype.parseEventData = function () {
this.lines = this.data.split(/\r?\n/);
this.checkHeader();
// Discard the header row.
this.lines.shift();
this.lines.forEach(function (line, lineIndex) {
this.readLine(line, lineIndex + 1);
}, this);
if (!this.anyCompetitors) {
throwInvalidData("No competitors' data were found");
}
var classes = this.createAgeClasses();
var courses = this.determineCourses(classes);
return new Event(classes, courses);
};
SplitsBrowser.Input.SI = {};
/**
* Parse 'SI' data read from a semicolon-separated data string.
* @param {String} data - The input data string read.
* @return {SplitsBrowser.Model.Event} All event data read.
*/
SplitsBrowser.Input.SI.parseEventData = function (data) {
var reader = new Reader(data);
return reader.parseEventData();
};
})();
(function () {
"use strict";
var isNotNull = SplitsBrowser.isNotNull;
var throwInvalidData = SplitsBrowser.throwInvalidData;
var throwWrongFileFormat = SplitsBrowser.throwWrongFileFormat;
var parseCourseLength = SplitsBrowser.parseCourseLength;
var formatTime = SplitsBrowser.formatTime;
var parseTime = SplitsBrowser.parseTime;
var Competitor = SplitsBrowser.Model.Competitor;
var AgeClass = SplitsBrowser.Model.AgeClass;
var Course = SplitsBrowser.Model.Course;
var Event = SplitsBrowser.Model.Event;
// Regexps to help with parsing.
var HTML_TAG_STRIP_REGEXP = /<[^>]+>/g;
var DISTANCE_FIND_REGEXP = /([0-9.,]+)\s*(?:Km|km)/;
var CLIMB_FIND_REGEXP = /(\d+)\s*(?:Cm|Hm|hm|m)/;
/**
* Returns whether the given string is nonempty.
* @param {String} string - The string to check.
* @return True if the string is neither null nor empty, false if it is null
* or empty.
*/
function isNonEmpty(string) {
return string !== null && string !== "";
}
/**
* Returns whether the given string contains a number. The string is
* considered to contain a number if, after stripping whitespace, the string
* is not empty and calling isFinite on it returns true.
* @param {String} string - The string to test.
* @return True if the string contains a number, false if not.
*/
function hasNumber(string) {
string = $.trim(string);
// isFinite is not enough on its own: isFinite("") is true.
return string !== "" && isFinite(string);
}
/**
* Splits a line by whitespace.
* @param {String} line - The line to split.
* @return {Array} Array of whitespace-separated strings.
*/
function splitByWhitespace (line) {
return line.split(/\s+/g).filter(isNonEmpty);
}
/**
* Strips all HTML tags from a string and returns the remaining string.
* @param {String} text - The HTML string to strip tags from.
* @return {String} The input string with HTML tags removed.
*/
function stripHtml(text) {
return text.replace(HTML_TAG_STRIP_REGEXP, "");
}
/**
* Returns all matches of the given regexp within the given text,
* after being stripped of HTML.
*
* Note that it is recommended to pass this function a new regular
* expression each time, rather than using a precompiled regexp.
*
* @param {RegExp} regexp - The regular expression to find all matches of.
* @param {String} text - The text to search for matches within.
* @return {Array} Array of strings representing the HTML-stripped regexp
* matches.
*/
function getHtmlStrippedRegexMatches(regexp, text) {
var matches = [];
var match;
while (true) {
match = regexp.exec(text);
if (match === null) {
break;
} else {
matches.push(stripHtml(match[1]));
}
}
return matches;
}
/**
* Returns the contents of all ... elements within the given
* text. The contents of the elements are stripped of all other HTML
* tags.
* @param {String} text - The HTML string containing the elements.
* @return {Array} Array of strings of text inside elements.
*/
function getFontBits(text) {
return getHtmlStrippedRegexMatches(/]*>(.*?)<\/font>/g, text);
}
/**
* Returns the contents of all
... | elements within the given
* text. The contents of the elements are stripped of all other HTML
* tags.
* @param {String} text - The HTML string containing the | elements.
* @return {Array} Array of strings of text inside | elements.
*/
function getTableDataBits(text) {
return getHtmlStrippedRegexMatches(/ | ]*>(.*?)<\/td>/g, text).map($.trim);
}
/**
* Returns the contents of all | ... | elements within the given
* text. The contents of the elements are stripped of all other HTML
* tags. Empty matches are removed.
* @param {String} text - The HTML string containing the | elements.
* @return {Array} Array of strings of text inside | elements.
*/
function getNonEmptyTableDataBits(text) {
return getTableDataBits(text).filter(function (bit) { return bit !== ""; });
}
/**
* Returns the contents of all | ... | elements within the given
* text. The contents of the elements are stripped of all other HTML
* tags. Empty matches are removed.
* @param {String} text - The HTML string containing the | elements.
* @return {Array} Array of strings of text inside | elements.
*/
function getNonEmptyTableHeaderBits(text) {
var matches = getHtmlStrippedRegexMatches(/ | ]*>(.*?)<\/th>/g, text);
return matches.filter(function (bit) { return bit !== ""; });
}
/**
* Attempts to read a course distance from the given string.
* @param {String} text - The text string to read a course distance from.
* @return {Number|null} - The parsed course distance, or null if no
* distance could be parsed.
*/
function tryReadDistance(text) {
var distanceMatch = DISTANCE_FIND_REGEXP.exec(text);
if (distanceMatch === null) {
return null;
} else {
return parseCourseLength(distanceMatch[1]);
}
}
/**
* Attempts to read a course climb from the given string.
* @param {String} text - The text string to read a course climb from.
* @return {Number|null} - The parsed course climb, or null if no climb
* could be parsed.
*/
function tryReadClimb(text) {
var climbMatch = CLIMB_FIND_REGEXP.exec(text);
if (climbMatch === null) {
return null;
} else {
return parseInt(climbMatch[1], 10);
}
}
/**
* Reads control codes from an array of strings. Each code should be of the
* form num(code), with the exception of the finish, which, if it appears,
* should contain no parentheses and must be the last. The finish is
* returned as null.
* @param {Array} labels - Array of string labels.
* @return {Array} Array of control codes, with null indicating the finish.
*/
function readControlCodes(labels) {
var controlCodes = [];
for (var labelIdx = 0; labelIdx < labels.length; labelIdx += 1) {
var label = labels[labelIdx];
var parenPos = label.indexOf("(");
if (parenPos > -1 && label[label.length - 1] === ")") {
var controlCode = label.substring(parenPos + 1, label.length - 1);
controlCodes.push(controlCode);
} else if (labelIdx + 1 === labels.length) {
controlCodes.push(null);
} else {
throwInvalidData("Unrecognised control header label: '" + label + "'");
}
}
return controlCodes;
}
/**
* Removes from the given arrays of cumulative and split times any 'extra'
* controls.
*
* An 'extra' control is a control that a competitor punches without it
* being a control on their course. Extra controls are indicated by the
* split 'time' beginning with an asterisk.
*
* This method does not return anything, instead it mutates the arrays
* given.
*
* @param {Array} cumTimes - Array of cumulative times.
* @param {Array} splitTimes - Array of split times.
*/
function removeExtraControls(cumTimes, splitTimes) {
while (splitTimes.length > 0 && splitTimes[splitTimes.length - 1][0] === "*") {
splitTimes.splice(splitTimes.length - 1, 1);
cumTimes.splice(cumTimes.length - 1, 1);
}
}
/**
* Represents the result of parsing lines of competitor data. This can
* represent intermediate data as well as complete data.
* @constructor
* @param {String} name - The name of the competitor.
* @param {String} club - The name of the competitor's club.
* @param {String} className - The class of the competitor.
* @param {Number|null} - The total time taken by the competitor, or null
* for no total time.
* @param {Array} cumTimes - Array of cumulative split times.
* @param {boolean} competitive - Whether the competitor's run is competitive.
*/
var CompetitorParseRecord = function (name, club, className, totalTime, cumTimes, competitive) {
this.name = name;
this.club = club;
this.className = className;
this.totalTime = totalTime;
this.cumTimes = cumTimes;
this.competitive = competitive;
};
/**
* Returns whether this competitor record is a 'continuation' record.
* A continuation record is one that has no name, club, class name or total
* time. Instead it represents the data read from lines of data other than
* the first two.
* @return {boolean} True if the record is a continuation record, false if not.
*/
CompetitorParseRecord.prototype.isContinuation = function () {
return (this.name === "" && this.club === "" && this.className === null && this.totalTime === "" && !this.competitive);
};
/**
* Appends the cumulative split times in another CompetitorParseRecord to
* this one. The one given must be a 'continuation' record.
* @param {CompetitorParseRecord} other - The record whose cumulative times
* we wish to append.
*/
CompetitorParseRecord.prototype.append = function (other) {
if (other.isContinuation()) {
this.cumTimes = this.cumTimes.concat(other.cumTimes);
} else {
throw new Error("Can only append a continuation CompetitorParseRecord");
}
};
/**
* Creates a Competitor object from this CompetitorParseRecord object.
* @param {Number} order - The number of this competitor within their class
* (1=first, 2=second, ...).
* @return {Competitor} Converted competitor object.
*/
CompetitorParseRecord.prototype.toCompetitor = function (order) {
var lastCumTime = 0;
this.cumTimes.forEach(function (cumTime) {
if (cumTime !== null) {
if (cumTime <= lastCumTime) {
throwInvalidData("Cumulative times must be strictly ascending: read " +
formatTime(lastCumTime) + " and " + formatTime(cumTime) +
" in that order");
}
lastCumTime = cumTime;
}
});
// Prepend a zero cumulative time.
var cumTimes = [0].concat(this.cumTimes);
// The null is for the start time.
var competitor = Competitor.fromCumTimes(order, this.name, this.club, null, cumTimes);
if (competitor.completed() && !this.competitive) {
competitor.setNonCompetitive();
}
return competitor;
};
/*
* There are two types of HTML format supported by this parser: one that is
* based on pre-formatted text, and one that uses HTML tables. The overall
* strategy when parsing either format is largely the same, but the exact
* details vary.
*
* A 'Recognizer' is used to handle the finer details of the format parsing.
* A recognizer should contain methods 'isTextOfThisFormat',
* 'preprocess', 'canIgnoreThisLine', 'isCourseHeaderLine',
* 'parseCourseHeaderLine', 'parseControlsLine' and 'parseCompetitor'.
* See the documentation on the objects below for more information about
* what these methods do.
*/
/**
* A Recognizer that handles the 'older' HTML format based on preformatted
* text.
* @constructor
*/
var OldHtmlFormatRecognizer = function () {
// Intentionally empty.
};
/**
* Returns whether this recognizer is likely to recognize the given HTML
* text and possibly be able to parse it. If this method returns true, the
* parser will use this recognizer to attempt to parse the HTML. If it
* returns false, the parser will not use this recognizer. Other methods on
* this object can therefore assume that this method has returned true.
*
* As this recognizer is for recognizing preformatted text, it simply checks
* for the presence of an HTML <pre> tag.
*
* @param {String} text - The entire input text read in.
* @return {boolean} True if the text contains any pre-formatted HTML, false
* otherwise
*/
OldHtmlFormatRecognizer.prototype.isTextOfThisFormat = function (text) {
return (text.indexOf("") >= 0);
};
/**
* Performs some pre-processing on the text before it is read in.
*
* This object strips everything up to and including the opening
* <pre> tag, and everything from the closing </pre> tag
* to the end of the text.
*
* @param {String} text - The HTML text to preprocess.
* @return {String} The preprocessed text.
*/
OldHtmlFormatRecognizer.prototype.preprocess = function (text) {
var prePos = text.indexOf("");
if (prePos === -1) {
throw new Error("Cannot find opening pre tag");
}
var lineEndPos = text.indexOf("\n", prePos);
text = text.substring(lineEndPos + 1);
var closePrePos = text.lastIndexOf(" ");
if (closePrePos === -1) {
throwInvalidData("Found opening but no closing ");
}
lineEndPos = text.lastIndexOf("\n", closePrePos);
text = text.substring(0, lineEndPos);
return $.trim(text);
};
/**
* Returns whether the HTML parser can ignore the given line altogether.
*
* The parser will call this method with every line read in, apart from
* the second line of each pair of competitor data rows. These are always
* assumed to be in pairs.
*
* This recognizer ignores only blank lines.
*
* @param {String} line - The line to check.
* @return {boolean} True if the line should be ignored, false if not.
*/
OldHtmlFormatRecognizer.prototype.canIgnoreThisLine = function (line) {
return line === "";
};
/**
* Returns whether the given line is the first line of a course.
*
* If so, it means the parser has finished processing the previous course
* (if any), and can start a new course.
*
* This recognizer treats a line with exactly two
* <font>...</font> elements as a course header line, and
* anything else not.
*
* @param {String} line - The line to check.
* @return {boolean} True if this is the first line of a course, false
* otherwise.
*/
OldHtmlFormatRecognizer.prototype.isCourseHeaderLine = function (line) {
return (getFontBits(line).length === 2);
};
/**
* Parse a course header line and return the course name, distance and
* climb.
*
* This method can assume that the line given is a course header line.
*
* @param {String} line - The line to parse course details from.
* @return {Object} Object containing the parsed course details.
*/
OldHtmlFormatRecognizer.prototype.parseCourseHeaderLine = function (line) {
var bits = getFontBits(line);
if (bits.length !== 2) {
throw new Error("Course header line should have two parts");
}
var nameAndControls = bits[0];
var distanceAndClimb = bits[1];
var openParenPos = nameAndControls.indexOf("(");
var courseName = (openParenPos > -1) ? nameAndControls.substring(0, openParenPos) : nameAndControls;
var distance = tryReadDistance(distanceAndClimb);
var climb = tryReadClimb(distanceAndClimb);
return {
name: $.trim(courseName),
distance: distance,
climb: climb
};
};
/**
* Parse control codes from the given line and return a list of them.
*
* This method can assume that the previous line was the course header or a
* previous control line. It should also return null for the finish, which
* should have no code. The finish is assumed to he the last.
*
* @param {String} line - The line to parse control codes from.
* @return {Array} Array of control codes.
*/
OldHtmlFormatRecognizer.prototype.parseControlsLine = function (line) {
var lastFontPos = line.lastIndexOf("");
var controlsText = (lastFontPos === -1) ? line : line.substring(lastFontPos + "".length);
var controlLabels = splitByWhitespace($.trim(controlsText));
return readControlCodes(controlLabels);
};
/**
* Read either cumulative or split times from the given line of competitor
* data.
* (This method is not used by the parser, only elsewhere in the recognizer.)
* @param {String} line - The line to read the times from.
* @return {Array} Array of times.
*/
OldHtmlFormatRecognizer.prototype.readCompetitorSplitDataLine = function (line) {
for (var i = 0; i < 4; i += 1) {
var closeFontPos = line.indexOf("");
line = line.substring(closeFontPos + "".length);
}
var times = splitByWhitespace(stripHtml(line));
return times;
};
/**
* Parse two lines of competitor data into a CompetitorParseRecord object
* containing the data.
* @param {String} firstLine - The first line of competitor data.
* @param {String} secondLine - The second line of competitor data.
* @return {CompetitorParseRecord} The parsed competitor.
*/
OldHtmlFormatRecognizer.prototype.parseCompetitor = function (firstLine, secondLine) {
var firstLineBits = getFontBits(firstLine);
var secondLineBits = getFontBits(secondLine);
var competitive = hasNumber(firstLineBits[0]);
var name = $.trim(firstLineBits[2]);
var totalTime = $.trim(firstLineBits[3]);
var club = $.trim(secondLineBits[2]);
var cumulativeTimes = this.readCompetitorSplitDataLine(firstLine);
var splitTimes = this.readCompetitorSplitDataLine(secondLine);
cumulativeTimes = cumulativeTimes.map(parseTime);
var nonZeroCumTimeCount = cumulativeTimes.filter(isNotNull).length;
if (nonZeroCumTimeCount !== splitTimes.length) {
throwInvalidData("Cumulative and split times do not have the same length: " + nonZeroCumTimeCount + " cumulative times, " + splitTimes.length + " split times");
}
var className = null;
if (name !== null && name !== "") {
var lastCloseFontPos = -1;
for (var i = 0; i < 4; i += 1) {
lastCloseFontPos = firstLine.indexOf("", lastCloseFontPos + 1);
}
var firstLineUpToFourth = firstLine.substring(0, lastCloseFontPos + "".length);
var firstLineMinusFonts = firstLineUpToFourth.replace(/]*>(.*?)<\/font>/g, "");
var lineParts = splitByWhitespace(firstLineMinusFonts);
if (lineParts.length > 0) {
className = lineParts[0];
}
}
removeExtraControls(cumulativeTimes, splitTimes);
return new CompetitorParseRecord(name, club, className, totalTime, cumulativeTimes, competitive);
};
/**
* Constructs a recognizer for formatting the 'newer' format of SI HTML
* event results data.
*
* Data in this format is given within a number of HTML tables, three per
* course.
* @constructor
*/
var NewHtmlFormatRecognizer = function () {
this.currentCourseHasClass = false;
};
/**
* Returns whether this recognizer is likely to recognize the given HTML
* text and possibly be able to parse it. If this method returns true, the
* parser will use this recognizer to attempt to parse the HTML. If it
* returns false, the parser will not use this recognizer. Other methods on
* this object can therefore assume that this method has returned true.
*
* As this recognizer is for recognizing HTML formatted in tables, it
* returns whether the number of HTML <table> tags is at least five.
* Each course uses three tables, and there are two HTML tables before the
* courses.
*
* @param {String} text - The entire input text read in.
* @return {boolean} True if the text contains at least five HTML table
* tags.
*/
NewHtmlFormatRecognizer.prototype.isTextOfThisFormat = function (text) {
var tablePos = -1;
for (var i = 0; i < 5; i += 1) {
tablePos = text.indexOf(" it is contained in.
var tableEndPos = text.indexOf(" ");
if (tableEndPos === -1) {
throwInvalidData("Could not find any closing tags");
}
text = text.substring(tableEndPos + "".length);
var closeDivPos = text.indexOf("");
var openTablePos = text.indexOf(" -1 && closeDivPos < openTablePos) {
text = text.substring(closeDivPos + "".length);
}
// Rejig the line endings so that each row of competitor data is on its
// own line, with table and table-row tags starting on new lines,
// and closing table and table-row tags at the end of lines.
text = text.replace(/>\n<").replace(/>/g, ">\n ").replace(/<\/tr>\n<")
.replace(/>\n\n<");
// Remove all elements.
text = text.replace(/<\/col[^>]*>/g, "");
// Remove all rows that contain only a single non-breaking space.
// In the file I have, the entities are missing their
// semicolons. However, this could well be fixed in the future.
text = text.replace(/]*>]*>(?:)? ?(?:<\/nobr>)?<\/td><\/tr>/g, "");
// Finally, remove the trailing | |