This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 70c2114588 feat(frontend): add toggleable performance heat-map overlay
(#6213)
70c2114588 is described below
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