diff --git a/src/component/1d/BrushTracker1D.tsx b/src/component/1d/BrushTracker1D.tsx index 3a0d4cb11..93750ab76 100644 --- a/src/component/1d/BrushTracker1D.tsx +++ b/src/component/1d/BrushTracker1D.tsx @@ -260,9 +260,12 @@ export function BrushTracker1D({ children }: Required) { } const tools = new Set(['zoom']); + // The default brush follows the `general.invert` preference: the + // primary key activates the tool action, so the brush (the host + // default, i.e. the region zoom) is enabled when the primary key is + // NOT activated. const isDefaultBrushEnabled = - !tools.has(selectedTool) || - (tools.has(selectedTool) && !brushData.shiftKey); + !tools.has(selectedTool) || (tools.has(selectedTool) && !isPrimary); if (!shouldExecuteDefaultAction || selectedTool == null) { return; diff --git a/src/component/1d/plugin_chart_brush_tracker.tsx b/src/component/1d/plugin_chart_brush_tracker.tsx index 77da0d976..4c836980c 100644 --- a/src/component/1d/plugin_chart_brush_tracker.tsx +++ b/src/component/1d/plugin_chart_brush_tracker.tsx @@ -33,6 +33,7 @@ export function PluginChartBrushTracker() { if (!operatorUI) return null; const isLiveEditable = operatorUI?.isLiveEditable ?? false; + const isLivePreview = isLiveEditable && (liveEdit.value?.checked ?? false); return ( { + liveOperation = setLiveOperation(liveOperation); + + if (!isLiveEditable) return; + if (!liveEdit.value?.checked) return; + + void processingsMutations.applyLiveChange( + liveOperation, + liveEdit.value?.shouldProcessNext ?? false, + ); + }} height={height} width={width} margin={margin} diff --git a/src/component/EventsTrackers/BrushTracker.tsx b/src/component/EventsTrackers/BrushTracker.tsx index 254dc9f03..36d31c1c2 100644 --- a/src/component/EventsTrackers/BrushTracker.tsx +++ b/src/component/EventsTrackers/BrushTracker.tsx @@ -28,6 +28,8 @@ import type { MouseButton, Position, } from './brush_tracker.types.ts'; +import type { DragGestureController } from './brush_tracker_drag_gesture.ts'; +import { createDragGesture } from './brush_tracker_drag_gesture.ts'; import type { BrushTrackerEventEmitter } from './brush_tracker_events_listeners.ts'; import { BrushTrackerEventEmitterContext, @@ -83,6 +85,15 @@ export function useBrushTracker() { function isSelfControlledTarget(target: EventTarget) { return (target as Element).closest('[data-self-control="true"]') !== null; } + +/** + * Minimal pointer travel in px between pointerdown and pointerup for the + * gesture to be considered a drag. Pointer jitter below this threshold + * (hand tremor, trackpad imprecision) still counts as a click. + * Keep it in sync with the consumer drag threshold + * (`ProcessingOperatorUI.ChartBrushTracker` dragThreshold). + */ +const dragDetectionThreshold = 4; function getMouseXY(event: MouseEvent, currentTarget?: Element) { const boundingRect = ( currentTarget ?? event.currentTarget @@ -112,8 +123,16 @@ export function BrushTracker(options: BrushTrackerProps) { const lastRef = useRef({ x: 0, y: 0 }); const isSelfControlledRef = useRef(false); - const { onClick, onDoubleClick, onBrush, onBrushEnd, onZoom, emitter } = - useWrappedEvents(options); + const { + onClick, + onDoubleClick, + onBrush, + onBrushEnd, + onZoom, + onDrag, + onDragEnd, + emitter, + } = useWrappedEvents(options); const handleClickWithDebounce = useCallback( (event: MouseEvent, currentTarget: Element) => { @@ -179,6 +198,8 @@ export function BrushTracker(options: BrushTrackerProps) { isDraggingRef.current = false; // Reset dragging flag + let dragGesture: DragGestureController | null = null; + //check that the right or left mouse button pressed if ([0, 2].includes(event.button)) { if (noPropagation) { @@ -193,6 +214,14 @@ export function BrushTracker(options: BrushTrackerProps) { }; lastRef.current = { x: 0, y: 0 }; + dragGesture = createDragGesture({ + mouseButton: MouseButtons[event.button], + startX: startPositionRef.current.x, + startY: startPositionRef.current.y, + rectX: boundingRect.x, + rectY: boundingRect.y, + }); + if (!event.ctrlKey) { dispatch({ type: 'DOWN', @@ -211,16 +240,31 @@ export function BrushTracker(options: BrushTrackerProps) { } function moveCallback(event: globalThis.PointerEvent) { - isDraggingRef.current = true; // set flag to true to skip click event if the user dragged the mouse - const { - clientX, - clientY, - screenX, - screenY, - shiftKey, - altKey, - ctrlKey, - } = event; + const { clientX, clientY } = event; + + // Only a real pointer travel marks the gesture as a drag: pointer + // jitter must not suppress the click event dispatched on pointerup. + // Both positions must be compared in the same coordinate system: + // `clientX` is viewport-relative while `startPositionRef` is + // relative to the chart container, so the container offset must + // be removed before comparing, otherwise the layout offset itself + // counts as pointer travel. The same threshold also gates the + // `MOVE` dispatch, so pointer jitter neither shows the brush + // banner nor updates the brush range while clicking (e.g. adding + // anchors). + const boundingRect = boundingRectRef.current; + const isPointerTravel = + boundingRect && + Math.hypot( + clientX - boundingRect.x - startPositionRef.current.x, + clientY - boundingRect.y - startPositionRef.current.y, + ) > dragDetectionThreshold; + + if (isPointerTravel) { + isDraggingRef.current = true; + } + + const { screenX, screenY, shiftKey, altKey, ctrlKey } = event; if (ctrlKey) { if (boundingRectRef.current) { @@ -248,19 +292,31 @@ export function BrushTracker(options: BrushTrackerProps) { lastRef.current = { x, y }; } } else { - dispatch({ - type: 'MOVE', - payload: { - screenX, - screenY, - clientX, - clientY, - }, - }); + const dragEvent = dragGesture?.move(event); + if (dragEvent) { + onDrag(dragEvent); + } + + if (!dragEvent?.isPropagationStopped() && isPointerTravel) { + dispatch({ + type: 'MOVE', + payload: { + screenX, + screenY, + clientX, + clientY, + }, + }); + } } } - function upCallback() { + function upCallback(pointerUpEvent: globalThis.PointerEvent) { + const dragEndEvent = dragGesture?.end(pointerUpEvent); + if (dragEndEvent) { + onDragEnd(dragEndEvent); + } + if (isDraggingRef.current) { dispatch({ type: 'UP', @@ -283,7 +339,14 @@ export function BrushTracker(options: BrushTrackerProps) { return false; }, - [clickTriggerMode, handleClickWithDebounce, noPropagation, onZoom], + [ + clickTriggerMode, + handleClickWithDebounce, + noPropagation, + onDrag, + onDragEnd, + onZoom, + ], ); const handleMouseWheel = useCallback( @@ -590,7 +653,13 @@ export function detectBrushing( type UseWrappedEventsProps = Pick< BrushTrackerProps, - 'onBrush' | 'onBrushEnd' | 'onClick' | 'onDoubleClick' | 'onZoom' + | 'onBrush' + | 'onBrushEnd' + | 'onClick' + | 'onDoubleClick' + | 'onZoom' + | 'onDrag' + | 'onDragEnd' >; interface UseWrappedEvents extends Required { @@ -610,6 +679,8 @@ function useWrappedEvents(props: UseWrappedEventsProps): UseWrappedEvents { onZoom: onZoomProp = noop, onDoubleClick: onDoubleClickProp = noop, onClick: onClickProp = noop, + onDrag: onDragProp = noop, + onDragEnd: onDragEndProp = noop, } = props; const emitter = useInitBrushTrackerEventEmitter(); @@ -680,5 +751,44 @@ function useWrappedEvents(props: UseWrappedEventsProps): UseWrappedEvents { }, ); - return { onClick, onDoubleClick, onBrush, onBrushEnd, onZoom, emitter }; + const onDrag = useEventCallback, void>( + (event) => { + try { + emitter.emit('drag', event); + } catch (error) { + reportError(error); + } + + if (event.isPropagationStopped()) return; + if (event.isDefaultPrevented()) return; + + onDragProp(event); + }, + ); + + const onDragEnd = useEventCallback, void>( + (event) => { + try { + emitter.emit('drag-end', event); + } catch (error) { + reportError(error); + } + + if (event.isPropagationStopped()) return; + if (event.isDefaultPrevented()) return; + + onDragEndProp(event); + }, + ); + + return { + onClick, + onDoubleClick, + onBrush, + onBrushEnd, + onZoom, + onDrag, + onDragEnd, + emitter, + }; } diff --git a/src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts b/src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts new file mode 100644 index 000000000..623a30731 --- /dev/null +++ b/src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'vitest'; + +import type { DragGestureOptions } from '../brush_tracker_drag_gesture.ts'; +import { createDragGesture } from '../brush_tracker_drag_gesture.ts'; + +function createPointerEvent( + options: Partial = {}, +): globalThis.PointerEvent { + return { + clientX: 0, + clientY: 0, + shiftKey: false, + altKey: false, + ctrlKey: false, + ...options, + } as globalThis.PointerEvent; +} + +const options: DragGestureOptions = { + mouseButton: 'main', + startX: 30, + startY: 40, + rectX: 100, + rectY: 200, +}; + +describe('createDragGesture', () => { + test('move event coordinates are relative to the chart container', () => { + const gesture = createDragGesture(options); + + const event = gesture.move( + createPointerEvent({ clientX: 120, clientY: 280 }), + ); + + expect(event.x).toBe(20); + expect(event.y).toBe(80); + expect(event.startX).toBe(30); + expect(event.startY).toBe(40); + }); + + test('move event carries the pointerdown mouse button and move modifier keys', () => { + const gesture = createDragGesture({ + ...options, + mouseButton: 'secondary', + }); + + const event = gesture.move( + createPointerEvent({ shiftKey: true, altKey: true, ctrlKey: false }), + ); + + expect(event.mouseButton).toBe('secondary'); + expect(event.shiftKey).toBe(true); + expect(event.altKey).toBe(true); + expect(event.ctrlKey).toBe(false); + }); + + test('claiming a drag persists for the rest of the gesture', () => { + const gesture = createDragGesture(options); + + const firstEvent = gesture.move(createPointerEvent()); + expect(firstEvent.isPropagationStopped()).toBe(false); + + firstEvent.stopPropagation(); + + expect(firstEvent.isPropagationStopped()).toBe(true); + + const secondEvent = gesture.move(createPointerEvent()); + expect(secondEvent.isPropagationStopped()).toBe(true); + + const endEvent = gesture.end(createPointerEvent()); + expect(endEvent?.isPropagationStopped()).toBe(true); + }); + + test('default prevention persists for the rest of the gesture', () => { + const gesture = createDragGesture(options); + + const firstEvent = gesture.move(createPointerEvent()); + firstEvent.preventDefault(); + + expect(firstEvent.isDefaultPrevented()).toBe(true); + + const secondEvent = gesture.move(createPointerEvent()); + expect(secondEvent.isDefaultPrevented()).toBe(true); + }); + + test('end returns null when no drag was emitted', () => { + const gesture = createDragGesture(options); + + expect(gesture.end(createPointerEvent())).toBeNull(); + }); + + test('end event uses the pointerup coordinates', () => { + const gesture = createDragGesture(options); + + gesture.move(createPointerEvent({ clientX: 130, clientY: 240 })); + + const endEvent = gesture.end( + createPointerEvent({ clientX: 150, clientY: 260 }), + ); + + expect(endEvent?.x).toBe(50); + expect(endEvent?.y).toBe(60); + }); +}); diff --git a/src/component/EventsTrackers/brush_tracker.types.ts b/src/component/EventsTrackers/brush_tracker.types.ts index a44e6ea43..8f668df98 100644 --- a/src/component/EventsTrackers/brush_tracker.types.ts +++ b/src/component/EventsTrackers/brush_tracker.types.ts @@ -49,6 +49,47 @@ export type OnZoom = (options: ZoomOptions) => void; export type OnBrush = (state: BrushTrackerData) => void; type OnBrushEnd = OnBrush; +interface DragCoordination { + /** + x coordinate relative to the chart container + */ + x: number; + /** + y coordinate relative to the chart container + */ + y: number; + /** + x coordinate relative to the chart container at pointerdown + */ + startX: number; + /** + y coordinate relative to the chart container at pointerdown + */ + startY: number; +} + +export interface DragTrackerEvent extends DragCoordination { + /** + Native pointer event that triggered this event + */ + domEvent: globalThis.PointerEvent; + mouseButton: MouseButton; + shiftKey: boolean; + altKey: boolean; + ctrlKey: boolean; + isDefaultPrevented(): boolean; + isPropagationStopped(): boolean; + preventDefault(): void; + /** + * Claim the current gesture as a drag. + * Prevents the default brush behavior for the rest of the gesture. + */ + stopPropagation(): void; +} + +type OnDrag = (event: DragTrackerEvent) => void; +type OnDragEnd = OnDrag; + export type BaseDetectBrushingOptions = AdvanceOmit< DetectBrushingOptions, 'width' | 'height' @@ -60,6 +101,8 @@ export interface BrushTrackerProps { style?: CSSProperties; onBrushEnd?: OnBrushEnd; onBrush?: OnBrush; + onDrag?: OnDrag; + onDragEnd?: OnDragEnd; onZoom?: OnZoom; onDoubleClick?: OnDoubleClick; onClick?: OnClick; diff --git a/src/component/EventsTrackers/brush_tracker_drag_gesture.ts b/src/component/EventsTrackers/brush_tracker_drag_gesture.ts new file mode 100644 index 000000000..ea5546aaf --- /dev/null +++ b/src/component/EventsTrackers/brush_tracker_drag_gesture.ts @@ -0,0 +1,84 @@ +import type { DragTrackerEvent, MouseButton } from './brush_tracker.types.ts'; + +export interface DragGestureOptions { + mouseButton: MouseButton; + /** + x coordinate of the pointerdown relative to the chart container. + */ + startX: number; + /** + y coordinate of the pointerdown relative to the chart container. + */ + startY: number; + /** + x of the chart container bounding rect origin. + */ + rectX: number; + /** + y of the chart container bounding rect origin. + */ + rectY: number; +} + +export interface DragGestureController { + /** + * Create the drag event for a pointermove. + * The consumer decides via the event claim API (`stopPropagation`) + * whether the gesture takes precedence over the default brush behavior. + */ + move(pointerEvent: globalThis.PointerEvent): DragTrackerEvent; + /** + * Create the drag-end event for the pointerup, + * or `null` if no drag was emitted during the gesture. + */ + end(pointerEvent: globalThis.PointerEvent): DragTrackerEvent | null; +} + +/** + * Stateful factory for the drag events of a single pointer gesture. + * + * The claim state (`stopPropagation` / `preventDefault`) is shared by all + * the events of the gesture, so a consumer claiming the gesture on the + * first `drag` event keeps it claimed until `drag-end`. + */ +export function createDragGesture( + options: DragGestureOptions, +): DragGestureController { + const { mouseButton, startX, startY, rectX, rectY } = options; + let claimed = false; + let defaultPrevented = false; + let emitted = false; + + function build(pointerEvent: globalThis.PointerEvent): DragTrackerEvent { + return { + x: pointerEvent.clientX - rectX, + y: pointerEvent.clientY - rectY, + startX, + startY, + domEvent: pointerEvent, + mouseButton, + shiftKey: pointerEvent.shiftKey, + altKey: pointerEvent.altKey, + ctrlKey: pointerEvent.ctrlKey, + isDefaultPrevented: () => defaultPrevented, + isPropagationStopped: () => claimed, + preventDefault: () => { + defaultPrevented = true; + }, + stopPropagation: () => { + claimed = true; + }, + }; + } + + return { + move(pointerEvent) { + emitted = true; + return build(pointerEvent); + }, + end(pointerEvent) { + if (!emitted) return null; + return build(pointerEvent); + }, + }; +} diff --git a/src/component/EventsTrackers/brush_tracker_events_listeners.ts b/src/component/EventsTrackers/brush_tracker_events_listeners.ts index f7e960525..2955ff6d7 100644 --- a/src/component/EventsTrackers/brush_tracker_events_listeners.ts +++ b/src/component/EventsTrackers/brush_tracker_events_listeners.ts @@ -3,7 +3,13 @@ import { EventEmitter } from 'eventemitter3'; import { createContext, use, useState } from 'react'; export type BrushTrackerEventEmitterSupportedEvents = - 'click' | 'click-double' | 'zoom' | 'brush' | 'brush-end'; + | 'click' + | 'click-double' + | 'zoom' + | 'brush' + | 'brush-end' + | 'drag' + | 'drag-end'; export type BrushTrackerEventEmitter = EventEmitter; diff --git a/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx b/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx index 325208d6c..28460c015 100644 --- a/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx +++ b/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx @@ -7,6 +7,12 @@ import { Button } from 'react-science/ui'; import type { UseLiveEdit } from './use_live_edit.ts'; interface OperatorEditBannerProps { + /** + * Content rendered at the leftmost position of the banner, before the + * live preview controls. Provided by the processing operator UI + * (second argument of the `children` render method). + */ + leftContent?: ReactNode; isLiveEditable: boolean; liveEdit: UseLiveEdit; onClose: () => void; @@ -14,10 +20,11 @@ interface OperatorEditBannerProps { } export function OperatorEditBanner(props: OperatorEditBannerProps) { - const { isLiveEditable, liveEdit, children, onClose } = props; + const { leftContent, isLiveEditable, liveEdit, children, onClose } = props; return ( + {leftContent} {isLiveEditable && ( <> - {(submitButton) => + {(submitButton, leftContent) => !isEditable ? null : ( selectProcessingOperator(undefined)} diff --git a/src/component/utility/core_slots/core_operator_chart_brush.commons.ts b/src/component/utility/core_slots/core_operator_chart_brush.commons.ts index c6410c14a..faa014665 100644 --- a/src/component/utility/core_slots/core_operator_chart_brush.commons.ts +++ b/src/component/utility/core_slots/core_operator_chart_brush.commons.ts @@ -1,5 +1,6 @@ import type { BrushTrackerEvents, + BrushTrackerEventsDragEvent, BrushTrackerEventsOnClickEvent, BrushTrackerEventsTrackerEvent, BrushTrackerEventsZoomEvent, @@ -11,6 +12,7 @@ import { useEventCallback } from 'usehooks-ts'; import type { BrushTrackerData, ClickOptions, + DragTrackerEvent, ZoomOptions, } from '../../EventsTrackers/brush_tracker.types.ts'; import type { @@ -18,6 +20,10 @@ import type { BrushTrackerEventEmitterSupportedEvents, } from '../../EventsTrackers/brush_tracker_events_listeners.ts'; import { useBrushTrackerEventEmitter } from '../../EventsTrackers/brush_tracker_events_listeners.ts'; +import { + getModifiers, + useMapKeyModifiers, +} from '../../context/KeyModifierContext.tsx'; /** * Hook to forward to `ProcessingOperatorUI.ChartBrushTracker`. @@ -25,6 +31,7 @@ import { useBrushTrackerEventEmitter } from '../../EventsTrackers/brush_tracker_ */ export function useBrushTrackerEvent(events: BrushTrackerEvents) { const emitter = useBrushTrackerEventEmitter(); + const isPrimaryKeyActivated = useMapKeyModifiers(); // // Integration mapping to core events listening stabilized @@ -46,15 +53,37 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { events.onZoom?.(event); }); const onClickStable = useEventCallback((options: ClickOptions) => { - const event = clickOptionsToCoreClickEvent(options); + const event = clickOptionsToCoreClickEvent( + options, + isPrimaryKeyActivated(getModifiers(options)), + ); events.onClick?.(event); }); const onDoubleClickStable = useEventCallback((options: ClickOptions) => { - const event = clickOptionsToCoreClickEvent(options); + const event = clickOptionsToCoreClickEvent( + options, + isPrimaryKeyActivated(getModifiers(options)), + ); events.onDoubleClick?.(event); }); + const onDragStable = useEventCallback((data: DragTrackerEvent) => { + const event = dragTrackerEventToCoreDragEvent( + data, + isPrimaryKeyActivated(getModifiers(getDragEventModifiers(data))), + ); + + events.onDrag?.(event); + }); + const onDragEndStable = useEventCallback((data: DragTrackerEvent) => { + const event = dragTrackerEventToCoreDragEvent( + data, + isPrimaryKeyActivated(getModifiers(getDragEventModifiers(data))), + ); + + events.onDragEnd?.(event); + }); // // Use stable version if the event is defined. @@ -66,6 +95,8 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { const onZoom = events.onZoom && onZoomStable; const onClick = events.onClick && onClickStable; const onDoubleClick = events.onDoubleClick && onDoubleClickStable; + const onDrag = events.onDrag && onDragStable; + const onDragEnd = events.onDragEnd && onDragEndStable; // // Forward the events listeners @@ -76,6 +107,8 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { useRegisterListener(emitter, 'zoom', onZoom); useRegisterListener(emitter, 'click', onClick); useRegisterListener(emitter, 'click-double', onDoubleClick); + useRegisterListener(emitter, 'drag', onDrag); + useRegisterListener(emitter, 'drag-end', onDragEnd); } function useRegisterListener( @@ -131,8 +164,71 @@ function zoomOptionsToCoreZoomEvent( function clickOptionsToCoreClickEvent( event: ClickOptions, + isPrimaryKeyActivated: boolean, ): BrushTrackerEventsOnClickEvent { const { x, y, ...domEvent } = event; - return { domEvent, position: { x, y } }; + return { + // Claiming the event flags the emitted tracker event (NOT the native + // domEvent, whose stopPropagation would not be honored by the veto + // check), so the host `useWrappedEvents` skips its default behavior + // for this event (like the double-click zoom-out). + stopPropagation: () => claimTrackerEvent(event), + domEvent, + position: { x, y }, + isPrimaryKeyActivated, + }; +} + +/** + * Flag a tracker event as propagation-stopped. + * + * The emitted click events are spreads of the React synthetic event, so + * they carry an own `isPropagationStopped` flag but no `stopPropagation` + * method: it lives on the synthetic event prototype and is lost in the + * spread. The claim reassigns the flag on the tracker event itself, + * which is the flag checked by the host `useWrappedEvents` after + * emitting to its listeners. + */ +function claimTrackerEvent(event: ClickOptions) { + event.isPropagationStopped = () => true; +} + +function dragTrackerEventToCoreDragEvent( + event: DragTrackerEvent, + isPrimaryKeyActivated: boolean, +): BrushTrackerEventsDragEvent { + const { + x, + y, + startX, + startY, + domEvent, + mouseButton, + ctrlKey, + altKey, + shiftKey, + } = event; + + return { + // Claiming the gesture delegates to the tracker event (NOT the native + // domEvent, whose stopPropagation is a no-op for the veto), so the + // brush suppression is honored by the host `BrushTracker`. + stopPropagation: () => event.stopPropagation(), + mouseButton, + modifiersKey: { ctrlKey, altKey, shiftKey, metaKey: domEvent.metaKey }, + position: { x, y }, + positionStart: { x: startX, y: startY }, + isPrimaryKeyActivated, + }; +} + +/** + * Modifier keys of a drag tracker event, in the shape expected by the + * `KeyModifierContext` helpers (`metaKey` comes from the native event). + */ +function getDragEventModifiers(event: DragTrackerEvent) { + const { shiftKey, altKey, ctrlKey, domEvent } = event; + + return { shiftKey, altKey, ctrlKey, metaKey: domEvent.metaKey }; } diff --git a/src/component/utility/core_slots/core_operator_chart_svg.tsx b/src/component/utility/core_slots/core_operator_chart_svg.tsx index 74305c6d1..593073d86 100644 --- a/src/component/utility/core_slots/core_operator_chart_svg.tsx +++ b/src/component/utility/core_slots/core_operator_chart_svg.tsx @@ -17,6 +17,8 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { core, spectrum, operation, + onChange, + isLivePreview, height, width, margin, @@ -34,6 +36,8 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { core={core} spectrum={spectrum} operation={operation} + onChange={onChange} + isLivePreview={isLivePreview} height={height} width={width} margin={margin}