Spaces:
Running
on
Zero
Running
on
Zero
| 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; | |
| } | |
| } |