From a64a3e9fe96922b5f6a25c915c61fffed59586e9 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 7 Oct 2026 11:34:28 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=ED=84=B0=EB=9E=99=ED=8B=B0=EB=B8=8C?= =?UTF-8?q?=20=EC=95=84=ED=8A=B8=EC=9B=8C=ED=81=AC=2030=EC=A2=85=EA=B3=BC?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EB=B0=98=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B0=9C=EB=B0=9C=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + AGENTS.md | 6 + README.md | 78 +++ artwork-schedule.js | 50 ++ artwork.js | 938 ++++++++++++++++++++++++++++++++ favicon.png | Bin 0 -> 31051 bytes index.html | 728 +++++++++++++++++++++++++ tests/artwork-schedule.test.cjs | 84 +++ tests/artwork.test.cjs | 191 +++++++ 9 files changed, 2077 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 artwork-schedule.js create mode 100644 artwork.js create mode 100644 favicon.png create mode 100644 index.html create mode 100644 tests/artwork-schedule.test.cjs create mode 100644 tests/artwork.test.cjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f074fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +**/.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3bcae8c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# 저장소 작업 규칙 + +- 커밋 메시지는 항상 한국어로 작성합니다. +- Git 작성자는 `zenn `을 사용합니다. 이 저장소의 로컬 설정에만 적용하고 전역 설정은 변경하지 않습니다. +- 코드 주석은 한국어로 작성합니다. +- 사이트 구조, 시간 기반 전환, 개발자 모드, 테스트 사용 방법은 `README.md`에 기록합니다. 관련 동작을 변경하면 문서도 갱신합니다. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3dba955 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# NO NAME COMPANY. + +30가지 인터랙티브 아트워크를 보여주는 정적 웹사이트입니다. 서버, 데이터베이스, 별도의 빌드 과정 없이 HTML과 JavaScript로 동작합니다. + +## 파일 구성 + +| 파일 / 폴더 | 역할 | +| --- | --- | +| `index.html` | 페이지 구조, 반응형 스타일, 푸터와 개발자 패널 | +| `artwork.js` | 아트워크 30종의 캔버스 렌더링, 커서·터치 반응, 개발자 패널 조작 | +| `artwork-schedule.js` | 시간 슬롯 계산, Seed 기반 난수 생성과 셔플 | +| `favicon.png` | 사이트 아이콘 | +| `tests/` | 렌더링과 동작을 확인하는 자동 테스트. 웹사이트 실행에 사용되지 않음 | + +## 로컬 실행 + +프로젝트 폴더에서 아래 명령을 실행하고 `http://localhost:8080`에 접속합니다. Python 3가 필요합니다. + +```sh +python3 -m http.server 8080 +``` + +정적 호스팅에는 `index.html`, `artwork.js`, `artwork-schedule.js`, `favicon.png`를 함께 올리면 됩니다. `tests/`는 배포하지 않아도 됩니다. + +## 아트워크 전환 방식 + +- 브라우저의 `Date.now()`로 현재 시간을 계산합니다. +- Unix 시간 기준 **137분(2시간 17분)**을 하나의 슬롯으로 사용합니다. +- 같은 슬롯에서는 새로고침하거나 다른 브라우저로 접속해도 같은 작품을 선택합니다. 기기의 시계가 일치해야 합니다. +- 30개 슬롯을 하나의 루프로 묶습니다. 한 루프는 **68시간 30분**입니다. +- 루프 번호를 Seed로 사용한 Mulberry32 난수와 Fisher–Yates 셔플로 순서를 생성합니다. 한 루프에서 30개 작품을 각각 한 번씩 보여줍니다. +- 다음 루프의 첫 작품이 이전 루프의 마지막 작품과 같으면 첫 번째와 두 번째 항목을 교환합니다. +- 순서나 현재 작품을 서버, 쿠키, 브라우저 저장소에 저장하지 않습니다. +- 페이지를 열어둔 상태에서도 다음 슬롯에 진입하면 작품이 바뀝니다. 탭에 다시 돌아오거나 창이 활성화될 때도 현재 시간을 확인합니다. +- 점을 사용하는 작품의 기본 배치 역시 슬롯과 작품 번호로 재현합니다. + +다크 모드와 움직임 줄이기 설정을 지원합니다. 움직임 줄이기가 켜져 있어도 137분마다 작품 자체는 전환됩니다. + +## 개발자 모드 + +개발용 플로팅 패널은 기본적으로 숨겨져 있습니다. + +1. 푸터의 **`zenn`을 5초간 길게 누릅니다.** PC 마우스와 모바일 터치를 지원합니다. +2. 드롭다운에서 작품 번호와 이름을 확인하고, 직접 선택합니다. +3. 이전·다음·랜덤 버튼으로 작품을 미리 볼 수 있습니다. +4. 패널 우측 상단의 **X**를 누르면 숨겨지고, 현재 시간에 맞는 작품으로 돌아갑니다. + +5초 전에 손을 떼거나 12px보다 크게 움직이면 롱프레스가 취소됩니다. 터치 취소, 포커스 이탈, 탭 숨김도 취소 조건입니다. 키보드로 `zenn`에 포커스를 둔 뒤 Enter 또는 Space를 5초간 눌러 열 수 있으며, 패널에서 Escape로 닫을 수 있습니다. + +직접 고른 작품은 임시 미리보기입니다. 다음 시간 슬롯에 진입해도 예정된 작품으로 돌아갑니다. 새로고침하면 패널은 다시 숨겨집니다. + +## 자동 테스트 (`tests/`) + +`tests/`는 사이트 기능을 검사하기 위해 만든 개발용 코드입니다. 삭제해도 사이트 동작에는 영향이 없지만, 이후 수정 사항을 검증하려면 유지하는 편이 좋습니다. 테스트 실행에는 Node.js가 필요하며, 별도 패키지 설치는 필요하지 않습니다. + +```sh +node --test tests/artwork-schedule.test.cjs tests/artwork.test.cjs +``` + +| 테스트 파일 | 확인하는 내용 | +| --- | --- | +| `artwork-schedule.test.cjs` | 137분 경계, Seed 재현성, 한 루프의 순열, 1만 개 루프의 경계 중복 방지, 첫 두 항목 교환 | +| `artwork.test.cjs` | 패널 기본 숨김, 마우스·터치의 5초 롱프레스, 입력 취소, 닫기, 슬롯 자동 전환, 기본 배치 재현, 30개 작품 미리보기 | + +테스트는 가상 시간과 브라우저 모의 객체를 사용합니다. 실제 브라우저의 시각적 품질이나 실제 모바일 기기의 터치 동작까지 검증하지는 않습니다. 디자인이나 입력 처리를 변경했다면 실제 브라우저에서도 확인해 주세요. + +## Git 작업 규칙 + +- 커밋 메시지는 **항상 한국어**로 작성합니다. +- 이 저장소의 작성자는 `zenn `을 사용합니다. +- 작성자 설정은 저장소에만 적용하고, 다른 프로젝트의 전역 설정은 변경하지 않습니다. + +```sh +git config --local user.name zenn +git config --local user.email zenn.message@gmail.com +``` + +원격 저장소: `https://git.sori.studio/noname.company/root.git` diff --git a/artwork-schedule.js b/artwork-schedule.js new file mode 100644 index 0000000..4f771e4 --- /dev/null +++ b/artwork-schedule.js @@ -0,0 +1,50 @@ +(() => { + 'use strict'; + + const SLOT_DURATION = 137 * 60 * 1000; + + // Mulberry32: 같은 Seed는 항상 0 이상 1 미만의 동일한 난수열을 생성합니다. + function createRandom(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x6D2B79F5) >>> 0; + let value = Math.imul(state ^ (state >>> 15), state | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; + } + + function rawOrder(loop, count) { + const random = createRandom(loop); + const order = Array.from({ length: count }, (_, index) => index); + for (let index = count - 1; index > 0; index--) { + const other = Math.floor(random() * (index + 1)); + [order[index], order[other]] = [order[other], order[index]]; + } + return order; + } + + function orderForLoop(loop, count = 30) { + if (!Number.isInteger(count) || count < 3) throw new RangeError('At least three artworks are required.'); + if (!Number.isSafeInteger(loop) || loop < 0) throw new RangeError('Invalid loop number.'); + const order = rawOrder(loop, count); + if (loop > 0) { + // 항목이 3개 이상이면 첫 두 위치를 교환해도 마지막 항목은 바뀌지 않습니다. + // 따라서 이전 루프의 보정 전 마지막 항목을 그대로 비교할 수 있습니다. + // 재귀 호출, 상태 저장, 과거 루프 전체 순회는 필요하지 않습니다. + const previousLast = rawOrder(loop - 1, count)[count - 1]; + if (order[0] === previousLast) [order[0], order[1]] = [order[1], order[0]]; + } + return order; + } + + function selectionAt(time, count = 30) { + if (!Number.isFinite(time) || time < 0) throw new RangeError('Invalid timestamp.'); + const slot = Math.floor(time / SLOT_DURATION); + const loop = Math.floor(slot / count); + const position = slot % count; + return { slot, loop, position, variant: orderForLoop(loop, count)[position] }; + } + + globalThis.NNCArtworkSchedule = Object.freeze({ SLOT_DURATION, createRandom, orderForLoop, selectionAt }); +})(); diff --git a/artwork.js b/artwork.js new file mode 100644 index 0000000..e1b7ce3 --- /dev/null +++ b/artwork.js @@ -0,0 +1,938 @@ +(() => { + '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); +})(); diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f34924880226a8dd5b6e5c0797b257b92299a7 GIT binary patch literal 31051 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 G$NnE>RC&Ju literal 0 HcmV?d00001 diff --git a/index.html b/index.html new file mode 100644 index 0000000..8d2fdf9 --- /dev/null +++ b/index.html @@ -0,0 +1,728 @@ + + + + + + + + + + + + + NO NAME COMPANY. + + + + + +
+
+
+

