diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index a4818fbaf..13c63cc2f 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -8,6 +8,12 @@ 3판부터는 hub 의 패턴 바퀴가 한 바퀴에 한 판씩 더해요. 1바퀴가 3판이에요. 10월 23일에 절차가 바뀌어서, 그 뒤 판은 새 패턴이 열 개예요. 한 바퀴의 절차는 [ROUND.md](ROUND.md) 에 있어요. +## 추려 보기 + +10월 25일에 바퀴를 멈추고 1–39판 298개를 다 돌려 봤어요. 창작자의 기본 프리셋 48개도 같은 엔진에서 돌려 `tools/measure.ts` 의 잣대로 쟀어요. 연구의 검사와 같은 문턱으로 패널의 3% 넘게 켜는 45개의 가운데값은 복잡도 0.22, 움직임 0.66, 채움 0.55, 가장자리 0.62 예요. 1–14판 48개는 복잡도 0.58, 움직임 0.38 로 그 근처에 서요. 15–27판 130개는 복잡도 1.58, 움직임 0.22, 채움 0.85 이고, 28–39판 120개도 1.31, 0.21, 0.86 이에요. 프리셋은 큰 모양 몇 개가 쉬지 않고 움직이는 그림이에요. 15판부터는 바탕까지 칠한 정지 그림에 작은 움직임을 얹은 그림이 됐어요. + +[shelf.json](shelf.json) 이 308개를 여섯 묶음에 나눠 담아요. 나눈 뒤에 들어온 40판 열 개도 같은 잣대로 보고 넣었는데, 후보는 없었어요. 먼저 볼 후보 13, 하나가 모자란 추상 50, 사물·장면 그림 127, 수학·데이터 도해 47, 옅은 화장품 질감 39, 잔 결과 흔한 데모 32 예요. 묶음마다 까닭이 있고, 위 두 묶음은 패턴마다 한 줄이 더 있어요. 페이지는 이 차례로 묶음을 접어 두고 후보만 펼쳐요. 묶음에 없는 패턴은 맨 위 「아직 안 추린 것」에 서요. 그림은 하나도 지우지 않았어요. + ## 패턴 패턴마다 `patterns/` 에 코드 파일 하나가 있어요. 화면은 `index.html` 에서 봐요. 예전에는 `shots/` 에 3초짜리 GIF 와 노브 시트도 있었는데, 한 판에 열 개를 만들게 되면서 지웠어요. 연구의 처음 값은 Lab 의 기본 노브 값 0, 2, 0, 0.06 을 패턴의 범위 안으로 당긴 자리예요. 2판 파일의 머리에는 `// Round: 2` 줄이 있어서, 페이지는 그 줄로 1판과 2판을 나눠 표시하고 거를 수 있어요. 가장 새 판의 점과 카드는 흰 테두리로 따로 보여요. 갈래의 한국어 이름과 색은 [families.json](families.json) 에 있어요. @@ -2191,7 +2197,7 @@ npx tsc --noEmit -p ../incoming/pattern-study/tools # 도구의 타입 실제 Pattern Lab 에서도 봤어요. `web/` 의 개발 서버를 띄우고 23개를 차례로 CODE 칸의 Paste 로 붙여 넣으면, 모두 Knobs 칸에 제 노브 이름 넷이 서고 미리보기에 오류 상자가 뜨지 않아요. -`index.html` 은 `tools/page.html` 에 `tools/gallery.ts` 를 esbuild 로 묶어 넣은 것이에요. 카드는 `web/src/lib/lab/engine.ts` 의 LabEngine 으로 돌고, 레이어는 Lab 이 코드에서 레이어를 만들 때 쓰는 `codeLayerFromSource` 가 만든 것을 그대로 실어요. 손으로 고치지 말고 위 명령으로 다시 지어요. 카드 옆의 한국어 한 줄은 위 패턴 표에서 읽어요. +`index.html` 은 `tools/page.html` 에 `tools/gallery.ts` 를 esbuild 로 묶어 넣은 것이에요. 카드는 `web/src/lib/lab/engine.ts` 의 LabEngine 으로 돌고, 레이어는 Lab 이 코드에서 레이어를 만들 때 쓰는 `codeLayerFromSource` 가 만든 것을 그대로 실어요. 코드는 패턴마다 한 번, 묶음별 JSON 블록에 들어가요. 펼친 묶음의 블록이 스크립트보다 앞에 와서, 나머지 코드가 내려오는 동안에도 첫 화면이 돌아요. 칸은 화면에 들어올 때 엔진을 만들고 나가면 버려요. 손으로 고치지 말고 위 명령으로 다시 지어요. 카드 옆의 한국어 한 줄은 위 패턴 표에서 읽어요. ## 라이선스 diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index f729431d0..50fce6934 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -5,7 +5,7 @@ 패턴 연구 + the Lab engine in web/src/lib/, shelf.json and the files in patterns/. Do not edit: run the study. --> @@ -122,26 +184,34 @@

패턴 연구

-

새 패턴 개예요(). 모두 패널을 세운 64×128 로 그리고, Pattern Lab 의 엔진으로 돌아가요. 노브는 Lab 의 기본 노브 값 0, 2, 0, 0.06 을 패턴의 범위 안으로 당긴 자리에서 시작해요. 마음에 드는 것은 「코드 복사」로 가져가 Lab 의 CODE 칸에 붙여 넣으면 돼요. 붙여 넣으면 노브는 그때 Lab 에 있던 값을 새 범위로 당긴 자리에 서니, 같은 화면을 보려면 카드의 슬라이더 값대로 Knobs 칸을 맞춰요.

+

새 패턴 개예요(). 모두 패널을 세운 64×128 로 그리고, Pattern Lab 의 엔진으로 돌아가요. 칸을 누르면 노브와 버튼이 있는 카드가 열려요. ☆ 로 고른 것은 이 브라우저에 남고, 「고른 목록 복사」로 PR 댓글에 붙여 넣을 수 있어요.

+

-
+
+ + + + + + +
+ +
+ +
+ 지도: 잰 값 둘로 놓은 패턴 전부
-

점 하나가 패턴 하나예요. 누르면 그 패턴으로 가요. 흰 테두리 점이 가장 새로 나온 판이에요. 점선 칸은 아직 아무 패턴도 없는 자리예요. 자리는 카드의 처음 노브 값에서 8초 동안 돌린 화면을 재서 정했고, 거의 같은 자리에 겹친 점은 조금 떼어 놓았어요.

-
- -
- - -
- -
+

점 하나가 패턴 하나예요. 누르면 그 패턴의 카드가 열려요. 흰 테두리 점이 가장 새로 나온 판이고, 금색 테두리 점이 「먼저 볼 후보」예요. 점선 칸은 아직 아무 패턴도 없는 자리예요. 자리는 카드의 처음 노브 값에서 8초 동안 돌린 화면을 재서 정했고, 거의 같은 자리에 겹친 점은 조금 떼어 놓았어요. 아래 칩은 판이나 갈래를 지도와 칸에서 숨겨요.

