mirror of
https://git.datalinker.icu/kijai/ComfyUI-KJNodes.git
synced 2025-12-09 21:04:41 +08:00
1379 lines
48 KiB
JavaScript
1379 lines
48 KiB
JavaScript
const { app } = window.comfyAPI.app;
|
|
|
|
//from melmass
|
|
export function makeUUID() {
|
|
let dt = new Date().getTime()
|
|
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = ((dt + Math.random() * 16) % 16) | 0
|
|
dt = Math.floor(dt / 16)
|
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
|
})
|
|
return uuid
|
|
}
|
|
|
|
export const loadScript = (
|
|
FILE_URL,
|
|
async = true,
|
|
type = 'text/javascript',
|
|
) => {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
// Check if the script already exists
|
|
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
|
|
if (existingScript) {
|
|
resolve({ status: true, message: 'Script already loaded' })
|
|
return
|
|
}
|
|
|
|
const scriptEle = document.createElement('script')
|
|
scriptEle.type = type
|
|
scriptEle.async = async
|
|
scriptEle.src = FILE_URL
|
|
|
|
scriptEle.addEventListener('load', (ev) => {
|
|
resolve({ status: true })
|
|
})
|
|
|
|
scriptEle.addEventListener('error', (ev) => {
|
|
reject({
|
|
status: false,
|
|
message: `Failed to load the script ${FILE_URL}`,
|
|
})
|
|
})
|
|
|
|
document.body.appendChild(scriptEle)
|
|
} catch (error) {
|
|
reject(error)
|
|
}
|
|
})
|
|
}
|
|
const create_documentation_stylesheet = () => {
|
|
const tag = 'kj-splineditor-stylesheet'
|
|
|
|
let styleTag = document.head.querySelector(tag)
|
|
|
|
if (!styleTag) {
|
|
styleTag = document.createElement('style')
|
|
styleTag.type = 'text/css'
|
|
styleTag.id = tag
|
|
styleTag.innerHTML = `
|
|
.spline-editor {
|
|
|
|
position: absolute;
|
|
|
|
font: 12px monospace;
|
|
line-height: 1.5em;
|
|
padding: 10px;
|
|
z-index: 0;
|
|
overflow: hidden;
|
|
}
|
|
`
|
|
document.head.appendChild(styleTag)
|
|
}
|
|
}
|
|
|
|
loadScript('kjweb_async/svg-path-properties.min.js').catch((e) => {
|
|
console.log(e)
|
|
})
|
|
loadScript('kjweb_async/protovis.min.js').catch((e) => {
|
|
console.log(e)
|
|
})
|
|
create_documentation_stylesheet()
|
|
|
|
function chainCallback(object, property, callback) {
|
|
if (object == undefined) {
|
|
//This should not happen.
|
|
console.error("Tried to add callback to non-existant object")
|
|
return;
|
|
}
|
|
if (property in object) {
|
|
const callback_orig = object[property]
|
|
object[property] = function () {
|
|
const r = callback_orig.apply(this, arguments);
|
|
callback.apply(this, arguments);
|
|
return r
|
|
};
|
|
} else {
|
|
object[property] = callback;
|
|
}
|
|
}
|
|
app.registerExtension({
|
|
name: 'KJNodes.SplineEditor',
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData?.name === 'SplineEditor') {
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
|
|
this.widgets.find(w => w.name === "coordinates").hidden = true
|
|
|
|
var element = document.createElement("div");
|
|
this.uuid = makeUUID()
|
|
element.id = `spline-editor-${this.uuid}`
|
|
|
|
this.previewMediaType = 'image'
|
|
|
|
this.splineEditor = this.addDOMWidget(nodeData.name, "SplineEditorWidget", element, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
});
|
|
|
|
// context menu
|
|
this.contextMenu = document.createElement("div");
|
|
this.contextMenu.className = 'spline-editor-context-menu';
|
|
this.contextMenu.id = "context-menu";
|
|
this.contextMenu.style.display = "none";
|
|
this.contextMenu.style.position = "absolute";
|
|
this.contextMenu.style.backgroundColor = "#202020";
|
|
this.contextMenu.style.minWidth = "100px";
|
|
this.contextMenu.style.boxShadow = "0px 8px 16px 0px rgba(0,0,0,0.2)";
|
|
this.contextMenu.style.zIndex = "100";
|
|
this.contextMenu.style.padding = "5px";
|
|
|
|
function styleMenuItem(menuItem) {
|
|
menuItem.style.display = "block";
|
|
menuItem.style.padding = "5px";
|
|
menuItem.style.color = "#FFF";
|
|
menuItem.style.fontFamily = "Arial, sans-serif";
|
|
menuItem.style.fontSize = "16px";
|
|
menuItem.style.textDecoration = "none";
|
|
menuItem.style.marginBottom = "5px";
|
|
}
|
|
function createMenuItem(id, textContent) {
|
|
let menuItem = document.createElement("a");
|
|
menuItem.href = "#";
|
|
menuItem.id = `menu-item-${id}`;
|
|
menuItem.textContent = textContent;
|
|
styleMenuItem(menuItem);
|
|
return menuItem;
|
|
}
|
|
|
|
// Create an array of menu items using the createMenuItem function
|
|
this.menuItems = [
|
|
createMenuItem(0, "Toggle handles"),
|
|
createMenuItem(1, "Display sample points"),
|
|
createMenuItem(2, "Switch point shape"),
|
|
createMenuItem(3, "Background image"),
|
|
createMenuItem(4, "Invert point order"),
|
|
createMenuItem(5, "Clear Image"),
|
|
createMenuItem(6, "Add new spline"),
|
|
createMenuItem(7, "Add new single point"),
|
|
createMenuItem(8, "Delete current spline"),
|
|
createMenuItem(9, "Next spline"),
|
|
];
|
|
|
|
// Add mouseover and mouseout event listeners to each menu item for styling
|
|
this.menuItems.forEach(menuItem => {
|
|
menuItem.addEventListener('mouseover', function() {
|
|
this.style.backgroundColor = "gray";
|
|
});
|
|
|
|
menuItem.addEventListener('mouseout', function() {
|
|
this.style.backgroundColor = "#202020";
|
|
});
|
|
});
|
|
|
|
// Append each menu item to the context menu
|
|
this.menuItems.forEach(menuItem => {
|
|
this.contextMenu.appendChild(menuItem);
|
|
});
|
|
|
|
document.body.appendChild(this.contextMenu);
|
|
|
|
this.addWidget("button", "New canvas", null, () => {
|
|
if (!this.properties || !("points" in this.properties)) {
|
|
this.editor = new SplineEditor(this);
|
|
this.addProperty("points", this.constructor.type, "string");
|
|
}
|
|
else {
|
|
this.editor = new SplineEditor(this, true);
|
|
}
|
|
});
|
|
|
|
this.setSize([550, 1000]);
|
|
this.resizable = false;
|
|
this.splineEditor.parentEl = document.createElement("div");
|
|
this.splineEditor.parentEl.className = "spline-editor";
|
|
this.splineEditor.parentEl.id = `spline-editor-${this.uuid}`
|
|
element.appendChild(this.splineEditor.parentEl);
|
|
|
|
chainCallback(this, "onConfigure", function () {
|
|
try {
|
|
this.editor = new SplineEditor(this);
|
|
} catch (error) {
|
|
console.error("An error occurred while configuring the editor:", error);
|
|
}
|
|
});
|
|
chainCallback(this, "onExecuted", function (message) {
|
|
let bg_image = message["bg_image"];
|
|
this.properties.imgData = {
|
|
name: "bg_image",
|
|
base64: bg_image
|
|
};
|
|
this.editor.refreshBackgroundImage(this);
|
|
});
|
|
|
|
}); // onAfterGraphConfigured
|
|
}//node created
|
|
} //before register
|
|
})//register
|
|
|
|
|
|
class SplineEditor{
|
|
constructor(context, reset = false) {
|
|
this.node = context;
|
|
this.reset=reset;
|
|
const self = this;
|
|
console.log("creatingSplineEditor")
|
|
|
|
this.node.pasteFile = (file) => {
|
|
if (file.type.startsWith("image/")) {
|
|
this.handleImageFile(file);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
this.node.onDragOver = function (e) {
|
|
if (e.dataTransfer && e.dataTransfer.items) {
|
|
return [...e.dataTransfer.items].some(f => f.kind === "file" && f.type.startsWith("image/"));
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// On drop upload files
|
|
this.node.onDragDrop = (e) => {
|
|
console.log("onDragDrop called");
|
|
let handled = false;
|
|
for (const file of e.dataTransfer.files) {
|
|
if (file.type.startsWith("image/")) {
|
|
this.handleImageFile(file);
|
|
handled = true;
|
|
}
|
|
}
|
|
return handled;
|
|
};
|
|
|
|
// context menu
|
|
this.createContextMenu();
|
|
|
|
|
|
this.dotShape = "circle";
|
|
this.drawSamplePoints = false;
|
|
|
|
if (reset && context.splineEditor.element) {
|
|
context.splineEditor.element.innerHTML = ''; // Clear the container
|
|
}
|
|
this.coordWidget = context.widgets.find(w => w.name === "coordinates");
|
|
this.interpolationWidget = context.widgets.find(w => w.name === "interpolation");
|
|
this.pointsWidget = context.widgets.find(w => w.name === "points_to_sample");
|
|
this.pointsStoreWidget = context.widgets.find(w => w.name === "points_store");
|
|
this.tensionWidget = context.widgets.find(w => w.name === "tension");
|
|
this.minValueWidget = context.widgets.find(w => w.name === "min_value");
|
|
this.maxValueWidget = context.widgets.find(w => w.name === "max_value");
|
|
this.samplingMethodWidget = context.widgets.find(w => w.name === "sampling_method");
|
|
this.widthWidget = context.widgets.find(w => w.name === "mask_width");
|
|
this.heightWidget = context.widgets.find(w => w.name === "mask_height");
|
|
|
|
this.interpolation = this.interpolationWidget.value
|
|
this.tension = this.tensionWidget.value
|
|
this.points_to_sample = this.pointsWidget.value
|
|
this.rangeMin = this.minValueWidget.value
|
|
this.rangeMax = this.maxValueWidget.value
|
|
this.pointsLayer = null;
|
|
this.samplingMethod = this.samplingMethodWidget.value
|
|
|
|
if (this.samplingMethod == "path"||this.samplingMethod == "speed") {
|
|
this.dotShape = "triangle"
|
|
}
|
|
|
|
|
|
this.interpolationWidget.callback = () => {
|
|
this.interpolation = this.interpolationWidget.value
|
|
this.updatePath();
|
|
}
|
|
this.samplingMethodWidget.callback = () => {
|
|
this.samplingMethod = this.samplingMethodWidget.value
|
|
if (this.samplingMethod == "path") {
|
|
this.dotShape = "triangle"
|
|
}
|
|
else if (this.samplingMethod == "controlpoints") {
|
|
this.dotShape = "circle"
|
|
this.drawSamplePoints = true;
|
|
}
|
|
this.updatePath();
|
|
}
|
|
this.tensionWidget.callback = () => {
|
|
this.tension = this.tensionWidget.value
|
|
this.updatePath();
|
|
}
|
|
this.pointsWidget.callback = () => {
|
|
this.points_to_sample = this.pointsWidget.value
|
|
this.updatePath();
|
|
}
|
|
this.minValueWidget.callback = () => {
|
|
this.rangeMin = this.minValueWidget.value
|
|
this.updatePath();
|
|
}
|
|
this.maxValueWidget.callback = () => {
|
|
this.rangeMax = this.maxValueWidget.value
|
|
this.updatePath();
|
|
}
|
|
this.widthWidget.callback = () => {
|
|
this.width = this.widthWidget.value;
|
|
if (this.width > 256) {
|
|
context.setSize([this.width + 45, context.size[1]]);
|
|
}
|
|
this.vis.width(this.width);
|
|
this.updatePath();
|
|
}
|
|
this.heightWidget.callback = () => {
|
|
this.height = this.heightWidget.value
|
|
this.vis.height(this.height)
|
|
context.setSize([context.size[0], this.height + 450]);
|
|
this.updatePath();
|
|
}
|
|
this.pointsStoreWidget.callback = () => {
|
|
points = JSON.parse(this.pointsStoreWidget.value);
|
|
this.updatePath();
|
|
}
|
|
|
|
// Initialize or reset points array
|
|
this.drawHandles = false;
|
|
this.drawRuler = true;
|
|
var hoverIndex = -1;
|
|
var isDragging = false;
|
|
this.width = this.widthWidget.value;
|
|
this.height = this.heightWidget.value;
|
|
var i = 3;
|
|
this.splines = [];
|
|
this.activeSplineIndex = 0; // Track which spline is being edited
|
|
// init mouse position
|
|
this.lastMousePosition = { x: this.width/2, y: this.height/2 };
|
|
|
|
if (!reset && this.pointsStoreWidget.value != "") {
|
|
try {
|
|
const parsedData = JSON.parse(this.pointsStoreWidget.value);
|
|
// Check if it's already in the new format (array of splines)
|
|
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0].hasOwnProperty('points')) {
|
|
this.splines = parsedData;
|
|
} else {
|
|
// Convert old format (single array of points) to new format
|
|
this.splines = [{
|
|
points: parsedData,
|
|
color: "#1f77b4",
|
|
name: "Spline 1"
|
|
}];
|
|
}
|
|
} catch (e) {
|
|
console.error("Error parsing spline data:", e);
|
|
this.initializeDefaultSplines();
|
|
}
|
|
} else {
|
|
this.initializeDefaultSplines();
|
|
this.pointsStoreWidget.value = JSON.stringify(this.splines);
|
|
}
|
|
|
|
this.vis = new pv.Panel()
|
|
.width(this.width)
|
|
.height(this.height)
|
|
.fillStyle("#222")
|
|
.strokeStyle("gray")
|
|
.lineWidth(2)
|
|
.antialias(false)
|
|
.margin(10)
|
|
.event("mousedown", function () {
|
|
if (pv.event.shiftKey) { // Use pv.event to access the event object
|
|
let scaledMouse = {
|
|
x: this.mouse().x / app.canvas.ds.scale,
|
|
y: this.mouse().y / app.canvas.ds.scale
|
|
};
|
|
i = self.splines[self.activeSplineIndex].points.push(scaledMouse) - 1;
|
|
self.updatePath();
|
|
return this;
|
|
}
|
|
else if (pv.event.ctrlKey) {
|
|
// Capture the clicked location
|
|
let clickedPoint = {
|
|
x: this.mouse().x / app.canvas.ds.scale,
|
|
y: this.mouse().y / app.canvas.ds.scale
|
|
};
|
|
|
|
// Find the two closest points to the clicked location
|
|
const activePoints = self.splines[self.activeSplineIndex].points;
|
|
let { point1Index, point2Index } = self.findClosestPoints(self.splines[self.activeSplineIndex].points, clickedPoint);
|
|
|
|
// Calculate the midpoint between the two closest points
|
|
let midpoint = {
|
|
x: (activePoints[point1Index].x + activePoints[point2Index].x) / 2,
|
|
y: (activePoints[point1Index].y + activePoints[point2Index].y) / 2
|
|
};
|
|
|
|
// Insert the midpoint into the array
|
|
activePoints.splice(point2Index, 0, midpoint);
|
|
i = point2Index;
|
|
self.updatePath();
|
|
}
|
|
else if (pv.event.button === 2) {
|
|
// Store the current mouse position adjusted for scale
|
|
self.lastMousePosition = {
|
|
x: this.mouse().x / app.canvas.ds.scale,
|
|
y: this.mouse().y / app.canvas.ds.scale
|
|
};
|
|
|
|
self.node.contextMenu.style.display = 'block';
|
|
self.node.contextMenu.style.left = `${pv.event.clientX}px`;
|
|
self.node.contextMenu.style.top = `${pv.event.clientY}px`;
|
|
}
|
|
})
|
|
this.backgroundImage = this.vis.add(pv.Image).visible(false)
|
|
|
|
this.vis.add(pv.Rule)
|
|
.data(pv.range(0, this.height, 64))
|
|
.bottom(d => d)
|
|
.strokeStyle("gray")
|
|
.lineWidth(3)
|
|
.visible(() => self.drawRuler)
|
|
|
|
this.hoverSplineIndex = -1;
|
|
|
|
this.splines.forEach((spline, splineIndex) => {
|
|
const strokeObj = this.vis.add(pv.Line)
|
|
.data(() => spline.points)
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.interpolate(() => this.interpolation)
|
|
.tension(() => this.tension)
|
|
.segmented(() => false)
|
|
.strokeStyle("black") // Stroke color
|
|
.lineWidth(() => {
|
|
// Make stroke slightly wider than the main line
|
|
if (splineIndex === this.activeSplineIndex) return 5;
|
|
if (splineIndex === this.hoverSplineIndex) return 4;
|
|
return 3.5;
|
|
});
|
|
|
|
this.vis.add(pv.Line)
|
|
.data(() => spline.points)
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.interpolate(() => this.interpolation)
|
|
.tension(() => this.tension)
|
|
.segmented(() => false)
|
|
.strokeStyle(spline.color)
|
|
.lineWidth(() => {
|
|
// Change line width based on active or hover state
|
|
if (splineIndex === this.activeSplineIndex) return 3;
|
|
if (splineIndex === this.hoverSplineIndex) return 2;
|
|
return 1.5;
|
|
})
|
|
.event("mouseover", () => {
|
|
this.hoverSplineIndex = splineIndex;
|
|
this.vis.render();
|
|
})
|
|
.event("mouseout", () => {
|
|
this.hoverSplineIndex = -1;
|
|
this.vis.render();
|
|
})
|
|
.event("mousedown", () => {
|
|
if (this.activeSplineIndex !== splineIndex) {
|
|
this.activeSplineIndex = splineIndex;
|
|
this.refreshSplineElements();
|
|
}
|
|
});
|
|
});
|
|
|
|
this.vis.add(pv.Dot)
|
|
.data(() => {
|
|
const activeSpline = this.splines[this.activeSplineIndex];
|
|
// If this is a single point, don't show it in the main visualization
|
|
if (activeSpline.isSinglePoint || (activeSpline.points && activeSpline.points.length === 1)) {
|
|
return []; // Return empty array to hide in main visualization
|
|
}
|
|
return activeSpline.points;
|
|
})
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.radius(12)
|
|
.shape(function() {
|
|
return self.dotShape;
|
|
})
|
|
.angle(function() {
|
|
const index = this.index;
|
|
let angle = 0;
|
|
|
|
if (self.dotShape === "triangle") {
|
|
const activePoints = self.splines[self.activeSplineIndex].points;
|
|
let dxNext = 0, dyNext = 0;
|
|
if (index < activePoints.length - 1) {
|
|
dxNext = activePoints[index + 1].x - activePoints[index].x;
|
|
dyNext = activePoints[index + 1].y - activePoints[index].y;
|
|
}
|
|
|
|
let dxPrev = 0, dyPrev = 0;
|
|
if (index > 0) {
|
|
dxPrev = activePoints[index].x - activePoints[index - 1].x;
|
|
dyPrev = activePoints[index].y - activePoints[index - 1].y;
|
|
}
|
|
|
|
const dx = (dxNext + dxPrev) / 2;
|
|
const dy = (dyNext + dyPrev) / 2;
|
|
|
|
angle = Math.atan2(dy, dx);
|
|
angle -= Math.PI / 2;
|
|
angle = (angle + 2 * Math.PI) % (2 * Math.PI);
|
|
}
|
|
|
|
return angle;
|
|
})
|
|
.cursor("move")
|
|
.strokeStyle(function () { return i == this.index ? "#ff7f0e" : "#1f77b4"; })
|
|
.fillStyle(function () { return "rgba(100, 100, 100, 0.3)"; })
|
|
.event("mousedown", pv.Behavior.drag())
|
|
.event("dragstart", function () {
|
|
i = this.index;
|
|
hoverIndex = this.index;
|
|
isDragging = true;
|
|
const activePoints = self.splines[self.activeSplineIndex].points;
|
|
if (pv.event.button === 2 && i !== 0 && i !== activePoints.length - 1) {
|
|
activePoints.splice(i--, 1);
|
|
self.vis.render();
|
|
}
|
|
return this;
|
|
})
|
|
.event("dragend", function() {
|
|
if (this.pathElements !== null) {
|
|
self.updatePath();
|
|
}
|
|
isDragging = false;
|
|
})
|
|
.event("drag", function () {
|
|
let adjustedX = this.mouse().x / app.canvas.ds.scale; // Adjust the new X position by the inverse of the scale factor
|
|
let adjustedY = this.mouse().y / app.canvas.ds.scale; // Adjust the new Y position by the inverse of the scale factor
|
|
// Determine the bounds of the vis.Panel
|
|
const panelWidth = self.vis.width();
|
|
const panelHeight = self.vis.height();
|
|
|
|
// Adjust the new position if it would place the dot outside the bounds of the vis.Panel
|
|
adjustedX = Math.max(0, Math.min(panelWidth, adjustedX));
|
|
adjustedY = Math.max(0, Math.min(panelHeight, adjustedY));
|
|
self.splines[self.activeSplineIndex].points[this.index] = { x: adjustedX, y: adjustedY }; // Update the point's position
|
|
self.vis.render(); // Re-render the visualization to reflect the new position
|
|
})
|
|
.event("mouseover", function() {
|
|
hoverIndex = this.index; // Set the hover index to the index of the hovered dot
|
|
self.vis.render(); // Re-render the visualization
|
|
})
|
|
.event("mouseout", function() {
|
|
!isDragging && (hoverIndex = -1); // Reset the hover index when the mouse leaves the dot
|
|
self.vis.render(); // Re-render the visualization
|
|
})
|
|
.anchor("center")
|
|
.add(pv.Label)
|
|
.visible(function() {
|
|
return hoverIndex === this.index; // Only show the label for the hovered dot
|
|
})
|
|
.left(d => d.x < this.width / 2 ? d.x + 80 : d.x - 70) // Shift label to right if on left half, otherwise shift to left
|
|
.top(d => d.y < this.height / 2 ? d.y + 20 : d.y - 20) // Shift label down if on top half, otherwise shift up
|
|
.font(12 + "px sans-serif")
|
|
.text(d => {
|
|
if (this.samplingMethod == "path") {
|
|
return `X: ${Math.round(d.x)}, Y: ${Math.round(d.y)}`;
|
|
} else {
|
|
let frame = Math.round((d.x / self.width) * self.points_to_sample);
|
|
let normalizedY = (1.0 - (d.y / self.height) - 0.0) * (self.rangeMax - self.rangeMin) + self.rangeMin;
|
|
let normalizedX = (d.x / self.width);
|
|
return `F: ${frame}, X: ${normalizedX.toFixed(2)}, Y: ${normalizedY.toFixed(2)}`;
|
|
}
|
|
})
|
|
.textStyle("orange")
|
|
|
|
// single points
|
|
this.vis.add(pv.Dot)
|
|
.data(() => {
|
|
// Collect all single points from all splines
|
|
const singlePoints = [];
|
|
this.splines.forEach((spline, splineIndex) => {
|
|
if (spline.isSinglePoint || (spline.points && spline.points.length === 1)) {
|
|
singlePoints.push({
|
|
x: spline.points[0].x,
|
|
y: spline.points[0].y,
|
|
splineIndex: splineIndex,
|
|
color: spline.color
|
|
});
|
|
}
|
|
});
|
|
return singlePoints;
|
|
})
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.radius(6)
|
|
.shape("square")
|
|
.strokeStyle(d => d.splineIndex === this.activeSplineIndex ? "#ff7f0e" : d.color)
|
|
.fillStyle(d => "rgba(100, 100, 100, 0.9)")
|
|
.lineWidth(d => d.splineIndex === this.activeSplineIndex ? 3 : 1.5)
|
|
.cursor("move")
|
|
.event("mousedown", pv.Behavior.drag())
|
|
.event("dragstart", function(d) {
|
|
self.activeSplineIndex = d.splineIndex;
|
|
self.refreshSplineElements();
|
|
return this;
|
|
})
|
|
.event("drag", function(d) {
|
|
let adjustedX = this.mouse().x / app.canvas.ds.scale;
|
|
let adjustedY = this.mouse().y / app.canvas.ds.scale;
|
|
|
|
// Determine the bounds of the vis.Panel
|
|
const panelWidth = self.vis.width();
|
|
const panelHeight = self.vis.height();
|
|
|
|
// Adjust the new position if it would place the dot outside the bounds
|
|
adjustedX = Math.max(0, Math.min(panelWidth, adjustedX));
|
|
adjustedY = Math.max(0, Math.min(panelHeight, adjustedY));
|
|
|
|
// Update the point position
|
|
const spline = self.splines[d.splineIndex];
|
|
spline.points[0] = { x: adjustedX, y: adjustedY };
|
|
|
|
// For single points, we need to refresh the entire spline element
|
|
// to prevent the line-drawing effect
|
|
|
|
})
|
|
.event("dragend", function(d) {
|
|
self.refreshSplineElements();
|
|
self.updatePath();
|
|
})
|
|
.visible(d => true); // Make always visible
|
|
|
|
if (this.splines.length != 0) {
|
|
this.vis.render();
|
|
}
|
|
var svgElement = this.vis.canvas();
|
|
svgElement.style['zIndex'] = "2"
|
|
svgElement.style['position'] = "relative"
|
|
this.node.splineEditor.element.appendChild(svgElement);
|
|
this.pathElements = svgElement.getElementsByTagName('path'); // Get all path elements
|
|
|
|
if (this.width > 256) {
|
|
this.node.setSize([this.width + 45, this.node.size[1]]);
|
|
}
|
|
this.node.setSize([this.node.size[0], this.height + 450]);
|
|
this.updatePath();
|
|
this.refreshBackgroundImage();
|
|
}
|
|
|
|
updatePath = () => {
|
|
if (!this.splines || this.splines.length === 0) {
|
|
console.log("no splines");
|
|
return;
|
|
}
|
|
// Get active spline points
|
|
console.log("this.activeSplineIndex", this.activeSplineIndex);
|
|
const activeSpline = this.splines[this.activeSplineIndex];
|
|
const activePoints = activeSpline.points;
|
|
|
|
if (!activePoints || activePoints.length === 0) {
|
|
console.log("no points in active spline");
|
|
return;
|
|
}
|
|
|
|
|
|
let coords;
|
|
if (this.samplingMethod != "controlpoints") {
|
|
coords = this.samplePoints(this.pathElements[this.activeSplineIndex], this.points_to_sample, this.samplingMethod, this.width, this.activeSplineIndex);
|
|
} else {
|
|
coords = activePoints;
|
|
}
|
|
|
|
let allSplineCoords = [];
|
|
for (let i = 0; i < this.splines.length; i++) {
|
|
// Use the same sampling method for all splines
|
|
let splineCoords;
|
|
const pathElement = this.pathElements[i];
|
|
|
|
if (this.samplingMethod != "controlpoints" && pathElement) {
|
|
splineCoords = this.samplePoints(pathElement, this.points_to_sample, this.samplingMethod, this.width, i);
|
|
} else {
|
|
// Fall back to control points if no path element or sampling method is "controlpoints"
|
|
splineCoords = this.splines[i].points;
|
|
}
|
|
|
|
allSplineCoords.push(splineCoords);
|
|
}
|
|
|
|
if (this.drawSamplePoints) {
|
|
if (this.pointsLayer) {
|
|
// Update the data of the existing points layer
|
|
this.pointsLayer.data(coords);
|
|
} else {
|
|
// Create the points layer if it doesn't exist
|
|
this.pointsLayer = this.vis.add(pv.Dot)
|
|
.data(coords)
|
|
.left(function(d) { return d.x; })
|
|
.top(function(d) { return d.y; })
|
|
.radius(5) // Adjust the radius as needed
|
|
.fillStyle("red") // Change the color as needed
|
|
.strokeStyle("black") // Change the stroke color as needed
|
|
.lineWidth(1); // Adjust the line width as needed
|
|
}
|
|
} else {
|
|
if (this.pointsLayer) {
|
|
// Remove the points layer
|
|
this.pointsLayer.data([]);
|
|
this.vis.render();
|
|
}
|
|
}
|
|
this.pointsStoreWidget.value = JSON.stringify(this.splines);
|
|
if (this.coordWidget) {
|
|
this.coordWidget.value = JSON.stringify(allSplineCoords);
|
|
}
|
|
this.vis.render();
|
|
};
|
|
|
|
handleImageLoad = (img, file, base64String) => {
|
|
//console.log(img.width, img.height); // Access width and height here
|
|
this.widthWidget.value = img.width;
|
|
this.heightWidget.value = img.height;
|
|
this.drawRuler = false;
|
|
|
|
if (img.width != this.vis.width() || img.height != this.vis.height()) {
|
|
if (img.width > 256) {
|
|
this.node.setSize([img.width + 45, this.node.size[1]]);
|
|
}
|
|
this.node.setSize([this.node.size[0], img.height + 520]);
|
|
this.vis.width(img.width);
|
|
this.vis.height(img.height);
|
|
this.height = img.height;
|
|
this.width = img.width;
|
|
|
|
this.updatePath();
|
|
}
|
|
this.backgroundImage.url(file ? URL.createObjectURL(file) : `data:${this.node.properties.imgData.type};base64,${base64String}`).visible(true).root.render();
|
|
};
|
|
|
|
processImage = (img, file) => {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const maxWidth = 800; // maximum width
|
|
const maxHeight = 600; // maximum height
|
|
let width = img.width;
|
|
let height = img.height;
|
|
|
|
// Calculate the new dimensions while preserving the aspect ratio
|
|
if (width > height) {
|
|
if (width > maxWidth) {
|
|
height *= maxWidth / width;
|
|
width = maxWidth;
|
|
}
|
|
} else {
|
|
if (height > maxHeight) {
|
|
width *= maxHeight / height;
|
|
height = maxHeight;
|
|
}
|
|
}
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Get the compressed image data as a Base64 string
|
|
const base64String = canvas.toDataURL('image/jpeg', 0.5).replace('data:', '').replace(/^.+,/, ''); // 0.5 is the quality from 0 to 1
|
|
|
|
this.node.properties.imgData = {
|
|
name: file.name,
|
|
lastModified: file.lastModified,
|
|
size: file.size,
|
|
type: file.type,
|
|
base64: base64String
|
|
};
|
|
handleImageLoad(img, file, base64String);
|
|
};
|
|
|
|
handleImageFile = (file) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const img = new Image();
|
|
img.src = reader.result;
|
|
img.onload = () => processImage(img, file);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
const imageUrl = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.src = imageUrl;
|
|
img.onload = () => this.handleImageLoad(img, file, null);
|
|
};
|
|
|
|
refreshBackgroundImage = () => {
|
|
if (this.node.properties.imgData && this.node.properties.imgData.base64) {
|
|
const base64String = this.node.properties.imgData.base64;
|
|
const imageUrl = `data:${this.node.properties.imgData.type};base64,${base64String}`;
|
|
const img = new Image();
|
|
img.src = imageUrl;
|
|
img.onload = () => this.handleImageLoad(img, null, base64String);
|
|
}
|
|
};
|
|
|
|
refreshSplineElements = () => {
|
|
// Clear existing line elements and recreate them
|
|
const svgElement = this.vis.canvas();
|
|
|
|
// Remove all existing line elements
|
|
const oldLines = svgElement.querySelectorAll('path');
|
|
oldLines.forEach(line => line.remove());
|
|
|
|
this.pathElements = [];
|
|
this.lineObjects = [];
|
|
|
|
const originalChildren = [...this.vis.children];
|
|
|
|
// Find line objects to remove (those that represent splines)
|
|
const linesToRemove = originalChildren.filter(child =>
|
|
child instanceof pv.Line
|
|
);
|
|
linesToRemove.forEach(line => line.visible(false));
|
|
|
|
// Re-add all spline lines and store references to them
|
|
this.splines.forEach((spline, splineIndex) => {
|
|
// For single points, we need a special handling
|
|
if (spline.isSinglePoint || (spline.points && spline.points.length === 1)) {
|
|
const point = spline.points[0];
|
|
// For single points, create a tiny line at the same point
|
|
// This ensures we have a path element for the point
|
|
const lineObj = this.vis.add(pv.Line)
|
|
.data([point, {x: point.x + 0.001, y: point.y + 0.001}])
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.strokeStyle(spline.color)
|
|
.lineWidth(() => {
|
|
if (splineIndex === this.activeSplineIndex) return 3;
|
|
if (splineIndex === this.hoverSplineIndex) return 2;
|
|
return 1.5;
|
|
})
|
|
.event("mouseover", () => {
|
|
this.hoverSplineIndex = splineIndex;
|
|
this.vis.render();
|
|
})
|
|
.event("mouseout", () => {
|
|
this.hoverSplineIndex = -1;
|
|
this.vis.render();
|
|
})
|
|
.event("mousedown", () => {
|
|
if (this.activeSplineIndex !== splineIndex) {
|
|
this.activeSplineIndex = splineIndex;
|
|
this.refreshSplineElements();
|
|
}
|
|
});
|
|
this.lineObjects.push(lineObj);
|
|
} else {
|
|
// For normal multi-point splines
|
|
const strokeObj = this.vis.add(pv.Line)
|
|
.data(() => spline.points)
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.interpolate(() => this.interpolation)
|
|
.tension(() => this.tension)
|
|
.segmented(() => false)
|
|
.strokeStyle("black") // Stroke color
|
|
.lineWidth(() => {
|
|
// Make stroke slightly wider than the main line
|
|
if (splineIndex === this.activeSplineIndex) return 5;
|
|
if (splineIndex === this.hoverSplineIndex) return 4;
|
|
return 3.5;
|
|
});
|
|
const lineObj = this.vis.add(pv.Line)
|
|
.data(() => spline.points)
|
|
.left(d => d.x)
|
|
.top(d => d.y)
|
|
.interpolate(() => this.interpolation)
|
|
.tension(() => this.tension)
|
|
.segmented(() => false)
|
|
.strokeStyle(spline.color)
|
|
.lineWidth(() => {
|
|
if (splineIndex === this.activeSplineIndex) return 3;
|
|
if (splineIndex === this.hoverSplineIndex) return 2;
|
|
return 1.5;
|
|
})
|
|
.event("mouseover", () => {
|
|
this.hoverSplineIndex = splineIndex;
|
|
this.vis.render();
|
|
})
|
|
.event("mouseout", () => {
|
|
this.hoverSplineIndex = -1;
|
|
this.vis.render();
|
|
})
|
|
.event("mousedown", () => {
|
|
if (this.activeSplineIndex !== splineIndex) {
|
|
this.activeSplineIndex = splineIndex;
|
|
this.refreshSplineElements();
|
|
}
|
|
});
|
|
|
|
// // Add invisible wider hit area for easier selection
|
|
// this.vis.add(pv.Line)
|
|
// .data(() => spline.points)
|
|
// .left(d => d.x)
|
|
// .top(d => d.y)
|
|
// .interpolate(() => this.interpolation)
|
|
// .tension(() => this.tension)
|
|
// .segmented(() => false)
|
|
// .strokeStyle("rgba(0,0,0,0.01)") // Nearly invisible
|
|
// .lineWidth(15) // Much wider hit area
|
|
// .event("mouseover", () => {
|
|
// this.hoverSplineIndex = splineIndex;
|
|
// this.vis.render();
|
|
// })
|
|
// .event("mouseout", () => {
|
|
// this.hoverSplineIndex = -1;
|
|
// this.vis.render();
|
|
// })
|
|
// .event("mousedown", () => {
|
|
// if (pv.event.shiftKey) {
|
|
// if (this.activeSplineIndex !== splineIndex) {
|
|
// this.activeSplineIndex = splineIndex;
|
|
// this.refreshSplineElements();
|
|
// }
|
|
// }}
|
|
// );
|
|
|
|
this.lineObjects.push(lineObj);
|
|
}
|
|
});
|
|
|
|
this.vis.render();
|
|
|
|
requestAnimationFrame(() => {
|
|
const allPaths = Array.from(svgElement.querySelectorAll('path'));
|
|
this.pathElements = [];
|
|
|
|
// First try: look at paths with specific childIndex values
|
|
this.lineObjects.forEach((lineObj, i) => {
|
|
// Find paths that correspond to our line objects
|
|
const childIndex = lineObj.childIndex;
|
|
const matchingPath = allPaths.find(path =>
|
|
path.$scene && path.$scene.scenes &&
|
|
path.$scene.scenes.childIndex === childIndex
|
|
);
|
|
|
|
if (matchingPath) {
|
|
//console.log("matchingPath:", matchingPath);
|
|
this.pathElements[i] = matchingPath;
|
|
}
|
|
});
|
|
|
|
// Check if we found all paths
|
|
if (this.pathElements.filter(p => p).length !== this.splines.length) {
|
|
// Fallback to color matching
|
|
this.pathElements = [];
|
|
for (let i = 0; i < this.splines.length; i++) {
|
|
const color = this.splines[i].color;
|
|
const matchingPath = allPaths.find(p =>
|
|
p.getAttribute('style')?.includes(color) &&
|
|
!this.pathElements.includes(p)
|
|
);
|
|
|
|
if (matchingPath) {
|
|
this.pathElements[i] = matchingPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we still don't have the right number of paths, use the first N paths
|
|
if (this.pathElements.filter(p => p).length !== this.splines.length) {
|
|
this.pathElements = allPaths.slice(0, this.splines.length);
|
|
}
|
|
|
|
this.updatePath();
|
|
});
|
|
};
|
|
|
|
|
|
initializeDefaultSplines() {
|
|
this.splines = [{
|
|
points: pv.range(1, 4).map((i, index) => {
|
|
if (index === 0) {
|
|
return { x: 0, y: this.height };
|
|
} else if (index === 2) {
|
|
return { x: this.width, y: 0 };
|
|
} else {
|
|
return {
|
|
x: i * this.width / 5,
|
|
y: 50 + Math.random() * (this.height - 100)
|
|
};
|
|
}
|
|
}),
|
|
color: this.getSplineColor(0),
|
|
name: "Spline 1"
|
|
}];
|
|
}
|
|
|
|
getSplineColor(index) {
|
|
const colors = [
|
|
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728",
|
|
"#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
|
|
"#bcbd22", "#17becf"
|
|
];
|
|
return colors[index % colors.length];
|
|
}
|
|
|
|
createContextMenu = () => {
|
|
const self = this;
|
|
const oldMenu = this.node.contextMenu;
|
|
const newMenu = oldMenu.cloneNode(true);
|
|
oldMenu.parentNode.replaceChild(newMenu, oldMenu);
|
|
this.node.contextMenu = newMenu;
|
|
|
|
document.addEventListener('contextmenu', function (e) {
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('click', function (e) {
|
|
document.querySelectorAll('.spline-editor-context-menu').forEach(menu => {
|
|
menu.style.display = 'none';
|
|
});
|
|
});
|
|
|
|
this.node.contextMenu.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
if (e.target.tagName === 'A') {
|
|
const id = parseInt(e.target.id.split('-')[2]);
|
|
|
|
switch(id) {
|
|
case 0:
|
|
e.preventDefault();
|
|
if (!self.drawHandles) {
|
|
self.drawHandles = true
|
|
self.vis.add(pv.Line)
|
|
.data(() => self.splines[self.activeSplineIndex].points.map((point, index) => ({
|
|
start: point,
|
|
end: [index]
|
|
})))
|
|
.left(d => d.start.x)
|
|
.top(d => d.start.y)
|
|
.interpolate("linear")
|
|
.tension(0) // Straight lines
|
|
.strokeStyle("#ff7f0e") // Same color as control points
|
|
.lineWidth(1)
|
|
.visible(() => self.drawHandles);
|
|
self.vis.render();
|
|
} else {
|
|
self.drawHandles = false
|
|
self.vis.render();
|
|
}
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
case 1:
|
|
|
|
self.drawSamplePoints = !self.drawSamplePoints;
|
|
self.updatePath();
|
|
break;
|
|
case 2:
|
|
if (self.dotShape == "circle"){
|
|
self.dotShape = "triangle"
|
|
}
|
|
else {
|
|
self.dotShape = "circle"
|
|
}
|
|
self.updatePath();
|
|
break;
|
|
case 3:
|
|
// Create file input element
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.accept = 'image/*'; // Accept only image files
|
|
|
|
// Listen for file selection
|
|
fileInput.addEventListener('change', function (event) {
|
|
const file = event.target.files[0]; // Get the selected file
|
|
|
|
if (file) {
|
|
const imageUrl = URL.createObjectURL(file);
|
|
let img = new Image();
|
|
img.src = imageUrl;
|
|
img.onload = () => self.handleImageLoad(img, file, null);
|
|
}
|
|
});
|
|
|
|
fileInput.click();
|
|
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
case 4:
|
|
self.splines[self.activeSplineIndex].points.reverse();
|
|
self.updatePath();
|
|
break;
|
|
case 5:
|
|
self.backgroundImage.visible(false).root.render();
|
|
self.node.properties.imgData = null;
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
case 6: // Add new spline
|
|
const newSplineIndex = self.splines.length;
|
|
self.splines.push({
|
|
points: [
|
|
// Create default points for the new spline
|
|
{ x: 0, y: self.height },
|
|
{ x: self.width/2, y: self.height/2 },
|
|
{ x: self.width, y: 0 }
|
|
],
|
|
color: self.getSplineColor(newSplineIndex),
|
|
name: `Spline ${newSplineIndex + 1}`
|
|
});
|
|
self.activeSplineIndex = newSplineIndex;
|
|
self.refreshSplineElements();
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
case 7: // Add new single point
|
|
const newSingleSplineIndex = self.splines.length;
|
|
self.splines.push({
|
|
points: [
|
|
{ x: self.lastMousePosition.x, y: self.lastMousePosition.y },
|
|
],
|
|
color: self.getSplineColor(newSingleSplineIndex),
|
|
name: `Spline ${newSingleSplineIndex + 1}`,
|
|
isSinglePoint: true
|
|
});
|
|
self.activeSplineIndex = newSingleSplineIndex;
|
|
self.refreshSplineElements();
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
case 8: // Delete current spline
|
|
if (self.splines.length > 1) {
|
|
self.splines.splice(self.activeSplineIndex, 1);
|
|
self.activeSplineIndex = Math.min(self.activeSplineIndex, self.splines.length - 1);
|
|
self.refreshSplineElements();
|
|
}
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
case 9: // Next spline
|
|
self.activeSplineIndex = (self.activeSplineIndex + 1) % self.splines.length;
|
|
self.refreshSplineElements();
|
|
self.node.contextMenu.style.display = 'none';
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
samplePoints(svgPathElement, numSamples, samplingMethod, width, splineIndex) {
|
|
const spline = this.splines[splineIndex];
|
|
|
|
// Check if this is a single point spline
|
|
if (spline && (spline.isSinglePoint || (spline.points && spline.points.length === 1))) {
|
|
// For a single point, return an array with the same coordinates repeated
|
|
const point = spline.points[0];
|
|
return Array(numSamples).fill().map(() => ({ x: point.x, y: point.y }));
|
|
}
|
|
|
|
if (!svgPathElement) {
|
|
console.warn(`Path element not found for spline index: ${splineIndex}. Available paths: ${this.pathElements.length}`);
|
|
|
|
|
|
const splinePoints = this.splines[splineIndex].points;
|
|
|
|
// If we have no points, return an empty array
|
|
if (!splinePoints || splinePoints.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Create a simple interpolation between control points
|
|
const result = [];
|
|
for (let i = 0; i < numSamples; i++) {
|
|
const t = i / (numSamples - 1);
|
|
const idx = Math.min(
|
|
Math.floor(t * (splinePoints.length - 1)),
|
|
splinePoints.length - 2
|
|
);
|
|
const fraction = (t * (splinePoints.length - 1)) - idx;
|
|
|
|
const x = splinePoints[idx].x + fraction * (splinePoints[idx + 1].x - splinePoints[idx].x);
|
|
const y = splinePoints[idx].y + fraction * (splinePoints[idx + 1].y - splinePoints[idx].y);
|
|
|
|
result.push({ x, y });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
var svgWidth = width; // Fixed width of the SVG element
|
|
var pathLength = svgPathElement.getTotalLength();
|
|
var points = [];
|
|
|
|
if (samplingMethod === "speed") {
|
|
// Calculate control point distances along the path
|
|
const controlPoints = this.splines[splineIndex].points;
|
|
const pathPositions = [];
|
|
|
|
// Find approximate path positions for each control point
|
|
for (const cp of controlPoints) {
|
|
let bestDist = Infinity;
|
|
let bestPos = 0;
|
|
|
|
// Sample the path to find closest point to each control point
|
|
for (let pos = 0; pos <= pathLength; pos += pathLength / 100) {
|
|
const pt = svgPathElement.getPointAtLength(pos);
|
|
const dist = Math.sqrt(Math.pow(pt.x - cp.x, 2) + Math.pow(pt.y - cp.y, 2));
|
|
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestPos = pos;
|
|
}
|
|
}
|
|
pathPositions.push(bestPos);
|
|
}
|
|
|
|
// Sort positions along path
|
|
pathPositions.sort((a, b) => a - b);
|
|
|
|
// Create a smooth speed mapping function with synchronization
|
|
const createSynchronizedMapping = () => {
|
|
// Calculate segment lengths and densities
|
|
const segments = [];
|
|
let totalLength = pathPositions[pathPositions.length - 1] - pathPositions[0];
|
|
|
|
for (let i = 0; i < pathPositions.length - 1; i++) {
|
|
const segLength = pathPositions[i+1] - pathPositions[i];
|
|
// Inverse relationship - shorter segments = higher density = slower speed
|
|
const density = 1 / Math.max(segLength, 0.0001);
|
|
segments.push({
|
|
position: pathPositions[i],
|
|
length: segLength,
|
|
density: density
|
|
});
|
|
}
|
|
|
|
// Create mapping function with forced synchronization at endpoints
|
|
return t => {
|
|
// Force synchronization at t=0 and t=1
|
|
if (t === 0) return 0;
|
|
if (t === 1) return pathLength;
|
|
|
|
// For intermediate points, use the speed control
|
|
// Scale t to fit between first and last control points
|
|
const firstPos = pathPositions[0];
|
|
const lastPos = pathPositions[pathPositions.length - 1];
|
|
|
|
// Create a density-weighted position mapping
|
|
let totalWeight = 0;
|
|
let weights = [];
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
totalWeight += segments[i].density;
|
|
weights.push(segments[i].density);
|
|
}
|
|
|
|
// Normalize weights
|
|
const normalizedWeights = weights.map(w => w / totalWeight);
|
|
|
|
// Calculate cumulative weights
|
|
let cumulativeWeight = 0;
|
|
const cumulativeWeights = normalizedWeights.map(w => {
|
|
cumulativeWeight += w;
|
|
return cumulativeWeight;
|
|
});
|
|
|
|
// Find the segment for this t value
|
|
let segmentIndex = 0;
|
|
for (let i = 0; i < cumulativeWeights.length; i++) {
|
|
if (t <= cumulativeWeights[i]) {
|
|
segmentIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Calculate position within segment
|
|
const segmentStart = segmentIndex > 0 ? cumulativeWeights[segmentIndex - 1] : 0;
|
|
const segmentEnd = cumulativeWeights[segmentIndex];
|
|
const segmentT = (t - segmentStart) / (segmentEnd - segmentStart);
|
|
|
|
// Map to path position
|
|
const pathStart = pathPositions[segmentIndex];
|
|
const pathEnd = pathPositions[segmentIndex + 1];
|
|
const pos = pathStart + segmentT * (pathEnd - pathStart);
|
|
|
|
// Scale to fill entire path
|
|
return pos;
|
|
};
|
|
};
|
|
|
|
const mapToPath = createSynchronizedMapping();
|
|
|
|
// Sample using the synchronized mapping function
|
|
for (let i = 0; i < numSamples; i++) {
|
|
const t = i / (numSamples - 1);
|
|
const pathPos = mapToPath(t);
|
|
const point = svgPathElement.getPointAtLength(pathPos);
|
|
points.push({ x: point.x, y: point.y });
|
|
}
|
|
|
|
return points;
|
|
|
|
}
|
|
else{
|
|
for (var i = 0; i < numSamples; i++) {
|
|
if (samplingMethod === "time") {
|
|
// Calculate the x-coordinate for the current sample based on the SVG's width
|
|
var x = (svgWidth / (numSamples - 1)) * i;
|
|
// Find the point on the path that intersects the vertical line at the calculated x-coordinate
|
|
var point = this.findPointAtX(svgPathElement, x, pathLength);
|
|
}
|
|
else if (samplingMethod === "path") {
|
|
// Calculate the distance along the path for the current sample
|
|
var distance = (pathLength / (numSamples - 1)) * i;
|
|
// Get the point at the current distance
|
|
var point = svgPathElement.getPointAtLength(distance);
|
|
}
|
|
|
|
// Add the point to the array of points
|
|
points.push({ x: point.x, y: point.y });
|
|
}
|
|
return points;
|
|
}
|
|
}
|
|
|
|
findClosestPoints(points, clickedPoint) {
|
|
// Calculate distances from clickedPoint to each point in the array
|
|
let distances = points.map(point => {
|
|
let dx = clickedPoint.x - point.x;
|
|
let dy = clickedPoint.y - point.y;
|
|
return { index: points.indexOf(point), distance: Math.sqrt(dx * dx + dy * dy) };
|
|
});
|
|
// Sort distances and get the indices of the two closest points
|
|
let sortedDistances = distances.sort((a, b) => a.distance - b.distance);
|
|
let closestPoint1Index = sortedDistances[0].index;
|
|
let closestPoint2Index = sortedDistances[1].index;
|
|
// Ensure point1Index is always the smaller index
|
|
if (closestPoint1Index > closestPoint2Index) {
|
|
[closestPoint1Index, closestPoint2Index] = [closestPoint2Index, closestPoint1Index];
|
|
}
|
|
return { point1Index: closestPoint1Index, point2Index: closestPoint2Index };
|
|
}
|
|
|
|
findPointAtX(svgPathElement, targetX, pathLength) {
|
|
let low = 0;
|
|
let high = pathLength;
|
|
let bestPoint = svgPathElement.getPointAtLength(0);
|
|
|
|
while (low <= high) {
|
|
let mid = low + (high - low) / 2;
|
|
let point = svgPathElement.getPointAtLength(mid);
|
|
|
|
if (Math.abs(point.x - targetX) < 1) {
|
|
return point; // The point is close enough to the target
|
|
}
|
|
|
|
if (point.x < targetX) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
|
|
// Keep track of the closest point found so far
|
|
if (Math.abs(point.x - targetX) < Math.abs(bestPoint.x - targetX)) {
|
|
bestPoint = point;
|
|
}
|
|
}
|
|
|
|
// Return the closest point found
|
|
return bestPoint;
|
|
}
|
|
} |