diff --git a/src/lib/exports/variables.ts b/src/lib/exports/variables.ts index 53c57b78..02e154c7 100644 --- a/src/lib/exports/variables.ts +++ b/src/lib/exports/variables.ts @@ -1,3 +1,4 @@ +import type { QtiAreaMapping } from '../qti-components/qti-response-processing/qti-expression/qti-area-mapping/qti-area-mapping'; import type { QtiMapping } from '../qti-components/qti-response-processing/qti-expression/qti-mapping/qti-mapping'; import type { BaseType, Cardinality } from './expression-result'; @@ -22,5 +23,6 @@ export interface ResponseVariable extends VariableDeclaration { + return { + shape: el.getAttribute('shape'), + coords: el.getAttribute('coords'), + mappedValue: +el.getAttribute('mapped-value'), + defaultValue: el.getAttribute('default-value') ? +el.getAttribute('default-value') : 0 + } as { + shape: 'default' | 'circle' | 'rect' | 'ellipse' | 'poly'; + coords: string; + mappedValue: number; + defaultValue: number; + }; + }); + } +} + +customElements.define('qti-area-mapping', QtiAreaMapping); diff --git a/src/lib/qti-components/qti-response-processing/qti-expression/qti-map-response-point/qti-map-response-point.ts b/src/lib/qti-components/qti-response-processing/qti-expression/qti-map-response-point/qti-map-response-point.ts new file mode 100644 index 00000000..2171e65f --- /dev/null +++ b/src/lib/qti-components/qti-response-processing/qti-expression/qti-map-response-point/qti-map-response-point.ts @@ -0,0 +1,71 @@ +import { property } from 'lit/decorators.js'; + +import { QtiExpression } from '../../../internal/qti-expression'; +import { ScoringHelper } from '../../utilities/scoring-helper'; + +import type { ResponseVariable } from '../../../../exports/variables'; +import type { QtiAreaMapping } from '../qti-area-mapping/qti-area-mapping'; + +export class QtiMapResponsePoint extends QtiExpression { + @property({ type: String }) identifier: string; + + public override getResult(): number { + const response: ResponseVariable = this.context.variables.find(r => r.identifier === this.identifier); + if (!response) { + console.warn(`Response ${this.identifier} cannot be found`); + return null; + } + + const areaMapping: QtiAreaMapping = response.areaMapping; + if (!areaMapping) { + console.warn(`Area mapping not found for response ${this.identifier}`); + return null; + } + + const candidateResponses = !Array.isArray(response.value) ? [response.value] : response.value; + if (!candidateResponses || candidateResponses.length === 0) { + console.warn(`No candidate responses for response ${this.identifier}`); + return null; + } + + let result = 0; + + // Keep track of areas that have already been matched + const mappedAreas = new Set(); + + for (const candidateResponse of candidateResponses) { + for (const entry of areaMapping.mapEntries) { + if (mappedAreas.has(entry.coords)) { + continue; // Skip areas that have already been mapped + } + + const isPointInArea = ScoringHelper.isPointInArea( + candidateResponse, + `${entry.shape},${entry.coords}`, + response.baseType + ); + if (isPointInArea) { + result += entry.mappedValue ?? 0; + mappedAreas.add(entry.coords); + } + } + } + + // Add default value for unmatched candidate responses + if (mappedAreas.size < candidateResponses.length) { + result += areaMapping.defaultValue; + } + + // Apply bounds if defined + if (areaMapping.lowerBound != null) { + result = Math.max(areaMapping.lowerBound, result); + } + if (areaMapping.upperBound != null) { + result = Math.min(areaMapping.upperBound, result); + } + + return result; + } +} + +customElements.define('qti-map-response-point', QtiMapResponsePoint); diff --git a/src/lib/qti-components/qti-response-processing/utilities/scoring-helper.ts b/src/lib/qti-components/qti-response-processing/utilities/scoring-helper.ts index 3db6df5c..a6989ae7 100644 --- a/src/lib/qti-components/qti-response-processing/utilities/scoring-helper.ts +++ b/src/lib/qti-components/qti-response-processing/utilities/scoring-helper.ts @@ -1,6 +1,98 @@ -import type { BaseType } from '../../'; +import type { BaseType } from '../../../exports/expression-result'; export class ScoringHelper { + /** + * Checks if a given point is within a specified area. + * @param point The point to test, represented as a string "x y" (e.g., "102 113"). + * @param areaKey The area definition, including shape and coordinates (e.g., "circle,102,113,16"). + * @param baseType The base type of the response, must be "point" for this method to proceed. + * @returns True if the point is within the area; false otherwise. + */ + public static isPointInArea(point: string, areaKey: string, baseType: string): boolean { + if (baseType !== 'point') { + console.warn(`Base type ${baseType} is not supported for point area mapping.`); + return false; + } + + // Parse the point as x and y coordinates + const [px, py] = point.split(' ').map(Number); + + // Parse the area definition + const [shape, ...coords] = areaKey.split(','); + const coordinates = coords.map(Number); + + switch (shape.toLowerCase()) { + case 'circle': + case 'default': { + const [cx, cy, radius] = coordinates; + if (coordinates.length !== 3) { + console.warn(`Invalid circle definition: ${areaKey}`); + return false; + } + const distance = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2); + return distance <= radius; + } + + case 'rect': { + const [x1, y1, x2, y2] = coordinates; + if (coordinates.length !== 4) { + console.warn(`Invalid rectangle definition: ${areaKey}`); + return false; + } + return px >= x1 && px <= x2 && py >= y1 && py <= y2; + } + + case 'ellipse': { + const [cx, cy, rx, ry] = coordinates; + if (coordinates.length !== 4) { + console.warn(`Invalid ellipse definition: ${areaKey}`); + return false; + } + // Ellipse equation: ((px - cx)² / rx²) + ((py - cy)² / ry²) <= 1 + const normalizedX = (px - cx) ** 2 / rx ** 2; + const normalizedY = (py - cy) ** 2 / ry ** 2; + return normalizedX + normalizedY <= 1; + } + + case 'poly': { + if (coordinates.length < 6 || coordinates.length % 2 !== 0) { + console.warn(`Invalid polygon definition: ${areaKey}`); + return false; + } + const vertices = []; + for (let i = 0; i < coordinates.length; i += 2) { + vertices.push({ x: coordinates[i], y: coordinates[i + 1] }); + } + return this.isPointInPolygon({ x: px, y: py }, vertices); + } + + default: + console.warn(`Unsupported shape type: ${shape}`); + return false; + } + } + + /** + * Checks if a point is inside a polygon using the ray-casting algorithm. + * @param point The point to test. + * @param vertices The vertices of the polygon in order (array of {x, y} objects). + * @returns True if the point is inside the polygon; false otherwise. + */ + static isPointInPolygon(point: { x: number; y: number }, vertices: { x: number; y: number }[]): boolean { + let inside = false; + for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) { + const xi = vertices[i].x, + yi = vertices[i].y; + const xj = vertices[j].x, + yj = vertices[j].y; + + const intersect = yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi; + + if (intersect) inside = !inside; + } + return inside; + } + public static compareSingleValues(value1: Readonly, value2: Readonly, baseType: BaseType): boolean { switch (baseType) { case 'identifier': diff --git a/src/lib/qti-components/qti-variable-declaration/qti-response-declaration/qti-response-declaration.ts b/src/lib/qti-components/qti-variable-declaration/qti-response-declaration/qti-response-declaration.ts index 75d29d27..9b96e59b 100755 --- a/src/lib/qti-components/qti-variable-declaration/qti-response-declaration/qti-response-declaration.ts +++ b/src/lib/qti-components/qti-variable-declaration/qti-response-declaration/qti-response-declaration.ts @@ -5,6 +5,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { itemContext } from '../../../exports/qti-assessment-item.context'; import { QtiVariableDeclaration } from '../qti-variable-declaration'; +import type { QtiAreaMapping } from '../../qti-response-processing'; import type { BaseType, Cardinality } from '../../../exports/expression-result'; import type { ResponseVariable } from '../../../exports/variables'; import type { QtiMapping } from '../../qti-response-processing/qti-expression/qti-mapping/qti-mapping'; @@ -44,6 +45,7 @@ export class QtiResponseDeclaration extends QtiVariableDeclaration { correctResponse: this.correctResponse, cardinality: this.cardinality || 'single', mapping: this.mapping, + areaMapping: this.areaMapping, value: null, type: 'response', candidateResponse: null @@ -81,6 +83,10 @@ export class QtiResponseDeclaration extends QtiVariableDeclaration { private get mapping() { return this.querySelector('qti-mapping') as QtiMapping; } + + private get areaMapping() { + return this.querySelector('qti-area-mapping') as QtiAreaMapping; + } } declare global {