mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-24 08:02:27 +00:00
ui: heat map for "highlighted executed lines"
This commit is contained in:
1 parent
b0c9dccf2c
commit
b01acb90b8
6 files changed
+141
-13
No files matched your search
+1
-1
@@ -34,7 +34,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md).
|
||||
- Editor moved from CodeMirror 5 to CodeMirror 6 with Lezer grammars for 6502, Z80, 6809, Verilog, BASIC, batari BASIC, FastBasic, Inform 6 and Wiz; new Cobalt and MBO themes and better indent handling.
|
||||
- Source search across the project and toolchain headers (F1 for help), with arrow-key navigation, plus symbol search without source.
|
||||
- Shortcut bar and keyboard shortcut help, F8 pause/resume, Cmd+Shift+G go to offset.
|
||||
- Settings menu with developer options (highlight whitespace, highlight executed lines, line numbers off by default).
|
||||
- Settings menu with developer options (highlight whitespace, highlight executed lines, line numbers off by default). "Highlight Executed Lines" draws a hot/cold heat map: lines run since the last update are colored by how often they have run (blue = cold, red = hot) and disappear once they stop executing.
|
||||
- Hash routing for windows, and per-page titles.
|
||||
- Mobile layout: tab bar instead of split panes, and a flex toolbar.
|
||||
- Click `#include`/`#link` decorations to open the file; offer to import a GitHub repo given by `repo=` ("user/repo" or a tree URL).
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Shortcut } from "../shortcutbar";
|
||||
import { createTextTransformFilterEffect, textTransformFilterCompartment } from "./filters";
|
||||
import { breakpointMarkers, bytes, clock, currentPcMarker, errorMarkers, ErrorInfo, offset, statusMarkers } from "./gutter";
|
||||
import { currentPc, errorMessages, errorSpans, highlightLines, showValue, tracedLines } from "./visuals";
|
||||
import { computeHeatLevels } from "./traceheat";
|
||||
|
||||
// look ahead this many bytes when finding source lines for a PC
|
||||
export const PC_LINE_LOOKAHEAD = 64;
|
||||
@@ -123,6 +124,10 @@ export class SourceEditor implements ProjectView {
|
||||
currentDebugLine: SourceLocation;
|
||||
refreshDelayMsec = 300;
|
||||
probe: ProbeRecorder = null;
|
||||
// Cumulative executed-line counts for this tracing session. Only lines
|
||||
// executed in the last update are shown, but their color (hot/cold) comes
|
||||
// from these totals so it stays stable from tick to tick.
|
||||
tracedLineCounts = new Map<number, number>();
|
||||
|
||||
createDiv(parent: HTMLElement) {
|
||||
var div = document.createElement('div');
|
||||
@@ -174,6 +179,7 @@ export class SourceEditor implements ProjectView {
|
||||
this.probe = platform.startProbing();
|
||||
this.probe.singleFrame = false; // accumulate between our polls, we clear it ourselves
|
||||
this.probe.clear();
|
||||
this.tracedLineCounts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +188,7 @@ export class SourceEditor implements ProjectView {
|
||||
platform.stopProbing();
|
||||
this.probe = null;
|
||||
}
|
||||
this.tracedLineCounts.clear();
|
||||
this.editor.dispatch({ effects: tracedLines.effect.of([]) });
|
||||
}
|
||||
|
||||
@@ -719,17 +726,22 @@ export class SourceEditor implements ProjectView {
|
||||
updateTracedLines() {
|
||||
const p = this.probe;
|
||||
if (!p || !p.idx || !this.sourcefile) return;
|
||||
const lines = new Set<number>();
|
||||
// Accumulate per-line counts since tracing started, and remember which
|
||||
// lines ran in this update (those are the only ones we'll highlight).
|
||||
const activeLines = new Set<number>();
|
||||
for (let i = 0; i < p.idx; i++) {
|
||||
const word = p.buf[i];
|
||||
if ((word & 0xff000000) === ProbeFlags.EXECUTE) {
|
||||
const loc = this.sourcefile.findLineForOffset(word & 0xffff, PC_LINE_LOOKAHEAD);
|
||||
if (loc) lines.add(loc.line);
|
||||
if (loc) {
|
||||
this.tracedLineCounts.set(loc.line, (this.tracedLineCounts.get(loc.line) || 0) + 1);
|
||||
activeLines.add(loc.line);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.clear();
|
||||
this.editor.dispatch({
|
||||
effects: tracedLines.effect.of(Array.from(lines)),
|
||||
effects: tracedLines.effect.of(computeHeatLevels(this.tracedLineCounts, activeLines)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Map per-line execution counts to hot/cold heat levels for the
|
||||
// "Highlight Executed Lines" overlay.
|
||||
|
||||
export const TRACED_HEAT_LEVELS = 4;
|
||||
|
||||
// Convert one count to a level 0..TRACED_HEAT_LEVELS-1, relative to the
|
||||
// maximum count. Uses a log scale so a few very hot lines don't flatten
|
||||
// everything else into the coldest bucket.
|
||||
export function heatLevel(count: number, max: number): number {
|
||||
if (max <= 1 || count <= 1) return 0;
|
||||
const level = Math.round((Math.log(count) / Math.log(max)) * (TRACED_HEAT_LEVELS - 1));
|
||||
return Math.max(0, Math.min(TRACED_HEAT_LEVELS - 1, level));
|
||||
}
|
||||
|
||||
// Compute hot/cold levels for lines in a set of execution counts.
|
||||
// Levels are normalized against the maximum over ALL counts, so a line's
|
||||
// color stays stable even when it isn't in the current update. If `visible`
|
||||
// is given, only those lines are returned; the caller uses this to hide
|
||||
// lines that haven't run since the last update.
|
||||
export function computeHeatLevels(counts: Map<number, number>, visible?: Set<number>): { line: number, level: number }[] {
|
||||
let max = 0;
|
||||
for (const count of counts.values()) {
|
||||
if (count > max) max = count;
|
||||
}
|
||||
const lines: { line: number, level: number }[] = [];
|
||||
for (const [line, count] of counts) {
|
||||
if (visible && !visible.has(line)) continue;
|
||||
lines.push({ line, level: heatLevel(count, max) });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { errorMarkers } from "./gutter";
|
||||
import { TRACED_HEAT_LEVELS } from "./traceheat";
|
||||
|
||||
import { SelectionRange, StateEffect, StateField } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView, WidgetType } from "@codemirror/view";
|
||||
@@ -263,12 +264,22 @@ export const highlightLines = {
|
||||
field: highlightLinesField,
|
||||
};
|
||||
|
||||
// Highlight lines that were recently executed, per live trace data.
|
||||
const tracedLinesEffect = StateEffect.define<number[]>();
|
||||
// Highlight recently-executed lines, colored by execution frequency (hot/cold).
|
||||
// A line's level is 0 (coldest) .. TRACED_HEAT_LEVELS-1 (hottest).
|
||||
export interface TracedLine {
|
||||
line: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const tracedLineDecoration = Decoration.line({
|
||||
attributes: { class: "cm-traced-line" }
|
||||
});
|
||||
const tracedLinesEffect = StateEffect.define<TracedLine[]>();
|
||||
|
||||
// one line decoration per heat level
|
||||
const tracedLineDecorations = [];
|
||||
for (let i = 0; i < TRACED_HEAT_LEVELS; i++) {
|
||||
tracedLineDecorations.push(Decoration.line({
|
||||
attributes: { class: `cm-traced-line cm-traced-line-h${i}` }
|
||||
}));
|
||||
}
|
||||
|
||||
const tracedLinesField = StateField.define({
|
||||
create() { return Decoration.none },
|
||||
@@ -278,10 +289,11 @@ const tracedLinesField = StateField.define({
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(tracedLinesEffect)) {
|
||||
const ranges: any[] = [];
|
||||
for (const lineNum of e.value) {
|
||||
for (const { line: lineNum, level } of e.value) {
|
||||
try {
|
||||
const line = tr.state.doc.line(lineNum);
|
||||
ranges.push(tracedLineDecoration.range(line.from));
|
||||
const deco = tracedLineDecorations[Math.max(0, Math.min(TRACED_HEAT_LEVELS - 1, level | 0))];
|
||||
ranges.push(deco.range(line.from));
|
||||
} catch {
|
||||
// Line doesn't exist, skip
|
||||
}
|
||||
|
||||
@@ -17,8 +17,19 @@ export const editorTheme = EditorView.theme({
|
||||
".highlight-lines": {
|
||||
backgroundColor: "#003399 !important",
|
||||
},
|
||||
".cm-traced-line": {
|
||||
backgroundColor: "rgba(129, 123, 249, 0.15)",
|
||||
// Executed-line heat map: cold (rarely run) -> hot (frequently run).
|
||||
".cm-traced-line": {},
|
||||
".cm-traced-line-h0": {
|
||||
backgroundColor: "rgba(70, 120, 255, 0.18)",
|
||||
},
|
||||
".cm-traced-line-h1": {
|
||||
backgroundColor: "rgba(80, 210, 170, 0.20)",
|
||||
},
|
||||
".cm-traced-line-h2": {
|
||||
backgroundColor: "rgba(255, 200, 60, 0.22)",
|
||||
},
|
||||
".cm-traced-line-h3": {
|
||||
backgroundColor: "rgba(255, 70, 50, 0.28)",
|
||||
},
|
||||
".cm-error-span": {
|
||||
textDecoration: "underline wavy red",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "assert";
|
||||
import { describe, it } from "mocha";
|
||||
import { computeHeatLevels, heatLevel, TRACED_HEAT_LEVELS } from "../../src/ide/views/traceheat";
|
||||
|
||||
// The "Highlight Executed Lines" overlay colors source lines by how often
|
||||
// they run (cold -> hot). These tests pin down the level mapping so the
|
||||
// color scale doesn't silently change.
|
||||
|
||||
describe("trace heat levels", function () {
|
||||
it("puts an isolated count in the coldest bucket", function () {
|
||||
assert.equal(heatLevel(1, 1), 0); // nothing to compare against
|
||||
assert.equal(heatLevel(1, 1000), 0);
|
||||
assert.equal(heatLevel(2, 1000), 0);
|
||||
});
|
||||
|
||||
it("maps the maximum count to the hottest bucket", function () {
|
||||
assert.equal(heatLevel(1000, 1000), TRACED_HEAT_LEVELS - 1);
|
||||
assert.equal(heatLevel(2, 2), TRACED_HEAT_LEVELS - 1);
|
||||
});
|
||||
|
||||
it("increases monotonically with count", function () {
|
||||
let prev = -1;
|
||||
for (const count of [1, 2, 4, 8, 16, 64, 256, 1024]) {
|
||||
const level = heatLevel(count, 1024);
|
||||
assert.ok(level >= prev, `level for ${count} went backwards`);
|
||||
assert.ok(level >= 0 && level < TRACED_HEAT_LEVELS);
|
||||
prev = level;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps cold lines cold when a few lines are very hot", function () {
|
||||
// one line dominates; a rarely-run line should not be pulled hot
|
||||
assert.ok(heatLevel(1, 100000) < heatLevel(100, 100000));
|
||||
assert.ok(heatLevel(100, 100000) < heatLevel(100000, 100000));
|
||||
});
|
||||
|
||||
it("computes levels for every line", function () {
|
||||
const levels = computeHeatLevels(new Map([[10, 1], [20, 100], [30, 10000]]));
|
||||
const byLine = new Map(levels.map(l => [l.line, l.level]));
|
||||
assert.equal(byLine.size, 3);
|
||||
assert.ok(byLine.get(10) < byLine.get(20));
|
||||
assert.ok(byLine.get(20) < byLine.get(30));
|
||||
assert.equal(byLine.get(30), TRACED_HEAT_LEVELS - 1);
|
||||
});
|
||||
|
||||
it("only returns lines visible in the current update", function () {
|
||||
const counts = new Map([[10, 1], [20, 100], [30, 10000]]);
|
||||
const levels = computeHeatLevels(counts, new Set([30]));
|
||||
assert.deepEqual(levels.map(l => l.line), [30]);
|
||||
// color still comes from the global maximum
|
||||
assert.equal(levels[0].level, TRACED_HEAT_LEVELS - 1);
|
||||
});
|
||||
|
||||
it("returns nothing when no lines are visible", function () {
|
||||
const counts = new Map([[10, 1], [20, 100]]);
|
||||
assert.deepEqual(computeHeatLevels(counts, new Set()), []);
|
||||
});
|
||||
|
||||
it("handles an empty map", function () {
|
||||
assert.deepEqual(computeHeatLevels(new Map()), []);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user