(() => { 'use strict'; const field = document.querySelector('.artwork'); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) return; const styles = ['orbit', 'wave', 'mesh', 'bloom', 'particles', 'type', 'moire', 'slits', 'trails', 'pixels', 'negative', 'eclipse', 'hexagons', 'planes', 'flowmap', 'vortex', 'ribbons', 'checker', 'dunes', 'constellation', 'lens', 'sweep', 'window', 'mosaic', 'fragments', 'soundfield', 'blobs', 'tiles', 'monogram', 'glyphs']; const names = { orbit: 'Orbit', wave: 'Wave', mesh: 'Mesh', bloom: 'Light Bloom', particles: 'Particle Field', type: 'Kinetic Type', moire: 'Moiré', slits: 'Color Slits', trails: 'Afterimage', pixels: 'Pixel Field', negative: 'Negative Space', eclipse: 'Eclipse', hexagons: 'Hex Field', planes: 'Offset Planes', flowmap: 'Flow Map', vortex: 'Vortex', ribbons: 'Silk Ribbons', checker: 'Soft Checker', dunes: 'Dunes', constellation: 'Constellation', lens: 'Lens', sweep: 'Band Sweep', window: 'Elastic Window', mosaic: 'Tri Mosaic', fragments: 'Fragments', soundfield: 'Sound Field', blobs: 'Duotone Blobs', tiles: 'Arc Tiles', monogram: 'Chromatic N', glyphs: 'Glyph Field' }; const schedule = globalThis.NNCArtworkSchedule; const initialSelection = schedule.selectionAt(Date.now(), styles.length); let currentSlot = initialSelection.slot; let variant = initialSelection.variant; field.dataset.artwork = styles[variant]; const extendedHues = [12, 190, 275, 335, 165, 28, 215, 185, 35, 320, 155, 205, 265, 15, 170, 240, 175]; if (variant >= 13) { field.dataset.series = 'extended'; field.style.setProperty('--art-hue', extendedHues[variant - 13]); } field.append(canvas); const picker = document.querySelector('#artwork-picker'); styles.forEach((style, index) => { const option = document.createElement('option'); option.value = String(index); option.textContent = `${String(index + 1).padStart(2, '0')} / ${names[style]}`; picker.append(option); }); picker.value = String(variant); const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); const theme = matchMedia('(prefers-color-scheme: dark)'); const pointer = { x: 0, y: 0, targetX: 0, targetY: 0, active: 0, targetActive: 0 }; function particleSet() { const random = schedule.createRandom(Math.imul(currentSlot, 31) + variant); return Array.from({ length: 1800 }, () => ({ x: random(), y: random(), offset: random() * Math.PI * 2, depth: random(), size: 0.5 + random() * 1.2 })); } let particles = particleSet(); let width = 0; let height = 0; let stroke; let frame = 0; let lastTime = 0; let phase = 0; let visible = true; function localPointer() { const bounds = field.getBoundingClientRect(); return { x: (pointer.x + 1) / 2 * innerWidth - bounds.left, y: (pointer.y + 1) / 2 * innerHeight - bounds.top }; } function path(points) { context.beginPath(); points.forEach(([x, y], index) => { if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); } function polygon(points, color) { context.fillStyle = color; context.beginPath(); points.forEach(([x, y], index) => { if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.closePath(); context.fill(); } function drawExtended(style, px, py) { const hue = extendedHues[variant - 13]; const color = (offset = 0, light = 50, saturation = 45) => `hsl(${hue + offset}, ${saturation}%, ${theme.matches ? light * 0.8 : light}%)`; const ink = color(0, theme.matches ? 80 : 35, 30); const unit = Math.min(width, height); const cx = width * (0.5 + px * 0.06); const cy = height * (0.5 + py * 0.06); context.strokeStyle = ink; context.fillStyle = ink; context.lineCap = 'round'; switch (style) { case 'planes': { context.save(); context.globalCompositeOperation = theme.matches ? 'screen' : 'multiply'; const size = unit * 0.6; for (let plane = 0; plane < 4; plane++) { context.save(); const direction = plane % 2 ? 1 : -1; context.translate(cx + direction * size * (0.17 + px * 0.12), cy + (plane - 1.5) * size * 0.12 + py * size * direction * 0.1); context.rotate(direction * 0.2 + Math.sin(phase * 0.2 + plane) * 0.03); context.globalAlpha = 0.7; context.fillStyle = color(plane * 65, 55 + plane * 5, 50); context.fillRect(-size * 0.47, -size * 0.43, size * 0.94, size * 0.86); context.restore(); } context.restore(); break; } case 'flowmap': { const cell = Math.max(28, Math.sqrt(width * height / 700)); const cursor = localPointer(); const reach = Math.max(unit * 0.35, 80); context.lineWidth = 1.6; for (let y = cell / 2; y < height; y += cell) { for (let x = cell / 2; x < width; x += cell) { const dx = x - cursor.x, dy = y - cursor.y; const focus = Math.exp(-(dx * dx + dy * dy) / (reach * reach)) * pointer.active; const angle = Math.sin(x / width * 5 + phase * 0.2) + Math.cos(y / height * 4 - phase * 0.15) + focus * 1.4; const length = cell * (0.26 + focus * 0.1); const ax = Math.cos(angle) * length, ay = Math.sin(angle) * length; context.strokeStyle = color(focus * 55, theme.matches ? 72 : 42, 35); path([[x - ax, y - ay], [x + ax, y + ay]]); } } break; } case 'vortex': { for (let arm = 0; arm < 12; arm++) { const points = []; for (let step = 0; step <= 150; step++) { const u = step / 150; const angle = arm / 12 * Math.PI * 2 + u * 7 + phase * 0.12 + px * 0.6; const radius = u * Math.hypot(width, height) * 0.65; points.push([cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius * (0.72 + py * 0.08)]); } context.strokeStyle = color(arm * 3, theme.matches ? 72 : 46); context.lineWidth = 1.5; path(points); } break; } case 'ribbons': { const thickness = unit * 0.045; for (let ribbon = 0; ribbon < 7; ribbon++) { const upper = [], lower = []; for (let step = 0; step <= 100; step++) { const u = step / 100; const y = height * (0.12 + ribbon * 0.12 + Math.sin(u * 5 + ribbon * 0.5 + phase * 0.2 + px) * 0.16) + py * height * 0.06 * Math.sin(u * Math.PI); upper.push([u * width, y - thickness]); lower.push([u * width, y + thickness]); } polygon([...upper, ...lower.reverse()], color(ribbon * 9, 38 + ribbon * 6, 48)); } break; } case 'checker': { const cell = Math.max(48, Math.sqrt(width * height / 170)); const bend = (x, y) => [x + Math.sin(y / height * 5 + phase * 0.2 + py) * cell * 0.25, y + Math.sin(x / width * 6 + phase * 0.25 + px) * cell * 0.25]; for (let row = -1; row < height / cell + 1; row++) { for (let column = -1; column < width / cell + 1; column++) { if ((row + column) % 2 === 0) continue; const x = column * cell, y = row * cell; polygon([bend(x, y), bend(x + cell, y), bend(x + cell, y + cell), bend(x, y + cell)], color(0, theme.matches ? 48 : 63, 24)); } } break; } case 'dunes': { for (let layer = 0; layer < 7; layer++) { const points = [[0, height]]; for (let step = 0; step <= 100; step++) { const u = step / 100; points.push([u * width, height * (0.15 + layer * 0.125 + Math.sin(u * 4 + layer * 0.65 + phase * 0.12 + px * (layer + 1) * 0.15) * 0.1 + py * 0.025 * layer)]); } points.push([width, height]); polygon(points, color(layer * -3, 78 - layer * 7, 42)); } break; } case 'constellation': { const count = width < 680 ? 35 : 65; const points = particles.slice(0, count).map(point => [ width * (0.05 + point.x * 0.9) + Math.sin(phase * 0.2 + point.offset) * 8 + px * point.depth * 16, height * (0.06 + point.y * 0.88) + Math.cos(phase * 0.2 + point.offset) * 8 + py * point.depth * 16]); const reach = Math.sqrt(width * height / count) * 1.4; points.forEach((point, index) => { for (let other = index + 1; other < count; other++) { const distance = Math.hypot(point[0] - points[other][0], point[1] - points[other][1]); if (distance < reach) { context.globalAlpha = (1 - distance / reach) * 0.6; path([point, points[other]]); } } context.globalAlpha = 0.8; context.beginPath(); context.arc(point[0], point[1], 1.8 + particles[index].depth * 1.5, 0, Math.PI * 2); context.fill(); }); break; } case 'lens': { const gap = Math.max(14, width / 90); const radius = unit * 0.38; for (let x = -radius; x < width + radius; x += gap) { const points = []; for (let step = 0; step <= 100; step++) { const y = step / 100 * height; const distance = Math.hypot(x - cx, y - cy) / radius; const warp = Math.max(0, 1 - distance * distance) * 0.75; points.push([x + (x - cx) * warp, y]); } context.lineWidth = gap * 0.3; path(points); } break; } case 'sweep': { context.save(); context.translate(cx, cy); context.rotate(-0.5 + px * 0.2); const span = Math.hypot(width, height) * 1.5; const thickness = unit * 0.13; for (let band = 0; band < 5; band++) { const y = (band - 2) * thickness * 1.3 + py * thickness * (band - 2) * 0.3; const gradient = context.createLinearGradient(-span / 2, 0, span / 2, 0); gradient.addColorStop(0, color(band * 15, 45, 55)); gradient.addColorStop(0.5, color(band * 15 + 30, 72, 50)); gradient.addColorStop(1, color(band * 15 + 55, 48, 40)); context.fillStyle = gradient; context.fillRect(-span / 2 + Math.sin(phase * 0.2 + band) * 10, y - thickness / 2, span, thickness); } context.restore(); break; } case 'window': { const size = unit * 0.62; const half = size / 2; const thickness = unit * 0.065; const shear = px * size * 0.18; const tilt = py * size * 0.13; const corner = (x, y) => [cx + x + y / half * shear, cy + y + x / half * tilt]; const outer = [corner(-half, -half), corner(half, -half), corner(half, half), corner(-half, half)]; const inset = half - thickness; const inner = [corner(-inset, -inset), corner(inset, -inset), corner(inset, inset), corner(-inset, inset)]; const gradient = context.createLinearGradient(cx - half + px * size * 0.2, cy - half, cx + half, cy + half + py * size * 0.2); gradient.addColorStop(0, color(px * 65, 48, 55)); gradient.addColorStop(0.5, color(75 + py * 55, 64, 55)); gradient.addColorStop(1, color(155 + px * 65, 48, 50)); context.fillStyle = gradient; context.beginPath(); for (const points of [outer, inner]) { points.forEach(([x, y], index) => { if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.closePath(); } context.fill('evenodd'); break; } case 'mosaic': { const cell = Math.max(70, Math.sqrt(width * height / 90)); const cursor = localPointer(); const reach = Math.max(unit * 0.4, 100); const vertex = (column, row) => { const point = particles[((row + 2) * 37 + column + 2) % particles.length]; return [column * cell + (point.x - 0.5) * cell * 0.24, row * cell + (point.y - 0.5) * cell * 0.24]; }; for (let row = -1; row < height / cell + 1; row++) { for (let column = -1; column < width / cell + 1; column++) { const a = vertex(column, row), b = vertex(column + 1, row); const c = vertex(column + 1, row + 1), d = vertex(column, row + 1); const focus = Math.exp(-Math.pow(Math.hypot(a[0] - cursor.x, a[1] - cursor.y) / reach, 2)) * pointer.active; const point = particles[((row + 2) * 23 + column + 2) % particles.length]; const light = 45 + point.depth * 25 + focus * 10; polygon([a, b, c], color(point.x * 50 + focus * 25, light, 28)); polygon([a, c, d], color(point.y * 50 + 20, light - 9, 32)); } } break; } case 'fragments': { const count = width < 680 ? 55 : 100; const cursor = localPointer(); const reach = Math.max(unit * 0.3, 80); for (let index = 0; index < count; index++) { const point = particles[index]; let x = width * (0.04 + point.x * 0.92), y = height * (0.05 + point.y * 0.9); const dx = x - cursor.x, dy = y - cursor.y; const focus = Math.exp(-(dx * dx + dy * dy) / (reach * reach)) * pointer.active; x += dx * focus * 0.15; y += dy * focus * 0.15; const angle = point.offset + Math.sin(phase * 0.2 + point.offset) * 0.12 + focus * 0.9; const length = unit * (0.015 + point.depth * 0.025); context.lineWidth = 2 + point.size; context.strokeStyle = color(point.depth * 60 + focus * 20, 42 + point.depth * 20, 35); path([[x - Math.cos(angle) * length, y - Math.sin(angle) * length], [x + Math.cos(angle) * length, y + Math.sin(angle) * length]]); } break; } case 'soundfield': { const count = width < 680 ? 32 : 72; const gap = width / (count + 1); context.lineWidth = gap * 0.4; for (let bar = 1; bar <= count; bar++) { const u = bar / count; const envelope = Math.sin(u * Math.PI); const amplitude = height * (0.04 + envelope * (0.12 + (Math.sin(u * 17 + phase * 0.6 + px * 2) + 1) * 0.12)); const center = height * (0.5 + py * 0.1 * envelope); context.strokeStyle = color(bar * 0.5, theme.matches ? 74 : 45); path([[bar * gap, center - amplitude], [bar * gap, center + amplitude]]); } break; } case 'blobs': { const size = unit * 0.3; context.save(); context.globalCompositeOperation = theme.matches ? 'screen' : 'multiply'; for (let blob = 0; blob < 2; blob++) { const direction = blob === 0 ? -1 : 1; const x = width * 0.5 + direction * size * (0.4 + px * 0.35); const y = height * 0.5 + direction * py * size * 0.3; const points = []; for (let step = 0; step <= 160; step++) { const angle = step / 160 * Math.PI * 2; const contour = 1 + Math.sin(angle * 3 + blob * 2 + phase * 0.25) * 0.13 + Math.cos(angle * 2 - py * 0.7) * 0.1; const rotation = direction * (0.15 + px * 0.2); const bx = Math.cos(angle) * size * contour; const by = Math.sin(angle) * size * contour * 0.87; points.push([x + bx * Math.cos(rotation) - by * Math.sin(rotation), y + bx * Math.sin(rotation) + by * Math.cos(rotation)]); } context.globalAlpha = 0.85; polygon(points, color(blob === 0 ? 0 : 155, blob === 0 ? 52 : 62, 60)); } context.restore(); break; } case 'tiles': { const cell = Math.max(70, Math.sqrt(width * height / 60)); for (let row = 0; row < height / cell; row++) { for (let column = 0; column < width / cell; column++) { context.save(); context.translate((column + 0.5) * cell, (row + 0.5) * cell); context.rotate(((row * 3 + column) % 4) * Math.PI / 2); context.beginPath(); context.rect(-cell / 2, -cell / 2, cell, cell); context.clip(); context.fillStyle = color((row + column) % 3 * 22, 40 + (row % 3) * 9, 35); context.beginPath(); context.moveTo(-cell / 2, -cell / 2); context.arc(-cell / 2, -cell / 2, cell * (0.82 + px * 0.08 + Math.sin(phase * 0.2) * 0.03), 0, Math.PI / 2); context.closePath(); context.fill(); context.restore(); } } break; } case 'monogram': { // 큰 글자 하나를 중심으로 구성해 반복 패턴과 다른 분위기를 만듭니다. context.save(); const size = unit * 0.92; const shift = px * unit * 0.28; const hueShift = px * 110 + py * 65 + Math.sin(phase * 0.3) * 25; const gradient = context.createLinearGradient(cx - size * 0.5 + shift, cy - size * 0.3 + py * unit * 0.12, cx + size * 0.5 + shift, cy + size * 0.3); gradient.addColorStop(0, color(hueShift, 42, 70)); gradient.addColorStop(0.35, color(hueShift + 50, 62, 75)); gradient.addColorStop(0.65, color(hueShift + 130, 65, 70)); gradient.addColorStop(1, color(hueShift + 200, 48, 65)); context.fillStyle = gradient; context.fillRect(0, 0, width, height); const lightX = cx + px * size * 0.35; const lightY = cy + py * size * 0.35; const light = context.createRadialGradient(lightX, lightY, 0, lightX, lightY, size * 0.5); light.addColorStop(0, 'rgba(255, 255, 255, 0.75)'); light.addColorStop(0.4, 'rgba(255, 245, 225, 0.25)'); light.addColorStop(1, 'rgba(255, 255, 255, 0)'); context.globalCompositeOperation = 'screen'; context.fillStyle = light; context.fillRect(0, 0, width, height); context.globalCompositeOperation = 'destination-in'; context.fillStyle = '#000'; context.font = `700 ${size}px Arial, sans-serif`; context.textAlign = 'center'; context.textBaseline = 'middle'; context.translate(cx, cy); context.rotate(px * 0.075); const growth = 1 + pointer.active * 0.045 + Math.sin(phase * 0.25) * 0.01; context.scale(growth, growth); context.fillText('N', 0, size * 0.045); context.restore(); break; } case 'glyphs': { const cell = Math.max(64, Math.sqrt(width * height / 130)); const cursor = localPointer(); const reach = Math.max(cell * 1.8, unit * 0.3); let index = 0; for (let y = cell * 0.5; y < height; y += cell) { for (let x = cell * 0.5; x < width; x += cell) { const point = particles[index++ % particles.length]; const focus = Math.exp(-Math.pow(Math.hypot(x - cursor.x, y - cursor.y) / reach, 2) * 2) * pointer.active; const size = cell * (0.16 + focus * 0.03); const glyph = Math.floor(point.depth * 5); context.save(); context.translate(x, y); context.rotate(Math.floor(point.x * 4) * Math.PI / 2 + focus * Math.PI / 2 + Math.sin(phase * 0.15 + point.offset) * 0.025); context.strokeStyle = color(focus * 70 + glyph * 8, theme.matches ? 72 : 40, 32 + focus * 20); context.fillStyle = context.strokeStyle; context.lineWidth = Math.max(1.5, cell * 0.024); if (glyph === 0) { path([[-size, 0], [size, 0]]); path([[0, -size], [0, size]]); } else if (glyph === 1) { path([[-size, -size], [-size, size], [size, size]]); } else if (glyph === 2) { path([[-size, -size * 0.4], [size, -size * 0.4]]); path([[-size, size * 0.4], [size, size * 0.4]]); } else if (glyph === 3) { context.beginPath(); context.arc(0, 0, size, -Math.PI / 2, Math.PI / 2); context.stroke(); context.beginPath(); context.arc(-size * 0.5, 0, size * 0.16, 0, Math.PI * 2); context.fill(); } else { path([[-size * 0.7, size], [0, -size], [size * 0.7, size]]); } context.restore(); } } break; } } context.globalAlpha = 1; } function draw() { context.clearRect(0, 0, width, height); context.strokeStyle = stroke; context.fillStyle = stroke; context.lineWidth = 0.8; context.lineCap = 'butt'; const drift = reducedMotion.matches ? 0 : Math.sin(phase) * 0.012; const px = pointer.x; const py = pointer.y; const scale = Math.max(width, height * 1.35); if (width <= 0 || height <= 0) return; if (variant === 0) { const angle = -0.42 + px * 0.14 + drift; const cos = Math.cos(angle); const sin = Math.sin(angle); for (let ring = 0; ring < 64; ring++) { const rx = scale * (0.086 + ring * 0.014); const ry = height * (0.11 + ring * 0.023); const cx = width * (0.66 - ring * 0.0028 + px * 0.055); const cy = height * (0.58 - ring * 0.0046 + py * 0.07); const points = []; for (let step = 0; step <= 160; step++) { const t = step / 160 * Math.PI * 2; const flex = 1 + Math.sin(t * 3 + ring * 0.04) * (0.012 + py * 0.015); const x = Math.cos(t) * rx * flex; const y = Math.sin(t) * ry * flex; points.push([cx + x * cos - y * sin, cy + x * sin + y * cos]); } path(points); } } else if (variant === 1) { for (let line = -18; line < 58; line++) { const points = []; for (let step = 0; step <= 150; step++) { const x = step / 150 * width; const u = x / width; const envelope = Math.sin(u * Math.PI); const wave = Math.sin(u * Math.PI * 2.1 + line * 0.048 + px * 0.6 + drift); const y = height * (line / 40 + wave * envelope * (0.24 + py * 0.07)); points.push([x, y]); } path(points); } } else if (variant === 2) { const bend = (u, v) => { const dx = u - (0.54 + px * 0.12); const dy = v - (0.48 + py * 0.12); const influence = Math.exp(-(dx * dx + dy * dy) * 5); const twist = influence * (0.85 + drift * 3); return [ width * (u + (dx * (Math.cos(twist) - 1) - dy * Math.sin(twist)) * 0.7), height * (v + (dx * Math.sin(twist) + dy * (Math.cos(twist) - 1)) * 0.7) ]; }; for (let line = -8; line <= 48; line++) { const horizontal = []; const vertical = []; for (let step = 0; step <= 120; step++) { const position = -0.3 + step / 120 * 1.6; horizontal.push(bend(position, line / 40)); vertical.push(bend(line / 40, position)); } path(horizontal); path(vertical); } } else if (variant === 3) { // 넓은 빛 영역을 겹쳐 표현하고, 전체 캔버스의 블러 연산은 피합니다. context.save(); context.globalCompositeOperation = theme.matches ? 'screen' : 'multiply'; const colors = theme.matches ? ['255, 99, 61', '114, 90, 238', '38, 170, 178'] : ['255, 123, 84', '158, 139, 242', '82, 193, 185']; for (let pool = 0; pool < 3; pool++) { const t = phase * 0.5 + pool * 2.1; const x = width * (0.25 + pool * 0.25 + Math.sin(t) * 0.05 + px * 0.08); const y = height * (0.5 + Math.cos(t) * 0.14 + py * 0.12); const radius = Math.max(width * 0.36, height * 0.7); const glow = context.createRadialGradient(x, y, 0, x, y, radius); glow.addColorStop(0, `rgba(${colors[pool]}, 0.85)`); glow.addColorStop(0.35, `rgba(${colors[pool]}, 0.4)`); glow.addColorStop(1, `rgba(${colors[pool]}, 0)`); context.fillStyle = glow; context.fillRect(0, 0, width, height); } context.restore(); } else if (variant === 4) { const bounds = field.getBoundingClientRect(); const cursorX = (px + 1) / 2 * innerWidth - bounds.left; const cursorY = (py + 1) / 2 * innerHeight - bounds.top; const radius = Math.min(width, height) * 0.28; const count = Math.min(particles.length, Math.max(350, Math.round(width * height / 480))); for (let index = 0; index < count; index++) { const point = particles[index]; // 입자를 전체 영역에 분산하고, 가장자리를 지나면 반대편에서 이어집니다. const flowX = point.x + phase * (0.025 + point.depth * 0.025) + Math.sin(phase * 0.5 + point.offset) * 0.025; const flowY = point.y + Math.sin(phase * 0.35 + point.offset) * 0.045; let x = ((flowX % 1 + 1) % 1) * width; let y = ((flowY % 1 + 1) % 1) * height; const dx = x - cursorX; const dy = y - cursorY; const distance = Math.hypot(dx, dy); const force = Math.pow(Math.max(0, 1 - distance / Math.max(radius, 1)), 2) * radius * 0.65 * pointer.active; x += dx / Math.max(distance, 1) * force; y += dy / Math.max(distance, 1) * force; context.globalAlpha = 0.3 + point.depth * 0.7; context.beginPath(); context.arc(x, y, point.size, 0, Math.PI * 2); context.fill(); } context.globalAlpha = 1; } else if (variant === 5) { const wide = width > 680; const rows = wide ? Math.max(3, Math.round(height / 160)) : 5; const rowHeight = height / rows; const text = 'NO NAME'; context.font = `700 ${Math.max(rowHeight * 1.12, 1)}px Arial, sans-serif`; context.textBaseline = 'middle'; const textWidth = context.measureText(text).width; const stretch = wide ? 1 : width * 0.91 / Math.max(textWidth, 1); const spacing = textWidth * stretch + rowHeight * 0.32; for (let row = 0; row < rows; row++) { const proximity = Math.exp(-Math.pow((row + 0.5) / rows - (py + 1) / 2, 2) * 18); const shift = px * width * 0.07 * proximity; const breathing = Math.sin(phase * 0.6 + row * 0.7) * 0.015; context.save(); context.beginPath(); context.rect(0, row * rowHeight, width, rowHeight); context.clip(); const offset = wide ? -spacing * (row % 2 ? 0.45 : 0.08) : width * 0.045; context.translate(offset + shift, (row + 0.5) * rowHeight); context.scale(stretch * (1 + breathing), 0.95 + proximity * pointer.active * 0.16); const copies = wide ? Math.ceil(width / spacing) + 2 : 1; for (let copy = 0; copy < copies; copy++) { const x = copy * spacing / stretch; if (row % 2 === 0) context.fillText(text, x, 0); else { context.lineWidth = 1.2 / Math.max(stretch, 0.1); context.strokeText(text, x, 0); } } context.restore(); } } else if (variant === 6) { context.save(); context.globalAlpha = 0.5; const gap = Math.max(8, Math.min(width, height) / 48); const extent = Math.hypot(width, height); for (let layer = 0; layer < 2; layer++) { const direction = layer === 0 ? -1 : 1; const cx = width * (0.5 + direction * 0.055) + direction * px * width * 0.04; const cy = height * 0.5 + direction * (py * height * 0.06 + Math.sin(phase * 0.3) * 8); for (let radius = gap; radius < extent; radius += gap) { context.beginPath(); context.ellipse(cx, cy, radius, radius * (1 + layer * 0.018), layer * 0.08, 0, Math.PI * 2); context.stroke(); } } context.restore(); } else if (variant === 7) { const columns = Math.max(24, Math.min(64, Math.round(width / 24))); const boundaries = []; for (let column = 0; column <= columns; column++) { const u = column / columns; const focus = (px + 1) / 2; const bend = Math.sin((u - focus) * Math.PI * 2) * 0.045 * pointer.active; boundaries.push(width * (u + bend * Math.sin(u * Math.PI))); } for (let column = 0; column < columns; column++) { const u = column / columns; const hue = 15 + u * 215 + Math.sin(phase * 0.25) * 8; const lightness = theme.matches ? 36 : 62; const color = context.createLinearGradient(0, 0, 0, height); color.addColorStop(0, `hsl(${hue}, 55%, ${lightness + 10}%)`); color.addColorStop(0.5 + py * 0.15, `hsl(${hue + 18}, 65%, ${lightness - 9}%)`); color.addColorStop(1, `hsl(${hue - 10}, 45%, ${lightness}%)`); context.fillStyle = color; context.fillRect(boundaries[column], 0, Math.max(0, boundaries[column + 1] - boundaries[column] - 1.5), height); } } else if (variant === 8) { // 커서 입력 없이도 완성된 구성이 보이도록 수식으로 궤적을 그립니다. for (let ribbon = 0; ribbon < 5; ribbon++) { const points = []; for (let step = 0; step <= 160; step++) { const t = step / 160 * Math.PI * 2; points.push([ width * 0.5 + Math.sin(t * 2 + phase * 0.12 + ribbon * 0.065) * width * 0.4, height * 0.5 + Math.sin(t * 3 + ribbon * 0.085) * height * 0.35 ]); } context.globalAlpha = 0.3 + ribbon * 0.1; context.strokeStyle = `hsl(${190 + ribbon * 18}, 65%, ${theme.matches ? 65 : 38}%)`; context.lineWidth = 0.9; path(points); } context.globalAlpha = 1; } else if (variant === 9) { const cell = Math.max(12, Math.sqrt(width * height / 1800)); const cursor = localPointer(); for (let y = 0; y < height; y += cell) { for (let x = 0; x < width; x += cell) { const distance = Math.hypot(x - cursor.x, y - cursor.y) / Math.max(height, 1); const wave = Math.sin(x / width * 8 + phase * 0.55) * Math.cos(y / height * 7 - phase * 0.35); const ripple = Math.cos(distance * 18 - phase * 1.3) * Math.exp(-distance * 3) * pointer.active; const value = Math.max(0, Math.min(1, (wave + ripple + 1) / 2)); context.fillStyle = `hsl(${175 + value * 75}, 45%, ${ (theme.matches ? 18 : 35) + value * (theme.matches ? 40 : 38)}%)`; const inset = cell * (0.1 + (1 - value) * 0.12); context.fillRect(x + inset, y + inset, cell - inset * 2, cell - inset * 2); } } } else if (variant === 10) { // 단색 면을 뚫어 아래에 있는 색 영역을 드러냅니다. context.save(); context.fillStyle = theme.matches ? '#172e3d' : '#263f55'; context.fillRect(0, 0, width, height); const cursor = localPointer(); const restingX = width * (0.56 + Math.sin(phase * 0.25) * 0.045); const restingY = height * (0.5 + Math.cos(phase * 0.2) * 0.04); const blend = pointer.active * 0.85; const cx = restingX * (1 - blend) + Math.max(0, Math.min(width, cursor.x)) * blend; const cy = restingY * (1 - blend) + Math.max(0, Math.min(height, cursor.y)) * blend; context.globalCompositeOperation = 'destination-out'; context.beginPath(); context.ellipse(cx, cy, Math.min(width * 0.31, height * 0.48), Math.min(width * 0.24, height * 0.35), -0.35 + px * 0.2, 0, Math.PI * 2); context.fill(); context.restore(); } else if (variant === 11) { const radius = Math.min(width * 0.3, height * 0.37); const cx = width * 0.5; const cy = height * 0.5; const offsetX = radius * (0.45 + Math.sin(phase * 0.3) * 0.12 + px * 0.22); const offsetY = radius * (-0.2 + py * 0.2); context.save(); const glow = context.createRadialGradient(cx, cy, radius * 0.65, cx, cy, radius * 1.65); glow.addColorStop(0, theme.matches ? 'rgba(245, 167, 79, 0.65)' : 'rgba(222, 113, 44, 0.35)'); glow.addColorStop(1, 'rgba(222, 113, 44, 0)'); context.fillStyle = glow; context.fillRect(0, 0, width, height); context.fillStyle = theme.matches ? '#f1b76a' : '#d87c38'; context.beginPath(); context.arc(cx, cy, radius, 0, Math.PI * 2); context.fill(); // 빛나는 원 내부로 가리는 형태를 제한해 원 밖에는 표시되지 않도록 합니다. context.clip(); context.fillStyle = theme.matches ? '#182631' : '#e5ddd0'; context.beginPath(); context.arc(cx + offsetX, cy + offsetY, radius * 0.98, 0, Math.PI * 2); context.fill(); context.restore(); } else if (variant === 12) { const cell = Math.max(44, Math.sqrt(width * height / 360)); const rowStep = cell * Math.sqrt(3) / 2; const cursor = localPointer(); const reach = Math.max(cell * 2.2, Math.min(width, height) * 0.3); for (let row = 0; row * rowStep < height + cell; row++) { const y = row * rowStep; for (let x = cell * (row % 2 ? 0.5 : 0); x < width + cell; x += cell) { const u = x / width; const v = y / height; const wave = Math.sin(u * 4 - v * 3 + phase * 0.2) * Math.cos(v * 3 + phase * 0.12); const distance = Math.hypot(x - cursor.x, y - cursor.y) / reach; const focus = Math.exp(-distance * distance * 2.5) * pointer.active; const base = 0.34 + (wave + 1) * 0.03; const radius = cell * (base * (1 - focus) + 0.5 * focus); const lightness = theme.matches ? 34 + focus * 28 : 68 - focus * 18; context.fillStyle = `hsl(${315 + focus * 22}, ${18 + focus * 30}%, ${lightness}%)`; context.globalAlpha = 0.7 + focus * 0.2; context.beginPath(); for (let corner = 0; corner < 6; corner++) { const angle = corner / 6 * Math.PI * 2 - Math.PI / 2; const hx = x + Math.cos(angle) * radius; const hy = y + Math.sin(angle) * radius; if (corner === 0) context.moveTo(hx, hy); else context.lineTo(hx, hy); } context.closePath(); context.fill(); } } context.globalAlpha = 1; } else { drawExtended(styles[variant], px, py); } } function tick(time) { frame = 0; if (!visible || document.hidden || reducedMotion.matches) return; if (time - lastTime >= 1000 / 30) { const elapsed = Math.min(time - lastTime, 64); lastTime = time; phase += elapsed * 0.00018; const response = variant === 12 || variant === 28 ? 0.24 : 0.09; pointer.x += (pointer.targetX - pointer.x) * response; pointer.y += (pointer.targetY - pointer.y) * response; pointer.active += (pointer.targetActive - pointer.active) * response; draw(); } frame = requestAnimationFrame(tick); } function resume() { if (!frame && visible && !document.hidden && !reducedMotion.matches) { lastTime = performance.now(); frame = requestAnimationFrame(tick); } } function resize() { const bounds = field.getBoundingClientRect(); width = bounds.width; height = bounds.height; const ratio = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); context.setTransform(ratio, 0, 0, ratio, 0, 0); stroke = getComputedStyle(field).getPropertyValue('--contour').trim(); draw(); field.classList.add('is-ready'); resume(); } // 개발용 선택 기능: 새로고침 없이 현재 화면에서 작품을 전환합니다. function selectArtwork(index) { variant = (index + styles.length) % styles.length; field.dataset.artwork = styles[variant]; if (variant >= 13) { field.dataset.series = 'extended'; field.style.setProperty('--art-hue', extendedHues[variant - 13]); } else { delete field.dataset.series; field.style.removeProperty('--art-hue'); } phase = 0; pointer.x = pointer.y = pointer.targetX = pointer.targetY = 0; pointer.active = pointer.targetActive = 0; picker.value = String(variant); particles = particleSet(); resize(); } picker.addEventListener('change', () => selectArtwork(Number(picker.value))); document.querySelector('#artwork-previous').addEventListener('click', () => selectArtwork(variant - 1)); document.querySelector('#artwork-next').addEventListener('click', () => selectArtwork(variant + 1)); document.querySelector('#artwork-random').addEventListener('click', () => { const random = schedule.createRandom(Date.now()); selectArtwork(variant + 1 + Math.floor(random() * (styles.length - 1))); }); // 작품은 현재 시간만으로 결정하고, 개발자 선택은 임시 미리보기로 사용합니다. // 패널을 닫거나 다음 시간 슬롯에 진입하면 예정된 작품으로 돌아갑니다. let scheduleTimer; function syncSchedule(restore = false) { clearTimeout(scheduleTimer); const now = Date.now(); const selection = schedule.selectionAt(now, styles.length); if (restore || selection.slot !== currentSlot) { currentSlot = selection.slot; selectArtwork(selection.variant); } const untilBoundary = schedule.SLOT_DURATION - (now % schedule.SLOT_DURATION); scheduleTimer = setTimeout(syncSchedule, Math.min(untilBoundary, 60000)); } syncSchedule(); const controls = document.querySelector('.artwork-controls'); const trigger = document.querySelector('#developer-trigger'); let holdTimer; let holdPointer = null; let holdOrigin; function cancelHold() { clearTimeout(holdTimer); const pointerId = holdPointer; holdPointer = null; if (pointerId !== null && trigger.hasPointerCapture(pointerId)) { trigger.releasePointerCapture(pointerId); } } function openControls() { cancelHold(); controls.hidden = false; controls.open = true; document.querySelector('#artwork-close').focus({ preventScroll: true }); } trigger.addEventListener('pointerdown', event => { if (!event.isPrimary || event.button !== 0) return; event.preventDefault(); cancelHold(); holdPointer = event.pointerId; holdOrigin = { x: event.clientX, y: event.clientY }; trigger.setPointerCapture(event.pointerId); holdTimer = setTimeout(openControls, 5000); }); trigger.addEventListener('pointermove', event => { if (event.pointerId === holdPointer && Math.hypot(event.clientX - holdOrigin.x, event.clientY - holdOrigin.y) > 12) cancelHold(); }); for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) { trigger.addEventListener(event, cancelHold); } trigger.addEventListener('contextmenu', event => event.preventDefault()); trigger.addEventListener('keydown', event => { if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) { event.preventDefault(); clearTimeout(holdTimer); holdTimer = setTimeout(openControls, 5000); } }); trigger.addEventListener('keyup', cancelHold); trigger.addEventListener('blur', cancelHold); function closeControls() { controls.hidden = true; cancelHold(); syncSchedule(true); trigger.focus({ preventScroll: true }); } document.querySelector('#artwork-close').addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); closeControls(); }); controls.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); closeControls(); } }); window.addEventListener('blur', cancelHold); window.addEventListener('focus', () => syncSchedule()); document.addEventListener('pointermove', event => { if (reducedMotion.matches) return; if (event.target.closest('.artwork-controls, #developer-trigger')) { release(); return; } pointer.targetX = Math.max(-1, Math.min(1, event.clientX / innerWidth * 2 - 1)); pointer.targetY = Math.max(-1, Math.min(1, event.clientY / innerHeight * 2 - 1)); pointer.targetActive = 1; }, { passive: true }); function release() { pointer.targetX = 0; pointer.targetY = 0; pointer.targetActive = 0; } document.documentElement.addEventListener('pointerleave', release); document.addEventListener('pointerup', event => { if (event.pointerType !== 'mouse') release(); }); window.addEventListener('blur', release); document.addEventListener('visibilitychange', () => { cancelHold(); if (!document.hidden) syncSchedule(); resume(); }); reducedMotion.addEventListener('change', () => { pointer.x = pointer.y = pointer.targetX = pointer.targetY = 0; pointer.active = pointer.targetActive = 0; draw(); resume(); }); theme.addEventListener('change', resize); new ResizeObserver(resize).observe(field); new IntersectionObserver(entries => { visible = entries[0].isIntersecting; resume(); }).observe(field); })();