Differences
This shows you the differences between two versions of the page.
| Both sides previous revision Previous revision Next revision | Previous revision | ||
| universaltech:start [2026/06/14 05:39] – removed jumpstartadmin | universaltech:start [2026/06/22 06:57] (current) – jumpstartadmin | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| + | ~~NOCACHE~~ | ||
| + | < | ||
| + | < | ||
| + | <html lang=" | ||
| + | < | ||
| + | <meta charset=" | ||
| + | < | ||
| + | body.mode_show # | ||
| + | * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| + | .navbar.navbar-default { z-index: 10000 !important; } | ||
| + | body: | ||
| + | #ut-page { | ||
| + | position: fixed; | ||
| + | top: 0; | ||
| + | left: 0; right: 0; bottom: 0; | ||
| + | z-index: 9000; | ||
| + | background: #0A0816; | ||
| + | display: flex; | ||
| + | overflow: hidden; | ||
| + | font-family: | ||
| + | color: #EDE8F5; | ||
| + | } | ||
| + | |||
| + | /* VIDEO BG */ | ||
| + | # | ||
| + | position: absolute; | ||
| + | inset: 0; | ||
| + | z-index: 0; | ||
| + | } | ||
| + | # | ||
| + | width: 100%; height: 100%; | ||
| + | object-fit: cover; | ||
| + | opacity: 0.55; | ||
| + | } | ||
| + | # | ||
| + | content: ''; | ||
| + | position: absolute; | ||
| + | inset: 0; | ||
| + | background: linear-gradient(to right, rgba(10, | ||
| + | } | ||
| + | |||
| + | /* LEFT */ | ||
| + | #ut-left { | ||
| + | position: relative; | ||
| + | z-index: 10; | ||
| + | flex: 1; | ||
| + | display: flex; | ||
| + | flex-direction: | ||
| + | justify-content: | ||
| + | padding: 0 0 64px 64px; | ||
| + | pointer-events: | ||
| + | } | ||
| + | |||
| + | #ut-splash, #ut-content { | ||
| + | transition: opacity 0.35s, transform 0.35s; | ||
| + | } | ||
| + | # | ||
| + | opacity: 0; | ||
| + | transform: translateY(12px); | ||
| + | pointer-events: | ||
| + | position: absolute; | ||
| + | } | ||
| + | #ut-content { pointer-events: | ||
| + | |||
| + | .ut-eyebrow { | ||
| + | font-size: 11px; | ||
| + | letter-spacing: | ||
| + | text-transform: | ||
| + | color: #6B4FBB; | ||
| + | margin-bottom: | ||
| + | } | ||
| + | # | ||
| + | font-size: 72px; | ||
| + | font-weight: | ||
| + | line-height: | ||
| + | letter-spacing: | ||
| + | margin-bottom: | ||
| + | color: #EDE8F5; | ||
| + | } | ||
| + | # | ||
| + | # | ||
| + | font-size: 15px; | ||
| + | color: #9D85DB; | ||
| + | max-width: 400px; | ||
| + | line-height: | ||
| + | } | ||
| + | |||
| + | /* content state */ | ||
| + | # | ||
| + | font-size: 11px; | ||
| + | letter-spacing: | ||
| + | text-transform: | ||
| + | color: #6B4FBB; | ||
| + | margin-bottom: | ||
| + | } | ||
| + | # | ||
| + | font-size: 56px; | ||
| + | font-weight: | ||
| + | letter-spacing: | ||
| + | line-height: | ||
| + | margin-bottom: | ||
| + | color: #EDE8F5; | ||
| + | } | ||
| + | # | ||
| + | font-size: 14px; | ||
| + | color: #9D85DB; | ||
| + | max-width: 420px; | ||
| + | line-height: | ||
| + | margin-bottom: | ||
| + | } | ||
| + | # | ||
| + | display: flex; | ||
| + | flex-wrap: wrap; | ||
| + | gap: 8px; | ||
| + | margin-bottom: | ||
| + | pointer-events: | ||
| + | } | ||
| + | .ut-guide-card { | ||
| + | background: rgba(18, | ||
| + | border: 1px solid rgba(108, | ||
| + | border-radius: | ||
| + | padding: 12px 16px; | ||
| + | cursor: pointer; | ||
| + | transition: all 0.15s; | ||
| + | text-decoration: | ||
| + | display: block; | ||
| + | } | ||
| + | .ut-guide-card: | ||
| + | background: rgba(61, | ||
| + | border-color: | ||
| + | transform: translateY(-2px); | ||
| + | } | ||
| + | .ut-guide-card-name { font-size: 13px; font-weight: | ||
| + | .ut-guide-card-meta { font-size: 11px; color: #6B4FBB; } | ||
| + | |||
| + | # | ||
| + | display: inline-flex; | ||
| + | align-items: | ||
| + | gap: 8px; | ||
| + | background: rgba(108, | ||
| + | border: 1px solid rgba(108, | ||
| + | border-radius: | ||
| + | padding: 10px 20px; | ||
| + | color: #B8ADDB; | ||
| + | font-size: 13px; | ||
| + | font-family: | ||
| + | cursor: pointer; | ||
| + | text-decoration: | ||
| + | transition: all 0.15s; | ||
| + | pointer-events: | ||
| + | } | ||
| + | # | ||
| + | background: rgba(108, | ||
| + | color: #EDE8F5; | ||
| + | border-color: | ||
| + | } | ||
| + | |||
| + | /* RIGHT — osu carousel */ | ||
| + | # | ||
| + | position: relative; | ||
| + | z-index: 10; | ||
| + | width: 500px; | ||
| + | flex-shrink: | ||
| + | overflow: hidden; | ||
| + | display: flex; | ||
| + | align-items: | ||
| + | user-select: | ||
| + | } | ||
| + | |||
| + | # | ||
| + | position: absolute; | ||
| + | right: 0; | ||
| + | width: 100%; | ||
| + | } | ||
| + | |||
| + | /* tiles */ | ||
| + | .ut-item { | ||
| + | position: absolute; | ||
| + | right: 0; | ||
| + | border-radius: | ||
| + | overflow: hidden; | ||
| + | cursor: pointer; | ||
| + | border: 1px solid rgba(108, | ||
| + | border-right: | ||
| + | will-change: | ||
| + | transition: | ||
| + | width 0.22s cubic-bezier(0.4, | ||
| + | height 0.22s cubic-bezier(0.4, | ||
| + | opacity 0.22s; | ||
| + | } | ||
| + | |||
| + | .ut-item.is-cat | ||
| + | .ut-item.is-sub | ||
| + | .ut-item.is-cat.selected { width: 480px; height: 96px; } | ||
| + | .ut-item.is-sub.selected { width: 440px; height: 72px; } | ||
| + | |||
| + | .ut-item-bg { | ||
| + | position: absolute; inset: 0; | ||
| + | opacity: 0.2; | ||
| + | transition: opacity 0.2s; | ||
| + | } | ||
| + | .ut-item: | ||
| + | .ut-item.selected .ut-item-bg { opacity: 0.38; } | ||
| + | |||
| + | .ut-item-overlay { | ||
| + | position: absolute; inset: 0; | ||
| + | background: linear-gradient(90deg, | ||
| + | } | ||
| + | |||
| + | .ut-item-inner { | ||
| + | position: relative; | ||
| + | z-index: 2; | ||
| + | display: flex; | ||
| + | align-items: | ||
| + | gap: 16px; | ||
| + | padding: 0 22px; | ||
| + | width: 100%; | ||
| + | height: 100%; | ||
| + | } | ||
| + | |||
| + | .ut-item-icon { | ||
| + | font-size: 22px; | ||
| + | color: #9D85DB; | ||
| + | flex-shrink: | ||
| + | transition: color 0.2s, font-size 0.2s; | ||
| + | } | ||
| + | .ut-item.selected .ut-item-icon | ||
| + | .ut-item.is-sub .ut-item-icon | ||
| + | .ut-item.is-sub.selected .ut-item-icon { font-size: 17px; color: #9D85DB; } | ||
| + | |||
| + | .ut-item-text { flex: 1; min-width: 0; } | ||
| + | .ut-item-label { | ||
| + | font-size: 15px; | ||
| + | font-weight: | ||
| + | color: #C8BFED; | ||
| + | white-space: | ||
| + | overflow: hidden; | ||
| + | text-overflow: | ||
| + | transition: color 0.2s, font-size 0.2s; | ||
| + | } | ||
| + | .ut-item.selected .ut-item-label | ||
| + | .ut-item.is-sub .ut-item-label | ||
| + | .ut-item.is-sub.selected .ut-item-label { color: #C8BFED; font-size: 14px; } | ||
| + | |||
| + | .ut-item-sub-count { | ||
| + | font-size: 11px; | ||
| + | color: #6B4FBB; | ||
| + | margin-top: 3px; | ||
| + | } | ||
| + | |||
| + | .ut-item-accent-bar { | ||
| + | position: absolute; | ||
| + | left: 0; top: 0; bottom: 0; | ||
| + | width: 3px; | ||
| + | border-radius: | ||
| + | opacity: 0; | ||
| + | transition: opacity 0.2s; | ||
| + | } | ||
| + | .ut-item.selected .ut-item-accent-bar { opacity: 1; } | ||
| + | |||
| + | /* accent colors */ | ||
| + | .ut-acc-movement .ut-item-bg | ||
| + | .ut-acc-movement .ut-item-accent-bar { background: #9D85DB; } | ||
| + | .ut-acc-combat .ut-item-bg | ||
| + | .ut-acc-combat .ut-item-accent-bar | ||
| + | .ut-acc-items .ut-item-bg | ||
| + | .ut-acc-items .ut-item-accent-bar | ||
| + | .ut-acc-map .ut-item-bg | ||
| + | .ut-acc-map .ut-item-accent-bar | ||
| + | .ut-acc-mechanics .ut-item-bg { background: linear-gradient(90deg,# | ||
| + | .ut-acc-mechanics .ut-item-accent-bar{ background: #DBC085; } | ||
| + | .ut-acc-abilities .ut-item-bg { background: linear-gradient(90deg,# | ||
| + | .ut-acc-abilities .ut-item-accent-bar{ background: #C085DB; } | ||
| + | |||
| + | /* search */ | ||
| + | # | ||
| + | position: absolute; | ||
| + | top: 24px; right: 0; | ||
| + | width: 440px; | ||
| + | z-index: 20; | ||
| + | } | ||
| + | #ut-search { | ||
| + | width: 100%; | ||
| + | background: rgba(10, | ||
| + | border: 1px solid rgba(108, | ||
| + | border-right: | ||
| + | border-radius: | ||
| + | padding: 10px 16px; | ||
| + | color: #EDE8F5; | ||
| + | font-size: 13px; | ||
| + | font-family: | ||
| + | outline: none; | ||
| + | transition: border-color 0.15s; | ||
| + | } | ||
| + | # | ||
| + | # | ||
| + | </ | ||
| + | </ | ||
| + | < | ||
| + | <div id=" | ||
| + | |||
| + | <div id=" | ||
| + | <video autoplay muted loop playsinline id=" | ||
| + | <source src="/ | ||
| + | </ | ||
| + | </ | ||
| + | |||
| + | <div id=" | ||
| + | <div id=" | ||
| + | <div class=" | ||
| + | <div id=" | ||
| + | <div id=" | ||
| + | </ | ||
| + | <div id=" | ||
| + | <div id=" | ||
| + | <div id=" | ||
| + | <div id=" | ||
| + | <div id=" | ||
| + | <a id=" | ||
| + | </ | ||
| + | </ | ||
| + | |||
| + | <div id=" | ||
| + | <div id=" | ||
| + | <input type=" | ||
| + | </ | ||
| + | <div id=" | ||
| + | </ | ||
| + | |||
| + | </ | ||
| + | < | ||
| + | (function(){ | ||
| + | |||
| + | const CATS = [ | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | ]}, | ||
| + | ]; | ||
| + | |||
| + | const VIDS = [ | ||
| + | '/ | ||
| + | '/ | ||
| + | '/ | ||
| + | '/ | ||
| + | ]; | ||
| + | let vi = 0; | ||
| + | const video = document.getElementById(' | ||
| + | video.addEventListener(' | ||
| + | vi = (vi + 1) % VIDS.length; | ||
| + | video.src = VIDS[vi]; | ||
| + | video.play(); | ||
| + | }); | ||
| + | |||
| + | // ── STATE ────────────────────────────────────────────── | ||
| + | let activeCatId = null; | ||
| + | let activeSubId = null; | ||
| + | let filter = ''; | ||
| + | |||
| + | // ── LAYOUT CONSTANTS ──────────────────────────────────── | ||
| + | const CAT_H = 80; | ||
| + | const CAT_H_SEL = 96; | ||
| + | const SUB_H = 60; | ||
| + | const SUB_H_SEL = 72; | ||
| + | const GAP = 7; | ||
| + | |||
| + | // scrollY = offset into virtual list (px). Center of viewport = scrollY. | ||
| + | let scrollY | ||
| + | let velocity | ||
| + | let rafId = null; | ||
| + | let isDragging = false; | ||
| + | let lastDragY | ||
| + | let lastDragT | ||
| + | |||
| + | // ── FLAT ITEM LIST ────────────────────────────────────── | ||
| + | function buildItemList() { | ||
| + | const items = []; | ||
| + | const q = filter.toLowerCase(); | ||
| + | CATS.forEach(cat => { | ||
| + | const subMatch = cat.subs.filter(s => | ||
| + | !q || s.label.toLowerCase().includes(q) || cat.label.toLowerCase().includes(q) | ||
| + | ); | ||
| + | if (q && subMatch.length === 0 && !cat.label.toLowerCase().includes(q)) return; | ||
| + | items.push({ type:' | ||
| + | if (activeCatId === cat.id) { | ||
| + | (q ? subMatch : cat.subs).forEach(sub => items.push({ type:' | ||
| + | } | ||
| + | }); | ||
| + | return items; | ||
| + | } | ||
| + | |||
| + | // ── POSITIONS ─────────────────────────────────────────── | ||
| + | function computePositions(items) { | ||
| + | let y = 0; | ||
| + | return items.map(item => { | ||
| + | const isSel = item.type === ' | ||
| + | ? activeCatId === item.cat.id && activeSubId === null | ||
| + | : activeSubId === item.sub.id; | ||
| + | const h = item.type === ' | ||
| + | const pos = { y, h, isSel }; | ||
| + | y += h + GAP; | ||
| + | return pos; | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | // ── RENDER ───────────────────────────────────────────── | ||
| + | function render() { | ||
| + | const carousel = document.getElementById(' | ||
| + | const wrapH = document.getElementById(' | ||
| + | const items = buildItemList(); | ||
| + | const pos = computePositions(items); | ||
| + | const totalH | ||
| + | |||
| + | // clamp scroll: allow scrolling through the whole list | ||
| + | const minScroll = -(wrapH / 2); | ||
| + | const maxScroll = totalH - wrapH / 2; | ||
| + | scrollY = Math.max(minScroll, | ||
| + | |||
| + | // track which uids exist | ||
| + | const existingEls = {}; | ||
| + | carousel.querySelectorAll(' | ||
| + | const newUids = new Set(); | ||
| + | items.forEach(item => newUids.add(uid(item))); | ||
| + | Object.keys(existingEls).forEach(u => { if (!newUids.has(u)) existingEls[u].remove(); | ||
| + | |||
| + | // viewport center | ||
| + | const vc = wrapH / 2; | ||
| + | |||
| + | items.forEach((item, | ||
| + | const u = uid(item); | ||
| + | const { y: iy, h, isSel } = pos[i]; | ||
| + | |||
| + | // position relative to viewport center | ||
| + | const screenY = iy - scrollY; | ||
| + | const itemCenter = screenY + h / 2; | ||
| + | const distFromCenter = itemCenter - vc; | ||
| + | const absD = Math.abs(distFromCenter); | ||
| + | const maxD = wrapH * 0.52; | ||
| + | |||
| + | // osu curve: items further away pushed right | ||
| + | const pushFrac = Math.min(absD / maxD, 1); | ||
| + | // ease it: more push at the edges | ||
| + | const push = Math.pow(pushFrac, | ||
| + | |||
| + | // fade near edges | ||
| + | const fadeFrac = Math.min(absD / (wrapH * 0.44), 1); | ||
| + | const opacity | ||
| + | |||
| + | if (opacity <= 0.01) { | ||
| + | if (existingEls[u]) existingEls[u].style.opacity = ' | ||
| + | return; | ||
| + | } | ||
| + | |||
| + | let el = existingEls[u]; | ||
| + | if (!el) { | ||
| + | el = document.createElement(' | ||
| + | el.dataset.uid = u; | ||
| + | el.innerHTML = buildInner(item); | ||
| + | el.addEventListener(' | ||
| + | carousel.appendChild(el); | ||
| + | } | ||
| + | |||
| + | el.className = ' | ||
| + | (item.type === ' | ||
| + | ' ut-acc-' | ||
| + | (isSel ? ' selected' | ||
| + | |||
| + | el.style.top | ||
| + | el.style.height | ||
| + | el.style.transform = `translateX(${push}px)`; | ||
| + | el.style.opacity = opacity.toFixed(3); | ||
| + | }); | ||
| + | |||
| + | carousel.style.height = wrapH + ' | ||
| + | } | ||
| + | |||
| + | function uid(item) { | ||
| + | return item.type === ' | ||
| + | } | ||
| + | |||
| + | function buildInner(item) { | ||
| + | if (item.type === ' | ||
| + | return `<div class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | <i class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | </ | ||
| + | <i class=" | ||
| + | </ | ||
| + | } else { | ||
| + | return `<div class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | <i class=" | ||
| + | <div class=" | ||
| + | <div class=" | ||
| + | </ | ||
| + | <i class=" | ||
| + | </ | ||
| + | } | ||
| + | } | ||
| + | |||
| + | // ── CLICK ────────────────────────────────────────────── | ||
| + | function onItemClick(item) { | ||
| + | if (item.type === ' | ||
| + | if (activeCatId === item.cat.id && activeSubId === null) { | ||
| + | activeCatId = null; | ||
| + | showSplash(); | ||
| + | } else { | ||
| + | activeCatId = item.cat.id; | ||
| + | activeSubId = null; | ||
| + | showCat(item.cat); | ||
| + | } | ||
| + | } else { | ||
| + | activeCatId = item.cat.id; | ||
| + | activeSubId = item.sub.id; | ||
| + | showSub(item.cat, | ||
| + | } | ||
| + | scrollToSelected(); | ||
| + | render(); | ||
| + | } | ||
| + | |||
| + | function scrollToSelected() { | ||
| + | const items = buildItemList(); | ||
| + | const pos = computePositions(items); | ||
| + | const wrapH = document.getElementById(' | ||
| + | let selY = null, selH = CAT_H; | ||
| + | items.forEach((item, | ||
| + | const isSel = item.type === ' | ||
| + | ? activeCatId === item.cat.id && activeSubId === null | ||
| + | : activeSubId === item.sub? | ||
| + | if (isSel) { selY = pos[i].y; selH = pos[i].h; } | ||
| + | }); | ||
| + | if (selY !== null) { | ||
| + | animateScroll(selY + selH / 2 - wrapH / 2); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | // Spring scroll — sets velocity toward target naturally instead of teleporting | ||
| + | function animateScroll(target) { | ||
| + | // Stop any current momentum and spring toward target | ||
| + | momentumRunning = false; | ||
| + | velocity = 0; | ||
| + | const SPRING = 0.12; // attraction strength per frame | ||
| + | const DAMP = 0.72; // damping to prevent overshoot | ||
| + | let springRunning = true; | ||
| + | let sv = 0; // spring velocity separate from main velocity | ||
| + | function step() { | ||
| + | if (!springRunning) return; | ||
| + | const diff = target - scrollY; | ||
| + | if (Math.abs(diff) < 0.5 && Math.abs(sv) < 0.5) { | ||
| + | scrollY = target; | ||
| + | render(); | ||
| + | springRunning = false; | ||
| + | return; | ||
| + | } | ||
| + | sv += diff * SPRING; | ||
| + | sv *= DAMP; | ||
| + | scrollY += sv; | ||
| + | render(); | ||
| + | requestAnimationFrame(step); | ||
| + | } | ||
| + | requestAnimationFrame(step); | ||
| + | } | ||
| + | |||
| + | // ── CONTENT AREA ─────────────────────────────────────── | ||
| + | const splash | ||
| + | const content = document.getElementById(' | ||
| + | |||
| + | function showSplash() { | ||
| + | splash.classList.remove(' | ||
| + | content.classList.add(' | ||
| + | } | ||
| + | function showCat(cat) { | ||
| + | splash.classList.add(' | ||
| + | content.classList.remove(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | } | ||
| + | function showSub(cat, | ||
| + | splash.classList.add(' | ||
| + | content.classList.remove(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | document.getElementById(' | ||
| + | } | ||
| + | |||
| + | // ── SCROLL ───────────────────────────────────────────── | ||
| + | const wrap = document.getElementById(' | ||
| + | let momentumRunning = false; | ||
| + | |||
| + | // Wheel sensitivity: | ||
| + | const WHEEL_ACCEL | ||
| + | const FRICTION | ||
| + | const VEL_CAP | ||
| + | const VEL_STOP | ||
| + | |||
| + | function startMomentum() { | ||
| + | if (momentumRunning) return; | ||
| + | momentumRunning = true; | ||
| + | function step() { | ||
| + | velocity *= FRICTION; | ||
| + | if (Math.abs(velocity) < VEL_STOP) { | ||
| + | velocity = 0; | ||
| + | momentumRunning = false; | ||
| + | return; | ||
| + | } | ||
| + | scrollY += velocity; | ||
| + | render(); | ||
| + | requestAnimationFrame(step); | ||
| + | } | ||
| + | requestAnimationFrame(step); | ||
| + | } | ||
| + | |||
| + | // WHEEL — accumulate velocity proportional to scroll speed | ||
| + | wrap.addEventListener(' | ||
| + | e.preventDefault(); | ||
| + | const scale = e.deltaMode === 1 ? 20 : e.deltaMode === 2 ? 300 : 1; | ||
| + | const delta = e.deltaY * scale; | ||
| + | // Add a fraction of delta to velocity — proportional feel | ||
| + | velocity += delta * WHEEL_ACCEL; | ||
| + | velocity = Math.max(-VEL_CAP, | ||
| + | startMomentum(); | ||
| + | }, { passive: false }); | ||
| + | |||
| + | // DRAG | ||
| + | wrap.addEventListener(' | ||
| + | momentumRunning = false; | ||
| + | velocity = 0; | ||
| + | isDragging = true; lastDragY = e.clientY; lastDragT = performance.now(); | ||
| + | wrap.style.cursor = ' | ||
| + | e.preventDefault(); | ||
| + | }); | ||
| + | window.addEventListener(' | ||
| + | if (!isDragging) return; | ||
| + | const now = performance.now(); | ||
| + | const dy = e.clientY - lastDragY; | ||
| + | const dt = Math.max(now - lastDragT, 1); | ||
| + | velocity | ||
| + | scrollY | ||
| + | lastDragY = e.clientY; lastDragT = now; | ||
| + | render(); | ||
| + | }); | ||
| + | window.addEventListener(' | ||
| + | if (!isDragging) return; | ||
| + | isDragging = false; | ||
| + | wrap.style.cursor = ' | ||
| + | startMomentum(); | ||
| + | }); | ||
| + | |||
| + | // TOUCH | ||
| + | wrap.addEventListener(' | ||
| + | momentumRunning = false; velocity = 0; | ||
| + | isDragging = true; lastDragY = e.touches[0].clientY; | ||
| + | }, { passive: true }); | ||
| + | wrap.addEventListener(' | ||
| + | if (!isDragging) return; | ||
| + | const now = performance.now(); | ||
| + | const dy = e.touches[0].clientY - lastDragY; | ||
| + | velocity | ||
| + | scrollY | ||
| + | lastDragY = e.touches[0].clientY; | ||
| + | render(); | ||
| + | }, { passive: true }); | ||
| + | wrap.addEventListener(' | ||
| + | |||
| + | // ── SEARCH ───────────────────────────────────────────── | ||
| + | document.getElementById(' | ||
| + | filter = e.target.value; | ||
| + | render(); | ||
| + | }); | ||
| + | |||
| + | // ── INIT ─────────────────────────────────────────────── | ||
| + | window.addEventListener(' | ||
| + | // start scroll so first item is near center | ||
| + | scrollY = -(document.getElementById(' | ||
| + | render(); | ||
| + | |||
| + | })(); | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||
| + | </ | ||