diff --git a/src/features/cseMachine/CseMachineAnimation.tsx b/src/features/cseMachine/CseMachineAnimation.tsx index 9f549351fa..dc50429306 100644 --- a/src/features/cseMachine/CseMachineAnimation.tsx +++ b/src/features/cseMachine/CseMachineAnimation.tsx @@ -26,7 +26,7 @@ import { Frame } from './components/Frame'; import { ArrayValue } from './components/values/ArrayValue'; import CseMachine from './CseMachine'; import { Layout } from './CseMachineLayout'; -import { isBuiltInFn, isInstr, isStreamFn } from './CseMachineUtils'; +import { isBuiltInFn, isEnvEqual, isInstr, isStreamFn } from './CseMachineUtils'; import { isList, isSymbol } from './utils/scheme'; export class CseAnimation { @@ -51,11 +51,12 @@ export class CseAnimation { } static setCurrentFrame(frame: Frame) { - CseAnimation.previousFrame = CseAnimation.currentFrame; + CseAnimation.previousFrame = CseAnimation.currentFrame ?? frame; CseAnimation.currentFrame = frame; } private static clearAnimationComponents(): void { + CseAnimation.animations.forEach(a => a.destroy()); CseAnimation.animations.length = 0; } @@ -72,21 +73,26 @@ export class CseAnimation { const currStashComponent = Layout.stashComponent.stashItemComponents.at(-1)!; switch (node.type) { case 'Program': - CseAnimation.animations.push( - new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems()) - ); - if (CseMachine.getCurrentEnvId() !== '-1') { + case 'BlockStatement': + case 'StatementSequence': + if (node.body.length === 1) { + CseAnimation.handleNode(node.body[0]); + } else { CseAnimation.animations.push( - new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame) + new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems()) ); + if ( + !isEnvEqual( + CseAnimation.currentFrame.environment, + CseAnimation.previousFrame.environment + ) + ) { + CseAnimation.animations.push( + new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame) + ); + } } break; - case 'BlockStatement': - CseAnimation.animations.push( - new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems()), - new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame) - ); - break; case 'Literal': CseAnimation.animations.push( new ControlToStashAnimation(lastControlComponent, currStashComponent!) @@ -127,7 +133,6 @@ export class CseAnimation { case 'IfStatement': case 'MemberExpression': case 'ReturnStatement': - case 'StatementSequence': case 'UnaryExpression': case 'VariableDeclaration': case 'FunctionDeclaration': @@ -143,7 +148,6 @@ export class CseAnimation { } static updateAnimation() { - CseAnimation.animations.forEach(a => a.destroy()); CseAnimation.clearAnimationComponents(); if (!Layout.previousControlComponent) return; @@ -393,16 +397,15 @@ export class CseAnimation { static async playAnimation() { if (!CseAnimation.animationEnabled) { - CseAnimation.animations.forEach(a => a.destroy()); CseAnimation.clearAnimationComponents(); return; } CseAnimation.disableAnimations(); // Get the actual HTML element and set the pointer events to none, to allow for - // mouse events to pass through the animation layer, and be handled by the actual CSE Machine. + // mouse events to pass through the animation layer and be handled by the actual CSE Machine. // Setting the listening property to false on the Konva Layer does not seem to work, so // this a workaround. - const canvasElement = CseAnimation.getLayer()?.getCanvas()._canvas; + const canvasElement = CseAnimation.getLayer()?.getNativeCanvasElement(); if (canvasElement) canvasElement.style.pointerEvents = 'none'; // Play all the animations await Promise.all(this.animations.map(a => a.animate())); diff --git a/src/features/cseMachine/CseMachineConfig.ts b/src/features/cseMachine/CseMachineConfig.ts index bdbd1c006d..4c210aca2d 100644 --- a/src/features/cseMachine/CseMachineConfig.ts +++ b/src/features/cseMachine/CseMachineConfig.ts @@ -11,6 +11,7 @@ export const Config = Object.freeze({ FrameMinWidth: 100, FramePaddingX: 20, FramePaddingY: 30, + FrameMinGapX: 80, FrameMarginX: 30, FrameMarginY: 10, FrameCornerRadius: 3, @@ -18,6 +19,7 @@ export const Config = Object.freeze({ FnRadius: 15, FnInnerRadius: 3, FnTooltipOpacity: 0.3, + FnTooltipTextPadding: 5, DataMinWidth: 20, DataUnitWidth: 40, diff --git a/src/features/cseMachine/CseMachineLayout.tsx b/src/features/cseMachine/CseMachineLayout.tsx index f5491b5bd9..14db52fc31 100644 --- a/src/features/cseMachine/CseMachineLayout.tsx +++ b/src/features/cseMachine/CseMachineLayout.tsx @@ -2,8 +2,9 @@ import Heap from 'js-slang/dist/cse-machine/heap'; import { Control, Stash } from 'js-slang/dist/cse-machine/interpreter'; import { Chapter, Frame } from 'js-slang/dist/types'; import { KonvaEventObject } from 'konva/lib/Node'; +import { Stage } from 'konva/lib/Stage'; import React, { RefObject } from 'react'; -import { Layer, Rect, Stage } from 'react-konva'; +import { Layer as KonvaLayer, Rect as KonvaRect, Stage as KonvaStage } from 'react-konva'; import classes from 'src/styles/Draggable.module.scss'; import { Binding } from './components/Binding'; @@ -98,17 +99,19 @@ export class Layout { static currentStackTruncDark: React.ReactNode; static currentStackLight: React.ReactNode; static currentStackTruncLight: React.ReactNode; - static stageRef: RefObject = React.createRef(); + static stageRef: RefObject = React.createRef(); // buffer for faster rendering of diagram when scrolling static invisiblePaddingVertical: number = 300; static invisiblePaddingHorizontal: number = 300; - static scrollContainerRef: RefObject = React.createRef(); + static scrollContainerRef: RefObject = React.createRef(); static updateDimensions(width: number, height: number) { // update the size of the scroll container and stage given the width and height of the sidebar content. Layout.visibleWidth = width; Layout.visibleHeight = height; + Layout._width = Math.max(Layout.visibleWidth, Layout.stageWidth); + Layout._height = Math.max(Layout.visibleHeight, Layout.stageHeight); if ( Layout.stageRef.current !== null && (Math.min(Layout.width(), window.innerWidth) > Layout.stageWidth || @@ -122,8 +125,6 @@ export class Layout { Layout.stageRef.current.height(Layout.stageHeight); CseMachine.redraw(); } - if (Layout.stageHeight > Layout.visibleHeight) { - } Layout.invisiblePaddingVertical = Layout.stageHeight > Layout.visibleHeight ? (Layout.stageHeight - Layout.visibleHeight) / 2 @@ -131,7 +132,7 @@ export class Layout { Layout.invisiblePaddingHorizontal = Layout.stageWidth > Layout.visibleWidth ? (Layout.stageWidth - Layout.visibleWidth) / 2 : 0; - const container: HTMLElement | null = this.scrollContainerRef.current as HTMLDivElement; + const container = this.scrollContainerRef.current; if (container) { container.style.width = `${Layout.visibleWidth}px`; container.style.height = `${Layout.visibleHeight}px`; @@ -183,12 +184,13 @@ export class Layout { // calculate height and width by considering lowest and widest level const lastLevel = Layout.levels[Layout.levels.length - 1]; Layout._height = Math.max( + Layout.visibleHeight, Config.CanvasMinHeight, lastLevel.y() + lastLevel.height() + Config.CanvasPaddingY, Layout.controlStashHeight ?? 0 ); - Layout._width = Math.max( + Layout.visibleWidth, Config.CanvasMinWidth, Layout.levels.reduce((maxWidth, level) => Math.max(maxWidth, level.width()), 0) + Config.CanvasPaddingX * 2 + @@ -412,10 +414,10 @@ export class Layout { * Scrolls diagram to top left, resets the zoom, and saves the diagram as multiple images of width < MaxExportWidth. */ static exportImage = () => { - const container: HTMLElement | null = this.scrollContainerRef.current as HTMLDivElement; - container.scrollTo({ left: 0, top: 0 }); + const container = this.scrollContainerRef.current; + container?.scrollTo({ left: 0, top: 0 }); Layout.handleScrollPosition(0, 0); - this.stageRef.current.scale({ x: 1, y: 1 }); + this.stageRef.current?.scale({ x: 1, y: 1 }); const height = Layout.height(); const width = Layout.width(); const horizontalImages = Math.ceil(width / Config.MaxExportWidth); @@ -429,13 +431,14 @@ export class Layout { const y = Math.floor(n / horizontalImages); const a = document.createElement('a'); a.style.display = 'none'; - a.href = this.stageRef.current.toDataURL({ - x: x * Config.MaxExportWidth + Layout.invisiblePaddingHorizontal, - y: y * Config.MaxExportHeight + Layout.invisiblePaddingVertical, - width: Math.min(width - x * Config.MaxExportWidth, Config.MaxExportWidth), - height: Math.min(height - y * Config.MaxExportHeight, Config.MaxExportHeight), - mimeType: 'image/jpeg' - }); + a.href = + this.stageRef.current?.toDataURL({ + x: x * Config.MaxExportWidth + Layout.invisiblePaddingHorizontal, + y: y * Config.MaxExportHeight + Layout.invisiblePaddingVertical, + width: Math.min(width - x * Config.MaxExportWidth, Config.MaxExportWidth), + height: Math.min(height - y * Config.MaxExportHeight, Config.MaxExportHeight), + mimeType: 'image/jpeg' + }) ?? ''; a.download = `diagram_${x}_${y}.jpg`; document.body.appendChild(a); @@ -457,6 +460,7 @@ export class Layout { * @param y y position of the scroll container */ private static handleScrollPosition(x: number, y: number) { + if (!this.stageRef.current) return; const dx = x - Layout.invisiblePaddingHorizontal; const dy = y - Layout.invisiblePaddingVertical; this.stageRef.current.container().style.transform = 'translate(' + dx + 'px, ' + dy + 'px)'; @@ -475,7 +479,10 @@ export class Layout { if (Layout.stageRef.current !== null) { const stage = Layout.stageRef.current; const oldScale = stage.scaleX(); - const { x: pointerX, y: pointerY } = stage.getPointerPosition(); + const { x: pointerX, y: pointerY } = stage.getPointerPosition() ?? { + x: Layout.visibleWidth / 2 - stage.x(), + y: Layout.visibleHeight / 2 - stage.y() + }; const mousePointTo = { x: (pointerX - stage.x()) / oldScale, y: (pointerY - stage.y()) / oldScale @@ -531,7 +538,7 @@ export class Layout { backgroundColor: defaultBackgroundColor() }} > - - - + level.draw())} {CseMachine.getControlStash() && Layout.controlComponent.draw()} {CseMachine.getControlStash() && Layout.stashComponent.draw()} - - + + {CseMachine.getControlStash() && CseAnimation.animations.map(c => c.draw())} - - + + diff --git a/src/features/cseMachine/CseMachineUtils.ts b/src/features/cseMachine/CseMachineUtils.ts index abbb8ef2cd..6abea62ad1 100644 --- a/src/features/cseMachine/CseMachineUtils.ts +++ b/src/features/cseMachine/CseMachineUtils.ts @@ -233,7 +233,7 @@ export function setDifference(set1: Set, set2: Set) { * order is the first binding or array unit which shares the same environment with `value`. * * An exception is for a global function value, in which case the global frame binding is - * always prioritised over array units. + * always prioritised over other bindings or array units. */ export function isMainReference(value: Value, reference: ReferenceType) { if (isContinuation(value.data)) { @@ -442,8 +442,8 @@ export function getNonEmptyEnv(environment: Env): Env { /** Returns whether the given environments `env1` and `env2` refer to the same environment. */ export function isEnvEqual(env1: Env, env2: Env): boolean { - // Cannot check env references because of deep cloning and the step after where - // property descriptors are copied over, so can only check id + // Cannot check env references because of partial cloning of environment tree, + // so we can only check id return env1.id === env2.id; } @@ -901,11 +901,16 @@ export function getStashItemComponent( return new StashItemComponent(stashItem, stackHeight, index, arrowTo); } -// Helper function to get environment ID. Accounts for the hidden prelude environment right -// after the global environment. Does not need to be used for frame environments, only for -// environments from the context. +// Helper function to get environment ID. +// Accounts for the hidden prelude environment and empty environments. export const getEnvId = (environment: Environment): string => { - return environment.name === 'prelude' ? environment.tail!.id : environment.id; + while ( + environment.tail && + (environment.name === 'prelude' || Object.keys(environment.head).length === 0) + ) { + environment = environment.tail; + } + return environment.id; }; // Function that returns whether the stash item will be popped off in the next step diff --git a/src/features/cseMachine/__tests__/CseMachine.tsx b/src/features/cseMachine/__tests__/CseMachine.tsx index ac2827a432..59d34a3b83 100644 --- a/src/features/cseMachine/__tests__/CseMachine.tsx +++ b/src/features/cseMachine/__tests__/CseMachine.tsx @@ -1,15 +1,26 @@ import { runInContext } from 'js-slang/dist/'; import createContext from 'js-slang/dist/createContext'; +import { Binding } from '../components/Binding'; import { ControlItemComponent } from '../components/ControlItemComponent'; +import { Frame } from '../components/Frame'; import { StashItemComponent } from '../components/StashItemComponent'; import { ArrayValue } from '../components/values/ArrayValue'; import { FnValue } from '../components/values/FnValue'; import { GlobalFnValue } from '../components/values/GlobalFnValue'; import CseMachine from '../CseMachine'; +import { Config } from '../CseMachineConfig'; import { Layout } from '../CseMachineLayout'; import { Env, EnvTree } from '../CseMachineTypes'; -import { isDataArray, isFunction } from '../CseMachineUtils'; +import { isMainReference } from '../CseMachineUtils'; + +function isArray(x: any): x is any[] { + return Array.isArray(x); +} + +function isFunction(x: any): x is Function { + return typeof x === 'function'; +} // The following are code samples that are more complex/known to have caused bugs // Some are commented out to keep the tests shorter @@ -18,7 +29,7 @@ const codeSamples = [ ` const fn = () => "L"; const x = ["long string", pair(() => 1, () => 2), list(1, pair(2, 3), () => 3), () => "THIS", 5, 6]; - const y = list(x[1], x[2], tail(x[1]), tail(x[2]), fn); + const y = list(x[1], x[2], tail(x[1]), tail(x[2]), fn); debugger;`, ` const fn = () => 1; @@ -49,7 +60,7 @@ const codeSamples = [ const z = f(11); debugger;`, ` - const x = list(1,pair, accumulate); + const x = list(1, pair, accumulate); debugger;`, ` const x = []; @@ -85,12 +96,18 @@ codeSamples.forEach((code, idx) => { context.runtime.stash! ); + // Map of environment.id to Frame + const frameMap = new Map(); + const toTest: any[] = []; const environmentsToTest: Env[] = []; + + // Testing environment contents and order Layout.levels.forEach(({ frames }) => { - frames.forEach(({ environment, bindings }) => { - environmentsToTest.push(environment); - bindings.forEach(({ keyString, data }) => { + frames.forEach(frame => { + frameMap.set(frame.environment.id, frame); + environmentsToTest.push(frame.environment); + frame.bindings.forEach(({ keyString, data }) => { toTest.push(keyString); toTest.push(data); }); @@ -100,64 +117,71 @@ codeSamples.forEach((code, idx) => { expect(environment).toMatchSnapshot(); }); expect(toTest).toMatchSnapshot(); - // Note: Old code is kept here as a reference for later - // const checkNonCompactLayout = () => { - // Layout.draw(); - // Layout.values.forEach(v => { - // if (v instanceof GlobalFnValue || v instanceof FnValue) { - // const arrow = v.arrow(); - // expect(arrow).toBeDefined(); - // expect(arrow?.target).toBeDefined(); - // const path = arrow!.path().match(/[^ ]+/g) ?? []; - // expect(path.length).toEqual(14); - // expect(path[1]).toEqual(path[4]); // move up - // expect(path[8]).toEqual(path[10]); // move left - // expect(Frame.lastXCoordBelow(v.x())).toEqual(Frame.lastXCoordBelow(arrow!.target!.x())); // target - // } else if (v instanceof ArrayValue) { - // v.arrows().forEach(arrow => { - // expect(arrow).toBeDefined(); - // expect(arrow?.target).toBeDefined(); - // if ( - // arrow instanceof ArrowFromArrayUnit && - // arrow.target instanceof ArrayValue && - // arrow.source instanceof ArrayUnit - // ) { - // const sourceArray = arrow.source.parent as ArrayValue; - // const targetArray = arrow.target as ArrayValue; - // if (sourceArray.level === targetArray.level) { - // // for arrows within same array level - // const path = arrow!.path().match(/[^ ]+/g) ?? []; - // expect(parseFloat(path[1])).toEqual(arrow.source.x() + Config.DataUnitWidth / 2); - // expect(parseFloat(path[2])).toEqual(arrow.source.y() + Config.DataUnitHeight / 2); - // if (sourceArray.data === targetArray.data) { - // expect(path.length).toEqual(22); // up, right, down. - // expect(path[1]).toEqual(path[4]); - // expect(path[17]).toEqual(path[20]); - // expect(parseFloat(path[20]) - parseFloat(path[1])).toBeCloseTo( - // Config.DataUnitWidth / 3 - // ); - // } - // } - // } - // }); - // } - // }); - // }; const checkLayout = () => { Layout.draw(); - // TODO: write proper tests to check layout, similar to the above tests for the old components. - // In addition to the tests below, it would also be nice to check each frame and its bindings as well, - // and check all the relevant arrows are drawn correctly + const globalFrame = frameMap.get(Layout.globalEnvNode.environment.id); Layout.values.forEach(v => { if (v instanceof GlobalFnValue || v instanceof FnValue) { - // TODO: check the arrow of each function value that starts from its tail, and points to the - // environment frame that the function value originates from. - // 1. Check that arrow and its target is defined, and format the path into an array - // 2. Check that path[1] === path[4], i.e. the arrow moves up first - // 3. Check that path[8] === path[10], i.e. the arrow moves left afterwards - // 4. Check that the arrow is indeed drawn to the correct frame. + const arrow = v.arrow(); + expect(arrow).toBeDefined(); + expect(arrow!.target).toBeDefined(); + // Split path text into an array by the spaces + const path = arrow!.path().match(/[^ ]+/g) ?? []; + expect(path).toHaveLength(14); + expect(path[1]).toEqual(path[4]); // moves up + expect(path[8]).toEqual(path[10]); // moves left + const targetFrame = + v instanceof FnValue ? frameMap.get(v.data.environment.id) : globalFrame; + expect(targetFrame).toBeDefined(); + expect(arrow!.target).toEqual(targetFrame); + expect(v.x()).toBeGreaterThan(targetFrame!.x()); + expect(v.y()).toBeGreaterThan(targetFrame!.y()); } else if (v instanceof ArrayValue) { - // TODO: check the arrows of each array unit + const targetFrame = frameMap.get(v.data.environment.id); + expect(targetFrame).toBeDefined(); + expect(v.x()).toBeGreaterThan(targetFrame!.x()); + expect(v.y()).toBeGreaterThan(targetFrame!.y()); + v.units.forEach((unit, i) => { + expect(unit.index).toEqual(i); + const lastIndex = i === v.units.length - 1; + if (isFunction(unit.data) || isArray(unit.data)) { + expect(unit.arrow).toBeDefined(); + expect(unit.arrow!.target).toBeDefined(); + const path = unit.arrow!.path().match(/[^ ]+/g) ?? []; + if (unit.value instanceof GlobalFnValue) { + // Check value has a binding in the global frame + expect( + unit.value.references.filter(r => r instanceof Binding && r.frame === globalFrame) + ).toHaveLength(1); + } else { + expect(unit.data).toHaveProperty('environment'); + if (isMainReference(unit.value, unit)) { + if (lastIndex) { + expect(unit.isLastUnit).toEqual(true); + expect(unit.value.x()).toBeGreaterThan(v.x()); + expect(Number(path[1])).toBeLessThan(Number(path[4])); // arrow moves right + expect(path[2]).toEqual(path[5]); // horizontal arrow + // Box-and-pointer notation, y-coordinate should be the same + if (isArray(unit.data)) expect(unit.value.y()).toEqual(v.y()); + // y-coordinates of functions should be at the mid-point of the array + else expect(unit.value.y()).toEqual(v.y() + Config.DataUnitHeight / 2); + } else { + expect(unit.value.y()).toBeGreaterThan(v.y()); + expect(path[1]).toEqual(path[4]); // vertical arrow + expect(Number(path[2])).toBeLessThan(Number(path[5])); // arrow moves down + // Arrays have matching x-coordinates + if (isArray(unit.data)) expect(unit.value.x()).toEqual(unit.x()); + // Functions have the centers aligned instead + else { + expect(unit.value.x() + unit.value.width() / 2).toEqual( + unit.x() + unit.width() / 2 + ); + } + } + } + } + } + }); } }); }; @@ -168,7 +192,7 @@ codeSamples.forEach((code, idx) => { }); }); -const codeSamplesControlStash = [ +const codeSamplesControlStash: [string, string, number, boolean?][] = [ [ 'arrows from the environment instruction to the frame and arrows from the stash to closures', ` @@ -204,17 +228,17 @@ const codeSamplesControlStash = [ } fact(10); `, - 160, + 140, true ] ]; -codeSamplesControlStash.forEach((codeSample, idx) => { +codeSamplesControlStash.forEach(codeSample => { test('CSE Machine Control Stash correctly renders: ' + codeSample[0], async () => { const code = codeSample[1] as string; const currentStep = codeSample[2] as number; const truncate = codeSample[3]; - if (truncate) { + if (Boolean(truncate) !== CseMachine.getStackTruncated()) { CseMachine.toggleStackTruncated(); } if (!CseMachine.getControlStash()) { @@ -237,7 +261,7 @@ codeSamplesControlStash.forEach((codeSample, idx) => { if (truncate) expect(controlItemsToTest.length).toBeLessThanOrEqual(10); stashItemsToTest.forEach(item => { expect(item.draw()).toMatchSnapshot(); - if (isFunction(item.value) || isDataArray(item.value)) expect(item.arrow).toBeDefined(); + if (isFunction(item.value) || isArray(item.value)) expect(item.arrow).toBeDefined(); }); }); }); diff --git a/src/features/cseMachine/__tests__/CseMachineAnimation.tsx b/src/features/cseMachine/__tests__/CseMachineAnimation.tsx new file mode 100644 index 0000000000..fa8a9930cf --- /dev/null +++ b/src/features/cseMachine/__tests__/CseMachineAnimation.tsx @@ -0,0 +1,230 @@ +import Konva from 'konva'; +import { RefObject } from 'react'; +import * as ReactKonva from 'react-konva'; + +import { AnimatableTo, AnimationConfig } from '../animationComponents/base/Animatable'; +import { AnimationComponent } from '../animationComponents/base/AnimationComponents'; +import { CseAnimation } from '../CseMachineAnimation'; + +jest.setTimeout(10000); + +const mockStage = new Konva.Stage({ + container: document.createElement('div'), + width: 500, + height: 500 +} as Konva.StageConfig); +const mockLayer = new Konva.Layer(); +mockStage.add(mockLayer); +// Override layer ref's current property to the mock layer +Object.defineProperty(CseAnimation.layerRef, 'current', { value: mockLayer }); + +// Mapping of React Konva components to Konva nodes +const konvaNodeMap = { + Label: Konva.Label, + Rect: Konva.Rect, + Circle: Konva.Circle, + Ellipse: Konva.Ellipse, + Wedge: Konva.Wedge, + Line: Konva.Line, + Sprite: Konva.Sprite, + Image: Konva.Image, + Text: Konva.Text, + TextPath: Konva.TextPath, + Star: Konva.Star, + Ring: Konva.Ring, + Arc: Konva.Arc, + Tag: Konva.Tag, + Path: Konva.Path, + RegularPolygon: Konva.RegularPolygon, + Arrow: Konva.Arrow, + Shape: Konva.Shape +}; + +type ValueTolerancePair = [number, number]; +type ExpectedValues = { + [K in keyof O]?: NonNullable extends number ? ValueTolerancePair : O[K]; +}; +type Writable = { -readonly [K in keyof T]: T[K] }; + +async function testAnimationComponent< + KonvaNode extends Konva.Node, + KonvaConfig extends Konva.NodeConfig +>(args: { + /** Type of konva node we want to construct, e.g. `ReactKonva.Rect`, `ReactKonva.Text`, etc. */ + nodeType: ReactKonva.KonvaNodeComponent; + /** Initial props of the Konva node */ + nodeProps: KonvaConfig; + /** Delta values we want to test, scaled by `CseAnimation.defaultDuration` */ + deltas: number[]; + /** List of `animateTo` parameters, to call in parallel for testing */ + animations: Parameters['animateTo']>[]; + /** + * Expected props of the node when given the timestamp. + * Props which contain numbers (e.g. `x`, `width`, `opacity`) instead require a pair of numbers, + * where the left value is the expected value, and the right value is the tolerance. + */ + expected: (elapsed: number) => ExpectedValues; +}) { + const { nodeProps, animations, expected } = args; + const component = new AnimationComponent(args.nodeType, nodeProps); + expect(component.draw()).toMatchSnapshot(); + + // See: https://github.com/konvajs/react-konva/blob/master/src/ReactKonvaCore.tsx#L108 + // `nodeType` has a runtime type of string, and the `KonvaNodeComponent` TS type is only + // for compatibility with React components. + const nodeType = args.nodeType as unknown as keyof typeof konvaNodeMap; + + // React RefObjects don't work well in test environments, so we have to manually create the + // node and assign it to the RefObject's `current` property + expect(konvaNodeMap[nodeType]).toBeDefined(); + const node = Reflect.construct(konvaNodeMap[nodeType], [nodeProps]) as Konva.Shape; + (component.ref as Writable>).current = node; + mockLayer.add(node); + + const timings = args.deltas.map(d => d * CseAnimation.defaultDuration); + const checker = () => { + return new Promise((resolve, reject) => { + let i = 0; + const startTime = performance.now(); + const fn = () => { + const elapsed = performance.now() - startTime; + if (timings[i] - elapsed < 50 / 3 || elapsed > timings[i]) { + const expectedProps = expected(elapsed); + for (const attr in expectedProps) { + const actual = node.getAttr(attr); + const expected = expectedProps[attr]; + try { + if (Array.isArray(expected)) { + const [value, tolerance] = expected as ValueTolerancePair; + // See: https://github.com/jestjs/jest/blob/main/packages/expect/src/matchers.ts#L144 + // Below calculation for precision is the inverse of the calculation in `toBeCloseTo` + expect(actual).toBeCloseTo(value, -Math.log10(2 * tolerance)); + } else { + expect(actual).toEqual(expected); + } + } catch (e) { + reject(e); + } + } + i++; + if (i === timings.length) { + resolve(); + return; + } + } + requestAnimationFrame(fn); + }; + requestAnimationFrame(fn); + }); + }; + + await Promise.all([...animations.map(params => component.animateTo(...params)), checker()]); + node.destroy(); +} + +test('AnimationComponent animates correctly with default animation config', async () => { + await testAnimationComponent({ + nodeType: ReactKonva.Rect, + nodeProps: { height: 100, fill: '#000' }, + deltas: [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1], + animations: [[{ height: 200 }]], + expected: elapsed => ({ + height: [CseAnimation.defaultEasing(elapsed, 100, 100, CseAnimation.defaultDuration), 1.5] + }) + }); +}); + +test('AnimationComponent animates correctly with custom animation config', async () => { + const easing: AnimationConfig['easing'] = (t, b, c, d) => b + (t /= d) * t * t * c; // EaseInCubic + await testAnimationComponent({ + nodeType: ReactKonva.Text, + nodeProps: { x: 0, y: 100, opacity: 0, text: 'test' }, + deltas: [0, 0.25, 0.5, 1, 1.25, 1.5, 2], + animations: [ + [ + { x: 400, y: 200, opacity: 1 }, + { duration: 1.5, delay: 0.5, easing } + ] + ], + expected: elapsed => { + const duration = CseAnimation.defaultDuration * 1.5; + const delay = CseAnimation.defaultDuration * 0.5; + const timing = Math.max(0, Math.min(elapsed - delay, duration)); + return { + x: [easing(timing, 0, 400, duration), 4], + y: [easing(timing, 100, 100, duration), 1], + opacity: [easing(timing, 0, 1, duration), 0.01] + }; + } + }); +}); + +test('AnimationComponent animates correctly with parallel animateTo calls 1', async () => { + await testAnimationComponent({ + nodeType: ReactKonva.Path, + nodeProps: { x: 0, y: 0, opacity: 0, data: 'L 0 0 M 100 100' }, + deltas: [0, 0.25, 0.5, 0.75, 1, 1.25, 1.5], + animations: [ + [{ x: 100 }], + [{ y: 100 }, { delay: 0.5 }], + [{ opacity: 1 }, { duration: 0.75, delay: 0.25 }] + ], + expected: elapsed => { + const d = CseAnimation.defaultDuration; + return { + x: [CseAnimation.defaultEasing(Math.min(elapsed / d, 1), 0, 100, 1), 1], + y: [CseAnimation.defaultEasing(Math.min(Math.max(0, elapsed / d - 0.5), 1), 0, 100, 1), 1], + opacity: [ + CseAnimation.defaultEasing(Math.min(Math.max(0, elapsed / d / 0.75 - 1 / 3), 1), 0, 1, 1), + 0.01 + ] + }; + } + }); +}); + +test('AnimationComponent animates correctly with parallel animateTo calls 2', async () => { + const easing: AnimationConfig['easing'] = (t, b, c, d) => b + (t / d) * c; // Linear + await testAnimationComponent({ + nodeType: ReactKonva.Circle, + nodeProps: { x: 0, y: 0, width: 100, height: 100, fill: '#000' }, + deltas: [0, 0.5, 1, 1.5, 2, 2.5, 3], + animations: [ + [{ x: 100 }, { easing }], + [{ x: 200 }, { delay: 1, easing }], + [{ x: 150 }, { delay: 2, easing }] + ], + expected: elapsed => { + const d = CseAnimation.defaultDuration; + return { + x: + elapsed < d + ? [easing(elapsed, 0, 100, d), 4] + : elapsed < d * 2 + ? [easing(elapsed - d, 100, 100, d), 4] + : [easing(elapsed - d * 2, 200, -50, d), 2] + }; + } + }); +}); + +test('AnimationComponent animates correctly with conflicting animateTo calls', async () => { + const easing: AnimationConfig['easing'] = (t, b, c, d) => b + (t / d) * c; // Linear + await testAnimationComponent({ + nodeType: ReactKonva.Rect, + nodeProps: { x: 0, y: 0, width: 100, height: 100, fill: '#000' }, + deltas: [0, 0.25, 0.5, 0.75, 1, 1.25, 1.5], + animations: [[{ x: 100 }], [{ x: 200 }, { easing }], [{ x: 100 }, { delay: 0.5 }]], + expected: elapsed => { + const d = CseAnimation.defaultDuration; + return { + x: + elapsed < d * 0.5 + ? [easing(elapsed, 0, 200, d), 1] + : // Larger tolerance value at the start because of overshoot from 2nd animation, + // will gradually go back to value of 100 towards the end. + [100, easing(elapsed - d * 0.5, 15, 1, d)] + }; + } + }); +}); diff --git a/src/features/cseMachine/__tests__/__snapshots__/CseMachine.tsx.snap b/src/features/cseMachine/__tests__/__snapshots__/CseMachine.tsx.snap index b46cd29ac9..345d88fd31 100644 --- a/src/features/cseMachine/__tests__/__snapshots__/CseMachine.tsx.snap +++ b/src/features/cseMachine/__tests__/__snapshots__/CseMachine.tsx.snap @@ -62,7 +62,7 @@ exports[`CSE Machine Control Stash correctly renders: Control is truncated prope @@ -156,13 +158,13 @@ exports[`CSE Machine Control Stash correctly renders: Control is truncated prope @@ -205,13 +209,13 @@ exports[`CSE Machine Control Stash correctly renders: Control is truncated prope + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 6`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 7`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 8`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 9`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 10`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 11`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 12`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 13`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 14`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 15`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 16`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 17`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 18`] = ` + + + + +`; + +exports[`CSE Machine Control Stash correctly renders: Control is truncated properly 19`] = ` + + + @@ -1238,7 +1940,7 @@ exports[`CSE Machine Control Stash correctly renders: global environments are tr listening={false} > +`; + +exports[`AnimationComponent animates correctly with custom animation config 1`] = ` + +`; + +exports[`AnimationComponent animates correctly with default animation config 1`] = ` + +`; + +exports[`AnimationComponent animates correctly with parallel animateTo calls 1 1`] = ` + +`; + +exports[`AnimationComponent animates correctly with parallel animateTo calls 2 1`] = ` + +`; diff --git a/src/features/cseMachine/animationComponents/BranchAnimation.tsx b/src/features/cseMachine/animationComponents/BranchAnimation.tsx index 092651359d..b0f6d33ced 100644 --- a/src/features/cseMachine/animationComponents/BranchAnimation.tsx +++ b/src/features/cseMachine/animationComponents/BranchAnimation.tsx @@ -10,7 +10,7 @@ import { getNodePosition } from './base/AnimationUtils'; import { ControlExpansionAnimation } from './ControlExpansionAnimation'; /** - * Animation for the `branch` instruction. + * Animation for the `branch` instruction. Also used for `for` and `while` instructions. */ export class BranchAnimation extends Animatable { private booleanItemAnimation: AnimatedTextbox; diff --git a/src/features/cseMachine/animationComponents/ControlToStashAnimation.tsx b/src/features/cseMachine/animationComponents/ControlToStashAnimation.tsx index 6cbd749966..4ecc09af8a 100644 --- a/src/features/cseMachine/animationComponents/ControlToStashAnimation.tsx +++ b/src/features/cseMachine/animationComponents/ControlToStashAnimation.tsx @@ -18,7 +18,7 @@ import { getNodePosition } from './base/AnimationUtils'; /** * Animation for any single item movement from control to stash. - * Used for literals and arrow function expressions + * Used for literals and arrow function expressions. */ export class ControlToStashAnimation extends Animatable { private borderRectAnimation: AnimatedRectComponent; diff --git a/src/features/cseMachine/animationComponents/FrameCreationAnimation.tsx b/src/features/cseMachine/animationComponents/FrameCreationAnimation.tsx index cbc7db54c7..35bd36bf74 100644 --- a/src/features/cseMachine/animationComponents/FrameCreationAnimation.tsx +++ b/src/features/cseMachine/animationComponents/FrameCreationAnimation.tsx @@ -44,7 +44,7 @@ export class FrameCreationAnimation extends Animatable { private variadicArray?: ArrayValue; constructor( - origin: ControlItemComponent | StashItemComponent, + private origin: ControlItemComponent | StashItemComponent, private frame: Frame ) { super(); @@ -95,7 +95,14 @@ export class FrameCreationAnimation extends Animatable { opacity: 0 }); }); - this.frameArrows = frame.bindings.flatMap(binding => { + this.frameArrows = this.frameArrowAnimations = []; + } + + draw(): React.ReactNode { + // Bindings arrows only gets created when drawn, so `frameArrows` is initialised here instead + const xDiff = this.frame.x() - this.origin.x(); + const yDiff = this.frame.y() - this.origin.y(); + this.frameArrows = this.frame.bindings.flatMap(binding => { if ( binding.value instanceof ArrayValue && isEnvEqual(binding.value.data.environment, this.frame.environment) @@ -111,9 +118,6 @@ export class FrameCreationAnimation extends Animatable { opacity: 0 }); }); - } - - draw(): React.ReactNode { return ( {this.controlTextAnimation.draw()} diff --git a/src/features/cseMachine/animationComponents/base/AnimationComponents.tsx b/src/features/cseMachine/animationComponents/base/AnimationComponents.tsx index b69420f81e..2242927a6e 100644 --- a/src/features/cseMachine/animationComponents/base/AnimationComponents.tsx +++ b/src/features/cseMachine/animationComponents/base/AnimationComponents.tsx @@ -11,13 +11,13 @@ import { Animatable, AnimatableTo, AnimationConfig } from './Animatable'; import { lerp } from './AnimationUtils'; type AnimationData = { - startTime: number; - endTime: number; - from: Readonly>; - current: Partial; - to: Readonly>; - easing: NonNullable; - resolve: (value: void | PromiseLike) => void; + readonly startTime: number; + readonly endTime: number; + from?: Readonly>; // This is set only once + current?: Partial; // This is mutated on every frame of the animation + readonly to: Readonly>; + readonly easing: NonNullable; + readonly resolve: (value: void | PromiseLike) => void; }; abstract class BaseAnimationComponent< @@ -42,15 +42,27 @@ abstract class BaseAnimationComponent< let i = 0; while (i < this.animationData.length) { const data = this.animationData[i]; + // Don't run animation yet if current time is less than animation start time. + // This is true for animations that have a delay. if (frame.time <= data.startTime) { animationComplete = false; i++; continue; } + // Set starting values when animation first starts + if (!data.from || !data.current) { + const node: Konva.Node = this.ref.current; + const from: Partial = {}; + for (const attr in data.to) { + from[attr] = attrs[attr] ?? node.getAttr(attr); + } + data.from = from; + data.current = {}; + } // Calculate animation progress from 0 to 1 const delta = Math.min((frame.time - data.startTime) / (data.endTime - data.startTime), 1); // Interpolate each attribute between the starting and ending values - for (const attr in data.current) { + for (const attr in data.to) { const value = lerp(delta, attr, data.from[attr], data.to[attr], data.easing); data.current[attr] = value; if (attr === 'x') this._x = value; @@ -60,8 +72,8 @@ abstract class BaseAnimationComponent< } // Add the new attributes and values into the main attrs object Object.assign(attrs, data.current); - // Resolve the animation's promise later if the animation is done, and also - // remove the animation data from the list + // If animation is donw, remove the animation data from the list, and resolve + // the animation's promise later if (delta === 1) { resolveList.push(data.resolve); this.animationData.splice(i, 1); @@ -76,6 +88,7 @@ abstract class BaseAnimationComponent< this.listeners.forEach(f => f({ ...attrs })); } if (animationComplete) this.animation.stop(); + // Promises are only resolved after attributes have been fully set on the Konva node resolveList.forEach(r => r()); return; }; @@ -103,19 +116,13 @@ abstract class BaseAnimationComponent< resolve(); return; } - const node: Konva.Node = this.ref.current; - // Get current node values first - const from: Partial = {}; - for (const attr in to) { - from[attr] = node.getAttr(attr); - } // Calculate timings based on values given in animationConfig const startTime = this.animation.frame.time + (animationConfig?.delay ?? 0) * CseAnimation.defaultDuration; const endTime = startTime + (animationConfig?.duration ?? 1) * CseAnimation.defaultDuration; const easing = animationConfig?.easing ?? CseAnimation.defaultEasing; // Add animation data - const data = { startTime, endTime, from, current: { ...from }, to, easing, resolve }; + const data = { startTime, endTime, to, easing, resolve }; this.animationData.push(data); // Play animation if (!this.animation.isRunning()) this.animation.start(); diff --git a/src/features/cseMachine/components/ArrayUnit.tsx b/src/features/cseMachine/components/ArrayUnit.tsx index 5ebbd04022..43c230cbde 100644 --- a/src/features/cseMachine/components/ArrayUnit.tsx +++ b/src/features/cseMachine/components/ArrayUnit.tsx @@ -30,29 +30,26 @@ export class ArrayUnit extends Visible { readonly isFirstUnit: boolean; /** check if this is the last unit in the array */ readonly isLastUnit: boolean; - /** check if this unit is the main reference of the value */ - readonly isMainReference: boolean; /** arrow that is drawn from the array unit to the value */ arrow?: GenericArrow; readonly indexRef = React.createRef(); constructor( /** index of this unit in its parent */ - readonly idx: number, + readonly index: number, /** the value this unit contains*/ readonly data: Data, /** parent of this unit */ readonly parent: ArrayValue ) { super(); - this._x = this.parent.x() + this.idx * Config.DataUnitWidth; + this._x = this.parent.x() + this.index * Config.DataUnitWidth; this._y = this.parent.y(); this._height = Config.DataUnitHeight; this._width = Config.DataUnitWidth; - this.isFirstUnit = this.idx === 0; - this.isLastUnit = this.idx === this.parent.data.length - 1; + this.isFirstUnit = this.index === 0; + this.isLastUnit = this.index === this.parent.data.length - 1; this.value = Layout.createValue(this.data, this); - this.isMainReference = this.value.references.length > 1; } showIndex() { @@ -113,7 +110,7 @@ export class ArrayUnit extends Visible { ref={this.indexRef} {...ShapeDefaultProps} {...indexProps} - text={`${this.idx}`} + text={`${this.index}`} /> {this.value.draw()} {this.arrow?.draw()} diff --git a/src/features/cseMachine/components/Binding.tsx b/src/features/cseMachine/components/Binding.tsx index e2abdeb8d5..5f49f23d32 100644 --- a/src/features/cseMachine/components/Binding.tsx +++ b/src/features/cseMachine/components/Binding.tsx @@ -66,8 +66,7 @@ export class Binding extends Visible { // derive the width from the right bound of the value this._width = isMainReference(this.value, this) - ? this.value.x() + - this.value.width() - + ? this.value.x() - this.x() + (this.value instanceof FnValue || this.value instanceof GlobalFnValue || @@ -76,7 +75,10 @@ export class Binding extends Visible { : 0) : this.key.width(); - this._height = Math.max(this.key.height(), this.value.height()); + this._height = Math.max( + this.key.height(), + this.value instanceof ArrayValue ? this.value.totalHeight : this.value.height() + ); if (this.isDummyBinding && !isMainReference(this.value, this)) { if (this.prevBinding) { diff --git a/src/features/cseMachine/components/ControlItemComponent.tsx b/src/features/cseMachine/components/ControlItemComponent.tsx index 3f7327a77f..b693b7cb12 100644 --- a/src/features/cseMachine/components/ControlItemComponent.tsx +++ b/src/features/cseMachine/components/ControlItemComponent.tsx @@ -69,14 +69,14 @@ export class ControlItemComponent extends Visible implements IHoverable { } // Save previous z-index to go back to later - private zIndex = 0; + private zIndex = 1; onMouseEnter = (e: KonvaEventObject) => { this.highlightOnHover(); if (!this.topItem) { setHoveredStyle(e.currentTarget); } setHoveredCursor(e.currentTarget); - this.zIndex = this.ref.current.zIndex(); + this.zIndex = Math.max(this.ref.current.zIndex(), 1); this.ref.current.moveToTop(); this.tooltipRef.current.moveToTop(); this.tooltipRef.current.show(); diff --git a/src/features/cseMachine/components/Frame.tsx b/src/features/cseMachine/components/Frame.tsx index e632fd19f6..c528264741 100644 --- a/src/features/cseMachine/components/Frame.tsx +++ b/src/features/cseMachine/components/Frame.tsx @@ -1,3 +1,4 @@ +import { isPrimitive } from '@sentry/utils'; import React from 'react'; import { Group, Rect } from 'react-konva'; @@ -13,7 +14,8 @@ import { getUnreferencedObjects, isClosure, isDataArray, - isPrimitiveData, + isDummyKey, + isSourceObject, isUnassigned } from '../CseMachineUtils'; import { isContinuation } from '../utils/scheme'; @@ -65,51 +67,28 @@ export class Frame extends Visible implements IHoverable { readonly leftSiblingFrame: Frame | null ) { super(); - this._width = Config.FrameMinWidth; + this.level = envTreeNode.level as Level; + this.parentFrame = envTreeNode.parent?.frame; this.environment = envTreeNode.environment; Frame.envFrameMap.set(this.environment.id, this); - this.parentFrame = envTreeNode.parent?.frame; - this._x = this.level.x(); - // derive the x coordinate from the left sibling frame - if (this.leftSiblingFrame) { - this._x += this.leftSiblingFrame.x() + this.leftSiblingFrame.totalWidth + Config.FrameMarginX; - } - // ensure x coordinate cannot be less than that of parent frame - if (this.parentFrame) { - this._x = Math.max(this._x, this.parentFrame.x()); - } - this.name = new Text( - frameNames.get(this.environment.name) || this.environment.name, - this.x(), - this.level.y(), - { maxWidth: this.width() } - ); - this._y = this.level.y() + this.name.height() + Config.TextPaddingY / 2; - - // width of the frame = max width of the bindings in the frame + frame padding * 2 (the left and right padding) - let maxBindingWidth = 0; - for (const [key, data] of Object.entries(this.environment.head)) { - const bindingWidth = - Math.max(Config.TextMinWidth, getTextWidth(key + Config.ConstantColon)) + - Config.TextPaddingX + - (isUnassigned(data) - ? Math.max(Config.TextMinWidth, getTextWidth(Config.UnassignedData)) - : isPrimitiveData(data) - ? Math.max(Config.TextMinWidth, getTextWidth(String(data))) - : 0); - maxBindingWidth = Math.max(maxBindingWidth, bindingWidth); - } - this._width = maxBindingWidth + Config.FramePaddingX * 2; - - // initializes bindings (keys + values) - let prevBinding: Binding | null = null; - let totalWidth = this._width; + this._x = this.leftSiblingFrame + ? this.leftSiblingFrame.x() + this.leftSiblingFrame.totalWidth + Config.FrameMarginX + : this.level.x(); + // ensure x coordinate cannot be less than that of parent frame + if (this.parentFrame) this._x = Math.max(this._x, this.parentFrame.x()); + this._y = this.level.y() + Config.FontSize + Config.TextPaddingY / 2; // get all keys and object descriptors of each value inside the head const entries = Object.entries(Object.getOwnPropertyDescriptors(this.environment.head)); + // move the global frame default text to the first position if it isn't in there already + if (this.environment.name === 'global' && entries[0][0] !== Config.GlobalFrameDefaultText) { + const index = entries.findIndex(([key]) => key === Config.GlobalFrameDefaultText); + entries.unshift(entries.splice(index, 1)[0]); + } + // get values that are unreferenced, which will used to created dummy bindings const unreferencedValues = [...getUnreferencedObjects(this.environment)]; @@ -146,26 +125,57 @@ export class Frame extends Visible implements IHoverable { writable: false }; // The key is a number string to "disguise" as a dummy binding - // TODO: revamp the dummy binding behavior, don't rely on numeric keys entries.push([`${i++}`, descriptor]); } + // Find the correct width of the frame before creating the bindings + this._width = Config.FrameMinWidth; + let totalWidth = this._width + Config.FrameMinGapX; + for (const [key, data] of entries) { + if (isDummyKey(key)) continue; + const constant = + this.environment.head[key]?.description === 'const declaration' || !data.writable; + let bindingTextWidth = getTextWidth( + key + (constant ? Config.ConstantColon : Config.VariableColon) + ); + if (isUnassigned(data.value)) { + bindingTextWidth += Config.TextPaddingX + getTextWidth(Config.UnassignedData); + } else if (isPrimitive(data.value)) { + bindingTextWidth += + Config.TextPaddingX + + getTextWidth( + isSourceObject(data.value) + ? data.value.toReplString() + : JSON.stringify(data.value) || String(data.value) + ); + } + this._width = Math.max(this._width, bindingTextWidth + Config.FramePaddingX * 2); + totalWidth = Math.max(totalWidth, this._width + Config.FrameMinGapX); + } + + // Create all the bindings and values + let prevBinding: Binding | null = null; for (const [key, data] of entries) { - // If the value is unassigned, retrieve declaration type from its description, otherwise, retrieve directly from the data's property const constant = this.environment.head[key]?.description === 'const declaration' || !data.writable; const currBinding: Binding = new Binding(key, data.value, this, prevBinding, constant); - this.bindings.push(currBinding); prevBinding = currBinding; + this.bindings.push(currBinding); totalWidth = Math.max(totalWidth, currBinding.width() + Config.FramePaddingX); } this.totalWidth = totalWidth; // derive the height of the frame from the the position of the last binding this._height = prevBinding - ? prevBinding.y() + prevBinding.height() + Config.FramePaddingY - this.y() + ? prevBinding.y() - this.y() + prevBinding.height() + Config.FramePaddingY : Config.FramePaddingY * 2; + this.name = new Text( + frameNames.get(this.environment.name) ?? this.environment.name, + this.x(), + this.level.y(), + { maxWidth: this.width() } + ); this.totalHeight = this.height() + this.name.height() + Config.TextPaddingY / 2; if (this.parentFrame) this.arrow = new ArrowFromFrame(this).to(this.parentFrame); diff --git a/src/features/cseMachine/components/StashItemComponent.tsx b/src/features/cseMachine/components/StashItemComponent.tsx index b0d37e634c..49c31a140a 100644 --- a/src/features/cseMachine/components/StashItemComponent.tsx +++ b/src/features/cseMachine/components/StashItemComponent.tsx @@ -85,13 +85,13 @@ export class StashItemComponent extends Visible implements IHoverable { } // Save previous z-index to go back to later - private zIndex = 0; + private zIndex = 1; onMouseEnter = (e: KonvaEventObject) => { if (!isStashItemInDanger(this.index)) { setHoveredStyle(e.currentTarget); } setHoveredCursor(e.currentTarget); - this.zIndex = this.ref.current.zIndex(); + this.zIndex = Math.max(this.ref.current.zIndex(), 1); this.ref.current.moveToTop(); this.tooltipRef.current.moveToTop(); this.tooltipRef.current.show(); diff --git a/src/features/cseMachine/components/arrows/ArrowFromArrayUnit.tsx b/src/features/cseMachine/components/arrows/ArrowFromArrayUnit.tsx index 83492003d2..90592740cd 100644 --- a/src/features/cseMachine/components/arrows/ArrowFromArrayUnit.tsx +++ b/src/features/cseMachine/components/arrows/ArrowFromArrayUnit.tsx @@ -28,14 +28,23 @@ export class ArrowFromArrayUnit extends GenericArrow { steps.push(() => [from.x() < to.x() ? to.x() : to.centerX, to.y()]); } else if (to instanceof ArrayValue) { if (from.y() === to.y()) { - if (Math.abs(from.x() - to.x()) > Config.DataUnitWidth * 2) { + if (from.isLastUnit && to.x() > from.x() && to.x() <= from.x() + Config.DataUnitWidth * 2) { + // Horizontal arrow that follows box-and-pointer notation for lists + steps.push(() => [to.x(), to.y() + Config.DataUnitHeight / 2]); + } else if (Math.abs(from.x() - to.x()) < Config.DataUnitWidth / 2) { + // Longer circular arrow for arrows pointing back to the same spot + steps.push((x, y) => [x, y - Config.DataUnitHeight]); + steps.push(() => [to.x() - Config.DataUnitWidth / 2, to.y() - Config.DataUnitHeight / 2]); + steps.push((x, y) => [x, y + (Config.DataUnitHeight * 2) / 3]); + steps.push((x, y) => [x + Config.DataUnitWidth / 2, y]); + } else { + // Standard arrow that curves upwards first before pointing to the target steps.push((x, y) => [x, y - Config.DataUnitHeight]); steps.push(() => [to.x() + Config.DataUnitWidth / 2, to.y() - Config.DataUnitHeight / 2]); steps.push((x, y) => [x, y + Config.DataUnitHeight / 2]); - } else { - steps.push(() => [to.x(), to.y() + Config.DataUnitHeight / 2]); } } else { + // Straight arrow that points directly to the target steps.push(() => [ to.x() + Config.DataUnitWidth / 2, to.y() + (from.y() > to.y() ? Config.DataUnitHeight : 0) diff --git a/src/features/cseMachine/components/arrows/GenericArrow.tsx b/src/features/cseMachine/components/arrows/GenericArrow.tsx index 38786dfa1e..792a4a0937 100644 --- a/src/features/cseMachine/components/arrows/GenericArrow.tsx +++ b/src/features/cseMachine/components/arrows/GenericArrow.tsx @@ -61,7 +61,7 @@ export class GenericArrow exte const y2 = yb + br * Math.sign(dy2); // draw quadratic curves over corners - this._path += `L ${x1} ${y1} Q ${xb} ${yb} ${x2} ${y2}`; + this._path += `L ${x1} ${y1} Q ${xb} ${yb} ${x2} ${y2} `; n += 2; } } diff --git a/src/features/cseMachine/components/values/ArrayValue.tsx b/src/features/cseMachine/components/values/ArrayValue.tsx index 1726254c91..a2a61548a4 100644 --- a/src/features/cseMachine/components/values/ArrayValue.tsx +++ b/src/features/cseMachine/components/values/ArrayValue.tsx @@ -9,7 +9,7 @@ import { isMainReference } from '../../CseMachineUtils'; import { ArrayEmptyUnit } from '../ArrayEmptyUnit'; import { ArrayUnit } from '../ArrayUnit'; import { Binding } from '../Binding'; -import { PrimitiveValue } from './PrimitiveValue'; +import { FnValue } from './FnValue'; import { Value } from './Value'; /** this class encapsulates an array value in source, @@ -17,6 +17,10 @@ import { Value } from './Value'; export class ArrayValue extends Value implements IHoverable { /** array of units this array is made of */ units: ArrayUnit[] = []; + /** width of the array or the nested values inside the array. */ + totalWidth: number = 0; + /** height of the array and nested values inside the array */ + totalHeight: number = 0; constructor( /** underlying values this array contains */ @@ -42,38 +46,39 @@ export class ArrayValue extends Value implements IHoverable { this._y = newReference.y(); } else { this._x = newReference.x(); - this._y = newReference.y() + newReference.parent.height() + Config.DataUnitHeight; + this._y = newReference.y() + newReference.parent.totalHeight + Config.DataUnitHeight; } } this._width = Math.max(this.data.length * Config.DataUnitWidth, Config.DataMinWidth); + this.totalWidth = this._width; this._height = Config.DataUnitHeight; + this.totalHeight = this._height; + this.units = new Array(this.data.length); // initialize array units from the last index for (let i = this.data.length - 1; i >= 0; i--) { const unit = new ArrayUnit(i, this.data[i], this); - // update the dimensions, so that children array values can derive their coordinates - // from these intermediate dimensions - - // update the width - this._width = Math.max( - this.width(), - unit.value.width() + - (!(unit.value instanceof PrimitiveValue) && i === this.data.length - 1 - ? (i + 1) * Config.DataUnitWidth + Config.DataUnitWidth - : i * Config.DataUnitWidth) - ); - - // update the height - this._height = Math.max( - this._height, - unit.value instanceof PrimitiveValue || unit.isMainReference - ? Config.DataUnitHeight - : unit.value.y() + unit.value.height() - unit.y() - ); + // Update total width and height for values that are drawn next to the array + if ( + (unit.value instanceof ArrayValue || unit.value instanceof FnValue) && + isMainReference(unit.value, unit) + ) { + this.totalWidth = Math.max( + this.totalWidth, + unit.value.totalWidth + + (i === this.data.length - 1 ? (i + 2) * Config.DataUnitWidth : i * Config.DataUnitWidth) + ); + this.totalHeight = Math.max( + this.totalHeight, + unit.value.y() + + (unit.value instanceof ArrayValue ? unit.value.totalHeight : unit.value.height() / 2) - + unit.y() + ); + } - this.units = [unit, ...this.units]; + this.units[i] = unit; } } diff --git a/src/features/cseMachine/components/values/FnValue.tsx b/src/features/cseMachine/components/values/FnValue.tsx index 3cc9f9637d..56c4a42ecc 100644 --- a/src/features/cseMachine/components/values/FnValue.tsx +++ b/src/features/cseMachine/components/values/FnValue.tsx @@ -46,6 +46,9 @@ export class FnValue extends Value implements IHoverable { readonly tooltipWidth: number; readonly exportTooltip: string; readonly exportTooltipWidth: number; + + /** width of the closure circles + label */ + readonly totalWidth: number; readonly labelRef: RefObject