diff --git a/src/commons/sagas/WorkspaceSaga/helpers/updateInspector.ts b/src/commons/sagas/WorkspaceSaga/helpers/updateInspector.ts index ac3316fb19..34823809cd 100644 --- a/src/commons/sagas/WorkspaceSaga/helpers/updateInspector.ts +++ b/src/commons/sagas/WorkspaceSaga/helpers/updateInspector.ts @@ -47,8 +47,8 @@ export function* updateInspector(workspaceLocation: WorkspaceLocation): SagaIter } catch (e) { // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. yield put(actions.setEditorHighlightedLines(workspaceLocation, 0, [])); - // most likely harmless, we can pretty much ignore this. - // half of the time this comes from execution ending or a stack overflow and - // the context goes missing. + // Log rendering errors so they are not silently swallowed — the visualization + // simply won't update when this fires, which looks like the diagram is "stuck". + console.error('[updateInspector] Visualization rendering failed:', e); } } diff --git a/src/commons/sideContent/content/SideContentCseMachine.tsx b/src/commons/sideContent/content/SideContentCseMachine.tsx index e2205f1785..b2f6e79c8b 100644 --- a/src/commons/sideContent/content/SideContentCseMachine.tsx +++ b/src/commons/sideContent/content/SideContentCseMachine.tsx @@ -393,6 +393,12 @@ class SideContentCseMachineBase extends Component { label="From stash" onChange={() => this.toggleArrowFilter('stash')} /> + this.togglePairCreationModeArrows()} + /> } > @@ -636,7 +642,8 @@ class SideContentCseMachineBase extends Component { }; private stepNextChangepoint = () => { - for (const step of this.props.changepointSteps) { + const changeSteps = this.props.changepointSteps; + for (const step of changeSteps) { if (step > this.state.value) { this.sliderShift(step); this.sliderRelease(step); @@ -648,8 +655,9 @@ class SideContentCseMachineBase extends Component { }; private stepPrevChangepoint = () => { - for (let i = this.props.changepointSteps.length - 1; i >= 0; i--) { - const step = this.props.changepointSteps[i]; + const changeSteps = this.props.changepointSteps; + for (let i = changeSteps.length - 1; i >= 0; i--) { + const step = changeSteps[i]; if (step < this.state.value) { this.sliderShift(step); this.sliderRelease(step); @@ -666,6 +674,11 @@ class SideContentCseMachineBase extends Component { this.refreshArrowFilters(); }; + private togglePairCreationModeArrows = () => { + CseMachine.togglePairCreationMode(); + this.refreshArrowFilters(); + }; + private setAllArrowFilters = (visible: boolean) => { CseMachine.setAllArrowOriginsVisible(visible); this.refreshArrowFilters(); diff --git a/src/features/cseMachine/CseMachine.tsx b/src/features/cseMachine/CseMachine.tsx index 6d14dce791..a0a9f9767c 100644 --- a/src/features/cseMachine/CseMachine.tsx +++ b/src/features/cseMachine/CseMachine.tsx @@ -50,6 +50,7 @@ export default class CseMachine { private static printableMode: boolean = false; private static controlStash: boolean = false; // TODO: discuss if the default should be true private static stackTruncated: boolean = false; + private static pairCreationMode: boolean = false; private static centerAlignment: boolean = false; private static centerAlignmentToggled: boolean = false; private static arrowOriginFilters: ArrowOriginFilters = { @@ -59,6 +60,7 @@ export default class CseMachine { private static currentEnvId: string; private static control: Control | undefined; private static stash: Stash | undefined; + private static streamLineage: Map = new Map(); public static togglePrintableMode(): void { CseMachine.printableMode = !CseMachine.printableMode; } @@ -68,6 +70,9 @@ export default class CseMachine { public static toggleStackTruncated(): void { CseMachine.stackTruncated = !CseMachine.stackTruncated; } + public static togglePairCreationMode(): void { + CseMachine.pairCreationMode = !CseMachine.pairCreationMode; + } public static setClearDeadFrames(enabled: boolean): void { Layout.clearDeadFrames = enabled; } @@ -172,7 +177,21 @@ export default class CseMachine { } } } + public static getStreamLineage(key: string): string[] | undefined { + return CseMachine.streamLineage.get(key); + } + public static findKeyByValueInMap(value: any) { + for (const [key, array] of CseMachine.streamLineage.entries()) { + if (array.includes(value)) { + return key; + } + } + return undefined; + } + public static getPairCreationMode(): boolean { + return CseMachine.pairCreationMode; + } public static isControl(): boolean { return this.control ? !this.control.isEmpty() : false; } @@ -207,6 +226,7 @@ export default class CseMachine { throw new Error('CSE machine not initialized'); CseMachine.control = context.runtime.control; CseMachine.stash = context.runtime.stash; + CseMachine.streamLineage = context.streamLineage; CseMachine.setClearDeadFrames(false); Layout.setContext( @@ -405,7 +425,15 @@ export default class CseMachine { CseAnimation.updateAnimation(); } - if ( + if (CseMachine.getPairCreationMode()) { + Layout.setContext(CseMachine.environmentTree, CseMachine.control, CseMachine.stash); + if (!CseMachine.getMasterLayout()) { + CseMachine.setMasterLayout(Layout.getLayoutPositions(this.controlStash)); + } + Layout.applyFixedPositions(); + CseAnimation.updateAnimation(); + this.setVis(Layout.draw()); + } else if ( CseMachine.getPrintableMode() && CseMachine.getControlStash() && CseMachine.getStackTruncated() && diff --git a/src/features/cseMachine/CseMachineLayout.tsx b/src/features/cseMachine/CseMachineLayout.tsx index de32ebca7b..8bb4681697 100644 --- a/src/features/cseMachine/CseMachineLayout.tsx +++ b/src/features/cseMachine/CseMachineLayout.tsx @@ -206,6 +206,7 @@ export class Layout { Layout.currentStackTruncDark = undefined; Layout.currentStackLight = undefined; Layout.currentStackTruncLight = undefined; + // clear/initialize data and value arrays Layout.values.clear(); arrowSelection.clearSelection(); @@ -789,6 +790,7 @@ export class Layout { ); + Layout.prevLayout = layout; if (CseMachine.getPrintableMode()) { if (CseMachine.getControlStash()) { diff --git a/src/features/cseMachine/components/arrows/ArrowFromStreamNullaryFn.tsx b/src/features/cseMachine/components/arrows/ArrowFromStreamNullaryFn.tsx new file mode 100644 index 0000000000..3ebfcb6698 --- /dev/null +++ b/src/features/cseMachine/components/arrows/ArrowFromStreamNullaryFn.tsx @@ -0,0 +1,49 @@ +import { Config } from '../../CseMachineConfig'; +import type { StepsArray } from '../../CseMachineTypes'; +import { ArrayValue } from '../values/ArrayValue'; +import { ContValue } from '../values/ContValue'; +import { FnValue } from '../values/FnValue'; +import { GlobalFnValue } from '../values/GlobalFnValue'; +import { DottedArrow } from './DottedArrow'; + +/** this class encapsulates an GenericArrow to be drawn between 2 points */ +export class ArrowFromStreamNullaryFn extends DottedArrow { + constructor( + from: FnValue | GlobalFnValue | ContValue, + public offsetIndex: number = 0, + ) { + super(from); + this.faded = !from.isReferenced(); + } + + protected calculateSteps() { + const from = this.source as FnValue | GlobalFnValue | ContValue; + const to = this.target; + + if (!to || !(to instanceof ArrayValue)) return []; + + const verticalShift = this.offsetIndex * 20; // 20px vertical separation for multiple arrows to the same target + + // The arrow starts from the right of the function circle + const startPointX = from.centerX + 2 * from.radius; + const startPointY = from.y(); + // The arrow ends at the top-center of the array + const endPointX = to.x() + Config.DataUnitWidth / 2; + const endPointY = to.y(); + + // An intermediate point is used to create the arch. + // It is placed horizontally between the start and end points, + // and vertically above them to form an upward arch. + const midPointX = (startPointX + endPointX) / 2; + const archHeight = 50; + const midPointY = Math.min(startPointY, endPointY) - archHeight - verticalShift; + const steps: StepsArray = [ + // The GenericArrow class will draw a path through these points, + // creating smooth curves at the corners. + () => [startPointX, startPointY], + () => [midPointX, midPointY], + () => [endPointX, endPointY], + ]; + return steps; + } +} diff --git a/src/features/cseMachine/components/arrows/DottedArrow.tsx b/src/features/cseMachine/components/arrows/DottedArrow.tsx new file mode 100644 index 0000000000..5f150f3c9c --- /dev/null +++ b/src/features/cseMachine/components/arrows/DottedArrow.tsx @@ -0,0 +1,33 @@ +import { Arrow as KonvaArrow, Group as KonvaGroup, Path as KonvaPath } from 'react-konva'; + +import CseMachine from '../../CseMachine'; +import { Config, ShapeDefaultProps } from '../../CseMachineConfig'; +import { Layout } from '../../CseMachineLayout'; +import type { IVisible } from '../../CseMachineTypes'; +import { GenericArrow } from './GenericArrow'; + +export class DottedArrow extends GenericArrow { + draw() { + const stroke = CseMachine.getPrintableMode() ? '#9B870C' : '#ded74e'; + return ( + + + + + ); + } +} diff --git a/src/features/cseMachine/components/values/ArrayValue.tsx b/src/features/cseMachine/components/values/ArrayValue.tsx index e4458681c9..4f77ce7592 100644 --- a/src/features/cseMachine/components/values/ArrayValue.tsx +++ b/src/features/cseMachine/components/values/ArrayValue.tsx @@ -1,4 +1,5 @@ import type { KonvaEventObject } from 'konva/lib/Node'; +import React from 'react'; import { Group } from 'react-konva'; import CseMachine from '../../CseMachine'; diff --git a/src/features/cseMachine/components/values/FnValue.tsx b/src/features/cseMachine/components/values/FnValue.tsx index 24c4a12266..9b8bc89b20 100644 --- a/src/features/cseMachine/components/values/FnValue.tsx +++ b/src/features/cseMachine/components/values/FnValue.tsx @@ -25,6 +25,7 @@ import { } from '../../CseMachineUtils'; import { ArrowFromFn } from '../arrows/ArrowFromFn'; import { ArrowFromFnTooltip } from '../arrows/ArrowFromFnTooltip'; +import { ArrowFromStreamNullaryFn } from '../arrows/ArrowFromStreamNullaryFn'; import { Binding } from '../Binding'; import { Frame } from '../Frame'; import { FunctionTooltipLabels, Value } from './Value'; @@ -58,6 +59,8 @@ export class FnValue extends Value implements IHoverable { enclosingFrame?: Frame; private isExpandedDescription: boolean = false; private _arrow: ArrowFromFn | undefined; + private _streamArrows: ArrowFromStreamNullaryFn[] = []; + private tooltipArrow: ArrowFromFnTooltip | undefined; private showTooltipArrow: boolean = false; @@ -139,6 +142,16 @@ export class FnValue extends Value implements IHoverable { return this._arrow; } + addArrow(target: any): void { + // Check how many arrows already point to this specific target + const currentCount = this._streamArrows.filter(arrow => arrow.target === target).length; + + // Pass the count as the offsetIndex + this._streamArrows?.push( + new ArrowFromStreamNullaryFn(this, currentCount).to(target) as ArrowFromStreamNullaryFn, + ); + } + onMouseEnter = ({ currentTarget }: KonvaEventObject) => { if (CseMachine.getPrintableMode()) return; setHoveredCursor(currentTarget); @@ -230,11 +243,20 @@ export class FnValue extends Value implements IHoverable { } draw(): React.ReactNode { - if (this.fnName === undefined) { - throw new Error('Closure has no main reference and is not initialised!'); - } - //update center x accourding to the same id from cache + // Update centerX before arrow geometry is computed so arrow start positions are correct. this.centerX = this.x() + this.radius * 2; + this._streamArrows = []; + if (CseMachine.getPairCreationMode()) { + const pairs = CseMachine.getStreamLineage((this.data as any).id); + if (pairs != undefined) { + for (const pair of pairs) { + const target = Layout.values.get(pair); + if (target) { + this.addArrow(target); + } + } + } + } this.enclosingFrame = Frame.getFrom(this.data.environment); if (this.enclosingFrame) { this._arrow = new ArrowFromFn(this).to(this.enclosingFrame) as ArrowFromFn; @@ -308,6 +330,7 @@ export class FnValue extends Value implements IHoverable { /> {this._arrow?.draw()} + {this._streamArrows.map((arrow, index) => arrow.draw())} {this.tooltipArrow?.draw()} );