Files
workbuddy_skills/oil-motion/assets/motion-explainer-template.html
T
admin 237a09a5b0 修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`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` 挪回原处即可。
2026-10-08 22:29:52 +08:00

1044 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>