Skip to content

Commit

Permalink
feat: add QtiAreaMapping and QtiMapResponsePoint classes for area-bas…
Browse files Browse the repository at this point in the history
…ed response mapping
  • Loading branch information
Marcelh1983 committed Jan 26, 2025
1 parent 052303f commit 84e79ff
Show file tree
Hide file tree
Showing 6 changed files with 200 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/lib/exports/variables.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -22,5 +23,6 @@ export interface ResponseVariable extends VariableDeclaration<string | string[]
// specific to response variables
candidateResponse?: string | string[] | null;
mapping?: QtiMapping;
areaMapping?: QtiAreaMapping; // Optional property for area mappings
correctResponse?: string | string[] | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export * from './qti-is-null/qti-is-null';
export * from './qti-lt/qti-lt';
export * from './qti-lte/qti-lte';
export * from './qti-map-response/qti-map-response';
export * from './qti-map-response-point/qti-map-response-point';
export * from './qti-mapping/qti-mapping';
export * from './qti-area-mapping/qti-area-mapping';
export * from './qti-match/qti-match';
export * from './qti-member/qti-member';
export * from './qti-multiple/qti-multiple';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { property } from 'lit/decorators.js';
import { LitElement } from 'lit';

export class QtiAreaMapping extends LitElement {
@property({ attribute: 'default-value', type: Number }) defaultValue: number = 0;
@property({ attribute: 'lower-bound', type: Number }) lowerBound: number;
@property({ attribute: 'upper-bound', type: Number }) upperBound: number;

public get mapEntries() {
return Array.from(this.querySelectorAll('qti-area-map-entry')).map(el => {
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);
Original file line number Diff line number Diff line change
@@ -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<number> {
@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<string>();

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);
Original file line number Diff line number Diff line change
@@ -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<string>, value2: Readonly<string>, baseType: BaseType): boolean {
switch (baseType) {
case 'identifier':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 84e79ff

Please sign in to comment.