my-multiplayer-app-2 / client /components /EquationRendererSimple.tsx
Jaimodiji's picture
Deploying multiplayer tldraw app
2ec5eb1 verified
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
import { track, useEditor, TldrawUiButton, TldrawUiButtonIcon, createShapeId, TLShapeId } from 'tldraw'
import { math2svg, math2svgFull } from '../utils/math2svg'
import { EnhancedColorPicker } from './EnhancedColorPicker'
// Default Initial State
const INITIAL_STATE = {
type: 'function' as 'function' | 'parametric',
expression: 'sin(x)',
xExpression: 'cos(t)',
yExpression: 'sin(t)',
xMin: -10,
xMax: 10,
tMin: 0,
tMax: 6.28,
scaleX: 40,
scaleY: 40,
offsetX: 250,
offsetY: 250,
showAxes: true,
showGrid: false,
showNumbers: true,
axisColor: '#666666',
gridColor: '#e5e5e5',
strokeColor: '#2563eb',
fontSize: 8,
fontFamily: 'monospace' as 'monospace' | 'sans-serif' | 'serif'
}
export const EquationRenderer = track(() => {
const editor = useEditor()
const [isOpen, setIsOpen] = useState(false)
const [editingShapeId, setEditingShapeId] = useState<TLShapeId | null>(null)
const isDark = editor.user.getIsDarkMode()
// Local state for creation mode
const [localState, setLocalState] = useState(INITIAL_STATE)
// Reset state when closing
const handleClose = useCallback(() => {
setIsOpen(false)
setEditingShapeId(null)
setLocalState(INITIAL_STATE)
}, [])
// Listen for external open-equation-editor event (from context menu)
useEffect(() => {
const handleOpenEditor = (event: CustomEvent) => {
const { shapeId } = event.detail
const shape = editor.getShape(shapeId)
if (shape && shape.type === 'equation') {
setEditingShapeId(shapeId)
setLocalState({
type: (shape.props as any).type || 'function',
expression: (shape.props as any).expression || 'sin(x)',
xExpression: (shape.props as any).xExpression || 'cos(t)',
yExpression: (shape.props as any).yExpression || 'sin(t)',
xMin: (shape.props as any).xMin ?? -10,
xMax: (shape.props as any).xMax ?? 10,
tMin: (shape.props as any).tMin ?? 0,
tMax: (shape.props as any).tMax ?? 6.28,
scaleX: (shape.props as any).scaleX ?? 40,
scaleY: (shape.props as any).scaleY ?? 40,
offsetX: (shape.props as any).offsetX ?? 250,
offsetY: (shape.props as any).offsetY ?? 250,
showAxes: (shape.props as any).showAxes ?? true,
showGrid: (shape.props as any).showGrid ?? false,
showNumbers: (shape.props as any).showNumbers ?? true,
axisColor: (shape.props as any).axisColor || '#666666',
gridColor: (shape.props as any).gridColor || '#e5e5e5',
strokeColor: (shape.props as any).strokeColor || '#2563eb',
fontSize: (shape.props as any).fontSize ?? 8,
fontFamily: (shape.props as any).fontFamily || 'monospace'
})
setIsOpen(true)
}
}
window.addEventListener('open-equation-editor' as any, handleOpenEditor)
return () => window.removeEventListener('open-equation-editor' as any, handleOpenEditor)
}, [editor])
// Selection Logic - only open via explicit trigger, not auto-open on selection
// Note: We don't auto-open on selection anymore
// Derived State (Current Values)
const values = localState
// Ensure defaults for missing props (compatibility)
const state = { ...INITIAL_STATE, ...values }
// Update Handler
const updateState = useCallback((changes: Partial<typeof INITIAL_STATE>) => {
setLocalState(prev => ({ ...prev, ...changes }))
// If editing an existing shape, update it in real-time
if (editingShapeId) {
editor.updateShape({
id: editingShapeId,
type: 'equation',
props: changes
})
}
}, [editor, editingShapeId])
// SVG Generation for Preview
const svgPath = useMemo(() => {
return math2svg({
type: state.type,
expression: state.expression,
xExpression: state.xExpression,
yExpression: state.yExpression,
xMin: state.xMin,
xMax: state.xMax,
tMin: state.tMin,
tMax: state.tMax,
scaleX: state.scaleX,
scaleY: state.scaleY,
offsetX: state.offsetX,
offsetY: state.offsetY,
samples: 400
})
}, [state])
// Interaction State for Preview Pan/Zoom
const isDragging = useRef(false)
const lastPos = useRef({ x: 0, y: 0 })
const initialDistance = useRef(0)
const isPinching = useRef(false)
const velocity = useRef({ x: 0, y: 0 })
const lastMoveTime = useRef(0)
const animationFrame = useRef<number | null>(null)
const handleWheel = (e: React.WheelEvent) => {
e.stopPropagation()
e.preventDefault()
const s = e.deltaY > 0 ? 0.9 : 1.1
updateState({
scaleX: Math.max(1, state.scaleX * s),
scaleY: Math.max(1, state.scaleY * s)
})
}
// Use touch events for better Android support
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
// Two-finger pinch
isPinching.current = true
isDragging.current = false
const touch1 = e.touches[0]
const touch2 = e.touches[1]
const distance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
)
initialDistance.current = distance
} else if (e.touches.length === 1) {
// Single finger drag
isDragging.current = true
isPinching.current = false
const touch = e.touches[0]
lastPos.current = { x: touch.clientX, y: touch.clientY }
}
}
const handleTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 2 && isPinching.current) {
// Pinch zoom
e.preventDefault()
const touch1 = e.touches[0]
const touch2 = e.touches[1]
const distance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
)
if (initialDistance.current > 0) {
const scale = distance / initialDistance.current
updateState({
scaleX: Math.max(1, state.scaleX * scale),
scaleY: Math.max(1, state.scaleY * scale)
})
initialDistance.current = distance
}
} else if (e.touches.length === 1 && isDragging.current && !isPinching.current) {
// Single finger pan with velocity tracking
e.preventDefault()
const touch = e.touches[0]
const now = Date.now()
const dt = now - lastMoveTime.current
const dx = touch.clientX - lastPos.current.x
const dy = touch.clientY - lastPos.current.y
// Calculate velocity for momentum
if (dt > 0) {
velocity.current = {
x: dx / dt * 16, // Normalize to ~60fps
y: dy / dt * 16
}
}
lastPos.current = { x: touch.clientX, y: touch.clientY }
lastMoveTime.current = now
updateState({
offsetX: state.offsetX + dx,
offsetY: state.offsetY + dy
})
}
}
const handleTouchEnd = (e: React.TouchEvent) => {
if (e.touches.length < 2) {
isPinching.current = false
initialDistance.current = 0
}
if (e.touches.length === 0) {
isDragging.current = false
// Apply momentum/inertia
const speed = Math.hypot(velocity.current.x, velocity.current.y)
if (speed > 1) {
applyMomentum()
}
}
}
const applyMomentum = () => {
const friction = 0.95 // Deceleration factor
const minSpeed = 0.5
const animate = () => {
const speed = Math.hypot(velocity.current.x, velocity.current.y)
if (speed < minSpeed || isDragging.current) {
velocity.current = { x: 0, y: 0 }
animationFrame.current = null
return
}
// Apply velocity
updateState({
offsetX: state.offsetX + velocity.current.x,
offsetY: state.offsetY + velocity.current.y
})
// Apply friction
velocity.current = {
x: velocity.current.x * friction,
y: velocity.current.y * friction
}
animationFrame.current = requestAnimationFrame(animate)
}
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current)
}
animationFrame.current = requestAnimationFrame(animate)
}
// Mouse events for desktop
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
// Cancel any ongoing momentum
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current)
animationFrame.current = null
}
velocity.current = { x: 0, y: 0 }
isDragging.current = true
lastPos.current = { x: e.clientX, y: e.clientY }
lastMoveTime.current = Date.now()
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return
e.stopPropagation()
e.preventDefault()
const now = Date.now()
const dt = now - lastMoveTime.current
const dx = e.clientX - lastPos.current.x
const dy = e.clientY - lastPos.current.y
// Calculate velocity for momentum
if (dt > 0) {
velocity.current = {
x: dx / dt * 16,
y: dy / dt * 16
}
}
lastPos.current = { x: e.clientX, y: e.clientY }
lastMoveTime.current = now
updateState({
offsetX: state.offsetX + dx,
offsetY: state.offsetY + dy
})
}
const handleMouseUp = (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
isDragging.current = false
// Apply momentum/inertia for mouse too
const speed = Math.hypot(velocity.current.x, velocity.current.y)
if (speed > 1) {
applyMomentum()
}
}
// Cleanup animation frame on unmount
useEffect(() => {
return () => {
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current)
}
}
}, [])
const copy = async () => {
const svgString = math2svgFull({ ...state, samples: 400 })
try {
await navigator.clipboard.writeText(svgString)
editor.setCurrentTool('select')
} catch (err) {}
}
const addToCanvas = () => {
// Create Equation Shape
const id = createShapeId()
const center = editor.getViewportPageBounds().center
editor.createShape({
id,
type: 'equation',
x: center.x - 250, // Center the 500x500 shape
y: center.y - 250,
props: {
w: 500,
h: 500,
...state
}
})
// Don't close, maybe user wants to add more? Or close.
// setIsOpen(false)
}
if (!isOpen) {
return (
<div style={{
position: 'fixed',
top: '180px',
left: '12px',
zIndex: 400,
}}>
<TldrawUiButton
type="normal"
onClick={() => setIsOpen(true)}
title="Function Plotter"
style={{
width: '40px',
height: '40px',
background: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-medium)',
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: '14px', fontWeight: 700 }}>f(x)</span>
</TldrawUiButton>
</div>
)
}
// Styles
const previewBg = isDark ? '#212121' : '#ffffff'
return (
<div
onPointerDown={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: 'max(60px, env(safe-area-inset-top, 12px))',
left: 'max(60px, env(safe-area-inset-left, 12px))',
right: 'max(12px, env(safe-area-inset-right, 12px))',
width: '320px',
maxWidth: 'calc(100vw - max(72px, env(safe-area-inset-left, 12px) + env(safe-area-inset-right, 12px) + 24px))',
maxHeight: 'calc(100dvh - max(80px, env(safe-area-inset-top, 12px) + env(safe-area-inset-bottom, 12px) + 24px))',
background: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-medium)',
boxShadow: 'var(--shadow-3)',
zIndex: 400,
display: 'flex',
flexDirection: 'column',
backdropFilter: 'blur(20px)',
overflow: 'hidden'
}}
>
{/* Header */}
<div style={{
padding: '12px',
borderBottom: '1px solid var(--color-divider)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'var(--color-panel-header)',
flexShrink: 0,
}}>
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--color-text)' }}>
{editingShapeId ? 'Edit Formula' : 'Function Plotter'}
</span>
<TldrawUiButton
type="icon"
onClick={handleClose}
title="Close"
>
<TldrawUiButtonIcon icon="cross-2" />
</TldrawUiButton>
</div>
{/* Content */}
<div style={{
padding: '12px',
overflowY: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
{/* Type Selector */}
<div style={{ display: 'flex', gap: '8px', background: 'var(--color-low)', padding: '4px', borderRadius: '6px' }}>
<button
onClick={() => updateState({ type: 'function' })}
style={{
flex: 1,
padding: '6px',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 600,
background: state.type === 'function' ? 'var(--color-selected)' : 'transparent',
color: state.type === 'function' ? 'white' : 'var(--color-text)',
cursor: 'pointer'
}}
>
y(x)
</button>
<button
onClick={() => updateState({ type: 'parametric' })}
style={{
flex: 1,
padding: '6px',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 600,
background: state.type === 'parametric' ? 'var(--color-selected)' : 'transparent',
color: state.type === 'parametric' ? 'white' : 'var(--color-text)',
cursor: 'pointer'
}}
>
Parametric
</button>
</div>
{/* Inputs */}
{state.type === 'function' ? (
<>
<div>
<Label>y(x) =</Label>
<Input value={state.expression} onChange={(v: string) => updateState({ expression: v })} placeholder="sin(x)" />
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<Label>x min</Label>
<Input value={state.xMin} onChange={(v: string) => updateState({ xMin: Number(v) })} type="number" />
</div>
<div style={{ flex: 1 }}>
<Label>x max</Label>
<Input value={state.xMax} onChange={(v: string) => updateState({ xMax: Number(v) })} type="number" />
</div>
</div>
</>
) : (
<>
<div>
<Label>x(t) =</Label>
<Input value={state.xExpression} onChange={(v: string) => updateState({ xExpression: v })} placeholder="cos(t)" />
</div>
<div>
<Label>y(t) =</Label>
<Input value={state.yExpression} onChange={(v: string) => updateState({ yExpression: v })} placeholder="sin(t)" />
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<Label>t min</Label>
<Input value={state.tMin} onChange={(v: string) => updateState({ tMin: Number(v) })} type="number" />
</div>
<div style={{ flex: 1 }}>
<Label>t max</Label>
<Input value={state.tMax} onChange={(v: string) => updateState({ tMax: Number(v) })} type="number" />
</div>
</div>
</>
)}
{/* Visual Settings */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<Label>Scale X</Label>
<Input value={state.scaleX} onChange={(v: string) => updateState({ scaleX: Number(v) })} type="number" />
</div>
<div style={{ flex: 1 }}>
<Label>Scale Y</Label>
<Input value={state.scaleY} onChange={(v: string) => updateState({ scaleY: Number(v) })} type="number" />
</div>
</div>
{/* Toggles */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Checkbox
label="Axes & Grid"
checked={state.showAxes}
onChange={(v: boolean) => updateState({ showAxes: v, showGrid: v })}
/>
<Checkbox label="Numbers" checked={state.showNumbers} onChange={(v: boolean) => updateState({ showNumbers: v })} />
</div>
{/* Font Settings */}
{state.showNumbers && (
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<Label>Font Size</Label>
<Input value={state.fontSize} onChange={(v: string) => updateState({ fontSize: Number(v) })} type="number" />
</div>
<div style={{ flex: 1 }}>
<Label>Font</Label>
<select
value={state.fontFamily}
onChange={(e) => updateState({ fontFamily: e.target.value as 'monospace' | 'sans-serif' | 'serif' })}
style={{
width: '100%',
padding: '6px',
fontSize: '12px',
background: 'var(--color-low)',
color: 'var(--color-text)',
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-small)',
outline: 'none'
}}
>
<option value="monospace">Mono</option>
<option value="sans-serif">Sans</option>
<option value="serif">Serif</option>
</select>
</div>
</div>
)}
{/* Colors */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<EnhancedColorPicker
label="Curve Color"
color={state.strokeColor}
onChange={(color) => updateState({ strokeColor: color })}
/>
<EnhancedColorPicker
label="Axes Color"
color={state.axisColor}
onChange={(color) => updateState({ axisColor: color })}
/>
<EnhancedColorPicker
label="Grid Color"
color={state.gridColor}
onChange={(color) => updateState({ gridColor: color })}
/>
</div>
{/* Preview */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingTop: '8px',
borderTop: '1px solid var(--color-divider)',
flexShrink: 0
}}>
<div
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
style={{
background: previewBg,
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-small)',
height: '200px',
overflow: 'hidden',
flexShrink: 0,
position: 'relative',
cursor: 'move',
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none'
}}
>
<svg
width="100%"
height="100%"
viewBox="0 0 500 500"
style={{ overflow: 'visible', pointerEvents: 'none' }}
>
{state.showGrid && (
<pattern id="preview-grid" width={state.scaleX} height={state.scaleY} patternUnits="userSpaceOnUse">
<path d={`M ${state.scaleX} 0 L 0 0 0 ${state.scaleY}`} fill="none" stroke={state.gridColor} strokeWidth="0.5"/>
</pattern>
)}
{state.showGrid && <rect width="100%" height="100%" fill="url(#preview-grid)" />}
{state.showAxes && (
<>
<line x1="0" y1={state.offsetY} x2="500" y2={state.offsetY} stroke={state.axisColor} strokeWidth="1.5" />
<line x1={state.offsetX} y1="0" x2={state.offsetX} y2="500" stroke={state.axisColor} strokeWidth="1.5" />
{state.showNumbers && (
<>
{/* X-axis numbers */}
{(() => {
const ticks = []
const xStep = state.scaleX
if (xStep > 20) {
for (let x = state.offsetX % xStep; x < 500; x += xStep) {
const val = Math.round((x - state.offsetX) / state.scaleX)
if (val === 0) continue
ticks.push(
<g key={`x-${x}`}>
<line x1={x} y1={state.offsetY - 3} x2={x} y2={state.offsetY + 3} stroke={state.axisColor} strokeWidth="1" />
<text x={x} y={state.offsetY + 12} fontSize="8" fill={state.axisColor} textAnchor="middle" fontFamily="monospace">{val}</text>
</g>
)
}
}
return ticks
})()}
{/* Y-axis numbers */}
{(() => {
const ticks = []
const yStep = state.scaleY
if (yStep > 20) {
for (let y = state.offsetY % yStep; y < 500; y += yStep) {
const val = Math.round((state.offsetY - y) / state.scaleY)
if (val === 0) continue
ticks.push(
<g key={`y-${y}`}>
<line x1={state.offsetX - 3} y1={y} x2={state.offsetX + 3} y2={y} stroke={state.axisColor} strokeWidth="1" />
<text x={state.offsetX - 6} y={y + 3} fontSize="8" fill={state.axisColor} textAnchor="end" fontFamily="monospace">{val}</text>
</g>
)
}
}
return ticks
})()}
<text x={state.offsetX - 4} y={state.offsetY + 12} fontSize="8" fill={state.axisColor} textAnchor="end" fontFamily="monospace">0</text>
</>
)}
</>
)}
<path
d={svgPath}
fill="none"
stroke={state.strokeColor}
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
<div style={{
position: 'absolute', bottom: 4, right: 6,
fontSize: '10px', color: 'var(--color-text-3)', pointerEvents: 'none',
background: 'rgba(0,0,0,0.5)',
padding: '2px 6px',
borderRadius: '4px'
}}>
Pinch to zoom • Drag to pan
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<TldrawUiButton
type="normal"
onClick={copy}
style={{ flex: 1, justifyContent: 'center' }}
>
Copy SVG
</TldrawUiButton>
{!editingShapeId && (
<TldrawUiButton
type="primary"
onClick={addToCanvas}
style={{ flex: 1, justifyContent: 'center' }}
>
Add to Board
</TldrawUiButton>
)}
{editingShapeId && (
<TldrawUiButton
type="primary"
onClick={handleClose}
style={{ flex: 1, justifyContent: 'center' }}
>
Done
</TldrawUiButton>
)}
</div>
</div>
</div>
</div>
)
})
const Label = ({ children }: { children: React.ReactNode }) => (
<label style={{
display: 'block',
fontSize: '10px',
fontWeight: 600,
marginBottom: '4px',
color: 'var(--color-text-1)',
textTransform: 'uppercase'
}}>
{children}
</label>
)
const Input = ({ value, onChange, placeholder, type = 'text' }: any) => (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
width: '100%',
padding: '6px',
fontSize: '12px',
background: 'var(--color-low)',
color: 'var(--color-text)',
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-small)',
outline: 'none',
fontFamily: 'monospace'
}}
/>
)
const Checkbox = ({ label, checked, onChange }: any) => (
<div
onClick={() => onChange(!checked)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
color: 'var(--color-text)'
}}
>
<div style={{
width: '14px',
height: '14px',
border: `1px solid ${checked ? 'var(--color-selected)' : 'var(--color-text-2)'}`,
background: checked ? 'var(--color-selected)' : 'transparent',
borderRadius: '3px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{checked && <div style={{ width: '8px', height: '8px', background: 'white', borderRadius: '1px' }} />}
</div>
{label}
</div>
)