157 lines
4.1 KiB
JavaScript

// scoring.js - accuracy calculation
const GRID_SIZE = 96;
const MIN_DRAWN_POINTS = 10;
const MIN_FILLED_CELLS = 8;
/**
* @typedef {{ x: number, y: number }} Point
* @typedef {{ rings: Point[][] }} CountryOutline
*/
/**
* Calculate a deterministic shape score from a drawn polygon and reference outline.
* @param {Point[] | Point[][]} drawnShape - Points or rings normalized to 0-100 canvas space.
* @param {CountryOutline | undefined | null} referenceOutline
* @returns {number} Score between 0 and 100.
*/
export function calculateScore(drawnShape, referenceOutline) {
return compareShapes(drawnShape, referenceOutline);
}
/**
* Compare a drawn polygon against one or more reference rings using rasterized IoU.
* @param {Point[] | Point[][]} drawnShape - Points or rings normalized to 0-100 canvas space.
* @param {CountryOutline | undefined | null} referenceOutline
* @returns {number} Score between 0 and 100.
*/
export function compareShapes(drawnShape, referenceOutline) {
if (!referenceOutline?.rings?.length) return 0;
const drawnRings = normalizeDrawnRings(drawnShape);
const referenceRings = referenceOutline.rings.filter(isValidReferenceRing).map(closeRing);
if (!drawnRings.length || !referenceRings.length) return 0;
let intersection = 0;
let union = 0;
let drawnCells = 0;
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const point = cellCenter(col, row);
const inDrawn = isInsideAnyRing(point, drawnRings);
const inReference = isInsideAnyRing(point, referenceRings);
if (inDrawn) drawnCells++;
if (inDrawn && inReference) intersection++;
if (inDrawn || inReference) union++;
}
}
if (drawnCells < MIN_FILLED_CELLS || union === 0) return 0;
return Math.round((intersection / union) * 100);
}
/**
* @param {Point[] | Point[][]} drawnShape
* @returns {Point[][]}
*/
function normalizeDrawnRings(drawnShape) {
if (!Array.isArray(drawnShape) || !drawnShape.length) return [];
if (isPoint(drawnShape[0])) {
return isValidRing(drawnShape) ? [closeRing(drawnShape)] : [];
}
return drawnShape
.filter(isValidRing)
.map(closeRing);
}
/**
* Map a numeric score to a letter grade with colour.
* @param {number} score
* @returns {{ label: string, color: string }}
*/
export function getGrade(score) {
if (score >= 90) return { label: "S", color: "#f0b429" };
if (score >= 75) return { label: "A", color: "#41b869" };
if (score >= 60) return { label: "B", color: "#1a7fc4" };
if (score >= 40) return { label: "C", color: "#7a9aaa" };
return { label: "D", color: "#e05c5c" };
}
/**
* @param {number} col
* @param {number} row
* @returns {Point}
*/
function cellCenter(col, row) {
return {
x: ((col + 0.5) / GRID_SIZE) * 100,
y: ((row + 0.5) / GRID_SIZE) * 100,
};
}
/**
* @param {Point} point
* @param {Point[][]} rings
*/
function isInsideAnyRing(point, rings) {
return rings.some((ring) => pointInRing(point, ring));
}
/** @param {unknown} value */
function isPoint(value) {
return (
!!value &&
typeof value === "object" &&
Number.isFinite(value.x) &&
Number.isFinite(value.y)
);
}
/**
* @param {Point} point
* @param {Point[]} ring
*/
function pointInRing(point, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i];
const b = ring[j];
const intersects =
a.y > point.y !== b.y > point.y &&
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
if (intersects) inside = !inside;
}
return inside;
}
/** @param {Point[]} ring */
function isValidRing(ring) {
return (
Array.isArray(ring) &&
ring.length >= MIN_DRAWN_POINTS &&
ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
);
}
/** @param {Point[]} ring */
function isValidReferenceRing(ring) {
return (
Array.isArray(ring) &&
ring.length >= 4 &&
ring.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
);
}
/** @param {Point[]} ring */
function closeRing(ring) {
const first = ring[0];
const last = ring[ring.length - 1];
if (first.x === last.x && first.y === last.y) return ring;
return [...ring, { ...first }];
}