+
- + + + + + + + + diff --git a/incoming/pattern-study/shelf.json b/incoming/pattern-study/shelf.json new file mode 100644 index 000000000..ed0a858d6 --- /dev/null +++ b/incoming/pattern-study/shelf.json @@ -0,0 +1,424 @@ +{ + "date": "2026-10-25", + "about": "1–39판 298개를 창작자의 기본 프리셋과 같은 잣대로 재서 여섯 묶음으로 나눴어요. 프리셋은 화면이 단순하고(복잡도 가운데값 0.22) 쉬지 않고 움직이는데(움직임 0.66), 15판부터 나온 250개는 복잡도 1.3–1.6, 움직임 0.21 이에요. 나눈 뒤에 들어온 40판 열 개도 같은 잣대로 보고 넣었어요.", + "groups": [ + { + "key": "pick", + "label": "먼저 볼 후보", + "open": true, + "why": "프리셋이 서는 자리에 서는 것이에요. 큰 모양 몇 개가 쉬지 않고 움직이고, 글자나 사물이 없어서 한두 걸음 떨어져도 무엇이 움직이는지 보여요. 패널에 먼저 올려 볼 것들이에요.", + "patterns": [ + "marbling", + "constant-width", + "voronoi", + "pentomino-cover", + "moire-rings", + "kuramoto-sivashinsky", + "plait", + "domain-warp", + "heawood-torus", + "perron-tree", + "figure-eight-orbit", + "kdv-solitons", + "bokeh" + ], + "notes": { + "marbling": "검은 바탕에 원색 띠가 쉬지 않고 끌려가요. 복잡도 0.28, 움직임 1.65 로 프리셋이 서는 자리예요.", + "constant-width": "세 색의 뢸로 삼각형이 맞물려 굴러요. 0531 처럼 큰 색면 몇 개가 화면 전부예요.", + "voronoi": "색 칸이 쉬지 않고 갈라지고 합쳐져요. 움직임 2.10, 가장자리 1.00, 복잡도 0.21 로 프리셋의 잰 값과 거의 같아요.", + "pentomino-cover": "색 블록 열두 개가 판을 덮었다 풀었다 해요. 0514 처럼 굵은 블록이에요.", + "moire-rings": "고리 무늬 둘이 겹쳐 간섭 무늬를 만들어요. 0707 의 과녁처럼 또렷하고(가장자리 1.00) 단순해요(복잡도 0.18).", + "kuramoto-sivashinsky": "청록과 자홍 줄이 쉬지 않고 꿈틀대요. 0718 의 굵은 줄무늬와 가까워요.", + "plait": "굵은 세 가닥이 땋였다 풀려요. 잰 값 일곱이 프리셋과 가장 가까운 패턴이에요.", + "domain-warp": "어두운 바탕에 빛나는 결이 흘러가요. A Big Hit, 0524-2 와 같은 쪽이에요.", + "heawood-torus": "일곱 색으로 칠한 토러스가 돌아요. 색면이 커서 멀리서도 읽혀요.", + "perron-tree": "검은 바탕에 무지개 빛살이 모였다 퍼져요. 패널의 72% 가 꺼져 있어서 빛살이 또렷해요.", + "figure-eight-orbit": "빛나는 공 셋이 8자 궤도를 돌아요. 0529, 0701 처럼 선 하나와 점 몇 개가 전부예요.", + "kdv-solitons": "어두운 바탕에 빛줄기가 엇갈려 지나가요. 잰 값이 프리셋과 두 번째로 가까워요.", + "bokeh": "흐린 빛 원이 떠다녀요. 0609, 0524 처럼 번진 빛 덩어리예요." + } + }, + { + "key": "near", + "label": "하나가 모자란 추상", + "why": "추상이고 패널에서 읽히지만 하나가 모자라요. 거의 안 움직이거나, 패널을 꽉 채우거나, 데모에서 자주 본 것이에요. 칸마다 모자란 것을 적었어요.", + "patterns": [ + "bauhaus", + "fire", + "tunnel", + "aurora", + "reaction-diffusion", + "op-stripes", + "meander", + "jelly", + "ripple-tank", + "kaleidoscope", + "penrose", + "rauzy-fractal", + "hyperbolic-band", + "pinwheel-tiling", + "gibbs-phenomenon", + "zeta-loops", + "karman-street", + "dyson-gas", + "steiner-chain", + "lava-lamp", + "indras-pearls", + "j-invariant", + "bath-fizz", + "soap-film", + "crackle-polish", + "powder-burst", + "gold-leaf", + "glitter-fall", + "borromean-rings", + "flock", + "euclid-pulse", + "sparks", + "jacobs-ladder", + "searchlights", + "star-trails", + "curve-shortening", + "hofstadter-butterfly", + "ford-circles", + "gosset-polytope", + "cloud-chamber", + "kelvin-wake", + "hopf-fibration", + "lava-lake", + "prince-ruperts-cube", + "brownian-frontier", + "zeldovich-pancakes", + "continuum-random-tree", + "hydrogen-orbital", + "lotion-stitch", + "perfume-tears" + ], + "notes": { + "bauhaus": "모양은 프리셋과 가장 닮았는데 거의 안 움직여요(움직임 0.04).", + "fire": "패널에서 잘 읽히지만 불꽃은 데모의 단골이에요.", + "tunnel": "굵고 빨라요. 다만 체크무늬 터널은 데모의 단골이에요.", + "aurora": "예쁘지만 거의 제자리예요(움직임 0.04).", + "reaction-diffusion": "무늬는 굵은데 아주 느려요(움직임 0.06).", + "op-stripes": "굵은 줄무늬가 거의 안 움직여요(움직임 0.06).", + "meander": "굵은 무지개 관이 좋은데 느려요(움직임 0.11).", + "jelly": "덩어리 넷이 바닥에 쌓여 있는 시간이 길어요.", + "ripple-tank": "고리가 LED 한 알 굵기라 멀리서는 가늘어요.", + "kaleidoscope": "색 조각이 잘아서 무늬가 자잘해요.", + "penrose": "타일 한 칸이 작아서 잘게 보여요(촘촘함 0.26).", + "rauzy-fractal": "색 조각이 패널을 빈틈없이 채워요(채움 1.00).", + "hyperbolic-band": "패널을 꽉 채우고 결이 복잡해요(복잡도 2.13).", + "pinwheel-tiling": "삼각형이 잘고 패널을 다 채워요.", + "gibbs-phenomenon": "아치가 겹친 결이 패널을 채워요(채움 0.87).", + "zeta-loops": "선 하나가 어두운 판에 작아요(채움 0.17).", + "karman-street": "소용돌이가 작고 어두워요.", + "dyson-gas": "선이 LED 한 알 굵기예요.", + "steiner-chain": "원 안의 결이 잘아요.", + "lava-lamp": "느리고 패널을 꽉 채워요(움직임 0.11, 채움 1.00).", + "indras-pearls": "패널을 꽉 채우고 고리가 잘아요.", + "j-invariant": "가장 복잡한 화면 가운데 하나예요(복잡도 2.69).", + "bath-fizz": "색은 굵고 진한데 패널을 꽉 채워요(채움 1.00).", + "soap-film": "무지개 막이 화려한데 결이 복잡해요(복잡도 2.34).", + "crackle-polish": "갈라진 선이 좋은데 거의 안 움직여요(움직임 0.06).", + "powder-burst": "검은 바탕의 자홍 구름이 좋은데 모양이 흐릿해요(가장자리 0.05).", + "gold-leaf": "금박이 좋은데 조각이 작고 느려요.", + "glitter-fall": "반짝이가 점 하나 크기예요.", + "borromean-rings": "고리 셋이 작고 느려요.", + "flock": "새 떼가 점 크기라 멀리서 안 보여요.", + "euclid-pulse": "리듬은 좋은데 그림이 작고 어두워요.", + "sparks": "불꽃이 작고 패널이 거의 비어요(채움 0.16).", + "jacobs-ladder": "아크는 좋은데 전극이 사물로 같이 그려져요.", + "searchlights": "빛기둥은 좋은데 도시 실루엣이 장면을 만들어요.", + "star-trails": "거의 멈춘 사진이에요(움직임 0.02).", + "curve-shortening": "도형 하나가 줄어들다 사라져서 빈 화면이 길어요.", + "hofstadter-butterfly": "나비 모양은 좋은데 결이 도해처럼 잘아요.", + "ford-circles": "원이 겹겹이라 패널이 꽉 차요(채움 0.80).", + "gosset-polytope": "점 무늬가 작고 거의 안 움직여요(움직임 0.10).", + "cloud-chamber": "흰 줄 몇 개뿐이라 색이 없어요(색 0.05).", + "kelvin-wake": "패널을 한 색으로 채워요(채움 1.00).", + "hopf-fibration": "가는 선이 엉켜 덩어리로 보여요.", + "lava-lake": "갈라진 용암 결이 잘고 느려요.", + "prince-ruperts-cube": "큰 색면이 좋은데 입체 퍼즐이라 무엇을 보는지 설명이 있어야 해요.", + "brownian-frontier": "검은 바탕의 덩어리는 좋은데 테두리 결이 잘아요.", + "zeldovich-pancakes": "빛나는 그물이 좋은데 아주 느리고 점점 흐려져요(움직임 0.05).", + "continuum-random-tree": "빛나는 나뭇가지로 읽혀서 나무 그림이 돼요.", + "hydrogen-orbital": "궤도가 LED 한 알 크기의 점 구름이라 면으로 안 보여요.", + "lotion-stitch": "굵은 코일 하나가 좋은데 모양이 늘 같아요.", + "perfume-tears": "눈물 줄이 가늘고 바탕이 패널을 채워요(채움 0.79)." + } + }, + { + "key": "scene", + "label": "사물·장면 그림", + "why": "사물이나 장소를 그린 픽셀 그림이에요. 무엇인지는 바로 보이지만 한 번 보고 나면 더 볼 것이 없어요. 64칸 폭에 물건을 그리느라 결이 잘고, 바탕까지 칠해서 패널을 꽉 채워요. 15–27판이 거의 다 여기 있고, 프리셋 48개에는 이런 그림이 없어요.", + "patterns": [ + "center-pivot", + "climbing-wall", + "fluoroscope", + "funicular", + "glassblower", + "horse-in-motion", + "mitosis", + "sled-dogs", + "dark-field", + "geyser", + "iceberg", + "implosion", + "lichtenberg", + "noborigama", + "origami", + "pole-vault", + "rice-planting", + "shadow-play", + "eruption", + "excavation", + "ferris-wheel", + "kintsugi", + "marionette", + "rice-terraces", + "skydiver", + "snow-monkeys", + "breath", + "emergence", + "flying-trapeze", + "pipe-organ", + "potters-wheel", + "shoot-em-up", + "thermal", + "tower-crane", + "waggle-dance", + "axon", + "bait-ball", + "elephant-toothpaste", + "final-approach", + "giraffe", + "hanging-scroll", + "high-dive", + "proscenium", + "sequins", + "skein", + "bowling-lane", + "circulation", + "flame-test", + "flipdot", + "galilean-moons", + "liftoff", + "night-flight", + "pin-art", + "senbazuru", + "weaver", + "bread-mould", + "chemical-garden", + "fiddlehead", + "lap-pool", + "nixie", + "pancakes", + "pop-up-book", + "slinky", + "woodpecker", + "agate", + "change-ringing", + "chromatography", + "high-striker", + "kettle", + "kingfisher", + "moth-lamp", + "veiled-lady", + "cyanotype", + "dripstone", + "dust-motes", + "foxfire", + "helter-skelter", + "needlepoint", + "paternoster", + "popcorn", + "rabbit-ears", + "smalti", + "bismuth", + "drop-tower", + "longcase-clock", + "peg-light", + "pint", + "server-rack", + "stained-glass", + "turntables", + "typewriter", + "zen-garden", + "ant-farm", + "brushstrokes", + "circuit-board", + "crossroads", + "marble-run", + "one-bit", + "shoreline", + "train-window", + "abyss", + "domino-run", + "drying-mud", + "fireflies", + "harp", + "lunation", + "neon-alley", + "analemma", + "cam-wave", + "datamosh", + "incense", + "ivy", + "metro-map", + "player-piano", + "waterfall", + "balloon-box", + "tetromino-well", + "gear-train", + "jacquard", + "aquarium", + "climber", + "cloth", + "day-cycle", + "flip-clock", + "hourglass", + "rainstorm", + "sprout" + ] + }, + { + "key": "diagram", + "label": "수학·데이터 도해", + "why": "수학이나 데이터를 설명하는 그림이에요. 숫자와 눈금, 한 LED 굵기의 선이 많아서 패널에서는 뭉개지고, 무엇을 보는지 알려면 설명을 읽어야 해요.", + "patterns": [ + "borsuk-ulam", + "brachistochrone", + "explicit-formula", + "isospectral-drums", + "kingman-coalescent", + "anderson-localization", + "bernoulli-convolutions", + "infinity-series", + "polya-urn", + "sperner-lemma", + "antoine-necklace", + "fortune-sweep", + "johnson-mehl", + "catastrophe-machine", + "elliptic-chords", + "schwarz-lantern", + "joukowsky-airfoil", + "kolakoski", + "renyi-parking", + "lakes-of-wada", + "moving-sofa", + "thomson-problem", + "bertrand-paradox", + "dandelin-spheres", + "goldbach-comet", + "penrose-room", + "sorting-network", + "arnold-tongues", + "gilbreath-table", + "hammersley-lines", + "invasion-percolation", + "boys-surface", + "gaussian-moat", + "littlewood-roots", + "outer-billiards", + "three-gaps", + "seven-shuffles", + "hairy-ball", + "loewner-trace", + "look-and-say", + "stadium-billiard", + "wythoff-nim", + "population-pyramid", + "pendulum-wave", + "contour", + "galton", + "sorting" + ] + }, + { + "key": "cosmetic", + "label": "옅은 화장품 질감", + "why": "화장품 팝업용 질감이에요. 연분홍과 흰 바탕이 패널을 다 채워서(채움 가운데값 1.00) LED 에서는 밝은 판 하나로 보여요. 광택이나 눌린 자국 같은 표면 결은 64×128 에서 남지 않아요. 진한 색이 굵은 여섯은 위 묶음에 있어요.", + "patterns": [ + "jelly-layers", + "blotting-paper", + "serum-drip", + "couette-shimmer", + "milk-ring", + "photochromic", + "serum-fingers", + "embossed-glow", + "hair-oil", + "taffy-cream", + "blooming-gel", + "lotion-ribbon", + "shimmer-oil", + "whipped-soap", + "gilded-cream", + "sheet-mask", + "toner-pad", + "cat-eye", + "hair-gloss", + "mood-tint", + "velvet-nap", + "cleansing-balm", + "essence-drop", + "peel-off", + "chrome-powder", + "clay-mask", + "cushion-mesh", + "sugar-scrub", + "foam-lather", + "lip-gloss", + "setting-mist", + "sponge-blend", + "bi-phase", + "pearl-sheen", + "satin-fold", + "swatch-row", + "cream-swirl", + "loose-powder", + "serum-gel" + ] + }, + { + "key": "grain", + "label": "잔 결과 흔한 데모", + "why": "LED 한두 알 크기의 결이 화면을 덮거나, 생성 미술에서 너무 자주 본 것이에요. 가까이서는 촘촘하지만 한 걸음만 물러나도 잡음처럼 보여요.", + "patterns": [ + "defect-turbulence", + "lamellar-cream", + "hexatic-phase", + "chimera-state", + "kari-culik", + "scheidegger-rivers", + "talbot-carpet", + "cat-map", + "ballistic-deposition", + "percolation", + "aztec-diamond", + "sandpile", + "millefiori", + "sunglint", + "frost", + "spectrogram", + "chladni", + "guilloche", + "wireworld", + "warp", + "dragon", + "halftone", + "mandelbrot", + "maze", + "big-type", + "glyph-rain", + "harmonograph", + "life", + "pulse-bars", + "rule-scroll", + "test-card", + "truchet" + ] + } + ] +} diff --git a/incoming/pattern-study/shots/page-cards.png b/incoming/pattern-study/shots/page-cards.png index da03948d4..adf4b5e05 100644 Binary files a/incoming/pattern-study/shots/page-cards.png and b/incoming/pattern-study/shots/page-cards.png differ diff --git a/incoming/pattern-study/shots/page-map.png b/incoming/pattern-study/shots/page-map.png index 687e93bb1..b8b663a0b 100644 Binary files a/incoming/pattern-study/shots/page-map.png and b/incoming/pattern-study/shots/page-map.png differ diff --git a/incoming/pattern-study/shots/page-phone.png b/incoming/pattern-study/shots/page-phone.png index 7c728a2df..4011b3825 100644 Binary files a/incoming/pattern-study/shots/page-phone.png and b/incoming/pattern-study/shots/page-phone.png differ diff --git a/incoming/pattern-study/shots/shelf-compare.png b/incoming/pattern-study/shots/shelf-compare.png new file mode 100644 index 000000000..e4762b988 Binary files /dev/null and b/incoming/pattern-study/shots/shelf-compare.png differ diff --git a/incoming/pattern-study/tools/gallery.ts b/incoming/pattern-study/tools/gallery.ts index 253fc47bf..4f2aa864b 100644 --- a/incoming/pattern-study/tools/gallery.ts +++ b/incoming/pattern-study/tools/gallery.ts @@ -1,40 +1,57 @@ /** - * The page script of index.html (page.ts bundles it). A card is one pattern - * running in its own LabEngine, the Lab preview's engine, with the layer the - * Lab builds from its code and the study's start knobs. Cards off screen - * don't run. - * The map at the top places every pattern by two of the measures study.ts - * took; a dot takes you to its card. The newest round's dots wear a white - * ring, whichever round that is, and the chips under the map hide a round or - * a family. + * The page script of index.html (page.ts bundles it). The patterns sit in the + * groups shelf.json sorts them into, one folded section each; a group's tiles + * are built the first time it opens. A tile is one pattern running in its own + * LabEngine, the Lab preview's engine, with the layer the Lab builds from its + * code and the study's start knobs. Only tiles on screen run: a tile gets an + * engine when it scrolls into view and drops it when it leaves, and all the + * running tiles share a few milliseconds a frame, so a screen full of heavy + * patterns plays slower instead of locking the page. + * + * A tile opens a card with the knobs, the buttons and the code. The map, + * folded at the bottom, places every pattern by two of the measures study.ts + * took; a dot opens its card. The newest round's dots wear a white ring, and + * the chips under the map hide a round or a family. + * + * A star marks a pattern as picked. The picks stay in this browser, and the + * toolbar copies them as a list to paste where the next round will read it. */ -import { LabEngine } from "../../../web/src/lib/lab/engine"; +import { LabEngine, type EngineFrame } from "../../../web/src/lib/lab/engine"; import type { CodeLayer } from "../../../web/src/lib/lab/types"; +import { stripRampAnnotation } from "../../../web/src/lib/pattern/ramp"; import { AXES, GRID, scale } from "./map"; -type Knob = { name: string; min: number; max: number; about: string }; +type Knob = { name: string; min: number; max: number }; type Entry = { slug: string; title: string; family: string; round: number; note: string; - idea: string; - buttons: string; - code: string; - layer: CodeLayer; + why: string; + block: number; + ramped: boolean; + layer: Omit; matrix: { width: number; height: number }; knobs: Knob[]; start: number[]; measures: Record; knobResponse: number[]; }; -type Data = { patterns: Entry[]; families: Record }; +type Group = { key: string; label: string; why: string; open: boolean; patterns: string[] }; +type Data = { + patterns: Entry[]; + families: Record; + shelf: { date: string; about: string; groups: Group[] }; +}; const data = JSON.parse(document.getElementById("study-data")!.textContent!) as Data; +const bySlug = new Map(data.patterns.map((p) => [p.slug, p])); +const groupOf = new Map(); +for (const group of data.shelf.groups) for (const slug of group.patterns) groupOf.set(slug, group); -// The newest round stands out on the map, its chip and its cards. With one +// The newest round stands out on the map, its chip and its tiles. With one // round there is nothing newer to tell apart. const rounds = [...new Set(data.patterns.map((p) => p.round))].sort((a, b) => a - b); const newest = rounds[rounds.length - 1] ?? 0; @@ -57,7 +74,246 @@ const remember = (key: string, value?: string) => { return null; }; -// ── LED look ──────────────────────────────────────────────────────────────── +// ── Code ──────────────────────────────────────────────────────────────────── +// Each pattern's code sits in one of the JSON blocks page.ts writes. The first +// block is read before this script runs; the others may still be arriving. +const blocks = new Map>(); +const loaded = () => document.readyState !== "loading"; + +function codeOf(entry: Entry): string | null { + if (!blocks.has(entry.block)) { + const node = document.getElementById(`code-${entry.block}`); + if (!node || (entry.block > 0 && !loaded())) return null; + blocks.set(entry.block, JSON.parse(node.textContent!) as Record); + } + return blocks.get(entry.block)![entry.slug] ?? null; +} + +/** The sentence after the knob's name on each "// Knob N:" line: "Hue. Turns the pair..." → "Turns the pair...". */ +function knobNotes(code: string): string[] { + return [1, 2, 3, 4].map((n) => { + const line = code.match(new RegExp(`^//[ \\t]*Knob ${n}:[ \\t]*(.+)$`, "m"))?.[1].trim() ?? ""; + return line.replace(/^[^.]{1,20}\.\s*/, ""); + }); +} + +function layerOf(entry: Entry): CodeLayer | null { + const code = codeOf(entry); + if (code === null) return null; + // The Lab runs the code without its @ramp line, as page.ts checked. + return { ...entry.layer, code: entry.ramped ? stripRampAnnotation(code) : code } as CodeLayer; +} + +// ── Running ───────────────────────────────────────────────────────────────── +// Every runner steps on the Lab's 30 fps clock, so a pattern runs at the same +// speed here whatever the screen's refresh rate, until the page runs out of +// time in a frame. +const STEP = 1 / 30; +const BUDGET_MS = 10; + +type Runner = { + entry: Entry; + engine: LabEngine | null; + layer: CodeLayer | null; + knobs: number[]; + time: number; + owed: number; + show: (frame: EngineFrame) => void; + error: (message: string) => void; +}; + +const active = new Set(); +let running = true; +let cursor = 0; +/** The open card's runner. */ +let focus: Runner | null = null; + +function runner(entry: Entry, show: Runner["show"], error: Runner["error"]): Runner { + return { entry, engine: null, layer: null, knobs: [...entry.start], time: 0, owed: STEP, show, error }; +} + +/** One step of one runner. False when its code has not arrived yet. */ +function step(r: Runner): boolean { + if (!r.layer) r.layer = layerOf(r.entry); + if (!r.layer) return false; + if (!r.engine) r.engine = new LabEngine(); + r.time += STEP; + const frame = r.engine.render( + { + matrix: r.entry.matrix, + layers: [r.layer], + activeLayerId: r.layer.id, + knobs: r.knobs, + ranges: r.entry.knobs.map((k): [number, number] => [k.min, k.max]), + }, + STEP, + r.time, + ); + r.error(r.engine.errors.get(r.layer.id) ?? ""); + r.show(frame); + return true; +} + +let last = performance.now(); +function loop(now: number) { + const dt = Math.min((now - last) / 1000, 0.25); + last = now; + if (running) { + const started = performance.now(); + // An open card covers the tiles, so it runs alone. + const list = focus ? [focus] : [...active]; + for (const r of list) r.owed = Math.min(r.owed + dt, STEP * 2); + for (let k = 0; k < list.length; k++) { + const r = list[(cursor + k) % list.length]; + if (r.owed < STEP) continue; + if (step(r)) r.owed -= STEP; + if (performance.now() - started > BUDGET_MS) { + cursor = (cursor + k + 1) % list.length; + break; + } + } + } + requestAnimationFrame(loop); +} + +// ── Tiles ─────────────────────────────────────────────────────────────────── +type Tile = { entry: Entry; root: HTMLElement; runner: Runner }; +const tiles = new Map(); + +const seen = new IntersectionObserver( + (entries) => { + for (const s of entries) { + const tile = tiles.get((s.target as HTMLElement).dataset.slug!); + if (!tile) continue; + if (s.isIntersecting) active.add(tile.runner); + else { + // Off screen, a tile keeps its last frame and lets its engine go. + active.delete(tile.runner); + tile.runner.engine = null; + tile.runner.time = 0; + } + } + }, + { rootMargin: "160px" }, +); + +/** Clear pixels are dark LEDs. */ +function toLeds(frame: EngineFrame, out: ImageData) { + const src = frame.data; + const dst = out.data; + for (let p = 0; p < src.length; p += 4) { + const a = src[p + 3] / 255; + dst[p] = src[p] * a; + dst[p + 1] = src[p + 1] * a; + dst[p + 2] = src[p + 2] * a; + dst[p + 3] = 255; + } +} + +function buildTile(entry: Entry): Tile { + const family = data.families[entry.family]; + const root = el("article", `tile ${roundClass(entry.round)}`); + root.dataset.slug = entry.slug; + root.style.setProperty("--family", family?.color ?? "#ccc"); + root.title = entry.note; + + const open = el("button", "tile-open"); + open.setAttribute("aria-label", `${entry.title} 열기`); + const canvas = el("canvas", "tile-panel"); + canvas.width = entry.matrix.width; + canvas.height = entry.matrix.height; + canvas.style.aspectRatio = `${entry.matrix.width} / ${entry.matrix.height}`; + const ctx = canvas.getContext("2d")!; + const image = ctx.createImageData(entry.matrix.width, entry.matrix.height); + const name = el("span", "tile-name", entry.title); + const round = el("span", "tile-round", `${entry.round}판`); + open.append(canvas, name, round); + open.addEventListener("click", () => openCard(entry.slug)); + + const star = starButton(entry.slug); + root.append(open, star); + if (entry.why) root.append(el("p", "tile-why", entry.why)); + + const tile: Tile = { + entry, + root, + runner: runner( + entry, + (frame) => { + toLeds(frame, image); + ctx.putImageData(image, 0, 0); + }, + (message) => root.classList.toggle("broken", Boolean(message)), + ), + }; + tiles.set(entry.slug, tile); + root.hidden = isHidden(entry); + seen.observe(root); + return tile; +} + +// ── Picks ─────────────────────────────────────────────────────────────────── +const picked = new Set( + (() => { + try { + const list = JSON.parse(remember("picked") ?? "[]") as unknown; + return Array.isArray(list) ? list.filter((s): s is string => typeof s === "string" && bySlug.has(s)) : []; + } catch { + return []; + } + })(), +); + +function starButton(slug: string) { + const star = el("button", "star"); + star.dataset.star = slug; + star.addEventListener("click", (event) => { + event.stopPropagation(); + if (picked.has(slug)) picked.delete(slug); + else picked.add(slug); + remember("picked", JSON.stringify([...picked])); + showPicks(); + }); + paintStar(star, picked.has(slug)); + return star; +} + +function paintStar(star: HTMLElement, on: boolean) { + star.textContent = on ? "★" : "☆"; + star.classList.toggle("on", on); + star.title = on ? "고른 것에서 빼기" : "고르기"; +} + +function pickList() { + const chosen = data.patterns.filter((p) => picked.has(p.slug)); + return `패턴 연구에서 고른 것 ${chosen.length}개: ${chosen.map((p) => `${p.title} (${p.slug})`).join(", ")}`; +} + +function showPicks() { + for (const star of document.querySelectorAll("[data-star]")) paintStar(star, picked.has(star.dataset.star!)); + document.getElementById("picked-count")!.textContent = `고른 것 ${picked.size}개`; + (document.getElementById("picked-copy") as HTMLButtonElement).disabled = picked.size === 0; + (document.getElementById("picked-clear") as HTMLButtonElement).disabled = picked.size === 0; +} + +async function copyText(text: string, button: HTMLButtonElement, done: string) { + const label = button.textContent; + try { + await navigator.clipboard.writeText(text); + } catch { + const area = el("textarea"); + area.value = text; + document.body.append(area); + area.select(); + document.execCommand("copy"); + area.remove(); + } + button.textContent = done; + setTimeout(() => (button.textContent = label), 1400); +} + +// ── Card ──────────────────────────────────────────────────────────────────── +// The open card draws the panel larger, as LED dots or as plain pixels. const SCALE = 4; let ledLook = remember("look") !== "pixel"; @@ -84,60 +340,52 @@ function masksFor(width: number, height: number) { return masks.get(key)!; } -// ── Cards ─────────────────────────────────────────────────────────────────── -type Card = { - entry: Entry; - root: HTMLElement; - engine: LabEngine; - knobs: number[]; - time: number; - visible: boolean; - small: HTMLCanvasElement; - smallCtx: CanvasRenderingContext2D; - image: ImageData; - view: HTMLCanvasElement; - viewCtx: CanvasRenderingContext2D; - sliders: HTMLInputElement[]; - readouts: HTMLElement[]; - error: HTMLElement; -}; - -const cards: Card[] = []; -let running = true; - -function setKnob(card: Card, index: number, value: number) { - card.knobs[index] = value; - card.sliders[index].value = String(value); - card.readouts[index].textContent = fmt(value); -} +const dialog = document.getElementById("card") as HTMLDialogElement; -function buildCard(entry: Entry): Card { +function openCard(slug: string) { + const entry = bySlug.get(slug); + if (!entry) return; + dropCard(); const family = data.families[entry.family]; + const group = groupOf.get(slug); + const { width, height } = entry.matrix; + const root = el("article", "card"); - root.id = entry.slug; root.style.setProperty("--family", family?.color ?? "#ccc"); + // A panel standing up sits beside its controls instead of above them. + root.classList.toggle("portrait", height > width); const head = el("header", "card-head"); const title = el("h3", "", entry.title); const tags = el("span", "tags"); tags.append(el("span", "chip", family?.label ?? entry.family), el("span", `round ${roundClass(entry.round)}`, `${entry.round}판`)); - head.append(title, tags); + const close = el("button", "close", "닫기"); + close.addEventListener("click", () => closeCard()); + head.append(title, tags, starButton(slug), close); - const { width, height } = entry.matrix; - // A panel standing up sits beside its controls instead of above them. - root.classList.toggle("portrait", height > width); const view = el("canvas", "panel"); view.width = width * SCALE; view.height = height * SCALE; view.style.aspectRatio = `${width} / ${height}`; + const viewCtx = view.getContext("2d")!; const small = document.createElement("canvas"); small.width = width; small.height = height; const smallCtx = small.getContext("2d")!; + const image = smallCtx.createImageData(width, height); - const note = el("p", "note", entry.note || entry.idea); + const placed = el("p", "placed"); + if (group) { + placed.append(el("strong", "", group.label)); + placed.append(document.createTextNode(` · ${entry.why || group.why}`)); + } + const note = el("p", "note", entry.note); const error = el("p", "error"); + // A card opened from the map before the later code blocks have arrived + // shows no knob notes; its panel starts once the code is there. + const code = codeOf(entry) ?? ""; + const about = knobNotes(code); const knobBox = el("div", "knobs"); const sliders: HTMLInputElement[] = []; const readouts: HTMLElement[] = []; @@ -151,15 +399,39 @@ function buildCard(entry: Entry): Card { slider.step = String((knob.max - knob.min) / 480); slider.value = String(entry.start[i]); const readout = el("span", "knob-value", fmt(entry.start[i])); - row.title = knob.about; - row.append(name, slider, readout); - const about = el("span", "knob-about", knob.about); - row.append(about); + row.title = about[i]; + row.append(name, slider, readout, el("span", "knob-about", about[i])); knobBox.append(row); sliders.push(slider); readouts.push(readout); }); + const r = runner( + entry, + (frame) => { + toLeds(frame, image); + smallCtx.putImageData(image, 0, 0); + viewCtx.globalCompositeOperation = "source-over"; + viewCtx.imageSmoothingEnabled = false; + viewCtx.drawImage(small, 0, 0, view.width, view.height); + if (ledLook) { + const mask = masksFor(width, height); + viewCtx.globalCompositeOperation = "multiply"; + viewCtx.drawImage(mask.lit, 0, 0); + viewCtx.globalCompositeOperation = "lighten"; + viewCtx.drawImage(mask.dim, 0, 0); + viewCtx.globalCompositeOperation = "source-over"; + } + }, + (message) => (error.textContent = message), + ); + const setKnob = (index: number, value: number) => { + r.knobs[index] = value; + sliders[index].value = String(value); + readouts[index].textContent = fmt(value); + }; + sliders.forEach((slider, i) => slider.addEventListener("input", () => setKnob(i, Number(slider.value)))); + const tools = el("div", "tools"); const buttons = el("div", "buttons"); for (let i = 0; i < 4; i++) { @@ -168,131 +440,97 @@ function buildCard(entry: Entry): Card { buttons.append(b); b.addEventListener("pointerdown", (event) => { event.preventDefault(); - card.engine.pressButton(i); + r.engine?.pressButton(i); }); - const up = () => card.engine.releaseButton(i); + const up = () => r.engine?.releaseButton(i); b.addEventListener("pointerup", up); b.addEventListener("pointerleave", up); } const copy = el("button", "action", "코드 복사"); const reset = el("button", "action", "처음 값"); const restart = el("button", "action", "다시 시작"); - tools.append(buttons, copy, reset, restart); + const look = el("button", "action"); + const showLook = () => (look.textContent = ledLook ? "LED 점" : "픽셀"); + showLook(); + tools.append(buttons, copy, reset, restart, look); + reset.addEventListener("click", () => entry.start.forEach((v, i) => setKnob(i, v))); + restart.addEventListener("click", () => { + r.engine = null; + r.time = 0; + }); + copy.addEventListener("click", () => { + const code = codeOf(entry); + if (code !== null) void copyText(code, copy, "복사했어요"); + }); + look.addEventListener("click", () => { + ledLook = !ledLook; + remember("look", ledLook ? "led" : "pixel"); + showLook(); + }); - const pressNote = el("p", "press-note", entry.buttons ? `버튼: ${entry.buttons}` : ""); + const buttonsLine = code.match(/^\/\/[ \t]*Buttons:[ \t]*(.+)$/m)?.[1].trim() ?? ""; + const pressNote = el("p", "press-note", buttonsLine ? `버튼: ${buttonsLine}` : ""); const measured = el("details", "measured"); measured.append(el("summary", "", "잰 값")); const table = el("dl"); - for (const [key, axis] of Object.entries(AXES)) { - table.append(el("dt", "", axis.label), el("dd", "", fmt(entry.measures[key] ?? 0))); - } + for (const [key, axis] of Object.entries(AXES)) table.append(el("dt", "", axis.label), el("dd", "", fmt(entry.measures[key] ?? 0))); table.append(el("dt", "", "노브 반응"), el("dd", "", entry.knobResponse.map(fmt).join(" · "))); measured.append(table); + const pasteNote = el( + "p", + "paste-note", + "「코드 복사」로 가져가 Lab 의 CODE 칸에 붙여 넣으면 돼요. 노브는 Lab 의 기본 노브 값 0, 2, 0, 0.06 을 이 패턴의 범위 안으로 당긴 자리에서 시작해요. 붙여 넣으면 노브는 그때 Lab 에 있던 값을 새 범위로 당긴 자리에 서니, 같은 화면을 보려면 슬라이더 값대로 Knobs 칸을 맞춰요.", + ); + const side = el("div", "card-side"); - side.append(note, error, knobBox, tools, pressNote, measured); + side.append(placed, note, error, knobBox, tools, pressNote, measured, pasteNote); const body = el("div", "card-body"); body.append(view, side); root.append(head, body); - const card: Card = { - entry, - root, - engine: new LabEngine(), - knobs: [...entry.start], - time: 0, - visible: false, - small, - smallCtx, - image: smallCtx.createImageData(width, height), - view, - viewCtx: view.getContext("2d")!, - sliders, - readouts, - error, - }; - - sliders.forEach((slider, i) => slider.addEventListener("input", () => setKnob(card, i, Number(slider.value)))); - reset.addEventListener("click", () => entry.start.forEach((v, i) => setKnob(card, i, v))); - restart.addEventListener("click", () => { - card.engine = new LabEngine(); - card.time = 0; - }); - copy.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(entry.code); - } catch { - const area = el("textarea"); - area.value = entry.code; - document.body.append(area); - area.select(); - document.execCommand("copy"); - area.remove(); - } - copy.textContent = "복사했어요"; - setTimeout(() => (copy.textContent = "코드 복사"), 1400); - }); + dialog.replaceChildren(root); + focus = r; + if (!dialog.open) dialog.showModal(); + history.replaceState(null, "", `#${slug}`); +} - return card; +function dropCard() { + focus = null; } -function paint(card: Card) { - const { width, height } = card.entry.matrix; - const frame = card.engine.render( - { - matrix: card.entry.matrix, - layers: [card.entry.layer], - activeLayerId: card.entry.layer.id, - knobs: card.knobs, - ranges: card.entry.knobs.map((k): [number, number] => [k.min, k.max]), - }, - 1 / 30, - card.time, - ); - const error = card.engine.errors.get(card.entry.layer.id) ?? ""; - card.error.textContent = error; - const out = card.image.data; - for (let p = 0; p < width * height; p++) { - // Clear pixels are dark LEDs. - const a = frame.data[p * 4 + 3] / 255; - out[p * 4] = frame.data[p * 4] * a; - out[p * 4 + 1] = frame.data[p * 4 + 1] * a; - out[p * 4 + 2] = frame.data[p * 4 + 2] * a; - out[p * 4 + 3] = 255; - } - card.smallCtx.putImageData(card.image, 0, 0); - const ctx = card.viewCtx; - ctx.globalCompositeOperation = "source-over"; - ctx.imageSmoothingEnabled = false; - ctx.drawImage(card.small, 0, 0, card.view.width, card.view.height); - if (ledLook) { - const mask = masksFor(width, height); - ctx.globalCompositeOperation = "multiply"; - ctx.drawImage(mask.lit, 0, 0); - ctx.globalCompositeOperation = "lighten"; - ctx.drawImage(mask.dim, 0, 0); - ctx.globalCompositeOperation = "source-over"; - } +function closeCard() { + dropCard(); + if (dialog.open) dialog.close(); + if (location.hash) history.replaceState(null, "", location.pathname + location.search); } -// Every card steps on the Lab's 30 fps clock, so a pattern runs at the same -// speed here whatever the screen's refresh rate. -const STEP = 1 / 30; -let last = performance.now(); -let owed = 0; -function loop(now: number) { - owed = Math.min(owed + (now - last) / 1000, STEP * 3); - last = now; - if (running && owed >= STEP) { - owed -= STEP; - for (const card of cards) { - if (!card.visible) continue; - card.time += STEP; - paint(card); +// ── Groups ────────────────────────────────────────────────────────────────── +function buildGroups() { + const box = document.getElementById("groups")!; + for (const group of data.shelf.groups) { + const section = el("details", `group g-${group.key}`); + section.id = `group-${group.key}`; + const summary = el("summary"); + const label = el("span", "group-label", group.label); + const count = el("span", "group-count", `${group.patterns.length}개`); + summary.append(label, count, el("span", "group-why", group.why)); + const grid = el("div", "tiles"); + section.append(summary, grid); + let built = false; + const build = () => { + if (built) return; + built = true; + for (const slug of group.patterns) grid.append(buildTile(bySlug.get(slug)!).root); + }; + section.addEventListener("toggle", () => section.open && build()); + if (group.open) { + section.open = true; + build(); } + box.append(section); } - requestAnimationFrame(loop); } // ── Map ───────────────────────────────────────────────────────────────────── @@ -300,11 +538,12 @@ let axisX = remember("x") ?? "edges"; let axisY = remember("y") ?? "motion"; if (!AXES[axisX]) axisX = "edges"; if (!AXES[axisY]) axisY = "motion"; -let hiddenFamilies = new Set(); -let hiddenRounds = new Set(); +const hiddenFamilies = new Set(); +const hiddenRounds = new Set(); const isHidden = (p: Entry) => hiddenFamilies.has(p.family) || hiddenRounds.has(p.round); const allMeasures = data.patterns.map((p) => p.measures); +const mapSection = document.getElementById("map-section") as HTMLDetailsElement; type Spot = { x: number; y: number; anchor: "start" | "end" }; @@ -342,6 +581,7 @@ function labelPlaces( } function drawMap() { + if (!mapSection.open) return; const box = document.getElementById("map")!; const W = box.clientWidth; const H = Math.round(Math.min(Math.max(W * 0.62, 300), 560)); @@ -423,14 +663,14 @@ function drawMap() { const color = data.families[p.family]?.color ?? "#ccc"; const g = document.createElementNS(NS, "a"); g.setAttribute("href", `#${p.slug}`); - g.setAttribute("class", `dot ${roundClass(p.round)}`); + g.setAttribute("class", `dot ${roundClass(p.round)}${groupOf.get(p.slug)?.key === "pick" ? " pick" : ""}`); const circle = document.createElementNS(NS, "circle"); circle.setAttribute("cx", String(x)); circle.setAttribute("cy", String(y)); circle.setAttribute("r", "7"); circle.setAttribute("fill", color); const tip = document.createElementNS(NS, "title"); - tip.textContent = `${p.title} · ${data.families[p.family]?.label ?? p.family} · ${p.round}판`; + tip.textContent = `${p.title} · ${data.families[p.family]?.label ?? p.family} · ${p.round}판 · ${groupOf.get(p.slug)?.label ?? ""}`; circle.append(tip); g.append(circle); const text = document.createElementNS(NS, "text"); @@ -442,21 +682,13 @@ function drawMap() { g.append(text); g.addEventListener("click", (event) => { event.preventDefault(); - focusCard(p.slug); + openCard(p.slug); }); svg.append(g); } box.replaceChildren(svg); } -function focusCard(slug: string) { - const card = cards.find((c) => c.entry.slug === slug); - if (!card) return; - for (const c of cards) c.root.classList.toggle("focus", c === card); - card.root.scrollIntoView({ behavior: "smooth", block: "center" }); - history.replaceState(null, "", `#${slug}`); -} - function axisSelect(id: string, current: string, set: (v: string) => void) { const select = document.getElementById(id) as HTMLSelectElement; for (const [key, axis] of Object.entries(AXES)) { @@ -474,7 +706,7 @@ function axisSelect(id: string, current: string, set: (v: string) => void) { function legend() { const box = document.getElementById("legend")!; const refresh = () => { - for (const card of cards) card.root.hidden = isHidden(card.entry); + for (const tile of tiles.values()) tile.root.hidden = isHidden(tile.entry); drawMap(); }; for (const round of rounds) { @@ -505,31 +737,13 @@ function legend() { // ── Start ─────────────────────────────────────────────────────────────────── function start() { document.getElementById("count")!.textContent = String(data.patterns.length); + document.getElementById("rounds")!.textContent = rounds.length > 1 ? `${rounds[0]}–${newest}판` : `${newest}판`; document.getElementById("newest")!.textContent = String(newest); - document.getElementById("rounds")!.textContent = rounds - .map((round) => `${round}판 ${data.patterns.filter((p) => p.round === round).length}개`) - .join(", "); - const grid = document.getElementById("cards")!; - // The newest round comes first, so a returning visitor meets what is new. - const order = [...data.patterns].sort((a, b) => b.round - a.round || a.title.localeCompare(b.title)); - for (const entry of order) { - const card = buildCard(entry); - cards.push(card); - grid.append(card.root); - } - const observer = new IntersectionObserver( - (seen) => { - for (const s of seen) { - const card = cards.find((c) => c.root === s.target); - if (card) card.visible = s.isIntersecting; - } - }, - { rootMargin: "120px" }, - ); - for (const card of cards) { - observer.observe(card.root); - paint(card); - } + const about = document.getElementById("about")!; + about.textContent = data.shelf.about; + about.hidden = !data.shelf.about; + + buildGroups(); axisSelect("axis-x", axisX, (v) => { axisX = v; @@ -540,28 +754,37 @@ function start() { remember("y", v); }); legend(); - drawMap(); + mapSection.addEventListener("toggle", drawMap); window.addEventListener("resize", drawMap); - const look = document.getElementById("look") as HTMLButtonElement; - const showLook = () => (look.textContent = ledLook ? "LED 점으로 보는 중" : "픽셀로 보는 중"); - showLook(); - look.addEventListener("click", () => { - ledLook = !ledLook; - remember("look", ledLook ? "led" : "pixel"); - showLook(); - for (const card of cards) paint(card); - }); const pause = document.getElementById("pause") as HTMLButtonElement; pause.addEventListener("click", () => { running = !running; pause.textContent = running ? "모두 멈춤" : "다시 재생"; }); + const copyPicks = document.getElementById("picked-copy") as HTMLButtonElement; + copyPicks.addEventListener("click", () => void copyText(pickList(), copyPicks, "복사했어요")); + document.getElementById("picked-clear")!.addEventListener("click", () => { + if (!confirm(`고른 것 ${picked.size}개를 비울까요?`)) return; + picked.clear(); + remember("picked", "[]"); + showPicks(); + }); + showPicks(); - if (location.hash) { + dialog.addEventListener("click", (event) => { + if (event.target === dialog) closeCard(); + }); + dialog.addEventListener("close", () => closeCard()); + + // A link to #, or the back button to one, opens that card. + const fromHash = () => { const slug = decodeURIComponent(location.hash.slice(1)); - requestAnimationFrame(() => focusCard(slug)); - } + if (bySlug.has(slug)) openCard(slug); + else if (dialog.open) closeCard(); + }; + window.addEventListener("hashchange", fromHash); + if (location.hash) requestAnimationFrame(fromHash); requestAnimationFrame(loop); } diff --git a/incoming/pattern-study/tools/page.html b/incoming/pattern-study/tools/page.html index 20b081a15..a8a7c66d8 100644 --- a/incoming/pattern-study/tools/page.html +++ b/incoming/pattern-study/tools/page.html @@ -5,7 +5,7 @@ 패턴 연구 + the Lab engine in web/src/lib/, shelf.json and the files in patterns/. Do not edit: run the study. --> @@ -122,28 +184,37 @@

