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 00000000000..b086fddf549
--- /dev/null
+++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.html
@@ -0,0 +1,29 @@
+
+
+
+
{{ legend.title }}
+
+
+ {{ legend.minLabel }}
+ {{ legend.maxLabel }}
+
+
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 00000000000..25f7c339c2a
--- /dev/null
+++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.scss
@@ -0,0 +1,54 @@
+/**
+ * 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, lifted above the mini-map preview toggle in the corner. The mini-map
+ // itself occupies the bottom-right of the canvas.
+ 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;
+ // Mirrors the cold -> hot stops in heatmap-color.ts (scoreToColor).
+ background: linear-gradient(to right, #5b9bd5, #ffffbf, #e05a52);
+ }
+
+ &__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.ts b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.ts
new file mode 100644
index 00000000000..ef8448bc8f5
--- /dev/null
+++ b/frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.ts
@@ -0,0 +1,79 @@
+/**
+ * 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";
+
+interface HeatmapLegendState {
+ readonly view: HeatmapView;
+ 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;
+
+ 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): HeatmapLegendState {
+ const raws = Object.values(metrics)
+ .map(m => rawMetricForView(m, view))
+ .filter(v => Number.isFinite(v));
+ const hasData = raws.length > 0;
+ const min = hasData ? Math.min(...raws) : 0;
+ const max = hasData ? Math.max(...raws) : 0;
+ return {
+ view,
+ 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 85a856eaba8..ec189470275 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -197,6 +197,39 @@
>Status
+
+ Performance
+
+
+
+ Runtime
+ Throughput
+ I/O imbalance
+
+
{
});
});
+ 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.Throughput;
+ component.toggleHeatmap();
+
+ expect(setSpy).toHaveBeenCalledWith(HeatmapView.Throughput);
+ });
+
+ 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 a205b2b8bbf..e79e3250135 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -31,6 +31,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, filter, mergeMap, switchMap, tap } from "rxjs/operators";
@@ -70,6 +71,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";
@@ -114,6 +116,8 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
NzMenuDirective,
NzMenuItemComponent,
NzCheckboxComponent,
+ NzRadioComponent,
+ NzRadioGroupComponent,
NgTemplateOutlet,
ComputingUnitSelectionComponent,
NzPopoverDirective,
@@ -138,6 +142,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;
@@ -539,6 +546,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 ed0b7cc748b..0b652d1a76c 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 @@
+
+
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 482dac56a26..9812a3ca037 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,41 @@
#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 (e.g. heat-map recolor on view switch / toggle).
+::ng-deep #workflow-editor .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.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 230b867c11e..d551e1f96ea 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";
// jointjs interactive options for enabling and disabling interactivity
// https://resources.jointjs.com/docs/jointjs/v3.2/joint.html#dia.Paper.prototype.options.interactive
@@ -88,12 +96,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 = new Subject();
private wrapper;
@@ -188,6 +211,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
this.handlePortHighlightEvent();
this.registerPortDisplayNameChangeHandler();
this.handleOperatorStatisticsUpdate();
+ this.handleHeatmapOverlay();
+ this.handleHeatmapHover();
this.handleRegionEvents();
this.handleOperatorSuggestionHighlightEvent();
this.handleAgentHoverHighlight();
@@ -386,6 +411,111 @@ 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.
+ this.wrapper
+ .getHeatmapViewStream()
+ .pipe(untilDestroyed(this))
+ .subscribe(view => {
+ 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);
+ }
+ });
+ }
+
+ private heatmapScores(view: HeatmapView): Record {
+ const metrics = this.workflowStatusService.getCurrentPerformanceMetrics();
+ const rawById: Record = {};
+ for (const operatorId of Object.keys(metrics)) {
+ rawById[operatorId] = rawMetricForView(metrics[operatorId], view);
+ }
+ 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 rawValue = metrics ? rawMetricForView(metrics, view) : 0;
+ const score = this.heatmapScores(view)[operatorId];
+ const rect = this.editor.getBoundingClientRect();
+ const mouseEvent = evt as unknown as MouseEvent;
+ this.heatmapTooltip = {
+ x: mouseEvent.clientX - rect.left + 12,
+ y: mouseEvent.clientY - rect.top + 12,
+ title: heatmapViewTitle(view),
+ metricLabel: formatMetricForView(rawValue, 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(() => {
+ 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 00000000000..9f32daabf44
--- /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("treats a non-finite score as cold rather than emitting an invalid color", () => {
+ 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 00000000000..d5ab6cebd4e
--- /dev/null
+++ b/frontend/src/app/workspace/service/heatmap/heatmap-color.ts
@@ -0,0 +1,63 @@
+/**
+ * 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 that has no score (no metrics captured yet). */
+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 00000000000..a869b7f560e
--- /dev/null
+++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts
@@ -0,0 +1,178 @@
+/**
+ * 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 {
+ 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("Throughput 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.Throughput)).toBe(0.5);
+ });
+
+ it("Throughput returns 0 when there is no output (cold)", () => {
+ const m = makeMetrics({ dataProcessingTimeNs: 2_000_000_000, outputRows: 0 });
+ expect(rawMetricForView(m, HeatmapView.Throughput)).toBe(0);
+ });
+
+ it("Throughput returns 0 when there is no processing time (infinitely fast -> cold)", () => {
+ const m = makeMetrics({ dataProcessingTimeNs: 0, controlProcessingTimeNs: 0, outputRows: 10 });
+ expect(rawMetricForView(m, HeatmapView.Throughput)).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 0 when there is no input (cold)", () => {
+ const m = makeMetrics({ inputRows: 0, outputRows: 250 });
+ const score = rawMetricForView(m, HeatmapView.IoImbalance);
+ expect(score).toBe(0);
+ expect(Number.isFinite(score)).toBe(true);
+ });
+});
+
+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 Throughput as time-per-row", () => {
+ expect(formatMetricForView(2, HeatmapView.Throughput)).toBe("2.00 s/row");
+ expect(formatMetricForView(0.0015, HeatmapView.Throughput)).toBe("1.5 ms/row");
+ expect(formatMetricForView(0.0005, HeatmapView.Throughput)).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.Throughput)).toBe("0");
+ expect(formatMetricForView(Number.POSITIVE_INFINITY, HeatmapView.IoImbalance)).toBe("0");
+ });
+});
+
+describe("heatmapViewTitle", () => {
+ it("returns a human-readable title for each view", () => {
+ expect(heatmapViewTitle(HeatmapView.Runtime)).toBe("Runtime");
+ expect(heatmapViewTitle(HeatmapView.Throughput)).toBe("Throughput");
+ expect(heatmapViewTitle(HeatmapView.IoImbalance)).toBe("I/O imbalance");
+ });
+});
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 00000000000..457a0abeb77
--- /dev/null
+++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.ts
@@ -0,0 +1,154 @@
+/**
+ * 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",
+ Throughput = "throughput",
+ IoImbalance = "io-imbalance",
+}
+
+/**
+ * Per-operator raw cost for a view, BEFORE normalization. Higher = hotter (more
+ * worth a look).
+ *
+ * - Runtime: data + control processing time — slower operators are hotter.
+ * - Throughput: seconds per output tuple — slow producers (low throughput) are
+ * hotter; no output -> 0 (cold).
+ * - IoImbalance: |out - in| / (out + in) — operators that drop OR amplify rows are
+ * hotter; a balanced operator or missing input -> 0 (cold). Normalized
+ * to [0, 1] so an extreme amplifier can't dominate the scale.
+ */
+export function rawMetricForView(metrics: OperatorPerformanceMetrics, view: HeatmapView): number {
+ switch (view) {
+ case HeatmapView.Runtime:
+ return metrics.dataProcessingTimeNs + metrics.controlProcessingTimeNs;
+ case HeatmapView.Throughput: {
+ const timeSec = (metrics.dataProcessingTimeNs + metrics.controlProcessingTimeNs) / 1e9;
+ return metrics.outputRows > 0 ? timeSec / metrics.outputRows : 0;
+ }
+ case HeatmapView.IoImbalance:
+ return metrics.inputRows > 0
+ ? Math.abs(metrics.outputRows - metrics.inputRows) / (metrics.outputRows + metrics.inputRows)
+ : 0;
+ default:
+ return 0;
+ }
+}
+
+/** 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.Throughput:
+ return "Throughput";
+ case HeatmapView.IoImbalance:
+ return "I/O imbalance";
+ }
+}
+
+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. Units match each view: Runtime is a duration, Throughput is time-per-row,
+ * I/O imbalance is a unitless ratio.
+ */
+export function formatMetricForView(value: number, view: HeatmapView): string {
+ if (!Number.isFinite(value) || value <= 0) {
+ return "0";
+ }
+ switch (view) {
+ case HeatmapView.Runtime:
+ return formatNanos(value);
+ case HeatmapView.Throughput:
+ 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): Record {
+ 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 = {};
+ 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 = {};
+
+ // 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 87b5acd924e..e63f4e6d7d8 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
@@ -590,6 +591,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 d069a270eb7..231360bd465 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
@@ -502,6 +503,24 @@ 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`).
+ * A `score` of undefined means "no metrics captured yet" and paints a neutral color.
+ */
+ 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 type/disable 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 b7a2bf05b51..66ddc42cdde 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(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(null);
+
private elementPositions: Map = new Map();
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 {
+ return this.heatmapViewStream.asObservable();
+ }
+
/**
* This method is used to toggle the multiselect mode.
* @param multiSelect