-
-
Save kldh/ef97d171bd8e168c8438bde9aa61c43b to your computer and use it in GitHub Desktop.
WALKALONE_LAB — angle-window.astro
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| import Layout from '@/layouts/Layout.astro'; | |
| import { loopCount } from '@walkal0ne/lasagna'; | |
| export const options = { | |
| title: 'Angle Window', | |
| description: 'Stacked rings of windows rotating in 3D, each row independently controlled.', | |
| created: '20260427', | |
| ref: 'https://youtu.be/QWN4nZUt39M?si=nZbQC9TYejXH8xVI/' | |
| } | |
| const ROWS = 7; | |
| --- | |
| <Layout> | |
| <section class="angle-window"> | |
| <div class="angle-window__world" id="js-world"> | |
| {loopCount(ROWS).map((rowIndex) => ( | |
| <div class="angle-window__row" data-row={rowIndex}></div> | |
| ))} | |
| </div> | |
| </section> | |
| </Layout> | |
| <script> | |
| import { Pane } from 'tweakpane'; | |
| import { addFpsFolder } from '@/lib/tweakpane-fps'; | |
| import Tempus from 'tempus'; | |
| import { $,$$ } from '@walkal0ne/lasagna'; | |
| import gsap from 'gsap'; | |
| class App { | |
| constructor() { | |
| this.initAngleWindow(); | |
| this.fadeAnimation(); | |
| } | |
| initAngleWindow() { | |
| const section = $('.angle-window'); | |
| const world = $('#js-world'); | |
| if (!section || !world) return; | |
| const ROWS = 7; | |
| const globalParams = { | |
| speed: 0.1, | |
| tilt: 0, | |
| }; | |
| const rowParamsList = [ | |
| { y: -217, count: 10, itemWidth: 110, gap: 140 }, | |
| { y: -185, count: 7, itemWidth: 110, gap: 104 }, | |
| { y: -120, count: 9, itemWidth: 110, gap: 140 }, | |
| { y: 0, count: 8, itemWidth: 110, gap: 140 }, | |
| { y: 109, count: 7, itemWidth: 110, gap: 140 }, | |
| { y: 163, count: 6, itemWidth: 110, gap: 72 }, | |
| { y: 250, count: 5, itemWidth: 110, gap: 179 }, | |
| ]; | |
| const pane = new Pane(); | |
| const _isSP = matchMedia('(max-width: 768px)').matches; | |
| window.__addNavToPane?.(pane); | |
| const rows = Array.from(section.querySelectorAll('.angle-window__row')) as HTMLElement[]; | |
| const createItem = (i: number, count: number) => { | |
| const el = document.createElement('div'); | |
| el.className = 'angle-window__item'; | |
| el.style.setProperty('--i', String(i)); | |
| el.style.setProperty('--count', String(count)); | |
| el.innerHTML = ` | |
| <div class="angle-window__item__bg"></div> | |
| <div class="angle-window__item__window"> | |
| <div class="angle-window__item__header"> | |
| <div class="angle-window__item__header-circle"></div> | |
| <div class="angle-window__item__header-circle"></div> | |
| <div class="angle-window__item__header-circle"></div> | |
| </div> | |
| <div class="angle-window__item__container"> | |
| <p class="angle-window__item__text"></p> | |
| </div> | |
| </div> | |
| `; | |
| return el; | |
| }; | |
| const renderRowItems = (rowEl: HTMLElement, count: number) => { | |
| rowEl.innerHTML = ''; | |
| for (let i = 0; i < count; i++) { | |
| rowEl.appendChild(createItem(i, count)); | |
| } | |
| }; | |
| rows.forEach((rowEl, i) => { | |
| rowEl.style.setProperty('--item-w', `${rowParamsList[i].itemWidth}rem`); | |
| rowEl.style.setProperty('--gap', `${rowParamsList[i].gap}rem`); | |
| rowEl.style.setProperty('--row-y', `${rowParamsList[i].y}rem`); | |
| renderRowItems(rowEl, rowParamsList[i].count); | |
| }); | |
| const updateItemRotateX = (speed: number) => { | |
| section.style.setProperty('--item-rotateX', `${speed * 15}deg`); | |
| }; | |
| updateItemRotateX(globalParams.speed); | |
| // speed=1 で 1deg/frame @60fps → 360deg/6s = 6000ms が基準 | |
| const BASE_DURATION = 6000; | |
| world.style.transform = `rotateX(${globalParams.tilt}deg)`; | |
| const spinAnim = (world as HTMLElement).animate( | |
| [{ transform: 'rotateY(0deg)' }, { transform: 'rotateY(360deg)' }], | |
| { duration: BASE_DURATION, iterations: Infinity, easing: 'linear', composite: 'add' } | |
| ); | |
| spinAnim.updatePlaybackRate(globalParams.speed); | |
| let currentSpeed = globalParams.speed; | |
| let targetSpeed = globalParams.speed; | |
| let wheelTimeout: ReturnType<typeof setTimeout> | null = null; | |
| const applySpeed = (speed: number) => { | |
| currentSpeed = speed; | |
| globalParams.speed = speed; | |
| updateItemRotateX(speed); | |
| spinAnim.updatePlaybackRate(speed); | |
| }; | |
| const optionsFolder = pane.addFolder({ title: 'Options', expanded: !_isSP }); | |
| optionsFolder.addBinding(globalParams, 'speed', { min: -3, max: 3, step: 0.01, label: 'rotation speed' }) | |
| .on('change', ({ value }: { value: number }) => { | |
| currentSpeed = value; | |
| targetSpeed = value; | |
| applySpeed(value); | |
| }); | |
| optionsFolder.addBinding(globalParams, 'tilt', { min: 0, max: 90, step: 1 }) | |
| .on('change', ({ value }: { value: number }) => { | |
| world.style.transform = `rotateX(${value}deg)`; | |
| }); | |
| const onWheel = (e: WheelEvent) => { | |
| e.preventDefault(); | |
| if (Math.abs(e.deltaY) < 3) return; | |
| const amount = Math.min(Math.abs(e.deltaY) * 0.015, 0.6); | |
| if (e.deltaY < 0) { | |
| currentSpeed = Math.min(2.0, (currentSpeed > 0 ? currentSpeed : 0.1) + amount); | |
| } else { | |
| currentSpeed = Math.max(-2.0, (currentSpeed < 0 ? currentSpeed : -0.1) - amount); | |
| } | |
| targetSpeed = currentSpeed; | |
| applySpeed(currentSpeed); | |
| if (wheelTimeout) clearTimeout(wheelTimeout); | |
| wheelTimeout = setTimeout(() => { | |
| targetSpeed = currentSpeed >= 0 ? 0.1 : -0.1; | |
| }, 150); | |
| }; | |
| section.addEventListener('wheel', onWheel, { passive: false }); | |
| const monitor = { rows: '' }; | |
| let monitorBinding: any; | |
| const updateMonitor = () => { | |
| monitor.rows = JSON.stringify( | |
| rowParamsList.map(({ y, count, itemWidth, gap }) => ({ y, count, itemWidth, gap })), | |
| null, 2 | |
| ); | |
| monitorBinding?.refresh(); | |
| }; | |
| const rowsFolder = pane.addFolder({ title: 'Rows', expanded: !_isSP }); | |
| const rowTabs = rowsFolder.addTab({ | |
| pages: rows.map((_, i) => ({ title: `${i + 1}` })), | |
| }); | |
| rows.forEach((rowEl: HTMLElement, rowIndex: number) => { | |
| const page = rowTabs.pages[rowIndex]; | |
| page.addBinding(rowParamsList[rowIndex], 'y', { min: -500, max: 500, step: 1 }) | |
| .on('change', ({ value }: { value: number }) => { rowEl.style.setProperty('--row-y', `${value}rem`); updateMonitor(); }); | |
| page.addBinding(rowParamsList[rowIndex], 'count', { min: 1, max: 12, step: 1 }) | |
| .on('change', ({ value }: { value: number }) => { renderRowItems(rowEl, value); updateMonitor(); }); | |
| page.addBinding(rowParamsList[rowIndex], 'itemWidth', { min: 50, max: 600, step: 1, label: 'item width' }) | |
| .on('change', ({ value }: { value: number }) => { rowEl.style.setProperty('--item-w', `${value}rem`); updateMonitor(); }); | |
| page.addBinding(rowParamsList[rowIndex], 'gap', { min: 0, max: 300, step: 1 }) | |
| .on('change', ({ value }: { value: number }) => { rowEl.style.setProperty('--gap', `${value}rem`); updateMonitor(); }); | |
| }); | |
| monitorBinding = rowsFolder.addBinding(monitor, 'rows', { | |
| readonly: true, | |
| multiline: true, | |
| rows: 9, | |
| interval: 0, | |
| }); | |
| updateMonitor(); | |
| const fps = addFpsFolder(pane, { expanded: !_isSP }); | |
| const raf = Tempus.add(() => { | |
| fps.tick(); | |
| // 逆再生時に currentTime が 0 に達して止まるのをラップアラウンドで防ぐ | |
| if (spinAnim.playbackRate < 0 && Number(spinAnim.currentTime) <= 10) { | |
| spinAnim.currentTime = BASE_DURATION - 10; | |
| } | |
| // targetSpeed に向けてラープ | |
| const diff = targetSpeed - currentSpeed; | |
| if (Math.abs(diff) > 0.001) { | |
| applySpeed(currentSpeed + diff * 0.06); | |
| } | |
| }, { fps: 60 }); | |
| document.addEventListener('swup:visit:start', () => { | |
| raf(); | |
| spinAnim.cancel(); | |
| section.removeEventListener('wheel', onWheel); | |
| if (wheelTimeout) clearTimeout(wheelTimeout); | |
| pane.dispose(); | |
| }); | |
| } | |
| fadeAnimation(){ | |
| $$(".angle-window__item").forEach((el,i)=>{ | |
| // 0〜4000の間でランダムな値 | |
| const delay = Math.random() * 10000; | |
| const targets = $$(".angle-window__item__window, .angle-window__item__bg",el); | |
| const tl = gsap.timeline({ | |
| paused: true, | |
| repeat: -1, | |
| }); | |
| tl.to(targets,{ | |
| scale: 0, | |
| duration: 0.5, | |
| delay: 2, | |
| }) | |
| tl.set(targets,{ | |
| scaleX: 0, | |
| scaleY: 1, | |
| height: "0%" | |
| }) | |
| tl.to(targets,{ | |
| scaleX: 1, | |
| duration: 0.2, | |
| delay: 2, | |
| }) | |
| tl.to(targets,{ | |
| height: "100%", | |
| duration: 0.5, | |
| }) | |
| setTimeout(() => { | |
| tl.play(); | |
| }, delay); | |
| }) | |
| } | |
| } | |
| new App(); | |
| </script> | |
| <style lang="sass" is:global> | |
| section.angle-window | |
| --item-w: 110rem | |
| --gap: 140rem | |
| width: 100% | |
| height: 100svh | |
| overflow: hidden | |
| display: flex | |
| align-items: center | |
| justify-content: center | |
| perspective: 1200rem | |
| .angle-window__world | |
| transform-style: preserve-3d | |
| transform: rotateX(0deg) | |
| width: 0 | |
| height: 0 | |
| .angle-window__row | |
| position: absolute | |
| transform-style: preserve-3d | |
| transform: translateY(var(--row-y, 0)) | |
| width: 0 | |
| height: 0 | |
| .angle-window__item | |
| --border-color: #787878 | |
| --item-h: calc(var(--item-w) * 4 / 6) | |
| --angle: calc(360deg / var(--count) * var(--i)) | |
| // radius = (item-w + gap) / 2 / tan(180deg / count) — gap分だけ半径を広げてパネル間隔を確保 | |
| --radius: calc((var(--item-w) + var(--gap)) / 2 / tan(180deg / var(--count))) | |
| position: absolute | |
| width: var(--item-w) | |
| aspect-ratio: 6 / 4 | |
| left: calc(var(--item-w) / -2) | |
| top: calc(var(--item-h) / -2) | |
| transform-style: preserve-3d | |
| backface-visibility: hidden | |
| +flex-middle | |
| // rotateY → 各パネルを等角度に向ける | |
| // translateZ → ローカルZ軸(正面方向)に押し出す = 缶の側面に配置 | |
| transform: rotateY(var(--angle)) translateZ(var(--radius)) rotateX(var(--item-rotateX, 0deg)) | |
| .angle-window__item__bg | |
| position: absolute | |
| inset: 0 | |
| margin: auto | |
| background: var(--border-color) | |
| transform: rotateY(180deg) | |
| z-index: 1 | |
| backface-visibility: hidden | |
| border: 1px solid var(--color-white-200) | |
| +size(100%) | |
| .angle-window__item__window | |
| border: 1px solid var(--border-color) | |
| background: #f2f2f2 | |
| display: flex | |
| flex-direction: column | |
| +size(100%) | |
| .angle-window__item__header | |
| height: 15% | |
| border-bottom: 1px solid var(--border-color) | |
| padding-inline: 3% | |
| display: flex | |
| gap: 1% | |
| align-items: center | |
| justify-content: flex-start | |
| .angle-window__item__header-circle | |
| height: 50% | |
| aspect-ratio: 1 | |
| background: var(--border-color) | |
| border-radius: 50% | |
| .angle-window__item__container | |
| width: 100% | |
| flex: 1 | |
| +flex-middle | |
| .angle-window__item__text | |
| +size(fit-content) | |
| +text(20rem, regular) | |
| color: black | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment