Skip to content

Instantly share code, notes, and snippets.

@kldh
Forked from WALKAL0NE/angle-window.astro
Created April 28, 2026 15:42
Show Gist options
  • Select an option

  • Save kldh/ef97d171bd8e168c8438bde9aa61c43b to your computer and use it in GitHub Desktop.

Select an option

Save kldh/ef97d171bd8e168c8438bde9aa61c43b to your computer and use it in GitHub Desktop.
WALKALONE_LAB — angle-window.astro
---
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