rsrc/extra/etc/anim.html
2026-11-03 05:58:41 +00:00

1234 lines
37 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title></title>
<style>
:root{
--bg:#000000;
--fg:#ffffff;
--muted:rgba(255,255,255,.62);
--soft:rgba(255,255,255,.08);
--panel:rgba(255,255,255,.04);
--panel-border:rgba(255,255,255,.08);
--tip:rgba(255,255,255,.07);
}
*{box-sizing:border-box}
html,body{
width:100%;
height:100%;
margin:0;
background:var(--bg);
color:var(--fg);
overflow:hidden;
font-family:ui-monospace,sfmono-regular,menlo,monaco,Consolas,"Liberation Mono",monospace;
}
canvas{
display:block;
width:100vw;
height:100vh;
cursor:default;
touch-action:none;
}
.toolbar{
position:fixed;
left:18px;
right:18px;
bottom:18px;
z-index:12;
display:flex;
justify-content:flex-start;
pointer-events:none;
}
.toolbar-inner{
display:flex;
gap:8px;
flex-wrap:wrap;
max-width:calc(100vw - 36px);
transform:translateY(0);
opacity:1;
transition:transform .18s ease,opacity .18s ease;
pointer-events:auto;
}
.toolbar.auto-hidden .toolbar-inner{
transform:translateY(calc(100% + 12px));
opacity:.02;
}
.hover-strip{
position:fixed;
left:0;
right:0;
bottom:0;
height:92px;
z-index:11;
}
.panel{
background:var(--panel);
border:1px solid var(--panel-border);
border-radius:12px;
backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);
box-shadow:0 12px 40px rgba(0,0,0,.26);
}
.controls{
display:flex;
gap:8px;
flex-wrap:wrap;
padding:10px 12px;
align-items:center;
}
.info{
position:fixed;
left:18px;
bottom:126px;
z-index:11;
max-width:min(1120px,calc(100vw - 36px));
padding:10px 12px;
font-size:12px;
line-height:1.42;
color:var(--muted);
white-space:pre-wrap;
display:none;
}
.info.show{display:block}
.info strong{color:#fff;font-weight:600}
button,textarea{
appearance:none;
border:1px solid rgba(255,255,255,.11);
background:#050505;
color:#fff;
border-radius:10px;
padding:8px 11px;
font:inherit;
font-size:12px;
min-height:38px;
}
button{
cursor:pointer;
transition:background .12s ease,border-color .12s ease,transform .08s ease;
}
button:hover{
background:#0d0d0d;
border-color:rgba(255,255,255,.2);
}
button:active{transform:translateY(1px)}
textarea{
outline:none;
width:min(460px,42vw);
min-height:38px;
height:38px;
resize:vertical;
}
textarea:focus{
border-color:rgba(255,255,255,.24);
background:#0b0b0b;
}
.tooltip{
position:fixed;
z-index:30;
pointer-events:none;
transform:translate(14px,14px);
background:var(--tip);
border:1px solid rgba(255,255,255,.09);
color:#fff;
padding:8px 10px;
border-radius:10px;
backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
font-size:12px;
line-height:1.35;
white-space:pre;
opacity:0;
transition:opacity .12s ease;
}
.tooltip.show{opacity:1}
.flash{
position:fixed;
right:18px;
bottom:18px;
z-index:20;
padding:9px 11px;
font-size:12px;
border-radius:10px;
background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.08);
color:#fff;
opacity:0;
transform:translateY(6px);
transition:opacity .18s ease,transform .18s ease;
pointer-events:none;
}
.flash.show{
opacity:1;
transform:translateY(0);
}
.hud{
position:fixed;
top:18px;
left:18px;
z-index:10;
display:flex;
flex-direction:column;
gap:8px;
pointer-events:none;
}
.badge{
display:inline-flex;
align-items:center;
gap:8px;
padding:7px 10px;
border-radius:10px;
background:rgba(255,255,255,.04);
border:1px solid rgba(255,255,255,.08);
color:#fff;
font-size:12px;
backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
}
.badge .muted{color:var(--muted)}
@media (max-width: 1240px){
.toolbar{
left:12px;
right:12px;
bottom:12px;
}
.toolbar-inner{max-width:none}
.controls{
width:100%;
padding:8px;
gap:8px;
}
.info{
left:12px;
right:12px;
bottom:176px;
max-width:none;
}
textarea{
width:100%;
min-width:240px;
}
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="hover-strip" id="hoverStrip"></div>
<div class="hud">
<div class="badge"><span class="muted">selected</span><span id="hudSelected">none</span></div>
<div class="badge"><span class="muted">current</span><span id="hudPreview">0.000s · 0f · 0%</span></div>
</div>
<div class="toolbar auto-hidden" id="toolbar">
<div class="toolbar-inner">
<div class="panel controls">
<button id="reset">reset</button>
<button id="rand">randomize</button>
<button id="copyAnim">copy graph</button>
<button id="importAnim">import</button>
<button id="play">pause</button>
<button id="addPoint">add point</button>
<button id="delPoint">delete point</button>
<button id="toggleGraphSize">expand graph</button>
<button id="toggleInfo">show info</button>
<textarea id="importText" placeholder=""></textarea>
</div>
</div>
</div>
<div class="panel info" id="info"></div>
<div class="tooltip" id="tip"></div>
<div class="flash" id="flash">copied</div>
<script>
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const info = document.getElementById("info");
const tip = document.getElementById("tip");
const flash = document.getElementById("flash");
const toolbar = document.getElementById("toolbar");
const hoverStrip = document.getElementById("hoverStrip");
const $ = id => document.getElementById(id);
let dpr = Math.max(1, window.devicePixelRatio || 1);
let w = 0;
let h = 0;
let raf = 0;
let ticking = false;
let resizeTimer = 0;
let hideTimer = 0;
const GRAPH_POINT_HIT = 14;
const HANDLE_HIT = 12;
const TOUCH_RADIUS = 38;
const DEFAULT_DURATION = 1.0;
const DEFAULT_FPS = 30;
const DEFAULT_PLAYBACK = 1.0;
const DEFAULT_TIME_SNAP = 1;
const DEFAULT_VALUE_SNAP = 1;
const DEFAULT_MODE = "ae";
const state = {
playing: true,
t0: performance.now(),
durAnim: 2200,
showInfo: false,
duration: DEFAULT_DURATION,
fps: DEFAULT_FPS,
playback: DEFAULT_PLAYBACK,
timeSnap: DEFAULT_TIME_SNAP,
valueSnap: DEFAULT_VALUE_SNAP,
mode: DEFAULT_MODE,
graph: [],
selectedGraph: -1,
draggingGraph: -1,
draggingHandle: null,
graphOx: 0,
graphOy: 0,
toolbarPinned: false,
graphExpanded: false,
graphExpandAnim: 0,
graphExpandFrom: 0,
graphExpandTo: 0,
graphExpandStart: 0,
cubeZoom: 1
};
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const round = (n, p = 3) => Math.round(n * Math.pow(10, p)) / Math.pow(10, p);
const lerp = (a, b, t) => a + (b - a) * t;
const safeDiv = (a, b, fallback = 0) => Math.abs(b) < 1e-9 ? fallback : a / b;
const fmt = (n, p = 4) => Number(round(n, p)).toString();
const easeInOutSine = t => -(Math.cos(Math.PI * t) - 1) / 2;
function randomRange(a, b){ return a + Math.random() * (b - a); }
function durSec(){ return state.duration; }
function fpsVal(){ return state.fps; }
function playbackVal(){ return state.playback; }
function isTouchLike(e){ return e.pointerType === "touch" || e.pointerType === "pen"; }
function constrainedMode(){ return state.mode === "ae"; }
function snapFrameStep(){ return state.timeSnap; }
function snapValueStep(){ return state.valueSnap; }
function flashCopied(msg = "copied"){
flash.textContent = msg;
flash.classList.add("show");
clearTimeout(flash._t);
flash._t = setTimeout(() => flash.classList.remove("show"), 980);
}
function fallbackCopy(text){
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.left = "-9999px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try{ document.execCommand("copy"); flashCopied(); }catch(_){}
document.body.removeChild(ta);
}
function copyText(text){
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(() => flashCopied()).catch(() => fallbackCopy(text));
}else{
fallbackCopy(text);
}
}
function pointerPos(e){
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function showTip(text, clientX, clientY){
tip.textContent = text;
tip.style.left = clientX + "px";
tip.style.top = clientY + "px";
tip.classList.add("show");
}
function hideTip(){
tip.classList.remove("show");
}
function graphCollapsedRect(){
const gw = Math.min(420, w * 0.32);
const gh = Math.min(300, h * 0.30);
return { x:24, y:24, w:gw, h:gh };
}
function graphExpandedRect(){
const pad = Math.min(24, Math.max(12, w * 0.018));
return {
x: pad,
y: pad,
w: w - pad * 2,
h: h - pad * 2
};
}
function currentGraphExpandT(){
if(state.graphExpandAnim === state.graphExpandTo) return state.graphExpandAnim;
const elapsed = (performance.now() - state.graphExpandStart) / 300;
const t = clamp(elapsed, 0, 1);
const eased = easeInOutSine(t);
state.graphExpandAnim = lerp(state.graphExpandFrom, state.graphExpandTo, eased);
if(t >= 1) state.graphExpandAnim = state.graphExpandTo;
return state.graphExpandAnim;
}
function miniRect(){
const a = graphCollapsedRect();
const b = graphExpandedRect();
const t = currentGraphExpandT();
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
w: lerp(a.w, b.w, t),
h: lerp(a.h, b.h, t)
};
}
function defaultGraph(){
return [
{ t:0.0, v:0.0, inInf:33.3333, outInf:20 },
{ t:0.22, v:0.08, inInf:20, outInf:20 },
{ t:0.58, v:0.78, inInf:20, outInf:20 },
{ t:1.0, v:1.0, inInf:20, outInf:33.3333 }
];
}
function sortGraph(){
state.graph.sort((a, b) => a.t - b.t);
}
function snapFrame(frame){
const step = snapFrameStep();
if(step <= 0) return frame;
return Math.round(frame / step) * step;
}
function snapValue(valuePct){
const step = snapValueStep();
if(step <= 0) return valuePct;
return Math.round(valuePct / step) * step;
}
function timeToFrame(t){
return Math.round(t * durSec() * fpsVal());
}
function frameToTime(frame){
return safeDiv(frame, durSec() * fpsVal(), 0);
}
function graphPointToScreen(pt){
const r = miniRect();
return {
x: r.x + pt.t * r.w,
y: r.y + r.h - pt.v * r.h
};
}
function screenToGraphPoint(x, y){
const r = miniRect();
let t = clamp((x - r.x) / r.w, 0, 1);
let v = clamp(1 - (y - r.y) / r.h, 0, 1);
const frame = snapFrame(timeToFrame(t));
const valPct = snapValue(v * 100);
t = clamp(frameToTime(frame), 0, 1);
v = clamp(valPct / 100, 0, 1);
return { t, v };
}
function sanitizeGraph(){
if(state.graph.length < 2) state.graph = defaultGraph();
sortGraph();
state.graph[0].t = 0;
state.graph[state.graph.length - 1].t = 1;
for(let i = 0; i < state.graph.length; i++){
const p = state.graph[i];
p.v = clamp(p.v, 0, 1);
p.inInf = clamp(Number.isFinite(p.inInf) ? p.inInf : 33.3333, 0.1, 100);
p.outInf = clamp(Number.isFinite(p.outInf) ? p.outInf : 33.3333, 0.1, 100);
if(i > 0 && i < state.graph.length - 1){
p.t = clamp(p.t, state.graph[i - 1].t + 0.001, state.graph[i + 1].t - 0.001);
}
}
if(constrainedMode()){
for(let i = 1; i < state.graph.length; i++){
state.graph[i].v = Math.max(state.graph[i].v, state.graph[i - 1].v);
}
}
}
function hitGraphPoint(pos, radius = GRAPH_POINT_HIT){
const rr = radius * radius;
for(let i = state.graph.length - 1; i >= 0; i--){
const sp = graphPointToScreen(state.graph[i]);
const dx = pos.x - sp.x;
const dy = pos.y - sp.y;
if(dx * dx + dy * dy <= rr) return i;
}
return -1;
}
function getHandleScreen(i, side){
const p = state.graph[i];
const sp = graphPointToScreen(p);
const r = miniRect();
const lenPx = clamp(r.w * 0.08, 44, 96);
if(side === "in"){
const dx = -(p.inInf / 100) * lenPx;
return { x: sp.x + dx, y: sp.y };
}
const dx = (p.outInf / 100) * lenPx;
return { x: sp.x + dx, y: sp.y };
}
function hitHandle(pos){
const rad = HANDLE_HIT * HANDLE_HIT;
for(let i = state.graph.length - 1; i >= 0; i--){
if(i > 0){
const h = getHandleScreen(i, "in");
const dx = pos.x - h.x;
const dy = pos.y - h.y;
if(dx * dx + dy * dy <= rad) return { index:i, side:"in" };
}
if(i < state.graph.length - 1){
const h = getHandleScreen(i, "out");
const dx = pos.x - h.x;
const dy = pos.y - h.y;
if(dx * dx + dy * dy <= rad) return { index:i, side:"out" };
}
}
return null;
}
function cubicBezierEase(t, p1x, p1y, p2x, p2y){
const cx = 3 * p1x;
const bx = 3 * (p2x - p1x) - cx;
const ax = 1 - cx - bx;
const cy = 3 * p1y;
const by = 3 * (p2y - p1y) - cy;
const ay = 1 - cy - by;
const sampleX = u => ((ax * u + bx) * u + cx) * u;
const sampleY = u => ((ay * u + by) * u + cy) * u;
const sampleDx = u => (3 * ax * u + 2 * bx) * u + cx;
let u = t;
for(let i = 0; i < 7; i++){
const x = sampleX(u) - t;
if(Math.abs(x) < 0.0001) break;
const d = sampleDx(u);
if(Math.abs(d) < 0.000001) break;
u -= x / d;
}
u = clamp(u, 0, 1);
return sampleY(u);
}
function segmentEase(i, u){
const a = state.graph[i];
const b = state.graph[i + 1];
const x1 = clamp(a.outInf / 100, 0.001, 0.999);
const x2 = clamp(1 - b.inInf / 100, 0.001, 0.999);
return cubicBezierEase(u, x1, 0, x2, 1);
}
function graphValueAt(t){
const g = state.graph;
if(t <= g[0].t) return g[0].v;
if(t >= g[g.length - 1].t) return g[g.length - 1].v;
for(let i = 0; i < g.length - 1; i++){
const a = g[i];
const b = g[i + 1];
if(t >= a.t && t <= b.t){
const u = safeDiv(t - a.t, b.t - a.t, 0);
const e = segmentEase(i, u);
return lerp(a.v, b.v, e);
}
}
return g[g.length - 1].v;
}
function buildAeRows(){
const rows = [];
const duration = durSec();
for(let i = 0; i < state.graph.length; i++){
const p = state.graph[i];
const frame = timeToFrame(p.t);
const value = round(p.v * 100, 4);
let inSpeed = 0;
let outSpeed = 0;
if(i > 0){
const prev = state.graph[i - 1];
const dt = Math.max(0.0001, (p.t - prev.t) * duration);
const dv = (p.v - prev.v) * 100;
inSpeed = round(Math.abs(dv / dt), 4);
}
if(i < state.graph.length - 1){
const next = state.graph[i + 1];
const dt = Math.max(0.0001, (next.t - p.t) * duration);
const dv = (next.v - p.v) * 100;
outSpeed = round(Math.abs(dv / dt), 4);
}
rows.push({
frame,
value,
inSpeed,
inInfluence: round(p.inInf, 4),
outSpeed,
outInfluence: round(p.outInf, 4)
});
}
return rows;
}
function getCopyText(){
const rows = buildAeRows();
return rows.map(r => `${r.frame},${fmt(r.value,4)},${fmt(r.inSpeed,4)},${fmt(r.inInfluence,4)},${fmt(r.outSpeed,4)},${fmt(r.outInfluence,4)}`).join("\n");
}
function updateHud(){
if(state.selectedGraph === -1){
$("hudSelected").textContent = "none";
}else{
const p = state.graph[state.selectedGraph];
$("hudSelected").textContent = `k${state.selectedGraph} · ${timeToFrame(p.t)}f · ${fmt(p.v * 100,2)}% · in ${fmt(p.inInf,1)} · out ${fmt(p.outInf,1)}`;
}
const duration = durSec();
const fps = fpsVal();
const loopSec = duration / playbackVal();
const elapsed = (performance.now() - state.t0) / 1000;
const loopT = loopSec > 0 ? (elapsed % loopSec) / loopSec : 0;
const sec = loopT * duration;
const frame = Math.floor(sec * fps);
const pct = Math.round(loopT * 1000) / 10;
$("hudPreview").textContent = `${fmt(sec,3)}s · ${frame}f · ${pct}% · zoom ${fmt(state.cubeZoom * 100,0)}%`;
}
function graphPointTooltip(i){
const p = state.graph[i];
return `k${i}\nframe ${timeToFrame(p.t)}\ntime ${fmt(p.t * durSec(), 3)}s\nvalue ${fmt(p.v * 100, 2)}%\nin ${fmt(p.inInf,1)}\nout ${fmt(p.outInf,1)}`;
}
function updateInfo(){
const rows = buildAeRows();
const sample = rows.slice(0, 8).map(r => `${r.frame},${fmt(r.value,2)},${fmt(r.inSpeed,2)},${fmt(r.inInfluence,2)},${fmt(r.outSpeed,2)},${fmt(r.outInfluence,2)}`).join("\n");
info.innerHTML =
`<div><strong>defaults</strong> duration ${fmt(state.duration,3)}s · fps ${fmt(state.fps,0)} · playback ${fmt(state.playback,2)}x · snap ${fmt(state.timeSnap,0)}f / ${fmt(state.valueSnap,0)}% · mode ${state.mode}</div>` +
`<div><strong>copy rows</strong> frame,value,in_speed,in_influence,out_speed,out_influence</div>` +
`<div><strong>preview rows</strong>\n${sample}</div>`;
}
function drawMiniGraph(){
const r = miniRect();
const gx = r.x;
const gy = r.y;
const gw = r.w;
const gh = r.h;
const tExpand = state.graphExpandAnim;
ctx.save();
ctx.translate(gx, gy);
ctx.fillStyle = `rgba(255,255,255,${lerp(0.025, 0.045, tExpand)})`;
ctx.fillRect(0, 0, gw, gh);
const divsX = state.graphExpanded || tExpand > 0.5 ? 12 : 4;
const divsY = state.graphExpanded || tExpand > 0.5 ? 8 : 4;
ctx.strokeStyle = "rgba(255,255,255,.08)";
ctx.lineWidth = 1;
for(let i = 0; i <= divsX; i++){
const xx = gw * i / divsX;
ctx.beginPath();
ctx.moveTo(xx, 0);
ctx.lineTo(xx, gh);
ctx.stroke();
}
for(let i = 0; i <= divsY; i++){
const yy = gh * i / divsY;
ctx.beginPath();
ctx.moveTo(0, yy);
ctx.lineTo(gw, yy);
ctx.stroke();
}
ctx.strokeStyle = "rgba(255,255,255,.22)";
ctx.strokeRect(0, 0, gw, gh);
ctx.beginPath();
const firstY = gh - graphValueAt(0) * gh;
ctx.moveTo(0, firstY);
const steps = state.graphExpanded || tExpand > 0.5 ? 900 : 320;
for(let i = 1; i <= steps; i++){
const t = i / steps;
const x = t * gw;
const y = gh - graphValueAt(t) * gh;
ctx.lineTo(x, y);
}
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = lerp(2.5, 3, tExpand);
ctx.stroke();
for(let i = 0; i < state.graph.length; i++){
const p = state.graph[i];
const x = p.t * gw;
const y = gh - p.v * gh;
const sel = i === state.selectedGraph;
if(i > 0){
const hin = getHandleScreen(i, "in");
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(hin.x - gx, hin.y - gy);
ctx.strokeStyle = "rgba(255,255,255,.18)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(hin.x - gx, hin.y - gy, state.graphExpanded ? 4.5 : 3.5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,.7)";
ctx.fill();
}
if(i < state.graph.length - 1){
const hout = getHandleScreen(i, "out");
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(hout.x - gx, hout.y - gy);
ctx.strokeStyle = "rgba(255,255,255,.18)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(hout.x - gx, hout.y - gy, state.graphExpanded ? 4.5 : 3.5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,.7)";
ctx.fill();
}
ctx.beginPath();
const pr = state.graphExpanded ? (sel ? 7.5 : 5.5) : (sel ? 6 : 4.5);
ctx.arc(x, y, pr, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
if(sel){
ctx.beginPath();
ctx.arc(x, y, state.graphExpanded ? 18 : 14, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,.16)";
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
ctx.restore();
}
function drawRollingPreview(){
const speed = playbackVal();
const cycle = Math.max(0.001, state.durAnim / speed);
const t = ((performance.now() - state.t0) % cycle) / cycle;
const eased = graphValueAt(t);
const expandT = state.graphExpandAnim;
const cubeAreaLeft = lerp(w * 0.54, w * 0.50, expandT);
const cubeAreaRight = lerp(w * 0.88, w * 0.50, expandT);
const centerY = lerp(h * 0.56, h * 0.54, expandT);
const travel = Math.max(0, cubeAreaRight - cubeAreaLeft);
const x = lerp(cubeAreaLeft, cubeAreaRight, expandT > 0.96 ? 0.5 : eased);
const baseSize = Math.min(110, Math.min(w, h) * 0.14);
const size = baseSize * state.cubeZoom * (1 - expandT * 0.18);
const roll = eased * Math.PI * 2.2;
const yaw = -0.72 + eased * 1.6;
const pitch = 0.55 + Math.sin(easeInOutSine(eased) * Math.PI) * 0.15;
const bounce = Math.sin(easeInOutSine(eased) * Math.PI) * Math.min(14, size * 0.14) * (1 - expandT);
ctx.save();
ctx.globalAlpha = 1 - expandT * 0.94;
const verts = [
{x:-1,y:-1,z:-1},{x:1,y:-1,z:-1},{x:1,y:1,z:-1},{x:-1,y:1,z:-1},
{x:-1,y:-1,z:1},{x:1,y:-1,z:1},{x:1,y:1,z:1},{x:-1,y:1,z:1}
];
function rotate3d(px, py, pz, rx, ry){
const y1 = py * Math.cos(rx) - pz * Math.sin(rx);
const z1 = py * Math.sin(rx) + pz * Math.cos(rx);
const x2 = px * Math.cos(ry) + z1 * Math.sin(ry);
const z2 = -px * Math.sin(ry) + z1 * Math.cos(ry);
return { x: x2, y: y1, z: z2 };
}
function project3d(p, cx, cy, fl){
const scale = fl / (fl + p.z);
return { x: cx + p.x * scale, y: cy + p.y * scale };
}
function drawPoly(points, fill, stroke = "rgba(255,255,255,.14)"){
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for(let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.lineWidth = 1;
ctx.stroke();
}
const world = verts.map(v => {
const r = rotate3d(v.x * size * 0.5, v.y * size * 0.5, v.z * size * 0.5, roll + pitch, yaw);
return { x: r.x, y: r.y - bounce, z: r.z };
});
const projected = world.map(p => project3d(p, x, centerY, Math.max(520, size * 5)));
const faces = [
{idx:[0,1,2,3], fill:"rgba(118,72,255,0.16)"},
{idx:[4,5,6,7], fill:"rgba(255,190,246,0.32)"},
{idx:[0,1,5,4], fill:"rgba(186,128,255,0.22)"},
{idx:[3,2,6,7], fill:"rgba(255,166,228,0.26)"},
{idx:[1,2,6,5], fill:"rgba(208,118,255,0.30)"},
{idx:[0,3,7,4], fill:"rgba(125,88,255,0.18)"}
];
const ordered = faces.map(face => {
const z = face.idx.reduce((sum, i) => sum + world[i].z, 0) / face.idx.length;
return { z, fill: face.fill, pts: face.idx.map(i => projected[i]) };
}).sort((a, b) => a.z - b.z);
const glowX = x;
const glowY = centerY + size * 0.08;
const glowR = size * 0.95;
const grad = ctx.createRadialGradient(glowX, glowY, 0, glowX, glowY, glowR);
grad.addColorStop(0, "rgba(183,120,255,0.18)");
grad.addColorStop(0.45, "rgba(255,166,228,0.09)");
grad.addColorStop(1, "rgba(0,0,0,0)");
ctx.beginPath();
ctx.arc(glowX, glowY, glowR, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
for(let i = 0; i < ordered.length; i++){
drawPoly(ordered[i].pts, ordered[i].fill);
}
ctx.restore();
}
function drawScene(){
ctx.clearRect(0, 0, w, h);
drawRollingPreview();
drawMiniGraph();
updateInfo();
updateHud();
}
function frame(){
const graphAnimating = Math.abs(state.graphExpandAnim - state.graphExpandTo) > 0.0001;
currentGraphExpandT();
drawScene();
if(!state.playing && !graphAnimating){
ticking = false;
return;
}
raf = requestAnimationFrame(frame);
}
function refresh(){
const graphAnimating = Math.abs(state.graphExpandAnim - state.graphExpandTo) > 0.0001;
if(state.playing || graphAnimating){
if(!ticking){
ticking = true;
raf = requestAnimationFrame(frame);
}
}else{
cancelAnimationFrame(raf);
ticking = false;
drawScene();
}
}
function resize(){
dpr = Math.max(1, window.devicePixelRatio || 1);
w = window.innerWidth;
h = window.innerHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
refresh();
}
function addGraphPointAt(t, v){
state.graph.push({ t, v, inInf:20, outInf:20 });
sortGraph();
sanitizeGraph();
let best = 0;
let bestD = Infinity;
for(let i = 0; i < state.graph.length; i++){
const d = Math.abs(state.graph[i].t - t) + Math.abs(state.graph[i].v - v);
if(d < bestD){
bestD = d;
best = i;
}
}
state.selectedGraph = best;
}
function addPointMid(){
let bestI = 0;
let bestGap = -1;
for(let i = 0; i < state.graph.length - 1; i++){
const gap = state.graph[i + 1].t - state.graph[i].t;
if(gap > bestGap){
bestGap = gap;
bestI = i;
}
}
const a = state.graph[bestI];
const b = state.graph[bestI + 1];
const t = (a.t + b.t) * 0.5;
const v = graphValueAt(t);
addGraphPointAt(t, v);
}
function deleteSelectedPoint(){
if(state.selectedGraph <= 0) return;
if(state.selectedGraph >= state.graph.length - 1) return;
state.graph.splice(state.selectedGraph, 1);
state.selectedGraph = clamp(state.selectedGraph - 1, 0, state.graph.length - 1);
sanitizeGraph();
}
function resetGraph(){
state.graph = defaultGraph();
state.selectedGraph = 1;
sanitizeGraph();
}
function randomizeGraph(){
const count = 4 + Math.floor(Math.random() * 3);
const pts = [{ t:0, v:0, inInf:33.3333, outInf:randomRange(10, 35) }];
let lastV = 0;
for(let i = 1; i < count - 1; i++){
const t = i / (count - 1) + randomRange(-0.08, 0.08);
let v = i / (count - 1) + randomRange(-0.18, 0.18);
if(constrainedMode()) v = Math.max(lastV, v);
v = clamp(v, 0, 1);
pts.push({
t: clamp(t, 0.05, 0.95),
v,
inInf: randomRange(8, 38),
outInf: randomRange(8, 38)
});
lastV = v;
}
pts.push({ t:1, v:1, inInf:randomRange(10, 35), outInf:33.3333 });
state.graph = pts;
sanitizeGraph();
state.selectedGraph = 1;
}
function parseImportRows(text){
const lines = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const rows = [];
for(const line of lines){
if(/[a-z]/i.test(line) && line.includes("frame")) continue;
const parts = line.split(/[\s,;|\t]+/).filter(Boolean);
if(parts.length < 2) continue;
const frame = parseFloat(parts[0]);
const value = parseFloat(parts[1]);
const inInf = parts.length > 3 ? parseFloat(parts[3]) : 20;
const outInf = parts.length > 5 ? parseFloat(parts[5]) : 20;
if(!Number.isFinite(frame) || !Number.isFinite(value)) continue;
rows.push({
frame,
value,
inInf: Number.isFinite(inInf) ? inInf : 20,
outInf: Number.isFinite(outInf) ? outInf : 20
});
}
return rows;
}
function importRows(text){
const rows = parseImportRows(text);
if(rows.length < 2){
flashCopied("import failed");
return;
}
const totalFrames = Math.max(1, durSec() * fpsVal());
state.graph = rows.map(r => ({
t: clamp(r.frame / totalFrames, 0, 1),
v: clamp(r.value / 100, 0, 1),
inInf: clamp(r.inInf, 0.1, 100),
outInf: clamp(r.outInf, 0.1, 100)
}));
sanitizeGraph();
state.selectedGraph = Math.min(1, state.graph.length - 1);
flashCopied("imported");
if(!state.playing) drawScene();
}
function setToolbarVisible(show){
toolbar.classList.toggle("auto-hidden", !show && !state.toolbarPinned);
}
function scheduleToolbarHide(){
clearTimeout(hideTimer);
hideTimer = setTimeout(() => setToolbarVisible(false), 700);
}
function toggleGraphSize(){
state.graphExpanded = !state.graphExpanded;
state.graphExpandFrom = state.graphExpandAnim;
state.graphExpandTo = state.graphExpanded ? 1 : 0;
state.graphExpandStart = performance.now();
$("toggleGraphSize").textContent = state.graphExpanded ? "collapse graph" : "expand graph";
refresh();
}
function setCubeZoom(next){
state.cubeZoom = clamp(next, 0.75, 3);
if(!state.playing) drawScene();
}
canvas.addEventListener("pointerdown", e => {
const p = pointerPos(e);
const r = miniRect();
setToolbarVisible(false);
if(p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h){
const hh = hitHandle(p);
if(hh){
state.selectedGraph = hh.index;
state.draggingHandle = hh;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing";
showTip(graphPointTooltip(hh.index), e.clientX, e.clientY);
if(!state.playing) drawScene();
return;
}
const hit = hitGraphPoint(p, isTouchLike(e) ? TOUCH_RADIUS : GRAPH_POINT_HIT);
if(hit !== -1){
state.selectedGraph = hit;
state.draggingGraph = hit;
const sp = graphPointToScreen(state.graph[hit]);
state.graphOx = p.x - sp.x;
state.graphOy = p.y - sp.y;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing";
showTip(graphPointTooltip(hit), e.clientX, e.clientY);
if(!state.playing) drawScene();
return;
}
const gp = screenToGraphPoint(p.x, p.y);
addGraphPointAt(gp.t, gp.v);
showTip(graphPointTooltip(state.selectedGraph), e.clientX, e.clientY);
if(!state.playing) drawScene();
return;
}
hideTip();
}, { passive:true });
canvas.addEventListener("pointermove", e => {
const p = pointerPos(e);
if(state.draggingHandle){
const { index, side } = state.draggingHandle;
const pt = state.graph[index];
const sp = graphPointToScreen(pt);
const dx = p.x - sp.x;
const lenPx = clamp(miniRect().w * 0.08, 44, 96);
if(side === "in" && index > 0){
pt.inInf = clamp((-dx / lenPx) * 100, 0.1, 100);
}else if(side === "out" && index < state.graph.length - 1){
pt.outInf = clamp((dx / lenPx) * 100, 0.1, 100);
}
showTip(graphPointTooltip(index), e.clientX, e.clientY);
if(!state.playing) drawScene();
return;
}
if(state.draggingGraph !== -1){
const idx = state.draggingGraph;
const gp = screenToGraphPoint(p.x - state.graphOx, p.y - state.graphOy);
if(idx === 0){
state.graph[idx].t = 0;
state.graph[idx].v = constrainedMode() ? Math.min(state.graph[1].v, gp.v) : gp.v;
}else if(idx === state.graph.length - 1){
state.graph[idx].t = 1;
state.graph[idx].v = constrainedMode() ? Math.max(state.graph[idx - 1].v, gp.v) : gp.v;
}else{
const prevT = state.graph[idx - 1].t + 0.001;
const nextT = state.graph[idx + 1].t - 0.001;
state.graph[idx].t = clamp(gp.t, prevT, nextT);
if(constrainedMode()){
const minV = state.graph[idx - 1].v;
const maxV = state.graph[idx + 1].v;
state.graph[idx].v = clamp(gp.v, minV, maxV);
}else{
state.graph[idx].v = clamp(gp.v, 0, 1);
}
}
sanitizeGraph();
showTip(graphPointTooltip(idx), e.clientX, e.clientY);
if(!state.playing) drawScene();
return;
}
const hh = hitHandle(p);
if(hh){
canvas.style.cursor = "grab";
showTip(graphPointTooltip(hh.index), e.clientX, e.clientY);
if(!state.playing) drawScene();
return;
}
const hit = hitGraphPoint(p, isTouchLike(e) ? TOUCH_RADIUS : GRAPH_POINT_HIT);
canvas.style.cursor = hit !== -1 ? "grab" : "default";
if(hit !== -1){
showTip(graphPointTooltip(hit), e.clientX, e.clientY);
}else{
hideTip();
}
if(!state.playing) drawScene();
}, { passive:true });
function endPointer(e){
state.draggingGraph = -1;
state.draggingHandle = null;
canvas.style.cursor = "default";
try{ canvas.releasePointerCapture(e.pointerId); }catch(_){}
hideTip();
if(!state.playing) drawScene();
}
canvas.addEventListener("pointerup", endPointer, { passive:true });
canvas.addEventListener("pointercancel", endPointer, { passive:true });
canvas.addEventListener("pointerleave", () => {
if(state.draggingGraph === -1 && !state.draggingHandle){
hideTip();
if(!state.playing) drawScene();
}
}, { passive:true });
canvas.addEventListener("wheel", e => {
const p = pointerPos(e);
const r = miniRect();
const overGraph = p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
if(overGraph) return;
const delta = -e.deltaY;
const factor = delta > 0 ? 1.08 : 0.92;
setCubeZoom(state.cubeZoom * factor);
refresh();
}, { passive:true });
$("reset").onclick = () => { resetGraph(); refresh(); };
$("rand").onclick = () => { randomizeGraph(); refresh(); };
$("copyAnim").onclick = () => { copyText(getCopyText()); };
$("importAnim").onclick = () => { importRows($("importText").value); };
$("addPoint").onclick = () => { addPointMid(); if(!state.playing) drawScene(); };
$("delPoint").onclick = () => { deleteSelectedPoint(); if(!state.playing) drawScene(); };
$("toggleGraphSize").onclick = () => { toggleGraphSize(); };
$("play").onclick = () => {
state.playing = !state.playing;
state.t0 = performance.now();
$("play").textContent = state.playing ? "pause" : "play";
if(!state.playing){
cancelAnimationFrame(raf);
ticking = false;
drawScene();
}else{
refresh();
}
};
$("toggleInfo").onclick = () => {
state.showInfo = !state.showInfo;
info.classList.toggle("show", state.showInfo);
$("toggleInfo").textContent = state.showInfo ? "hide info" : "show info";
};
toolbar.addEventListener("mouseenter", () => {
clearTimeout(hideTimer);
setToolbarVisible(true);
});
toolbar.addEventListener("mouseleave", () => {
scheduleToolbarHide();
});
hoverStrip.addEventListener("mouseenter", () => {
clearTimeout(hideTimer);
setToolbarVisible(true);
});
document.addEventListener("mousemove", e => {
if(e.clientY > window.innerHeight - 110){
clearTimeout(hideTimer);
setToolbarVisible(true);
}else if(state.draggingGraph === -1 && !state.draggingHandle){
scheduleToolbarHide();
}
}, { passive:true });
window.addEventListener("keydown", e => {
if(e.key === "f"){
toggleGraphSize();
}else if(e.key === "=" || e.key === "+"){
setCubeZoom(state.cubeZoom * 1.1);
refresh();
}else if(e.key === "-"){
setCubeZoom(state.cubeZoom / 1.1);
refresh();
}else if(e.key === "0"){
setCubeZoom(1);
refresh();
}
});
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resize, 40);
}, { passive:true });
resetGraph();
resize();
scheduleToolbarHide();
</script>
</body>
</html>