1234 lines
37 KiB
HTML
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> |