This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6213-98588bf2ab49aac65ebf002496eb057670e6fc07 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 70c21145887920528d7d5540e3fb790b43e8b759 Author: Prateek Ganigi <[email protected]> AuthorDate: Sun Aug 30 22:31:48 2026 +0000 feat(frontend): add toggleable performance heat-map overlay (#6213) ### What changes were proposed in this PR? Adds a toggleable, canvas-level **performance heat-map overlay** (Layers -> Performance) that colors operators cold -> hot from the per-operator metrics captured in #5773. A read-only consumer of existing data and **no backend changes**. - **Three views** with bottleneck-oriented scoring: - **Runtime**: data + control processing time (matches the backend cost model) - **Time / row**: seconds per output row (slow producers / low throughput read hotter) - **I/O imbalance**: `|out − in| / (out + in)`, bounded to [0, 1] so an extreme amplifier can't dominate the scale - **Scoring/color helpers** (pure, unit-tested): `rawMetricForView`, `normalizeScores` (log1p + min-max), `scoreToColor` (colorblind-safe ramp) - **Overlay layer** modeled on the existing Grid/Regions/Workers pattern: a `BehaviorSubject` view stream on `JointGraphWrapper`, applied by the editor to the shared model (canvas + mini-map) - Heat-map owns **only the operator body fill**, so it coexists with the execution-status border - **Legend** showing the active view and its real min/max value range - **Hover tooltip** with the operator's metric value + heat score - Live recolor as stats stream in; restores default fills when toggled off Note on size: this is one cohesive sub-issue (#5774); roughly 40% of the diff is unit tests. ### Any related issues, documentation, discussions? Closes #5774. Part of umbrella #5772. Follows RFC discussion #5216. ### How was this PR tested? New Vitest specs (scoring, color, recolor methods, and the menu toggle): cd frontend && ng test --watch=false --include "**/heatmap-scoring.spec.ts" --include "**/heatmap-color.spec.ts" --include "**/heatmap-legend.component.spec.ts" --include "**/joint-ui.service.spec.ts" --include "**/menu.component.spec.ts" --include "**/workflow-editor.component.spec.ts" Also verified: `tsc --noEmit` (strict) clean, `eslint ./src` clean, Prettier clean, `ng build` succeeds, and the existing `workflow-editor`/`menu` specs still pass. Manually tested in the workspace: ran a workflow, toggled Performance on, switched all three views (recolor + legend range update), verified the hover tooltip, toggled off (fills restore), and confirmed the mini-map mirrors the colors. <!-- before/after screenshots + the three views go here --> UI screenshots Before: <img width="1469" height="837" alt="image" src="https://github.com/user-attachments/assets/cd612dac-934b-4803-ad2a-6deb311679d6" /> After: <img width="1470" height="836" alt="image" src="https://github.com/user-attachments/assets/1c184de8-d5bd-4482-a8ae-c7f2e2d53f39" /> ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Anthropic Claude Opus 4.7) --- .../heatmap-legend/heatmap-legend.component.html | 31 ++++ .../heatmap-legend/heatmap-legend.component.scss | 55 ++++++ .../heatmap-legend.component.spec.ts | 148 ++++++++++++++++ .../heatmap-legend/heatmap-legend.component.ts | 84 +++++++++ .../workspace/component/menu/menu.component.html | 33 ++++ .../workspace/component/menu/menu.component.scss | 20 +++ .../component/menu/menu.component.spec.ts | 38 ++++ .../app/workspace/component/menu/menu.component.ts | 20 +++ .../workflow-editor/workflow-editor.component.html | 10 ++ .../workflow-editor/workflow-editor.component.scss | 31 ++++ .../workflow-editor.component.spec.ts | 14 ++ .../workflow-editor/workflow-editor.component.ts | 170 +++++++++++++++++- .../service/heatmap/heatmap-color.spec.ts | 64 +++++++ .../app/workspace/service/heatmap/heatmap-color.ts | 66 +++++++ .../service/heatmap/heatmap-scoring.spec.ts | 192 +++++++++++++++++++++ .../workspace/service/heatmap/heatmap-scoring.ts | 176 +++++++++++++++++++ .../service/joint-ui/joint-ui.service.spec.ts | 41 +++++ .../workspace/service/joint-ui/joint-ui.service.ts | 21 +++ .../workflow-graph/model/joint-graph-wrapper.ts | 21 +++ 19 files changed, 1234 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.html b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.html new file mode 100644 index 0000000000..9b0a2e34c8 --- /dev/null +++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.html @@ -0,0 +1,31 @@ +<!-- + 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. +--> + +<div + class="heatmap-legend" + *ngIf="legend$ | async as legend"> + <div class="heatmap-legend__title">{{ legend.title }}</div> + <div + class="heatmap-legend__bar" + [style.background]="scaleGradient"></div> + <div class="heatmap-legend__labels"> + <span>{{ legend.minLabel }}</span> + <span>{{ legend.maxLabel }}</span> + </div> +</div> diff --git a/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.scss b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.scss new file mode 100644 index 0000000000..4c8f4fb2bd --- /dev/null +++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.scss @@ -0,0 +1,55 @@ +/** + * 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. + */ + +.heatmap-legend { + position: absolute; + // Bottom-left of the canvas; the mini-map and its toggle buttons sit in the bottom-right + // corner (see mini-map.component.scss). The bottom offset lifts the legend clear of the + // result panel docked along the lower edge (workspace.component.scss #result). + bottom: 72px; + left: 0px; + z-index: 10; + padding: 8px 10px; + font-size: 12px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid #d9d9d9; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + pointer-events: none; + + &__title { + font-weight: 600; + margin-bottom: 4px; + } + + &__bar { + width: 140px; + height: 10px; + border-radius: 2px; + // The gradient is bound in the template from scoreToColor(0/0.5/1) so it can't + // drift from the canvas ramp. + } + + &__labels { + display: flex; + justify-content: space-between; + margin-top: 2px; + color: #666; + } +} diff --git a/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.spec.ts b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.spec.ts new file mode 100644 index 0000000000..f86d969e85 --- /dev/null +++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.spec.ts @@ -0,0 +1,148 @@ +/** + * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; +import { BehaviorSubject } from "rxjs"; +import { HeatmapLegendComponent } from "./heatmap-legend.component"; +import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; +import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service"; +import { OperatorPerformanceMetrics } from "../../service/workflow-status/performance-metrics"; +import { HeatmapView } from "../../service/heatmap/heatmap-scoring"; + +function makeMetrics(overrides: Partial<OperatorPerformanceMetrics>): OperatorPerformanceMetrics { + return { + dataProcessingTimeNs: 0, + controlProcessingTimeNs: 0, + idleTimeNs: 0, + inputRows: 0, + outputRows: 0, + inputSize: 0, + outputSize: 0, + numWorkers: 1, + ...overrides, + }; +} + +describe("HeatmapLegendComponent", () => { + let fixture: ComponentFixture<HeatmapLegendComponent>; + let viewSubject: BehaviorSubject<HeatmapView | null>; + let metricsSubject: BehaviorSubject<Record<string, OperatorPerformanceMetrics>>; + + const legendElement = (): HTMLElement | null => fixture.nativeElement.querySelector(".heatmap-legend"); + const textOf = (selector: string): string => + (fixture.nativeElement.querySelector(selector) as HTMLElement).textContent!.trim(); + const labels = (): string[] => + Array.from(fixture.nativeElement.querySelectorAll(".heatmap-legend__labels span") as NodeListOf<HTMLElement>).map( + el => el.textContent!.trim() + ); + + beforeEach(() => { + viewSubject = new BehaviorSubject<HeatmapView | null>(null); + metricsSubject = new BehaviorSubject<Record<string, OperatorPerformanceMetrics>>({}); + + const workflowActionServiceStub = { + getJointGraphWrapper: () => ({ getHeatmapViewStream: () => viewSubject.asObservable() }), + } as unknown as WorkflowActionService; + const workflowStatusServiceStub = { + getPerformanceMetricsStream: () => metricsSubject.asObservable(), + } as unknown as WorkflowStatusService; + + TestBed.configureTestingModule({ + imports: [HeatmapLegendComponent], + providers: [ + { provide: WorkflowActionService, useValue: workflowActionServiceStub }, + { provide: WorkflowStatusService, useValue: workflowStatusServiceStub }, + ], + }); + fixture = TestBed.createComponent(HeatmapLegendComponent); + fixture.detectChanges(); + }); + + it("is hidden while the overlay is off (view is null)", () => { + expect(legendElement()).toBeNull(); + }); + + it("shows the active view's title and the metric range once the overlay is on", () => { + metricsSubject.next({ + fast: makeMetrics({ dataProcessingTimeNs: 5_000_000 }), + slow: makeMetrics({ dataProcessingTimeNs: 8_620_000_000 }), + }); + viewSubject.next(HeatmapView.Runtime); + fixture.detectChanges(); + + expect(textOf(".heatmap-legend__title")).toBe("Runtime"); + expect(labels()).toEqual(["5 ms", "8.62 s"]); + }); + + it("shows — for both labels when there are no metrics yet", () => { + viewSubject.next(HeatmapView.Runtime); + fixture.detectChanges(); + + expect(labels()).toEqual(["—", "—"]); + }); + + it("keeps not-measurable operators out of the range so they don't anchor the low label", () => { + // The blocking operator (no output yet) has no time-per-row value; without + // the filter it would read as 0 and pin the min label to "0". + metricsSubject.next({ + blocking: makeMetrics({ dataProcessingTimeNs: 30_000_000_000, outputRows: 0 }), + producer: makeMetrics({ dataProcessingTimeNs: 2_000_000_000, outputRows: 4 }), + }); + viewSubject.next(HeatmapView.TimePerRow); + fixture.detectChanges(); + + // Only the producer is measurable: 0.5 s/row is both min and max. + expect(labels()).toEqual(["500.0 ms/row", "500.0 ms/row"]); + }); + + it("shows — when no operator is measurable for the view", () => { + // A lone source has no input rows, so I/O imbalance is undefined for it. + metricsSubject.next({ source: makeMetrics({ outputRows: 250 }) }); + viewSubject.next(HeatmapView.IoImbalance); + fixture.detectChanges(); + + expect(textOf(".heatmap-legend__title")).toBe("I/O imbalance"); + expect(labels()).toEqual(["—", "—"]); + }); + + it("updates the title and range when the view switches", () => { + metricsSubject.next({ + op: makeMetrics({ dataProcessingTimeNs: 5_000_000, inputRows: 1_000, outputRows: 250 }), + }); + viewSubject.next(HeatmapView.Runtime); + fixture.detectChanges(); + expect(textOf(".heatmap-legend__title")).toBe("Runtime"); + + viewSubject.next(HeatmapView.IoImbalance); + fixture.detectChanges(); + expect(textOf(".heatmap-legend__title")).toBe("I/O imbalance"); + // |250 - 1000| / (250 + 1000) = 0.6, the only operator anchors both ends. + expect(labels()).toEqual(["0.60", "0.60"]); + }); + + it("hides again when the overlay is turned off", () => { + viewSubject.next(HeatmapView.Runtime); + fixture.detectChanges(); + expect(legendElement()).not.toBeNull(); + + viewSubject.next(null); + fixture.detectChanges(); + expect(legendElement()).toBeNull(); + }); +}); diff --git a/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.ts b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.ts new file mode 100644 index 0000000000..bd57370868 --- /dev/null +++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.ts @@ -0,0 +1,84 @@ +/** + * 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 } from "@angular/core"; +import { AsyncPipe, NgIf } from "@angular/common"; +import { combineLatest, Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; +import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service"; +import { OperatorPerformanceMetrics } from "../../service/workflow-status/performance-metrics"; +import { + formatMetricForView, + HeatmapView, + heatmapViewTitle, + rawMetricForView, +} from "../../service/heatmap/heatmap-scoring"; +import { scoreToColor } from "../../service/heatmap/heatmap-color"; + +interface HeatmapLegendState { + readonly title: string; + readonly minLabel: string; + readonly maxLabel: string; +} + +/** + * Presentational legend for the performance heat-map overlay. Shows the active view's name, the + * cold -> hot color scale, and the actual min/max metric values behind that scale. Hidden when the + * overlay is off (view is null). + */ +@Component({ + selector: "texera-heatmap-legend", + templateUrl: "./heatmap-legend.component.html", + styleUrls: ["./heatmap-legend.component.scss"], + imports: [NgIf, AsyncPipe], +}) +export class HeatmapLegendComponent { + public readonly legend$: Observable<HeatmapLegendState | null>; + + // Bind the scale bar's gradient from the same source as the operator fills (scoreToColor), + // so the legend can never drift from the canvas ramp. + public readonly scaleGradient = `linear-gradient(to right, ${scoreToColor(0)}, ${scoreToColor(0.5)}, ${scoreToColor(1)})`; + + constructor( + private workflowActionService: WorkflowActionService, + private workflowStatusService: WorkflowStatusService + ) { + this.legend$ = combineLatest([ + this.workflowActionService.getJointGraphWrapper().getHeatmapViewStream(), + this.workflowStatusService.getPerformanceMetricsStream(), + ]).pipe(map(([view, metrics]) => (view === null ? null : this.buildState(view, metrics)))); + } + + private buildState(view: HeatmapView, metrics: Record<string, OperatorPerformanceMetrics>): HeatmapLegendState { + // Undefined raws (view not measurable for the operator) stay out of the + // range, so they never anchor the legend's low label. + const raws = Object.values(metrics) + .map(m => rawMetricForView(m, view)) + .filter((v): v is number => v !== undefined && Number.isFinite(v)); + const hasData = raws.length > 0; + const min = hasData ? Math.min(...raws) : 0; + const max = hasData ? Math.max(...raws) : 0; + return { + title: heatmapViewTitle(view), + minLabel: hasData ? formatMetricForView(min, view) : "—", + maxLabel: hasData ? formatMetricForView(max, view) : "—", + }; + } +} diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index 34fa3dccc4..cfdeea7710 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -195,6 +195,39 @@ >Status</label > </li> + <li nz-menu-item> + <label + nz-checkbox + [(ngModel)]="showHeatmap" + (ngModelChange)="toggleHeatmap()" + >Performance</label + > + </li> + <li + nz-menu-item + *ngIf="showHeatmap" + class="heatmap-view-options"> + <nz-radio-group + [(ngModel)]="heatmapView" + (ngModelChange)="setHeatmapView($event)" + (click)="$event.stopPropagation()"> + <label + nz-radio + [nzValue]="HeatmapView.Runtime" + >Runtime</label + > + <label + nz-radio + [nzValue]="HeatmapView.TimePerRow" + >Time / row</label + > + <label + nz-radio + [nzValue]="HeatmapView.IoImbalance" + >I/O imbalance</label + > + </nz-radio-group> + </li> </ul> </nz-dropdown-menu> <button diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss b/frontend/src/app/workspace/component/menu/menu.component.scss index cefda1cd79..deb31c0258 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.scss +++ b/frontend/src/app/workspace/component/menu/menu.component.scss @@ -188,6 +188,26 @@ texera-coeditor-user-icon { ::ng-deep .layers-dropdown { user-select: none; + + // Present the performance views as a sub-group nested under the "Performance" toggle: + // indented, stacked vertically, with a tree-style left rule. + .heatmap-view-options { + cursor: default; + + .ant-radio-group { + display: flex; + flex-direction: column; + row-gap: 2px; + margin-left: 24px; + padding-left: 10px; + border-left: 2px solid #f0f0f0; + } + + .ant-radio-wrapper { + margin-left: 0; + color: rgba(0, 0, 0, 0.85); + } + } } .jupyter-notebook-icon { diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts index 8601e7d38c..c726bfec65 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts @@ -42,6 +42,7 @@ import { WorkflowVersionService } from "../../../dashboard/service/user/workflow import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; import { NotificationService } from "../../../common/service/notification/notification.service"; import { ExecutionState } from "../../types/execute-workflow.interface"; +import { HeatmapView } from "../../service/heatmap/heatmap-scoring"; import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; import { mockPoint, mockScanPredicate } from "../../service/workflow-graph/model/mock-workflow-data"; import { saveAs } from "file-saver"; @@ -753,6 +754,43 @@ describe("MenuComponent", () => { }); }); + describe("toggleHeatmap / setHeatmapView", () => { + it("publishes the selected view to the joint graph wrapper when enabled", () => { + const setSpy = vi.spyOn(workflowActionService.getJointGraphWrapper(), "setHeatmapView"); + + component.showHeatmap = true; + component.heatmapView = HeatmapView.TimePerRow; + component.toggleHeatmap(); + + expect(setSpy).toHaveBeenCalledWith(HeatmapView.TimePerRow); + }); + + it("publishes null to the joint graph wrapper when disabled", () => { + const setSpy = vi.spyOn(workflowActionService.getJointGraphWrapper(), "setHeatmapView"); + + component.showHeatmap = false; + component.toggleHeatmap(); + + expect(setSpy).toHaveBeenCalledWith(null); + }); + + it("pushes a newly selected view only while the overlay is enabled", () => { + const setSpy = vi.spyOn(workflowActionService.getJointGraphWrapper(), "setHeatmapView"); + + component.showHeatmap = true; + component.setHeatmapView(HeatmapView.IoImbalance); + expect(component.heatmapView).toBe(HeatmapView.IoImbalance); + expect(setSpy).toHaveBeenCalledWith(HeatmapView.IoImbalance); + + setSpy.mockClear(); + component.showHeatmap = false; + component.setHeatmapView(HeatmapView.Runtime); + // View selection is remembered, but nothing is pushed while the overlay is off. + expect(component.heatmapView).toBe(HeatmapView.Runtime); + expect(setSpy).not.toHaveBeenCalled(); + }); + }); + describe("toggleStatus", () => { it("removes hide-operator-status when enabled and repositions the status label", () => { const operator = fakeElement("operator"); diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts index 4f88930335..b5b209b3eb 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.ts @@ -28,6 +28,7 @@ import { UndoRedoService } from "../../service/undo-redo/undo-redo.service"; import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service"; import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; import { ExecutionState } from "../../types/execute-workflow.interface"; +import { HeatmapView } from "../../service/heatmap/heatmap-scoring"; import { WorkflowWebsocketService } from "../../service/workflow-websocket/workflow-websocket.service"; import { WorkflowResultExportService } from "../../service/workflow-result-export/workflow-result-export.service"; import { catchError, debounceTime, switchMap, tap } from "rxjs/operators"; @@ -64,6 +65,7 @@ import { UserIconComponent } from "../../../dashboard/component/user/user-icon/u import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; import { NzCheckboxComponent } from "ng-zorro-antd/checkbox"; +import { NzRadioComponent, NzRadioGroupComponent } from "ng-zorro-antd/radio"; import { NzPopoverDirective } from "ng-zorro-antd/popover"; import { NzSwitchComponent } from "ng-zorro-antd/switch"; import { NzBadgeComponent } from "ng-zorro-antd/badge"; @@ -108,6 +110,8 @@ import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.s NzMenuDirective, NzMenuItemComponent, NzCheckboxComponent, + NzRadioComponent, + NzRadioGroupComponent, NgTemplateOutlet, ComputingUnitSelectionComponent, NzPopoverDirective, @@ -133,6 +137,9 @@ export class MenuComponent implements OnInit, OnDestroy { public showGrid: boolean = false; public showNumWorkers: boolean = false; public showStatus: boolean = false; + public showHeatmap: boolean = false; + public heatmapView: HeatmapView = HeatmapView.Runtime; + public HeatmapView = HeatmapView; // make Angular HTML access enum definition protected readonly USER_WORKFLOW = USER_WORKFLOW; @Input() public writeAccess: boolean = false; @@ -533,6 +540,19 @@ export class MenuComponent implements OnInit, OnDestroy { this.workflowActionService.getJointGraphWrapper().setRegionsDisplayed(this.showRegion); } + public toggleHeatmap(): void { + // The editor subscribes to this stream and colors operator fills (canvas + mini-map). + // A null view turns the overlay off; a view enables it. + this.workflowActionService.getJointGraphWrapper().setHeatmapView(this.showHeatmap ? this.heatmapView : null); + } + + public setHeatmapView(view: HeatmapView): void { + this.heatmapView = view; + if (this.showHeatmap) { + this.workflowActionService.getJointGraphWrapper().setHeatmapView(view); + } + } + /** * This method will run the autoLayout function * 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 ed0b7cc748..0b652d1a76 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 @@ -21,6 +21,16 @@ <div id="workflow-editor" (contextmenu)="nzContextMenu.create($event, menu)"></div> + <texera-heatmap-legend></texera-heatmap-legend> + <div + class="heatmap-tooltip" + *ngIf="heatmapTooltip" + [style.left.px]="heatmapTooltip.x" + [style.top.px]="heatmapTooltip.y"> + <div class="heatmap-tooltip__title">{{ heatmapTooltip.title }}</div> + <div class="heatmap-tooltip__row">{{ heatmapTooltip.metricLabel }}</div> + <div class="heatmap-tooltip__row">Heat: {{ heatmapTooltip.heatLabel }}</div> + </div> <nz-dropdown-menu nzNoAnimation #menu="nzDropdownMenu"> diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss index 482dac56a2..2d2928c002 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.scss @@ -19,12 +19,43 @@ #workflow-editor-wrapper { height: 100%; + // Anchors absolutely-positioned canvas overlays (e.g. the heat-map legend). + position: relative; } #workflow-editor { height: 100%; } +// Smoothly animate operator body fill changes for the heat-map recolor (view switch / toggle). +// Scoped to the .heatmap-active class the overlay toggles, so it never animates the +// disable/enable or validation fill paths when the overlay is off. +::ng-deep #workflow-editor.heatmap-active .body { + transition: fill 0.25s ease; +} + +.heatmap-tooltip { + position: absolute; + z-index: 11; + padding: 6px 8px; + font-size: 12px; + line-height: 1.4; + color: #fff; + background: rgba(0, 0, 0, 0.8); + border-radius: 4px; + pointer-events: none; + white-space: nowrap; + + &__title { + font-weight: 600; + margin-bottom: 2px; + } + + &__row { + color: #eee; + } +} + ::ng-deep .agent-action { // Agent action highlights - temporary 5-second visual indicators // Styles are defined inline in JointJS element, but this provides a hook for customization diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts index 0dbb5d9f38..9597229482 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts @@ -18,6 +18,7 @@ */ import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; +import { HeatmapView } from "../../service/heatmap/heatmap-scoring"; import { UndoRedoService } from "../../service/undo-redo/undo-redo.service"; import { DragDropService } from "../../service/drag-drop/drag-drop.service"; import { WorkflowUtilService } from "../../service/workflow-graph/util/workflow-util.service"; @@ -120,6 +121,19 @@ describe("WorkflowEditorComponent", () => { expect(component).toBeTruthy(); }); + it("should reset the heat-map view on destroy so a re-entered workspace starts with the overlay off", () => { + // The wrapper is root-provided and outlives the editor, while the menu's + // checkbox re-initializes to off on every workspace entry; without the + // reset the stale view repaints no-data colors and the first checkbox + // click re-publishes the view instead of clearing it. + const wrapper = TestBed.inject(WorkflowActionService).getJointGraphWrapper(); + wrapper.setHeatmapView(HeatmapView.Runtime); + + fixture.destroy(); + + expect(wrapper.getHeatmapView()).toBeNull(); + }); + it("should hide operator status on the canvas by default", () => { // keeps the Status toggle off until the user enables it const editor = (component as any).editor as HTMLElement; 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 f84ed89e88..99a30acdd1 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 @@ -28,6 +28,13 @@ import { fromJointPaperEvent, JointUIService, linkPathStrokeColor } from "../../ import { Validation, ValidationWorkflowService } from "../../service/validation/validation-workflow.service"; import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service"; +import { + formatMetricForView, + HeatmapView, + heatmapViewTitle, + normalizeScores, + rawMetricForView, +} from "../../service/heatmap/heatmap-scoring"; import { ExecutionState, OperatorState } from "../../types/execute-workflow.interface"; import { LogicalPort, OperatorLink, OperatorPredicate } from "../../types/workflow-common.interface"; import { auditTime, filter, map, takeUntil, withLatestFrom } from "rxjs/operators"; @@ -48,6 +55,7 @@ import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation"; import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component"; import { NgIf } from "@angular/common"; import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component"; +import { HeatmapLegendComponent } from "../heatmap-legend/heatmap-legend.component"; import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service"; // jointjs interactive options for enabling and disabling interactivity @@ -89,12 +97,27 @@ export const MAIN_CANVAS = { selector: "texera-workflow-editor", templateUrl: "workflow-editor.component.html", styleUrls: ["workflow-editor.component.scss"], - imports: [NzDropdownMenuComponent, NzNoAnimationDirective, ContextMenuComponent, NgIf, AgentInteractionComponent], + imports: [ + NzDropdownMenuComponent, + NzNoAnimationDirective, + ContextMenuComponent, + NgIf, + AgentInteractionComponent, + HeatmapLegendComponent, + ], }) export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy { editor!: HTMLElement; editorWrapper!: HTMLElement; paper!: joint.dia.Paper; + // Heat-map hover tooltip (shown while the Performance overlay is on). Null when hidden. + public heatmapTooltip: { + x: number; + y: number; + title: string; + metricLabel: string; + heatLabel: string; + } | null = null; private interactive: boolean = true; private _onProcessKeyboardActionObservable: Subject<void> = new Subject(); private wrapper; @@ -190,6 +213,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy this.handlePortHighlightEvent(); this.registerPortDisplayNameChangeHandler(); this.handleOperatorStatisticsUpdate(); + this.handleHeatmapOverlay(); + this.handleHeatmapHover(); this.handleRegionEvents(); this.handleOperatorSuggestionHighlightEvent(); this.handleAgentHoverHighlight(); @@ -211,6 +236,12 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy ngOnDestroy(): void { document.removeEventListener("keydown", this._handleKeyboardAction.bind(this)); + // The overlay belongs to the canvas being viewed, but the wrapper holding + // the view is root-provided and outlives this component, while the menu's + // checkbox re-initializes to off and the metrics behind the overlay are + // cleared on workspace teardown. Reset the view here so all three agree + // when a workspace is re-entered. + this.workflowActionService.getJointGraphWrapper().setHeatmapView(null); } private _handleKeyboardAction(event: any) { @@ -391,6 +422,143 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy }); } + /** + * Drives the performance heat-map overlay (Layers > Performance). The overlay colors only the + * operator body fill, so it coexists with the execution-status border. Colors are derived from + * the WorkflowStatusService performance metrics via the pure heatmap-scoring helpers, so both the + * main canvas and the mini-map (shared model) update. + */ + private handleHeatmapOverlay(): void { + // Repaint whenever the active view or the metrics change (only while a view is active). + combineLatest([this.wrapper.getHeatmapViewStream(), this.workflowStatusService.getPerformanceMetricsStream()]) + .pipe(untilDestroyed(this)) + .subscribe(([view]) => { + if (view !== null) { + this.repaintHeatmapColors(view); + } + }); + + // Restore default fills when the overlay is turned off, and toggle the .heatmap-active + // class that scopes the body-fill transition, so the animation only applies while the + // overlay is on (not to the disable/enable or validation fill paths). + this.wrapper + .getHeatmapViewStream() + .pipe(untilDestroyed(this)) + .subscribe(view => { + this.editor.classList.toggle("heatmap-active", view !== null); + if (view === null) { + this.restoreAllOperatorFills(); + this.heatmapTooltip = null; + } + }); + + // Paint newly (re)added operators when the overlay is active (e.g. after reload). + this.workflowActionService + .getTexeraGraph() + .getOperatorAddStream() + .pipe(untilDestroyed(this)) + .subscribe(() => { + const view = this.wrapper.getHeatmapView(); + if (view !== null) { + this.repaintHeatmapColors(view); + } + }); + + // Enabling/disabling an operator rewrites its body fill via changeOperatorDisableStatus + // (#FFFFFF / #E0E0E0) — colors this overlay owns. Repaint so the heat-map survives a + // disable/enable while active, even on an otherwise idle workflow. This subscription is + // registered after handleDisableOperator (called earlier in ngAfterViewInit), so the + // repaint runs last and wins the fill. + this.workflowActionService + .getTexeraGraph() + .getDisabledOperatorsChangedStream() + .pipe(untilDestroyed(this)) + .subscribe(() => { + const view = this.wrapper.getHeatmapView(); + if (view !== null) { + this.repaintHeatmapColors(view); + } + }); + } + + private heatmapScores(view: HeatmapView): Record<string, number> { + const metrics = this.workflowStatusService.getCurrentPerformanceMetrics(); + const rawById: Record<string, number> = {}; + for (const operatorId of Object.keys(metrics)) { + const raw = rawMetricForView(metrics[operatorId], view); + // Not measurable for this view: leave the operator out so it gets an + // undefined score (painted as no-data) instead of anchoring the scale + // minimum as the coldest operator. + if (raw !== undefined) { + rawById[operatorId] = raw; + } + } + return normalizeScores(rawById); + } + + private repaintHeatmapColors(view: HeatmapView): void { + const scores = this.heatmapScores(view); + this.workflowActionService + .getTexeraGraph() + .getAllOperators() + .forEach(op => this.jointUIService.applyHeatmapColor(this.paper, op.operatorID, scores[op.operatorID])); + } + + private restoreAllOperatorFills(): void { + this.workflowActionService + .getTexeraGraph() + .getAllOperators() + .forEach(op => this.jointUIService.restoreOperatorFill(this.paper, op)); + } + + /** + * Shows a small tooltip with the hovered operator's metric value and heat score for the active + * view. Only active while the heat-map overlay is on. + */ + private handleHeatmapHover(): void { + fromJointPaperEvent(this.paper, "element:mouseenter") + .pipe(untilDestroyed(this)) + .subscribe(([elementView, evt]) => { + const view = this.wrapper.getHeatmapView(); + if (view === null) { + return; + } + const operatorId = elementView.model.id.toString(); + if (!this.workflowActionService.getTexeraGraph().hasOperator(operatorId)) { + return; + } + const metrics = this.workflowStatusService.getCurrentPerformanceMetrics()[operatorId]; + const score = this.heatmapScores(view)[operatorId]; + const rect = this.editor.getBoundingClientRect(); + const mouseEvent = evt as unknown as MouseEvent; + // "—" marks the absence of a value — missing metrics for both fields, or a + // view that is not measurable for the operator (metricLabel) — so neither is + // confused with a genuine zero value. + this.heatmapTooltip = { + x: mouseEvent.clientX - rect.left + 12, + y: mouseEvent.clientY - rect.top + 12, + title: heatmapViewTitle(view), + metricLabel: metrics ? formatMetricForView(rawMetricForView(metrics, view), view) : "—", + heatLabel: score === undefined ? "—" : `${Math.round(score * 100)}%`, + }; + // JointJS paper events fire outside Angular's zone, so trigger change detection + // for the tooltip to render (mirrors the chat-popover handling). + this.changeDetectorRef.detectChanges(); + }); + + fromJointPaperEvent(this.paper, "element:mouseleave") + .pipe(untilDestroyed(this)) + .subscribe(() => { + // Stay inert when the overlay is off (no tooltip is ever shown then), and skip the + // change-detection pass when there is nothing to clear. + if (this.wrapper.getHeatmapView() === null || this.heatmapTooltip === null) { + return; + } + this.heatmapTooltip = null; + this.changeDetectorRef.detectChanges(); + }); + } + /** * Single source of truth for the operator's border color. Both the * validation stream and the operator-add stream route through here so diff --git a/frontend/src/app/workspace/service/heatmap/heatmap-color.spec.ts b/frontend/src/app/workspace/service/heatmap/heatmap-color.spec.ts new file mode 100644 index 0000000000..41345e068b --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-color.spec.ts @@ -0,0 +1,64 @@ +/** + * 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 { HEATMAP_NO_DATA_COLOR, scoreToColor } from "./heatmap-color"; + +describe("scoreToColor", () => { + it("maps 0 to the cold stop (blue)", () => { + expect(scoreToColor(0)).toBe("#5b9bd5"); + }); + + it("maps 0.5 to the mid stop (pale yellow)", () => { + expect(scoreToColor(0.5)).toBe("#ffffbf"); + }); + + it("maps 1 to the hot stop (red)", () => { + expect(scoreToColor(1)).toBe("#e05a52"); + }); + + it("clamps values below 0 to the cold stop", () => { + expect(scoreToColor(-0.5)).toBe(scoreToColor(0)); + }); + + it("clamps values above 1 to the hot stop", () => { + expect(scoreToColor(2)).toBe(scoreToColor(1)); + }); + + it("maps non-finite scores to the ramp endpoints (NaN -> cold, Infinity -> hot)", () => { + expect(scoreToColor(Number.NaN)).toBe(scoreToColor(0)); + expect(scoreToColor(Number.POSITIVE_INFINITY)).toBe(scoreToColor(1)); + }); + + it("returns a valid hex color for interpolated values", () => { + for (const score of [0.1, 0.25, 0.5, 0.75, 0.9]) { + expect(scoreToColor(score)).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it("interpolates between stops rather than snapping to an endpoint", () => { + const mid = scoreToColor(0.25); + expect(mid).not.toBe(scoreToColor(0)); + expect(mid).not.toBe(scoreToColor(0.5)); + }); + + it("exposes a distinct neutral no-data color", () => { + expect(HEATMAP_NO_DATA_COLOR).toMatch(/^#[0-9a-f]{6}$/); + expect(HEATMAP_NO_DATA_COLOR).not.toBe(scoreToColor(0)); + }); +}); diff --git a/frontend/src/app/workspace/service/heatmap/heatmap-color.ts b/frontend/src/app/workspace/service/heatmap/heatmap-color.ts new file mode 100644 index 0000000000..726a1d0cd2 --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-color.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +type Rgb = readonly [number, number, number]; + +/** + * Cold -> hot ramp (ColorBrewer RdYlBu, reversed): blue -> pale yellow -> red. + * Chosen because it is colorblind-safer than a rainbow ramp. + */ +const COLD: Rgb = [91, 155, 213]; // #5b9bd5 — bright blue (still light enough for readable labels) +const MID: Rgb = [255, 255, 191]; // #ffffbf — pale yellow +const HOT: Rgb = [224, 90, 82]; // #e05a52 — bright red + +/** + * Neutral fill for an operator with no score — either no metrics captured yet, or the active + * view is not measurable for it. + */ +export const HEATMAP_NO_DATA_COLOR = "#eeeeee"; + +function clamp01(value: number): number { + // Guard NaN (which would otherwise produce "#NaNNaNNaN"); it maps to 0 (cold). + // Infinity still clamps to 1 (hot) via the min/max below. + const safe = Number.isNaN(value) ? 0 : value; + return Math.min(1, Math.max(0, safe)); +} + +function lerpChannel(from: number, to: number, t: number): number { + return Math.round(from + (to - from) * t); +} + +function toHex(channel: number): string { + return channel.toString(16).padStart(2, "0"); +} + +function mix(from: Rgb, to: Rgb, t: number): string { + const r = lerpChannel(from[0], to[0], t); + const g = lerpChannel(from[1], to[1], t); + const b = lerpChannel(from[2], to[2], t); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** + * Map a normalized [0, 1] heat score to a hex color on the cold -> hot ramp. + * Values outside [0, 1] are clamped. 0 -> cold (blue), 0.5 -> pale yellow, + * 1 -> hot (red). + */ +export function scoreToColor(score: number): string { + const t = clamp01(score); + return t <= 0.5 ? mix(COLD, MID, t / 0.5) : mix(MID, HOT, (t - 0.5) / 0.5); +} diff --git a/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts new file mode 100644 index 0000000000..18ecd41047 --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts @@ -0,0 +1,192 @@ +/** + * 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 { + formatMetricForView, + HeatmapView, + heatmapViewTitle, + normalizeScores, + rawMetricForView, +} from "./heatmap-scoring"; +import { OperatorPerformanceMetrics } from "../workflow-status/performance-metrics"; + +function makeMetrics(overrides: Partial<OperatorPerformanceMetrics>): OperatorPerformanceMetrics { + return { + dataProcessingTimeNs: 0, + controlProcessingTimeNs: 0, + idleTimeNs: 0, + inputRows: 0, + outputRows: 0, + inputSize: 0, + outputSize: 0, + numWorkers: 1, + ...overrides, + }; +} + +describe("rawMetricForView", () => { + it("Runtime sums data and control processing time", () => { + const m = makeMetrics({ dataProcessingTimeNs: 5_000_000, controlProcessingTimeNs: 2_000_000 }); + expect(rawMetricForView(m, HeatmapView.Runtime)).toBe(7_000_000); + }); + + it("Time-per-row returns seconds per output tuple (slow producers are hotter)", () => { + // 2s of processing over 4 rows -> 0.5s per tuple + const m = makeMetrics({ dataProcessingTimeNs: 2_000_000_000, outputRows: 4 }); + expect(rawMetricForView(m, HeatmapView.TimePerRow)).toBe(0.5); + }); + + it("Time-per-row is not measurable (undefined) before the operator emits a row", () => { + // A blocking operator mid-run has burned time but produced nothing; folding + // this into 0 would paint it coldest and anchor the scale minimum. + const m = makeMetrics({ dataProcessingTimeNs: 2_000_000_000, outputRows: 0 }); + expect(rawMetricForView(m, HeatmapView.TimePerRow)).toBeUndefined(); + }); + + it("Time-per-row returns 0 when there is no processing time (infinitely fast -> cold)", () => { + const m = makeMetrics({ dataProcessingTimeNs: 0, controlProcessingTimeNs: 0, outputRows: 10 }); + expect(rawMetricForView(m, HeatmapView.TimePerRow)).toBe(0); + }); + + it("IoImbalance scores a row-dropping operator (out < in)", () => { + // |250 - 1000| / (250 + 1000) = 0.6 + const m = makeMetrics({ inputRows: 1_000, outputRows: 250 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(0.6); + }); + + it("IoImbalance scores an amplifying operator (out > in)", () => { + // |300 - 100| / (300 + 100) = 0.5 + const m = makeMetrics({ inputRows: 100, outputRows: 300 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(0.5); + }); + + it("IoImbalance scores a total drop (out = 0) as maximally imbalanced", () => { + const m = makeMetrics({ inputRows: 1_000, outputRows: 0 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(1); + }); + + it("IoImbalance stays within [0, 1] even for an extreme amplifier", () => { + const m = makeMetrics({ inputRows: 1, outputRows: 1_000_000 }); + const score = rawMetricForView(m, HeatmapView.IoImbalance); + expect(score).toBeGreaterThan(0.99); + expect(score).toBeLessThanOrEqual(1); + }); + + it("IoImbalance is 0 for a balanced operator (out == in)", () => { + const m = makeMetrics({ inputRows: 1_000, outputRows: 1_000 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(0); + }); + + it("IoImbalance is not measurable (undefined) without input rows", () => { + // Sources (and operators that have consumed nothing yet) have no in/out + // ratio; 0 would collide with a genuinely balanced operator. + const source = makeMetrics({ inputRows: 0, outputRows: 250 }); + expect(rawMetricForView(source, HeatmapView.IoImbalance)).toBeUndefined(); + + const idle = makeMetrics({ inputRows: 0, outputRows: 0 }); + expect(rawMetricForView(idle, HeatmapView.IoImbalance)).toBeUndefined(); + }); +}); + +describe("normalizeScores", () => { + it("returns an empty object for empty input", () => { + expect(normalizeScores({})).toEqual({}); + }); + + it("scores a single operator that did work as 1", () => { + expect(normalizeScores({ a: 42 })).toEqual({ a: 1 }); + }); + + it("scores a single operator that did no work as 0.5", () => { + expect(normalizeScores({ a: 0 })).toEqual({ a: 0.5 }); + }); + + it("scores all-equal values as 0.5 (avoids divide-by-zero)", () => { + expect(normalizeScores({ a: 5, b: 5, c: 5 })).toEqual({ a: 0.5, b: 0.5, c: 0.5 }); + }); + + it("scores all-zero values as 0.5", () => { + expect(normalizeScores({ a: 0, b: 0 })).toEqual({ a: 0.5, b: 0.5 }); + }); + + it("maps the min to 0 and the max to 1 for two distinct values", () => { + const scores = normalizeScores({ low: 1, high: 100 }); + expect(scores["low"]).toBe(0); + expect(scores["high"]).toBe(1); + }); + + it("keeps all scores within [0, 1]", () => { + const scores = normalizeScores({ a: 3, b: 50, c: 900, d: 12 }); + for (const s of Object.values(scores)) { + expect(s).toBeGreaterThanOrEqual(0); + expect(s).toBeLessThanOrEqual(1); + } + }); + + it("compresses heavy-tailed values so the middle is not flattened to ~0", () => { + // Linear min-max would map 100 to ~0.1; log scaling lifts it above 0.5. + const scores = normalizeScores({ small: 1, mid: 100, big: 1000 }); + expect(scores["small"]).toBe(0); + expect(scores["big"]).toBe(1); + expect(scores["mid"]).toBeGreaterThan(0.5); + }); +}); + +describe("formatMetricForView", () => { + it("formats Runtime nanoseconds as a human duration", () => { + expect(formatMetricForView(8_620_000_000, HeatmapView.Runtime)).toBe("8.62 s"); + expect(formatMetricForView(5_000_000, HeatmapView.Runtime)).toBe("5 ms"); + expect(formatMetricForView(2_000, HeatmapView.Runtime)).toBe("2 µs"); + expect(formatMetricForView(500, HeatmapView.Runtime)).toBe("500 ns"); + }); + + it("formats Time-per-row as time-per-row", () => { + expect(formatMetricForView(2, HeatmapView.TimePerRow)).toBe("2.00 s/row"); + expect(formatMetricForView(0.0015, HeatmapView.TimePerRow)).toBe("1.5 ms/row"); + expect(formatMetricForView(0.0005, HeatmapView.TimePerRow)).toBe("500 µs/row"); + }); + + it("formats I/O imbalance as a 2-decimal ratio", () => { + expect(formatMetricForView(0.75, HeatmapView.IoImbalance)).toBe("0.75"); + expect(formatMetricForView(4, HeatmapView.IoImbalance)).toBe("4.00"); + }); + + it("renders 0 for non-positive or non-finite values", () => { + expect(formatMetricForView(0, HeatmapView.Runtime)).toBe("0"); + expect(formatMetricForView(Number.NaN, HeatmapView.TimePerRow)).toBe("0"); + expect(formatMetricForView(Number.POSITIVE_INFINITY, HeatmapView.IoImbalance)).toBe("0"); + }); + + it("renders a not-measurable (undefined) value as — so it is distinct from a genuine zero", () => { + expect(formatMetricForView(undefined, HeatmapView.TimePerRow)).toBe("—"); + expect(formatMetricForView(undefined, HeatmapView.IoImbalance)).toBe("—"); + }); +}); + +describe("heatmapViewTitle", () => { + it("returns a human-readable title for each view", () => { + expect(heatmapViewTitle(HeatmapView.Runtime)).toBe("Runtime"); + expect(heatmapViewTitle(HeatmapView.TimePerRow)).toBe("Time / row"); + expect(heatmapViewTitle(HeatmapView.IoImbalance)).toBe("I/O imbalance"); + }); + + it("falls back to a generic title for an unexpected value", () => { + expect(heatmapViewTitle("bogus" as HeatmapView)).toBe("Performance"); + }); +}); diff --git a/frontend/src/app/workspace/service/heatmap/heatmap-scoring.ts b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.ts new file mode 100644 index 0000000000..8a62c68032 --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.ts @@ -0,0 +1,176 @@ +/** + * 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 { OperatorPerformanceMetrics } from "../workflow-status/performance-metrics"; + +/** + * The three heat-map views. Each answers a different "where should I look?" + * question; see {@link rawMetricForView} for the per-operator cost each uses. + * String-valued so the selection serializes readably (e.g. to localStorage). + */ +export enum HeatmapView { + Runtime = "runtime", + // Seconds per output row — the reciprocal of throughput, so it grows (hotter) as throughput + // falls. Named directionally rather than "throughput" to match how the ramp reads. + TimePerRow = "time-per-row", + IoImbalance = "io-imbalance", +} + +/** + * Per-operator raw cost for a view, BEFORE normalization. Higher = hotter (more + * worth a look). + * + * Returns undefined when the view's metric is NOT MEASURABLE for the operator, + * as opposed to measured-as-zero. Callers must exclude undefined from + * normalization and the legend range and render it as no-data; folding it into + * 0 would paint the operator coldest and anchor the scale minimum — e.g. a + * blocking operator mid-run (rows consumed, none emitted yet) would read as + * the fastest thing on the canvas. + * + * - Runtime: data + control processing time — slower operators are hotter. + * Always measurable (0 = genuinely idle). + * - TimePerRow: seconds per output tuple — slow producers (low throughput) are + * hotter; undefined until the operator has emitted a row. + * - IoImbalance: |out - in| / (out + in) — operators that drop OR amplify rows + * are hotter; a balanced operator -> 0 (cold); undefined without + * input rows (sources, or nothing consumed yet). Bounded to + * [0, 1] so an extreme amplifier can't dominate the scale. + */ +export function rawMetricForView(metrics: OperatorPerformanceMetrics, view: HeatmapView): number | undefined { + switch (view) { + case HeatmapView.Runtime: + return metrics.dataProcessingTimeNs + metrics.controlProcessingTimeNs; + case HeatmapView.TimePerRow: { + if (metrics.outputRows === 0) { + return undefined; + } + const timeSec = (metrics.dataProcessingTimeNs + metrics.controlProcessingTimeNs) / 1e9; + return timeSec / metrics.outputRows; + } + case HeatmapView.IoImbalance: + return metrics.inputRows > 0 + ? Math.abs(metrics.outputRows - metrics.inputRows) / (metrics.outputRows + metrics.inputRows) + : undefined; + default: + return undefined; + } +} + +/** Human-readable title for a view, shown in the legend and hover tooltip. */ +export function heatmapViewTitle(view: HeatmapView): string { + switch (view) { + case HeatmapView.Runtime: + return "Runtime"; + case HeatmapView.TimePerRow: + return "Time / row"; + case HeatmapView.IoImbalance: + return "I/O imbalance"; + default: + // Total function: guards against an unexpected (e.g. persisted) value slipping through. + return "Performance"; + } +} + +function formatNanos(ns: number): string { + if (ns >= 1e9) return `${(ns / 1e9).toFixed(2)} s`; + if (ns >= 1e6) return `${(ns / 1e6).toFixed(0)} ms`; + if (ns >= 1e3) return `${(ns / 1e3).toFixed(0)} µs`; + return `${Math.round(ns)} ns`; +} + +function formatSeconds(seconds: number): string { + if (seconds >= 1) return `${seconds.toFixed(2)} s`; + if (seconds >= 1e-3) return `${(seconds * 1e3).toFixed(1)} ms`; + return `${(seconds * 1e6).toFixed(0)} µs`; +} + +/** + * Human-readable label for a raw view metric, used by the legend to show the actual value range + * behind the color scale and by the hover tooltip. Units match each view: Runtime is a duration, + * Time/row is seconds per row, I/O imbalance is a unitless ratio. An undefined value (the view is + * not measurable for the operator, see {@link rawMetricForView}) renders as "—" so it cannot be + * confused with a genuine zero. + */ +export function formatMetricForView(value: number | undefined, view: HeatmapView): string { + if (value === undefined) { + return "—"; + } + if (!Number.isFinite(value) || value <= 0) { + return "0"; + } + switch (view) { + case HeatmapView.Runtime: + return formatNanos(value); + case HeatmapView.TimePerRow: + return `${formatSeconds(value)}/row`; + case HeatmapView.IoImbalance: + return value.toFixed(2); + default: + return String(value); + } +} + +/** + * Normalize per-operator raw costs into [0, 1] heat scores. + * + * Uses log1p compression then min-max across operators, so a single dominant + * operator does not flatten everyone else toward 0. Rules: + * - empty input -> {} + * - single operator -> 1 if it did measurable work, else 0.5 (neutral) + * - all values equal -> 0.5 for everyone (no spread to show; avoids /0) + * - otherwise -> min maps to 0, max maps to 1, rest interpolated + */ +export function normalizeScores(rawById: Record<string, number>): Record<string, number> { + const ids = Object.keys(rawById); + if (ids.length === 0) { + return {}; + } + + // Log-compress every raw cost so a heavy tail doesn't flatten everyone else. + const compressed: Record<string, number> = {}; + for (const id of ids) { + compressed[id] = Math.log1p(rawById[id]); + } + + // A single operator is trivially the hottest, unless it did no measurable work. + if (ids.length === 1) { + return { [ids[0]]: compressed[ids[0]] > 0 ? 1 : 0.5 }; + } + + const values = Object.values(compressed); + const min = Math.min(...values); + const max = Math.max(...values); + + const scores: Record<string, number> = {}; + + // Everything equal (covers all-zero): no spread to show, and avoids /0. + if (max === min) { + for (const id of ids) { + scores[id] = 0.5; + } + return scores; + } + + // Linear min-max into [0, 1]. + const range = max - min; + for (const id of ids) { + scores[id] = (compressed[id] - min) / range; + } + return scores; +} diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts index c383e0963d..39e294b27d 100644 --- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts +++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts @@ -23,6 +23,7 @@ import { JointUIService, operatorNameClass, operatorStateClass, operatorPortMetr import { CommentBox, OperatorPredicate } from "../../types/workflow-common.interface"; import { OperatorState } from "../../types/execute-workflow.interface"; import { Coeditor } from "../../../common/type/user"; +import { HEATMAP_NO_DATA_COLOR, scoreToColor } from "../heatmap/heatmap-color"; // Minimal mock of OperatorMetadataService — the constructor subscribes to // getOperatorMetadata() but the schemas list isn't needed for the methods @@ -631,6 +632,46 @@ describe("JointUIService", () => { }); }); + describe("applyHeatmapColor", () => { + it("paints the body fill with the ramp color for a given score", () => { + const { paper, attrSpy } = makePaperWithModel(); + const service = new JointUIService(emptyMetadataStub as never); + service.applyHeatmapColor(paper, "op-1", 1); + expect(attrSpy).toHaveBeenCalledWith("rect.body/fill", scoreToColor(1)); + }); + it("paints the neutral no-data color when the score is undefined", () => { + const { paper, attrSpy } = makePaperWithModel(); + const service = new JointUIService(emptyMetadataStub as never); + service.applyHeatmapColor(paper, "op-1", undefined); + expect(attrSpy).toHaveBeenCalledWith("rect.body/fill", HEATMAP_NO_DATA_COLOR); + }); + it("no-ops when the model is missing", () => { + const paper = { getModelById: vi.fn(() => null) } as unknown as joint.dia.Paper; + const service = new JointUIService(emptyMetadataStub as never); + expect(() => service.applyHeatmapColor(paper, "missing-op", 0.5)).not.toThrow(); + }); + }); + + describe("restoreOperatorFill", () => { + it("restores the default white fill for an enabled operator", () => { + const { paper, attrSpy } = makePaperWithModel(); + const service = new JointUIService(emptyMetadataStub as never); + service.restoreOperatorFill(paper, { operatorID: "op-1" } as OperatorPredicate); + expect(attrSpy).toHaveBeenCalledWith("rect.body/fill", "#FFFFFF"); + }); + it("restores the disabled grey fill for a disabled operator", () => { + const { paper, attrSpy } = makePaperWithModel(); + const service = new JointUIService(emptyMetadataStub as never); + service.restoreOperatorFill(paper, { operatorID: "op-1", isDisabled: true } as OperatorPredicate); + expect(attrSpy).toHaveBeenCalledWith("rect.body/fill", "#E0E0E0"); + }); + it("no-ops when the model is missing", () => { + const paper = { getModelById: vi.fn(() => null) } as unknown as joint.dia.Paper; + const service = new JointUIService(emptyMetadataStub as never); + expect(() => service.restoreOperatorFill(paper, { operatorID: "missing-op" } as OperatorPredicate)).not.toThrow(); + }); + }); + describe("changeOperatorViewResultStatus", () => { it("writes the view-result asset path when viewResult is true", () => { const { paper, attrSpy } = makePaperWithModel(); diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts index bb6fbff0f0..3afbccde87 100644 --- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts +++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts @@ -26,6 +26,7 @@ import * as joint from "jointjs"; import { fromEventPattern, Observable } from "rxjs"; import { Coeditor } from "../../../common/type/user"; import { OperatorResultCacheStatus } from "../../types/workflow-websocket.interface"; +import { HEATMAP_NO_DATA_COLOR, scoreToColor } from "../heatmap/heatmap-color"; /** * Defines the SVG path for the delete button @@ -515,6 +516,26 @@ export class JointUIService { jointPaper.getModelById(operator.operatorID).attr("rect.body/fill", JointUIService.getOperatorFillColor(operator)); } + /** + * Paints an operator's body fill for the performance heat-map overlay. The heat-map owns only + * `rect.body/fill`, so it coexists with the execution-status border (`rect.body/stroke`). + * The method paints a neutral color when `score` is undefined, which means no heat is known + * for the operator — either no metrics captured yet, or the active view is not measurable + * for it. + */ + public applyHeatmapColor(jointPaper: joint.dia.Paper, operatorID: string, score: number | undefined): void { + const fill = score === undefined ? HEATMAP_NO_DATA_COLOR : scoreToColor(score); + jointPaper.getModelById(operatorID)?.attr("rect.body/fill", fill); + } + + /** + * Restores an operator's default body fill (used when the heat-map overlay is turned off), + * reusing the same source as the normal enabled/disabled coloring. + */ + public restoreOperatorFill(jointPaper: joint.dia.Paper, operator: OperatorPredicate): void { + jointPaper.getModelById(operator.operatorID)?.attr("rect.body/fill", JointUIService.getOperatorFillColor(operator)); + } + public changeOperatorViewResultStatus( jointPaper: joint.dia.Paper, operator: OperatorPredicate, diff --git a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts index b7a2bf05b5..66ddc42cdd 100644 --- a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts +++ b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts @@ -26,6 +26,7 @@ import * as graphlib from "graphlib"; import { ObservableContextManager } from "src/app/common/util/context"; import { Coeditor, User } from "../../../../common/type/user"; import { operatorCoeditorChangedPropertyClass, operatorCoeditorEditingClass } from "../../joint-ui/joint-ui.service"; +import { HeatmapView } from "../../heatmap/heatmap-scoring"; import { dia } from "jointjs/types/joint"; import * as _ from "lodash"; import Selectors = dia.Cell.Selectors; @@ -108,6 +109,11 @@ export class JointGraphWrapper { // reapply it to the shared model (covering both the main canvas and the mini-map). private regionsDisplayedStream = new BehaviorSubject<boolean>(false); + // The active performance heat-map view, or null when the overlay is off (Layers > Performance). + // Kept here so the editor can (re)apply operator colors on the shared model, covering both the + // main canvas and the mini-map. + private heatmapViewStream = new BehaviorSubject<HeatmapView | null>(null); + private elementPositions: Map<string, PositionInfo> = new Map<string, PositionInfo>(); private listenPositionChange: boolean = true; @@ -229,6 +235,21 @@ export class JointGraphWrapper { return this.regionsDisplayedStream.asObservable(); } + /** + * Sets the active performance heat-map view, or null to turn the overlay off. + */ + public setHeatmapView(view: HeatmapView | null): void { + this.heatmapViewStream.next(view); + } + + public getHeatmapView(): HeatmapView | null { + return this.heatmapViewStream.value; + } + + public getHeatmapViewStream(): Observable<HeatmapView | null> { + return this.heatmapViewStream.asObservable(); + } + /** * This method is used to toggle the multiselect mode. * @param multiSelect
