一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
1044 lines
33 KiB
HTML
1044 lines
33 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Oil Motion — 动画原理</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
--ink: #191919;
|
||
--paper: #fffefa;
|
||
--muted: #92928e;
|
||
--line: rgba(25, 25, 25, 0.13);
|
||
--grid: rgba(25, 25, 25, 0.05);
|
||
--yellow: #ffe28a;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html {
|
||
min-width: 320px;
|
||
background: var(--paper);
|
||
}
|
||
|
||
body {
|
||
min-width: 320px;
|
||
min-height: 100svh;
|
||
margin: 0;
|
||
overflow-x: hidden;
|
||
color: var(--ink);
|
||
background:
|
||
linear-gradient(var(--grid) 1px, transparent 1px),
|
||
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
|
||
radial-gradient(circle at 8% 4%, rgba(255, 226, 138, 0.24), transparent 28%),
|
||
var(--paper);
|
||
background-size: 44px 44px, 44px 44px, auto, auto;
|
||
font-family:
|
||
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC",
|
||
"Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
button,
|
||
input {
|
||
font: inherit;
|
||
}
|
||
|
||
.shell {
|
||
min-height: 100svh;
|
||
height: 100svh;
|
||
display: grid;
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
gap: 16px;
|
||
padding: 22px;
|
||
}
|
||
|
||
body.is-scroll .shell {
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
|
||
.scroll-spacer {
|
||
display: none;
|
||
height: var(--scroll-height, 300svh);
|
||
pointer-events: none;
|
||
}
|
||
|
||
body.is-scroll .scroll-spacer {
|
||
display: block;
|
||
}
|
||
|
||
.topbar {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
min-width: 0;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.topbar h1 {
|
||
margin: 0;
|
||
font-size: clamp(25px, 2.4vw, 40px);
|
||
font-weight: 760;
|
||
line-height: 1;
|
||
letter-spacing: -0.045em;
|
||
}
|
||
|
||
.topbar p,
|
||
.kicker,
|
||
.readout,
|
||
.formula {
|
||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||
}
|
||
|
||
.topbar p {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
letter-spacing: 0.13em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.workspace {
|
||
min-height: 0;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.panel {
|
||
position: relative;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
border: 1px solid var(--line);
|
||
border-radius: 28px;
|
||
background: rgba(255, 255, 255, 0.82);
|
||
}
|
||
|
||
.source-panel,
|
||
.preview-panel {
|
||
display: grid;
|
||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||
}
|
||
|
||
.panel-head {
|
||
position: relative;
|
||
z-index: 4;
|
||
min-height: 62px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
padding: 14px 20px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.source-tabs {
|
||
display: flex;
|
||
gap: 5px;
|
||
padding: 4px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: rgba(25, 25, 25, 0.035);
|
||
}
|
||
|
||
.source-tab {
|
||
min-height: 30px;
|
||
padding: 0 12px;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
color: var(--muted);
|
||
background: transparent;
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.source-tab[aria-selected="true"] {
|
||
color: var(--ink);
|
||
background: var(--paper);
|
||
box-shadow: 0 1px 5px rgba(25, 25, 25, 0.1);
|
||
}
|
||
|
||
.source-tab:focus-visible,
|
||
.zoom-controls button:focus-visible,
|
||
.zoom-controls input:focus-visible {
|
||
outline: 2px solid var(--ink);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.kicker {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
font-weight: 650;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.readout {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 9px;
|
||
margin-left: auto;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.readout strong {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.readout span {
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
}
|
||
|
||
[hidden] {
|
||
display: none !important;
|
||
}
|
||
|
||
.atlas-viewport {
|
||
position: relative;
|
||
min-height: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.atlas-viewport.dragging {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.atlas-stage {
|
||
position: relative;
|
||
width: min(94%, calc((100svh - 178px) * var(--atlas-ratio)));
|
||
max-height: 94%;
|
||
aspect-ratio: var(--atlas-ratio);
|
||
flex: none;
|
||
transform: translate3d(var(--pan-x, 0), var(--pan-y, 0), 0)
|
||
scale(var(--zoom, 1));
|
||
transform-origin: center;
|
||
will-change: transform;
|
||
}
|
||
|
||
.atlas-stage img {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: fill;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.atlas-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background:
|
||
repeating-linear-gradient(
|
||
90deg,
|
||
transparent 0 calc(var(--cell-pct-x) - 1px),
|
||
rgba(25, 25, 25, 0.09) calc(var(--cell-pct-x) - 1px) var(--cell-pct-x)
|
||
),
|
||
repeating-linear-gradient(
|
||
0deg,
|
||
transparent 0 calc(var(--cell-pct-y) - 1px),
|
||
rgba(25, 25, 25, 0.09) calc(var(--cell-pct-y) - 1px) var(--cell-pct-y)
|
||
);
|
||
}
|
||
|
||
.frame-highlight {
|
||
position: absolute;
|
||
z-index: 3;
|
||
width: var(--cell-pct-x);
|
||
height: var(--cell-pct-y);
|
||
border: calc(3px / var(--zoom, 1)) solid var(--ink);
|
||
background: rgba(255, 226, 138, 0.24);
|
||
box-shadow:
|
||
0 0 0 calc(4px / var(--zoom, 1)) var(--yellow),
|
||
0 0 calc(20px / var(--zoom, 1)) rgba(255, 196, 31, 0.75);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.zoom-controls,
|
||
.video-meta {
|
||
min-height: 58px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
padding: 10px 18px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.zoom-controls button {
|
||
min-width: 36px;
|
||
min-height: 34px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.zoom-controls .reset {
|
||
min-width: 64px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.zoom-controls input {
|
||
width: min(220px, 42%);
|
||
accent-color: var(--ink);
|
||
}
|
||
|
||
.video-view {
|
||
min-height: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
padding: 18px;
|
||
}
|
||
|
||
.video-shell {
|
||
position: relative;
|
||
width: min(84%, 62svh);
|
||
max-height: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: 22px;
|
||
overflow: hidden;
|
||
background: #00ff00;
|
||
}
|
||
|
||
.video-shell video {
|
||
display: block;
|
||
width: 100%;
|
||
max-height: calc(100svh - 190px);
|
||
object-fit: contain;
|
||
}
|
||
|
||
.video-badge {
|
||
position: absolute;
|
||
top: 12px;
|
||
left: 12px;
|
||
z-index: 2;
|
||
padding: 7px 10px;
|
||
border: 1px solid rgba(25, 25, 25, 0.18);
|
||
border-radius: 999px;
|
||
background: rgba(255, 254, 250, 0.9);
|
||
font: 650 10px/1 ui-monospace, Menlo, monospace;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
.video-meta {
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.preview-stage {
|
||
position: relative;
|
||
min-height: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.preview-stage.drag-driver {
|
||
cursor: ew-resize;
|
||
}
|
||
|
||
.preview-stage::before {
|
||
content: "";
|
||
position: absolute;
|
||
width: min(82%, 430px);
|
||
aspect-ratio: 1;
|
||
border: 1px dashed rgba(25, 25, 25, 0.12);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.sprite {
|
||
position: relative;
|
||
z-index: 2;
|
||
width: min(78%, 430px);
|
||
aspect-ratio: var(--cell-ratio);
|
||
background-image: var(--atlas-image);
|
||
background-repeat: no-repeat;
|
||
background-size: calc(var(--columns) * 100%) calc(var(--rows) * 100%);
|
||
filter: drop-shadow(0 18px 22px rgba(25, 25, 25, 0.1));
|
||
will-change: background-position;
|
||
}
|
||
|
||
.target-dot {
|
||
position: absolute;
|
||
z-index: 4;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 3px solid var(--ink);
|
||
border-radius: 50%;
|
||
background: var(--yellow);
|
||
opacity: 0;
|
||
transform: translate(-50%, -50%) scale(0.75);
|
||
transition: opacity 160ms ease, transform 160ms ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.target-dot.visible {
|
||
opacity: 1;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
}
|
||
|
||
.hint {
|
||
position: absolute;
|
||
top: 24px;
|
||
left: 50%;
|
||
z-index: 3;
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
letter-spacing: 0.12em;
|
||
transform: translateX(-50%);
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.formula {
|
||
min-height: 58px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
gap: 9px;
|
||
padding: 12px 18px;
|
||
border-top: 1px solid var(--line);
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
text-align: center;
|
||
}
|
||
|
||
.formula b {
|
||
color: var(--ink);
|
||
font-weight: 650;
|
||
}
|
||
|
||
.formula i {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: var(--yellow);
|
||
box-shadow: 0 0 0 1px var(--ink);
|
||
}
|
||
|
||
.loading-cover {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 20;
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--paper);
|
||
transition: opacity 280ms ease, visibility 280ms ease;
|
||
}
|
||
|
||
.loading-cover::before {
|
||
content: "";
|
||
width: 34px;
|
||
aspect-ratio: 1;
|
||
border: 3px solid rgba(25, 25, 25, 0.12);
|
||
border-top-color: var(--ink);
|
||
border-radius: 50%;
|
||
animation: spin 700ms linear infinite;
|
||
}
|
||
|
||
.loading-cover.ready {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(1turn); }
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.shell,
|
||
body.is-scroll .shell {
|
||
position: relative;
|
||
height: auto;
|
||
min-height: 100svh;
|
||
display: block;
|
||
padding: 14px;
|
||
}
|
||
|
||
body.is-scroll .shell {
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
|
||
.topbar {
|
||
align-items: flex-start;
|
||
margin: 4px 2px 14px;
|
||
}
|
||
|
||
.topbar p {
|
||
display: none;
|
||
}
|
||
|
||
.workspace {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.panel {
|
||
min-height: 70svh;
|
||
border-radius: 22px;
|
||
}
|
||
|
||
.source-panel {
|
||
min-height: 78svh;
|
||
}
|
||
|
||
.atlas-stage {
|
||
width: min(96%, calc(55svh * var(--atlas-ratio)));
|
||
}
|
||
|
||
.preview-panel {
|
||
min-height: 70svh;
|
||
}
|
||
|
||
.sprite {
|
||
width: min(68vw, 390px);
|
||
}
|
||
|
||
body.is-scroll .source-panel {
|
||
display: none;
|
||
}
|
||
|
||
body.is-scroll .workspace {
|
||
height: calc(100svh - 76px);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.loading-cover,
|
||
.target-dot {
|
||
transition: none;
|
||
}
|
||
|
||
.loading-cover::before {
|
||
animation: none;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<script type="application/json" id="motion-config">__MOTION_EXPLAINER_CONFIG__</script>
|
||
<div class="loading-cover" id="loading-cover" aria-hidden="true"></div>
|
||
|
||
<main class="shell">
|
||
<header class="topbar">
|
||
<h1 id="page-title">动画原理展示</h1>
|
||
<p id="driver-summary">Input → Smooth → Frame → Sprite</p>
|
||
</header>
|
||
|
||
<div class="workspace">
|
||
<section class="panel source-panel" aria-label="动画素材">
|
||
<header class="panel-head">
|
||
<div class="source-tabs" role="tablist" aria-label="查看动画素材">
|
||
<button class="source-tab" id="tab-atlas" type="button" role="tab"
|
||
aria-selected="true" aria-controls="panel-atlas">雪碧图</button>
|
||
<button class="source-tab" id="tab-video" type="button" role="tab"
|
||
aria-selected="false" aria-controls="panel-video">母版视频</button>
|
||
</div>
|
||
<div class="readout" id="atlas-readout" aria-live="polite">
|
||
<strong id="frame-number">000</strong>
|
||
<span id="frame-position">ROW 01 · COL 01</span>
|
||
</div>
|
||
<div class="readout" id="video-readout" hidden>
|
||
<strong id="video-time">00:00</strong>
|
||
<span>MASTER</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="atlas-viewport" id="panel-atlas" role="tabpanel"
|
||
aria-labelledby="tab-atlas">
|
||
<div class="atlas-stage" id="atlas-stage">
|
||
<img id="atlas-image" alt="动画完整雪碧图" draggable="false" />
|
||
<div class="atlas-grid" aria-hidden="true"></div>
|
||
<div class="frame-highlight" id="frame-highlight" aria-hidden="true"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="video-view" id="panel-video" role="tabpanel"
|
||
aria-labelledby="tab-video" hidden>
|
||
<div class="video-shell">
|
||
<span class="video-badge">AI MOTION · ORIGINAL</span>
|
||
<video id="source-video" controls muted loop playsinline preload="metadata"></video>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="zoom-controls" id="atlas-controls" aria-label="雪碧图缩放">
|
||
<button id="zoom-out" type="button" aria-label="缩小">−</button>
|
||
<input id="zoom-range" type="range" min="1" max="4" step="0.05"
|
||
value="1" aria-label="雪碧图缩放比例" />
|
||
<button id="zoom-in" type="button" aria-label="放大">+</button>
|
||
<button class="reset" id="zoom-reset" type="button">100%</button>
|
||
</div>
|
||
<div class="video-meta" id="video-meta" hidden>
|
||
<span>AI 生成的动作母版</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel preview-panel" aria-label="实时渲染">
|
||
<header class="panel-head">
|
||
<p class="kicker" id="driver-label">实时渲染</p>
|
||
<div class="readout">
|
||
<strong id="rendered-frame">000</strong>
|
||
<span>RENDERED FRAME</span>
|
||
</div>
|
||
</header>
|
||
<div class="preview-stage" id="preview-stage">
|
||
<p class="hint" id="hint"></p>
|
||
<div class="sprite" id="sprite" role="img" aria-label="实时动画"></div>
|
||
<div class="target-dot" id="target-dot" aria-hidden="true"></div>
|
||
</div>
|
||
<div class="formula" id="formula" aria-label="动画实现原理"></div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
<div class="scroll-spacer" aria-hidden="true"></div>
|
||
|
||
<script>
|
||
(() => {
|
||
const config = JSON.parse(
|
||
document.querySelector("#motion-config").textContent,
|
||
);
|
||
const FRAME_COUNT = config.frameCount;
|
||
const COLUMNS = config.columns;
|
||
const ROWS = config.rows;
|
||
const REST_FRAME = config.restFrame;
|
||
const CIRCULAR = config.driver === "pointer-angle";
|
||
const FULL_TURN = Math.PI * 2;
|
||
const ANGLE_START = (config.angleStartDeg * Math.PI) / 180;
|
||
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
const root = document.documentElement;
|
||
const body = document.body;
|
||
const loadingCover = document.querySelector("#loading-cover");
|
||
const pageTitle = document.querySelector("#page-title");
|
||
const driverSummary = document.querySelector("#driver-summary");
|
||
const driverLabel = document.querySelector("#driver-label");
|
||
const hint = document.querySelector("#hint");
|
||
const formula = document.querySelector("#formula");
|
||
const previewStage = document.querySelector("#preview-stage");
|
||
const sprite = document.querySelector("#sprite");
|
||
const targetDot = document.querySelector("#target-dot");
|
||
const highlight = document.querySelector("#frame-highlight");
|
||
const frameNumber = document.querySelector("#frame-number");
|
||
const framePosition = document.querySelector("#frame-position");
|
||
const renderedFrame = document.querySelector("#rendered-frame");
|
||
const atlasImage = document.querySelector("#atlas-image");
|
||
const atlasViewport = document.querySelector("#panel-atlas");
|
||
const atlasStage = document.querySelector("#atlas-stage");
|
||
const zoomRange = document.querySelector("#zoom-range");
|
||
const zoomReset = document.querySelector("#zoom-reset");
|
||
const sourceVideo = document.querySelector("#source-video");
|
||
const videoTab = document.querySelector("#tab-video");
|
||
const videoPanel = document.querySelector("#panel-video");
|
||
const videoMeta = document.querySelector("#video-meta");
|
||
const videoReadout = document.querySelector("#video-readout");
|
||
const atlasReadout = document.querySelector("#atlas-readout");
|
||
const atlasControls = document.querySelector("#atlas-controls");
|
||
const videoTime = document.querySelector("#video-time");
|
||
const sourceTabs = [...document.querySelectorAll(".source-tab")];
|
||
|
||
const driverCopy = {
|
||
"pointer-angle": {
|
||
label: "POINTER ANGLE",
|
||
hint: "移动指针,观察方向如何映射成帧",
|
||
summary: "Pointer → Angle → Frame → Sprite",
|
||
formula: ["指针方向", "atan2", "smoothDamp", "background-position"],
|
||
},
|
||
"pointer-x": {
|
||
label: "POINTER X",
|
||
hint: "左右移动指针,控制动画进度",
|
||
summary: "Pointer X → Progress → Frame → Sprite",
|
||
formula: ["指针 X", "progress", "smoothDamp", "background-position"],
|
||
},
|
||
drag: {
|
||
label: "DRAG",
|
||
hint: "左右拖拽,逐帧查看动作",
|
||
summary: "Drag → Progress → Frame → Sprite",
|
||
formula: ["拖拽距离", "progress", "smoothDamp", "background-position"],
|
||
},
|
||
scroll: {
|
||
label: "SCROLL",
|
||
hint: "继续向下滚动,时间轴会跟着前进",
|
||
summary: "Scroll → Progress → Frame → Sprite",
|
||
formula: ["滚动进度", "progress", "smoothDamp", "background-position"],
|
||
},
|
||
autoplay: {
|
||
label: "AUTOPLAY",
|
||
hint: "时间轴正在自动播放",
|
||
summary: "Time → Progress → Frame → Sprite",
|
||
formula: ["时间", config.autoplayMode, "frame", "background-position"],
|
||
},
|
||
};
|
||
const copy = driverCopy[config.driver];
|
||
|
||
const clamp = (value, min = 0, max = 1) =>
|
||
Math.min(max, Math.max(min, value));
|
||
const wrap = (value) =>
|
||
((value % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT;
|
||
const shortestDelta = (from, to) => {
|
||
let delta = wrap(to) - wrap(from);
|
||
if (delta > FRAME_COUNT / 2) delta -= FRAME_COUNT;
|
||
if (delta < -FRAME_COUNT / 2) delta += FRAME_COUNT;
|
||
return delta;
|
||
};
|
||
const setTarget = (target) => {
|
||
frameTarget = CIRCULAR
|
||
? frameValue + shortestDelta(frameValue, target)
|
||
: clamp(target, 0, FRAME_COUNT - 1);
|
||
schedule();
|
||
};
|
||
const smoothDamp = (
|
||
current, target, velocity, smoothTime, maxSpeed, deltaTime,
|
||
) => {
|
||
const safeTime = Math.max(0.0001, smoothTime);
|
||
const omega = 2 / safeTime;
|
||
const x = omega * deltaTime;
|
||
const decay = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
|
||
const originalTarget = target;
|
||
const change = clamp(
|
||
current - target,
|
||
-maxSpeed * safeTime,
|
||
maxSpeed * safeTime,
|
||
);
|
||
const limitedTarget = current - change;
|
||
const temp = (velocity + omega * change) * deltaTime;
|
||
let nextVelocity = (velocity - omega * temp) * decay;
|
||
let next = limitedTarget + (change + temp) * decay;
|
||
if ((originalTarget - current > 0) === (next > originalTarget)) {
|
||
next = originalTarget;
|
||
nextVelocity = 0;
|
||
}
|
||
return [next, nextVelocity];
|
||
};
|
||
|
||
let frameTarget = REST_FRAME;
|
||
let frameValue = REST_FRAME;
|
||
let frameVelocity = 0;
|
||
let lastRendered = -1;
|
||
let lastTime = 0;
|
||
let raf = 0;
|
||
let autoStart = 0;
|
||
let dragPreviewId = null;
|
||
let dragStartX = 0;
|
||
let dragStartFrame = REST_FRAME;
|
||
|
||
let zoom = 1;
|
||
let panX = 0;
|
||
let panY = 0;
|
||
let atlasDragId = null;
|
||
let atlasDragX = 0;
|
||
let atlasDragY = 0;
|
||
let atlasPanX = 0;
|
||
let atlasPanY = 0;
|
||
|
||
const setFrame = (frame) => {
|
||
const column = frame % COLUMNS;
|
||
const row = Math.floor(frame / COLUMNS);
|
||
const x = COLUMNS === 1 ? 0 : (column / (COLUMNS - 1)) * 100;
|
||
const y = ROWS === 1 ? 0 : (row / (ROWS - 1)) * 100;
|
||
sprite.style.backgroundPosition = `${x}% ${y}%`;
|
||
highlight.style.left = `${(column / COLUMNS) * 100}%`;
|
||
highlight.style.top = `${(row / ROWS) * 100}%`;
|
||
frameNumber.textContent = String(frame).padStart(3, "0");
|
||
renderedFrame.textContent = String(frame).padStart(3, "0");
|
||
framePosition.textContent =
|
||
`ROW ${String(row + 1).padStart(2, "0")} · COL ${String(column + 1).padStart(2, "0")}`;
|
||
};
|
||
|
||
const schedule = () => {
|
||
if (!raf) raf = requestAnimationFrame(loop);
|
||
};
|
||
|
||
const autoplayTarget = (now) => {
|
||
if (!autoStart) autoStart = now;
|
||
const raw = ((now - autoStart) / 1000) * config.autoplayFps;
|
||
if (config.autoplayMode === "once") {
|
||
return Math.min(raw, FRAME_COUNT - 1);
|
||
}
|
||
if (config.autoplayMode === "pingpong") {
|
||
const span = Math.max(1, (FRAME_COUNT - 1) * 2);
|
||
const phase = raw % span;
|
||
return phase <= FRAME_COUNT - 1 ? phase : span - phase;
|
||
}
|
||
return raw % FRAME_COUNT;
|
||
};
|
||
|
||
const loop = (now) => {
|
||
raf = 0;
|
||
const dt = lastTime ? Math.min((now - lastTime) / 1000, 1 / 30) : 1 / 60;
|
||
lastTime = now;
|
||
|
||
if (config.driver === "autoplay" && !reduceMotion) {
|
||
frameTarget = autoplayTarget(now);
|
||
}
|
||
|
||
if (reduceMotion) {
|
||
frameValue = config.driver === "scroll" ? frameTarget : REST_FRAME;
|
||
frameVelocity = 0;
|
||
} else {
|
||
[frameValue, frameVelocity] = smoothDamp(
|
||
frameValue,
|
||
frameTarget,
|
||
frameVelocity,
|
||
config.smoothTime,
|
||
config.maxFrameSpeed,
|
||
dt,
|
||
);
|
||
}
|
||
|
||
const rendered = CIRCULAR || config.autoplayMode === "loop"
|
||
? wrap(Math.round(frameValue))
|
||
: clamp(Math.round(frameValue), 0, FRAME_COUNT - 1);
|
||
if (rendered !== lastRendered) {
|
||
setFrame(rendered);
|
||
lastRendered = rendered;
|
||
}
|
||
|
||
const moving =
|
||
Math.abs(frameTarget - frameValue) > 0.003 ||
|
||
Math.abs(frameVelocity) > 0.003;
|
||
const keepPlaying =
|
||
config.driver === "autoplay" &&
|
||
!reduceMotion &&
|
||
!(config.autoplayMode === "once" && frameTarget >= FRAME_COUNT - 1);
|
||
if (moving || keepPlaying) raf = requestAnimationFrame(loop);
|
||
};
|
||
|
||
const pointerPosition = (event) => {
|
||
const rect = previewStage.getBoundingClientRect();
|
||
targetDot.style.left = `${clamp(event.clientX - rect.left, 10, rect.width - 10)}px`;
|
||
targetDot.style.top = `${clamp(event.clientY - rect.top, 10, rect.height - 10)}px`;
|
||
targetDot.classList.add("visible");
|
||
return rect;
|
||
};
|
||
|
||
const updatePointer = (event) => {
|
||
if (config.driver !== "pointer-angle" && config.driver !== "pointer-x") return;
|
||
const stageRect = pointerPosition(event);
|
||
if (config.driver === "pointer-x") {
|
||
const progress = clamp((event.clientX - stageRect.left) / stageRect.width);
|
||
setTarget(progress * (FRAME_COUNT - 1));
|
||
return;
|
||
}
|
||
const rect = sprite.getBoundingClientRect();
|
||
const originX = rect.left + rect.width / 2;
|
||
const originY = rect.top + rect.height / 2;
|
||
const angle = Math.atan2(event.clientY - originY, event.clientX - originX);
|
||
const progress = ((angle - ANGLE_START + FULL_TURN) % FULL_TURN) / FULL_TURN;
|
||
setTarget(progress * FRAME_COUNT);
|
||
};
|
||
|
||
const updateScroll = () => {
|
||
if (config.driver !== "scroll") return;
|
||
const maxScroll = Math.max(1, document.documentElement.scrollHeight - innerHeight);
|
||
setTarget(clamp(scrollY / maxScroll) * (FRAME_COUNT - 1));
|
||
};
|
||
|
||
const clampPan = () => {
|
||
const maxX = Math.max(
|
||
0,
|
||
(atlasStage.offsetWidth * zoom - atlasViewport.clientWidth) / 2,
|
||
);
|
||
const maxY = Math.max(
|
||
0,
|
||
(atlasStage.offsetHeight * zoom - atlasViewport.clientHeight) / 2,
|
||
);
|
||
panX = clamp(panX, -maxX, maxX);
|
||
panY = clamp(panY, -maxY, maxY);
|
||
};
|
||
const renderZoom = () => {
|
||
clampPan();
|
||
atlasStage.style.setProperty("--zoom", zoom.toFixed(3));
|
||
atlasStage.style.setProperty("--pan-x", `${panX.toFixed(1)}px`);
|
||
atlasStage.style.setProperty("--pan-y", `${panY.toFixed(1)}px`);
|
||
zoomRange.value = String(zoom);
|
||
zoomReset.textContent = `${Math.round(zoom * 100)}%`;
|
||
};
|
||
const setZoom = (next) => {
|
||
zoom = clamp(next, 1, 4);
|
||
if (zoom === 1) {
|
||
panX = 0;
|
||
panY = 0;
|
||
}
|
||
renderZoom();
|
||
};
|
||
|
||
const selectSource = (source) => {
|
||
const video = source === "video";
|
||
sourceTabs.forEach((tab) => {
|
||
const selected = tab.id === (video ? "tab-video" : "tab-atlas");
|
||
tab.setAttribute("aria-selected", String(selected));
|
||
tab.tabIndex = selected ? 0 : -1;
|
||
});
|
||
atlasViewport.hidden = video;
|
||
videoPanel.hidden = !video;
|
||
atlasReadout.hidden = video;
|
||
videoReadout.hidden = !video;
|
||
atlasControls.hidden = video;
|
||
videoMeta.hidden = !video;
|
||
if (video) sourceVideo.play().catch(() => {});
|
||
else sourceVideo.pause();
|
||
};
|
||
|
||
pageTitle.textContent = config.title;
|
||
document.title = `Oil Motion — ${config.title}`;
|
||
driverSummary.textContent = copy.summary;
|
||
driverLabel.textContent = copy.label;
|
||
hint.textContent = copy.hint;
|
||
formula.innerHTML = copy.formula
|
||
.map((part, index) => `${index ? "<i></i>" : ""}${index === 0 || index === copy.formula.length - 1 ? `<b>${part}</b>` : `<span>${part}</span>`}`)
|
||
.join("");
|
||
|
||
const atlasRatio =
|
||
(COLUMNS * config.cellWidth) / (ROWS * config.cellHeight);
|
||
root.style.setProperty("--atlas-ratio", String(atlasRatio));
|
||
root.style.setProperty("--cell-ratio", String(config.cellWidth / config.cellHeight));
|
||
root.style.setProperty("--columns", String(COLUMNS));
|
||
root.style.setProperty("--rows", String(ROWS));
|
||
root.style.setProperty("--cell-pct-x", `${100 / COLUMNS}%`);
|
||
root.style.setProperty("--cell-pct-y", `${100 / ROWS}%`);
|
||
root.style.setProperty("--atlas-image", `url("${config.atlasUrl.replaceAll('"', '\\"')}")`);
|
||
root.style.setProperty("--scroll-height", `${config.scrollPages * 100}svh`);
|
||
atlasImage.src = config.atlasUrl;
|
||
|
||
if (config.driver === "scroll") {
|
||
body.classList.add("is-scroll");
|
||
addEventListener("scroll", updateScroll, { passive: true });
|
||
}
|
||
if (config.driver === "drag") {
|
||
previewStage.classList.add("drag-driver");
|
||
previewStage.addEventListener("pointerdown", (event) => {
|
||
dragPreviewId = event.pointerId;
|
||
dragStartX = event.clientX;
|
||
dragStartFrame = clamp(frameValue, 0, FRAME_COUNT - 1);
|
||
previewStage.setPointerCapture(event.pointerId);
|
||
});
|
||
previewStage.addEventListener("pointermove", (event) => {
|
||
if (event.pointerId !== dragPreviewId) return;
|
||
const width = Math.max(1, previewStage.clientWidth);
|
||
setTarget(
|
||
dragStartFrame +
|
||
((event.clientX - dragStartX) / width) * (FRAME_COUNT - 1),
|
||
);
|
||
});
|
||
const endPreviewDrag = (event) => {
|
||
if (event.pointerId !== dragPreviewId) return;
|
||
dragPreviewId = null;
|
||
if (previewStage.hasPointerCapture(event.pointerId)) {
|
||
previewStage.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
previewStage.addEventListener("pointerup", endPreviewDrag);
|
||
previewStage.addEventListener("pointercancel", endPreviewDrag);
|
||
}
|
||
|
||
addEventListener("pointermove", updatePointer, { passive: true });
|
||
document.documentElement.addEventListener("pointerleave", () => {
|
||
targetDot.classList.remove("visible");
|
||
});
|
||
addEventListener("resize", () => {
|
||
renderZoom();
|
||
updateScroll();
|
||
});
|
||
|
||
sourceTabs.forEach((tab) => {
|
||
tab.addEventListener("click", () => {
|
||
selectSource(tab.id === "tab-video" ? "video" : "atlas");
|
||
});
|
||
tab.addEventListener("keydown", (event) => {
|
||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||
event.preventDefault();
|
||
const index = sourceTabs.indexOf(tab);
|
||
const next = sourceTabs[(index + 1) % sourceTabs.length];
|
||
next.focus();
|
||
next.click();
|
||
});
|
||
});
|
||
|
||
if (!config.videoUrl) {
|
||
videoTab.hidden = true;
|
||
sourceTabs.splice(sourceTabs.indexOf(videoTab), 1);
|
||
} else {
|
||
sourceVideo.src = config.videoUrl;
|
||
sourceVideo.addEventListener("timeupdate", () => {
|
||
const seconds = Math.max(0, Math.floor(sourceVideo.currentTime || 0));
|
||
videoTime.textContent =
|
||
`${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||
});
|
||
}
|
||
|
||
atlasViewport.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
setZoom(zoom + (event.deltaY > 0 ? -0.16 : 0.16));
|
||
}, { passive: false });
|
||
atlasViewport.addEventListener("pointerdown", (event) => {
|
||
atlasDragId = event.pointerId;
|
||
atlasDragX = event.clientX;
|
||
atlasDragY = event.clientY;
|
||
atlasPanX = panX;
|
||
atlasPanY = panY;
|
||
atlasViewport.setPointerCapture(event.pointerId);
|
||
atlasViewport.classList.add("dragging");
|
||
});
|
||
atlasViewport.addEventListener("pointermove", (event) => {
|
||
if (event.pointerId !== atlasDragId) return;
|
||
panX = atlasPanX + event.clientX - atlasDragX;
|
||
panY = atlasPanY + event.clientY - atlasDragY;
|
||
renderZoom();
|
||
});
|
||
const endAtlasDrag = (event) => {
|
||
if (event.pointerId !== atlasDragId) return;
|
||
atlasDragId = null;
|
||
atlasViewport.classList.remove("dragging");
|
||
if (atlasViewport.hasPointerCapture(event.pointerId)) {
|
||
atlasViewport.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
atlasViewport.addEventListener("pointerup", endAtlasDrag);
|
||
atlasViewport.addEventListener("pointercancel", endAtlasDrag);
|
||
zoomRange.addEventListener("input", () => setZoom(Number(zoomRange.value)));
|
||
document.querySelector("#zoom-out").addEventListener("click", () => setZoom(zoom - 0.25));
|
||
document.querySelector("#zoom-in").addEventListener("click", () => setZoom(zoom + 0.25));
|
||
zoomReset.addEventListener("click", () => setZoom(1));
|
||
|
||
setFrame(REST_FRAME);
|
||
renderZoom();
|
||
updateScroll();
|
||
|
||
const asset = new Image();
|
||
asset.src = config.atlasUrl;
|
||
const decoded = typeof asset.decode === "function"
|
||
? asset.decode()
|
||
: new Promise((resolve, reject) => {
|
||
asset.onload = resolve;
|
||
asset.onerror = reject;
|
||
});
|
||
decoded.catch(() => {}).finally(() => {
|
||
loadingCover.classList.add("ready");
|
||
schedule();
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|