패턴 연구

-

새 패턴 개예요(). 모두 패널을 세운 64×128 로 그리고, Pattern Lab 의 엔진으로 돌아가요. 노브는 Lab 의 기본 노브 값 0, 2, 0, 0.06 을 패턴의 범위 안으로 당긴 자리에서 시작해요. 마음에 드는 것은 「코드 복사」로 가져가 Lab 의 CODE 칸에 붙여 넣으면 돼요. 붙여 넣으면 노브는 그때 Lab 에 있던 값을 새 범위로 당긴 자리에 서니, 같은 화면을 보려면 카드의 슬라이더 값대로 Knobs 칸을 맞춰요.

+

새 패턴 개예요(). 모두 패널을 세운 64×128 로 그리고, Pattern Lab 의 엔진으로 돌아가요. 칸을 누르면 노브와 버튼이 있는 카드가 열려요. ☆ 로 고른 것은 이 브라우저에 남고, 「고른 목록 복사」로 PR 댓글에 붙여 넣을 수 있어요.

+

-
+
+ + + + + + +
+ +
+ +
+ 지도: 잰 값 둘로 놓은 패턴 전부
-

점 하나가 패턴 하나예요. 누르면 그 패턴으로 가요. 흰 테두리 점이 가장 새로 나온 판이에요. 점선 칸은 아직 아무 패턴도 없는 자리예요. 자리는 카드의 처음 노브 값에서 8초 동안 돌린 화면을 재서 정했고, 거의 같은 자리에 겹친 점은 조금 떼어 놓았어요.

