Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | 1198x 660x 660x 609x 608x 599x 376x 376x 15x 15x 614x 614x 178x 178x 152x 26x 26x 15x 11x 12x 12x 12x 8x 2x 6x 5x 3x 3x 2x 4x 4x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 2x 2x 2x 2x 4x 6x 6x 6x 2x 4x 1x 3x 1x 2x 1x 1x 604x 604x 1x 1x 1x 1x 1x 1x 6x 6x 17x 4x 4x 4x 4x 2x 2x 2x 14x 2x | import { normalizeGraphAnswerForImport } from "@/utils/components/question-bank/question-display/graph/graphAnswerUtils";
/**
* @param {unknown} questionType
* @returns {string}
*/
export function normalizeQuestionType(questionType) {
return String(questionType ?? "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
}
/**
* True for Graph-Multiple-Select / the legacy Graph-Multiple-Choice slug.
* @param {unknown} questionType
* @returns {boolean}
*/
export function isInteractiveDotsQuestionType(questionType) {
const type = normalizeQuestionType(questionType);
return type === "graph-multiple-select" || type === "graph-multiple-choice";
}
/**
* Route to the toggle-dots player when the type says so, or when a Graph
* document actually stores interactiveDots (mis-tagged as "Graph").
* @param {object} [question]
* @returns {boolean}
*/
export function isInteractiveDotsQuestion(question) {
if (!question) return false;
if (isInteractiveDotsQuestionType(question.questionType)) return true;
return countInteractiveDots(question?.correctAnswer?.answers) > 0;
}
/**
* Graph and Graph-Multiple-Select both need a sequential graph2d-react canvas
* id on a shared instance. Hashed ids miss `canvases[graphId]` after addCanvas
* push(); repeating 0 collides on `embed-0`.
*
* @param {object} [question]
* @returns {boolean}
*/
export function needsSharedGraphCanvas(question) {
const type = normalizeQuestionType(question?.questionType);
return type === "graph" || isInteractiveDotsQuestion(question);
}
/**
* Display helpers for Graph-Multiple-Select only.
*
* Grading lives in the API (`grade_interactive_dots_answer` via
* `is_answer_correct`). Do not compare student dots to the answer key here.
*
* Parse a Graph2D export into the inner data object (envelope or legacy).
* @param {unknown} answers
* @returns {Record<string, unknown> | null}
*/
function looksLikeGraphInner(obj) {
Iif (!obj || typeof obj !== "object") return false;
return (
Array.isArray(obj.interactiveDots) ||
Array.isArray(obj.dots) ||
(obj.attributes && typeof obj.attributes === "object") ||
(obj.interactiveDotLimits && typeof obj.interactiveDotLimits === "object")
);
}
export function parseGraphInnerData(answers) {
const encoded = normalizeGraphAnswerForImport(answers);
if (!encoded || encoded.includes("#o#")) return null;
let parsed;
try {
parsed = JSON.parse(encoded);
} catch {
return null;
}
Iif (!parsed || typeof parsed !== "object") return null;
// Only unwrap Graph2D `.data` when it actually looks like a canvas export.
// The student prompt is the inner object; a leftover `.data` must not hide limits.
if (parsed.data && typeof parsed.data === "object" && looksLikeGraphInner(parsed.data)) {
return parsed.data;
}
return parsed;
}
/**
* Student prompt: same graph as the answer key, but every interactive dot starts inactive.
* Preserves background drawings and interactiveDotLimits.
* @param {unknown} answerKeyAnswers - correctAnswer.answers (string or object)
* @returns {string | null} JSON string for importAnswer
*/
export function toStudentPromptGraph(answerKeyAnswers) {
const encoded = normalizeGraphAnswerForImport(answerKeyAnswers);
if (!encoded) return null;
if (encoded.includes("#o#")) return encoded;
let parsed;
try {
parsed = JSON.parse(encoded);
} catch {
return encoded;
}
if (!parsed || typeof parsed !== "object") return encoded;
const clone = structuredClone(parsed);
const content = clone.data && typeof clone.data === "object" ? clone.data : clone;
if (Array.isArray(content.interactiveDots)) {
content.interactiveDots = content.interactiveDots.map((d) => ({
...d,
active: false,
}));
}
// Drop envelope checksum — Graph2D will recompute on import after we mutated dots.
if (clone.format === "Graph2D") {
delete clone.checksum;
delete clone.exportedAt;
}
try {
return JSON.stringify(clone);
} catch {
return encoded;
}
}
/**
* @param {unknown} answers
* @returns {{ min: number | null, max: number | null }}
*/
function toDotLimit(value) {
Iif (value == null || value === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function limitsFromObject(limits) {
if (!limits || typeof limits !== "object") {
return { min: null, max: null };
}
return { min: toDotLimit(limits.min), max: toDotLimit(limits.max) };
}
export function parseInteractiveDotLimits(answers) {
if (answers && typeof answers === "object" && !Array.isArray(answers)) {
const direct = limitsFromObject(answers.interactiveDotLimits);
if (direct.min != null || direct.max != null) return direct;
Iif ("min" in answers || "max" in answers) {
const raw = limitsFromObject(answers);
if (raw.min != null || raw.max != null) return raw;
}
}
const content = parseGraphInnerData(answers);
return limitsFromObject(content?.interactiveDotLimits);
}
/**
* Student take-assignment may put limits on `correctAnswer.interactiveDotLimits`
* (API prompt) while teacher preview still has them only inside the Graph2D envelope.
* @param {object} [question]
* @returns {{ min: number | null, max: number | null }}
*/
export function parseInteractiveDotLimitsFromQuestion(question) {
const correct = question?.correctAnswer;
const fromRoot = limitsFromObject(correct?.interactiveDotLimits);
Eif (fromRoot.min != null || fromRoot.max != null) return fromRoot;
const fromAnswers = parseInteractiveDotLimits(correct?.answers);
Iif (fromAnswers.min != null || fromAnswers.max != null) return fromAnswers;
return parseInteractiveDotLimits(correct?.content);
}
/**
* Write min/max onto a Graph2D export so `importAnswer` loads them into the engine.
* graph2d-react only enforces toggle limits from engine state, not from the chip.
* @param {unknown} answers
* @param {{ min?: number | null, max?: number | null }} limits
* @returns {string | null}
*/
export function stampInteractiveDotLimits(answers, limits) {
const encoded = normalizeGraphAnswerForImport(answers);
Iif (!encoded || encoded.includes("#o#")) return encoded;
let parsed;
try {
parsed = JSON.parse(encoded);
} catch {
return encoded;
}
Iif (!parsed || typeof parsed !== "object") return encoded;
const content = parsed.data && typeof parsed.data === "object" ? parsed.data : parsed;
content.interactiveDotLimits = {
min: limits?.min ?? null,
max: limits?.max ?? null,
};
if (parsed.format === "Graph2D") {
delete parsed.checksum;
delete parsed.exportedAt;
}
try {
return JSON.stringify(parsed);
} catch {
return encoded;
}
}
/**
* True when the student's current selection satisfies authored min (and max).
* Used by the answer tracker — max is also blocked on the canvas itself.
* @param {unknown} answers
* @param {{ min?: number | null, max?: number | null } | null} [limits]
* @returns {boolean}
*/
export function isInteractiveDotsAnswerComplete(answers, limits) {
const active = countActiveInteractiveDotsInAnswer(answers);
Eif (limits?.min != null) return active >= limits.min;
if (limits?.max != null) return active > 0 && active <= limits.max;
return active > 0;
}
function dotsWord(n) {
return n === 1 ? "dot" : "dots";
}
/**
* Short student instruction for how many interactive dots to select.
* @param {{ min?: number | null, max?: number | null } | null} [limits]
* @returns {string}
*/
export function describeInteractiveDotLimitNote(limits) {
const min = limits?.min;
const max = limits?.max;
if (min != null && max != null && min === max) {
return `Select ${min} ${dotsWord(min)} that ${min === 1 ? "applies" : "apply"}.`;
}
if (min != null && max != null) {
return `Select ${min}–${max} dots that apply.`;
}
if (min != null) {
return `Select at least ${min} ${dotsWord(min)} that ${min === 1 ? "applies" : "apply"}.`;
}
if (max != null) {
return `Select at most ${max} ${dotsWord(max)} that ${max === 1 ? "applies" : "apply"}.`;
}
return "Select the dots that apply.";
}
/**
* Push authored min/max into a live Graph2D instance (toggle enforcement).
* @param {{ setInteractiveDotLimits?: Function }} graphInstance
* @param {number} graphId
* @param {{ min?: number | null, max?: number | null }} limits
*/
export function applyInteractiveDotLimits(graphInstance, graphId, limits) {
if (!graphInstance || typeof graphInstance.setInteractiveDotLimits !== "function") return;
if (limits?.min == null && limits?.max == null) return;
try {
graphInstance.setInteractiveDotLimits(graphId, {
min: limits.min ?? null,
max: limits.max ?? null,
});
} catch {
/* canvas may not be mounted */
}
}
/**
* @param {unknown} answers
* @returns {number}
*/
export function countInteractiveDots(answers) {
const content = parseGraphInnerData(answers);
return Array.isArray(content?.interactiveDots) ? content.interactiveDots.length : 0;
}
/**
* Resume a saved student canvas only when it still has interactive dots.
* A leftover Graph-type autosave is a full Graph2D string with no dots — importing
* that would wipe the authored prompt.
* @param {unknown} savedAnswer
* @param {string | null} promptGraph
* @param {string | null} answerKey
* @returns {string | null}
*/
export function resolveInteractiveDotsImport(savedAnswer, promptGraph, answerKey) {
const saved = normalizeGraphAnswerForImport(savedAnswer);
const source = saved && countInteractiveDots(saved) > 0 ? saved : promptGraph || answerKey;
Iif (!source) return null;
const authored = parseInteractiveDotLimits(answerKey);
Iif (authored.min == null && authored.max == null) return source;
// A leftover student autosave often has dots but null limits — stamping the
// authored range keeps toggle min/max working after the first save.
return stampInteractiveDotLimits(source, authored) || source;
}
/**
* @param {unknown} answers
* @returns {number}
*/
export function countActiveInteractiveDotsInAnswer(answers) {
const content = parseGraphInnerData(answers);
Iif (!Array.isArray(content?.interactiveDots)) return 0;
return content.interactiveDots.filter((d) => d?.active).length;
}
/**
* Compact summary for answer trackers and submission review.
* @param {unknown} answers
* @returns {string}
*/
export function describeInteractiveDotsAnswer(answers) {
const total = countInteractiveDots(answers);
const active = countActiveInteractiveDotsInAnswer(answers);
Iif (total === 0 && active === 0) return "";
return `${active} of ${total} dots selected`;
}
/**
* Stable DrawingGraph `graphId` per question for single-canvas contexts.
*
* Prefer sequential 0..n on a **shared** Graph2D instance when several Graph /
* Graph-Multiple-Select canvases share a page (Question Bank, layout-1). Hashed
* ids miss `canvases[graphId]` after addCanvas (push vs index) and drop imports.
*
* @param {object} question
* @param {number} [offset=1000]
* @returns {number}
*/
export function graphCanvasIdForQuestion(question, offset = 1000) {
const raw = String(question?._id ?? question?.id ?? "graph-multiple-select");
let hash = 0;
for (let i = 0; i < raw.length; i += 1) {
hash = (hash * 33 + raw.charCodeAt(i)) >>> 0;
}
return offset + (hash % 8000);
}
|