1043 lines
33 KiB
HTML
1043 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>
|