Spaces:
Running
Running
| import { useEffect, useRef, useState, useCallback } from 'react'; | |
| import * as d3 from 'd3'; | |
| interface Stage { | |
| title: string; | |
| description: string; | |
| explanation: string[]; | |
| draw: (svg: d3.Selection<SVGGElement, unknown, null, undefined>, w: number, h: number) => void; | |
| } | |
| // ββ Color tokens ββββββββββββββββββββββββββββββββββββββββββββββ | |
| const C = { | |
| bg: '#09090b', // zinc-950 | |
| surface: '#18181b', // zinc-900 | |
| border: '#27272a', // zinc-800 | |
| muted: '#3f3f46', // zinc-700 | |
| dim: '#52525b', // zinc-600 | |
| text: '#a1a1aa', // zinc-400 | |
| bright: '#e4e4e7', // zinc-200 | |
| white: '#fafafa', // zinc-50 | |
| blue: '#3b82f6', | |
| violet: '#8b5cf6', | |
| amber: '#f59e0b', | |
| emerald: '#10b981', | |
| red: '#ef4444', | |
| }; | |
| // ββ Shared drawing helpers ββββββββββββββββββββββββββββββββββββ | |
| function arrow( | |
| g: d3.Selection<SVGGElement, unknown, null, undefined>, | |
| x1: number, y1: number, x2: number, y2: number, | |
| color = C.text, strokeWidth = 1.5, | |
| ) { | |
| const id = `ah-${Math.random().toString(36).slice(2, 8)}`; | |
| g.append('defs').append('marker') | |
| .attr('id', id) | |
| .attr('viewBox', '0 0 10 10') | |
| .attr('refX', 9).attr('refY', 5) | |
| .attr('markerWidth', 6).attr('markerHeight', 6) | |
| .attr('orient', 'auto-start-reverse') | |
| .append('path').attr('d', 'M 0 0 L 10 5 L 0 10 z').attr('fill', color); | |
| g.append('line') | |
| .attr('x1', x1).attr('y1', y1).attr('x2', x2).attr('y2', y2) | |
| .attr('stroke', color).attr('stroke-width', strokeWidth) | |
| .attr('marker-end', `url(#${id})`); | |
| } | |
| function labelText( | |
| g: d3.Selection<SVGGElement, unknown, null, undefined>, | |
| x: number, y: number, text: string, | |
| opts: { size?: number; color?: string; anchor?: string; weight?: string } = {}, | |
| ) { | |
| g.append('text') | |
| .attr('x', x).attr('y', y) | |
| .attr('text-anchor', opts.anchor ?? 'middle') | |
| .attr('fill', opts.color ?? C.text) | |
| .attr('font-size', opts.size ?? 11) | |
| .attr('font-family', 'ui-monospace, monospace') | |
| .attr('font-weight', opts.weight ?? '400') | |
| .text(text); | |
| } | |
| function roundedRect( | |
| g: d3.Selection<SVGGElement, unknown, null, undefined>, | |
| x: number, y: number, w: number, h: number, | |
| opts: { fill?: string; stroke?: string; rx?: number; opacity?: number } = {}, | |
| ) { | |
| g.append('rect') | |
| .attr('x', x).attr('y', y).attr('width', w).attr('height', h) | |
| .attr('rx', opts.rx ?? 4) | |
| .attr('fill', opts.fill ?? 'none') | |
| .attr('stroke', opts.stroke ?? C.border) | |
| .attr('stroke-width', 1) | |
| .attr('opacity', opts.opacity ?? 1); | |
| } | |
| // ββ Stage definitions βββββββββββββββββββββββββββββββββββββββββ | |
| const stages: Stage[] = [ | |
| // ββββββββββββ 1. Input Y Plane ββββββββββββ | |
| { | |
| title: 'Input Y Plane', | |
| description: 'Luminance extraction from video frame', | |
| explanation: [ | |
| 'Each video frame is converted from RGB to YCbCr color space. We extract only the Y (luminance) channel β the brightness of each pixel, ignoring color information.', | |
| 'Human vision is far more sensitive to luminance than to chrominance. By embedding exclusively in the Y channel, we can exploit this: small changes to brightness values are imperceptible, while the watermark survives color adjustments and chroma subsampling during compression.', | |
| ], | |
| draw(g, w, h) { | |
| const gridSize = 10; | |
| const cellSize = 28; | |
| const offsetX = (w - gridSize * cellSize) / 2; | |
| const offsetY = (h - gridSize * cellSize) / 2 + 10; | |
| roundedRect(g, offsetX - 16, offsetY - 16, gridSize * cellSize + 32, gridSize * cellSize + 32, { | |
| stroke: C.muted, rx: 8, fill: C.surface + '80', | |
| }); | |
| const rng = d3.randomLcg(42); | |
| for (let r = 0; r < gridSize; r++) { | |
| for (let c = 0; c < gridSize; c++) { | |
| const val = Math.floor(rng() * 220 + 20); | |
| const lum = d3.interpolateGreys(1 - val / 255); | |
| g.append('rect') | |
| .attr('x', offsetX + c * cellSize) | |
| .attr('y', offsetY + r * cellSize) | |
| .attr('width', cellSize - 1).attr('height', cellSize - 1) | |
| .attr('fill', lum).attr('rx', 2); | |
| if ((r + c) % 3 === 0) { | |
| g.append('text') | |
| .attr('x', offsetX + c * cellSize + cellSize / 2) | |
| .attr('y', offsetY + r * cellSize + cellSize / 2 + 4) | |
| .attr('text-anchor', 'middle') | |
| .attr('fill', val > 140 ? '#000' : '#fff') | |
| .attr('font-size', 8) | |
| .attr('font-family', 'ui-monospace, monospace') | |
| .attr('opacity', 0.7) | |
| .text(val); | |
| } | |
| } | |
| } | |
| labelText(g, w / 2, offsetY - 28, 'Y (luminance) channel', { color: C.bright, size: 13, weight: '600' }); | |
| // RGB stack β arrow β Y plane | |
| const arrowG = g.append('g'); | |
| const frameLeft = offsetX - 50; | |
| roundedRect(arrowG, frameLeft - 30, h / 2 - 20, 24, 18, { fill: C.blue + '20', stroke: C.blue + '50', rx: 3 }); | |
| roundedRect(arrowG, frameLeft - 28, h / 2 - 18, 24, 18, { fill: C.violet + '20', stroke: C.violet + '50', rx: 3 }); | |
| roundedRect(arrowG, frameLeft - 26, h / 2 - 16, 24, 18, { fill: C.emerald + '20', stroke: C.emerald + '50', rx: 3 }); | |
| labelText(arrowG, frameLeft - 14, h / 2 + 20, 'RGB', { color: C.dim, size: 9 }); | |
| arrow(arrowG, frameLeft + 4, h / 2 - 6, offsetX - 20, h / 2 - 6, C.blue); | |
| }, | |
| }, | |
| // ββββββββββββ 2. Haar DWT ββββββββββββ | |
| { | |
| title: 'Haar DWT', | |
| description: '2-level Haar discrete wavelet transform', | |
| explanation: [ | |
| 'The Haar wavelet transform decomposes the Y plane into frequency subbands. Each level splits the image into four quadrants: LL (approximation), LH (vertical detail), HL (horizontal detail), and HH (diagonal detail). We apply this twice, recursively subdividing the LL quadrant.', | |
| 'We embed in the HL (horizontal detail) subband at the deepest decomposition level. HL captures horizontal edge and texture energy β changes here are masked by the image content itself. The deepest level provides maximum downsampling, concentrating energy so that each modified coefficient affects a larger spatial region, improving robustness against compression and rescaling.', | |
| 'LH or HH could also work, but HL empirically gives the best balance of imperceptibility and robustness for typical video content with horizontal motion.', | |
| ], | |
| draw(g, w, h) { | |
| const size = 260; | |
| const ox = (w - size) / 2; | |
| const oy = (h - size) / 2 + 8; | |
| const half = size / 2; | |
| const quarter = size / 4; | |
| // Glow filter (must be defined before use) | |
| const defs = g.append('defs'); | |
| const filter = defs.append('filter').attr('id', 'glow'); | |
| filter.append('feGaussianBlur').attr('stdDeviation', 4).attr('result', 'coloredBlur'); | |
| const merge = filter.append('feMerge'); | |
| merge.append('feMergeNode').attr('in', 'coloredBlur'); | |
| merge.append('feMergeNode').attr('in', 'SourceGraphic'); | |
| // Full square | |
| g.append('rect') | |
| .attr('x', ox).attr('y', oy).attr('width', size).attr('height', size) | |
| .attr('fill', C.surface).attr('stroke', C.border).attr('stroke-width', 1.5).attr('rx', 4); | |
| // First level quadrants | |
| const quads = [ | |
| { x: 0, y: 0, w: half, h: half, label: '', fill: C.muted + '30' }, | |
| { x: half, y: 0, w: half, h: half, label: 'HLβ', fill: C.blue + '15', sub: 'horiz. detail' }, | |
| { x: 0, y: half, w: half, h: half, label: 'LHβ', fill: C.muted + '20', sub: 'vert. detail' }, | |
| { x: half, y: half, w: half, h: half, label: 'HHβ', fill: C.muted + '15', sub: 'diagonal' }, | |
| ]; | |
| quads.forEach(q => { | |
| g.append('rect') | |
| .attr('x', ox + q.x).attr('y', oy + q.y) | |
| .attr('width', q.w).attr('height', q.h) | |
| .attr('fill', q.fill).attr('stroke', C.border); | |
| if (q.label) { | |
| labelText(g, ox + q.x + q.w / 2, oy + q.y + q.h / 2 - 2, q.label, { color: C.dim, size: 12 }); | |
| labelText(g, ox + q.x + q.w / 2, oy + q.y + q.h / 2 + 12, (q as { sub?: string }).sub ?? '', { color: C.muted, size: 8 }); | |
| } | |
| }); | |
| // Second level (subdivide LLβ) | |
| const sub = [ | |
| { x: 0, y: 0, w: quarter, h: quarter, label: 'LLβ', fill: C.muted + '40', sub: 'approx.' }, | |
| { x: quarter, y: 0, w: quarter, h: quarter, label: 'HLβ', fill: C.blue + '25', sub: '' }, | |
| { x: 0, y: quarter, w: quarter, h: quarter, label: 'LHβ', fill: C.muted + '25', sub: '' }, | |
| { x: quarter, y: quarter, w: quarter, h: quarter, label: 'HHβ', fill: C.muted + '20', sub: '' }, | |
| ]; | |
| sub.forEach(q => { | |
| g.append('rect') | |
| .attr('x', ox + q.x).attr('y', oy + q.y) | |
| .attr('width', q.w).attr('height', q.h) | |
| .attr('fill', q.fill).attr('stroke', C.border); | |
| labelText(g, ox + q.x + q.w / 2, oy + q.y + q.h / 2 + 4, q.label, { | |
| color: q.label.startsWith('HL') ? C.blue : C.dim, size: 10, | |
| }); | |
| }); | |
| // Highlight HL subbands with glow | |
| [{ x: half, y: 0, w: half, h: half }, { x: quarter, y: 0, w: quarter, h: quarter }].forEach(hl => { | |
| g.append('rect') | |
| .attr('x', ox + hl.x).attr('y', oy + hl.y) | |
| .attr('width', hl.w).attr('height', hl.h) | |
| .attr('fill', 'none').attr('stroke', C.blue) | |
| .attr('stroke-width', 2.5) | |
| .attr('filter', 'url(#glow)'); | |
| }); | |
| // Annotations | |
| labelText(g, ox + size + 14, oy + quarter / 2, 'embed', { color: C.blue, size: 10, anchor: 'start', weight: '600' }); | |
| labelText(g, ox + size + 14, oy + quarter / 2 + 13, 'here', { color: C.blue, size: 10, anchor: 'start', weight: '600' }); | |
| arrow(g, ox + size + 10, oy + quarter / 2 + 2, ox + half + half + 4, oy + quarter / 2 + 2, C.blue + '60'); | |
| labelText(g, w / 2, oy - 16, 'Wavelet decomposition (2 levels)', { color: C.bright, size: 13, weight: '600' }); | |
| // Level annotations on left | |
| labelText(g, ox - 10, oy + quarter / 2 + 4, 'L2', { color: C.dim, size: 9, anchor: 'end' }); | |
| labelText(g, ox - 10, oy + half + half / 2 + 4, 'L1', { color: C.dim, size: 9, anchor: 'end' }); | |
| }, | |
| }, | |
| // ββββββββββββ 3. Tile Grid ββββββββββββ | |
| { | |
| title: 'Tile Grid', | |
| description: 'Periodic tile overlay for redundancy', | |
| explanation: [ | |
| 'The HL subband is partitioned into a periodic grid of equally-sized tiles. Each tile independently carries a complete copy of the entire coded payload. This is the core redundancy mechanism.', | |
| 'If parts of the frame are cropped or heavily damaged, the surviving tiles still contain the full message. During detection, soft-bit estimates from all tiles are averaged together β more tiles means a stronger signal. For a typical 1080p frame with the "moderate" preset, this yields ~24 tiles with ~37x bit replication per tile.', | |
| ], | |
| draw(g, w, h) { | |
| const rectW = 420; | |
| const rectH = 220; | |
| const ox = (w - rectW) / 2; | |
| const oy = (h - rectH) / 2 + 4; | |
| const tileW = 70; | |
| const tileH = 55; | |
| const cols = Math.floor(rectW / tileW); | |
| const rows = Math.floor(rectH / tileH); | |
| // HL subband background | |
| g.append('rect') | |
| .attr('x', ox).attr('y', oy).attr('width', rectW).attr('height', rectH) | |
| .attr('fill', C.blue + '08').attr('stroke', C.blue + '40').attr('stroke-width', 1.5).attr('rx', 4); | |
| labelText(g, w / 2, oy - 18, 'HL subband tile partitioning', { color: C.bright, size: 13, weight: '600' }); | |
| for (let r = 0; r < rows; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| const tx = ox + c * tileW; | |
| const ty = oy + r * tileH; | |
| const isFilled = (r + c) % 2 === 0; | |
| g.append('rect') | |
| .attr('x', tx + 1).attr('y', ty + 1) | |
| .attr('width', tileW - 2).attr('height', tileH - 2) | |
| .attr('fill', isFilled ? C.violet + '12' : 'none') | |
| .attr('stroke', C.muted) | |
| .attr('stroke-dasharray', '3,3') | |
| .attr('rx', 2); | |
| if (isFilled) { | |
| labelText(g, tx + tileW / 2, ty + tileH / 2 + 3, 'P', { | |
| color: C.violet + '60', size: 16, weight: '700', | |
| }); | |
| } | |
| } | |
| } | |
| // Legend β positioned clearly below with no overlap | |
| const ly = oy + rectH + 16; | |
| g.append('rect').attr('x', w / 2 - 140).attr('y', ly - 5).attr('width', 12).attr('height', 12) | |
| .attr('fill', C.violet + '12').attr('stroke', C.muted).attr('rx', 2); | |
| labelText(g, w / 2 - 122, ly + 5, 'P', { color: C.violet + '60', size: 10, anchor: 'start', weight: '700' }); | |
| labelText(g, w / 2 - 110, ly + 5, '= full payload copy', { color: C.dim, size: 10, anchor: 'start' }); | |
| labelText(g, w / 2 + 60, ly + 5, `${cols * rows} tiles`, { color: C.text, size: 10, anchor: 'start' }); | |
| }, | |
| }, | |
| // ββββββββββββ 4. 8Γ8 DCT Blocks ββββββββββββ | |
| { | |
| title: '8Γ8 DCT Blocks', | |
| description: 'Block DCT frequency decomposition', | |
| explanation: [ | |
| 'Within each tile, the subband coefficients are divided into non-overlapping 8x8 blocks β the same block size used by JPEG and H.264. Each block is transformed from the spatial domain into the frequency domain using the Discrete Cosine Transform (DCT).', | |
| 'The DCT concentrates energy into a few low-frequency coefficients (top-left), while mid and high frequencies (bottom-right) carry texture detail. We embed watermark bits into mid-frequency coefficients β they carry enough energy to survive lossy compression, but are perceptually less salient than the dominant low-frequency components.', | |
| ], | |
| draw(g, w, h) { | |
| const tileSize = 190; | |
| const ox = w / 2 - tileSize - 30; | |
| const oy = (h - tileSize) / 2 + 14; | |
| const blockCount = 5; | |
| const blockSize = tileSize / blockCount; | |
| labelText(g, w / 2, oy - 20, 'One tile β 8x8 block grid β DCT', { color: C.bright, size: 13, weight: '600' }); | |
| g.append('rect') | |
| .attr('x', ox).attr('y', oy).attr('width', tileSize).attr('height', tileSize) | |
| .attr('fill', C.surface).attr('stroke', C.border).attr('rx', 4); | |
| const highlightR = 1, highlightC = 2; | |
| for (let r = 0; r < blockCount; r++) { | |
| for (let c = 0; c < blockCount; c++) { | |
| const isHighlight = r === highlightR && c === highlightC; | |
| g.append('rect') | |
| .attr('x', ox + c * blockSize + 1) | |
| .attr('y', oy + r * blockSize + 1) | |
| .attr('width', blockSize - 2).attr('height', blockSize - 2) | |
| .attr('fill', isHighlight ? C.amber + '25' : 'none') | |
| .attr('stroke', isHighlight ? C.amber : C.muted) | |
| .attr('stroke-width', isHighlight ? 2 : 0.5) | |
| .attr('rx', 1); | |
| } | |
| } | |
| // Arrow to DCT heatmap | |
| const dctOx = w / 2 + 40; | |
| const dctOy = oy + 16; | |
| const dctSize = 160; | |
| arrow(g, ox + (highlightC + 1) * blockSize + 8, oy + (highlightR + 0.5) * blockSize, dctOx - 10, dctOy + dctSize / 2, C.amber); | |
| // Frequency-domain heatmap | |
| const cells = 8; | |
| const cs = dctSize / cells; | |
| g.append('rect') | |
| .attr('x', dctOx - 2).attr('y', dctOy - 2) | |
| .attr('width', dctSize + 4).attr('height', dctSize + 4) | |
| .attr('fill', 'none').attr('stroke', C.amber + '60').attr('rx', 4); | |
| const colorScale = d3.scaleSequential(d3.interpolateInferno).domain([0, 12]); | |
| for (let r = 0; r < cells; r++) { | |
| for (let c = 0; c < cells; c++) { | |
| const energy = Math.max(0, 10 - (r + c) * 0.9 + (Math.sin(r * c) * 1.5)); | |
| g.append('rect') | |
| .attr('x', dctOx + c * cs).attr('y', dctOy + r * cs) | |
| .attr('width', cs - 1).attr('height', cs - 1) | |
| .attr('fill', colorScale(energy)).attr('rx', 1); | |
| } | |
| } | |
| // Frequency labels | |
| labelText(g, dctOx + dctSize / 2, dctOy + dctSize + 18, 'DCT coefficients', { color: C.amber, size: 10 }); | |
| labelText(g, dctOx - 8, dctOy + 8, 'DC', { color: C.dim, size: 8, anchor: 'end' }); | |
| labelText(g, dctOx + dctSize + 4, dctOy + dctSize - 4, 'HF', { color: C.dim, size: 8, anchor: 'start' }); | |
| arrow(g, dctOx + dctSize * 0.15, dctOy + dctSize + 28, dctOx + dctSize * 0.85, dctOy + dctSize + 28, C.dim, 1); | |
| labelText(g, dctOx + dctSize / 2, dctOy + dctSize + 40, 'increasing frequency β', { color: C.muted, size: 8 }); | |
| labelText(g, ox + tileSize / 2, oy + tileSize + 18, 'spatial domain', { color: C.dim, size: 9 }); | |
| }, | |
| }, | |
| // ββββββββββββ 5. Coefficient Selection + Masking ββββββββββββ | |
| { | |
| title: 'Coefficient Selection', | |
| description: 'Zigzag scan with perceptual masking', | |
| explanation: [ | |
| 'DCT coefficients are traversed in zigzag order β a diagonal path from low to high frequency. We select mid-frequency positions (indices ~10-25): low enough to survive quantization in lossy codecs, high enough to avoid visible artifacts in flat areas.', | |
| 'Each block also gets a perceptual masking factor based on its AC energy relative to the tile median, using a square-root curve. Flat/smooth blocks (low energy) are barely embedded at all (factor as low as 0.1x) β any modification there would be visible. Textured and noisy blocks tolerate much stronger embedding (up to 2.0x). No blocks are skipped entirely, but flat areas contribute almost nothing while busy areas carry the bulk of the watermark signal.', | |
| ], | |
| draw(g, w, h) { | |
| const cells = 8; | |
| const cellSize = 30; | |
| const gridSize = cells * cellSize; | |
| const ox = (w - gridSize) / 2 - 80; | |
| const oy = (h - gridSize) / 2 + 14; | |
| labelText(g, w / 2, oy - 20, 'Zigzag scan + perceptual masking', { color: C.bright, size: 13, weight: '600' }); | |
| // Zigzag order | |
| const zigzag: [number, number][] = []; | |
| for (let sum = 0; sum < 2 * cells - 1; sum++) { | |
| if (sum % 2 === 0) { | |
| for (let r = Math.min(sum, cells - 1); r >= Math.max(0, sum - cells + 1); r--) { | |
| zigzag.push([r, sum - r]); | |
| } | |
| } else { | |
| for (let r = Math.max(0, sum - cells + 1); r <= Math.min(sum, cells - 1); r++) { | |
| zigzag.push([r, sum - r]); | |
| } | |
| } | |
| } | |
| const midStart = 10, midEnd = 26; | |
| zigzag.forEach(([r, c], idx) => { | |
| const isMid = idx >= midStart && idx < midEnd; | |
| const isDc = idx === 0; | |
| const isLow = idx > 0 && idx < midStart; | |
| g.append('rect') | |
| .attr('x', ox + c * cellSize + 1) | |
| .attr('y', oy + r * cellSize + 1) | |
| .attr('width', cellSize - 2).attr('height', cellSize - 2) | |
| .attr('fill', isMid ? C.amber + '30' : isDc ? C.blue + '20' : isLow ? C.blue + '08' : C.surface) | |
| .attr('stroke', isMid ? C.amber + '80' : C.muted) | |
| .attr('stroke-width', isMid ? 1.5 : 0.5) | |
| .attr('rx', 2); | |
| if (isMid || idx < 4 || idx > 60) { | |
| g.append('text') | |
| .attr('x', ox + c * cellSize + cellSize / 2) | |
| .attr('y', oy + r * cellSize + cellSize / 2 + 3.5) | |
| .attr('text-anchor', 'middle') | |
| .attr('fill', isMid ? C.amber : C.dim) | |
| .attr('font-size', 8) | |
| .attr('font-family', 'ui-monospace, monospace') | |
| .text(idx); | |
| } | |
| }); | |
| // Zigzag path | |
| const pathPoints = zigzag.slice(0, 30).map(([r, c]) => [ | |
| ox + c * cellSize + cellSize / 2, | |
| oy + r * cellSize + cellSize / 2, | |
| ] as [number, number]); | |
| g.append('path') | |
| .attr('d', d3.line().curve(d3.curveLinear)(pathPoints)!) | |
| .attr('fill', 'none') | |
| .attr('stroke', C.text + '40') | |
| .attr('stroke-width', 1) | |
| .attr('stroke-dasharray', '3,2'); | |
| // Legend below zigzag grid | |
| const ly = oy + gridSize + 14; | |
| g.append('rect').attr('x', ox).attr('y', ly - 4).attr('width', 12).attr('height', 12) | |
| .attr('fill', C.amber + '30').attr('stroke', C.amber + '80').attr('rx', 2); | |
| labelText(g, ox + 16, ly + 6, `positions ${midStart}β${midEnd - 1}`, { color: C.amber, size: 9, anchor: 'start' }); | |
| // Masking diagram on the right | |
| const mx = ox + gridSize + 40; | |
| const my = oy + 10; | |
| const barW = 24; | |
| const barGap = 6; | |
| const maxBarH = 120; | |
| labelText(g, mx + 70, my - 4, 'masking factor', { color: C.bright, size: 10, weight: '600' }); | |
| const blockTypes = [ | |
| { label: 'flat', energy: 0.01, factor: 0.1, color: C.red }, | |
| { label: 'smooth', energy: 0.25, factor: 0.5, color: C.blue }, | |
| { label: 'texture', energy: 1.0, factor: 1.0, color: C.emerald }, | |
| { label: 'noisy', energy: 4.0, factor: 2.0, color: C.amber }, | |
| ]; | |
| blockTypes.forEach((bt, i) => { | |
| const bx = mx + i * (barW + barGap); | |
| const barH = (bt.factor / 2.0) * maxBarH; | |
| const by = my + 16 + maxBarH - barH; | |
| // Bar | |
| g.append('rect') | |
| .attr('x', bx).attr('y', by) | |
| .attr('width', barW).attr('height', barH) | |
| .attr('fill', bt.color + '30') | |
| .attr('stroke', bt.color + '80') | |
| .attr('rx', 3); | |
| // Factor label | |
| labelText(g, bx + barW / 2, by - 6, `${bt.factor}x`, { color: bt.color, size: 9, weight: '600' }); | |
| // Block type label | |
| labelText(g, bx + barW / 2, my + 16 + maxBarH + 14, bt.label, { color: C.dim, size: 8 }); | |
| }); | |
| // Baseline at 1.0x | |
| const baselineY = my + 16 + maxBarH - (1.0 / 2.0) * maxBarH; | |
| g.append('line') | |
| .attr('x1', mx - 4).attr('y1', baselineY) | |
| .attr('x2', mx + blockTypes.length * (barW + barGap)).attr('y2', baselineY) | |
| .attr('stroke', C.dim).attr('stroke-dasharray', '4,3').attr('stroke-width', 1); | |
| labelText(g, mx + blockTypes.length * (barW + barGap) + 4, baselineY + 4, '1.0x', { color: C.dim, size: 8, anchor: 'start' }); | |
| labelText(g, mx + 70, my + 16 + maxBarH + 30, 'Ξ_eff = Ξ Γ factor', { color: C.muted, size: 9 }); | |
| }, | |
| }, | |
| // ββββββββββββ 6. DM-QIM Embedding ββββββββββββ | |
| { | |
| title: 'DM-QIM Embedding', | |
| description: 'Dithered quantization index modulation', | |
| explanation: [ | |
| 'To embed a single bit into a DCT coefficient, DM-QIM defines two interleaved quantization lattices spaced Ξ apart. The lattice for bit=0 has points at even multiples of Ξ (β¦, -2Ξ, 0, 2Ξ, β¦). The lattice for bit=1 is shifted by Ξ/2 (β¦, -Ξ/2, Ξ/2, 3Ξ/2, β¦).', | |
| 'To embed: subtract a secret dither value d, quantize the shifted coefficient to the nearest point on the target bit\'s lattice, then add d back. The dither is derived from the secret key β without it, an attacker cannot determine which lattice a coefficient belongs to.', | |
| 'During detection, we subtract d and measure which lattice the coefficient is closer to. The signed distance gives a "soft bit" β its magnitude indicates confidence. Larger Ξ means more robust (bigger lattice spacing) but more visible distortion.', | |
| ], | |
| draw(g, w, h) { | |
| const lineY = h / 2 - 15; | |
| const lineLeft = 70; | |
| const lineRight = w - 70; | |
| const lineW = lineRight - lineLeft; | |
| labelText(g, w / 2, 30, 'Two interleaved quantization lattices', { color: C.bright, size: 13, weight: '600' }); | |
| // Number line | |
| g.append('line') | |
| .attr('x1', lineLeft - 10).attr('y1', lineY) | |
| .attr('x2', lineRight + 10).attr('y2', lineY) | |
| .attr('stroke', C.muted).attr('stroke-width', 1.5); | |
| // Lattice points β bit 0 (even multiples of Ξ) | |
| const delta = lineW / 6; | |
| const lattice0: number[] = []; | |
| const lattice1: number[] = []; | |
| for (let i = 0; i <= 6; i++) { | |
| const x = lineLeft + i * delta; | |
| if (i % 2 === 0) { | |
| lattice0.push(x); | |
| // Bit=0 markers (triangles pointing up) | |
| g.append('path') | |
| .attr('d', `M${x - 5},${lineY + 4} L${x},${lineY - 6} L${x + 5},${lineY + 4}Z`) | |
| .attr('fill', C.blue + '60').attr('stroke', C.blue).attr('stroke-width', 1.5); | |
| } else { | |
| lattice1.push(x); | |
| // Bit=1 markers (circles) | |
| g.append('circle') | |
| .attr('cx', x).attr('cy', lineY) | |
| .attr('r', 5) | |
| .attr('fill', C.emerald + '40').attr('stroke', C.emerald).attr('stroke-width', 1.5); | |
| } | |
| } | |
| // Ξ bracket between first two points | |
| const bY = lineY + 20; | |
| g.append('line').attr('x1', lineLeft).attr('y1', bY).attr('x2', lineLeft + delta).attr('y2', bY) | |
| .attr('stroke', C.text).attr('stroke-width', 1); | |
| g.append('line').attr('x1', lineLeft).attr('y1', bY - 4).attr('x2', lineLeft).attr('y2', bY + 4) | |
| .attr('stroke', C.text).attr('stroke-width', 1); | |
| g.append('line').attr('x1', lineLeft + delta).attr('y1', bY - 4).attr('x2', lineLeft + delta).attr('y2', bY + 4) | |
| .attr('stroke', C.text).attr('stroke-width', 1); | |
| labelText(g, lineLeft + delta / 2, bY + 14, 'Ξ', { color: C.bright, size: 11, weight: '600' }); | |
| // Ξ/2 bracket | |
| g.append('line').attr('x1', lineLeft).attr('y1', bY + 28).attr('x2', lineLeft + delta / 2).attr('y2', bY + 28) | |
| .attr('stroke', C.dim).attr('stroke-width', 1); | |
| g.append('line').attr('x1', lineLeft).attr('y1', bY + 24).attr('x2', lineLeft).attr('y2', bY + 32) | |
| .attr('stroke', C.dim).attr('stroke-width', 1); | |
| g.append('line').attr('x1', lineLeft + delta / 2).attr('y1', bY + 24).attr('x2', lineLeft + delta / 2).attr('y2', bY + 32) | |
| .attr('stroke', C.dim).attr('stroke-width', 1); | |
| labelText(g, lineLeft + delta / 4, bY + 42, 'Ξ/2', { color: C.dim, size: 10 }); | |
| // Original coefficient and snap animation | |
| const origX = lineLeft + 2.3 * delta; | |
| const snapTarget = lineLeft + 2 * delta; // snap to nearest bit=0 point (even) | |
| const snapTarget1 = lineLeft + 3 * delta; // or bit=1 point (odd) | |
| // Original value | |
| g.append('line') | |
| .attr('x1', origX).attr('y1', lineY - 40) | |
| .attr('x2', origX).attr('y2', lineY - 10) | |
| .attr('stroke', C.amber).attr('stroke-width', 1.5) | |
| .attr('stroke-dasharray', '3,2'); | |
| g.append('circle') | |
| .attr('cx', origX).attr('cy', lineY - 44) | |
| .attr('r', 3).attr('fill', C.amber); | |
| labelText(g, origX, lineY - 54, 'c (original)', { color: C.amber, size: 9 }); | |
| // Snap arrows β show both options | |
| // To bit=0 | |
| arrow(g, origX - 4, lineY - 28, snapTarget + 6, lineY - 10, C.blue, 1.5); | |
| labelText(g, (origX + snapTarget) / 2 - 14, lineY - 36, 'if bit=0', { color: C.blue, size: 8 }); | |
| // To bit=1 | |
| arrow(g, origX + 4, lineY - 28, snapTarget1 - 6, lineY - 10, C.emerald, 1.5); | |
| labelText(g, (origX + snapTarget1) / 2 + 14, lineY - 36, 'if bit=1', { color: C.emerald, size: 8 }); | |
| // Legend | |
| const ly = lineY + 62; | |
| // Bit 0 | |
| g.append('path') | |
| .attr('d', `M${w / 2 - 120 - 5},${ly + 4} L${w / 2 - 120},${ly - 4} L${w / 2 - 115},${ly + 4}Z`) | |
| .attr('fill', C.blue + '60').attr('stroke', C.blue).attr('stroke-width', 1); | |
| labelText(g, w / 2 - 105, ly + 3, 'bit = 0 lattice (evenΒ·Ξ)', { color: C.blue, size: 9, anchor: 'start' }); | |
| // Bit 1 | |
| g.append('circle').attr('cx', w / 2 + 50).attr('cy', ly).attr('r', 4) | |
| .attr('fill', C.emerald + '40').attr('stroke', C.emerald); | |
| labelText(g, w / 2 + 60, ly + 3, 'bit = 1 lattice (oddΒ·Ξ + Ξ/2)', { color: C.emerald, size: 9, anchor: 'start' }); | |
| // Dither note | |
| labelText(g, w / 2, ly + 24, 'Dither d (from secret key) shifts both lattices β prevents unauthorized detection', { color: C.muted, size: 9 }); | |
| }, | |
| }, | |
| // ββββββββββββ 7. Payload Encoding ββββββββββββ | |
| { | |
| title: 'Payload Encoding', | |
| description: 'Error-correcting payload encoding', | |
| explanation: [ | |
| 'The raw 32-bit payload (e.g. 0xDEADBEEF) is first protected with a 4-bit CRC checksum for integrity verification, expanding to 36 bits. Then BCH(63,36,5) error-correcting coding adds parity bits, yielding 63 coded bits that can correct up to 5 bit errors.', | |
| 'Finally, the 63 bits are pseudo-randomly interleaved using a key-derived permutation. This spreads burst errors (from localized damage) across the codeword, converting them into scattered errors that BCH can correct more effectively.', | |
| ], | |
| draw(g, w, h) { | |
| const cy = h / 2; | |
| const steps = [ | |
| { label: '32-bit', label2: 'payload', bits: '32', color: C.blue, desc: '0xDEADBEEF' }, | |
| { label: 'CRC-4', label2: 'append', bits: '36', color: C.violet, desc: '+4 check bits' }, | |
| { label: 'BCH', label2: '(63,36,t=5)', bits: '63', color: C.emerald, desc: 'correct 5 errors' }, | |
| { label: 'Keyed', label2: 'interleave', bits: '63', color: C.amber, desc: 'spread burst errors' }, | |
| ]; | |
| const pad = 30; | |
| const availW = w - pad * 2; | |
| const gap = 30; | |
| const boxW = Math.min(110, (availW - (steps.length - 1) * gap) / steps.length); | |
| const totalW = steps.length * boxW + (steps.length - 1) * gap; | |
| const startX = (w - totalW) / 2 + boxW / 2; | |
| labelText(g, w / 2, 36, 'Payload encoding pipeline', { color: C.bright, size: 13, weight: '600' }); | |
| steps.forEach((step, i) => { | |
| const cx = startX + i * (boxW + gap); | |
| roundedRect(g, cx - boxW / 2, cy - 36, boxW, 72, { | |
| fill: step.color + '12', stroke: step.color + '50', rx: 8, | |
| }); | |
| labelText(g, cx, cy - 14, step.label, { color: C.bright, size: 12, weight: '600' }); | |
| labelText(g, cx, cy + 2, step.label2, { color: C.bright, size: 11, weight: '600' }); | |
| labelText(g, cx, cy + 18, step.desc, { color: C.dim, size: 8 }); | |
| // Bit count badge | |
| g.append('rect') | |
| .attr('x', cx - 18).attr('y', cy + 40) | |
| .attr('width', 36).attr('height', 18) | |
| .attr('fill', step.color + '20') | |
| .attr('stroke', step.color + '40') | |
| .attr('rx', 9); | |
| labelText(g, cx, cy + 52, `${step.bits} bits`, { color: step.color, size: 8, weight: '600' }); | |
| if (i < steps.length - 1) { | |
| arrow(g, cx + boxW / 2 + 4, cy, cx + boxW / 2 + gap - 4, cy, step.color + 'a0', 1.5); | |
| } | |
| }); | |
| }, | |
| }, | |
| // ββββββββββββ 8. Reconstruction ββββββββββββ | |
| { | |
| title: 'Reconstruction', | |
| description: 'Inverse transform back to pixel domain', | |
| explanation: [ | |
| 'After embedding modifies DCT coefficients within the HL subband, we reverse the transforms to reconstruct a full video frame. Inverse DCT converts frequency-domain blocks back to spatial samples. Inverse DWT recombines all subbands (including the modified HL) into the full-resolution Y plane.', | |
| 'The resulting watermarked Y plane is nearly identical to the original β typical PSNR is above 38 dB (the "moderate" preset), meaning the per-pixel difference is invisible to the human eye.', | |
| ], | |
| draw(g, w, h) { | |
| const cy = h * 0.32; | |
| // Correct direction: embedded coefficients β iDCT β write HL β iDWT β watermarked Y | |
| const steps = [ | |
| { label: 'Modified', label2: 'DCT coeffs', color: C.amber }, | |
| { label: 'iDCT β', label2: 'write HL', color: C.emerald }, | |
| { label: 'inverse', label2: 'DWT', color: C.blue }, | |
| { label: 'Watermarked', label2: 'Y plane', color: C.violet }, | |
| ]; | |
| const boxW = 110; | |
| const gap = 34; | |
| const totalW = steps.length * boxW + (steps.length - 1) * gap; | |
| const startX = (w - totalW) / 2 + boxW / 2; | |
| labelText(g, w / 2, 30, 'Reconstruction: frequency β spatial', { color: C.bright, size: 13, weight: '600' }); | |
| steps.forEach((step, i) => { | |
| const cx = startX + i * (boxW + gap); | |
| roundedRect(g, cx - boxW / 2, cy - 28, boxW, 56, { | |
| fill: step.color + '12', stroke: step.color + '50', rx: 8, | |
| }); | |
| labelText(g, cx, cy - 6, step.label, { color: C.bright, size: 11, weight: '600' }); | |
| labelText(g, cx, cy + 10, step.label2, { color: C.bright, size: 11, weight: '600' }); | |
| // Step number | |
| g.append('circle') | |
| .attr('cx', cx - boxW / 2 + 10).attr('cy', cy - 28 + 10) | |
| .attr('r', 8).attr('fill', step.color + '30').attr('stroke', step.color + '60'); | |
| labelText(g, cx - boxW / 2 + 10, cy - 28 + 13, `${i + 1}`, { color: step.color, size: 8, weight: '700' }); | |
| if (i < steps.length - 1) { | |
| arrow(g, cx + boxW / 2 + 4, cy, cx + boxW / 2 + gap - 4, cy, C.dim + 'a0', 1.5); | |
| } | |
| }); | |
| // Before/after comparison β centered below with generous spacing | |
| const compY = cy + 66; | |
| const compSize = 80; | |
| const compGap = 70; | |
| const compOx = w / 2 - compSize - compGap / 2; | |
| const cells = 8; | |
| const cs = compSize / cells; | |
| const rng1 = d3.randomLcg(42); | |
| for (let r = 0; r < cells; r++) { | |
| for (let c = 0; c < cells; c++) { | |
| const val = Math.floor(rng1() * 200 + 30); | |
| g.append('rect') | |
| .attr('x', compOx + c * cs).attr('y', compY + r * cs) | |
| .attr('width', cs - 0.5).attr('height', cs - 0.5) | |
| .attr('fill', d3.interpolateGreys(1 - val / 255)).attr('rx', 1); | |
| } | |
| } | |
| labelText(g, compOx + compSize / 2, compY - 8, 'original', { color: C.dim, size: 9 }); | |
| const compOx2 = w / 2 + compGap / 2; | |
| const rng2 = d3.randomLcg(42); | |
| for (let r = 0; r < cells; r++) { | |
| for (let c = 0; c < cells; c++) { | |
| const val = Math.floor(rng2() * 200 + 30) + (Math.random() > 0.6 ? 1 : 0); | |
| g.append('rect') | |
| .attr('x', compOx2 + c * cs).attr('y', compY + r * cs) | |
| .attr('width', cs - 0.5).attr('height', cs - 0.5) | |
| .attr('fill', d3.interpolateGreys(1 - Math.min(255, val) / 255)).attr('rx', 1); | |
| } | |
| } | |
| labelText(g, compOx2 + compSize / 2, compY - 8, 'watermarked', { color: C.violet, size: 9 }); | |
| labelText(g, w / 2, compY + compSize / 2 + 4, 'β', { color: C.emerald, size: 22, weight: '700' }); | |
| labelText(g, w / 2, compY + compSize + 16, 'PSNR > 38 dB β imperceptible', { color: C.dim, size: 9 }); | |
| }, | |
| }, | |
| // ββββββββββββ 9. Detection ββββββββββββ | |
| { | |
| title: 'Detection', | |
| description: 'Multi-frame soft detection and decoding', | |
| explanation: [ | |
| 'Detection reverses the embedding path but never needs the original frame. Each frame is independently DWT-transformed, tiles are located in the HL subband, and DCT + DM-QIM soft extraction produces a signed confidence value per bit per tile.', | |
| 'The real power comes from sheer repetition. Each of the 63 coded bits is embedded into ~12 mid-frequency coefficients per block, across ~196 blocks per tile, across ~24 tiles per frame, across multiple frames. That\'s thousands of independent readings per bit. By the law of large numbers, averaging this many noisy soft-bit estimates drives the error rate exponentially toward zero β even when each individual reading is heavily corrupted by compression or noise.', | |
| 'After averaging, the combined soft bits are de-interleaved, BCH-decoded (correcting any residual errors), and CRC-verified. The combination of massive statistical redundancy from tiling + frames, algebraic error correction from BCH, and integrity checking from CRC makes the system robust even under severe degradation.', | |
| ], | |
| draw(g, w, h) { | |
| const pad = 20; // horizontal padding from edges | |
| labelText(g, w / 2, 28, 'Multi-frame detection pipeline', { color: C.bright, size: 13, weight: '600' }); | |
| // Frame icons β vertically use top third | |
| const frameY = 56; | |
| const frameCount = 5; | |
| const frameW = 44; | |
| const frameH = 32; | |
| const frameGap = 16; | |
| const framesW = frameCount * frameW + (frameCount - 1) * frameGap; | |
| const framesOx = (w - framesW) / 2; | |
| for (let i = 0; i < frameCount; i++) { | |
| const fx = framesOx + i * (frameW + frameGap); | |
| roundedRect(g, fx, frameY, frameW, frameH, { | |
| fill: C.blue + '15', stroke: C.blue + '50', rx: 4, | |
| }); | |
| for (let r = 0; r < 3; r++) { | |
| for (let c = 0; c < 4; c++) { | |
| g.append('rect') | |
| .attr('x', fx + 4 + c * 9).attr('y', frameY + 4 + r * 8) | |
| .attr('width', 7).attr('height', 6) | |
| .attr('fill', C.blue + (10 + Math.floor(Math.random() * 20)).toString(16)) | |
| .attr('rx', 1); | |
| } | |
| } | |
| labelText(g, fx + frameW / 2, frameY + frameH + 13, `f${i + 1}`, { color: C.dim, size: 8 }); | |
| } | |
| labelText(g, w / 2, frameY + frameH + 28, 'each: DWT β HL β DCT β DM-QIM soft extract', { color: C.muted, size: 8 }); | |
| // Funnel arrows β use proportional spacing | |
| const combineX = w / 2; | |
| const combineY = frameY + frameH + 56; | |
| for (let i = 0; i < frameCount; i++) { | |
| const fx = framesOx + i * (frameW + frameGap) + frameW / 2; | |
| arrow(g, fx, frameY + frameH + 32, combineX + (i - 2) * 6, combineY - 10, C.blue + '50'); | |
| } | |
| // Soft-combine box | |
| roundedRect(g, combineX - 75, combineY - 8, 150, 30, { | |
| fill: C.blue + '15', stroke: C.blue + '50', rx: 6, | |
| }); | |
| labelText(g, combineX, combineY + 11, 'average soft bits', { color: C.blue, size: 10, weight: '600' }); | |
| // Pipeline β use available width with padding | |
| const pipeY = combineY + 52; | |
| const pipeSteps = [ | |
| { label: 'De-interleave', color: C.amber }, | |
| { label: 'BCH decode', color: C.emerald }, | |
| { label: 'CRC verify', color: C.violet }, | |
| ]; | |
| const availW = w - pad * 2; | |
| const pStepW = Math.min(120, (availW - 60) / 3); | |
| const pGap = (availW - pipeSteps.length * pStepW) / (pipeSteps.length - 1); | |
| const pStartX = pad + pStepW / 2; | |
| arrow(g, combineX, combineY + 22, pStartX, pipeY - 12, C.blue + '60'); | |
| pipeSteps.forEach((step, i) => { | |
| const px = pStartX + i * (pStepW + pGap); | |
| roundedRect(g, px - pStepW / 2, pipeY - 14, pStepW, 28, { | |
| fill: step.color + '12', stroke: step.color + '50', rx: 6, | |
| }); | |
| labelText(g, px, pipeY + 4, step.label, { color: step.color, size: 10, weight: '600' }); | |
| if (i < pipeSteps.length - 1) { | |
| arrow(g, px + pStepW / 2 + 2, pipeY, px + pStepW / 2 + pGap - 2, pipeY, step.color + '80'); | |
| } | |
| }); | |
| // Result box β generous spacing below | |
| const outY = pipeY + 38; | |
| const lastPipeX = pStartX + (pipeSteps.length - 1) * (pStepW + pGap); | |
| arrow(g, lastPipeX, pipeY + 14, w / 2, outY, C.violet + '80'); | |
| const resultW = 220; | |
| roundedRect(g, w / 2 - resultW / 2, outY + 4, resultW, 58, { | |
| fill: C.emerald + '08', stroke: C.emerald + '40', rx: 8, | |
| }); | |
| labelText(g, w / 2, outY + 22, 'payload: 0xDEADBEEF', { color: C.emerald, size: 11, weight: '600' }); | |
| labelText(g, w / 2, outY + 38, 'confidence: 0.97', { color: C.dim, size: 9 }); | |
| labelText(g, w / 2, outY + 52, '12 / 16 tiles decoded Β· CRC β', { color: C.dim, size: 9 }); | |
| }, | |
| }, | |
| ]; | |
| // ββ Main component ββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function HowItWorks() { | |
| const svgRef = useRef<SVGSVGElement>(null); | |
| const [stage, setStage] = useState(0); | |
| const [svgDims, setSvgDims] = useState({ w: 800, h: 470 }); | |
| const total = stages.length; | |
| const prev = useCallback(() => setStage(s => Math.max(0, s - 1)), []); | |
| const next = useCallback(() => setStage(s => Math.min(total - 1, s + 1)), [total]); | |
| // Keyboard navigation | |
| useEffect(() => { | |
| const handler = (e: KeyboardEvent) => { | |
| if (e.key === 'ArrowLeft') prev(); | |
| if (e.key === 'ArrowRight') next(); | |
| }; | |
| window.addEventListener('keydown', handler); | |
| return () => window.removeEventListener('keydown', handler); | |
| }, [prev, next]); | |
| // Responsive sizing | |
| useEffect(() => { | |
| const el = svgRef.current?.parentElement; | |
| if (!el) return; | |
| const observer = new ResizeObserver(entries => { | |
| for (const entry of entries) { | |
| const w = Math.min(entry.contentRect.width, 860); | |
| setSvgDims({ w, h: Math.max(400, Math.min(500, w * 0.58)) }); | |
| } | |
| }); | |
| observer.observe(el); | |
| return () => observer.disconnect(); | |
| }, []); | |
| // D3 render | |
| useEffect(() => { | |
| const svg = d3.select(svgRef.current); | |
| if (!svg.node()) return; | |
| svg.selectAll('*').remove(); | |
| const { w, h } = svgDims; | |
| svg.attr('width', w).attr('height', h) | |
| .attr('viewBox', `0 0 ${w} ${h}`); | |
| const root = svg.append('g'); | |
| root.attr('opacity', 0) | |
| .transition() | |
| .duration(350) | |
| .ease(d3.easeCubicOut) | |
| .attr('opacity', 1); | |
| stages[stage].draw(root as unknown as d3.Selection<SVGGElement, unknown, null, undefined>, w, h); | |
| }, [stage, svgDims]); | |
| const current = stages[stage]; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Stage header */} | |
| <div className="text-center"> | |
| <span className="inline-block rounded-full bg-blue-500/10 px-3 py-0.5 text-[11px] font-semibold tracking-wide text-blue-400 ring-1 ring-blue-500/20"> | |
| {stage + 1} / {total} | |
| </span> | |
| <h3 className="mt-3 text-xl font-bold tracking-tight text-zinc-100"> | |
| {current.title} | |
| </h3> | |
| <p className="mt-1 text-sm text-zinc-500">{current.description}</p> | |
| </div> | |
| {/* SVG canvas */} | |
| <div className="flex justify-center overflow-hidden rounded-xl bg-zinc-900/60 ring-1 ring-zinc-800/50"> | |
| <svg ref={svgRef} className="block" /> | |
| </div> | |
| {/* Explanation text */} | |
| <div className="space-y-2 rounded-lg bg-zinc-900/40 px-4 py-3 ring-1 ring-zinc-800/40"> | |
| {current.explanation.map((para, i) => ( | |
| <p key={i} className="text-xs leading-relaxed text-zinc-400"> | |
| {para} | |
| </p> | |
| ))} | |
| </div> | |
| {/* Navigation */} | |
| <div className="flex items-center justify-between"> | |
| <button | |
| onClick={prev} | |
| disabled={stage === 0} | |
| className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-zinc-400 transition-all hover:bg-zinc-800 hover:text-zinc-200 disabled:pointer-events-none disabled:opacity-30" | |
| > | |
| <span className="text-xs">β</span> Prev | |
| </button> | |
| {/* Dots */} | |
| <div className="flex gap-1.5"> | |
| {stages.map((_, i) => ( | |
| <button | |
| key={i} | |
| onClick={() => setStage(i)} | |
| className={`h-2 rounded-full transition-all ${ | |
| i === stage | |
| ? 'w-6 bg-blue-500' | |
| : 'w-2 bg-zinc-700 hover:bg-zinc-500' | |
| }`} | |
| aria-label={`Stage ${i + 1}`} | |
| /> | |
| ))} | |
| </div> | |
| <button | |
| onClick={next} | |
| disabled={stage === total - 1} | |
| className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-zinc-400 transition-all hover:bg-zinc-800 hover:text-zinc-200 disabled:pointer-events-none disabled:opacity-30" | |
| > | |
| Next <span className="text-xs">β</span> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |