sophia_controller/curveEditor.js

977 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

export class CurveEditor {
constructor(canvas, timelineLength = 6, _slider) {
this.canvas = canvas;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
this.ctx = canvas.getContext('2d');
this.timelineLength = timelineLength;
this.logicalHeight = 800;
this.scale = 1;
this.scaleX = 1;
this.scaleY = 1;
this.offset = { x: 0, y: 0 };
this.pixelsPerSecond = 48;
this.exportRange = [0, 4095];
this.currentTime = timelineLength * this.pixelsPerSecond / 2;
this.durationHandle = {
x: timelineLength, // or maxTimeCS
y: 30, // top of canvas
radius: 10,
type: "duration"
};
this.motorButtons = {
width: 30,
height: 30,
padding: 10,
buttons: [] // will be populated dynamically
};
// COMMENT OUT AND READ FROM EXISTING TIMELINE LATER
this.slider = _slider;//document.getElementById('timeSlider');
this.slider.max = this.timelineLength * this.pixelsPerSecond;
console.log(this.slider.max);
this.slider.value = 0;
this.slider.addEventListener('input', () => {
this.currentTime = parseFloat(this.slider.value);
//console.log(slider.value);
this.draw();
});
this.setLength(timelineLength * this.pixelsPerSecond);
this.curveSets = {}; // motorID → array of curves
this.selectedMotorID = 4;
this.curves = [];
this.dragging = null;
this.isPanning = false;
this.panStart = { x: 0, y: 0 };
this.initEvents();
this.draw();
console.log(this.curveSets);
}
encodeCurves() {
const bufferSize = 1024; // adjust based on expected graph size
const buffer = new ArrayBuffer(bufferSize);
const view = new DataView(buffer);
let offset = 0;
const curveSegments = [];
Object.entries(this.curveSets).forEach(([motorIDStr, segments]) => {
const motorID = parseInt(motorIDStr, 10);
segments.forEach(segment => {
curveSegments.push({
motorID,
startTime: segment.startPoint.x,
endTime: segment.endPoint.x,
startPointY: segment.startPoint.y,
startHandleX: segment.startPointHandle.x,
startHandleY: segment.startPointHandle.y,
endHandleX: segment.endPointHandle.x,
endHandleY: segment.endPointHandle.y,
endPointY: segment.endPoint.y
});
});
});
console.log(curveSegments);
const curveCount = curveSegments.length;
view.setUint16(offset, curveCount, true); offset += 2;
// 🔹 Curve segments
curveSegments.forEach(seg => {
console.log(offset, seg.motorID);
view.setUint8(offset++, seg.motorID);
view.setUint16(offset, seg.startTime, true); offset += 2;
view.setUint16(offset, seg.endTime, true); offset += 2;
view.setInt16(offset, this.yToExportRange(seg.startPointY), true); offset += 2;
view.setUint16(offset, seg.startHandleX, true); offset += 2;
view.setInt16(offset, this.yToExportRange(seg.startHandleY), true); offset += 2;
view.setUint16(offset, seg.endHandleX, true); offset += 2;
view.setInt16(offset, this.yToExportRange(seg.endHandleY), true); offset += 2;
view.setInt16(offset, this.yToExportRange(seg.endPointY), true); offset += 2;
console.log(seg.startPointY, seg.endPointY);
console.log(this.yToExportRange(seg.startPointY), this.yToExportRange(seg.endPointY));
});
//console.log("🧵 Curve segments packed:", curveSegments.length);
return new Uint8Array(buffer.slice(0, offset));
}
setCurrentTime(time) {
this.currentTime = parseFloat(time);
this.draw();
}
addChannel(motorID) {
const midY = this.logicalHeight / 2;//this.offset.y + (this.logicalHeight / 2) * this.scaleY;
this.setCurves([
{
startPoint: { x: 0, y: midY },
startPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.25, y: midY },
endPointHandle: { x: this.timelineLength * this.pixelsPerSecond * 0.75, y: midY },
endPoint: { x: this.timelineLength * this.pixelsPerSecond, y: midY }
}
]);
this.curveSets[motorID] = this.curves;
}
setLength(endTime) {
this.timelineLength = endTime / this.pixelsPerSecond;
this.slider.max = this.timelineLength * this.pixelsPerSecond;
//console.log(this.slider.max, this.slider.value);
if (this.slider.value > this.slider.max) {
this.slider.value = this.slider.max;
}
this.durationHandle.x = this.timelineLength;
//console.log("new endtime: " + endTime);
//this.currentTime = this.timelineLength * this.pixelsPerSecond / 2;
this.draw();
}
getLength() {
return this.timelineLength * this.pixelsPerSecond;
}
loadCurveSets(curveSets) {
this.curveSets = []
this.curveSets = curveSets;
console.log(curveSets);
// If selectedMotorID is present in the new set, load its curves
if (curveSets[this.selectedMotorID]) {
this.setCurves(curveSets[this.selectedMotorID]);
} else {
this.setCurves([]); // fallback to empty
}
console.log("LOADED");
console.log(this.curveSets);
console.log(Object.keys(curveSets)[0]);
setSelectedMotor(parseInt(Object.keys(curveSets)[0]));
// hello
//this.selectAdjacentMotor(1);
// Optional: update motor selector UI or redraw timeline
//this.refreshMotorSelector?.(); // if you have a method for that
//this.drawTimelineMarkers?.();
}
selectMotor(motorID) {
this.selectedMotorID = motorID;
this.curves = this.curveSets[motorID] || [];
this.draw();
}
// valueToY(v) {
// return (this.canvas.height / 2 - v * (this.canvas.height / 2)) * this.scaleY + this.offset.y;
// }
valueToYNoOffset(v) {
return (this.canvas.height / 2 - v * (this.canvas.height / 2));
}
yToValueNoOffset(y) {
return 1 - (2 * y / this.canvas.height);
}
valueToY(v) {
return (this.logicalHeight / 2 - v * (this.logicalHeight / 2)) * this.scaleY + this.offset.y;
}
yToValue(y) {
return ((this.logicalHeight / 2 - (y - this.offset.y) / this.scaleY) / (this.logicalHeight / 2));
}
// Maps normalised -1 to 1 value to motor range (0, 4095)
yToExportRange(yCanvas) {
const [minOut, maxOut] = this.exportRange;
// undo offset + scale to get logical Y
const yLogical = (yCanvas - this.offset.y) / this.scaleY;
// invert valueToYNoOffset: get back logical [-1,1]
const logical = this.yToValueNoOffset(yLogical);
// map logical [-1,1] → normalized [0,1]
const normalized = (logical + 1) / 2;
// flip back (since exportRangeToY used 1 - normalized)
const flipped = 1 - normalized;
// map to export range
return (flipped * (maxOut - minOut) + minOut)/2;
}
exportRangeToY(value) {
const [minOut, maxOut] = this.exportRange;
// normalize value into [0,1], flipped
const normalized = 1 - (value - minOut) / (maxOut - minOut);
// map into logical [-1,1]
const logical = normalized * 2 - 1;
// convert logical → logical Y
const yLogical = this.valueToYNoOffset(logical);
// apply scale + offset to get canvas Y
return yLogical * this.scaleY + this.offset.y;
}
valueToX(value) {
return value * this.pixelsPerSecond * this.scaleX + this.offset.x;
}
xToValue(x) {
return (x - this.offset.x) / (this.pixelsPerSecond * this.scaleX);
}
transform(p) {
return {
x: p.x * this.scaleX + this.offset.x,
y: p.y * this.scaleY + this.offset.y
};
}
inverseTransform(p) {
return {
x: (p.x - this.offset.x) / this.scaleX,
y: (p.y - this.offset.y) / this.scaleY
};
}
draw() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.drawGrid(ctx);
// Draw inactive curves dimmed
// Draw inactive curves dimmed only if there are curve sets
if (this.curveSets && Object.keys(this.curveSets).length > 0) {
for (const [motorID, curves] of Object.entries(this.curveSets)) {
if (!curves || curves.length === 0) continue; // skip empty sets
const isSelected = motorID == this.selectedMotorID;
this.drawCurves(this.ctx, curves, isSelected ? 1.0 : 0.3, isSelected);
}
}
this.drawCurves(ctx, this.curves);
this.drawMotorButtons();
this.drawDurationHandle(ctx);
}
drawDurationHandle() {
const ctx = this.ctx;
const x = this.valueToX(this.durationHandle.x); // e.g. derived from header.frameCount or maxTime
const y = 20; // top of canvas
ctx.save();
// vertical guide line
ctx.strokeStyle = "red";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.canvas.height);
ctx.stroke();
// draggable knob at the top
ctx.fillStyle = "red";
ctx.beginPath();
ctx.arc(x, this.durationHandle.y, this.durationHandle.radius, 0, Math.PI * 2); // circle 10px down from top
ctx.fill();
ctx.restore();
}
drawMotorButtons() {
const ctx = this.ctx;
const canvas = this.canvas;
const { width, height, padding } = this.motorButtons;
const y = canvas.height - height - padding;
// Start from the right edge and move left
const xRight = canvas.width - width - padding;
const xMid = xRight - (width + padding);
const xLeft = xMid - (width + padding);
this.motorButtons.buttons = [
{ label: "<", x: xLeft, y, motorOffset: -1 },
{ label: `${this.selectedMotorID}`, x: xMid, y, motorOffset: 0 },
{ label: ">", x: xRight, y, motorOffset: 1 }
];
for (let btn of this.motorButtons.buttons) {
ctx.fillStyle = "#eee";
ctx.fillRect(btn.x, btn.y, width, height);
ctx.strokeStyle = "#333";
ctx.strokeRect(btn.x, btn.y, width, height);
ctx.fillStyle = "#000";
ctx.font = "16px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(btn.label, btn.x + width / 2, btn.y + height / 2);
}
}
cubicBezierX(t, p0, h0, h1, p1) {
return Math.pow(1 - t, 3) * p0.x +
3 * Math.pow(1 - t, 2) * t * h0.x +
3 * (1 - t) * Math.pow(t, 2) * h1.x +
Math.pow(t, 3) * p1.x;
}
solveTForX(targetX, P0, P1, P2, P3, epsilon = 0.5) {
let lower = 0;
let upper = 1;
let t;
for (let i = 0; i < 20; i++) {
t = (lower + upper) / 2;
const u = 1 - t;
const x = u ** 3 * P0.x + 3 * u ** 2 * t * P1.x + 3 * u * t ** 2 * P2.x + t ** 3 * P3.x;
if (Math.abs(x - targetX) < epsilon) {
return t;
}
if (x < targetX) {
lower = t;
} else {
upper = t;
}
}
return t;
}
getMotorPositionAtTime(motorID, timeInFrames) {
const curves = this.curveSets[motorID];
if (!curves || curves.length === 0) return null;
// put time into canvas space if youre transforming points
const currentTimeX = this.offset.x + timeInFrames * this.scaleX;
for (let curve of curves) {
const p0 = this.transform(curve.startPoint);
const h0 = this.transform(curve.startPointHandle);
const h1 = this.transform(curve.endPointHandle);
const p1 = this.transform(curve.endPoint);
const minX = Math.min(p0.x, p1.x);
const maxX = Math.max(p0.x, p1.x);
if (currentTimeX >= minX && currentTimeX <= maxX) {
const t = this.solveTForX(currentTimeX, p0, h0, h1, p1);
const pt = this.cubicBezier(t, p0, h0, h1, p1);
return this.yToExportRange(pt.y); // or pt.y if you want raw canvas Y
}
}
return null;
}
drawCurves(ctx, curves, opacity = 1.0, showControlPoints = true) {
ctx.save();
ctx.globalAlpha = opacity;
if (!curves) {
return;
}
for (let curve of curves) {
const p0 = this.transform(curve.startPoint);
const h0 = this.transform(curve.startPointHandle);
const h1 = this.transform(curve.endPointHandle);
const p1 = this.transform(curve.endPoint);
// Draw curve
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.bezierCurveTo(h0.x, h0.y, h1.x, h1.y, p1.x, p1.y);
ctx.strokeStyle = "#0077cc";
ctx.lineWidth = 2;
ctx.stroke();
const seconds = this.currentTime / 48; // convert to seconds
const currentTimeX = this.valueToX(seconds);
if (this.currentTime !== null) {
for (let curve of curves) {
const p0 = this.transform(curve.startPoint);
const h0 = this.transform(curve.startPointHandle);
const h1 = this.transform(curve.endPointHandle);
const p1 = this.transform(curve.endPoint);
const minX = Math.min(p0.x, p1.x);
const maxX = Math.max(p0.x, p1.x);
if (currentTimeX >= minX && currentTimeX <= maxX) {
const t = this.solveTForX(currentTimeX, p0, h0, h1, p1);
const pt = this.cubicBezier(t, p0, h0, h1, p1);
ctx.beginPath();
ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
ctx.fillStyle = "#00cc66";
ctx.fill();
ctx.strokeStyle = "#003300";
ctx.stroke();
}
}
}
// Draw control points (optional)
if (showControlPoints) {
// Draw handles
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(h0.x, h0.y);
ctx.moveTo(h1.x, h1.y);
ctx.lineTo(p1.x, p1.y);
ctx.strokeStyle = "#aaa";
ctx.lineWidth = 1;
ctx.stroke();
for (let key of ['startPoint', 'startPointHandle', 'endPointHandle', 'endPoint']) {
const p = this.transform(curve[key]);
ctx.beginPath();
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
ctx.fillStyle = key.includes('Handle') ? "#888" : "#ff6600";
ctx.fill();
ctx.strokeStyle = "#333";
ctx.stroke();
}
}
}
ctx.restore();
}
drawGrid(ctx) {
ctx.fillStyle = "#f0f0f0";
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const totalTicks = this.timelineLength * 4;
for (let s = 0; s <= totalTicks; s++) {
const time = s / 4;
const x = this.valueToX(time);
if (x < 0 || x > this.canvas.width) continue;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.canvas.height);
if (s % 4 === 0) {
ctx.strokeStyle = "#999";
ctx.lineWidth = 1.5;
ctx.font = "12px sans-serif";
ctx.fillStyle = "#666";
ctx.fillText(`${time}s`, x + 0 / this.scaleX, 12 / this.scaleY);
} else if (s % 2 === 0) {
ctx.strokeStyle = "#bbb";
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = "#ddd";
ctx.lineWidth = 0.5;
}
ctx.stroke();
}
const visibleYMin = Math.max(this.yToValue(this.canvas.height), -1);
const visibleYMax = Math.min(this.yToValue(0), 1);
for (let v = Math.ceil(visibleYMin * 4) / 4; v <= visibleYMax; v += 0.25) {
const y = this.valueToY(v);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.canvas.width, y);
ctx.strokeStyle = (v === 1 || v === -1 || v === 0) ? "#999" : "#ddd";
ctx.lineWidth = (v === 1 || v === -1 || v === 0) ? 1.5 : 0.5;
ctx.stroke();
ctx.font = "12px sans-serif";
ctx.fillStyle = "#666";
ctx.fillText(v.toFixed(2), 4 / this.scaleX + 10, y - 6 / this.scaleY);
}
}
cubicBezier(t, P0, P1, P2, P3) {
const u = 1 - t;
return {
x: u ** 3 * P0.x + 3 * u ** 2 * t * P1.x + 3 * u * t ** 2 * P2.x + t ** 3 * P3.x,
y: u ** 3 * P0.y + 3 * u ** 2 * t * P1.y + 3 * u * t ** 2 * P2.y + t ** 3 * P3.y
};
}
splitCurve(curve, t) {
const lerp = (a, b, t) => ({
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t
});
const p01 = lerp(curve.startPoint, curve.startPointHandle, t);
const p12 = lerp(curve.startPointHandle, curve.endPointHandle, t);
const p23 = lerp(curve.endPointHandle, curve.endPoint, t);
const p012 = lerp(p01, p12, t);
const p123 = lerp(p12, p23, t);
const split = lerp(p012, p123, t);
return [
{
startPoint: curve.startPoint,
startPointHandle: p01,
endPointHandle: p012,
endPoint: split
},
{
startPoint: split,
startPointHandle: p123,
endPointHandle: p23,
endPoint: curve.endPoint
}
];
}
setCurves(curves) {
this.curves = curves;
this.draw();
}
getCurves() {
return this.curves;
}
selectAdjacentMotor(direction) {
const ids = Object.keys(this.curveSets)
.map(id => parseInt(id))
.sort((a, b) => a - b);
const currentIndex = ids.indexOf(this.selectedMotorID);
if (currentIndex === -1) return;
const newIndex = (currentIndex + direction + ids.length) % ids.length;
setSelectedMotor(ids[newIndex]); // Global defined in script.js
}
initEvents() {
this.canvas.addEventListener('mousedown', e => {
const { buttons, width, height } = this.motorButtons;
const mx = e.offsetX;
const my = e.offsetY;
// Check duration handle
const handleX = this.valueToX(this.durationHandle.x);
const handleY = this.durationHandle.y; // same as draw
if (Math.hypot(e.offsetX - handleX, e.offsetY - handleY) < this.durationHandle.radius) {
this.dragging = { type: "durationHandle" };
return;
}
for (let btn of buttons) {
if (
mx >= btn.x &&
mx <= btn.x + width &&
my >= btn.y &&
my <= btn.y + height
) {
if (btn.motorOffset === -1) {
this.selectAdjacentMotor(-1);
} else if (btn.motorOffset === 1) {
this.selectAdjacentMotor(1);
}
console.log(this.selectedMotorID);
// No action needed for label button (motorOffset === 0)
return;
}
}
const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY });
if (e.button === 1) {
e.preventDefault(); // Prevent page panning
this.isPanning = true;
this.panStart = { x: e.offsetX, y: e.offsetY };
return;
}
for (let curve of this.curves) {
for (let key of ['startPointHandle', 'endPointHandle', 'startPoint', 'endPoint']) {
const p = curve[key];
if (Math.hypot(p.x - mouse.x, p.y - mouse.y) < 10 / this.scale) {
this.dragging = { curve, key };
return;
}
}
}
});
this.canvas.addEventListener('mousemove', e => {
if (this.isPanning) {
this.offset.x += e.offsetX - this.panStart.x;
this.offset.y += e.offsetY - this.panStart.y;
this.panStart = { x: e.offsetX, y: e.offsetY };
this.draw();
return;
}
if (this.dragging) {
const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY });
const { curve, key } = this.dragging;
const index = this.curves.indexOf(curve);
if (key === 'startPoint' || key === 'endPoint') {
this.dragEndpoint(curve, key, mouse.x, mouse.y, index);
} else if (this.dragging.type === "durationHandle") {
// Update duration in timeline units
const newTime = Math.max(0, mouse.x); // timeline units
// Optionally update timeline length
this.setLength(newTime);
this.adjustAllCurvesToDuration(newTime); // truncate/extend curves
//console.log(this.durationHandle.x);
this.draw();
return;
} else {
this.dragControlPoint(curve, key, mouse.x, mouse.y, index);
}
this.curveSets[this.selectedMotorID] = this.curves;
this.draw();
}
});
this.canvas.addEventListener('mouseup', () => {
this.dragging = null;
this.isPanning = false;
});
this.canvas.addEventListener('wheel', e => {
e.preventDefault();
const mouse = { x: e.offsetX, y: e.offsetY };
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
const before = this.inverseTransform(mouse);
// Apply zoom based on modifier keys
if (e.ctrlKey && !e.altKey) {
this.scaleX *= zoomFactor;
} else if (e.altKey && !e.ctrlKey) {
this.scaleY *= zoomFactor;
} else {
// Default: uniform zoom
this.scaleX *= zoomFactor;
this.scaleY *= zoomFactor;
}
const after = this.inverseTransform(mouse);
this.offset.x += (after.x - before.x) * this.scaleX;
this.offset.y += (after.y - before.y) * this.scaleY;
this.draw();
});
this.canvas.addEventListener('dblclick', e => {
const mouse = this.inverseTransform({ x: e.offsetX, y: e.offsetY });
//const xPosInTicks = parseInt(this.xToValue(mouse.x)*this.pixelsPerSecond);
//console.log(mouse.x);
this.splitCurveAtTime(this.selectedMotorID, mouse.x);
});
}
splitCurveAtTime(motorID, timeX, yPosition = null) {
const curves = this.curveSets[motorID];
if (!curves) return;
for (let i = 0; i < curves.length; i++) {
const curve = curves[i];
// --- merge with previous if near startPoint ---
if (i > 0 && Math.abs(curve.startPoint.x - timeX) < 1e-3) {
const prev = curves[i - 1];
const merged = {
startPoint: prev.startPoint,
startPointHandle: prev.startPointHandle,
endPointHandle: curve.endPointHandle,
endPoint: curve.endPoint
};
curves.splice(i - 1, 2, merged);
this.curveSets[motorID] = curves;
this.draw();
return;
}
// --- merge with next if near endPoint ---
if (i < curves.length - 1 && Math.abs(curve.endPoint.x - timeX) < 1e-3) {
const next = curves[i + 1];
const merged = {
startPoint: curve.startPoint,
startPointHandle: curve.startPointHandle,
endPointHandle: next.endPointHandle,
endPoint: next.endPoint
};
curves.splice(i, 2, merged);
this.curveSets[motorID] = curves;
this.draw();
return;
}
// --- split inside curve if timeX lies between start and end ---
const x0 = curve.startPoint.x;
const x3 = curve.endPoint.x;
if (timeX >= x0 && timeX <= x3) {
// binary search for t where curve.x ≈ timeX
let t = 0.5, minT = 0, maxT = 1;
for (let j = 0; j < 10; j++) {
const pt = this.cubicBezier(
t,
curve.startPoint,
curve.startPointHandle,
curve.endPointHandle,
curve.endPoint
);
if (pt.x < timeX) minT = t;
else maxT = t;
t = (minT + maxT) / 2;
}
let [left, right] = this.splitCurve(curve, t);
// ✅ If yPosition provided, override the split points Y
if (yPosition !== null) {
const yEditor = yPosition * 800 / 4095
// adjust the shared split point
left.endPoint.y = yEditor;
right.startPoint.y = yEditor;
// ✅ flatten handles to same Y
if (left.endPointHandle) left.endPointHandle.y = yEditor;
if (right.startPointHandle) right.startPointHandle.y = yEditor;
}
curves.splice(i, 1, left, right);
this.curveSets[motorID] = curves;
this.draw();
return;
}
}
}
adjustAllCurvesToDuration(newTime) {
for (const [motorID, curves] of Object.entries(this.curveSets)) {
if (!curves || curves.length === 0) continue;
for (let i = 0; i < curves.length; i++) {
const curve = curves[i];
// If a curve starts beyond the new duration, drop it
if (curve.startPoint.x >= newTime) {
curves.splice(i);
break;
}
// If a curve ends beyond the new duration, truncate it
if (curve.endPoint.x > newTime) {
curve.endPoint.x = newTime;
curve.endPointHandle.x = newTime;
curves.splice(i + 1);
break;
}
}
// If extending, push the last curves end point out
const last = curves[curves.length - 1];
if (last.endPoint.x < newTime) {
const delta = newTime - last.endPoint.x;
last.endPoint.x = newTime;
last.endPointHandle.x += delta;
}
this.curveSets[motorID] = curves;
}
// Update the active reference too
this.curves = this.curveSets[this.selectedMotorID] || [];
}
dragControlPoint(curve, key, mouseX, mouseY, index) {
curve[key].y = mouseY;
const firstX = this.curves[0].startPoint.x;
const lastX = this.curves[this.curves.length - 1].endPoint.x;
if (key === 'startPointHandle') {
const minX = Math.max(curve.startPoint.x, this.curves[index - 1]?.endPoint?.x ?? firstX);
const maxX = this.curves[index + 1]?.startPoint?.x ?? lastX;
curve.startPointHandle.x = Math.max(minX, Math.min(mouseX, maxX - 0.01));
} else if (key === 'endPointHandle') {
const minX = this.curves[index - 1]?.endPoint?.x ?? firstX;
const maxX = Math.min(curve.endPoint.x, this.curves[index + 1]?.endPoint?.x ?? lastX);
curve.endPointHandle.x = Math.max(minX, Math.min(mouseX, maxX - 0.01));
} else {
curve[key].x = Math.max(firstX, Math.min(mouseX, lastX));
}
}
dragEndpoint(curve, key, mouseX, mouseY, index) {
let deltaX = 0;
let deltaY = 0;
if (key === 'startPoint') {
const oldX = curve.startPoint.x;
const oldY = curve.startPoint.y;
if (index === 0) {
curve.startPoint.x = 0;
} else {
const firstX = this.curves[0].startPoint.x;
const nextX = this.curves[index + 1]?.startPoint?.x ?? Infinity;
curve.startPoint.x = Math.max(firstX + 0.01, Math.min(mouseX, nextX - 0.01));
}
curve.startPoint.y = mouseY;
deltaX = curve.startPoint.x - oldX;
deltaY = curve.startPoint.y - oldY;
curve.startPointHandle.x += deltaX;
curve.startPointHandle.y += deltaY;
this.dragControlPoint(curve, 'startPointHandle', curve.startPointHandle.x, curve.startPointHandle.y, index);
//console.log(this.yToExportRange(curve.startPoint.y));
} else if (key === 'endPoint') {
const oldX = curve.endPoint.x;
const oldY = curve.endPoint.y;
if (index === this.curves.length - 1) {
curve.endPoint.x = this.timelineLength * this.pixelsPerSecond; // logical time, not screen space
} else {
const firstX = this.curves[0].startPoint.x;
const prevX = this.curves[index - 1]?.endPoint?.x ?? firstX;
const nextX = this.curves[index + 1]?.endPoint?.x ?? Infinity;
curve.endPoint.x = Math.max(prevX + 0.01, Math.min(mouseX, nextX - 0.01));
}
curve.endPoint.y = mouseY;
deltaX = curve.endPoint.x - oldX;
deltaY = curve.endPoint.y - oldY;
curve.endPointHandle.x += deltaX;
curve.endPointHandle.y += deltaY;
this.dragControlPoint(curve, 'endPointHandle', curve.endPointHandle.x, curve.endPointHandle.y, index);
const nextCurve = this.curves[index + 1];
//console.log(curve.endPoint.y);
if (nextCurve) {
nextCurve.startPoint.x = curve.endPoint.x;
nextCurve.startPoint.y = curve.endPoint.y;
nextCurve.startPointHandle.x += deltaX;
nextCurve.startPointHandle.y += deltaY;
this.dragControlPoint(nextCurve, 'startPointHandle', nextCurve.startPointHandle.x, nextCurve.startPointHandle.y, index + 1);
}
}
// After all endpoint logic is done
for (let i = 0; i < this.curves.length; i++) {
const curve = this.curves[i];
this.dragControlPoint(curve, 'startPointHandle', curve.startPointHandle.x, curve.startPointHandle.y, i);
this.dragControlPoint(curve, 'endPointHandle', curve.endPointHandle.x, curve.endPointHandle.y, i);
}
}
isNearPoint(mouse, point, threshold = 5) {
return (
Math.abs(mouse.x - point.x) < threshold &&
Math.abs(mouse.y - point.y) < threshold
);
}
}