-
- -
- - -
- -
+

점 하나가 패턴 하나예요. 누르면 그 패턴의 카드가 열려요. 흰 테두리 점이 가장 새로 나온 판이고, 금색 테두리 점이 「먼저 볼 후보」예요. 점선 칸은 아직 아무 패턴도 없는 자리예요. 자리는 카드의 처음 노브 값에서 8초 동안 돌린 화면을 재서 정했고, 거의 같은 자리에 겹친 점은 조금 떼어 놓았어요. 아래 칩은 판이나 갈래를 지도와 칸에서 숨겨요.

+
+ + + diff --git a/incoming/pattern-study/tools/page.ts b/incoming/pattern-study/tools/page.ts index c3b3ef66a..967a3d022 100644 --- a/incoming/pattern-study/tools/page.ts +++ b/incoming/pattern-study/tools/page.ts @@ -1,16 +1,27 @@ /** * index.html: one file that runs on its own, opened from disk or from any * static host. The script is tools/gallery.ts bundled by esbuild together with - * LabEngine, the Lab preview's engine, so a card on the page renders what - * Pattern Lab renders at the same knobs. Each card gets the layer that - * codeLayerFromSource builds and the study's start knobs (lab.ts), computed - * here and carried as JSON, and the map gets the measures study.ts took. + * LabEngine, the Lab preview's engine, so a tile on the page renders what + * Pattern Lab renders at the same knobs. Each pattern carries the layer that + * codeLayerFromSource builds (without its code) and the study's start knobs + * (lab.ts), computed here, and the map gets the measures study.ts took. + * + * The page groups the patterns the way shelf.json beside them sorts them, a + * group to a folded section. A pattern the shelf does not name yet, such as + * one a round has just added, lands in a group of its own at the top. + * + * Each pattern's code is in the file once, in JSON blocks after the list. + * The block for the groups that open by themselves comes before the script + * and the rest after it, so the first screen runs while the remaining code is + * still arriving. A pattern's code is read from its block when its tile first + * comes into view. * * The one-line Korean note beside each card comes from the README's table of * patterns, the row that links patterns/.js. Without a row, the card - * shows the pattern's own Idea line. A family's name and colour come from - * families.json beside the patterns, so a round that opens a family adds a - * line there and leaves tools/ alone. + * shows the pattern's own Idea line. The "// Knob N:" and "// Buttons:" + * lines stay in the code, and gallery.ts reads them when a card opens. A + * family's name and colour come from families.json beside the patterns, so a + * round that opens a family adds a line there and leaves tools/ alone. */ import fs from "node:fs"; import { createRequire } from "node:module"; @@ -18,11 +29,61 @@ import path from "node:path"; import type * as Esbuild from "esbuild"; +import { stripRampAnnotation } from "@/lib/pattern/ramp"; + import { loadFamilies, roundOf, STUDY } from "./lab"; import type { Result } from "./study"; const esbuild = createRequire(path.resolve(STUDY, "../../web/package.json"))("esbuild") as typeof Esbuild; +export const SHELF_FILE = path.join(STUDY, "shelf.json"); + +export type ShelfGroup = { + key: string; + label: string; + why: string; + /** Opens by itself when the page loads. */ + open?: boolean; + patterns: string[]; + /** A line of its own for a pattern, shown under its tile. */ + notes?: Record; +}; +export type Shelf = { date: string; about: string; groups: ShelfGroup[] }; + +export function loadShelf(): Shelf { + if (!fs.existsSync(SHELF_FILE)) return { date: "", about: "", groups: [] }; + return JSON.parse(fs.readFileSync(SHELF_FILE, "utf8")) as Shelf; +} + +/** What is wrong with shelf.json, given the patterns there are. A pattern the shelf leaves out is not wrong: it is new. */ +export function shelfProblems(slugs: string[]): string[] { + let shelf: Shelf; + try { + shelf = loadShelf(); + } catch (error) { + return [`cannot read it: ${(error as Error).message.split("\n")[0]}`]; + } + const problems: string[] = []; + const known = new Set(slugs); + const seen = new Map(); + const keys = new Set(); + for (const group of shelf.groups ?? []) { + if (!/^[a-z][a-z0-9-]*$/.test(group.key ?? "")) problems.push(`"${group.key}" is not a lowercase group key`); + else if (keys.has(group.key) || group.key === NEW_GROUP) problems.push(`group "${group.key}" is there twice`); + keys.add(group.key); + for (const field of ["label", "why"] as const) if (typeof group[field] !== "string" || !group[field].trim()) problems.push(`${group.key} has no ${field}`); + for (const slug of group.patterns ?? []) { + if (!known.has(slug)) problems.push(`${group.key} names ${slug}, which is not in patterns/`); + if (seen.has(slug)) problems.push(`${slug} is in ${seen.get(slug)} and ${group.key}`); + seen.set(slug, group.key); + } + for (const slug of Object.keys(group.notes ?? {})) if (!(group.patterns ?? []).includes(slug)) problems.push(`${group.key} has a note for ${slug}, which it does not hold`); + } + return problems; +} + +const NEW_GROUP = "new"; + function readmeNotes(): Map { const notes = new Map(); const file = path.join(STUDY, "README.md"); @@ -34,46 +95,84 @@ function readmeNotes(): Map { return notes; } -function knobNotes(code: string): string[] { - return [1, 2, 3, 4].map((n) => { - const line = code.match(new RegExp(`^//[ \\t]*Knob ${n}:[ \\t]*(.+)$`, "m"))?.[1].trim() ?? ""; - // "Hue. Turns the pair..." → the sentence after the name. - return line.replace(/^[^.]{1,20}\.\s*/, ""); - }); -} - const round = (v: number) => Number(v.toFixed(4)); +const inline = (value: unknown) => JSON.stringify(value).replace(/ r.measures) - .map((r) => { - const p = r.pattern; - const about = knobNotes(p.code); - return { - slug: p.slug, - title: p.title, - family: p.family, - round: roundOf(p.code), - note: notes.get(p.slug) ?? "", - idea: p.idea, - buttons: p.code.match(/^\/\/[ \t]*Buttons:[ \t]*(.+)$/m)?.[1].trim() ?? "", - code: p.code, - layer: { ...p.layer, id: `layer-${p.slug}` }, - matrix: p.matrix, - knobs: p.knobs.map((k, i) => ({ ...k, about: about[i] })), - start: p.start, - measures: Object.fromEntries(Object.entries(r.measures!).map(([k, v]) => [k, round(v)])), - knobResponse: r.knobResponse.map(round), - }; - }); + const shelf = loadShelf(); + const measured = results.filter((r) => r.measures); + + // The groups in page order. Patterns no group names come first, newest first. + const placed = new Set(shelf.groups.flatMap((g) => g.patterns)); + const fresh = measured + .filter((r) => !placed.has(r.pattern.slug)) + .sort((a, b) => roundOf(b.pattern.code) - roundOf(a.pattern.code) || a.pattern.title.localeCompare(b.pattern.title)) + .map((r) => r.pattern.slug); + const groups: ShelfGroup[] = [ + ...(fresh.length + ? [{ key: NEW_GROUP, label: "아직 안 추린 것", why: "추린 뒤에 들어온 패턴이에요. 아직 어느 묶음에도 넣지 않았어요.", open: true, patterns: fresh }] + : []), + ...shelf.groups, + ]; + + const bySlug = new Map(measured.map((r) => [r.pattern.slug, r])); + const groupOf = new Map(); + for (const group of groups) for (const slug of group.patterns) groupOf.set(slug, group); + + // Code blocks: one for every group that opens by itself, then one per group. + const blocks: string[][] = [[]]; + for (const group of groups) { + const slugs = group.patterns.filter((s) => bySlug.has(s)); + if (group.open) blocks[0].push(...slugs); + else blocks.push(slugs); + } + const blockOf = new Map(); + blocks.forEach((slugs, i) => slugs.forEach((s) => blockOf.set(s, i))); + + const patterns = measured.map((r) => { + const p = r.pattern; + // The Lab drops the @ramp line from the code it runs. The page takes the + // code from the file and drops it the same way. + const ramped = p.layer.code !== p.code; + if (ramped && p.layer.code !== stripRampAnnotation(p.code)) + throw new Error(`${p.slug}: the Lab's layer code is not the file without its @ramp line`); + const { code: _code, ...layer } = p.layer; + return { + slug: p.slug, + title: p.title, + family: p.family, + round: roundOf(p.code), + // Without a README row, the pattern's own Idea line. + note: notes.get(p.slug) || p.idea, + why: groupOf.get(p.slug)?.notes?.[p.slug] ?? "", + block: blockOf.get(p.slug) ?? 0, + ramped, + layer: { ...layer, id: `layer-${p.slug}` }, + matrix: p.matrix, + knobs: p.knobs, + start: p.start, + measures: Object.fromEntries(Object.entries(r.measures!).map(([k, v]) => [k, round(v)])), + knobResponse: r.knobResponse.map(round), + }; + }); const used = new Set(patterns.map((p) => p.family)); const known = loadFamilies(); const families = Object.fromEntries( [...used].sort().map((f) => [f, known[f] ?? { label: f, color: "#cccccc" }]), ); - const data = { patterns, families }; + const data = { + patterns, + families, + shelf: { + date: shelf.date, + about: shelf.about, + groups: groups.map((g) => ({ key: g.key, label: g.label, why: g.why, open: Boolean(g.open), patterns: g.patterns.filter((s) => bySlug.has(s)) })), + }, + }; + + const codeBlock = (i: number) => + ``; const bundle = esbuild.buildSync({ entryPoints: [path.join(STUDY, "tools", "gallery.ts")], @@ -92,6 +191,8 @@ export function buildPage(results: Result[]): string { const template = fs.readFileSync(path.join(STUDY, "tools", "page.html"), "utf8"); return template - .replace("/*__DATA__*/", () => JSON.stringify(data).replace(/ js); + .replace("/*__DATA__*/", () => inline(data)) + .replace("", () => codeBlock(0)) + .replace("/*__SCRIPT__*/", () => js) + .replace("", () => blocks.slice(1).map((_, i) => codeBlock(i + 1)).join("\n")); } diff --git a/incoming/pattern-study/tools/study.ts b/incoming/pattern-study/tools/study.ts index 58820c4f5..bf11b2edd 100644 --- a/incoming/pattern-study/tools/study.ts +++ b/incoming/pattern-study/tools/study.ts @@ -12,7 +12,8 @@ * with every knob at its minimum, then at its maximum), it lights the panel, * it moves, and each knob on its own changes the picture. Its code may not * name the network, the page, a clock or code built from text (OFF_LIMITS), - * its family is one families.json names, and a file from round three on + * its family is one families.json names, shelf.json names it at most once + * and names no pattern that is not there, and a file from round three on * carries the whole header the rounds write: Author, Date, Lineage and a * "// Knob N:" line for each knob. * @@ -286,9 +287,12 @@ async function main() { console.log(`${results.length - failed.length} of ${results.length} patterns pass`); // The page builder is optional while the study is being set up. + let shelfFailures: string[] = []; if (!only && fs.existsSync(path.join(STUDY, "tools", "page.ts"))) { const pagePath = path.join(STUDY, "index.html"); - const { buildPage } = await import("./page"); + const { buildPage, shelfProblems, SHELF_FILE } = await import("./page"); + shelfFailures = shelfProblems(listPatterns()); + for (const p of shelfFailures) console.log(`FAIL ${path.relative(STUDY, SHELF_FILE)}: ${p}`); const html = buildPage(results); if (checkOnly) { const current = fs.existsSync(pagePath) ? fs.readFileSync(pagePath, "utf8") : ""; @@ -302,7 +306,7 @@ async function main() { console.log(`wrote index.html (${(html.length / 1024).toFixed(0)} KB)`); } } - if (failed.length || familyFailures.length) process.exit(1); + if (failed.length || familyFailures.length || shelfFailures.length) process.exit(1); } void main();