# 4. Editor Heatmap Window Part of [[Playtest Telemetry and Heatmaps]]. Previous: [[3. Path Sampling and Death Events]]. Next: [[5. Worked Example Classroom Playtest Loop]]. > [!warning] Code not compiled > Written against the 6000.5 Scripting API for `SceneView.duringSceneGui`, `Handles.DrawSolidRectangleWithOutline` and `Handles.DrawAAPolyLine`, and the UI Toolkit `EditorWindow.CreateGUI` pattern from [[Editor Windows with UI Toolkit]]. Not compiled or run. The Editor is the analysis app. Three scripts, all in an `Editor` folder (or an Editor-only asmdef): 1. `TelemetryCsvReader` - parse CSV rows back into `TelemetryEvent`s. 2. `HeatmapGrid` - a uniform XZ grid (the **Spatial Partition** pattern) with an optional blur kernel. 3. `TelemetryHeatmapWindow` - UI Toolkit controls plus a `SceneView.duringSceneGui` callback that draws. ## 4.1 CSV reader The reader handles quoted fields and skips malformed rows with a warning instead of throwing, because student builds will produce a few odd lines (a crash mid-write, an old schema). ```csharp using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityEngine; namespace Playtest.Telemetry.EditorTools { /// <summary> /// Reads session_*.csv files written by PlaytestTelemetry. Editor only. Not compiled in an editor. /// </summary> public static class TelemetryCsvReader { public static List<TelemetryEvent> ReadFolder(string folder, List<string> warnings) { var all = new List<TelemetryEvent>(); if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) { warnings?.Add("Folder not found: " + folder); return all; } // Recursive, so a folder of per-headset subfolders (part 5) loads in one go. foreach (string file in Directory.GetFiles(folder, "session_*.csv", SearchOption.AllDirectories)) { all.AddRange(ReadFile(file, warnings)); } return all; } public static List<TelemetryEvent> ReadFile(string path, List<string> warnings) { var result = new List<TelemetryEvent>(); string[] lines; try { lines = File.ReadAllLines(path, Encoding.UTF8); } catch (IOException ex) { warnings?.Add(Path.GetFileName(path) + ": " + ex.Message); return result; } for (int i = 1; i < lines.Length; i++) // line 0 is the header { string line = lines[i]; if (string.IsNullOrWhiteSpace(line)) continue; List<string> c = SplitCsvLine(line); if (c.Count < 9) { warnings?.Add(Path.GetFileName(path) + " line " + (i + 1) + ": expected 9 columns, got " + c.Count); continue; } if (!TryFloat(c[1], out float time) || !TryFloat(c[4], out float x) || !TryFloat(c[5], out float y) || !TryFloat(c[6], out float z)) { warnings?.Add(Path.GetFileName(path) + " line " + (i + 1) + ": bad number"); continue; } TryFloat(c[8], out float value); result.Add(new TelemetryEvent { sessionId = c[0], time = time, scene = c[2], type = c[3], position = new Vector3(x, y, z), label = c[7], value = value }); } return result; } static bool TryFloat(string s, out float f) { return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out f); } /// <summary>RFC-4180-style split: commas inside quotes are kept, "" is an escaped quote.</summary> public static List<string> SplitCsvLine(string line) { var fields = new List<string>(9); var sb = new StringBuilder(); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { char ch = line[i]; if (inQuotes) { if (ch == '"') { if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; } else { inQuotes = false; } } else { sb.Append(ch); } } else if (ch == '"') { inQuotes = true; } else if (ch == ',') { fields.Add(sb.ToString()); sb.Clear(); } else { sb.Append(ch); } } fields.Add(sb.ToString()); return fields; } } } ``` ## 4.2 Heatmap grid Each event adds weight to its cell, and optionally to neighbours with a linear falloff (`blurRadius` in cells). Blur matters: 20 deaths spread across four adjacent 1 m cells look like four cold spots without it, but they are one problem. The grid also tracks a weighted average height per cell, so a multi-floor level draws heat on the floor where the events happened rather than at y = 0. ```csharp using System.Collections.Generic; using UnityEngine; namespace Playtest.Telemetry.EditorTools { /// <summary> /// Uniform XZ grid of weights. Editor only. Not compiled in an editor. /// </summary> public class HeatmapGrid { public struct Cell { public float weight; public float heightSum; public float heightWeight; public int rawCount; public float AverageHeight => heightWeight > 0f ? heightSum / heightWeight : 0f; } readonly Dictionary<Vector2Int, Cell> cells = new(); public float CellSize { get; } public float MaxWeight { get; private set; } public int CellCount => cells.Count; public IReadOnlyDictionary<Vector2Int, Cell> Cells => cells; public HeatmapGrid(float cellSize) { CellSize = Mathf.Max(0.05f, cellSize); } public Vector2Int ToCell(Vector3 world) { return new Vector2Int(Mathf.FloorToInt(world.x / CellSize), Mathf.FloorToInt(world.z / CellSize)); } public void Add(Vector3 world, int blurRadius) { Vector2Int centre = ToCell(world); int r = Mathf.Max(0, blurRadius); for (int dx = -r; dx <= r; dx++) { for (int dz = -r; dz <= r; dz++) { float distance = Mathf.Sqrt(dx * dx + dz * dz); if (distance > r + 0.01f) continue; float w = r == 0 ? 1f : 1f - distance / (r + 1f); Accumulate(new Vector2Int(centre.x + dx, centre.y + dz), w, world.y, dx == 0 && dz == 0); } } } void Accumulate(Vector2Int key, float w, float height, bool isCentre) { cells.TryGetValue(key, out Cell cell); cell.weight += w; cell.heightSum += height * w; cell.heightWeight += w; if (isCentre) cell.rawCount++; cells[key] = cell; if (cell.weight > MaxWeight) MaxWeight = cell.weight; } /// <summary>Four corners of a cell at the given height, in order, for Handles.DrawSolidRectangleWithOutline.</summary> public void GetQuad(Vector2Int key, float height, Vector3[] corners) { float x0 = key.x * CellSize; float z0 = key.y * CellSize; float x1 = x0 + CellSize; float z1 = z0 + CellSize; corners[0] = new Vector3(x0, height, z0); corners[1] = new Vector3(x0, height, z1); corners[2] = new Vector3(x1, height, z1); corners[3] = new Vector3(x1, height, z0); } public Vector3 CellCentre(Vector2Int key, float height) { return new Vector3((key.x + 0.5f) * CellSize, height, (key.y + 0.5f) * CellSize); } } } ``` ## 4.3 The window What it does: - **Folder**: defaults to `Application.persistentDataPath/Telemetry` (where Play mode and Windows builds write); **Browse** to a folder of CSVs pulled from Quest headsets. - **Filters**: event type (`death`, `path`, `idle`, `teleport`...), cause/label contains, build label contains (from `session_start`), only the active scene. - **Draw**: heat cells with a gradient, the top five cells labelled with their raw counts, and each session's path as a polyline (broken at deaths and teleports). - **Export** the visible grid as `heatmap_<type>.csv` for a spreadsheet chart. ```csharp using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.SceneManagement; using UnityEngine.UIElements; namespace Playtest.Telemetry.EditorTools { /// <summary> /// Tools > Playtest > Telemetry Heatmap. Draws telemetry in the Scene view. /// Not compiled in an editor. /// </summary> public class TelemetryHeatmapWindow : EditorWindow { [MenuItem("Tools/Playtest/Telemetry Heatmap")] static void Open() { GetWindow<TelemetryHeatmapWindow>("Telemetry Heatmap"); } // Serialized so settings survive domain reloads. [SerializeField] string folder; [SerializeField] string eventType = TelemetryTypes.Death; [SerializeField] string labelContains = ""; [SerializeField] string buildContains = ""; [SerializeField] bool onlyActiveScene = true; [SerializeField] float cellSize = 1f; [SerializeField] int blurRadius = 1; [SerializeField] bool showHeat = true; [SerializeField] bool showPaths = true; [SerializeField] bool xRay = false; [SerializeField] float heightOffset = 0.05f; [SerializeField, Range(0.1f, 1f)] float maxAlpha = 0.7f; readonly List<TelemetryEvent> allEvents = new(); readonly List<string> warnings = new(); readonly Dictionary<string, string> buildBySession = new(); readonly List<Vector3[]> pathSegments = new(); readonly List<Vector2Int> topCells = new(); readonly Vector3[] quad = new Vector3[4]; HeatmapGrid grid; Gradient gradient; Label status; int sessionsShown; int eventsShown; void OnEnable() { if (string.IsNullOrEmpty(folder)) { folder = Path.Combine(Application.persistentDataPath, "Telemetry"); } gradient = BuildGradient(); SceneView.duringSceneGui += OnSceneGui; } void OnDisable() { SceneView.duringSceneGui -= OnSceneGui; } void CreateGUI() { VisualElement root = rootVisualElement; root.style.paddingLeft = 6; root.style.paddingRight = 6; root.style.paddingTop = 6; var folderField = new TextField("CSV folder") { value = folder }; folderField.RegisterValueChangedCallback(e => folder = e.newValue); root.Add(folderField); var folderRow = new VisualElement(); folderRow.style.flexDirection = FlexDirection.Row; folderRow.Add(new Button(() => { string picked = EditorUtility.OpenFolderPanel("Telemetry folder", folder, ""); if (!string.IsNullOrEmpty(picked)) { folder = picked; folderField.value = picked; } }) { text = "Browse..." }); folderRow.Add(new Button(() => { if (Directory.Exists(folder)) EditorUtility.RevealInFinder(folder); }) { text = "Reveal" }); folderRow.Add(new Button(Load) { text = "Load / Reload" }); root.Add(folderRow); root.Add(MakeTextField("Event type", eventType, v => eventType = v)); root.Add(MakeTextField("Label contains", labelContains, v => labelContains = v)); root.Add(MakeTextField("Build label contains", buildContains, v => buildContains = v)); root.Add(MakeToggle("Only active scene", onlyActiveScene, v => onlyActiveScene = v)); var cellField = new FloatField("Cell size (m)") { value = cellSize }; cellField.RegisterValueChangedCallback(e => { cellSize = Mathf.Max(0.1f, e.newValue); Rebuild(); }); root.Add(cellField); var blurField = new SliderInt("Blur radius (cells)", 0, 4) { value = blurRadius, showInputField = true }; blurField.RegisterValueChangedCallback(e => { blurRadius = e.newValue; Rebuild(); }); root.Add(blurField); var alphaField = new Slider("Max alpha", 0.1f, 1f) { value = maxAlpha, showInputField = true }; alphaField.RegisterValueChangedCallback(e => { maxAlpha = e.newValue; SceneView.RepaintAll(); }); root.Add(alphaField); root.Add(MakeToggle("Show heat", showHeat, v => showHeat = v)); root.Add(MakeToggle("Show paths", showPaths, v => showPaths = v)); root.Add(MakeToggle("X-ray (draw through walls)", xRay, v => xRay = v)); root.Add(new Button(ExportGrid) { text = "Export grid CSV" }); status = new Label("No data loaded. Press Load."); status.style.whiteSpace = WhiteSpace.Normal; status.style.marginTop = 6; root.Add(status); } TextField MakeTextField(string label, string initial, System.Action<string> set) { var field = new TextField(label) { value = initial, isDelayed = true }; field.RegisterValueChangedCallback(e => { set(e.newValue); Rebuild(); }); return field; } Toggle MakeToggle(string label, bool initial, System.Action<bool> set) { var toggle = new Toggle(label) { value = initial }; toggle.RegisterValueChangedCallback(e => { set(e.newValue); Rebuild(); }); return toggle; } void Load() { warnings.Clear(); allEvents.Clear(); allEvents.AddRange(TelemetryCsvReader.ReadFolder(folder, warnings)); buildBySession.Clear(); foreach (TelemetryEvent e in allEvents) { if (e.type == TelemetryTypes.SessionStart && !buildBySession.ContainsKey(e.sessionId)) { buildBySession[e.sessionId] = e.label ?? ""; } } Rebuild(); } bool PassesCommonFilters(TelemetryEvent e, string activeScene) { if (onlyActiveScene && e.scene != activeScene) return false; if (!string.IsNullOrEmpty(buildContains)) { buildBySession.TryGetValue(e.sessionId, out string build); if (build == null || !build.Contains(buildContains)) return false; } return true; } void Rebuild() { string activeScene = SceneManager.GetActiveScene().name; grid = new HeatmapGrid(cellSize); pathSegments.Clear(); topCells.Clear(); eventsShown = 0; var sessions = new HashSet<string>(); foreach (TelemetryEvent e in allEvents) { if (e.type != eventType) continue; if (!PassesCommonFilters(e, activeScene)) continue; if (!string.IsNullOrEmpty(labelContains) && (e.label == null || !e.label.Contains(labelContains))) continue; grid.Add(e.position, blurRadius); sessions.Add(e.sessionId); eventsShown++; } BuildPaths(activeScene); topCells.AddRange(grid.Cells .OrderByDescending(kv => kv.Value.rawCount) .Take(5) .Where(kv => kv.Value.rawCount > 0) .Select(kv => kv.Key)); sessionsShown = sessions.Count; UpdateStatus(); SceneView.RepaintAll(); } void BuildPaths(string activeScene) { // Group path rows by session, in time order, and break at deaths, teleports and scene changes. foreach (IGrouping<string, TelemetryEvent> session in allEvents .Where(ev => PassesCommonFilters(ev, activeScene)) .GroupBy(ev => ev.sessionId)) { var current = new List<Vector3>(); string currentScene = null; foreach (TelemetryEvent e in session.OrderBy(ev => ev.time)) { bool breaks = e.type == TelemetryTypes.Death || e.type == TelemetryTypes.Teleport || e.scene != currentScene; if (breaks) { if (current.Count > 1) pathSegments.Add(current.ToArray()); current.Clear(); currentScene = e.scene; } if (e.type == TelemetryTypes.Path) { current.Add(e.position + Vector3.up * heightOffset); } } if (current.Count > 1) pathSegments.Add(current.ToArray()); } } void UpdateStatus() { if (status == null) return; var sb = new StringBuilder(); sb.Append(allEvents.Count).Append(" rows loaded from ").Append(buildBySession.Count).Append(" sessions. "); sb.Append("Showing ").Append(eventsShown).Append(" '").Append(eventType).Append("' events from ") .Append(sessionsShown).Append(" sessions in ").Append(grid != null ? grid.CellCount : 0).Append(" cells; ") .Append(pathSegments.Count).Append(" path segments."); if (warnings.Count > 0) { sb.Append("\n").Append(warnings.Count).Append(" warnings, first: ").Append(warnings[0]); } status.text = sb.ToString(); } void OnSceneGui(SceneView view) { if (Event.current.type != EventType.Repaint) return; if (grid == null) return; CompareFunction previousZTest = Handles.zTest; Handles.zTest = xRay ? CompareFunction.Always : CompareFunction.LessEqual; if (showHeat && grid.MaxWeight > 0f) { foreach (KeyValuePair<Vector2Int, HeatmapGrid.Cell> kv in grid.Cells) { float t = kv.Value.weight / grid.MaxWeight; Color c = gradient.Evaluate(t); c.a = Mathf.Lerp(0.15f, maxAlpha, t); grid.GetQuad(kv.Key, kv.Value.AverageHeight + heightOffset, quad); Handles.DrawSolidRectangleWithOutline(quad, c, Color.clear); } foreach (Vector2Int key in topCells) { HeatmapGrid.Cell cell = grid.Cells[key]; Vector3 p = grid.CellCentre(key, cell.AverageHeight + heightOffset + 0.5f); Handles.Label(p, cell.rawCount.ToString(CultureInfo.InvariantCulture) + " " + eventType); } } if (showPaths) { Handles.color = new Color(0.2f, 0.8f, 1f, 0.6f); foreach (Vector3[] segment in pathSegments) { Handles.DrawAAPolyLine(3f, segment); } } Handles.zTest = previousZTest; } void ExportGrid() { if (grid == null || grid.CellCount == 0) { EditorUtility.DisplayDialog("Telemetry Heatmap", "Nothing to export. Load data first.", "OK"); return; } CultureInfo ci = CultureInfo.InvariantCulture; var sb = new StringBuilder("cellX,cellZ,worldX,worldZ,height,weight,rawCount\n"); foreach (KeyValuePair<Vector2Int, HeatmapGrid.Cell> kv in grid.Cells.OrderByDescending(k => k.Value.weight)) { Vector3 centre = grid.CellCentre(kv.Key, kv.Value.AverageHeight); sb.Append(kv.Key.x).Append(',').Append(kv.Key.y).Append(',') .Append(centre.x.ToString("F2", ci)).Append(',') .Append(centre.z.ToString("F2", ci)).Append(',') .Append(centre.y.ToString("F2", ci)).Append(',') .Append(kv.Value.weight.ToString("F3", ci)).Append(',') .Append(kv.Value.rawCount).Append('\n'); } string path = Path.Combine(folder, "heatmap_" + eventType + ".csv"); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); EditorUtility.RevealInFinder(path); } static Gradient BuildGradient() { var g = new Gradient(); g.SetKeys( new[] { new GradientColorKey(new Color(0.1f, 0.2f, 1f), 0f), new GradientColorKey(new Color(0.1f, 0.9f, 0.3f), 0.35f), new GradientColorKey(new Color(1f, 0.9f, 0.1f), 0.65f), new GradientColorKey(new Color(1f, 0.15f, 0.1f), 1f) }, new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }); return g; } } } ``` > [!note] Why the file pattern is `session_*.csv` > `ExportGrid` writes `heatmap_<type>.csv` into the same folder. The reader only picks up `session_*.csv`, so exports never get re-read as telemetry. ## 4.4 Step by step 1. Put the three scripts in an `Editor` folder (or the Editor asmdef from part 2). Wait for the compile. 2. **Tools > Playtest > Telemetry Heatmap**. Dock it next to the Inspector. 3. Open the scene the telemetry came from (the window filters by the **active scene** name by default). 4. Press **Load / Reload**. The status line shows rows, sessions and cells. 5. Set **Event type** to `death`. Heat cells appear in the Scene view. The five hottest cells show their raw counts. 6. Change **Event type** to `idle`, then `path`. A `path` heatmap is a "where do people walk" map; compare it with where you *wanted* them to walk. 7. Toggle **X-ray** when a heat cell is hidden under a ceiling or inside a mesh. 8. **Export grid CSV** for a chart in a spreadsheet, or to attach to a playtest report. > [!tip] Cell size is a design choice > Use roughly the player's footprint for deaths (0.5 to 1 m), larger cells (2 to 4 m) for idle and path in big outdoor spaces ([[Terrain]]). Blur radius 1 is a good default; 0 when you want to check one exact hazard tile. ## 4.5 Extending the window - Add a **ListView** of sessions with checkboxes, using the patterns in [[4. Lists Selection and Refresh]]. - Draw `checkpoint` rows as labelled discs (`Handles.DrawWireDisc`) and show average time between checkpoints. - Replace the Scene view quads with a baked `Texture2D` projected by a decal for a screenshot-friendly overlay. The Handles version is fine for analysis and costs nothing in builds. Next: [[5. Worked Example Classroom Playtest Loop]]