인터랙티브 아트워크 30종과 시간 기반 전환 및 개발 문서 추가
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
6
AGENTS.md
Normal file
6
AGENTS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 저장소 작업 규칙
|
||||
|
||||
- 커밋 메시지는 항상 한국어로 작성합니다.
|
||||
- Git 작성자는 `zenn <zenn.message@gmail.com>`을 사용합니다. 이 저장소의 로컬 설정에만 적용하고 전역 설정은 변경하지 않습니다.
|
||||
- 코드 주석은 한국어로 작성합니다.
|
||||
- 사이트 구조, 시간 기반 전환, 개발자 모드, 테스트 사용 방법은 `README.md`에 기록합니다. 관련 동작을 변경하면 문서도 갱신합니다.
|
||||
78
README.md
Normal file
78
README.md
Normal file
@@ -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 <zenn.message@gmail.com>`을 사용합니다.
|
||||
- 작성자 설정은 저장소에만 적용하고, 다른 프로젝트의 전역 설정은 변경하지 않습니다.
|
||||
|
||||
```sh
|
||||
git config --local user.name zenn
|
||||
git config --local user.email zenn.message@gmail.com
|
||||
```
|
||||
|
||||
원격 저장소: `https://git.sori.studio/noname.company/root.git`
|
||||
50
artwork-schedule.js
Normal file
50
artwork-schedule.js
Normal file
@@ -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 });
|
||||
})();
|
||||
938
artwork.js
Normal file
938
artwork.js
Normal file
@@ -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);
|
||||
})();
|
||||
BIN
favicon.png
Normal file
BIN
favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
728
index.html
Normal file
728
index.html
Normal file
@@ -0,0 +1,728 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#111111" media="(prefers-color-scheme: dark)">
|
||||
<meta name="description" content="NO NAME COMPANY.">
|
||||
<meta property="og:title" content="NO NAME COMPANY.">
|
||||
<meta property="og:description" content="NONAME.COMPANY">
|
||||
<meta property="og:type" content="website">
|
||||
<title>NO NAME COMPANY.</title>
|
||||
<link rel="icon" href="favicon.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--paper: #fff;
|
||||
--ink: #181818;
|
||||
--contour: #414141;
|
||||
--muted: #737373;
|
||||
--line: #dedede;
|
||||
--axis: rgb(0 0 0 / 9%);
|
||||
--marker: #b6b6b6;
|
||||
--gutter: clamp(24px, 5.5vw, 112px);
|
||||
--inset: clamp(20px, 2.2vw, 40px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: #111;
|
||||
--ink: #eee;
|
||||
--contour: #929292;
|
||||
--muted: #a3a3a3;
|
||||
--line: #343434;
|
||||
--axis: rgb(255 255 255 / 10%);
|
||||
--marker: #606060;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(360px, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 제목, 식별 정보, 푸터는 동일한 12열 격자를 사용합니다. */
|
||||
.intro,
|
||||
.colophon {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
margin-inline: var(--gutter);
|
||||
border-inline: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.intro {
|
||||
min-height: clamp(254px, 34vh, 368px);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
grid-column: 1 / 8;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
padding: 40px var(--inset) 44px;
|
||||
font-size: clamp(52px, 6.4vw, 116px);
|
||||
font-weight: 400;
|
||||
line-height: 0.94;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.wordmark span {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.identity {
|
||||
grid-column: 8 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto 1fr minmax(28px, auto);
|
||||
align-items: start;
|
||||
gap: 24px 16px;
|
||||
padding: 38px var(--inset) 35px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.micro {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.065em;
|
||||
}
|
||||
|
||||
.identifier {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.domain {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
align-self: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 캔버스가 준비되기 전에는 정적인 선 패턴을 표시합니다. */
|
||||
.artwork {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 360px;
|
||||
margin: 0;
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.contours {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.artwork canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="orbit"] {
|
||||
--contour: #a65327;
|
||||
background: radial-gradient(ellipse at 66% 58%, #f4ba77, #eed8b9 55%, #ede6da);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="wave"] {
|
||||
--contour: #326f96;
|
||||
background: radial-gradient(ellipse at 40% 65%, #a5d2e5, #d4e4e8 65%, #e5e9e7);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="mesh"] {
|
||||
--contour: #76609a;
|
||||
background: radial-gradient(ellipse at 54% 48%, #c7b0e1, #e0d6e8 60%, #eae5e9);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="bloom"] {
|
||||
background: #eee9e2;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="particles"] {
|
||||
--contour: #215950;
|
||||
background: radial-gradient(ellipse at center, #d8e7cf, #e9eee1 70%);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="type"] {
|
||||
--contour: #b53627;
|
||||
background: #f0d4b9;
|
||||
}
|
||||
|
||||
|
||||
.artwork[data-artwork="moire"] {
|
||||
--contour: #775436;
|
||||
background: #e9decb;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="slits"] {
|
||||
background: #e5d9cc;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="trails"] {
|
||||
background: radial-gradient(ellipse at center, #e0e7ed, #efe9e1);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="pixels"] {
|
||||
background: #d5e3df;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="negative"] {
|
||||
background: linear-gradient(135deg, #eab48f, #f4d7a0 55%, #dd7655);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="eclipse"] {
|
||||
background: #e5ddd0;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="hexagons"] {
|
||||
background: #ebdfe0;
|
||||
}
|
||||
|
||||
.artwork[data-series="extended"] {
|
||||
--contour: hsl(var(--art-hue) 30% 38%);
|
||||
background: hsl(var(--art-hue) 22% 89%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.artwork[data-artwork="orbit"] {
|
||||
--contour: #d3955d;
|
||||
background: radial-gradient(ellipse at 66% 58%, #573623, #261e19 65%, #171513);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="wave"] {
|
||||
--contour: #7aaec8;
|
||||
background: radial-gradient(ellipse at 40% 65%, #173e51, #17262d 65%, #13191d);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="mesh"] {
|
||||
--contour: #b29bcf;
|
||||
background: radial-gradient(ellipse at 54% 48%, #3a2b51, #241e2e 60%, #19161e);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="bloom"] {
|
||||
background: #14121b;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="particles"] {
|
||||
--contour: #b9d9a4;
|
||||
background: radial-gradient(ellipse at center, #23392f, #111e1a 70%);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="type"] {
|
||||
--contour: #f08560;
|
||||
background: #311c21;
|
||||
}
|
||||
|
||||
|
||||
.artwork[data-artwork="moire"] {
|
||||
--contour: #bca588;
|
||||
background: #25211d;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="slits"] {
|
||||
background: #1b2028;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="trails"] {
|
||||
background: radial-gradient(ellipse at center, #202b3c, #171b24);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="pixels"] {
|
||||
background: #111f27;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="negative"] {
|
||||
background: linear-gradient(135deg, #c46d50, #e1ab73 55%, #974f46);
|
||||
}
|
||||
|
||||
.artwork[data-artwork="eclipse"] {
|
||||
background: #182631;
|
||||
}
|
||||
|
||||
.artwork[data-artwork="hexagons"] {
|
||||
background: #30232e;
|
||||
}
|
||||
|
||||
.artwork[data-series="extended"] {
|
||||
--contour: hsl(var(--art-hue) 28% 72%);
|
||||
background: hsl(var(--art-hue) 22% 13%);
|
||||
}
|
||||
}
|
||||
|
||||
.artwork.is-ready .contours {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
left: calc(66% - var(--i) * 0.28%);
|
||||
top: calc(58% - var(--i) * 0.46%);
|
||||
width: calc(17% + var(--i) * 2.8%);
|
||||
height: calc(22% + var(--i) * 4.6%);
|
||||
border: 1px solid var(--contour);
|
||||
border-radius: 48% 52% 49% 51% / 43% 44% 56% 57%;
|
||||
transform: translate(-50%, -50%) rotate(-24deg);
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset-block: 0;
|
||||
width: 1px;
|
||||
background: var(--axis);
|
||||
}
|
||||
|
||||
.axis-left {
|
||||
left: var(--gutter);
|
||||
}
|
||||
|
||||
.axis-right {
|
||||
right: var(--gutter);
|
||||
}
|
||||
|
||||
.cross {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -5px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--marker);
|
||||
}
|
||||
|
||||
.cross-left {
|
||||
left: calc(var(--gutter) - 4px);
|
||||
}
|
||||
|
||||
.cross-right {
|
||||
right: calc(var(--gutter) - 4px);
|
||||
}
|
||||
|
||||
.colophon {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
padding-inline: var(--inset);
|
||||
}
|
||||
|
||||
.credit {
|
||||
grid-column: 8 / -1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.developer-trigger {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.developer-trigger:focus-visible {
|
||||
outline: 1px solid var(--muted);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* 개발용 작품 선택 패널은 푸터의 zenn을 길게 눌러 열 수 있습니다. */
|
||||
.artwork-controls {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
right: 16px;
|
||||
bottom: calc(76px + env(safe-area-inset-bottom));
|
||||
width: min(252px, calc(100vw - 32px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.artwork-controls:not([open]) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.artwork-controls[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.artwork-controls summary {
|
||||
padding: 12px 48px 12px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.artwork-controls-panel {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.artwork-controls label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.artwork-controls select,
|
||||
.artwork-controls button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.artwork-controls select {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.artwork-controls-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.artwork-controls button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.artwork-controls .artwork-controls-close {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 5px;
|
||||
width: 32px;
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.artwork-controls button:hover {
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.artwork-controls :focus-visible {
|
||||
outline: 2px solid var(--muted);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* 좁은 화면에서는 작품 영역을 확보하고 패턴의 중심 위치를 조정합니다. */
|
||||
@media (max-width: 680px) {
|
||||
:root {
|
||||
--gutter: 20px;
|
||||
--inset: 16px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100svh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.intro {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
grid-column: 1 / -1;
|
||||
padding-block: 38px 32px;
|
||||
font-size: clamp(44px, 11.8vw, 80px);
|
||||
}
|
||||
|
||||
.identity {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: 25px 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
gap: 9px 16px;
|
||||
padding-block: 17px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.micro {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.identifier {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.domain {
|
||||
grid-column: 2 / -1;
|
||||
grid-row: 2;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.credit {
|
||||
grid-column: 7 / -1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.artwork {
|
||||
min-height: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.contours {
|
||||
inset-inline: -42%;
|
||||
}
|
||||
|
||||
.ring {
|
||||
left: calc(56% - var(--i) * 0.28%);
|
||||
top: calc(56% - var(--i) * 0.46%);
|
||||
}
|
||||
|
||||
.colophon {
|
||||
min-height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 모바일 가로 화면처럼 높이가 낮은 경우에도 한 화면에 전체를 표시합니다. */
|
||||
@media (max-height: 520px) {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100svh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.intro,
|
||||
.artwork {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.artwork {
|
||||
overflow-x: clip;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) and (max-height: 520px) {
|
||||
.wordmark {
|
||||
padding-block: 18px 14px;
|
||||
font-size: clamp(32px, 9vw, 48px);
|
||||
}
|
||||
|
||||
.identity {
|
||||
gap: 6px 12px;
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.intro,
|
||||
.colophon {
|
||||
animation: appear 600ms ease-out both;
|
||||
}
|
||||
|
||||
.contours {
|
||||
animation: develop 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes develop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<main>
|
||||
<header class="intro">
|
||||
<h1 class="wordmark">
|
||||
<span>NO NAME</span>
|
||||
<span>COMPANY.</span>
|
||||
</h1>
|
||||
<div class="identity">
|
||||
<p class="identifier micro">NNC</p>
|
||||
<p class="domain micro">NONAME.COMPANY</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="artwork" aria-hidden="true">
|
||||
<div class="contours">
|
||||
<span class="ring" style="--i: 0"></span>
|
||||
<span class="ring" style="--i: 1"></span>
|
||||
<span class="ring" style="--i: 2"></span>
|
||||
<span class="ring" style="--i: 3"></span>
|
||||
<span class="ring" style="--i: 4"></span>
|
||||
<span class="ring" style="--i: 5"></span>
|
||||
<span class="ring" style="--i: 6"></span>
|
||||
<span class="ring" style="--i: 7"></span>
|
||||
<span class="ring" style="--i: 8"></span>
|
||||
<span class="ring" style="--i: 9"></span>
|
||||
<span class="ring" style="--i: 10"></span>
|
||||
<span class="ring" style="--i: 11"></span>
|
||||
<span class="ring" style="--i: 12"></span>
|
||||
<span class="ring" style="--i: 13"></span>
|
||||
<span class="ring" style="--i: 14"></span>
|
||||
<span class="ring" style="--i: 15"></span>
|
||||
<span class="ring" style="--i: 16"></span>
|
||||
<span class="ring" style="--i: 17"></span>
|
||||
<span class="ring" style="--i: 18"></span>
|
||||
<span class="ring" style="--i: 19"></span>
|
||||
<span class="ring" style="--i: 20"></span>
|
||||
<span class="ring" style="--i: 21"></span>
|
||||
<span class="ring" style="--i: 22"></span>
|
||||
<span class="ring" style="--i: 23"></span>
|
||||
<span class="ring" style="--i: 24"></span>
|
||||
<span class="ring" style="--i: 25"></span>
|
||||
<span class="ring" style="--i: 26"></span>
|
||||
<span class="ring" style="--i: 27"></span>
|
||||
<span class="ring" style="--i: 28"></span>
|
||||
<span class="ring" style="--i: 29"></span>
|
||||
<span class="ring" style="--i: 30"></span>
|
||||
<span class="ring" style="--i: 31"></span>
|
||||
<span class="ring" style="--i: 32"></span>
|
||||
<span class="ring" style="--i: 33"></span>
|
||||
<span class="ring" style="--i: 34"></span>
|
||||
<span class="ring" style="--i: 35"></span>
|
||||
<span class="ring" style="--i: 36"></span>
|
||||
<span class="ring" style="--i: 37"></span>
|
||||
<span class="ring" style="--i: 38"></span>
|
||||
<span class="ring" style="--i: 39"></span>
|
||||
<span class="ring" style="--i: 40"></span>
|
||||
<span class="ring" style="--i: 41"></span>
|
||||
<span class="ring" style="--i: 42"></span>
|
||||
<span class="ring" style="--i: 43"></span>
|
||||
<span class="ring" style="--i: 44"></span>
|
||||
<span class="ring" style="--i: 45"></span>
|
||||
<span class="ring" style="--i: 46"></span>
|
||||
<span class="ring" style="--i: 47"></span>
|
||||
<span class="ring" style="--i: 48"></span>
|
||||
<span class="ring" style="--i: 49"></span>
|
||||
<span class="ring" style="--i: 50"></span>
|
||||
<span class="ring" style="--i: 51"></span>
|
||||
<span class="ring" style="--i: 52"></span>
|
||||
<span class="ring" style="--i: 53"></span>
|
||||
<span class="ring" style="--i: 54"></span>
|
||||
<span class="ring" style="--i: 55"></span>
|
||||
<span class="ring" style="--i: 56"></span>
|
||||
<span class="ring" style="--i: 57"></span>
|
||||
<span class="ring" style="--i: 58"></span>
|
||||
<span class="ring" style="--i: 59"></span>
|
||||
<span class="ring" style="--i: 60"></span>
|
||||
<span class="ring" style="--i: 61"></span>
|
||||
<span class="ring" style="--i: 62"></span>
|
||||
<span class="ring" style="--i: 63"></span>
|
||||
</div>
|
||||
<span class="axis axis-left"></span>
|
||||
<span class="axis axis-right"></span>
|
||||
<span class="cross cross-left"></span>
|
||||
<span class="cross cross-right"></span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="colophon">
|
||||
<p class="credit micro">Designed by <button class="developer-trigger" id="developer-trigger" type="button" aria-label="Hold for 5 seconds to open artwork controls">zenn</button></p>
|
||||
</footer>
|
||||
</div>
|
||||
<details class="artwork-controls" hidden open>
|
||||
<summary>ARTWORKS / 30 · TEST<button id="artwork-close" class="artwork-controls-close" type="button" aria-label="Close artwork controls">×</button></summary>
|
||||
<div class="artwork-controls-panel">
|
||||
<label for="artwork-picker">Choose artwork</label>
|
||||
<select id="artwork-picker"></select>
|
||||
<div class="artwork-controls-actions">
|
||||
<button id="artwork-previous" type="button" aria-label="Previous artwork">← Prev</button>
|
||||
<button id="artwork-random" type="button">Random</button>
|
||||
<button id="artwork-next" type="button" aria-label="Next artwork">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<script src="artwork-schedule.js" defer></script>
|
||||
<script src="artwork.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
84
tests/artwork-schedule.test.cjs
Normal file
84
tests/artwork-schedule.test.cjs
Normal file
@@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
191
tests/artwork.test.cjs
Normal file
191
tests/artwork.test.cjs
Normal file
@@ -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, /<details class="artwork-controls" hidden open>/);
|
||||
assert.match(html, /id="developer-trigger"[^>]*>zenn<\/button>/);
|
||||
assert.match(html, /id="artwork-close"/);
|
||||
assert(!html.includes('artwork-name'));
|
||||
assert(!artworkCode.includes('artwork-name'));
|
||||
assert(html.indexOf('src="artwork-schedule.js"') < html.indexOf('src="artwork.js"'));
|
||||
assert(!/Math\.random|sessionStorage|localStorage/.test(artworkCode));
|
||||
});
|
||||
|
||||
for (const pointerType of ['mouse', 'touch']) {
|
||||
test(`${pointerType} opens the panel at exactly 5 seconds, then X hides it`, () => {
|
||||
const app = page();
|
||||
assert(app.controls.hidden);
|
||||
app.trigger.emit('pointerdown', pointer(pointerType));
|
||||
app.advance(4999);
|
||||
assert(app.controls.hidden);
|
||||
app.advance(1);
|
||||
assert.equal(app.controls.hidden, false);
|
||||
app.choose(29);
|
||||
app.close.emit('click', { preventDefault() {}, stopPropagation() {} });
|
||||
assert(app.controls.hidden);
|
||||
assert.equal(Number(app.picker.value), app.schedule.selectionAt(app.now()).variant);
|
||||
assert(page({ time: app.now() }).controls.hidden);
|
||||
});
|
||||
}
|
||||
|
||||
test('release, movement, canceled touch, lost capture, blur and hidden tab cancel a pending hold', () => {
|
||||
for (const abort of ['pointerup', 'pointercancel', 'lostpointercapture', 'move', 'blur', 'hidden']) {
|
||||
const app = page();
|
||||
app.trigger.emit('pointerdown', pointer('touch'));
|
||||
app.advance(4000);
|
||||
if (abort === 'move') app.trigger.emit('pointermove', pointer('touch', { clientX: 365 }));
|
||||
else if (abort === 'blur') app.window.emit('blur');
|
||||
else if (abort === 'hidden') { app.document.hidden = true; app.document.emit('visibilitychange'); }
|
||||
else app.trigger.emit(abort, pointer('touch'));
|
||||
app.advance(2000);
|
||||
assert(app.controls.hidden, abort);
|
||||
}
|
||||
});
|
||||
|
||||
test('right click never starts a hold; keyboard long press and Escape are supported', () => {
|
||||
const app = page();
|
||||
app.trigger.emit('pointerdown', pointer('mouse', { button: 2 }));
|
||||
app.advance(5000);
|
||||
assert(app.controls.hidden);
|
||||
app.trigger.emit('keydown', { key: 'Enter', repeat: false, preventDefault() {} });
|
||||
app.advance(5000);
|
||||
assert.equal(app.controls.hidden, false);
|
||||
app.controls.emit('keydown', { key: 'Escape', preventDefault() {} });
|
||||
assert(app.controls.hidden);
|
||||
});
|
||||
|
||||
test('reloading the same slot reproduces the artwork and generated particle layout', () => {
|
||||
const first = page();
|
||||
const second = page({ time: first.now() + 20000 });
|
||||
assert.equal(first.field.dataset.artwork, second.field.dataset.artwork);
|
||||
first.choose(19);
|
||||
second.choose(19);
|
||||
assert.equal(first.points(), second.points());
|
||||
});
|
||||
|
||||
test('the open page changes at the exact next slot boundary, including with reduced motion', () => {
|
||||
const app = page();
|
||||
const initial = Number(app.picker.value);
|
||||
const remaining = app.schedule.SLOT_DURATION - app.now() % app.schedule.SLOT_DURATION;
|
||||
app.advance(remaining - 1);
|
||||
assert.equal(Number(app.picker.value), initial);
|
||||
app.advance(1);
|
||||
assert.notEqual(Number(app.picker.value), initial);
|
||||
assert.equal(Number(app.picker.value), app.schedule.selectionAt(app.now()).variant);
|
||||
});
|
||||
|
||||
test('all 30 previews render; next/previous wrap; closing restores the schedule', () => {
|
||||
for (const width of [390, 1440, 2560]) {
|
||||
const app = page({ width });
|
||||
assert.equal(app.picker.options.length, 30);
|
||||
for (let index = 0; index < 30; index++) {
|
||||
app.choose(index);
|
||||
assert.equal(app.picker.value, String(index));
|
||||
assert.match(app.picker.options[index].textContent, /^\d{2} \/ .+/);
|
||||
}
|
||||
app.next.emit('click'); assert.equal(app.picker.value, '0');
|
||||
app.previous.emit('click'); assert.equal(app.picker.value, '29');
|
||||
app.random.emit('click'); assert.notEqual(app.picker.value, '29');
|
||||
app.close.emit('click', { preventDefault() {}, stopPropagation() {} });
|
||||
assert.equal(Number(app.picker.value), app.schedule.selectionAt(app.now()).variant);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user