// 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 }]; }