]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-140677 Add heatmap visualization to Tachyon sampling profiler (#140680)
authorPablo Galindo Salgado <Pablogsal@gmail.com>
Tue, 2 Dec 2025 20:33:40 +0000 (20:33 +0000)
committerGitHub <noreply@github.com>
Tue, 2 Dec 2025 20:33:40 +0000 (20:33 +0000)
Co-authored-by: Ivona Stojanovic <stojanovic.i@hotmail.com>
18 files changed:
Lib/profiling/sampling/__init__.py
Lib/profiling/sampling/_css_utils.py [new file with mode: 0644]
Lib/profiling/sampling/_flamegraph_assets/flamegraph.css [moved from Lib/profiling/sampling/flamegraph.css with 72% similarity]
Lib/profiling/sampling/_flamegraph_assets/flamegraph.js [moved from Lib/profiling/sampling/flamegraph.js with 100% similarity]
Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html [moved from Lib/profiling/sampling/flamegraph_template.html with 100% similarity]
Lib/profiling/sampling/_heatmap_assets/heatmap.css [new file with mode: 0644]
Lib/profiling/sampling/_heatmap_assets/heatmap.js [new file with mode: 0644]
Lib/profiling/sampling/_heatmap_assets/heatmap_index.js [new file with mode: 0644]
Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html [new file with mode: 0644]
Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html [new file with mode: 0644]
Lib/profiling/sampling/_shared_assets/base.css [new file with mode: 0644]
Lib/profiling/sampling/cli.py
Lib/profiling/sampling/heatmap_collector.py [new file with mode: 0644]
Lib/profiling/sampling/sample.py
Lib/profiling/sampling/stack_collector.py
Lib/test/test_profiling/test_heatmap.py [new file with mode: 0644]
Makefile.pre.in
Misc/NEWS.d/next/Library/2025-10-27-17-00-11.gh-issue-140677.hM9pTq.rst [new file with mode: 0644]

index b493c6aa7eb06d95fad4f51bceb672f93a5298ae..6a0bb5e5c2f3877323d08fdf8b9cd40c5df75dd8 100644 (file)
@@ -7,7 +7,8 @@ call stack rather than tracing every function call.
 from .collector import Collector
 from .pstats_collector import PstatsCollector
 from .stack_collector import CollapsedStackCollector
+from .heatmap_collector import HeatmapCollector
 from .gecko_collector import GeckoCollector
 from .string_table import StringTable
 
-__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "GeckoCollector", "StringTable")
+__all__ = ("Collector", "PstatsCollector", "CollapsedStackCollector", "HeatmapCollector", "GeckoCollector", "StringTable")
diff --git a/Lib/profiling/sampling/_css_utils.py b/Lib/profiling/sampling/_css_utils.py
new file mode 100644 (file)
index 0000000..40912e9
--- /dev/null
@@ -0,0 +1,22 @@
+import importlib.resources
+
+
+def get_combined_css(component: str) -> str:
+    template_dir = importlib.resources.files(__package__)
+
+    base_css = (template_dir / "_shared_assets" / "base.css").read_text(encoding="utf-8")
+
+    if component == "flamegraph":
+        component_css = (
+            template_dir / "_flamegraph_assets" / "flamegraph.css"
+        ).read_text(encoding="utf-8")
+    elif component == "heatmap":
+        component_css = (template_dir / "_heatmap_assets" / "heatmap.css").read_text(
+            encoding="utf-8"
+        )
+    else:
+        raise ValueError(
+            f"Unknown component: {component}. Expected 'flamegraph' or 'heatmap'."
+        )
+
+    return f"{base_css}\n\n{component_css}"
similarity index 72%
rename from Lib/profiling/sampling/flamegraph.css
rename to Lib/profiling/sampling/_flamegraph_assets/flamegraph.css
index 1703815acd9e1df2ba9d42f0430c54ef7b25cf1e..c75f2324b6d499957868e1b25c277c8dbfae29a3 100644 (file)
 /* ==========================================================================
-   Flamegraph Viewer - CSS
-   Python-branded profiler with dark/light theme support
-   ========================================================================== */
+   Flamegraph Viewer - Component-Specific CSS
 
