303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
'use client'
|
|
|
|
import type React from 'react'
|
|
import { createContext, useContext, useMemo } from 'react'
|
|
import { useDecomposition } from '@/contexts/DecompositionContext'
|
|
import type { PedagogicalSegment, UnifiedStepData } from '@/utils/unifiedStepGenerator'
|
|
import { ReasonTooltip } from './ReasonTooltip'
|
|
import './decomposition.css'
|
|
|
|
// ============================================================================
|
|
// Internal Context for term hover coordination
|
|
// ============================================================================
|
|
|
|
interface InternalDecompositionContextType {
|
|
activeTerms: Set<number>
|
|
activeSegmentId: string | null
|
|
addActiveTerm: (termIndex: number, segmentId?: string) => void
|
|
removeActiveTerm: (termIndex: number, segmentId?: string) => void
|
|
}
|
|
|
|
const InternalDecompositionContext = createContext<InternalDecompositionContextType>({
|
|
activeTerms: new Set(),
|
|
activeSegmentId: null,
|
|
addActiveTerm: () => {},
|
|
removeActiveTerm: () => {},
|
|
})
|
|
|
|
// ============================================================================
|
|
// TermSpan Component
|
|
// ============================================================================
|
|
|
|
interface TermSpanProps {
|
|
termIndex: number
|
|
text: string
|
|
segment?: PedagogicalSegment
|
|
isCurrentStep?: boolean
|
|
}
|
|
|
|
function TermSpan({ termIndex, text, segment, isCurrentStep = false }: TermSpanProps) {
|
|
const { addActiveTerm, removeActiveTerm } = useContext(InternalDecompositionContext)
|
|
const rule = segment?.plan[0]?.rule
|
|
|
|
// Only show styling for terms that have pedagogical reasoning
|
|
if (!rule) {
|
|
return <span className="term term--plain">{text}</span>
|
|
}
|
|
|
|
// Determine CSS classes based on current step only
|
|
const cssClasses = ['term', isCurrentStep && 'term--current'].filter(Boolean).join(' ')
|
|
|
|
// Individual term hover handlers for two-level highlighting
|
|
const handleTermHover = (isHovering: boolean) => {
|
|
if (isHovering) {
|
|
addActiveTerm(termIndex, segment?.id)
|
|
} else {
|
|
removeActiveTerm(termIndex, segment?.id)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<span
|
|
data-element="decomposition-term"
|
|
data-term-index={termIndex}
|
|
className={cssClasses}
|
|
onMouseEnter={() => handleTermHover(true)}
|
|
onMouseLeave={() => handleTermHover(false)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{text}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// SegmentGroup Component
|
|
// ============================================================================
|
|
|
|
interface SegmentGroupProps {
|
|
segment: PedagogicalSegment
|
|
steps: UnifiedStepData[]
|
|
children: React.ReactNode
|
|
}
|
|
|
|
function SegmentGroup({ segment, steps, children }: SegmentGroupProps) {
|
|
const { addActiveTerm, removeActiveTerm } = useContext(InternalDecompositionContext)
|
|
|
|
// Calculate the original term that was expanded
|
|
// digit * 10^place gives us the original value (e.g., digit=5, place=1 -> 50)
|
|
const originalValue = (segment.digit * 10 ** segment.place).toString()
|
|
|
|
// Get provenance from the first step in this segment
|
|
const firstStepIndex = segment.termIndices[0]
|
|
const firstStep = steps[firstStepIndex]
|
|
const provenance = firstStep?.provenance
|
|
|
|
const handleHighlightChange = (active: boolean) => {
|
|
// Only handle highlighting, let HoverCard manage its own open/close timing
|
|
if (active) {
|
|
segment.termIndices.forEach((termIndex) => addActiveTerm(termIndex, segment.id))
|
|
} else {
|
|
segment.termIndices.forEach((termIndex) => removeActiveTerm(termIndex, segment.id))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ReasonTooltip
|
|
termIndex={segment.termIndices[0]} // Use first term for tooltip ID
|
|
segment={segment}
|
|
originalValue={originalValue}
|
|
steps={steps}
|
|
provenance={provenance}
|
|
>
|
|
<span
|
|
data-element="segment-group"
|
|
data-segment-id={segment.id}
|
|
className="segment-group"
|
|
onMouseEnter={() => handleHighlightChange(true)}
|
|
onMouseLeave={() => handleHighlightChange(false)}
|
|
>
|
|
{children}
|
|
</span>
|
|
</ReasonTooltip>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// DecompositionDisplay Component
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Renders the decomposition string with interactive terms and tooltips.
|
|
*
|
|
* Must be used inside a DecompositionProvider.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <DecompositionProvider startValue={0} targetValue={45}>
|
|
* <DecompositionDisplay />
|
|
* </DecompositionProvider>
|
|
* ```
|
|
*/
|
|
export function DecompositionDisplay() {
|
|
const {
|
|
fullDecomposition,
|
|
termPositions,
|
|
segments,
|
|
steps,
|
|
currentStepIndex,
|
|
activeTermIndices,
|
|
setActiveTermIndices,
|
|
setActiveIndividualTermIndex,
|
|
getGroupTermIndicesFromTermIndex,
|
|
} = useDecomposition()
|
|
|
|
// Build a quick lookup: termIndex -> segment
|
|
const termIndexToSegment = useMemo(() => {
|
|
const map = new Map<number, PedagogicalSegment>()
|
|
segments?.forEach((seg) => seg.termIndices.forEach((i) => map.set(i, seg)))
|
|
return map
|
|
}, [segments])
|
|
|
|
// Determine which segment should be highlighted based on active terms
|
|
const activeSegmentId = useMemo(() => {
|
|
if (activeTermIndices.size === 0) return null
|
|
|
|
// Find the segment that contains any of the active terms
|
|
for (const termIndex of activeTermIndices) {
|
|
const segment = termIndexToSegment.get(termIndex)
|
|
if (segment) {
|
|
return segment.id
|
|
}
|
|
}
|
|
return null
|
|
}, [activeTermIndices, termIndexToSegment])
|
|
|
|
// Term hover handlers
|
|
const addActiveTerm = (termIndex: number, _segmentId?: string) => {
|
|
// Set individual term highlight (orange glow)
|
|
setActiveIndividualTermIndex(termIndex)
|
|
|
|
// Set group term highlights (blue glow) - for complement groups, highlight only the target column
|
|
const groupTermIndices = getGroupTermIndicesFromTermIndex(termIndex)
|
|
|
|
if (groupTermIndices.length > 0) {
|
|
// For complement groups, highlight only the target column (rhsPlace, not individual termPlaces)
|
|
// Use any term from the group since they all share the same rhsPlace (target column)
|
|
setActiveTermIndices(new Set([termIndex]))
|
|
} else {
|
|
// This is a standalone term, just highlight it
|
|
setActiveTermIndices(new Set([termIndex]))
|
|
}
|
|
}
|
|
|
|
const removeActiveTerm = (_termIndex: number, _segmentId?: string) => {
|
|
// Clear individual term highlight
|
|
setActiveIndividualTermIndex(null)
|
|
|
|
// Clear group term highlights
|
|
setActiveTermIndices(new Set())
|
|
}
|
|
|
|
// Render elements with segment groupings
|
|
const renderElements = () => {
|
|
const elements: React.ReactNode[] = []
|
|
let cursor = 0
|
|
|
|
for (let termIndex = 0; termIndex < termPositions.length; termIndex++) {
|
|
const { startIndex, endIndex } = termPositions[termIndex]
|
|
const segment = termIndexToSegment.get(termIndex)
|
|
|
|
// Add connector text before this term
|
|
if (cursor < startIndex) {
|
|
elements.push(
|
|
<span key={`connector-${cursor}`}>{fullDecomposition.slice(cursor, startIndex)}</span>
|
|
)
|
|
}
|
|
|
|
// Check if this term starts a new segment
|
|
if (segment && segment.termIndices[0] === termIndex) {
|
|
// This is the first term of a segment - wrap all segment terms
|
|
const segmentElements: React.ReactNode[] = []
|
|
let segmentCursor = startIndex
|
|
|
|
for (const segTermIndex of segment.termIndices) {
|
|
const segPos = termPositions[segTermIndex]
|
|
if (!segPos) continue
|
|
|
|
// Add connector within segment
|
|
if (segmentCursor < segPos.startIndex) {
|
|
segmentElements.push(
|
|
<span key={`seg-connector-${segmentCursor}`}>
|
|
{fullDecomposition.slice(segmentCursor, segPos.startIndex)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const segText = fullDecomposition.slice(segPos.startIndex, segPos.endIndex)
|
|
|
|
segmentElements.push(
|
|
<TermSpan
|
|
key={`seg-term-${segTermIndex}`}
|
|
termIndex={segTermIndex}
|
|
text={segText}
|
|
segment={segment}
|
|
isCurrentStep={segTermIndex === currentStepIndex}
|
|
/>
|
|
)
|
|
|
|
segmentCursor = segPos.endIndex
|
|
}
|
|
|
|
elements.push(
|
|
<SegmentGroup key={`segment-${segment.id}`} segment={segment} steps={steps}>
|
|
{segmentElements}
|
|
</SegmentGroup>
|
|
)
|
|
|
|
// Skip ahead past all terms in this segment
|
|
const lastSegTermIndex = segment.termIndices[segment.termIndices.length - 1]
|
|
const lastSegPos = termPositions[lastSegTermIndex]
|
|
cursor = lastSegPos?.endIndex ?? endIndex
|
|
termIndex = lastSegTermIndex // Will be incremented by for loop
|
|
} else if (!segment) {
|
|
// Regular term not in a segment
|
|
const termText = fullDecomposition.slice(startIndex, endIndex)
|
|
elements.push(
|
|
<TermSpan
|
|
key={`term-${termIndex}`}
|
|
termIndex={termIndex}
|
|
text={termText}
|
|
segment={segment}
|
|
isCurrentStep={termIndex === currentStepIndex}
|
|
/>
|
|
)
|
|
cursor = endIndex
|
|
}
|
|
// If this term is part of a segment but not the first, it was already handled above
|
|
}
|
|
|
|
// Add trailing text
|
|
if (cursor < fullDecomposition.length) {
|
|
elements.push(<span key="trailing">{fullDecomposition.slice(cursor)}</span>)
|
|
}
|
|
|
|
return elements
|
|
}
|
|
|
|
return (
|
|
<InternalDecompositionContext.Provider
|
|
value={{
|
|
activeTerms: activeTermIndices,
|
|
activeSegmentId,
|
|
addActiveTerm,
|
|
removeActiveTerm,
|
|
}}
|
|
>
|
|
<div data-element="decomposition-display" className="decomposition">
|
|
{renderElements()}
|
|
</div>
|
|
</InternalDecompositionContext.Provider>
|
|
)
|
|
}
|