diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html index aadb1775a7a..fc5dd05f090 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html @@ -86,7 +86,7 @@

Empty result set

@@ -195,9 +195,10 @@
+ nothing. The top menu and the context menu already honour the same switch. + Nor on a port past the first: a cell export reads the operator's first port. --> (); // The first rows of a table result, one string per shown column; undefined while they load. rows: readonly (readonly string[])[] | undefined = undefined; diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.html b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.html new file mode 100644 index 00000000000..687abbd3557 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.html @@ -0,0 +1,70 @@ + + +
+
+ + {{ title }} + +
+
+ +
+
+ + + +
+
diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.scss b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.scss new file mode 100644 index 00000000000..6d25a26c344 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.scss @@ -0,0 +1,102 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.pinned-result { + width: 520px; + // tall enough for the table's first page to hold 5 rows, as the result panel's does + height: 480px; + min-width: 280px; + min-height: 160px; + display: flex; + flex-direction: column; + // the bottom-right corner resizes the window + resize: both; + overflow: hidden; + background: white; + border: 1px solid #d9d9d9; + border-radius: 6px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.16); + + &__header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 4px 0 10px; + cursor: move; + user-select: none; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; + } + + &__pin { + color: #1890ff; + } + + &__title { + flex: 1; + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__close { + border: none; + background: transparent; + cursor: pointer; + color: #595959; + padding: 2px 6px; + } + + &__ports { + flex-shrink: 0; + display: flex; + gap: 2px; + overflow-x: auto; + padding: 0 6px; + border-bottom: 1px solid #f0f0f0; + } + + &__port { + flex-shrink: 0; + height: 28px; + padding: 0 10px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + cursor: pointer; + color: #595959; + + &--active { + border-bottom-color: #1890ff; + color: #1890ff; + font-weight: 600; + } + } + + &__body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 4px; + } +} diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.spec.ts new file mode 100644 index 00000000000..120193b082d --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.spec.ts @@ -0,0 +1,161 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, Input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { PinnedResultWindowComponent } from "./pinned-result-window.component"; +import { ResultTableFrameComponent } from "../../result-panel/result-table-frame/result-table-frame.component"; +import { VisualizationFrameContentComponent } from "../../visualization-panel-content/visualization-frame-content.component"; +import { OperatorResultPreviewComponent } from "../operator-result-preview/operator-result-preview.component"; +import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; +import { + OperatorPaginationResultService, + WorkflowResultService, +} from "../../../service/workflow-result/workflow-result.service"; +import { OperatorPredicate, PortDescription } from "../../../types/workflow-common.interface"; + +// Stand-ins for the frames the window holds, so the test needs none of their services. +@Component({ selector: "texera-result-table-frame", template: "", standalone: true }) +class StubTableFrame { + @Input() operatorId?: string; + @Input() portIndex = 0; + @Input() containerHeight?: number; +} + +@Component({ selector: "texera-visualization-panel-content", template: "", standalone: true }) +class StubChartFrame { + @Input() operatorId?: string; +} + +@Component({ selector: "texera-operator-result-preview", template: "", standalone: true }) +class StubPreview { + @Input() operatorId?: string; +} + +describe("PinnedResultWindowComponent", () => { + let fixture: ComponentFixture; + let operator: OperatorPredicate; + let hasTable: boolean; + + const ports = (...names: (string | undefined)[]): PortDescription[] => + names.map((displayName, i) => ({ portID: `output-${i}`, displayName })); + + beforeEach(async () => { + operator = { operatorID: "op1", operatorType: "Split", outputPorts: ports(undefined) } as OperatorPredicate; + hasTable = true; + const graph = { hasOperator: () => true, getOperator: () => operator }; + + await TestBed.configureTestingModule({ + imports: [PinnedResultWindowComponent], + providers: [ + { provide: WorkflowActionService, useValue: { getTexeraGraph: () => graph } }, + { + provide: WorkflowResultService, + useValue: { + getPaginatedResultService: () => (hasTable ? ({} as OperatorPaginationResultService) : undefined), + getResultService: () => undefined, + }, + }, + ], + }) + .overrideComponent(PinnedResultWindowComponent, { + remove: { + imports: [ResultTableFrameComponent, VisualizationFrameContentComponent, OperatorResultPreviewComponent], + }, + add: { imports: [StubTableFrame, StubChartFrame, StubPreview] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PinnedResultWindowComponent); + fixture.componentRef.setInput("operatorId", "op1"); + }); + + const tabs = () => fixture.debugElement.queryAll(By.css(".pinned-result__port")); + const table = () => fixture.debugElement.query(By.directive(StubTableFrame)).componentInstance as StubTableFrame; + + it("shows no port tabs for an operator with one output", () => { + fixture.detectChanges(); + + expect(tabs().length).toBe(0); + expect(table().portIndex).toBe(0); + }); + + it("shows a tab for each output, named as on the canvas or by its place, the first one shown", () => { + operator = { ...operator, outputPorts: ports("False", "True") }; + fixture.detectChanges(); + expect(tabs().map(tab => (tab.nativeElement as HTMLElement).textContent?.trim())).toEqual(["False", "True"]); + + operator = { ...operator, outputPorts: ports(undefined, "") }; + fixture.detectChanges(); + expect(tabs().map(tab => (tab.nativeElement as HTMLElement).textContent?.trim())).toEqual(["Output 1", "Output 2"]); + expect(tabs()[0].classes["pinned-result__port--active"]).toBe(true); + expect(table().portIndex).toBe(0); + }); + + it("shows the output whose tab is clicked", () => { + operator = { ...operator, outputPorts: ports("False", "True") }; + fixture.detectChanges(); + + (tabs()[1].nativeElement as HTMLElement).click(); + fixture.detectChanges(); + + expect(table().portIndex).toBe(1); + expect(table().operatorId).toBe("op1"); + expect(tabs()[1].attributes["aria-selected"]).toBe("true"); + expect(tabs()[0].attributes["aria-selected"]).toBe("false"); + }); + + it("shows the last output when the chosen one is removed, and the chosen one again when it is back", () => { + operator = { ...operator, outputPorts: ports("Out 1", "Out 2", "Out 3") }; + fixture.detectChanges(); + (tabs()[2].nativeElement as HTMLElement).click(); + fixture.detectChanges(); + + operator = { ...operator, outputPorts: ports("Out 1", "Out 2") }; + fixture.detectChanges(); + expect(table().portIndex).toBe(1); + expect(tabs()[1].classes["pinned-result__port--active"]).toBe(true); + + operator = { ...operator, outputPorts: ports("Out 1") }; + fixture.detectChanges(); + expect(tabs().length).toBe(0); + expect(table().portIndex).toBe(0); + + operator = { ...operator, outputPorts: ports("Out 1", "Out 2", "Out 3") }; + fixture.detectChanges(); + expect(table().portIndex).toBe(2); + }); + + it("hands the table the height its body leaves, not the result panel's", () => { + fixture.detectChanges(); + fixture.componentInstance.tableHeight = 400; + fixture.detectChanges(); + + expect(table().containerHeight).toBe(400); + }); + + it("shows no port tabs when the result is not a table", () => { + operator = { ...operator, outputPorts: ports("False", "True") }; + hasTable = false; + fixture.detectChanges(); + + expect(tabs().length).toBe(0); + }); +}); diff --git a/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.ts b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.ts new file mode 100644 index 00000000000..879b36ae570 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-editor/pinned-result-window/pinned-result-window.component.ts @@ -0,0 +1,139 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + Output, + ViewChild, +} from "@angular/core"; +import { NgFor, NgIf } from "@angular/common"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; +import { WorkflowResultService } from "../../../service/workflow-result/workflow-result.service"; +import { ResultTableFrameComponent } from "../../result-panel/result-table-frame/result-table-frame.component"; +import { VisualizationFrameContentComponent } from "../../visualization-panel-content/visualization-frame-content.component"; +import { OperatorResultPreviewComponent } from "../operator-result-preview/operator-result-preview.component"; + +/** + * An operator's result pinned next to it on the canvas. It shows the whole result, the way the + * result panel would, and stays until it is closed. Dragging its header moves it away from the + * operator; the editor keeps it at that offset when the operator or the canvas moves. + */ +@Component({ + selector: "texera-pinned-result-window", + templateUrl: "./pinned-result-window.component.html", + styleUrls: ["./pinned-result-window.component.scss"], + imports: [ + NgIf, + NgFor, + NzIconDirective, + ResultTableFrameComponent, + VisualizationFrameContentComponent, + OperatorResultPreviewComponent, + ], +}) +export class PinnedResultWindowComponent implements AfterViewInit, OnDestroy { + @Input() operatorId = ""; + @Output() closed = new EventEmitter(); + @Output() moved = new EventEmitter<{ dx: number; dy: number }>(); + @ViewChild("body") bodyElement?: ElementRef; + + /** The height the window's body leaves for the table, which fills it. */ + tableHeight?: number; + private resizeObserver?: ResizeObserver; + + constructor( + private workflowActionService: WorkflowActionService, + private workflowResultService: WorkflowResultService, + private changeDetectorRef: ChangeDetectorRef + ) {} + + ngAfterViewInit(): void { + const element = this.bodyElement?.nativeElement; + if (!element || typeof ResizeObserver === "undefined") return; + // the user resizes the window by its corner, so the body's height is read as it changes + this.resizeObserver = new ResizeObserver(() => { + const style = getComputedStyle(element); + const height = element.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); + if (height === this.tableHeight) return; + this.tableHeight = height; + this.changeDetectorRef.detectChanges(); + }); + this.resizeObserver.observe(element); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + } + + get title(): string { + const graph = this.workflowActionService.getTexeraGraph(); + if (!graph.hasOperator(this.operatorId)) return this.operatorId; + const operator = graph.getOperator(this.operatorId); + return operator.customDisplayName ?? operator.operatorType; + } + + /** The output port chosen by its tab. */ + selectedPort = 0; + + /** The port shown: the one chosen, or the last when the chosen one is gone. */ + get shownPort(): number { + return Math.min(this.selectedPort, Math.max(this.outputPorts.length - 1, 0)); + } + + /** The operator's output ports, each named as on the canvas, or by its place when unnamed. */ + get outputPorts(): { index: number; label: string }[] { + const graph = this.workflowActionService.getTexeraGraph(); + if (!graph.hasOperator(this.operatorId)) return []; + return graph + .getOperator(this.operatorId) + .outputPorts.map((port, index) => ({ index, label: port.displayName || `Output ${index + 1}` })); + } + + get kind(): "table" | "chart" | "other" { + if (this.workflowResultService.getPaginatedResultService(this.operatorId)) return "table"; + if (this.workflowResultService.getResultService(this.operatorId)) return "chart"; + return "other"; + } + + /** Drag by the header. Reports how far the pointer moved, so the editor can keep the offset. */ + startDrag(event: MouseEvent): void { + if ((event.target as HTMLElement).closest("button")) return; + event.preventDefault(); + let lastX = event.clientX; + let lastY = event.clientY; + const move = (e: MouseEvent) => { + this.moved.emit({ dx: e.clientX - lastX, dy: e.clientY - lastY }); + lastX = e.clientX; + lastY = e.clientY; + }; + const stop = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", stop); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", stop); + } +} diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html index 7e777e5f48d..fc441271276 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html @@ -31,10 +31,29 @@
{{ heatmapTooltip.metricLabel }}
Heat: {{ heatmapTooltip.heatLabel }}
+ + + + { expect(component.resultPreview).toBeNull(); })); + describe("pinned result windows", () => { + const pinnedWindows = () => fixture.debugElement.queryAll(By.css("texera-pinned-result-window")); + + /** Open the scan operator's card and pin it, as its Pin button does. */ + function pinScan(): void { + const view = component.paper.findViewByModel(mockScanPredicate.operatorID); + component.paper.trigger("element:mouseenter", view, new MouseEvent("mouseenter")); + tick(RESULT_PREVIEW_DELAY_MS); + component.pinResultPreview(); + fixture.detectChanges(); + } + + /** The scan operator's box as drawn, in the editor's coordinates. */ + function scanBox(): { left: number; right: number; top: number; bottom: number } { + const bbox = component.paper.getModelById(mockScanPredicate.operatorID).getBBox(); + const { sx, sy } = component.paper.scale(); + const { tx, ty } = component.paper.translate(); + return { + left: bbox.x * sx + tx, + right: (bbox.x + bbox.width) * sx + tx, + top: bbox.y * sy + ty, + bottom: (bbox.y + bbox.height) * sy + ty, + }; + } + + it("pins the card's operator as a window and closes the card, which that operator no longer opens", fakeAsync(() => { + addLinkedPair(); + pinScan(); + + expect(component.resultPreview).toBeNull(); + expect(component.pinnedResults).toEqual([{ operatorId: mockScanPredicate.operatorID, dx: 0, dy: 0 }]); + expect(pinnedWindows().length).toBe(1); + expect(pinnedWindows()[0].attributes["data-operator-id"]).toBe(mockScanPredicate.operatorID); + + const view = component.paper.findViewByModel(mockScanPredicate.operatorID); + component.paper.trigger("element:mouseenter", view, new MouseEvent("mouseenter")); + tick(RESULT_PREVIEW_DELAY_MS); + expect(component.resultPreview).toBeNull(); + })); + + it("unpins a window when it is closed", fakeAsync(() => { + addLinkedPair(); + pinScan(); + + (pinnedWindows()[0].componentInstance as PinnedResultWindowComponent).closed.emit(); + fixture.detectChanges(); + + expect(component.pinnedResults).toEqual([]); + expect(pinnedWindows().length).toBe(0); + })); + + it("unpins the window of a deleted operator, but not another's", fakeAsync(() => { + addLinkedPair(); + pinScan(); + const workflowActionService = TestBed.inject(WorkflowActionService); + + workflowActionService.deleteOperator(mockResultPredicate.operatorID); + expect(component.pinnedResults.map(pinned => pinned.operatorId)).toEqual([mockScanPredicate.operatorID]); + + workflowActionService.deleteOperator(mockScanPredicate.operatorID); + expect(component.pinnedResults).toEqual([]); + expect(pinnedWindows().length).toBe(0); + })); + + it("places a window just right of its operator, level with its top, and keeps it there as the canvas moves", fakeAsync(() => { + addLinkedPair(); + pinScan(); + const pinned = component.pinnedResults[0]; + + let box = scanBox(); + expect(component.pinnedResultPosition(pinned)).toEqual({ x: box.right + 12, y: box.top }); + + component.paper.translate(40, -25); + component.paper.scale(0.5, 0.5); + box = scanBox(); + expect(component.pinnedResultPosition(pinned)).toEqual({ x: box.right + 12, y: box.top }); + })); + + it("keeps a dragged window at the offset it was dragged to", fakeAsync(() => { + addLinkedPair(); + pinScan(); + const box = scanBox(); + + component.movePinnedResult(mockScanPredicate.operatorID, { dx: 30, dy: 10 }); + component.movePinnedResult(mockScanPredicate.operatorID, { dx: -5, dy: 15 }); + component.movePinnedResult("another-operator", { dx: 100, dy: 100 }); + + expect(component.pinnedResults[0]).toEqual({ operatorId: mockScanPredicate.operatorID, dx: 25, dy: 25 }); + expect(component.pinnedResultPosition(component.pinnedResults[0])).toEqual({ + x: box.right + 12 + 25, + y: box.top + 25, + }); + })); + + describe("the line from an operator to its window", () => { + const WIDTH = 300; + const HEIGHT = 200; + + // a window's size is read from the page, which lays nothing out in a test + beforeEach(() => + vi.spyOn(component as any, "pinnedWindowSize").mockReturnValue({ width: WIDTH, height: HEIGHT }) + ); + + /** The path's start and end points. */ + function ends(path: string): number[][] { + const numbers = path.match(/-?[\d.]+/g)!.map(Number); + return [numbers.slice(0, 2), numbers.slice(-2)]; + } + + it("runs from the operator's right side into the header of a window on its right", fakeAsync(() => { + addLinkedPair(); + pinScan(); + const box = scanBox(); + const corner = component.pinnedResultPosition(component.pinnedResults[0]); + + expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([ + [box.right, (box.top + box.bottom) / 2], + [corner.x, corner.y + PINNED_HEADER_HEIGHT / 2], + ]); + })); + + it("runs from the operator's left side into the header of a window dragged to its left", fakeAsync(() => { + addLinkedPair(); + pinScan(); + const box = scanBox(); + component.movePinnedResult(mockScanPredicate.operatorID, { + dx: -(box.right - box.left) - WIDTH - 100, + dy: 0, + }); + const corner = component.pinnedResultPosition(component.pinnedResults[0]); + + expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([ + [box.left, (box.top + box.bottom) / 2], + [corner.x + WIDTH, corner.y + PINNED_HEADER_HEIGHT / 2], + ]); + })); + + it("runs from the operator's bottom to the top of a window dragged below it, and top to bottom above", fakeAsync(() => { + addLinkedPair(); + pinScan(); + const box = scanBox(); + const midX = (box.left + box.right) / 2; + // under the operator: the window's left edge left of the operator's middle + component.movePinnedResult(mockScanPredicate.operatorID, { dx: -(box.right - box.left), dy: 0 }); + component.movePinnedResult(mockScanPredicate.operatorID, { dx: 0, dy: box.bottom - box.top + 50 }); + let corner = component.pinnedResultPosition(component.pinnedResults[0]); + expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([ + [midX, box.bottom], + [midX, corner.y], + ]); + + component.movePinnedResult(mockScanPredicate.operatorID, { + dx: 0, + dy: -(box.bottom - box.top + 50) - HEIGHT - 80, + }); + corner = component.pinnedResultPosition(component.pinnedResults[0]); + expect(ends(component.pinnedConnectorPath(component.pinnedResults[0]))).toEqual([ + [midX, box.top], + [midX, corner.y + HEIGHT], + ]); + })); + }); + }); + /** Select the scan operator the way a click does and return its model, whose attrs say what is on show. */ function selectScanOperator(): joint.dia.Cell { addLinkedPair(); diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts index 023f2624da8..90a7027f59c 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts @@ -52,10 +52,11 @@ import concaveman from "concaveman"; import { OperatorResultSummary, AgentService } from "../../service/agent/agent.service"; import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation"; import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component"; -import { NgIf } from "@angular/common"; +import { NgFor, NgIf } from "@angular/common"; import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component"; import { HeatmapLegendComponent } from "../heatmap-legend/heatmap-legend.component"; import { OperatorResultPreviewComponent } from "./operator-result-preview/operator-result-preview.component"; +import { PinnedResultWindowComponent } from "./pinned-result-window/pinned-result-window.component"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; // jointjs interactive options for enabling and disabling interactivity @@ -83,6 +84,8 @@ export const MAIN_CANVAS = { export const RESULT_PREVIEW_DELAY_MS = 500; /** How long the card stays after the pointer leaves, so the pointer can move into it. */ export const RESULT_PREVIEW_HIDE_MS = 200; +/** The height of a pinned window's header, where the line from its operator comes in. */ +export const PINNED_HEADER_HEIGHT = 30; /** Whether a mouse button is down. JointJS hands over a jQuery event, which keeps the DOM one inside. */ function isButtonHeld(evt: unknown): boolean { @@ -116,6 +119,8 @@ function isButtonHeld(evt: unknown): boolean { AgentInteractionComponent, HeatmapLegendComponent, OperatorResultPreviewComponent, + PinnedResultWindowComponent, + NgFor, ], }) export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy { @@ -134,6 +139,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy } | null = null; // The hovered operator's result preview, and where to show it. Null when hidden. public resultPreview: { operatorId: string; x: number; y: number } | null = null; + // Results pinned beside their operators, each with how far it was dragged from its spot. + public pinnedResults: { operatorId: string; dx: number; dy: number }[] = []; private previewShowTimer?: ReturnType; // The operator previewShowTimer opens the card for. private previewPendingOperatorId?: string; @@ -614,8 +621,9 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy } /** - * Shows the hovered operator's result preview beside it. Off when Layers > Result preview is - * unchecked, and while the heat-map overlay is on, whose own tooltip answers the hover then. + * Shows the hovered operator's result preview beside it; its Pin button keeps the whole result + * there. Off when Layers > Result preview is unchecked, and while the heat-map overlay is on, + * whose own tooltip answers the hover then. */ private handleResultPreviewHover(): void { fromJointPaperEvent(this.paper, "element:mouseenter") @@ -628,7 +636,11 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy } const operatorId = elementView.model.id.toString(); // no preview while a button is held: the pointer is dragging an operator or a link - if (!this.workflowActionService.getTexeraGraph().hasOperator(operatorId) || isButtonHeld(evt)) { + if ( + !this.workflowActionService.getTexeraGraph().hasOperator(operatorId) || + isButtonHeld(evt) || + this.pinnedResults.some(pinned => pinned.operatorId === operatorId) + ) { return; } // Only a pointer that rests on the operator opens the card, so passing over one does not. @@ -658,16 +670,27 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy this.changeDetectorRef.detectChanges(); }); - // A deleted operator takes its card along, and a card still waiting to open for it. + // Pinned windows sit beside their operators, so they move when an operator or the canvas does. + merge( + fromJointPaperEvent(this.paper, "translate"), + fromJointPaperEvent(this.paper, "scale"), + fromEvent(this.paper.model as any, "change:position") + ) + .pipe(untilDestroyed(this)) + .subscribe(() => { + if (this.pinnedResults.length > 0) this.changeDetectorRef.detectChanges(); + }); + + // A deleted operator takes its window and its card along, and a card still waiting to open for it. this.workflowActionService .getTexeraGraph() .getOperatorDeleteStream() .pipe(untilDestroyed(this)) .subscribe(({ deletedOperatorID }) => { if (this.previewPendingOperatorId === deletedOperatorID) clearTimeout(this.previewShowTimer); - if (this.resultPreview?.operatorId !== deletedOperatorID) return; - this.resultPreview = null; - this.changeDetectorRef.detectChanges(); + if (this.resultPreview?.operatorId === deletedOperatorID) this.resultPreview = null; + // detects changes for both + this.unpinResult(deletedOperatorID); }); } @@ -685,6 +708,78 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy clearTimeout(this.previewHideTimer); } + public pinResultPreview(): void { + if (this.resultPreview === null) return; + this.pinnedResults = [...this.pinnedResults, { operatorId: this.resultPreview.operatorId, dx: 0, dy: 0 }]; + this.resultPreview = null; + } + + public unpinResult(operatorId: string): void { + this.pinnedResults = this.pinnedResults.filter(pinned => pinned.operatorId !== operatorId); + this.changeDetectorRef.detectChanges(); + } + + // A window is its operator's, so a move keeps the window being dragged instead of rebuilding it. + public trackPinnedResult(_index: number, pinned: { operatorId: string }): string { + return pinned.operatorId; + } + + public movePinnedResult(operatorId: string, delta: { dx: number; dy: number }): void { + this.pinnedResults = this.pinnedResults.map(pinned => + pinned.operatorId === operatorId ? { ...pinned, dx: pinned.dx + delta.dx, dy: pinned.dy + delta.dy } : pinned + ); + } + + /** Where a pinned window goes: beside its operator, plus however far it was dragged from there. */ + public pinnedResultPosition(pinned: { operatorId: string; dx: number; dy: number }): { x: number; y: number } { + const anchor = this.operatorAnchor(pinned.operatorId); + return { x: anchor.x + pinned.dx, y: anchor.y + pinned.dy }; + } + + /** + * The line from an operator to its pinned window, a curve like the links between operators. + * Beside the operator, it runs side to side into the window's header; above or below, it runs + * from the operator's top or bottom to the window's nearest edge. + */ + public pinnedConnectorPath(pinned: { operatorId: string; dx: number; dy: number }): string { + const cell = this.paper.getModelById(pinned.operatorId); + if (!cell) return ""; + const bbox = cell.getBBox(); + const scale = this.paper.scale(); + const translate = this.paper.translate(); + const op = { + left: bbox.x * scale.sx + translate.tx, + right: (bbox.x + bbox.width) * scale.sx + translate.tx, + top: bbox.y * scale.sy + translate.ty, + bottom: (bbox.y + bbox.height) * scale.sy + translate.ty, + }; + const midX = (op.left + op.right) / 2; + const midY = (op.top + op.bottom) / 2; + + const corner = this.pinnedResultPosition(pinned); + const size = this.pinnedWindowSize(pinned.operatorId); + const win = { left: corner.x, right: corner.x + size.width, top: corner.y, bottom: corner.y + size.height }; + const headerY = win.top + PINNED_HEADER_HEIGHT / 2; + + const curve = (x1: number, y1: number, x2: number, y2: number, horizontal: boolean) => { + const bend = Math.max(30, (horizontal ? Math.abs(x2 - x1) : Math.abs(y2 - y1)) / 2); + const [bx, by] = horizontal ? [Math.sign(x2 - x1) * bend, 0] : [0, Math.sign(y2 - y1) * bend]; + return `M ${x1} ${y1} C ${x1 + bx} ${y1 + by}, ${x2 - bx} ${y2 - by}, ${x2} ${y2}`; + }; + if (win.left >= op.right) return curve(op.right, midY, win.left, headerY, true); + if (win.right <= op.left) return curve(op.left, midY, win.right, headerY, true); + // the window overlaps the operator's column: join the facing edges, at the point under the operator + const x = Math.min(Math.max(midX, win.left + 12), win.right - 12); + return win.top >= op.bottom ? curve(midX, op.bottom, x, win.top, false) : curve(midX, op.top, x, win.bottom, false); + } + + /** A pinned window's size as drawn: the user can resize it, so it is read from the page. */ + private pinnedWindowSize(operatorId: string): { width: number; height: number } { + const host = this.elementRef.nativeElement as HTMLElement; + const box = host.querySelector(`[data-operator-id="${operatorId}"] .pinned-result`); + return { width: box?.offsetWidth ?? 0, height: box?.offsetHeight ?? 0 }; + } + /** Just right of the operator's box, level with its top, in the editor's coordinates. */ private operatorAnchor(operatorId: string): { x: number; y: number } { const cell = this.paper.getModelById(operatorId);