import { app } from '../../../scripts/app.js' //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; } }