@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
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>