UNPKG

@fxshelf/card-orbit

Version:

Path-constrained 3D card orbit carousel for React and Vue — rise, arc, exit. No Framer Motion / GSAP. Copy-as-source friendly.

509 lines (482 loc) • 14.4 kB
<!doctype html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>轨道坐标演示 — card-orbit</title> <style> :root { --ink: #0a0a0a; --muted: #6b7280; --accent: #ff6b00; --rise: #cc5400; --arc: #0a0a0a; --exit: #6b7280; --paper: #f7f5f1; --bg: #efece6; } * { box-sizing: border-box; } body { margin: 0; color: var(--ink); font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; background: radial-gradient(ellipse 70% 40% at 15% 0%, rgba(255, 107, 0, 0.07), transparent 50%), var(--bg); -webkit-font-smoothing: antialiased; } main { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; } a { color: var(--rise); } .eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--rise); } h1 { margin: 0.6rem 0 0; font-family: Georgia, 'Times New Roman', serif; font-size: clamp(1.75rem, 4vw, 2.4rem); font-weight: 600; letter-spacing: -0.02em; line-height: 1.15; } .lede { margin: 1rem 0 0; max-width: 36rem; color: #525252; font-size: 15px; line-height: 1.65; } .grid { display: grid; gap: 2rem; margin-top: 2.25rem; } @media (min-width: 900px) { .grid { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr); gap: 2.5rem; align-items: start; } } .seg { display: flex; gap: 0.75rem; align-items: baseline; margin: 0.65rem 0; opacity: 0.42; transition: opacity 0.2s; } .seg.active { opacity: 1; } .dot { width: 8px; height: 8px; margin-top: 0.4rem; border-radius: 50%; flex-shrink: 0; } .seg strong { display: block; font-size: 15px; } .seg span { font-size: 13px; color: var(--muted); } .toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-top: 1.5rem; } button { border: 0; border-radius: 6px; background: var(--ink); color: #fff; font-size: 13px; font-weight: 500; padding: 0.55rem 0.9rem; cursor: pointer; } button:hover { background: #222; } .meta { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); } .panel { overflow: hidden; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 16px; background: var(--paper); box-shadow: 0 24px 60px rgba(20, 20, 20, 0.08); } svg { display: block; width: 100%; height: auto; } .note { margin: 0.75rem 0 0; font-size: 12px; line-height: 1.55; color: var(--muted); } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; color: var(--ink); } nav { margin-bottom: 1.5rem; font-size: 13px; color: var(--muted); } </style> </head> <body> <main> <nav> <a href="./动效说明-大白话.md">← 动效说明(大白话)</a> · <a href="./动效说明-专业.md">专业版</a> · <a href="../README.md">README</a> </nav> <p class="eyebrow">Path coordinates</p> <h1>坐标轴上的三条弯道</h1> <p class="lede"> 卡片不自由飞——每一帧只在共用进度 <code>local ∈ [0,1]</code> 上取样。下图把舞台中心当作原点,画出真实路径的 <code>x%</code> / <code>y%</code> 投影,以及示踪点沿轨运动(与 <code>src/core/path.ts</code> 同一套公式)。 </p> <div class="grid"> <div> <div id="segs"> <div class="seg" data-seg="rise"> <i class="dot" style="background: var(--rise)"></i> <div> <strong>① 上升</strong> <span>竖直爬上来</span> </div> </div> <div class="seg" data-seg="arc"> <i class="dot" style="background: var(--arc)"></i> <div> <strong>② 拐弯</strong> <span>四分之一圆弧</span> </div> </div> <div class="seg" data-seg="exit"> <i class="dot" style="background: var(--exit)"></i> <div> <strong>③ 退出</strong> <span>水平滑出</span> </div> </div> </div> <div class="toolbar"> <button type="button" id="toggle">暂停示踪点</button> <p class="meta" id="meta">local = 0.000 · depth = 0.00</p> </div> <p class="note"> 示踪点大小随 <code>depth</code> 变化。真实舞台还有 translateZ / rotate / scale,但平面轨迹就是这条折线。 </p> </div> <div> <div class="panel"> <svg id="canvas" role="img" aria-label="Card orbit path on XY axes"></svg> </div> </div> </div> </main> <script> // Mirrored from src/core/path.ts const RISE = 280 const ARC = (Math.PI / 2) * 90 const EXIT = 240 const PATH_LEN = RISE + ARC + EXIT const F = RISE / PATH_LEN const G = (RISE + ARC) / PATH_LEN function samplePath(local, active) { const fade = active ? Math.min( Math.min(1, Math.max(0, local / 0.06)), Math.min(1, Math.max(0, (1 - local) / 0.08)), ) : 0 const depth = active ? Math.sin( (local < F ? local / (2 * F) : 0.5 + (local - F) / (2 * (1 - F))) * Math.PI, ) : 0 let x let y if (local < F) { x = -10 y = -300 + (local / F) * RISE } else if (local < G) { const ang = (180 + ((local - F) / (G - F)) * 90) * (Math.PI / 180) x = 80 + 90 * Math.cos(ang) y = -20 - 90 * Math.sin(ang) } else { x = 80 + ((local - G) / (1 - G)) * EXIT y = 70 } return { x, y, depth, fade, local, active } } const toSvg = (x, y) => ({ sx: x, sy: -y }) const segmentOf = (local) => (local < F ? 'rise' : local < G ? 'arc' : 'exit') const SAMPLES = 96 const pts = [] for (let i = 0; i <= SAMPLES; i++) { const local = i / SAMPLES const p = samplePath(local, true) pts.push({ x: p.x, y: p.y, local }) } let minX = Infinity let maxX = -Infinity let minSy = Infinity let maxSy = -Infinity for (const p of pts) { const { sx, sy } = toSvg(p.x, p.y) minX = Math.min(minX, sx) maxX = Math.max(maxX, sx) minSy = Math.min(minSy, sy) maxSy = Math.max(maxSy, sy) } const pad = 36 const bounds = { minX: minX - pad, minSy: minSy - pad, w: maxX - minX + pad * 2, h: maxSy - minSy + pad * 2, } function polyFor(pred) { return pts .filter(pred) .map((p) => { const { sx, sy } = toSvg(p.x, p.y) return `${sx},${sy}` }) .join(' ') } const risePts = pts.filter((p) => p.local <= F) const arcPts = pts.filter((p) => p.local >= F && p.local <= G) const exitPts = pts.filter((p) => p.local >= G) // stitch const join = (a, b) => { if (!a.length || !b.length) return b const last = a[a.length - 1] const { sx, sy } = toSvg(last.x, last.y) return [{ x: last.x, y: last.y, local: last.local, _s: `${sx},${sy}` }, ...b] } const arcJoined = join(risePts, arcPts) const exitJoined = join(arcPts, exitPts) const svg = document.getElementById('canvas') svg.setAttribute( 'viewBox', `${bounds.minX} ${bounds.minSy} ${bounds.w} ${bounds.h}`, ) const origin = toSvg(0, 0) const axisX2 = toSvg(bounds.minX + bounds.w - 12, 0) const axisY2 = toSvg(0, -(bounds.minSy + 12)) const NS = 'http://www.w3.org/2000/svg' const el = (name, attrs = {}) => { const node = document.createElementNS(NS, name) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v) return node } svg.appendChild( el('rect', { x: bounds.minX, y: bounds.minSy, width: bounds.w, height: bounds.h, fill: 'rgba(255,107,0,0.04)', }), ) for (const gx of [-200, -100, 0, 100, 200, 300]) { svg.appendChild( el('line', { x1: gx, y1: bounds.minSy, x2: gx, y2: bounds.minSy + bounds.h, stroke: 'rgba(10,10,10,0.06)', 'stroke-width': 0.8, }), ) } for (const gy of [-300, -200, -100, 0, 100]) { const sy = -gy svg.appendChild( el('line', { x1: bounds.minX, y1: sy, x2: bounds.minX + bounds.w, y2: sy, stroke: 'rgba(10,10,10,0.06)', 'stroke-width': 0.8, }), ) } svg.appendChild( el('line', { x1: bounds.minX + 8, y1: origin.sy, x2: axisX2.sx, y2: origin.sy, stroke: '#0a0a0a', 'stroke-width': 1.4, 'stroke-linecap': 'round', }), ) svg.appendChild( el('line', { x1: origin.sx, y1: bounds.minSy + bounds.h - 8, x2: origin.sx, y2: axisY2.sy, stroke: '#0a0a0a', 'stroke-width': 1.4, 'stroke-linecap': 'round', }), ) svg.appendChild( el('polygon', { points: `${axisX2.sx},${axisX2.sy} ${axisX2.sx - 7},${axisX2.sy - 4} ${axisX2.sx - 7},${axisX2.sy + 4}`, fill: '#0a0a0a', }), ) svg.appendChild( el('polygon', { points: `${axisY2.sx},${axisY2.sy} ${axisY2.sx - 4},${axisY2.sy + 7} ${axisY2.sx + 4},${axisY2.sy + 7}`, fill: '#0a0a0a', }), ) const label = (text, x, y) => { const t = el('text', { x, y, fill: '#0a0a0a', 'font-size': 11, 'font-family': 'ui-monospace, monospace', }) t.textContent = text svg.appendChild(t) } label('x%', axisX2.sx - 14, origin.sy - 8) label('y%', origin.sx + 8, axisY2.sy + 14) svg.appendChild(el('circle', { cx: origin.sx, cy: origin.sy, r: 2.4, fill: '#0a0a0a' })) const originLbl = el('text', { x: origin.sx + 6, y: origin.sy + 14, fill: 'rgba(10,10,10,0.45)', 'font-size': 10, 'font-family': 'ui-monospace, monospace', }) originLbl.textContent = 'origin' svg.appendChild(originLbl) const asPoints = (list) => list .map((p) => { const { sx, sy } = toSvg(p.x, p.y) return `${sx},${sy}` }) .join(' ') svg.appendChild( el('polyline', { points: asPoints(risePts), fill: 'none', stroke: '#cc5400', 'stroke-width': 3.2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', }), ) svg.appendChild( el('polyline', { points: asPoints(arcJoined), fill: 'none', stroke: '#0a0a0a', 'stroke-width': 3.2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', }), ) svg.appendChild( el('polyline', { points: asPoints(exitJoined), fill: 'none', stroke: '#6b7280', 'stroke-width': 3.2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-dasharray': '6 5', }), ) const markerOuter = el('circle', { fill: '#ff6b00', stroke: '#fff', 'stroke-width': 2, }) const markerInner = el('circle', { fill: '#fff', r: 2.2 }) svg.appendChild(markerOuter) svg.appendChild(markerInner) let local = 0 let paused = false const SPEED = 0.18 const meta = document.getElementById('meta') const segs = [...document.querySelectorAll('.seg')] const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches function paint() { const marker = samplePath(local, true) const { sx, sy } = toSvg(marker.x, marker.y) const r = 4 + marker.depth * 10 markerOuter.setAttribute('cx', sx) markerOuter.setAttribute('cy', sy) markerOuter.setAttribute('r', r) markerOuter.setAttribute('opacity', String(0.35 + marker.depth * 0.65)) markerInner.setAttribute('cx', sx) markerInner.setAttribute('cy', sy) meta.textContent = `local = ${local.toFixed(3)} · depth = ${marker.depth.toFixed(2)}` const seg = segmentOf(local) for (const node of segs) { node.classList.toggle('active', node.dataset.seg === seg) } } document.getElementById('toggle').addEventListener('click', (e) => { paused = !paused e.currentTarget.textContent = paused ? '继续动画' : '暂停示踪点' }) let last = performance.now() function tick(now) { if (!paused && !reduce) { const dt = (now - last) / 1000 local = (local + SPEED * dt) % 1 } last = now paint() requestAnimationFrame(tick) } paint() requestAnimationFrame(tick) </script> </body> </html>