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/22 06:19] – jumpstartadmin | universaltech:start [2026/06/22 06:57] (current) – jumpstartadmin | ||
|---|---|---|---|
| Line 7: | Line 7: | ||
| < | < | ||
| body.mode_show # | body.mode_show # | ||
| - | |||
| * { box-sizing: border-box; margin: 0; padding: 0; } | * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| - | |||
| .navbar.navbar-default { z-index: 10000 !important; } | .navbar.navbar-default { z-index: 10000 !important; } | ||
| - | |||
| body: | body: | ||
| #ut-page { | #ut-page { | ||
| position: fixed; | position: fixed; | ||
| - | top: 50px; | + | top: 0; |
| - | left: 0; | + | left: 0; right: 0; bottom: 0; |
| - | | + | |
| - | | + | |
| z-index: 9000; | z-index: 9000; | ||
| background: #0A0816; | background: #0A0816; | ||
| Line 35: | Line 30: | ||
| } | } | ||
| # | # | ||
| - | width: 100%; | + | width: 100%; height: 100%; |
| - | | + | |
| object-fit: cover; | object-fit: cover; | ||
| - | opacity: 0.3; | + | opacity: 0.55; |
| } | } | ||
| # | # | ||
| Line 44: | Line 38: | ||
| position: absolute; | position: absolute; | ||
| inset: 0; | inset: 0; | ||
| - | background: linear-gradient(to right, rgba(10, | + | background: linear-gradient(to right, rgba(10, |
| } | } | ||
| - | /* LEFT — content area */ | + | /* LEFT */ |
| #ut-left { | #ut-left { | ||
| position: relative; | position: relative; | ||
| Line 91: | Line 85: | ||
| max-width: 400px; | max-width: 400px; | ||
| line-height: | line-height: | ||
| - | margin-bottom: | ||
| - | } | ||
| - | # | ||
| - | display: flex; | ||
| - | flex-wrap: wrap; | ||
| - | gap: 8px; | ||
| - | pointer-events: | ||
| - | } | ||
| - | .ut-pill { | ||
| - | background: rgba(61, | ||
| - | border: 1px solid rgba(108, | ||
| - | border-radius: | ||
| - | padding: 6px 16px; | ||
| - | font-size: 12px; | ||
| - | color: #B8ADDB; | ||
| - | cursor: pointer; | ||
| - | font-family: | ||
| - | transition: all 0.15s; | ||
| - | } | ||
| - | .ut-pill: | ||
| - | background: rgba(108, | ||
| - | color: #EDE8F5; | ||
| - | border-color: | ||
| } | } | ||
| Line 186: | Line 157: | ||
| } | } | ||
| - | /* RIGHT — osu-style | + | /* RIGHT — osu carousel */ |
| # | # | ||
| position: relative; | position: relative; | ||
| z-index: 10; | z-index: 10; | ||
| - | width: | + | width: |
| flex-shrink: | flex-shrink: | ||
| overflow: hidden; | overflow: hidden; | ||
| display: flex; | display: flex; | ||
| align-items: | align-items: | ||
| - | cursor: grab; | ||
| user-select: | user-select: | ||
| } | } | ||
| - | # | ||
| # | # | ||
| Line 204: | Line 173: | ||
| right: 0; | right: 0; | ||
| width: 100%; | width: 100%; | ||
| - | /* items positioned absolutely within this */ | ||
| } | } | ||
| - | /* Each item in the carousel | + | /* tiles */ |
| .ut-item { | .ut-item { | ||
| position: absolute; | position: absolute; | ||
| right: 0; | right: 0; | ||
| - | | + | border-radius: |
| - | | + | |
| - | display: flex; | + | |
| - | align-items: | + | |
| - | gap: 0; | + | |
| overflow: hidden; | overflow: hidden; | ||
| cursor: pointer; | cursor: pointer; | ||
| - | transition: | ||
| - | width 0.28s cubic-bezier(0.4, | ||
| - | transform 0.28s cubic-bezier(0.4, | ||
| - | height 0.28s cubic-bezier(0.4, | ||
| - | opacity 0.28s; | ||
| border: 1px solid rgba(108, | border: 1px solid rgba(108, | ||
| border-right: | border-right: | ||
| - | will-change: | + | will-change: |
| + | transition: | ||
| + | width 0.22s cubic-bezier(0.4, | ||
| + | height 0.22s cubic-bezier(0.4, | ||
| + | opacity 0.22s; | ||
| } | } | ||
| - | .ut-item.is-cat { width: | + | .ut-item.is-cat |
| - | .ut-item.is-sub { width: | + | .ut-item.is-sub |
| + | .ut-item.is-cat.selected { width: 480px; height: 96px; } | ||
| + | .ut-item.is-sub.selected { width: 440px; height: 72px; } | ||
| - | .ut-item.selected { height: 80px; } | ||
| - | .ut-item.is-cat.selected { width: 400px; } | ||
| - | .ut-item.is-sub.selected { width: 360px; } | ||
| - | |||
| - | /* accent bg */ | ||
| .ut-item-bg { | .ut-item-bg { | ||
| - | position: absolute; | + | position: absolute; inset: 0; |
| - | | + | opacity: 0.2; |
| - | opacity: 0.22; | + | |
| transition: opacity 0.2s; | transition: opacity 0.2s; | ||
| } | } | ||
| - | .ut-item: | + | .ut-item: |
| .ut-item.selected .ut-item-bg { opacity: 0.38; } | .ut-item.selected .ut-item-bg { opacity: 0.38; } | ||
| .ut-item-overlay { | .ut-item-overlay { | ||
| - | position: absolute; | + | position: absolute; inset: 0; |
| - | | + | background: linear-gradient(90deg, |
| - | background: linear-gradient(90deg, | + | |
| } | } | ||
| Line 256: | Line 214: | ||
| display: flex; | display: flex; | ||
| align-items: | align-items: | ||
| - | gap: 14px; | + | gap: 16px; |
| - | padding: 0 20px; | + | padding: 0 22px; |
| width: 100%; | width: 100%; | ||
| height: 100%; | height: 100%; | ||
| Line 263: | Line 221: | ||
| .ut-item-icon { | .ut-item-icon { | ||
| - | font-size: | + | font-size: |
| color: #9D85DB; | color: #9D85DB; | ||
| flex-shrink: | flex-shrink: | ||
| transition: color 0.2s, font-size 0.2s; | transition: color 0.2s, font-size 0.2s; | ||
| } | } | ||
| - | .ut-item.selected .ut-item-icon { color: #C8BFED; font-size: | + | .ut-item.selected .ut-item-icon |
| - | .ut-item.is-sub .ut-item-icon { font-size: | + | .ut-item.is-sub .ut-item-icon |
| - | .ut-item.is-sub.selected .ut-item-icon { font-size: | + | .ut-item.is-sub.selected .ut-item-icon { font-size: |
| .ut-item-text { flex: 1; min-width: 0; } | .ut-item-text { flex: 1; min-width: 0; } | ||
| .ut-item-label { | .ut-item-label { | ||
| - | font-size: | + | font-size: |
| font-weight: | font-weight: | ||
| color: #C8BFED; | color: #C8BFED; | ||
| Line 282: | Line 240: | ||
| transition: color 0.2s, font-size 0.2s; | transition: color 0.2s, font-size 0.2s; | ||
| } | } | ||
| - | .ut-item.selected .ut-item-label { color: #EDE8F5; font-size: | + | .ut-item.selected .ut-item-label |
| - | .ut-item.is-sub .ut-item-label { font-size: 13px; color: #9D85DB; font-weight: | + | .ut-item.is-sub .ut-item-label |
| .ut-item.is-sub.selected .ut-item-label { color: #C8BFED; font-size: 14px; } | .ut-item.is-sub.selected .ut-item-label { color: #C8BFED; font-size: 14px; } | ||
| Line 289: | Line 247: | ||
| font-size: 11px; | font-size: 11px; | ||
| color: #6B4FBB; | color: #6B4FBB; | ||
| - | margin-top: | + | margin-top: |
| - | transition: opacity 0.2s; | + | |
| } | } | ||
| .ut-item-accent-bar { | .ut-item-accent-bar { | ||
| position: absolute; | position: absolute; | ||
| - | left: 0; | + | left: 0; top: 0; bottom: 0; |
| - | | + | |
| - | | + | |
| width: 3px; | width: 3px; | ||
| - | border-radius: | + | border-radius: |
| opacity: 0; | opacity: 0; | ||
| transition: opacity 0.2s; | transition: opacity 0.2s; | ||
| Line 306: | Line 261: | ||
| /* accent colors */ | /* accent colors */ | ||
| - | .ut-acc-movement .ut-item-bg { background: linear-gradient(90deg,# | + | .ut-acc-movement .ut-item-bg |
| .ut-acc-movement .ut-item-accent-bar { background: #9D85DB; } | .ut-acc-movement .ut-item-accent-bar { background: #9D85DB; } | ||
| - | .ut-acc-combat .ut-item-bg { background: linear-gradient(90deg,# | + | .ut-acc-combat .ut-item-bg |
| - | .ut-acc-combat .ut-item-accent-bar { background: #DB85AD; } | + | .ut-acc-combat .ut-item-accent-bar |
| - | .ut-acc-items .ut-item-bg { background: linear-gradient(90deg,# | + | .ut-acc-items .ut-item-bg |
| - | .ut-acc-items .ut-item-accent-bar { background: #85CADB; } | + | .ut-acc-items .ut-item-accent-bar |
| - | .ut-acc-map .ut-item-bg { background: linear-gradient(90deg,# | + | .ut-acc-map .ut-item-bg |
| - | .ut-acc-map .ut-item-accent-bar { background: #85DBA8; } | + | .ut-acc-map .ut-item-accent-bar |
| .ut-acc-mechanics .ut-item-bg { background: linear-gradient(90deg,# | .ut-acc-mechanics .ut-item-bg { background: linear-gradient(90deg,# | ||
| - | .ut-acc-mechanics .ut-item-accent-bar { background: #DBC085; } | + | .ut-acc-mechanics .ut-item-accent-bar{ background: #DBC085; } |
| .ut-acc-abilities .ut-item-bg { background: linear-gradient(90deg,# | .ut-acc-abilities .ut-item-bg { background: linear-gradient(90deg,# | ||
| - | .ut-acc-abilities .ut-item-accent-bar { background: #C085DB; } | + | .ut-acc-abilities .ut-item-accent-bar{ background: #C085DB; } |
| - | /* search | + | /* search */ |
| # | # | ||
| position: absolute; | position: absolute; | ||
| - | top: 28px; | + | top: 24px; right: 0; |
| - | | + | width: |
| - | width: | + | |
| z-index: 20; | z-index: 20; | ||
| - | padding-right: | ||
| } | } | ||
| #ut-search { | #ut-search { | ||
| width: 100%; | width: 100%; | ||
| - | background: rgba(10, | + | background: rgba(10, |
| border: 1px solid rgba(108, | border: 1px solid rgba(108, | ||
| border-right: | border-right: | ||
| Line 350: | Line 303: | ||
| <div id=" | <div id=" | ||
| <video autoplay muted loop playsinline id=" | <video autoplay muted loop playsinline id=" | ||
| - | <source src="/ | + | <source src="/ |
| </ | </ | ||
| </ | </ | ||
| - | <!-- LEFT CONTENT --> | ||
| <div id=" | <div id=" | ||
| <div id=" | <div id=" | ||
| Line 360: | Line 312: | ||
| <div id=" | <div id=" | ||
| <div id=" | <div id=" | ||
| - | <div id=" | ||
| </ | </ | ||
| <div id=" | <div id=" | ||
| Line 371: | Line 322: | ||
| </ | </ | ||
| - | <!-- RIGHT CAROUSEL --> | ||
| <div id=" | <div id=" | ||
| <div id=" | <div id=" | ||
| Line 380: | Line 330: | ||
| </ | </ | ||
| - | |||
| < | < | ||
| (function(){ | (function(){ | ||
| Line 386: | Line 335: | ||
| const CATS = [ | const CATS = [ | ||
| { id:' | { id:' | ||
| - | desc:' | + | desc:' |
| wikiId:' | wikiId:' | ||
| subs:[ | subs:[ | ||
| - | {id:'bunnyhop', | + | {id:'walking', |
| - | {id:'airstrafing', | + | {id:'sprinting', label:' |
| - | {id:'walljump', | + | {id:'crouching', label:' |
| - | {id:'slidehop', | + | {id:'sliding', |
| - | {id:'zipline', | + | {id:' |
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:'wallstrafe', | ||
| + | ]}, | ||
| + | { id:' | ||
| + | | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| ]}, | ]}, | ||
| - | { id:'combat', icon:' | + | { id:'ziplines', icon:' |
| - | desc:'Engagement techniques | + | desc:'Zipline techs — momentum conservation, cancels, jumps, and launch techniques.', |
| - | wikiId:' | + | wikiId:' |
| subs:[ | subs:[ | ||
| - | {id:'peekshoot', | + | {id:'zmc', |
| - | {id:'jumpshoot', | + | {id:' |
| - | {id:'slideshoot', | + | {id:'zipjump', label:' |
| - | {id:'reloadcancel', | + | {id:'zipdash', label:' |
| + | {id:'ziplaunch', | ||
| ]}, | ]}, | ||
| - | { id:'items', icon:' | + | { id:'ropes', icon:' |
| - | desc:'Hidden mechanics | + | desc:'Rope techs — cancels, launches, |
| - | wikiId:' | + | wikiId:' |
| subs:[ | subs:[ | ||
| - | {id:'warpstone', | + | {id:'ropecancel', label:' |
| - | {id:'coldfront', | + | {id:'ropelaunch', label:' |
| - | {id:'magiccarpet', | + | {id:'ropewj', |
| - | {id:' | + | |
| ]}, | ]}, | ||
| - | { id:'map', icon:' | + | { id:'vent', icon:' |
| - | desc:'Geometry exploits, map-specific routes, | + | desc:'Vent interactions — boost techs and stun mechanics.', |
| - | wikiId:' | + | wikiId:' |
| subs:[ | subs:[ | ||
| - | {id:'roofjumps', | + | {id:'ventboost', label:' |
| - | {id:'shortcuts', | + | {id:'ventstun', |
| - | {id:' | + | |
| ]}, | ]}, | ||
| - | { id:'mechanics', icon:' | + | { id:'melee', icon:' |
| - | desc:'Engine | + | desc:'Melee techs — light, heavy, parry, cancels, |
| - | wikiId:' | + | wikiId:' |
| subs:[ | subs:[ | ||
| - | {id:'animation', | + | {id:'lightmelee', label:' |
| - | {id:'momentum', label:' | + | {id:'heavymelee', label:' |
| - | {id:'phasing', | + | {id:'parry', |
| + | {id:' | ||
| + | {id:' | ||
| + | {id:' | ||
| ]}, | ]}, | ||
| - | { id:'abilities', icon:' | + | { id:'walljump', icon:' |
| - | desc:'Universal interactions between abilities, items, and game systems.', | + | desc:'Wall jump techs — directional control, corner and edge boosts, surf, and fatigue management.', |
| - | wikiId:' | + | wikiId:' |
| subs:[ | subs:[ | ||
| - | {id:'quickcast', label:' | + | {id:'wjd', |
| - | {id:'buffstack', label:' | + | {id:' |
| - | {id:'canceltech', | + | {id:' |
| + | {id:' | ||
| + | {id:'wjfatigue', | ||
| + | {id:' | ||
| + | ]}, | ||
| + | { id:' | ||
| + | desc:' | ||
| + | wikiId:' | ||
| + | subs:[ | ||
| + | {id:' | ||
| + | {id:'bunnyhop', | ||
| + | {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 ────────────────────────────────────────────── | // ── STATE ────────────────────────────────────────────── | ||
| Line 443: | Line 453: | ||
| let activeSubId = null; | let activeSubId = null; | ||
| let filter = ''; | 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 ────────────────────────────────────── | // ── FLAT ITEM LIST ────────────────────────────────────── | ||
| - | // Builds the ordered list of items to render in the carousel | ||
| function buildItemList() { | function buildItemList() { | ||
| const items = []; | const items = []; | ||
| const q = filter.toLowerCase(); | const q = filter.toLowerCase(); | ||
| CATS.forEach(cat => { | CATS.forEach(cat => { | ||
| - | const subMatch = cat.subs.filter(s => !q || s.label.toLowerCase().includes(q) || cat.label.toLowerCase().includes(q)); | + | const subMatch = cat.subs.filter(s => |
| + | | ||
| + | | ||
| if (q && subMatch.length === 0 && !cat.label.toLowerCase().includes(q)) return; | if (q && subMatch.length === 0 && !cat.label.toLowerCase().includes(q)) return; | ||
| items.push({ type:' | items.push({ type:' | ||
| if (activeCatId === cat.id) { | if (activeCatId === cat.id) { | ||
| - | | + | |
| - | subs.forEach(sub => items.push({ type:' | + | |
| } | } | ||
| }); | }); | ||
| Line 461: | Line 486: | ||
| } | } | ||
| - | // ── | + | // ── |
| - | const ITEM_H | + | function computePositions(items) { |
| - | const ITEM_H_SEL | + | let y = 0; |
| - | const GAP = 6; | + | |
| - | const INDENT_SUB | + | const isSel = item.type === ' |
| - | const CAROUSEL_AREA_H | + | ? activeCatId |
| - | + | : activeSubId === item.sub.id; | |
| - | // scroll offset | + | const h = item.type === 'cat' |
| - | let scrollY | + | const pos = { y, h, isSel }; |
| - | let velocity | + | y += h + GAP; |
| - | let rafId = null; | + | |
| + | }); | ||
| + | } | ||
| // ── RENDER ───────────────────────────────────────────── | // ── RENDER ───────────────────────────────────────────── | ||
| function render() { | function render() { | ||
| const carousel = document.getElementById(' | const carousel = document.getElementById(' | ||
| - | const items = buildItemList(); | + | |
| - | const wrapH = CAROUSEL_AREA_H(); | + | |
| + | const pos | ||
| + | const totalH | ||
| - | | + | // clamp scroll: allow scrolling through the whole list |
| - | const positions = []; | + | const minScroll = -(wrapH / 2); |
| - | let y = 0; | + | const maxScroll = totalH - wrapH / 2; |
| - | items.forEach((item, | + | |
| - | const isSel = item.type===' | + | |
| - | ? activeCatId===item.cat.id && activeSubId===null | + | |
| - | : activeSubId===item.sub.id; | + | |
| - | const h = isSel ? ITEM_H_SEL : ITEM_H; | + | |
| - | positions.push({ y, h, isSel }); | + | |
| - | y += h + GAP; | + | |
| - | }); | + | |
| - | const totalH = y; | + | |
| - | + | ||
| - | | + | |
| - | const minScroll = 0; | + | |
| - | const maxScroll = Math.max(0, | + | |
| scrollY = Math.max(minScroll, | scrollY = Math.max(minScroll, | ||
| - | // center selected item if exists | + | // track which uids exist |
| - | // (auto-scroll is done on select, not on every render) | + | |
| - | + | ||
| - | // remove old els that no longer match | + | |
| const existingEls = {}; | const existingEls = {}; | ||
| - | carousel.querySelectorAll(' | + | carousel.querySelectorAll(' |
| - | | + | |
| - | | + | |
| const newUids = new Set(); | const newUids = new Set(); | ||
| - | items.forEach((item, i) => { | + | items.forEach(item => newUids.add(uid(item))); |
| - | const uid = item.type===' | + | |
| - | | + | |
| - | }); | + | |
| - | // remove stale | + | // viewport center |
| - | | + | |
| - | if (!newUids.has(uid)) existingEls[uid].remove(); | + | |
| - | }); | + | |
| - | // upsert items | ||
| items.forEach((item, | items.forEach((item, | ||
| - | const uid = item.type===' | + | const u = uid(item); |
| - | const { y: iy, h, isSel } = positions[i]; | + | const { y: iy, h, isSel } = pos[i]; |
| - | const screenY = iy - scrollY + wrapH/2 - totalH/2; | + | |
| - | // osu-style: items further from center | + | // position relative to viewport |
| - | const centerY | + | const screenY |
| - | const itemCenterY | + | const itemCenter |
| - | const dist = Math.abs(itemCenterY - centerY); | + | const distFromCenter = itemCenter - vc; |
| - | const maxDist | + | const absD = Math.abs(distFromCenter); |
| - | const pushRight | + | const maxD = wrapH * 0.52; |
| + | |||
| + | // osu curve: items further away pushed right | ||
| + | const pushFrac | ||
| + | // 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[uid]; | + | let el = existingEls[u]; |
| if (!el) { | if (!el) { | ||
| el = document.createElement(' | el = document.createElement(' | ||
| - | | + | el.dataset.uid = u; |
| - | | + | el.innerHTML = buildInner(item); |
| - | el.innerHTML = buildItemHTML(item); | + | |
| el.addEventListener(' | el.addEventListener(' | ||
| carousel.appendChild(el); | carousel.appendChild(el); | ||
| } | } | ||
| - | // update classes | ||
| el.className = ' | el.className = ' | ||
| - | (item.type===' | + | (item.type === ' |
| - | | + | ' ut-acc-' |
| (isSel ? ' selected' | (isSel ? ' selected' | ||
| - | el.style.top = screenY + ' | + | el.style.top |
| - | el.style.height = h + ' | + | el.style.height |
| - | el.style.transform = `translateX(${pushRight}px)`; | + | el.style.transform = `translateX(${push}px)`; |
| - | el.style.opacity = dist > wrapH * 0.85 ? ' | + | el.style.opacity = opacity.toFixed(3); |
| }); | }); | ||
| - | // set carousel height so scroll math works | ||
| carousel.style.height = wrapH + ' | carousel.style.height = wrapH + ' | ||
| } | } | ||
| - | function | + | function |
| + | return item.type === ' | ||
| + | } | ||
| + | |||
| + | function buildInner(item) { | ||
| if (item.type === ' | if (item.type === ' | ||
| - | return ` | + | return `<div class=" |
| - | | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | <i class=" |
| - | <i class=" | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | </ |
| - | </ | + | <i class=" |
| - | <i class=" | + | </ |
| - | </ | + | |
| } else { | } else { | ||
| - | return ` | + | return `<div class=" |
| - | | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | <i class=" |
| - | <i class=" | + | <div class=" |
| - | <div class=" | + | <div class=" |
| - | <div class=" | + | </ |
| - | </ | + | <i class=" |
| - | <i class=" | + | </ |
| - | </ | + | |
| } | } | ||
| } | } | ||
| - | // ── CLICK HANDLERS | + | // ── CLICK ────────────────────────────────────────────── |
| function onItemClick(item) { | function onItemClick(item) { | ||
| if (item.type === ' | if (item.type === ' | ||
| Line 606: | Line 623: | ||
| function scrollToSelected() { | function scrollToSelected() { | ||
| - | // recalc positions after state change | ||
| const items = buildItemList(); | const items = buildItemList(); | ||
| - | const wrapH = CAROUSEL_AREA_H(); | + | const pos = computePositions(items); |
| - | | + | |
| - | let selY = null, selH = ITEM_H; | + | let selY = null, selH = CAT_H; |
| - | items.forEach(item => { | + | items.forEach((item, i) => { |
| - | const isSel = item.type===' | + | const isSel = item.type === ' |
| - | ? activeCatId===item.cat.id && activeSubId===null | + | ? activeCatId === item.cat.id && activeSubId === null |
| - | : activeSubId===item.sub? | + | : activeSubId === item.sub? |
| - | const h = isSel ? ITEM_H_SEL : ITEM_H; | + | if (isSel) { selY = pos[i].y; selH = pos[i].h; } |
| - | if (isSel) { selY = y; selH = h; } | + | |
| - | y += h + GAP; | + | |
| }); | }); | ||
| - | const totalH = y; | ||
| if (selY !== null) { | if (selY !== null) { | ||
| - | | + | |
| - | const target = selY + selH/2 - totalH/2; | + | |
| - | animateScrollTo(target); | + | |
| } | } | ||
| } | } | ||
| - | function | + | // Spring scroll — sets velocity toward target naturally instead of teleporting |
| - | | + | function |
| - | | + | |
| - | const dur = 380; | + | momentumRunning |
| - | const t0 = performance.now(); | + | |
| - | function step(now) { | + | const SPRING |
| - | | + | const DAMP = 0.72; // damping to prevent overshoot |
| - | const ease = p < 0.5 ? 2*p*p : -1+(4-2*p)*p; | + | let springRunning = true; |
| - | | + | let sv = 0; // spring velocity separate from main velocity |
| + | function step() { | ||
| + | | ||
| + | const diff = target - scrollY; | ||
| + | if (Math.abs(diff) | ||
| + | scrollY = target; | ||
| + | render(); | ||
| + | springRunning = false; | ||
| + | return; | ||
| + | } | ||
| + | sv += diff * SPRING; | ||
| + | | ||
| + | scrollY | ||
| render(); | render(); | ||
| - | | + | requestAnimationFrame(step); |
| } | } | ||
| requestAnimationFrame(step); | requestAnimationFrame(step); | ||
| Line 650: | Line 673: | ||
| content.classList.add(' | content.classList.add(' | ||
| } | } | ||
| - | |||
| function showCat(cat) { | function showCat(cat) { | ||
| splash.classList.add(' | splash.classList.add(' | ||
| content.classList.remove(' | content.classList.remove(' | ||
| - | document.getElementById(' | + | document.getElementById(' |
| document.getElementById(' | document.getElementById(' | ||
| - | document.getElementById(' | + | document.getElementById(' |
| - | | + | document.getElementById(' |
| - | grid.innerHTML = ''; | + | document.getElementById(' |
| - | | + | |
| - | const a = document.createElement(' | + | |
| - | a.className = ' | + | |
| - | a.href = '/ | + | |
| - | a.innerHTML = `<div class=" | + | |
| - | a.addEventListener(' | + | |
| - | grid.appendChild(a); | + | |
| - | }); | + | |
| - | const btn = document.getElementById(' | + | |
| - | btn.href = '/ | + | |
| document.getElementById(' | document.getElementById(' | ||
| } | } | ||
| - | |||
| function showSub(cat, | function showSub(cat, | ||
| splash.classList.add(' | splash.classList.add(' | ||
| content.classList.remove(' | content.classList.remove(' | ||
| - | document.getElementById(' | + | document.getElementById(' |
| document.getElementById(' | document.getElementById(' | ||
| - | document.getElementById(' | + | document.getElementById(' |
| - | | + | document.getElementById(' |
| - | grid.innerHTML = ''; | + | document.getElementById(' |
| - | | + | |
| - | const a = document.createElement(' | + | |
| - | a.className = ' | + | |
| - | a.href = '/ | + | |
| - | a.innerHTML = `<div class=" | + | |
| - | grid.appendChild(a); | + | |
| - | }); | + | |
| - | const btn = document.getElementById(' | + | |
| - | btn.href = '/ | + | |
| document.getElementById(' | document.getElementById(' | ||
| } | } | ||
| - | // ── | + | // ── |
| - | const pillsEl = document.getElementById(' | + | |
| - | CATS.forEach(cat => { | + | |
| - | const p = document.createElement(' | + | |
| - | p.className = ' | + | |
| - | p.textContent = cat.label; | + | |
| - | p.addEventListener(' | + | |
| - | pillsEl.appendChild(p); | + | |
| - | }); | + | |
| - | + | ||
| - | // ── MOMENTUM SCROLL | + | |
| const wrap = document.getElementById(' | const wrap = document.getElementById(' | ||
| - | let isDragging | + | let momentumRunning |
| - | let lastY = 0; | + | |
| - | let lastTime = 0; | + | |
| + | // 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(' | wrap.addEventListener(' | ||
| - | | + | |
| - | lastY = e.clientY; | + | |
| - | lastTime = performance.now(); | + | |
| velocity = 0; | velocity = 0; | ||
| - | | + | |
| + | wrap.style.cursor = ' | ||
| e.preventDefault(); | e.preventDefault(); | ||
| }); | }); | ||
| - | |||
| window.addEventListener(' | window.addEventListener(' | ||
| if (!isDragging) return; | if (!isDragging) return; | ||
| const now = performance.now(); | const now = performance.now(); | ||
| - | const dy = e.clientY - lastY; | + | const dy = e.clientY - lastDragY; |
| - | | + | |
| - | scrollY -= dy; | + | velocity |
| - | | + | scrollY |
| - | lastTime | + | |
| render(); | render(); | ||
| }); | }); | ||
| - | |||
| window.addEventListener(' | window.addEventListener(' | ||
| if (!isDragging) return; | if (!isDragging) return; | ||
| isDragging = false; | isDragging = false; | ||
| + | wrap.style.cursor = ' | ||
| startMomentum(); | startMomentum(); | ||
| }); | }); | ||
| - | wrap.addEventListener(' | + | // TOUCH |
| - | e.preventDefault(); | + | |
| - | scrollY += e.deltaY * 0.8; | + | |
| - | velocity = 0; | + | |
| - | render(); | + | |
| - | }, { passive: false }); | + | |
| - | + | ||
| - | // touch | + | |
| wrap.addEventListener(' | wrap.addEventListener(' | ||
| - | | + | |
| - | | + | |
| - | lastTime | + | |
| - | velocity = 0; | + | |
| - | cancelAnimationFrame(rafId); | + | |
| }, { passive: true }); | }, { passive: true }); | ||
| - | |||
| wrap.addEventListener(' | wrap.addEventListener(' | ||
| if (!isDragging) return; | if (!isDragging) return; | ||
| const now = performance.now(); | const now = performance.now(); | ||
| - | const dy = e.touches[0].clientY - lastY; | + | const dy = e.touches[0].clientY - lastDragY; |
| - | velocity = dy / (now - lastTime + 1) * 16; | + | velocity |
| - | scrollY -= dy; | + | scrollY |
| - | | + | |
| - | lastTime | + | |
| render(); | render(); | ||
| }, { passive: true }); | }, { passive: true }); | ||
| - | |||
| wrap.addEventListener(' | wrap.addEventListener(' | ||
| - | |||
| - | function startMomentum() { | ||
| - | cancelAnimationFrame(rafId); | ||
| - | function step() { | ||
| - | if (Math.abs(velocity) < 0.3) return; | ||
| - | scrollY -= velocity; | ||
| - | velocity *= 0.92; | ||
| - | render(); | ||
| - | rafId = requestAnimationFrame(step); | ||
| - | } | ||
| - | rafId = requestAnimationFrame(step); | ||
| - | } | ||
| // ── SEARCH ───────────────────────────────────────────── | // ── SEARCH ───────────────────────────────────────────── | ||
| Line 780: | Line 778: | ||
| render(); | render(); | ||
| }); | }); | ||
| - | |||
| - | // ── VIDEO CYCLE ───────────────────────────────────────── | ||
| - | const video = document.getElementById(' | ||
| - | const vids = [ | ||
| - | '/ | ||
| - | '/ | ||
| - | '/ | ||
| - | '/ | ||
| - | ]; | ||
| - | let vi = 0; | ||
| - | video.addEventListener(' | ||
| // ── INIT ─────────────────────────────────────────────── | // ── INIT ─────────────────────────────────────────────── | ||
| window.addEventListener(' | window.addEventListener(' | ||
| + | // start scroll so first item is near center | ||
| + | scrollY = -(document.getElementById(' | ||
| render(); | render(); | ||