157 lines
4.1 KiB
JavaScript
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 }];
|
|
}
|