771 lines
24 KiB
Markdown
771 lines
24 KiB
Markdown
# Geometric Puzzles & Dissections
|
|
|
|
Animated geometric puzzles: Dudeney dissections, tangrams, tessellations, Penrose tiles, and more. SVG + GSAP for precise animated transformations.
|
|
|
|
## Table of Contents
|
|
|
|
- [Dudeney Dissections](#dudeney-dissections)
|
|
- [Interactive Tangram](#interactive-tangram)
|
|
- [Tessellations](#tessellations)
|
|
- [Penrose Tiles](#penrose-tiles)
|
|
- [Polyominoes](#polyominoes)
|
|
- [Geometric Transformations](#geometric-transformations)
|
|
- [Dissection Puzzle Framework](#dissection-puzzle-framework)
|
|
|
|
---
|
|
|
|
## Dudeney Dissections
|
|
|
|
The famous equilateral triangle → square dissection with 4 hinged pieces.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useState } from 'react'
|
|
import { gsap, useGSAP } from '@/lib/gsap'
|
|
|
|
// Triangle-to-square: 4 pieces with precise SVG paths
|
|
// Coordinates based on Dudeney's original 4-piece hinged dissection
|
|
const TRIANGLE_PIECES = [
|
|
{ id: 'A', tri: 'M 0,173.2 L 50,86.6 L 100,173.2 Z', sq: 'M 0,0 L 100,0 L 100,86.6 L 0,86.6 Z' },
|
|
{ id: 'B', tri: 'M 50,86.6 L 100,0 L 150,86.6 Z', sq: 'M 100,0 L 200,0 L 200,86.6 L 100,86.6 Z' },
|
|
{ id: 'C', tri: 'M 100,173.2 L 150,86.6 L 200,173.2 Z', sq: 'M 0,86.6 L 100,86.6 L 100,173.2 L 0,173.2 Z' },
|
|
{ id: 'D', tri: 'M 50,86.6 L 100,173.2 L 150,86.6 L 100,0 Z', sq: 'M 100,86.6 L 200,86.6 L 200,173.2 L 100,173.2 Z' },
|
|
]
|
|
|
|
const COLORS = ['#f43f5e', '#8b5cf6', '#06b6d4', '#f59e0b']
|
|
|
|
export function DudeneyDissection() {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const [isSquare, setIsSquare] = useState(false)
|
|
|
|
useGSAP(() => {
|
|
// Initial setup — start as triangle
|
|
TRIANGLE_PIECES.forEach((piece, i) => {
|
|
const el = svgRef.current!.querySelector(`#piece-${piece.id}`)
|
|
if (el) {
|
|
gsap.set(el, { attr: { d: piece.tri } })
|
|
}
|
|
})
|
|
}, { scope: svgRef })
|
|
|
|
const morph = () => {
|
|
const target = !isSquare
|
|
TRIANGLE_PIECES.forEach((piece, i) => {
|
|
const el = svgRef.current!.querySelector(`#piece-${piece.id}`)
|
|
if (el) {
|
|
gsap.to(el, {
|
|
attr: { d: target ? piece.sq : piece.tri },
|
|
duration: 1.5,
|
|
ease: 'power2.inOut',
|
|
delay: i * 0.15,
|
|
})
|
|
}
|
|
})
|
|
setIsSquare(target)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-6">
|
|
<svg ref={svgRef} viewBox="-10 -10 220 200" className="w-80 h-80">
|
|
{TRIANGLE_PIECES.map((piece, i) => (
|
|
<path
|
|
key={piece.id}
|
|
id={`piece-${piece.id}`}
|
|
d={piece.tri}
|
|
fill={COLORS[i]}
|
|
stroke="#000"
|
|
strokeWidth="1.5"
|
|
className="cursor-pointer"
|
|
/>
|
|
))}
|
|
</svg>
|
|
<button
|
|
onClick={morph}
|
|
className="px-6 py-3 bg-white text-black font-mono text-sm uppercase tracking-wider"
|
|
>
|
|
{isSquare ? 'To Triangle' : 'To Square'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Interactive Tangram
|
|
|
|
7 draggable pieces with snap-to-position.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useState, useCallback } from 'react'
|
|
import { gsap } from '@/lib/gsap'
|
|
|
|
interface TangramPiece {
|
|
id: string; name: string; points: string; color: string
|
|
homeX: number; homeY: number; homeRotate: number
|
|
}
|
|
|
|
const PIECES: TangramPiece[] = [
|
|
{ id: 'lg1', name: 'Large Triangle 1', points: '0,0 200,0 100,100', color: '#ef4444', homeX: 0, homeY: 0, homeRotate: 0 },
|
|
{ id: 'lg2', name: 'Large Triangle 2', points: '0,0 100,100 0,200', color: '#f97316', homeX: 0, homeY: 0, homeRotate: 0 },
|
|
{ id: 'md', name: 'Medium Triangle', points: '0,0 100,0 50,50', color: '#eab308', homeX: 100, homeY: 100, homeRotate: 0 },
|
|
{ id: 'sm1', name: 'Small Triangle 1', points: '0,0 100,0 50,50', color: '#22c55e', homeX: 100, homeY: 0, homeRotate: 90 },
|
|
{ id: 'sm2', name: 'Small Triangle 2', points: '0,0 100,0 50,50', color: '#06b6d4', homeX: 50, homeY: 150, homeRotate: 180 },
|
|
{ id: 'sq', name: 'Square', points: '0,0 50,0 50,50 0,50', color: '#8b5cf6', homeX: 100, homeY: 50, homeRotate: 45 },
|
|
{ id: 'par', name: 'Parallelogram', points: '0,0 50,0 100,50 50,50', color: '#ec4899', homeX: 50, homeY: 50, homeRotate: 0 },
|
|
]
|
|
|
|
// Target shapes: each piece's target transform
|
|
const TARGETS = {
|
|
house: { lg1: { x: 50, y: 100, r: 0 }, lg2: { x: 0, y: 200, r: -90 }, md: { x: 25, y: 50, r: 0 }, sm1: { x: 0, y: 100, r: 90 }, sm2: { x: 150, y: 100, r: 0 }, sq: { x: 75, y: 150, r: 0 }, par: { x: 100, y: 100, r: 0 } },
|
|
cat: { lg1: { x: 30, y: 80, r: 45 }, lg2: { x: 30, y: 80, r: -45 }, md: { x: 80, y: 180, r: 180 }, sm1: { x: 0, y: 0, r: 0 }, sm2: { x: 120, y: 0, r: 90 }, sq: { x: 60, y: 30, r: 0 }, par: { x: 60, y: 130, r: 0 } },
|
|
}
|
|
|
|
export function Tangram() {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const [activeTarget, setActiveTarget] = useState<keyof typeof TARGETS | null>(null)
|
|
const dragState = useRef<{ id: string; startX: number; startY: number; offsetX: number; offsetY: number } | null>(null)
|
|
|
|
const animateToTarget = useCallback((target: keyof typeof TARGETS) => {
|
|
const t = TARGETS[target]
|
|
PIECES.forEach((piece, i) => {
|
|
const el = svgRef.current!.querySelector(`#tangram-${piece.id}`) as SVGGElement
|
|
const data = t[piece.id as keyof typeof t]
|
|
if (el && data) {
|
|
gsap.to(el, {
|
|
x: data.x,
|
|
y: data.y,
|
|
rotation: data.r,
|
|
duration: 0.8,
|
|
ease: 'back.out(1.2)',
|
|
delay: i * 0.08,
|
|
transformOrigin: 'center center',
|
|
})
|
|
}
|
|
})
|
|
setActiveTarget(target)
|
|
}, [])
|
|
|
|
const scatter = useCallback(() => {
|
|
PIECES.forEach((piece, i) => {
|
|
const el = svgRef.current!.querySelector(`#tangram-${piece.id}`) as SVGGElement
|
|
if (el) {
|
|
gsap.to(el, {
|
|
x: Math.random() * 250,
|
|
y: Math.random() * 250,
|
|
rotation: Math.random() * 360,
|
|
duration: 0.6,
|
|
ease: 'power2.out',
|
|
delay: i * 0.05,
|
|
})
|
|
}
|
|
})
|
|
setActiveTarget(null)
|
|
}, [])
|
|
|
|
const handlePointerDown = useCallback((e: React.PointerEvent, pieceId: string) => {
|
|
const svg = svgRef.current!
|
|
const pt = svg.createSVGPoint()
|
|
pt.x = e.clientX
|
|
pt.y = e.clientY
|
|
const svgPt = pt.matrixTransform(svg.getScreenCTM()!.inverse())
|
|
const el = svg.querySelector(`#tangram-${pieceId}`) as SVGGElement
|
|
const transform = gsap.getProperty(el)
|
|
dragState.current = {
|
|
id: pieceId,
|
|
startX: svgPt.x,
|
|
startY: svgPt.y,
|
|
offsetX: (transform('x') as number) || 0,
|
|
offsetY: (transform('y') as number) || 0,
|
|
}
|
|
;(e.target as Element).setPointerCapture(e.pointerId)
|
|
}, [])
|
|
|
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
|
if (!dragState.current) return
|
|
const svg = svgRef.current!
|
|
const pt = svg.createSVGPoint()
|
|
pt.x = e.clientX
|
|
pt.y = e.clientY
|
|
const svgPt = pt.matrixTransform(svg.getScreenCTM()!.inverse())
|
|
const el = svg.querySelector(`#tangram-${dragState.current.id}`)
|
|
if (el) {
|
|
gsap.set(el, {
|
|
x: dragState.current.offsetX + (svgPt.x - dragState.current.startX),
|
|
y: dragState.current.offsetY + (svgPt.y - dragState.current.startY),
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
const handlePointerUp = useCallback(() => {
|
|
dragState.current = null
|
|
}, [])
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox="0 0 400 400"
|
|
className="w-96 h-96 bg-gray-950 rounded-lg touch-none"
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
>
|
|
{PIECES.map(piece => (
|
|
<g
|
|
key={piece.id}
|
|
id={`tangram-${piece.id}`}
|
|
onPointerDown={e => handlePointerDown(e, piece.id)}
|
|
className="cursor-grab active:cursor-grabbing"
|
|
>
|
|
<polygon
|
|
points={piece.points}
|
|
fill={piece.color}
|
|
stroke="#000"
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</g>
|
|
))}
|
|
</svg>
|
|
<div className="flex gap-3">
|
|
{Object.keys(TARGETS).map(target => (
|
|
<button
|
|
key={target}
|
|
onClick={() => animateToTarget(target as keyof typeof TARGETS)}
|
|
className={`px-4 py-2 text-sm font-mono uppercase ${activeTarget === target ? 'bg-white text-black' : 'border border-white/30 text-white/70'}`}
|
|
>
|
|
{target}
|
|
</button>
|
|
))}
|
|
<button onClick={scatter} className="px-4 py-2 text-sm font-mono uppercase border border-white/30 text-white/70">
|
|
Scatter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Tessellations
|
|
|
|
Regular, semi-regular, and Escher-style animated tessellations.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useEffect } from 'react'
|
|
|
|
type TessType = 'triangular' | 'hexagonal' | 'cairo'
|
|
|
|
export function Tessellation({ type = 'hexagonal', animate = true }: {
|
|
type?: TessType; animate?: boolean
|
|
}) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const animRef = useRef<number>(0)
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current!
|
|
const ctx = canvas.getContext('2d')!
|
|
const w = canvas.offsetWidth
|
|
const h = canvas.offsetHeight
|
|
canvas.width = w * 2
|
|
canvas.height = h * 2
|
|
ctx.scale(2, 2)
|
|
|
|
let time = 0
|
|
|
|
const drawHexGrid = (t: number) => {
|
|
const size = 30
|
|
const hSpacing = size * Math.sqrt(3)
|
|
const vSpacing = size * 1.5
|
|
|
|
for (let row = -1; row < h / vSpacing + 1; row++) {
|
|
for (let col = -1; col < w / hSpacing + 1; col++) {
|
|
const x = col * hSpacing + (row % 2 ? hSpacing / 2 : 0)
|
|
const y = row * vSpacing
|
|
|
|
ctx.beginPath()
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = ((60 * i - 30) * Math.PI) / 180
|
|
const px = x + size * Math.cos(angle + t * 0.3)
|
|
const py = y + size * Math.sin(angle + t * 0.3)
|
|
if (i === 0) ctx.moveTo(px, py)
|
|
else ctx.lineTo(px, py)
|
|
}
|
|
ctx.closePath()
|
|
|
|
const hue = ((x + y) * 0.5 + t * 50) % 360
|
|
ctx.fillStyle = `hsla(${hue}, 60%, 50%, 0.7)`
|
|
ctx.fill()
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.3)'
|
|
ctx.lineWidth = 1
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
const drawTriGrid = (t: number) => {
|
|
const size = 35
|
|
const height = size * Math.sqrt(3) / 2
|
|
|
|
for (let row = -1; row < h / height + 1; row++) {
|
|
for (let col = -1; col < w / size + 1; col++) {
|
|
const upward = (row + col) % 2 === 0
|
|
const x = col * (size / 2)
|
|
const y = row * height
|
|
|
|
ctx.beginPath()
|
|
if (upward) {
|
|
ctx.moveTo(x, y + height)
|
|
ctx.lineTo(x + size / 2, y)
|
|
ctx.lineTo(x + size, y + height)
|
|
} else {
|
|
ctx.moveTo(x, y)
|
|
ctx.lineTo(x + size, y)
|
|
ctx.lineTo(x + size / 2, y + height)
|
|
}
|
|
ctx.closePath()
|
|
|
|
const hue = ((row * 40 + col * 20) + t * 30) % 360
|
|
ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.8)`
|
|
ctx.fill()
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.2)'
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
const drawCairo = (t: number) => {
|
|
const size = 40
|
|
for (let row = -1; row < h / size + 2; row++) {
|
|
for (let col = -1; col < w / size + 2; col++) {
|
|
const x = col * size
|
|
const y = row * size
|
|
const wobble = Math.sin(t + col * 0.3 + row * 0.3) * 3
|
|
|
|
// Cairo pentagon approximation
|
|
ctx.beginPath()
|
|
ctx.moveTo(x + wobble, y)
|
|
ctx.lineTo(x + size * 0.7, y + wobble)
|
|
ctx.lineTo(x + size, y + size * 0.3)
|
|
ctx.lineTo(x + size * 0.5, y + size * 0.7 + wobble)
|
|
ctx.lineTo(x, y + size * 0.4)
|
|
ctx.closePath()
|
|
|
|
const hue = ((col + row) * 30 + t * 20) % 360
|
|
ctx.fillStyle = `hsla(${hue}, 45%, 55%, 0.8)`
|
|
ctx.fill()
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.3)'
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
const render = () => {
|
|
ctx.clearRect(0, 0, w, h)
|
|
const t = animate ? time : 0
|
|
|
|
switch (type) {
|
|
case 'hexagonal': drawHexGrid(t); break
|
|
case 'triangular': drawTriGrid(t); break
|
|
case 'cairo': drawCairo(t); break
|
|
}
|
|
|
|
if (animate) {
|
|
time += 0.01
|
|
animRef.current = requestAnimationFrame(render)
|
|
}
|
|
}
|
|
render()
|
|
return () => cancelAnimationFrame(animRef.current)
|
|
}, [type, animate])
|
|
|
|
return <canvas ref={canvasRef} className="w-full h-full" />
|
|
}
|
|
```
|
|
|
|
## Penrose Tiles
|
|
|
|
Kite and dart aperiodic tiling with deflation generation.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useEffect } from 'react'
|
|
|
|
type PenroseTile = { type: 'kite' | 'dart'; vertices: [number, number][] }
|
|
|
|
const PHI = (1 + Math.sqrt(5)) / 2
|
|
|
|
function generatePenrose(depth: number, cx: number, cy: number, radius: number): PenroseTile[] {
|
|
// Start with a sun (10 kites)
|
|
let tiles: PenroseTile[] = []
|
|
for (let i = 0; i < 10; i++) {
|
|
const a1 = ((i * 36) * Math.PI) / 180
|
|
const a2 = (((i + 1) * 36) * Math.PI) / 180
|
|
const mid = (((i * 36 + 18)) * Math.PI) / 180
|
|
tiles.push({
|
|
type: 'kite',
|
|
vertices: [
|
|
[cx, cy],
|
|
[cx + Math.cos(a1) * radius, cy + Math.sin(a1) * radius],
|
|
[cx + Math.cos(mid) * radius / PHI, cy + Math.sin(mid) * radius / PHI],
|
|
[cx + Math.cos(a2) * radius, cy + Math.sin(a2) * radius],
|
|
],
|
|
})
|
|
}
|
|
|
|
// Subdivide
|
|
for (let d = 0; d < depth; d++) {
|
|
const newTiles: PenroseTile[] = []
|
|
for (const tile of tiles) {
|
|
const [A, B, C, D] = tile.vertices
|
|
if (tile.type === 'kite') {
|
|
const E: [number, number] = [A[0] + (B[0] - A[0]) / PHI, A[1] + (B[1] - A[1]) / PHI]
|
|
const F: [number, number] = [A[0] + (D[0] - A[0]) / PHI, A[1] + (D[1] - A[1]) / PHI]
|
|
newTiles.push({ type: 'kite', vertices: [A, E, C, F] })
|
|
newTiles.push({ type: 'dart', vertices: [E, B, C, E] })
|
|
newTiles.push({ type: 'dart', vertices: [F, C, D, F] })
|
|
} else {
|
|
const E: [number, number] = [B[0] + (A[0] - B[0]) / PHI, B[1] + (A[1] - B[1]) / PHI]
|
|
newTiles.push({ type: 'kite', vertices: [E, B, C, E] })
|
|
newTiles.push({ type: 'dart', vertices: [A, E, C, D] })
|
|
}
|
|
}
|
|
tiles = newTiles
|
|
}
|
|
return tiles
|
|
}
|
|
|
|
export function PenroseTiling({ depth = 3 }: { depth?: number }) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const animRef = useRef<number>(0)
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current!
|
|
const ctx = canvas.getContext('2d')!
|
|
const w = canvas.offsetWidth
|
|
const h = canvas.offsetHeight
|
|
canvas.width = w * 2
|
|
canvas.height = h * 2
|
|
ctx.scale(2, 2)
|
|
|
|
const tiles = generatePenrose(depth, w / 2, h / 2, Math.min(w, h) * 0.45)
|
|
let drawn = 0
|
|
|
|
const animate = () => {
|
|
if (drawn >= tiles.length) return
|
|
|
|
const batch = Math.min(10, tiles.length - drawn)
|
|
for (let i = 0; i < batch; i++) {
|
|
const tile = tiles[drawn + i]
|
|
ctx.beginPath()
|
|
tile.vertices.forEach(([x, y], j) => {
|
|
if (j === 0) ctx.moveTo(x, y)
|
|
else ctx.lineTo(x, y)
|
|
})
|
|
ctx.closePath()
|
|
|
|
ctx.fillStyle = tile.type === 'kite'
|
|
? `hsla(220, 60%, 50%, 0.7)`
|
|
: `hsla(40, 70%, 55%, 0.7)`
|
|
ctx.fill()
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.4)'
|
|
ctx.lineWidth = 0.5
|
|
ctx.stroke()
|
|
}
|
|
drawn += batch
|
|
|
|
if (drawn < tiles.length) {
|
|
animRef.current = requestAnimationFrame(animate)
|
|
}
|
|
}
|
|
animate()
|
|
return () => cancelAnimationFrame(animRef.current)
|
|
}, [depth])
|
|
|
|
return <canvas ref={canvasRef} className="w-full h-full bg-gray-950" />
|
|
}
|
|
```
|
|
|
|
## Polyominoes
|
|
|
|
Pentomino puzzle with animated placement.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useCallback } from 'react'
|
|
import { gsap, useGSAP } from '@/lib/gsap'
|
|
|
|
// All 12 pentomino shapes (relative cell positions)
|
|
const PENTOMINOES: { name: string; cells: [number, number][]; color: string }[] = [
|
|
{ name: 'F', cells: [[0,1],[1,0],[1,1],[1,2],[2,2]], color: '#ef4444' },
|
|
{ name: 'I', cells: [[0,0],[0,1],[0,2],[0,3],[0,4]], color: '#f97316' },
|
|
{ name: 'L', cells: [[0,0],[1,0],[2,0],[3,0],[3,1]], color: '#eab308' },
|
|
{ name: 'N', cells: [[0,0],[1,0],[1,1],[2,1],[3,1]], color: '#22c55e' },
|
|
{ name: 'P', cells: [[0,0],[0,1],[1,0],[1,1],[2,0]], color: '#06b6d4' },
|
|
{ name: 'T', cells: [[0,0],[0,1],[0,2],[1,1],[2,1]], color: '#8b5cf6' },
|
|
{ name: 'U', cells: [[0,0],[0,2],[1,0],[1,1],[1,2]], color: '#ec4899' },
|
|
{ name: 'V', cells: [[0,0],[1,0],[2,0],[2,1],[2,2]], color: '#14b8a6' },
|
|
{ name: 'W', cells: [[0,0],[1,0],[1,1],[2,1],[2,2]], color: '#f43f5e' },
|
|
{ name: 'X', cells: [[0,1],[1,0],[1,1],[1,2],[2,1]], color: '#a855f7' },
|
|
{ name: 'Y', cells: [[0,0],[1,0],[1,1],[2,0],[3,0]], color: '#fb923c' },
|
|
{ name: 'Z', cells: [[0,0],[0,1],[1,1],[2,1],[2,2]], color: '#38bdf8' },
|
|
]
|
|
|
|
const CELL_SIZE = 28
|
|
|
|
export function PentominoShowcase() {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
|
|
useGSAP(() => {
|
|
const pieces = svgRef.current!.querySelectorAll('.pentomino-group')
|
|
gsap.from(pieces, {
|
|
scale: 0,
|
|
rotation: 180,
|
|
opacity: 0,
|
|
duration: 0.6,
|
|
stagger: 0.1,
|
|
ease: 'back.out(1.7)',
|
|
transformOrigin: 'center center',
|
|
})
|
|
}, { scope: svgRef })
|
|
|
|
return (
|
|
<svg ref={svgRef} viewBox="0 0 500 200" className="w-full max-w-2xl">
|
|
{PENTOMINOES.map((piece, pi) => {
|
|
const offsetX = (pi % 6) * 80 + 10
|
|
const offsetY = Math.floor(pi / 6) * 100 + 10
|
|
|
|
return (
|
|
<g key={piece.name} className="pentomino-group">
|
|
{piece.cells.map(([r, c], ci) => (
|
|
<rect
|
|
key={ci}
|
|
x={offsetX + c * CELL_SIZE}
|
|
y={offsetY + r * CELL_SIZE}
|
|
width={CELL_SIZE - 2}
|
|
height={CELL_SIZE - 2}
|
|
rx={3}
|
|
fill={piece.color}
|
|
stroke="rgba(0,0,0,0.3)"
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
<text
|
|
x={offsetX + 10}
|
|
y={offsetY - 5}
|
|
className="text-xs fill-white/50 font-mono"
|
|
>
|
|
{piece.name}
|
|
</text>
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Geometric Transformations
|
|
|
|
Animated rotation, reflection, dilation, and composition.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useState } from 'react'
|
|
import { gsap, useGSAP } from '@/lib/gsap'
|
|
|
|
type TransformType = 'rotate' | 'reflect' | 'dilate' | 'compose'
|
|
|
|
export function GeometricTransform({ type = 'rotate' }: { type?: TransformType }) {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const [playing, setPlaying] = useState(false)
|
|
|
|
const animate = () => {
|
|
if (playing) return
|
|
setPlaying(true)
|
|
const shape = svgRef.current!.querySelector('#transform-shape')!
|
|
const ghost = svgRef.current!.querySelector('#transform-ghost')!
|
|
|
|
gsap.set(ghost, { opacity: 0.3 })
|
|
|
|
const tl = gsap.timeline({ onComplete: () => setPlaying(false) })
|
|
|
|
switch (type) {
|
|
case 'rotate':
|
|
tl.to(shape, { rotation: 90, duration: 1.5, ease: 'power2.inOut', transformOrigin: '200 200' })
|
|
.to(shape, { rotation: 0, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
|
|
break
|
|
case 'reflect':
|
|
tl.to(shape, { scaleX: -1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' })
|
|
.to(shape, { scaleX: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
|
|
break
|
|
case 'dilate':
|
|
tl.to(shape, { scale: 1.8, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' })
|
|
.to(shape, { scale: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
|
|
break
|
|
case 'compose':
|
|
tl.to(shape, { rotation: 45, scale: 1.3, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' })
|
|
.to(shape, { scaleX: -1, duration: 0.8, ease: 'power2.inOut', transformOrigin: '200 200' })
|
|
.to(shape, { rotation: 0, scale: 1, scaleX: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 })
|
|
break
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<svg ref={svgRef} viewBox="0 0 400 400" className="w-80 h-80">
|
|
{/* Axes */}
|
|
<line x1="200" y1="0" x2="200" y2="400" stroke="rgba(255,255,255,0.1)" strokeDasharray="4" />
|
|
<line x1="0" y1="200" x2="400" y2="200" stroke="rgba(255,255,255,0.1)" strokeDasharray="4" />
|
|
{/* Ghost (original position) */}
|
|
<polygon
|
|
id="transform-ghost"
|
|
points="160,140 240,140 260,200 240,260 160,260 140,200"
|
|
fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="1" strokeDasharray="4"
|
|
opacity="0"
|
|
/>
|
|
{/* Shape */}
|
|
<polygon
|
|
id="transform-shape"
|
|
points="160,140 240,140 260,200 240,260 160,260 140,200"
|
|
fill="rgba(139, 92, 246, 0.6)" stroke="#8b5cf6" strokeWidth="2"
|
|
/>
|
|
{/* Center marker */}
|
|
<circle cx="200" cy="200" r="3" fill="#fff" />
|
|
</svg>
|
|
<button
|
|
onClick={animate}
|
|
disabled={playing}
|
|
className="px-6 py-3 bg-white text-black font-mono text-sm uppercase tracking-wider disabled:opacity-50"
|
|
>
|
|
{type}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Dissection Puzzle Framework
|
|
|
|
Reusable framework for any SVG-based dissection puzzle.
|
|
|
|
```tsx
|
|
'use client'
|
|
import { useRef, useCallback } from 'react'
|
|
import { gsap, useGSAP } from '@/lib/gsap'
|
|
|
|
interface PuzzlePiece {
|
|
id: string
|
|
path: string // SVG path data
|
|
color: string
|
|
states: Record<string, { x: number; y: number; rotation: number; scale?: number }>
|
|
}
|
|
|
|
interface DissectionPuzzleProps {
|
|
pieces: PuzzlePiece[]
|
|
viewBox?: string
|
|
initialState: string
|
|
className?: string
|
|
}
|
|
|
|
export function DissectionPuzzle({ pieces, viewBox = '0 0 400 400', initialState, className }: DissectionPuzzleProps) {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const currentState = useRef(initialState)
|
|
|
|
useGSAP(() => {
|
|
pieces.forEach(piece => {
|
|
const el = svgRef.current!.querySelector(`#dp-${piece.id}`)
|
|
const state = piece.states[initialState]
|
|
if (el && state) {
|
|
gsap.set(el, {
|
|
x: state.x,
|
|
y: state.y,
|
|
rotation: state.rotation,
|
|
scale: state.scale ?? 1,
|
|
transformOrigin: 'center center',
|
|
})
|
|
}
|
|
})
|
|
}, { scope: svgRef })
|
|
|
|
const transitionTo = useCallback((targetState: string) => {
|
|
if (currentState.current === targetState) return
|
|
pieces.forEach((piece, i) => {
|
|
const el = svgRef.current!.querySelector(`#dp-${piece.id}`)
|
|
const state = piece.states[targetState]
|
|
if (el && state) {
|
|
gsap.to(el, {
|
|
x: state.x,
|
|
y: state.y,
|
|
rotation: state.rotation,
|
|
scale: state.scale ?? 1,
|
|
duration: 1.2,
|
|
ease: 'power2.inOut',
|
|
delay: i * 0.1,
|
|
transformOrigin: 'center center',
|
|
})
|
|
}
|
|
})
|
|
currentState.current = targetState
|
|
}, [pieces])
|
|
|
|
const availableStates = pieces[0] ? Object.keys(pieces[0].states) : []
|
|
|
|
return (
|
|
<div className={`flex flex-col items-center gap-4 ${className ?? ''}`}>
|
|
<svg ref={svgRef} viewBox={viewBox} className="w-80 h-80">
|
|
{pieces.map(piece => (
|
|
<path
|
|
key={piece.id}
|
|
id={`dp-${piece.id}`}
|
|
d={piece.path}
|
|
fill={piece.color}
|
|
stroke="#000"
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
/>
|
|
))}
|
|
</svg>
|
|
<div className="flex gap-2">
|
|
{availableStates.map(state => (
|
|
<button
|
|
key={state}
|
|
onClick={() => transitionTo(state)}
|
|
className="px-4 py-2 text-sm font-mono uppercase border border-white/30 hover:bg-white hover:text-black transition-colors"
|
|
>
|
|
{state}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Example usage:
|
|
// <DissectionPuzzle
|
|
// initialState="triangle"
|
|
// pieces={[
|
|
// {
|
|
// id: 'p1',
|
|
// path: 'M 0 0 L 50 0 L 25 43 Z',
|
|
// color: '#f43f5e',
|
|
// states: {
|
|
// triangle: { x: 100, y: 100, rotation: 0 },
|
|
// square: { x: 150, y: 50, rotation: 45 },
|
|
// },
|
|
// },
|
|
// // ... more pieces
|
|
// ]}
|
|
// />
|
|
```
|
|
|
|
## Choosing the Right Puzzle
|
|
|
|
| Puzzle | Best For | Complexity |
|
|
|--------|----------|------------|
|
|
| Dudeney | Math demonstrations, educational | Medium |
|
|
| Tangram | Interactive play, creativity | Low-Medium |
|
|
| Tessellation | Backgrounds, patterns | Low |
|
|
| Penrose | Mathematical beauty, wow factor | High |
|
|
| Polyominoes | Game-like interactions | Medium |
|
|
| Transformations | Educational, geometric concepts | Low |
|