+ NO NAME + COMPANY. +

+
+

NNC

+

NONAME.COMPANY

+
+
+ + +
+ +
+

Designed by

+
+
+ + + + + diff --git a/tests/artwork-schedule.test.cjs b/tests/artwork-schedule.test.cjs new file mode 100644 index 0000000..30f6d36 --- /dev/null +++ b/tests/artwork-schedule.test.cjs @@ -0,0 +1,84 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const sandbox = {}; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../artwork-schedule.js'), 'utf8'), sandbox); +const schedule = sandbox.NNCArtworkSchedule; +const order = loop => Array.from(schedule.orderForLoop(loop)); + +test('137-minute slots have a fixed selection up to the exact boundary', () => { + const duration = 137 * 60 * 1000; + assert.equal(schedule.SLOT_DURATION, duration); + const now = Date.parse('2026-10-07T09:00:00+09:00'); + const start = Math.floor(now / duration) * duration; + const first = schedule.selectionAt(start); + assert.equal(schedule.selectionAt(start + 1).variant, first.variant); + assert.equal(schedule.selectionAt(start + duration - 1).variant, first.variant); + const next = schedule.selectionAt(start + duration); + assert.equal(next.slot, first.slot + 1); + assert.notEqual(next.variant, first.variant); +}); + +test('PRNG streams and permutations reproduce across fresh evaluations', () => { + const fresh = {}; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../artwork-schedule.js'), 'utf8'), fresh); + for (const seed of [0, 1, 100, 101, 102, 2147483647]) { + const a = schedule.createRandom(seed); + const b = fresh.NNCArtworkSchedule.createRandom(seed); + for (let index = 0; index < 100; index++) { + const value = a(); + assert(value >= 0 && value < 1); + assert.equal(value, b()); + } + assert.deepEqual(order(seed), Array.from(fresh.NNCArtworkSchedule.orderForLoop(seed))); + } +}); + +test('10,000 loops each contain all 30 artworks with no repeat across boundaries', () => { + let previous; + for (let loop = 0; loop < 10000; loop++) { + const current = order(loop); + assert.deepEqual([...current].sort((a, b) => a - b), Array.from({ length: 30 }, (_, i) => i)); + if (previous) { + assert.notEqual(previous[29], current[0]); + assert.notDeepEqual(previous, current); + } + previous = current; + } +}); + +test('a boundary collision swaps exactly the first two items', () => { + function raw(loop) { + const random = schedule.createRandom(loop); + const result = Array.from({ length: 30 }, (_, i) => i); + for (let i = 29; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; + } + let collisions = 0; + for (let loop = 1; loop < 500; loop++) { + const expected = raw(loop); + if (raw(loop - 1)[29] === expected[0]) { + [expected[0], expected[1]] = [expected[1], expected[0]]; + collisions++; + } + assert.deepEqual(order(loop), expected); + } + assert(collisions > 0); +}); + +test('all slots of a loop map to its permutation, including loop transitions', () => { + for (const loop of [0, 100, 101, 102, 2102]) { + for (let position = 0; position < 30; position++) { + const value = schedule.selectionAt((loop * 30 + position) * schedule.SLOT_DURATION); + assert.equal(value.loop, loop); + assert.equal(value.position, position); + assert.equal(value.variant, order(loop)[position]); + } + } +}); diff --git a/tests/artwork.test.cjs b/tests/artwork.test.cjs new file mode 100644 index 0000000..65a938f --- /dev/null +++ b/tests/artwork.test.cjs @@ -0,0 +1,191 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const scheduleCode = fs.readFileSync(path.join(__dirname, '../artwork-schedule.js'), 'utf8'); +const artworkCode = fs.readFileSync(path.join(__dirname, '../artwork.js'), 'utf8'); + +function page({ time = Date.parse('2026-10-07T09:00:00+09:00'), reduced = true, width = 390, height = 480 } = {}) { + let now = time; + let nextTimer = 0; + let balance = 0; + let points = []; + const timers = new Map(); + function node() { + const events = {}; + const captures = new Set(); + return { + events, options: [], value: '', dataset: {}, hidden: false, + style: { setProperty() {}, removeProperty() {} }, + append(child) { this.options.push(child); }, + addEventListener(name, fn) { (events[name] ??= []).push(fn); }, + emit(name, event = {}) { for (const fn of events[name] ?? []) fn(event); }, + focus() {}, + setPointerCapture(id) { captures.add(id); }, + hasPointerCapture(id) { return captures.has(id); }, + releasePointerCapture(id) { captures.delete(id); this.emit('lostpointercapture', { pointerId: id }); } + }; + } + const field = Object.assign(node(), { + classList: { add() {} }, getBoundingClientRect: () => ({ width, height, left: 0, top: 250 }) + }); + const picker = node(); + const trigger = node(); + const controls = Object.assign(node(), { hidden: true, open: true }); + const close = node(); + const previous = node(); + const next = node(); + const random = node(); + const nodes = { '.artwork': field, '#artwork-picker': picker, + '#developer-trigger': trigger, '.artwork-controls': controls, '#artwork-close': close, + '#artwork-previous': previous, '#artwork-next': next, '#artwork-random': random }; + const finite = (...values) => values.filter(value => typeof value === 'number') + .forEach(value => assert(Number.isFinite(value))); + const gradient = { addColorStop(value, color) { assert(value >= 0 && value <= 1); assert(!/NaN|undefined/.test(color)); } }; + const context = new Proxy({ + clearRect() { points = []; }, + save() { balance++; }, restore() { balance--; assert(balance >= 0); }, + arc(...args) { finite(...args); assert(args[2] > 0); points.push(args); }, + ellipse(...args) { finite(...args); assert(args[2] > 0 && args[3] > 0); }, + measureText: () => ({ width: 500 }), + createRadialGradient(...args) { finite(...args); return gradient; }, + createLinearGradient(...args) { finite(...args); return gradient; } + }, { get: (object, key) => object[key] ?? finite }); + const document = Object.assign(node(), { + hidden: false, documentElement: node(), + querySelector(selector) { assert(nodes[selector], selector); return nodes[selector]; }, + createElement: tag => tag === 'canvas' ? { getContext: () => context } : node() + }); + const window = node(); + const sandbox = { + document, window, Date: { now: () => now }, Math, devicePixelRatio: 2, + innerWidth: width, innerHeight: height + 400, + performance: { now: () => 0 }, + getComputedStyle: () => ({ getPropertyValue: () => '#879098' }), + matchMedia: query => ({ matches: query.includes('reduce') && reduced, addEventListener() {} }), + requestAnimationFrame: () => 1, + setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, at: now + delay }); return id; }, + clearTimeout(id) { timers.delete(id); }, + ResizeObserver: class { constructor(fn) { this.fn = fn; } observe() { this.fn(); } }, + IntersectionObserver: class { observe() {} } + }; + vm.runInNewContext(scheduleCode, sandbox); + vm.runInNewContext(artworkCode, sandbox); + return { + field, picker, trigger, controls, close, previous, next, random, document, window, + schedule: sandbox.NNCArtworkSchedule, + now: () => now, + points: () => JSON.stringify(points), + choose(index) { picker.value = String(index); picker.emit('change'); assert.equal(balance, 0); }, + advance(duration) { + const end = now + duration; + while (true) { + const entry = [...timers.entries()].filter(([, timer]) => timer.at <= end) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!entry) break; + now = entry[1].at; + timers.delete(entry[0]); + entry[1].fn(); + } + now = end; + } + }; +} + +function pointer(pointerType = 'mouse', extras = {}) { + return { isPrimary: true, button: 0, pointerId: 1, pointerType, clientX: 340, clientY: 900, + preventDefault() {}, ...extras }; +} + +test('markup is hidden before scripts run, with a close button and correct script order', () => { + const html = fs.readFileSync(path.join(__dirname, '../index.html'), 'utf8'); + assert.match(html, /