From 770c91291c0ee52c2dfe934c19aaf5d361d3001e Mon Sep 17 00:00:00 2001 From: PG1204 Date: Wed, 1 Jul 2026 17:25:14 -0700 Subject: [PATCH 1/4] feat(frontend): add heat-map scoring and color helpers --- .../service/heatmap/heatmap-color.spec.ts | 59 +++++++++ .../service/heatmap/heatmap-color.ts | 60 +++++++++ .../service/heatmap/heatmap-scoring.spec.ts | 122 ++++++++++++++++++ .../service/heatmap/heatmap-scoring.ts | 105 +++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 frontend/src/app/workspace/service/heatmap/heatmap-color.spec.ts create mode 100644 frontend/src/app/workspace/service/heatmap/heatmap-color.ts create mode 100644 frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts create mode 100644 frontend/src/app/workspace/service/heatmap/heatmap-scoring.ts 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..411f7f8754b --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-color.spec.ts @@ -0,0 +1,59 @@ +/** + * 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("#2c7bb6"); + }); + + 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("#d7191c"); + }); + + 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("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..19ea7bbb44a --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-color.ts @@ -0,0 +1,60 @@ +/** + * 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 = [44, 123, 182]; // #2c7bb6 +const MID: Rgb = [255, 255, 191]; // #ffffbf +const HOT: Rgb = [215, 25, 28]; // #d7191c + +/** 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 { + return Math.min(1, Math.max(0, value)); +} + +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..77692bbd5e5 --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.spec.ts @@ -0,0 +1,122 @@ +/** + * 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 { HeatmapView, 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)", () => { + const m = makeMetrics({ inputRows: 1_000, outputRows: 250 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(0.75); + }); + + it("IoImbalance scores an amplifying operator (out > in)", () => { + const m = makeMetrics({ inputRows: 100, outputRows: 500 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(4); + }); + + 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 }); + expect(rawMetricForView(m, HeatmapView.IoImbalance)).toBe(0); + }); +}); + +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); + }); +}); 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..a912d95da9f --- /dev/null +++ b/frontend/src/app/workspace/service/heatmap/heatmap-scoring.ts @@ -0,0 +1,105 @@ +/** + * 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: |1 - out/in| — operators that drop OR amplify rows are hotter; + * a balanced operator or missing input -> 0 (cold). + */ +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(1 - metrics.outputRows / metrics.inputRows) : 0; + default: + return 0; + } +} + +/** + * 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; +} From 1b8943f9062776b5dec9fb924f5371efb777315f Mon Sep 17 00:00:00 2001 From: PG1204 Date: Wed, 1 Jul 2026 21:17:22 -0700 Subject: [PATCH 2/4] feat(frontend): refine heat-map legend, tooltip, colors, and view sub-grouping --- .../heatmap-legend.component.html | 29 ++++ .../heatmap-legend.component.scss | 54 ++++++++ .../heatmap-legend.component.ts | 79 +++++++++++ .../component/menu/menu.component.html | 33 +++++ .../component/menu/menu.component.scss | 20 +++ .../component/menu/menu.component.ts | 20 +++ .../workflow-editor.component.html | 10 ++ .../workflow-editor.component.scss | 29 ++++ .../workflow-editor.component.ts | 126 +++++++++++++++++- .../service/heatmap/heatmap-color.spec.ts | 4 +- .../service/heatmap/heatmap-color.ts | 6 +- .../service/heatmap/heatmap-scoring.spec.ts | 42 +++++- .../service/heatmap/heatmap-scoring.ts | 46 +++++++ .../service/joint-ui/joint-ui.service.spec.ts | 41 ++++++ .../service/joint-ui/joint-ui.service.ts | 19 +++ .../model/joint-graph-wrapper.ts | 21 +++ 16 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.html create mode 100644 frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.scss create mode 100644 frontend/src/app/workspace/component/heatmap-legend/heatmap-legend.component.ts 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..72dc7726ea2 --- /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 +
  • + +
  • +
  • + + + + + +