diff --git a/resources/icons/icons_unbind-data .svg b/resources/icons/icons_unbind-data .svg new file mode 100644 index 000000000..9becfa679 --- /dev/null +++ b/resources/icons/icons_unbind-data .svg @@ -0,0 +1,10 @@ + + icons + + + \ No newline at end of file diff --git a/src/app/resources/icons.ts b/src/app/resources/icons.ts index f15e5fe00..9021262c2 100644 --- a/src/app/resources/icons.ts +++ b/src/app/resources/icons.ts @@ -39,6 +39,7 @@ addSVGIcon("general/dropdown", require("resources/icons/icons_dropdown.svg")); addSVGIcon("Edit", require("resources/icons/icons_edit.svg")); addSVGIcon("general/eraser", require("resources/icons/icons_eraser.svg")); addSVGIcon("general/bind-data", require("resources/icons/icons_bind-data.svg")); +addSVGIcon("general/unbind-data", require("resources/icons/icons_unbind-data .svg")); addSVGIcon("general/confirm", require("resources/icons/icons_confirm.svg")); addSVGIcon( diff --git a/src/app/views/canvas/handles/BezierEditor.tsx b/src/app/views/canvas/handles/BezierEditor.tsx new file mode 100644 index 000000000..e7b9f6305 --- /dev/null +++ b/src/app/views/canvas/handles/BezierEditor.tsx @@ -0,0 +1,386 @@ +/* eslint-disable powerbi-visuals/insecure-random */ +/* eslint-disable max-lines-per-function */ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Prototypes, Point, ZoomInfo } from "../../../../core"; +import * as R from "../../../resources"; + +// The helper functions (getControlPoints, generatePathData) remain the same +// as the previous example. They are included here for completeness. + +function generatePathData(points) { + if (!points || points.length < 4) { + return ''; + } + + let path = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i += 3) { + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2]; + if (p3) { // Ensure all points for a segment exist + path += ` C ${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`; + } + } + return path; +} + +export interface IBezierEditor { + points: Point[]; + svgRef?: React.RefObject; + onChange: (points: { x: number, y: number }[], pathData: string, curve: Point[][]) => void; + x: number; + y: number; + width: number; + height: number; + handle: Prototypes.Handles.InputCurve; + zoom: ZoomInfo; +} + +// The main component updated with Hammer.js +// eslint-disable-next-line no-empty-pattern +export function BezierEditor({ handle, zoom, height, width, x, y, onChange, points: initialPoints }: IBezierEditor) { + + + const fX = (x: number) => + x * zoom.scale + zoom.centerX; + const fY = (y: number) => + -y * zoom.scale + zoom.centerY; + const transformPoint = (p: Point) => { + const scaler = Math.abs(handle.x2 - handle.x1) / 2; + const x = p.x * scaler + (handle.x1 + handle.x2) / 2; + const y = p.y * scaler + (handle.y1 + handle.y2) / 2; + return { + x: fX(x), + y: fY(y), + }; + }; + + const [draggingIndex, setDraggingIndex] = useState(null); + const svgRef = useRef(null); + + const getPoint = useCallback((x: number, y: number): Point => { + const bbox = svgRef.current.getBoundingClientRect(); + x -= bbox.left; + y -= bbox.top + bbox.height; + x /= zoom.scale; + y /= -zoom.scale; + // Scale x, y + const w = Math.abs(handle.x2 - handle.x1); + const h = Math.abs(handle.y2 - handle.y1); + return { + x: (x - w / 2) / (w / 2), + y: (y - h / 2) / (w / 2), + }; + }, [handle, zoom]) + + // TODO segments + const [points, setPoints] = useState(initialPoints); + const [symmetrical, setSymmetrical] = useState(true); + + useEffect(() => { + if (initialPoints.length <= 4) { + const extendedPoints = [...initialPoints] + for (let i = 0; i < 4 - initialPoints.length; i++) { + extendedPoints.push({ + x: Math.random(), + y: Math.random(), + }); + } + setPoints(extendedPoints); + return; + } + }, [initialPoints]) + + const handleMouseDown = (index) => { + setDraggingIndex(index); + }; + + const handleMouseUp = useCallback(() => { + setDraggingIndex(null); + }, []); + + const handleMouseMove = useCallback((event) => { + if (draggingIndex === null || !svgRef.current) return; + + console.log('mousemove', event.x, event.y, getPoint(event.x, event.y)) + const newPoint = getPoint(event.x, event.y); + + const newPoints = [...points]; + + const oldPoint = newPoints[draggingIndex]; + + newPoints[draggingIndex] = { x: newPoint.x, y: newPoint.y }; + + if (symmetrical && draggingIndex % 3 !== 0) { + let anchorIndex; + let siblingIndex; + + // If dragging a 'right' control point (like P2, P5) + if ((draggingIndex + 1) % 3 === 0) { + anchorIndex = draggingIndex + 1; + siblingIndex = draggingIndex + 2; + } + // If dragging a 'left' control point (like P1, P4) + if ((draggingIndex - 1) % 3 === 0) { + anchorIndex = draggingIndex - 1; + siblingIndex = draggingIndex - 2; + } + + if (siblingIndex >= 0 && siblingIndex < newPoints.length) { + const anchorPoint = newPoints[anchorIndex]; + const draggedPoint = newPoints[draggingIndex]; + + // Reflect the sibling point through the anchor + const dx = anchorPoint.x - draggedPoint.x; + const dy = anchorPoint.y - draggedPoint.y; + + newPoints[siblingIndex] = { x: anchorPoint.x + dx, y: anchorPoint.y + dy }; + } + } + + // if dragging anchor point + if ((draggingIndex) % 3 === 0) { + const dx = newPoints[draggingIndex].x - oldPoint.x; + const dy = newPoints[draggingIndex].y - oldPoint.y; + + if (draggingIndex !== points.length - 1) { + newPoints[draggingIndex + 1] = { x: newPoints[draggingIndex + 1].x + dx, y: newPoints[draggingIndex + 1].y + dy }; + } + if (draggingIndex !== 0) { + newPoints[draggingIndex - 1] = { x: newPoints[draggingIndex - 1].x + dx, y: newPoints[draggingIndex - 1].y + dy }; + } + } + + setPoints(newPoints); + }, [draggingIndex, getPoint, setPoints, symmetrical, points]); + + // Attach global mouse listeners for smoother dragging + useEffect(() => { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + + const pathData = generatePathData(points.map(transformPoint)); + + const renderBezierControlAddButton = (x: number, y: number) => { + const margin = 2; + const cx = x - 16 - margin; + const cy = y + 16 + margin; + return ( + { + setPoints((prevPoints) => { + const newPoints = [...prevPoints]; + newPoints.push({ x: Math.random(), y: Math.random() }); + newPoints.push({ x: Math.random(), y: Math.random() }); + newPoints.push({ x: Math.random(), y: Math.random() }); + return newPoints; + }); + }} + > + + + + ); + } + + const renderBezierControlRemoveButton = (x: number, y: number) => { + const margin = 2; + const cx = x - 16 - margin; + const cy = y + 16 + margin; + return ( + { + if (points.length <= 4) { + return; + } + setPoints((prevPoints) => { + const newPoints = [...prevPoints]; + newPoints.pop(); + newPoints.pop(); + newPoints.pop(); + return newPoints; + }); + }} + > + + + + ); + } + + const renderBezierControlCloseButton = (x: number, y: number) => { + const margin = 2; + const cx = x - 16 - margin; + const cy = y + 16 + margin; + return ( + { + const pathData = generatePathData(points.map(transformPoint)); + let doubledPoint = []; + if (points.length > 4) { + // double anchor points + doubledPoint = points.flatMap((point, index) => { + if (index === 0 || index === points.length - 1) { + return point; + } + if ((index) % 3 === 0) { + return [ + point, + point + ] + } + + return point; + }); + } else { + doubledPoint = points; + } + + const chunks: Point[][] = []; + const chunkSize = 4; + for (let i = 0; i < doubledPoint.length; i += chunkSize) { + const chunk = doubledPoint.slice(i, i + chunkSize); + if (chunk.length == 4) { + chunks.push(chunk) + } + } + + onChange(points, pathData, chunks); + }} + > + + + + ); + } + + const renderBezierControlSymmetryButton = (x: number, y: number) => { + const margin = 2; + const cx = x - 16 - margin; + const cy = y + 16 + margin; + return ( + { + setSymmetrical((prev) => !prev); + }} + > + + + + ); + } + + return ( + <> + {renderBezierControlAddButton( + Math.max(fX(handle.x1), fX(handle.x2)), + Math.min(fY(handle.y1), fY(handle.y2)) + 38 + )} + {renderBezierControlRemoveButton( + Math.max(fX(handle.x1), fX(handle.x2)), + Math.min(fY(handle.y1), fY(handle.y2)) + 38 * 2 + )} + {/* ADD SYmmetrical checkbox */} + {renderBezierControlSymmetryButton( + Math.max(fX(handle.x1), fX(handle.x2)), + Math.min(fY(handle.y1), fY(handle.y2)) + 38 * 3 + )} + {renderBezierControlCloseButton( + Math.max(fX(handle.x1), fX(handle.x2)), + Math.min(fY(handle.y1), fY(handle.y2)) + )} + + + + {/* The Bezier Curve Path */} + + {/* The Draggable Anchor Points */} + + + {points.map((point, index) => { + const pt = transformPoint(point); + return ( + <> + handleMouseDown(index)} + /> + + ); + })} + + + ); +} + +export default BezierEditor; \ No newline at end of file diff --git a/src/app/views/canvas/handles/input_curve.tsx b/src/app/views/canvas/handles/input_curve.tsx index 891c8252b..24e4b34a4 100644 --- a/src/app/views/canvas/handles/input_curve.tsx +++ b/src/app/views/canvas/handles/input_curve.tsx @@ -12,12 +12,15 @@ import { HandlesDragContext, HandleViewProps } from "./common"; import { strings } from "../../../../strings"; import { FluentInputNumber } from "../../panels/widgets/controls/fluentui_input_number"; +import BezierEditor from "./BezierEditor"; + export interface InputCurveHandleViewProps extends HandleViewProps { handle: Prototypes.Handles.InputCurve; } export interface InputCurveHandleViewState { enabled: boolean; - drawing: boolean; + drawingPen: boolean; + drawingCurve: boolean; points: Point[]; } @@ -31,7 +34,8 @@ export class InputCurveHandleView extends React.Component< public state: InputCurveHandleViewState = { enabled: false, - drawing: false, + drawingPen: false, + drawingCurve: false, points: [], }; @@ -105,26 +109,35 @@ export class InputCurveHandleView extends React.Component< this.hammer = new Hammer(this.refs.interaction); this.hammer.on("panstart", (e) => { + if (this.state.drawingCurve) { + return; + } const x = e.center.x - e.deltaX; const y = e.center.y - e.deltaY; this.setState({ - drawing: true, + drawingPen: true, points: [this.getPoint(x, y)], }); }); this.hammer.on("pan", (e) => { + if (this.state.drawingCurve) { + return; + } this.state.points.push(this.getPoint(e.center.x, e.center.y)); this.setState({ points: this.state.points, }); }); this.hammer.on("panend", () => { + if (this.state.drawingCurve) { + return; + } const curve = this.getBezierCurvesFromMousePoints(this.state.points); const context = new HandlesDragContext(); this.props.onDragStart(this.props.handle, context); context.emit("end", { value: curve }); this.setState({ - drawing: false, + drawingPen: false, enabled: false, }); }); @@ -134,6 +147,38 @@ export class InputCurveHandleView extends React.Component< this.hammer.destroy(); } + public renderCurveDrawing() { + const handle = this.props.handle; + const fX = (x: number) => + x * this.props.zoom.scale + this.props.zoom.centerX; + const fY = (y: number) => + -y * this.props.zoom.scale + this.props.zoom.centerY; + return ( + { + console.log('onChange', points, pathData) + this.setState({ + points: points, + drawingCurve: false, + enabled: false, + }); + const context = new HandlesDragContext(); + this.props.onDragStart(this.props.handle, context); + context.emit("end", { value: curve }); + }} + /> + ); + } + public renderDrawing() { const handle = this.props.handle; const fX = (x: number) => @@ -188,6 +233,77 @@ export class InputCurveHandleView extends React.Component< ); } + public renderClearButton(x: number, y: number) { + const margin = 2; + const cx = x - 16 - margin; + const cy = y + 16 + margin; + return ( + { + this.setState({ + points: [ + { x: -.5, y: -.5 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: .5, y: .5 } + ], + drawingCurve: false, + enabled: false, + drawingPen: false, + }); + + const context = new HandlesDragContext(); + this.props.onDragStart(this.props.handle, context); + context.emit("end", { value: [ + [ + { x: -1, y: -1 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 1, y: 1 } + ] + ] }); + }} + > + + + + ); + } + + public renderBezierCurvesButton(x: number, y: number) { + const margin = 2; + const cx = x - 16 - margin; + const cy = y + 16 + margin; + return ( + { + this.setState({ + enabled: true, + drawingCurve: true, + drawingPen: false, + }); + }} + > + + + + ); + } + // eslint-disable-next-line public renderSpiralButton(x: number, y: number) { const margin = 2; @@ -275,13 +391,13 @@ export class InputCurveHandleView extends React.Component< (a * (Math.cos(theta1) - theta1 * Math.sin(theta1))) / - scaler, + scaler, y: p1.y + (a * (Math.sin(theta1) + theta1 * Math.cos(theta1))) / - scaler, + scaler, }; const cp2 = { x: @@ -289,13 +405,13 @@ export class InputCurveHandleView extends React.Component< (a * (Math.cos(theta2) - theta2 * Math.sin(theta2))) / - scaler, + scaler, y: p2.y - (a * (Math.sin(theta2) + theta2 * Math.cos(theta2))) / - scaler, + scaler, }; curve.push([p1, cp1, cp2, p2].map(swapXY)); } @@ -329,12 +445,14 @@ export class InputCurveHandleView extends React.Component< ); } + private fX = (x: number) => + x * this.props.zoom.scale + this.props.zoom.centerX; + + private fY = (y: number) => + -y * this.props.zoom.scale + this.props.zoom.centerY; + public render() { const handle = this.props.handle; - const fX = (x: number) => - x * this.props.zoom.scale + this.props.zoom.centerX; - const fY = (y: number) => - -y * this.props.zoom.scale + this.props.zoom.centerY; return ( - {this.state.drawing ? this.renderDrawing() : null} + {this.state.drawingPen ? this.renderDrawing() : null} + {this.state.drawingCurve ? this.renderCurveDrawing() : null} {!this.state.enabled ? ( {this.renderSpiralButton( - Math.max(fX(handle.x1), fX(handle.x2)) - 38, - Math.min(fY(handle.y1), fY(handle.y2)) + Math.max(this.fX(handle.x1), this.fX(handle.x2)) - 76, + Math.min(this.fY(handle.y1), this.fY(handle.y2)) )} {this.renderButton( - Math.max(fX(handle.x1), fX(handle.x2)), - Math.min(fY(handle.y1), fY(handle.y2)) + Math.max(this.fX(handle.x1), this.fX(handle.x2)) - 38, + Math.min(this.fY(handle.y1), this.fY(handle.y2)) + )} + {this.renderBezierCurvesButton( + Math.max(this.fX(handle.x1), this.fX(handle.x2)), + Math.min(this.fY(handle.y1), this.fY(handle.y2)) + )} + {this.renderClearButton( + Math.max(this.fX(handle.x1), this.fX(handle.x2)), + Math.min(this.fY(handle.y1), this.fY(handle.y2)) + 38 )} ) : null} diff --git a/src/app/views/panels/widgets/fluent_mapping_editor.tsx b/src/app/views/panels/widgets/fluent_mapping_editor.tsx index 2a240a349..6179e564e 100644 --- a/src/app/views/panels/widgets/fluent_mapping_editor.tsx +++ b/src/app/views/panels/widgets/fluent_mapping_editor.tsx @@ -12,7 +12,6 @@ import { Specification, } from "../../../../core"; import { DragData } from "../../../actions"; -import { ColorPicker } from "../../../components/fluentui_color_picker"; import { ContextedComponent } from "../../../context_component"; import { isKindAcceptable, type2DerivedColumns } from "../../dataset/common"; import { ScaleEditor } from "../scale_editor";