diff --git a/frontend/src/components/questions/Match.tsx b/frontend/src/components/questions/Match.tsx index f811675..6c0b871 100644 --- a/frontend/src/components/questions/Match.tsx +++ b/frontend/src/components/questions/Match.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import Tex from '../Tex' import type { Question } from '../../types' @@ -10,17 +10,102 @@ interface Props { graded?: boolean } +const PASTEL_COLORS = [ + 'oklch(0.78 0.12 330)', // pink + 'oklch(0.78 0.12 200)', // sky blue + 'oklch(0.80 0.13 140)', // mint green + 'oklch(0.78 0.12 80)', // peach / orange + 'oklch(0.78 0.12 270)', // lavender + 'oklch(0.82 0.10 60)', // soft yellow + 'oklch(0.78 0.12 170)', // teal + 'oklch(0.78 0.10 20)', // salmon + 'oklch(0.78 0.12 300)', // orchid + 'oklch(0.80 0.10 110)', // lime +] + export default function Match({ q, value, onChange, locked, graded }: Props) { const current = value ?? {} const [activeLeft, setActiveLeft] = useState(null) const answer = (q.answer as Record) ?? {} + const gridRef = useRef(null) + const svgRef = useRef(null) + const leftRefs = useRef>({}) + const rightRefs = useRef>({}) + const reverseMap = useMemo(() => { const m: Record = {} Object.entries(current).forEach(([l, r]) => { m[r] = l }) return m }, [current]) + /* Build a stable color map: each pair index gets a pastel */ + const pairColorMap = useMemo(() => { + const map: Record = {} + let colorIdx = 0 + const leftItems = q.left ?? [] + leftItems.forEach((item) => { + if (current[item.id]) { + map[item.id] = PASTEL_COLORS[colorIdx % PASTEL_COLORS.length] + colorIdx++ + } + }) + return map + }, [current, q.left]) + + /* Stringify the current pairings so we can use it as a stable dependency */ + const currentKey = JSON.stringify(current) + + const drawLines = useCallback(() => { + const grid = gridRef.current + const svg = svgRef.current + if (!grid || !svg) return + + const gridRect = grid.getBoundingClientRect() + + /* Clear existing paths */ + while (svg.firstChild) svg.removeChild(svg.firstChild) + + Object.entries(current).forEach(([leftId, rightId]) => { + const leftEl = leftRefs.current[leftId] + const rightEl = rightRefs.current[rightId] + if (!leftEl || !rightEl) return + + const lr = leftEl.getBoundingClientRect() + const rr = rightEl.getBoundingClientRect() + + const x1 = lr.right - gridRect.left + const y1 = lr.top + lr.height / 2 - gridRect.top + const x2 = rr.left - gridRect.left + const y2 = rr.top + rr.height / 2 - gridRect.top + + const dx = (x2 - x1) * 0.45 + const color = pairColorMap[leftId] ?? PASTEL_COLORS[0] + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('d', `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`) + path.setAttribute('stroke', color) + path.setAttribute('stroke-width', '2.5') + path.setAttribute('fill', 'none') + path.setAttribute('stroke-linecap', 'round') + path.setAttribute('opacity', '0.85') + svg.appendChild(path) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentKey, pairColorMap]) + + useEffect(() => { + /* Draw lines after render */ + requestAnimationFrame(drawLines) + + window.addEventListener('resize', drawLines) + window.addEventListener('scroll', drawLines, true) + return () => { + window.removeEventListener('resize', drawLines) + window.removeEventListener('scroll', drawLines, true) + } + }, [drawLines]) + const handleRight = (rightId: string) => { if (locked) return if (activeLeft) { @@ -48,7 +133,10 @@ export default function Match({ q, value, onChange, locked, graded }: Props) { } return ( -
+
+ {/* SVG overlay for connection lines */} + +
Items
{(q.left ?? []).map((item, i) => { @@ -62,14 +150,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) { const pairedRightIdx = current[item.id] ? (q.right ?? []).findIndex((x) => x.id === current[item.id]) : -1 + const pairColor = pairColorMap[item.id] return ( - ) @@ -86,19 +187,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) { const pairedLeftIdx = reverseMap[item.id] ? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id]) : -1 + const pairColor = reverseMap[item.id] ? pairColorMap[reverseMap[item.id]] : undefined return ( ) diff --git a/frontend/src/index.css b/frontend/src/index.css index c96db62..550055f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -655,9 +655,18 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher .match-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 32px; + gap: 48px; position: relative; } +.match-lines-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; + overflow: visible; +} .match-col { display: flex; flex-direction: column;