From e0e36225fe06c7c6a99b870e8dbb532e0367860f Mon Sep 17 00:00:00 2001 From: "Kevin Carter (via Claude)" Date: Sat, 4 Jul 2026 12:13:13 +0000 Subject: [PATCH] WS-2 items 4+5: draw structure on canvas + preserve N-deep tree on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trip fix (item 5): serializeCanvasShapes no longer re-derives the question tree from boundary pairs (which flattened 3-deep → 2-tier). The whole tree is now carried as 'part' shapes that hold parentId + isContainer + depth; save preserves the real parent_id chains (persisted parent wins; geometry is only a fallback for manually-drawn shapes). Boundary-pair synthesis is kept but attaches to a covering container box when one exists. New test locks a 3-deep Q→part→subpart round-trip. Draw structure (item 4): shapesFromTemplate now draws containers too (were skipped), each with nesting depth. examCanvasShapes renders container frames (transparent, depth-hued border), PRINTS MARKS on part/container boxes (◆N pill; were never drawn), and NAMES context figures (figure name as label + description tooltip + '→ Q' tether pill; was an anonymous purple box). Context name/description round-trip via exam_response_areas.meta. Threads new shape props through the tldraw util + setup page + types. Adds meta to ExamResponseArea + replace payload. tsc: no new errors; 7/7 model tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV --- .../exam/setup/ExamTemplateSetupPage.tsx | 7 +- src/pages/exam/setup/examCanvasShapes.tsx | 60 +++++++--- src/types/exam.types.ts | 7 ++ src/utils/exam-canvas/model.test.ts | 31 ++++++ src/utils/exam-canvas/model.ts | 103 +++++++++++++++--- 5 files changed, 175 insertions(+), 33 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 648de6c..62dc12b 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -136,6 +136,11 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null { responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'], contextType: s.props.contextType, questionId: s.props.questionId ?? null, + parentId: s.props.parentId ?? null, + isContainer: s.props.isContainer ?? false, + depth: s.props.depth, + description: s.props.description, + linkLabel: s.props.linkLabel, source: s.props.source ?? 'manual', confirmed: s.props.confirmed ?? s.props.source !== 'ai', confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null, @@ -157,7 +162,7 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { type: SHAPE_TYPES[m.kind], x: m.x, y: m.y, - props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') }, + props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, parentId: m.parentId ?? undefined, isContainer: m.isContainer ?? undefined, depth: m.depth ?? undefined, description: m.description ?? undefined, linkLabel: m.linkLabel ?? undefined, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') }, }))) } diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index 4e01e90..cee137d 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -35,6 +35,11 @@ export type ExamCanvasTLShape = TLBaseBoxShape & { responseForm?: string contextType?: string questionId?: string | null + parentId?: string | null + isContainer?: boolean + depth?: number + description?: string + linkLabel?: string domainId?: string source?: 'manual' | 'ai' confirmed?: boolean @@ -137,45 +142,68 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) { ) } +// Distinct look for a container box (main question / intermediate part). It frames its children rather than +// filling over them: transparent interior + a coloured border whose hue shifts by nesting depth, so +// question ⊃ part ⊃ subpart reads as nested frames on the paper. +const CONTAINER_DEPTH_STROKE = ['#4f46e5', '#0d9488', '#b45309'] +function containerStroke(depth: number) { return CONTAINER_DEPTH_STROKE[Math.min(depth, CONTAINER_DEPTH_STROKE.length - 1)] } + +function marksPill(maxMarks: number | undefined) { + if (typeof maxMarks !== 'number' || maxMarks <= 0) return null + return ( + + {maxMarks} + + ) +} + function renderShape(shape: ExamCanvasTLShape) { const kind = shape.props.kind const p = canvasShapePalette[kind] ?? canvasShapePalette.response const isBoundary = kind === 'boundary' const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false if (isBoundary) return renderBoundaryLine(shape) - const isAi = shape.props.source === 'ai' + const isContainer = shape.props.isContainer === true && kind === 'part' + const depth = shape.props.depth ?? 0 + const stroke = isContainer ? containerStroke(depth) : p.stroke + const darkStroke = isContainer ? containerStroke(depth) : p.darkStroke const confidence = confidenceLabel(shape.props.confidence) const flags = reviewFlags(shape) - const title = provenanceTitle(shape, `${p.label}: ${p.role}`) + const roleLabel = isContainer ? (depth === 0 ? 'Question: container' : 'Part: container') : `${p.label}: ${p.role}` + const title = shape.props.description ? `${provenanceTitle(shape, roleLabel)} • ${shape.props.description}` : provenanceTitle(shape, roleLabel) return (
- - + + {shape.props.label || p.label} - {confidence && {confidence}} - {!confidence && !isBoundary && shape.props.questionId && Attached} + + {marksPill(shape.props.maxMarks)} + {confidence && {confidence}} + {!confidence && !marksPill(shape.props.maxMarks) && shape.props.questionId && kind !== 'context' && Attached} + {flags.length > 0 && {flags.slice(0, 2).join(' · ')}} {kind === 'response' && shape.props.responseForm && {RESPONSE_FORM_LABEL[shape.props.responseForm] ?? shape.props.responseForm}} + {/* Context region: a visible tether to its owning question + the figure kind. */} + {kind === 'context' && shape.props.linkLabel && → {shape.props.linkLabel}} {kind === 'context' && shape.props.contextType && shape.props.contextType !== 'generic' && {shape.props.contextType}} - {isBoundary && pair across pages}
) @@ -186,7 +214,7 @@ function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true } } -const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) } +const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) } const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => class PdfPageUtil extends BaseBoxShapeUtil { diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 3018a01..5d9da47 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -91,6 +91,10 @@ export interface ExamQuestion { spec_ref: string | null; bounds?: Record | null; page?: number | null; + /** Analyse contract v2 (migration 78): primary command verb for a leaf part. */ + command_word?: string | null; + /** Analyse contract v2 (migration 78): stem prose for the outline view. */ + preamble?: Record | null; source: ExamTemplateSource; confirmed: boolean; confidence: number | null; @@ -116,6 +120,8 @@ export interface ExamResponseArea { kind: ExamResponseAreaKind; response_form: string | null; context_type?: string | null; + /** Rich recognition payload (migration 75): figure name/description, OMR box geometry, unit/quantity, table… */ + meta?: Record | null; source: ExamTemplateSource; confirmed: boolean; confidence: number | null; @@ -197,6 +203,7 @@ export interface TemplateReplacePayload { kind: ExamResponseArea['kind']; response_form?: string | null; context_type?: string | null; + meta?: Record | null; source?: 'manual' | 'ai'; confirmed?: boolean; confidence?: number | null; diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts index 77506d6..20aaee9 100644 --- a/src/utils/exam-canvas/model.test.ts +++ b/src/utils/exam-canvas/model.test.ts @@ -104,6 +104,37 @@ describe('exam setup canvas serialization', () => { expect(payload.response_areas[0].question_id).toBe(part?.id) // falls back to the part, not lost }) + it('preserves a 3-deep question → part → subpart chain across a round-trip save', () => { + const Q = '11111111-1111-4111-8111-111111111111' // container, depth 0 + const P = '22222222-2222-4222-8222-222222222222' // container part, depth 1 + const S = '33333333-3333-4333-8333-333333333333' // leaf subpart, depth 2 + const detail: ExamTemplateDetail = { + ...template, + questions: [ + { id: Q, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 40, y: 80, w: 700, h: 600 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' }, + { id: P, template_id: 'tpl-1', parent_id: Q, label: 'Q1(a)', order: 1, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 60, y: 120, w: 660, h: 500 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' }, + { id: S, template_id: 'tpl-1', parent_id: P, label: 'Q1(a)(i)', order: 2, max_marks: 3, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 80, y: 160, w: 620, h: 200 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' }, + ], + response_areas: [], + boundaries: [], + } + const shapes = shapesFromTemplate(detail) + // all three tiers are drawn (containers are no longer skipped), with depth + container flags + expect(shapes.filter((s) => s.kind === 'part')).toHaveLength(3) + expect(shapes.find((s) => s.id === S)).toMatchObject({ isContainer: false, depth: 2, parentId: P }) + expect(shapes.find((s) => s.id === Q)).toMatchObject({ isContainer: true, depth: 0 }) + + const payload = serializeCanvasShapes(template, shapes) + const byId = new Map(payload.questions.map((q) => [q.id, q])) + expect(payload.questions).toHaveLength(3) + expect(byId.get(Q)?.parent_id ?? null).toBeNull() + expect(byId.get(P)?.parent_id).toBe(Q) // middle tier survives — not flattened away + expect(byId.get(S)?.parent_id).toBe(P) // 3-deep chain intact + expect(byId.get(Q)?.is_container).toBe(true) + expect(byId.get(S)?.is_container).toBe(false) + expect(byId.get(S)?.max_marks).toBe(3) + }) + it('respects a region persisted question over geometric nearest when no part contains it', () => { const A = '11111111-1111-4111-8111-111111111111' const B = '22222222-2222-4222-8222-222222222222' diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index 41904b7..40d7c09 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -26,6 +26,16 @@ export interface ExamCanvasShapeModel { responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks' contextType?: string questionId?: string | null + /** Tree parent (container question) id — preserved across a round-trip so N-deep structure survives a save. */ + parentId?: string | null + /** True for a container question (main question / intermediate part) drawn as a nesting box. */ + isContainer?: boolean + /** 0 = main question, 1 = part, 2 = subpart … drives nesting styling. */ + depth?: number + /** Figure/table caption for a context region (shown as a tooltip). */ + description?: string + /** Label of the owning question, for a context region's visible tether pill. */ + linkLabel?: string source?: ExamTemplateSource confirmed?: boolean confidence?: number | null @@ -118,34 +128,69 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam const orderedBoundaries = shapes .filter((s) => s.kind === 'boundary') .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y)) - const parts = shapes.filter((s) => s.kind === 'part') + // 'part' shapes carry the whole question tree: containers (isContainer) + leaf parts/subparts. They are the + // authoritative structure — the boundary-pair synthesis below is only a fallback for the manual workflow. + const structural = shapes.filter((s) => s.kind === 'part') + const containerShapes = structural.filter((s) => s.isContainer) const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') const questions: TemplateReplacePayload['questions'] = [] const boundaries: TemplateReplacePayload['boundaries'] = [] const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = [] + // Stable question id per structural shape — prefer the persisted domain id so a re-save keeps join keys. + const idOf = new Map() + for (const s of structural) idOf.set(s.id, isUuid(s.questionId) ? (s.questionId as string) : isUuid(s.id) ? s.id : newDomainId()) + const emittedIds = new Set(idOf.values()) + + // Boundary pairs: attach each pair to the container shape it spans (AI/structured path), else SYNTHESIZE a + // container question from the pair (pure manual authoring, no container box drawn). v1 always synthesized — + // which overwrote real containers and flattened the tree. for (let i = 0; i < orderedBoundaries.length; i += 2) { const top = orderedBoundaries[i] const bottom = orderedBoundaries[i + 1] if (!top || !bottom) break - const qNum = bands.length + 1 - const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId() - const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}` - questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) }) + const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${bands.length + 1}` + // A container box whose vertical extent spans this start/end pair owns the boundary (structured path); + // otherwise the pair itself defines a container (manual path). + const pairTop = Math.min(top.y, bottom.y) + const pairBottom = Math.max(top.y, bottom.y) + const cover = containerShapes.find((c) => c.y <= pairTop + 4 && c.y + c.h >= pairBottom - 4) + let questionId: string + if (cover) { + questionId = idOf.get(cover.id) as string + } else { + questionId = isUuid(top.questionId) ? (top.questionId as string) : isUuid(bottom.questionId) ? (bottom.questionId as string) : newDomainId() + questions.push({ id: questionId, label, order: questions.length, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) }) + emittedIds.add(questionId) + } bands.push({ questionId, top, bottom }) for (const b of [top, bottom]) { boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) }) } } + // Resolve a structural shape's parent: the persisted parent (if still emitted) wins — this is what keeps a + // 3-deep subpart→part→question chain intact across a save. Manually-drawn shapes with no persisted parent + // fall back to geometry: the deepest container box that contains them, else the boundary band around them. + const resolveParent = (s: ExamCanvasShapeModel): string | null => { + if (isUuid(s.parentId) && emittedIds.has(s.parentId as string)) return s.parentId as string + const containing = containerShapes + .filter((c) => c.id !== s.id && contains(bounds(c), bounds(s))) + .sort((a, b) => (b.depth ?? 0) - (a.depth ?? 0) || (a.w * a.h) - (b.w * b.h)) + if (containing.length) return idOf.get(containing[0].id) ?? null + const band = bands.find((bd) => bandContains(bd.top, bd.bottom, s)) + return band?.questionId ?? null + } + const partQuestionIds = new Map() - parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => { - const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) - const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() + const structuralSorted = [...structural].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || (a.y - b.y) || (a.x - b.x)) + structuralSorted.forEach((part, index) => { + const qid = idOf.get(part.id) as string partQuestionIds.set(part.id, qid) - questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) }) + questions.push({ id: qid, parent_id: resolveParent(part), label: part.label || (part.isContainer ? `Q${index + 1}` : `Part ${index + 1}`), order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.isContainer ? null : (part.answerType ?? 'written'), mcq_options: null, mark_scheme: {}, is_container: !!part.isContainer, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) }) }) + const parts = structural // Resolve each region's owner question. Order: current geometric containment (a user who drags a // region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved @@ -154,18 +199,29 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam // never silently drop a region while any question exists (only a template with zero questions can). const questionIds = new Set(questions.map((q) => q.id)) const response_areas: TemplateReplacePayload['response_areas'] = [] + // Prefer the DEEPEST (smallest) containing structural box so a region lands on its leaf part, not the + // enclosing container. Leaf parts win over containers at equal containment. + const deepestContainer = (region: ExamCanvasShapeModel): ExamCanvasShapeModel | undefined => + parts + .filter((part) => part.id !== region.id && contains(bounds(part), bounds(region))) + .sort((a, b) => Number(!!a.isContainer) - Number(!!b.isContainer) || (a.w * a.h) - (b.w * b.h))[0] for (const region of regions) { - const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) + const containingPart = deepestContainer(region) const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined - const nearestPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0] + const nearestLeaf = parts.filter((p) => !p.isContainer).find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts.find((p) => !p.isContainer) ?? parts[0] const questionId = (containingPart && partQuestionIds.get(containingPart.id)) || persisted - || (nearestPart && partQuestionIds.get(nearestPart.id)) + || (nearestLeaf && partQuestionIds.get(nearestLeaf.id)) || questions[0]?.id if (!questionId) continue const kind = region.kind as ExamCanvasRegionKind - response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) }) + // Carry the figure/table name + caption through the save so a named context region survives a round-trip + // (the replace endpoint persists exam_response_areas.meta). + const meta = kind === 'context' && (region.label || region.description) + ? { name: region.label || undefined, description: region.description || undefined } + : undefined + response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, meta, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) }) } return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] } @@ -174,20 +230,35 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] { const shapes: ExamCanvasShapeModel[] = [] const questions = new Map(detail.questions.map((q) => [q.id, q])) + const depthOf = (id: string | null | undefined, guard = 0): number => { + const q = id ? questions.get(id) : undefined + return !q || !q.parent_id || guard > 8 ? 0 : 1 + depthOf(q.parent_id, guard + 1) + } for (const b of detail.boundaries ?? []) { const page = pageGeometry((b.page_index ?? 0) + 1, pages) // Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids, // but render and save a full rendered-page-width horizontal rule. shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id, source: b.source, confirmed: b.confirmed, confidence: b.confidence, derivation: b.derivation }) } + // Draw the WHOLE tree that has geometry: containers (main questions / intermediate parts) AND leaf parts, + // each carrying its parent id + depth so nesting renders and a save preserves the N-deep chain. A question + // with no bounds still can't be drawn (legacy data) — it falls back to boundary-band synthesis on save. for (const q of detail.questions ?? []) { - if (q.is_container || !q.bounds) continue - shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: (q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id, source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation }) + if (!q.bounds) continue + shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.is_container ? undefined : ((q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written'), questionId: q.id, parentId: q.parent_id, isContainer: q.is_container, depth: depthOf(q.id), source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation }) } for (const r of detail.response_areas ?? []) { const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 } const q = questions.get(r.question_id) - shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation }) + const meta = ((r as { meta?: Record }).meta) ?? {} + const name = typeof meta.name === 'string' ? meta.name : undefined + const description = typeof meta.description === 'string' ? meta.description : undefined + // A context region is NAMED by its figure/table (was an anonymous purple box). Response/other regions keep + // the "→ owning question" label so their link is legible. + const label = r.kind === 'context' + ? (name || (r.context_type && r.context_type !== 'generic' ? r.context_type : 'Context')) + : (q ? `→ ${q.label}` : r.kind) + shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label, description, linkLabel: q?.label, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation }) } return addCheapReviewFlags(shapes, pages) }