196 lines
6.9 KiB
JavaScript
196 lines
6.9 KiB
JavaScript
import { useRef, useState } from 'react'
|
||
import { useDraggable } from '@dnd-kit/core'
|
||
import { useSortable } from '@dnd-kit/sortable'
|
||
import { CSS } from '@dnd-kit/utilities'
|
||
import { VARIABLES } from '../engine/variables.js'
|
||
import { getUnitsForFamily } from '../engine/units.js'
|
||
import { formatValue } from '../engine/format.js'
|
||
|
||
// Card shown in the left palette — draggable
|
||
export function PaletteCard({ varId, isInWorkspace }) {
|
||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||
id: `palette-${varId}`,
|
||
data: { varId, source: 'palette' },
|
||
})
|
||
|
||
const v = VARIABLES[varId]
|
||
if (!v) return null
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
{...listeners}
|
||
{...attributes}
|
||
className={`
|
||
flex items-center gap-2 px-3 py-2 rounded-lg border text-sm cursor-grab select-none transition-all
|
||
${isDragging ? 'opacity-40' : ''}
|
||
${isInWorkspace
|
||
? 'border-slate-600 bg-slate-800 text-slate-500'
|
||
: 'border-slate-600 bg-slate-700 text-slate-200 hover:border-blue-400 hover:bg-slate-600 active:cursor-grabbing'}
|
||
`}
|
||
>
|
||
<span className="font-mono font-bold text-blue-300 w-10 shrink-0 text-center">{v.symbol}</span>
|
||
<div className="min-w-0">
|
||
<div className="truncate">{v.name}</div>
|
||
<div className="text-xs text-slate-400">{v.units}</div>
|
||
</div>
|
||
{isInWorkspace && (
|
||
<span className="ml-auto text-green-500 text-xs shrink-0">✓</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Card shown in the workspace
|
||
export function WorkspaceCard({
|
||
varId,
|
||
userValue,
|
||
solvedInfo,
|
||
onRemove,
|
||
onValueChange,
|
||
getUnit,
|
||
onUnitChange,
|
||
sciNotation,
|
||
areaRatioBranch,
|
||
onToggleBranch,
|
||
}) {
|
||
const inputRef = useRef(null)
|
||
const [inputError, setInputError] = useState(false)
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: varId })
|
||
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
}
|
||
|
||
const v = VARIABLES[varId]
|
||
if (!v) return null
|
||
|
||
const unit = getUnit(varId)
|
||
const availableUnits = getUnitsForFamily(v.unitFamily)
|
||
|
||
const isUserSet = userValue !== undefined && userValue !== ''
|
||
const isSolved = !!solvedInfo
|
||
|
||
const handleInputChange = (rawDisplayValue) => {
|
||
if (rawDisplayValue === '') {
|
||
setInputError(false)
|
||
onValueChange(rawDisplayValue)
|
||
return
|
||
}
|
||
const parsed = parseFloat(rawDisplayValue)
|
||
if (isNaN(parsed)) return
|
||
|
||
// Validate positive constraint
|
||
if (v.positive && parsed < 0) {
|
||
setInputError(true)
|
||
return
|
||
}
|
||
setInputError(false)
|
||
onValueChange(rawDisplayValue)
|
||
}
|
||
|
||
// State: user-entered (blue), solved (green), unknown (grey), error (red)
|
||
const borderColor = inputError ? 'border-red-500' : isUserSet ? 'border-blue-500' : isSolved ? 'border-green-500' : 'border-slate-600'
|
||
const symbolColor = inputError ? 'text-red-300' : isUserSet ? 'text-blue-300' : isSolved ? 'text-green-300' : 'text-slate-400'
|
||
const valueBg = inputError ? 'bg-red-950' : isUserSet ? 'bg-blue-950' : isSolved ? 'bg-green-950' : 'bg-slate-800'
|
||
|
||
// Display value converted from stored SI to current display unit
|
||
const inputDisplayValue = isUserSet
|
||
? parseFloat(unit.fromSI(userValue).toPrecision(10))
|
||
: ''
|
||
|
||
const siUnit = availableUnits[0]
|
||
const isNonSI = unit.id !== siUnit.id
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={`rounded-xl border-2 ${borderColor} bg-slate-800 p-3 flex flex-col gap-1 relative group ${isDragging ? 'opacity-50' : ''}`}
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<button
|
||
onClick={onRemove}
|
||
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-slate-700 text-slate-400 hover:bg-red-700 hover:text-white opacity-0 group-hover:opacity-100 transition-all text-xs flex items-center justify-center leading-none"
|
||
title="Remove from workspace"
|
||
>
|
||
×
|
||
</button>
|
||
<div className="flex items-baseline gap-2">
|
||
<span className={`font-mono font-bold text-lg ${symbolColor}`}>{v.symbol}</span>
|
||
{availableUnits.length > 1 ? (
|
||
<select
|
||
value={unit.id}
|
||
onChange={e => onUnitChange(e.target.value)}
|
||
className={`text-xs rounded px-1.5 py-0.5 border outline-none cursor-pointer transition-colors ${
|
||
isNonSI
|
||
? 'bg-violet-900/40 text-violet-200 border-violet-600 hover:border-violet-400'
|
||
: 'bg-transparent text-slate-400 border-transparent hover:border-slate-500 hover:text-slate-300'
|
||
}`}
|
||
>
|
||
{availableUnits.map(u => (
|
||
<option key={u.id} value={u.id} className="bg-slate-800 text-slate-200">{u.label}</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<span className="text-slate-400 text-xs">{unit.label}</span>
|
||
)}
|
||
{isSolved && varId === 'Me' && solvedInfo?.equationName === 'Isentropic Area Ratio' && onToggleBranch && (
|
||
<button
|
||
onClick={onToggleBranch}
|
||
title={`Switch to ${areaRatioBranch === 'supersonic' ? 'subsonic' : 'supersonic'}`}
|
||
className="ml-auto text-xs px-1.5 py-0.5 rounded border transition-colors bg-amber-900/40 text-amber-300 border-amber-600 hover:border-amber-400 hover:bg-amber-900/60"
|
||
>
|
||
{areaRatioBranch === 'supersonic' ? 'SS' : 'Sub'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="text-slate-300 text-xs truncate">{v.name}</div>
|
||
<div className={`mt-1 rounded-md ${valueBg} px-2 py-1`}>
|
||
{isSolved && !isUserSet ? (
|
||
<div>
|
||
<div className="font-mono text-green-300 text-sm font-semibold">
|
||
{formatValue(unit.fromSI(solvedInfo.value), sciNotation)}
|
||
</div>
|
||
{isNonSI && (
|
||
<div className="text-green-700 text-[10px] mt-0.5 font-mono">
|
||
= {formatValue(solvedInfo.value, sciNotation)} {siUnit.label}
|
||
</div>
|
||
)}
|
||
<div className="text-green-600 text-xs mt-0.5">via {solvedInfo.equationName}</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<input
|
||
ref={inputRef}
|
||
type="number"
|
||
value={inputDisplayValue}
|
||
onChange={e => handleInputChange(e.target.value)}
|
||
placeholder="Enter value…"
|
||
className={`w-full bg-transparent font-mono text-sm ${inputError ? 'text-red-200' : 'text-blue-200'} placeholder-slate-600 outline-none`}
|
||
/>
|
||
{inputError && (
|
||
<p className="text-xs text-red-400 mt-0.5">Must be positive</p>
|
||
)}
|
||
{isUserSet && isNonSI && !inputError && (
|
||
<div className="text-blue-800 text-[10px] mt-0.5 font-mono">
|
||
= {formatValue(userValue, sciNotation)} {siUnit.label}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|