@scope-profiler/plotly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) scope-profiler contributors
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @scope-profiler/plotly
2
+
3
+ Pure, framework-neutral Plotly figure builders for JSON written by
4
+ `scope-profiler export plot-data --format json`. The package does not import
5
+ Plotly; applications choose their own Plotly bundle.
6
+
7
+ ```js
8
+ import Plotly from "plotly.js-dist-min";
9
+ import { buildGanttFigure, renderFigure } from "@scope-profiler/plotly";
10
+
11
+ const payload = await fetch("/figures/gantt_data.json").then((response) => response.json());
12
+ const figure = buildGanttFigure(payload);
13
+ await renderFigure(Plotly, document.querySelector("#gantt"), figure);
14
+ ```
15
+
16
+ Builders accept both the current scope-profiler payloads and versioned payloads
17
+ with `format: "scope-profiler-plot-data"` and `format_version: 1`.
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@scope-profiler/plotly",
3
+ "version": "0.1.0",
4
+ "description": "Framework-neutral Plotly figure builders for scope-profiler plot-data JSON.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/max-models/scope-profiler.git"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.d.ts",
14
+ "default": "./src/index.js"
15
+ }
16
+ },
17
+ "files": ["src", "README.md", "LICENSE.txt"],
18
+ "publishConfig": { "access": "public" },
19
+ "scripts": { "test": "node --test" },
20
+ "keywords": ["scope-profiler", "plotly", "profiling", "gantt", "flamegraph"]
21
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export interface Figure { data: object[]; layout: object }
2
+ export interface PlotlyLike { newPlot(element: Element | string, data: object[], layout: object, config?: object): unknown }
3
+ export interface BuildOptions { colors?: Record<string, string>; filterRegion?: (region: string, row: object) => boolean; layout?: object; metric?: string; xField?: string; ideal?: boolean; rootLabel?: string }
4
+ export function buildGanttFigure(payload: object, options?: BuildOptions): Figure;
5
+ export function buildFlameFigure(payload: object, options?: BuildOptions): Figure;
6
+ export function buildDurationsFigure(payload: object, options?: BuildOptions): Figure;
7
+ export function buildSpeedupFigure(payload: object, options?: BuildOptions): Figure;
8
+ export function renderFigure(plotly: PlotlyLike, element: Element | string, figure: Figure, config?: object): unknown;
package/src/index.js ADDED
@@ -0,0 +1,104 @@
1
+ /** Framework-neutral Plotly specifications for scope-profiler plot-data. */
2
+
3
+ const DEFAULT_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"];
4
+ const NEUTRAL = "#898781";
5
+
6
+ function colorMap(names, supplied = {}) {
7
+ const map = new Map();
8
+ let index = 0;
9
+ for (const name of names) {
10
+ if (map.has(name)) continue;
11
+ map.set(name, supplied[name] ?? DEFAULT_COLORS[index++ % DEFAULT_COLORS.length]);
12
+ }
13
+ return map;
14
+ }
15
+
16
+ function values(payload, key) {
17
+ if (!payload || !Array.isArray(payload[key])) throw new TypeError(`Expected a scope-profiler plot-data payload with a ${key} array.`);
18
+ return payload[key];
19
+ }
20
+
21
+ function baseLayout(overrides = {}) {
22
+ return {
23
+ paper_bgcolor: "transparent", plot_bgcolor: "transparent", font: { family: "system-ui, sans-serif" },
24
+ margin: { l: 100, r: 24, t: 32, b: 56 }, legend: { orientation: "h" }, ...overrides,
25
+ };
26
+ }
27
+
28
+ function filtered(rows, options) {
29
+ return typeof options?.filterRegion === "function" ? rows.filter((row) => options.filterRegion(row.region, row)) : rows;
30
+ }
31
+
32
+ /** Build a multi-run, multi-rank timeline. Regions are colored; file/rank are lanes. */
33
+ export function buildGanttFigure(payload, options = {}) {
34
+ const intervals = filtered(values(payload, "intervals"), options);
35
+ const regions = [...new Set(intervals.map((row) => row.region))];
36
+ const colors = colorMap(regions, options.colors ?? payload.colors);
37
+ const lanes = [...new Set(intervals.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`))];
38
+ const data = regions.map((region) => {
39
+ const rows = intervals.filter((row) => row.region === region);
40
+ return { type: "bar", orientation: "h", name: region,
41
+ y: rows.map((row) => `${row.file ?? "run"} / rank ${row.rank ?? 0}`),
42
+ x: rows.map((row) => row.end_seconds - row.start_seconds), base: rows.map((row) => row.start_seconds),
43
+ marker: { color: colors.get(region) },
44
+ customdata: rows.map((row) => [row.file, row.rank]),
45
+ hovertemplate: `<b>${region}</b><br>%{customdata[0]} / rank %{customdata[1]}<br>start: %{base:.6g} s<br>duration: %{x:.6g} s<extra></extra>`,
46
+ };
47
+ });
48
+ return { data, layout: baseLayout({ barmode: "overlay", height: Math.max(280, 48 * lanes.length + 150), xaxis: { title: "Time (s)" }, yaxis: { categoryorder: "array", categoryarray: lanes, autorange: "reversed" }, ...options.layout }) };
49
+ }
50
+
51
+ /** Build an icicle flame chart using scope-profiler's explicit call IDs. */
52
+ export function buildFlameFigure(payload, options = {}) {
53
+ const calls = values(payload, "calls");
54
+ const regions = [...new Set(calls.map((call) => call.region))];
55
+ const colors = colorMap(regions, options.colors ?? payload.colors);
56
+ const root = "scope-profiler-root";
57
+ const ids = [root], labels = [options.rootLabel ?? "All calls"], parents = [""], markerColors = [NEUTRAL], hovertext = ["All calls"];
58
+ const roots = calls.filter((call) => call.parent_call_id == null);
59
+ const rootDuration = roots.reduce((sum, call) => sum + (call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds), 0);
60
+ const callKey = (call) => `${call.file ?? "run"}:${call.rank ?? 0}:${call.call_id}`;
61
+ for (const call of calls) {
62
+ ids.push(callKey(call)); labels.push(call.region);
63
+ parents.push(call.parent_call_id == null ? root : `${call.file ?? "run"}:${call.rank ?? 0}:${call.parent_call_id}`);
64
+ markerColors.push(colors.get(call.region));
65
+ hovertext.push(`<b>${call.region}</b><br>${call.file ?? "run"} / rank ${call.rank ?? 0}<br>start: ${call.start_seconds.toPrecision(6)} s<br>inclusive: ${(call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds).toPrecision(6)} s`);
66
+ }
67
+ return { data: [{ type: "icicle", ids, labels, parents, values: [rootDuration, ...calls.map((call) => call.inclusive_duration_seconds ?? call.end_seconds - call.start_seconds)], branchvalues: "total", tiling: { orientation: "h" }, marker: { colors: markerColors }, hovertext, hoverinfo: "text" }], layout: baseLayout({ height: 500, margin: { l: 24, r: 24, t: 24, b: 24 }, ...options.layout }) };
68
+ }
69
+
70
+ export function buildDurationsFigure(payload, options = {}) {
71
+ const metric = options.metric ?? payload.options?.metric ?? payload.metrics?.[0] ?? "total";
72
+ const bars = filtered(values(payload, "bars"), options).filter((bar) => bar.metric === metric);
73
+ // A stacked-children export is already decomposed into segments. Preserve
74
+ // that decomposition instead of letting duplicate region rows overwrite.
75
+ const stacked = bars.some((bar) => bar.segment != null);
76
+ const groups = [...new Set(bars.map((bar) => stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`))];
77
+ const regions = [...new Set(bars.map((bar) => bar.region))];
78
+ const colors = colorMap(groups, options.colors ?? payload.colors);
79
+ const data = groups.map((group) => {
80
+ const rows = bars.filter((bar) => (stacked ? bar.segment : bar.rank == null ? bar.file : `rank ${bar.rank}`) === group);
81
+ const byRegion = new Map(rows.map((bar) => [bar.region, bar.value_seconds]));
82
+ return { type: "bar", name: group, x: regions, y: regions.map((region) => byRegion.get(region) ?? null), marker: { color: colors.get(group) }, hovertemplate: `<b>%{x}</b><br>${group}: %{y:.6g} s<extra></extra>` };
83
+ });
84
+ return { data, layout: baseLayout({ barmode: stacked ? "stack" : "group", height: Math.max(360, 34 * regions.length + 180), xaxis: { tickangle: -35 }, yaxis: { title: `${metric} duration (s)` }, ...options.layout }) };
85
+ }
86
+
87
+ export function buildSpeedupFigure(payload, options = {}) {
88
+ const xField = options.xField ?? payload.options?.x_field ?? "num_ranks";
89
+ const points = filtered(values(payload, "points"), options);
90
+ const regions = [...new Set(points.map((point) => point.region))];
91
+ const colors = colorMap(regions, options.colors ?? payload.colors);
92
+ const xValues = [...new Set(points.map((point) => point[xField]))].sort((a, b) => typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b)));
93
+ const numeric = xValues.every((value) => typeof value === "number");
94
+ const data = regions.map((region) => { const rows = points.filter((point) => point.region === region).sort((a, b) => xValues.indexOf(a[xField]) - xValues.indexOf(b[xField])); return { type: "scatter", mode: "lines+markers", name: region, x: rows.map((row) => row[xField]), y: rows.map((row) => row.speedup), line: { color: colors.get(region) }, marker: { color: colors.get(region) }, hovertemplate: `<b>%{x}</b><br>${region}: %{y:.3g}×<extra></extra>` }; });
95
+ const baseline = payload.options?.baseline ?? xValues[0];
96
+ if (numeric && options.ideal !== false) data.push({ type: "scatter", mode: "lines", name: "Ideal speedup", x: xValues, y: xValues.map((value) => value / baseline), line: { color: "#777", dash: "dash" }, hoverinfo: "skip" });
97
+ return { data, layout: baseLayout({ height: 420, xaxis: { title: payload.options?.x_label ?? xField, tickvals: xValues }, yaxis: { title: "Speedup" }, ...options.layout }) };
98
+ }
99
+
100
+ /** Render a figure with any Plotly-compatible bundle. */
101
+ export function renderFigure(plotly, element, figure, config = {}) {
102
+ if (!plotly || typeof plotly.newPlot !== "function") throw new TypeError("renderFigure requires a Plotly-compatible object with newPlot().");
103
+ return plotly.newPlot(element, figure.data, figure.layout, { responsive: true, displaylogo: false, ...config });
104
+ }