igc-xc-score
Version:
igc-xc-score is a paragliding and hang-gliding XC scoring program in vanilla JS
470 lines (402 loc) • 18.4 kB
JavaScript
'use strict';
import { Box, Point } from './foundation.js';
import * as geom from './geom.js';
export function closingPenalty(cd, opt) {
return opt.scoring.rounding(
(cd > (opt.scoring.closingDistanceFree || 0) ? cd : 0));
}
export function closingWithLimit(distance, opt) {
return opt.scoring.rounding(
Math.max(opt.scoring.closingDistanceFixed || 0,
distance * (opt.scoring.closingDistanceRelative || 0)));
}
/*eslint no-unused-vars: ["error", { "args": "none" }]*/
export function closingWithPenalty(distance, opt) {
/* c8 ignore next */
return Infinity;
}
export function finalRounding(v, opt) {
if (opt.scoring.finalRounding)
return opt.scoring.finalRounding(v);
if (opt.scoring.rounding)
return opt.scoring.rounding(v);
return v;
}
// Upper limit for a 3TP distance flight with 3 TPs in boxes
export function boundDistance3Points(ranges, boxes, opt) {
const pin = geom.findFurthestPointInSegment(opt.launch, ranges[0].start, boxes[0], opt);
const pout = geom.findFurthestPointInSegment(ranges[2].end, opt.landing, boxes[2], opt);
const maxDistance = opt.scoring.rounding(
geom.maxDistanceNRectangles([pin, boxes[0], boxes[1], boxes[2], pout]));
if (maxDistance < (opt.scoring.minDistance || 0))
return 0;
return finalRounding(maxDistance * opt.scoring.multiplier, opt);
}
// Score of a 3TP distance flight with all 3 points selected
export function scoreDistance3Points(tp, opt) {
let distance = 0;
const pin = geom.findFurthestPointInSegment(opt.launch, tp[0].r, tp[0], opt);
const pout = geom.findFurthestPointInSegment(tp[2].r, opt.landing, tp[2], opt);
const all = [pin, tp[0], tp[1], tp[2], pout];
const legs = [
{name: 'START : TP1'},
{name: 'TP1 : TP2'},
{name: 'TP2 : TP3'},
{name: 'TP3 : FINISH'}
];
for (let i = 0; i < all.length - 1; i++) {
legs[i].d = opt.scoring.rounding(all[i].distanceEarth(all[i + 1]));
distance += legs[i].d;
legs[i].start = all[i];
legs[i].finish = all[i+1];
}
distance = finalRounding(distance, opt);
const score = distance >= (opt.scoring.minDistance || 0) ?
finalRounding(distance * opt.scoring.multiplier, opt) :
0;
return { distance, score, tp: tp, ep: { start: pin, finish: pout }, legs };
}
// Upper limit for a FAI triangle with vertices somewhere in boxes,
// maxTriDistance is the upper limit of the flat triangle
function maxFAIDistance(maxTriDistance, boxes, opt) {
// smallest triangle with we could possibly have for these boxes
const minTriDistance = geom.minDistance3Rectangles(boxes, (i, j, k) => {
return opt.scoring.rounding(i.distanceEarth(j)) +
opt.scoring.rounding(j.distanceEarth(k)) +
opt.scoring.rounding(k.distanceEarth(i));
});
if (maxTriDistance < minTriDistance)
return 0;
// biggest possible leg for each side
const maxAB = opt.scoring.rounding(geom.maxDistance2Rectangles([boxes[0], boxes[1]]));
const maxBC = opt.scoring.rounding(geom.maxDistance2Rectangles([boxes[1], boxes[2]]));
const maxCA = opt.scoring.rounding(geom.maxDistance2Rectangles([boxes[2], boxes[0]]));
// our FAI triangle is limited to maxDistance
const maxDistance = opt.scoring.rounding(Math.min(maxAB, maxBC, maxCA) / opt.scoring.minSide);
// Is the maximum FAI triangle smaller than the minimum possible triangle?
if (maxDistance < minTriDistance)
return 0;
return Math.min(maxDistance, maxTriDistance);
}
// Upper limit for a flat triangle /w maxSide with vertices somewhere in boxes,
// maxTriDistance is the upper limit of the unconstrained flat triangle
function maxTRIDistance(maxTriDistance, boxes, opt) {
// smallest possible leg for each side
const minAB = opt.scoring.rounding(geom.minDistance2Rectangles([boxes[0], boxes[1]]));
const minBC = opt.scoring.rounding(geom.minDistance2Rectangles([boxes[1], boxes[2]]));
const minCA = opt.scoring.rounding(geom.minDistance2Rectangles([boxes[2], boxes[0]]));
// our constrained triangle cannot be smaller then minDistance
const minDistance = opt.scoring.rounding(Math.max(minAB, minBC, minCA) / opt.scoring.maxSide);
// Is the minimum constrained triangle bigger than the maximum possible triangle?
if (minDistance > maxTriDistance)
return 0;
return maxTriDistance;
}
// These are not used by any scoring method at the moment
/* c8 ignore start */
export function boundOpenTriangle(ranges, boxes, opt) {
const pin = geom.findFurthestPointInSegment(opt.launch, ranges[0].start, boxes[0], opt);
const pout = geom.findFurthestPointInSegment(ranges[2].end, opt.landing, boxes[2], opt);
const maxD3PDistance = opt.scoring.rounding(geom.maxDistanceNRectangles([pin, boxes[0], boxes[1], boxes[2], pout]));
const maxTriDistance = geom.maxDistance3Rectangles(boxes, (i, j, k) => {
return opt.scoring.rounding(i.distanceEarth(j)) +
opt.scoring.rounding(j.distanceEarth(k)) +
opt.scoring.rounding(k.distanceEarth(i));
});
if (maxTriDistance < (opt.scoring.minDistance || 0))
return 0;
if (opt.scoring.minSide !== undefined) {
if (maxFAIDistance(maxTriDistance, boxes, opt) === 0)
return 0;
}
if (opt.scoring.maxSide !== undefined) {
if (maxTRIDistance(maxTriDistance, boxes, opt) === 0)
return 0;
}
let cp = { d: 0 };
if (ranges[0].end < ranges[2].start) {
cp = geom.isTriangleClosed(ranges[0].end, ranges[2].start, maxTriDistance, opt);
if (!cp)
return 0;
return finalRounding((maxD3PDistance - closingPenalty(cp.d, opt)) * opt.scoring.multiplier, opt);
}
return finalRounding(maxD3PDistance * opt.scoring.multiplier, opt);
}
export function scoreOpenTriangle(tp, opt) {
const d0 = opt.scoring.rounding(tp[0].distanceEarth(tp[1]));
const d1 = opt.scoring.rounding(tp[1].distanceEarth(tp[2]));
const d2 = opt.scoring.rounding(tp[2].distanceEarth(tp[0]));
const triDistance = d0 + d1 + d2;
if (triDistance < (opt.scoring.minDistance || 0))
return { score: 0 };
if (opt.scoring.minSide !== undefined) {
const minSide = opt.scoring.minSide * triDistance;
if (d0 < minSide || d1 < minSide || d2 < minSide)
return { score: 0 };
}
let cp = geom.isTriangleClosed(tp[0].r, tp[2].r, triDistance, opt);
if (!cp)
return { score: 0 };
const pin = geom.findFurthestPointInSegment(opt.launch, tp[0].r, tp[0], opt);
const pout = geom.findFurthestPointInSegment(tp[2].r, opt.landing, tp[2], opt);
const all = [pin, tp[0], tp[1], tp[2], pout];
const legs = [
{name: 'START : TP1'},
{name: 'TP1 : TP2'},
{name: 'TP2 : TP3'},
{name: 'TP3 : FINISH'}
];
let distance = 0;
for (let i = 0; i < all.length - 1; i++) {
legs[i].d = opt.scoring.rounding(all[i].distanceEarth(all[i + 1]));
distance += legs[i].d;
legs[i].start = all[i];
legs[i].finish = all[i+1];
}
distance = finalRounding(distance, opt);
const score = finalRounding(distance * opt.scoring.multiplier, opt) - closingPenalty(cp.d, opt);
return { distance, score, tp: tp, ep: { start: pin, finish: pout }, cp, legs };
}
/* c8 ignore stop */
// Upper limit for a flat triangle with vertices somewhere in boxes
export function boundTriangle(ranges, boxes, opt) {
const maxTriDistance = geom.maxDistance3Rectangles(boxes, (i, j, k) => {
return opt.scoring.rounding(i.distanceEarth(j)) +
opt.scoring.rounding(j.distanceEarth(k)) +
opt.scoring.rounding(k.distanceEarth(i));
});
if (maxTriDistance < (opt.scoring.minDistance || 0))
return 0;
let maxDistance = maxTriDistance;
if (opt.scoring.minSide !== undefined)
maxDistance = maxFAIDistance(maxDistance, boxes, opt);
if (opt.scoring.maxSide !== undefined)
maxDistance = maxTRIDistance(maxDistance, boxes, opt);
if (maxDistance === 0)
return 0;
let cp = { d: 0 };
if (ranges[0].end < ranges[2].start) {
// Ranges do not overlap
cp = geom.isTriangleClosed(ranges[0].end, ranges[2].start, maxDistance, opt);
if (!cp)
return 0;
return finalRounding((maxDistance - closingPenalty(cp.d, opt)) * opt.scoring.multiplier, opt);
}
// Ranges overlap - bounding is impossible at this stage
return finalRounding(maxDistance * opt.scoring.multiplier, opt);
}
// Score a triangle once all 3 points have been selected
export function scoreTriangle(tp, opt) {
let distance = 0;
const legs = [
{name: 'TP1 : TP2'},
{name: 'TP2 : TP3'},
{name: 'TP3 : TP1'}
];
for (let i = 0; i < tp.length; i++) {
legs[i].d = opt.scoring.rounding(tp[i].distanceEarth(tp[(i + 1) % tp.length]));
distance += legs[i].d;
legs[i].start = tp[i];
legs[i].finish = tp[(i + 1) % tp.length];
}
distance = finalRounding(distance, opt);
if (distance < (opt.scoring.minDistance || 0))
return { score: 0 };
if (opt.scoring.minSide !== undefined) {
const minSide = opt.scoring.minSide * distance;
if (legs[0].d < minSide || legs[1].d < minSide || legs[2].d < minSide)
return { score: 0 };
}
if (opt.scoring.maxSide !== undefined) {
const maxSide = opt.scoring.maxSide * distance;
if (legs[0].d > maxSide || legs[1].d > maxSide || legs[2].d > maxSide)
return { score: 0 };
}
let cp = geom.isTriangleClosed(tp[0].r, tp[2].r, distance, opt);
if (!cp)
return { score: 0 };
const penalty = closingPenalty(cp.d, opt);
let score = finalRounding((distance - penalty) * opt.scoring.multiplier, opt);
return { distance, score, tp, cp, legs, penalty };
}
// Upper limit for an out-and-return with 2 TPs (XCLeague) with TPs somewhere in boxes
export function boundOutAndReturn2(ranges, boxes, opt) {
const maxDistance = opt.scoring.rounding(geom.maxDistance2Rectangles(boxes)) * 2;
if (maxDistance < (opt.scoring.minDistance || 0))
return 0;
if (ranges[0].end < ranges[1].start) {
// Ranges do not overlap
const cp = geom.isTriangleClosed(ranges[0].end, ranges[1].start, maxDistance, opt);
if (!cp)
return 0;
return finalRounding((maxDistance - closingPenalty(cp.d, opt)) * opt.scoring.multiplier, opt);
}
// Ranges overlap - bounding is impossible at this stage
return finalRounding(maxDistance * opt.scoring.multiplier, opt);
}
// Score an out-and-return with 2 TPs once the 2 points have been selected
export function scoreOutAndReturn2(tp, opt) {
const leg = opt.scoring.rounding(tp[0].distanceEarth(tp[1]));
const distance = finalRounding(leg * 2, opt);
if (distance < (opt.scoring.minDistance || 0))
return { score: 0 };
let cp = geom.isTriangleClosed(tp[0].r, tp[1].r, distance, opt);
if (!cp)
return { score: 0 };
const penalty = closingPenalty(cp.d, opt);
const score = finalRounding((distance - penalty) * opt.scoring.multiplier, opt);
const legs = [
{name: 'TP1 : TP2', start: tp[0], finish: tp[1], d: leg},
{name: 'TP2 : TP1', start: tp[1], finish: tp[0], d: leg}
];
return { distance, score, tp, cp, legs, penalty };
}
// Upper limit for an out-and-return with 1 TP (FAI) with a TP somewhere in boxes
export function boundOutAndReturn1(ranges, boxes, opt) {
// Merge box[0] and box[2]
const box2 = new Box(
Math.min(boxes[0].x1, boxes[2].x1),
Math.min(boxes[0].y1, boxes[2].y1),
Math.max(boxes[0].x2, boxes[2].x2),
Math.max(boxes[0].y2, boxes[2].y2),
);
const maxDistance = opt.scoring.rounding(geom.maxDistance2Rectangles([boxes[1], box2]));
if (maxDistance < (opt.scoring.minDistance || 0))
return 0;
if (ranges[0].end < ranges[2].start) {
// Ranges do not overlap
const cp = geom.isOutAndReturnClosed(ranges[0], ranges[2], maxDistance, opt);
if (!cp)
return 0;
// The final closing point has to be somewhere in this box
// (this is the box containing all the medians of all lines
// starting in box[0] and ending in box[2])
const box2 = new Box(
(boxes[0].x1 + boxes[2].x1) / 2,
(boxes[0].y1 + boxes[2].y1) / 2,
(boxes[0].x2 + boxes[2].x2) / 2,
(boxes[0].y2 + boxes[2].y2) / 2
);
const realDistance = opt.scoring.rounding(geom.maxDistance2Rectangles([boxes[1], box2]));
return finalRounding((realDistance - closingPenalty(cp.d, opt)) * 2 * opt.scoring.multiplier, opt);
}
// Ranges overlap - bounding is impossible at this stage
return finalRounding(maxDistance * 2 * opt.scoring.multiplier, opt);
}
// Score an out-and-return with 1 TPs once the point has been selected
export function scoreOutAndReturn1(tp, opt) {
// Create the second turn point on the middle of the closing line
const tp2 = new Point((tp[0].x + tp[2].x) / 2, (tp[0].y + tp[2].y) / 2);
const leg = opt.scoring.rounding(tp[1].distanceEarth(tp2));
const distance = finalRounding(leg * 2, opt);
if (distance < (opt.scoring.minDistance || 0))
return { score: 0 };
const closing = opt.scoring.rounding(tp[0].distanceEarth(tp[2]));
if (closing > opt.scoring.closingDistance(distance, opt))
return { score: 0 };
const penalty = closingPenalty(closing, opt);
const score = finalRounding((distance - penalty) * opt.scoring.multiplier, opt);
const legs = [
{name: 'TP1 : TP2', start: tp[1], finish: tp2, d: leg},
{name: 'TP2 : TP1', start: tp2, finish: tp[1], d: leg}
];
return { distance, score, tp: [tp[1], tp2], cp: { d: closing, in: tp[0], out: tp[2] }, legs };
}
// These implement the FAI Sporting Code, Section 7D, Paragraph 5.2.5
// https://www.fai.org/sites/default/files/civl/documents/sporting_code_s7_d_-_records_and_badges_2022.pdf
// In igc-xc-score all TPs are lying on the track
// They are to be transformed to the best possible cylinders
export function adjustFAICylinders(score, opt) {
// Do not readjust incomplete solutions
if (!score.tp || !score.legs || score.score == 0)
return;
// Move away each TP by 'cylinders' (400m)
// https://math.stackexchange.com/questions/175896/finding-a-point-along-a-line-a-certain-distance-away-from-another-point
// We can safely assume that the Earth is flat for a distance of 400m
// (ie unless we are very near the poles, the curvature will be much less than the 10m declared accuracy)
function moveAway(point, origin) {
const d0 = point.distanceEarth(origin);
const t = (d0 + opt.scoring.cylinders) / d0;
const x = (1 - t) * origin.x + t * point.x;
const y = (1 - t) * origin.y + t * point.y;
return new Point(x, y);
}
// For each TP we have to determine a new location that lies on a line
// determined by the middle of the line between the previous and the next TP
// and the TP itself
// (For a triangle, this would be the centroid of the triangle, but
// for an open flight every TP is to be moved away from a different center)
const newTP = [];
for (const i in score.tp) {
if (score.tp[i].r === undefined) {
// The second TP of an Out-and-Return flight is not lying on the track
// and it is already a cylinder TP
continue;
}
let previous = +i - 1;
if (previous < 0) {
if (score.ep)
// Open flight - the previous one is START
previous = score.ep.start;
else
// Circuit flight - the previous one is the last TP
previous = score.tp[score.tp.length - 1];
} else
previous = score.tp[previous];
let next = +i + 1;
if (next >= score.tp.length) {
if (score.ep)
// Open flight - the next one is FINISH
next = score.ep.finish;
else
// Circuit flight - the next one is the first TP
next = score.tp[0];
} else
next = score.tp[next];
const centroid = new Point((previous.x + next.x) / 2, (previous.y + next.y) / 2);
newTP[i] = moveAway(score.tp[i], centroid);
}
for (const i in score.tp)
if (newTP[i])
score.tp[i] = newTP[i];
// If there are end-points (free distance flight), they are to be moved away
// from their nearest respective TP
if (score.ep && score.ep.start)
score.ep.start = moveAway(score.ep.start, score.tp[0]);
if (score.ep && score.ep.finish)
score.ep.finish = moveAway(score.ep.finish, score.tp[2]);
switch (opt.scoring.code) {
case 'tri':
case 'fai':
{
score.distance = 0;
for (let i = 0; i < score.legs.length; i++) {
score.legs[i].d = opt.scoring.rounding(score.tp[i].distanceEarth(score.tp[(i + 1) % score.tp.length]))
- opt.scoring.cylinders * 2;
score.distance += score.legs[i].d;
}
}
break;
case 'oar':
{
const distance = opt.scoring.rounding(score.tp[0].distanceEarth(score.tp[1])) - opt.scoring.cylinders;
score.legs[0].d = score.legs[1].d = distance;
score.distance = distance * 2;
}
break;
case 'od':
{
const all = [score.ep.start, score.tp[0], score.tp[1], score.tp[2], score.ep.finish];
score.distance = 0;
for (let i = 0; i < all.length - 1; i++) {
score.legs[i].d = opt.scoring.rounding(all[i].distanceEarth(all[i + 1]));
score.legs[i].d -= opt.scoring.cylinders * 2;
score.distance += score.legs[i].d;
}
}
break;
}
score.distance = finalRounding(score.distance, opt);
score.score = score.distance >= (opt.scoring.minDistance || 0) ?
finalRounding((score.distance - (score.penalty || 0)) * opt.scoring.multiplier, opt) : 0;
}