-/* --------------------------------------------------------------------------
-   CSS Variables & Theme System
-   -------------------------------------------------------------------------- */
-
-:root {
-  /* Typography */
-  --font-sans: "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode",
-               "Geneva", "Verdana", sans-serif;
-  --font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
-
-  /* Python brand colors (theme-independent) */
-  --python-blue: #3776ab;
-  --python-blue-light: #4584bb;
-  --python-blue-lighter: #5592cc;
-  --python-gold: #ffd43b;
-  --python-gold-dark: #ffcd02;
-  --python-gold-light: #ffdc5c;
-
-  /* Heat palette - defined per theme below */
-
-  /* Layout */
-  --sidebar-width: 280px;
-  --sidebar-collapsed: 44px;
-  --topbar-height: 52px;
-  --statusbar-height: 32px;
-
-  /* Transitions */
-  --transition-fast: 0.15s ease;
-  --transition-normal: 0.25s ease;
-}
-
-/* Light theme (default) - Python yellow-to-blue heat palette */
-:root, [data-theme="light"] {
-  --bg-primary: #ffffff;
-  --bg-secondary: #f8f9fa;
-  --bg-tertiary: #e9ecef;
-  --border: #e9ecef;
-  --border-subtle: #f0f2f5;
-
-  --text-primary: #2e3338;
-  --text-secondary: #5a6c7d;
-  --text-muted: #8b949e;
-
-  --accent: #3776ab;
-  --accent-hover: #2d5aa0;
-  --accent-glow: rgba(55, 118, 171, 0.15);
-
-  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
-  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
-  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
-
-  --header-gradient: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
-
-  /* Light mode heat palette - blue to yellow to orange to red (cold to hot) */
-  --heat-1: #d6e9f8;
-  --heat-2: #a8d0ef;
-  --heat-3: #7ba3d1;
-  --heat-4: #ffe6a8;
-  --heat-5: #ffd43b;
-  --heat-6: #ffb84d;
-  --heat-7: #ff9966;
-  --heat-8: #ff6347;
-}
-
-/* Dark theme - teal-to-orange heat palette */
-[data-theme="dark"] {
-  --bg-primary: #0d1117;
-  --bg-secondary: #161b22;
-  --bg-tertiary: #21262d;
-  --border: #30363d;
-  --border-subtle: #21262d;
-
-  --text-primary: #e6edf3;
-  --text-secondary: #8b949e;
-  --text-muted: #6e7681;
-
-  --accent: #58a6ff;
-  --accent-hover: #79b8ff;
-  --accent-glow: rgba(88, 166, 255, 0.15);
-
-  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
-  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
-  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
-
-  --header-gradient: linear-gradient(135deg, #21262d 0%, #30363d 100%);
-
-  /* Dark mode heat palette - dark blue to teal to yellow to orange (cold to hot) */
-  --heat-1: #1e3a5f;
-  --heat-2: #2d5580;
-  --heat-3: #4a7ba7;
-  --heat-4: #5a9fa8;
-  --heat-5: #7ec488;
-  --heat-6: #c4de6a;
-  --heat-7: #f4d44d;
-  --heat-8: #ff6b35;
-}
+   DEPENDENCY: Requires _shared_assets/base.css to be loaded first
+   This file extends the shared foundation with flamegraph-specific styles.
+   ========================================================================== */
 
 /* --------------------------------------------------------------------------
-   Base Styles
+   Layout Overrides (Flamegraph-specific)
    -------------------------------------------------------------------------- */
 
-*, *::before, *::after {
-  box-sizing: border-box;
-}
-
 html, body {
-  margin: 0;
-  padding: 0;
   height: 100%;
   overflow: hidden;
 }
 
-body {
-  font-family: var(--font-sans);
-  font-size: 14px;
-  line-height: 1.6;
-  color: var(--text-primary);
-  background: var(--bg-primary);
-  transition: background var(--transition-normal), color var(--transition-normal);
-}
-
-/* --------------------------------------------------------------------------
-   Layout Structure
-   -------------------------------------------------------------------------- */
-
 .app-layout {
-  display: flex;
-  flex-direction: column;
   height: 100vh;
 }
 
@@ -141,78 +25,9 @@ body {
 }
 
 /* --------------------------------------------------------------------------
-   Top Bar
+   Search Input (Flamegraph-specific)
    -------------------------------------------------------------------------- */
 
-.top-bar {
-  height: 56px;
-  background: var(--header-gradient);
-  display: flex;
-  align-items: center;
-  padding: 0 16px;
-  gap: 16px;
-  flex-shrink: 0;
-  box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
-  border-bottom: 2px solid var(--python-gold);
-}
-
-/* Brand / Logo */
-.brand {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  color: white;
-  text-decoration: none;
-  flex-shrink: 0;
-}
-
-.brand-logo {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 28px;
-  height: 28px;
-  flex-shrink: 0;
-}
-
-/* Style the inlined SVG/img inside brand-logo */
-.brand-logo svg,
-.brand-logo img {
-  width: 28px;
-  height: 28px;
-  display: block;
-  object-fit: contain;
-  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
-}
-
-.brand-info {
-  display: flex;
-  flex-direction: column;
-  line-height: 1.15;
-}
-
-.brand-text {
-  font-weight: 700;
-  font-size: 16px;
-  letter-spacing: -0.3px;
-  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
-}
-
-.brand-subtitle {
-  font-weight: 500;
-  font-size: 10px;
-  opacity: 0.9;
-  text-transform: uppercase;
-  letter-spacing: 0.5px;
-}
-
-.brand-divider {
-  width: 1px;
-  height: 16px;
-  background: rgba(255, 255, 255, 0.3);
-}
-
-/* Search */
 .search-wrapper {
   flex: 1;
   max-width: 360px;
@@ -308,39 +123,6 @@ body {
   display: flex;
 }
 
-/* Toolbar */
-.toolbar {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  margin-left: auto;
-}
-
-.toolbar-btn {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 32px;
-  height: 32px;
-  padding: 0;
-  font-size: 15px;
-  color: white;
-  background: rgba(255, 255, 255, 0.12);
-  border: 1px solid rgba(255, 255, 255, 0.18);
-  border-radius: 6px;
-  cursor: pointer;
-  transition: all var(--transition-fast);
-}
-
-.toolbar-btn:hover {
-  background: rgba(255, 255, 255, 0.22);
-  border-color: rgba(255, 255, 255, 0.35);
-}
-
-.toolbar-btn:active {
-  transform: scale(0.95);
-}
-
 /* --------------------------------------------------------------------------
    Sidebar
    -------------------------------------------------------------------------- */
@@ -667,11 +449,6 @@ body.resizing-sidebar {
   animation: shimmer 2s ease-in-out infinite;
 }
 
-@keyframes shimmer {
-  0% { left: -100%; }
-  100% { left: 100%; }
-}
-
 /* --------------------------------------------------------------------------
    Thread Stats Grid (in Sidebar)
    -------------------------------------------------------------------------- */
@@ -974,56 +751,6 @@ body.resizing-sidebar {
   opacity: 0.25;
 }
 
-/* --------------------------------------------------------------------------
-   Status Bar
-   -------------------------------------------------------------------------- */
-
-.status-bar {
-  height: var(--statusbar-height);
-  background: var(--bg-secondary);
-  border-top: 1px solid var(--border);
-  display: flex;
-  align-items: center;
-  padding: 0 16px;
-  gap: 16px;
-  font-family: var(--font-mono);
-  font-size: 11px;
-  color: var(--text-secondary);
-  flex-shrink: 0;
-}
-
-.status-item {
-  display: flex;
-  align-items: center;
-  gap: 5px;
-}
-
-.status-item::before {
-  content: '';
-  width: 4px;
-  height: 4px;
-  background: var(--python-gold);
-  border-radius: 50%;
-}
-
-.status-item:first-child::before {
-  display: none;
-}
-
-.status-label {
-  color: var(--text-muted);
-}
-
-.status-value {
-  color: var(--text-primary);
-  font-weight: 500;
-}
-
-.status-value.accent {
-  color: var(--accent);
-  font-weight: 600;
-}
-
 /* --------------------------------------------------------------------------
    Tooltip
    -------------------------------------------------------------------------- */
@@ -1137,38 +864,7 @@ body.resizing-sidebar {
 }
 
 /* --------------------------------------------------------------------------
-   Animations
-   -------------------------------------------------------------------------- */
-
-@keyframes fadeIn {
-  from { opacity: 0; }
-  to { opacity: 1; }
-}
-
-@keyframes slideUp {
-  from {
-    opacity: 0;
-    transform: translateY(12px);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-/* --------------------------------------------------------------------------
-   Focus States (Accessibility)
-   -------------------------------------------------------------------------- */
-
-button:focus-visible,
-select:focus-visible,
-input:focus-visible {
-  outline: 2px solid var(--python-gold);
-  outline-offset: 2px;
-}
-
-/* --------------------------------------------------------------------------
-   Responsive
+   Responsive (Flamegraph-specific)
    -------------------------------------------------------------------------- */
 
 @media (max-width: 900px) {
@@ -1185,20 +881,12 @@ input:focus-visible {
     width: var(--sidebar-collapsed);
   }
 
-  .brand-subtitle {
-    display: none;
-  }
-
   .search-wrapper {
     max-width: 220px;
   }
 }
 
 @media (max-width: 600px) {
-  .toolbar-btn:not(.theme-toggle) {
-    display: none;
-  }
-
   .search-wrapper {
     max-width: 160px;
   }
diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap.css b/Lib/profiling/sampling/_heatmap_assets/heatmap.css
new file mode 100644 (file)
index 0000000..44915b2
--- /dev/null
@@ -0,0 +1,1146 @@
+/* ==========================================================================
+   Heatmap Viewer - Component-Specific CSS
+
+   DEPENDENCY: Requires _shared_assets/base.css to be loaded first
+   This file extends the shared foundation with heatmap-specific styles.
+   ========================================================================== */
+
+/* --------------------------------------------------------------------------
+   Layout Overrides (Heatmap-specific)
+   -------------------------------------------------------------------------- */
+
+.app-layout {
+  min-height: 100vh;
+}
+
+/* Sticky top bar for heatmap views */
+.top-bar {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+/* Back link in toolbar */
+.back-link {
+  color: white;
+  text-decoration: none;
+  padding: 6px 14px;
+  background: rgba(255, 255, 255, 0.12);
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  border-radius: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  transition: all var(--transition-fast);
+}
+
+.back-link:hover {
+  background: rgba(255, 255, 255, 0.22);
+  border-color: rgba(255, 255, 255, 0.35);
+}
+
+/* --------------------------------------------------------------------------
+   Main Content Area
+   -------------------------------------------------------------------------- */
+
+.main-content {
+  flex: 1;
+  padding: 24px 3%;
+  width: 100%;
+  max-width: 100%;
+}
+
+/* --------------------------------------------------------------------------
+   Stats Summary Cards - Enhanced with Icons & Animations
+   -------------------------------------------------------------------------- */
+
+.stats-summary {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+  margin-bottom: 24px;
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: var(--bg-primary);
+  border: 2px solid var(--border);
+  border-radius: 10px;
+  padding: 14px 16px;
+  transition: all var(--transition-fast);
+  animation: slideUp 0.5s ease-out backwards;
+  animation-delay: calc(var(--i, 0) * 0.08s);
+  position: relative;
+  overflow: hidden;
+}
+
+.stat-card::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 3px;
+  background: linear-gradient(90deg, var(--python-blue), var(--python-gold));
+  opacity: 0;
+  transition: opacity var(--transition-fast);
+}
+
+.stat-card:nth-child(1) { --i: 0; --card-color: 55, 118, 171; }
+.stat-card:nth-child(2) { --i: 1; --card-color: 40, 167, 69; }
+.stat-card:nth-child(3) { --i: 2; --card-color: 255, 193, 7; }
+.stat-card:nth-child(4) { --i: 3; --card-color: 111, 66, 193; }
+.stat-card:nth-child(5) { --i: 4; --card-color: 220, 53, 69; }
+.stat-card:nth-child(6) { --i: 5; --card-color: 23, 162, 184; }
+
+.stat-card:hover {
+  border-color: rgba(var(--card-color), 0.6);
+  background: linear-gradient(135deg, rgba(var(--card-color), 0.08) 0%, var(--bg-primary) 100%);
+  transform: translateY(-2px);
+  box-shadow: 0 4px 16px rgba(var(--card-color), 0.15);
+}
+
+.stat-card:hover::before {
+  opacity: 1;
+}
+
+.stat-icon {
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  background: linear-gradient(135deg, rgba(var(--card-color), 0.15) 0%, rgba(var(--card-color), 0.05) 100%);
+  border: 1px solid rgba(var(--card-color), 0.2);
+  border-radius: 10px;
+  flex-shrink: 0;
+  transition: all var(--transition-fast);
+}
+
+.stat-card:hover .stat-icon {
+  transform: scale(1.05) rotate(-2deg);
+  background: linear-gradient(135deg, rgba(var(--card-color), 0.25) 0%, rgba(var(--card-color), 0.1) 100%);
+}
+
+.stat-data {
+  flex: 1;
+  min-width: 0;
+}
+
+.stat-value {
+  font-family: var(--font-mono);
+  font-size: 1.35em;
+  font-weight: 800;
+  color: rgb(var(--card-color));
+  display: block;
+  line-height: 1.1;
+  letter-spacing: -0.3px;
+}
+
+.stat-label {
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.3px;
+  margin-top: 2px;
+}
+
+/* Sparkline decoration for stats */
+.stat-sparkline {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 30px;
+  opacity: 0.1;
+  background: linear-gradient(180deg,
+    transparent 0%,
+    rgba(var(--card-color), 0.3) 100%
+  );
+  pointer-events: none;
+}
+
+/* --------------------------------------------------------------------------
+   Rate Cards (Error Rate, Missed Samples) with Progress Bars
+   -------------------------------------------------------------------------- */
+
+.rate-card {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  background: var(--bg-primary);
+  border: 2px solid var(--border);
+  border-radius: 12px;
+  padding: 18px 20px;
+  transition: all var(--transition-fast);
+  animation: slideUp 0.5s ease-out backwards;
+  position: relative;
+  overflow: hidden;
+}
+
+.rate-card:nth-child(5) { animation-delay: 0.32s; --rate-color: 220, 53, 69; }
+.rate-card:nth-child(6) { animation-delay: 0.40s; --rate-color: 255, 152, 0; }
+
+.rate-card:hover {
+  border-color: rgba(var(--rate-color), 0.5);
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(var(--rate-color), 0.15);
+}
+
+.rate-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.rate-info {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.rate-icon {
+  width: 36px;
+  height: 36px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  background: linear-gradient(135deg, rgba(var(--rate-color), 0.15) 0%, rgba(var(--rate-color), 0.05) 100%);
+  border: 1px solid rgba(var(--rate-color), 0.2);
+  border-radius: 10px;
+  flex-shrink: 0;
+}
+
+.rate-label {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--text-secondary);
+  text-transform: uppercase;
+  letter-spacing: 0.3px;
+}
+
+.rate-value {
+  font-family: var(--font-mono);
+  font-size: 1.4em;
+  font-weight: 800;
+  color: rgb(var(--rate-color));
+}
+
+.rate-bar {
+  height: 8px;
+  background: var(--bg-tertiary);
+  border-radius: 4px;
+  overflow: hidden;
+  position: relative;
+}
+
+.rate-fill {
+  height: 100%;
+  border-radius: 4px;
+  transition: width 0.8s ease-out;
+  position: relative;
+  overflow: hidden;
+}
+
+.rate-fill.error {
+  background: linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%);
+}
+
+.rate-fill.warning {
+  background: linear-gradient(90deg, #ff9800 0%, #ffc107 100%);
+}
+
+.rate-fill.good {
+  background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
+}
+
+/* Shimmer animation on rate bars */
+.rate-fill::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: -100%;
+  width: 100%;
+  height: 100%;
+  background: linear-gradient(
+    90deg,
+    transparent 0%,
+    rgba(255, 255, 255, 0.4) 50%,
+    transparent 100%
+  );
+  animation: shimmer 2.5s ease-in-out infinite;
+}
+
+/* --------------------------------------------------------------------------
+   Section Headers
+   -------------------------------------------------------------------------- */
+
+.section-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 2px solid var(--python-gold);
+}
+
+.section-title {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--text-primary);
+  margin: 0;
+  flex: 1;
+}
+
+/* --------------------------------------------------------------------------
+   Filter Controls
+   -------------------------------------------------------------------------- */
+
+.filter-controls {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.control-btn {
+  padding: 8px 16px;
+  background: var(--bg-secondary);
+  color: var(--text-primary);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.control-btn:hover {
+  background: var(--accent);
+  color: white;
+  border-color: var(--accent);
+}
+
+/* --------------------------------------------------------------------------
+   Type Sections (stdlib, project, etc)
+   -------------------------------------------------------------------------- */
+
+.type-section {
+  background: var(--bg-primary);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  overflow: hidden;
+  margin-bottom: 12px;
+}
+
+.type-header {
+  padding: 12px 16px;
+  background: var(--header-gradient);
+  color: white;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  user-select: none;
+  transition: all var(--transition-fast);
+  font-weight: 600;
+}
+
+.type-header:hover {
+  opacity: 0.95;
+}
+
+.type-icon {
+  font-size: 12px;
+  transition: transform var(--transition-fast);
+  min-width: 12px;
+}
+
+.type-title {
+  font-size: 14px;
+  flex: 1;
+}
+
+.type-stats {
+  font-size: 12px;
+  opacity: 0.9;
+  background: rgba(255, 255, 255, 0.15);
+  padding: 4px 10px;
+  border-radius: 4px;
+  font-family: var(--font-mono);
+}
+
+.type-content {
+  padding: 12px;
+}
+
+/* --------------------------------------------------------------------------
+   Folder Nodes (hierarchical structure)
+   -------------------------------------------------------------------------- */
+
+.folder-node {
+  margin-bottom: 6px;
+}
+
+.folder-header {
+  padding: 8px 12px;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  user-select: none;
+  transition: all var(--transition-fast);
+}
+
+.folder-header:hover {
+  background: var(--accent-glow);
+  border-color: var(--accent);
+}
+
+.folder-icon {
+  font-size: 10px;
+  color: var(--accent);
+  transition: transform var(--transition-fast);
+  min-width: 12px;
+}
+
+.folder-name {
+  flex: 1;
+  font-weight: 500;
+  color: var(--text-primary);
+  font-size: 13px;
+}
+
+.folder-stats {
+  font-size: 11px;
+  color: var(--text-secondary);
+  background: var(--bg-tertiary);
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-family: var(--font-mono);
+}
+
+.folder-content {
+  padding-left: 20px;
+  margin-top: 6px;
+}
+
+/* --------------------------------------------------------------------------
+   File Items
+   -------------------------------------------------------------------------- */
+
+.files-list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-top: 8px;
+}
+
+.file-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 8px 12px;
+  background: var(--bg-primary);
+  border: 1px solid var(--border-subtle);
+  border-radius: 6px;
+  transition: all var(--transition-fast);
+}
+
+.file-item:hover {
+  background: var(--bg-secondary);
+  border-color: var(--border);
+}
+
+.file-item .file-link {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+}
+
+.file-samples {
+  font-size: 12px;
+  color: var(--text-secondary);
+  font-weight: 600;
+  white-space: nowrap;
+  width: 130px;
+  flex-shrink: 0;
+  text-align: right;
+  font-family: var(--font-mono);
+}
+
+.heatmap-bar-container {
+  width: 120px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+}
+
+.heatmap-bar {
+  flex-shrink: 0;
+  border-radius: 2px;
+}
+
+/* Links */
+.file-link {
+  color: var(--accent);
+  text-decoration: none;
+  font-weight: 500;
+  transition: color var(--transition-fast);
+}
+
+.file-link:hover {
+  color: var(--accent-hover);
+  text-decoration: underline;
+}
+
+/* --------------------------------------------------------------------------
+   Module Badges
+   -------------------------------------------------------------------------- */
+
+.module-badge {
+  display: inline-block;
+  padding: 3px 8px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 600;
+}
+
+.badge-stdlib {
+  background: rgba(40, 167, 69, 0.15);
+  color: #28a745;
+}
+
+.badge-site-packages {
+  background: rgba(0, 123, 255, 0.15);
+  color: #007bff;
+}
+
+.badge-project {
+  background: rgba(255, 193, 7, 0.2);
+  color: #d39e00;
+}
+
+.badge-other {
+  background: var(--bg-tertiary);
+  color: var(--text-secondary);
+}
+
+[data-theme="dark"] .badge-stdlib {
+  background: rgba(40, 167, 69, 0.25);
+  color: #5dd879;
+}
+
+[data-theme="dark"] .badge-site-packages {
+  background: rgba(88, 166, 255, 0.25);
+  color: #79b8ff;
+}
+
+[data-theme="dark"] .badge-project {
+  background: rgba(255, 212, 59, 0.25);
+  color: #ffd43b;
+}
+
+/* ==========================================================================
+   FILE VIEW STYLES (Code Display)
+   ========================================================================== */
+
+.code-view {
+  font-family: var(--font-mono);
+  min-height: 100vh;
+}
+
+/* Code Header (Top Bar for file view) */
+.code-header {
+  height: var(--topbar-height);
+  background: var(--header-gradient);
+  display: flex;
+  align-items: center;
+  padding: 0 16px;
+  gap: 16px;
+  box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
+  border-bottom: 2px solid var(--python-gold);
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+.code-header-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 94%;
+  max-width: 100%;
+  margin: 0 auto;
+}
+
+.code-header h1 {
+  font-size: 14px;
+  font-weight: 600;
+  color: white;
+  margin: 0;
+  font-family: var(--font-mono);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+/* File Stats Bar */
+.file-stats {
+  background: var(--bg-secondary);
+  padding: 16px 24px;
+  border-bottom: 1px solid var(--border);
+}
+
+.file-stats .stats-grid {
+  width: 94%;
+  max-width: 100%;
+  margin: 0 auto;
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+  gap: 12px;
+}
+
+.stat-item {
+  background: var(--bg-primary);
+  padding: 12px;
+  border-radius: 8px;
+  box-shadow: var(--shadow-sm);
+  text-align: center;
+  border: 1px solid var(--border);
+  transition: all var(--transition-fast);
+}
+
+.stat-item:hover {
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-md);
+  border-color: var(--accent);
+}
+
+.stat-item .stat-value {
+  font-size: 1.4em;
+  font-weight: 700;
+  color: var(--accent);
+}
+
+.stat-item .stat-label {
+  color: var(--text-muted);
+  font-size: 10px;
+  margin-top: 2px;
+}
+
+/* Legend */
+.legend {
+  background: var(--bg-secondary);
+  padding: 12px 24px;
+  border-bottom: 1px solid var(--border);
+}
+
+.legend-content {
+  width: 94%;
+  max-width: 100%;
+  margin: 0 auto;
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  flex-wrap: wrap;
+}
+
+.legend-title {
+  font-weight: 600;
+  color: var(--text-primary);
+  font-size: 13px;
+  font-family: var(--font-sans);
+}
+
+.legend-gradient {
+  flex: 1;
+  max-width: 300px;
+  height: 24px;
+  background: linear-gradient(90deg,
+    var(--bg-tertiary) 0%,
+    var(--heat-2) 25%,
+    var(--heat-4) 50%,
+    var(--heat-6) 75%,
+    var(--heat-8) 100%
+  );
+  border-radius: 4px;
+  border: 1px solid var(--border);
+}
+
+.legend-labels {
+  display: flex;
+  gap: 12px;
+  font-size: 11px;
+  color: var(--text-muted);
+  font-family: var(--font-sans);
+}
+
+/* Toggle Switch Styles */
+.toggle-switch {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  user-select: none;
+  font-family: var(--font-sans);
+  transition: opacity var(--transition-fast);
+}
+
+.toggle-switch:hover {
+  opacity: 0.85;
+}
+
+.toggle-switch .toggle-label {
+  font-size: 11px;
+  font-weight: 500;
+  color: var(--text-muted);
+  min-width: 55px;
+  text-align: right;
+  transition: color var(--transition-fast);
+}
+
+.toggle-switch .toggle-label:last-child {
+  text-align: left;
+}
+
+.toggle-switch .toggle-label.active {
+  color: var(--text-primary);
+  font-weight: 600;
+}
+
+.toggle-track {
+  position: relative;
+  width: 36px;
+  height: 20px;
+  background: var(--bg-tertiary);
+  border: 2px solid var(--border);
+  border-radius: 12px;
+  transition: all var(--transition-fast);
+  box-shadow: inset var(--shadow-sm);
+}
+
+.toggle-track:hover {
+  border-color: var(--text-muted);
+}
+
+.toggle-track.on {
+  background: var(--accent);
+  border-color: var(--accent);
+  box-shadow: 0 0 8px var(--accent-glow);
+}
+
+.toggle-track::after {
+  content: '';
+  position: absolute;
+  top: 1px;
+  left: 1px;
+  width: 14px;
+  height: 14px;
+  background: white;
+  border-radius: 50%;
+  box-shadow: var(--shadow-sm);
+  transition: all var(--transition-fast);
+}
+
+.toggle-track.on::after {
+  transform: translateX(16px);
+  box-shadow: var(--shadow-md);
+}
+
+/* Specific toggle overrides */
+#toggle-color-mode .toggle-track.on {
+  background: #8e44ad;
+  border-color: #8e44ad;
+  box-shadow: 0 0 8px rgba(142, 68, 173, 0.3);
+}
+
+#toggle-cold .toggle-track.on {
+  background: #e67e22;
+  border-color: #e67e22;
+  box-shadow: 0 0 8px rgba(230, 126, 34, 0.3);
+}
+
+/* Code Container */
+.code-container {
+  width: 94%;
+  max-width: 100%;
+  margin: 16px auto;
+  background: var(--bg-primary);
+  border: 1px solid var(--border);
+  border-radius: 8px 8px 8px 8px;
+  box-shadow: var(--shadow-sm);
+  /* Allow horizontal scroll for long lines, but don't clip sticky header */
+}
+
+/* Code Header Row */
+.code-header-row {
+  position: sticky;
+  top: var(--topbar-height);
+  z-index: 50;
+  display: flex;
+  background: var(--bg-secondary);
+  border-bottom: 2px solid var(--border);
+  font-weight: 700;
+  font-size: 11px;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+  border-radius: 8px 8px 0 0;
+}
+
+.header-line-number {
+  flex-shrink: 0;
+  width: 60px;
+  padding: 8px 10px;
+  text-align: right;
+  border-right: 1px solid var(--border);
+}
+
+.header-samples-self,
+.header-samples-cumulative {
+  flex-shrink: 0;
+  width: 90px;
+  padding: 8px 10px;
+  text-align: right;
+  border-right: 1px solid var(--border);
+}
+
+.header-samples-self {
+  color: var(--heat-8);
+}
+
+.header-samples-cumulative {
+  color: var(--accent);
+}
+
+.header-content {
+  flex: 1;
+  padding: 8px 15px;
+}
+
+/* Code Lines */
+.code-line {
+  position: relative;
+  display: flex;
+  min-height: 20px;
+  line-height: 20px;
+  font-size: 13px;
+  transition: background var(--transition-fast);
+  scroll-margin-top: calc(var(--topbar-height) + 50px);
+}
+
+.code-line:hover {
+  filter: brightness(0.97);
+}
+
+[data-theme="dark"] .code-line:hover {
+  filter: brightness(1.1);
+}
+
+.line-number {
+  flex-shrink: 0;
+  width: 60px;
+  padding: 0 10px;
+  text-align: right;
+  color: var(--text-muted);
+  background: var(--bg-secondary);
+  border-right: 1px solid var(--border);
+  user-select: none;
+  transition: all var(--transition-fast);
+}
+
+.line-number:hover {
+  background: var(--accent);
+  color: white;
+  cursor: pointer;
+}
+
+.line-samples-self,
+.line-samples-cumulative {
+  flex-shrink: 0;
+  width: 90px;
+  padding: 0 10px;
+  text-align: right;
+  background: var(--bg-secondary);
+  border-right: 1px solid var(--border);
+  font-weight: 600;
+  user-select: none;
+  font-size: 12px;
+}
+
+.line-samples-self {
+  color: var(--heat-8);
+}
+
+.line-samples-cumulative {
+  color: var(--accent);
+}
+
+.line-content {
+  flex: 1;
+  padding: 0 15px;
+  white-space: pre;
+  overflow-x: auto;
+}
+
+/* Scrollbar Styling */
+.line-content::-webkit-scrollbar {
+  height: 6px;
+}
+
+.line-content::-webkit-scrollbar-thumb {
+  background: var(--border);
+  border-radius: 3px;
+}
+
+.line-content::-webkit-scrollbar-thumb:hover {
+  background: var(--text-muted);
+}
+
+/* Navigation Buttons */
+.line-nav-buttons {
+  position: absolute;
+  right: 8px;
+  top: 50%;
+  transform: translateY(-50%);
+  display: flex;
+  gap: 4px;
+  align-items: center;
+}
+
+.nav-btn {
+  padding: 2px 6px;
+  font-size: 12px;
+  font-weight: 500;
+  border: 1px solid var(--accent);
+  border-radius: 4px;
+  background: var(--bg-primary);
+  color: var(--accent);
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  user-select: none;
+  line-height: 1;
+}
+
+.nav-btn:hover:not(:disabled) {
+  background: var(--accent);
+  color: white;
+  transform: translateY(-1px);
+  box-shadow: var(--shadow-sm);
+}
+
+.nav-btn:active:not(:disabled) {
+  transform: translateY(0);
+}
+
+.nav-btn:disabled {
+  opacity: 0.3;
+  cursor: not-allowed;
+  color: var(--text-muted);
+  background: var(--bg-secondary);
+  border-color: var(--border);
+}
+
+.nav-btn.caller {
+  color: var(--nav-caller);
+  border-color: var(--nav-caller);
+}
+
+.nav-btn.callee {
+  color: var(--nav-callee);
+  border-color: var(--nav-callee);
+}
+
+.nav-btn.caller:hover:not(:disabled) {
+  background: var(--nav-caller-hover);
+  color: white;
+}
+
+.nav-btn.callee:hover:not(:disabled) {
+  background: var(--nav-callee-hover);
+  color: white;
+}
+
+/* Highlighted target line */
+.code-line:target {
+  animation: highlight-line 2s ease-out;
+}
+
+@keyframes highlight-line {
+  0% {
+    background: rgba(255, 212, 59, 0.6) !important;
+    outline: 3px solid var(--python-gold);
+    outline-offset: -3px;
+  }
+  50% {
+    background: rgba(255, 212, 59, 0.5) !important;
+    outline: 3px solid var(--python-gold);
+    outline-offset: -3px;
+  }
+  100% {
+    background: inherit;
+    outline: 3px solid transparent;
+    outline-offset: -3px;
+  }
+}
+
+/* Popup menu for multiple callees */
+.callee-menu {
+  position: absolute;
+  background: var(--bg-primary);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  box-shadow: var(--shadow-lg);
+  padding: 8px;
+  z-index: 1000;
+  min-width: 250px;
+  max-width: 400px;
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.callee-menu-header {
+  font-weight: 600;
+  color: var(--text-primary);
+  margin-bottom: 8px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid var(--border);
+  font-size: 13px;
+  font-family: var(--font-sans);
+}
+
+.callee-menu-item {
+  padding: 8px;
+  margin: 4px 0;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: background var(--transition-fast);
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.callee-menu-item:hover {
+  background: var(--bg-secondary);
+}
+
+.callee-menu-func {
+  font-weight: 500;
+  color: var(--accent);
+  font-size: 12px;
+}
+
+.callee-menu-file {
+  font-size: 11px;
+  color: var(--text-muted);
+}
+
+.count-badge {
+  display: inline-block;
+  background: var(--accent);
+  color: white;
+  font-size: 10px;
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-weight: 600;
+  margin-left: 6px;
+}
+
+/* Callee menu scrollbar */
+.callee-menu::-webkit-scrollbar {
+  width: 6px;
+}
+
+.callee-menu::-webkit-scrollbar-track {
+  background: var(--bg-secondary);
+  border-radius: 3px;
+}
+
+.callee-menu::-webkit-scrollbar-thumb {
+  background: var(--border);
+  border-radius: 3px;
+}
+
+/* --------------------------------------------------------------------------
+   Scroll Minimap Marker
+   -------------------------------------------------------------------------- */
+
+#scroll_marker {
+  position: fixed;
+  z-index: 1000;
+  right: 0;
+  top: 0;
+  width: 12px;
+  height: 100%;
+  background: var(--bg-secondary);
+  border-left: 1px solid var(--border);
+  pointer-events: none;
+}
+
+#scroll_marker .marker {
+  position: absolute;
+  min-height: 3px;
+  width: 100%;
+  pointer-events: none;
+}
+
+#scroll_marker .marker.cold {
+  background: var(--heat-2);
+}
+
+#scroll_marker .marker.warm {
+  background: var(--heat-5);
+}
+
+#scroll_marker .marker.hot {
+  background: var(--heat-7);
+}
+
+#scroll_marker .marker.vhot {
+  background: var(--heat-8);
+}
+
+/* --------------------------------------------------------------------------
+   Responsive (Heatmap-specific)
+   -------------------------------------------------------------------------- */
+
+@media (max-width: 1100px) {
+  .stats-summary {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+
+@media (max-width: 900px) {
+  .main-content {
+    padding: 16px;
+  }
+}
+
+@media (max-width: 600px) {
+  .stats-summary {
+    grid-template-columns: 1fr;
+  }
+
+  .file-stats .stats-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .legend-content {
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .legend-gradient {
+    width: 100%;
+    max-width: none;
+  }
+}
diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap.js b/Lib/profiling/sampling/_heatmap_assets/heatmap.js
new file mode 100644 (file)
index 0000000..ccf8238
--- /dev/null
@@ -0,0 +1,349 @@
+// Tachyon Profiler - Heatmap JavaScript
+// Interactive features for the heatmap visualization
+// Aligned with Flamegraph viewer design patterns
+
+// ============================================================================
+// State Management
+// ============================================================================
+
+let currentMenu = null;
+let colorMode = 'self';  // 'self' or 'cumulative' - default to self
+let coldCodeHidden = false;
+
+// ============================================================================
+// Theme Support
+// ============================================================================
+
+function toggleTheme() {
+    const html = document.documentElement;
+    const current = html.getAttribute('data-theme') || 'light';
+    const next = current === 'light' ? 'dark' : 'light';
+    html.setAttribute('data-theme', next);
+    localStorage.setItem('heatmap-theme', next);
+
+    // Update theme button icon
+    const btn = document.getElementById('theme-btn');
+    if (btn) {
+        btn.innerHTML = next === 'dark' ? '&#9788;' : '&#9790;';  // sun or moon
+    }
+
+    // Rebuild scroll marker with new theme colors
+    buildScrollMarker();
+}
+
+function restoreUIState() {
+    // Restore theme
+    const savedTheme = localStorage.getItem('heatmap-theme');
+    if (savedTheme) {
+        document.documentElement.setAttribute('data-theme', savedTheme);
+        const btn = document.getElementById('theme-btn');
+        if (btn) {
+            btn.innerHTML = savedTheme === 'dark' ? '&#9788;' : '&#9790;';
+        }
+    }
+}
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+function createElement(tag, className, textContent = '') {
+    const el = document.createElement(tag);
+    if (className) el.className = className;
+    if (textContent) el.textContent = textContent;
+    return el;
+}
+
+function calculateMenuPosition(buttonRect, menuWidth, menuHeight) {
+    const viewport = { width: window.innerWidth, height: window.innerHeight };
+    const scroll = {
+        x: window.pageXOffset || document.documentElement.scrollLeft,
+        y: window.pageYOffset || document.documentElement.scrollTop
+    };
+
+    const left = buttonRect.right + menuWidth + 10 < viewport.width
+        ? buttonRect.right + scroll.x + 10
+        : Math.max(scroll.x + 10, buttonRect.left + scroll.x - menuWidth - 10);
+
+    const top = buttonRect.bottom + menuHeight + 10 < viewport.height
+        ? buttonRect.bottom + scroll.y + 5
+        : Math.max(scroll.y + 10, buttonRect.top + scroll.y - menuHeight - 10);
+
+    return { left, top };
+}
+
+// ============================================================================
+// Menu Management
+// ============================================================================
+
+function closeMenu() {
+    if (currentMenu) {
+        currentMenu.remove();
+        currentMenu = null;
+    }
+}
+
+function showNavigationMenu(button, items, title) {
+    closeMenu();
+
+    const menu = createElement('div', 'callee-menu');
+    menu.appendChild(createElement('div', 'callee-menu-header', title));
+
+    items.forEach(linkData => {
+        const item = createElement('div', 'callee-menu-item');
+
+        const funcDiv = createElement('div', 'callee-menu-func');
+        funcDiv.textContent = linkData.func;
+
+        if (linkData.count !== undefined && linkData.count > 0) {
+            const countBadge = createElement('span', 'count-badge');
+            countBadge.textContent = linkData.count.toLocaleString();
+            countBadge.title = `${linkData.count.toLocaleString()} samples`;
+            funcDiv.appendChild(document.createTextNode(' '));
+            funcDiv.appendChild(countBadge);
+        }
+
+        item.appendChild(funcDiv);
+        item.appendChild(createElement('div', 'callee-menu-file', linkData.file));
+        item.addEventListener('click', () => window.location.href = linkData.link);
+        menu.appendChild(item);
+    });
+
+    const pos = calculateMenuPosition(button.getBoundingClientRect(), 350, 300);
+    menu.style.left = `${pos.left}px`;
+    menu.style.top = `${pos.top}px`;
+
+    document.body.appendChild(menu);
+    currentMenu = menu;
+}
+
+// ============================================================================
+// Navigation
+// ============================================================================
+
+function handleNavigationClick(button, e) {
+    e.stopPropagation();
+
+    const navData = button.getAttribute('data-nav');
+    if (navData) {
+        window.location.href = JSON.parse(navData).link;
+        return;
+    }
+
+    const navMulti = button.getAttribute('data-nav-multi');
+    if (navMulti) {
+        const items = JSON.parse(navMulti);
+        const title = button.classList.contains('caller') ? 'Choose a caller:' : 'Choose a callee:';
+        showNavigationMenu(button, items, title);
+    }
+}
+
+function scrollToTargetLine() {
+    if (!window.location.hash) return;
+    const target = document.querySelector(window.location.hash);
+    if (target) {
+        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+}
+
+// ============================================================================
+// Sample Count & Intensity
+// ============================================================================
+
+function getSampleCount(line) {
+    let text;
+    if (colorMode === 'self') {
+        text = line.querySelector('.line-samples-self')?.textContent.trim().replace(/,/g, '');
+    } else {
+        text = line.querySelector('.line-samples-cumulative')?.textContent.trim().replace(/,/g, '');
+    }
+    return parseInt(text) || 0;
+}
+
+function getIntensityClass(ratio) {
+    if (ratio > 0.75) return 'vhot';
+    if (ratio > 0.5) return 'hot';
+    if (ratio > 0.25) return 'warm';
+    return 'cold';
+}
+
+// ============================================================================
+// Scroll Minimap
+// ============================================================================
+
+function buildScrollMarker() {
+    const existing = document.getElementById('scroll_marker');
+    if (existing) existing.remove();
+
+    if (document.body.scrollHeight <= window.innerHeight) return;
+
+    const allLines = document.querySelectorAll('.code-line');
+    const lines = Array.from(allLines).filter(line => line.style.display !== 'none');
+    const markerScale = window.innerHeight / document.body.scrollHeight;
+    const lineHeight = Math.min(Math.max(3, window.innerHeight / lines.length), 10);
+    const maxSamples = Math.max(...Array.from(lines, getSampleCount));
+
+    const scrollMarker = createElement('div', '');
+    scrollMarker.id = 'scroll_marker';
+
+    let prevLine = -99, lastMark, lastTop;
+
+    lines.forEach((line, index) => {
+        const samples = getSampleCount(line);
+        if (samples === 0) return;
+
+        const lineTop = Math.floor(line.offsetTop * markerScale);
+        const lineNumber = index + 1;
+        const intensityClass = maxSamples > 0 ? getIntensityClass(samples / maxSamples) : 'cold';
+
+        if (lineNumber === prevLine + 1 && lastMark?.classList.contains(intensityClass)) {
+            lastMark.style.height = `${lineTop + lineHeight - lastTop}px`;
+        } else {
+            lastMark = createElement('div', `marker ${intensityClass}`);
+            lastMark.style.height = `${lineHeight}px`;
+            lastMark.style.top = `${lineTop}px`;
+            scrollMarker.appendChild(lastMark);
+            lastTop = lineTop;
+        }
+
+        prevLine = lineNumber;
+    });
+
+    document.body.appendChild(scrollMarker);
+}
+
+// ============================================================================
+// Toggle Controls
+// ============================================================================
+
+function updateToggleUI(toggleId, isOn) {
+    const toggle = document.getElementById(toggleId);
+    if (toggle) {
+        const track = toggle.querySelector('.toggle-track');
+        const labels = toggle.querySelectorAll('.toggle-label');
+        if (isOn) {
+            track.classList.add('on');
+            labels[0].classList.remove('active');
+            labels[1].classList.add('active');
+        } else {
+            track.classList.remove('on');
+            labels[0].classList.add('active');
+            labels[1].classList.remove('active');
+        }
+    }
+}
+
+function toggleColdCode() {
+    coldCodeHidden = !coldCodeHidden;
+    applyHotFilter();
+    updateToggleUI('toggle-cold', coldCodeHidden);
+    buildScrollMarker();
+}
+
+function applyHotFilter() {
+    const lines = document.querySelectorAll('.code-line');
+
+    lines.forEach(line => {
+        const selfSamples = line.querySelector('.line-samples-self')?.textContent.trim();
+        const cumulativeSamples = line.querySelector('.line-samples-cumulative')?.textContent.trim();
+
+        let isCold;
+        if (colorMode === 'self') {
+            isCold = !selfSamples || selfSamples === '';
+        } else {
+            isCold = !cumulativeSamples || cumulativeSamples === '';
+        }
+
+        if (isCold) {
+            line.style.display = coldCodeHidden ? 'none' : 'flex';
+        } else {
+            line.style.display = 'flex';
+        }
+    });
+}
+
+function toggleColorMode() {
+    colorMode = colorMode === 'self' ? 'cumulative' : 'self';
+    const lines = document.querySelectorAll('.code-line');
+
+    lines.forEach(line => {
+        let bgColor;
+        if (colorMode === 'self') {
+            bgColor = line.getAttribute('data-self-color');
+        } else {
+            bgColor = line.getAttribute('data-cumulative-color');
+        }
+
+        if (bgColor) {
+            line.style.background = bgColor;
+        }
+    });
+
+    updateToggleUI('toggle-color-mode', colorMode === 'cumulative');
+
+    if (coldCodeHidden) {
+        applyHotFilter();
+    }
+
+    buildScrollMarker();
+}
+
+// ============================================================================
+// Initialization
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+    // Restore UI state (theme, etc.)
+    restoreUIState();
+
+    // Apply background colors
+    document.querySelectorAll('.code-line[data-bg-color]').forEach(line => {
+        const bgColor = line.getAttribute('data-bg-color');
+        if (bgColor) {
+            line.style.background = bgColor;
+        }
+    });
+
+    // Initialize navigation buttons
+    document.querySelectorAll('.nav-btn').forEach(button => {
+        button.addEventListener('click', e => handleNavigationClick(button, e));
+    });
+
+    // Initialize line number permalink handlers
+    document.querySelectorAll('.line-number').forEach(lineNum => {
+        lineNum.style.cursor = 'pointer';
+        lineNum.addEventListener('click', e => {
+            window.location.hash = `line-${e.target.textContent.trim()}`;
+        });
+    });
+
+    // Initialize toggle buttons
+    const toggleColdBtn = document.getElementById('toggle-cold');
+    if (toggleColdBtn) {
+        toggleColdBtn.addEventListener('click', toggleColdCode);
+    }
+
+    const colorModeBtn = document.getElementById('toggle-color-mode');
+    if (colorModeBtn) {
+        colorModeBtn.addEventListener('click', toggleColorMode);
+    }
+
+    // Build scroll marker
+    setTimeout(buildScrollMarker, 200);
+
+    // Setup scroll-to-line behavior
+    setTimeout(scrollToTargetLine, 100);
+});
+
+// Close menu when clicking outside
+document.addEventListener('click', e => {
+    if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) {
+        closeMenu();
+    }
+});
+
+// Handle hash changes
+window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50));
+
+// Rebuild scroll marker on resize
+window.addEventListener('resize', buildScrollMarker);
diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_index.js b/Lib/profiling/sampling/_heatmap_assets/heatmap_index.js
new file mode 100644 (file)
index 0000000..5f3e65c
--- /dev/null
@@ -0,0 +1,111 @@
+// Tachyon Profiler - Heatmap Index JavaScript
+// Index page specific functionality
+
+// ============================================================================
+// Theme Support
+// ============================================================================
+
+function toggleTheme() {
+    const html = document.documentElement;
+    const current = html.getAttribute('data-theme') || 'light';
+    const next = current === 'light' ? 'dark' : 'light';
+    html.setAttribute('data-theme', next);
+    localStorage.setItem('heatmap-theme', next);
+
+    // Update theme button icon
+    const btn = document.getElementById('theme-btn');
+    if (btn) {
+        btn.innerHTML = next === 'dark' ? '&#9788;' : '&#9790;';  // sun or moon
+    }
+}
+
+function restoreUIState() {
+    // Restore theme
+    const savedTheme = localStorage.getItem('heatmap-theme');
+    if (savedTheme) {
+        document.documentElement.setAttribute('data-theme', savedTheme);
+        const btn = document.getElementById('theme-btn');
+        if (btn) {
+            btn.innerHTML = savedTheme === 'dark' ? '&#9788;' : '&#9790;';
+        }
+    }
+}
+
+// ============================================================================
+// Type Section Toggle (stdlib, project, etc)
+// ============================================================================
+
+function toggleTypeSection(header) {
+    const section = header.parentElement;
+    const content = section.querySelector('.type-content');
+    const icon = header.querySelector('.type-icon');
+
+    if (content.style.display === 'none') {
+        content.style.display = 'block';
+        icon.textContent = '\u25BC';
+    } else {
+        content.style.display = 'none';
+        icon.textContent = '\u25B6';
+    }
+}
+
+// ============================================================================
+// Folder Toggle
+// ============================================================================
+
+function toggleFolder(header) {
+    const folder = header.parentElement;
+    const content = folder.querySelector('.folder-content');
+    const icon = header.querySelector('.folder-icon');
+
+    if (content.style.display === 'none') {
+        content.style.display = 'block';
+        icon.textContent = '\u25BC';
+        folder.classList.remove('collapsed');
+    } else {
+        content.style.display = 'none';
+        icon.textContent = '\u25B6';
+        folder.classList.add('collapsed');
+    }
+}
+
+// ============================================================================
+// Expand/Collapse All
+// ============================================================================
+
+function expandAll() {
+    // Expand all type sections
+    document.querySelectorAll('.type-section').forEach(section => {
+        const content = section.querySelector('.type-content');
+        const icon = section.querySelector('.type-icon');
+        content.style.display = 'block';
+        icon.textContent = '\u25BC';
+    });
+
+    // Expand all folders
+    document.querySelectorAll('.folder-node').forEach(folder => {
+        const content = folder.querySelector('.folder-content');
+        const icon = folder.querySelector('.folder-icon');
+        content.style.display = 'block';
+        icon.textContent = '\u25BC';
+        folder.classList.remove('collapsed');
+    });
+}
+
+function collapseAll() {
+    document.querySelectorAll('.folder-node').forEach(folder => {
+        const content = folder.querySelector('.folder-content');
+        const icon = folder.querySelector('.folder-icon');
+        content.style.display = 'none';
+        icon.textContent = '\u25B6';
+        folder.classList.add('collapsed');
+    });
+}
+
+// ============================================================================
+// Initialization
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+    restoreUIState();
+});
diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html b/Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html
new file mode 100644 (file)
index 0000000..b71bd94
--- /dev/null
@@ -0,0 +1,118 @@
+<!doctype html>
+<html lang="en" data-theme="light">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Tachyon Profiler - Heatmap Report</title>
+    <!-- INLINE_CSS -->
+</head>
+<body>
+    <div class="app-layout">
+        <!-- Top Bar -->
+        <header class="top-bar">
+            <div class="brand">
+                <span class="brand-text">Tachyon</span>
+                <span class="brand-divider"></span>
+                <span class="brand-subtitle">Heatmap Report</span>
+            </div>
+            <div class="toolbar">
+                <button
+                    class="toolbar-btn theme-toggle"
+                    onclick="toggleTheme()"
+                    title="Toggle theme"
+                    id="theme-btn"
+                >&#9790;</button>
+            </div>
+        </header>
+
+        <!-- Main Content -->
+        <div class="main-content">
+            <!-- Stats Summary -->
+            <div class="stats-summary">
+                <div class="stat-card">
+                    <div class="stat-icon">&#128196;</div>
+                    <div class="stat-data">
+                        <span class="stat-value"><!-- NUM_FILES --></span>
+                        <span class="stat-label">Files Profiled</span>
+                    </div>
+                    <div class="stat-sparkline"></div>
+                </div>
+                <div class="stat-card">
+                    <div class="stat-icon">&#128202;</div>
+                    <div class="stat-data">
+                        <span class="stat-value"><!-- TOTAL_SAMPLES --></span>
+                        <span class="stat-label">Total Snapshots</span>
+                    </div>
+                    <div class="stat-sparkline"></div>
+                </div>
+                <div class="stat-card">
+                    <div class="stat-icon">&#9201;</div>
+                    <div class="stat-data">
+                        <span class="stat-value"><!-- DURATION --></span>
+                        <span class="stat-label">Duration</span>
+                    </div>
+                    <div class="stat-sparkline"></div>
+                </div>
+                <div class="stat-card">
+                    <div class="stat-icon">&#9889;</div>
+                    <div class="stat-data">
+                        <span class="stat-value"><!-- SAMPLE_RATE --></span>
+                        <span class="stat-label">Samples/sec</span>
+                    </div>
+                    <div class="stat-sparkline"></div>
+                </div>
+                <div class="rate-card">
+                    <div class="rate-header">
+                        <div class="rate-info">
+                            <div class="rate-icon">&#9888;</div>
+                            <span class="rate-label">Error Rate</span>
+                        </div>
+                        <span class="rate-value"><!-- ERROR_RATE --></span>
+                    </div>
+                    <div class="rate-bar">
+                        <div class="rate-fill <!-- ERROR_RATE_CLASS -->" style="width: <!-- ERROR_RATE_WIDTH -->%;"></div>
+                    </div>
+                </div>
+                <div class="rate-card">
+                    <div class="rate-header">
+                        <div class="rate-info">
+                            <div class="rate-icon">&#128165;</div>
+                            <span class="rate-label">Missed Samples</span>
+                        </div>
+                        <span class="rate-value"><!-- MISSED_SAMPLES --></span>
+                    </div>
+                    <div class="rate-bar">
+                        <div class="rate-fill <!-- MISSED_SAMPLES_CLASS -->" style="width: <!-- MISSED_SAMPLES_WIDTH -->%;"></div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- File List Section -->
+            <div class="section-header">
+                <h2 class="section-title">Profiled Files</h2>
+            </div>
+
+            <div class="filter-controls">
+                <button onclick="expandAll()" class="control-btn">Expand All</button>
+                <button onclick="collapseAll()" class="control-btn">Collapse All</button>
+            </div>
+
+            <div class="module-sections">
+<!-- SECTIONS_HTML -->
+            </div>
+        </div>
+
+        <!-- Status Bar -->
+        <footer class="status-bar">
+            <span class="status-item">
+                <span class="status-value">Tachyon Profiler</span>
+            </span>
+            <span class="status-item">
+                <span class="status-label">Python Sampling Profiler</span>
+            </span>
+        </footer>
+    </div>
+
+    <!-- INLINE_JS -->
+</body>
+</html>
diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html b/Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html
new file mode 100644 (file)
index 0000000..d8b26ad
--- /dev/null
@@ -0,0 +1,96 @@
+<!doctype html>
+<html lang="en" data-theme="light">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><!-- FILENAME --> - Heatmap</title>
+    <!-- INLINE_CSS -->
+</head>
+<body class="code-view">
+    <div class="app-layout">
+        <!-- Top Bar (Code Header) -->
+        <header class="top-bar">
+            <div class="brand">
+                <span class="brand-text">Tachyon</span>
+                <span class="brand-divider"></span>
+                <span class="brand-subtitle" style="font-family: var(--font-mono); font-size: 13px;"><!-- FILENAME --></span>
+            </div>
+            <div class="toolbar">
+                <a href="index.html" class="back-link">Back to Index</a>
+                <button
+                    class="toolbar-btn theme-toggle"
+                    onclick="toggleTheme()"
+                    title="Toggle theme"
+                    id="theme-btn"
+                >&#9790;</button>
+            </div>
+        </header>
+
+        <!-- File Stats Bar -->
+        <div class="file-stats">
+            <div class="stats-grid">
+                <div class="stat-item">
+                    <div class="stat-value"><!-- TOTAL_SELF_SAMPLES --></div>
+                    <div class="stat-label">Self Samples</div>
+                </div>
+                <div class="stat-item">
+                    <div class="stat-value"><!-- TOTAL_SAMPLES --></div>
+                    <div class="stat-label">Cumulative</div>
+                </div>
+                <div class="stat-item">
+                    <div class="stat-value"><!-- NUM_LINES --></div>
+                    <div class="stat-label">Lines Hit</div>
+                </div>
+                <div class="stat-item">
+                    <div class="stat-value"><!-- PERCENTAGE -->%</div>
+                    <div class="stat-label">% of Total</div>
+                </div>
+                <div class="stat-item">
+                    <div class="stat-value"><!-- MAX_SELF_SAMPLES --></div>
+                    <div class="stat-label">Max Self</div>
+                </div>
+                <div class="stat-item">
+                    <div class="stat-value"><!-- MAX_SAMPLES --></div>
+                    <div class="stat-label">Max Total</div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Legend -->
+        <div class="legend">
+            <div class="legend-content">
+                <span class="legend-title">Intensity:</span>
+                <div class="legend-gradient"></div>
+                <div class="legend-labels">
+                    <span>Cold</span>
+                    <span>→</span>
+                    <span>Hot</span>
+                </div>
+                <div class="toggle-switch" id="toggle-color-mode" title="Toggle between self time and total time coloring">
+                    <span class="toggle-label active">Self Time</span>
+                    <div class="toggle-track"></div>
+                    <span class="toggle-label">Total Time</span>
+                </div>
+                <div class="toggle-switch" id="toggle-cold" title="Toggle visibility of lines with zero samples">
+                    <span class="toggle-label active">Show All</span>
+                    <div class="toggle-track"></div>
+                    <span class="toggle-label">Hot Only</span>
+                </div>
+            </div>
+        </div>
+
+        <!-- Code Container -->
+        <div class="code-container">
+            <div class="code-header-row">
+                <div class="header-line-number">Line</div>
+                <div class="header-samples-self">Self</div>
+                <div class="header-samples-cumulative">Total</div>
+                <div class="header-content">Code</div>
+            </div>
+<!-- CODE_LINES -->
+        </div>
+    </div>
+
+    <!-- INLINE_JS -->
+</body>
+</html>
diff --git a/Lib/profiling/sampling/_shared_assets/base.css b/Lib/profiling/sampling/_shared_assets/base.css
new file mode 100644 (file)
index 0000000..2051691
--- /dev/null
@@ -0,0 +1,369 @@
+/* ==========================================================================
+   Python Profiler - Shared CSS Foundation
+   Design system shared between Flamegraph and Heatmap viewers
+   ========================================================================== */
+
+/* --------------------------------------------------------------------------
+   CSS Variables & Theme System
+   -------------------------------------------------------------------------- */
+
+:root {
+  /* Typography */
+  --font-sans: "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode",
+               "Geneva", "Verdana", sans-serif;
+  --font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
+
+  /* Python brand colors (theme-independent) */
+  --python-blue: #3776ab;
+  --python-blue-light: #4584bb;
+  --python-blue-lighter: #5592cc;
+  --python-gold: #ffd43b;
+  --python-gold-dark: #ffcd02;
+  --python-gold-light: #ffdc5c;
+
+  /* Heat palette - defined per theme below */
+
+  /* Layout */
+  --sidebar-width: 280px;
+  --sidebar-collapsed: 44px;
+  --topbar-height: 56px;
+  --statusbar-height: 32px;
+
+  /* Transitions */
+  --transition-fast: 0.15s ease;
+  --transition-normal: 0.25s ease;
+}
+
+/* Light theme (default) */
+:root, [data-theme="light"] {
+  --bg-primary: #ffffff;
+  --bg-secondary: #f8f9fa;
+  --bg-tertiary: #e9ecef;
+  --border: #e9ecef;
+  --border-subtle: #f0f2f5;
+
+  --text-primary: #2e3338;
+  --text-secondary: #5a6c7d;
+  --text-muted: #8b949e;
+
+  --accent: #3776ab;
+  --accent-hover: #2d5aa0;
+  --accent-glow: rgba(55, 118, 171, 0.15);
+
+  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
+  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
+  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
+
+  --header-gradient: linear-gradient(135deg, #3776ab 0%, #4584bb 100%);
+
+  /* Light mode heat palette - blue to yellow to orange to red (cold to hot) */
+  --heat-1: #d6e9f8;
+  --heat-2: #a8d0ef;
+  --heat-3: #7ba3d1;
+  --heat-4: #ffe6a8;
+  --heat-5: #ffd43b;
+  --heat-6: #ffb84d;
+  --heat-7: #ff9966;
+  --heat-8: #ff6347;
+
+  /* Code view specific */
+  --code-bg: #ffffff;
+  --code-bg-line: #f8f9fa;
+  --code-border: #e9ecef;
+  --code-text: #2e3338;
+  --code-text-muted: #8b949e;
+  --code-accent: #3776ab;
+
+  /* Navigation colors */
+  --nav-caller: #2563eb;
+  --nav-caller-hover: #1d4ed8;
+  --nav-callee: #dc2626;
+  --nav-callee-hover: #b91c1c;
+}
+
+/* Dark theme */
+[data-theme="dark"] {
+  --bg-primary: #0d1117;
+  --bg-secondary: #161b22;
+  --bg-tertiary: #21262d;
+  --border: #30363d;
+  --border-subtle: #21262d;
+
+  --text-primary: #e6edf3;
+  --text-secondary: #8b949e;
+  --text-muted: #6e7681;
+
+  --accent: #58a6ff;
+  --accent-hover: #79b8ff;
+  --accent-glow: rgba(88, 166, 255, 0.15);
+
+  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
+  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
+
+  --header-gradient: linear-gradient(135deg, #21262d 0%, #30363d 100%);
+
+  /* Dark mode heat palette - dark blue to teal to yellow to orange (cold to hot) */
+  --heat-1: #1e3a5f;
+  --heat-2: #2d5580;
+  --heat-3: #4a7ba7;
+  --heat-4: #5a9fa8;
+  --heat-5: #7ec488;
+  --heat-6: #c4de6a;
+  --heat-7: #f4d44d;
+  --heat-8: #ff6b35;
+
+  /* Code view specific - dark mode */
+  --code-bg: #0d1117;
+  --code-bg-line: #161b22;
+  --code-border: #30363d;
+  --code-text: #e6edf3;
+  --code-text-muted: #6e7681;
+  --code-accent: #58a6ff;
+
+  /* Navigation colors - dark theme friendly */
+  --nav-caller: #58a6ff;
+  --nav-caller-hover: #4184e4;
+  --nav-callee: #f87171;
+  --nav-callee-hover: #e53e3e;
+}
+
+/* --------------------------------------------------------------------------
+   Base Styles
+   -------------------------------------------------------------------------- */
+
+*, *::before, *::after {
+  box-sizing: border-box;
+}
+
+html, body {
+  margin: 0;
+  padding: 0;
+}
+
+body {
+  font-family: var(--font-sans);
+  font-size: 14px;
+  line-height: 1.6;
+  color: var(--text-primary);
+  background: var(--bg-primary);
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  transition: background var(--transition-normal), color var(--transition-normal);
+}
+
+/* --------------------------------------------------------------------------
+   Layout Structure
+   -------------------------------------------------------------------------- */
+
+.app-layout {
+  display: flex;
+  flex-direction: column;
+}
+
+/* --------------------------------------------------------------------------
+   Top Bar
+   -------------------------------------------------------------------------- */
+
+.top-bar {
+  height: var(--topbar-height);
+  background: var(--header-gradient);
+  display: flex;
+  align-items: center;
+  padding: 0 16px;
+  gap: 16px;
+  flex-shrink: 0;
+  box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25);
+  border-bottom: 2px solid var(--python-gold);
+}
+
+/* Brand / Logo */
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: white;
+  text-decoration: none;
+  flex-shrink: 0;
+}
+
+.brand-logo {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  flex-shrink: 0;
+}
+
+/* Style the inlined SVG/img inside brand-logo */
+.brand-logo svg,
+.brand-logo img {
+  width: 28px;
+  height: 28px;
+  display: block;
+  object-fit: contain;
+  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
+}
+
+.brand-info {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.15;
+}
+
+.brand-text {
+  font-weight: 700;
+  font-size: 16px;
+  letter-spacing: -0.3px;
+  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+}
+
+.brand-subtitle {
+  font-weight: 500;
+  font-size: 10px;
+  opacity: 0.9;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.brand-divider {
+  width: 1px;
+  height: 16px;
+  background: rgba(255, 255, 255, 0.3);
+}
+
+/* Toolbar */
+.toolbar {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-left: auto;
+}
+
+.toolbar-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  padding: 0;
+  font-size: 15px;
+  color: white;
+  background: rgba(255, 255, 255, 0.12);
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.toolbar-btn:hover {
+  background: rgba(255, 255, 255, 0.22);
+  border-color: rgba(255, 255, 255, 0.35);
+}
+
+.toolbar-btn:active {
+  transform: scale(0.95);
+}
+
+/* --------------------------------------------------------------------------
+   Status Bar
+   -------------------------------------------------------------------------- */
+
+.status-bar {
+  height: var(--statusbar-height);
+  background: var(--bg-secondary);
+  border-top: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  padding: 0 16px;
+  gap: 16px;
+  font-family: var(--font-mono);
+  font-size: 11px;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.status-item {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.status-item::before {
+  content: '';
+  width: 4px;
+  height: 4px;
+  background: var(--python-gold);
+  border-radius: 50%;
+}
+
+.status-item:first-child::before {
+  display: none;
+}
+
+.status-label {
+  color: var(--text-muted);
+}
+
+.status-value {
+  color: var(--text-primary);
+  font-weight: 500;
+}
+
+.status-value.accent {
+  color: var(--accent);
+  font-weight: 600;
+}
+
+/* --------------------------------------------------------------------------
+   Animations
+   -------------------------------------------------------------------------- */
+
+@keyframes fadeIn {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+@keyframes slideUp {
+  from {
+    opacity: 0;
+    transform: translateY(12px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes shimmer {
+  0% { left: -100%; }
+  100% { left: 100%; }
+}
+
+/* --------------------------------------------------------------------------
+   Focus States (Accessibility)
+   -------------------------------------------------------------------------- */
+
+button:focus-visible,
+select:focus-visible,
+input:focus-visible {
+  outline: 2px solid var(--python-gold);
+  outline-offset: 2px;
+}
+
+/* --------------------------------------------------------------------------
+   Shared Responsive
+   -------------------------------------------------------------------------- */
+
+@media (max-width: 900px) {
+  .brand-subtitle {
+    display: none;
+  }
+}
+
+@media (max-width: 600px) {
+  .toolbar-btn:not(.theme-toggle) {
+    display: none;
+  }
+}
index aede6a4d3e9f1bdf156221a49ac3d7e4aec55775..5c0e39d77371ef219cbaaf683f8fb082eda89aee 100644 (file)
@@ -9,6 +9,7 @@ import sys
 from .sample import sample, sample_live
 from .pstats_collector import PstatsCollector
 from .stack_collector import CollapsedStackCollector, FlamegraphCollector
+from .heatmap_collector import HeatmapCollector
 from .gecko_collector import GeckoCollector
 from .constants import (
     PROFILING_MODE_ALL,
@@ -71,6 +72,7 @@ FORMAT_EXTENSIONS = {
     "collapsed": "txt",
     "flamegraph": "html",
     "gecko": "json",
+    "heatmap": "html",
 }
 
 COLLECTOR_MAP = {
@@ -78,6 +80,7 @@ COLLECTOR_MAP = {
     "collapsed": CollapsedStackCollector,
     "flamegraph": FlamegraphCollector,
     "gecko": GeckoCollector,
+    "heatmap": HeatmapCollector,
 }
 
 
@@ -238,14 +241,21 @@ def _add_format_options(parser):
         dest="format",
         help="Generate Gecko format for Firefox Profiler",
     )
+    format_group.add_argument(
+        "--heatmap",
+        action="store_const",
+        const="heatmap",
+        dest="format",
+        help="Generate interactive HTML heatmap visualization with line-level sample counts",
+    )
     parser.set_defaults(format="pstats")
 
     output_group.add_argument(
         "-o",
         "--output",
         dest="outfile",
-        help="Save output to a file (default: stdout for pstats, "
-        "auto-generated filename for other formats)",
+        help="Output path (default: stdout for pstats, auto-generated for others). "
+        "For heatmap: directory name (default: heatmap_PID)",
     )
 
 
@@ -327,6 +337,9 @@ def _generate_output_filename(format_type, pid):
         Generated filename
     """
     extension = FORMAT_EXTENSIONS.get(format_type, "txt")
+    # For heatmap, use cleaner directory name without extension
+    if format_type == "heatmap":
+        return f"heatmap_{pid}"
     return f"{format_type}.{pid}.{extension}"
 
 
diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py
new file mode 100644 (file)
index 0000000..eb51ce3
--- /dev/null
@@ -0,0 +1,1039 @@
+"""Heatmap collector for Python profiling with line-level execution heat visualization."""
+
+import base64
+import collections
+import html
+import importlib.resources
+import json
+import os
+import platform
+import site
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Tuple, Optional, Any
+
+from ._css_utils import get_combined_css
+from .stack_collector import StackTraceCollector
+
+
+# ============================================================================
+# Data Classes
+# ============================================================================
+
+@dataclass
+class FileStats:
+    """Statistics for a single profiled file."""
+    filename: str
+    module_name: str
+    module_type: str
+    total_samples: int
+    total_self_samples: int
+    num_lines: int
+    max_samples: int
+    max_self_samples: int
+    percentage: float = 0.0
+
+
+@dataclass
+class TreeNode:
+    """Node in the hierarchical file tree structure."""
+    files: List[FileStats] = field(default_factory=list)
+    samples: int = 0
+    count: int = 0
+    children: Dict[str, 'TreeNode'] = field(default_factory=dict)
+
+
+@dataclass
+class ColorGradient:
+    """Configuration for heatmap color gradient calculations."""
+    # Color stops thresholds
+    stop_1: float = 0.2  # Blue to cyan transition
+    stop_2: float = 0.4  # Cyan to green transition
+    stop_3: float = 0.6  # Green to yellow transition
+    stop_4: float = 0.8  # Yellow to orange transition
+    stop_5: float = 1.0  # Orange to red transition
+
+    # Alpha (opacity) values
+    alpha_very_cold: float = 0.3
+    alpha_cold: float = 0.4
+    alpha_medium: float = 0.5
+    alpha_warm: float = 0.6
+    alpha_hot_base: float = 0.7
+    alpha_hot_range: float = 0.15
+
+    # Gradient multiplier
+    multiplier: int = 5
+
+    # Cache for calculated colors
+    cache: Dict[float, Tuple[int, int, int, float]] = field(default_factory=dict)
+
+
+# ============================================================================
+# Module Path Analysis
+# ============================================================================
+
+def get_python_path_info():
+    """Get information about Python installation paths for module extraction.
+
+    Returns:
+        dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries.
+    """
+    info = {
+        'stdlib': None,
+        'site_packages': [],
+        'sys_path': []
+    }
+
+    # Get standard library path from os module location
+    try:
+        if hasattr(os, '__file__') and os.__file__:
+            info['stdlib'] = Path(os.__file__).parent
+    except (AttributeError, OSError):
+        pass  # Silently continue if we can't determine stdlib path
+
+    # Get site-packages directories
+    site_packages = []
+    try:
+        site_packages.extend(Path(p) for p in site.getsitepackages())
+    except (AttributeError, OSError):
+        pass  # Continue without site packages if unavailable
+
+    # Get user site-packages
+    try:
+        user_site = site.getusersitepackages()
+        if user_site and Path(user_site).exists():
+            site_packages.append(Path(user_site))
+    except (AttributeError, OSError):
+        pass  # Continue without user site packages
+
+    info['site_packages'] = site_packages
+    info['sys_path'] = [Path(p) for p in sys.path if p]
+
+    return info
+
+
+def extract_module_name(filename, path_info):
+    """Extract Python module name and type from file path.
+
+    Args:
+        filename: Path to the Python file
+        path_info: Dictionary from get_python_path_info()
+
+    Returns:
+        tuple: (module_name, module_type) where module_type is one of:
+               'stdlib', 'site-packages', 'project', or 'other'
+    """
+    if not filename:
+        return ('unknown', 'other')
+
+    try:
+        file_path = Path(filename)
+    except (ValueError, OSError):
+        return (str(filename), 'other')
+
+    # Check if it's in stdlib
+    if path_info['stdlib'] and _is_subpath(file_path, path_info['stdlib']):
+        try:
+            rel_path = file_path.relative_to(path_info['stdlib'])
+            return (_path_to_module(rel_path), 'stdlib')
+        except ValueError:
+            pass
+
+    # Check site-packages
+    for site_pkg in path_info['site_packages']:
+        if _is_subpath(file_path, site_pkg):
+            try:
+                rel_path = file_path.relative_to(site_pkg)
+                return (_path_to_module(rel_path), 'site-packages')
+            except ValueError:
+                continue
+
+    # Check other sys.path entries (project files)
+    if not str(file_path).startswith(('<', '[')):  # Skip special files
+        for path_entry in path_info['sys_path']:
+            if _is_subpath(file_path, path_entry):
+                try:
+                    rel_path = file_path.relative_to(path_entry)
+                    return (_path_to_module(rel_path), 'project')
+                except ValueError:
+                    continue
+
+    # Fallback: just use the filename
+    return (_path_to_module(file_path), 'other')
+
+
+def _is_subpath(file_path, parent_path):
+    try:
+        file_path.relative_to(parent_path)
+        return True
+    except (ValueError, OSError):
+        return False
+
+
+def _path_to_module(path):
+    if isinstance(path, str):
+        path = Path(path)
+
+    # Remove .py extension
+    if path.suffix == '.py':
+        path = path.with_suffix('')
+
+    # Convert path separators to dots
+    parts = path.parts
+
+    # Handle __init__ files - they represent the package itself
+    if parts and parts[-1] == '__init__':
+        parts = parts[:-1]
+
+    return '.'.join(parts) if parts else path.stem
+
+
+# ============================================================================
+# Helper Classes
+# ============================================================================
+
+class _TemplateLoader:
+    """Loads and caches HTML/CSS/JS templates for heatmap generation."""
+
+    def __init__(self):
+        """Load all templates and assets once."""
+        self.index_template = None
+        self.file_template = None
+        self.index_css = None
+        self.index_js = None
+        self.file_css = None
+        self.file_js = None
+        self.logo_html = None
+
+        self._load_templates()
+
+    def _load_templates(self):
+        """Load all template files from _heatmap_assets."""
+        try:
+            template_dir = importlib.resources.files(__package__)
+            assets_dir = template_dir / "_heatmap_assets"
+
+            # Load HTML templates
+            self.index_template = (assets_dir / "heatmap_index_template.html").read_text(encoding="utf-8")
+            self.file_template = (assets_dir / "heatmap_pyfile_template.html").read_text(encoding="utf-8")
+
+            # Load CSS (same file used for both index and file pages)
+            css_content = get_combined_css("heatmap")
+            self.index_css = css_content
+            self.file_css = css_content
+
+            # Load JS
+            self.index_js = (assets_dir / "heatmap_index.js").read_text(encoding="utf-8")
+            self.file_js = (assets_dir / "heatmap.js").read_text(encoding="utf-8")
+
+            # Load Python logo
+            logo_dir = template_dir / "_assets"
+            try:
+                png_path = logo_dir / "python-logo-only.png"
+                b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii")
+                self.logo_html = f'<img src="data:image/png;base64,{b64_logo}" alt="Python logo" class="python-logo"/>'
+            except (FileNotFoundError, IOError) as e:
+                self.logo_html = '<div class="python-logo-placeholder"></div>'
+                print(f"Warning: Could not load Python logo: {e}")
+
+        except (FileNotFoundError, IOError) as e:
+            raise RuntimeError(f"Failed to load heatmap template files: {e}") from e
+
+
+class _TreeBuilder:
+    """Builds hierarchical tree structure from file statistics."""
+
+    @staticmethod
+    def build_file_tree(file_stats: List[FileStats]) -> Dict[str, TreeNode]:
+        """Build hierarchical tree grouped by module type, then by module structure.
+
+        Args:
+            file_stats: List of FileStats objects
+
+        Returns:
+            Dictionary mapping module types to their tree roots
+        """
+        # Group by module type first
+        type_groups = {'stdlib': [], 'site-packages': [], 'project': [], 'other': []}
+        for stat in file_stats:
+            type_groups[stat.module_type].append(stat)
+
+        # Build tree for each type
+        trees = {}
+        for module_type, stats in type_groups.items():
+            if not stats:
+                continue
+
+            root_node = TreeNode()
+
+            for stat in stats:
+                module_name = stat.module_name
+                parts = module_name.split('.')
+
+                # Navigate/create tree structure
+                current_node = root_node
+                for i, part in enumerate(parts):
+                    if i == len(parts) - 1:
+                        # Last part - store the file
+                        current_node.files.append(stat)
+                    else:
+                        # Intermediate part - create or navigate
+                        if part not in current_node.children:
+                            current_node.children[part] = TreeNode()
+                        current_node = current_node.children[part]
+
+            # Calculate aggregate stats for this type's tree
+            _TreeBuilder._calculate_node_stats(root_node)
+            trees[module_type] = root_node
+
+        return trees
+
+    @staticmethod
+    def _calculate_node_stats(node: TreeNode) -> Tuple[int, int]:
+        """Recursively calculate aggregate statistics for tree nodes.
+
+        Args:
+            node: TreeNode to calculate stats for
+
+        Returns:
+            Tuple of (total_samples, file_count)
+        """
+        total_samples = 0
+        file_count = 0
+
+        # Count files at this level
+        for file_stat in node.files:
+            total_samples += file_stat.total_samples
+            file_count += 1
+
+        # Recursively process children
+        for child in node.children.values():
+            child_samples, child_count = _TreeBuilder._calculate_node_stats(child)
+            total_samples += child_samples
+            file_count += child_count
+
+        node.samples = total_samples
+        node.count = file_count
+        return total_samples, file_count
+
+
+class _HtmlRenderer:
+    """Renders hierarchical tree structures as HTML."""
+
+    def __init__(self, file_index: Dict[str, str], color_gradient: ColorGradient,
+                 calculate_intensity_color_func):
+        """Initialize renderer with file index and color calculation function.
+
+        Args:
+            file_index: Mapping from filenames to HTML file names
+            color_gradient: ColorGradient configuration
+            calculate_intensity_color_func: Function to calculate colors
+        """
+        self.file_index = file_index
+        self.color_gradient = color_gradient
+        self.calculate_intensity_color = calculate_intensity_color_func
+        self.heatmap_bar_height = 16
+
+    def render_hierarchical_html(self, trees: Dict[str, TreeNode]) -> str:
+        """Build hierarchical HTML with type sections and collapsible module folders.
+
+        Args:
+            trees: Dictionary mapping module types to tree roots
+
+        Returns:
+            Complete HTML string for all sections
+        """
+        type_names = {
+            'stdlib': '📚 Standard Library',
+            'site-packages': '📦 Site Packages',
+            'project': '🏗️ Project Files',
+            'other': '📄 Other Files'
+        }
+
+        sections = []
+        for module_type in ['project', 'stdlib', 'site-packages', 'other']:
+            if module_type not in trees:
+                continue
+
+            tree = trees[module_type]
+
+            # Project starts expanded, others start collapsed
+            is_collapsed = module_type in {'stdlib', 'site-packages', 'other'}
+            icon = '▶' if is_collapsed else '▼'
+            content_style = ' style="display: none;"' if is_collapsed else ''
+
+            section_html = f'''
+<div class="type-section">
+  <div class="type-header" onclick="toggleTypeSection(this)">
+    <span class="type-icon">{icon}</span>
+    <span class="type-title">{type_names[module_type]}</span>
+    <span class="type-stats">({tree.count} files, {tree.samples:,} samples)</span>
+  </div>
+  <div class="type-content"{content_style}>
+'''
+
+            # Render root folders
+            root_folders = sorted(tree.children.items(),
+                                key=lambda x: x[1].samples, reverse=True)
+
+            for folder_name, folder_node in root_folders:
+                section_html += self._render_folder(folder_node, folder_name, level=1)
+
+            # Render root files (files not in any module)
+            if tree.files:
+                sorted_files = sorted(tree.files, key=lambda x: x.total_samples, reverse=True)
+                section_html += '    <div class="files-list">\n'
+                for stat in sorted_files:
+                    section_html += self._render_file_item(stat, indent='      ')
+                section_html += '    </div>\n'
+
+            section_html += '  </div>\n</div>\n'
+            sections.append(section_html)
+
+        return '\n'.join(sections)
+
+    def _render_folder(self, node: TreeNode, name: str, level: int = 1) -> str:
+        """Render a single folder node recursively.
+
+        Args:
+            node: TreeNode to render
+            name: Display name for the folder
+            level: Nesting level for indentation
+
+        Returns:
+            HTML string for this folder and its contents
+        """
+        indent = '  ' * level
+        parts = []
+
+        # Render folder header (collapsed by default)
+        parts.append(f'{indent}<div class="folder-node collapsed" data-level="{level}">')
+        parts.append(f'{indent}  <div class="folder-header" onclick="toggleFolder(this)">')
+        parts.append(f'{indent}    <span class="folder-icon">▶</span>')
+        parts.append(f'{indent}    <span class="folder-name">📁 {html.escape(name)}</span>')
+        parts.append(f'{indent}    <span class="folder-stats">({node.count} files, {node.samples:,} samples)</span>')
+        parts.append(f'{indent}  </div>')
+        parts.append(f'{indent}  <div class="folder-content" style="display: none;">')
+
+        # Render sub-folders sorted by sample count
+        subfolders = sorted(node.children.items(),
+                          key=lambda x: x[1].samples, reverse=True)
+
+        for subfolder_name, subfolder_node in subfolders:
+            parts.append(self._render_folder(subfolder_node, subfolder_name, level + 1))
+
+        # Render files in this folder
+        if node.files:
+            sorted_files = sorted(node.files, key=lambda x: x.total_samples, reverse=True)
+            parts.append(f'{indent}    <div class="files-list">')
+            for stat in sorted_files:
+                parts.append(self._render_file_item(stat, indent=f'{indent}      '))
+            parts.append(f'{indent}    </div>')
+
+        parts.append(f'{indent}  </div>')
+        parts.append(f'{indent}</div>')
+
+        return '\n'.join(parts)
+
+    def _render_file_item(self, stat: FileStats, indent: str = '') -> str:
+        """Render a single file item with heatmap bar.
+
+        Args:
+            stat: FileStats object
+            indent: Indentation string
+
+        Returns:
+            HTML string for file item
+        """
+        full_path = html.escape(stat.filename)
+        module_name = html.escape(stat.module_name)
+
+        intensity = stat.percentage / 100.0
+        r, g, b, alpha = self.calculate_intensity_color(intensity)
+        bg_color = f"rgba({r}, {g}, {b}, {alpha})"
+        bar_width = min(stat.percentage, 100)
+
+        html_file = self.file_index[stat.filename]
+
+        return (f'{indent}<div class="file-item">\n'
+                f'{indent}  <a href="{html_file}" class="file-link" title="{full_path}">📄 {module_name}</a>\n'
+                f'{indent}  <span class="file-samples">{stat.total_samples:,} samples</span>\n'
+                f'{indent}  <div class="heatmap-bar-container"><div class="heatmap-bar" style="width: {bar_width}px; background-color: {bg_color}; height: {self.heatmap_bar_height}px;"></div></div>\n'
+                f'{indent}</div>\n')
+
+
+# ============================================================================
+# Main Collector Class
+# ============================================================================
+
+class HeatmapCollector(StackTraceCollector):
+    """Collector that generates coverage.py-style heatmap HTML output with line intensity.
+
+    This collector creates detailed HTML reports showing which lines of code
+    were executed most frequently during profiling, similar to coverage.py
+    but showing execution "heat" rather than just coverage.
+    """
+
+    # File naming and formatting constants
+    FILE_INDEX_FORMAT = "file_{:04d}.html"
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the heatmap collector with data structures for analysis."""
+        super().__init__(*args, **kwargs)
+
+        # Sample counting data structures
+        self.line_samples = collections.Counter()
+        self.file_samples = collections.defaultdict(collections.Counter)
+        self.line_self_samples = collections.Counter()
+        self.file_self_samples = collections.defaultdict(collections.Counter)
+
+        # Call graph data structures for navigation
+        self.call_graph = collections.defaultdict(list)
+        self.callers_graph = collections.defaultdict(list)
+        self.function_definitions = {}
+
+        # Edge counting for call path analysis
+        self.edge_samples = collections.Counter()
+
+        # Statistics and metadata
+        self._total_samples = 0
+        self._path_info = get_python_path_info()
+        self.stats = {}
+
+        # Color gradient configuration
+        self._color_gradient = ColorGradient()
+
+        # Template loader (loads all templates once)
+        self._template_loader = _TemplateLoader()
+
+        # File index (populated during export)
+        self.file_index = {}
+
+    @property
+    def _color_cache(self):
+        """Compatibility property for accessing color cache."""
+        return self._color_gradient.cache
+
+    def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None, missed_samples=None, **kwargs):
+        """Set profiling statistics to include in heatmap output.
+
+        Args:
+            sample_interval_usec: Sampling interval in microseconds
+            duration_sec: Total profiling duration in seconds
+            sample_rate: Effective sampling rate
+            error_rate: Optional error rate during profiling
+            missed_samples: Optional percentage of missed samples
+            **kwargs: Additional statistics to include
+        """
+        self.stats = {
+            "sample_interval_usec": sample_interval_usec,
+            "duration_sec": duration_sec,
+            "sample_rate": sample_rate,
+            "error_rate": error_rate,
+            "missed_samples": missed_samples,
+            "python_version": sys.version,
+            "python_implementation": platform.python_implementation(),
+            "platform": platform.platform(),
+        }
+        self.stats.update(kwargs)
+
+    def process_frames(self, frames, thread_id):
+        """Process stack frames and count samples per line.
+
+        Args:
+            frames: List of frame tuples (filename, lineno, funcname)
+                    frames[0] is the leaf (top of stack, where execution is)
+            thread_id: Thread ID for this stack trace
+        """
+        self._total_samples += 1
+
+        # Count each line in the stack and build call graph
+        for i, frame_info in enumerate(frames):
+            filename, lineno, funcname = frame_info
+
+            if not self._is_valid_frame(filename, lineno):
+                continue
+
+            # frames[0] is the leaf - where execution is actually happening
+            is_leaf = (i == 0)
+            self._record_line_sample(filename, lineno, funcname, is_leaf=is_leaf)
+
+            # Build call graph for adjacent frames
+            if i + 1 < len(frames):
+                self._record_call_relationship(frames[i], frames[i + 1])
+
+    def _is_valid_frame(self, filename, lineno):
+        """Check if a frame should be included in the heatmap."""
+        # Skip internal or invalid files
+        if not filename or filename.startswith('<') or filename.startswith('['):
+            return False
+
+        # Skip invalid frames with corrupted filename data
+        if filename == "__init__" and lineno == 0:
+            return False
+
+        return True
+
+    def _record_line_sample(self, filename, lineno, funcname, is_leaf=False):
+        """Record a sample for a specific line."""
+        # Track cumulative samples (all occurrences in stack)
+        self.line_samples[(filename, lineno)] += 1
+        self.file_samples[filename][lineno] += 1
+
+        # Track self/leaf samples (only when at top of stack)
+        if is_leaf:
+            self.line_self_samples[(filename, lineno)] += 1
+            self.file_self_samples[filename][lineno] += 1
+
+        # Record function definition location
+        if funcname and (filename, funcname) not in self.function_definitions:
+            self.function_definitions[(filename, funcname)] = lineno
+
+    def _record_call_relationship(self, callee_frame, caller_frame):
+        """Record caller/callee relationship between adjacent frames."""
+        callee_filename, callee_lineno, callee_funcname = callee_frame
+        caller_filename, caller_lineno, caller_funcname = caller_frame
+
+        # Skip internal files for call graph
+        if callee_filename.startswith('<') or callee_filename.startswith('['):
+            return
+
+        # Get the callee's function definition line
+        callee_def_line = self.function_definitions.get(
+            (callee_filename, callee_funcname), callee_lineno
+        )
+
+        # Record caller -> callee relationship
+        caller_key = (caller_filename, caller_lineno)
+        callee_info = (callee_filename, callee_def_line, callee_funcname)
+        if callee_info not in self.call_graph[caller_key]:
+            self.call_graph[caller_key].append(callee_info)
+
+        # Record callee <- caller relationship
+        callee_key = (callee_filename, callee_def_line)
+        caller_info = (caller_filename, caller_lineno, caller_funcname)
+        if caller_info not in self.callers_graph[callee_key]:
+            self.callers_graph[callee_key].append(caller_info)
+
+        # Count this call edge for path analysis
+        edge_key = (caller_key, callee_key)
+        self.edge_samples[edge_key] += 1
+
+    def export(self, output_path):
+        """Export heatmap data as HTML files in a directory.
+
+        Args:
+            output_path: Path where to create the heatmap output directory
+        """
+        if not self.file_samples:
+            print("Warning: No heatmap data to export")
+            return
+
+        try:
+            output_dir = self._prepare_output_directory(output_path)
+            file_stats = self._calculate_file_stats()
+            self._create_file_index(file_stats)
+
+            # Generate individual file reports
+            self._generate_file_reports(output_dir, file_stats)
+
+            # Generate index page
+            self._generate_index_html(output_dir / 'index.html', file_stats)
+
+            self._print_export_summary(output_dir, file_stats)
+
+        except Exception as e:
+            print(f"Error: Failed to export heatmap: {e}")
+            raise
+
+    def _prepare_output_directory(self, output_path):
+        """Create output directory for heatmap files."""
+        output_dir = Path(output_path)
+        if output_dir.suffix == '.html':
+            output_dir = output_dir.with_suffix('')
+
+        try:
+            output_dir.mkdir(exist_ok=True, parents=True)
+        except (IOError, OSError) as e:
+            raise RuntimeError(f"Failed to create output directory {output_dir}: {e}") from e
+
+        return output_dir
+
+    def _create_file_index(self, file_stats: List[FileStats]):
+        """Create mapping from filenames to HTML file names."""
+        self.file_index = {
+            stat.filename: self.FILE_INDEX_FORMAT.format(i)
+            for i, stat in enumerate(file_stats)
+        }
+
+    def _generate_file_reports(self, output_dir, file_stats: List[FileStats]):
+        """Generate HTML report for each source file."""
+        for stat in file_stats:
+            file_path = output_dir / self.file_index[stat.filename]
+            line_counts = self.file_samples[stat.filename]
+            valid_line_counts = {line: count for line, count in line_counts.items() if line >= 0}
+
+            self_counts = self.file_self_samples.get(stat.filename, {})
+            valid_self_counts = {line: count for line, count in self_counts.items() if line >= 0}
+
+            self._generate_file_html(
+                file_path,
+                stat.filename,
+                valid_line_counts,
+                valid_self_counts,
+                stat
+            )
+
+    def _print_export_summary(self, output_dir, file_stats: List[FileStats]):
+        """Print summary of exported heatmap."""
+        print(f"Heatmap output written to {output_dir}/")
+        print(f"  - Index: {output_dir / 'index.html'}")
+        print(f"  - {len(file_stats)} source file(s) analyzed")
+
+    def _calculate_file_stats(self) -> List[FileStats]:
+        """Calculate statistics for each file.
+
+        Returns:
+            List of FileStats objects sorted by total samples
+        """
+        file_stats = []
+        for filename, line_counts in self.file_samples.items():
+            # Skip special frames
+            if filename in ('~', '...', '.') or filename.startswith('<') or filename.startswith('['):
+                continue
+
+            # Filter out lines with -1 (special frames)
+            valid_line_counts = {line: count for line, count in line_counts.items() if line >= 0}
+            if not valid_line_counts:
+                continue
+
+            # Get self samples for this file
+            self_line_counts = self.file_self_samples.get(filename, {})
+            valid_self_counts = {line: count for line, count in self_line_counts.items() if line >= 0}
+
+            total_samples = sum(valid_line_counts.values())
+            total_self_samples = sum(valid_self_counts.values())
+            num_lines = len(valid_line_counts)
+            max_samples = max(valid_line_counts.values())
+            max_self_samples = max(valid_self_counts.values()) if valid_self_counts else 0
+            module_name, module_type = extract_module_name(filename, self._path_info)
+
+            file_stats.append(FileStats(
+                filename=filename,
+                module_name=module_name,
+                module_type=module_type,
+                total_samples=total_samples,
+                total_self_samples=total_self_samples,
+                num_lines=num_lines,
+                max_samples=max_samples,
+                max_self_samples=max_self_samples,
+                percentage=0.0
+            ))
+
+        # Sort by total samples and calculate percentages
+        file_stats.sort(key=lambda x: x.total_samples, reverse=True)
+        if file_stats:
+            max_total = file_stats[0].total_samples
+            for stat in file_stats:
+                stat.percentage = (stat.total_samples / max_total * 100) if max_total > 0 else 0
+
+        return file_stats
+
+    def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]):
+        """Generate index.html with list of all profiled files."""
+        # Build hierarchical tree
+        tree = _TreeBuilder.build_file_tree(file_stats)
+
+        # Render tree as HTML
+        renderer = _HtmlRenderer(self.file_index, self._color_gradient,
+                                self._calculate_intensity_color)
+        sections_html = renderer.render_hierarchical_html(tree)
+
+        # Format error rate and missed samples with bar classes
+        error_rate = self.stats.get('error_rate')
+        if error_rate is not None:
+            error_rate_str = f"{error_rate:.1f}%"
+            error_rate_width = min(error_rate, 100)
+            # Determine bar color class based on rate
+            if error_rate < 5:
+                error_rate_class = "good"
+            elif error_rate < 15:
+                error_rate_class = "warning"
+            else:
+                error_rate_class = "error"
+        else:
+            error_rate_str = "N/A"
+            error_rate_width = 0
+            error_rate_class = "good"
+
+        missed_samples = self.stats.get('missed_samples')
+        if missed_samples is not None:
+            missed_samples_str = f"{missed_samples:.1f}%"
+            missed_samples_width = min(missed_samples, 100)
+            if missed_samples < 5:
+                missed_samples_class = "good"
+            elif missed_samples < 15:
+                missed_samples_class = "warning"
+            else:
+                missed_samples_class = "error"
+        else:
+            missed_samples_str = "N/A"
+            missed_samples_width = 0
+            missed_samples_class = "good"
+
+        # Populate template
+        replacements = {
+            "<!-- INLINE_CSS -->": f"<style>\n{self._template_loader.index_css}\n</style>",
+            "<!-- INLINE_JS -->": f"<script>\n{self._template_loader.index_js}\n</script>",
+            "<!-- PYTHON_LOGO -->": self._template_loader.logo_html,
+            "<!-- NUM_FILES -->": str(len(file_stats)),
+            "<!-- TOTAL_SAMPLES -->": f"{self._total_samples:,}",
+            "<!-- DURATION -->": f"{self.stats.get('duration_sec', 0):.1f}s",
+            "<!-- SAMPLE_RATE -->": f"{self.stats.get('sample_rate', 0):.1f}",
+            "<!-- ERROR_RATE -->": error_rate_str,
+            "<!-- ERROR_RATE_WIDTH -->": str(error_rate_width),
+            "<!-- ERROR_RATE_CLASS -->": error_rate_class,
+            "<!-- MISSED_SAMPLES -->": missed_samples_str,
+            "<!-- MISSED_SAMPLES_WIDTH -->": str(missed_samples_width),
+            "<!-- MISSED_SAMPLES_CLASS -->": missed_samples_class,
+            "<!-- SECTIONS_HTML -->": sections_html,
+        }
+
+        html_content = self._template_loader.index_template
+        for placeholder, value in replacements.items():
+            html_content = html_content.replace(placeholder, value)
+
+        try:
+            index_path.write_text(html_content, encoding='utf-8')
+        except (IOError, OSError) as e:
+            raise RuntimeError(f"Failed to write index file {index_path}: {e}") from e
+
+    def _calculate_intensity_color(self, intensity: float) -> Tuple[int, int, int, float]:
+        """Calculate RGB color and alpha for given intensity (0-1 range).
+
+        Returns (r, g, b, alpha) tuple representing the heatmap color gradient:
+        blue -> green -> yellow -> orange -> red
+
+        Results are cached to improve performance.
+        """
+        # Round to 3 decimal places for cache key
+        cache_key = round(intensity, 3)
+        if cache_key in self._color_gradient.cache:
+            return self._color_gradient.cache[cache_key]
+
+        gradient = self._color_gradient
+        m = gradient.multiplier
+
+        # Color stops with (threshold, rgb_func, alpha_func)
+        stops = [
+            (gradient.stop_1,
+             lambda i: (0, int(150 * i * m), 255),
+             lambda i: gradient.alpha_very_cold),
+            (gradient.stop_2,
+             lambda i: (0, 255, int(255 * (1 - (i - gradient.stop_1) * m))),
+             lambda i: gradient.alpha_cold),
+            (gradient.stop_3,
+             lambda i: (int(255 * (i - gradient.stop_2) * m), 255, 0),
+             lambda i: gradient.alpha_medium),
+            (gradient.stop_4,
+             lambda i: (255, int(200 - 100 * (i - gradient.stop_3) * m), 0),
+             lambda i: gradient.alpha_warm),
+            (gradient.stop_5,
+             lambda i: (255, int(100 * (1 - (i - gradient.stop_4) * m)), 0),
+             lambda i: gradient.alpha_hot_base + gradient.alpha_hot_range * (i - gradient.stop_4) * m),
+        ]
+
+        result = None
+        for threshold, rgb_func, alpha_func in stops:
+            if intensity < threshold or threshold == gradient.stop_5:
+                r, g, b = rgb_func(intensity)
+                result = (r, g, b, alpha_func(intensity))
+                break
+
+        # Fallback
+        if result is None:
+            result = (255, 0, 0, 0.75)
+
+        # Cache the result
+        self._color_gradient.cache[cache_key] = result
+        return result
+
+    def _generate_file_html(self, output_path: Path, filename: str,
+                          line_counts: Dict[int, int], self_counts: Dict[int, int],
+                          file_stat: FileStats):
+        """Generate HTML for a single source file with heatmap coloring."""
+        # Read source file
+        try:
+            source_lines = Path(filename).read_text(encoding='utf-8', errors='replace').splitlines()
+        except (IOError, OSError) as e:
+            if not (filename.startswith('<') or filename.startswith('[') or
+                    filename in ('~', '...', '.') or len(filename) < 2):
+                print(f"Warning: Could not read source file {filename}: {e}")
+            source_lines = [f"# Source file not available: {filename}"]
+
+        # Generate HTML for each line
+        max_samples = max(line_counts.values()) if line_counts else 1
+        max_self_samples = max(self_counts.values()) if self_counts else 1
+        code_lines_html = [
+            self._build_line_html(line_num, line_content, line_counts, self_counts,
+                                max_samples, max_self_samples, filename)
+            for line_num, line_content in enumerate(source_lines, start=1)
+        ]
+
+        # Populate template
+        replacements = {
+            "<!-- FILENAME -->": html.escape(filename),
+            "<!-- TOTAL_SAMPLES -->": f"{file_stat.total_samples:,}",
+            "<!-- TOTAL_SELF_SAMPLES -->": f"{file_stat.total_self_samples:,}",
+            "<!-- NUM_LINES -->": str(file_stat.num_lines),
+            "<!-- PERCENTAGE -->": f"{file_stat.percentage:.2f}",
+            "<!-- MAX_SAMPLES -->": str(file_stat.max_samples),
+            "<!-- MAX_SELF_SAMPLES -->": str(file_stat.max_self_samples),
+            "<!-- CODE_LINES -->": ''.join(code_lines_html),
+            "<!-- INLINE_CSS -->": f"<style>\n{self._template_loader.file_css}\n</style>",
+            "<!-- INLINE_JS -->": f"<script>\n{self._template_loader.file_js}\n</script>",
+        }
+
+        html_content = self._template_loader.file_template
+        for placeholder, value in replacements.items():
+            html_content = html_content.replace(placeholder, value)
+
+        try:
+            output_path.write_text(html_content, encoding='utf-8')
+        except (IOError, OSError) as e:
+            raise RuntimeError(f"Failed to write file {output_path}: {e}") from e
+
+    def _build_line_html(self, line_num: int, line_content: str,
+                        line_counts: Dict[int, int], self_counts: Dict[int, int],
+                        max_samples: int, max_self_samples: int, filename: str) -> str:
+        """Build HTML for a single line of source code."""
+        cumulative_samples = line_counts.get(line_num, 0)
+        self_samples = self_counts.get(line_num, 0)
+
+        # Calculate colors for both self and cumulative modes
+        if cumulative_samples > 0:
+            cumulative_intensity = cumulative_samples / max_samples if max_samples > 0 else 0
+            self_intensity = self_samples / max_self_samples if max_self_samples > 0 and self_samples > 0 else 0
+
+            # Default to self-based coloring
+            intensity = self_intensity if self_samples > 0 else cumulative_intensity
+            r, g, b, alpha = self._calculate_intensity_color(intensity)
+            bg_color = f"rgba({r}, {g}, {b}, {alpha})"
+
+            # Pre-calculate colors for both modes (for JS toggle)
+            self_bg_color = self._format_color_for_intensity(self_intensity) if self_samples > 0 else "transparent"
+            cumulative_bg_color = self._format_color_for_intensity(cumulative_intensity)
+
+            self_display = f"{self_samples:,}" if self_samples > 0 else ""
+            cumulative_display = f"{cumulative_samples:,}"
+            tooltip = f"Self: {self_samples:,}, Total: {cumulative_samples:,}"
+        else:
+            bg_color = "transparent"
+            self_bg_color = "transparent"
+            cumulative_bg_color = "transparent"
+            self_display = ""
+            cumulative_display = ""
+            tooltip = ""
+
+        # Get navigation buttons
+        nav_buttons_html = self._build_navigation_buttons(filename, line_num)
+
+        # Build line HTML
+        line_html = html.escape(line_content.rstrip('\n'))
+        title_attr = f' title="{html.escape(tooltip)}"' if tooltip else ""
+
+        return (
+            f'        <div class="code-line" data-bg-color="{bg_color}" '
+            f'data-self-color="{self_bg_color}" data-cumulative-color="{cumulative_bg_color}" '
+            f'id="line-{line_num}"{title_attr}>\n'
+            f'            <div class="line-number">{line_num}</div>\n'
+            f'            <div class="line-samples-self">{self_display}</div>\n'
+            f'            <div class="line-samples-cumulative">{cumulative_display}</div>\n'
+            f'            <div class="line-content">{line_html}</div>\n'
+            f'            {nav_buttons_html}\n'
+            f'        </div>\n'
+        )
+
+    def _format_color_for_intensity(self, intensity: float) -> str:
+        """Format color as rgba() string for given intensity."""
+        r, g, b, alpha = self._calculate_intensity_color(intensity)
+        return f"rgba({r}, {g}, {b}, {alpha})"
+
+    def _build_navigation_buttons(self, filename: str, line_num: int) -> str:
+        """Build navigation buttons for callers/callees."""
+        line_key = (filename, line_num)
+        caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, []))
+        callee_list = self._deduplicate_by_function(self.call_graph.get(line_key, []))
+
+        # Get edge counts for each caller/callee
+        callers_with_counts = self._get_edge_counts(line_key, caller_list, is_caller=True)
+        callees_with_counts = self._get_edge_counts(line_key, callee_list, is_caller=False)
+
+        # Build navigation buttons with counts
+        caller_btn = self._create_navigation_button(callers_with_counts, 'caller', '▲')
+        callee_btn = self._create_navigation_button(callees_with_counts, 'callee', '▼')
+
+        if caller_btn or callee_btn:
+            return f'<div class="line-nav-buttons">{caller_btn}{callee_btn}</div>'
+        return ''
+
+    def _get_edge_counts(self, line_key: Tuple[str, int],
+                        items: List[Tuple[str, int, str]],
+                        is_caller: bool) -> List[Tuple[str, int, str, int]]:
+        """Get sample counts for each caller/callee edge."""
+        result = []
+        for file, line, func in items:
+            edge_line_key = (file, line)
+            if is_caller:
+                edge_key = (edge_line_key, line_key)
+            else:
+                edge_key = (line_key, edge_line_key)
+
+            count = self.edge_samples.get(edge_key, 0)
+            result.append((file, line, func, count))
+
+        result.sort(key=lambda x: x[3], reverse=True)
+        return result
+
+    def _deduplicate_by_function(self, items: List[Tuple[str, int, str]]) -> List[Tuple[str, int, str]]:
+        """Remove duplicate entries based on (file, function) key."""
+        seen = {}
+        result = []
+        for file, line, func in items:
+            key = (file, func)
+            if key not in seen:
+                seen[key] = True
+                result.append((file, line, func))
+        return result
+
+    def _create_navigation_button(self, items_with_counts: List[Tuple[str, int, str, int]],
+                                 btn_class: str, arrow: str) -> str:
+        """Create HTML for a navigation button with sample counts."""
+        # Filter valid items
+        valid_items = [(f, l, fn, cnt) for f, l, fn, cnt in items_with_counts
+                      if f in self.file_index and l > 0]
+        if not valid_items:
+            return ""
+
+        if len(valid_items) == 1:
+            file, line, func, count = valid_items[0]
+            target_html = self.file_index[file]
+            nav_data = json.dumps({'link': f"{target_html}#line-{line}", 'func': func})
+            title = f"Go to {btn_class}: {html.escape(func)} ({count:,} samples)"
+            return f'<button class="nav-btn {btn_class}" data-nav=\'{html.escape(nav_data)}\' title="{title}">{arrow}</button>'
+
+        # Multiple items - create menu
+        total_samples = sum(cnt for _, _, _, cnt in valid_items)
+        items_data = [
+            {
+                'file': os.path.basename(file),
+                'func': func,
+                'count': count,
+                'link': f"{self.file_index[file]}#line-{line}"
+            }
+            for file, line, func, count in valid_items
+        ]
+        items_json = html.escape(json.dumps(items_data))
+        title = f"{len(items_data)} {btn_class}s ({total_samples:,} samples)"
+        return f'<button class="nav-btn {btn_class}" data-nav-multi=\'{items_json}\' title="{title}">{arrow}</button>'
index 88d9a4fa13baf957ca4020d3cca7b98e8ee5f71f..46fc1a05afaa7452af34cfc1a78a7bd9fadcbb15 100644 (file)
@@ -10,6 +10,7 @@ from _colorize import ANSIColors
 
 from .pstats_collector import PstatsCollector
 from .stack_collector import CollapsedStackCollector, FlamegraphCollector
+from .heatmap_collector import HeatmapCollector
 from .gecko_collector import GeckoCollector
 from .constants import (
     PROFILING_MODE_WALL,
@@ -25,7 +26,6 @@ except ImportError:
 _FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None
 
 
-
 class SampleProfiler:
     def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MODE_WALL, native=False, gc=True, skip_non_matching_threads=True):
         self.pid = pid
index 146a058a03ac1454299c7294f59bfeb6c36ca365..e26536093130d12e65b53eea364ea09bb88fc56c 100644 (file)
@@ -6,6 +6,7 @@ import json
 import linecache
 import os
 
+from ._css_utils import get_combined_css
 from .collector import Collector
 from .string_table import StringTable
 
@@ -331,9 +332,9 @@ class FlamegraphCollector(StackTraceCollector):
         fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js"
         fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js"
 
-        html_template = (template_dir / "flamegraph_template.html").read_text(encoding="utf-8")
-        css_content = (template_dir / "flamegraph.css").read_text(encoding="utf-8")
-        js_content = (template_dir / "flamegraph.js").read_text(encoding="utf-8")
+        html_template = (template_dir / "_flamegraph_assets" / "flamegraph_template.html").read_text(encoding="utf-8")
+        css_content = get_combined_css("flamegraph")
+        js_content = (template_dir /  "_flamegraph_assets" / "flamegraph.js").read_text(encoding="utf-8")
 
         # Inline first-party CSS/JS
         html_template = html_template.replace(
diff --git a/Lib/test/test_profiling/test_heatmap.py b/Lib/test/test_profiling/test_heatmap.py
new file mode 100644 (file)
index 0000000..a6ff3b8
--- /dev/null
@@ -0,0 +1,653 @@
+"""Tests for the heatmap collector (profiling.sampling)."""
+
+import os
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+from profiling.sampling.heatmap_collector import (
+    HeatmapCollector,
+    get_python_path_info,
+    extract_module_name,
+)
+
+from test.support import captured_stdout, captured_stderr
+
+
+# =============================================================================
+# Unit Tests for Public Helper Functions
+# =============================================================================
+
+class TestPathInfoFunctions(unittest.TestCase):
+    """Test public helper functions for path information."""
+
+    def test_get_python_path_info_returns_dict(self):
+        """Test that get_python_path_info returns a dictionary with expected keys."""
+        path_info = get_python_path_info()
+
+        self.assertIsInstance(path_info, dict)
+        self.assertIn('stdlib', path_info)
+        self.assertIn('site_packages', path_info)
+        self.assertIn('sys_path', path_info)
+
+    def test_get_python_path_info_stdlib_is_path_or_none(self):
+        """Test that stdlib is either a Path object or None."""
+        path_info = get_python_path_info()
+
+        if path_info['stdlib'] is not None:
+            self.assertIsInstance(path_info['stdlib'], Path)
+
+    def test_get_python_path_info_site_packages_is_list(self):
+        """Test that site_packages is a list."""
+        path_info = get_python_path_info()
+
+        self.assertIsInstance(path_info['site_packages'], list)
+        for item in path_info['site_packages']:
+            self.assertIsInstance(item, Path)
+
+    def test_get_python_path_info_sys_path_is_list(self):
+        """Test that sys_path is a list of Path objects."""
+        path_info = get_python_path_info()
+
+        self.assertIsInstance(path_info['sys_path'], list)
+        for item in path_info['sys_path']:
+            self.assertIsInstance(item, Path)
+
+    def test_extract_module_name_with_none(self):
+        """Test extract_module_name with None filename."""
+        path_info = get_python_path_info()
+        module_name, module_type = extract_module_name(None, path_info)
+
+        self.assertEqual(module_name, 'unknown')
+        self.assertEqual(module_type, 'other')
+
+    def test_extract_module_name_with_empty_string(self):
+        """Test extract_module_name with empty filename."""
+        path_info = get_python_path_info()
+        module_name, module_type = extract_module_name('', path_info)
+
+        self.assertEqual(module_name, 'unknown')
+        self.assertEqual(module_type, 'other')
+
+    def test_extract_module_name_with_stdlib_file(self):
+        """Test extract_module_name with a standard library file."""
+        path_info = get_python_path_info()
+
+        # Use os module as a known stdlib file
+        if path_info['stdlib']:
+            stdlib_file = str(path_info['stdlib'] / 'os.py')
+            module_name, module_type = extract_module_name(stdlib_file, path_info)
+
+            self.assertEqual(module_type, 'stdlib')
+            self.assertIn('os', module_name)
+
+    def test_extract_module_name_with_project_file(self):
+        """Test extract_module_name with a project file."""
+        path_info = get_python_path_info()
+
+        # Create a mock project file path
+        if path_info['sys_path']:
+            # Use current directory as project path
+            project_file = '/some/project/path/mymodule.py'
+            module_name, module_type = extract_module_name(project_file, path_info)
+
+            # Should classify as 'other' if not in sys.path
+            self.assertIn(module_type, ['project', 'other'])
+
+    def test_extract_module_name_removes_py_extension(self):
+        """Test that .py extension is removed from module names."""
+        path_info = get_python_path_info()
+
+        # Test with a simple .py file
+        module_name, module_type = extract_module_name('/path/to/test.py', path_info)
+
+        # Module name should not contain .py
+        self.assertNotIn('.py', module_name)
+
+    def test_extract_module_name_with_special_files(self):
+        """Test extract_module_name with special filenames like <string>."""
+        path_info = get_python_path_info()
+
+        special_files = ['<string>', '<stdin>', '[eval]']
+        for special_file in special_files:
+            module_name, module_type = extract_module_name(special_file, path_info)
+            self.assertEqual(module_type, 'other')
+
+
+# =============================================================================
+# Unit Tests for HeatmapCollector Public API
+# =============================================================================
+
+class TestHeatmapCollectorInit(unittest.TestCase):
+    """Test HeatmapCollector initialization."""
+
+    def test_init_creates_empty_data_structures(self):
+        """Test that __init__ creates empty data structures."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # Check that data structures are initialized
+        self.assertIsInstance(collector.line_samples, dict)
+        self.assertIsInstance(collector.file_samples, dict)
+        self.assertIsInstance(collector.line_self_samples, dict)
+        self.assertIsInstance(collector.file_self_samples, dict)
+        self.assertIsInstance(collector.call_graph, dict)
+        self.assertIsInstance(collector.callers_graph, dict)
+        self.assertIsInstance(collector.function_definitions, dict)
+        self.assertIsInstance(collector.edge_samples, dict)
+
+        # Check that they're empty
+        self.assertEqual(len(collector.line_samples), 0)
+        self.assertEqual(len(collector.file_samples), 0)
+        self.assertEqual(len(collector.line_self_samples), 0)
+        self.assertEqual(len(collector.file_self_samples), 0)
+
+    def test_init_sets_total_samples_to_zero(self):
+        """Test that total samples starts at zero."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+        self.assertEqual(collector._total_samples, 0)
+
+    def test_init_creates_color_cache(self):
+        """Test that color cache is initialized."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+        self.assertIsInstance(collector._color_cache, dict)
+        self.assertEqual(len(collector._color_cache), 0)
+
+    def test_init_gets_path_info(self):
+        """Test that path info is retrieved during init."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+        self.assertIsNotNone(collector._path_info)
+        self.assertIn('stdlib', collector._path_info)
+
+
+class TestHeatmapCollectorSetStats(unittest.TestCase):
+    """Test HeatmapCollector.set_stats() method."""
+
+    def test_set_stats_stores_all_parameters(self):
+        """Test that set_stats stores all provided parameters."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        collector.set_stats(
+            sample_interval_usec=500,
+            duration_sec=10.5,
+            sample_rate=99.5,
+            error_rate=0.5
+        )
+
+        self.assertEqual(collector.stats['sample_interval_usec'], 500)
+        self.assertEqual(collector.stats['duration_sec'], 10.5)
+        self.assertEqual(collector.stats['sample_rate'], 99.5)
+        self.assertEqual(collector.stats['error_rate'], 0.5)
+
+    def test_set_stats_includes_system_info(self):
+        """Test that set_stats includes Python and platform info."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+        collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
+
+        self.assertIn('python_version', collector.stats)
+        self.assertIn('python_implementation', collector.stats)
+        self.assertIn('platform', collector.stats)
+
+    def test_set_stats_accepts_kwargs(self):
+        """Test that set_stats accepts additional kwargs."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        collector.set_stats(
+            sample_interval_usec=100,
+            duration_sec=1.0,
+            sample_rate=100.0,
+            custom_key='custom_value',
+            another_key=42
+        )
+
+        self.assertEqual(collector.stats['custom_key'], 'custom_value')
+        self.assertEqual(collector.stats['another_key'], 42)
+
+    def test_set_stats_with_none_error_rate(self):
+        """Test set_stats with error_rate=None."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+        collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
+
+        self.assertIn('error_rate', collector.stats)
+        self.assertIsNone(collector.stats['error_rate'])
+
+
+class TestHeatmapCollectorProcessFrames(unittest.TestCase):
+    """Test HeatmapCollector.process_frames() method."""
+
+    def test_process_frames_increments_total_samples(self):
+        """Test that process_frames increments total samples count."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        initial_count = collector._total_samples
+        frames = [('file.py', 10, 'func')]
+        collector.process_frames(frames, thread_id=1)
+
+        self.assertEqual(collector._total_samples, initial_count + 1)
+
+    def test_process_frames_records_line_samples(self):
+        """Test that process_frames records line samples."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('test.py', 5, 'test_func')]
+        collector.process_frames(frames, thread_id=1)
+
+        # Check that line was recorded
+        self.assertIn(('test.py', 5), collector.line_samples)
+        self.assertEqual(collector.line_samples[('test.py', 5)], 1)
+
+    def test_process_frames_records_multiple_lines_in_stack(self):
+        """Test that process_frames records all lines in a stack."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [
+            ('file1.py', 10, 'func1'),
+            ('file2.py', 20, 'func2'),
+            ('file3.py', 30, 'func3')
+        ]
+        collector.process_frames(frames, thread_id=1)
+
+        # All frames should be recorded
+        self.assertIn(('file1.py', 10), collector.line_samples)
+        self.assertIn(('file2.py', 20), collector.line_samples)
+        self.assertIn(('file3.py', 30), collector.line_samples)
+
+    def test_process_frames_distinguishes_self_samples(self):
+        """Test that process_frames distinguishes self (leaf) samples."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [
+            ('leaf.py', 5, 'leaf_func'),  # This is the leaf (top of stack)
+            ('caller.py', 10, 'caller_func')
+        ]
+        collector.process_frames(frames, thread_id=1)
+
+        # Leaf should have self sample
+        self.assertIn(('leaf.py', 5), collector.line_self_samples)
+        self.assertEqual(collector.line_self_samples[('leaf.py', 5)], 1)
+
+        # Caller should NOT have self sample
+        self.assertNotIn(('caller.py', 10), collector.line_self_samples)
+
+    def test_process_frames_accumulates_samples(self):
+        """Test that multiple calls accumulate samples."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('file.py', 10, 'func')]
+
+        collector.process_frames(frames, thread_id=1)
+        collector.process_frames(frames, thread_id=1)
+        collector.process_frames(frames, thread_id=1)
+
+        self.assertEqual(collector.line_samples[('file.py', 10)], 3)
+        self.assertEqual(collector._total_samples, 3)
+
+    def test_process_frames_ignores_invalid_frames(self):
+        """Test that process_frames ignores invalid frames."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # These should be ignored
+        invalid_frames = [
+            ('<string>', 1, 'test'),
+            ('[eval]', 1, 'test'),
+            ('', 1, 'test'),
+            (None, 1, 'test'),
+            ('__init__', 0, 'test'),  # Special invalid frame
+        ]
+
+        for frame in invalid_frames:
+            collector.process_frames([frame], thread_id=1)
+
+        # Should not record these invalid frames
+        for frame in invalid_frames:
+            if frame[0]:
+                self.assertNotIn((frame[0], frame[1]), collector.line_samples)
+
+    def test_process_frames_builds_call_graph(self):
+        """Test that process_frames builds call graph relationships."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [
+            ('callee.py', 5, 'callee_func'),
+            ('caller.py', 10, 'caller_func')
+        ]
+        collector.process_frames(frames, thread_id=1)
+
+        # Check that call relationship was recorded
+        caller_key = ('caller.py', 10)
+        self.assertIn(caller_key, collector.call_graph)
+
+        # Check callers graph
+        callee_key = ('callee.py', 5)
+        self.assertIn(callee_key, collector.callers_graph)
+
+    def test_process_frames_records_function_definitions(self):
+        """Test that process_frames records function definition locations."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('module.py', 42, 'my_function')]
+        collector.process_frames(frames, thread_id=1)
+
+        self.assertIn(('module.py', 'my_function'), collector.function_definitions)
+        self.assertEqual(collector.function_definitions[('module.py', 'my_function')], 42)
+
+    def test_process_frames_tracks_edge_samples(self):
+        """Test that process_frames tracks edge sample counts."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [
+            ('callee.py', 5, 'callee'),
+            ('caller.py', 10, 'caller')
+        ]
+
+        # Process same call stack multiple times
+        collector.process_frames(frames, thread_id=1)
+        collector.process_frames(frames, thread_id=1)
+
+        # Check that edge count is tracked
+        self.assertGreater(len(collector.edge_samples), 0)
+
+    def test_process_frames_handles_empty_frames(self):
+        """Test that process_frames handles empty frame list."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        initial_count = collector._total_samples
+        collector.process_frames([], thread_id=1)
+
+        # Should still increment total samples
+        self.assertEqual(collector._total_samples, initial_count + 1)
+
+    def test_process_frames_with_file_samples_dict(self):
+        """Test that file_samples dict is properly populated."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('test.py', 10, 'func')]
+        collector.process_frames(frames, thread_id=1)
+
+        self.assertIn('test.py', collector.file_samples)
+        self.assertIn(10, collector.file_samples['test.py'])
+        self.assertEqual(collector.file_samples['test.py'][10], 1)
+
+
+class TestHeatmapCollectorExport(unittest.TestCase):
+    """Test HeatmapCollector.export() method."""
+
+    def setUp(self):
+        """Set up test directory."""
+        self.test_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.test_dir)
+
+    def test_export_creates_output_directory(self):
+        """Test that export creates the output directory."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # Add some data
+        frames = [('test.py', 10, 'func')]
+        collector.process_frames(frames, thread_id=1)
+
+        output_path = os.path.join(self.test_dir, 'heatmap_output')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        self.assertTrue(os.path.exists(output_path))
+        self.assertTrue(os.path.isdir(output_path))
+
+    def test_export_creates_index_html(self):
+        """Test that export creates index.html."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('test.py', 10, 'func')]
+        collector.process_frames(frames, thread_id=1)
+
+        output_path = os.path.join(self.test_dir, 'heatmap_output')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        index_path = os.path.join(output_path, 'index.html')
+        self.assertTrue(os.path.exists(index_path))
+
+    def test_export_creates_file_htmls(self):
+        """Test that export creates individual file HTMLs."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('test.py', 10, 'func')]
+        collector.process_frames(frames, thread_id=1)
+
+        output_path = os.path.join(self.test_dir, 'heatmap_output')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        # Check for file_XXXX.html files
+        html_files = [f for f in os.listdir(output_path)
+                      if f.startswith('file_') and f.endswith('.html')]
+        self.assertGreater(len(html_files), 0)
+
+    def test_export_with_empty_data(self):
+        """Test export with no data collected."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        output_path = os.path.join(self.test_dir, 'empty_output')
+
+        # Should handle empty data gracefully
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+    def test_export_handles_html_suffix(self):
+        """Test that export handles .html suffix in output path."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        frames = [('test.py', 10, 'func')]
+        collector.process_frames(frames, thread_id=1)
+
+        # Path with .html suffix should be stripped
+        output_path = os.path.join(self.test_dir, 'output.html')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        # Should create directory without .html
+        expected_dir = os.path.join(self.test_dir, 'output')
+        self.assertTrue(os.path.exists(expected_dir))
+
+    def test_export_with_multiple_files(self):
+        """Test export with multiple files."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # Add samples for multiple files
+        collector.process_frames([('file1.py', 10, 'func1')], thread_id=1)
+        collector.process_frames([('file2.py', 20, 'func2')], thread_id=1)
+        collector.process_frames([('file3.py', 30, 'func3')], thread_id=1)
+
+        output_path = os.path.join(self.test_dir, 'multi_file')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        # Should create HTML for each file
+        html_files = [f for f in os.listdir(output_path)
+                      if f.startswith('file_') and f.endswith('.html')]
+        self.assertGreaterEqual(len(html_files), 3)
+
+    def test_export_index_contains_file_references(self):
+        """Test that index.html contains references to profiled files."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+        collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0)
+
+        frames = [('mytest.py', 10, 'my_func')]
+        collector.process_frames(frames, thread_id=1)
+
+        output_path = os.path.join(self.test_dir, 'test_output')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        index_path = os.path.join(output_path, 'index.html')
+        with open(index_path, 'r', encoding='utf-8') as f:
+            content = f.read()
+
+        # Should contain reference to the file
+        self.assertIn('mytest', content)
+
+    def test_export_file_html_has_line_numbers(self):
+        """Test that exported file HTML contains line numbers."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # Create a temporary Python file
+        temp_file = os.path.join(self.test_dir, 'temp_source.py')
+        with open(temp_file, 'w') as f:
+            f.write('def test():\n    pass\n')
+
+        frames = [(temp_file, 1, 'test')]
+        collector.process_frames(frames, thread_id=1)
+
+        output_path = os.path.join(self.test_dir, 'line_test')
+
+        with captured_stdout(), captured_stderr():
+            collector.export(output_path)
+
+        # Find the generated file HTML
+        html_files = [f for f in os.listdir(output_path)
+                      if f.startswith('file_') and f.endswith('.html')]
+
+        if html_files:
+            with open(os.path.join(output_path, html_files[0]), 'r', encoding='utf-8') as f:
+                content = f.read()
+
+            # Should have line-related content
+            self.assertIn('line-', content)
+
+
+class MockFrameInfo:
+    """Mock FrameInfo for testing since the real one isn't accessible."""
+
+    def __init__(self, filename, lineno, funcname):
+        self.filename = filename
+        self.lineno = lineno
+        self.funcname = funcname
+
+    def __repr__(self):
+        return f"MockFrameInfo(filename='{self.filename}', lineno={self.lineno}, funcname='{self.funcname}')"
+
+
+class MockThreadInfo:
+    """Mock ThreadInfo for testing since the real one isn't accessible."""
+
+    def __init__(self, thread_id, frame_info):
+        self.thread_id = thread_id
+        self.frame_info = frame_info
+
+    def __repr__(self):
+        return f"MockThreadInfo(thread_id={self.thread_id}, frame_info={self.frame_info})"
+
+
+class MockInterpreterInfo:
+    """Mock InterpreterInfo for testing since the real one isn't accessible."""
+
+    def __init__(self, interpreter_id, threads):
+        self.interpreter_id = interpreter_id
+        self.threads = threads
+
+    def __repr__(self):
+        return f"MockInterpreterInfo(interpreter_id={self.interpreter_id}, threads={self.threads})"
+
+
+class TestHeatmapCollector(unittest.TestCase):
+    """Tests for HeatmapCollector functionality."""
+
+    def test_heatmap_collector_basic(self):
+        """Test basic HeatmapCollector functionality."""
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # Test empty state
+        self.assertEqual(len(collector.file_samples), 0)
+        self.assertEqual(len(collector.line_samples), 0)
+
+        # Test collecting sample data
+        test_frames = [
+            MockInterpreterInfo(
+                0,
+                [MockThreadInfo(
+                    1,
+                    [("file.py", 10, "func1"), ("file.py", 20, "func2")],
+                )]
+            )
+        ]
+        collector.collect(test_frames)
+
+        # Should have recorded samples for the file
+        self.assertGreater(len(collector.line_samples), 0)
+        self.assertIn("file.py", collector.file_samples)
+
+        # Check that line samples were recorded
+        file_data = collector.file_samples["file.py"]
+        self.assertGreater(len(file_data), 0)
+
+    def test_heatmap_collector_export(self):
+        """Test heatmap HTML export functionality."""
+        heatmap_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, heatmap_dir)
+
+        collector = HeatmapCollector(sample_interval_usec=100)
+
+        # Create test data with multiple files
+        test_frames1 = [
+            MockInterpreterInfo(
+                0,
+                [MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
+            )
+        ]
+        test_frames2 = [
+            MockInterpreterInfo(
+                0,
+                [MockThreadInfo(1, [("file.py", 10, "func1"), ("file.py", 20, "func2")])],
+            )
+        ]  # Same stack
+        test_frames3 = [
+            MockInterpreterInfo(0, [MockThreadInfo(1, [("other.py", 5, "other_func")])])
+        ]
+
+        collector.collect(test_frames1)
+        collector.collect(test_frames2)
+        collector.collect(test_frames3)
+
+        # Export heatmap
+        with (captured_stdout(), captured_stderr()):
+            collector.export(heatmap_dir)
+
+        # Verify index.html was created
+        index_path = os.path.join(heatmap_dir, "index.html")
+        self.assertTrue(os.path.exists(index_path))
+        self.assertGreater(os.path.getsize(index_path), 0)
+
+        # Check index contains HTML content
+        with open(index_path, "r", encoding="utf-8") as f:
+            content = f.read()
+
+        # Should be valid HTML
+        self.assertIn("<!doctype html>", content.lower())
+        self.assertIn("<html", content)
+        self.assertIn("Tachyon Profiler", content)
+
+        # Should contain file references
+        self.assertIn("file.py", content)
+        self.assertIn("other.py", content)
+
+        # Verify individual file HTMLs were created
+        file_htmls = [f for f in os.listdir(heatmap_dir) if f.startswith("file_") and f.endswith(".html")]
+        self.assertGreater(len(file_htmls), 0)
+
+        # Check one of the file HTMLs
+        file_html_path = os.path.join(heatmap_dir, file_htmls[0])
+        with open(file_html_path, "r", encoding="utf-8") as f:
+            file_content = f.read()
+
+        # Should contain heatmap styling and JavaScript
+        self.assertIn("line-sample", file_content)
+        self.assertIn("nav-btn", file_content)
+
+
+if __name__ == "__main__":
+    unittest.main()
index 816080faa1f5c351943b36ac05ccecd295ae9f0b..086adbdf262c489dd1acf9dd816e2ea5609c5371 100644 (file)
@@ -2578,6 +2578,9 @@ LIBSUBDIRS=       asyncio \
                profile \
                profiling profiling/sampling profiling/tracing \
                profiling/sampling/_assets \
+               profiling/sampling/_heatmap_assets \
+               profiling/sampling/_flamegraph_assets \
+               profiling/sampling/_shared_assets \
                profiling/sampling/live_collector \
                profiling/sampling/_vendor/d3/7.8.5 \
                profiling/sampling/_vendor/d3-flame-graph/4.1.3 \
diff --git a/Misc/NEWS.d/next/Library/2025-10-27-17-00-11.gh-issue-140677.hM9pTq.rst b/Misc/NEWS.d/next/Library/2025-10-27-17-00-11.gh-issue-140677.hM9pTq.rst
new file mode 100644 (file)
index 0000000..2daa15e
--- /dev/null
@@ -0,0 +1,4 @@
+Add heatmap visualization mode to the Tachyon sampling profiler. The new
+``--heatmap`` output format provides a line-by-line view showing execution
+intensity with color-coded samples, inline statistics, and interactive call
+graph navigation between callers and callees.